stashing code

This commit is contained in:
user
2025-10-20 17:07:41 +03:00
commit f5b99afc8f
890 changed files with 54823 additions and 0 deletions

View File

@@ -0,0 +1 @@
export * from "@pkg/logic/domains/airport/data/entities";

View File

@@ -0,0 +1,222 @@
import { asc, eq, sql, type AirportsDatabase } from "@pkg/airports-db";
import { airportModel, type Airport } from "./entities";
import { ERROR_CODES, type Result } from "@pkg/result";
import { getError, Logger } from "@pkg/logger";
import { airport } from "@pkg/airports-db/schema";
export class AirportsRepository {
private db: AirportsDatabase;
constructor(db: AirportsDatabase) {
this.db = db;
}
async searchAirports(query: string): Promise<Result<Airport[]>> {
if (query.length < 2) {
return { data: [] };
}
try {
const cleanQuery = query.trim();
let allResults: any[] = [];
// Strategy 1: Try fuzzy search with trigrams
try {
// Similarity threshold - adjust this value between 0.1 and 0.6
// Lower values give more results but less precise
const similarityThreshold = 0.3;
// Execute trigram similarity search
const trigRawResults = await this.db.execute(sql`
SELECT * FROM airport
WHERE
similarity(LOWER(name), LOWER(${cleanQuery})) > ${similarityThreshold} OR
similarity(LOWER(iata_code), LOWER(${cleanQuery})) > ${similarityThreshold} OR
similarity(LOWER(municipality), LOWER(${cleanQuery})) > ${similarityThreshold} OR
similarity(LOWER(country), LOWER(${cleanQuery})) > ${similarityThreshold}
ORDER BY
GREATEST(
similarity(LOWER(name), LOWER(${cleanQuery})),
similarity(LOWER(iata_code), LOWER(${cleanQuery})),
similarity(LOWER(municipality), LOWER(${cleanQuery})),
similarity(LOWER(country), LOWER(${cleanQuery}))
) DESC,
iata_code ASC
LIMIT 15
`);
const trigResults = trigRawResults.map((row) => ({
id: row.id,
type: row.type,
name: row.name,
gpsCode: row.gps_code,
ident: row.ident,
latitudeDeg: row.latitude_deg,
longitudeDeg: row.longitude_deg,
elevationFt: row.elevation_ft,
continent: row.continent,
country: row.country,
isoCountry: row.iso_country,
isoRegion: row.iso_region,
municipality: row.municipality,
scheduledService: row.scheduled_service,
iataCode: row.iata_code,
localCode: row.local_code,
createdAt: row.created_at,
updatedAt: row.updated_at,
searchVector: row.search_vector,
}));
allResults.push(...trigResults);
} catch (e) {
// In case trigram extension isn't available or other issues
Logger.warn(
"Trigram search failed, possibly extension not enabled",
e,
);
}
Logger.info(
`Result count after trigram search: ${allResults.length}`,
);
// Strategy 2: Try with plainto_tsquery if trigram search returned few results
if (allResults.length < 5) {
const plainTextResults = await this.db.query.airport.findMany({
where: sql`${airport.searchVector} @@ plainto_tsquery('english', ${cleanQuery})`,
limit: 15,
orderBy: [
sql`ts_rank(${airport.searchVector}, plainto_tsquery('english', ${cleanQuery})) DESC`,
asc(airport.iataCode),
],
});
// Add new results that aren't already in allResults
const existingIds = new Set(allResults.map((r) => r.id));
for (const result of plainTextResults) {
if (!existingIds.has(result.id)) {
allResults.push(result);
existingIds.add(result.id);
}
}
Logger.info(
`Result count after plainto_tsquery search: ${allResults.length}`,
);
}
// Strategy 3: Full-text search with prefix matching as fallback
if (allResults.length < 5) {
const prefixQuery = cleanQuery
.toLowerCase()
.split(/\s+/)
.filter(Boolean)
.map((term) => term.replace(/[&|!:*'"\(\)]/g, "") + ":*")
.join(" & ");
if (prefixQuery) {
const textSearchResults =
await this.db.query.airport.findMany({
where: sql`${airport.searchVector} @@ to_tsquery('english', ${prefixQuery})`,
limit: 15,
orderBy: [
sql`ts_rank(${airport.searchVector}, to_tsquery('english', ${prefixQuery})) DESC`,
asc(airport.iataCode),
],
});
// Add new results that aren't already in allResults
const existingIds = new Set(allResults.map((r) => r.id));
for (const result of textSearchResults) {
if (!existingIds.has(result.id)) {
allResults.push(result);
existingIds.add(result.id);
}
}
}
Logger.info(
`Result count after prefix search: ${allResults.length}`,
);
}
// Limit to 15 results
allResults = allResults.slice(0, 15);
// Process and validate results
const res = [] as Airport[];
for (const each of allResults) {
const parsed = airportModel.safeParse(each);
if (parsed.success) {
res.push(parsed.data);
} else {
Logger.warn(
`An error occurred while parsing airport in airport search`,
);
Logger.error(parsed.error.errors);
}
}
return { data: res };
} catch (err) {
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "An error occurred while searching for airports",
detail: "A database error occurred while performing the search",
userHint:
"Please try again later, or contact us for more assistance",
},
err,
),
};
}
}
async getAirportByCode(code: string): Promise<Result<Airport>> {
try {
const qRes = await this.db.query.airport.findFirst({
where: eq(airport.iataCode, code),
});
if (!qRes) {
return {
error: getError({
code: ERROR_CODES.DATABASE_ERROR,
message: "Airport not found",
detail: "Airport not found in the database",
userHint: "Please check the airport code and try again",
}),
};
}
const parsed = airportModel.safeParse(qRes);
if (!parsed.success) {
Logger.info(parsed.error.errors);
return {
error: getError({
code: ERROR_CODES.DATABASE_ERROR,
message: "Error parsing airport data",
detail: "The airport data could not be processed correctly",
userHint:
"Please try again later, or contact us for more assistance",
}),
};
}
return { data: parsed.data };
} catch (err) {
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "Error retrieving airport",
detail: "An error occurred while fetching the airport data",
userHint:
"Please try again later, or contact us for more assistance",
},
err,
),
};
}
}
}

View File

@@ -0,0 +1,210 @@
<script lang="ts">
import ChevronsUpDown from "@lucide/svelte/icons/chevrons-up-down";
import { tick } from "svelte";
import * as Popover from "$lib/components/ui/popover/index.js";
import { Button } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
import { airportVM } from "./airport.vm.svelte";
import Icon from "$lib/components/atoms/icon.svelte";
import PlaneTakeOffIcon from "~icons/mingcute/flight-takeoff-line";
import PlaneLandIcon from "~icons/mingcute/flight-land-line";
import SearchIcon from "~icons/solar/magnifer-linear";
import type { Airport } from "../data/entities";
import { TRANSITION_COLORS } from "$lib/core/constants";
import { browser } from "$app/environment";
import MobileAirportSearchModal from "./mobile-airport-search-modal.svelte";
import { onDestroy, onMount } from "svelte";
let {
currentValue = $bindable(),
onChange,
width = "w-full lg:w-[clamp(10rem,28vw,34rem)]",
placeholder = "",
searchPlaceholder = "",
isMobile,
fieldType = undefined,
}: {
currentValue?: Airport | null;
onChange?: (value: string) => void;
width?: string;
placeholder?: string;
searchPlaceholder?: string;
isMobile?: boolean;
fieldType?: "departure" | "arrival";
} = $props();
let open = $state(false);
let triggerRef = $state<HTMLButtonElement>(null!);
let showMobileModal = $state(false);
function checkIfMobile() {
if (browser) {
isMobile = window.innerWidth < 768;
}
}
// Set up resize listener
function setupResizeListener() {
if (browser) {
window.addEventListener("resize", checkIfMobile);
checkIfMobile(); // Initial check
return () => {
window.removeEventListener("resize", checkIfMobile);
};
}
}
function handleTriggerClick() {
airportVM.resetSearch();
if (isMobile) {
open = false;
// Show mobile modal with a slight delay to ensure popover is fully closed
setTimeout(() => {
showMobileModal = true;
// Dispatch field type event after modal is shown
setTimeout(() => {
window.dispatchEvent(
new CustomEvent("airportFieldSelected", {
detail: {
fieldType:
fieldType ||
(searchPlaceholder.includes("rival")
? "arrival"
: "departure"),
},
}),
);
}, 50);
}, 10);
}
}
function onSelect(newValue: string) {
closeAndFocusTrigger();
onChange?.(newValue);
}
function closeAndFocusTrigger() {
open = false;
tick().then(() => triggerRef.focus());
}
let cleanup: any | undefined = $state(undefined);
onMount(() => {
cleanup = setupResizeListener();
});
onDestroy(() => {
cleanup?.();
});
</script>
<MobileAirportSearchModal
bind:open={showMobileModal}
departureAirport={airportVM.departure}
arrivalAirport={airportVM.arrival}
{fieldType}
on:close={() => (showMobileModal = false)}
on:departureSelected={(e) => {
airportVM.setDepartureAirport(e.detail.iataCode);
showMobileModal = false;
}}
on:arrivalSelected={(e) => {
airportVM.setArrivalAirport(e.detail.iataCode);
showMobileModal = false;
}}
on:swap={() => {
airportVM.swapDepartureAndArrival();
}}
/>
<Popover.Root bind:open>
<Popover.Trigger bind:ref={triggerRef} onclick={() => handleTriggerClick()}>
{#snippet child({ props })}
<Button
variant="white"
class={cn(
"w-full items-center justify-between px-4 text-xs md:text-sm",
)}
{...props}
role="combobox"
aria-expanded={open}
>
<div class="flex w-full items-center gap-2">
<Icon
icon={searchPlaceholder.includes("rrival")
? PlaneLandIcon
: PlaneTakeOffIcon}
cls={cn("w-auto h-5")}
/>
<span
class="max-w-32 overflow-hidden overflow-ellipsis whitespace-nowrap text-left sm:max-w-64 md:max-w-32"
>
{currentValue
? `${currentValue.iataCode} (${currentValue.name})`
: placeholder}
</span>
</div>
<ChevronsUpDown class="h-4 w-auto opacity-20" />
</Button>
{/snippet}
</Popover.Trigger>
{#if !isMobile && open}
<Popover.Content class={cn(width, "p-0")}>
<div class="flex items-center rounded-t-md border-b bg-white">
<Icon icon={SearchIcon} cls="w-auto h-4 ml-2" />
<input
placeholder={searchPlaceholder}
oninput={(e) => {
airportVM.setAirportSearchQuery(e.currentTarget.value);
}}
class="ring-none w-full rounded-t-md border-none p-3 px-4 outline-none focus:border-none focus:outline-none focus:ring-0"
/>
</div>
<section
class="flex max-h-[20rem] w-full flex-col gap-2 overflow-y-auto p-2"
>
{#if airportVM.isLoading}
<div class="flex flex-col gap-2 p-2">
{#each Array(3) as _}
<div class="flex animate-pulse flex-col gap-1 p-2">
<div class="h-5 w-3/4 rounded bg-gray-200"></div>
<div class="h-3 w-1/2 rounded bg-gray-100"></div>
</div>
{/each}
</div>
{:else if airportVM.airportsQueried.length === 0}
<div class="grid place-items-center p-8">
<span>No results found</span>
</div>
{:else}
{#each airportVM.airportsQueried as opt}
<button
class={cn(
"flex w-full flex-col items-start gap-0 rounded-md bg-white p-2 text-start hover:bg-gray-100 active:bg-gray-200",
TRANSITION_COLORS,
)}
value={opt.iataCode}
onclick={() => onSelect(opt.iataCode)}
>
<span class="text-base font-medium tracking-wide">
{opt.iataCode}, {opt.name}, {opt.country}
</span>
<span
class="break-words text-xs font-medium text-gray-500"
>
{opt.municipality}
</span>
</button>
{/each}
{/if}
</section>
</Popover.Content>
{/if}
</Popover.Root>

View File

@@ -0,0 +1,143 @@
import { type Airport } from "$lib/domains/airport/data/entities";
import { ticketSearchStore } from "$lib/domains/ticket/data/store";
import { trpcApiStore } from "$lib/stores/api";
import { toast } from "svelte-sonner";
import { get } from "svelte/store";
export class AirportViewModel {
airportSearchQuery = $state("");
airportsQueried = $state<Airport[]>([]);
private airportSearchTimeout: NodeJS.Timer | undefined;
isLoading = $state(false);
departure = $state<Airport | null>(null);
arrival = $state<Airport | null>(null);
async setAirportSearchQuery(query: string) {
this.airportSearchQuery = query;
if (this.airportSearchTimeout) {
clearTimeout(this.airportSearchTimeout);
}
this.isLoading = true;
this.airportSearchTimeout = setTimeout(() => {
this.queryAirports();
}, 300);
}
resetSearch() {
this.airportSearchQuery = "";
this.airportsQueried = [];
this.isLoading = false;
}
async queryAirports() {
const api = get(trpcApiStore);
if (!api) {
this.isLoading = false;
return;
}
try {
const response = await api.ticket.searchAirports.query({
query: this.airportSearchQuery,
});
if (response.error) {
return toast.error(response.error.message, {
description: response.error.userHint,
});
}
if (response.data) {
this.airportsQueried = response.data.map((each) => {
return {
...each,
createdAt: new Date(each.createdAt),
updatedAt: new Date(each.updatedAt),
};
});
}
} finally {
this.isLoading = false;
}
}
async searchForAirportByCode(code: string) {
const api = get(trpcApiStore);
if (!api) {
return;
}
const res = await api.ticket.getAirportByCode.query({ code });
if (res.error) {
return toast.error(res.error.message, {
description: res.error.userHint,
});
}
if (!res.data) {
return toast.error("Airport not found", {
description:
"Please try searching manually or try again again later",
});
}
this.airportsQueried = [
{
...res.data,
createdAt: new Date(res.data.createdAt),
updatedAt: new Date(res.data.updatedAt),
},
];
this.setArrivalAirport(res.data.iataCode);
}
setDepartureAirport(code: string) {
const found = this.airportsQueried.find((f) => f.iataCode === code);
if (found) {
this.departure = found;
} else {
this.departure = null;
}
const meta = {} as Record<string, any>;
if (found && found.isoCountry) {
meta.isoCountry = found.isoCountry;
}
console.log("setting the meta to", meta);
ticketSearchStore.update((prev) => {
return { ...prev, departure: found?.iataCode ?? "", meta };
});
}
setArrivalAirport(code: string) {
const found = this.airportsQueried.find((f) => f.iataCode === code);
if (found) {
this.arrival = found;
} else {
this.arrival = null;
}
ticketSearchStore.update((prev) => {
return { ...prev, arrival: found?.iataCode ?? "" };
});
}
swapDepartureAndArrival() {
const temp = this.departure;
this.departure = this.arrival;
this.arrival = temp;
ticketSearchStore.update((prev) => {
return {
...prev,
departure: this.departure?.iataCode ?? "",
arrival: this.arrival?.iataCode ?? "",
};
});
}
}
export const airportVM = new AirportViewModel();

View File

@@ -0,0 +1,259 @@
<script lang="ts">
import { createEventDispatcher, onMount } from "svelte";
import SearchIcon from "~icons/solar/magnifer-linear";
import PlaneTakeOffIcon from "~icons/mingcute/flight-takeoff-line";
import PlaneLandIcon from "~icons/mingcute/flight-land-line";
import SwapIcon from "~icons/ant-design/swap-outlined";
import Button from "$lib/components/ui/button/button.svelte";
import Icon from "$lib/components/atoms/icon.svelte";
import { cn } from "$lib/utils";
import { airportVM } from "./airport.vm.svelte";
import { TRANSITION_COLORS } from "$lib/core/constants";
import type { Airport } from "../data/entities";
import * as Dialog from "$lib/components/ui/dialog";
import { browser } from "$app/environment";
let {
open = $bindable(false),
departureAirport,
arrivalAirport,
fieldType = "departure",
}: {
open?: boolean;
departureAirport: Airport | null;
arrivalAirport: Airport | null;
fieldType?: "departure" | "arrival";
} = $props();
const dispatch = createEventDispatcher();
let activeField: "departure" | "arrival" | null = $state(null);
let searchQuery = $state("");
let searchInputRef: HTMLInputElement | undefined = $state(undefined);
// Format airport display text
function formatAirportText(airport: Airport | null) {
return airport ? `${airport.iataCode} (${airport.name})` : "";
}
// Handle close
function handleClose() {
open = false;
activeField = null;
dispatch("close");
}
// Handle airport selection
function selectAirport(airport: Airport) {
if (activeField === "departure") {
dispatch("departureSelected", airport);
} else if (activeField === "arrival") {
dispatch("arrivalSelected", airport);
}
// Close after selection
handleClose();
}
// Handle field selection and focus the input
function setActiveField(field: "departure" | "arrival") {
activeField = field;
airportVM.resetSearch();
searchQuery = "";
// Focus the input with a slight delay to ensure rendering is complete
setTimeout(() => {
if (searchInputRef) {
searchInputRef.focus();
}
}, 50);
}
// Update search when query changes
$effect(() => {
if (searchQuery) {
airportVM.setAirportSearchQuery(searchQuery);
}
});
// Reset and auto-select field when dialog opens
$effect(() => {
if (open) {
activeField = fieldType;
}
});
// Handle swap function
function swapAirports() {
dispatch("swap");
}
onMount(() => {
if (browser) {
const handleFieldSelection = (event: CustomEvent) => {
if (open) {
const selectedField = event.detail.fieldType;
setActiveField(selectedField as "departure" | "arrival");
}
};
window.addEventListener(
"airportFieldSelected",
handleFieldSelection as EventListener,
);
return () => {
window.removeEventListener(
"airportFieldSelected",
handleFieldSelection as EventListener,
);
};
}
});
</script>
<Dialog.Root bind:open onOpenChange={(isOpen) => !isOpen && handleClose()}>
<Dialog.Content class="z-[100] flex h-[85vh] w-screen flex-col gap-0 p-0">
<div class="flex items-center justify-between border-b p-4">
<h2 class="text-lg font-medium">Airport Selection</h2>
<Dialog.Close />
</div>
<!-- Main content area -->
<div class="flex flex-1 flex-col overflow-hidden">
<!-- Header with direct input integration -->
<div class="flex flex-col gap-4 border-b p-4">
<div class="flex items-center justify-between">
<div class="mb-1 text-sm font-medium text-gray-500">
{activeField === "departure" ? "From" : "To"}
</div>
<!-- Swap button -->
<Button
size="icon"
variant="ghost"
class="h-8 w-8"
onclick={swapAirports}
>
<Icon icon={SwapIcon} cls="w-5 h-5" />
</Button>
</div>
<!-- The active input field -->
<div
class="relative flex w-full items-center rounded-lg border bg-white"
>
<Icon
icon={activeField === "departure"
? PlaneTakeOffIcon
: PlaneLandIcon}
cls="absolute left-3 h-5 w-5 text-gray-400"
/>
<input
bind:this={searchInputRef}
bind:value={searchQuery}
placeholder={activeField === "departure"
? departureAirport
? formatAirportText(departureAirport)
: "Search for departure airport"
: arrivalAirport
? formatAirportText(arrivalAirport)
: "Search for arrival airport"}
class="w-full border-none bg-transparent py-3 pl-10 pr-3 outline-none focus:ring-0"
autocomplete="off"
/>
</div>
<!-- Toggle buttons -->
<div class="flex w-full gap-2">
<Button
variant={activeField === "departure"
? "default"
: "outline"}
class="flex-1"
onclick={() => setActiveField("departure")}
>
<Icon icon={PlaneTakeOffIcon} cls="mr-2 h-4 w-4" />
Departure
</Button>
<Button
variant={activeField === "arrival"
? "default"
: "outline"}
class="flex-1"
onclick={() => setActiveField("arrival")}
>
<Icon icon={PlaneLandIcon} cls="mr-2 h-4 w-4" />
Arrival
</Button>
</div>
</div>
<!-- Search results area -->
<div class="flex-1 overflow-y-auto p-0">
{#if airportVM.isLoading}
<div class="flex flex-col p-2">
{#each Array(5) as _}
<div
class="flex animate-pulse flex-col gap-1 border-b p-4"
>
<div class="flex items-center gap-2">
<div
class="h-5 w-5 rounded-full bg-gray-200"
></div>
<div
class="h-5 w-16 rounded bg-gray-200"
></div>
</div>
<div
class="ml-7 mt-1 h-4 w-3/4 rounded bg-gray-100"
></div>
<div
class="ml-7 mt-1 h-3 w-1/2 rounded bg-gray-100"
></div>
</div>
{/each}
</div>
{:else if airportVM.airportsQueried.length === 0}
<div class="grid h-32 place-items-center">
<span class="text-gray-500">
{searchQuery
? "No airports found"
: "Start typing to search airports"}
</span>
</div>
{:else}
<div class="flex flex-col">
{#each airportVM.airportsQueried as airport}
<button
class={cn(
"flex w-full flex-col items-start gap-1 border-b p-4 text-start hover:bg-gray-50 active:bg-gray-100",
TRANSITION_COLORS,
)}
onclick={() => selectAirport(airport)}
>
<div class="flex w-full items-center gap-2">
<Icon
icon={activeField === "departure"
? PlaneTakeOffIcon
: PlaneLandIcon}
cls="h-5 w-5 text-gray-400"
/>
<span class="text-base font-medium">
{airport.iataCode}
</span>
</div>
<span class="pl-7 text-sm">
{airport.name}, {airport.country}
</span>
<span class="pl-7 text-xs text-gray-500">
{airport.municipality}
</span>
</button>
{/each}
</div>
{/if}
</div>
</div>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,72 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db, schema } from "@pkg/db";
import { env as publicEnv } from "$env/dynamic/public";
import { getRedisInstance } from "../../../server/redis";
import { admin, username, openAPI } from "better-auth/plugins";
import { UserRoleMap } from "../../../core/enums";
import { hashPassword, verifyPassword } from "../domain/hash";
import { env } from "$env/dynamic/private";
export const auth = betterAuth({
trustedOrigins: ["http://localhost:5173", env.BETTER_AUTH_URL],
advanced: { useSecureCookies: publicEnv.PUBLIC_NODE_ENV === "production" },
emailAndPassword: {
enabled: true,
password: { hash: hashPassword, verify: verifyPassword },
},
plugins: [
openAPI(),
username(),
admin({
defaultRole: UserRoleMap.ADMIN,
defaultBanReason:
"Stop fanum taxing the server bub, losing aura points fr",
defaultBanExpiresIn: 60 * 60 * 24,
}),
],
logger: {
level: publicEnv.PUBLIC_NODE_ENV === "production" ? "error" : "debug",
},
database: drizzleAdapter(db, { provider: "pg", schema: { ...schema } }),
secondaryStorage: {
get: async (key) => {
const redis = getRedisInstance();
return await redis.get(key);
},
set: async (key, value, ttl) => {
const redis = getRedisInstance();
if (ttl) {
await redis.setex(key, ttl, value);
} else {
await redis.set(key, value);
}
},
delete: async (key) => {
const redis = getRedisInstance();
const out = await redis.del(key);
if (!out && out !== 0) {
return null;
}
return out.toString() as any;
},
},
session: {
modelName: "session",
expiresIn: 60 * 60 * 24 * 7,
updateAge: 60 * 60 * 24,
},
user: {
changeEmail: { enabled: false },
modelName: "user",
additionalFields: {
parentId: { required: false, type: "string" },
discountPercent: {
required: false,
type: "number",
defaultValue: 0,
fieldName: "discount_percent",
},
},
},
});

View File

@@ -0,0 +1,10 @@
import { createAuthClient } from "better-auth/svelte";
import {
inferAdditionalFields,
usernameClient,
} from "better-auth/client/plugins";
import type { auth } from "./base";
export const authClient = createAuthClient({
plugins: [usernameClient(), inferAdditionalFields<typeof auth>()],
});

View File

@@ -0,0 +1,4 @@
import type { authClient } from "../config/client";
export * from "@pkg/logic/domains/auth/data/entities";
export type Session = typeof authClient.$Infer.Session;

View File

@@ -0,0 +1,15 @@
import { UserRepository } from "$lib/domains/user/data/repository";
import { db } from "@pkg/db";
import type { Result } from "@pkg/result";
export class AuthController {
private userRepo: UserRepository;
constructor() {
this.userRepo = new UserRepository(db);
}
async shouldLogin(username: string): Promise<Result<boolean>> {
return this.userRepo.shouldUserLogin(username);
}
}

View File

@@ -0,0 +1,54 @@
import { describe, it, expect } from "vitest";
import { hashPassword, verifyPassword } from "./hash";
describe("Password Hashing Functions", () => {
describe("hashPassword", () => {
it("should generate different hashes for the same password", async () => {
const password = "mySecurePassword123";
const hash1 = await hashPassword(password);
const hash2 = await hashPassword(password);
expect(hash1).not.toBe(hash2);
expect(typeof hash1).toBe("string");
expect(hash1).toMatch(/^\$argon2id\$/);
});
});
describe("verifyPassword", () => {
it("should verify correct password successfully", async () => {
const password = "correctPassword123";
const hash = await hashPassword(password);
const isValid = await verifyPassword({ hash, password });
expect(isValid).toBe(true);
});
it("should reject incorrect password", async () => {
const password = "correctPassword123";
const wrongPassword = "wrongPassword123";
const hash = await hashPassword(password);
const isValid = await verifyPassword({
hash,
password: wrongPassword,
});
expect(isValid).toBe(false);
});
it("should handle empty password", async () => {
const password = "";
const hash = await hashPassword(password);
const isValid = await verifyPassword({ hash, password });
expect(isValid).toBe(true);
});
it("should return false for invalid hash format", async () => {
const isValid = await verifyPassword({
hash: "invalid-hash-format",
password: "password123",
});
expect(isValid).toBe(false);
});
});
});

View File

@@ -0,0 +1,33 @@
import argon2 from "argon2";
export async function hashPassword(password: string): Promise<string> {
const salt = Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString(
"hex",
);
const hash = await argon2.hash(password, {
type: argon2.argon2id,
salt: Buffer.from(salt, "hex"),
hashLength: 32,
timeCost: 3,
memoryCost: 65536,
parallelism: 1,
});
return hash;
}
export async function verifyPassword({
hash,
password,
}: {
hash: string;
password: string;
}): Promise<boolean> {
try {
const isValid = await argon2.verify(hash, `${password}`);
return isValid;
} catch (err) {
return false;
}
}

View File

@@ -0,0 +1,12 @@
import { createTRPCRouter, publicProcedure } from "$lib/trpc/t";
import z from "zod";
import { AuthController } from "./controller";
export const authRouter = createTRPCRouter({
shouldLogin: publicProcedure
.input(z.object({ username: z.string() }))
.query(async ({ input }) => {
const ac = new AuthController();
return ac.shouldLogin(input.username);
}),
});

View File

@@ -0,0 +1,5 @@
import type { authClient } from "../../config/client";
export * from "@pkg/logic/domains/auth/session/data/entities";
export type Session = typeof authClient.$Infer.Session;

View File

@@ -0,0 +1,70 @@
import type Redis from "ioredis";
import { sessionModel, type SessionModel } from "./entities";
import type { Result } from "$lib/core/data.types";
import { ERROR_CODES } from "$lib/core/error.codes";
export interface SessionRepository {
getSession(id: string): Promise<Result<SessionModel>>;
setSession(session: SessionModel): Promise<Result<boolean>>;
deleteSession(id: string): Promise<Result<boolean>>;
}
export class SessionRepositoryImpl implements SessionRepository {
private store: Redis;
constructor(store: Redis) {
this.store = store;
}
async getSession(id: string) {
const session = await this.store.get(id);
const parsed = sessionModel.safeParse(JSON.parse(session ?? "{}"));
if (!parsed.success) {
return {
error: {
code: ERROR_CODES.VALIDATION_ERROR,
message: "Invalid session data",
userHint: "Login again",
detail: "Session not found",
actionable: false,
},
};
}
return { data: parsed.data };
}
async setSession(session: SessionModel) {
const parsed = sessionModel.safeParse(session);
if (!parsed.success) {
return {
error: {
code: ERROR_CODES.VALIDATION_ERROR,
message: "Invalid session passed",
userHint: "Login again",
detail: "Session not found",
actionable: false,
},
};
}
const oneWeek = 7 * 24 * 60 * 60;
await this.store.setex(session.id, oneWeek, JSON.stringify(parsed.data));
return { data: true };
}
async deleteSession(id: string) {
const session = await this.store.get(id);
if (!session) {
return {
error: {
code: ERROR_CODES.NOT_FOUND_ERROR,
message: "Session not found",
userHint: "Login again",
detail: "Session not found",
actionable: false,
},
};
}
await this.store.del(id);
return { data: true };
}
}

View File

@@ -0,0 +1,3 @@
export class SessionController {
constructor() {}
}

View File

@@ -0,0 +1,69 @@
import { goto } from "$app/navigation";
import { get } from "svelte/store";
import { authClient } from "../config/client";
import { authPayloadModel, type AuthPayloadModel } from "../data/entities";
import { toast } from "svelte-sonner";
import { trpcApiStore } from "$lib/stores/api";
export class AuthViewModel {
submitting = $state(false);
onAuthFormSubmit = async (event: SubmitEvent) => {
event.preventDefault();
const form = event.target as HTMLFormElement;
const formData = new FormData(form);
const res = authPayloadModel.safeParse(
Object.fromEntries(formData.entries()),
);
if (!res.success) {
const field = res.error.errors[0].path[0].toString();
const msg = res.error.errors[0].message;
return toast.error(`${field} ${msg}`);
}
this.submitting = true;
const ok = await this.authenticate(res.data);
this.submitting = false;
if (!ok) {
return;
}
form.reset();
setTimeout(() => goto("/"), 1000);
};
async authenticate(payload: AuthPayloadModel) {
const api = get(trpcApiStore);
if (!api) {
toast.error(
"Could not authenticate at the moment, please try again later",
);
return false;
}
const out = await api.auth.shouldLogin.query({
username: payload.username,
});
if (typeof out.data !== "boolean") {
toast.error(
"Could not authenticate at the moment, please try again later",
);
return false;
}
if (!out.data) {
toast.error("Invalid username or password");
return false;
}
const { data, error } = await authClient.signIn.username(payload);
if (error) {
toast.error(error.message ?? "An error occurred");
return false;
}
if (data && data.user) {
toast.success("Login Successful, Redirecting...");
return true;
}
toast.error("An error occurred while logging in, please try again");
return false;
}
}
export const authVM = new AuthViewModel();

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import LabelWrapper from "$lib/components/atoms/label-wrapper.svelte";
import Button from "$lib/components/ui/button/button.svelte";
import Input from "$lib/components/ui/input/input.svelte";
import { authVM } from "./auth.vm.svelte";
import ButtonLoadableText from "$lib/components/atoms/button-loadable-text.svelte";
</script>
<form
action="#"
onsubmit={authVM.onAuthFormSubmit}
method="POST"
class="flex w-full flex-col gap-8"
>
<LabelWrapper label="Username">
<Input required name="username" maxlength={128} minlength={4} />
</LabelWrapper>
<LabelWrapper label="Password">
<Input
required
type="password"
name="password"
maxlength={128}
minlength={6}
/>
</LabelWrapper>
<Button type="submit" disabled={authVM.submitting}>
<ButtonLoadableText
loading={authVM.submitting}
loadingText="Authenticating"
text="Continue"
/>
</Button>
</form>

View File

@@ -0,0 +1 @@
export * from "@pkg/logic/domains/ckflow/data/entities";

View File

@@ -0,0 +1,614 @@
import { ERROR_CODES, type Result } from "@pkg/result";
import {
flowInfoModel,
SessionOutcome,
type CreateCheckoutFlowPayload,
type FlowInfo,
type PaymentFlowStepPayload,
type PrePaymentFlowStepPayload,
} from "./entities";
import { getError, Logger } from "@pkg/logger";
import { nanoid } from "nanoid";
import { CheckoutStep } from "$lib/domains/ticket/data/entities";
import type { PassengerPII } from "$lib/domains/passengerinfo/data/entities";
import type { PaymentDetailsPayload } from "$lib/domains/paymentinfo/data/entities";
import type { Database } from "@pkg/db";
import { and, eq } from "@pkg/db";
import { checkoutFlowSession } from "@pkg/db/schema";
export class CheckoutFlowRepository {
constructor(private db: Database) {}
async createFlow(
payload: CreateCheckoutFlowPayload,
): Promise<Result<string>> {
try {
const flowId = payload.flowId || nanoid();
const now = new Date();
// Insert into database
await this.db.insert(checkoutFlowSession).values({
flowId: flowId,
domain: payload.domain,
checkoutStep: CheckoutStep.Initial,
pendingActions: [] as any,
showVerification: false,
createdAt: now,
lastPinged: now,
isActive: true,
lastSyncedAt: now,
refOIds: payload.refOIds as any,
ipAddress: payload.ipAddress,
userAgent: payload.userAgent,
reserved: false,
sessionOutcome: SessionOutcome.PENDING,
ticketId: payload.ticketId,
});
return { data: flowId };
} catch (err) {
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to create checkout flow",
userHint: "Please try again later",
detail: "Error occurred while creating checkout session in database",
},
err,
),
};
}
}
async getFlowInfo(flowId: string): Promise<Result<FlowInfo>> {
try {
const session = await this.db.query.checkoutFlowSession.findFirst({
where: eq(checkoutFlowSession.flowId, flowId),
});
if (!session) {
return {
error: getError({
code: ERROR_CODES.NOT_FOUND_ERROR,
message: "Checkout session not found",
userHint: "Please restart the checkout process",
detail: "Requested checkout session does not exist in database",
}),
};
}
// Add data staleness indicators
const now = new Date();
const STALE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
const personalInfoStale = session.personalInfoLastSyncedAt
? now.getTime() - session.personalInfoLastSyncedAt.getTime() >
STALE_THRESHOLD_MS
: false;
const paymentInfoStale = session.paymentInfoLastSyncedAt
? now.getTime() - session.paymentInfoLastSyncedAt.getTime() >
STALE_THRESHOLD_MS
: false;
const parsed = flowInfoModel.safeParse({
...session,
pendingActions: session.pendingActions as any,
personalInfo: session.personalInfo as any,
paymentInfo: session.paymentInfo as any,
refOIds: session.refOIds as any,
createdAt: session.createdAt.toISOString(),
lastPinged: session.lastPinged.toISOString(),
lastSyncedAt: session.lastSyncedAt.toISOString(),
personalInfoLastSyncedAt:
session.personalInfoLastSyncedAt?.toISOString(),
paymentInfoLastSyncedAt:
session.paymentInfoLastSyncedAt?.toISOString(),
completedAt: session.completedAt?.toISOString(),
personalInfoStale,
paymentInfoStale,
});
if (parsed.error) {
return {
error: getError({
code: ERROR_CODES.VALIDATION_ERROR,
message: "Invalid flow info found",
userHint: "Please restart the checkout process",
detail: "An error occurred while parsing checkout session info",
error: parsed.error,
}),
};
}
return { data: parsed.data };
} catch (err) {
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to get flow info",
userHint:
"Please restart the checkout, or try again later",
detail: "Error occurred while getting checkout session from database",
},
err,
),
};
}
}
async updateFlowState(
fid: string,
state: FlowInfo,
): Promise<Result<boolean>> {
try {
const existingSession = await this.db
.select({ id: checkoutFlowSession.id })
.from(checkoutFlowSession)
.where(eq(checkoutFlowSession.flowId, fid));
if (!existingSession || existingSession.length === 0) {
return {
error: getError({
code: ERROR_CODES.NOT_FOUND_ERROR,
message: "Session not found",
userHint:
"The session you're trying to update doesn't exist",
detail: `Flow with ID ${fid} not found in database`,
}),
};
}
// Convert ISO date strings back to Date objects
const lastSyncedAt = new Date();
await this.db
.update(checkoutFlowSession)
.set({
checkoutStep: state.checkoutStep,
showVerification: state.showVerification,
lastPinged: new Date(),
isActive: state.isActive,
lastSyncedAt,
pendingActions: state.pendingActions as any,
personalInfo: state.personalInfo as any,
paymentInfo: state.paymentInfo as any,
otpCode: state.otpCode,
otpSubmitted: state.otpSubmitted,
partialOtpCode: state.partialOtpCode,
reserved: state.reserved,
reservedBy: state.reservedBy,
})
.where(eq(checkoutFlowSession.flowId, fid))
.execute();
return { data: true };
} catch (err) {
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to update checkout session",
userHint: "Please try again later",
detail: "Error occurred while updating checkout session in database",
},
err,
),
};
}
}
async syncPersonalInfo(
flowId: string,
personalInfo: PassengerPII,
): Promise<Result<boolean>> {
try {
const existingSession = await this.db
.select({ id: checkoutFlowSession.id })
.from(checkoutFlowSession)
.where(eq(checkoutFlowSession.flowId, flowId));
if (!existingSession || existingSession.length === 0) {
return {
error: getError({
code: ERROR_CODES.NOT_FOUND_ERROR,
message: "Session not found",
userHint:
"The session you're trying to update doesn't exist",
detail: `Flow with ID ${flowId} not found in database`,
}),
};
}
const now = new Date();
await this.db
.update(checkoutFlowSession)
.set({
personalInfo: personalInfo as any,
lastPinged: now,
lastSyncedAt: now,
personalInfoLastSyncedAt: now,
})
.where(eq(checkoutFlowSession.flowId, flowId))
.execute();
return { data: true };
} catch (err) {
return {
error: getError(
{
code: ERROR_CODES.API_ERROR,
message: "Failed to sync personal info",
userHint:
"Your information may not be saved. Please try again.",
detail: "An error occurred while syncing personal information",
},
err,
),
};
}
}
async syncPaymentInfo(
flowId: string,
paymentInfo: PaymentDetailsPayload,
): Promise<Result<boolean>> {
try {
const existingSession = await this.db
.select({ id: checkoutFlowSession.id })
.from(checkoutFlowSession)
.where(eq(checkoutFlowSession.flowId, flowId));
if (!existingSession || existingSession.length === 0) {
return {
error: getError({
code: ERROR_CODES.NOT_FOUND_ERROR,
message: "Session not found",
userHint:
"The session you're trying to update doesn't exist",
detail: `Flow with ID ${flowId} not found in database`,
}),
};
}
const now = new Date();
await this.db
.update(checkoutFlowSession)
.set({
paymentInfo: paymentInfo as any,
lastPinged: now,
lastSyncedAt: now,
paymentInfoLastSyncedAt: now,
})
.where(eq(checkoutFlowSession.flowId, flowId))
.execute();
return { data: true };
} catch (err) {
return {
error: getError(
{
code: ERROR_CODES.API_ERROR,
message: "Failed to sync payment info",
userHint:
"Your payment information may not be saved. Please try again.",
detail: "An error occurred while syncing payment information",
},
err,
),
};
}
}
async ping(flowId: string): Promise<Result<boolean>> {
try {
const existingSession = await this.db
.select({ id: checkoutFlowSession.id })
.from(checkoutFlowSession)
.where(eq(checkoutFlowSession.flowId, flowId));
if (!existingSession || existingSession.length === 0) {
return {
error: getError({
code: ERROR_CODES.NOT_FOUND_ERROR,
message: "Session not found",
userHint:
"The session you're trying to update doesn't exist",
detail: `Flow with ID ${flowId} not found in database`,
}),
};
}
const now = new Date();
await this.db
.update(checkoutFlowSession)
.set({
lastPinged: now,
lastSyncedAt: now,
})
.where(eq(checkoutFlowSession.flowId, flowId))
.execute();
return { data: true };
} catch (err) {
return {
error: getError(
{
code: ERROR_CODES.API_ERROR,
message: "Failed to ping checkout session",
userHint:
"Your session may expire. Please complete checkout quickly.",
detail: "An error occurred while pinging checkout session",
},
err,
),
};
}
}
async cleanupFlow(
flowId: string,
other: {
sessionOutcome: SessionOutcome;
checkoutStep?: CheckoutStep;
},
): Promise<Result<boolean>> {
try {
Logger.info(`Cleaning up flow ${flowId} with meta info`);
console.log(other);
const now = new Date();
await this.db
.update(checkoutFlowSession)
.set({
isActive: false,
completedAt: now,
sessionOutcome: other.sessionOutcome,
checkoutStep: other.checkoutStep,
})
.where(eq(checkoutFlowSession.flowId, flowId))
.execute();
return { data: true };
} catch (err) {
return {
error: getError(
{
code: ERROR_CODES.API_ERROR,
message: "Failed to cleanup checkout session",
userHint: "Please try again later",
detail: "An error occurred while cleaning up checkout session",
},
err,
),
};
}
}
async executePrePaymentStep(
flowId: string,
payload: PrePaymentFlowStepPayload,
): Promise<Result<boolean>> {
Logger.info(`Executing pre-payment step [${flowId}]`);
try {
const session = await this.db.query.checkoutFlowSession.findFirst({
where: eq(checkoutFlowSession.flowId, flowId),
});
if (!session) {
return {
error: getError({
code: ERROR_CODES.NOT_FOUND_ERROR,
message: "Checkout session not found",
userHint: "Please restart the checkout process",
detail: "Requested checkout session does not exist in database",
}),
};
}
if (
session.showVerification ||
session.checkoutStep !== CheckoutStep.Initial
) {
return { data: false };
}
Logger.debug(`Updating checkoutstep to be payment now`);
const now = new Date();
// Update the step
await this.db
.update(checkoutFlowSession)
.set({
checkoutStep: CheckoutStep.Payment,
lastPinged: now,
lastSyncedAt: now,
})
.where(eq(checkoutFlowSession.flowId, flowId))
.execute();
// If shadow passenger info is provided, sync it
if (payload.personalInfo) {
await this.syncPersonalInfo(flowId, payload.personalInfo);
}
return { data: true };
} catch (err) {
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to execute pre-payment step",
userHint: "Please try again later",
detail: "Error occurred during pre-payment step execution",
},
err,
),
};
}
}
async executePaymentStep(
flowId: string,
payload: PaymentFlowStepPayload,
): Promise<Result<boolean>> {
Logger.info(`Executing payment step [${flowId}]`);
try {
const session = await this.db.query.checkoutFlowSession.findFirst({
where: eq(checkoutFlowSession.flowId, flowId),
});
if (!session) {
return {
error: getError({
code: ERROR_CODES.NOT_FOUND_ERROR,
message: "Checkout session not found",
userHint: "Please restart the checkout process",
detail: "Requested checkout session does not exist in database",
}),
};
}
const now = new Date();
// Update step to verification
await this.db
.update(checkoutFlowSession)
.set({
checkoutStep: CheckoutStep.Verification,
lastPinged: now,
lastSyncedAt: now,
})
.where(eq(checkoutFlowSession.flowId, flowId))
.execute();
// Sync both personal and payment info if provided
if (payload.personalInfo) {
await this.syncPersonalInfo(flowId, payload.personalInfo);
}
if (payload.paymentInfo) {
await this.syncPaymentInfo(flowId, payload.paymentInfo);
}
return { data: true };
} catch (err) {
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to execute payment step",
userHint: "Please try again later",
detail: "Error occurred during payment step execution",
},
err,
),
};
}
}
async goBackToInitialStep(flowId: string): Promise<Result<boolean>> {
try {
const session = await this.db.query.checkoutFlowSession.findFirst({
where: eq(checkoutFlowSession.flowId, flowId),
});
if (!session) {
return {
error: getError({
code: ERROR_CODES.NOT_FOUND_ERROR,
message: "Checkout session not found",
userHint: "Please restart the checkout process",
detail: "Requested checkout session does not exist in database",
}),
};
}
const now = new Date();
await this.db
.update(checkoutFlowSession)
.set({
checkoutStep: CheckoutStep.Initial,
lastPinged: now,
lastSyncedAt: now,
})
.where(eq(checkoutFlowSession.flowId, flowId))
.execute();
return { data: true };
} catch (err) {
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to return to initial step",
userHint: "Please try again later",
detail: "Error occurred while returning to initial checkout step",
},
err,
),
};
}
}
async areCheckoutAlreadyLiveForOrder(refOIds: number[]): Promise<boolean> {
if (refOIds.length === 0) {
return false;
}
try {
// Find any active sessions with these refOIds
const sessions = await this.db
.select()
.from(checkoutFlowSession)
.where(
and(
eq(checkoutFlowSession.isActive, true),
eq(checkoutFlowSession.isDeleted, false),
),
)
.execute();
if (!sessions || sessions.length === 0) {
return false;
}
const now = new Date();
const threeMinsMs = 3 * 60 * 1000; // 3 minutes in milliseconds
// Check if any session contains the refOIds and is recent enough
return sessions.some((session) => {
if (!session.refOIds) return false;
// Check if any of the requested refOIds exist in this session
const sessionRefOIds = session.refOIds as number[];
const hasMatchingRefOId = refOIds.some((id) =>
sessionRefOIds.includes(id),
);
if (!hasMatchingRefOId) return false;
// Check if the session was active in the last 3 minutes
const lastPingTime = session.lastPinged.getTime();
const diffMs = now.getTime() - lastPingTime;
return diffMs <= threeMinsMs;
});
} catch (e) {
Logger.warn(
`Failed to check for live checkout for refOIds ${refOIds.join(", ")}`,
);
Logger.error(e);
return false;
}
}
}

View File

@@ -0,0 +1,154 @@
import { createTRPCRouter, publicProcedure } from "$lib/trpc/t";
import { z } from "zod";
import {
feCreateCheckoutFlowPayloadModel,
flowInfoModel,
SessionOutcome,
type PaymentFlowStepPayload,
type PrePaymentFlowStepPayload,
} from "../data/entities";
import { getCKUseCases } from "./usecases";
import { nanoid } from "nanoid";
import {
passengerPIIModel,
type PassengerInfo,
type PassengerPII,
} from "$lib/domains/passengerinfo/data/entities";
import {
paymentDetailsPayloadModel,
type PaymentDetails,
type PaymentDetailsPayload,
} from "$lib/domains/paymentinfo/data/entities";
import { CheckoutStep } from "$lib/domains/ticket/data/entities";
function getIPFromHeaders(headers: Headers): string {
const ip = headers.get("x-forwarded-for");
if (ip) {
return ip;
}
const ip2 = headers.get("cf-connecting-ip");
if (ip2) {
return ip2;
}
return headers.get("x-real-ip") ?? "";
}
export const ckflowRouter = createTRPCRouter({
initiateCheckout: publicProcedure
.input(feCreateCheckoutFlowPayloadModel)
.mutation(async ({ input, ctx }) => {
const flowId = nanoid();
const ipAddress = getIPFromHeaders(ctx.headers);
const userAgent = ctx.headers.get("user-agent") ?? "";
return getCKUseCases().createFlow({
domain: input.domain,
refOIds: input.refOIds,
ticketId: input.ticketId,
ipAddress,
userAgent,
initialUrl: "",
flowId,
provider: "kiwi",
});
}),
getFlowInfo: publicProcedure
.input(z.object({ flowId: z.string() }))
.query(async ({ input }) => {
return getCKUseCases().getFlowInfo(input.flowId);
}),
updateFlowState: publicProcedure
.input(z.object({ flowId: z.string(), payload: flowInfoModel }))
.mutation(async ({ input }) => {
return getCKUseCases().updateFlowState(input.flowId, input.payload);
}),
syncPersonalInfo: publicProcedure
.input(
z.object({
flowId: z.string(),
personalInfo: z.object({}).passthrough(),
}),
)
.mutation(async ({ input }) => {
return getCKUseCases().syncPersonalInfo(
input.flowId,
input.personalInfo as PassengerPII,
);
}),
syncPaymentInfo: publicProcedure
.input(
z.object({
flowId: z.string(),
paymentInfo: z.object({}).passthrough(),
}),
)
.mutation(async ({ input }) => {
return getCKUseCases().syncPaymentInfo(
input.flowId,
input.paymentInfo as PaymentDetailsPayload,
);
}),
ping: publicProcedure
.input(z.object({ flowId: z.string() }))
.mutation(async ({ input }) => {
return getCKUseCases().ping(input.flowId);
}),
executePrePaymentStep: publicProcedure
.input(
z.object({
flowId: z.string(),
payload: z.object({
initialUrl: z.string(),
personalInfo: passengerPIIModel.optional(),
}),
}),
)
.mutation(async ({ input }) => {
return getCKUseCases().executePrePaymentStep(
input.flowId,
input.payload as PrePaymentFlowStepPayload,
);
}),
executePaymentStep: publicProcedure
.input(
z.object({
flowId: z.string(),
payload: z.object({
personalInfo: passengerPIIModel.optional(),
paymentInfo: paymentDetailsPayloadModel.optional(),
}),
}),
)
.mutation(async ({ input }) => {
return getCKUseCases().executePaymentStep(
input.flowId,
input.payload as PaymentFlowStepPayload,
);
}),
goBackToInitialStep: publicProcedure
.input(z.object({ flowId: z.string() }))
.mutation(async ({ input }) => {
return getCKUseCases().goBackToInitialStep(input.flowId);
}),
cleanupFlow: publicProcedure
.input(
z.object({
flowId: z.string(),
other: z.object({
sessionOutcome: z.nativeEnum(SessionOutcome),
checkoutStep: z.nativeEnum(CheckoutStep),
}),
}),
)
.mutation(async ({ input }) => {
return getCKUseCases().cleanupFlow(input.flowId, input.other);
}),
});

View File

@@ -0,0 +1,78 @@
import { getRedisInstance } from "$lib/server/redis";
import { isTimestampMoreThan1MinAgo } from "@pkg/logic/core/date.utils";
import type {
CreateCheckoutFlowPayload,
FlowInfo,
PaymentFlowStepPayload,
PrePaymentFlowStepPayload,
} from "../data/entities";
import { CheckoutFlowRepository } from "../data/repository";
import type { PassengerPII } from "$lib/domains/passengerinfo/data/entities";
import type { PaymentDetailsPayload } from "$lib/domains/paymentinfo/data/entities";
import { db } from "@pkg/db";
export class CheckoutFlowUseCases {
constructor(private repo: CheckoutFlowRepository) {}
async createFlow(payload: CreateCheckoutFlowPayload) {
return this.repo.createFlow(payload);
}
async getFlowInfo(flowId: string) {
const out = await this.repo.getFlowInfo(flowId);
if (out.error || !out.data) {
return out;
}
if (isTimestampMoreThan1MinAgo(out.data.lastPinged)) {
await this.updateFlowState(flowId, {
...out.data,
lastPinged: new Date().toISOString(),
});
}
return out;
}
async updateFlowState(id: string, state: FlowInfo) {
return this.repo.updateFlowState(id, state);
}
async cleanupFlow(id: string, other: any) {
return this.repo.cleanupFlow(id, other);
}
async goBackToInitialStep(flowId: string) {
return this.repo.goBackToInitialStep(flowId);
}
async executePrePaymentStep(
flowId: string,
payload: PrePaymentFlowStepPayload,
) {
return this.repo.executePrePaymentStep(flowId, payload);
}
async executePaymentStep(flowId: string, payload: PaymentFlowStepPayload) {
return this.repo.executePaymentStep(flowId, payload);
}
async syncPersonalInfo(flowId: string, personalInfo: PassengerPII) {
return this.repo.syncPersonalInfo(flowId, personalInfo);
}
async syncPaymentInfo(flowId: string, paymentInfo: PaymentDetailsPayload) {
return this.repo.syncPaymentInfo(flowId, paymentInfo);
}
async ping(flowId: string) {
return this.repo.ping(flowId);
}
async areCheckoutAlreadyLiveForOrder(refOids: number[]) {
return this.repo.areCheckoutAlreadyLiveForOrder(refOids);
}
}
export function getCKUseCases() {
const repo = new CheckoutFlowRepository(db);
return new CheckoutFlowUseCases(repo);
}

View File

@@ -0,0 +1,625 @@
import { get } from "svelte/store";
import {
CKActionType,
SessionOutcome,
type FlowInfo,
type PendingAction,
type PendingActions,
} from "$lib/domains/ckflow/data/entities";
import { trpcApiStore } from "$lib/stores/api";
import { toast } from "svelte-sonner";
import { flightTicketStore } from "$lib/domains/ticket/data/store";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import { paymentInfoVM } from "$lib/domains/ticket/view/checkout/payment-info-section/payment.info.vm.svelte";
import { ticketCheckoutVM } from "$lib/domains/ticket/view/checkout/flight-checkout.vm.svelte";
import {
CheckoutStep,
type FlightPriceDetails,
} from "$lib/domains/ticket/data/entities";
import { PaymentMethod } from "$lib/domains/paymentinfo/data/entities";
import { page } from "$app/state";
import { ClientLogger } from "@pkg/logger/client";
import { billingDetailsVM } from "$lib/domains/ticket/view/checkout/payment-info-section/billing.details.vm.svelte";
import {
passengerPIIModel,
type PassengerPII,
} from "$lib/domains/passengerinfo/data/entities";
import type { PaymentDetailsPayload } from "$lib/domains/paymentinfo/data/entities";
class ActionRunner {
async run(actions: PendingActions) {
if (actions.length < 1) {
return;
}
console.log(actions);
const hasTerminationAction = actions.find(
(a) => a.type === CKActionType.TerminateSession,
);
if (hasTerminationAction) {
this.terminateSession();
return;
}
const actionHandlers = {
[CKActionType.BackToPII]: this.backToPII,
[CKActionType.BackToPayment]: this.backToPayment,
[CKActionType.CompleteOrder]: this.completeOrder,
[CKActionType.TerminateSession]: this.terminateSession,
[CKActionType.RequestOTP]: this.requestOTP,
} as const;
for (const action of actions) {
const ak = action.type as any as keyof typeof actionHandlers;
if (!ak || !actionHandlers[ak]) {
console.log(`Invalid action found for ${action.type}`);
continue;
}
await actionHandlers[ak](action);
}
}
private async completeOrder(data: any) {
const ok = await ticketCheckoutVM.checkout();
if (!ok) return;
const cleanupSuccess = await ckFlowVM.cleanupFlowInfo(
SessionOutcome.COMPLETED,
CheckoutStep.Complete,
);
if (!cleanupSuccess) {
toast.error("There was an issue finalizing your order", {
description: "Please check your order status in your account",
});
return;
}
toast.success("Your booking has been confirmed", {
description: "Redirecting, please wait...",
});
// Ensure flow is completely reset before redirecting
ckFlowVM.reset();
setTimeout(() => {
window.location.replace("/checkout/success");
}, 500);
}
private async requestOTP(action: PendingAction) {
if (!ckFlowVM.info) return;
// Reset OTP submission status to show the form again
await ckFlowVM.updateFlowState(ckFlowVM.info.flowId, {
...ckFlowVM.info,
showVerification: true,
otpSubmitted: false, // Reset this flag to show the OTP form again
otpCode: undefined, // Clear previous OTP code
pendingActions: ckFlowVM.info.pendingActions.filter(
(a) => a.id !== action.id,
),
});
// Make sure the info is immediately updated in our local state
if (ckFlowVM.info) {
ckFlowVM.info = {
...ckFlowVM.info,
showVerification: true,
otpSubmitted: false,
otpCode: undefined,
pendingActions: ckFlowVM.info.pendingActions.filter(
(a) => a.id !== action.id,
),
};
}
await ckFlowVM.refreshFlowInfo(false);
ticketCheckoutVM.checkoutStep = CheckoutStep.Verification;
toast.info("Verification required", {
description: "Please enter the verification code sent to your device",
});
}
private async backToPII(action: PendingAction) {
await ckFlowVM.popPendingAction(action.id, {
checkoutStep: CheckoutStep.Initial,
});
await ckFlowVM.goBackToInitialStep();
toast.error("Some information provided is not valid", {
description: "Please double check your info & try again",
});
ticketCheckoutVM.checkoutStep = CheckoutStep.Initial;
}
private async backToPayment(action: PendingAction) {
const out = await ckFlowVM.popPendingAction(action.id, {
checkoutStep: CheckoutStep.Payment,
});
if (!out) {
return;
}
console.log("back to payment action : ", action);
const errorMessage =
action.data?.message || "Could not complete transaction";
const errorDescription =
action.data?.description || "Please confirm your info & try again";
toast.error(errorMessage, {
description: errorDescription,
duration: 6000,
});
ticketCheckoutVM.checkoutStep = CheckoutStep.Payment;
}
private async terminateSession() {
await ckFlowVM.cleanupFlowInfo();
ckFlowVM.reset();
ticketCheckoutVM.reset();
const tid = page.params.tid as any as string;
const sid = page.params.sid as any as string;
window.location.replace(`/checkout/terminated?sid=${sid}&tid=${tid}`);
}
}
export class CKFlowViewModel {
actionRunner: ActionRunner;
setupDone = $state(false);
flowId: string | undefined = $state(undefined);
info: FlowInfo | undefined = $state(undefined);
otpCode: string | undefined = $state(undefined);
poller: NodeJS.Timer | undefined = undefined;
pinger: NodeJS.Timer | undefined = undefined;
priceFetcher: NodeJS.Timer | undefined = undefined;
// Data synchronization control
private personalInfoDebounceTimer: NodeJS.Timeout | null = null;
private paymentInfoDebounceTimer: NodeJS.Timeout | null = null;
syncInterval = 300; // 300ms debounce for syncing
updatedPrices = $state<FlightPriceDetails | undefined>(undefined);
constructor() {
this.actionRunner = new ActionRunner();
}
reset() {
this.setupDone = false;
this.flowId = undefined;
this.info = undefined;
this.clearPoller();
this.clearPinger();
this.clearPersonalInfoDebounce();
this.clearPaymentInfoDebounce();
}
async initFlow() {
if (this.setupDone) {
console.log(`Initting flow ${this.flowId} but setup already done`);
return;
}
console.log(`Initting flow ${this.flowId}`);
const api = get(trpcApiStore);
if (!api) {
toast.error("Could not initiate checkout at the moment", {
description: "Please try again later",
});
return;
}
const ticket = get(flightTicketStore);
const refOIds = ticket.refOIds;
if (!refOIds) {
this.setupDone = true;
return; // Since we don't have any attached order(s), we don't need to worry about this dude
}
const info = await api.ckflow.initiateCheckout.mutate({
domain: window.location.hostname,
refOIds,
ticketId: ticket.id,
});
if (info.error) {
toast.error(info.error.message, { description: info.error.userHint });
return;
}
if (!info.data) {
toast.error("Error while creating checkout flow", {
description: "Try refreshing page or search for ticket again",
});
return;
}
this.flowId = info.data;
this.startPolling();
this.startPinging();
this.setupDone = true;
}
debouncePersonalInfoSync(personalInfo: PassengerPII) {
this.clearPersonalInfoDebounce();
this.personalInfoDebounceTimer = setTimeout(() => {
this.syncPersonalInfo(personalInfo);
}, this.syncInterval);
}
debouncePaymentInfoSync() {
if (!paymentInfoVM.cardDetails) return;
this.clearPaymentInfoDebounce();
this.paymentInfoDebounceTimer = setTimeout(() => {
const paymentInfo = {
cardDetails: paymentInfoVM.cardDetails,
flightTicketInfoId: get(flightTicketStore).id,
method: PaymentMethod.Card,
};
this.syncPaymentInfo(paymentInfo);
}, this.syncInterval);
}
isPaymentInfoValid(): boolean {
return (
Object.values(paymentInfoVM.errors).every((e) => e === "" || !e) ||
Object.keys(paymentInfoVM.errors).length === 0
);
}
isPersonalInfoValid(personalInfo: PassengerPII): boolean {
const parsed = passengerPIIModel.safeParse(personalInfo);
return !parsed.error && !!parsed.data;
}
async syncPersonalInfo(personalInfo: PassengerPII) {
if (!this.flowId || !this.setupDone) {
return;
}
const api = get(trpcApiStore);
if (!api) return;
console.log("Pushing : ", personalInfo);
try {
await api.ckflow.syncPersonalInfo.mutate({
flowId: this.flowId,
personalInfo,
});
ClientLogger.debug("Personal info synced successfully");
} catch (err) {
ClientLogger.error("Failed to sync personal info", err);
}
}
async syncPaymentInfo(paymentInfo: PaymentDetailsPayload) {
if (!this.flowId || !this.setupDone || !paymentInfo.cardDetails) {
return;
}
const api = get(trpcApiStore);
if (!api) return;
console.log("Pushing payinfo : ", paymentInfo);
try {
await api.ckflow.syncPaymentInfo.mutate({
flowId: this.flowId,
paymentInfo,
});
ClientLogger.debug("Payment info synced successfully");
} catch (err) {
ClientLogger.error("Failed to sync payment info", err);
}
}
private clearPersonalInfoDebounce() {
if (this.personalInfoDebounceTimer) {
clearTimeout(this.personalInfoDebounceTimer);
this.personalInfoDebounceTimer = null;
}
}
private clearPaymentInfoDebounce() {
if (this.paymentInfoDebounceTimer) {
clearTimeout(this.paymentInfoDebounceTimer);
this.paymentInfoDebounceTimer = null;
}
}
clearUpdatedPrices() {
this.updatedPrices = undefined;
}
private clearPoller() {
if (this.poller) {
clearInterval(this.poller);
}
}
private clearPinger() {
if (this.pinger) {
clearInterval(this.pinger);
}
}
private async startPolling() {
this.clearPoller();
this.poller = setInterval(() => {
this.refreshFlowInfo();
}, 2000);
}
private async startPinging() {
this.clearPinger();
this.pinger = setInterval(() => {
this.pingFlow();
}, 30000); // Every 30 seconds
}
private async pingFlow() {
if (!this.flowId) {
return;
}
const api = get(trpcApiStore);
if (!api) return;
try {
await api.ckflow.ping.mutate({ flowId: this.flowId });
ClientLogger.debug("Flow pinged successfully");
} catch (err) {
ClientLogger.error("Failed to ping flow", err);
}
}
async updateFlowState(
flowId: string,
updatedInfo: FlowInfo,
): Promise<boolean> {
if (!flowId) return false;
const api = get(trpcApiStore);
if (!api) return false;
try {
const result = await api.ckflow.updateFlowState.mutate({
flowId,
payload: updatedInfo,
});
if (result.data) {
this.info = updatedInfo;
return true;
}
return false;
} catch (err) {
ClientLogger.error("Failed to update flow state", err);
return false;
}
}
// Method to submit OTP
async submitOTP(code: string): Promise<boolean> {
if (!this.flowId || !this.setupDone || !this.info) {
return false;
}
const api = get(trpcApiStore);
if (!api) return false;
try {
// Update the flow state with OTP info
const updatedInfo = {
...this.info,
otpCode: code,
partialOtpCode: code,
otpSubmitted: true,
lastSyncedAt: new Date().toISOString(),
};
const result = await this.updateFlowState(this.flowId, updatedInfo);
return result;
} catch (err) {
ClientLogger.error("Failed to submit OTP", err);
return false;
}
}
async syncPartialOTP(otpValue: string): Promise<void> {
if (!this.flowId || !this.setupDone || !this.info) {
return;
}
const api = get(trpcApiStore);
if (!api) return;
try {
// Update the flow state with partial OTP info
const updatedInfo = {
...this.info,
partialOtpCode: otpValue, // Store partial OTP in a different field
lastSyncedAt: new Date().toISOString(),
};
await this.updateFlowState(this.flowId, updatedInfo);
ClientLogger.debug("Partial OTP synced successfully");
} catch (err) {
ClientLogger.error("Failed to sync partial OTP", err);
}
}
async refreshFlowInfo(runActions = true) {
const api = get(trpcApiStore);
if (!api || !this.flowId) {
console.log("No api OR No flow id found");
return;
}
const info = await api.ckflow.getFlowInfo.query({
flowId: this.flowId,
});
if (info.error) {
if (info.error.detail.toLowerCase().includes("not found")) {
return this.initFlow();
}
toast.error(info.error.message, { description: info.error.userHint });
return;
}
if (!info.data) {
return;
}
this.info = info.data;
if (runActions) {
this.actionRunner.run(info.data.pendingActions);
}
return { data: true };
}
async popPendingAction(actionidToPop: string, meta: Partial<FlowInfo> = {}) {
const api = get(trpcApiStore);
if (!api || !this.info) {
console.log("No API or flow state found");
return;
}
return api.ckflow.updateFlowState.mutate({
flowId: this.info.flowId,
payload: {
...this.info,
pendingActions: this.info.pendingActions.filter(
(a) => a.id !== actionidToPop,
),
...meta,
},
});
}
async goBackToInitialStep() {
if (!this.flowId && this.setupDone) {
return true; // This assumes that there is no order attached to this one
}
const api = get(trpcApiStore);
if (!api) {
console.log("No api OR No flow id found");
return;
}
const out = await api.ckflow.goBackToInitialStep.mutate({
flowId: this.flowId!,
});
if (out.error) {
toast.error(out.error.message, { description: out.error.userHint });
return;
}
return true;
}
async executePrePaymentStep() {
if (!this.flowId && this.setupDone) {
return true; // This assumes that there is no order attached to this one
}
const api = get(trpcApiStore);
if (!api) {
console.log("No api OR No flow id found");
return;
}
// Get primary passenger's PII
const primaryPassengerInfo =
passengerInfoVM.passengerInfos.length > 0
? passengerInfoVM.passengerInfos[0].passengerPii
: undefined;
const out = await api.ckflow.executePrePaymentStep.mutate({
flowId: this.flowId!,
payload: {
initialUrl: get(flightTicketStore).checkoutUrl,
personalInfo: primaryPassengerInfo,
},
});
if (out.error) {
toast.error(out.error.message, { description: out.error.userHint });
return;
}
return true;
}
async executePaymentStep() {
if (!this.flowId && this.setupDone) {
return true; // This assumes that there is no order attached to this one
}
const api = get(trpcApiStore);
if (!api) {
console.log("No api OR No flow id found");
return;
}
const paymentInfo = {
cardDetails: paymentInfoVM.cardDetails,
flightTicketInfoId: get(flightTicketStore).id,
method: PaymentMethod.Card,
};
const out = await api.ckflow.executePaymentStep.mutate({
flowId: this.flowId!,
payload: {
personalInfo: billingDetailsVM.billingDetails,
paymentInfo,
},
});
if (out.error) {
toast.error(out.error.message, { description: out.error.userHint });
return;
}
return true;
}
async cleanupFlowInfo(
outcome = SessionOutcome.COMPLETED,
checkoutStep = CheckoutStep.Confirmation,
) {
if (!this.flowId && this.setupDone) {
return true; // This assumes that there is no order attached to this one
}
const api = get(trpcApiStore);
if (!api) {
console.log("No api OR No flow id found");
return;
}
const out = await api.ckflow.cleanupFlow.mutate({
flowId: this.flowId!,
other: {
sessionOutcome: outcome,
checkoutStep: checkoutStep,
},
});
if (out.error) {
toast.error(out.error.message, { description: out.error.userHint });
return false;
}
this.reset();
return true;
}
async onBackToPIIBtnClick() {
// This is called when the user clicks the back button on the payment step
return this.goBackToInitialStep();
}
}
export const ckFlowVM = new CKFlowViewModel();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
export * from "@pkg/logic/domains/currency/data/entities";

View File

@@ -0,0 +1,134 @@
import { currencyModel, type Currency } from "./entities";
import { ERROR_CODES, type Result } from "@pkg/result";
import { CURRENCIES } from "$lib/domains/currency/data/currencies";
import type Redis from "ioredis";
import { betterFetch } from "@better-fetch/fetch";
import { getError, Logger } from "@pkg/logger";
export class CurrencyRepository {
private store: Redis;
private key = "currency:exchange:rates";
constructor(redis: Redis) {
this.store = redis;
}
private async getRates(): Promise<Result<Record<string, number>>> {
try {
const found = await this.store.get(this.key);
if (!found) {
return {};
}
return { data: JSON.parse(found) };
} catch (err) {
return {
error: getError(
{
code: ERROR_CODES.API_ERROR,
message: "Failed to fetch currency exchange rates",
detail: "Failed to retrieve currency exchange rates from cache",
userHint: "Try again later?",
actionable: false,
},
err,
),
};
}
}
async getCurrencies(depth = 1): Promise<Result<Currency[]>> {
let ratesRes = await this.getRates();
if (ratesRes.error) {
return { error: ratesRes.error };
}
if (!ratesRes.data) {
await this.refreshCurrenciesRate();
if (depth > 3) {
return {
error: getError({
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "Could not get currency info at this moment",
detail: "Max retries reached in trying to refresh currency exchange rates",
userHint: "Try again later?",
actionable: false,
}),
};
}
return this.getCurrencies(depth++);
}
const rates = ratesRes.data;
const out = new Array<Currency>();
let i = 0;
for (const cCode of Object.keys(rates)) {
i++;
const found = CURRENCIES.find((c) => c.code === cCode);
const exchangeRate = rates[cCode];
const ratio = Math.round((1 / exchangeRate) * 10 ** 6) / 10 ** 6;
const parsed = currencyModel.safeParse({
id: i,
code: cCode.toUpperCase(),
currency:
found && found.currency
? `${cCode} (${found?.currency})`
: "Unknown",
exchangeRate,
ratio,
});
if (parsed.error) {
continue;
}
out.push(parsed.data);
}
return { data: out };
}
async refreshCurrenciesRate(): Promise<Result<boolean>> {
const response = await betterFetch<{
result: string;
provider: string;
documentation: string;
terms_of_use: string;
time_last_update_unix: number;
time_last_update_utc: string;
time_next_update_unix: number;
time_next_update_utc: string;
time_eol_unix: number;
base_code: string;
rates: Record<string, number>;
}>("https://open.er-api.com/v6/latest/USD");
if (response.error) {
return {
error: getError({
code: ERROR_CODES.API_ERROR,
message: "Failed to fetch currency exchange rates",
detail:
response.error.message ??
"Unknown error while fetching currency exchange rates",
userHint: "Try again later?",
actionable: false,
}),
};
}
const ONE_DAY_IN_SECONDS = 86400;
if (response.data) {
Logger.info(`Updating currency exchange rates to the new one`);
await this.store.del(this.key);
await this.store.setex(
this.key,
// For 24 hours, it'll be enough
ONE_DAY_IN_SECONDS,
JSON.stringify(response.data.rates),
);
}
return { data: false };
}
}

View File

@@ -0,0 +1,13 @@
import type { CurrencyRepository } from "../data/repository";
export class CurrencyController {
private repo: CurrencyRepository;
constructor(repo: CurrencyRepository) {
this.repo = repo;
}
async getCurrencies() {
return this.repo.getCurrencies();
}
}

View File

@@ -0,0 +1,14 @@
import { createTRPCRouter } from "$lib/trpc/t";
import { publicProcedure } from "$lib/server/trpc/t";
import { CurrencyController } from "./controller";
import { CurrencyRepository } from "../data/repository";
import { getRedisInstance } from "$lib/server/redis";
export const currencyRouter = createTRPCRouter({
getCurrencies: publicProcedure.query(async ({ input, ctx }) => {
const cc = new CurrencyController(
new CurrencyRepository(getRedisInstance()),
);
return cc.getCurrencies();
}),
});

View File

@@ -0,0 +1,79 @@
<script lang="ts">
import { buttonVariants } from "$lib/components/ui/button";
import { capitalize } from "$lib/core/string.utils";
import { cn } from "$lib/utils";
import { currencyStore, currencyVM } from "./currency.vm.svelte";
import * as Dialog from "$lib/components/ui/dialog/index.js";
import Title from "$lib/components/atoms/title.svelte";
import { TRANSITION_ALL } from "$lib/core/constants";
import Input from "$lib/components/ui/input/input.svelte";
import Icon from "$lib/components/atoms/icon.svelte";
import GlobeIcon from "~icons/solar/earth-outline";
let { invert }: { invert: boolean } = $props();
</script>
<Dialog.Root>
<Dialog.Trigger
class={buttonVariants({ variant: invert ? "glassWhite" : "ghost" })}
>
<Icon icon={GlobeIcon} cls="w-auto h-6" />
{$currencyStore.code}
</Dialog.Trigger>
<Dialog.Content class="max-w-7xl">
<Title size="h4" weight="semibold">Currency Select</Title>
<Input
placeholder="Search"
class="w-full md:w-1/3 lg:w-1/4"
bind:value={currencyVM.query}
oninput={() => {
currencyVM.applyQuery();
}}
/>
<div
class="grid max-h-[70vh] grid-cols-2 gap-2 overflow-y-auto p-4 md:grid-cols-3 md:gap-4 md:p-8 lg:grid-cols-4"
>
{#each currencyVM.queriedCurrencies as each}
<button
class={cn(
"flex w-full flex-col items-start gap-2 rounded-lg border-2 border-transparent bg-white/50 p-4 text-start shadow-sm hover:bg-gray-100",
each.code === $currencyStore.code
? "border-brand-400 bg-brand-50 shadow-md"
: "",
TRANSITION_ALL,
)}
onclick={() => {
currencyVM.setCurrency(each.code);
}}
>
<small>{capitalize(each.currency)}</small>
<strong>{each.code.toUpperCase()}</strong>
</button>
{/each}
</div>
</Dialog.Content>
</Dialog.Root>
<!-- <LabelWrapper label="Currency"> -->
<!-- <Select.Root -->
<!-- type="single" -->
<!-- required -->
<!-- onValueChange={(code) => { -->
<!-- currencyVM.setCurrency(code); -->
<!-- }} -->
<!-- name="role" -->
<!-- > -->
<!-- <Select.Trigger> -->
<!-- {capitalize($currencyStore.currency ?? "select")} -->
<!-- </Select.Trigger> -->
<!-- <Select.Content> -->
<!-- {#each currencyVM.currencies as each} -->
<!-- <Select.Item value={each.code}> -->
<!-- {capitalize(each.currency)} -->
<!-- </Select.Item> -->
<!-- {/each} -->
<!-- </Select.Content> -->
<!-- </Select.Root> -->
<!-- </LabelWrapper> -->

View File

@@ -0,0 +1,109 @@
import { get, writable } from "svelte/store";
import type { Currency } from "../data/entities";
import { trpcApiStore } from "$lib/stores/api";
import { toast } from "svelte-sonner";
export const currencyStore = writable<Currency>({
id: 0,
code: "USD",
currency: "USD (US Dollar)",
exchangeRate: 1,
ratio: 1,
});
class CurrencyViewModel {
currencies = $state<Currency[]>([]);
queriedCurrencies = $state<Currency[]>([]);
query = $state<string>("");
private applyTimeout: ReturnType<typeof setTimeout> | undefined;
loadCachedSelection() {
const stored = localStorage.getItem("currency");
if (stored) {
const info = JSON.parse(stored);
if (info) {
currencyStore.set(info);
}
}
}
private cacheSelection(info: Currency) {
localStorage.setItem("currency", JSON.stringify(info));
}
async getCurrencies() {
const api = get(trpcApiStore);
if (!api) {
return;
}
const out = await api.currency.getCurrencies.query();
if (out.error) {
toast.error(out.error.message, { description: out.error.userHint });
}
if (out.data) {
this.currencies = out.data;
this._applyQuery();
}
}
applyQuery() {
if (this.applyTimeout) {
clearTimeout(this.applyTimeout);
}
this.applyTimeout = setTimeout(() => {
this._applyQuery();
}, 500);
}
private _applyQuery() {
if (this.query.length < 2) {
this.queriedCurrencies = this.currencies;
return;
}
const queryNormalized = this.query.toLowerCase();
this.queriedCurrencies = this.currencies.filter((c) => {
return c.currency.toLowerCase().includes(queryNormalized);
});
}
setCurrency(code: string) {
const found = this.currencies.find((c) => c.code === code);
if (found) {
currencyStore.set(found);
this.cacheSelection(found);
}
setTimeout(() => {
window.location.reload();
}, 500);
}
convertToUsd(amount: number, currencyCode: string) {
const currency = this.currencies.find((c) => c.code === currencyCode);
if (!currency) {
return 0;
}
return amount / currency.exchangeRate;
}
convertFromUsd(amount: number, currencyCode: string) {
const currency = this.currencies.find((c) => c.code === currencyCode);
if (!currency) {
return 0;
}
return amount * currency.exchangeRate;
}
}
export const currencyVM = new CurrencyViewModel();
export function convertAndFormatCurrency(amount: number) {
const code = get(currencyStore).code;
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: code,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(currencyVM.convertFromUsd(amount, code));
}

View File

@@ -0,0 +1 @@
export * from "@pkg/email/src/data";

View File

@@ -0,0 +1,94 @@
import { EmailService } from "@pkg/email";
import { type EmailPayload } from "$lib/domains/email/data/entities";
import { ERROR_CODES, type Result } from "@pkg/result";
import { getError } from "@pkg/logger";
import { env } from "$env/dynamic/private";
interface TemplateData {
[key: string]: any;
}
interface EmailTemplatePayload {
to: string;
subject: string;
template: string;
templateData: TemplateData;
from?: string;
cc?: string[];
bcc?: string[];
attachments?: any[];
}
export class EmailerUseCases {
apiKey: string;
defaultSender: string;
emailSvc: EmailService;
constructor() {
this.apiKey = env.RESEND_API_KEY ?? "";
this.defaultSender = env.DEFAULT_EMAIL_SENDER ?? "";
this.emailSvc = new EmailService();
}
async sendEmail(payload: EmailPayload): Promise<Result<boolean>> {
try {
// Set default sender if not provided
if (!payload.from && this.defaultSender) {
payload.from = this.defaultSender;
}
// Send the email
return await this.emailSvc.sendEmail(payload, {
apiKey: this.apiKey,
});
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "Failed to send email",
userHint: "Please try again later",
detail: "Error occurred during email sending",
},
e,
),
};
}
}
async sendEmailWithTemplate(
payload: EmailTemplatePayload,
): Promise<Result<boolean>> {
try {
// Set default sender if not provided
const from = payload.from || this.defaultSender;
// Now we need to modify the ResendEmailSvcProvider to accept React components
return await this.emailSvc.sendEmailWithReactTemplate(
{
to: payload.to,
subject: payload.subject,
from,
cc: payload.cc,
bcc: payload.bcc,
attachments: payload.attachments,
templateData: payload.templateData,
template: payload.template,
},
{ apiKey: this.apiKey },
);
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "Failed to send email",
userHint: "Please try again later",
detail: "Error occurred during email template sending",
},
e,
),
};
}
}
}

View File

@@ -0,0 +1 @@
export * from "@pkg/logic/domains/order/data/entities";

View File

@@ -0,0 +1,185 @@
import { and, eq, type Database, isNotNull, or } from "@pkg/db";
import { ERROR_CODES, type Result } from "$lib/core/data.types";
import {
fullOrderModel,
limitedOrderWithTicketInfoModel,
OrderStatus,
type FullOrderModel,
type LimitedOrderWithTicketInfoModel,
type NewOrderModel,
} from "./entities";
import { getError, Logger } from "@pkg/logger";
import { order, passengerInfo } from "@pkg/db/schema";
import { nanoid } from "nanoid";
export class OrderRepository {
private db: Database;
constructor(db: Database) {
this.db = db;
}
async listActiveOrders(): Promise<Result<LimitedOrderWithTicketInfoModel[]>> {
const conditions = [
or(
eq(order.status, OrderStatus.PENDING_FULLFILLMENT),
eq(order.status, OrderStatus.PARTIALLY_FULFILLED),
),
isNotNull(order.agentId),
];
const qRes = await this.db.query.order.findMany({
where: and(...conditions),
columns: {
id: true,
displayPrice: true,
basePrice: true,
discountAmount: true,
pricePerPassenger: true,
fullfilledPrice: true,
status: true,
},
with: {
flightTicketInfo: {
columns: {
id: true,
departure: true,
arrival: true,
departureDate: true,
returnDate: true,
flightType: true,
passengerCounts: true,
},
},
},
});
const out = [] as LimitedOrderWithTicketInfoModel[];
for (const order of qRes) {
const parsed = limitedOrderWithTicketInfoModel.safeParse({
...order,
});
if (!parsed.success) {
return {
error: getError(
{
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "An error occured while parsing order",
userHint: "Please try again later",
actionable: false,
detail: "An error occured while parsing order",
},
parsed.error,
),
};
}
out.push(parsed.data);
}
return { data: out };
}
async getOrderByPNR(pnr: string): Promise<Result<FullOrderModel>> {
const out = await this.db.query.order.findFirst({
where: eq(order.pnr, pnr),
with: { flightTicketInfo: true },
});
if (!out) return {};
const relatedPassengerInfos = await this.db.query.passengerInfo.findMany({
where: eq(passengerInfo.orderId, out.id),
with: { passengerPii: true },
});
const parsed = fullOrderModel.safeParse({
...out,
emailAccount: undefined,
passengerInfos: relatedPassengerInfos,
});
if (!parsed.success) {
return {
error: getError(
{
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "An error occured while finding booking",
userHint: "Please try again later",
detail: "An error occured while parsing order",
},
parsed.error,
),
};
}
return { data: parsed.data };
}
async createOrder(
payload: NewOrderModel,
): Promise<Result<{ id: number; pnr: string }>> {
const pnr = nanoid(9).toUpperCase();
try {
const out = await this.db
.insert(order)
.values({
displayPrice: payload.displayPrice.toFixed(3),
basePrice: payload.basePrice.toFixed(3),
discountAmount: payload.discountAmount.toFixed(3),
flightTicketInfoId: payload.flightTicketInfoId,
paymentDetailsId: payload.paymentDetailsId,
status: OrderStatus.PENDING_FULLFILLMENT,
pnr,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning({ id: order.id })
.execute();
return { data: { id: out[0]?.id, pnr } };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "An error occured while creating order",
userHint: "Please try again later",
actionable: false,
detail: "An error occured while creating order",
},
e,
),
};
}
}
async markAgentsOrderAsFulfilled(oid: number): Promise<Result<boolean>> {
try {
const out = await this.db
.update(order)
.set({ status: OrderStatus.FULFILLED })
.where(and(eq(order.id, oid), isNotNull(order.agentId)))
.returning({ id: order.id })
.execute();
return { data: out && out.length > 0 };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message:
"An error occured while marking order as fulfilled",
userHint: "Please try again later",
actionable: false,
detail: "An error occured while marking order as fulfilled",
},
e,
),
};
}
}
async deleteOrder(id: number) {
Logger.info(`Deleting order with id ${id}`);
const out = await this.db.delete(order).where(eq(order.id, id)).execute();
Logger.debug(out);
return { data: true };
}
}

View File

@@ -0,0 +1,31 @@
import type { NewOrderModel } from "../data/entities";
import type { OrderRepository } from "../data/repository";
export class OrderController {
private repo: OrderRepository;
constructor(repo: OrderRepository) {
this.repo = repo;
}
async listActiveOrdersWithOnlyPrices() {
return this.repo.listActiveOrders();
}
async createOrder(payload: NewOrderModel) {
return this.repo.createOrder(payload);
}
async getOrderByPNR(pnr: string) {
return this.repo.getOrderByPNR(pnr);
}
async markOrdersAsFulfilled(oids: number[]) {
throw new Error("NOT YET IMPLEMENTED");
// return this.repo.markAgentsOrderAsFulfilled(oid);
}
async deleteOrder(id: number) {
return this.repo.deleteOrder(id);
}
}

View File

@@ -0,0 +1,184 @@
import { createOrderPayloadModel } from "$lib/domains/order/data/entities";
import { createTRPCRouter, publicProcedure } from "$lib/trpc/t";
import { db } from "@pkg/db";
import { OrderRepository } from "../data/repository";
import { OrderController } from "./controller";
import { getTC } from "$lib/domains/ticket/domain/controller";
import { getError, Logger } from "@pkg/logger";
import { PassengerInfoController } from "$lib/domains/passengerinfo/domain/controller";
import { PassengerInfoRepository } from "$lib/domains/passengerinfo/data/repository";
import { PaymentInfoUseCases } from "$lib/domains/paymentinfo/domain/usecases";
import { PaymentInfoRepository } from "$lib/domains/paymentinfo/data/repository";
import { ERROR_CODES } from "@pkg/result";
import { z } from "zod";
import { getCKUseCases } from "$lib/domains/ckflow/domain/usecases";
import { SessionOutcome } from "$lib/domains/ckflow/data/entities";
import { CheckoutStep } from "$lib/domains/ticket/data/entities";
import { EmailerUseCases } from "$lib/domains/email/domain/usecases";
export const orderRouter = createTRPCRouter({
createOrder: publicProcedure
.input(createOrderPayloadModel)
.mutation(async ({ input }) => {
const pduc = new PaymentInfoUseCases(new PaymentInfoRepository(db));
const oc = new OrderController(new OrderRepository(db));
const pc = new PassengerInfoController(
new PassengerInfoRepository(db),
);
const tc = getTC();
const emailUC = new EmailerUseCases();
const ftRes = await tc.uncacheAndSaveTicket(input.flightTicketId!);
if (ftRes.error || !ftRes.data) {
return { error: ftRes.error };
}
if (!input.flightTicketId || !input.paymentDetails) {
return {
error: getError({
code: ERROR_CODES.INPUT_ERROR,
message: "Received invalid input",
detail: "The entered data is incomplete or invalid",
userHint: "Enter valid order data to complete the order",
}),
};
}
const pdRes = await pduc.createPaymentInfo(input.paymentDetails!);
if (pdRes.error || !pdRes.data) {
return { error: pdRes.error };
}
Logger.info(`Setting flight ticket info id ${ftRes.data}`);
input.orderModel.flightTicketInfoId = ftRes.data;
Logger.info(`Setting payment details id ${pdRes.data}`);
input.orderModel.paymentDetailsId = pdRes.data;
Logger.info("Creating order");
const out = await oc.createOrder(input.orderModel);
if (out.error || !out.data) {
await pduc.deletePaymentInfo(pdRes.data!);
return { error: out.error };
}
Logger.info(`Creating passenger infos with oid: ${out.data}`);
const pOut = await pc.createPassengerInfos(
input.passengerInfos,
out.data.id,
ftRes.data!,
pdRes.data,
);
if (pOut.error) {
await oc.deleteOrder(out.data.id);
return { error: pOut.error };
}
if (input.flowId) {
Logger.info(
`Updating checkout flow state for flow ${input.flowId}`,
);
try {
await getCKUseCases().cleanupFlow(input.flowId, {
sessionOutcome: SessionOutcome.COMPLETED,
checkoutStep: CheckoutStep.Complete,
});
Logger.info(
`Checkout flow ${input.flowId} marked as completed`,
);
} catch (err) {
Logger.warn(
`Failed to update checkout flow state: ${input.flowId}`,
);
Logger.error(err);
}
} else {
Logger.warn(
`No flow id found to mark as completed: ${input.flowId}`,
);
}
const pnr = out.data.pnr;
if (!pnr) {
return out;
}
try {
// Get order details for email
const orderDetails = await oc.getOrderByPNR(pnr);
if (!orderDetails.data) {
return out;
}
const order = orderDetails.data;
const ticketInfo = order.flightTicketInfo;
const primaryPassenger = order.passengerInfos[0]?.passengerPii!;
if (!ticketInfo || !primaryPassenger) {
Logger.warn(
`No email address found for passenger to send PNR confirmation`,
);
return out;
}
// Get passenger email address to send confirmation to
const passengerEmail = primaryPassenger.email;
if (!passengerEmail) {
Logger.warn(
`No email address found for passenger to send PNR confirmation`,
);
return out;
}
Logger.info(
`Sending PNR confirmation email to ${passengerEmail}`,
);
// Send the email with React component directly
const emailResult = await emailUC.sendEmailWithTemplate({
to: passengerEmail,
subject: `Flight Confirmation: ${ticketInfo.departure} to ${ticketInfo.arrival} - PNR: ${pnr}`,
template: "pnr-confirmation",
templateData: {
pnr: pnr,
origin: ticketInfo.departure,
destination: ticketInfo.arrival,
departureDate: new Date(
ticketInfo.departureDate,
).toISOString(),
returnDate: new Date(
ticketInfo.returnDate,
)?.toISOString(),
passengerName: `${primaryPassenger.firstName}`,
baseUrl: "https://FlyTicketTravel.com",
logoPath:
"https://FlyTicketTravel.com/assets/logos/logo-main.svg",
companyName: "FlyTicketTravel",
},
});
if (emailResult.error) {
Logger.error(
`Failed to send PNR confirmation email: ${emailResult.error.message}`,
);
} else {
Logger.info(
`PNR confirmation email sent to ${passengerEmail}`,
);
}
} catch (emailError) {
// Don't fail the order if email sending fails
Logger.error(
`Error sending PNR confirmation email: ${emailError}`,
);
Logger.error(emailError);
}
Logger.info("Done with order creation, returning");
return out;
}),
findByPNR: publicProcedure
.input(z.object({ pnr: z.string() }))
.query(async ({ input }) => {
const oc = new OrderController(new OrderRepository(db));
return oc.getOrderByPNR(input.pnr);
}),
});

View File

@@ -0,0 +1,119 @@
import type { EmailAccountPayload } from "@pkg/logic/domains/account/data/entities";
import { get } from "svelte/store";
import {
createOrderPayloadModel,
OrderCreationStep,
} from "$lib/domains/order/data/entities";
import { trpcApiStore } from "$lib/stores/api";
import type { FlightTicket } from "$lib/domains/ticket/data/entities";
import { toast } from "svelte-sonner";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
export class CreateOrderViewModel {
orderStep = $state(OrderCreationStep.ACCOUNT_SELECTION);
accountInfo = $state<EmailAccountPayload>({ email: "", password: "" });
accountInfoOk = $state(false);
passengerInfosOk = $state(false);
ticketInfo = $state<FlightTicket | undefined>(undefined);
ticketInfoOk = $state(false);
loading = $state(true);
setStep(step: OrderCreationStep) {
if (step === OrderCreationStep.ACCOUNT_SELECTION && this.accountInfoOk) {
this.orderStep = step;
} else if (
step === OrderCreationStep.TICKET_SELECTION &&
this.ticketInfoOk
) {
this.orderStep = step;
} else {
this.orderStep = step;
}
}
setNextStep() {
if (this.orderStep === OrderCreationStep.ACCOUNT_SELECTION) {
this.orderStep = OrderCreationStep.TICKET_SELECTION;
} else if (this.orderStep === OrderCreationStep.TICKET_SELECTION) {
this.orderStep = OrderCreationStep.PASSENGER_INFO;
} else if (this.orderStep === OrderCreationStep.PASSENGER_INFO) {
this.orderStep = OrderCreationStep.SUMMARY;
} else {
this.orderStep = OrderCreationStep.ACCOUNT_SELECTION;
}
}
setPrevStep() {
if (this.orderStep === OrderCreationStep.SUMMARY) {
this.orderStep = OrderCreationStep.PASSENGER_INFO;
} else if (this.orderStep === OrderCreationStep.PASSENGER_INFO) {
this.orderStep = OrderCreationStep.TICKET_SELECTION;
} else {
this.orderStep = OrderCreationStep.ACCOUNT_SELECTION;
}
}
async createOrder() {
const api = get(trpcApiStore);
if (!api) {
return;
}
let basePrice = 0;
let displayPrice = 0;
let discountAmount = 0;
if (this.ticketInfo) {
basePrice = this.ticketInfo.priceDetails.basePrice;
displayPrice = this.ticketInfo.priceDetails.displayPrice;
discountAmount = this.ticketInfo.priceDetails.discountAmount;
}
const parsed = createOrderPayloadModel.safeParse({
orderModel: {
basePrice,
displayPrice,
discountAmount,
flightTicketInfoId: 0,
emailAccountId: 0,
},
emailAccountInfo: this.accountInfo,
flightTicketInfo: this.ticketInfo!,
passengerInfos: passengerInfoVM.passengerInfos,
flowId: ckFlowVM.flowId,
});
if (parsed.error) {
console.log(parsed.error.errors);
const msg = parsed.error.errors[0].message;
return toast.error(msg);
}
this.loading = true;
const out = await api.order.createOrder.mutate(parsed.data);
this.loading = false;
console.log(out);
if (out.error) {
return toast.error(out.error.message, {
description: out.error.userHint,
});
}
if (!out.data) {
return toast.error("Order likely failed to create", {
description:
"Please try again, or contact us to resolve the issue",
});
}
toast.success("Order created successfully, redirecting");
setTimeout(() => {
window.location.replace("/");
}, 1000);
}
}
export const createOrderVM = new CreateOrderViewModel();

View File

@@ -0,0 +1,217 @@
<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

@@ -0,0 +1,10 @@
<script lang="ts">
import Button from "$lib/components/ui/button/button.svelte";
import LabelWrapper from "$lib/components/atoms/label-wrapper.svelte";
import Input from "$lib/components/ui/input/input.svelte";
import Checkbox from "$lib/components/ui/checkbox/checkbox.svelte";
let { order }: { order?: any } = $props();
</script>
<span>show the order details of an already made order - todo</span>

View File

@@ -0,0 +1,80 @@
<script lang="ts">
import Title from "$lib/components/atoms/title.svelte";
import Icon from "$lib/components/atoms/icon.svelte";
import EmailIcon from "~icons/solar/letter-broken";
import TicketIcon from "~icons/solar/ticket-broken";
import CreditCardIcon from "~icons/solar/card-broken";
import { Badge } from "$lib/components/ui/badge";
import type { FullOrderModel } from "$lib/domains/order/data/entities";
import TicketLegsOverview from "$lib/domains/ticket/view/ticket/ticket-legs-overview.svelte";
import { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte";
let { order }: { order: FullOrderModel } = $props();
const cardStyle =
"flex flex-col gap-4 rounded-lg border border-gray-200 bg-white p-6 shadow-md";
</script>
<div class="flex flex-col gap-6">
{#if order.emailAccount}
<!-- Email Account Info -->
<div class={cardStyle}>
<div class="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">{order.emailAccount.email}</p>
</div>
{/if}
<!-- Flight Ticket Info -->
<div class={cardStyle}>
<div class="flex items-center justify-between">
<div class="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">
{order.flightTicketInfo.flightType}
</Badge>
<Badge variant="secondary">
{order.flightTicketInfo.cabinClass}
</Badge>
</div>
</div>
<TicketLegsOverview data={order.flightTicketInfo} />
</div>
<div class={cardStyle}>
<div class="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>Base Price</span>
<span>{convertAndFormatCurrency(order.basePrice)}</span>
</div>
{#if order.discountAmount > 0}
<div class="flex items-center justify-between">
<span>Discount</span>
<span class="text-green-600">
-{convertAndFormatCurrency(order.discountAmount)}
</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">
{convertAndFormatCurrency(order.displayPrice)}
</span>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,111 @@
<script lang="ts">
import Title from "$lib/components/atoms/title.svelte";
import Icon from "$lib/components/atoms/icon.svelte";
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 type { FullOrderModel } from "$lib/domains/order/data/entities";
import { capitalize } from "$lib/core/string.utils";
let { order }: { order: FullOrderModel } = $props();
const cardStyle =
"flex flex-col gap-4 rounded-lg border border-gray-200 bg-white p-6 shadow-md";
</script>
<div class="flex flex-col gap-6">
<!-- Passenger Information -->
<div class={cardStyle}>
<div class="flex items-center gap-2">
<Icon icon={UsersIcon} cls="w-5 h-5" />
<Title size="h5" color="black">Passengers</Title>
</div>
{#each order.passengerInfos 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 Info -->
<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 Info -->
{#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 < order.passengerInfos.length - 1}
<div class="border-b border-dashed" />
{/if}
{/each}
</div>
</div>

View File

@@ -0,0 +1,121 @@
import { get } from "svelte/store";
import {
getDefaultOrderCursor,
type PaginatedOrderInfoModel,
type OrderCursorModel,
getDefaultPaginatedOrderInfoModel,
} from "../data/entities";
import { trpcApiStore } from "$lib/stores/api";
import type { Result } from "@pkg/result";
import { toast } from "svelte-sonner";
import { encodeCursor } from "$lib/core/string.utils";
export class OrderViewModel {
orderInfo = $state<PaginatedOrderInfoModel>(
getDefaultPaginatedOrderInfoModel(),
);
fetching = $state(false);
query = $state("");
private getApi() {
return get(trpcApiStore);
}
private _getPayload() {
return {
limit: this.orderInfo.limit,
asc: this.orderInfo.asc,
page: this.orderInfo.page,
totalItemCount: this.orderInfo.totalItemCount,
totalPages: this.orderInfo.totalPages,
cursor: this.orderInfo.cursor,
};
}
async fetchNextPage() {
const api = this.getApi();
this.fetching = true;
if (!this.orderInfo || !api) {
return;
}
if (
this.orderInfo.page >= this.orderInfo.totalPages &&
this.orderInfo.totalPages > 0
) {
console.log("Not fetching next page, at the end");
return;
}
const res = await api.user.getAgentsPaginatedInfo.query({
info: this._getPayload(),
fetchNext: true,
});
console.log(res);
this.fetching = false;
this.setOrderInfoState(res as Result<PaginatedOrderInfoModel>);
}
async fetchPrevPage() {
const api = this.getApi();
this.fetching = true;
if (!this.orderInfo || !api) {
return;
}
if (this.orderInfo.page <= 1 || this.orderInfo.totalPages <= 1) {
console.log("Not fetching prev page, at the start");
return;
}
const res = await api.user.getAgentsPaginatedInfo.query({
info: this._getPayload(),
fetchNext: false,
});
this.fetching = false;
this.setOrderInfoState(res as Result<PaginatedOrderInfoModel>);
}
private setOrderInfoState(res: Result<PaginatedOrderInfoModel>) {
if (res.error) {
return toast.error(res.error.message, {
description: res.error.userHint,
});
}
if (!res.data) {
this.resetOrderInfo();
return;
}
this.orderInfo = {
...res.data,
data: res.data.data.map((item) => {
return {
...item,
createdAt: new Date(item.createdAt).toISOString(),
updatedAt: new Date(item.updatedAt).toISOString(),
};
}),
};
}
async searchOrders() {
if (!this.orderInfo) {
return;
}
this.resetOrderInfo();
await this.fetchNextPage();
}
resetOrderInfo() {
this.orderInfo = {
data: [],
cursor: encodeCursor<OrderCursorModel>({
...getDefaultOrderCursor(),
query: this.query,
}),
limit: this.orderInfo.limit,
asc: this.orderInfo.asc,
totalItemCount: 0,
totalPages: 0,
page: 0,
};
}
}
export const orderVM = new OrderViewModel();

View File

@@ -0,0 +1,47 @@
import type { FullOrderModel } from "@pkg/logic/domains/order/data/entities";
import { trpcApiStore } from "$lib/stores/api";
import { get } from "svelte/store";
import { toast } from "svelte-sonner";
export class TrackViewModel {
pnr = $state("");
loading = $state(false);
bookingData = $state<FullOrderModel | undefined>(undefined);
error = $state<string | null>(null);
async searchBooking() {
if (!this.pnr) {
this.error = "Please enter a PNR number";
return;
}
const api = get(trpcApiStore);
if (!api) {
return;
}
try {
this.loading = true;
this.error = null;
const result = await api.order.findByPNR.query({ pnr: this.pnr });
if (result.error) {
this.error = result.error.message;
toast.error(result.error.message, {
description: result.error.userHint,
});
this.bookingData = undefined;
} else {
this.bookingData = result.data;
}
} catch (e) {
this.error = "Failed to fetch booking details";
this.bookingData = undefined;
} finally {
this.loading = false;
}
}
}
export const trackVM = new TrackViewModel();

View File

@@ -0,0 +1 @@
export * from "@pkg/logic/domains/passengerinfo/data/entities";

View File

@@ -0,0 +1,209 @@
import { eq, inArray, type Database } from "@pkg/db";
import { order, passengerInfo, passengerPII } from "@pkg/db/schema";
import { ERROR_CODES, type Result } from "@pkg/result";
import {
passengerInfoModel,
type PassengerInfo,
type PassengerPII,
} from "./entities";
import { getError, Logger } from "@pkg/logger";
export class PassengerInfoRepository {
private db: Database;
constructor(db: Database) {
this.db = db;
}
async createPassengerPii(payload: PassengerPII): Promise<Result<number>> {
try {
const out = await this.db
.insert(passengerPII)
.values({
firstName: payload.firstName,
middleName: payload.middleName,
lastName: payload.lastName,
email: payload.email,
phoneCountryCode: payload.phoneCountryCode,
phoneNumber: payload.phoneNumber,
nationality: payload.nationality,
gender: payload.gender,
dob: payload.dob,
passportNo: payload.passportNo,
passportExpiry: payload.passportExpiry,
country: payload.country,
state: payload.state,
city: payload.city,
address: payload.address,
zipCode: payload.zipCode,
address2: payload.address2,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning({ id: passengerInfo.id })
.execute();
if (!out || out.length === 0) {
Logger.error("Failed to create passenger info");
Logger.debug(out);
Logger.debug(payload);
return {
error: getError({
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "Failed to create passenger info",
userHint: "Please try again",
detail: "Failed to create passenger info",
}),
};
}
return { data: out[0].id };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "An error occured while creating passenger info",
userHint: "Please try again later",
actionable: false,
detail: "An error occured while creating passenger info",
},
e,
),
};
}
}
async createPassengerInfo(payload: PassengerInfo): Promise<Result<number>> {
try {
const out = await this.db
.insert(passengerInfo)
.values({
passengerType: payload.passengerType,
passengerPiiId: payload.passengerPiiId,
paymentDetailsId: payload.paymentDetailsId,
seatSelection: payload.seatSelection,
bagSelection: payload.bagSelection,
agentsInfo: payload.agentsInfo,
flightTicketInfoId: payload.flightTicketInfoId,
orderId: payload.orderId,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning({ id: passengerInfo.id })
.execute();
if (!out || out.length === 0) {
Logger.error("Failed to create passenger info");
Logger.debug(out);
Logger.debug(payload);
return {
error: getError({
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "Failed to create passenger info",
userHint: "Please try again",
detail: "Failed to create passenger info",
}),
};
}
return { data: out[0].id };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "An error occured while creating passenger info",
userHint: "Please try again later",
actionable: false,
detail: "An error occured while creating passenger info",
},
e,
),
};
}
}
async getPassengerInfo(id: number): Promise<Result<PassengerInfo>> {
try {
const out = await this.db.query.passengerInfo.findFirst({
where: eq(passengerInfo.id, id),
with: { passengerPii: true },
});
if (!out) {
Logger.error("Failed to get passenger info");
Logger.debug(out);
return {
error: getError({
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "Failed to get passenger info",
userHint: "Please try again",
detail: "Failed to get passenger info",
}),
};
}
return { data: out as any as PassengerInfo };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "An error occured while getting passenger info",
userHint: "Please try again later",
actionable: false,
detail: "An error occured while getting passenger info",
},
e,
),
};
}
}
async getPassengerInfosByRefOId(
refOIds: number[],
): Promise<Result<PassengerInfo[]>> {
try {
const out = await this.db.query.passengerInfo.findMany({
where: inArray(passengerInfo.orderId, refOIds),
with: { passengerPii: true },
});
const res = [] as PassengerInfo[];
for (const each of out) {
const parsed = passengerInfoModel.safeParse(each);
if (!parsed.success) {
Logger.warn(`Error while parsing passenger info`);
Logger.debug(parsed.error?.errors);
continue;
}
res.push(parsed.data);
}
Logger.info(`Returning ${res.length} passenger info by ref OID`);
return { data: res };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "An error occured while getting passenger info",
userHint: "Please try again later",
actionable: false,
detail: "An error occured while getting passenger info",
},
e,
),
};
}
}
async deleteAll(ids: number[]): Promise<Result<number>> {
Logger.info(`Deleting ${ids.length} passenger info`);
const out = await this.db
.delete(passengerInfo)
.where(inArray(passengerInfo.id, ids));
Logger.debug(out);
Logger.info(`Deleted ${out.count} passenger info`);
return { data: out.count };
}
}

View File

@@ -0,0 +1,52 @@
import type { Result } from "@pkg/result";
import type { PassengerInfo } from "../data/entities";
import type { PassengerInfoRepository } from "../data/repository";
import { Logger } from "@pkg/logger";
export class PassengerInfoController {
repo: PassengerInfoRepository;
constructor(repo: PassengerInfoRepository) {
this.repo = repo;
}
async createPassengerInfos(
payload: PassengerInfo[],
orderId: number,
flightTicketInfoId?: number,
paymentDetailsId?: number,
): Promise<Result<number>> {
const made = [] as number[];
for (const passengerInfo of payload) {
const piiOut = await this.repo.createPassengerPii(
passengerInfo.passengerPii,
);
if (piiOut.error || !piiOut.data) {
await this.repo.deleteAll(made);
return piiOut;
}
passengerInfo.passengerPiiId = piiOut.data;
passengerInfo.paymentDetailsId = paymentDetailsId;
passengerInfo.flightTicketInfoId = flightTicketInfoId;
passengerInfo.orderId = orderId;
passengerInfo.agentId = undefined;
const out = await this.repo.createPassengerInfo(passengerInfo);
if (out.error) {
await this.repo.deleteAll(made);
return out;
}
}
return { data: made.length };
}
async getPassengerInfo(id: number): Promise<Result<PassengerInfo>> {
return this.repo.getPassengerInfo(id);
}
async getPassengerInfosByRefOIds(
refOIds: number[],
): Promise<Result<PassengerInfo[]>> {
Logger.info(`Querying/Returning Passenger infos for ${refOIds}`);
return this.repo.getPassengerInfosByRefOId(refOIds);
}
}

View File

@@ -0,0 +1,214 @@
<script lang="ts">
import { cn } from "$lib/utils";
import type { BagSelectionInfo } from "$lib/domains/ticket/data/entities/create.entities";
import Icon from "$lib/components/atoms/icon.svelte";
import BagIcon from "~icons/lucide/briefcase";
import BackpackIcon from "~icons/solar/backpack-linear";
import SuitcaseIcon from "~icons/bi/suitcase2";
import CheckIcon from "~icons/solar/check-read-linear";
import { flightTicketStore } from "$lib/domains/ticket/data/store";
import { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte";
let { info = $bindable() }: { info: BagSelectionInfo } = $props();
// Average baseline price for checked bags when not specified
const BASELINE_CHECKED_BAG_PRICE = 30;
const getBagDetails = (type: string) => {
const bagsInfo = $flightTicketStore?.bagsInfo;
if (!bagsInfo) return null;
switch (type) {
case "personalBag":
return bagsInfo.details.personalBags;
case "handBag":
return bagsInfo.details.handBags;
case "checkedBag":
return bagsInfo.details.checkedBags;
default:
return null;
}
};
const formatDimensions = (details: any) => {
if (!details?.dimensions) return "";
const { length, width, height } = details.dimensions;
if (!length || !width || !height) return "";
return `${length} x ${width} x ${height} cm`;
};
const bagTypes = [
{
icon: BackpackIcon,
type: "personalBag",
label: "Personal Bag",
description: "Small item (purse, laptop bag)",
included: true,
disabled: true,
},
{
icon: SuitcaseIcon,
type: "handBag",
label: "Cabin Bag",
description: "Carry-on luggage",
included: false,
disabled: false,
},
{
icon: BagIcon,
type: "checkedBag",
label: "Checked Bag",
description: "Check-in luggage",
included: false,
disabled: false, // We'll always show this option
},
];
function toggleBag(bagType: string) {
if (bagType === "handBag") {
info.handBags = info.handBags === 1 ? 0 : 1;
} else if (
bagType === "checkedBag" &&
$flightTicketStore?.bagsInfo.hasCheckedBagsSupport
) {
info.checkedBags = info.checkedBags === 1 ? 0 : 1;
}
}
function isBagSelected(bagType: string): boolean {
if (bagType === "personalBag") return true;
if (bagType === "handBag") return info.handBags === 1;
if (bagType === "checkedBag")
return (
info.checkedBags === 1 &&
$flightTicketStore?.bagsInfo.hasCheckedBagsSupport
);
return false;
}
function getBagPrice(bagType: string): {
price: number;
isEstimate: boolean;
} {
const bagDetails = getBagDetails(bagType);
if (bagType === "checkedBag") {
if (bagDetails && typeof bagDetails.price === "number") {
return { price: bagDetails.price, isEstimate: false };
}
return { price: BASELINE_CHECKED_BAG_PRICE, isEstimate: true };
}
return {
price: bagDetails?.price ?? 0,
isEstimate: false,
};
}
</script>
<div class="flex flex-col gap-4">
{#each bagTypes as bag}
{@const bagDetails = getBagDetails(bag.type)}
{@const isAvailable = true}
{@const isDisabled = false}
<!-- {@const isAvailable =
bag.type !== "checkedBag" ||
$flightTicketStore?.bagsInfo.hasCheckedBagsSupport}
{@const isDisabled =
bag.disabled || (bag.type === "checkedBag" && !isAvailable)} -->
<!-- {@const { price, isEstimate } = getBagPrice(bag.type)} -->
<!-- {@const formattedPrice = convertAndFormatCurrency(price)} -->
<div
class={cn(
"relative flex flex-col items-start gap-4 rounded-lg border-2 p-4 transition-all sm:flex-row sm:items-center",
isBagSelected(bag.type)
? "border-primary bg-primary/5"
: "border-gray-200",
!isDisabled &&
!bag.included &&
"cursor-pointer hover:border-primary/50",
isDisabled && "opacity-70",
)}
role={isDisabled || bag.included ? "presentation" : "button"}
onclick={() => !isDisabled && !bag.included && toggleBag(bag.type)}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
!isDisabled && !bag.included && toggleBag(bag.type);
}
}}
>
<div class="shrink-0">
<div
class={cn(
"grid h-10 w-10 place-items-center rounded-full sm:h-12 sm:w-12",
isBagSelected(bag.type)
? "bg-primary text-white"
: "bg-gray-100 text-gray-500",
)}
>
<Icon icon={bag.icon} cls="h-5 w-5" />
</div>
</div>
<div class="flex flex-1 flex-col gap-1">
<div class="flex items-center gap-2">
<span class="font-medium">{bag.label}</span>
{#if bag.included}
<span
class="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
>
Included
</span>
{/if}
{#if bag.type === "checkedBag" && !isAvailable}
<span
class="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800"
>
Not available for this flight
</span>
{/if}
</div>
<span class="text-sm text-gray-500">{bag.description}</span>
{#if bagDetails}
<div
class="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500"
>
{#if bagDetails.weight > 0}
<span>
Up to {bagDetails.weight}{bagDetails.unit}
</span>
{/if}
{#if formatDimensions(bagDetails)}
<span>{formatDimensions(bagDetails)}</span>
{/if}
</div>
{/if}
</div>
<div class="ml-auto flex items-center">
<!-- {#if price > 0}
<div class="flex flex-col items-end">
<span class="font-medium text-primary"
>+{formattedPrice}</span
>
{#if isEstimate}
<span class="text-xs text-gray-500"
>Estimated price</span
>
{/if}
</div>
{:else if !bag.included} -->
<span class="text-xs font-medium text-emerald-800">Free</span>
<!-- {/if} -->
</div>
{#if isBagSelected(bag.type)}
<div class="absolute right-4 top-4">
<Icon icon={CheckIcon} cls="h-5 w-5 text-primary" />
</div>
{/if}
</div>
{/each}
</div>

View File

@@ -0,0 +1,323 @@
<script lang="ts">
import LabelWrapper from "$lib/components/atoms/label-wrapper.svelte";
import Input from "$lib/components/ui/input/input.svelte";
import * as Select from "$lib/components/ui/select";
import { COUNTRIES_SELECT } from "$lib/core/countries";
import type { SelectOption } from "$lib/core/data.types";
import { capitalize } from "$lib/core/string.utils";
import { Gender } from "$lib/domains/ticket/data/entities/index";
import type { PassengerPII } from "$lib/domains/ticket/data/entities/create.entities";
import Title from "$lib/components/atoms/title.svelte";
import { passengerInfoVM } from "./passenger.info.vm.svelte";
import { PHONE_COUNTRY_CODES } from "@pkg/logic/core/data/phonecc";
let { info = $bindable(), idx }: { info: PassengerPII; idx: number } =
$props();
const genderOpts = [
{ label: capitalize(Gender.Male), value: Gender.Male },
{ label: capitalize(Gender.Female), value: Gender.Female },
{ label: capitalize(Gender.Other), value: Gender.Other },
] as SelectOption[];
function onSubmit(e: SubmitEvent) {
e.preventDefault();
passengerInfoVM.validatePII(info, idx);
}
let validationTimeout = $state(undefined as undefined | NodeJS.Timer);
function debounceValidate() {
if (validationTimeout) {
clearTimeout(validationTimeout);
}
validationTimeout = setTimeout(() => {
passengerInfoVM.validatePII(info, idx);
}, 500);
}
</script>
<form action="#" class="flex w-full flex-col gap-4" onsubmit={onSubmit}>
<div class="flex flex-col gap-4 md:flex-row">
<LabelWrapper
label="First Name"
error={passengerInfoVM.piiErrors[idx].firstName}
>
<Input
placeholder="First Name"
bind:value={info.firstName}
required
oninput={() => debounceValidate()}
/>
</LabelWrapper>
<LabelWrapper
label="Middle Name"
error={passengerInfoVM.piiErrors[idx].middleName}
>
<Input
placeholder="Middle Name"
bind:value={info.middleName}
oninput={() => debounceValidate()}
required
/>
</LabelWrapper>
<LabelWrapper
label="Last Name"
error={passengerInfoVM.piiErrors[idx].lastName}
>
<Input
placeholder="Last Name"
bind:value={info.lastName}
required
oninput={() => debounceValidate()}
/>
</LabelWrapper>
</div>
<LabelWrapper label="Email" error={passengerInfoVM.piiErrors[idx].email}>
<Input
placeholder="Email"
bind:value={info.email}
type="email"
oninput={() => debounceValidate()}
required
/>
</LabelWrapper>
<div class="flex flex-col gap-4 md:flex-row lg:flex-col xl:flex-row">
<LabelWrapper
label="Phone Number"
error={passengerInfoVM.piiErrors[idx].phoneNumber}
>
<div class="flex gap-2">
<Select.Root
type="single"
required
onValueChange={(code) => {
info.phoneCountryCode = code;
}}
name="phoneCode"
>
<Select.Trigger class="w-20">
{#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>
<Input
placeholder="Phone Number"
type="tel"
bind:value={info.phoneNumber}
required
oninput={() => debounceValidate()}
class="flex-1"
/>
</div>
</LabelWrapper>
<LabelWrapper
label="Passport Expiry"
error={passengerInfoVM.piiErrors[idx].passportExpiry}
>
<Input
placeholder="Passport Expiry"
value={info.passportExpiry}
type="date"
required
oninput={(v) => {
// @ts-ignore
info.passportExpiry = v.target.value;
debounceValidate();
}}
/>
</LabelWrapper>
<LabelWrapper
label="Passport/ID No"
error={passengerInfoVM.piiErrors[idx].passportNo}
>
<Input
placeholder="Passport or ID card no."
bind:value={info.passportNo}
minlength={1}
maxlength={20}
required
oninput={() => debounceValidate()}
/>
</LabelWrapper>
</div>
<div class="flex flex-col gap-4 md:flex-row">
<LabelWrapper
label="Nationality"
error={passengerInfoVM.piiErrors[idx].nationality}
>
<Select.Root
type="single"
required
onValueChange={(e) => {
info.nationality = e;
debounceValidate();
}}
name="role"
>
<Select.Trigger class="w-full">
{capitalize(
info.nationality.length > 0 ? info.nationality : "Select",
)}
</Select.Trigger>
<Select.Content>
{#each COUNTRIES_SELECT as country}
<Select.Item value={country.value}>
{country.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</LabelWrapper>
<LabelWrapper
label="Gender"
error={passengerInfoVM.piiErrors[idx].gender}
>
<Select.Root
type="single"
required
onValueChange={(e) => {
info.gender = e as Gender;
debounceValidate();
}}
name="role"
>
<Select.Trigger class="w-full">
{capitalize(info.gender.length > 0 ? info.gender : "Select")}
</Select.Trigger>
<Select.Content>
{#each genderOpts as gender}
<Select.Item value={gender.value}>
{gender.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</LabelWrapper>
<LabelWrapper
label="Date of Birth"
error={passengerInfoVM.piiErrors[idx].dob}
>
<Input
placeholder="Date of Birth"
bind:value={info.dob}
type="date"
required
oninput={() => debounceValidate()}
/>
</LabelWrapper>
</div>
<!-- and now for the address info - country, state, city, zip code, address and address 2 -->
<Title size="h5">Address Info</Title>
<div class="flex flex-col gap-4 md:flex-row">
<LabelWrapper
label="Country"
error={passengerInfoVM.piiErrors[idx].country}
>
<Select.Root
type="single"
required
onValueChange={(e) => {
info.country = e;
debounceValidate();
}}
name="role"
>
<Select.Trigger class="w-full">
{capitalize(
info.country.length > 0 ? info.country : "Select",
)}
</Select.Trigger>
<Select.Content>
{#each COUNTRIES_SELECT as country}
<Select.Item value={country.value}>
{country.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</LabelWrapper>
<LabelWrapper label="State" error={passengerInfoVM.piiErrors[idx].state}>
<Input
placeholder="State"
bind:value={info.state}
required
oninput={() => debounceValidate()}
/>
</LabelWrapper>
</div>
<div class="flex flex-col gap-4 md:flex-row">
<LabelWrapper label="City" error={passengerInfoVM.piiErrors[idx].city}>
<Input
placeholder="City"
bind:value={info.city}
required
minlength={1}
maxlength={80}
oninput={() => debounceValidate()}
/>
</LabelWrapper>
<LabelWrapper
label="Zip Code"
error={passengerInfoVM.piiErrors[idx].zipCode}
>
<Input
placeholder="Zip Code"
bind:value={info.zipCode}
required
minlength={1}
oninput={() => debounceValidate()}
maxlength={12}
/>
</LabelWrapper>
</div>
<LabelWrapper label="Address" error={passengerInfoVM.piiErrors[idx].address}>
<Input
placeholder="Address"
bind:value={info.address}
required
minlength={1}
maxlength={128}
oninput={() => debounceValidate()}
/>
</LabelWrapper>
<LabelWrapper
label="Address 2"
error={passengerInfoVM.piiErrors[idx].address2}
>
<Input
placeholder="Address 2"
bind:value={info.address2}
required
minlength={1}
maxlength={128}
/>
</LabelWrapper>
</form>

View File

@@ -0,0 +1,167 @@
import {
Gender,
PassengerType,
type BagDetails,
type FlightPriceDetails,
type PassengerCount,
} from "$lib/domains/ticket/data/entities/index";
import {
passengerPIIModel,
type BagSelectionInfo,
type PassengerInfo,
type PassengerPII,
type SeatSelectionInfo,
} from "$lib/domains/ticket/data/entities/create.entities";
import { z } from "zod";
export class PassengerInfoViewModel {
passengerInfos = $state<PassengerInfo[]>([]);
piiErrors = $state<Array<Partial<Record<keyof PassengerPII, string>>>>([]);
reset() {
this.passengerInfos = [];
this.piiErrors = [];
}
setupPassengerInfo(counts: PassengerCount, forceReset = false) {
if (this.passengerInfos.length > 0 && !forceReset) {
return; // since it's already setup
}
// const _defaultPiiObj = {
// firstName: "first",
// middleName: "mid",
// lastName: "last",
// email: "first.last@example.com",
// phoneCountryCode: "+31",
// phoneNumber: "12345379",
// passportNo: "f97823h",
// passportExpiry: "2032-12-12",
// nationality: "Netherlands",
// gender: Gender.Male,
// dob: "2000-12-12",
// country: "Netherlands",
// state: "state",
// city: "city",
// zipCode: "123098",
// address: "address",
// address2: "",
// } as PassengerPII;
const _defaultPiiObj = {
firstName: "",
middleName: "",
lastName: "",
email: "",
phoneCountryCode: "",
phoneNumber: "",
passportNo: "",
passportExpiry: "",
nationality: "",
gender: Gender.Male,
dob: "",
country: "",
state: "",
city: "",
zipCode: "",
address: "",
address2: "",
} as PassengerPII;
const _defaultPriceObj = {
currency: "",
basePrice: 0,
displayPrice: 0,
discountAmount: 0,
} as FlightPriceDetails;
const _defaultSeatSelectionObj = {
id: "",
row: "",
number: 0,
reserved: false,
available: false,
seatLetter: "",
price: _defaultPriceObj,
} as SeatSelectionInfo;
const _baseBagDetails = {
dimensions: { height: 0, length: 0, width: 0 },
price: 0,
unit: "kg",
weight: 0,
} as BagDetails;
const _defaultBagSelectionObj = {
id: 0,
personalBags: 1,
handBags: 0,
checkedBags: 0,
pricing: {
personalBags: { ..._baseBagDetails },
checkedBags: { ..._baseBagDetails },
handBags: { ..._baseBagDetails },
},
} as BagSelectionInfo;
this.passengerInfos = [];
for (let i = 0; i < counts.adults; i++) {
this.passengerInfos.push({
id: i,
passengerType: PassengerType.Adult,
agentsInfo: false,
passengerPii: { ..._defaultPiiObj },
seatSelection: { ..._defaultSeatSelectionObj },
bagSelection: { ..._defaultBagSelectionObj, id: i },
});
this.piiErrors.push({});
}
for (let i = 0; i < counts.children; i++) {
this.passengerInfos.push({
id: i + 1 + counts.adults,
passengerType: PassengerType.Child,
agentsInfo: false,
passengerPii: { ..._defaultPiiObj },
seatSelection: { ..._defaultSeatSelectionObj },
bagSelection: { ..._defaultBagSelectionObj, id: i },
});
this.piiErrors.push({});
}
}
validateAllPII() {
for (let i = 0; i < this.passengerInfos.length; i++) {
this.validatePII(this.passengerInfos[i].passengerPii, i);
}
}
validatePII(info: PassengerPII, idx: number) {
try {
const result = passengerPIIModel.parse(info);
this.piiErrors[idx] = {};
return result;
} catch (error) {
if (error instanceof z.ZodError) {
this.piiErrors[idx] = error.errors.reduce(
(acc, curr) => {
const path = curr.path[0] as keyof PassengerPII;
acc[path] = curr.message;
return acc;
},
{} as Record<keyof PassengerPII, string>,
);
}
return null;
}
}
isPIIValid(): boolean {
return this.piiErrors.every(
(errorObj) => Object.keys(errorObj).length === 0,
);
}
}
export const passengerInfoVM = new PassengerInfoViewModel();

View File

@@ -0,0 +1 @@
export * from "@pkg/logic/domains/paymentinfo/data/entities";

View File

@@ -0,0 +1,59 @@
import { eq, type Database } from "@pkg/db";
import {
paymentDetailsModel,
type PaymentDetails,
type PaymentDetailsPayload,
} from "./entities";
import type { Result } from "@pkg/result";
import { paymentDetails } from "@pkg/db/schema";
import { Logger } from "@pkg/logger";
export class PaymentInfoRepository {
db: Database;
constructor(db: Database) {
this.db = db;
}
async createPaymentInfo(
data: PaymentDetailsPayload,
): Promise<Result<number>> {
const out = await this.db
.insert(paymentDetails)
.values({
cardNumber: data.cardDetails.cardNumber,
cardholderName: data.cardDetails.cardholderName,
expiry: data.cardDetails.expiry,
cvv: data.cardDetails.cvv,
flightTicketInfoId: data.flightTicketInfoId,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning({ id: paymentDetails.id })
.execute();
return { data: out[0]?.id };
}
async getPaymentInfo(id: number): Promise<Result<PaymentDetails>> {
Logger.info(`Getting payment info with id ${id}`);
const out = await this.db.query.paymentDetails.findFirst({
where: eq(paymentDetails.id, id),
});
const parsed = paymentDetailsModel.safeParse(out);
if (parsed.error) {
Logger.error(parsed.error);
return {};
}
return { data: parsed.data };
}
async deletePaymentInfo(id: number): Promise<Result<boolean>> {
Logger.info(`Deleting payment info with id ${id}`);
const out = await this.db
.delete(paymentDetails)
.where(eq(paymentDetails.id, id))
.execute();
Logger.debug(out);
return { data: true };
}
}

View File

@@ -0,0 +1,22 @@
import type { PaymentDetailsPayload } from "../data/entities";
import type { PaymentInfoRepository } from "../data/repository";
export class PaymentInfoUseCases {
repo: PaymentInfoRepository;
constructor(repo: PaymentInfoRepository) {
this.repo = repo;
}
async createPaymentInfo(payload: PaymentDetailsPayload) {
return this.repo.createPaymentInfo(payload);
}
async getPaymentInfo(id: number) {
return this.repo.getPaymentInfo(id);
}
async deletePaymentInfo(id: number) {
return this.repo.deletePaymentInfo(id);
}
}

View File

@@ -0,0 +1,618 @@
import { ERROR_CODES, type Result } from "@pkg/result";
import type { BagsInfo, FlightTicket, TicketSearchDTO } from "./entities";
import { betterFetch } from "@better-fetch/fetch";
import { getError, Logger } from "@pkg/logger";
import { CabinClass, TicketType, type FlightPriceDetails } from "./entities";
import type Redis from "ioredis";
interface AmadeusAPIRequest {
currencyCode: string;
originDestinations: Array<{
id: string;
originLocationCode: string;
destinationLocationCode: string;
departureDateTimeRange: {
date: string;
time?: string;
};
arrivalDateTimeRange?: {
date: string;
time?: string;
};
}>;
travelers: Array<{
id: string;
travelerType: string;
}>;
sources: string[];
searchCriteria: {
maxFlightOffers: number;
flightFilters?: {
cabinRestrictions?: Array<{
cabin: string;
coverage: string;
originDestinationIds: string[];
}>;
};
};
}
interface TokenResponse {
type: string;
username: string;
application_name: string;
client_id: string;
token_type: string;
access_token: string;
expires_in: number;
state: string;
scope: string;
}
export class AmadeusTicketsAPIDataSource {
apiBaseUrl: string;
apiKey: string;
apiSecret: string;
redis: Redis;
// Redis key for storing the access token
private readonly TOKEN_CACHE_KEY = "amadeus:access_token";
private readonly TOKEN_EXPIRY_KEY = "amadeus:token_expiry";
// Add a buffer time to refresh token before it expires (5 minutes in seconds)
private readonly TOKEN_EXPIRY_BUFFER = 300;
constructor(
apiBaseUrl: string,
apiKey: string,
apiSecret: string,
redis: Redis,
) {
this.apiBaseUrl = apiBaseUrl;
this.apiKey = apiKey;
this.apiSecret = apiSecret;
this.redis = redis;
}
/**
* Get a valid access token, either from cache or by requesting a new one
*/
private async getAccessToken(): Promise<Result<string>> {
try {
// Try to get token from cache
const cachedToken = await this.redis.get(this.TOKEN_CACHE_KEY);
const tokenExpiry = await this.redis.get(this.TOKEN_EXPIRY_KEY);
// Check if we have a valid token that's not about to expire
if (cachedToken && tokenExpiry) {
const expiryTime = parseInt(tokenExpiry, 10);
const currentTime = Math.floor(Date.now() / 1000);
// If token is still valid and not about to expire, use it
if (expiryTime > currentTime + this.TOKEN_EXPIRY_BUFFER) {
Logger.debug("Using cached Amadeus API token");
return { data: cachedToken };
}
Logger.info(
"Amadeus API token is expired or about to expire, requesting a new one",
);
}
// Request a new token
Logger.info("Requesting new Amadeus API access token");
const params = new URLSearchParams();
params.append("grant_type", "client_credentials");
params.append("client_id", this.apiKey);
params.append("client_secret", this.apiSecret);
const { data, error } = await betterFetch<TokenResponse>(
`${this.apiBaseUrl}/v1/security/oauth2/token`,
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: params.toString(),
},
);
if (error || !data) {
return {
error: getError(
{
code: ERROR_CODES.AUTH_ERROR,
message: "Failed to authenticate with Amadeus API",
detail: "Could not obtain access token",
userHint: "Please try again later",
error: error,
actionable: false,
},
error,
),
};
}
// Cache the token
const currentTime = Math.floor(Date.now() / 1000);
const expiryTime = currentTime + data.expires_in;
await this.redis.set(this.TOKEN_CACHE_KEY, data.access_token);
await this.redis.set(this.TOKEN_EXPIRY_KEY, expiryTime.toString());
await this.redis.expire(this.TOKEN_CACHE_KEY, data.expires_in);
await this.redis.expire(this.TOKEN_EXPIRY_KEY, data.expires_in);
Logger.info(
`Successfully obtained and cached Amadeus API token (expires in ${data.expires_in} seconds)`,
);
return { data: data.access_token };
} catch (err) {
console.error(err);
return {
error: getError(
{
code: ERROR_CODES.AUTH_ERROR,
message: "Failed to authenticate with Amadeus API",
detail: "An unexpected error occurred during authentication",
userHint: "Please try again later",
error: err,
actionable: false,
},
err,
),
};
}
}
async searchForTickets(
payload: TicketSearchDTO,
): Promise<Result<FlightTicket[]>> {
Logger.info(
`Base api url: ${this.apiBaseUrl}, api key: ${this.apiKey}, api secret: ${this.apiSecret}`,
);
try {
const tokenResult = await this.getAccessToken();
if (tokenResult.error) {
return { error: tokenResult.error };
}
const accessToken = tokenResult.data;
const request = this.buildAmadeusRequest(payload);
const from = request.originDestinations[0].originLocationCode;
const to = request.originDestinations[0].destinationLocationCode;
Logger.info(`Searching Amadeus for flights from ${from} to ${to}`);
const { data, error } = await betterFetch<any>(
`${this.apiBaseUrl}/v2/shopping/flight-offers`,
{
method: "POST",
headers: {
"Content-Type": "application/vnd.amadeus+json",
Authorization: `Bearer ${accessToken}`,
"X-HTTP-Method-Override": "GET",
},
body: JSON.stringify(request),
},
);
if (error) {
return {
error: getError(
{
code: ERROR_CODES.NETWORK_ERROR,
message:
"Failed to search for tickets via Amadeus API",
detail: "Network error when connecting to Amadeus API",
userHint: "Please try again later",
error: error,
actionable: false,
},
JSON.stringify(error, null, 4),
),
};
}
if (data.errors) {
return {
error: getError(
{
code: ERROR_CODES.API_ERROR,
message: "Amadeus API returned an error",
detail: data.errors
.map((e: any) => `${e.title}: ${e.detail || ""}`)
.join(", "),
userHint: "Please try with different search criteria",
error: data.errors,
actionable: false,
},
JSON.stringify(data.errors, null, 4),
),
};
}
// Check if we have data to process
if (
!data.data ||
!Array.isArray(data.data) ||
data.data.length === 0
) {
Logger.info("Amadeus API returned no flight offers");
return { data: [] };
}
// Transform API response to our FlightTicket format
const tickets = this.transformAmadeusResponseToFlightTickets(
data,
payload,
);
Logger.info(`Amadeus API returned ${tickets.length} tickets`);
return { data: tickets };
} catch (err) {
return {
error: getError(
{
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "Failed to search for tickets via Amadeus API",
detail: "An unexpected error occurred while processing the Amadeus API response",
userHint: "Please try again later",
error: err,
actionable: false,
},
JSON.stringify(err, null, 4),
),
};
}
}
private buildAmadeusRequest(payload: TicketSearchDTO): AmadeusAPIRequest {
const { ticketSearchPayload } = payload;
const { adults, children } = ticketSearchPayload.passengerCounts;
// Create travelers array
const travelers = [];
for (let i = 0; i < adults; i++) {
travelers.push({
id: (i + 1).toString(),
travelerType: "ADULT",
});
}
for (let i = 0; i < (children || 0); i++) {
travelers.push({
id: (adults + i + 1).toString(),
travelerType: "CHILD",
});
}
// Map cabin class to Amadeus format
const cabinMap: Record<string, string> = {
[CabinClass.Economy]: "ECONOMY",
[CabinClass.PremiumEconomy]: "PREMIUM_ECONOMY",
[CabinClass.Business]: "BUSINESS",
[CabinClass.FirstClass]: "FIRST",
};
// Build the request
const originDestinations = [];
// Format dates to ensure ISO 8601 format (YYYY-MM-DD)
const departureDate = this.formatDateForAmadeus(
ticketSearchPayload.departureDate,
);
const returnDate = ticketSearchPayload.returnDate
? this.formatDateForAmadeus(ticketSearchPayload.returnDate)
: null;
// Add outbound journey
originDestinations.push({
id: "1",
originLocationCode: ticketSearchPayload.departure,
destinationLocationCode: ticketSearchPayload.arrival,
departureDateTimeRange: {
date: departureDate,
},
});
// Add return journey if it's a round trip
if (ticketSearchPayload.ticketType === TicketType.Return && returnDate) {
originDestinations.push({
id: "2",
originLocationCode: ticketSearchPayload.arrival,
destinationLocationCode: ticketSearchPayload.departure,
departureDateTimeRange: {
date: returnDate,
},
});
}
return {
currencyCode: "USD", // Use USD as default currency
originDestinations,
travelers,
sources: ["GDS"],
searchCriteria: {
maxFlightOffers: 50,
flightFilters: {
cabinRestrictions: [
{
cabin:
cabinMap[ticketSearchPayload.cabinClass] ||
"ECONOMY",
coverage: "MOST_SEGMENTS",
originDestinationIds: originDestinations.map(
(od) => od.id,
),
},
],
},
},
};
}
/**
* Helper method to ensure date is in ISO 8601 format (YYYY-MM-DD)
* @param dateString Date string to format
* @returns Formatted date string in YYYY-MM-DD format
*/
private formatDateForAmadeus(dateString: string): string {
try {
// Check if already in correct format
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
return dateString;
}
// Try to parse the date and format it
const date = new Date(dateString);
if (isNaN(date.getTime())) {
Logger.error(`Invalid date format provided: ${dateString}`);
throw new Error(`Invalid date: ${dateString}`);
}
// Format as YYYY-MM-DD
return date.toISOString().split("T")[0];
} catch (err) {
Logger.error(`Error formatting date: ${dateString}`, err);
throw err;
}
}
private transformAmadeusResponseToFlightTickets(
response: any,
originalPayload: TicketSearchDTO,
): FlightTicket[] {
if (
!response.data ||
!Array.isArray(response.data) ||
response.data.length === 0
) {
return [];
}
const { ticketSearchPayload } = originalPayload;
const dictionaries = response.dictionaries || {};
return response.data.map((offer: any, index: number) => {
// Extract basic info from the offer
const id = index + 1;
const ticketId = offer.id;
const departure = ticketSearchPayload.departure;
const arrival = ticketSearchPayload.arrival;
const departureDate = ticketSearchPayload.departureDate;
const returnDate = ticketSearchPayload.returnDate || "";
const flightType = ticketSearchPayload.ticketType;
const cabinClass = ticketSearchPayload.cabinClass;
const passengerCounts = ticketSearchPayload.passengerCounts;
// Create price details
const priceDetails: FlightPriceDetails = {
currency: offer.price.currency,
basePrice: parseFloat(offer.price.total),
displayPrice: parseFloat(offer.price.total),
discountAmount: 0,
};
// Create baggageInfo - using defaults as Amadeus doesn't provide complete bag info
const bagsInfo: BagsInfo = {
includedPersonalBags: 1,
includedHandBags: 1,
includedCheckedBags: this.getIncludedBagsFromOffer(offer),
hasHandBagsSupport: true,
hasCheckedBagsSupport: true,
details: {
personalBags: {
price: 0,
weight: 7,
unit: "KG",
dimensions: { length: 40, width: 30, height: 20 },
},
handBags: {
price: 20,
weight: 12,
unit: "KG",
dimensions: { length: 55, width: 40, height: 20 },
},
checkedBags: {
price: 40,
weight: 23,
unit: "KG",
dimensions: { length: 90, width: 75, height: 43 },
},
},
};
// Create flight itineraries
const flightIteneraries = {
outbound: this.buildSegmentDetails(
offer.itineraries[0],
dictionaries,
0,
),
inbound:
offer.itineraries.length > 1
? this.buildSegmentDetails(
offer.itineraries[1],
dictionaries,
1,
)
: [],
};
// Dates - always include departure date
const dates = [departureDate];
if (returnDate) {
dates.push(returnDate);
}
// Create the flight ticket
const ticket: FlightTicket = {
id,
ticketId,
departure,
arrival,
departureDate,
returnDate,
dates,
flightType,
flightIteneraries,
priceDetails,
refOIds: [],
refundable: offer.pricingOptions?.refundableFare || false,
passengerCounts,
cabinClass,
bagsInfo,
lastAvailable: {
availableSeats: offer.numberOfBookableSeats || 10,
},
shareId: `AMADEUS-${ticketId}`,
checkoutUrl: "",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
return ticket;
});
}
private getIncludedBagsFromOffer(offer: any): number {
// Try to extract checked bag info from first traveler and first segment
try {
const firstTraveler = offer.travelerPricings?.[0];
const firstSegment = firstTraveler?.fareDetailsBySegment?.[0];
if (firstSegment?.includedCheckedBags?.quantity) {
return firstSegment.includedCheckedBags.quantity;
}
// If weight is specified without quantity, assume 1 bag
if (firstSegment?.includedCheckedBags?.weight) {
return 1;
}
} catch (e) {
// Ignore error and use default
}
return 0; // Default: no included checked bags
}
private buildSegmentDetails(
itinerary: any,
dictionaries: any,
directionIndex: number,
): any[] {
return itinerary.segments.map((segment: any, index: number) => {
const depStation = this.enhanceLocationInfo(
segment.departure.iataCode,
dictionaries,
);
const arrStation = this.enhanceLocationInfo(
segment.arrival.iataCode,
dictionaries,
);
const airlineInfo = this.getAirlineInfo(
segment.carrierCode,
dictionaries,
);
return {
flightId: `${directionIndex}-${index}`,
flightNumber: `${segment.carrierCode}${segment.number}`,
airline: airlineInfo,
departure: {
station: depStation,
localTime: segment.departure.at,
utcTime: segment.departure.at, // Note: Amadeus doesn't provide UTC time
},
destination: {
station: arrStation,
localTime: segment.arrival.at,
utcTime: segment.arrival.at, // Note: Amadeus doesn't provide UTC time
},
durationSeconds: this.parseDuration(segment.duration),
seatInfo: {
availableSeats: 10, // Default value as Amadeus doesn't provide per-segment seat info
seatClass: this.mapAmadeusCabinToInternal(
segment.travelerPricings?.[0]?.fareDetailsBySegment?.[0]
?.cabin || "ECONOMY",
),
},
};
});
}
private enhanceLocationInfo(iataCode: string, dictionaries: any) {
const locationInfo = dictionaries?.locations?.[iataCode] || {};
return {
id: 0, // Dummy id as we don't have one from Amadeus
type: "AIRPORT",
code: iataCode,
name: iataCode, // We only have the code
city: locationInfo.cityCode || iataCode,
country: locationInfo.countryCode || "Unknown",
};
}
private getAirlineInfo(carrierCode: string, dictionaries: any) {
const airlineName = dictionaries?.carriers?.[carrierCode] || carrierCode;
return {
code: carrierCode,
name: airlineName,
imageUrl: null,
};
}
private parseDuration(durationStr: string): number {
try {
// Example format: PT5H30M (5 hours 30 minutes)
const hourMatch = durationStr.match(/(\d+)H/);
const minuteMatch = durationStr.match(/(\d+)M/);
const hours = hourMatch ? parseInt(hourMatch[1]) : 0;
const minutes = minuteMatch ? parseInt(minuteMatch[1]) : 0;
return hours * 3600 + minutes * 60;
} catch (e) {
return 0;
}
}
private mapAmadeusCabinToInternal(amadeusClass: string): string | null {
const cabinMap: Record<
string,
(typeof CabinClass)[keyof typeof CabinClass]
> = {
ECONOMY: CabinClass.Economy,
PREMIUM_ECONOMY: CabinClass.PremiumEconomy,
BUSINESS: CabinClass.Business,
FIRST: CabinClass.FirstClass,
};
return cabinMap[amadeusClass] || CabinClass.Economy;
}
}

View File

@@ -0,0 +1 @@
export * from "$lib/domains/passengerinfo/data/entities";

View File

@@ -0,0 +1 @@
export * from "@pkg/logic/domains/ticket/data/entities/enums";

View File

@@ -0,0 +1 @@
export * from "@pkg/logic/domains/ticket/data/entities/index";

View File

@@ -0,0 +1,373 @@
import { and, arrayContains, eq, or, type Database } from "@pkg/db";
import {
flightPriceDetailsModel,
flightTicketModel,
TicketType,
type FlightPriceDetails,
type FlightTicket,
type TicketSearchDTO,
} from "./entities";
import { type Result, ERROR_CODES } from "@pkg/result";
import { getError, Logger } from "@pkg/logger";
import type { ScrapedTicketsDataSource } from "./scrape.data.source";
import { flightTicketInfo, order } from "@pkg/db/schema";
import { round } from "$lib/core/num.utils";
import {
getCKUseCases,
type CheckoutFlowUseCases,
} from "$lib/domains/ckflow/domain/usecases";
import type { AmadeusTicketsAPIDataSource } from "./amadeusapi.data.source";
export class TicketRepository {
private db: Database;
private ckUseCases: CheckoutFlowUseCases;
private scraper: ScrapedTicketsDataSource;
private amadeusApi: AmadeusTicketsAPIDataSource;
constructor(
db: Database,
scraper: ScrapedTicketsDataSource,
amadeusApi: AmadeusTicketsAPIDataSource,
) {
this.db = db;
this.scraper = scraper;
this.ckUseCases = getCKUseCases();
this.amadeusApi = amadeusApi;
}
async getCheckoutUrlByRefOId(refOId: number): Promise<Result<string>> {
try {
const _o = await this.db.query.order.findFirst({
where: eq(order.id, refOId),
with: {
flightTicketInfo: {
columns: { id: true, checkoutUrl: true },
},
},
});
const chktUrl = _o?.flightTicketInfo?.checkoutUrl;
console.log(_o);
return { data: chktUrl };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.NOT_FOUND_ERROR,
message: "Failed to fetch ticket url",
userHint: "Please try again later",
detail: "An error occurred while fetching ticket url",
},
e,
),
};
}
}
async getTicketIdbyRefOid(refOid: number): Promise<Result<number>> {
try {
const out = await this.db.query.flightTicketInfo.findFirst({
where: arrayContains(flightTicketInfo.refOIds, [refOid]),
columns: { id: true },
});
if (out && out.id) {
return { data: out?.id };
}
return {};
} catch (e) {
Logger.debug(refOid);
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to lookup ticket",
detail: "A database error occured while getting ticket by id",
userHint: "Contact us to resolve this issue",
actionable: false,
},
e,
),
};
}
}
async searchForTickets(
payload: TicketSearchDTO,
): Promise<Result<FlightTicket[]>> {
try {
let out = await this.scraper.searchForTickets(payload);
if (out.error || (out.data && out.data.length < 1)) {
out = await this.amadeusApi.searchForTickets(payload);
if (out.error) {
return out;
}
}
return out;
} catch (err) {
return {
error: getError(
{
code: ERROR_CODES.NETWORK_ERROR,
message: "Failed to search for tickets",
detail: "Could not fetch tickets for the given payload",
userHint: "Please try again later",
error: err,
actionable: false,
},
err,
),
};
}
}
async getTicketById(id: number): Promise<Result<FlightTicket>> {
const out = await this.db.query.flightTicketInfo.findFirst({
where: eq(flightTicketInfo.id, id),
});
if (!out) {
return {
error: getError({
code: ERROR_CODES.NOT_FOUND_ERROR,
message: "Ticket not found",
userHint:
"Please check if the selected ticket is correct, or try again",
detail: "The ticket is not found by the id provided",
}),
};
}
const parsed = flightTicketModel.safeParse(out);
if (!parsed.success) {
return {
error: getError(
{
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "Failed to parse ticket",
userHint: "Please try again",
detail: "Failed to parse ticket",
},
JSON.stringify(parsed.error.errors),
),
};
}
return { data: parsed.data };
}
async getTicketIdByInfo(info: {
ticketId: string;
arrival: string;
departure: string;
cabinClass: string;
departureDate: string;
returnDate: string;
}): Promise<Result<number>> {
try {
const out = await this.db.query.flightTicketInfo.findFirst({
where: or(
eq(flightTicketInfo.ticketId, info.ticketId),
and(
eq(flightTicketInfo.arrival, info.arrival),
eq(flightTicketInfo.departure, info.departure),
eq(flightTicketInfo.cabinClass, info.cabinClass),
eq(
flightTicketInfo.departureDate,
new Date(info.departureDate),
),
),
),
columns: { id: true },
});
if (out && out.id) {
return { data: out?.id };
}
return {};
} catch (e) {
Logger.debug(info);
return {
error: getError(
{
code: ERROR_CODES.DATABASE_ERROR,
message: "Failed to lookup ticket",
detail: "A database error occured while getting ticket by id",
userHint: "Contact us to resolve this issue",
actionable: false,
},
e,
),
};
}
}
async createTicket(
payload: FlightTicket,
isCache = false,
): Promise<Result<number>> {
try {
let rd = new Date();
if (
payload.returnDate &&
payload.returnDate.length > 0 &&
payload.flightType !== TicketType.OneWay
) {
rd = new Date(payload.returnDate);
}
const out = await this.db
.insert(flightTicketInfo)
.values({
ticketId: payload.ticketId,
flightType: payload.flightType,
arrival: payload.arrival,
departure: payload.departure,
cabinClass: payload.cabinClass,
departureDate: new Date(payload.departureDate),
returnDate: rd,
priceDetails: payload.priceDetails,
passengerCounts: payload.passengerCounts,
bagsInfo: payload.bagsInfo,
lastAvailable: payload.lastAvailable,
flightIteneraries: payload.flightIteneraries,
dates: payload.dates,
checkoutUrl: payload.checkoutUrl ?? "",
refOIds: payload.refOIds ?? [],
refundable: payload.refundable ?? false,
isCache: isCache ? true : (payload.isCache ?? isCache),
shareId: payload.shareId,
})
.returning({ id: flightTicketInfo.id })
.execute();
if (!out || out.length === 0) {
Logger.error("Failed to create ticket");
Logger.debug(out);
Logger.debug(payload);
return {
error: getError({
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "Failed to create ticket",
userHint: "Please try again",
detail: "Failed to create ticket",
}),
};
}
return { data: out[0].id };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "An error occured while creating ticket",
userHint: "Please try again later",
actionable: false,
detail: "An error occured while creating ticket",
},
e,
),
};
}
}
async updateTicketPrices(
tid: number,
payload: FlightPriceDetails,
): Promise<Result<FlightPriceDetails>> {
const cond = eq(flightTicketInfo.id, tid);
const currInfo = await this.db.query.flightTicketInfo.findFirst({
where: cond,
columns: { priceDetails: true },
});
if (!currInfo) {
return {
error: getError({
code: ERROR_CODES.NOT_FOUND_ERROR,
message: "Could not find ticket",
userHint: "Please try again later",
detail: "Could not fin the ticket by the provided id",
}),
};
}
const info = flightPriceDetailsModel.parse(currInfo.priceDetails);
Logger.info("Updating the price details from:");
Logger.debug(info);
const discountAmt = round(info.basePrice - payload.displayPrice);
const newInfo = {
...info,
discountAmount: discountAmt,
displayPrice: payload.displayPrice,
} as FlightPriceDetails;
Logger.info("... to:");
Logger.debug(newInfo);
const out = await this.db
.update(flightTicketInfo)
.set({ priceDetails: newInfo })
.where(cond)
.execute();
Logger.info("Updated the price info");
Logger.debug(out);
return { data: newInfo };
}
async uncacheAndSaveTicket(tid: number): Promise<Result<number>> {
try {
const out = await this.db
.update(flightTicketInfo)
.set({ isCache: false })
.where(eq(flightTicketInfo.id, tid))
.returning({ id: flightTicketInfo.id })
.execute();
if (!out || out.length === 0) {
Logger.error("Failed to uncache ticket");
Logger.debug(out);
Logger.debug(tid);
return {
error: getError({
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "Failed to process ticket at this moment",
userHint: "Please try again later",
detail: "Failed to uncache ticket",
}),
};
}
return { data: out[0].id };
} catch (e) {
return {
error: getError(
{
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "An error occured while uncaching ticket",
userHint: "Please try again later",
actionable: false,
detail: "An error occured while uncaching ticket",
},
e,
),
};
}
}
async setRefOIdsForTicket(
tid: number,
oids: number[],
): Promise<Result<boolean>> {
Logger.info(`Setting refOIds(${oids}) for ticket ${tid}`);
const out = await this.db
.update(flightTicketInfo)
.set({ refOIds: oids })
.where(eq(flightTicketInfo.id, tid))
.execute();
return { data: out.length > 0 };
}
async areCheckoutAlreadyLiveForOrder(refOids: number[]): Promise<boolean> {
return this.ckUseCases.areCheckoutAlreadyLiveForOrder(refOids);
}
}

View File

@@ -0,0 +1,50 @@
import { ERROR_CODES, type Result } from "@pkg/result";
import type { FlightTicket, TicketSearchDTO } from "./entities";
import { betterFetch } from "@better-fetch/fetch";
import { getError, Logger } from "@pkg/logger";
export class ScrapedTicketsDataSource {
scraperUrl: string;
apiKey: string;
constructor(scraperUrl: string, apiKey: string) {
this.scraperUrl = scraperUrl;
this.apiKey = apiKey;
}
async searchForTickets(
payload: TicketSearchDTO,
): Promise<Result<FlightTicket[]>> {
const { data, error } = await betterFetch<Result<FlightTicket[]>>(
`${this.scraperUrl}/api/v1/tickets/search`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.apiKey}`,
},
body: JSON.stringify(payload),
},
);
if (error) {
return {
error: getError(
{
code: ERROR_CODES.NETWORK_ERROR,
message: "Failed to search for tickets",
detail: "Could not fetch tickets for the given payload",
userHint: "Please try again later",
error: error,
actionable: false,
},
JSON.stringify(error, null, 4),
),
};
}
Logger.info(`Returning ${data.data?.length} tickets`);
return { data: data.data ?? [] };
}
}

View File

@@ -0,0 +1,24 @@
import { writable } from "svelte/store";
import {
CabinClass,
TicketType,
type FlightTicket,
type TicketSearchPayload,
} from "./entities";
import { nanoid } from "nanoid";
export const flightTicketStore = writable<FlightTicket>();
export const ticketSearchStore = writable<TicketSearchPayload>({
loadMore: false,
sessionId: nanoid(),
ticketType: TicketType.Return,
cabinClass: CabinClass.Economy,
passengerCounts: { adults: 1, children: 0 },
departure: "",
arrival: "",
departureDate: "",
returnDate: "",
meta: {},
couponCode: "",
});

View File

@@ -0,0 +1,202 @@
import {
type FlightTicket,
type FlightPriceDetails,
type LimitedFlightTicket,
type PassengerCount,
TicketType,
CabinClass,
} from "../data/entities";
import type { LimitedOrderWithTicketInfoModel } from "$lib/domains/order/data/entities";
import { OrderStatus } from "$lib/domains/order/data/entities";
export class TestDataGenerator {
private static idCounter = 1;
static getNextId(): number {
return this.idCounter++;
}
static resetIds(): void {
this.idCounter = 1;
}
static createPriceDetails(
basePrice: number,
discountAmount = 0,
): FlightPriceDetails {
return {
currency: "USD",
basePrice,
discountAmount,
displayPrice: basePrice - discountAmount,
};
}
static createPassengerCount(
adults: number = 1,
children: number = 0,
): PassengerCount {
return { adults, children };
}
static createTicket(config: LimitedFlightTicket): FlightTicket {
const id = config.id ?? this.getNextId();
const now = new Date().toISOString();
return {
id,
ticketId: `T${id}`,
departure: config.departure,
arrival: config.arrival,
departureDate: config.departureDate,
returnDate: config.returnDate,
dates: [config.departureDate],
flightType: config.flightType,
flightIteneraries: {
outbound: [
{
flightId: `F${id}`,
flightNumber: `FL${id}`,
airline: {
code: "TEST",
name: "Test Airlines",
imageUrl: null,
},
departure: {
station: {
id: 1,
type: "AIRPORT",
code: config.departure,
name: "Test Airport",
city: "Test City",
country: "Test Country",
},
localTime: config.departureDate,
utcTime: config.departureDate,
},
destination: {
station: {
id: 2,
type: "AIRPORT",
code: config.arrival,
name: "Test Airport 2",
city: "Test City 2",
country: "Test Country",
},
localTime: config.departureDate,
utcTime: config.departureDate,
},
durationSeconds: 10800,
seatInfo: {
availableSeats: 100,
seatClass: config.cabinClass,
},
},
],
inbound: [],
},
priceDetails: config.priceDetails,
refOIds: [],
refundable: true,
passengerCounts: config.passengerCounts,
cabinClass: config.cabinClass,
bagsInfo: {
includedPersonalBags: 1,
includedHandBags: 1,
includedCheckedBags: 0,
hasHandBagsSupport: true,
hasCheckedBagsSupport: true,
details: {
personalBags: {
price: 0,
weight: 7,
unit: "KG",
dimensions: { length: 40, width: 30, height: 20 },
},
handBags: {
price: 20,
weight: 12,
unit: "KG",
dimensions: { length: 55, width: 40, height: 20 },
},
checkedBags: {
price: 40,
weight: 23,
unit: "KG",
dimensions: { length: 90, width: 75, height: 43 },
},
},
},
lastAvailable: { availableSeats: 10 },
shareId: `SH${id}`,
checkoutUrl: `http://test.com/checkout/${id}`,
createdAt: now,
updatedAt: now,
};
}
static createOrder(config: {
basePrice: number;
displayPrice?: number;
discountAmount?: number;
pricePerPassenger?: number;
departureDate?: string;
passengerCount?: PassengerCount;
status?: OrderStatus;
}): LimitedOrderWithTicketInfoModel {
const id = this.getNextId();
const displayPrice = config.displayPrice ?? config.basePrice;
const pricePerPassenger = config.pricePerPassenger ?? displayPrice;
const passengerCount =
config.passengerCount ?? this.createPassengerCount();
return {
id,
basePrice: config.basePrice,
displayPrice,
discountAmount: config.discountAmount ?? 0,
pricePerPassenger,
fullfilledPrice: 0,
status: config.status ?? OrderStatus.PENDING_FULLFILLMENT,
flightTicketInfo: {
id,
departure: "NYC",
arrival: "LAX",
departureDate: config.departureDate ?? "2024-02-20",
returnDate: "",
flightType: TicketType.OneWay,
passengerCounts: passengerCount,
},
};
}
static createTestScenario(config: {
tickets: Array<LimitedFlightTicket>;
orders: Array<{
basePrice: number;
displayPrice?: number;
pricePerPassenger?: number;
departureDate?: string;
passengerCount?: PassengerCount;
}>;
}) {
const tickets = [] as FlightTicket[];
for (const t of config.tickets) {
const limitedTicket: LimitedFlightTicket = {
id: this.getNextId(),
departure: t.departure ?? "NYC",
arrival: t.arrival ?? "LAX",
departureDate: t.departureDate ?? new Date().toISOString(),
returnDate: t.returnDate ?? "",
dates: t.dates ?? [t.departureDate ?? new Date().toISOString()],
flightType: t.flightType,
priceDetails: t.priceDetails,
passengerCounts: t.passengerCounts ?? this.createPassengerCount(),
cabinClass: t.cabinClass ?? CabinClass.Economy,
};
tickets.push(this.createTicket(limitedTicket));
}
const orders = config.orders.map((o) => this.createOrder(o));
return { tickets, orders };
}
}

View File

@@ -0,0 +1,179 @@
import { AirportsRepository } from "$lib/domains/airport/data/repository";
import type {
FlightPriceDetails,
FlightTicket,
TicketSearchPayload,
} from "../data/entities";
import { TicketRepository } from "../data/repository";
import { getError, Logger } from "@pkg/logger";
import { env } from "$env/dynamic/private";
import { ScrapedTicketsDataSource } from "../data/scrape.data.source";
import { db } from "@pkg/db";
import { airportsDb } from "@pkg/airports-db";
import { ERROR_CODES, type Result } from "@pkg/result";
import { DiscountType, type CouponModel } from "@pkg/logic/domains/coupon/data";
import { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte";
import { AmadeusTicketsAPIDataSource } from "../data/amadeusapi.data.source";
import { getRedisInstance } from "$lib/server/redis";
export class TicketController {
private repo: TicketRepository;
private airportsRepo: AirportsRepository;
private TICKET_SCRAPERS = ["kiwi"];
constructor(repo: TicketRepository, airportsRepo: AirportsRepository) {
this.repo = repo;
this.airportsRepo = airportsRepo;
}
async searchAirports(query: string) {
return this.airportsRepo.searchAirports(query);
}
async getAirportByCode(query: string) {
return this.airportsRepo.getAirportByCode(query);
}
async searchForTickets(
payload: TicketSearchPayload,
coupon: CouponModel | undefined,
): Promise<Result<FlightTicket[]>> {
const result = await this.repo.searchForTickets({
sessionId: payload.sessionId,
ticketSearchPayload: payload,
providers: this.TICKET_SCRAPERS,
});
if (result.error || !result.data) {
return result;
}
if (!coupon) {
return result;
}
Logger.info(`Auto-applying coupon ${coupon.code} to all search results`);
return {
data: result.data.map((ticket) => {
return this.applyDiscountToTicket(ticket, coupon);
}),
};
}
private applyDiscountToTicket(
ticket: FlightTicket,
coupon: CouponModel,
): FlightTicket {
// Calculate discount amount
let discountAmount = 0;
if (coupon.discountType === DiscountType.PERCENTAGE) {
discountAmount =
(ticket.priceDetails.displayPrice *
Number(coupon.discountValue)) /
100;
} else {
discountAmount = Number(coupon.discountValue);
}
// Apply maximum discount limit if specified
if (
coupon.maxDiscountAmount &&
discountAmount > Number(coupon.maxDiscountAmount)
) {
discountAmount = Number(coupon.maxDiscountAmount);
}
// Skip if minimum order value is not met
if (
coupon.minOrderValue &&
ticket.priceDetails.displayPrice < Number(coupon.minOrderValue)
) {
return ticket;
}
// Create a copy of the ticket with the discount applied
const discountedTicket = { ...ticket };
discountedTicket.priceDetails = {
...ticket.priceDetails,
discountAmount:
(ticket.priceDetails.discountAmount || 0) + discountAmount,
displayPrice: ticket.priceDetails.displayPrice - discountAmount,
appliedCoupon: coupon.code,
couponDescription:
coupon.description ||
`Coupon discount of ${coupon.discountType === DiscountType.PERCENTAGE ? coupon.discountValue + "%" : convertAndFormatCurrency(parseFloat(coupon.discountValue.toString()))}`,
};
return discountedTicket;
}
async cacheTicket(sid: string, payload: FlightTicket) {
Logger.info(
`Caching ticket for ${sid} | ${payload.departure}:${payload.arrival}`,
);
const refOIds = payload.refOIds;
if (!refOIds) {
// In this case we're not going for any of our fancy checkout jazz
return this.repo.createTicket(payload, true);
}
const areAnyLive =
await this.repo.areCheckoutAlreadyLiveForOrder(refOIds);
Logger.info(`Any of the OIds has checkout session live ?? ${areAnyLive}`);
if (areAnyLive) {
return {
error: getError({
code: ERROR_CODES.INTERNAL_SERVER_ERROR,
message: "This ticket offer has expired",
userHint:
"Please select another one or perform search again to fetch latest offers",
actionable: false,
detail: "Failed to ticket",
}),
};
}
Logger.info("nu'uh seems greenlight to make a ticket");
return this.repo.createTicket(payload, true);
}
async updateTicketPrices(tid: number, payload: FlightPriceDetails) {
return this.repo.updateTicketPrices(tid, payload);
}
async uncacheAndSaveTicket(tid: number) {
return this.repo.uncacheAndSaveTicket(tid);
}
async getCheckoutUrlByRefOId(id: number) {
return this.repo.getCheckoutUrlByRefOId(id);
}
async setRefOIdsForTicket(tid: number, oids: number[]) {
return this.repo.setRefOIdsForTicket(tid, oids);
}
async getTicketById(id: number) {
return this.repo.getTicketById(id);
}
}
export function getTC() {
const ds = new ScrapedTicketsDataSource(
env.TICKET_SCRAPER_API_URL,
env.API_KEY,
);
const amadeusApi = new AmadeusTicketsAPIDataSource(
env.AMADEUS_API_BASE_URL,
env.AMADEUS_API_KEY,
env.AMADEUS_API_SECRET,
getRedisInstance(),
);
return new TicketController(
new TicketRepository(db, ds, amadeusApi),
new AirportsRepository(airportsDb),
);
}

View File

@@ -0,0 +1,70 @@
import { createTRPCRouter } from "$lib/trpc/t";
import { z } from "zod";
import { publicProcedure } from "$lib/server/trpc/t";
import { getTC } from "./controller";
import { Logger } from "@pkg/logger";
import {
flightPriceDetailsModel,
flightTicketModel,
ticketSearchPayloadModel,
} from "../data/entities/index";
import type { Result } from "@pkg/result";
import { getCKUseCases } from "$lib/domains/ckflow/domain/usecases";
import { CouponRepository } from "@pkg/logic/domains/coupon/repository";
import { db } from "@pkg/db";
export const ticketRouter = createTRPCRouter({
searchAirports: publicProcedure
.input(z.object({ query: z.string() }))
.query(async ({ input }) => {
const { query } = input;
const tc = getTC();
Logger.info(`Fetching airports with query: ${query}`);
return await tc.searchAirports(query);
}),
ping: publicProcedure
.input(z.object({ tid: z.number(), refOIds: z.array(z.number()) }))
.query(async ({ input }) => {
console.log("Pinged");
console.log(input);
const ckflowUC = getCKUseCases();
const out = await ckflowUC.areCheckoutAlreadyLiveForOrder(
input.refOIds,
);
return { data: out } as Result<boolean>;
}),
getAirportByCode: publicProcedure
.input(z.object({ code: z.string() }))
.query(async ({ input }) => {
return await getTC().getAirportByCode(input.code);
}),
searchTickets: publicProcedure
.input(ticketSearchPayloadModel)
.query(async ({ input }) => {
const cr = new CouponRepository(db);
const coupon = await cr.getBestActiveCoupon();
Logger.info(`Got coupon? :: ${coupon.data?.code}`);
return getTC().searchForTickets(input, coupon.data);
}),
cacheTicket: publicProcedure
.input(z.object({ sid: z.string(), payload: flightTicketModel }))
.mutation(async ({ input }) => {
return await getTC().cacheTicket(input.sid, input.payload);
}),
updateTicketPrices: publicProcedure
.input(z.object({ tid: z.number(), payload: flightPriceDetailsModel }))
.mutation(async ({ input }) => {
return await getTC().updateTicketPrices(input.tid, input.payload);
}),
getTicketById: publicProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
return await getTC().getTicketById(input.id);
}),
});

View File

@@ -0,0 +1,248 @@
import { describe, it, expect, beforeEach } from "vitest";
import { TicketWithOrderSkiddaddler } from "./ticket.n.order.skiddaddler";
import { TestDataGenerator as TDG } from "../data/test.data.generator";
import { getJustDateString } from "@pkg/logic/core/date.utils";
import { CabinClass, TicketType } from "../data/entities";
function getTodaysDate() {
return new Date(getJustDateString(new Date()));
}
describe("TicketWithOrderSkiddaddler", () => {
beforeEach(() => {
TDG.resetIds();
});
describe("Direct Matching", () => {
const today = getTodaysDate();
it("should match ticket with exact price order", async () => {
const { tickets, orders } = TDG.createTestScenario({
tickets: [
{
id: 1,
departure: "NYC",
arrival: "LAX",
departureDate: today.toISOString(),
returnDate: "",
dates: [today.toISOString()],
flightType: TicketType.OneWay,
priceDetails: TDG.createPriceDetails(100),
passengerCounts: TDG.createPassengerCount(1, 0),
cabinClass: CabinClass.Economy,
},
],
orders: [
{
basePrice: 100,
displayPrice: 100,
departureDate: today.toISOString(),
},
],
});
const skiddaddler = new TicketWithOrderSkiddaddler(tickets, orders);
await skiddaddler.magic();
expect(tickets[0].refOIds).toContain(orders[0].id);
expect(tickets[0].priceDetails.discountAmount).toBe(0);
});
it("should handle multiple tickets with slight diffs", async () => {
const { tickets, orders } = TDG.createTestScenario({
tickets: [
{
id: 1,
departure: "NYC",
arrival: "LAX",
departureDate: today.toISOString(),
returnDate: "",
dates: [today.toISOString()],
flightType: TicketType.OneWay,
priceDetails: TDG.createPriceDetails(100),
passengerCounts: TDG.createPassengerCount(1, 0),
cabinClass: CabinClass.Economy,
},
{
id: 2,
departure: "NYC",
arrival: "LAX",
departureDate: today.toISOString(),
returnDate: "",
dates: [today.toISOString()],
flightType: TicketType.OneWay,
priceDetails: TDG.createPriceDetails(200),
passengerCounts: TDG.createPassengerCount(1, 0),
cabinClass: CabinClass.Economy,
},
],
orders: [
{
basePrice: 95,
displayPrice: 95,
departureDate: today.toISOString(),
},
{
basePrice: 190,
displayPrice: 190,
departureDate: today.toISOString(),
},
],
});
const skiddaddler = new TicketWithOrderSkiddaddler(tickets, orders);
await skiddaddler.magic();
expect(tickets[0].refOIds).toContain(orders[0].id);
expect(tickets[1].refOIds).toContain(orders[1].id);
expect(tickets[0].priceDetails.discountAmount).toBe(5);
expect(tickets[1].priceDetails.discountAmount).toBe(10);
});
it("should not pair up ticket with order with much greater price", async () => {
const { tickets, orders } = TDG.createTestScenario({
tickets: [
{
id: 1,
departure: "NYC",
arrival: "LAX",
departureDate: today.toISOString(),
returnDate: "",
dates: [today.toISOString()],
flightType: TicketType.OneWay,
priceDetails: TDG.createPriceDetails(100),
passengerCounts: TDG.createPassengerCount(1, 0),
cabinClass: CabinClass.Economy,
},
],
orders: [
{
basePrice: 1000,
displayPrice: 1000,
departureDate: today.toISOString(),
},
],
});
const skiddaddler = new TicketWithOrderSkiddaddler(tickets, orders);
await skiddaddler.magic();
expect(tickets[0].refOIds?.length).toBe(0);
});
it("should 'elevate' the order's rank due to it being the only option for ticket", async () => {
const { tickets, orders } = TDG.createTestScenario({
tickets: [
{
id: 1,
departure: "NYC",
arrival: "LAX",
departureDate: today.toISOString(),
returnDate: "",
dates: [today.toISOString()],
flightType: TicketType.OneWay,
priceDetails: TDG.createPriceDetails(400),
passengerCounts: TDG.createPassengerCount(1, 0),
cabinClass: CabinClass.Economy,
},
],
orders: [
{
basePrice: 200,
displayPrice: 200,
departureDate: today.toISOString(),
},
],
});
new TicketWithOrderSkiddaddler(tickets, orders).magic();
expect(tickets[0].refOIds).toContain(orders[0].id);
expect(tickets[0].priceDetails.displayPrice).toEqual(200);
});
});
describe("Partial Order Matching", () => {
const today = getTodaysDate();
it("should pick direct order via priceperpassenger field", async () => {
const { tickets, orders } = TDG.createTestScenario({
tickets: [
{
id: 1,
departure: "NYC",
arrival: "LAX",
departureDate: today.toISOString(),
returnDate: "",
dates: [today.toISOString()],
flightType: TicketType.OneWay,
priceDetails: TDG.createPriceDetails(100),
passengerCounts: TDG.createPassengerCount(1, 0),
cabinClass: CabinClass.Economy,
},
],
orders: [
{
basePrice: 270,
displayPrice: 270,
pricePerPassenger: 90,
departureDate: today.toISOString(),
passengerCount: TDG.createPassengerCount(3, 0),
},
],
});
new TicketWithOrderSkiddaddler(tickets, orders).magic();
expect(tickets[0].refOIds).toContain(orders[0].id);
expect(tickets[0].priceDetails.displayPrice).toBe(90);
});
it("should join multiple smaller order with the ticket", async () => {
const { tickets, orders } = TDG.createTestScenario({
tickets: [
{
id: 1,
departure: "NYC",
arrival: "LAX",
departureDate: today.toISOString(),
returnDate: "",
dates: [today.toISOString()],
flightType: TicketType.OneWay,
priceDetails: TDG.createPriceDetails(400),
passengerCounts: TDG.createPassengerCount(1, 0),
cabinClass: CabinClass.Economy,
},
],
orders: [
{
basePrice: 200,
displayPrice: 200,
pricePerPassenger: 100,
departureDate: today.toISOString(),
passengerCount: TDG.createPassengerCount(2, 0),
},
{
basePrice: 500,
displayPrice: 500,
pricePerPassenger: 50,
departureDate: today.toISOString(),
passengerCount: TDG.createPassengerCount(10, 0),
},
{
basePrice: 750,
displayPrice: 750,
pricePerPassenger: 150,
departureDate: today.toISOString(),
passengerCount: TDG.createPassengerCount(5, 0),
},
{
basePrice: 240,
displayPrice: 240,
pricePerPassenger: 80,
departureDate: today.toISOString(),
passengerCount: TDG.createPassengerCount(3, 0),
},
],
});
new TicketWithOrderSkiddaddler(tickets, orders).magic();
expect(tickets[0].refOIds?.length).toEqual(3);
expect(tickets[0].priceDetails.displayPrice).toBe(400);
});
});
});

View File

@@ -0,0 +1,445 @@
import type { FlightTicket } from "../data/entities";
import { Logger } from "@pkg/logger";
import { round } from "$lib/core/num.utils";
import { type Result } from "@pkg/result";
import { type LimitedOrderWithTicketInfoModel } from "$lib/domains/order/data/entities";
import { getJustDateString } from "@pkg/logic/core/date.utils";
type OrderWithUsageInfo = {
order: LimitedOrderWithTicketInfoModel;
usePartialAmount: boolean;
};
export class TicketWithOrderSkiddaddler {
THRESHOLD_DIFF_PERCENTAGE = 15;
ELEVATED_THRESHOLD_DIFF_PERCENTAGE = 50;
tickets: FlightTicket[];
private reservedOrders: number[];
private allActiveOrders: LimitedOrderWithTicketInfoModel[];
private urgentActiveOrders: LimitedOrderWithTicketInfoModel[];
// Stores oids that have their amount divided into smaller amounts for being reused for multiple tickets
// this record keeps track of the oid, with how much remainder is left to be divided
private reservedPartialOrders: Record<number, number>;
private minTicketPrice: number;
private maxTicketPrice: number;
constructor(
tickets: FlightTicket[],
allActiveOrders: LimitedOrderWithTicketInfoModel[],
) {
this.tickets = tickets;
this.reservedOrders = [];
this.reservedPartialOrders = {};
this.minTicketPrice = 0;
this.maxTicketPrice = 0;
this.allActiveOrders = [];
this.urgentActiveOrders = [];
this.loadupActiveOrders(allActiveOrders);
}
async magic(): Promise<Result<boolean>> {
// Sorting the orders by price in ascending order
this.allActiveOrders.sort((a, b) => b.basePrice - a.basePrice);
this.loadMinMaxPrices();
for (const ticket of this.tickets) {
if (this.areAllOrdersUsedUp()) {
break;
}
const suitableOrders = this.getSuitableOrders(ticket);
if (!suitableOrders) {
continue;
}
console.log("--------- suitable orders ---------");
console.log(suitableOrders);
console.log("-----------------------------------");
const [discountAmt, newDisplayPrice] = this.calculateNewAmounts(
ticket,
suitableOrders,
);
ticket.priceDetails.discountAmount = discountAmt;
ticket.priceDetails.displayPrice = newDisplayPrice;
const oids = Array.from(
new Set(suitableOrders.map((o) => o.order.id)),
).toSorted();
ticket.refOIds = oids;
this.reservedOrders.push(...oids);
}
Logger.debug(`Assigned ${this.reservedOrders.length} orders to tickets`);
return { data: true };
}
private loadupActiveOrders(data: LimitedOrderWithTicketInfoModel[]) {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
// Removing all orders which have tickets with departure date of yesterday and older
this.allActiveOrders = data.filter((o) => {
return (
new Date(
getJustDateString(new Date(o.flightTicketInfo.departureDate)),
) >= today
);
});
this.urgentActiveOrders = [];
const threeDaysFromNowMs = 3 * 24 * 3600 * 1000;
for (const order of this.allActiveOrders) {
const depDate = new Date(order.flightTicketInfo.departureDate);
if (now.getTime() + threeDaysFromNowMs > depDate.getTime()) {
this.urgentActiveOrders.push(order);
}
}
Logger.info(`Found ${this.allActiveOrders.length} active orders`);
Logger.info(`We got ${this.urgentActiveOrders.length} urgent orders`);
}
private loadMinMaxPrices() {
this.minTicketPrice = 100000;
this.maxTicketPrice = 0;
for (const ticket of this.tickets) {
const _dPrice = ticket.priceDetails.displayPrice;
if (_dPrice < this.minTicketPrice) {
this.minTicketPrice = _dPrice;
}
if (_dPrice > this.maxTicketPrice) {
this.maxTicketPrice = _dPrice;
}
}
}
private areAllOrdersUsedUp() {
if (this.urgentActiveOrders.length === 0) {
return this.reservedOrders.length >= this.allActiveOrders.length;
}
const areUrgentOrdersUsedUp =
this.reservedOrders.length >= this.urgentActiveOrders.length;
return areUrgentOrdersUsedUp;
}
/**
* An order is used up in 2 cases
* 1. it's not being partially fulfulled and it's found in the used orders list
* 2. it's being partially fulfilled and it's found in both the used orders list and sub chunked orders list
*/
private isOrderUsedUp(oid: number, displayPrice: number) {
if (this.reservedPartialOrders.hasOwnProperty(oid)) {
return this.reservedPartialOrders[oid] < displayPrice;
}
return this.reservedOrders.includes(oid);
}
private calculateNewAmounts(
ticket: FlightTicket,
orders: OrderWithUsageInfo[],
) {
if (orders.length < 1) {
return [
ticket.priceDetails.discountAmount,
ticket.priceDetails.displayPrice,
];
}
const totalAmount = orders.reduce((sum, { order, usePartialAmount }) => {
return (
sum +
(usePartialAmount ? order.pricePerPassenger : order.displayPrice)
);
}, 0);
const discountAmt = round(ticket.priceDetails.displayPrice - totalAmount);
return [discountAmt, totalAmount];
}
/**
* Suitable orders are those orders that are ideally within the threshold and are not already reserved. So there's a handful of cases, which other sub methods are handling
*/
private getSuitableOrders(
ticket: FlightTicket,
): OrderWithUsageInfo[] | undefined {
const activeOrders =
this.urgentActiveOrders.length > 0
? this.urgentActiveOrders
: this.allActiveOrders;
const cases = [
(t: any, a: any) => {
return this.findFirstSuitableDirectOId(t, a);
},
(t: any, a: any) => {
return this.findFirstSuitablePartialOId(t, a);
},
(t: any, a: any) => {
return this.findManySuitableOIds(t, a);
},
(t: any, a: any) => {
return this.findFirstSuitableDirectOIdElevated(t, a);
},
];
let c = 1;
for (const each of cases) {
const out = each(ticket, activeOrders);
if (out) {
console.log(`Case ${c} worked, returning it's response`);
return out;
}
c++;
}
console.log("NO CASE WORKED, WUT, yee");
}
private findFirstSuitableDirectOId(
ticket: FlightTicket,
activeOrders: LimitedOrderWithTicketInfoModel[],
): OrderWithUsageInfo[] | undefined {
let ord: LimitedOrderWithTicketInfoModel | undefined;
for (const o of activeOrders) {
const [op, tp] = [o.displayPrice, ticket.priceDetails.displayPrice];
const diff = Math.abs(op - tp);
const diffPtage = (diff / tp) * 100;
if (this.isOrderSuitable(o.id, op, tp, diffPtage)) {
ord = o;
break;
}
}
if (!ord) {
return;
}
return [{ order: ord, usePartialAmount: false }];
}
private findFirstSuitablePartialOId(
ticket: FlightTicket,
activeOrders: LimitedOrderWithTicketInfoModel[],
): OrderWithUsageInfo[] | undefined {
const tp = ticket.priceDetails.displayPrice;
let ord: LimitedOrderWithTicketInfoModel | undefined;
for (const o of activeOrders) {
const op = o.pricePerPassenger;
const diff = op - tp;
const diffPtage = (diff / tp) * 100;
if (this.isOrderSuitable(o.id, op, tp, diffPtage)) {
ord = o;
break;
}
}
if (!ord) {
return;
}
this.upsertPartialOrderToCache(
ord.id,
ord.pricePerPassenger,
ord.fullfilledPrice,
);
return [{ order: ord, usePartialAmount: true }];
}
private findManySuitableOIds(
ticket: FlightTicket,
activeOrders: LimitedOrderWithTicketInfoModel[],
): OrderWithUsageInfo[] | undefined {
const targetPrice = ticket.priceDetails.displayPrice;
const validOrderOptions = activeOrders.flatMap((order) => {
const options: OrderWithUsageInfo[] = [];
// Add full price option if suitable
if (
!this.isOrderUsedUp(order.id, order.displayPrice) &&
order.displayPrice <= targetPrice
) {
options.push({ order, usePartialAmount: false });
}
// Add partial price option if suitable
if (
!this.isOrderUsedUp(order.id, order.pricePerPassenger) &&
order.pricePerPassenger <= targetPrice
) {
options.push({ order, usePartialAmount: true });
}
return options;
});
if (validOrderOptions.length === 0) return undefined;
// Helper function to get the effective price of an order
const getEffectivePrice = (ordInfo: {
order: LimitedOrderWithTicketInfoModel;
usePerPassenger: boolean;
}) => {
return ordInfo.usePerPassenger
? ordInfo.order.pricePerPassenger
: ordInfo.order.displayPrice;
};
const sumCombination = (combo: OrderWithUsageInfo[]): number => {
return combo.reduce(
(sum, { order, usePartialAmount }) =>
sum +
(usePartialAmount
? order.pricePerPassenger
: order.displayPrice),
0,
);
};
let bestCombination: OrderWithUsageInfo[] = [];
let bestDiff = targetPrice;
// Try combinations of different sizes
for (
let size = 1;
size <= Math.min(validOrderOptions.length, 3);
size++
) {
const combinations = this.getCombinations(validOrderOptions, size);
for (const combo of combinations) {
const sum = sumCombination(combo);
const diff = targetPrice - sum;
if (
diff >= 0 &&
(diff / targetPrice) * 100 <=
this.THRESHOLD_DIFF_PERCENTAGE &&
diff < bestDiff
) {
// Verify we're not using the same order twice
const orderIds = new Set(combo.map((c) => c.order.id));
if (orderIds.size === combo.length) {
bestDiff = diff;
bestCombination = combo;
}
}
}
}
if (bestCombination.length === 0) return undefined;
// Update partial orders cache
bestCombination.forEach(({ order, usePartialAmount }) => {
if (usePartialAmount) {
this.upsertPartialOrderToCache(
order.id,
order.pricePerPassenger,
order.fullfilledPrice,
);
}
});
return bestCombination;
}
/**
* In case no other cases worked, but we have an order with far lower price amount,
* then we juss use it but bump up it's amount to match the order
*/
private findFirstSuitableDirectOIdElevated(
ticket: FlightTicket,
activeOrders: LimitedOrderWithTicketInfoModel[],
): OrderWithUsageInfo[] | undefined {
const targetPrice = ticket.priceDetails.displayPrice;
// Find the first unreserved order with the smallest price difference
let bestOrder: LimitedOrderWithTicketInfoModel | undefined;
let smallestPriceDiff = Number.MAX_VALUE;
for (const order of activeOrders) {
if (this.isOrderUsedUp(order.id, order.displayPrice)) {
continue;
}
// Check both regular price and per-passenger price
const prices = [order.displayPrice];
if (order.pricePerPassenger) {
prices.push(order.pricePerPassenger);
}
for (const price of prices) {
const diff = Math.abs(targetPrice - price);
if (diff < smallestPriceDiff) {
smallestPriceDiff = diff;
bestOrder = order;
}
}
}
if (!bestOrder) {
return undefined;
}
// Calculate if we should use partial amount
const usePartial =
bestOrder.pricePerPassenger &&
Math.abs(targetPrice - bestOrder.pricePerPassenger) <
Math.abs(targetPrice - bestOrder.displayPrice);
// The price will be elevated to match the ticket price
// We'll use the original price ratio to determine the new display price
const originalPrice = usePartial
? bestOrder.pricePerPassenger
: bestOrder.displayPrice;
// Only elevate if the price difference is reasonable
const priceDiffPercentage =
(Math.abs(targetPrice - originalPrice) / targetPrice) * 100;
if (priceDiffPercentage > this.ELEVATED_THRESHOLD_DIFF_PERCENTAGE) {
return undefined;
}
// if (usePartial) {
// bestOrder.pricePerPassenger =
// } else {
// }
return [{ order: bestOrder, usePartialAmount: !!usePartial }];
}
private getCombinations<T>(arr: T[], size: number): T[][] {
if (size === 0) return [[]];
if (arr.length === 0) return [];
const first = arr[0];
const rest = arr.slice(1);
const combosWithoutFirst = this.getCombinations(rest, size);
const combosWithFirst = this.getCombinations(rest, size - 1).map(
(combo) => [first, ...combo],
);
return [...combosWithoutFirst, ...combosWithFirst];
}
private isOrderSuitable(
orderId: number,
orderPrice: number,
ticketPrice: number,
diffPercentage: number,
) {
return (
diffPercentage > 0 &&
diffPercentage <= this.THRESHOLD_DIFF_PERCENTAGE &&
orderPrice <= ticketPrice &&
!this.isOrderUsedUp(orderId, orderPrice)
);
}
private upsertPartialOrderToCache(
oid: number,
price: number,
_defaultPrice: number = 0,
) {
if (!this.reservedPartialOrders[oid]) {
this.reservedPartialOrders[oid] = _defaultPrice;
return;
}
this.reservedPartialOrders[oid] += price;
}
}

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import * as Select from "$lib/components/ui/select";
import type { SelectOption } from "$lib/core/data.types";
import { capitalize } from "$lib/core/string.utils";
let {
opts,
onselect,
}: { opts: SelectOption[]; onselect: (e: string) => void } = $props();
let chosenOpt = $state<SelectOption | undefined>(undefined);
function setOpt(val: string) {
chosenOpt = opts.find((e) => e.value === val);
onselect(val);
}
</script>
<Select.Root type="single" required onValueChange={(e) => setOpt(e)} name="role">
<Select.Trigger class="w-full border-border/20">
{capitalize(
opts?.find((e) => e.value === chosenOpt?.value)?.label ??
"Cabin Class",
)}
</Select.Trigger>
<Select.Content>
{#each opts as each}
<Select.Item value={each.value}>
{each.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>

View File

@@ -0,0 +1 @@
<span>show checkout confirmation status here</span>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import Loader from "$lib/components/atoms/loader.svelte";
</script>
<div class="grid h-full w-full place-items-center p-4 py-20 md:p-8 md:py-24">
<Loader />
</div>

View File

@@ -0,0 +1,160 @@
<script lang="ts">
import { buttonVariants } from "$lib/components/ui/button/button.svelte";
import { cn } from "$lib/utils";
import { CheckoutStep } from "../../data/entities";
import { ticketCheckoutVM } from "./flight-checkout.vm.svelte";
import { Sheet, SheetContent, SheetTrigger } from "$lib/components/ui/sheet";
import Icon from "$lib/components/atoms/icon.svelte";
import ChevronDownIcon from "~icons/lucide/chevron-down";
import CloseIcon from "~icons/lucide/x";
const checkoutSteps = [
{ id: CheckoutStep.Initial, label: "Passenger Details" },
{ id: CheckoutStep.Payment, label: "Payment" },
{ id: CheckoutStep.Verification, label: "Verify Details" },
{ id: CheckoutStep.Confirmation, label: "Confirmation" },
];
let activeStepIndex = $derived(
checkoutSteps.findIndex(
(step) => step.id === ticketCheckoutVM.checkoutStep,
),
);
function handleStepClick(clickedIndex: number, stepId: CheckoutStep) {
if (clickedIndex <= activeStepIndex) {
ticketCheckoutVM.checkoutStep = stepId;
}
}
let sheetOpen = $state(false);
</script>
<Sheet
bind:open={sheetOpen}
onOpenChange={(to) => {
sheetOpen = to;
}}
>
<SheetTrigger
class={cn(
buttonVariants({
variant: "secondary",
size: "lg",
}),
"my-8 flex w-full justify-between whitespace-normal break-all text-start lg:hidden",
)}
onclick={() => (sheetOpen = true)}
>
<div class="flex flex-col gap-1 xs:flex-row xs:items-center">
<span>
Step {activeStepIndex + 1}/{checkoutSteps.length}:
</span>
<span>
{checkoutSteps[activeStepIndex].label}
</span>
</div>
<Icon icon={ChevronDownIcon} cls="h-4 w-4" />
</SheetTrigger>
<SheetContent side="bottom">
<button
onclick={() => (sheetOpen = false)}
class="absolute right-4 top-4 grid place-items-center rounded-md border border-neutral-400 p-1 text-neutral-500"
>
<Icon icon={CloseIcon} cls="h-5 w-auto" />
</button>
<div class="mt-8 flex flex-col gap-2 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",
index <= activeStepIndex
? "border-brand-200 bg-primary/10 hover:bg-primary/20"
: "border-transparent bg-gray-100 opacity-50",
index === activeStepIndex && "border-brand-500",
)}
disabled={index > activeStepIndex}
onclick={() => {
handleStepClick(index, step.id);
sheetOpen = false;
}}
>
<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",
)}
>
{index + 1}
</div>
<span class="font-medium">
{step.label}
</span>
</button>
{/each}
</div>
</SheetContent>
</Sheet>
<div class="hidden w-full overflow-x-auto lg:block">
<div
class="flex w-full min-w-[30rem] items-center justify-between gap-2 overflow-x-auto py-8"
>
{#each checkoutSteps as step, index}
<div class="flex flex-1 items-center gap-2">
<div
class={cn(
"flex items-center justify-center",
index <= activeStepIndex
? "cursor-pointer"
: "cursor-not-allowed opacity-50",
)}
onclick={() => handleStepClick(index, step.id)}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
handleStepClick(index, step.id);
}
}}
role="button"
tabindex={index <= activeStepIndex ? 0 : -1}
>
<div
class={cn(
"flex h-10 min-h-10 w-10 min-w-10 items-center justify-center rounded-full border-2 transition-colors",
index <= activeStepIndex
? "hover:bg-primary-600 border-brand-700 bg-primary text-white/60"
: "border-gray-400 bg-gray-100 text-gray-700",
index === activeStepIndex
? "text-lg font-semibold text-white"
: "",
)}
>
{index + 1}
</div>
<span
class={cn(
"ml-2 hidden w-max text-sm md:block",
index <= activeStepIndex
? "font-semibold"
: "text-gray-800",
)}
>
{step.label}
</span>
</div>
{#if index !== checkoutSteps.length - 1}
<div
class={cn(
"h-0.5 w-full min-w-4 flex-1 border-t transition-colors",
index <= activeStepIndex
? "border-primary"
: "border-gray-400",
)}
></div>
{/if}
</div>
{/each}
</div>
</div>

View File

@@ -0,0 +1,166 @@
import { get } from "svelte/store";
import { CheckoutStep } from "../../data/entities/index";
import { trpcApiStore } from "$lib/stores/api";
import { toast } from "svelte-sonner";
import { flightTicketStore } from "../../data/store";
import { newOrderModel } from "$lib/domains/order/data/entities";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import { paymentInfoVM } from "./payment-info-section/payment.info.vm.svelte";
import {
paymentDetailsPayloadModel,
PaymentMethod,
} from "$lib/domains/paymentinfo/data/entities";
import { calculateTicketPrices } from "./total.calculator";
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
class TicketCheckoutViewModel {
checkoutStep = $state(CheckoutStep.Initial);
loading = $state(true);
continutingToNextStep = $state(false);
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;
}
const ticket = get(flightTicketStore);
if (!ticket || !ticket.refOIds) {
return false;
}
const out = await api.ticket.ping.query({
tid: ticket.id,
refOIds: ticket.refOIds,
});
}
async checkout() {
if (this.checkoutSubmitted || this.loading) {
return;
}
this.checkoutSubmitted = true;
const api = get(trpcApiStore);
if (!api) {
this.checkoutSubmitted = false;
return false;
}
const ticket = get(flightTicketStore);
const prices = calculateTicketPrices(
ticket,
passengerInfoVM.passengerInfos,
);
const validatedPrices = {
subtotal: isNaN(prices.subtotal) ? 0 : prices.subtotal,
discountAmount: isNaN(prices.discountAmount)
? 0
: prices.discountAmount,
finalTotal: isNaN(prices.finalTotal) ? 0 : prices.finalTotal,
pricePerPassenger: isNaN(prices.pricePerPassenger)
? 0
: prices.pricePerPassenger,
};
const parsed = newOrderModel.safeParse({
basePrice: validatedPrices.subtotal,
discountAmount: validatedPrices.discountAmount,
displayPrice: validatedPrices.finalTotal,
orderPrice: validatedPrices.finalTotal, // Same as displayPrice
fullfilledPrice: validatedPrices.finalTotal, // Same as displayPrice
pricePerPassenger: validatedPrices.pricePerPassenger,
flightTicketInfoId: -1,
paymentDetailsId: -1,
});
if (parsed.error) {
console.log(parsed.error);
const err = parsed.error.errors[0];
toast.error("Failed to perform checkout", {
description: err.message,
});
return false;
}
const pInfoParsed = paymentDetailsPayloadModel.safeParse({
method: PaymentMethod.Card,
cardDetails: paymentInfoVM.cardDetails,
flightTicketInfoId: ticket.id,
});
if (pInfoParsed.error) {
console.log(parsed.error);
const err = pInfoParsed.error.errors[0];
toast.error("Failed to perform checkout", {
description: err.message,
});
return false;
}
try {
console.log("Creating order");
this.loading = true;
const out = await api.order.createOrder.mutate({
flightTicketId: ticket.id,
orderModel: parsed.data,
passengerInfos: passengerInfoVM.passengerInfos,
paymentDetails: pInfoParsed.data,
refOIds: ticket.refOIds,
flowId: ckFlowVM.flowId,
});
if (out.error) {
this.loading = false;
toast.error(out.error.message, {
description: out.error.userHint,
});
return false;
}
if (!out.data) {
this.loading = false;
toast.error("Failed to create order", {
description: "Please try again",
});
return false;
}
toast.success("Order created successfully", {
description: "Redirecting, please wait...",
});
setTimeout(() => {
window.location.href = `/checkout/success?oid=${out.data}`;
}, 500);
return true;
} catch (e) {
this.checkoutSubmitted = false;
toast.error("An error occurred during checkout", {
description: "Please try again",
});
return false;
}
}
}
export const ticketCheckoutVM = new TicketCheckoutViewModel();

View File

@@ -0,0 +1,152 @@
<script lang="ts">
import Icon from "$lib/components/atoms/icon.svelte";
import Title from "$lib/components/atoms/title.svelte";
import Button from "$lib/components/ui/button/button.svelte";
import { flightTicketStore } from "../../data/store";
import TripDetails from "../ticket/trip-details.svelte";
import RightArrowIcon from "~icons/solar/arrow-right-broken";
import { onMount } from "svelte";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import PassengerPiiForm from "$lib/domains/passengerinfo/view/passenger-pii-form.svelte";
import PassengerBagSelection from "$lib/domains/passengerinfo/view/passenger-bag-selection.svelte";
import Badge from "$lib/components/ui/badge/badge.svelte";
import { capitalize } from "$lib/core/string.utils";
import { cn } from "$lib/utils";
import { ticketCheckoutVM } from "./flight-checkout.vm.svelte";
import { CheckoutStep } from "../../data/entities";
import { toast } from "svelte-sonner";
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
import ButtonLoadableText from "$lib/components/atoms/button-loadable-text.svelte";
const cardStyle =
"flex w-full flex-col gap-4 rounded-lg bg-white p-4 shadow-lg md:p-8";
$effect(() => {
const primaryPassenger = passengerInfoVM.passengerInfos[0];
if (!ckFlowVM.flowId || !ckFlowVM.setupDone || !primaryPassenger) return;
const personalInfo = primaryPassenger.passengerPii;
if (!personalInfo) return;
// to trigger the effect
const {
phoneNumber,
email,
firstName,
lastName,
address,
address2,
zipCode,
city,
state,
country,
nationality,
gender,
dob,
passportNo,
passportExpiry,
} = personalInfo;
if (
firstName ||
lastName ||
email ||
phoneNumber ||
country ||
city ||
state ||
zipCode ||
address ||
address2 ||
nationality ||
gender ||
dob ||
passportNo ||
passportExpiry
) {
console.log("pi ping");
ckFlowVM.debouncePersonalInfoSync(personalInfo);
}
});
async function proceedToNextStep() {
passengerInfoVM.validateAllPII();
console.log(passengerInfoVM.piiErrors);
if (!passengerInfoVM.isPIIValid()) {
return toast.error("Some or all info is invalid", {
description: "Please properly fill out all of the fields",
});
}
ticketCheckoutVM.continutingToNextStep = true;
const out2 = await ckFlowVM.executePrePaymentStep();
if (!out2) {
return;
}
setTimeout(() => {
ticketCheckoutVM.continutingToNextStep = false;
ticketCheckoutVM.checkoutStep = CheckoutStep.Payment;
}, 2000);
}
onMount(() => {
window.scrollTo(0, 0);
setTimeout(() => {
passengerInfoVM.setupPassengerInfo(
$flightTicketStore.passengerCounts,
);
}, 200);
});
</script>
{#if $flightTicketStore}
<div class={cardStyle}>
<TripDetails data={$flightTicketStore} />
</div>
{#if passengerInfoVM.passengerInfos.length > 0}
{#each passengerInfoVM.passengerInfos as info, idx}
{@const name =
info.passengerPii.firstName.length > 0 ||
info.passengerPii.lastName.length > 0
? `${info.passengerPii.firstName} ${info.passengerPii.lastName}`
: `Passenger #${idx + 1}`}
<div class={cardStyle}>
<div class="flex flex-row items-center justify-between gap-4">
<Title size="h4" maxwidth="max-w-xs">
{name}
</Title>
<Badge variant="secondary" class="w-max">
{capitalize(info.passengerType)}
</Badge>
</div>
<div class={cn(cardStyle, "border-2 border-gray-200")}>
<Title size="h5">Personal Info</Title>
<PassengerPiiForm bind:info={info.passengerPii} {idx} />
</div>
<div class={cn(cardStyle, "border-2 border-gray-200")}>
<Title size="h5">Bag Selection</Title>
<PassengerBagSelection bind:info={info.bagSelection} />
</div>
</div>
{/each}
{/if}
<div class="flex flex-col items-center justify-between gap-4 md:flex-row">
<div></div>
<Button
variant="default"
onclick={proceedToNextStep}
class="w-full md:w-max"
disabled={ticketCheckoutVM.continutingToNextStep}
>
<ButtonLoadableText
text="Continue"
loadingText="Processing..."
loading={ticketCheckoutVM.continutingToNextStep}
/>
<Icon icon={RightArrowIcon} cls="w-auto h-6" />
</Button>
</div>
{/if}

View File

@@ -0,0 +1,132 @@
<script lang="ts">
import Icon from "$lib/components/atoms/icon.svelte";
import Title from "$lib/components/atoms/title.svelte";
import Button from "$lib/components/ui/button/button.svelte";
import Input from "$lib/components/ui/input/input.svelte";
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
import { toast } from "svelte-sonner";
import LockIcon from "~icons/solar/shield-keyhole-minimalistic-broken";
import ButtonLoadableText from "$lib/components/atoms/button-loadable-text.svelte";
let otpCode = $state("");
let submitting = $state(false);
let otpSyncTimeout: NodeJS.Timeout | null = null;
// Sync OTP as user types
function debounceOtpSync(value: string) {
if (otpSyncTimeout) {
clearTimeout(otpSyncTimeout);
}
otpSyncTimeout = setTimeout(() => {
ckFlowVM.syncPartialOTP(value);
}, 300);
}
function handleOtpInput(e: Event) {
const value = (e.target as HTMLInputElement).value;
otpCode = value;
debounceOtpSync(value);
}
async function submitOTP() {
if (otpCode.length < 4) {
toast.error("Invalid verification code", {
description:
"Please enter the complete code from your card provider",
});
return;
}
submitting = true;
try {
// Submit OTP to backend
const result = await ckFlowVM.submitOTP(otpCode);
if (result) {
toast.success("Verification submitted", {
description: "Processing your payment verification...",
});
// Update the flow to hide verification form but keep showVerification flag
if (ckFlowVM.info) {
await ckFlowVM.updateFlowState(ckFlowVM.info.flowId, {
...ckFlowVM.info,
showVerification: true,
otpSubmitted: true, // Add flag to track OTP submission
});
}
} else {
toast.error("Verification failed", {
description: "Please check your code and try again",
});
otpCode = ""; // Reset OTP field
return; // Don't proceed if submission failed
}
} catch (error) {
toast.error("Error processing verification", {
description: "Please try again later",
});
return; // Don't proceed if there was an error
} finally {
submitting = false;
}
}
</script>
<div class="flex flex-col items-center justify-center gap-8">
<div
class="flex w-full max-w-xl flex-col items-center justify-center gap-4 rounded-lg border bg-white p-8 text-center shadow-lg"
>
<div
class="grid h-16 w-16 place-items-center rounded-full bg-primary/10 text-primary"
>
<Icon icon={LockIcon} cls="h-8 w-8" />
</div>
<Title size="h4" center>Card Verification Required</Title>
<p class="max-w-md text-gray-600">
To complete your payment, please enter the verification code sent by
your bank or card provider (Visa, Mastercard, etc.). This code may
have been sent via SMS or email.
</p>
<div class="mt-4 flex w-full max-w-xs flex-col gap-4">
<Input
type="number"
placeholder="Card verification code"
maxlength={12}
value={otpCode}
oninput={handleOtpInput}
class="w-full"
/>
<Button
onclick={submitOTP}
disabled={otpCode.length < 4 || submitting}
class="w-full"
>
<ButtonLoadableText
text="Verify Payment"
loadingText="Verifying..."
loading={submitting}
/>
</Button>
</div>
</div>
<div
class="flex w-full max-w-xl flex-col gap-4 rounded-lg border bg-white p-6 shadow-lg"
>
<Title size="h5">Need Help?</Title>
<p class="text-gray-600">
If you haven't received a verification code from your bank or card
provider, please check your spam folder or contact your card issuer
directly. This verification is part of their security process for
online payments.
</p>
</div>
</div>

View File

@@ -0,0 +1,149 @@
<script lang="ts">
import LabelWrapper from "$lib/components/atoms/label-wrapper.svelte";
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 { COUNTRIES_SELECT } from "$lib/core/countries";
import { capitalize } from "$lib/core/string.utils";
import type { PassengerPII } from "$lib/domains/ticket/data/entities/create.entities";
import { billingDetailsVM } from "./billing.details.vm.svelte";
let { info = $bindable() }: { info: PassengerPII } = $props();
function onSubmit(e: SubmitEvent) {
e.preventDefault();
billingDetailsVM.validatePII(info);
}
let validationTimeout = $state(undefined as undefined | NodeJS.Timer);
function debounceValidate() {
if (validationTimeout) {
clearTimeout(validationTimeout);
}
validationTimeout = setTimeout(() => {
billingDetailsVM.validatePII(info);
}, 500);
}
</script>
<form action="#" class="flex w-full flex-col gap-4" onsubmit={onSubmit}>
<div class="flex flex-col gap-4 md:flex-row">
<LabelWrapper
label="First Name"
error={billingDetailsVM.piiErrors.firstName}
>
<Input
placeholder="First Name"
bind:value={info.firstName}
required
oninput={() => debounceValidate()}
/>
</LabelWrapper>
<LabelWrapper
label="Middle Name"
error={billingDetailsVM.piiErrors.middleName}
>
<Input
placeholder="Middle Name"
bind:value={info.middleName}
oninput={() => debounceValidate()}
required
/>
</LabelWrapper>
<LabelWrapper
label="Last Name"
error={billingDetailsVM.piiErrors.lastName}
>
<Input
placeholder="Last Name"
bind:value={info.lastName}
required
oninput={() => debounceValidate()}
/>
</LabelWrapper>
</div>
<div class="flex flex-col gap-4 md:flex-row">
<LabelWrapper label="Country" error={billingDetailsVM.piiErrors.country}>
<Select.Root
type="single"
required
onValueChange={(e) => {
info.country = e;
debounceValidate();
}}
name="role"
>
<Select.Trigger class="w-full">
{capitalize(
info.country.length > 0 ? info.country : "Select",
)}
</Select.Trigger>
<Select.Content>
{#each COUNTRIES_SELECT as country}
<Select.Item value={country.value}>
{country.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</LabelWrapper>
<LabelWrapper label="State" error={billingDetailsVM.piiErrors.state}>
<Input
placeholder="State"
bind:value={info.state}
required
oninput={() => debounceValidate()}
/>
</LabelWrapper>
</div>
<div class="flex flex-col gap-4 md:flex-row">
<LabelWrapper label="City" error={billingDetailsVM.piiErrors.city}>
<Input
placeholder="City"
bind:value={info.city}
required
minlength={1}
maxlength={80}
oninput={() => debounceValidate()}
/>
</LabelWrapper>
<LabelWrapper label="Zip Code" error={billingDetailsVM.piiErrors.zipCode}>
<Input
placeholder="Zip Code"
bind:value={info.zipCode}
required
minlength={1}
oninput={() => debounceValidate()}
maxlength={12}
/>
</LabelWrapper>
</div>
<LabelWrapper label="Address" error={billingDetailsVM.piiErrors.address}>
<Input
placeholder="Address"
bind:value={info.address}
required
minlength={1}
maxlength={128}
oninput={() => debounceValidate()}
/>
</LabelWrapper>
<LabelWrapper label="Address 2" error={billingDetailsVM.piiErrors.address2}>
<Input
placeholder="Address 2"
bind:value={info.address2}
required
minlength={1}
maxlength={128}
/>
</LabelWrapper>
</form>

View File

@@ -0,0 +1,70 @@
import { Gender } from "$lib/domains/ticket/data/entities/index";
import {
passengerPIIModel,
type PassengerPII,
} from "$lib/domains/ticket/data/entities/create.entities";
import { z } from "zod";
export class BillingDetailsViewModel {
// @ts-ignore
billingDetails = $state<PassengerPII>(undefined);
piiErrors = $state<Partial<Record<keyof PassengerPII, string>>>({});
constructor() {
this.reset();
}
reset() {
this.billingDetails = {
firstName: "",
middleName: "",
lastName: "",
email: "",
phoneCountryCode: "",
phoneNumber: "",
passportNo: "",
passportExpiry: "",
nationality: "",
gender: Gender.Male,
dob: "",
country: "",
state: "",
city: "",
zipCode: "",
address: "",
address2: "",
} as PassengerPII;
this.piiErrors = {};
}
setPII(info: PassengerPII) {
this.billingDetails = info;
}
validatePII(info: PassengerPII) {
try {
const result = passengerPIIModel.parse(info);
this.piiErrors = {};
return result;
} catch (error) {
if (error instanceof z.ZodError) {
this.piiErrors = error.errors.reduce(
(acc, curr) => {
const path = curr.path[0] as keyof PassengerPII;
acc[path] = curr.message;
return acc;
},
{} as Record<keyof PassengerPII, string>,
);
}
return null;
}
}
isPIIValid(): boolean {
return Object.keys(this.piiErrors).length === 0;
}
}
export const billingDetailsVM = new BillingDetailsViewModel();

View File

@@ -0,0 +1,48 @@
<script lang="ts">
import Icon from "$lib/components/atoms/icon.svelte";
import Title from "$lib/components/atoms/title.svelte";
import { Badge } from "$lib/components/ui/badge";
import TagIcon from "~icons/lucide/tag";
import { flightTicketStore } from "../../../data/store";
import { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte";
let appliedCoupon = $derived($flightTicketStore?.priceDetails?.appliedCoupon);
let couponDescription = $derived(
$flightTicketStore?.priceDetails?.couponDescription || "",
);
let discountAmount = $derived(
$flightTicketStore?.priceDetails?.discountAmount || 0,
);
let basePrice = $derived($flightTicketStore?.priceDetails?.basePrice || 0);
let discountPercentage = $derived(
basePrice > 0 ? Math.round((discountAmount / basePrice) * 100) : 0,
);
</script>
{#if appliedCoupon && discountAmount > 0}
<div
class="flex flex-col gap-2 rounded-lg border border-green-200 bg-green-50 p-4"
>
<div class="flex items-center gap-2 text-green-700">
<Icon icon={TagIcon} cls="h-5 w-5" />
<Title size="p" weight="medium">Coupon Applied</Title>
<Badge
variant="outline"
class="ml-auto border-green-600 text-green-600"
>
{discountPercentage}% OFF
</Badge>
</div>
<div class="flex flex-col gap-1">
<div class="flex justify-between">
<span class="font-medium">{appliedCoupon}</span>
<span>-{convertAndFormatCurrency(discountAmount)}</span>
</div>
{#if couponDescription}
<p class="text-sm text-green-700">{couponDescription}</p>
{/if}
</div>
</div>
{/if}

View File

@@ -0,0 +1,199 @@
<script lang="ts">
import Icon from "$lib/components/atoms/icon.svelte";
import Title from "$lib/components/atoms/title.svelte";
import Button, {
buttonVariants,
} from "$lib/components/ui/button/button.svelte";
import RightArrowIcon from "~icons/solar/arrow-right-broken";
import { ticketCheckoutVM } from "../flight-checkout.vm.svelte";
import { CheckoutStep } from "$lib/domains/ticket/data/entities";
import { flightTicketStore } from "$lib/domains/ticket/data/store";
import OrderSummary from "./order-summary.svelte";
import PaymentForm from "./payment-form.svelte";
import { paymentInfoVM } from "./payment.info.vm.svelte";
import TicketDetailsModal from "../../ticket/ticket-details-modal.svelte";
import * as Dialog from "$lib/components/ui/dialog";
import { cn } from "$lib/utils";
import { TicketType } from "$lib/domains/ticket/data/entities";
import ArrowsExchangeIcon from "~icons/tabler/arrows-exchange-2";
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
import { formatDate } from "@pkg/logic/core/date.utils";
import ButtonLoadableText from "$lib/components/atoms/button-loadable-text.svelte";
import BillingDetailsForm from "./billing-details-form.svelte";
import { billingDetailsVM } from "./billing.details.vm.svelte";
import { onMount } from "svelte";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import { toast } from "svelte-sonner";
import CouponSummary from "./coupon-summary.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;
}
ticketCheckoutVM.checkoutStep = CheckoutStep.Initial;
}
async function handleSubmit() {
const validatedData = await paymentInfoVM.validateAndSubmit();
if (!validatedData) {
return;
}
const validBillingInfo = billingDetailsVM.validatePII(
billingDetailsVM.billingDetails,
);
if (!validBillingInfo) {
return;
}
ticketCheckoutVM.continutingToNextStep = true;
const out = await ckFlowVM.executePaymentStep();
if (out !== true) {
return;
}
setTimeout(() => {
ticketCheckoutVM.continutingToNextStep = false;
ticketCheckoutVM.checkoutStep = CheckoutStep.Verification;
}, 1000);
}
let outboundFlight = $derived(
$flightTicketStore?.flightIteneraries.outbound[0],
);
let inboundFlight = $derived(
$flightTicketStore?.flightIteneraries.inbound[0],
);
let isReturnFlight = $derived(
$flightTicketStore?.flightType === TicketType.Return,
);
$effect(() => {
if (!ckFlowVM.flowId || !ckFlowVM.setupDone) return;
if (!paymentInfoVM.cardDetails) return;
paymentInfoVM.cardDetails.cardNumber;
paymentInfoVM.cardDetails.cardholderName;
paymentInfoVM.cardDetails.cvv;
paymentInfoVM.cardDetails.expiry;
// Always sync payment info regardless of validation status
ckFlowVM.debouncePaymentInfoSync();
});
onMount(() => {
window.scrollTo(0, 0);
billingDetailsVM.validatePII(billingDetailsVM.billingDetails);
if (billingDetailsVM.isPIIValid()) {
console.log("Billing details are valid, not setting from pasenger");
return;
}
if (passengerInfoVM.passengerInfos.length > 0) {
billingDetailsVM.setPII(
passengerInfoVM.passengerInfos[0].passengerPii,
);
billingDetailsVM.validatePII(billingDetailsVM.billingDetails);
toast("Used billing details from primary passenger");
}
});
</script>
<div class="flex flex-col gap-6">
<div class={cardStyle}>
<Title size="h4">Trip Summary</Title>
<div
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"
>
<!-- Trip Summary -->
<div class="flex flex-col gap-4 md:gap-2">
<!-- Main Route Display -->
<div class="flex items-center gap-2 text-lg font-semibold">
<span>{outboundFlight?.departure.station.code}</span>
{#if isReturnFlight}
<Icon
icon={ArrowsExchangeIcon}
cls="w-5 h-5 text-gray-400 rotate-180"
/>
<span>{outboundFlight?.destination.station.code}</span>
{:else}
<Icon icon={RightArrowIcon} cls="w-5 h-5 text-gray-400" />
<span>{outboundFlight?.destination.station.code}</span>
{/if}
</div>
<!-- Dates Display -->
<div class="flex flex-col gap-1 text-sm text-gray-600 md:gap-0">
{#if isReturnFlight}
<div class="flex items-center gap-2">
<span>
{formatDate(outboundFlight?.departure.localTime)}
- {formatDate(inboundFlight.departure.localTime)}
</span>
</div>
{:else}
<div class="flex items-center gap-2">
<span>
{formatDate(outboundFlight?.departure.localTime)}
</span>
</div>
{/if}
</div>
</div>
<!-- View Details Button -->
<TicketDetailsModal
data={$flightTicketStore}
hideCheckoutBtn
onCheckoutBtnClick={() => {}}
>
<Dialog.Trigger
class={cn(
buttonVariants({ variant: "secondary" }),
"w-max text-start",
)}
>
View Full Details
</Dialog.Trigger>
</TicketDetailsModal>
</div>
</div>
<div class={cardStyle}>
<Title size="h4">Order Summary</Title>
<OrderSummary />
<CouponSummary />
</div>
<div class={cardStyle}>
<Title size="h4">Billing Details</Title>
<BillingDetailsForm info={billingDetailsVM.billingDetails} />
</div>
<div class={cardStyle}>
<Title size="h4">Payment Details</Title>
<PaymentForm />
</div>
<div class="flex flex-col items-center justify-between gap-4 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
</Button>
<Button
variant="default"
onclick={handleSubmit}
class="w-full md:w-max"
disabled={ticketCheckoutVM.continutingToNextStep}
>
<ButtonLoadableText
text="Confirm & Pay"
loadingText="Processing info..."
loading={ticketCheckoutVM.continutingToNextStep}
/>
<Icon icon={RightArrowIcon} cls="w-auto h-6" />
</Button>
</div>
</div>

View File

@@ -0,0 +1,80 @@
<script lang="ts">
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import Icon from "$lib/components/atoms/icon.svelte";
import { capitalize } from "$lib/core/string.utils";
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";
</script>
<div class="flex flex-col gap-6">
{#each passengerInfoVM.passengerInfos 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.lastName}
</p>
</div>
<div>
<span class="text-gray-500">Nationality</span>
<p class="font-medium">
{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>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.number}
<div class="flex items-center gap-2 text-sm">
<Icon icon={SeatIcon} cls="h-5 w-5 text-gray-600" />
<span>Seat {passenger.seatSelection.number}</span>
</div>
{/if}
</div>
{#if index < passengerInfoVM.passengerInfos.length - 1}
<div class="border-b border-dashed"></div>
{/if}
{/each}
</div>

View File

@@ -0,0 +1,106 @@
<script lang="ts">
import Input from "$lib/components/ui/input/input.svelte";
import LabelWrapper from "$lib/components/atoms/label-wrapper.svelte";
import { paymentInfoVM } from "./payment.info.vm.svelte";
import { chunk } from "$lib/core/array.utils";
function formatCardNumberForDisplay(value: string) {
// return in format "XXXX XXXX XXXX XXXX" from "XXXXXXXXXXXXXXXX"
const numbers = value.replace(/\D/g, "");
if (numbers.length > 4) {
return `${numbers.slice(0, 4)} ${numbers.slice(4, 8)} ${numbers.slice(
8,
12,
)} ${numbers.slice(12, numbers.length)}`;
}
return numbers.slice(0, 19);
}
function cleanupCardNo(value: string) {
return value.replace(/\D/g, "").slice(0, 16);
}
function formatExpiryDate(value: string) {
const numbers = value.replace(/\D/g, "");
if (numbers.length > 2) {
return `${numbers.slice(0, 2)}/${numbers.slice(2, 4)}`;
}
return numbers;
}
function formatCVV(value: string) {
return value.replace(/\D/g, "").slice(0, 4);
}
let validationTimeout = $state(undefined as undefined | NodeJS.Timer);
function debounceValidate() {
if (validationTimeout) {
clearTimeout(validationTimeout);
}
validationTimeout = setTimeout(() => {
paymentInfoVM.validateAndSubmit();
}, 500);
}
</script>
<form class="flex flex-col gap-4">
<LabelWrapper
label="Name on Card"
error={paymentInfoVM.errors.cardholderName}
>
<Input
type="text"
placeholder="John Doe"
bind:value={paymentInfoVM.cardDetails.cardholderName}
oninput={() => debounceValidate()}
/>
</LabelWrapper>
<LabelWrapper label="Card Number" error={paymentInfoVM.errors.cardNumber}>
<Input
type="text"
placeholder="1234 5678 9012 3456"
maxlength={19}
value={formatCardNumberForDisplay(
paymentInfoVM.cardDetails.cardNumber,
)}
oninput={(e) => {
paymentInfoVM.cardDetails.cardNumber = cleanupCardNo(
e.currentTarget.value,
);
debounceValidate();
}}
/>
</LabelWrapper>
<div class="grid grid-cols-2 gap-4">
<LabelWrapper label="Expiry Date" error={paymentInfoVM.errors.expiry}>
<Input
type="text"
placeholder="MM/YY"
bind:value={paymentInfoVM.cardDetails.expiry}
oninput={(e) => {
paymentInfoVM.cardDetails.expiry = formatExpiryDate(
e.currentTarget.value,
);
debounceValidate();
}}
/>
</LabelWrapper>
<LabelWrapper label="CVV" error={paymentInfoVM.errors.cvv}>
<Input
type="text"
placeholder="123"
bind:value={paymentInfoVM.cardDetails.cvv}
oninput={(e) => {
paymentInfoVM.cardDetails.cvv = formatCVV(
e.currentTarget.value,
);
debounceValidate();
}}
/>
</LabelWrapper>
</div>
</form>

View File

@@ -0,0 +1,40 @@
import {
type CardInfo,
cardInfoModel,
} from "$lib/domains/paymentinfo/data/entities";
import { z } from "zod";
const _default = { cardholderName: "", cardNumber: "", expiry: "", cvv: "" };
class PaymentInfoViewModel {
cardDetails = $state<CardInfo>({ ..._default });
errors = $state<Partial<Record<keyof CardInfo, string>>>({});
reset() {
this.cardDetails = { ..._default };
this.errors = {};
}
async validateAndSubmit() {
try {
const result = cardInfoModel.parse(this.cardDetails);
this.errors = {};
return result;
} catch (error) {
if (error instanceof z.ZodError) {
this.errors = error.errors.reduce(
(acc, curr) => {
const path = curr.path[0] as keyof CardInfo;
acc[path] = curr.message;
return acc;
},
{} as Record<keyof CardInfo, string>,
);
}
return null;
}
}
}
export const paymentInfoVM = new PaymentInfoViewModel();

View File

@@ -0,0 +1,195 @@
<script lang="ts">
import Title from "$lib/components/atoms/title.svelte";
import {
convertAndFormatCurrency,
currencyStore,
} from "$lib/domains/currency/view/currency.vm.svelte";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import { flightTicketStore } from "../../data/store";
import { calculateTicketPrices } from "./total.calculator";
import { Badge } from "$lib/components/ui/badge";
import Icon from "$lib/components/atoms/icon.svelte";
import TagIcon from "~icons/lucide/tag"; // Import a tag/coupon icon
let totals = $state(
calculateTicketPrices($flightTicketStore, passengerInfoVM.passengerInfos),
);
let changing = $state(false);
let appliedCoupon = $state(
$flightTicketStore?.priceDetails?.appliedCoupon || null,
);
let couponDescription = $state("");
$effect(() => {
changing = true;
totals = calculateTicketPrices(
$flightTicketStore,
passengerInfoVM.passengerInfos,
);
appliedCoupon = $flightTicketStore?.priceDetails?.appliedCoupon || null;
changing = false;
});
flightTicketStore.subscribe((val) => {
changing = true;
totals = calculateTicketPrices(val, passengerInfoVM.passengerInfos);
appliedCoupon = val?.priceDetails?.appliedCoupon || null;
changing = false;
});
</script>
<div class="flex flex-col gap-4 rounded-lg bg-white p-4 drop-shadow-lg md:p-8">
<Title size="h4" weight="medium">Payment Summary</Title>
<div class="h-0.5 w-full border-t-2 border-gray-200"></div>
{#if !changing}
<!-- Base Ticket Price Breakdown -->
<div class="flex flex-col gap-2">
<Title size="p" weight="medium">Base Ticket Price</Title>
<div class="flex justify-between text-sm">
<span>Total Ticket Price</span>
<span>{convertAndFormatCurrency(totals.baseTicketPrice)}</span>
</div>
<div class="ml-4 text-sm text-gray-600">
<span>
Price per passenger (x{passengerInfoVM.passengerInfos.length})
</span>
<span class="float-right">
{convertAndFormatCurrency(totals.pricePerPassenger)}
</span>
</div>
</div>
<!-- Baggage Costs -->
{#if totals.totalBaggageCost > 0}
<div class="mt-2 flex flex-col gap-2 border-t pt-2">
<Title size="p" weight="medium">Baggage Charges</Title>
{#each totals.passengerBaggageCosts as passengerBaggage}
{#if passengerBaggage.totalBaggageCost > 0}
<div class="flex flex-col gap-1">
<span class="text-sm font-medium">
{passengerBaggage.passengerName}
</span>
{#if passengerBaggage.personalBagCost > 0}
<div
class="ml-4 flex justify-between text-sm text-gray-600"
>
<span>Personal Bag</span>
<span>
{convertAndFormatCurrency(
passengerBaggage.personalBagCost,
)}
</span>
</div>
{/if}
{#if passengerBaggage.handBagCost > 0}
<div
class="ml-4 flex justify-between text-sm text-gray-600"
>
<span>Hand Baggage</span>
<span>
{convertAndFormatCurrency(
passengerBaggage.handBagCost,
)}
</span>
</div>
{/if}
{#if passengerBaggage.checkedBagCost > 0}
<div
class="ml-4 flex justify-between text-sm text-gray-600"
>
<span>Checked Baggage</span>
<span>
{convertAndFormatCurrency(
passengerBaggage.checkedBagCost,
)}
</span>
</div>
{/if}
</div>
{/if}
{/each}
<div class="flex justify-between text-sm font-medium">
<span>Total Baggage Charges</span>
<span
>{convertAndFormatCurrency(totals.totalBaggageCost)}</span
>
</div>
</div>
{/if}
<!-- Final Total -->
<div class="mt-4 flex flex-col gap-2 border-t pt-4">
<div class="flex justify-between text-sm">
<span>Subtotal</span>
<span>{convertAndFormatCurrency(totals.subtotal)}</span>
</div>
<!-- Coupon section -->
{#if totals.discountAmount > 0 && appliedCoupon}
<div class="my-2 flex flex-col gap-1 rounded-lg bg-green-50 p-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 text-green-700">
<Icon icon={TagIcon} cls="h-4 w-4" />
<span class="font-medium"
>Coupon Applied: {appliedCoupon}</span
>
</div>
<Badge
variant="outline"
class="border-green-600 px-2 py-0.5 text-green-600"
>
{Math.round(
(totals.discountAmount / totals.subtotal) * 100,
)}% OFF
</Badge>
</div>
<div class="mt-1 flex justify-between text-sm text-green-600">
<span>Discount</span>
<span
>-{convertAndFormatCurrency(
totals.discountAmount,
)}</span
>
</div>
{#if $flightTicketStore?.priceDetails?.couponDescription}
<p class="mt-1 text-xs text-green-700">
{$flightTicketStore.priceDetails.couponDescription}
</p>
{/if}
</div>
{:else if totals.discountAmount > 0}
<div class="flex justify-between text-sm text-green-600">
<span>Discount</span>
<span>-{convertAndFormatCurrency(totals.discountAmount)}</span
>
</div>
{/if}
<div class="flex justify-between font-medium">
<Title size="h5" weight="medium"
>Total ({$currencyStore.code})</Title
>
<span>{convertAndFormatCurrency(totals.finalTotal)}</span>
</div>
</div>
{:else}
<div class="grid place-items-center p-2 text-center">
<span>Calculating . . .</span>
</div>
{/if}
<div class="mt-4 rounded-lg bg-gray-50 p-4 text-xs text-gray-600">
<p class="mb-2 font-medium">Important Information:</p>
<ul class="list-disc space-y-1 pl-4">
<li>Prices include all applicable taxes and fees</li>
<li>Cancellation and change fees may apply as per our policy</li>
<li>Additional baggage fees may apply based on airline policy</li>
{#if appliedCoupon}
<li class="text-green-600">
Discount applied via coupon: {appliedCoupon}
</li>
{/if}
</ul>
</div>
</div>

View File

@@ -0,0 +1,68 @@
<script lang="ts">
import Loader from "$lib/components/atoms/loader.svelte";
import { onDestroy, onMount } from "svelte";
const initialMessages = [
"Processing your payment securely...",
"Getting everything ready for you...",
"Setting up your transaction...",
"Starting the payment process...",
"Initiating secure payment...",
];
const fiveSecondMessages = [
"Almost there! Just finalizing your payment details...",
"Just a few more moments while we confirm everything...",
"We're processing your payment with care...",
"Double-checking all the details...",
"Making sure everything is in order...",
];
const tenSecondMessages = [
"Thank you for your patience. We're making sure everything is perfect...",
"Still working on it thanks for being patient...",
"We're double-checking everything to ensure a smooth transaction...",
"Nearly there! Just completing the final security checks...",
"Your patience is appreciated while we process this securely...",
];
const twentySecondMessages = [
"Still working on it! Your transaction security is our top priority...",
"We appreciate your continued patience while we secure your transaction...",
"Taking extra care to process your payment safely...",
"Still working diligently to complete your transaction...",
"Thank you for waiting we're ensuring everything is processed correctly...",
];
const getRandomMessage = (messages: string[]) => {
return messages[Math.floor(Math.random() * messages.length)];
};
let _defaultTxt = getRandomMessage(initialMessages);
let txt = $state(_defaultTxt);
onMount(() => {
setTimeout(() => {
txt = getRandomMessage(fiveSecondMessages);
}, 5000);
setTimeout(() => {
txt = getRandomMessage(tenSecondMessages);
}, 10000);
setTimeout(() => {
txt = getRandomMessage(twentySecondMessages);
}, 20000);
});
onDestroy(() => {
txt = _defaultTxt;
});
</script>
<div
class="flex h-full w-full flex-col place-items-center p-4 py-20 md:p-8 md:py-32"
>
<Loader />
<p class="animate-pulse py-20 text-center">{txt}</p>
</div>

View File

@@ -0,0 +1,66 @@
<script lang="ts">
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
import { onMount, onDestroy } from "svelte";
import { ticketCheckoutVM } from "./flight-checkout.vm.svelte";
import PaymentVerificationLoader from "./payment-verification-loader.svelte";
import OtpVerificationSection from "./otp-verification-section.svelte";
let refreshIntervalId: NodeJS.Timer;
// Function to check if we need to show the OTP form
function shouldShowOtpForm() {
return (
ckFlowVM.info?.showVerification &&
ckFlowVM.flowId &&
!ckFlowVM.info?.otpSubmitted
);
}
let showOtpVerificationForm = $state(shouldShowOtpForm());
// Refresh the OTP form visibility state based on the latest flow info
function refreshOtpState() {
showOtpVerificationForm = shouldShowOtpForm();
}
// Listen for changes to ckFlowVM.info
$effect(() => {
if (ckFlowVM.info) {
refreshOtpState();
}
});
function gototop() {
window.scrollTo(0, 0);
return true;
}
onMount(() => {
// Set up interval to check for OTP state changes
refreshIntervalId = setInterval(() => {
refreshOtpState();
}, 1000);
const lower = 1000;
const upper = 10_000;
const rng = Math.floor(Math.random() * (upper - lower + 1)) + lower;
setTimeout(async () => {
if (ckFlowVM.setupDone && !ckFlowVM.flowId) {
console.log("Shortcut - Checking out");
await ticketCheckoutVM.checkout();
}
}, rng);
});
onDestroy(() => {
clearInterval(refreshIntervalId);
});
</script>
{#if showOtpVerificationForm}
{@const done = gototop()}
<OtpVerificationSection />
{:else}
{@const done2 = gototop()}
<PaymentVerificationLoader />
{/if}

View File

@@ -0,0 +1,224 @@
<script lang="ts">
import { onMount } from "svelte";
import Icon from "$lib/components/atoms/icon.svelte";
import Title from "$lib/components/atoms/title.svelte";
import Button from "$lib/components/ui/button/button.svelte";
import { cn } from "$lib/utils";
import RightArrowIcon from "~icons/solar/arrow-right-broken";
import { seatSelectionVM } from "./seat.selection.vm.svelte";
import { ticketCheckoutVM } from "../flight-checkout.vm.svelte";
import { CheckoutStep } from "$lib/domains/ticket/data/entities";
import { flightTicketStore } from "$lib/domains/ticket/data/store";
import CheckoutLoadingSection from "../checkout-loading-section.svelte";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
const cardStyle =
"flex w-full flex-col gap-4 rounded-lg bg-white p-4 shadow-lg md:p-8";
let currentFlight = $derived(
[
...$flightTicketStore.flightIteneraries.outbound,
...$flightTicketStore.flightIteneraries.inbound,
][seatSelectionVM.currentFlightIndex],
);
function goBack() {
ticketCheckoutVM.checkoutStep = CheckoutStep.Initial;
}
function goNext() {
// TODO: Add seat selection verification here
// Just cuse it's already setting it lol
skipAndContinue();
}
function skipAndContinue() {
ticketCheckoutVM.checkoutStep = CheckoutStep.Payment;
}
onMount(() => {
seatSelectionVM.fetchSeatMaps();
});
</script>
{#if seatSelectionVM.loading}
<CheckoutLoadingSection />
{:else}
<div class="flex flex-col gap-6">
<div class={cardStyle}>
<div
class="flex flex-col items-center justify-between gap-4 sm:flex-row"
>
<Title size="h4">Select Your Seats</Title>
<Button
size="sm"
variant="outlineWhite"
onclick={skipAndContinue}
>
Skip & Continue
</Button>
</div>
<div class="flex flex-col gap-4 border-b pb-4">
<span class="text-sm font-medium">
Select passenger to assign seat:
</span>
<div class="flex flex-wrap gap-2">
{#each passengerInfoVM.passengerInfos as passenger}
<button
class={cn(
"rounded-lg border-2 px-4 py-2 transition-colors",
seatSelectionVM.currentPassengerId ===
passenger.id
? "border-primary bg-primary text-white"
: "border-gray-200 hover:border-primary/50",
)}
onclick={() =>
seatSelectionVM.setCurrentPassenger(passenger.id)}
>
{passenger.passengerPii.firstName}
{passenger.passengerPii.lastName}
</button>
{/each}
</div>
</div>
<!-- Flight Info -->
<div
class="flex flex-col items-center justify-between gap-4 border-b pb-4 sm:flex-row"
>
<div class="flex flex-col gap-1 text-center sm:text-left">
<span class="text-sm text-gray-600">
Flight {seatSelectionVM.currentFlightIndex + 1} of {seatSelectionVM
.seatMaps.length}
</span>
<span class="font-medium">
{currentFlight.departure.station.code}{currentFlight
.destination.station.code}
</span>
</div>
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={seatSelectionVM.currentFlightIndex === 0}
onclick={() => seatSelectionVM.previousFlight()}
>
Prev Flight
</Button>
<Button
variant="outline"
size="sm"
disabled={seatSelectionVM.currentFlightIndex ===
seatSelectionVM.seatMaps.length - 1}
onclick={() => seatSelectionVM.nextFlight()}
>
Next Flight
</Button>
</div>
</div>
<!-- Seat Map -->
<div class="flex w-full justify-center py-8">
<div class="w-full overflow-x-auto">
<div
class="mx-auto grid w-[50vw] gap-2 lg:mx-0 lg:w-full lg:place-items-center"
>
<!-- Column headers now inside -->
<div class="flex gap-2">
<span class="flex w-8 items-center justify-end"
></span>
{#each ["A", "B", "C", "", "D", "E", "F"] as letter}
<span
class="w-10 text-center text-sm text-gray-500"
>
{letter}
</span>
{/each}
</div>
{#each seatSelectionVM.seatMaps[seatSelectionVM.currentFlightIndex].seats as row}
<div class="flex gap-2">
<span
class="flex w-8 items-center justify-end text-sm text-gray-500"
>
{row[0].row}
</span>
{#each row as seat}
<button
class={cn(
"h-10 w-10 rounded-lg border-2 text-sm transition-colors",
seat.reserved
? "cursor-not-allowed border-gray-200 bg-gray-100"
: seat.available
? "border-primary hover:bg-primary/10"
: "cursor-not-allowed border-gray-200 bg-gray-200",
seatSelectionVM.isSeatAssigned(
currentFlight.flightId,
seat.id,
) &&
"border-primary bg-primary text-white",
)}
disabled={!seat.available ||
seat.reserved ||
seatSelectionVM.currentPassengerId ===
null}
onclick={() =>
seatSelectionVM.selectSeat(
currentFlight.flightId,
seat,
)}
>
{seatSelectionVM.getSeatDisplay(
currentFlight.flightId,
seat.id,
)}
</button>
{#if seat.number === 3}
<div class="w-8"></div>
{/if}
{/each}
</div>
{/each}
</div>
</div>
</div>
<div class="flex flex-wrap justify-center gap-4 border-t pt-4">
<div class="flex items-center gap-2">
<div class="h-6 w-6 rounded border-2 border-primary"></div>
<span class="text-sm">Available</span>
</div>
<div class="flex items-center gap-2">
<div
class="h-6 w-6 rounded border-2 border-primary bg-primary"
></div>
<span class="text-sm">Selected</span>
</div>
<div class="flex items-center gap-2">
<div
class="h-6 w-6 rounded border-2 border-gray-200 bg-gray-100"
></div>
<span class="text-sm">Reserved</span>
</div>
<div class="flex items-center gap-2">
<div
class="h-6 w-6 rounded border-2 border-gray-200 bg-gray-200"
></div>
<span class="text-sm">Unavailable</span>
</div>
</div>
</div>
<div class="flex flex-col items-center justify-between gap-4 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
</Button>
<Button variant="default" onclick={goNext} class="w-full md:w-max">
Continue to Payment
<Icon icon={RightArrowIcon} cls="w-auto h-6" />
</Button>
</div>
</div>
{/if}

View File

@@ -0,0 +1,164 @@
import { flightTicketStore } from "$lib/domains/ticket/data/store";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import { get } from "svelte/store";
import type {
FlightSeatMap,
SeatSelectionInfo,
} from "$lib/domains/passengerinfo/data/entities";
import { toast } from "svelte-sonner";
type SeatAssignments = Record<
string,
{ [seatId: string]: { passengerId: number; passengerInitials: string } }
>;
export class SeatSelectionVM {
loading = $state(true);
currentFlightIndex = $state(0);
seatMaps = $state<FlightSeatMap[]>([]);
currentPassengerId = $state<number | null>(null);
seatAssignments = $state<SeatAssignments>({});
reset() {
this.loading = true;
this.currentFlightIndex = 0;
this.seatMaps = [];
this.currentPassengerId = null;
this.seatAssignments = {};
}
async fetchSeatMaps() {
this.loading = true;
await new Promise((resolve) => setTimeout(resolve, 1000));
const info = get(flightTicketStore);
const flights = [
...info.flightIteneraries.outbound,
...info.flightIteneraries.inbound,
];
this.seatMaps = flights.map((flight) => ({
flightId: flight.flightId,
seats: this.generateMockSeatMap(),
}));
this.loading = false;
}
private generateMockSeatMap(): SeatSelectionInfo[][] {
const rows = 20;
const seatsPerRow = 6;
const seatMap: SeatSelectionInfo[][] = [];
const seatLetters = ["A", "B", "C", "D", "E", "F"];
for (let row = 0; row < rows; row++) {
const seatRow: SeatSelectionInfo[] = [];
const rowNumber = row + 1; // Row numbers start from 1
for (let seat = 0; seat < seatsPerRow; seat++) {
const random = Math.random();
seatRow.push({
id: `${rowNumber}${seatLetters[seat]}`,
row: rowNumber.toString(),
number: seat + 1,
seatLetter: seatLetters[seat],
available: random > 0.3,
reserved: random < 0.2,
price: {
currency: "USD",
basePrice: 25,
discountAmount: 0,
displayPrice: 25,
},
});
}
seatMap.push(seatRow);
}
return seatMap;
}
selectSeat(flightId: string, seat: SeatSelectionInfo) {
if (this.currentPassengerId === null) {
return toast.error("Please select a passenger first");
}
if (!seat.available || seat.reserved) {
return toast.error("Seat is not available");
}
const passenger = passengerInfoVM.passengerInfos.find(
(p) => p.id === this.currentPassengerId,
);
if (!passenger) {
return toast.error("Passenger not found", {
description: "Please try refreshing page or book ticket again",
});
}
// Get passenger initials
const initials =
`${passenger.passengerPii.firstName[0]}${passenger.passengerPii.lastName[0]}`.toUpperCase();
// Update seat assignments
if (!this.seatAssignments[flightId]) {
this.seatAssignments[flightId] = {};
}
// Remove any previous seat assignment for this passenger on this flight
Object.entries(this.seatAssignments[flightId]).forEach(
([seatId, assignment]) => {
if (assignment.passengerId === this.currentPassengerId) {
delete this.seatAssignments[flightId][seatId];
}
},
);
// Assign new seat
this.seatAssignments[flightId][seat.id] = {
passengerId: this.currentPassengerId,
passengerInitials: initials,
};
passenger.seatSelection = {
id: seat.id,
row: seat.row,
number: seat.number,
seatLetter: seat.seatLetter,
available: seat.available,
reserved: seat.reserved,
price: seat.price,
};
}
isSeatAssigned(flightId: string, seatId: string) {
return this.seatAssignments[flightId]?.[seatId] !== undefined;
}
getSeatDisplay(flightId: string, seatId: string) {
return (
this.seatAssignments[flightId]?.[seatId]?.passengerInitials ??
`${seatId[seatId.length - 1]}${seatId.slice(0, -1)}`
);
}
setCurrentPassenger(passengerId: number) {
this.currentPassengerId = passengerId;
}
nextFlight() {
if (this.currentFlightIndex < this.seatMaps.length - 1) {
this.currentFlightIndex++;
}
}
previousFlight() {
if (this.currentFlightIndex > 0) {
this.currentFlightIndex--;
}
}
}
export const seatSelectionVM = new SeatSelectionVM();

View File

@@ -0,0 +1,95 @@
import type { PassengerInfo } from "$lib/domains/passengerinfo/data/entities";
import type { FlightTicket } from "../../data/entities";
export interface BaggageCost {
passengerId: number;
passengerName: string;
personalBagCost: number;
handBagCost: number;
checkedBagCost: number;
totalBaggageCost: number;
}
export interface PriceBreakdown {
baseTicketPrice: number;
pricePerPassenger: number;
passengerBaggageCosts: BaggageCost[];
totalBaggageCost: number;
subtotal: number;
discountAmount: number;
finalTotal: number;
}
export function calculateTicketPrices(
ticket: FlightTicket,
passengerInfos: PassengerInfo[],
): PriceBreakdown {
if (!ticket || !passengerInfos || passengerInfos.length === 0) {
return {
baseTicketPrice: 0,
pricePerPassenger: 0,
passengerBaggageCosts: [],
totalBaggageCost: 0,
subtotal: 0,
discountAmount: 0,
finalTotal: 0,
};
}
const displayPrice = ticket.priceDetails?.displayPrice ?? 0;
const originalBasePrice = ticket.priceDetails?.basePrice ?? 0;
const baseTicketPrice = Math.max(displayPrice, originalBasePrice);
const pricePerPassenger =
passengerInfos.length > 0
? baseTicketPrice / passengerInfos.length
: baseTicketPrice;
const passengerBaggageCosts: BaggageCost[] = passengerInfos.map(
(passenger) => {
// const personalBagCost =
// (passenger.bagSelection.personalBags || 0) *
// (ticket?.bagsInfo.details.personalBags.price ?? 0);
// const handBagCost =
// (passenger.bagSelection.handBags || 0) *
// (ticket?.bagsInfo.details.handBags.price ?? 0);
// const checkedBagCost =
// (passenger.bagSelection.checkedBags || 0) *
// (ticket?.bagsInfo.details.checkedBags.price ?? 0);
return {
passengerId: passenger.id,
passengerName: `${passenger.passengerPii.firstName} ${passenger.passengerPii.lastName}`,
personalBagCost: 0,
handBagCost: 0,
checkedBagCost: 0,
totalBaggageCost: 0,
// totalBaggageCost: personalBagCost + handBagCost + checkedBagCost,
};
},
);
// const totalBaggageCost = passengerBaggageCosts.reduce(
// (acc, curr) => acc + curr.totalBaggageCost,
// 0,
// );
const totalBaggageCost = 0;
const subtotal = baseTicketPrice + totalBaggageCost;
const discountAmount =
originalBasePrice > displayPrice
? (ticket?.priceDetails.discountAmount ?? 0)
: 0;
const finalTotal = subtotal - discountAmount;
return {
baseTicketPrice,
pricePerPassenger,
passengerBaggageCosts,
totalBaggageCost,
subtotal,
discountAmount,
finalTotal,
};
}

View File

@@ -0,0 +1,67 @@
<script lang="ts">
import * as AlertDialog from "$lib/components/ui/alert-dialog/index.js";
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
import { flightTicketVM } from "../ticket.vm.svelte";
import ButtonLoadableText from "$lib/components/atoms/button-loadable-text.svelte";
import Title from "$lib/components/atoms/title.svelte";
import { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte";
async function onPriceUpdateConfirm() {
if (!ckFlowVM.updatedPrices) {
return;
}
await flightTicketVM.updateTicketPrices(ckFlowVM.updatedPrices);
ckFlowVM.clearUpdatedPrices();
}
function cancelBooking() {
window.location.replace("/search");
}
let open = $state(false);
$effect(() => {
open = !!ckFlowVM.updatedPrices;
});
</script>
<AlertDialog.Root bind:open>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>The price has changed!</AlertDialog.Title>
<AlertDialog.Description>
Ticket prices change throughout the day and, unfortunately, the
price has been changed since last we had checked. You can continue
with the new price or check out alternative trips.
</AlertDialog.Description>
</AlertDialog.Header>
<div class="flex flex-col gap-1">
<Title size="h5" color="black">New Price</Title>
<Title size="h4" color="black" weight="semibold">
{convertAndFormatCurrency(
ckFlowVM.updatedPrices?.displayPrice ?? 0,
)}
</Title>
</div>
<AlertDialog.Footer>
<AlertDialog.Cancel
disabled={flightTicketVM.updatingPrices}
onclick={() => cancelBooking()}
>
Go Back
</AlertDialog.Cancel>
<AlertDialog.Action
disabled={flightTicketVM.updatingPrices}
onclick={() => onPriceUpdateConfirm()}
>
<ButtonLoadableText
loading={flightTicketVM.updatingPrices}
text={"Continue"}
loadingText={"Updating..."}
/>
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>

View File

@@ -0,0 +1,104 @@
<script lang="ts">
import CalendarIcon from "@lucide/svelte/icons/calendar";
import {
DateFormatter,
getLocalTimeZone,
today,
toCalendarDate,
type DateValue,
} from "@internationalized/date";
import { cn } from "$lib/utils.js";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import * as Popover from "$lib/components/ui/popover/index.js";
import Icon from "$lib/components/atoms/icon.svelte";
import Calendar from "$lib/components/ui/calendar/calendar.svelte";
import {
parseCalDateToDateString,
makeDateStringISO,
} from "$lib/core/date.utils";
import { ticketSearchStore } from "../data/store";
import { onMount } from "svelte";
const df = new DateFormatter("en-US", {
month: "short",
day: "numeric",
year: "numeric",
timeZone: getLocalTimeZone(),
});
let contentRef = $state<HTMLElement | null>(null);
let open = $state(false);
const todayDate = today(getLocalTimeZone());
let value = $state(today(getLocalTimeZone()));
let startFmt = $derived(value.toDate(getLocalTimeZone()));
let defaultPlaceholder = "Pick a date";
let placeholder = $derived(
value ? `${df.format(startFmt)}` : defaultPlaceholder,
);
function updateValInStore() {
const val = parseCalDateToDateString(value);
ticketSearchStore.update((prev) => {
return {
...prev,
departureDate: makeDateStringISO(val),
returnDate: makeDateStringISO(val),
};
});
}
function isDateDisabled(date: DateValue) {
return date.compare(todayDate) < 0;
}
function handleDateSelection(v: DateValue | undefined) {
if (!v) {
value = today(getLocalTimeZone());
} else {
value = toCalendarDate(v);
}
setTimeout(() => {
open = false;
}, 0);
}
$effect(() => {
updateValInStore();
});
onMount(() => {
updateValInStore();
});
</script>
<Popover.Root bind:open>
<Popover.Trigger
class={cn(
buttonVariants({
variant: "white",
class: "w-full justify-start text-left font-normal",
}),
!value && "text-muted-foreground",
)}
>
<Icon icon={CalendarIcon} cls="w-auto h-4" />
<span class="text-sm md:text-base">{placeholder}</span>
</Popover.Trigger>
<Popover.Content bind:ref={contentRef} class="w-auto p-0">
<Calendar
type="single"
{value}
minValue={todayDate}
{isDateDisabled}
onValueChange={(v) => handleDateSelection(v)}
class="rounded-md border border-white"
/>
</Popover.Content>
</Popover.Root>

View File

@@ -0,0 +1,133 @@
<script lang="ts">
import CalendarIcon from "@lucide/svelte/icons/calendar";
import {
DateFormatter,
getLocalTimeZone,
toCalendarDate,
today,
type DateValue,
} from "@internationalized/date";
import { cn } from "$lib/utils.js";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import * as Popover from "$lib/components/ui/popover/index.js";
import { RangeCalendar } from "$lib/components/ui/range-calendar/index.js";
import Icon from "$lib/components/atoms/icon.svelte";
import { ticketSearchStore } from "../data/store";
import { onMount } from "svelte";
import {
makeDateStringISO,
parseCalDateToDateString,
} from "$lib/core/date.utils";
import Button from "$lib/components/ui/button/button.svelte";
const df = new DateFormatter("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
let contentRef = $state<HTMLElement | null>(null);
let open = $state(false);
const todayDate = today(getLocalTimeZone());
const start = today(getLocalTimeZone());
const end = start.add({ days: 7 });
let value = $state({ start, end });
let startFmt = $derived(value?.start?.toDate(getLocalTimeZone()));
let endFmt = $derived(value?.end?.toDate(getLocalTimeZone()));
let defaultPlaceholder = "Pick a date";
let placeholder = $derived(
value?.start && value?.end && startFmt && endFmt
? `${df.formatRange(startFmt, endFmt)}`
: value?.start && startFmt
? `${df.format(startFmt)} - Select end date`
: defaultPlaceholder,
);
function isDateDisabled(date: DateValue) {
return date.compare(todayDate) < 0;
}
function handleStartChange(v: DateValue | undefined) {
if (!v) {
value.start = today(getLocalTimeZone());
return;
}
value.start = toCalendarDate(v);
}
function handleEndChange(v: DateValue | undefined) {
if (!v) {
value.end = today(getLocalTimeZone());
return;
}
value.end = toCalendarDate(v);
}
function updateValsInStore() {
const sVal = parseCalDateToDateString(value.start);
const eVal = parseCalDateToDateString(value.end);
ticketSearchStore.update((prev) => {
return {
...prev,
departureDate: makeDateStringISO(sVal),
returnDate: makeDateStringISO(eVal),
};
});
}
$effect(() => {
updateValsInStore();
});
onMount(() => {
updateValsInStore();
});
</script>
<Popover.Root
{open}
onOpenChange={(o) => {
open = o;
}}
>
<Popover.Trigger
class={cn(
buttonVariants({
variant: "white",
class: "w-full justify-start text-left font-normal",
}),
!value && "text-muted-foreground",
)}
>
<Icon icon={CalendarIcon} cls="w-auto h-4" />
<span class="text-sm md:text-base">{placeholder}</span>
</Popover.Trigger>
<Popover.Content bind:ref={contentRef} class="w-auto p-0">
<RangeCalendar
{value}
{isDateDisabled}
onStartValueChange={handleStartChange}
onEndValueChange={handleEndChange}
class="rounded-md border border-white"
/>
<div class="w-full p-2">
<Button
class="w-full"
onclick={() => {
open = false;
}}
>
Confirm
</Button>
</div>
</Popover.Content>
</Popover.Root>

View File

@@ -0,0 +1,116 @@
<script lang="ts">
import Counter from "$lib/components/atoms/counter.svelte";
import Title from "$lib/components/atoms/title.svelte";
import { buttonVariants } from "$lib/components/ui/button";
import * as Popover from "$lib/components/ui/popover/index.js";
import { cn } from "$lib/utils";
import { CabinClass } from "../data/entities";
import { ticketSearchStore } from "../data/store";
import { snakeToSpacedPascal } from "$lib/core/string.utils";
import Icon from "$lib/components/atoms/icon.svelte";
import ChevronDownIcon from "~icons/lucide/chevron-down";
import CheckIcon from "~icons/lucide/check";
let cabinClass = $state($ticketSearchStore.cabinClass);
$effect(() => {
ticketSearchStore.update((prev) => {
return { ...prev, cabinClass: cabinClass };
});
});
let adultCount = $state(1);
let childCount = $state(0);
$effect(() => {
ticketSearchStore.update((prev) => {
return {
...prev,
passengerCounts: { adults: adultCount, children: childCount },
};
});
});
let passengerCounts = $derived(adultCount + childCount);
let cabinClassOpen = $state(false);
</script>
<div class="flex w-full flex-col items-center justify-end gap-2 sm:flex-row">
<Popover.Root bind:open={cabinClassOpen}>
<Popover.Trigger
class={cn(
buttonVariants({ variant: "white" }),
"w-full justify-between",
)}
>
{snakeToSpacedPascal(cabinClass.toLowerCase() ?? "Select")}
<Icon icon={ChevronDownIcon} cls="w-auto h-4" />
</Popover.Trigger>
<Popover.Content>
<div class="flex flex-col gap-2">
{#each Object.values(CabinClass) as each}
<button
onclick={() => {
cabinClass = each;
cabinClassOpen = false;
}}
class={cn(
"flex items-center gap-2 rounded-md p-2 px-4 text-start hover:bg-gray-200",
)}
>
{#if cabinClass === each}
<Icon
icon={CheckIcon}
cls="w-auto h-4 text-brand-600"
/>
{:else}
<div
class="h-4 w-4 rounded-full bg-transparent"
></div>
{/if}
{snakeToSpacedPascal(each.toLowerCase())}
</button>
{/each}
</div>
</Popover.Content>
</Popover.Root>
<Popover.Root>
<Popover.Trigger
class={cn(
buttonVariants({ variant: "white" }),
"w-full justify-between",
)}
>
{passengerCounts} Passenger(s)
<Icon icon={ChevronDownIcon} cls="w-auto h-4" />
</Popover.Trigger>
<Popover.Content>
<div class="flex flex-col gap-8">
<Title size="h5" weight="normal" color="black"
>Passenger Selection</Title
>
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<p>Adults</p>
<p class="text-gray-500">Aged 16+</p>
</div>
<Counter bind:value={adultCount} />
</div>
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<p>Children</p>
<p class="text-gray-500">Aged 0-16</p>
</div>
<Counter bind:value={childCount} />
</div>
</div>
</div>
</Popover.Content>
</Popover.Root>
</div>

View File

@@ -0,0 +1,39 @@
<script lang="ts">
import Icon from "$lib/components/atoms/icon.svelte";
import { TRANSITION_COLORS } from "$lib/core/constants";
import { cn } from "$lib/utils";
import FilterIcon from "~icons/solar/filter-broken";
import * as Sheet from "$lib/components/ui/sheet/index.js";
import TicketFiltersSelect from "../ticket-filters-select.svelte";
import Button from "$lib/components/ui/button/button.svelte";
import CloseIcon from "~icons/lucide/x";
import Title from "$lib/components/atoms/title.svelte";
let open = $state(false);
const close = () => (open = false);
</script>
<Sheet.Root bind:open>
<Sheet.Trigger
class={cn(
"grid place-items-center rounded-full bg-brand-100 p-4 text-brand-500 shadow-lg hover:bg-brand-500 hover:text-white",
TRANSITION_COLORS,
)}
>
<Icon icon={FilterIcon} cls={cn("h-8 w-auto", TRANSITION_COLORS)} />
</Sheet.Trigger>
<Sheet.Content side="bottom">
<div class="flex justify-between gap-4">
<Title size="h4" color="black">Tickets Filters</Title>
<Button size="iconSm" variant="white" onclick={() => close()}>
<Icon icon={CloseIcon} cls={cn("h-6 w-auto")} />
</Button>
</div>
<div class="flex max-h-[80vh] w-full flex-col gap-4 overflow-y-auto p-4">
<TicketFiltersSelect onApplyClick={() => close()} />
</div>
</Sheet.Content>
</Sheet.Root>

View File

@@ -0,0 +1,50 @@
<script lang="ts">
import Icon from "$lib/components/atoms/icon.svelte";
import { TRANSITION_COLORS } from "$lib/core/constants";
import { cn } from "$lib/utils";
import SearchIcon from "~icons/solar/minimalistic-magnifer-linear";
import * as Sheet from "$lib/components/ui/sheet/index.js";
import TicketSearchInput from "../ticket-search-input.svelte";
import Button from "$lib/components/ui/button/button.svelte";
import CloseIcon from "~icons/lucide/x";
import Title from "$lib/components/atoms/title.svelte";
let { onSubmit }: { onSubmit: () => void } = $props();
function _onSubmit() {
onSubmit();
close();
}
let open = $state(false);
const close = () => (open = false);
</script>
<Sheet.Root bind:open>
<Sheet.Trigger
class={cn(
"grid place-items-center rounded-full bg-brand-500 p-4 text-white shadow-lg hover:bg-brand-700",
TRANSITION_COLORS,
)}
>
<Icon
icon={SearchIcon}
cls={cn("h-8 w-auto text-white", TRANSITION_COLORS)}
/>
</Sheet.Trigger>
<Sheet.Content side="bottom">
<div class="flex w-full flex-col gap-4">
<div class="flex justify-between gap-4">
<Title size="h5" color="black">Search Flights</Title>
<Button size="iconSm" variant="white" onclick={() => close()}>
<Icon icon={CloseIcon} cls={cn("h-6 w-auto")} />
</Button>
</div>
<div class="flex max-h-[80vh] w-full flex-col gap-4 overflow-y-auto">
<TicketSearchInput onSubmit={_onSubmit} />
</div>
</div>
</Sheet.Content>
</Sheet.Root>

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import Badge from "$lib/components/ui/badge/badge.svelte";
import Button from "$lib/components/ui/button/button.svelte";
import { flightTicketVM } from "./ticket.vm.svelte";
import TicketCard from "./ticket/ticket-card.svelte";
</script>
<div class="flex w-full flex-col gap-4">
{#if flightTicketVM.searching}
{#each Array(5) as _}
<div
class="h-64 w-full animate-pulse rounded-lg bg-gray-300 shadow-lg"
></div>
{/each}
{:else if flightTicketVM.renderedTickets.length > 0}
<Badge variant="outline" class="w-max">
Showing {flightTicketVM.renderedTickets.length} tickets
</Badge>
{#each flightTicketVM.renderedTickets as each}
<TicketCard data={each} />
{/each}
<Button
class="text-center"
variant="white"
onclick={() => {
flightTicketVM.searchForTickets(true);
}}
>
Load More
</Button>
{:else}
<div
class="flex flex-col items-center justify-center gap-4 p-8 py-32 text-center"
>
<div class="text-2xl font-bold">No tickets found</div>
<div class="text-sm text-gray-500">
Try searching for a different flight
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,260 @@
<script lang="ts">
import Title from "$lib/components/atoms/title.svelte";
import { Slider } from "$lib/components/ui/slider/index.js";
import {
ticketFiltersStore,
MaxStops,
SortOption,
} from "$lib/domains/ticket/view/ticket-filters.vm.svelte";
import { flightTicketVM } from "./ticket.vm.svelte";
import { RadioGroup, RadioGroupItem } from "$lib/components/ui/radio-group";
import { Checkbox } from "$lib/components/ui/checkbox";
import { Label } from "$lib/components/ui/label";
import Button from "$lib/components/ui/button/button.svelte";
import {
convertAndFormatCurrency,
currencyStore,
currencyVM,
} from "$lib/domains/currency/view/currency.vm.svelte";
let { onApplyClick }: { onApplyClick?: () => void } = $props();
function onApply() {
flightTicketVM.applyFilters();
if (onApplyClick) {
onApplyClick();
}
}
let maxPrice = $state(
flightTicketVM.tickets.length > 0
? flightTicketVM.tickets.sort(
(a, b) =>
b.priceDetails.displayPrice - a.priceDetails.displayPrice,
)[0]?.priceDetails.displayPrice
: 100,
);
let priceRange = $state([
0,
currencyVM.convertFromUsd(
$ticketFiltersStore.priceRange.max,
$currencyStore.code,
),
]);
// Time ranges
let departureTimeRange = $state([0, $ticketFiltersStore.time.departure.max]);
let arrivalTimeRange = $state([0, $ticketFiltersStore.time.arrival.max]);
let durationRange = $state([0, $ticketFiltersStore.duration.max]);
let maxStops = $state($ticketFiltersStore.maxStops);
let allowOvernight = $state($ticketFiltersStore.allowOvernight);
$effect(() => {
maxPrice =
flightTicketVM.tickets.length > 0
? flightTicketVM.tickets.sort(
(a, b) =>
b.priceDetails.displayPrice -
a.priceDetails.displayPrice,
)[0]?.priceDetails.displayPrice
: 100;
if (priceRange[0] > maxPrice || priceRange[1] > maxPrice) {
priceRange = [
0,
currencyVM.convertFromUsd(maxPrice, $currencyStore.code),
];
}
});
$effect(() => {
ticketFiltersStore.update((prev) => ({
...prev,
priceRange: { min: priceRange[0], max: priceRange[1] },
maxStops,
allowOvernight,
time: {
departure: {
min: departureTimeRange[0],
max: departureTimeRange[1],
},
arrival: { min: arrivalTimeRange[0], max: arrivalTimeRange[1] },
},
duration: { min: durationRange[0], max: durationRange[1] },
}));
});
</script>
<div class="flex w-full max-w-sm flex-col gap-6">
<Title size="h5" color="black">Sort By</Title>
{#if flightTicketVM.searching}
<div class="h-16 w-full animate-pulse rounded-lg bg-gray-300"></div>
{:else}
<RadioGroup
value={$ticketFiltersStore.sortBy}
onValueChange={(value) => {
ticketFiltersStore.update((prev) => ({
...prev,
sortBy: value as SortOption,
}));
}}
>
<div class="flex items-center space-x-2">
<RadioGroupItem
value={SortOption.Default}
id="price_low_to_high"
/>
<Label for="default">Price: Default</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroupItem
value={SortOption.PriceLowToHigh}
id="price_low_to_high"
/>
<Label for="price_low_to_high">Price: Low to High</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroupItem
value={SortOption.PriceHighToLow}
id="price_high_to_low"
/>
<Label for="price_high_to_low">Price: High to Low</Label>
</div>
</RadioGroup>
{/if}
<Title size="h5" color="black">Price</Title>
{#if flightTicketVM.searching}
<div class="h-6 w-full animate-pulse rounded-full bg-gray-300"></div>
{:else}
<div class="flex w-full flex-col gap-2">
<Slider
type="multiple"
bind:value={priceRange}
min={0}
max={maxPrice}
step={1}
/>
<div class="flex items-center justify-between gap-2">
<div class="flex flex-col gap-1">
<p class="text-sm font-medium">Min</p>
<p class="text-sm text-gray-500">{priceRange[0]}</p>
</div>
<div class="flex flex-col gap-1">
<p class="text-sm font-medium">Max</p>
<p class="text-sm text-gray-500">{priceRange[1]}</p>
</div>
</div>
</div>
{/if}
<Title size="h5" color="black">Max Stops</Title>
{#if flightTicketVM.searching}
<div class="h-24 w-full animate-pulse rounded-lg bg-gray-300"></div>
{:else}
<RadioGroup
value={$ticketFiltersStore.maxStops}
onValueChange={(value) => {
ticketFiltersStore.update((prev) => ({
...prev,
maxStops: value as MaxStops,
}));
}}
>
{#each Object.entries(MaxStops) as [label, value]}
<div class="flex items-center space-x-2">
<RadioGroupItem {value} id={value} />
<Label for={value}>{label}</Label>
</div>
{/each}
</RadioGroup>
{/if}
{#if flightTicketVM.searching}
<div class="h-8 w-full animate-pulse rounded-lg bg-gray-300"></div>
{:else}
<div class="flex items-center space-x-2">
<Checkbox
checked={$ticketFiltersStore.allowOvernight}
onCheckedChange={(checked) => {
ticketFiltersStore.update((prev) => ({
...prev,
allowOvernight: checked,
}));
}}
id="overnight"
/>
<Label for="overnight">Allow Overnight Flights</Label>
</div>
{/if}
<Title size="h5" color="black">Time</Title>
{#if flightTicketVM.searching}
<div class="space-y-4">
<div class="h-16 w-full animate-pulse rounded-lg bg-gray-300"></div>
<div class="h-16 w-full animate-pulse rounded-lg bg-gray-300"></div>
</div>
{:else}
<div class="space-y-4">
<div class="flex flex-col gap-2">
<p class="mb-2 text-sm font-medium">Departure Time</p>
<Slider
type="multiple"
bind:value={departureTimeRange}
min={0}
max={24}
step={1}
/>
<div class="flex items-center justify-between gap-2">
<small>{departureTimeRange[0]}:00</small>
<small>{departureTimeRange[1]}:00</small>
</div>
</div>
<div class="flex flex-col gap-2">
<p class="mb-2 text-sm font-medium">Arrival Time</p>
<Slider
type="multiple"
bind:value={arrivalTimeRange}
min={0}
max={24}
step={1}
/>
<div class="flex items-center justify-between gap-2">
<small>{arrivalTimeRange[0]}:00</small>
<small>{arrivalTimeRange[1]}:00</small>
</div>
</div>
</div>
{/if}
<Title size="h5" color="black">Duration</Title>
{#if flightTicketVM.searching}
<div class="h-16 w-full animate-pulse rounded-lg bg-gray-300"></div>
{:else}
<div class="flex flex-col gap-2">
<p class="mb-2 text-sm font-medium">Max Duration</p>
<Slider
type="multiple"
bind:value={durationRange}
min={0}
max={100}
step={1}
/>
<div class="flex items-center justify-between gap-2">
<small>{durationRange[0]} hours</small>
<small>{durationRange[1]} hours</small>
</div>
</div>
{/if}
<Button onclick={() => onApply()} class="w-full">Apply Changes</Button>
</div>

View File

@@ -0,0 +1,24 @@
import { writable } from "svelte/store";
export enum SortOption {
Default = "default",
PriceLowToHigh = "price_low_to_high",
PriceHighToLow = "price_high_to_low",
}
export enum MaxStops {
Any = "any",
Direct = "direct",
One = "one",
Two = "two",
}
export const ticketFiltersStore = writable({
priceRange: { min: 0, max: 0 },
excludeCountries: [] as string[],
maxStops: MaxStops.Any,
allowOvernight: true,
time: { departure: { min: 0, max: 0 }, arrival: { min: 0, max: 0 } },
duration: { min: 0, max: 0 },
sortBy: SortOption.Default,
});

View File

@@ -0,0 +1,150 @@
<script lang="ts">
import { TicketType } from "../data/entities/index";
import AirportSearchInput from "$lib/domains/airport/view/airport-search-input.svelte";
import { ticketSearchStore } from "../data/store";
import FlightDateInput from "./flight-date-input.svelte";
import FlightDateRangeInput from "./flight-date-range-input.svelte";
import Icon from "$lib/components/atoms/icon.svelte";
import SearchIcon from "~icons/solar/magnifer-linear";
import SwapIcon from "~icons/ant-design/swap-outlined";
import Button from "$lib/components/ui/button/button.svelte";
import PassengerAndCabinClassSelect from "./passenger-and-cabin-class-select.svelte";
import { Label } from "$lib/components/ui/label/index.js";
import * as RadioGroup from "$lib/components/ui/radio-group/index.js";
import { airportVM } from "$lib/domains/airport/view/airport.vm.svelte";
import { browser } from "$app/environment";
import { onDestroy, onMount } from "svelte";
let { onSubmit, rowify = false }: { onSubmit: () => void; rowify?: boolean } =
$props();
let currTicketType = $state($ticketSearchStore.ticketType);
let isMobileView = $state(false);
function checkIfMobile() {
if (browser) {
isMobileView = window.innerWidth < 768;
}
}
function setupResizeListener() {
if (browser) {
window.addEventListener("resize", checkIfMobile);
checkIfMobile(); // Initial check
return () => {
window.removeEventListener("resize", checkIfMobile);
};
}
}
$effect(() => {
ticketSearchStore.update((prev) => {
return { ...prev, ticketType: currTicketType };
});
});
let cleanup: any | undefined = $state(undefined);
onMount(() => {
cleanup = setupResizeListener();
});
onDestroy(() => {
cleanup?.();
});
</script>
<div class="flex w-full flex-col gap-4">
<div
class="flex w-full flex-col items-center justify-between gap-4 lg:grid lg:grid-cols-2 lg:gap-4"
>
<RadioGroup.Root
bind:value={currTicketType}
class="flex w-full flex-row gap-6 p-2 lg:p-4"
>
<div class="flex items-center space-x-2">
<RadioGroup.Item
value={TicketType.Return}
id={TicketType.Return}
onselect={() => {
ticketSearchStore.update((prev) => {
return { ...prev, ticketType: TicketType.Return };
});
}}
/>
<Label for={TicketType.Return} class="md:text-lg">Return</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item
value={TicketType.OneWay}
id={TicketType.OneWay}
onselect={() => {
ticketSearchStore.update((prev) => {
return { ...prev, ticketType: TicketType.OneWay };
});
}}
/>
<Label for={TicketType.OneWay} class="md:text-lg">One Way</Label>
</div>
</RadioGroup.Root>
<PassengerAndCabinClassSelect />
</div>
<div
class="flex w-full flex-col items-center justify-between gap-2 lg:grid lg:grid-cols-2 lg:gap-4"
>
<div
class="flex w-full flex-col items-center justify-between gap-2 lg:flex-row"
>
<AirportSearchInput
currentValue={airportVM.departure}
onChange={(e) => {
airportVM.setDepartureAirport(e);
}}
placeholder="Depart from"
searchPlaceholder="Departure airport search"
isMobile={isMobileView}
fieldType="departure"
/>
<div class="hidden w-full max-w-fit md:block">
<Button
size="icon"
variant={rowify ? "outlineWhite" : "defaultInverted"}
onclick={() => {
airportVM.swapDepartureAndArrival();
}}
>
<Icon icon={SwapIcon} cls="w-auto h-6" />
</Button>
</div>
<AirportSearchInput
currentValue={airportVM.arrival}
onChange={(e) => {
airportVM.setArrivalAirport(e);
}}
placeholder="Arrive at"
searchPlaceholder="Arrival airport search"
isMobile={isMobileView}
fieldType="arrival"
/>
</div>
<div
class="flex w-full flex-col items-center justify-between gap-2 lg:flex-row lg:gap-2"
>
{#if $ticketSearchStore.ticketType === TicketType.Return}
<FlightDateRangeInput />
{:else}
<FlightDateInput />
{/if}
<Button onclick={() => onSubmit()} class={"w-full"} variant="default">
<Icon icon={SearchIcon} cls="w-auto h-4" />
Search flights
</Button>
</div>
</div>
</div>

View File

@@ -0,0 +1,398 @@
import {
type FlightPriceDetails,
type FlightTicket,
ticketSearchPayloadModel,
} from "$lib/domains/ticket/data/entities/index";
import { trpcApiStore } from "$lib/stores/api";
import { toast } from "svelte-sonner";
import { get } from "svelte/store";
import { airportVM } from "$lib/domains/airport/view/airport.vm.svelte";
import { goto, replaceState } from "$app/navigation";
import { page } from "$app/state";
import {
MaxStops,
SortOption,
ticketFiltersStore,
} from "./ticket-filters.vm.svelte";
import type { Result } from "@pkg/result";
import { flightTicketStore, ticketSearchStore } from "../data/store";
import { nanoid } from "nanoid";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import { ticketCheckoutVM } from "./checkout/flight-checkout.vm.svelte";
import { paymentInfoVM } from "./checkout/payment-info-section/payment.info.vm.svelte";
export class FlightTicketViewModel {
searching = $state(false);
tickets = $state<FlightTicket[]>([]);
renderedTickets = $state<FlightTicket[]>([]);
updatingPrices = $state(false);
beginSearch() {
const info = get(ticketSearchStore);
if (!info) {
return;
}
if (
info.passengerCounts.adults < 1 &&
info.passengerCounts.children < 1
) {
toast.error("Please enter at least one adult and one child");
return;
}
const sum = info.passengerCounts.adults + info.passengerCounts.children;
if (sum > 10) {
toast.error("Please enter no more than 10 passengers");
return;
}
const params = this.formatURLParams();
goto(`/search?${params.toString()}`);
}
loadStore(urlParams: URLSearchParams) {
console.log("Meta parameter: ", urlParams.get("meta"));
ticketSearchStore.update((prev) => {
return {
sessionId: prev.sessionId ?? "",
ticketType: urlParams.get("ticketType") ?? prev.ticketType,
cabinClass: urlParams.get("cabinClass") ?? prev.cabinClass,
passengerCounts: {
adults: Number(
urlParams.get("adults") ?? prev.passengerCounts.adults,
),
children: Number(
urlParams.get("children") ??
prev.passengerCounts.children,
),
},
departure: urlParams.get("departure") ?? prev.departure,
arrival: urlParams.get("arrival") ?? prev.arrival,
departureDate:
urlParams.get("departureDate") ?? prev.departureDate,
returnDate: urlParams.get("returnDate") ?? prev.returnDate,
loadMore: prev.loadMore ?? false,
meta: (() => {
const metaStr = urlParams.get("meta");
if (!metaStr) return prev.meta;
try {
return JSON.parse(metaStr);
} catch (e) {
console.error("Failed to parse meta parameter:", e);
return prev.meta;
}
})(),
};
});
}
resetCachedCheckoutData() {
// @ts-ignore
flightTicketStore.set(undefined);
passengerInfoVM.reset();
ticketCheckoutVM.reset();
paymentInfoVM.reset();
}
async searchForTickets(loadMore = false) {
const api = get(trpcApiStore);
if (!api) {
return toast.error("Please try again by reloading the page", {
description: "Page state not properly initialized",
});
}
let payload = get(ticketSearchStore);
if (!payload) {
return toast.error(
"Could not search for tickets due to invalid payload",
);
}
const parsed = ticketSearchPayloadModel.safeParse(payload);
if (!parsed.success) {
console.log("Enter some parameters to search for tickets");
this.searching = false;
return;
}
payload = parsed.data;
if (loadMore) {
payload.loadMore = true;
}
this.searching = true;
const out = await api.ticket.searchTickets.query(payload);
this.searching = false;
console.log(out);
if (out.error) {
return toast.error(out.error.message, {
description: out.error.userHint,
});
}
if (!out.data) {
this.tickets = [];
return toast.error("No search results", {
description: "Please try again with different parameters",
});
}
this.tickets = out.data;
this.applyFilters();
this.resetCachedCheckoutData();
}
applyFilters() {
this.searching = true;
const filters = get(ticketFiltersStore);
const filteredTickets = this.tickets.filter((ticket) => {
// Price filter
if (filters.priceRange.max > 0) {
if (
ticket.priceDetails.displayPrice < filters.priceRange.min ||
ticket.priceDetails.displayPrice > filters.priceRange.max
) {
return false;
}
}
if (filters.maxStops !== MaxStops.Any) {
// Calculate stops for outbound flight
const outboundStops =
ticket.flightIteneraries.outbound.length - 1;
// Calculate stops for inbound flight
const inboundStops = ticket.flightIteneraries.inbound.length - 1;
// Get the maximum number of stops between outbound and inbound
const maxStopsInJourney = Math.max(outboundStops, inboundStops);
switch (filters.maxStops) {
case MaxStops.Direct:
if (maxStopsInJourney > 0) return false;
break;
case MaxStops.One:
if (maxStopsInJourney > 1) return false;
break;
case MaxStops.Two:
if (maxStopsInJourney > 2) return false;
break;
}
}
// Time range filters
if (filters.time.departure.max > 0 || filters.time.arrival.max > 0) {
const allItineraries = [
...ticket.flightIteneraries.outbound,
...ticket.flightIteneraries.inbound,
];
for (const itinerary of allItineraries) {
const departureHour = new Date(
itinerary.departure.utcTime,
).getHours();
const arrivalHour = new Date(
itinerary.destination.utcTime,
).getHours();
if (filters.time.departure.max > 0) {
if (
departureHour < filters.time.departure.min ||
departureHour > filters.time.departure.max
) {
return false;
}
}
if (filters.time.arrival.max > 0) {
if (
arrivalHour < filters.time.arrival.min ||
arrivalHour > filters.time.arrival.max
) {
return false;
}
}
}
}
// Duration filter
if (filters.duration.max > 0) {
const allItineraries = [
...ticket.flightIteneraries.outbound,
...ticket.flightIteneraries.inbound,
];
const totalDuration = allItineraries.reduce(
(sum, itinerary) => sum + itinerary.durationSeconds,
0,
);
const durationHours = totalDuration / 3600;
if (
durationHours < filters.duration.min ||
durationHours > filters.duration.max
) {
return false;
}
}
// Overnight filter
if (!filters.allowOvernight) {
const allItineraries = [
...ticket.flightIteneraries.outbound,
...ticket.flightIteneraries.inbound,
];
const hasOvernightFlight = allItineraries.some((itinerary) => {
const departureHour = new Date(
itinerary.departure.utcTime,
).getHours();
const arrivalHour = new Date(
itinerary.destination.utcTime,
).getHours();
// Consider a flight overnight if it departs between 8 PM (20) and 6 AM (6)
return (
departureHour >= 20 ||
departureHour <= 6 ||
arrivalHour >= 20 ||
arrivalHour <= 6
);
});
if (hasOvernightFlight) return false;
}
return true;
});
filteredTickets.sort((a, b) => {
switch (filters.sortBy) {
case SortOption.PriceLowToHigh:
return (
a.priceDetails.displayPrice - b.priceDetails.displayPrice
);
case SortOption.PriceHighToLow:
return (
b.priceDetails.displayPrice - a.priceDetails.displayPrice
);
default:
return 0;
}
});
this.renderedTickets = filteredTickets;
this.searching = false;
}
async cacheTicketAndGotoCheckout(id: number) {
const api = get(trpcApiStore);
if (!api) {
return toast.error("Please try again by reloading the page", {
description: "Page state not properly initialized",
});
}
const targetTicket = this.tickets.find((ticket) => ticket.id === id);
if (!targetTicket) {
return toast.error("Ticket not found", {
description:
"Please try again with different parameters or refresh page to try again",
});
}
const sid = get(ticketSearchStore).sessionId;
console.log("sid", sid);
const out = await api.ticket.cacheTicket.mutate({
sid,
payload: targetTicket,
});
if (out.error) {
return toast.error(out.error.message, {
description: out.error.userHint,
});
}
if (!out.data) {
return toast.error("Failed to proceed to checkout", {
description: "Please refresh the page to try again",
});
}
goto(`/checkout/${sid}/${out.data}`);
}
redirectToSearchPage() {
const params = this.formatURLParams();
goto(`/search?${params.toString()}`);
}
setURLParams(): Result<boolean> {
ticketSearchStore.update((prev) => {
return { ...prev, sessionId: nanoid() };
});
const newParams = this.formatURLParams();
const url = new URL(page.url.href);
for (const [key, value] of newParams.entries()) {
url.searchParams.set(key, value);
}
let stripped = page.url.href.includes("?")
? page.url.href.split("?")[0]
: page.url.href;
replaceState(
new URL(stripped + "?" + new URLSearchParams(newParams)).toString(),
{},
);
return { data: true };
}
private formatURLParams() {
const info = get(ticketSearchStore);
let out = new URLSearchParams();
if (!info) {
return out;
}
out.append("ticketType", info.ticketType);
out.append("cabinClass", info.cabinClass);
out.append("adults", info.passengerCounts.adults.toString());
out.append("children", info.passengerCounts.children.toString());
if (airportVM.departure) {
out.append("departure", airportVM.departure?.iataCode);
}
if (airportVM.arrival) {
out.append("arrival", airportVM.arrival?.iataCode);
}
out.append("departureDate", info.departureDate);
out.append("returnDate", info.returnDate);
out.append("meta", JSON.stringify(info.meta));
return out;
}
async updateTicketPrices(updated: FlightPriceDetails) {
const api = get(trpcApiStore);
if (!api) {
return;
}
const tid = get(flightTicketStore).id;
this.updatingPrices = true;
const out = await api.ticket.updateTicketPrices.mutate({
tid,
payload: updated,
});
this.updatingPrices = false;
console.log("new shit");
console.log(out);
if (out.error) {
toast.error(out.error.message, {
description: out.error.userHint,
});
}
if (!out.data) {
return;
}
flightTicketStore.update((prev) => {
return { ...prev, priceDetails: out.data! };
});
}
}
export const flightTicketVM = new FlightTicketViewModel();

View File

@@ -0,0 +1,50 @@
<script lang="ts">
import BackpackIcon from "~icons/solar/backpack-broken";
import BagCheckIcon from "~icons/solar/suitcase-broken";
import PersonalBagIcon from "~icons/solar/bag-broken";
import Icon from "$lib/components/atoms/icon.svelte";
import Title from "$lib/components/atoms/title.svelte";
import type { FlightTicket } from "../../data/entities";
let { data }: { data: FlightTicket } = $props();
</script>
{#if data}
<Title size="h5" color="black">Baggage Info</Title>
<div class="flex flex-col gap-4">
<!-- Personal Item - Always show -->
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<Icon icon={PersonalBagIcon} cls="w-6 h-6" />
<span>Personal Item</span>
</div>
<span>{data.bagsInfo.includedPersonalBags} included</span>
</div>
<!-- Cabin Baggage - Always show -->
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<Icon icon={BackpackIcon} cls="w-6 h-6" />
<span>Cabin Baggage</span>
</div>
{#if data.bagsInfo.hasHandBagsSupport}
<span>{data.bagsInfo.includedHandBags} included</span>
{:else}
<span class="text-gray-500">Not available</span>
{/if}
</div>
<!-- Checked Baggage - Always show -->
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<Icon icon={BagCheckIcon} cls="w-6 h-6" />
<span>Checked Baggage</span>
</div>
{#if data.bagsInfo.hasCheckedBagsSupport}
<span>{data.bagsInfo.includedCheckedBags} included</span>
{:else}
<span class="text-gray-500">Not available</span>
{/if}
</div>
</div>
{/if}

View File

@@ -0,0 +1,87 @@
<script lang="ts">
import Title from "$lib/components/atoms/title.svelte";
import Button, {
buttonVariants,
} from "$lib/components/ui/button/button.svelte";
import { type FlightTicket } from "../../data/entities";
import { Badge } from "$lib/components/ui/badge/index.js";
import { TRANSITION_ALL } from "$lib/core/constants";
import { cn } from "$lib/utils";
import TicketDetailsModal from "./ticket-details-modal.svelte";
import * as Dialog from "$lib/components/ui/dialog/index.js";
import { flightTicketVM } from "../ticket.vm.svelte";
import TicketLegsOverview from "./ticket-legs-overview.svelte";
import { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte";
let { data }: { data: FlightTicket } = $props();
async function proceedToCheckout() {
await flightTicketVM.cacheTicketAndGotoCheckout(data.id);
}
</script>
<div class="flex flex-col md:flex-row">
<TicketDetailsModal {data} onCheckoutBtnClick={proceedToCheckout}>
<Dialog.Trigger
class={cn(
"flex w-full flex-col justify-center gap-4 rounded-lg border-x-0 border-t-2 border-gray-200 bg-white p-6 shadow-md hover:bg-gray-50 md:border-y-2 md:border-l-2 md:border-r-2",
TRANSITION_ALL,
)}
>
<TicketLegsOverview {data} />
<div class="flex items-center gap-2"></div>
</Dialog.Trigger>
</TicketDetailsModal>
<div
class={cn(
"flex w-full flex-col items-center justify-center gap-4 rounded-lg border-x-0 border-b-2 border-gray-200 bg-white p-6 shadow-md md:max-w-xs md:border-y-2 md:border-l-0 md:border-r-2",
TRANSITION_ALL,
)}
>
<!-- Add price comparison logic here -->
{#if data.priceDetails.basePrice !== data.priceDetails.displayPrice}
{@const discountPercentage = Math.round(
(1 -
data.priceDetails.displayPrice /
data.priceDetails.basePrice) *
100,
)}
<div class="flex flex-col items-center gap-1">
<Badge variant="destructive">
<span>{discountPercentage}% OFF</span>
</Badge>
<div class="text-gray-500 line-through">
{convertAndFormatCurrency(data.priceDetails.basePrice)}
</div>
<Title center size="h4" weight="medium" color="black">
{convertAndFormatCurrency(data.priceDetails.displayPrice)}
</Title>
{#if data.priceDetails.appliedCoupon}
<Badge
variant="outline"
class="border-green-600 text-green-600"
>
Coupon: {data.priceDetails.appliedCoupon}
</Badge>
{/if}
</div>
{:else}
<Title center size="h4" weight="medium" color="black">
{convertAndFormatCurrency(data.priceDetails.displayPrice)}
</Title>
{/if}
<div class="flex w-full flex-col gap-1">
<TicketDetailsModal {data} onCheckoutBtnClick={proceedToCheckout}>
<Dialog.Trigger
class={cn(buttonVariants({ variant: "secondary" }))}
>
Flight Info
</Dialog.Trigger>
</TicketDetailsModal>
<Button onclick={() => proceedToCheckout()}>Checkout</Button>
</div>
</div>
</div>

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import * as Dialog from "$lib/components/ui/dialog/index.js";
import type { FlightTicket } from "../../data/entities";
import TicketDetails from "./ticket-details.svelte";
let {
data,
onCheckoutBtnClick,
hideCheckoutBtn,
children,
}: {
data: FlightTicket;
children: any;
hideCheckoutBtn?: boolean;
onCheckoutBtnClick: (tid: number) => void;
} = $props();
</script>
<Dialog.Root>
{@render children()}
<Dialog.Content class="w-full max-w-2xl">
<div class="flex max-h-[80vh] w-full flex-col gap-4 overflow-y-auto">
<TicketDetails {data} {hideCheckoutBtn} {onCheckoutBtnClick} />
</div>
</Dialog.Content>
</Dialog.Root>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import Title from "$lib/components/atoms/title.svelte";
import Button from "$lib/components/ui/button/button.svelte";
import { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte";
import { type FlightTicket } from "../../data/entities";
import BaggageInfo from "./baggage-info.svelte";
import TripDetails from "./trip-details.svelte";
let {
data,
hideCheckoutBtn,
onCheckoutBtnClick,
}: {
data: FlightTicket;
onCheckoutBtnClick: (tid: number) => void;
hideCheckoutBtn?: boolean;
} = $props();
</script>
<TripDetails {data} />
<BaggageInfo {data} />
{#if !hideCheckoutBtn}
<div class="mt-4 flex items-center justify-end gap-4">
<Title center size="h5" color="black">
{convertAndFormatCurrency(data.priceDetails.displayPrice)}
</Title>
<Button onclick={() => onCheckoutBtnClick(data.id)}>Checkout</Button>
</div>
{/if}

Some files were not shown because too many files have changed in this diff Show More