Skip to content

Commit

Permalink
Schema.org support for PDP (#279)
Browse files Browse the repository at this point in the history
* Interim commit of schema.org support.

* Fix for variesBy field.

* Fixes for E2E tests.

* Clean up.
  • Loading branch information
amcaffee-ep authored Feb 3, 2025
1 parent 6e6c9d4 commit 4022ac2
Show file tree
Hide file tree
Showing 8 changed files with 187 additions and 52 deletions.
17 changes: 11 additions & 6 deletions examples/commerce-essentials/e2e/models/d2c-product-detail-page.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import type { ElasticPath, ProductResponse } from "@elasticpath/js-sdk";
import type { Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
import { getSkuIdFromOptions } from "../../src/lib/product-helper";
import { getCartId } from "../util/get-cart-id";
import {
getProductById,
getSimpleProduct,
getVariationsProduct,
} from "../util/resolver-product-from-store";
import type {ElasticPath, ProductResponse } from "@elasticpath/js-sdk";
import { getCartId } from "../util/get-cart-id";
import { getSkuIdFromOptions } from "../../src/lib/product-helper";

const host = process.env.NEXT_PUBLIC_EPCC_ENDPOINT_URL;

Expand Down Expand Up @@ -110,16 +110,21 @@ async function selectOptions(
): Promise<string> {
/* select one of each variation option */
const options = baseProduct.meta.variations?.reduce((acc, variation) => {
return [...acc, ...([variation.options?.[0]] ?? [])];
const value = variation.options?.[0];
return [...acc, { ...variation, value }];
}, []);

if (options && baseProduct.meta.variation_matrix) {
for (const option of options) {
await page.click(`text=${option.name}`);
if (option.name.toLowerCase() === "color") {
await page.getByTitle(option.value.name).click();
} else {
await page.click(`text=${option.value.name}`);
}
}

const variationId = getSkuIdFromOptions(
options.map((x) => x.id),
options.map((x) => x.value.id),
baseProduct.meta.variation_matrix,
);

Expand Down
29 changes: 15 additions & 14 deletions examples/commerce-essentials/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
},
"dependencies": {
"@elasticpath/js-sdk": "5.0.0",
"@elasticpath/react-shopper-hooks": "0.14.0",
"@elasticpath/react-shopper-hooks": "0.14.6",
"@floating-ui/react": "^0.26.3",
"@headlessui/react": "^1.7.17",
"@heroicons/react": "^2.0.18",
Expand Down Expand Up @@ -54,6 +54,7 @@
"react-dom": "^18.3.1",
"react-hook-form": "^7.49.0",
"react-toastify": "^9.1.3",
"schema-dts": "^1.1.2",
"server-only": "^0.0.1",
"tailwind-clip-path": "^1.0.0",
"tailwind-merge": "^2.0.0",
Expand All @@ -64,32 +65,32 @@
"@babel/core": "^7.18.10",
"@next/bundle-analyzer": "^14.0.0",
"@next/env": "^14.0.0",
"@playwright/test": "^1.49.1",
"@svgr/webpack": "^6.3.1",
"@tailwindcss/forms": "^0.5.7",
"@testing-library/jest-dom": "^6.1.3",
"@testing-library/react": "^14.0.0",
"@types/node": "18.7.3",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
"@vitest/coverage-istanbul": "^0.34.5",
"autoprefixer": "^10.4.14",
"babel-loader": "^8.2.5",
"encoding": "^0.1.13",
"eslint": "^8.49.0",
"eslint-config-next": "^14.0.0",
"eslint-config-prettier": "^9.0.0",
"encoding": "^0.1.13",
"eslint-plugin-react": "^7.33.2",
"vite": "^4.2.1",
"vitest": "^0.34.5",
"@vitest/coverage-istanbul": "^0.34.5",
"@testing-library/jest-dom": "^6.1.3",
"@testing-library/react": "^14.0.0",
"@playwright/test": "^1.49.1",
"lint-staged": "^13.0.3",
"postcss": "^8.4.30",
"prettier": "^3.0.3",
"prettier-eslint": "^15.0.1",
"prettier-eslint-cli": "^7.1.0",
"typescript": "^5.2.2",
"tailwindcss": "^3.3.3",
"autoprefixer": "^10.4.14",
"postcss": "^8.4.30",
"prettier-plugin-tailwindcss": "^0.5.4",
"@tailwindcss/forms": "^0.5.7",
"tailwindcss-animate": "^1.0.7"
"tailwindcss": "^3.3.3",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.2.2",
"vite": "^4.2.1",
"vitest": "^0.34.5"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { parseProductResponse } from "@elasticpath/react-shopper-hooks";
import React from "react";

import { RecommendedProducts } from "../../../../components/recommendations/RecommendationProducts";
import ProductSchema from "../../../../components/product/schema/ProductSchema";

export const dynamic = "force-dynamic";

Expand Down Expand Up @@ -46,6 +47,7 @@ export default async function ProductPage({ params }: Props) {
key={"page_" + params.productId}
>
<ProductProvider>
<ProductSchema product={shopperProduct} />
<ProductDetailsComponent product={shopperProduct} />
<RecommendedProducts product={shopperProduct} />
</ProductProvider>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import type {
ChildProduct,
ShopperProduct,
} from "@elasticpath/react-shopper-hooks";
import type {
Offer,
Product,
ProductGroup,
Thing,
WithContext,
} from "schema-dts";
interface IProductSchema {
product: ShopperProduct;
}

/**
* Generates schema.org representation data for a product. Currently supports simple products and child products.
* Parent products do not have child product data to sufficiently render a full representation. Future enhancements
* may render this possible. Splitting the variant data across variant pages is supported according to
* https://developers.google.com/search/docs/appearance/structured-data/product-variants#multi-page-example-1
*
* @param param0 ShopperProduct to render
* @returns script element with schema.org representation data
*/
const ProductSchema = ({ product }: IProductSchema): JSX.Element => {
let schema;

if (product.kind === "child-product") {
const productGroupSchema: ProductGroup = buildProductGroup(product);

const offers: Offer[] = buildOffers(product);
const productSchema: Product = buildProduct(product, offers);
productSchema.isVariantOf = {
"@id": product.baseProduct.response.attributes.slug,
};

addOptions(productSchema, product);

productGroupSchema.hasVariant = [productSchema];
schema = addContext(productGroupSchema);
} else if (product.kind === "simple-product") {
const offers: Offer[] = buildOffers(product);
const productSchema: Product = buildProduct(product, offers);

schema = addContext(productSchema);
}

if (!schema) {
return <></>;
}

return (
<script
id="product-schema"
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: schema,
}}
/>
);
};

export default ProductSchema;

function addOptions(productSchema: Product, product: ShopperProduct) {
const response = product.response;
// @ts-ignore - support for custom options
productSchema.options = [];
// @ts-ignore - child_variations is supported but missing from the sdk type.
response.meta.child_variations.forEach((variation) => {
if (variation.name.toLowerCase() === "color") {
productSchema.color = variation.option.name;
} else if (variation.name.toLowerCase() === "size") {
productSchema.size = variation.option.name;
} else {
// @ts-ignore - support for custom options
productSchema.options.push({
"@type": "PropertyValue",
name: variation.name,
value: variation.option.name,
});
}
});
}

function buildProductGroup(product: ChildProduct): ProductGroup {
const baseResponse = product.baseProduct.response;

return {
"@type": "ProductGroup",
"@id": baseResponse.attributes.slug,
productGroupID: baseResponse.attributes.sku,
name: baseResponse.attributes.name,
description: baseResponse.attributes.description,
sku: baseResponse.attributes.sku,
image: product.baseProduct.main_image?.link.href,
// @ts-ignore - support for custom options
variesBy: baseResponse.meta?.variations?.map((variation) => variation.name),
};
}

function addContext(productSchema: Product) {
return JsonLd<Product>({
"@context": "https://schema.org",
...productSchema,
});
}

export function JsonLd<T extends Thing>(json: WithContext<T>): string {
return JSON.stringify(json);
}

function buildProduct(product: ShopperProduct, offers: Offer[]): Product {
const response = product.response;
return {
"@type": "Product",
name: response.attributes.name,
description: response.attributes.description,
sku: response.attributes.sku,
mpn: response.attributes.manufacturer_part_num,
image: product.main_image?.link.href,
offers,
};
}

function buildOffers(product: ShopperProduct): Offer[] {
if (!product.response.attributes.price) {
return [];
}
return Object.keys(product.response.attributes.price).map((key) => {
return {
"@type": "Offer",
price: product.response.attributes.price[key].amount,
priceCurrency: key,
};
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ const ProductVariationColor = ({
>
<button
type="button"
title={o.name}
className={clsx(
colorLookup[o.name.toLowerCase()],
`bg-${colorLookup[o.name.toLowerCase()]}-500`,
"rounded-full border border-gray-200 p-4",
)}
onClick={() => updateOptionHandler(variation.id, o.id)}
Expand Down
2 changes: 2 additions & 0 deletions examples/commerce-essentials/src/lib/color-lookup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@ export const colorLookup: { [key: string]: string } = {
purple: "purple",
green: "green",
blue: "blue",
yellow: "yellow",
orange: "orange"
};
5 changes: 5 additions & 0 deletions examples/commerce-essentials/tailwind.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import type { Config } from "tailwindcss";

export default {
content: ["./src/**/*.{js,jsx,ts,tsx}"],
safelist: [
"bg-purple-500",
"bg-yellow-500",
"bg-orange-500"
],
theme: {
extend: {
maxWidth: {
Expand Down
44 changes: 13 additions & 31 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 4022ac2

Please sign in to comment.