admin side for now | 🔄 started FE

This commit is contained in:
user
2025-10-21 13:11:31 +03:00
parent 3232542de1
commit 5f4e9fc7fc
65 changed files with 3605 additions and 1508 deletions

View File

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

View File

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

View File

@@ -1,52 +0,0 @@
import { createTRPCRouter } from "$lib/trpc/t";
import { z } from "zod";
import { getCouponUseCases } from "./usecases";
import { createCouponPayload, updateCouponPayload } from "./data";
import { protectedProcedure } from "$lib/server/trpc/t";
export const couponRouter = createTRPCRouter({
getAllCoupons: protectedProcedure.query(async ({}) => {
const controller = getCouponUseCases();
return controller.getAllCoupons();
}),
getCouponById: protectedProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const controller = getCouponUseCases();
return controller.getCouponById(input.id);
}),
createCoupon: protectedProcedure
.input(createCouponPayload)
.mutation(async ({ input, ctx }) => {
const controller = getCouponUseCases();
return controller.createCoupon(ctx.user, input);
}),
updateCoupon: protectedProcedure
.input(updateCouponPayload)
.mutation(async ({ input }) => {
const controller = getCouponUseCases();
return controller.updateCoupon(input);
}),
deleteCoupon: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
const controller = getCouponUseCases();
return controller.deleteCoupon(input.id);
}),
toggleCouponStatus: protectedProcedure
.input(z.object({ id: z.number(), isActive: z.boolean() }))
.mutation(async ({ input }) => {
const controller = getCouponUseCases();
return controller.toggleCouponStatus(input.id, input.isActive);
}),
getActiveCoupons: protectedProcedure.query(async ({}) => {
const controller = getCouponUseCases();
return controller.getActiveCoupons();
}),
});

View File

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

View File

@@ -1,187 +0,0 @@
<script lang="ts">
import Input from "$lib/components/ui/input/input.svelte";
import Button from "$lib/components/ui/button/button.svelte";
import { Label } from "$lib/components/ui/label";
import * as Select from "$lib/components/ui/select";
import {
type CreateCouponPayload,
type UpdateCouponPayload,
DiscountType,
} from "$lib/domains/coupon/data";
import { Textarea } from "$lib/components/ui/textarea";
export let formData: CreateCouponPayload | UpdateCouponPayload;
export let loading = false;
export let onSubmit: () => void;
export let onCancel: () => void;
const isNewCoupon = !("id" in formData);
const discountTypes = [
{ value: DiscountType.PERCENTAGE, label: "Percentage" },
{ value: DiscountType.FIXED, label: "Fixed Amount" },
];
</script>
<form on:submit|preventDefault={onSubmit} class="space-y-6">
<div class="grid grid-cols-2 gap-4">
<!-- Code -->
<div class="space-y-2">
<Label for="code">Coupon Code</Label>
<Input
id="code"
bind:value={formData.code}
placeholder="e.g. SUMMER20"
required
/>
</div>
<!-- Discount Type -->
<div class="space-y-2">
<Label for="discountType">Discount Type</Label>
<Select.Root
type="single"
required
bind:value={formData.discountType}
>
<Select.Trigger class="w-full">
{discountTypes.find((t) => t.value === formData.discountType)
?.label || "Select type"}
</Select.Trigger>
<Select.Content>
{#each discountTypes as type}
<Select.Item value={type.value}>{type.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</div>
<!-- Discount Value -->
<div class="space-y-2">
<Label for="discountValue">
{formData.discountType === DiscountType.PERCENTAGE
? "Discount Percentage"
: "Discount Amount"}
</Label>
<div class="relative">
<Input
id="discountValue"
type="number"
min={0}
max={formData.discountType === DiscountType.PERCENTAGE
? 100
: undefined}
step="0.01"
bind:value={formData.discountValue}
required
/>
<div
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3"
>
<span class="text-gray-500">
{formData.discountType === DiscountType.PERCENTAGE
? "%"
: "$"}
</span>
</div>
</div>
</div>
<!-- Description -->
<div class="space-y-2">
<Label for="description">Description</Label>
<Textarea
id="description"
bind:value={formData.description}
placeholder="Describe what this coupon is for"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<!-- Start Date -->
<div class="space-y-2">
<Label for="startDate">Start Date</Label>
<Input
id="startDate"
type="date"
bind:value={formData.startDate}
required
/>
</div>
<!-- End Date -->
<div class="space-y-2">
<Label for="endDate">End Date (Optional)</Label>
<Input id="endDate" type="date" bind:value={formData.endDate} />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<!-- Max Usage Count -->
<div class="space-y-2">
<Label for="maxUsageCount">Max Usage Count (Optional)</Label>
<Input
id="maxUsageCount"
type="number"
min={1}
step={1}
bind:value={formData.maxUsageCount}
placeholder="Leave empty for unlimited"
/>
</div>
<!-- Max Discount Amount -->
<div class="space-y-2">
<Label for="maxDiscountAmount">Max Discount Amount (Optional)</Label>
<div class="relative">
<Input
id="maxDiscountAmount"
type="number"
min={0}
step="0.01"
bind:value={formData.maxDiscountAmount}
placeholder="Leave empty for no limit"
/>
<div
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3"
>
<span class="text-gray-500">$</span>
</div>
</div>
</div>
</div>
<!-- Min Order Value -->
<div class="space-y-2">
<Label for="minOrderValue">Minimum Order Value (Optional)</Label>
<div class="relative">
<Input
id="minOrderValue"
type="number"
min={0}
step="0.01"
bind:value={formData.minOrderValue}
placeholder="Leave empty for no minimum"
/>
<div
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3"
>
<span class="text-gray-500">$</span>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="flex justify-end space-x-2">
<Button variant="outline" onclick={onCancel} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{#if loading}
Processing...
{:else}
{isNewCoupon ? "Create Coupon" : "Update Coupon"}
{/if}
</Button>
</div>
</form>

View File

@@ -1,297 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import { couponVM } from "$lib/domains/coupon/coupon.vm.svelte";
import Button from "$lib/components/ui/button/button.svelte";
import * as Dialog from "$lib/components/ui/dialog";
import * as AlertDialog from "$lib/components/ui/alert-dialog/index";
import { Badge } from "$lib/components/ui/badge";
import CouponForm from "./coupon-form.svelte";
import Title from "$lib/components/atoms/title.svelte";
import {
PlusIcon,
Pencil,
Trash2,
CheckCircle,
XCircle,
} from "@lucide/svelte";
import Loader from "$lib/components/atoms/loader.svelte";
import { DiscountType, type CreateCouponPayload } from "../data";
let createDialogOpen = $state(false);
let editDialogOpen = $state(false);
let deleteDialogOpen = $state(false);
let newCouponData = $state<CreateCouponPayload>(couponVM.getDefaultCoupon());
async function handleCreateCoupon() {
const success = await couponVM.createCoupon(newCouponData);
if (success) {
createDialogOpen = false;
newCouponData = couponVM.getDefaultCoupon();
}
}
async function handleUpdateCoupon() {
if (!couponVM.selectedCoupon) return;
const success = await couponVM.updateCoupon({
id: couponVM.selectedCoupon.id! ?? -1,
...couponVM.selectedCoupon,
});
if (success) {
editDialogOpen = false;
}
}
async function handleDeleteCoupon() {
if (!couponVM.selectedCoupon) return;
const success = await couponVM.deleteCoupon(couponVM.selectedCoupon.id!);
if (success) {
deleteDialogOpen = false;
}
}
function formatDate(dateString: string | null | undefined) {
if (!dateString) return "None";
return new Date(dateString).toLocaleDateString();
}
function formatDiscountValue(type: DiscountType, value: number) {
if (type === DiscountType.PERCENTAGE) {
return `${value}%`;
} else {
return `$${value.toFixed(2)}`;
}
}
onMount(() => {
setTimeout(async () => {
couponVM.fetchCoupons();
}, 1000);
});
</script>
<div class="mb-6 flex items-center justify-between">
<Title size="h3" weight="semibold">Coupons</Title>
<Button onclick={() => (createDialogOpen = true)}>
<PlusIcon class="mr-2 h-4 w-4" />
New Coupon
</Button>
</div>
{#if couponVM.loading}
<div class="flex items-center justify-center py-20">
<Loader />
</div>
{:else if couponVM.coupons.length === 0}
<div class="rounded-lg border py-10 text-center">
<p class="text-gray-500">
No coupons found. Create your first coupon to get started.
</p>
</div>
{:else}
<div class="overflow-x-auto">
<table class="w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
>Code</th
>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
>Discount</th
>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
>Valid Period</th
>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
>Status</th
>
<th
class="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500"
>Actions</th
>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#each couponVM.coupons as coupon}
<tr>
<td class="whitespace-nowrap px-6 py-4">
<div class="font-medium text-gray-900">
{coupon.code}
</div>
{#if coupon.description}
<div class="text-sm text-gray-500">
{coupon.description}
</div>
{/if}
</td>
<td class="whitespace-nowrap px-6 py-4">
<Badge
>{formatDiscountValue(
coupon.discountType,
coupon.discountValue,
)}</Badge
>
{#if coupon.maxDiscountAmount}
<div class="mt-1 text-xs text-gray-500">
Max: ${coupon.maxDiscountAmount}
</div>
{/if}
</td>
<td class="whitespace-nowrap px-6 py-4">
<div>From: {formatDate(coupon.startDate)}</div>
<div>To: {formatDate(coupon.endDate)}</div>
</td>
<td class="whitespace-nowrap px-6 py-4">
{#if coupon.isActive}
<Badge
variant="success"
class="flex items-center gap-1"
>
<CheckCircle class="h-3 w-3" />
Active
</Badge>
{:else}
<Badge
variant="destructive"
class="flex items-center gap-1"
>
<XCircle class="h-3 w-3" />
Inactive
</Badge>
{/if}
</td>
<td
class="flex w-full items-center justify-end gap-2 whitespace-nowrap px-6 py-4 text-right"
>
<Button
variant="ghost"
size="sm"
onclick={() => {
couponVM.selectCoupon(coupon);
editDialogOpen = true;
}}
>
<Pencil class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onclick={() => {
couponVM.selectCoupon(coupon);
deleteDialogOpen = true;
}}
>
<Trash2 class="h-4 w-4" />
</Button>
<Button
variant={coupon.isActive
? "destructive"
: "default"}
size="sm"
onclick={() =>
couponVM.toggleCouponStatus(
coupon.id!,
!coupon.isActive,
)}
>
{coupon.isActive ? "Deactivate" : "Activate"}
</Button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<!-- Create Coupon Dialog -->
<Dialog.Root bind:open={createDialogOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Create New Coupon</Dialog.Title>
<Dialog.Description>
Create a new coupon to offer discounts on flight tickets.
</Dialog.Description>
</Dialog.Header>
<CouponForm
formData={newCouponData}
loading={couponVM.formLoading}
onSubmit={handleCreateCoupon}
onCancel={() => (createDialogOpen = false)}
/>
</Dialog.Content>
</Dialog.Root>
<!-- Edit Coupon Dialog -->
<Dialog.Root bind:open={editDialogOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Edit Coupon</Dialog.Title>
<Dialog.Description>Update this coupon's details.</Dialog.Description>
</Dialog.Header>
{#if couponVM.selectedCoupon}
<CouponForm
formData={couponVM.selectedCoupon}
loading={couponVM.formLoading}
onSubmit={handleUpdateCoupon}
onCancel={() => (editDialogOpen = false)}
/>
{/if}
</Dialog.Content>
</Dialog.Root>
<!-- Delete Confirmation Dialog -->
<AlertDialog.Root bind:open={deleteDialogOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Delete Coupon</AlertDialog.Title>
<AlertDialog.Description>
Are you sure you want to delete this coupon? This action cannot be
undone.
</AlertDialog.Description>
</AlertDialog.Header>
{#if couponVM.selectedCoupon}
<div class="mb-4 rounded-md bg-gray-100 p-4">
<div class="font-bold">{couponVM.selectedCoupon.code}</div>
{#if couponVM.selectedCoupon.description}
<div class="text-sm text-gray-600">
{couponVM.selectedCoupon.description}
</div>
{/if}
<div class="mt-1">
Discount: {formatDiscountValue(
couponVM.selectedCoupon.discountType,
couponVM.selectedCoupon.discountValue,
)}
</div>
</div>
{/if}
<AlertDialog.Footer>
<AlertDialog.Cancel onclick={() => (deleteDialogOpen = false)}>
Cancel
</AlertDialog.Cancel>
<AlertDialog.Action
onclick={handleDeleteCoupon}
disabled={couponVM.formLoading}
>
{couponVM.formLoading ? "Deleting..." : "Delete"}
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>

View File

@@ -1,6 +1,5 @@
import { authRouter } from "$lib/domains/auth/domain/router"; import { authRouter } from "$lib/domains/auth/domain/router";
import { ckflowRouter } from "$lib/domains/ckflow/router"; import { ckflowRouter } from "$lib/domains/ckflow/router";
import { couponRouter } from "$lib/domains/coupon/router";
import { customerInfoRouter } from "$lib/domains/customerinfo/router"; import { customerInfoRouter } from "$lib/domains/customerinfo/router";
import { orderRouter } from "$lib/domains/order/domain/router"; import { orderRouter } from "$lib/domains/order/domain/router";
import { productRouter } from "$lib/domains/product/router"; import { productRouter } from "$lib/domains/product/router";
@@ -12,7 +11,6 @@ export const router = createTRPCRouter({
user: userRouter, user: userRouter,
order: orderRouter, order: orderRouter,
ckflow: ckflowRouter, ckflow: ckflowRouter,
coupon: couponRouter,
product: productRouter, product: productRouter,
customerInfo: customerInfoRouter, customerInfo: customerInfoRouter,
}); });

View File

@@ -1,27 +0,0 @@
<script lang="ts">
import { pageTitle } from "$lib/hooks/page-title.svelte";
import CouponList from "$lib/domains/coupon/view/coupon-list.svelte";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "$lib/components/ui/tabs/index";
pageTitle.set("Settings");
</script>
<div class="container mx-auto py-8">
<!-- <Tabs value="coupons" class="w-full">
<TabsList class="mb-6">
<TabsTrigger value="coupons">Coupon Management</TabsTrigger>
<!-- Add more tabs here as needed -->
<!-- </TabsList> -->
<!-- <TabsContent value="coupons"> -->
<CouponList />
<!-- </TabsContent> -->
<!-- Add more tab content here as needed -->
<!-- </Tabs> -->
</div>

View File

@@ -0,0 +1 @@
<span>show checkout confirmation status here</span>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import Loader from "$lib/components/atoms/loader.svelte";
</script>
<div class="grid h-full w-full place-items-center p-4 py-20 md:p-8 md:py-24">
<Loader />
</div>

View File

@@ -0,0 +1,158 @@
<script lang="ts">
import Icon from "$lib/components/atoms/icon.svelte";
import { buttonVariants } from "$lib/components/ui/button/button.svelte";
import { Sheet, SheetContent, SheetTrigger } from "$lib/components/ui/sheet";
import { cn } from "$lib/utils";
import ChevronDownIcon from "~icons/lucide/chevron-down";
import CloseIcon from "~icons/lucide/x";
import { CheckoutStep } from "../../data/entities";
import { checkoutVM } from "./checkout.vm.svelte";
const checkoutSteps = [
{ id: CheckoutStep.Initial, label: "Passenger Details" },
{ id: CheckoutStep.Payment, label: "Payment" },
{ id: CheckoutStep.Verification, label: "Verify Details" },
{ id: CheckoutStep.Confirmation, label: "Confirmation" },
];
let activeStepIndex = $derived(
checkoutSteps.findIndex((step) => step.id === checkoutVM.checkoutStep),
);
function handleStepClick(clickedIndex: number, stepId: CheckoutStep) {
if (clickedIndex <= activeStepIndex) {
checkoutVM.checkoutStep = stepId;
}
}
let sheetOpen = $state(false);
</script>
<Sheet
bind:open={sheetOpen}
onOpenChange={(to) => {
sheetOpen = to;
}}
>
<SheetTrigger
class={cn(
buttonVariants({
variant: "secondary",
size: "lg",
}),
"my-8 flex w-full justify-between whitespace-normal break-all text-start lg:hidden",
)}
onclick={() => (sheetOpen = true)}
>
<div class="flex flex-col gap-1 xs:flex-row xs:items-center">
<span>
Step {activeStepIndex + 1}/{checkoutSteps.length}:
</span>
<span>
{checkoutSteps[activeStepIndex].label}
</span>
</div>
<Icon icon={ChevronDownIcon} cls="h-4 w-4" />
</SheetTrigger>
<SheetContent side="bottom">
<button
onclick={() => (sheetOpen = false)}
class="absolute right-4 top-4 grid place-items-center rounded-md border border-neutral-400 p-1 text-neutral-500"
>
<Icon icon={CloseIcon} cls="h-5 w-auto" />
</button>
<div class="mt-8 flex flex-col gap-2 overflow-y-auto">
{#each checkoutSteps as step, index}
<button
class={cn(
"flex items-center gap-3 rounded-lg border-2 p-3 text-left outline-none transition",
index <= activeStepIndex
? "border-brand-200 bg-primary/10 hover:bg-primary/20"
: "border-transparent bg-gray-100 opacity-50",
index === activeStepIndex && "border-brand-500",
)}
disabled={index > activeStepIndex}
onclick={() => {
handleStepClick(index, step.id);
sheetOpen = false;
}}
>
<div
class={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
index <= activeStepIndex
? "bg-primary text-white"
: "bg-gray-200 text-gray-600",
)}
>
{index + 1}
</div>
<span class="font-medium">
{step.label}
</span>
</button>
{/each}
</div>
</SheetContent>
</Sheet>
<div class="hidden w-full overflow-x-auto lg:block">
<div
class="flex w-full min-w-[30rem] items-center justify-between gap-2 overflow-x-auto py-8"
>
{#each checkoutSteps as step, index}
<div class="flex flex-1 items-center gap-2">
<div
class={cn(
"flex items-center justify-center",
index <= activeStepIndex
? "cursor-pointer"
: "cursor-not-allowed opacity-50",
)}
onclick={() => handleStepClick(index, step.id)}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
handleStepClick(index, step.id);
}
}}
role="button"
tabindex={index <= activeStepIndex ? 0 : -1}
>
<div
class={cn(
"flex h-10 min-h-10 w-10 min-w-10 items-center justify-center rounded-full border-2 transition-colors",
index <= activeStepIndex
? "hover:bg-primary-600 border-brand-700 bg-primary text-white/60"
: "border-gray-400 bg-gray-100 text-gray-700",
index === activeStepIndex
? "text-lg font-semibold text-white"
: "",
)}
>
{index + 1}
</div>
<span
class={cn(
"ml-2 hidden w-max text-sm md:block",
index <= activeStepIndex
? "font-semibold"
: "text-gray-800",
)}
>
{step.label}
</span>
</div>
{#if index !== checkoutSteps.length - 1}
<div
class={cn(
"h-0.5 w-full min-w-4 flex-1 border-t transition-colors",
index <= activeStepIndex
? "border-primary"
: "border-gray-400",
)}
></div>
{/if}
</div>
{/each}
</div>
</div>

View File

@@ -1,5 +1,5 @@
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte"; import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
import { newOrderModel } from "$lib/domains/order/data/entities"; import { CheckoutStep, newOrderModel } from "$lib/domains/order/data/entities";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte"; import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import { import {
paymentInfoPayloadModel, paymentInfoPayloadModel,
@@ -8,12 +8,11 @@ import {
import { trpcApiStore } from "$lib/stores/api"; import { trpcApiStore } from "$lib/stores/api";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { CheckoutStep } from "../../data/entities/index"; import { flightTicketStore } from "../ticket/data/store";
import { flightTicketStore } from "../../data/store";
import { paymentInfoVM } from "./payment-info-section/payment.info.vm.svelte"; import { paymentInfoVM } from "./payment-info-section/payment.info.vm.svelte";
import { calculateTicketPrices } from "./total.calculator"; import { calculateTicketPrices } from "./total.calculator";
class TicketCheckoutViewModel { class CheckoutViewModel {
checkoutStep = $state(CheckoutStep.Initial); checkoutStep = $state(CheckoutStep.Initial);
loading = $state(true); loading = $state(true);
continutingToNextStep = $state(false); continutingToNextStep = $state(false);
@@ -163,4 +162,4 @@ class TicketCheckoutViewModel {
} }
} }
export const ticketCheckoutVM = new TicketCheckoutViewModel(); export const checkoutVM = new CheckoutViewModel();

View File

@@ -0,0 +1,152 @@
<script lang="ts">
import ButtonLoadableText from "$lib/components/atoms/button-loadable-text.svelte";
import Icon from "$lib/components/atoms/icon.svelte";
import Title from "$lib/components/atoms/title.svelte";
import Badge from "$lib/components/ui/badge/badge.svelte";
import Button from "$lib/components/ui/button/button.svelte";
import { capitalize } from "$lib/core/string.utils";
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
import PassengerBagSelection from "$lib/domains/passengerinfo/view/passenger-bag-selection.svelte";
import PassengerPiiForm from "$lib/domains/passengerinfo/view/passenger-pii-form.svelte";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import { cn } from "$lib/utils";
import { onMount } from "svelte";
import { toast } from "svelte-sonner";
import RightArrowIcon from "~icons/solar/arrow-right-broken";
import { CheckoutStep } from "../../data/entities";
import { flightTicketStore } from "../../data/store";
import TripDetails from "../ticket/trip-details.svelte";
import { checkoutVM } from "./checkout.vm.svelte";
const cardStyle =
"flex w-full flex-col gap-4 rounded-lg bg-white p-4 shadow-lg md:p-8";
$effect(() => {
const primaryPassenger = passengerInfoVM.passengerInfos[0];
if (!ckFlowVM.flowId || !ckFlowVM.setupDone || !primaryPassenger) return;
const personalInfo = primaryPassenger.passengerPii;
if (!personalInfo) return;
// to trigger the effect
const {
phoneNumber,
email,
firstName,
lastName,
address,
address2,
zipCode,
city,
state,
country,
nationality,
gender,
dob,
passportNo,
passportExpiry,
} = personalInfo;
if (
firstName ||
lastName ||
email ||
phoneNumber ||
country ||
city ||
state ||
zipCode ||
address ||
address2 ||
nationality ||
gender ||
dob ||
passportNo ||
passportExpiry
) {
console.log("pi ping");
ckFlowVM.debouncePersonalInfoSync(personalInfo);
}
});
async function proceedToNextStep() {
passengerInfoVM.validateAllPII();
console.log(passengerInfoVM.piiErrors);
if (!passengerInfoVM.isPIIValid()) {
return toast.error("Some or all info is invalid", {
description: "Please properly fill out all of the fields",
});
}
checkoutVM.continutingToNextStep = true;
const out2 = await ckFlowVM.executePrePaymentStep();
if (!out2) {
return;
}
setTimeout(() => {
checkoutVM.continutingToNextStep = false;
checkoutVM.checkoutStep = CheckoutStep.Payment;
}, 2000);
}
onMount(() => {
window.scrollTo(0, 0);
setTimeout(() => {
passengerInfoVM.setupPassengerInfo(
$flightTicketStore.passengerCounts,
);
}, 200);
});
</script>
{#if $flightTicketStore}
<div class={cardStyle}>
<TripDetails data={$flightTicketStore} />
</div>
{#if passengerInfoVM.passengerInfos.length > 0}
{#each passengerInfoVM.passengerInfos as info, idx}
{@const name =
info.passengerPii.firstName.length > 0 ||
info.passengerPii.lastName.length > 0
? `${info.passengerPii.firstName} ${info.passengerPii.lastName}`
: `Passenger #${idx + 1}`}
<div class={cardStyle}>
<div class="flex flex-row items-center justify-between gap-4">
<Title size="h4" maxwidth="max-w-xs">
{name}
</Title>
<Badge variant="secondary" class="w-max">
{capitalize(info.passengerType)}
</Badge>
</div>
<div class={cn(cardStyle, "border-2 border-gray-200")}>
<Title size="h5">Personal Info</Title>
<PassengerPiiForm bind:info={info.passengerPii} {idx} />
</div>
<div class={cn(cardStyle, "border-2 border-gray-200")}>
<Title size="h5">Bag Selection</Title>
<PassengerBagSelection bind:info={info.bagSelection} />
</div>
</div>
{/each}
{/if}
<div class="flex flex-col items-center justify-between gap-4 md:flex-row">
<div></div>
<Button
variant="default"
onclick={proceedToNextStep}
class="w-full md:w-max"
disabled={checkoutVM.continutingToNextStep}
>
<ButtonLoadableText
text="Continue"
loadingText="Processing..."
loading={checkoutVM.continutingToNextStep}
/>
<Icon icon={RightArrowIcon} cls="w-auto h-6" />
</Button>
</div>
{/if}

View File

@@ -0,0 +1,132 @@
<script lang="ts">
import Icon from "$lib/components/atoms/icon.svelte";
import Title from "$lib/components/atoms/title.svelte";
import Button from "$lib/components/ui/button/button.svelte";
import Input from "$lib/components/ui/input/input.svelte";
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
import { toast } from "svelte-sonner";
import LockIcon from "~icons/solar/shield-keyhole-minimalistic-broken";
import ButtonLoadableText from "$lib/components/atoms/button-loadable-text.svelte";
let otpCode = $state("");
let submitting = $state(false);
let otpSyncTimeout: NodeJS.Timeout | null = null;
// Sync OTP as user types
function debounceOtpSync(value: string) {
if (otpSyncTimeout) {
clearTimeout(otpSyncTimeout);
}
otpSyncTimeout = setTimeout(() => {
ckFlowVM.syncPartialOTP(value);
}, 300);
}
function handleOtpInput(e: Event) {
const value = (e.target as HTMLInputElement).value;
otpCode = value;
debounceOtpSync(value);
}
async function submitOTP() {
if (otpCode.length < 4) {
toast.error("Invalid verification code", {
description:
"Please enter the complete code from your card provider",
});
return;
}
submitting = true;
try {
// Submit OTP to backend
const result = await ckFlowVM.submitOTP(otpCode);
if (result) {
toast.success("Verification submitted", {
description: "Processing your payment verification...",
});
// Update the flow to hide verification form but keep showVerification flag
if (ckFlowVM.info) {
await ckFlowVM.updateFlowState(ckFlowVM.info.flowId, {
...ckFlowVM.info,
showVerification: true,
otpSubmitted: true, // Add flag to track OTP submission
});
}
} else {
toast.error("Verification failed", {
description: "Please check your code and try again",
});
otpCode = ""; // Reset OTP field
return; // Don't proceed if submission failed
}
} catch (error) {
toast.error("Error processing verification", {
description: "Please try again later",
});
return; // Don't proceed if there was an error
} finally {
submitting = false;
}
}
</script>
<div class="flex flex-col items-center justify-center gap-8">
<div
class="flex w-full max-w-xl flex-col items-center justify-center gap-4 rounded-lg border bg-white p-8 text-center shadow-lg"
>
<div
class="grid h-16 w-16 place-items-center rounded-full bg-primary/10 text-primary"
>
<Icon icon={LockIcon} cls="h-8 w-8" />
</div>
<Title size="h4" center>Card Verification Required</Title>
<p class="max-w-md text-gray-600">
To complete your payment, please enter the verification code sent by
your bank or card provider (Visa, Mastercard, etc.). This code may
have been sent via SMS or email.
</p>
<div class="mt-4 flex w-full max-w-xs flex-col gap-4">
<Input
type="number"
placeholder="Card verification code"
maxlength={12}
value={otpCode}
oninput={handleOtpInput}
class="w-full"
/>
<Button
onclick={submitOTP}
disabled={otpCode.length < 4 || submitting}
class="w-full"
>
<ButtonLoadableText
text="Verify Payment"
loadingText="Verifying..."
loading={submitting}
/>
</Button>
</div>
</div>
<div
class="flex w-full max-w-xl flex-col gap-4 rounded-lg border bg-white p-6 shadow-lg"
>
<Title size="h5">Need Help?</Title>
<p class="text-gray-600">
If you haven't received a verification code from your bank or card
provider, please check your spam folder or contact your card issuer
directly. This verification is part of their security process for
online payments.
</p>
</div>
</div>

View File

@@ -0,0 +1,148 @@
<script lang="ts">
import LabelWrapper from "$lib/components/atoms/label-wrapper.svelte";
import Input from "$lib/components/ui/input/input.svelte";
import * as Select from "$lib/components/ui/select";
import { COUNTRIES_SELECT } from "$lib/core/countries";
import { capitalize } from "$lib/core/string.utils";
import type { CustomerInfo } from "$lib/domains/ticket/data/entities/create.entities";
import { billingDetailsVM } from "./billing.details.vm.svelte";
let { info = $bindable() }: { info: CustomerInfo } = $props();
function onSubmit(e: SubmitEvent) {
e.preventDefault();
billingDetailsVM.validatePII(info);
}
let validationTimeout = $state(undefined as undefined | NodeJS.Timer);
function debounceValidate() {
if (validationTimeout) {
clearTimeout(validationTimeout);
}
validationTimeout = setTimeout(() => {
billingDetailsVM.validatePII(info);
}, 500);
}
</script>
<form action="#" class="flex w-full flex-col gap-4" onsubmit={onSubmit}>
<div class="flex flex-col gap-4 md:flex-row">
<LabelWrapper
label="First Name"
error={billingDetailsVM.piiErrors.firstName}
>
<Input
placeholder="First Name"
bind:value={info.firstName}
required
oninput={() => debounceValidate()}
/>
</LabelWrapper>
<LabelWrapper
label="Middle Name"
error={billingDetailsVM.piiErrors.middleName}
>
<Input
placeholder="Middle Name"
bind:value={info.middleName}
oninput={() => debounceValidate()}
required
/>
</LabelWrapper>
<LabelWrapper
label="Last Name"
error={billingDetailsVM.piiErrors.lastName}
>
<Input
placeholder="Last Name"
bind:value={info.lastName}
required
oninput={() => debounceValidate()}
/>
</LabelWrapper>
</div>
<div class="flex flex-col gap-4 md:flex-row">
<LabelWrapper label="Country" error={billingDetailsVM.piiErrors.country}>
<Select.Root
type="single"
required
onValueChange={(e) => {
info.country = e;
debounceValidate();
}}
name="role"
>
<Select.Trigger class="w-full">
{capitalize(
info.country.length > 0 ? info.country : "Select",
)}
</Select.Trigger>
<Select.Content>
{#each COUNTRIES_SELECT as country}
<Select.Item value={country.value}>
{country.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</LabelWrapper>
<LabelWrapper label="State" error={billingDetailsVM.piiErrors.state}>
<Input
placeholder="State"
bind:value={info.state}
required
oninput={() => debounceValidate()}
/>
</LabelWrapper>
</div>
<div class="flex flex-col gap-4 md:flex-row">
<LabelWrapper label="City" error={billingDetailsVM.piiErrors.city}>
<Input
placeholder="City"
bind:value={info.city}
required
minlength={1}
maxlength={80}
oninput={() => debounceValidate()}
/>
</LabelWrapper>
<LabelWrapper label="Zip Code" error={billingDetailsVM.piiErrors.zipCode}>
<Input
placeholder="Zip Code"
bind:value={info.zipCode}
required
minlength={1}
oninput={() => debounceValidate()}
maxlength={12}
/>
</LabelWrapper>
</div>
<LabelWrapper label="Address" error={billingDetailsVM.piiErrors.address}>
<Input
placeholder="Address"
bind:value={info.address}
required
minlength={1}
maxlength={128}
oninput={() => debounceValidate()}
/>
</LabelWrapper>
<LabelWrapper label="Address 2" error={billingDetailsVM.piiErrors.address2}>
<Input
placeholder="Address 2"
bind:value={info.address2}
required
minlength={1}
maxlength={128}
/>
</LabelWrapper>
</form>

View File

@@ -0,0 +1,70 @@
import {
customerInfoModel,
type CustomerInfo,
} from "$lib/domains/ticket/data/entities/create.entities";
import { Gender } from "$lib/domains/ticket/data/entities/index";
import { z } from "zod";
export class BillingDetailsViewModel {
// @ts-ignore
billingDetails = $state<CustomerInfo>(undefined);
piiErrors = $state<Partial<Record<keyof CustomerInfo, string>>>({});
constructor() {
this.reset();
}
reset() {
this.billingDetails = {
firstName: "",
middleName: "",
lastName: "",
email: "",
phoneCountryCode: "",
phoneNumber: "",
passportNo: "",
passportExpiry: "",
nationality: "",
gender: Gender.Male,
dob: "",
country: "",
state: "",
city: "",
zipCode: "",
address: "",
address2: "",
} as CustomerInfo;
this.piiErrors = {};
}
setPII(info: CustomerInfo) {
this.billingDetails = info;
}
validatePII(info: CustomerInfo) {
try {
const result = customerInfoModel.parse(info);
this.piiErrors = {};
return result;
} catch (error) {
if (error instanceof z.ZodError) {
this.piiErrors = error.errors.reduce(
(acc, curr) => {
const path = curr.path[0] as keyof CustomerInfo;
acc[path] = curr.message;
return acc;
},
{} as Record<keyof CustomerInfo, string>,
);
}
return null;
}
}
isPIIValid(): boolean {
return Object.keys(this.piiErrors).length === 0;
}
}
export const billingDetailsVM = new BillingDetailsViewModel();

View File

@@ -0,0 +1,193 @@
<script lang="ts">
import ButtonLoadableText from "$lib/components/atoms/button-loadable-text.svelte";
import Icon from "$lib/components/atoms/icon.svelte";
import Title from "$lib/components/atoms/title.svelte";
import Button, {
buttonVariants,
} from "$lib/components/ui/button/button.svelte";
import * as Dialog from "$lib/components/ui/dialog";
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
import { CheckoutStep } from "$lib/domains/order/data/entities";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import { flightTicketStore } from "$lib/domains/ticket/data/store";
import TicketDetailsModal from "$lib/domains/ticket/view/ticket/ticket-details-modal.svelte";
import { cn } from "$lib/utils";
import { formatDate } from "@pkg/logic/core/date.utils";
import { onMount } from "svelte";
import { toast } from "svelte-sonner";
import RightArrowIcon from "~icons/solar/arrow-right-broken";
import ArrowsExchangeIcon from "~icons/tabler/arrows-exchange-2";
import { checkoutVM } from "../checkout.vm.svelte";
import BillingDetailsForm from "./billing-details-form.svelte";
import { billingDetailsVM } from "./billing.details.vm.svelte";
import OrderSummary from "./order-summary.svelte";
import PaymentForm from "./payment-form.svelte";
import { paymentInfoVM } from "./payment.info.vm.svelte";
const cardStyle =
"flex w-full flex-col gap-4 rounded-lg bg-white p-4 shadow-lg md:p-8";
async function goBack() {
if ((await ckFlowVM.onBackToPIIBtnClick()) !== true) {
return;
}
checkoutVM.checkoutStep = CheckoutStep.Initial;
}
async function handleSubmit() {
const validatedData = await paymentInfoVM.validateAndSubmit();
if (!validatedData) {
return;
}
const validBillingInfo = billingDetailsVM.validatePII(
billingDetailsVM.billingDetails,
);
if (!validBillingInfo) {
return;
}
checkoutVM.continutingToNextStep = true;
const out = await ckFlowVM.executePaymentStep();
if (out !== true) {
return;
}
setTimeout(() => {
checkoutVM.continutingToNextStep = false;
checkoutVM.checkoutStep = CheckoutStep.Verification;
}, 1000);
}
let outboundFlight = $derived(
$flightTicketStore?.flightIteneraries.outbound[0],
);
let inboundFlight = $derived(
$flightTicketStore?.flightIteneraries.inbound[0],
);
let isReturnFlight = $derived($flightTicketStore?.flightType === "Return");
$effect(() => {
if (!ckFlowVM.flowId || !ckFlowVM.setupDone) return;
if (!paymentInfoVM.cardDetails) return;
paymentInfoVM.cardDetails.cardNumber;
paymentInfoVM.cardDetails.cardholderName;
paymentInfoVM.cardDetails.cvv;
paymentInfoVM.cardDetails.expiry;
// Always sync payment info regardless of validation status
ckFlowVM.debouncePaymentInfoSync();
});
onMount(() => {
window.scrollTo(0, 0);
billingDetailsVM.validatePII(billingDetailsVM.billingDetails);
if (billingDetailsVM.isPIIValid()) {
console.log("Billing details are valid, not setting from pasenger");
return;
}
if (passengerInfoVM.passengerInfos.length > 0) {
billingDetailsVM.setPII(
passengerInfoVM.passengerInfos[0].passengerPii,
);
billingDetailsVM.validatePII(billingDetailsVM.billingDetails);
toast("Used billing details from primary passenger");
}
});
</script>
<div class="flex flex-col gap-6">
<div class={cardStyle}>
<Title size="h4">Trip Summary</Title>
<div
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"
>
<!-- Trip Summary -->
<div class="flex flex-col gap-4 md:gap-2">
<!-- Main Route Display -->
<div class="flex items-center gap-2 text-lg font-semibold">
<span>{outboundFlight?.departure.station.code}</span>
{#if isReturnFlight}
<Icon
icon={ArrowsExchangeIcon}
cls="w-5 h-5 text-gray-400 rotate-180"
/>
<span>{outboundFlight?.destination.station.code}</span>
{:else}
<Icon icon={RightArrowIcon} cls="w-5 h-5 text-gray-400" />
<span>{outboundFlight?.destination.station.code}</span>
{/if}
</div>
<!-- Dates Display -->
<div class="flex flex-col gap-1 text-sm text-gray-600 md:gap-0">
{#if isReturnFlight}
<div class="flex items-center gap-2">
<span>
{formatDate(outboundFlight?.departure.localTime)}
- {formatDate(inboundFlight.departure.localTime)}
</span>
</div>
{:else}
<div class="flex items-center gap-2">
<span>
{formatDate(outboundFlight?.departure.localTime)}
</span>
</div>
{/if}
</div>
</div>
<!-- View Details Button -->
<TicketDetailsModal
data={$flightTicketStore}
hideCheckoutBtn
onCheckoutBtnClick={() => {}}
>
<Dialog.Trigger
class={cn(
buttonVariants({ variant: "secondary" }),
"w-max text-start",
)}
>
View Full Details
</Dialog.Trigger>
</TicketDetailsModal>
</div>
</div>
<div class={cardStyle}>
<Title size="h4">Order Summary</Title>
<OrderSummary />
</div>
<div class={cardStyle}>
<Title size="h4">Billing Details</Title>
<BillingDetailsForm info={billingDetailsVM.billingDetails} />
</div>
<div class={cardStyle}>
<Title size="h4">Payment Details</Title>
<PaymentForm />
</div>
<div class="flex flex-col items-center justify-between gap-4 md:flex-row">
<Button variant="secondary" onclick={goBack} class="w-full md:w-max">
<Icon icon={RightArrowIcon} cls="w-auto h-6 rotate-180" />
Back
</Button>
<Button
variant="default"
onclick={handleSubmit}
class="w-full md:w-max"
disabled={checkoutVM.continutingToNextStep}
>
<ButtonLoadableText
text="Confirm & Pay"
loadingText="Processing info..."
loading={checkoutVM.continutingToNextStep}
/>
<Icon icon={RightArrowIcon} cls="w-auto h-6" />
</Button>
</div>
</div>

View File

@@ -0,0 +1,80 @@
<script lang="ts">
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import Icon from "$lib/components/atoms/icon.svelte";
import { capitalize } from "$lib/core/string.utils";
import BackpackIcon from "~icons/solar/backpack-linear";
import BagIcon from "~icons/lucide/briefcase";
import SuitcaseIcon from "~icons/bi/suitcase2";
import SeatIcon from "~icons/solar/armchair-2-linear";
</script>
<div class="flex flex-col gap-6">
{#each passengerInfoVM.passengerInfos as passenger, index}
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<span class="font-semibold">
Passenger {index + 1} ({capitalize(passenger.passengerType)})
</span>
</div>
<!-- Personal Info -->
<div class="rounded-lg border bg-gray-50 p-4">
<div class="grid grid-cols-2 gap-3 text-sm md:grid-cols-3">
<div>
<span class="text-gray-500">Name</span>
<p class="font-medium">
{passenger.passengerPii.firstName}
{passenger.passengerPii.lastName}
</p>
</div>
<div>
<span class="text-gray-500">Nationality</span>
<p class="font-medium">
{passenger.passengerPii.nationality}
</p>
</div>
<div>
<span class="text-gray-500">Date of Birth</span>
<p class="font-medium">{passenger.passengerPii.dob}</p>
</div>
</div>
</div>
<!-- Baggage Selection -->
<div class="flex flex-wrap gap-4 text-sm">
{#if passenger.bagSelection.personalBags > 0}
<div class="flex items-center gap-2">
<Icon icon={BackpackIcon} cls="h-5 w-5 text-gray-600" />
<span>Personal Item</span>
</div>
{/if}
{#if passenger.bagSelection.handBags > 0}
<div class="flex items-center gap-2">
<Icon icon={SuitcaseIcon} cls="h-5 w-5 text-gray-600" />
<span>{passenger.bagSelection.handBags} x Cabin Bag</span>
</div>
{/if}
{#if passenger.bagSelection.checkedBags > 0}
<div class="flex items-center gap-2">
<Icon icon={BagIcon} cls="h-5 w-5 text-gray-600" />
<span>
{passenger.bagSelection.checkedBags} x Checked Bag
</span>
</div>
{/if}
</div>
<!-- Seat Selection -->
{#if passenger.seatSelection.number}
<div class="flex items-center gap-2 text-sm">
<Icon icon={SeatIcon} cls="h-5 w-5 text-gray-600" />
<span>Seat {passenger.seatSelection.number}</span>
</div>
{/if}
</div>
{#if index < passengerInfoVM.passengerInfos.length - 1}
<div class="border-b border-dashed"></div>
{/if}
{/each}
</div>

View File

@@ -0,0 +1,106 @@
<script lang="ts">
import Input from "$lib/components/ui/input/input.svelte";
import LabelWrapper from "$lib/components/atoms/label-wrapper.svelte";
import { paymentInfoVM } from "./payment.info.vm.svelte";
import { chunk } from "$lib/core/array.utils";
function formatCardNumberForDisplay(value: string) {
// return in format "XXXX XXXX XXXX XXXX" from "XXXXXXXXXXXXXXXX"
const numbers = value.replace(/\D/g, "");
if (numbers.length > 4) {
return `${numbers.slice(0, 4)} ${numbers.slice(4, 8)} ${numbers.slice(
8,
12,
)} ${numbers.slice(12, numbers.length)}`;
}
return numbers.slice(0, 19);
}
function cleanupCardNo(value: string) {
return value.replace(/\D/g, "").slice(0, 16);
}
function formatExpiryDate(value: string) {
const numbers = value.replace(/\D/g, "");
if (numbers.length > 2) {
return `${numbers.slice(0, 2)}/${numbers.slice(2, 4)}`;
}
return numbers;
}
function formatCVV(value: string) {
return value.replace(/\D/g, "").slice(0, 4);
}
let validationTimeout = $state(undefined as undefined | NodeJS.Timer);
function debounceValidate() {
if (validationTimeout) {
clearTimeout(validationTimeout);
}
validationTimeout = setTimeout(() => {
paymentInfoVM.validateAndSubmit();
}, 500);
}
</script>
<form class="flex flex-col gap-4">
<LabelWrapper
label="Name on Card"
error={paymentInfoVM.errors.cardholderName}
>
<Input
type="text"
placeholder="John Doe"
bind:value={paymentInfoVM.cardDetails.cardholderName}
oninput={() => debounceValidate()}
/>
</LabelWrapper>
<LabelWrapper label="Card Number" error={paymentInfoVM.errors.cardNumber}>
<Input
type="text"
placeholder="1234 5678 9012 3456"
maxlength={19}
value={formatCardNumberForDisplay(
paymentInfoVM.cardDetails.cardNumber,
)}
oninput={(e) => {
paymentInfoVM.cardDetails.cardNumber = cleanupCardNo(
e.currentTarget.value,
);
debounceValidate();
}}
/>
</LabelWrapper>
<div class="grid grid-cols-2 gap-4">
<LabelWrapper label="Expiry Date" error={paymentInfoVM.errors.expiry}>
<Input
type="text"
placeholder="MM/YY"
bind:value={paymentInfoVM.cardDetails.expiry}
oninput={(e) => {
paymentInfoVM.cardDetails.expiry = formatExpiryDate(
e.currentTarget.value,
);
debounceValidate();
}}
/>
</LabelWrapper>
<LabelWrapper label="CVV" error={paymentInfoVM.errors.cvv}>
<Input
type="text"
placeholder="123"
bind:value={paymentInfoVM.cardDetails.cvv}
oninput={(e) => {
paymentInfoVM.cardDetails.cvv = formatCVV(
e.currentTarget.value,
);
debounceValidate();
}}
/>
</LabelWrapper>
</div>
</form>

View File

@@ -0,0 +1,40 @@
import {
type CardInfo,
cardInfoModel,
} from "$lib/domains/paymentinfo/data/entities";
import { z } from "zod";
const _default = { cardholderName: "", cardNumber: "", expiry: "", cvv: "" };
class PaymentInfoViewModel {
cardDetails = $state<CardInfo>({ ..._default });
errors = $state<Partial<Record<keyof CardInfo, string>>>({});
reset() {
this.cardDetails = { ..._default };
this.errors = {};
}
async validateAndSubmit() {
try {
const result = cardInfoModel.parse(this.cardDetails);
this.errors = {};
return result;
} catch (error) {
if (error instanceof z.ZodError) {
this.errors = error.errors.reduce(
(acc, curr) => {
const path = curr.path[0] as keyof CardInfo;
acc[path] = curr.message;
return acc;
},
{} as Record<keyof CardInfo, string>,
);
}
return null;
}
}
}
export const paymentInfoVM = new PaymentInfoViewModel();

View File

@@ -0,0 +1,148 @@
<script lang="ts">
import Title from "$lib/components/atoms/title.svelte";
import {
convertAndFormatCurrency,
currencyStore,
} from "$lib/domains/currency/view/currency.vm.svelte";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import { flightTicketStore } from "../../data/store";
import { calculateTicketPrices } from "./total.calculator";
let totals = $state(
calculateTicketPrices($flightTicketStore, passengerInfoVM.passengerInfos),
);
let changing = $state(false);
$effect(() => {
changing = true;
totals = calculateTicketPrices(
$flightTicketStore,
passengerInfoVM.passengerInfos,
);
changing = false;
});
flightTicketStore.subscribe((val) => {
changing = true;
totals = calculateTicketPrices(val, passengerInfoVM.passengerInfos);
changing = false;
});
</script>
<div class="flex flex-col gap-4 rounded-lg bg-white p-4 drop-shadow-lg md:p-8">
<Title size="h4" weight="medium">Payment Summary</Title>
<div class="h-0.5 w-full border-t-2 border-gray-200"></div>
{#if !changing}
<!-- Base Ticket Price Breakdown -->
<div class="flex flex-col gap-2">
<Title size="p" weight="medium">Base Ticket Price</Title>
<div class="flex justify-between text-sm">
<span>Total Ticket Price</span>
<span>{convertAndFormatCurrency(totals.baseTicketPrice)}</span>
</div>
<div class="ml-4 text-sm text-gray-600">
<span>
Price per passenger (x{passengerInfoVM.passengerInfos.length})
</span>
<span class="float-right">
{convertAndFormatCurrency(totals.pricePerPassenger)}
</span>
</div>
</div>
<!-- Baggage Costs -->
{#if totals.totalBaggageCost > 0}
<div class="mt-2 flex flex-col gap-2 border-t pt-2">
<Title size="p" weight="medium">Baggage Charges</Title>
{#each totals.passengerBaggageCosts as passengerBaggage}
{#if passengerBaggage.totalBaggageCost > 0}
<div class="flex flex-col gap-1">
<span class="text-sm font-medium">
{passengerBaggage.passengerName}
</span>
{#if passengerBaggage.personalBagCost > 0}
<div
class="ml-4 flex justify-between text-sm text-gray-600"
>
<span>Personal Bag</span>
<span>
{convertAndFormatCurrency(
passengerBaggage.personalBagCost,
)}
</span>
</div>
{/if}
{#if passengerBaggage.handBagCost > 0}
<div
class="ml-4 flex justify-between text-sm text-gray-600"
>
<span>Hand Baggage</span>
<span>
{convertAndFormatCurrency(
passengerBaggage.handBagCost,
)}
</span>
</div>
{/if}
{#if passengerBaggage.checkedBagCost > 0}
<div
class="ml-4 flex justify-between text-sm text-gray-600"
>
<span>Checked Baggage</span>
<span>
{convertAndFormatCurrency(
passengerBaggage.checkedBagCost,
)}
</span>
</div>
{/if}
</div>
{/if}
{/each}
<div class="flex justify-between text-sm font-medium">
<span>Total Baggage Charges</span>
<span
>{convertAndFormatCurrency(totals.totalBaggageCost)}</span
>
</div>
</div>
{/if}
<!-- Final Total -->
<div class="mt-4 flex flex-col gap-2 border-t pt-4">
<div class="flex justify-between text-sm">
<span>Subtotal</span>
<span>{convertAndFormatCurrency(totals.subtotal)}</span>
</div>
{#if totals.discountAmount > 0}
<div class="flex justify-between text-sm text-green-600">
<span>Discount</span>
<span>-{convertAndFormatCurrency(totals.discountAmount)}</span
>
</div>
{/if}
<div class="flex justify-between font-medium">
<Title size="h5" weight="medium"
>Total ({$currencyStore.code})</Title
>
<span>{convertAndFormatCurrency(totals.finalTotal)}</span>
</div>
</div>
{:else}
<div class="grid place-items-center p-2 text-center">
<span>Calculating . . .</span>
</div>
{/if}
<div class="mt-4 rounded-lg bg-gray-50 p-4 text-xs text-gray-600">
<p class="mb-2 font-medium">Important Information:</p>
<ul class="list-disc space-y-1 pl-4">
<li>Prices include all applicable taxes and fees</li>
<li>Cancellation and change fees may apply as per our policy</li>
<li>Additional baggage fees may apply based on airline policy</li>
</ul>
</div>
</div>

View File

@@ -0,0 +1,68 @@
<script lang="ts">
import Loader from "$lib/components/atoms/loader.svelte";
import { onDestroy, onMount } from "svelte";
const initialMessages = [
"Processing your payment securely...",
"Getting everything ready for you...",
"Setting up your transaction...",
"Starting the payment process...",
"Initiating secure payment...",
];
const fiveSecondMessages = [
"Almost there! Just finalizing your payment details...",
"Just a few more moments while we confirm everything...",
"We're processing your payment with care...",
"Double-checking all the details...",
"Making sure everything is in order...",
];
const tenSecondMessages = [
"Thank you for your patience. We're making sure everything is perfect...",
"Still working on it thanks for being patient...",
"We're double-checking everything to ensure a smooth transaction...",
"Nearly there! Just completing the final security checks...",
"Your patience is appreciated while we process this securely...",
];
const twentySecondMessages = [
"Still working on it! Your transaction security is our top priority...",
"We appreciate your continued patience while we secure your transaction...",
"Taking extra care to process your payment safely...",
"Still working diligently to complete your transaction...",
"Thank you for waiting we're ensuring everything is processed correctly...",
];
const getRandomMessage = (messages: string[]) => {
return messages[Math.floor(Math.random() * messages.length)];
};
let _defaultTxt = getRandomMessage(initialMessages);
let txt = $state(_defaultTxt);
onMount(() => {
setTimeout(() => {
txt = getRandomMessage(fiveSecondMessages);
}, 5000);
setTimeout(() => {
txt = getRandomMessage(tenSecondMessages);
}, 10000);
setTimeout(() => {
txt = getRandomMessage(twentySecondMessages);
}, 20000);
});
onDestroy(() => {
txt = _defaultTxt;
});
</script>
<div
class="flex h-full w-full flex-col place-items-center p-4 py-20 md:p-8 md:py-32"
>
<Loader />
<p class="animate-pulse py-20 text-center">{txt}</p>
</div>

View File

@@ -0,0 +1,66 @@
<script lang="ts">
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
import { onDestroy, onMount } from "svelte";
import { checkoutVM } from "./checkout.vm.svelte";
import OtpVerificationSection from "./otp-verification-section.svelte";
import PaymentVerificationLoader from "./payment-verification-loader.svelte";
let refreshIntervalId: NodeJS.Timer;
// Function to check if we need to show the OTP form
function shouldShowOtpForm() {
return (
ckFlowVM.info?.showVerification &&
ckFlowVM.flowId &&
!ckFlowVM.info?.otpSubmitted
);
}
let showOtpVerificationForm = $state(shouldShowOtpForm());
// Refresh the OTP form visibility state based on the latest flow info
function refreshOtpState() {
showOtpVerificationForm = shouldShowOtpForm();
}
// Listen for changes to ckFlowVM.info
$effect(() => {
if (ckFlowVM.info) {
refreshOtpState();
}
});
function gototop() {
window.scrollTo(0, 0);
return true;
}
onMount(() => {
// Set up interval to check for OTP state changes
refreshIntervalId = setInterval(() => {
refreshOtpState();
}, 1000);
const lower = 1000;
const upper = 10_000;
const rng = Math.floor(Math.random() * (upper - lower + 1)) + lower;
setTimeout(async () => {
if (ckFlowVM.setupDone && !ckFlowVM.flowId) {
console.log("Shortcut - Checking out");
await checkoutVM.checkout();
}
}, rng);
});
onDestroy(() => {
clearInterval(refreshIntervalId);
});
</script>
{#if showOtpVerificationForm}
{@const done = gototop()}
<OtpVerificationSection />
{:else}
{@const done2 = gototop()}
<PaymentVerificationLoader />
{/if}

View File

@@ -0,0 +1,95 @@
import type { PassengerInfo } from "$lib/domains/passengerinfo/data/entities";
import type { FlightTicket } from "../ticket/data/entities";
export interface BaggageCost {
passengerId: number;
passengerName: string;
personalBagCost: number;
handBagCost: number;
checkedBagCost: number;
totalBaggageCost: number;
}
export interface PriceBreakdown {
baseTicketPrice: number;
pricePerPassenger: number;
passengerBaggageCosts: BaggageCost[];
totalBaggageCost: number;
subtotal: number;
discountAmount: number;
finalTotal: number;
}
export function calculateTicketPrices(
ticket: FlightTicket,
passengerInfos: PassengerInfo[],
): PriceBreakdown {
if (!ticket || !passengerInfos || passengerInfos.length === 0) {
return {
baseTicketPrice: 0,
pricePerPassenger: 0,
passengerBaggageCosts: [],
totalBaggageCost: 0,
subtotal: 0,
discountAmount: 0,
finalTotal: 0,
};
}
const displayPrice = ticket.priceDetails?.displayPrice ?? 0;
const originalBasePrice = ticket.priceDetails?.basePrice ?? 0;
const baseTicketPrice = Math.max(displayPrice, originalBasePrice);
const pricePerPassenger =
passengerInfos.length > 0
? baseTicketPrice / passengerInfos.length
: baseTicketPrice;
const passengerBaggageCosts: BaggageCost[] = passengerInfos.map(
(passenger) => {
// const personalBagCost =
// (passenger.bagSelection.personalBags || 0) *
// (ticket?.bagsInfo.details.personalBags.price ?? 0);
// const handBagCost =
// (passenger.bagSelection.handBags || 0) *
// (ticket?.bagsInfo.details.handBags.price ?? 0);
// const checkedBagCost =
// (passenger.bagSelection.checkedBags || 0) *
// (ticket?.bagsInfo.details.checkedBags.price ?? 0);
return {
passengerId: passenger.id,
passengerName: `${passenger.passengerPii.firstName} ${passenger.passengerPii.lastName}`,
personalBagCost: 0,
handBagCost: 0,
checkedBagCost: 0,
totalBaggageCost: 0,
// totalBaggageCost: personalBagCost + handBagCost + checkedBagCost,
};
},
);
// const totalBaggageCost = passengerBaggageCosts.reduce(
// (acc, curr) => acc + curr.totalBaggageCost,
// 0,
// );
const totalBaggageCost = 0;
const subtotal = baseTicketPrice + totalBaggageCost;
const discountAmount =
originalBasePrice > displayPrice
? (ticket?.priceDetails.discountAmount ?? 0)
: 0;
const finalTotal = subtotal - discountAmount;
return {
baseTicketPrice,
pricePerPassenger,
passengerBaggageCosts,
totalBaggageCost,
subtotal,
discountAmount,
finalTotal,
};
}

View File

@@ -0,0 +1,67 @@
<script lang="ts">
import ButtonLoadableText from "$lib/components/atoms/button-loadable-text.svelte";
import Title from "$lib/components/atoms/title.svelte";
import * as AlertDialog from "$lib/components/ui/alert-dialog/index.js";
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
import { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte";
import { flightTicketVM } from "$lib/domains/ticket/view/ticket.vm.svelte";
async function onPriceUpdateConfirm() {
if (!ckFlowVM.updatedPrices) {
return;
}
await flightTicketVM.updateTicketPrices(ckFlowVM.updatedPrices);
ckFlowVM.clearUpdatedPrices();
}
function cancelBooking() {
window.location.replace("/search");
}
let open = $state(false);
$effect(() => {
open = !!ckFlowVM.updatedPrices;
});
</script>
<AlertDialog.Root bind:open>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>The price has changed!</AlertDialog.Title>
<AlertDialog.Description>
Ticket prices change throughout the day and, unfortunately, the
price has been changed since last we had checked. You can continue
with the new price or check out alternative trips.
</AlertDialog.Description>
</AlertDialog.Header>
<div class="flex flex-col gap-1">
<Title size="h5" color="black">New Price</Title>
<Title size="h4" color="black" weight="semibold">
{convertAndFormatCurrency(
ckFlowVM.updatedPrices?.displayPrice ?? 0,
)}
</Title>
</div>
<AlertDialog.Footer>
<AlertDialog.Cancel
disabled={flightTicketVM.updatingPrices}
onclick={() => cancelBooking()}
>
Go Back
</AlertDialog.Cancel>
<AlertDialog.Action
disabled={flightTicketVM.updatingPrices}
onclick={() => onPriceUpdateConfirm()}
>
<ButtonLoadableText
loading={flightTicketVM.updatingPrices}
text={"Continue"}
loadingText={"Updating..."}
/>
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>

View File

@@ -1,6 +1,6 @@
import { CheckoutStep } from "$lib/domains/order/data/entities";
import type { CustomerInfo } from "$lib/domains/passengerinfo/data/entities"; import type { CustomerInfo } from "$lib/domains/passengerinfo/data/entities";
import type { PaymentInfoPayload } from "$lib/domains/paymentinfo/data/entities"; import type { PaymentInfoPayload } from "$lib/domains/paymentinfo/data/entities";
import { CheckoutStep } from "$lib/domains/ticket/data/entities";
import type { Database } from "@pkg/db"; import type { Database } from "@pkg/db";
import { and, eq } from "@pkg/db"; import { and, eq } from "@pkg/db";
import { checkoutFlowSession } from "@pkg/db/schema"; import { checkoutFlowSession } from "@pkg/db/schema";
@@ -42,7 +42,7 @@ export class CheckoutFlowRepository {
userAgent: payload.userAgent, userAgent: payload.userAgent,
reserved: false, reserved: false,
sessionOutcome: SessionOutcome.PENDING, sessionOutcome: SessionOutcome.PENDING,
ticketId: payload.ticketId, product: payload.productId,
}); });
return { data: flowId }; return { data: flowId };

View File

@@ -18,7 +18,7 @@ import {
type FlightPriceDetails, type FlightPriceDetails,
} from "$lib/domains/ticket/data/entities"; } from "$lib/domains/ticket/data/entities";
import { flightTicketStore } from "$lib/domains/ticket/data/store"; import { flightTicketStore } from "$lib/domains/ticket/data/store";
import { ticketCheckoutVM } from "$lib/domains/ticket/view/checkout/flight-checkout.vm.svelte"; import { checkoutVM } from "$lib/domains/ticket/view/checkout/checkout.vm.svelte";
import { billingDetailsVM } from "$lib/domains/ticket/view/checkout/payment-info-section/billing.details.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"; import { paymentInfoVM } from "$lib/domains/ticket/view/checkout/payment-info-section/payment.info.vm.svelte";
import { trpcApiStore } from "$lib/stores/api"; import { trpcApiStore } from "$lib/stores/api";
@@ -61,7 +61,7 @@ class ActionRunner {
} }
} }
private async completeOrder(data: any) { private async completeOrder(data: any) {
const ok = await ticketCheckoutVM.checkout(); const ok = await checkoutVM.checkout();
if (!ok) return; if (!ok) return;
const cleanupSuccess = await ckFlowVM.cleanupFlowInfo( const cleanupSuccess = await ckFlowVM.cleanupFlowInfo(
@@ -117,7 +117,7 @@ class ActionRunner {
await ckFlowVM.refreshFlowInfo(false); await ckFlowVM.refreshFlowInfo(false);
ticketCheckoutVM.checkoutStep = CheckoutStep.Verification; checkoutVM.checkoutStep = CheckoutStep.Verification;
toast.info("Verification required", { toast.info("Verification required", {
description: "Please enter the verification code sent to your device", description: "Please enter the verification code sent to your device",
}); });
@@ -131,7 +131,7 @@ class ActionRunner {
toast.error("Some information provided is not valid", { toast.error("Some information provided is not valid", {
description: "Please double check your info & try again", description: "Please double check your info & try again",
}); });
ticketCheckoutVM.checkoutStep = CheckoutStep.Initial; checkoutVM.checkoutStep = CheckoutStep.Initial;
} }
private async backToPayment(action: PendingAction) { private async backToPayment(action: PendingAction) {
@@ -155,13 +155,13 @@ class ActionRunner {
duration: 6000, duration: 6000,
}); });
ticketCheckoutVM.checkoutStep = CheckoutStep.Payment; checkoutVM.checkoutStep = CheckoutStep.Payment;
} }
private async terminateSession() { private async terminateSession() {
await ckFlowVM.cleanupFlowInfo(); await ckFlowVM.cleanupFlowInfo();
ckFlowVM.reset(); ckFlowVM.reset();
ticketCheckoutVM.reset(); checkoutVM.reset();
const tid = page.params.tid as any as string; const tid = page.params.tid as any as string;
const sid = page.params.sid as any as string; const sid = page.params.sid as any as string;
window.location.replace(`/checkout/terminated?sid=${sid}&tid=${tid}`); window.location.replace(`/checkout/terminated?sid=${sid}&tid=${tid}`);

View File

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

View File

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

View File

@@ -0,0 +1,40 @@
import { protectedProcedure } from "$lib/server/trpc/t";
import { createTRPCRouter } from "$lib/trpc/t";
import { z } from "zod";
import { createCustomerInfoPayload, updateCustomerInfoPayload } from "./data";
import { getCustomerInfoUseCases } from "./usecases";
export const customerInfoRouter = createTRPCRouter({
getAllCustomerInfo: protectedProcedure.query(async ({}) => {
const controller = getCustomerInfoUseCases();
return controller.getAllCustomerInfo();
}),
getCustomerInfoById: protectedProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const controller = getCustomerInfoUseCases();
return controller.getCustomerInfoById(input.id);
}),
createCustomerInfo: protectedProcedure
.input(createCustomerInfoPayload)
.mutation(async ({ input }) => {
const controller = getCustomerInfoUseCases();
return controller.createCustomerInfo(input);
}),
updateCustomerInfo: protectedProcedure
.input(updateCustomerInfoPayload)
.mutation(async ({ input }) => {
const controller = getCustomerInfoUseCases();
return controller.updateCustomerInfo(input);
}),
deleteCustomerInfo: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
const controller = getCustomerInfoUseCases();
return controller.deleteCustomerInfo(input.id);
}),
});

View File

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

View File

@@ -0,0 +1,322 @@
<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";
let { info = $bindable(), idx }: { info: CustomerInfo; idx: number } =
$props();
const genderOpts = [
{ label: capitalize(Gender.Male), value: Gender.Male },
{ label: capitalize(Gender.Female), value: Gender.Female },
{ label: capitalize(Gender.Other), value: Gender.Other },
] as SelectOption[];
function onSubmit(e: SubmitEvent) {
e.preventDefault();
passengerInfoVM.validatePII(info, idx);
}
let validationTimeout = $state(undefined as undefined | NodeJS.Timer);
function debounceValidate() {
if (validationTimeout) {
clearTimeout(validationTimeout);
}
validationTimeout = setTimeout(() => {
passengerInfoVM.validatePII(info, idx);
}, 500);
}
</script>
<form action="#" class="flex w-full flex-col gap-4" onsubmit={onSubmit}>
<div class="flex flex-col gap-4 md:flex-row">
<LabelWrapper
label="First Name"
error={passengerInfoVM.piiErrors[idx].firstName}
>
<Input
placeholder="First Name"
bind:value={info.firstName}
required
oninput={() => debounceValidate()}
/>
</LabelWrapper>
<LabelWrapper
label="Middle Name"
error={passengerInfoVM.piiErrors[idx].middleName}
>
<Input
placeholder="Middle Name"
bind:value={info.middleName}
oninput={() => debounceValidate()}
required
/>
</LabelWrapper>
<LabelWrapper
label="Last Name"
error={passengerInfoVM.piiErrors[idx].lastName}
>
<Input
placeholder="Last Name"
bind:value={info.lastName}
required
oninput={() => debounceValidate()}
/>
</LabelWrapper>
</div>
<LabelWrapper label="Email" error={passengerInfoVM.piiErrors[idx].email}>
<Input
placeholder="Email"
bind:value={info.email}
type="email"
oninput={() => debounceValidate()}
required
/>
</LabelWrapper>
<div class="flex flex-col gap-4 md:flex-row lg:flex-col xl:flex-row">
<LabelWrapper
label="Phone Number"
error={passengerInfoVM.piiErrors[idx].phoneNumber}
>
<div class="flex gap-2">
<Select.Root
type="single"
required
onValueChange={(code) => {
info.phoneCountryCode = code;
}}
name="phoneCode"
>
<Select.Trigger class="w-20">
{#if info.phoneCountryCode}
{info.phoneCountryCode}
{:else}
Select
{/if}
</Select.Trigger>
<Select.Content>
{#each PHONE_COUNTRY_CODES as { country, phoneCode }}
<Select.Item value={phoneCode}>
<span class="flex items-center gap-2">
{phoneCode} ({country})
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Input
placeholder="Phone Number"
type="tel"
bind:value={info.phoneNumber}
required
oninput={() => debounceValidate()}
class="flex-1"
/>
</div>
</LabelWrapper>
<LabelWrapper
label="Passport Expiry"
error={passengerInfoVM.piiErrors[idx].passportExpiry}
>
<Input
placeholder="Passport Expiry"
value={info.passportExpiry}
type="date"
required
oninput={(v) => {
// @ts-ignore
info.passportExpiry = v.target.value;
debounceValidate();
}}
/>
</LabelWrapper>
<LabelWrapper
label="Passport/ID No"
error={passengerInfoVM.piiErrors[idx].passportNo}
>
<Input
placeholder="Passport or ID card no."
bind:value={info.passportNo}
minlength={1}
maxlength={20}
required
oninput={() => debounceValidate()}
/>
</LabelWrapper>
</div>
<div class="flex flex-col gap-4 md:flex-row">
<LabelWrapper
label="Nationality"
error={passengerInfoVM.piiErrors[idx].nationality}
>
<Select.Root
type="single"
required
onValueChange={(e) => {
info.nationality = e;
debounceValidate();
}}
name="role"
>
<Select.Trigger class="w-full">
{capitalize(
info.nationality.length > 0 ? info.nationality : "Select",
)}
</Select.Trigger>
<Select.Content>
{#each COUNTRIES_SELECT as country}
<Select.Item value={country.value}>
{country.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</LabelWrapper>
<LabelWrapper
label="Gender"
error={passengerInfoVM.piiErrors[idx].gender}
>
<Select.Root
type="single"
required
onValueChange={(e) => {
info.gender = e as Gender;
debounceValidate();
}}
name="role"
>
<Select.Trigger class="w-full">
{capitalize(info.gender.length > 0 ? info.gender : "Select")}
</Select.Trigger>
<Select.Content>
{#each genderOpts as gender}
<Select.Item value={gender.value}>
{gender.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</LabelWrapper>
<LabelWrapper
label="Date of Birth"
error={passengerInfoVM.piiErrors[idx].dob}
>
<Input
placeholder="Date of Birth"
bind:value={info.dob}
type="date"
required
oninput={() => debounceValidate()}
/>
</LabelWrapper>
</div>
<!-- and now for the address info - country, state, city, zip code, address and address 2 -->
<Title size="h5">Address Info</Title>
<div class="flex flex-col gap-4 md:flex-row">
<LabelWrapper
label="Country"
error={passengerInfoVM.piiErrors[idx].country}
>
<Select.Root
type="single"
required
onValueChange={(e) => {
info.country = e;
debounceValidate();
}}
name="role"
>
<Select.Trigger class="w-full">
{capitalize(
info.country.length > 0 ? info.country : "Select",
)}
</Select.Trigger>
<Select.Content>
{#each COUNTRIES_SELECT as country}
<Select.Item value={country.value}>
{country.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</LabelWrapper>
<LabelWrapper label="State" error={passengerInfoVM.piiErrors[idx].state}>
<Input
placeholder="State"
bind:value={info.state}
required
oninput={() => debounceValidate()}
/>
</LabelWrapper>
</div>
<div class="flex flex-col gap-4 md:flex-row">
<LabelWrapper label="City" error={passengerInfoVM.piiErrors[idx].city}>
<Input
placeholder="City"
bind:value={info.city}
required
minlength={1}
maxlength={80}
oninput={() => debounceValidate()}
/>
</LabelWrapper>
<LabelWrapper
label="Zip Code"
error={passengerInfoVM.piiErrors[idx].zipCode}
>
<Input
placeholder="Zip Code"
bind:value={info.zipCode}
required
minlength={1}
oninput={() => debounceValidate()}
maxlength={12}
/>
</LabelWrapper>
</div>
<LabelWrapper label="Address" error={passengerInfoVM.piiErrors[idx].address}>
<Input
placeholder="Address"
bind:value={info.address}
required
minlength={1}
maxlength={128}
oninput={() => debounceValidate()}
/>
</LabelWrapper>
<LabelWrapper
label="Address 2"
error={passengerInfoVM.piiErrors[idx].address2}
>
<Input
placeholder="Address 2"
bind:value={info.address2}
required
minlength={1}
maxlength={128}
/>
</LabelWrapper>
</form>

View File

@@ -0,0 +1,10 @@
import type { CustomerInfoModel } from "../data";
export class CustomerInfoViewModel {
customerInfos = $state([] as CustomerInfoModel[]);
loading = $state(false);
query = $state("");
}
export const customerInfoVM = new CustomerInfoViewModel();

View File

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

View File

@@ -1,22 +1,22 @@
import { PUBLIC_FRONTEND_URL } from "$lib/core/constants";
import { trpcApiStore } from "$lib/stores/api"; import { trpcApiStore } from "$lib/stores/api";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { get } from "svelte/store"; import { get } from "svelte/store";
import type { import type {
CouponModel, CreateProductPayload,
CreateCouponPayload, ProductModel,
UpdateCouponPayload, UpdateProductPayload,
} from "./data"; } from "./data";
import { DiscountType } from "./data";
export class CouponViewModel { export class ProductViewModel {
coupons = $state<CouponModel[]>([]); products = $state<ProductModel[]>([]);
loading = $state(false); loading = $state(false);
formLoading = $state(false); formLoading = $state(false);
// Selected coupon for edit/delete operations // Selected product for edit/delete operations
selectedCoupon = $state<CouponModel | null>(null); selectedProduct = $state<ProductModel | null>(null);
async fetchCoupons() { async fetchProducts() {
const api = get(trpcApiStore); const api = get(trpcApiStore);
if (!api) { if (!api) {
return toast.error("API client not initialized"); return toast.error("API client not initialized");
@@ -24,20 +24,19 @@ export class CouponViewModel {
this.loading = true; this.loading = true;
try { try {
const result = await api.coupon.getAllCoupons.query(); const result = await api.product.getAllProducts.query();
if (result.error) { if (result.error) {
toast.error(result.error.message, { toast.error(result.error.message, {
description: result.error.userHint, description: result.error.userHint,
}); });
return false; return false;
} }
console.log(result);
this.coupons = result.data || []; this.products = result.data || [];
return true; return true;
} catch (e) { } catch (e) {
console.error(e); console.error(e);
toast.error("Failed to fetch coupons", { toast.error("Failed to fetch products", {
description: "Please try again later", description: "Please try again later",
}); });
return false; return false;
@@ -46,7 +45,7 @@ export class CouponViewModel {
} }
} }
async createCoupon(payload: CreateCouponPayload) { async createProduct(payload: CreateProductPayload) {
const api = get(trpcApiStore); const api = get(trpcApiStore);
if (!api) { if (!api) {
return toast.error("API client not initialized"); return toast.error("API client not initialized");
@@ -54,7 +53,7 @@ export class CouponViewModel {
this.formLoading = true; this.formLoading = true;
try { try {
const result = await api.coupon.createCoupon.mutate(payload); const result = await api.product.createProduct.mutate(payload);
if (result.error) { if (result.error) {
toast.error(result.error.message, { toast.error(result.error.message, {
description: result.error.userHint, description: result.error.userHint,
@@ -62,11 +61,11 @@ export class CouponViewModel {
return false; return false;
} }
toast.success("Coupon created successfully"); toast.success("Product created successfully");
await this.fetchCoupons(); await this.fetchProducts();
return true; return true;
} catch (e) { } catch (e) {
toast.error("Failed to create coupon", { toast.error("Failed to create product", {
description: "Please try again later", description: "Please try again later",
}); });
return false; return false;
@@ -75,7 +74,7 @@ export class CouponViewModel {
} }
} }
async updateCoupon(payload: UpdateCouponPayload) { async updateProduct(payload: UpdateProductPayload) {
const api = get(trpcApiStore); const api = get(trpcApiStore);
if (!api) { if (!api) {
return toast.error("API client not initialized"); return toast.error("API client not initialized");
@@ -83,7 +82,7 @@ export class CouponViewModel {
this.formLoading = true; this.formLoading = true;
try { try {
const result = await api.coupon.updateCoupon.mutate(payload); const result = await api.product.updateProduct.mutate(payload);
if (result.error) { if (result.error) {
toast.error(result.error.message, { toast.error(result.error.message, {
description: result.error.userHint, description: result.error.userHint,
@@ -91,21 +90,21 @@ export class CouponViewModel {
return false; return false;
} }
toast.success("Coupon updated successfully"); toast.success("Product updated successfully");
await this.fetchCoupons(); await this.fetchProducts();
return true; return true;
} catch (e) { } catch (e) {
toast.error("Failed to update coupon", { toast.error("Failed to update product", {
description: "Please try again later", description: "Please try again later",
}); });
return false; return false;
} finally { } finally {
this.formLoading = false; this.formLoading = false;
this.selectedCoupon = null; this.selectedProduct = null;
} }
} }
async deleteCoupon(id: number) { async deleteProduct(id: number) {
const api = get(trpcApiStore); const api = get(trpcApiStore);
if (!api) { if (!api) {
return toast.error("API client not initialized"); return toast.error("API client not initialized");
@@ -113,7 +112,7 @@ export class CouponViewModel {
this.formLoading = true; this.formLoading = true;
try { try {
const result = await api.coupon.deleteCoupon.mutate({ id }); const result = await api.product.deleteProduct.mutate({ id });
if (result.error) { if (result.error) {
toast.error(result.error.message, { toast.error(result.error.message, {
description: result.error.userHint, description: result.error.userHint,
@@ -121,80 +120,78 @@ export class CouponViewModel {
return false; return false;
} }
toast.success("Coupon deleted successfully"); toast.success("Product deleted successfully");
await this.fetchCoupons(); await this.fetchProducts();
return true; return true;
} catch (e) { } catch (e) {
toast.error("Failed to delete coupon", { toast.error("Failed to delete product", {
description: "Please try again later", description: "Please try again later",
}); });
return false; return false;
} finally { } finally {
this.formLoading = false; this.formLoading = false;
this.selectedCoupon = null; this.selectedProduct = null;
} }
} }
async toggleCouponStatus(id: number, isActive: boolean) { async refreshProductLinkId(id: number) {
const api = get(trpcApiStore); const api = get(trpcApiStore);
if (!api) { if (!api) {
return toast.error("API client not initialized"); return toast.error("API client not initialized");
} }
try { try {
const result = await api.coupon.toggleCouponStatus.mutate({ const result = await api.product.refreshProductLinkId.mutate({ id });
id,
isActive,
});
if (result.error) { if (result.error) {
toast.error(result.error.message, { toast.error(result.error.message, {
description: result.error.userHint, description: result.error.userHint,
}); });
return false; return null;
} }
toast.success( toast.success("Link ID refreshed successfully");
`Coupon ${isActive ? "activated" : "deactivated"} successfully`, await this.fetchProducts();
); return result.data;
await this.fetchCoupons();
return true;
} catch (e) { } catch (e) {
toast.error( toast.error("Failed to refresh link ID", {
`Failed to ${isActive ? "activate" : "deactivate"} coupon`, description: "Please try again later",
{ });
description: "Please try again later", return null;
},
);
return false;
} }
} }
selectCoupon(coupon: CouponModel) { selectProduct(product: ProductModel) {
this.selectedCoupon = { ...coupon }; this.selectedProduct = { ...product };
} }
clearSelectedCoupon() { clearSelectedProduct() {
this.selectedCoupon = null; this.selectedProduct = null;
} }
getDefaultCoupon(): CreateCouponPayload { getDefaultProduct(): CreateProductPayload {
const today = new Date();
const nextMonth = new Date();
nextMonth.setMonth(today.getMonth() + 1);
return { return {
code: "", title: "",
description: "", description: "",
discountType: DiscountType.PERCENTAGE, longDescription: "",
discountValue: 10, price: 0,
maxUsageCount: null, discountPrice: 0,
minOrderValue: null,
maxDiscountAmount: null,
startDate: today.toISOString().split("T")[0],
endDate: nextMonth.toISOString().split("T")[0],
isActive: true,
}; };
} }
async copyLinkToClipboard(linkId: string) {
try {
await navigator.clipboard.writeText(
`${PUBLIC_FRONTEND_URL}/${linkId}`,
);
toast.success("Frontend link copied to clipboard");
return true;
} catch (e) {
toast.error("Failed to copy link ID", {
description: "Please try copying manually",
});
return false;
}
}
} }
export const couponVM = new CouponViewModel(); export const productVM = new ProductViewModel();

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,111 @@
<script lang="ts">
import Button from "$lib/components/ui/button/button.svelte";
import Input from "$lib/components/ui/input/input.svelte";
import { Label } from "$lib/components/ui/label";
import { Textarea } from "$lib/components/ui/textarea";
import type {
CreateProductPayload,
UpdateProductPayload,
} from "$lib/domains/product/data";
export let formData: CreateProductPayload | UpdateProductPayload;
export let loading = false;
export let onSubmit: () => void;
export let onCancel: () => void;
const isNewProduct = !("id" in formData);
</script>
<form on:submit|preventDefault={onSubmit} class="space-y-6">
<!-- Title -->
<div class="space-y-2">
<Label for="title">Product Title</Label>
<Input
id="title"
bind:value={formData.title}
placeholder="e.g. Premium Flight Package"
maxlength={64}
required
/>
</div>
<!-- Description -->
<div class="space-y-2">
<Label for="description">Short Description</Label>
<Textarea
id="description"
bind:value={formData.description}
placeholder="Brief description of the product"
rows={3}
required
/>
</div>
<!-- Long Description -->
<div class="space-y-2">
<Label for="longDescription">Long Description</Label>
<Textarea
id="longDescription"
bind:value={formData.longDescription}
placeholder="Detailed description of the product"
rows={6}
required
/>
</div>
<div class="grid grid-cols-2 gap-4">
<!-- Price -->
<div class="space-y-2">
<Label for="price">Price</Label>
<div class="relative">
<Input
id="price"
type="number"
min={0}
step="0.01"
bind:value={formData.price}
required
/>
<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>
<!-- Discount Price -->
<div class="space-y-2">
<Label for="discountPrice">Discount Price</Label>
<div class="relative">
<Input
id="discountPrice"
type="number"
min={0}
step="0.01"
bind:value={formData.discountPrice}
required
/>
<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>
<!-- 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}
{isNewProduct ? "Create Product" : "Update Product"}
{/if}
</Button>
</div>
</form>

View File

@@ -0,0 +1,288 @@
<script lang="ts">
import Loader from "$lib/components/atoms/loader.svelte";
import Title from "$lib/components/atoms/title.svelte";
import * as AlertDialog from "$lib/components/ui/alert-dialog/index";
import { Badge } from "$lib/components/ui/badge";
import Button from "$lib/components/ui/button/button.svelte";
import * as Dialog from "$lib/components/ui/dialog";
import { productVM } from "$lib/domains/product/product.vm.svelte";
import { Copy, Pencil, PlusIcon, RefreshCw, Trash2 } from "@lucide/svelte";
import { onMount } from "svelte";
import type { CreateProductPayload } from "../data";
import ProductForm from "./product-form.svelte";
let createDialogOpen = $state(false);
let editDialogOpen = $state(false);
let deleteDialogOpen = $state(false);
let newProductData = $state<CreateProductPayload>(
productVM.getDefaultProduct(),
);
async function handleCreateProduct() {
const success = await productVM.createProduct(newProductData);
if (success) {
createDialogOpen = false;
newProductData = productVM.getDefaultProduct();
}
}
async function handleUpdateProduct() {
if (!productVM.selectedProduct) return;
const success = await productVM.updateProduct({
id: productVM.selectedProduct.id! ?? -1,
...productVM.selectedProduct,
});
if (success) {
editDialogOpen = false;
}
}
async function handleDeleteProduct() {
if (!productVM.selectedProduct) return;
const success = await productVM.deleteProduct(
productVM.selectedProduct.id!,
);
if (success) {
deleteDialogOpen = false;
}
}
async function handleRefreshLinkId(id: number) {
await productVM.refreshProductLinkId(id);
}
async function handleCopyLink(linkId: string) {
await productVM.copyLinkToClipboard(linkId);
}
function formatPrice(price: number) {
return `$${price.toFixed(2)}`;
}
onMount(() => {
setTimeout(async () => {
productVM.fetchProducts();
}, 1000);
});
</script>
<div class="mb-6 flex items-center justify-between">
<Title size="h3" weight="semibold">Products</Title>
<Button onclick={() => (createDialogOpen = true)}>
<PlusIcon class="mr-2 h-4 w-4" />
New Product
</Button>
</div>
{#if productVM.loading}
<div class="flex items-center justify-center py-20">
<Loader />
</div>
{:else if productVM.products.length === 0}
<div class="rounded-lg border py-10 text-center">
<p class="text-gray-500">
No products found. Create your first product 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"
>Product</th
>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
>Pricing</th
>
<th
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
>Link ID</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 productVM.products as product}
<tr>
<td class="whitespace-nowrap px-6 py-4">
<div class="font-medium text-gray-900">
{product.title}
</div>
<div
class="max-w-48 overflow-hidden text-ellipsis text-sm text-gray-500"
>
{product.description}
</div>
</td>
<td class="whitespace-nowrap px-6 py-4">
<div class="flex flex-col gap-1">
<div class="text-sm">
<span class="text-gray-500">Price:</span>
<span class="ml-1 font-medium"
>{formatPrice(product.price)}</span
>
</div>
{#if product.discountPrice > 0 && product.discountPrice < product.price}
<Badge variant="success" class="w-fit">
Discount: {formatPrice(
product.discountPrice,
)}
</Badge>
{/if}
</div>
</td>
<td class="px-6 py-4">
<div class="flex items-center gap-2">
<code
class="rounded bg-gray-100 px-2 py-1 font-mono text-xs"
>
{product.linkId}
</code>
<Button
variant="ghost"
size="sm"
onclick={() => handleCopyLink(product.linkId)}
>
<Copy class="h-3 w-3" />
</Button>
</div>
</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={() => handleRefreshLinkId(product.id!)}
title="Refresh Link ID"
>
<RefreshCw class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onclick={() => {
productVM.selectProduct(product);
editDialogOpen = true;
}}
>
<Pencil class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onclick={() => {
productVM.selectProduct(product);
deleteDialogOpen = true;
}}
>
<Trash2 class="h-4 w-4" />
</Button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<!-- Create Product Dialog -->
<Dialog.Root bind:open={createDialogOpen}>
<Dialog.Content class="max-w-2xl">
<Dialog.Header>
<Dialog.Title>Create New Product</Dialog.Title>
<Dialog.Description>
Create a new product for your catalog.
</Dialog.Description>
</Dialog.Header>
<ProductForm
formData={newProductData}
loading={productVM.formLoading}
onSubmit={handleCreateProduct}
onCancel={() => (createDialogOpen = false)}
/>
</Dialog.Content>
</Dialog.Root>
<!-- Edit Product Dialog -->
<Dialog.Root bind:open={editDialogOpen}>
<Dialog.Content class="max-w-2xl">
<Dialog.Header>
<Dialog.Title>Edit Product</Dialog.Title>
<Dialog.Description>Update this product's details.</Dialog.Description
>
</Dialog.Header>
{#if productVM.selectedProduct}
<ProductForm
formData={productVM.selectedProduct}
loading={productVM.formLoading}
onSubmit={handleUpdateProduct}
onCancel={() => (editDialogOpen = false)}
/>
{/if}
</Dialog.Content>
</Dialog.Root>
<!-- Delete Confirmation Dialog -->
<AlertDialog.Root bind:open={deleteDialogOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Delete Product</AlertDialog.Title>
<AlertDialog.Description>
Are you sure you want to delete this product? This action cannot
be undone.
</AlertDialog.Description>
</AlertDialog.Header>
{#if productVM.selectedProduct}
<div class="mb-4 rounded-md bg-gray-100 p-4">
<div class="font-bold">{productVM.selectedProduct.title}</div>
<div class="text-sm text-gray-600">
{productVM.selectedProduct.description}
</div>
<div class="mt-2 flex items-center gap-2">
<Badge
>Price: {formatPrice(
productVM.selectedProduct.price,
)}</Badge
>
{#if productVM.selectedProduct.discountPrice > 0}
<Badge variant="success"
>Discount: {formatPrice(
productVM.selectedProduct.discountPrice,
)}</Badge
>
{/if}
</div>
</div>
{/if}
<AlertDialog.Footer>
<AlertDialog.Cancel onclick={() => (deleteDialogOpen = false)}>
Cancel
</AlertDialog.Cancel>
<AlertDialog.Action
onclick={handleDeleteProduct}
disabled={productVM.formLoading}
>
{productVM.formLoading ? "Deleting..." : "Delete"}
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>

View File

@@ -1,3 +1,4 @@
import { nanoid } from "nanoid";
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import { import {
CabinClass, CabinClass,
@@ -5,7 +6,6 @@ import {
type FlightTicket, type FlightTicket,
type TicketSearchPayload, type TicketSearchPayload,
} from "./entities"; } from "./entities";
import { nanoid } from "nanoid";
export const flightTicketStore = writable<FlightTicket>(); export const flightTicketStore = writable<FlightTicket>();
@@ -20,5 +20,4 @@ export const ticketSearchStore = writable<TicketSearchPayload>({
departureDate: "", departureDate: "",
returnDate: "", returnDate: "",
meta: {}, meta: {},
couponCode: "",
}); });

View File

@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { buttonVariants } from "$lib/components/ui/button/button.svelte";
import { cn } from "$lib/utils";
import { CheckoutStep } from "../../data/entities";
import { ticketCheckoutVM } from "./flight-checkout.vm.svelte";
import { Sheet, SheetContent, SheetTrigger } from "$lib/components/ui/sheet";
import Icon from "$lib/components/atoms/icon.svelte"; import Icon from "$lib/components/atoms/icon.svelte";
import { buttonVariants } from "$lib/components/ui/button/button.svelte";
import { Sheet, SheetContent, SheetTrigger } from "$lib/components/ui/sheet";
import { cn } from "$lib/utils";
import ChevronDownIcon from "~icons/lucide/chevron-down"; import ChevronDownIcon from "~icons/lucide/chevron-down";
import CloseIcon from "~icons/lucide/x"; import CloseIcon from "~icons/lucide/x";
import { CheckoutStep } from "../../data/entities";
import { checkoutVM } from "./checkout.vm.svelte";
const checkoutSteps = [ const checkoutSteps = [
{ id: CheckoutStep.Initial, label: "Passenger Details" }, { id: CheckoutStep.Initial, label: "Passenger Details" },
@@ -16,14 +16,12 @@
]; ];
let activeStepIndex = $derived( let activeStepIndex = $derived(
checkoutSteps.findIndex( checkoutSteps.findIndex((step) => step.id === checkoutVM.checkoutStep),
(step) => step.id === ticketCheckoutVM.checkoutStep,
),
); );
function handleStepClick(clickedIndex: number, stepId: CheckoutStep) { function handleStepClick(clickedIndex: number, stepId: CheckoutStep) {
if (clickedIndex <= activeStepIndex) { if (clickedIndex <= activeStepIndex) {
ticketCheckoutVM.checkoutStep = stepId; checkoutVM.checkoutStep = stepId;
} }
} }

View File

@@ -0,0 +1,165 @@
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
import { CheckoutStep, newOrderModel } from "$lib/domains/order/data/entities";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import {
paymentInfoPayloadModel,
PaymentMethod,
} from "$lib/domains/paymentinfo/data/entities";
import { trpcApiStore } from "$lib/stores/api";
import { toast } from "svelte-sonner";
import { get } from "svelte/store";
import { flightTicketStore } from "../../data/store";
import { paymentInfoVM } from "./payment-info-section/payment.info.vm.svelte";
import { calculateTicketPrices } from "./total.calculator";
class CheckoutViewModel {
checkoutStep = $state(CheckoutStep.Initial);
loading = $state(true);
continutingToNextStep = $state(false);
checkoutSubmitted = $state(false);
livenessPinger: NodeJS.Timer | undefined = $state(undefined);
reset() {
this.checkoutStep = CheckoutStep.Initial;
this.resetPinger();
}
setupPinger() {
this.resetPinger();
this.livenessPinger = setInterval(() => {
this.ping();
}, 5_000);
}
resetPinger() {
if (this.livenessPinger) {
clearInterval(this.livenessPinger);
}
}
private async ping() {
const api = get(trpcApiStore);
if (!api) {
return false;
}
const ticket = get(flightTicketStore);
if (!ticket || !ticket.refOIds) {
return false;
}
const out = await api.ticket.ping.query({
tid: ticket.id,
refOIds: ticket.refOIds,
});
}
async checkout() {
if (this.checkoutSubmitted || this.loading) {
return;
}
this.checkoutSubmitted = true;
const api = get(trpcApiStore);
if (!api) {
this.checkoutSubmitted = false;
return false;
}
const ticket = get(flightTicketStore);
const prices = calculateTicketPrices(
ticket,
passengerInfoVM.passengerInfos,
);
const validatedPrices = {
subtotal: isNaN(prices.subtotal) ? 0 : prices.subtotal,
discountAmount: isNaN(prices.discountAmount)
? 0
: prices.discountAmount,
finalTotal: isNaN(prices.finalTotal) ? 0 : prices.finalTotal,
pricePerPassenger: isNaN(prices.pricePerPassenger)
? 0
: prices.pricePerPassenger,
};
const parsed = newOrderModel.safeParse({
basePrice: validatedPrices.subtotal,
discountAmount: validatedPrices.discountAmount,
displayPrice: validatedPrices.finalTotal,
orderPrice: validatedPrices.finalTotal, // Same as displayPrice
fullfilledPrice: validatedPrices.finalTotal, // Same as displayPrice
pricePerPassenger: validatedPrices.pricePerPassenger,
flightTicketInfoId: -1,
paymentInfoId: -1,
});
if (parsed.error) {
console.log(parsed.error);
const err = parsed.error.errors[0];
toast.error("Failed to perform checkout", {
description: err.message,
});
return false;
}
const pInfoParsed = paymentInfoPayloadModel.safeParse({
method: PaymentMethod.Card,
cardDetails: paymentInfoVM.cardDetails,
flightTicketInfoId: ticket.id,
});
if (pInfoParsed.error) {
console.log(parsed.error);
const err = pInfoParsed.error.errors[0];
toast.error("Failed to perform checkout", {
description: err.message,
});
return false;
}
try {
console.log("Creating order");
this.loading = true;
const out = await api.order.createOrder.mutate({
flightTicketId: ticket.id,
orderModel: parsed.data,
passengerInfos: passengerInfoVM.passengerInfos,
paymentInfo: pInfoParsed.data,
refOIds: ticket.refOIds,
flowId: ckFlowVM.flowId,
});
if (out.error) {
this.loading = false;
toast.error(out.error.message, {
description: out.error.userHint,
});
return false;
}
if (!out.data) {
this.loading = false;
toast.error("Failed to create order", {
description: "Please try again",
});
return false;
}
toast.success("Order created successfully", {
description: "Redirecting, please wait...",
});
setTimeout(() => {
window.location.href = `/checkout/success?oid=${out.data}`;
}, 500);
return true;
} catch (e) {
this.checkoutSubmitted = false;
toast.error("An error occurred during checkout", {
description: "Please try again",
});
return false;
}
}
}
export const checkoutVM = new CheckoutViewModel();

View File

@@ -1,22 +1,21 @@
<script lang="ts"> <script lang="ts">
import ButtonLoadableText from "$lib/components/atoms/button-loadable-text.svelte";
import Icon from "$lib/components/atoms/icon.svelte"; import Icon from "$lib/components/atoms/icon.svelte";
import Title from "$lib/components/atoms/title.svelte"; import Title from "$lib/components/atoms/title.svelte";
import Badge from "$lib/components/ui/badge/badge.svelte";
import Button from "$lib/components/ui/button/button.svelte"; import Button from "$lib/components/ui/button/button.svelte";
import { capitalize } from "$lib/core/string.utils";
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
import { CheckoutStep } from "$lib/domains/order/data/entities";
import PassengerPiiForm from "$lib/domains/passengerinfo/view/passenger-pii-form.svelte";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import { cn } from "$lib/utils";
import { onMount } from "svelte";
import { toast } from "svelte-sonner";
import RightArrowIcon from "~icons/solar/arrow-right-broken";
import { flightTicketStore } from "../../data/store"; import { flightTicketStore } from "../../data/store";
import TripDetails from "../ticket/trip-details.svelte"; import TripDetails from "../ticket/trip-details.svelte";
import RightArrowIcon from "~icons/solar/arrow-right-broken"; import { checkoutVM } from "./checkout.vm.svelte";
import { onMount } from "svelte";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import PassengerPiiForm from "$lib/domains/passengerinfo/view/passenger-pii-form.svelte";
import PassengerBagSelection from "$lib/domains/passengerinfo/view/passenger-bag-selection.svelte";
import Badge from "$lib/components/ui/badge/badge.svelte";
import { capitalize } from "$lib/core/string.utils";
import { cn } from "$lib/utils";
import { ticketCheckoutVM } from "./flight-checkout.vm.svelte";
import { CheckoutStep } from "../../data/entities";
import { toast } from "svelte-sonner";
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
import ButtonLoadableText from "$lib/components/atoms/button-loadable-text.svelte";
const cardStyle = const cardStyle =
"flex w-full flex-col gap-4 rounded-lg bg-white p-4 shadow-lg md:p-8"; "flex w-full flex-col gap-4 rounded-lg bg-white p-4 shadow-lg md:p-8";
@@ -76,14 +75,14 @@
description: "Please properly fill out all of the fields", description: "Please properly fill out all of the fields",
}); });
} }
ticketCheckoutVM.continutingToNextStep = true; checkoutVM.continutingToNextStep = true;
const out2 = await ckFlowVM.executePrePaymentStep(); const out2 = await ckFlowVM.executePrePaymentStep();
if (!out2) { if (!out2) {
return; return;
} }
setTimeout(() => { setTimeout(() => {
ticketCheckoutVM.continutingToNextStep = false; checkoutVM.continutingToNextStep = false;
ticketCheckoutVM.checkoutStep = CheckoutStep.Payment; checkoutVM.checkoutStep = CheckoutStep.Payment;
}, 2000); }, 2000);
} }
@@ -123,11 +122,6 @@
<Title size="h5">Personal Info</Title> <Title size="h5">Personal Info</Title>
<PassengerPiiForm bind:info={info.passengerPii} {idx} /> <PassengerPiiForm bind:info={info.passengerPii} {idx} />
</div> </div>
<div class={cn(cardStyle, "border-2 border-gray-200")}>
<Title size="h5">Bag Selection</Title>
<PassengerBagSelection bind:info={info.bagSelection} />
</div>
</div> </div>
{/each} {/each}
{/if} {/if}
@@ -139,12 +133,12 @@
variant="default" variant="default"
onclick={proceedToNextStep} onclick={proceedToNextStep}
class="w-full md:w-max" class="w-full md:w-max"
disabled={ticketCheckoutVM.continutingToNextStep} disabled={checkoutVM.continutingToNextStep}
> >
<ButtonLoadableText <ButtonLoadableText
text="Continue" text="Continue"
loadingText="Processing..." loadingText="Processing..."
loading={ticketCheckoutVM.continutingToNextStep} loading={checkoutVM.continutingToNextStep}
/> />
<Icon icon={RightArrowIcon} cls="w-auto h-6" /> <Icon icon={RightArrowIcon} cls="w-auto h-6" />
</Button> </Button>

View File

@@ -1,48 +0,0 @@
<script lang="ts">
import Icon from "$lib/components/atoms/icon.svelte";
import Title from "$lib/components/atoms/title.svelte";
import { Badge } from "$lib/components/ui/badge";
import TagIcon from "~icons/lucide/tag";
import { flightTicketStore } from "../../../data/store";
import { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte";
let appliedCoupon = $derived($flightTicketStore?.priceDetails?.appliedCoupon);
let couponDescription = $derived(
$flightTicketStore?.priceDetails?.couponDescription || "",
);
let discountAmount = $derived(
$flightTicketStore?.priceDetails?.discountAmount || 0,
);
let basePrice = $derived($flightTicketStore?.priceDetails?.basePrice || 0);
let discountPercentage = $derived(
basePrice > 0 ? Math.round((discountAmount / basePrice) * 100) : 0,
);
</script>
{#if appliedCoupon && discountAmount > 0}
<div
class="flex flex-col gap-2 rounded-lg border border-green-200 bg-green-50 p-4"
>
<div class="flex items-center gap-2 text-green-700">
<Icon icon={TagIcon} cls="h-5 w-5" />
<Title size="p" weight="medium">Coupon Applied</Title>
<Badge
variant="outline"
class="ml-auto border-green-600 text-green-600"
>
{discountPercentage}% OFF
</Badge>
</div>
<div class="flex flex-col gap-1">
<div class="flex justify-between">
<span class="font-medium">{appliedCoupon}</span>
<span>-{convertAndFormatCurrency(discountAmount)}</span>
</div>
{#if couponDescription}
<p class="text-sm text-green-700">{couponDescription}</p>
{/if}
</div>
</div>
{/if}

View File

@@ -1,30 +1,29 @@
<script lang="ts"> <script lang="ts">
import ButtonLoadableText from "$lib/components/atoms/button-loadable-text.svelte";
import Icon from "$lib/components/atoms/icon.svelte"; import Icon from "$lib/components/atoms/icon.svelte";
import Title from "$lib/components/atoms/title.svelte"; import Title from "$lib/components/atoms/title.svelte";
import Button, { import Button, {
buttonVariants, buttonVariants,
} from "$lib/components/ui/button/button.svelte"; } from "$lib/components/ui/button/button.svelte";
import RightArrowIcon from "~icons/solar/arrow-right-broken"; import * as Dialog from "$lib/components/ui/dialog";
import { ticketCheckoutVM } from "../flight-checkout.vm.svelte"; import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
import { CheckoutStep } from "$lib/domains/ticket/data/entities"; import { CheckoutStep } from "$lib/domains/order/data/entities";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import { flightTicketStore } from "$lib/domains/ticket/data/store"; import { flightTicketStore } from "$lib/domains/ticket/data/store";
import { cn } from "$lib/utils";
import { formatDate } from "@pkg/logic/core/date.utils";
import { onMount } from "svelte";
import { toast } from "svelte-sonner";
import RightArrowIcon from "~icons/solar/arrow-right-broken";
import ArrowsExchangeIcon from "~icons/tabler/arrows-exchange-2";
import TicketDetailsModal from "../../ticket/ticket-details-modal.svelte";
import { checkoutVM } from "../checkout.vm.svelte";
import BillingDetailsForm from "./billing-details-form.svelte";
import { billingDetailsVM } from "./billing.details.vm.svelte";
import CouponSummary from "./coupon-summary.svelte";
import OrderSummary from "./order-summary.svelte"; import OrderSummary from "./order-summary.svelte";
import PaymentForm from "./payment-form.svelte"; import PaymentForm from "./payment-form.svelte";
import { paymentInfoVM } from "./payment.info.vm.svelte"; import { paymentInfoVM } from "./payment.info.vm.svelte";
import TicketDetailsModal from "../../ticket/ticket-details-modal.svelte";
import * as Dialog from "$lib/components/ui/dialog";
import { cn } from "$lib/utils";
import { TicketType } from "$lib/domains/ticket/data/entities";
import ArrowsExchangeIcon from "~icons/tabler/arrows-exchange-2";
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
import { formatDate } from "@pkg/logic/core/date.utils";
import ButtonLoadableText from "$lib/components/atoms/button-loadable-text.svelte";
import BillingDetailsForm from "./billing-details-form.svelte";
import { billingDetailsVM } from "./billing.details.vm.svelte";
import { onMount } from "svelte";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import { toast } from "svelte-sonner";
import CouponSummary from "./coupon-summary.svelte";
const cardStyle = const cardStyle =
"flex w-full flex-col gap-4 rounded-lg bg-white p-4 shadow-lg md:p-8"; "flex w-full flex-col gap-4 rounded-lg bg-white p-4 shadow-lg md:p-8";
@@ -33,7 +32,7 @@
if ((await ckFlowVM.onBackToPIIBtnClick()) !== true) { if ((await ckFlowVM.onBackToPIIBtnClick()) !== true) {
return; return;
} }
ticketCheckoutVM.checkoutStep = CheckoutStep.Initial; checkoutVM.checkoutStep = CheckoutStep.Initial;
} }
async function handleSubmit() { async function handleSubmit() {
@@ -47,14 +46,14 @@
if (!validBillingInfo) { if (!validBillingInfo) {
return; return;
} }
ticketCheckoutVM.continutingToNextStep = true; checkoutVM.continutingToNextStep = true;
const out = await ckFlowVM.executePaymentStep(); const out = await ckFlowVM.executePaymentStep();
if (out !== true) { if (out !== true) {
return; return;
} }
setTimeout(() => { setTimeout(() => {
ticketCheckoutVM.continutingToNextStep = false; checkoutVM.continutingToNextStep = false;
ticketCheckoutVM.checkoutStep = CheckoutStep.Verification; checkoutVM.checkoutStep = CheckoutStep.Verification;
}, 1000); }, 1000);
} }
@@ -186,12 +185,12 @@
variant="default" variant="default"
onclick={handleSubmit} onclick={handleSubmit}
class="w-full md:w-max" class="w-full md:w-max"
disabled={ticketCheckoutVM.continutingToNextStep} disabled={checkoutVM.continutingToNextStep}
> >
<ButtonLoadableText <ButtonLoadableText
text="Confirm & Pay" text="Confirm & Pay"
loadingText="Processing info..." loadingText="Processing info..."
loading={ticketCheckoutVM.continutingToNextStep} loading={checkoutVM.continutingToNextStep}
/> />
<Icon icon={RightArrowIcon} cls="w-auto h-6" /> <Icon icon={RightArrowIcon} cls="w-auto h-6" />
</Button> </Button>

View File

@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte"; import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
import { onMount, onDestroy } from "svelte"; import { onDestroy, onMount } from "svelte";
import { ticketCheckoutVM } from "./flight-checkout.vm.svelte"; import { checkoutVM } from "./checkout.vm.svelte";
import PaymentVerificationLoader from "./payment-verification-loader.svelte";
import OtpVerificationSection from "./otp-verification-section.svelte"; import OtpVerificationSection from "./otp-verification-section.svelte";
import PaymentVerificationLoader from "./payment-verification-loader.svelte";
let refreshIntervalId: NodeJS.Timer; let refreshIntervalId: NodeJS.Timer;
@@ -47,7 +47,7 @@
setTimeout(async () => { setTimeout(async () => {
if (ckFlowVM.setupDone && !ckFlowVM.flowId) { if (ckFlowVM.setupDone && !ckFlowVM.flowId) {
console.log("Shortcut - Checking out"); console.log("Shortcut - Checking out");
await ticketCheckoutVM.checkout(); await checkoutVM.checkout();
} }
}, rng); }, rng);
}); });

View File

@@ -1,16 +1,16 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte";
import Icon from "$lib/components/atoms/icon.svelte"; import Icon from "$lib/components/atoms/icon.svelte";
import Title from "$lib/components/atoms/title.svelte"; import Title from "$lib/components/atoms/title.svelte";
import Button from "$lib/components/ui/button/button.svelte"; import Button from "$lib/components/ui/button/button.svelte";
import { cn } from "$lib/utils"; import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import RightArrowIcon from "~icons/solar/arrow-right-broken";
import { seatSelectionVM } from "./seat.selection.vm.svelte";
import { ticketCheckoutVM } from "../flight-checkout.vm.svelte";
import { CheckoutStep } from "$lib/domains/ticket/data/entities"; import { CheckoutStep } from "$lib/domains/ticket/data/entities";
import { flightTicketStore } from "$lib/domains/ticket/data/store"; import { flightTicketStore } from "$lib/domains/ticket/data/store";
import { cn } from "$lib/utils";
import { onMount } from "svelte";
import RightArrowIcon from "~icons/solar/arrow-right-broken";
import CheckoutLoadingSection from "../checkout-loading-section.svelte"; import CheckoutLoadingSection from "../checkout-loading-section.svelte";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte"; import { checkoutVM } from "../checkout.vm.svelte";
import { seatSelectionVM } from "./seat.selection.vm.svelte";
const cardStyle = const cardStyle =
"flex w-full flex-col gap-4 rounded-lg bg-white p-4 shadow-lg md:p-8"; "flex w-full flex-col gap-4 rounded-lg bg-white p-4 shadow-lg md:p-8";
@@ -23,7 +23,7 @@
); );
function goBack() { function goBack() {
ticketCheckoutVM.checkoutStep = CheckoutStep.Initial; checkoutVM.checkoutStep = CheckoutStep.Initial;
} }
function goNext() { function goNext() {
@@ -33,7 +33,7 @@
} }
function skipAndContinue() { function skipAndContinue() {
ticketCheckoutVM.checkoutStep = CheckoutStep.Payment; checkoutVM.checkoutStep = CheckoutStep.Payment;
} }
onMount(() => { onMount(() => {

View File

@@ -12,7 +12,7 @@ import { nanoid } from "nanoid";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { flightTicketStore, ticketSearchStore } from "../data/store"; import { flightTicketStore, ticketSearchStore } from "../data/store";
import { ticketCheckoutVM } from "./checkout/flight-checkout.vm.svelte"; import { checkoutVM } from "./checkout/checkout.vm.svelte";
import { paymentInfoVM } from "./checkout/payment-info-section/payment.info.vm.svelte"; import { paymentInfoVM } from "./checkout/payment-info-section/payment.info.vm.svelte";
import { import {
MaxStops, MaxStops,
@@ -91,7 +91,7 @@ export class FlightTicketViewModel {
// @ts-ignore // @ts-ignore
flightTicketStore.set(undefined); flightTicketStore.set(undefined);
passengerInfoVM.reset(); passengerInfoVM.reset();
ticketCheckoutVM.reset(); checkoutVM.reset();
paymentInfoVM.reset(); paymentInfoVM.reset();
} }

View File

@@ -1,17 +1,17 @@
<script lang="ts"> <script lang="ts">
import Title from "$lib/components/atoms/title.svelte"; import Title from "$lib/components/atoms/title.svelte";
import { Badge } from "$lib/components/ui/badge/index.js";
import Button, { import Button, {
buttonVariants, buttonVariants,
} from "$lib/components/ui/button/button.svelte"; } from "$lib/components/ui/button/button.svelte";
import { type FlightTicket } from "../../data/entities";
import { Badge } from "$lib/components/ui/badge/index.js";
import { TRANSITION_ALL } from "$lib/core/constants";
import { cn } from "$lib/utils";
import TicketDetailsModal from "./ticket-details-modal.svelte";
import * as Dialog from "$lib/components/ui/dialog/index.js"; import * as Dialog from "$lib/components/ui/dialog/index.js";
import { flightTicketVM } from "../ticket.vm.svelte"; import { TRANSITION_ALL } from "$lib/core/constants";
import TicketLegsOverview from "./ticket-legs-overview.svelte";
import { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte"; import { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte";
import { cn } from "$lib/utils";
import { type FlightTicket } from "../../data/entities";
import { flightTicketVM } from "../ticket.vm.svelte";
import TicketDetailsModal from "./ticket-details-modal.svelte";
import TicketLegsOverview from "./ticket-legs-overview.svelte";
let { data }: { data: FlightTicket } = $props(); let { data }: { data: FlightTicket } = $props();
@@ -57,15 +57,6 @@
<Title center size="h4" weight="medium" color="black"> <Title center size="h4" weight="medium" color="black">
{convertAndFormatCurrency(data.priceDetails.displayPrice)} {convertAndFormatCurrency(data.priceDetails.displayPrice)}
</Title> </Title>
{#if data.priceDetails.appliedCoupon}
<Badge
variant="outline"
class="border-green-600 text-green-600"
>
Coupon: {data.priceDetails.appliedCoupon}
</Badge>
{/if}
</div> </div>
{:else} {:else}
<Title center size="h4" weight="medium" color="black"> <Title center size="h4" weight="medium" color="black">

View File

@@ -1,18 +1,18 @@
import { createTRPCRouter } from "../t";
import { userRouter } from "$lib/domains/user/domain/router";
import { authRouter } from "$lib/domains/auth/domain/router"; import { authRouter } from "$lib/domains/auth/domain/router";
import { ticketRouter } from "$lib/domains/ticket/domain/router"; import { ckflowRouter } from "$lib/domains/ckflow/domain/router";
import { currencyRouter } from "$lib/domains/currency/domain/router"; import { currencyRouter } from "$lib/domains/currency/domain/router";
import { orderRouter } from "$lib/domains/order/domain/router"; import { orderRouter } from "$lib/domains/order/domain/router";
import { ckflowRouter } from "$lib/domains/ckflow/domain/router"; import { productRouter } from "$lib/domains/product/router";
import { userRouter } from "$lib/domains/user/domain/router";
import { createTRPCRouter } from "../t";
export const router = createTRPCRouter({ export const router = createTRPCRouter({
auth: authRouter, auth: authRouter,
user: userRouter, user: userRouter,
ticket: ticketRouter,
currency: currencyRouter, currency: currencyRouter,
order: orderRouter, order: orderRouter,
ckflow: ckflowRouter, ckflow: ckflowRouter,
product: productRouter,
}); });
export type Router = typeof router; export type Router = typeof router;

View File

@@ -0,0 +1,7 @@
import { getProductUseCases } from "$lib/domains/product/usecases";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ params }) => {
const pid = params.pageid;
return await getProductUseCases().getProductByLinkId(pid);
};

View File

@@ -1,4 +1,23 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte";
import { toast } from "svelte-sonner";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
onMount(() => {
toast("Do the check here to then either redirect or show the error");
setTimeout(() => {
toast("redirect to checkout naw");
}, 3000);
});
</script> </script>
<span>Show the thing to do the thing about the thing around the thing</span> {#if data.data}
<span>
Either show the user the product as being valid and redirecting them to
the checkout
</span>
{:else}
<span>Show the user an error around "page not found" or "expired link"</span>
{/if}

View File

@@ -1,41 +1,27 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from "svelte";
import type { PageData } from "./$types";
import Icon from "$lib/components/atoms/icon.svelte"; import Icon from "$lib/components/atoms/icon.svelte";
import Title from "$lib/components/atoms/title.svelte"; import Title from "$lib/components/atoms/title.svelte";
import Button from "$lib/components/ui/button/button.svelte";
import SearchIcon from "~icons/solar/magnifer-linear";
import { toast } from "svelte-sonner";
import { ticketCheckoutVM } from "$lib/domains/ticket/view/checkout/flight-checkout.vm.svelte";
import { CheckoutStep } from "$lib/domains/ticket/data/entities/index";
import InitialInfoSection from "$lib/domains/ticket/view/checkout/initial-info-section.svelte";
import { flightTicketStore } from "$lib/domains/ticket/data/store";
import CheckoutLoadingSection from "$lib/domains/ticket/view/checkout/checkout-loading-section.svelte";
import CheckoutConfirmationSection from "$lib/domains/ticket/view/checkout/checkout-confirmation-section.svelte";
import CheckoutStepsIndicator from "$lib/domains/ticket/view/checkout/checkout-steps-indicator.svelte";
import PaymentInfoSection from "$lib/domains/ticket/view/checkout/payment-info-section/index.svelte";
import PaymentVerificationSection from "$lib/domains/ticket/view/checkout/payment-verification-section.svelte";
import PaymentSummary from "$lib/domains/ticket/view/checkout/payment-summary.svelte";
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte"; import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
import Button from "$lib/components/ui/button/button.svelte";
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte"; import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
import { CheckoutStep } from "$lib/domains/order/data/entities";
import { flightTicketStore } from "$lib/domains/ticket/data/store";
import CheckoutConfirmationSection from "$lib/domains/ticket/view/checkout/checkout-confirmation-section.svelte";
import CheckoutLoadingSection from "$lib/domains/ticket/view/checkout/checkout-loading-section.svelte";
import CheckoutStepsIndicator from "$lib/domains/ticket/view/checkout/checkout-steps-indicator.svelte";
import { checkoutVM } from "$lib/domains/ticket/view/checkout/checkout.vm.svelte";
import InitialInfoSection from "$lib/domains/ticket/view/checkout/initial-info-section.svelte";
import PaymentInfoSection from "$lib/domains/ticket/view/checkout/payment-info-section/index.svelte";
import PaymentSummary from "$lib/domains/ticket/view/checkout/payment-summary.svelte";
import PaymentVerificationSection from "$lib/domains/ticket/view/checkout/payment-verification-section.svelte";
import UpdatePriceDialog from "$lib/domains/ticket/view/checkout/update-price-dialog.svelte"; import UpdatePriceDialog from "$lib/domains/ticket/view/checkout/update-price-dialog.svelte";
import { onDestroy, onMount } from "svelte";
import { toast } from "svelte-sonner";
import SearchIcon from "~icons/solar/magnifer-linear";
import type { PageData } from "./$types";
let { data: pageData }: { data: PageData } = $props(); let { data: pageData }: { data: PageData } = $props();
function trackConversion() {
console.log("ct-ing");
let callback = function () {
console.log("ct-ed");
};
// window.gtag("event", "conversion", {
// send_to: "AW-17085207253/ZFAVCLD6tMkaENWl7tI_",
// value: 1.0,
// currency: "USD",
// event_callback: callback,
// });
return false;
}
onMount(() => { onMount(() => {
if (pageData.error) { if (pageData.error) {
toast.error(pageData.error.message, { toast.error(pageData.error.message, {
@@ -47,17 +33,16 @@
return; return;
} }
flightTicketStore.set(pageData.data); flightTicketStore.set(pageData.data);
ticketCheckoutVM.loading = false; checkoutVM.loading = false;
ticketCheckoutVM.setupPinger(); checkoutVM.setupPinger();
setTimeout(async () => { setTimeout(async () => {
await ckFlowVM.initFlow(); await ckFlowVM.initFlow();
trackConversion();
}, 2000); }, 2000);
}); });
onDestroy(() => { onDestroy(() => {
ticketCheckoutVM.reset(); checkoutVM.reset();
}); });
</script> </script>
@@ -75,7 +60,7 @@
</Button> </Button>
</div> </div>
</div> </div>
{:else if ticketCheckoutVM.checkoutStep === CheckoutStep.Confirmation} {:else if checkoutVM.checkoutStep === CheckoutStep.Confirmation}
<div class="grid w-full place-items-center p-4 py-32"> <div class="grid w-full place-items-center p-4 py-32">
<CheckoutConfirmationSection /> <CheckoutConfirmationSection />
</div> </div>
@@ -84,13 +69,13 @@
<div class="flex w-full flex-col gap-8 lg:flex-row"> <div class="flex w-full flex-col gap-8 lg:flex-row">
<div class="flex w-full flex-col"> <div class="flex w-full flex-col">
<div class="flex w-full flex-col gap-12"> <div class="flex w-full flex-col gap-12">
{#if ticketCheckoutVM.loading} {#if checkoutVM.loading}
<CheckoutLoadingSection /> <CheckoutLoadingSection />
{:else if ticketCheckoutVM.checkoutStep === CheckoutStep.Initial} {:else if checkoutVM.checkoutStep === CheckoutStep.Initial}
<InitialInfoSection /> <InitialInfoSection />
{:else if ticketCheckoutVM.checkoutStep === CheckoutStep.Payment} {:else if checkoutVM.checkoutStep === CheckoutStep.Payment}
<PaymentInfoSection /> <PaymentInfoSection />
{:else if ticketCheckoutVM.checkoutStep === CheckoutStep.Verification} {:else if checkoutVM.checkoutStep === CheckoutStep.Verification}
<PaymentVerificationSection /> <PaymentVerificationSection />
{/if} {/if}
</div> </div>

View File

@@ -1,49 +0,0 @@
<script lang="ts">
import { page } from "$app/stores";
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
import SearchedTicketsList from "$lib/domains/ticket/view/searched-tickets-list.svelte";
import TicketFiltersSelect from "$lib/domains/ticket/view/ticket-filters-select.svelte";
import TicketSearchInput from "$lib/domains/ticket/view/ticket-search-input.svelte";
import { flightTicketVM } from "$lib/domains/ticket/view/ticket.vm.svelte";
import { onMount } from "svelte";
import { toast } from "svelte-sonner";
function onSubmit() {
const out = flightTicketVM.setURLParams();
if (out.data) {
flightTicketVM.searchForTickets();
}
if (out.error) {
toast.error(out.error.message, { description: out.error.userHint });
}
}
onMount(() => {
flightTicketVM.loadStore($page.url.searchParams);
flightTicketVM.searching = true;
setTimeout(() => flightTicketVM.searchForTickets());
});
</script>
<div class="grid h-full min-h-[70vh] w-full place-items-center">
<section class="max-w-8xl flex w-full flex-col items-center gap-8 p-4 md:p-8">
<MaxWidthWrapper cls="flex w-full flex-col items-center gap-2">
<div
class="hidden w-full rounded-lg bg-white p-4 drop-shadow-lg lg:block"
>
<TicketSearchInput rowify {onSubmit} />
</div>
</MaxWidthWrapper>
<MaxWidthWrapper cls="flex gap-8 w-full h-full">
{#if flightTicketVM.tickets.length > 0}
<div
class="hidden h-max w-full max-w-sm flex-col rounded-lg bg-white p-8 shadow-lg lg:flex"
>
<TicketFiltersSelect />
</div>
{/if}
<SearchedTicketsList />
</MaxWidthWrapper>
</section>
</div>

View File

@@ -1,5 +1,9 @@
# The goal # "Domain Wall" (honestly no idea why I named it this, but juss gonna go wid it)
The goal is that we need to create a sort of a make-shift checkout panel The goal is to create a product checkout page, something like gumroad/stripe where the user is coming here to do checkout on our product page,
--- Which is basically a proxy way for us to do the payment on their behalf, e.g how the internet billing companies do over the phone, we're cooking this live-sync panel where the checkout info is shown to the (admin panel) agent whose on the call with the user in order to do the info filling on their behalf, since the user is typing the info on the checkout page it's gonna be 100% accurate and fast.
This is a reboot of my previous learning project which was somewhat a mashup of a travel booking website with this same sync logic.
But now doing a flexible product-based checkout, where the admin can flexibly set products for the customers to bill and then gather the info for the work.

View File

@@ -0,0 +1 @@
DROP TABLE "coupon" CASCADE;

View File

@@ -0,0 +1,901 @@
{
"id": "95304f23-5c79-4e23-bd6b-0d2f99cc5249",
"prevId": "e8de9102-c79e-46ff-a25f-80e1039b6091",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": false
},
"display_username": {
"name": "display_username",
"type": "text",
"primaryKey": false,
"notNull": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"parent_id": {
"name": "parent_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"discount_percent": {
"name": "discount_percent",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
},
"user_username_unique": {
"name": "user_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.checkout_flow_session": {
"name": "checkout_flow_session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"flow_id": {
"name": "flow_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"domain": {
"name": "domain",
"type": "text",
"primaryKey": false,
"notNull": true
},
"checkout_step": {
"name": "checkout_step",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true
},
"show_verification": {
"name": "show_verification",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"last_pinged": {
"name": "last_pinged",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"last_synced_at": {
"name": "last_synced_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"personal_info_last_synced_at": {
"name": "personal_info_last_synced_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"payment_info_last_synced_at": {
"name": "payment_info_last_synced_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"pending_actions": {
"name": "pending_actions",
"type": "json",
"primaryKey": false,
"notNull": true,
"default": "'[]'::json"
},
"personal_info": {
"name": "personal_info",
"type": "json",
"primaryKey": false,
"notNull": false
},
"payment_info": {
"name": "payment_info",
"type": "json",
"primaryKey": false,
"notNull": false
},
"ref_o_ids": {
"name": "ref_o_ids",
"type": "json",
"primaryKey": false,
"notNull": false,
"default": "'[]'::json"
},
"otp_code": {
"name": "otp_code",
"type": "varchar(20)",
"primaryKey": false,
"notNull": false
},
"otp_submitted": {
"name": "otp_submitted",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"partial_otp_code": {
"name": "partial_otp_code",
"type": "varchar(20)",
"primaryKey": false,
"notNull": false
},
"ip_address": {
"name": "ip_address",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"reserved": {
"name": "reserved",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"reserved_by": {
"name": "reserved_by",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"completed_at": {
"name": "completed_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"session_outcome": {
"name": "session_outcome",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false
},
"is_deleted": {
"name": "is_deleted",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"product_id": {
"name": "product_id",
"type": "integer",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"checkout_flow_session_product_id_product_id_fk": {
"name": "checkout_flow_session_product_id_product_id_fk",
"tableFrom": "checkout_flow_session",
"tableTo": "product",
"columnsFrom": [
"product_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"checkout_flow_session_flow_id_unique": {
"name": "checkout_flow_session_flow_id_unique",
"nullsNotDistinct": false,
"columns": [
"flow_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.customer_info": {
"name": "customer_info",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"first_name": {
"name": "first_name",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"middle_name": {
"name": "middle_name",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"default": "''"
},
"last_name": {
"name": "last_name",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true
},
"phone_country_code": {
"name": "phone_country_code",
"type": "varchar(6)",
"primaryKey": false,
"notNull": true
},
"phone_number": {
"name": "phone_number",
"type": "varchar(20)",
"primaryKey": false,
"notNull": true
},
"country": {
"name": "country",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true
},
"state": {
"name": "state",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true
},
"city": {
"name": "city",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true
},
"zip_code": {
"name": "zip_code",
"type": "varchar(21)",
"primaryKey": false,
"notNull": true
},
"address": {
"name": "address",
"type": "text",
"primaryKey": false,
"notNull": true
},
"address2": {
"name": "address2",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order": {
"name": "order",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_price": {
"name": "order_price",
"type": "numeric(12, 2)",
"primaryKey": false,
"notNull": false
},
"discount_amount": {
"name": "discount_amount",
"type": "numeric(12, 2)",
"primaryKey": false,
"notNull": false
},
"display_price": {
"name": "display_price",
"type": "numeric(12, 2)",
"primaryKey": false,
"notNull": false
},
"base_price": {
"name": "base_price",
"type": "numeric(12, 2)",
"primaryKey": false,
"notNull": false
},
"fullfilled_price": {
"name": "fullfilled_price",
"type": "numeric(12, 2)",
"primaryKey": false,
"notNull": false,
"default": "'0'"
},
"status": {
"name": "status",
"type": "varchar(24)",
"primaryKey": false,
"notNull": false
},
"product_id": {
"name": "product_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"customer_info_id": {
"name": "customer_info_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"payment_info_id": {
"name": "payment_info_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"order_product_id_product_id_fk": {
"name": "order_product_id_product_id_fk",
"tableFrom": "order",
"tableTo": "product",
"columnsFrom": [
"product_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"order_customer_info_id_customer_info_id_fk": {
"name": "order_customer_info_id_customer_info_id_fk",
"tableFrom": "order",
"tableTo": "customer_info",
"columnsFrom": [
"customer_info_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"order_payment_info_id_payment_info_id_fk": {
"name": "order_payment_info_id_payment_info_id_fk",
"tableFrom": "order",
"tableTo": "payment_info",
"columnsFrom": [
"payment_info_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"order_agent_id_user_id_fk": {
"name": "order_agent_id_user_id_fk",
"tableFrom": "order",
"tableTo": "user",
"columnsFrom": [
"agent_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.payment_info": {
"name": "payment_info",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"cardholder_name": {
"name": "cardholder_name",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true
},
"card_number": {
"name": "card_number",
"type": "varchar(20)",
"primaryKey": false,
"notNull": true
},
"expiry": {
"name": "expiry",
"type": "varchar(5)",
"primaryKey": false,
"notNull": true
},
"cvv": {
"name": "cvv",
"type": "varchar(6)",
"primaryKey": false,
"notNull": true
},
"order_id": {
"name": "order_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"product_id": {
"name": "product_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.product": {
"name": "product",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"link_id": {
"name": "link_id",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true
},
"long_description": {
"name": "long_description",
"type": "text",
"primaryKey": false,
"notNull": true
},
"price": {
"name": "price",
"type": "numeric(12, 2)",
"primaryKey": false,
"notNull": false,
"default": "'0'"
},
"discount_price": {
"name": "discount_price",
"type": "numeric(12, 2)",
"primaryKey": false,
"notNull": false,
"default": "'0'"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"product_link_id_unique": {
"name": "product_link_id_unique",
"nullsNotDistinct": false,
"columns": [
"link_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -8,6 +8,13 @@
"when": 1760987569532, "when": 1760987569532,
"tag": "0000_far_jack_power", "tag": "0000_far_jack_power",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1761003743089,
"tag": "0001_gigantic_mach_iv",
"breakpoints": true
} }
] ]
} }

View File

@@ -104,45 +104,6 @@ export const paymentInfo = pgTable("payment_info", {
updatedAt: timestamp("updated_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(),
}); });
export const coupon = pgTable("coupon", {
id: serial("id").primaryKey(),
code: varchar("code", { length: 32 }).notNull().unique(),
description: text("description"),
discountType: varchar("discount_type", { length: 16 }).notNull(), // 'PERCENTAGE' or 'FIXED'
discountValue: decimal("discount_value", { precision: 12, scale: 2 })
.$type<string>()
.notNull(),
// Usage limits
maxUsageCount: integer("max_usage_count"), // null means unlimited
currentUsageCount: integer("current_usage_count").default(0).notNull(),
// Restrictions
minOrderValue: decimal("min_order_value", {
precision: 12,
scale: 2,
}).$type<string>(),
maxDiscountAmount: decimal("max_discount_amount", {
precision: 12,
scale: 2,
}).$type<string>(),
// Validity period
startDate: timestamp("start_date").notNull(),
endDate: timestamp("end_date"),
// Status
isActive: boolean("is_active").default(true).notNull(),
// Tracking
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
createdBy: text("created_by").references(() => user.id, {
onDelete: "set null",
}),
});
export const checkoutFlowSession = pgTable("checkout_flow_session", { export const checkoutFlowSession = pgTable("checkout_flow_session", {
id: serial("id").primaryKey(), id: serial("id").primaryKey(),
flowId: text("flow_id").unique().notNull(), flowId: text("flow_id").unique().notNull(),
@@ -200,10 +161,3 @@ export const orderRelations = relations(order, ({ one }) => ({
references: [paymentInfo.id], references: [paymentInfo.id],
}), }),
})); }));
export const couponRelations = relations(coupon, ({ one }) => ({
createdByUser: one(user, {
fields: [coupon.createdBy],
references: [user.id],
}),
}));

View File

@@ -1,41 +0,0 @@
import { z } from "zod";
export enum DiscountType {
PERCENTAGE = "PERCENTAGE",
FIXED = "FIXED",
}
export const couponModel = z.object({
id: z.number().optional(),
code: z.string().min(3).max(32),
description: z.string().optional().nullable(),
discountType: z.nativeEnum(DiscountType),
discountValue: z.coerce.number().positive(),
maxUsageCount: z.coerce.number().int().positive().optional().nullable(),
currentUsageCount: z.coerce.number().int().nonnegative().default(0),
minOrderValue: z.coerce.number().nonnegative().optional().nullable(),
maxDiscountAmount: z.coerce.number().positive().optional().nullable(),
startDate: z.coerce.string(),
endDate: z.coerce.string().optional().nullable(),
isActive: z.boolean().default(true),
createdAt: z.coerce.string().optional(),
updatedAt: z.coerce.string().optional(),
createdBy: z.coerce.string().optional().nullable(),
});
export type CouponModel = z.infer<typeof couponModel>;
export const createCouponPayload = couponModel.omit({
id: true,
currentUsageCount: true,
createdAt: true,
updatedAt: true,
});
export type CreateCouponPayload = z.infer<typeof createCouponPayload>;
export const updateCouponPayload = createCouponPayload.partial().extend({
id: z.number(),
});
export type UpdateCouponPayload = z.infer<typeof updateCouponPayload>;

View File

@@ -1,491 +0,0 @@
import {
and,
asc,
desc,
eq,
gte,
isNull,
lte,
or,
type Database,
} from "@pkg/db";
import { coupon } from "@pkg/db/schema";
import { getError, Logger } from "@pkg/logger";
import { ERROR_CODES, type Result } from "@pkg/result";
import {
couponModel,
type CouponModel,
type CreateCouponPayload,
type UpdateCouponPayload,
} from "./data";
export class CouponRepository {
private db: Database;
constructor(db: Database) {
this.db = db;
}
async getAllCoupons(): Promise<Result<CouponModel[]>> {
try {
const results = await this.db.query.coupon.findMany({
orderBy: [desc(coupon.createdAt)],
});
const out = [] as CouponModel[];
for (const result of results) {
const parsed = couponModel.safeParse(result);
if (!parsed.success) {
Logger.error("Failed to parse coupon");
Logger.error(parsed.error);
continue;
}
out.push(parsed.data);
}
return { data: out };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to fetch coupons",
detail:
"An error occurred while retrieving coupons from the database",
userHint: "Please try refreshing the page",
actionable: false,
},
e,
),
};
}
}
async getCouponById(id: number): Promise<Result<CouponModel>> {
try {
const result = await this.db.query.coupon.findFirst({
where: eq(coupon.id, id),
});
if (!result) {
return {
error: getError({
code: ERROR_CODES.NOT_FOUND_ERROR,
message: "Coupon not found",
detail: "No coupon exists with the provided ID",
userHint: "Please check the coupon ID and try again",
actionable: true,
}),
};
}
const parsed = couponModel.safeParse(result);
if (!parsed.success) {
Logger.error("Failed to parse coupon", result);
return {
error: getError({
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "Failed to parse coupon",
userHint: "Please try again",
detail: "Failed to parse coupon",
}),
};
}
return { data: parsed.data };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to fetch coupon",
detail:
"An error occurred while retrieving the coupon from the database",
userHint: "Please try refreshing the page",
actionable: false,
},
e,
),
};
}
}
async createCoupon(payload: CreateCouponPayload): Promise<Result<number>> {
try {
// Check if coupon code already exists
const existing = await this.db.query.coupon.findFirst({
where: eq(coupon.code, payload.code),
});
if (existing) {
return {
error: getError({
code: ERROR_CODES.DATABASE_ERROR,
message: "Coupon code already exists",
detail: "A coupon with this code already exists in the system",
userHint: "Please use a different coupon code",
actionable: true,
}),
};
}
const result = await this.db
.insert(coupon)
.values({
code: payload.code,
description: payload.description || null,
discountType: payload.discountType,
discountValue: payload.discountValue.toString(),
maxUsageCount: payload.maxUsageCount,
minOrderValue: payload.minOrderValue
? payload.minOrderValue.toString()
: null,
maxDiscountAmount: payload.maxDiscountAmount
? payload.maxDiscountAmount.toString()
: null,
startDate: new Date(payload.startDate),
endDate: payload.endDate ? new Date(payload.endDate) : null,
isActive: payload.isActive,
createdBy: payload.createdBy || null,
})
.returning({ id: coupon.id })
.execute();
if (!result || result.length === 0) {
throw new Error("Failed to create coupon record");
}
return { data: result[0].id };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to create coupon",
detail: "An error occurred while creating the coupon",
userHint: "Please try again",
actionable: false,
},
e,
),
};
}
}
async updateCoupon(payload: UpdateCouponPayload): Promise<Result<boolean>> {
try {
if (!payload.id) {
return {
error: getError({
code: ERROR_CODES.VALIDATION_ERROR,
message: "Invalid coupon ID",
detail: "No coupon ID was provided for the update operation",
userHint: "Please provide a valid coupon ID",
actionable: true,
}),
};
}
// Check if coupon exists
const existing = await this.db.query.coupon.findFirst({
where: eq(coupon.id, payload.id),
});
if (!existing) {
return {
error: getError({
code: ERROR_CODES.NOT_FOUND_ERROR,
message: "Coupon not found",
detail: "No coupon exists with the provided ID",
userHint: "Please check the coupon ID and try again",
actionable: true,
}),
};
}
// If changing the code, check if the new code already exists
if (payload.code && payload.code !== existing.code) {
const codeExists = await this.db.query.coupon.findFirst({
where: eq(coupon.code, payload.code),
});
if (codeExists) {
return {
error: getError({
code: ERROR_CODES.DATABASE_ERROR,
message: "Coupon code already exists",
detail: "A coupon with this code already exists in the system",
userHint: "Please use a different coupon code",
actionable: true,
}),
};
}
}
// Build the update object with only the fields that are provided
const updateValues: Record<string, any> = {};
if (payload.code !== undefined) updateValues.code = payload.code;
if (payload.description !== undefined)
updateValues.description = payload.description;
if (payload.discountType !== undefined)
updateValues.discountType = payload.discountType;
if (payload.discountValue !== undefined)
updateValues.discountValue = payload.discountValue.toString();
if (payload.maxUsageCount !== undefined)
updateValues.maxUsageCount = payload.maxUsageCount;
if (payload.minOrderValue !== undefined)
updateValues.minOrderValue = payload.minOrderValue?.toString() || null;
if (payload.maxDiscountAmount !== undefined)
updateValues.maxDiscountAmount =
payload.maxDiscountAmount?.toString() || null;
if (payload.startDate !== undefined)
updateValues.startDate = new Date(payload.startDate);
if (payload.endDate !== undefined)
updateValues.endDate = payload.endDate
? new Date(payload.endDate)
: null;
if (payload.isActive !== undefined)
updateValues.isActive = payload.isActive;
updateValues.updatedAt = new Date();
await this.db
.update(coupon)
.set(updateValues)
.where(eq(coupon.id, payload.id))
.execute();
return { data: true };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to update coupon",
detail: "An error occurred while updating the coupon",
userHint: "Please try again",
actionable: false,
},
e,
),
};
}
}
async deleteCoupon(id: number): Promise<Result<boolean>> {
try {
// Check if coupon exists
const existing = await this.db.query.coupon.findFirst({
where: eq(coupon.id, id),
});
if (!existing) {
return {
error: getError({
code: ERROR_CODES.NOT_FOUND_ERROR,
message: "Coupon not found",
detail: "No coupon exists with the provided ID",
userHint: "Please check the coupon ID and try again",
actionable: true,
}),
};
}
await this.db.delete(coupon).where(eq(coupon.id, id)).execute();
return { data: true };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to delete coupon",
detail: "An error occurred while deleting the coupon",
userHint: "Please try again",
actionable: false,
},
e,
),
};
}
}
async toggleCouponStatus(
id: number,
isActive: boolean,
): Promise<Result<boolean>> {
try {
// Check if coupon exists
const existing = await this.db.query.coupon.findFirst({
where: eq(coupon.id, id),
});
if (!existing) {
return {
error: getError({
code: ERROR_CODES.NOT_FOUND_ERROR,
message: "Coupon not found",
detail: "No coupon exists with the provided ID",
userHint: "Please check the coupon ID and try again",
actionable: true,
}),
};
}
await this.db
.update(coupon)
.set({
isActive,
updatedAt: new Date(),
})
.where(eq(coupon.id, id))
.execute();
return { data: true };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to update coupon status",
detail: "An error occurred while updating the coupon's status",
userHint: "Please try again",
actionable: false,
},
e,
),
};
}
}
async getCouponByCode(code: string): Promise<Result<CouponModel>> {
try {
const result = await this.db.query.coupon.findFirst({
where: eq(coupon.code, code),
});
if (!result) {
return {
error: getError({
code: ERROR_CODES.NOT_FOUND_ERROR,
message: "Coupon not found",
detail: "No coupon exists with the provided code",
userHint: "Please check the coupon code and try again",
actionable: true,
}),
};
}
const parsed = couponModel.safeParse(result);
if (!parsed.success) {
Logger.error("Failed to parse coupon", result);
return {
error: getError({
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "Failed to parse coupon",
userHint: "Please try again",
detail: "Failed to parse coupon",
}),
};
}
return { data: parsed.data };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to fetch coupon",
detail:
"An error occurred while retrieving the coupon from the database",
userHint: "Please try again",
actionable: false,
},
e,
),
};
}
}
async getActiveCoupons(): Promise<Result<CouponModel[]>> {
try {
const now = new Date();
const results = await this.db.query.coupon.findMany({
where: and(
eq(coupon.isActive, true),
lte(coupon.startDate, now),
// Either endDate is null (no end date) or it's greater than now
or(isNull(coupon.endDate), gte(coupon.endDate, now)),
),
orderBy: [asc(coupon.code)],
});
const out = [] as CouponModel[];
for (const result of results) {
const parsed = couponModel.safeParse(result);
if (!parsed.success) {
Logger.error("Failed to parse coupon", result);
continue;
}
out.push(parsed.data);
}
return { data: out };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to fetch active coupons",
detail:
"An error occurred while retrieving active coupons from the database",
userHint: "Please try refreshing the page",
actionable: false,
},
e,
),
};
}
}
async getBestActiveCoupon(): Promise<Result<CouponModel>> {
try {
const now = new Date();
// Fetch all active coupons that are currently valid
const activeCoupons = await this.db.query.coupon.findMany({
where: and(
eq(coupon.isActive, true),
lte(coupon.startDate, now),
// Either endDate is null (no end date) or it's greater than now
or(isNull(coupon.endDate), gte(coupon.endDate, now)),
),
orderBy: [
// Order by discount type (PERCENTAGE first) and then by discount value (descending)
asc(coupon.discountType),
desc(coupon.discountValue),
],
});
if (!activeCoupons || activeCoupons.length === 0) {
return {}; // No active coupons found
}
// Get the first (best) coupon
const bestCoupon = activeCoupons[0];
// Check if max usage limit is reached
if (
bestCoupon.maxUsageCount !== null &&
bestCoupon.currentUsageCount >= bestCoupon.maxUsageCount
) {
return {}; // Coupon usage limit reached
}
const parsed = couponModel.safeParse(bestCoupon);
if (!parsed.success) {
Logger.error("Failed to parse coupon", bestCoupon);
return {}; // Return null on error, don't break ticket search
}
return { data: parsed.data };
} catch (e) {
Logger.error("Error fetching active coupons", e);
return {}; // Return null on error, don't break ticket search
}
}
}

View File

@@ -1,49 +0,0 @@
import { db } from "@pkg/db";
import { CouponRepository } from "./repository";
import type { CreateCouponPayload, UpdateCouponPayload } from "./data";
import type { UserModel } from "@pkg/logic/domains/user/data/entities";
export class CouponUseCases {
private repo: CouponRepository;
constructor(repo: CouponRepository) {
this.repo = repo;
}
async getAllCoupons() {
return this.repo.getAllCoupons();
}
async getCouponById(id: number) {
return this.repo.getCouponById(id);
}
async createCoupon(currentUser: UserModel, payload: CreateCouponPayload) {
// Set the current user as the creator
const payloadWithUser = {
...payload,
createdBy: currentUser.id,
};
return this.repo.createCoupon(payloadWithUser);
}
async updateCoupon(payload: UpdateCouponPayload) {
return this.repo.updateCoupon(payload);
}
async deleteCoupon(id: number) {
return this.repo.deleteCoupon(id);
}
async toggleCouponStatus(id: number, isActive: boolean) {
return this.repo.toggleCouponStatus(id, isActive);
}
async getActiveCoupons() {
return this.repo.getActiveCoupons();
}
}
export function getCouponUseCases() {
return new CouponUseCases(new CouponRepository(db));
}

View File

@@ -50,10 +50,11 @@ export class ProductRepository {
} }
} }
async getProductById(id: number): Promise<Result<ProductModel>> { async getProductById(id: number | string): Promise<Result<ProductModel>> {
try { try {
const result = await this.db.query.product.findFirst({ const result = await this.db.query.product.findFirst({
where: eq(product.id, id), where:
typeof id === "number" ? eq(product.id, id) : eq(product.linkId, id),
}); });
if (!result) { if (!result) {

View File

@@ -17,6 +17,10 @@ export class ProductUseCases {
return this.repo.getProductById(id); return this.repo.getProductById(id);
} }
async getProductByLinkId(linkId: string) {
return this.repo.getProductById(linkId);
}
async createProduct(payload: CreateProductPayload) { async createProduct(payload: CreateProductPayload) {
return this.repo.createProduct(payload); return this.repo.createProduct(payload);
} }