Takazudo Modular Styleguide

Article/PurchaseNav

Ask To Buy
Available
Default
Discontinued
Incoming
Incoming With Buttons
Multiple Products
Sold Out
Unavailable
With Article Navigation

Component Source

types.ts

import type { MercariStatus } from '@/src/types/product';
import type { ResponsiveImageCoreMetadata } from '@/components/responsive-image-core';

/**
 * Serializable, pre-resolved product data for the PurchaseNav island.
 *
 * Resolved at SSR/build time by
 * `src/lib/resolve-purchase-nav-client-props.ts` from the product
 * catalog (`lib/data/products-mapping`), brands data, and metadata-db.
 * The island consumes only these narrow props — it must NOT import the
 * catalog or metadata-db itself, directly or transitively, or the ~8 MB
 * data files get inlined into the islands bundle again (#476).
 */
export interface PurchaseNavProduct {
  slug: string;
  name: string;
  subtitle?: string | null;
  /** Brand display name, pre-resolved from the brand slug at SSR */
  brandName: string;
  detailHref?: string | null;
  mercariProductId?: string | null;
  mercariStatus?: MercariStatus | null;
  price?: number | null;
  /** Optional label rendered in the price slot instead of a numeric price (e.g. "準備中") */
  priceLabel?: string | null;
  /** Show reservation button for pre-orders */
  showReservation?: boolean;
  /** Show notify-me button for stock notifications */
  showNotifyMe?: boolean;
  /** Image slug extracted from the catalog imgSrc at SSR */
  imageSlug: string;
  /**
   * Image metadata pre-resolved from metadata-db at SSR — narrowed to
   * exactly the fields ResponsiveImageCore consumes, since this object is
   * serialized into the island's hydration props.
   */
  imageMetadata?: ResponsiveImageCoreMetadata;
  /**
   * Bundle discount fields — present only for bundle products whose component
   * sum could be fully resolved at SSR. All three are plain numbers so the
   * island's hydration props stay serializable.
   *
   * - `referencePrice` — sum of the individual component prices (the "before"
   *   figure shown as a crossed-out comparison price)
   * - `savings` — `referencePrice − price` (the monetary discount amount)
   * - `discountPercent` — `savings / referencePrice * 100`, rounded to one
   *   decimal place (display-ready percentage string value)
   */
  referencePrice?: number;
  savings?: number;
  discountPercent?: number;
  /** true when this product is a pre-configured bundle (`bundle != null`); drives the BUNDLE thumb banner */
  isBundle?: boolean;
}

/**
 * One PurchaseNav entry. `product` is undefined when the requested id is
 * missing from the catalog — the island renders the missing-product error.
 */
export interface PurchaseNavItem {
  productId: string;
  product?: PurchaseNavProduct;
}

purchase-item.tsx

import type { FunctionComponent } from 'preact';
import type { PurchaseNavProduct } from './types';
import { generateMercariUrl } from '@/lib/utils/product-utils';
import type { MercariStatus } from '@/src/types/product';
import { AvailableItem } from './status-items/available-item';
import { SoldItem } from './status-items/sold-item';
import { IncomingItem } from './status-items/incoming-item';
import { DiscontinuedItem } from './status-items/discontinued-item';
import { AskToBuyItem } from './status-items/ask-to-buy-item';
import { UnavailableItem } from './status-items/unavailable-item';
import { getMissingProductError } from './constants';
import type { Locale } from '@/lib/i18n/types';
import { localePath } from '@/lib/i18n/paths';

type ProductStatus = MercariStatus | 'available';

interface PurchaseItemProps {
  product: PurchaseNavProduct | undefined;
  locale?: Locale;
}

export const PurchaseItem: FunctionComponent<PurchaseItemProps> = ({ product, locale = 'ja' }) => {
  if (!product) {
    return <div>{getMissingProductError(locale)}</div>;
  }

  const { mercariProductId, mercariStatus } = product;

  // Determine the status: 'available', 'sold', 'incoming', 'discontinued', 'askToBuy', or 'unavailable'
  const status: ProductStatus = mercariStatus || 'available';

  // Determine link URL based on status (for available and sold items)
  const contactPath = localePath('/contact/', locale);
  const linkUrl =
    status === 'incoming' || status === 'askToBuy'
      ? contactPath
      : generateMercariUrl(mercariProductId);

  // Route to the appropriate status-specific component
  switch (status) {
    case 'available':
      return <AvailableItem product={product} linkUrl={linkUrl} locale={locale} />;
    case 'sold':
      return <SoldItem product={product} linkUrl={linkUrl} locale={locale} />;
    case 'incoming':
      return <IncomingItem product={product} locale={locale} />;
    case 'askToBuy':
      return <AskToBuyItem product={product} locale={locale} />;
    case 'discontinued':
      return <DiscontinuedItem product={product} locale={locale} />;
    case 'unavailable':
      return <UnavailableItem product={product} locale={locale} />;
    default:
      // Fail safe: an unrecognized mercariStatus (typo in the hand-edited
      // master data) must not fall through to the buyable UI.
      return <UnavailableItem product={product} locale={locale} />;
  }
};

constants.ts

/**
 * Shared className for product name container with arrow
 */
export const PRODUCT_NAME_CONTAINER_CLASS =
  'inline-flex items-center pt-[1px] pb-[2px] pr-[7px] mt-[-1px] mb-[-2px] mr-[-7px] rounded-sm underline';

/**
 * Base link wrapper className (used in available, sold, incoming items)
 */
export const LINK_WRAPPER_BASE_CLASS =
  'font-futura flex gap-hgap-sm zd-invert-color-link !text-zd-literal-white hover:!text-zd-literal-black hover:!underline focus:!text-zd-literal-black';

/**
 * Text content container className (used in all status items)
 */
export const TEXT_CONTENT_CONTAINER_CLASS =
  'text-xs sm:text-base md:text-lg flex flex-col items-start justify-center grow';

/**
 * Base container className for non-link items (used in discontinued/unavailable items)
 */
export const BASE_CONTAINER_CLASS = 'font-futura flex gap-hgap-sm !text-zd-literal-white';

/**
 * Full display title for a product: `Brand: Name Subtitle`.
 * The subtitle (e.g. "VC Stinggy Filter" for ADDAC705) is part of the item
 * name and must not be dropped — purchase rows and the article nav share
 * this format.
 */
export function formatProductFullTitle(
  brandName: string,
  name: string,
  subtitle?: string | null,
): string {
  return `${brandName}: ${name}${subtitle ? ` ${subtitle}` : ''}`;
}

/**
 * Error messages for missing products
 */
export const MISSING_PRODUCT_ERROR =
  'この商品ページは現在取り扱っていないか、変更があったため、表示できません。';

export const MISSING_PRODUCT_ERROR_EN =
  'This product page is currently unavailable or has been changed.';

/**
 * Status-specific messages (Japanese)
 */
export const STATUS_MESSAGES = {
  incoming: '近日入荷予定',
  discontinued: 'こちらの商品は製造終了品になります',
  discontinuedSpecsNote: '仕様情報は資料として掲載を継続しています。',
  unavailable: 'こちらの商品は現在当店で取り扱っていない商品になります',
  soldOutNotification: 'メルカリShopsで入荷通知の設定をご利用いただけます。',
  askToBuy: '要メーカー問い合わせ製品です。ご検討の際にはまずご相談ください。',
} as const;

/**
 * Status-specific messages (English)
 */
export const STATUS_MESSAGES_EN = {
  incoming: 'Coming soon. You can set up restock notifications or make a reservation.',
  discontinued: 'This product has been discontinued.',
  discontinuedSpecsNote: 'Specs preserved for reference.',
  unavailable: 'This product is not currently available at our shop.',
  soldOutNotification: 'You can set up restock notifications on Mercari Shops.',
  askToBuy: 'This product requires a manufacturer inquiry. Please contact us first.',
} as const;

/**
 * Get locale-appropriate status messages
 */
export function getStatusMessages(locale: 'ja' | 'en' = 'ja') {
  return locale === 'en' ? STATUS_MESSAGES_EN : STATUS_MESSAGES;
}

/**
 * Build the incoming-status message from the showNotifyMe / showReservation flags.
 * Picks the wording that matches which buttons are actually rendered.
 */
export function getIncomingMessage(
  locale: 'ja' | 'en' = 'ja',
  options: { showNotifyMe?: boolean; showReservation?: boolean } = {},
): string {
  const { showNotifyMe = false, showReservation = false } = options;

  if (locale === 'en') {
    if (showNotifyMe && showReservation) {
      return 'Coming soon. You can set up restock notifications or make a reservation.';
    }
    if (showNotifyMe) return 'Coming soon. You can set up restock notifications.';
    if (showReservation) return 'Coming soon. You can make a reservation.';
    return 'Coming soon.';
  }

  return '近日入荷予定';
}

/**
 * Get locale-appropriate missing product error
 */
export function getMissingProductError(locale: 'ja' | 'en' = 'ja') {
  return locale === 'en' ? MISSING_PRODUCT_ERROR_EN : MISSING_PRODUCT_ERROR;
}

products.ts

import type { PurchaseNavProduct } from '../types';

/**
 * Mock brand display names for testing
 */
export const MOCK_BRAND_NAME = 'OAM';
export const MOCK_BRAND_NAME_TAKAZUDO = 'Takazudo Modular';

/**
 * Standard blurHash for mock product images
 * Used across all mock products for consistent loading placeholders
 */
export const MOCK_BLURHASH = 'LEHV6nWB2yk8pyo0adR*.7kCMdnj';

/**
 * Mock URL constants
 */
export const MOCK_URLS = {
  mercariProduct: 'https://mercari-shops.com/shops/mock-shop/items/mock-id',
  contact: '/contact/',
} as const;

/**
 * Creates a fully-typed mock product for testing.
 *
 * Mirrors the serializable shape produced at SSR by
 * `src/lib/resolve-purchase-nav-client-props.ts` (#476): brand name
 * pre-resolved, image slug pre-extracted, optional pre-resolved metadata.
 */
export const createMockProduct = (
  overrides: Partial<PurchaseNavProduct> = {},
): PurchaseNavProduct => ({
  slug: 'mock-product',
  name: 'Mock Product',
  brandName: MOCK_BRAND_NAME,
  imageSlug: 'oam-tiny-view1',
  imageMetadata: undefined,
  ...overrides,
});

/**
 * Pre-configured mock products for PurchaseNav status testing
 */
export const mockMercariProducts = {
  available: createMockProduct({
    slug: 'mock-available',
    name: 'Available Product',
    mercariProductId: 'mock-mercari-id-available',
    detailHref: '/products/mock-available-intro/',
    price: 153800,
  }),
  sold: createMockProduct({
    slug: 'mock-sold',
    name: 'Sold Out Product',
    mercariProductId: 'mock-mercari-id-sold',
    mercariStatus: 'sold',
    detailHref: '/products/mock-sold-intro/',
    price: 89000,
  }),
  incoming: createMockProduct({
    slug: 'mock-incoming',
    name: 'Incoming Product',
    mercariProductId: 'mock-mercari-id-incoming',
    mercariStatus: 'incoming',
    detailHref: '/products/mock-incoming-intro/',
    price: 45000,
  }),
  askToBuy: createMockProduct({
    slug: 'mock-ask-to-buy',
    name: 'Ask to Buy Product',
    mercariProductId: 'mock-mercari-id-asktobuy',
    mercariStatus: 'askToBuy',
    detailHref: '/products/mock-ask-to-buy-intro/',
    price: 275000,
  }),
  discontinued: createMockProduct({
    slug: 'mock-discontinued',
    name: 'Discontinued Product',
    mercariProductId: 'mock-mercari-id-discontinued',
    mercariStatus: 'discontinued',
    detailHref: '/products/mock-discontinued-intro/',
    price: 32000,
  }),
  unavailable: createMockProduct({
    slug: 'mock-unavailable',
    name: 'Unavailable Product',
    mercariProductId: 'mock-mercari-id-unavailable',
    mercariStatus: 'unavailable',
    detailHref: '/products/mock-unavailable-intro/',
    price: 28000,
  }),
  // Flagged incoming variants used by incoming-item.stories.tsx
  incomingBoth: createMockProduct({
    slug: 'mock-incoming-both',
    name: 'Incoming Both Buttons',
    mercariProductId: 'mock-mercari-id-incoming-both',
    mercariStatus: 'incoming',
    detailHref: '/products/mock-incoming-both-intro/',
    price: 45000,
    showNotifyMe: true,
    showReservation: true,
  }),
  incomingNotify: createMockProduct({
    slug: 'mock-incoming-notify',
    name: 'Incoming Notify Only',
    mercariProductId: 'mock-mercari-id-incoming-notify',
    mercariStatus: 'incoming',
    detailHref: '/products/mock-incoming-notify-intro/',
    price: 45000,
    showNotifyMe: true,
  }),
  incomingReservation: createMockProduct({
    slug: 'mock-incoming-reservation',
    name: 'Incoming Reservation Only',
    mercariProductId: 'mock-mercari-id-incoming-reservation',
    mercariStatus: 'incoming',
    detailHref: '/products/mock-incoming-reservation-intro/',
    price: 45000,
    showReservation: true,
  }),
};

/**
 * Pre-configured mock products for RelatedProducts testing
 */
export const mockRelatedProducts = {
  product1: createMockProduct({
    slug: 'mock-product-1',
    name: 'Mock Product One',
    detailHref: '/products/mock-product-1-intro/',
  }),
  product2: createMockProduct({
    slug: 'mock-product-2',
    name: 'Mock Product Two',
    detailHref: '/products/mock-product-2-intro/',
  }),
  product3: createMockProduct({
    slug: 'mock-product-3',
    name: 'Mock Product Three',
    brandName: MOCK_BRAND_NAME_TAKAZUDO,
    detailHref: '/products/mock-product-3-intro/',
  }),
  productNoDetail: createMockProduct({
    slug: 'mock-product-no-detail',
    name: 'Mock Product Without Detail',
    detailHref: undefined,
  }),
};

Story Source

purchase-nav.stories.tsx

import type { PurchaseNavProduct } from './types';
import { MERCARI_STATUS_VALUES } from '@/src/types/product';
import type { MercariStatus } from '@/src/types/product';
import { PurchaseItem } from './purchase-item';
import { formatProductFullTitle } from './constants';
import { mockMercariProducts, createMockProduct } from './__mocks__/products';
import type { ControlsMap } from '../../../sub-packages/styleguide-v2/src/data/control-types';

/**
 * PurchaseNav Component Stories with Mock Data
 *
 * Uses virtual product data to test all 6 status states:
 * - Available (default) - Shows link to Mercari product page
 * - Sold - Shows sold out message with link to Mercari for stock notifications
 * - Incoming - Shows coming soon message with link to contact page
 * - Ask to Buy - Shows manufacturer consultation required message with link to contact page
 * - Discontinued - Shows discontinued message (non-clickable)
 * - Unavailable - Shows unavailable message (non-clickable)
 */

// Convert mock products object to mapping by slug
const mockProducts: Record<string, PurchaseNavProduct> = {
  'mock-available': mockMercariProducts.available,
  'mock-sold': mockMercariProducts.sold,
  'mock-incoming': mockMercariProducts.incoming,
  'mock-ask-to-buy': mockMercariProducts.askToBuy,
  'mock-discontinued': mockMercariProducts.discontinued,
  'mock-unavailable': mockMercariProducts.unavailable,
  // Inline override: incoming + both buttons enabled; used by IncomingWithButtons variant
  'mock-incoming-with-buttons': {
    ...mockMercariProducts.incoming,
    showNotifyMe: true,
    showReservation: true,
  },
};

interface ArticleNavItemProps {
  product: PurchaseNavProduct;
}

const ArticleNavItem = ({ product }: ArticleNavItemProps) => {
  const { name, subtitle, detailHref, brandName } = product;
  return detailHref ? (
    <li>
      <a href={detailHref}>{formatProductFullTitle(brandName, name, subtitle)}紹介</a>
    </li>
  ) : null;
};

interface PurchaseNavStorybookProps {
  ids?: string[];
  id?: string | null;
  addArticleNav?: boolean;
}

/** Demo wrapper that renders PurchaseItems with mock product data. */
const PurchaseNavStorybook = ({
  ids = [],
  id = null,
  addArticleNav = false,
}: PurchaseNavStorybookProps) => {
  const productIds = id ? [...ids, id] : ids;

  return (
    <>
      <div className="pb-vgap-lg">
        <div className={'border-b-2 border-zd-white flex flex-col gap-vgap-sm pb-vgap-sm'}>
          {productIds.map((productId) => {
            const product = mockProducts[productId];
            if (!product) {
              return (
                <div key={productId} className="text-red-500">
                  Product not found: {productId}
                </div>
              );
            }
            return (
              <div key={productId} className={'border-t-2 border-zd-white pt-vgap-sm'}>
                <PurchaseItem product={product} />
              </div>
            );
          })}
        </div>
      </div>
      {addArticleNav && (
        <div className="pb-vgap-sm">
          <ul>
            {productIds.map((productId) => {
              const product = mockProducts[productId];
              return product ? <ArticleNavItem product={product} key={productId} /> : null;
            })}
          </ul>
        </div>
      )}
    </>
  );
};

export const meta = {
  title: 'Article/PurchaseNav',
};

/**
 * Live controls: tweak status and flags to explore all prop combinations.
 *
 * Keys:
 * - status: one of available|sold|incoming|askToBuy|discontinued|unavailable
 * - showNotifyMe: boolean — shows "入荷通知を受け取る" ActionButton (incoming)
 * - showReservation: boolean — shows "予約する" ActionButton (incoming)
 * - price: number — product price in yen
 * - subtitle: text — optional product subtitle
 */
export const controls: ControlsMap = {
  status: {
    type: 'select',
    default: 'incoming',
    // 'available' is the derived state (mercariStatus undefined); the rest come from MERCARI_STATUS_VALUES
    options: ['available', ...MERCARI_STATUS_VALUES],
  },
  showNotifyMe: { type: 'boolean', default: true },
  showReservation: { type: 'boolean', default: true },
  price: { type: 'number', default: 153800, min: 0, step: 100 },
  subtitle: { type: 'text', default: 'Demo subtitle' },
};

type DefaultArgs = {
  status: MercariStatus | 'available';
  showNotifyMe: boolean;
  showReservation: boolean;
  price: number;
  subtitle: string;
};

/**
 * Interactive default variant — adjust the controls panel to see how each prop
 * combination renders. (Named `Default` to match the repo-wide controls-story
 * convention; the controls apply to every variant in this file.)
 *
 * Props in play: status, showNotifyMe, showReservation, price, subtitle.
 */
export const Default = {
  args: {
    status: 'incoming',
    showNotifyMe: true,
    showReservation: true,
    price: 153800,
    subtitle: 'Demo subtitle',
  },
  render: ({ status, showNotifyMe, showReservation, price, subtitle }: DefaultArgs) => {
    const product = createMockProduct({
      slug: 'playground-product',
      name: 'Playground Product',
      subtitle,
      price,
      // 'available' is not a MercariStatus — it maps to undefined (PurchaseItem derives it)
      mercariStatus: status === 'available' ? undefined : status,
      mercariProductId: 'mock-mercari-id-playground',
      detailHref: '/products/playground-intro/',
      showNotifyMe,
      showReservation,
    });
    return <PurchaseItem product={product} />;
  },
};

/**
 * Available Status (Default)
 *
 * Default state when product is available for purchase on Mercari.
 * Shows Mercari link with product name and image.
 *
 * Key props: mercariStatus=undefined (treated as available), price=153800,
 * showNotifyMe=undefined, showReservation=undefined.
 *
 * Virtual slug: mock-available
 */
export const Available = () => <PurchaseNavStorybook id="mock-available" />;

/**
 * Sold Status
 *
 * When product is sold out (mercariStatus: 'sold').
 * Links to Mercari product page and shows:
 * - "メルカリShops: Takazudo Modularをチェック!"
 * - Product name with "現在売り切れ中"
 * - Message: "メルカリShopsで入荷通知の設定をご利用いただけます。"
 *
 * Key props: mercariStatus='sold', price=89000, showNotifyMe=undefined, showReservation=undefined.
 *
 * Virtual slug: mock-sold
 */
export const SoldOut = () => <PurchaseNavStorybook id="mock-sold" />;

/**
 * Incoming Status
 *
 * When product is coming soon (mercariStatus: 'incoming').
 * Links to contact page and shows:
 * - "Coming soon..."
 * - "近日入荷予定" (no flags set — see IncomingWithButtons for the both-buttons message)
 * - Link text: "お問い合わせ"
 *
 * Key props: mercariStatus='incoming', price=45000, showNotifyMe=undefined, showReservation=undefined.
 * See IncomingWithButtons for the variant with both action buttons enabled.
 *
 * Virtual slug: mock-incoming
 */
export const Incoming = () => <PurchaseNavStorybook id="mock-incoming" />;

/**
 * Incoming Status with Both Action Buttons
 *
 * Extends the Incoming variant to show both optional action buttons.
 *
 * Key props: mercariStatus='incoming', price=45000, showNotifyMe=true, showReservation=true.
 * Inline override of mockMercariProducts.incoming — does not mutate __mocks__/products.ts.
 *
 * Virtual slug: mock-incoming-with-buttons
 */
export const IncomingWithButtons = () => <PurchaseNavStorybook id="mock-incoming-with-buttons" />;

/**
 * Ask to Buy Status
 *
 * When product requires manufacturer consultation (mercariStatus: 'askToBuy').
 * Links to contact page and shows:
 * - "Ask to Buy!"
 * - "要メーカー問い合わせ製品です。ご検討の際にはまずご相談ください。"
 * - Link text: "お問い合わせ"
 *
 * Key props: mercariStatus='askToBuy', price=275000, showNotifyMe=undefined, showReservation=undefined.
 *
 * Virtual slug: mock-ask-to-buy
 */
export const AskToBuy = () => <PurchaseNavStorybook id="mock-ask-to-buy" />;

/**
 * Discontinued Status
 *
 * When product is discontinued (mercariStatus: 'discontinued').
 * Non-clickable div showing:
 * - "こちらの商品は製造終了品になります"
 *
 * Key props: mercariStatus='discontinued', price=32000, showNotifyMe=undefined, showReservation=undefined.
 *
 * Virtual slug: mock-discontinued
 */
export const Discontinued = () => <PurchaseNavStorybook id="mock-discontinued" />;

/**
 * Unavailable Status
 *
 * When product is no longer carried (mercariStatus: 'unavailable').
 * Non-clickable div showing:
 * - "こちらの商品は現在当店で取り扱っていない商品になります"
 *
 * Key props: mercariStatus='unavailable', price=28000, showNotifyMe=undefined, showReservation=undefined.
 *
 * Virtual slug: mock-unavailable
 */
export const Unavailable = () => <PurchaseNavStorybook id="mock-unavailable" />;

/**
 * Multiple Products
 *
 * Shows multiple products with different statuses in a single view.
 * Useful for comparing different states side by side.
 *
 * Displays all 6 virtual status variations together.
 */
export const MultipleProducts = () => (
  <PurchaseNavStorybook
    ids={[
      'mock-available',
      'mock-sold',
      'mock-incoming',
      'mock-ask-to-buy',
      'mock-discontinued',
      'mock-unavailable',
    ]}
  />
);

/**
 * With Article Navigation
 *
 * Shows PurchaseNav with article navigation links enabled.
 * The addArticleNav prop adds links to product detail pages below the purchase cards.
 *
 * Virtual slug: mock-available
 */
export const WithArticleNavigation = () => (
  <PurchaseNavStorybook id="mock-available" addArticleNav />
);