Article/PurchaseNav
Ask To Buy
Available
Default
Discontinued
Incoming
Incoming With Buttons
Multiple Products
Sold Out
Unavailable
With Article Navigation
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,
}),
};
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 />
);