539 lines
18 KiB
JavaScript
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);
|
|
});
|
|
});
|
|
}
|