diff --git a/libs/domain/product/attributes/src/attributes.component.ts b/libs/domain/product/attributes/src/attributes.component.ts index 5fbf7c01..a438b661 100644 --- a/libs/domain/product/attributes/src/attributes.component.ts +++ b/libs/domain/product/attributes/src/attributes.component.ts @@ -6,7 +6,10 @@ import { ProductAttributesOptions } from './attributes.model'; import { productAttributeStyles } from './attributes.styles'; @ssrShim('style') -@defaultOptions({ columnCount: '2' }) +@defaultOptions({ + columnCount: '2', + ...(featureVersion >= '1.5' ? { highlightVariantAttribute: true } : {}), +}) @hydrate({ context: featureVersion >= '1.4' ? PRODUCT : ProductContext.SKU }) export class ProductAttributesComponent extends ProductMixin( ContentMixin(LitElement) @@ -23,13 +26,30 @@ export class ProductAttributesComponent extends ProductMixin( ${Object.keys(names).map( (key) => html`
${this.getName(names, key)}
-
${values[key]}
+
${values[key]}
` )} `; } + /** + * Highlighted attributes will clarify the uniqueness of the value among + * the variants. + * + * Indicates whether the attribute value should be highlighted, based on + * a global component configuration and if the attribute is part of the + * variant definition. + * + * @since 1.5 + */ + protected isHighlighted(key: string): boolean { + const { variantDefinition } = this.$product() ?? {}; + return ( + !!this.$options().highlightVariantAttribute && !!variantDefinition?.[key] + ); + } + protected getName( names: Record, key: string diff --git a/libs/domain/product/attributes/src/attributes.model.ts b/libs/domain/product/attributes/src/attributes.model.ts index 9e5a93b0..22bee5c8 100644 --- a/libs/domain/product/attributes/src/attributes.model.ts +++ b/libs/domain/product/attributes/src/attributes.model.ts @@ -1,3 +1,11 @@ export interface ProductAttributesOptions { columnCount?: string; + /** + * Highlighted variant attributes will clarify the uniqueness of the value among + * the variants. If the attribute is unqieu to the variant, a highlight attribute + * is added, so that the stylesheet can mark the attribute value. + * + * @since 1.5 + */ + highlightVariantAttribute?: boolean; } diff --git a/libs/domain/product/attributes/src/attributes.styles.ts b/libs/domain/product/attributes/src/attributes.styles.ts index ef97dbf7..29d1029d 100644 --- a/libs/domain/product/attributes/src/attributes.styles.ts +++ b/libs/domain/product/attributes/src/attributes.styles.ts @@ -16,4 +16,13 @@ export const productAttributeStyles = css` margin-block: 10px 20px; color: var(--oryx-color-neutral-9); } + + dd[highlight] { + background: var(--oryx-color-primary-4); + color: initial; + border-radius: 8px; + padding: 1px 6px; + margin-inline-start: -6px; + width: fit-content; + } `; diff --git a/libs/domain/product/src/components.ts b/libs/domain/product/src/components.ts index c032adc7..211f426b 100644 --- a/libs/domain/product/src/components.ts +++ b/libs/domain/product/src/components.ts @@ -14,3 +14,4 @@ export * from '../media/src/media.def'; export * from '../price/src/price.def'; export * from '../relations/relations.def'; export * from '../title/src/title.def'; +export * from '../variant-selector/variant-selector.def'; diff --git a/libs/domain/product/src/mocks/src/mock-product.service.ts b/libs/domain/product/src/mocks/src/mock-product.service.ts index f8612230..e44c0e54 100644 --- a/libs/domain/product/src/mocks/src/mock-product.service.ts +++ b/libs/domain/product/src/mocks/src/mock-product.service.ts @@ -100,6 +100,15 @@ export class MockProductService implements Partial { availability: true, }, categoryIds: ['category id 1'], + variants: { + 'variant-1': { brand: 'Brand1', color: 'color1' }, + 'variant-2': { brand: 'Brand1', color: 'green' }, + 'variant-3': { brand: 'Brand1', color: 'blue' }, + }, + variantDefinition: { + brand: ['Brand1'], + color: ['color1', 'green', 'blue'], + }, }, { sku: '2', @@ -138,6 +147,14 @@ export class MockProductService implements Partial { availability: false, }, categoryIds: ['category id 2'], + variantDefinition: { + brand: ['Brand2'], + color: ['red', 'green', 'blue'], + }, + variants: { + 'variant-1': { brand: 'Brand2', color: 'color2' }, + 'variant-2': { brand: 'Brand3' }, + } }, { sku: '3', @@ -291,7 +308,6 @@ export class MockProductService implements Partial { 'Sample attribute lengthy name, Sample attribute lengthy name, Sample attribute lengthy name.', }, }, - { sku: 'single-image', name: 'Sample product with one image', @@ -326,6 +342,59 @@ export class MockProductService implements Partial { discontinued: true, discontinuedNote: 'This product is discontinued...', }, + { + sku: 'variant-selector', + name: 'Product with Variants', + mediaSet, + description: 'This product has multiple variants to choose from.', + price: { + defaultPrice: { + currency: 'EUR', + value: 1999, + isNet: true, + }, + originalPrice: { + currency: 'EUR', + value: 2499, + isNet: true, + }, + }, + averageRating: 4.5, + reviewCount: 10, + attributes: { + brand: 'Brand9', + color: 'red', + size: 'M', + }, + attributeNames: { + brand: 'Brand', + color: 'Color', + size: 'Size', + }, + labels: [newLabel, saleLabel], + availability: { + quantity: 5, + isNeverOutOfStock: false, + availability: true, + }, + categoryIds: ['category id 9'], + variants: { + 'variant-1': { brand: 'Brand9', color: 'red', size: 'S' }, + 'variant-2': { brand: 'Brand9', color: 'red', size: 'M' }, + 'variant-3': { brand: 'Brand9', color: 'red', size: 'L' }, + 'variant-4': { brand: 'Brand9', color: 'green', size: 'S' }, + 'variant-5': { brand: 'Brand9', color: 'green', size: 'M' }, + 'variant-6': { brand: 'Brand9', color: 'green', size: 'L' }, + 'variant-7': { brand: 'Brand9', color: 'blue', size: 'S' }, + 'variant-8': { brand: 'Brand9', color: 'blue', size: 'M' }, + 'variant-9': { brand: 'Brand9', color: 'blue', size: 'L' }, + }, + variantDefinition: { + brand: ['Brand9'], + color: ['red', 'green', 'blue'], + size: ['S', 'M', 'L'], + }, + } ]; get(qualifier: ProductQualifier): Observable { diff --git a/libs/domain/product/src/models/product.api.model.ts b/libs/domain/product/src/models/product.api.model.ts index dd94475c..fb92c0fc 100644 --- a/libs/domain/product/src/models/product.api.model.ts +++ b/libs/domain/product/src/models/product.api.model.ts @@ -20,12 +20,28 @@ export module ApiProductModel { id?: string; isDiscontinued?: boolean; productAbstractSku?: string; + + /** + * Reference to the abstract product(s). + */ + abstractProducts: Abstract[]; + /** + * Reference to the concrete variations. This includes the current SKU. + */ + concreteProducts: Concrete[]; } export interface Abstract extends Attributes { - attributeMap?: string[][]; + attributeMap?: { + superAttributes?: Record; + attributeVariantMap?: Record>; + }; merchantReference?: string; superAttributes?: string[]; + /** + * Reference to the concrete variations. This includes the current SKU. + */ + concreteProducts: Concrete[]; } export interface Image { @@ -121,7 +137,8 @@ export module ApiProductModel { | Include | Include | Include - | Include; + | Include + | Include; export type Response = JsonApiModel; } diff --git a/libs/domain/product/src/models/product.model.ts b/libs/domain/product/src/models/product.model.ts index 067755c7..8f6aec29 100644 --- a/libs/domain/product/src/models/product.model.ts +++ b/libs/domain/product/src/models/product.model.ts @@ -43,6 +43,12 @@ export interface Product { availability?: ProductAvailability; discontinued?: boolean; discontinuedNote?: string; + /** + * Holds variants of the current product. We only keep track of the SKU, so + * that additional product data must be resolved from the product service. + */ + variants?: Record>; + variantDefinition?: Record; } export interface ProductLabel { diff --git a/libs/domain/product/src/services/adapter/normalizers/concrete-products/concrete-products.normalizer.ts b/libs/domain/product/src/services/adapter/normalizers/concrete-products/concrete-products.normalizer.ts index 723a17e2..062953bd 100644 --- a/libs/domain/product/src/services/adapter/normalizers/concrete-products/concrete-products.normalizer.ts +++ b/libs/domain/product/src/services/adapter/normalizers/concrete-products/concrete-products.normalizer.ts @@ -20,20 +20,24 @@ export function concreteProductsNormalizer( return combineLatest( data .filter((abstract) => abstract[concreteProductsKey]?.length) - .map((abstract) => - combineLatest([ - transformer.transform( - abstract[concreteProductsKey]?.[0], - ProductNormalizer - ), + .map((abstract) => { + const concretes = abstract[concreteProductsKey]; + const concrete = abstract[concreteProductsKey][0]; + + if (concrete.abstractProducts) { + concrete.abstractProducts[0][concreteProductsKey] = concretes; + } + + return combineLatest([ + transformer.transform(concrete, ProductNormalizer), transformer.transform(abstract[categoryKey], CategoryIdNormalizer), ]).pipe( map(([product, nodeId]) => ({ ...product, ...nodeId, })) - ) - ) + ); + }) ); } diff --git a/libs/domain/product/src/services/adapter/normalizers/product/model.ts b/libs/domain/product/src/services/adapter/normalizers/product/model.ts index eda458cc..aca02632 100644 --- a/libs/domain/product/src/services/adapter/normalizers/product/model.ts +++ b/libs/domain/product/src/services/adapter/normalizers/product/model.ts @@ -10,4 +10,5 @@ export type DeserializedProduct = ApiProductModel.Concrete & | CamelCase | CamelCase | CamelCase + | CamelCase >; diff --git a/libs/domain/product/src/services/adapter/normalizers/product/product.normalizer.ts b/libs/domain/product/src/services/adapter/normalizers/product/product.normalizer.ts index 99f1464f..6b122c5b 100644 --- a/libs/domain/product/src/services/adapter/normalizers/product/product.normalizer.ts +++ b/libs/domain/product/src/services/adapter/normalizers/product/product.normalizer.ts @@ -25,7 +25,6 @@ export function productAttributeNormalizer( attributes, attributeNames, } = data; - return { sku, name, @@ -129,6 +128,45 @@ export function productCategoryNormalizer( return transformer.transform(node, CategoryNormalizer); } +export function productVariantNormalizer( + data: DeserializedProduct +): Partial { + const { abstractProducts } = data; + + const abstract = abstractProducts?.[0]; + + const variantMap = abstract.attributeMap?.attributeVariantMap; + const variantDefinition = abstract.attributeMap?.superAttributes; + + if (!variantDefinition) { + return {}; + } + + if (variantMap) { + const variantMapEntries = Object.entries(variantMap); + + const variants = variantMapEntries.reduce((skuMap, [_, mapAttributes]) => { + const matchingVariant = abstract.concreteProducts.find( + (variant) => + variant.attributes && + Object.keys(mapAttributes).every( + (attrKey) => variant.attributes![attrKey] === mapAttributes[attrKey] + ) + ); + + if (matchingVariant && matchingVariant.sku) { + skuMap[matchingVariant.sku] = mapAttributes; + } + + return skuMap; + }, {} as Record>); + + return { variants, variantDefinition }; + } + + return {}; +} + export const productNormalizer: Provider[] = [ { provide: ProductNormalizer, @@ -162,6 +200,10 @@ export const productNormalizer: Provider[] = [ provide: ProductNormalizer, useValue: productCategoryNormalizer, }, + { + provide: ProductNormalizer, + useValue: productVariantNormalizer, + }, ]; declare global { diff --git a/libs/domain/product/src/services/adapter/product-includes.ts b/libs/domain/product/src/services/adapter/product-includes.ts index 280a3674..463c2644 100644 --- a/libs/domain/product/src/services/adapter/product-includes.ts +++ b/libs/domain/product/src/services/adapter/product-includes.ts @@ -8,6 +8,7 @@ export const productIncludes = provideIncludes(PRODUCT, [ ApiProductModel.Includes.ConcreteProductAvailabilities, ApiProductModel.Includes.Labels, ApiProductModel.Includes.AbstractProducts, + ApiProductModel.Includes.ConcreteProducts, { include: ApiProductModel.Includes.CategoryNodes, fields: [ diff --git a/libs/domain/product/variant-selector/index.ts b/libs/domain/product/variant-selector/index.ts new file mode 100644 index 00000000..98009c99 --- /dev/null +++ b/libs/domain/product/variant-selector/index.ts @@ -0,0 +1,2 @@ +export * from './variant-selector.component'; +export * from './variant-selector.styles'; diff --git a/libs/domain/product/variant-selector/stories/demo.stories.ts b/libs/domain/product/variant-selector/stories/demo.stories.ts new file mode 100644 index 00000000..aa2d8613 --- /dev/null +++ b/libs/domain/product/variant-selector/stories/demo.stories.ts @@ -0,0 +1,19 @@ +import { MockProductService } from '@oryx-frontend/product/mocks'; +import { Meta, Story } from '@storybook/web-components'; +import { TemplateResult, html } from 'lit'; +import { storybookPrefix } from '../../.constants'; + +export default { + title: `${storybookPrefix}/Variant selector`, + parameters: { + chromatic: { + disableSnapshot: true, + }, + }, +} as Meta; + +const Template: Story = (): TemplateResult => { + return html``; +}; + +export const Demo = Template.bind({}); diff --git a/libs/domain/product/variant-selector/stories/static.stories.ts b/libs/domain/product/variant-selector/stories/static.stories.ts new file mode 100644 index 00000000..512f9e31 --- /dev/null +++ b/libs/domain/product/variant-selector/stories/static.stories.ts @@ -0,0 +1,26 @@ +import { MockProductService } from '@oryx-frontend/product/mocks'; +import { Meta, Story } from '@storybook/web-components'; +import { TemplateResult, html } from 'lit'; +import { storybookPrefix } from '../../.constants'; + +export default { + title: `${storybookPrefix}/Variant selector/Static`, +} as unknown as Meta; + +const Template: Story = (): TemplateResult => { + return html` +
+

3 variants:

+ +
+
+

2 variants:

+ +
+
+

Color variants disabled:

+ +
+ `; +} +export const Variations = Template.bind({}); diff --git a/libs/domain/product/variant-selector/variant-selector.component.spec.ts b/libs/domain/product/variant-selector/variant-selector.component.spec.ts new file mode 100644 index 00000000..66dd85b6 --- /dev/null +++ b/libs/domain/product/variant-selector/variant-selector.component.spec.ts @@ -0,0 +1,63 @@ +import { fixture } from '@open-wc/testing-helpers'; +import { createInjector, destroyInjector } from '@oryx-frontend/di'; +import { mockProductProviders, MockProductService } from '@oryx-frontend/product/mocks'; +import { useComponent } from '@oryx-frontend/utilities'; +import { html } from 'lit'; +import { ProductVariantSelectorComponent } from './variant-selector.component'; +import { productVariantSelectorComponent } from './variant-selector.def'; +import { ProductService } from '@oryx-frontend/product'; +import { ContextService, DefaultContextService } from '@oryx-frontend/core'; +import { beforeEach } from 'vitest'; + +describe('ProductVariantSelectorComponent', () => { + let element: ProductVariantSelectorComponent; + let productService: MockProductService; + + beforeAll(async () => { + await useComponent(productVariantSelectorComponent); + }); + + beforeEach(async () => { + const injector = createInjector({ + providers: [ + { + provide: ProductService, + useClass: MockProductService + }, + { + provide: ContextService, + useClass: DefaultContextService, + }, + ], + }); + + productService = injector.inject(ProductService); + + + }); + + afterEach(() => { + destroyInjector(); + }); + + describe('when the component is created', () => { + beforeEach(async () => { + element = await fixture( + html` + ` + ); + }); + + it('should be defined', () => { + expect(element).toBeInstanceOf(ProductVariantSelectorComponent); + }); + + it('passes the a11y audit', async () => { + expect(element).shadowDom.to.be.accessible(); + }); + + it('should render a toggle variants', () => { + expect(element).toContainElement('oryx-toggle-icon'); + }); + }); +}); diff --git a/libs/domain/product/variant-selector/variant-selector.component.ts b/libs/domain/product/variant-selector/variant-selector.component.ts new file mode 100644 index 00000000..855712bc --- /dev/null +++ b/libs/domain/product/variant-selector/variant-selector.component.ts @@ -0,0 +1,148 @@ +import { resolve } from '@oryx-frontend/di'; +import { LayoutMixin } from '@oryx-frontend/experience'; +import { + PRODUCT, + ProductListPageService, + ProductListService, + ProductMixin, +} from '@oryx-frontend/product'; +import { LinkService, RouteType, RouterService } from '@oryx-frontend/router'; +import { computed, hydrate, signalAware } from '@oryx-frontend/utilities'; +import { + createSignal, + effect, +} from '@oryx-frontend/utilities/src/signals/core'; +import { LitElement, TemplateResult, html } from 'lit'; +import { repeat } from 'lit/directives/repeat.js'; +import { variantListStyle } from './variant-selector.styles'; + +@hydrate({ context: PRODUCT }) +@signalAware() +export class ProductVariantSelectorComponent extends ProductMixin( + LayoutMixin(LitElement) +) { + static styles = variantListStyle; + + protected routerService = resolve(RouterService); + protected linkService = resolve(LinkService); + + protected $variant = createSignal(undefined); + + protected $link = computed(() => { + const variantSku = this.$variant(); + if (!variantSku) return; + return this.linkService.get({ + type: RouteType.Product, + qualifier: { sku: variantSku }, + }); + }); + + protected route = effect(() => { + const link = this.$link(); + if (link) { + this.routerService.navigate(link as any as string); + } + }); + + protected render(): TemplateResult | void { + console.log('this.$product():'); + + + const variants = this.$product()?.variants; + + if (!variants || Object.keys(variants).length < 2) return; + + return this.renderAttributeSelectors(); + } + + protected renderAttributeSelectors() { + const { variantDefinition, attributeNames } = this.$product() ?? {}; + + if (!variantDefinition) return; + const keys = Object.keys(variantDefinition); + + return html`
+ ${repeat( + keys, + (key, index) => + html`

${attributeNames?.[key]}

+ ${repeat( + variantDefinition[key], + (value) => html` + + + ${value} + + ` + )}` + )} +
`; + } + + protected isChecked(key: string, value: string) { + const product = this.$product(); + return product?.attributes?.[key] === value; + } + + /** + * Determines if a given attribute is disabled based on the product's variant attribute info. + */ + protected isDisabled( + attributeKey: string, + attributeValue: string, + index: number + ): boolean { + const product = this.$product(); + + if (!product?.variants) { + return false; + } + + // Extract all active variant attribute keys except the one at the given index + const allKeys = Object.keys(product.variantDefinition || {}); + const activeKeys = allKeys.filter((_, i) => i !== index); + + // Check if any variant matches the selected attribute values + const isVariantDisabled = !Object.values(product.variants).some( + (variant) => { + // Check if all selected attribute values (except the one at the index) match the variant + return ( + activeKeys.every( + (key) => variant[key] === this.$product()?.attributes?.[key] + ) && variant[attributeKey] === attributeValue + ); + } + ); + + return isVariantDisabled; + } + + protected handleVariantChange(e: Event) { + const form = e.currentTarget as HTMLFormElement; + const formData = new FormData(form); + + const selectedAttributes: Record = {}; + formData.forEach((value, key) => { + selectedAttributes[key] = value as string; + }); + + const { variants } = this.$product() ?? {}; + + if (!variants) return; + const matchingVariant = Object.keys(variants).find((sku) => { + const values = variants[sku]; + + return Object.keys(selectedAttributes).every( + (attrKey) => values![attrKey] === selectedAttributes[attrKey] + ); + }); + if (matchingVariant) this.$variant.set(matchingVariant); + } +} diff --git a/libs/domain/product/variant-selector/variant-selector.def.ts b/libs/domain/product/variant-selector/variant-selector.def.ts new file mode 100644 index 00000000..86b2bffd --- /dev/null +++ b/libs/domain/product/variant-selector/variant-selector.def.ts @@ -0,0 +1,16 @@ +import { componentDef } from '@oryx-frontend/utilities'; +import { ProductVariantSelectorComponent } from './variant-selector.component'; + +declare global { + interface FeatureOptions { + 'oryx-product-variant-selector'?: ProductVariantSelectorComponent; + } +} + +export const productVariantSelectorComponent = componentDef({ + name: 'oryx-product-variant-selector', + impl: () => + import('./variant-selector.component').then( + (m) => m.ProductVariantSelectorComponent + ), +}); diff --git a/libs/domain/product/variant-selector/variant-selector.styles.ts b/libs/domain/product/variant-selector/variant-selector.styles.ts new file mode 100644 index 00000000..12a0a3e4 --- /dev/null +++ b/libs/domain/product/variant-selector/variant-selector.styles.ts @@ -0,0 +1,8 @@ +import { css } from 'lit'; + +export const variantListStyle = css` + :host { + display: grid; + gap: 8px; + } +`; diff --git a/libs/template/presets/storefront/experience/pages/product-page.ts b/libs/template/presets/storefront/experience/pages/product-page.ts index 6e20891d..d92eca27 100644 --- a/libs/template/presets/storefront/experience/pages/product-page.ts +++ b/libs/template/presets/storefront/experience/pages/product-page.ts @@ -97,6 +97,7 @@ export const productPage: ExperienceComponent = { }, { type: 'oryx-product-average-rating' }, { type: 'oryx-product-id' }, + { type: 'oryx-product-variant-selector' }, { type: 'oryx-product-price', options: { enableSalesLabel: true }, diff --git a/tsconfig.base.json b/tsconfig.base.json index c4bd8ce6..7341fc14 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -199,6 +199,9 @@ "libs/domain/product/relations/index.ts" ], "@oryx-frontend/product/title": ["libs/domain/product/title/index.ts"], + "@oryx-frontend/product/variant-selector": [ + "libs/domain/product/variant-selector/index.ts" + ], "@oryx-frontend/push-notification": [ "libs/platform/push-notification/src/index.ts" ],