stashing code
This commit is contained in:
1
apps/frontend/src/lib/domains/airport/data/entities.ts
Normal file
1
apps/frontend/src/lib/domains/airport/data/entities.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "@pkg/logic/domains/airport/data/entities";
|
||||
222
apps/frontend/src/lib/domains/airport/data/repository.ts
Normal file
222
apps/frontend/src/lib/domains/airport/data/repository.ts
Normal 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,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
143
apps/frontend/src/lib/domains/airport/view/airport.vm.svelte.ts
Normal file
143
apps/frontend/src/lib/domains/airport/view/airport.vm.svelte.ts
Normal 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();
|
||||
@@ -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>
|
||||
72
apps/frontend/src/lib/domains/auth/config/base.ts
Normal file
72
apps/frontend/src/lib/domains/auth/config/base.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
10
apps/frontend/src/lib/domains/auth/config/client.ts
Normal file
10
apps/frontend/src/lib/domains/auth/config/client.ts
Normal 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>()],
|
||||
});
|
||||
4
apps/frontend/src/lib/domains/auth/data/entities.ts
Normal file
4
apps/frontend/src/lib/domains/auth/data/entities.ts
Normal 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;
|
||||
15
apps/frontend/src/lib/domains/auth/domain/controller.ts
Normal file
15
apps/frontend/src/lib/domains/auth/domain/controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
54
apps/frontend/src/lib/domains/auth/domain/hash.test.ts
Normal file
54
apps/frontend/src/lib/domains/auth/domain/hash.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
33
apps/frontend/src/lib/domains/auth/domain/hash.ts
Normal file
33
apps/frontend/src/lib/domains/auth/domain/hash.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
12
apps/frontend/src/lib/domains/auth/domain/router.ts
Normal file
12
apps/frontend/src/lib/domains/auth/domain/router.ts
Normal 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);
|
||||
}),
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export class SessionController {
|
||||
constructor() {}
|
||||
}
|
||||
69
apps/frontend/src/lib/domains/auth/view/auth.vm.svelte.ts
Normal file
69
apps/frontend/src/lib/domains/auth/view/auth.vm.svelte.ts
Normal 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();
|
||||
36
apps/frontend/src/lib/domains/auth/view/login-form.svelte
Normal file
36
apps/frontend/src/lib/domains/auth/view/login-form.svelte
Normal 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>
|
||||
1
apps/frontend/src/lib/domains/ckflow/data/entities.ts
Normal file
1
apps/frontend/src/lib/domains/ckflow/data/entities.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "@pkg/logic/domains/ckflow/data/entities";
|
||||
614
apps/frontend/src/lib/domains/ckflow/data/repository.ts
Normal file
614
apps/frontend/src/lib/domains/ckflow/data/repository.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
154
apps/frontend/src/lib/domains/ckflow/domain/router.ts
Normal file
154
apps/frontend/src/lib/domains/ckflow/domain/router.ts
Normal 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);
|
||||
}),
|
||||
});
|
||||
78
apps/frontend/src/lib/domains/ckflow/domain/usecases.ts
Normal file
78
apps/frontend/src/lib/domains/ckflow/domain/usecases.ts
Normal 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);
|
||||
}
|
||||
625
apps/frontend/src/lib/domains/ckflow/view/ckflow.vm.svelte.ts
Normal file
625
apps/frontend/src/lib/domains/ckflow/view/ckflow.vm.svelte.ts
Normal 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();
|
||||
1295
apps/frontend/src/lib/domains/currency/data/currencies.ts
Normal file
1295
apps/frontend/src/lib/domains/currency/data/currencies.ts
Normal file
File diff suppressed because it is too large
Load Diff
1
apps/frontend/src/lib/domains/currency/data/entities.ts
Normal file
1
apps/frontend/src/lib/domains/currency/data/entities.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "@pkg/logic/domains/currency/data/entities";
|
||||
134
apps/frontend/src/lib/domains/currency/data/repository.ts
Normal file
134
apps/frontend/src/lib/domains/currency/data/repository.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
13
apps/frontend/src/lib/domains/currency/domain/controller.ts
Normal file
13
apps/frontend/src/lib/domains/currency/domain/controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
14
apps/frontend/src/lib/domains/currency/domain/router.ts
Normal file
14
apps/frontend/src/lib/domains/currency/domain/router.ts
Normal 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();
|
||||
}),
|
||||
});
|
||||
0
apps/frontend/src/lib/domains/currency/utils.ts
Normal file
0
apps/frontend/src/lib/domains/currency/utils.ts
Normal 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> -->
|
||||
@@ -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));
|
||||
}
|
||||
1
apps/frontend/src/lib/domains/email/data/entities.ts
Normal file
1
apps/frontend/src/lib/domains/email/data/entities.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "@pkg/email/src/data";
|
||||
94
apps/frontend/src/lib/domains/email/domain/usecases.ts
Normal file
94
apps/frontend/src/lib/domains/email/domain/usecases.ts
Normal 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,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
1
apps/frontend/src/lib/domains/order/data/entities.ts
Normal file
1
apps/frontend/src/lib/domains/order/data/entities.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "@pkg/logic/domains/order/data/entities";
|
||||
185
apps/frontend/src/lib/domains/order/data/repository.ts
Normal file
185
apps/frontend/src/lib/domains/order/data/repository.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
31
apps/frontend/src/lib/domains/order/domain/controller.ts
Normal file
31
apps/frontend/src/lib/domains/order/domain/controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
184
apps/frontend/src/lib/domains/order/domain/router.ts
Normal file
184
apps/frontend/src/lib/domains/order/domain/router.ts
Normal 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);
|
||||
}),
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
111
apps/frontend/src/lib/domains/order/view/order-misc-info.svelte
Normal file
111
apps/frontend/src/lib/domains/order/view/order-misc-info.svelte
Normal 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>
|
||||
121
apps/frontend/src/lib/domains/order/view/order.vm.svelte.ts
Normal file
121
apps/frontend/src/lib/domains/order/view/order.vm.svelte.ts
Normal 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();
|
||||
@@ -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();
|
||||
@@ -0,0 +1 @@
|
||||
export * from "@pkg/logic/domains/passengerinfo/data/entities";
|
||||
209
apps/frontend/src/lib/domains/passengerinfo/data/repository.ts
Normal file
209
apps/frontend/src/lib/domains/passengerinfo/data/repository.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
@@ -0,0 +1 @@
|
||||
export * from "@pkg/logic/domains/paymentinfo/data/entities";
|
||||
59
apps/frontend/src/lib/domains/paymentinfo/data/repository.ts
Normal file
59
apps/frontend/src/lib/domains/paymentinfo/data/repository.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
22
apps/frontend/src/lib/domains/paymentinfo/domain/usecases.ts
Normal file
22
apps/frontend/src/lib/domains/paymentinfo/domain/usecases.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "$lib/domains/passengerinfo/data/entities";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "@pkg/logic/domains/ticket/data/entities/enums";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "@pkg/logic/domains/ticket/data/entities/index";
|
||||
373
apps/frontend/src/lib/domains/ticket/data/repository.ts
Normal file
373
apps/frontend/src/lib/domains/ticket/data/repository.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 ?? [] };
|
||||
}
|
||||
}
|
||||
24
apps/frontend/src/lib/domains/ticket/data/store.ts
Normal file
24
apps/frontend/src/lib/domains/ticket/data/store.ts
Normal 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: "",
|
||||
});
|
||||
202
apps/frontend/src/lib/domains/ticket/data/test.data.generator.ts
Normal file
202
apps/frontend/src/lib/domains/ticket/data/test.data.generator.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
179
apps/frontend/src/lib/domains/ticket/domain/controller.ts
Normal file
179
apps/frontend/src/lib/domains/ticket/domain/controller.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
70
apps/frontend/src/lib/domains/ticket/domain/router.ts
Normal file
70
apps/frontend/src/lib/domains/ticket/domain/router.ts
Normal 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);
|
||||
}),
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
<span>show checkout confirmation status here</span>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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>
|
||||
398
apps/frontend/src/lib/domains/ticket/view/ticket.vm.svelte.ts
Normal file
398
apps/frontend/src/lib/domains/ticket/view/ticket.vm.svelte.ts
Normal 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();
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user