NutritionFactsPanel.tsx 5.34 KB
import React from 'react';
import { cn } from '../../ui/utils';
import {
  buildNutritionFactsViewModel,
  DEFAULT_NUTRITION_FOOTER_NOTE,
  NUTRITION_AMOUNT_COL_WIDTH,
  NUTRITION_BODY_FONT_SIZE,
  NUTRITION_PCT_COL_WIDTH,
  type NutritionDivider,
  type NutritionFactsViewModel,
} from '../../../lib/nutritionFactsLayout';

/** 双下划线两线之间的间距(px) */
const DOUBLE_LINE_GAP_PX = 3;

/** FreightSans 仅有 Bold 字重;正文区改用常规字体以保证 12px 非加粗展示 */
function nutritionPanelBodyFontFamily(configured: string): string {
  const lower = String(configured ?? '').toLowerCase();
  if (lower.includes('freightsans') || lower.includes('bold')) {
    return 'Roboto, Arial, sans-serif';
  }
  return configured || 'Roboto, Arial, sans-serif';
}

/** 单/双下划线:双线之间留空,不重叠 */
function NutritionDivider({ kind }: { kind: Exclude<NutritionDivider, 'none'> }) {
  if (kind === 'thin') {
    return <div className="w-full border-b border-black" aria-hidden />;
  }
  return (
    <div className="w-full pb-0.5" aria-hidden>
      <div className="w-full border-b border-black" />
      <div className="w-full border-b border-black" style={{ marginTop: `${DOUBLE_LINE_GAP_PX}px` }} />
    </div>
  );
}

export function NutritionFactsPanel({
  cfg,
  fontFamily,
  className,
}: {
  cfg: Record<string, unknown>;
  fontFamily: string;
  className?: string;
}) {
  const model = buildNutritionFactsViewModel(cfg);
  const bodyFont = nutritionPanelBodyFontFamily(fontFamily);
  const bodySize = NUTRITION_BODY_FONT_SIZE;
  const footerSize = Math.max(8, Math.round(bodySize * 0.67));

  return (
    <div
      className={cn(
        'w-full h-full overflow-hidden flex flex-col bg-white text-black px-1.5 py-0.5',
        className,
      )}
      style={{ fontFamily: bodyFont, fontWeight: 400, lineHeight: 1.2 }}
    >
      <div
        className="pb-0.5 leading-none tracking-tight"
        style={{
          fontSize: `${model.titleFontSize}px`,
          fontWeight: 700,
          fontFamily: fontFamily.includes('Freight') ? fontFamily : bodyFont,
        }}
      >
        Nutrition Facts
        <NutritionDivider kind="double" />
      </div>

      <HeaderRow label={model.servingsLabel} value={model.servingsValue} size={bodySize} divider="thin" />
      <HeaderRow
        label={model.servingSizeLabel}
        value={model.servingSizeValue}
        size={bodySize}
        divider="double"
      />

      <div className="py-[3px]" style={{ fontSize: `${bodySize}px`, fontWeight: 400 }}>
        <div className="flex items-center justify-between">
          <span style={{ fontWeight: 700 }}>{model.caloriesLabel}</span>
          <span className="tabular-nums whitespace-nowrap" style={{ fontWeight: 400 }}>
            {model.caloriesAmountText || model.caloriesValue}
          </span>
        </div>
        <NutritionDivider kind="double" />
      </div>

      <div className="flex-1 min-h-0 overflow-hidden">
        {model.rows.map((row) => (
          <NutrientRow key={row.key} row={row} bodySize={bodySize} />
        ))}
      </div>

      <p
        className="mt-1 pt-1 border-t border-black leading-[1.2]"
        style={{ fontSize: `${footerSize}px`, fontWeight: 400 }}
      >
        {DEFAULT_NUTRITION_FOOTER_NOTE.split(/(\b2000\b)/).map((part, i) =>
          part === '2000' ? (
            <span key={i} style={{ fontWeight: 700 }}>
              2000
            </span>
          ) : (
            part
          ),
        )}
      </p>
    </div>
  );
}

function NutrientRow({
  row,
  bodySize,
}: {
  row: {
    key: string;
    label: string;
    amountText: string;
    dailyValueText: string;
    labelBold: boolean;
    indent: boolean;
    dividerAfter: NutritionDivider;
  };
  bodySize: number;
}) {
  return (
    <div className="py-[3px]">
      <div
        className="flex flex-row flex-nowrap items-center gap-0.5 min-w-0 w-full"
        style={{ fontSize: `${bodySize}px`, fontWeight: 400 }}
      >
        <span
          className={cn(
            'flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-left',
            row.indent && 'pl-2.5',
          )}
          style={{ fontWeight: row.labelBold ? 700 : 400 }}
        >
          {row.label}
        </span>
        <span
          className="shrink-0 text-center tabular-nums whitespace-nowrap overflow-hidden text-ellipsis"
          style={{ width: NUTRITION_AMOUNT_COL_WIDTH, fontWeight: 400 }}
        >
          {row.amountText}
        </span>
        <span
          className="shrink-0 text-right tabular-nums whitespace-nowrap"
          style={{ width: NUTRITION_PCT_COL_WIDTH, fontWeight: 400 }}
        >
          {row.dailyValueText}
        </span>
      </div>
      {row.dividerAfter !== 'none' ? <NutritionDivider kind={row.dividerAfter} /> : null}
    </div>
  );
}

function HeaderRow({
  label,
  value,
  size,
  divider,
}: {
  label: string;
  value: string;
  size: number;
  divider: NutritionDivider;
}) {
  return (
    <div className="py-[3px]" style={{ fontSize: `${size}px`, fontWeight: 400 }}>
      <div className="flex items-center justify-between">
        <span>{label}</span>
        <span className="tabular-nums whitespace-nowrap">{value}</span>
      </div>
      {divider !== 'none' ? <NutritionDivider kind={divider} /> : null}
    </div>
  );
}

export { buildNutritionFactsViewModel, type NutritionFactsViewModel };