Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/variants productization #49

Open
wants to merge 19 commits into
base: development
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions libs/domain/product/attributes/src/attributes.component.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { ContentMixin, defaultOptions } from '@oryx-frontend/experience';

Check warning on line 1 in libs/domain/product/attributes/src/attributes.component.ts

View workflow job for this annotation

GitHub Actions / test-lint

'defaultOptions' is defined but never used
import { PRODUCT, ProductContext, ProductMixin } from '@oryx-frontend/product';

Check warning on line 2 in libs/domain/product/attributes/src/attributes.component.ts

View workflow job for this annotation

GitHub Actions / test-lint

'PRODUCT' is defined but never used

Check warning on line 2 in libs/domain/product/attributes/src/attributes.component.ts

View workflow job for this annotation

GitHub Actions / test-lint

'ProductContext' is defined but never used
import { featureVersion, hydrate, ssrShim } from '@oryx-frontend/utilities';

Check warning on line 3 in libs/domain/product/attributes/src/attributes.component.ts

View workflow job for this annotation

GitHub Actions / test-lint

'featureVersion' is defined but never used

Check warning on line 3 in libs/domain/product/attributes/src/attributes.component.ts

View workflow job for this annotation

GitHub Actions / test-lint

'hydrate' is defined but never used

Check warning on line 3 in libs/domain/product/attributes/src/attributes.component.ts

View workflow job for this annotation

GitHub Actions / test-lint

'ssrShim' is defined but never used
import { LitElement, TemplateResult, html } from 'lit';
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(

Check warning on line 14 in libs/domain/product/attributes/src/attributes.component.ts

View workflow job for this annotation

GitHub Actions / test-lint

'ProductAttributesComponent' is defined but never used
ContentMixin<ProductAttributesOptions>(LitElement)
) {
static styles = [productAttributeStyles];
Expand All @@ -23,13 +26,30 @@
${Object.keys(names).map(
(key) => html`
<dt>${this.getName(names, key)}</dt>
<dd>${values[key]}</dd>
<dd ?highlight=${this.isHighlighted(key)}>${values[key]}</dd>
`
)}
</dl>
`;
}

/**
* 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<string, string>,
key: string
Expand Down
8 changes: 8 additions & 0 deletions libs/domain/product/attributes/src/attributes.model.ts
Original file line number Diff line number Diff line change
@@ -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;
}
9 changes: 9 additions & 0 deletions libs/domain/product/attributes/src/attributes.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
`;
1 change: 1 addition & 0 deletions libs/domain/product/src/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
71 changes: 70 additions & 1 deletion libs/domain/product/src/mocks/src/mock-product.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,15 @@ export class MockProductService implements Partial<ProductService> {
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',
Expand Down Expand Up @@ -138,6 +147,14 @@ export class MockProductService implements Partial<ProductService> {
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',
Expand Down Expand Up @@ -291,7 +308,6 @@ export class MockProductService implements Partial<ProductService> {
'Sample attribute lengthy name, Sample attribute lengthy name, Sample attribute lengthy name.',
},
},

{
sku: 'single-image',
name: 'Sample product with one image',
Expand Down Expand Up @@ -326,6 +342,59 @@ export class MockProductService implements Partial<ProductService> {
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<Product> {
Expand Down
21 changes: 19 additions & 2 deletions libs/domain/product/src/models/product.api.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string[]>;
attributeVariantMap?: Record<string, Record<string, string>>;
};
merchantReference?: string;
superAttributes?: string[];
/**
* Reference to the concrete variations. This includes the current SKU.
*/
concreteProducts: Concrete[];
}

export interface Image {
Expand Down Expand Up @@ -121,7 +137,8 @@ export module ApiProductModel {
| Include<Includes.ConcreteProductAvailabilities, ProductAvailability>
| Include<Includes.Labels, ProductLabels>
| Include<Includes.AbstractProducts, Abstract>
| Include<Includes.CategoryNodes, CategoryNodes>;
| Include<Includes.CategoryNodes, CategoryNodes>
| Include<Includes.ConcreteProducts, Concrete>;

export type Response = JsonApiModel<Concrete, ResponseIncludes[]>;
}
6 changes: 6 additions & 0 deletions libs/domain/product/src/models/product.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Record<string, string>>;
variantDefinition?: Record<string, string[]>;
}

export interface ProductLabel {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}))
)
)
);
})
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ export type DeserializedProduct = ApiProductModel.Concrete &
| CamelCase<ApiProductModel.Includes.Labels>
| CamelCase<ApiProductModel.Includes.ConcreteProductAvailabilities>
| CamelCase<ApiProductModel.Includes.AbstractProducts>
| CamelCase<ApiProductModel.Includes.ConcreteProducts>
>;
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ export function productAttributeNormalizer(
attributes,
attributeNames,
} = data;

return {
sku,
name,
Expand Down Expand Up @@ -129,6 +128,45 @@ export function productCategoryNormalizer(
return transformer.transform(node, CategoryNormalizer);
}

export function productVariantNormalizer(
data: DeserializedProduct
): Partial<Product> {
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<string, Record<string, string>>);

return { variants, variantDefinition };
}

return {};
}

export const productNormalizer: Provider[] = [
{
provide: ProductNormalizer,
Expand Down Expand Up @@ -162,6 +200,10 @@ export const productNormalizer: Provider[] = [
provide: ProductNormalizer,
useValue: productCategoryNormalizer,
},
{
provide: ProductNormalizer,
useValue: productVariantNormalizer,
},
];

declare global {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
2 changes: 2 additions & 0 deletions libs/domain/product/variant-selector/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './variant-selector.component';
export * from './variant-selector.styles';
19 changes: 19 additions & 0 deletions libs/domain/product/variant-selector/stories/demo.stories.ts
Original file line number Diff line number Diff line change
@@ -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`<oryx-product-variant-selector sku="variant-selector"></oryx-product-variant-selector>`;
};

export const Demo = Template.bind({});
26 changes: 26 additions & 0 deletions libs/domain/product/variant-selector/stories/static.stories.ts
Original file line number Diff line number Diff line change
@@ -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<unknown> = (): TemplateResult => {
return html`
<section>
<h3>3 variants:</h3>
<oryx-product-variant-selector sku="variant-selector"></oryx-product-variant-selector>
</section>
<section>
<h3>2 variants:</h3>
<oryx-product-variant-selector sku="1"></oryx-product-variant-selector>
</section>
<section>
<h3>Color variants disabled:</h3>
<oryx-product-variant-selector sku="2"></oryx-product-variant-selector>
</section>
`;
}
export const Variations = Template.bind({});
Loading
Loading