a.... LOT of Refactoring ~ 30% done???
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
50
apps/frontend/src/lib/domains/checkout/utils.ts
Normal file
50
apps/frontend/src/lib/domains/checkout/utils.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}),
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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!,
|
||||
|
||||
47
apps/frontend/src/lib/domains/customerinfo/controller.ts
Normal file
47
apps/frontend/src/lib/domains/customerinfo/controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}),
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export * from "@pkg/logic/domains/customerinfo/usecases";
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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;
|
||||
|
||||
4
apps/frontend/src/lib/domains/product/store.ts
Normal file
4
apps/frontend/src/lib/domains/product/store.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { writable } from "svelte/store";
|
||||
import type { ProductModel } from "./data";
|
||||
|
||||
export const productStore = writable<ProductModel | null>(null);
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user