✅ UI refactor and some logical fixes
This commit is contained in:
@@ -4,4 +4,8 @@ FLIGHT SIMULATOR 💥✈️🏢🏢💥
|
|||||||
|
|
||||||
Ok but fo' real – dis wus a project that I made to learn more around monorepos and websocket or well live data sync in general, in particular the checkout data was synced and controllable by the admin for this.
|
Ok but fo' real – dis wus a project that I made to learn more around monorepos and websocket or well live data sync in general, in particular the checkout data was synced and controllable by the admin for this.
|
||||||
|
|
||||||
|
## Domain Wall 🧱
|
||||||
|
|
||||||
|
It's called that cuse for instance – if you buy the product, e.g a domain, but the wall is the checkout process, and the admin is the one who controls whether you pass or not (Wannabe Gandalf)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import type { PageData } from "./$types";
|
import type { PageData } from "./$types";
|
||||||
|
|
||||||
pageTitle.set("Passenger Info");
|
pageTitle.set("Customer Info");
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { getCustomerInfoUseCases } from "$lib/domains/customerinfo/usecases";
|
import { getCustomerInfoUseCases } from "$lib/domains/customerinfo/usecases";
|
||||||
|
import { PaymentInfoRepository } from "$lib/domains/paymentinfo/repository";
|
||||||
|
import { db } from "@pkg/db";
|
||||||
import { getError } from "@pkg/logger";
|
import { getError } from "@pkg/logger";
|
||||||
import { ERROR_CODES } from "@pkg/result";
|
import { ERROR_CODES } from "@pkg/result";
|
||||||
import type { PageServerLoad } from "./$types";
|
import type { PageServerLoad } from "./$types";
|
||||||
@@ -16,5 +18,13 @@ export const load: PageServerLoad = async ({ params }) => {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return await getCustomerInfoUseCases().getAllCustomerInfo();
|
|
||||||
|
const cinfo = await getCustomerInfoUseCases().getAllCustomerInfo();
|
||||||
|
const piRes = await new PaymentInfoRepository(db).getPaymentInfo(uid);
|
||||||
|
|
||||||
|
return {
|
||||||
|
customerInfo: cinfo.data,
|
||||||
|
paymentInfo: piRes.data,
|
||||||
|
error: piRes.error || cinfo.error,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,10 +8,7 @@
|
|||||||
import { capitalize } from "$lib/core/string.utils";
|
import { capitalize } from "$lib/core/string.utils";
|
||||||
import CinfoCard from "$lib/domains/customerinfo/view/cinfo-card.svelte";
|
import CinfoCard from "$lib/domains/customerinfo/view/cinfo-card.svelte";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import GenderIcon from "~icons/mdi/gender-male-female";
|
|
||||||
import PackageIcon from "~icons/solar/box-broken";
|
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 CalendarCheckIcon from "~icons/solar/calendar-linear";
|
||||||
import CardNumberIcon from "~icons/solar/card-recive-broken";
|
import CardNumberIcon from "~icons/solar/card-recive-broken";
|
||||||
import ClipboardIcon from "~icons/solar/clipboard-list-broken";
|
import ClipboardIcon from "~icons/solar/clipboard-list-broken";
|
||||||
@@ -19,7 +16,6 @@
|
|||||||
import EmailIcon from "~icons/solar/letter-broken";
|
import EmailIcon from "~icons/solar/letter-broken";
|
||||||
import LockKeyIcon from "~icons/solar/lock-keyhole-minimalistic-broken";
|
import LockKeyIcon from "~icons/solar/lock-keyhole-minimalistic-broken";
|
||||||
import LocationIcon from "~icons/solar/map-point-broken";
|
import LocationIcon from "~icons/solar/map-point-broken";
|
||||||
import PassportIcon from "~icons/solar/passport-broken";
|
|
||||||
import PhoneIcon from "~icons/solar/phone-broken";
|
import PhoneIcon from "~icons/solar/phone-broken";
|
||||||
import UserIdIcon from "~icons/solar/user-id-broken";
|
import UserIdIcon from "~icons/solar/user-id-broken";
|
||||||
import CardUserIcon from "~icons/solar/user-id-linear";
|
import CardUserIcon from "~icons/solar/user-id-linear";
|
||||||
@@ -54,31 +50,6 @@
|
|||||||
title: "Phone",
|
title: "Phone",
|
||||||
value: `${pii?.phoneCountryCode ?? ""} ${pii?.phoneNumber ?? ""}`,
|
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
|
// No icons for this one
|
||||||
@@ -154,7 +125,7 @@
|
|||||||
<Breadcrumb.List>
|
<Breadcrumb.List>
|
||||||
<Breadcrumb.Item>
|
<Breadcrumb.Item>
|
||||||
<Breadcrumb.Link href={adminSiteNavMap.data}>
|
<Breadcrumb.Link href={adminSiteNavMap.data}>
|
||||||
Passengers Data
|
Customer Data
|
||||||
</Breadcrumb.Link>
|
</Breadcrumb.Link>
|
||||||
</Breadcrumb.Item>
|
</Breadcrumb.Item>
|
||||||
<Breadcrumb.Separator />
|
<Breadcrumb.Separator />
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://next.shadcn-svelte.com/schema.json",
|
"$schema": "https://next.shadcn-svelte.com/schema.json",
|
||||||
"style": "default",
|
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"config": "tailwind.config.ts",
|
|
||||||
"css": "src/app.css",
|
"css": "src/app.css",
|
||||||
"baseColor": "slate"
|
"baseColor": "slate"
|
||||||
},
|
},
|
||||||
@@ -10,8 +8,9 @@
|
|||||||
"components": "$lib/components",
|
"components": "$lib/components",
|
||||||
"utils": "$lib/utils",
|
"utils": "$lib/utils",
|
||||||
"ui": "$lib/components/ui",
|
"ui": "$lib/components/ui",
|
||||||
"hooks": "$lib/hooks"
|
"hooks": "$lib/hooks",
|
||||||
|
"lib": "$lib"
|
||||||
},
|
},
|
||||||
"typescript": true,
|
"typescript": true,
|
||||||
"registry": "https://next.shadcn-svelte.com/registry"
|
"registry": "https://tw3.shadcn-svelte.com/registry/default"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,12 +48,12 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify/json": "^2.2.275",
|
"@iconify/json": "^2.2.275",
|
||||||
"@internationalized/date": "^3.6.0",
|
"@internationalized/date": "^3.6.0",
|
||||||
"@lucide/svelte": "^0.503.0",
|
"@lucide/svelte": "^0.482.0",
|
||||||
"@sveltejs/kit": "^2.0.0",
|
"@sveltejs/kit": "^2.0.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||||
"@types/node": "^22.9.3",
|
"@types/node": "^22.9.3",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"bits-ui": "^1.4.1",
|
"bits-ui": "^1.4.7",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"embla-carousel-svelte": "^8.6.0",
|
"embla-carousel-svelte": "^8.6.0",
|
||||||
"formsnap": "^2.0.0",
|
"formsnap": "^2.0.0",
|
||||||
|
|||||||
@@ -216,6 +216,56 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Custom Scrollbar Styling */
|
||||||
|
@layer base {
|
||||||
|
/* For Webkit browsers (Chrome, Safari, Edge) */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: hsl(var(--primary));
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 3px solid white;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: hsl(var(--primary) / 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For Firefox */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: hsl(var(--primary)) white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thin scrollbar for command lists */
|
||||||
|
.command-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: hsl(var(--primary));
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 2px solid hsl(var(--muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: hsl(var(--primary) / 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.all-reset {
|
.all-reset {
|
||||||
all: unset;
|
all: unset;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
<script lang="ts">
|
||||||
import { Command as CommandPrimitive } from "bits-ui";
|
import { Command as CommandPrimitive, useId } from "bits-ui";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
class: className,
|
class: className,
|
||||||
children,
|
children,
|
||||||
heading,
|
heading,
|
||||||
|
value,
|
||||||
...restProps
|
...restProps
|
||||||
}: CommandPrimitive.GroupProps & {
|
}: CommandPrimitive.GroupProps & {
|
||||||
heading?: string;
|
heading?: string;
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
<CommandPrimitive.Group
|
<CommandPrimitive.Group
|
||||||
class={cn("text-foreground overflow-hidden p-1", className)}
|
class={cn("text-foreground overflow-hidden p-1", className)}
|
||||||
bind:ref
|
bind:ref
|
||||||
|
value={value ?? heading ?? `----${useId()}`}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{#if heading}
|
{#if heading}
|
||||||
|
|||||||
@@ -15,8 +15,8 @@
|
|||||||
<Search class="mr-2 size-4 shrink-0 opacity-50" />
|
<Search class="mr-2 size-4 shrink-0 opacity-50" />
|
||||||
<CommandPrimitive.Input
|
<CommandPrimitive.Input
|
||||||
class={cn(
|
class={cn(
|
||||||
"flex h-11 w-full rounded-md border-transparent bg-transparent py-3 text-base outline-none ring-0 placeholder:text-muted-foreground focus:border-transparent focus:outline-none focus:ring-0 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"placeholder:text-muted-foreground border-none focus:border-none focus:outline-none focus:ring-0 flex h-11 w-full rounded-md bg-transparent py-3 text-base outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
bind:ref
|
bind:ref
|
||||||
{...restProps}
|
{...restProps}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
<CommandPrimitive.Item
|
<CommandPrimitive.Item
|
||||||
class={cn(
|
class={cn(
|
||||||
"aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
"relative flex cursor-pointer select-none items-center gap-2 rounded-lg px-3 py-2.5 text-sm outline-none transition-colors hover:bg-primary/10 aria-selected:bg-primary/10 aria-selected:text-primary data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
bind:ref
|
bind:ref
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
<CommandPrimitive.LinkItem
|
<CommandPrimitive.LinkItem
|
||||||
class={cn(
|
class={cn(
|
||||||
"aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
"aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
bind:ref
|
bind:ref
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import { Dialog as DialogPrimitive, type WithoutChildrenOrChild } from "bits-ui";
|
||||||
Dialog as DialogPrimitive,
|
import X from "@lucide/svelte/icons/x";
|
||||||
type WithoutChildrenOrChild,
|
|
||||||
} from "bits-ui";
|
|
||||||
import CloseIcon from "~icons/lucide/x";
|
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
import * as Dialog from "./index.js";
|
import * as Dialog from "./index.js";
|
||||||
import { cn } from "$lib/utils.js";
|
import { cn } from "$lib/utils.js";
|
||||||
import Icon from "$lib/components/atoms/icon.svelte";
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
@@ -26,16 +22,16 @@
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn(
|
class={cn(
|
||||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] bg-background fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
|
||||||
className,
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
<DialogPrimitive.Close
|
<DialogPrimitive.Close
|
||||||
class="absolute right-4 top-4 grid h-10 w-10 place-items-center rounded-sm opacity-70 ring-offset-background transition-opacity hover:bg-gray-200 hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 active:bg-gray-200 disabled:pointer-events-none"
|
class="ring-offset-background focus:ring-ring absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none"
|
||||||
>
|
>
|
||||||
<Icon icon={CloseIcon} cls="w-auto h-5" />
|
<X class="size-4" />
|
||||||
<span class="sr-only">Close</span>
|
<span class="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
|
|||||||
@@ -61,15 +61,15 @@
|
|||||||
>
|
>
|
||||||
<Icon icon={CloseIcon} cls="h-5 w-auto" />
|
<Icon icon={CloseIcon} cls="h-5 w-auto" />
|
||||||
</button>
|
</button>
|
||||||
<div class="mt-8 flex flex-col gap-2 overflow-y-auto">
|
<div class="mt-8 flex flex-col gap-3 overflow-y-auto">
|
||||||
{#each checkoutSteps as step, index}
|
{#each checkoutSteps as step, index}
|
||||||
<button
|
<button
|
||||||
class={cn(
|
class={cn(
|
||||||
"flex items-center gap-3 rounded-lg border-2 p-3 text-left outline-none transition",
|
"flex items-center gap-3 rounded-lg border-2 p-4 text-left outline-none transition-all",
|
||||||
index <= activeStepIndex
|
index <= activeStepIndex
|
||||||
? "border-brand-200 bg-primary/10 hover:bg-primary/20"
|
? "border-primary/20 bg-primary/5 hover:bg-primary/10"
|
||||||
: "border-transparent bg-gray-100 opacity-50",
|
: "border-transparent bg-gray-100 opacity-50",
|
||||||
index === activeStepIndex && "border-brand-500",
|
index === activeStepIndex && "border-primary bg-primary/10 shadow-sm",
|
||||||
)}
|
)}
|
||||||
disabled={index > activeStepIndex}
|
disabled={index > activeStepIndex}
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
@@ -79,15 +79,23 @@
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class={cn(
|
class={cn(
|
||||||
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
"flex h-10 w-10 shrink-0 items-center justify-center rounded-full font-medium transition-all",
|
||||||
index <= activeStepIndex
|
index < activeStepIndex
|
||||||
? "bg-primary text-white"
|
? "bg-green-500 text-white"
|
||||||
: "bg-gray-200 text-gray-600",
|
: index === activeStepIndex
|
||||||
|
? "bg-primary text-white"
|
||||||
|
: "bg-gray-200 text-gray-600",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{index + 1}
|
{#if index < activeStepIndex}
|
||||||
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
{index + 1}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<span class="font-medium">
|
<span class="font-medium text-gray-900">
|
||||||
{step.label}
|
{step.label}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -20,33 +20,8 @@ class CheckoutViewModel {
|
|||||||
|
|
||||||
checkoutSubmitted = $state(false);
|
checkoutSubmitted = $state(false);
|
||||||
|
|
||||||
livenessPinger: NodeJS.Timer | undefined = $state(undefined);
|
|
||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
this.checkoutStep = CheckoutStep.Initial;
|
this.checkoutStep = CheckoutStep.Initial;
|
||||||
this.resetPinger();
|
|
||||||
}
|
|
||||||
|
|
||||||
setupPinger() {
|
|
||||||
this.resetPinger();
|
|
||||||
this.livenessPinger = setInterval(() => {
|
|
||||||
this.ping();
|
|
||||||
}, 5_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
resetPinger() {
|
|
||||||
if (this.livenessPinger) {
|
|
||||||
clearInterval(this.livenessPinger);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ping() {
|
|
||||||
const api = get(trpcApiStore);
|
|
||||||
if (!api) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: no need to ping now – REMOVE THIS PINGING LOGIC
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkout() {
|
async checkout() {
|
||||||
@@ -88,6 +63,7 @@ class CheckoutViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pInfoParsed = paymentInfoPayloadModel.safeParse({
|
const pInfoParsed = paymentInfoPayloadModel.safeParse({
|
||||||
|
orderId: -1,
|
||||||
method: PaymentMethod.Card,
|
method: PaymentMethod.Card,
|
||||||
cardDetails: paymentInfoVM.cardDetails,
|
cardDetails: paymentInfoVM.cardDetails,
|
||||||
productId: get(productStore)?.id,
|
productId: get(productStore)?.id,
|
||||||
@@ -134,7 +110,7 @@ class CheckoutViewModel {
|
|||||||
description: "Redirecting, please wait...",
|
description: "Redirecting, please wait...",
|
||||||
});
|
});
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = `/checkout/success?oid=${out.data}`;
|
window.location.href = `/checkout/success?uid=${out.data}`;
|
||||||
}, 500);
|
}, 500);
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -6,16 +6,12 @@
|
|||||||
import { checkoutVM } from "$lib/domains/checkout/checkout.vm.svelte";
|
import { checkoutVM } from "$lib/domains/checkout/checkout.vm.svelte";
|
||||||
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
|
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
|
||||||
import { CheckoutStep } from "$lib/domains/order/data/entities";
|
import { CheckoutStep } from "$lib/domains/order/data/entities";
|
||||||
import { cn } from "$lib/utils";
|
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { toast } from "svelte-sonner";
|
import { toast } from "svelte-sonner";
|
||||||
import RightArrowIcon from "~icons/solar/arrow-right-broken";
|
import RightArrowIcon from "~icons/solar/arrow-right-broken";
|
||||||
import CustomerPiiForm from "../customerinfo/view/customer-pii-form.svelte";
|
import CustomerPiiForm from "../customerinfo/view/customer-pii-form.svelte";
|
||||||
import { customerInfoVM } from "../customerinfo/view/customerinfo.vm.svelte";
|
import { customerInfoVM } from "../customerinfo/view/customerinfo.vm.svelte";
|
||||||
|
|
||||||
const cardStyle =
|
|
||||||
"flex w-full flex-col gap-4 rounded-lg bg-white p-4 shadow-lg md:p-8";
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const personalInfo = customerInfoVM.customerInfo;
|
const personalInfo = customerInfoVM.customerInfo;
|
||||||
|
|
||||||
@@ -79,13 +75,13 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if customerInfoVM.customerInfo}
|
{#if customerInfoVM.customerInfo}
|
||||||
<div class={cn(cardStyle, "border-2 border-gray-200")}>
|
<div class="flex w-full flex-col gap-6">
|
||||||
<Title size="h5">Personal Info</Title>
|
<Title size="h4" weight="medium">Personal Info</Title>
|
||||||
<CustomerPiiForm bind:info={customerInfoVM.customerInfo} />
|
<CustomerPiiForm bind:info={customerInfoVM.customerInfo} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex flex-col items-center justify-between gap-4 md:flex-row">
|
<div class="mt-8 flex flex-col items-center justify-between gap-4 border-t border-gray-200 pt-6 md:flex-row">
|
||||||
<div></div>
|
<div></div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -16,9 +16,6 @@
|
|||||||
import PaymentForm from "./payment-form.svelte";
|
import PaymentForm from "./payment-form.svelte";
|
||||||
import { paymentInfoVM } from "./payment.info.vm.svelte";
|
import { paymentInfoVM } from "./payment.info.vm.svelte";
|
||||||
|
|
||||||
const cardStyle =
|
|
||||||
"flex w-full flex-col gap-4 rounded-lg bg-white p-4 shadow-lg md:p-8";
|
|
||||||
|
|
||||||
async function goBack() {
|
async function goBack() {
|
||||||
if ((await ckFlowVM.onBackToPIIBtnClick()) !== true) {
|
if ((await ckFlowVM.onBackToPIIBtnClick()) !== true) {
|
||||||
return;
|
return;
|
||||||
@@ -76,23 +73,23 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-8">
|
||||||
<div class={cardStyle}>
|
<div class="flex w-full flex-col gap-6">
|
||||||
<Title size="h4">Order Summary</Title>
|
<Title size="h4" weight="medium">Order Summary</Title>
|
||||||
<OrderSummary />
|
<OrderSummary />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class={cardStyle}>
|
<div class="flex w-full flex-col gap-6 border-t border-gray-200 pt-8">
|
||||||
<Title size="h4">Billing Details</Title>
|
<Title size="h4" weight="medium">Billing Details</Title>
|
||||||
<BillingDetailsForm info={billingDetailsVM.billingDetails} />
|
<BillingDetailsForm info={billingDetailsVM.billingDetails} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class={cardStyle}>
|
<div class="flex w-full flex-col gap-6 border-t border-gray-200 pt-8">
|
||||||
<Title size="h4">Payment Details</Title>
|
<Title size="h4" weight="medium">Payment Details</Title>
|
||||||
<PaymentForm />
|
<PaymentForm />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col items-center justify-between gap-4 md:flex-row">
|
<div class="flex flex-col items-center justify-between gap-4 border-t border-gray-200 pt-6 md:flex-row">
|
||||||
<Button variant="secondary" onclick={goBack} class="w-full md:w-max">
|
<Button variant="secondary" onclick={goBack} class="w-full md:w-max">
|
||||||
<Icon icon={RightArrowIcon} cls="w-auto h-6 rotate-180" />
|
<Icon icon={RightArrowIcon} cls="w-auto h-6 rotate-180" />
|
||||||
Back
|
Back
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ class ActionRunner {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success("Your booking has been confirmed", {
|
toast.success("Checkout completed successfully", {
|
||||||
description: "Redirecting, please wait...",
|
description: "Redirecting, please wait...",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -160,9 +160,9 @@ class ActionRunner {
|
|||||||
await ckFlowVM.cleanupFlowInfo();
|
await ckFlowVM.cleanupFlowInfo();
|
||||||
ckFlowVM.reset();
|
ckFlowVM.reset();
|
||||||
checkoutVM.reset();
|
checkoutVM.reset();
|
||||||
const tid = page.params.tid as any as string;
|
const plid = page.params.plid as any as string;
|
||||||
const sid = page.params.sid as any as string;
|
const sid = page.params.sid as any as string;
|
||||||
window.location.replace(`/checkout/terminated?sid=${sid}&tid=${tid}`);
|
window.location.replace(`/checkout/terminated?sid=${sid}&plid=${plid}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,9 +174,8 @@ export class CKFlowViewModel {
|
|||||||
|
|
||||||
otpCode: string | undefined = $state(undefined);
|
otpCode: string | undefined = $state(undefined);
|
||||||
|
|
||||||
poller: NodeJS.Timer | undefined = undefined;
|
_flowPoller: NodeJS.Timeout | undefined = undefined;
|
||||||
pinger: NodeJS.Timer | undefined = undefined;
|
_flowPinger: NodeJS.Timeout | undefined = undefined;
|
||||||
priceFetcher: NodeJS.Timer | undefined = undefined;
|
|
||||||
|
|
||||||
// Data synchronization control
|
// Data synchronization control
|
||||||
private personalInfoDebounceTimer: NodeJS.Timeout | null = null;
|
private personalInfoDebounceTimer: NodeJS.Timeout | null = null;
|
||||||
@@ -334,27 +333,27 @@ export class CKFlowViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private clearPoller() {
|
private clearPoller() {
|
||||||
if (this.poller) {
|
if (this._flowPoller) {
|
||||||
clearInterval(this.poller);
|
clearInterval(this._flowPoller);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearPinger() {
|
private clearPinger() {
|
||||||
if (this.pinger) {
|
if (this._flowPinger) {
|
||||||
clearInterval(this.pinger);
|
clearInterval(this._flowPinger);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async startPolling() {
|
private async startPolling() {
|
||||||
this.clearPoller();
|
this.clearPoller();
|
||||||
this.poller = setInterval(() => {
|
this._flowPoller = setInterval(() => {
|
||||||
this.refreshFlowInfo();
|
this.refreshFlowInfo();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async startPinging() {
|
private async startPinging() {
|
||||||
this.clearPinger();
|
this.clearPinger();
|
||||||
this.pinger = setInterval(() => {
|
this._flowPinger = setInterval(() => {
|
||||||
this.pingFlow();
|
this.pingFlow();
|
||||||
}, 30000); // Every 30 seconds
|
}, 30000); // Every 30 seconds
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,24 @@
|
|||||||
import Title from "$lib/components/atoms/title.svelte";
|
import Title from "$lib/components/atoms/title.svelte";
|
||||||
import Input from "$lib/components/ui/input/input.svelte";
|
import Input from "$lib/components/ui/input/input.svelte";
|
||||||
import * as Select from "$lib/components/ui/select";
|
import * as Select from "$lib/components/ui/select";
|
||||||
|
import * as Popover from "$lib/components/ui/popover";
|
||||||
|
import * as Command from "$lib/components/ui/command";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
import { COUNTRIES_SELECT } from "$lib/core/countries";
|
import { COUNTRIES_SELECT } from "$lib/core/countries";
|
||||||
import { capitalize } from "$lib/core/string.utils";
|
import { capitalize } from "$lib/core/string.utils";
|
||||||
import { PHONE_COUNTRY_CODES } from "@pkg/logic/core/data/phonecc";
|
import { PHONE_COUNTRY_CODES } from "@pkg/logic/core/data/phonecc";
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
import { tick } from "svelte";
|
||||||
|
import ChevronsUpDownIcon from "~icons/lucide/chevrons-up-down";
|
||||||
|
import CheckIcon from "~icons/lucide/check";
|
||||||
import type { CustomerInfoModel } from "../data";
|
import type { CustomerInfoModel } from "../data";
|
||||||
import { customerInfoVM } from "./customerinfo.vm.svelte";
|
import { customerInfoVM } from "./customerinfo.vm.svelte";
|
||||||
|
|
||||||
let { info = $bindable() }: { info: CustomerInfoModel } = $props();
|
let { info = $bindable() }: { info: CustomerInfoModel } = $props();
|
||||||
|
|
||||||
|
let phoneCodeOpen = $state(false);
|
||||||
|
let phoneCodeTriggerRef = $state<HTMLButtonElement>(null!);
|
||||||
|
|
||||||
function onSubmit(e: SubmitEvent) {
|
function onSubmit(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
customerInfoVM.validateCustomerInfo(info);
|
customerInfoVM.validateCustomerInfo(info);
|
||||||
@@ -19,6 +29,13 @@
|
|||||||
function debounceValidate() {
|
function debounceValidate() {
|
||||||
customerInfoVM.debounceValidate(info);
|
customerInfoVM.debounceValidate(info);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closePhoneCodeAndFocus() {
|
||||||
|
phoneCodeOpen = false;
|
||||||
|
tick().then(() => {
|
||||||
|
phoneCodeTriggerRef?.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form action="#" class="flex w-full flex-col gap-4" onsubmit={onSubmit}>
|
<form action="#" class="flex w-full flex-col gap-4" onsubmit={onSubmit}>
|
||||||
@@ -74,32 +91,54 @@
|
|||||||
<!-- Phone Number Field -->
|
<!-- Phone Number Field -->
|
||||||
<LabelWrapper label="Phone Number" error={customerInfoVM.errors.phoneNumber}>
|
<LabelWrapper label="Phone Number" error={customerInfoVM.errors.phoneNumber}>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Select.Root
|
<Popover.Root bind:open={phoneCodeOpen}>
|
||||||
type="single"
|
<Popover.Trigger bind:ref={phoneCodeTriggerRef}>
|
||||||
required
|
{#snippet child({ props })}
|
||||||
onValueChange={(code) => {
|
<Button
|
||||||
info.phoneCountryCode = code;
|
{...props}
|
||||||
debounceValidate();
|
variant="outline"
|
||||||
}}
|
class="w-32 justify-between"
|
||||||
name="phoneCode"
|
role="combobox"
|
||||||
>
|
aria-expanded={phoneCodeOpen}
|
||||||
<Select.Trigger class="w-28">
|
>
|
||||||
{#if info.phoneCountryCode}
|
{info.phoneCountryCode || "Select"}
|
||||||
{info.phoneCountryCode}
|
<ChevronsUpDownIcon class="h-4 w-4 opacity-50" />
|
||||||
{:else}
|
</Button>
|
||||||
Select
|
{/snippet}
|
||||||
{/if}
|
</Popover.Trigger>
|
||||||
</Select.Trigger>
|
<Popover.Content class="w-[300px] p-0">
|
||||||
<Select.Content>
|
<Command.Root>
|
||||||
{#each PHONE_COUNTRY_CODES as { country, phoneCode }}
|
<Command.Input placeholder="Search country..." class="h-9" />
|
||||||
<Select.Item value={phoneCode}>
|
<Command.List class="command-scrollbar max-h-[300px]">
|
||||||
<span class="flex items-center gap-2">
|
<Command.Empty>No country found.</Command.Empty>
|
||||||
{phoneCode} ({country})
|
<Command.Group>
|
||||||
</span>
|
{#each PHONE_COUNTRY_CODES as { country, phoneCode } (country)}
|
||||||
</Select.Item>
|
<Command.Item
|
||||||
{/each}
|
value={`${phoneCode} ${country}`}
|
||||||
</Select.Content>
|
onSelect={() => {
|
||||||
</Select.Root>
|
info.phoneCountryCode = phoneCode;
|
||||||
|
debounceValidate();
|
||||||
|
closePhoneCodeAndFocus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckIcon
|
||||||
|
class={cn(
|
||||||
|
"h-4 w-4 flex-shrink-0",
|
||||||
|
info.phoneCountryCode !== phoneCode &&
|
||||||
|
"text-transparent",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span class="flex flex-1 items-center justify-between gap-3">
|
||||||
|
<span class="font-semibold text-gray-900">{phoneCode}</span>
|
||||||
|
<span class="text-sm text-gray-600">{country}</span>
|
||||||
|
</span>
|
||||||
|
</Command.Item>
|
||||||
|
{/each}
|
||||||
|
</Command.Group>
|
||||||
|
</Command.List>
|
||||||
|
</Command.Root>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
placeholder="Phone Number"
|
placeholder="Phone Number"
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export class OrderRepository {
|
|||||||
error: getError(
|
error: getError(
|
||||||
{
|
{
|
||||||
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
|
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
|
||||||
message: "An error occured while finding booking",
|
message: "An error occured while finding order",
|
||||||
userHint: "Please try again later",
|
userHint: "Please try again later",
|
||||||
detail: "An error occured while parsing order",
|
detail: "An error occured while parsing order",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -32,7 +32,6 @@
|
|||||||
}
|
}
|
||||||
productStore.set(pageData.data);
|
productStore.set(pageData.data);
|
||||||
checkoutVM.loading = false;
|
checkoutVM.loading = false;
|
||||||
checkoutVM.setupPinger();
|
|
||||||
|
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
await ckFlowVM.initFlow();
|
await ckFlowVM.initFlow();
|
||||||
@@ -48,7 +47,9 @@
|
|||||||
{#if !pageData.data || !!pageData.error}
|
{#if !pageData.data || !!pageData.error}
|
||||||
<!-- Error State -->
|
<!-- Error State -->
|
||||||
<div class="flex min-h-screen w-full items-center justify-center p-4">
|
<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="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="mb-6 flex justify-center">
|
||||||
<div class="rounded-full bg-red-100 p-3">
|
<div class="rounded-full bg-red-100 p-3">
|
||||||
<Icon icon={SearchIcon} cls="h-12 w-12 text-red-600" />
|
<Icon icon={SearchIcon} cls="h-12 w-12 text-red-600" />
|
||||||
@@ -56,7 +57,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<Title size="h4" weight="medium" center>Product Not Found</Title>
|
<Title size="h4" weight="medium" center>Product Not Found</Title>
|
||||||
<p class="mb-6 mt-3 text-center text-gray-600">
|
<p class="mb-6 mt-3 text-center text-gray-600">
|
||||||
Something went wrong. Please try again or contact support for assistance.
|
Something went wrong. Please try again or contact support for
|
||||||
|
assistance.
|
||||||
</p>
|
</p>
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<a
|
<a
|
||||||
@@ -87,7 +89,9 @@
|
|||||||
<div class="flex flex-col gap-6 lg:flex-row lg:gap-8">
|
<div class="flex flex-col gap-6 lg:flex-row lg:gap-8">
|
||||||
<!-- Left Column: Forms -->
|
<!-- Left Column: Forms -->
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="rounded-2xl bg-white p-6 shadow-lg md:p-8 lg:p-10">
|
<div
|
||||||
|
class="rounded-2xl bg-white p-6 shadow-lg md:p-8 lg:p-10"
|
||||||
|
>
|
||||||
{#if checkoutVM.loading}
|
{#if checkoutVM.loading}
|
||||||
<CheckoutLoadingSection />
|
<CheckoutLoadingSection />
|
||||||
{:else if checkoutVM.checkoutStep === CheckoutStep.Initial}
|
{:else if checkoutVM.checkoutStep === CheckoutStep.Initial}
|
||||||
|
|||||||
6
bun.lock
6
bun.lock
@@ -107,12 +107,12 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify/json": "^2.2.275",
|
"@iconify/json": "^2.2.275",
|
||||||
"@internationalized/date": "^3.6.0",
|
"@internationalized/date": "^3.6.0",
|
||||||
"@lucide/svelte": "^0.503.0",
|
"@lucide/svelte": "^0.482.0",
|
||||||
"@sveltejs/kit": "^2.0.0",
|
"@sveltejs/kit": "^2.0.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||||
"@types/node": "^22.9.3",
|
"@types/node": "^22.9.3",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"bits-ui": "^1.4.1",
|
"bits-ui": "^1.4.7",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"embla-carousel-svelte": "^8.6.0",
|
"embla-carousel-svelte": "^8.6.0",
|
||||||
"formsnap": "^2.0.0",
|
"formsnap": "^2.0.0",
|
||||||
@@ -1636,6 +1636,8 @@
|
|||||||
|
|
||||||
"@better-auth/core/zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="],
|
"@better-auth/core/zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="],
|
||||||
|
|
||||||
|
"@domain-wall/frontend/@lucide/svelte": ["@lucide/svelte@0.482.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-n2ycHU9cNcleRDwwpEHBJ6pYzVhHIaL3a+9dQa8kns9hB2g05bY+v2p2KP8v0pZwtNhYTHk/F2o2uZ1bVtQGhw=="],
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||||
|
|
||||||
"@finom/zod-to-json-schema/zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="],
|
"@finom/zod-to-json-schema/zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="],
|
||||||
|
|||||||
@@ -924,11 +924,6 @@ export const PHONE_COUNTRY_CODES = [
|
|||||||
country: "Saint Martin",
|
country: "Saint Martin",
|
||||||
phoneCode: "+590",
|
phoneCode: "+590",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
countryCode: "sx",
|
|
||||||
country: "Saint Martin",
|
|
||||||
phoneCode: "+1721",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
countryCode: "pm",
|
countryCode: "pm",
|
||||||
country: "Saint Pierre and Miquelon",
|
country: "Saint Pierre and Miquelon",
|
||||||
|
|||||||
Reference in New Issue
Block a user