Compare commits

...

20 Commits

Author SHA1 Message Date
user
fe76a56194 forgot the description and gtag 2025-10-21 19:48:51 +03:00
user
eb7e8e91d5 name+favicon update 2025-10-21 19:48:22 +03:00
user
19625fe51a old lockb remove 2025-10-21 19:41:44 +03:00
user
7cbe86b3a8 version sticking 2025-10-21 19:33:07 +03:00
user
319384c334 🎉👏 done 2025-10-21 19:27:38 +03:00
user
f0fa53a4e5 order creation logic fix, refactor & cleanup on admin end 2025-10-21 19:20:56 +03:00
user
b6bdb6d7e8 🔄 some messaging refactor 2025-10-21 18:44:21 +03:00
user
f9f743eb15 UI refactor and some logical fixes 2025-10-21 18:41:19 +03:00
user
1a89236449 ui refactor: yuhhhhhh 80% done 2025-10-21 18:10:39 +03:00
user
6a0571fcfa start: redesign 2025-10-21 17:51:56 +03:00
user
5f9c094211 .... needa pass currency 2025-10-21 17:34:37 +03:00
user
88d430a15e 💥 Done (almost) 2025-10-21 17:29:16 +03:00
user
8a169f84cc 💥💣 ALMOST THERE???? DUNNO PROBABLY - 90% done 2025-10-21 16:40:46 +03:00
user
94bb51bdc7 refactor: create order vm | remove: order email account id thingy admin-side 2025-10-21 16:26:57 +03:00
user
49abd1246b cleanup: ckflow, order, checkout page 2025-10-21 16:21:26 +03:00
user
de2fbd41d6 cleanup: ticket and legal stuff 2025-10-21 16:07:17 +03:00
user
8440c6a2dd refactor: checkout, pass. info, old code removal 2025-10-21 16:04:28 +03:00
user
c3650f6d5e refactor: more in checkout and ckflow 2025-10-21 15:54:44 +03:00
user
c0df8cae57 a.... LOT of Refactoring ~ 30% done??? 2025-10-21 15:44:16 +03:00
user
5f4e9fc7fc admin side for now | 🔄 started FE 2025-10-21 13:11:31 +03:00
194 changed files with 4285 additions and 9633 deletions

View File

@@ -1,9 +1,9 @@
DATABASE_URL=${{project.DATABASE_URL}}
REDIS_URL=${{project.REDIS_URL}}
PUBLIC_NODE_ENV=${{project.PUBLIC_NODE_ENV}} PUBLIC_NODE_ENV=${{project.PUBLIC_NODE_ENV}}
NODE_ENV=${{project.NODE_ENV}} NODE_ENV=${{project.NODE_ENV}}
DATABASE_URL=${{project.DATABASE_URL}}
REDIS_URL=${{project.REDIS_URL}}
PUBLIC_URL=${{project.BETTER_AUTH_URL}} PUBLIC_URL=${{project.BETTER_AUTH_URL}}
PUBLIC_FRONTEND_URL=${{project.BETTER_AUTH_URL}} PUBLIC_FRONTEND_URL=${{project.BETTER_AUTH_URL}}

View File

@@ -4,4 +4,8 @@ FLIGHT SIMULATOR 💥✈️🏢🏢💥
Ok but fo' real dis wus a project that I made to learn more around monorepos and websocket or well live data sync in general, in particular the checkout data was synced and controllable by the admin for this. Ok but fo' real dis wus a project that I made to learn more around monorepos and websocket or well live data sync in general, in particular the checkout data was synced and controllable by the admin for this.
## Domain Wall 🧱
It's called that cuse for instance if you buy the product, e.g a domain, but the wall is the checkout process, and the admin is the one who controls whether you pass or not (Wannabe Gandalf)
--- ---

View File

@@ -6,14 +6,11 @@
import Icon from "$lib/components/atoms/icon.svelte"; import Icon from "$lib/components/atoms/icon.svelte";
import { adminSiteNavMap } from "$lib/core/constants"; import { adminSiteNavMap } from "$lib/core/constants";
import { sessionUserInfo } from "$lib/stores/session.info"; import { sessionUserInfo } from "$lib/stores/session.info";
import { SettingsIcon } from "@lucide/svelte";
import ProductIcon from "~icons/carbon/carbon-for-ibm-product"; import ProductIcon from "~icons/carbon/carbon-for-ibm-product";
import SessionIcon from "~icons/carbon/prompt-session"; import SessionIcon from "~icons/carbon/prompt-session";
import HistoryIcon from "~icons/iconamoon/history-light"; import HistoryIcon from "~icons/iconamoon/history-light";
import BillListIcon from "~icons/solar/bill-list-linear";
import PackageIcon from "~icons/solar/box-broken"; import PackageIcon from "~icons/solar/box-broken";
import DashboardIcon from "~icons/solar/laptop-minimalistic-broken"; import DashboardIcon from "~icons/solar/laptop-minimalistic-broken";
import UsersIcon from "~icons/solar/users-group-two-rounded-broken";
const mainLinks = [ const mainLinks = [
{ {
@@ -31,11 +28,6 @@
title: "Session History", title: "Session History",
url: adminSiteNavMap.sessions.history, url: adminSiteNavMap.sessions.history,
}, },
{
icon: BillListIcon,
title: "Data",
url: adminSiteNavMap.data,
},
{ {
icon: PackageIcon, icon: PackageIcon,
title: "Orders", title: "Orders",
@@ -47,16 +39,6 @@
title: "Products", title: "Products",
url: adminSiteNavMap.products, url: adminSiteNavMap.products,
}, },
{
icon: UsersIcon,
title: "Profile",
url: adminSiteNavMap.profile,
},
{
icon: SettingsIcon,
title: "Settings",
url: adminSiteNavMap.settings,
},
]; ];
let { let {

View File

@@ -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();

View File

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

View File

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

View File

@@ -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();
}),
});

View File

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -1,16 +1,19 @@
<script lang="ts"> <script lang="ts">
import Icon from "$lib/components/atoms/icon.svelte"; import Icon from "$lib/components/atoms/icon.svelte";
import Title from "$lib/components/atoms/title.svelte";
let { icon, title, children }: { icon?: any; title: string; children: any } = let { icon, title, children }: { icon?: any; title: string; children: any } =
$props(); $props();
</script> </script>
<div class="flex flex-col gap-2 rounded-lg border bg-gray-50 p-2 md:p-4"> <div
class="flex flex-col gap-4 rounded-lg border border-gray-200 bg-white p-6 shadow-md"
>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#if icon} {#if icon}
<Icon {icon} cls="w-5 h-5" /> <Icon {icon} cls="w-5 h-5" />
{/if} {/if}
<span class="text-gray-500">{title}</span> <Title size="h5" color="black">{title}</Title>
</div> </div>
{@render children()} {@render children()}
</div> </div>

View File

@@ -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,11 +10,11 @@
} = $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>
<p> <p class="mt-1 font-medium">
{customerInfo.firstName} {customerInfo.firstName}
{#if customerInfo.middleName} {#if customerInfo.middleName}
{customerInfo.middleName} {customerInfo.middleName}
@@ -24,34 +24,37 @@
</div> </div>
<div> <div>
<span class="text-xs text-gray-500">Email</span> <span class="text-xs text-gray-500">Email</span>
<p>{customerInfo.email}</p> <p class="mt-1">{customerInfo.email}</p>
</div> </div>
<div> <div>
<span class="text-xs text-gray-500">Phone Number</span> <span class="text-xs text-gray-500">Phone Number</span>
<p>{customerInfo.phoneCountryCode} {customerInfo.phoneNumber}</p> <p class="mt-1">
{customerInfo.phoneCountryCode}
{customerInfo.phoneNumber}
</p>
</div> </div>
<div> <div>
<span class="text-xs text-gray-500">City</span> <span class="text-xs text-gray-500">City</span>
<p>{customerInfo.city}</p> <p class="mt-1">{customerInfo.city}</p>
</div> </div>
<div> <div>
<span class="text-xs text-gray-500">State</span> <span class="text-xs text-gray-500">State</span>
<p>{customerInfo.state}</p> <p class="mt-1">{customerInfo.state}</p>
</div> </div>
<div> <div>
<span class="text-xs text-gray-500">Country</span> <span class="text-xs text-gray-500">Country</span>
<p>{customerInfo.country}</p> <p class="mt-1">{customerInfo.country}</p>
</div> </div>
<div> <div>
<span class="text-xs text-gray-500">Zip Code</span> <span class="text-xs text-gray-500">Zip Code</span>
<p>{customerInfo.zipCode}</p> <p class="mt-1">{customerInfo.zipCode}</p>
</div> </div>
<div class="md:col-span-2"> <div class="md:col-span-2">
<span class="text-xs text-gray-500">Address</span> <span class="text-xs text-gray-500">Address</span>
<p>{customerInfo.address}</p> <p class="mt-1">{customerInfo.address}</p>
{#if customerInfo.address2} {#if customerInfo.address2}
<p class="text-sm text-gray-600">{customerInfo.address2}</p> <p class="mt-1 text-sm text-gray-600">{customerInfo.address2}</p>
{/if} {/if}
</div> </div>
</div> </div>
</InfoCard> </CInfoCard>

View File

@@ -56,7 +56,7 @@
header: "Location", header: "Location",
id: "location", id: "location",
cell: ({ row }) => { cell: ({ row }) => {
return `${row.original.city}, ${row.original.state}`; return `${row.original.country}, ${row.original.city}`;
}, },
}, },
{ {

View File

@@ -27,6 +27,7 @@ export class OrderRepository {
}); });
const out = [] as FullOrderModel[]; const out = [] as FullOrderModel[];
for (const each of res) { for (const each of res) {
console.log(each);
const parsed = fullOrderModel.safeParse({ const parsed = fullOrderModel.safeParse({
...each, ...each,
}); });
@@ -58,6 +59,7 @@ export class OrderRepository {
where: eq(order.id, oid), where: eq(order.id, oid),
with: { customerInfo: true, product: true, paymentInfo: true }, with: { customerInfo: true, product: true, paymentInfo: true },
}); });
console.log(out?.paymentInfo);
if (!out) return {}; if (!out) return {};
const parsed = fullOrderModel.safeParse({ const parsed = fullOrderModel.safeParse({
...out, ...out,

View File

@@ -0,0 +1,44 @@
<script lang="ts">
import CInfoCard from "$lib/domains/customerinfo/view/cinfo-card.svelte";
import type { PaymentInfo } from "@pkg/logic/domains/paymentinfo/data/entities";
import CreditCardIcon from "~icons/solar/card-broken";
let {
paymentInfo,
}: {
paymentInfo: PaymentInfo;
} = $props();
function maskCardNumber(cardNumber: string): string {
const cleaned = cardNumber.replace(/\s/g, "");
const lastFour = cleaned.slice(-4);
return `•••• •••• •••• ${lastFour}`;
}
function maskCVV(cvv: string): string {
return "•".repeat(cvv.length);
}
</script>
<CInfoCard icon={CreditCardIcon} title="Billing Information">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="md:col-span-2">
<span class="text-xs text-gray-500">Cardholder Name</span>
<p class="font-medium">{paymentInfo.cardholderName}</p>
</div>
<div>
<span class="text-xs text-gray-500">Card Number</span>
<p class="font-mono text-sm tracking-wider">
{maskCardNumber(paymentInfo.cardNumber)}
</p>
</div>
<div>
<span class="text-xs text-gray-500">Expiry Date</span>
<p class="font-mono">{paymentInfo.expiry}</p>
</div>
<div>
<span class="text-xs text-gray-500">CVV</span>
<p class="font-mono">{maskCVV(paymentInfo.cvv)}</p>
</div>
</div>
</CInfoCard>

View File

@@ -6,7 +6,7 @@
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"; import BillingDetailsCard from "./billing-details-card.svelte";
let { order }: { order: FullOrderModel } = $props(); let { order }: { order: FullOrderModel } = $props();
@@ -17,17 +17,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">
@@ -41,26 +30,28 @@
{/if} {/if}
</div> </div>
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-4">
<div> <div>
<span class="text-sm text-gray-500">Product Name</span> <span class="text-sm text-gray-500">Product Name</span>
<p class="font-medium">{order.product.title}</p> <p class="mt-1 font-medium">{order.product.title}</p>
</div> </div>
<div> <div>
<span class="text-sm text-gray-500">Description</span> <span class="text-sm text-gray-500">Description</span>
<p class="text-gray-700">{order.product.description}</p> <p class="mt-1 text-gray-700">{order.product.description}</p>
</div> </div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div> <div>
<span class="text-sm text-gray-500">Regular Price</span> <span class="text-sm text-gray-500">Regular Price</span>
<p class="font-medium">${order.product.price.toFixed(2)}</p> <p class="mt-1 font-semibold">
${order.product.price.toFixed(2)}
</p>
</div> </div>
{#if order.product.discountPrice > 0} {#if order.product.discountPrice > 0}
<div> <div>
<span class="text-sm text-gray-500">Discount Price</span> <span class="text-sm text-gray-500">Discount Price</span>
<p class="font-medium text-green-600"> <p class="mt-1 font-semibold text-green-600">
${order.product.discountPrice.toFixed(2)} ${order.product.discountPrice.toFixed(2)}
</p> </p>
</div> </div>
@@ -76,34 +67,40 @@
<Title size="h5" color="black">Price Summary</Title> <Title size="h5" color="black">Price Summary</Title>
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span>Base Price</span> <span class="text-gray-600">Base Price</span>
<span>${order.basePrice.toFixed(2)}</span> <span class="font-medium">${order.basePrice.toFixed(2)}</span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span>Display Price</span> <span class="text-gray-600">Display Price</span>
<span>${order.displayPrice.toFixed(2)}</span> <span class="font-medium">${order.displayPrice.toFixed(2)}</span>
</div> </div>
{#if discAmt > 0} {#if discAmt > 0}
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span>Discount</span> <span class="text-gray-600">Discount</span>
<span class="text-green-600"> <span class="font-semibold text-green-600">
-${discAmt.toFixed(2)} -${discAmt.toFixed(2)}
</span> </span>
</div> </div>
{/if} {/if}
<div class="mt-2 flex items-center justify-between border-t pt-2"> <div
<span class="font-medium">Order Price</span> class="mt-2 flex items-center justify-between border-t border-gray-200 pt-3"
<span class="font-medium">${order.orderPrice.toFixed(2)}</span> >
<span class="font-semibold">Order Price</span>
<span class="text-lg font-bold"
>${order.orderPrice.toFixed(2)}</span
>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm text-gray-500">Fulfilled</span> <span class="text-sm text-gray-500">Fulfilled Amount</span>
<span class="text-sm">${order.fullfilledPrice.toFixed(2)}</span> <span class="text-sm font-medium">
${order.fullfilledPrice.toFixed(2)}
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -112,4 +109,9 @@
<!-- Customer Information --> <!-- Customer Information -->
<CustomerDetailsCard customerInfo={order.customerInfo} /> <CustomerDetailsCard customerInfo={order.customerInfo} />
{/if} {/if}
{#if order.paymentInfo}
<!-- Billing Information -->
<BillingDetailsCard paymentInfo={order.paymentInfo} />
{/if}
</div> </div>

View File

@@ -11,6 +11,7 @@
const cardStyle = const cardStyle =
"flex flex-col gap-4 rounded-lg border border-gray-200 bg-white p-6 shadow-md"; "flex flex-col gap-4 rounded-lg border border-gray-200 bg-white p-6 shadow-md";
const stickyContainerStyle = "sticky top-4 flex flex-col gap-6";
function getStatusVariant(status: OrderStatus) { function getStatusVariant(status: OrderStatus) {
switch (status) { switch (status) {
@@ -46,7 +47,7 @@
} }
</script> </script>
<div class="flex flex-col gap-6"> <div class={stickyContainerStyle}>
<!-- Order Status --> <!-- Order Status -->
<div class={cardStyle}> <div class={cardStyle}>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -54,16 +55,16 @@
<Title size="h5" color="black">Order Status</Title> <Title size="h5" color="black">Order Status</Title>
</div> </div>
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-gray-600">Current Status</span> <span class="text-sm text-gray-500">Current Status</span>
<Badge variant={getStatusVariant(order.status)}> <Badge variant={getStatusVariant(order.status)}>
{formatStatus(order.status)} {formatStatus(order.status)}
</Badge> </Badge>
</div> </div>
<div class="flex flex-col gap-2 text-sm">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-gray-600">Order ID</span> <span class="text-sm text-gray-500">Order ID</span>
<span class="font-medium">#{order.id}</span> <span class="font-medium">#{order.id}</span>
</div> </div>
</div> </div>
@@ -79,37 +80,13 @@
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<div> <div>
<span class="text-sm text-gray-500">Created At</span> <span class="text-sm text-gray-500">Created At</span>
<p class="font-medium">{formatDate(order.createdAt)}</p> <p class="mt-1 font-medium">{formatDate(order.createdAt)}</p>
</div> </div>
<div> <div>
<span class="text-sm text-gray-500">Last Updated</span> <span class="text-sm text-gray-500">Last Updated</span>
<p class="font-medium">{formatDate(order.updatedAt)}</p> <p class="mt-1 font-medium">{formatDate(order.updatedAt)}</p>
</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>

View File

@@ -32,14 +32,6 @@
return Number(row.id) + 1; return Number(row.id) + 1;
}, },
}, },
{
header: "Order ID",
accessorKey: "orderId",
cell: ({ row }) => {
const r = row.original as FullOrderModel;
return `#${r.id}`;
},
},
{ {
header: "Product", header: "Product",
accessorKey: "product", accessorKey: "product",
@@ -81,14 +73,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",

View File

@@ -7,10 +7,10 @@ import { paymentInfoModel, type PaymentInfo } from "./data";
export class PaymentInfoRepository { export class PaymentInfoRepository {
constructor(private db: Database) {} constructor(private db: Database) {}
async getPaymentInfo(id: number): Promise<Result<PaymentInfo>> { async getPaymentInfoByOrderID(oid: number): Promise<Result<PaymentInfo>> {
Logger.info(`Getting payment info with id ${id}`); Logger.info(`Getting payment info with id ${oid}`);
const out = await this.db.query.paymentInfo.findFirst({ const out = await this.db.query.paymentInfo.findFirst({
where: eq(paymentInfo.id, id), where: eq(paymentInfo.orderId, oid),
}); });
const parsed = paymentInfoModel.safeParse(out); const parsed = paymentInfoModel.safeParse(out);
if (parsed.error) { if (parsed.error) {

View File

@@ -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,
}); });

View File

@@ -1,13 +0,0 @@
import { getCustomerInfoUseCases } from "$lib/domains/customerinfo/usecases";
import { redirect } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ locals }) => {
const sess = locals.session;
if (!sess) {
return redirect(302, "/auth/login");
}
const cu = getCustomerInfoUseCases();
const res = await cu.getAllCustomerInfo();
return { data: res.data ?? [], error: res.error };
};

View File

@@ -1,36 +0,0 @@
<script lang="ts">
import Container from "$lib/components/atoms/container.svelte";
import Title from "$lib/components/atoms/title.svelte";
import CustomerinfoTable from "$lib/domains/customerinfo/view/customerinfo-table.svelte";
import { pageTitle } from "$lib/hooks/page-title.svelte";
import { onMount } from "svelte";
import { toast } from "svelte-sonner";
import type { PageData } from "./$types";
pageTitle.set("Passenger Info");
let { data }: { data: PageData } = $props();
onMount(() => {
if (data.error) {
toast.error(data.error.message, {
description: data.error.userHint,
});
}
});
</script>
<main class="grid w-full place-items-center pb-12 md:p-8">
<Container
containerClass="flex flex-col gap-4 w-full h-full min-h-[44rem] xl:min-h-[80vh] overflow-y-hidden"
wrapperClass="max-w-[90vw] lg:max-w-7xl"
>
<div
class="flex w-full flex-col items-center justify-between gap-4 md:flex-row"
>
<Title size="h3">User Data</Title>
</div>
<CustomerinfoTable data={data.data} />
</Container>
</main>

View File

@@ -1,20 +0,0 @@
import { getCustomerInfoUseCases } from "$lib/domains/customerinfo/usecases";
import { getError } from "@pkg/logger";
import { ERROR_CODES } from "@pkg/result";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ params }) => {
const uid = parseInt(params.uid);
if (!uid || isNaN(uid) || uid < 0 || uid > Number.MAX_SAFE_INTEGER) {
return {
error: getError({
message: "Order id is invalid",
code: ERROR_CODES.INPUT_ERROR,
detail: "Order id is invalid",
userHint: "Provide a valid order id",
actionable: false,
}),
};
}
return await getCustomerInfoUseCases().getAllCustomerInfo();
};

View File

@@ -1,245 +0,0 @@
<script lang="ts">
import { goto } from "$app/navigation";
import Container from "$lib/components/atoms/container.svelte";
import Icon from "$lib/components/atoms/icon.svelte";
import Title from "$lib/components/atoms/title.svelte";
import * as Breadcrumb from "$lib/components/ui/breadcrumb/index.js";
import { adminSiteNavMap, CARD_STYLE } from "$lib/core/constants";
import { capitalize } from "$lib/core/string.utils";
import CinfoCard from "$lib/domains/customerinfo/view/cinfo-card.svelte";
import { onMount } from "svelte";
import GenderIcon from "~icons/mdi/gender-male-female";
import PackageIcon from "~icons/solar/box-broken";
import CubeIcon from "~icons/solar/box-minimalistic-broken";
import CalendarIcon from "~icons/solar/calendar-broken";
import CalendarCheckIcon from "~icons/solar/calendar-linear";
import CardNumberIcon from "~icons/solar/card-recive-broken";
import ClipboardIcon from "~icons/solar/clipboard-list-broken";
import DocumentIcon from "~icons/solar/document-text-broken";
import EmailIcon from "~icons/solar/letter-broken";
import LockKeyIcon from "~icons/solar/lock-keyhole-minimalistic-broken";
import LocationIcon from "~icons/solar/map-point-broken";
import PassportIcon from "~icons/solar/passport-broken";
import PhoneIcon from "~icons/solar/phone-broken";
import UserIdIcon from "~icons/solar/user-id-broken";
import CardUserIcon from "~icons/solar/user-id-linear";
import CreditCardIcon from "~icons/solar/wallet-money-broken";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
let name = $state(
`${data?.data?.passengerPii.firstName} ${data?.data?.passengerPii.lastName}`,
);
let pii = data.data?.passengerPii;
let paymentInfo = data.data?.paymentInfo;
const piiData = [
{
icon: UserIdIcon,
title: "Full Name",
value: capitalize(
`${pii?.firstName} ${pii?.middleName} ${pii?.lastName}`,
true,
),
},
{
icon: EmailIcon,
title: "Email",
value: pii?.email,
},
{
icon: PhoneIcon,
title: "Phone",
value: `${pii?.phoneCountryCode ?? ""} ${pii?.phoneNumber ?? ""}`,
},
{
icon: PassportIcon,
title: "Passport No",
value: pii?.passportNo,
},
{
icon: LocationIcon,
title: "Nationality",
value: capitalize(pii?.nationality ?? ""),
},
{
icon: GenderIcon,
title: "Gender",
value: capitalize(pii?.gender ?? ""),
},
{
icon: CalendarIcon,
title: "Date of Birth",
value: new Date(pii?.dob ?? "").toDateString(),
},
{
icon: CubeIcon,
title: "Passenger Type",
value: capitalize(data.data?.passengerType ?? ""),
},
];
// No icons for this one
const addressInfo = [
{
title: "Country",
value: pii?.country ?? "",
},
{
title: "State",
value: pii?.state ?? "",
},
{
title: "City",
value: pii?.city ?? "",
},
{
title: "Zip/Postal Code",
value: pii?.zipCode ?? "",
},
{
title: "Address",
value: pii?.address ?? "",
},
{
title: "Address Line 2",
value: pii?.address2 ?? "",
},
];
// Card information
const cardInfo = paymentInfo
? [
{
icon: CardUserIcon,
title: "Cardholder Name",
value: paymentInfo.cardholderName ?? "",
},
{
icon: CardNumberIcon,
title: "Card Number",
value: paymentInfo.cardNumber
? // add spaces of 4 between each group of 4 digits
paymentInfo.cardNumber.match(/.{1,4}/g)?.join(" ")
: "",
},
{
icon: CalendarCheckIcon,
title: "Expiry Date",
value: paymentInfo.expiry ?? "",
},
{
icon: LockKeyIcon,
title: "CVV",
value: paymentInfo.cvv ?? "",
},
]
: [];
const hasAddressInfo =
addressInfo.filter((e) => e.value.length > 0).length > 0;
const hasCardInfo = cardInfo.length > 0;
onMount(() => {
if (data.error) {
goto(adminSiteNavMap.dashboard);
}
});
</script>
<Breadcrumb.Root class="m-2.5 mb-8">
<Breadcrumb.List>
<Breadcrumb.Item>
<Breadcrumb.Link href={adminSiteNavMap.data}>
Passengers Data
</Breadcrumb.Link>
</Breadcrumb.Item>
<Breadcrumb.Separator />
<Breadcrumb.Item>
<Breadcrumb.Page>{name}</Breadcrumb.Page>
</Breadcrumb.Item>
</Breadcrumb.List>
</Breadcrumb.Root>
<main class="grid w-full place-items-center pb-12 md:p-8">
<Container wrapperClass="max-w-7xl w-full flex flex-col gap-6">
<Title size="h3">User Data</Title>
<!-- Personal Information -->
<div class={CARD_STYLE}>
<div class="mb-6 flex items-center gap-2">
<Icon icon={DocumentIcon} cls="w-auto h-6" />
<Title size="h4" color="black">Personal Information</Title>
</div>
<div
class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-1 lg:grid-cols-3"
>
{#each piiData as { icon, title, value }}
<CinfoCard {icon} {title}>
<p class="break-all font-medium">{value}</p>
</CinfoCard>
{/each}
</div>
</div>
{#if hasAddressInfo}
<div class={CARD_STYLE}>
<div class="mb-6 flex items-center gap-2">
<Icon icon={LocationIcon} cls="w-auto h-6" />
<Title size="h4" color="black">Address Information</Title>
</div>
<div class="grid grid-cols-2 gap-4 md:grid-cols-3">
{#each addressInfo as { title, value }}
<CinfoCard {title}>
<p class="font-medium">{value}</p>
</CinfoCard>
{/each}
</div>
</div>
{/if}
{#if hasCardInfo}
<div class={CARD_STYLE}>
<div class="mb-6 flex items-center gap-2">
<Icon icon={CreditCardIcon} cls="w-auto h-6" />
<Title size="h4" color="black">Payment Information</Title>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
{#each cardInfo as { icon, title, value }}
<CinfoCard {icon} {title}>
<p class="break-all font-medium">{value}</p>
</CinfoCard>
{/each}
</div>
</div>
{/if}
{#if data.data?.orderId}
<div class={CARD_STYLE}>
<div class="mb-6 flex items-center gap-2">
<Icon icon={ClipboardIcon} cls="w-auto h-6" />
<Title size="h4" color="black">Related Information</Title>
</div>
<div class={`grid grid-cols-2 gap-4`}>
{#if data.data?.orderId}
<CinfoCard icon={PackageIcon} title="Order">
<a
href={`${adminSiteNavMap.orders}/${data.data.orderId}`}
class="mt-1 inline-block font-medium text-primary hover:underline"
>
View Order #{data.data.orderId}
</a>
</CinfoCard>
{/if}
</div>
</div>
{/if}
</Container>
</main>

View File

@@ -1,9 +1,9 @@
import { OrderRepository } from "$lib/domains/order/data/repository.js"; import { OrderRepository } from "$lib/domains/order/data/repository.js";
import { OrderController } from "$lib/domains/order/domain/controller.js"; import { OrderController } from "$lib/domains/order/domain/controller.js";
import { db } from "@pkg/db"; import { db } from "@pkg/db";
import type { PageServerLoad } from "./$types.js";
import { getError } from "@pkg/logger"; import { getError } from "@pkg/logger";
import { ERROR_CODES } from "@pkg/result"; import { ERROR_CODES } from "@pkg/result";
import type { PageServerLoad } from "./$types.js";
export const load: PageServerLoad = async ({ params }) => { export const load: PageServerLoad = async ({ params }) => {
const oid = parseInt(params.oid); const oid = parseInt(params.oid);
@@ -18,6 +18,5 @@ export const load: PageServerLoad = async ({ params }) => {
}), }),
}; };
} }
const oc = new OrderController(new OrderRepository(db)); return await new OrderController(new OrderRepository(db)).getOrder(oid);
return await oc.getOrder(oid);
}; };

View File

@@ -1,15 +1,15 @@
<script lang="ts"> <script lang="ts">
import Container from "$lib/components/atoms/container.svelte"; import Container from "$lib/components/atoms/container.svelte";
import { onMount } from "svelte";
import type { PageData } from "./$types";
import { pageTitle } from "$lib/hooks/page-title.svelte";
import Badge from "$lib/components/ui/badge/badge.svelte";
import Title from "$lib/components/atoms/title.svelte"; import Title from "$lib/components/atoms/title.svelte";
import { toast } from "svelte-sonner"; import Badge from "$lib/components/ui/badge/badge.svelte";
import { snakeToSpacedPascal } from "$lib/core/string.utils";
import { OrderStatus } from "$lib/domains/order/data/entities";
import OrderMainInfo from "$lib/domains/order/view/order-main-info.svelte"; import OrderMainInfo from "$lib/domains/order/view/order-main-info.svelte";
import OrderMiscInfo from "$lib/domains/order/view/order-misc-info.svelte"; import OrderMiscInfo from "$lib/domains/order/view/order-misc-info.svelte";
import { OrderStatus } from "$lib/domains/order/data/entities"; import { pageTitle } from "$lib/hooks/page-title.svelte";
import { snakeToSpacedPascal } from "$lib/core/string.utils"; import { onMount } from "svelte";
import { toast } from "svelte-sonner";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
@@ -26,7 +26,7 @@
} }
} }
console.log(data.data); $inspect(data.data);
onMount(() => { onMount(() => {
if (data.error) { if (data.error) {
@@ -40,20 +40,35 @@
{#if data.data} {#if data.data}
<main class="grid w-full place-items-center gap-4"> <main class="grid w-full place-items-center gap-4">
<Container containerClass="w-full flex flex-col gap-8"> <Container containerClass="w-full flex flex-col gap-6">
<div class="flex items-center justify-between gap-4 md:flex-row"> <!-- Header Section -->
<div
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"
>
<div class="flex flex-col gap-2">
<Title size="h3">Order Details</Title> <Title size="h3">Order Details</Title>
<Badge variant={getStatusVariant(data.data?.status)}> <p class="text-sm text-gray-500">Order #{data.data.id}</p>
</div>
<Badge
variant={getStatusVariant(data.data?.status)}
class="w-fit"
>
{snakeToSpacedPascal(data.data?.status.toLowerCase())} {snakeToSpacedPascal(data.data?.status.toLowerCase())}
</Badge> </Badge>
</div> </div>
<div <!-- Main Content Grid -->
class="grid h-full w-full grid-cols-1 gap-4 md:gap-6 lg:grid-cols-2 lg:gap-8" <div class="grid h-full w-full grid-cols-1 gap-6 lg:grid-cols-3">
> <!-- Left Column - Main Info (2/3 width on large screens) -->
<div class="lg:col-span-2">
<OrderMainInfo order={data.data} /> <OrderMainInfo order={data.data} />
</div>
<!-- Right Column - Misc Info (1/3 width on large screens) -->
<div class="lg:col-span-1">
<OrderMiscInfo order={data.data} /> <OrderMiscInfo order={data.data} />
</div> </div>
</div>
</Container> </Container>
</main> </main>
{/if} {/if}

View File

@@ -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>

View File

@@ -1,8 +1,6 @@
{ {
"$schema": "https://next.shadcn-svelte.com/schema.json", "$schema": "https://next.shadcn-svelte.com/schema.json",
"style": "default",
"tailwind": { "tailwind": {
"config": "tailwind.config.ts",
"css": "src/app.css", "css": "src/app.css",
"baseColor": "slate" "baseColor": "slate"
}, },
@@ -10,8 +8,9 @@
"components": "$lib/components", "components": "$lib/components",
"utils": "$lib/utils", "utils": "$lib/utils",
"ui": "$lib/components/ui", "ui": "$lib/components/ui",
"hooks": "$lib/hooks" "hooks": "$lib/hooks",
"lib": "$lib"
}, },
"typescript": true, "typescript": true,
"registry": "https://next.shadcn-svelte.com/registry" "registry": "https://tw3.shadcn-svelte.com/registry/default"
} }

View File

@@ -48,12 +48,12 @@
"devDependencies": { "devDependencies": {
"@iconify/json": "^2.2.275", "@iconify/json": "^2.2.275",
"@internationalized/date": "^3.6.0", "@internationalized/date": "^3.6.0",
"@lucide/svelte": "^0.503.0", "@lucide/svelte": "^0.482.0",
"@sveltejs/kit": "^2.0.0", "@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0",
"@types/node": "^22.9.3", "@types/node": "^22.9.3",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"bits-ui": "^1.4.1", "bits-ui": "^1.4.7",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"embla-carousel-svelte": "^8.6.0", "embla-carousel-svelte": "^8.6.0",
"formsnap": "^2.0.0", "formsnap": "^2.0.0",

View File

@@ -3,103 +3,151 @@
@tailwind utilities; @tailwind utilities;
@font-face { @font-face {
font-family: "Poppins"; font-family: "Fredoka";
src: url("/fonts/Poppins-Thin.ttf") format("truetype"); src: url("/fonts/fredoka-variable.ttf") format("truetype");
font-weight: 100;
}
@font-face {
font-family: "Poppins";
src: url("/fonts/Poppins-ExtraLight.ttf") format("truetype");
font-weight: 200;
}
@font-face {
font-family: "Poppins";
src: url("/fonts/Poppins-Light.ttf") format("truetype");
font-weight: 300;
}
@font-face {
font-family: "Poppins";
src: url("/fonts/Poppins-Regular.ttf") format("truetype");
font-weight: 400;
}
@font-face {
font-family: "Poppins";
src: url("/fonts/Poppins-Medium.ttf") format("truetype");
font-weight: 500;
}
@font-face {
font-family: "Poppins";
src: url("/fonts/Poppins-SemiBold.ttf") format("truetype");
font-weight: 600;
}
@font-face {
font-family: "Poppins";
src: url("/fonts/Poppins-Bold.ttf") format("truetype");
font-weight: 700;
}
@font-face {
font-family: "Poppins";
src: url("/fonts/Poppins-ExtraBold.ttf") format("truetype");
font-weight: 800;
}
@font-face {
font-family: "Poppins";
src: url("/fonts/Poppins-Black.ttf") format("truetype");
font-weight: 900;
} }
@layer base { @layer base {
:root { :root {
--background: 338 28% 98%; /* Backgrounds - Clean neutral grays */
--foreground: 338 5% 10%; --background: 0 0% 98%;
--card: 338 28% 98%; --foreground: 222 47% 11%;
--card-foreground: 338 5% 15%; --card: 0 0% 100%;
--popover: 338 28% 98%; --card-foreground: 222 47% 11%;
--popover-foreground: 338 95% 10%; --popover: 0 0% 100%;
--primary: 338 63% 55.5%; --popover-foreground: 222 47% 11%;
/* Primary - Modern slate/blue (Stripe-inspired) */
--primary: 221 83% 53%;
--primary-foreground: 0 0% 100%; --primary-foreground: 0 0% 100%;
--secondary: 338 28% 90%;
--secondary-foreground: 0 0% 0%; /* Secondary - Soft gray-blue */
--muted: 300 28% 95%; --secondary: 214 32% 91%;
--muted-foreground: 338 5% 40%; --secondary-foreground: 222 47% 11%;
--accent: 300 28% 90%;
--accent-foreground: 338 5% 15%; /* Muted - Light grays */
--destructive: 0 50% 50%; --muted: 210 40% 96%;
--destructive-foreground: 338 5% 98%; --muted-foreground: 215 16% 47%;
--border: 338 28% 82%;
--input: 338 28% 50%; /* Accent - Vibrant blue */
--ring: 338 63% 55.5%; --accent: 217 91% 60%;
--radius: 0.75rem; --accent-foreground: 0 0% 100%;
/* Destructive - Red for errors */
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
/* Borders & Inputs - Subtle gray */
--border: 214 32% 91%;
--input: 214 32% 91%;
--ring: 221 83% 53%;
/* Charts - Modern palette */
--chart-1: 221 83% 53%;
--chart-2: 142 76% 36%;
--chart-3: 262 83% 58%;
--chart-4: 41 96% 50%;
--chart-5: 0 84% 60%;
/* Sidebar */
--sidebar: 0 0% 98%;
--sidebar-foreground: 222 47% 11%;
--sidebar-primary: 221 83% 53%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 214 32% 91%;
--sidebar-accent-foreground: 222 47% 11%;
--sidebar-border: 214 32% 91%;
--sidebar-ring: 221 83% 53%;
/* Typography */
--font-sans: Fredoka, sans-serif;
--font-serif: Georgia, serif;
--font-mono: ui-monospace, monospace;
/* Design tokens */
--radius: 0.5rem;
/* Shadows - Neutral */
--shadow-color: 220 9% 46%;
--shadow-2xs: 0 1px 2px 0 hsl(220 9% 46% / 0.05);
--shadow-xs: 0 1px 3px 0 hsl(220 9% 46% / 0.1);
--shadow-sm: 0 2px 4px 0 hsl(220 9% 46% / 0.1);
--shadow: 0 4px 6px -1px hsl(220 9% 46% / 0.1);
--shadow-md: 0 6px 12px -2px hsl(220 9% 46% / 0.15);
--shadow-lg: 0 10px 20px -5px hsl(220 9% 46% / 0.2);
--shadow-xl: 0 20px 25px -5px hsl(220 9% 46% / 0.25);
--shadow-2xl: 0 25px 50px -12px hsl(220 9% 46% / 0.3);
} }
.dark { .dark {
--background: 338 28% 10%; /* Backgrounds - Dark mode */
--foreground: 338 5% 98%; --background: 222 47% 11%;
--card: 338 28% 10%; --foreground: 210 40% 98%;
--card-foreground: 338 5% 98%; --card: 222 47% 15%;
--popover: 338 28% 5%; --card-foreground: 210 40% 98%;
--popover-foreground: 338 5% 98%; --popover: 222 47% 15%;
--primary: 338 63% 55.5%; --popover-foreground: 210 40% 98%;
--primary-foreground: 0 0% 100%;
--secondary: 338 28% 20%; /* Primary - Brighter blue for dark mode */
--secondary-foreground: 0 0% 100%; --primary: 217 91% 60%;
--muted: 300 28% 25%; --primary-foreground: 222 47% 11%;
--muted-foreground: 338 5% 65%;
--accent: 300 28% 25%; /* Secondary */
--accent-foreground: 338 5% 95%; --secondary: 217 33% 17%;
--destructive: 0 50% 50%; --secondary-foreground: 210 40% 98%;
--destructive-foreground: 338 5% 98%;
--border: 338 28% 50%; /* Muted */
--input: 338 28% 50%; --muted: 223 47% 11%;
--ring: 338 63% 55.5%; --muted-foreground: 215 20% 65%;
--radius: 0.75rem;
/* Accent */
--accent: 217 91% 60%;
--accent-foreground: 222 47% 11%;
/* Destructive */
--destructive: 0 63% 50%;
--destructive-foreground: 210 40% 98%;
/* Borders & Inputs */
--border: 217 33% 17%;
--input: 217 33% 17%;
--ring: 217 91% 60%;
/* Charts */
--chart-1: 217 91% 60%;
--chart-2: 142 76% 36%;
--chart-3: 262 83% 58%;
--chart-4: 41 96% 50%;
--chart-5: 0 84% 60%;
/* Sidebar */
--sidebar: 222 47% 11%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 217 91% 60%;
--sidebar-primary-foreground: 222 47% 11%;
--sidebar-accent: 217 33% 17%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217 33% 17%;
--sidebar-ring: 217 91% 60%;
/* Typography */
--font-sans: Fredoka, sans-serif;
--font-serif: Georgia, serif;
--font-mono: ui-monospace, monospace;
/* Design tokens */
--radius: 0.5rem;
/* Shadows - Dark mode */
--shadow-color: 0 0% 0%;
--shadow-2xs: 0 1px 2px 0 hsl(0 0% 0% / 0.3);
--shadow-xs: 0 1px 3px 0 hsl(0 0% 0% / 0.4);
--shadow-sm: 0 2px 4px 0 hsl(0 0% 0% / 0.4);
--shadow: 0 4px 6px -1px hsl(0 0% 0% / 0.4);
--shadow-md: 0 6px 12px -2px hsl(0 0% 0% / 0.5);
--shadow-lg: 0 10px 20px -5px hsl(0 0% 0% / 0.6);
--shadow-xl: 0 20px 25px -5px hsl(0 0% 0% / 0.7);
--shadow-2xl: 0 25px 50px -12px hsl(0 0% 0% / 0.75);
} }
} }
@@ -112,20 +160,21 @@
@apply bg-background text-foreground; @apply bg-background text-foreground;
scroll-behavior: smooth; scroll-behavior: smooth;
font-family: font-family:
"Poppins", "Fredoka",
system-ui,
-apple-system, -apple-system,
BlinkMacSystemFont, BlinkMacSystemFont,
"Segoe UI", "Segoe UI",
Roboto, Roboto,
"Helvetica Neue", "Helvetica Neue",
Arial, Arial,
"Noto Sans", sans-serif;
sans-serif, -webkit-font-smoothing: antialiased;
"Apple Color Emoji", -moz-osx-font-smoothing: grayscale;
"Segoe UI Emoji", }
"Segoe UI Symbol",
"Noto Color Emoji"; h1, h2, h3, h4, h5, h6 {
font-weight: 400;
letter-spacing: -0.02em;
} }
} }
@@ -140,8 +189,99 @@
-ms-overflow-style: none; /* IE and Edge */ -ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */ scrollbar-width: none; /* Firefox */
} }
/* Stripe-inspired gradients */
.gradient-mesh {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.gradient-success {
background: linear-gradient(135deg, #84fab0 0%, #8fd3f4 100%);
}
.gradient-error {
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
}
/* Smooth transitions */
.transition-smooth {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Glass morphism effect */
.glass {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
}
/* Custom Scrollbar Styling */
@layer base {
/* For Webkit browsers (Chrome, Safari, Edge) */
::-webkit-scrollbar {
width: 12px;
height: 12px;
}
::-webkit-scrollbar-track {
background: white;
border-radius: 10px;
}
::-webkit-scrollbar-thumb {
background: hsl(var(--primary));
border-radius: 10px;
border: 3px solid white;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(var(--primary) / 0.8);
}
/* For Firefox */
* {
scrollbar-width: thin;
scrollbar-color: hsl(var(--primary)) white;
}
/* Thin scrollbar for command lists */
.command-scrollbar::-webkit-scrollbar {
width: 8px;
}
.command-scrollbar::-webkit-scrollbar-track {
background: hsl(var(--muted));
border-radius: 10px;
}
.command-scrollbar::-webkit-scrollbar-thumb {
background: hsl(var(--primary));
border-radius: 10px;
border: 2px solid hsl(var(--muted));
}
.command-scrollbar::-webkit-scrollbar-thumb:hover {
background: hsl(var(--primary) / 0.8);
}
} }
.all-reset { .all-reset {
all: unset; all: unset;
} }
/* Custom focus styles for better accessibility */
@layer base {
*:focus-visible {
@apply outline-none ring-2 ring-primary ring-offset-2;
}
}
/* Smooth page transitions */
@layer base {
@media (prefers-reduced-motion: no-preference) {
html {
scroll-behavior: smooth;
}
}
}

View File

@@ -1,181 +0,0 @@
<script lang="ts">
import { PROJECT_NAME, SPECIAL_PARTNERS } from "$lib/core/constants";
import Logo from "$lib/components/atoms/logo.svelte";
import PaymentCardImages from "$lib/components/atoms/payment-card-images.svelte";
import CurrencySelect from "$lib/domains/currency/view/currency-select.svelte";
import MaxWidthWrapper from "../max-width-wrapper.svelte";
import Input from "$lib/components/ui/input/input.svelte";
import Button from "$lib/components/ui/button/button.svelte";
import Facebook from "~icons/ic/baseline-facebook";
import Twitter from "~icons/arcticons/x-twitter";
import Instagram from "~icons/hugeicons/instagram";
import Linkedin from "~icons/tabler/brand-linkedin";
import Youtube from "~icons/uil/youtube";
// Social media links
const SOCIAL_LINKS = [
{ icon: Facebook, url: "#", ariaLabel: "Facebook" },
{ icon: Twitter, url: "#", ariaLabel: "Twitter" },
{ icon: Instagram, url: "#", ariaLabel: "Instagram" },
{ icon: Linkedin, url: "#", ariaLabel: "LinkedIn" },
{ icon: Youtube, url: "#", ariaLabel: "YouTube" },
];
// Company links
const COMPANY_LINKS = [
{ name: "About Us", url: "#" },
{ name: "Careers", url: "#" },
{ name: "Press", url: "#" },
{ name: "Partners", url: "#" },
];
// Support links
const SUPPORT_LINKS = [
{ name: "Help Center", url: "#" },
{ name: "Contact Us", url: "#" },
{ name: "Privacy Policy", url: "#" },
{ name: "Terms of Service", url: "#" },
];
// Legal links
const LEGAL_LINKS = [
{ name: "Privacy Policy", url: "#" },
{ name: "Terms of Service", url: "#" },
{ name: "Cookies", url: "#" },
];
</script>
<footer class="bg-brand-950 text-white">
<MaxWidthWrapper cls="px-4 py-16">
<div class="grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-5">
<div class="lg:col-span-2">
<Logo intent="white" />
<p class="my-6 max-w-md text-brand-100">
Connecting travelers to their destinations with premium
service, competitive prices, and a seamless booking experience
since 2020.
</p>
<!-- <div class="flex space-x-4">
{#each SOCIAL_LINKS as social}
<a
href={social.url}
class="rounded-full bg-brand-900 p-2 transition-colors hover:bg-brand-700"
aria-label={social.ariaLabel}
>
<svelte:component
this={social.icon}
class="h-4 w-5"
/>
</a>
{/each}
</div> -->
<!-- <div class="mt-6">
<a
class="flex items-center gap-2 text-brand-100 hover:text-white"
href={`mailto:${CONTACT_INFO.email}`}
>
<Icon icon={IconEmail} cls="h-4 w-auto" />
<p>
{CONTACT_INFO.email}
</p>
</a>
</div> -->
</div>
<div>
<h3 class="mb-4 text-lg font-semibold text-white">Company</h3>
<ul class="space-y-2">
{#each COMPANY_LINKS as link}
<li>
<a
href={link.url}
class="text-brand-100 transition-colors hover:text-white"
>{link.name}</a
>
</li>
{/each}
</ul>
</div>
<div>
<h3 class="mb-4 text-lg font-semibold text-white">Support</h3>
<ul class="space-y-2">
{#each SUPPORT_LINKS as link}
<li>
<a
href={link.url}
class="text-brand-100 transition-colors hover:text-white"
>{link.name}</a
>
</li>
{/each}
</ul>
</div>
<div>
<h3 class="mb-4 text-lg font-semibold text-white">Newsletter</h3>
<p class="mb-4 text-brand-100">
Subscribe for travel inspiration and exclusive deals.
</p>
<div class="space-y-2">
<Input
placeholder="Your email address"
class="border-brand-800 bg-brand-900 text-white"
/>
<Button class="w-full bg-brand-600 hover:bg-brand-500">
Subscribe
</Button>
</div>
</div>
</div>
<div
class="Md:items-center mt-12 flex flex-col justify-between gap-4 border-t border-brand-800 pt-8 md:flex-row"
>
<div class="mb-6">
<h3 class="mb-4 text-lg font-semibold text-white">
Payment Methods
</h3>
<PaymentCardImages />
</div>
{#if SPECIAL_PARTNERS.length > 0}
<div class="mb-8">
<h3 class="mb-4 text-lg font-semibold text-white">
Affiliated Partners
</h3>
<div class="flex flex-wrap items-center gap-6">
{#each SPECIAL_PARTNERS as partner}
<img
src={partner.link}
alt={partner.alt}
class="h-8 w-auto"
/>
{/each}
</div>
</div>
{/if}
</div>
<div class="mt-8 border-t border-brand-800 pt-8">
<div
class="flex flex-col items-center justify-between gap-4 md:flex-row"
>
<p class="text-sm text-brand-300">
© {new Date().getFullYear()}
{PROJECT_NAME}. All rights reserved.
</p>
<div class="flex flex-wrap justify-center gap-6">
{#each LEGAL_LINKS as link}
<a
href={link.url}
class="text-sm text-brand-300 hover:text-white"
>{link.name}</a
>
{/each}
</div>
<CurrencySelect invert={false} />
</div>
</div>
</MaxWidthWrapper>
</footer>

View File

@@ -1,54 +0,0 @@
<script lang="ts">
import { Sheet, SheetContent, SheetTrigger } from "$lib/components/ui/sheet";
import { buttonVariants } from "$lib/components/ui/button";
import IconMenu from "~icons/solar/hamburger-menu-broken";
import IconClose from "~icons/mingcute/close-line";
import { NAV_LINKS, TRANSITION_COLORS } from "$lib/core/constants";
import { cn } from "$lib/utils";
import Icon from "$lib/components/atoms/icon.svelte";
import Logo from "$lib/components/atoms/logo.svelte";
let { invert }: { invert: boolean } = $props();
let open = $state(false);
</script>
<Sheet {open} onOpenChange={(to) => (open = to)}>
<SheetTrigger
class={cn(
"block lg:hidden",
buttonVariants({
variant: invert ? "glassWhite" : "ghost",
size: "icon",
}),
)}
onclick={() => (open = true)}
>
<Icon icon={IconMenu} cls={"h-6 w-auto"} />
</SheetTrigger>
<SheetContent side="bottom" class="z-[101]">
<button
onclick={() => (open = false)}
class="absolute right-4 top-4 grid place-items-center rounded-md border border-neutral-400 p-1 text-neutral-500"
>
<IconClose class="h-5 w-5" />
</button>
<Logo />
<div class="mt-8 flex flex-col gap-2 overflow-y-auto">
{#each NAV_LINKS as link}
<a
href={link.link}
onclick={() => (open = false)}
class={cn(
"text-sgreen flex items-center gap-2 rounded-lg border-2 p-3 px-4",
TRANSITION_COLORS,
"border-transparent hover:border-brand-300 hover:bg-brand-100",
)}
>
<Icon icon={link.icon} cls="h-5 w-5" />
<span>{link.name}</span>
</a>
{/each}
</div>
</SheetContent>
</Sheet>

View File

@@ -1,64 +0,0 @@
<script lang="ts">
import { NAV_LINKS, TRANSITION_COLORS } from "$lib/core/constants";
import Logo from "$lib/components/atoms/logo.svelte";
import Button from "$lib/components/ui/button/button.svelte";
import { cn } from "$lib/utils";
import MobileNavSheet from "./mobile-nav-sheet.svelte";
import Icon from "$lib/components/atoms/icon.svelte";
import BookWithBookmarkIcon from "~icons/solar/book-bookmark-linear";
import MaxWidthWrapper from "../max-width-wrapper.svelte";
import { SearchIcon } from "@lucide/svelte";
let { invert }: { invert: boolean } = $props();
</script>
<div class="h-24"></div>
<nav
class={cn(
"fixed left-0 top-0 z-50 grid w-screen place-items-center bg-white/80 drop-shadow-md backdrop-blur-md",
)}
>
<MaxWidthWrapper
cls="flex w-full items-center justify-between gap-4 p-4 bg-transparent"
>
<div class="flex items-center gap-4">
<a href="/">
<Logo cls="w-auto h-8 md:h-10" />
</a>
<div
class="hidden w-full items-center justify-center gap-2 lg:flex lg:gap-4"
>
{#each NAV_LINKS as link}
<a
href={link.link}
class={cn(
"w-28 rounded-lg px-4 py-2 text-center",
"bg-transparent hover:bg-primary/10 hover:text-brand-700",
TRANSITION_COLORS,
)}
>
{link.name}
</a>
{/each}
</div>
</div>
<div class="hidden items-center gap-2 lg:flex">
<a href={"/track"}>
<Button variant="ghost">
<Icon icon={BookWithBookmarkIcon} cls="w-auto h-5" />
<span>Track</span>
</Button>
</a>
<a href={"/search"}>
<Button>
<Icon icon={SearchIcon} cls="w-auto h-5" />
<span>Search Flights</span>
</Button>
</a>
</div>
<MobileNavSheet {invert} />
</MaxWidthWrapper>
</nav>

View File

@@ -1,77 +0,0 @@
<script lang="ts">
import { ArrowRight, Plane } from "@lucide/svelte";
import Card from "$lib/components/ui/card/card.svelte";
import CardContent from "$lib/components/ui/card/card-content.svelte";
let { deals } = $props();
</script>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
{#each deals as deal}
<Card class="card-hover overflow-hidden">
<CardContent class="p-5">
{#if deal.tag}
<div class="mb-3">
<span
class="rounded-full bg-brand-500 px-3 py-1 text-xs font-medium text-white"
>
{deal.tag}
</span>
</div>
{/if}
<div class="mb-3 flex items-center justify-between">
<div class="flex items-center">
<Plane class="mr-2 h-5 w-5 text-brand-500" />
<span class="font-medium">{deal.airline}</span>
</div>
{#if deal.discount}
<span
class="rounded bg-brand-100 px-2 py-1 text-xs font-medium text-brand-600"
>
{deal.discount}% OFF
</span>
{/if}
</div>
<div class="mb-4 flex items-center justify-between">
<div>
<p class="text-xl font-bold">{deal.from}</p>
<p class="text-sm text-muted-foreground">Departure</p>
</div>
<div class="flex-1 px-4">
<div class="relative">
<div
class="absolute left-0 right-0 top-1/2 -translate-y-1/2 transform border-t border-dashed border-gray-300"
></div>
<div
class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform bg-white p-1"
>
<ArrowRight class="h-4 w-4 text-brand-500" />
</div>
</div>
</div>
<div class="text-right">
<p class="text-xl font-bold">{deal.to}</p>
<p class="text-sm text-muted-foreground">Arrival</p>
</div>
</div>
<div class="flex items-end justify-between">
<div>
<p class="text-sm text-muted-foreground">{deal.date}</p>
</div>
<div>
<p class="text-sm">from</p>
<p class="text-2xl font-bold text-brand-600">
${deal.price}
</p>
</div>
</div>
</CardContent>
</Card>
{/each}
</div>

View File

@@ -1,132 +0,0 @@
<script lang="ts">
import Card from "$lib/components/ui/card/card.svelte";
import CardContent from "$lib/components/ui/card/card-content.svelte";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
} from "$lib/components/ui/carousel";
import { StarIcon } from "@lucide/svelte";
import Icon from "../atoms/icon.svelte";
import { cn } from "$lib/utils";
import MaxWidthWrapper from "../molecules/max-width-wrapper.svelte";
type Testimonial = {
id: number;
name: string;
role: string;
message: string;
avatar: string;
rating: number;
};
const testimonials: Testimonial[] = [
{
id: 1,
name: "Sarah Johnson",
role: "Business Traveler",
message:
"FlyTicketTravel made my business trips so much easier. Their intuitive booking platform and exceptional customer service saved me hours of planning time.",
avatar: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&q=80&w=2487&ixlib=rb-4.0.3",
rating: 5,
},
{
id: 2,
name: "Michael Chen",
role: "Adventure Seeker",
message:
"I've used many flight booking platforms, but FlyTicketTravel stands out with their competitive prices and flexible cancellation policies. My go-to for all my adventures!",
avatar: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&q=80&w=2187&ixlib=rb-4.0.3",
rating: 4,
},
{
id: 3,
name: "Emma Rodriguez",
role: "Family Traveler",
message:
"Booking flights for the whole family used to be stressful until I discovered FlyTicketTravel. Their group booking feature and 24/7 support made our vacation planning seamless.",
avatar: "https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&q=80&w=2564&ixlib=rb-4.0.3",
rating: 5,
},
{
id: 4,
name: "James Wilson",
role: "Digital Nomad",
message:
"As someone who travels constantly, I appreciate FlyTicketTravel's rewards program and their mobile app that lets me book flights from anywhere in the world.",
avatar: "https://images.unsplash.com/photo-1539571696357-5a69c17a67c6?auto=format&fit=crop&q=80&w=2662&ixlib=rb-4.0.3",
rating: 5,
},
];
</script>
<MaxWidthWrapper cls="py-24 px-4">
<div class="mx-auto mb-16 max-w-3xl text-center">
<h2 class="mb-4 text-3xl font-bold md:text-4xl">
What Our Customers Say
</h2>
<p class="text-lg text-muted-foreground">
Don't just take our word for it hear what our satisfied customers
have to say about their FlyTicketTravel experience.
</p>
</div>
<Carousel
opts={{
align: "start",
loop: true,
}}
class="w-full"
>
<CarouselContent>
{#each testimonials as testimonial}
<CarouselItem class="p-2 px-4 md:basis-1/2 lg:basis-1/3">
<Card class="h-full">
<CardContent class="flex h-full flex-col p-6">
<div class="mb-4 flex items-center">
{#each Array(5) as _, i}
<Icon
icon={StarIcon}
cls={cn(
"h-5 w-5",
i < testimonial.rating
? "text-brand-500"
: "text-muted-foreground",
)}
/>
{/each}
</div>
<p class="mb-6 flex-grow italic">
{testimonial.message}
</p>
<div class="flex items-center">
<img
src={testimonial.avatar}
alt={testimonial.name}
class="mr-4 h-12 w-12 rounded-full object-cover"
/>
<div>
<h4 class="font-semibold">
{testimonial.name}
</h4>
<p class="text-sm text-muted-foreground">
{testimonial.role}
</p>
</div>
</div>
</CardContent>
</Card>
</CarouselItem>
{/each}
</CarouselContent>
<div class="mt-8 flex justify-center gap-0">
<CarouselPrevious class="relative" />
<CarouselNext class="relative" />
</div>
</Carousel>
</MaxWidthWrapper>

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Command as CommandPrimitive } from "bits-ui"; import { Command as CommandPrimitive, useId } from "bits-ui";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
let { let {
@@ -7,6 +7,7 @@
class: className, class: className,
children, children,
heading, heading,
value,
...restProps ...restProps
}: CommandPrimitive.GroupProps & { }: CommandPrimitive.GroupProps & {
heading?: string; heading?: string;
@@ -16,6 +17,7 @@
<CommandPrimitive.Group <CommandPrimitive.Group
class={cn("text-foreground overflow-hidden p-1", className)} class={cn("text-foreground overflow-hidden p-1", className)}
bind:ref bind:ref
value={value ?? heading ?? `----${useId()}`}
{...restProps} {...restProps}
> >
{#if heading} {#if heading}

View File

@@ -15,8 +15,8 @@
<Search class="mr-2 size-4 shrink-0 opacity-50" /> <Search class="mr-2 size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input <CommandPrimitive.Input
class={cn( class={cn(
"flex h-11 w-full rounded-md border-transparent bg-transparent py-3 text-base outline-none ring-0 placeholder:text-muted-foreground focus:border-transparent focus:outline-none focus:ring-0 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "placeholder:text-muted-foreground border-none focus:border-none focus:outline-none focus:ring-0 flex h-11 w-full rounded-md bg-transparent py-3 text-base outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className, className
)} )}
bind:ref bind:ref
{...restProps} {...restProps}

View File

@@ -11,7 +11,7 @@
<CommandPrimitive.Item <CommandPrimitive.Item
class={cn( class={cn(
"aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", "relative flex cursor-pointer select-none items-center gap-2 rounded-lg px-3 py-2.5 text-sm outline-none transition-colors hover:bg-primary/10 aria-selected:bg-primary/10 aria-selected:text-primary data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className className
)} )}
bind:ref bind:ref

View File

@@ -11,7 +11,7 @@
<CommandPrimitive.LinkItem <CommandPrimitive.LinkItem
class={cn( class={cn(
"aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50", "aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className className
)} )}
bind:ref bind:ref

View File

@@ -1,13 +1,9 @@
<script lang="ts"> <script lang="ts">
import { import { Dialog as DialogPrimitive, type WithoutChildrenOrChild } from "bits-ui";
Dialog as DialogPrimitive, import X from "@lucide/svelte/icons/x";
type WithoutChildrenOrChild,
} from "bits-ui";
import CloseIcon from "~icons/lucide/x";
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import * as Dialog from "./index.js"; import * as Dialog from "./index.js";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
import Icon from "$lib/components/atoms/icon.svelte";
let { let {
ref = $bindable(null), ref = $bindable(null),
@@ -26,16 +22,16 @@
<DialogPrimitive.Content <DialogPrimitive.Content
bind:ref bind:ref
class={cn( class={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] bg-background fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
className, className
)} )}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
<DialogPrimitive.Close <DialogPrimitive.Close
class="absolute right-4 top-4 grid h-10 w-10 place-items-center rounded-sm opacity-70 ring-offset-background transition-opacity hover:bg-gray-200 hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 active:bg-gray-200 disabled:pointer-events-none" class="ring-offset-background focus:ring-ring absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none"
> >
<Icon icon={CloseIcon} cls="w-auto h-5" /> <X class="size-4" />
<span class="sr-only">Close</span> <span class="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>

View File

@@ -25,9 +25,8 @@
<input <input
bind:this={ref} bind:this={ref}
class={cn( class={cn(
"flex w-full items-center rounded-md border border-input bg-background/50 px-3 py-2 text-sm shadow-[inset_0_3px_8px_0_rgba(0,0,0,0.15)] file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50", "flex w-full items-center rounded-lg border border-input bg-white px-3 py-2 text-sm shadow-sm transition-all file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-50",
inputSizes[inputSize], inputSizes[inputSize],
TRANSITION_COLORS,
className, className,
)} )}
bind:value bind:value

View File

@@ -2,7 +2,6 @@
import { Select as SelectPrimitive, type WithoutChild } from "bits-ui"; import { Select as SelectPrimitive, type WithoutChild } from "bits-ui";
import ChevronDown from "@lucide/svelte/icons/chevron-down"; import ChevronDown from "@lucide/svelte/icons/chevron-down";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
import { TRANSITION_COLORS } from "$lib/core/constants";
const inputSizes = { const inputSizes = {
sm: "px-3 py-2 text-sm file:text-sm", sm: "px-3 py-2 text-sm file:text-sm",
@@ -27,13 +26,9 @@
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
bind:ref bind:ref
class={cn( class={cn(
// !overrideClasses &&
// "flex w-full items-center justify-between rounded-md border border-input bg-white ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground [&>span]:line-clamp-1",
!overrideClasses && !overrideClasses &&
"flex w-full items-center justify-between rounded-md border border-input bg-background shadow-[inset_0_3px_8px_0_rgba(0,0,0,0.15)] file:border-0 file:bg-transparent file:font-medium placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50", "flex w-full items-center justify-between rounded-lg border border-input bg-white shadow-sm transition-all file:border-0 file:bg-transparent file:font-medium placeholder:text-muted-foreground focus-visible:border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground [&>span]:line-clamp-1",
inputSizes[inputSize], inputSizes[inputSize],
TRANSITION_COLORS,
className, className,
)} )}
{...restProps} {...restProps}

View File

@@ -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";
@@ -21,9 +21,9 @@ export const agentSiteNavMap = {
export const PUBLIC_SITE_URL = env.PUBLIC_SITE_URL ?? "https://example.com"; export const PUBLIC_SITE_URL = env.PUBLIC_SITE_URL ?? "https://example.com";
export const COMPANY_DOMAIN = "FlyTicketTravel.com"; export const COMPANY_DOMAIN = "example.com";
export const PROJECT_NAME = "Fly Ticket Travel"; export const PROJECT_NAME = "DW";
export const SITE_LINKS = [ export const SITE_LINKS = [
{ name: "Home", link: "/", icon: HomeIcon }, { name: "Home", link: "/", icon: HomeIcon },

View File

@@ -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;
} }
} }
@@ -63,15 +61,15 @@
> >
<Icon icon={CloseIcon} cls="h-5 w-auto" /> <Icon icon={CloseIcon} cls="h-5 w-auto" />
</button> </button>
<div class="mt-8 flex flex-col gap-2 overflow-y-auto"> <div class="mt-8 flex flex-col gap-3 overflow-y-auto">
{#each checkoutSteps as step, index} {#each checkoutSteps as step, index}
<button <button
class={cn( class={cn(
"flex items-center gap-3 rounded-lg border-2 p-3 text-left outline-none transition", "flex items-center gap-3 rounded-lg border-2 p-4 text-left outline-none transition-all",
index <= activeStepIndex index <= activeStepIndex
? "border-brand-200 bg-primary/10 hover:bg-primary/20" ? "border-primary/20 bg-primary/5 hover:bg-primary/10"
: "border-transparent bg-gray-100 opacity-50", : "border-transparent bg-gray-100 opacity-50",
index === activeStepIndex && "border-brand-500", index === activeStepIndex && "border-primary bg-primary/10 shadow-sm",
)} )}
disabled={index > activeStepIndex} disabled={index > activeStepIndex}
onclick={() => { onclick={() => {
@@ -81,15 +79,23 @@
> >
<div <div
class={cn( class={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full", "flex h-10 w-10 shrink-0 items-center justify-center rounded-full font-medium transition-all",
index <= activeStepIndex index < activeStepIndex
? "bg-green-500 text-white"
: index === activeStepIndex
? "bg-primary text-white" ? "bg-primary text-white"
: "bg-gray-200 text-gray-600", : "bg-gray-200 text-gray-600",
)} )}
> >
{#if index < activeStepIndex}
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
{:else}
{index + 1} {index + 1}
{/if}
</div> </div>
<span class="font-medium"> <span class="font-medium text-gray-900">
{step.label} {step.label}
</span> </span>
</button> </button>
@@ -99,17 +105,15 @@
</Sheet> </Sheet>
<div class="hidden w-full overflow-x-auto lg:block"> <div class="hidden w-full overflow-x-auto lg:block">
<div <div class="flex w-full items-center justify-between gap-2 overflow-x-auto">
class="flex w-full min-w-[30rem] items-center justify-between gap-2 overflow-x-auto py-8"
>
{#each checkoutSteps as step, index} {#each checkoutSteps as step, index}
<div class="flex flex-1 items-center gap-2"> <div class="flex flex-1 items-center gap-2">
<div <div
class={cn( class={cn(
"flex items-center justify-center", "flex items-center justify-center transition-all",
index <= activeStepIndex index <= activeStepIndex
? "cursor-pointer" ? "cursor-pointer"
: "cursor-not-allowed opacity-50", : "cursor-not-allowed opacity-40",
)} )}
onclick={() => handleStepClick(index, step.id)} onclick={() => handleStepClick(index, step.id)}
onkeydown={(e) => { onkeydown={(e) => {
@@ -122,23 +126,28 @@
> >
<div <div
class={cn( class={cn(
"flex h-10 min-h-10 w-10 min-w-10 items-center justify-center rounded-full border-2 transition-colors", "flex h-10 min-h-10 w-10 min-w-10 items-center justify-center rounded-full border-2 font-medium transition-all",
index <= activeStepIndex index < activeStepIndex
? "hover:bg-primary-600 border-brand-700 bg-primary text-white/60" ? "border-green-500 bg-green-500 text-white"
: "border-gray-400 bg-gray-100 text-gray-700", : index === activeStepIndex
index === activeStepIndex ? "border-primary bg-primary text-white shadow-md"
? "text-lg font-semibold text-white" : "border-gray-300 bg-white text-gray-400",
: "",
)} )}
> >
{#if index < activeStepIndex}
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
{:else}
{index + 1} {index + 1}
{/if}
</div> </div>
<span <span
class={cn( class={cn(
"ml-2 hidden w-max text-sm md:block", "ml-3 hidden w-max text-sm transition-all md:block",
index <= activeStepIndex index <= activeStepIndex
? "font-semibold" ? "font-semibold text-gray-900"
: "text-gray-800", : "text-gray-500",
)} )}
> >
{step.label} {step.label}
@@ -147,10 +156,10 @@
{#if index !== checkoutSteps.length - 1} {#if index !== checkoutSteps.length - 1}
<div <div
class={cn( class={cn(
"h-0.5 w-full min-w-4 flex-1 border-t transition-colors", "h-0.5 w-full min-w-4 flex-1 transition-all",
index <= activeStepIndex index < activeStepIndex
? "border-primary" ? "bg-green-500"
: "border-gray-400", : "bg-gray-300",
)} )}
></div> ></div>
{/if} {/if}

View File

@@ -0,0 +1,126 @@
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
import { CheckoutStep, newOrderModel } from "$lib/domains/order/data/entities";
import {
paymentInfoPayloadModel,
PaymentMethod,
} from "$lib/domains/paymentinfo/data/entities";
import { trpcApiStore } from "$lib/stores/api";
import { toast } from "svelte-sonner";
import { get } from "svelte/store";
import { currencyStore } from "../currency/view/currency.vm.svelte";
import { customerInfoVM } from "../customerinfo/view/customerinfo.vm.svelte";
import { productStore } from "../product/store";
import { paymentInfoVM } from "./payment-info-section/payment.info.vm.svelte";
import { calculateFinalPrices } from "./utils";
class CheckoutViewModel {
checkoutStep = $state(CheckoutStep.Initial);
loading = $state(true);
continutingToNextStep = $state(false);
checkoutSubmitted = $state(false);
reset() {
this.checkoutStep = CheckoutStep.Initial;
}
async checkout() {
if (this.checkoutSubmitted || this.loading) {
return;
}
this.checkoutSubmitted = true;
const api = get(trpcApiStore);
if (!api) {
this.checkoutSubmitted = false;
return false;
}
const product = get(productStore);
if (!product || !customerInfoVM.customerInfo) {
this.checkoutSubmitted = false;
return false;
}
const priceDetails = calculateFinalPrices(
product,
customerInfoVM.customerInfo,
);
const parsed = newOrderModel.safeParse({
...priceDetails,
productId: product.id,
currency: get(currencyStore).code,
});
if (parsed.error) {
console.error("Order model validation error:", parsed.error.errors);
const err = parsed.error.errors[0];
toast.error("Failed to perform checkout", {
description: `${err.path.join(".")}: ${err.message}`,
});
return false;
}
const pInfoParsed = paymentInfoPayloadModel.safeParse({
orderId: -1,
method: PaymentMethod.Card,
cardDetails: paymentInfoVM.cardDetails,
productId: get(productStore)?.id,
});
if (pInfoParsed.error) {
console.error(
"Payment info validation error:",
pInfoParsed.error.errors,
);
const err = pInfoParsed.error.errors[0];
toast.error("Failed to validate payment information", {
description: `${err.path.join(".")}: ${err.message}`,
});
return false;
}
try {
console.log("Creating order");
this.loading = true;
const out = await api.order.createOrder.mutate({
productId: get(productStore)?.id,
orderModel: parsed.data,
customerInfo: customerInfoVM.customerInfo!,
paymentInfo: pInfoParsed.data,
flowId: ckFlowVM.flowId,
});
if (out.error) {
this.loading = false;
toast.error(out.error.message, {
description: out.error.userHint,
});
return false;
}
if (!out.data) {
this.loading = false;
toast.error("Failed to create order", {
description: "Please try again",
});
return false;
}
toast.success("Order created successfully", {
description: "Redirecting, please wait...",
});
setTimeout(() => {
window.location.href = `/checkout/success?uid=${out.data}`;
}, 500);
return true;
} catch (e) {
this.checkoutSubmitted = false;
toast.error("An error occurred during checkout", {
description: "Please try again",
});
return false;
}
}
}
export const checkoutVM = new CheckoutViewModel();

View File

@@ -0,0 +1,100 @@
<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 { 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";
$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="flex w-full flex-col gap-6">
<Title size="h4" weight="medium">Personal Info</Title>
<CustomerPiiForm bind:info={customerInfoVM.customerInfo} />
</div>
{/if}
<div class="mt-8 flex flex-col items-center justify-between gap-4 border-t border-gray-200 pt-6 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>

View File

@@ -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();

View File

@@ -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;

View File

@@ -0,0 +1,112 @@
<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";
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-8">
<div class="flex w-full flex-col gap-6">
<Title size="h4" weight="medium">Order Summary</Title>
<OrderSummary />
</div>
<div class="flex w-full flex-col gap-6 border-t border-gray-200 pt-8">
<Title size="h4" weight="medium">Billing Details</Title>
<BillingDetailsForm info={billingDetailsVM.billingDetails} />
</div>
<div class="flex w-full flex-col gap-6 border-t border-gray-200 pt-8">
<Title size="h4" weight="medium">Payment Details</Title>
<PaymentForm />
</div>
<div class="flex flex-col items-center justify-between gap-4 border-t border-gray-200 pt-6 md:flex-row">
<Button variant="secondary" onclick={goBack} class="w-full md:w-max">
<Icon icon={RightArrowIcon} cls="w-auto h-6 rotate-180" />
Back
</Button>
<Button
variant="default"
onclick={handleSubmit}
class="w-full md:w-max"
disabled={checkoutVM.continutingToNextStep}
>
<ButtonLoadableText
text="Confirm & Pay"
loadingText="Processing info..."
loading={checkoutVM.continutingToNextStep}
/>
<Icon icon={RightArrowIcon} cls="w-auto h-6" />
</Button>
</div>
</div>

View File

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

View File

@@ -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"

View File

@@ -0,0 +1,100 @@
<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-6">
<Title size="h4" weight="medium">Order Summary</Title>
{#if !calculating}
<!-- Product Information -->
{#if $productStore}
<div class="flex flex-col gap-3 border-b border-gray-200 pb-6">
<Title size="p" weight="medium">
{$productStore.title}
</Title>
<p class="text-sm leading-relaxed text-gray-600">
{$productStore.description}
</p>
</div>
{/if}
<!-- Price Breakdown -->
<div class="flex flex-col gap-3 border-b border-gray-200 pb-6">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-600">Base Price</span>
<span class="font-medium text-gray-900">
{convertAndFormatCurrency(priceDetails.basePrice)}
</span>
</div>
{#if priceDetails.discountAmount > 0}
<div class="flex items-center justify-between text-sm">
<span class="text-gray-600">Discount</span>
<span class="font-medium text-green-600">
-{convertAndFormatCurrency(priceDetails.discountAmount)}
</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-gray-600">Display Price</span>
<span class="font-medium text-gray-900">
{convertAndFormatCurrency(priceDetails.displayPrice)}
</span>
</div>
{/if}
</div>
<!-- Final Total -->
<div class="flex items-center justify-between">
<span class="text-base font-semibold text-gray-900">
Total ({$currencyStore.code})
</span>
<span class="text-2xl font-bold text-gray-900">
{convertAndFormatCurrency(priceDetails.orderPrice)}
</span>
</div>
<!-- Security Badge -->
<div class="mt-4 rounded-lg bg-blue-50 p-4">
<div class="flex items-start gap-3">
<svg class="mt-0.5 h-5 w-5 flex-shrink-0 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<div class="flex-1">
<p class="text-xs font-medium text-blue-900">Secure Checkout</p>
<p class="mt-1 text-xs text-blue-700">
Your payment information is encrypted and secure
</p>
</div>
</div>
</div>
{:else}
<div class="grid place-items-center py-12">
<div class="flex items-center gap-3">
<div class="h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-primary"></div>
<span class="text-sm text-gray-600">Calculating...</span>
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,68 @@
<script lang="ts">
import Loader from "$lib/components/atoms/loader.svelte";
import { onDestroy, onMount } from "svelte";
const initialMessages = [
"Just a moment...",
"Please wait...",
"Processing...",
"Working on it...",
"One moment please...",
];
const fiveSecondMessages = [
"Still processing...",
"Just a bit longer...",
"Almost there...",
"This may take a moment...",
"Thank you for waiting...",
];
const tenSecondMessages = [
"Thanks for your patience...",
"Still working on it...",
"We appreciate your patience...",
"Processing securely...",
"Just a little longer...",
];
const twentySecondMessages = [
"Thank you for waiting...",
"Still processing thanks for being patient...",
"We're working on it...",
"Your patience is appreciated...",
"Almost done...",
];
const getRandomMessage = (messages: string[]) => {
return messages[Math.floor(Math.random() * messages.length)];
};
let _defaultTxt = getRandomMessage(initialMessages);
let txt = $state(_defaultTxt);
onMount(() => {
setTimeout(() => {
txt = getRandomMessage(fiveSecondMessages);
}, 5000);
setTimeout(() => {
txt = getRandomMessage(tenSecondMessages);
}, 10000);
setTimeout(() => {
txt = getRandomMessage(twentySecondMessages);
}, 20000);
});
onDestroy(() => {
txt = _defaultTxt;
});
</script>
<div
class="flex h-full w-full flex-col place-items-center p-4 py-20 md:p-8 md:py-32"
>
<Loader />
<p class="animate-pulse py-20 text-center">{txt}</p>
</div>

View File

@@ -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;
@@ -30,7 +30,7 @@
} }
}); });
function gototop() { function goToTop() {
window.scrollTo(0, 0); window.scrollTo(0, 0);
return true; return true;
} }
@@ -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);
}); });
@@ -57,10 +57,12 @@
}); });
</script> </script>
{#if showOtpVerificationForm} <div class="grid h-full w-full place-items-center gap-4">
{@const done = gototop()} {#if showOtpVerificationForm}
{@const done = goToTop()}
<OtpVerificationSection /> <OtpVerificationSection />
{:else} {:else}
{@const done2 = gototop()} {@const done2 = goToTop()}
<PaymentVerificationLoader /> <PaymentVerificationLoader />
{/if} {/if}
</div>

View File

@@ -0,0 +1,4 @@
import { nanoid } from "nanoid/non-secure";
import { writable } from "svelte/store";
export const checkoutSessionIdStore = writable(nanoid());

View 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,
};
}

View File

@@ -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);
} }

View File

@@ -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,
); );
}), }),

View File

@@ -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);
} }

View File

@@ -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(
@@ -76,7 +74,7 @@ class ActionRunner {
return; return;
} }
toast.success("Your booking has been confirmed", { toast.success("Checkout completed successfully", {
description: "Redirecting, please wait...", description: "Redirecting, please wait...",
}); });
@@ -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,16 +153,16 @@ 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 plid = page.params.plid 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}&plid=${plid}`);
} }
} }
@@ -176,16 +174,15 @@ export class CKFlowViewModel {
otpCode: string | undefined = $state(undefined); otpCode: string | undefined = $state(undefined);
poller: NodeJS.Timer | undefined = undefined; _flowPoller: NodeJS.Timeout | undefined = undefined;
pinger: NodeJS.Timer | undefined = undefined; _flowPinger: NodeJS.Timeout | undefined = undefined;
priceFetcher: NodeJS.Timer | undefined = undefined;
// Data synchronization control // Data synchronization control
private personalInfoDebounceTimer: NodeJS.Timeout | null = null; private personalInfoDebounceTimer: NodeJS.Timeout | null = null;
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 +213,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 +226,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 +238,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 +252,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 +267,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;
} }
@@ -342,27 +333,27 @@ export class CKFlowViewModel {
} }
private clearPoller() { private clearPoller() {
if (this.poller) { if (this._flowPoller) {
clearInterval(this.poller); clearInterval(this._flowPoller);
} }
} }
private clearPinger() { private clearPinger() {
if (this.pinger) { if (this._flowPinger) {
clearInterval(this.pinger); clearInterval(this._flowPinger);
} }
} }
private async startPolling() { private async startPolling() {
this.clearPoller(); this.clearPoller();
this.poller = setInterval(() => { this._flowPoller = setInterval(() => {
this.refreshFlowInfo(); this.refreshFlowInfo();
}, 2000); }, 2000);
} }
private async startPinging() { private async startPinging() {
this.clearPinger(); this.clearPinger();
this.pinger = setInterval(() => { this._flowPinger = setInterval(() => {
this.pingFlow(); this.pingFlow();
}, 30000); // Every 30 seconds }, 30000); // Every 30 seconds
} }
@@ -537,17 +528,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 +563,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!,

View 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));
}

View File

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

View File

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

View File

@@ -0,0 +1,241 @@
<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 * as Popover from "$lib/components/ui/popover";
import * as Command from "$lib/components/ui/command";
import { Button } from "$lib/components/ui/button";
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 { cn } from "$lib/utils";
import { tick } from "svelte";
import ChevronsUpDownIcon from "~icons/lucide/chevrons-up-down";
import CheckIcon from "~icons/lucide/check";
import type { CustomerInfoModel } from "../data";
import { customerInfoVM } from "./customerinfo.vm.svelte";
let { info = $bindable() }: { info: CustomerInfoModel } = $props();
let phoneCodeOpen = $state(false);
let phoneCodeTriggerRef = $state<HTMLButtonElement>(null!);
function onSubmit(e: SubmitEvent) {
e.preventDefault();
customerInfoVM.validateCustomerInfo(info);
}
function debounceValidate() {
customerInfoVM.debounceValidate(info);
}
function closePhoneCodeAndFocus() {
phoneCodeOpen = false;
tick().then(() => {
phoneCodeTriggerRef?.focus();
});
}
</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">
<Popover.Root bind:open={phoneCodeOpen}>
<Popover.Trigger bind:ref={phoneCodeTriggerRef}>
{#snippet child({ props })}
<Button
{...props}
variant="outline"
class="w-32 justify-between"
role="combobox"
aria-expanded={phoneCodeOpen}
>
{info.phoneCountryCode || "Select"}
<ChevronsUpDownIcon class="h-4 w-4 opacity-50" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-[300px] p-0">
<Command.Root>
<Command.Input placeholder="Search country..." class="h-9" />
<Command.List class="command-scrollbar max-h-[300px]">
<Command.Empty>No country found.</Command.Empty>
<Command.Group>
{#each PHONE_COUNTRY_CODES as { country, phoneCode } (country)}
<Command.Item
value={`${phoneCode} ${country}`}
onSelect={() => {
info.phoneCountryCode = phoneCode;
debounceValidate();
closePhoneCodeAndFocus();
}}
>
<CheckIcon
class={cn(
"h-4 w-4 flex-shrink-0",
info.phoneCountryCode !== phoneCode &&
"text-transparent",
)}
/>
<span class="flex flex-1 items-center justify-between gap-3">
<span class="font-semibold text-gray-900">{phoneCode}</span>
<span class="text-sm text-gray-600">{country}</span>
</span>
</Command.Item>
{/each}
</Command.Group>
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.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>

View File

@@ -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();

View File

@@ -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,27 +78,19 @@ 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(
{ {
code: ERROR_CODES.INTERNAL_SERVER_ERROR, code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "An error occured while finding booking", message: "An error occured while finding order",
userHint: "Please try again later", userHint: "Please try again later",
detail: "An error occured while parsing order", detail: "An error occured while parsing order",
}, },
@@ -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(

View File

@@ -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[]) {

View File

@@ -1,78 +1,117 @@
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 piuc = new PaymentInfoUseCases(new PaymentInfoRepository(db));
const oc = new OrderController(new OrderRepository(db)); const orderController = new OrderController(new OrderRepository(db));
const pc = new PassengerInfoController( const customerInfoController = getCustomerInfoController();
new PassengerInfoRepository(db), const productUC = getProductUseCases();
);
const tc = getTC();
const emailUC = new EmailerUseCases();
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 piuc.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 piuc.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}`);
if (paymentInfoId) {
await piuc.updatePaymentInfoOrderId(paymentInfoId, 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 +119,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 +130,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);
}), }),
}); });

View File

@@ -1,119 +0,0 @@
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
import {
createOrderPayloadModel,
OrderCreationStep,
} from "$lib/domains/order/data/entities";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import type { FlightTicket } from "$lib/domains/ticket/data/entities";
import { trpcApiStore } from "$lib/stores/api";
import type { EmailAccountPayload } from "@pkg/logic/domains/account/data/entities";
import { toast } from "svelte-sonner";
import { get } from "svelte/store";
export class CreateOrderViewModel {
orderStep = $state(OrderCreationStep.ACCOUNT_SELECTION);
accountInfo = $state<EmailAccountPayload>({ email: "", password: "" });
accountInfoOk = $state(false);
passengerInfosOk = $state(false);
ticketInfo = $state<FlightTicket | undefined>(undefined);
ticketInfoOk = $state(false);
loading = $state(true);
setStep(step: OrderCreationStep) {
if (step === OrderCreationStep.ACCOUNT_SELECTION && this.accountInfoOk) {
this.orderStep = step;
} else if (
step === OrderCreationStep.TICKET_SELECTION &&
this.ticketInfoOk
) {
this.orderStep = step;
} else {
this.orderStep = step;
}
}
setNextStep() {
if (this.orderStep === OrderCreationStep.ACCOUNT_SELECTION) {
this.orderStep = OrderCreationStep.TICKET_SELECTION;
} else if (this.orderStep === OrderCreationStep.TICKET_SELECTION) {
this.orderStep = OrderCreationStep.CUSTOMER_INFO;
} 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;
}
let basePrice = 0;
let displayPrice = 0;
let discountAmount = 0;
if (this.ticketInfo) {
basePrice = this.ticketInfo.priceDetails.basePrice;
displayPrice = this.ticketInfo.priceDetails.displayPrice;
discountAmount = this.ticketInfo.priceDetails.discountAmount;
}
const parsed = createOrderPayloadModel.safeParse({
orderModel: {
basePrice,
displayPrice,
discountAmount,
flightTicketInfoId: 0,
emailAccountId: 0,
},
emailAccountInfo: this.accountInfo,
flightTicketInfo: this.ticketInfo!,
passengerInfos: passengerInfoVM.passengerInfos,
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 (out.error) {
return toast.error(out.error.message, {
description: out.error.userHint,
});
}
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");
setTimeout(() => {
window.location.replace("/");
}, 1000);
}
}
export const createOrderVM = new CreateOrderViewModel();

View File

@@ -1,217 +0,0 @@
<script lang="ts">
import Title from "$lib/components/atoms/title.svelte";
import Button from "$lib/components/ui/button/button.svelte";
import Icon from "$lib/components/atoms/icon.svelte";
import EmailIcon from "~icons/solar/letter-broken";
import TicketIcon from "~icons/solar/ticket-broken";
import UsersIcon from "~icons/solar/users-group-rounded-broken";
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";
import CreditCardIcon from "~icons/solar/card-broken";
import ButtonLoadableText from "$lib/components/atoms/button-loadable-text.svelte";
import TicketLegsOverview from "$lib/domains/ticket/view/ticket/ticket-legs-overview.svelte";
import Badge from "$lib/components/ui/badge/badge.svelte";
import { createOrderVM } from "./create.order.vm.svelte";
import { capitalize, snakeToSpacedPascal } from "$lib/core/string.utils";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
let isCreatingOrder = $state(false);
async function handleCreateOrder() {
isCreatingOrder = true;
await createOrderVM.createOrder();
isCreatingOrder = false;
}
const cardStyle =
"flex flex-col gap-4 rounded-lg border border-gray-200 bg-white p-4 shadow-md lg:p-8";
</script>
<div class={cardStyle}>
<div class="mb-4 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">{createOrderVM.accountInfo.email}</p>
</div>
{#if createOrderVM.ticketInfo}
<div class={cardStyle}>
<div class="flex flex-col items-center justify-between gap-2 md:flex-row">
<div class="mb-4 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">
{snakeToSpacedPascal(
createOrderVM.ticketInfo.flightType.toLowerCase(),
)}
</Badge>
<Badge variant="secondary">
{snakeToSpacedPascal(
createOrderVM.ticketInfo.cabinClass.toLowerCase(),
)}
</Badge>
</div>
</div>
{#if createOrderVM.ticketInfo}
<TicketLegsOverview data={createOrderVM.ticketInfo} />
{/if}
</div>
<div class={cardStyle}>
<div class="mb-4 flex items-center gap-2">
<Icon icon={UsersIcon} cls="w-5 h-5" />
<Title size="h5" color="black">Passengers</Title>
</div>
{#each passengerInfoVM.passengerInfo 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.middleName}
{passenger.passengerPii.lastName}
</p>
</div>
<div>
<span class="text-gray-500">Nationality</span>
<p class="font-medium">
{capitalize(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>
{passenger.bagSelection.personalBags}x 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.seatNumber}
<div class="flex items-center gap-2 text-sm">
<Icon icon={SeatIcon} cls="h-5 w-5 text-gray-600" />
<span>Seat {passenger.seatSelection.seatNumber}</span>
</div>
{/if}
</div>
{#if index < passengerInfoVM.passengerInfos.length - 1}
<div class="border-b border-dashed"></div>
{/if}
{/each}
</div>
{/if}
{#if createOrderVM.ticketInfo}
<div class={cardStyle}>
<div class="mb-4 flex items-center gap-2">
<Icon icon={CreditCardIcon} cls="w-5 h-5" />
<Title size="h5" color="black">Price Summary</Title>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span>Ticket Price</span>
<span>
${createOrderVM.ticketInfo.priceDetails.basePrice.toFixed(2)}
</span>
</div>
{#if createOrderVM.ticketInfo.priceDetails.discountAmount > 0}
<div class="flex items-center justify-between">
<span>Discount</span>
<span class="text-green-600">
-${createOrderVM.ticketInfo.priceDetails.discountAmount.toFixed(
2,
)}
</span>
</div>
{/if}
<div class="mt-2 flex items-center justify-between border-t pt-2">
<span class="font-medium">Total Price</span>
<span class="font-medium">
${createOrderVM.ticketInfo.priceDetails.displayPrice.toFixed(
2,
)}
</span>
</div>
</div>
</div>
{/if}
<div class="flex flex-col items-center justify-between gap-4 lg:flex-row">
<Button
class="w-full lg:max-w-max"
variant="white"
onclick={() => {
createOrderVM.setPrevStep();
}}
>
Go Back
</Button>
<Button
class="w-full lg:max-w-max"
disabled={isCreatingOrder}
onclick={handleCreateOrder}
>
<ButtonLoadableText
loading={isCreatingOrder}
loadingText="Creating Order"
text="Confirm & Create Order"
/>
</Button>
</div>

View File

@@ -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">

View File

@@ -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;

View File

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

View File

@@ -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 };
}
}

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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();

View File

@@ -22,8 +22,8 @@ export class PaymentInfoRepository {
cardholderName: data.cardDetails.cardholderName, cardholderName: data.cardDetails.cardholderName,
expiry: data.cardDetails.expiry, expiry: data.cardDetails.expiry,
cvv: data.cardDetails.cvv, cvv: data.cardDetails.cvv,
flightTicketInfoId: data.flightTicketInfoId, productId: data.productId,
orderId: data.orderId,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}) })
@@ -45,6 +45,20 @@ export class PaymentInfoRepository {
return { data: parsed.data }; return { data: parsed.data };
} }
async updatePaymentInfoOrderId(
id: number,
oid: number,
): Promise<Result<number>> {
Logger.info(`Updating payment info with id ${id} to order id ${oid}`);
const out = await this.db
.update(paymentInfo)
.set({ orderId: oid })
.where(eq(paymentInfo.id, id))
.execute();
Logger.debug(out);
return { data: id };
}
async deletePaymentInfo(id: number): Promise<Result<boolean>> { async deletePaymentInfo(id: number): Promise<Result<boolean>> {
Logger.info(`Deleting payment info with id ${id}`); Logger.info(`Deleting payment info with id ${id}`);
const out = await this.db const out = await this.db

View File

@@ -16,6 +16,10 @@ export class PaymentInfoUseCases {
return this.repo.getPaymentInfo(id); return this.repo.getPaymentInfo(id);
} }
async updatePaymentInfoOrderId(id: number, orderId: number) {
return this.repo.updatePaymentInfoOrderId(id, orderId);
}
async deletePaymentInfo(id: number) { async deletePaymentInfo(id: number) {
return this.repo.deletePaymentInfo(id); return this.repo.deletePaymentInfo(id);
} }

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
import { writable } from "svelte/store";
import type { ProductModel } from "./data";
export const productStore = writable<ProductModel | null>(null);

View File

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

View File

@@ -1 +0,0 @@
export * from "$lib/domains/passengerinfo/data/entities";

View File

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

View File

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

View File

@@ -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);
}
}

View File

@@ -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 ?? [] };
}
}

View File

@@ -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: "",
});

View File

@@ -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));
}

View File

@@ -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);
}),
});

View File

@@ -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;
}
}

View File

@@ -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>

Some files were not shown because too many files have changed in this diff Show More