✅ admin side for now | 🔄 started FE
This commit is contained in:
@@ -1 +0,0 @@
|
|||||||
export * from "@pkg/logic/domains/coupon/data";
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from "@pkg/logic/domains/coupon/repository";
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { createTRPCRouter } from "$lib/trpc/t";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { getCouponUseCases } from "./usecases";
|
|
||||||
import { createCouponPayload, updateCouponPayload } from "./data";
|
|
||||||
import { protectedProcedure } from "$lib/server/trpc/t";
|
|
||||||
|
|
||||||
export const couponRouter = createTRPCRouter({
|
|
||||||
getAllCoupons: protectedProcedure.query(async ({}) => {
|
|
||||||
const controller = getCouponUseCases();
|
|
||||||
return controller.getAllCoupons();
|
|
||||||
}),
|
|
||||||
|
|
||||||
getCouponById: protectedProcedure
|
|
||||||
.input(z.object({ id: z.number() }))
|
|
||||||
.query(async ({ input }) => {
|
|
||||||
const controller = getCouponUseCases();
|
|
||||||
return controller.getCouponById(input.id);
|
|
||||||
}),
|
|
||||||
|
|
||||||
createCoupon: protectedProcedure
|
|
||||||
.input(createCouponPayload)
|
|
||||||
.mutation(async ({ input, ctx }) => {
|
|
||||||
const controller = getCouponUseCases();
|
|
||||||
return controller.createCoupon(ctx.user, input);
|
|
||||||
}),
|
|
||||||
|
|
||||||
updateCoupon: protectedProcedure
|
|
||||||
.input(updateCouponPayload)
|
|
||||||
.mutation(async ({ input }) => {
|
|
||||||
const controller = getCouponUseCases();
|
|
||||||
return controller.updateCoupon(input);
|
|
||||||
}),
|
|
||||||
|
|
||||||
deleteCoupon: protectedProcedure
|
|
||||||
.input(z.object({ id: z.number() }))
|
|
||||||
.mutation(async ({ input }) => {
|
|
||||||
const controller = getCouponUseCases();
|
|
||||||
return controller.deleteCoupon(input.id);
|
|
||||||
}),
|
|
||||||
|
|
||||||
toggleCouponStatus: protectedProcedure
|
|
||||||
.input(z.object({ id: z.number(), isActive: z.boolean() }))
|
|
||||||
.mutation(async ({ input }) => {
|
|
||||||
const controller = getCouponUseCases();
|
|
||||||
return controller.toggleCouponStatus(input.id, input.isActive);
|
|
||||||
}),
|
|
||||||
|
|
||||||
getActiveCoupons: protectedProcedure.query(async ({}) => {
|
|
||||||
const controller = getCouponUseCases();
|
|
||||||
return controller.getActiveCoupons();
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from "@pkg/logic/domains/coupon/usecases";
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Input from "$lib/components/ui/input/input.svelte";
|
|
||||||
import Button from "$lib/components/ui/button/button.svelte";
|
|
||||||
import { Label } from "$lib/components/ui/label";
|
|
||||||
import * as Select from "$lib/components/ui/select";
|
|
||||||
import {
|
|
||||||
type CreateCouponPayload,
|
|
||||||
type UpdateCouponPayload,
|
|
||||||
DiscountType,
|
|
||||||
} from "$lib/domains/coupon/data";
|
|
||||||
import { Textarea } from "$lib/components/ui/textarea";
|
|
||||||
|
|
||||||
export let formData: CreateCouponPayload | UpdateCouponPayload;
|
|
||||||
export let loading = false;
|
|
||||||
export let onSubmit: () => void;
|
|
||||||
export let onCancel: () => void;
|
|
||||||
|
|
||||||
const isNewCoupon = !("id" in formData);
|
|
||||||
const discountTypes = [
|
|
||||||
{ value: DiscountType.PERCENTAGE, label: "Percentage" },
|
|
||||||
{ value: DiscountType.FIXED, label: "Fixed Amount" },
|
|
||||||
];
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<form on:submit|preventDefault={onSubmit} class="space-y-6">
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<!-- Code -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="code">Coupon Code</Label>
|
|
||||||
<Input
|
|
||||||
id="code"
|
|
||||||
bind:value={formData.code}
|
|
||||||
placeholder="e.g. SUMMER20"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Discount Type -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="discountType">Discount Type</Label>
|
|
||||||
<Select.Root
|
|
||||||
type="single"
|
|
||||||
required
|
|
||||||
bind:value={formData.discountType}
|
|
||||||
>
|
|
||||||
<Select.Trigger class="w-full">
|
|
||||||
{discountTypes.find((t) => t.value === formData.discountType)
|
|
||||||
?.label || "Select type"}
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Content>
|
|
||||||
{#each discountTypes as type}
|
|
||||||
<Select.Item value={type.value}>{type.label}</Select.Item>
|
|
||||||
{/each}
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Discount Value -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="discountValue">
|
|
||||||
{formData.discountType === DiscountType.PERCENTAGE
|
|
||||||
? "Discount Percentage"
|
|
||||||
: "Discount Amount"}
|
|
||||||
</Label>
|
|
||||||
<div class="relative">
|
|
||||||
<Input
|
|
||||||
id="discountValue"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={formData.discountType === DiscountType.PERCENTAGE
|
|
||||||
? 100
|
|
||||||
: undefined}
|
|
||||||
step="0.01"
|
|
||||||
bind:value={formData.discountValue}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3"
|
|
||||||
>
|
|
||||||
<span class="text-gray-500">
|
|
||||||
{formData.discountType === DiscountType.PERCENTAGE
|
|
||||||
? "%"
|
|
||||||
: "$"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Description -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="description">Description</Label>
|
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
bind:value={formData.description}
|
|
||||||
placeholder="Describe what this coupon is for"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<!-- Start Date -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="startDate">Start Date</Label>
|
|
||||||
<Input
|
|
||||||
id="startDate"
|
|
||||||
type="date"
|
|
||||||
bind:value={formData.startDate}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- End Date -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="endDate">End Date (Optional)</Label>
|
|
||||||
<Input id="endDate" type="date" bind:value={formData.endDate} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<!-- Max Usage Count -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="maxUsageCount">Max Usage Count (Optional)</Label>
|
|
||||||
<Input
|
|
||||||
id="maxUsageCount"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
step={1}
|
|
||||||
bind:value={formData.maxUsageCount}
|
|
||||||
placeholder="Leave empty for unlimited"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Max Discount Amount -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="maxDiscountAmount">Max Discount Amount (Optional)</Label>
|
|
||||||
<div class="relative">
|
|
||||||
<Input
|
|
||||||
id="maxDiscountAmount"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
step="0.01"
|
|
||||||
bind:value={formData.maxDiscountAmount}
|
|
||||||
placeholder="Leave empty for no limit"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3"
|
|
||||||
>
|
|
||||||
<span class="text-gray-500">$</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Min Order Value -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="minOrderValue">Minimum Order Value (Optional)</Label>
|
|
||||||
<div class="relative">
|
|
||||||
<Input
|
|
||||||
id="minOrderValue"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
step="0.01"
|
|
||||||
bind:value={formData.minOrderValue}
|
|
||||||
placeholder="Leave empty for no minimum"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3"
|
|
||||||
>
|
|
||||||
<span class="text-gray-500">$</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
|
||||||
<div class="flex justify-end space-x-2">
|
|
||||||
<Button variant="outline" onclick={onCancel} disabled={loading}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={loading}>
|
|
||||||
{#if loading}
|
|
||||||
Processing...
|
|
||||||
{:else}
|
|
||||||
{isNewCoupon ? "Create Coupon" : "Update Coupon"}
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { couponVM } from "$lib/domains/coupon/coupon.vm.svelte";
|
|
||||||
import Button from "$lib/components/ui/button/button.svelte";
|
|
||||||
import * as Dialog from "$lib/components/ui/dialog";
|
|
||||||
import * as AlertDialog from "$lib/components/ui/alert-dialog/index";
|
|
||||||
import { Badge } from "$lib/components/ui/badge";
|
|
||||||
import CouponForm from "./coupon-form.svelte";
|
|
||||||
import Title from "$lib/components/atoms/title.svelte";
|
|
||||||
import {
|
|
||||||
PlusIcon,
|
|
||||||
Pencil,
|
|
||||||
Trash2,
|
|
||||||
CheckCircle,
|
|
||||||
XCircle,
|
|
||||||
} from "@lucide/svelte";
|
|
||||||
import Loader from "$lib/components/atoms/loader.svelte";
|
|
||||||
import { DiscountType, type CreateCouponPayload } from "../data";
|
|
||||||
|
|
||||||
let createDialogOpen = $state(false);
|
|
||||||
let editDialogOpen = $state(false);
|
|
||||||
let deleteDialogOpen = $state(false);
|
|
||||||
|
|
||||||
let newCouponData = $state<CreateCouponPayload>(couponVM.getDefaultCoupon());
|
|
||||||
|
|
||||||
async function handleCreateCoupon() {
|
|
||||||
const success = await couponVM.createCoupon(newCouponData);
|
|
||||||
if (success) {
|
|
||||||
createDialogOpen = false;
|
|
||||||
newCouponData = couponVM.getDefaultCoupon();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleUpdateCoupon() {
|
|
||||||
if (!couponVM.selectedCoupon) return;
|
|
||||||
|
|
||||||
const success = await couponVM.updateCoupon({
|
|
||||||
id: couponVM.selectedCoupon.id! ?? -1,
|
|
||||||
...couponVM.selectedCoupon,
|
|
||||||
});
|
|
||||||
if (success) {
|
|
||||||
editDialogOpen = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeleteCoupon() {
|
|
||||||
if (!couponVM.selectedCoupon) return;
|
|
||||||
|
|
||||||
const success = await couponVM.deleteCoupon(couponVM.selectedCoupon.id!);
|
|
||||||
if (success) {
|
|
||||||
deleteDialogOpen = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateString: string | null | undefined) {
|
|
||||||
if (!dateString) return "None";
|
|
||||||
return new Date(dateString).toLocaleDateString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDiscountValue(type: DiscountType, value: number) {
|
|
||||||
if (type === DiscountType.PERCENTAGE) {
|
|
||||||
return `${value}%`;
|
|
||||||
} else {
|
|
||||||
return `$${value.toFixed(2)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
setTimeout(async () => {
|
|
||||||
couponVM.fetchCoupons();
|
|
||||||
}, 1000);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="mb-6 flex items-center justify-between">
|
|
||||||
<Title size="h3" weight="semibold">Coupons</Title>
|
|
||||||
<Button onclick={() => (createDialogOpen = true)}>
|
|
||||||
<PlusIcon class="mr-2 h-4 w-4" />
|
|
||||||
New Coupon
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if couponVM.loading}
|
|
||||||
<div class="flex items-center justify-center py-20">
|
|
||||||
<Loader />
|
|
||||||
</div>
|
|
||||||
{:else if couponVM.coupons.length === 0}
|
|
||||||
<div class="rounded-lg border py-10 text-center">
|
|
||||||
<p class="text-gray-500">
|
|
||||||
No coupons found. Create your first coupon to get started.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full divide-y divide-gray-200">
|
|
||||||
<thead class="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
|
||||||
>Code</th
|
|
||||||
>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
|
||||||
>Discount</th
|
|
||||||
>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
|
||||||
>Valid Period</th
|
|
||||||
>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
|
|
||||||
>Status</th
|
|
||||||
>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500"
|
|
||||||
>Actions</th
|
|
||||||
>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-gray-200 bg-white">
|
|
||||||
{#each couponVM.coupons as coupon}
|
|
||||||
<tr>
|
|
||||||
<td class="whitespace-nowrap px-6 py-4">
|
|
||||||
<div class="font-medium text-gray-900">
|
|
||||||
{coupon.code}
|
|
||||||
</div>
|
|
||||||
{#if coupon.description}
|
|
||||||
<div class="text-sm text-gray-500">
|
|
||||||
{coupon.description}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td class="whitespace-nowrap px-6 py-4">
|
|
||||||
<Badge
|
|
||||||
>{formatDiscountValue(
|
|
||||||
coupon.discountType,
|
|
||||||
coupon.discountValue,
|
|
||||||
)}</Badge
|
|
||||||
>
|
|
||||||
{#if coupon.maxDiscountAmount}
|
|
||||||
<div class="mt-1 text-xs text-gray-500">
|
|
||||||
Max: ${coupon.maxDiscountAmount}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td class="whitespace-nowrap px-6 py-4">
|
|
||||||
<div>From: {formatDate(coupon.startDate)}</div>
|
|
||||||
<div>To: {formatDate(coupon.endDate)}</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td class="whitespace-nowrap px-6 py-4">
|
|
||||||
{#if coupon.isActive}
|
|
||||||
<Badge
|
|
||||||
variant="success"
|
|
||||||
class="flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<CheckCircle class="h-3 w-3" />
|
|
||||||
Active
|
|
||||||
</Badge>
|
|
||||||
{:else}
|
|
||||||
<Badge
|
|
||||||
variant="destructive"
|
|
||||||
class="flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<XCircle class="h-3 w-3" />
|
|
||||||
Inactive
|
|
||||||
</Badge>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td
|
|
||||||
class="flex w-full items-center justify-end gap-2 whitespace-nowrap px-6 py-4 text-right"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onclick={() => {
|
|
||||||
couponVM.selectCoupon(coupon);
|
|
||||||
editDialogOpen = true;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pencil class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onclick={() => {
|
|
||||||
couponVM.selectCoupon(coupon);
|
|
||||||
deleteDialogOpen = true;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant={coupon.isActive
|
|
||||||
? "destructive"
|
|
||||||
: "default"}
|
|
||||||
size="sm"
|
|
||||||
onclick={() =>
|
|
||||||
couponVM.toggleCouponStatus(
|
|
||||||
coupon.id!,
|
|
||||||
!coupon.isActive,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{coupon.isActive ? "Deactivate" : "Activate"}
|
|
||||||
</Button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Create Coupon Dialog -->
|
|
||||||
<Dialog.Root bind:open={createDialogOpen}>
|
|
||||||
<Dialog.Content>
|
|
||||||
<Dialog.Header>
|
|
||||||
<Dialog.Title>Create New Coupon</Dialog.Title>
|
|
||||||
<Dialog.Description>
|
|
||||||
Create a new coupon to offer discounts on flight tickets.
|
|
||||||
</Dialog.Description>
|
|
||||||
</Dialog.Header>
|
|
||||||
|
|
||||||
<CouponForm
|
|
||||||
formData={newCouponData}
|
|
||||||
loading={couponVM.formLoading}
|
|
||||||
onSubmit={handleCreateCoupon}
|
|
||||||
onCancel={() => (createDialogOpen = false)}
|
|
||||||
/>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog.Root>
|
|
||||||
|
|
||||||
<!-- Edit Coupon Dialog -->
|
|
||||||
<Dialog.Root bind:open={editDialogOpen}>
|
|
||||||
<Dialog.Content>
|
|
||||||
<Dialog.Header>
|
|
||||||
<Dialog.Title>Edit Coupon</Dialog.Title>
|
|
||||||
<Dialog.Description>Update this coupon's details.</Dialog.Description>
|
|
||||||
</Dialog.Header>
|
|
||||||
|
|
||||||
{#if couponVM.selectedCoupon}
|
|
||||||
<CouponForm
|
|
||||||
formData={couponVM.selectedCoupon}
|
|
||||||
loading={couponVM.formLoading}
|
|
||||||
onSubmit={handleUpdateCoupon}
|
|
||||||
onCancel={() => (editDialogOpen = false)}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog.Root>
|
|
||||||
|
|
||||||
<!-- Delete Confirmation Dialog -->
|
|
||||||
<AlertDialog.Root bind:open={deleteDialogOpen}>
|
|
||||||
<AlertDialog.Content>
|
|
||||||
<AlertDialog.Header>
|
|
||||||
<AlertDialog.Title>Delete Coupon</AlertDialog.Title>
|
|
||||||
<AlertDialog.Description>
|
|
||||||
Are you sure you want to delete this coupon? This action cannot be
|
|
||||||
undone.
|
|
||||||
</AlertDialog.Description>
|
|
||||||
</AlertDialog.Header>
|
|
||||||
|
|
||||||
{#if couponVM.selectedCoupon}
|
|
||||||
<div class="mb-4 rounded-md bg-gray-100 p-4">
|
|
||||||
<div class="font-bold">{couponVM.selectedCoupon.code}</div>
|
|
||||||
{#if couponVM.selectedCoupon.description}
|
|
||||||
<div class="text-sm text-gray-600">
|
|
||||||
{couponVM.selectedCoupon.description}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="mt-1">
|
|
||||||
Discount: {formatDiscountValue(
|
|
||||||
couponVM.selectedCoupon.discountType,
|
|
||||||
couponVM.selectedCoupon.discountValue,
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<AlertDialog.Footer>
|
|
||||||
<AlertDialog.Cancel onclick={() => (deleteDialogOpen = false)}>
|
|
||||||
Cancel
|
|
||||||
</AlertDialog.Cancel>
|
|
||||||
<AlertDialog.Action
|
|
||||||
onclick={handleDeleteCoupon}
|
|
||||||
disabled={couponVM.formLoading}
|
|
||||||
>
|
|
||||||
{couponVM.formLoading ? "Deleting..." : "Delete"}
|
|
||||||
</AlertDialog.Action>
|
|
||||||
</AlertDialog.Footer>
|
|
||||||
</AlertDialog.Content>
|
|
||||||
</AlertDialog.Root>
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { authRouter } from "$lib/domains/auth/domain/router";
|
import { authRouter } from "$lib/domains/auth/domain/router";
|
||||||
import { ckflowRouter } from "$lib/domains/ckflow/router";
|
import { ckflowRouter } from "$lib/domains/ckflow/router";
|
||||||
import { couponRouter } from "$lib/domains/coupon/router";
|
|
||||||
import { customerInfoRouter } from "$lib/domains/customerinfo/router";
|
import { customerInfoRouter } from "$lib/domains/customerinfo/router";
|
||||||
import { orderRouter } from "$lib/domains/order/domain/router";
|
import { orderRouter } from "$lib/domains/order/domain/router";
|
||||||
import { productRouter } from "$lib/domains/product/router";
|
import { productRouter } from "$lib/domains/product/router";
|
||||||
@@ -12,7 +11,6 @@ export const router = createTRPCRouter({
|
|||||||
user: userRouter,
|
user: userRouter,
|
||||||
order: orderRouter,
|
order: orderRouter,
|
||||||
ckflow: ckflowRouter,
|
ckflow: ckflowRouter,
|
||||||
coupon: couponRouter,
|
|
||||||
product: productRouter,
|
product: productRouter,
|
||||||
customerInfo: customerInfoRouter,
|
customerInfo: customerInfoRouter,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { pageTitle } from "$lib/hooks/page-title.svelte";
|
|
||||||
import CouponList from "$lib/domains/coupon/view/coupon-list.svelte";
|
|
||||||
import {
|
|
||||||
Tabs,
|
|
||||||
TabsContent,
|
|
||||||
TabsList,
|
|
||||||
TabsTrigger,
|
|
||||||
} from "$lib/components/ui/tabs/index";
|
|
||||||
|
|
||||||
pageTitle.set("Settings");
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="container mx-auto py-8">
|
|
||||||
<!-- <Tabs value="coupons" class="w-full">
|
|
||||||
<TabsList class="mb-6">
|
|
||||||
<TabsTrigger value="coupons">Coupon Management</TabsTrigger>
|
|
||||||
<!-- Add more tabs here as needed -->
|
|
||||||
<!-- </TabsList> -->
|
|
||||||
|
|
||||||
<!-- <TabsContent value="coupons"> -->
|
|
||||||
<CouponList />
|
|
||||||
<!-- </TabsContent> -->
|
|
||||||
|
|
||||||
<!-- Add more tab content here as needed -->
|
|
||||||
<!-- </Tabs> -->
|
|
||||||
</div>
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<span>show checkout confirmation status here</span>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
@@ -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}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
148
apps/frontend/src/lib/domains/checkout/payment-summary.svelte
Normal file
148
apps/frontend/src/lib/domains/checkout/payment-summary.svelte
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -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}
|
||||||
95
apps/frontend/src/lib/domains/checkout/total.calculator.ts
Normal file
95
apps/frontend/src/lib/domains/checkout/total.calculator.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
1
apps/frontend/src/lib/domains/customerinfo/data.ts
Normal file
1
apps/frontend/src/lib/domains/customerinfo/data.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "@pkg/logic/domains/customerinfo/data";
|
||||||
1
apps/frontend/src/lib/domains/customerinfo/repository.ts
Normal file
1
apps/frontend/src/lib/domains/customerinfo/repository.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "@pkg/logic/domains/customerinfo/repository";
|
||||||
40
apps/frontend/src/lib/domains/customerinfo/router.ts
Normal file
40
apps/frontend/src/lib/domains/customerinfo/router.ts
Normal 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);
|
||||||
|
}),
|
||||||
|
});
|
||||||
1
apps/frontend/src/lib/domains/customerinfo/usecases.ts
Normal file
1
apps/frontend/src/lib/domains/customerinfo/usecases.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "@pkg/logic/domains/customerinfo/usecases";
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
1
apps/frontend/src/lib/domains/product/data.ts
Normal file
1
apps/frontend/src/lib/domains/product/data.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "@pkg/logic/domains/product/data";
|
||||||
@@ -1,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();
|
||||||
1
apps/frontend/src/lib/domains/product/repository.ts
Normal file
1
apps/frontend/src/lib/domains/product/repository.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "@pkg/logic/domains/product/repository";
|
||||||
10
apps/frontend/src/lib/domains/product/router.ts
Normal file
10
apps/frontend/src/lib/domains/product/router.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { protectedProcedure } from "$lib/server/trpc/t";
|
||||||
|
import { createTRPCRouter } from "$lib/trpc/t";
|
||||||
|
import { getProductUseCases } from "./usecases";
|
||||||
|
|
||||||
|
export const productRouter = createTRPCRouter({
|
||||||
|
getAllProducts: protectedProcedure.query(async ({}) => {
|
||||||
|
const controller = getProductUseCases();
|
||||||
|
return controller.getAllProducts();
|
||||||
|
}),
|
||||||
|
});
|
||||||
1
apps/frontend/src/lib/domains/product/usecases.ts
Normal file
1
apps/frontend/src/lib/domains/product/usecases.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "@pkg/logic/domains/product/usecases";
|
||||||
111
apps/frontend/src/lib/domains/product/view/product-form.svelte
Normal file
111
apps/frontend/src/lib/domains/product/view/product-form.svelte
Normal 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>
|
||||||
288
apps/frontend/src/lib/domains/product/view/product-list.svelte
Normal file
288
apps/frontend/src/lib/domains/product/view/product-list.svelte
Normal 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>
|
||||||
@@ -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: "",
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Icon from "$lib/components/atoms/icon.svelte";
|
|
||||||
import Title from "$lib/components/atoms/title.svelte";
|
|
||||||
import { Badge } from "$lib/components/ui/badge";
|
|
||||||
import TagIcon from "~icons/lucide/tag";
|
|
||||||
import { flightTicketStore } from "../../../data/store";
|
|
||||||
import { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte";
|
|
||||||
|
|
||||||
let appliedCoupon = $derived($flightTicketStore?.priceDetails?.appliedCoupon);
|
|
||||||
let couponDescription = $derived(
|
|
||||||
$flightTicketStore?.priceDetails?.couponDescription || "",
|
|
||||||
);
|
|
||||||
let discountAmount = $derived(
|
|
||||||
$flightTicketStore?.priceDetails?.discountAmount || 0,
|
|
||||||
);
|
|
||||||
let basePrice = $derived($flightTicketStore?.priceDetails?.basePrice || 0);
|
|
||||||
let discountPercentage = $derived(
|
|
||||||
basePrice > 0 ? Math.round((discountAmount / basePrice) * 100) : 0,
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if appliedCoupon && discountAmount > 0}
|
|
||||||
<div
|
|
||||||
class="flex flex-col gap-2 rounded-lg border border-green-200 bg-green-50 p-4"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-2 text-green-700">
|
|
||||||
<Icon icon={TagIcon} cls="h-5 w-5" />
|
|
||||||
<Title size="p" weight="medium">Coupon Applied</Title>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
class="ml-auto border-green-600 text-green-600"
|
|
||||||
>
|
|
||||||
{discountPercentage}% OFF
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="font-medium">{appliedCoupon}</span>
|
|
||||||
<span>-{convertAndFormatCurrency(discountAmount)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if couponDescription}
|
|
||||||
<p class="text-sm text-green-700">{couponDescription}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,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>
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { createTRPCRouter } from "../t";
|
|
||||||
import { userRouter } from "$lib/domains/user/domain/router";
|
|
||||||
import { authRouter } from "$lib/domains/auth/domain/router";
|
import { authRouter } from "$lib/domains/auth/domain/router";
|
||||||
import { ticketRouter } from "$lib/domains/ticket/domain/router";
|
import { ckflowRouter } from "$lib/domains/ckflow/domain/router";
|
||||||
import { currencyRouter } from "$lib/domains/currency/domain/router";
|
import { currencyRouter } from "$lib/domains/currency/domain/router";
|
||||||
import { orderRouter } from "$lib/domains/order/domain/router";
|
import { orderRouter } from "$lib/domains/order/domain/router";
|
||||||
import { ckflowRouter } from "$lib/domains/ckflow/domain/router";
|
import { productRouter } from "$lib/domains/product/router";
|
||||||
|
import { userRouter } from "$lib/domains/user/domain/router";
|
||||||
|
import { createTRPCRouter } from "../t";
|
||||||
|
|
||||||
export const router = createTRPCRouter({
|
export const router = createTRPCRouter({
|
||||||
auth: authRouter,
|
auth: authRouter,
|
||||||
user: userRouter,
|
user: userRouter,
|
||||||
ticket: ticketRouter,
|
|
||||||
currency: currencyRouter,
|
currency: currencyRouter,
|
||||||
order: orderRouter,
|
order: orderRouter,
|
||||||
ckflow: ckflowRouter,
|
ckflow: ckflowRouter,
|
||||||
|
product: productRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Router = typeof router;
|
export type Router = typeof router;
|
||||||
|
|||||||
7
apps/frontend/src/routes/(main)/[pageid]/+page.server.ts
Normal file
7
apps/frontend/src/routes/(main)/[pageid]/+page.server.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { getProductUseCases } from "$lib/domains/product/usecases";
|
||||||
|
import type { PageServerLoad } from "./$types";
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params }) => {
|
||||||
|
const pid = params.pageid;
|
||||||
|
return await getProductUseCases().getProductByLinkId(pid);
|
||||||
|
};
|
||||||
@@ -1,4 +1,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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
10
context.md
10
context.md
@@ -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.
|
||||||
|
|||||||
1
packages/db/migrations/0001_gigantic_mach_iv.sql
Normal file
1
packages/db/migrations/0001_gigantic_mach_iv.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE "coupon" CASCADE;
|
||||||
901
packages/db/migrations/meta/0001_snapshot.json
Normal file
901
packages/db/migrations/meta/0001_snapshot.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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],
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|||||||
@@ -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>;
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user