💥💣 ALMOST THERE???? DUNNO PROBABLY - 90% done

This commit is contained in:
user
2025-10-21 16:40:46 +03:00
parent 94bb51bdc7
commit 8a169f84cc
35 changed files with 1022 additions and 2225 deletions

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import UserIcon from "~icons/solar/user-broken";
import type { CustomerInfoModel } from "../data";
import InfoCard from "./info-card.svelte";
import CInfoCard from "./cinfo-card.svelte";
let {
customerInfo,
@@ -10,7 +10,7 @@
} = $props();
</script>
<InfoCard icon={UserIcon} title="Customer Information">
<CInfoCard icon={UserIcon} title="Customer Information">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<span class="text-xs text-gray-500">Full Name</span>
@@ -54,4 +54,4 @@
{/if}
</div>
</div>
</InfoCard>
</CInfoCard>

View File

@@ -45,3 +45,7 @@ export class CustomerInfoController {
return this.repo.getAllCustomerInfo();
}
}
export function getCustomerInfoController() {
return new CustomerInfoController(new CustomerInfoRepository(db));
}

View File

@@ -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(

View File

@@ -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[]) {

View File

@@ -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);
}),
});

View File

@@ -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">

View File

@@ -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;

View File

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

View File

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

View File

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

View File

@@ -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);
}
}

View File

@@ -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 ?? [] };
}
}

View File

@@ -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: {},
});

View File

@@ -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));
}

View File

@@ -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);
}),
});

View File

@@ -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;
}
}

View File

@@ -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();

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>

View File

@@ -0,0 +1,8 @@
import { getProductUseCases } from "$lib/domains/product/usecases";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ params }) => {
return await getProductUseCases().getProductByLinkId(
Number(params.plid ?? "-1"),
);
};

View File

@@ -13,7 +13,7 @@
import PaymentVerificationSection from "$lib/domains/checkout/payment-verification-section.svelte";
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
import { CheckoutStep } from "$lib/domains/order/data/entities";
import { flightTicketStore } from "$lib/domains/ticket/data/store";
import { productStore } from "$lib/domains/product/store";
import { onDestroy, onMount } from "svelte";
import { toast } from "svelte-sonner";
import SearchIcon from "~icons/solar/magnifer-linear";
@@ -31,7 +31,7 @@
if (!pageData.data) {
return;
}
flightTicketStore.set(pageData.data);
productStore.set(pageData.data);
checkoutVM.loading = false;
checkoutVM.setupPinger();

View File

@@ -1,6 +0,0 @@
import { getTC } from "$lib/domains/ticket/domain/controller";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ params }) => {
return await getTC().getTicketById(Number(params.tid ?? "-1"));
};

View File

@@ -1,20 +1,20 @@
<script lang="ts">
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
import { Input } from "$lib/components/ui/input";
import { Button } from "$lib/components/ui/button";
import { trackVM } from "$lib/domains/order/view/track/track.vm.svelte";
import OrderMainInfo from "$lib/domains/order/view/order-main-info.svelte";
import ButtonLoadableText from "$lib/components/atoms/button-loadable-text.svelte";
import OrderMiscInfo from "$lib/domains/order/view/order-misc-info.svelte";
import { onMount } from "svelte";
import { page } from "$app/state";
import ButtonLoadableText from "$lib/components/atoms/button-loadable-text.svelte";
import MaxWidthWrapper from "$lib/components/molecules/max-width-wrapper.svelte";
import { Button } from "$lib/components/ui/button";
import { Input } from "$lib/components/ui/input";
import OrderMainInfo from "$lib/domains/order/view/order-main-info.svelte";
import OrderMiscInfo from "$lib/domains/order/view/order-misc-info.svelte";
import { trackVM } from "$lib/domains/order/view/track/track.vm.svelte";
import { onMount } from "svelte";
let searchDisabled = $derived(trackVM.loading || !trackVM.pnr);
let searchDisabled = $derived(trackVM.loading || !trackVM.uid);
onMount(() => {
const pnr = page.url.searchParams.get("pnr");
if (pnr) {
trackVM.pnr = pnr;
const uid = page.url.searchParams.get("uid");
if (uid) {
trackVM.uid = uid;
trackVM.searchBooking();
}
});
@@ -29,7 +29,7 @@
<div class="flex gap-2">
<Input
placeholder="Enter your PNR number"
bind:value={trackVM.pnr}
bind:value={trackVM.uid}
/>
<Button
disabled={searchDisabled}