Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { gql } from "@dextinity/site-nextjs";
import { productJsonLdFragment } from "@src/products/ProductJsonLd";

export const fragment = gql`
fragment ProductDetailPage on Product {
title
image
description
...ProductJsonLd
}
${productJsonLdFragment}
`;
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export const dynamic = "error";

import { gql } from "@dextinity/site-nextjs";
import type { VisibilityParam } from "@src/middleware/domainRewrite";
import { ProductJsonLd } from "@src/products/ProductJsonLd";
import { createGraphQLFetch } from "@src/util/graphQLClient";
import { setVisibilityParam } from "@src/util/ServerContext";
import { notFound } from "next/navigation";
Expand All @@ -11,8 +12,9 @@ import { fragment } from "./fragment";
import type { GQLProductDetailPageQuery, GQLProductDetailPageQueryVariables } from "./page.generated";

export default async function ProductDetailPage({ params }: PageProps<"/[visibility]/[domain]/[language]/product/[slug]">) {
const { slug, visibility } = await params;
const { domain, language, slug, visibility } = await params;
setVisibilityParam(visibility as VisibilityParam);
const scope = { domain, language };
const graphqlFetch = createGraphQLFetch();

const data = await graphqlFetch<GQLProductDetailPageQuery, GQLProductDetailPageQueryVariables>(
Expand All @@ -32,5 +34,10 @@ export default async function ProductDetailPage({ params }: PageProps<"/[visibil
notFound();
}

return <Content product={data.productBySlug} />;
return (
<>
<ProductJsonLd product={data.productBySlug} scope={scope} />
<Content product={data.productBySlug} />
</>
);
}
76 changes: 76 additions & 0 deletions demo/site/src/products/ProductJsonLd.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { generateImageUrl, gql, JsonLd } from "@dextinity/site-nextjs";
import type { DamImageBlockData } from "@src/blocks.generated";
import type { ContentScope } from "@src/site-configs";
import { getSiteConfigForDomain } from "@src/util/siteConfig";
import type { Product, WithContext } from "schema-dts";

import type { GQLProductJsonLdFragment } from "./ProductJsonLd.generated";

export const productJsonLdFragment = gql`
fragment ProductJsonLd on Product {
title
description
image
articleNumbers
inStock
price
priceRange {
min
}
manufacturer {
name
}
}
`;

interface Props {
product: GQLProductJsonLdFragment;
scope: ContentScope;
}

const maxWidth = 1200;

function productImageUrl(image: DamImageBlockData, siteUrl: string): string | undefined {
const props = image.block?.props;

if (!props) {
return undefined;
}

if ("urlTemplate" in props && props.damFile?.image) {
const width = Math.min(props.damFile.image.width, maxWidth);
const aspectRatio = props.damFile.image.width / props.damFile.image.height;
return new URL(generateImageUrl({ src: props.urlTemplate, width }, aspectRatio), siteUrl).toString();
}

return props.damFile?.fileUrl ? new URL(props.damFile.fileUrl, siteUrl).toString() : undefined;
}

export function ProductJsonLd({ product, scope }: Props) {
const siteConfig = getSiteConfigForDomain(scope.domain);
const image = productImageUrl(product.image, siteConfig.url);
const price = product.price ?? product.priceRange?.min;

const data: WithContext<Product> = {
"@context": "https://schema.org",
"@type": "Product",
name: product.title,
...(image ? { image } : {}),
...(product.description ? { description: product.description } : {}),
...(product.articleNumbers[0] ? { sku: product.articleNumbers[0] } : {}),
...(product.manufacturer ? { brand: { "@type": "Brand", name: product.manufacturer.name } } : {}),
...(price != null
? {
offers: {
"@type": "Offer",
price,
// TODO: Read the currency from the site config once it carries one; the Demo only sells in EUR.
priceCurrency: "EUR",
availability: product.inStock ? "https://schema.org/InStock" : "https://schema.org/OutOfStock",
},
}
: {}),
};

return <JsonLd<Product> data={data} />;
}
21 changes: 21 additions & 0 deletions demo/site/src/products/ProductListJsonLd.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { JsonLd } from "@dextinity/site-nextjs";
import type { ItemList, WithContext } from "schema-dts";

interface Props {
products: { title: string; url: string }[];
}

export function ProductListJsonLd({ products }: Props) {
const data: WithContext<ItemList> = {
"@context": "https://schema.org",
"@type": "ItemList",
itemListElement: products.map((product, index) => ({
"@type": "ListItem",
position: index + 1,
name: product.title,
url: product.url,
})),
};

return <JsonLd<ItemList> data={data} />;
}
13 changes: 9 additions & 4 deletions demo/site/src/products/blocks/ProductListBlock.loader.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { type BlockLoaderOptions, gql } from "@dextinity/site-nextjs";
import type { ProductListBlockData } from "@src/blocks.generated";
import { createSitePath } from "@src/util/createSitePath";
import { getSiteConfigForDomain } from "@src/util/siteConfig";

import type { GQLProductListBlockQuery, GQLProductListBlockQueryVariables } from "./ProductListBlock.loader.generated";

Expand Down Expand Up @@ -28,8 +29,12 @@ export const loader = async ({ blockData, graphQLFetch, scope }: BlockLoaderOpti
{ ids: blockData.ids },
);

return data.productsByIds.map((product) => ({
...product,
path: createSitePath({ scope, path: `/product/${product.slug}` }),
}));
const siteConfig = getSiteConfigForDomain(scope.domain);

return data.productsByIds.map((product) => {
const path = createSitePath({ scope, path: `/product/${product.slug}` });

// The ItemList structured data requires absolute URLs.
return { ...product, path, url: new URL(path, siteConfig.url).toString() };
});
};
18 changes: 11 additions & 7 deletions demo/site/src/products/blocks/ProductListBlock.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type PropsWithData, withPreview } from "@dextinity/site-nextjs";
import type { ProductListBlockData } from "@src/blocks.generated";
import { ProductListJsonLd } from "@src/products/ProductListJsonLd";
import Link from "next/link";

import type { LoadedData } from "./ProductListBlock.loader";
Expand All @@ -11,13 +12,16 @@ export const ProductListBlock = withPreview(
}

return (
<ol>
{products.map((product) => (
<li key={product.id}>
<Link href={product.path}>{product.title}</Link>
</li>
))}
</ol>
<>
<ProductListJsonLd products={products} />

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, although this was a requirement in the ticket, I don't think we should have a product ItemList JsonLd when we have a proper Product JsonLd on the detail page

<ol>
{products.map((product) => (
<li key={product.id}>
<Link href={product.path}>{product.title}</Link>
</li>
))}
</ol>
</>
);
},
{ label: "Product List" },
Expand Down
Loading