Files
BarangaySystem/resources/js/app.js
2026-06-06 18:43:00 +08:00

539 lines
18 KiB
JavaScript

import { createApp, h, defineAsyncComponent, ref, watch } from 'vue';
import { createPinia } from 'pinia';
import axios from 'axios';
import { handleSessionExpired } from './composables/Core/useSessionGuard.js';
import TopHeader from './Pages/Core/Fragments/TopHeader.vue';
import BottomNav from './Pages/Core/Fragments/BottomNav.vue';
import { deepEqual } from './utils/deepEqual';
import { useModal } from './composables/Core/useModal.js';
import { useNavigate, setPageModules } from './composables/Core/useNavigate.js';
import BaseModal from './Components/Core/BaseModal.vue';
import { useNetworkStore } from './stores/network.js';
import { useUserStore } from './stores/user.js';
import { useUIStore } from './stores/ui.js';
import { useAuth } from './composables/Core/useAuth.js';
import { useSessionGuard, startGuard } from './composables/Core/useSessionGuard.js';
import LottiePlayer from './Components/Core/Animations/LottiePlayer.vue';
import AnimatedButton from './Components/Core/Animations/AnimatedButton.vue';
import RouteTransition from './Components/Core/Animations/RouteTransition.vue';
// --- Chunk load error handler (stale hash after rebuild) ---
function handleChunkError(error) {
const isChunkError =
error?.message?.includes('Failed to fetch dynamically imported module') ||
error?.message?.includes('Importing a module script failed') ||
error?.message?.includes('error loading dynamically imported module') ||
error?.message?.includes('Loading chunk') ||
error?.message?.includes('Loading CSS chunk');
if (isChunkError) {
// Guard against infinite reload loops — only retry once per URL
const reloadKey = 'chunk-reload:' + window.location.pathname;
if (!sessionStorage.getItem(reloadKey)) {
sessionStorage.setItem(reloadKey, '1');
console.warn('[ChunkReload] Stale chunk detected, reloading page…');
window.location.reload();
} else {
// Already retried once — clear the flag for next time and log
sessionStorage.removeItem(reloadKey);
console.error('[ChunkReload] Reload already attempted, chunk still failing:', error);
}
}
}
// Safety-net: catch any unhandled dynamic import rejections globally
window.addEventListener('unhandledrejection', (event) => {
handleChunkError(event.reason);
});
// Axios request interceptor: ensure POST requests always have at least an empty object as data.
// This prevents 400 Bad Request errors on some production environments (Nginx/WAF) that reject empty POST bodies.
axios.interceptors.request.use(config => {
if (config.method?.toLowerCase() === 'post' && (config.data === undefined || config.data === null)) {
config.data = {};
}
return config;
});
// Axios interceptor: catch 401/419 from any API call and trigger logout.
// Acts as a last-resort fallback when SSE and polling have both failed.
// Skips guest-probe endpoints that legitimately 401 for unauthenticated visitors,
// and only redirects when we previously had an authenticated session — otherwise
// a guest hitting "/" would be bounced to /login the moment any auth-required
// probe returns 401.
const SESSION_GUARD_SKIP = [
'/post/loginnow',
'/get/isloggedin',
'/sse/stream',
'/account_settings/details',
'/get/user/acct-type',
];
axios.interceptors.response.use(
response => response,
error => {
const status = error?.response?.status;
const url = error?.config?.url ?? '';
const onLoginPage = window.location.pathname === '/login' || window.location.pathname === '/sp/login';
const acctType = sessionStorage.getItem('user_acct_type');
const hadAuthenticatedSession = acctType && acctType !== 'public';
if (
(status === 401 || status === 419) &&
!onLoginPage &&
hadAuthenticatedSession &&
!SESSION_GUARD_SKIP.some(s => url.includes(s))
) {
handleSessionExpired();
}
return Promise.reject(error);
}
);
const modal = useModal()
// --- Auto-import all pages (exclude Fragments) ---
const modules = import.meta.glob('./Pages/**/*.vue');
const pages = {};
for (const path in modules) {
// Exclude global layout/core fragments that aren't standalone pages
if (path.includes('Core/Fragments')) continue;
const key = path
.replace('./Pages/', '')
.replace('.vue', '')
.replace(/\//g, '.');
pages[key] = modules[path];
// Also add a simplified lowercase version for easier server-side matching
const simplifiedKey = key.toLowerCase().replace(/\./g, '/');
pages[simplifiedKey] = modules[path];
}
// Ensure fallback NotFound
if (!pages['Core.NotFound']) {
pages['Core.NotFound'] = modules['./Pages/Core/NotFound.vue'];
}
// Wire up page modules for prefetching (hover-based eager loading)
setPageModules(pages);
// --- Get initial page from server ---
const el = document.getElementById('main-body');
const initialPage = JSON.parse(el.dataset.page);
const app = createApp({
setup() {
const currentPage = ref(initialPage.component);
const currentProps = ref(initialPage.props || {});
const loading = ref(false);
// UI Store for global state
const uiStore = useUIStore();
// Start polling /get/isloggedin — auto-logout on session expiry
useSessionGuard();
const { navigate, reloadPage } = useNavigate({ currentPage, currentProps, loading })
// Initialize User Store and fetch user data on app load
const userStore = useUserStore();
userStore.fetchCurrentUser().then(() => {
if (userStore.user?.settings) {
uiStore.syncSettings(userStore.user.settings);
}
// Re-attempt session guard once acctType is known. The initial call in
// setup() bails for guests (no acctType yet); after fetchCurrentUser
// resolves with a real authenticated user we kick it off here.
startGuard();
});
// Ensure the initial page is in the history state so that back/forward works correctly from the start
if (typeof window !== 'undefined' && !window.history.state) {
window.history.replaceState({ page: initialPage.component, props: initialPage.props }, '', window.location.href);
}
// Fetch public system settings (branding, disabled pages, etc.)
// First sync from initial props to avoid flutters
if (initialPage.props?.systemSettings) {
uiStore.syncSettings(initialPage.props.systemSettings);
}
uiStore.refreshSettings();
// Start background interval to refresh system settings every 1 minute
setInterval(() => {
uiStore.refreshSettings();
}, 60000);
// Provide global access to user details
app.config.globalProperties.$user = userStore;
app.config.globalProperties.$auth = useAuth();
// --- AsyncComponent based on currentPage
const AsyncComponent = ref(
defineAsyncComponent({
loader: () => (pages[currentPage.value] || pages['Core.NotFound'])(),
onError: (error, retry, fail) => {
handleChunkError(error);
fail(); // let Vue handle the error if reload didn't trigger
},
})
);
// Watch page changes and reload async component
watch(currentPage, (newPage) => {
// Close any open modals on page change
modal.close();
// Reset titles on page change (components can override this in onMounted)
uiStore.resetPageTitle();
const mainContent = document.querySelector('.main-content');
if (mainContent) mainContent.scrollTop = 0;
AsyncComponent.value = defineAsyncComponent({
loader: () => (pages[newPage] || pages['Core.NotFound'])(),
onError: (error, retry, fail) => {
handleChunkError(error);
fail();
},
});
});
// Watch for logo changes and update favicon/apple-touch-icon DOM elements
watch(() => uiStore.appLogo, (logoUrl) => {
if (!logoUrl) return;
document.querySelectorAll('link[rel="shortcut icon"], link[rel="icon"], link[rel="apple-touch-icon"], link[rel="apple-touch-icon-precomposed"]').forEach(link => {
link.href = logoUrl;
});
}, { immediate: true });
// Watch for dark mode and update body class
watch(() => uiStore.darkMode, (isDark) => {
if (isDark) {
document.body.classList.add('dark-mode');
} else {
document.body.classList.remove('dark-mode');
}
}, { immediate: true });
// Watch for full-width mode and update body class
watch(() => uiStore.isFullWidth, (isFull) => {
if (isFull) {
document.body.classList.add('is-full-width');
} else {
document.body.classList.remove('is-full-width');
}
}, { immediate: true });
return { currentPage, currentProps, loading, navigate, AsyncComponent, uiStore };
},
render() {
return h('div', { class: ['app-layout', { 'is-full-width': this.uiStore.isFullWidth }] }, [
this.uiStore.showHeader ? h(TopHeader) : null,
h('main', { class: ['main-content', { 'no-header': !this.uiStore.showHeader, 'no-nav': !this.uiStore.showBottomNav }] }, [
h(RouteTransition, { routeKey: this.currentPage }, {
default: () => h(this.AsyncComponent, this.currentProps)
})
]),
this.uiStore.showBottomNav ? h(BottomNav) : null,
h(
BaseModal,
{
modelValue: modal.show.value,
'onUpdate:modelValue': v => (modal.show.value = v),
modalTitle: modal.title.value,
body: modal.body.value,
footerClose: !modal.footer.value,
},
modal.footer.value
? { footer: () => h(modal.footer.value) }
: {}
),
]);
},
});
// Add global styles for the layout to ensure no overlap
const style = document.createElement('style');
style.textContent = `
:root {
--bg-primary: #ffffff;
--bg-secondary: #f8f9fa;
--bg-tertiary: #f0f2f5;
--bg-card: #ffffff;
--text-primary: #1e1e1e;
--text-secondary: #717171;
--text-muted: #a0a0a0;
--border-color: rgba(0, 0, 0, 0.08);
--accent-color: #533dea;
--accent-soft: rgba(83, 61, 234, 0.1);
--header-bg: rgba(255, 255, 255, 0.85);
--nav-bg: rgba(255, 255, 255, 0.85);
--bg-html: #fcebeb;
--icon-filter: none;
--layout-max-width: 1440px;
}
body.dark-mode {
--bg-primary: #121418;
--bg-secondary: #1a1c22;
--bg-tertiary: #24272d;
--bg-card: #1f2228;
--text-primary: #e0e0e0;
--text-secondary: #b0b0b0;
--text-muted: #888888;
--border-color: rgba(255, 255, 255, 0.08);
--accent-color: #816ef0;
--accent-soft: rgba(129, 110, 240, 0.15);
--header-bg: rgba(18, 20, 24, 0.9);
--nav-bg: rgba(18, 20, 24, 0.95);
--bg-html: #1A0D0D;
--icon-filter: brightness(0) invert(1);
}
html {
background-color: var(--bg-html);
transition: background-color 0.3s ease;
}
.app-layout {
display: flex;
flex-direction: column;
min-height: 100vh;
background-color: var(--bg-primary);
color: var(--text-primary);
transition: background-color 0.3s ease, color 0.3s ease;
}
body {
margin: 0 auto !important;
padding: 0;
overflow: hidden; /* Prevent double scrollbars if main-content scrolls */
max-width: var(--layout-max-width, 100%);
width: 100%;
transition: max-width 0.3s ease, background-color 0.3s ease;
background-color: var(--bg-primary);
color: var(--text-primary);
}
body.is-full-width {
max-width: none !important;
}
body.is-full-width .header,
body.is-full-width .bottom-navigation-bar {
max-width: none !important;
}
.app-layout.is-full-width .tf-container {
max-width: none !important;
width: 100% !important;
padding: 0 !important;
}
.main-content {
flex: 1;
padding-top: 66px; /* Height of TopHeader (56px) + safe margin */
padding-bottom: 80px; /* Height of BottomNav + some breathing room */
overflow-y: auto;
background-color: var(--bg-primary);
transition: background-color 0.3s ease, padding 0.3s ease;
}
.main-content.no-header {
padding-top: 0 !important;
}
.main-content.no-nav {
padding-bottom: 0 !important;
}
/* Global theme consistency overrides */
.dark-mode .tf-container,
.dark-mode .manage-stores-page,
.dark-mode .home-page,
.dark-mode .account-settings-page,
.dark-mode .card-section,
.dark-mode .store-details-page,
.dark-mode .pos-container,
.dark-mode .box-user {
background-color: transparent !important;
background: transparent !important;
color: var(--text-primary) !important;
}
.dark-mode h1, .dark-mode h2, .dark-mode h3, .dark-mode h4, .dark-mode h5, .dark-mode h6,
.dark-mode span, .dark-mode p, .dark-mode label, .dark-mode i, .dark-mode div:not(.icon-box):not(.btn) {
color: var(--text-primary) !important;
}
.dark-mode .text-muted, .dark-mode .text-secondary, .dark-mode .smallest {
color: var(--text-secondary) !important;
}
.dark-mode input, .dark-mode select, .dark-mode textarea, .dark-mode .search-field {
background-color: var(--bg-secondary) !important;
color: var(--text-primary) !important;
border-color: var(--border-color) !important;
}
.dark-mode .card, .dark-mode .list-user-info,
.dark-mode .store-table-container,
.dark-mode .manage-stores-page .store-table-container,
.dark-mode .pos-container .products-side, .dark-mode .pos-container .cart-side,
.dark-mode .pos-container .product-card, .dark-mode .profile-card,
.dark-mode .suggestions-dropdown, .dark-mode .scanner-container {
background-color: var(--bg-card) !important;
color: var(--text-primary) !important;
border-color: var(--border-color) !important;
}
.dark-mode .table tbody tr, .dark-mode .table td {
background-color: transparent !important;
color: var(--text-primary) !important;
border-color: var(--border-color) !important;
}
.dark-mode .table tr.active, .dark-mode .table tr:hover {
background-color: var(--bg-tertiary) !important;
}
.dark-mode .icon-wrapper, .dark-mode .icon-box {
background-color: var(--accent-soft) !important;
border: 1px solid var(--border-color);
}
/* Force standard border color for all themed elements */
.dark-mode div, .dark-mode section, .dark-mode ul, .dark-mode li, .dark-mode header, .dark-mode footer, .dark-mode nav {
border-color: var(--border-color) !important;
}
.dark-mode .table thead th {
background-color: var(--bg-tertiary) !important;
color: var(--accent-color) !important;
border-bottom: 1px solid var(--border-color);
}
.dark-mode .table tbody td {
border-bottom: 1px solid var(--border-color);
color: var(--text-primary) !important;
}
.dark-mode .header, .dark-mode .top-header {
background-color: var(--header-bg) !important;
backdrop-filter: blur(12px) !important;
-webkit-backdrop-filter: blur(12px) !important;
border-bottom-color: var(--border-color) !important;
}
.dark-mode .bottom-navigation-bar {
background-color: var(--nav-bg) !important;
backdrop-filter: blur(12px) !important;
-webkit-backdrop-filter: blur(12px) !important;
border-top-color: var(--border-color) !important;
box-shadow: 0 -4px 30px rgba(0, 0, 0, 0.5) !important;
}
/* Bootstrap class overrides for dark mode — neutralizes hardcoded bg-white / bg-light / text-dark
so components don't need per-file :global(.dark-mode) patches for these specific classes */
.dark-mode .bg-white { background-color: var(--bg-card) !important; }
.dark-mode .bg-light { background-color: var(--bg-secondary) !important; }
.dark-mode .text-dark { color: var(--text-primary) !important; }
.dark-mode .border { border-color: var(--border-color) !important; }
.dark-mode .modal-content { background-color: var(--bg-card) !important; color: var(--text-primary) !important; border-color: var(--border-color) !important; }
.dark-mode .dropdown-menu { background-color: var(--bg-card) !important; border-color: var(--border-color) !important; }
.dark-mode .dropdown-item { color: var(--text-primary) !important; }
.dark-mode .dropdown-item:hover { background-color: var(--bg-tertiary) !important; }
.dark-mode .list-group-item { background-color: var(--bg-card) !important; color: var(--text-primary) !important; border-color: var(--border-color) !important; }
.dark-mode .modal-header, .dark-mode .modal-footer { border-color: var(--border-color) !important; }
/* Glassmorphism utility */
.glass-card {
background: rgba(255, 255, 255, 0.4) !important;
backdrop-filter: blur(10px) !important;
-webkit-backdrop-filter: blur(10px) !important;
border: 1px solid rgba(0, 0, 0, 0.05) !important;
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.07) !important;
}
.dark-mode .glass-card {
background: rgba(31, 34, 40, 0.7) !important;
backdrop-filter: blur(10px) !important;
-webkit-backdrop-filter: blur(10px) !important;
border: 1px solid rgba(255, 255, 255, 0.08) !important;
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3) !important;
}
/* Theme-aware icons */
.icon-box img, .icon-wrapper img, .nav-icon, .icon-user {
filter: var(--icon-filter);
transition: filter 0.3s ease;
}
`;
document.head.appendChild(style);
app.config.globalProperties.$modal = modal;
// Initialize Pinia
const pinia = createPinia();
app.use(pinia);
// --- Global navigate helper
let rootInstance = null;
app.config.globalProperties.$navigate = payload => {
if (rootInstance) {
rootInstance.navigate(payload);
} else {
console.warn('App not yet mounted, $navigate called too early.');
}
};
const network = useNetworkStore()
// app.use(network) // Removed: Pinia stores are not Vue plugins. Pinia (the instance) is already registered above.
// Provide global access to user details - moved inside setup function
window.addEventListener('online', () => {
network.setOnline(true)
})
window.addEventListener('offline', () => {
network.setOnline(false)
})
// Register components
app.component('TopHeader', TopHeader);
app.component('BottomNav', BottomNav);
app.component('LottiePlayer', LottiePlayer);
app.component('AnimatedButton', AnimatedButton);
rootInstance = app.mount('#app');
window.$navigateHelper = app.config.globalProperties.$navigate;
// Expose prefetch helper globally for hover-based chunk preloading
window.$prefetchPage = (pageName) => {
if (!pageName) return;
const loader = pages[pageName];
if (loader) loader().catch(() => {});
};
// Register Service Worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('ServiceWorker registration successful with scope: ', registration.scope);
})
.catch(error => {
console.log('ServiceWorker registration failed: ', error);
});
});
}