UI refactor and some logical fixes

This commit is contained in:
user
2025-10-21 18:41:19 +03:00
parent 1a89236449
commit f9f743eb15
29 changed files with 206 additions and 1070 deletions

View File

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

View File

@@ -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();

View File

@@ -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,
};
}; };

View File

@@ -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 />

View File

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

View File

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

View File

@@ -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;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}

View File

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

View File

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