refactor: checkout, pass. info, old code removal
This commit is contained in:
@@ -2,16 +2,16 @@
|
|||||||
import Icon from "$lib/components/atoms/icon.svelte";
|
import Icon from "$lib/components/atoms/icon.svelte";
|
||||||
import { buttonVariants } from "$lib/components/ui/button/button.svelte";
|
import { buttonVariants } from "$lib/components/ui/button/button.svelte";
|
||||||
import { Sheet, SheetContent, SheetTrigger } from "$lib/components/ui/sheet";
|
import { Sheet, SheetContent, SheetTrigger } from "$lib/components/ui/sheet";
|
||||||
|
import { CheckoutStep } from "$lib/domains/order/data/entities";
|
||||||
import { cn } from "$lib/utils";
|
import { cn } from "$lib/utils";
|
||||||
import ChevronDownIcon from "~icons/lucide/chevron-down";
|
import ChevronDownIcon from "~icons/lucide/chevron-down";
|
||||||
import CloseIcon from "~icons/lucide/x";
|
import CloseIcon from "~icons/lucide/x";
|
||||||
import { CheckoutStep } from "../../data/entities";
|
|
||||||
import { checkoutVM } from "./checkout.vm.svelte";
|
import { checkoutVM } from "./checkout.vm.svelte";
|
||||||
|
|
||||||
const checkoutSteps = [
|
const checkoutSteps = [
|
||||||
{ id: CheckoutStep.Initial, label: "Passenger Details" },
|
{ id: CheckoutStep.Initial, label: "Initial Details" },
|
||||||
{ id: CheckoutStep.Payment, label: "Payment" },
|
{ id: CheckoutStep.Payment, label: "Payment" },
|
||||||
{ id: CheckoutStep.Verification, label: "Verify Details" },
|
{ id: CheckoutStep.Verification, label: "Verify" },
|
||||||
{ id: CheckoutStep.Confirmation, label: "Confirmation" },
|
{ id: CheckoutStep.Confirmation, label: "Confirmation" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import * as Select from "$lib/components/ui/select";
|
import * as Select from "$lib/components/ui/select";
|
||||||
import { COUNTRIES_SELECT } from "$lib/core/countries";
|
import { COUNTRIES_SELECT } from "$lib/core/countries";
|
||||||
import { capitalize } from "$lib/core/string.utils";
|
import { capitalize } from "$lib/core/string.utils";
|
||||||
import type { CustomerInfoModel } from "$lib/domains/ticket/data/entities/create.entities";
|
import type { CustomerInfoModel } from "$lib/domains/customerinfo/data";
|
||||||
import { billingDetailsVM } from "./billing.details.vm.svelte";
|
import { billingDetailsVM } from "./billing.details.vm.svelte";
|
||||||
|
|
||||||
let { info = $bindable() }: { info: CustomerInfoModel } = $props();
|
let { info = $bindable() }: { info: CustomerInfoModel } = $props();
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { type CustomerInfoModel, Gender } from "$lib/domains/customerinfo/data";
|
import {
|
||||||
import { customerInfoModel } from "$lib/domains/passengerinfo/data/entities";
|
type CustomerInfoModel,
|
||||||
|
customerInfoModel,
|
||||||
|
Gender,
|
||||||
|
} from "$lib/domains/customerinfo/data";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export class BillingDetailsViewModel {
|
export class BillingDetailsViewModel {
|
||||||
|
|||||||
@@ -2,21 +2,13 @@
|
|||||||
import ButtonLoadableText from "$lib/components/atoms/button-loadable-text.svelte";
|
import ButtonLoadableText from "$lib/components/atoms/button-loadable-text.svelte";
|
||||||
import Icon from "$lib/components/atoms/icon.svelte";
|
import Icon from "$lib/components/atoms/icon.svelte";
|
||||||
import Title from "$lib/components/atoms/title.svelte";
|
import Title from "$lib/components/atoms/title.svelte";
|
||||||
import Button, {
|
import Button from "$lib/components/ui/button/button.svelte";
|
||||||
buttonVariants,
|
|
||||||
} from "$lib/components/ui/button/button.svelte";
|
|
||||||
import * as Dialog from "$lib/components/ui/dialog";
|
|
||||||
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
|
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
|
||||||
|
import { customerInfoVM } from "$lib/domains/customerinfo/view/customerinfo.vm.svelte";
|
||||||
import { CheckoutStep } from "$lib/domains/order/data/entities";
|
import { CheckoutStep } from "$lib/domains/order/data/entities";
|
||||||
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
|
|
||||||
import { flightTicketStore } from "$lib/domains/ticket/data/store";
|
|
||||||
import TicketDetailsModal from "$lib/domains/ticket/view/ticket/ticket-details-modal.svelte";
|
|
||||||
import { cn } from "$lib/utils";
|
|
||||||
import { formatDate } from "@pkg/logic/core/date.utils";
|
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import RightArrowIcon from "~icons/solar/arrow-right-broken";
|
import RightArrowIcon from "~icons/solar/arrow-right-broken";
|
||||||
import ArrowsExchangeIcon from "~icons/tabler/arrows-exchange-2";
|
|
||||||
import { checkoutVM } from "../checkout.vm.svelte";
|
import { checkoutVM } from "../checkout.vm.svelte";
|
||||||
import BillingDetailsForm from "./billing-details-form.svelte";
|
import BillingDetailsForm from "./billing-details-form.svelte";
|
||||||
import { billingDetailsVM } from "./billing.details.vm.svelte";
|
import { billingDetailsVM } from "./billing.details.vm.svelte";
|
||||||
@@ -56,14 +48,6 @@
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
let outboundFlight = $derived(
|
|
||||||
$flightTicketStore?.flightIteneraries.outbound[0],
|
|
||||||
);
|
|
||||||
let inboundFlight = $derived(
|
|
||||||
$flightTicketStore?.flightIteneraries.inbound[0],
|
|
||||||
);
|
|
||||||
let isReturnFlight = $derived($flightTicketStore?.flightType === "Return");
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!ckFlowVM.flowId || !ckFlowVM.setupDone) return;
|
if (!ckFlowVM.flowId || !ckFlowVM.setupDone) return;
|
||||||
if (!paymentInfoVM.cardDetails) return;
|
if (!paymentInfoVM.cardDetails) return;
|
||||||
@@ -82,79 +66,17 @@
|
|||||||
|
|
||||||
billingDetailsVM.validatePII(billingDetailsVM.billingDetails);
|
billingDetailsVM.validatePII(billingDetailsVM.billingDetails);
|
||||||
if (billingDetailsVM.isPIIValid()) {
|
if (billingDetailsVM.isPIIValid()) {
|
||||||
console.log("Billing details are valid, not setting from pasenger");
|
console.log("Billing details are valid, not setting from initials");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (passengerInfoVM.passengerInfos.length > 0) {
|
if (!customerInfoVM.customerInfo) return;
|
||||||
billingDetailsVM.setPII(
|
billingDetailsVM.setPII(customerInfoVM.customerInfo);
|
||||||
passengerInfoVM.passengerInfos[0].passengerPii,
|
|
||||||
);
|
|
||||||
billingDetailsVM.validatePII(billingDetailsVM.billingDetails);
|
billingDetailsVM.validatePII(billingDetailsVM.billingDetails);
|
||||||
toast("Used billing details from primary passenger");
|
toast("Used billing details from initial info");
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<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}>
|
<div class={cardStyle}>
|
||||||
<Title size="h4">Order Summary</Title>
|
<Title size="h4">Order Summary</Title>
|
||||||
<OrderSummary />
|
<OrderSummary />
|
||||||
|
|||||||
@@ -4,28 +4,23 @@
|
|||||||
convertAndFormatCurrency,
|
convertAndFormatCurrency,
|
||||||
currencyStore,
|
currencyStore,
|
||||||
} from "$lib/domains/currency/view/currency.vm.svelte";
|
} from "$lib/domains/currency/view/currency.vm.svelte";
|
||||||
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
|
import { customerInfoVM } from "../customerinfo/view/customerinfo.vm.svelte";
|
||||||
import { flightTicketStore } from "../../data/store";
|
import { productStore } from "../product/store";
|
||||||
import { calculateTicketPrices } from "./total.calculator";
|
import { calculateFinalPrices } from "./utils";
|
||||||
|
|
||||||
let totals = $state(
|
let priceDetails = $state(
|
||||||
calculateTicketPrices($flightTicketStore, passengerInfoVM.passengerInfos),
|
calculateFinalPrices($productStore, customerInfoVM.customerInfo),
|
||||||
);
|
);
|
||||||
let changing = $state(false);
|
let calculating = $state(false);
|
||||||
|
|
||||||
|
// Reactively update price details when product or customer info changes
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
changing = true;
|
calculating = true;
|
||||||
totals = calculateTicketPrices(
|
priceDetails = calculateFinalPrices(
|
||||||
$flightTicketStore,
|
$productStore,
|
||||||
passengerInfoVM.passengerInfos,
|
customerInfoVM.customerInfo,
|
||||||
);
|
);
|
||||||
changing = false;
|
calculating = false;
|
||||||
});
|
|
||||||
|
|
||||||
flightTicketStore.subscribe((val) => {
|
|
||||||
changing = true;
|
|
||||||
totals = calculateTicketPrices(val, passengerInfoVM.passengerInfos);
|
|
||||||
changing = false;
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -33,116 +28,69 @@
|
|||||||
<Title size="h4" weight="medium">Payment Summary</Title>
|
<Title size="h4" weight="medium">Payment Summary</Title>
|
||||||
<div class="h-0.5 w-full border-t-2 border-gray-200"></div>
|
<div class="h-0.5 w-full border-t-2 border-gray-200"></div>
|
||||||
|
|
||||||
{#if !changing}
|
{#if !calculating}
|
||||||
<!-- Base Ticket Price Breakdown -->
|
<!-- Product Information -->
|
||||||
|
{#if $productStore}
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<Title size="p" weight="medium">Base Ticket Price</Title>
|
<Title size="p" weight="medium">{$productStore.title}</Title>
|
||||||
|
<p class="text-sm text-gray-600">{$productStore.description}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Price Breakdown -->
|
||||||
|
<div class="mt-2 flex flex-col gap-2 border-t pt-4">
|
||||||
<div class="flex justify-between text-sm">
|
<div class="flex justify-between text-sm">
|
||||||
<span>Total Ticket Price</span>
|
<span>Base Price</span>
|
||||||
<span>{convertAndFormatCurrency(totals.baseTicketPrice)}</span>
|
<span>{convertAndFormatCurrency(priceDetails.basePrice)}</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>
|
</div>
|
||||||
|
|
||||||
<!-- Baggage Costs -->
|
{#if priceDetails.discountAmount > 0}
|
||||||
{#if totals.totalBaggageCost > 0}
|
<div class="flex justify-between text-sm text-green-600">
|
||||||
<div class="mt-2 flex flex-col gap-2 border-t pt-2">
|
<span>Discount</span>
|
||||||
<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
|
<span
|
||||||
>{convertAndFormatCurrency(totals.totalBaggageCost)}</span
|
>-{convertAndFormatCurrency(
|
||||||
|
priceDetails.discountAmount,
|
||||||
|
)}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<span>Display Price</span>
|
||||||
|
<span
|
||||||
|
>{convertAndFormatCurrency(
|
||||||
|
priceDetails.displayPrice,
|
||||||
|
)}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Final Total -->
|
<!-- Final Total -->
|
||||||
<div class="mt-4 flex flex-col gap-2 border-t pt-4">
|
<div class="mt-4 flex flex-col gap-2 border-t pt-4">
|
||||||
<div class="flex justify-between text-sm">
|
|
||||||
<span>Subtotal</span>
|
|
||||||
<span>{convertAndFormatCurrency(totals.subtotal)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if totals.discountAmount > 0}
|
|
||||||
<div class="flex justify-between text-sm text-green-600">
|
|
||||||
<span>Discount</span>
|
|
||||||
<span>-{convertAndFormatCurrency(totals.discountAmount)}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="flex justify-between font-medium">
|
<div class="flex justify-between font-medium">
|
||||||
<Title size="h5" weight="medium"
|
<Title size="h5" weight="medium"
|
||||||
>Total ({$currencyStore.code})</Title
|
>Total ({$currencyStore.code})</Title
|
||||||
>
|
>
|
||||||
<span>{convertAndFormatCurrency(totals.finalTotal)}</span>
|
<span class="text-lg"
|
||||||
|
>{convertAndFormatCurrency(priceDetails.orderPrice)}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="grid place-items-center p-2 text-center">
|
<div class="grid place-items-center p-8 text-center">
|
||||||
<span>Calculating . . .</span>
|
<span class="text-gray-600">Calculating...</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Important Information -->
|
||||||
<div class="mt-4 rounded-lg bg-gray-50 p-4 text-xs text-gray-600">
|
<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>
|
<p class="mb-2 font-medium">Important Information:</p>
|
||||||
<ul class="list-disc space-y-1 pl-4">
|
<ul class="list-disc space-y-1 pl-4">
|
||||||
<li>Prices include all applicable taxes and fees</li>
|
<li>Price includes all applicable taxes and fees</li>
|
||||||
<li>Cancellation and change fees may apply as per our policy</li>
|
<li>
|
||||||
<li>Additional baggage fees may apply based on airline policy</li>
|
Cancellation and refund policies may apply as per our terms of
|
||||||
|
service
|
||||||
|
</li>
|
||||||
|
<li>Payment will be processed securely upon order confirmation</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
import { trpcApiStore } from "$lib/stores/api";
|
|
||||||
import { toast } from "svelte-sonner";
|
|
||||||
import { get } from "svelte/store";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type {
|
import type { CreateCustomerInfoPayload, CustomerInfoModel } from "../data";
|
||||||
CreateCustomerInfoPayload,
|
|
||||||
CustomerInfoModel,
|
|
||||||
UpdateCustomerInfoPayload,
|
|
||||||
} from "../data";
|
|
||||||
import { customerInfoModel } from "../data";
|
import { customerInfoModel } from "../data";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,136 +25,6 @@ export class CustomerInfoViewModel {
|
|||||||
this.errors = {};
|
this.errors = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches a single customer info record by ID and sets it as current
|
|
||||||
* @param id - Customer info ID to fetch
|
|
||||||
* @returns true if successful, false otherwise
|
|
||||||
*/
|
|
||||||
async fetchCustomerInfoById(id: number): Promise<boolean> {
|
|
||||||
const api = get(trpcApiStore);
|
|
||||||
if (!api) {
|
|
||||||
toast.error("API client not initialized");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.loading = true;
|
|
||||||
try {
|
|
||||||
const result = await api.customerInfo.getCustomerInfoById.query({
|
|
||||||
id,
|
|
||||||
});
|
|
||||||
if (result.error) {
|
|
||||||
toast.error(result.error.message, {
|
|
||||||
description: result.error.userHint,
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.customerInfo = result.data || null;
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
toast.error("Failed to fetch customer information", {
|
|
||||||
description: "Please try again later",
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new customer information record
|
|
||||||
* @param payload - Customer info data to create
|
|
||||||
* @returns Customer info ID if successful, null otherwise
|
|
||||||
*/
|
|
||||||
async createCustomerInfo(
|
|
||||||
payload: CreateCustomerInfoPayload,
|
|
||||||
): Promise<number | null> {
|
|
||||||
const api = get(trpcApiStore);
|
|
||||||
if (!api) {
|
|
||||||
toast.error("API client not initialized");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate before submitting
|
|
||||||
if (!this.validateCustomerInfo(payload)) {
|
|
||||||
toast.error("Please fix validation errors before submitting");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.formLoading = true;
|
|
||||||
try {
|
|
||||||
const result =
|
|
||||||
await api.customerInfo.createCustomerInfo.mutate(payload);
|
|
||||||
if (result.error) {
|
|
||||||
toast.error(result.error.message, {
|
|
||||||
description: result.error.userHint,
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success("Customer information saved successfully");
|
|
||||||
return result.data || null;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
toast.error("Failed to save customer information", {
|
|
||||||
description: "Please try again later",
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
this.formLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the current customer information record
|
|
||||||
* @param payload - Customer info data to update (must include id)
|
|
||||||
* @returns true if successful, false otherwise
|
|
||||||
*/
|
|
||||||
async updateCustomerInfo(
|
|
||||||
payload: UpdateCustomerInfoPayload,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const api = get(trpcApiStore);
|
|
||||||
if (!api) {
|
|
||||||
toast.error("API client not initialized");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!payload.id) {
|
|
||||||
toast.error("Customer ID is required for update");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.formLoading = true;
|
|
||||||
try {
|
|
||||||
const result =
|
|
||||||
await api.customerInfo.updateCustomerInfo.mutate(payload);
|
|
||||||
if (result.error) {
|
|
||||||
toast.error(result.error.message, {
|
|
||||||
description: result.error.userHint,
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success("Customer information updated successfully");
|
|
||||||
|
|
||||||
// Update local state with the updated data
|
|
||||||
if (this.customerInfo && this.customerInfo.id === payload.id) {
|
|
||||||
this.customerInfo = { ...this.customerInfo, ...payload };
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
toast.error("Failed to update customer information", {
|
|
||||||
description: "Please try again later",
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
this.formLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates customer information data using Zod schema
|
* Validates customer information data using Zod schema
|
||||||
* @param info - Customer info to validate
|
* @param info - Customer info to validate
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export * from "@pkg/logic/domains/passengerinfo/data/entities";
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
import { eq, inArray, type Database } from "@pkg/db";
|
|
||||||
import { passengerInfo, passengerPII } from "@pkg/db/schema";
|
|
||||||
import { getError, Logger } from "@pkg/logger";
|
|
||||||
import { ERROR_CODES, type Result } from "@pkg/result";
|
|
||||||
import {
|
|
||||||
passengerInfoModel,
|
|
||||||
type CustomerInfoModel,
|
|
||||||
type PassengerInfo,
|
|
||||||
} from "./entities";
|
|
||||||
|
|
||||||
export class PassengerInfoRepository {
|
|
||||||
private db: Database;
|
|
||||||
|
|
||||||
constructor(db: Database) {
|
|
||||||
this.db = db;
|
|
||||||
}
|
|
||||||
|
|
||||||
async createPassengerPii(
|
|
||||||
payload: CustomerInfoModel,
|
|
||||||
): Promise<Result<number>> {
|
|
||||||
try {
|
|
||||||
const out = await this.db
|
|
||||||
.insert(passengerPII)
|
|
||||||
.values({
|
|
||||||
firstName: payload.firstName,
|
|
||||||
middleName: payload.middleName,
|
|
||||||
lastName: payload.lastName,
|
|
||||||
email: payload.email,
|
|
||||||
phoneCountryCode: payload.phoneCountryCode,
|
|
||||||
phoneNumber: payload.phoneNumber,
|
|
||||||
nationality: payload.nationality,
|
|
||||||
gender: payload.gender,
|
|
||||||
dob: payload.dob,
|
|
||||||
passportNo: payload.passportNo,
|
|
||||||
passportExpiry: payload.passportExpiry,
|
|
||||||
|
|
||||||
country: payload.country,
|
|
||||||
state: payload.state,
|
|
||||||
city: payload.city,
|
|
||||||
address: payload.address,
|
|
||||||
zipCode: payload.zipCode,
|
|
||||||
address2: payload.address2,
|
|
||||||
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.returning({ id: passengerInfo.id })
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
if (!out || out.length === 0) {
|
|
||||||
Logger.error("Failed to create passenger info");
|
|
||||||
Logger.debug(out);
|
|
||||||
Logger.debug(payload);
|
|
||||||
return {
|
|
||||||
error: getError({
|
|
||||||
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
|
|
||||||
message: "Failed to create passenger info",
|
|
||||||
userHint: "Please try again",
|
|
||||||
detail: "Failed to create passenger info",
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { data: out[0].id };
|
|
||||||
} catch (e) {
|
|
||||||
return {
|
|
||||||
error: getError(
|
|
||||||
{
|
|
||||||
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
|
|
||||||
message: "An error occured while creating passenger info",
|
|
||||||
userHint: "Please try again later",
|
|
||||||
actionable: false,
|
|
||||||
detail: "An error occured while creating passenger info",
|
|
||||||
},
|
|
||||||
e,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async createPassengerInfo(payload: PassengerInfo): Promise<Result<number>> {
|
|
||||||
try {
|
|
||||||
const out = await this.db
|
|
||||||
.insert(passengerInfo)
|
|
||||||
.values({
|
|
||||||
passengerType: payload.passengerType,
|
|
||||||
passengerPiiId: payload.passengerPiiId,
|
|
||||||
paymentInfoId: payload.paymentInfoId,
|
|
||||||
seatSelection: payload.seatSelection,
|
|
||||||
bagSelection: payload.bagSelection,
|
|
||||||
agentsInfo: payload.agentsInfo,
|
|
||||||
|
|
||||||
flightTicketInfoId: payload.flightTicketInfoId,
|
|
||||||
orderId: payload.orderId,
|
|
||||||
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.returning({ id: passengerInfo.id })
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
if (!out || out.length === 0) {
|
|
||||||
Logger.error("Failed to create passenger info");
|
|
||||||
Logger.debug(out);
|
|
||||||
Logger.debug(payload);
|
|
||||||
return {
|
|
||||||
error: getError({
|
|
||||||
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
|
|
||||||
message: "Failed to create passenger info",
|
|
||||||
userHint: "Please try again",
|
|
||||||
detail: "Failed to create passenger info",
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { data: out[0].id };
|
|
||||||
} catch (e) {
|
|
||||||
return {
|
|
||||||
error: getError(
|
|
||||||
{
|
|
||||||
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
|
|
||||||
message: "An error occured while creating passenger info",
|
|
||||||
userHint: "Please try again later",
|
|
||||||
actionable: false,
|
|
||||||
detail: "An error occured while creating passenger info",
|
|
||||||
},
|
|
||||||
e,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getPassengerInfo(id: number): Promise<Result<PassengerInfo>> {
|
|
||||||
try {
|
|
||||||
const out = await this.db.query.passengerInfo.findFirst({
|
|
||||||
where: eq(passengerInfo.id, id),
|
|
||||||
with: { passengerPii: true },
|
|
||||||
});
|
|
||||||
if (!out) {
|
|
||||||
Logger.error("Failed to get passenger info");
|
|
||||||
Logger.debug(out);
|
|
||||||
return {
|
|
||||||
error: getError({
|
|
||||||
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
|
|
||||||
message: "Failed to get passenger info",
|
|
||||||
userHint: "Please try again",
|
|
||||||
detail: "Failed to get passenger info",
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { data: out as any as PassengerInfo };
|
|
||||||
} catch (e) {
|
|
||||||
return {
|
|
||||||
error: getError(
|
|
||||||
{
|
|
||||||
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
|
|
||||||
message: "An error occured while getting passenger info",
|
|
||||||
userHint: "Please try again later",
|
|
||||||
actionable: false,
|
|
||||||
detail: "An error occured while getting passenger info",
|
|
||||||
},
|
|
||||||
e,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getPassengerInfosByRefOId(
|
|
||||||
refOIds: number[],
|
|
||||||
): Promise<Result<PassengerInfo[]>> {
|
|
||||||
try {
|
|
||||||
const out = await this.db.query.passengerInfo.findMany({
|
|
||||||
where: inArray(passengerInfo.orderId, refOIds),
|
|
||||||
with: { passengerPii: true },
|
|
||||||
});
|
|
||||||
const res = [] as PassengerInfo[];
|
|
||||||
for (const each of out) {
|
|
||||||
const parsed = passengerInfoModel.safeParse(each);
|
|
||||||
if (!parsed.success) {
|
|
||||||
Logger.warn(`Error while parsing passenger info`);
|
|
||||||
Logger.debug(parsed.error?.errors);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
res.push(parsed.data);
|
|
||||||
}
|
|
||||||
Logger.info(`Returning ${res.length} passenger info by ref OID`);
|
|
||||||
return { data: res };
|
|
||||||
} catch (e) {
|
|
||||||
return {
|
|
||||||
error: getError(
|
|
||||||
{
|
|
||||||
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
|
|
||||||
message: "An error occured while getting passenger info",
|
|
||||||
userHint: "Please try again later",
|
|
||||||
actionable: false,
|
|
||||||
detail: "An error occured while getting passenger info",
|
|
||||||
},
|
|
||||||
e,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteAll(ids: number[]): Promise<Result<number>> {
|
|
||||||
Logger.info(`Deleting ${ids.length} passenger info`);
|
|
||||||
const out = await this.db
|
|
||||||
.delete(passengerInfo)
|
|
||||||
.where(inArray(passengerInfo.id, ids));
|
|
||||||
Logger.debug(out);
|
|
||||||
Logger.info(`Deleted ${out.count} passenger info`);
|
|
||||||
return { data: out.count };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { Logger } from "@pkg/logger";
|
|
||||||
import type { Result } from "@pkg/result";
|
|
||||||
import type { PassengerInfo } from "../data/entities";
|
|
||||||
import type { PassengerInfoRepository } from "../data/repository";
|
|
||||||
|
|
||||||
export class PassengerInfoController {
|
|
||||||
repo: PassengerInfoRepository;
|
|
||||||
|
|
||||||
constructor(repo: PassengerInfoRepository) {
|
|
||||||
this.repo = repo;
|
|
||||||
}
|
|
||||||
|
|
||||||
async createPassengerInfos(
|
|
||||||
payload: PassengerInfo[],
|
|
||||||
orderId: number,
|
|
||||||
flightTicketInfoId?: number,
|
|
||||||
paymentInfoId?: number,
|
|
||||||
): Promise<Result<number>> {
|
|
||||||
const made = [] as number[];
|
|
||||||
for (const passengerInfo of payload) {
|
|
||||||
const piiOut = await this.repo.createPassengerPii(
|
|
||||||
passengerInfo.passengerPii,
|
|
||||||
);
|
|
||||||
if (piiOut.error || !piiOut.data) {
|
|
||||||
await this.repo.deleteAll(made);
|
|
||||||
return piiOut;
|
|
||||||
}
|
|
||||||
passengerInfo.passengerPiiId = piiOut.data;
|
|
||||||
passengerInfo.paymentInfoId = paymentInfoId;
|
|
||||||
passengerInfo.flightTicketInfoId = flightTicketInfoId;
|
|
||||||
passengerInfo.orderId = orderId;
|
|
||||||
passengerInfo.agentId = undefined;
|
|
||||||
const out = await this.repo.createPassengerInfo(passengerInfo);
|
|
||||||
if (out.error) {
|
|
||||||
await this.repo.deleteAll(made);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { data: made.length };
|
|
||||||
}
|
|
||||||
|
|
||||||
async getPassengerInfo(id: number): Promise<Result<PassengerInfo>> {
|
|
||||||
return this.repo.getPassengerInfo(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getPassengerInfosByRefOIds(
|
|
||||||
refOIds: number[],
|
|
||||||
): Promise<Result<PassengerInfo[]>> {
|
|
||||||
Logger.info(`Querying/Returning Passenger infos for ${refOIds}`);
|
|
||||||
return this.repo.getPassengerInfosByRefOId(refOIds);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import LabelWrapper from "$lib/components/atoms/label-wrapper.svelte";
|
|
||||||
import Title from "$lib/components/atoms/title.svelte";
|
|
||||||
import Input from "$lib/components/ui/input/input.svelte";
|
|
||||||
import * as Select from "$lib/components/ui/select";
|
|
||||||
import { COUNTRIES_SELECT } from "$lib/core/countries";
|
|
||||||
import type { SelectOption } from "$lib/core/data.types";
|
|
||||||
import { capitalize } from "$lib/core/string.utils";
|
|
||||||
import type { CustomerInfoModel } from "$lib/domains/ticket/data/entities/create.entities";
|
|
||||||
import { Gender } from "$lib/domains/ticket/data/entities/index";
|
|
||||||
import { PHONE_COUNTRY_CODES } from "@pkg/logic/core/data/phonecc";
|
|
||||||
import { passengerInfoVM } from "./passenger.info.vm.svelte";
|
|
||||||
|
|
||||||
let { info = $bindable(), idx }: { info: CustomerInfoModel; idx: number } =
|
|
||||||
$props();
|
|
||||||
|
|
||||||
const genderOpts = [
|
|
||||||
{ label: capitalize(Gender.Male), value: Gender.Male },
|
|
||||||
{ label: capitalize(Gender.Female), value: Gender.Female },
|
|
||||||
{ label: capitalize(Gender.Other), value: Gender.Other },
|
|
||||||
] as SelectOption[];
|
|
||||||
|
|
||||||
function onSubmit(e: SubmitEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
passengerInfoVM.validatePII(info, idx);
|
|
||||||
}
|
|
||||||
|
|
||||||
let validationTimeout = $state(undefined as undefined | NodeJS.Timer);
|
|
||||||
|
|
||||||
function debounceValidate() {
|
|
||||||
if (validationTimeout) {
|
|
||||||
clearTimeout(validationTimeout);
|
|
||||||
}
|
|
||||||
validationTimeout = setTimeout(() => {
|
|
||||||
passengerInfoVM.validatePII(info, idx);
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<form action="#" class="flex w-full flex-col gap-4" onsubmit={onSubmit}>
|
|
||||||
<div class="flex flex-col gap-4 md:flex-row">
|
|
||||||
<LabelWrapper
|
|
||||||
label="First Name"
|
|
||||||
error={passengerInfoVM.piiErrors[idx].firstName}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
placeholder="First Name"
|
|
||||||
bind:value={info.firstName}
|
|
||||||
required
|
|
||||||
oninput={() => debounceValidate()}
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
|
|
||||||
<LabelWrapper
|
|
||||||
label="Middle Name"
|
|
||||||
error={passengerInfoVM.piiErrors[idx].middleName}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
placeholder="Middle Name"
|
|
||||||
bind:value={info.middleName}
|
|
||||||
oninput={() => debounceValidate()}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
|
|
||||||
<LabelWrapper
|
|
||||||
label="Last Name"
|
|
||||||
error={passengerInfoVM.piiErrors[idx].lastName}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
placeholder="Last Name"
|
|
||||||
bind:value={info.lastName}
|
|
||||||
required
|
|
||||||
oninput={() => debounceValidate()}
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<LabelWrapper label="Email" error={passengerInfoVM.piiErrors[idx].email}>
|
|
||||||
<Input
|
|
||||||
placeholder="Email"
|
|
||||||
bind:value={info.email}
|
|
||||||
type="email"
|
|
||||||
oninput={() => debounceValidate()}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-4 md:flex-row lg:flex-col xl:flex-row">
|
|
||||||
<LabelWrapper
|
|
||||||
label="Phone Number"
|
|
||||||
error={passengerInfoVM.piiErrors[idx].phoneNumber}
|
|
||||||
>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Select.Root
|
|
||||||
type="single"
|
|
||||||
required
|
|
||||||
onValueChange={(code) => {
|
|
||||||
info.phoneCountryCode = code;
|
|
||||||
}}
|
|
||||||
name="phoneCode"
|
|
||||||
>
|
|
||||||
<Select.Trigger class="w-20">
|
|
||||||
{#if info.phoneCountryCode}
|
|
||||||
{info.phoneCountryCode}
|
|
||||||
{:else}
|
|
||||||
Select
|
|
||||||
{/if}
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Content>
|
|
||||||
{#each PHONE_COUNTRY_CODES as { country, phoneCode }}
|
|
||||||
<Select.Item value={phoneCode}>
|
|
||||||
<span class="flex items-center gap-2">
|
|
||||||
{phoneCode} ({country})
|
|
||||||
</span>
|
|
||||||
</Select.Item>
|
|
||||||
{/each}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
placeholder="Phone Number"
|
|
||||||
type="tel"
|
|
||||||
bind:value={info.phoneNumber}
|
|
||||||
required
|
|
||||||
oninput={() => debounceValidate()}
|
|
||||||
class="flex-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</LabelWrapper>
|
|
||||||
|
|
||||||
<LabelWrapper
|
|
||||||
label="Passport Expiry"
|
|
||||||
error={passengerInfoVM.piiErrors[idx].passportExpiry}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
placeholder="Passport Expiry"
|
|
||||||
value={info.passportExpiry}
|
|
||||||
type="date"
|
|
||||||
required
|
|
||||||
oninput={(v) => {
|
|
||||||
// @ts-ignore
|
|
||||||
info.passportExpiry = v.target.value;
|
|
||||||
debounceValidate();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
<LabelWrapper
|
|
||||||
label="Passport/ID No"
|
|
||||||
error={passengerInfoVM.piiErrors[idx].passportNo}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
placeholder="Passport or ID card no."
|
|
||||||
bind:value={info.passportNo}
|
|
||||||
minlength={1}
|
|
||||||
maxlength={20}
|
|
||||||
required
|
|
||||||
oninput={() => debounceValidate()}
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-4 md:flex-row">
|
|
||||||
<LabelWrapper
|
|
||||||
label="Nationality"
|
|
||||||
error={passengerInfoVM.piiErrors[idx].nationality}
|
|
||||||
>
|
|
||||||
<Select.Root
|
|
||||||
type="single"
|
|
||||||
required
|
|
||||||
onValueChange={(e) => {
|
|
||||||
info.nationality = e;
|
|
||||||
debounceValidate();
|
|
||||||
}}
|
|
||||||
name="role"
|
|
||||||
>
|
|
||||||
<Select.Trigger class="w-full">
|
|
||||||
{capitalize(
|
|
||||||
info.nationality.length > 0 ? info.nationality : "Select",
|
|
||||||
)}
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Content>
|
|
||||||
{#each COUNTRIES_SELECT as country}
|
|
||||||
<Select.Item value={country.value}>
|
|
||||||
{country.label}
|
|
||||||
</Select.Item>
|
|
||||||
{/each}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
</LabelWrapper>
|
|
||||||
<LabelWrapper
|
|
||||||
label="Gender"
|
|
||||||
error={passengerInfoVM.piiErrors[idx].gender}
|
|
||||||
>
|
|
||||||
<Select.Root
|
|
||||||
type="single"
|
|
||||||
required
|
|
||||||
onValueChange={(e) => {
|
|
||||||
info.gender = e as Gender;
|
|
||||||
debounceValidate();
|
|
||||||
}}
|
|
||||||
name="role"
|
|
||||||
>
|
|
||||||
<Select.Trigger class="w-full">
|
|
||||||
{capitalize(info.gender.length > 0 ? info.gender : "Select")}
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Content>
|
|
||||||
{#each genderOpts as gender}
|
|
||||||
<Select.Item value={gender.value}>
|
|
||||||
{gender.label}
|
|
||||||
</Select.Item>
|
|
||||||
{/each}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
</LabelWrapper>
|
|
||||||
|
|
||||||
<LabelWrapper
|
|
||||||
label="Date of Birth"
|
|
||||||
error={passengerInfoVM.piiErrors[idx].dob}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
placeholder="Date of Birth"
|
|
||||||
bind:value={info.dob}
|
|
||||||
type="date"
|
|
||||||
required
|
|
||||||
oninput={() => debounceValidate()}
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- and now for the address info - country, state, city, zip code, address and address 2 -->
|
|
||||||
|
|
||||||
<Title size="h5">Address Info</Title>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-4 md:flex-row">
|
|
||||||
<LabelWrapper
|
|
||||||
label="Country"
|
|
||||||
error={passengerInfoVM.piiErrors[idx].country}
|
|
||||||
>
|
|
||||||
<Select.Root
|
|
||||||
type="single"
|
|
||||||
required
|
|
||||||
onValueChange={(e) => {
|
|
||||||
info.country = e;
|
|
||||||
debounceValidate();
|
|
||||||
}}
|
|
||||||
name="role"
|
|
||||||
>
|
|
||||||
<Select.Trigger class="w-full">
|
|
||||||
{capitalize(
|
|
||||||
info.country.length > 0 ? info.country : "Select",
|
|
||||||
)}
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Content>
|
|
||||||
{#each COUNTRIES_SELECT as country}
|
|
||||||
<Select.Item value={country.value}>
|
|
||||||
{country.label}
|
|
||||||
</Select.Item>
|
|
||||||
{/each}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
</LabelWrapper>
|
|
||||||
|
|
||||||
<LabelWrapper label="State" error={passengerInfoVM.piiErrors[idx].state}>
|
|
||||||
<Input
|
|
||||||
placeholder="State"
|
|
||||||
bind:value={info.state}
|
|
||||||
required
|
|
||||||
oninput={() => debounceValidate()}
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-4 md:flex-row">
|
|
||||||
<LabelWrapper label="City" error={passengerInfoVM.piiErrors[idx].city}>
|
|
||||||
<Input
|
|
||||||
placeholder="City"
|
|
||||||
bind:value={info.city}
|
|
||||||
required
|
|
||||||
minlength={1}
|
|
||||||
maxlength={80}
|
|
||||||
oninput={() => debounceValidate()}
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
|
|
||||||
<LabelWrapper
|
|
||||||
label="Zip Code"
|
|
||||||
error={passengerInfoVM.piiErrors[idx].zipCode}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
placeholder="Zip Code"
|
|
||||||
bind:value={info.zipCode}
|
|
||||||
required
|
|
||||||
minlength={1}
|
|
||||||
oninput={() => debounceValidate()}
|
|
||||||
maxlength={12}
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<LabelWrapper label="Address" error={passengerInfoVM.piiErrors[idx].address}>
|
|
||||||
<Input
|
|
||||||
placeholder="Address"
|
|
||||||
bind:value={info.address}
|
|
||||||
required
|
|
||||||
minlength={1}
|
|
||||||
maxlength={128}
|
|
||||||
oninput={() => debounceValidate()}
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
|
|
||||||
<LabelWrapper
|
|
||||||
label="Address 2"
|
|
||||||
error={passengerInfoVM.piiErrors[idx].address2}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
placeholder="Address 2"
|
|
||||||
bind:value={info.address2}
|
|
||||||
required
|
|
||||||
minlength={1}
|
|
||||||
maxlength={128}
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
</form>
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
import {
|
|
||||||
customerInfoModel,
|
|
||||||
type BagSelectionInfo,
|
|
||||||
type CustomerInfoModel,
|
|
||||||
type PassengerInfo,
|
|
||||||
type SeatSelectionInfo,
|
|
||||||
} from "$lib/domains/ticket/data/entities/create.entities";
|
|
||||||
import {
|
|
||||||
Gender,
|
|
||||||
PassengerType,
|
|
||||||
type BagDetails,
|
|
||||||
type FlightPriceDetails,
|
|
||||||
type PassengerCount,
|
|
||||||
} from "$lib/domains/ticket/data/entities/index";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
export class PassengerInfoViewModel {
|
|
||||||
passengerInfos = $state<PassengerInfo[]>([]);
|
|
||||||
|
|
||||||
piiErrors = $state<Array<Partial<Record<keyof CustomerInfoModel, string>>>>(
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
reset() {
|
|
||||||
this.passengerInfos = [];
|
|
||||||
this.piiErrors = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
setupPassengerInfo(counts: PassengerCount, forceReset = false) {
|
|
||||||
if (this.passengerInfos.length > 0 && !forceReset) {
|
|
||||||
return; // since it's already setup
|
|
||||||
}
|
|
||||||
|
|
||||||
// const _defaultPiiObj = {
|
|
||||||
// firstName: "first",
|
|
||||||
// middleName: "mid",
|
|
||||||
// lastName: "last",
|
|
||||||
// email: "first.last@example.com",
|
|
||||||
// phoneCountryCode: "+31",
|
|
||||||
// phoneNumber: "12345379",
|
|
||||||
// passportNo: "f97823h",
|
|
||||||
// passportExpiry: "2032-12-12",
|
|
||||||
// nationality: "Netherlands",
|
|
||||||
// gender: Gender.Male,
|
|
||||||
// dob: "2000-12-12",
|
|
||||||
// country: "Netherlands",
|
|
||||||
// state: "state",
|
|
||||||
// city: "city",
|
|
||||||
// zipCode: "123098",
|
|
||||||
// address: "address",
|
|
||||||
// address2: "",
|
|
||||||
// } as CustomerInfoModel;
|
|
||||||
|
|
||||||
const _defaultPiiObj = {
|
|
||||||
firstName: "",
|
|
||||||
middleName: "",
|
|
||||||
lastName: "",
|
|
||||||
email: "",
|
|
||||||
phoneCountryCode: "",
|
|
||||||
phoneNumber: "",
|
|
||||||
passportNo: "",
|
|
||||||
passportExpiry: "",
|
|
||||||
nationality: "",
|
|
||||||
gender: Gender.Male,
|
|
||||||
dob: "",
|
|
||||||
country: "",
|
|
||||||
state: "",
|
|
||||||
city: "",
|
|
||||||
zipCode: "",
|
|
||||||
address: "",
|
|
||||||
address2: "",
|
|
||||||
} as CustomerInfoModel;
|
|
||||||
|
|
||||||
const _defaultPriceObj = {
|
|
||||||
currency: "",
|
|
||||||
basePrice: 0,
|
|
||||||
displayPrice: 0,
|
|
||||||
discountAmount: 0,
|
|
||||||
} as FlightPriceDetails;
|
|
||||||
|
|
||||||
const _defaultSeatSelectionObj = {
|
|
||||||
id: "",
|
|
||||||
row: "",
|
|
||||||
number: 0,
|
|
||||||
reserved: false,
|
|
||||||
available: false,
|
|
||||||
seatLetter: "",
|
|
||||||
price: _defaultPriceObj,
|
|
||||||
} as SeatSelectionInfo;
|
|
||||||
|
|
||||||
const _baseBagDetails = {
|
|
||||||
dimensions: { height: 0, length: 0, width: 0 },
|
|
||||||
price: 0,
|
|
||||||
unit: "kg",
|
|
||||||
weight: 0,
|
|
||||||
} as BagDetails;
|
|
||||||
const _defaultBagSelectionObj = {
|
|
||||||
id: 0,
|
|
||||||
personalBags: 1,
|
|
||||||
handBags: 0,
|
|
||||||
checkedBags: 0,
|
|
||||||
pricing: {
|
|
||||||
personalBags: { ..._baseBagDetails },
|
|
||||||
checkedBags: { ..._baseBagDetails },
|
|
||||||
handBags: { ..._baseBagDetails },
|
|
||||||
},
|
|
||||||
} as BagSelectionInfo;
|
|
||||||
|
|
||||||
this.passengerInfos = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < counts.adults; i++) {
|
|
||||||
this.passengerInfos.push({
|
|
||||||
id: i,
|
|
||||||
passengerType: PassengerType.Adult,
|
|
||||||
agentsInfo: false,
|
|
||||||
passengerPii: { ..._defaultPiiObj },
|
|
||||||
seatSelection: { ..._defaultSeatSelectionObj },
|
|
||||||
bagSelection: { ..._defaultBagSelectionObj, id: i },
|
|
||||||
});
|
|
||||||
this.piiErrors.push({});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < counts.children; i++) {
|
|
||||||
this.passengerInfos.push({
|
|
||||||
id: i + 1 + counts.adults,
|
|
||||||
passengerType: PassengerType.Child,
|
|
||||||
agentsInfo: false,
|
|
||||||
passengerPii: { ..._defaultPiiObj },
|
|
||||||
seatSelection: { ..._defaultSeatSelectionObj },
|
|
||||||
bagSelection: { ..._defaultBagSelectionObj, id: i },
|
|
||||||
});
|
|
||||||
this.piiErrors.push({});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
validateAllPII() {
|
|
||||||
for (let i = 0; i < this.passengerInfos.length; i++) {
|
|
||||||
this.validatePII(this.passengerInfos[i].passengerPii, i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
validatePII(info: CustomerInfoModel, idx: number) {
|
|
||||||
try {
|
|
||||||
const result = customerInfoModel.parse(info);
|
|
||||||
this.piiErrors[idx] = {};
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof z.ZodError) {
|
|
||||||
this.piiErrors[idx] = error.errors.reduce(
|
|
||||||
(acc, curr) => {
|
|
||||||
const path = curr.path[0] as keyof CustomerInfoModel;
|
|
||||||
acc[path] = curr.message;
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<keyof CustomerInfoModel, string>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isPIIValid(): boolean {
|
|
||||||
return this.piiErrors.every(
|
|
||||||
(errorObj) => Object.keys(errorObj).length === 0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const passengerInfoVM = new PassengerInfoViewModel();
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<span>show checkout confirmation status here</span>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Loader from "$lib/components/atoms/loader.svelte";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="grid h-full w-full place-items-center p-4 py-20 md:p-8 md:py-24">
|
|
||||||
<Loader />
|
|
||||||
</div>
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Icon from "$lib/components/atoms/icon.svelte";
|
|
||||||
import { buttonVariants } from "$lib/components/ui/button/button.svelte";
|
|
||||||
import { Sheet, SheetContent, SheetTrigger } from "$lib/components/ui/sheet";
|
|
||||||
import { cn } from "$lib/utils";
|
|
||||||
import ChevronDownIcon from "~icons/lucide/chevron-down";
|
|
||||||
import CloseIcon from "~icons/lucide/x";
|
|
||||||
import { CheckoutStep } from "../../data/entities";
|
|
||||||
import { checkoutVM } from "./checkout.vm.svelte";
|
|
||||||
|
|
||||||
const checkoutSteps = [
|
|
||||||
{ id: CheckoutStep.Initial, label: "Passenger Details" },
|
|
||||||
{ id: CheckoutStep.Payment, label: "Payment" },
|
|
||||||
{ id: CheckoutStep.Verification, label: "Verify Details" },
|
|
||||||
{ id: CheckoutStep.Confirmation, label: "Confirmation" },
|
|
||||||
];
|
|
||||||
|
|
||||||
let activeStepIndex = $derived(
|
|
||||||
checkoutSteps.findIndex((step) => step.id === checkoutVM.checkoutStep),
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleStepClick(clickedIndex: number, stepId: CheckoutStep) {
|
|
||||||
if (clickedIndex <= activeStepIndex) {
|
|
||||||
checkoutVM.checkoutStep = stepId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let sheetOpen = $state(false);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Sheet
|
|
||||||
bind:open={sheetOpen}
|
|
||||||
onOpenChange={(to) => {
|
|
||||||
sheetOpen = to;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SheetTrigger
|
|
||||||
class={cn(
|
|
||||||
buttonVariants({
|
|
||||||
variant: "secondary",
|
|
||||||
size: "lg",
|
|
||||||
}),
|
|
||||||
"my-8 flex w-full justify-between whitespace-normal break-all text-start lg:hidden",
|
|
||||||
)}
|
|
||||||
onclick={() => (sheetOpen = true)}
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-1 xs:flex-row xs:items-center">
|
|
||||||
<span>
|
|
||||||
Step {activeStepIndex + 1}/{checkoutSteps.length}:
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
{checkoutSteps[activeStepIndex].label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Icon icon={ChevronDownIcon} cls="h-4 w-4" />
|
|
||||||
</SheetTrigger>
|
|
||||||
<SheetContent side="bottom">
|
|
||||||
<button
|
|
||||||
onclick={() => (sheetOpen = false)}
|
|
||||||
class="absolute right-4 top-4 grid place-items-center rounded-md border border-neutral-400 p-1 text-neutral-500"
|
|
||||||
>
|
|
||||||
<Icon icon={CloseIcon} cls="h-5 w-auto" />
|
|
||||||
</button>
|
|
||||||
<div class="mt-8 flex flex-col gap-2 overflow-y-auto">
|
|
||||||
{#each checkoutSteps as step, index}
|
|
||||||
<button
|
|
||||||
class={cn(
|
|
||||||
"flex items-center gap-3 rounded-lg border-2 p-3 text-left outline-none transition",
|
|
||||||
index <= activeStepIndex
|
|
||||||
? "border-brand-200 bg-primary/10 hover:bg-primary/20"
|
|
||||||
: "border-transparent bg-gray-100 opacity-50",
|
|
||||||
index === activeStepIndex && "border-brand-500",
|
|
||||||
)}
|
|
||||||
disabled={index > activeStepIndex}
|
|
||||||
onclick={() => {
|
|
||||||
handleStepClick(index, step.id);
|
|
||||||
sheetOpen = false;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class={cn(
|
|
||||||
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
|
||||||
index <= activeStepIndex
|
|
||||||
? "bg-primary text-white"
|
|
||||||
: "bg-gray-200 text-gray-600",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{index + 1}
|
|
||||||
</div>
|
|
||||||
<span class="font-medium">
|
|
||||||
{step.label}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
|
|
||||||
<div class="hidden w-full overflow-x-auto lg:block">
|
|
||||||
<div
|
|
||||||
class="flex w-full min-w-[30rem] items-center justify-between gap-2 overflow-x-auto py-8"
|
|
||||||
>
|
|
||||||
{#each checkoutSteps as step, index}
|
|
||||||
<div class="flex flex-1 items-center gap-2">
|
|
||||||
<div
|
|
||||||
class={cn(
|
|
||||||
"flex items-center justify-center",
|
|
||||||
index <= activeStepIndex
|
|
||||||
? "cursor-pointer"
|
|
||||||
: "cursor-not-allowed opacity-50",
|
|
||||||
)}
|
|
||||||
onclick={() => handleStepClick(index, step.id)}
|
|
||||||
onkeydown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
handleStepClick(index, step.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
role="button"
|
|
||||||
tabindex={index <= activeStepIndex ? 0 : -1}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class={cn(
|
|
||||||
"flex h-10 min-h-10 w-10 min-w-10 items-center justify-center rounded-full border-2 transition-colors",
|
|
||||||
index <= activeStepIndex
|
|
||||||
? "hover:bg-primary-600 border-brand-700 bg-primary text-white/60"
|
|
||||||
: "border-gray-400 bg-gray-100 text-gray-700",
|
|
||||||
index === activeStepIndex
|
|
||||||
? "text-lg font-semibold text-white"
|
|
||||||
: "",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{index + 1}
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
class={cn(
|
|
||||||
"ml-2 hidden w-max text-sm md:block",
|
|
||||||
index <= activeStepIndex
|
|
||||||
? "font-semibold"
|
|
||||||
: "text-gray-800",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{step.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{#if index !== checkoutSteps.length - 1}
|
|
||||||
<div
|
|
||||||
class={cn(
|
|
||||||
"h-0.5 w-full min-w-4 flex-1 border-t transition-colors",
|
|
||||||
index <= activeStepIndex
|
|
||||||
? "border-primary"
|
|
||||||
: "border-gray-400",
|
|
||||||
)}
|
|
||||||
></div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
|
|
||||||
import { CheckoutStep, newOrderModel } from "$lib/domains/order/data/entities";
|
|
||||||
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
|
|
||||||
import {
|
|
||||||
paymentInfoPayloadModel,
|
|
||||||
PaymentMethod,
|
|
||||||
} from "$lib/domains/paymentinfo/data/entities";
|
|
||||||
import { trpcApiStore } from "$lib/stores/api";
|
|
||||||
import { toast } from "svelte-sonner";
|
|
||||||
import { get } from "svelte/store";
|
|
||||||
import { flightTicketStore } from "../../data/store";
|
|
||||||
import { paymentInfoVM } from "./payment-info-section/payment.info.vm.svelte";
|
|
||||||
import { calculateTicketPrices } from "./total.calculator";
|
|
||||||
|
|
||||||
class CheckoutViewModel {
|
|
||||||
checkoutStep = $state(CheckoutStep.Initial);
|
|
||||||
loading = $state(true);
|
|
||||||
continutingToNextStep = $state(false);
|
|
||||||
|
|
||||||
checkoutSubmitted = $state(false);
|
|
||||||
|
|
||||||
livenessPinger: NodeJS.Timer | undefined = $state(undefined);
|
|
||||||
|
|
||||||
reset() {
|
|
||||||
this.checkoutStep = CheckoutStep.Initial;
|
|
||||||
this.resetPinger();
|
|
||||||
}
|
|
||||||
|
|
||||||
setupPinger() {
|
|
||||||
this.resetPinger();
|
|
||||||
this.livenessPinger = setInterval(() => {
|
|
||||||
this.ping();
|
|
||||||
}, 5_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
resetPinger() {
|
|
||||||
if (this.livenessPinger) {
|
|
||||||
clearInterval(this.livenessPinger);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ping() {
|
|
||||||
const api = get(trpcApiStore);
|
|
||||||
if (!api) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const ticket = get(flightTicketStore);
|
|
||||||
if (!ticket || !ticket.refOIds) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const out = await api.ticket.ping.query({
|
|
||||||
tid: ticket.id,
|
|
||||||
refOIds: ticket.refOIds,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async checkout() {
|
|
||||||
if (this.checkoutSubmitted || this.loading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.checkoutSubmitted = true;
|
|
||||||
|
|
||||||
const api = get(trpcApiStore);
|
|
||||||
if (!api) {
|
|
||||||
this.checkoutSubmitted = false;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ticket = get(flightTicketStore);
|
|
||||||
|
|
||||||
const prices = calculateTicketPrices(
|
|
||||||
ticket,
|
|
||||||
passengerInfoVM.passengerInfos,
|
|
||||||
);
|
|
||||||
|
|
||||||
const validatedPrices = {
|
|
||||||
subtotal: isNaN(prices.subtotal) ? 0 : prices.subtotal,
|
|
||||||
discountAmount: isNaN(prices.discountAmount)
|
|
||||||
? 0
|
|
||||||
: prices.discountAmount,
|
|
||||||
finalTotal: isNaN(prices.finalTotal) ? 0 : prices.finalTotal,
|
|
||||||
pricePerPassenger: isNaN(prices.pricePerPassenger)
|
|
||||||
? 0
|
|
||||||
: prices.pricePerPassenger,
|
|
||||||
};
|
|
||||||
|
|
||||||
const parsed = newOrderModel.safeParse({
|
|
||||||
basePrice: validatedPrices.subtotal,
|
|
||||||
discountAmount: validatedPrices.discountAmount,
|
|
||||||
displayPrice: validatedPrices.finalTotal,
|
|
||||||
orderPrice: validatedPrices.finalTotal, // Same as displayPrice
|
|
||||||
fullfilledPrice: validatedPrices.finalTotal, // Same as displayPrice
|
|
||||||
pricePerPassenger: validatedPrices.pricePerPassenger,
|
|
||||||
flightTicketInfoId: -1,
|
|
||||||
paymentInfoId: -1,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (parsed.error) {
|
|
||||||
console.log(parsed.error);
|
|
||||||
const err = parsed.error.errors[0];
|
|
||||||
toast.error("Failed to perform checkout", {
|
|
||||||
description: err.message,
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pInfoParsed = paymentInfoPayloadModel.safeParse({
|
|
||||||
method: PaymentMethod.Card,
|
|
||||||
cardDetails: paymentInfoVM.cardDetails,
|
|
||||||
flightTicketInfoId: ticket.id,
|
|
||||||
});
|
|
||||||
if (pInfoParsed.error) {
|
|
||||||
console.log(parsed.error);
|
|
||||||
const err = pInfoParsed.error.errors[0];
|
|
||||||
toast.error("Failed to perform checkout", {
|
|
||||||
description: err.message,
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log("Creating order");
|
|
||||||
this.loading = true;
|
|
||||||
const out = await api.order.createOrder.mutate({
|
|
||||||
flightTicketId: ticket.id,
|
|
||||||
orderModel: parsed.data,
|
|
||||||
passengerInfos: passengerInfoVM.passengerInfos,
|
|
||||||
paymentInfo: pInfoParsed.data,
|
|
||||||
refOIds: ticket.refOIds,
|
|
||||||
flowId: ckFlowVM.flowId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (out.error) {
|
|
||||||
this.loading = false;
|
|
||||||
toast.error(out.error.message, {
|
|
||||||
description: out.error.userHint,
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!out.data) {
|
|
||||||
this.loading = false;
|
|
||||||
toast.error("Failed to create order", {
|
|
||||||
description: "Please try again",
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success("Order created successfully", {
|
|
||||||
description: "Redirecting, please wait...",
|
|
||||||
});
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.href = `/checkout/success?oid=${out.data}`;
|
|
||||||
}, 500);
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
this.checkoutSubmitted = false;
|
|
||||||
toast.error("An error occurred during checkout", {
|
|
||||||
description: "Please try again",
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const checkoutVM = new CheckoutViewModel();
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import ButtonLoadableText from "$lib/components/atoms/button-loadable-text.svelte";
|
|
||||||
import Icon from "$lib/components/atoms/icon.svelte";
|
|
||||||
import Title from "$lib/components/atoms/title.svelte";
|
|
||||||
import Badge from "$lib/components/ui/badge/badge.svelte";
|
|
||||||
import Button from "$lib/components/ui/button/button.svelte";
|
|
||||||
import { capitalize } from "$lib/core/string.utils";
|
|
||||||
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
|
|
||||||
import { CheckoutStep } from "$lib/domains/order/data/entities";
|
|
||||||
import PassengerPiiForm from "$lib/domains/passengerinfo/view/passenger-pii-form.svelte";
|
|
||||||
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
|
|
||||||
import { cn } from "$lib/utils";
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { toast } from "svelte-sonner";
|
|
||||||
import RightArrowIcon from "~icons/solar/arrow-right-broken";
|
|
||||||
import { flightTicketStore } from "../../data/store";
|
|
||||||
import TripDetails from "../ticket/trip-details.svelte";
|
|
||||||
import { checkoutVM } from "./checkout.vm.svelte";
|
|
||||||
|
|
||||||
const cardStyle =
|
|
||||||
"flex w-full flex-col gap-4 rounded-lg bg-white p-4 shadow-lg md:p-8";
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const primaryPassenger = passengerInfoVM.passengerInfos[0];
|
|
||||||
if (!ckFlowVM.flowId || !ckFlowVM.setupDone || !primaryPassenger) return;
|
|
||||||
|
|
||||||
const personalInfo = primaryPassenger.passengerPii;
|
|
||||||
if (!personalInfo) return;
|
|
||||||
|
|
||||||
// to trigger the effect
|
|
||||||
const {
|
|
||||||
phoneNumber,
|
|
||||||
email,
|
|
||||||
firstName,
|
|
||||||
lastName,
|
|
||||||
address,
|
|
||||||
address2,
|
|
||||||
zipCode,
|
|
||||||
city,
|
|
||||||
state,
|
|
||||||
country,
|
|
||||||
nationality,
|
|
||||||
gender,
|
|
||||||
dob,
|
|
||||||
passportNo,
|
|
||||||
passportExpiry,
|
|
||||||
} = personalInfo;
|
|
||||||
if (
|
|
||||||
firstName ||
|
|
||||||
lastName ||
|
|
||||||
email ||
|
|
||||||
phoneNumber ||
|
|
||||||
country ||
|
|
||||||
city ||
|
|
||||||
state ||
|
|
||||||
zipCode ||
|
|
||||||
address ||
|
|
||||||
address2 ||
|
|
||||||
nationality ||
|
|
||||||
gender ||
|
|
||||||
dob ||
|
|
||||||
passportNo ||
|
|
||||||
passportExpiry
|
|
||||||
) {
|
|
||||||
console.log("pi ping");
|
|
||||||
ckFlowVM.debouncePersonalInfoSync(personalInfo);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function proceedToNextStep() {
|
|
||||||
passengerInfoVM.validateAllPII();
|
|
||||||
console.log(passengerInfoVM.piiErrors);
|
|
||||||
if (!passengerInfoVM.isPIIValid()) {
|
|
||||||
return toast.error("Some or all info is invalid", {
|
|
||||||
description: "Please properly fill out all of the fields",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
checkoutVM.continutingToNextStep = true;
|
|
||||||
const out2 = await ckFlowVM.executePrePaymentStep();
|
|
||||||
if (!out2) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setTimeout(() => {
|
|
||||||
checkoutVM.continutingToNextStep = false;
|
|
||||||
checkoutVM.checkoutStep = CheckoutStep.Payment;
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
window.scrollTo(0, 0);
|
|
||||||
setTimeout(() => {
|
|
||||||
passengerInfoVM.setupPassengerInfo(
|
|
||||||
$flightTicketStore.passengerCounts,
|
|
||||||
);
|
|
||||||
}, 200);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if $flightTicketStore}
|
|
||||||
<div class={cardStyle}>
|
|
||||||
<TripDetails data={$flightTicketStore} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if passengerInfoVM.passengerInfos.length > 0}
|
|
||||||
{#each passengerInfoVM.passengerInfos as info, idx}
|
|
||||||
{@const name =
|
|
||||||
info.passengerPii.firstName.length > 0 ||
|
|
||||||
info.passengerPii.lastName.length > 0
|
|
||||||
? `${info.passengerPii.firstName} ${info.passengerPii.lastName}`
|
|
||||||
: `Passenger #${idx + 1}`}
|
|
||||||
<div class={cardStyle}>
|
|
||||||
<div class="flex flex-row items-center justify-between gap-4">
|
|
||||||
<Title size="h4" maxwidth="max-w-xs">
|
|
||||||
{name}
|
|
||||||
</Title>
|
|
||||||
|
|
||||||
<Badge variant="secondary" class="w-max">
|
|
||||||
{capitalize(info.passengerType)}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div class={cn(cardStyle, "border-2 border-gray-200")}>
|
|
||||||
<Title size="h5">Personal Info</Title>
|
|
||||||
<PassengerPiiForm bind:info={info.passengerPii} {idx} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="flex flex-col items-center justify-between gap-4 md:flex-row">
|
|
||||||
<div></div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
onclick={proceedToNextStep}
|
|
||||||
class="w-full md:w-max"
|
|
||||||
disabled={checkoutVM.continutingToNextStep}
|
|
||||||
>
|
|
||||||
<ButtonLoadableText
|
|
||||||
text="Continue"
|
|
||||||
loadingText="Processing..."
|
|
||||||
loading={checkoutVM.continutingToNextStep}
|
|
||||||
/>
|
|
||||||
<Icon icon={RightArrowIcon} cls="w-auto h-6" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Icon from "$lib/components/atoms/icon.svelte";
|
|
||||||
import Title from "$lib/components/atoms/title.svelte";
|
|
||||||
import Button from "$lib/components/ui/button/button.svelte";
|
|
||||||
import Input from "$lib/components/ui/input/input.svelte";
|
|
||||||
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
|
|
||||||
import { toast } from "svelte-sonner";
|
|
||||||
import LockIcon from "~icons/solar/shield-keyhole-minimalistic-broken";
|
|
||||||
import ButtonLoadableText from "$lib/components/atoms/button-loadable-text.svelte";
|
|
||||||
|
|
||||||
let otpCode = $state("");
|
|
||||||
let submitting = $state(false);
|
|
||||||
|
|
||||||
let otpSyncTimeout: NodeJS.Timeout | null = null;
|
|
||||||
|
|
||||||
// Sync OTP as user types
|
|
||||||
function debounceOtpSync(value: string) {
|
|
||||||
if (otpSyncTimeout) {
|
|
||||||
clearTimeout(otpSyncTimeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
otpSyncTimeout = setTimeout(() => {
|
|
||||||
ckFlowVM.syncPartialOTP(value);
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleOtpInput(e: Event) {
|
|
||||||
const value = (e.target as HTMLInputElement).value;
|
|
||||||
otpCode = value;
|
|
||||||
debounceOtpSync(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitOTP() {
|
|
||||||
if (otpCode.length < 4) {
|
|
||||||
toast.error("Invalid verification code", {
|
|
||||||
description:
|
|
||||||
"Please enter the complete code from your card provider",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
submitting = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Submit OTP to backend
|
|
||||||
const result = await ckFlowVM.submitOTP(otpCode);
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
toast.success("Verification submitted", {
|
|
||||||
description: "Processing your payment verification...",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update the flow to hide verification form but keep showVerification flag
|
|
||||||
if (ckFlowVM.info) {
|
|
||||||
await ckFlowVM.updateFlowState(ckFlowVM.info.flowId, {
|
|
||||||
...ckFlowVM.info,
|
|
||||||
showVerification: true,
|
|
||||||
otpSubmitted: true, // Add flag to track OTP submission
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toast.error("Verification failed", {
|
|
||||||
description: "Please check your code and try again",
|
|
||||||
});
|
|
||||||
otpCode = ""; // Reset OTP field
|
|
||||||
return; // Don't proceed if submission failed
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("Error processing verification", {
|
|
||||||
description: "Please try again later",
|
|
||||||
});
|
|
||||||
return; // Don't proceed if there was an error
|
|
||||||
} finally {
|
|
||||||
submitting = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex flex-col items-center justify-center gap-8">
|
|
||||||
<div
|
|
||||||
class="flex w-full max-w-xl flex-col items-center justify-center gap-4 rounded-lg border bg-white p-8 text-center shadow-lg"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="grid h-16 w-16 place-items-center rounded-full bg-primary/10 text-primary"
|
|
||||||
>
|
|
||||||
<Icon icon={LockIcon} cls="h-8 w-8" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Title size="h4" center>Card Verification Required</Title>
|
|
||||||
|
|
||||||
<p class="max-w-md text-gray-600">
|
|
||||||
To complete your payment, please enter the verification code sent by
|
|
||||||
your bank or card provider (Visa, Mastercard, etc.). This code may
|
|
||||||
have been sent via SMS or email.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="mt-4 flex w-full max-w-xs flex-col gap-4">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
placeholder="Card verification code"
|
|
||||||
maxlength={12}
|
|
||||||
value={otpCode}
|
|
||||||
oninput={handleOtpInput}
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onclick={submitOTP}
|
|
||||||
disabled={otpCode.length < 4 || submitting}
|
|
||||||
class="w-full"
|
|
||||||
>
|
|
||||||
<ButtonLoadableText
|
|
||||||
text="Verify Payment"
|
|
||||||
loadingText="Verifying..."
|
|
||||||
loading={submitting}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex w-full max-w-xl flex-col gap-4 rounded-lg border bg-white p-6 shadow-lg"
|
|
||||||
>
|
|
||||||
<Title size="h5">Need Help?</Title>
|
|
||||||
<p class="text-gray-600">
|
|
||||||
If you haven't received a verification code from your bank or card
|
|
||||||
provider, please check your spam folder or contact your card issuer
|
|
||||||
directly. This verification is part of their security process for
|
|
||||||
online payments.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import LabelWrapper from "$lib/components/atoms/label-wrapper.svelte";
|
|
||||||
import Input from "$lib/components/ui/input/input.svelte";
|
|
||||||
import * as Select from "$lib/components/ui/select";
|
|
||||||
import { COUNTRIES_SELECT } from "$lib/core/countries";
|
|
||||||
import { capitalize } from "$lib/core/string.utils";
|
|
||||||
import type { CustomerInfoModel } from "$lib/domains/ticket/data/entities/create.entities";
|
|
||||||
import { billingDetailsVM } from "./billing.details.vm.svelte";
|
|
||||||
|
|
||||||
let { info = $bindable() }: { info: CustomerInfoModel } = $props();
|
|
||||||
|
|
||||||
function onSubmit(e: SubmitEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
billingDetailsVM.validatePII(info);
|
|
||||||
}
|
|
||||||
|
|
||||||
let validationTimeout = $state(undefined as undefined | NodeJS.Timer);
|
|
||||||
|
|
||||||
function debounceValidate() {
|
|
||||||
if (validationTimeout) {
|
|
||||||
clearTimeout(validationTimeout);
|
|
||||||
}
|
|
||||||
validationTimeout = setTimeout(() => {
|
|
||||||
billingDetailsVM.validatePII(info);
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<form action="#" class="flex w-full flex-col gap-4" onsubmit={onSubmit}>
|
|
||||||
<div class="flex flex-col gap-4 md:flex-row">
|
|
||||||
<LabelWrapper
|
|
||||||
label="First Name"
|
|
||||||
error={billingDetailsVM.piiErrors.firstName}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
placeholder="First Name"
|
|
||||||
bind:value={info.firstName}
|
|
||||||
required
|
|
||||||
oninput={() => debounceValidate()}
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
|
|
||||||
<LabelWrapper
|
|
||||||
label="Middle Name"
|
|
||||||
error={billingDetailsVM.piiErrors.middleName}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
placeholder="Middle Name"
|
|
||||||
bind:value={info.middleName}
|
|
||||||
oninput={() => debounceValidate()}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
|
|
||||||
<LabelWrapper
|
|
||||||
label="Last Name"
|
|
||||||
error={billingDetailsVM.piiErrors.lastName}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
placeholder="Last Name"
|
|
||||||
bind:value={info.lastName}
|
|
||||||
required
|
|
||||||
oninput={() => debounceValidate()}
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-4 md:flex-row">
|
|
||||||
<LabelWrapper label="Country" error={billingDetailsVM.piiErrors.country}>
|
|
||||||
<Select.Root
|
|
||||||
type="single"
|
|
||||||
required
|
|
||||||
onValueChange={(e) => {
|
|
||||||
info.country = e;
|
|
||||||
debounceValidate();
|
|
||||||
}}
|
|
||||||
name="role"
|
|
||||||
>
|
|
||||||
<Select.Trigger class="w-full">
|
|
||||||
{capitalize(
|
|
||||||
info.country.length > 0 ? info.country : "Select",
|
|
||||||
)}
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Content>
|
|
||||||
{#each COUNTRIES_SELECT as country}
|
|
||||||
<Select.Item value={country.value}>
|
|
||||||
{country.label}
|
|
||||||
</Select.Item>
|
|
||||||
{/each}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
</LabelWrapper>
|
|
||||||
|
|
||||||
<LabelWrapper label="State" error={billingDetailsVM.piiErrors.state}>
|
|
||||||
<Input
|
|
||||||
placeholder="State"
|
|
||||||
bind:value={info.state}
|
|
||||||
required
|
|
||||||
oninput={() => debounceValidate()}
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-4 md:flex-row">
|
|
||||||
<LabelWrapper label="City" error={billingDetailsVM.piiErrors.city}>
|
|
||||||
<Input
|
|
||||||
placeholder="City"
|
|
||||||
bind:value={info.city}
|
|
||||||
required
|
|
||||||
minlength={1}
|
|
||||||
maxlength={80}
|
|
||||||
oninput={() => debounceValidate()}
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
|
|
||||||
<LabelWrapper label="Zip Code" error={billingDetailsVM.piiErrors.zipCode}>
|
|
||||||
<Input
|
|
||||||
placeholder="Zip Code"
|
|
||||||
bind:value={info.zipCode}
|
|
||||||
required
|
|
||||||
minlength={1}
|
|
||||||
oninput={() => debounceValidate()}
|
|
||||||
maxlength={12}
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<LabelWrapper label="Address" error={billingDetailsVM.piiErrors.address}>
|
|
||||||
<Input
|
|
||||||
placeholder="Address"
|
|
||||||
bind:value={info.address}
|
|
||||||
required
|
|
||||||
minlength={1}
|
|
||||||
maxlength={128}
|
|
||||||
oninput={() => debounceValidate()}
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
|
|
||||||
<LabelWrapper label="Address 2" error={billingDetailsVM.piiErrors.address2}>
|
|
||||||
<Input
|
|
||||||
placeholder="Address 2"
|
|
||||||
bind:value={info.address2}
|
|
||||||
required
|
|
||||||
minlength={1}
|
|
||||||
maxlength={128}
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
</form>
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import {
|
|
||||||
customerInfoModel,
|
|
||||||
type CustomerInfoModel,
|
|
||||||
} from "$lib/domains/ticket/data/entities/create.entities";
|
|
||||||
import { Gender } from "$lib/domains/ticket/data/entities/index";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
export class BillingDetailsViewModel {
|
|
||||||
// @ts-ignore
|
|
||||||
billingDetails = $state<CustomerInfoModel>(undefined);
|
|
||||||
|
|
||||||
piiErrors = $state<Partial<Record<keyof CustomerInfoModel, string>>>({});
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
reset() {
|
|
||||||
this.billingDetails = {
|
|
||||||
firstName: "",
|
|
||||||
middleName: "",
|
|
||||||
lastName: "",
|
|
||||||
email: "",
|
|
||||||
phoneCountryCode: "",
|
|
||||||
phoneNumber: "",
|
|
||||||
passportNo: "",
|
|
||||||
passportExpiry: "",
|
|
||||||
nationality: "",
|
|
||||||
gender: Gender.Male,
|
|
||||||
dob: "",
|
|
||||||
country: "",
|
|
||||||
state: "",
|
|
||||||
city: "",
|
|
||||||
zipCode: "",
|
|
||||||
address: "",
|
|
||||||
address2: "",
|
|
||||||
} as CustomerInfoModel;
|
|
||||||
this.piiErrors = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
setPII(info: CustomerInfoModel) {
|
|
||||||
this.billingDetails = info;
|
|
||||||
}
|
|
||||||
|
|
||||||
validatePII(info: CustomerInfoModel) {
|
|
||||||
try {
|
|
||||||
const result = customerInfoModel.parse(info);
|
|
||||||
this.piiErrors = {};
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof z.ZodError) {
|
|
||||||
this.piiErrors = error.errors.reduce(
|
|
||||||
(acc, curr) => {
|
|
||||||
const path = curr.path[0] as keyof CustomerInfoModel;
|
|
||||||
acc[path] = curr.message;
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<keyof CustomerInfoModel, string>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isPIIValid(): boolean {
|
|
||||||
return Object.keys(this.piiErrors).length === 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const billingDetailsVM = new BillingDetailsViewModel();
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import ButtonLoadableText from "$lib/components/atoms/button-loadable-text.svelte";
|
|
||||||
import Icon from "$lib/components/atoms/icon.svelte";
|
|
||||||
import Title from "$lib/components/atoms/title.svelte";
|
|
||||||
import Button, {
|
|
||||||
buttonVariants,
|
|
||||||
} from "$lib/components/ui/button/button.svelte";
|
|
||||||
import * as Dialog from "$lib/components/ui/dialog";
|
|
||||||
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
|
|
||||||
import { CheckoutStep } from "$lib/domains/order/data/entities";
|
|
||||||
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
|
|
||||||
import { flightTicketStore } from "$lib/domains/ticket/data/store";
|
|
||||||
import { cn } from "$lib/utils";
|
|
||||||
import { formatDate } from "@pkg/logic/core/date.utils";
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { toast } from "svelte-sonner";
|
|
||||||
import RightArrowIcon from "~icons/solar/arrow-right-broken";
|
|
||||||
import ArrowsExchangeIcon from "~icons/tabler/arrows-exchange-2";
|
|
||||||
import TicketDetailsModal from "../../ticket/ticket-details-modal.svelte";
|
|
||||||
import { checkoutVM } from "../checkout.vm.svelte";
|
|
||||||
import BillingDetailsForm from "./billing-details-form.svelte";
|
|
||||||
import { billingDetailsVM } from "./billing.details.vm.svelte";
|
|
||||||
import CouponSummary from "./coupon-summary.svelte";
|
|
||||||
import OrderSummary from "./order-summary.svelte";
|
|
||||||
import PaymentForm from "./payment-form.svelte";
|
|
||||||
import { paymentInfoVM } from "./payment.info.vm.svelte";
|
|
||||||
|
|
||||||
const cardStyle =
|
|
||||||
"flex w-full flex-col gap-4 rounded-lg bg-white p-4 shadow-lg md:p-8";
|
|
||||||
|
|
||||||
async function goBack() {
|
|
||||||
if ((await ckFlowVM.onBackToPIIBtnClick()) !== true) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
checkoutVM.checkoutStep = CheckoutStep.Initial;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
const validatedData = await paymentInfoVM.validateAndSubmit();
|
|
||||||
if (!validatedData) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const validBillingInfo = billingDetailsVM.validatePII(
|
|
||||||
billingDetailsVM.billingDetails,
|
|
||||||
);
|
|
||||||
if (!validBillingInfo) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
checkoutVM.continutingToNextStep = true;
|
|
||||||
const out = await ckFlowVM.executePaymentStep();
|
|
||||||
if (out !== true) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setTimeout(() => {
|
|
||||||
checkoutVM.continutingToNextStep = false;
|
|
||||||
checkoutVM.checkoutStep = CheckoutStep.Verification;
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
let outboundFlight = $derived(
|
|
||||||
$flightTicketStore?.flightIteneraries.outbound[0],
|
|
||||||
);
|
|
||||||
let inboundFlight = $derived(
|
|
||||||
$flightTicketStore?.flightIteneraries.inbound[0],
|
|
||||||
);
|
|
||||||
let isReturnFlight = $derived(
|
|
||||||
$flightTicketStore?.flightType === TicketType.Return,
|
|
||||||
);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!ckFlowVM.flowId || !ckFlowVM.setupDone) return;
|
|
||||||
if (!paymentInfoVM.cardDetails) return;
|
|
||||||
|
|
||||||
paymentInfoVM.cardDetails.cardNumber;
|
|
||||||
paymentInfoVM.cardDetails.cardholderName;
|
|
||||||
paymentInfoVM.cardDetails.cvv;
|
|
||||||
paymentInfoVM.cardDetails.expiry;
|
|
||||||
|
|
||||||
// Always sync payment info regardless of validation status
|
|
||||||
ckFlowVM.debouncePaymentInfoSync();
|
|
||||||
});
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
window.scrollTo(0, 0);
|
|
||||||
|
|
||||||
billingDetailsVM.validatePII(billingDetailsVM.billingDetails);
|
|
||||||
if (billingDetailsVM.isPIIValid()) {
|
|
||||||
console.log("Billing details are valid, not setting from pasenger");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (passengerInfoVM.passengerInfos.length > 0) {
|
|
||||||
billingDetailsVM.setPII(
|
|
||||||
passengerInfoVM.passengerInfos[0].passengerPii,
|
|
||||||
);
|
|
||||||
billingDetailsVM.validatePII(billingDetailsVM.billingDetails);
|
|
||||||
toast("Used billing details from primary passenger");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
|
||||||
<div class={cardStyle}>
|
|
||||||
<Title size="h4">Trip Summary</Title>
|
|
||||||
<div
|
|
||||||
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"
|
|
||||||
>
|
|
||||||
<!-- Trip Summary -->
|
|
||||||
<div class="flex flex-col gap-4 md:gap-2">
|
|
||||||
<!-- Main Route Display -->
|
|
||||||
<div class="flex items-center gap-2 text-lg font-semibold">
|
|
||||||
<span>{outboundFlight?.departure.station.code}</span>
|
|
||||||
{#if isReturnFlight}
|
|
||||||
<Icon
|
|
||||||
icon={ArrowsExchangeIcon}
|
|
||||||
cls="w-5 h-5 text-gray-400 rotate-180"
|
|
||||||
/>
|
|
||||||
<span>{outboundFlight?.destination.station.code}</span>
|
|
||||||
{:else}
|
|
||||||
<Icon icon={RightArrowIcon} cls="w-5 h-5 text-gray-400" />
|
|
||||||
<span>{outboundFlight?.destination.station.code}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Dates Display -->
|
|
||||||
<div class="flex flex-col gap-1 text-sm text-gray-600 md:gap-0">
|
|
||||||
{#if isReturnFlight}
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span>
|
|
||||||
{formatDate(outboundFlight?.departure.localTime)}
|
|
||||||
- {formatDate(inboundFlight.departure.localTime)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span>
|
|
||||||
{formatDate(outboundFlight?.departure.localTime)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- View Details Button -->
|
|
||||||
<TicketDetailsModal
|
|
||||||
data={$flightTicketStore}
|
|
||||||
hideCheckoutBtn
|
|
||||||
onCheckoutBtnClick={() => {}}
|
|
||||||
>
|
|
||||||
<Dialog.Trigger
|
|
||||||
class={cn(
|
|
||||||
buttonVariants({ variant: "secondary" }),
|
|
||||||
"w-max text-start",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
View Full Details
|
|
||||||
</Dialog.Trigger>
|
|
||||||
</TicketDetailsModal>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class={cardStyle}>
|
|
||||||
<Title size="h4">Order Summary</Title>
|
|
||||||
<OrderSummary />
|
|
||||||
|
|
||||||
<CouponSummary />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class={cardStyle}>
|
|
||||||
<Title size="h4">Billing Details</Title>
|
|
||||||
<BillingDetailsForm info={billingDetailsVM.billingDetails} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class={cardStyle}>
|
|
||||||
<Title size="h4">Payment Details</Title>
|
|
||||||
<PaymentForm />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col items-center justify-between gap-4 md:flex-row">
|
|
||||||
<Button variant="secondary" onclick={goBack} class="w-full md:w-max">
|
|
||||||
<Icon icon={RightArrowIcon} cls="w-auto h-6 rotate-180" />
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
onclick={handleSubmit}
|
|
||||||
class="w-full md:w-max"
|
|
||||||
disabled={checkoutVM.continutingToNextStep}
|
|
||||||
>
|
|
||||||
<ButtonLoadableText
|
|
||||||
text="Confirm & Pay"
|
|
||||||
loadingText="Processing info..."
|
|
||||||
loading={checkoutVM.continutingToNextStep}
|
|
||||||
/>
|
|
||||||
<Icon icon={RightArrowIcon} cls="w-auto h-6" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
|
|
||||||
import Icon from "$lib/components/atoms/icon.svelte";
|
|
||||||
import { capitalize } from "$lib/core/string.utils";
|
|
||||||
import BackpackIcon from "~icons/solar/backpack-linear";
|
|
||||||
import BagIcon from "~icons/lucide/briefcase";
|
|
||||||
import SuitcaseIcon from "~icons/bi/suitcase2";
|
|
||||||
import SeatIcon from "~icons/solar/armchair-2-linear";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
|
||||||
{#each passengerInfoVM.passengerInfos as passenger, index}
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="font-semibold">
|
|
||||||
Passenger {index + 1} ({capitalize(passenger.passengerType)})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Personal Info -->
|
|
||||||
<div class="rounded-lg border bg-gray-50 p-4">
|
|
||||||
<div class="grid grid-cols-2 gap-3 text-sm md:grid-cols-3">
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500">Name</span>
|
|
||||||
<p class="font-medium">
|
|
||||||
{passenger.passengerPii.firstName}
|
|
||||||
{passenger.passengerPii.lastName}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500">Nationality</span>
|
|
||||||
<p class="font-medium">
|
|
||||||
{passenger.passengerPii.nationality}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500">Date of Birth</span>
|
|
||||||
<p class="font-medium">{passenger.passengerPii.dob}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Baggage Selection -->
|
|
||||||
<div class="flex flex-wrap gap-4 text-sm">
|
|
||||||
{#if passenger.bagSelection.personalBags > 0}
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Icon icon={BackpackIcon} cls="h-5 w-5 text-gray-600" />
|
|
||||||
<span>Personal Item</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if passenger.bagSelection.handBags > 0}
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Icon icon={SuitcaseIcon} cls="h-5 w-5 text-gray-600" />
|
|
||||||
<span>{passenger.bagSelection.handBags} x Cabin Bag</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if passenger.bagSelection.checkedBags > 0}
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Icon icon={BagIcon} cls="h-5 w-5 text-gray-600" />
|
|
||||||
<span>
|
|
||||||
{passenger.bagSelection.checkedBags} x Checked Bag
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Seat Selection -->
|
|
||||||
{#if passenger.seatSelection.number}
|
|
||||||
<div class="flex items-center gap-2 text-sm">
|
|
||||||
<Icon icon={SeatIcon} cls="h-5 w-5 text-gray-600" />
|
|
||||||
<span>Seat {passenger.seatSelection.number}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if index < passengerInfoVM.passengerInfos.length - 1}
|
|
||||||
<div class="border-b border-dashed"></div>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Input from "$lib/components/ui/input/input.svelte";
|
|
||||||
import LabelWrapper from "$lib/components/atoms/label-wrapper.svelte";
|
|
||||||
import { paymentInfoVM } from "./payment.info.vm.svelte";
|
|
||||||
import { chunk } from "$lib/core/array.utils";
|
|
||||||
|
|
||||||
function formatCardNumberForDisplay(value: string) {
|
|
||||||
// return in format "XXXX XXXX XXXX XXXX" from "XXXXXXXXXXXXXXXX"
|
|
||||||
const numbers = value.replace(/\D/g, "");
|
|
||||||
if (numbers.length > 4) {
|
|
||||||
return `${numbers.slice(0, 4)} ${numbers.slice(4, 8)} ${numbers.slice(
|
|
||||||
8,
|
|
||||||
12,
|
|
||||||
)} ${numbers.slice(12, numbers.length)}`;
|
|
||||||
}
|
|
||||||
return numbers.slice(0, 19);
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanupCardNo(value: string) {
|
|
||||||
return value.replace(/\D/g, "").slice(0, 16);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatExpiryDate(value: string) {
|
|
||||||
const numbers = value.replace(/\D/g, "");
|
|
||||||
if (numbers.length > 2) {
|
|
||||||
return `${numbers.slice(0, 2)}/${numbers.slice(2, 4)}`;
|
|
||||||
}
|
|
||||||
return numbers;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCVV(value: string) {
|
|
||||||
return value.replace(/\D/g, "").slice(0, 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
let validationTimeout = $state(undefined as undefined | NodeJS.Timer);
|
|
||||||
|
|
||||||
function debounceValidate() {
|
|
||||||
if (validationTimeout) {
|
|
||||||
clearTimeout(validationTimeout);
|
|
||||||
}
|
|
||||||
validationTimeout = setTimeout(() => {
|
|
||||||
paymentInfoVM.validateAndSubmit();
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<form class="flex flex-col gap-4">
|
|
||||||
<LabelWrapper
|
|
||||||
label="Name on Card"
|
|
||||||
error={paymentInfoVM.errors.cardholderName}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder="John Doe"
|
|
||||||
bind:value={paymentInfoVM.cardDetails.cardholderName}
|
|
||||||
oninput={() => debounceValidate()}
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
|
|
||||||
<LabelWrapper label="Card Number" error={paymentInfoVM.errors.cardNumber}>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder="1234 5678 9012 3456"
|
|
||||||
maxlength={19}
|
|
||||||
value={formatCardNumberForDisplay(
|
|
||||||
paymentInfoVM.cardDetails.cardNumber,
|
|
||||||
)}
|
|
||||||
oninput={(e) => {
|
|
||||||
paymentInfoVM.cardDetails.cardNumber = cleanupCardNo(
|
|
||||||
e.currentTarget.value,
|
|
||||||
);
|
|
||||||
debounceValidate();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<LabelWrapper label="Expiry Date" error={paymentInfoVM.errors.expiry}>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder="MM/YY"
|
|
||||||
bind:value={paymentInfoVM.cardDetails.expiry}
|
|
||||||
oninput={(e) => {
|
|
||||||
paymentInfoVM.cardDetails.expiry = formatExpiryDate(
|
|
||||||
e.currentTarget.value,
|
|
||||||
);
|
|
||||||
debounceValidate();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
|
|
||||||
<LabelWrapper label="CVV" error={paymentInfoVM.errors.cvv}>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder="123"
|
|
||||||
bind:value={paymentInfoVM.cardDetails.cvv}
|
|
||||||
oninput={(e) => {
|
|
||||||
paymentInfoVM.cardDetails.cvv = formatCVV(
|
|
||||||
e.currentTarget.value,
|
|
||||||
);
|
|
||||||
debounceValidate();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import {
|
|
||||||
type CardInfo,
|
|
||||||
cardInfoModel,
|
|
||||||
} from "$lib/domains/paymentinfo/data/entities";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
const _default = { cardholderName: "", cardNumber: "", expiry: "", cvv: "" };
|
|
||||||
|
|
||||||
class PaymentInfoViewModel {
|
|
||||||
cardDetails = $state<CardInfo>({ ..._default });
|
|
||||||
|
|
||||||
errors = $state<Partial<Record<keyof CardInfo, string>>>({});
|
|
||||||
|
|
||||||
reset() {
|
|
||||||
this.cardDetails = { ..._default };
|
|
||||||
this.errors = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
async validateAndSubmit() {
|
|
||||||
try {
|
|
||||||
const result = cardInfoModel.parse(this.cardDetails);
|
|
||||||
this.errors = {};
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof z.ZodError) {
|
|
||||||
this.errors = error.errors.reduce(
|
|
||||||
(acc, curr) => {
|
|
||||||
const path = curr.path[0] as keyof CardInfo;
|
|
||||||
acc[path] = curr.message;
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<keyof CardInfo, string>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const paymentInfoVM = new PaymentInfoViewModel();
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Title from "$lib/components/atoms/title.svelte";
|
|
||||||
import {
|
|
||||||
convertAndFormatCurrency,
|
|
||||||
currencyStore,
|
|
||||||
} from "$lib/domains/currency/view/currency.vm.svelte";
|
|
||||||
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
|
|
||||||
import { flightTicketStore } from "../../data/store";
|
|
||||||
import { calculateTicketPrices } from "./total.calculator";
|
|
||||||
import { Badge } from "$lib/components/ui/badge";
|
|
||||||
import Icon from "$lib/components/atoms/icon.svelte";
|
|
||||||
import TagIcon from "~icons/lucide/tag"; // Import a tag/coupon icon
|
|
||||||
|
|
||||||
let totals = $state(
|
|
||||||
calculateTicketPrices($flightTicketStore, passengerInfoVM.passengerInfos),
|
|
||||||
);
|
|
||||||
let changing = $state(false);
|
|
||||||
let appliedCoupon = $state(
|
|
||||||
$flightTicketStore?.priceDetails?.appliedCoupon || null,
|
|
||||||
);
|
|
||||||
let couponDescription = $state("");
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
changing = true;
|
|
||||||
totals = calculateTicketPrices(
|
|
||||||
$flightTicketStore,
|
|
||||||
passengerInfoVM.passengerInfos,
|
|
||||||
);
|
|
||||||
appliedCoupon = $flightTicketStore?.priceDetails?.appliedCoupon || null;
|
|
||||||
changing = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
flightTicketStore.subscribe((val) => {
|
|
||||||
changing = true;
|
|
||||||
totals = calculateTicketPrices(val, passengerInfoVM.passengerInfos);
|
|
||||||
appliedCoupon = val?.priceDetails?.appliedCoupon || null;
|
|
||||||
changing = false;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-4 rounded-lg bg-white p-4 drop-shadow-lg md:p-8">
|
|
||||||
<Title size="h4" weight="medium">Payment Summary</Title>
|
|
||||||
<div class="h-0.5 w-full border-t-2 border-gray-200"></div>
|
|
||||||
|
|
||||||
{#if !changing}
|
|
||||||
<!-- Base Ticket Price Breakdown -->
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<Title size="p" weight="medium">Base Ticket Price</Title>
|
|
||||||
<div class="flex justify-between text-sm">
|
|
||||||
<span>Total Ticket Price</span>
|
|
||||||
<span>{convertAndFormatCurrency(totals.baseTicketPrice)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4 text-sm text-gray-600">
|
|
||||||
<span>
|
|
||||||
Price per passenger (x{passengerInfoVM.passengerInfos.length})
|
|
||||||
</span>
|
|
||||||
<span class="float-right">
|
|
||||||
{convertAndFormatCurrency(totals.pricePerPassenger)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Baggage Costs -->
|
|
||||||
{#if totals.totalBaggageCost > 0}
|
|
||||||
<div class="mt-2 flex flex-col gap-2 border-t pt-2">
|
|
||||||
<Title size="p" weight="medium">Baggage Charges</Title>
|
|
||||||
{#each totals.passengerBaggageCosts as passengerBaggage}
|
|
||||||
{#if passengerBaggage.totalBaggageCost > 0}
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<span class="text-sm font-medium">
|
|
||||||
{passengerBaggage.passengerName}
|
|
||||||
</span>
|
|
||||||
{#if passengerBaggage.personalBagCost > 0}
|
|
||||||
<div
|
|
||||||
class="ml-4 flex justify-between text-sm text-gray-600"
|
|
||||||
>
|
|
||||||
<span>Personal Bag</span>
|
|
||||||
<span>
|
|
||||||
{convertAndFormatCurrency(
|
|
||||||
passengerBaggage.personalBagCost,
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if passengerBaggage.handBagCost > 0}
|
|
||||||
<div
|
|
||||||
class="ml-4 flex justify-between text-sm text-gray-600"
|
|
||||||
>
|
|
||||||
<span>Hand Baggage</span>
|
|
||||||
<span>
|
|
||||||
{convertAndFormatCurrency(
|
|
||||||
passengerBaggage.handBagCost,
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if passengerBaggage.checkedBagCost > 0}
|
|
||||||
<div
|
|
||||||
class="ml-4 flex justify-between text-sm text-gray-600"
|
|
||||||
>
|
|
||||||
<span>Checked Baggage</span>
|
|
||||||
<span>
|
|
||||||
{convertAndFormatCurrency(
|
|
||||||
passengerBaggage.checkedBagCost,
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
<div class="flex justify-between text-sm font-medium">
|
|
||||||
<span>Total Baggage Charges</span>
|
|
||||||
<span
|
|
||||||
>{convertAndFormatCurrency(totals.totalBaggageCost)}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Final Total -->
|
|
||||||
<div class="mt-4 flex flex-col gap-2 border-t pt-4">
|
|
||||||
<div class="flex justify-between text-sm">
|
|
||||||
<span>Subtotal</span>
|
|
||||||
<span>{convertAndFormatCurrency(totals.subtotal)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Coupon section -->
|
|
||||||
{#if totals.discountAmount > 0 && appliedCoupon}
|
|
||||||
<div class="my-2 flex flex-col gap-1 rounded-lg bg-green-50 p-3">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-2 text-green-700">
|
|
||||||
<Icon icon={TagIcon} cls="h-4 w-4" />
|
|
||||||
<span class="font-medium"
|
|
||||||
>Coupon Applied: {appliedCoupon}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
class="border-green-600 px-2 py-0.5 text-green-600"
|
|
||||||
>
|
|
||||||
{Math.round(
|
|
||||||
(totals.discountAmount / totals.subtotal) * 100,
|
|
||||||
)}% OFF
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 flex justify-between text-sm text-green-600">
|
|
||||||
<span>Discount</span>
|
|
||||||
<span
|
|
||||||
>-{convertAndFormatCurrency(
|
|
||||||
totals.discountAmount,
|
|
||||||
)}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{#if $flightTicketStore?.priceDetails?.couponDescription}
|
|
||||||
<p class="mt-1 text-xs text-green-700">
|
|
||||||
{$flightTicketStore.priceDetails.couponDescription}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else if totals.discountAmount > 0}
|
|
||||||
<div class="flex justify-between text-sm text-green-600">
|
|
||||||
<span>Discount</span>
|
|
||||||
<span>-{convertAndFormatCurrency(totals.discountAmount)}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="flex justify-between font-medium">
|
|
||||||
<Title size="h5" weight="medium"
|
|
||||||
>Total ({$currencyStore.code})</Title
|
|
||||||
>
|
|
||||||
<span>{convertAndFormatCurrency(totals.finalTotal)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="grid place-items-center p-2 text-center">
|
|
||||||
<span>Calculating . . .</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="mt-4 rounded-lg bg-gray-50 p-4 text-xs text-gray-600">
|
|
||||||
<p class="mb-2 font-medium">Important Information:</p>
|
|
||||||
<ul class="list-disc space-y-1 pl-4">
|
|
||||||
<li>Prices include all applicable taxes and fees</li>
|
|
||||||
<li>Cancellation and change fees may apply as per our policy</li>
|
|
||||||
<li>Additional baggage fees may apply based on airline policy</li>
|
|
||||||
{#if appliedCoupon}
|
|
||||||
<li class="text-green-600">
|
|
||||||
Discount applied via coupon: {appliedCoupon}
|
|
||||||
</li>
|
|
||||||
{/if}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Loader from "$lib/components/atoms/loader.svelte";
|
|
||||||
import { onDestroy, onMount } from "svelte";
|
|
||||||
|
|
||||||
const initialMessages = [
|
|
||||||
"Processing your payment securely...",
|
|
||||||
"Getting everything ready for you...",
|
|
||||||
"Setting up your transaction...",
|
|
||||||
"Starting the payment process...",
|
|
||||||
"Initiating secure payment...",
|
|
||||||
];
|
|
||||||
|
|
||||||
const fiveSecondMessages = [
|
|
||||||
"Almost there! Just finalizing your payment details...",
|
|
||||||
"Just a few more moments while we confirm everything...",
|
|
||||||
"We're processing your payment with care...",
|
|
||||||
"Double-checking all the details...",
|
|
||||||
"Making sure everything is in order...",
|
|
||||||
];
|
|
||||||
|
|
||||||
const tenSecondMessages = [
|
|
||||||
"Thank you for your patience. We're making sure everything is perfect...",
|
|
||||||
"Still working on it – thanks for being patient...",
|
|
||||||
"We're double-checking everything to ensure a smooth transaction...",
|
|
||||||
"Nearly there! Just completing the final security checks...",
|
|
||||||
"Your patience is appreciated while we process this securely...",
|
|
||||||
];
|
|
||||||
|
|
||||||
const twentySecondMessages = [
|
|
||||||
"Still working on it! Your transaction security is our top priority...",
|
|
||||||
"We appreciate your continued patience while we secure your transaction...",
|
|
||||||
"Taking extra care to process your payment safely...",
|
|
||||||
"Still working diligently to complete your transaction...",
|
|
||||||
"Thank you for waiting – we're ensuring everything is processed correctly...",
|
|
||||||
];
|
|
||||||
|
|
||||||
const getRandomMessage = (messages: string[]) => {
|
|
||||||
return messages[Math.floor(Math.random() * messages.length)];
|
|
||||||
};
|
|
||||||
|
|
||||||
let _defaultTxt = getRandomMessage(initialMessages);
|
|
||||||
let txt = $state(_defaultTxt);
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
txt = getRandomMessage(fiveSecondMessages);
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
txt = getRandomMessage(tenSecondMessages);
|
|
||||||
}, 10000);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
txt = getRandomMessage(twentySecondMessages);
|
|
||||||
}, 20000);
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
txt = _defaultTxt;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex h-full w-full flex-col place-items-center p-4 py-20 md:p-8 md:py-32"
|
|
||||||
>
|
|
||||||
<Loader />
|
|
||||||
<p class="animate-pulse py-20 text-center">{txt}</p>
|
|
||||||
</div>
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
|
|
||||||
import { onDestroy, onMount } from "svelte";
|
|
||||||
import { checkoutVM } from "./checkout.vm.svelte";
|
|
||||||
import OtpVerificationSection from "./otp-verification-section.svelte";
|
|
||||||
import PaymentVerificationLoader from "./payment-verification-loader.svelte";
|
|
||||||
|
|
||||||
let refreshIntervalId: NodeJS.Timer;
|
|
||||||
|
|
||||||
// Function to check if we need to show the OTP form
|
|
||||||
function shouldShowOtpForm() {
|
|
||||||
return (
|
|
||||||
ckFlowVM.info?.showVerification &&
|
|
||||||
ckFlowVM.flowId &&
|
|
||||||
!ckFlowVM.info?.otpSubmitted
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let showOtpVerificationForm = $state(shouldShowOtpForm());
|
|
||||||
|
|
||||||
// Refresh the OTP form visibility state based on the latest flow info
|
|
||||||
function refreshOtpState() {
|
|
||||||
showOtpVerificationForm = shouldShowOtpForm();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Listen for changes to ckFlowVM.info
|
|
||||||
$effect(() => {
|
|
||||||
if (ckFlowVM.info) {
|
|
||||||
refreshOtpState();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function gototop() {
|
|
||||||
window.scrollTo(0, 0);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
// Set up interval to check for OTP state changes
|
|
||||||
refreshIntervalId = setInterval(() => {
|
|
||||||
refreshOtpState();
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
const lower = 1000;
|
|
||||||
const upper = 10_000;
|
|
||||||
const rng = Math.floor(Math.random() * (upper - lower + 1)) + lower;
|
|
||||||
setTimeout(async () => {
|
|
||||||
if (ckFlowVM.setupDone && !ckFlowVM.flowId) {
|
|
||||||
console.log("Shortcut - Checking out");
|
|
||||||
await checkoutVM.checkout();
|
|
||||||
}
|
|
||||||
}, rng);
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
clearInterval(refreshIntervalId);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if showOtpVerificationForm}
|
|
||||||
{@const done = gototop()}
|
|
||||||
<OtpVerificationSection />
|
|
||||||
{:else}
|
|
||||||
{@const done2 = gototop()}
|
|
||||||
<PaymentVerificationLoader />
|
|
||||||
{/if}
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Icon from "$lib/components/atoms/icon.svelte";
|
|
||||||
import Title from "$lib/components/atoms/title.svelte";
|
|
||||||
import Button from "$lib/components/ui/button/button.svelte";
|
|
||||||
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
|
|
||||||
import { CheckoutStep } from "$lib/domains/ticket/data/entities";
|
|
||||||
import { flightTicketStore } from "$lib/domains/ticket/data/store";
|
|
||||||
import { cn } from "$lib/utils";
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import RightArrowIcon from "~icons/solar/arrow-right-broken";
|
|
||||||
import CheckoutLoadingSection from "../checkout-loading-section.svelte";
|
|
||||||
import { checkoutVM } from "../checkout.vm.svelte";
|
|
||||||
import { seatSelectionVM } from "./seat.selection.vm.svelte";
|
|
||||||
|
|
||||||
const cardStyle =
|
|
||||||
"flex w-full flex-col gap-4 rounded-lg bg-white p-4 shadow-lg md:p-8";
|
|
||||||
|
|
||||||
let currentFlight = $derived(
|
|
||||||
[
|
|
||||||
...$flightTicketStore.flightIteneraries.outbound,
|
|
||||||
...$flightTicketStore.flightIteneraries.inbound,
|
|
||||||
][seatSelectionVM.currentFlightIndex],
|
|
||||||
);
|
|
||||||
|
|
||||||
function goBack() {
|
|
||||||
checkoutVM.checkoutStep = CheckoutStep.Initial;
|
|
||||||
}
|
|
||||||
|
|
||||||
function goNext() {
|
|
||||||
// TODO: Add seat selection verification here
|
|
||||||
// Just cuse it's already setting it lol
|
|
||||||
skipAndContinue();
|
|
||||||
}
|
|
||||||
|
|
||||||
function skipAndContinue() {
|
|
||||||
checkoutVM.checkoutStep = CheckoutStep.Payment;
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
seatSelectionVM.fetchSeatMaps();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if seatSelectionVM.loading}
|
|
||||||
<CheckoutLoadingSection />
|
|
||||||
{:else}
|
|
||||||
<div class="flex flex-col gap-6">
|
|
||||||
<div class={cardStyle}>
|
|
||||||
<div
|
|
||||||
class="flex flex-col items-center justify-between gap-4 sm:flex-row"
|
|
||||||
>
|
|
||||||
<Title size="h4">Select Your Seats</Title>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outlineWhite"
|
|
||||||
onclick={skipAndContinue}
|
|
||||||
>
|
|
||||||
Skip & Continue
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-4 border-b pb-4">
|
|
||||||
<span class="text-sm font-medium">
|
|
||||||
Select passenger to assign seat:
|
|
||||||
</span>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
{#each passengerInfoVM.passengerInfos as passenger}
|
|
||||||
<button
|
|
||||||
class={cn(
|
|
||||||
"rounded-lg border-2 px-4 py-2 transition-colors",
|
|
||||||
seatSelectionVM.currentPassengerId ===
|
|
||||||
passenger.id
|
|
||||||
? "border-primary bg-primary text-white"
|
|
||||||
: "border-gray-200 hover:border-primary/50",
|
|
||||||
)}
|
|
||||||
onclick={() =>
|
|
||||||
seatSelectionVM.setCurrentPassenger(passenger.id)}
|
|
||||||
>
|
|
||||||
{passenger.passengerPii.firstName}
|
|
||||||
{passenger.passengerPii.lastName}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Flight Info -->
|
|
||||||
<div
|
|
||||||
class="flex flex-col items-center justify-between gap-4 border-b pb-4 sm:flex-row"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-1 text-center sm:text-left">
|
|
||||||
<span class="text-sm text-gray-600">
|
|
||||||
Flight {seatSelectionVM.currentFlightIndex + 1} of {seatSelectionVM
|
|
||||||
.seatMaps.length}
|
|
||||||
</span>
|
|
||||||
<span class="font-medium">
|
|
||||||
{currentFlight.departure.station.code} → {currentFlight
|
|
||||||
.destination.station.code}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={seatSelectionVM.currentFlightIndex === 0}
|
|
||||||
onclick={() => seatSelectionVM.previousFlight()}
|
|
||||||
>
|
|
||||||
Prev Flight
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={seatSelectionVM.currentFlightIndex ===
|
|
||||||
seatSelectionVM.seatMaps.length - 1}
|
|
||||||
onclick={() => seatSelectionVM.nextFlight()}
|
|
||||||
>
|
|
||||||
Next Flight
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Seat Map -->
|
|
||||||
<div class="flex w-full justify-center py-8">
|
|
||||||
<div class="w-full overflow-x-auto">
|
|
||||||
<div
|
|
||||||
class="mx-auto grid w-[50vw] gap-2 lg:mx-0 lg:w-full lg:place-items-center"
|
|
||||||
>
|
|
||||||
<!-- Column headers now inside -->
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<span class="flex w-8 items-center justify-end"
|
|
||||||
></span>
|
|
||||||
{#each ["A", "B", "C", "", "D", "E", "F"] as letter}
|
|
||||||
<span
|
|
||||||
class="w-10 text-center text-sm text-gray-500"
|
|
||||||
>
|
|
||||||
{letter}
|
|
||||||
</span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#each seatSelectionVM.seatMaps[seatSelectionVM.currentFlightIndex].seats as row}
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<span
|
|
||||||
class="flex w-8 items-center justify-end text-sm text-gray-500"
|
|
||||||
>
|
|
||||||
{row[0].row}
|
|
||||||
</span>
|
|
||||||
{#each row as seat}
|
|
||||||
<button
|
|
||||||
class={cn(
|
|
||||||
"h-10 w-10 rounded-lg border-2 text-sm transition-colors",
|
|
||||||
seat.reserved
|
|
||||||
? "cursor-not-allowed border-gray-200 bg-gray-100"
|
|
||||||
: seat.available
|
|
||||||
? "border-primary hover:bg-primary/10"
|
|
||||||
: "cursor-not-allowed border-gray-200 bg-gray-200",
|
|
||||||
seatSelectionVM.isSeatAssigned(
|
|
||||||
currentFlight.flightId,
|
|
||||||
seat.id,
|
|
||||||
) &&
|
|
||||||
"border-primary bg-primary text-white",
|
|
||||||
)}
|
|
||||||
disabled={!seat.available ||
|
|
||||||
seat.reserved ||
|
|
||||||
seatSelectionVM.currentPassengerId ===
|
|
||||||
null}
|
|
||||||
onclick={() =>
|
|
||||||
seatSelectionVM.selectSeat(
|
|
||||||
currentFlight.flightId,
|
|
||||||
seat,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{seatSelectionVM.getSeatDisplay(
|
|
||||||
currentFlight.flightId,
|
|
||||||
seat.id,
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{#if seat.number === 3}
|
|
||||||
<div class="w-8"></div>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap justify-center gap-4 border-t pt-4">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div class="h-6 w-6 rounded border-2 border-primary"></div>
|
|
||||||
<span class="text-sm">Available</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
class="h-6 w-6 rounded border-2 border-primary bg-primary"
|
|
||||||
></div>
|
|
||||||
<span class="text-sm">Selected</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
class="h-6 w-6 rounded border-2 border-gray-200 bg-gray-100"
|
|
||||||
></div>
|
|
||||||
<span class="text-sm">Reserved</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
class="h-6 w-6 rounded border-2 border-gray-200 bg-gray-200"
|
|
||||||
></div>
|
|
||||||
<span class="text-sm">Unavailable</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col items-center justify-between gap-4 md:flex-row">
|
|
||||||
<Button variant="secondary" onclick={goBack} class="w-full md:w-max">
|
|
||||||
<Icon icon={RightArrowIcon} cls="w-auto h-6 rotate-180" />
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button variant="default" onclick={goNext} class="w-full md:w-max">
|
|
||||||
Continue to Payment
|
|
||||||
<Icon icon={RightArrowIcon} cls="w-auto h-6" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
import { flightTicketStore } from "$lib/domains/ticket/data/store";
|
|
||||||
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
|
|
||||||
import { get } from "svelte/store";
|
|
||||||
import type {
|
|
||||||
FlightSeatMap,
|
|
||||||
SeatSelectionInfo,
|
|
||||||
} from "$lib/domains/passengerinfo/data/entities";
|
|
||||||
import { toast } from "svelte-sonner";
|
|
||||||
|
|
||||||
type SeatAssignments = Record<
|
|
||||||
string,
|
|
||||||
{ [seatId: string]: { passengerId: number; passengerInitials: string } }
|
|
||||||
>;
|
|
||||||
|
|
||||||
export class SeatSelectionVM {
|
|
||||||
loading = $state(true);
|
|
||||||
currentFlightIndex = $state(0);
|
|
||||||
seatMaps = $state<FlightSeatMap[]>([]);
|
|
||||||
|
|
||||||
currentPassengerId = $state<number | null>(null);
|
|
||||||
|
|
||||||
seatAssignments = $state<SeatAssignments>({});
|
|
||||||
|
|
||||||
reset() {
|
|
||||||
this.loading = true;
|
|
||||||
this.currentFlightIndex = 0;
|
|
||||||
this.seatMaps = [];
|
|
||||||
this.currentPassengerId = null;
|
|
||||||
this.seatAssignments = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchSeatMaps() {
|
|
||||||
this.loading = true;
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
const info = get(flightTicketStore);
|
|
||||||
|
|
||||||
const flights = [
|
|
||||||
...info.flightIteneraries.outbound,
|
|
||||||
...info.flightIteneraries.inbound,
|
|
||||||
];
|
|
||||||
|
|
||||||
this.seatMaps = flights.map((flight) => ({
|
|
||||||
flightId: flight.flightId,
|
|
||||||
seats: this.generateMockSeatMap(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private generateMockSeatMap(): SeatSelectionInfo[][] {
|
|
||||||
const rows = 20;
|
|
||||||
const seatsPerRow = 6;
|
|
||||||
const seatMap: SeatSelectionInfo[][] = [];
|
|
||||||
const seatLetters = ["A", "B", "C", "D", "E", "F"];
|
|
||||||
|
|
||||||
for (let row = 0; row < rows; row++) {
|
|
||||||
const seatRow: SeatSelectionInfo[] = [];
|
|
||||||
const rowNumber = row + 1; // Row numbers start from 1
|
|
||||||
|
|
||||||
for (let seat = 0; seat < seatsPerRow; seat++) {
|
|
||||||
const random = Math.random();
|
|
||||||
seatRow.push({
|
|
||||||
id: `${rowNumber}${seatLetters[seat]}`,
|
|
||||||
row: rowNumber.toString(),
|
|
||||||
number: seat + 1,
|
|
||||||
seatLetter: seatLetters[seat],
|
|
||||||
available: random > 0.3,
|
|
||||||
reserved: random < 0.2,
|
|
||||||
price: {
|
|
||||||
currency: "USD",
|
|
||||||
basePrice: 25,
|
|
||||||
discountAmount: 0,
|
|
||||||
displayPrice: 25,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
seatMap.push(seatRow);
|
|
||||||
}
|
|
||||||
|
|
||||||
return seatMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
selectSeat(flightId: string, seat: SeatSelectionInfo) {
|
|
||||||
if (this.currentPassengerId === null) {
|
|
||||||
return toast.error("Please select a passenger first");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!seat.available || seat.reserved) {
|
|
||||||
return toast.error("Seat is not available");
|
|
||||||
}
|
|
||||||
const passenger = passengerInfoVM.passengerInfos.find(
|
|
||||||
(p) => p.id === this.currentPassengerId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!passenger) {
|
|
||||||
return toast.error("Passenger not found", {
|
|
||||||
description: "Please try refreshing page or book ticket again",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get passenger initials
|
|
||||||
const initials =
|
|
||||||
`${passenger.passengerPii.firstName[0]}${passenger.passengerPii.lastName[0]}`.toUpperCase();
|
|
||||||
|
|
||||||
// Update seat assignments
|
|
||||||
if (!this.seatAssignments[flightId]) {
|
|
||||||
this.seatAssignments[flightId] = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove any previous seat assignment for this passenger on this flight
|
|
||||||
Object.entries(this.seatAssignments[flightId]).forEach(
|
|
||||||
([seatId, assignment]) => {
|
|
||||||
if (assignment.passengerId === this.currentPassengerId) {
|
|
||||||
delete this.seatAssignments[flightId][seatId];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Assign new seat
|
|
||||||
this.seatAssignments[flightId][seat.id] = {
|
|
||||||
passengerId: this.currentPassengerId,
|
|
||||||
passengerInitials: initials,
|
|
||||||
};
|
|
||||||
|
|
||||||
passenger.seatSelection = {
|
|
||||||
id: seat.id,
|
|
||||||
row: seat.row,
|
|
||||||
number: seat.number,
|
|
||||||
seatLetter: seat.seatLetter,
|
|
||||||
available: seat.available,
|
|
||||||
reserved: seat.reserved,
|
|
||||||
price: seat.price,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
isSeatAssigned(flightId: string, seatId: string) {
|
|
||||||
return this.seatAssignments[flightId]?.[seatId] !== undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
getSeatDisplay(flightId: string, seatId: string) {
|
|
||||||
return (
|
|
||||||
this.seatAssignments[flightId]?.[seatId]?.passengerInitials ??
|
|
||||||
`${seatId[seatId.length - 1]}${seatId.slice(0, -1)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentPassenger(passengerId: number) {
|
|
||||||
this.currentPassengerId = passengerId;
|
|
||||||
}
|
|
||||||
nextFlight() {
|
|
||||||
if (this.currentFlightIndex < this.seatMaps.length - 1) {
|
|
||||||
this.currentFlightIndex++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
previousFlight() {
|
|
||||||
if (this.currentFlightIndex > 0) {
|
|
||||||
this.currentFlightIndex--;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const seatSelectionVM = new SeatSelectionVM();
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import type { PassengerInfo } from "$lib/domains/passengerinfo/data/entities";
|
|
||||||
import type { FlightTicket } from "../../data/entities";
|
|
||||||
|
|
||||||
export interface BaggageCost {
|
|
||||||
passengerId: number;
|
|
||||||
passengerName: string;
|
|
||||||
personalBagCost: number;
|
|
||||||
handBagCost: number;
|
|
||||||
checkedBagCost: number;
|
|
||||||
totalBaggageCost: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PriceBreakdown {
|
|
||||||
baseTicketPrice: number;
|
|
||||||
pricePerPassenger: number;
|
|
||||||
passengerBaggageCosts: BaggageCost[];
|
|
||||||
totalBaggageCost: number;
|
|
||||||
subtotal: number;
|
|
||||||
discountAmount: number;
|
|
||||||
finalTotal: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function calculateTicketPrices(
|
|
||||||
ticket: FlightTicket,
|
|
||||||
passengerInfos: PassengerInfo[],
|
|
||||||
): PriceBreakdown {
|
|
||||||
if (!ticket || !passengerInfos || passengerInfos.length === 0) {
|
|
||||||
return {
|
|
||||||
baseTicketPrice: 0,
|
|
||||||
pricePerPassenger: 0,
|
|
||||||
passengerBaggageCosts: [],
|
|
||||||
totalBaggageCost: 0,
|
|
||||||
subtotal: 0,
|
|
||||||
discountAmount: 0,
|
|
||||||
finalTotal: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const displayPrice = ticket.priceDetails?.displayPrice ?? 0;
|
|
||||||
const originalBasePrice = ticket.priceDetails?.basePrice ?? 0;
|
|
||||||
const baseTicketPrice = Math.max(displayPrice, originalBasePrice);
|
|
||||||
const pricePerPassenger =
|
|
||||||
passengerInfos.length > 0
|
|
||||||
? baseTicketPrice / passengerInfos.length
|
|
||||||
: baseTicketPrice;
|
|
||||||
|
|
||||||
const passengerBaggageCosts: BaggageCost[] = passengerInfos.map(
|
|
||||||
(passenger) => {
|
|
||||||
// const personalBagCost =
|
|
||||||
// (passenger.bagSelection.personalBags || 0) *
|
|
||||||
// (ticket?.bagsInfo.details.personalBags.price ?? 0);
|
|
||||||
// const handBagCost =
|
|
||||||
// (passenger.bagSelection.handBags || 0) *
|
|
||||||
// (ticket?.bagsInfo.details.handBags.price ?? 0);
|
|
||||||
// const checkedBagCost =
|
|
||||||
// (passenger.bagSelection.checkedBags || 0) *
|
|
||||||
// (ticket?.bagsInfo.details.checkedBags.price ?? 0);
|
|
||||||
|
|
||||||
return {
|
|
||||||
passengerId: passenger.id,
|
|
||||||
passengerName: `${passenger.passengerPii.firstName} ${passenger.passengerPii.lastName}`,
|
|
||||||
personalBagCost: 0,
|
|
||||||
handBagCost: 0,
|
|
||||||
checkedBagCost: 0,
|
|
||||||
totalBaggageCost: 0,
|
|
||||||
// totalBaggageCost: personalBagCost + handBagCost + checkedBagCost,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// const totalBaggageCost = passengerBaggageCosts.reduce(
|
|
||||||
// (acc, curr) => acc + curr.totalBaggageCost,
|
|
||||||
// 0,
|
|
||||||
// );
|
|
||||||
const totalBaggageCost = 0;
|
|
||||||
|
|
||||||
const subtotal = baseTicketPrice + totalBaggageCost;
|
|
||||||
|
|
||||||
const discountAmount =
|
|
||||||
originalBasePrice > displayPrice
|
|
||||||
? (ticket?.priceDetails.discountAmount ?? 0)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const finalTotal = subtotal - discountAmount;
|
|
||||||
|
|
||||||
return {
|
|
||||||
baseTicketPrice,
|
|
||||||
pricePerPassenger,
|
|
||||||
passengerBaggageCosts,
|
|
||||||
totalBaggageCost,
|
|
||||||
subtotal,
|
|
||||||
discountAmount,
|
|
||||||
finalTotal,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import * as AlertDialog from "$lib/components/ui/alert-dialog/index.js";
|
|
||||||
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
|
|
||||||
import { flightTicketVM } from "../ticket.vm.svelte";
|
|
||||||
import ButtonLoadableText from "$lib/components/atoms/button-loadable-text.svelte";
|
|
||||||
import Title from "$lib/components/atoms/title.svelte";
|
|
||||||
import { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte";
|
|
||||||
|
|
||||||
async function onPriceUpdateConfirm() {
|
|
||||||
if (!ckFlowVM.updatedPrices) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await flightTicketVM.updateTicketPrices(ckFlowVM.updatedPrices);
|
|
||||||
ckFlowVM.clearUpdatedPrices();
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelBooking() {
|
|
||||||
window.location.replace("/search");
|
|
||||||
}
|
|
||||||
|
|
||||||
let open = $state(false);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
open = !!ckFlowVM.updatedPrices;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<AlertDialog.Root bind:open>
|
|
||||||
<AlertDialog.Content>
|
|
||||||
<AlertDialog.Header>
|
|
||||||
<AlertDialog.Title>The price has changed!</AlertDialog.Title>
|
|
||||||
<AlertDialog.Description>
|
|
||||||
Ticket prices change throughout the day and, unfortunately, the
|
|
||||||
price has been changed since last we had checked. You can continue
|
|
||||||
with the new price or check out alternative trips.
|
|
||||||
</AlertDialog.Description>
|
|
||||||
</AlertDialog.Header>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<Title size="h5" color="black">New Price</Title>
|
|
||||||
|
|
||||||
<Title size="h4" color="black" weight="semibold">
|
|
||||||
{convertAndFormatCurrency(
|
|
||||||
ckFlowVM.updatedPrices?.displayPrice ?? 0,
|
|
||||||
)}
|
|
||||||
</Title>
|
|
||||||
</div>
|
|
||||||
<AlertDialog.Footer>
|
|
||||||
<AlertDialog.Cancel
|
|
||||||
disabled={flightTicketVM.updatingPrices}
|
|
||||||
onclick={() => cancelBooking()}
|
|
||||||
>
|
|
||||||
Go Back
|
|
||||||
</AlertDialog.Cancel>
|
|
||||||
<AlertDialog.Action
|
|
||||||
disabled={flightTicketVM.updatingPrices}
|
|
||||||
onclick={() => onPriceUpdateConfirm()}
|
|
||||||
>
|
|
||||||
<ButtonLoadableText
|
|
||||||
loading={flightTicketVM.updatingPrices}
|
|
||||||
text={"Continue"}
|
|
||||||
loadingText={"Updating..."}
|
|
||||||
/>
|
|
||||||
</AlertDialog.Action>
|
|
||||||
</AlertDialog.Footer>
|
|
||||||
</AlertDialog.Content>
|
|
||||||
</AlertDialog.Root>
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { authRouter } from "$lib/domains/auth/domain/router";
|
import { authRouter } from "$lib/domains/auth/domain/router";
|
||||||
import { ckflowRouter } from "$lib/domains/ckflow/domain/router";
|
import { ckflowRouter } from "$lib/domains/ckflow/domain/router";
|
||||||
import { currencyRouter } from "$lib/domains/currency/domain/router";
|
import { currencyRouter } from "$lib/domains/currency/domain/router";
|
||||||
import { customerInfoRouter } from "$lib/domains/customerinfo/router";
|
|
||||||
import { orderRouter } from "$lib/domains/order/domain/router";
|
import { orderRouter } from "$lib/domains/order/domain/router";
|
||||||
import { productRouter } from "$lib/domains/product/router";
|
import { productRouter } from "$lib/domains/product/router";
|
||||||
import { userRouter } from "$lib/domains/user/domain/router";
|
import { userRouter } from "$lib/domains/user/domain/router";
|
||||||
@@ -14,7 +13,6 @@ export const router = createTRPCRouter({
|
|||||||
order: orderRouter,
|
order: orderRouter,
|
||||||
ckflow: ckflowRouter,
|
ckflow: ckflowRouter,
|
||||||
product: productRouter,
|
product: productRouter,
|
||||||
customerInfo: customerInfoRouter,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Router = typeof router;
|
export type Router = typeof router;
|
||||||
|
|||||||
Reference in New Issue
Block a user