import { createApp, computed, nextTick, onMounted, ref, watch } from 'vue'; import { prepareSession, type PortalSession } from './session'; import { catalog, FixtureWorkflow, subtotal, validateCart, type CartItem, type Product, type Quote, type Order, type Role } from './domain'; // Shared modules remain external so every Lab route uses the same preference contract. // @ts-ignore shared browser module import { initShell } from '../../shared/runtime.mjs'; // @ts-ignore shared browser module import { createStore } from '../../shared/storage.mjs'; // @ts-ignore shared browser module import { createTable } from '../../shared/table.mjs'; // @ts-ignore shared deterministic synthetic dataset import { dealers } from '../../shared/data.mjs'; // @ts-ignore shared recording scaffold import { initRecording } from '../../shared/recording.mjs'; import { recordingChapters, scaffoldPlan, chapterFromHash } from '../recording-plan.mjs'; import { recordingSnippets } from '../recording-snippets.mjs'; const typedRecording = initRecording as unknown as (options:{mount:string;plan:unknown;onReset?:()=>Promise;onRestore?:()=>Promise;comparison?:{before:string;after:string;limit:string};snippets?:readonly {label:string;text:string}[]})=>unknown; initShell({project: 'dealer-portal', title: 'Vue Dealer Portal', description: 'A synthetic catalog, quote and approval workflow with Vue, TypeScript and a tested isolated Laravel API.'}); interface PreferenceStore { get(): T; set(value: T): T } const typedStore = createStore as unknown as (scope: string, defaults: T, validate: (value: T) => boolean) => PreferenceStore; const viewStore = typedStore('demo:dealer-portal:view', {category: 'All'}, (value: {category: string}) => Object.keys(value).length === 1 && ['All', 'Service', 'Parts', 'Equipment'].includes(value.category)); let engine = new FixtureWorkflow(); type Table = {replace: (data: object[]) => void; select: (id: string, options?: {reveal?:boolean}) => void; destroy: () => void; api: unknown; store: {get:()=>{state:unknown};status:()=>string}}; const typedTable = createTable as unknown as (options: {element: string; tableId: string; columns: {key: string; title: string}[]; data: object[]; persistState?:boolean; initialState?:unknown; onSelect: (row: {id: string}) => void}) => Promise; let table: Table | null = null; let activeRequest: AbortController | null = null; let activeTrigger: HTMLElement | null = null; let requestGeneration = 0; const money = (cents: number) => new Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD'}).format(cents / 100); const delay = (signal: AbortSignal) => new Promise((resolve, reject) => { const timer = setTimeout(resolve, 300); signal.addEventListener('abort', () => { clearTimeout(timer); reject(new DOMException('Cancelled', 'AbortError')); }, {once: true}); }); createApp({ setup() { const sceneActive = ref(false); let sceneTransition = false; let originalCheckpoint: {session:PortalSession;tableState:unknown;storageMode:string} | null = null; const dealer = (dealers as unknown as {id: string; name: string; region: string}[]).find(item => item.id === 'D001')!; const currentChapter = ref<(typeof recordingChapters)[number] | null>(null); async function activateChapter() { const chapter = chapterFromHash(location.hash); currentChapter.value = chapter; if (!chapter) return; await nextTick(); const heading = document.getElementById(chapter.target); heading?.focus({preventScroll:true}); heading?.scrollIntoView({block:'start',behavior:'instant'}); } const role = ref('buyer'); const category = ref(viewStore.get().category); const selected = ref({...catalog[0]}); const quantity = ref(1); const products = ref(engine.products.map(p => ({...p}))); const cart = ref([]); const quote = ref(null); const order = ref(null); const audit = ref(engine.audit.slice()); const busy = ref(false); const error = ref(''); const status = ref('Choose a catalog record, add quantities, then request a quote.'); const failNext = ref(false); const quoteKey = ref(crypto.randomUUID()); const orderKey = ref(crypto.randomUUID()); const cartTotal = computed(() => subtotal(cart.value)); const cartLines = computed(() => cart.value.map(line => ({...line, name: catalog.find(p => p.sku === line.sku)!.name, total: catalog.find(p => p.sku === line.sku)!.priceCents * line.quantity}))); const expired = computed(() => quote.value ? quote.value.expiresAt < Date.now() : false); const refresh = () => { products.value = engine.products.map(p => ({...p})); audit.value = engine.audit.slice(); if (quote.value) quote.value = engine.readQuote(quote.value.id); table?.replace(rows()); }; const rows = () => products.value.filter(p => category.value === 'All' || p.category === category.value).map(p => ({id: p.sku, sku: p.sku, name: p.name, category: p.category, price: money(p.priceCents), stock: p.stock})); watch(category, () => { if (!sceneActive.value && !sceneTransition) viewStore.set({category: category.value}); if (sceneTransition) return; table?.replace(rows()); if (table?.api) (table.api as {page: (page: string) => {draw: (mode: string) => void}}).page('first').draw('page'); }); const selectProduct = (row: {id: string}) => { const p = products.value.find(p => p.sku === row.id); if (p) { selected.value = {...p}; quantity.value = 1; } }; onMounted(async () => { try { table = await typedTable({element: '#catalog-table', tableId: 'dealer-catalog', columns: [{key: 'sku', title: 'SKU'}, {key: 'name', title: 'Product'}, {key: 'category', title: 'Category'}, {key: 'price', title: 'Unit price'}, {key: 'stock', title: 'Synthetic stock'}], data: rows(), onSelect: selectProduct}); table?.select(selected.value.sku,{reveal:false}); } catch { error.value = 'The enhanced catalog could not load. Use the accessible product selector below.'; } typedRecording({mount:'#portal-recording-tools',plan:scaffoldPlan,snippets:recordingSnippets,onReset:prepareRecording,onRestore:restoreRecording,comparison:{before:'An empty synthetic cart has no quoted decision or receipt.',after:'A deliberately reviewed fixture conversion produces one receipt and three audit transitions.',limit:'These browser actions do not authenticate a user or send a real order. Separate Laravel/database evidence has its own source revision.'}}); window.addEventListener('hashchange',()=>{void activateChapter();}); window.addEventListener('popstate',()=>{void activateChapter();}); await activateChapter(); }); function selectFromList(event: Event) { const id = (event.target as HTMLSelectElement).value; selectProduct({id}); table?.select(id); } function add() { error.value = ''; const next = cart.value.filter(item => item.sku !== selected.value.sku).concat({sku: selected.value.sku, quantity: Number(quantity.value)}); const errors = validateCart(next, products.value); if (errors.length) { error.value = errors.join(' '); return; } cart.value = next; quote.value = null; order.value = null; quoteKey.value = crypto.randomUUID(); orderKey.value = crypto.randomUUID(); status.value = `${selected.value.name} quantity set to ${quantity.value}. Request a new quote when ready.`; } function remove(sku: string) { cart.value = cart.value.filter(item => item.sku !== sku); quote.value = null; order.value = null; quoteKey.value = crypto.randomUUID(); orderKey.value = crypto.randomUUID(); } const restoreFocus = (preferred: HTMLElement | null) => { const target = preferred?.isConnected && preferred !== document.body && !(preferred instanceof HTMLButtonElement && preferred.disabled) ? preferred : document.querySelector('#workflow-status'); target?.focus({preventScroll: true}); }; async function run(action: () => void, successFocus: string) { if (busy.value) return; const trigger = document.activeElement instanceof HTMLElement ? document.activeElement : null; activeTrigger = trigger; activeRequest = new AbortController(); const generation = ++requestGeneration; busy.value = true; error.value = ''; let completed = false; let restore = false; try { await delay(activeRequest.signal); if (generation !== requestGeneration || activeRequest.signal.aborted) return; restore = document.activeElement === trigger || document.activeElement === document.body || document.activeElement?.id === 'cancel-request'; action(); completed = true; refresh(); } catch (e) { const problem = e as Error; if (problem.name !== 'AbortError') error.value = problem.message; } finally { if (generation === requestGeneration) { busy.value = false; activeRequest = null; activeTrigger = null; await nextTick(); // Restore only workflow-owned focus; do not pull focus from a link the user chose while waiting. if (restore && generation === requestGeneration) restoreFocus(completed ? document.querySelector(successFocus) : trigger); } } } function requestQuote() { void run(() => { quote.value = engine.request(cart.value, quoteKey.value, role.value); order.value = null; status.value = 'Quote requested. Switch the simulated role preview to Reviewer to approve.'; }, '#simulated-role'); } function approve() { void run(() => { quote.value = engine.approve(quote.value!.id, role.value); status.value = 'Quote approved in the fixture. Switch to Buyer to create a synthetic order.'; }, '#simulated-role'); } function convert() { void run(() => { if (failNext.value) { engine.failNextOrder(); failNext.value = false; } order.value = engine.order(quote.value!.id, orderKey.value, role.value); status.value = 'Synthetic order accepted. No real order, payment or external send occurred.'; }, '#order-receipt-heading'); } function invalidatePending() { activeRequest?.abort(); ++requestGeneration; activeRequest = null; activeTrigger = null; busy.value = false; } function cancel() { const trigger = activeTrigger; invalidatePending(); status.value = 'Request cancelled before fixture state changed.'; void nextTick().then(() => restoreFocus(trigger)); } function captureSession(): PortalSession { return prepareSession({version:1,workflow:engine.snapshot(),role:role.value,category:category.value,selectedSku:selected.value.sku,quantity:quantity.value,cart:cart.value.map(line=>({...line})),quoteId:quote.value?.id??null,orderId:order.value?.id??null,quoteKey:quoteKey.value,orderKey:orderKey.value,failNext:failNext.value,error:error.value,status:busy.value?'Pending fixture request cancelled; restored settled state without replay.':status.value}).state; } function applySession(prepared: ReturnType) { const state=prepared.state;engine=prepared.engine;role.value=state.role;category.value=state.category; products.value=engine.products.map(p=>({...p}));selected.value={...products.value.find(p=>p.sku===state.selectedSku)!};quantity.value=state.quantity; cart.value=state.cart;quote.value=state.quoteId?engine.readQuote(state.quoteId):null;order.value=state.orderId?engine.readOrder(state.orderId):null; audit.value=engine.audit.slice();quoteKey.value=state.quoteKey;orderKey.value=state.orderKey;failNext.value=state.failNext;error.value=state.error;status.value=state.status; } async function mountSceneTable(persistState: boolean, initialState: unknown) { table=await typedTable({element:'#catalog-table',tableId:'dealer-catalog',columns:[{key:'sku',title:'SKU'},{key:'name',title:'Product'},{key:'category',title:'Category'},{key:'price',title:'Unit price'},{key:'stock',title:'Synthetic stock'}],data:rows(),onSelect:selectProduct,persistState,initialState}); table.select(selected.value.sku,{reveal:false}); } async function prepareRecording() { if (sceneTransition) throw new Error('Wait for the current synthetic scene transition.'); // Validate the complete checkpoint before changing any current state. const checkpoint=originalCheckpoint??{session:captureSession(),tableState:structuredClone(table?.store.get().state??null),storageMode:table?.store.status()??'memory'}; const clean: PortalSession={version:1,workflow:new FixtureWorkflow().snapshot(),role:'buyer',category:'All',selectedSku:catalog[0].sku,quantity:1,cart:[],quoteId:null,orderId:null,quoteKey:crypto.randomUUID(),orderKey:crypto.randomUUID(),failNext:false,error:'',status:'Temporary synthetic recording scene. Saved browser preferences are unchanged.'}; const prepared=prepareSession(clean);sceneTransition=true;sceneActive.value=true;invalidatePending();busy.value=true; try { // Dispose the persistent writer BEFORE temporary rows/category are assigned. table?.destroy();table=null;originalCheckpoint=checkpoint;applySession(prepared);await nextTick(); await mountSceneTable(false,null); return 'Fresh synthetic scene ready. The first original session remains only in this tab’s memory; saved preferences are unchanged.'; } finally {sceneTransition=false;busy.value=false;} } async function restoreRecording() { if (sceneTransition) throw new Error('Wait for the current synthetic scene transition.'); if (!originalCheckpoint) throw new Error('No pre-recording session is available in this tab.'); const checkpoint=originalCheckpoint;const prepared=prepareSession(checkpoint.session); sceneTransition=true;invalidatePending();busy.value=true; try { table?.destroy();table=null;applySession(prepared);await nextTick(); await mountSceneTable(true,checkpoint.tableState); sceneActive.value=false;originalCheckpoint=null; return 'Original settled synthetic session restored. No request, approval or order was replayed. Saved browser preferences are unchanged.'; } finally {sceneTransition=false;busy.value=false;} } function reset() { const panel=document.querySelector('#portal-recording-tools > details');if(panel)panel.open=true; const trigger=[...document.querySelectorAll('#portal-recording-tools button')].find(button=>button.textContent==='Prepare synthetic reset'); trigger?.click();(document.activeElement as HTMLElement | null)?.scrollIntoView({behavior:'instant',block:'center'}); } return {recordingChapters,currentChapter,sceneActive,dealer, role, category, selected, quantity, products, cart, cartLines, cartTotal, quote, order, audit, busy, error, status, failNext, expired, money, add, remove, requestQuote, approve, convert, cancel, reset, selectFromList}; } }).mount('#portal-app'); document.querySelector('#boot-message')?.remove();