Compare commits
10 Commits
3232542de1
...
5f9c094211
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f9c094211 | ||
|
|
88d430a15e | ||
|
|
8a169f84cc | ||
|
|
94bb51bdc7 | ||
|
|
49abd1246b | ||
|
|
de2fbd41d6 | ||
|
|
8440c6a2dd | ||
|
|
c3650f6d5e | ||
|
|
c0df8cae57 | ||
|
|
5f4e9fc7fc |
@@ -1,200 +0,0 @@
|
|||||||
import { trpcApiStore } from "$lib/stores/api";
|
|
||||||
import { toast } from "svelte-sonner";
|
|
||||||
import { get } from "svelte/store";
|
|
||||||
import type {
|
|
||||||
CouponModel,
|
|
||||||
CreateCouponPayload,
|
|
||||||
UpdateCouponPayload,
|
|
||||||
} from "./data";
|
|
||||||
import { DiscountType } from "./data";
|
|
||||||
|
|
||||||
export class CouponViewModel {
|
|
||||||
coupons = $state<CouponModel[]>([]);
|
|
||||||
loading = $state(false);
|
|
||||||
formLoading = $state(false);
|
|
||||||
|
|
||||||
// Selected coupon for edit/delete operations
|
|
||||||
selectedCoupon = $state<CouponModel | null>(null);
|
|
||||||
|
|
||||||
async fetchCoupons() {
|
|
||||||
const api = get(trpcApiStore);
|
|
||||||
if (!api) {
|
|
||||||
return toast.error("API client not initialized");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.loading = true;
|
|
||||||
try {
|
|
||||||
const result = await api.coupon.getAllCoupons.query();
|
|
||||||
if (result.error) {
|
|
||||||
toast.error(result.error.message, {
|
|
||||||
description: result.error.userHint,
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
console.log(result);
|
|
||||||
|
|
||||||
this.coupons = result.data || [];
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
toast.error("Failed to fetch coupons", {
|
|
||||||
description: "Please try again later",
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async createCoupon(payload: CreateCouponPayload) {
|
|
||||||
const api = get(trpcApiStore);
|
|
||||||
if (!api) {
|
|
||||||
return toast.error("API client not initialized");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.formLoading = true;
|
|
||||||
try {
|
|
||||||
const result = await api.coupon.createCoupon.mutate(payload);
|
|
||||||
if (result.error) {
|
|
||||||
toast.error(result.error.message, {
|
|
||||||
description: result.error.userHint,
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success("Coupon created successfully");
|
|
||||||
await this.fetchCoupons();
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
toast.error("Failed to create coupon", {
|
|
||||||
description: "Please try again later",
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
this.formLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateCoupon(payload: UpdateCouponPayload) {
|
|
||||||
const api = get(trpcApiStore);
|
|
||||||
if (!api) {
|
|
||||||
return toast.error("API client not initialized");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.formLoading = true;
|
|
||||||
try {
|
|
||||||
const result = await api.coupon.updateCoupon.mutate(payload);
|
|
||||||
if (result.error) {
|
|
||||||
toast.error(result.error.message, {
|
|
||||||
description: result.error.userHint,
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success("Coupon updated successfully");
|
|
||||||
await this.fetchCoupons();
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
toast.error("Failed to update coupon", {
|
|
||||||
description: "Please try again later",
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
this.formLoading = false;
|
|
||||||
this.selectedCoupon = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteCoupon(id: number) {
|
|
||||||
const api = get(trpcApiStore);
|
|
||||||
if (!api) {
|
|
||||||
return toast.error("API client not initialized");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.formLoading = true;
|
|
||||||
try {
|
|
||||||
const result = await api.coupon.deleteCoupon.mutate({ id });
|
|
||||||
if (result.error) {
|
|
||||||
toast.error(result.error.message, {
|
|
||||||
description: result.error.userHint,
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success("Coupon deleted successfully");
|
|
||||||
await this.fetchCoupons();
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
toast.error("Failed to delete coupon", {
|
|
||||||
description: "Please try again later",
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
this.formLoading = false;
|
|
||||||
this.selectedCoupon = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async toggleCouponStatus(id: number, isActive: boolean) {
|
|
||||||
const api = get(trpcApiStore);
|
|
||||||
if (!api) {
|
|
||||||
return toast.error("API client not initialized");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await api.coupon.toggleCouponStatus.mutate({
|
|
||||||
id,
|
|
||||||
isActive,
|
|
||||||
});
|
|
||||||
if (result.error) {
|
|
||||||
toast.error(result.error.message, {
|
|
||||||
description: result.error.userHint,
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success(
|
|
||||||
`Coupon ${isActive ? "activated" : "deactivated"} successfully`,
|
|
||||||
);
|
|
||||||
await this.fetchCoupons();
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(
|
|
||||||
`Failed to ${isActive ? "activate" : "deactivate"} coupon`,
|
|
||||||
{
|
|
||||||
description: "Please try again later",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
selectCoupon(coupon: CouponModel) {
|
|
||||||
this.selectedCoupon = { ...coupon };
|
|
||||||
}
|
|
||||||
|
|
||||||
clearSelectedCoupon() {
|
|
||||||
this.selectedCoupon = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
getDefaultCoupon(): CreateCouponPayload {
|
|
||||||
const today = new Date();
|
|
||||||
const nextMonth = new Date();
|
|
||||||
nextMonth.setMonth(today.getMonth() + 1);
|
|
||||||
|
|
||||||
return {
|
|
||||||
code: "",
|
|
||||||
description: "",
|
|
||||||
discountType: DiscountType.PERCENTAGE,
|
|
||||||
discountValue: 10,
|
|
||||||
maxUsageCount: null,
|
|
||||||
minOrderValue: null,
|
|
||||||
maxDiscountAmount: null,
|
|
||||||
startDate: today.toISOString().split("T")[0],
|
|
||||||
endDate: nextMonth.toISOString().split("T")[0],
|
|
||||||
isActive: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const couponVM = new CouponViewModel();
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from "@pkg/logic/domains/coupon/data";
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from "@pkg/logic/domains/coupon/repository";
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { createTRPCRouter } from "$lib/trpc/t";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { getCouponUseCases } from "./usecases";
|
|
||||||
import { createCouponPayload, updateCouponPayload } from "./data";
|
|
||||||
import { protectedProcedure } from "$lib/server/trpc/t";
|
|
||||||
|
|
||||||
export const couponRouter = createTRPCRouter({
|
|
||||||
getAllCoupons: protectedProcedure.query(async ({}) => {
|
|
||||||
const controller = getCouponUseCases();
|
|
||||||
return controller.getAllCoupons();
|
|
||||||
}),
|
|
||||||
|
|
||||||
getCouponById: protectedProcedure
|
|
||||||
.input(z.object({ id: z.number() }))
|
|
||||||
.query(async ({ input }) => {
|
|
||||||
const controller = getCouponUseCases();
|
|
||||||
return controller.getCouponById(input.id);
|
|
||||||
}),
|
|
||||||
|
|
||||||
createCoupon: protectedProcedure
|
|
||||||
.input(createCouponPayload)
|
|
||||||
.mutation(async ({ input, ctx }) => {
|
|
||||||
const controller = getCouponUseCases();
|
|
||||||
return controller.createCoupon(ctx.user, input);
|
|
||||||
}),
|
|
||||||
|
|
||||||
updateCoupon: protectedProcedure
|
|
||||||
.input(updateCouponPayload)
|
|
||||||
.mutation(async ({ input }) => {
|
|
||||||
const controller = getCouponUseCases();
|
|
||||||
return controller.updateCoupon(input);
|
|
||||||
}),
|
|
||||||
|
|
||||||
deleteCoupon: protectedProcedure
|
|
||||||
.input(z.object({ id: z.number() }))
|
|
||||||
.mutation(async ({ input }) => {
|
|
||||||
const controller = getCouponUseCases();
|
|
||||||
return controller.deleteCoupon(input.id);
|
|
||||||
}),
|
|
||||||
|
|
||||||
toggleCouponStatus: protectedProcedure
|
|
||||||
.input(z.object({ id: z.number(), isActive: z.boolean() }))
|
|
||||||
.mutation(async ({ input }) => {
|
|
||||||
const controller = getCouponUseCases();
|
|
||||||
return controller.toggleCouponStatus(input.id, input.isActive);
|
|
||||||
}),
|
|
||||||
|
|
||||||
getActiveCoupons: protectedProcedure.query(async ({}) => {
|
|
||||||
const controller = getCouponUseCases();
|
|
||||||
return controller.getActiveCoupons();
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from "@pkg/logic/domains/coupon/usecases";
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Input from "$lib/components/ui/input/input.svelte";
|
|
||||||
import Button from "$lib/components/ui/button/button.svelte";
|
|
||||||
import { Label } from "$lib/components/ui/label";
|
|
||||||
import * as Select from "$lib/components/ui/select";
|
|
||||||
import {
|
|
||||||
type CreateCouponPayload,
|
|
||||||
type UpdateCouponPayload,
|
|
||||||
DiscountType,
|
|
||||||
} from "$lib/domains/coupon/data";
|
|
||||||
import { Textarea } from "$lib/components/ui/textarea";
|
|
||||||
|
|
||||||
export let formData: CreateCouponPayload | UpdateCouponPayload;
|
|
||||||
export let loading = false;
|
|
||||||
export let onSubmit: () => void;
|
|
||||||
export let onCancel: () => void;
|
|
||||||
|
|
||||||
const isNewCoupon = !("id" in formData);
|
|
||||||
const discountTypes = [
|
|
||||||
{ value: DiscountType.PERCENTAGE, label: "Percentage" },
|
|
||||||
{ value: DiscountType.FIXED, label: "Fixed Amount" },
|
|
||||||
];
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<form on:submit|preventDefault={onSubmit} class="space-y-6">
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<!-- Code -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="code">Coupon Code</Label>
|
|
||||||
<Input
|
|
||||||
id="code"
|
|
||||||
bind:value={formData.code}
|
|
||||||
placeholder="e.g. SUMMER20"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Discount Type -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="discountType">Discount Type</Label>
|
|
||||||
<Select.Root
|
|
||||||
type="single"
|
|
||||||
required
|
|
||||||
bind:value={formData.discountType}
|
|
||||||
>
|
|
||||||
<Select.Trigger class="w-full">
|
|
||||||
{discountTypes.find((t) => t.value === formData.discountType)
|
|
||||||
?.label || "Select type"}
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Content>
|
|
||||||
{#each discountTypes as type}
|
|
||||||
<Select.Item value={type.value}>{type.label}</Select.Item>
|
|
||||||
{/each}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Discount Value -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="discountValue">
|
|
||||||
{formData.discountType === DiscountType.PERCENTAGE
|
|
||||||
? "Discount Percentage"
|
|
||||||
: "Discount Amount"}
|
|
||||||
</Label>
|
|
||||||
<div class="relative">
|
|
||||||
<Input
|
|
||||||
id="discountValue"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={formData.discountType === DiscountType.PERCENTAGE
|
|
||||||
? 100
|
|
||||||
: undefined}
|
|
||||||
step="0.01"
|
|
||||||
bind:value={formData.discountValue}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3"
|
|
||||||
>
|
|
||||||
<span class="text-gray-500">
|
|
||||||
{formData.discountType === DiscountType.PERCENTAGE
|
|
||||||
? "%"
|
|
||||||
: "$"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Description -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="description">Description</Label>
|
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
bind:value={formData.description}
|
|
||||||
placeholder="Describe what this coupon is for"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<!-- Start Date -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="startDate">Start Date</Label>
|
|
||||||
<Input
|
|
||||||
id="startDate"
|
|
||||||
type="date"
|
|
||||||
bind:value={formData.startDate}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- End Date -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="endDate">End Date (Optional)</Label>
|
|
||||||
<Input id="endDate" type="date" bind:value={formData.endDate} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<!-- Max Usage Count -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="maxUsageCount">Max Usage Count (Optional)</Label>
|
|
||||||
<Input
|
|
||||||
id="maxUsageCount"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
step={1}
|
|
||||||
bind:value={formData.maxUsageCount}
|
|
||||||
placeholder="Leave empty for unlimited"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Max Discount Amount -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="maxDiscountAmount">Max Discount Amount (Optional)</Label>
|
|
||||||
<div class="relative">
|
|
||||||
<Input
|
|
||||||
id="maxDiscountAmount"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
step="0.01"
|
|
||||||
bind:value={formData.maxDiscountAmount}
|
|
||||||
placeholder="Leave empty for no limit"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3"
|
|
||||||
>
|
|
||||||
<span class="text-gray-500">$</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Min Order Value -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="minOrderValue">Minimum Order Value (Optional)</Label>
|
|
||||||
<div class="relative">
|
|
||||||
<Input
|
|
||||||
id="minOrderValue"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
step="0.01"
|
|
||||||
bind:value={formData.minOrderValue}
|
|
||||||
placeholder="Leave empty for no minimum"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3"
|
|
||||||
>
|
|
||||||
<span class="text-gray-500">$</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
|
||||||
<div class="flex justify-end space-x-2">
|
|
||||||
<Button variant="outline" onclick={onCancel} disabled={loading}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={loading}>
|
|
||||||
{#if loading}
|
|
||||||
Processing...
|
|
||||||
{:else}
|
|
||||||
{isNewCoupon ? "Create Coupon" : "Update Coupon"}
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { couponVM } from "$lib/domains/coupon/coupon.vm.svelte";
|
|
||||||
import Button from "$lib/components/ui/button/button.svelte";
|
|
||||||
import * as Dialog from "$lib/components/ui/dialog";
|
|
||||||
import * as AlertDialog from "$lib/components/ui/alert-dialog/index";
|
|
||||||
import { Badge } from "$lib/components/ui/badge";
|
|
||||||
import CouponForm from "./coupon-form.svelte";
|
|
||||||
import Title from "$lib/components/atoms/title.svelte";
|
|
||||||
import {
|
|
||||||
PlusIcon,
|
|
||||||
Pencil,
|
|
||||||
Trash2,
|
|
||||||
CheckCircle,
|
|
||||||
XCircle,
|
|
||||||
} from "@lucide/svelte";
|
|
||||||
import Loader from "$lib/components/atoms/loader.svelte";
|
|
||||||
import { DiscountType, type CreateCouponPayload } from "../data";
|
|
||||||
|
|
||||||
let createDialogOpen = $state(false);
|
|
||||||
let editDialogOpen = $state(false);
|
|
||||||
let deleteDialogOpen = $state(false);
|
|
||||||
|
|
||||||
let newCouponData = $state<CreateCouponPayload>(couponVM.getDefaultCoupon());
|
|
||||||
|
|
||||||
async function handleCreateCoupon() {
|
|
||||||
const success = await couponVM.createCoupon(newCouponData);
|
|
||||||
if (success) {
|
|
||||||
createDialogOpen = false;
|
|
||||||
newCouponData = couponVM.getDefaultCoupon();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleUpdateCoupon() {
|
|
||||||
if (!couponVM.selectedCoupon) return;
|
|
||||||
|
|
||||||
const success = await couponVM.updateCoupon({
|
|
||||||
id: couponVM.selectedCoupon.id! ?? -1,
|
|
||||||
...couponVM.selectedCoupon,
|
|
||||||
});
|
|
||||||
if (success) {
|
|
||||||
editDialogOpen = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeleteCoupon() {
|
|
||||||
if (!couponVM.selectedCoupon) return;
|
|
||||||
|
|
||||||
const success = await couponVM.deleteCoupon(couponVM.selectedCoupon.id!);
|
|
||||||
if (success) {
|
|
||||||
deleteDialogOpen = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateString: string | null | undefined) {
|
|
||||||
if (!dateString) return "None";
|
|
||||||
return new Date(dateString).toLocaleDateString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDiscountValue(type: DiscountType, value: number) {
|
|
||||||
if (type === DiscountType.PERCENTAGE) {
|
|
||||||
return `${value}%`;
|
|
||||||
} else {
|
|
||||||
return `$${value.toFixed(2)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
setTimeout(async () => {
|
|
||||||
couponVM.fetchCoupons();
|
|
||||||
}, 1000);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="mb-6 flex items-center justify-between">
|
|
||||||
<Title size="h3" weight="semibold">Coupons</Title>
|
|
||||||
<Button onclick={() => (createDialogOpen = true)}>
|
|
||||||
<PlusIcon class="mr-2 h-4 w-4" />
|
|
||||||
New Coupon
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if couponVM.loading}
|
|
||||||
<div class="flex items-center justify-center py-20">
|
|
||||||
<Loader />
|
|
||||||
</div>
|
|
||||||
{:else if couponVM.coupons.length === 0}
|
|
||||||
<div class="rounded-lg border py-10 text-center">
|
|
||||||
<p class="text-gray-500">
|
|
||||||
No coupons found. Create your first coupon to get started.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full divide-y divide-gray-200">
|
|
||||||
<thead class="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
|
||||||
>Code</th
|
|
||||||
>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
|
||||||
>Discount</th
|
|
||||||
>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
|
||||||
>Valid Period</th
|
|
||||||
>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
|
||||||
>Status</th
|
|
||||||
>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500"
|
|
||||||
>Actions</th
|
|
||||||
>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-gray-200 bg-white">
|
|
||||||
{#each couponVM.coupons as coupon}
|
|
||||||
<tr>
|
|
||||||
<td class="whitespace-nowrap px-6 py-4">
|
|
||||||
<div class="font-medium text-gray-900">
|
|
||||||
{coupon.code}
|
|
||||||
</div>
|
|
||||||
{#if coupon.description}
|
|
||||||
<div class="text-sm text-gray-500">
|
|
||||||
{coupon.description}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td class="whitespace-nowrap px-6 py-4">
|
|
||||||
<Badge
|
|
||||||
>{formatDiscountValue(
|
|
||||||
coupon.discountType,
|
|
||||||
coupon.discountValue,
|
|
||||||
)}</Badge
|
|
||||||
>
|
|
||||||
{#if coupon.maxDiscountAmount}
|
|
||||||
<div class="mt-1 text-xs text-gray-500">
|
|
||||||
Max: ${coupon.maxDiscountAmount}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td class="whitespace-nowrap px-6 py-4">
|
|
||||||
<div>From: {formatDate(coupon.startDate)}</div>
|
|
||||||
<div>To: {formatDate(coupon.endDate)}</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td class="whitespace-nowrap px-6 py-4">
|
|
||||||
{#if coupon.isActive}
|
|
||||||
<Badge
|
|
||||||
variant="success"
|
|
||||||
class="flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<CheckCircle class="h-3 w-3" />
|
|
||||||
Active
|
|
||||||
</Badge>
|
|
||||||
{:else}
|
|
||||||
<Badge
|
|
||||||
variant="destructive"
|
|
||||||
class="flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<XCircle class="h-3 w-3" />
|
|
||||||
Inactive
|
|
||||||
</Badge>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td
|
|
||||||
class="flex w-full items-center justify-end gap-2 whitespace-nowrap px-6 py-4 text-right"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onclick={() => {
|
|
||||||
couponVM.selectCoupon(coupon);
|
|
||||||
editDialogOpen = true;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pencil class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onclick={() => {
|
|
||||||
couponVM.selectCoupon(coupon);
|
|
||||||
deleteDialogOpen = true;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant={coupon.isActive
|
|
||||||
? "destructive"
|
|
||||||
: "default"}
|
|
||||||
size="sm"
|
|
||||||
onclick={() =>
|
|
||||||
couponVM.toggleCouponStatus(
|
|
||||||
coupon.id!,
|
|
||||||
!coupon.isActive,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{coupon.isActive ? "Deactivate" : "Activate"}
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Create Coupon Dialog -->
|
|
||||||
<Dialog.Root bind:open={createDialogOpen}>
|
|
||||||
<Dialog.Content>
|
|
||||||
<Dialog.Header>
|
|
||||||
<Dialog.Title>Create New Coupon</Dialog.Title>
|
|
||||||
<Dialog.Description>
|
|
||||||
Create a new coupon to offer discounts on flight tickets.
|
|
||||||
</Dialog.Description>
|
|
||||||
</Dialog.Header>
|
|
||||||
|
|
||||||
<CouponForm
|
|
||||||
formData={newCouponData}
|
|
||||||
loading={couponVM.formLoading}
|
|
||||||
onSubmit={handleCreateCoupon}
|
|
||||||
onCancel={() => (createDialogOpen = false)}
|
|
||||||
/>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog.Root>
|
|
||||||
|
|
||||||
<!-- Edit Coupon Dialog -->
|
|
||||||
<Dialog.Root bind:open={editDialogOpen}>
|
|
||||||
<Dialog.Content>
|
|
||||||
<Dialog.Header>
|
|
||||||
<Dialog.Title>Edit Coupon</Dialog.Title>
|
|
||||||
<Dialog.Description>Update this coupon's details.</Dialog.Description>
|
|
||||||
</Dialog.Header>
|
|
||||||
|
|
||||||
{#if couponVM.selectedCoupon}
|
|
||||||
<CouponForm
|
|
||||||
formData={couponVM.selectedCoupon}
|
|
||||||
loading={couponVM.formLoading}
|
|
||||||
onSubmit={handleUpdateCoupon}
|
|
||||||
onCancel={() => (editDialogOpen = false)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog.Root>
|
|
||||||
|
|
||||||
<!-- Delete Confirmation Dialog -->
|
|
||||||
<AlertDialog.Root bind:open={deleteDialogOpen}>
|
|
||||||
<AlertDialog.Content>
|
|
||||||
<AlertDialog.Header>
|
|
||||||
<AlertDialog.Title>Delete Coupon</AlertDialog.Title>
|
|
||||||
<AlertDialog.Description>
|
|
||||||
Are you sure you want to delete this coupon? This action cannot be
|
|
||||||
undone.
|
|
||||||
</AlertDialog.Description>
|
|
||||||
</AlertDialog.Header>
|
|
||||||
|
|
||||||
{#if couponVM.selectedCoupon}
|
|
||||||
<div class="mb-4 rounded-md bg-gray-100 p-4">
|
|
||||||
<div class="font-bold">{couponVM.selectedCoupon.code}</div>
|
|
||||||
{#if couponVM.selectedCoupon.description}
|
|
||||||
<div class="text-sm text-gray-600">
|
|
||||||
{couponVM.selectedCoupon.description}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="mt-1">
|
|
||||||
Discount: {formatDiscountValue(
|
|
||||||
couponVM.selectedCoupon.discountType,
|
|
||||||
couponVM.selectedCoupon.discountValue,
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<AlertDialog.Footer>
|
|
||||||
<AlertDialog.Cancel onclick={() => (deleteDialogOpen = false)}>
|
|
||||||
Cancel
|
|
||||||
</AlertDialog.Cancel>
|
|
||||||
<AlertDialog.Action
|
|
||||||
onclick={handleDeleteCoupon}
|
|
||||||
disabled={couponVM.formLoading}
|
|
||||||
>
|
|
||||||
{couponVM.formLoading ? "Deleting..." : "Delete"}
|
|
||||||
</AlertDialog.Action>
|
|
||||||
</AlertDialog.Footer>
|
|
||||||
</AlertDialog.Content>
|
|
||||||
</AlertDialog.Root>
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import UserIcon from "~icons/solar/user-broken";
|
import UserIcon from "~icons/solar/user-broken";
|
||||||
import type { CustomerInfoModel } from "../data";
|
import type { CustomerInfoModel } from "../data";
|
||||||
import InfoCard from "./info-card.svelte";
|
import CInfoCard from "./cinfo-card.svelte";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
customerInfo,
|
customerInfo,
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<InfoCard icon={UserIcon} title="Customer Information">
|
<CInfoCard icon={UserIcon} title="Customer Information">
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<span class="text-xs text-gray-500">Full Name</span>
|
<span class="text-xs text-gray-500">Full Name</span>
|
||||||
@@ -54,4 +54,4 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</InfoCard>
|
</CInfoCard>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
import type { FullOrderModel } from "$lib/domains/order/data/entities";
|
import type { FullOrderModel } from "$lib/domains/order/data/entities";
|
||||||
import ProductIcon from "~icons/solar/box-broken";
|
import ProductIcon from "~icons/solar/box-broken";
|
||||||
import CreditCardIcon from "~icons/solar/card-broken";
|
import CreditCardIcon from "~icons/solar/card-broken";
|
||||||
import EmailIcon from "~icons/solar/letter-broken";
|
|
||||||
|
|
||||||
let { order }: { order: FullOrderModel } = $props();
|
let { order }: { order: FullOrderModel } = $props();
|
||||||
|
|
||||||
@@ -17,17 +16,6 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
{#if order.emailAccountId}
|
|
||||||
<!-- Email Account Info -->
|
|
||||||
<div class={cardStyle}>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Icon icon={EmailIcon} cls="w-5 h-5" />
|
|
||||||
<Title size="h5" color="black">Account Information</Title>
|
|
||||||
</div>
|
|
||||||
<p class="text-gray-800">Email Account ID: #{order.emailAccountId}</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Product Info -->
|
<!-- Product Info -->
|
||||||
<div class={cardStyle}>
|
<div class={cardStyle}>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
|
|||||||
@@ -88,28 +88,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Additional Info -->
|
|
||||||
{#if order.emailAccountId}
|
|
||||||
<div class={cardStyle}>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Icon icon={InfoIcon} cls="w-5 h-5" />
|
|
||||||
<Title size="h5" color="black">Additional Information</Title>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-2 text-sm">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-gray-600">Email Account ID</span>
|
|
||||||
<span class="font-medium">#{order.emailAccountId}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if order.paymentInfoId}
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-gray-600">Payment Info ID</span>
|
|
||||||
<span class="font-medium">#{order.paymentInfoId}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -81,14 +81,6 @@
|
|||||||
return snakeToSpacedPascal(r.status.toLowerCase());
|
return snakeToSpacedPascal(r.status.toLowerCase());
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
header: "Order Type",
|
|
||||||
accessorKey: "ordertype",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const r = row.original as FullOrderModel;
|
|
||||||
return r.emailAccountId ? "Agent" : "Customer";
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
header: "Action",
|
header: "Action",
|
||||||
id: "actions",
|
id: "actions",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { authRouter } from "$lib/domains/auth/domain/router";
|
import { authRouter } from "$lib/domains/auth/domain/router";
|
||||||
import { ckflowRouter } from "$lib/domains/ckflow/router";
|
import { ckflowRouter } from "$lib/domains/ckflow/router";
|
||||||
import { couponRouter } from "$lib/domains/coupon/router";
|
|
||||||
import { customerInfoRouter } from "$lib/domains/customerinfo/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";
|
||||||
@@ -12,7 +11,6 @@ export const router = createTRPCRouter({
|
|||||||
user: userRouter,
|
user: userRouter,
|
||||||
order: orderRouter,
|
order: orderRouter,
|
||||||
ckflow: ckflowRouter,
|
ckflow: ckflowRouter,
|
||||||
coupon: couponRouter,
|
|
||||||
product: productRouter,
|
product: productRouter,
|
||||||
customerInfo: customerInfoRouter,
|
customerInfo: customerInfoRouter,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { pageTitle } from "$lib/hooks/page-title.svelte";
|
|
||||||
import CouponList from "$lib/domains/coupon/view/coupon-list.svelte";
|
|
||||||
import {
|
|
||||||
Tabs,
|
|
||||||
TabsContent,
|
|
||||||
TabsList,
|
|
||||||
TabsTrigger,
|
|
||||||
} from "$lib/components/ui/tabs/index";
|
|
||||||
|
|
||||||
pageTitle.set("Settings");
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="container mx-auto py-8">
|
|
||||||
<!-- <Tabs value="coupons" class="w-full">
|
|
||||||
<TabsList class="mb-6">
|
|
||||||
<TabsTrigger value="coupons">Coupon Management</TabsTrigger>
|
|
||||||
<!-- Add more tabs here as needed -->
|
|
||||||
<!-- </TabsList> -->
|
|
||||||
|
|
||||||
<!-- <TabsContent value="coupons"> -->
|
|
||||||
<CouponList />
|
|
||||||
<!-- </TabsContent> -->
|
|
||||||
|
|
||||||
<!-- Add more tab content here as needed -->
|
|
||||||
<!-- </Tabs> -->
|
|
||||||
</div>
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { env } from "$env/dynamic/public";
|
import { env } from "$env/dynamic/public";
|
||||||
import IconLinkedInLogo from "~icons/basil/linkedin-outline";
|
import IconLinkedInLogo from "~icons/basil/linkedin-outline";
|
||||||
|
import PlaneIcon from "~icons/hugeicons/airplane-02";
|
||||||
import IconInstagramLogo from "~icons/mdi/instagram";
|
import IconInstagramLogo from "~icons/mdi/instagram";
|
||||||
import DocumentTextIcon from "~icons/solar/document-linear";
|
import DocumentTextIcon from "~icons/solar/document-linear";
|
||||||
import HomeIcon from "~icons/solar/home-angle-2-linear";
|
import HomeIcon from "~icons/solar/home-angle-2-linear";
|
||||||
import PlaneIcon from "~icons/hugeicons/airplane-02";
|
|
||||||
|
|
||||||
export const TRANSITION_COLORS = "transition-colors duration-150 ease-in-out";
|
export const TRANSITION_COLORS = "transition-colors duration-150 ease-in-out";
|
||||||
export const TRANSITION_ALL = "transition-all duration-150 ease-in-out";
|
export const TRANSITION_ALL = "transition-all duration-150 ease-in-out";
|
||||||
|
|||||||
@@ -1,29 +1,27 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { buttonVariants } from "$lib/components/ui/button/button.svelte";
|
|
||||||
import { cn } from "$lib/utils";
|
|
||||||
import { CheckoutStep } from "../../data/entities";
|
|
||||||
import { ticketCheckoutVM } from "./flight-checkout.vm.svelte";
|
|
||||||
import { Sheet, SheetContent, SheetTrigger } from "$lib/components/ui/sheet";
|
|
||||||
import Icon from "$lib/components/atoms/icon.svelte";
|
import Icon from "$lib/components/atoms/icon.svelte";
|
||||||
|
import { buttonVariants } from "$lib/components/ui/button/button.svelte";
|
||||||
|
import { Sheet, SheetContent, SheetTrigger } from "$lib/components/ui/sheet";
|
||||||
|
import { CheckoutStep } from "$lib/domains/order/data/entities";
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
import ChevronDownIcon from "~icons/lucide/chevron-down";
|
import ChevronDownIcon from "~icons/lucide/chevron-down";
|
||||||
import CloseIcon from "~icons/lucide/x";
|
import CloseIcon from "~icons/lucide/x";
|
||||||
|
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" },
|
||||||
];
|
];
|
||||||
|
|
||||||
let activeStepIndex = $derived(
|
let activeStepIndex = $derived(
|
||||||
checkoutSteps.findIndex(
|
checkoutSteps.findIndex((step) => step.id === checkoutVM.checkoutStep),
|
||||||
(step) => step.id === ticketCheckoutVM.checkoutStep,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleStepClick(clickedIndex: number, stepId: CheckoutStep) {
|
function handleStepClick(clickedIndex: number, stepId: CheckoutStep) {
|
||||||
if (clickedIndex <= activeStepIndex) {
|
if (clickedIndex <= activeStepIndex) {
|
||||||
ticketCheckoutVM.checkoutStep = stepId;
|
checkoutVM.checkoutStep = stepId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
|
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
|
||||||
import { newOrderModel } from "$lib/domains/order/data/entities";
|
import { CheckoutStep, newOrderModel } from "$lib/domains/order/data/entities";
|
||||||
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
|
|
||||||
import {
|
import {
|
||||||
paymentInfoPayloadModel,
|
paymentInfoPayloadModel,
|
||||||
PaymentMethod,
|
PaymentMethod,
|
||||||
@@ -8,12 +7,13 @@ import {
|
|||||||
import { trpcApiStore } from "$lib/stores/api";
|
import { trpcApiStore } from "$lib/stores/api";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
import { CheckoutStep } from "../../data/entities/index";
|
import { currencyStore } from "../currency/view/currency.vm.svelte";
|
||||||
import { flightTicketStore } from "../../data/store";
|
import { customerInfoVM } from "../customerinfo/view/customerinfo.vm.svelte";
|
||||||
|
import { productStore } from "../product/store";
|
||||||
import { paymentInfoVM } from "./payment-info-section/payment.info.vm.svelte";
|
import { paymentInfoVM } from "./payment-info-section/payment.info.vm.svelte";
|
||||||
import { calculateTicketPrices } from "./total.calculator";
|
import { calculateFinalPrices } from "./utils";
|
||||||
|
|
||||||
class TicketCheckoutViewModel {
|
class CheckoutViewModel {
|
||||||
checkoutStep = $state(CheckoutStep.Initial);
|
checkoutStep = $state(CheckoutStep.Initial);
|
||||||
loading = $state(true);
|
loading = $state(true);
|
||||||
continutingToNextStep = $state(false);
|
continutingToNextStep = $state(false);
|
||||||
@@ -45,14 +45,8 @@ class TicketCheckoutViewModel {
|
|||||||
if (!api) {
|
if (!api) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const ticket = get(flightTicketStore);
|
|
||||||
if (!ticket || !ticket.refOIds) {
|
// TODO: no need to ping now – REMOVE THIS PINGING LOGIC
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const out = await api.ticket.ping.query({
|
|
||||||
tid: ticket.id,
|
|
||||||
refOIds: ticket.refOIds,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkout() {
|
async checkout() {
|
||||||
@@ -66,41 +60,29 @@ class TicketCheckoutViewModel {
|
|||||||
this.checkoutSubmitted = false;
|
this.checkoutSubmitted = false;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const product = get(productStore);
|
||||||
|
|
||||||
const ticket = get(flightTicketStore);
|
if (!product || !customerInfoVM.customerInfo) {
|
||||||
|
this.checkoutSubmitted = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const prices = calculateTicketPrices(
|
const priceDetails = calculateFinalPrices(
|
||||||
ticket,
|
product,
|
||||||
passengerInfoVM.passengerInfos,
|
customerInfoVM.customerInfo,
|
||||||
);
|
);
|
||||||
|
|
||||||
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({
|
const parsed = newOrderModel.safeParse({
|
||||||
basePrice: validatedPrices.subtotal,
|
...priceDetails,
|
||||||
discountAmount: validatedPrices.discountAmount,
|
productId: product.id,
|
||||||
displayPrice: validatedPrices.finalTotal,
|
currency: get(currencyStore).code,
|
||||||
orderPrice: validatedPrices.finalTotal, // Same as displayPrice
|
|
||||||
fullfilledPrice: validatedPrices.finalTotal, // Same as displayPrice
|
|
||||||
pricePerPassenger: validatedPrices.pricePerPassenger,
|
|
||||||
flightTicketInfoId: -1,
|
|
||||||
paymentInfoId: -1,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (parsed.error) {
|
if (parsed.error) {
|
||||||
console.log(parsed.error);
|
console.error("Order model validation error:", parsed.error.errors);
|
||||||
const err = parsed.error.errors[0];
|
const err = parsed.error.errors[0];
|
||||||
toast.error("Failed to perform checkout", {
|
toast.error("Failed to perform checkout", {
|
||||||
description: err.message,
|
description: `${err.path.join(".")}: ${err.message}`,
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -108,13 +90,16 @@ class TicketCheckoutViewModel {
|
|||||||
const pInfoParsed = paymentInfoPayloadModel.safeParse({
|
const pInfoParsed = paymentInfoPayloadModel.safeParse({
|
||||||
method: PaymentMethod.Card,
|
method: PaymentMethod.Card,
|
||||||
cardDetails: paymentInfoVM.cardDetails,
|
cardDetails: paymentInfoVM.cardDetails,
|
||||||
flightTicketInfoId: ticket.id,
|
productId: get(productStore)?.id,
|
||||||
});
|
});
|
||||||
if (pInfoParsed.error) {
|
if (pInfoParsed.error) {
|
||||||
console.log(parsed.error);
|
console.error(
|
||||||
|
"Payment info validation error:",
|
||||||
|
pInfoParsed.error.errors,
|
||||||
|
);
|
||||||
const err = pInfoParsed.error.errors[0];
|
const err = pInfoParsed.error.errors[0];
|
||||||
toast.error("Failed to perform checkout", {
|
toast.error("Failed to validate payment information", {
|
||||||
description: err.message,
|
description: `${err.path.join(".")}: ${err.message}`,
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -123,11 +108,10 @@ class TicketCheckoutViewModel {
|
|||||||
console.log("Creating order");
|
console.log("Creating order");
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
const out = await api.order.createOrder.mutate({
|
const out = await api.order.createOrder.mutate({
|
||||||
flightTicketId: ticket.id,
|
productId: get(productStore)?.id,
|
||||||
orderModel: parsed.data,
|
orderModel: parsed.data,
|
||||||
passengerInfos: passengerInfoVM.passengerInfos,
|
customerInfo: customerInfoVM.customerInfo!,
|
||||||
paymentInfo: pInfoParsed.data,
|
paymentInfo: pInfoParsed.data,
|
||||||
refOIds: ticket.refOIds,
|
|
||||||
flowId: ckFlowVM.flowId,
|
flowId: ckFlowVM.flowId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -163,4 +147,4 @@ class TicketCheckoutViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ticketCheckoutVM = new TicketCheckoutViewModel();
|
export const checkoutVM = new CheckoutViewModel();
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<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 from "$lib/components/ui/button/button.svelte";
|
||||||
|
import { checkoutVM } from "$lib/domains/checkout/checkout.vm.svelte";
|
||||||
|
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
|
||||||
|
import { CheckoutStep } from "$lib/domains/order/data/entities";
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { toast } from "svelte-sonner";
|
||||||
|
import RightArrowIcon from "~icons/solar/arrow-right-broken";
|
||||||
|
import CustomerPiiForm from "../customerinfo/view/customer-pii-form.svelte";
|
||||||
|
import { customerInfoVM } from "../customerinfo/view/customerinfo.vm.svelte";
|
||||||
|
|
||||||
|
const cardStyle =
|
||||||
|
"flex w-full flex-col gap-4 rounded-lg bg-white p-4 shadow-lg md:p-8";
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const personalInfo = customerInfoVM.customerInfo;
|
||||||
|
|
||||||
|
if (!ckFlowVM.flowId || !ckFlowVM.setupDone || !personalInfo) return;
|
||||||
|
|
||||||
|
// to trigger the effect
|
||||||
|
const {
|
||||||
|
phoneNumber,
|
||||||
|
email,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
address,
|
||||||
|
address2,
|
||||||
|
zipCode,
|
||||||
|
city,
|
||||||
|
state,
|
||||||
|
country,
|
||||||
|
} = personalInfo;
|
||||||
|
if (
|
||||||
|
firstName ||
|
||||||
|
lastName ||
|
||||||
|
email ||
|
||||||
|
phoneNumber ||
|
||||||
|
country ||
|
||||||
|
city ||
|
||||||
|
state ||
|
||||||
|
zipCode ||
|
||||||
|
address ||
|
||||||
|
address2
|
||||||
|
) {
|
||||||
|
console.log("pi ping");
|
||||||
|
ckFlowVM.debouncePersonalInfoSync(personalInfo);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function proceedToNextStep() {
|
||||||
|
customerInfoVM.validateCustomerInfo();
|
||||||
|
console.log(customerInfoVM.errors);
|
||||||
|
if (!customerInfoVM.isValid()) {
|
||||||
|
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(() => {
|
||||||
|
customerInfoVM.initializeCustomerInfo();
|
||||||
|
}, 200);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if customerInfoVM.customerInfo}
|
||||||
|
<div class={cn(cardStyle, "border-2 border-gray-200")}>
|
||||||
|
<Title size="h5">Personal Info</Title>
|
||||||
|
<CustomerPiiForm bind:info={customerInfoVM.customerInfo} />
|
||||||
|
</div>
|
||||||
|
{/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>
|
||||||
@@ -4,10 +4,10 @@
|
|||||||
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 { CustomerInfo } 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: CustomerInfo } = $props();
|
let { info = $bindable() }: { info: CustomerInfoModel } = $props();
|
||||||
|
|
||||||
function onSubmit(e: SubmitEvent) {
|
function onSubmit(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
|
type CustomerInfoModel,
|
||||||
customerInfoModel,
|
customerInfoModel,
|
||||||
type CustomerInfo,
|
Gender,
|
||||||
} from "$lib/domains/ticket/data/entities/create.entities";
|
} from "$lib/domains/customerinfo/data";
|
||||||
import { Gender } from "$lib/domains/ticket/data/entities/index";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export class BillingDetailsViewModel {
|
export class BillingDetailsViewModel {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
billingDetails = $state<CustomerInfo>(undefined);
|
billingDetails = $state<CustomerInfoModel>(undefined);
|
||||||
|
|
||||||
piiErrors = $state<Partial<Record<keyof CustomerInfo, string>>>({});
|
piiErrors = $state<Partial<Record<keyof CustomerInfoModel, string>>>({});
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.reset();
|
this.reset();
|
||||||
@@ -34,15 +34,15 @@ export class BillingDetailsViewModel {
|
|||||||
zipCode: "",
|
zipCode: "",
|
||||||
address: "",
|
address: "",
|
||||||
address2: "",
|
address2: "",
|
||||||
} as CustomerInfo;
|
} as CustomerInfoModel;
|
||||||
this.piiErrors = {};
|
this.piiErrors = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
setPII(info: CustomerInfo) {
|
setPII(info: CustomerInfoModel) {
|
||||||
this.billingDetails = info;
|
this.billingDetails = info;
|
||||||
}
|
}
|
||||||
|
|
||||||
validatePII(info: CustomerInfo) {
|
validatePII(info: CustomerInfoModel) {
|
||||||
try {
|
try {
|
||||||
const result = customerInfoModel.parse(info);
|
const result = customerInfoModel.parse(info);
|
||||||
this.piiErrors = {};
|
this.piiErrors = {};
|
||||||
@@ -51,11 +51,11 @@ export class BillingDetailsViewModel {
|
|||||||
if (error instanceof z.ZodError) {
|
if (error instanceof z.ZodError) {
|
||||||
this.piiErrors = error.errors.reduce(
|
this.piiErrors = error.errors.reduce(
|
||||||
(acc, curr) => {
|
(acc, curr) => {
|
||||||
const path = curr.path[0] as keyof CustomerInfo;
|
const path = curr.path[0] as keyof CustomerInfoModel;
|
||||||
acc[path] = curr.message;
|
acc[path] = curr.message;
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<keyof CustomerInfo, string>,
|
{} as Record<keyof CustomerInfoModel, string>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<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 from "$lib/components/ui/button/button.svelte";
|
||||||
|
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
|
||||||
|
import { customerInfoVM } from "$lib/domains/customerinfo/view/customerinfo.vm.svelte";
|
||||||
|
import { CheckoutStep } from "$lib/domains/order/data/entities";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { toast } from "svelte-sonner";
|
||||||
|
import RightArrowIcon from "~icons/solar/arrow-right-broken";
|
||||||
|
import { checkoutVM } from "../checkout.vm.svelte";
|
||||||
|
import BillingDetailsForm from "./billing-details-form.svelte";
|
||||||
|
import { billingDetailsVM } from "./billing.details.vm.svelte";
|
||||||
|
import OrderSummary from "./order-summary.svelte";
|
||||||
|
import PaymentForm from "./payment-form.svelte";
|
||||||
|
import { paymentInfoVM } from "./payment.info.vm.svelte";
|
||||||
|
|
||||||
|
const cardStyle =
|
||||||
|
"flex w-full flex-col gap-4 rounded-lg bg-white p-4 shadow-lg md:p-8";
|
||||||
|
|
||||||
|
async function goBack() {
|
||||||
|
if ((await ckFlowVM.onBackToPIIBtnClick()) !== true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
checkoutVM.checkoutStep = CheckoutStep.Initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
const validatedData = await paymentInfoVM.validateAndSubmit();
|
||||||
|
if (!validatedData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const validBillingInfo = billingDetailsVM.validatePII(
|
||||||
|
billingDetailsVM.billingDetails,
|
||||||
|
);
|
||||||
|
if (!validBillingInfo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
checkoutVM.continutingToNextStep = true;
|
||||||
|
const out = await ckFlowVM.executePaymentStep();
|
||||||
|
if (out !== true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
checkoutVM.continutingToNextStep = false;
|
||||||
|
checkoutVM.checkoutStep = CheckoutStep.Verification;
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
$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 initials");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!customerInfoVM.customerInfo) return;
|
||||||
|
billingDetailsVM.setPII(customerInfoVM.customerInfo);
|
||||||
|
billingDetailsVM.validatePII(billingDetailsVM.billingDetails);
|
||||||
|
toast("Used billing details from initial info");
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<div class={cardStyle}>
|
||||||
|
<Title size="h4">Order Summary</Title>
|
||||||
|
<OrderSummary />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={cardStyle}>
|
||||||
|
<Title size="h4">Billing Details</Title>
|
||||||
|
<BillingDetailsForm info={billingDetailsVM.billingDetails} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={cardStyle}>
|
||||||
|
<Title size="h4">Payment Details</Title>
|
||||||
|
<PaymentForm />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center justify-between gap-4 md:flex-row">
|
||||||
|
<Button variant="secondary" onclick={goBack} class="w-full md:w-max">
|
||||||
|
<Icon icon={RightArrowIcon} cls="w-auto h-6 rotate-180" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onclick={handleSubmit}
|
||||||
|
class="w-full md:w-max"
|
||||||
|
disabled={checkoutVM.continutingToNextStep}
|
||||||
|
>
|
||||||
|
<ButtonLoadableText
|
||||||
|
text="Confirm & Pay"
|
||||||
|
loadingText="Processing info..."
|
||||||
|
loading={checkoutVM.continutingToNextStep}
|
||||||
|
/>
|
||||||
|
<Icon icon={RightArrowIcon} cls="w-auto h-6" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from "$lib/components/atoms/icon.svelte";
|
||||||
|
import Title from "$lib/components/atoms/title.svelte";
|
||||||
|
import { customerInfoVM } from "$lib/domains/customerinfo/view/customerinfo.vm.svelte";
|
||||||
|
import { productStore } from "$lib/domains/product/store";
|
||||||
|
import MailIcon from "~icons/lucide/mail";
|
||||||
|
import MapPinIcon from "~icons/lucide/map-pin";
|
||||||
|
import PackageIcon from "~icons/lucide/package";
|
||||||
|
import PhoneIcon from "~icons/lucide/phone";
|
||||||
|
import UserIcon from "~icons/lucide/user";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<!-- Product Summary -->
|
||||||
|
{#if $productStore}
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Icon icon={PackageIcon} cls="h-5 w-5 text-gray-600" />
|
||||||
|
<Title size="p" weight="semibold">Product</Title>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border bg-gray-50 p-4">
|
||||||
|
<p class="font-medium">{$productStore.title}</p>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">
|
||||||
|
{$productStore.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Customer Information Summary -->
|
||||||
|
{#if customerInfoVM.customerInfo}
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Icon icon={UserIcon} cls="h-5 w-5 text-gray-600" />
|
||||||
|
<Title size="p" weight="semibold">Customer Information</Title>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Personal Info -->
|
||||||
|
<div class="rounded-lg border bg-gray-50 p-4">
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<!-- Name -->
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-gray-500">Full Name</span>
|
||||||
|
<p class="font-medium">
|
||||||
|
{customerInfoVM.customerInfo.firstName}
|
||||||
|
{#if customerInfoVM.customerInfo.middleName}
|
||||||
|
{customerInfoVM.customerInfo.middleName}
|
||||||
|
{/if}
|
||||||
|
{customerInfoVM.customerInfo.lastName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Icon icon={MailIcon} cls="h-4 w-4 text-gray-500" />
|
||||||
|
<div class="flex-1">
|
||||||
|
<span class="text-xs text-gray-500">Email</span>
|
||||||
|
<p class="text-sm">
|
||||||
|
{customerInfoVM.customerInfo.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Phone -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Icon icon={PhoneIcon} cls="h-4 w-4 text-gray-500" />
|
||||||
|
<div class="flex-1">
|
||||||
|
<span class="text-xs text-gray-500">Phone</span>
|
||||||
|
<p class="text-sm">
|
||||||
|
{customerInfoVM.customerInfo.phoneCountryCode}
|
||||||
|
{customerInfoVM.customerInfo.phoneNumber}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Address -->
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<Icon
|
||||||
|
icon={MapPinIcon}
|
||||||
|
cls="h-4 w-4 text-gray-500 mt-1"
|
||||||
|
/>
|
||||||
|
<div class="flex-1">
|
||||||
|
<span class="text-xs text-gray-500">Address</span>
|
||||||
|
<p class="text-sm">
|
||||||
|
{customerInfoVM.customerInfo.address}
|
||||||
|
{#if customerInfoVM.customerInfo.address2}
|
||||||
|
, {customerInfoVM.customerInfo.address2}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
{customerInfoVM.customerInfo.city},
|
||||||
|
{customerInfoVM.customerInfo.state}
|
||||||
|
{customerInfoVM.customerInfo.zipCode}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
{customerInfoVM.customerInfo.country}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="rounded-lg border border-dashed p-6 text-center">
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Customer information not yet provided
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Input from "$lib/components/ui/input/input.svelte";
|
|
||||||
import LabelWrapper from "$lib/components/atoms/label-wrapper.svelte";
|
import LabelWrapper from "$lib/components/atoms/label-wrapper.svelte";
|
||||||
|
import Input from "$lib/components/ui/input/input.svelte";
|
||||||
import { paymentInfoVM } from "./payment.info.vm.svelte";
|
import { paymentInfoVM } from "./payment.info.vm.svelte";
|
||||||
import { chunk } from "$lib/core/array.utils";
|
|
||||||
|
|
||||||
function formatCardNumberForDisplay(value: string) {
|
function formatCardNumberForDisplay(value: string) {
|
||||||
// return in format "XXXX XXXX XXXX XXXX" from "XXXXXXXXXXXXXXXX"
|
// return in format "XXXX XXXX XXXX XXXX" from "XXXXXXXXXXXXXXXX"
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Title from "$lib/components/atoms/title.svelte";
|
||||||
|
import {
|
||||||
|
convertAndFormatCurrency,
|
||||||
|
currencyStore,
|
||||||
|
} from "$lib/domains/currency/view/currency.vm.svelte";
|
||||||
|
import { customerInfoVM } from "../customerinfo/view/customerinfo.vm.svelte";
|
||||||
|
import { productStore } from "../product/store";
|
||||||
|
import { calculateFinalPrices } from "./utils";
|
||||||
|
|
||||||
|
let priceDetails = $state(
|
||||||
|
calculateFinalPrices($productStore, customerInfoVM.customerInfo),
|
||||||
|
);
|
||||||
|
let calculating = $state(false);
|
||||||
|
|
||||||
|
// Reactively update price details when product or customer info changes
|
||||||
|
$effect(() => {
|
||||||
|
calculating = true;
|
||||||
|
priceDetails = calculateFinalPrices(
|
||||||
|
$productStore,
|
||||||
|
customerInfoVM.customerInfo,
|
||||||
|
);
|
||||||
|
calculating = 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 !calculating}
|
||||||
|
<!-- Product Information -->
|
||||||
|
{#if $productStore}
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<Title size="p" weight="medium">{$productStore.title}</Title>
|
||||||
|
<p class="text-sm text-gray-600">{$productStore.description}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Price Breakdown -->
|
||||||
|
<div class="mt-2 flex flex-col gap-2 border-t pt-4">
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<span>Base Price</span>
|
||||||
|
<span>{convertAndFormatCurrency(priceDetails.basePrice)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if priceDetails.discountAmount > 0}
|
||||||
|
<div class="flex justify-between text-sm text-green-600">
|
||||||
|
<span>Discount</span>
|
||||||
|
<span
|
||||||
|
>-{convertAndFormatCurrency(
|
||||||
|
priceDetails.discountAmount,
|
||||||
|
)}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<span>Display Price</span>
|
||||||
|
<span
|
||||||
|
>{convertAndFormatCurrency(
|
||||||
|
priceDetails.displayPrice,
|
||||||
|
)}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Final Total -->
|
||||||
|
<div class="mt-4 flex flex-col gap-2 border-t pt-4">
|
||||||
|
<div class="flex justify-between font-medium">
|
||||||
|
<Title size="h5" weight="medium"
|
||||||
|
>Total ({$currencyStore.code})</Title
|
||||||
|
>
|
||||||
|
<span class="text-lg"
|
||||||
|
>{convertAndFormatCurrency(priceDetails.orderPrice)}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="grid place-items-center p-8 text-center">
|
||||||
|
<span class="text-gray-600">Calculating...</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Important Information -->
|
||||||
|
<div class="mt-4 rounded-lg bg-gray-50 p-4 text-xs text-gray-600">
|
||||||
|
<p class="mb-2 font-medium">Important Information:</p>
|
||||||
|
<ul class="list-disc space-y-1 pl-4">
|
||||||
|
<li>Price includes all applicable taxes and fees</li>
|
||||||
|
<li>
|
||||||
|
Cancellation and refund policies may apply as per our terms of
|
||||||
|
service
|
||||||
|
</li>
|
||||||
|
<li>Payment will be processed securely upon order confirmation</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
|
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
|
||||||
import { onMount, onDestroy } from "svelte";
|
import { onDestroy, onMount } from "svelte";
|
||||||
import { ticketCheckoutVM } from "./flight-checkout.vm.svelte";
|
import { checkoutVM } from "./checkout.vm.svelte";
|
||||||
import PaymentVerificationLoader from "./payment-verification-loader.svelte";
|
|
||||||
import OtpVerificationSection from "./otp-verification-section.svelte";
|
import OtpVerificationSection from "./otp-verification-section.svelte";
|
||||||
|
import PaymentVerificationLoader from "./payment-verification-loader.svelte";
|
||||||
|
|
||||||
let refreshIntervalId: NodeJS.Timer;
|
let refreshIntervalId: NodeJS.Timer;
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
if (ckFlowVM.setupDone && !ckFlowVM.flowId) {
|
if (ckFlowVM.setupDone && !ckFlowVM.flowId) {
|
||||||
console.log("Shortcut - Checking out");
|
console.log("Shortcut - Checking out");
|
||||||
await ticketCheckoutVM.checkout();
|
await checkoutVM.checkout();
|
||||||
}
|
}
|
||||||
}, rng);
|
}, rng);
|
||||||
});
|
});
|
||||||
4
apps/frontend/src/lib/domains/checkout/sid.store.ts
Normal file
4
apps/frontend/src/lib/domains/checkout/sid.store.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { nanoid } from "nanoid/non-secure";
|
||||||
|
import { writable } from "svelte/store";
|
||||||
|
|
||||||
|
export const checkoutSessionIdStore = writable(nanoid());
|
||||||
50
apps/frontend/src/lib/domains/checkout/utils.ts
Normal file
50
apps/frontend/src/lib/domains/checkout/utils.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import type { CustomerInfoModel } from "@pkg/logic/domains/customerinfo/data";
|
||||||
|
import type { OrderPriceDetailsModel } from "@pkg/logic/domains/order/data/entities";
|
||||||
|
import type { ProductModel } from "@pkg/logic/domains/product/data";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates final prices for a product checkout using OrderPriceDetailsModel
|
||||||
|
* @param product - The product being purchased
|
||||||
|
* @param customerInfo - Customer information (included for future extensibility)
|
||||||
|
* @returns OrderPriceDetailsModel with all price fields calculated
|
||||||
|
*/
|
||||||
|
export function calculateFinalPrices(
|
||||||
|
product: ProductModel | null,
|
||||||
|
customerInfo?: CustomerInfoModel | null,
|
||||||
|
): OrderPriceDetailsModel {
|
||||||
|
if (!product) {
|
||||||
|
return {
|
||||||
|
currency: "USD",
|
||||||
|
basePrice: 0,
|
||||||
|
discountAmount: 0,
|
||||||
|
displayPrice: 0,
|
||||||
|
orderPrice: 0,
|
||||||
|
fullfilledPrice: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const basePrice = product.price || 0;
|
||||||
|
const discountPrice = product.discountPrice || 0;
|
||||||
|
|
||||||
|
// Calculate discount amount: if discountPrice is set and less than base price
|
||||||
|
const hasDiscount = discountPrice > 0 && discountPrice < basePrice;
|
||||||
|
const discountAmount = hasDiscount ? basePrice - discountPrice : 0;
|
||||||
|
|
||||||
|
// Display price is either the discounted price or the base price
|
||||||
|
const displayPrice = hasDiscount ? discountPrice : basePrice;
|
||||||
|
|
||||||
|
// For single product checkout:
|
||||||
|
// - orderPrice = displayPrice (what customer pays)
|
||||||
|
// - fullfilledPrice = displayPrice (same as order price for immediate fulfillment)
|
||||||
|
const orderPrice = displayPrice;
|
||||||
|
const fullfilledPrice = displayPrice;
|
||||||
|
|
||||||
|
return {
|
||||||
|
currency: "USD",
|
||||||
|
basePrice,
|
||||||
|
discountAmount,
|
||||||
|
displayPrice,
|
||||||
|
orderPrice,
|
||||||
|
fullfilledPrice,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { CustomerInfo } from "$lib/domains/passengerinfo/data/entities";
|
import type { CustomerInfoModel } from "$lib/domains/customerinfo/data";
|
||||||
|
import { CheckoutStep } from "$lib/domains/order/data/entities";
|
||||||
import type { PaymentInfoPayload } from "$lib/domains/paymentinfo/data/entities";
|
import type { PaymentInfoPayload } from "$lib/domains/paymentinfo/data/entities";
|
||||||
import { CheckoutStep } from "$lib/domains/ticket/data/entities";
|
|
||||||
import type { Database } from "@pkg/db";
|
import type { Database } from "@pkg/db";
|
||||||
import { and, eq } from "@pkg/db";
|
import { and, eq } from "@pkg/db";
|
||||||
import { checkoutFlowSession } from "@pkg/db/schema";
|
import { checkoutFlowSession } from "@pkg/db/schema";
|
||||||
@@ -42,7 +42,7 @@ export class CheckoutFlowRepository {
|
|||||||
userAgent: payload.userAgent,
|
userAgent: payload.userAgent,
|
||||||
reserved: false,
|
reserved: false,
|
||||||
sessionOutcome: SessionOutcome.PENDING,
|
sessionOutcome: SessionOutcome.PENDING,
|
||||||
ticketId: payload.ticketId,
|
product: payload.productId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { data: flowId };
|
return { data: flowId };
|
||||||
@@ -202,7 +202,7 @@ export class CheckoutFlowRepository {
|
|||||||
|
|
||||||
async syncPersonalInfo(
|
async syncPersonalInfo(
|
||||||
flowId: string,
|
flowId: string,
|
||||||
personalInfo: CustomerInfo,
|
personalInfo: CustomerInfoModel,
|
||||||
): Promise<Result<boolean>> {
|
): Promise<Result<boolean>> {
|
||||||
try {
|
try {
|
||||||
const existingSession = await this.db
|
const existingSession = await this.db
|
||||||
@@ -435,7 +435,7 @@ export class CheckoutFlowRepository {
|
|||||||
.where(eq(checkoutFlowSession.flowId, flowId))
|
.where(eq(checkoutFlowSession.flowId, flowId))
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
// If shadow passenger info is provided, sync it
|
// If shadow info is provided, sync it
|
||||||
if (payload.personalInfo) {
|
if (payload.personalInfo) {
|
||||||
await this.syncPersonalInfo(flowId, payload.personalInfo);
|
await this.syncPersonalInfo(flowId, payload.personalInfo);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
customerInfoModel,
|
customerInfoModel,
|
||||||
type CustomerInfo,
|
type CustomerInfoModel,
|
||||||
} from "$lib/domains/passengerinfo/data/entities";
|
} from "$lib/domains/customerinfo/data";
|
||||||
|
import { CheckoutStep } from "$lib/domains/order/data/entities";
|
||||||
import {
|
import {
|
||||||
paymentInfoPayloadModel,
|
paymentInfoPayloadModel,
|
||||||
type PaymentInfoPayload,
|
type PaymentInfoPayload,
|
||||||
} from "$lib/domains/paymentinfo/data/entities";
|
} from "$lib/domains/paymentinfo/data/entities";
|
||||||
import { CheckoutStep } from "$lib/domains/ticket/data/entities";
|
|
||||||
import { createTRPCRouter, publicProcedure } from "$lib/trpc/t";
|
import { createTRPCRouter, publicProcedure } from "$lib/trpc/t";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -41,7 +41,7 @@ export const ckflowRouter = createTRPCRouter({
|
|||||||
return getCKUseCases().createFlow({
|
return getCKUseCases().createFlow({
|
||||||
domain: input.domain,
|
domain: input.domain,
|
||||||
refOIds: input.refOIds,
|
refOIds: input.refOIds,
|
||||||
ticketId: input.ticketId,
|
productId: input.productId,
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
initialUrl: "",
|
initialUrl: "",
|
||||||
@@ -72,7 +72,7 @@ export const ckflowRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
return getCKUseCases().syncPersonalInfo(
|
return getCKUseCases().syncPersonalInfo(
|
||||||
input.flowId,
|
input.flowId,
|
||||||
input.personalInfo as CustomerInfo,
|
input.personalInfo as CustomerInfoModel,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { CustomerInfo } from "$lib/domains/passengerinfo/data/entities";
|
import type { CustomerInfoModel } from "$lib/domains/customerinfo/data";
|
||||||
import type { PaymentInfoPayload } from "$lib/domains/paymentinfo/data/entities";
|
import type { PaymentInfoPayload } from "$lib/domains/paymentinfo/data/entities";
|
||||||
import { db } from "@pkg/db";
|
import { db } from "@pkg/db";
|
||||||
import { isTimestampMoreThan1MinAgo } from "@pkg/logic/core/date.utils";
|
import { isTimestampMoreThan1MinAgo } from "@pkg/logic/core/date.utils";
|
||||||
@@ -54,7 +54,7 @@ export class CheckoutFlowUseCases {
|
|||||||
return this.repo.executePaymentStep(flowId, payload);
|
return this.repo.executePaymentStep(flowId, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
async syncPersonalInfo(flowId: string, personalInfo: CustomerInfo) {
|
async syncPersonalInfo(flowId: string, personalInfo: CustomerInfoModel) {
|
||||||
return this.repo.syncPersonalInfo(flowId, personalInfo);
|
return this.repo.syncPersonalInfo(flowId, personalInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
|
import { checkoutVM } from "$lib/domains/checkout/checkout.vm.svelte";
|
||||||
|
import { billingDetailsVM } from "$lib/domains/checkout/payment-info-section/billing.details.vm.svelte";
|
||||||
|
import { paymentInfoVM } from "$lib/domains/checkout/payment-info-section/payment.info.vm.svelte";
|
||||||
import {
|
import {
|
||||||
CKActionType,
|
CKActionType,
|
||||||
SessionOutcome,
|
SessionOutcome,
|
||||||
@@ -6,21 +9,16 @@ import {
|
|||||||
type PendingAction,
|
type PendingAction,
|
||||||
type PendingActions,
|
type PendingActions,
|
||||||
} from "$lib/domains/ckflow/data/entities";
|
} from "$lib/domains/ckflow/data/entities";
|
||||||
import {
|
import type { CustomerInfoModel } from "$lib/domains/customerinfo/data";
|
||||||
customerInfoModel,
|
import { customerInfoModel } from "$lib/domains/customerinfo/data";
|
||||||
type CustomerInfo,
|
import { customerInfoVM } from "$lib/domains/customerinfo/view/customerinfo.vm.svelte";
|
||||||
} from "$lib/domains/passengerinfo/data/entities";
|
|
||||||
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
|
|
||||||
import type { PaymentInfoPayload } from "$lib/domains/paymentinfo/data/entities";
|
|
||||||
import { PaymentMethod } from "$lib/domains/paymentinfo/data/entities";
|
|
||||||
import {
|
import {
|
||||||
CheckoutStep,
|
CheckoutStep,
|
||||||
type FlightPriceDetails,
|
type OrderPriceDetailsModel,
|
||||||
} from "$lib/domains/ticket/data/entities";
|
} from "$lib/domains/order/data/entities";
|
||||||
import { flightTicketStore } from "$lib/domains/ticket/data/store";
|
import type { PaymentInfoPayload } from "$lib/domains/paymentinfo/data/entities";
|
||||||
import { ticketCheckoutVM } from "$lib/domains/ticket/view/checkout/flight-checkout.vm.svelte";
|
import { PaymentMethod } from "$lib/domains/paymentinfo/data/entities";
|
||||||
import { billingDetailsVM } from "$lib/domains/ticket/view/checkout/payment-info-section/billing.details.vm.svelte";
|
import { productStore } from "$lib/domains/product/store";
|
||||||
import { paymentInfoVM } from "$lib/domains/ticket/view/checkout/payment-info-section/payment.info.vm.svelte";
|
|
||||||
import { trpcApiStore } from "$lib/stores/api";
|
import { trpcApiStore } from "$lib/stores/api";
|
||||||
import { ClientLogger } from "@pkg/logger/client";
|
import { ClientLogger } from "@pkg/logger/client";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
@@ -61,7 +59,7 @@ class ActionRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
private async completeOrder(data: any) {
|
private async completeOrder(data: any) {
|
||||||
const ok = await ticketCheckoutVM.checkout();
|
const ok = await checkoutVM.checkout();
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|
||||||
const cleanupSuccess = await ckFlowVM.cleanupFlowInfo(
|
const cleanupSuccess = await ckFlowVM.cleanupFlowInfo(
|
||||||
@@ -117,7 +115,7 @@ class ActionRunner {
|
|||||||
|
|
||||||
await ckFlowVM.refreshFlowInfo(false);
|
await ckFlowVM.refreshFlowInfo(false);
|
||||||
|
|
||||||
ticketCheckoutVM.checkoutStep = CheckoutStep.Verification;
|
checkoutVM.checkoutStep = CheckoutStep.Verification;
|
||||||
toast.info("Verification required", {
|
toast.info("Verification required", {
|
||||||
description: "Please enter the verification code sent to your device",
|
description: "Please enter the verification code sent to your device",
|
||||||
});
|
});
|
||||||
@@ -131,7 +129,7 @@ class ActionRunner {
|
|||||||
toast.error("Some information provided is not valid", {
|
toast.error("Some information provided is not valid", {
|
||||||
description: "Please double check your info & try again",
|
description: "Please double check your info & try again",
|
||||||
});
|
});
|
||||||
ticketCheckoutVM.checkoutStep = CheckoutStep.Initial;
|
checkoutVM.checkoutStep = CheckoutStep.Initial;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async backToPayment(action: PendingAction) {
|
private async backToPayment(action: PendingAction) {
|
||||||
@@ -155,13 +153,13 @@ class ActionRunner {
|
|||||||
duration: 6000,
|
duration: 6000,
|
||||||
});
|
});
|
||||||
|
|
||||||
ticketCheckoutVM.checkoutStep = CheckoutStep.Payment;
|
checkoutVM.checkoutStep = CheckoutStep.Payment;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async terminateSession() {
|
private async terminateSession() {
|
||||||
await ckFlowVM.cleanupFlowInfo();
|
await ckFlowVM.cleanupFlowInfo();
|
||||||
ckFlowVM.reset();
|
ckFlowVM.reset();
|
||||||
ticketCheckoutVM.reset();
|
checkoutVM.reset();
|
||||||
const tid = page.params.tid as any as string;
|
const tid = page.params.tid as any as string;
|
||||||
const sid = page.params.sid as any as string;
|
const sid = page.params.sid as any as string;
|
||||||
window.location.replace(`/checkout/terminated?sid=${sid}&tid=${tid}`);
|
window.location.replace(`/checkout/terminated?sid=${sid}&tid=${tid}`);
|
||||||
@@ -185,7 +183,7 @@ export class CKFlowViewModel {
|
|||||||
private paymentInfoDebounceTimer: NodeJS.Timeout | null = null;
|
private paymentInfoDebounceTimer: NodeJS.Timeout | null = null;
|
||||||
syncInterval = 300; // 300ms debounce for syncing
|
syncInterval = 300; // 300ms debounce for syncing
|
||||||
|
|
||||||
updatedPrices = $state<FlightPriceDetails | undefined>(undefined);
|
updatedPrices = $state<OrderPriceDetailsModel | undefined>(undefined);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.actionRunner = new ActionRunner();
|
this.actionRunner = new ActionRunner();
|
||||||
@@ -216,17 +214,10 @@ export class CKFlowViewModel {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ticket = get(flightTicketStore);
|
|
||||||
const refOIds = ticket.refOIds;
|
|
||||||
if (!refOIds) {
|
|
||||||
this.setupDone = true;
|
|
||||||
return; // Since we don't have any attached order(s), we don't need to worry about this dude
|
|
||||||
}
|
|
||||||
|
|
||||||
const info = await api.ckflow.initiateCheckout.mutate({
|
const info = await api.ckflow.initiateCheckout.mutate({
|
||||||
domain: window.location.hostname,
|
domain: window.location.hostname,
|
||||||
refOIds,
|
refOIds: [],
|
||||||
ticketId: ticket.id,
|
productId: get(productStore)?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (info.error) {
|
if (info.error) {
|
||||||
@@ -236,7 +227,7 @@ export class CKFlowViewModel {
|
|||||||
|
|
||||||
if (!info.data) {
|
if (!info.data) {
|
||||||
toast.error("Error while creating checkout flow", {
|
toast.error("Error while creating checkout flow", {
|
||||||
description: "Try refreshing page or search for ticket again",
|
description: "Try refreshing page or contact us",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -248,7 +239,7 @@ export class CKFlowViewModel {
|
|||||||
this.setupDone = true;
|
this.setupDone = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
debouncePersonalInfoSync(personalInfo: CustomerInfo) {
|
debouncePersonalInfoSync(personalInfo: CustomerInfoModel) {
|
||||||
this.clearPersonalInfoDebounce();
|
this.clearPersonalInfoDebounce();
|
||||||
this.personalInfoDebounceTimer = setTimeout(() => {
|
this.personalInfoDebounceTimer = setTimeout(() => {
|
||||||
this.syncPersonalInfo(personalInfo);
|
this.syncPersonalInfo(personalInfo);
|
||||||
@@ -262,9 +253,10 @@ export class CKFlowViewModel {
|
|||||||
this.paymentInfoDebounceTimer = setTimeout(() => {
|
this.paymentInfoDebounceTimer = setTimeout(() => {
|
||||||
const paymentInfo = {
|
const paymentInfo = {
|
||||||
cardDetails: paymentInfoVM.cardDetails,
|
cardDetails: paymentInfoVM.cardDetails,
|
||||||
flightTicketInfoId: get(flightTicketStore).id,
|
|
||||||
method: PaymentMethod.Card,
|
method: PaymentMethod.Card,
|
||||||
};
|
orderId: -1,
|
||||||
|
productId: get(productStore)?.id,
|
||||||
|
} as PaymentInfoPayload;
|
||||||
this.syncPaymentInfo(paymentInfo);
|
this.syncPaymentInfo(paymentInfo);
|
||||||
}, this.syncInterval);
|
}, this.syncInterval);
|
||||||
}
|
}
|
||||||
@@ -276,12 +268,12 @@ export class CKFlowViewModel {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
isPersonalInfoValid(personalInfo: CustomerInfo): boolean {
|
isPersonalInfoValid(personalInfo: CustomerInfoModel): boolean {
|
||||||
const parsed = customerInfoModel.safeParse(personalInfo);
|
const parsed = customerInfoModel.safeParse(personalInfo);
|
||||||
return !parsed.error && !!parsed.data;
|
return !parsed.error && !!parsed.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async syncPersonalInfo(personalInfo: CustomerInfo) {
|
async syncPersonalInfo(personalInfo: CustomerInfoModel) {
|
||||||
if (!this.flowId || !this.setupDone) {
|
if (!this.flowId || !this.setupDone) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -537,17 +529,19 @@ export class CKFlowViewModel {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get primary passenger's PII
|
const personalInfo = customerInfoVM.customerInfo;
|
||||||
const primaryPassengerInfo =
|
if (!personalInfo) {
|
||||||
passengerInfoVM.passengerInfos.length > 0
|
toast.error("Could not find customer info", {
|
||||||
? passengerInfoVM.passengerInfos[0].passengerPii
|
description: "Please try again later or contact support",
|
||||||
: undefined;
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const out = await api.ckflow.executePrePaymentStep.mutate({
|
const out = await api.ckflow.executePrePaymentStep.mutate({
|
||||||
flowId: this.flowId!,
|
flowId: this.flowId!,
|
||||||
payload: {
|
payload: {
|
||||||
initialUrl: get(flightTicketStore).checkoutUrl,
|
initialUrl: "",
|
||||||
personalInfo: primaryPassengerInfo,
|
personalInfo: personalInfo,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -570,9 +564,10 @@ export class CKFlowViewModel {
|
|||||||
|
|
||||||
const paymentInfo = {
|
const paymentInfo = {
|
||||||
cardDetails: paymentInfoVM.cardDetails,
|
cardDetails: paymentInfoVM.cardDetails,
|
||||||
flightTicketInfoId: get(flightTicketStore).id,
|
|
||||||
method: PaymentMethod.Card,
|
method: PaymentMethod.Card,
|
||||||
};
|
orderId: -1,
|
||||||
|
productId: get(productStore)?.id,
|
||||||
|
} as PaymentInfoPayload;
|
||||||
|
|
||||||
const out = await api.ckflow.executePaymentStep.mutate({
|
const out = await api.ckflow.executePaymentStep.mutate({
|
||||||
flowId: this.flowId!,
|
flowId: this.flowId!,
|
||||||
|
|||||||
78
apps/frontend/src/lib/domains/customerinfo/controller.ts
Normal file
78
apps/frontend/src/lib/domains/customerinfo/controller.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { db } from "@pkg/db";
|
||||||
|
import { Logger } from "@pkg/logger";
|
||||||
|
import type { Result } from "@pkg/result";
|
||||||
|
import type {
|
||||||
|
CreateCustomerInfoPayload,
|
||||||
|
CustomerInfoModel,
|
||||||
|
UpdateCustomerInfoPayload,
|
||||||
|
} from "./data";
|
||||||
|
import { CustomerInfoRepository } from "./repository";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CustomerInfoController handles business logic for customer information operations.
|
||||||
|
* Coordinates between the repository layer and the UI/API layer.
|
||||||
|
*/
|
||||||
|
export class CustomerInfoController {
|
||||||
|
repo: CustomerInfoRepository;
|
||||||
|
|
||||||
|
constructor(repo: CustomerInfoRepository) {
|
||||||
|
this.repo = repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new customer information record
|
||||||
|
* @param payload - Customer information data to create
|
||||||
|
* @returns Result containing the new customer info ID or error
|
||||||
|
*/
|
||||||
|
async createCustomerInfo(
|
||||||
|
payload: CreateCustomerInfoPayload,
|
||||||
|
): Promise<Result<number>> {
|
||||||
|
Logger.info("Creating customer info", { email: payload.email });
|
||||||
|
return this.repo.createCustomerInfo(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves customer information by ID
|
||||||
|
* @param id - Customer info ID to retrieve
|
||||||
|
* @returns Result containing customer info model or error
|
||||||
|
*/
|
||||||
|
async getCustomerInfo(id: number): Promise<Result<CustomerInfoModel>> {
|
||||||
|
Logger.info(`Retrieving customer info with ID: ${id}`);
|
||||||
|
return this.repo.getCustomerInfoById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves all customer information records
|
||||||
|
* @returns Result containing array of customer info models or error
|
||||||
|
*/
|
||||||
|
async getAllCustomerInfo(): Promise<Result<CustomerInfoModel[]>> {
|
||||||
|
Logger.info("Retrieving all customer info records");
|
||||||
|
return this.repo.getAllCustomerInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates existing customer information
|
||||||
|
* @param payload - Customer information data to update (must include id)
|
||||||
|
* @returns Result containing success boolean or error
|
||||||
|
*/
|
||||||
|
async updateCustomerInfo(
|
||||||
|
payload: UpdateCustomerInfoPayload,
|
||||||
|
): Promise<Result<boolean>> {
|
||||||
|
Logger.info(`Updating customer info with ID: ${payload.id}`);
|
||||||
|
return this.repo.updateCustomerInfo(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes customer information by ID
|
||||||
|
* @param id - Customer info ID to delete
|
||||||
|
* @returns Result containing success boolean or error
|
||||||
|
*/
|
||||||
|
async deleteCustomerInfo(id: number): Promise<Result<boolean>> {
|
||||||
|
Logger.info(`Deleting customer info with ID: ${id}`);
|
||||||
|
return this.repo.deleteCustomerInfo(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCustomerInfoController() {
|
||||||
|
return new CustomerInfoController(new CustomerInfoRepository(db));
|
||||||
|
}
|
||||||
1
apps/frontend/src/lib/domains/customerinfo/data.ts
Normal file
1
apps/frontend/src/lib/domains/customerinfo/data.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "@pkg/logic/domains/customerinfo/data";
|
||||||
1
apps/frontend/src/lib/domains/customerinfo/repository.ts
Normal file
1
apps/frontend/src/lib/domains/customerinfo/repository.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "@pkg/logic/domains/customerinfo/repository";
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import LabelWrapper from "$lib/components/atoms/label-wrapper.svelte";
|
||||||
|
import Title from "$lib/components/atoms/title.svelte";
|
||||||
|
import Input from "$lib/components/ui/input/input.svelte";
|
||||||
|
import * as Select from "$lib/components/ui/select";
|
||||||
|
import { COUNTRIES_SELECT } from "$lib/core/countries";
|
||||||
|
import { capitalize } from "$lib/core/string.utils";
|
||||||
|
import { PHONE_COUNTRY_CODES } from "@pkg/logic/core/data/phonecc";
|
||||||
|
import type { CustomerInfoModel } from "../data";
|
||||||
|
import { customerInfoVM } from "./customerinfo.vm.svelte";
|
||||||
|
|
||||||
|
let { info = $bindable() }: { info: CustomerInfoModel } = $props();
|
||||||
|
|
||||||
|
function onSubmit(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
customerInfoVM.validateCustomerInfo(info);
|
||||||
|
}
|
||||||
|
|
||||||
|
function debounceValidate() {
|
||||||
|
customerInfoVM.debounceValidate(info);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form action="#" class="flex w-full flex-col gap-4" onsubmit={onSubmit}>
|
||||||
|
<!-- Name Fields -->
|
||||||
|
<div class="flex flex-col gap-4 md:flex-row">
|
||||||
|
<LabelWrapper label="First Name" error={customerInfoVM.errors.firstName}>
|
||||||
|
<Input
|
||||||
|
placeholder="First Name"
|
||||||
|
bind:value={info.firstName}
|
||||||
|
required
|
||||||
|
oninput={() => debounceValidate()}
|
||||||
|
minlength={1}
|
||||||
|
maxlength={64}
|
||||||
|
/>
|
||||||
|
</LabelWrapper>
|
||||||
|
|
||||||
|
<LabelWrapper
|
||||||
|
label="Middle Name"
|
||||||
|
error={customerInfoVM.errors.middleName}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="Middle Name (Optional)"
|
||||||
|
bind:value={info.middleName}
|
||||||
|
oninput={() => debounceValidate()}
|
||||||
|
maxlength={64}
|
||||||
|
/>
|
||||||
|
</LabelWrapper>
|
||||||
|
|
||||||
|
<LabelWrapper label="Last Name" error={customerInfoVM.errors.lastName}>
|
||||||
|
<Input
|
||||||
|
placeholder="Last Name"
|
||||||
|
bind:value={info.lastName}
|
||||||
|
required
|
||||||
|
oninput={() => debounceValidate()}
|
||||||
|
minlength={1}
|
||||||
|
maxlength={64}
|
||||||
|
/>
|
||||||
|
</LabelWrapper>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email Field -->
|
||||||
|
<LabelWrapper label="Email" error={customerInfoVM.errors.email}>
|
||||||
|
<Input
|
||||||
|
placeholder="Email"
|
||||||
|
bind:value={info.email}
|
||||||
|
type="email"
|
||||||
|
oninput={() => debounceValidate()}
|
||||||
|
required
|
||||||
|
maxlength={128}
|
||||||
|
/>
|
||||||
|
</LabelWrapper>
|
||||||
|
|
||||||
|
<!-- Phone Number Field -->
|
||||||
|
<LabelWrapper label="Phone Number" error={customerInfoVM.errors.phoneNumber}>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Select.Root
|
||||||
|
type="single"
|
||||||
|
required
|
||||||
|
onValueChange={(code) => {
|
||||||
|
info.phoneCountryCode = code;
|
||||||
|
debounceValidate();
|
||||||
|
}}
|
||||||
|
name="phoneCode"
|
||||||
|
>
|
||||||
|
<Select.Trigger class="w-28">
|
||||||
|
{#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"
|
||||||
|
minlength={1}
|
||||||
|
maxlength={20}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</LabelWrapper>
|
||||||
|
|
||||||
|
<!-- Address Section -->
|
||||||
|
<Title size="h5">Address Information</Title>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4 md:flex-row">
|
||||||
|
<LabelWrapper label="Country" error={customerInfoVM.errors.country}>
|
||||||
|
<Select.Root
|
||||||
|
type="single"
|
||||||
|
required
|
||||||
|
onValueChange={(e) => {
|
||||||
|
info.country = e;
|
||||||
|
debounceValidate();
|
||||||
|
}}
|
||||||
|
name="country"
|
||||||
|
>
|
||||||
|
<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={customerInfoVM.errors.state}>
|
||||||
|
<Input
|
||||||
|
placeholder="State/Province"
|
||||||
|
bind:value={info.state}
|
||||||
|
required
|
||||||
|
oninput={() => debounceValidate()}
|
||||||
|
minlength={1}
|
||||||
|
maxlength={128}
|
||||||
|
/>
|
||||||
|
</LabelWrapper>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4 md:flex-row">
|
||||||
|
<LabelWrapper label="City" error={customerInfoVM.errors.city}>
|
||||||
|
<Input
|
||||||
|
placeholder="City"
|
||||||
|
bind:value={info.city}
|
||||||
|
required
|
||||||
|
minlength={1}
|
||||||
|
maxlength={128}
|
||||||
|
oninput={() => debounceValidate()}
|
||||||
|
/>
|
||||||
|
</LabelWrapper>
|
||||||
|
|
||||||
|
<LabelWrapper label="Zip Code" error={customerInfoVM.errors.zipCode}>
|
||||||
|
<Input
|
||||||
|
placeholder="Zip/Postal Code"
|
||||||
|
bind:value={info.zipCode}
|
||||||
|
required
|
||||||
|
minlength={1}
|
||||||
|
maxlength={21}
|
||||||
|
oninput={() => debounceValidate()}
|
||||||
|
/>
|
||||||
|
</LabelWrapper>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LabelWrapper label="Address" error={customerInfoVM.errors.address}>
|
||||||
|
<Input
|
||||||
|
placeholder="Street Address"
|
||||||
|
bind:value={info.address}
|
||||||
|
required
|
||||||
|
minlength={1}
|
||||||
|
oninput={() => debounceValidate()}
|
||||||
|
/>
|
||||||
|
</LabelWrapper>
|
||||||
|
|
||||||
|
<LabelWrapper
|
||||||
|
label="Address 2 (Optional)"
|
||||||
|
error={customerInfoVM.errors.address2}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="Apartment, suite, etc. (Optional)"
|
||||||
|
bind:value={info.address2}
|
||||||
|
oninput={() => debounceValidate()}
|
||||||
|
/>
|
||||||
|
</LabelWrapper>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import type { CreateCustomerInfoPayload, CustomerInfoModel } from "../data";
|
||||||
|
import { customerInfoModel } from "../data";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CustomerInfoViewModel manages single customer information state for checkout.
|
||||||
|
* Handles validation, API interactions, and form state for one customer per checkout.
|
||||||
|
*/
|
||||||
|
export class CustomerInfoViewModel {
|
||||||
|
// State: current customer info being edited/created (single customer for checkout)
|
||||||
|
customerInfo = $state<CustomerInfoModel | null>(null);
|
||||||
|
|
||||||
|
// State: validation errors for the current customer info
|
||||||
|
errors = $state<Partial<Record<keyof CustomerInfoModel, string>>>({});
|
||||||
|
|
||||||
|
// Loading states
|
||||||
|
loading = $state(false);
|
||||||
|
formLoading = $state(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes customer info with default empty values for checkout
|
||||||
|
*/
|
||||||
|
initializeCustomerInfo() {
|
||||||
|
this.customerInfo = this.getDefaultCustomerInfo() as CustomerInfoModel;
|
||||||
|
this.errors = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates customer information data using Zod schema
|
||||||
|
* @param info - Customer info to validate
|
||||||
|
* @returns true if valid, false otherwise
|
||||||
|
*/
|
||||||
|
validateCustomerInfo(info?: Partial<CustomerInfoModel>): boolean {
|
||||||
|
try {
|
||||||
|
customerInfoModel.parse(info || this.customerInfo);
|
||||||
|
this.errors = {};
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
this.errors = 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 false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounced validation for real-time form feedback
|
||||||
|
* @param info - Customer info to validate
|
||||||
|
* @param delay - Debounce delay in milliseconds (default: 500)
|
||||||
|
*/
|
||||||
|
private validationTimeout: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
|
debounceValidate(info: Partial<CustomerInfoModel>, delay = 500) {
|
||||||
|
if (this.validationTimeout) {
|
||||||
|
clearTimeout(this.validationTimeout);
|
||||||
|
}
|
||||||
|
this.validationTimeout = setTimeout(() => {
|
||||||
|
this.validateCustomerInfo(info);
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the current customer info has validation errors
|
||||||
|
* @returns true if there are no errors, false otherwise
|
||||||
|
*/
|
||||||
|
isValid(): boolean {
|
||||||
|
return Object.keys(this.errors).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if customer info is filled out (has required fields)
|
||||||
|
* @returns true if customer info exists and has data, false otherwise
|
||||||
|
*/
|
||||||
|
isFilled(): boolean {
|
||||||
|
if (!this.customerInfo) return false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
this.customerInfo.firstName.length > 0 &&
|
||||||
|
this.customerInfo.lastName.length > 0 &&
|
||||||
|
this.customerInfo.email.length > 0 &&
|
||||||
|
this.customerInfo.phoneNumber.length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the current customer info (for editing existing records)
|
||||||
|
* @param customerInfo - Customer info to edit
|
||||||
|
*/
|
||||||
|
setCustomerInfo(customerInfo: CustomerInfoModel) {
|
||||||
|
this.customerInfo = { ...customerInfo };
|
||||||
|
this.errors = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the customer info and errors
|
||||||
|
*/
|
||||||
|
resetCustomerInfo() {
|
||||||
|
this.customerInfo = null;
|
||||||
|
this.errors = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a default/empty customer info object for forms
|
||||||
|
* @returns Default customer info payload
|
||||||
|
*/
|
||||||
|
getDefaultCustomerInfo(): CreateCustomerInfoPayload {
|
||||||
|
return {
|
||||||
|
firstName: "",
|
||||||
|
middleName: "",
|
||||||
|
lastName: "",
|
||||||
|
email: "",
|
||||||
|
phoneCountryCode: "",
|
||||||
|
phoneNumber: "",
|
||||||
|
country: "",
|
||||||
|
state: "",
|
||||||
|
city: "",
|
||||||
|
zipCode: "",
|
||||||
|
address: "",
|
||||||
|
address2: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets all state (for cleanup or re-initialization)
|
||||||
|
*/
|
||||||
|
reset() {
|
||||||
|
this.customerInfo = null;
|
||||||
|
this.errors = {};
|
||||||
|
this.loading = false;
|
||||||
|
this.formLoading = false;
|
||||||
|
if (this.validationTimeout) {
|
||||||
|
clearTimeout(this.validationTimeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const customerInfoVM = new CustomerInfoViewModel();
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import { ERROR_CODES, type Result } from "$lib/core/data.types";
|
import { ERROR_CODES, type Result } from "$lib/core/data.types";
|
||||||
import { and, eq, isNotNull, or, type Database } from "@pkg/db";
|
import { and, eq, isNotNull, or, type Database } from "@pkg/db";
|
||||||
import { order, passengerInfo } from "@pkg/db/schema";
|
import { order } from "@pkg/db/schema";
|
||||||
import { getError, Logger } from "@pkg/logger";
|
import { getError, Logger } from "@pkg/logger";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import {
|
import {
|
||||||
fullOrderModel,
|
fullOrderModel,
|
||||||
limitedOrderWithTicketInfoModel,
|
limitedOrderWithProductModel,
|
||||||
OrderStatus,
|
OrderStatus,
|
||||||
type FullOrderModel,
|
type FullOrderModel,
|
||||||
type LimitedOrderWithTicketInfoModel,
|
type LimitedOrderWithProductModel,
|
||||||
type NewOrderModel,
|
type NewOrderModel,
|
||||||
} from "./entities";
|
} from "./entities";
|
||||||
|
|
||||||
@@ -19,10 +19,10 @@ export class OrderRepository {
|
|||||||
this.db = db;
|
this.db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
async listActiveOrders(): Promise<Result<LimitedOrderWithTicketInfoModel[]>> {
|
async listActiveOrders(): Promise<Result<LimitedOrderWithProductModel[]>> {
|
||||||
const conditions = [
|
const conditions = [
|
||||||
or(
|
or(
|
||||||
eq(order.status, OrderStatus.PENDING_FULLFILLMENT),
|
eq(order.status, OrderStatus.PENDING_FULFILLMENT),
|
||||||
eq(order.status, OrderStatus.PARTIALLY_FULFILLED),
|
eq(order.status, OrderStatus.PARTIALLY_FULFILLED),
|
||||||
),
|
),
|
||||||
isNotNull(order.agentId),
|
isNotNull(order.agentId),
|
||||||
@@ -34,29 +34,29 @@ export class OrderRepository {
|
|||||||
displayPrice: true,
|
displayPrice: true,
|
||||||
basePrice: true,
|
basePrice: true,
|
||||||
discountAmount: true,
|
discountAmount: true,
|
||||||
pricePerPassenger: true,
|
orderPrice: true,
|
||||||
fullfilledPrice: true,
|
fullfilledPrice: true,
|
||||||
status: true,
|
status: true,
|
||||||
},
|
},
|
||||||
with: {
|
with: {
|
||||||
flightTicketInfo: {
|
product: true,
|
||||||
|
customerInfo: {
|
||||||
columns: {
|
columns: {
|
||||||
id: true,
|
firstName: true,
|
||||||
departure: true,
|
middleName: true,
|
||||||
arrival: true,
|
lastName: true,
|
||||||
departureDate: true,
|
email: true,
|
||||||
returnDate: true,
|
country: true,
|
||||||
flightType: true,
|
state: true,
|
||||||
passengerCounts: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const out = [] as LimitedOrderWithTicketInfoModel[];
|
const out = [] as LimitedOrderWithProductModel[];
|
||||||
|
|
||||||
for (const order of qRes) {
|
for (const order of qRes) {
|
||||||
const parsed = limitedOrderWithTicketInfoModel.safeParse({
|
const parsed = limitedOrderWithProductModel.safeParse({
|
||||||
...order,
|
...order,
|
||||||
});
|
});
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -78,21 +78,13 @@ export class OrderRepository {
|
|||||||
return { data: out };
|
return { data: out };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOrderByPNR(pnr: string): Promise<Result<FullOrderModel>> {
|
async getOrderByUID(uid: string): Promise<Result<FullOrderModel>> {
|
||||||
const out = await this.db.query.order.findFirst({
|
const out = await this.db.query.order.findFirst({
|
||||||
where: eq(order.pnr, pnr),
|
where: eq(order.uid, uid),
|
||||||
with: { flightTicketInfo: true },
|
with: { customerInfo: true, product: true },
|
||||||
});
|
});
|
||||||
if (!out) return {};
|
if (!out) return {};
|
||||||
const relatedPassengerInfos = await this.db.query.passengerInfo.findMany({
|
const parsed = fullOrderModel.safeParse({ ...out });
|
||||||
where: eq(passengerInfo.orderId, out.id),
|
|
||||||
with: { passengerPii: true },
|
|
||||||
});
|
|
||||||
const parsed = fullOrderModel.safeParse({
|
|
||||||
...out,
|
|
||||||
emailAccount: undefined,
|
|
||||||
passengerInfos: relatedPassengerInfos,
|
|
||||||
});
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return {
|
return {
|
||||||
error: getError(
|
error: getError(
|
||||||
@@ -111,21 +103,25 @@ export class OrderRepository {
|
|||||||
|
|
||||||
async createOrder(
|
async createOrder(
|
||||||
payload: NewOrderModel,
|
payload: NewOrderModel,
|
||||||
): Promise<Result<{ id: number; pnr: string }>> {
|
): Promise<Result<{ id: number; uid: string }>> {
|
||||||
const pnr = nanoid(9).toUpperCase();
|
const uid = nanoid(12).toUpperCase();
|
||||||
try {
|
try {
|
||||||
const out = await this.db
|
const out = await this.db
|
||||||
.insert(order)
|
.insert(order)
|
||||||
.values({
|
.values({
|
||||||
|
uid,
|
||||||
displayPrice: payload.displayPrice.toFixed(3),
|
displayPrice: payload.displayPrice.toFixed(3),
|
||||||
basePrice: payload.basePrice.toFixed(3),
|
basePrice: payload.basePrice.toFixed(3),
|
||||||
discountAmount: payload.discountAmount.toFixed(3),
|
discountAmount: payload.discountAmount.toFixed(3),
|
||||||
|
fullfilledPrice: payload.fullfilledPrice.toFixed(3),
|
||||||
|
orderPrice: payload.orderPrice.toFixed(3),
|
||||||
|
|
||||||
flightTicketInfoId: payload.flightTicketInfoId,
|
|
||||||
paymentInfoId: payload.paymentInfoId,
|
paymentInfoId: payload.paymentInfoId,
|
||||||
|
|
||||||
status: OrderStatus.PENDING_FULLFILLMENT,
|
status: OrderStatus.PENDING_FULFILLMENT,
|
||||||
pnr,
|
|
||||||
|
customerInfoId: payload.customerInfoId,
|
||||||
|
productId: payload.productId,
|
||||||
|
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
@@ -133,7 +129,7 @@ export class OrderRepository {
|
|||||||
.returning({ id: order.id })
|
.returning({ id: order.id })
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return { data: { id: out[0]?.id, pnr } };
|
return { data: { id: out[0]?.id, uid } };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return {
|
return {
|
||||||
error: getError(
|
error: getError(
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ export class OrderController {
|
|||||||
return this.repo.createOrder(payload);
|
return this.repo.createOrder(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOrderByPNR(pnr: string) {
|
async getOrderByUID(uid: string) {
|
||||||
return this.repo.getOrderByPNR(pnr);
|
return this.repo.getOrderByUID(uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
async markOrdersAsFulfilled(oids: number[]) {
|
async markOrdersAsFulfilled(oids: number[]) {
|
||||||
|
|||||||
@@ -1,78 +1,114 @@
|
|||||||
import { SessionOutcome } from "$lib/domains/ckflow/data/entities";
|
import { SessionOutcome } from "$lib/domains/ckflow/data/entities";
|
||||||
import { getCKUseCases } from "$lib/domains/ckflow/domain/usecases";
|
import { getCKUseCases } from "$lib/domains/ckflow/domain/usecases";
|
||||||
import { EmailerUseCases } from "$lib/domains/email/domain/usecases";
|
import { getCustomerInfoController } from "$lib/domains/customerinfo/controller";
|
||||||
import { createOrderPayloadModel } from "$lib/domains/order/data/entities";
|
import {
|
||||||
import { PassengerInfoRepository } from "$lib/domains/passengerinfo/data/repository";
|
createOrderPayloadModel,
|
||||||
import { PassengerInfoController } from "$lib/domains/passengerinfo/domain/controller";
|
OrderCreationStep,
|
||||||
|
} from "$lib/domains/order/data/entities";
|
||||||
import { PaymentInfoRepository } from "$lib/domains/paymentinfo/data/repository";
|
import { PaymentInfoRepository } from "$lib/domains/paymentinfo/data/repository";
|
||||||
import { PaymentInfoUseCases } from "$lib/domains/paymentinfo/domain/usecases";
|
import { PaymentInfoUseCases } from "$lib/domains/paymentinfo/domain/usecases";
|
||||||
import { CheckoutStep } from "$lib/domains/ticket/data/entities";
|
import { getProductUseCases } from "$lib/domains/product/usecases";
|
||||||
import { getTC } from "$lib/domains/ticket/domain/controller";
|
|
||||||
import { createTRPCRouter, publicProcedure } from "$lib/trpc/t";
|
import { createTRPCRouter, publicProcedure } from "$lib/trpc/t";
|
||||||
import { db } from "@pkg/db";
|
import { db } from "@pkg/db";
|
||||||
import { getError, Logger } from "@pkg/logger";
|
import { getError, Logger } from "@pkg/logger";
|
||||||
import { ERROR_CODES } from "@pkg/result";
|
import { ERROR_CODES, type Result } from "@pkg/result";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { OrderRepository } from "../data/repository";
|
import { OrderRepository } from "../data/repository";
|
||||||
import { OrderController } from "./controller";
|
import { OrderController } from "./controller";
|
||||||
|
|
||||||
export const orderRouter = createTRPCRouter({
|
export const orderRouter = createTRPCRouter({
|
||||||
|
/**
|
||||||
|
* Creates a new order for product checkout
|
||||||
|
* Handles customer info creation, payment info creation, and order creation
|
||||||
|
*/
|
||||||
createOrder: publicProcedure
|
createOrder: publicProcedure
|
||||||
.input(createOrderPayloadModel)
|
.input(createOrderPayloadModel)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
const pduc = new PaymentInfoUseCases(new PaymentInfoRepository(db));
|
const paymentInfoUC = new PaymentInfoUseCases(
|
||||||
const oc = new OrderController(new OrderRepository(db));
|
new PaymentInfoRepository(db),
|
||||||
const pc = new PassengerInfoController(
|
|
||||||
new PassengerInfoRepository(db),
|
|
||||||
);
|
);
|
||||||
const tc = getTC();
|
const orderController = new OrderController(new OrderRepository(db));
|
||||||
const emailUC = new EmailerUseCases();
|
const customerInfoController = getCustomerInfoController();
|
||||||
|
const productUC = getProductUseCases();
|
||||||
|
|
||||||
const ftRes = await tc.uncacheAndSaveTicket(input.flightTicketId!);
|
// Validate required inputs
|
||||||
if (ftRes.error || !ftRes.data) {
|
if (!input.productId || !input.customerInfo) {
|
||||||
return { error: ftRes.error };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!input.flightTicketId || !input.paymentInfo) {
|
|
||||||
return {
|
return {
|
||||||
error: getError({
|
error: getError({
|
||||||
code: ERROR_CODES.INPUT_ERROR,
|
code: ERROR_CODES.INPUT_ERROR,
|
||||||
message: "Received invalid input",
|
message: "Missing required order information",
|
||||||
detail: "The entered data is incomplete or invalid",
|
detail: "Product ID and customer information are required",
|
||||||
userHint: "Enter valid order data to complete the order",
|
userHint:
|
||||||
|
"Please ensure product and customer information are provided",
|
||||||
}),
|
}),
|
||||||
};
|
} as Result<string>;
|
||||||
}
|
|
||||||
const pdRes = await pduc.createPaymentInfo(input.paymentInfo!);
|
|
||||||
if (pdRes.error || !pdRes.data) {
|
|
||||||
return { error: pdRes.error };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.info(`Setting flight ticket info id ${ftRes.data}`);
|
// Verify product exists
|
||||||
input.orderModel.flightTicketInfoId = ftRes.data;
|
Logger.info(`Verifying product with ID: ${input.productId}`);
|
||||||
|
const productRes = await productUC.getProductById(input.productId);
|
||||||
Logger.info(`Setting payment details id ${pdRes.data}`);
|
if (productRes.error || !productRes.data) {
|
||||||
input.orderModel.paymentInfoId = pdRes.data;
|
return {
|
||||||
|
error: getError({
|
||||||
Logger.info("Creating order");
|
code: ERROR_CODES.NOT_FOUND_ERROR,
|
||||||
const out = await oc.createOrder(input.orderModel);
|
message: "Product not found",
|
||||||
if (out.error || !out.data) {
|
detail: `Product with ID ${input.productId} does not exist`,
|
||||||
await pduc.deletePaymentInfo(pdRes.data!);
|
userHint: "Please select a valid product",
|
||||||
return { error: out.error };
|
}),
|
||||||
|
} as Result<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.info(`Creating passenger infos with oid: ${out.data}`);
|
// Create customer information
|
||||||
const pOut = await pc.createPassengerInfos(
|
Logger.info("Creating customer information");
|
||||||
input.passengerInfos,
|
const customerRes = await customerInfoController.createCustomerInfo(
|
||||||
out.data.id,
|
input.customerInfo,
|
||||||
ftRes.data!,
|
|
||||||
pdRes.data,
|
|
||||||
);
|
);
|
||||||
if (pOut.error) {
|
if (customerRes.error || !customerRes.data) {
|
||||||
await oc.deleteOrder(out.data.id);
|
return { error: customerRes.error } as Result<string>;
|
||||||
return { error: pOut.error };
|
}
|
||||||
|
const customerInfoId = customerRes.data;
|
||||||
|
Logger.info(`Customer info created with ID: ${customerInfoId}`);
|
||||||
|
|
||||||
|
// Create payment information if provided
|
||||||
|
let paymentInfoId: number | undefined = undefined;
|
||||||
|
if (input.paymentInfo) {
|
||||||
|
Logger.info("Creating payment information");
|
||||||
|
const paymentRes = await paymentInfoUC.createPaymentInfo(
|
||||||
|
input.paymentInfo,
|
||||||
|
);
|
||||||
|
if (paymentRes.error || !paymentRes.data) {
|
||||||
|
// Cleanup customer info on payment failure
|
||||||
|
await customerInfoController.deleteCustomerInfo(
|
||||||
|
customerInfoId,
|
||||||
|
);
|
||||||
|
return { error: paymentRes.error } as Result<string>;
|
||||||
|
}
|
||||||
|
paymentInfoId = paymentRes.data;
|
||||||
|
Logger.info(`Payment info created with ID: ${paymentInfoId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set IDs in order model
|
||||||
|
input.orderModel.productId = input.productId;
|
||||||
|
input.orderModel.customerInfoId = customerInfoId;
|
||||||
|
input.orderModel.paymentInfoId = paymentInfoId;
|
||||||
|
|
||||||
|
// Create the order
|
||||||
|
Logger.info("Creating order");
|
||||||
|
const orderRes = await orderController.createOrder(input.orderModel);
|
||||||
|
if (orderRes.error || !orderRes.data) {
|
||||||
|
// Cleanup on order creation failure
|
||||||
|
if (paymentInfoId) {
|
||||||
|
await paymentInfoUC.deletePaymentInfo(paymentInfoId);
|
||||||
|
}
|
||||||
|
await customerInfoController.deleteCustomerInfo(customerInfoId);
|
||||||
|
return { error: orderRes.error } as Result<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderId = orderRes.data.id;
|
||||||
|
const orderUID = orderRes.data.uid;
|
||||||
|
Logger.info(`Order created successfully with ID: ${orderId}`);
|
||||||
|
|
||||||
|
// Update checkout flow state if flowId is provided
|
||||||
if (input.flowId) {
|
if (input.flowId) {
|
||||||
Logger.info(
|
Logger.info(
|
||||||
`Updating checkout flow state for flow ${input.flowId}`,
|
`Updating checkout flow state for flow ${input.flowId}`,
|
||||||
@@ -80,7 +116,7 @@ export const orderRouter = createTRPCRouter({
|
|||||||
try {
|
try {
|
||||||
await getCKUseCases().cleanupFlow(input.flowId, {
|
await getCKUseCases().cleanupFlow(input.flowId, {
|
||||||
sessionOutcome: SessionOutcome.COMPLETED,
|
sessionOutcome: SessionOutcome.COMPLETED,
|
||||||
checkoutStep: CheckoutStep.Complete,
|
checkoutStep: OrderCreationStep.SUMMARY,
|
||||||
});
|
});
|
||||||
Logger.info(
|
Logger.info(
|
||||||
`Checkout flow ${input.flowId} marked as completed`,
|
`Checkout flow ${input.flowId} marked as completed`,
|
||||||
@@ -91,94 +127,29 @@ export const orderRouter = createTRPCRouter({
|
|||||||
);
|
);
|
||||||
Logger.error(err);
|
Logger.error(err);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
Logger.warn(
|
|
||||||
`No flow id found to mark as completed: ${input.flowId}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const pnr = out.data.pnr;
|
Logger.debug(
|
||||||
|
"Rotating the link id for the product now that it's been used",
|
||||||
if (!pnr) {
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
// Get order details for email
|
|
||||||
const orderDetails = await oc.getOrderByPNR(pnr);
|
|
||||||
|
|
||||||
if (!orderDetails.data) {
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
const order = orderDetails.data;
|
|
||||||
const ticketInfo = order.flightTicketInfo;
|
|
||||||
const primaryPassenger = order.passengerInfos[0]?.passengerPii!;
|
|
||||||
|
|
||||||
if (!ticketInfo || !primaryPassenger) {
|
|
||||||
Logger.warn(
|
|
||||||
`No email address found for passenger to send PNR confirmation`,
|
|
||||||
);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
// Get passenger email address to send confirmation to
|
|
||||||
const passengerEmail = primaryPassenger.email;
|
|
||||||
if (!passengerEmail) {
|
|
||||||
Logger.warn(
|
|
||||||
`No email address found for passenger to send PNR confirmation`,
|
|
||||||
);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
Logger.info(
|
|
||||||
`Sending PNR confirmation email to ${passengerEmail}`,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Send the email with React component directly
|
const out = await productUC.refreshProductLinkId(input.productId);
|
||||||
const emailResult = await emailUC.sendEmailWithTemplate({
|
|
||||||
to: passengerEmail,
|
|
||||||
subject: `Flight Confirmation: ${ticketInfo.departure} to ${ticketInfo.arrival} - PNR: ${pnr}`,
|
|
||||||
template: "pnr-confirmation",
|
|
||||||
templateData: {
|
|
||||||
pnr: pnr,
|
|
||||||
origin: ticketInfo.departure,
|
|
||||||
destination: ticketInfo.arrival,
|
|
||||||
departureDate: new Date(
|
|
||||||
ticketInfo.departureDate,
|
|
||||||
).toISOString(),
|
|
||||||
returnDate: new Date(
|
|
||||||
ticketInfo.returnDate,
|
|
||||||
)?.toISOString(),
|
|
||||||
passengerName: `${primaryPassenger.firstName}`,
|
|
||||||
baseUrl: "https://FlyTicketTravel.com",
|
|
||||||
logoPath:
|
|
||||||
"https://FlyTicketTravel.com/assets/logos/logo-main.svg",
|
|
||||||
companyName: "FlyTicketTravel",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (emailResult.error) {
|
Logger.debug(
|
||||||
Logger.error(
|
`Product link id rotated: ${JSON.stringify(out, null, 2)}`,
|
||||||
`Failed to send PNR confirmation email: ${emailResult.error.message}`,
|
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
Logger.info(
|
|
||||||
`PNR confirmation email sent to ${passengerEmail}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (emailError) {
|
|
||||||
// Don't fail the order if email sending fails
|
|
||||||
Logger.error(
|
|
||||||
`Error sending PNR confirmation email: ${emailError}`,
|
|
||||||
);
|
|
||||||
Logger.error(emailError);
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.info("Done with order creation, returning");
|
Logger.info("Order creation completed successfully");
|
||||||
return out;
|
return { data: orderUID } as Result<string>;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
findByPNR: publicProcedure
|
/**
|
||||||
.input(z.object({ pnr: z.string() }))
|
* Finds an order by its ID
|
||||||
|
*/
|
||||||
|
findByIdUID: publicProcedure
|
||||||
|
.input(z.object({ uid: z.string() }))
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
const oc = new OrderController(new OrderRepository(db));
|
const orderController = new OrderController(new OrderRepository(db));
|
||||||
return oc.getOrderByPNR(input.pnr);
|
return orderController.getOrderByUID(input.uid);
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,118 +1,186 @@
|
|||||||
|
import { billingDetailsVM } from "$lib/domains/checkout/payment-info-section/billing.details.vm.svelte";
|
||||||
|
import { calculateFinalPrices } from "$lib/domains/checkout/utils";
|
||||||
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
|
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
|
||||||
|
import { currencyStore } from "$lib/domains/currency/view/currency.vm.svelte";
|
||||||
|
import { customerInfoVM } from "$lib/domains/customerinfo/view/customerinfo.vm.svelte";
|
||||||
import {
|
import {
|
||||||
createOrderPayloadModel,
|
createOrderPayloadModel,
|
||||||
OrderCreationStep,
|
OrderCreationStep,
|
||||||
} from "$lib/domains/order/data/entities";
|
} from "$lib/domains/order/data/entities";
|
||||||
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
|
import { productStore } from "$lib/domains/product/store";
|
||||||
import type { FlightTicket } from "$lib/domains/ticket/data/entities";
|
|
||||||
import { trpcApiStore } from "$lib/stores/api";
|
import { trpcApiStore } from "$lib/stores/api";
|
||||||
import type { EmailAccountPayload } from "@pkg/logic/domains/account/data/entities";
|
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CreateOrderViewModel manages the order creation flow for product checkout.
|
||||||
|
* Handles step progression, validation, and order submission.
|
||||||
|
*/
|
||||||
export class CreateOrderViewModel {
|
export class CreateOrderViewModel {
|
||||||
orderStep = $state(OrderCreationStep.ACCOUNT_SELECTION);
|
// Current step in the order creation flow
|
||||||
|
orderStep = $state(OrderCreationStep.CUSTOMER_INFO);
|
||||||
|
|
||||||
accountInfo = $state<EmailAccountPayload>({ email: "", password: "" });
|
// Loading state
|
||||||
accountInfoOk = $state(false);
|
loading = $state(false);
|
||||||
|
|
||||||
passengerInfosOk = $state(false);
|
|
||||||
|
|
||||||
ticketInfo = $state<FlightTicket | undefined>(undefined);
|
|
||||||
ticketInfoOk = $state(false);
|
|
||||||
|
|
||||||
loading = $state(true);
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the current order creation step
|
||||||
|
* @param step - The step to navigate to
|
||||||
|
*/
|
||||||
setStep(step: OrderCreationStep) {
|
setStep(step: OrderCreationStep) {
|
||||||
if (step === OrderCreationStep.ACCOUNT_SELECTION && this.accountInfoOk) {
|
|
||||||
this.orderStep = step;
|
this.orderStep = step;
|
||||||
} else if (
|
|
||||||
step === OrderCreationStep.TICKET_SELECTION &&
|
|
||||||
this.ticketInfoOk
|
|
||||||
) {
|
|
||||||
this.orderStep = step;
|
|
||||||
} else {
|
|
||||||
this.orderStep = step;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advances to the next step in the order creation flow
|
||||||
|
*/
|
||||||
setNextStep() {
|
setNextStep() {
|
||||||
if (this.orderStep === OrderCreationStep.ACCOUNT_SELECTION) {
|
if (this.orderStep === OrderCreationStep.CUSTOMER_INFO) {
|
||||||
this.orderStep = OrderCreationStep.TICKET_SELECTION;
|
// Validate customer info before proceeding
|
||||||
} else if (this.orderStep === OrderCreationStep.TICKET_SELECTION) {
|
if (!this.isCustomerInfoValid()) {
|
||||||
this.orderStep = OrderCreationStep.CUSTOMER_INFO;
|
toast.error("Please complete customer information");
|
||||||
} else if (this.orderStep === OrderCreationStep.CUSTOMER_INFO) {
|
|
||||||
this.orderStep = OrderCreationStep.SUMMARY;
|
|
||||||
} else {
|
|
||||||
this.orderStep = OrderCreationStep.ACCOUNT_SELECTION;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setPrevStep() {
|
|
||||||
if (this.orderStep === OrderCreationStep.SUMMARY) {
|
|
||||||
this.orderStep = OrderCreationStep.CUSTOMER_INFO;
|
|
||||||
} else if (this.orderStep === OrderCreationStep.CUSTOMER_INFO) {
|
|
||||||
this.orderStep = OrderCreationStep.TICKET_SELECTION;
|
|
||||||
} else {
|
|
||||||
this.orderStep = OrderCreationStep.ACCOUNT_SELECTION;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async createOrder() {
|
|
||||||
const api = get(trpcApiStore);
|
|
||||||
if (!api) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.orderStep = OrderCreationStep.PAYMENT;
|
||||||
let basePrice = 0;
|
} else if (this.orderStep === OrderCreationStep.PAYMENT) {
|
||||||
let displayPrice = 0;
|
this.orderStep = OrderCreationStep.SUMMARY;
|
||||||
let discountAmount = 0;
|
}
|
||||||
if (this.ticketInfo) {
|
|
||||||
basePrice = this.ticketInfo.priceDetails.basePrice;
|
|
||||||
displayPrice = this.ticketInfo.priceDetails.displayPrice;
|
|
||||||
discountAmount = this.ticketInfo.priceDetails.discountAmount;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Goes back to the previous step
|
||||||
|
*/
|
||||||
|
setPrevStep() {
|
||||||
|
if (this.orderStep === OrderCreationStep.SUMMARY) {
|
||||||
|
this.orderStep = OrderCreationStep.PAYMENT;
|
||||||
|
} else if (this.orderStep === OrderCreationStep.PAYMENT) {
|
||||||
|
this.orderStep = OrderCreationStep.CUSTOMER_INFO;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates if customer information is complete
|
||||||
|
* @returns true if customer info is valid, false otherwise
|
||||||
|
*/
|
||||||
|
isCustomerInfoValid(): boolean {
|
||||||
|
if (!customerInfoVM.customerInfo) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return customerInfoVM.isValid();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates if product is selected
|
||||||
|
* @returns true if product exists, false otherwise
|
||||||
|
*/
|
||||||
|
isProductValid(): boolean {
|
||||||
|
const product = get(productStore);
|
||||||
|
return product !== null && product.id !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if order can be submitted (all validations pass)
|
||||||
|
* @returns true if order is ready to submit, false otherwise
|
||||||
|
*/
|
||||||
|
canSubmitOrder(): boolean {
|
||||||
|
return this.isProductValid() && this.isCustomerInfoValid();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates and submits the order
|
||||||
|
* @returns true if successful, false otherwise
|
||||||
|
*/
|
||||||
|
async createOrder(): Promise<boolean> {
|
||||||
|
const api = get(trpcApiStore);
|
||||||
|
if (!api) {
|
||||||
|
toast.error("API client not initialized");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const product = get(productStore);
|
||||||
|
if (!product || !customerInfoVM.customerInfo) {
|
||||||
|
toast.error("Missing required information", {
|
||||||
|
description: "Product or customer information is incomplete",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate price details from product
|
||||||
|
const priceDetails = calculateFinalPrices(
|
||||||
|
product,
|
||||||
|
customerInfoVM.customerInfo,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build the order payload
|
||||||
const parsed = createOrderPayloadModel.safeParse({
|
const parsed = createOrderPayloadModel.safeParse({
|
||||||
|
product: product,
|
||||||
|
productId: product.id,
|
||||||
|
customerInfo: customerInfoVM.customerInfo,
|
||||||
|
paymentInfo: billingDetailsVM.billingDetails,
|
||||||
orderModel: {
|
orderModel: {
|
||||||
basePrice,
|
...priceDetails,
|
||||||
displayPrice,
|
productId: product.id,
|
||||||
discountAmount,
|
currency: get(currencyStore).code,
|
||||||
flightTicketInfoId: 0,
|
|
||||||
emailAccountId: 0,
|
|
||||||
},
|
},
|
||||||
emailAccountInfo: this.accountInfo,
|
|
||||||
flightTicketInfo: this.ticketInfo!,
|
|
||||||
passengerInfos: passengerInfoVM.passengerInfos,
|
|
||||||
flowId: ckFlowVM.flowId,
|
flowId: ckFlowVM.flowId,
|
||||||
});
|
});
|
||||||
if (parsed.error) {
|
|
||||||
console.log(parsed.error.errors);
|
|
||||||
const msg = parsed.error.errors[0].message;
|
|
||||||
return toast.error(msg);
|
|
||||||
}
|
|
||||||
this.loading = true;
|
|
||||||
const out = await api.order.createOrder.mutate(parsed.data);
|
|
||||||
this.loading = false;
|
|
||||||
|
|
||||||
console.log(out);
|
if (parsed.error) {
|
||||||
|
console.error("Order payload validation error:", parsed.error.errors);
|
||||||
|
const err = parsed.error.errors[0];
|
||||||
|
toast.error("Invalid order data", {
|
||||||
|
description: `${err.path.join(".")}: ${err.message}`,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const out = await api.order.createOrder.mutate(parsed.data);
|
||||||
|
|
||||||
if (out.error) {
|
if (out.error) {
|
||||||
return toast.error(out.error.message, {
|
toast.error(out.error.message, {
|
||||||
description: out.error.userHint,
|
description: out.error.userHint,
|
||||||
});
|
});
|
||||||
}
|
return false;
|
||||||
if (!out.data) {
|
|
||||||
return toast.error("Order likely failed to create", {
|
|
||||||
description:
|
|
||||||
"Please try again, or contact us to resolve the issue",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success("Order created successfully, redirecting");
|
if (!out.data) {
|
||||||
|
toast.error("Order creation failed", {
|
||||||
|
description:
|
||||||
|
"Please try again, or contact support if the issue persists",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Order created successfully", {
|
||||||
|
description: "Please wait, redirecting...",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Redirect to success page after a short delay
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.replace("/");
|
window.location.href = `/order/success?uid=${out.data}`;
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Order creation error:", e);
|
||||||
|
toast.error("An unexpected error occurred", {
|
||||||
|
description: "Please try again later",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the view model state
|
||||||
|
*/
|
||||||
|
reset() {
|
||||||
|
this.orderStep = OrderCreationStep.CUSTOMER_INFO;
|
||||||
|
this.loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Title from "$lib/components/atoms/title.svelte";
|
|
||||||
import Icon from "$lib/components/atoms/icon.svelte";
|
import Icon from "$lib/components/atoms/icon.svelte";
|
||||||
import EmailIcon from "~icons/solar/letter-broken";
|
import Title from "$lib/components/atoms/title.svelte";
|
||||||
import TicketIcon from "~icons/solar/ticket-broken";
|
|
||||||
import CreditCardIcon from "~icons/solar/card-broken";
|
|
||||||
import { Badge } from "$lib/components/ui/badge";
|
|
||||||
import type { FullOrderModel } from "$lib/domains/order/data/entities";
|
|
||||||
import TicketLegsOverview from "$lib/domains/ticket/view/ticket/ticket-legs-overview.svelte";
|
|
||||||
import { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte";
|
import { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte";
|
||||||
|
import type { FullOrderModel } from "$lib/domains/order/data/entities";
|
||||||
|
import CreditCardIcon from "~icons/solar/card-broken";
|
||||||
|
|
||||||
let { order }: { order: FullOrderModel } = $props();
|
let { order }: { order: FullOrderModel } = $props();
|
||||||
|
|
||||||
@@ -16,37 +12,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
{#if order.emailAccount}
|
<span>TODO: SHOW PRODUCT DETSIL INFO HERE</span>
|
||||||
<!-- Email Account Info -->
|
|
||||||
<div class={cardStyle}>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Icon icon={EmailIcon} cls="w-5 h-5" />
|
|
||||||
<Title size="h5" color="black">Account Information</Title>
|
|
||||||
</div>
|
|
||||||
<p class="text-gray-800">{order.emailAccount.email}</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Flight Ticket Info -->
|
|
||||||
<div class={cardStyle}>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Icon icon={TicketIcon} cls="w-5 h-5" />
|
|
||||||
<Title size="h5" color="black">Flight Details</Title>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Badge variant="outline">
|
|
||||||
{order.flightTicketInfo.flightType}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="secondary">
|
|
||||||
{order.flightTicketInfo.cabinClass}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TicketLegsOverview data={order.flightTicketInfo} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class={cardStyle}>
|
<div class={cardStyle}>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import type { FullOrderModel } from "@pkg/logic/domains/order/data/entities";
|
|
||||||
import { trpcApiStore } from "$lib/stores/api";
|
import { trpcApiStore } from "$lib/stores/api";
|
||||||
import { get } from "svelte/store";
|
import type { FullOrderModel } from "@pkg/logic/domains/order/data/entities";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
|
||||||
export class TrackViewModel {
|
export class TrackViewModel {
|
||||||
pnr = $state("");
|
uid = $state("");
|
||||||
loading = $state(false);
|
loading = $state(false);
|
||||||
bookingData = $state<FullOrderModel | undefined>(undefined);
|
bookingData = $state<FullOrderModel | undefined>(undefined);
|
||||||
error = $state<string | null>(null);
|
error = $state<string | null>(null);
|
||||||
|
|
||||||
async searchBooking() {
|
async searchBooking() {
|
||||||
if (!this.pnr) {
|
if (!this.uid) {
|
||||||
this.error = "Please enter a PNR number";
|
this.error = "Please enter a PNR number";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -24,7 +24,7 @@ export class TrackViewModel {
|
|||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
|
||||||
const result = await api.order.findByPNR.query({ pnr: this.pnr });
|
const result = await api.order.findByUID.query({ uid: this.uid });
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
this.error = result.error.message;
|
this.error = result.error.message;
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export * from "@pkg/logic/domains/passengerinfo/data/entities";
|
|
||||||
@@ -1,209 +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 CustomerInfo,
|
|
||||||
type PassengerInfo,
|
|
||||||
} from "./entities";
|
|
||||||
|
|
||||||
export class PassengerInfoRepository {
|
|
||||||
private db: Database;
|
|
||||||
|
|
||||||
constructor(db: Database) {
|
|
||||||
this.db = db;
|
|
||||||
}
|
|
||||||
|
|
||||||
async createPassengerPii(payload: CustomerInfo): 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,214 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { cn } from "$lib/utils";
|
|
||||||
import type { BagSelectionInfo } from "$lib/domains/ticket/data/entities/create.entities";
|
|
||||||
import Icon from "$lib/components/atoms/icon.svelte";
|
|
||||||
import BagIcon from "~icons/lucide/briefcase";
|
|
||||||
import BackpackIcon from "~icons/solar/backpack-linear";
|
|
||||||
import SuitcaseIcon from "~icons/bi/suitcase2";
|
|
||||||
import CheckIcon from "~icons/solar/check-read-linear";
|
|
||||||
import { flightTicketStore } from "$lib/domains/ticket/data/store";
|
|
||||||
import { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte";
|
|
||||||
|
|
||||||
let { info = $bindable() }: { info: BagSelectionInfo } = $props();
|
|
||||||
|
|
||||||
// Average baseline price for checked bags when not specified
|
|
||||||
const BASELINE_CHECKED_BAG_PRICE = 30;
|
|
||||||
|
|
||||||
const getBagDetails = (type: string) => {
|
|
||||||
const bagsInfo = $flightTicketStore?.bagsInfo;
|
|
||||||
if (!bagsInfo) return null;
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case "personalBag":
|
|
||||||
return bagsInfo.details.personalBags;
|
|
||||||
case "handBag":
|
|
||||||
return bagsInfo.details.handBags;
|
|
||||||
case "checkedBag":
|
|
||||||
return bagsInfo.details.checkedBags;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDimensions = (details: any) => {
|
|
||||||
if (!details?.dimensions) return "";
|
|
||||||
const { length, width, height } = details.dimensions;
|
|
||||||
if (!length || !width || !height) return "";
|
|
||||||
return `${length} x ${width} x ${height} cm`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const bagTypes = [
|
|
||||||
{
|
|
||||||
icon: BackpackIcon,
|
|
||||||
type: "personalBag",
|
|
||||||
label: "Personal Bag",
|
|
||||||
description: "Small item (purse, laptop bag)",
|
|
||||||
included: true,
|
|
||||||
disabled: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: SuitcaseIcon,
|
|
||||||
type: "handBag",
|
|
||||||
label: "Cabin Bag",
|
|
||||||
description: "Carry-on luggage",
|
|
||||||
included: false,
|
|
||||||
disabled: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: BagIcon,
|
|
||||||
type: "checkedBag",
|
|
||||||
label: "Checked Bag",
|
|
||||||
description: "Check-in luggage",
|
|
||||||
included: false,
|
|
||||||
disabled: false, // We'll always show this option
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function toggleBag(bagType: string) {
|
|
||||||
if (bagType === "handBag") {
|
|
||||||
info.handBags = info.handBags === 1 ? 0 : 1;
|
|
||||||
} else if (
|
|
||||||
bagType === "checkedBag" &&
|
|
||||||
$flightTicketStore?.bagsInfo.hasCheckedBagsSupport
|
|
||||||
) {
|
|
||||||
info.checkedBags = info.checkedBags === 1 ? 0 : 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isBagSelected(bagType: string): boolean {
|
|
||||||
if (bagType === "personalBag") return true;
|
|
||||||
if (bagType === "handBag") return info.handBags === 1;
|
|
||||||
if (bagType === "checkedBag")
|
|
||||||
return (
|
|
||||||
info.checkedBags === 1 &&
|
|
||||||
$flightTicketStore?.bagsInfo.hasCheckedBagsSupport
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBagPrice(bagType: string): {
|
|
||||||
price: number;
|
|
||||||
isEstimate: boolean;
|
|
||||||
} {
|
|
||||||
const bagDetails = getBagDetails(bagType);
|
|
||||||
|
|
||||||
if (bagType === "checkedBag") {
|
|
||||||
if (bagDetails && typeof bagDetails.price === "number") {
|
|
||||||
return { price: bagDetails.price, isEstimate: false };
|
|
||||||
}
|
|
||||||
return { price: BASELINE_CHECKED_BAG_PRICE, isEstimate: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
price: bagDetails?.price ?? 0,
|
|
||||||
isEstimate: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
{#each bagTypes as bag}
|
|
||||||
{@const bagDetails = getBagDetails(bag.type)}
|
|
||||||
{@const isAvailable = true}
|
|
||||||
{@const isDisabled = false}
|
|
||||||
<!-- {@const isAvailable =
|
|
||||||
bag.type !== "checkedBag" ||
|
|
||||||
$flightTicketStore?.bagsInfo.hasCheckedBagsSupport}
|
|
||||||
{@const isDisabled =
|
|
||||||
bag.disabled || (bag.type === "checkedBag" && !isAvailable)} -->
|
|
||||||
<!-- {@const { price, isEstimate } = getBagPrice(bag.type)} -->
|
|
||||||
<!-- {@const formattedPrice = convertAndFormatCurrency(price)} -->
|
|
||||||
|
|
||||||
<div
|
|
||||||
class={cn(
|
|
||||||
"relative flex flex-col items-start gap-4 rounded-lg border-2 p-4 transition-all sm:flex-row sm:items-center",
|
|
||||||
isBagSelected(bag.type)
|
|
||||||
? "border-primary bg-primary/5"
|
|
||||||
: "border-gray-200",
|
|
||||||
!isDisabled &&
|
|
||||||
!bag.included &&
|
|
||||||
"cursor-pointer hover:border-primary/50",
|
|
||||||
isDisabled && "opacity-70",
|
|
||||||
)}
|
|
||||||
role={isDisabled || bag.included ? "presentation" : "button"}
|
|
||||||
onclick={() => !isDisabled && !bag.included && toggleBag(bag.type)}
|
|
||||||
onkeydown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
!isDisabled && !bag.included && toggleBag(bag.type);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="shrink-0">
|
|
||||||
<div
|
|
||||||
class={cn(
|
|
||||||
"grid h-10 w-10 place-items-center rounded-full sm:h-12 sm:w-12",
|
|
||||||
isBagSelected(bag.type)
|
|
||||||
? "bg-primary text-white"
|
|
||||||
: "bg-gray-100 text-gray-500",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon icon={bag.icon} cls="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col gap-1">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="font-medium">{bag.label}</span>
|
|
||||||
{#if bag.included}
|
|
||||||
<span
|
|
||||||
class="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
|
|
||||||
>
|
|
||||||
Included
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if bag.type === "checkedBag" && !isAvailable}
|
|
||||||
<span
|
|
||||||
class="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800"
|
|
||||||
>
|
|
||||||
Not available for this flight
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<span class="text-sm text-gray-500">{bag.description}</span>
|
|
||||||
{#if bagDetails}
|
|
||||||
<div
|
|
||||||
class="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500"
|
|
||||||
>
|
|
||||||
{#if bagDetails.weight > 0}
|
|
||||||
<span>
|
|
||||||
Up to {bagDetails.weight}{bagDetails.unit}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{#if formatDimensions(bagDetails)}
|
|
||||||
<span>{formatDimensions(bagDetails)}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ml-auto flex items-center">
|
|
||||||
<!-- {#if price > 0}
|
|
||||||
<div class="flex flex-col items-end">
|
|
||||||
<span class="font-medium text-primary"
|
|
||||||
>+{formattedPrice}</span
|
|
||||||
>
|
|
||||||
{#if isEstimate}
|
|
||||||
<span class="text-xs text-gray-500"
|
|
||||||
>Estimated price</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else if !bag.included} -->
|
|
||||||
<span class="text-xs font-medium text-emerald-800">Free</span>
|
|
||||||
<!-- {/if} -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if isBagSelected(bag.type)}
|
|
||||||
<div class="absolute right-4 top-4">
|
|
||||||
<Icon icon={CheckIcon} cls="h-5 w-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
@@ -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 { CustomerInfo } from "$lib/domains/ticket/data/entities/create.entities";
|
|
||||||
import { Gender } from "$lib/domains/ticket/data/entities/index";
|
|
||||||
import { PHONE_COUNTRY_CODES } from "@pkg/logic/core/data/phonecc";
|
|
||||||
import { passengerInfoVM } from "./passenger.info.vm.svelte";
|
|
||||||
|
|
||||||
let { info = $bindable(), idx }: { info: CustomerInfo; idx: number } =
|
|
||||||
$props();
|
|
||||||
|
|
||||||
const genderOpts = [
|
|
||||||
{ label: capitalize(Gender.Male), value: Gender.Male },
|
|
||||||
{ label: capitalize(Gender.Female), value: Gender.Female },
|
|
||||||
{ label: capitalize(Gender.Other), value: Gender.Other },
|
|
||||||
] as SelectOption[];
|
|
||||||
|
|
||||||
function onSubmit(e: SubmitEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
passengerInfoVM.validatePII(info, idx);
|
|
||||||
}
|
|
||||||
|
|
||||||
let validationTimeout = $state(undefined as undefined | NodeJS.Timer);
|
|
||||||
|
|
||||||
function debounceValidate() {
|
|
||||||
if (validationTimeout) {
|
|
||||||
clearTimeout(validationTimeout);
|
|
||||||
}
|
|
||||||
validationTimeout = setTimeout(() => {
|
|
||||||
passengerInfoVM.validatePII(info, idx);
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<form action="#" class="flex w-full flex-col gap-4" onsubmit={onSubmit}>
|
|
||||||
<div class="flex flex-col gap-4 md:flex-row">
|
|
||||||
<LabelWrapper
|
|
||||||
label="First Name"
|
|
||||||
error={passengerInfoVM.piiErrors[idx].firstName}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
placeholder="First Name"
|
|
||||||
bind:value={info.firstName}
|
|
||||||
required
|
|
||||||
oninput={() => debounceValidate()}
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
|
|
||||||
<LabelWrapper
|
|
||||||
label="Middle Name"
|
|
||||||
error={passengerInfoVM.piiErrors[idx].middleName}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
placeholder="Middle Name"
|
|
||||||
bind:value={info.middleName}
|
|
||||||
oninput={() => debounceValidate()}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
|
|
||||||
<LabelWrapper
|
|
||||||
label="Last Name"
|
|
||||||
error={passengerInfoVM.piiErrors[idx].lastName}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
placeholder="Last Name"
|
|
||||||
bind:value={info.lastName}
|
|
||||||
required
|
|
||||||
oninput={() => debounceValidate()}
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<LabelWrapper label="Email" error={passengerInfoVM.piiErrors[idx].email}>
|
|
||||||
<Input
|
|
||||||
placeholder="Email"
|
|
||||||
bind:value={info.email}
|
|
||||||
type="email"
|
|
||||||
oninput={() => debounceValidate()}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-4 md:flex-row lg:flex-col xl:flex-row">
|
|
||||||
<LabelWrapper
|
|
||||||
label="Phone Number"
|
|
||||||
error={passengerInfoVM.piiErrors[idx].phoneNumber}
|
|
||||||
>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Select.Root
|
|
||||||
type="single"
|
|
||||||
required
|
|
||||||
onValueChange={(code) => {
|
|
||||||
info.phoneCountryCode = code;
|
|
||||||
}}
|
|
||||||
name="phoneCode"
|
|
||||||
>
|
|
||||||
<Select.Trigger class="w-20">
|
|
||||||
{#if info.phoneCountryCode}
|
|
||||||
{info.phoneCountryCode}
|
|
||||||
{:else}
|
|
||||||
Select
|
|
||||||
{/if}
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Content>
|
|
||||||
{#each PHONE_COUNTRY_CODES as { country, phoneCode }}
|
|
||||||
<Select.Item value={phoneCode}>
|
|
||||||
<span class="flex items-center gap-2">
|
|
||||||
{phoneCode} ({country})
|
|
||||||
</span>
|
|
||||||
</Select.Item>
|
|
||||||
{/each}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
placeholder="Phone Number"
|
|
||||||
type="tel"
|
|
||||||
bind:value={info.phoneNumber}
|
|
||||||
required
|
|
||||||
oninput={() => debounceValidate()}
|
|
||||||
class="flex-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</LabelWrapper>
|
|
||||||
|
|
||||||
<LabelWrapper
|
|
||||||
label="Passport Expiry"
|
|
||||||
error={passengerInfoVM.piiErrors[idx].passportExpiry}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
placeholder="Passport Expiry"
|
|
||||||
value={info.passportExpiry}
|
|
||||||
type="date"
|
|
||||||
required
|
|
||||||
oninput={(v) => {
|
|
||||||
// @ts-ignore
|
|
||||||
info.passportExpiry = v.target.value;
|
|
||||||
debounceValidate();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
<LabelWrapper
|
|
||||||
label="Passport/ID No"
|
|
||||||
error={passengerInfoVM.piiErrors[idx].passportNo}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
placeholder="Passport or ID card no."
|
|
||||||
bind:value={info.passportNo}
|
|
||||||
minlength={1}
|
|
||||||
maxlength={20}
|
|
||||||
required
|
|
||||||
oninput={() => debounceValidate()}
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-4 md:flex-row">
|
|
||||||
<LabelWrapper
|
|
||||||
label="Nationality"
|
|
||||||
error={passengerInfoVM.piiErrors[idx].nationality}
|
|
||||||
>
|
|
||||||
<Select.Root
|
|
||||||
type="single"
|
|
||||||
required
|
|
||||||
onValueChange={(e) => {
|
|
||||||
info.nationality = e;
|
|
||||||
debounceValidate();
|
|
||||||
}}
|
|
||||||
name="role"
|
|
||||||
>
|
|
||||||
<Select.Trigger class="w-full">
|
|
||||||
{capitalize(
|
|
||||||
info.nationality.length > 0 ? info.nationality : "Select",
|
|
||||||
)}
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Content>
|
|
||||||
{#each COUNTRIES_SELECT as country}
|
|
||||||
<Select.Item value={country.value}>
|
|
||||||
{country.label}
|
|
||||||
</Select.Item>
|
|
||||||
{/each}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
</LabelWrapper>
|
|
||||||
<LabelWrapper
|
|
||||||
label="Gender"
|
|
||||||
error={passengerInfoVM.piiErrors[idx].gender}
|
|
||||||
>
|
|
||||||
<Select.Root
|
|
||||||
type="single"
|
|
||||||
required
|
|
||||||
onValueChange={(e) => {
|
|
||||||
info.gender = e as Gender;
|
|
||||||
debounceValidate();
|
|
||||||
}}
|
|
||||||
name="role"
|
|
||||||
>
|
|
||||||
<Select.Trigger class="w-full">
|
|
||||||
{capitalize(info.gender.length > 0 ? info.gender : "Select")}
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Content>
|
|
||||||
{#each genderOpts as gender}
|
|
||||||
<Select.Item value={gender.value}>
|
|
||||||
{gender.label}
|
|
||||||
</Select.Item>
|
|
||||||
{/each}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
</LabelWrapper>
|
|
||||||
|
|
||||||
<LabelWrapper
|
|
||||||
label="Date of Birth"
|
|
||||||
error={passengerInfoVM.piiErrors[idx].dob}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
placeholder="Date of Birth"
|
|
||||||
bind:value={info.dob}
|
|
||||||
type="date"
|
|
||||||
required
|
|
||||||
oninput={() => debounceValidate()}
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- and now for the address info - country, state, city, zip code, address and address 2 -->
|
|
||||||
|
|
||||||
<Title size="h5">Address Info</Title>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-4 md:flex-row">
|
|
||||||
<LabelWrapper
|
|
||||||
label="Country"
|
|
||||||
error={passengerInfoVM.piiErrors[idx].country}
|
|
||||||
>
|
|
||||||
<Select.Root
|
|
||||||
type="single"
|
|
||||||
required
|
|
||||||
onValueChange={(e) => {
|
|
||||||
info.country = e;
|
|
||||||
debounceValidate();
|
|
||||||
}}
|
|
||||||
name="role"
|
|
||||||
>
|
|
||||||
<Select.Trigger class="w-full">
|
|
||||||
{capitalize(
|
|
||||||
info.country.length > 0 ? info.country : "Select",
|
|
||||||
)}
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Content>
|
|
||||||
{#each COUNTRIES_SELECT as country}
|
|
||||||
<Select.Item value={country.value}>
|
|
||||||
{country.label}
|
|
||||||
</Select.Item>
|
|
||||||
{/each}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
</LabelWrapper>
|
|
||||||
|
|
||||||
<LabelWrapper label="State" error={passengerInfoVM.piiErrors[idx].state}>
|
|
||||||
<Input
|
|
||||||
placeholder="State"
|
|
||||||
bind:value={info.state}
|
|
||||||
required
|
|
||||||
oninput={() => debounceValidate()}
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-4 md:flex-row">
|
|
||||||
<LabelWrapper label="City" error={passengerInfoVM.piiErrors[idx].city}>
|
|
||||||
<Input
|
|
||||||
placeholder="City"
|
|
||||||
bind:value={info.city}
|
|
||||||
required
|
|
||||||
minlength={1}
|
|
||||||
maxlength={80}
|
|
||||||
oninput={() => debounceValidate()}
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
|
|
||||||
<LabelWrapper
|
|
||||||
label="Zip Code"
|
|
||||||
error={passengerInfoVM.piiErrors[idx].zipCode}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
placeholder="Zip Code"
|
|
||||||
bind:value={info.zipCode}
|
|
||||||
required
|
|
||||||
minlength={1}
|
|
||||||
oninput={() => debounceValidate()}
|
|
||||||
maxlength={12}
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<LabelWrapper label="Address" error={passengerInfoVM.piiErrors[idx].address}>
|
|
||||||
<Input
|
|
||||||
placeholder="Address"
|
|
||||||
bind:value={info.address}
|
|
||||||
required
|
|
||||||
minlength={1}
|
|
||||||
maxlength={128}
|
|
||||||
oninput={() => debounceValidate()}
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
|
|
||||||
<LabelWrapper
|
|
||||||
label="Address 2"
|
|
||||||
error={passengerInfoVM.piiErrors[idx].address2}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
placeholder="Address 2"
|
|
||||||
bind:value={info.address2}
|
|
||||||
required
|
|
||||||
minlength={1}
|
|
||||||
maxlength={128}
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
</form>
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
import {
|
|
||||||
customerInfoModel,
|
|
||||||
type BagSelectionInfo,
|
|
||||||
type CustomerInfo,
|
|
||||||
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 CustomerInfo, 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 CustomerInfo;
|
|
||||||
|
|
||||||
const _defaultPiiObj = {
|
|
||||||
firstName: "",
|
|
||||||
middleName: "",
|
|
||||||
lastName: "",
|
|
||||||
email: "",
|
|
||||||
phoneCountryCode: "",
|
|
||||||
phoneNumber: "",
|
|
||||||
passportNo: "",
|
|
||||||
passportExpiry: "",
|
|
||||||
nationality: "",
|
|
||||||
gender: Gender.Male,
|
|
||||||
dob: "",
|
|
||||||
country: "",
|
|
||||||
state: "",
|
|
||||||
city: "",
|
|
||||||
zipCode: "",
|
|
||||||
address: "",
|
|
||||||
address2: "",
|
|
||||||
} as CustomerInfo;
|
|
||||||
|
|
||||||
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: CustomerInfo, 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 CustomerInfo;
|
|
||||||
acc[path] = curr.message;
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<keyof CustomerInfo, string>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isPIIValid(): boolean {
|
|
||||||
return this.piiErrors.every(
|
|
||||||
(errorObj) => Object.keys(errorObj).length === 0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const passengerInfoVM = new PassengerInfoViewModel();
|
|
||||||
1
apps/frontend/src/lib/domains/product/data.ts
Normal file
1
apps/frontend/src/lib/domains/product/data.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "@pkg/logic/domains/product/data";
|
||||||
1
apps/frontend/src/lib/domains/product/repository.ts
Normal file
1
apps/frontend/src/lib/domains/product/repository.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "@pkg/logic/domains/product/repository";
|
||||||
10
apps/frontend/src/lib/domains/product/router.ts
Normal file
10
apps/frontend/src/lib/domains/product/router.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { protectedProcedure } from "$lib/server/trpc/t";
|
||||||
|
import { createTRPCRouter } from "$lib/trpc/t";
|
||||||
|
import { getProductUseCases } from "./usecases";
|
||||||
|
|
||||||
|
export const productRouter = createTRPCRouter({
|
||||||
|
getAllProducts: protectedProcedure.query(async ({}) => {
|
||||||
|
const controller = getProductUseCases();
|
||||||
|
return controller.getAllProducts();
|
||||||
|
}),
|
||||||
|
});
|
||||||
4
apps/frontend/src/lib/domains/product/store.ts
Normal file
4
apps/frontend/src/lib/domains/product/store.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { writable } from "svelte/store";
|
||||||
|
import type { ProductModel } from "./data";
|
||||||
|
|
||||||
|
export const productStore = writable<ProductModel | null>(null);
|
||||||
1
apps/frontend/src/lib/domains/product/usecases.ts
Normal file
1
apps/frontend/src/lib/domains/product/usecases.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "@pkg/logic/domains/product/usecases";
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from "$lib/domains/passengerinfo/data/entities";
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from "@pkg/logic/domains/ticket/data/entities/enums";
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from "@pkg/logic/domains/ticket/data/entities/index";
|
|
||||||
@@ -1,363 +0,0 @@
|
|||||||
import { round } from "$lib/core/num.utils";
|
|
||||||
import {
|
|
||||||
getCKUseCases,
|
|
||||||
type CheckoutFlowUseCases,
|
|
||||||
} from "$lib/domains/ckflow/domain/usecases";
|
|
||||||
import { and, arrayContains, eq, or, type Database } from "@pkg/db";
|
|
||||||
import { flightTicketInfo, order } from "@pkg/db/schema";
|
|
||||||
import { getError, Logger } from "@pkg/logger";
|
|
||||||
import { ERROR_CODES, type Result } from "@pkg/result";
|
|
||||||
import {
|
|
||||||
flightPriceDetailsModel,
|
|
||||||
flightTicketModel,
|
|
||||||
TicketType,
|
|
||||||
type FlightPriceDetails,
|
|
||||||
type FlightTicket,
|
|
||||||
type TicketSearchDTO,
|
|
||||||
} from "./entities";
|
|
||||||
import type { ScrapedTicketsDataSource } from "./scrape.data.source";
|
|
||||||
|
|
||||||
export class TicketRepository {
|
|
||||||
private db: Database;
|
|
||||||
private ckUseCases: CheckoutFlowUseCases;
|
|
||||||
private scraper: ScrapedTicketsDataSource;
|
|
||||||
|
|
||||||
constructor(db: Database, scraper: ScrapedTicketsDataSource) {
|
|
||||||
this.db = db;
|
|
||||||
this.scraper = scraper;
|
|
||||||
this.ckUseCases = getCKUseCases();
|
|
||||||
}
|
|
||||||
|
|
||||||
async getCheckoutUrlByRefOId(refOId: number): Promise<Result<string>> {
|
|
||||||
try {
|
|
||||||
const _o = await this.db.query.order.findFirst({
|
|
||||||
where: eq(order.id, refOId),
|
|
||||||
with: {
|
|
||||||
flightTicketInfo: {
|
|
||||||
columns: { id: true, checkoutUrl: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const chktUrl = _o?.flightTicketInfo?.checkoutUrl;
|
|
||||||
console.log(_o);
|
|
||||||
return { data: chktUrl };
|
|
||||||
} catch (e) {
|
|
||||||
return {
|
|
||||||
error: getError(
|
|
||||||
{
|
|
||||||
code: ERROR_CODES.NOT_FOUND_ERROR,
|
|
||||||
message: "Failed to fetch ticket url",
|
|
||||||
userHint: "Please try again later",
|
|
||||||
detail: "An error occurred while fetching ticket url",
|
|
||||||
},
|
|
||||||
e,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getTicketIdbyRefOid(refOid: number): Promise<Result<number>> {
|
|
||||||
try {
|
|
||||||
const out = await this.db.query.flightTicketInfo.findFirst({
|
|
||||||
where: arrayContains(flightTicketInfo.refOIds, [refOid]),
|
|
||||||
columns: { id: true },
|
|
||||||
});
|
|
||||||
if (out && out.id) {
|
|
||||||
return { data: out?.id };
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
} catch (e) {
|
|
||||||
Logger.debug(refOid);
|
|
||||||
return {
|
|
||||||
error: getError(
|
|
||||||
{
|
|
||||||
code: ERROR_CODES.DATABASE_ERROR,
|
|
||||||
message: "Failed to lookup ticket",
|
|
||||||
detail: "A database error occured while getting ticket by id",
|
|
||||||
userHint: "Contact us to resolve this issue",
|
|
||||||
actionable: false,
|
|
||||||
},
|
|
||||||
e,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async searchForTickets(
|
|
||||||
payload: TicketSearchDTO,
|
|
||||||
): Promise<Result<FlightTicket[]>> {
|
|
||||||
try {
|
|
||||||
let out = await this.scraper.searchForTickets(payload);
|
|
||||||
if (out.error || (out.data && out.data.length < 1)) {
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
error: getError(
|
|
||||||
{
|
|
||||||
code: ERROR_CODES.NETWORK_ERROR,
|
|
||||||
message: "Failed to search for tickets",
|
|
||||||
detail: "Could not fetch tickets for the given payload",
|
|
||||||
userHint: "Please try again later",
|
|
||||||
error: err,
|
|
||||||
actionable: false,
|
|
||||||
},
|
|
||||||
err,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getTicketById(id: number): Promise<Result<FlightTicket>> {
|
|
||||||
const out = await this.db.query.flightTicketInfo.findFirst({
|
|
||||||
where: eq(flightTicketInfo.id, id),
|
|
||||||
});
|
|
||||||
if (!out) {
|
|
||||||
return {
|
|
||||||
error: getError({
|
|
||||||
code: ERROR_CODES.NOT_FOUND_ERROR,
|
|
||||||
message: "Ticket not found",
|
|
||||||
userHint:
|
|
||||||
"Please check if the selected ticket is correct, or try again",
|
|
||||||
detail: "The ticket is not found by the id provided",
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const parsed = flightTicketModel.safeParse(out);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return {
|
|
||||||
error: getError(
|
|
||||||
{
|
|
||||||
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
|
|
||||||
message: "Failed to parse ticket",
|
|
||||||
userHint: "Please try again",
|
|
||||||
detail: "Failed to parse ticket",
|
|
||||||
},
|
|
||||||
JSON.stringify(parsed.error.errors),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return { data: parsed.data };
|
|
||||||
}
|
|
||||||
|
|
||||||
async getTicketIdByInfo(info: {
|
|
||||||
ticketId: string;
|
|
||||||
arrival: string;
|
|
||||||
departure: string;
|
|
||||||
cabinClass: string;
|
|
||||||
departureDate: string;
|
|
||||||
returnDate: string;
|
|
||||||
}): Promise<Result<number>> {
|
|
||||||
try {
|
|
||||||
const out = await this.db.query.flightTicketInfo.findFirst({
|
|
||||||
where: or(
|
|
||||||
eq(flightTicketInfo.ticketId, info.ticketId),
|
|
||||||
and(
|
|
||||||
eq(flightTicketInfo.arrival, info.arrival),
|
|
||||||
eq(flightTicketInfo.departure, info.departure),
|
|
||||||
eq(flightTicketInfo.cabinClass, info.cabinClass),
|
|
||||||
eq(
|
|
||||||
flightTicketInfo.departureDate,
|
|
||||||
new Date(info.departureDate),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
columns: { id: true },
|
|
||||||
});
|
|
||||||
if (out && out.id) {
|
|
||||||
return { data: out?.id };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {};
|
|
||||||
} catch (e) {
|
|
||||||
Logger.debug(info);
|
|
||||||
return {
|
|
||||||
error: getError(
|
|
||||||
{
|
|
||||||
code: ERROR_CODES.DATABASE_ERROR,
|
|
||||||
message: "Failed to lookup ticket",
|
|
||||||
detail: "A database error occured while getting ticket by id",
|
|
||||||
userHint: "Contact us to resolve this issue",
|
|
||||||
actionable: false,
|
|
||||||
},
|
|
||||||
e,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async createTicket(
|
|
||||||
payload: FlightTicket,
|
|
||||||
isCache = false,
|
|
||||||
): Promise<Result<number>> {
|
|
||||||
try {
|
|
||||||
let rd = new Date();
|
|
||||||
if (
|
|
||||||
payload.returnDate &&
|
|
||||||
payload.returnDate.length > 0 &&
|
|
||||||
payload.flightType !== TicketType.OneWay
|
|
||||||
) {
|
|
||||||
rd = new Date(payload.returnDate);
|
|
||||||
}
|
|
||||||
const out = await this.db
|
|
||||||
.insert(flightTicketInfo)
|
|
||||||
.values({
|
|
||||||
ticketId: payload.ticketId,
|
|
||||||
flightType: payload.flightType,
|
|
||||||
|
|
||||||
arrival: payload.arrival,
|
|
||||||
departure: payload.departure,
|
|
||||||
cabinClass: payload.cabinClass,
|
|
||||||
departureDate: new Date(payload.departureDate),
|
|
||||||
returnDate: rd,
|
|
||||||
|
|
||||||
priceDetails: payload.priceDetails,
|
|
||||||
passengerCounts: payload.passengerCounts,
|
|
||||||
bagsInfo: payload.bagsInfo,
|
|
||||||
|
|
||||||
lastAvailable: payload.lastAvailable,
|
|
||||||
|
|
||||||
flightIteneraries: payload.flightIteneraries,
|
|
||||||
|
|
||||||
dates: payload.dates,
|
|
||||||
|
|
||||||
checkoutUrl: payload.checkoutUrl ?? "",
|
|
||||||
refOIds: payload.refOIds ?? [],
|
|
||||||
|
|
||||||
refundable: payload.refundable ?? false,
|
|
||||||
isCache: isCache ? true : (payload.isCache ?? isCache),
|
|
||||||
|
|
||||||
shareId: payload.shareId,
|
|
||||||
})
|
|
||||||
.returning({ id: flightTicketInfo.id })
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
if (!out || out.length === 0) {
|
|
||||||
Logger.error("Failed to create ticket");
|
|
||||||
Logger.debug(out);
|
|
||||||
Logger.debug(payload);
|
|
||||||
return {
|
|
||||||
error: getError({
|
|
||||||
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
|
|
||||||
message: "Failed to create ticket",
|
|
||||||
userHint: "Please try again",
|
|
||||||
detail: "Failed to create ticket",
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { data: out[0].id };
|
|
||||||
} catch (e) {
|
|
||||||
return {
|
|
||||||
error: getError(
|
|
||||||
{
|
|
||||||
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
|
|
||||||
message: "An error occured while creating ticket",
|
|
||||||
userHint: "Please try again later",
|
|
||||||
actionable: false,
|
|
||||||
detail: "An error occured while creating ticket",
|
|
||||||
},
|
|
||||||
e,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateTicketPrices(
|
|
||||||
tid: number,
|
|
||||||
payload: FlightPriceDetails,
|
|
||||||
): Promise<Result<FlightPriceDetails>> {
|
|
||||||
const cond = eq(flightTicketInfo.id, tid);
|
|
||||||
const currInfo = await this.db.query.flightTicketInfo.findFirst({
|
|
||||||
where: cond,
|
|
||||||
columns: { priceDetails: true },
|
|
||||||
});
|
|
||||||
if (!currInfo) {
|
|
||||||
return {
|
|
||||||
error: getError({
|
|
||||||
code: ERROR_CODES.NOT_FOUND_ERROR,
|
|
||||||
message: "Could not find ticket",
|
|
||||||
userHint: "Please try again later",
|
|
||||||
detail: "Could not fin the ticket by the provided id",
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const info = flightPriceDetailsModel.parse(currInfo.priceDetails);
|
|
||||||
Logger.info("Updating the price details from:");
|
|
||||||
Logger.debug(info);
|
|
||||||
const discountAmt = round(info.basePrice - payload.displayPrice);
|
|
||||||
const newInfo = {
|
|
||||||
...info,
|
|
||||||
discountAmount: discountAmt,
|
|
||||||
displayPrice: payload.displayPrice,
|
|
||||||
} as FlightPriceDetails;
|
|
||||||
Logger.info("... to:");
|
|
||||||
Logger.debug(newInfo);
|
|
||||||
const out = await this.db
|
|
||||||
.update(flightTicketInfo)
|
|
||||||
.set({ priceDetails: newInfo })
|
|
||||||
.where(cond)
|
|
||||||
.execute();
|
|
||||||
Logger.info("Updated the price info");
|
|
||||||
Logger.debug(out);
|
|
||||||
return { data: newInfo };
|
|
||||||
}
|
|
||||||
|
|
||||||
async uncacheAndSaveTicket(tid: number): Promise<Result<number>> {
|
|
||||||
try {
|
|
||||||
const out = await this.db
|
|
||||||
.update(flightTicketInfo)
|
|
||||||
.set({ isCache: false })
|
|
||||||
.where(eq(flightTicketInfo.id, tid))
|
|
||||||
.returning({ id: flightTicketInfo.id })
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
if (!out || out.length === 0) {
|
|
||||||
Logger.error("Failed to uncache ticket");
|
|
||||||
Logger.debug(out);
|
|
||||||
Logger.debug(tid);
|
|
||||||
return {
|
|
||||||
error: getError({
|
|
||||||
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
|
|
||||||
message: "Failed to process ticket at this moment",
|
|
||||||
userHint: "Please try again later",
|
|
||||||
detail: "Failed to uncache ticket",
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { data: out[0].id };
|
|
||||||
} catch (e) {
|
|
||||||
return {
|
|
||||||
error: getError(
|
|
||||||
{
|
|
||||||
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
|
|
||||||
message: "An error occured while uncaching ticket",
|
|
||||||
userHint: "Please try again later",
|
|
||||||
actionable: false,
|
|
||||||
detail: "An error occured while uncaching ticket",
|
|
||||||
},
|
|
||||||
e,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async setRefOIdsForTicket(
|
|
||||||
tid: number,
|
|
||||||
oids: number[],
|
|
||||||
): Promise<Result<boolean>> {
|
|
||||||
Logger.info(`Setting refOIds(${oids}) for ticket ${tid}`);
|
|
||||||
const out = await this.db
|
|
||||||
.update(flightTicketInfo)
|
|
||||||
.set({ refOIds: oids })
|
|
||||||
.where(eq(flightTicketInfo.id, tid))
|
|
||||||
.execute();
|
|
||||||
return { data: out.length > 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
async areCheckoutAlreadyLiveForOrder(refOids: number[]): Promise<boolean> {
|
|
||||||
return this.ckUseCases.areCheckoutAlreadyLiveForOrder(refOids);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { ERROR_CODES, type Result } from "@pkg/result";
|
|
||||||
import type { FlightTicket, TicketSearchDTO } from "./entities";
|
|
||||||
import { betterFetch } from "@better-fetch/fetch";
|
|
||||||
import { getError, Logger } from "@pkg/logger";
|
|
||||||
|
|
||||||
export class ScrapedTicketsDataSource {
|
|
||||||
scraperUrl: string;
|
|
||||||
apiKey: string;
|
|
||||||
|
|
||||||
constructor(scraperUrl: string, apiKey: string) {
|
|
||||||
this.scraperUrl = scraperUrl;
|
|
||||||
this.apiKey = apiKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
async searchForTickets(
|
|
||||||
payload: TicketSearchDTO,
|
|
||||||
): Promise<Result<FlightTicket[]>> {
|
|
||||||
const { data, error } = await betterFetch<Result<FlightTicket[]>>(
|
|
||||||
`${this.scraperUrl}/api/v1/tickets/search`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${this.apiKey}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return {
|
|
||||||
error: getError(
|
|
||||||
{
|
|
||||||
code: ERROR_CODES.NETWORK_ERROR,
|
|
||||||
message: "Failed to search for tickets",
|
|
||||||
detail: "Could not fetch tickets for the given payload",
|
|
||||||
userHint: "Please try again later",
|
|
||||||
error: error,
|
|
||||||
actionable: false,
|
|
||||||
},
|
|
||||||
JSON.stringify(error, null, 4),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.info(`Returning ${data.data?.length} tickets`);
|
|
||||||
|
|
||||||
return { data: data.data ?? [] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { writable } from "svelte/store";
|
|
||||||
import {
|
|
||||||
CabinClass,
|
|
||||||
TicketType,
|
|
||||||
type FlightTicket,
|
|
||||||
type TicketSearchPayload,
|
|
||||||
} from "./entities";
|
|
||||||
import { nanoid } from "nanoid";
|
|
||||||
|
|
||||||
export const flightTicketStore = writable<FlightTicket>();
|
|
||||||
|
|
||||||
export const ticketSearchStore = writable<TicketSearchPayload>({
|
|
||||||
loadMore: false,
|
|
||||||
sessionId: nanoid(),
|
|
||||||
ticketType: TicketType.Return,
|
|
||||||
cabinClass: CabinClass.Economy,
|
|
||||||
passengerCounts: { adults: 1, children: 0 },
|
|
||||||
departure: "",
|
|
||||||
arrival: "",
|
|
||||||
departureDate: "",
|
|
||||||
returnDate: "",
|
|
||||||
meta: {},
|
|
||||||
couponCode: "",
|
|
||||||
});
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
import { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte";
|
|
||||||
import { db } from "@pkg/db";
|
|
||||||
import { getError, Logger } from "@pkg/logger";
|
|
||||||
import { DiscountType, type CouponModel } from "@pkg/logic/domains/coupon/data";
|
|
||||||
import { ERROR_CODES, type Result } from "@pkg/result";
|
|
||||||
import type {
|
|
||||||
FlightPriceDetails,
|
|
||||||
FlightTicket,
|
|
||||||
TicketSearchPayload,
|
|
||||||
} from "../data/entities";
|
|
||||||
import { TicketRepository } from "../data/repository";
|
|
||||||
import { ScrapedTicketsDataSource } from "../data/scrape.data.source";
|
|
||||||
|
|
||||||
export class TicketController {
|
|
||||||
private repo: TicketRepository;
|
|
||||||
private TICKET_SCRAPERS = ["kiwi"];
|
|
||||||
|
|
||||||
constructor(repo: TicketRepository) {
|
|
||||||
this.repo = repo;
|
|
||||||
}
|
|
||||||
|
|
||||||
async searchForTickets(
|
|
||||||
payload: TicketSearchPayload,
|
|
||||||
coupon: CouponModel | undefined,
|
|
||||||
): Promise<Result<FlightTicket[]>> {
|
|
||||||
const result = await this.repo.searchForTickets({
|
|
||||||
sessionId: payload.sessionId,
|
|
||||||
ticketSearchPayload: payload,
|
|
||||||
providers: this.TICKET_SCRAPERS,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.error || !result.data) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!coupon) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.info(`Auto-applying coupon ${coupon.code} to all search results`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: result.data.map((ticket) => {
|
|
||||||
return this.applyDiscountToTicket(ticket, coupon);
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private applyDiscountToTicket(
|
|
||||||
ticket: FlightTicket,
|
|
||||||
coupon: CouponModel,
|
|
||||||
): FlightTicket {
|
|
||||||
// Calculate discount amount
|
|
||||||
let discountAmount = 0;
|
|
||||||
|
|
||||||
if (coupon.discountType === DiscountType.PERCENTAGE) {
|
|
||||||
discountAmount =
|
|
||||||
(ticket.priceDetails.displayPrice *
|
|
||||||
Number(coupon.discountValue)) /
|
|
||||||
100;
|
|
||||||
} else {
|
|
||||||
discountAmount = Number(coupon.discountValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply maximum discount limit if specified
|
|
||||||
if (
|
|
||||||
coupon.maxDiscountAmount &&
|
|
||||||
discountAmount > Number(coupon.maxDiscountAmount)
|
|
||||||
) {
|
|
||||||
discountAmount = Number(coupon.maxDiscountAmount);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip if minimum order value is not met
|
|
||||||
if (
|
|
||||||
coupon.minOrderValue &&
|
|
||||||
ticket.priceDetails.displayPrice < Number(coupon.minOrderValue)
|
|
||||||
) {
|
|
||||||
return ticket;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a copy of the ticket with the discount applied
|
|
||||||
const discountedTicket = { ...ticket };
|
|
||||||
|
|
||||||
discountedTicket.priceDetails = {
|
|
||||||
...ticket.priceDetails,
|
|
||||||
discountAmount:
|
|
||||||
(ticket.priceDetails.discountAmount || 0) + discountAmount,
|
|
||||||
displayPrice: ticket.priceDetails.displayPrice - discountAmount,
|
|
||||||
appliedCoupon: coupon.code,
|
|
||||||
couponDescription:
|
|
||||||
coupon.description ||
|
|
||||||
`Coupon discount of ${coupon.discountType === DiscountType.PERCENTAGE ? coupon.discountValue + "%" : convertAndFormatCurrency(parseFloat(coupon.discountValue.toString()))}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
return discountedTicket;
|
|
||||||
}
|
|
||||||
|
|
||||||
async cacheTicket(sid: string, payload: FlightTicket) {
|
|
||||||
Logger.info(
|
|
||||||
`Caching ticket for ${sid} | ${payload.departure}:${payload.arrival}`,
|
|
||||||
);
|
|
||||||
const refOIds = payload.refOIds;
|
|
||||||
if (!refOIds) {
|
|
||||||
// In this case we're not going for any of our fancy checkout jazz
|
|
||||||
return this.repo.createTicket(payload, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const areAnyLive =
|
|
||||||
await this.repo.areCheckoutAlreadyLiveForOrder(refOIds);
|
|
||||||
|
|
||||||
Logger.info(`Any of the OIds has checkout session live ?? ${areAnyLive}`);
|
|
||||||
|
|
||||||
if (areAnyLive) {
|
|
||||||
return {
|
|
||||||
error: getError({
|
|
||||||
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
|
|
||||||
message: "This ticket offer has expired",
|
|
||||||
userHint:
|
|
||||||
"Please select another one or perform search again to fetch latest offers",
|
|
||||||
actionable: false,
|
|
||||||
detail: "Failed to ticket",
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Logger.info("nu'uh seems greenlight to make a ticket");
|
|
||||||
return this.repo.createTicket(payload, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateTicketPrices(tid: number, payload: FlightPriceDetails) {
|
|
||||||
return this.repo.updateTicketPrices(tid, payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
async uncacheAndSaveTicket(tid: number) {
|
|
||||||
return this.repo.uncacheAndSaveTicket(tid);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getCheckoutUrlByRefOId(id: number) {
|
|
||||||
return this.repo.getCheckoutUrlByRefOId(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async setRefOIdsForTicket(tid: number, oids: number[]) {
|
|
||||||
return this.repo.setRefOIdsForTicket(tid, oids);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getTicketById(id: number) {
|
|
||||||
return this.repo.getTicketById(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTC() {
|
|
||||||
const ds = new ScrapedTicketsDataSource("", "");
|
|
||||||
return new TicketController(new TicketRepository(db, ds));
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import { createTRPCRouter } from "$lib/trpc/t";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { publicProcedure } from "$lib/server/trpc/t";
|
|
||||||
import { getTC } from "./controller";
|
|
||||||
import { Logger } from "@pkg/logger";
|
|
||||||
import {
|
|
||||||
flightPriceDetailsModel,
|
|
||||||
flightTicketModel,
|
|
||||||
ticketSearchPayloadModel,
|
|
||||||
} from "../data/entities/index";
|
|
||||||
import type { Result } from "@pkg/result";
|
|
||||||
import { getCKUseCases } from "$lib/domains/ckflow/domain/usecases";
|
|
||||||
import { CouponRepository } from "@pkg/logic/domains/coupon/repository";
|
|
||||||
import { db } from "@pkg/db";
|
|
||||||
|
|
||||||
export const ticketRouter = createTRPCRouter({
|
|
||||||
searchAirports: publicProcedure
|
|
||||||
.input(z.object({ query: z.string() }))
|
|
||||||
.query(async ({ input }) => {
|
|
||||||
const { query } = input;
|
|
||||||
const tc = getTC();
|
|
||||||
Logger.info(`Fetching airports with query: ${query}`);
|
|
||||||
return await tc.searchAirports(query);
|
|
||||||
}),
|
|
||||||
|
|
||||||
ping: publicProcedure
|
|
||||||
.input(z.object({ tid: z.number(), refOIds: z.array(z.number()) }))
|
|
||||||
.query(async ({ input }) => {
|
|
||||||
console.log("Pinged");
|
|
||||||
console.log(input);
|
|
||||||
const ckflowUC = getCKUseCases();
|
|
||||||
const out = await ckflowUC.areCheckoutAlreadyLiveForOrder(
|
|
||||||
input.refOIds,
|
|
||||||
);
|
|
||||||
return { data: out } as Result<boolean>;
|
|
||||||
}),
|
|
||||||
|
|
||||||
getAirportByCode: publicProcedure
|
|
||||||
.input(z.object({ code: z.string() }))
|
|
||||||
.query(async ({ input }) => {
|
|
||||||
return await getTC().getAirportByCode(input.code);
|
|
||||||
}),
|
|
||||||
|
|
||||||
searchTickets: publicProcedure
|
|
||||||
.input(ticketSearchPayloadModel)
|
|
||||||
.query(async ({ input }) => {
|
|
||||||
const cr = new CouponRepository(db);
|
|
||||||
const coupon = await cr.getBestActiveCoupon();
|
|
||||||
Logger.info(`Got coupon? :: ${coupon.data?.code}`);
|
|
||||||
return getTC().searchForTickets(input, coupon.data);
|
|
||||||
}),
|
|
||||||
|
|
||||||
cacheTicket: publicProcedure
|
|
||||||
.input(z.object({ sid: z.string(), payload: flightTicketModel }))
|
|
||||||
.mutation(async ({ input }) => {
|
|
||||||
return await getTC().cacheTicket(input.sid, input.payload);
|
|
||||||
}),
|
|
||||||
|
|
||||||
updateTicketPrices: publicProcedure
|
|
||||||
.input(z.object({ tid: z.number(), payload: flightPriceDetailsModel }))
|
|
||||||
.mutation(async ({ input }) => {
|
|
||||||
return await getTC().updateTicketPrices(input.tid, input.payload);
|
|
||||||
}),
|
|
||||||
|
|
||||||
getTicketById: publicProcedure
|
|
||||||
.input(z.object({ id: z.number() }))
|
|
||||||
.query(async ({ input }) => {
|
|
||||||
return await getTC().getTicketById(input.id);
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
@@ -1,445 +0,0 @@
|
|||||||
import type { FlightTicket } from "../data/entities";
|
|
||||||
import { Logger } from "@pkg/logger";
|
|
||||||
import { round } from "$lib/core/num.utils";
|
|
||||||
import { type Result } from "@pkg/result";
|
|
||||||
import { type LimitedOrderWithTicketInfoModel } from "$lib/domains/order/data/entities";
|
|
||||||
import { getJustDateString } from "@pkg/logic/core/date.utils";
|
|
||||||
|
|
||||||
type OrderWithUsageInfo = {
|
|
||||||
order: LimitedOrderWithTicketInfoModel;
|
|
||||||
usePartialAmount: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class TicketWithOrderSkiddaddler {
|
|
||||||
THRESHOLD_DIFF_PERCENTAGE = 15;
|
|
||||||
ELEVATED_THRESHOLD_DIFF_PERCENTAGE = 50;
|
|
||||||
tickets: FlightTicket[];
|
|
||||||
|
|
||||||
private reservedOrders: number[];
|
|
||||||
private allActiveOrders: LimitedOrderWithTicketInfoModel[];
|
|
||||||
private urgentActiveOrders: LimitedOrderWithTicketInfoModel[];
|
|
||||||
// Stores oids that have their amount divided into smaller amounts for being reused for multiple tickets
|
|
||||||
// this record keeps track of the oid, with how much remainder is left to be divided
|
|
||||||
private reservedPartialOrders: Record<number, number>;
|
|
||||||
private minTicketPrice: number;
|
|
||||||
private maxTicketPrice: number;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
tickets: FlightTicket[],
|
|
||||||
allActiveOrders: LimitedOrderWithTicketInfoModel[],
|
|
||||||
) {
|
|
||||||
this.tickets = tickets;
|
|
||||||
this.reservedOrders = [];
|
|
||||||
this.reservedPartialOrders = {};
|
|
||||||
this.minTicketPrice = 0;
|
|
||||||
this.maxTicketPrice = 0;
|
|
||||||
this.allActiveOrders = [];
|
|
||||||
this.urgentActiveOrders = [];
|
|
||||||
|
|
||||||
this.loadupActiveOrders(allActiveOrders);
|
|
||||||
}
|
|
||||||
|
|
||||||
async magic(): Promise<Result<boolean>> {
|
|
||||||
// Sorting the orders by price in ascending order
|
|
||||||
this.allActiveOrders.sort((a, b) => b.basePrice - a.basePrice);
|
|
||||||
|
|
||||||
this.loadMinMaxPrices();
|
|
||||||
|
|
||||||
for (const ticket of this.tickets) {
|
|
||||||
if (this.areAllOrdersUsedUp()) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const suitableOrders = this.getSuitableOrders(ticket);
|
|
||||||
if (!suitableOrders) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
console.log("--------- suitable orders ---------");
|
|
||||||
console.log(suitableOrders);
|
|
||||||
console.log("-----------------------------------");
|
|
||||||
const [discountAmt, newDisplayPrice] = this.calculateNewAmounts(
|
|
||||||
ticket,
|
|
||||||
suitableOrders,
|
|
||||||
);
|
|
||||||
ticket.priceDetails.discountAmount = discountAmt;
|
|
||||||
ticket.priceDetails.displayPrice = newDisplayPrice;
|
|
||||||
|
|
||||||
const oids = Array.from(
|
|
||||||
new Set(suitableOrders.map((o) => o.order.id)),
|
|
||||||
).toSorted();
|
|
||||||
ticket.refOIds = oids;
|
|
||||||
this.reservedOrders.push(...oids);
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.debug(`Assigned ${this.reservedOrders.length} orders to tickets`);
|
|
||||||
return { data: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadupActiveOrders(data: LimitedOrderWithTicketInfoModel[]) {
|
|
||||||
const now = new Date();
|
|
||||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
||||||
|
|
||||||
// Removing all orders which have tickets with departure date of yesterday and older
|
|
||||||
this.allActiveOrders = data.filter((o) => {
|
|
||||||
return (
|
|
||||||
new Date(
|
|
||||||
getJustDateString(new Date(o.flightTicketInfo.departureDate)),
|
|
||||||
) >= today
|
|
||||||
);
|
|
||||||
});
|
|
||||||
this.urgentActiveOrders = [];
|
|
||||||
const threeDaysFromNowMs = 3 * 24 * 3600 * 1000;
|
|
||||||
for (const order of this.allActiveOrders) {
|
|
||||||
const depDate = new Date(order.flightTicketInfo.departureDate);
|
|
||||||
if (now.getTime() + threeDaysFromNowMs > depDate.getTime()) {
|
|
||||||
this.urgentActiveOrders.push(order);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.info(`Found ${this.allActiveOrders.length} active orders`);
|
|
||||||
Logger.info(`We got ${this.urgentActiveOrders.length} urgent orders`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadMinMaxPrices() {
|
|
||||||
this.minTicketPrice = 100000;
|
|
||||||
this.maxTicketPrice = 0;
|
|
||||||
|
|
||||||
for (const ticket of this.tickets) {
|
|
||||||
const _dPrice = ticket.priceDetails.displayPrice;
|
|
||||||
if (_dPrice < this.minTicketPrice) {
|
|
||||||
this.minTicketPrice = _dPrice;
|
|
||||||
}
|
|
||||||
if (_dPrice > this.maxTicketPrice) {
|
|
||||||
this.maxTicketPrice = _dPrice;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private areAllOrdersUsedUp() {
|
|
||||||
if (this.urgentActiveOrders.length === 0) {
|
|
||||||
return this.reservedOrders.length >= this.allActiveOrders.length;
|
|
||||||
}
|
|
||||||
const areUrgentOrdersUsedUp =
|
|
||||||
this.reservedOrders.length >= this.urgentActiveOrders.length;
|
|
||||||
return areUrgentOrdersUsedUp;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An order is used up in 2 cases
|
|
||||||
* 1. it's not being partially fulfulled and it's found in the used orders list
|
|
||||||
* 2. it's being partially fulfilled and it's found in both the used orders list and sub chunked orders list
|
|
||||||
*/
|
|
||||||
private isOrderUsedUp(oid: number, displayPrice: number) {
|
|
||||||
if (this.reservedPartialOrders.hasOwnProperty(oid)) {
|
|
||||||
return this.reservedPartialOrders[oid] < displayPrice;
|
|
||||||
}
|
|
||||||
return this.reservedOrders.includes(oid);
|
|
||||||
}
|
|
||||||
|
|
||||||
private calculateNewAmounts(
|
|
||||||
ticket: FlightTicket,
|
|
||||||
orders: OrderWithUsageInfo[],
|
|
||||||
) {
|
|
||||||
if (orders.length < 1) {
|
|
||||||
return [
|
|
||||||
ticket.priceDetails.discountAmount,
|
|
||||||
ticket.priceDetails.displayPrice,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalAmount = orders.reduce((sum, { order, usePartialAmount }) => {
|
|
||||||
return (
|
|
||||||
sum +
|
|
||||||
(usePartialAmount ? order.pricePerPassenger : order.displayPrice)
|
|
||||||
);
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
const discountAmt = round(ticket.priceDetails.displayPrice - totalAmount);
|
|
||||||
return [discountAmt, totalAmount];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Suitable orders are those orders that are ideally within the threshold and are not already reserved. So there's a handful of cases, which other sub methods are handling
|
|
||||||
*/
|
|
||||||
private getSuitableOrders(
|
|
||||||
ticket: FlightTicket,
|
|
||||||
): OrderWithUsageInfo[] | undefined {
|
|
||||||
const activeOrders =
|
|
||||||
this.urgentActiveOrders.length > 0
|
|
||||||
? this.urgentActiveOrders
|
|
||||||
: this.allActiveOrders;
|
|
||||||
const cases = [
|
|
||||||
(t: any, a: any) => {
|
|
||||||
return this.findFirstSuitableDirectOId(t, a);
|
|
||||||
},
|
|
||||||
(t: any, a: any) => {
|
|
||||||
return this.findFirstSuitablePartialOId(t, a);
|
|
||||||
},
|
|
||||||
(t: any, a: any) => {
|
|
||||||
return this.findManySuitableOIds(t, a);
|
|
||||||
},
|
|
||||||
(t: any, a: any) => {
|
|
||||||
return this.findFirstSuitableDirectOIdElevated(t, a);
|
|
||||||
},
|
|
||||||
];
|
|
||||||
let c = 1;
|
|
||||||
for (const each of cases) {
|
|
||||||
const out = each(ticket, activeOrders);
|
|
||||||
if (out) {
|
|
||||||
console.log(`Case ${c} worked, returning it's response`);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
c++;
|
|
||||||
}
|
|
||||||
console.log("NO CASE WORKED, WUT, yee");
|
|
||||||
}
|
|
||||||
|
|
||||||
private findFirstSuitableDirectOId(
|
|
||||||
ticket: FlightTicket,
|
|
||||||
activeOrders: LimitedOrderWithTicketInfoModel[],
|
|
||||||
): OrderWithUsageInfo[] | undefined {
|
|
||||||
let ord: LimitedOrderWithTicketInfoModel | undefined;
|
|
||||||
for (const o of activeOrders) {
|
|
||||||
const [op, tp] = [o.displayPrice, ticket.priceDetails.displayPrice];
|
|
||||||
const diff = Math.abs(op - tp);
|
|
||||||
const diffPtage = (diff / tp) * 100;
|
|
||||||
if (this.isOrderSuitable(o.id, op, tp, diffPtage)) {
|
|
||||||
ord = o;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!ord) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return [{ order: ord, usePartialAmount: false }];
|
|
||||||
}
|
|
||||||
|
|
||||||
private findFirstSuitablePartialOId(
|
|
||||||
ticket: FlightTicket,
|
|
||||||
activeOrders: LimitedOrderWithTicketInfoModel[],
|
|
||||||
): OrderWithUsageInfo[] | undefined {
|
|
||||||
const tp = ticket.priceDetails.displayPrice;
|
|
||||||
|
|
||||||
let ord: LimitedOrderWithTicketInfoModel | undefined;
|
|
||||||
for (const o of activeOrders) {
|
|
||||||
const op = o.pricePerPassenger;
|
|
||||||
const diff = op - tp;
|
|
||||||
const diffPtage = (diff / tp) * 100;
|
|
||||||
if (this.isOrderSuitable(o.id, op, tp, diffPtage)) {
|
|
||||||
ord = o;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!ord) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.upsertPartialOrderToCache(
|
|
||||||
ord.id,
|
|
||||||
ord.pricePerPassenger,
|
|
||||||
ord.fullfilledPrice,
|
|
||||||
);
|
|
||||||
return [{ order: ord, usePartialAmount: true }];
|
|
||||||
}
|
|
||||||
|
|
||||||
private findManySuitableOIds(
|
|
||||||
ticket: FlightTicket,
|
|
||||||
activeOrders: LimitedOrderWithTicketInfoModel[],
|
|
||||||
): OrderWithUsageInfo[] | undefined {
|
|
||||||
const targetPrice = ticket.priceDetails.displayPrice;
|
|
||||||
|
|
||||||
const validOrderOptions = activeOrders.flatMap((order) => {
|
|
||||||
const options: OrderWithUsageInfo[] = [];
|
|
||||||
|
|
||||||
// Add full price option if suitable
|
|
||||||
if (
|
|
||||||
!this.isOrderUsedUp(order.id, order.displayPrice) &&
|
|
||||||
order.displayPrice <= targetPrice
|
|
||||||
) {
|
|
||||||
options.push({ order, usePartialAmount: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add partial price option if suitable
|
|
||||||
if (
|
|
||||||
!this.isOrderUsedUp(order.id, order.pricePerPassenger) &&
|
|
||||||
order.pricePerPassenger <= targetPrice
|
|
||||||
) {
|
|
||||||
options.push({ order, usePartialAmount: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
return options;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (validOrderOptions.length === 0) return undefined;
|
|
||||||
// Helper function to get the effective price of an order
|
|
||||||
const getEffectivePrice = (ordInfo: {
|
|
||||||
order: LimitedOrderWithTicketInfoModel;
|
|
||||||
usePerPassenger: boolean;
|
|
||||||
}) => {
|
|
||||||
return ordInfo.usePerPassenger
|
|
||||||
? ordInfo.order.pricePerPassenger
|
|
||||||
: ordInfo.order.displayPrice;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sumCombination = (combo: OrderWithUsageInfo[]): number => {
|
|
||||||
return combo.reduce(
|
|
||||||
(sum, { order, usePartialAmount }) =>
|
|
||||||
sum +
|
|
||||||
(usePartialAmount
|
|
||||||
? order.pricePerPassenger
|
|
||||||
: order.displayPrice),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
let bestCombination: OrderWithUsageInfo[] = [];
|
|
||||||
let bestDiff = targetPrice;
|
|
||||||
|
|
||||||
// Try combinations of different sizes
|
|
||||||
for (
|
|
||||||
let size = 1;
|
|
||||||
size <= Math.min(validOrderOptions.length, 3);
|
|
||||||
size++
|
|
||||||
) {
|
|
||||||
const combinations = this.getCombinations(validOrderOptions, size);
|
|
||||||
|
|
||||||
for (const combo of combinations) {
|
|
||||||
const sum = sumCombination(combo);
|
|
||||||
const diff = targetPrice - sum;
|
|
||||||
|
|
||||||
if (
|
|
||||||
diff >= 0 &&
|
|
||||||
(diff / targetPrice) * 100 <=
|
|
||||||
this.THRESHOLD_DIFF_PERCENTAGE &&
|
|
||||||
diff < bestDiff
|
|
||||||
) {
|
|
||||||
// Verify we're not using the same order twice
|
|
||||||
const orderIds = new Set(combo.map((c) => c.order.id));
|
|
||||||
if (orderIds.size === combo.length) {
|
|
||||||
bestDiff = diff;
|
|
||||||
bestCombination = combo;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bestCombination.length === 0) return undefined;
|
|
||||||
|
|
||||||
// Update partial orders cache
|
|
||||||
bestCombination.forEach(({ order, usePartialAmount }) => {
|
|
||||||
if (usePartialAmount) {
|
|
||||||
this.upsertPartialOrderToCache(
|
|
||||||
order.id,
|
|
||||||
order.pricePerPassenger,
|
|
||||||
order.fullfilledPrice,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return bestCombination;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In case no other cases worked, but we have an order with far lower price amount,
|
|
||||||
* then we juss use it but bump up it's amount to match the order
|
|
||||||
*/
|
|
||||||
private findFirstSuitableDirectOIdElevated(
|
|
||||||
ticket: FlightTicket,
|
|
||||||
activeOrders: LimitedOrderWithTicketInfoModel[],
|
|
||||||
): OrderWithUsageInfo[] | undefined {
|
|
||||||
const targetPrice = ticket.priceDetails.displayPrice;
|
|
||||||
|
|
||||||
// Find the first unreserved order with the smallest price difference
|
|
||||||
let bestOrder: LimitedOrderWithTicketInfoModel | undefined;
|
|
||||||
let smallestPriceDiff = Number.MAX_VALUE;
|
|
||||||
|
|
||||||
for (const order of activeOrders) {
|
|
||||||
if (this.isOrderUsedUp(order.id, order.displayPrice)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check both regular price and per-passenger price
|
|
||||||
const prices = [order.displayPrice];
|
|
||||||
if (order.pricePerPassenger) {
|
|
||||||
prices.push(order.pricePerPassenger);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const price of prices) {
|
|
||||||
const diff = Math.abs(targetPrice - price);
|
|
||||||
if (diff < smallestPriceDiff) {
|
|
||||||
smallestPriceDiff = diff;
|
|
||||||
bestOrder = order;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!bestOrder) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate if we should use partial amount
|
|
||||||
const usePartial =
|
|
||||||
bestOrder.pricePerPassenger &&
|
|
||||||
Math.abs(targetPrice - bestOrder.pricePerPassenger) <
|
|
||||||
Math.abs(targetPrice - bestOrder.displayPrice);
|
|
||||||
|
|
||||||
// The price will be elevated to match the ticket price
|
|
||||||
// We'll use the original price ratio to determine the new display price
|
|
||||||
const originalPrice = usePartial
|
|
||||||
? bestOrder.pricePerPassenger
|
|
||||||
: bestOrder.displayPrice;
|
|
||||||
|
|
||||||
// Only elevate if the price difference is reasonable
|
|
||||||
const priceDiffPercentage =
|
|
||||||
(Math.abs(targetPrice - originalPrice) / targetPrice) * 100;
|
|
||||||
if (priceDiffPercentage > this.ELEVATED_THRESHOLD_DIFF_PERCENTAGE) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if (usePartial) {
|
|
||||||
// bestOrder.pricePerPassenger =
|
|
||||||
// } else {
|
|
||||||
// }
|
|
||||||
|
|
||||||
return [{ order: bestOrder, usePartialAmount: !!usePartial }];
|
|
||||||
}
|
|
||||||
|
|
||||||
private getCombinations<T>(arr: T[], size: number): T[][] {
|
|
||||||
if (size === 0) return [[]];
|
|
||||||
if (arr.length === 0) return [];
|
|
||||||
|
|
||||||
const first = arr[0];
|
|
||||||
const rest = arr.slice(1);
|
|
||||||
|
|
||||||
const combosWithoutFirst = this.getCombinations(rest, size);
|
|
||||||
const combosWithFirst = this.getCombinations(rest, size - 1).map(
|
|
||||||
(combo) => [first, ...combo],
|
|
||||||
);
|
|
||||||
return [...combosWithoutFirst, ...combosWithFirst];
|
|
||||||
}
|
|
||||||
|
|
||||||
private isOrderSuitable(
|
|
||||||
orderId: number,
|
|
||||||
orderPrice: number,
|
|
||||||
ticketPrice: number,
|
|
||||||
diffPercentage: number,
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
diffPercentage > 0 &&
|
|
||||||
diffPercentage <= this.THRESHOLD_DIFF_PERCENTAGE &&
|
|
||||||
orderPrice <= ticketPrice &&
|
|
||||||
!this.isOrderUsedUp(orderId, orderPrice)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private upsertPartialOrderToCache(
|
|
||||||
oid: number,
|
|
||||||
price: number,
|
|
||||||
_defaultPrice: number = 0,
|
|
||||||
) {
|
|
||||||
if (!this.reservedPartialOrders[oid]) {
|
|
||||||
this.reservedPartialOrders[oid] = _defaultPrice;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.reservedPartialOrders[oid] += price;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import * as Select from "$lib/components/ui/select";
|
|
||||||
import type { SelectOption } from "$lib/core/data.types";
|
|
||||||
import { capitalize } from "$lib/core/string.utils";
|
|
||||||
|
|
||||||
let {
|
|
||||||
opts,
|
|
||||||
onselect,
|
|
||||||
}: { opts: SelectOption[]; onselect: (e: string) => void } = $props();
|
|
||||||
|
|
||||||
let chosenOpt = $state<SelectOption | undefined>(undefined);
|
|
||||||
|
|
||||||
function setOpt(val: string) {
|
|
||||||
chosenOpt = opts.find((e) => e.value === val);
|
|
||||||
onselect(val);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Select.Root type="single" required onValueChange={(e) => setOpt(e)} name="role">
|
|
||||||
<Select.Trigger class="w-full border-border/20">
|
|
||||||
{capitalize(
|
|
||||||
opts?.find((e) => e.value === chosenOpt?.value)?.label ??
|
|
||||||
"Cabin Class",
|
|
||||||
)}
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Content>
|
|
||||||
{#each opts as each}
|
|
||||||
<Select.Item value={each.value}>
|
|
||||||
{each.label}
|
|
||||||
</Select.Item>
|
|
||||||
{/each}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
@@ -1,152 +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 { flightTicketStore } from "../../data/store";
|
|
||||||
import TripDetails from "../ticket/trip-details.svelte";
|
|
||||||
import RightArrowIcon from "~icons/solar/arrow-right-broken";
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
|
|
||||||
import PassengerPiiForm from "$lib/domains/passengerinfo/view/passenger-pii-form.svelte";
|
|
||||||
import PassengerBagSelection from "$lib/domains/passengerinfo/view/passenger-bag-selection.svelte";
|
|
||||||
import Badge from "$lib/components/ui/badge/badge.svelte";
|
|
||||||
import { capitalize } from "$lib/core/string.utils";
|
|
||||||
import { cn } from "$lib/utils";
|
|
||||||
import { ticketCheckoutVM } from "./flight-checkout.vm.svelte";
|
|
||||||
import { CheckoutStep } from "../../data/entities";
|
|
||||||
import { toast } from "svelte-sonner";
|
|
||||||
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
|
|
||||||
import ButtonLoadableText from "$lib/components/atoms/button-loadable-text.svelte";
|
|
||||||
|
|
||||||
const cardStyle =
|
|
||||||
"flex w-full flex-col gap-4 rounded-lg bg-white p-4 shadow-lg md:p-8";
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const primaryPassenger = passengerInfoVM.passengerInfos[0];
|
|
||||||
if (!ckFlowVM.flowId || !ckFlowVM.setupDone || !primaryPassenger) return;
|
|
||||||
|
|
||||||
const personalInfo = primaryPassenger.passengerPii;
|
|
||||||
if (!personalInfo) return;
|
|
||||||
|
|
||||||
// to trigger the effect
|
|
||||||
const {
|
|
||||||
phoneNumber,
|
|
||||||
email,
|
|
||||||
firstName,
|
|
||||||
lastName,
|
|
||||||
address,
|
|
||||||
address2,
|
|
||||||
zipCode,
|
|
||||||
city,
|
|
||||||
state,
|
|
||||||
country,
|
|
||||||
nationality,
|
|
||||||
gender,
|
|
||||||
dob,
|
|
||||||
passportNo,
|
|
||||||
passportExpiry,
|
|
||||||
} = personalInfo;
|
|
||||||
if (
|
|
||||||
firstName ||
|
|
||||||
lastName ||
|
|
||||||
email ||
|
|
||||||
phoneNumber ||
|
|
||||||
country ||
|
|
||||||
city ||
|
|
||||||
state ||
|
|
||||||
zipCode ||
|
|
||||||
address ||
|
|
||||||
address2 ||
|
|
||||||
nationality ||
|
|
||||||
gender ||
|
|
||||||
dob ||
|
|
||||||
passportNo ||
|
|
||||||
passportExpiry
|
|
||||||
) {
|
|
||||||
console.log("pi ping");
|
|
||||||
ckFlowVM.debouncePersonalInfoSync(personalInfo);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function proceedToNextStep() {
|
|
||||||
passengerInfoVM.validateAllPII();
|
|
||||||
console.log(passengerInfoVM.piiErrors);
|
|
||||||
if (!passengerInfoVM.isPIIValid()) {
|
|
||||||
return toast.error("Some or all info is invalid", {
|
|
||||||
description: "Please properly fill out all of the fields",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
ticketCheckoutVM.continutingToNextStep = true;
|
|
||||||
const out2 = await ckFlowVM.executePrePaymentStep();
|
|
||||||
if (!out2) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setTimeout(() => {
|
|
||||||
ticketCheckoutVM.continutingToNextStep = false;
|
|
||||||
ticketCheckoutVM.checkoutStep = CheckoutStep.Payment;
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
window.scrollTo(0, 0);
|
|
||||||
setTimeout(() => {
|
|
||||||
passengerInfoVM.setupPassengerInfo(
|
|
||||||
$flightTicketStore.passengerCounts,
|
|
||||||
);
|
|
||||||
}, 200);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if $flightTicketStore}
|
|
||||||
<div class={cardStyle}>
|
|
||||||
<TripDetails data={$flightTicketStore} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if passengerInfoVM.passengerInfos.length > 0}
|
|
||||||
{#each passengerInfoVM.passengerInfos as info, idx}
|
|
||||||
{@const name =
|
|
||||||
info.passengerPii.firstName.length > 0 ||
|
|
||||||
info.passengerPii.lastName.length > 0
|
|
||||||
? `${info.passengerPii.firstName} ${info.passengerPii.lastName}`
|
|
||||||
: `Passenger #${idx + 1}`}
|
|
||||||
<div class={cardStyle}>
|
|
||||||
<div class="flex flex-row items-center justify-between gap-4">
|
|
||||||
<Title size="h4" maxwidth="max-w-xs">
|
|
||||||
{name}
|
|
||||||
</Title>
|
|
||||||
|
|
||||||
<Badge variant="secondary" class="w-max">
|
|
||||||
{capitalize(info.passengerType)}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div class={cn(cardStyle, "border-2 border-gray-200")}>
|
|
||||||
<Title size="h5">Personal Info</Title>
|
|
||||||
<PassengerPiiForm bind:info={info.passengerPii} {idx} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class={cn(cardStyle, "border-2 border-gray-200")}>
|
|
||||||
<Title size="h5">Bag Selection</Title>
|
|
||||||
<PassengerBagSelection bind:info={info.bagSelection} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="flex flex-col items-center justify-between gap-4 md:flex-row">
|
|
||||||
<div></div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
onclick={proceedToNextStep}
|
|
||||||
class="w-full md:w-max"
|
|
||||||
disabled={ticketCheckoutVM.continutingToNextStep}
|
|
||||||
>
|
|
||||||
<ButtonLoadableText
|
|
||||||
text="Continue"
|
|
||||||
loadingText="Processing..."
|
|
||||||
loading={ticketCheckoutVM.continutingToNextStep}
|
|
||||||
/>
|
|
||||||
<Icon icon={RightArrowIcon} cls="w-auto h-6" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Icon from "$lib/components/atoms/icon.svelte";
|
|
||||||
import Title from "$lib/components/atoms/title.svelte";
|
|
||||||
import { Badge } from "$lib/components/ui/badge";
|
|
||||||
import TagIcon from "~icons/lucide/tag";
|
|
||||||
import { flightTicketStore } from "../../../data/store";
|
|
||||||
import { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte";
|
|
||||||
|
|
||||||
let appliedCoupon = $derived($flightTicketStore?.priceDetails?.appliedCoupon);
|
|
||||||
let couponDescription = $derived(
|
|
||||||
$flightTicketStore?.priceDetails?.couponDescription || "",
|
|
||||||
);
|
|
||||||
let discountAmount = $derived(
|
|
||||||
$flightTicketStore?.priceDetails?.discountAmount || 0,
|
|
||||||
);
|
|
||||||
let basePrice = $derived($flightTicketStore?.priceDetails?.basePrice || 0);
|
|
||||||
let discountPercentage = $derived(
|
|
||||||
basePrice > 0 ? Math.round((discountAmount / basePrice) * 100) : 0,
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if appliedCoupon && discountAmount > 0}
|
|
||||||
<div
|
|
||||||
class="flex flex-col gap-2 rounded-lg border border-green-200 bg-green-50 p-4"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2 text-green-700">
|
|
||||||
<Icon icon={TagIcon} cls="h-5 w-5" />
|
|
||||||
<Title size="p" weight="medium">Coupon Applied</Title>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
class="ml-auto border-green-600 text-green-600"
|
|
||||||
>
|
|
||||||
{discountPercentage}% OFF
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="font-medium">{appliedCoupon}</span>
|
|
||||||
<span>-{convertAndFormatCurrency(discountAmount)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if couponDescription}
|
|
||||||
<p class="text-sm text-green-700">{couponDescription}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Icon from "$lib/components/atoms/icon.svelte";
|
|
||||||
import Title from "$lib/components/atoms/title.svelte";
|
|
||||||
import Button, {
|
|
||||||
buttonVariants,
|
|
||||||
} from "$lib/components/ui/button/button.svelte";
|
|
||||||
import RightArrowIcon from "~icons/solar/arrow-right-broken";
|
|
||||||
import { ticketCheckoutVM } from "../flight-checkout.vm.svelte";
|
|
||||||
import { CheckoutStep } from "$lib/domains/ticket/data/entities";
|
|
||||||
import { flightTicketStore } from "$lib/domains/ticket/data/store";
|
|
||||||
import OrderSummary from "./order-summary.svelte";
|
|
||||||
import PaymentForm from "./payment-form.svelte";
|
|
||||||
import { paymentInfoVM } from "./payment.info.vm.svelte";
|
|
||||||
import TicketDetailsModal from "../../ticket/ticket-details-modal.svelte";
|
|
||||||
import * as Dialog from "$lib/components/ui/dialog";
|
|
||||||
import { cn } from "$lib/utils";
|
|
||||||
import { TicketType } from "$lib/domains/ticket/data/entities";
|
|
||||||
import ArrowsExchangeIcon from "~icons/tabler/arrows-exchange-2";
|
|
||||||
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
|
|
||||||
import { formatDate } from "@pkg/logic/core/date.utils";
|
|
||||||
import ButtonLoadableText from "$lib/components/atoms/button-loadable-text.svelte";
|
|
||||||
import BillingDetailsForm from "./billing-details-form.svelte";
|
|
||||||
import { billingDetailsVM } from "./billing.details.vm.svelte";
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
|
|
||||||
import { toast } from "svelte-sonner";
|
|
||||||
import CouponSummary from "./coupon-summary.svelte";
|
|
||||||
|
|
||||||
const cardStyle =
|
|
||||||
"flex w-full flex-col gap-4 rounded-lg bg-white p-4 shadow-lg md:p-8";
|
|
||||||
|
|
||||||
async function goBack() {
|
|
||||||
if ((await ckFlowVM.onBackToPIIBtnClick()) !== true) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ticketCheckoutVM.checkoutStep = CheckoutStep.Initial;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
const validatedData = await paymentInfoVM.validateAndSubmit();
|
|
||||||
if (!validatedData) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const validBillingInfo = billingDetailsVM.validatePII(
|
|
||||||
billingDetailsVM.billingDetails,
|
|
||||||
);
|
|
||||||
if (!validBillingInfo) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ticketCheckoutVM.continutingToNextStep = true;
|
|
||||||
const out = await ckFlowVM.executePaymentStep();
|
|
||||||
if (out !== true) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setTimeout(() => {
|
|
||||||
ticketCheckoutVM.continutingToNextStep = false;
|
|
||||||
ticketCheckoutVM.checkoutStep = CheckoutStep.Verification;
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
let outboundFlight = $derived(
|
|
||||||
$flightTicketStore?.flightIteneraries.outbound[0],
|
|
||||||
);
|
|
||||||
let inboundFlight = $derived(
|
|
||||||
$flightTicketStore?.flightIteneraries.inbound[0],
|
|
||||||
);
|
|
||||||
let isReturnFlight = $derived(
|
|
||||||
$flightTicketStore?.flightType === TicketType.Return,
|
|
||||||
);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!ckFlowVM.flowId || !ckFlowVM.setupDone) return;
|
|
||||||
if (!paymentInfoVM.cardDetails) return;
|
|
||||||
|
|
||||||
paymentInfoVM.cardDetails.cardNumber;
|
|
||||||
paymentInfoVM.cardDetails.cardholderName;
|
|
||||||
paymentInfoVM.cardDetails.cvv;
|
|
||||||
paymentInfoVM.cardDetails.expiry;
|
|
||||||
|
|
||||||
// Always sync payment info regardless of validation status
|
|
||||||
ckFlowVM.debouncePaymentInfoSync();
|
|
||||||
});
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
window.scrollTo(0, 0);
|
|
||||||
|
|
||||||
billingDetailsVM.validatePII(billingDetailsVM.billingDetails);
|
|
||||||
if (billingDetailsVM.isPIIValid()) {
|
|
||||||
console.log("Billing details are valid, not setting from pasenger");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (passengerInfoVM.passengerInfos.length > 0) {
|
|
||||||
billingDetailsVM.setPII(
|
|
||||||
passengerInfoVM.passengerInfos[0].passengerPii,
|
|
||||||
);
|
|
||||||
billingDetailsVM.validatePII(billingDetailsVM.billingDetails);
|
|
||||||
toast("Used billing details from primary passenger");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
|
||||||
<div class={cardStyle}>
|
|
||||||
<Title size="h4">Trip Summary</Title>
|
|
||||||
<div
|
|
||||||
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"
|
|
||||||
>
|
|
||||||
<!-- Trip Summary -->
|
|
||||||
<div class="flex flex-col gap-4 md:gap-2">
|
|
||||||
<!-- Main Route Display -->
|
|
||||||
<div class="flex items-center gap-2 text-lg font-semibold">
|
|
||||||
<span>{outboundFlight?.departure.station.code}</span>
|
|
||||||
{#if isReturnFlight}
|
|
||||||
<Icon
|
|
||||||
icon={ArrowsExchangeIcon}
|
|
||||||
cls="w-5 h-5 text-gray-400 rotate-180"
|
|
||||||
/>
|
|
||||||
<span>{outboundFlight?.destination.station.code}</span>
|
|
||||||
{:else}
|
|
||||||
<Icon icon={RightArrowIcon} cls="w-5 h-5 text-gray-400" />
|
|
||||||
<span>{outboundFlight?.destination.station.code}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Dates Display -->
|
|
||||||
<div class="flex flex-col gap-1 text-sm text-gray-600 md:gap-0">
|
|
||||||
{#if isReturnFlight}
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span>
|
|
||||||
{formatDate(outboundFlight?.departure.localTime)}
|
|
||||||
- {formatDate(inboundFlight.departure.localTime)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span>
|
|
||||||
{formatDate(outboundFlight?.departure.localTime)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- View Details Button -->
|
|
||||||
<TicketDetailsModal
|
|
||||||
data={$flightTicketStore}
|
|
||||||
hideCheckoutBtn
|
|
||||||
onCheckoutBtnClick={() => {}}
|
|
||||||
>
|
|
||||||
<Dialog.Trigger
|
|
||||||
class={cn(
|
|
||||||
buttonVariants({ variant: "secondary" }),
|
|
||||||
"w-max text-start",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
View Full Details
|
|
||||||
</Dialog.Trigger>
|
|
||||||
</TicketDetailsModal>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class={cardStyle}>
|
|
||||||
<Title size="h4">Order Summary</Title>
|
|
||||||
<OrderSummary />
|
|
||||||
|
|
||||||
<CouponSummary />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class={cardStyle}>
|
|
||||||
<Title size="h4">Billing Details</Title>
|
|
||||||
<BillingDetailsForm info={billingDetailsVM.billingDetails} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class={cardStyle}>
|
|
||||||
<Title size="h4">Payment Details</Title>
|
|
||||||
<PaymentForm />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col items-center justify-between gap-4 md:flex-row">
|
|
||||||
<Button variant="secondary" onclick={goBack} class="w-full md:w-max">
|
|
||||||
<Icon icon={RightArrowIcon} cls="w-auto h-6 rotate-180" />
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
onclick={handleSubmit}
|
|
||||||
class="w-full md:w-max"
|
|
||||||
disabled={ticketCheckoutVM.continutingToNextStep}
|
|
||||||
>
|
|
||||||
<ButtonLoadableText
|
|
||||||
text="Confirm & Pay"
|
|
||||||
loadingText="Processing info..."
|
|
||||||
loading={ticketCheckoutVM.continutingToNextStep}
|
|
||||||
/>
|
|
||||||
<Icon icon={RightArrowIcon} cls="w-auto h-6" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -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,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,224 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import Icon from "$lib/components/atoms/icon.svelte";
|
|
||||||
import Title from "$lib/components/atoms/title.svelte";
|
|
||||||
import Button from "$lib/components/ui/button/button.svelte";
|
|
||||||
import { cn } from "$lib/utils";
|
|
||||||
import RightArrowIcon from "~icons/solar/arrow-right-broken";
|
|
||||||
import { seatSelectionVM } from "./seat.selection.vm.svelte";
|
|
||||||
import { ticketCheckoutVM } from "../flight-checkout.vm.svelte";
|
|
||||||
import { CheckoutStep } from "$lib/domains/ticket/data/entities";
|
|
||||||
import { flightTicketStore } from "$lib/domains/ticket/data/store";
|
|
||||||
import CheckoutLoadingSection from "../checkout-loading-section.svelte";
|
|
||||||
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
|
|
||||||
|
|
||||||
const cardStyle =
|
|
||||||
"flex w-full flex-col gap-4 rounded-lg bg-white p-4 shadow-lg md:p-8";
|
|
||||||
|
|
||||||
let currentFlight = $derived(
|
|
||||||
[
|
|
||||||
...$flightTicketStore.flightIteneraries.outbound,
|
|
||||||
...$flightTicketStore.flightIteneraries.inbound,
|
|
||||||
][seatSelectionVM.currentFlightIndex],
|
|
||||||
);
|
|
||||||
|
|
||||||
function goBack() {
|
|
||||||
ticketCheckoutVM.checkoutStep = CheckoutStep.Initial;
|
|
||||||
}
|
|
||||||
|
|
||||||
function goNext() {
|
|
||||||
// TODO: Add seat selection verification here
|
|
||||||
// Just cuse it's already setting it lol
|
|
||||||
skipAndContinue();
|
|
||||||
}
|
|
||||||
|
|
||||||
function skipAndContinue() {
|
|
||||||
ticketCheckoutVM.checkoutStep = CheckoutStep.Payment;
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
seatSelectionVM.fetchSeatMaps();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if seatSelectionVM.loading}
|
|
||||||
<CheckoutLoadingSection />
|
|
||||||
{:else}
|
|
||||||
<div class="flex flex-col gap-6">
|
|
||||||
<div class={cardStyle}>
|
|
||||||
<div
|
|
||||||
class="flex flex-col items-center justify-between gap-4 sm:flex-row"
|
|
||||||
>
|
|
||||||
<Title size="h4">Select Your Seats</Title>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outlineWhite"
|
|
||||||
onclick={skipAndContinue}
|
|
||||||
>
|
|
||||||
Skip & Continue
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-4 border-b pb-4">
|
|
||||||
<span class="text-sm font-medium">
|
|
||||||
Select passenger to assign seat:
|
|
||||||
</span>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
{#each passengerInfoVM.passengerInfos as passenger}
|
|
||||||
<button
|
|
||||||
class={cn(
|
|
||||||
"rounded-lg border-2 px-4 py-2 transition-colors",
|
|
||||||
seatSelectionVM.currentPassengerId ===
|
|
||||||
passenger.id
|
|
||||||
? "border-primary bg-primary text-white"
|
|
||||||
: "border-gray-200 hover:border-primary/50",
|
|
||||||
)}
|
|
||||||
onclick={() =>
|
|
||||||
seatSelectionVM.setCurrentPassenger(passenger.id)}
|
|
||||||
>
|
|
||||||
{passenger.passengerPii.firstName}
|
|
||||||
{passenger.passengerPii.lastName}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Flight Info -->
|
|
||||||
<div
|
|
||||||
class="flex flex-col items-center justify-between gap-4 border-b pb-4 sm:flex-row"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-1 text-center sm:text-left">
|
|
||||||
<span class="text-sm text-gray-600">
|
|
||||||
Flight {seatSelectionVM.currentFlightIndex + 1} of {seatSelectionVM
|
|
||||||
.seatMaps.length}
|
|
||||||
</span>
|
|
||||||
<span class="font-medium">
|
|
||||||
{currentFlight.departure.station.code} → {currentFlight
|
|
||||||
.destination.station.code}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={seatSelectionVM.currentFlightIndex === 0}
|
|
||||||
onclick={() => seatSelectionVM.previousFlight()}
|
|
||||||
>
|
|
||||||
Prev Flight
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={seatSelectionVM.currentFlightIndex ===
|
|
||||||
seatSelectionVM.seatMaps.length - 1}
|
|
||||||
onclick={() => seatSelectionVM.nextFlight()}
|
|
||||||
>
|
|
||||||
Next Flight
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Seat Map -->
|
|
||||||
<div class="flex w-full justify-center py-8">
|
|
||||||
<div class="w-full overflow-x-auto">
|
|
||||||
<div
|
|
||||||
class="mx-auto grid w-[50vw] gap-2 lg:mx-0 lg:w-full lg:place-items-center"
|
|
||||||
>
|
|
||||||
<!-- Column headers now inside -->
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<span class="flex w-8 items-center justify-end"
|
|
||||||
></span>
|
|
||||||
{#each ["A", "B", "C", "", "D", "E", "F"] as letter}
|
|
||||||
<span
|
|
||||||
class="w-10 text-center text-sm text-gray-500"
|
|
||||||
>
|
|
||||||
{letter}
|
|
||||||
</span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#each seatSelectionVM.seatMaps[seatSelectionVM.currentFlightIndex].seats as row}
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<span
|
|
||||||
class="flex w-8 items-center justify-end text-sm text-gray-500"
|
|
||||||
>
|
|
||||||
{row[0].row}
|
|
||||||
</span>
|
|
||||||
{#each row as seat}
|
|
||||||
<button
|
|
||||||
class={cn(
|
|
||||||
"h-10 w-10 rounded-lg border-2 text-sm transition-colors",
|
|
||||||
seat.reserved
|
|
||||||
? "cursor-not-allowed border-gray-200 bg-gray-100"
|
|
||||||
: seat.available
|
|
||||||
? "border-primary hover:bg-primary/10"
|
|
||||||
: "cursor-not-allowed border-gray-200 bg-gray-200",
|
|
||||||
seatSelectionVM.isSeatAssigned(
|
|
||||||
currentFlight.flightId,
|
|
||||||
seat.id,
|
|
||||||
) &&
|
|
||||||
"border-primary bg-primary text-white",
|
|
||||||
)}
|
|
||||||
disabled={!seat.available ||
|
|
||||||
seat.reserved ||
|
|
||||||
seatSelectionVM.currentPassengerId ===
|
|
||||||
null}
|
|
||||||
onclick={() =>
|
|
||||||
seatSelectionVM.selectSeat(
|
|
||||||
currentFlight.flightId,
|
|
||||||
seat,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{seatSelectionVM.getSeatDisplay(
|
|
||||||
currentFlight.flightId,
|
|
||||||
seat.id,
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{#if seat.number === 3}
|
|
||||||
<div class="w-8"></div>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap justify-center gap-4 border-t pt-4">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div class="h-6 w-6 rounded border-2 border-primary"></div>
|
|
||||||
<span class="text-sm">Available</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
class="h-6 w-6 rounded border-2 border-primary bg-primary"
|
|
||||||
></div>
|
|
||||||
<span class="text-sm">Selected</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
class="h-6 w-6 rounded border-2 border-gray-200 bg-gray-100"
|
|
||||||
></div>
|
|
||||||
<span class="text-sm">Reserved</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
class="h-6 w-6 rounded border-2 border-gray-200 bg-gray-200"
|
|
||||||
></div>
|
|
||||||
<span class="text-sm">Unavailable</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col items-center justify-between gap-4 md:flex-row">
|
|
||||||
<Button variant="secondary" onclick={goBack} class="w-full md:w-max">
|
|
||||||
<Icon icon={RightArrowIcon} cls="w-auto h-6 rotate-180" />
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button variant="default" onclick={goNext} class="w-full md:w-max">
|
|
||||||
Continue to Payment
|
|
||||||
<Icon icon={RightArrowIcon} cls="w-auto h-6" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -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,104 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import CalendarIcon from "@lucide/svelte/icons/calendar";
|
|
||||||
import {
|
|
||||||
DateFormatter,
|
|
||||||
getLocalTimeZone,
|
|
||||||
today,
|
|
||||||
toCalendarDate,
|
|
||||||
type DateValue,
|
|
||||||
} from "@internationalized/date";
|
|
||||||
import { cn } from "$lib/utils.js";
|
|
||||||
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
|
||||||
import * as Popover from "$lib/components/ui/popover/index.js";
|
|
||||||
|
|
||||||
import Icon from "$lib/components/atoms/icon.svelte";
|
|
||||||
import Calendar from "$lib/components/ui/calendar/calendar.svelte";
|
|
||||||
import {
|
|
||||||
parseCalDateToDateString,
|
|
||||||
makeDateStringISO,
|
|
||||||
} from "$lib/core/date.utils";
|
|
||||||
|
|
||||||
import { ticketSearchStore } from "../data/store";
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
|
|
||||||
const df = new DateFormatter("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
timeZone: getLocalTimeZone(),
|
|
||||||
});
|
|
||||||
|
|
||||||
let contentRef = $state<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
let open = $state(false);
|
|
||||||
|
|
||||||
const todayDate = today(getLocalTimeZone());
|
|
||||||
let value = $state(today(getLocalTimeZone()));
|
|
||||||
|
|
||||||
let startFmt = $derived(value.toDate(getLocalTimeZone()));
|
|
||||||
|
|
||||||
let defaultPlaceholder = "Pick a date";
|
|
||||||
let placeholder = $derived(
|
|
||||||
value ? `${df.format(startFmt)}` : defaultPlaceholder,
|
|
||||||
);
|
|
||||||
|
|
||||||
function updateValInStore() {
|
|
||||||
const val = parseCalDateToDateString(value);
|
|
||||||
ticketSearchStore.update((prev) => {
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
departureDate: makeDateStringISO(val),
|
|
||||||
returnDate: makeDateStringISO(val),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDateDisabled(date: DateValue) {
|
|
||||||
return date.compare(todayDate) < 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDateSelection(v: DateValue | undefined) {
|
|
||||||
if (!v) {
|
|
||||||
value = today(getLocalTimeZone());
|
|
||||||
} else {
|
|
||||||
value = toCalendarDate(v);
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
open = false;
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
updateValInStore();
|
|
||||||
});
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
updateValInStore();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Popover.Root bind:open>
|
|
||||||
<Popover.Trigger
|
|
||||||
class={cn(
|
|
||||||
buttonVariants({
|
|
||||||
variant: "white",
|
|
||||||
class: "w-full justify-start text-left font-normal",
|
|
||||||
}),
|
|
||||||
!value && "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon icon={CalendarIcon} cls="w-auto h-4" />
|
|
||||||
<span class="text-sm md:text-base">{placeholder}</span>
|
|
||||||
</Popover.Trigger>
|
|
||||||
<Popover.Content bind:ref={contentRef} class="w-auto p-0">
|
|
||||||
<Calendar
|
|
||||||
type="single"
|
|
||||||
{value}
|
|
||||||
minValue={todayDate}
|
|
||||||
{isDateDisabled}
|
|
||||||
onValueChange={(v) => handleDateSelection(v)}
|
|
||||||
class="rounded-md border border-white"
|
|
||||||
/>
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover.Root>
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import CalendarIcon from "@lucide/svelte/icons/calendar";
|
|
||||||
import {
|
|
||||||
DateFormatter,
|
|
||||||
getLocalTimeZone,
|
|
||||||
toCalendarDate,
|
|
||||||
today,
|
|
||||||
type DateValue,
|
|
||||||
} from "@internationalized/date";
|
|
||||||
import { cn } from "$lib/utils.js";
|
|
||||||
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
|
||||||
import * as Popover from "$lib/components/ui/popover/index.js";
|
|
||||||
|
|
||||||
import { RangeCalendar } from "$lib/components/ui/range-calendar/index.js";
|
|
||||||
import Icon from "$lib/components/atoms/icon.svelte";
|
|
||||||
import { ticketSearchStore } from "../data/store";
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import {
|
|
||||||
makeDateStringISO,
|
|
||||||
parseCalDateToDateString,
|
|
||||||
} from "$lib/core/date.utils";
|
|
||||||
import Button from "$lib/components/ui/button/button.svelte";
|
|
||||||
|
|
||||||
const df = new DateFormatter("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
|
|
||||||
let contentRef = $state<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
let open = $state(false);
|
|
||||||
|
|
||||||
const todayDate = today(getLocalTimeZone());
|
|
||||||
|
|
||||||
const start = today(getLocalTimeZone());
|
|
||||||
const end = start.add({ days: 7 });
|
|
||||||
|
|
||||||
let value = $state({ start, end });
|
|
||||||
|
|
||||||
let startFmt = $derived(value?.start?.toDate(getLocalTimeZone()));
|
|
||||||
let endFmt = $derived(value?.end?.toDate(getLocalTimeZone()));
|
|
||||||
|
|
||||||
let defaultPlaceholder = "Pick a date";
|
|
||||||
let placeholder = $derived(
|
|
||||||
value?.start && value?.end && startFmt && endFmt
|
|
||||||
? `${df.formatRange(startFmt, endFmt)}`
|
|
||||||
: value?.start && startFmt
|
|
||||||
? `${df.format(startFmt)} - Select end date`
|
|
||||||
: defaultPlaceholder,
|
|
||||||
);
|
|
||||||
|
|
||||||
function isDateDisabled(date: DateValue) {
|
|
||||||
return date.compare(todayDate) < 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleStartChange(v: DateValue | undefined) {
|
|
||||||
if (!v) {
|
|
||||||
value.start = today(getLocalTimeZone());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
value.start = toCalendarDate(v);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEndChange(v: DateValue | undefined) {
|
|
||||||
if (!v) {
|
|
||||||
value.end = today(getLocalTimeZone());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
value.end = toCalendarDate(v);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateValsInStore() {
|
|
||||||
const sVal = parseCalDateToDateString(value.start);
|
|
||||||
const eVal = parseCalDateToDateString(value.end);
|
|
||||||
ticketSearchStore.update((prev) => {
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
departureDate: makeDateStringISO(sVal),
|
|
||||||
returnDate: makeDateStringISO(eVal),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
updateValsInStore();
|
|
||||||
});
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
updateValsInStore();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Popover.Root
|
|
||||||
{open}
|
|
||||||
onOpenChange={(o) => {
|
|
||||||
open = o;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Popover.Trigger
|
|
||||||
class={cn(
|
|
||||||
buttonVariants({
|
|
||||||
variant: "white",
|
|
||||||
class: "w-full justify-start text-left font-normal",
|
|
||||||
}),
|
|
||||||
!value && "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon icon={CalendarIcon} cls="w-auto h-4" />
|
|
||||||
<span class="text-sm md:text-base">{placeholder}</span>
|
|
||||||
</Popover.Trigger>
|
|
||||||
<Popover.Content bind:ref={contentRef} class="w-auto p-0">
|
|
||||||
<RangeCalendar
|
|
||||||
{value}
|
|
||||||
{isDateDisabled}
|
|
||||||
onStartValueChange={handleStartChange}
|
|
||||||
onEndValueChange={handleEndChange}
|
|
||||||
class="rounded-md border border-white"
|
|
||||||
/>
|
|
||||||
<div class="w-full p-2">
|
|
||||||
<Button
|
|
||||||
class="w-full"
|
|
||||||
onclick={() => {
|
|
||||||
open = false;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Confirm
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover.Root>
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Counter from "$lib/components/atoms/counter.svelte";
|
|
||||||
import Title from "$lib/components/atoms/title.svelte";
|
|
||||||
import { buttonVariants } from "$lib/components/ui/button";
|
|
||||||
import * as Popover from "$lib/components/ui/popover/index.js";
|
|
||||||
import { cn } from "$lib/utils";
|
|
||||||
import { CabinClass } from "../data/entities";
|
|
||||||
import { ticketSearchStore } from "../data/store";
|
|
||||||
import { snakeToSpacedPascal } from "$lib/core/string.utils";
|
|
||||||
import Icon from "$lib/components/atoms/icon.svelte";
|
|
||||||
import ChevronDownIcon from "~icons/lucide/chevron-down";
|
|
||||||
import CheckIcon from "~icons/lucide/check";
|
|
||||||
|
|
||||||
let cabinClass = $state($ticketSearchStore.cabinClass);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
ticketSearchStore.update((prev) => {
|
|
||||||
return { ...prev, cabinClass: cabinClass };
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
let adultCount = $state(1);
|
|
||||||
let childCount = $state(0);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
ticketSearchStore.update((prev) => {
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
passengerCounts: { adults: adultCount, children: childCount },
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
let passengerCounts = $derived(adultCount + childCount);
|
|
||||||
|
|
||||||
let cabinClassOpen = $state(false);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex w-full flex-col items-center justify-end gap-2 sm:flex-row">
|
|
||||||
<Popover.Root bind:open={cabinClassOpen}>
|
|
||||||
<Popover.Trigger
|
|
||||||
class={cn(
|
|
||||||
buttonVariants({ variant: "white" }),
|
|
||||||
"w-full justify-between",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{snakeToSpacedPascal(cabinClass.toLowerCase() ?? "Select")}
|
|
||||||
<Icon icon={ChevronDownIcon} cls="w-auto h-4" />
|
|
||||||
</Popover.Trigger>
|
|
||||||
|
|
||||||
<Popover.Content>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
{#each Object.values(CabinClass) as each}
|
|
||||||
<button
|
|
||||||
onclick={() => {
|
|
||||||
cabinClass = each;
|
|
||||||
cabinClassOpen = false;
|
|
||||||
}}
|
|
||||||
class={cn(
|
|
||||||
"flex items-center gap-2 rounded-md p-2 px-4 text-start hover:bg-gray-200",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{#if cabinClass === each}
|
|
||||||
<Icon
|
|
||||||
icon={CheckIcon}
|
|
||||||
cls="w-auto h-4 text-brand-600"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div
|
|
||||||
class="h-4 w-4 rounded-full bg-transparent"
|
|
||||||
></div>
|
|
||||||
{/if}
|
|
||||||
{snakeToSpacedPascal(each.toLowerCase())}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover.Root>
|
|
||||||
|
|
||||||
<Popover.Root>
|
|
||||||
<Popover.Trigger
|
|
||||||
class={cn(
|
|
||||||
buttonVariants({ variant: "white" }),
|
|
||||||
"w-full justify-between",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{passengerCounts} Passenger(s)
|
|
||||||
<Icon icon={ChevronDownIcon} cls="w-auto h-4" />
|
|
||||||
</Popover.Trigger>
|
|
||||||
|
|
||||||
<Popover.Content>
|
|
||||||
<div class="flex flex-col gap-8">
|
|
||||||
<Title size="h5" weight="normal" color="black"
|
|
||||||
>Passenger Selection</Title
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<p>Adults</p>
|
|
||||||
<p class="text-gray-500">Aged 16+</p>
|
|
||||||
</div>
|
|
||||||
<Counter bind:value={adultCount} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<p>Children</p>
|
|
||||||
<p class="text-gray-500">Aged 0-16</p>
|
|
||||||
</div>
|
|
||||||
<Counter bind:value={childCount} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover.Root>
|
|
||||||
</div>
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Badge from "$lib/components/ui/badge/badge.svelte";
|
|
||||||
import Button from "$lib/components/ui/button/button.svelte";
|
|
||||||
import { flightTicketVM } from "./ticket.vm.svelte";
|
|
||||||
import TicketCard from "./ticket/ticket-card.svelte";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex w-full flex-col gap-4">
|
|
||||||
{#if flightTicketVM.searching}
|
|
||||||
{#each Array(5) as _}
|
|
||||||
<div
|
|
||||||
class="h-64 w-full animate-pulse rounded-lg bg-gray-300 shadow-lg"
|
|
||||||
></div>
|
|
||||||
{/each}
|
|
||||||
{:else if flightTicketVM.renderedTickets.length > 0}
|
|
||||||
<Badge variant="outline" class="w-max">
|
|
||||||
Showing {flightTicketVM.renderedTickets.length} tickets
|
|
||||||
</Badge>
|
|
||||||
{#each flightTicketVM.renderedTickets as each}
|
|
||||||
<TicketCard data={each} />
|
|
||||||
{/each}
|
|
||||||
<Button
|
|
||||||
class="text-center"
|
|
||||||
variant="white"
|
|
||||||
onclick={() => {
|
|
||||||
flightTicketVM.searchForTickets(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Load More
|
|
||||||
</Button>
|
|
||||||
{:else}
|
|
||||||
<div
|
|
||||||
class="flex flex-col items-center justify-center gap-4 p-8 py-32 text-center"
|
|
||||||
>
|
|
||||||
<div class="text-2xl font-bold">No tickets found</div>
|
|
||||||
<div class="text-sm text-gray-500">
|
|
||||||
Try searching for a different flight
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Title from "$lib/components/atoms/title.svelte";
|
|
||||||
import { Slider } from "$lib/components/ui/slider/index.js";
|
|
||||||
import {
|
|
||||||
ticketFiltersStore,
|
|
||||||
MaxStops,
|
|
||||||
SortOption,
|
|
||||||
} from "$lib/domains/ticket/view/ticket-filters.vm.svelte";
|
|
||||||
import { flightTicketVM } from "./ticket.vm.svelte";
|
|
||||||
import { RadioGroup, RadioGroupItem } from "$lib/components/ui/radio-group";
|
|
||||||
import { Checkbox } from "$lib/components/ui/checkbox";
|
|
||||||
import { Label } from "$lib/components/ui/label";
|
|
||||||
import Button from "$lib/components/ui/button/button.svelte";
|
|
||||||
import {
|
|
||||||
convertAndFormatCurrency,
|
|
||||||
currencyStore,
|
|
||||||
currencyVM,
|
|
||||||
} from "$lib/domains/currency/view/currency.vm.svelte";
|
|
||||||
|
|
||||||
let { onApplyClick }: { onApplyClick?: () => void } = $props();
|
|
||||||
|
|
||||||
function onApply() {
|
|
||||||
flightTicketVM.applyFilters();
|
|
||||||
if (onApplyClick) {
|
|
||||||
onApplyClick();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let maxPrice = $state(
|
|
||||||
flightTicketVM.tickets.length > 0
|
|
||||||
? flightTicketVM.tickets.sort(
|
|
||||||
(a, b) =>
|
|
||||||
b.priceDetails.displayPrice - a.priceDetails.displayPrice,
|
|
||||||
)[0]?.priceDetails.displayPrice
|
|
||||||
: 100,
|
|
||||||
);
|
|
||||||
|
|
||||||
let priceRange = $state([
|
|
||||||
0,
|
|
||||||
currencyVM.convertFromUsd(
|
|
||||||
$ticketFiltersStore.priceRange.max,
|
|
||||||
$currencyStore.code,
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Time ranges
|
|
||||||
let departureTimeRange = $state([0, $ticketFiltersStore.time.departure.max]);
|
|
||||||
let arrivalTimeRange = $state([0, $ticketFiltersStore.time.arrival.max]);
|
|
||||||
let durationRange = $state([0, $ticketFiltersStore.duration.max]);
|
|
||||||
|
|
||||||
let maxStops = $state($ticketFiltersStore.maxStops);
|
|
||||||
let allowOvernight = $state($ticketFiltersStore.allowOvernight);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
maxPrice =
|
|
||||||
flightTicketVM.tickets.length > 0
|
|
||||||
? flightTicketVM.tickets.sort(
|
|
||||||
(a, b) =>
|
|
||||||
b.priceDetails.displayPrice -
|
|
||||||
a.priceDetails.displayPrice,
|
|
||||||
)[0]?.priceDetails.displayPrice
|
|
||||||
: 100;
|
|
||||||
if (priceRange[0] > maxPrice || priceRange[1] > maxPrice) {
|
|
||||||
priceRange = [
|
|
||||||
0,
|
|
||||||
currencyVM.convertFromUsd(maxPrice, $currencyStore.code),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
ticketFiltersStore.update((prev) => ({
|
|
||||||
...prev,
|
|
||||||
priceRange: { min: priceRange[0], max: priceRange[1] },
|
|
||||||
maxStops,
|
|
||||||
allowOvernight,
|
|
||||||
time: {
|
|
||||||
departure: {
|
|
||||||
min: departureTimeRange[0],
|
|
||||||
max: departureTimeRange[1],
|
|
||||||
},
|
|
||||||
arrival: { min: arrivalTimeRange[0], max: arrivalTimeRange[1] },
|
|
||||||
},
|
|
||||||
duration: { min: durationRange[0], max: durationRange[1] },
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex w-full max-w-sm flex-col gap-6">
|
|
||||||
<Title size="h5" color="black">Sort By</Title>
|
|
||||||
|
|
||||||
{#if flightTicketVM.searching}
|
|
||||||
<div class="h-16 w-full animate-pulse rounded-lg bg-gray-300"></div>
|
|
||||||
{:else}
|
|
||||||
<RadioGroup
|
|
||||||
value={$ticketFiltersStore.sortBy}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
ticketFiltersStore.update((prev) => ({
|
|
||||||
...prev,
|
|
||||||
sortBy: value as SortOption,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<RadioGroupItem
|
|
||||||
value={SortOption.Default}
|
|
||||||
id="price_low_to_high"
|
|
||||||
/>
|
|
||||||
<Label for="default">Price: Default</Label>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<RadioGroupItem
|
|
||||||
value={SortOption.PriceLowToHigh}
|
|
||||||
id="price_low_to_high"
|
|
||||||
/>
|
|
||||||
<Label for="price_low_to_high">Price: Low to High</Label>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<RadioGroupItem
|
|
||||||
value={SortOption.PriceHighToLow}
|
|
||||||
id="price_high_to_low"
|
|
||||||
/>
|
|
||||||
<Label for="price_high_to_low">Price: High to Low</Label>
|
|
||||||
</div>
|
|
||||||
</RadioGroup>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<Title size="h5" color="black">Price</Title>
|
|
||||||
|
|
||||||
{#if flightTicketVM.searching}
|
|
||||||
<div class="h-6 w-full animate-pulse rounded-full bg-gray-300"></div>
|
|
||||||
{:else}
|
|
||||||
<div class="flex w-full flex-col gap-2">
|
|
||||||
<Slider
|
|
||||||
type="multiple"
|
|
||||||
bind:value={priceRange}
|
|
||||||
min={0}
|
|
||||||
max={maxPrice}
|
|
||||||
step={1}
|
|
||||||
/>
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<p class="text-sm font-medium">Min</p>
|
|
||||||
<p class="text-sm text-gray-500">{priceRange[0]}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<p class="text-sm font-medium">Max</p>
|
|
||||||
<p class="text-sm text-gray-500">{priceRange[1]}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<Title size="h5" color="black">Max Stops</Title>
|
|
||||||
|
|
||||||
{#if flightTicketVM.searching}
|
|
||||||
<div class="h-24 w-full animate-pulse rounded-lg bg-gray-300"></div>
|
|
||||||
{:else}
|
|
||||||
<RadioGroup
|
|
||||||
value={$ticketFiltersStore.maxStops}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
ticketFiltersStore.update((prev) => ({
|
|
||||||
...prev,
|
|
||||||
maxStops: value as MaxStops,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{#each Object.entries(MaxStops) as [label, value]}
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<RadioGroupItem {value} id={value} />
|
|
||||||
<Label for={value}>{label}</Label>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</RadioGroup>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if flightTicketVM.searching}
|
|
||||||
<div class="h-8 w-full animate-pulse rounded-lg bg-gray-300"></div>
|
|
||||||
{:else}
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<Checkbox
|
|
||||||
checked={$ticketFiltersStore.allowOvernight}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
ticketFiltersStore.update((prev) => ({
|
|
||||||
...prev,
|
|
||||||
allowOvernight: checked,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
id="overnight"
|
|
||||||
/>
|
|
||||||
<Label for="overnight">Allow Overnight Flights</Label>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<Title size="h5" color="black">Time</Title>
|
|
||||||
|
|
||||||
{#if flightTicketVM.searching}
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="h-16 w-full animate-pulse rounded-lg bg-gray-300"></div>
|
|
||||||
<div class="h-16 w-full animate-pulse rounded-lg bg-gray-300"></div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<p class="mb-2 text-sm font-medium">Departure Time</p>
|
|
||||||
<Slider
|
|
||||||
type="multiple"
|
|
||||||
bind:value={departureTimeRange}
|
|
||||||
min={0}
|
|
||||||
max={24}
|
|
||||||
step={1}
|
|
||||||
/>
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<small>{departureTimeRange[0]}:00</small>
|
|
||||||
<small>{departureTimeRange[1]}:00</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<p class="mb-2 text-sm font-medium">Arrival Time</p>
|
|
||||||
<Slider
|
|
||||||
type="multiple"
|
|
||||||
bind:value={arrivalTimeRange}
|
|
||||||
min={0}
|
|
||||||
max={24}
|
|
||||||
step={1}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<small>{arrivalTimeRange[0]}:00</small>
|
|
||||||
<small>{arrivalTimeRange[1]}:00</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<Title size="h5" color="black">Duration</Title>
|
|
||||||
|
|
||||||
{#if flightTicketVM.searching}
|
|
||||||
<div class="h-16 w-full animate-pulse rounded-lg bg-gray-300"></div>
|
|
||||||
{:else}
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<p class="mb-2 text-sm font-medium">Max Duration</p>
|
|
||||||
<Slider
|
|
||||||
type="multiple"
|
|
||||||
bind:value={durationRange}
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
step={1}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<small>{durationRange[0]} hours</small>
|
|
||||||
<small>{durationRange[1]} hours</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<Button onclick={() => onApply()} class="w-full">Apply Changes</Button>
|
|
||||||
</div>
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { writable } from "svelte/store";
|
|
||||||
|
|
||||||
export enum SortOption {
|
|
||||||
Default = "default",
|
|
||||||
PriceLowToHigh = "price_low_to_high",
|
|
||||||
PriceHighToLow = "price_high_to_low",
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum MaxStops {
|
|
||||||
Any = "any",
|
|
||||||
Direct = "direct",
|
|
||||||
One = "one",
|
|
||||||
Two = "two",
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ticketFiltersStore = writable({
|
|
||||||
priceRange: { min: 0, max: 0 },
|
|
||||||
excludeCountries: [] as string[],
|
|
||||||
maxStops: MaxStops.Any,
|
|
||||||
allowOvernight: true,
|
|
||||||
time: { departure: { min: 0, max: 0 }, arrival: { min: 0, max: 0 } },
|
|
||||||
duration: { min: 0, max: 0 },
|
|
||||||
sortBy: SortOption.Default,
|
|
||||||
});
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { TicketType } from "../data/entities/index";
|
|
||||||
import AirportSearchInput from "$lib/domains/airport/view/airport-search-input.svelte";
|
|
||||||
import { ticketSearchStore } from "../data/store";
|
|
||||||
import FlightDateInput from "./flight-date-input.svelte";
|
|
||||||
import FlightDateRangeInput from "./flight-date-range-input.svelte";
|
|
||||||
import Icon from "$lib/components/atoms/icon.svelte";
|
|
||||||
import SearchIcon from "~icons/solar/magnifer-linear";
|
|
||||||
import SwapIcon from "~icons/ant-design/swap-outlined";
|
|
||||||
import Button from "$lib/components/ui/button/button.svelte";
|
|
||||||
import PassengerAndCabinClassSelect from "./passenger-and-cabin-class-select.svelte";
|
|
||||||
import { Label } from "$lib/components/ui/label/index.js";
|
|
||||||
import * as RadioGroup from "$lib/components/ui/radio-group/index.js";
|
|
||||||
import { airportVM } from "$lib/domains/airport/view/airport.vm.svelte";
|
|
||||||
import { browser } from "$app/environment";
|
|
||||||
import { onDestroy, onMount } from "svelte";
|
|
||||||
|
|
||||||
let { onSubmit, rowify = false }: { onSubmit: () => void; rowify?: boolean } =
|
|
||||||
$props();
|
|
||||||
|
|
||||||
let currTicketType = $state($ticketSearchStore.ticketType);
|
|
||||||
let isMobileView = $state(false);
|
|
||||||
|
|
||||||
function checkIfMobile() {
|
|
||||||
if (browser) {
|
|
||||||
isMobileView = window.innerWidth < 768;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupResizeListener() {
|
|
||||||
if (browser) {
|
|
||||||
window.addEventListener("resize", checkIfMobile);
|
|
||||||
checkIfMobile(); // Initial check
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("resize", checkIfMobile);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
ticketSearchStore.update((prev) => {
|
|
||||||
return { ...prev, ticketType: currTicketType };
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
let cleanup: any | undefined = $state(undefined);
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
cleanup = setupResizeListener();
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
cleanup?.();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex w-full flex-col gap-4">
|
|
||||||
<div
|
|
||||||
class="flex w-full flex-col items-center justify-between gap-4 lg:grid lg:grid-cols-2 lg:gap-4"
|
|
||||||
>
|
|
||||||
<RadioGroup.Root
|
|
||||||
bind:value={currTicketType}
|
|
||||||
class="flex w-full flex-row gap-6 p-2 lg:p-4"
|
|
||||||
>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<RadioGroup.Item
|
|
||||||
value={TicketType.Return}
|
|
||||||
id={TicketType.Return}
|
|
||||||
onselect={() => {
|
|
||||||
ticketSearchStore.update((prev) => {
|
|
||||||
return { ...prev, ticketType: TicketType.Return };
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Label for={TicketType.Return} class="md:text-lg">Return</Label>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<RadioGroup.Item
|
|
||||||
value={TicketType.OneWay}
|
|
||||||
id={TicketType.OneWay}
|
|
||||||
onselect={() => {
|
|
||||||
ticketSearchStore.update((prev) => {
|
|
||||||
return { ...prev, ticketType: TicketType.OneWay };
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Label for={TicketType.OneWay} class="md:text-lg">One Way</Label>
|
|
||||||
</div>
|
|
||||||
</RadioGroup.Root>
|
|
||||||
|
|
||||||
<PassengerAndCabinClassSelect />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex w-full flex-col items-center justify-between gap-2 lg:grid lg:grid-cols-2 lg:gap-4"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex w-full flex-col items-center justify-between gap-2 lg:flex-row"
|
|
||||||
>
|
|
||||||
<AirportSearchInput
|
|
||||||
currentValue={airportVM.departure}
|
|
||||||
onChange={(e) => {
|
|
||||||
airportVM.setDepartureAirport(e);
|
|
||||||
}}
|
|
||||||
placeholder="Depart from"
|
|
||||||
searchPlaceholder="Departure airport search"
|
|
||||||
isMobile={isMobileView}
|
|
||||||
fieldType="departure"
|
|
||||||
/>
|
|
||||||
<div class="hidden w-full max-w-fit md:block">
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant={rowify ? "outlineWhite" : "defaultInverted"}
|
|
||||||
onclick={() => {
|
|
||||||
airportVM.swapDepartureAndArrival();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon icon={SwapIcon} cls="w-auto h-6" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AirportSearchInput
|
|
||||||
currentValue={airportVM.arrival}
|
|
||||||
onChange={(e) => {
|
|
||||||
airportVM.setArrivalAirport(e);
|
|
||||||
}}
|
|
||||||
placeholder="Arrive at"
|
|
||||||
searchPlaceholder="Arrival airport search"
|
|
||||||
isMobile={isMobileView}
|
|
||||||
fieldType="arrival"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex w-full flex-col items-center justify-between gap-2 lg:flex-row lg:gap-2"
|
|
||||||
>
|
|
||||||
{#if $ticketSearchStore.ticketType === TicketType.Return}
|
|
||||||
<FlightDateRangeInput />
|
|
||||||
{:else}
|
|
||||||
<FlightDateInput />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<Button onclick={() => onSubmit()} class={"w-full"} variant="default">
|
|
||||||
<Icon icon={SearchIcon} cls="w-auto h-4" />
|
|
||||||
Search flights
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,391 +0,0 @@
|
|||||||
import { goto, replaceState } from "$app/navigation";
|
|
||||||
import { page } from "$app/state";
|
|
||||||
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
|
|
||||||
import {
|
|
||||||
type FlightPriceDetails,
|
|
||||||
type FlightTicket,
|
|
||||||
ticketSearchPayloadModel,
|
|
||||||
} from "$lib/domains/ticket/data/entities/index";
|
|
||||||
import { trpcApiStore } from "$lib/stores/api";
|
|
||||||
import type { Result } from "@pkg/result";
|
|
||||||
import { nanoid } from "nanoid";
|
|
||||||
import { toast } from "svelte-sonner";
|
|
||||||
import { get } from "svelte/store";
|
|
||||||
import { flightTicketStore, ticketSearchStore } from "../data/store";
|
|
||||||
import { ticketCheckoutVM } from "./checkout/flight-checkout.vm.svelte";
|
|
||||||
import { paymentInfoVM } from "./checkout/payment-info-section/payment.info.vm.svelte";
|
|
||||||
import {
|
|
||||||
MaxStops,
|
|
||||||
SortOption,
|
|
||||||
ticketFiltersStore,
|
|
||||||
} from "./ticket-filters.vm.svelte";
|
|
||||||
|
|
||||||
export class FlightTicketViewModel {
|
|
||||||
searching = $state(false);
|
|
||||||
tickets = $state<FlightTicket[]>([]);
|
|
||||||
renderedTickets = $state<FlightTicket[]>([]);
|
|
||||||
|
|
||||||
updatingPrices = $state(false);
|
|
||||||
|
|
||||||
beginSearch() {
|
|
||||||
const info = get(ticketSearchStore);
|
|
||||||
if (!info) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
info.passengerCounts.adults < 1 &&
|
|
||||||
info.passengerCounts.children < 1
|
|
||||||
) {
|
|
||||||
toast.error("Please enter at least one adult and one child");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sum = info.passengerCounts.adults + info.passengerCounts.children;
|
|
||||||
if (sum > 10) {
|
|
||||||
toast.error("Please enter no more than 10 passengers");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = this.formatURLParams();
|
|
||||||
goto(`/search?${params.toString()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
loadStore(urlParams: URLSearchParams) {
|
|
||||||
console.log("Meta parameter: ", urlParams.get("meta"));
|
|
||||||
|
|
||||||
ticketSearchStore.update((prev) => {
|
|
||||||
return {
|
|
||||||
sessionId: prev.sessionId ?? "",
|
|
||||||
ticketType: urlParams.get("ticketType") ?? prev.ticketType,
|
|
||||||
cabinClass: urlParams.get("cabinClass") ?? prev.cabinClass,
|
|
||||||
passengerCounts: {
|
|
||||||
adults: Number(
|
|
||||||
urlParams.get("adults") ?? prev.passengerCounts.adults,
|
|
||||||
),
|
|
||||||
children: Number(
|
|
||||||
urlParams.get("children") ??
|
|
||||||
prev.passengerCounts.children,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
departure: urlParams.get("departure") ?? prev.departure,
|
|
||||||
arrival: urlParams.get("arrival") ?? prev.arrival,
|
|
||||||
departureDate:
|
|
||||||
urlParams.get("departureDate") ?? prev.departureDate,
|
|
||||||
returnDate: urlParams.get("returnDate") ?? prev.returnDate,
|
|
||||||
loadMore: prev.loadMore ?? false,
|
|
||||||
meta: (() => {
|
|
||||||
const metaStr = urlParams.get("meta");
|
|
||||||
if (!metaStr) return prev.meta;
|
|
||||||
try {
|
|
||||||
return JSON.parse(metaStr);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to parse meta parameter:", e);
|
|
||||||
return prev.meta;
|
|
||||||
}
|
|
||||||
})(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
resetCachedCheckoutData() {
|
|
||||||
// @ts-ignore
|
|
||||||
flightTicketStore.set(undefined);
|
|
||||||
passengerInfoVM.reset();
|
|
||||||
ticketCheckoutVM.reset();
|
|
||||||
paymentInfoVM.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
async searchForTickets(loadMore = false) {
|
|
||||||
const api = get(trpcApiStore);
|
|
||||||
if (!api) {
|
|
||||||
return toast.error("Please try again by reloading the page", {
|
|
||||||
description: "Page state not properly initialized",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let payload = get(ticketSearchStore);
|
|
||||||
if (!payload) {
|
|
||||||
return toast.error(
|
|
||||||
"Could not search for tickets due to invalid payload",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = ticketSearchPayloadModel.safeParse(payload);
|
|
||||||
if (!parsed.success) {
|
|
||||||
console.log("Enter some parameters to search for tickets");
|
|
||||||
this.searching = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
payload = parsed.data;
|
|
||||||
|
|
||||||
if (loadMore) {
|
|
||||||
payload.loadMore = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.searching = true;
|
|
||||||
const out = await api.ticket.searchTickets.query(payload);
|
|
||||||
this.searching = false;
|
|
||||||
|
|
||||||
console.log(out);
|
|
||||||
|
|
||||||
if (out.error) {
|
|
||||||
return toast.error(out.error.message, {
|
|
||||||
description: out.error.userHint,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!out.data) {
|
|
||||||
this.tickets = [];
|
|
||||||
return toast.error("No search results", {
|
|
||||||
description: "Please try again with different parameters",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.tickets = out.data;
|
|
||||||
this.applyFilters();
|
|
||||||
this.resetCachedCheckoutData();
|
|
||||||
}
|
|
||||||
|
|
||||||
applyFilters() {
|
|
||||||
this.searching = true;
|
|
||||||
const filters = get(ticketFiltersStore);
|
|
||||||
const filteredTickets = this.tickets.filter((ticket) => {
|
|
||||||
// Price filter
|
|
||||||
if (filters.priceRange.max > 0) {
|
|
||||||
if (
|
|
||||||
ticket.priceDetails.displayPrice < filters.priceRange.min ||
|
|
||||||
ticket.priceDetails.displayPrice > filters.priceRange.max
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.maxStops !== MaxStops.Any) {
|
|
||||||
// Calculate stops for outbound flight
|
|
||||||
const outboundStops =
|
|
||||||
ticket.flightIteneraries.outbound.length - 1;
|
|
||||||
// Calculate stops for inbound flight
|
|
||||||
const inboundStops = ticket.flightIteneraries.inbound.length - 1;
|
|
||||||
|
|
||||||
// Get the maximum number of stops between outbound and inbound
|
|
||||||
const maxStopsInJourney = Math.max(outboundStops, inboundStops);
|
|
||||||
|
|
||||||
switch (filters.maxStops) {
|
|
||||||
case MaxStops.Direct:
|
|
||||||
if (maxStopsInJourney > 0) return false;
|
|
||||||
break;
|
|
||||||
case MaxStops.One:
|
|
||||||
if (maxStopsInJourney > 1) return false;
|
|
||||||
break;
|
|
||||||
case MaxStops.Two:
|
|
||||||
if (maxStopsInJourney > 2) return false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Time range filters
|
|
||||||
if (filters.time.departure.max > 0 || filters.time.arrival.max > 0) {
|
|
||||||
const allItineraries = [
|
|
||||||
...ticket.flightIteneraries.outbound,
|
|
||||||
...ticket.flightIteneraries.inbound,
|
|
||||||
];
|
|
||||||
for (const itinerary of allItineraries) {
|
|
||||||
const departureHour = new Date(
|
|
||||||
itinerary.departure.utcTime,
|
|
||||||
).getHours();
|
|
||||||
const arrivalHour = new Date(
|
|
||||||
itinerary.destination.utcTime,
|
|
||||||
).getHours();
|
|
||||||
|
|
||||||
if (filters.time.departure.max > 0) {
|
|
||||||
if (
|
|
||||||
departureHour < filters.time.departure.min ||
|
|
||||||
departureHour > filters.time.departure.max
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.time.arrival.max > 0) {
|
|
||||||
if (
|
|
||||||
arrivalHour < filters.time.arrival.min ||
|
|
||||||
arrivalHour > filters.time.arrival.max
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Duration filter
|
|
||||||
if (filters.duration.max > 0) {
|
|
||||||
const allItineraries = [
|
|
||||||
...ticket.flightIteneraries.outbound,
|
|
||||||
...ticket.flightIteneraries.inbound,
|
|
||||||
];
|
|
||||||
const totalDuration = allItineraries.reduce(
|
|
||||||
(sum, itinerary) => sum + itinerary.durationSeconds,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const durationHours = totalDuration / 3600;
|
|
||||||
|
|
||||||
if (
|
|
||||||
durationHours < filters.duration.min ||
|
|
||||||
durationHours > filters.duration.max
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overnight filter
|
|
||||||
if (!filters.allowOvernight) {
|
|
||||||
const allItineraries = [
|
|
||||||
...ticket.flightIteneraries.outbound,
|
|
||||||
...ticket.flightIteneraries.inbound,
|
|
||||||
];
|
|
||||||
const hasOvernightFlight = allItineraries.some((itinerary) => {
|
|
||||||
const departureHour = new Date(
|
|
||||||
itinerary.departure.utcTime,
|
|
||||||
).getHours();
|
|
||||||
const arrivalHour = new Date(
|
|
||||||
itinerary.destination.utcTime,
|
|
||||||
).getHours();
|
|
||||||
|
|
||||||
// Consider a flight overnight if it departs between 8 PM (20) and 6 AM (6)
|
|
||||||
return (
|
|
||||||
departureHour >= 20 ||
|
|
||||||
departureHour <= 6 ||
|
|
||||||
arrivalHour >= 20 ||
|
|
||||||
arrivalHour <= 6
|
|
||||||
);
|
|
||||||
});
|
|
||||||
if (hasOvernightFlight) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
filteredTickets.sort((a, b) => {
|
|
||||||
switch (filters.sortBy) {
|
|
||||||
case SortOption.PriceLowToHigh:
|
|
||||||
return (
|
|
||||||
a.priceDetails.displayPrice - b.priceDetails.displayPrice
|
|
||||||
);
|
|
||||||
case SortOption.PriceHighToLow:
|
|
||||||
return (
|
|
||||||
b.priceDetails.displayPrice - a.priceDetails.displayPrice
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.renderedTickets = filteredTickets;
|
|
||||||
this.searching = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async cacheTicketAndGotoCheckout(id: number) {
|
|
||||||
const api = get(trpcApiStore);
|
|
||||||
if (!api) {
|
|
||||||
return toast.error("Please try again by reloading the page", {
|
|
||||||
description: "Page state not properly initialized",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const targetTicket = this.tickets.find((ticket) => ticket.id === id);
|
|
||||||
if (!targetTicket) {
|
|
||||||
return toast.error("Ticket not found", {
|
|
||||||
description:
|
|
||||||
"Please try again with different parameters or refresh page to try again",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const sid = get(ticketSearchStore).sessionId;
|
|
||||||
console.log("sid", sid);
|
|
||||||
const out = await api.ticket.cacheTicket.mutate({
|
|
||||||
sid,
|
|
||||||
payload: targetTicket,
|
|
||||||
});
|
|
||||||
if (out.error) {
|
|
||||||
return toast.error(out.error.message, {
|
|
||||||
description: out.error.userHint,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!out.data) {
|
|
||||||
return toast.error("Failed to proceed to checkout", {
|
|
||||||
description: "Please refresh the page to try again",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
goto(`/checkout/${sid}/${out.data}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
redirectToSearchPage() {
|
|
||||||
const params = this.formatURLParams();
|
|
||||||
goto(`/search?${params.toString()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
setURLParams(): Result<boolean> {
|
|
||||||
ticketSearchStore.update((prev) => {
|
|
||||||
return { ...prev, sessionId: nanoid() };
|
|
||||||
});
|
|
||||||
const newParams = this.formatURLParams();
|
|
||||||
|
|
||||||
const url = new URL(page.url.href);
|
|
||||||
|
|
||||||
for (const [key, value] of newParams.entries()) {
|
|
||||||
url.searchParams.set(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
let stripped = page.url.href.includes("?")
|
|
||||||
? page.url.href.split("?")[0]
|
|
||||||
: page.url.href;
|
|
||||||
|
|
||||||
replaceState(
|
|
||||||
new URL(stripped + "?" + new URLSearchParams(newParams)).toString(),
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
|
|
||||||
return { data: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
private formatURLParams() {
|
|
||||||
const info = get(ticketSearchStore);
|
|
||||||
let out = new URLSearchParams();
|
|
||||||
if (!info) {
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
out.append("ticketType", info.ticketType);
|
|
||||||
out.append("cabinClass", info.cabinClass);
|
|
||||||
out.append("adults", info.passengerCounts.adults.toString());
|
|
||||||
out.append("children", info.passengerCounts.children.toString());
|
|
||||||
out.append("departureDate", info.departureDate);
|
|
||||||
out.append("returnDate", info.returnDate);
|
|
||||||
out.append("meta", JSON.stringify(info.meta));
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateTicketPrices(updated: FlightPriceDetails) {
|
|
||||||
const api = get(trpcApiStore);
|
|
||||||
if (!api) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const tid = get(flightTicketStore).id;
|
|
||||||
this.updatingPrices = true;
|
|
||||||
const out = await api.ticket.updateTicketPrices.mutate({
|
|
||||||
tid,
|
|
||||||
payload: updated,
|
|
||||||
});
|
|
||||||
this.updatingPrices = false;
|
|
||||||
console.log("new shit");
|
|
||||||
console.log(out);
|
|
||||||
if (out.error) {
|
|
||||||
toast.error(out.error.message, {
|
|
||||||
description: out.error.userHint,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!out.data) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
flightTicketStore.update((prev) => {
|
|
||||||
return { ...prev, priceDetails: out.data! };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const flightTicketVM = new FlightTicketViewModel();
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import BackpackIcon from "~icons/solar/backpack-broken";
|
|
||||||
import BagCheckIcon from "~icons/solar/suitcase-broken";
|
|
||||||
import PersonalBagIcon from "~icons/solar/bag-broken";
|
|
||||||
import Icon from "$lib/components/atoms/icon.svelte";
|
|
||||||
import Title from "$lib/components/atoms/title.svelte";
|
|
||||||
import type { FlightTicket } from "../../data/entities";
|
|
||||||
|
|
||||||
let { data }: { data: FlightTicket } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if data}
|
|
||||||
<Title size="h5" color="black">Baggage Info</Title>
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<!-- Personal Item - Always show -->
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Icon icon={PersonalBagIcon} cls="w-6 h-6" />
|
|
||||||
<span>Personal Item</span>
|
|
||||||
</div>
|
|
||||||
<span>{data.bagsInfo.includedPersonalBags} included</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Cabin Baggage - Always show -->
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Icon icon={BackpackIcon} cls="w-6 h-6" />
|
|
||||||
<span>Cabin Baggage</span>
|
|
||||||
</div>
|
|
||||||
{#if data.bagsInfo.hasHandBagsSupport}
|
|
||||||
<span>{data.bagsInfo.includedHandBags} included</span>
|
|
||||||
{:else}
|
|
||||||
<span class="text-gray-500">Not available</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Checked Baggage - Always show -->
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Icon icon={BagCheckIcon} cls="w-6 h-6" />
|
|
||||||
<span>Checked Baggage</span>
|
|
||||||
</div>
|
|
||||||
{#if data.bagsInfo.hasCheckedBagsSupport}
|
|
||||||
<span>{data.bagsInfo.includedCheckedBags} included</span>
|
|
||||||
{:else}
|
|
||||||
<span class="text-gray-500">Not available</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Title from "$lib/components/atoms/title.svelte";
|
|
||||||
import Button, {
|
|
||||||
buttonVariants,
|
|
||||||
} from "$lib/components/ui/button/button.svelte";
|
|
||||||
import { type FlightTicket } from "../../data/entities";
|
|
||||||
import { Badge } from "$lib/components/ui/badge/index.js";
|
|
||||||
import { TRANSITION_ALL } from "$lib/core/constants";
|
|
||||||
import { cn } from "$lib/utils";
|
|
||||||
import TicketDetailsModal from "./ticket-details-modal.svelte";
|
|
||||||
import * as Dialog from "$lib/components/ui/dialog/index.js";
|
|
||||||
import { flightTicketVM } from "../ticket.vm.svelte";
|
|
||||||
import TicketLegsOverview from "./ticket-legs-overview.svelte";
|
|
||||||
import { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte";
|
|
||||||
|
|
||||||
let { data }: { data: FlightTicket } = $props();
|
|
||||||
|
|
||||||
async function proceedToCheckout() {
|
|
||||||
await flightTicketVM.cacheTicketAndGotoCheckout(data.id);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex flex-col md:flex-row">
|
|
||||||
<TicketDetailsModal {data} onCheckoutBtnClick={proceedToCheckout}>
|
|
||||||
<Dialog.Trigger
|
|
||||||
class={cn(
|
|
||||||
"flex w-full flex-col justify-center gap-4 rounded-lg border-x-0 border-t-2 border-gray-200 bg-white p-6 shadow-md hover:bg-gray-50 md:border-y-2 md:border-l-2 md:border-r-2",
|
|
||||||
TRANSITION_ALL,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<TicketLegsOverview {data} />
|
|
||||||
<div class="flex items-center gap-2"></div>
|
|
||||||
</Dialog.Trigger>
|
|
||||||
</TicketDetailsModal>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class={cn(
|
|
||||||
"flex w-full flex-col items-center justify-center gap-4 rounded-lg border-x-0 border-b-2 border-gray-200 bg-white p-6 shadow-md md:max-w-xs md:border-y-2 md:border-l-0 md:border-r-2",
|
|
||||||
TRANSITION_ALL,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<!-- Add price comparison logic here -->
|
|
||||||
{#if data.priceDetails.basePrice !== data.priceDetails.displayPrice}
|
|
||||||
{@const discountPercentage = Math.round(
|
|
||||||
(1 -
|
|
||||||
data.priceDetails.displayPrice /
|
|
||||||
data.priceDetails.basePrice) *
|
|
||||||
100,
|
|
||||||
)}
|
|
||||||
<div class="flex flex-col items-center gap-1">
|
|
||||||
<Badge variant="destructive">
|
|
||||||
<span>{discountPercentage}% OFF</span>
|
|
||||||
</Badge>
|
|
||||||
<div class="text-gray-500 line-through">
|
|
||||||
{convertAndFormatCurrency(data.priceDetails.basePrice)}
|
|
||||||
</div>
|
|
||||||
<Title center size="h4" weight="medium" color="black">
|
|
||||||
{convertAndFormatCurrency(data.priceDetails.displayPrice)}
|
|
||||||
</Title>
|
|
||||||
|
|
||||||
{#if data.priceDetails.appliedCoupon}
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
class="border-green-600 text-green-600"
|
|
||||||
>
|
|
||||||
Coupon: {data.priceDetails.appliedCoupon}
|
|
||||||
</Badge>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<Title center size="h4" weight="medium" color="black">
|
|
||||||
{convertAndFormatCurrency(data.priceDetails.displayPrice)}
|
|
||||||
</Title>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="flex w-full flex-col gap-1">
|
|
||||||
<TicketDetailsModal {data} onCheckoutBtnClick={proceedToCheckout}>
|
|
||||||
<Dialog.Trigger
|
|
||||||
class={cn(buttonVariants({ variant: "secondary" }))}
|
|
||||||
>
|
|
||||||
Flight Info
|
|
||||||
</Dialog.Trigger>
|
|
||||||
</TicketDetailsModal>
|
|
||||||
<Button onclick={() => proceedToCheckout()}>Checkout</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import * as Dialog from "$lib/components/ui/dialog/index.js";
|
|
||||||
import type { FlightTicket } from "../../data/entities";
|
|
||||||
import TicketDetails from "./ticket-details.svelte";
|
|
||||||
|
|
||||||
let {
|
|
||||||
data,
|
|
||||||
onCheckoutBtnClick,
|
|
||||||
hideCheckoutBtn,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
data: FlightTicket;
|
|
||||||
children: any;
|
|
||||||
hideCheckoutBtn?: boolean;
|
|
||||||
onCheckoutBtnClick: (tid: number) => void;
|
|
||||||
} = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Dialog.Root>
|
|
||||||
{@render children()}
|
|
||||||
|
|
||||||
<Dialog.Content class="w-full max-w-2xl">
|
|
||||||
<div class="flex max-h-[80vh] w-full flex-col gap-4 overflow-y-auto">
|
|
||||||
<TicketDetails {data} {hideCheckoutBtn} {onCheckoutBtnClick} />
|
|
||||||
</div>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog.Root>
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Title from "$lib/components/atoms/title.svelte";
|
|
||||||
import Button from "$lib/components/ui/button/button.svelte";
|
|
||||||
import { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte";
|
|
||||||
import { type FlightTicket } from "../../data/entities";
|
|
||||||
import BaggageInfo from "./baggage-info.svelte";
|
|
||||||
import TripDetails from "./trip-details.svelte";
|
|
||||||
|
|
||||||
let {
|
|
||||||
data,
|
|
||||||
hideCheckoutBtn,
|
|
||||||
onCheckoutBtnClick,
|
|
||||||
}: {
|
|
||||||
data: FlightTicket;
|
|
||||||
onCheckoutBtnClick: (tid: number) => void;
|
|
||||||
hideCheckoutBtn?: boolean;
|
|
||||||
} = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<TripDetails {data} />
|
|
||||||
|
|
||||||
<BaggageInfo {data} />
|
|
||||||
|
|
||||||
{#if !hideCheckoutBtn}
|
|
||||||
<div class="mt-4 flex items-center justify-end gap-4">
|
|
||||||
<Title center size="h5" color="black">
|
|
||||||
{convertAndFormatCurrency(data.priceDetails.displayPrice)}
|
|
||||||
</Title>
|
|
||||||
<Button onclick={() => onCheckoutBtnClick(data.id)}>Checkout</Button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Title from "$lib/components/atoms/title.svelte";
|
|
||||||
import { Badge } from "$lib/components/ui/badge/index.js";
|
|
||||||
import { Plane } from "@lucide/svelte";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
departure: string;
|
|
||||||
destination: string;
|
|
||||||
durationSeconds: number;
|
|
||||||
departureTime: string;
|
|
||||||
arrivalTime: string;
|
|
||||||
departureDate: string;
|
|
||||||
arrivalDate: string;
|
|
||||||
airlineName?: string;
|
|
||||||
stops?: number;
|
|
||||||
}
|
|
||||||
let {
|
|
||||||
departure,
|
|
||||||
destination,
|
|
||||||
durationSeconds,
|
|
||||||
airlineName,
|
|
||||||
stops,
|
|
||||||
departureDate,
|
|
||||||
departureTime,
|
|
||||||
arrivalDate,
|
|
||||||
arrivalTime,
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
const dH = Math.floor(durationSeconds / 3600);
|
|
||||||
const dM = Math.floor((durationSeconds % 3600) / 60);
|
|
||||||
const durationFormatted = `${dH}h ${dM}m`;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex w-full flex-col gap-4 p-2">
|
|
||||||
<div class="flex w-full items-start justify-between gap-4">
|
|
||||||
<div class="flex flex-col items-start text-start">
|
|
||||||
<Title color="black" size="h5">{departure}</Title>
|
|
||||||
<Title color="black" size="p" weight="normal">
|
|
||||||
{departureTime}
|
|
||||||
</Title>
|
|
||||||
<span class="text-xs text-gray-500 sm:text-sm">{departureDate}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col items-center gap-2 pt-2">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div class="h-2 w-2 rounded-full bg-gray-400"></div>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="w-20 border-t-2 border-gray-300 md:w-40"></div>
|
|
||||||
<Plane
|
|
||||||
class="absolute -top-[10px] left-1/2 -translate-x-1/2 transform text-primary"
|
|
||||||
size={20}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="h-2 w-2 rounded-full bg-gray-400"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span class="text-sm font-medium text-gray-600">
|
|
||||||
{durationFormatted}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div class="flex flex-col items-center gap-1">
|
|
||||||
{#if stops !== undefined}
|
|
||||||
<Badge
|
|
||||||
variant={stops > 0 ? "secondary" : "outline"}
|
|
||||||
class="font-medium"
|
|
||||||
>
|
|
||||||
{#if stops > 0}
|
|
||||||
{stops} {stops === 1 ? "Stop" : "Stops"}
|
|
||||||
{:else}
|
|
||||||
Direct Flight
|
|
||||||
{/if}
|
|
||||||
</Badge>
|
|
||||||
{/if}
|
|
||||||
{#if airlineName}
|
|
||||||
<span class="text-xs text-gray-500">{airlineName}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col items-end text-end">
|
|
||||||
<Title color="black" size="h5">{destination}</Title>
|
|
||||||
<Title color="black" size="p" weight="normal">
|
|
||||||
{arrivalTime}
|
|
||||||
</Title>
|
|
||||||
<span class="text-xs text-gray-500 sm:text-sm">{arrivalDate}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { TicketType, type FlightTicket } from "../../data/entities";
|
|
||||||
import { Badge } from "$lib/components/ui/badge/index.js";
|
|
||||||
import TicketItinerary from "./ticket-itinerary.svelte";
|
|
||||||
|
|
||||||
let { data }: { data: FlightTicket } = $props();
|
|
||||||
|
|
||||||
const outboundDuration = data.flightIteneraries.outbound.reduce(
|
|
||||||
(acc, curr) => acc + curr.durationSeconds,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
const inboundDuration = data.flightIteneraries.inbound.reduce(
|
|
||||||
(acc, curr) => acc + curr.durationSeconds,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
const isReturnTicket =
|
|
||||||
data.flightIteneraries.inbound.length > 0 &&
|
|
||||||
data.flightType === TicketType.Return;
|
|
||||||
|
|
||||||
function formatDateTime(dateTimeStr: string) {
|
|
||||||
const date = new Date(dateTimeStr);
|
|
||||||
return {
|
|
||||||
time: date.toLocaleTimeString("en-US", {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
hour12: false,
|
|
||||||
}),
|
|
||||||
date: date.toLocaleDateString("en-US", {
|
|
||||||
weekday: "short",
|
|
||||||
day: "2-digit",
|
|
||||||
month: "short",
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const outboundFirst = data.flightIteneraries.outbound[0];
|
|
||||||
const outboundLast =
|
|
||||||
data.flightIteneraries.outbound[
|
|
||||||
data.flightIteneraries.outbound.length - 1
|
|
||||||
];
|
|
||||||
|
|
||||||
const inboundFirst = isReturnTicket
|
|
||||||
? data.flightIteneraries.inbound[0]
|
|
||||||
: null;
|
|
||||||
const inboundLast = isReturnTicket
|
|
||||||
? data.flightIteneraries.inbound[
|
|
||||||
data.flightIteneraries.inbound.length - 1
|
|
||||||
]
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const outboundDeparture = formatDateTime(outboundFirst.departure.localTime);
|
|
||||||
const outboundArrival = formatDateTime(outboundLast.destination.localTime);
|
|
||||||
|
|
||||||
const inboundDeparture = inboundFirst
|
|
||||||
? formatDateTime(inboundFirst.departure.localTime)
|
|
||||||
: null;
|
|
||||||
const inboundArrival = inboundLast
|
|
||||||
? formatDateTime(inboundLast.destination.localTime)
|
|
||||||
: null;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if isReturnTicket}
|
|
||||||
<Badge variant="outline" class="w-max">
|
|
||||||
<span>Outbound</span>
|
|
||||||
</Badge>
|
|
||||||
{/if}
|
|
||||||
<TicketItinerary
|
|
||||||
departure={data.departure}
|
|
||||||
destination={data.arrival}
|
|
||||||
durationSeconds={outboundDuration}
|
|
||||||
stops={data.flightIteneraries.outbound.length - 1}
|
|
||||||
departureTime={outboundDeparture.time}
|
|
||||||
departureDate={outboundDeparture.date}
|
|
||||||
arrivalTime={outboundArrival.time}
|
|
||||||
arrivalDate={outboundArrival.date}
|
|
||||||
airlineName={outboundFirst.airline.name}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if isReturnTicket}
|
|
||||||
<div class="w-full border-t-2 border-dashed border-gray-400"></div>
|
|
||||||
|
|
||||||
{#if isReturnTicket}
|
|
||||||
<Badge variant="outline" class="w-max">
|
|
||||||
<span>Inbound</span>
|
|
||||||
</Badge>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<TicketItinerary
|
|
||||||
departure={data.arrival}
|
|
||||||
destination={data.departure}
|
|
||||||
durationSeconds={inboundDuration}
|
|
||||||
stops={data.flightIteneraries.inbound.length - 1}
|
|
||||||
departureTime={inboundDeparture?.time || ""}
|
|
||||||
departureDate={inboundDeparture?.date || ""}
|
|
||||||
arrivalTime={inboundArrival?.time || ""}
|
|
||||||
arrivalDate={inboundArrival?.date || ""}
|
|
||||||
airlineName={inboundFirst?.airline.name}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div class="w-full border-t-2 border-dashed border-gray-400"></div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Title from "$lib/components/atoms/title.svelte";
|
|
||||||
import { TicketType, type FlightTicket } from "../../data/entities";
|
|
||||||
import * as Accordion from "$lib/components/ui/accordion";
|
|
||||||
import { Badge } from "$lib/components/ui/badge";
|
|
||||||
import { formatDateTime } from "@pkg/logic/core/date.utils";
|
|
||||||
|
|
||||||
let { data }: { data: FlightTicket } = $props();
|
|
||||||
|
|
||||||
const isReturnTicket = data.flightType === TicketType.Return;
|
|
||||||
|
|
||||||
function formatDuration(seconds: number) {
|
|
||||||
const hours = Math.floor(seconds / 3600);
|
|
||||||
const minutes = Math.floor((seconds % 3600) / 60);
|
|
||||||
return `${hours}h ${minutes}m`;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Title size="h4" color="black">Trip Details</Title>
|
|
||||||
|
|
||||||
<Accordion.Root type="single" class="flex w-full flex-col gap-4">
|
|
||||||
<Accordion.Item
|
|
||||||
value="outbound"
|
|
||||||
class="rounded-lg border-2 border-gray-200 bg-white px-4 shadow-md"
|
|
||||||
>
|
|
||||||
<Accordion.Trigger class="w-full">
|
|
||||||
<div class="flex w-full flex-col gap-2">
|
|
||||||
<Badge variant="outline" class="w-max">Outbound</Badge>
|
|
||||||
<div class="flex w-full items-center justify-between">
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="font-medium">
|
|
||||||
{data.flightIteneraries.outbound[0].departure
|
|
||||||
.station.code}
|
|
||||||
</span>
|
|
||||||
<span class="text-gray-500">→</span>
|
|
||||||
<span class="font-medium">
|
|
||||||
{data.flightIteneraries.outbound[
|
|
||||||
data.flightIteneraries.outbound.length - 1
|
|
||||||
].destination.station.code}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-sm text-gray-600">
|
|
||||||
{data.flightIteneraries.outbound[0].airline.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span>
|
|
||||||
{formatDuration(
|
|
||||||
data.flightIteneraries.outbound.reduce(
|
|
||||||
(acc, curr) => acc + curr.durationSeconds,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Accordion.Trigger>
|
|
||||||
<Accordion.Content>
|
|
||||||
<div
|
|
||||||
class="flex flex-col gap-4 border-t-2 border-dashed border-gray-200 p-4"
|
|
||||||
>
|
|
||||||
{#each data.flightIteneraries.outbound as flight, index}
|
|
||||||
{#if index > 0}
|
|
||||||
<div class="my-2 border-t border-dashed"></div>
|
|
||||||
{/if}
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span>Flight {flight.flightNumber}</span>
|
|
||||||
<span class="text-sm text-gray-500">
|
|
||||||
• {flight.airline.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span>{formatDuration(flight.durationSeconds)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<span class="text-sm font-semibold">
|
|
||||||
{formatDateTime(flight.departure.localTime)
|
|
||||||
.time}
|
|
||||||
</span>
|
|
||||||
<span class="text-sm text-gray-500">
|
|
||||||
{formatDateTime(flight.departure.localTime)
|
|
||||||
.date}
|
|
||||||
</span>
|
|
||||||
<span class="mt-1 text-sm text-gray-600">
|
|
||||||
{flight.departure.station.city} ({flight
|
|
||||||
.departure.station.code})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col items-end">
|
|
||||||
<span class="text-sm font-semibold">
|
|
||||||
{formatDateTime(flight.destination.localTime)
|
|
||||||
.time}
|
|
||||||
</span>
|
|
||||||
<span class="text-sm text-gray-500">
|
|
||||||
{formatDateTime(flight.destination.localTime)
|
|
||||||
.date}
|
|
||||||
</span>
|
|
||||||
<span class="mt-1 text-sm text-gray-600">
|
|
||||||
{flight.destination.station.city} ({flight
|
|
||||||
.destination.station.code})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</Accordion.Content>
|
|
||||||
</Accordion.Item>
|
|
||||||
|
|
||||||
{#if isReturnTicket && data.flightIteneraries.inbound.length > 0}
|
|
||||||
<Accordion.Item
|
|
||||||
value="inbound"
|
|
||||||
class="rounded-lg border-2 border-gray-200 bg-white px-4 shadow-md"
|
|
||||||
>
|
|
||||||
<Accordion.Trigger class="w-full">
|
|
||||||
<div class="flex w-full flex-col gap-2">
|
|
||||||
<Badge variant="outline" class="w-max">Inbound</Badge>
|
|
||||||
<div class="flex w-full items-center justify-between">
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="font-medium">
|
|
||||||
{data.flightIteneraries.inbound[0].departure
|
|
||||||
.station.code}
|
|
||||||
</span>
|
|
||||||
<span class="text-gray-500">→</span>
|
|
||||||
<span class="font-medium">
|
|
||||||
{data.flightIteneraries.inbound[
|
|
||||||
data.flightIteneraries.inbound.length - 1
|
|
||||||
].destination.station.code}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-sm text-gray-600">
|
|
||||||
{data.flightIteneraries.inbound[0].airline.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span>
|
|
||||||
{formatDuration(
|
|
||||||
data.flightIteneraries.inbound.reduce(
|
|
||||||
(acc, curr) => acc + curr.durationSeconds,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Accordion.Trigger>
|
|
||||||
|
|
||||||
<Accordion.Content>
|
|
||||||
<div
|
|
||||||
class="flex flex-col gap-4 border-t-2 border-dashed border-gray-200 p-4"
|
|
||||||
>
|
|
||||||
{#each data.flightIteneraries.inbound as flight, index}
|
|
||||||
{#if index > 0}
|
|
||||||
<div class="my-2 border-t border-dashed"></div>
|
|
||||||
{/if}
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span>Flight {flight.flightNumber}</span>
|
|
||||||
<span class="text-sm text-gray-500">
|
|
||||||
• {flight.airline.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
>{formatDuration(
|
|
||||||
flight.durationSeconds,
|
|
||||||
)}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<span class="text-sm font-semibold">
|
|
||||||
{formatDateTime(
|
|
||||||
flight.departure.localTime,
|
|
||||||
).time}
|
|
||||||
</span>
|
|
||||||
<span class="text-sm text-gray-500">
|
|
||||||
{formatDateTime(
|
|
||||||
flight.departure.localTime,
|
|
||||||
).date}
|
|
||||||
</span>
|
|
||||||
<span class="mt-1 text-sm text-gray-600">
|
|
||||||
{flight.departure.station.city} ({flight
|
|
||||||
.departure.station.code})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col items-end">
|
|
||||||
<span class="text-sm font-semibold">
|
|
||||||
{formatDateTime(
|
|
||||||
flight.destination.localTime,
|
|
||||||
).time}
|
|
||||||
</span>
|
|
||||||
<span class="text-sm text-gray-500">
|
|
||||||
{formatDateTime(
|
|
||||||
flight.destination.localTime,
|
|
||||||
).date}
|
|
||||||
</span>
|
|
||||||
<span class="mt-1 text-sm text-gray-600">
|
|
||||||
{flight.destination.station.city} ({flight
|
|
||||||
.destination.station.code})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</Accordion.Content>
|
|
||||||
</Accordion.Item>
|
|
||||||
{/if}
|
|
||||||
</Accordion.Root>
|
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
import { createTRPCRouter } from "../t";
|
|
||||||
import { userRouter } from "$lib/domains/user/domain/router";
|
|
||||||
import { authRouter } from "$lib/domains/auth/domain/router";
|
import { authRouter } from "$lib/domains/auth/domain/router";
|
||||||
import { ticketRouter } from "$lib/domains/ticket/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 { orderRouter } from "$lib/domains/order/domain/router";
|
import { orderRouter } from "$lib/domains/order/domain/router";
|
||||||
import { ckflowRouter } from "$lib/domains/ckflow/domain/router";
|
import { productRouter } from "$lib/domains/product/router";
|
||||||
|
import { userRouter } from "$lib/domains/user/domain/router";
|
||||||
|
import { createTRPCRouter } from "../t";
|
||||||
|
|
||||||
export const router = createTRPCRouter({
|
export const router = createTRPCRouter({
|
||||||
auth: authRouter,
|
auth: authRouter,
|
||||||
user: userRouter,
|
user: userRouter,
|
||||||
ticket: ticketRouter,
|
|
||||||
currency: currencyRouter,
|
currency: currencyRouter,
|
||||||
order: orderRouter,
|
order: orderRouter,
|
||||||
ckflow: ckflowRouter,
|
ckflow: ckflowRouter,
|
||||||
|
product: productRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Router = typeof router;
|
export type Router = typeof router;
|
||||||
|
|||||||
7
apps/frontend/src/routes/(main)/[pageid]/+page.server.ts
Normal file
7
apps/frontend/src/routes/(main)/[pageid]/+page.server.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { getProductUseCases } from "$lib/domains/product/usecases";
|
||||||
|
import type { PageServerLoad } from "./$types";
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params }) => {
|
||||||
|
const pid = params.pageid;
|
||||||
|
return await getProductUseCases().getProductByLinkId(pid);
|
||||||
|
};
|
||||||
@@ -1,4 +1,45 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Title from "$lib/components/atoms/title.svelte";
|
||||||
|
import { checkoutSessionIdStore } from "$lib/domains/checkout/sid.store";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { toast } from "svelte-sonner";
|
||||||
|
import type { PageData } from "./$types";
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
toast("Do the check here to then either redirect or show the error");
|
||||||
|
if (data.error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!data.data) {
|
||||||
|
toast.error("An error occurred during checkout", {
|
||||||
|
description: "Please try again later or contact support",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast("Please hold...", {
|
||||||
|
description: "Preparing checkout session",
|
||||||
|
});
|
||||||
|
window.location.replace(
|
||||||
|
`/checkout/${$checkoutSessionIdStore}/${data.data.linkId}`,
|
||||||
|
);
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<span>Show the thing to do the thing about the thing around the thing</span>
|
<div class="flex w-full flex-col items-center justify-center gap-8 p-20">
|
||||||
|
{#if data.data}
|
||||||
|
<Title size="h3" weight="medium">{data.data.title}</Title>
|
||||||
|
<p>{data.data.description}</p>
|
||||||
|
<span>
|
||||||
|
Either show the user the product as being valid and redirecting them
|
||||||
|
to the checkout
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span
|
||||||
|
>Show the user an error around "page not found" or "expired link"</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { getProductUseCases } from "$lib/domains/product/usecases";
|
||||||
|
import type { PageServerLoad } from "./$types";
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params }) => {
|
||||||
|
return await getProductUseCases().getProductByLinkId(params.plid);
|
||||||
|
};
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from "$lib/components/atoms/icon.svelte";
|
||||||
|
import Title from "$lib/components/atoms/title.svelte";
|
||||||
|
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
|
||||||
|
import CheckoutConfirmationSection from "$lib/domains/checkout/checkout-confirmation-section.svelte";
|
||||||
|
import CheckoutLoadingSection from "$lib/domains/checkout/checkout-loading-section.svelte";
|
||||||
|
import CheckoutStepsIndicator from "$lib/domains/checkout/checkout-steps-indicator.svelte";
|
||||||
|
import { checkoutVM } from "$lib/domains/checkout/checkout.vm.svelte";
|
||||||
|
import InitialInfoSection from "$lib/domains/checkout/initial-info-section.svelte";
|
||||||
|
import PaymentInfoSection from "$lib/domains/checkout/payment-info-section/index.svelte";
|
||||||
|
import PaymentSummary from "$lib/domains/checkout/payment-summary.svelte";
|
||||||
|
import PaymentVerificationSection from "$lib/domains/checkout/payment-verification-section.svelte";
|
||||||
|
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
|
||||||
|
import { CheckoutStep } from "$lib/domains/order/data/entities";
|
||||||
|
import { productStore } from "$lib/domains/product/store";
|
||||||
|
import { onDestroy, onMount } from "svelte";
|
||||||
|
import { toast } from "svelte-sonner";
|
||||||
|
import SearchIcon from "~icons/solar/magnifer-linear";
|
||||||
|
import type { PageData } from "./$types";
|
||||||
|
|
||||||
|
let { data: pageData }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (pageData.error) {
|
||||||
|
toast.error(pageData.error.message, {
|
||||||
|
description: pageData.error.userHint,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!pageData.data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
productStore.set(pageData.data);
|
||||||
|
checkoutVM.loading = false;
|
||||||
|
checkoutVM.setupPinger();
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
await ckFlowVM.initFlow();
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
checkoutVM.reset();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="grid h-full w-full place-items-center">
|
||||||
|
<MaxWidthWrapper cls="p-4 md:p-8 lg:p-10 3xl:p-0">
|
||||||
|
{#if !pageData.data || !!pageData.error}
|
||||||
|
<div class="grid h-full min-h-screen w-full place-items-center">
|
||||||
|
<div class="flex flex-col items-center justify-center gap-4">
|
||||||
|
<Icon icon={SearchIcon} cls="w-12 h-12" />
|
||||||
|
<Title size="h4" color="black">Product not found</Title>
|
||||||
|
<p>Something went wrong, please try again or contact us</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if checkoutVM.checkoutStep === CheckoutStep.Confirmation}
|
||||||
|
<div class="grid w-full place-items-center p-4 py-32">
|
||||||
|
<CheckoutConfirmationSection />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<CheckoutStepsIndicator />
|
||||||
|
<div class="flex w-full flex-col gap-8 lg:flex-row">
|
||||||
|
<div class="flex w-full flex-col">
|
||||||
|
<div class="flex w-full flex-col gap-12">
|
||||||
|
{#if checkoutVM.loading}
|
||||||
|
<CheckoutLoadingSection />
|
||||||
|
{:else if checkoutVM.checkoutStep === CheckoutStep.Initial}
|
||||||
|
<InitialInfoSection />
|
||||||
|
{:else if checkoutVM.checkoutStep === CheckoutStep.Payment}
|
||||||
|
<PaymentInfoSection />
|
||||||
|
{:else if checkoutVM.checkoutStep === CheckoutStep.Verification}
|
||||||
|
<PaymentVerificationSection />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="grid w-full place-items-center lg:max-w-lg lg:place-items-start"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex w-full flex-col gap-8 pt-8 md:max-w-md lg:max-w-full lg:pt-0"
|
||||||
|
>
|
||||||
|
<PaymentSummary />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</MaxWidthWrapper>
|
||||||
|
</div>
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { getTC } from "$lib/domains/ticket/domain/controller";
|
|
||||||
import type { PageServerLoad } from "./$types";
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params }) => {
|
|
||||||
return await getTC().getTicketById(Number(params.tid ?? "-1"));
|
|
||||||
};
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onDestroy, onMount } from "svelte";
|
|
||||||
import type { PageData } from "./$types";
|
|
||||||
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 SearchIcon from "~icons/solar/magnifer-linear";
|
|
||||||
import { toast } from "svelte-sonner";
|
|
||||||
import { ticketCheckoutVM } from "$lib/domains/ticket/view/checkout/flight-checkout.vm.svelte";
|
|
||||||
import { CheckoutStep } from "$lib/domains/ticket/data/entities/index";
|
|
||||||
import InitialInfoSection from "$lib/domains/ticket/view/checkout/initial-info-section.svelte";
|
|
||||||
import { flightTicketStore } from "$lib/domains/ticket/data/store";
|
|
||||||
import CheckoutLoadingSection from "$lib/domains/ticket/view/checkout/checkout-loading-section.svelte";
|
|
||||||
import CheckoutConfirmationSection from "$lib/domains/ticket/view/checkout/checkout-confirmation-section.svelte";
|
|
||||||
import CheckoutStepsIndicator from "$lib/domains/ticket/view/checkout/checkout-steps-indicator.svelte";
|
|
||||||
import PaymentInfoSection from "$lib/domains/ticket/view/checkout/payment-info-section/index.svelte";
|
|
||||||
import PaymentVerificationSection from "$lib/domains/ticket/view/checkout/payment-verification-section.svelte";
|
|
||||||
import PaymentSummary from "$lib/domains/ticket/view/checkout/payment-summary.svelte";
|
|
||||||
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
|
|
||||||
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
|
|
||||||
import UpdatePriceDialog from "$lib/domains/ticket/view/checkout/update-price-dialog.svelte";
|
|
||||||
|
|
||||||
let { data: pageData }: { data: PageData } = $props();
|
|
||||||
|
|
||||||
function trackConversion() {
|
|
||||||
console.log("ct-ing");
|
|
||||||
let callback = function () {
|
|
||||||
console.log("ct-ed");
|
|
||||||
};
|
|
||||||
// window.gtag("event", "conversion", {
|
|
||||||
// send_to: "AW-17085207253/ZFAVCLD6tMkaENWl7tI_",
|
|
||||||
// value: 1.0,
|
|
||||||
// currency: "USD",
|
|
||||||
// event_callback: callback,
|
|
||||||
// });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if (pageData.error) {
|
|
||||||
toast.error(pageData.error.message, {
|
|
||||||
description: pageData.error.userHint,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!pageData.data) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
flightTicketStore.set(pageData.data);
|
|
||||||
ticketCheckoutVM.loading = false;
|
|
||||||
ticketCheckoutVM.setupPinger();
|
|
||||||
|
|
||||||
setTimeout(async () => {
|
|
||||||
await ckFlowVM.initFlow();
|
|
||||||
trackConversion();
|
|
||||||
}, 2000);
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
ticketCheckoutVM.reset();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<UpdatePriceDialog />
|
|
||||||
|
|
||||||
<div class="grid h-full w-full place-items-center">
|
|
||||||
<MaxWidthWrapper cls="p-4 md:p-8 lg:p-10 3xl:p-0">
|
|
||||||
{#if !pageData.data || !!pageData.error}
|
|
||||||
<div class="grid h-full min-h-screen w-full place-items-center">
|
|
||||||
<div class="flex flex-col items-center justify-center gap-4">
|
|
||||||
<Icon icon={SearchIcon} cls="w-12 h-12" />
|
|
||||||
<Title size="h4" color="black">No ticket found</Title>
|
|
||||||
<Button href="/search" variant="default">
|
|
||||||
Search for another ticket
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else if ticketCheckoutVM.checkoutStep === CheckoutStep.Confirmation}
|
|
||||||
<div class="grid w-full place-items-center p-4 py-32">
|
|
||||||
<CheckoutConfirmationSection />
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<CheckoutStepsIndicator />
|
|
||||||
<div class="flex w-full flex-col gap-8 lg:flex-row">
|
|
||||||
<div class="flex w-full flex-col">
|
|
||||||
<div class="flex w-full flex-col gap-12">
|
|
||||||
{#if ticketCheckoutVM.loading}
|
|
||||||
<CheckoutLoadingSection />
|
|
||||||
{:else if ticketCheckoutVM.checkoutStep === CheckoutStep.Initial}
|
|
||||||
<InitialInfoSection />
|
|
||||||
{:else if ticketCheckoutVM.checkoutStep === CheckoutStep.Payment}
|
|
||||||
<PaymentInfoSection />
|
|
||||||
{:else if ticketCheckoutVM.checkoutStep === CheckoutStep.Verification}
|
|
||||||
<PaymentVerificationSection />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="grid w-full place-items-center lg:max-w-lg lg:place-items-start"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex w-full flex-col gap-8 pt-8 md:max-w-md lg:max-w-full lg:pt-0"
|
|
||||||
>
|
|
||||||
<PaymentSummary />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</MaxWidthWrapper>
|
|
||||||
</div>
|
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import Title from "$lib/components/atoms/title.svelte";
|
import Title from "$lib/components/atoms/title.svelte";
|
||||||
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
|
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
|
||||||
import CheckIcon from "~icons/ic/round-check";
|
import CheckIcon from "~icons/ic/round-check";
|
||||||
|
// Maybe todo? if the `uid` search param is present, do something?? figure out later
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="grid min-h-[80vh] w-full place-items-center px-4 sm:px-6">
|
<div class="grid min-h-[80vh] w-full place-items-center px-4 sm:px-6">
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user