228 lines
8.0 KiB
JavaScript
228 lines
8.0 KiB
JavaScript
// resources/js/composables/Core/useNavigate.js
|
|
import { getCurrentInstance, h } from 'vue'
|
|
import { encodeHash, encodePayload } from '../useUrlEncoder'
|
|
import { useUIStore } from '../../stores/ui'
|
|
import { useModal } from './useModal'
|
|
|
|
// Page module map — populated once from app.js for prefetching
|
|
let _pageModules = null;
|
|
export function setPageModules(modules) { _pageModules = modules; }
|
|
|
|
// Track which pages have already been prefetched to avoid duplicate calls
|
|
const _prefetchedPages = new Set();
|
|
|
|
export function useNavigate({ currentPage, currentProps, loading } = {}) {
|
|
const instance = getCurrentInstance()
|
|
const uiStore = useUIStore()
|
|
const modal = useModal()
|
|
// Try to get the global navigate helper first from window.$navigateHelper,
|
|
// then fall back to instance?.proxy?.$navigate
|
|
const globalNavigate = (window.$navigateHelper || instance?.proxy?.$navigate)
|
|
|
|
/**
|
|
* Prefetch a page component chunk by name.
|
|
* Call on hover/focus to start loading the JS bundle before navigation.
|
|
*/
|
|
const prefetch = (pageName) => {
|
|
if (!pageName || !_pageModules || _prefetchedPages.has(pageName)) return;
|
|
const loader = _pageModules[pageName];
|
|
if (loader) {
|
|
_prefetchedPages.add(pageName);
|
|
loader().catch(() => { /* chunk may not exist, ignore */ });
|
|
}
|
|
}
|
|
|
|
const navigate = async (arg1, arg2 = {}) => {
|
|
// Universal navigate handler that accepts:
|
|
// 1. navigate({ page: 'Home', props: {} })
|
|
// 2. navigate('Home', { target: '...' })
|
|
let page, props, delay, fromPopState;
|
|
|
|
if (typeof arg1 === 'string') {
|
|
page = arg1;
|
|
props = arg2.props || arg2 || {};
|
|
delay = arg2.delay || 0;
|
|
fromPopState = arg2.fromPopState || false;
|
|
} else {
|
|
({ page, props = {}, delay = 0, fromPopState = false } = arg1);
|
|
}
|
|
|
|
if (!page) {
|
|
console.error('[useNavigate] Navigation failed: No page specified.', { arg1, arg2 });
|
|
return;
|
|
}
|
|
|
|
// Global Page Disabled Check — fire-and-forget refresh if stale (don't block navigation)
|
|
const cacheThreshold = 30 * 1000; // 30 seconds
|
|
const isStale = !uiStore.lastSynced || (new Date() - new Date(uiStore.lastSynced) > cacheThreshold);
|
|
|
|
if (isStale) {
|
|
// Non-blocking: refresh in background, don't await
|
|
uiStore.refreshSettings().catch(e => {
|
|
console.error('[useNavigate] Failed to refresh settings during navigation:', e);
|
|
});
|
|
}
|
|
|
|
const pageLower = page.toLowerCase();
|
|
const isDisabled = uiStore.disabledPages && uiStore.disabledPages.some(p => p.toLowerCase() === pageLower);
|
|
|
|
if (isDisabled) {
|
|
console.warn(`[useNavigate] Page ${page} is globally disabled.`);
|
|
|
|
modal.open({
|
|
title: 'Access Restricted',
|
|
body: h('div', { class: 'text-center p-4' }, [
|
|
h('div', { class: 'icon-shape bg-soft-danger text-danger rounded-circle mx-auto mb-4 p-3 shadow-sm' }, [
|
|
h('i', { class: 'fas fa-lock fa-3x' })
|
|
]),
|
|
h('h4', { class: 'fw-black mb-3 text-dark' }, 'Page Offline'),
|
|
h('p', { class: 'text-muted mb-4 px-3' }, `The requested page (${page}) has been temporarily disabled by the system administrator.`),
|
|
h('button', {
|
|
class: 'btn btn-primary rounded-pill px-5 py-2 fw-bold shadow-primary-sm',
|
|
onClick: () => {
|
|
modal.close();
|
|
if (pageLower !== 'home') navigate({ page: 'Home' });
|
|
}
|
|
}, 'Back to Dashboard')
|
|
]),
|
|
footer: null
|
|
});
|
|
|
|
if (loading) loading.value = false;
|
|
return;
|
|
}
|
|
|
|
// If local refs are provided (root app usage), update them directly
|
|
if (currentPage || currentProps || loading) {
|
|
if (loading) loading.value = true
|
|
|
|
if (delay) await new Promise(r => setTimeout(r, delay))
|
|
|
|
if (currentPage) currentPage.value = page
|
|
if (currentProps) currentProps.value = props
|
|
|
|
// Update browser URL (only if NOT coming from a popstate event)
|
|
if (!fromPopState) {
|
|
let url;
|
|
if (page === 'Home') {
|
|
url = '/';
|
|
} else if (page === 'Auth.Login') {
|
|
url = '/login';
|
|
} else {
|
|
// Check for special props that should be encoded into URL
|
|
const hashkeyProp = props?.target || props?.target_user || props?.hashkey || props?.id;
|
|
const payloadProp = props?.payload;
|
|
|
|
let basePageUrl = `/${page
|
|
.replace(/\./g, '/')
|
|
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
.toLowerCase()}`;
|
|
|
|
if (hashkeyProp) {
|
|
// Encode the hashkey into URL as --h:ENCODED
|
|
const encodedHash = encodeHash(hashkeyProp);
|
|
url = `${basePageUrl}--${encodedHash}`;
|
|
} else if (payloadProp) {
|
|
// Encode the payload into URL as --e:ENCODED_PAYLOAD
|
|
const encodedPayload = encodePayload(payloadProp);
|
|
url = `${basePageUrl}--${encodedPayload}`;
|
|
} else {
|
|
url = basePageUrl;
|
|
}
|
|
}
|
|
window.history.pushState({ page, props }, '', url);
|
|
}
|
|
|
|
if (loading) loading.value = false
|
|
return
|
|
}
|
|
|
|
// In child components, fall back to the global $navigate helper
|
|
if (globalNavigate) {
|
|
globalNavigate({ page, props, delay, fromPopState })
|
|
} else {
|
|
console.warn('[useNavigate] No local refs and global navigate helper not found.')
|
|
}
|
|
}
|
|
|
|
const reloadPage = () => {
|
|
if (currentPage) {
|
|
navigate({ page: currentPage.value, props: currentProps?.value, delay: 0 })
|
|
} else if (globalNavigate) {
|
|
// In children, we can't easily get 'currentPage' unless we pass it or use a store
|
|
// but usually reloadPage is used in the context that knows the current state.
|
|
console.warn('[useNavigate] reloadPage called in child without refs.')
|
|
}
|
|
}
|
|
|
|
return { navigate, reloadPage, prefetch }
|
|
}
|
|
|
|
/**
|
|
* Helper to derive component name from a URL path in a generic way.
|
|
* Matches the logic in VueRouteMap.php.
|
|
*/
|
|
const getPageFromUrl = (path) => {
|
|
let cleanPath = path.trim().replace(/^\/+|\/+$/g, '');
|
|
if (!cleanPath) return 'Home';
|
|
|
|
// Handle explicit common mappings
|
|
if (cleanPath === 'login') return 'Auth.Login';
|
|
if (cleanPath === 'app') return 'Home';
|
|
|
|
// Strip hash/payload suffix (e.g., "view-store-market--h:ABC" -> "view-store-market")
|
|
const slug = cleanPath.split('--h:')[0].split('--e:')[0];
|
|
|
|
// Convert kebab-case/path to PascalCase/dots (e.g., "market/list-products" -> "Market.ListProducts")
|
|
return slug.split('/').map(part => {
|
|
return part.split('-').map(subPart => {
|
|
if (!subPart) return '';
|
|
return subPart.charAt(0).toUpperCase() + subPart.slice(1);
|
|
}).join('');
|
|
}).join('.');
|
|
}
|
|
|
|
// Global popstate listener to handle browser back button
|
|
if (typeof window !== 'undefined') {
|
|
window.addEventListener('popstate', (event) => {
|
|
if (event.state && event.state.page) {
|
|
if (window.$navigateHelper) {
|
|
window.$navigateHelper({
|
|
page: event.state.page,
|
|
props: event.state.props,
|
|
fromPopState: true,
|
|
delay: 0
|
|
})
|
|
} else {
|
|
// Fallback if app not yet initialized
|
|
window.location.reload()
|
|
}
|
|
} else {
|
|
// If no state (initial load entry point), attempt to restore from initial DOM payload
|
|
const el = document.getElementById('main-body');
|
|
if (el && el.dataset.page) {
|
|
try {
|
|
const initialPage = JSON.parse(el.dataset.page);
|
|
if (window.$navigateHelper) {
|
|
window.$navigateHelper({
|
|
page: initialPage.component,
|
|
props: initialPage.props,
|
|
fromPopState: true,
|
|
delay: 0
|
|
});
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
console.error('[useNavigate] Failed to parse initial page from DOM:', e);
|
|
}
|
|
}
|
|
|
|
// Ultimate Fallback: Derive the page from the URL (without props, as we can't reliably decode them here)
|
|
const page = getPageFromUrl(window.location.pathname);
|
|
if (window.$navigateHelper) {
|
|
window.$navigateHelper({ page, fromPopState: true, delay: 0 })
|
|
}
|
|
}
|
|
})
|
|
}
|