Compare commits
20 Commits
3232542de1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe76a56194 | ||
|
|
eb7e8e91d5 | ||
|
|
19625fe51a | ||
|
|
7cbe86b3a8 | ||
|
|
319384c334 | ||
|
|
f0fa53a4e5 | ||
|
|
b6bdb6d7e8 | ||
|
|
f9f743eb15 | ||
|
|
1a89236449 | ||
|
|
6a0571fcfa | ||
|
|
5f9c094211 | ||
|
|
88d430a15e | ||
|
|
8a169f84cc | ||
|
|
94bb51bdc7 | ||
|
|
49abd1246b | ||
|
|
de2fbd41d6 | ||
|
|
8440c6a2dd | ||
|
|
c3650f6d5e | ||
|
|
c0df8cae57 | ||
|
|
5f4e9fc7fc |
@@ -1,9 +1,9 @@
|
||||
DATABASE_URL=${{project.DATABASE_URL}}
|
||||
REDIS_URL=${{project.REDIS_URL}}
|
||||
|
||||
PUBLIC_NODE_ENV=${{project.PUBLIC_NODE_ENV}}
|
||||
NODE_ENV=${{project.NODE_ENV}}
|
||||
|
||||
DATABASE_URL=${{project.DATABASE_URL}}
|
||||
REDIS_URL=${{project.REDIS_URL}}
|
||||
|
||||
PUBLIC_URL=${{project.BETTER_AUTH_URL}}
|
||||
PUBLIC_FRONTEND_URL=${{project.BETTER_AUTH_URL}}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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)
|
||||
|
||||
---
|
||||
|
||||
@@ -6,14 +6,11 @@
|
||||
import Icon from "$lib/components/atoms/icon.svelte";
|
||||
import { adminSiteNavMap } from "$lib/core/constants";
|
||||
import { sessionUserInfo } from "$lib/stores/session.info";
|
||||
import { SettingsIcon } from "@lucide/svelte";
|
||||
import ProductIcon from "~icons/carbon/carbon-for-ibm-product";
|
||||
import SessionIcon from "~icons/carbon/prompt-session";
|
||||
import HistoryIcon from "~icons/iconamoon/history-light";
|
||||
import BillListIcon from "~icons/solar/bill-list-linear";
|
||||
import PackageIcon from "~icons/solar/box-broken";
|
||||
import DashboardIcon from "~icons/solar/laptop-minimalistic-broken";
|
||||
import UsersIcon from "~icons/solar/users-group-two-rounded-broken";
|
||||
|
||||
const mainLinks = [
|
||||
{
|
||||
@@ -31,11 +28,6 @@
|
||||
title: "Session History",
|
||||
url: adminSiteNavMap.sessions.history,
|
||||
},
|
||||
{
|
||||
icon: BillListIcon,
|
||||
title: "Data",
|
||||
url: adminSiteNavMap.data,
|
||||
},
|
||||
{
|
||||
icon: PackageIcon,
|
||||
title: "Orders",
|
||||
@@ -47,16 +39,6 @@
|
||||
title: "Products",
|
||||
url: adminSiteNavMap.products,
|
||||
},
|
||||
{
|
||||
icon: UsersIcon,
|
||||
title: "Profile",
|
||||
url: adminSiteNavMap.profile,
|
||||
},
|
||||
{
|
||||
icon: SettingsIcon,
|
||||
title: "Settings",
|
||||
url: adminSiteNavMap.settings,
|
||||
},
|
||||
];
|
||||
|
||||
let {
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
import { trpcApiStore } from "$lib/stores/api";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { get } from "svelte/store";
|
||||
import type {
|
||||
CouponModel,
|
||||
CreateCouponPayload,
|
||||
UpdateCouponPayload,
|
||||
} from "./data";
|
||||
import { DiscountType } from "./data";
|
||||
|
||||
export class CouponViewModel {
|
||||
coupons = $state<CouponModel[]>([]);
|
||||
loading = $state(false);
|
||||
formLoading = $state(false);
|
||||
|
||||
// Selected coupon for edit/delete operations
|
||||
selectedCoupon = $state<CouponModel | null>(null);
|
||||
|
||||
async fetchCoupons() {
|
||||
const api = get(trpcApiStore);
|
||||
if (!api) {
|
||||
return toast.error("API client not initialized");
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
try {
|
||||
const result = await api.coupon.getAllCoupons.query();
|
||||
if (result.error) {
|
||||
toast.error(result.error.message, {
|
||||
description: result.error.userHint,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
console.log(result);
|
||||
|
||||
this.coupons = result.data || [];
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast.error("Failed to fetch coupons", {
|
||||
description: "Please try again later",
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async createCoupon(payload: CreateCouponPayload) {
|
||||
const api = get(trpcApiStore);
|
||||
if (!api) {
|
||||
return toast.error("API client not initialized");
|
||||
}
|
||||
|
||||
this.formLoading = true;
|
||||
try {
|
||||
const result = await api.coupon.createCoupon.mutate(payload);
|
||||
if (result.error) {
|
||||
toast.error(result.error.message, {
|
||||
description: result.error.userHint,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
toast.success("Coupon created successfully");
|
||||
await this.fetchCoupons();
|
||||
return true;
|
||||
} catch (e) {
|
||||
toast.error("Failed to create coupon", {
|
||||
description: "Please try again later",
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
this.formLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async updateCoupon(payload: UpdateCouponPayload) {
|
||||
const api = get(trpcApiStore);
|
||||
if (!api) {
|
||||
return toast.error("API client not initialized");
|
||||
}
|
||||
|
||||
this.formLoading = true;
|
||||
try {
|
||||
const result = await api.coupon.updateCoupon.mutate(payload);
|
||||
if (result.error) {
|
||||
toast.error(result.error.message, {
|
||||
description: result.error.userHint,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
toast.success("Coupon updated successfully");
|
||||
await this.fetchCoupons();
|
||||
return true;
|
||||
} catch (e) {
|
||||
toast.error("Failed to update coupon", {
|
||||
description: "Please try again later",
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
this.formLoading = false;
|
||||
this.selectedCoupon = null;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteCoupon(id: number) {
|
||||
const api = get(trpcApiStore);
|
||||
if (!api) {
|
||||
return toast.error("API client not initialized");
|
||||
}
|
||||
|
||||
this.formLoading = true;
|
||||
try {
|
||||
const result = await api.coupon.deleteCoupon.mutate({ id });
|
||||
if (result.error) {
|
||||
toast.error(result.error.message, {
|
||||
description: result.error.userHint,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
toast.success("Coupon deleted successfully");
|
||||
await this.fetchCoupons();
|
||||
return true;
|
||||
} catch (e) {
|
||||
toast.error("Failed to delete coupon", {
|
||||
description: "Please try again later",
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
this.formLoading = false;
|
||||
this.selectedCoupon = null;
|
||||
}
|
||||
}
|
||||
|
||||
async toggleCouponStatus(id: number, isActive: boolean) {
|
||||
const api = get(trpcApiStore);
|
||||
if (!api) {
|
||||
return toast.error("API client not initialized");
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api.coupon.toggleCouponStatus.mutate({
|
||||
id,
|
||||
isActive,
|
||||
});
|
||||
if (result.error) {
|
||||
toast.error(result.error.message, {
|
||||
description: result.error.userHint,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
toast.success(
|
||||
`Coupon ${isActive ? "activated" : "deactivated"} successfully`,
|
||||
);
|
||||
await this.fetchCoupons();
|
||||
return true;
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
`Failed to ${isActive ? "activate" : "deactivate"} coupon`,
|
||||
{
|
||||
description: "Please try again later",
|
||||
},
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
selectCoupon(coupon: CouponModel) {
|
||||
this.selectedCoupon = { ...coupon };
|
||||
}
|
||||
|
||||
clearSelectedCoupon() {
|
||||
this.selectedCoupon = null;
|
||||
}
|
||||
|
||||
getDefaultCoupon(): CreateCouponPayload {
|
||||
const today = new Date();
|
||||
const nextMonth = new Date();
|
||||
nextMonth.setMonth(today.getMonth() + 1);
|
||||
|
||||
return {
|
||||
code: "",
|
||||
description: "",
|
||||
discountType: DiscountType.PERCENTAGE,
|
||||
discountValue: 10,
|
||||
maxUsageCount: null,
|
||||
minOrderValue: null,
|
||||
maxDiscountAmount: null,
|
||||
startDate: today.toISOString().split("T")[0],
|
||||
endDate: nextMonth.toISOString().split("T")[0],
|
||||
isActive: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const couponVM = new CouponViewModel();
|
||||
@@ -1 +0,0 @@
|
||||
export * from "@pkg/logic/domains/coupon/data";
|
||||
@@ -1 +0,0 @@
|
||||
export * from "@pkg/logic/domains/coupon/repository";
|
||||
@@ -1,52 +0,0 @@
|
||||
import { createTRPCRouter } from "$lib/trpc/t";
|
||||
import { z } from "zod";
|
||||
import { getCouponUseCases } from "./usecases";
|
||||
import { createCouponPayload, updateCouponPayload } from "./data";
|
||||
import { protectedProcedure } from "$lib/server/trpc/t";
|
||||
|
||||
export const couponRouter = createTRPCRouter({
|
||||
getAllCoupons: protectedProcedure.query(async ({}) => {
|
||||
const controller = getCouponUseCases();
|
||||
return controller.getAllCoupons();
|
||||
}),
|
||||
|
||||
getCouponById: protectedProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.query(async ({ input }) => {
|
||||
const controller = getCouponUseCases();
|
||||
return controller.getCouponById(input.id);
|
||||
}),
|
||||
|
||||
createCoupon: protectedProcedure
|
||||
.input(createCouponPayload)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const controller = getCouponUseCases();
|
||||
return controller.createCoupon(ctx.user, input);
|
||||
}),
|
||||
|
||||
updateCoupon: protectedProcedure
|
||||
.input(updateCouponPayload)
|
||||
.mutation(async ({ input }) => {
|
||||
const controller = getCouponUseCases();
|
||||
return controller.updateCoupon(input);
|
||||
}),
|
||||
|
||||
deleteCoupon: protectedProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const controller = getCouponUseCases();
|
||||
return controller.deleteCoupon(input.id);
|
||||
}),
|
||||
|
||||
toggleCouponStatus: protectedProcedure
|
||||
.input(z.object({ id: z.number(), isActive: z.boolean() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const controller = getCouponUseCases();
|
||||
return controller.toggleCouponStatus(input.id, input.isActive);
|
||||
}),
|
||||
|
||||
getActiveCoupons: protectedProcedure.query(async ({}) => {
|
||||
const controller = getCouponUseCases();
|
||||
return controller.getActiveCoupons();
|
||||
}),
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export * from "@pkg/logic/domains/coupon/usecases";
|
||||
@@ -1,187 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Input from "$lib/components/ui/input/input.svelte";
|
||||
import Button from "$lib/components/ui/button/button.svelte";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
import {
|
||||
type CreateCouponPayload,
|
||||
type UpdateCouponPayload,
|
||||
DiscountType,
|
||||
} from "$lib/domains/coupon/data";
|
||||
import { Textarea } from "$lib/components/ui/textarea";
|
||||
|
||||
export let formData: CreateCouponPayload | UpdateCouponPayload;
|
||||
export let loading = false;
|
||||
export let onSubmit: () => void;
|
||||
export let onCancel: () => void;
|
||||
|
||||
const isNewCoupon = !("id" in formData);
|
||||
const discountTypes = [
|
||||
{ value: DiscountType.PERCENTAGE, label: "Percentage" },
|
||||
{ value: DiscountType.FIXED, label: "Fixed Amount" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<form on:submit|preventDefault={onSubmit} class="space-y-6">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Code -->
|
||||
<div class="space-y-2">
|
||||
<Label for="code">Coupon Code</Label>
|
||||
<Input
|
||||
id="code"
|
||||
bind:value={formData.code}
|
||||
placeholder="e.g. SUMMER20"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Discount Type -->
|
||||
<div class="space-y-2">
|
||||
<Label for="discountType">Discount Type</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
required
|
||||
bind:value={formData.discountType}
|
||||
>
|
||||
<Select.Trigger class="w-full">
|
||||
{discountTypes.find((t) => t.value === formData.discountType)
|
||||
?.label || "Select type"}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each discountTypes as type}
|
||||
<Select.Item value={type.value}>{type.label}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Discount Value -->
|
||||
<div class="space-y-2">
|
||||
<Label for="discountValue">
|
||||
{formData.discountType === DiscountType.PERCENTAGE
|
||||
? "Discount Percentage"
|
||||
: "Discount Amount"}
|
||||
</Label>
|
||||
<div class="relative">
|
||||
<Input
|
||||
id="discountValue"
|
||||
type="number"
|
||||
min={0}
|
||||
max={formData.discountType === DiscountType.PERCENTAGE
|
||||
? 100
|
||||
: undefined}
|
||||
step="0.01"
|
||||
bind:value={formData.discountValue}
|
||||
required
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3"
|
||||
>
|
||||
<span class="text-gray-500">
|
||||
{formData.discountType === DiscountType.PERCENTAGE
|
||||
? "%"
|
||||
: "$"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="space-y-2">
|
||||
<Label for="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
bind:value={formData.description}
|
||||
placeholder="Describe what this coupon is for"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Start Date -->
|
||||
<div class="space-y-2">
|
||||
<Label for="startDate">Start Date</Label>
|
||||
<Input
|
||||
id="startDate"
|
||||
type="date"
|
||||
bind:value={formData.startDate}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- End Date -->
|
||||
<div class="space-y-2">
|
||||
<Label for="endDate">End Date (Optional)</Label>
|
||||
<Input id="endDate" type="date" bind:value={formData.endDate} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Max Usage Count -->
|
||||
<div class="space-y-2">
|
||||
<Label for="maxUsageCount">Max Usage Count (Optional)</Label>
|
||||
<Input
|
||||
id="maxUsageCount"
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
bind:value={formData.maxUsageCount}
|
||||
placeholder="Leave empty for unlimited"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Max Discount Amount -->
|
||||
<div class="space-y-2">
|
||||
<Label for="maxDiscountAmount">Max Discount Amount (Optional)</Label>
|
||||
<div class="relative">
|
||||
<Input
|
||||
id="maxDiscountAmount"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
bind:value={formData.maxDiscountAmount}
|
||||
placeholder="Leave empty for no limit"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3"
|
||||
>
|
||||
<span class="text-gray-500">$</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Min Order Value -->
|
||||
<div class="space-y-2">
|
||||
<Label for="minOrderValue">Minimum Order Value (Optional)</Label>
|
||||
<div class="relative">
|
||||
<Input
|
||||
id="minOrderValue"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
bind:value={formData.minOrderValue}
|
||||
placeholder="Leave empty for no minimum"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3"
|
||||
>
|
||||
<span class="text-gray-500">$</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex justify-end space-x-2">
|
||||
<Button variant="outline" onclick={onCancel} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{#if loading}
|
||||
Processing...
|
||||
{:else}
|
||||
{isNewCoupon ? "Create Coupon" : "Update Coupon"}
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -1,297 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { couponVM } from "$lib/domains/coupon/coupon.vm.svelte";
|
||||
import Button from "$lib/components/ui/button/button.svelte";
|
||||
import * as Dialog from "$lib/components/ui/dialog";
|
||||
import * as AlertDialog from "$lib/components/ui/alert-dialog/index";
|
||||
import { Badge } from "$lib/components/ui/badge";
|
||||
import CouponForm from "./coupon-form.svelte";
|
||||
import Title from "$lib/components/atoms/title.svelte";
|
||||
import {
|
||||
PlusIcon,
|
||||
Pencil,
|
||||
Trash2,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
} from "@lucide/svelte";
|
||||
import Loader from "$lib/components/atoms/loader.svelte";
|
||||
import { DiscountType, type CreateCouponPayload } from "../data";
|
||||
|
||||
let createDialogOpen = $state(false);
|
||||
let editDialogOpen = $state(false);
|
||||
let deleteDialogOpen = $state(false);
|
||||
|
||||
let newCouponData = $state<CreateCouponPayload>(couponVM.getDefaultCoupon());
|
||||
|
||||
async function handleCreateCoupon() {
|
||||
const success = await couponVM.createCoupon(newCouponData);
|
||||
if (success) {
|
||||
createDialogOpen = false;
|
||||
newCouponData = couponVM.getDefaultCoupon();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateCoupon() {
|
||||
if (!couponVM.selectedCoupon) return;
|
||||
|
||||
const success = await couponVM.updateCoupon({
|
||||
id: couponVM.selectedCoupon.id! ?? -1,
|
||||
...couponVM.selectedCoupon,
|
||||
});
|
||||
if (success) {
|
||||
editDialogOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteCoupon() {
|
||||
if (!couponVM.selectedCoupon) return;
|
||||
|
||||
const success = await couponVM.deleteCoupon(couponVM.selectedCoupon.id!);
|
||||
if (success) {
|
||||
deleteDialogOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateString: string | null | undefined) {
|
||||
if (!dateString) return "None";
|
||||
return new Date(dateString).toLocaleDateString();
|
||||
}
|
||||
|
||||
function formatDiscountValue(type: DiscountType, value: number) {
|
||||
if (type === DiscountType.PERCENTAGE) {
|
||||
return `${value}%`;
|
||||
} else {
|
||||
return `$${value.toFixed(2)}`;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
setTimeout(async () => {
|
||||
couponVM.fetchCoupons();
|
||||
}, 1000);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<Title size="h3" weight="semibold">Coupons</Title>
|
||||
<Button onclick={() => (createDialogOpen = true)}>
|
||||
<PlusIcon class="mr-2 h-4 w-4" />
|
||||
New Coupon
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if couponVM.loading}
|
||||
<div class="flex items-center justify-center py-20">
|
||||
<Loader />
|
||||
</div>
|
||||
{:else if couponVM.coupons.length === 0}
|
||||
<div class="rounded-lg border py-10 text-center">
|
||||
<p class="text-gray-500">
|
||||
No coupons found. Create your first coupon to get started.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
>Code</th
|
||||
>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
>Discount</th
|
||||
>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
>Valid Period</th
|
||||
>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
>Status</th
|
||||
>
|
||||
<th
|
||||
class="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500"
|
||||
>Actions</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white">
|
||||
{#each couponVM.coupons as coupon}
|
||||
<tr>
|
||||
<td class="whitespace-nowrap px-6 py-4">
|
||||
<div class="font-medium text-gray-900">
|
||||
{coupon.code}
|
||||
</div>
|
||||
{#if coupon.description}
|
||||
<div class="text-sm text-gray-500">
|
||||
{coupon.description}
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
|
||||
<td class="whitespace-nowrap px-6 py-4">
|
||||
<Badge
|
||||
>{formatDiscountValue(
|
||||
coupon.discountType,
|
||||
coupon.discountValue,
|
||||
)}</Badge
|
||||
>
|
||||
{#if coupon.maxDiscountAmount}
|
||||
<div class="mt-1 text-xs text-gray-500">
|
||||
Max: ${coupon.maxDiscountAmount}
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
|
||||
<td class="whitespace-nowrap px-6 py-4">
|
||||
<div>From: {formatDate(coupon.startDate)}</div>
|
||||
<div>To: {formatDate(coupon.endDate)}</div>
|
||||
</td>
|
||||
|
||||
<td class="whitespace-nowrap px-6 py-4">
|
||||
{#if coupon.isActive}
|
||||
<Badge
|
||||
variant="success"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<CheckCircle class="h-3 w-3" />
|
||||
Active
|
||||
</Badge>
|
||||
{:else}
|
||||
<Badge
|
||||
variant="destructive"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<XCircle class="h-3 w-3" />
|
||||
Inactive
|
||||
</Badge>
|
||||
{/if}
|
||||
</td>
|
||||
|
||||
<td
|
||||
class="flex w-full items-center justify-end gap-2 whitespace-nowrap px-6 py-4 text-right"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
couponVM.selectCoupon(coupon);
|
||||
editDialogOpen = true;
|
||||
}}
|
||||
>
|
||||
<Pencil class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
couponVM.selectCoupon(coupon);
|
||||
deleteDialogOpen = true;
|
||||
}}
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={coupon.isActive
|
||||
? "destructive"
|
||||
: "default"}
|
||||
size="sm"
|
||||
onclick={() =>
|
||||
couponVM.toggleCouponStatus(
|
||||
coupon.id!,
|
||||
!coupon.isActive,
|
||||
)}
|
||||
>
|
||||
{coupon.isActive ? "Deactivate" : "Activate"}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Create Coupon Dialog -->
|
||||
<Dialog.Root bind:open={createDialogOpen}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Create New Coupon</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Create a new coupon to offer discounts on flight tickets.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<CouponForm
|
||||
formData={newCouponData}
|
||||
loading={couponVM.formLoading}
|
||||
onSubmit={handleCreateCoupon}
|
||||
onCancel={() => (createDialogOpen = false)}
|
||||
/>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Edit Coupon Dialog -->
|
||||
<Dialog.Root bind:open={editDialogOpen}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Edit Coupon</Dialog.Title>
|
||||
<Dialog.Description>Update this coupon's details.</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
{#if couponVM.selectedCoupon}
|
||||
<CouponForm
|
||||
formData={couponVM.selectedCoupon}
|
||||
loading={couponVM.formLoading}
|
||||
onSubmit={handleUpdateCoupon}
|
||||
onCancel={() => (editDialogOpen = false)}
|
||||
/>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<AlertDialog.Root bind:open={deleteDialogOpen}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Delete Coupon</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
Are you sure you want to delete this coupon? This action cannot be
|
||||
undone.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
|
||||
{#if couponVM.selectedCoupon}
|
||||
<div class="mb-4 rounded-md bg-gray-100 p-4">
|
||||
<div class="font-bold">{couponVM.selectedCoupon.code}</div>
|
||||
{#if couponVM.selectedCoupon.description}
|
||||
<div class="text-sm text-gray-600">
|
||||
{couponVM.selectedCoupon.description}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mt-1">
|
||||
Discount: {formatDiscountValue(
|
||||
couponVM.selectedCoupon.discountType,
|
||||
couponVM.selectedCoupon.discountValue,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel onclick={() => (deleteDialogOpen = false)}>
|
||||
Cancel
|
||||
</AlertDialog.Cancel>
|
||||
<AlertDialog.Action
|
||||
onclick={handleDeleteCoupon}
|
||||
disabled={couponVM.formLoading}
|
||||
>
|
||||
{couponVM.formLoading ? "Deleting..." : "Delete"}
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
@@ -1,16 +1,19 @@
|
||||
<script lang="ts">
|
||||
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 } =
|
||||
$props();
|
||||
</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">
|
||||
{#if icon}
|
||||
<Icon {icon} cls="w-5 h-5" />
|
||||
{/if}
|
||||
<span class="text-gray-500">{title}</span>
|
||||
<Title size="h5" color="black">{title}</Title>
|
||||
</div>
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import UserIcon from "~icons/solar/user-broken";
|
||||
import type { CustomerInfoModel } from "../data";
|
||||
import InfoCard from "./info-card.svelte";
|
||||
import CInfoCard from "./cinfo-card.svelte";
|
||||
|
||||
let {
|
||||
customerInfo,
|
||||
@@ -10,11 +10,11 @@
|
||||
} = $props();
|
||||
</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>
|
||||
<span class="text-xs text-gray-500">Full Name</span>
|
||||
<p>
|
||||
<p class="mt-1 font-medium">
|
||||
{customerInfo.firstName}
|
||||
{#if customerInfo.middleName}
|
||||
{customerInfo.middleName}
|
||||
@@ -24,34 +24,37 @@
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">Email</span>
|
||||
<p>{customerInfo.email}</p>
|
||||
<p class="mt-1">{customerInfo.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
<span class="text-xs text-gray-500">City</span>
|
||||
<p>{customerInfo.city}</p>
|
||||
<p class="mt-1">{customerInfo.city}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">State</span>
|
||||
<p>{customerInfo.state}</p>
|
||||
<p class="mt-1">{customerInfo.state}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">Country</span>
|
||||
<p>{customerInfo.country}</p>
|
||||
<p class="mt-1">{customerInfo.country}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">Zip Code</span>
|
||||
<p>{customerInfo.zipCode}</p>
|
||||
<p class="mt-1">{customerInfo.zipCode}</p>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<span class="text-xs text-gray-500">Address</span>
|
||||
<p>{customerInfo.address}</p>
|
||||
<p class="mt-1">{customerInfo.address}</p>
|
||||
{#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}
|
||||
</div>
|
||||
</div>
|
||||
</InfoCard>
|
||||
</CInfoCard>
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
header: "Location",
|
||||
id: "location",
|
||||
cell: ({ row }) => {
|
||||
return `${row.original.city}, ${row.original.state}`;
|
||||
return `${row.original.country}, ${row.original.city}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -27,6 +27,7 @@ export class OrderRepository {
|
||||
});
|
||||
const out = [] as FullOrderModel[];
|
||||
for (const each of res) {
|
||||
console.log(each);
|
||||
const parsed = fullOrderModel.safeParse({
|
||||
...each,
|
||||
});
|
||||
@@ -58,6 +59,7 @@ export class OrderRepository {
|
||||
where: eq(order.id, oid),
|
||||
with: { customerInfo: true, product: true, paymentInfo: true },
|
||||
});
|
||||
console.log(out?.paymentInfo);
|
||||
if (!out) return {};
|
||||
const parsed = fullOrderModel.safeParse({
|
||||
...out,
|
||||
|
||||
@@ -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>
|
||||
@@ -6,7 +6,7 @@
|
||||
import type { FullOrderModel } from "$lib/domains/order/data/entities";
|
||||
import ProductIcon from "~icons/solar/box-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();
|
||||
|
||||
@@ -17,17 +17,6 @@
|
||||
</script>
|
||||
|
||||
<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 -->
|
||||
<div class={cardStyle}>
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -41,26 +30,28 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<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>
|
||||
<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 class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<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>
|
||||
{#if order.product.discountPrice > 0}
|
||||
<div>
|
||||
<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)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -76,34 +67,40 @@
|
||||
<Title size="h5" color="black">Price Summary</Title>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Base Price</span>
|
||||
<span>${order.basePrice.toFixed(2)}</span>
|
||||
<span class="text-gray-600">Base Price</span>
|
||||
<span class="font-medium">${order.basePrice.toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Display Price</span>
|
||||
<span>${order.displayPrice.toFixed(2)}</span>
|
||||
<span class="text-gray-600">Display Price</span>
|
||||
<span class="font-medium">${order.displayPrice.toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
{#if discAmt > 0}
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Discount</span>
|
||||
<span class="text-green-600">
|
||||
<span class="text-gray-600">Discount</span>
|
||||
<span class="font-semibold text-green-600">
|
||||
-${discAmt.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-2 flex items-center justify-between border-t pt-2">
|
||||
<span class="font-medium">Order Price</span>
|
||||
<span class="font-medium">${order.orderPrice.toFixed(2)}</span>
|
||||
<div
|
||||
class="mt-2 flex items-center justify-between border-t border-gray-200 pt-3"
|
||||
>
|
||||
<span class="font-semibold">Order Price</span>
|
||||
<span class="text-lg font-bold"
|
||||
>${order.orderPrice.toFixed(2)}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-500">Fulfilled</span>
|
||||
<span class="text-sm">${order.fullfilledPrice.toFixed(2)}</span>
|
||||
<span class="text-sm text-gray-500">Fulfilled Amount</span>
|
||||
<span class="text-sm font-medium">
|
||||
${order.fullfilledPrice.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -112,4 +109,9 @@
|
||||
<!-- Customer Information -->
|
||||
<CustomerDetailsCard customerInfo={order.customerInfo} />
|
||||
{/if}
|
||||
|
||||
{#if order.paymentInfo}
|
||||
<!-- Billing Information -->
|
||||
<BillingDetailsCard paymentInfo={order.paymentInfo} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
const cardStyle =
|
||||
"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) {
|
||||
switch (status) {
|
||||
@@ -46,7 +47,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class={stickyContainerStyle}>
|
||||
<!-- Order Status -->
|
||||
<div class={cardStyle}>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -54,16 +55,16 @@
|
||||
<Title size="h5" color="black">Order Status</Title>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600">Current Status</span>
|
||||
<Badge variant={getStatusVariant(order.status)}>
|
||||
{formatStatus(order.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 text-sm">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600">Order ID</span>
|
||||
<span class="text-sm text-gray-500">Current Status</span>
|
||||
<Badge variant={getStatusVariant(order.status)}>
|
||||
{formatStatus(order.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-500">Order ID</span>
|
||||
<span class="font-medium">#{order.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -79,37 +80,13 @@
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
@@ -32,14 +32,6 @@
|
||||
return Number(row.id) + 1;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Order ID",
|
||||
accessorKey: "orderId",
|
||||
cell: ({ row }) => {
|
||||
const r = row.original as FullOrderModel;
|
||||
return `#${r.id}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Product",
|
||||
accessorKey: "product",
|
||||
@@ -81,14 +73,6 @@
|
||||
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",
|
||||
id: "actions",
|
||||
|
||||
@@ -7,10 +7,10 @@ import { paymentInfoModel, type PaymentInfo } from "./data";
|
||||
export class PaymentInfoRepository {
|
||||
constructor(private db: Database) {}
|
||||
|
||||
async getPaymentInfo(id: number): Promise<Result<PaymentInfo>> {
|
||||
Logger.info(`Getting payment info with id ${id}`);
|
||||
async getPaymentInfoByOrderID(oid: number): Promise<Result<PaymentInfo>> {
|
||||
Logger.info(`Getting payment info with id ${oid}`);
|
||||
const out = await this.db.query.paymentInfo.findFirst({
|
||||
where: eq(paymentInfo.id, id),
|
||||
where: eq(paymentInfo.orderId, oid),
|
||||
});
|
||||
const parsed = paymentInfoModel.safeParse(out);
|
||||
if (parsed.error) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { authRouter } from "$lib/domains/auth/domain/router";
|
||||
import { ckflowRouter } from "$lib/domains/ckflow/router";
|
||||
import { couponRouter } from "$lib/domains/coupon/router";
|
||||
import { customerInfoRouter } from "$lib/domains/customerinfo/router";
|
||||
import { orderRouter } from "$lib/domains/order/domain/router";
|
||||
import { productRouter } from "$lib/domains/product/router";
|
||||
@@ -12,7 +11,6 @@ export const router = createTRPCRouter({
|
||||
user: userRouter,
|
||||
order: orderRouter,
|
||||
ckflow: ckflowRouter,
|
||||
coupon: couponRouter,
|
||||
product: productRouter,
|
||||
customerInfo: customerInfoRouter,
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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>
|
||||
@@ -1,9 +1,9 @@
|
||||
import { OrderRepository } from "$lib/domains/order/data/repository.js";
|
||||
import { OrderController } from "$lib/domains/order/domain/controller.js";
|
||||
import { db } from "@pkg/db";
|
||||
import type { PageServerLoad } from "./$types.js";
|
||||
import { getError } from "@pkg/logger";
|
||||
import { ERROR_CODES } from "@pkg/result";
|
||||
import type { PageServerLoad } from "./$types.js";
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const oid = parseInt(params.oid);
|
||||
@@ -18,6 +18,5 @@ export const load: PageServerLoad = async ({ params }) => {
|
||||
}),
|
||||
};
|
||||
}
|
||||
const oc = new OrderController(new OrderRepository(db));
|
||||
return await oc.getOrder(oid);
|
||||
return await new OrderController(new OrderRepository(db)).getOrder(oid);
|
||||
};
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<script lang="ts">
|
||||
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 { 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 OrderMiscInfo from "$lib/domains/order/view/order-misc-info.svelte";
|
||||
import { OrderStatus } from "$lib/domains/order/data/entities";
|
||||
import { snakeToSpacedPascal } from "$lib/core/string.utils";
|
||||
import { pageTitle } from "$lib/hooks/page-title.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
import type { PageData } from "./$types";
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
console.log(data.data);
|
||||
$inspect(data.data);
|
||||
|
||||
onMount(() => {
|
||||
if (data.error) {
|
||||
@@ -40,19 +40,34 @@
|
||||
|
||||
{#if data.data}
|
||||
<main class="grid w-full place-items-center gap-4">
|
||||
<Container containerClass="w-full flex flex-col gap-8">
|
||||
<div class="flex items-center justify-between gap-4 md:flex-row">
|
||||
<Title size="h3">Order Details</Title>
|
||||
<Badge variant={getStatusVariant(data.data?.status)}>
|
||||
<Container containerClass="w-full flex flex-col gap-6">
|
||||
<!-- 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>
|
||||
<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())}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid h-full w-full grid-cols-1 gap-4 md:gap-6 lg:grid-cols-2 lg:gap-8"
|
||||
>
|
||||
<OrderMainInfo order={data.data} />
|
||||
<OrderMiscInfo order={data.data} />
|
||||
<!-- Main Content Grid -->
|
||||
<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} />
|
||||
</div>
|
||||
|
||||
<!-- Right Column - Misc Info (1/3 width on large screens) -->
|
||||
<div class="lg:col-span-1">
|
||||
<OrderMiscInfo order={data.data} />
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</main>
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { pageTitle } from "$lib/hooks/page-title.svelte";
|
||||
import CouponList from "$lib/domains/coupon/view/coupon-list.svelte";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "$lib/components/ui/tabs/index";
|
||||
|
||||
pageTitle.set("Settings");
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto py-8">
|
||||
<!-- <Tabs value="coupons" class="w-full">
|
||||
<TabsList class="mb-6">
|
||||
<TabsTrigger value="coupons">Coupon Management</TabsTrigger>
|
||||
<!-- Add more tabs here as needed -->
|
||||
<!-- </TabsList> -->
|
||||
|
||||
<!-- <TabsContent value="coupons"> -->
|
||||
<CouponList />
|
||||
<!-- </TabsContent> -->
|
||||
|
||||
<!-- Add more tab content here as needed -->
|
||||
<!-- </Tabs> -->
|
||||
</div>
|
||||
@@ -1,8 +1,6 @@
|
||||
{
|
||||
"$schema": "https://next.shadcn-svelte.com/schema.json",
|
||||
"style": "default",
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/app.css",
|
||||
"baseColor": "slate"
|
||||
},
|
||||
@@ -10,8 +8,9 @@
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"hooks": "$lib/hooks"
|
||||
"hooks": "$lib/hooks",
|
||||
"lib": "$lib"
|
||||
},
|
||||
"typescript": true,
|
||||
"registry": "https://next.shadcn-svelte.com/registry"
|
||||
"registry": "https://tw3.shadcn-svelte.com/registry/default"
|
||||
}
|
||||
|
||||
@@ -48,12 +48,12 @@
|
||||
"devDependencies": {
|
||||
"@iconify/json": "^2.2.275",
|
||||
"@internationalized/date": "^3.6.0",
|
||||
"@lucide/svelte": "^0.503.0",
|
||||
"@lucide/svelte": "^0.482.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@types/node": "^22.9.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"bits-ui": "^1.4.1",
|
||||
"bits-ui": "^1.4.7",
|
||||
"clsx": "^2.1.1",
|
||||
"embla-carousel-svelte": "^8.6.0",
|
||||
"formsnap": "^2.0.0",
|
||||
|
||||
@@ -3,103 +3,151 @@
|
||||
@tailwind utilities;
|
||||
|
||||
@font-face {
|
||||
font-family: "Poppins";
|
||||
src: url("/fonts/Poppins-Thin.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;
|
||||
font-family: "Fredoka";
|
||||
src: url("/fonts/fredoka-variable.ttf") format("truetype");
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 338 28% 98%;
|
||||
--foreground: 338 5% 10%;
|
||||
--card: 338 28% 98%;
|
||||
--card-foreground: 338 5% 15%;
|
||||
--popover: 338 28% 98%;
|
||||
--popover-foreground: 338 95% 10%;
|
||||
--primary: 338 63% 55.5%;
|
||||
/* Backgrounds - Clean neutral grays */
|
||||
--background: 0 0% 98%;
|
||||
--foreground: 222 47% 11%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222 47% 11%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222 47% 11%;
|
||||
|
||||
/* Primary - Modern slate/blue (Stripe-inspired) */
|
||||
--primary: 221 83% 53%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 338 28% 90%;
|
||||
--secondary-foreground: 0 0% 0%;
|
||||
--muted: 300 28% 95%;
|
||||
--muted-foreground: 338 5% 40%;
|
||||
--accent: 300 28% 90%;
|
||||
--accent-foreground: 338 5% 15%;
|
||||
--destructive: 0 50% 50%;
|
||||
--destructive-foreground: 338 5% 98%;
|
||||
--border: 338 28% 82%;
|
||||
--input: 338 28% 50%;
|
||||
--ring: 338 63% 55.5%;
|
||||
--radius: 0.75rem;
|
||||
|
||||
/* Secondary - Soft gray-blue */
|
||||
--secondary: 214 32% 91%;
|
||||
--secondary-foreground: 222 47% 11%;
|
||||
|
||||
/* Muted - Light grays */
|
||||
--muted: 210 40% 96%;
|
||||
--muted-foreground: 215 16% 47%;
|
||||
|
||||
/* Accent - Vibrant blue */
|
||||
--accent: 217 91% 60%;
|
||||
--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 {
|
||||
--background: 338 28% 10%;
|
||||
--foreground: 338 5% 98%;
|
||||
--card: 338 28% 10%;
|
||||
--card-foreground: 338 5% 98%;
|
||||
--popover: 338 28% 5%;
|
||||
--popover-foreground: 338 5% 98%;
|
||||
--primary: 338 63% 55.5%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 338 28% 20%;
|
||||
--secondary-foreground: 0 0% 100%;
|
||||
--muted: 300 28% 25%;
|
||||
--muted-foreground: 338 5% 65%;
|
||||
--accent: 300 28% 25%;
|
||||
--accent-foreground: 338 5% 95%;
|
||||
--destructive: 0 50% 50%;
|
||||
--destructive-foreground: 338 5% 98%;
|
||||
--border: 338 28% 50%;
|
||||
--input: 338 28% 50%;
|
||||
--ring: 338 63% 55.5%;
|
||||
--radius: 0.75rem;
|
||||
/* Backgrounds - Dark mode */
|
||||
--background: 222 47% 11%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222 47% 15%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222 47% 15%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
|
||||
/* Primary - Brighter blue for dark mode */
|
||||
--primary: 217 91% 60%;
|
||||
--primary-foreground: 222 47% 11%;
|
||||
|
||||
/* Secondary */
|
||||
--secondary: 217 33% 17%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
|
||||
/* Muted */
|
||||
--muted: 223 47% 11%;
|
||||
--muted-foreground: 215 20% 65%;
|
||||
|
||||
/* 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;
|
||||
scroll-behavior: smooth;
|
||||
font-family:
|
||||
"Poppins",
|
||||
system-ui,
|
||||
"Fredoka",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
Arial,
|
||||
"Noto Sans",
|
||||
sans-serif,
|
||||
"Apple Color Emoji",
|
||||
"Segoe UI Emoji",
|
||||
"Segoe UI Symbol",
|
||||
"Noto Color Emoji";
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,8 +189,99 @@
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
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: 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "bits-ui";
|
||||
import { Command as CommandPrimitive, useId } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
@@ -7,6 +7,7 @@
|
||||
class: className,
|
||||
children,
|
||||
heading,
|
||||
value,
|
||||
...restProps
|
||||
}: CommandPrimitive.GroupProps & {
|
||||
heading?: string;
|
||||
@@ -16,6 +17,7 @@
|
||||
<CommandPrimitive.Group
|
||||
class={cn("text-foreground overflow-hidden p-1", className)}
|
||||
bind:ref
|
||||
value={value ?? heading ?? `----${useId()}`}
|
||||
{...restProps}
|
||||
>
|
||||
{#if heading}
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
<Search class="mr-2 size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
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",
|
||||
className,
|
||||
"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
|
||||
)}
|
||||
bind:ref
|
||||
{...restProps}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
<CommandPrimitive.Item
|
||||
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
|
||||
)}
|
||||
bind:ref
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
<CommandPrimitive.LinkItem
|
||||
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
|
||||
)}
|
||||
bind:ref
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Dialog as DialogPrimitive,
|
||||
type WithoutChildrenOrChild,
|
||||
} from "bits-ui";
|
||||
import CloseIcon from "~icons/lucide/x";
|
||||
import { Dialog as DialogPrimitive, type WithoutChildrenOrChild } from "bits-ui";
|
||||
import X from "@lucide/svelte/icons/x";
|
||||
import type { Snippet } from "svelte";
|
||||
import * as Dialog from "./index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import Icon from "$lib/components/atoms/icon.svelte";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -26,16 +22,16 @@
|
||||
<DialogPrimitive.Content
|
||||
bind:ref
|
||||
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",
|
||||
className,
|
||||
"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
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
<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>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
|
||||
@@ -25,9 +25,8 @@
|
||||
<input
|
||||
bind:this={ref}
|
||||
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],
|
||||
TRANSITION_COLORS,
|
||||
className,
|
||||
)}
|
||||
bind:value
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { Select as SelectPrimitive, type WithoutChild } from "bits-ui";
|
||||
import ChevronDown from "@lucide/svelte/icons/chevron-down";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { TRANSITION_COLORS } from "$lib/core/constants";
|
||||
|
||||
const inputSizes = {
|
||||
sm: "px-3 py-2 text-sm file:text-sm",
|
||||
@@ -27,13 +26,9 @@
|
||||
<SelectPrimitive.Trigger
|
||||
bind:ref
|
||||
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 &&
|
||||
"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],
|
||||
TRANSITION_COLORS,
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { env } from "$env/dynamic/public";
|
||||
import IconLinkedInLogo from "~icons/basil/linkedin-outline";
|
||||
import PlaneIcon from "~icons/hugeicons/airplane-02";
|
||||
import IconInstagramLogo from "~icons/mdi/instagram";
|
||||
import DocumentTextIcon from "~icons/solar/document-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_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 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 = [
|
||||
{ name: "Home", link: "/", icon: HomeIcon },
|
||||
|
||||
@@ -1,29 +1,27 @@
|
||||
<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 { 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 CloseIcon from "~icons/lucide/x";
|
||||
import { checkoutVM } from "./checkout.vm.svelte";
|
||||
|
||||
const checkoutSteps = [
|
||||
{ id: CheckoutStep.Initial, label: "Passenger Details" },
|
||||
{ id: CheckoutStep.Initial, label: "Initial Details" },
|
||||
{ id: CheckoutStep.Payment, label: "Payment" },
|
||||
{ id: CheckoutStep.Verification, label: "Verify Details" },
|
||||
{ id: CheckoutStep.Verification, label: "Verify" },
|
||||
{ id: CheckoutStep.Confirmation, label: "Confirmation" },
|
||||
];
|
||||
|
||||
let activeStepIndex = $derived(
|
||||
checkoutSteps.findIndex(
|
||||
(step) => step.id === ticketCheckoutVM.checkoutStep,
|
||||
),
|
||||
checkoutSteps.findIndex((step) => step.id === checkoutVM.checkoutStep),
|
||||
);
|
||||
|
||||
function handleStepClick(clickedIndex: number, stepId: CheckoutStep) {
|
||||
if (clickedIndex <= activeStepIndex) {
|
||||
ticketCheckoutVM.checkoutStep = stepId;
|
||||
checkoutVM.checkoutStep = stepId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,15 +61,15 @@
|
||||
>
|
||||
<Icon icon={CloseIcon} cls="h-5 w-auto" />
|
||||
</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}
|
||||
<button
|
||||
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
|
||||
? "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",
|
||||
index === activeStepIndex && "border-brand-500",
|
||||
index === activeStepIndex && "border-primary bg-primary/10 shadow-sm",
|
||||
)}
|
||||
disabled={index > activeStepIndex}
|
||||
onclick={() => {
|
||||
@@ -81,15 +79,23 @@
|
||||
>
|
||||
<div
|
||||
class={cn(
|
||||
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
||||
index <= activeStepIndex
|
||||
? "bg-primary text-white"
|
||||
: "bg-gray-200 text-gray-600",
|
||||
"flex h-10 w-10 shrink-0 items-center justify-center rounded-full font-medium transition-all",
|
||||
index < activeStepIndex
|
||||
? "bg-green-500 text-white"
|
||||
: index === activeStepIndex
|
||||
? "bg-primary text-white"
|
||||
: "bg-gray-200 text-gray-600",
|
||||
)}
|
||||
>
|
||||
{index + 1}
|
||||
{#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}
|
||||
{/if}
|
||||
</div>
|
||||
<span class="font-medium">
|
||||
<span class="font-medium text-gray-900">
|
||||
{step.label}
|
||||
</span>
|
||||
</button>
|
||||
@@ -99,17 +105,15 @@
|
||||
</Sheet>
|
||||
|
||||
<div class="hidden w-full overflow-x-auto lg:block">
|
||||
<div
|
||||
class="flex w-full min-w-[30rem] items-center justify-between gap-2 overflow-x-auto py-8"
|
||||
>
|
||||
<div class="flex w-full items-center justify-between gap-2 overflow-x-auto">
|
||||
{#each checkoutSteps as step, index}
|
||||
<div class="flex flex-1 items-center gap-2">
|
||||
<div
|
||||
class={cn(
|
||||
"flex items-center justify-center",
|
||||
"flex items-center justify-center transition-all",
|
||||
index <= activeStepIndex
|
||||
? "cursor-pointer"
|
||||
: "cursor-not-allowed opacity-50",
|
||||
: "cursor-not-allowed opacity-40",
|
||||
)}
|
||||
onclick={() => handleStepClick(index, step.id)}
|
||||
onkeydown={(e) => {
|
||||
@@ -122,23 +126,28 @@
|
||||
>
|
||||
<div
|
||||
class={cn(
|
||||
"flex h-10 min-h-10 w-10 min-w-10 items-center justify-center rounded-full border-2 transition-colors",
|
||||
index <= activeStepIndex
|
||||
? "hover:bg-primary-600 border-brand-700 bg-primary text-white/60"
|
||||
: "border-gray-400 bg-gray-100 text-gray-700",
|
||||
index === activeStepIndex
|
||||
? "text-lg font-semibold text-white"
|
||||
: "",
|
||||
"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
|
||||
? "border-green-500 bg-green-500 text-white"
|
||||
: index === activeStepIndex
|
||||
? "border-primary bg-primary text-white shadow-md"
|
||||
: "border-gray-300 bg-white text-gray-400",
|
||||
)}
|
||||
>
|
||||
{index + 1}
|
||||
{#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}
|
||||
{/if}
|
||||
</div>
|
||||
<span
|
||||
class={cn(
|
||||
"ml-2 hidden w-max text-sm md:block",
|
||||
"ml-3 hidden w-max text-sm transition-all md:block",
|
||||
index <= activeStepIndex
|
||||
? "font-semibold"
|
||||
: "text-gray-800",
|
||||
? "font-semibold text-gray-900"
|
||||
: "text-gray-500",
|
||||
)}
|
||||
>
|
||||
{step.label}
|
||||
@@ -147,10 +156,10 @@
|
||||
{#if index !== checkoutSteps.length - 1}
|
||||
<div
|
||||
class={cn(
|
||||
"h-0.5 w-full min-w-4 flex-1 border-t transition-colors",
|
||||
index <= activeStepIndex
|
||||
? "border-primary"
|
||||
: "border-gray-400",
|
||||
"h-0.5 w-full min-w-4 flex-1 transition-all",
|
||||
index < activeStepIndex
|
||||
? "bg-green-500"
|
||||
: "bg-gray-300",
|
||||
)}
|
||||
></div>
|
||||
{/if}
|
||||
126
apps/frontend/src/lib/domains/checkout/checkout.vm.svelte.ts
Normal file
126
apps/frontend/src/lib/domains/checkout/checkout.vm.svelte.ts
Normal 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();
|
||||
@@ -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>
|
||||
@@ -4,10 +4,10 @@
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
import { COUNTRIES_SELECT } from "$lib/core/countries";
|
||||
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";
|
||||
|
||||
let { info = $bindable() }: { info: CustomerInfo } = $props();
|
||||
let { info = $bindable() }: { info: CustomerInfoModel } = $props();
|
||||
|
||||
function onSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
@@ -1,15 +1,15 @@
|
||||
import {
|
||||
type CustomerInfoModel,
|
||||
customerInfoModel,
|
||||
type CustomerInfo,
|
||||
} from "$lib/domains/ticket/data/entities/create.entities";
|
||||
import { Gender } from "$lib/domains/ticket/data/entities/index";
|
||||
Gender,
|
||||
} from "$lib/domains/customerinfo/data";
|
||||
import { z } from "zod";
|
||||
|
||||
export class BillingDetailsViewModel {
|
||||
// @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() {
|
||||
this.reset();
|
||||
@@ -34,15 +34,15 @@ export class BillingDetailsViewModel {
|
||||
zipCode: "",
|
||||
address: "",
|
||||
address2: "",
|
||||
} as CustomerInfo;
|
||||
} as CustomerInfoModel;
|
||||
this.piiErrors = {};
|
||||
}
|
||||
|
||||
setPII(info: CustomerInfo) {
|
||||
setPII(info: CustomerInfoModel) {
|
||||
this.billingDetails = info;
|
||||
}
|
||||
|
||||
validatePII(info: CustomerInfo) {
|
||||
validatePII(info: CustomerInfoModel) {
|
||||
try {
|
||||
const result = customerInfoModel.parse(info);
|
||||
this.piiErrors = {};
|
||||
@@ -51,11 +51,11 @@ export class BillingDetailsViewModel {
|
||||
if (error instanceof z.ZodError) {
|
||||
this.piiErrors = error.errors.reduce(
|
||||
(acc, curr) => {
|
||||
const path = curr.path[0] as keyof CustomerInfo;
|
||||
const path = curr.path[0] as keyof CustomerInfoModel;
|
||||
acc[path] = curr.message;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<keyof CustomerInfo, string>,
|
||||
{} as Record<keyof CustomerInfoModel, string>,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -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>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
import Icon from "$lib/components/atoms/icon.svelte";
|
||||
import Title from "$lib/components/atoms/title.svelte";
|
||||
import { customerInfoVM } from "$lib/domains/customerinfo/view/customerinfo.vm.svelte";
|
||||
import { productStore } from "$lib/domains/product/store";
|
||||
import MailIcon from "~icons/lucide/mail";
|
||||
import MapPinIcon from "~icons/lucide/map-pin";
|
||||
import PackageIcon from "~icons/lucide/package";
|
||||
import PhoneIcon from "~icons/lucide/phone";
|
||||
import UserIcon from "~icons/lucide/user";
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<!-- Product Summary -->
|
||||
{#if $productStore}
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon={PackageIcon} cls="h-5 w-5 text-gray-600" />
|
||||
<Title size="p" weight="semibold">Product</Title>
|
||||
</div>
|
||||
<div class="rounded-lg border bg-gray-50 p-4">
|
||||
<p class="font-medium">{$productStore.title}</p>
|
||||
<p class="mt-1 text-sm text-gray-600">
|
||||
{$productStore.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Customer Information Summary -->
|
||||
{#if customerInfoVM.customerInfo}
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon={UserIcon} cls="h-5 w-5 text-gray-600" />
|
||||
<Title size="p" weight="semibold">Customer Information</Title>
|
||||
</div>
|
||||
|
||||
<!-- Personal Info -->
|
||||
<div class="rounded-lg border bg-gray-50 p-4">
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">Full Name</span>
|
||||
<p class="font-medium">
|
||||
{customerInfoVM.customerInfo.firstName}
|
||||
{#if customerInfoVM.customerInfo.middleName}
|
||||
{customerInfoVM.customerInfo.middleName}
|
||||
{/if}
|
||||
{customerInfoVM.customerInfo.lastName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon={MailIcon} cls="h-4 w-4 text-gray-500" />
|
||||
<div class="flex-1">
|
||||
<span class="text-xs text-gray-500">Email</span>
|
||||
<p class="text-sm">
|
||||
{customerInfoVM.customerInfo.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phone -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon={PhoneIcon} cls="h-4 w-4 text-gray-500" />
|
||||
<div class="flex-1">
|
||||
<span class="text-xs text-gray-500">Phone</span>
|
||||
<p class="text-sm">
|
||||
{customerInfoVM.customerInfo.phoneCountryCode}
|
||||
{customerInfoVM.customerInfo.phoneNumber}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Address -->
|
||||
<div class="flex items-start gap-2">
|
||||
<Icon
|
||||
icon={MapPinIcon}
|
||||
cls="h-4 w-4 text-gray-500 mt-1"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<span class="text-xs text-gray-500">Address</span>
|
||||
<p class="text-sm">
|
||||
{customerInfoVM.customerInfo.address}
|
||||
{#if customerInfoVM.customerInfo.address2}
|
||||
, {customerInfoVM.customerInfo.address2}
|
||||
{/if}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600">
|
||||
{customerInfoVM.customerInfo.city},
|
||||
{customerInfoVM.customerInfo.state}
|
||||
{customerInfoVM.customerInfo.zipCode}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600">
|
||||
{customerInfoVM.customerInfo.country}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-lg border border-dashed p-6 text-center">
|
||||
<p class="text-sm text-gray-500">
|
||||
Customer information not yet provided
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,8 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Input from "$lib/components/ui/input/input.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 { chunk } from "$lib/core/array.utils";
|
||||
|
||||
function formatCardNumberForDisplay(value: string) {
|
||||
// return in format "XXXX XXXX XXXX XXXX" from "XXXXXXXXXXXXXXXX"
|
||||
100
apps/frontend/src/lib/domains/checkout/payment-summary.svelte
Normal file
100
apps/frontend/src/lib/domains/checkout/payment-summary.svelte
Normal 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>
|
||||
@@ -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>
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { ticketCheckoutVM } from "./flight-checkout.vm.svelte";
|
||||
import PaymentVerificationLoader from "./payment-verification-loader.svelte";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { checkoutVM } from "./checkout.vm.svelte";
|
||||
import OtpVerificationSection from "./otp-verification-section.svelte";
|
||||
import PaymentVerificationLoader from "./payment-verification-loader.svelte";
|
||||
|
||||
let refreshIntervalId: NodeJS.Timer;
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
function gototop() {
|
||||
function goToTop() {
|
||||
window.scrollTo(0, 0);
|
||||
return true;
|
||||
}
|
||||
@@ -47,7 +47,7 @@
|
||||
setTimeout(async () => {
|
||||
if (ckFlowVM.setupDone && !ckFlowVM.flowId) {
|
||||
console.log("Shortcut - Checking out");
|
||||
await ticketCheckoutVM.checkout();
|
||||
await checkoutVM.checkout();
|
||||
}
|
||||
}, rng);
|
||||
});
|
||||
@@ -57,10 +57,12 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if showOtpVerificationForm}
|
||||
{@const done = gototop()}
|
||||
<OtpVerificationSection />
|
||||
{:else}
|
||||
{@const done2 = gototop()}
|
||||
<PaymentVerificationLoader />
|
||||
{/if}
|
||||
<div class="grid h-full w-full place-items-center gap-4">
|
||||
{#if showOtpVerificationForm}
|
||||
{@const done = goToTop()}
|
||||
<OtpVerificationSection />
|
||||
{:else}
|
||||
{@const done2 = goToTop()}
|
||||
<PaymentVerificationLoader />
|
||||
{/if}
|
||||
</div>
|
||||
4
apps/frontend/src/lib/domains/checkout/sid.store.ts
Normal file
4
apps/frontend/src/lib/domains/checkout/sid.store.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { nanoid } from "nanoid/non-secure";
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
export const checkoutSessionIdStore = writable(nanoid());
|
||||
50
apps/frontend/src/lib/domains/checkout/utils.ts
Normal file
50
apps/frontend/src/lib/domains/checkout/utils.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { CustomerInfoModel } from "@pkg/logic/domains/customerinfo/data";
|
||||
import type { OrderPriceDetailsModel } from "@pkg/logic/domains/order/data/entities";
|
||||
import type { ProductModel } from "@pkg/logic/domains/product/data";
|
||||
|
||||
/**
|
||||
* Calculates final prices for a product checkout using OrderPriceDetailsModel
|
||||
* @param product - The product being purchased
|
||||
* @param customerInfo - Customer information (included for future extensibility)
|
||||
* @returns OrderPriceDetailsModel with all price fields calculated
|
||||
*/
|
||||
export function calculateFinalPrices(
|
||||
product: ProductModel | null,
|
||||
customerInfo?: CustomerInfoModel | null,
|
||||
): OrderPriceDetailsModel {
|
||||
if (!product) {
|
||||
return {
|
||||
currency: "USD",
|
||||
basePrice: 0,
|
||||
discountAmount: 0,
|
||||
displayPrice: 0,
|
||||
orderPrice: 0,
|
||||
fullfilledPrice: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const basePrice = product.price || 0;
|
||||
const discountPrice = product.discountPrice || 0;
|
||||
|
||||
// Calculate discount amount: if discountPrice is set and less than base price
|
||||
const hasDiscount = discountPrice > 0 && discountPrice < basePrice;
|
||||
const discountAmount = hasDiscount ? basePrice - discountPrice : 0;
|
||||
|
||||
// Display price is either the discounted price or the base price
|
||||
const displayPrice = hasDiscount ? discountPrice : basePrice;
|
||||
|
||||
// For single product checkout:
|
||||
// - orderPrice = displayPrice (what customer pays)
|
||||
// - fullfilledPrice = displayPrice (same as order price for immediate fulfillment)
|
||||
const orderPrice = displayPrice;
|
||||
const fullfilledPrice = displayPrice;
|
||||
|
||||
return {
|
||||
currency: "USD",
|
||||
basePrice,
|
||||
discountAmount,
|
||||
displayPrice,
|
||||
orderPrice,
|
||||
fullfilledPrice,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CustomerInfo } from "$lib/domains/passengerinfo/data/entities";
|
||||
import type { CustomerInfoModel } from "$lib/domains/customerinfo/data";
|
||||
import { CheckoutStep } from "$lib/domains/order/data/entities";
|
||||
import type { PaymentInfoPayload } from "$lib/domains/paymentinfo/data/entities";
|
||||
import { CheckoutStep } from "$lib/domains/ticket/data/entities";
|
||||
import type { Database } from "@pkg/db";
|
||||
import { and, eq } from "@pkg/db";
|
||||
import { checkoutFlowSession } from "@pkg/db/schema";
|
||||
@@ -42,7 +42,7 @@ export class CheckoutFlowRepository {
|
||||
userAgent: payload.userAgent,
|
||||
reserved: false,
|
||||
sessionOutcome: SessionOutcome.PENDING,
|
||||
ticketId: payload.ticketId,
|
||||
product: payload.productId,
|
||||
});
|
||||
|
||||
return { data: flowId };
|
||||
@@ -202,7 +202,7 @@ export class CheckoutFlowRepository {
|
||||
|
||||
async syncPersonalInfo(
|
||||
flowId: string,
|
||||
personalInfo: CustomerInfo,
|
||||
personalInfo: CustomerInfoModel,
|
||||
): Promise<Result<boolean>> {
|
||||
try {
|
||||
const existingSession = await this.db
|
||||
@@ -435,7 +435,7 @@ export class CheckoutFlowRepository {
|
||||
.where(eq(checkoutFlowSession.flowId, flowId))
|
||||
.execute();
|
||||
|
||||
// If shadow passenger info is provided, sync it
|
||||
// If shadow info is provided, sync it
|
||||
if (payload.personalInfo) {
|
||||
await this.syncPersonalInfo(flowId, payload.personalInfo);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import {
|
||||
customerInfoModel,
|
||||
type CustomerInfo,
|
||||
} from "$lib/domains/passengerinfo/data/entities";
|
||||
type CustomerInfoModel,
|
||||
} from "$lib/domains/customerinfo/data";
|
||||
import { CheckoutStep } from "$lib/domains/order/data/entities";
|
||||
import {
|
||||
paymentInfoPayloadModel,
|
||||
type PaymentInfoPayload,
|
||||
} from "$lib/domains/paymentinfo/data/entities";
|
||||
import { CheckoutStep } from "$lib/domains/ticket/data/entities";
|
||||
import { createTRPCRouter, publicProcedure } from "$lib/trpc/t";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
@@ -41,7 +41,7 @@ export const ckflowRouter = createTRPCRouter({
|
||||
return getCKUseCases().createFlow({
|
||||
domain: input.domain,
|
||||
refOIds: input.refOIds,
|
||||
ticketId: input.ticketId,
|
||||
productId: input.productId,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
initialUrl: "",
|
||||
@@ -72,7 +72,7 @@ export const ckflowRouter = createTRPCRouter({
|
||||
.mutation(async ({ input }) => {
|
||||
return getCKUseCases().syncPersonalInfo(
|
||||
input.flowId,
|
||||
input.personalInfo as CustomerInfo,
|
||||
input.personalInfo as CustomerInfoModel,
|
||||
);
|
||||
}),
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CustomerInfo } from "$lib/domains/passengerinfo/data/entities";
|
||||
import type { CustomerInfoModel } from "$lib/domains/customerinfo/data";
|
||||
import type { PaymentInfoPayload } from "$lib/domains/paymentinfo/data/entities";
|
||||
import { db } from "@pkg/db";
|
||||
import { isTimestampMoreThan1MinAgo } from "@pkg/logic/core/date.utils";
|
||||
@@ -54,7 +54,7 @@ export class CheckoutFlowUseCases {
|
||||
return this.repo.executePaymentStep(flowId, payload);
|
||||
}
|
||||
|
||||
async syncPersonalInfo(flowId: string, personalInfo: CustomerInfo) {
|
||||
async syncPersonalInfo(flowId: string, personalInfo: CustomerInfoModel) {
|
||||
return this.repo.syncPersonalInfo(flowId, personalInfo);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
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 {
|
||||
CKActionType,
|
||||
SessionOutcome,
|
||||
@@ -6,21 +9,16 @@ import {
|
||||
type PendingAction,
|
||||
type PendingActions,
|
||||
} from "$lib/domains/ckflow/data/entities";
|
||||
import {
|
||||
customerInfoModel,
|
||||
type CustomerInfo,
|
||||
} 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 type { CustomerInfoModel } from "$lib/domains/customerinfo/data";
|
||||
import { customerInfoModel } from "$lib/domains/customerinfo/data";
|
||||
import { customerInfoVM } from "$lib/domains/customerinfo/view/customerinfo.vm.svelte";
|
||||
import {
|
||||
CheckoutStep,
|
||||
type FlightPriceDetails,
|
||||
} from "$lib/domains/ticket/data/entities";
|
||||
import { flightTicketStore } from "$lib/domains/ticket/data/store";
|
||||
import { ticketCheckoutVM } from "$lib/domains/ticket/view/checkout/flight-checkout.vm.svelte";
|
||||
import { billingDetailsVM } from "$lib/domains/ticket/view/checkout/payment-info-section/billing.details.vm.svelte";
|
||||
import { paymentInfoVM } from "$lib/domains/ticket/view/checkout/payment-info-section/payment.info.vm.svelte";
|
||||
type OrderPriceDetailsModel,
|
||||
} from "$lib/domains/order/data/entities";
|
||||
import type { PaymentInfoPayload } from "$lib/domains/paymentinfo/data/entities";
|
||||
import { PaymentMethod } from "$lib/domains/paymentinfo/data/entities";
|
||||
import { productStore } from "$lib/domains/product/store";
|
||||
import { trpcApiStore } from "$lib/stores/api";
|
||||
import { ClientLogger } from "@pkg/logger/client";
|
||||
import { toast } from "svelte-sonner";
|
||||
@@ -61,7 +59,7 @@ class ActionRunner {
|
||||
}
|
||||
}
|
||||
private async completeOrder(data: any) {
|
||||
const ok = await ticketCheckoutVM.checkout();
|
||||
const ok = await checkoutVM.checkout();
|
||||
if (!ok) return;
|
||||
|
||||
const cleanupSuccess = await ckFlowVM.cleanupFlowInfo(
|
||||
@@ -76,7 +74,7 @@ class ActionRunner {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Your booking has been confirmed", {
|
||||
toast.success("Checkout completed successfully", {
|
||||
description: "Redirecting, please wait...",
|
||||
});
|
||||
|
||||
@@ -117,7 +115,7 @@ class ActionRunner {
|
||||
|
||||
await ckFlowVM.refreshFlowInfo(false);
|
||||
|
||||
ticketCheckoutVM.checkoutStep = CheckoutStep.Verification;
|
||||
checkoutVM.checkoutStep = CheckoutStep.Verification;
|
||||
toast.info("Verification required", {
|
||||
description: "Please enter the verification code sent to your device",
|
||||
});
|
||||
@@ -131,7 +129,7 @@ class ActionRunner {
|
||||
toast.error("Some information provided is not valid", {
|
||||
description: "Please double check your info & try again",
|
||||
});
|
||||
ticketCheckoutVM.checkoutStep = CheckoutStep.Initial;
|
||||
checkoutVM.checkoutStep = CheckoutStep.Initial;
|
||||
}
|
||||
|
||||
private async backToPayment(action: PendingAction) {
|
||||
@@ -155,16 +153,16 @@ class ActionRunner {
|
||||
duration: 6000,
|
||||
});
|
||||
|
||||
ticketCheckoutVM.checkoutStep = CheckoutStep.Payment;
|
||||
checkoutVM.checkoutStep = CheckoutStep.Payment;
|
||||
}
|
||||
|
||||
private async terminateSession() {
|
||||
await ckFlowVM.cleanupFlowInfo();
|
||||
ckFlowVM.reset();
|
||||
ticketCheckoutVM.reset();
|
||||
const tid = page.params.tid as any as string;
|
||||
checkoutVM.reset();
|
||||
const plid = page.params.plid 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);
|
||||
|
||||
poller: NodeJS.Timer | undefined = undefined;
|
||||
pinger: NodeJS.Timer | undefined = undefined;
|
||||
priceFetcher: NodeJS.Timer | undefined = undefined;
|
||||
_flowPoller: NodeJS.Timeout | undefined = undefined;
|
||||
_flowPinger: NodeJS.Timeout | undefined = undefined;
|
||||
|
||||
// Data synchronization control
|
||||
private personalInfoDebounceTimer: NodeJS.Timeout | null = null;
|
||||
private paymentInfoDebounceTimer: NodeJS.Timeout | null = null;
|
||||
syncInterval = 300; // 300ms debounce for syncing
|
||||
|
||||
updatedPrices = $state<FlightPriceDetails | undefined>(undefined);
|
||||
updatedPrices = $state<OrderPriceDetailsModel | undefined>(undefined);
|
||||
|
||||
constructor() {
|
||||
this.actionRunner = new ActionRunner();
|
||||
@@ -216,17 +213,10 @@ export class CKFlowViewModel {
|
||||
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({
|
||||
domain: window.location.hostname,
|
||||
refOIds,
|
||||
ticketId: ticket.id,
|
||||
refOIds: [],
|
||||
productId: get(productStore)?.id,
|
||||
});
|
||||
|
||||
if (info.error) {
|
||||
@@ -236,7 +226,7 @@ export class CKFlowViewModel {
|
||||
|
||||
if (!info.data) {
|
||||
toast.error("Error while creating checkout flow", {
|
||||
description: "Try refreshing page or search for ticket again",
|
||||
description: "Try refreshing page or contact us",
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -248,7 +238,7 @@ export class CKFlowViewModel {
|
||||
this.setupDone = true;
|
||||
}
|
||||
|
||||
debouncePersonalInfoSync(personalInfo: CustomerInfo) {
|
||||
debouncePersonalInfoSync(personalInfo: CustomerInfoModel) {
|
||||
this.clearPersonalInfoDebounce();
|
||||
this.personalInfoDebounceTimer = setTimeout(() => {
|
||||
this.syncPersonalInfo(personalInfo);
|
||||
@@ -262,9 +252,10 @@ export class CKFlowViewModel {
|
||||
this.paymentInfoDebounceTimer = setTimeout(() => {
|
||||
const paymentInfo = {
|
||||
cardDetails: paymentInfoVM.cardDetails,
|
||||
flightTicketInfoId: get(flightTicketStore).id,
|
||||
method: PaymentMethod.Card,
|
||||
};
|
||||
orderId: -1,
|
||||
productId: get(productStore)?.id,
|
||||
} as PaymentInfoPayload;
|
||||
this.syncPaymentInfo(paymentInfo);
|
||||
}, this.syncInterval);
|
||||
}
|
||||
@@ -276,12 +267,12 @@ export class CKFlowViewModel {
|
||||
);
|
||||
}
|
||||
|
||||
isPersonalInfoValid(personalInfo: CustomerInfo): boolean {
|
||||
isPersonalInfoValid(personalInfo: CustomerInfoModel): boolean {
|
||||
const parsed = customerInfoModel.safeParse(personalInfo);
|
||||
return !parsed.error && !!parsed.data;
|
||||
}
|
||||
|
||||
async syncPersonalInfo(personalInfo: CustomerInfo) {
|
||||
async syncPersonalInfo(personalInfo: CustomerInfoModel) {
|
||||
if (!this.flowId || !this.setupDone) {
|
||||
return;
|
||||
}
|
||||
@@ -342,27 +333,27 @@ export class CKFlowViewModel {
|
||||
}
|
||||
|
||||
private clearPoller() {
|
||||
if (this.poller) {
|
||||
clearInterval(this.poller);
|
||||
if (this._flowPoller) {
|
||||
clearInterval(this._flowPoller);
|
||||
}
|
||||
}
|
||||
|
||||
private clearPinger() {
|
||||
if (this.pinger) {
|
||||
clearInterval(this.pinger);
|
||||
if (this._flowPinger) {
|
||||
clearInterval(this._flowPinger);
|
||||
}
|
||||
}
|
||||
|
||||
private async startPolling() {
|
||||
this.clearPoller();
|
||||
this.poller = setInterval(() => {
|
||||
this._flowPoller = setInterval(() => {
|
||||
this.refreshFlowInfo();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
private async startPinging() {
|
||||
this.clearPinger();
|
||||
this.pinger = setInterval(() => {
|
||||
this._flowPinger = setInterval(() => {
|
||||
this.pingFlow();
|
||||
}, 30000); // Every 30 seconds
|
||||
}
|
||||
@@ -537,17 +528,19 @@ export class CKFlowViewModel {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get primary passenger's PII
|
||||
const primaryPassengerInfo =
|
||||
passengerInfoVM.passengerInfos.length > 0
|
||||
? passengerInfoVM.passengerInfos[0].passengerPii
|
||||
: undefined;
|
||||
const personalInfo = customerInfoVM.customerInfo;
|
||||
if (!personalInfo) {
|
||||
toast.error("Could not find customer info", {
|
||||
description: "Please try again later or contact support",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const out = await api.ckflow.executePrePaymentStep.mutate({
|
||||
flowId: this.flowId!,
|
||||
payload: {
|
||||
initialUrl: get(flightTicketStore).checkoutUrl,
|
||||
personalInfo: primaryPassengerInfo,
|
||||
initialUrl: "",
|
||||
personalInfo: personalInfo,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -570,9 +563,10 @@ export class CKFlowViewModel {
|
||||
|
||||
const paymentInfo = {
|
||||
cardDetails: paymentInfoVM.cardDetails,
|
||||
flightTicketInfoId: get(flightTicketStore).id,
|
||||
method: PaymentMethod.Card,
|
||||
};
|
||||
orderId: -1,
|
||||
productId: get(productStore)?.id,
|
||||
} as PaymentInfoPayload;
|
||||
|
||||
const out = await api.ckflow.executePaymentStep.mutate({
|
||||
flowId: this.flowId!,
|
||||
|
||||
78
apps/frontend/src/lib/domains/customerinfo/controller.ts
Normal file
78
apps/frontend/src/lib/domains/customerinfo/controller.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { db } from "@pkg/db";
|
||||
import { Logger } from "@pkg/logger";
|
||||
import type { Result } from "@pkg/result";
|
||||
import type {
|
||||
CreateCustomerInfoPayload,
|
||||
CustomerInfoModel,
|
||||
UpdateCustomerInfoPayload,
|
||||
} from "./data";
|
||||
import { CustomerInfoRepository } from "./repository";
|
||||
|
||||
/**
|
||||
* CustomerInfoController handles business logic for customer information operations.
|
||||
* Coordinates between the repository layer and the UI/API layer.
|
||||
*/
|
||||
export class CustomerInfoController {
|
||||
repo: CustomerInfoRepository;
|
||||
|
||||
constructor(repo: CustomerInfoRepository) {
|
||||
this.repo = repo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new customer information record
|
||||
* @param payload - Customer information data to create
|
||||
* @returns Result containing the new customer info ID or error
|
||||
*/
|
||||
async createCustomerInfo(
|
||||
payload: CreateCustomerInfoPayload,
|
||||
): Promise<Result<number>> {
|
||||
Logger.info("Creating customer info", { email: payload.email });
|
||||
return this.repo.createCustomerInfo(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves customer information by ID
|
||||
* @param id - Customer info ID to retrieve
|
||||
* @returns Result containing customer info model or error
|
||||
*/
|
||||
async getCustomerInfo(id: number): Promise<Result<CustomerInfoModel>> {
|
||||
Logger.info(`Retrieving customer info with ID: ${id}`);
|
||||
return this.repo.getCustomerInfoById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all customer information records
|
||||
* @returns Result containing array of customer info models or error
|
||||
*/
|
||||
async getAllCustomerInfo(): Promise<Result<CustomerInfoModel[]>> {
|
||||
Logger.info("Retrieving all customer info records");
|
||||
return this.repo.getAllCustomerInfo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates existing customer information
|
||||
* @param payload - Customer information data to update (must include id)
|
||||
* @returns Result containing success boolean or error
|
||||
*/
|
||||
async updateCustomerInfo(
|
||||
payload: UpdateCustomerInfoPayload,
|
||||
): Promise<Result<boolean>> {
|
||||
Logger.info(`Updating customer info with ID: ${payload.id}`);
|
||||
return this.repo.updateCustomerInfo(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes customer information by ID
|
||||
* @param id - Customer info ID to delete
|
||||
* @returns Result containing success boolean or error
|
||||
*/
|
||||
async deleteCustomerInfo(id: number): Promise<Result<boolean>> {
|
||||
Logger.info(`Deleting customer info with ID: ${id}`);
|
||||
return this.repo.deleteCustomerInfo(id);
|
||||
}
|
||||
}
|
||||
|
||||
export function getCustomerInfoController() {
|
||||
return new CustomerInfoController(new CustomerInfoRepository(db));
|
||||
}
|
||||
1
apps/frontend/src/lib/domains/customerinfo/data.ts
Normal file
1
apps/frontend/src/lib/domains/customerinfo/data.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "@pkg/logic/domains/customerinfo/data";
|
||||
1
apps/frontend/src/lib/domains/customerinfo/repository.ts
Normal file
1
apps/frontend/src/lib/domains/customerinfo/repository.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "@pkg/logic/domains/customerinfo/repository";
|
||||
@@ -0,0 +1,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>
|
||||
@@ -0,0 +1,144 @@
|
||||
import { z } from "zod";
|
||||
import type { CreateCustomerInfoPayload, CustomerInfoModel } from "../data";
|
||||
import { customerInfoModel } from "../data";
|
||||
|
||||
/**
|
||||
* CustomerInfoViewModel manages single customer information state for checkout.
|
||||
* Handles validation, API interactions, and form state for one customer per checkout.
|
||||
*/
|
||||
export class CustomerInfoViewModel {
|
||||
// State: current customer info being edited/created (single customer for checkout)
|
||||
customerInfo = $state<CustomerInfoModel | null>(null);
|
||||
|
||||
// State: validation errors for the current customer info
|
||||
errors = $state<Partial<Record<keyof CustomerInfoModel, string>>>({});
|
||||
|
||||
// Loading states
|
||||
loading = $state(false);
|
||||
formLoading = $state(false);
|
||||
|
||||
/**
|
||||
* Initializes customer info with default empty values for checkout
|
||||
*/
|
||||
initializeCustomerInfo() {
|
||||
this.customerInfo = this.getDefaultCustomerInfo() as CustomerInfoModel;
|
||||
this.errors = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates customer information data using Zod schema
|
||||
* @param info - Customer info to validate
|
||||
* @returns true if valid, false otherwise
|
||||
*/
|
||||
validateCustomerInfo(info?: Partial<CustomerInfoModel>): boolean {
|
||||
try {
|
||||
customerInfoModel.parse(info || this.customerInfo);
|
||||
this.errors = {};
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
this.errors = error.errors.reduce(
|
||||
(acc, curr) => {
|
||||
const path = curr.path[0] as keyof CustomerInfoModel;
|
||||
acc[path] = curr.message;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<keyof CustomerInfoModel, string>,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced validation for real-time form feedback
|
||||
* @param info - Customer info to validate
|
||||
* @param delay - Debounce delay in milliseconds (default: 500)
|
||||
*/
|
||||
private validationTimeout: NodeJS.Timeout | undefined;
|
||||
|
||||
debounceValidate(info: Partial<CustomerInfoModel>, delay = 500) {
|
||||
if (this.validationTimeout) {
|
||||
clearTimeout(this.validationTimeout);
|
||||
}
|
||||
this.validationTimeout = setTimeout(() => {
|
||||
this.validateCustomerInfo(info);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current customer info has validation errors
|
||||
* @returns true if there are no errors, false otherwise
|
||||
*/
|
||||
isValid(): boolean {
|
||||
return Object.keys(this.errors).length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if customer info is filled out (has required fields)
|
||||
* @returns true if customer info exists and has data, false otherwise
|
||||
*/
|
||||
isFilled(): boolean {
|
||||
if (!this.customerInfo) return false;
|
||||
|
||||
return (
|
||||
this.customerInfo.firstName.length > 0 &&
|
||||
this.customerInfo.lastName.length > 0 &&
|
||||
this.customerInfo.email.length > 0 &&
|
||||
this.customerInfo.phoneNumber.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the current customer info (for editing existing records)
|
||||
* @param customerInfo - Customer info to edit
|
||||
*/
|
||||
setCustomerInfo(customerInfo: CustomerInfoModel) {
|
||||
this.customerInfo = { ...customerInfo };
|
||||
this.errors = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the customer info and errors
|
||||
*/
|
||||
resetCustomerInfo() {
|
||||
this.customerInfo = null;
|
||||
this.errors = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a default/empty customer info object for forms
|
||||
* @returns Default customer info payload
|
||||
*/
|
||||
getDefaultCustomerInfo(): CreateCustomerInfoPayload {
|
||||
return {
|
||||
firstName: "",
|
||||
middleName: "",
|
||||
lastName: "",
|
||||
email: "",
|
||||
phoneCountryCode: "",
|
||||
phoneNumber: "",
|
||||
country: "",
|
||||
state: "",
|
||||
city: "",
|
||||
zipCode: "",
|
||||
address: "",
|
||||
address2: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets all state (for cleanup or re-initialization)
|
||||
*/
|
||||
reset() {
|
||||
this.customerInfo = null;
|
||||
this.errors = {};
|
||||
this.loading = false;
|
||||
this.formLoading = false;
|
||||
if (this.validationTimeout) {
|
||||
clearTimeout(this.validationTimeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const customerInfoVM = new CustomerInfoViewModel();
|
||||
@@ -1,14 +1,14 @@
|
||||
import { ERROR_CODES, type Result } from "$lib/core/data.types";
|
||||
import { 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 { nanoid } from "nanoid";
|
||||
import {
|
||||
fullOrderModel,
|
||||
limitedOrderWithTicketInfoModel,
|
||||
limitedOrderWithProductModel,
|
||||
OrderStatus,
|
||||
type FullOrderModel,
|
||||
type LimitedOrderWithTicketInfoModel,
|
||||
type LimitedOrderWithProductModel,
|
||||
type NewOrderModel,
|
||||
} from "./entities";
|
||||
|
||||
@@ -19,10 +19,10 @@ export class OrderRepository {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
async listActiveOrders(): Promise<Result<LimitedOrderWithTicketInfoModel[]>> {
|
||||
async listActiveOrders(): Promise<Result<LimitedOrderWithProductModel[]>> {
|
||||
const conditions = [
|
||||
or(
|
||||
eq(order.status, OrderStatus.PENDING_FULLFILLMENT),
|
||||
eq(order.status, OrderStatus.PENDING_FULFILLMENT),
|
||||
eq(order.status, OrderStatus.PARTIALLY_FULFILLED),
|
||||
),
|
||||
isNotNull(order.agentId),
|
||||
@@ -34,29 +34,29 @@ export class OrderRepository {
|
||||
displayPrice: true,
|
||||
basePrice: true,
|
||||
discountAmount: true,
|
||||
pricePerPassenger: true,
|
||||
orderPrice: true,
|
||||
fullfilledPrice: true,
|
||||
status: true,
|
||||
},
|
||||
with: {
|
||||
flightTicketInfo: {
|
||||
product: true,
|
||||
customerInfo: {
|
||||
columns: {
|
||||
id: true,
|
||||
departure: true,
|
||||
arrival: true,
|
||||
departureDate: true,
|
||||
returnDate: true,
|
||||
flightType: true,
|
||||
passengerCounts: true,
|
||||
firstName: true,
|
||||
middleName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
country: true,
|
||||
state: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const out = [] as LimitedOrderWithTicketInfoModel[];
|
||||
const out = [] as LimitedOrderWithProductModel[];
|
||||
|
||||
for (const order of qRes) {
|
||||
const parsed = limitedOrderWithTicketInfoModel.safeParse({
|
||||
const parsed = limitedOrderWithProductModel.safeParse({
|
||||
...order,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
@@ -78,27 +78,19 @@ export class OrderRepository {
|
||||
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({
|
||||
where: eq(order.pnr, pnr),
|
||||
with: { flightTicketInfo: true },
|
||||
where: eq(order.uid, uid),
|
||||
with: { customerInfo: true, product: true },
|
||||
});
|
||||
if (!out) return {};
|
||||
const relatedPassengerInfos = await this.db.query.passengerInfo.findMany({
|
||||
where: eq(passengerInfo.orderId, out.id),
|
||||
with: { passengerPii: true },
|
||||
});
|
||||
const parsed = fullOrderModel.safeParse({
|
||||
...out,
|
||||
emailAccount: undefined,
|
||||
passengerInfos: relatedPassengerInfos,
|
||||
});
|
||||
const parsed = fullOrderModel.safeParse({ ...out });
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
error: getError(
|
||||
{
|
||||
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",
|
||||
detail: "An error occured while parsing order",
|
||||
},
|
||||
@@ -111,21 +103,25 @@ export class OrderRepository {
|
||||
|
||||
async createOrder(
|
||||
payload: NewOrderModel,
|
||||
): Promise<Result<{ id: number; pnr: string }>> {
|
||||
const pnr = nanoid(9).toUpperCase();
|
||||
): Promise<Result<{ id: number; uid: string }>> {
|
||||
const uid = nanoid(12).toUpperCase();
|
||||
try {
|
||||
const out = await this.db
|
||||
.insert(order)
|
||||
.values({
|
||||
uid,
|
||||
displayPrice: payload.displayPrice.toFixed(3),
|
||||
basePrice: payload.basePrice.toFixed(3),
|
||||
discountAmount: payload.discountAmount.toFixed(3),
|
||||
fullfilledPrice: payload.fullfilledPrice.toFixed(3),
|
||||
orderPrice: payload.orderPrice.toFixed(3),
|
||||
|
||||
flightTicketInfoId: payload.flightTicketInfoId,
|
||||
paymentInfoId: payload.paymentInfoId,
|
||||
|
||||
status: OrderStatus.PENDING_FULLFILLMENT,
|
||||
pnr,
|
||||
status: OrderStatus.PENDING_FULFILLMENT,
|
||||
|
||||
customerInfoId: payload.customerInfoId,
|
||||
productId: payload.productId,
|
||||
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -133,7 +129,7 @@ export class OrderRepository {
|
||||
.returning({ id: order.id })
|
||||
.execute();
|
||||
|
||||
return { data: { id: out[0]?.id, pnr } };
|
||||
return { data: { id: out[0]?.id, uid } };
|
||||
} catch (e) {
|
||||
return {
|
||||
error: getError(
|
||||
|
||||
@@ -16,8 +16,8 @@ export class OrderController {
|
||||
return this.repo.createOrder(payload);
|
||||
}
|
||||
|
||||
async getOrderByPNR(pnr: string) {
|
||||
return this.repo.getOrderByPNR(pnr);
|
||||
async getOrderByUID(uid: string) {
|
||||
return this.repo.getOrderByUID(uid);
|
||||
}
|
||||
|
||||
async markOrdersAsFulfilled(oids: number[]) {
|
||||
|
||||
@@ -1,78 +1,117 @@
|
||||
import { SessionOutcome } from "$lib/domains/ckflow/data/entities";
|
||||
import { getCKUseCases } from "$lib/domains/ckflow/domain/usecases";
|
||||
import { EmailerUseCases } from "$lib/domains/email/domain/usecases";
|
||||
import { createOrderPayloadModel } from "$lib/domains/order/data/entities";
|
||||
import { PassengerInfoRepository } from "$lib/domains/passengerinfo/data/repository";
|
||||
import { PassengerInfoController } from "$lib/domains/passengerinfo/domain/controller";
|
||||
import { getCustomerInfoController } from "$lib/domains/customerinfo/controller";
|
||||
import {
|
||||
createOrderPayloadModel,
|
||||
OrderCreationStep,
|
||||
} from "$lib/domains/order/data/entities";
|
||||
import { PaymentInfoRepository } from "$lib/domains/paymentinfo/data/repository";
|
||||
import { PaymentInfoUseCases } from "$lib/domains/paymentinfo/domain/usecases";
|
||||
import { CheckoutStep } from "$lib/domains/ticket/data/entities";
|
||||
import { getTC } from "$lib/domains/ticket/domain/controller";
|
||||
import { getProductUseCases } from "$lib/domains/product/usecases";
|
||||
import { createTRPCRouter, publicProcedure } from "$lib/trpc/t";
|
||||
import { db } from "@pkg/db";
|
||||
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 { OrderRepository } from "../data/repository";
|
||||
import { OrderController } from "./controller";
|
||||
|
||||
export const orderRouter = createTRPCRouter({
|
||||
/**
|
||||
* Creates a new order for product checkout
|
||||
* Handles customer info creation, payment info creation, and order creation
|
||||
*/
|
||||
createOrder: publicProcedure
|
||||
.input(createOrderPayloadModel)
|
||||
.mutation(async ({ input }) => {
|
||||
const pduc = new PaymentInfoUseCases(new PaymentInfoRepository(db));
|
||||
const oc = new OrderController(new OrderRepository(db));
|
||||
const pc = new PassengerInfoController(
|
||||
new PassengerInfoRepository(db),
|
||||
);
|
||||
const tc = getTC();
|
||||
const emailUC = new EmailerUseCases();
|
||||
const piuc = new PaymentInfoUseCases(new PaymentInfoRepository(db));
|
||||
const orderController = new OrderController(new OrderRepository(db));
|
||||
const customerInfoController = getCustomerInfoController();
|
||||
const productUC = getProductUseCases();
|
||||
|
||||
const ftRes = await tc.uncacheAndSaveTicket(input.flightTicketId!);
|
||||
if (ftRes.error || !ftRes.data) {
|
||||
return { error: ftRes.error };
|
||||
}
|
||||
|
||||
if (!input.flightTicketId || !input.paymentInfo) {
|
||||
// Validate required inputs
|
||||
if (!input.productId || !input.customerInfo) {
|
||||
return {
|
||||
error: getError({
|
||||
code: ERROR_CODES.INPUT_ERROR,
|
||||
message: "Received invalid input",
|
||||
detail: "The entered data is incomplete or invalid",
|
||||
userHint: "Enter valid order data to complete the order",
|
||||
message: "Missing required order information",
|
||||
detail: "Product ID and customer information are required",
|
||||
userHint:
|
||||
"Please ensure product and customer information are provided",
|
||||
}),
|
||||
};
|
||||
}
|
||||
const pdRes = await pduc.createPaymentInfo(input.paymentInfo!);
|
||||
if (pdRes.error || !pdRes.data) {
|
||||
return { error: pdRes.error };
|
||||
} as Result<string>;
|
||||
}
|
||||
|
||||
Logger.info(`Setting flight ticket info id ${ftRes.data}`);
|
||||
input.orderModel.flightTicketInfoId = ftRes.data;
|
||||
|
||||
Logger.info(`Setting payment details id ${pdRes.data}`);
|
||||
input.orderModel.paymentInfoId = pdRes.data;
|
||||
|
||||
Logger.info("Creating order");
|
||||
const out = await oc.createOrder(input.orderModel);
|
||||
if (out.error || !out.data) {
|
||||
await pduc.deletePaymentInfo(pdRes.data!);
|
||||
return { error: out.error };
|
||||
// Verify product exists
|
||||
Logger.info(`Verifying product with ID: ${input.productId}`);
|
||||
const productRes = await productUC.getProductById(input.productId);
|
||||
if (productRes.error || !productRes.data) {
|
||||
return {
|
||||
error: getError({
|
||||
code: ERROR_CODES.NOT_FOUND_ERROR,
|
||||
message: "Product not found",
|
||||
detail: `Product with ID ${input.productId} does not exist`,
|
||||
userHint: "Please select a valid product",
|
||||
}),
|
||||
} as Result<string>;
|
||||
}
|
||||
|
||||
Logger.info(`Creating passenger infos with oid: ${out.data}`);
|
||||
const pOut = await pc.createPassengerInfos(
|
||||
input.passengerInfos,
|
||||
out.data.id,
|
||||
ftRes.data!,
|
||||
pdRes.data,
|
||||
// Create customer information
|
||||
Logger.info("Creating customer information");
|
||||
const customerRes = await customerInfoController.createCustomerInfo(
|
||||
input.customerInfo,
|
||||
);
|
||||
if (pOut.error) {
|
||||
await oc.deleteOrder(out.data.id);
|
||||
return { error: pOut.error };
|
||||
if (customerRes.error || !customerRes.data) {
|
||||
return { error: customerRes.error } as Result<string>;
|
||||
}
|
||||
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) {
|
||||
Logger.info(
|
||||
`Updating checkout flow state for flow ${input.flowId}`,
|
||||
@@ -80,7 +119,7 @@ export const orderRouter = createTRPCRouter({
|
||||
try {
|
||||
await getCKUseCases().cleanupFlow(input.flowId, {
|
||||
sessionOutcome: SessionOutcome.COMPLETED,
|
||||
checkoutStep: CheckoutStep.Complete,
|
||||
checkoutStep: OrderCreationStep.SUMMARY,
|
||||
});
|
||||
Logger.info(
|
||||
`Checkout flow ${input.flowId} marked as completed`,
|
||||
@@ -91,94 +130,29 @@ export const orderRouter = createTRPCRouter({
|
||||
);
|
||||
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);
|
||||
const out = await productUC.refreshProductLinkId(input.productId);
|
||||
|
||||
if (!orderDetails.data) {
|
||||
return out;
|
||||
}
|
||||
const order = orderDetails.data;
|
||||
const ticketInfo = order.flightTicketInfo;
|
||||
const primaryPassenger = order.passengerInfos[0]?.passengerPii!;
|
||||
Logger.debug(
|
||||
`Product link id rotated: ${JSON.stringify(out, null, 2)}`,
|
||||
);
|
||||
|
||||
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 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.error(
|
||||
`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");
|
||||
return out;
|
||||
Logger.info("Order creation completed successfully");
|
||||
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 }) => {
|
||||
const oc = new OrderController(new OrderRepository(db));
|
||||
return oc.getOrderByPNR(input.pnr);
|
||||
const orderController = new OrderController(new OrderRepository(db));
|
||||
return orderController.getOrderByUID(input.uid);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
@@ -1,13 +1,9 @@
|
||||
<script lang="ts">
|
||||
import Title from "$lib/components/atoms/title.svelte";
|
||||
import Icon from "$lib/components/atoms/icon.svelte";
|
||||
import EmailIcon from "~icons/solar/letter-broken";
|
||||
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 Title from "$lib/components/atoms/title.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();
|
||||
|
||||
@@ -16,37 +12,7 @@
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
{#if order.emailAccount}
|
||||
<!-- 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>
|
||||
<span>TODO: SHOW PRODUCT DETSIL INFO HERE</span>
|
||||
|
||||
<div class={cardStyle}>
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { FullOrderModel } from "@pkg/logic/domains/order/data/entities";
|
||||
import { trpcApiStore } from "$lib/stores/api";
|
||||
import { get } from "svelte/store";
|
||||
import type { FullOrderModel } from "@pkg/logic/domains/order/data/entities";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
export class TrackViewModel {
|
||||
pnr = $state("");
|
||||
uid = $state("");
|
||||
loading = $state(false);
|
||||
bookingData = $state<FullOrderModel | undefined>(undefined);
|
||||
error = $state<string | null>(null);
|
||||
|
||||
async searchBooking() {
|
||||
if (!this.pnr) {
|
||||
if (!this.uid) {
|
||||
this.error = "Please enter a PNR number";
|
||||
return;
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export class TrackViewModel {
|
||||
this.loading = true;
|
||||
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) {
|
||||
this.error = result.error.message;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "@pkg/logic/domains/passengerinfo/data/entities";
|
||||
@@ -1,209 +0,0 @@
|
||||
import { eq, inArray, type Database } from "@pkg/db";
|
||||
import { passengerInfo, passengerPII } from "@pkg/db/schema";
|
||||
import { getError, Logger } from "@pkg/logger";
|
||||
import { ERROR_CODES, type Result } from "@pkg/result";
|
||||
import {
|
||||
passengerInfoModel,
|
||||
type CustomerInfo,
|
||||
type PassengerInfo,
|
||||
} from "./entities";
|
||||
|
||||
export class PassengerInfoRepository {
|
||||
private db: Database;
|
||||
|
||||
constructor(db: Database) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
async createPassengerPii(payload: CustomerInfo): Promise<Result<number>> {
|
||||
try {
|
||||
const out = await this.db
|
||||
.insert(passengerPII)
|
||||
.values({
|
||||
firstName: payload.firstName,
|
||||
middleName: payload.middleName,
|
||||
lastName: payload.lastName,
|
||||
email: payload.email,
|
||||
phoneCountryCode: payload.phoneCountryCode,
|
||||
phoneNumber: payload.phoneNumber,
|
||||
nationality: payload.nationality,
|
||||
gender: payload.gender,
|
||||
dob: payload.dob,
|
||||
passportNo: payload.passportNo,
|
||||
passportExpiry: payload.passportExpiry,
|
||||
|
||||
country: payload.country,
|
||||
state: payload.state,
|
||||
city: payload.city,
|
||||
address: payload.address,
|
||||
zipCode: payload.zipCode,
|
||||
address2: payload.address2,
|
||||
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning({ id: passengerInfo.id })
|
||||
.execute();
|
||||
|
||||
if (!out || out.length === 0) {
|
||||
Logger.error("Failed to create passenger info");
|
||||
Logger.debug(out);
|
||||
Logger.debug(payload);
|
||||
return {
|
||||
error: getError({
|
||||
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
|
||||
message: "Failed to create passenger info",
|
||||
userHint: "Please try again",
|
||||
detail: "Failed to create passenger info",
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { data: out[0].id };
|
||||
} catch (e) {
|
||||
return {
|
||||
error: getError(
|
||||
{
|
||||
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
|
||||
message: "An error occured while creating passenger info",
|
||||
userHint: "Please try again later",
|
||||
actionable: false,
|
||||
detail: "An error occured while creating passenger info",
|
||||
},
|
||||
e,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async createPassengerInfo(payload: PassengerInfo): Promise<Result<number>> {
|
||||
try {
|
||||
const out = await this.db
|
||||
.insert(passengerInfo)
|
||||
.values({
|
||||
passengerType: payload.passengerType,
|
||||
passengerPiiId: payload.passengerPiiId,
|
||||
paymentInfoId: payload.paymentInfoId,
|
||||
seatSelection: payload.seatSelection,
|
||||
bagSelection: payload.bagSelection,
|
||||
agentsInfo: payload.agentsInfo,
|
||||
|
||||
flightTicketInfoId: payload.flightTicketInfoId,
|
||||
orderId: payload.orderId,
|
||||
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning({ id: passengerInfo.id })
|
||||
.execute();
|
||||
|
||||
if (!out || out.length === 0) {
|
||||
Logger.error("Failed to create passenger info");
|
||||
Logger.debug(out);
|
||||
Logger.debug(payload);
|
||||
return {
|
||||
error: getError({
|
||||
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
|
||||
message: "Failed to create passenger info",
|
||||
userHint: "Please try again",
|
||||
detail: "Failed to create passenger info",
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { data: out[0].id };
|
||||
} catch (e) {
|
||||
return {
|
||||
error: getError(
|
||||
{
|
||||
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
|
||||
message: "An error occured while creating passenger info",
|
||||
userHint: "Please try again later",
|
||||
actionable: false,
|
||||
detail: "An error occured while creating passenger info",
|
||||
},
|
||||
e,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async getPassengerInfo(id: number): Promise<Result<PassengerInfo>> {
|
||||
try {
|
||||
const out = await this.db.query.passengerInfo.findFirst({
|
||||
where: eq(passengerInfo.id, id),
|
||||
with: { passengerPii: true },
|
||||
});
|
||||
if (!out) {
|
||||
Logger.error("Failed to get passenger info");
|
||||
Logger.debug(out);
|
||||
return {
|
||||
error: getError({
|
||||
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
|
||||
message: "Failed to get passenger info",
|
||||
userHint: "Please try again",
|
||||
detail: "Failed to get passenger info",
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { data: out as any as PassengerInfo };
|
||||
} catch (e) {
|
||||
return {
|
||||
error: getError(
|
||||
{
|
||||
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
|
||||
message: "An error occured while getting passenger info",
|
||||
userHint: "Please try again later",
|
||||
actionable: false,
|
||||
detail: "An error occured while getting passenger info",
|
||||
},
|
||||
e,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async getPassengerInfosByRefOId(
|
||||
refOIds: number[],
|
||||
): Promise<Result<PassengerInfo[]>> {
|
||||
try {
|
||||
const out = await this.db.query.passengerInfo.findMany({
|
||||
where: inArray(passengerInfo.orderId, refOIds),
|
||||
with: { passengerPii: true },
|
||||
});
|
||||
const res = [] as PassengerInfo[];
|
||||
for (const each of out) {
|
||||
const parsed = passengerInfoModel.safeParse(each);
|
||||
if (!parsed.success) {
|
||||
Logger.warn(`Error while parsing passenger info`);
|
||||
Logger.debug(parsed.error?.errors);
|
||||
continue;
|
||||
}
|
||||
res.push(parsed.data);
|
||||
}
|
||||
Logger.info(`Returning ${res.length} passenger info by ref OID`);
|
||||
return { data: res };
|
||||
} catch (e) {
|
||||
return {
|
||||
error: getError(
|
||||
{
|
||||
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
|
||||
message: "An error occured while getting passenger info",
|
||||
userHint: "Please try again later",
|
||||
actionable: false,
|
||||
detail: "An error occured while getting passenger info",
|
||||
},
|
||||
e,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAll(ids: number[]): Promise<Result<number>> {
|
||||
Logger.info(`Deleting ${ids.length} passenger info`);
|
||||
const out = await this.db
|
||||
.delete(passengerInfo)
|
||||
.where(inArray(passengerInfo.id, ids));
|
||||
Logger.debug(out);
|
||||
Logger.info(`Deleted ${out.count} passenger info`);
|
||||
return { data: out.count };
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Logger } from "@pkg/logger";
|
||||
import type { Result } from "@pkg/result";
|
||||
import type { PassengerInfo } from "../data/entities";
|
||||
import type { PassengerInfoRepository } from "../data/repository";
|
||||
|
||||
export class PassengerInfoController {
|
||||
repo: PassengerInfoRepository;
|
||||
|
||||
constructor(repo: PassengerInfoRepository) {
|
||||
this.repo = repo;
|
||||
}
|
||||
|
||||
async createPassengerInfos(
|
||||
payload: PassengerInfo[],
|
||||
orderId: number,
|
||||
flightTicketInfoId?: number,
|
||||
paymentInfoId?: number,
|
||||
): Promise<Result<number>> {
|
||||
const made = [] as number[];
|
||||
for (const passengerInfo of payload) {
|
||||
const piiOut = await this.repo.createPassengerPii(
|
||||
passengerInfo.passengerPii,
|
||||
);
|
||||
if (piiOut.error || !piiOut.data) {
|
||||
await this.repo.deleteAll(made);
|
||||
return piiOut;
|
||||
}
|
||||
passengerInfo.passengerPiiId = piiOut.data;
|
||||
passengerInfo.paymentInfoId = paymentInfoId;
|
||||
passengerInfo.flightTicketInfoId = flightTicketInfoId;
|
||||
passengerInfo.orderId = orderId;
|
||||
passengerInfo.agentId = undefined;
|
||||
const out = await this.repo.createPassengerInfo(passengerInfo);
|
||||
if (out.error) {
|
||||
await this.repo.deleteAll(made);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
return { data: made.length };
|
||||
}
|
||||
|
||||
async getPassengerInfo(id: number): Promise<Result<PassengerInfo>> {
|
||||
return this.repo.getPassengerInfo(id);
|
||||
}
|
||||
|
||||
async getPassengerInfosByRefOIds(
|
||||
refOIds: number[],
|
||||
): Promise<Result<PassengerInfo[]>> {
|
||||
Logger.info(`Querying/Returning Passenger infos for ${refOIds}`);
|
||||
return this.repo.getPassengerInfosByRefOId(refOIds);
|
||||
}
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils";
|
||||
import type { BagSelectionInfo } from "$lib/domains/ticket/data/entities/create.entities";
|
||||
import Icon from "$lib/components/atoms/icon.svelte";
|
||||
import BagIcon from "~icons/lucide/briefcase";
|
||||
import BackpackIcon from "~icons/solar/backpack-linear";
|
||||
import SuitcaseIcon from "~icons/bi/suitcase2";
|
||||
import CheckIcon from "~icons/solar/check-read-linear";
|
||||
import { flightTicketStore } from "$lib/domains/ticket/data/store";
|
||||
import { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte";
|
||||
|
||||
let { info = $bindable() }: { info: BagSelectionInfo } = $props();
|
||||
|
||||
// Average baseline price for checked bags when not specified
|
||||
const BASELINE_CHECKED_BAG_PRICE = 30;
|
||||
|
||||
const getBagDetails = (type: string) => {
|
||||
const bagsInfo = $flightTicketStore?.bagsInfo;
|
||||
if (!bagsInfo) return null;
|
||||
|
||||
switch (type) {
|
||||
case "personalBag":
|
||||
return bagsInfo.details.personalBags;
|
||||
case "handBag":
|
||||
return bagsInfo.details.handBags;
|
||||
case "checkedBag":
|
||||
return bagsInfo.details.checkedBags;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDimensions = (details: any) => {
|
||||
if (!details?.dimensions) return "";
|
||||
const { length, width, height } = details.dimensions;
|
||||
if (!length || !width || !height) return "";
|
||||
return `${length} x ${width} x ${height} cm`;
|
||||
};
|
||||
|
||||
const bagTypes = [
|
||||
{
|
||||
icon: BackpackIcon,
|
||||
type: "personalBag",
|
||||
label: "Personal Bag",
|
||||
description: "Small item (purse, laptop bag)",
|
||||
included: true,
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
icon: SuitcaseIcon,
|
||||
type: "handBag",
|
||||
label: "Cabin Bag",
|
||||
description: "Carry-on luggage",
|
||||
included: false,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
icon: BagIcon,
|
||||
type: "checkedBag",
|
||||
label: "Checked Bag",
|
||||
description: "Check-in luggage",
|
||||
included: false,
|
||||
disabled: false, // We'll always show this option
|
||||
},
|
||||
];
|
||||
|
||||
function toggleBag(bagType: string) {
|
||||
if (bagType === "handBag") {
|
||||
info.handBags = info.handBags === 1 ? 0 : 1;
|
||||
} else if (
|
||||
bagType === "checkedBag" &&
|
||||
$flightTicketStore?.bagsInfo.hasCheckedBagsSupport
|
||||
) {
|
||||
info.checkedBags = info.checkedBags === 1 ? 0 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
function isBagSelected(bagType: string): boolean {
|
||||
if (bagType === "personalBag") return true;
|
||||
if (bagType === "handBag") return info.handBags === 1;
|
||||
if (bagType === "checkedBag")
|
||||
return (
|
||||
info.checkedBags === 1 &&
|
||||
$flightTicketStore?.bagsInfo.hasCheckedBagsSupport
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
function getBagPrice(bagType: string): {
|
||||
price: number;
|
||||
isEstimate: boolean;
|
||||
} {
|
||||
const bagDetails = getBagDetails(bagType);
|
||||
|
||||
if (bagType === "checkedBag") {
|
||||
if (bagDetails && typeof bagDetails.price === "number") {
|
||||
return { price: bagDetails.price, isEstimate: false };
|
||||
}
|
||||
return { price: BASELINE_CHECKED_BAG_PRICE, isEstimate: true };
|
||||
}
|
||||
|
||||
return {
|
||||
price: bagDetails?.price ?? 0,
|
||||
isEstimate: false,
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
{#each bagTypes as bag}
|
||||
{@const bagDetails = getBagDetails(bag.type)}
|
||||
{@const isAvailable = true}
|
||||
{@const isDisabled = false}
|
||||
<!-- {@const isAvailable =
|
||||
bag.type !== "checkedBag" ||
|
||||
$flightTicketStore?.bagsInfo.hasCheckedBagsSupport}
|
||||
{@const isDisabled =
|
||||
bag.disabled || (bag.type === "checkedBag" && !isAvailable)} -->
|
||||
<!-- {@const { price, isEstimate } = getBagPrice(bag.type)} -->
|
||||
<!-- {@const formattedPrice = convertAndFormatCurrency(price)} -->
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
"relative flex flex-col items-start gap-4 rounded-lg border-2 p-4 transition-all sm:flex-row sm:items-center",
|
||||
isBagSelected(bag.type)
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-gray-200",
|
||||
!isDisabled &&
|
||||
!bag.included &&
|
||||
"cursor-pointer hover:border-primary/50",
|
||||
isDisabled && "opacity-70",
|
||||
)}
|
||||
role={isDisabled || bag.included ? "presentation" : "button"}
|
||||
onclick={() => !isDisabled && !bag.included && toggleBag(bag.type)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
!isDisabled && !bag.included && toggleBag(bag.type);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="shrink-0">
|
||||
<div
|
||||
class={cn(
|
||||
"grid h-10 w-10 place-items-center rounded-full sm:h-12 sm:w-12",
|
||||
isBagSelected(bag.type)
|
||||
? "bg-primary text-white"
|
||||
: "bg-gray-100 text-gray-500",
|
||||
)}
|
||||
>
|
||||
<Icon icon={bag.icon} cls="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{bag.label}</span>
|
||||
{#if bag.included}
|
||||
<span
|
||||
class="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
|
||||
>
|
||||
Included
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if bag.type === "checkedBag" && !isAvailable}
|
||||
<span
|
||||
class="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800"
|
||||
>
|
||||
Not available for this flight
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-sm text-gray-500">{bag.description}</span>
|
||||
{#if bagDetails}
|
||||
<div
|
||||
class="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500"
|
||||
>
|
||||
{#if bagDetails.weight > 0}
|
||||
<span>
|
||||
Up to {bagDetails.weight}{bagDetails.unit}
|
||||
</span>
|
||||
{/if}
|
||||
{#if formatDimensions(bagDetails)}
|
||||
<span>{formatDimensions(bagDetails)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="ml-auto flex items-center">
|
||||
<!-- {#if price > 0}
|
||||
<div class="flex flex-col items-end">
|
||||
<span class="font-medium text-primary"
|
||||
>+{formattedPrice}</span
|
||||
>
|
||||
{#if isEstimate}
|
||||
<span class="text-xs text-gray-500"
|
||||
>Estimated price</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if !bag.included} -->
|
||||
<span class="text-xs font-medium text-emerald-800">Free</span>
|
||||
<!-- {/if} -->
|
||||
</div>
|
||||
|
||||
{#if isBagSelected(bag.type)}
|
||||
<div class="absolute right-4 top-4">
|
||||
<Icon icon={CheckIcon} cls="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -1,323 +0,0 @@
|
||||
<script lang="ts">
|
||||
import LabelWrapper from "$lib/components/atoms/label-wrapper.svelte";
|
||||
import Title from "$lib/components/atoms/title.svelte";
|
||||
import Input from "$lib/components/ui/input/input.svelte";
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
import { COUNTRIES_SELECT } from "$lib/core/countries";
|
||||
import type { SelectOption } from "$lib/core/data.types";
|
||||
import { capitalize } from "$lib/core/string.utils";
|
||||
import type { CustomerInfo } from "$lib/domains/ticket/data/entities/create.entities";
|
||||
import { Gender } from "$lib/domains/ticket/data/entities/index";
|
||||
import { PHONE_COUNTRY_CODES } from "@pkg/logic/core/data/phonecc";
|
||||
import { passengerInfoVM } from "./passenger.info.vm.svelte";
|
||||
|
||||
let { info = $bindable(), idx }: { info: CustomerInfo; idx: number } =
|
||||
$props();
|
||||
|
||||
const genderOpts = [
|
||||
{ label: capitalize(Gender.Male), value: Gender.Male },
|
||||
{ label: capitalize(Gender.Female), value: Gender.Female },
|
||||
{ label: capitalize(Gender.Other), value: Gender.Other },
|
||||
] as SelectOption[];
|
||||
|
||||
function onSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
passengerInfoVM.validatePII(info, idx);
|
||||
}
|
||||
|
||||
let validationTimeout = $state(undefined as undefined | NodeJS.Timer);
|
||||
|
||||
function debounceValidate() {
|
||||
if (validationTimeout) {
|
||||
clearTimeout(validationTimeout);
|
||||
}
|
||||
validationTimeout = setTimeout(() => {
|
||||
passengerInfoVM.validatePII(info, idx);
|
||||
}, 500);
|
||||
}
|
||||
</script>
|
||||
|
||||
<form action="#" class="flex w-full flex-col gap-4" onsubmit={onSubmit}>
|
||||
<div class="flex flex-col gap-4 md:flex-row">
|
||||
<LabelWrapper
|
||||
label="First Name"
|
||||
error={passengerInfoVM.piiErrors[idx].firstName}
|
||||
>
|
||||
<Input
|
||||
placeholder="First Name"
|
||||
bind:value={info.firstName}
|
||||
required
|
||||
oninput={() => debounceValidate()}
|
||||
/>
|
||||
</LabelWrapper>
|
||||
|
||||
<LabelWrapper
|
||||
label="Middle Name"
|
||||
error={passengerInfoVM.piiErrors[idx].middleName}
|
||||
>
|
||||
<Input
|
||||
placeholder="Middle Name"
|
||||
bind:value={info.middleName}
|
||||
oninput={() => debounceValidate()}
|
||||
required
|
||||
/>
|
||||
</LabelWrapper>
|
||||
|
||||
<LabelWrapper
|
||||
label="Last Name"
|
||||
error={passengerInfoVM.piiErrors[idx].lastName}
|
||||
>
|
||||
<Input
|
||||
placeholder="Last Name"
|
||||
bind:value={info.lastName}
|
||||
required
|
||||
oninput={() => debounceValidate()}
|
||||
/>
|
||||
</LabelWrapper>
|
||||
</div>
|
||||
|
||||
<LabelWrapper label="Email" error={passengerInfoVM.piiErrors[idx].email}>
|
||||
<Input
|
||||
placeholder="Email"
|
||||
bind:value={info.email}
|
||||
type="email"
|
||||
oninput={() => debounceValidate()}
|
||||
required
|
||||
/>
|
||||
</LabelWrapper>
|
||||
|
||||
<div class="flex flex-col gap-4 md:flex-row lg:flex-col xl:flex-row">
|
||||
<LabelWrapper
|
||||
label="Phone Number"
|
||||
error={passengerInfoVM.piiErrors[idx].phoneNumber}
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<Select.Root
|
||||
type="single"
|
||||
required
|
||||
onValueChange={(code) => {
|
||||
info.phoneCountryCode = code;
|
||||
}}
|
||||
name="phoneCode"
|
||||
>
|
||||
<Select.Trigger class="w-20">
|
||||
{#if info.phoneCountryCode}
|
||||
{info.phoneCountryCode}
|
||||
{:else}
|
||||
Select
|
||||
{/if}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each PHONE_COUNTRY_CODES as { country, phoneCode }}
|
||||
<Select.Item value={phoneCode}>
|
||||
<span class="flex items-center gap-2">
|
||||
{phoneCode} ({country})
|
||||
</span>
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
|
||||
<Input
|
||||
placeholder="Phone Number"
|
||||
type="tel"
|
||||
bind:value={info.phoneNumber}
|
||||
required
|
||||
oninput={() => debounceValidate()}
|
||||
class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</LabelWrapper>
|
||||
|
||||
<LabelWrapper
|
||||
label="Passport Expiry"
|
||||
error={passengerInfoVM.piiErrors[idx].passportExpiry}
|
||||
>
|
||||
<Input
|
||||
placeholder="Passport Expiry"
|
||||
value={info.passportExpiry}
|
||||
type="date"
|
||||
required
|
||||
oninput={(v) => {
|
||||
// @ts-ignore
|
||||
info.passportExpiry = v.target.value;
|
||||
debounceValidate();
|
||||
}}
|
||||
/>
|
||||
</LabelWrapper>
|
||||
<LabelWrapper
|
||||
label="Passport/ID No"
|
||||
error={passengerInfoVM.piiErrors[idx].passportNo}
|
||||
>
|
||||
<Input
|
||||
placeholder="Passport or ID card no."
|
||||
bind:value={info.passportNo}
|
||||
minlength={1}
|
||||
maxlength={20}
|
||||
required
|
||||
oninput={() => debounceValidate()}
|
||||
/>
|
||||
</LabelWrapper>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 md:flex-row">
|
||||
<LabelWrapper
|
||||
label="Nationality"
|
||||
error={passengerInfoVM.piiErrors[idx].nationality}
|
||||
>
|
||||
<Select.Root
|
||||
type="single"
|
||||
required
|
||||
onValueChange={(e) => {
|
||||
info.nationality = e;
|
||||
debounceValidate();
|
||||
}}
|
||||
name="role"
|
||||
>
|
||||
<Select.Trigger class="w-full">
|
||||
{capitalize(
|
||||
info.nationality.length > 0 ? info.nationality : "Select",
|
||||
)}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each COUNTRIES_SELECT as country}
|
||||
<Select.Item value={country.value}>
|
||||
{country.label}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</LabelWrapper>
|
||||
<LabelWrapper
|
||||
label="Gender"
|
||||
error={passengerInfoVM.piiErrors[idx].gender}
|
||||
>
|
||||
<Select.Root
|
||||
type="single"
|
||||
required
|
||||
onValueChange={(e) => {
|
||||
info.gender = e as Gender;
|
||||
debounceValidate();
|
||||
}}
|
||||
name="role"
|
||||
>
|
||||
<Select.Trigger class="w-full">
|
||||
{capitalize(info.gender.length > 0 ? info.gender : "Select")}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each genderOpts as gender}
|
||||
<Select.Item value={gender.value}>
|
||||
{gender.label}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</LabelWrapper>
|
||||
|
||||
<LabelWrapper
|
||||
label="Date of Birth"
|
||||
error={passengerInfoVM.piiErrors[idx].dob}
|
||||
>
|
||||
<Input
|
||||
placeholder="Date of Birth"
|
||||
bind:value={info.dob}
|
||||
type="date"
|
||||
required
|
||||
oninput={() => debounceValidate()}
|
||||
/>
|
||||
</LabelWrapper>
|
||||
</div>
|
||||
|
||||
<!-- and now for the address info - country, state, city, zip code, address and address 2 -->
|
||||
|
||||
<Title size="h5">Address Info</Title>
|
||||
|
||||
<div class="flex flex-col gap-4 md:flex-row">
|
||||
<LabelWrapper
|
||||
label="Country"
|
||||
error={passengerInfoVM.piiErrors[idx].country}
|
||||
>
|
||||
<Select.Root
|
||||
type="single"
|
||||
required
|
||||
onValueChange={(e) => {
|
||||
info.country = e;
|
||||
debounceValidate();
|
||||
}}
|
||||
name="role"
|
||||
>
|
||||
<Select.Trigger class="w-full">
|
||||
{capitalize(
|
||||
info.country.length > 0 ? info.country : "Select",
|
||||
)}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each COUNTRIES_SELECT as country}
|
||||
<Select.Item value={country.value}>
|
||||
{country.label}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</LabelWrapper>
|
||||
|
||||
<LabelWrapper label="State" error={passengerInfoVM.piiErrors[idx].state}>
|
||||
<Input
|
||||
placeholder="State"
|
||||
bind:value={info.state}
|
||||
required
|
||||
oninput={() => debounceValidate()}
|
||||
/>
|
||||
</LabelWrapper>
|
||||
</div>
|
||||
<div class="flex flex-col gap-4 md:flex-row">
|
||||
<LabelWrapper label="City" error={passengerInfoVM.piiErrors[idx].city}>
|
||||
<Input
|
||||
placeholder="City"
|
||||
bind:value={info.city}
|
||||
required
|
||||
minlength={1}
|
||||
maxlength={80}
|
||||
oninput={() => debounceValidate()}
|
||||
/>
|
||||
</LabelWrapper>
|
||||
|
||||
<LabelWrapper
|
||||
label="Zip Code"
|
||||
error={passengerInfoVM.piiErrors[idx].zipCode}
|
||||
>
|
||||
<Input
|
||||
placeholder="Zip Code"
|
||||
bind:value={info.zipCode}
|
||||
required
|
||||
minlength={1}
|
||||
oninput={() => debounceValidate()}
|
||||
maxlength={12}
|
||||
/>
|
||||
</LabelWrapper>
|
||||
</div>
|
||||
|
||||
<LabelWrapper label="Address" error={passengerInfoVM.piiErrors[idx].address}>
|
||||
<Input
|
||||
placeholder="Address"
|
||||
bind:value={info.address}
|
||||
required
|
||||
minlength={1}
|
||||
maxlength={128}
|
||||
oninput={() => debounceValidate()}
|
||||
/>
|
||||
</LabelWrapper>
|
||||
|
||||
<LabelWrapper
|
||||
label="Address 2"
|
||||
error={passengerInfoVM.piiErrors[idx].address2}
|
||||
>
|
||||
<Input
|
||||
placeholder="Address 2"
|
||||
bind:value={info.address2}
|
||||
required
|
||||
minlength={1}
|
||||
maxlength={128}
|
||||
/>
|
||||
</LabelWrapper>
|
||||
</form>
|
||||
@@ -1,167 +0,0 @@
|
||||
import {
|
||||
customerInfoModel,
|
||||
type BagSelectionInfo,
|
||||
type CustomerInfo,
|
||||
type PassengerInfo,
|
||||
type SeatSelectionInfo,
|
||||
} from "$lib/domains/ticket/data/entities/create.entities";
|
||||
import {
|
||||
Gender,
|
||||
PassengerType,
|
||||
type BagDetails,
|
||||
type FlightPriceDetails,
|
||||
type PassengerCount,
|
||||
} from "$lib/domains/ticket/data/entities/index";
|
||||
import { z } from "zod";
|
||||
|
||||
export class PassengerInfoViewModel {
|
||||
passengerInfos = $state<PassengerInfo[]>([]);
|
||||
|
||||
piiErrors = $state<Array<Partial<Record<keyof CustomerInfo, string>>>>([]);
|
||||
|
||||
reset() {
|
||||
this.passengerInfos = [];
|
||||
this.piiErrors = [];
|
||||
}
|
||||
|
||||
setupPassengerInfo(counts: PassengerCount, forceReset = false) {
|
||||
if (this.passengerInfos.length > 0 && !forceReset) {
|
||||
return; // since it's already setup
|
||||
}
|
||||
|
||||
// const _defaultPiiObj = {
|
||||
// firstName: "first",
|
||||
// middleName: "mid",
|
||||
// lastName: "last",
|
||||
// email: "first.last@example.com",
|
||||
// phoneCountryCode: "+31",
|
||||
// phoneNumber: "12345379",
|
||||
// passportNo: "f97823h",
|
||||
// passportExpiry: "2032-12-12",
|
||||
// nationality: "Netherlands",
|
||||
// gender: Gender.Male,
|
||||
// dob: "2000-12-12",
|
||||
// country: "Netherlands",
|
||||
// state: "state",
|
||||
// city: "city",
|
||||
// zipCode: "123098",
|
||||
// address: "address",
|
||||
// address2: "",
|
||||
// } as CustomerInfo;
|
||||
|
||||
const _defaultPiiObj = {
|
||||
firstName: "",
|
||||
middleName: "",
|
||||
lastName: "",
|
||||
email: "",
|
||||
phoneCountryCode: "",
|
||||
phoneNumber: "",
|
||||
passportNo: "",
|
||||
passportExpiry: "",
|
||||
nationality: "",
|
||||
gender: Gender.Male,
|
||||
dob: "",
|
||||
country: "",
|
||||
state: "",
|
||||
city: "",
|
||||
zipCode: "",
|
||||
address: "",
|
||||
address2: "",
|
||||
} as CustomerInfo;
|
||||
|
||||
const _defaultPriceObj = {
|
||||
currency: "",
|
||||
basePrice: 0,
|
||||
displayPrice: 0,
|
||||
discountAmount: 0,
|
||||
} as FlightPriceDetails;
|
||||
|
||||
const _defaultSeatSelectionObj = {
|
||||
id: "",
|
||||
row: "",
|
||||
number: 0,
|
||||
reserved: false,
|
||||
available: false,
|
||||
seatLetter: "",
|
||||
price: _defaultPriceObj,
|
||||
} as SeatSelectionInfo;
|
||||
|
||||
const _baseBagDetails = {
|
||||
dimensions: { height: 0, length: 0, width: 0 },
|
||||
price: 0,
|
||||
unit: "kg",
|
||||
weight: 0,
|
||||
} as BagDetails;
|
||||
const _defaultBagSelectionObj = {
|
||||
id: 0,
|
||||
personalBags: 1,
|
||||
handBags: 0,
|
||||
checkedBags: 0,
|
||||
pricing: {
|
||||
personalBags: { ..._baseBagDetails },
|
||||
checkedBags: { ..._baseBagDetails },
|
||||
handBags: { ..._baseBagDetails },
|
||||
},
|
||||
} as BagSelectionInfo;
|
||||
|
||||
this.passengerInfos = [];
|
||||
|
||||
for (let i = 0; i < counts.adults; i++) {
|
||||
this.passengerInfos.push({
|
||||
id: i,
|
||||
passengerType: PassengerType.Adult,
|
||||
agentsInfo: false,
|
||||
passengerPii: { ..._defaultPiiObj },
|
||||
seatSelection: { ..._defaultSeatSelectionObj },
|
||||
bagSelection: { ..._defaultBagSelectionObj, id: i },
|
||||
});
|
||||
this.piiErrors.push({});
|
||||
}
|
||||
|
||||
for (let i = 0; i < counts.children; i++) {
|
||||
this.passengerInfos.push({
|
||||
id: i + 1 + counts.adults,
|
||||
passengerType: PassengerType.Child,
|
||||
agentsInfo: false,
|
||||
passengerPii: { ..._defaultPiiObj },
|
||||
seatSelection: { ..._defaultSeatSelectionObj },
|
||||
bagSelection: { ..._defaultBagSelectionObj, id: i },
|
||||
});
|
||||
this.piiErrors.push({});
|
||||
}
|
||||
}
|
||||
|
||||
validateAllPII() {
|
||||
for (let i = 0; i < this.passengerInfos.length; i++) {
|
||||
this.validatePII(this.passengerInfos[i].passengerPii, i);
|
||||
}
|
||||
}
|
||||
|
||||
validatePII(info: CustomerInfo, idx: number) {
|
||||
try {
|
||||
const result = customerInfoModel.parse(info);
|
||||
this.piiErrors[idx] = {};
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
this.piiErrors[idx] = error.errors.reduce(
|
||||
(acc, curr) => {
|
||||
const path = curr.path[0] as keyof CustomerInfo;
|
||||
acc[path] = curr.message;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<keyof CustomerInfo, string>,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
isPIIValid(): boolean {
|
||||
return this.piiErrors.every(
|
||||
(errorObj) => Object.keys(errorObj).length === 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const passengerInfoVM = new PassengerInfoViewModel();
|
||||
@@ -22,8 +22,8 @@ export class PaymentInfoRepository {
|
||||
cardholderName: data.cardDetails.cardholderName,
|
||||
expiry: data.cardDetails.expiry,
|
||||
cvv: data.cardDetails.cvv,
|
||||
flightTicketInfoId: data.flightTicketInfoId,
|
||||
|
||||
productId: data.productId,
|
||||
orderId: data.orderId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
@@ -45,6 +45,20 @@ export class PaymentInfoRepository {
|
||||
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>> {
|
||||
Logger.info(`Deleting payment info with id ${id}`);
|
||||
const out = await this.db
|
||||
|
||||
@@ -16,6 +16,10 @@ export class PaymentInfoUseCases {
|
||||
return this.repo.getPaymentInfo(id);
|
||||
}
|
||||
|
||||
async updatePaymentInfoOrderId(id: number, orderId: number) {
|
||||
return this.repo.updatePaymentInfoOrderId(id, orderId);
|
||||
}
|
||||
|
||||
async deletePaymentInfo(id: number) {
|
||||
return this.repo.deletePaymentInfo(id);
|
||||
}
|
||||
|
||||
1
apps/frontend/src/lib/domains/product/data.ts
Normal file
1
apps/frontend/src/lib/domains/product/data.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "@pkg/logic/domains/product/data";
|
||||
1
apps/frontend/src/lib/domains/product/repository.ts
Normal file
1
apps/frontend/src/lib/domains/product/repository.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "@pkg/logic/domains/product/repository";
|
||||
10
apps/frontend/src/lib/domains/product/router.ts
Normal file
10
apps/frontend/src/lib/domains/product/router.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { protectedProcedure } from "$lib/server/trpc/t";
|
||||
import { createTRPCRouter } from "$lib/trpc/t";
|
||||
import { getProductUseCases } from "./usecases";
|
||||
|
||||
export const productRouter = createTRPCRouter({
|
||||
getAllProducts: protectedProcedure.query(async ({}) => {
|
||||
const controller = getProductUseCases();
|
||||
return controller.getAllProducts();
|
||||
}),
|
||||
});
|
||||
4
apps/frontend/src/lib/domains/product/store.ts
Normal file
4
apps/frontend/src/lib/domains/product/store.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { writable } from "svelte/store";
|
||||
import type { ProductModel } from "./data";
|
||||
|
||||
export const productStore = writable<ProductModel | null>(null);
|
||||
1
apps/frontend/src/lib/domains/product/usecases.ts
Normal file
1
apps/frontend/src/lib/domains/product/usecases.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "@pkg/logic/domains/product/usecases";
|
||||
@@ -1 +0,0 @@
|
||||
export * from "$lib/domains/passengerinfo/data/entities";
|
||||
@@ -1 +0,0 @@
|
||||
export * from "@pkg/logic/domains/ticket/data/entities/enums";
|
||||
@@ -1 +0,0 @@
|
||||
export * from "@pkg/logic/domains/ticket/data/entities/index";
|
||||
@@ -1,363 +0,0 @@
|
||||
import { round } from "$lib/core/num.utils";
|
||||
import {
|
||||
getCKUseCases,
|
||||
type CheckoutFlowUseCases,
|
||||
} from "$lib/domains/ckflow/domain/usecases";
|
||||
import { and, arrayContains, eq, or, type Database } from "@pkg/db";
|
||||
import { flightTicketInfo, order } from "@pkg/db/schema";
|
||||
import { getError, Logger } from "@pkg/logger";
|
||||
import { ERROR_CODES, type Result } from "@pkg/result";
|
||||
import {
|
||||
flightPriceDetailsModel,
|
||||
flightTicketModel,
|
||||
TicketType,
|
||||
type FlightPriceDetails,
|
||||
type FlightTicket,
|
||||
type TicketSearchDTO,
|
||||
} from "./entities";
|
||||
import type { ScrapedTicketsDataSource } from "./scrape.data.source";
|
||||
|
||||
export class TicketRepository {
|
||||
private db: Database;
|
||||
private ckUseCases: CheckoutFlowUseCases;
|
||||
private scraper: ScrapedTicketsDataSource;
|
||||
|
||||
constructor(db: Database, scraper: ScrapedTicketsDataSource) {
|
||||
this.db = db;
|
||||
this.scraper = scraper;
|
||||
this.ckUseCases = getCKUseCases();
|
||||
}
|
||||
|
||||
async getCheckoutUrlByRefOId(refOId: number): Promise<Result<string>> {
|
||||
try {
|
||||
const _o = await this.db.query.order.findFirst({
|
||||
where: eq(order.id, refOId),
|
||||
with: {
|
||||
flightTicketInfo: {
|
||||
columns: { id: true, checkoutUrl: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
const chktUrl = _o?.flightTicketInfo?.checkoutUrl;
|
||||
console.log(_o);
|
||||
return { data: chktUrl };
|
||||
} catch (e) {
|
||||
return {
|
||||
error: getError(
|
||||
{
|
||||
code: ERROR_CODES.NOT_FOUND_ERROR,
|
||||
message: "Failed to fetch ticket url",
|
||||
userHint: "Please try again later",
|
||||
detail: "An error occurred while fetching ticket url",
|
||||
},
|
||||
e,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async getTicketIdbyRefOid(refOid: number): Promise<Result<number>> {
|
||||
try {
|
||||
const out = await this.db.query.flightTicketInfo.findFirst({
|
||||
where: arrayContains(flightTicketInfo.refOIds, [refOid]),
|
||||
columns: { id: true },
|
||||
});
|
||||
if (out && out.id) {
|
||||
return { data: out?.id };
|
||||
}
|
||||
return {};
|
||||
} catch (e) {
|
||||
Logger.debug(refOid);
|
||||
return {
|
||||
error: getError(
|
||||
{
|
||||
code: ERROR_CODES.DATABASE_ERROR,
|
||||
message: "Failed to lookup ticket",
|
||||
detail: "A database error occured while getting ticket by id",
|
||||
userHint: "Contact us to resolve this issue",
|
||||
actionable: false,
|
||||
},
|
||||
e,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async searchForTickets(
|
||||
payload: TicketSearchDTO,
|
||||
): Promise<Result<FlightTicket[]>> {
|
||||
try {
|
||||
let out = await this.scraper.searchForTickets(payload);
|
||||
if (out.error || (out.data && out.data.length < 1)) {
|
||||
return out;
|
||||
}
|
||||
return out;
|
||||
} catch (err) {
|
||||
return {
|
||||
error: getError(
|
||||
{
|
||||
code: ERROR_CODES.NETWORK_ERROR,
|
||||
message: "Failed to search for tickets",
|
||||
detail: "Could not fetch tickets for the given payload",
|
||||
userHint: "Please try again later",
|
||||
error: err,
|
||||
actionable: false,
|
||||
},
|
||||
err,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async getTicketById(id: number): Promise<Result<FlightTicket>> {
|
||||
const out = await this.db.query.flightTicketInfo.findFirst({
|
||||
where: eq(flightTicketInfo.id, id),
|
||||
});
|
||||
if (!out) {
|
||||
return {
|
||||
error: getError({
|
||||
code: ERROR_CODES.NOT_FOUND_ERROR,
|
||||
message: "Ticket not found",
|
||||
userHint:
|
||||
"Please check if the selected ticket is correct, or try again",
|
||||
detail: "The ticket is not found by the id provided",
|
||||
}),
|
||||
};
|
||||
}
|
||||
const parsed = flightTicketModel.safeParse(out);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
error: getError(
|
||||
{
|
||||
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
|
||||
message: "Failed to parse ticket",
|
||||
userHint: "Please try again",
|
||||
detail: "Failed to parse ticket",
|
||||
},
|
||||
JSON.stringify(parsed.error.errors),
|
||||
),
|
||||
};
|
||||
}
|
||||
return { data: parsed.data };
|
||||
}
|
||||
|
||||
async getTicketIdByInfo(info: {
|
||||
ticketId: string;
|
||||
arrival: string;
|
||||
departure: string;
|
||||
cabinClass: string;
|
||||
departureDate: string;
|
||||
returnDate: string;
|
||||
}): Promise<Result<number>> {
|
||||
try {
|
||||
const out = await this.db.query.flightTicketInfo.findFirst({
|
||||
where: or(
|
||||
eq(flightTicketInfo.ticketId, info.ticketId),
|
||||
and(
|
||||
eq(flightTicketInfo.arrival, info.arrival),
|
||||
eq(flightTicketInfo.departure, info.departure),
|
||||
eq(flightTicketInfo.cabinClass, info.cabinClass),
|
||||
eq(
|
||||
flightTicketInfo.departureDate,
|
||||
new Date(info.departureDate),
|
||||
),
|
||||
),
|
||||
),
|
||||
columns: { id: true },
|
||||
});
|
||||
if (out && out.id) {
|
||||
return { data: out?.id };
|
||||
}
|
||||
|
||||
return {};
|
||||
} catch (e) {
|
||||
Logger.debug(info);
|
||||
return {
|
||||
error: getError(
|
||||
{
|
||||
code: ERROR_CODES.DATABASE_ERROR,
|
||||
message: "Failed to lookup ticket",
|
||||
detail: "A database error occured while getting ticket by id",
|
||||
userHint: "Contact us to resolve this issue",
|
||||
actionable: false,
|
||||
},
|
||||
e,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async createTicket(
|
||||
payload: FlightTicket,
|
||||
isCache = false,
|
||||
): Promise<Result<number>> {
|
||||
try {
|
||||
let rd = new Date();
|
||||
if (
|
||||
payload.returnDate &&
|
||||
payload.returnDate.length > 0 &&
|
||||
payload.flightType !== TicketType.OneWay
|
||||
) {
|
||||
rd = new Date(payload.returnDate);
|
||||
}
|
||||
const out = await this.db
|
||||
.insert(flightTicketInfo)
|
||||
.values({
|
||||
ticketId: payload.ticketId,
|
||||
flightType: payload.flightType,
|
||||
|
||||
arrival: payload.arrival,
|
||||
departure: payload.departure,
|
||||
cabinClass: payload.cabinClass,
|
||||
departureDate: new Date(payload.departureDate),
|
||||
returnDate: rd,
|
||||
|
||||
priceDetails: payload.priceDetails,
|
||||
passengerCounts: payload.passengerCounts,
|
||||
bagsInfo: payload.bagsInfo,
|
||||
|
||||
lastAvailable: payload.lastAvailable,
|
||||
|
||||
flightIteneraries: payload.flightIteneraries,
|
||||
|
||||
dates: payload.dates,
|
||||
|
||||
checkoutUrl: payload.checkoutUrl ?? "",
|
||||
refOIds: payload.refOIds ?? [],
|
||||
|
||||
refundable: payload.refundable ?? false,
|
||||
isCache: isCache ? true : (payload.isCache ?? isCache),
|
||||
|
||||
shareId: payload.shareId,
|
||||
})
|
||||
.returning({ id: flightTicketInfo.id })
|
||||
.execute();
|
||||
|
||||
if (!out || out.length === 0) {
|
||||
Logger.error("Failed to create ticket");
|
||||
Logger.debug(out);
|
||||
Logger.debug(payload);
|
||||
return {
|
||||
error: getError({
|
||||
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
|
||||
message: "Failed to create ticket",
|
||||
userHint: "Please try again",
|
||||
detail: "Failed to create ticket",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return { data: out[0].id };
|
||||
} catch (e) {
|
||||
return {
|
||||
error: getError(
|
||||
{
|
||||
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
|
||||
message: "An error occured while creating ticket",
|
||||
userHint: "Please try again later",
|
||||
actionable: false,
|
||||
detail: "An error occured while creating ticket",
|
||||
},
|
||||
e,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async updateTicketPrices(
|
||||
tid: number,
|
||||
payload: FlightPriceDetails,
|
||||
): Promise<Result<FlightPriceDetails>> {
|
||||
const cond = eq(flightTicketInfo.id, tid);
|
||||
const currInfo = await this.db.query.flightTicketInfo.findFirst({
|
||||
where: cond,
|
||||
columns: { priceDetails: true },
|
||||
});
|
||||
if (!currInfo) {
|
||||
return {
|
||||
error: getError({
|
||||
code: ERROR_CODES.NOT_FOUND_ERROR,
|
||||
message: "Could not find ticket",
|
||||
userHint: "Please try again later",
|
||||
detail: "Could not fin the ticket by the provided id",
|
||||
}),
|
||||
};
|
||||
}
|
||||
const info = flightPriceDetailsModel.parse(currInfo.priceDetails);
|
||||
Logger.info("Updating the price details from:");
|
||||
Logger.debug(info);
|
||||
const discountAmt = round(info.basePrice - payload.displayPrice);
|
||||
const newInfo = {
|
||||
...info,
|
||||
discountAmount: discountAmt,
|
||||
displayPrice: payload.displayPrice,
|
||||
} as FlightPriceDetails;
|
||||
Logger.info("... to:");
|
||||
Logger.debug(newInfo);
|
||||
const out = await this.db
|
||||
.update(flightTicketInfo)
|
||||
.set({ priceDetails: newInfo })
|
||||
.where(cond)
|
||||
.execute();
|
||||
Logger.info("Updated the price info");
|
||||
Logger.debug(out);
|
||||
return { data: newInfo };
|
||||
}
|
||||
|
||||
async uncacheAndSaveTicket(tid: number): Promise<Result<number>> {
|
||||
try {
|
||||
const out = await this.db
|
||||
.update(flightTicketInfo)
|
||||
.set({ isCache: false })
|
||||
.where(eq(flightTicketInfo.id, tid))
|
||||
.returning({ id: flightTicketInfo.id })
|
||||
.execute();
|
||||
|
||||
if (!out || out.length === 0) {
|
||||
Logger.error("Failed to uncache ticket");
|
||||
Logger.debug(out);
|
||||
Logger.debug(tid);
|
||||
return {
|
||||
error: getError({
|
||||
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
|
||||
message: "Failed to process ticket at this moment",
|
||||
userHint: "Please try again later",
|
||||
detail: "Failed to uncache ticket",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return { data: out[0].id };
|
||||
} catch (e) {
|
||||
return {
|
||||
error: getError(
|
||||
{
|
||||
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
|
||||
message: "An error occured while uncaching ticket",
|
||||
userHint: "Please try again later",
|
||||
actionable: false,
|
||||
detail: "An error occured while uncaching ticket",
|
||||
},
|
||||
e,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async setRefOIdsForTicket(
|
||||
tid: number,
|
||||
oids: number[],
|
||||
): Promise<Result<boolean>> {
|
||||
Logger.info(`Setting refOIds(${oids}) for ticket ${tid}`);
|
||||
const out = await this.db
|
||||
.update(flightTicketInfo)
|
||||
.set({ refOIds: oids })
|
||||
.where(eq(flightTicketInfo.id, tid))
|
||||
.execute();
|
||||
return { data: out.length > 0 };
|
||||
}
|
||||
|
||||
async areCheckoutAlreadyLiveForOrder(refOids: number[]): Promise<boolean> {
|
||||
return this.ckUseCases.areCheckoutAlreadyLiveForOrder(refOids);
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { ERROR_CODES, type Result } from "@pkg/result";
|
||||
import type { FlightTicket, TicketSearchDTO } from "./entities";
|
||||
import { betterFetch } from "@better-fetch/fetch";
|
||||
import { getError, Logger } from "@pkg/logger";
|
||||
|
||||
export class ScrapedTicketsDataSource {
|
||||
scraperUrl: string;
|
||||
apiKey: string;
|
||||
|
||||
constructor(scraperUrl: string, apiKey: string) {
|
||||
this.scraperUrl = scraperUrl;
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
async searchForTickets(
|
||||
payload: TicketSearchDTO,
|
||||
): Promise<Result<FlightTicket[]>> {
|
||||
const { data, error } = await betterFetch<Result<FlightTicket[]>>(
|
||||
`${this.scraperUrl}/api/v1/tickets/search`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return {
|
||||
error: getError(
|
||||
{
|
||||
code: ERROR_CODES.NETWORK_ERROR,
|
||||
message: "Failed to search for tickets",
|
||||
detail: "Could not fetch tickets for the given payload",
|
||||
userHint: "Please try again later",
|
||||
error: error,
|
||||
actionable: false,
|
||||
},
|
||||
JSON.stringify(error, null, 4),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
Logger.info(`Returning ${data.data?.length} tickets`);
|
||||
|
||||
return { data: data.data ?? [] };
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { writable } from "svelte/store";
|
||||
import {
|
||||
CabinClass,
|
||||
TicketType,
|
||||
type FlightTicket,
|
||||
type TicketSearchPayload,
|
||||
} from "./entities";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export const flightTicketStore = writable<FlightTicket>();
|
||||
|
||||
export const ticketSearchStore = writable<TicketSearchPayload>({
|
||||
loadMore: false,
|
||||
sessionId: nanoid(),
|
||||
ticketType: TicketType.Return,
|
||||
cabinClass: CabinClass.Economy,
|
||||
passengerCounts: { adults: 1, children: 0 },
|
||||
departure: "",
|
||||
arrival: "",
|
||||
departureDate: "",
|
||||
returnDate: "",
|
||||
meta: {},
|
||||
couponCode: "",
|
||||
});
|
||||
@@ -1,153 +0,0 @@
|
||||
import { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte";
|
||||
import { db } from "@pkg/db";
|
||||
import { getError, Logger } from "@pkg/logger";
|
||||
import { DiscountType, type CouponModel } from "@pkg/logic/domains/coupon/data";
|
||||
import { ERROR_CODES, type Result } from "@pkg/result";
|
||||
import type {
|
||||
FlightPriceDetails,
|
||||
FlightTicket,
|
||||
TicketSearchPayload,
|
||||
} from "../data/entities";
|
||||
import { TicketRepository } from "../data/repository";
|
||||
import { ScrapedTicketsDataSource } from "../data/scrape.data.source";
|
||||
|
||||
export class TicketController {
|
||||
private repo: TicketRepository;
|
||||
private TICKET_SCRAPERS = ["kiwi"];
|
||||
|
||||
constructor(repo: TicketRepository) {
|
||||
this.repo = repo;
|
||||
}
|
||||
|
||||
async searchForTickets(
|
||||
payload: TicketSearchPayload,
|
||||
coupon: CouponModel | undefined,
|
||||
): Promise<Result<FlightTicket[]>> {
|
||||
const result = await this.repo.searchForTickets({
|
||||
sessionId: payload.sessionId,
|
||||
ticketSearchPayload: payload,
|
||||
providers: this.TICKET_SCRAPERS,
|
||||
});
|
||||
|
||||
if (result.error || !result.data) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!coupon) {
|
||||
return result;
|
||||
}
|
||||
|
||||
Logger.info(`Auto-applying coupon ${coupon.code} to all search results`);
|
||||
|
||||
return {
|
||||
data: result.data.map((ticket) => {
|
||||
return this.applyDiscountToTicket(ticket, coupon);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private applyDiscountToTicket(
|
||||
ticket: FlightTicket,
|
||||
coupon: CouponModel,
|
||||
): FlightTicket {
|
||||
// Calculate discount amount
|
||||
let discountAmount = 0;
|
||||
|
||||
if (coupon.discountType === DiscountType.PERCENTAGE) {
|
||||
discountAmount =
|
||||
(ticket.priceDetails.displayPrice *
|
||||
Number(coupon.discountValue)) /
|
||||
100;
|
||||
} else {
|
||||
discountAmount = Number(coupon.discountValue);
|
||||
}
|
||||
|
||||
// Apply maximum discount limit if specified
|
||||
if (
|
||||
coupon.maxDiscountAmount &&
|
||||
discountAmount > Number(coupon.maxDiscountAmount)
|
||||
) {
|
||||
discountAmount = Number(coupon.maxDiscountAmount);
|
||||
}
|
||||
|
||||
// Skip if minimum order value is not met
|
||||
if (
|
||||
coupon.minOrderValue &&
|
||||
ticket.priceDetails.displayPrice < Number(coupon.minOrderValue)
|
||||
) {
|
||||
return ticket;
|
||||
}
|
||||
|
||||
// Create a copy of the ticket with the discount applied
|
||||
const discountedTicket = { ...ticket };
|
||||
|
||||
discountedTicket.priceDetails = {
|
||||
...ticket.priceDetails,
|
||||
discountAmount:
|
||||
(ticket.priceDetails.discountAmount || 0) + discountAmount,
|
||||
displayPrice: ticket.priceDetails.displayPrice - discountAmount,
|
||||
appliedCoupon: coupon.code,
|
||||
couponDescription:
|
||||
coupon.description ||
|
||||
`Coupon discount of ${coupon.discountType === DiscountType.PERCENTAGE ? coupon.discountValue + "%" : convertAndFormatCurrency(parseFloat(coupon.discountValue.toString()))}`,
|
||||
};
|
||||
|
||||
return discountedTicket;
|
||||
}
|
||||
|
||||
async cacheTicket(sid: string, payload: FlightTicket) {
|
||||
Logger.info(
|
||||
`Caching ticket for ${sid} | ${payload.departure}:${payload.arrival}`,
|
||||
);
|
||||
const refOIds = payload.refOIds;
|
||||
if (!refOIds) {
|
||||
// In this case we're not going for any of our fancy checkout jazz
|
||||
return this.repo.createTicket(payload, true);
|
||||
}
|
||||
|
||||
const areAnyLive =
|
||||
await this.repo.areCheckoutAlreadyLiveForOrder(refOIds);
|
||||
|
||||
Logger.info(`Any of the OIds has checkout session live ?? ${areAnyLive}`);
|
||||
|
||||
if (areAnyLive) {
|
||||
return {
|
||||
error: getError({
|
||||
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
|
||||
message: "This ticket offer has expired",
|
||||
userHint:
|
||||
"Please select another one or perform search again to fetch latest offers",
|
||||
actionable: false,
|
||||
detail: "Failed to ticket",
|
||||
}),
|
||||
};
|
||||
}
|
||||
Logger.info("nu'uh seems greenlight to make a ticket");
|
||||
return this.repo.createTicket(payload, true);
|
||||
}
|
||||
|
||||
async updateTicketPrices(tid: number, payload: FlightPriceDetails) {
|
||||
return this.repo.updateTicketPrices(tid, payload);
|
||||
}
|
||||
|
||||
async uncacheAndSaveTicket(tid: number) {
|
||||
return this.repo.uncacheAndSaveTicket(tid);
|
||||
}
|
||||
|
||||
async getCheckoutUrlByRefOId(id: number) {
|
||||
return this.repo.getCheckoutUrlByRefOId(id);
|
||||
}
|
||||
|
||||
async setRefOIdsForTicket(tid: number, oids: number[]) {
|
||||
return this.repo.setRefOIdsForTicket(tid, oids);
|
||||
}
|
||||
|
||||
async getTicketById(id: number) {
|
||||
return this.repo.getTicketById(id);
|
||||
}
|
||||
}
|
||||
|
||||
export function getTC() {
|
||||
const ds = new ScrapedTicketsDataSource("", "");
|
||||
return new TicketController(new TicketRepository(db, ds));
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { createTRPCRouter } from "$lib/trpc/t";
|
||||
import { z } from "zod";
|
||||
import { publicProcedure } from "$lib/server/trpc/t";
|
||||
import { getTC } from "./controller";
|
||||
import { Logger } from "@pkg/logger";
|
||||
import {
|
||||
flightPriceDetailsModel,
|
||||
flightTicketModel,
|
||||
ticketSearchPayloadModel,
|
||||
} from "../data/entities/index";
|
||||
import type { Result } from "@pkg/result";
|
||||
import { getCKUseCases } from "$lib/domains/ckflow/domain/usecases";
|
||||
import { CouponRepository } from "@pkg/logic/domains/coupon/repository";
|
||||
import { db } from "@pkg/db";
|
||||
|
||||
export const ticketRouter = createTRPCRouter({
|
||||
searchAirports: publicProcedure
|
||||
.input(z.object({ query: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
const { query } = input;
|
||||
const tc = getTC();
|
||||
Logger.info(`Fetching airports with query: ${query}`);
|
||||
return await tc.searchAirports(query);
|
||||
}),
|
||||
|
||||
ping: publicProcedure
|
||||
.input(z.object({ tid: z.number(), refOIds: z.array(z.number()) }))
|
||||
.query(async ({ input }) => {
|
||||
console.log("Pinged");
|
||||
console.log(input);
|
||||
const ckflowUC = getCKUseCases();
|
||||
const out = await ckflowUC.areCheckoutAlreadyLiveForOrder(
|
||||
input.refOIds,
|
||||
);
|
||||
return { data: out } as Result<boolean>;
|
||||
}),
|
||||
|
||||
getAirportByCode: publicProcedure
|
||||
.input(z.object({ code: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
return await getTC().getAirportByCode(input.code);
|
||||
}),
|
||||
|
||||
searchTickets: publicProcedure
|
||||
.input(ticketSearchPayloadModel)
|
||||
.query(async ({ input }) => {
|
||||
const cr = new CouponRepository(db);
|
||||
const coupon = await cr.getBestActiveCoupon();
|
||||
Logger.info(`Got coupon? :: ${coupon.data?.code}`);
|
||||
return getTC().searchForTickets(input, coupon.data);
|
||||
}),
|
||||
|
||||
cacheTicket: publicProcedure
|
||||
.input(z.object({ sid: z.string(), payload: flightTicketModel }))
|
||||
.mutation(async ({ input }) => {
|
||||
return await getTC().cacheTicket(input.sid, input.payload);
|
||||
}),
|
||||
|
||||
updateTicketPrices: publicProcedure
|
||||
.input(z.object({ tid: z.number(), payload: flightPriceDetailsModel }))
|
||||
.mutation(async ({ input }) => {
|
||||
return await getTC().updateTicketPrices(input.tid, input.payload);
|
||||
}),
|
||||
|
||||
getTicketById: publicProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.query(async ({ input }) => {
|
||||
return await getTC().getTicketById(input.id);
|
||||
}),
|
||||
});
|
||||
@@ -1,445 +0,0 @@
|
||||
import type { FlightTicket } from "../data/entities";
|
||||
import { Logger } from "@pkg/logger";
|
||||
import { round } from "$lib/core/num.utils";
|
||||
import { type Result } from "@pkg/result";
|
||||
import { type LimitedOrderWithTicketInfoModel } from "$lib/domains/order/data/entities";
|
||||
import { getJustDateString } from "@pkg/logic/core/date.utils";
|
||||
|
||||
type OrderWithUsageInfo = {
|
||||
order: LimitedOrderWithTicketInfoModel;
|
||||
usePartialAmount: boolean;
|
||||
};
|
||||
|
||||
export class TicketWithOrderSkiddaddler {
|
||||
THRESHOLD_DIFF_PERCENTAGE = 15;
|
||||
ELEVATED_THRESHOLD_DIFF_PERCENTAGE = 50;
|
||||
tickets: FlightTicket[];
|
||||
|
||||
private reservedOrders: number[];
|
||||
private allActiveOrders: LimitedOrderWithTicketInfoModel[];
|
||||
private urgentActiveOrders: LimitedOrderWithTicketInfoModel[];
|
||||
// Stores oids that have their amount divided into smaller amounts for being reused for multiple tickets
|
||||
// this record keeps track of the oid, with how much remainder is left to be divided
|
||||
private reservedPartialOrders: Record<number, number>;
|
||||
private minTicketPrice: number;
|
||||
private maxTicketPrice: number;
|
||||
|
||||
constructor(
|
||||
tickets: FlightTicket[],
|
||||
allActiveOrders: LimitedOrderWithTicketInfoModel[],
|
||||
) {
|
||||
this.tickets = tickets;
|
||||
this.reservedOrders = [];
|
||||
this.reservedPartialOrders = {};
|
||||
this.minTicketPrice = 0;
|
||||
this.maxTicketPrice = 0;
|
||||
this.allActiveOrders = [];
|
||||
this.urgentActiveOrders = [];
|
||||
|
||||
this.loadupActiveOrders(allActiveOrders);
|
||||
}
|
||||
|
||||
async magic(): Promise<Result<boolean>> {
|
||||
// Sorting the orders by price in ascending order
|
||||
this.allActiveOrders.sort((a, b) => b.basePrice - a.basePrice);
|
||||
|
||||
this.loadMinMaxPrices();
|
||||
|
||||
for (const ticket of this.tickets) {
|
||||
if (this.areAllOrdersUsedUp()) {
|
||||
break;
|
||||
}
|
||||
|
||||
const suitableOrders = this.getSuitableOrders(ticket);
|
||||
if (!suitableOrders) {
|
||||
continue;
|
||||
}
|
||||
console.log("--------- suitable orders ---------");
|
||||
console.log(suitableOrders);
|
||||
console.log("-----------------------------------");
|
||||
const [discountAmt, newDisplayPrice] = this.calculateNewAmounts(
|
||||
ticket,
|
||||
suitableOrders,
|
||||
);
|
||||
ticket.priceDetails.discountAmount = discountAmt;
|
||||
ticket.priceDetails.displayPrice = newDisplayPrice;
|
||||
|
||||
const oids = Array.from(
|
||||
new Set(suitableOrders.map((o) => o.order.id)),
|
||||
).toSorted();
|
||||
ticket.refOIds = oids;
|
||||
this.reservedOrders.push(...oids);
|
||||
}
|
||||
|
||||
Logger.debug(`Assigned ${this.reservedOrders.length} orders to tickets`);
|
||||
return { data: true };
|
||||
}
|
||||
|
||||
private loadupActiveOrders(data: LimitedOrderWithTicketInfoModel[]) {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
|
||||
// Removing all orders which have tickets with departure date of yesterday and older
|
||||
this.allActiveOrders = data.filter((o) => {
|
||||
return (
|
||||
new Date(
|
||||
getJustDateString(new Date(o.flightTicketInfo.departureDate)),
|
||||
) >= today
|
||||
);
|
||||
});
|
||||
this.urgentActiveOrders = [];
|
||||
const threeDaysFromNowMs = 3 * 24 * 3600 * 1000;
|
||||
for (const order of this.allActiveOrders) {
|
||||
const depDate = new Date(order.flightTicketInfo.departureDate);
|
||||
if (now.getTime() + threeDaysFromNowMs > depDate.getTime()) {
|
||||
this.urgentActiveOrders.push(order);
|
||||
}
|
||||
}
|
||||
|
||||
Logger.info(`Found ${this.allActiveOrders.length} active orders`);
|
||||
Logger.info(`We got ${this.urgentActiveOrders.length} urgent orders`);
|
||||
}
|
||||
|
||||
private loadMinMaxPrices() {
|
||||
this.minTicketPrice = 100000;
|
||||
this.maxTicketPrice = 0;
|
||||
|
||||
for (const ticket of this.tickets) {
|
||||
const _dPrice = ticket.priceDetails.displayPrice;
|
||||
if (_dPrice < this.minTicketPrice) {
|
||||
this.minTicketPrice = _dPrice;
|
||||
}
|
||||
if (_dPrice > this.maxTicketPrice) {
|
||||
this.maxTicketPrice = _dPrice;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private areAllOrdersUsedUp() {
|
||||
if (this.urgentActiveOrders.length === 0) {
|
||||
return this.reservedOrders.length >= this.allActiveOrders.length;
|
||||
}
|
||||
const areUrgentOrdersUsedUp =
|
||||
this.reservedOrders.length >= this.urgentActiveOrders.length;
|
||||
return areUrgentOrdersUsedUp;
|
||||
}
|
||||
|
||||
/**
|
||||
* An order is used up in 2 cases
|
||||
* 1. it's not being partially fulfulled and it's found in the used orders list
|
||||
* 2. it's being partially fulfilled and it's found in both the used orders list and sub chunked orders list
|
||||
*/
|
||||
private isOrderUsedUp(oid: number, displayPrice: number) {
|
||||
if (this.reservedPartialOrders.hasOwnProperty(oid)) {
|
||||
return this.reservedPartialOrders[oid] < displayPrice;
|
||||
}
|
||||
return this.reservedOrders.includes(oid);
|
||||
}
|
||||
|
||||
private calculateNewAmounts(
|
||||
ticket: FlightTicket,
|
||||
orders: OrderWithUsageInfo[],
|
||||
) {
|
||||
if (orders.length < 1) {
|
||||
return [
|
||||
ticket.priceDetails.discountAmount,
|
||||
ticket.priceDetails.displayPrice,
|
||||
];
|
||||
}
|
||||
|
||||
const totalAmount = orders.reduce((sum, { order, usePartialAmount }) => {
|
||||
return (
|
||||
sum +
|
||||
(usePartialAmount ? order.pricePerPassenger : order.displayPrice)
|
||||
);
|
||||
}, 0);
|
||||
|
||||
const discountAmt = round(ticket.priceDetails.displayPrice - totalAmount);
|
||||
return [discountAmt, totalAmount];
|
||||
}
|
||||
|
||||
/**
|
||||
* Suitable orders are those orders that are ideally within the threshold and are not already reserved. So there's a handful of cases, which other sub methods are handling
|
||||
*/
|
||||
private getSuitableOrders(
|
||||
ticket: FlightTicket,
|
||||
): OrderWithUsageInfo[] | undefined {
|
||||
const activeOrders =
|
||||
this.urgentActiveOrders.length > 0
|
||||
? this.urgentActiveOrders
|
||||
: this.allActiveOrders;
|
||||
const cases = [
|
||||
(t: any, a: any) => {
|
||||
return this.findFirstSuitableDirectOId(t, a);
|
||||
},
|
||||
(t: any, a: any) => {
|
||||
return this.findFirstSuitablePartialOId(t, a);
|
||||
},
|
||||
(t: any, a: any) => {
|
||||
return this.findManySuitableOIds(t, a);
|
||||
},
|
||||
(t: any, a: any) => {
|
||||
return this.findFirstSuitableDirectOIdElevated(t, a);
|
||||
},
|
||||
];
|
||||
let c = 1;
|
||||
for (const each of cases) {
|
||||
const out = each(ticket, activeOrders);
|
||||
if (out) {
|
||||
console.log(`Case ${c} worked, returning it's response`);
|
||||
return out;
|
||||
}
|
||||
c++;
|
||||
}
|
||||
console.log("NO CASE WORKED, WUT, yee");
|
||||
}
|
||||
|
||||
private findFirstSuitableDirectOId(
|
||||
ticket: FlightTicket,
|
||||
activeOrders: LimitedOrderWithTicketInfoModel[],
|
||||
): OrderWithUsageInfo[] | undefined {
|
||||
let ord: LimitedOrderWithTicketInfoModel | undefined;
|
||||
for (const o of activeOrders) {
|
||||
const [op, tp] = [o.displayPrice, ticket.priceDetails.displayPrice];
|
||||
const diff = Math.abs(op - tp);
|
||||
const diffPtage = (diff / tp) * 100;
|
||||
if (this.isOrderSuitable(o.id, op, tp, diffPtage)) {
|
||||
ord = o;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!ord) {
|
||||
return;
|
||||
}
|
||||
return [{ order: ord, usePartialAmount: false }];
|
||||
}
|
||||
|
||||
private findFirstSuitablePartialOId(
|
||||
ticket: FlightTicket,
|
||||
activeOrders: LimitedOrderWithTicketInfoModel[],
|
||||
): OrderWithUsageInfo[] | undefined {
|
||||
const tp = ticket.priceDetails.displayPrice;
|
||||
|
||||
let ord: LimitedOrderWithTicketInfoModel | undefined;
|
||||
for (const o of activeOrders) {
|
||||
const op = o.pricePerPassenger;
|
||||
const diff = op - tp;
|
||||
const diffPtage = (diff / tp) * 100;
|
||||
if (this.isOrderSuitable(o.id, op, tp, diffPtage)) {
|
||||
ord = o;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!ord) {
|
||||
return;
|
||||
}
|
||||
this.upsertPartialOrderToCache(
|
||||
ord.id,
|
||||
ord.pricePerPassenger,
|
||||
ord.fullfilledPrice,
|
||||
);
|
||||
return [{ order: ord, usePartialAmount: true }];
|
||||
}
|
||||
|
||||
private findManySuitableOIds(
|
||||
ticket: FlightTicket,
|
||||
activeOrders: LimitedOrderWithTicketInfoModel[],
|
||||
): OrderWithUsageInfo[] | undefined {
|
||||
const targetPrice = ticket.priceDetails.displayPrice;
|
||||
|
||||
const validOrderOptions = activeOrders.flatMap((order) => {
|
||||
const options: OrderWithUsageInfo[] = [];
|
||||
|
||||
// Add full price option if suitable
|
||||
if (
|
||||
!this.isOrderUsedUp(order.id, order.displayPrice) &&
|
||||
order.displayPrice <= targetPrice
|
||||
) {
|
||||
options.push({ order, usePartialAmount: false });
|
||||
}
|
||||
|
||||
// Add partial price option if suitable
|
||||
if (
|
||||
!this.isOrderUsedUp(order.id, order.pricePerPassenger) &&
|
||||
order.pricePerPassenger <= targetPrice
|
||||
) {
|
||||
options.push({ order, usePartialAmount: true });
|
||||
}
|
||||
|
||||
return options;
|
||||
});
|
||||
|
||||
if (validOrderOptions.length === 0) return undefined;
|
||||
// Helper function to get the effective price of an order
|
||||
const getEffectivePrice = (ordInfo: {
|
||||
order: LimitedOrderWithTicketInfoModel;
|
||||
usePerPassenger: boolean;
|
||||
}) => {
|
||||
return ordInfo.usePerPassenger
|
||||
? ordInfo.order.pricePerPassenger
|
||||
: ordInfo.order.displayPrice;
|
||||
};
|
||||
|
||||
const sumCombination = (combo: OrderWithUsageInfo[]): number => {
|
||||
return combo.reduce(
|
||||
(sum, { order, usePartialAmount }) =>
|
||||
sum +
|
||||
(usePartialAmount
|
||||
? order.pricePerPassenger
|
||||
: order.displayPrice),
|
||||
0,
|
||||
);
|
||||
};
|
||||
|
||||
let bestCombination: OrderWithUsageInfo[] = [];
|
||||
let bestDiff = targetPrice;
|
||||
|
||||
// Try combinations of different sizes
|
||||
for (
|
||||
let size = 1;
|
||||
size <= Math.min(validOrderOptions.length, 3);
|
||||
size++
|
||||
) {
|
||||
const combinations = this.getCombinations(validOrderOptions, size);
|
||||
|
||||
for (const combo of combinations) {
|
||||
const sum = sumCombination(combo);
|
||||
const diff = targetPrice - sum;
|
||||
|
||||
if (
|
||||
diff >= 0 &&
|
||||
(diff / targetPrice) * 100 <=
|
||||
this.THRESHOLD_DIFF_PERCENTAGE &&
|
||||
diff < bestDiff
|
||||
) {
|
||||
// Verify we're not using the same order twice
|
||||
const orderIds = new Set(combo.map((c) => c.order.id));
|
||||
if (orderIds.size === combo.length) {
|
||||
bestDiff = diff;
|
||||
bestCombination = combo;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestCombination.length === 0) return undefined;
|
||||
|
||||
// Update partial orders cache
|
||||
bestCombination.forEach(({ order, usePartialAmount }) => {
|
||||
if (usePartialAmount) {
|
||||
this.upsertPartialOrderToCache(
|
||||
order.id,
|
||||
order.pricePerPassenger,
|
||||
order.fullfilledPrice,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return bestCombination;
|
||||
}
|
||||
|
||||
/**
|
||||
* In case no other cases worked, but we have an order with far lower price amount,
|
||||
* then we juss use it but bump up it's amount to match the order
|
||||
*/
|
||||
private findFirstSuitableDirectOIdElevated(
|
||||
ticket: FlightTicket,
|
||||
activeOrders: LimitedOrderWithTicketInfoModel[],
|
||||
): OrderWithUsageInfo[] | undefined {
|
||||
const targetPrice = ticket.priceDetails.displayPrice;
|
||||
|
||||
// Find the first unreserved order with the smallest price difference
|
||||
let bestOrder: LimitedOrderWithTicketInfoModel | undefined;
|
||||
let smallestPriceDiff = Number.MAX_VALUE;
|
||||
|
||||
for (const order of activeOrders) {
|
||||
if (this.isOrderUsedUp(order.id, order.displayPrice)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check both regular price and per-passenger price
|
||||
const prices = [order.displayPrice];
|
||||
if (order.pricePerPassenger) {
|
||||
prices.push(order.pricePerPassenger);
|
||||
}
|
||||
|
||||
for (const price of prices) {
|
||||
const diff = Math.abs(targetPrice - price);
|
||||
if (diff < smallestPriceDiff) {
|
||||
smallestPriceDiff = diff;
|
||||
bestOrder = order;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestOrder) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Calculate if we should use partial amount
|
||||
const usePartial =
|
||||
bestOrder.pricePerPassenger &&
|
||||
Math.abs(targetPrice - bestOrder.pricePerPassenger) <
|
||||
Math.abs(targetPrice - bestOrder.displayPrice);
|
||||
|
||||
// The price will be elevated to match the ticket price
|
||||
// We'll use the original price ratio to determine the new display price
|
||||
const originalPrice = usePartial
|
||||
? bestOrder.pricePerPassenger
|
||||
: bestOrder.displayPrice;
|
||||
|
||||
// Only elevate if the price difference is reasonable
|
||||
const priceDiffPercentage =
|
||||
(Math.abs(targetPrice - originalPrice) / targetPrice) * 100;
|
||||
if (priceDiffPercentage > this.ELEVATED_THRESHOLD_DIFF_PERCENTAGE) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// if (usePartial) {
|
||||
// bestOrder.pricePerPassenger =
|
||||
// } else {
|
||||
// }
|
||||
|
||||
return [{ order: bestOrder, usePartialAmount: !!usePartial }];
|
||||
}
|
||||
|
||||
private getCombinations<T>(arr: T[], size: number): T[][] {
|
||||
if (size === 0) return [[]];
|
||||
if (arr.length === 0) return [];
|
||||
|
||||
const first = arr[0];
|
||||
const rest = arr.slice(1);
|
||||
|
||||
const combosWithoutFirst = this.getCombinations(rest, size);
|
||||
const combosWithFirst = this.getCombinations(rest, size - 1).map(
|
||||
(combo) => [first, ...combo],
|
||||
);
|
||||
return [...combosWithoutFirst, ...combosWithFirst];
|
||||
}
|
||||
|
||||
private isOrderSuitable(
|
||||
orderId: number,
|
||||
orderPrice: number,
|
||||
ticketPrice: number,
|
||||
diffPercentage: number,
|
||||
) {
|
||||
return (
|
||||
diffPercentage > 0 &&
|
||||
diffPercentage <= this.THRESHOLD_DIFF_PERCENTAGE &&
|
||||
orderPrice <= ticketPrice &&
|
||||
!this.isOrderUsedUp(orderId, orderPrice)
|
||||
);
|
||||
}
|
||||
|
||||
private upsertPartialOrderToCache(
|
||||
oid: number,
|
||||
price: number,
|
||||
_defaultPrice: number = 0,
|
||||
) {
|
||||
if (!this.reservedPartialOrders[oid]) {
|
||||
this.reservedPartialOrders[oid] = _defaultPrice;
|
||||
return;
|
||||
}
|
||||
this.reservedPartialOrders[oid] += price;
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
import type { SelectOption } from "$lib/core/data.types";
|
||||
import { capitalize } from "$lib/core/string.utils";
|
||||
|
||||
let {
|
||||
opts,
|
||||
onselect,
|
||||
}: { opts: SelectOption[]; onselect: (e: string) => void } = $props();
|
||||
|
||||
let chosenOpt = $state<SelectOption | undefined>(undefined);
|
||||
|
||||
function setOpt(val: string) {
|
||||
chosenOpt = opts.find((e) => e.value === val);
|
||||
onselect(val);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Select.Root type="single" required onValueChange={(e) => setOpt(e)} name="role">
|
||||
<Select.Trigger class="w-full border-border/20">
|
||||
{capitalize(
|
||||
opts?.find((e) => e.value === chosenOpt?.value)?.label ??
|
||||
"Cabin Class",
|
||||
)}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each opts as each}
|
||||
<Select.Item value={each.value}>
|
||||
{each.label}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user