import { ref } from 'vue' type IsAuthenticatedFn = () => boolean type RouterLike = { push: (to: any) => Promise | unknown } type LocationLike = { fullPath?: string } & Record type ExitEvents = { onConfirmExit?: (payload: { wizardId?: string; fromRoute: LocationLike }) => void onConfirmedReload?: (payload: { wizardId?: string; fromRoute: LocationLike }) => void } // Global singleton state - shared across all components / consumers const globalHasUnsavedChanges = ref(false) const globalShowExitConfirmModal = ref(false) const globalPendingNavigationTarget = ref(null) const globalPendingNavigationCallback = ref<(() => void | Promise) | null>(null) const globalLastRoute = ref(null) let globalRouter: RouterLike | null = null let isAuthenticatedFn: IsAuthenticatedFn | null = null let beforeUnloadHandler: ((event: BeforeUnloadEvent) => void) | null = null let pageHideHandler: ((event: PageTransitionEvent) => void) | null = null let exitEvents: ExitEvents | null = null const SESSION_KEY = 'fds-exit-guard-last' // Export global state for checking from anywhere export const useExitConfirmationGuardState = () => ({ hasUnsavedChanges: globalHasUnsavedChanges, showExitConfirmModal: globalShowExitConfirmModal, setPendingNavigationCallback: (callback: () => void | Promise) => { globalPendingNavigationCallback.value = callback }, }) /** * Clears the unsaved changes state. Useful for programmatic logout flows. */ export const clearUnsavedChanges = () => { globalHasUnsavedChanges.value = false globalShowExitConfirmModal.value = false globalPendingNavigationTarget.value = null globalPendingNavigationCallback.value = null } /** * Helper to extract a wizard identifier from a route. * * A route is considered part of a wizard flow if either: * - `route.meta.wizard.id` is set, or * - any of the matched route records has `meta.wizard.id` set. * * This allows router configs like: * ```ts * { * path: '/wizard/person', * component: WizardLayout, * meta: { wizard: { id: 'person-wizard' } }, * children: [ ...step routes... ] * } * ``` */ export function getExitGuardWizardId(route: any): string | undefined { const meta = route.meta as { wizard?: { id?: string } } if (meta?.wizard?.id) return meta.wizard.id for (const r of route.matched) { const m = r.meta as { wizard?: { id?: string } } if (m?.wizard?.id) return m.wizard.id } return undefined } /** * Wizard-aware wrapper around `shouldBlockNavigation`. * * - Returns `true` (block navigation + show modal) only when we are * *leaving* a wizard flow, i.e. `from` belongs to a wizard and * `to` does not belong to samma wizard. * - Navigations *within* the same wizard (same `wizard.id`) are allowed. */ export function shouldBlockWizardExit(to: any, from: any): boolean { const fromWizardId = getExitGuardWizardId(from) const toWizardId = getExitGuardWizardId(to) // Bara intressant om vi går från ett wizard-flöde till något som inte är wizard const leavingWizard = !!fromWizardId && !toWizardId if (!leavingWizard) return false return shouldBlockNavigation(to, from) } /** * Checks if navigation should be blocked due to unsaved changes. * Called by the router guard or components (e.g. FdsWizard) to intercept navigation attempts. * * @param to - The target route location (router location or similar shape) * @param from - The current route location (router currentRoute or similar shape) * @returns true if navigation should be blocked, false otherwise */ export const shouldBlockNavigation = (to: LocationLike, from: LocationLike): boolean => { // Skip guard check if no unsaved changes if (!globalHasUnsavedChanges.value) { return false } // Skip if navigating to the same route (no actual navigation) if (to.fullPath && from.fullPath && to.fullPath === from.fullPath) { return false } // If user is not authenticated (no token or expired), allow navigation without confirmation. // This prevents the modal from blocking logout when session expires. if (isAuthenticatedFn && !isAuthenticatedFn()) { clearUnsavedChanges() return false } // If there are unsaved changes, show modal and prevent navigation globalPendingNavigationTarget.value = to.fullPath ?? null globalShowExitConfirmModal.value = true globalLastRoute.value = from return true } // Setup function to be called once at app level - must be called with the router instance, // a function that tells whether the user is authenticated, and optional event callbacks. export function setupExitConfirmationGuard( router: RouterLike, isAuthenticated: IsAuthenticatedFn, events?: ExitEvents, ) { globalRouter = router isAuthenticatedFn = isAuthenticated exitEvents = events ?? null // Om vi har sparad wizard-exit från föregående session (reload/stängd flik), rapportera den. try { const raw = sessionStorage.getItem(SESSION_KEY) if (raw && exitEvents?.onConfirmedReload) { const parsed = JSON.parse(raw) as { wizardId?: string; fromRoute?: LocationLike } if (parsed && parsed.fromRoute) { exitEvents.onConfirmedReload({ wizardId: parsed.wizardId, fromRoute: parsed.fromRoute, }) } } sessionStorage.removeItem(SESSION_KEY) } catch { // Ignorera problem med sessionStorage i miljöer där det inte finns } beforeUnloadHandler = (event: BeforeUnloadEvent) => { if (!globalHasUnsavedChanges.value) return // Om användaren inte längre är autentiserad, låt sidan lämnas utan dialog if (isAuthenticatedFn && !isAuthenticatedFn()) { clearUnsavedChanges() return } // Annars: trigga browserns inbyggda "lämna sidan?"-dialog event.preventDefault() } // Setup beforeunload handler window.addEventListener('beforeunload', beforeUnloadHandler) // Spara wizard-info vid reload/stängning så att konsumenten kan få onConfirmedReload pageHideHandler = () => { if (!globalHasUnsavedChanges.value) return try { const routeLike = (globalRouter as any)?.currentRoute?.value as LocationLike | undefined const fromRoute = routeLike ?? globalLastRoute.value if (!fromRoute) return const wizardId = getExitGuardWizardId(fromRoute as any) const payload = { wizardId, fromRoute: { fullPath: fromRoute.fullPath, ...(fromRoute as Record), }, } sessionStorage.setItem(SESSION_KEY, JSON.stringify(payload)) } catch { // Ignorera storage-fel } } window.addEventListener('pagehide', pageHideHandler) const cancelExit = () => { globalPendingNavigationTarget.value = null globalPendingNavigationCallback.value = null globalHasUnsavedChanges.value = true globalShowExitConfirmModal.value = false } const confirmExit = async () => { globalHasUnsavedChanges.value = false globalShowExitConfirmModal.value = false const pendingTarget = globalPendingNavigationTarget.value const pendingCallback = globalPendingNavigationCallback.value // Clear pending values before navigation globalPendingNavigationTarget.value = null globalPendingNavigationCallback.value = null // Rapportera bekräftad exit från wizard (SPA-navigation) const fromRouteLike = (globalRouter as any)?.currentRoute?.value as LocationLike | undefined const fromRoute = fromRouteLike ?? globalLastRoute.value ?? {} const wizardId = getExitGuardWizardId(fromRoute as any) exitEvents?.onConfirmExit?.({ wizardId, fromRoute, }) if (pendingTarget && globalRouter) { await globalRouter.push(pendingTarget) // Execute callback efter navigation if (pendingCallback) { await pendingCallback() } } } return { cancelExit, confirmExit, } } // Composable for components to enable/disable the guard export function useExitConfirmationGuard(initialUnsaved = true) { // Set the global state globalHasUnsavedChanges.value = initialUnsaved return { hasUnsavedChanges: globalHasUnsavedChanges, showExitConfirmModal: globalShowExitConfirmModal, setHasUnsavedChanges: (value: boolean) => { globalHasUnsavedChanges.value = value }, } }