stashing code

This commit is contained in:
user
2025-10-20 17:07:41 +03:00
commit f5b99afc8f
890 changed files with 54823 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import * as Select from "$lib/components/ui/select";
import type { SelectOption } from "$lib/core/data.types";
import { capitalize } from "$lib/core/string.utils";
let {
opts,
onselect,
}: { opts: SelectOption[]; onselect: (e: string) => void } = $props();
let chosenOpt = $state<SelectOption | undefined>(undefined);
function setOpt(val: string) {
chosenOpt = opts.find((e) => e.value === val);
onselect(val);
}
</script>
<Select.Root type="single" required onValueChange={(e) => setOpt(e)} name="role">
<Select.Trigger class="w-full border-border/20">
{capitalize(
opts?.find((e) => e.value === chosenOpt?.value)?.label ??
"Cabin Class",
)}
</Select.Trigger>
<Select.Content>
{#each opts as each}
<Select.Item value={each.value}>
{each.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>

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,160 @@
<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 ChevronDownIcon from "~icons/lucide/chevron-down";
import CloseIcon from "~icons/lucide/x";
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 === ticketCheckoutVM.checkoutStep,
),
);
function handleStepClick(clickedIndex: number, stepId: CheckoutStep) {
if (clickedIndex <= activeStepIndex) {
ticketCheckoutVM.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

@@ -0,0 +1,166 @@
import { get } from "svelte/store";
import { CheckoutStep } from "../../data/entities/index";
import { trpcApiStore } from "$lib/stores/api";
import { toast } from "svelte-sonner";
import { flightTicketStore } from "../../data/store";
import { newOrderModel } from "$lib/domains/order/data/entities";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import { paymentInfoVM } from "./payment-info-section/payment.info.vm.svelte";
import {
paymentDetailsPayloadModel,
PaymentMethod,
} from "$lib/domains/paymentinfo/data/entities";
import { calculateTicketPrices } from "./total.calculator";
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
class TicketCheckoutViewModel {
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,
paymentDetailsId: -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 = paymentDetailsPayloadModel.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,
paymentDetails: 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 ticketCheckoutVM = new TicketCheckoutViewModel();

View File

@@ -0,0 +1,152 @@
<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 { 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";
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",
});
}
ticketCheckoutVM.continutingToNextStep = true;
const out2 = await ckFlowVM.executePrePaymentStep();
if (!out2) {
return;
}
setTimeout(() => {
ticketCheckoutVM.continutingToNextStep = false;
ticketCheckoutVM.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={ticketCheckoutVM.continutingToNextStep}
>
<ButtonLoadableText
text="Continue"
loadingText="Processing..."
loading={ticketCheckoutVM.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,149 @@
<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 { capitalize } from "$lib/core/string.utils";
import type { PassengerPII } from "$lib/domains/ticket/data/entities/create.entities";
import { billingDetailsVM } from "./billing.details.vm.svelte";
let { info = $bindable() }: { info: PassengerPII } = $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 { Gender } from "$lib/domains/ticket/data/entities/index";
import {
passengerPIIModel,
type PassengerPII,
} from "$lib/domains/ticket/data/entities/create.entities";
import { z } from "zod";
export class BillingDetailsViewModel {
// @ts-ignore
billingDetails = $state<PassengerPII>(undefined);
piiErrors = $state<Partial<Record<keyof PassengerPII, 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 PassengerPII;
this.piiErrors = {};
}
setPII(info: PassengerPII) {
this.billingDetails = info;
}
validatePII(info: PassengerPII) {
try {
const result = passengerPIIModel.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 PassengerPII;
acc[path] = curr.message;
return acc;
},
{} as Record<keyof PassengerPII, string>,
);
}
return null;
}
}
isPIIValid(): boolean {
return Object.keys(this.piiErrors).length === 0;
}
}
export const billingDetailsVM = new BillingDetailsViewModel();

View File

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

@@ -0,0 +1,199 @@
<script lang="ts">
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 { flightTicketStore } from "$lib/domains/ticket/data/store";
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";
async function goBack() {
if ((await ckFlowVM.onBackToPIIBtnClick()) !== true) {
return;
}
ticketCheckoutVM.checkoutStep = CheckoutStep.Initial;
}
async function handleSubmit() {
const validatedData = await paymentInfoVM.validateAndSubmit();
if (!validatedData) {
return;
}
const validBillingInfo = billingDetailsVM.validatePII(
billingDetailsVM.billingDetails,
);
if (!validBillingInfo) {
return;
}
ticketCheckoutVM.continutingToNextStep = true;
const out = await ckFlowVM.executePaymentStep();
if (out !== true) {
return;
}
setTimeout(() => {
ticketCheckoutVM.continutingToNextStep = false;
ticketCheckoutVM.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={ticketCheckoutVM.continutingToNextStep}
>
<ButtonLoadableText
text="Confirm & Pay"
loadingText="Processing info..."
loading={ticketCheckoutVM.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,195 @@
<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

@@ -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 { onMount, onDestroy } from "svelte";
import { ticketCheckoutVM } from "./flight-checkout.vm.svelte";
import PaymentVerificationLoader from "./payment-verification-loader.svelte";
import OtpVerificationSection from "./otp-verification-section.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 ticketCheckoutVM.checkout();
}
}, rng);
});
onDestroy(() => {
clearInterval(refreshIntervalId);
});
</script>
{#if showOtpVerificationForm}
{@const done = gototop()}
<OtpVerificationSection />
{:else}
{@const done2 = gototop()}
<PaymentVerificationLoader />
{/if}

View File

@@ -0,0 +1,224 @@
<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 { CheckoutStep } from "$lib/domains/ticket/data/entities";
import { flightTicketStore } from "$lib/domains/ticket/data/store";
import CheckoutLoadingSection from "../checkout-loading-section.svelte";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.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() {
ticketCheckoutVM.checkoutStep = CheckoutStep.Initial;
}
function goNext() {
// TODO: Add seat selection verification here
// Just cuse it's already setting it lol
skipAndContinue();
}
function skipAndContinue() {
ticketCheckoutVM.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

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

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

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

View File

@@ -0,0 +1,104 @@
<script lang="ts">
import CalendarIcon from "@lucide/svelte/icons/calendar";
import {
DateFormatter,
getLocalTimeZone,
today,
toCalendarDate,
type DateValue,
} from "@internationalized/date";
import { cn } from "$lib/utils.js";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import * as Popover from "$lib/components/ui/popover/index.js";
import Icon from "$lib/components/atoms/icon.svelte";
import Calendar from "$lib/components/ui/calendar/calendar.svelte";
import {
parseCalDateToDateString,
makeDateStringISO,
} from "$lib/core/date.utils";
import { ticketSearchStore } from "../data/store";
import { onMount } from "svelte";
const df = new DateFormatter("en-US", {
month: "short",
day: "numeric",
year: "numeric",
timeZone: getLocalTimeZone(),
});
let contentRef = $state<HTMLElement | null>(null);
let open = $state(false);
const todayDate = today(getLocalTimeZone());
let value = $state(today(getLocalTimeZone()));
let startFmt = $derived(value.toDate(getLocalTimeZone()));
let defaultPlaceholder = "Pick a date";
let placeholder = $derived(
value ? `${df.format(startFmt)}` : defaultPlaceholder,
);
function updateValInStore() {
const val = parseCalDateToDateString(value);
ticketSearchStore.update((prev) => {
return {
...prev,
departureDate: makeDateStringISO(val),
returnDate: makeDateStringISO(val),
};
});
}
function isDateDisabled(date: DateValue) {
return date.compare(todayDate) < 0;
}
function handleDateSelection(v: DateValue | undefined) {
if (!v) {
value = today(getLocalTimeZone());
} else {
value = toCalendarDate(v);
}
setTimeout(() => {
open = false;
}, 0);
}
$effect(() => {
updateValInStore();
});
onMount(() => {
updateValInStore();
});
</script>
<Popover.Root bind:open>
<Popover.Trigger
class={cn(
buttonVariants({
variant: "white",
class: "w-full justify-start text-left font-normal",
}),
!value && "text-muted-foreground",
)}
>
<Icon icon={CalendarIcon} cls="w-auto h-4" />
<span class="text-sm md:text-base">{placeholder}</span>
</Popover.Trigger>
<Popover.Content bind:ref={contentRef} class="w-auto p-0">
<Calendar
type="single"
{value}
minValue={todayDate}
{isDateDisabled}
onValueChange={(v) => handleDateSelection(v)}
class="rounded-md border border-white"
/>
</Popover.Content>
</Popover.Root>

View File

@@ -0,0 +1,133 @@
<script lang="ts">
import CalendarIcon from "@lucide/svelte/icons/calendar";
import {
DateFormatter,
getLocalTimeZone,
toCalendarDate,
today,
type DateValue,
} from "@internationalized/date";
import { cn } from "$lib/utils.js";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import * as Popover from "$lib/components/ui/popover/index.js";
import { RangeCalendar } from "$lib/components/ui/range-calendar/index.js";
import Icon from "$lib/components/atoms/icon.svelte";
import { ticketSearchStore } from "../data/store";
import { onMount } from "svelte";
import {
makeDateStringISO,
parseCalDateToDateString,
} from "$lib/core/date.utils";
import Button from "$lib/components/ui/button/button.svelte";
const df = new DateFormatter("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
let contentRef = $state<HTMLElement | null>(null);
let open = $state(false);
const todayDate = today(getLocalTimeZone());
const start = today(getLocalTimeZone());
const end = start.add({ days: 7 });
let value = $state({ start, end });
let startFmt = $derived(value?.start?.toDate(getLocalTimeZone()));
let endFmt = $derived(value?.end?.toDate(getLocalTimeZone()));
let defaultPlaceholder = "Pick a date";
let placeholder = $derived(
value?.start && value?.end && startFmt && endFmt
? `${df.formatRange(startFmt, endFmt)}`
: value?.start && startFmt
? `${df.format(startFmt)} - Select end date`
: defaultPlaceholder,
);
function isDateDisabled(date: DateValue) {
return date.compare(todayDate) < 0;
}
function handleStartChange(v: DateValue | undefined) {
if (!v) {
value.start = today(getLocalTimeZone());
return;
}
value.start = toCalendarDate(v);
}
function handleEndChange(v: DateValue | undefined) {
if (!v) {
value.end = today(getLocalTimeZone());
return;
}
value.end = toCalendarDate(v);
}
function updateValsInStore() {
const sVal = parseCalDateToDateString(value.start);
const eVal = parseCalDateToDateString(value.end);
ticketSearchStore.update((prev) => {
return {
...prev,
departureDate: makeDateStringISO(sVal),
returnDate: makeDateStringISO(eVal),
};
});
}
$effect(() => {
updateValsInStore();
});
onMount(() => {
updateValsInStore();
});
</script>
<Popover.Root
{open}
onOpenChange={(o) => {
open = o;
}}
>
<Popover.Trigger
class={cn(
buttonVariants({
variant: "white",
class: "w-full justify-start text-left font-normal",
}),
!value && "text-muted-foreground",
)}
>
<Icon icon={CalendarIcon} cls="w-auto h-4" />
<span class="text-sm md:text-base">{placeholder}</span>
</Popover.Trigger>
<Popover.Content bind:ref={contentRef} class="w-auto p-0">
<RangeCalendar
{value}
{isDateDisabled}
onStartValueChange={handleStartChange}
onEndValueChange={handleEndChange}
class="rounded-md border border-white"
/>
<div class="w-full p-2">
<Button
class="w-full"
onclick={() => {
open = false;
}}
>
Confirm
</Button>
</div>
</Popover.Content>
</Popover.Root>

View File

@@ -0,0 +1,116 @@
<script lang="ts">
import Counter from "$lib/components/atoms/counter.svelte";
import Title from "$lib/components/atoms/title.svelte";
import { buttonVariants } from "$lib/components/ui/button";
import * as Popover from "$lib/components/ui/popover/index.js";
import { cn } from "$lib/utils";
import { CabinClass } from "../data/entities";
import { ticketSearchStore } from "../data/store";
import { snakeToSpacedPascal } from "$lib/core/string.utils";
import Icon from "$lib/components/atoms/icon.svelte";
import ChevronDownIcon from "~icons/lucide/chevron-down";
import CheckIcon from "~icons/lucide/check";
let cabinClass = $state($ticketSearchStore.cabinClass);
$effect(() => {
ticketSearchStore.update((prev) => {
return { ...prev, cabinClass: cabinClass };
});
});
let adultCount = $state(1);
let childCount = $state(0);
$effect(() => {
ticketSearchStore.update((prev) => {
return {
...prev,
passengerCounts: { adults: adultCount, children: childCount },
};
});
});
let passengerCounts = $derived(adultCount + childCount);
let cabinClassOpen = $state(false);
</script>
<div class="flex w-full flex-col items-center justify-end gap-2 sm:flex-row">
<Popover.Root bind:open={cabinClassOpen}>
<Popover.Trigger
class={cn(
buttonVariants({ variant: "white" }),
"w-full justify-between",
)}
>
{snakeToSpacedPascal(cabinClass.toLowerCase() ?? "Select")}
<Icon icon={ChevronDownIcon} cls="w-auto h-4" />
</Popover.Trigger>
<Popover.Content>
<div class="flex flex-col gap-2">
{#each Object.values(CabinClass) as each}
<button
onclick={() => {
cabinClass = each;
cabinClassOpen = false;
}}
class={cn(
"flex items-center gap-2 rounded-md p-2 px-4 text-start hover:bg-gray-200",
)}
>
{#if cabinClass === each}
<Icon
icon={CheckIcon}
cls="w-auto h-4 text-brand-600"
/>
{:else}
<div
class="h-4 w-4 rounded-full bg-transparent"
></div>
{/if}
{snakeToSpacedPascal(each.toLowerCase())}
</button>
{/each}
</div>
</Popover.Content>
</Popover.Root>
<Popover.Root>
<Popover.Trigger
class={cn(
buttonVariants({ variant: "white" }),
"w-full justify-between",
)}
>
{passengerCounts} Passenger(s)
<Icon icon={ChevronDownIcon} cls="w-auto h-4" />
</Popover.Trigger>
<Popover.Content>
<div class="flex flex-col gap-8">
<Title size="h5" weight="normal" color="black"
>Passenger Selection</Title
>
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<p>Adults</p>
<p class="text-gray-500">Aged 16+</p>
</div>
<Counter bind:value={adultCount} />
</div>
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<p>Children</p>
<p class="text-gray-500">Aged 0-16</p>
</div>
<Counter bind:value={childCount} />
</div>
</div>
</div>
</Popover.Content>
</Popover.Root>
</div>

View File

@@ -0,0 +1,39 @@
<script lang="ts">
import Icon from "$lib/components/atoms/icon.svelte";
import { TRANSITION_COLORS } from "$lib/core/constants";
import { cn } from "$lib/utils";
import FilterIcon from "~icons/solar/filter-broken";
import * as Sheet from "$lib/components/ui/sheet/index.js";
import TicketFiltersSelect from "../ticket-filters-select.svelte";
import Button from "$lib/components/ui/button/button.svelte";
import CloseIcon from "~icons/lucide/x";
import Title from "$lib/components/atoms/title.svelte";
let open = $state(false);
const close = () => (open = false);
</script>
<Sheet.Root bind:open>
<Sheet.Trigger
class={cn(
"grid place-items-center rounded-full bg-brand-100 p-4 text-brand-500 shadow-lg hover:bg-brand-500 hover:text-white",
TRANSITION_COLORS,
)}
>
<Icon icon={FilterIcon} cls={cn("h-8 w-auto", TRANSITION_COLORS)} />
</Sheet.Trigger>
<Sheet.Content side="bottom">
<div class="flex justify-between gap-4">
<Title size="h4" color="black">Tickets Filters</Title>
<Button size="iconSm" variant="white" onclick={() => close()}>
<Icon icon={CloseIcon} cls={cn("h-6 w-auto")} />
</Button>
</div>
<div class="flex max-h-[80vh] w-full flex-col gap-4 overflow-y-auto p-4">
<TicketFiltersSelect onApplyClick={() => close()} />
</div>
</Sheet.Content>
</Sheet.Root>

View File

@@ -0,0 +1,50 @@
<script lang="ts">
import Icon from "$lib/components/atoms/icon.svelte";
import { TRANSITION_COLORS } from "$lib/core/constants";
import { cn } from "$lib/utils";
import SearchIcon from "~icons/solar/minimalistic-magnifer-linear";
import * as Sheet from "$lib/components/ui/sheet/index.js";
import TicketSearchInput from "../ticket-search-input.svelte";
import Button from "$lib/components/ui/button/button.svelte";
import CloseIcon from "~icons/lucide/x";
import Title from "$lib/components/atoms/title.svelte";
let { onSubmit }: { onSubmit: () => void } = $props();
function _onSubmit() {
onSubmit();
close();
}
let open = $state(false);
const close = () => (open = false);
</script>
<Sheet.Root bind:open>
<Sheet.Trigger
class={cn(
"grid place-items-center rounded-full bg-brand-500 p-4 text-white shadow-lg hover:bg-brand-700",
TRANSITION_COLORS,
)}
>
<Icon
icon={SearchIcon}
cls={cn("h-8 w-auto text-white", TRANSITION_COLORS)}
/>
</Sheet.Trigger>
<Sheet.Content side="bottom">
<div class="flex w-full flex-col gap-4">
<div class="flex justify-between gap-4">
<Title size="h5" color="black">Search Flights</Title>
<Button size="iconSm" variant="white" onclick={() => close()}>
<Icon icon={CloseIcon} cls={cn("h-6 w-auto")} />
</Button>
</div>
<div class="flex max-h-[80vh] w-full flex-col gap-4 overflow-y-auto">
<TicketSearchInput onSubmit={_onSubmit} />
</div>
</div>
</Sheet.Content>
</Sheet.Root>

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import Badge from "$lib/components/ui/badge/badge.svelte";
import Button from "$lib/components/ui/button/button.svelte";
import { flightTicketVM } from "./ticket.vm.svelte";
import TicketCard from "./ticket/ticket-card.svelte";
</script>
<div class="flex w-full flex-col gap-4">
{#if flightTicketVM.searching}
{#each Array(5) as _}
<div
class="h-64 w-full animate-pulse rounded-lg bg-gray-300 shadow-lg"
></div>
{/each}
{:else if flightTicketVM.renderedTickets.length > 0}
<Badge variant="outline" class="w-max">
Showing {flightTicketVM.renderedTickets.length} tickets
</Badge>
{#each flightTicketVM.renderedTickets as each}
<TicketCard data={each} />
{/each}
<Button
class="text-center"
variant="white"
onclick={() => {
flightTicketVM.searchForTickets(true);
}}
>
Load More
</Button>
{:else}
<div
class="flex flex-col items-center justify-center gap-4 p-8 py-32 text-center"
>
<div class="text-2xl font-bold">No tickets found</div>
<div class="text-sm text-gray-500">
Try searching for a different flight
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,260 @@
<script lang="ts">
import Title from "$lib/components/atoms/title.svelte";
import { Slider } from "$lib/components/ui/slider/index.js";
import {
ticketFiltersStore,
MaxStops,
SortOption,
} from "$lib/domains/ticket/view/ticket-filters.vm.svelte";
import { flightTicketVM } from "./ticket.vm.svelte";
import { RadioGroup, RadioGroupItem } from "$lib/components/ui/radio-group";
import { Checkbox } from "$lib/components/ui/checkbox";
import { Label } from "$lib/components/ui/label";
import Button from "$lib/components/ui/button/button.svelte";
import {
convertAndFormatCurrency,
currencyStore,
currencyVM,
} from "$lib/domains/currency/view/currency.vm.svelte";
let { onApplyClick }: { onApplyClick?: () => void } = $props();
function onApply() {
flightTicketVM.applyFilters();
if (onApplyClick) {
onApplyClick();
}
}
let maxPrice = $state(
flightTicketVM.tickets.length > 0
? flightTicketVM.tickets.sort(
(a, b) =>
b.priceDetails.displayPrice - a.priceDetails.displayPrice,
)[0]?.priceDetails.displayPrice
: 100,
);
let priceRange = $state([
0,
currencyVM.convertFromUsd(
$ticketFiltersStore.priceRange.max,
$currencyStore.code,
),
]);
// Time ranges
let departureTimeRange = $state([0, $ticketFiltersStore.time.departure.max]);
let arrivalTimeRange = $state([0, $ticketFiltersStore.time.arrival.max]);
let durationRange = $state([0, $ticketFiltersStore.duration.max]);
let maxStops = $state($ticketFiltersStore.maxStops);
let allowOvernight = $state($ticketFiltersStore.allowOvernight);
$effect(() => {
maxPrice =
flightTicketVM.tickets.length > 0
? flightTicketVM.tickets.sort(
(a, b) =>
b.priceDetails.displayPrice -
a.priceDetails.displayPrice,
)[0]?.priceDetails.displayPrice
: 100;
if (priceRange[0] > maxPrice || priceRange[1] > maxPrice) {
priceRange = [
0,
currencyVM.convertFromUsd(maxPrice, $currencyStore.code),
];
}
});
$effect(() => {
ticketFiltersStore.update((prev) => ({
...prev,
priceRange: { min: priceRange[0], max: priceRange[1] },
maxStops,
allowOvernight,
time: {
departure: {
min: departureTimeRange[0],
max: departureTimeRange[1],
},
arrival: { min: arrivalTimeRange[0], max: arrivalTimeRange[1] },
},
duration: { min: durationRange[0], max: durationRange[1] },
}));
});
</script>
<div class="flex w-full max-w-sm flex-col gap-6">
<Title size="h5" color="black">Sort By</Title>
{#if flightTicketVM.searching}
<div class="h-16 w-full animate-pulse rounded-lg bg-gray-300"></div>
{:else}
<RadioGroup
value={$ticketFiltersStore.sortBy}
onValueChange={(value) => {
ticketFiltersStore.update((prev) => ({
...prev,
sortBy: value as SortOption,
}));
}}
>
<div class="flex items-center space-x-2">
<RadioGroupItem
value={SortOption.Default}
id="price_low_to_high"
/>
<Label for="default">Price: Default</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroupItem
value={SortOption.PriceLowToHigh}
id="price_low_to_high"
/>
<Label for="price_low_to_high">Price: Low to High</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroupItem
value={SortOption.PriceHighToLow}
id="price_high_to_low"
/>
<Label for="price_high_to_low">Price: High to Low</Label>
</div>
</RadioGroup>
{/if}
<Title size="h5" color="black">Price</Title>
{#if flightTicketVM.searching}
<div class="h-6 w-full animate-pulse rounded-full bg-gray-300"></div>
{:else}
<div class="flex w-full flex-col gap-2">
<Slider
type="multiple"
bind:value={priceRange}
min={0}
max={maxPrice}
step={1}
/>
<div class="flex items-center justify-between gap-2">
<div class="flex flex-col gap-1">
<p class="text-sm font-medium">Min</p>
<p class="text-sm text-gray-500">{priceRange[0]}</p>
</div>
<div class="flex flex-col gap-1">
<p class="text-sm font-medium">Max</p>
<p class="text-sm text-gray-500">{priceRange[1]}</p>
</div>
</div>
</div>
{/if}
<Title size="h5" color="black">Max Stops</Title>
{#if flightTicketVM.searching}
<div class="h-24 w-full animate-pulse rounded-lg bg-gray-300"></div>
{:else}
<RadioGroup
value={$ticketFiltersStore.maxStops}
onValueChange={(value) => {
ticketFiltersStore.update((prev) => ({
...prev,
maxStops: value as MaxStops,
}));
}}
>
{#each Object.entries(MaxStops) as [label, value]}
<div class="flex items-center space-x-2">
<RadioGroupItem {value} id={value} />
<Label for={value}>{label}</Label>
</div>
{/each}
</RadioGroup>
{/if}
{#if flightTicketVM.searching}
<div class="h-8 w-full animate-pulse rounded-lg bg-gray-300"></div>
{:else}
<div class="flex items-center space-x-2">
<Checkbox
checked={$ticketFiltersStore.allowOvernight}
onCheckedChange={(checked) => {
ticketFiltersStore.update((prev) => ({
...prev,
allowOvernight: checked,
}));
}}
id="overnight"
/>
<Label for="overnight">Allow Overnight Flights</Label>
</div>
{/if}
<Title size="h5" color="black">Time</Title>
{#if flightTicketVM.searching}
<div class="space-y-4">
<div class="h-16 w-full animate-pulse rounded-lg bg-gray-300"></div>
<div class="h-16 w-full animate-pulse rounded-lg bg-gray-300"></div>
</div>
{:else}
<div class="space-y-4">
<div class="flex flex-col gap-2">
<p class="mb-2 text-sm font-medium">Departure Time</p>
<Slider
type="multiple"
bind:value={departureTimeRange}
min={0}
max={24}
step={1}
/>
<div class="flex items-center justify-between gap-2">
<small>{departureTimeRange[0]}:00</small>
<small>{departureTimeRange[1]}:00</small>
</div>
</div>
<div class="flex flex-col gap-2">
<p class="mb-2 text-sm font-medium">Arrival Time</p>
<Slider
type="multiple"
bind:value={arrivalTimeRange}
min={0}
max={24}
step={1}
/>
<div class="flex items-center justify-between gap-2">
<small>{arrivalTimeRange[0]}:00</small>
<small>{arrivalTimeRange[1]}:00</small>
</div>
</div>
</div>
{/if}
<Title size="h5" color="black">Duration</Title>
{#if flightTicketVM.searching}
<div class="h-16 w-full animate-pulse rounded-lg bg-gray-300"></div>
{:else}
<div class="flex flex-col gap-2">
<p class="mb-2 text-sm font-medium">Max Duration</p>
<Slider
type="multiple"
bind:value={durationRange}
min={0}
max={100}
step={1}
/>
<div class="flex items-center justify-between gap-2">
<small>{durationRange[0]} hours</small>
<small>{durationRange[1]} hours</small>
</div>
</div>
{/if}
<Button onclick={() => onApply()} class="w-full">Apply Changes</Button>
</div>

View File

@@ -0,0 +1,24 @@
import { writable } from "svelte/store";
export enum SortOption {
Default = "default",
PriceLowToHigh = "price_low_to_high",
PriceHighToLow = "price_high_to_low",
}
export enum MaxStops {
Any = "any",
Direct = "direct",
One = "one",
Two = "two",
}
export const ticketFiltersStore = writable({
priceRange: { min: 0, max: 0 },
excludeCountries: [] as string[],
maxStops: MaxStops.Any,
allowOvernight: true,
time: { departure: { min: 0, max: 0 }, arrival: { min: 0, max: 0 } },
duration: { min: 0, max: 0 },
sortBy: SortOption.Default,
});

View File

@@ -0,0 +1,150 @@
<script lang="ts">
import { TicketType } from "../data/entities/index";
import AirportSearchInput from "$lib/domains/airport/view/airport-search-input.svelte";
import { ticketSearchStore } from "../data/store";
import FlightDateInput from "./flight-date-input.svelte";
import FlightDateRangeInput from "./flight-date-range-input.svelte";
import Icon from "$lib/components/atoms/icon.svelte";
import SearchIcon from "~icons/solar/magnifer-linear";
import SwapIcon from "~icons/ant-design/swap-outlined";
import Button from "$lib/components/ui/button/button.svelte";
import PassengerAndCabinClassSelect from "./passenger-and-cabin-class-select.svelte";
import { Label } from "$lib/components/ui/label/index.js";
import * as RadioGroup from "$lib/components/ui/radio-group/index.js";
import { airportVM } from "$lib/domains/airport/view/airport.vm.svelte";
import { browser } from "$app/environment";
import { onDestroy, onMount } from "svelte";
let { onSubmit, rowify = false }: { onSubmit: () => void; rowify?: boolean } =
$props();
let currTicketType = $state($ticketSearchStore.ticketType);
let isMobileView = $state(false);
function checkIfMobile() {
if (browser) {
isMobileView = window.innerWidth < 768;
}
}
function setupResizeListener() {
if (browser) {
window.addEventListener("resize", checkIfMobile);
checkIfMobile(); // Initial check
return () => {
window.removeEventListener("resize", checkIfMobile);
};
}
}
$effect(() => {
ticketSearchStore.update((prev) => {
return { ...prev, ticketType: currTicketType };
});
});
let cleanup: any | undefined = $state(undefined);
onMount(() => {
cleanup = setupResizeListener();
});
onDestroy(() => {
cleanup?.();
});
</script>
<div class="flex w-full flex-col gap-4">
<div
class="flex w-full flex-col items-center justify-between gap-4 lg:grid lg:grid-cols-2 lg:gap-4"
>
<RadioGroup.Root
bind:value={currTicketType}
class="flex w-full flex-row gap-6 p-2 lg:p-4"
>
<div class="flex items-center space-x-2">
<RadioGroup.Item
value={TicketType.Return}
id={TicketType.Return}
onselect={() => {
ticketSearchStore.update((prev) => {
return { ...prev, ticketType: TicketType.Return };
});
}}
/>
<Label for={TicketType.Return} class="md:text-lg">Return</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item
value={TicketType.OneWay}
id={TicketType.OneWay}
onselect={() => {
ticketSearchStore.update((prev) => {
return { ...prev, ticketType: TicketType.OneWay };
});
}}
/>
<Label for={TicketType.OneWay} class="md:text-lg">One Way</Label>
</div>
</RadioGroup.Root>
<PassengerAndCabinClassSelect />
</div>
<div
class="flex w-full flex-col items-center justify-between gap-2 lg:grid lg:grid-cols-2 lg:gap-4"
>
<div
class="flex w-full flex-col items-center justify-between gap-2 lg:flex-row"
>
<AirportSearchInput
currentValue={airportVM.departure}
onChange={(e) => {
airportVM.setDepartureAirport(e);
}}
placeholder="Depart from"
searchPlaceholder="Departure airport search"
isMobile={isMobileView}
fieldType="departure"
/>
<div class="hidden w-full max-w-fit md:block">
<Button
size="icon"
variant={rowify ? "outlineWhite" : "defaultInverted"}
onclick={() => {
airportVM.swapDepartureAndArrival();
}}
>
<Icon icon={SwapIcon} cls="w-auto h-6" />
</Button>
</div>
<AirportSearchInput
currentValue={airportVM.arrival}
onChange={(e) => {
airportVM.setArrivalAirport(e);
}}
placeholder="Arrive at"
searchPlaceholder="Arrival airport search"
isMobile={isMobileView}
fieldType="arrival"
/>
</div>
<div
class="flex w-full flex-col items-center justify-between gap-2 lg:flex-row lg:gap-2"
>
{#if $ticketSearchStore.ticketType === TicketType.Return}
<FlightDateRangeInput />
{:else}
<FlightDateInput />
{/if}
<Button onclick={() => onSubmit()} class={"w-full"} variant="default">
<Icon icon={SearchIcon} cls="w-auto h-4" />
Search flights
</Button>
</div>
</div>
</div>

View File

@@ -0,0 +1,398 @@
import {
type FlightPriceDetails,
type FlightTicket,
ticketSearchPayloadModel,
} from "$lib/domains/ticket/data/entities/index";
import { trpcApiStore } from "$lib/stores/api";
import { toast } from "svelte-sonner";
import { get } from "svelte/store";
import { airportVM } from "$lib/domains/airport/view/airport.vm.svelte";
import { goto, replaceState } from "$app/navigation";
import { page } from "$app/state";
import {
MaxStops,
SortOption,
ticketFiltersStore,
} from "./ticket-filters.vm.svelte";
import type { Result } from "@pkg/result";
import { flightTicketStore, ticketSearchStore } from "../data/store";
import { nanoid } from "nanoid";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import { ticketCheckoutVM } from "./checkout/flight-checkout.vm.svelte";
import { paymentInfoVM } from "./checkout/payment-info-section/payment.info.vm.svelte";
export class FlightTicketViewModel {
searching = $state(false);
tickets = $state<FlightTicket[]>([]);
renderedTickets = $state<FlightTicket[]>([]);
updatingPrices = $state(false);
beginSearch() {
const info = get(ticketSearchStore);
if (!info) {
return;
}
if (
info.passengerCounts.adults < 1 &&
info.passengerCounts.children < 1
) {
toast.error("Please enter at least one adult and one child");
return;
}
const sum = info.passengerCounts.adults + info.passengerCounts.children;
if (sum > 10) {
toast.error("Please enter no more than 10 passengers");
return;
}
const params = this.formatURLParams();
goto(`/search?${params.toString()}`);
}
loadStore(urlParams: URLSearchParams) {
console.log("Meta parameter: ", urlParams.get("meta"));
ticketSearchStore.update((prev) => {
return {
sessionId: prev.sessionId ?? "",
ticketType: urlParams.get("ticketType") ?? prev.ticketType,
cabinClass: urlParams.get("cabinClass") ?? prev.cabinClass,
passengerCounts: {
adults: Number(
urlParams.get("adults") ?? prev.passengerCounts.adults,
),
children: Number(
urlParams.get("children") ??
prev.passengerCounts.children,
),
},
departure: urlParams.get("departure") ?? prev.departure,
arrival: urlParams.get("arrival") ?? prev.arrival,
departureDate:
urlParams.get("departureDate") ?? prev.departureDate,
returnDate: urlParams.get("returnDate") ?? prev.returnDate,
loadMore: prev.loadMore ?? false,
meta: (() => {
const metaStr = urlParams.get("meta");
if (!metaStr) return prev.meta;
try {
return JSON.parse(metaStr);
} catch (e) {
console.error("Failed to parse meta parameter:", e);
return prev.meta;
}
})(),
};
});
}
resetCachedCheckoutData() {
// @ts-ignore
flightTicketStore.set(undefined);
passengerInfoVM.reset();
ticketCheckoutVM.reset();
paymentInfoVM.reset();
}
async searchForTickets(loadMore = false) {
const api = get(trpcApiStore);
if (!api) {
return toast.error("Please try again by reloading the page", {
description: "Page state not properly initialized",
});
}
let payload = get(ticketSearchStore);
if (!payload) {
return toast.error(
"Could not search for tickets due to invalid payload",
);
}
const parsed = ticketSearchPayloadModel.safeParse(payload);
if (!parsed.success) {
console.log("Enter some parameters to search for tickets");
this.searching = false;
return;
}
payload = parsed.data;
if (loadMore) {
payload.loadMore = true;
}
this.searching = true;
const out = await api.ticket.searchTickets.query(payload);
this.searching = false;
console.log(out);
if (out.error) {
return toast.error(out.error.message, {
description: out.error.userHint,
});
}
if (!out.data) {
this.tickets = [];
return toast.error("No search results", {
description: "Please try again with different parameters",
});
}
this.tickets = out.data;
this.applyFilters();
this.resetCachedCheckoutData();
}
applyFilters() {
this.searching = true;
const filters = get(ticketFiltersStore);
const filteredTickets = this.tickets.filter((ticket) => {
// Price filter
if (filters.priceRange.max > 0) {
if (
ticket.priceDetails.displayPrice < filters.priceRange.min ||
ticket.priceDetails.displayPrice > filters.priceRange.max
) {
return false;
}
}
if (filters.maxStops !== MaxStops.Any) {
// Calculate stops for outbound flight
const outboundStops =
ticket.flightIteneraries.outbound.length - 1;
// Calculate stops for inbound flight
const inboundStops = ticket.flightIteneraries.inbound.length - 1;
// Get the maximum number of stops between outbound and inbound
const maxStopsInJourney = Math.max(outboundStops, inboundStops);
switch (filters.maxStops) {
case MaxStops.Direct:
if (maxStopsInJourney > 0) return false;
break;
case MaxStops.One:
if (maxStopsInJourney > 1) return false;
break;
case MaxStops.Two:
if (maxStopsInJourney > 2) return false;
break;
}
}
// Time range filters
if (filters.time.departure.max > 0 || filters.time.arrival.max > 0) {
const allItineraries = [
...ticket.flightIteneraries.outbound,
...ticket.flightIteneraries.inbound,
];
for (const itinerary of allItineraries) {
const departureHour = new Date(
itinerary.departure.utcTime,
).getHours();
const arrivalHour = new Date(
itinerary.destination.utcTime,
).getHours();
if (filters.time.departure.max > 0) {
if (
departureHour < filters.time.departure.min ||
departureHour > filters.time.departure.max
) {
return false;
}
}
if (filters.time.arrival.max > 0) {
if (
arrivalHour < filters.time.arrival.min ||
arrivalHour > filters.time.arrival.max
) {
return false;
}
}
}
}
// Duration filter
if (filters.duration.max > 0) {
const allItineraries = [
...ticket.flightIteneraries.outbound,
...ticket.flightIteneraries.inbound,
];
const totalDuration = allItineraries.reduce(
(sum, itinerary) => sum + itinerary.durationSeconds,
0,
);
const durationHours = totalDuration / 3600;
if (
durationHours < filters.duration.min ||
durationHours > filters.duration.max
) {
return false;
}
}
// Overnight filter
if (!filters.allowOvernight) {
const allItineraries = [
...ticket.flightIteneraries.outbound,
...ticket.flightIteneraries.inbound,
];
const hasOvernightFlight = allItineraries.some((itinerary) => {
const departureHour = new Date(
itinerary.departure.utcTime,
).getHours();
const arrivalHour = new Date(
itinerary.destination.utcTime,
).getHours();
// Consider a flight overnight if it departs between 8 PM (20) and 6 AM (6)
return (
departureHour >= 20 ||
departureHour <= 6 ||
arrivalHour >= 20 ||
arrivalHour <= 6
);
});
if (hasOvernightFlight) return false;
}
return true;
});
filteredTickets.sort((a, b) => {
switch (filters.sortBy) {
case SortOption.PriceLowToHigh:
return (
a.priceDetails.displayPrice - b.priceDetails.displayPrice
);
case SortOption.PriceHighToLow:
return (
b.priceDetails.displayPrice - a.priceDetails.displayPrice
);
default:
return 0;
}
});
this.renderedTickets = filteredTickets;
this.searching = false;
}
async cacheTicketAndGotoCheckout(id: number) {
const api = get(trpcApiStore);
if (!api) {
return toast.error("Please try again by reloading the page", {
description: "Page state not properly initialized",
});
}
const targetTicket = this.tickets.find((ticket) => ticket.id === id);
if (!targetTicket) {
return toast.error("Ticket not found", {
description:
"Please try again with different parameters or refresh page to try again",
});
}
const sid = get(ticketSearchStore).sessionId;
console.log("sid", sid);
const out = await api.ticket.cacheTicket.mutate({
sid,
payload: targetTicket,
});
if (out.error) {
return toast.error(out.error.message, {
description: out.error.userHint,
});
}
if (!out.data) {
return toast.error("Failed to proceed to checkout", {
description: "Please refresh the page to try again",
});
}
goto(`/checkout/${sid}/${out.data}`);
}
redirectToSearchPage() {
const params = this.formatURLParams();
goto(`/search?${params.toString()}`);
}
setURLParams(): Result<boolean> {
ticketSearchStore.update((prev) => {
return { ...prev, sessionId: nanoid() };
});
const newParams = this.formatURLParams();
const url = new URL(page.url.href);
for (const [key, value] of newParams.entries()) {
url.searchParams.set(key, value);
}
let stripped = page.url.href.includes("?")
? page.url.href.split("?")[0]
: page.url.href;
replaceState(
new URL(stripped + "?" + new URLSearchParams(newParams)).toString(),
{},
);
return { data: true };
}
private formatURLParams() {
const info = get(ticketSearchStore);
let out = new URLSearchParams();
if (!info) {
return out;
}
out.append("ticketType", info.ticketType);
out.append("cabinClass", info.cabinClass);
out.append("adults", info.passengerCounts.adults.toString());
out.append("children", info.passengerCounts.children.toString());
if (airportVM.departure) {
out.append("departure", airportVM.departure?.iataCode);
}
if (airportVM.arrival) {
out.append("arrival", airportVM.arrival?.iataCode);
}
out.append("departureDate", info.departureDate);
out.append("returnDate", info.returnDate);
out.append("meta", JSON.stringify(info.meta));
return out;
}
async updateTicketPrices(updated: FlightPriceDetails) {
const api = get(trpcApiStore);
if (!api) {
return;
}
const tid = get(flightTicketStore).id;
this.updatingPrices = true;
const out = await api.ticket.updateTicketPrices.mutate({
tid,
payload: updated,
});
this.updatingPrices = false;
console.log("new shit");
console.log(out);
if (out.error) {
toast.error(out.error.message, {
description: out.error.userHint,
});
}
if (!out.data) {
return;
}
flightTicketStore.update((prev) => {
return { ...prev, priceDetails: out.data! };
});
}
}
export const flightTicketVM = new FlightTicketViewModel();

View File

@@ -0,0 +1,50 @@
<script lang="ts">
import BackpackIcon from "~icons/solar/backpack-broken";
import BagCheckIcon from "~icons/solar/suitcase-broken";
import PersonalBagIcon from "~icons/solar/bag-broken";
import Icon from "$lib/components/atoms/icon.svelte";
import Title from "$lib/components/atoms/title.svelte";
import type { FlightTicket } from "../../data/entities";
let { data }: { data: FlightTicket } = $props();
</script>
{#if data}
<Title size="h5" color="black">Baggage Info</Title>
<div class="flex flex-col gap-4">
<!-- Personal Item - Always show -->
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<Icon icon={PersonalBagIcon} cls="w-6 h-6" />
<span>Personal Item</span>
</div>
<span>{data.bagsInfo.includedPersonalBags} included</span>
</div>
<!-- Cabin Baggage - Always show -->
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<Icon icon={BackpackIcon} cls="w-6 h-6" />
<span>Cabin Baggage</span>
</div>
{#if data.bagsInfo.hasHandBagsSupport}
<span>{data.bagsInfo.includedHandBags} included</span>
{:else}
<span class="text-gray-500">Not available</span>
{/if}
</div>
<!-- Checked Baggage - Always show -->
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<Icon icon={BagCheckIcon} cls="w-6 h-6" />
<span>Checked Baggage</span>
</div>
{#if data.bagsInfo.hasCheckedBagsSupport}
<span>{data.bagsInfo.includedCheckedBags} included</span>
{:else}
<span class="text-gray-500">Not available</span>
{/if}
</div>
</div>
{/if}

View File

@@ -0,0 +1,87 @@
<script lang="ts">
import Title from "$lib/components/atoms/title.svelte";
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 { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte";
let { data }: { data: FlightTicket } = $props();
async function proceedToCheckout() {
await flightTicketVM.cacheTicketAndGotoCheckout(data.id);
}
</script>
<div class="flex flex-col md:flex-row">
<TicketDetailsModal {data} onCheckoutBtnClick={proceedToCheckout}>
<Dialog.Trigger
class={cn(
"flex w-full flex-col justify-center gap-4 rounded-lg border-x-0 border-t-2 border-gray-200 bg-white p-6 shadow-md hover:bg-gray-50 md:border-y-2 md:border-l-2 md:border-r-2",
TRANSITION_ALL,
)}
>
<TicketLegsOverview {data} />
<div class="flex items-center gap-2"></div>
</Dialog.Trigger>
</TicketDetailsModal>
<div
class={cn(
"flex w-full flex-col items-center justify-center gap-4 rounded-lg border-x-0 border-b-2 border-gray-200 bg-white p-6 shadow-md md:max-w-xs md:border-y-2 md:border-l-0 md:border-r-2",
TRANSITION_ALL,
)}
>
<!-- Add price comparison logic here -->
{#if data.priceDetails.basePrice !== data.priceDetails.displayPrice}
{@const discountPercentage = Math.round(
(1 -
data.priceDetails.displayPrice /
data.priceDetails.basePrice) *
100,
)}
<div class="flex flex-col items-center gap-1">
<Badge variant="destructive">
<span>{discountPercentage}% OFF</span>
</Badge>
<div class="text-gray-500 line-through">
{convertAndFormatCurrency(data.priceDetails.basePrice)}
</div>
<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">
{convertAndFormatCurrency(data.priceDetails.displayPrice)}
</Title>
{/if}
<div class="flex w-full flex-col gap-1">
<TicketDetailsModal {data} onCheckoutBtnClick={proceedToCheckout}>
<Dialog.Trigger
class={cn(buttonVariants({ variant: "secondary" }))}
>
Flight Info
</Dialog.Trigger>
</TicketDetailsModal>
<Button onclick={() => proceedToCheckout()}>Checkout</Button>
</div>
</div>
</div>

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import * as Dialog from "$lib/components/ui/dialog/index.js";
import type { FlightTicket } from "../../data/entities";
import TicketDetails from "./ticket-details.svelte";
let {
data,
onCheckoutBtnClick,
hideCheckoutBtn,
children,
}: {
data: FlightTicket;
children: any;
hideCheckoutBtn?: boolean;
onCheckoutBtnClick: (tid: number) => void;
} = $props();
</script>
<Dialog.Root>
{@render children()}
<Dialog.Content class="w-full max-w-2xl">
<div class="flex max-h-[80vh] w-full flex-col gap-4 overflow-y-auto">
<TicketDetails {data} {hideCheckoutBtn} {onCheckoutBtnClick} />
</div>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import Title from "$lib/components/atoms/title.svelte";
import Button from "$lib/components/ui/button/button.svelte";
import { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte";
import { type FlightTicket } from "../../data/entities";
import BaggageInfo from "./baggage-info.svelte";
import TripDetails from "./trip-details.svelte";
let {
data,
hideCheckoutBtn,
onCheckoutBtnClick,
}: {
data: FlightTicket;
onCheckoutBtnClick: (tid: number) => void;
hideCheckoutBtn?: boolean;
} = $props();
</script>
<TripDetails {data} />
<BaggageInfo {data} />
{#if !hideCheckoutBtn}
<div class="mt-4 flex items-center justify-end gap-4">
<Title center size="h5" color="black">
{convertAndFormatCurrency(data.priceDetails.displayPrice)}
</Title>
<Button onclick={() => onCheckoutBtnClick(data.id)}>Checkout</Button>
</div>
{/if}

View File

@@ -0,0 +1,88 @@
<script lang="ts">
import Title from "$lib/components/atoms/title.svelte";
import { Badge } from "$lib/components/ui/badge/index.js";
import { Plane } from "@lucide/svelte";
interface Props {
departure: string;
destination: string;
durationSeconds: number;
departureTime: string;
arrivalTime: string;
departureDate: string;
arrivalDate: string;
airlineName?: string;
stops?: number;
}
let {
departure,
destination,
durationSeconds,
airlineName,
stops,
departureDate,
departureTime,
arrivalDate,
arrivalTime,
}: Props = $props();
const dH = Math.floor(durationSeconds / 3600);
const dM = Math.floor((durationSeconds % 3600) / 60);
const durationFormatted = `${dH}h ${dM}m`;
</script>
<div class="flex w-full flex-col gap-4 p-2">
<div class="flex w-full items-start justify-between gap-4">
<div class="flex flex-col items-start text-start">
<Title color="black" size="h5">{departure}</Title>
<Title color="black" size="p" weight="normal">
{departureTime}
</Title>
<span class="text-xs text-gray-500 sm:text-sm">{departureDate}</span>
</div>
<div class="flex flex-col items-center gap-2 pt-2">
<div class="flex items-center gap-2">
<div class="h-2 w-2 rounded-full bg-gray-400"></div>
<div class="relative">
<div class="w-20 border-t-2 border-gray-300 md:w-40"></div>
<Plane
class="absolute -top-[10px] left-1/2 -translate-x-1/2 transform text-primary"
size={20}
/>
</div>
<div class="h-2 w-2 rounded-full bg-gray-400"></div>
</div>
<span class="text-sm font-medium text-gray-600">
{durationFormatted}
</span>
<div class="flex flex-col items-center gap-1">
{#if stops !== undefined}
<Badge
variant={stops > 0 ? "secondary" : "outline"}
class="font-medium"
>
{#if stops > 0}
{stops} {stops === 1 ? "Stop" : "Stops"}
{:else}
Direct Flight
{/if}
</Badge>
{/if}
{#if airlineName}
<span class="text-xs text-gray-500">{airlineName}</span>
{/if}
</div>
</div>
<div class="flex flex-col items-end text-end">
<Title color="black" size="h5">{destination}</Title>
<Title color="black" size="p" weight="normal">
{arrivalTime}
</Title>
<span class="text-xs text-gray-500 sm:text-sm">{arrivalDate}</span>
</div>
</div>
</div>

View File

@@ -0,0 +1,103 @@
<script lang="ts">
import { TicketType, type FlightTicket } from "../../data/entities";
import { Badge } from "$lib/components/ui/badge/index.js";
import TicketItinerary from "./ticket-itinerary.svelte";
let { data }: { data: FlightTicket } = $props();
const outboundDuration = data.flightIteneraries.outbound.reduce(
(acc, curr) => acc + curr.durationSeconds,
0,
);
const inboundDuration = data.flightIteneraries.inbound.reduce(
(acc, curr) => acc + curr.durationSeconds,
0,
);
const isReturnTicket =
data.flightIteneraries.inbound.length > 0 &&
data.flightType === TicketType.Return;
function formatDateTime(dateTimeStr: string) {
const date = new Date(dateTimeStr);
return {
time: date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
}),
date: date.toLocaleDateString("en-US", {
weekday: "short",
day: "2-digit",
month: "short",
}),
};
}
const outboundFirst = data.flightIteneraries.outbound[0];
const outboundLast =
data.flightIteneraries.outbound[
data.flightIteneraries.outbound.length - 1
];
const inboundFirst = isReturnTicket
? data.flightIteneraries.inbound[0]
: null;
const inboundLast = isReturnTicket
? data.flightIteneraries.inbound[
data.flightIteneraries.inbound.length - 1
]
: null;
const outboundDeparture = formatDateTime(outboundFirst.departure.localTime);
const outboundArrival = formatDateTime(outboundLast.destination.localTime);
const inboundDeparture = inboundFirst
? formatDateTime(inboundFirst.departure.localTime)
: null;
const inboundArrival = inboundLast
? formatDateTime(inboundLast.destination.localTime)
: null;
</script>
{#if isReturnTicket}
<Badge variant="outline" class="w-max">
<span>Outbound</span>
</Badge>
{/if}
<TicketItinerary
departure={data.departure}
destination={data.arrival}
durationSeconds={outboundDuration}
stops={data.flightIteneraries.outbound.length - 1}
departureTime={outboundDeparture.time}
departureDate={outboundDeparture.date}
arrivalTime={outboundArrival.time}
arrivalDate={outboundArrival.date}
airlineName={outboundFirst.airline.name}
/>
{#if isReturnTicket}
<div class="w-full border-t-2 border-dashed border-gray-400"></div>
{#if isReturnTicket}
<Badge variant="outline" class="w-max">
<span>Inbound</span>
</Badge>
{/if}
<TicketItinerary
departure={data.arrival}
destination={data.departure}
durationSeconds={inboundDuration}
stops={data.flightIteneraries.inbound.length - 1}
departureTime={inboundDeparture?.time || ""}
departureDate={inboundDeparture?.date || ""}
arrivalTime={inboundArrival?.time || ""}
arrivalDate={inboundArrival?.date || ""}
airlineName={inboundFirst?.airline.name}
/>
{:else}
<div class="w-full border-t-2 border-dashed border-gray-400"></div>
{/if}

View File

@@ -0,0 +1,212 @@
<script lang="ts">
import Title from "$lib/components/atoms/title.svelte";
import { TicketType, type FlightTicket } from "../../data/entities";
import * as Accordion from "$lib/components/ui/accordion";
import { Badge } from "$lib/components/ui/badge";
import { formatDateTime } from "@pkg/logic/core/date.utils";
let { data }: { data: FlightTicket } = $props();
const isReturnTicket = data.flightType === TicketType.Return;
function formatDuration(seconds: number) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}h ${minutes}m`;
}
</script>
<Title size="h4" color="black">Trip Details</Title>
<Accordion.Root type="single" class="flex w-full flex-col gap-4">
<Accordion.Item
value="outbound"
class="rounded-lg border-2 border-gray-200 bg-white px-4 shadow-md"
>
<Accordion.Trigger class="w-full">
<div class="flex w-full flex-col gap-2">
<Badge variant="outline" class="w-max">Outbound</Badge>
<div class="flex w-full items-center justify-between">
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<span class="font-medium">
{data.flightIteneraries.outbound[0].departure
.station.code}
</span>
<span class="text-gray-500"></span>
<span class="font-medium">
{data.flightIteneraries.outbound[
data.flightIteneraries.outbound.length - 1
].destination.station.code}
</span>
</div>
<span class="text-sm text-gray-600">
{data.flightIteneraries.outbound[0].airline.name}
</span>
</div>
<span>
{formatDuration(
data.flightIteneraries.outbound.reduce(
(acc, curr) => acc + curr.durationSeconds,
0,
),
)}
</span>
</div>
</div>
</Accordion.Trigger>
<Accordion.Content>
<div
class="flex flex-col gap-4 border-t-2 border-dashed border-gray-200 p-4"
>
{#each data.flightIteneraries.outbound as flight, index}
{#if index > 0}
<div class="my-2 border-t border-dashed"></div>
{/if}
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span>Flight {flight.flightNumber}</span>
<span class="text-sm text-gray-500">
{flight.airline.name}
</span>
</div>
<span>{formatDuration(flight.durationSeconds)}</span>
</div>
<div class="flex justify-between">
<div class="flex flex-col">
<span class="text-sm font-semibold">
{formatDateTime(flight.departure.localTime)
.time}
</span>
<span class="text-sm text-gray-500">
{formatDateTime(flight.departure.localTime)
.date}
</span>
<span class="mt-1 text-sm text-gray-600">
{flight.departure.station.city} ({flight
.departure.station.code})
</span>
</div>
<div class="flex flex-col items-end">
<span class="text-sm font-semibold">
{formatDateTime(flight.destination.localTime)
.time}
</span>
<span class="text-sm text-gray-500">
{formatDateTime(flight.destination.localTime)
.date}
</span>
<span class="mt-1 text-sm text-gray-600">
{flight.destination.station.city} ({flight
.destination.station.code})
</span>
</div>
</div>
</div>
{/each}
</div>
</Accordion.Content>
</Accordion.Item>
{#if isReturnTicket && data.flightIteneraries.inbound.length > 0}
<Accordion.Item
value="inbound"
class="rounded-lg border-2 border-gray-200 bg-white px-4 shadow-md"
>
<Accordion.Trigger class="w-full">
<div class="flex w-full flex-col gap-2">
<Badge variant="outline" class="w-max">Inbound</Badge>
<div class="flex w-full items-center justify-between">
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<span class="font-medium">
{data.flightIteneraries.inbound[0].departure
.station.code}
</span>
<span class="text-gray-500"></span>
<span class="font-medium">
{data.flightIteneraries.inbound[
data.flightIteneraries.inbound.length - 1
].destination.station.code}
</span>
</div>
<span class="text-sm text-gray-600">
{data.flightIteneraries.inbound[0].airline.name}
</span>
</div>
<span>
{formatDuration(
data.flightIteneraries.inbound.reduce(
(acc, curr) => acc + curr.durationSeconds,
0,
),
)}
</span>
</div>
</div>
</Accordion.Trigger>
<Accordion.Content>
<div
class="flex flex-col gap-4 border-t-2 border-dashed border-gray-200 p-4"
>
{#each data.flightIteneraries.inbound as flight, index}
{#if index > 0}
<div class="my-2 border-t border-dashed"></div>
{/if}
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span>Flight {flight.flightNumber}</span>
<span class="text-sm text-gray-500">
{flight.airline.name}
</span>
</div>
<span
>{formatDuration(
flight.durationSeconds,
)}</span
>
</div>
<div class="flex justify-between">
<div class="flex flex-col">
<span class="text-sm font-semibold">
{formatDateTime(
flight.departure.localTime,
).time}
</span>
<span class="text-sm text-gray-500">
{formatDateTime(
flight.departure.localTime,
).date}
</span>
<span class="mt-1 text-sm text-gray-600">
{flight.departure.station.city} ({flight
.departure.station.code})
</span>
</div>
<div class="flex flex-col items-end">
<span class="text-sm font-semibold">
{formatDateTime(
flight.destination.localTime,
).time}
</span>
<span class="text-sm text-gray-500">
{formatDateTime(
flight.destination.localTime,
).date}
</span>
<span class="mt-1 text-sm text-gray-600">
{flight.destination.station.city} ({flight
.destination.station.code})
</span>
</div>
</div>
</div>
{/each}
</div>
</Accordion.Content>
</Accordion.Item>
{/if}
</Accordion.Root>