and so it begins
This commit is contained in:
@@ -1 +0,0 @@
|
||||
export * from "@pkg/logic/domains/airport/data/entities";
|
||||
@@ -1,222 +0,0 @@
|
||||
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,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
<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>
|
||||
@@ -1,143 +0,0 @@
|
||||
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();
|
||||
@@ -1,259 +0,0 @@
|
||||
<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>
|
||||
@@ -1,4 +1,12 @@
|
||||
import { round } from "$lib/core/num.utils";
|
||||
import {
|
||||
getCKUseCases,
|
||||
type CheckoutFlowUseCases,
|
||||
} from "$lib/domains/ckflow/domain/usecases";
|
||||
import { and, arrayContains, eq, or, type Database } from "@pkg/db";
|
||||
import { flightTicketInfo, order } from "@pkg/db/schema";
|
||||
import { getError, Logger } from "@pkg/logger";
|
||||
import { ERROR_CODES, type Result } from "@pkg/result";
|
||||
import {
|
||||
flightPriceDetailsModel,
|
||||
flightTicketModel,
|
||||
@@ -7,32 +15,17 @@ import {
|
||||
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,
|
||||
) {
|
||||
constructor(db: Database, scraper: ScrapedTicketsDataSource) {
|
||||
this.db = db;
|
||||
this.scraper = scraper;
|
||||
this.ckUseCases = getCKUseCases();
|
||||
this.amadeusApi = amadeusApi;
|
||||
}
|
||||
|
||||
async getCheckoutUrlByRefOId(refOId: number): Promise<Result<string>> {
|
||||
@@ -96,10 +89,7 @@ export class TicketRepository {
|
||||
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;
|
||||
}
|
||||
return out;
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,25 @@
|
||||
import { AirportsRepository } from "$lib/domains/airport/data/repository";
|
||||
import { env } from "$env/dynamic/private";
|
||||
import { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte";
|
||||
import { db } from "@pkg/db";
|
||||
import { getError, Logger } from "@pkg/logger";
|
||||
import { DiscountType, type CouponModel } from "@pkg/logic/domains/coupon/data";
|
||||
import { ERROR_CODES, type Result } from "@pkg/result";
|
||||
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) {
|
||||
constructor(repo: TicketRepository) {
|
||||
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,
|
||||
@@ -166,14 +153,5 @@ export function getTC() {
|
||||
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),
|
||||
);
|
||||
return new TicketController(new TicketRepository(db, ds));
|
||||
}
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,24 @@
|
||||
import { goto, replaceState } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
|
||||
import {
|
||||
type FlightPriceDetails,
|
||||
type FlightTicket,
|
||||
ticketSearchPayloadModel,
|
||||
} from "$lib/domains/ticket/data/entities/index";
|
||||
import { trpcApiStore } from "$lib/stores/api";
|
||||
import type { Result } from "@pkg/result";
|
||||
import { nanoid } from "nanoid";
|
||||
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 { flightTicketStore, ticketSearchStore } from "../data/store";
|
||||
import { ticketCheckoutVM } from "./checkout/flight-checkout.vm.svelte";
|
||||
import { paymentInfoVM } from "./checkout/payment-info-section/payment.info.vm.svelte";
|
||||
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);
|
||||
@@ -355,12 +354,6 @@ export class FlightTicketViewModel {
|
||||
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));
|
||||
|
||||
Reference in New Issue
Block a user