Article/RelatedProducts
Four Products
Invalid Id
Two Products
related-products.tsx
import { allProducts } from '@data/products.mjs';
import { allBrands } from '@data/brands.mjs';
import { ProductNavList } from '@/components/shared/product-nav-list';
import metadataDb from '@/src/lib/metadata-db';
import { extractSlugFromImagePath } from '@/lib/utils/image-path-utils';
import type { Product } from '@/src/types/product';
import type { ProductEnhanced } from '@/lib/types/product-enhanced';
import type { Locale } from '@/lib/i18n/types';
interface RelatedProductsProps {
ids: string[];
locale?: Locale;
}
const SITE_OGP_IMG_URL = 'https://takazudomodular.com/images/ogp-large.jpg';
const DUMMY_BLURHASH = '000000';
/**
* fs-free port of `transformToProcessedImagePath` (sub 6.1, #61).
*
* Replaces the original `fs.existsSync(metadata.json)` probes with a
* `slug in metadataDb` membership check against the typed wrapper from
* sub 4.3. zfb's esbuild bundle has no `fs`, so the original module
* cannot reach the zfb route bundle. The membership check is semantically
* equivalent — `pnpm build:metadata` registers exactly the same slug set
* that the original code probed for `metadata.json` on disk.
*
* Tuned for the RelatedProducts call site only — products supply slug-style
* imgSrc values like `/images/p/<slug>/`, occasionally legacy
* `/images/<directory>/<file>.<ext>`. External `http(s)://` and the
* SITE_OGP_IMG_URL sentinel are passed through unchanged.
*/
function transformToProcessedImagePathFsFree(imgSrc: string): string {
if (!imgSrc || imgSrc === SITE_OGP_IMG_URL) return imgSrc;
if (imgSrc.startsWith('http://') || imgSrc.startsWith('https://')) return imgSrc;
// Directory paths like /images/p/articles-news-16/
if (imgSrc.startsWith('/images/p/') && imgSrc.endsWith('/')) {
const slug = imgSrc.replace('/images/p/', '').replace(/\/$/, '');
if (slug in metadataDb) {
return `/images/p/${slug}/1200w.webp`;
}
}
// Various local image patterns: /images/(directory/)filename.ext
const pathMatch = imgSrc.match(/\/images\/(?:(products|misc|addac|brands)\/)?([^/]+)\.\w+$/);
if (pathMatch) {
const [, directory, filename] = pathMatch;
// filename is a regex capture group → string | undefined under strict TS
const slug = directory ? `${directory}-${filename}` : filename;
if (slug && slug in metadataDb) {
return `/images/p/${slug}/600w.webp`;
}
}
return imgSrc;
}
/**
* Synchronously enhance a raw product for rendering.
* Mirrors lib/data/products/enhance-product.ts but without async.
*/
function enhanceProductSync(product: Product, index: number): ProductEnhanced | null {
const originalImgSrc = product.imgSrc || SITE_OGP_IMG_URL;
// Get blurhash synchronously from metadata database
let blurHash = DUMMY_BLURHASH;
if (originalImgSrc.startsWith('/')) {
const slug = extractSlugFromImagePath(originalImgSrc);
if (slug) {
blurHash = metadataDb[slug]?.blurhash || DUMMY_BLURHASH;
}
}
// Transform image path (already sync)
const imgSrc = transformToProcessedImagePathFsFree(originalImgSrc);
// Find brand object
const brand = allBrands.find((b) => b.slug === product.brand);
if (!brand) {
return null;
}
return {
...product,
imgSrc,
blurHash,
brand,
index,
};
}
/**
* Related Products component for MDX.
* Displays product cards for given product IDs.
*
* Sync component — compatible with Astro's react-dom/server rendering.
*/
export function RelatedProducts({ ids, locale = 'ja' }: RelatedProductsProps) {
const validProducts: ProductEnhanced[] = [];
const invalidIds: string[] = [];
ids.forEach((id, index) => {
const product = allProducts.find((p) => p.slug === id);
if (!product) {
invalidIds.push(id);
return;
}
const enhanced = enhanceProductSync(product, index);
if (enhanced && enhanced.detailHref) {
validProducts.push(enhanced);
}
});
return (
<section className="mb-vgap-md">
{invalidIds.length > 0 && (
<div
role="alert"
className={
'mb-vgap-sm p-vgap-xs bg-zd-literal-white text-zd-literal-black border border-zd-black rounded-xs'
}
>
<p className="text-sm">以下の商品が見つかりませんでした: {invalidIds.join(', ')}</p>
</div>
)}
{validProducts.length === 0 && invalidIds.length === 0 && (
<p className="text-sm">表示できる関連商品がありません。</p>
)}
{validProducts.length > 0 && <ProductNavList products={validProducts} locale={locale} />}
</section>
);
}
related-products.stories.tsx
import { RelatedProducts } from './related-products';
/**
* RelatedProducts Component Stories
*
* Unlike PurchaseNav, RelatedProducts pulls live catalog data directly from
* `src/data/products.mjs` — there is no mock layer. Variants below use
* known-stable ids already in production MDX content, so they stay
* accurate as long as those products remain in the catalog.
*/
export const meta = {
title: 'Article/RelatedProducts',
};
/**
* Two Products
*
* The default 2-col "brand" grid variant with a single-brand pair.
* Same ids used live in `src/mdx/guides/oxi-coral-guide-ep1.mdx`.
*/
export const TwoProducts = () => <RelatedProducts ids={['oxi-coral', 'oxi-coral-bk']} />;
/**
* Four Products
*
* The exact 4-card grid rendered live via `MetaModuleExpanders` on
* `/guides/4ms-podcast-richard-devine/` — the reference pattern this
* component replaces PurchaseNav with in the Highlights mail archive.
*/
export const FourProducts = () => (
<RelatedProducts
ids={[
'metamodule',
'metamodule-audio-expander',
'metamodule-button-expander',
'metamodule-wifi-expander',
]}
/>
);
/**
* Invalid Id
*
* Demonstrates the "以下の商品が見つかりませんでした" warning branch shown
* when a requested id has no matching catalog product.
*/
export const InvalidId = () => <RelatedProducts ids={['nonexistent-product-demo']} />;