ov25-ui
Version:
A UI configurator for OV25 product visualization
398 lines (397 loc) • 17.2 kB
TypeScript
import type { Swatch, SwatchRulesData } from '../contexts/ov25-ui-context.js';
import type { BedAllowNonePartsInput } from '../lib/config/bed-embed-query.js';
import type { StringReplacementsConfig } from './string-replacements.js';
export type { StringReplacementRuleTrigger, StringReplacementDefinitionKey, StringReplacementRule, StringReplacementsConfig, StringReplacementDefinition, StringInterpolationValueDefinition, StringReplacements, } from './string-replacements.js';
export { optionNameReplacement } from './string-replacements.js';
export type StringOrFunction = string | (() => string);
export type ElementConfig = {
/** CSS selector (id, class, or any valid selector). */
selector?: string;
/** @deprecated Use `selector` instead. Supported for backward compatibility. */
id?: string;
replace?: boolean;
};
export type ElementSelector = string | ElementConfig;
/** Responsive value: desktop and mobile breakpoints. */
export type ResponsiveValue<T> = {
desktop: T;
mobile?: T;
};
/** Carousel display: 'none' hides it, 'carousel' | 'stacked' show it. */
export type CarouselDisplayMode = 'none' | 'carousel' | 'stacked';
export type CarouselConfig = ResponsiveValue<CarouselDisplayMode> & {
/** Max images to show in carousel; excess are cut. Responsive: { desktop, mobile }. */
maxImages?: number | ResponsiveValue<number>;
};
export type ConfiguratorDisplayMode = 'inline' | 'sheet' | 'drawer' | 'modal' | 'inline-sheet';
export type ConfiguratorConfig = {
displayMode: ResponsiveValue<ConfiguratorDisplayMode>;
triggerStyle?: ResponsiveValue<'single-button' | 'split-buttons'>;
variants?: VariantsConfig;
modules?: ModulesConfig;
};
/** Variant UI style: wizard | list | tabs | accordion | tree. Accordion not available on mobile. */
export type VariantDisplayMode = 'wizard' | 'list' | 'tabs' | 'accordion' | 'tree';
/** Snap2 / inline-sheet: which horizontal edge the variant settings sheet attaches to. */
export type Snap2VariantSheetSide = 'LEFT' | 'RIGHT';
/**
* Snap2 (sheet / drawer / modal): where the compatible-modules UI is shown when not using {@link ConfiguratorDisplayMode} `inline` or `inline-sheet` (those modes always keep modules in the variants column; this value is ignored there).
* If this matches {@link Snap2VariantSheetSide} (both LEFT or both RIGHT), modules render inside the variant sheet; otherwise a separate module rail/sheet is used. `BOTTOM` uses the bottom dock.
*/
export type Snap2ModulePanelPosition = 'LEFT' | 'RIGHT' | 'BOTTOM';
export type ModulesConfig = {
position?: ResponsiveValue<Snap2ModulePanelPosition>;
};
export type VariantsConfig = {
displayMode: ResponsiveValue<VariantDisplayMode>;
position?: ResponsiveValue<Snap2VariantSheetSide>;
useSimpleVariantsSelector?: boolean;
/**
* Option ids or display names (case-insensitive) to omit from variant UI (list, wizard, tabs, etc.).
* Iframe defaults and CURRENT_SKU state stay applied; users cannot change these options in the shell.
*/
hideOptions?: string[];
};
export type SelectorsConfig = {
/** Root target for full-page display modes that own their own shell. */
root?: ElementSelector;
gallery?: ElementSelector;
price?: ElementSelector;
name?: ElementSelector;
variants?: ElementSelector;
swatches?: ElementSelector;
configureButton?: ElementSelector;
initialiseMenu?: ElementSelector;
};
/**
* Maps option names to their selected SKU values.
* Keys: option names (e.g. 'Color', 'Size'); reserved keys: 'Range', 'Product', 'Ranges', 'Products'.
* @example { Color: 'RED-001', Size: 'M-001', Range: 'RANGE-123' }
*/
export interface OptionSkuMap {
[optionName: string]: string;
}
/** One billable/configured product line after normalization (SKU side). */
export interface CommerceLineItemSku {
/** Catalogue product id or other stable line id from the iframe. */
id: string;
skuString: string;
skuMap: Record<string, string>;
quantity: number;
}
/** One configured option row inside a price line (single-product breakdown or Snap2 selections). */
export interface CommerceLineItemSelection {
category?: string;
name: string;
sku?: string;
price: number;
formattedPrice: string;
thumbnail?: string;
}
/** One billable product line after normalization (price side). */
export interface CommerceLineItemPrice {
/** Same id semantics as {@link CommerceLineItemSku.id} when both messages come from Snap2 with `productId`. */
id: string;
name: string;
quantity: number;
price: number;
formattedPrice: string;
subtotal: number;
formattedSubtotal: string;
discountedAmount: number;
formattedDiscountAmount: string;
discountPercentage: number;
selections: CommerceLineItemSelection[];
/** Snap2 3D model id when present on the iframe breakdown. */
modelId?: string;
}
/** One selection row inside {@link ProductPriceBreakdown} (Snap2; aligns with OV25 `useSnap2TotalPrice`). */
export interface SelectionPriceBreakdown {
name: string;
price: number;
formattedPrice: string;
thumbnail?: string;
}
/**
* Per-product Snap2 totals (aligns with OV25 `ProductPriceBreakdown` in `useSnap2TotalPrice`).
* Optional on {@link UnifiedPricePayload} for host consumers (cart, invoices).
*/
export interface ProductPriceBreakdown {
productId: string;
formattedSubtotal: string;
subtotal: number;
discountedAmount: number;
formattedDiscountAmount: string;
discountPercentage: number;
name: string;
quantity: number;
price: number;
formattedPrice: string;
selections: SelectionPriceBreakdown[];
modelId: string;
image?: string;
}
/** Single-product iframe: legacy top-level sku fields plus `lines` (length 1). */
export interface UnifiedSkuPayloadSingle {
mode: 'single';
lines: CommerceLineItemSku[];
skuString: string;
skuMap?: OptionSkuMap;
}
/** Multi-product (Snap2): only `lines`; no top-level `skuString`. */
export interface UnifiedSkuPayloadMulti {
mode: 'multi';
lines: CommerceLineItemSku[];
}
export type UnifiedSkuPayload = UnifiedSkuPayloadSingle | UnifiedSkuPayloadMulti;
export interface UnifiedPricePayload {
mode: 'single' | 'multi';
totalPrice: number;
subtotal: number;
formattedPrice: string;
formattedSubtotal: string;
discount: {
amount: number;
formattedAmount: string;
percentage: number;
};
lines: CommerceLineItemPrice[];
/** Raw single-product `priceBreakdown` from the iframe when present. */
priceBreakdown?: unknown[];
/** Snap2 `productBreakdowns` from the iframe when present (same shape as OV25 `ProductPriceBreakdown[]`). */
productBreakdowns?: ProductPriceBreakdown[];
}
/**
* Callback payload: normalized SKU and price. Each half is null until that message was received at least once.
*/
export interface UnifiedOnChangePayload {
skus: UnifiedSkuPayload | null;
price: UnifiedPricePayload | null;
}
export type OnChangePayload = UnifiedOnChangePayload;
/**
* Normalized SKU from CURRENT_SKU. Use `mode` / `lines` for multi-product; in `single` mode top-level `skuString` remains for legacy code.
* @deprecated Prefer the name {@link UnifiedSkuPayload} in new integrations.
*/
export type OnChangeSkuPayload = UnifiedSkuPayload;
/**
* Normalized price from CURRENT_PRICE (includes `mode`, `lines`, optional raw breakdown passthrough).
* @deprecated Prefer the name {@link UnifiedPricePayload} in new integrations.
*/
export type OnChangePricePayload = UnifiedPricePayload;
/** Callbacks for inject configurator. */
export interface CallbacksConfig {
/** Add configured product to basket/checkout. Receives current { skus, price } so you don't need to store them in state. */
addToBasket: (payload?: OnChangePayload) => void;
/** Buy now / checkout immediately. Receives current { skus, price } so you don't need to store them in state. */
buyNow: (payload?: OnChangePayload) => void;
/** Purchase selected swatches */
buySwatches: (swatches: Swatch[], swatchRulesData: SwatchRulesData) => void;
/**
* Called when price or SKU changes from configurator messages.
* Receives normalized `{ skus, price }` (see {@link UnifiedOnChangePayload}); each is `null` until that message type has been received at least once.
*/
onChange?: (payload: OnChangePayload) => void;
}
export type BrandingConfig = {
logoURL?: string;
mobileLogoURL?: string;
cssString?: string;
hideLogo?: boolean;
};
export type FlagsConfig = {
hidePricing?: boolean;
disableAddToCart?: boolean;
hideAr?: boolean;
deferThreeD?: boolean;
showOptional?: boolean;
/** Force mobile layout for testing (e.g. in device frame). */
forceMobile?: boolean;
/** Auto-open configurator modal on load. Only applies when not using inline display mode. Default false. */
autoOpen?: boolean;
/**
* Display symbol for formatted prices from the iframe (OV25 emits GBP/`£`). Replaces `£` in `CURRENT_PRICE`
* strings after normalization; not FX conversion. Default `£`.
*/
currencySymbol?: string;
};
/** Per bed line: when true, variant UI hides selections whose `metadata.bedSize` ≠ iframe current size. */
export type BedPartSizeFilterFlags = {
headboard: boolean;
base: boolean;
mattress: boolean;
};
/** Bed iframe (OV25): optional `bedAllowNone` query param on the configurator URL. */
export type BedEmbedConfig = {
allowNone?: BedAllowNonePartsInput;
filterSelectionsByCurrentSize?: BedPartSizeFilterFlags;
};
export type DiningDisplayOptions = {
/** Show in-scene dining attachment point buttons. Default true. */
showAttachmentPoints?: boolean;
};
export type DiningStyleImagesConfig = {
/** Image for the fixed/full-range style choice. */
fullRange?: string;
/** Image for the mix-and-match style choice. */
mixAndMatch?: string;
};
export type DiningEmbedConfig = {
displayMode?: ResponsiveValue<'split' | 'full-page'>;
displayOptions?: DiningDisplayOptions;
/** Optional hero images for the initial full-page style-choice screen. */
styleImages?: DiningStyleImagesConfig;
};
/** Primary inject config: grouped structure. */
export interface InjectConfiguratorOptions {
apiKey: StringOrFunction;
productLink: StringOrFunction;
configurationUuid?: StringOrFunction;
images?: string[];
uniqueId?: string;
selectors: SelectorsConfig;
carousel?: CarouselConfig;
configurator?: ConfiguratorConfig;
/** Callbacks for add to basket, buy now, buy swatches, and onChange (price/SKU updates) */
callbacks: CallbacksConfig;
branding?: BrandingConfig;
flags?: FlagsConfig;
bed?: BedEmbedConfig;
stringReplacements?: StringReplacementsConfig;
dining?: DiningEmbedConfig;
}
/** Legacy flat config. Supported for backward compatibility. */
export interface LegacyInjectConfiguratorOptions {
apiKey: StringOrFunction;
productLink: StringOrFunction;
configurationUuid?: StringOrFunction;
images?: string[];
uniqueId?: string;
stringReplacements?: StringReplacementsConfig;
gallerySelector?: ElementSelector;
rootSelector?: ElementSelector;
rootId?: ElementSelector;
priceSelector?: ElementSelector;
nameSelector?: ElementSelector;
variantsSelector?: ElementSelector;
swatchesSelector?: ElementSelector;
configureButtonSelector?: ElementSelector;
initialiseMenuSelector?: ElementSelector;
carouselDisplayMode?: CarouselDisplayMode;
carouselDisplayModeMobile?: CarouselDisplayMode;
configuratorDisplayMode?: 'inline' | 'sheet' | 'modal' | 'inline-sheet';
configuratorDisplayModeMobile?: 'inline' | 'drawer' | 'modal';
configuratorTriggerStyle?: 'single-button' | 'split-buttons';
configuratorTriggerStyleMobile?: 'single-button' | 'split-buttons';
variantDisplayMode?: VariantDisplayMode;
variantDisplayModeMobile?: VariantDisplayMode;
useSimpleVariantsSelector?: boolean;
addToBasketFunction: (payload?: OnChangePayload) => void;
buyNowFunction: (payload?: OnChangePayload) => void;
buySwatchesFunction: (swatches: Swatch[], swatchRulesData: SwatchRulesData) => void;
onChangeFunction?: (payload: OnChangePayload) => void;
logoURL?: string;
mobileLogoURL?: string;
cssString?: string;
hideLogo?: boolean;
hidePricing?: boolean;
disableAddToCart?: boolean;
hideAr?: boolean;
deferThreeD?: boolean;
showOptional?: boolean;
forceMobile?: boolean;
autoOpen?: boolean;
/** @see {@link FlagsConfig.currencySymbol} */
currencySymbol?: string;
/** @see {@link BedEmbedConfig} */
bedAllowNone?: BedAllowNonePartsInput;
/** @see {@link BedEmbedConfig.filterSelectionsByCurrentSize} */
bedFilterSelectionsByCurrentSize?: BedPartSizeFilterFlags;
/** @see {@link DiningDisplayOptions.showAttachmentPoints} */
showDiningAttachmentPoints?: boolean;
/** @see {@link DiningEmbedConfig.displayMode} */
diningDisplayMode?: 'split' | 'full-page';
/** @see {@link DiningEmbedConfig.displayMode} */
diningDisplayModeMobile?: 'split' | 'full-page';
/** @see {@link DiningEmbedConfig.styleImages} */
diningFullRangeImageURL?: string;
/** @see {@link DiningEmbedConfig.styleImages} */
diningMixAndMatchImageURL?: string;
galleryId?: ElementSelector;
priceId?: ElementSelector;
nameId?: ElementSelector;
variantsId?: ElementSelector;
swatchesId?: ElementSelector;
configureButtonId?: ElementSelector;
initialiseMenuId?: ElementSelector;
variantDisplayStyle?: VariantDisplayMode;
variantDisplayStyleMobile?: VariantDisplayMode;
useInlineVariantControls?: boolean;
hideOptions?: string[];
}
export type InjectConfiguratorInput = InjectConfiguratorOptions | LegacyInjectConfiguratorOptions;
/** Internal flattened config used by inject logic. */
export interface NormalizedInjectConfig {
apiKey: StringOrFunction;
productLink: StringOrFunction;
configurationUuid?: StringOrFunction;
images?: string[];
uniqueId?: string;
stringReplacements?: StringReplacementsConfig;
gallerySelector?: ElementSelector;
rootSelector?: ElementSelector;
priceSelector?: ElementSelector;
nameSelector?: ElementSelector;
variantsSelector?: ElementSelector;
swatchesSelector?: ElementSelector;
configureButtonSelector?: ElementSelector;
initialiseMenuSelector?: ElementSelector;
carouselDisplayMode: CarouselDisplayMode;
carouselDisplayModeMobile: CarouselDisplayMode;
carouselMaxImagesDesktop?: number;
carouselMaxImagesMobile?: number;
configuratorDisplayMode: 'inline' | 'sheet' | 'modal' | 'inline-sheet';
configuratorDisplayModeMobile: 'inline' | 'drawer' | 'modal';
configuratorTriggerStyle: 'single-button' | 'split-buttons';
configuratorTriggerStyleMobile: 'single-button' | 'split-buttons';
variantDisplayMode: VariantDisplayMode;
variantDisplayModeMobile: VariantDisplayMode;
useSimpleVariantsSelector: boolean;
/** Lowercase trimmed option ids/names to hide from variant selectors (see {@link VariantsConfig.hideOptions}). */
hideVariantOptions: string[];
addToBasketFunction: (payload?: OnChangePayload) => void;
buyNowFunction: (payload?: OnChangePayload) => void;
buySwatchesFunction: (swatches: Swatch[], swatchRulesData: SwatchRulesData) => void;
onChangeFunction?: (payload: OnChangePayload) => void;
logoURL?: string;
mobileLogoURL?: string;
cssString?: string;
hideLogo: boolean;
hidePricing?: boolean;
disableAddToCart?: boolean;
hideAr?: boolean;
deferThreeD?: boolean;
showOptional?: boolean;
forceMobile?: boolean;
autoOpen?: boolean;
/** Serialized `bedAllowNone` query value; omit when unset (OV25 default: all parts may use None). */
bedAllowNoneQueryValue?: string;
/** When false, adds `showAttachmentPoints=false` to dining iframe URLs. */
diningShowAttachmentPoints?: boolean;
/** Dining UI shell display mode. */
diningDisplayModeDesktop: 'split' | 'full-page';
diningDisplayModeMobile: 'split' | 'full-page';
/** Dining full-page style choice hero images. */
diningStyleImages?: DiningStyleImagesConfig;
/** Bed variant UI: hide non-matching `metadata.bedSize` per line when enabled. */
bedFilterSelectionsByCurrentSize: BedPartSizeFilterFlags;
/** Display symbol for iframe price strings; see {@link FlagsConfig.currencySymbol}. */
currencySymbol: string;
/** Snap2 variant rail (normalized lowercase for UI). Defaults: right (desktop/mobile). */
snap2VariantSheetSideDesktop: 'left' | 'right';
snap2VariantSheetSideMobile: 'left' | 'right';
/** Snap2 module picker placement. Default bottom (desktop/mobile). */
snap2ModulePanelPositionDesktop: 'left' | 'right' | 'bottom';
snap2ModulePanelPositionMobile: 'left' | 'right' | 'bottom';
}
export declare function normalizeInjectConfig(opts: InjectConfiguratorInput): NormalizedInjectConfig;