initial: bootstrap from BukidBountyApp base
This commit is contained in:
538
resources/js/app.js
Normal file
538
resources/js/app.js
Normal file
@@ -0,0 +1,538 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user