nutritionFactsLayout.ts 8.27 KB
/**
 * US Nutrition Facts 面板布局(与 Web src/lib/nutritionFactsLayout.ts 保持一致)
 */
export type NutritionDivider = 'none' | 'thin' | 'double'

export type NutritionLayoutRowDef = {
  key: string
  label: string
  defaultUnit: string
  labelBold?: boolean
  indent?: boolean
  dividerAfter?: NutritionDivider
}

export const NUTRITION_FACTS_LAYOUT_ROWS: readonly NutritionLayoutRowDef[] = [
  { key: 'fat', label: 'Total Fat', defaultUnit: 'g', labelBold: true, dividerAfter: 'double' },
  { key: 'transFat', label: 'Trans Fat', defaultUnit: 'g', dividerAfter: 'thin' },
  { key: 'cholesterol', label: 'Cholesterol', defaultUnit: 'mg', labelBold: true, dividerAfter: 'double' },
  { key: 'sodium', label: 'Sodium', defaultUnit: 'mg', labelBold: true, dividerAfter: 'double' },
  { key: 'carbs', label: 'Total Carbo.', defaultUnit: 'g', labelBold: true, dividerAfter: 'double' },
  { key: 'totalSugar', label: 'Sugars', defaultUnit: 'g', dividerAfter: 'thin' },
  { key: 'dietaryFiber', label: 'Dietary Fiber', defaultUnit: 'g', dividerAfter: 'thin' },
  { key: 'protein', label: 'Protein', defaultUnit: 'g', labelBold: true, dividerAfter: 'double' },
  { key: 'calcium', label: 'Calcium', defaultUnit: 'mg', dividerAfter: 'thin' },
  { key: 'potassium', label: 'Potassium', defaultUnit: 'mg', dividerAfter: 'thin' },
  { key: 'vitaminA', label: 'Vitamin A', defaultUnit: 'mg', dividerAfter: 'thin' },
  { key: 'vitaminD', label: 'Vitamin D', defaultUnit: 'mg', dividerAfter: 'thin' },
  { key: 'iron', label: 'Iron', defaultUnit: 'mg', dividerAfter: 'none' },
] as const

export const DEFAULT_NUTRITION_FOOTER_NOTE =
  '* Percent Daily Values are based on a 2000 calorie diet'

/** 与 Web src/lib/nutritionFactsLayout.ts 一致 */
export const NUTRITION_AMOUNT_COL_WIDTH = 64
export const NUTRITION_PCT_COL_WIDTH = 44
export const NUTRITION_BODY_FONT_SIZE = 12

export type NutritionFactsRowView = {
  key: string
  label: string
  amountText: string
  dailyValueText: string
  labelBold: boolean
  indent: boolean
  dividerAfter: NutritionDivider
}

export type NutritionFactsViewModel = {
  titleFontSize: number
  servingsLabel: string
  servingsValue: string
  servingSizeLabel: string
  servingSizeValue: string
  caloriesLabel: string
  caloriesValue: string
  caloriesAmountText: string
  rows: NutritionFactsRowView[]
  footerNote: string
  ingredientsText: string
}

function cfgStr(cfg: Record<string, unknown>, keys: string[], fallback = ''): string {
  for (const k of keys) {
    const v = cfg[k]
    if (v != null && String(v).trim() !== '') return String(v).trim()
  }
  return fallback
}

function cfgBool(cfg: Record<string, unknown>, keys: string[]): boolean {
  for (const k of keys) {
    const v = cfg[k]
    if (v === true || v === 'true' || v === 1 || v === '1') return true
    if (v === false || v === 'false' || v === 0 || v === '0') return false
  }
  return false
}

function fixedRows(cfg: Record<string, unknown>): Record<string, unknown>[] {
  return Array.isArray(cfg.fixedNutrients) ? (cfg.fixedNutrients as Record<string, unknown>[]) : []
}

function rowFromFixed(cfg: Record<string, unknown>, key: string): Record<string, unknown> | undefined {
  return fixedRows(cfg).find((r) => String(r.key ?? '').trim() === key)
}

export function readNutritionLessThan(cfg: Record<string, unknown>, key: string): boolean {
  const row = rowFromFixed(cfg, key)
  if (row && row.lessThan != null) return cfgBool({ lessThan: row.lessThan }, ['lessThan'])
  return cfgBool(cfg, [`${key}LessThan`, `${key}UseLessThan`])
}

export function nutritionFixedField(
  cfg: Record<string, unknown>,
  key: string,
  field: 'value' | 'unit' | 'dailyValuePercent',
): string {
  const row = rowFromFixed(cfg, key)
  if (field === 'dailyValuePercent') {
    const fromRow = row?.dailyValuePercent ?? row?.percent ?? row?.Percent
    if (fromRow != null && String(fromRow).trim() !== '') return String(fromRow).trim()
    return cfgStr(cfg, [`${key}Percent`, `${key}DailyValue`, `${key}DailyValuePercent`], '')
  }
  if (field === 'unit') {
    const fromRow = row?.unit
    if (fromRow != null && String(fromRow).trim() !== '') return String(fromRow).trim()
    const def = NUTRITION_FACTS_LAYOUT_ROWS.find((r) => r.key === key)
    return cfgStr(cfg, [`${key}Unit`], def?.defaultUnit ?? '')
  }
  const fromRow = row?.value
  if (fromRow != null && String(fromRow).trim() !== '') return String(fromRow).trim()
  return cfgStr(cfg, [key, key.charAt(0).toUpperCase() + key.slice(1)], '')
}

export function formatNutritionAmount(
  value: string,
  _unit?: string,
  lessThan?: boolean,
): string {
  const v = String(value ?? '').trim()
  if (!v) return ''
  const prefix = lessThan ? '<' : ''
  return `${prefix}${v}`
}

export function formatNutritionDailyValue(raw: string): string {
  const v = String(raw ?? '').trim()
  if (!v) return ''
  return v.endsWith('%') ? v : `${v}%`
}

function nutritionExtraRows(cfg: Record<string, unknown>): Array<{ id: string; name: string; value: string; unit: string }> {
  const raw = cfg.extraNutrients
  if (!Array.isArray(raw)) return []
  return raw.map((item, idx) => {
    const row = item as Record<string, unknown>
    return {
      id: String(row.id ?? `extra-${idx}`),
      name: String(row.name ?? ''),
      value: String(row.value ?? ''),
      unit: String(row.unit ?? ''),
    }
  })
}

export function buildNutritionFactsViewModel(cfg: Record<string, unknown>): NutritionFactsViewModel {
  const titleFontSize = Number(cfg.nutritionTitleFontSize ?? cfg.NutritionTitleFontSize ?? 16) || 16
  const servingsValue = cfgStr(cfg, ['servings', 'servingsPerContainer', 'ServingsPerContainer'])
  const servingSizeValue = cfgStr(cfg, ['servingSize', 'ServingSize'])
  const caloriesRaw = nutritionFixedField(cfg, 'calories', 'value') || cfgStr(cfg, ['calories', 'Calories'])
  const caloriesLessThan = readNutritionLessThan(cfg, 'calories')
  const layoutByKey = new Map(NUTRITION_FACTS_LAYOUT_ROWS.map((r) => [r.key, r]))
  const rows: NutritionFactsRowView[] = []
  const seen = new Set<string>()

  for (const def of NUTRITION_FACTS_LAYOUT_ROWS) {
    seen.add(def.key)
    const value = nutritionFixedField(cfg, def.key, 'value')
    const lessThan = readNutritionLessThan(cfg, def.key)
    const pct = nutritionFixedField(cfg, def.key, 'dailyValuePercent')
    rows.push({
      key: def.key,
      label: String(rowFromFixed(cfg, def.key)?.label ?? def.label),
      amountText: formatNutritionAmount(value, '', lessThan),
      dailyValueText: formatNutritionDailyValue(pct),
      labelBold: def.labelBold ?? false,
      indent: def.indent ?? false,
      dividerAfter: def.dividerAfter ?? 'none',
    })
  }

  for (const ex of nutritionExtraRows(cfg)) {
    const key = `extra:${ex.id}`
    if (seen.has(key)) continue
    const lessThan = cfgBool(cfg, [`extra:${ex.id}:lessThan`])
    rows.push({
      key,
      label: ex.name.trim() || 'Other',
      amountText: formatNutritionAmount(ex.value, '', lessThan),
      dailyValueText: formatNutritionDailyValue(
        cfgStr(cfg, [`extra:${ex.id}:percent`, `extra:${ex.id}:dailyValuePercent`]),
      ),
      labelBold: false,
      indent: false,
      dividerAfter: 'thin',
    })
  }

  for (const fr of fixedRows(cfg)) {
    const key = String(fr.key ?? '').trim()
    if (!key || seen.has(key)) continue
    const def = layoutByKey.get(key)
    const value = String(fr.value ?? '').trim()
    const lessThan = cfgBool({ lessThan: fr.lessThan }, ['lessThan'])
    rows.push({
      key,
      label: String(fr.label ?? def?.label ?? key),
      amountText: formatNutritionAmount(value, '', lessThan),
      dailyValueText: formatNutritionDailyValue(String(fr.dailyValuePercent ?? fr.percent ?? '')),
      labelBold: def?.labelBold ?? false,
      indent: def?.indent ?? false,
      dividerAfter: def?.dividerAfter ?? 'thin',
    })
  }

  return {
    titleFontSize,
    servingsLabel: cfgStr(cfg, ['servingsLabel'], 'Servings'),
    servingsValue,
    servingSizeLabel: cfgStr(cfg, ['servingSizeLabel'], 'Serve size'),
    servingSizeValue,
    caloriesLabel: cfgStr(cfg, ['caloriesLabel'], 'Calories'),
    caloriesValue: caloriesRaw,
    caloriesAmountText: formatNutritionAmount(caloriesRaw, '', caloriesLessThan),
    rows,
    footerNote: cfgStr(cfg, ['nutritionFooterNote', 'footerNote'], DEFAULT_NUTRITION_FOOTER_NOTE),
    ingredientsText: cfgStr(cfg, ['ingredientsText', 'ingredients', 'IngredientsText']),
  }
}