Compare commits
10 Commits
5f9c094211
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe76a56194 | ||
|
|
eb7e8e91d5 | ||
|
|
19625fe51a | ||
|
|
7cbe86b3a8 | ||
|
|
319384c334 | ||
|
|
f0fa53a4e5 | ||
|
|
b6bdb6d7e8 | ||
|
|
f9f743eb15 | ||
|
|
1a89236449 | ||
|
|
6a0571fcfa |
@@ -1,9 +1,9 @@
|
||||
DATABASE_URL=${{project.DATABASE_URL}}
|
||||
REDIS_URL=${{project.REDIS_URL}}
|
||||
|
||||
PUBLIC_NODE_ENV=${{project.PUBLIC_NODE_ENV}}
|
||||
NODE_ENV=${{project.NODE_ENV}}
|
||||
|
||||
DATABASE_URL=${{project.DATABASE_URL}}
|
||||
REDIS_URL=${{project.REDIS_URL}}
|
||||
|
||||
PUBLIC_URL=${{project.BETTER_AUTH_URL}}
|
||||
PUBLIC_FRONTEND_URL=${{project.BETTER_AUTH_URL}}
|
||||
|
||||
|
||||
@@ -4,4 +4,8 @@ FLIGHT SIMULATOR 💥✈️🏢🏢💥
|
||||
|
||||
Ok but fo' real – dis wus a project that I made to learn more around monorepos and websocket or well live data sync in general, in particular the checkout data was synced and controllable by the admin for this.
|
||||
|
||||
## Domain Wall 🧱
|
||||
|
||||
It's called that cuse for instance – if you buy the product, e.g a domain, but the wall is the checkout process, and the admin is the one who controls whether you pass or not (Wannabe Gandalf)
|
||||
|
||||
---
|
||||
|
||||
@@ -6,14 +6,11 @@
|
||||
import Icon from "$lib/components/atoms/icon.svelte";
|
||||
import { adminSiteNavMap } from "$lib/core/constants";
|
||||
import { sessionUserInfo } from "$lib/stores/session.info";
|
||||
import { SettingsIcon } from "@lucide/svelte";
|
||||
import ProductIcon from "~icons/carbon/carbon-for-ibm-product";
|
||||
import SessionIcon from "~icons/carbon/prompt-session";
|
||||
import HistoryIcon from "~icons/iconamoon/history-light";
|
||||
import BillListIcon from "~icons/solar/bill-list-linear";
|
||||
import PackageIcon from "~icons/solar/box-broken";
|
||||
import DashboardIcon from "~icons/solar/laptop-minimalistic-broken";
|
||||
import UsersIcon from "~icons/solar/users-group-two-rounded-broken";
|
||||
|
||||
const mainLinks = [
|
||||
{
|
||||
@@ -31,11 +28,6 @@
|
||||
title: "Session History",
|
||||
url: adminSiteNavMap.sessions.history,
|
||||
},
|
||||
{
|
||||
icon: BillListIcon,
|
||||
title: "Data",
|
||||
url: adminSiteNavMap.data,
|
||||
},
|
||||
{
|
||||
icon: PackageIcon,
|
||||
title: "Orders",
|
||||
@@ -47,16 +39,6 @@
|
||||
title: "Products",
|
||||
url: adminSiteNavMap.products,
|
||||
},
|
||||
{
|
||||
icon: UsersIcon,
|
||||
title: "Profile",
|
||||
url: adminSiteNavMap.profile,
|
||||
},
|
||||
{
|
||||
icon: SettingsIcon,
|
||||
title: "Settings",
|
||||
url: adminSiteNavMap.settings,
|
||||
},
|
||||
];
|
||||
|
||||
let {
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
<script lang="ts">
|
||||
import Icon from "$lib/components/atoms/icon.svelte";
|
||||
import Title from "$lib/components/atoms/title.svelte";
|
||||
|
||||
let { icon, title, children }: { icon?: any; title: string; children: any } =
|
||||
$props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2 rounded-lg border bg-gray-50 p-2 md:p-4">
|
||||
<div
|
||||
class="flex flex-col gap-4 rounded-lg border border-gray-200 bg-white p-6 shadow-md"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if icon}
|
||||
<Icon {icon} cls="w-5 h-5" />
|
||||
{/if}
|
||||
<span class="text-gray-500">{title}</span>
|
||||
<Title size="h5" color="black">{title}</Title>
|
||||
</div>
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">Full Name</span>
|
||||
<p>
|
||||
<p class="mt-1 font-medium">
|
||||
{customerInfo.firstName}
|
||||
{#if customerInfo.middleName}
|
||||
{customerInfo.middleName}
|
||||
@@ -24,33 +24,36 @@
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">Email</span>
|
||||
<p>{customerInfo.email}</p>
|
||||
<p class="mt-1">{customerInfo.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">Phone Number</span>
|
||||
<p>{customerInfo.phoneCountryCode} {customerInfo.phoneNumber}</p>
|
||||
<p class="mt-1">
|
||||
{customerInfo.phoneCountryCode}
|
||||
{customerInfo.phoneNumber}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">City</span>
|
||||
<p>{customerInfo.city}</p>
|
||||
<p class="mt-1">{customerInfo.city}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">State</span>
|
||||
<p>{customerInfo.state}</p>
|
||||
<p class="mt-1">{customerInfo.state}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">Country</span>
|
||||
<p>{customerInfo.country}</p>
|
||||
<p class="mt-1">{customerInfo.country}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">Zip Code</span>
|
||||
<p>{customerInfo.zipCode}</p>
|
||||
<p class="mt-1">{customerInfo.zipCode}</p>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<span class="text-xs text-gray-500">Address</span>
|
||||
<p>{customerInfo.address}</p>
|
||||
<p class="mt-1">{customerInfo.address}</p>
|
||||
{#if customerInfo.address2}
|
||||
<p class="text-sm text-gray-600">{customerInfo.address2}</p>
|
||||
<p class="mt-1 text-sm text-gray-600">{customerInfo.address2}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
header: "Location",
|
||||
id: "location",
|
||||
cell: ({ row }) => {
|
||||
return `${row.original.city}, ${row.original.state}`;
|
||||
return `${row.original.country}, ${row.original.city}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -27,6 +27,7 @@ export class OrderRepository {
|
||||
});
|
||||
const out = [] as FullOrderModel[];
|
||||
for (const each of res) {
|
||||
console.log(each);
|
||||
const parsed = fullOrderModel.safeParse({
|
||||
...each,
|
||||
});
|
||||
@@ -58,6 +59,7 @@ export class OrderRepository {
|
||||
where: eq(order.id, oid),
|
||||
with: { customerInfo: true, product: true, paymentInfo: true },
|
||||
});
|
||||
console.log(out?.paymentInfo);
|
||||
if (!out) return {};
|
||||
const parsed = fullOrderModel.safeParse({
|
||||
...out,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import CInfoCard from "$lib/domains/customerinfo/view/cinfo-card.svelte";
|
||||
import type { PaymentInfo } from "@pkg/logic/domains/paymentinfo/data/entities";
|
||||
import CreditCardIcon from "~icons/solar/card-broken";
|
||||
|
||||
let {
|
||||
paymentInfo,
|
||||
}: {
|
||||
paymentInfo: PaymentInfo;
|
||||
} = $props();
|
||||
|
||||
function maskCardNumber(cardNumber: string): string {
|
||||
const cleaned = cardNumber.replace(/\s/g, "");
|
||||
const lastFour = cleaned.slice(-4);
|
||||
return `•••• •••• •••• ${lastFour}`;
|
||||
}
|
||||
|
||||
function maskCVV(cvv: string): string {
|
||||
return "•".repeat(cvv.length);
|
||||
}
|
||||
</script>
|
||||
|
||||
<CInfoCard icon={CreditCardIcon} title="Billing Information">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="md:col-span-2">
|
||||
<span class="text-xs text-gray-500">Cardholder Name</span>
|
||||
<p class="font-medium">{paymentInfo.cardholderName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">Card Number</span>
|
||||
<p class="font-mono text-sm tracking-wider">
|
||||
{maskCardNumber(paymentInfo.cardNumber)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">Expiry Date</span>
|
||||
<p class="font-mono">{paymentInfo.expiry}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">CVV</span>
|
||||
<p class="font-mono">{maskCVV(paymentInfo.cvv)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CInfoCard>
|
||||
@@ -6,6 +6,7 @@
|
||||
import type { FullOrderModel } from "$lib/domains/order/data/entities";
|
||||
import ProductIcon from "~icons/solar/box-broken";
|
||||
import CreditCardIcon from "~icons/solar/card-broken";
|
||||
import BillingDetailsCard from "./billing-details-card.svelte";
|
||||
|
||||
let { order }: { order: FullOrderModel } = $props();
|
||||
|
||||
@@ -29,26 +30,28 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<span class="text-sm text-gray-500">Product Name</span>
|
||||
<p class="font-medium">{order.product.title}</p>
|
||||
<p class="mt-1 font-medium">{order.product.title}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="text-sm text-gray-500">Description</span>
|
||||
<p class="text-gray-700">{order.product.description}</p>
|
||||
<p class="mt-1 text-gray-700">{order.product.description}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span class="text-sm text-gray-500">Regular Price</span>
|
||||
<p class="font-medium">${order.product.price.toFixed(2)}</p>
|
||||
<p class="mt-1 font-semibold">
|
||||
${order.product.price.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
{#if order.product.discountPrice > 0}
|
||||
<div>
|
||||
<span class="text-sm text-gray-500">Discount Price</span>
|
||||
<p class="font-medium text-green-600">
|
||||
<p class="mt-1 font-semibold text-green-600">
|
||||
${order.product.discountPrice.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -64,34 +67,40 @@
|
||||
<Title size="h5" color="black">Price Summary</Title>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Base Price</span>
|
||||
<span>${order.basePrice.toFixed(2)}</span>
|
||||
<span class="text-gray-600">Base Price</span>
|
||||
<span class="font-medium">${order.basePrice.toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Display Price</span>
|
||||
<span>${order.displayPrice.toFixed(2)}</span>
|
||||
<span class="text-gray-600">Display Price</span>
|
||||
<span class="font-medium">${order.displayPrice.toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
{#if discAmt > 0}
|
||||
<div class="flex items-center justify-between">
|
||||
<span>Discount</span>
|
||||
<span class="text-green-600">
|
||||
<span class="text-gray-600">Discount</span>
|
||||
<span class="font-semibold text-green-600">
|
||||
-${discAmt.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-2 flex items-center justify-between border-t pt-2">
|
||||
<span class="font-medium">Order Price</span>
|
||||
<span class="font-medium">${order.orderPrice.toFixed(2)}</span>
|
||||
<div
|
||||
class="mt-2 flex items-center justify-between border-t border-gray-200 pt-3"
|
||||
>
|
||||
<span class="font-semibold">Order Price</span>
|
||||
<span class="text-lg font-bold"
|
||||
>${order.orderPrice.toFixed(2)}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-500">Fulfilled</span>
|
||||
<span class="text-sm">${order.fullfilledPrice.toFixed(2)}</span>
|
||||
<span class="text-sm text-gray-500">Fulfilled Amount</span>
|
||||
<span class="text-sm font-medium">
|
||||
${order.fullfilledPrice.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,4 +109,9 @@
|
||||
<!-- Customer Information -->
|
||||
<CustomerDetailsCard customerInfo={order.customerInfo} />
|
||||
{/if}
|
||||
|
||||
{#if order.paymentInfo}
|
||||
<!-- Billing Information -->
|
||||
<BillingDetailsCard paymentInfo={order.paymentInfo} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
const cardStyle =
|
||||
"flex flex-col gap-4 rounded-lg border border-gray-200 bg-white p-6 shadow-md";
|
||||
const stickyContainerStyle = "sticky top-4 flex flex-col gap-6";
|
||||
|
||||
function getStatusVariant(status: OrderStatus) {
|
||||
switch (status) {
|
||||
@@ -46,7 +47,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class={stickyContainerStyle}>
|
||||
<!-- Order Status -->
|
||||
<div class={cardStyle}>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -54,16 +55,16 @@
|
||||
<Title size="h5" color="black">Order Status</Title>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600">Current Status</span>
|
||||
<span class="text-sm text-gray-500">Current Status</span>
|
||||
<Badge variant={getStatusVariant(order.status)}>
|
||||
{formatStatus(order.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600">Order ID</span>
|
||||
<span class="text-sm text-gray-500">Order ID</span>
|
||||
<span class="font-medium">#{order.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -79,12 +80,12 @@
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
<span class="text-sm text-gray-500">Created At</span>
|
||||
<p class="font-medium">{formatDate(order.createdAt)}</p>
|
||||
<p class="mt-1 font-medium">{formatDate(order.createdAt)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="text-sm text-gray-500">Last Updated</span>
|
||||
<p class="font-medium">{formatDate(order.updatedAt)}</p>
|
||||
<p class="mt-1 font-medium">{formatDate(order.updatedAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,14 +32,6 @@
|
||||
return Number(row.id) + 1;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Order ID",
|
||||
accessorKey: "orderId",
|
||||
cell: ({ row }) => {
|
||||
const r = row.original as FullOrderModel;
|
||||
return `#${r.id}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Product",
|
||||
accessorKey: "product",
|
||||
|
||||
@@ -7,10 +7,10 @@ import { paymentInfoModel, type PaymentInfo } from "./data";
|
||||
export class PaymentInfoRepository {
|
||||
constructor(private db: Database) {}
|
||||
|
||||
async getPaymentInfo(id: number): Promise<Result<PaymentInfo>> {
|
||||
Logger.info(`Getting payment info with id ${id}`);
|
||||
async getPaymentInfoByOrderID(oid: number): Promise<Result<PaymentInfo>> {
|
||||
Logger.info(`Getting payment info with id ${oid}`);
|
||||
const out = await this.db.query.paymentInfo.findFirst({
|
||||
where: eq(paymentInfo.id, id),
|
||||
where: eq(paymentInfo.orderId, oid),
|
||||
});
|
||||
const parsed = paymentInfoModel.safeParse(out);
|
||||
if (parsed.error) {
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { getCustomerInfoUseCases } from "$lib/domains/customerinfo/usecases";
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
import type { PageServerLoad } from "./$types";
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const sess = locals.session;
|
||||
if (!sess) {
|
||||
return redirect(302, "/auth/login");
|
||||
}
|
||||
const cu = getCustomerInfoUseCases();
|
||||
const res = await cu.getAllCustomerInfo();
|
||||
return { data: res.data ?? [], error: res.error };
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Container from "$lib/components/atoms/container.svelte";
|
||||
import Title from "$lib/components/atoms/title.svelte";
|
||||
import CustomerinfoTable from "$lib/domains/customerinfo/view/customerinfo-table.svelte";
|
||||
import { pageTitle } from "$lib/hooks/page-title.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
import type { PageData } from "./$types";
|
||||
|
||||
pageTitle.set("Passenger Info");
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
onMount(() => {
|
||||
if (data.error) {
|
||||
toast.error(data.error.message, {
|
||||
description: data.error.userHint,
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<main class="grid w-full place-items-center pb-12 md:p-8">
|
||||
<Container
|
||||
containerClass="flex flex-col gap-4 w-full h-full min-h-[44rem] xl:min-h-[80vh] overflow-y-hidden"
|
||||
wrapperClass="max-w-[90vw] lg:max-w-7xl"
|
||||
>
|
||||
<div
|
||||
class="flex w-full flex-col items-center justify-between gap-4 md:flex-row"
|
||||
>
|
||||
<Title size="h3">User Data</Title>
|
||||
</div>
|
||||
|
||||
<CustomerinfoTable data={data.data} />
|
||||
</Container>
|
||||
</main>
|
||||
@@ -1,20 +0,0 @@
|
||||
import { getCustomerInfoUseCases } from "$lib/domains/customerinfo/usecases";
|
||||
import { getError } from "@pkg/logger";
|
||||
import { ERROR_CODES } from "@pkg/result";
|
||||
import type { PageServerLoad } from "./$types";
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const uid = parseInt(params.uid);
|
||||
if (!uid || isNaN(uid) || uid < 0 || uid > Number.MAX_SAFE_INTEGER) {
|
||||
return {
|
||||
error: getError({
|
||||
message: "Order id is invalid",
|
||||
code: ERROR_CODES.INPUT_ERROR,
|
||||
detail: "Order id is invalid",
|
||||
userHint: "Provide a valid order id",
|
||||
actionable: false,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return await getCustomerInfoUseCases().getAllCustomerInfo();
|
||||
};
|
||||
@@ -1,245 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import Container from "$lib/components/atoms/container.svelte";
|
||||
import Icon from "$lib/components/atoms/icon.svelte";
|
||||
import Title from "$lib/components/atoms/title.svelte";
|
||||
import * as Breadcrumb from "$lib/components/ui/breadcrumb/index.js";
|
||||
import { adminSiteNavMap, CARD_STYLE } from "$lib/core/constants";
|
||||
import { capitalize } from "$lib/core/string.utils";
|
||||
import CinfoCard from "$lib/domains/customerinfo/view/cinfo-card.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import GenderIcon from "~icons/mdi/gender-male-female";
|
||||
import PackageIcon from "~icons/solar/box-broken";
|
||||
import CubeIcon from "~icons/solar/box-minimalistic-broken";
|
||||
import CalendarIcon from "~icons/solar/calendar-broken";
|
||||
import CalendarCheckIcon from "~icons/solar/calendar-linear";
|
||||
import CardNumberIcon from "~icons/solar/card-recive-broken";
|
||||
import ClipboardIcon from "~icons/solar/clipboard-list-broken";
|
||||
import DocumentIcon from "~icons/solar/document-text-broken";
|
||||
import EmailIcon from "~icons/solar/letter-broken";
|
||||
import LockKeyIcon from "~icons/solar/lock-keyhole-minimalistic-broken";
|
||||
import LocationIcon from "~icons/solar/map-point-broken";
|
||||
import PassportIcon from "~icons/solar/passport-broken";
|
||||
import PhoneIcon from "~icons/solar/phone-broken";
|
||||
import UserIdIcon from "~icons/solar/user-id-broken";
|
||||
import CardUserIcon from "~icons/solar/user-id-linear";
|
||||
import CreditCardIcon from "~icons/solar/wallet-money-broken";
|
||||
import type { PageData } from "./$types";
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let name = $state(
|
||||
`${data?.data?.passengerPii.firstName} ${data?.data?.passengerPii.lastName}`,
|
||||
);
|
||||
|
||||
let pii = data.data?.passengerPii;
|
||||
let paymentInfo = data.data?.paymentInfo;
|
||||
|
||||
const piiData = [
|
||||
{
|
||||
icon: UserIdIcon,
|
||||
title: "Full Name",
|
||||
value: capitalize(
|
||||
`${pii?.firstName} ${pii?.middleName} ${pii?.lastName}`,
|
||||
true,
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: EmailIcon,
|
||||
title: "Email",
|
||||
value: pii?.email,
|
||||
},
|
||||
{
|
||||
icon: PhoneIcon,
|
||||
title: "Phone",
|
||||
value: `${pii?.phoneCountryCode ?? ""} ${pii?.phoneNumber ?? ""}`,
|
||||
},
|
||||
{
|
||||
icon: PassportIcon,
|
||||
title: "Passport No",
|
||||
value: pii?.passportNo,
|
||||
},
|
||||
{
|
||||
icon: LocationIcon,
|
||||
title: "Nationality",
|
||||
value: capitalize(pii?.nationality ?? ""),
|
||||
},
|
||||
{
|
||||
icon: GenderIcon,
|
||||
title: "Gender",
|
||||
value: capitalize(pii?.gender ?? ""),
|
||||
},
|
||||
{
|
||||
icon: CalendarIcon,
|
||||
title: "Date of Birth",
|
||||
value: new Date(pii?.dob ?? "").toDateString(),
|
||||
},
|
||||
{
|
||||
icon: CubeIcon,
|
||||
title: "Passenger Type",
|
||||
value: capitalize(data.data?.passengerType ?? ""),
|
||||
},
|
||||
];
|
||||
|
||||
// No icons for this one
|
||||
const addressInfo = [
|
||||
{
|
||||
title: "Country",
|
||||
value: pii?.country ?? "",
|
||||
},
|
||||
{
|
||||
title: "State",
|
||||
value: pii?.state ?? "",
|
||||
},
|
||||
{
|
||||
title: "City",
|
||||
value: pii?.city ?? "",
|
||||
},
|
||||
{
|
||||
title: "Zip/Postal Code",
|
||||
value: pii?.zipCode ?? "",
|
||||
},
|
||||
{
|
||||
title: "Address",
|
||||
value: pii?.address ?? "",
|
||||
},
|
||||
{
|
||||
title: "Address Line 2",
|
||||
value: pii?.address2 ?? "",
|
||||
},
|
||||
];
|
||||
|
||||
// Card information
|
||||
const cardInfo = paymentInfo
|
||||
? [
|
||||
{
|
||||
icon: CardUserIcon,
|
||||
title: "Cardholder Name",
|
||||
value: paymentInfo.cardholderName ?? "",
|
||||
},
|
||||
{
|
||||
icon: CardNumberIcon,
|
||||
title: "Card Number",
|
||||
value: paymentInfo.cardNumber
|
||||
? // add spaces of 4 between each group of 4 digits
|
||||
paymentInfo.cardNumber.match(/.{1,4}/g)?.join(" ")
|
||||
: "",
|
||||
},
|
||||
{
|
||||
icon: CalendarCheckIcon,
|
||||
title: "Expiry Date",
|
||||
value: paymentInfo.expiry ?? "",
|
||||
},
|
||||
{
|
||||
icon: LockKeyIcon,
|
||||
title: "CVV",
|
||||
value: paymentInfo.cvv ?? "",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const hasAddressInfo =
|
||||
addressInfo.filter((e) => e.value.length > 0).length > 0;
|
||||
|
||||
const hasCardInfo = cardInfo.length > 0;
|
||||
|
||||
onMount(() => {
|
||||
if (data.error) {
|
||||
goto(adminSiteNavMap.dashboard);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Breadcrumb.Root class="m-2.5 mb-8">
|
||||
<Breadcrumb.List>
|
||||
<Breadcrumb.Item>
|
||||
<Breadcrumb.Link href={adminSiteNavMap.data}>
|
||||
Passengers Data
|
||||
</Breadcrumb.Link>
|
||||
</Breadcrumb.Item>
|
||||
<Breadcrumb.Separator />
|
||||
<Breadcrumb.Item>
|
||||
<Breadcrumb.Page>{name}</Breadcrumb.Page>
|
||||
</Breadcrumb.Item>
|
||||
</Breadcrumb.List>
|
||||
</Breadcrumb.Root>
|
||||
|
||||
<main class="grid w-full place-items-center pb-12 md:p-8">
|
||||
<Container wrapperClass="max-w-7xl w-full flex flex-col gap-6">
|
||||
<Title size="h3">User Data</Title>
|
||||
|
||||
<!-- Personal Information -->
|
||||
<div class={CARD_STYLE}>
|
||||
<div class="mb-6 flex items-center gap-2">
|
||||
<Icon icon={DocumentIcon} cls="w-auto h-6" />
|
||||
<Title size="h4" color="black">Personal Information</Title>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-1 lg:grid-cols-3"
|
||||
>
|
||||
{#each piiData as { icon, title, value }}
|
||||
<CinfoCard {icon} {title}>
|
||||
<p class="break-all font-medium">{value}</p>
|
||||
</CinfoCard>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if hasAddressInfo}
|
||||
<div class={CARD_STYLE}>
|
||||
<div class="mb-6 flex items-center gap-2">
|
||||
<Icon icon={LocationIcon} cls="w-auto h-6" />
|
||||
<Title size="h4" color="black">Address Information</Title>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 md:grid-cols-3">
|
||||
{#each addressInfo as { title, value }}
|
||||
<CinfoCard {title}>
|
||||
<p class="font-medium">{value}</p>
|
||||
</CinfoCard>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hasCardInfo}
|
||||
<div class={CARD_STYLE}>
|
||||
<div class="mb-6 flex items-center gap-2">
|
||||
<Icon icon={CreditCardIcon} cls="w-auto h-6" />
|
||||
<Title size="h4" color="black">Payment Information</Title>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
|
||||
{#each cardInfo as { icon, title, value }}
|
||||
<CinfoCard {icon} {title}>
|
||||
<p class="break-all font-medium">{value}</p>
|
||||
</CinfoCard>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if data.data?.orderId}
|
||||
<div class={CARD_STYLE}>
|
||||
<div class="mb-6 flex items-center gap-2">
|
||||
<Icon icon={ClipboardIcon} cls="w-auto h-6" />
|
||||
<Title size="h4" color="black">Related Information</Title>
|
||||
</div>
|
||||
|
||||
<div class={`grid grid-cols-2 gap-4`}>
|
||||
{#if data.data?.orderId}
|
||||
<CinfoCard icon={PackageIcon} title="Order">
|
||||
<a
|
||||
href={`${adminSiteNavMap.orders}/${data.data.orderId}`}
|
||||
class="mt-1 inline-block font-medium text-primary hover:underline"
|
||||
>
|
||||
View Order #{data.data.orderId}
|
||||
</a>
|
||||
</CinfoCard>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Container>
|
||||
</main>
|
||||
@@ -1,9 +1,9 @@
|
||||
import { OrderRepository } from "$lib/domains/order/data/repository.js";
|
||||
import { OrderController } from "$lib/domains/order/domain/controller.js";
|
||||
import { db } from "@pkg/db";
|
||||
import type { PageServerLoad } from "./$types.js";
|
||||
import { getError } from "@pkg/logger";
|
||||
import { ERROR_CODES } from "@pkg/result";
|
||||
import type { PageServerLoad } from "./$types.js";
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const oid = parseInt(params.oid);
|
||||
@@ -18,6 +18,5 @@ export const load: PageServerLoad = async ({ params }) => {
|
||||
}),
|
||||
};
|
||||
}
|
||||
const oc = new OrderController(new OrderRepository(db));
|
||||
return await oc.getOrder(oid);
|
||||
return await new OrderController(new OrderRepository(db)).getOrder(oid);
|
||||
};
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<script lang="ts">
|
||||
import Container from "$lib/components/atoms/container.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import type { PageData } from "./$types";
|
||||
import { pageTitle } from "$lib/hooks/page-title.svelte";
|
||||
import Badge from "$lib/components/ui/badge/badge.svelte";
|
||||
import Title from "$lib/components/atoms/title.svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
import Badge from "$lib/components/ui/badge/badge.svelte";
|
||||
import { snakeToSpacedPascal } from "$lib/core/string.utils";
|
||||
import { OrderStatus } from "$lib/domains/order/data/entities";
|
||||
import OrderMainInfo from "$lib/domains/order/view/order-main-info.svelte";
|
||||
import OrderMiscInfo from "$lib/domains/order/view/order-misc-info.svelte";
|
||||
import { OrderStatus } from "$lib/domains/order/data/entities";
|
||||
import { snakeToSpacedPascal } from "$lib/core/string.utils";
|
||||
import { pageTitle } from "$lib/hooks/page-title.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
import type { PageData } from "./$types";
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
console.log(data.data);
|
||||
$inspect(data.data);
|
||||
|
||||
onMount(() => {
|
||||
if (data.error) {
|
||||
@@ -40,20 +40,35 @@
|
||||
|
||||
{#if data.data}
|
||||
<main class="grid w-full place-items-center gap-4">
|
||||
<Container containerClass="w-full flex flex-col gap-8">
|
||||
<div class="flex items-center justify-between gap-4 md:flex-row">
|
||||
<Container containerClass="w-full flex flex-col gap-6">
|
||||
<!-- Header Section -->
|
||||
<div
|
||||
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Title size="h3">Order Details</Title>
|
||||
<Badge variant={getStatusVariant(data.data?.status)}>
|
||||
<p class="text-sm text-gray-500">Order #{data.data.id}</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant={getStatusVariant(data.data?.status)}
|
||||
class="w-fit"
|
||||
>
|
||||
{snakeToSpacedPascal(data.data?.status.toLowerCase())}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid h-full w-full grid-cols-1 gap-4 md:gap-6 lg:grid-cols-2 lg:gap-8"
|
||||
>
|
||||
<!-- Main Content Grid -->
|
||||
<div class="grid h-full w-full grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<!-- Left Column - Main Info (2/3 width on large screens) -->
|
||||
<div class="lg:col-span-2">
|
||||
<OrderMainInfo order={data.data} />
|
||||
</div>
|
||||
|
||||
<!-- Right Column - Misc Info (1/3 width on large screens) -->
|
||||
<div class="lg:col-span-1">
|
||||
<OrderMiscInfo order={data.data} />
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</main>
|
||||
{/if}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
{
|
||||
"$schema": "https://next.shadcn-svelte.com/schema.json",
|
||||
"style": "default",
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/app.css",
|
||||
"baseColor": "slate"
|
||||
},
|
||||
@@ -10,8 +8,9 @@
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"hooks": "$lib/hooks"
|
||||
"hooks": "$lib/hooks",
|
||||
"lib": "$lib"
|
||||
},
|
||||
"typescript": true,
|
||||
"registry": "https://next.shadcn-svelte.com/registry"
|
||||
"registry": "https://tw3.shadcn-svelte.com/registry/default"
|
||||
}
|
||||
|
||||
@@ -48,12 +48,12 @@
|
||||
"devDependencies": {
|
||||
"@iconify/json": "^2.2.275",
|
||||
"@internationalized/date": "^3.6.0",
|
||||
"@lucide/svelte": "^0.503.0",
|
||||
"@lucide/svelte": "^0.482.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@types/node": "^22.9.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"bits-ui": "^1.4.1",
|
||||
"bits-ui": "^1.4.7",
|
||||
"clsx": "^2.1.1",
|
||||
"embla-carousel-svelte": "^8.6.0",
|
||||
"formsnap": "^2.0.0",
|
||||
|
||||
@@ -3,103 +3,151 @@
|
||||
@tailwind utilities;
|
||||
|
||||
@font-face {
|
||||
font-family: "Poppins";
|
||||
src: url("/fonts/Poppins-Thin.ttf") format("truetype");
|
||||
font-weight: 100;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Poppins";
|
||||
src: url("/fonts/Poppins-ExtraLight.ttf") format("truetype");
|
||||
font-weight: 200;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Poppins";
|
||||
src: url("/fonts/Poppins-Light.ttf") format("truetype");
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Poppins";
|
||||
src: url("/fonts/Poppins-Regular.ttf") format("truetype");
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Poppins";
|
||||
src: url("/fonts/Poppins-Medium.ttf") format("truetype");
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Poppins";
|
||||
src: url("/fonts/Poppins-SemiBold.ttf") format("truetype");
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Poppins";
|
||||
src: url("/fonts/Poppins-Bold.ttf") format("truetype");
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Poppins";
|
||||
src: url("/fonts/Poppins-ExtraBold.ttf") format("truetype");
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Poppins";
|
||||
src: url("/fonts/Poppins-Black.ttf") format("truetype");
|
||||
font-weight: 900;
|
||||
font-family: "Fredoka";
|
||||
src: url("/fonts/fredoka-variable.ttf") format("truetype");
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 338 28% 98%;
|
||||
--foreground: 338 5% 10%;
|
||||
--card: 338 28% 98%;
|
||||
--card-foreground: 338 5% 15%;
|
||||
--popover: 338 28% 98%;
|
||||
--popover-foreground: 338 95% 10%;
|
||||
--primary: 338 63% 55.5%;
|
||||
/* Backgrounds - Clean neutral grays */
|
||||
--background: 0 0% 98%;
|
||||
--foreground: 222 47% 11%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222 47% 11%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222 47% 11%;
|
||||
|
||||
/* Primary - Modern slate/blue (Stripe-inspired) */
|
||||
--primary: 221 83% 53%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 338 28% 90%;
|
||||
--secondary-foreground: 0 0% 0%;
|
||||
--muted: 300 28% 95%;
|
||||
--muted-foreground: 338 5% 40%;
|
||||
--accent: 300 28% 90%;
|
||||
--accent-foreground: 338 5% 15%;
|
||||
--destructive: 0 50% 50%;
|
||||
--destructive-foreground: 338 5% 98%;
|
||||
--border: 338 28% 82%;
|
||||
--input: 338 28% 50%;
|
||||
--ring: 338 63% 55.5%;
|
||||
--radius: 0.75rem;
|
||||
|
||||
/* Secondary - Soft gray-blue */
|
||||
--secondary: 214 32% 91%;
|
||||
--secondary-foreground: 222 47% 11%;
|
||||
|
||||
/* Muted - Light grays */
|
||||
--muted: 210 40% 96%;
|
||||
--muted-foreground: 215 16% 47%;
|
||||
|
||||
/* Accent - Vibrant blue */
|
||||
--accent: 217 91% 60%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
|
||||
/* Destructive - Red for errors */
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
/* Borders & Inputs - Subtle gray */
|
||||
--border: 214 32% 91%;
|
||||
--input: 214 32% 91%;
|
||||
--ring: 221 83% 53%;
|
||||
|
||||
/* Charts - Modern palette */
|
||||
--chart-1: 221 83% 53%;
|
||||
--chart-2: 142 76% 36%;
|
||||
--chart-3: 262 83% 58%;
|
||||
--chart-4: 41 96% 50%;
|
||||
--chart-5: 0 84% 60%;
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar: 0 0% 98%;
|
||||
--sidebar-foreground: 222 47% 11%;
|
||||
--sidebar-primary: 221 83% 53%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 214 32% 91%;
|
||||
--sidebar-accent-foreground: 222 47% 11%;
|
||||
--sidebar-border: 214 32% 91%;
|
||||
--sidebar-ring: 221 83% 53%;
|
||||
|
||||
/* Typography */
|
||||
--font-sans: Fredoka, sans-serif;
|
||||
--font-serif: Georgia, serif;
|
||||
--font-mono: ui-monospace, monospace;
|
||||
|
||||
/* Design tokens */
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Shadows - Neutral */
|
||||
--shadow-color: 220 9% 46%;
|
||||
--shadow-2xs: 0 1px 2px 0 hsl(220 9% 46% / 0.05);
|
||||
--shadow-xs: 0 1px 3px 0 hsl(220 9% 46% / 0.1);
|
||||
--shadow-sm: 0 2px 4px 0 hsl(220 9% 46% / 0.1);
|
||||
--shadow: 0 4px 6px -1px hsl(220 9% 46% / 0.1);
|
||||
--shadow-md: 0 6px 12px -2px hsl(220 9% 46% / 0.15);
|
||||
--shadow-lg: 0 10px 20px -5px hsl(220 9% 46% / 0.2);
|
||||
--shadow-xl: 0 20px 25px -5px hsl(220 9% 46% / 0.25);
|
||||
--shadow-2xl: 0 25px 50px -12px hsl(220 9% 46% / 0.3);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 338 28% 10%;
|
||||
--foreground: 338 5% 98%;
|
||||
--card: 338 28% 10%;
|
||||
--card-foreground: 338 5% 98%;
|
||||
--popover: 338 28% 5%;
|
||||
--popover-foreground: 338 5% 98%;
|
||||
--primary: 338 63% 55.5%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 338 28% 20%;
|
||||
--secondary-foreground: 0 0% 100%;
|
||||
--muted: 300 28% 25%;
|
||||
--muted-foreground: 338 5% 65%;
|
||||
--accent: 300 28% 25%;
|
||||
--accent-foreground: 338 5% 95%;
|
||||
--destructive: 0 50% 50%;
|
||||
--destructive-foreground: 338 5% 98%;
|
||||
--border: 338 28% 50%;
|
||||
--input: 338 28% 50%;
|
||||
--ring: 338 63% 55.5%;
|
||||
--radius: 0.75rem;
|
||||
/* Backgrounds - Dark mode */
|
||||
--background: 222 47% 11%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222 47% 15%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222 47% 15%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
|
||||
/* Primary - Brighter blue for dark mode */
|
||||
--primary: 217 91% 60%;
|
||||
--primary-foreground: 222 47% 11%;
|
||||
|
||||
/* Secondary */
|
||||
--secondary: 217 33% 17%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
|
||||
/* Muted */
|
||||
--muted: 223 47% 11%;
|
||||
--muted-foreground: 215 20% 65%;
|
||||
|
||||
/* Accent */
|
||||
--accent: 217 91% 60%;
|
||||
--accent-foreground: 222 47% 11%;
|
||||
|
||||
/* Destructive */
|
||||
--destructive: 0 63% 50%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
/* Borders & Inputs */
|
||||
--border: 217 33% 17%;
|
||||
--input: 217 33% 17%;
|
||||
--ring: 217 91% 60%;
|
||||
|
||||
/* Charts */
|
||||
--chart-1: 217 91% 60%;
|
||||
--chart-2: 142 76% 36%;
|
||||
--chart-3: 262 83% 58%;
|
||||
--chart-4: 41 96% 50%;
|
||||
--chart-5: 0 84% 60%;
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar: 222 47% 11%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 217 91% 60%;
|
||||
--sidebar-primary-foreground: 222 47% 11%;
|
||||
--sidebar-accent: 217 33% 17%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 217 33% 17%;
|
||||
--sidebar-ring: 217 91% 60%;
|
||||
|
||||
/* Typography */
|
||||
--font-sans: Fredoka, sans-serif;
|
||||
--font-serif: Georgia, serif;
|
||||
--font-mono: ui-monospace, monospace;
|
||||
|
||||
/* Design tokens */
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Shadows - Dark mode */
|
||||
--shadow-color: 0 0% 0%;
|
||||
--shadow-2xs: 0 1px 2px 0 hsl(0 0% 0% / 0.3);
|
||||
--shadow-xs: 0 1px 3px 0 hsl(0 0% 0% / 0.4);
|
||||
--shadow-sm: 0 2px 4px 0 hsl(0 0% 0% / 0.4);
|
||||
--shadow: 0 4px 6px -1px hsl(0 0% 0% / 0.4);
|
||||
--shadow-md: 0 6px 12px -2px hsl(0 0% 0% / 0.5);
|
||||
--shadow-lg: 0 10px 20px -5px hsl(0 0% 0% / 0.6);
|
||||
--shadow-xl: 0 20px 25px -5px hsl(0 0% 0% / 0.7);
|
||||
--shadow-2xl: 0 25px 50px -12px hsl(0 0% 0% / 0.75);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,20 +160,21 @@
|
||||
@apply bg-background text-foreground;
|
||||
scroll-behavior: smooth;
|
||||
font-family:
|
||||
"Poppins",
|
||||
system-ui,
|
||||
"Fredoka",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
"Helvetica Neue",
|
||||
Arial,
|
||||
"Noto Sans",
|
||||
sans-serif,
|
||||
"Apple Color Emoji",
|
||||
"Segoe UI Emoji",
|
||||
"Segoe UI Symbol",
|
||||
"Noto Color Emoji";
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,8 +189,99 @@
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
/* Stripe-inspired gradients */
|
||||
.gradient-mesh {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.gradient-success {
|
||||
background: linear-gradient(135deg, #84fab0 0%, #8fd3f4 100%);
|
||||
}
|
||||
|
||||
.gradient-error {
|
||||
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
.transition-smooth {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Glass morphism effect */
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom Scrollbar Styling */
|
||||
@layer base {
|
||||
/* For Webkit browsers (Chrome, Safari, Edge) */
|
||||
::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--primary));
|
||||
border-radius: 10px;
|
||||
border: 3px solid white;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--primary) / 0.8);
|
||||
}
|
||||
|
||||
/* For Firefox */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--primary)) white;
|
||||
}
|
||||
|
||||
/* Thin scrollbar for command lists */
|
||||
.command-scrollbar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.command-scrollbar::-webkit-scrollbar-track {
|
||||
background: hsl(var(--muted));
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.command-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--primary));
|
||||
border-radius: 10px;
|
||||
border: 2px solid hsl(var(--muted));
|
||||
}
|
||||
|
||||
.command-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--primary) / 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
.all-reset {
|
||||
all: unset;
|
||||
}
|
||||
|
||||
/* Custom focus styles for better accessibility */
|
||||
@layer base {
|
||||
*:focus-visible {
|
||||
@apply outline-none ring-2 ring-primary ring-offset-2;
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth page transitions */
|
||||
@layer base {
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { PROJECT_NAME, SPECIAL_PARTNERS } from "$lib/core/constants";
|
||||
import Logo from "$lib/components/atoms/logo.svelte";
|
||||
import PaymentCardImages from "$lib/components/atoms/payment-card-images.svelte";
|
||||
import CurrencySelect from "$lib/domains/currency/view/currency-select.svelte";
|
||||
import MaxWidthWrapper from "../max-width-wrapper.svelte";
|
||||
import Input from "$lib/components/ui/input/input.svelte";
|
||||
import Button from "$lib/components/ui/button/button.svelte";
|
||||
import Facebook from "~icons/ic/baseline-facebook";
|
||||
import Twitter from "~icons/arcticons/x-twitter";
|
||||
import Instagram from "~icons/hugeicons/instagram";
|
||||
import Linkedin from "~icons/tabler/brand-linkedin";
|
||||
import Youtube from "~icons/uil/youtube";
|
||||
|
||||
// Social media links
|
||||
const SOCIAL_LINKS = [
|
||||
{ icon: Facebook, url: "#", ariaLabel: "Facebook" },
|
||||
{ icon: Twitter, url: "#", ariaLabel: "Twitter" },
|
||||
{ icon: Instagram, url: "#", ariaLabel: "Instagram" },
|
||||
{ icon: Linkedin, url: "#", ariaLabel: "LinkedIn" },
|
||||
{ icon: Youtube, url: "#", ariaLabel: "YouTube" },
|
||||
];
|
||||
|
||||
// Company links
|
||||
const COMPANY_LINKS = [
|
||||
{ name: "About Us", url: "#" },
|
||||
{ name: "Careers", url: "#" },
|
||||
{ name: "Press", url: "#" },
|
||||
{ name: "Partners", url: "#" },
|
||||
];
|
||||
|
||||
// Support links
|
||||
const SUPPORT_LINKS = [
|
||||
{ name: "Help Center", url: "#" },
|
||||
{ name: "Contact Us", url: "#" },
|
||||
{ name: "Privacy Policy", url: "#" },
|
||||
{ name: "Terms of Service", url: "#" },
|
||||
];
|
||||
|
||||
// Legal links
|
||||
const LEGAL_LINKS = [
|
||||
{ name: "Privacy Policy", url: "#" },
|
||||
{ name: "Terms of Service", url: "#" },
|
||||
{ name: "Cookies", url: "#" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<footer class="bg-brand-950 text-white">
|
||||
<MaxWidthWrapper cls="px-4 py-16">
|
||||
<div class="grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-5">
|
||||
<div class="lg:col-span-2">
|
||||
<Logo intent="white" />
|
||||
<p class="my-6 max-w-md text-brand-100">
|
||||
Connecting travelers to their destinations with premium
|
||||
service, competitive prices, and a seamless booking experience
|
||||
since 2020.
|
||||
</p>
|
||||
<!-- <div class="flex space-x-4">
|
||||
{#each SOCIAL_LINKS as social}
|
||||
<a
|
||||
href={social.url}
|
||||
class="rounded-full bg-brand-900 p-2 transition-colors hover:bg-brand-700"
|
||||
aria-label={social.ariaLabel}
|
||||
>
|
||||
<svelte:component
|
||||
this={social.icon}
|
||||
class="h-4 w-5"
|
||||
/>
|
||||
</a>
|
||||
{/each}
|
||||
</div> -->
|
||||
<!-- <div class="mt-6">
|
||||
<a
|
||||
class="flex items-center gap-2 text-brand-100 hover:text-white"
|
||||
href={`mailto:${CONTACT_INFO.email}`}
|
||||
>
|
||||
<Icon icon={IconEmail} cls="h-4 w-auto" />
|
||||
<p>
|
||||
{CONTACT_INFO.email}
|
||||
</p>
|
||||
</a>
|
||||
</div> -->
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-4 text-lg font-semibold text-white">Company</h3>
|
||||
<ul class="space-y-2">
|
||||
{#each COMPANY_LINKS as link}
|
||||
<li>
|
||||
<a
|
||||
href={link.url}
|
||||
class="text-brand-100 transition-colors hover:text-white"
|
||||
>{link.name}</a
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-4 text-lg font-semibold text-white">Support</h3>
|
||||
<ul class="space-y-2">
|
||||
{#each SUPPORT_LINKS as link}
|
||||
<li>
|
||||
<a
|
||||
href={link.url}
|
||||
class="text-brand-100 transition-colors hover:text-white"
|
||||
>{link.name}</a
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-4 text-lg font-semibold text-white">Newsletter</h3>
|
||||
<p class="mb-4 text-brand-100">
|
||||
Subscribe for travel inspiration and exclusive deals.
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
<Input
|
||||
placeholder="Your email address"
|
||||
class="border-brand-800 bg-brand-900 text-white"
|
||||
/>
|
||||
<Button class="w-full bg-brand-600 hover:bg-brand-500">
|
||||
Subscribe
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="Md:items-center mt-12 flex flex-col justify-between gap-4 border-t border-brand-800 pt-8 md:flex-row"
|
||||
>
|
||||
<div class="mb-6">
|
||||
<h3 class="mb-4 text-lg font-semibold text-white">
|
||||
Payment Methods
|
||||
</h3>
|
||||
<PaymentCardImages />
|
||||
</div>
|
||||
|
||||
{#if SPECIAL_PARTNERS.length > 0}
|
||||
<div class="mb-8">
|
||||
<h3 class="mb-4 text-lg font-semibold text-white">
|
||||
Affiliated Partners
|
||||
</h3>
|
||||
<div class="flex flex-wrap items-center gap-6">
|
||||
{#each SPECIAL_PARTNERS as partner}
|
||||
<img
|
||||
src={partner.link}
|
||||
alt={partner.alt}
|
||||
class="h-8 w-auto"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-8 border-t border-brand-800 pt-8">
|
||||
<div
|
||||
class="flex flex-col items-center justify-between gap-4 md:flex-row"
|
||||
>
|
||||
<p class="text-sm text-brand-300">
|
||||
© {new Date().getFullYear()}
|
||||
{PROJECT_NAME}. All rights reserved.
|
||||
</p>
|
||||
<div class="flex flex-wrap justify-center gap-6">
|
||||
{#each LEGAL_LINKS as link}
|
||||
<a
|
||||
href={link.url}
|
||||
class="text-sm text-brand-300 hover:text-white"
|
||||
>{link.name}</a
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
<CurrencySelect invert={false} />
|
||||
</div>
|
||||
</div>
|
||||
</MaxWidthWrapper>
|
||||
</footer>
|
||||
@@ -1,54 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Sheet, SheetContent, SheetTrigger } from "$lib/components/ui/sheet";
|
||||
import { buttonVariants } from "$lib/components/ui/button";
|
||||
import IconMenu from "~icons/solar/hamburger-menu-broken";
|
||||
import IconClose from "~icons/mingcute/close-line";
|
||||
import { NAV_LINKS, TRANSITION_COLORS } from "$lib/core/constants";
|
||||
import { cn } from "$lib/utils";
|
||||
import Icon from "$lib/components/atoms/icon.svelte";
|
||||
import Logo from "$lib/components/atoms/logo.svelte";
|
||||
|
||||
let { invert }: { invert: boolean } = $props();
|
||||
|
||||
let open = $state(false);
|
||||
</script>
|
||||
|
||||
<Sheet {open} onOpenChange={(to) => (open = to)}>
|
||||
<SheetTrigger
|
||||
class={cn(
|
||||
"block lg:hidden",
|
||||
buttonVariants({
|
||||
variant: invert ? "glassWhite" : "ghost",
|
||||
size: "icon",
|
||||
}),
|
||||
)}
|
||||
onclick={() => (open = true)}
|
||||
>
|
||||
<Icon icon={IconMenu} cls={"h-6 w-auto"} />
|
||||
</SheetTrigger>
|
||||
<SheetContent side="bottom" class="z-[101]">
|
||||
<button
|
||||
onclick={() => (open = false)}
|
||||
class="absolute right-4 top-4 grid place-items-center rounded-md border border-neutral-400 p-1 text-neutral-500"
|
||||
>
|
||||
<IconClose class="h-5 w-5" />
|
||||
</button>
|
||||
<Logo />
|
||||
<div class="mt-8 flex flex-col gap-2 overflow-y-auto">
|
||||
{#each NAV_LINKS as link}
|
||||
<a
|
||||
href={link.link}
|
||||
onclick={() => (open = false)}
|
||||
class={cn(
|
||||
"text-sgreen flex items-center gap-2 rounded-lg border-2 p-3 px-4",
|
||||
TRANSITION_COLORS,
|
||||
"border-transparent hover:border-brand-300 hover:bg-brand-100",
|
||||
)}
|
||||
>
|
||||
<Icon icon={link.icon} cls="h-5 w-5" />
|
||||
<span>{link.name}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
@@ -1,64 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { NAV_LINKS, TRANSITION_COLORS } from "$lib/core/constants";
|
||||
import Logo from "$lib/components/atoms/logo.svelte";
|
||||
import Button from "$lib/components/ui/button/button.svelte";
|
||||
import { cn } from "$lib/utils";
|
||||
import MobileNavSheet from "./mobile-nav-sheet.svelte";
|
||||
import Icon from "$lib/components/atoms/icon.svelte";
|
||||
import BookWithBookmarkIcon from "~icons/solar/book-bookmark-linear";
|
||||
import MaxWidthWrapper from "../max-width-wrapper.svelte";
|
||||
import { SearchIcon } from "@lucide/svelte";
|
||||
|
||||
let { invert }: { invert: boolean } = $props();
|
||||
</script>
|
||||
|
||||
<div class="h-24"></div>
|
||||
|
||||
<nav
|
||||
class={cn(
|
||||
"fixed left-0 top-0 z-50 grid w-screen place-items-center bg-white/80 drop-shadow-md backdrop-blur-md",
|
||||
)}
|
||||
>
|
||||
<MaxWidthWrapper
|
||||
cls="flex w-full items-center justify-between gap-4 p-4 bg-transparent"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="/">
|
||||
<Logo cls="w-auto h-8 md:h-10" />
|
||||
</a>
|
||||
|
||||
<div
|
||||
class="hidden w-full items-center justify-center gap-2 lg:flex lg:gap-4"
|
||||
>
|
||||
{#each NAV_LINKS as link}
|
||||
<a
|
||||
href={link.link}
|
||||
class={cn(
|
||||
"w-28 rounded-lg px-4 py-2 text-center",
|
||||
"bg-transparent hover:bg-primary/10 hover:text-brand-700",
|
||||
TRANSITION_COLORS,
|
||||
)}
|
||||
>
|
||||
{link.name}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden items-center gap-2 lg:flex">
|
||||
<a href={"/track"}>
|
||||
<Button variant="ghost">
|
||||
<Icon icon={BookWithBookmarkIcon} cls="w-auto h-5" />
|
||||
<span>Track</span>
|
||||
</Button>
|
||||
</a>
|
||||
<a href={"/search"}>
|
||||
<Button>
|
||||
<Icon icon={SearchIcon} cls="w-auto h-5" />
|
||||
<span>Search Flights</span>
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
<MobileNavSheet {invert} />
|
||||
</MaxWidthWrapper>
|
||||
</nav>
|
||||
@@ -1,77 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { ArrowRight, Plane } from "@lucide/svelte";
|
||||
import Card from "$lib/components/ui/card/card.svelte";
|
||||
import CardContent from "$lib/components/ui/card/card-content.svelte";
|
||||
|
||||
let { deals } = $props();
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
{#each deals as deal}
|
||||
<Card class="card-hover overflow-hidden">
|
||||
<CardContent class="p-5">
|
||||
{#if deal.tag}
|
||||
<div class="mb-3">
|
||||
<span
|
||||
class="rounded-full bg-brand-500 px-3 py-1 text-xs font-medium text-white"
|
||||
>
|
||||
{deal.tag}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<Plane class="mr-2 h-5 w-5 text-brand-500" />
|
||||
<span class="font-medium">{deal.airline}</span>
|
||||
</div>
|
||||
|
||||
{#if deal.discount}
|
||||
<span
|
||||
class="rounded bg-brand-100 px-2 py-1 text-xs font-medium text-brand-600"
|
||||
>
|
||||
{deal.discount}% OFF
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xl font-bold">{deal.from}</p>
|
||||
<p class="text-sm text-muted-foreground">Departure</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 px-4">
|
||||
<div class="relative">
|
||||
<div
|
||||
class="absolute left-0 right-0 top-1/2 -translate-y-1/2 transform border-t border-dashed border-gray-300"
|
||||
></div>
|
||||
<div
|
||||
class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform bg-white p-1"
|
||||
>
|
||||
<ArrowRight class="h-4 w-4 text-brand-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-right">
|
||||
<p class="text-xl font-bold">{deal.to}</p>
|
||||
<p class="text-sm text-muted-foreground">Arrival</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">{deal.date}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm">from</p>
|
||||
<p class="text-2xl font-bold text-brand-600">
|
||||
${deal.price}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -1,132 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Card from "$lib/components/ui/card/card.svelte";
|
||||
import CardContent from "$lib/components/ui/card/card-content.svelte";
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
} from "$lib/components/ui/carousel";
|
||||
|
||||
import { StarIcon } from "@lucide/svelte";
|
||||
import Icon from "../atoms/icon.svelte";
|
||||
import { cn } from "$lib/utils";
|
||||
import MaxWidthWrapper from "../molecules/max-width-wrapper.svelte";
|
||||
|
||||
type Testimonial = {
|
||||
id: number;
|
||||
name: string;
|
||||
role: string;
|
||||
message: string;
|
||||
avatar: string;
|
||||
rating: number;
|
||||
};
|
||||
|
||||
const testimonials: Testimonial[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Sarah Johnson",
|
||||
role: "Business Traveler",
|
||||
message:
|
||||
"FlyTicketTravel made my business trips so much easier. Their intuitive booking platform and exceptional customer service saved me hours of planning time.",
|
||||
avatar: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&q=80&w=2487&ixlib=rb-4.0.3",
|
||||
rating: 5,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Michael Chen",
|
||||
role: "Adventure Seeker",
|
||||
message:
|
||||
"I've used many flight booking platforms, but FlyTicketTravel stands out with their competitive prices and flexible cancellation policies. My go-to for all my adventures!",
|
||||
avatar: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&q=80&w=2187&ixlib=rb-4.0.3",
|
||||
rating: 4,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Emma Rodriguez",
|
||||
role: "Family Traveler",
|
||||
message:
|
||||
"Booking flights for the whole family used to be stressful until I discovered FlyTicketTravel. Their group booking feature and 24/7 support made our vacation planning seamless.",
|
||||
avatar: "https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&q=80&w=2564&ixlib=rb-4.0.3",
|
||||
rating: 5,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "James Wilson",
|
||||
role: "Digital Nomad",
|
||||
message:
|
||||
"As someone who travels constantly, I appreciate FlyTicketTravel's rewards program and their mobile app that lets me book flights from anywhere in the world.",
|
||||
avatar: "https://images.unsplash.com/photo-1539571696357-5a69c17a67c6?auto=format&fit=crop&q=80&w=2662&ixlib=rb-4.0.3",
|
||||
rating: 5,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<MaxWidthWrapper cls="py-24 px-4">
|
||||
<div class="mx-auto mb-16 max-w-3xl text-center">
|
||||
<h2 class="mb-4 text-3xl font-bold md:text-4xl">
|
||||
What Our Customers Say
|
||||
</h2>
|
||||
<p class="text-lg text-muted-foreground">
|
||||
Don't just take our word for it – hear what our satisfied customers
|
||||
have to say about their FlyTicketTravel experience.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Carousel
|
||||
opts={{
|
||||
align: "start",
|
||||
loop: true,
|
||||
}}
|
||||
class="w-full"
|
||||
>
|
||||
<CarouselContent>
|
||||
{#each testimonials as testimonial}
|
||||
<CarouselItem class="p-2 px-4 md:basis-1/2 lg:basis-1/3">
|
||||
<Card class="h-full">
|
||||
<CardContent class="flex h-full flex-col p-6">
|
||||
<div class="mb-4 flex items-center">
|
||||
{#each Array(5) as _, i}
|
||||
<Icon
|
||||
icon={StarIcon}
|
||||
cls={cn(
|
||||
"h-5 w-5",
|
||||
i < testimonial.rating
|
||||
? "text-brand-500"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class="mb-6 flex-grow italic">
|
||||
{testimonial.message}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center">
|
||||
<img
|
||||
src={testimonial.avatar}
|
||||
alt={testimonial.name}
|
||||
class="mr-4 h-12 w-12 rounded-full object-cover"
|
||||
/>
|
||||
<div>
|
||||
<h4 class="font-semibold">
|
||||
{testimonial.name}
|
||||
</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{testimonial.role}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CarouselItem>
|
||||
{/each}
|
||||
</CarouselContent>
|
||||
<div class="mt-8 flex justify-center gap-0">
|
||||
<CarouselPrevious class="relative" />
|
||||
<CarouselNext class="relative" />
|
||||
</div>
|
||||
</Carousel>
|
||||
</MaxWidthWrapper>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Command as CommandPrimitive } from "bits-ui";
|
||||
import { Command as CommandPrimitive, useId } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
@@ -7,6 +7,7 @@
|
||||
class: className,
|
||||
children,
|
||||
heading,
|
||||
value,
|
||||
...restProps
|
||||
}: CommandPrimitive.GroupProps & {
|
||||
heading?: string;
|
||||
@@ -16,6 +17,7 @@
|
||||
<CommandPrimitive.Group
|
||||
class={cn("text-foreground overflow-hidden p-1", className)}
|
||||
bind:ref
|
||||
value={value ?? heading ?? `----${useId()}`}
|
||||
{...restProps}
|
||||
>
|
||||
{#if heading}
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
<Search class="mr-2 size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
class={cn(
|
||||
"flex h-11 w-full rounded-md border-transparent bg-transparent py-3 text-base outline-none ring-0 placeholder:text-muted-foreground focus:border-transparent focus:outline-none focus:ring-0 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
"placeholder:text-muted-foreground border-none focus:border-none focus:outline-none focus:ring-0 flex h-11 w-full rounded-md bg-transparent py-3 text-base outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
bind:ref
|
||||
{...restProps}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
<CommandPrimitive.Item
|
||||
class={cn(
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
"relative flex cursor-pointer select-none items-center gap-2 rounded-lg px-3 py-2.5 text-sm outline-none transition-colors hover:bg-primary/10 aria-selected:bg-primary/10 aria-selected:text-primary data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
bind:ref
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
<CommandPrimitive.LinkItem
|
||||
class={cn(
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
bind:ref
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Dialog as DialogPrimitive,
|
||||
type WithoutChildrenOrChild,
|
||||
} from "bits-ui";
|
||||
import CloseIcon from "~icons/lucide/x";
|
||||
import { Dialog as DialogPrimitive, type WithoutChildrenOrChild } from "bits-ui";
|
||||
import X from "@lucide/svelte/icons/x";
|
||||
import type { Snippet } from "svelte";
|
||||
import * as Dialog from "./index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import Icon from "$lib/components/atoms/icon.svelte";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
@@ -26,16 +22,16 @@
|
||||
<DialogPrimitive.Content
|
||||
bind:ref
|
||||
class={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] bg-background fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
<DialogPrimitive.Close
|
||||
class="absolute right-4 top-4 grid h-10 w-10 place-items-center rounded-sm opacity-70 ring-offset-background transition-opacity hover:bg-gray-200 hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 active:bg-gray-200 disabled:pointer-events-none"
|
||||
class="ring-offset-background focus:ring-ring absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none"
|
||||
>
|
||||
<Icon icon={CloseIcon} cls="w-auto h-5" />
|
||||
<X class="size-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
|
||||
@@ -25,9 +25,8 @@
|
||||
<input
|
||||
bind:this={ref}
|
||||
class={cn(
|
||||
"flex w-full items-center rounded-md border border-input bg-background/50 px-3 py-2 text-sm shadow-[inset_0_3px_8px_0_rgba(0,0,0,0.15)] file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex w-full items-center rounded-lg border border-input bg-white px-3 py-2 text-sm shadow-sm transition-all file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
inputSizes[inputSize],
|
||||
TRANSITION_COLORS,
|
||||
className,
|
||||
)}
|
||||
bind:value
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { Select as SelectPrimitive, type WithoutChild } from "bits-ui";
|
||||
import ChevronDown from "@lucide/svelte/icons/chevron-down";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { TRANSITION_COLORS } from "$lib/core/constants";
|
||||
|
||||
const inputSizes = {
|
||||
sm: "px-3 py-2 text-sm file:text-sm",
|
||||
@@ -27,13 +26,9 @@
|
||||
<SelectPrimitive.Trigger
|
||||
bind:ref
|
||||
class={cn(
|
||||
// !overrideClasses &&
|
||||
// "flex w-full items-center justify-between rounded-md border border-input bg-white ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground [&>span]:line-clamp-1",
|
||||
|
||||
!overrideClasses &&
|
||||
"flex w-full items-center justify-between rounded-md border border-input bg-background shadow-[inset_0_3px_8px_0_rgba(0,0,0,0.15)] file:border-0 file:bg-transparent file:font-medium placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex w-full items-center justify-between rounded-lg border border-input bg-white shadow-sm transition-all file:border-0 file:bg-transparent file:font-medium placeholder:text-muted-foreground focus-visible:border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground [&>span]:line-clamp-1",
|
||||
inputSizes[inputSize],
|
||||
TRANSITION_COLORS,
|
||||
className,
|
||||
)}
|
||||
{...restProps}
|
||||
|
||||
@@ -21,9 +21,9 @@ export const agentSiteNavMap = {
|
||||
|
||||
export const PUBLIC_SITE_URL = env.PUBLIC_SITE_URL ?? "https://example.com";
|
||||
|
||||
export const COMPANY_DOMAIN = "FlyTicketTravel.com";
|
||||
export const COMPANY_DOMAIN = "example.com";
|
||||
|
||||
export const PROJECT_NAME = "Fly Ticket Travel";
|
||||
export const PROJECT_NAME = "DW";
|
||||
|
||||
export const SITE_LINKS = [
|
||||
{ name: "Home", link: "/", icon: HomeIcon },
|
||||
|
||||
@@ -61,15 +61,15 @@
|
||||
>
|
||||
<Icon icon={CloseIcon} cls="h-5 w-auto" />
|
||||
</button>
|
||||
<div class="mt-8 flex flex-col gap-2 overflow-y-auto">
|
||||
<div class="mt-8 flex flex-col gap-3 overflow-y-auto">
|
||||
{#each checkoutSteps as step, index}
|
||||
<button
|
||||
class={cn(
|
||||
"flex items-center gap-3 rounded-lg border-2 p-3 text-left outline-none transition",
|
||||
"flex items-center gap-3 rounded-lg border-2 p-4 text-left outline-none transition-all",
|
||||
index <= activeStepIndex
|
||||
? "border-brand-200 bg-primary/10 hover:bg-primary/20"
|
||||
? "border-primary/20 bg-primary/5 hover:bg-primary/10"
|
||||
: "border-transparent bg-gray-100 opacity-50",
|
||||
index === activeStepIndex && "border-brand-500",
|
||||
index === activeStepIndex && "border-primary bg-primary/10 shadow-sm",
|
||||
)}
|
||||
disabled={index > activeStepIndex}
|
||||
onclick={() => {
|
||||
@@ -79,15 +79,23 @@
|
||||
>
|
||||
<div
|
||||
class={cn(
|
||||
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
||||
index <= activeStepIndex
|
||||
"flex h-10 w-10 shrink-0 items-center justify-center rounded-full font-medium transition-all",
|
||||
index < activeStepIndex
|
||||
? "bg-green-500 text-white"
|
||||
: index === activeStepIndex
|
||||
? "bg-primary text-white"
|
||||
: "bg-gray-200 text-gray-600",
|
||||
)}
|
||||
>
|
||||
{#if index < activeStepIndex}
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{:else}
|
||||
{index + 1}
|
||||
{/if}
|
||||
</div>
|
||||
<span class="font-medium">
|
||||
<span class="font-medium text-gray-900">
|
||||
{step.label}
|
||||
</span>
|
||||
</button>
|
||||
@@ -97,17 +105,15 @@
|
||||
</Sheet>
|
||||
|
||||
<div class="hidden w-full overflow-x-auto lg:block">
|
||||
<div
|
||||
class="flex w-full min-w-[30rem] items-center justify-between gap-2 overflow-x-auto py-8"
|
||||
>
|
||||
<div class="flex w-full items-center justify-between gap-2 overflow-x-auto">
|
||||
{#each checkoutSteps as step, index}
|
||||
<div class="flex flex-1 items-center gap-2">
|
||||
<div
|
||||
class={cn(
|
||||
"flex items-center justify-center",
|
||||
"flex items-center justify-center transition-all",
|
||||
index <= activeStepIndex
|
||||
? "cursor-pointer"
|
||||
: "cursor-not-allowed opacity-50",
|
||||
: "cursor-not-allowed opacity-40",
|
||||
)}
|
||||
onclick={() => handleStepClick(index, step.id)}
|
||||
onkeydown={(e) => {
|
||||
@@ -120,23 +126,28 @@
|
||||
>
|
||||
<div
|
||||
class={cn(
|
||||
"flex h-10 min-h-10 w-10 min-w-10 items-center justify-center rounded-full border-2 transition-colors",
|
||||
index <= activeStepIndex
|
||||
? "hover:bg-primary-600 border-brand-700 bg-primary text-white/60"
|
||||
: "border-gray-400 bg-gray-100 text-gray-700",
|
||||
index === activeStepIndex
|
||||
? "text-lg font-semibold text-white"
|
||||
: "",
|
||||
"flex h-10 min-h-10 w-10 min-w-10 items-center justify-center rounded-full border-2 font-medium transition-all",
|
||||
index < activeStepIndex
|
||||
? "border-green-500 bg-green-500 text-white"
|
||||
: index === activeStepIndex
|
||||
? "border-primary bg-primary text-white shadow-md"
|
||||
: "border-gray-300 bg-white text-gray-400",
|
||||
)}
|
||||
>
|
||||
{#if index < activeStepIndex}
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{:else}
|
||||
{index + 1}
|
||||
{/if}
|
||||
</div>
|
||||
<span
|
||||
class={cn(
|
||||
"ml-2 hidden w-max text-sm md:block",
|
||||
"ml-3 hidden w-max text-sm transition-all md:block",
|
||||
index <= activeStepIndex
|
||||
? "font-semibold"
|
||||
: "text-gray-800",
|
||||
? "font-semibold text-gray-900"
|
||||
: "text-gray-500",
|
||||
)}
|
||||
>
|
||||
{step.label}
|
||||
@@ -145,10 +156,10 @@
|
||||
{#if index !== checkoutSteps.length - 1}
|
||||
<div
|
||||
class={cn(
|
||||
"h-0.5 w-full min-w-4 flex-1 border-t transition-colors",
|
||||
index <= activeStepIndex
|
||||
? "border-primary"
|
||||
: "border-gray-400",
|
||||
"h-0.5 w-full min-w-4 flex-1 transition-all",
|
||||
index < activeStepIndex
|
||||
? "bg-green-500"
|
||||
: "bg-gray-300",
|
||||
)}
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
@@ -20,33 +20,8 @@ class CheckoutViewModel {
|
||||
|
||||
checkoutSubmitted = $state(false);
|
||||
|
||||
livenessPinger: NodeJS.Timer | undefined = $state(undefined);
|
||||
|
||||
reset() {
|
||||
this.checkoutStep = CheckoutStep.Initial;
|
||||
this.resetPinger();
|
||||
}
|
||||
|
||||
setupPinger() {
|
||||
this.resetPinger();
|
||||
this.livenessPinger = setInterval(() => {
|
||||
this.ping();
|
||||
}, 5_000);
|
||||
}
|
||||
|
||||
resetPinger() {
|
||||
if (this.livenessPinger) {
|
||||
clearInterval(this.livenessPinger);
|
||||
}
|
||||
}
|
||||
|
||||
private async ping() {
|
||||
const api = get(trpcApiStore);
|
||||
if (!api) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// TODO: no need to ping now – REMOVE THIS PINGING LOGIC
|
||||
}
|
||||
|
||||
async checkout() {
|
||||
@@ -88,6 +63,7 @@ class CheckoutViewModel {
|
||||
}
|
||||
|
||||
const pInfoParsed = paymentInfoPayloadModel.safeParse({
|
||||
orderId: -1,
|
||||
method: PaymentMethod.Card,
|
||||
cardDetails: paymentInfoVM.cardDetails,
|
||||
productId: get(productStore)?.id,
|
||||
@@ -134,7 +110,7 @@ class CheckoutViewModel {
|
||||
description: "Redirecting, please wait...",
|
||||
});
|
||||
setTimeout(() => {
|
||||
window.location.href = `/checkout/success?oid=${out.data}`;
|
||||
window.location.href = `/checkout/success?uid=${out.data}`;
|
||||
}, 500);
|
||||
return true;
|
||||
} catch (e) {
|
||||
|
||||
@@ -6,16 +6,12 @@
|
||||
import { checkoutVM } from "$lib/domains/checkout/checkout.vm.svelte";
|
||||
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
|
||||
import { CheckoutStep } from "$lib/domains/order/data/entities";
|
||||
import { cn } from "$lib/utils";
|
||||
import { onMount } from "svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
import RightArrowIcon from "~icons/solar/arrow-right-broken";
|
||||
import CustomerPiiForm from "../customerinfo/view/customer-pii-form.svelte";
|
||||
import { customerInfoVM } from "../customerinfo/view/customerinfo.vm.svelte";
|
||||
|
||||
const cardStyle =
|
||||
"flex w-full flex-col gap-4 rounded-lg bg-white p-4 shadow-lg md:p-8";
|
||||
|
||||
$effect(() => {
|
||||
const personalInfo = customerInfoVM.customerInfo;
|
||||
|
||||
@@ -79,13 +75,13 @@
|
||||
</script>
|
||||
|
||||
{#if customerInfoVM.customerInfo}
|
||||
<div class={cn(cardStyle, "border-2 border-gray-200")}>
|
||||
<Title size="h5">Personal Info</Title>
|
||||
<div class="flex w-full flex-col gap-6">
|
||||
<Title size="h4" weight="medium">Personal Info</Title>
|
||||
<CustomerPiiForm bind:info={customerInfoVM.customerInfo} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="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>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -16,9 +16,6 @@
|
||||
import PaymentForm from "./payment-form.svelte";
|
||||
import { paymentInfoVM } from "./payment.info.vm.svelte";
|
||||
|
||||
const cardStyle =
|
||||
"flex w-full flex-col gap-4 rounded-lg bg-white p-4 shadow-lg md:p-8";
|
||||
|
||||
async function goBack() {
|
||||
if ((await ckFlowVM.onBackToPIIBtnClick()) !== true) {
|
||||
return;
|
||||
@@ -76,23 +73,23 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class={cardStyle}>
|
||||
<Title size="h4">Order Summary</Title>
|
||||
<div class="flex flex-col gap-8">
|
||||
<div class="flex w-full flex-col gap-6">
|
||||
<Title size="h4" weight="medium">Order Summary</Title>
|
||||
<OrderSummary />
|
||||
</div>
|
||||
|
||||
<div class={cardStyle}>
|
||||
<Title size="h4">Billing Details</Title>
|
||||
<div class="flex w-full flex-col gap-6 border-t border-gray-200 pt-8">
|
||||
<Title size="h4" weight="medium">Billing Details</Title>
|
||||
<BillingDetailsForm info={billingDetailsVM.billingDetails} />
|
||||
</div>
|
||||
|
||||
<div class={cardStyle}>
|
||||
<Title size="h4">Payment Details</Title>
|
||||
<div class="flex w-full flex-col gap-6 border-t border-gray-200 pt-8">
|
||||
<Title size="h4" weight="medium">Payment Details</Title>
|
||||
<PaymentForm />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center justify-between gap-4 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">
|
||||
<Icon icon={RightArrowIcon} cls="w-auto h-6 rotate-180" />
|
||||
Back
|
||||
|
||||
@@ -24,73 +24,77 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4 rounded-lg bg-white p-4 drop-shadow-lg md:p-8">
|
||||
<Title size="h4" weight="medium">Payment Summary</Title>
|
||||
<div class="h-0.5 w-full border-t-2 border-gray-200"></div>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Title size="h4" weight="medium">Order Summary</Title>
|
||||
|
||||
{#if !calculating}
|
||||
<!-- Product Information -->
|
||||
{#if $productStore}
|
||||
<div class="flex flex-col gap-2">
|
||||
<Title size="p" weight="medium">{$productStore.title}</Title>
|
||||
<p class="text-sm text-gray-600">{$productStore.description}</p>
|
||||
<div class="flex flex-col gap-3 border-b border-gray-200 pb-6">
|
||||
<Title size="p" weight="medium">
|
||||
{$productStore.title}
|
||||
</Title>
|
||||
<p class="text-sm leading-relaxed text-gray-600">
|
||||
{$productStore.description}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Price Breakdown -->
|
||||
<div class="mt-2 flex flex-col gap-2 border-t pt-4">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span>Base Price</span>
|
||||
<span>{convertAndFormatCurrency(priceDetails.basePrice)}</span>
|
||||
<div class="flex flex-col gap-3 border-b border-gray-200 pb-6">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-600">Base Price</span>
|
||||
<span class="font-medium text-gray-900">
|
||||
{convertAndFormatCurrency(priceDetails.basePrice)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if priceDetails.discountAmount > 0}
|
||||
<div class="flex justify-between text-sm text-green-600">
|
||||
<span>Discount</span>
|
||||
<span
|
||||
>-{convertAndFormatCurrency(
|
||||
priceDetails.discountAmount,
|
||||
)}</span
|
||||
>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-600">Discount</span>
|
||||
<span class="font-medium text-green-600">
|
||||
-{convertAndFormatCurrency(priceDetails.discountAmount)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span>Display Price</span>
|
||||
<span
|
||||
>{convertAndFormatCurrency(
|
||||
priceDetails.displayPrice,
|
||||
)}</span
|
||||
>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-gray-600">Display Price</span>
|
||||
<span class="font-medium text-gray-900">
|
||||
{convertAndFormatCurrency(priceDetails.displayPrice)}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Final Total -->
|
||||
<div class="mt-4 flex flex-col gap-2 border-t pt-4">
|
||||
<div class="flex justify-between font-medium">
|
||||
<Title size="h5" weight="medium"
|
||||
>Total ({$currencyStore.code})</Title
|
||||
>
|
||||
<span class="text-lg"
|
||||
>{convertAndFormatCurrency(priceDetails.orderPrice)}</span
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-base font-semibold text-gray-900">
|
||||
Total ({$currencyStore.code})
|
||||
</span>
|
||||
<span class="text-2xl font-bold text-gray-900">
|
||||
{convertAndFormatCurrency(priceDetails.orderPrice)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Security Badge -->
|
||||
<div class="mt-4 rounded-lg bg-blue-50 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="mt-0.5 h-5 w-5 flex-shrink-0 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<p class="text-xs font-medium text-blue-900">Secure Checkout</p>
|
||||
<p class="mt-1 text-xs text-blue-700">
|
||||
Your payment information is encrypted and secure
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid place-items-center p-8 text-center">
|
||||
<span class="text-gray-600">Calculating...</span>
|
||||
<div class="grid place-items-center py-12">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-primary"></div>
|
||||
<span class="text-sm text-gray-600">Calculating...</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 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>
|
||||
|
||||
@@ -3,35 +3,35 @@
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
|
||||
const initialMessages = [
|
||||
"Processing your payment securely...",
|
||||
"Getting everything ready for you...",
|
||||
"Setting up your transaction...",
|
||||
"Starting the payment process...",
|
||||
"Initiating secure payment...",
|
||||
"Just a moment...",
|
||||
"Please wait...",
|
||||
"Processing...",
|
||||
"Working on it...",
|
||||
"One moment please...",
|
||||
];
|
||||
|
||||
const fiveSecondMessages = [
|
||||
"Almost there! Just finalizing your payment details...",
|
||||
"Just a few more moments while we confirm everything...",
|
||||
"We're processing your payment with care...",
|
||||
"Double-checking all the details...",
|
||||
"Making sure everything is in order...",
|
||||
"Still processing...",
|
||||
"Just a bit longer...",
|
||||
"Almost there...",
|
||||
"This may take a moment...",
|
||||
"Thank you for waiting...",
|
||||
];
|
||||
|
||||
const tenSecondMessages = [
|
||||
"Thank you for your patience. We're making sure everything is perfect...",
|
||||
"Still working on it – thanks for being patient...",
|
||||
"We're double-checking everything to ensure a smooth transaction...",
|
||||
"Nearly there! Just completing the final security checks...",
|
||||
"Your patience is appreciated while we process this securely...",
|
||||
"Thanks for your patience...",
|
||||
"Still working on it...",
|
||||
"We appreciate your patience...",
|
||||
"Processing securely...",
|
||||
"Just a little longer...",
|
||||
];
|
||||
|
||||
const twentySecondMessages = [
|
||||
"Still working on it! Your transaction security is our top priority...",
|
||||
"We appreciate your continued patience while we secure your transaction...",
|
||||
"Taking extra care to process your payment safely...",
|
||||
"Still working diligently to complete your transaction...",
|
||||
"Thank you for waiting – we're ensuring everything is processed correctly...",
|
||||
"Thank you for waiting...",
|
||||
"Still processing – thanks for being patient...",
|
||||
"We're working on it...",
|
||||
"Your patience is appreciated...",
|
||||
"Almost done...",
|
||||
];
|
||||
|
||||
const getRandomMessage = (messages: string[]) => {
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
function gototop() {
|
||||
function goToTop() {
|
||||
window.scrollTo(0, 0);
|
||||
return true;
|
||||
}
|
||||
@@ -57,10 +57,12 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="grid h-full w-full place-items-center gap-4">
|
||||
{#if showOtpVerificationForm}
|
||||
{@const done = gototop()}
|
||||
{@const done = goToTop()}
|
||||
<OtpVerificationSection />
|
||||
{:else}
|
||||
{@const done2 = gototop()}
|
||||
{@const done2 = goToTop()}
|
||||
<PaymentVerificationLoader />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -74,7 +74,7 @@ class ActionRunner {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Your booking has been confirmed", {
|
||||
toast.success("Checkout completed successfully", {
|
||||
description: "Redirecting, please wait...",
|
||||
});
|
||||
|
||||
@@ -160,9 +160,9 @@ class ActionRunner {
|
||||
await ckFlowVM.cleanupFlowInfo();
|
||||
ckFlowVM.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;
|
||||
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);
|
||||
|
||||
poller: NodeJS.Timer | undefined = undefined;
|
||||
pinger: NodeJS.Timer | undefined = undefined;
|
||||
priceFetcher: NodeJS.Timer | undefined = undefined;
|
||||
_flowPoller: NodeJS.Timeout | undefined = undefined;
|
||||
_flowPinger: NodeJS.Timeout | undefined = undefined;
|
||||
|
||||
// Data synchronization control
|
||||
private personalInfoDebounceTimer: NodeJS.Timeout | null = null;
|
||||
@@ -334,27 +333,27 @@ export class CKFlowViewModel {
|
||||
}
|
||||
|
||||
private clearPoller() {
|
||||
if (this.poller) {
|
||||
clearInterval(this.poller);
|
||||
if (this._flowPoller) {
|
||||
clearInterval(this._flowPoller);
|
||||
}
|
||||
}
|
||||
|
||||
private clearPinger() {
|
||||
if (this.pinger) {
|
||||
clearInterval(this.pinger);
|
||||
if (this._flowPinger) {
|
||||
clearInterval(this._flowPinger);
|
||||
}
|
||||
}
|
||||
|
||||
private async startPolling() {
|
||||
this.clearPoller();
|
||||
this.poller = setInterval(() => {
|
||||
this._flowPoller = setInterval(() => {
|
||||
this.refreshFlowInfo();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
private async startPinging() {
|
||||
this.clearPinger();
|
||||
this.pinger = setInterval(() => {
|
||||
this._flowPinger = setInterval(() => {
|
||||
this.pingFlow();
|
||||
}, 30000); // Every 30 seconds
|
||||
}
|
||||
|
||||
@@ -3,14 +3,24 @@
|
||||
import Title from "$lib/components/atoms/title.svelte";
|
||||
import Input from "$lib/components/ui/input/input.svelte";
|
||||
import * as Select from "$lib/components/ui/select";
|
||||
import * as Popover from "$lib/components/ui/popover";
|
||||
import * as Command from "$lib/components/ui/command";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { COUNTRIES_SELECT } from "$lib/core/countries";
|
||||
import { capitalize } from "$lib/core/string.utils";
|
||||
import { PHONE_COUNTRY_CODES } from "@pkg/logic/core/data/phonecc";
|
||||
import { cn } from "$lib/utils";
|
||||
import { tick } from "svelte";
|
||||
import ChevronsUpDownIcon from "~icons/lucide/chevrons-up-down";
|
||||
import CheckIcon from "~icons/lucide/check";
|
||||
import type { CustomerInfoModel } from "../data";
|
||||
import { customerInfoVM } from "./customerinfo.vm.svelte";
|
||||
|
||||
let { info = $bindable() }: { info: CustomerInfoModel } = $props();
|
||||
|
||||
let phoneCodeOpen = $state(false);
|
||||
let phoneCodeTriggerRef = $state<HTMLButtonElement>(null!);
|
||||
|
||||
function onSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
customerInfoVM.validateCustomerInfo(info);
|
||||
@@ -19,6 +29,13 @@
|
||||
function debounceValidate() {
|
||||
customerInfoVM.debounceValidate(info);
|
||||
}
|
||||
|
||||
function closePhoneCodeAndFocus() {
|
||||
phoneCodeOpen = false;
|
||||
tick().then(() => {
|
||||
phoneCodeTriggerRef?.focus();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<form action="#" class="flex w-full flex-col gap-4" onsubmit={onSubmit}>
|
||||
@@ -74,32 +91,54 @@
|
||||
<!-- Phone Number Field -->
|
||||
<LabelWrapper label="Phone Number" error={customerInfoVM.errors.phoneNumber}>
|
||||
<div class="flex gap-2">
|
||||
<Select.Root
|
||||
type="single"
|
||||
required
|
||||
onValueChange={(code) => {
|
||||
info.phoneCountryCode = code;
|
||||
debounceValidate();
|
||||
}}
|
||||
name="phoneCode"
|
||||
<Popover.Root bind:open={phoneCodeOpen}>
|
||||
<Popover.Trigger bind:ref={phoneCodeTriggerRef}>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
{...props}
|
||||
variant="outline"
|
||||
class="w-32 justify-between"
|
||||
role="combobox"
|
||||
aria-expanded={phoneCodeOpen}
|
||||
>
|
||||
<Select.Trigger class="w-28">
|
||||
{#if info.phoneCountryCode}
|
||||
{info.phoneCountryCode}
|
||||
{:else}
|
||||
Select
|
||||
{/if}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each PHONE_COUNTRY_CODES as { country, phoneCode }}
|
||||
<Select.Item value={phoneCode}>
|
||||
<span class="flex items-center gap-2">
|
||||
{phoneCode} ({country})
|
||||
{info.phoneCountryCode || "Select"}
|
||||
<ChevronsUpDownIcon class="h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-[300px] p-0">
|
||||
<Command.Root>
|
||||
<Command.Input placeholder="Search country..." class="h-9" />
|
||||
<Command.List class="command-scrollbar max-h-[300px]">
|
||||
<Command.Empty>No country found.</Command.Empty>
|
||||
<Command.Group>
|
||||
{#each PHONE_COUNTRY_CODES as { country, phoneCode } (country)}
|
||||
<Command.Item
|
||||
value={`${phoneCode} ${country}`}
|
||||
onSelect={() => {
|
||||
info.phoneCountryCode = phoneCode;
|
||||
debounceValidate();
|
||||
closePhoneCodeAndFocus();
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
class={cn(
|
||||
"h-4 w-4 flex-shrink-0",
|
||||
info.phoneCountryCode !== phoneCode &&
|
||||
"text-transparent",
|
||||
)}
|
||||
/>
|
||||
<span class="flex flex-1 items-center justify-between gap-3">
|
||||
<span class="font-semibold text-gray-900">{phoneCode}</span>
|
||||
<span class="text-sm text-gray-600">{country}</span>
|
||||
</span>
|
||||
</Select.Item>
|
||||
</Command.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Command.Group>
|
||||
</Command.List>
|
||||
</Command.Root>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
<Input
|
||||
placeholder="Phone Number"
|
||||
|
||||
@@ -90,7 +90,7 @@ export class OrderRepository {
|
||||
error: getError(
|
||||
{
|
||||
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
|
||||
message: "An error occured while finding booking",
|
||||
message: "An error occured while finding order",
|
||||
userHint: "Please try again later",
|
||||
detail: "An error occured while parsing order",
|
||||
},
|
||||
|
||||
@@ -24,9 +24,7 @@ export const orderRouter = createTRPCRouter({
|
||||
createOrder: publicProcedure
|
||||
.input(createOrderPayloadModel)
|
||||
.mutation(async ({ input }) => {
|
||||
const paymentInfoUC = new PaymentInfoUseCases(
|
||||
new PaymentInfoRepository(db),
|
||||
);
|
||||
const piuc = new PaymentInfoUseCases(new PaymentInfoRepository(db));
|
||||
const orderController = new OrderController(new OrderRepository(db));
|
||||
const customerInfoController = getCustomerInfoController();
|
||||
const productUC = getProductUseCases();
|
||||
@@ -73,7 +71,7 @@ export const orderRouter = createTRPCRouter({
|
||||
let paymentInfoId: number | undefined = undefined;
|
||||
if (input.paymentInfo) {
|
||||
Logger.info("Creating payment information");
|
||||
const paymentRes = await paymentInfoUC.createPaymentInfo(
|
||||
const paymentRes = await piuc.createPaymentInfo(
|
||||
input.paymentInfo,
|
||||
);
|
||||
if (paymentRes.error || !paymentRes.data) {
|
||||
@@ -98,7 +96,7 @@ export const orderRouter = createTRPCRouter({
|
||||
if (orderRes.error || !orderRes.data) {
|
||||
// Cleanup on order creation failure
|
||||
if (paymentInfoId) {
|
||||
await paymentInfoUC.deletePaymentInfo(paymentInfoId);
|
||||
await piuc.deletePaymentInfo(paymentInfoId);
|
||||
}
|
||||
await customerInfoController.deleteCustomerInfo(customerInfoId);
|
||||
return { error: orderRes.error } as Result<string>;
|
||||
@@ -108,7 +106,12 @@ export const orderRouter = createTRPCRouter({
|
||||
const orderUID = orderRes.data.uid;
|
||||
Logger.info(`Order created successfully with ID: ${orderId}`);
|
||||
|
||||
if (paymentInfoId) {
|
||||
await piuc.updatePaymentInfoOrderId(paymentInfoId, orderId);
|
||||
}
|
||||
|
||||
// Update checkout flow state if flowId is provided
|
||||
//
|
||||
if (input.flowId) {
|
||||
Logger.info(
|
||||
`Updating checkout flow state for flow ${input.flowId}`,
|
||||
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
@@ -22,8 +22,8 @@ export class PaymentInfoRepository {
|
||||
cardholderName: data.cardDetails.cardholderName,
|
||||
expiry: data.cardDetails.expiry,
|
||||
cvv: data.cardDetails.cvv,
|
||||
flightTicketInfoId: data.flightTicketInfoId,
|
||||
|
||||
productId: data.productId,
|
||||
orderId: data.orderId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
@@ -45,6 +45,20 @@ export class PaymentInfoRepository {
|
||||
return { data: parsed.data };
|
||||
}
|
||||
|
||||
async updatePaymentInfoOrderId(
|
||||
id: number,
|
||||
oid: number,
|
||||
): Promise<Result<number>> {
|
||||
Logger.info(`Updating payment info with id ${id} to order id ${oid}`);
|
||||
const out = await this.db
|
||||
.update(paymentInfo)
|
||||
.set({ orderId: oid })
|
||||
.where(eq(paymentInfo.id, id))
|
||||
.execute();
|
||||
Logger.debug(out);
|
||||
return { data: id };
|
||||
}
|
||||
|
||||
async deletePaymentInfo(id: number): Promise<Result<boolean>> {
|
||||
Logger.info(`Deleting payment info with id ${id}`);
|
||||
const out = await this.db
|
||||
|
||||
@@ -16,6 +16,10 @@ export class PaymentInfoUseCases {
|
||||
return this.repo.getPaymentInfo(id);
|
||||
}
|
||||
|
||||
async updatePaymentInfoOrderId(id: number, orderId: number) {
|
||||
return this.repo.updatePaymentInfoOrderId(id, orderId);
|
||||
}
|
||||
|
||||
async deletePaymentInfo(id: number) {
|
||||
return this.repo.deletePaymentInfo(id);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
<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 Icon from "$lib/components/atoms/icon.svelte";
|
||||
import UpIcon from "~icons/material-symbols/keyboard-double-arrow-up-rounded";
|
||||
import { svTrpcApiStore, trpcApiStore } from "$lib/stores/api";
|
||||
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 {
|
||||
children,
|
||||
@@ -23,18 +19,6 @@
|
||||
|
||||
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(() => {
|
||||
trpcApiStore.set(trpcRaw());
|
||||
|
||||
@@ -42,32 +26,11 @@
|
||||
setTimeout(() => {
|
||||
currencyVM.getCurrencies();
|
||||
}, 500);
|
||||
|
||||
// Add scroll event listener
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<Navbar invert={false} />
|
||||
|
||||
<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">
|
||||
<div class="min-h-screen w-full">
|
||||
<main class="flex w-full flex-col">
|
||||
{@render children?.()}
|
||||
</main>
|
||||
</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}
|
||||
|
||||
@@ -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>
|
||||
@@ -2,6 +2,6 @@ import { getProductUseCases } from "$lib/domains/product/usecases";
|
||||
import type { PageServerLoad } from "./$types";
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const pid = params.pageid;
|
||||
return await getProductUseCases().getProductByLinkId(pid);
|
||||
const plid = params.plid;
|
||||
return await getProductUseCases().getProductByLinkId(plid);
|
||||
};
|
||||
107
apps/frontend/src/routes/(main)/[plid]/+page.svelte
Normal file
107
apps/frontend/src/routes/(main)/[plid]/+page.svelte
Normal 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>
|
||||
@@ -32,7 +32,6 @@
|
||||
}
|
||||
productStore.set(pageData.data);
|
||||
checkoutVM.loading = false;
|
||||
checkoutVM.setupPinger();
|
||||
|
||||
setTimeout(async () => {
|
||||
await ckFlowVM.initFlow();
|
||||
@@ -44,25 +43,55 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="grid h-full w-full place-items-center">
|
||||
<MaxWidthWrapper cls="p-4 md:p-8 lg:p-10 3xl:p-0">
|
||||
<div class="min-h-screen w-full bg-gradient-to-br from-gray-50 to-gray-100">
|
||||
{#if !pageData.data || !!pageData.error}
|
||||
<div class="grid h-full min-h-screen w-full place-items-center">
|
||||
<div class="flex flex-col items-center justify-center gap-4">
|
||||
<Icon icon={SearchIcon} cls="w-12 h-12" />
|
||||
<Title size="h4" color="black">Product not found</Title>
|
||||
<p>Something went wrong, please try again or contact us</p>
|
||||
<!-- Error State -->
|
||||
<div class="flex min-h-screen w-full items-center justify-center p-4">
|
||||
<div
|
||||
class="animate-fade-in w-full max-w-md 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={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>
|
||||
{:else if checkoutVM.checkoutStep === CheckoutStep.Confirmation}
|
||||
<div class="grid w-full place-items-center p-4 py-32">
|
||||
<!-- Confirmation State -->
|
||||
<div class="flex min-h-screen w-full items-center justify-center p-4">
|
||||
<MaxWidthWrapper cls="w-full">
|
||||
<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 class="flex w-full flex-col gap-8 lg:flex-row">
|
||||
<div class="flex w-full flex-col">
|
||||
<div class="flex w-full flex-col gap-12">
|
||||
</div>
|
||||
|
||||
<!-- Main Checkout Layout -->
|
||||
<div class="flex flex-col gap-6 lg:flex-row lg:gap-8">
|
||||
<!-- Left Column: Forms -->
|
||||
<div class="flex-1">
|
||||
<div
|
||||
class="rounded-2xl bg-white p-6 shadow-lg md:p-8 lg:p-10"
|
||||
>
|
||||
{#if checkoutVM.loading}
|
||||
<CheckoutLoadingSection />
|
||||
{:else if checkoutVM.checkoutStep === CheckoutStep.Initial}
|
||||
@@ -74,16 +103,33 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid w-full place-items-center lg:max-w-lg lg:place-items-start"
|
||||
>
|
||||
<div
|
||||
class="flex w-full flex-col gap-8 pt-8 md:max-w-md lg:max-w-full lg:pt-0"
|
||||
>
|
||||
|
||||
<!-- Right Column: Summary (Sticky on larger screens) -->
|
||||
<div class="lg:w-[400px] xl:w-[440px]">
|
||||
<div class="lg:sticky lg:top-8">
|
||||
<div class="rounded-2xl bg-white p-6 shadow-lg md:p-8">
|
||||
<PaymentSummary />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</MaxWidthWrapper>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</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>
|
||||
|
||||
@@ -1,41 +1,98 @@
|
||||
<script lang="ts">
|
||||
import Icon from "$lib/components/atoms/icon.svelte";
|
||||
import Title from "$lib/components/atoms/title.svelte";
|
||||
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
|
||||
import CheckIcon from "~icons/ic/round-check";
|
||||
import CheckIcon from "~icons/heroicons/check-circle-20-solid";
|
||||
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
|
||||
</script>
|
||||
|
||||
<div class="grid min-h-[80vh] w-full place-items-center px-4 sm:px-6">
|
||||
<MaxWidthWrapper
|
||||
cls="flex flex-col gap-6 sm:gap-8 items-center justify-center"
|
||||
>
|
||||
<div
|
||||
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="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 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">
|
||||
<div class="w-full max-w-lg">
|
||||
<div class="animate-scale-in rounded-3xl bg-white p-8 shadow-2xl md:p-12">
|
||||
<!-- Success Icon with Animation -->
|
||||
<div class="mb-8 flex justify-center">
|
||||
<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>
|
||||
|
||||
<Title size="h3" center weight="medium">Booking confirmed</Title>
|
||||
<!-- Title -->
|
||||
<Title size="h3" weight="medium" center>Order Confirmed!</Title>
|
||||
|
||||
<p
|
||||
class="w-full max-w-prose text-center text-sm text-gray-600 sm:text-base"
|
||||
>
|
||||
Thank you for booking your flight! Your order has been placed
|
||||
successfully. You will receive a confirmation email shortly.
|
||||
<!-- Description -->
|
||||
<p class="mb-6 mt-4 text-center text-base text-gray-600">
|
||||
Thank you for your order! Your payment has been processed successfully.
|
||||
</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.
|
||||
|
||||
<!-- 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>
|
||||
</MaxWidthWrapper>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
import { page } from "$app/state";
|
||||
import Icon from "$lib/components/atoms/icon.svelte";
|
||||
import Title from "$lib/components/atoms/title.svelte";
|
||||
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.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);
|
||||
|
||||
@@ -18,25 +19,90 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="grid min-h-[80vh] w-full place-items-center">
|
||||
<MaxWidthWrapper
|
||||
cls="flex flex-col gap-8 items-center justify-center p-4 md:p-8"
|
||||
>
|
||||
<div
|
||||
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="rounded-full bg-rose-100 p-2 text-rose-600 drop-shadow-lg"
|
||||
>
|
||||
<Icon icon={CloseIcon} cls="w-12 h-12" />
|
||||
<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">
|
||||
<div class="w-full max-w-lg">
|
||||
<div class="animate-scale-in rounded-3xl bg-white p-8 shadow-2xl md:p-12">
|
||||
<!-- Error Icon with Animation -->
|
||||
<div class="mb-8 flex justify-center">
|
||||
<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>
|
||||
|
||||
<Title size="h3" center weight="medium">Session Terminated</Title>
|
||||
<p class="w-full max-w-prose text-center text-gray-600">
|
||||
Unfortunately, your session has been terminated due to inactivity
|
||||
or something went wrong, please contact us to walk through the
|
||||
steps again.
|
||||
<!-- Title -->
|
||||
<Title size="h3" weight="medium" center>Session Terminated</Title>
|
||||
|
||||
<!-- Description -->
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
</MaxWidthWrapper>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import "../app.css";
|
||||
import { Toaster } from "$lib/components/ui/sonner";
|
||||
import { browser } from "$app/environment";
|
||||
import { Toaster } from "$lib/components/ui/sonner";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/svelte-query";
|
||||
import "../app.css";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
@@ -12,16 +12,13 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>FlyTicketTravel</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Book a flight to your dream destination at an unbelieavably low price"
|
||||
/>
|
||||
<title>Checkout</title>
|
||||
<meta name="description" content="Checkout" />
|
||||
|
||||
<script
|
||||
<!-- <script
|
||||
async
|
||||
src="https://www.googletagmanager.com/gtag/js?id=AW-17085207253"
|
||||
></script>
|
||||
></script> -->
|
||||
|
||||
<script>
|
||||
// window.dataLayer = window.dataLayer || [];
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 652 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
apps/frontend/static/fonts/fredoka-variable.ttf
Normal file
BIN
apps/frontend/static/fonts/fredoka-variable.ttf
Normal file
Binary file not shown.
@@ -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 twForms from "@tailwindcss/forms";
|
||||
import twTypography from "@tailwindcss/typography";
|
||||
import type { Config } from "tailwindcss";
|
||||
import twAnimation from "tailwindcss-animate";
|
||||
import { fontFamily } from "tailwindcss/defaultTheme";
|
||||
|
||||
const config: Config = {
|
||||
darkMode: ["class"],
|
||||
@@ -93,7 +93,7 @@ const config: Config = {
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [...fontFamily.sans],
|
||||
sans: ["Fredoka", ...fontFamily.sans],
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
|
||||
6
bun.lock
6
bun.lock
@@ -107,12 +107,12 @@
|
||||
"devDependencies": {
|
||||
"@iconify/json": "^2.2.275",
|
||||
"@internationalized/date": "^3.6.0",
|
||||
"@lucide/svelte": "^0.503.0",
|
||||
"@lucide/svelte": "^0.482.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@types/node": "^22.9.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"bits-ui": "^1.4.1",
|
||||
"bits-ui": "^1.4.7",
|
||||
"clsx": "^2.1.1",
|
||||
"embla-carousel-svelte": "^8.6.0",
|
||||
"formsnap": "^2.0.0",
|
||||
@@ -1636,6 +1636,8 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@finom/zod-to-json-schema/zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
FROM node:24.10.0 AS base
|
||||
|
||||
RUN npm i -g bun
|
||||
RUN npm i -g bun@1.2.23
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
FROM node:24.10.0 AS base
|
||||
|
||||
RUN npm i -g bun
|
||||
RUN npm i -g bun@1.2.23
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json bun.lockb turbo.json ./
|
||||
COPY package.json bun.lock turbo.json ./
|
||||
|
||||
COPY apps/frontend/package.json ./apps/frontend/package.json
|
||||
|
||||
|
||||
@@ -265,9 +265,9 @@ PnrConfirmationEmail.PreviewProps = {
|
||||
departureDate: "2023-12-15",
|
||||
returnDate: "2023-12-22",
|
||||
passengerName: "John Smith",
|
||||
baseUrl: "https://flytickettravel.com",
|
||||
logoPath: "https://flytickettravel.com/assets/logos/logo-main.svg",
|
||||
companyName: "FlyTicketTravel",
|
||||
baseUrl: "https://checkout.com",
|
||||
logoPath: "https://checkout.com/assets/logos/logo-main.svg",
|
||||
companyName: "checkout",
|
||||
};
|
||||
|
||||
export default PnrConfirmationEmail;
|
||||
|
||||
@@ -924,11 +924,6 @@ export const PHONE_COUNTRY_CODES = [
|
||||
country: "Saint Martin",
|
||||
phoneCode: "+590",
|
||||
},
|
||||
{
|
||||
countryCode: "sx",
|
||||
country: "Saint Martin",
|
||||
phoneCode: "+1721",
|
||||
},
|
||||
{
|
||||
countryCode: "pm",
|
||||
country: "Saint Pierre and Miquelon",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { ERROR_CODES, type Result } from "@pkg/result";
|
||||
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(
|
||||
payload: CreateCustomerInfoPayload,
|
||||
): Promise<Result<number>> {
|
||||
|
||||
@@ -20,6 +20,10 @@ export class CustomerInfoUseCases {
|
||||
return this.repo.getCustomerInfoById(id);
|
||||
}
|
||||
|
||||
async getCustomerInfoByOrderId(oid: number) {
|
||||
return this.repo.getCustomerInfoByOrderId(oid);
|
||||
}
|
||||
|
||||
async createCustomerInfo(payload: CreateCustomerInfoPayload) {
|
||||
return this.repo.createCustomerInfo(payload);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ import { z } from "zod";
|
||||
import { paginationModel } from "../../../core/pagination.utils";
|
||||
import { encodeCursor } from "../../../core/string.utils";
|
||||
import { customerInfoModel } from "../../customerinfo/data";
|
||||
import { paymentInfoPayloadModel } from "../../paymentinfo/data/entities";
|
||||
import {
|
||||
paymentInfoModel,
|
||||
paymentInfoPayloadModel,
|
||||
} from "../../paymentinfo/data/entities";
|
||||
import { productModel } from "../../product/data";
|
||||
|
||||
export enum OrderCreationStep {
|
||||
@@ -19,7 +22,7 @@ export enum OrderStatus {
|
||||
}
|
||||
|
||||
export const orderPriceDetailsModel = z.object({
|
||||
currency: z.string(),
|
||||
currency: z.string().optional(),
|
||||
discountAmount: z.coerce.number().min(0),
|
||||
basePrice: z.coerce.number().min(0),
|
||||
displayPrice: z.coerce.number().min(0),
|
||||
@@ -72,6 +75,7 @@ export const fullOrderModel = orderModel.merge(
|
||||
z.object({
|
||||
product: productModel,
|
||||
customerInfo: customerInfoModel.optional().nullable(),
|
||||
paymentInfo: paymentInfoModel.optional().nullable(),
|
||||
}),
|
||||
);
|
||||
export type FullOrderModel = z.infer<typeof fullOrderModel>;
|
||||
@@ -114,7 +118,7 @@ export const newOrderModel = orderModel
|
||||
paymentInfoId: true,
|
||||
})
|
||||
.extend({
|
||||
currency: z.string().default("USD"),
|
||||
currency: z.string().optional().default("USD"),
|
||||
customerInfoId: z.number().optional(),
|
||||
paymentInfoId: z.number().optional(),
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
@@ -87,8 +87,8 @@ export const paymentInfoModel = cardInfoModel.merge(
|
||||
id: z.number().int(),
|
||||
productId: z.number().int(),
|
||||
orderId: z.number().int(),
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
createdAt: z.coerce.string(),
|
||||
updatedAt: z.coerce.string(),
|
||||
}),
|
||||
);
|
||||
export type PaymentInfo = z.infer<typeof paymentInfoModel>;
|
||||
|
||||
Reference in New Issue
Block a user