stashing code
This commit is contained in:
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
<span>show checkout confirmation status here</span>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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>
|
||||
398
apps/frontend/src/lib/domains/ticket/view/ticket.vm.svelte.ts
Normal file
398
apps/frontend/src/lib/domains/ticket/view/ticket.vm.svelte.ts
Normal 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();
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user