admin side for now | 🔄 started FE

This commit is contained in:
user
2025-10-21 13:11:31 +03:00
parent 3232542de1
commit 5f4e9fc7fc
65 changed files with 3605 additions and 1508 deletions

View File

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

View File

@@ -0,0 +1,7 @@
<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

@@ -0,0 +1,158 @@
<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,5 +1,5 @@
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
import { newOrderModel } from "$lib/domains/order/data/entities";
import { CheckoutStep, newOrderModel } from "$lib/domains/order/data/entities";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import {
paymentInfoPayloadModel,
@@ -8,12 +8,11 @@ import {
import { trpcApiStore } from "$lib/stores/api";
import { toast } from "svelte-sonner";
import { get } from "svelte/store";
import { CheckoutStep } from "../../data/entities/index";
import { flightTicketStore } from "../../data/store";
import { flightTicketStore } from "../ticket/data/store";
import { paymentInfoVM } from "./payment-info-section/payment.info.vm.svelte";
import { calculateTicketPrices } from "./total.calculator";
class TicketCheckoutViewModel {
class CheckoutViewModel {
checkoutStep = $state(CheckoutStep.Initial);
loading = $state(true);
continutingToNextStep = $state(false);
@@ -163,4 +162,4 @@ class TicketCheckoutViewModel {
}
}
export const ticketCheckoutVM = new TicketCheckoutViewModel();
export const checkoutVM = new CheckoutViewModel();

View File

@@ -0,0 +1,152 @@
<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 PassengerBagSelection from "$lib/domains/passengerinfo/view/passenger-bag-selection.svelte";
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 { CheckoutStep } from "../../data/entities";
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 class={cn(cardStyle, "border-2 border-gray-200")}>
<Title size="h5">Bag Selection</Title>
<PassengerBagSelection bind:info={info.bagSelection} />
</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

@@ -0,0 +1,132 @@
<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

@@ -0,0 +1,148 @@
<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 { CustomerInfo } from "$lib/domains/ticket/data/entities/create.entities";
import { billingDetailsVM } from "./billing.details.vm.svelte";
let { info = $bindable() }: { info: CustomerInfo } = $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

@@ -0,0 +1,70 @@
import {
customerInfoModel,
type CustomerInfo,
} 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<CustomerInfo>(undefined);
piiErrors = $state<Partial<Record<keyof CustomerInfo, 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 CustomerInfo;
this.piiErrors = {};
}
setPII(info: CustomerInfo) {
this.billingDetails = info;
}
validatePII(info: CustomerInfo) {
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 CustomerInfo;
acc[path] = curr.message;
return acc;
},
{} as Record<keyof CustomerInfo, string>,
);
}
return null;
}
}
isPIIValid(): boolean {
return Object.keys(this.piiErrors).length === 0;
}
}
export const billingDetailsVM = new BillingDetailsViewModel();

View File

@@ -0,0 +1,193 @@
<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 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";
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 === "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 />
</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

@@ -0,0 +1,80 @@
<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

@@ -0,0 +1,106 @@
<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

@@ -0,0 +1,40 @@
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

@@ -0,0 +1,148 @@
<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";
let totals = $state(
calculateTicketPrices($flightTicketStore, passengerInfoVM.passengerInfos),
);
let changing = $state(false);
$effect(() => {
changing = true;
totals = calculateTicketPrices(
$flightTicketStore,
passengerInfoVM.passengerInfos,
);
changing = false;
});
flightTicketStore.subscribe((val) => {
changing = true;
totals = calculateTicketPrices(val, passengerInfoVM.passengerInfos);
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>
{#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>
</ul>
</div>
</div>

View File

@@ -0,0 +1,68 @@
<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

@@ -0,0 +1,66 @@
<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

@@ -0,0 +1,95 @@
import type { PassengerInfo } from "$lib/domains/passengerinfo/data/entities";
import type { FlightTicket } from "../ticket/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

@@ -0,0 +1,67 @@
<script lang="ts">
import ButtonLoadableText from "$lib/components/atoms/button-loadable-text.svelte";
import Title from "$lib/components/atoms/title.svelte";
import * as AlertDialog from "$lib/components/ui/alert-dialog/index.js";
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
import { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte";
import { flightTicketVM } from "$lib/domains/ticket/view/ticket.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>

View File

@@ -1,6 +1,6 @@
import { CheckoutStep } from "$lib/domains/order/data/entities";
import type { CustomerInfo } from "$lib/domains/passengerinfo/data/entities";
import type { PaymentInfoPayload } from "$lib/domains/paymentinfo/data/entities";
import { CheckoutStep } from "$lib/domains/ticket/data/entities";
import type { Database } from "@pkg/db";
import { and, eq } from "@pkg/db";
import { checkoutFlowSession } from "@pkg/db/schema";
@@ -42,7 +42,7 @@ export class CheckoutFlowRepository {
userAgent: payload.userAgent,
reserved: false,
sessionOutcome: SessionOutcome.PENDING,
ticketId: payload.ticketId,
product: payload.productId,
});
return { data: flowId };

View File

@@ -18,7 +18,7 @@ import {
type FlightPriceDetails,
} from "$lib/domains/ticket/data/entities";
import { flightTicketStore } from "$lib/domains/ticket/data/store";
import { ticketCheckoutVM } from "$lib/domains/ticket/view/checkout/flight-checkout.vm.svelte";
import { checkoutVM } from "$lib/domains/ticket/view/checkout/checkout.vm.svelte";
import { billingDetailsVM } from "$lib/domains/ticket/view/checkout/payment-info-section/billing.details.vm.svelte";
import { paymentInfoVM } from "$lib/domains/ticket/view/checkout/payment-info-section/payment.info.vm.svelte";
import { trpcApiStore } from "$lib/stores/api";
@@ -61,7 +61,7 @@ class ActionRunner {
}
}
private async completeOrder(data: any) {
const ok = await ticketCheckoutVM.checkout();
const ok = await checkoutVM.checkout();
if (!ok) return;
const cleanupSuccess = await ckFlowVM.cleanupFlowInfo(
@@ -117,7 +117,7 @@ class ActionRunner {
await ckFlowVM.refreshFlowInfo(false);
ticketCheckoutVM.checkoutStep = CheckoutStep.Verification;
checkoutVM.checkoutStep = CheckoutStep.Verification;
toast.info("Verification required", {
description: "Please enter the verification code sent to your device",
});
@@ -131,7 +131,7 @@ class ActionRunner {
toast.error("Some information provided is not valid", {
description: "Please double check your info & try again",
});
ticketCheckoutVM.checkoutStep = CheckoutStep.Initial;
checkoutVM.checkoutStep = CheckoutStep.Initial;
}
private async backToPayment(action: PendingAction) {
@@ -155,13 +155,13 @@ class ActionRunner {
duration: 6000,
});
ticketCheckoutVM.checkoutStep = CheckoutStep.Payment;
checkoutVM.checkoutStep = CheckoutStep.Payment;
}
private async terminateSession() {
await ckFlowVM.cleanupFlowInfo();
ckFlowVM.reset();
ticketCheckoutVM.reset();
checkoutVM.reset();
const tid = page.params.tid as any as string;
const sid = page.params.sid as any as string;
window.location.replace(`/checkout/terminated?sid=${sid}&tid=${tid}`);

View File

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

View File

@@ -0,0 +1 @@
export * from "@pkg/logic/domains/customerinfo/repository";

View File

@@ -0,0 +1,40 @@
import { protectedProcedure } from "$lib/server/trpc/t";
import { createTRPCRouter } from "$lib/trpc/t";
import { z } from "zod";
import { createCustomerInfoPayload, updateCustomerInfoPayload } from "./data";
import { getCustomerInfoUseCases } from "./usecases";
export const customerInfoRouter = createTRPCRouter({
getAllCustomerInfo: protectedProcedure.query(async ({}) => {
const controller = getCustomerInfoUseCases();
return controller.getAllCustomerInfo();
}),
getCustomerInfoById: protectedProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const controller = getCustomerInfoUseCases();
return controller.getCustomerInfoById(input.id);
}),
createCustomerInfo: protectedProcedure
.input(createCustomerInfoPayload)
.mutation(async ({ input }) => {
const controller = getCustomerInfoUseCases();
return controller.createCustomerInfo(input);
}),
updateCustomerInfo: protectedProcedure
.input(updateCustomerInfoPayload)
.mutation(async ({ input }) => {
const controller = getCustomerInfoUseCases();
return controller.updateCustomerInfo(input);
}),
deleteCustomerInfo: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
const controller = getCustomerInfoUseCases();
return controller.deleteCustomerInfo(input.id);
}),
});

View File

@@ -0,0 +1 @@
export * from "@pkg/logic/domains/customerinfo/usecases";

View File

@@ -0,0 +1,322 @@
<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 { CustomerInfo } 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";
let { info = $bindable(), idx }: { info: CustomerInfo; 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

@@ -0,0 +1,10 @@
import type { CustomerInfoModel } from "../data";
export class CustomerInfoViewModel {
customerInfos = $state([] as CustomerInfoModel[]);
loading = $state(false);
query = $state("");
}
export const customerInfoVM = new CustomerInfoViewModel();

View File

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

View File

@@ -0,0 +1,197 @@
import { PUBLIC_FRONTEND_URL } from "$lib/core/constants";
import { trpcApiStore } from "$lib/stores/api";
import { toast } from "svelte-sonner";
import { get } from "svelte/store";
import type {
CreateProductPayload,
ProductModel,
UpdateProductPayload,
} from "./data";
export class ProductViewModel {
products = $state<ProductModel[]>([]);
loading = $state(false);
formLoading = $state(false);
// Selected product for edit/delete operations
selectedProduct = $state<ProductModel | null>(null);
async fetchProducts() {
const api = get(trpcApiStore);
if (!api) {
return toast.error("API client not initialized");
}
this.loading = true;
try {
const result = await api.product.getAllProducts.query();
if (result.error) {
toast.error(result.error.message, {
description: result.error.userHint,
});
return false;
}
this.products = result.data || [];
return true;
} catch (e) {
console.error(e);
toast.error("Failed to fetch products", {
description: "Please try again later",
});
return false;
} finally {
this.loading = false;
}
}
async createProduct(payload: CreateProductPayload) {
const api = get(trpcApiStore);
if (!api) {
return toast.error("API client not initialized");
}
this.formLoading = true;
try {
const result = await api.product.createProduct.mutate(payload);
if (result.error) {
toast.error(result.error.message, {
description: result.error.userHint,
});
return false;
}
toast.success("Product created successfully");
await this.fetchProducts();
return true;
} catch (e) {
toast.error("Failed to create product", {
description: "Please try again later",
});
return false;
} finally {
this.formLoading = false;
}
}
async updateProduct(payload: UpdateProductPayload) {
const api = get(trpcApiStore);
if (!api) {
return toast.error("API client not initialized");
}
this.formLoading = true;
try {
const result = await api.product.updateProduct.mutate(payload);
if (result.error) {
toast.error(result.error.message, {
description: result.error.userHint,
});
return false;
}
toast.success("Product updated successfully");
await this.fetchProducts();
return true;
} catch (e) {
toast.error("Failed to update product", {
description: "Please try again later",
});
return false;
} finally {
this.formLoading = false;
this.selectedProduct = null;
}
}
async deleteProduct(id: number) {
const api = get(trpcApiStore);
if (!api) {
return toast.error("API client not initialized");
}
this.formLoading = true;
try {
const result = await api.product.deleteProduct.mutate({ id });
if (result.error) {
toast.error(result.error.message, {
description: result.error.userHint,
});
return false;
}
toast.success("Product deleted successfully");
await this.fetchProducts();
return true;
} catch (e) {
toast.error("Failed to delete product", {
description: "Please try again later",
});
return false;
} finally {
this.formLoading = false;
this.selectedProduct = null;
}
}
async refreshProductLinkId(id: number) {
const api = get(trpcApiStore);
if (!api) {
return toast.error("API client not initialized");
}
try {
const result = await api.product.refreshProductLinkId.mutate({ id });
if (result.error) {
toast.error(result.error.message, {
description: result.error.userHint,
});
return null;
}
toast.success("Link ID refreshed successfully");
await this.fetchProducts();
return result.data;
} catch (e) {
toast.error("Failed to refresh link ID", {
description: "Please try again later",
});
return null;
}
}
selectProduct(product: ProductModel) {
this.selectedProduct = { ...product };
}
clearSelectedProduct() {
this.selectedProduct = null;
}
getDefaultProduct(): CreateProductPayload {
return {
title: "",
description: "",
longDescription: "",
price: 0,
discountPrice: 0,
};
}
async copyLinkToClipboard(linkId: string) {
try {
await navigator.clipboard.writeText(
`${PUBLIC_FRONTEND_URL}/${linkId}`,
);
toast.success("Frontend link copied to clipboard");
return true;
} catch (e) {
toast.error("Failed to copy link ID", {
description: "Please try copying manually",
});
return false;
}
}
}
export const productVM = new ProductViewModel();

View File

@@ -0,0 +1 @@
export * from "@pkg/logic/domains/product/repository";

View File

@@ -0,0 +1,10 @@
import { protectedProcedure } from "$lib/server/trpc/t";
import { createTRPCRouter } from "$lib/trpc/t";
import { getProductUseCases } from "./usecases";
export const productRouter = createTRPCRouter({
getAllProducts: protectedProcedure.query(async ({}) => {
const controller = getProductUseCases();
return controller.getAllProducts();
}),
});

View File

@@ -0,0 +1 @@
export * from "@pkg/logic/domains/product/usecases";

View File

@@ -0,0 +1,111 @@
<script lang="ts">
import Button from "$lib/components/ui/button/button.svelte";
import Input from "$lib/components/ui/input/input.svelte";
import { Label } from "$lib/components/ui/label";
import { Textarea } from "$lib/components/ui/textarea";
import type {
CreateProductPayload,
UpdateProductPayload,
} from "$lib/domains/product/data";
export let formData: CreateProductPayload | UpdateProductPayload;
export let loading = false;
export let onSubmit: () => void;
export let onCancel: () => void;
const isNewProduct = !("id" in formData);
</script>
<form on:submit|preventDefault={onSubmit} class="space-y-6">
<!-- Title -->
<div class="space-y-2">
<Label for="title">Product Title</Label>
<Input
id="title"
bind:value={formData.title}
placeholder="e.g. Premium Flight Package"
maxlength={64}
required
/>
</div>
<!-- Description -->
<div class="space-y-2">
<Label for="description">Short Description</Label>
<Textarea
id="description"
bind:value={formData.description}
placeholder="Brief description of the product"
rows={3}
required
/>
</div>
<!-- Long Description -->
<div class="space-y-2">
<Label for="longDescription">Long Description</Label>
<Textarea
id="longDescription"
bind:value={formData.longDescription}
placeholder="Detailed description of the product"
rows={6}
required
/>
</div>
<div class="grid grid-cols-2 gap-4">
<!-- Price -->
<div class="space-y-2">
<Label for="price">Price</Label>
<div class="relative">
<Input
id="price"
type="number"
min={0}
step="0.01"
bind:value={formData.price}
required
/>
<div
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3"
>
<span class="text-gray-500">$</span>
</div>
</div>
</div>
<!-- Discount Price -->
<div class="space-y-2">
<Label for="discountPrice">Discount Price</Label>
<div class="relative">
<Input
id="discountPrice"
type="number"
min={0}
step="0.01"
bind:value={formData.discountPrice}
required
/>
<div
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3"
>
<span class="text-gray-500">$</span>
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="flex justify-end space-x-2">
<Button variant="outline" onclick={onCancel} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{#if loading}
Processing...
{:else}
{isNewProduct ? "Create Product" : "Update Product"}
{/if}
</Button>
</div>
</form>

View File

@@ -0,0 +1,288 @@
<script lang="ts">
import Loader from "$lib/components/atoms/loader.svelte";
import Title from "$lib/components/atoms/title.svelte";
import * as AlertDialog from "$lib/components/ui/alert-dialog/index";
import { Badge } from "$lib/components/ui/badge";
import Button from "$lib/components/ui/button/button.svelte";
import * as Dialog from "$lib/components/ui/dialog";
import { productVM } from "$lib/domains/product/product.vm.svelte";
import { Copy, Pencil, PlusIcon, RefreshCw, Trash2 } from "@lucide/svelte";
import { onMount } from "svelte";
import type { CreateProductPayload } from "../data";
import ProductForm from "./product-form.svelte";
let createDialogOpen = $state(false);
let editDialogOpen = $state(false);
let deleteDialogOpen = $state(false);
let newProductData = $state<CreateProductPayload>(
productVM.getDefaultProduct(),
);
async function handleCreateProduct() {
const success = await productVM.createProduct(newProductData);
if (success) {
createDialogOpen = false;
newProductData = productVM.getDefaultProduct();
}
}
async function handleUpdateProduct() {
if (!productVM.selectedProduct) return;
const success = await productVM.updateProduct({
id: productVM.selectedProduct.id! ?? -1,
...productVM.selectedProduct,
});
if (success) {
editDialogOpen = false;
}
}
async function handleDeleteProduct() {
if (!productVM.selectedProduct) return;
const success = await productVM.deleteProduct(
productVM.selectedProduct.id!,
);
if (success) {
deleteDialogOpen = false;
}
}
async function handleRefreshLinkId(id: number) {
await productVM.refreshProductLinkId(id);
}
async function handleCopyLink(linkId: string) {
await productVM.copyLinkToClipboard(linkId);
}
function formatPrice(price: number) {
return `$${price.toFixed(2)}`;
}
onMount(() => {
setTimeout(async () => {
productVM.fetchProducts();
}, 1000);
});
</script>
<div class="mb-6 flex items-center justify-between">
<Title size="h3" weight="semibold">Products</Title>
<Button onclick={() => (createDialogOpen = true)}>
<PlusIcon class="mr-2 h-4 w-4" />
New Product
</Button>
</div>
{#if productVM.loading}
<div class="flex items-center justify-center py-20">
<Loader />
</div>
{:else if productVM.products.length === 0}
<div class="rounded-lg border py-10 text-center">
<p class="text-gray-500">
No products found. Create your first product to get started.
</p>
</div>
{:else}
<div class="overflow-x-auto">
<table class="w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
>Product</th
>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
>Pricing</th
>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
>Link ID</th
>
<th
class="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500"
>Actions</th
>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#each productVM.products as product}
<tr>
<td class="whitespace-nowrap px-6 py-4">
<div class="font-medium text-gray-900">
{product.title}
</div>
<div
class="max-w-48 overflow-hidden text-ellipsis text-sm text-gray-500"
>
{product.description}
</div>
</td>
<td class="whitespace-nowrap px-6 py-4">
<div class="flex flex-col gap-1">
<div class="text-sm">
<span class="text-gray-500">Price:</span>
<span class="ml-1 font-medium"
>{formatPrice(product.price)}</span
>
</div>
{#if product.discountPrice > 0 && product.discountPrice < product.price}
<Badge variant="success" class="w-fit">
Discount: {formatPrice(
product.discountPrice,
)}
</Badge>
{/if}
</div>
</td>
<td class="px-6 py-4">
<div class="flex items-center gap-2">
<code
class="rounded bg-gray-100 px-2 py-1 font-mono text-xs"
>
{product.linkId}
</code>
<Button
variant="ghost"
size="sm"
onclick={() => handleCopyLink(product.linkId)}
>
<Copy class="h-3 w-3" />
</Button>
</div>
</td>
<td
class="flex w-full items-center justify-end gap-2 whitespace-nowrap px-6 py-4 text-right"
>
<Button
variant="ghost"
size="sm"
onclick={() => handleRefreshLinkId(product.id!)}
title="Refresh Link ID"
>
<RefreshCw class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onclick={() => {
productVM.selectProduct(product);
editDialogOpen = true;
}}
>
<Pencil class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onclick={() => {
productVM.selectProduct(product);
deleteDialogOpen = true;
}}
>
<Trash2 class="h-4 w-4" />
</Button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<!-- Create Product Dialog -->
<Dialog.Root bind:open={createDialogOpen}>
<Dialog.Content class="max-w-2xl">
<Dialog.Header>
<Dialog.Title>Create New Product</Dialog.Title>
<Dialog.Description>
Create a new product for your catalog.
</Dialog.Description>
</Dialog.Header>
<ProductForm
formData={newProductData}
loading={productVM.formLoading}
onSubmit={handleCreateProduct}
onCancel={() => (createDialogOpen = false)}
/>
</Dialog.Content>
</Dialog.Root>
<!-- Edit Product Dialog -->
<Dialog.Root bind:open={editDialogOpen}>
<Dialog.Content class="max-w-2xl">
<Dialog.Header>
<Dialog.Title>Edit Product</Dialog.Title>
<Dialog.Description>Update this product's details.</Dialog.Description
>
</Dialog.Header>
{#if productVM.selectedProduct}
<ProductForm
formData={productVM.selectedProduct}
loading={productVM.formLoading}
onSubmit={handleUpdateProduct}
onCancel={() => (editDialogOpen = false)}
/>
{/if}
</Dialog.Content>
</Dialog.Root>
<!-- Delete Confirmation Dialog -->
<AlertDialog.Root bind:open={deleteDialogOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Delete Product</AlertDialog.Title>
<AlertDialog.Description>
Are you sure you want to delete this product? This action cannot
be undone.
</AlertDialog.Description>
</AlertDialog.Header>
{#if productVM.selectedProduct}
<div class="mb-4 rounded-md bg-gray-100 p-4">
<div class="font-bold">{productVM.selectedProduct.title}</div>
<div class="text-sm text-gray-600">
{productVM.selectedProduct.description}
</div>
<div class="mt-2 flex items-center gap-2">
<Badge
>Price: {formatPrice(
productVM.selectedProduct.price,
)}</Badge
>
{#if productVM.selectedProduct.discountPrice > 0}
<Badge variant="success"
>Discount: {formatPrice(
productVM.selectedProduct.discountPrice,
)}</Badge
>
{/if}
</div>
</div>
{/if}
<AlertDialog.Footer>
<AlertDialog.Cancel onclick={() => (deleteDialogOpen = false)}>
Cancel
</AlertDialog.Cancel>
<AlertDialog.Action
onclick={handleDeleteProduct}
disabled={productVM.formLoading}
>
{productVM.formLoading ? "Deleting..." : "Delete"}
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>

View File

@@ -1,3 +1,4 @@
import { nanoid } from "nanoid";
import { writable } from "svelte/store";
import {
CabinClass,
@@ -5,7 +6,6 @@ import {
type FlightTicket,
type TicketSearchPayload,
} from "./entities";
import { nanoid } from "nanoid";
export const flightTicketStore = writable<FlightTicket>();
@@ -20,5 +20,4 @@ export const ticketSearchStore = writable<TicketSearchPayload>({
departureDate: "",
returnDate: "",
meta: {},
couponCode: "",
});

View File

@@ -1,12 +1,12 @@
<script lang="ts">
import { buttonVariants } from "$lib/components/ui/button/button.svelte";
import { cn } from "$lib/utils";
import { CheckoutStep } from "../../data/entities";
import { ticketCheckoutVM } from "./flight-checkout.vm.svelte";
import { Sheet, SheetContent, SheetTrigger } from "$lib/components/ui/sheet";
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" },
@@ -16,14 +16,12 @@
];
let activeStepIndex = $derived(
checkoutSteps.findIndex(
(step) => step.id === ticketCheckoutVM.checkoutStep,
),
checkoutSteps.findIndex((step) => step.id === checkoutVM.checkoutStep),
);
function handleStepClick(clickedIndex: number, stepId: CheckoutStep) {
if (clickedIndex <= activeStepIndex) {
ticketCheckoutVM.checkoutStep = stepId;
checkoutVM.checkoutStep = stepId;
}
}

View File

@@ -0,0 +1,165 @@
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,22 +1,21 @@
<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 RightArrowIcon from "~icons/solar/arrow-right-broken";
import { onMount } from "svelte";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import PassengerPiiForm from "$lib/domains/passengerinfo/view/passenger-pii-form.svelte";
import PassengerBagSelection from "$lib/domains/passengerinfo/view/passenger-bag-selection.svelte";
import Badge from "$lib/components/ui/badge/badge.svelte";
import { capitalize } from "$lib/core/string.utils";
import { cn } from "$lib/utils";
import { ticketCheckoutVM } from "./flight-checkout.vm.svelte";
import { CheckoutStep } from "../../data/entities";
import { toast } from "svelte-sonner";
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
import ButtonLoadableText from "$lib/components/atoms/button-loadable-text.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";
@@ -76,14 +75,14 @@
description: "Please properly fill out all of the fields",
});
}
ticketCheckoutVM.continutingToNextStep = true;
checkoutVM.continutingToNextStep = true;
const out2 = await ckFlowVM.executePrePaymentStep();
if (!out2) {
return;
}
setTimeout(() => {
ticketCheckoutVM.continutingToNextStep = false;
ticketCheckoutVM.checkoutStep = CheckoutStep.Payment;
checkoutVM.continutingToNextStep = false;
checkoutVM.checkoutStep = CheckoutStep.Payment;
}, 2000);
}
@@ -123,11 +122,6 @@
<Title size="h5">Personal Info</Title>
<PassengerPiiForm bind:info={info.passengerPii} {idx} />
</div>
<div class={cn(cardStyle, "border-2 border-gray-200")}>
<Title size="h5">Bag Selection</Title>
<PassengerBagSelection bind:info={info.bagSelection} />
</div>
</div>
{/each}
{/if}
@@ -139,12 +133,12 @@
variant="default"
onclick={proceedToNextStep}
class="w-full md:w-max"
disabled={ticketCheckoutVM.continutingToNextStep}
disabled={checkoutVM.continutingToNextStep}
>
<ButtonLoadableText
text="Continue"
loadingText="Processing..."
loading={ticketCheckoutVM.continutingToNextStep}
loading={checkoutVM.continutingToNextStep}
/>
<Icon icon={RightArrowIcon} cls="w-auto h-6" />
</Button>

View File

@@ -1,48 +0,0 @@
<script lang="ts">
import Icon from "$lib/components/atoms/icon.svelte";
import Title from "$lib/components/atoms/title.svelte";
import { Badge } from "$lib/components/ui/badge";
import TagIcon from "~icons/lucide/tag";
import { flightTicketStore } from "../../../data/store";
import { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte";
let appliedCoupon = $derived($flightTicketStore?.priceDetails?.appliedCoupon);
let couponDescription = $derived(
$flightTicketStore?.priceDetails?.couponDescription || "",
);
let discountAmount = $derived(
$flightTicketStore?.priceDetails?.discountAmount || 0,
);
let basePrice = $derived($flightTicketStore?.priceDetails?.basePrice || 0);
let discountPercentage = $derived(
basePrice > 0 ? Math.round((discountAmount / basePrice) * 100) : 0,
);
</script>
{#if appliedCoupon && discountAmount > 0}
<div
class="flex flex-col gap-2 rounded-lg border border-green-200 bg-green-50 p-4"
>
<div class="flex items-center gap-2 text-green-700">
<Icon icon={TagIcon} cls="h-5 w-5" />
<Title size="p" weight="medium">Coupon Applied</Title>
<Badge
variant="outline"
class="ml-auto border-green-600 text-green-600"
>
{discountPercentage}% OFF
</Badge>
</div>
<div class="flex flex-col gap-1">
<div class="flex justify-between">
<span class="font-medium">{appliedCoupon}</span>
<span>-{convertAndFormatCurrency(discountAmount)}</span>
</div>
{#if couponDescription}
<p class="text-sm text-green-700">{couponDescription}</p>
{/if}
</div>
</div>
{/if}

View File

@@ -1,30 +1,29 @@
<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 RightArrowIcon from "~icons/solar/arrow-right-broken";
import { ticketCheckoutVM } from "../flight-checkout.vm.svelte";
import { CheckoutStep } from "$lib/domains/ticket/data/entities";
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";
import TicketDetailsModal from "../../ticket/ticket-details-modal.svelte";
import * as Dialog from "$lib/components/ui/dialog";
import { cn } from "$lib/utils";
import { TicketType } from "$lib/domains/ticket/data/entities";
import ArrowsExchangeIcon from "~icons/tabler/arrows-exchange-2";
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
import { formatDate } from "@pkg/logic/core/date.utils";
import ButtonLoadableText from "$lib/components/atoms/button-loadable-text.svelte";
import BillingDetailsForm from "./billing-details-form.svelte";
import { billingDetailsVM } from "./billing.details.vm.svelte";
import { onMount } from "svelte";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import { toast } from "svelte-sonner";
import CouponSummary from "./coupon-summary.svelte";
const cardStyle =
"flex w-full flex-col gap-4 rounded-lg bg-white p-4 shadow-lg md:p-8";
@@ -33,7 +32,7 @@
if ((await ckFlowVM.onBackToPIIBtnClick()) !== true) {
return;
}
ticketCheckoutVM.checkoutStep = CheckoutStep.Initial;
checkoutVM.checkoutStep = CheckoutStep.Initial;
}
async function handleSubmit() {
@@ -47,14 +46,14 @@
if (!validBillingInfo) {
return;
}
ticketCheckoutVM.continutingToNextStep = true;
checkoutVM.continutingToNextStep = true;
const out = await ckFlowVM.executePaymentStep();
if (out !== true) {
return;
}
setTimeout(() => {
ticketCheckoutVM.continutingToNextStep = false;
ticketCheckoutVM.checkoutStep = CheckoutStep.Verification;
checkoutVM.continutingToNextStep = false;
checkoutVM.checkoutStep = CheckoutStep.Verification;
}, 1000);
}
@@ -186,12 +185,12 @@
variant="default"
onclick={handleSubmit}
class="w-full md:w-max"
disabled={ticketCheckoutVM.continutingToNextStep}
disabled={checkoutVM.continutingToNextStep}
>
<ButtonLoadableText
text="Confirm & Pay"
loadingText="Processing info..."
loading={ticketCheckoutVM.continutingToNextStep}
loading={checkoutVM.continutingToNextStep}
/>
<Icon icon={RightArrowIcon} cls="w-auto h-6" />
</Button>

View File

@@ -1,9 +1,9 @@
<script lang="ts">
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
import { onMount, onDestroy } from "svelte";
import { ticketCheckoutVM } from "./flight-checkout.vm.svelte";
import PaymentVerificationLoader from "./payment-verification-loader.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;
@@ -47,7 +47,7 @@
setTimeout(async () => {
if (ckFlowVM.setupDone && !ckFlowVM.flowId) {
console.log("Shortcut - Checking out");
await ticketCheckoutVM.checkout();
await checkoutVM.checkout();
}
}, rng);
});

View File

@@ -1,16 +1,16 @@
<script lang="ts">
import { onMount } from "svelte";
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 { cn } from "$lib/utils";
import RightArrowIcon from "~icons/solar/arrow-right-broken";
import { seatSelectionVM } from "./seat.selection.vm.svelte";
import { ticketCheckoutVM } from "../flight-checkout.vm.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 { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.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";
@@ -23,7 +23,7 @@
);
function goBack() {
ticketCheckoutVM.checkoutStep = CheckoutStep.Initial;
checkoutVM.checkoutStep = CheckoutStep.Initial;
}
function goNext() {
@@ -33,7 +33,7 @@
}
function skipAndContinue() {
ticketCheckoutVM.checkoutStep = CheckoutStep.Payment;
checkoutVM.checkoutStep = CheckoutStep.Payment;
}
onMount(() => {

View File

@@ -12,7 +12,7 @@ import { nanoid } from "nanoid";
import { toast } from "svelte-sonner";
import { get } from "svelte/store";
import { flightTicketStore, ticketSearchStore } from "../data/store";
import { ticketCheckoutVM } from "./checkout/flight-checkout.vm.svelte";
import { checkoutVM } from "./checkout/checkout.vm.svelte";
import { paymentInfoVM } from "./checkout/payment-info-section/payment.info.vm.svelte";
import {
MaxStops,
@@ -91,7 +91,7 @@ export class FlightTicketViewModel {
// @ts-ignore
flightTicketStore.set(undefined);
passengerInfoVM.reset();
ticketCheckoutVM.reset();
checkoutVM.reset();
paymentInfoVM.reset();
}

View File

@@ -1,17 +1,17 @@
<script lang="ts">
import Title from "$lib/components/atoms/title.svelte";
import { Badge } from "$lib/components/ui/badge/index.js";
import Button, {
buttonVariants,
} from "$lib/components/ui/button/button.svelte";
import { type FlightTicket } from "../../data/entities";
import { Badge } from "$lib/components/ui/badge/index.js";
import { TRANSITION_ALL } from "$lib/core/constants";
import { cn } from "$lib/utils";
import TicketDetailsModal from "./ticket-details-modal.svelte";
import * as Dialog from "$lib/components/ui/dialog/index.js";
import { flightTicketVM } from "../ticket.vm.svelte";
import TicketLegsOverview from "./ticket-legs-overview.svelte";
import { TRANSITION_ALL } from "$lib/core/constants";
import { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte";
import { cn } from "$lib/utils";
import { type FlightTicket } from "../../data/entities";
import { flightTicketVM } from "../ticket.vm.svelte";
import TicketDetailsModal from "./ticket-details-modal.svelte";
import TicketLegsOverview from "./ticket-legs-overview.svelte";
let { data }: { data: FlightTicket } = $props();
@@ -57,15 +57,6 @@
<Title center size="h4" weight="medium" color="black">
{convertAndFormatCurrency(data.priceDetails.displayPrice)}
</Title>
{#if data.priceDetails.appliedCoupon}
<Badge
variant="outline"
class="border-green-600 text-green-600"
>
Coupon: {data.priceDetails.appliedCoupon}
</Badge>
{/if}
</div>
{:else}
<Title center size="h4" weight="medium" color="black">