💥💣 ALMOST THERE???? DUNNO PROBABLY - 90% done
This commit is contained in:
@@ -45,3 +45,7 @@ export class CustomerInfoController {
|
||||
return this.repo.getAllCustomerInfo();
|
||||
}
|
||||
}
|
||||
|
||||
export function getCustomerInfoController() {
|
||||
return new CustomerInfoController(new CustomerInfoRepository(db));
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { ERROR_CODES, type Result } from "$lib/core/data.types";
|
||||
import { and, eq, isNotNull, or, type Database } from "@pkg/db";
|
||||
import { order, passengerInfo } from "@pkg/db/schema";
|
||||
import { order } from "@pkg/db/schema";
|
||||
import { getError, Logger } from "@pkg/logger";
|
||||
import { nanoid } from "nanoid";
|
||||
import {
|
||||
fullOrderModel,
|
||||
limitedOrderWithTicketInfoModel,
|
||||
limitedOrderWithProductModel,
|
||||
OrderStatus,
|
||||
type FullOrderModel,
|
||||
type LimitedOrderWithTicketInfoModel,
|
||||
type LimitedOrderWithProductModel,
|
||||
type NewOrderModel,
|
||||
} from "./entities";
|
||||
|
||||
@@ -19,10 +19,10 @@ export class OrderRepository {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
async listActiveOrders(): Promise<Result<LimitedOrderWithTicketInfoModel[]>> {
|
||||
async listActiveOrders(): Promise<Result<LimitedOrderWithProductModel[]>> {
|
||||
const conditions = [
|
||||
or(
|
||||
eq(order.status, OrderStatus.PENDING_FULLFILLMENT),
|
||||
eq(order.status, OrderStatus.PENDING_FULFILLMENT),
|
||||
eq(order.status, OrderStatus.PARTIALLY_FULFILLED),
|
||||
),
|
||||
isNotNull(order.agentId),
|
||||
@@ -34,29 +34,29 @@ export class OrderRepository {
|
||||
displayPrice: true,
|
||||
basePrice: true,
|
||||
discountAmount: true,
|
||||
pricePerPassenger: true,
|
||||
orderPrice: true,
|
||||
fullfilledPrice: true,
|
||||
status: true,
|
||||
},
|
||||
with: {
|
||||
flightTicketInfo: {
|
||||
product: true,
|
||||
customerInfo: {
|
||||
columns: {
|
||||
id: true,
|
||||
departure: true,
|
||||
arrival: true,
|
||||
departureDate: true,
|
||||
returnDate: true,
|
||||
flightType: true,
|
||||
passengerCounts: true,
|
||||
firstName: true,
|
||||
middleName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
country: true,
|
||||
state: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const out = [] as LimitedOrderWithTicketInfoModel[];
|
||||
const out = [] as LimitedOrderWithProductModel[];
|
||||
|
||||
for (const order of qRes) {
|
||||
const parsed = limitedOrderWithTicketInfoModel.safeParse({
|
||||
const parsed = limitedOrderWithProductModel.safeParse({
|
||||
...order,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
@@ -78,21 +78,13 @@ export class OrderRepository {
|
||||
return { data: out };
|
||||
}
|
||||
|
||||
async getOrderByPNR(pnr: string): Promise<Result<FullOrderModel>> {
|
||||
async getOrderByUID(uid: string): Promise<Result<FullOrderModel>> {
|
||||
const out = await this.db.query.order.findFirst({
|
||||
where: eq(order.pnr, pnr),
|
||||
with: { flightTicketInfo: true },
|
||||
where: eq(order.uid, uid),
|
||||
with: { customerInfo: true, product: 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,
|
||||
});
|
||||
const parsed = fullOrderModel.safeParse({ ...out });
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
error: getError(
|
||||
@@ -111,21 +103,25 @@ export class OrderRepository {
|
||||
|
||||
async createOrder(
|
||||
payload: NewOrderModel,
|
||||
): Promise<Result<{ id: number; pnr: string }>> {
|
||||
const pnr = nanoid(9).toUpperCase();
|
||||
): Promise<Result<{ id: number; uid: string }>> {
|
||||
const uid = nanoid(12).toUpperCase();
|
||||
try {
|
||||
const out = await this.db
|
||||
.insert(order)
|
||||
.values({
|
||||
uid,
|
||||
displayPrice: payload.displayPrice.toFixed(3),
|
||||
basePrice: payload.basePrice.toFixed(3),
|
||||
discountAmount: payload.discountAmount.toFixed(3),
|
||||
fullfilledPrice: payload.fullfilledPrice.toFixed(3),
|
||||
orderPrice: payload.orderPrice.toFixed(3),
|
||||
|
||||
flightTicketInfoId: payload.flightTicketInfoId,
|
||||
paymentInfoId: payload.paymentInfoId,
|
||||
|
||||
status: OrderStatus.PENDING_FULLFILLMENT,
|
||||
pnr,
|
||||
status: OrderStatus.PENDING_FULFILLMENT,
|
||||
|
||||
customerInfoId: payload.customerInfoId,
|
||||
productId: payload.productId,
|
||||
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -133,7 +129,7 @@ export class OrderRepository {
|
||||
.returning({ id: order.id })
|
||||
.execute();
|
||||
|
||||
return { data: { id: out[0]?.id, pnr } };
|
||||
return { data: { id: out[0]?.id, uid } };
|
||||
} catch (e) {
|
||||
return {
|
||||
error: getError(
|
||||
|
||||
@@ -16,8 +16,8 @@ export class OrderController {
|
||||
return this.repo.createOrder(payload);
|
||||
}
|
||||
|
||||
async getOrderByPNR(pnr: string) {
|
||||
return this.repo.getOrderByPNR(pnr);
|
||||
async getOrderByUID(uid: string) {
|
||||
return this.repo.getOrderByUID(uid);
|
||||
}
|
||||
|
||||
async markOrdersAsFulfilled(oids: number[]) {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { SessionOutcome } from "$lib/domains/ckflow/data/entities";
|
||||
import { getCKUseCases } from "$lib/domains/ckflow/domain/usecases";
|
||||
import { getCustomerInfoController } from "$lib/domains/customerinfo/controller";
|
||||
import { EmailerUseCases } from "$lib/domains/email/domain/usecases";
|
||||
import { createOrderPayloadModel } from "$lib/domains/order/data/entities";
|
||||
import { PassengerInfoRepository } from "$lib/domains/passengerinfo/data/repository";
|
||||
import { PassengerInfoController } from "$lib/domains/passengerinfo/domain/controller";
|
||||
import {
|
||||
CheckoutStep,
|
||||
createOrderPayloadModel,
|
||||
} from "$lib/domains/order/data/entities";
|
||||
import { PaymentInfoRepository } from "$lib/domains/paymentinfo/data/repository";
|
||||
import { PaymentInfoUseCases } from "$lib/domains/paymentinfo/domain/usecases";
|
||||
import { CheckoutStep } from "$lib/domains/ticket/data/entities";
|
||||
import { getTC } from "$lib/domains/ticket/domain/controller";
|
||||
import { getProductUseCases } from "$lib/domains/product/usecases";
|
||||
import { createTRPCRouter, publicProcedure } from "$lib/trpc/t";
|
||||
import { db } from "@pkg/db";
|
||||
import { getError, Logger } from "@pkg/logger";
|
||||
@@ -22,10 +23,8 @@ export const orderRouter = createTRPCRouter({
|
||||
.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 cc = getCustomerInfoController();
|
||||
const puc = getProductUseCases();
|
||||
const emailUC = new EmailerUseCases();
|
||||
|
||||
const ftRes = await tc.uncacheAndSaveTicket(input.flightTicketId!);
|
||||
@@ -97,14 +96,14 @@ export const orderRouter = createTRPCRouter({
|
||||
);
|
||||
}
|
||||
|
||||
const pnr = out.data.pnr;
|
||||
const uid = out.data.uid;
|
||||
|
||||
if (!pnr) {
|
||||
if (!uid) {
|
||||
return out;
|
||||
}
|
||||
try {
|
||||
// Get order details for email
|
||||
const orderDetails = await oc.getOrderByPNR(pnr);
|
||||
const orderDetails = await oc.getOrderByPNR(uid);
|
||||
|
||||
if (!orderDetails.data) {
|
||||
return out;
|
||||
@@ -134,10 +133,10 @@ export const orderRouter = createTRPCRouter({
|
||||
// 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",
|
||||
subject: `Flight Confirmation: ${ticketInfo.departure} to ${ticketInfo.arrival} - PNR: ${uid}`,
|
||||
template: "uid-confirmation",
|
||||
templateData: {
|
||||
pnr: pnr,
|
||||
uid: uid,
|
||||
origin: ticketInfo.departure,
|
||||
destination: ticketInfo.arrival,
|
||||
departureDate: new Date(
|
||||
@@ -175,10 +174,10 @@ export const orderRouter = createTRPCRouter({
|
||||
return out;
|
||||
}),
|
||||
|
||||
findByPNR: publicProcedure
|
||||
.input(z.object({ pnr: z.string() }))
|
||||
findByUID: publicProcedure
|
||||
.input(z.object({ uid: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
const oc = new OrderController(new OrderRepository(db));
|
||||
return oc.getOrderByPNR(input.pnr);
|
||||
return oc.getOrderByUID(input.uid);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
<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 Title from "$lib/components/atoms/title.svelte";
|
||||
import { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte";
|
||||
import type { FullOrderModel } from "$lib/domains/order/data/entities";
|
||||
import CreditCardIcon from "~icons/solar/card-broken";
|
||||
|
||||
let { order }: { order: FullOrderModel } = $props();
|
||||
|
||||
@@ -16,37 +12,7 @@
|
||||
</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>
|
||||
<span>TODO: SHOW PRODUCT DETSIL INFO HERE</span>
|
||||
|
||||
<div class={cardStyle}>
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { FullOrderModel } from "@pkg/logic/domains/order/data/entities";
|
||||
import { trpcApiStore } from "$lib/stores/api";
|
||||
import { get } from "svelte/store";
|
||||
import type { FullOrderModel } from "@pkg/logic/domains/order/data/entities";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
export class TrackViewModel {
|
||||
pnr = $state("");
|
||||
uid = $state("");
|
||||
loading = $state(false);
|
||||
bookingData = $state<FullOrderModel | undefined>(undefined);
|
||||
error = $state<string | null>(null);
|
||||
|
||||
async searchBooking() {
|
||||
if (!this.pnr) {
|
||||
if (!this.uid) {
|
||||
this.error = "Please enter a PNR number";
|
||||
return;
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export class TrackViewModel {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
const result = await api.order.findByPNR.query({ pnr: this.pnr });
|
||||
const result = await api.order.findByUID.query({ uid: this.uid });
|
||||
|
||||
if (result.error) {
|
||||
this.error = result.error.message;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "$lib/domains/passengerinfo/data/entities";
|
||||
@@ -1 +0,0 @@
|
||||
export * from "@pkg/logic/domains/ticket/data/entities/enums";
|
||||
@@ -1 +0,0 @@
|
||||
export * from "@pkg/logic/domains/ticket/data/entities/index";
|
||||
@@ -1,363 +0,0 @@
|
||||
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,
|
||||
TicketType,
|
||||
type FlightPriceDetails,
|
||||
type FlightTicket,
|
||||
type TicketSearchDTO,
|
||||
} from "./entities";
|
||||
import type { ScrapedTicketsDataSource } from "./scrape.data.source";
|
||||
|
||||
export class TicketRepository {
|
||||
private db: Database;
|
||||
private ckUseCases: CheckoutFlowUseCases;
|
||||
private scraper: ScrapedTicketsDataSource;
|
||||
|
||||
constructor(db: Database, scraper: ScrapedTicketsDataSource) {
|
||||
this.db = db;
|
||||
this.scraper = scraper;
|
||||
this.ckUseCases = getCKUseCases();
|
||||
}
|
||||
|
||||
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)) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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 ?? [] };
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import { writable } from "svelte/store";
|
||||
import {
|
||||
CabinClass,
|
||||
TicketType,
|
||||
type FlightTicket,
|
||||
type TicketSearchPayload,
|
||||
} from "./entities";
|
||||
|
||||
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: {},
|
||||
});
|
||||
@@ -1,153 +0,0 @@
|
||||
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 { ScrapedTicketsDataSource } from "../data/scrape.data.source";
|
||||
|
||||
export class TicketController {
|
||||
private repo: TicketRepository;
|
||||
private TICKET_SCRAPERS = ["kiwi"];
|
||||
|
||||
constructor(repo: TicketRepository) {
|
||||
this.repo = repo;
|
||||
}
|
||||
|
||||
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("", "");
|
||||
return new TicketController(new TicketRepository(db, ds));
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
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);
|
||||
}),
|
||||
});
|
||||
@@ -1,445 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,391 +0,0 @@
|
||||
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 { flightTicketStore, ticketSearchStore } from "../data/store";
|
||||
import { checkoutVM } from "./checkout/checkout.vm.svelte";
|
||||
import { paymentInfoVM } from "./checkout/payment-info-section/payment.info.vm.svelte";
|
||||
import {
|
||||
MaxStops,
|
||||
SortOption,
|
||||
ticketFiltersStore,
|
||||
} from "./ticket-filters.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();
|
||||
checkoutVM.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());
|
||||
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();
|
||||
@@ -1,50 +0,0 @@
|
||||
<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}
|
||||
@@ -1,78 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Title from "$lib/components/atoms/title.svelte";
|
||||
import { Badge } from "$lib/components/ui/badge/index.js";
|
||||
import Button, {
|
||||
buttonVariants,
|
||||
} from "$lib/components/ui/button/button.svelte";
|
||||
import * as Dialog from "$lib/components/ui/dialog/index.js";
|
||||
import { TRANSITION_ALL } from "$lib/core/constants";
|
||||
import { convertAndFormatCurrency } from "$lib/domains/currency/view/currency.vm.svelte";
|
||||
import { cn } from "$lib/utils";
|
||||
import { type FlightTicket } from "../../data/entities";
|
||||
import { flightTicketVM } from "../ticket.vm.svelte";
|
||||
import TicketDetailsModal from "./ticket-details-modal.svelte";
|
||||
import TicketLegsOverview from "./ticket-legs-overview.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>
|
||||
</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>
|
||||
@@ -1,27 +0,0 @@
|
||||
<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>
|
||||
@@ -1,31 +0,0 @@
|
||||
<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}
|
||||
@@ -1,88 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Title from "$lib/components/atoms/title.svelte";
|
||||
import { Badge } from "$lib/components/ui/badge/index.js";
|
||||
import { Plane } from "@lucide/svelte";
|
||||
|
||||
interface Props {
|
||||
departure: string;
|
||||
destination: string;
|
||||
durationSeconds: number;
|
||||
departureTime: string;
|
||||
arrivalTime: string;
|
||||
departureDate: string;
|
||||
arrivalDate: string;
|
||||
airlineName?: string;
|
||||
stops?: number;
|
||||
}
|
||||
let {
|
||||
departure,
|
||||
destination,
|
||||
durationSeconds,
|
||||
airlineName,
|
||||
stops,
|
||||
departureDate,
|
||||
departureTime,
|
||||
arrivalDate,
|
||||
arrivalTime,
|
||||
}: Props = $props();
|
||||
|
||||
const dH = Math.floor(durationSeconds / 3600);
|
||||
const dM = Math.floor((durationSeconds % 3600) / 60);
|
||||
const durationFormatted = `${dH}h ${dM}m`;
|
||||
</script>
|
||||
|
||||
<div class="flex w-full flex-col gap-4 p-2">
|
||||
<div class="flex w-full items-start justify-between gap-4">
|
||||
<div class="flex flex-col items-start text-start">
|
||||
<Title color="black" size="h5">{departure}</Title>
|
||||
<Title color="black" size="p" weight="normal">
|
||||
{departureTime}
|
||||
</Title>
|
||||
<span class="text-xs text-gray-500 sm:text-sm">{departureDate}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center gap-2 pt-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-2 w-2 rounded-full bg-gray-400"></div>
|
||||
<div class="relative">
|
||||
<div class="w-20 border-t-2 border-gray-300 md:w-40"></div>
|
||||
<Plane
|
||||
class="absolute -top-[10px] left-1/2 -translate-x-1/2 transform text-primary"
|
||||
size={20}
|
||||
/>
|
||||
</div>
|
||||
<div class="h-2 w-2 rounded-full bg-gray-400"></div>
|
||||
</div>
|
||||
|
||||
<span class="text-sm font-medium text-gray-600">
|
||||
{durationFormatted}
|
||||
</span>
|
||||
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
{#if stops !== undefined}
|
||||
<Badge
|
||||
variant={stops > 0 ? "secondary" : "outline"}
|
||||
class="font-medium"
|
||||
>
|
||||
{#if stops > 0}
|
||||
{stops} {stops === 1 ? "Stop" : "Stops"}
|
||||
{:else}
|
||||
Direct Flight
|
||||
{/if}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if airlineName}
|
||||
<span class="text-xs text-gray-500">{airlineName}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-end text-end">
|
||||
<Title color="black" size="h5">{destination}</Title>
|
||||
<Title color="black" size="p" weight="normal">
|
||||
{arrivalTime}
|
||||
</Title>
|
||||
<span class="text-xs text-gray-500 sm:text-sm">{arrivalDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,103 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { TicketType, type FlightTicket } from "../../data/entities";
|
||||
import { Badge } from "$lib/components/ui/badge/index.js";
|
||||
import TicketItinerary from "./ticket-itinerary.svelte";
|
||||
|
||||
let { data }: { data: FlightTicket } = $props();
|
||||
|
||||
const outboundDuration = data.flightIteneraries.outbound.reduce(
|
||||
(acc, curr) => acc + curr.durationSeconds,
|
||||
0,
|
||||
);
|
||||
|
||||
const inboundDuration = data.flightIteneraries.inbound.reduce(
|
||||
(acc, curr) => acc + curr.durationSeconds,
|
||||
0,
|
||||
);
|
||||
|
||||
const isReturnTicket =
|
||||
data.flightIteneraries.inbound.length > 0 &&
|
||||
data.flightType === TicketType.Return;
|
||||
|
||||
function formatDateTime(dateTimeStr: string) {
|
||||
const date = new Date(dateTimeStr);
|
||||
return {
|
||||
time: date.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}),
|
||||
date: date.toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const outboundFirst = data.flightIteneraries.outbound[0];
|
||||
const outboundLast =
|
||||
data.flightIteneraries.outbound[
|
||||
data.flightIteneraries.outbound.length - 1
|
||||
];
|
||||
|
||||
const inboundFirst = isReturnTicket
|
||||
? data.flightIteneraries.inbound[0]
|
||||
: null;
|
||||
const inboundLast = isReturnTicket
|
||||
? data.flightIteneraries.inbound[
|
||||
data.flightIteneraries.inbound.length - 1
|
||||
]
|
||||
: null;
|
||||
|
||||
const outboundDeparture = formatDateTime(outboundFirst.departure.localTime);
|
||||
const outboundArrival = formatDateTime(outboundLast.destination.localTime);
|
||||
|
||||
const inboundDeparture = inboundFirst
|
||||
? formatDateTime(inboundFirst.departure.localTime)
|
||||
: null;
|
||||
const inboundArrival = inboundLast
|
||||
? formatDateTime(inboundLast.destination.localTime)
|
||||
: null;
|
||||
</script>
|
||||
|
||||
{#if isReturnTicket}
|
||||
<Badge variant="outline" class="w-max">
|
||||
<span>Outbound</span>
|
||||
</Badge>
|
||||
{/if}
|
||||
<TicketItinerary
|
||||
departure={data.departure}
|
||||
destination={data.arrival}
|
||||
durationSeconds={outboundDuration}
|
||||
stops={data.flightIteneraries.outbound.length - 1}
|
||||
departureTime={outboundDeparture.time}
|
||||
departureDate={outboundDeparture.date}
|
||||
arrivalTime={outboundArrival.time}
|
||||
arrivalDate={outboundArrival.date}
|
||||
airlineName={outboundFirst.airline.name}
|
||||
/>
|
||||
|
||||
{#if isReturnTicket}
|
||||
<div class="w-full border-t-2 border-dashed border-gray-400"></div>
|
||||
|
||||
{#if isReturnTicket}
|
||||
<Badge variant="outline" class="w-max">
|
||||
<span>Inbound</span>
|
||||
</Badge>
|
||||
{/if}
|
||||
|
||||
<TicketItinerary
|
||||
departure={data.arrival}
|
||||
destination={data.departure}
|
||||
durationSeconds={inboundDuration}
|
||||
stops={data.flightIteneraries.inbound.length - 1}
|
||||
departureTime={inboundDeparture?.time || ""}
|
||||
departureDate={inboundDeparture?.date || ""}
|
||||
arrivalTime={inboundArrival?.time || ""}
|
||||
arrivalDate={inboundArrival?.date || ""}
|
||||
airlineName={inboundFirst?.airline.name}
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full border-t-2 border-dashed border-gray-400"></div>
|
||||
{/if}
|
||||
@@ -1,212 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Title from "$lib/components/atoms/title.svelte";
|
||||
import { TicketType, type FlightTicket } from "../../data/entities";
|
||||
import * as Accordion from "$lib/components/ui/accordion";
|
||||
import { Badge } from "$lib/components/ui/badge";
|
||||
import { formatDateTime } from "@pkg/logic/core/date.utils";
|
||||
|
||||
let { data }: { data: FlightTicket } = $props();
|
||||
|
||||
const isReturnTicket = data.flightType === TicketType.Return;
|
||||
|
||||
function formatDuration(seconds: number) {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Title size="h4" color="black">Trip Details</Title>
|
||||
|
||||
<Accordion.Root type="single" class="flex w-full flex-col gap-4">
|
||||
<Accordion.Item
|
||||
value="outbound"
|
||||
class="rounded-lg border-2 border-gray-200 bg-white px-4 shadow-md"
|
||||
>
|
||||
<Accordion.Trigger class="w-full">
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<Badge variant="outline" class="w-max">Outbound</Badge>
|
||||
<div class="flex w-full items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">
|
||||
{data.flightIteneraries.outbound[0].departure
|
||||
.station.code}
|
||||
</span>
|
||||
<span class="text-gray-500">→</span>
|
||||
<span class="font-medium">
|
||||
{data.flightIteneraries.outbound[
|
||||
data.flightIteneraries.outbound.length - 1
|
||||
].destination.station.code}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm text-gray-600">
|
||||
{data.flightIteneraries.outbound[0].airline.name}
|
||||
</span>
|
||||
</div>
|
||||
<span>
|
||||
{formatDuration(
|
||||
data.flightIteneraries.outbound.reduce(
|
||||
(acc, curr) => acc + curr.durationSeconds,
|
||||
0,
|
||||
),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
<Accordion.Content>
|
||||
<div
|
||||
class="flex flex-col gap-4 border-t-2 border-dashed border-gray-200 p-4"
|
||||
>
|
||||
{#each data.flightIteneraries.outbound as flight, index}
|
||||
{#if index > 0}
|
||||
<div class="my-2 border-t border-dashed"></div>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Flight {flight.flightNumber}</span>
|
||||
<span class="text-sm text-gray-500">
|
||||
• {flight.airline.name}
|
||||
</span>
|
||||
</div>
|
||||
<span>{formatDuration(flight.durationSeconds)}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-semibold">
|
||||
{formatDateTime(flight.departure.localTime)
|
||||
.time}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500">
|
||||
{formatDateTime(flight.departure.localTime)
|
||||
.date}
|
||||
</span>
|
||||
<span class="mt-1 text-sm text-gray-600">
|
||||
{flight.departure.station.city} ({flight
|
||||
.departure.station.code})
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-end">
|
||||
<span class="text-sm font-semibold">
|
||||
{formatDateTime(flight.destination.localTime)
|
||||
.time}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500">
|
||||
{formatDateTime(flight.destination.localTime)
|
||||
.date}
|
||||
</span>
|
||||
<span class="mt-1 text-sm text-gray-600">
|
||||
{flight.destination.station.city} ({flight
|
||||
.destination.station.code})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
|
||||
{#if isReturnTicket && data.flightIteneraries.inbound.length > 0}
|
||||
<Accordion.Item
|
||||
value="inbound"
|
||||
class="rounded-lg border-2 border-gray-200 bg-white px-4 shadow-md"
|
||||
>
|
||||
<Accordion.Trigger class="w-full">
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<Badge variant="outline" class="w-max">Inbound</Badge>
|
||||
<div class="flex w-full items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">
|
||||
{data.flightIteneraries.inbound[0].departure
|
||||
.station.code}
|
||||
</span>
|
||||
<span class="text-gray-500">→</span>
|
||||
<span class="font-medium">
|
||||
{data.flightIteneraries.inbound[
|
||||
data.flightIteneraries.inbound.length - 1
|
||||
].destination.station.code}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm text-gray-600">
|
||||
{data.flightIteneraries.inbound[0].airline.name}
|
||||
</span>
|
||||
</div>
|
||||
<span>
|
||||
{formatDuration(
|
||||
data.flightIteneraries.inbound.reduce(
|
||||
(acc, curr) => acc + curr.durationSeconds,
|
||||
0,
|
||||
),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
|
||||
<Accordion.Content>
|
||||
<div
|
||||
class="flex flex-col gap-4 border-t-2 border-dashed border-gray-200 p-4"
|
||||
>
|
||||
{#each data.flightIteneraries.inbound as flight, index}
|
||||
{#if index > 0}
|
||||
<div class="my-2 border-t border-dashed"></div>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Flight {flight.flightNumber}</span>
|
||||
<span class="text-sm text-gray-500">
|
||||
• {flight.airline.name}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
>{formatDuration(
|
||||
flight.durationSeconds,
|
||||
)}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-semibold">
|
||||
{formatDateTime(
|
||||
flight.departure.localTime,
|
||||
).time}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500">
|
||||
{formatDateTime(
|
||||
flight.departure.localTime,
|
||||
).date}
|
||||
</span>
|
||||
<span class="mt-1 text-sm text-gray-600">
|
||||
{flight.departure.station.city} ({flight
|
||||
.departure.station.code})
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-end">
|
||||
<span class="text-sm font-semibold">
|
||||
{formatDateTime(
|
||||
flight.destination.localTime,
|
||||
).time}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500">
|
||||
{formatDateTime(
|
||||
flight.destination.localTime,
|
||||
).date}
|
||||
</span>
|
||||
<span class="mt-1 text-sm text-gray-600">
|
||||
{flight.destination.station.city} ({flight
|
||||
.destination.station.code})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
{/if}
|
||||
</Accordion.Root>
|
||||
Reference in New Issue
Block a user