Enhance catalog caching and pricing utilities

- Introduced a new interface, LegacyCatalogCachePayload, to improve cache handling in CatalogCacheService, allowing for better normalization of cached values.
- Updated the getDisplayPrice function to utilize a centralized price formatting utility, getCatalogProductPriceDisplay, for consistent price rendering across the application.
- Refactored order preparation logic in useCheckout to leverage a new domain helper, prepareOrderFromCart, streamlining SKU extraction and payload formatting.
- Added CatalogPriceInfo interface to standardize pricing display information across the frontend and backend.
This commit is contained in:
barsa 2025-11-21 15:59:14 +09:00
parent 833ff24645
commit c497eae763
8 changed files with 140 additions and 58 deletions

View File

@ -17,6 +17,12 @@ export interface CatalogCacheOptions<T> {
) => CacheDependencies | Promise<CacheDependencies | undefined> | undefined;
}
interface LegacyCatalogCachePayload<T> {
value: T;
__catalogCache?: boolean;
dependencies?: CacheDependencies;
}
/**
* Catalog cache service
*
@ -188,13 +194,12 @@ export class CatalogCacheService {
const allowNull = options?.allowNull ?? false;
// Check Redis cache first
const cached = await this.cache.get<T>(key);
const cached = await this.cache.get<T | LegacyCatalogCachePayload<T>>(key);
if (cached !== null) {
if (allowNull || cached !== null) {
this.metrics[bucket].hits++;
return cached;
}
const normalized = await this.normalizeCachedValue(key, cached, ttlSeconds);
this.metrics[bucket].hits++;
return normalized;
}
// Check for in-flight request (prevents thundering herd)
@ -243,6 +248,43 @@ export class CatalogCacheService {
return fetchPromise;
}
private async normalizeCachedValue<T>(
key: string,
cached: T | LegacyCatalogCachePayload<T>,
ttlSeconds: number | null
): Promise<T> {
if (this.isLegacyCatalogCachePayload<T>(cached)) {
const ttlArg = ttlSeconds === null ? undefined : ttlSeconds;
const normalizedValue = cached.value;
if (ttlArg === undefined) {
await this.cache.set(key, normalizedValue);
} else {
await this.cache.set(key, normalizedValue, ttlArg);
}
if (cached.dependencies) {
await this.storeDependencies(key, cached.dependencies);
await this.linkDependencies(key, cached.dependencies);
}
return normalizedValue;
}
return cached;
}
private isLegacyCatalogCachePayload<T>(
payload: unknown
): payload is LegacyCatalogCachePayload<T> {
if (!payload || typeof payload !== "object") {
return false;
}
const record = payload as Record<string, unknown>;
return record.__catalogCache === true && Object.prototype.hasOwnProperty.call(record, "value");
}
/**
* Invalidate catalog entries by product IDs
* Returns true if any entries were invalidated, false if no matches found

View File

@ -21,7 +21,7 @@ export type OrderItem = CatalogProductBase & {
itemClass?: string;
};
export interface OrderConfiguration {
export interface OrderSummaryConfiguration {
label: string;
value: string;
important?: boolean;
@ -45,7 +45,7 @@ export interface EnhancedOrderSummaryProps {
planDescription?: string;
// Configuration details
configurations?: OrderConfiguration[];
configurations?: OrderSummaryConfiguration[];
// Additional information
infoLines?: string[];

View File

@ -35,7 +35,7 @@ export type {
export type {
EnhancedOrderSummaryProps,
OrderItem,
OrderConfiguration,
OrderSummaryConfiguration,
OrderTotals,
} from "./base/EnhancedOrderSummary";
export type { ConfigurationStepProps, StepValidation } from "./base/ConfigurationStep";

View File

@ -3,35 +3,15 @@
* These are UI-specific formatting helpers, not business logic
*/
import type { CatalogProductBase } from "@customer-portal/domain/catalog";
import {
getCatalogProductPriceDisplay,
type CatalogProductBase,
type CatalogPriceInfo,
} from "@customer-portal/domain/catalog";
export interface PriceInfo {
display: string;
monthly: number | null;
oneTime: number | null;
currency: string;
}
// Re-export domain type for compatibility
export type PriceInfo = CatalogPriceInfo;
export function getDisplayPrice(item: CatalogProductBase): PriceInfo | null {
const monthlyPrice = item.monthlyPrice ?? null;
const oneTimePrice = item.oneTimePrice ?? null;
const currency = "JPY";
if (monthlyPrice === null && oneTimePrice === null) {
return null;
}
let display = "";
if (monthlyPrice !== null && monthlyPrice > 0) {
display = `¥${monthlyPrice.toLocaleString()}/month`;
} else if (oneTimePrice !== null && oneTimePrice > 0) {
display = `¥${oneTimePrice.toLocaleString()} (one-time)`;
}
return {
display,
monthly: monthlyPrice,
oneTime: oneTimePrice,
currency,
};
return getCatalogProductPriceDisplay(item);
}

View File

@ -18,6 +18,7 @@ import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscr
import {
ORDER_TYPE,
orderWithSkuValidationSchema,
prepareOrderFromCart,
type CheckoutCart,
} from "@customer-portal/domain/orders";
import { CheckoutParamsService } from "@/features/checkout/services/checkout-params.service";
@ -150,31 +151,15 @@ export function useCheckout() {
// Debug logging to check cart contents
console.log("[DEBUG] Cart data:", cart);
console.log("[DEBUG] Cart items:", cart.items);
const uniqueSkus = Array.from(
new Set(
cart.items
.map(item => item.sku)
.filter((sku): sku is string => typeof sku === "string" && sku.trim().length > 0)
)
);
console.log("[DEBUG] Extracted SKUs from cart:", uniqueSkus);
if (uniqueSkus.length === 0) {
throw new Error("No products selected for order. Please go back and select products.");
}
// Validate cart before submission
await checkoutService.validateCart(cart);
const orderData = {
orderType,
skus: uniqueSkus,
...(Object.keys(cart.configuration).length > 0
? { configurations: cart.configuration }
: {}),
};
// Use domain helper to prepare order data
// This encapsulates SKU extraction and payload formatting
const orderData = prepareOrderFromCart(cart, orderType);
console.log("[DEBUG] Extracted SKUs from cart:", orderData.skus);
const currentUserId = useAuthStore.getState().user?.id;
if (currentUserId) {

View File

@ -50,6 +50,17 @@ export interface PricingTier {
originalPrice?: number;
}
/**
* Standardized pricing display info
* Used for consistent price rendering across Frontend and BFF
*/
export interface CatalogPriceInfo {
display: string;
monthly: number | null;
oneTime: number | null;
currency: string;
}
/**
* Catalog filtering options
*/

View File

@ -8,7 +8,9 @@ import {
type SimCatalogCollection,
type VpnCatalogCollection,
type InternetPlanTemplate,
type CatalogProductBase,
} from "./schema";
import type { CatalogPriceInfo } from "./contract";
/**
* Empty catalog defaults shared by portal and BFF.
@ -156,3 +158,30 @@ export function enrichInternetPlanMetadata(plan: InternetPlanCatalogItem): Inter
export const internetPlanCollectionSchema = internetPlanCatalogItemSchema.array();
/**
* Calculates display price information for a catalog item
* Centralized logic for price formatting
*/
export function getCatalogProductPriceDisplay(item: CatalogProductBase): CatalogPriceInfo | null {
const monthlyPrice = item.monthlyPrice ?? null;
const oneTimePrice = item.oneTimePrice ?? null;
const currency = "JPY";
if (monthlyPrice === null && oneTimePrice === null) {
return null;
}
let display = "";
if (monthlyPrice !== null && monthlyPrice > 0) {
display = `¥${monthlyPrice.toLocaleString()}/month`;
} else if (oneTimePrice !== null && oneTimePrice > 0) {
display = `¥${oneTimePrice.toLocaleString()} (one-time)`;
}
return {
display,
monthly: monthlyPrice,
oneTime: oneTimePrice,
currency,
};
}

View File

@ -5,7 +5,7 @@ import {
type CreateOrderRequest,
type OrderSelections,
} from "./schema";
import { ORDER_TYPE } from "./contract";
import { ORDER_TYPE, type CheckoutCart, type OrderTypeValue } from "./contract";
export function buildOrderConfigurations(selections: OrderSelections): OrderConfigurations {
const normalizedSelections = orderSelectionsSchema.parse(selections);
@ -41,3 +41,38 @@ export function createOrderRequest(payload: {
};
}
/**
* Transform CheckoutCart into CreateOrderRequest
* Handles SKU extraction, validation, and payload formatting
*
* @throws Error if no products are selected
*/
export function prepareOrderFromCart(
cart: CheckoutCart,
orderType: OrderTypeValue
): CreateOrderRequest {
const uniqueSkus = Array.from(
new Set(
cart.items
.map(item => item.sku)
.filter((sku): sku is string => typeof sku === "string" && sku.trim().length > 0)
)
);
if (uniqueSkus.length === 0) {
throw new Error("No products selected for order. Please go back and select products.");
}
// Note: Zod validation of the final structure should happen at the boundary or via schema.parse
// This function focuses on the structural transformation logic.
const orderData: CreateOrderRequest = {
orderType,
skus: uniqueSkus,
...(Object.keys(cart.configuration).length > 0
? { configurations: cart.configuration }
: {}),
};
return orderData;
}