Takazudo Modular Styleguide

Article/RelatedProducts

Four Products
Invalid Id
Two Products

Component Source

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>
  );
}

Story Source

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']} />;