Compare commits

...

6 Commits

Author SHA1 Message Date
user
319384c334 🎉👏 done 2025-10-21 19:27:38 +03:00
user
f0fa53a4e5 order creation logic fix, refactor & cleanup on admin end 2025-10-21 19:20:56 +03:00
user
b6bdb6d7e8 🔄 some messaging refactor 2025-10-21 18:44:21 +03:00
user
f9f743eb15 UI refactor and some logical fixes 2025-10-21 18:41:19 +03:00
user
1a89236449 ui refactor: yuhhhhhh 80% done 2025-10-21 18:10:39 +03:00
user
6a0571fcfa start: redesign 2025-10-21 17:51:56 +03:00
81 changed files with 1066 additions and 1878 deletions

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,7 @@
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div> <div>
<span class="text-xs text-gray-500">Full Name</span> <span class="text-xs text-gray-500">Full Name</span>
<p> <p class="mt-1 font-medium">
{customerInfo.firstName} {customerInfo.firstName}
{#if customerInfo.middleName} {#if customerInfo.middleName}
{customerInfo.middleName} {customerInfo.middleName}
@@ -24,33 +24,36 @@
</div> </div>
<div> <div>
<span class="text-xs text-gray-500">Email</span> <span class="text-xs text-gray-500">Email</span>
<p>{customerInfo.email}</p> <p class="mt-1">{customerInfo.email}</p>
</div> </div>
<div> <div>
<span class="text-xs text-gray-500">Phone Number</span> <span class="text-xs text-gray-500">Phone Number</span>
<p>{customerInfo.phoneCountryCode} {customerInfo.phoneNumber}</p> <p class="mt-1">
{customerInfo.phoneCountryCode}
{customerInfo.phoneNumber}
</p>
</div> </div>
<div> <div>
<span class="text-xs text-gray-500">City</span> <span class="text-xs text-gray-500">City</span>
<p>{customerInfo.city}</p> <p class="mt-1">{customerInfo.city}</p>
</div> </div>
<div> <div>
<span class="text-xs text-gray-500">State</span> <span class="text-xs text-gray-500">State</span>
<p>{customerInfo.state}</p> <p class="mt-1">{customerInfo.state}</p>
</div> </div>
<div> <div>
<span class="text-xs text-gray-500">Country</span> <span class="text-xs text-gray-500">Country</span>
<p>{customerInfo.country}</p> <p class="mt-1">{customerInfo.country}</p>
</div> </div>
<div> <div>
<span class="text-xs text-gray-500">Zip Code</span> <span class="text-xs text-gray-500">Zip Code</span>
<p>{customerInfo.zipCode}</p> <p class="mt-1">{customerInfo.zipCode}</p>
</div> </div>
<div class="md:col-span-2"> <div class="md:col-span-2">
<span class="text-xs text-gray-500">Address</span> <span class="text-xs text-gray-500">Address</span>
<p>{customerInfo.address}</p> <p class="mt-1">{customerInfo.address}</p>
{#if customerInfo.address2} {#if customerInfo.address2}
<p class="text-sm text-gray-600">{customerInfo.address2}</p> <p class="mt-1 text-sm text-gray-600">{customerInfo.address2}</p>
{/if} {/if}
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,33 +20,8 @@ class CheckoutViewModel {
checkoutSubmitted = $state(false); checkoutSubmitted = $state(false);
livenessPinger: NodeJS.Timer | undefined = $state(undefined);
reset() { reset() {
this.checkoutStep = CheckoutStep.Initial; 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;
}
// TODO: no need to ping now REMOVE THIS PINGING LOGIC
} }
async checkout() { async checkout() {
@@ -88,6 +63,7 @@ class CheckoutViewModel {
} }
const pInfoParsed = paymentInfoPayloadModel.safeParse({ const pInfoParsed = paymentInfoPayloadModel.safeParse({
orderId: -1,
method: PaymentMethod.Card, method: PaymentMethod.Card,
cardDetails: paymentInfoVM.cardDetails, cardDetails: paymentInfoVM.cardDetails,
productId: get(productStore)?.id, productId: get(productStore)?.id,
@@ -134,7 +110,7 @@ class CheckoutViewModel {
description: "Redirecting, please wait...", description: "Redirecting, please wait...",
}); });
setTimeout(() => { setTimeout(() => {
window.location.href = `/checkout/success?oid=${out.data}`; window.location.href = `/checkout/success?uid=${out.data}`;
}, 500); }, 500);
return true; return true;
} catch (e) { } catch (e) {

View File

@@ -6,16 +6,12 @@
import { checkoutVM } from "$lib/domains/checkout/checkout.vm.svelte"; import { checkoutVM } from "$lib/domains/checkout/checkout.vm.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 { CheckoutStep } from "$lib/domains/order/data/entities";
import { cn } from "$lib/utils";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { toast } from "svelte-sonner"; import { toast } from "svelte-sonner";
import RightArrowIcon from "~icons/solar/arrow-right-broken"; import RightArrowIcon from "~icons/solar/arrow-right-broken";
import CustomerPiiForm from "../customerinfo/view/customer-pii-form.svelte"; import CustomerPiiForm from "../customerinfo/view/customer-pii-form.svelte";
import { customerInfoVM } from "../customerinfo/view/customerinfo.vm.svelte"; import { customerInfoVM } from "../customerinfo/view/customerinfo.vm.svelte";
const cardStyle =
"flex w-full flex-col gap-4 rounded-lg bg-white p-4 shadow-lg md:p-8";
$effect(() => { $effect(() => {
const personalInfo = customerInfoVM.customerInfo; const personalInfo = customerInfoVM.customerInfo;
@@ -79,13 +75,13 @@
</script> </script>
{#if customerInfoVM.customerInfo} {#if customerInfoVM.customerInfo}
<div class={cn(cardStyle, "border-2 border-gray-200")}> <div class="flex w-full flex-col gap-6">
<Title size="h5">Personal Info</Title> <Title size="h4" weight="medium">Personal Info</Title>
<CustomerPiiForm bind:info={customerInfoVM.customerInfo} /> <CustomerPiiForm bind:info={customerInfoVM.customerInfo} />
</div> </div>
{/if} {/if}
<div class="flex flex-col items-center justify-between gap-4 md:flex-row"> <div class="mt-8 flex flex-col items-center justify-between gap-4 border-t border-gray-200 pt-6 md:flex-row">
<div></div> <div></div>
<Button <Button

View File

@@ -16,9 +16,6 @@
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";
const cardStyle =
"flex w-full flex-col gap-4 rounded-lg bg-white p-4 shadow-lg md:p-8";
async function goBack() { async function goBack() {
if ((await ckFlowVM.onBackToPIIBtnClick()) !== true) { if ((await ckFlowVM.onBackToPIIBtnClick()) !== true) {
return; return;
@@ -76,23 +73,23 @@
}); });
</script> </script>
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-8">
<div class={cardStyle}> <div class="flex w-full flex-col gap-6">
<Title size="h4">Order Summary</Title> <Title size="h4" weight="medium">Order Summary</Title>
<OrderSummary /> <OrderSummary />
</div> </div>
<div class={cardStyle}> <div class="flex w-full flex-col gap-6 border-t border-gray-200 pt-8">
<Title size="h4">Billing Details</Title> <Title size="h4" weight="medium">Billing Details</Title>
<BillingDetailsForm info={billingDetailsVM.billingDetails} /> <BillingDetailsForm info={billingDetailsVM.billingDetails} />
</div> </div>
<div class={cardStyle}> <div class="flex w-full flex-col gap-6 border-t border-gray-200 pt-8">
<Title size="h4">Payment Details</Title> <Title size="h4" weight="medium">Payment Details</Title>
<PaymentForm /> <PaymentForm />
</div> </div>
<div class="flex flex-col items-center justify-between gap-4 md:flex-row"> <div class="flex flex-col items-center justify-between gap-4 border-t border-gray-200 pt-6 md:flex-row">
<Button variant="secondary" onclick={goBack} class="w-full md:w-max"> <Button variant="secondary" onclick={goBack} class="w-full md:w-max">
<Icon icon={RightArrowIcon} cls="w-auto h-6 rotate-180" /> <Icon icon={RightArrowIcon} cls="w-auto h-6 rotate-180" />
Back Back

View File

@@ -24,73 +24,77 @@
}); });
</script> </script>
<div class="flex flex-col gap-4 rounded-lg bg-white p-4 drop-shadow-lg md:p-8"> <div class="flex flex-col gap-6">
<Title size="h4" weight="medium">Payment Summary</Title> <Title size="h4" weight="medium">Order Summary</Title>
<div class="h-0.5 w-full border-t-2 border-gray-200"></div>
{#if !calculating} {#if !calculating}
<!-- Product Information --> <!-- Product Information -->
{#if $productStore} {#if $productStore}
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-3 border-b border-gray-200 pb-6">
<Title size="p" weight="medium">{$productStore.title}</Title> <Title size="p" weight="medium">
<p class="text-sm text-gray-600">{$productStore.description}</p> {$productStore.title}
</Title>
<p class="text-sm leading-relaxed text-gray-600">
{$productStore.description}
</p>
</div> </div>
{/if} {/if}
<!-- Price Breakdown --> <!-- Price Breakdown -->
<div class="mt-2 flex flex-col gap-2 border-t pt-4"> <div class="flex flex-col gap-3 border-b border-gray-200 pb-6">
<div class="flex justify-between text-sm"> <div class="flex items-center justify-between text-sm">
<span>Base Price</span> <span class="text-gray-600">Base Price</span>
<span>{convertAndFormatCurrency(priceDetails.basePrice)}</span> <span class="font-medium text-gray-900">
{convertAndFormatCurrency(priceDetails.basePrice)}
</span>
</div> </div>
{#if priceDetails.discountAmount > 0} {#if priceDetails.discountAmount > 0}
<div class="flex justify-between text-sm text-green-600"> <div class="flex items-center justify-between text-sm">
<span>Discount</span> <span class="text-gray-600">Discount</span>
<span <span class="font-medium text-green-600">
>-{convertAndFormatCurrency( -{convertAndFormatCurrency(priceDetails.discountAmount)}
priceDetails.discountAmount, </span>
)}</span
>
</div> </div>
<div class="flex justify-between text-sm"> <div class="flex items-center justify-between text-sm">
<span>Display Price</span> <span class="text-gray-600">Display Price</span>
<span <span class="font-medium text-gray-900">
>{convertAndFormatCurrency( {convertAndFormatCurrency(priceDetails.displayPrice)}
priceDetails.displayPrice, </span>
)}</span
>
</div> </div>
{/if} {/if}
</div> </div>
<!-- Final Total --> <!-- Final Total -->
<div class="mt-4 flex flex-col gap-2 border-t pt-4"> <div class="flex items-center justify-between">
<div class="flex justify-between font-medium"> <span class="text-base font-semibold text-gray-900">
<Title size="h5" weight="medium" Total ({$currencyStore.code})
>Total ({$currencyStore.code})</Title </span>
> <span class="text-2xl font-bold text-gray-900">
<span class="text-lg" {convertAndFormatCurrency(priceDetails.orderPrice)}
>{convertAndFormatCurrency(priceDetails.orderPrice)}</span </span>
> </div>
<!-- Security Badge -->
<div class="mt-4 rounded-lg bg-blue-50 p-4">
<div class="flex items-start gap-3">
<svg class="mt-0.5 h-5 w-5 flex-shrink-0 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<div class="flex-1">
<p class="text-xs font-medium text-blue-900">Secure Checkout</p>
<p class="mt-1 text-xs text-blue-700">
Your payment information is encrypted and secure
</p>
</div>
</div> </div>
</div> </div>
{:else} {:else}
<div class="grid place-items-center p-8 text-center"> <div class="grid place-items-center py-12">
<span class="text-gray-600">Calculating...</span> <div class="flex items-center gap-3">
<div class="h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-primary"></div>
<span class="text-sm text-gray-600">Calculating...</span>
</div>
</div> </div>
{/if} {/if}
<!-- Important Information -->
<div class="mt-4 rounded-lg bg-gray-50 p-4 text-xs text-gray-600">
<p class="mb-2 font-medium">Important Information:</p>
<ul class="list-disc space-y-1 pl-4">
<li>Price includes all applicable taxes and fees</li>
<li>
Cancellation and refund policies may apply as per our terms of
service
</li>
<li>Payment will be processed securely upon order confirmation</li>
</ul>
</div>
</div> </div>

View File

@@ -3,35 +3,35 @@
import { onDestroy, onMount } from "svelte"; import { onDestroy, onMount } from "svelte";
const initialMessages = [ const initialMessages = [
"Processing your payment securely...", "Just a moment...",
"Getting everything ready for you...", "Please wait...",
"Setting up your transaction...", "Processing...",
"Starting the payment process...", "Working on it...",
"Initiating secure payment...", "One moment please...",
]; ];
const fiveSecondMessages = [ const fiveSecondMessages = [
"Almost there! Just finalizing your payment details...", "Still processing...",
"Just a few more moments while we confirm everything...", "Just a bit longer...",
"We're processing your payment with care...", "Almost there...",
"Double-checking all the details...", "This may take a moment...",
"Making sure everything is in order...", "Thank you for waiting...",
]; ];
const tenSecondMessages = [ const tenSecondMessages = [
"Thank you for your patience. We're making sure everything is perfect...", "Thanks for your patience...",
"Still working on it thanks for being patient...", "Still working on it...",
"We're double-checking everything to ensure a smooth transaction...", "We appreciate your patience...",
"Nearly there! Just completing the final security checks...", "Processing securely...",
"Your patience is appreciated while we process this securely...", "Just a little longer...",
]; ];
const twentySecondMessages = [ const twentySecondMessages = [
"Still working on it! Your transaction security is our top priority...", "Thank you for waiting...",
"We appreciate your continued patience while we secure your transaction...", "Still processing thanks for being patient...",
"Taking extra care to process your payment safely...", "We're working on it...",
"Still working diligently to complete your transaction...", "Your patience is appreciated...",
"Thank you for waiting we're ensuring everything is processed correctly...", "Almost done...",
]; ];
const getRandomMessage = (messages: string[]) => { const getRandomMessage = (messages: string[]) => {

View File

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

View File

@@ -74,7 +74,7 @@ class ActionRunner {
return; return;
} }
toast.success("Your booking has been confirmed", { toast.success("Checkout completed successfully", {
description: "Redirecting, please wait...", description: "Redirecting, please wait...",
}); });
@@ -160,9 +160,9 @@ class ActionRunner {
await ckFlowVM.cleanupFlowInfo(); await ckFlowVM.cleanupFlowInfo();
ckFlowVM.reset(); ckFlowVM.reset();
checkoutVM.reset(); checkoutVM.reset();
const tid = page.params.tid as any as string; const plid = page.params.plid as any as string;
const sid = page.params.sid as any as string; const sid = page.params.sid as any as string;
window.location.replace(`/checkout/terminated?sid=${sid}&tid=${tid}`); window.location.replace(`/checkout/terminated?sid=${sid}&plid=${plid}`);
} }
} }
@@ -174,9 +174,8 @@ export class CKFlowViewModel {
otpCode: string | undefined = $state(undefined); otpCode: string | undefined = $state(undefined);
poller: NodeJS.Timer | undefined = undefined; _flowPoller: NodeJS.Timeout | undefined = undefined;
pinger: NodeJS.Timer | undefined = undefined; _flowPinger: NodeJS.Timeout | undefined = undefined;
priceFetcher: NodeJS.Timer | undefined = undefined;
// Data synchronization control // Data synchronization control
private personalInfoDebounceTimer: NodeJS.Timeout | null = null; private personalInfoDebounceTimer: NodeJS.Timeout | null = null;
@@ -334,27 +333,27 @@ export class CKFlowViewModel {
} }
private clearPoller() { private clearPoller() {
if (this.poller) { if (this._flowPoller) {
clearInterval(this.poller); clearInterval(this._flowPoller);
} }
} }
private clearPinger() { private clearPinger() {
if (this.pinger) { if (this._flowPinger) {
clearInterval(this.pinger); clearInterval(this._flowPinger);
} }
} }
private async startPolling() { private async startPolling() {
this.clearPoller(); this.clearPoller();
this.poller = setInterval(() => { this._flowPoller = setInterval(() => {
this.refreshFlowInfo(); this.refreshFlowInfo();
}, 2000); }, 2000);
} }
private async startPinging() { private async startPinging() {
this.clearPinger(); this.clearPinger();
this.pinger = setInterval(() => { this._flowPinger = setInterval(() => {
this.pingFlow(); this.pingFlow();
}, 30000); // Every 30 seconds }, 30000); // Every 30 seconds
} }

View File

@@ -3,14 +3,24 @@
import Title from "$lib/components/atoms/title.svelte"; import Title from "$lib/components/atoms/title.svelte";
import Input from "$lib/components/ui/input/input.svelte"; import Input from "$lib/components/ui/input/input.svelte";
import * as Select from "$lib/components/ui/select"; import * as Select from "$lib/components/ui/select";
import * as Popover from "$lib/components/ui/popover";
import * as Command from "$lib/components/ui/command";
import { Button } from "$lib/components/ui/button";
import { COUNTRIES_SELECT } from "$lib/core/countries"; import { COUNTRIES_SELECT } from "$lib/core/countries";
import { capitalize } from "$lib/core/string.utils"; import { capitalize } from "$lib/core/string.utils";
import { PHONE_COUNTRY_CODES } from "@pkg/logic/core/data/phonecc"; import { PHONE_COUNTRY_CODES } from "@pkg/logic/core/data/phonecc";
import { cn } from "$lib/utils";
import { tick } from "svelte";
import ChevronsUpDownIcon from "~icons/lucide/chevrons-up-down";
import CheckIcon from "~icons/lucide/check";
import type { CustomerInfoModel } from "../data"; import type { CustomerInfoModel } from "../data";
import { customerInfoVM } from "./customerinfo.vm.svelte"; import { customerInfoVM } from "./customerinfo.vm.svelte";
let { info = $bindable() }: { info: CustomerInfoModel } = $props(); let { info = $bindable() }: { info: CustomerInfoModel } = $props();
let phoneCodeOpen = $state(false);
let phoneCodeTriggerRef = $state<HTMLButtonElement>(null!);
function onSubmit(e: SubmitEvent) { function onSubmit(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
customerInfoVM.validateCustomerInfo(info); customerInfoVM.validateCustomerInfo(info);
@@ -19,6 +29,13 @@
function debounceValidate() { function debounceValidate() {
customerInfoVM.debounceValidate(info); customerInfoVM.debounceValidate(info);
} }
function closePhoneCodeAndFocus() {
phoneCodeOpen = false;
tick().then(() => {
phoneCodeTriggerRef?.focus();
});
}
</script> </script>
<form action="#" class="flex w-full flex-col gap-4" onsubmit={onSubmit}> <form action="#" class="flex w-full flex-col gap-4" onsubmit={onSubmit}>
@@ -74,32 +91,54 @@
<!-- Phone Number Field --> <!-- Phone Number Field -->
<LabelWrapper label="Phone Number" error={customerInfoVM.errors.phoneNumber}> <LabelWrapper label="Phone Number" error={customerInfoVM.errors.phoneNumber}>
<div class="flex gap-2"> <div class="flex gap-2">
<Select.Root <Popover.Root bind:open={phoneCodeOpen}>
type="single" <Popover.Trigger bind:ref={phoneCodeTriggerRef}>
required {#snippet child({ props })}
onValueChange={(code) => { <Button
info.phoneCountryCode = code; {...props}
debounceValidate(); variant="outline"
}} class="w-32 justify-between"
name="phoneCode" role="combobox"
> aria-expanded={phoneCodeOpen}
<Select.Trigger class="w-28"> >
{#if info.phoneCountryCode} {info.phoneCountryCode || "Select"}
{info.phoneCountryCode} <ChevronsUpDownIcon class="h-4 w-4 opacity-50" />
{:else} </Button>
Select {/snippet}
{/if} </Popover.Trigger>
</Select.Trigger> <Popover.Content class="w-[300px] p-0">
<Select.Content> <Command.Root>
{#each PHONE_COUNTRY_CODES as { country, phoneCode }} <Command.Input placeholder="Search country..." class="h-9" />
<Select.Item value={phoneCode}> <Command.List class="command-scrollbar max-h-[300px]">
<span class="flex items-center gap-2"> <Command.Empty>No country found.</Command.Empty>
{phoneCode} ({country}) <Command.Group>
</span> {#each PHONE_COUNTRY_CODES as { country, phoneCode } (country)}
</Select.Item> <Command.Item
{/each} value={`${phoneCode} ${country}`}
</Select.Content> onSelect={() => {
</Select.Root> info.phoneCountryCode = phoneCode;
debounceValidate();
closePhoneCodeAndFocus();
}}
>
<CheckIcon
class={cn(
"h-4 w-4 flex-shrink-0",
info.phoneCountryCode !== phoneCode &&
"text-transparent",
)}
/>
<span class="flex flex-1 items-center justify-between gap-3">
<span class="font-semibold text-gray-900">{phoneCode}</span>
<span class="text-sm text-gray-600">{country}</span>
</span>
</Command.Item>
{/each}
</Command.Group>
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>
<Input <Input
placeholder="Phone Number" placeholder="Phone Number"

View File

@@ -90,7 +90,7 @@ export class OrderRepository {
error: getError( error: getError(
{ {
code: ERROR_CODES.INTERNAL_SERVER_ERROR, code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "An error occured while finding booking", message: "An error occured while finding order",
userHint: "Please try again later", userHint: "Please try again later",
detail: "An error occured while parsing order", detail: "An error occured while parsing order",
}, },

View File

@@ -24,9 +24,7 @@ export const orderRouter = createTRPCRouter({
createOrder: publicProcedure createOrder: publicProcedure
.input(createOrderPayloadModel) .input(createOrderPayloadModel)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const paymentInfoUC = new PaymentInfoUseCases( const piuc = new PaymentInfoUseCases(new PaymentInfoRepository(db));
new PaymentInfoRepository(db),
);
const orderController = new OrderController(new OrderRepository(db)); const orderController = new OrderController(new OrderRepository(db));
const customerInfoController = getCustomerInfoController(); const customerInfoController = getCustomerInfoController();
const productUC = getProductUseCases(); const productUC = getProductUseCases();
@@ -73,7 +71,7 @@ export const orderRouter = createTRPCRouter({
let paymentInfoId: number | undefined = undefined; let paymentInfoId: number | undefined = undefined;
if (input.paymentInfo) { if (input.paymentInfo) {
Logger.info("Creating payment information"); Logger.info("Creating payment information");
const paymentRes = await paymentInfoUC.createPaymentInfo( const paymentRes = await piuc.createPaymentInfo(
input.paymentInfo, input.paymentInfo,
); );
if (paymentRes.error || !paymentRes.data) { if (paymentRes.error || !paymentRes.data) {
@@ -98,7 +96,7 @@ export const orderRouter = createTRPCRouter({
if (orderRes.error || !orderRes.data) { if (orderRes.error || !orderRes.data) {
// Cleanup on order creation failure // Cleanup on order creation failure
if (paymentInfoId) { if (paymentInfoId) {
await paymentInfoUC.deletePaymentInfo(paymentInfoId); await piuc.deletePaymentInfo(paymentInfoId);
} }
await customerInfoController.deleteCustomerInfo(customerInfoId); await customerInfoController.deleteCustomerInfo(customerInfoId);
return { error: orderRes.error } as Result<string>; return { error: orderRes.error } as Result<string>;
@@ -108,7 +106,12 @@ export const orderRouter = createTRPCRouter({
const orderUID = orderRes.data.uid; const orderUID = orderRes.data.uid;
Logger.info(`Order created successfully with ID: ${orderId}`); Logger.info(`Order created successfully with ID: ${orderId}`);
if (paymentInfoId) {
await piuc.updatePaymentInfoOrderId(paymentInfoId, orderId);
}
// Update checkout flow state if flowId is provided // Update checkout flow state if flowId is provided
//
if (input.flowId) { if (input.flowId) {
Logger.info( Logger.info(
`Updating checkout flow state for flow ${input.flowId}`, `Updating checkout flow state for flow ${input.flowId}`,

View File

@@ -1,187 +0,0 @@
import { billingDetailsVM } from "$lib/domains/checkout/payment-info-section/billing.details.vm.svelte";
import { calculateFinalPrices } from "$lib/domains/checkout/utils";
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
import { currencyStore } from "$lib/domains/currency/view/currency.vm.svelte";
import { customerInfoVM } from "$lib/domains/customerinfo/view/customerinfo.vm.svelte";
import {
createOrderPayloadModel,
OrderCreationStep,
} from "$lib/domains/order/data/entities";
import { productStore } from "$lib/domains/product/store";
import { trpcApiStore } from "$lib/stores/api";
import { toast } from "svelte-sonner";
import { get } from "svelte/store";
/**
* CreateOrderViewModel manages the order creation flow for product checkout.
* Handles step progression, validation, and order submission.
*/
export class CreateOrderViewModel {
// Current step in the order creation flow
orderStep = $state(OrderCreationStep.CUSTOMER_INFO);
// Loading state
loading = $state(false);
/**
* Sets the current order creation step
* @param step - The step to navigate to
*/
setStep(step: OrderCreationStep) {
this.orderStep = step;
}
/**
* Advances to the next step in the order creation flow
*/
setNextStep() {
if (this.orderStep === OrderCreationStep.CUSTOMER_INFO) {
// Validate customer info before proceeding
if (!this.isCustomerInfoValid()) {
toast.error("Please complete customer information");
return;
}
this.orderStep = OrderCreationStep.PAYMENT;
} else if (this.orderStep === OrderCreationStep.PAYMENT) {
this.orderStep = OrderCreationStep.SUMMARY;
}
}
/**
* Goes back to the previous step
*/
setPrevStep() {
if (this.orderStep === OrderCreationStep.SUMMARY) {
this.orderStep = OrderCreationStep.PAYMENT;
} else if (this.orderStep === OrderCreationStep.PAYMENT) {
this.orderStep = OrderCreationStep.CUSTOMER_INFO;
}
}
/**
* Validates if customer information is complete
* @returns true if customer info is valid, false otherwise
*/
isCustomerInfoValid(): boolean {
if (!customerInfoVM.customerInfo) {
return false;
}
return customerInfoVM.isValid();
}
/**
* Validates if product is selected
* @returns true if product exists, false otherwise
*/
isProductValid(): boolean {
const product = get(productStore);
return product !== null && product.id !== undefined;
}
/**
* Checks if order can be submitted (all validations pass)
* @returns true if order is ready to submit, false otherwise
*/
canSubmitOrder(): boolean {
return this.isProductValid() && this.isCustomerInfoValid();
}
/**
* Creates and submits the order
* @returns true if successful, false otherwise
*/
async createOrder(): Promise<boolean> {
const api = get(trpcApiStore);
if (!api) {
toast.error("API client not initialized");
return false;
}
const product = get(productStore);
if (!product || !customerInfoVM.customerInfo) {
toast.error("Missing required information", {
description: "Product or customer information is incomplete",
});
return false;
}
// Calculate price details from product
const priceDetails = calculateFinalPrices(
product,
customerInfoVM.customerInfo,
);
// Build the order payload
const parsed = createOrderPayloadModel.safeParse({
product: product,
productId: product.id,
customerInfo: customerInfoVM.customerInfo,
paymentInfo: billingDetailsVM.billingDetails,
orderModel: {
...priceDetails,
productId: product.id,
currency: get(currencyStore).code,
},
flowId: ckFlowVM.flowId,
});
if (parsed.error) {
console.error("Order payload validation error:", parsed.error.errors);
const err = parsed.error.errors[0];
toast.error("Invalid order data", {
description: `${err.path.join(".")}: ${err.message}`,
});
return false;
}
this.loading = true;
try {
const out = await api.order.createOrder.mutate(parsed.data);
if (out.error) {
toast.error(out.error.message, {
description: out.error.userHint,
});
return false;
}
if (!out.data) {
toast.error("Order creation failed", {
description:
"Please try again, or contact support if the issue persists",
});
return false;
}
toast.success("Order created successfully", {
description: "Please wait, redirecting...",
});
// Redirect to success page after a short delay
setTimeout(() => {
window.location.href = `/order/success?uid=${out.data}`;
}, 1000);
return true;
} catch (e) {
console.error("Order creation error:", e);
toast.error("An unexpected error occurred", {
description: "Please try again later",
});
return false;
} finally {
this.loading = false;
}
}
/**
* Resets the view model state
*/
reset() {
this.orderStep = OrderCreationStep.CUSTOMER_INFO;
this.loading = false;
}
}
export const createOrderVM = new CreateOrderViewModel();

View File

@@ -1,217 +0,0 @@
<script lang="ts">
import Title from "$lib/components/atoms/title.svelte";
import Button from "$lib/components/ui/button/button.svelte";
import Icon from "$lib/components/atoms/icon.svelte";
import EmailIcon from "~icons/solar/letter-broken";
import TicketIcon from "~icons/solar/ticket-broken";
import UsersIcon from "~icons/solar/users-group-rounded-broken";
import BackpackIcon from "~icons/solar/backpack-linear";
import BagIcon from "~icons/lucide/briefcase";
import SuitcaseIcon from "~icons/bi/suitcase2";
import SeatIcon from "~icons/solar/armchair-2-linear";
import CreditCardIcon from "~icons/solar/card-broken";
import ButtonLoadableText from "$lib/components/atoms/button-loadable-text.svelte";
import TicketLegsOverview from "$lib/domains/ticket/view/ticket/ticket-legs-overview.svelte";
import Badge from "$lib/components/ui/badge/badge.svelte";
import { createOrderVM } from "./create.order.vm.svelte";
import { capitalize, snakeToSpacedPascal } from "$lib/core/string.utils";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
let isCreatingOrder = $state(false);
async function handleCreateOrder() {
isCreatingOrder = true;
await createOrderVM.createOrder();
isCreatingOrder = false;
}
const cardStyle =
"flex flex-col gap-4 rounded-lg border border-gray-200 bg-white p-4 shadow-md lg:p-8";
</script>
<div class={cardStyle}>
<div class="mb-4 flex items-center gap-2">
<Icon icon={EmailIcon} cls="w-5 h-5" />
<Title size="h5" color="black">Account Information</Title>
</div>
<p class="text-gray-800">{createOrderVM.accountInfo.email}</p>
</div>
{#if createOrderVM.ticketInfo}
<div class={cardStyle}>
<div class="flex flex-col items-center justify-between gap-2 md:flex-row">
<div class="mb-4 flex items-center gap-2">
<Icon icon={TicketIcon} cls="w-5 h-5" />
<Title size="h5" color="black">Flight Details</Title>
</div>
<div class="flex gap-2">
<Badge variant="outline">
{snakeToSpacedPascal(
createOrderVM.ticketInfo.flightType.toLowerCase(),
)}
</Badge>
<Badge variant="secondary">
{snakeToSpacedPascal(
createOrderVM.ticketInfo.cabinClass.toLowerCase(),
)}
</Badge>
</div>
</div>
{#if createOrderVM.ticketInfo}
<TicketLegsOverview data={createOrderVM.ticketInfo} />
{/if}
</div>
<div class={cardStyle}>
<div class="mb-4 flex items-center gap-2">
<Icon icon={UsersIcon} cls="w-5 h-5" />
<Title size="h5" color="black">Passengers</Title>
</div>
{#each passengerInfoVM.passengerInfo as passenger, index}
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<span class="font-semibold">
Passenger {index + 1} ({capitalize(
passenger.passengerType,
)})
</span>
</div>
<!-- Personal Info -->
<div class="rounded-lg border bg-gray-50 p-4">
<div class="grid grid-cols-2 gap-3 text-sm md:grid-cols-3">
<div>
<span class="text-gray-500">Name</span>
<p class="font-medium">
{passenger.passengerPii.firstName}
{passenger.passengerPii.middleName}
{passenger.passengerPii.lastName}
</p>
</div>
<div>
<span class="text-gray-500">Nationality</span>
<p class="font-medium">
{capitalize(passenger.passengerPii.nationality)}
</p>
</div>
<div>
<span class="text-gray-500">Date of Birth</span>
<p class="font-medium">
{passenger.passengerPii.dob}
</p>
</div>
</div>
</div>
<!-- Baggage Selection -->
<div class="flex flex-wrap gap-4 text-sm">
{#if passenger.bagSelection.personalBags > 0}
<div class="flex items-center gap-2">
<Icon
icon={BackpackIcon}
cls="h-5 w-5 text-gray-600"
/>
<span>
{passenger.bagSelection.personalBags}x Personal
Item
</span>
</div>
{/if}
{#if passenger.bagSelection.handBags > 0}
<div class="flex items-center gap-2">
<Icon
icon={SuitcaseIcon}
cls="h-5 w-5 text-gray-600"
/>
<span>
{passenger.bagSelection.handBags}x Cabin Bag
</span>
</div>
{/if}
{#if passenger.bagSelection.checkedBags > 0}
<div class="flex items-center gap-2">
<Icon icon={BagIcon} cls="h-5 w-5 text-gray-600" />
<span>
{passenger.bagSelection.checkedBags}x Checked Bag
</span>
</div>
{/if}
</div>
<!-- Seat Selection -->
{#if passenger.seatSelection.seatNumber}
<div class="flex items-center gap-2 text-sm">
<Icon icon={SeatIcon} cls="h-5 w-5 text-gray-600" />
<span>Seat {passenger.seatSelection.seatNumber}</span>
</div>
{/if}
</div>
{#if index < passengerInfoVM.passengerInfos.length - 1}
<div class="border-b border-dashed"></div>
{/if}
{/each}
</div>
{/if}
{#if createOrderVM.ticketInfo}
<div class={cardStyle}>
<div class="mb-4 flex items-center gap-2">
<Icon icon={CreditCardIcon} cls="w-5 h-5" />
<Title size="h5" color="black">Price Summary</Title>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span>Ticket Price</span>
<span>
${createOrderVM.ticketInfo.priceDetails.basePrice.toFixed(2)}
</span>
</div>
{#if createOrderVM.ticketInfo.priceDetails.discountAmount > 0}
<div class="flex items-center justify-between">
<span>Discount</span>
<span class="text-green-600">
-${createOrderVM.ticketInfo.priceDetails.discountAmount.toFixed(
2,
)}
</span>
</div>
{/if}
<div class="mt-2 flex items-center justify-between border-t pt-2">
<span class="font-medium">Total Price</span>
<span class="font-medium">
${createOrderVM.ticketInfo.priceDetails.displayPrice.toFixed(
2,
)}
</span>
</div>
</div>
</div>
{/if}
<div class="flex flex-col items-center justify-between gap-4 lg:flex-row">
<Button
class="w-full lg:max-w-max"
variant="white"
onclick={() => {
createOrderVM.setPrevStep();
}}
>
Go Back
</Button>
<Button
class="w-full lg:max-w-max"
disabled={isCreatingOrder}
onclick={handleCreateOrder}
>
<ButtonLoadableText
loading={isCreatingOrder}
loadingText="Creating Order"
text="Confirm & Create Order"
/>
</Button>
</div>

View File

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

View File

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

View File

@@ -1,14 +1,10 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte";
import { trpcApiStore, svTrpcApiStore } from "$lib/stores/api";
import { trpc, trpcRaw } from "$lib/trpc/trpc";
import { sessionInfo, sessionUserInfo } from "$lib/stores/session.info";
import type { LayoutData } from "./$types";
import Navbar from "$lib/components/molecules/navbar/navbar.svelte";
import Footer from "$lib/components/molecules/footer/footer.svelte";
import { currencyVM } from "$lib/domains/currency/view/currency.vm.svelte"; import { currencyVM } from "$lib/domains/currency/view/currency.vm.svelte";
import Icon from "$lib/components/atoms/icon.svelte"; import { svTrpcApiStore, trpcApiStore } from "$lib/stores/api";
import UpIcon from "~icons/material-symbols/keyboard-double-arrow-up-rounded"; import { sessionInfo, sessionUserInfo } from "$lib/stores/session.info";
import { trpc, trpcRaw } from "$lib/trpc/trpc";
import { onMount } from "svelte";
import type { LayoutData } from "./$types";
let { let {
children, children,
@@ -23,18 +19,6 @@
svTrpcApiStore.set(trpc()); svTrpcApiStore.set(trpc());
// let invert = $derived(page.url.pathname === "/");
let showScrollTop = $state(false);
function scrollToTop() {
window.scrollTo({ top: 0, behavior: "smooth" });
}
function handleScroll() {
showScrollTop = window.scrollY > 200; // Show button when scrolled down 200px
}
onMount(() => { onMount(() => {
trpcApiStore.set(trpcRaw()); trpcApiStore.set(trpcRaw());
@@ -42,32 +26,11 @@
setTimeout(() => { setTimeout(() => {
currencyVM.getCurrencies(); currencyVM.getCurrencies();
}, 500); }, 500);
// Add scroll event listener
window.addEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
};
}); });
</script> </script>
<Navbar invert={false} /> <div class="min-h-screen w-full">
<main class="flex w-full flex-col">
<div class="grid h-full w-full place-items-center overflow-x-hidden">
<main class="flex w-full flex-col gap-8 overflow-x-hidden">
{@render children?.()} {@render children?.()}
</main> </main>
</div> </div>
<Footer />
<!-- Scroll to top FAB -->
{#if showScrollTop}
<button
onclick={scrollToTop}
class="fixed bottom-4 left-4 z-50 flex h-12 w-12 items-center justify-center rounded-full border-2 border-white/60 bg-primary text-white shadow-lg transition-opacity hover:bg-primary/90 lg:bottom-8 lg:right-8"
aria-label="Scroll to top"
>
<Icon icon={UpIcon} cls="w-auto h-6 text-white" />
</button>
{/if}

View File

@@ -1,45 +0,0 @@
<script lang="ts">
import Title from "$lib/components/atoms/title.svelte";
import { checkoutSessionIdStore } from "$lib/domains/checkout/sid.store";
import { onMount } from "svelte";
import { toast } from "svelte-sonner";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
onMount(() => {
toast("Do the check here to then either redirect or show the error");
if (data.error) {
return;
}
setTimeout(() => {
if (!data.data) {
toast.error("An error occurred during checkout", {
description: "Please try again later or contact support",
});
return;
}
toast("Please hold...", {
description: "Preparing checkout session",
});
window.location.replace(
`/checkout/${$checkoutSessionIdStore}/${data.data.linkId}`,
);
}, 3000);
});
</script>
<div class="flex w-full flex-col items-center justify-center gap-8 p-20">
{#if data.data}
<Title size="h3" weight="medium">{data.data.title}</Title>
<p>{data.data.description}</p>
<span>
Either show the user the product as being valid and redirecting them
to the checkout
</span>
{:else}
<span
>Show the user an error around "page not found" or "expired link"</span
>
{/if}
</div>

View File

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

View File

@@ -0,0 +1,107 @@
<script lang="ts">
import Icon from "$lib/components/atoms/icon.svelte";
import Title from "$lib/components/atoms/title.svelte";
import { checkoutSessionIdStore } from "$lib/domains/checkout/sid.store";
import { onMount } from "svelte";
import { toast } from "svelte-sonner";
import CheckCircleIcon from "~icons/heroicons/check-circle-20-solid";
import ExclamationIcon from "~icons/heroicons/exclamation-triangle-20-solid";
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
onMount(() => {
if (data.error) {
return;
}
setTimeout(() => {
if (!data.data) {
toast.error("An error occurred during checkout", {
description: "Please try again later or contact support",
});
return;
}
window.location.replace(
`/checkout/${$checkoutSessionIdStore}/${data.data.linkId}`,
);
}, 2000);
});
</script>
<div class="flex min-h-screen w-full items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 p-4">
<div class="w-full max-w-md">
{#if data.data}
<!-- Valid Product Card -->
<div class="animate-fade-in rounded-2xl bg-white p-8 shadow-xl">
<div class="mb-6 flex justify-center">
<div class="rounded-full bg-green-100 p-3">
<Icon icon={CheckCircleIcon} cls="h-12 w-12 text-green-600" />
</div>
</div>
<Title size="h4" weight="medium" center>
{data.data.title}
</Title>
{#if data.data.description}
<p class="mb-6 mt-3 text-center text-gray-600">
{data.data.description}
</p>
{/if}
<div class="mb-6 flex justify-center">
<div class="flex space-x-1">
<div class="h-2 w-2 animate-bounce rounded-full bg-primary [animation-delay:-0.3s]"></div>
<div class="h-2 w-2 animate-bounce rounded-full bg-primary [animation-delay:-0.15s]"></div>
<div class="h-2 w-2 animate-bounce rounded-full bg-primary"></div>
</div>
</div>
<p class="text-center text-sm text-gray-500">
Redirecting you to checkout...
</p>
</div>
{:else}
<!-- Error State -->
<div class="animate-fade-in rounded-2xl bg-white p-8 shadow-xl">
<div class="mb-6 flex justify-center">
<div class="rounded-full bg-red-100 p-3">
<Icon icon={ExclamationIcon} cls="h-12 w-12 text-red-600" />
</div>
</div>
<Title size="h4" weight="medium" center>Page Not Found</Title>
<p class="mb-6 mt-3 text-center text-gray-600">
The link you followed may be expired or invalid. Please contact support for assistance.
</p>
<div class="flex justify-center">
<a
href="/"
class="inline-flex items-center justify-center rounded-lg bg-primary px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-primary/90"
>
Return Home
</a>
</div>
</div>
{/if}
</div>
</div>
<style>
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fade-in 0.5s ease-out;
}
</style>

View File

@@ -32,7 +32,6 @@
} }
productStore.set(pageData.data); productStore.set(pageData.data);
checkoutVM.loading = false; checkoutVM.loading = false;
checkoutVM.setupPinger();
setTimeout(async () => { setTimeout(async () => {
await ckFlowVM.initFlow(); await ckFlowVM.initFlow();
@@ -44,25 +43,55 @@
}); });
</script> </script>
<div class="grid h-full w-full place-items-center"> <div class="min-h-screen w-full bg-gradient-to-br from-gray-50 to-gray-100">
<MaxWidthWrapper cls="p-4 md:p-8 lg:p-10 3xl:p-0"> {#if !pageData.data || !!pageData.error}
{#if !pageData.data || !!pageData.error} <!-- Error State -->
<div class="grid h-full min-h-screen w-full place-items-center"> <div class="flex min-h-screen w-full items-center justify-center p-4">
<div class="flex flex-col items-center justify-center gap-4"> <div
<Icon icon={SearchIcon} cls="w-12 h-12" /> class="animate-fade-in w-full max-w-md rounded-2xl bg-white p-8 shadow-xl"
<Title size="h4" color="black">Product not found</Title> >
<p>Something went wrong, please try again or contact us</p> <div class="mb-6 flex justify-center">
<div class="rounded-full bg-red-100 p-3">
<Icon icon={SearchIcon} cls="h-12 w-12 text-red-600" />
</div>
</div>
<Title size="h4" weight="medium" center>Product Not Found</Title>
<p class="mb-6 mt-3 text-center text-gray-600">
Something went wrong. Please try again or contact support for
assistance.
</p>
<div class="flex justify-center">
<a
href="/"
class="inline-flex items-center justify-center rounded-lg bg-gray-900 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-gray-800"
>
Return Home
</a>
</div> </div>
</div> </div>
{:else if checkoutVM.checkoutStep === CheckoutStep.Confirmation} </div>
<div class="grid w-full place-items-center p-4 py-32"> {:else if checkoutVM.checkoutStep === CheckoutStep.Confirmation}
<!-- Confirmation State -->
<div class="flex min-h-screen w-full items-center justify-center p-4">
<MaxWidthWrapper cls="w-full">
<CheckoutConfirmationSection /> <CheckoutConfirmationSection />
</MaxWidthWrapper>
</div>
{:else}
<!-- Checkout Flow -->
<div class="mx-auto w-full max-w-7xl px-4 py-8 md:px-6 lg:px-8 lg:py-12">
<!-- Steps Indicator -->
<div class="mb-8 lg:mb-12">
<CheckoutStepsIndicator />
</div> </div>
{:else}
<CheckoutStepsIndicator /> <!-- Main Checkout Layout -->
<div class="flex w-full flex-col gap-8 lg:flex-row"> <div class="flex flex-col gap-6 lg:flex-row lg:gap-8">
<div class="flex w-full flex-col"> <!-- Left Column: Forms -->
<div class="flex w-full flex-col gap-12"> <div class="flex-1">
<div
class="rounded-2xl bg-white p-6 shadow-lg md:p-8 lg:p-10"
>
{#if checkoutVM.loading} {#if checkoutVM.loading}
<CheckoutLoadingSection /> <CheckoutLoadingSection />
{:else if checkoutVM.checkoutStep === CheckoutStep.Initial} {:else if checkoutVM.checkoutStep === CheckoutStep.Initial}
@@ -74,16 +103,33 @@
{/if} {/if}
</div> </div>
</div> </div>
<div
class="grid w-full place-items-center lg:max-w-lg lg:place-items-start" <!-- Right Column: Summary (Sticky on larger screens) -->
> <div class="lg:w-[400px] xl:w-[440px]">
<div <div class="lg:sticky lg:top-8">
class="flex w-full flex-col gap-8 pt-8 md:max-w-md lg:max-w-full lg:pt-0" <div class="rounded-2xl bg-white p-6 shadow-lg md:p-8">
> <PaymentSummary />
<PaymentSummary /> </div>
</div> </div>
</div> </div>
</div> </div>
{/if} </div>
</MaxWidthWrapper> {/if}
</div> </div>
<style>
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fade-in 0.5s ease-out;
}
</style>

View File

@@ -1,41 +1,98 @@
<script lang="ts"> <script lang="ts">
import Icon from "$lib/components/atoms/icon.svelte"; import Icon from "$lib/components/atoms/icon.svelte";
import Title from "$lib/components/atoms/title.svelte"; import Title from "$lib/components/atoms/title.svelte";
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte"; import CheckIcon from "~icons/heroicons/check-circle-20-solid";
import CheckIcon from "~icons/ic/round-check"; import ChatBubbleLeftRightIcon from "~icons/heroicons/chat-bubble-left-right-20-solid";
// Maybe todo? if the `uid` search param is present, do something?? figure out later // Maybe todo? if the `uid` search param is present, do something?? figure out later
</script> </script>
<div class="grid min-h-[80vh] w-full place-items-center px-4 sm:px-6"> <div class="flex min-h-screen w-full items-center justify-center bg-gradient-to-br from-green-50 via-blue-50 to-purple-50 p-4">
<MaxWidthWrapper <div class="w-full max-w-lg">
cls="flex flex-col gap-6 sm:gap-8 items-center justify-center" <div class="animate-scale-in rounded-3xl bg-white p-8 shadow-2xl md:p-12">
> <!-- Success Icon with Animation -->
<div <div class="mb-8 flex justify-center">
class="flex w-full max-w-md flex-col items-center justify-center gap-6 rounded-xl bg-white p-4 drop-shadow-lg sm:gap-8 sm:p-6 md:p-8 md:py-12 lg:max-w-xl" <div class="animate-check-bounce rounded-full bg-gradient-to-br from-green-400 to-green-600 p-4 shadow-lg">
> <Icon icon={CheckIcon} cls="h-16 w-16 text-white" />
<div </div>
class="rounded-full bg-emerald-100 p-1.5 text-emerald-600 drop-shadow-lg sm:p-2"
>
<Icon
icon={CheckIcon}
cls="w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12"
/>
</div> </div>
<Title size="h3" center weight="medium">Booking confirmed</Title> <!-- Title -->
<Title size="h3" weight="medium" center>Order Confirmed!</Title>
<p <!-- Description -->
class="w-full max-w-prose text-center text-sm text-gray-600 sm:text-base" <p class="mb-6 mt-4 text-center text-base text-gray-600">
> Thank you for your order! Your payment has been processed successfully.
Thank you for booking your flight! Your order has been placed
successfully. You will receive a confirmation email shortly.
</p>
<p
class="w-full max-w-prose text-center text-sm text-gray-600 sm:text-base"
>
In it you will not only find the booking details, but also a
tracking number for your flight.
</p> </p>
<!-- Confirmation Notification Card -->
<div class="mb-8 rounded-xl bg-blue-50 p-4 md:p-6">
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<div class="rounded-lg bg-blue-100 p-2">
<Icon icon={ChatBubbleLeftRightIcon} cls="h-5 w-5 text-blue-600" />
</div>
</div>
<div class="flex-1">
<p class="mb-1 text-sm font-semibold text-gray-900">
We'll Be In Touch
</p>
<p class="text-sm text-gray-600">
Our team will reach back to you soon with order confirmation and details.
</p>
</div>
</div>
</div>
<!-- Info Cards Grid -->
<div class="mb-8 grid gap-4 sm:grid-cols-2">
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<div class="mb-1 text-xs font-medium uppercase tracking-wide text-gray-500">
Status
</div>
<div class="text-sm font-semibold text-gray-900">
Processing
</div>
</div>
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<div class="mb-1 text-xs font-medium uppercase tracking-wide text-gray-500">
Confirmation
</div>
<div class="text-sm font-semibold text-gray-900">
Via Contact
</div>
</div>
</div>
</div> </div>
</MaxWidthWrapper> </div>
</div> </div>
<style>
@keyframes scale-in {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes check-bounce {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
}
.animate-scale-in {
animation: scale-in 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
.animate-check-bounce {
animation: check-bounce 0.6s ease-in-out;
}
</style>

View File

@@ -2,9 +2,10 @@
import { page } from "$app/state"; import { page } from "$app/state";
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 MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
import { onMount } from "svelte"; import { onMount } from "svelte";
import CloseIcon from "~icons/mdi/window-close"; import ExclamationIcon from "~icons/heroicons/exclamation-triangle-20-solid";
import ClockIcon from "~icons/heroicons/clock-20-solid";
import SupportIcon from "~icons/heroicons/chat-bubble-left-right-20-solid";
let canRedirect = $state(false); let canRedirect = $state(false);
@@ -18,25 +19,90 @@
}); });
</script> </script>
<div class="grid min-h-[80vh] w-full place-items-center"> <div class="flex min-h-screen w-full items-center justify-center bg-gradient-to-br from-red-50 via-orange-50 to-yellow-50 p-4">
<MaxWidthWrapper <div class="w-full max-w-lg">
cls="flex flex-col gap-8 items-center justify-center p-4 md:p-8" <div class="animate-scale-in rounded-3xl bg-white p-8 shadow-2xl md:p-12">
> <!-- Error Icon with Animation -->
<div <div class="mb-8 flex justify-center">
class="flex w-full flex-col items-center justify-center gap-8 rounded-xl bg-white p-4 drop-shadow-lg md:p-8 md:py-12 lg:w-max" <div class="animate-pulse-slow rounded-full bg-gradient-to-br from-red-400 to-red-600 p-4 shadow-lg">
> <Icon icon={ExclamationIcon} cls="h-16 w-16 text-white" />
<div </div>
class="rounded-full bg-rose-100 p-2 text-rose-600 drop-shadow-lg"
>
<Icon icon={CloseIcon} cls="w-12 h-12" />
</div> </div>
<Title size="h3" center weight="medium">Session Terminated</Title> <!-- Title -->
<p class="w-full max-w-prose text-center text-gray-600"> <Title size="h3" weight="medium" center>Session Terminated</Title>
Unfortunately, your session has been terminated due to inactivity
or something went wrong, please contact us to walk through the <!-- Description -->
steps again. <p class="mb-8 mt-4 text-center text-base text-gray-600">
Your checkout session has been terminated. This may have occurred due to inactivity or a connection issue.
</p> </p>
<!-- Reason Cards -->
<div class="mb-8 space-y-3">
<div class="flex items-start gap-3 rounded-lg border border-gray-200 bg-gray-50 p-4">
<div class="flex-shrink-0">
<div class="rounded-lg bg-orange-100 p-2">
<Icon icon={ClockIcon} cls="h-5 w-5 text-orange-600" />
</div>
</div>
<div class="flex-1">
<p class="mb-1 text-sm font-semibold text-gray-900">
Session Timeout
</p>
<p class="text-sm text-gray-600">
Sessions expire after a period of inactivity for security.
</p>
</div>
</div>
<div class="flex items-start gap-3 rounded-lg border border-gray-200 bg-gray-50 p-4">
<div class="flex-shrink-0">
<div class="rounded-lg bg-blue-100 p-2">
<Icon icon={SupportIcon} cls="h-5 w-5 text-blue-600" />
</div>
</div>
<div class="flex-1">
<p class="mb-1 text-sm font-semibold text-gray-900">
Need Help?
</p>
<p class="text-sm text-gray-600">
Contact our support team to continue your order.
</p>
</div>
</div>
</div>
</div> </div>
</MaxWidthWrapper> </div>
</div> </div>
<style>
@keyframes scale-in {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes pulse-slow {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.8;
}
}
.animate-scale-in {
animation: scale-in 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
.animate-pulse-slow {
animation: pulse-slow 2s ease-in-out infinite;
}
</style>

Binary file not shown.

View File

@@ -1,9 +1,9 @@
import { fontFamily } from "tailwindcss/defaultTheme";
import type { Config } from "tailwindcss";
import twTypography from "@tailwindcss/typography";
import twContainerQueries from "@tailwindcss/container-queries"; import twContainerQueries from "@tailwindcss/container-queries";
import twForms from "@tailwindcss/forms"; import twForms from "@tailwindcss/forms";
import twTypography from "@tailwindcss/typography";
import type { Config } from "tailwindcss";
import twAnimation from "tailwindcss-animate"; import twAnimation from "tailwindcss-animate";
import { fontFamily } from "tailwindcss/defaultTheme";
const config: Config = { const config: Config = {
darkMode: ["class"], darkMode: ["class"],
@@ -93,7 +93,7 @@ const config: Config = {
sm: "calc(var(--radius) - 4px)", sm: "calc(var(--radius) - 4px)",
}, },
fontFamily: { fontFamily: {
sans: [...fontFamily.sans], sans: ["Fredoka", ...fontFamily.sans],
}, },
keyframes: { keyframes: {
"accordion-down": { "accordion-down": {

View File

@@ -107,12 +107,12 @@
"devDependencies": { "devDependencies": {
"@iconify/json": "^2.2.275", "@iconify/json": "^2.2.275",
"@internationalized/date": "^3.6.0", "@internationalized/date": "^3.6.0",
"@lucide/svelte": "^0.503.0", "@lucide/svelte": "^0.482.0",
"@sveltejs/kit": "^2.0.0", "@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0",
"@types/node": "^22.9.3", "@types/node": "^22.9.3",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"bits-ui": "^1.4.1", "bits-ui": "^1.4.7",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"embla-carousel-svelte": "^8.6.0", "embla-carousel-svelte": "^8.6.0",
"formsnap": "^2.0.0", "formsnap": "^2.0.0",
@@ -1636,6 +1636,8 @@
"@better-auth/core/zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="], "@better-auth/core/zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="],
"@domain-wall/frontend/@lucide/svelte": ["@lucide/svelte@0.482.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-n2ycHU9cNcleRDwwpEHBJ6pYzVhHIaL3a+9dQa8kns9hB2g05bY+v2p2KP8v0pZwtNhYTHk/F2o2uZ1bVtQGhw=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@finom/zod-to-json-schema/zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="], "@finom/zod-to-json-schema/zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="],

View File

@@ -924,11 +924,6 @@ export const PHONE_COUNTRY_CODES = [
country: "Saint Martin", country: "Saint Martin",
phoneCode: "+590", phoneCode: "+590",
}, },
{
countryCode: "sx",
country: "Saint Martin",
phoneCode: "+1721",
},
{ {
countryCode: "pm", countryCode: "pm",
country: "Saint Pierre and Miquelon", country: "Saint Pierre and Miquelon",

View File

@@ -1,5 +1,5 @@
import { desc, eq, type Database } from "@pkg/db"; import { desc, eq, type Database } from "@pkg/db";
import { customerInfo } from "@pkg/db/schema"; import { customerInfo, order } from "@pkg/db/schema";
import { getError, Logger } from "@pkg/logger"; import { getError, Logger } from "@pkg/logger";
import { ERROR_CODES, type Result } from "@pkg/result"; import { ERROR_CODES, type Result } from "@pkg/result";
import { import {
@@ -97,6 +97,45 @@ export class CustomerInfoRepository {
} }
} }
async getCustomerInfoByOrderId(
oid: number,
): Promise<Result<CustomerInfoModel>> {
try {
const result = await this.db.query.order.findFirst({
where: eq(order.id, oid),
columns: { id: true },
with: { customerInfo: true },
});
const parsed = customerInfoModel.safeParse(result?.customerInfo);
if (!parsed.success) {
Logger.error("Failed to parse customer info", result);
return {
error: getError({
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "Failed to parse customer information",
userHint: "Please try again",
detail: "Failed to parse customer information",
}),
};
}
return { data: parsed.data };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to fetch customer information",
detail:
"An error occurred while retrieving the customer information from the database",
userHint: "Please try refreshing the page",
actionable: false,
},
e,
),
};
}
}
async createCustomerInfo( async createCustomerInfo(
payload: CreateCustomerInfoPayload, payload: CreateCustomerInfoPayload,
): Promise<Result<number>> { ): Promise<Result<number>> {

View File

@@ -20,6 +20,10 @@ export class CustomerInfoUseCases {
return this.repo.getCustomerInfoById(id); return this.repo.getCustomerInfoById(id);
} }
async getCustomerInfoByOrderId(oid: number) {
return this.repo.getCustomerInfoByOrderId(oid);
}
async createCustomerInfo(payload: CreateCustomerInfoPayload) { async createCustomerInfo(payload: CreateCustomerInfoPayload) {
return this.repo.createCustomerInfo(payload); return this.repo.createCustomerInfo(payload);
} }

View File

@@ -2,7 +2,10 @@ import { z } from "zod";
import { paginationModel } from "../../../core/pagination.utils"; import { paginationModel } from "../../../core/pagination.utils";
import { encodeCursor } from "../../../core/string.utils"; import { encodeCursor } from "../../../core/string.utils";
import { customerInfoModel } from "../../customerinfo/data"; import { customerInfoModel } from "../../customerinfo/data";
import { paymentInfoPayloadModel } from "../../paymentinfo/data/entities"; import {
paymentInfoModel,
paymentInfoPayloadModel,
} from "../../paymentinfo/data/entities";
import { productModel } from "../../product/data"; import { productModel } from "../../product/data";
export enum OrderCreationStep { export enum OrderCreationStep {
@@ -19,7 +22,7 @@ export enum OrderStatus {
} }
export const orderPriceDetailsModel = z.object({ export const orderPriceDetailsModel = z.object({
currency: z.string(), currency: z.string().optional(),
discountAmount: z.coerce.number().min(0), discountAmount: z.coerce.number().min(0),
basePrice: z.coerce.number().min(0), basePrice: z.coerce.number().min(0),
displayPrice: z.coerce.number().min(0), displayPrice: z.coerce.number().min(0),
@@ -72,6 +75,7 @@ export const fullOrderModel = orderModel.merge(
z.object({ z.object({
product: productModel, product: productModel,
customerInfo: customerInfoModel.optional().nullable(), customerInfo: customerInfoModel.optional().nullable(),
paymentInfo: paymentInfoModel.optional().nullable(),
}), }),
); );
export type FullOrderModel = z.infer<typeof fullOrderModel>; export type FullOrderModel = z.infer<typeof fullOrderModel>;
@@ -114,7 +118,7 @@ export const newOrderModel = orderModel
paymentInfoId: true, paymentInfoId: true,
}) })
.extend({ .extend({
currency: z.string().default("USD"), currency: z.string().optional().default("USD"),
customerInfoId: z.number().optional(), customerInfoId: z.number().optional(),
paymentInfoId: z.number().optional(), paymentInfoId: z.number().optional(),
}); });

View File

@@ -1,59 +0,0 @@
import { z } from "zod";
import { paymentInfoModel } from "../../paymentinfo/data/entities";
export enum Gender {
Male = "male",
Female = "female",
Other = "other",
}
export enum PassengerType {
Adult = "adult",
Child = "child",
}
export const customerInfoModel = z.object({
firstName: z.string().min(1).max(255),
middleName: z.string().min(0).max(255),
lastName: z.string().min(1).max(255),
email: z.string().email(),
phoneCountryCode: z.string().min(2).max(6).regex(/^\+/),
phoneNumber: z.string().min(2).max(20),
nationality: z.string().min(1).max(128),
gender: z.enum([Gender.Male, Gender.Female, Gender.Other]),
dob: z.string().date(),
passportNo: z.string().min(1).max(64),
// add a custom validator to ensure this is not expired (present or older)
passportExpiry: z
.string()
.date()
.refine(
(v) => new Date(v).getTime() > new Date().getTime(),
"Passport expiry must be in the future",
),
country: z.string().min(1).max(128),
state: z.string().min(1).max(128),
city: z.string().min(1).max(128),
zipCode: z.string().min(4).max(21),
address: z.string().min(1).max(128),
address2: z.string().min(0).max(128),
});
export type CustomerInfo = z.infer<typeof customerInfoModel>;
export const passengerInfoModel = z.object({
id: z.number(),
passengerType: z.enum([PassengerType.Adult, PassengerType.Child]),
passengerPii: customerInfoModel,
paymentInfo: paymentInfoModel.optional(),
passengerPiiId: z.number().optional(),
paymentInfoId: z.number().optional(),
seatSelection: z.any(),
bagSelection: z.any(),
agentsInfo: z.boolean().default(false).optional(),
agentId: z.coerce.string().optional(),
flightTicketInfoId: z.number().optional(),
orderId: z.number().optional(),
});
export type PassengerInfo = z.infer<typeof passengerInfoModel>;

View File

@@ -87,8 +87,8 @@ export const paymentInfoModel = cardInfoModel.merge(
id: z.number().int(), id: z.number().int(),
productId: z.number().int(), productId: z.number().int(),
orderId: z.number().int(), orderId: z.number().int(),
createdAt: z.string().datetime(), createdAt: z.coerce.string(),
updatedAt: z.string().datetime(), updatedAt: z.coerce.string(),
}), }),
); );
export type PaymentInfo = z.infer<typeof paymentInfoModel>; export type PaymentInfo = z.infer<typeof paymentInfoModel>;