ov25-ui
Version:
A UI configurator for OV25 product visualization
425 lines (424 loc) • 17.5 kB
TypeScript
import React from 'react';
import { CompatibleModule } from '../utils/configurator-utils.js';
import { CarouselDisplayMode, CarouselLayout, VariantDisplayMode, VariantDisplayStyleOverlay } from '../types/config-enums.js';
import type { BedPartSizeFilterFlags, OnChangePayload, UnifiedPricePayload } from '../types/inject-config.js';
import type { StringReplacementsConfig } from '../types/string-replacements.js';
import { type ConfiguratorIframeScreenRect } from '../utils/configurator-dom-queries.js';
import { type StringInterpolationVars } from '../lib/strings/resolve-string-replacement.js';
export type DrawerSizes = 'closed' | 'small' | 'large';
export type AnimationState = 'unavailable' | 'open' | 'close' | 'loop' | 'stop';
export type TransitionProxyMode = 'opening' | 'closing' | null;
export interface ProductFilters {
[optionName: string]: {
[filterKey: string]: Array<{
value: string;
checked: boolean;
}>;
};
}
export interface Product {
id: string;
name: string;
price: number;
discount: number;
lowestPrice: number;
metadata: any;
}
export interface Selection {
id: string;
name: string;
sku?: string;
thumbnail?: string;
miniThumbnails?: {
small: string;
medium: string;
large: string;
};
price: number;
blurHash: string;
groupId?: string;
filter?: {
[key: string]: string[];
};
swatch?: Swatch;
isVisible?: boolean;
metadata?: {
bedSize?: string;
};
}
export interface Group {
id: string;
name: string;
selections: Selection[];
isVisible?: boolean;
}
export interface Option {
id: string;
name: string;
groups: Group[];
hasNonOption?: boolean;
isVisible?: boolean;
}
export interface ConfiguratorState {
options: Option[];
selectedSelections: Array<{
optionId: string;
groupId: string;
selectionId: string;
}>;
configuratorSettings: {
swatchSettings: SwatchRulesData;
availableProductFilters: {
[key: string]: string[];
};
};
snap2Objects: any[];
}
export interface SizeOption {
id: 'size';
name: 'size';
groups: [
{
id: 'size-group';
selections: Array<{
id: string;
name: string;
price: number;
thumbnail?: string;
}>;
}
];
hasNonOption?: boolean;
}
export interface Discount {
percentage: number;
amount: number;
formattedAmount: string;
}
export interface Swatch {
name: string;
option: string;
manufacturerId: string;
description: string;
sku: string;
thumbnail: {
blurHash: string;
thumbnail: string;
miniThumbnails: {
large: string;
medium: string;
small: string;
};
};
}
export type SwatchRulesData = {
freeSwatchLimit: number;
canExeedFreeLimit: boolean;
pricePerSwatch: number;
minSwatches: number;
maxSwatches: number;
enabled: boolean;
};
interface OV25UIContextType {
shadowDOMs?: {
mobileDrawer?: ShadowRoot;
snap2CheckoutSheet?: ShadowRoot;
configuratorViewControls?: ShadowRoot;
popoverPortal?: ShadowRoot;
modalPortal?: ShadowRoot;
swatchbookPortal?: ShadowRoot;
};
cssString?: string;
products: Product[];
currentProductId?: string;
configuratorState?: ConfiguratorState;
selectedSelections: Array<{
optionId: string;
groupId?: string;
selectionId: string;
}>;
activeOptionId: string | null;
quantity: number;
price: number;
subtotal: number;
formattedPrice: string;
formattedSubtotal: string;
discount: Discount;
galleryIndex: number;
currentSku: any;
range: any;
drawerSize: DrawerSizes;
isVariantsOpen: boolean;
isDrawerOrDialogOpen: boolean;
galleryCarouselFullscreenImage: string | null;
setGalleryCarouselFullscreenImage: React.Dispatch<React.SetStateAction<string | null>>;
/** Last frame from the iframe shown in the gallery slot while the iframe moves to sheet/modal. */
configuratorTransitionProxyBitmap: ImageBitmap | null;
configuratorTransitionProxyMode: TransitionProxyMode;
/** Captured sheet/modal iframe bounds; closing proxy is portaled here and slides/fades with the shell. */
configuratorClosingProxyRect: ConfiguratorIframeScreenRect | null;
useInstantIframeCloseRestore: boolean;
setConfiguratorTransitionProxyBitmap: (bitmap: ImageBitmap | null) => void;
setConfiguratorTransitionProxyMode: (mode: TransitionProxyMode) => void;
setConfiguratorClosingProxyRect: (rect: ConfiguratorIframeScreenRect | null) => void;
setUseInstantIframeCloseRestore: React.Dispatch<React.SetStateAction<boolean>>;
releaseConfiguratorTransitionProxy: () => void;
/** Stacked gallery: next close sync should skip the legacy 500ms wait (instant iframe restore). Set in useIframePositioning; consumed in ProductGallery. */
stackedGalleryCloseSyncImmediateRef: React.MutableRefObject<boolean>;
arPreviewLink: string | null;
error: Error | null;
canAnimate: boolean;
animationState: AnimationState;
iframeRef: React.RefObject<HTMLIFrameElement>;
isMobile: boolean;
deferThreeD: boolean;
/** True when gallery mounts in the hidden preload container (no page slot): modal open should not wait on iframe ImageBitmap. */
configuratorGalleryIsDeferred: boolean;
showOptional: boolean;
hidePricing: boolean;
disableAddToCart: boolean;
hideAr: boolean;
productLink: string | null;
apiKey: string;
configurationUuid: string | null;
stringReplacements?: StringReplacementsConfig;
/** OV25 bed iframe: `bedAllowNone` query segment (omit when all parts may use None). */
bedAllowNoneQueryValue?: string;
/** Dining iframe display option. Omitted unless explicitly false. */
diningShowAttachmentPoints?: boolean;
/** Bed iframe: latest size from `CURRENT_BED_SIZE` postMessage (`null` until first message or non-bed). */
currentBedSize: string | null;
/** When true for a part, selections in that option hide if `metadata.bedSize` ≠ {@link currentBedSize}. */
bedFilterSelectionsByCurrentSize: BedPartSizeFilterFlags;
/** Display symbol for prices; see inject `flags.currencySymbol`. */
currencySymbol: string;
buyNowFunction: (payload?: OnChangePayload) => void;
addToBasketFunction: (payload?: OnChangePayload) => void;
buySwatches: () => void;
images?: string[];
logoURL?: string;
hideLogo: boolean;
isProductGalleryStacked: boolean;
carouselLayout: CarouselLayout;
carouselLayoutMobile: CarouselLayout;
carouselMaxImagesDesktop?: number;
carouselMaxImagesMobile?: number;
showCarousel: boolean;
mobileLogoURL?: string;
uniqueId?: string;
initialiseMenuUsesExternalSelector?: boolean;
currentProduct?: Product;
sizeOption: SizeOption;
activeOption?: Option;
availableProductFilters?: ProductFilters;
optionHasVisibleFilters: (option: {
id: string;
name: string;
}) => boolean;
showFilters?: boolean;
allOptions: (Option | SizeOption)[];
allOptionsWithoutModules: (Option | SizeOption)[];
variantPanelOptions: (Option | SizeOption)[];
galleryIndexToUse: number;
filteredActiveOption?: Option | null;
searchQueries: {
[optionId: string]: string;
};
selectedSwatches: Swatch[];
swatchRulesData: SwatchRulesData;
isSwatchBookOpen: boolean;
swatchBookFlash: 'destructive' | 'cta' | null;
setSwatchBookFlash: React.Dispatch<React.SetStateAction<'destructive' | 'cta' | null>>;
hasSelectionsWithSwatches: boolean;
availableCameras: Array<{
id: string;
displayName: string;
}>;
availableLights: Array<{
id: string;
displayName: string;
}>;
isSnap2Mode: boolean;
/** Latest normalized price payload from CURRENT_PRICE; drives Snap2 checkout sheet line items. */
commercePriceSnapshot: UnifiedPricePayload | null;
isSnap2CheckoutSheetOpen: boolean;
setIsSnap2CheckoutSheetOpen: React.Dispatch<React.SetStateAction<boolean>>;
snap2SaveResponse: {
success: boolean;
shareUrl?: string;
error?: string;
} | null;
isModalOpen: boolean;
controlsHidden: boolean;
hasConfigureButton: boolean;
useInlineVariantControls: boolean;
configuratorDisplayMode: 'inline' | 'sheet' | 'drawer' | 'variants-only-sheet' | 'modal' | 'inline-sheet';
configuratorDisplayModeMobile: 'inline' | 'drawer' | 'modal' | 'variants-only-sheet';
useSimpleVariantsSelector: boolean;
/** Drawer trigger: single Configure button or per-option buttons (ProductOptionsGroup). */
configuratorTriggerStyle: 'single-button' | 'split-buttons';
variantDisplayStyleMobile: VariantDisplayMode;
variantDisplayStyleInline: VariantDisplayStyleOverlay;
variantDisplayStyleInlineMobile: VariantDisplayStyleOverlay;
variantDisplayStyleOverlay: VariantDisplayStyleOverlay;
variantDisplayStyleOverlayMobile: VariantDisplayStyleOverlay;
snap2VariantSheetSide: 'left' | 'right';
snap2ModuleSheetPosition: 'left' | 'right' | 'bottom';
snap2ModulesEmbedInVariantSheet: boolean;
shareDialogTrigger: 'none' | 'save-button' | 'modal-close';
skipNextDrawerCloseRef: React.MutableRefObject<boolean>;
skipNextShareClickRef: React.MutableRefObject<boolean>;
expandToOptionIdOnOpen: string | null;
preloading: boolean;
setPreloading: (preloading: boolean) => void;
resetIframe: () => void;
iframeResetKey: number;
configureHandlerRef: React.MutableRefObject<(() => void) | null>;
compatibleModules: CompatibleModule[] | null;
isModuleSelectionLoading: boolean;
selectedModuleType: 'all' | 'middle' | 'corner' | 'end';
isModulePanelOpen: boolean;
setProducts: React.Dispatch<React.SetStateAction<Product[]>>;
setCurrentProductId: React.Dispatch<React.SetStateAction<string | undefined>>;
setConfiguratorState: React.Dispatch<React.SetStateAction<ConfiguratorState | undefined>>;
setSelectedSelections: React.Dispatch<React.SetStateAction<Array<{
optionId: string;
groupId?: string;
selectionId: string;
}>>>;
setActiveOptionId: React.Dispatch<React.SetStateAction<string | null>>;
setQuantity: React.Dispatch<React.SetStateAction<number>>;
setPrice: React.Dispatch<React.SetStateAction<number>>;
setSubtotal: React.Dispatch<React.SetStateAction<number>>;
setDiscount: React.Dispatch<React.SetStateAction<Discount>>;
setGalleryIndex: React.Dispatch<React.SetStateAction<number>>;
setCurrentSku: React.Dispatch<React.SetStateAction<any>>;
setRange: React.Dispatch<React.SetStateAction<any>>;
setDrawerSize: React.Dispatch<React.SetStateAction<DrawerSizes>>;
setIsVariantsOpen: React.Dispatch<React.SetStateAction<boolean>>;
setIsDrawerOrDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
setArPreviewLink: React.Dispatch<React.SetStateAction<string | null>>;
setError: React.Dispatch<React.SetStateAction<Error | null>>;
setCanAnimate: React.Dispatch<React.SetStateAction<boolean>>;
setAnimationState: React.Dispatch<React.SetStateAction<AnimationState>>;
setAvailableProductFilters: React.Dispatch<React.SetStateAction<ProductFilters>>;
setSearchQuery: (optionId: string, query: string) => void;
setSelectedSwatches: React.Dispatch<React.SetStateAction<Swatch[]>>;
setSwatchRulesData: React.Dispatch<React.SetStateAction<SwatchRulesData>>;
toggleSwatch: (swatch: Swatch) => void;
isSwatchSelected: (swatch: Swatch) => boolean;
setIsSwatchBookOpen: React.Dispatch<React.SetStateAction<boolean>>;
setAvailableCameras: React.Dispatch<React.SetStateAction<Array<{
id: string;
displayName: string;
}>>>;
selectCamera: (cameraId: string) => void;
setAvailableLights: React.Dispatch<React.SetStateAction<Array<{
id: string;
displayName: string;
}>>>;
selectLightGroup: (subGroupId: string) => void;
setSnap2SaveResponse: React.Dispatch<React.SetStateAction<{
success: boolean;
shareUrl?: string;
error?: string;
} | null>>;
setIsModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
setControlsHidden: React.Dispatch<React.SetStateAction<boolean>>;
setHasConfigureButton: React.Dispatch<React.SetStateAction<boolean>>;
setShareDialogTrigger: React.Dispatch<React.SetStateAction<'none' | 'save-button' | 'modal-close'>>;
toggleHideAll: () => void;
setCompatibleModules: React.Dispatch<React.SetStateAction<CompatibleModule[] | null>>;
setIsModuleSelectionLoading: React.Dispatch<React.SetStateAction<boolean>>;
setSelectedModuleType: React.Dispatch<React.SetStateAction<'all' | 'middle' | 'corner' | 'end'>>;
setIsModulePanelOpen: React.Dispatch<React.SetStateAction<boolean>>;
handleSelectionSelect: (selection: Selection, optionId?: string) => void;
handleOptionClick: (optionId: string) => void;
handleNextOption: () => void;
handlePreviousOption: () => void;
getSelectedValue: (option: Option | SizeOption) => string;
toggleAR: () => void;
cleanupConfigurator: () => void;
applySearchAndFilters: (option: Option | SizeOption, optionId: string) => Option | SizeOption;
/** Open the variant configurator (closes swatch book, opens variants drawer). Optionally pass an option name (case insensitive) to open on that option. */
openConfigurator: (optionName?: string) => void;
/** Open configurator or Snap2 modal based on product type. For useSimpleVariantsSelector single button. */
openConfiguratorOrSnap2: () => void;
/** Close the variant configurator drawer. For use by custom buttons. */
closeConfigurator: () => void;
/** Open the swatch book. Exposed for custom buttons. */
openSwatchBook: () => void;
/** Close the swatch book. Exposed for custom buttons. */
closeSwatchBook: () => void;
/** Resolve a configured replacement by key. */
getString: (key: string, vars?: StringInterpolationVars, fallback?: string) => string;
}
export declare const createUniqueContext: (uniqueId: string) => React.Context<OV25UIContextType | undefined>;
export declare const OV25UIProvider: React.FC<{
children: React.ReactNode;
productLink: string | null;
apiKey: string;
configurationUuid: string;
stringReplacements?: StringReplacementsConfig;
bedAllowNoneQueryValue?: string;
diningShowAttachmentPoints?: boolean;
bedFilterSelectionsByCurrentSize?: BedPartSizeFilterFlags;
buyNowFunction: (payload?: OnChangePayload) => void;
addToBasketFunction: (payload?: OnChangePayload) => void;
buySwatchesFunction: (swatches: Swatch[], swatchRulesData: SwatchRulesData) => void;
onChange?: (payload: OnChangePayload) => void;
images?: string[];
deferThreeD?: boolean;
showOptional?: boolean;
hidePricing?: boolean;
disableAddToCart?: boolean;
hideAr?: boolean;
forceMobile?: boolean;
logoURL?: string;
hideLogo?: boolean;
isProductGalleryStacked: boolean;
carouselDisplayMode?: CarouselDisplayMode;
carouselDisplayModeMobile?: CarouselDisplayMode;
carouselMaxImagesDesktop?: number;
carouselMaxImagesMobile?: number;
/** @deprecated Use carouselDisplayMode */
carouselLayout?: CarouselDisplayMode;
showCarousel?: boolean;
hasConfigureButton: boolean;
mobileLogoURL?: string;
uniqueId?: string;
useInlineVariantControls?: boolean;
useInlineVariantControlsMobile?: boolean;
configuratorDisplayMode?: 'inline' | 'sheet' | 'drawer' | 'modal' | 'variants-only-sheet' | 'inline-sheet';
configuratorDisplayModeMobile?: 'inline' | 'drawer' | 'modal' | 'variants-only-sheet';
useSimpleVariantsSelector?: boolean;
configuratorTriggerStyle?: 'single-button' | 'split-buttons';
configuratorTriggerStyleMobile?: 'single-button' | 'split-buttons';
variantDisplayStyle?: VariantDisplayMode;
variantDisplayStyleMobile?: VariantDisplayMode;
variantDisplayStyleInline?: VariantDisplayStyleOverlay;
variantDisplayStyleInlineMobile?: VariantDisplayStyleOverlay;
variantDisplayStyleOverlay?: VariantDisplayStyleOverlay;
variantDisplayStyleOverlayMobile?: VariantDisplayStyleOverlay;
/** Normalized option keys (id or name, lowercase) to hide from variant UI; defaults stay from iframe. */
hideVariantOptions?: string[];
shadowDOMs?: {
mobileDrawer?: ShadowRoot;
snap2CheckoutSheet?: ShadowRoot;
configuratorViewControls?: ShadowRoot;
popoverPortal?: ShadowRoot;
modalPortal?: ShadowRoot;
swatchbookPortal?: ShadowRoot;
};
cssString?: string;
configuratorGalleryIsDeferred?: boolean;
/** Display symbol for iframe price strings; default £. */
currencySymbol?: string;
snap2VariantSheetSideDesktop?: 'left' | 'right';
snap2VariantSheetSideMobile?: 'left' | 'right';
snap2ModulePanelPositionDesktop?: 'left' | 'right' | 'bottom';
snap2ModulePanelPositionMobile?: 'left' | 'right' | 'bottom';
initialiseMenuUsesExternalSelector?: boolean;
}>;
export declare const useOV25UI: () => OV25UIContextType;
export {};