refactor: checkout, pass. info, old code removal

This commit is contained in:
user
2025-10-21 16:04:28 +03:00
parent c3650f6d5e
commit 8440c6a2dd
31 changed files with 67 additions and 3219 deletions

View File

@@ -2,16 +2,16 @@
import Icon from "$lib/components/atoms/icon.svelte";
import { buttonVariants } from "$lib/components/ui/button/button.svelte";
import { Sheet, SheetContent, SheetTrigger } from "$lib/components/ui/sheet";
import { CheckoutStep } from "$lib/domains/order/data/entities";
import { cn } from "$lib/utils";
import ChevronDownIcon from "~icons/lucide/chevron-down";
import CloseIcon from "~icons/lucide/x";
import { CheckoutStep } from "../../data/entities";
import { checkoutVM } from "./checkout.vm.svelte";
const checkoutSteps = [
{ id: CheckoutStep.Initial, label: "Passenger Details" },
{ id: CheckoutStep.Initial, label: "Initial Details" },
{ id: CheckoutStep.Payment, label: "Payment" },
{ id: CheckoutStep.Verification, label: "Verify Details" },
{ id: CheckoutStep.Verification, label: "Verify" },
{ id: CheckoutStep.Confirmation, label: "Confirmation" },
];

View File

@@ -4,7 +4,7 @@
import * as Select from "$lib/components/ui/select";
import { COUNTRIES_SELECT } from "$lib/core/countries";
import { capitalize } from "$lib/core/string.utils";
import type { CustomerInfoModel } from "$lib/domains/ticket/data/entities/create.entities";
import type { CustomerInfoModel } from "$lib/domains/customerinfo/data";
import { billingDetailsVM } from "./billing.details.vm.svelte";
let { info = $bindable() }: { info: CustomerInfoModel } = $props();

View File

@@ -1,5 +1,8 @@
import { type CustomerInfoModel, Gender } from "$lib/domains/customerinfo/data";
import { customerInfoModel } from "$lib/domains/passengerinfo/data/entities";
import {
type CustomerInfoModel,
customerInfoModel,
Gender,
} from "$lib/domains/customerinfo/data";
import { z } from "zod";
export class BillingDetailsViewModel {

View File

@@ -2,21 +2,13 @@
import ButtonLoadableText from "$lib/components/atoms/button-loadable-text.svelte";
import Icon from "$lib/components/atoms/icon.svelte";
import Title from "$lib/components/atoms/title.svelte";
import Button, {
buttonVariants,
} from "$lib/components/ui/button/button.svelte";
import * as Dialog from "$lib/components/ui/dialog";
import Button from "$lib/components/ui/button/button.svelte";
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
import { customerInfoVM } from "$lib/domains/customerinfo/view/customerinfo.vm.svelte";
import { CheckoutStep } from "$lib/domains/order/data/entities";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import { flightTicketStore } from "$lib/domains/ticket/data/store";
import TicketDetailsModal from "$lib/domains/ticket/view/ticket/ticket-details-modal.svelte";
import { cn } from "$lib/utils";
import { formatDate } from "@pkg/logic/core/date.utils";
import { onMount } from "svelte";
import { toast } from "svelte-sonner";
import RightArrowIcon from "~icons/solar/arrow-right-broken";
import ArrowsExchangeIcon from "~icons/tabler/arrows-exchange-2";
import { checkoutVM } from "../checkout.vm.svelte";
import BillingDetailsForm from "./billing-details-form.svelte";
import { billingDetailsVM } from "./billing.details.vm.svelte";
@@ -56,14 +48,6 @@
}, 1000);
}
let outboundFlight = $derived(
$flightTicketStore?.flightIteneraries.outbound[0],
);
let inboundFlight = $derived(
$flightTicketStore?.flightIteneraries.inbound[0],
);
let isReturnFlight = $derived($flightTicketStore?.flightType === "Return");
$effect(() => {
if (!ckFlowVM.flowId || !ckFlowVM.setupDone) return;
if (!paymentInfoVM.cardDetails) return;
@@ -82,79 +66,17 @@
billingDetailsVM.validatePII(billingDetailsVM.billingDetails);
if (billingDetailsVM.isPIIValid()) {
console.log("Billing details are valid, not setting from pasenger");
console.log("Billing details are valid, not setting from initials");
return;
}
if (passengerInfoVM.passengerInfos.length > 0) {
billingDetailsVM.setPII(
passengerInfoVM.passengerInfos[0].passengerPii,
);
billingDetailsVM.validatePII(billingDetailsVM.billingDetails);
toast("Used billing details from primary passenger");
}
if (!customerInfoVM.customerInfo) return;
billingDetailsVM.setPII(customerInfoVM.customerInfo);
billingDetailsVM.validatePII(billingDetailsVM.billingDetails);
toast("Used billing details from initial info");
});
</script>
<div class="flex flex-col gap-6">
<div class={cardStyle}>
<Title size="h4">Trip Summary</Title>
<div
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"
>
<!-- Trip Summary -->
<div class="flex flex-col gap-4 md:gap-2">
<!-- Main Route Display -->
<div class="flex items-center gap-2 text-lg font-semibold">
<span>{outboundFlight?.departure.station.code}</span>
{#if isReturnFlight}
<Icon
icon={ArrowsExchangeIcon}
cls="w-5 h-5 text-gray-400 rotate-180"
/>
<span>{outboundFlight?.destination.station.code}</span>
{:else}
<Icon icon={RightArrowIcon} cls="w-5 h-5 text-gray-400" />
<span>{outboundFlight?.destination.station.code}</span>
{/if}
</div>
<!-- Dates Display -->
<div class="flex flex-col gap-1 text-sm text-gray-600 md:gap-0">
{#if isReturnFlight}
<div class="flex items-center gap-2">
<span>
{formatDate(outboundFlight?.departure.localTime)}
- {formatDate(inboundFlight.departure.localTime)}
</span>
</div>
{:else}
<div class="flex items-center gap-2">
<span>
{formatDate(outboundFlight?.departure.localTime)}
</span>
</div>
{/if}
</div>
</div>
<!-- View Details Button -->
<TicketDetailsModal
data={$flightTicketStore}
hideCheckoutBtn
onCheckoutBtnClick={() => {}}
>
<Dialog.Trigger
class={cn(
buttonVariants({ variant: "secondary" }),
"w-max text-start",
)}
>
View Full Details
</Dialog.Trigger>
</TicketDetailsModal>
</div>
</div>
<div class={cardStyle}>
<Title size="h4">Order Summary</Title>
<OrderSummary />

View File

@@ -4,28 +4,23 @@
convertAndFormatCurrency,
currencyStore,
} from "$lib/domains/currency/view/currency.vm.svelte";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import { flightTicketStore } from "../../data/store";
import { calculateTicketPrices } from "./total.calculator";
import { customerInfoVM } from "../customerinfo/view/customerinfo.vm.svelte";
import { productStore } from "../product/store";
import { calculateFinalPrices } from "./utils";
let totals = $state(
calculateTicketPrices($flightTicketStore, passengerInfoVM.passengerInfos),
let priceDetails = $state(
calculateFinalPrices($productStore, customerInfoVM.customerInfo),
);
let changing = $state(false);
let calculating = $state(false);
// Reactively update price details when product or customer info changes
$effect(() => {
changing = true;
totals = calculateTicketPrices(
$flightTicketStore,
passengerInfoVM.passengerInfos,
calculating = true;
priceDetails = calculateFinalPrices(
$productStore,
customerInfoVM.customerInfo,
);
changing = false;
});
flightTicketStore.subscribe((val) => {
changing = true;
totals = calculateTicketPrices(val, passengerInfoVM.passengerInfos);
changing = false;
calculating = false;
});
</script>
@@ -33,116 +28,69 @@
<Title size="h4" weight="medium">Payment Summary</Title>
<div class="h-0.5 w-full border-t-2 border-gray-200"></div>
{#if !changing}
<!-- Base Ticket Price Breakdown -->
<div class="flex flex-col gap-2">
<Title size="p" weight="medium">Base Ticket Price</Title>
<div class="flex justify-between text-sm">
<span>Total Ticket Price</span>
<span>{convertAndFormatCurrency(totals.baseTicketPrice)}</span>
</div>
<div class="ml-4 text-sm text-gray-600">
<span>
Price per passenger (x{passengerInfoVM.passengerInfos.length})
</span>
<span class="float-right">
{convertAndFormatCurrency(totals.pricePerPassenger)}
</span>
</div>
</div>
<!-- Baggage Costs -->
{#if totals.totalBaggageCost > 0}
<div class="mt-2 flex flex-col gap-2 border-t pt-2">
<Title size="p" weight="medium">Baggage Charges</Title>
{#each totals.passengerBaggageCosts as passengerBaggage}
{#if passengerBaggage.totalBaggageCost > 0}
<div class="flex flex-col gap-1">
<span class="text-sm font-medium">
{passengerBaggage.passengerName}
</span>
{#if passengerBaggage.personalBagCost > 0}
<div
class="ml-4 flex justify-between text-sm text-gray-600"
>
<span>Personal Bag</span>
<span>
{convertAndFormatCurrency(
passengerBaggage.personalBagCost,
)}
</span>
</div>
{/if}
{#if passengerBaggage.handBagCost > 0}
<div
class="ml-4 flex justify-between text-sm text-gray-600"
>
<span>Hand Baggage</span>
<span>
{convertAndFormatCurrency(
passengerBaggage.handBagCost,
)}
</span>
</div>
{/if}
{#if passengerBaggage.checkedBagCost > 0}
<div
class="ml-4 flex justify-between text-sm text-gray-600"
>
<span>Checked Baggage</span>
<span>
{convertAndFormatCurrency(
passengerBaggage.checkedBagCost,
)}
</span>
</div>
{/if}
</div>
{/if}
{/each}
<div class="flex justify-between text-sm font-medium">
<span>Total Baggage Charges</span>
<span
>{convertAndFormatCurrency(totals.totalBaggageCost)}</span
>
</div>
{#if !calculating}
<!-- Product Information -->
{#if $productStore}
<div class="flex flex-col gap-2">
<Title size="p" weight="medium">{$productStore.title}</Title>
<p class="text-sm text-gray-600">{$productStore.description}</p>
</div>
{/if}
<!-- Final Total -->
<div class="mt-4 flex flex-col gap-2 border-t pt-4">
<!-- Price Breakdown -->
<div class="mt-2 flex flex-col gap-2 border-t pt-4">
<div class="flex justify-between text-sm">
<span>Subtotal</span>
<span>{convertAndFormatCurrency(totals.subtotal)}</span>
<span>Base Price</span>
<span>{convertAndFormatCurrency(priceDetails.basePrice)}</span>
</div>
{#if totals.discountAmount > 0}
{#if priceDetails.discountAmount > 0}
<div class="flex justify-between text-sm text-green-600">
<span>Discount</span>
<span>-{convertAndFormatCurrency(totals.discountAmount)}</span
<span
>-{convertAndFormatCurrency(
priceDetails.discountAmount,
)}</span
>
</div>
<div class="flex justify-between text-sm">
<span>Display Price</span>
<span
>{convertAndFormatCurrency(
priceDetails.displayPrice,
)}</span
>
</div>
{/if}
</div>
<!-- Final Total -->
<div class="mt-4 flex flex-col gap-2 border-t pt-4">
<div class="flex justify-between font-medium">
<Title size="h5" weight="medium"
>Total ({$currencyStore.code})</Title
>
<span>{convertAndFormatCurrency(totals.finalTotal)}</span>
<span class="text-lg"
>{convertAndFormatCurrency(priceDetails.orderPrice)}</span
>
</div>
</div>
{:else}
<div class="grid place-items-center p-2 text-center">
<span>Calculating . . .</span>
<div class="grid place-items-center p-8 text-center">
<span class="text-gray-600">Calculating...</span>
</div>
{/if}
<!-- Important Information -->
<div class="mt-4 rounded-lg bg-gray-50 p-4 text-xs text-gray-600">
<p class="mb-2 font-medium">Important Information:</p>
<ul class="list-disc space-y-1 pl-4">
<li>Prices include all applicable taxes and fees</li>
<li>Cancellation and change fees may apply as per our policy</li>
<li>Additional baggage fees may apply based on airline policy</li>
<li>Price includes all applicable taxes and fees</li>
<li>
Cancellation and refund policies may apply as per our terms of
service
</li>
<li>Payment will be processed securely upon order confirmation</li>
</ul>
</div>
</div>

View File

@@ -1,12 +1,5 @@
import { trpcApiStore } from "$lib/stores/api";
import { toast } from "svelte-sonner";
import { get } from "svelte/store";
import { z } from "zod";
import type {
CreateCustomerInfoPayload,
CustomerInfoModel,
UpdateCustomerInfoPayload,
} from "../data";
import type { CreateCustomerInfoPayload, CustomerInfoModel } from "../data";
import { customerInfoModel } from "../data";
/**
@@ -32,136 +25,6 @@ export class CustomerInfoViewModel {
this.errors = {};
}
/**
* Fetches a single customer info record by ID and sets it as current
* @param id - Customer info ID to fetch
* @returns true if successful, false otherwise
*/
async fetchCustomerInfoById(id: number): Promise<boolean> {
const api = get(trpcApiStore);
if (!api) {
toast.error("API client not initialized");
return false;
}
this.loading = true;
try {
const result = await api.customerInfo.getCustomerInfoById.query({
id,
});
if (result.error) {
toast.error(result.error.message, {
description: result.error.userHint,
});
return false;
}
this.customerInfo = result.data || null;
return true;
} catch (e) {
console.error(e);
toast.error("Failed to fetch customer information", {
description: "Please try again later",
});
return false;
} finally {
this.loading = false;
}
}
/**
* Creates a new customer information record
* @param payload - Customer info data to create
* @returns Customer info ID if successful, null otherwise
*/
async createCustomerInfo(
payload: CreateCustomerInfoPayload,
): Promise<number | null> {
const api = get(trpcApiStore);
if (!api) {
toast.error("API client not initialized");
return null;
}
// Validate before submitting
if (!this.validateCustomerInfo(payload)) {
toast.error("Please fix validation errors before submitting");
return null;
}
this.formLoading = true;
try {
const result =
await api.customerInfo.createCustomerInfo.mutate(payload);
if (result.error) {
toast.error(result.error.message, {
description: result.error.userHint,
});
return null;
}
toast.success("Customer information saved successfully");
return result.data || null;
} catch (e) {
console.error(e);
toast.error("Failed to save customer information", {
description: "Please try again later",
});
return null;
} finally {
this.formLoading = false;
}
}
/**
* Updates the current customer information record
* @param payload - Customer info data to update (must include id)
* @returns true if successful, false otherwise
*/
async updateCustomerInfo(
payload: UpdateCustomerInfoPayload,
): Promise<boolean> {
const api = get(trpcApiStore);
if (!api) {
toast.error("API client not initialized");
return false;
}
if (!payload.id) {
toast.error("Customer ID is required for update");
return false;
}
this.formLoading = true;
try {
const result =
await api.customerInfo.updateCustomerInfo.mutate(payload);
if (result.error) {
toast.error(result.error.message, {
description: result.error.userHint,
});
return false;
}
toast.success("Customer information updated successfully");
// Update local state with the updated data
if (this.customerInfo && this.customerInfo.id === payload.id) {
this.customerInfo = { ...this.customerInfo, ...payload };
}
return true;
} catch (e) {
console.error(e);
toast.error("Failed to update customer information", {
description: "Please try again later",
});
return false;
} finally {
this.formLoading = false;
}
}
/**
* Validates customer information data using Zod schema
* @param info - Customer info to validate

View File

@@ -1 +0,0 @@
export * from "@pkg/logic/domains/passengerinfo/data/entities";

View File

@@ -1,211 +0,0 @@
import { eq, inArray, type Database } from "@pkg/db";
import { passengerInfo, passengerPII } from "@pkg/db/schema";
import { getError, Logger } from "@pkg/logger";
import { ERROR_CODES, type Result } from "@pkg/result";
import {
passengerInfoModel,
type CustomerInfoModel,
type PassengerInfo,
} from "./entities";
export class PassengerInfoRepository {
private db: Database;
constructor(db: Database) {
this.db = db;
}
async createPassengerPii(
payload: CustomerInfoModel,
): Promise<Result<number>> {
try {
const out = await this.db
.insert(passengerPII)
.values({
firstName: payload.firstName,
middleName: payload.middleName,
lastName: payload.lastName,
email: payload.email,
phoneCountryCode: payload.phoneCountryCode,
phoneNumber: payload.phoneNumber,
nationality: payload.nationality,
gender: payload.gender,
dob: payload.dob,
passportNo: payload.passportNo,
passportExpiry: payload.passportExpiry,
country: payload.country,
state: payload.state,
city: payload.city,
address: payload.address,
zipCode: payload.zipCode,
address2: payload.address2,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning({ id: passengerInfo.id })
.execute();
if (!out || out.length === 0) {
Logger.error("Failed to create passenger info");
Logger.debug(out);
Logger.debug(payload);
return {
error: getError({
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "Failed to create passenger info",
userHint: "Please try again",
detail: "Failed to create passenger info",
}),
};
}
return { data: out[0].id };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "An error occured while creating passenger info",
userHint: "Please try again later",
actionable: false,
detail: "An error occured while creating passenger info",
},
e,
),
};
}
}
async createPassengerInfo(payload: PassengerInfo): Promise<Result<number>> {
try {
const out = await this.db
.insert(passengerInfo)
.values({
passengerType: payload.passengerType,
passengerPiiId: payload.passengerPiiId,
paymentInfoId: payload.paymentInfoId,
seatSelection: payload.seatSelection,
bagSelection: payload.bagSelection,
agentsInfo: payload.agentsInfo,
flightTicketInfoId: payload.flightTicketInfoId,
orderId: payload.orderId,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning({ id: passengerInfo.id })
.execute();
if (!out || out.length === 0) {
Logger.error("Failed to create passenger info");
Logger.debug(out);
Logger.debug(payload);
return {
error: getError({
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "Failed to create passenger info",
userHint: "Please try again",
detail: "Failed to create passenger info",
}),
};
}
return { data: out[0].id };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "An error occured while creating passenger info",
userHint: "Please try again later",
actionable: false,
detail: "An error occured while creating passenger info",
},
e,
),
};
}
}
async getPassengerInfo(id: number): Promise<Result<PassengerInfo>> {
try {
const out = await this.db.query.passengerInfo.findFirst({
where: eq(passengerInfo.id, id),
with: { passengerPii: true },
});
if (!out) {
Logger.error("Failed to get passenger info");
Logger.debug(out);
return {
error: getError({
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "Failed to get passenger info",
userHint: "Please try again",
detail: "Failed to get passenger info",
}),
};
}
return { data: out as any as PassengerInfo };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "An error occured while getting passenger info",
userHint: "Please try again later",
actionable: false,
detail: "An error occured while getting passenger info",
},
e,
),
};
}
}
async getPassengerInfosByRefOId(
refOIds: number[],
): Promise<Result<PassengerInfo[]>> {
try {
const out = await this.db.query.passengerInfo.findMany({
where: inArray(passengerInfo.orderId, refOIds),
with: { passengerPii: true },
});
const res = [] as PassengerInfo[];
for (const each of out) {
const parsed = passengerInfoModel.safeParse(each);
if (!parsed.success) {
Logger.warn(`Error while parsing passenger info`);
Logger.debug(parsed.error?.errors);
continue;
}
res.push(parsed.data);
}
Logger.info(`Returning ${res.length} passenger info by ref OID`);
return { data: res };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "An error occured while getting passenger info",
userHint: "Please try again later",
actionable: false,
detail: "An error occured while getting passenger info",
},
e,
),
};
}
}
async deleteAll(ids: number[]): Promise<Result<number>> {
Logger.info(`Deleting ${ids.length} passenger info`);
const out = await this.db
.delete(passengerInfo)
.where(inArray(passengerInfo.id, ids));
Logger.debug(out);
Logger.info(`Deleted ${out.count} passenger info`);
return { data: out.count };
}
}

View File

@@ -1,52 +0,0 @@
import { Logger } from "@pkg/logger";
import type { Result } from "@pkg/result";
import type { PassengerInfo } from "../data/entities";
import type { PassengerInfoRepository } from "../data/repository";
export class PassengerInfoController {
repo: PassengerInfoRepository;
constructor(repo: PassengerInfoRepository) {
this.repo = repo;
}
async createPassengerInfos(
payload: PassengerInfo[],
orderId: number,
flightTicketInfoId?: number,
paymentInfoId?: number,
): Promise<Result<number>> {
const made = [] as number[];
for (const passengerInfo of payload) {
const piiOut = await this.repo.createPassengerPii(
passengerInfo.passengerPii,
);
if (piiOut.error || !piiOut.data) {
await this.repo.deleteAll(made);
return piiOut;
}
passengerInfo.passengerPiiId = piiOut.data;
passengerInfo.paymentInfoId = paymentInfoId;
passengerInfo.flightTicketInfoId = flightTicketInfoId;
passengerInfo.orderId = orderId;
passengerInfo.agentId = undefined;
const out = await this.repo.createPassengerInfo(passengerInfo);
if (out.error) {
await this.repo.deleteAll(made);
return out;
}
}
return { data: made.length };
}
async getPassengerInfo(id: number): Promise<Result<PassengerInfo>> {
return this.repo.getPassengerInfo(id);
}
async getPassengerInfosByRefOIds(
refOIds: number[],
): Promise<Result<PassengerInfo[]>> {
Logger.info(`Querying/Returning Passenger infos for ${refOIds}`);
return this.repo.getPassengerInfosByRefOId(refOIds);
}
}

View File

@@ -1,323 +0,0 @@
<script lang="ts">
import LabelWrapper from "$lib/components/atoms/label-wrapper.svelte";
import Title from "$lib/components/atoms/title.svelte";
import Input from "$lib/components/ui/input/input.svelte";
import * as Select from "$lib/components/ui/select";
import { COUNTRIES_SELECT } from "$lib/core/countries";
import type { SelectOption } from "$lib/core/data.types";
import { capitalize } from "$lib/core/string.utils";
import type { CustomerInfoModel } from "$lib/domains/ticket/data/entities/create.entities";
import { Gender } from "$lib/domains/ticket/data/entities/index";
import { PHONE_COUNTRY_CODES } from "@pkg/logic/core/data/phonecc";
import { passengerInfoVM } from "./passenger.info.vm.svelte";
let { info = $bindable(), idx }: { info: CustomerInfoModel; idx: number } =
$props();
const genderOpts = [
{ label: capitalize(Gender.Male), value: Gender.Male },
{ label: capitalize(Gender.Female), value: Gender.Female },
{ label: capitalize(Gender.Other), value: Gender.Other },
] as SelectOption[];
function onSubmit(e: SubmitEvent) {
e.preventDefault();
passengerInfoVM.validatePII(info, idx);
}
let validationTimeout = $state(undefined as undefined | NodeJS.Timer);
function debounceValidate() {
if (validationTimeout) {
clearTimeout(validationTimeout);
}
validationTimeout = setTimeout(() => {
passengerInfoVM.validatePII(info, idx);
}, 500);
}
</script>
<form action="#" class="flex w-full flex-col gap-4" onsubmit={onSubmit}>
<div class="flex flex-col gap-4 md:flex-row">
<LabelWrapper
label="First Name"
error={passengerInfoVM.piiErrors[idx].firstName}
>
<Input
placeholder="First Name"
bind:value={info.firstName}
required
oninput={() => debounceValidate()}
/>
</LabelWrapper>
<LabelWrapper
label="Middle Name"
error={passengerInfoVM.piiErrors[idx].middleName}
>
<Input
placeholder="Middle Name"
bind:value={info.middleName}
oninput={() => debounceValidate()}
required
/>
</LabelWrapper>
<LabelWrapper
label="Last Name"
error={passengerInfoVM.piiErrors[idx].lastName}
>
<Input
placeholder="Last Name"
bind:value={info.lastName}
required
oninput={() => debounceValidate()}
/>
</LabelWrapper>
</div>
<LabelWrapper label="Email" error={passengerInfoVM.piiErrors[idx].email}>
<Input
placeholder="Email"
bind:value={info.email}
type="email"
oninput={() => debounceValidate()}
required
/>
</LabelWrapper>
<div class="flex flex-col gap-4 md:flex-row lg:flex-col xl:flex-row">
<LabelWrapper
label="Phone Number"
error={passengerInfoVM.piiErrors[idx].phoneNumber}
>
<div class="flex gap-2">
<Select.Root
type="single"
required
onValueChange={(code) => {
info.phoneCountryCode = code;
}}
name="phoneCode"
>
<Select.Trigger class="w-20">
{#if info.phoneCountryCode}
{info.phoneCountryCode}
{:else}
Select
{/if}
</Select.Trigger>
<Select.Content>
{#each PHONE_COUNTRY_CODES as { country, phoneCode }}
<Select.Item value={phoneCode}>
<span class="flex items-center gap-2">
{phoneCode} ({country})
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Input
placeholder="Phone Number"
type="tel"
bind:value={info.phoneNumber}
required
oninput={() => debounceValidate()}
class="flex-1"
/>
</div>
</LabelWrapper>
<LabelWrapper
label="Passport Expiry"
error={passengerInfoVM.piiErrors[idx].passportExpiry}
>
<Input
placeholder="Passport Expiry"
value={info.passportExpiry}
type="date"
required
oninput={(v) => {
// @ts-ignore
info.passportExpiry = v.target.value;
debounceValidate();
}}
/>
</LabelWrapper>
<LabelWrapper
label="Passport/ID No"
error={passengerInfoVM.piiErrors[idx].passportNo}
>
<Input
placeholder="Passport or ID card no."
bind:value={info.passportNo}
minlength={1}
maxlength={20}
required
oninput={() => debounceValidate()}
/>
</LabelWrapper>
</div>
<div class="flex flex-col gap-4 md:flex-row">
<LabelWrapper
label="Nationality"
error={passengerInfoVM.piiErrors[idx].nationality}
>
<Select.Root
type="single"
required
onValueChange={(e) => {
info.nationality = e;
debounceValidate();
}}
name="role"
>
<Select.Trigger class="w-full">
{capitalize(
info.nationality.length > 0 ? info.nationality : "Select",
)}
</Select.Trigger>
<Select.Content>
{#each COUNTRIES_SELECT as country}
<Select.Item value={country.value}>
{country.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</LabelWrapper>
<LabelWrapper
label="Gender"
error={passengerInfoVM.piiErrors[idx].gender}
>
<Select.Root
type="single"
required
onValueChange={(e) => {
info.gender = e as Gender;
debounceValidate();
}}
name="role"
>
<Select.Trigger class="w-full">
{capitalize(info.gender.length > 0 ? info.gender : "Select")}
</Select.Trigger>
<Select.Content>
{#each genderOpts as gender}
<Select.Item value={gender.value}>
{gender.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</LabelWrapper>
<LabelWrapper
label="Date of Birth"
error={passengerInfoVM.piiErrors[idx].dob}
>
<Input
placeholder="Date of Birth"
bind:value={info.dob}
type="date"
required
oninput={() => debounceValidate()}
/>
</LabelWrapper>
</div>
<!-- and now for the address info - country, state, city, zip code, address and address 2 -->
<Title size="h5">Address Info</Title>
<div class="flex flex-col gap-4 md:flex-row">
<LabelWrapper
label="Country"
error={passengerInfoVM.piiErrors[idx].country}
>
<Select.Root
type="single"
required
onValueChange={(e) => {
info.country = e;
debounceValidate();
}}
name="role"
>
<Select.Trigger class="w-full">
{capitalize(
info.country.length > 0 ? info.country : "Select",
)}
</Select.Trigger>
<Select.Content>
{#each COUNTRIES_SELECT as country}
<Select.Item value={country.value}>
{country.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</LabelWrapper>
<LabelWrapper label="State" error={passengerInfoVM.piiErrors[idx].state}>
<Input
placeholder="State"
bind:value={info.state}
required
oninput={() => debounceValidate()}
/>
</LabelWrapper>
</div>
<div class="flex flex-col gap-4 md:flex-row">
<LabelWrapper label="City" error={passengerInfoVM.piiErrors[idx].city}>
<Input
placeholder="City"
bind:value={info.city}
required
minlength={1}
maxlength={80}
oninput={() => debounceValidate()}
/>
</LabelWrapper>
<LabelWrapper
label="Zip Code"
error={passengerInfoVM.piiErrors[idx].zipCode}
>
<Input
placeholder="Zip Code"
bind:value={info.zipCode}
required
minlength={1}
oninput={() => debounceValidate()}
maxlength={12}
/>
</LabelWrapper>
</div>
<LabelWrapper label="Address" error={passengerInfoVM.piiErrors[idx].address}>
<Input
placeholder="Address"
bind:value={info.address}
required
minlength={1}
maxlength={128}
oninput={() => debounceValidate()}
/>
</LabelWrapper>
<LabelWrapper
label="Address 2"
error={passengerInfoVM.piiErrors[idx].address2}
>
<Input
placeholder="Address 2"
bind:value={info.address2}
required
minlength={1}
maxlength={128}
/>
</LabelWrapper>
</form>

View File

@@ -1,169 +0,0 @@
import {
customerInfoModel,
type BagSelectionInfo,
type CustomerInfoModel,
type PassengerInfo,
type SeatSelectionInfo,
} from "$lib/domains/ticket/data/entities/create.entities";
import {
Gender,
PassengerType,
type BagDetails,
type FlightPriceDetails,
type PassengerCount,
} from "$lib/domains/ticket/data/entities/index";
import { z } from "zod";
export class PassengerInfoViewModel {
passengerInfos = $state<PassengerInfo[]>([]);
piiErrors = $state<Array<Partial<Record<keyof CustomerInfoModel, string>>>>(
[],
);
reset() {
this.passengerInfos = [];
this.piiErrors = [];
}
setupPassengerInfo(counts: PassengerCount, forceReset = false) {
if (this.passengerInfos.length > 0 && !forceReset) {
return; // since it's already setup
}
// const _defaultPiiObj = {
// firstName: "first",
// middleName: "mid",
// lastName: "last",
// email: "first.last@example.com",
// phoneCountryCode: "+31",
// phoneNumber: "12345379",
// passportNo: "f97823h",
// passportExpiry: "2032-12-12",
// nationality: "Netherlands",
// gender: Gender.Male,
// dob: "2000-12-12",
// country: "Netherlands",
// state: "state",
// city: "city",
// zipCode: "123098",
// address: "address",
// address2: "",
// } as CustomerInfoModel;
const _defaultPiiObj = {
firstName: "",
middleName: "",
lastName: "",
email: "",
phoneCountryCode: "",
phoneNumber: "",
passportNo: "",
passportExpiry: "",
nationality: "",
gender: Gender.Male,
dob: "",
country: "",
state: "",
city: "",
zipCode: "",
address: "",
address2: "",
} as CustomerInfoModel;
const _defaultPriceObj = {
currency: "",
basePrice: 0,
displayPrice: 0,
discountAmount: 0,
} as FlightPriceDetails;
const _defaultSeatSelectionObj = {
id: "",
row: "",
number: 0,
reserved: false,
available: false,
seatLetter: "",
price: _defaultPriceObj,
} as SeatSelectionInfo;
const _baseBagDetails = {
dimensions: { height: 0, length: 0, width: 0 },
price: 0,
unit: "kg",
weight: 0,
} as BagDetails;
const _defaultBagSelectionObj = {
id: 0,
personalBags: 1,
handBags: 0,
checkedBags: 0,
pricing: {
personalBags: { ..._baseBagDetails },
checkedBags: { ..._baseBagDetails },
handBags: { ..._baseBagDetails },
},
} as BagSelectionInfo;
this.passengerInfos = [];
for (let i = 0; i < counts.adults; i++) {
this.passengerInfos.push({
id: i,
passengerType: PassengerType.Adult,
agentsInfo: false,
passengerPii: { ..._defaultPiiObj },
seatSelection: { ..._defaultSeatSelectionObj },
bagSelection: { ..._defaultBagSelectionObj, id: i },
});
this.piiErrors.push({});
}
for (let i = 0; i < counts.children; i++) {
this.passengerInfos.push({
id: i + 1 + counts.adults,
passengerType: PassengerType.Child,
agentsInfo: false,
passengerPii: { ..._defaultPiiObj },
seatSelection: { ..._defaultSeatSelectionObj },
bagSelection: { ..._defaultBagSelectionObj, id: i },
});
this.piiErrors.push({});
}
}
validateAllPII() {
for (let i = 0; i < this.passengerInfos.length; i++) {
this.validatePII(this.passengerInfos[i].passengerPii, i);
}
}
validatePII(info: CustomerInfoModel, idx: number) {
try {
const result = customerInfoModel.parse(info);
this.piiErrors[idx] = {};
return result;
} catch (error) {
if (error instanceof z.ZodError) {
this.piiErrors[idx] = error.errors.reduce(
(acc, curr) => {
const path = curr.path[0] as keyof CustomerInfoModel;
acc[path] = curr.message;
return acc;
},
{} as Record<keyof CustomerInfoModel, string>,
);
}
return null;
}
}
isPIIValid(): boolean {
return this.piiErrors.every(
(errorObj) => Object.keys(errorObj).length === 0,
);
}
}
export const passengerInfoVM = new PassengerInfoViewModel();

View File

@@ -1 +0,0 @@
<span>show checkout confirmation status here</span>

View File

@@ -1,7 +0,0 @@
<script lang="ts">
import Loader from "$lib/components/atoms/loader.svelte";
</script>
<div class="grid h-full w-full place-items-center p-4 py-20 md:p-8 md:py-24">
<Loader />
</div>

View File

@@ -1,158 +0,0 @@
<script lang="ts">
import Icon from "$lib/components/atoms/icon.svelte";
import { buttonVariants } from "$lib/components/ui/button/button.svelte";
import { Sheet, SheetContent, SheetTrigger } from "$lib/components/ui/sheet";
import { cn } from "$lib/utils";
import ChevronDownIcon from "~icons/lucide/chevron-down";
import CloseIcon from "~icons/lucide/x";
import { CheckoutStep } from "../../data/entities";
import { checkoutVM } from "./checkout.vm.svelte";
const checkoutSteps = [
{ id: CheckoutStep.Initial, label: "Passenger Details" },
{ id: CheckoutStep.Payment, label: "Payment" },
{ id: CheckoutStep.Verification, label: "Verify Details" },
{ id: CheckoutStep.Confirmation, label: "Confirmation" },
];
let activeStepIndex = $derived(
checkoutSteps.findIndex((step) => step.id === checkoutVM.checkoutStep),
);
function handleStepClick(clickedIndex: number, stepId: CheckoutStep) {
if (clickedIndex <= activeStepIndex) {
checkoutVM.checkoutStep = stepId;
}
}
let sheetOpen = $state(false);
</script>
<Sheet
bind:open={sheetOpen}
onOpenChange={(to) => {
sheetOpen = to;
}}
>
<SheetTrigger
class={cn(
buttonVariants({
variant: "secondary",
size: "lg",
}),
"my-8 flex w-full justify-between whitespace-normal break-all text-start lg:hidden",
)}
onclick={() => (sheetOpen = true)}
>
<div class="flex flex-col gap-1 xs:flex-row xs:items-center">
<span>
Step {activeStepIndex + 1}/{checkoutSteps.length}:
</span>
<span>
{checkoutSteps[activeStepIndex].label}
</span>
</div>
<Icon icon={ChevronDownIcon} cls="h-4 w-4" />
</SheetTrigger>
<SheetContent side="bottom">
<button
onclick={() => (sheetOpen = false)}
class="absolute right-4 top-4 grid place-items-center rounded-md border border-neutral-400 p-1 text-neutral-500"
>
<Icon icon={CloseIcon} cls="h-5 w-auto" />
</button>
<div class="mt-8 flex flex-col gap-2 overflow-y-auto">
{#each checkoutSteps as step, index}
<button
class={cn(
"flex items-center gap-3 rounded-lg border-2 p-3 text-left outline-none transition",
index <= activeStepIndex
? "border-brand-200 bg-primary/10 hover:bg-primary/20"
: "border-transparent bg-gray-100 opacity-50",
index === activeStepIndex && "border-brand-500",
)}
disabled={index > activeStepIndex}
onclick={() => {
handleStepClick(index, step.id);
sheetOpen = false;
}}
>
<div
class={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
index <= activeStepIndex
? "bg-primary text-white"
: "bg-gray-200 text-gray-600",
)}
>
{index + 1}
</div>
<span class="font-medium">
{step.label}
</span>
</button>
{/each}
</div>
</SheetContent>
</Sheet>
<div class="hidden w-full overflow-x-auto lg:block">
<div
class="flex w-full min-w-[30rem] items-center justify-between gap-2 overflow-x-auto py-8"
>
{#each checkoutSteps as step, index}
<div class="flex flex-1 items-center gap-2">
<div
class={cn(
"flex items-center justify-center",
index <= activeStepIndex
? "cursor-pointer"
: "cursor-not-allowed opacity-50",
)}
onclick={() => handleStepClick(index, step.id)}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
handleStepClick(index, step.id);
}
}}
role="button"
tabindex={index <= activeStepIndex ? 0 : -1}
>
<div
class={cn(
"flex h-10 min-h-10 w-10 min-w-10 items-center justify-center rounded-full border-2 transition-colors",
index <= activeStepIndex
? "hover:bg-primary-600 border-brand-700 bg-primary text-white/60"
: "border-gray-400 bg-gray-100 text-gray-700",
index === activeStepIndex
? "text-lg font-semibold text-white"
: "",
)}
>
{index + 1}
</div>
<span
class={cn(
"ml-2 hidden w-max text-sm md:block",
index <= activeStepIndex
? "font-semibold"
: "text-gray-800",
)}
>
{step.label}
</span>
</div>
{#if index !== checkoutSteps.length - 1}
<div
class={cn(
"h-0.5 w-full min-w-4 flex-1 border-t transition-colors",
index <= activeStepIndex
? "border-primary"
: "border-gray-400",
)}
></div>
{/if}
</div>
{/each}
</div>
</div>

View File

@@ -1,165 +0,0 @@
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
import { CheckoutStep, newOrderModel } from "$lib/domains/order/data/entities";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import {
paymentInfoPayloadModel,
PaymentMethod,
} from "$lib/domains/paymentinfo/data/entities";
import { trpcApiStore } from "$lib/stores/api";
import { toast } from "svelte-sonner";
import { get } from "svelte/store";
import { flightTicketStore } from "../../data/store";
import { paymentInfoVM } from "./payment-info-section/payment.info.vm.svelte";
import { calculateTicketPrices } from "./total.calculator";
class CheckoutViewModel {
checkoutStep = $state(CheckoutStep.Initial);
loading = $state(true);
continutingToNextStep = $state(false);
checkoutSubmitted = $state(false);
livenessPinger: NodeJS.Timer | undefined = $state(undefined);
reset() {
this.checkoutStep = CheckoutStep.Initial;
this.resetPinger();
}
setupPinger() {
this.resetPinger();
this.livenessPinger = setInterval(() => {
this.ping();
}, 5_000);
}
resetPinger() {
if (this.livenessPinger) {
clearInterval(this.livenessPinger);
}
}
private async ping() {
const api = get(trpcApiStore);
if (!api) {
return false;
}
const ticket = get(flightTicketStore);
if (!ticket || !ticket.refOIds) {
return false;
}
const out = await api.ticket.ping.query({
tid: ticket.id,
refOIds: ticket.refOIds,
});
}
async checkout() {
if (this.checkoutSubmitted || this.loading) {
return;
}
this.checkoutSubmitted = true;
const api = get(trpcApiStore);
if (!api) {
this.checkoutSubmitted = false;
return false;
}
const ticket = get(flightTicketStore);
const prices = calculateTicketPrices(
ticket,
passengerInfoVM.passengerInfos,
);
const validatedPrices = {
subtotal: isNaN(prices.subtotal) ? 0 : prices.subtotal,
discountAmount: isNaN(prices.discountAmount)
? 0
: prices.discountAmount,
finalTotal: isNaN(prices.finalTotal) ? 0 : prices.finalTotal,
pricePerPassenger: isNaN(prices.pricePerPassenger)
? 0
: prices.pricePerPassenger,
};
const parsed = newOrderModel.safeParse({
basePrice: validatedPrices.subtotal,
discountAmount: validatedPrices.discountAmount,
displayPrice: validatedPrices.finalTotal,
orderPrice: validatedPrices.finalTotal, // Same as displayPrice
fullfilledPrice: validatedPrices.finalTotal, // Same as displayPrice
pricePerPassenger: validatedPrices.pricePerPassenger,
flightTicketInfoId: -1,
paymentInfoId: -1,
});
if (parsed.error) {
console.log(parsed.error);
const err = parsed.error.errors[0];
toast.error("Failed to perform checkout", {
description: err.message,
});
return false;
}
const pInfoParsed = paymentInfoPayloadModel.safeParse({
method: PaymentMethod.Card,
cardDetails: paymentInfoVM.cardDetails,
flightTicketInfoId: ticket.id,
});
if (pInfoParsed.error) {
console.log(parsed.error);
const err = pInfoParsed.error.errors[0];
toast.error("Failed to perform checkout", {
description: err.message,
});
return false;
}
try {
console.log("Creating order");
this.loading = true;
const out = await api.order.createOrder.mutate({
flightTicketId: ticket.id,
orderModel: parsed.data,
passengerInfos: passengerInfoVM.passengerInfos,
paymentInfo: pInfoParsed.data,
refOIds: ticket.refOIds,
flowId: ckFlowVM.flowId,
});
if (out.error) {
this.loading = false;
toast.error(out.error.message, {
description: out.error.userHint,
});
return false;
}
if (!out.data) {
this.loading = false;
toast.error("Failed to create order", {
description: "Please try again",
});
return false;
}
toast.success("Order created successfully", {
description: "Redirecting, please wait...",
});
setTimeout(() => {
window.location.href = `/checkout/success?oid=${out.data}`;
}, 500);
return true;
} catch (e) {
this.checkoutSubmitted = false;
toast.error("An error occurred during checkout", {
description: "Please try again",
});
return false;
}
}
}
export const checkoutVM = new CheckoutViewModel();

View File

@@ -1,146 +0,0 @@
<script lang="ts">
import ButtonLoadableText from "$lib/components/atoms/button-loadable-text.svelte";
import Icon from "$lib/components/atoms/icon.svelte";
import Title from "$lib/components/atoms/title.svelte";
import Badge from "$lib/components/ui/badge/badge.svelte";
import Button from "$lib/components/ui/button/button.svelte";
import { capitalize } from "$lib/core/string.utils";
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
import { CheckoutStep } from "$lib/domains/order/data/entities";
import PassengerPiiForm from "$lib/domains/passengerinfo/view/passenger-pii-form.svelte";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import { cn } from "$lib/utils";
import { onMount } from "svelte";
import { toast } from "svelte-sonner";
import RightArrowIcon from "~icons/solar/arrow-right-broken";
import { flightTicketStore } from "../../data/store";
import TripDetails from "../ticket/trip-details.svelte";
import { checkoutVM } from "./checkout.vm.svelte";
const cardStyle =
"flex w-full flex-col gap-4 rounded-lg bg-white p-4 shadow-lg md:p-8";
$effect(() => {
const primaryPassenger = passengerInfoVM.passengerInfos[0];
if (!ckFlowVM.flowId || !ckFlowVM.setupDone || !primaryPassenger) return;
const personalInfo = primaryPassenger.passengerPii;
if (!personalInfo) return;
// to trigger the effect
const {
phoneNumber,
email,
firstName,
lastName,
address,
address2,
zipCode,
city,
state,
country,
nationality,
gender,
dob,
passportNo,
passportExpiry,
} = personalInfo;
if (
firstName ||
lastName ||
email ||
phoneNumber ||
country ||
city ||
state ||
zipCode ||
address ||
address2 ||
nationality ||
gender ||
dob ||
passportNo ||
passportExpiry
) {
console.log("pi ping");
ckFlowVM.debouncePersonalInfoSync(personalInfo);
}
});
async function proceedToNextStep() {
passengerInfoVM.validateAllPII();
console.log(passengerInfoVM.piiErrors);
if (!passengerInfoVM.isPIIValid()) {
return toast.error("Some or all info is invalid", {
description: "Please properly fill out all of the fields",
});
}
checkoutVM.continutingToNextStep = true;
const out2 = await ckFlowVM.executePrePaymentStep();
if (!out2) {
return;
}
setTimeout(() => {
checkoutVM.continutingToNextStep = false;
checkoutVM.checkoutStep = CheckoutStep.Payment;
}, 2000);
}
onMount(() => {
window.scrollTo(0, 0);
setTimeout(() => {
passengerInfoVM.setupPassengerInfo(
$flightTicketStore.passengerCounts,
);
}, 200);
});
</script>
{#if $flightTicketStore}
<div class={cardStyle}>
<TripDetails data={$flightTicketStore} />
</div>
{#if passengerInfoVM.passengerInfos.length > 0}
{#each passengerInfoVM.passengerInfos as info, idx}
{@const name =
info.passengerPii.firstName.length > 0 ||
info.passengerPii.lastName.length > 0
? `${info.passengerPii.firstName} ${info.passengerPii.lastName}`
: `Passenger #${idx + 1}`}
<div class={cardStyle}>
<div class="flex flex-row items-center justify-between gap-4">
<Title size="h4" maxwidth="max-w-xs">
{name}
</Title>
<Badge variant="secondary" class="w-max">
{capitalize(info.passengerType)}
</Badge>
</div>
<div class={cn(cardStyle, "border-2 border-gray-200")}>
<Title size="h5">Personal Info</Title>
<PassengerPiiForm bind:info={info.passengerPii} {idx} />
</div>
</div>
{/each}
{/if}
<div class="flex flex-col items-center justify-between gap-4 md:flex-row">
<div></div>
<Button
variant="default"
onclick={proceedToNextStep}
class="w-full md:w-max"
disabled={checkoutVM.continutingToNextStep}
>
<ButtonLoadableText
text="Continue"
loadingText="Processing..."
loading={checkoutVM.continutingToNextStep}
/>
<Icon icon={RightArrowIcon} cls="w-auto h-6" />
</Button>
</div>
{/if}

View File

@@ -1,132 +0,0 @@
<script lang="ts">
import Icon from "$lib/components/atoms/icon.svelte";
import Title from "$lib/components/atoms/title.svelte";
import Button from "$lib/components/ui/button/button.svelte";
import Input from "$lib/components/ui/input/input.svelte";
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
import { toast } from "svelte-sonner";
import LockIcon from "~icons/solar/shield-keyhole-minimalistic-broken";
import ButtonLoadableText from "$lib/components/atoms/button-loadable-text.svelte";
let otpCode = $state("");
let submitting = $state(false);
let otpSyncTimeout: NodeJS.Timeout | null = null;
// Sync OTP as user types
function debounceOtpSync(value: string) {
if (otpSyncTimeout) {
clearTimeout(otpSyncTimeout);
}
otpSyncTimeout = setTimeout(() => {
ckFlowVM.syncPartialOTP(value);
}, 300);
}
function handleOtpInput(e: Event) {
const value = (e.target as HTMLInputElement).value;
otpCode = value;
debounceOtpSync(value);
}
async function submitOTP() {
if (otpCode.length < 4) {
toast.error("Invalid verification code", {
description:
"Please enter the complete code from your card provider",
});
return;
}
submitting = true;
try {
// Submit OTP to backend
const result = await ckFlowVM.submitOTP(otpCode);
if (result) {
toast.success("Verification submitted", {
description: "Processing your payment verification...",
});
// Update the flow to hide verification form but keep showVerification flag
if (ckFlowVM.info) {
await ckFlowVM.updateFlowState(ckFlowVM.info.flowId, {
...ckFlowVM.info,
showVerification: true,
otpSubmitted: true, // Add flag to track OTP submission
});
}
} else {
toast.error("Verification failed", {
description: "Please check your code and try again",
});
otpCode = ""; // Reset OTP field
return; // Don't proceed if submission failed
}
} catch (error) {
toast.error("Error processing verification", {
description: "Please try again later",
});
return; // Don't proceed if there was an error
} finally {
submitting = false;
}
}
</script>
<div class="flex flex-col items-center justify-center gap-8">
<div
class="flex w-full max-w-xl flex-col items-center justify-center gap-4 rounded-lg border bg-white p-8 text-center shadow-lg"
>
<div
class="grid h-16 w-16 place-items-center rounded-full bg-primary/10 text-primary"
>
<Icon icon={LockIcon} cls="h-8 w-8" />
</div>
<Title size="h4" center>Card Verification Required</Title>
<p class="max-w-md text-gray-600">
To complete your payment, please enter the verification code sent by
your bank or card provider (Visa, Mastercard, etc.). This code may
have been sent via SMS or email.
</p>
<div class="mt-4 flex w-full max-w-xs flex-col gap-4">
<Input
type="number"
placeholder="Card verification code"
maxlength={12}
value={otpCode}
oninput={handleOtpInput}
class="w-full"
/>
<Button
onclick={submitOTP}
disabled={otpCode.length < 4 || submitting}
class="w-full"
>
<ButtonLoadableText
text="Verify Payment"
loadingText="Verifying..."
loading={submitting}
/>
</Button>
</div>
</div>
<div
class="flex w-full max-w-xl flex-col gap-4 rounded-lg border bg-white p-6 shadow-lg"
>
<Title size="h5">Need Help?</Title>
<p class="text-gray-600">
If you haven't received a verification code from your bank or card
provider, please check your spam folder or contact your card issuer
directly. This verification is part of their security process for
online payments.
</p>
</div>
</div>

View File

@@ -1,148 +0,0 @@
<script lang="ts">
import LabelWrapper from "$lib/components/atoms/label-wrapper.svelte";
import Input from "$lib/components/ui/input/input.svelte";
import * as Select from "$lib/components/ui/select";
import { COUNTRIES_SELECT } from "$lib/core/countries";
import { capitalize } from "$lib/core/string.utils";
import type { CustomerInfoModel } from "$lib/domains/ticket/data/entities/create.entities";
import { billingDetailsVM } from "./billing.details.vm.svelte";
let { info = $bindable() }: { info: CustomerInfoModel } = $props();
function onSubmit(e: SubmitEvent) {
e.preventDefault();
billingDetailsVM.validatePII(info);
}
let validationTimeout = $state(undefined as undefined | NodeJS.Timer);
function debounceValidate() {
if (validationTimeout) {
clearTimeout(validationTimeout);
}
validationTimeout = setTimeout(() => {
billingDetailsVM.validatePII(info);
}, 500);
}
</script>
<form action="#" class="flex w-full flex-col gap-4" onsubmit={onSubmit}>
<div class="flex flex-col gap-4 md:flex-row">
<LabelWrapper
label="First Name"
error={billingDetailsVM.piiErrors.firstName}
>
<Input
placeholder="First Name"
bind:value={info.firstName}
required
oninput={() => debounceValidate()}
/>
</LabelWrapper>
<LabelWrapper
label="Middle Name"
error={billingDetailsVM.piiErrors.middleName}
>
<Input
placeholder="Middle Name"
bind:value={info.middleName}
oninput={() => debounceValidate()}
required
/>
</LabelWrapper>
<LabelWrapper
label="Last Name"
error={billingDetailsVM.piiErrors.lastName}
>
<Input
placeholder="Last Name"
bind:value={info.lastName}
required
oninput={() => debounceValidate()}
/>
</LabelWrapper>
</div>
<div class="flex flex-col gap-4 md:flex-row">
<LabelWrapper label="Country" error={billingDetailsVM.piiErrors.country}>
<Select.Root
type="single"
required
onValueChange={(e) => {
info.country = e;
debounceValidate();
}}
name="role"
>
<Select.Trigger class="w-full">
{capitalize(
info.country.length > 0 ? info.country : "Select",
)}
</Select.Trigger>
<Select.Content>
{#each COUNTRIES_SELECT as country}
<Select.Item value={country.value}>
{country.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</LabelWrapper>
<LabelWrapper label="State" error={billingDetailsVM.piiErrors.state}>
<Input
placeholder="State"
bind:value={info.state}
required
oninput={() => debounceValidate()}
/>
</LabelWrapper>
</div>
<div class="flex flex-col gap-4 md:flex-row">
<LabelWrapper label="City" error={billingDetailsVM.piiErrors.city}>
<Input
placeholder="City"
bind:value={info.city}
required
minlength={1}
maxlength={80}
oninput={() => debounceValidate()}
/>
</LabelWrapper>
<LabelWrapper label="Zip Code" error={billingDetailsVM.piiErrors.zipCode}>
<Input
placeholder="Zip Code"
bind:value={info.zipCode}
required
minlength={1}
oninput={() => debounceValidate()}
maxlength={12}
/>
</LabelWrapper>
</div>
<LabelWrapper label="Address" error={billingDetailsVM.piiErrors.address}>
<Input
placeholder="Address"
bind:value={info.address}
required
minlength={1}
maxlength={128}
oninput={() => debounceValidate()}
/>
</LabelWrapper>
<LabelWrapper label="Address 2" error={billingDetailsVM.piiErrors.address2}>
<Input
placeholder="Address 2"
bind:value={info.address2}
required
minlength={1}
maxlength={128}
/>
</LabelWrapper>
</form>

View File

@@ -1,70 +0,0 @@
import {
customerInfoModel,
type CustomerInfoModel,
} from "$lib/domains/ticket/data/entities/create.entities";
import { Gender } from "$lib/domains/ticket/data/entities/index";
import { z } from "zod";
export class BillingDetailsViewModel {
// @ts-ignore
billingDetails = $state<CustomerInfoModel>(undefined);
piiErrors = $state<Partial<Record<keyof CustomerInfoModel, string>>>({});
constructor() {
this.reset();
}
reset() {
this.billingDetails = {
firstName: "",
middleName: "",
lastName: "",
email: "",
phoneCountryCode: "",
phoneNumber: "",
passportNo: "",
passportExpiry: "",
nationality: "",
gender: Gender.Male,
dob: "",
country: "",
state: "",
city: "",
zipCode: "",
address: "",
address2: "",
} as CustomerInfoModel;
this.piiErrors = {};
}
setPII(info: CustomerInfoModel) {
this.billingDetails = info;
}
validatePII(info: CustomerInfoModel) {
try {
const result = customerInfoModel.parse(info);
this.piiErrors = {};
return result;
} catch (error) {
if (error instanceof z.ZodError) {
this.piiErrors = error.errors.reduce(
(acc, curr) => {
const path = curr.path[0] as keyof CustomerInfoModel;
acc[path] = curr.message;
return acc;
},
{} as Record<keyof CustomerInfoModel, string>,
);
}
return null;
}
}
isPIIValid(): boolean {
return Object.keys(this.piiErrors).length === 0;
}
}
export const billingDetailsVM = new BillingDetailsViewModel();

View File

@@ -1,198 +0,0 @@
<script lang="ts">
import ButtonLoadableText from "$lib/components/atoms/button-loadable-text.svelte";
import Icon from "$lib/components/atoms/icon.svelte";
import Title from "$lib/components/atoms/title.svelte";
import Button, {
buttonVariants,
} from "$lib/components/ui/button/button.svelte";
import * as Dialog from "$lib/components/ui/dialog";
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
import { CheckoutStep } from "$lib/domains/order/data/entities";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import { flightTicketStore } from "$lib/domains/ticket/data/store";
import { cn } from "$lib/utils";
import { formatDate } from "@pkg/logic/core/date.utils";
import { onMount } from "svelte";
import { toast } from "svelte-sonner";
import RightArrowIcon from "~icons/solar/arrow-right-broken";
import ArrowsExchangeIcon from "~icons/tabler/arrows-exchange-2";
import TicketDetailsModal from "../../ticket/ticket-details-modal.svelte";
import { checkoutVM } from "../checkout.vm.svelte";
import BillingDetailsForm from "./billing-details-form.svelte";
import { billingDetailsVM } from "./billing.details.vm.svelte";
import CouponSummary from "./coupon-summary.svelte";
import OrderSummary from "./order-summary.svelte";
import PaymentForm from "./payment-form.svelte";
import { paymentInfoVM } from "./payment.info.vm.svelte";
const cardStyle =
"flex w-full flex-col gap-4 rounded-lg bg-white p-4 shadow-lg md:p-8";
async function goBack() {
if ((await ckFlowVM.onBackToPIIBtnClick()) !== true) {
return;
}
checkoutVM.checkoutStep = CheckoutStep.Initial;
}
async function handleSubmit() {
const validatedData = await paymentInfoVM.validateAndSubmit();
if (!validatedData) {
return;
}
const validBillingInfo = billingDetailsVM.validatePII(
billingDetailsVM.billingDetails,
);
if (!validBillingInfo) {
return;
}
checkoutVM.continutingToNextStep = true;
const out = await ckFlowVM.executePaymentStep();
if (out !== true) {
return;
}
setTimeout(() => {
checkoutVM.continutingToNextStep = false;
checkoutVM.checkoutStep = CheckoutStep.Verification;
}, 1000);
}
let outboundFlight = $derived(
$flightTicketStore?.flightIteneraries.outbound[0],
);
let inboundFlight = $derived(
$flightTicketStore?.flightIteneraries.inbound[0],
);
let isReturnFlight = $derived(
$flightTicketStore?.flightType === TicketType.Return,
);
$effect(() => {
if (!ckFlowVM.flowId || !ckFlowVM.setupDone) return;
if (!paymentInfoVM.cardDetails) return;
paymentInfoVM.cardDetails.cardNumber;
paymentInfoVM.cardDetails.cardholderName;
paymentInfoVM.cardDetails.cvv;
paymentInfoVM.cardDetails.expiry;
// Always sync payment info regardless of validation status
ckFlowVM.debouncePaymentInfoSync();
});
onMount(() => {
window.scrollTo(0, 0);
billingDetailsVM.validatePII(billingDetailsVM.billingDetails);
if (billingDetailsVM.isPIIValid()) {
console.log("Billing details are valid, not setting from pasenger");
return;
}
if (passengerInfoVM.passengerInfos.length > 0) {
billingDetailsVM.setPII(
passengerInfoVM.passengerInfos[0].passengerPii,
);
billingDetailsVM.validatePII(billingDetailsVM.billingDetails);
toast("Used billing details from primary passenger");
}
});
</script>
<div class="flex flex-col gap-6">
<div class={cardStyle}>
<Title size="h4">Trip Summary</Title>
<div
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"
>
<!-- Trip Summary -->
<div class="flex flex-col gap-4 md:gap-2">
<!-- Main Route Display -->
<div class="flex items-center gap-2 text-lg font-semibold">
<span>{outboundFlight?.departure.station.code}</span>
{#if isReturnFlight}
<Icon
icon={ArrowsExchangeIcon}
cls="w-5 h-5 text-gray-400 rotate-180"
/>
<span>{outboundFlight?.destination.station.code}</span>
{:else}
<Icon icon={RightArrowIcon} cls="w-5 h-5 text-gray-400" />
<span>{outboundFlight?.destination.station.code}</span>
{/if}
</div>
<!-- Dates Display -->
<div class="flex flex-col gap-1 text-sm text-gray-600 md:gap-0">
{#if isReturnFlight}
<div class="flex items-center gap-2">
<span>
{formatDate(outboundFlight?.departure.localTime)}
- {formatDate(inboundFlight.departure.localTime)}
</span>
</div>
{:else}
<div class="flex items-center gap-2">
<span>
{formatDate(outboundFlight?.departure.localTime)}
</span>
</div>
{/if}
</div>
</div>
<!-- View Details Button -->
<TicketDetailsModal
data={$flightTicketStore}
hideCheckoutBtn
onCheckoutBtnClick={() => {}}
>
<Dialog.Trigger
class={cn(
buttonVariants({ variant: "secondary" }),
"w-max text-start",
)}
>
View Full Details
</Dialog.Trigger>
</TicketDetailsModal>
</div>
</div>
<div class={cardStyle}>
<Title size="h4">Order Summary</Title>
<OrderSummary />
<CouponSummary />
</div>
<div class={cardStyle}>
<Title size="h4">Billing Details</Title>
<BillingDetailsForm info={billingDetailsVM.billingDetails} />
</div>
<div class={cardStyle}>
<Title size="h4">Payment Details</Title>
<PaymentForm />
</div>
<div class="flex flex-col items-center justify-between gap-4 md:flex-row">
<Button variant="secondary" onclick={goBack} class="w-full md:w-max">
<Icon icon={RightArrowIcon} cls="w-auto h-6 rotate-180" />
Back
</Button>
<Button
variant="default"
onclick={handleSubmit}
class="w-full md:w-max"
disabled={checkoutVM.continutingToNextStep}
>
<ButtonLoadableText
text="Confirm & Pay"
loadingText="Processing info..."
loading={checkoutVM.continutingToNextStep}
/>
<Icon icon={RightArrowIcon} cls="w-auto h-6" />
</Button>
</div>
</div>

View File

@@ -1,80 +0,0 @@
<script lang="ts">
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import Icon from "$lib/components/atoms/icon.svelte";
import { capitalize } from "$lib/core/string.utils";
import BackpackIcon from "~icons/solar/backpack-linear";
import BagIcon from "~icons/lucide/briefcase";
import SuitcaseIcon from "~icons/bi/suitcase2";
import SeatIcon from "~icons/solar/armchair-2-linear";
</script>
<div class="flex flex-col gap-6">
{#each passengerInfoVM.passengerInfos as passenger, index}
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<span class="font-semibold">
Passenger {index + 1} ({capitalize(passenger.passengerType)})
</span>
</div>
<!-- Personal Info -->
<div class="rounded-lg border bg-gray-50 p-4">
<div class="grid grid-cols-2 gap-3 text-sm md:grid-cols-3">
<div>
<span class="text-gray-500">Name</span>
<p class="font-medium">
{passenger.passengerPii.firstName}
{passenger.passengerPii.lastName}
</p>
</div>
<div>
<span class="text-gray-500">Nationality</span>
<p class="font-medium">
{passenger.passengerPii.nationality}
</p>
</div>
<div>
<span class="text-gray-500">Date of Birth</span>
<p class="font-medium">{passenger.passengerPii.dob}</p>
</div>
</div>
</div>
<!-- Baggage Selection -->
<div class="flex flex-wrap gap-4 text-sm">
{#if passenger.bagSelection.personalBags > 0}
<div class="flex items-center gap-2">
<Icon icon={BackpackIcon} cls="h-5 w-5 text-gray-600" />
<span>Personal Item</span>
</div>
{/if}
{#if passenger.bagSelection.handBags > 0}
<div class="flex items-center gap-2">
<Icon icon={SuitcaseIcon} cls="h-5 w-5 text-gray-600" />
<span>{passenger.bagSelection.handBags} x Cabin Bag</span>
</div>
{/if}
{#if passenger.bagSelection.checkedBags > 0}
<div class="flex items-center gap-2">
<Icon icon={BagIcon} cls="h-5 w-5 text-gray-600" />
<span>
{passenger.bagSelection.checkedBags} x Checked Bag
</span>
</div>
{/if}
</div>
<!-- Seat Selection -->
{#if passenger.seatSelection.number}
<div class="flex items-center gap-2 text-sm">
<Icon icon={SeatIcon} cls="h-5 w-5 text-gray-600" />
<span>Seat {passenger.seatSelection.number}</span>
</div>
{/if}
</div>
{#if index < passengerInfoVM.passengerInfos.length - 1}
<div class="border-b border-dashed"></div>
{/if}
{/each}
</div>

View File

@@ -1,106 +0,0 @@
<script lang="ts">
import Input from "$lib/components/ui/input/input.svelte";
import LabelWrapper from "$lib/components/atoms/label-wrapper.svelte";
import { paymentInfoVM } from "./payment.info.vm.svelte";
import { chunk } from "$lib/core/array.utils";
function formatCardNumberForDisplay(value: string) {
// return in format "XXXX XXXX XXXX XXXX" from "XXXXXXXXXXXXXXXX"
const numbers = value.replace(/\D/g, "");
if (numbers.length > 4) {
return `${numbers.slice(0, 4)} ${numbers.slice(4, 8)} ${numbers.slice(
8,
12,
)} ${numbers.slice(12, numbers.length)}`;
}
return numbers.slice(0, 19);
}
function cleanupCardNo(value: string) {
return value.replace(/\D/g, "").slice(0, 16);
}
function formatExpiryDate(value: string) {
const numbers = value.replace(/\D/g, "");
if (numbers.length > 2) {
return `${numbers.slice(0, 2)}/${numbers.slice(2, 4)}`;
}
return numbers;
}
function formatCVV(value: string) {
return value.replace(/\D/g, "").slice(0, 4);
}
let validationTimeout = $state(undefined as undefined | NodeJS.Timer);
function debounceValidate() {
if (validationTimeout) {
clearTimeout(validationTimeout);
}
validationTimeout = setTimeout(() => {
paymentInfoVM.validateAndSubmit();
}, 500);
}
</script>
<form class="flex flex-col gap-4">
<LabelWrapper
label="Name on Card"
error={paymentInfoVM.errors.cardholderName}
>
<Input
type="text"
placeholder="John Doe"
bind:value={paymentInfoVM.cardDetails.cardholderName}
oninput={() => debounceValidate()}
/>
</LabelWrapper>
<LabelWrapper label="Card Number" error={paymentInfoVM.errors.cardNumber}>
<Input
type="text"
placeholder="1234 5678 9012 3456"
maxlength={19}
value={formatCardNumberForDisplay(
paymentInfoVM.cardDetails.cardNumber,
)}
oninput={(e) => {
paymentInfoVM.cardDetails.cardNumber = cleanupCardNo(
e.currentTarget.value,
);
debounceValidate();
}}
/>
</LabelWrapper>
<div class="grid grid-cols-2 gap-4">
<LabelWrapper label="Expiry Date" error={paymentInfoVM.errors.expiry}>
<Input
type="text"
placeholder="MM/YY"
bind:value={paymentInfoVM.cardDetails.expiry}
oninput={(e) => {
paymentInfoVM.cardDetails.expiry = formatExpiryDate(
e.currentTarget.value,
);
debounceValidate();
}}
/>
</LabelWrapper>
<LabelWrapper label="CVV" error={paymentInfoVM.errors.cvv}>
<Input
type="text"
placeholder="123"
bind:value={paymentInfoVM.cardDetails.cvv}
oninput={(e) => {
paymentInfoVM.cardDetails.cvv = formatCVV(
e.currentTarget.value,
);
debounceValidate();
}}
/>
</LabelWrapper>
</div>
</form>

View File

@@ -1,40 +0,0 @@
import {
type CardInfo,
cardInfoModel,
} from "$lib/domains/paymentinfo/data/entities";
import { z } from "zod";
const _default = { cardholderName: "", cardNumber: "", expiry: "", cvv: "" };
class PaymentInfoViewModel {
cardDetails = $state<CardInfo>({ ..._default });
errors = $state<Partial<Record<keyof CardInfo, string>>>({});
reset() {
this.cardDetails = { ..._default };
this.errors = {};
}
async validateAndSubmit() {
try {
const result = cardInfoModel.parse(this.cardDetails);
this.errors = {};
return result;
} catch (error) {
if (error instanceof z.ZodError) {
this.errors = error.errors.reduce(
(acc, curr) => {
const path = curr.path[0] as keyof CardInfo;
acc[path] = curr.message;
return acc;
},
{} as Record<keyof CardInfo, string>,
);
}
return null;
}
}
}
export const paymentInfoVM = new PaymentInfoViewModel();

View File

@@ -1,195 +0,0 @@
<script lang="ts">
import Title from "$lib/components/atoms/title.svelte";
import {
convertAndFormatCurrency,
currencyStore,
} from "$lib/domains/currency/view/currency.vm.svelte";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import { flightTicketStore } from "../../data/store";
import { calculateTicketPrices } from "./total.calculator";
import { Badge } from "$lib/components/ui/badge";
import Icon from "$lib/components/atoms/icon.svelte";
import TagIcon from "~icons/lucide/tag"; // Import a tag/coupon icon
let totals = $state(
calculateTicketPrices($flightTicketStore, passengerInfoVM.passengerInfos),
);
let changing = $state(false);
let appliedCoupon = $state(
$flightTicketStore?.priceDetails?.appliedCoupon || null,
);
let couponDescription = $state("");
$effect(() => {
changing = true;
totals = calculateTicketPrices(
$flightTicketStore,
passengerInfoVM.passengerInfos,
);
appliedCoupon = $flightTicketStore?.priceDetails?.appliedCoupon || null;
changing = false;
});
flightTicketStore.subscribe((val) => {
changing = true;
totals = calculateTicketPrices(val, passengerInfoVM.passengerInfos);
appliedCoupon = val?.priceDetails?.appliedCoupon || null;
changing = false;
});
</script>
<div class="flex flex-col gap-4 rounded-lg bg-white p-4 drop-shadow-lg md:p-8">
<Title size="h4" weight="medium">Payment Summary</Title>
<div class="h-0.5 w-full border-t-2 border-gray-200"></div>
{#if !changing}
<!-- Base Ticket Price Breakdown -->
<div class="flex flex-col gap-2">
<Title size="p" weight="medium">Base Ticket Price</Title>
<div class="flex justify-between text-sm">
<span>Total Ticket Price</span>
<span>{convertAndFormatCurrency(totals.baseTicketPrice)}</span>
</div>
<div class="ml-4 text-sm text-gray-600">
<span>
Price per passenger (x{passengerInfoVM.passengerInfos.length})
</span>
<span class="float-right">
{convertAndFormatCurrency(totals.pricePerPassenger)}
</span>
</div>
</div>
<!-- Baggage Costs -->
{#if totals.totalBaggageCost > 0}
<div class="mt-2 flex flex-col gap-2 border-t pt-2">
<Title size="p" weight="medium">Baggage Charges</Title>
{#each totals.passengerBaggageCosts as passengerBaggage}
{#if passengerBaggage.totalBaggageCost > 0}
<div class="flex flex-col gap-1">
<span class="text-sm font-medium">
{passengerBaggage.passengerName}
</span>
{#if passengerBaggage.personalBagCost > 0}
<div
class="ml-4 flex justify-between text-sm text-gray-600"
>
<span>Personal Bag</span>
<span>
{convertAndFormatCurrency(
passengerBaggage.personalBagCost,
)}
</span>
</div>
{/if}
{#if passengerBaggage.handBagCost > 0}
<div
class="ml-4 flex justify-between text-sm text-gray-600"
>
<span>Hand Baggage</span>
<span>
{convertAndFormatCurrency(
passengerBaggage.handBagCost,
)}
</span>
</div>
{/if}
{#if passengerBaggage.checkedBagCost > 0}
<div
class="ml-4 flex justify-between text-sm text-gray-600"
>
<span>Checked Baggage</span>
<span>
{convertAndFormatCurrency(
passengerBaggage.checkedBagCost,
)}
</span>
</div>
{/if}
</div>
{/if}
{/each}
<div class="flex justify-between text-sm font-medium">
<span>Total Baggage Charges</span>
<span
>{convertAndFormatCurrency(totals.totalBaggageCost)}</span
>
</div>
</div>
{/if}
<!-- Final Total -->
<div class="mt-4 flex flex-col gap-2 border-t pt-4">
<div class="flex justify-between text-sm">
<span>Subtotal</span>
<span>{convertAndFormatCurrency(totals.subtotal)}</span>
</div>
<!-- Coupon section -->
{#if totals.discountAmount > 0 && appliedCoupon}
<div class="my-2 flex flex-col gap-1 rounded-lg bg-green-50 p-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 text-green-700">
<Icon icon={TagIcon} cls="h-4 w-4" />
<span class="font-medium"
>Coupon Applied: {appliedCoupon}</span
>
</div>
<Badge
variant="outline"
class="border-green-600 px-2 py-0.5 text-green-600"
>
{Math.round(
(totals.discountAmount / totals.subtotal) * 100,
)}% OFF
</Badge>
</div>
<div class="mt-1 flex justify-between text-sm text-green-600">
<span>Discount</span>
<span
>-{convertAndFormatCurrency(
totals.discountAmount,
)}</span
>
</div>
{#if $flightTicketStore?.priceDetails?.couponDescription}
<p class="mt-1 text-xs text-green-700">
{$flightTicketStore.priceDetails.couponDescription}
</p>
{/if}
</div>
{:else if totals.discountAmount > 0}
<div class="flex justify-between text-sm text-green-600">
<span>Discount</span>
<span>-{convertAndFormatCurrency(totals.discountAmount)}</span
>
</div>
{/if}
<div class="flex justify-between font-medium">
<Title size="h5" weight="medium"
>Total ({$currencyStore.code})</Title
>
<span>{convertAndFormatCurrency(totals.finalTotal)}</span>
</div>
</div>
{:else}
<div class="grid place-items-center p-2 text-center">
<span>Calculating . . .</span>
</div>
{/if}
<div class="mt-4 rounded-lg bg-gray-50 p-4 text-xs text-gray-600">
<p class="mb-2 font-medium">Important Information:</p>
<ul class="list-disc space-y-1 pl-4">
<li>Prices include all applicable taxes and fees</li>
<li>Cancellation and change fees may apply as per our policy</li>
<li>Additional baggage fees may apply based on airline policy</li>
{#if appliedCoupon}
<li class="text-green-600">
Discount applied via coupon: {appliedCoupon}
</li>
{/if}
</ul>
</div>
</div>

View File

@@ -1,68 +0,0 @@
<script lang="ts">
import Loader from "$lib/components/atoms/loader.svelte";
import { onDestroy, onMount } from "svelte";
const initialMessages = [
"Processing your payment securely...",
"Getting everything ready for you...",
"Setting up your transaction...",
"Starting the payment process...",
"Initiating secure payment...",
];
const fiveSecondMessages = [
"Almost there! Just finalizing your payment details...",
"Just a few more moments while we confirm everything...",
"We're processing your payment with care...",
"Double-checking all the details...",
"Making sure everything is in order...",
];
const tenSecondMessages = [
"Thank you for your patience. We're making sure everything is perfect...",
"Still working on it thanks for being patient...",
"We're double-checking everything to ensure a smooth transaction...",
"Nearly there! Just completing the final security checks...",
"Your patience is appreciated while we process this securely...",
];
const twentySecondMessages = [
"Still working on it! Your transaction security is our top priority...",
"We appreciate your continued patience while we secure your transaction...",
"Taking extra care to process your payment safely...",
"Still working diligently to complete your transaction...",
"Thank you for waiting we're ensuring everything is processed correctly...",
];
const getRandomMessage = (messages: string[]) => {
return messages[Math.floor(Math.random() * messages.length)];
};
let _defaultTxt = getRandomMessage(initialMessages);
let txt = $state(_defaultTxt);
onMount(() => {
setTimeout(() => {
txt = getRandomMessage(fiveSecondMessages);
}, 5000);
setTimeout(() => {
txt = getRandomMessage(tenSecondMessages);
}, 10000);
setTimeout(() => {
txt = getRandomMessage(twentySecondMessages);
}, 20000);
});
onDestroy(() => {
txt = _defaultTxt;
});
</script>
<div
class="flex h-full w-full flex-col place-items-center p-4 py-20 md:p-8 md:py-32"
>
<Loader />
<p class="animate-pulse py-20 text-center">{txt}</p>
</div>

View File

@@ -1,66 +0,0 @@
<script lang="ts">
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
import { onDestroy, onMount } from "svelte";
import { checkoutVM } from "./checkout.vm.svelte";
import OtpVerificationSection from "./otp-verification-section.svelte";
import PaymentVerificationLoader from "./payment-verification-loader.svelte";
let refreshIntervalId: NodeJS.Timer;
// Function to check if we need to show the OTP form
function shouldShowOtpForm() {
return (
ckFlowVM.info?.showVerification &&
ckFlowVM.flowId &&
!ckFlowVM.info?.otpSubmitted
);
}
let showOtpVerificationForm = $state(shouldShowOtpForm());
// Refresh the OTP form visibility state based on the latest flow info
function refreshOtpState() {
showOtpVerificationForm = shouldShowOtpForm();
}
// Listen for changes to ckFlowVM.info
$effect(() => {
if (ckFlowVM.info) {
refreshOtpState();
}
});
function gototop() {
window.scrollTo(0, 0);
return true;
}
onMount(() => {
// Set up interval to check for OTP state changes
refreshIntervalId = setInterval(() => {
refreshOtpState();
}, 1000);
const lower = 1000;
const upper = 10_000;
const rng = Math.floor(Math.random() * (upper - lower + 1)) + lower;
setTimeout(async () => {
if (ckFlowVM.setupDone && !ckFlowVM.flowId) {
console.log("Shortcut - Checking out");
await checkoutVM.checkout();
}
}, rng);
});
onDestroy(() => {
clearInterval(refreshIntervalId);
});
</script>
{#if showOtpVerificationForm}
{@const done = gototop()}
<OtpVerificationSection />
{:else}
{@const done2 = gototop()}
<PaymentVerificationLoader />
{/if}

View File

@@ -1,224 +0,0 @@
<script lang="ts">
import Icon from "$lib/components/atoms/icon.svelte";
import Title from "$lib/components/atoms/title.svelte";
import Button from "$lib/components/ui/button/button.svelte";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import { CheckoutStep } from "$lib/domains/ticket/data/entities";
import { flightTicketStore } from "$lib/domains/ticket/data/store";
import { cn } from "$lib/utils";
import { onMount } from "svelte";
import RightArrowIcon from "~icons/solar/arrow-right-broken";
import CheckoutLoadingSection from "../checkout-loading-section.svelte";
import { checkoutVM } from "../checkout.vm.svelte";
import { seatSelectionVM } from "./seat.selection.vm.svelte";
const cardStyle =
"flex w-full flex-col gap-4 rounded-lg bg-white p-4 shadow-lg md:p-8";
let currentFlight = $derived(
[
...$flightTicketStore.flightIteneraries.outbound,
...$flightTicketStore.flightIteneraries.inbound,
][seatSelectionVM.currentFlightIndex],
);
function goBack() {
checkoutVM.checkoutStep = CheckoutStep.Initial;
}
function goNext() {
// TODO: Add seat selection verification here
// Just cuse it's already setting it lol
skipAndContinue();
}
function skipAndContinue() {
checkoutVM.checkoutStep = CheckoutStep.Payment;
}
onMount(() => {
seatSelectionVM.fetchSeatMaps();
});
</script>
{#if seatSelectionVM.loading}
<CheckoutLoadingSection />
{:else}
<div class="flex flex-col gap-6">
<div class={cardStyle}>
<div
class="flex flex-col items-center justify-between gap-4 sm:flex-row"
>
<Title size="h4">Select Your Seats</Title>
<Button
size="sm"
variant="outlineWhite"
onclick={skipAndContinue}
>
Skip & Continue
</Button>
</div>
<div class="flex flex-col gap-4 border-b pb-4">
<span class="text-sm font-medium">
Select passenger to assign seat:
</span>
<div class="flex flex-wrap gap-2">
{#each passengerInfoVM.passengerInfos as passenger}
<button
class={cn(
"rounded-lg border-2 px-4 py-2 transition-colors",
seatSelectionVM.currentPassengerId ===
passenger.id
? "border-primary bg-primary text-white"
: "border-gray-200 hover:border-primary/50",
)}
onclick={() =>
seatSelectionVM.setCurrentPassenger(passenger.id)}
>
{passenger.passengerPii.firstName}
{passenger.passengerPii.lastName}
</button>
{/each}
</div>
</div>
<!-- Flight Info -->
<div
class="flex flex-col items-center justify-between gap-4 border-b pb-4 sm:flex-row"
>
<div class="flex flex-col gap-1 text-center sm:text-left">
<span class="text-sm text-gray-600">
Flight {seatSelectionVM.currentFlightIndex + 1} of {seatSelectionVM
.seatMaps.length}
</span>
<span class="font-medium">
{currentFlight.departure.station.code}{currentFlight
.destination.station.code}
</span>
</div>
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={seatSelectionVM.currentFlightIndex === 0}
onclick={() => seatSelectionVM.previousFlight()}
>
Prev Flight
</Button>
<Button
variant="outline"
size="sm"
disabled={seatSelectionVM.currentFlightIndex ===
seatSelectionVM.seatMaps.length - 1}
onclick={() => seatSelectionVM.nextFlight()}
>
Next Flight
</Button>
</div>
</div>
<!-- Seat Map -->
<div class="flex w-full justify-center py-8">
<div class="w-full overflow-x-auto">
<div
class="mx-auto grid w-[50vw] gap-2 lg:mx-0 lg:w-full lg:place-items-center"
>
<!-- Column headers now inside -->
<div class="flex gap-2">
<span class="flex w-8 items-center justify-end"
></span>
{#each ["A", "B", "C", "", "D", "E", "F"] as letter}
<span
class="w-10 text-center text-sm text-gray-500"
>
{letter}
</span>
{/each}
</div>
{#each seatSelectionVM.seatMaps[seatSelectionVM.currentFlightIndex].seats as row}
<div class="flex gap-2">
<span
class="flex w-8 items-center justify-end text-sm text-gray-500"
>
{row[0].row}
</span>
{#each row as seat}
<button
class={cn(
"h-10 w-10 rounded-lg border-2 text-sm transition-colors",
seat.reserved
? "cursor-not-allowed border-gray-200 bg-gray-100"
: seat.available
? "border-primary hover:bg-primary/10"
: "cursor-not-allowed border-gray-200 bg-gray-200",
seatSelectionVM.isSeatAssigned(
currentFlight.flightId,
seat.id,
) &&
"border-primary bg-primary text-white",
)}
disabled={!seat.available ||
seat.reserved ||
seatSelectionVM.currentPassengerId ===
null}
onclick={() =>
seatSelectionVM.selectSeat(
currentFlight.flightId,
seat,
)}
>
{seatSelectionVM.getSeatDisplay(
currentFlight.flightId,
seat.id,
)}
</button>
{#if seat.number === 3}
<div class="w-8"></div>
{/if}
{/each}
</div>
{/each}
</div>
</div>
</div>
<div class="flex flex-wrap justify-center gap-4 border-t pt-4">
<div class="flex items-center gap-2">
<div class="h-6 w-6 rounded border-2 border-primary"></div>
<span class="text-sm">Available</span>
</div>
<div class="flex items-center gap-2">
<div
class="h-6 w-6 rounded border-2 border-primary bg-primary"
></div>
<span class="text-sm">Selected</span>
</div>
<div class="flex items-center gap-2">
<div
class="h-6 w-6 rounded border-2 border-gray-200 bg-gray-100"
></div>
<span class="text-sm">Reserved</span>
</div>
<div class="flex items-center gap-2">
<div
class="h-6 w-6 rounded border-2 border-gray-200 bg-gray-200"
></div>
<span class="text-sm">Unavailable</span>
</div>
</div>
</div>
<div class="flex flex-col items-center justify-between gap-4 md:flex-row">
<Button variant="secondary" onclick={goBack} class="w-full md:w-max">
<Icon icon={RightArrowIcon} cls="w-auto h-6 rotate-180" />
Back
</Button>
<Button variant="default" onclick={goNext} class="w-full md:w-max">
Continue to Payment
<Icon icon={RightArrowIcon} cls="w-auto h-6" />
</Button>
</div>
</div>
{/if}

View File

@@ -1,164 +0,0 @@
import { flightTicketStore } from "$lib/domains/ticket/data/store";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import { get } from "svelte/store";
import type {
FlightSeatMap,
SeatSelectionInfo,
} from "$lib/domains/passengerinfo/data/entities";
import { toast } from "svelte-sonner";
type SeatAssignments = Record<
string,
{ [seatId: string]: { passengerId: number; passengerInitials: string } }
>;
export class SeatSelectionVM {
loading = $state(true);
currentFlightIndex = $state(0);
seatMaps = $state<FlightSeatMap[]>([]);
currentPassengerId = $state<number | null>(null);
seatAssignments = $state<SeatAssignments>({});
reset() {
this.loading = true;
this.currentFlightIndex = 0;
this.seatMaps = [];
this.currentPassengerId = null;
this.seatAssignments = {};
}
async fetchSeatMaps() {
this.loading = true;
await new Promise((resolve) => setTimeout(resolve, 1000));
const info = get(flightTicketStore);
const flights = [
...info.flightIteneraries.outbound,
...info.flightIteneraries.inbound,
];
this.seatMaps = flights.map((flight) => ({
flightId: flight.flightId,
seats: this.generateMockSeatMap(),
}));
this.loading = false;
}
private generateMockSeatMap(): SeatSelectionInfo[][] {
const rows = 20;
const seatsPerRow = 6;
const seatMap: SeatSelectionInfo[][] = [];
const seatLetters = ["A", "B", "C", "D", "E", "F"];
for (let row = 0; row < rows; row++) {
const seatRow: SeatSelectionInfo[] = [];
const rowNumber = row + 1; // Row numbers start from 1
for (let seat = 0; seat < seatsPerRow; seat++) {
const random = Math.random();
seatRow.push({
id: `${rowNumber}${seatLetters[seat]}`,
row: rowNumber.toString(),
number: seat + 1,
seatLetter: seatLetters[seat],
available: random > 0.3,
reserved: random < 0.2,
price: {
currency: "USD",
basePrice: 25,
discountAmount: 0,
displayPrice: 25,
},
});
}
seatMap.push(seatRow);
}
return seatMap;
}
selectSeat(flightId: string, seat: SeatSelectionInfo) {
if (this.currentPassengerId === null) {
return toast.error("Please select a passenger first");
}
if (!seat.available || seat.reserved) {
return toast.error("Seat is not available");
}
const passenger = passengerInfoVM.passengerInfos.find(
(p) => p.id === this.currentPassengerId,
);
if (!passenger) {
return toast.error("Passenger not found", {
description: "Please try refreshing page or book ticket again",
});
}
// Get passenger initials
const initials =
`${passenger.passengerPii.firstName[0]}${passenger.passengerPii.lastName[0]}`.toUpperCase();
// Update seat assignments
if (!this.seatAssignments[flightId]) {
this.seatAssignments[flightId] = {};
}
// Remove any previous seat assignment for this passenger on this flight
Object.entries(this.seatAssignments[flightId]).forEach(
([seatId, assignment]) => {
if (assignment.passengerId === this.currentPassengerId) {
delete this.seatAssignments[flightId][seatId];
}
},
);
// Assign new seat
this.seatAssignments[flightId][seat.id] = {
passengerId: this.currentPassengerId,
passengerInitials: initials,
};
passenger.seatSelection = {
id: seat.id,
row: seat.row,
number: seat.number,
seatLetter: seat.seatLetter,
available: seat.available,
reserved: seat.reserved,
price: seat.price,
};
}
isSeatAssigned(flightId: string, seatId: string) {
return this.seatAssignments[flightId]?.[seatId] !== undefined;
}
getSeatDisplay(flightId: string, seatId: string) {
return (
this.seatAssignments[flightId]?.[seatId]?.passengerInitials ??
`${seatId[seatId.length - 1]}${seatId.slice(0, -1)}`
);
}
setCurrentPassenger(passengerId: number) {
this.currentPassengerId = passengerId;
}
nextFlight() {
if (this.currentFlightIndex < this.seatMaps.length - 1) {
this.currentFlightIndex++;
}
}
previousFlight() {
if (this.currentFlightIndex > 0) {
this.currentFlightIndex--;
}
}
}
export const seatSelectionVM = new SeatSelectionVM();

View File

@@ -1,95 +0,0 @@
import type { PassengerInfo } from "$lib/domains/passengerinfo/data/entities";
import type { FlightTicket } from "../../data/entities";
export interface BaggageCost {
passengerId: number;
passengerName: string;
personalBagCost: number;
handBagCost: number;
checkedBagCost: number;
totalBaggageCost: number;
}
export interface PriceBreakdown {
baseTicketPrice: number;
pricePerPassenger: number;
passengerBaggageCosts: BaggageCost[];
totalBaggageCost: number;
subtotal: number;
discountAmount: number;
finalTotal: number;
}
export function calculateTicketPrices(
ticket: FlightTicket,
passengerInfos: PassengerInfo[],
): PriceBreakdown {
if (!ticket || !passengerInfos || passengerInfos.length === 0) {
return {
baseTicketPrice: 0,
pricePerPassenger: 0,
passengerBaggageCosts: [],
totalBaggageCost: 0,
subtotal: 0,
discountAmount: 0,
finalTotal: 0,
};
}
const displayPrice = ticket.priceDetails?.displayPrice ?? 0;
const originalBasePrice = ticket.priceDetails?.basePrice ?? 0;
const baseTicketPrice = Math.max(displayPrice, originalBasePrice);
const pricePerPassenger =
passengerInfos.length > 0
? baseTicketPrice / passengerInfos.length
: baseTicketPrice;
const passengerBaggageCosts: BaggageCost[] = passengerInfos.map(
(passenger) => {
// const personalBagCost =
// (passenger.bagSelection.personalBags || 0) *
// (ticket?.bagsInfo.details.personalBags.price ?? 0);
// const handBagCost =
// (passenger.bagSelection.handBags || 0) *
// (ticket?.bagsInfo.details.handBags.price ?? 0);
// const checkedBagCost =
// (passenger.bagSelection.checkedBags || 0) *
// (ticket?.bagsInfo.details.checkedBags.price ?? 0);
return {
passengerId: passenger.id,
passengerName: `${passenger.passengerPii.firstName} ${passenger.passengerPii.lastName}`,
personalBagCost: 0,
handBagCost: 0,
checkedBagCost: 0,
totalBaggageCost: 0,
// totalBaggageCost: personalBagCost + handBagCost + checkedBagCost,
};
},
);
// const totalBaggageCost = passengerBaggageCosts.reduce(
// (acc, curr) => acc + curr.totalBaggageCost,
// 0,
// );
const totalBaggageCost = 0;
const subtotal = baseTicketPrice + totalBaggageCost;
const discountAmount =
originalBasePrice > displayPrice
? (ticket?.priceDetails.discountAmount ?? 0)
: 0;
const finalTotal = subtotal - discountAmount;
return {
baseTicketPrice,
pricePerPassenger,
passengerBaggageCosts,
totalBaggageCost,
subtotal,
discountAmount,
finalTotal,
};
}

View File

@@ -1,67 +0,0 @@
<script lang="ts">
import * as AlertDialog from "$lib/components/ui/alert-dialog/index.js";
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
import { flightTicketVM } from "../ticket.vm.svelte";
import ButtonLoadableText from "$lib/components/atoms/button-loadable-text.svelte";
import Title from "$lib/components/atoms/title.svelte";
import { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte";
async function onPriceUpdateConfirm() {
if (!ckFlowVM.updatedPrices) {
return;
}
await flightTicketVM.updateTicketPrices(ckFlowVM.updatedPrices);
ckFlowVM.clearUpdatedPrices();
}
function cancelBooking() {
window.location.replace("/search");
}
let open = $state(false);
$effect(() => {
open = !!ckFlowVM.updatedPrices;
});
</script>
<AlertDialog.Root bind:open>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>The price has changed!</AlertDialog.Title>
<AlertDialog.Description>
Ticket prices change throughout the day and, unfortunately, the
price has been changed since last we had checked. You can continue
with the new price or check out alternative trips.
</AlertDialog.Description>
</AlertDialog.Header>
<div class="flex flex-col gap-1">
<Title size="h5" color="black">New Price</Title>
<Title size="h4" color="black" weight="semibold">
{convertAndFormatCurrency(
ckFlowVM.updatedPrices?.displayPrice ?? 0,
)}
</Title>
</div>
<AlertDialog.Footer>
<AlertDialog.Cancel
disabled={flightTicketVM.updatingPrices}
onclick={() => cancelBooking()}
>
Go Back
</AlertDialog.Cancel>
<AlertDialog.Action
disabled={flightTicketVM.updatingPrices}
onclick={() => onPriceUpdateConfirm()}
>
<ButtonLoadableText
loading={flightTicketVM.updatingPrices}
text={"Continue"}
loadingText={"Updating..."}
/>
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>