Takazudo Modular Styleguide

MDX/Addac120sPriceTable

Default
English

Component Source

addac120s-price-table.tsx

import { productsMapping } from '@/lib/data/products-mapping';
import { computeBundleComponentSum } from '@/lib/data/products/compute-bundle-component-sum';
import { productDetailHref } from '@/lib/utils/product-detail-href';
import { Table } from '@components/article/table';
import { H2 } from '@components/article/h2';
import { H3 } from '@components/article/h3';
import { ADDAC120S_SERIES_ORDER } from '@data/addac120s-series-order.mjs';
import type { Locale } from '@/lib/i18n/types';

interface Addac120sPriceTableProps {
  locale?: Locale;
}

const HEADING = {
  ja: 'ADDAC120s Four Stringsシリーズ 価格一覧',
  en: 'ADDAC120s Four Strings Series — Price List',
} as const;

/**
 * Value message — the on-page "a bundle is the reasonable choice" guidance the
 * shop owner asked for. The bundle systems are priced below the sum of their
 * component modules; the dedicated bundle-discount table makes that explicit.
 */
const CAPTION = {
  ja: 'セット(システム)は、モジュールを単品でそろえるよりも割引価格でお得です。',
  en: 'The bundle systems cost less than buying the modules individually.',
} as const;

const GROUP = {
  ja: { modules: 'モジュール / フレーム', bundles: 'セット(システム)' },
  en: { modules: 'Modules / Frames', bundles: 'Bundle systems' },
} as const;

const MODULE_COL = {
  ja: { product: '商品名', price: '価格(税込)' },
  en: { product: 'Product', price: 'Price (incl. tax)' },
} as const;

const BUNDLE_COL = {
  ja: {
    product: '商品名',
    setPrice: 'セット価格(税込)',
    sum: '単価合計(税込)',
    discount: 'バンドルディスカウント',
  },
  en: {
    product: 'Product',
    setPrice: 'Set price (incl. tax)',
    sum: 'Total of unit prices (incl. tax)',
    discount: 'Bundle discount',
  },
} as const;

/**
 * Futura numerals — matches the price styling used across the site (PurchaseNav:
 * `non-clickable-status-item.tsx`, `purchase-nav/constants.ts`), which is
 * `font-futura` WITHOUT `font-bold`. Adding `font-bold` makes the numerals fall
 * back off Futura to a generic bold sans (the `Futura` system face the stack
 * resolves to has no usable 700 weight), so keep every price cell non-bold.
 */
const PRICE_CLASS = 'font-futura whitespace-nowrap';
/** Bundle-discount accent (orange `zd-strong`) — same token discount-note.tsx uses. */
const DISCOUNT_CLASS = 'font-futura whitespace-nowrap text-zd-strong';

function yen(n: number): string {
  return `¥${n.toLocaleString()}`;
}

interface BundleEconomics {
  referencePrice: number;
  savings: number;
  discountPercent: number;
}

/**
 * Mirror of the PurchaseNav bundle-discount derivation
 * (src/lib/resolve-purchase-nav-client-props.ts): referencePrice = Σ parts,
 * savings = Σ − price, discountPercent = round((Σ − price)/Σ * 1000)/10.
 * Returns null unless the component sum is fully resolved and exceeds the price
 * — so a row never shows a discount figure it can't back up.
 */
function bundleEconomics(componentSlugs: string[], price: number): BundleEconomics | null {
  const { sum, allPriced } = computeBundleComponentSum(componentSlugs);
  if (!allPriced || sum <= price) return null;
  return {
    referencePrice: sum,
    savings: sum - price,
    discountPercent: Math.round(((sum - price) / sum) * 1000) / 10,
  };
}

function NameCell({ slug, locale }: { slug: string; locale: Locale }) {
  const product = productsMapping[slug];
  if (!product) return null;
  const href = productDetailHref(product.detailHref, locale);
  const label = (
    <>
      <span className="font-bold">{product.name}</span>
      {product.subtitle ? <span> {product.subtitle}</span> : null}
    </>
  );
  // addac901m (detailHref: null) and any untranslated EN page resolve to null → plain text, no 404 link.
  return href ? (
    <a href={href} className="underline">
      {label}
    </a>
  ) : (
    label
  );
}

/**
 * Shared, data-driven price list for the ADDAC120s "Four Strings" series.
 *
 * Renders the series as two tables, each in the canonical order (modules/frames
 * by module number, then bundle systems by price ascending — see
 * `src/data/addac120s-series-order.mjs`):
 *   1. Modules / Frames — name + plain price.
 *   2. Bundle systems — a bundle-discount table with the set price, the Σ-parts
 *      unit total, and the savings (バンドルディスカウント) in the accent color,
 *      making each bundle's value explicit.
 *
 * Prices render in the Futura style used by the PurchaseNav UI.
 *
 * Plain SSR component (no island wrapper) — safe to import productsMapping /
 * computeBundleComponentSum directly.
 */
export function Addac120sPriceTable({ locale = 'ja' }: Addac120sPriceTableProps) {
  const group = GROUP[locale];
  const moduleCol = MODULE_COL[locale];
  const bundleCol = BUNDLE_COL[locale];

  const moduleSlugs: string[] = [];
  const bundleSlugs: string[] = [];
  for (const slug of ADDAC120S_SERIES_ORDER) {
    const product = productsMapping[slug];
    if (!product) continue;
    if (product.bundle != null) {
      bundleSlugs.push(slug);
    } else {
      moduleSlugs.push(slug);
    }
  }

  return (
    <section className="mb-vgap-md">
      <H2>{HEADING[locale]}</H2>
      <p className="mb-vgap-sm text-sm">{CAPTION[locale]}</p>

      <H3>{group.modules}</H3>
      <Table className="mb-vgap-md">
        <thead>
          <tr>
            <th className="text-left">{moduleCol.product}</th>
            <th className="text-left">{moduleCol.price}</th>
          </tr>
        </thead>
        <tbody>
          {moduleSlugs.map((slug) => {
            const product = productsMapping[slug];
            const price = typeof product.price === 'number' ? product.price : null;
            return (
              <tr key={slug}>
                <td>
                  <NameCell slug={slug} locale={locale} />
                </td>
                <td className={PRICE_CLASS}>{price != null ? yen(price) : '—'}</td>
              </tr>
            );
          })}
        </tbody>
      </Table>

      <H3>{group.bundles}</H3>
      <Table>
        <thead>
          <tr>
            <th className="text-left">{bundleCol.product}</th>
            <th className="text-left">{bundleCol.setPrice}</th>
            <th className="text-left">{bundleCol.sum}</th>
            <th className="text-left text-zd-strong">{bundleCol.discount}</th>
          </tr>
        </thead>
        <tbody>
          {bundleSlugs.map((slug) => {
            const product = productsMapping[slug];
            const price = typeof product.price === 'number' ? product.price : null;
            const econ =
              price != null && product.bundle != null
                ? bundleEconomics(product.bundle.componentSlugs, price)
                : null;
            return (
              <tr key={slug}>
                <td>
                  <NameCell slug={slug} locale={locale} />
                </td>
                <td className={PRICE_CLASS}>{price != null ? yen(price) : '—'}</td>
                <td className={PRICE_CLASS}>{econ ? yen(econ.referencePrice) : '—'}</td>
                <td className={DISCOUNT_CLASS}>{econ ? yen(econ.savings) : '—'}</td>
              </tr>
            );
          })}
        </tbody>
      </Table>
    </section>
  );
}

Story Source

addac120s-price-table.stories.tsx

import { Addac120sPriceTable } from './addac120s-price-table';

/**
 * Addac120sPriceTable
 *
 * Shared, data-driven price list for the ADDAC120s "Four Strings" series,
 * rendered as two tables: a Modules/Frames table (name + plain price) and a
 * bundle-discount table (set price, Σ-parts unit total, and the savings —
 * バンドルディスカウント — in the accent color). Prices use the site's Futura
 * style. Rows follow the canonical order (modules by №, then bundles by price ↑).
 */
export const meta = {
  title: 'MDX/Addac120sPriceTable',
};

/** Japanese (default) */
export const Default = () => <Addac120sPriceTable locale="ja" />;

/** English */
export const English = () => <Addac120sPriceTable locale="en" />;