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