a.... LOT of Refactoring ~ 30% done???

This commit is contained in:
user
2025-10-21 15:44:16 +03:00
parent 5f4e9fc7fc
commit c0df8cae57
27 changed files with 586 additions and 746 deletions

View File

@@ -1,6 +1,5 @@
import { ckFlowVM } from "$lib/domains/ckflow/view/ckflow.vm.svelte";
import { CheckoutStep, newOrderModel } from "$lib/domains/order/data/entities";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import {
paymentInfoPayloadModel,
PaymentMethod,
@@ -8,9 +7,10 @@ import {
import { trpcApiStore } from "$lib/stores/api";
import { toast } from "svelte-sonner";
import { get } from "svelte/store";
import { flightTicketStore } from "../ticket/data/store";
import { customerInfoVM } from "../customerinfo/view/customerinfo.vm.svelte";
import { productStore } from "../product/store";
import { paymentInfoVM } from "./payment-info-section/payment.info.vm.svelte";
import { calculateTicketPrices } from "./total.calculator";
import { calculateFinalPrices } from "./utils";
class CheckoutViewModel {
checkoutStep = $state(CheckoutStep.Initial);
@@ -44,14 +44,8 @@ class CheckoutViewModel {
if (!api) {
return false;
}
const ticket = get(flightTicketStore);
if (!ticket || !ticket.refOIds) {
return false;
}
const out = await api.ticket.ping.query({
tid: ticket.id,
refOIds: ticket.refOIds,
});
// TODO: no need to ping now REMOVE THIS PINGING LOGIC
}
async checkout() {
@@ -65,34 +59,21 @@ class CheckoutViewModel {
this.checkoutSubmitted = false;
return false;
}
const product = get(productStore);
const ticket = get(flightTicketStore);
if (!product || !customerInfoVM.customerInfo) {
this.checkoutSubmitted = false;
return false;
}
const prices = calculateTicketPrices(
ticket,
passengerInfoVM.passengerInfos,
const priceDetails = calculateFinalPrices(
product,
customerInfoVM.customerInfo,
);
const validatedPrices = {
subtotal: isNaN(prices.subtotal) ? 0 : prices.subtotal,
discountAmount: isNaN(prices.discountAmount)
? 0
: prices.discountAmount,
finalTotal: isNaN(prices.finalTotal) ? 0 : prices.finalTotal,
pricePerPassenger: isNaN(prices.pricePerPassenger)
? 0
: prices.pricePerPassenger,
};
const parsed = newOrderModel.safeParse({
basePrice: validatedPrices.subtotal,
discountAmount: validatedPrices.discountAmount,
displayPrice: validatedPrices.finalTotal,
orderPrice: validatedPrices.finalTotal, // Same as displayPrice
fullfilledPrice: validatedPrices.finalTotal, // Same as displayPrice
pricePerPassenger: validatedPrices.pricePerPassenger,
flightTicketInfoId: -1,
paymentInfoId: -1,
...priceDetails,
productId: product.id,
});
if (parsed.error) {
@@ -107,7 +88,7 @@ class CheckoutViewModel {
const pInfoParsed = paymentInfoPayloadModel.safeParse({
method: PaymentMethod.Card,
cardDetails: paymentInfoVM.cardDetails,
flightTicketInfoId: ticket.id,
productId: get(productStore)?.id,
});
if (pInfoParsed.error) {
console.log(parsed.error);
@@ -122,11 +103,10 @@ class CheckoutViewModel {
console.log("Creating order");
this.loading = true;
const out = await api.order.createOrder.mutate({
flightTicketId: ticket.id,
productId: get(productStore)?.id,
orderModel: parsed.data,
passengerInfos: passengerInfoVM.passengerInfos,
customerInfo: customerInfoVM.customerInfo!,
paymentInfo: pInfoParsed.data,
refOIds: ticket.refOIds,
flowId: ckFlowVM.flowId,
});

View File

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

View File

@@ -4,10 +4,10 @@
import * as Select from "$lib/components/ui/select";
import { COUNTRIES_SELECT } from "$lib/core/countries";
import { capitalize } from "$lib/core/string.utils";
import type { CustomerInfo } from "$lib/domains/ticket/data/entities/create.entities";
import type { CustomerInfoModel } from "$lib/domains/ticket/data/entities/create.entities";
import { billingDetailsVM } from "./billing.details.vm.svelte";
let { info = $bindable() }: { info: CustomerInfo } = $props();
let { info = $bindable() }: { info: CustomerInfoModel } = $props();
function onSubmit(e: SubmitEvent) {
e.preventDefault();

View File

@@ -1,15 +1,12 @@
import {
customerInfoModel,
type CustomerInfo,
} from "$lib/domains/ticket/data/entities/create.entities";
import { Gender } from "$lib/domains/ticket/data/entities/index";
import { type CustomerInfoModel, Gender } from "$lib/domains/customerinfo/data";
import { customerInfoModel } from "$lib/domains/passengerinfo/data/entities";
import { z } from "zod";
export class BillingDetailsViewModel {
// @ts-ignore
billingDetails = $state<CustomerInfo>(undefined);
billingDetails = $state<CustomerInfoModel>(undefined);
piiErrors = $state<Partial<Record<keyof CustomerInfo, string>>>({});
piiErrors = $state<Partial<Record<keyof CustomerInfoModel, string>>>({});
constructor() {
this.reset();
@@ -34,15 +31,15 @@ export class BillingDetailsViewModel {
zipCode: "",
address: "",
address2: "",
} as CustomerInfo;
} as CustomerInfoModel;
this.piiErrors = {};
}
setPII(info: CustomerInfo) {
setPII(info: CustomerInfoModel) {
this.billingDetails = info;
}
validatePII(info: CustomerInfo) {
validatePII(info: CustomerInfoModel) {
try {
const result = customerInfoModel.parse(info);
this.piiErrors = {};
@@ -51,11 +48,11 @@ export class BillingDetailsViewModel {
if (error instanceof z.ZodError) {
this.piiErrors = error.errors.reduce(
(acc, curr) => {
const path = curr.path[0] as keyof CustomerInfo;
const path = curr.path[0] as keyof CustomerInfoModel;
acc[path] = curr.message;
return acc;
},
{} as Record<keyof CustomerInfo, string>,
{} as Record<keyof CustomerInfoModel, string>,
);
}
return null;

View File

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

View File

@@ -0,0 +1,50 @@
import type { CustomerInfoModel } from "@pkg/logic/domains/customerinfo/data";
import type { OrderPriceDetailsModel } from "@pkg/logic/domains/order/data/entities";
import type { ProductModel } from "@pkg/logic/domains/product/data";
/**
* Calculates final prices for a product checkout using OrderPriceDetailsModel
* @param product - The product being purchased
* @param customerInfo - Customer information (included for future extensibility)
* @returns OrderPriceDetailsModel with all price fields calculated
*/
export function calculateFinalPrices(
product: ProductModel | null,
customerInfo?: CustomerInfoModel | null,
): OrderPriceDetailsModel {
if (!product) {
return {
currency: "USD",
basePrice: 0,
discountAmount: 0,
displayPrice: 0,
orderPrice: 0,
fullfilledPrice: 0,
};
}
const basePrice = product.price || 0;
const discountPrice = product.discountPrice || 0;
// Calculate discount amount: if discountPrice is set and less than base price
const hasDiscount = discountPrice > 0 && discountPrice < basePrice;
const discountAmount = hasDiscount ? basePrice - discountPrice : 0;
// Display price is either the discounted price or the base price
const displayPrice = hasDiscount ? discountPrice : basePrice;
// For single product checkout:
// - orderPrice = displayPrice (what customer pays)
// - fullfilledPrice = displayPrice (same as order price for immediate fulfillment)
const orderPrice = displayPrice;
const fullfilledPrice = displayPrice;
return {
currency: "USD",
basePrice,
discountAmount,
displayPrice,
orderPrice,
fullfilledPrice,
};
}

View File

@@ -1,5 +1,5 @@
import type { CustomerInfoModel } from "$lib/domains/customerinfo/data";
import { CheckoutStep } from "$lib/domains/order/data/entities";
import type { CustomerInfo } from "$lib/domains/passengerinfo/data/entities";
import type { PaymentInfoPayload } from "$lib/domains/paymentinfo/data/entities";
import type { Database } from "@pkg/db";
import { and, eq } from "@pkg/db";
@@ -202,7 +202,7 @@ export class CheckoutFlowRepository {
async syncPersonalInfo(
flowId: string,
personalInfo: CustomerInfo,
personalInfo: CustomerInfoModel,
): Promise<Result<boolean>> {
try {
const existingSession = await this.db

View File

@@ -1,6 +1,6 @@
import {
customerInfoModel,
type CustomerInfo,
type CustomerInfoModel,
} from "$lib/domains/passengerinfo/data/entities";
import {
paymentInfoPayloadModel,
@@ -41,7 +41,7 @@ export const ckflowRouter = createTRPCRouter({
return getCKUseCases().createFlow({
domain: input.domain,
refOIds: input.refOIds,
ticketId: input.ticketId,
productId: input.productId,
ipAddress,
userAgent,
initialUrl: "",
@@ -72,7 +72,7 @@ export const ckflowRouter = createTRPCRouter({
.mutation(async ({ input }) => {
return getCKUseCases().syncPersonalInfo(
input.flowId,
input.personalInfo as CustomerInfo,
input.personalInfo as CustomerInfoModel,
);
}),

View File

@@ -1,4 +1,4 @@
import type { CustomerInfo } from "$lib/domains/passengerinfo/data/entities";
import type { CustomerInfoModel } from "$lib/domains/customerinfo/data";
import type { PaymentInfoPayload } from "$lib/domains/paymentinfo/data/entities";
import { db } from "@pkg/db";
import { isTimestampMoreThan1MinAgo } from "@pkg/logic/core/date.utils";
@@ -54,7 +54,7 @@ export class CheckoutFlowUseCases {
return this.repo.executePaymentStep(flowId, payload);
}
async syncPersonalInfo(flowId: string, personalInfo: CustomerInfo) {
async syncPersonalInfo(flowId: string, personalInfo: CustomerInfoModel) {
return this.repo.syncPersonalInfo(flowId, personalInfo);
}

View File

@@ -1,4 +1,7 @@
import { page } from "$app/state";
import { checkoutVM } from "$lib/domains/checkout/checkout.vm.svelte";
import { billingDetailsVM } from "$lib/domains/checkout/payment-info-section/billing.details.vm.svelte";
import { paymentInfoVM } from "$lib/domains/checkout/payment-info-section/payment.info.vm.svelte";
import {
CKActionType,
SessionOutcome,
@@ -6,21 +9,16 @@ import {
type PendingAction,
type PendingActions,
} from "$lib/domains/ckflow/data/entities";
import type { CustomerInfoModel } from "$lib/domains/customerinfo/data";
import {
customerInfoModel,
type CustomerInfo,
} from "$lib/domains/passengerinfo/data/entities";
CheckoutStep,
type OrderPriceDetailsModel,
} from "$lib/domains/order/data/entities";
import { customerInfoModel } from "$lib/domains/passengerinfo/data/entities";
import { passengerInfoVM } from "$lib/domains/passengerinfo/view/passenger.info.vm.svelte";
import type { PaymentInfoPayload } from "$lib/domains/paymentinfo/data/entities";
import { PaymentMethod } from "$lib/domains/paymentinfo/data/entities";
import {
CheckoutStep,
type FlightPriceDetails,
} from "$lib/domains/ticket/data/entities";
import { flightTicketStore } from "$lib/domains/ticket/data/store";
import { checkoutVM } from "$lib/domains/ticket/view/checkout/checkout.vm.svelte";
import { billingDetailsVM } from "$lib/domains/ticket/view/checkout/payment-info-section/billing.details.vm.svelte";
import { paymentInfoVM } from "$lib/domains/ticket/view/checkout/payment-info-section/payment.info.vm.svelte";
import { productStore } from "$lib/domains/product/store";
import { trpcApiStore } from "$lib/stores/api";
import { ClientLogger } from "@pkg/logger/client";
import { toast } from "svelte-sonner";
@@ -185,7 +183,7 @@ export class CKFlowViewModel {
private paymentInfoDebounceTimer: NodeJS.Timeout | null = null;
syncInterval = 300; // 300ms debounce for syncing
updatedPrices = $state<FlightPriceDetails | undefined>(undefined);
updatedPrices = $state<OrderPriceDetailsModel | undefined>(undefined);
constructor() {
this.actionRunner = new ActionRunner();
@@ -216,17 +214,10 @@ export class CKFlowViewModel {
return;
}
const ticket = get(flightTicketStore);
const refOIds = ticket.refOIds;
if (!refOIds) {
this.setupDone = true;
return; // Since we don't have any attached order(s), we don't need to worry about this dude
}
const info = await api.ckflow.initiateCheckout.mutate({
domain: window.location.hostname,
refOIds,
ticketId: ticket.id,
refOIds: [],
productId: get(productStore)?.id,
});
if (info.error) {
@@ -236,7 +227,7 @@ export class CKFlowViewModel {
if (!info.data) {
toast.error("Error while creating checkout flow", {
description: "Try refreshing page or search for ticket again",
description: "Try refreshing page or contact us",
});
return;
}
@@ -248,7 +239,7 @@ export class CKFlowViewModel {
this.setupDone = true;
}
debouncePersonalInfoSync(personalInfo: CustomerInfo) {
debouncePersonalInfoSync(personalInfo: CustomerInfoModel) {
this.clearPersonalInfoDebounce();
this.personalInfoDebounceTimer = setTimeout(() => {
this.syncPersonalInfo(personalInfo);
@@ -262,9 +253,10 @@ export class CKFlowViewModel {
this.paymentInfoDebounceTimer = setTimeout(() => {
const paymentInfo = {
cardDetails: paymentInfoVM.cardDetails,
flightTicketInfoId: get(flightTicketStore).id,
method: PaymentMethod.Card,
};
orderId: -1,
productId: get(productStore)?.id,
} as PaymentInfoPayload;
this.syncPaymentInfo(paymentInfo);
}, this.syncInterval);
}
@@ -276,12 +268,12 @@ export class CKFlowViewModel {
);
}
isPersonalInfoValid(personalInfo: CustomerInfo): boolean {
isPersonalInfoValid(personalInfo: CustomerInfoModel): boolean {
const parsed = customerInfoModel.safeParse(personalInfo);
return !parsed.error && !!parsed.data;
}
async syncPersonalInfo(personalInfo: CustomerInfo) {
async syncPersonalInfo(personalInfo: CustomerInfoModel) {
if (!this.flowId || !this.setupDone) {
return;
}
@@ -546,7 +538,7 @@ export class CKFlowViewModel {
const out = await api.ckflow.executePrePaymentStep.mutate({
flowId: this.flowId!,
payload: {
initialUrl: get(flightTicketStore).checkoutUrl,
initialUrl: "",
personalInfo: primaryPassengerInfo,
},
});
@@ -570,9 +562,10 @@ export class CKFlowViewModel {
const paymentInfo = {
cardDetails: paymentInfoVM.cardDetails,
flightTicketInfoId: get(flightTicketStore).id,
method: PaymentMethod.Card,
};
orderId: -1,
productId: get(productStore)?.id,
} as PaymentInfoPayload;
const out = await api.ckflow.executePaymentStep.mutate({
flowId: this.flowId!,

View File

@@ -0,0 +1,47 @@
import { Logger } from "@pkg/logger";
import type { Result } from "@pkg/result";
import type { CreateCustomerInfoPayload, CustomerInfoModel } from "./data";
import type { CustomerInfoRepository } from "./repository";
/**
* CustomerInfoController handles business logic for customer information operations.
* Coordinates between the repository layer and the UI/API layer.
*/
export class CustomerInfoController {
repo: CustomerInfoRepository;
constructor(repo: CustomerInfoRepository) {
this.repo = repo;
}
/**
* Creates a new customer information record
* @param payload - Customer information data to create
* @returns Result containing the new customer info ID or error
*/
async createCustomerInfo(
payload: CreateCustomerInfoPayload,
): Promise<Result<number>> {
Logger.info("Creating customer info", { email: payload.email });
return this.repo.createCustomerInfo(payload);
}
/**
* Retrieves customer information by ID
* @param id - Customer info ID to retrieve
* @returns Result containing customer info model or error
*/
async getCustomerInfo(id: number): Promise<Result<CustomerInfoModel>> {
Logger.info(`Retrieving customer info with ID: ${id}`);
return this.repo.getCustomerInfoById(id);
}
/**
* Retrieves all customer information records
* @returns Result containing array of customer info models or error
*/
async getAllCustomerInfo(): Promise<Result<CustomerInfoModel[]>> {
Logger.info("Retrieving all customer info records");
return this.repo.getAllCustomerInfo();
}
}

View File

@@ -1,40 +0,0 @@
import { protectedProcedure } from "$lib/server/trpc/t";
import { createTRPCRouter } from "$lib/trpc/t";
import { z } from "zod";
import { createCustomerInfoPayload, updateCustomerInfoPayload } from "./data";
import { getCustomerInfoUseCases } from "./usecases";
export const customerInfoRouter = createTRPCRouter({
getAllCustomerInfo: protectedProcedure.query(async ({}) => {
const controller = getCustomerInfoUseCases();
return controller.getAllCustomerInfo();
}),
getCustomerInfoById: protectedProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const controller = getCustomerInfoUseCases();
return controller.getCustomerInfoById(input.id);
}),
createCustomerInfo: protectedProcedure
.input(createCustomerInfoPayload)
.mutation(async ({ input }) => {
const controller = getCustomerInfoUseCases();
return controller.createCustomerInfo(input);
}),
updateCustomerInfo: protectedProcedure
.input(updateCustomerInfoPayload)
.mutation(async ({ input }) => {
const controller = getCustomerInfoUseCases();
return controller.updateCustomerInfo(input);
}),
deleteCustomerInfo: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
const controller = getCustomerInfoUseCases();
return controller.deleteCustomerInfo(input.id);
}),
});

View File

@@ -1 +0,0 @@
export * from "@pkg/logic/domains/customerinfo/usecases";

View File

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

View File

@@ -1,10 +1,281 @@
import type { CustomerInfoModel } from "../data";
import { trpcApiStore } from "$lib/stores/api";
import { toast } from "svelte-sonner";
import { get } from "svelte/store";
import { z } from "zod";
import type {
CreateCustomerInfoPayload,
CustomerInfoModel,
UpdateCustomerInfoPayload,
} from "../data";
import { customerInfoModel } from "../data";
/**
* CustomerInfoViewModel manages single customer information state for checkout.
* Handles validation, API interactions, and form state for one customer per checkout.
*/
export class CustomerInfoViewModel {
customerInfos = $state([] as CustomerInfoModel[]);
// State: current customer info being edited/created (single customer for checkout)
customerInfo = $state<CustomerInfoModel | null>(null);
// State: validation errors for the current customer info
errors = $state<Partial<Record<keyof CustomerInfoModel, string>>>({});
// Loading states
loading = $state(false);
query = $state("");
formLoading = $state(false);
/**
* Initializes customer info with default empty values for checkout
*/
initializeCustomerInfo() {
this.customerInfo = this.getDefaultCustomerInfo() as CustomerInfoModel;
this.errors = {};
}
/**
* Fetches a single customer info record by ID and sets it as current
* @param id - Customer info ID to fetch
* @returns true if successful, false otherwise
*/
async fetchCustomerInfoById(id: number): Promise<boolean> {
const api = get(trpcApiStore);
if (!api) {
toast.error("API client not initialized");
return false;
}
this.loading = true;
try {
const result = await api.customerInfo.getCustomerInfoById.query({
id,
});
if (result.error) {
toast.error(result.error.message, {
description: result.error.userHint,
});
return false;
}
this.customerInfo = result.data || null;
return true;
} catch (e) {
console.error(e);
toast.error("Failed to fetch customer information", {
description: "Please try again later",
});
return false;
} finally {
this.loading = false;
}
}
/**
* Creates a new customer information record
* @param payload - Customer info data to create
* @returns Customer info ID if successful, null otherwise
*/
async createCustomerInfo(
payload: CreateCustomerInfoPayload,
): Promise<number | null> {
const api = get(trpcApiStore);
if (!api) {
toast.error("API client not initialized");
return null;
}
// Validate before submitting
if (!this.validateCustomerInfo(payload)) {
toast.error("Please fix validation errors before submitting");
return null;
}
this.formLoading = true;
try {
const result =
await api.customerInfo.createCustomerInfo.mutate(payload);
if (result.error) {
toast.error(result.error.message, {
description: result.error.userHint,
});
return null;
}
toast.success("Customer information saved successfully");
return result.data || null;
} catch (e) {
console.error(e);
toast.error("Failed to save customer information", {
description: "Please try again later",
});
return null;
} finally {
this.formLoading = false;
}
}
/**
* Updates the current customer information record
* @param payload - Customer info data to update (must include id)
* @returns true if successful, false otherwise
*/
async updateCustomerInfo(
payload: UpdateCustomerInfoPayload,
): Promise<boolean> {
const api = get(trpcApiStore);
if (!api) {
toast.error("API client not initialized");
return false;
}
if (!payload.id) {
toast.error("Customer ID is required for update");
return false;
}
this.formLoading = true;
try {
const result =
await api.customerInfo.updateCustomerInfo.mutate(payload);
if (result.error) {
toast.error(result.error.message, {
description: result.error.userHint,
});
return false;
}
toast.success("Customer information updated successfully");
// Update local state with the updated data
if (this.customerInfo && this.customerInfo.id === payload.id) {
this.customerInfo = { ...this.customerInfo, ...payload };
}
return true;
} catch (e) {
console.error(e);
toast.error("Failed to update customer information", {
description: "Please try again later",
});
return false;
} finally {
this.formLoading = false;
}
}
/**
* Validates customer information data using Zod schema
* @param info - Customer info to validate
* @returns true if valid, false otherwise
*/
validateCustomerInfo(info?: Partial<CustomerInfoModel>): boolean {
try {
customerInfoModel.parse(info || this.customerInfo);
this.errors = {};
return true;
} catch (error) {
if (error instanceof z.ZodError) {
this.errors = error.errors.reduce(
(acc, curr) => {
const path = curr.path[0] as keyof CustomerInfoModel;
acc[path] = curr.message;
return acc;
},
{} as Record<keyof CustomerInfoModel, string>,
);
}
return false;
}
}
/**
* Debounced validation for real-time form feedback
* @param info - Customer info to validate
* @param delay - Debounce delay in milliseconds (default: 500)
*/
private validationTimeout: NodeJS.Timeout | undefined;
debounceValidate(info: Partial<CustomerInfoModel>, delay = 500) {
if (this.validationTimeout) {
clearTimeout(this.validationTimeout);
}
this.validationTimeout = setTimeout(() => {
this.validateCustomerInfo(info);
}, delay);
}
/**
* Checks if the current customer info has validation errors
* @returns true if there are no errors, false otherwise
*/
isValid(): boolean {
return Object.keys(this.errors).length === 0;
}
/**
* Checks if customer info is filled out (has required fields)
* @returns true if customer info exists and has data, false otherwise
*/
isFilled(): boolean {
if (!this.customerInfo) return false;
return (
this.customerInfo.firstName.length > 0 &&
this.customerInfo.lastName.length > 0 &&
this.customerInfo.email.length > 0 &&
this.customerInfo.phoneNumber.length > 0
);
}
/**
* Sets the current customer info (for editing existing records)
* @param customerInfo - Customer info to edit
*/
setCustomerInfo(customerInfo: CustomerInfoModel) {
this.customerInfo = { ...customerInfo };
this.errors = {};
}
/**
* Resets the customer info and errors
*/
resetCustomerInfo() {
this.customerInfo = null;
this.errors = {};
}
/**
* Returns a default/empty customer info object for forms
* @returns Default customer info payload
*/
getDefaultCustomerInfo(): CreateCustomerInfoPayload {
return {
firstName: "",
middleName: "",
lastName: "",
email: "",
phoneCountryCode: "",
phoneNumber: "",
country: "",
state: "",
city: "",
zipCode: "",
address: "",
address2: null,
};
}
/**
* Resets all state (for cleanup or re-initialization)
*/
reset() {
this.customerInfo = null;
this.errors = {};
this.loading = false;
this.formLoading = false;
if (this.validationTimeout) {
clearTimeout(this.validationTimeout);
}
}
}
export const customerInfoVM = new CustomerInfoViewModel();

View File

@@ -4,7 +4,7 @@ import { getError, Logger } from "@pkg/logger";
import { ERROR_CODES, type Result } from "@pkg/result";
import {
passengerInfoModel,
type CustomerInfo,
type CustomerInfoModel,
type PassengerInfo,
} from "./entities";
@@ -15,7 +15,9 @@ export class PassengerInfoRepository {
this.db = db;
}
async createPassengerPii(payload: CustomerInfo): Promise<Result<number>> {
async createPassengerPii(
payload: CustomerInfoModel,
): Promise<Result<number>> {
try {
const out = await this.db
.insert(passengerPII)

View File

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

View File

@@ -6,12 +6,12 @@
import { COUNTRIES_SELECT } from "$lib/core/countries";
import type { SelectOption } from "$lib/core/data.types";
import { capitalize } from "$lib/core/string.utils";
import type { CustomerInfo } from "$lib/domains/ticket/data/entities/create.entities";
import type { CustomerInfoModel } from "$lib/domains/ticket/data/entities/create.entities";
import { Gender } from "$lib/domains/ticket/data/entities/index";
import { PHONE_COUNTRY_CODES } from "@pkg/logic/core/data/phonecc";
import { passengerInfoVM } from "./passenger.info.vm.svelte";
let { info = $bindable(), idx }: { info: CustomerInfo; idx: number } =
let { info = $bindable(), idx }: { info: CustomerInfoModel; idx: number } =
$props();
const genderOpts = [

View File

@@ -1,7 +1,7 @@
import {
customerInfoModel,
type BagSelectionInfo,
type CustomerInfo,
type CustomerInfoModel,
type PassengerInfo,
type SeatSelectionInfo,
} from "$lib/domains/ticket/data/entities/create.entities";
@@ -17,7 +17,9 @@ import { z } from "zod";
export class PassengerInfoViewModel {
passengerInfos = $state<PassengerInfo[]>([]);
piiErrors = $state<Array<Partial<Record<keyof CustomerInfo, string>>>>([]);
piiErrors = $state<Array<Partial<Record<keyof CustomerInfoModel, string>>>>(
[],
);
reset() {
this.passengerInfos = [];
@@ -47,7 +49,7 @@ export class PassengerInfoViewModel {
// zipCode: "123098",
// address: "address",
// address2: "",
// } as CustomerInfo;
// } as CustomerInfoModel;
const _defaultPiiObj = {
firstName: "",
@@ -67,7 +69,7 @@ export class PassengerInfoViewModel {
zipCode: "",
address: "",
address2: "",
} as CustomerInfo;
} as CustomerInfoModel;
const _defaultPriceObj = {
currency: "",
@@ -137,7 +139,7 @@ export class PassengerInfoViewModel {
}
}
validatePII(info: CustomerInfo, idx: number) {
validatePII(info: CustomerInfoModel, idx: number) {
try {
const result = customerInfoModel.parse(info);
this.piiErrors[idx] = {};
@@ -146,11 +148,11 @@ export class PassengerInfoViewModel {
if (error instanceof z.ZodError) {
this.piiErrors[idx] = error.errors.reduce(
(acc, curr) => {
const path = curr.path[0] as keyof CustomerInfo;
const path = curr.path[0] as keyof CustomerInfoModel;
acc[path] = curr.message;
return acc;
},
{} as Record<keyof CustomerInfo, string>,
{} as Record<keyof CustomerInfoModel, string>,
);
}
return null;

View File

@@ -0,0 +1,4 @@
import { writable } from "svelte/store";
import type { ProductModel } from "./data";
export const productStore = writable<ProductModel | null>(null);

View File

@@ -4,10 +4,10 @@
import * as Select from "$lib/components/ui/select";
import { COUNTRIES_SELECT } from "$lib/core/countries";
import { capitalize } from "$lib/core/string.utils";
import type { CustomerInfo } from "$lib/domains/ticket/data/entities/create.entities";
import type { CustomerInfoModel } from "$lib/domains/ticket/data/entities/create.entities";
import { billingDetailsVM } from "./billing.details.vm.svelte";
let { info = $bindable() }: { info: CustomerInfo } = $props();
let { info = $bindable() }: { info: CustomerInfoModel } = $props();
function onSubmit(e: SubmitEvent) {
e.preventDefault();

View File

@@ -1,15 +1,15 @@
import {
customerInfoModel,
type CustomerInfo,
type CustomerInfoModel,
} from "$lib/domains/ticket/data/entities/create.entities";
import { Gender } from "$lib/domains/ticket/data/entities/index";
import { z } from "zod";
export class BillingDetailsViewModel {
// @ts-ignore
billingDetails = $state<CustomerInfo>(undefined);
billingDetails = $state<CustomerInfoModel>(undefined);
piiErrors = $state<Partial<Record<keyof CustomerInfo, string>>>({});
piiErrors = $state<Partial<Record<keyof CustomerInfoModel, string>>>({});
constructor() {
this.reset();
@@ -34,15 +34,15 @@ export class BillingDetailsViewModel {
zipCode: "",
address: "",
address2: "",
} as CustomerInfo;
} as CustomerInfoModel;
this.piiErrors = {};
}
setPII(info: CustomerInfo) {
setPII(info: CustomerInfoModel) {
this.billingDetails = info;
}
validatePII(info: CustomerInfo) {
validatePII(info: CustomerInfoModel) {
try {
const result = customerInfoModel.parse(info);
this.piiErrors = {};
@@ -51,11 +51,11 @@ export class BillingDetailsViewModel {
if (error instanceof z.ZodError) {
this.piiErrors = error.errors.reduce(
(acc, curr) => {
const path = curr.path[0] as keyof CustomerInfo;
const path = curr.path[0] as keyof CustomerInfoModel;
acc[path] = curr.message;
return acc;
},
{} as Record<keyof CustomerInfo, string>,
{} as Record<keyof CustomerInfoModel, string>,
);
}
return null;

View File

@@ -1,6 +1,7 @@
import { authRouter } from "$lib/domains/auth/domain/router";
import { ckflowRouter } from "$lib/domains/ckflow/domain/router";
import { currencyRouter } from "$lib/domains/currency/domain/router";
import { customerInfoRouter } from "$lib/domains/customerinfo/router";
import { orderRouter } from "$lib/domains/order/domain/router";
import { productRouter } from "$lib/domains/product/router";
import { userRouter } from "$lib/domains/user/domain/router";
@@ -13,6 +14,7 @@ export const router = createTRPCRouter({
order: orderRouter,
ckflow: ckflowRouter,
product: productRouter,
customerInfo: customerInfoRouter,
});
export type Router = typeof router;