NutritionFactsPanel.tsx
5.34 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
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 };