create-brainerce-store 1.33.2 → 1.34.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +2 -2
- package/package.json +1 -1
- package/templates/nextjs/base/.env.local.ejs +4 -1
- package/templates/nextjs/base/scripts/fetch-store-info.mjs +10 -4
- package/templates/nextjs/base/src/app/api/auth/me/route.ts +4 -1
- package/templates/nextjs/base/src/app/api/auth/reset-password/route.ts +4 -1
- package/templates/nextjs/base/src/app/contact/page.tsx +528 -528
- package/templates/nextjs/base/src/app/products/[slug]/product-client-section.tsx +66 -1
- package/templates/nextjs/base/src/components/cart/cart-bundle-offer.tsx +60 -195
- package/templates/nextjs/base/src/components/products/allergen-chips.tsx +78 -0
- package/templates/nextjs/base/src/components/products/modifier-group-selector.tsx +242 -0
- package/templates/nextjs/base/src/lib/auth.ts +6 -1
- package/templates/nextjs/base/src/lib/brainerce.ts.ejs +8 -3
- package/templates/nextjs/base/src/lib/safe-redirect.ts +38 -60
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
ProductImage,
|
|
9
9
|
ProductMetafield,
|
|
10
10
|
DownloadFile,
|
|
11
|
+
ModifierGroup,
|
|
11
12
|
} from 'brainerce';
|
|
12
13
|
import { getProductPriceInfo, getDescriptionContent } from 'brainerce';
|
|
13
14
|
import { useCart } from '@/providers/store-provider';
|
|
@@ -22,6 +23,12 @@ import {
|
|
|
22
23
|
validateCustomization,
|
|
23
24
|
type CustomizationValues,
|
|
24
25
|
} from '@/components/products/customization-fields';
|
|
26
|
+
import {
|
|
27
|
+
ModifierGroupSelector,
|
|
28
|
+
buildInitialSelections,
|
|
29
|
+
toModifierSelections,
|
|
30
|
+
validateSelections,
|
|
31
|
+
} from '@/components/products/modifier-group-selector';
|
|
25
32
|
import { useTranslations } from '@/lib/translations';
|
|
26
33
|
import { cn } from '@/lib/utils';
|
|
27
34
|
import { sanitizeProductHtml } from '@/lib/sanitize-html';
|
|
@@ -138,6 +145,16 @@ export function ProductClientSection({ product: initialProduct }: ProductClientS
|
|
|
138
145
|
});
|
|
139
146
|
const [customizationErrors, setCustomizationErrors] = useState<Record<string, string>>({});
|
|
140
147
|
|
|
148
|
+
// Modifier groups (PRD §8.4) — only present on restaurant / build-your-own products.
|
|
149
|
+
const modifierGroups: ModifierGroup[] = useMemo(
|
|
150
|
+
() => (product as Product & { modifierGroups?: ModifierGroup[] }).modifierGroups ?? [],
|
|
151
|
+
[product]
|
|
152
|
+
);
|
|
153
|
+
const [modifierSelections, setModifierSelections] = useState<Record<string, string[]>>(() =>
|
|
154
|
+
buildInitialSelections(modifierGroups)
|
|
155
|
+
);
|
|
156
|
+
const [modifierError, setModifierError] = useState<string | null>(null);
|
|
157
|
+
|
|
141
158
|
// Images list - switch main image when variant changes
|
|
142
159
|
const images: ProductImage[] = useMemo(() => {
|
|
143
160
|
return product?.images || [];
|
|
@@ -227,6 +244,24 @@ export function ProductClientSection({ product: initialProduct }: ProductClientS
|
|
|
227
244
|
}
|
|
228
245
|
setCustomizationErrors({});
|
|
229
246
|
|
|
247
|
+
// Client-side modifier validation mirrors the server's checks. The server
|
|
248
|
+
// is authoritative — `MODIFIER_VALIDATION_FAILED` envelope on add-to-cart
|
|
249
|
+
// failure is the source of truth — but pre-flighting catches the obvious
|
|
250
|
+
// issues without a round-trip.
|
|
251
|
+
if (modifierGroups.length > 0) {
|
|
252
|
+
const error = validateSelections(modifierGroups, modifierSelections);
|
|
253
|
+
if (error) {
|
|
254
|
+
setModifierError(error);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
setModifierError(null);
|
|
259
|
+
|
|
260
|
+
const selections =
|
|
261
|
+
modifierGroups.length > 0
|
|
262
|
+
? toModifierSelections(modifierGroups, modifierSelections)
|
|
263
|
+
: undefined;
|
|
264
|
+
|
|
230
265
|
try {
|
|
231
266
|
setAddingToCart(true);
|
|
232
267
|
const { getClient } = await import('@/lib/brainerce');
|
|
@@ -239,12 +274,20 @@ export function ProductClientSection({ product: initialProduct }: ProductClientS
|
|
|
239
274
|
customizationFields.length > 0 && Object.keys(customizationValues).length > 0
|
|
240
275
|
? customizationValues
|
|
241
276
|
: undefined,
|
|
277
|
+
...(selections && selections.length > 0 ? { selections } : {}),
|
|
242
278
|
});
|
|
243
279
|
await refreshCart();
|
|
244
280
|
setAddedMessage(true);
|
|
245
281
|
setTimeout(() => setAddedMessage(false), 2000);
|
|
246
282
|
} catch (err) {
|
|
247
|
-
|
|
283
|
+
// Surface the structured `MODIFIER_VALIDATION_FAILED` envelope when present.
|
|
284
|
+
const e = err as { details?: { code?: string; errors?: Array<{ message: string }> } };
|
|
285
|
+
const validationErrors = e?.details?.errors;
|
|
286
|
+
if (e?.details?.code === 'MODIFIER_VALIDATION_FAILED' && validationErrors?.length) {
|
|
287
|
+
setModifierError(validationErrors.map((v) => v.message).join('; '));
|
|
288
|
+
} else {
|
|
289
|
+
console.error('Failed to add to cart:', err);
|
|
290
|
+
}
|
|
248
291
|
} finally {
|
|
249
292
|
setAddingToCart(false);
|
|
250
293
|
}
|
|
@@ -439,6 +482,28 @@ export function ProductClientSection({ product: initialProduct }: ProductClientS
|
|
|
439
482
|
/>
|
|
440
483
|
)}
|
|
441
484
|
|
|
485
|
+
{/* Modifier groups (toppings, sauce, bread type, …) */}
|
|
486
|
+
{modifierGroups.length > 0 && (
|
|
487
|
+
<div className="space-y-4">
|
|
488
|
+
{modifierGroups.map((group) => (
|
|
489
|
+
<ModifierGroupSelector
|
|
490
|
+
key={group.attachmentId ?? group.id}
|
|
491
|
+
group={group}
|
|
492
|
+
value={modifierSelections[group.id] ?? []}
|
|
493
|
+
onChange={(next) =>
|
|
494
|
+
setModifierSelections((prev) => ({ ...prev, [group.id]: next }))
|
|
495
|
+
}
|
|
496
|
+
disabled={addingToCart}
|
|
497
|
+
/>
|
|
498
|
+
))}
|
|
499
|
+
{modifierError && (
|
|
500
|
+
<p className="text-destructive text-sm" role="alert">
|
|
501
|
+
{modifierError}
|
|
502
|
+
</p>
|
|
503
|
+
)}
|
|
504
|
+
</div>
|
|
505
|
+
)}
|
|
506
|
+
|
|
442
507
|
{/* Quantity + Add to Cart */}
|
|
443
508
|
<div className="flex items-center gap-4">
|
|
444
509
|
<div className="border-border flex items-center rounded border">
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState
|
|
3
|
+
import { useState } from 'react';
|
|
4
4
|
import Image from 'next/image';
|
|
5
5
|
import type { CartBundleOffer as CartBundleOfferType } from 'brainerce';
|
|
6
|
-
import { formatPrice
|
|
6
|
+
import { formatPrice } from 'brainerce';
|
|
7
7
|
import { useStoreInfo } from '@/providers/store-provider';
|
|
8
8
|
import { useTranslations } from '@/lib/translations';
|
|
9
9
|
import { cn } from '@/lib/utils';
|
|
@@ -20,117 +20,28 @@ export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBun
|
|
|
20
20
|
const t = useTranslations('cart');
|
|
21
21
|
const currency = storeInfo?.currency || 'USD';
|
|
22
22
|
const [adding, setAdding] = useState(false);
|
|
23
|
-
const [selectedVariantId, setSelectedVariantId] = useState<string | null>(null);
|
|
24
23
|
|
|
25
|
-
const
|
|
26
|
-
const variants = product.variants;
|
|
27
|
-
const requiresSelection = offer.requiresVariantSelection && variants && variants.length > 0;
|
|
24
|
+
const offered = offer.offeredProducts;
|
|
28
25
|
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
for (const opt of opts) {
|
|
36
|
-
if (!groups.has(opt.name)) groups.set(opt.name, new Set());
|
|
37
|
-
groups.get(opt.name)!.add(opt.value);
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
return Array.from(groups.entries()).map(([name, values]) => ({
|
|
41
|
-
name,
|
|
42
|
-
values: Array.from(values),
|
|
43
|
-
}));
|
|
44
|
-
}, [requiresSelection, variants]);
|
|
45
|
-
|
|
46
|
-
const [selectedAttrs, setSelectedAttrs] = useState<Record<string, string>>({});
|
|
47
|
-
|
|
48
|
-
const selectedVariant = useMemo(() => {
|
|
49
|
-
if (!requiresSelection || !variants) return null;
|
|
50
|
-
return (
|
|
51
|
-
variants.find((v) => {
|
|
52
|
-
const opts = getVariantOptions(v as any);
|
|
53
|
-
return attributeGroups.every((group) => {
|
|
54
|
-
const opt = opts.find((o) => o.name === group.name);
|
|
55
|
-
return opt && selectedAttrs[group.name] === opt.value;
|
|
56
|
-
});
|
|
57
|
-
}) ?? null
|
|
58
|
-
);
|
|
59
|
-
}, [requiresSelection, variants, selectedAttrs, attributeGroups]);
|
|
60
|
-
|
|
61
|
-
const effectiveVariantId = selectedVariant?.id ?? selectedVariantId;
|
|
62
|
-
|
|
63
|
-
function handleAttrSelect(attrName: string, value: string) {
|
|
64
|
-
const next = { ...selectedAttrs, [attrName]: value };
|
|
65
|
-
setSelectedAttrs(next);
|
|
66
|
-
if (variants) {
|
|
67
|
-
const match = variants.find((v) => {
|
|
68
|
-
const opts = getVariantOptions(v as any);
|
|
69
|
-
return attributeGroups.every((group) => {
|
|
70
|
-
const opt = opts.find((o) => o.name === group.name);
|
|
71
|
-
return opt && next[group.name] === opt.value;
|
|
72
|
-
});
|
|
73
|
-
});
|
|
74
|
-
setSelectedVariantId(match?.id ?? null);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// Compute display prices
|
|
79
|
-
const { displayOriginal, displayDiscounted, discountLabel } = useMemo(() => {
|
|
80
|
-
let effectivePrice: number;
|
|
81
|
-
if (selectedVariant) {
|
|
82
|
-
const vSale = selectedVariant.salePrice ? parseFloat(selectedVariant.salePrice) : null;
|
|
83
|
-
const vPrice = selectedVariant.price ? parseFloat(selectedVariant.price) : null;
|
|
84
|
-
effectivePrice = vSale ?? vPrice ?? parseFloat(offer.originalPrice);
|
|
85
|
-
} else {
|
|
86
|
-
effectivePrice = parseFloat(offer.originalPrice);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
let discounted: number;
|
|
90
|
-
if (offer.discountType === 'PERCENTAGE') {
|
|
91
|
-
discounted = effectivePrice * (1 - parseFloat(offer.discountValue) / 100);
|
|
92
|
-
} else {
|
|
93
|
-
discounted = Math.max(0, effectivePrice - parseFloat(offer.discountValue));
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const label =
|
|
97
|
-
offer.discountType === 'PERCENTAGE'
|
|
98
|
-
? `${offer.discountValue}%`
|
|
99
|
-
: (formatPrice(parseFloat(offer.discountValue), { currency }) as string);
|
|
100
|
-
|
|
101
|
-
return { displayOriginal: effectivePrice, displayDiscounted: discounted, discountLabel: label };
|
|
102
|
-
}, [selectedVariant, offer.originalPrice, offer.discountType, offer.discountValue, currency]);
|
|
103
|
-
|
|
104
|
-
const isOos =
|
|
105
|
-
selectedVariant?.inventory?.trackingMode !== 'NOT_TRACKED' &&
|
|
106
|
-
selectedVariant?.inventory?.available != null &&
|
|
107
|
-
selectedVariant.inventory.available <= 0;
|
|
108
|
-
|
|
109
|
-
const lockedLabel =
|
|
110
|
-
offer.lockedVariant?.name ??
|
|
111
|
-
(offer.lockedVariant?.attributes
|
|
112
|
-
? Object.values(offer.lockedVariant.attributes).join(' / ')
|
|
113
|
-
: null);
|
|
114
|
-
|
|
115
|
-
const firstImage = product.images?.[0];
|
|
116
|
-
const imageUrl = firstImage
|
|
117
|
-
? typeof firstImage === 'string'
|
|
118
|
-
? firstImage
|
|
119
|
-
: firstImage.url
|
|
120
|
-
: null;
|
|
121
|
-
|
|
122
|
-
const canAdd = !requiresSelection || !!effectiveVariantId;
|
|
26
|
+
const totalOriginal = parseFloat(offer.totalOriginalPrice);
|
|
27
|
+
const totalDiscounted = parseFloat(offer.totalDiscountedPrice);
|
|
28
|
+
const discountLabel =
|
|
29
|
+
offer.discountType === 'PERCENTAGE'
|
|
30
|
+
? `${offer.discountValue}%`
|
|
31
|
+
: (formatPrice(parseFloat(offer.discountValue), { currency }) as string);
|
|
123
32
|
|
|
124
33
|
async function handleAdd() {
|
|
125
|
-
if (adding
|
|
34
|
+
if (adding) return;
|
|
126
35
|
try {
|
|
127
36
|
setAdding(true);
|
|
128
37
|
const { getClient } = await import('@/lib/brainerce');
|
|
129
38
|
const client = getClient();
|
|
130
|
-
|
|
39
|
+
// No variant selections in the default template — products with
|
|
40
|
+
// variants are best handled with a dedicated picker per offered item.
|
|
41
|
+
await client.addBundleToCart(cartId, offer.id);
|
|
131
42
|
onAdd();
|
|
132
43
|
} catch (err) {
|
|
133
|
-
console.error('Failed to add bundle
|
|
44
|
+
console.error('Failed to add bundle:', err);
|
|
134
45
|
} finally {
|
|
135
46
|
setAdding(false);
|
|
136
47
|
}
|
|
@@ -138,110 +49,64 @@ export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBun
|
|
|
138
49
|
|
|
139
50
|
return (
|
|
140
51
|
<div className={cn('bg-background border-border rounded-lg border p-4', className)}>
|
|
141
|
-
<div className="
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
<div className="text-muted-foreground flex h-full w-full items-center justify-center">
|
|
148
|
-
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
149
|
-
<path
|
|
150
|
-
strokeLinecap="round"
|
|
151
|
-
strokeLinejoin="round"
|
|
152
|
-
strokeWidth={1.5}
|
|
153
|
-
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
|
154
|
-
/>
|
|
155
|
-
</svg>
|
|
156
|
-
</div>
|
|
157
|
-
)}
|
|
158
|
-
</div>
|
|
52
|
+
<div className="mb-3">
|
|
53
|
+
<p className="text-foreground text-sm font-medium">{offer.name}</p>
|
|
54
|
+
{offer.description && (
|
|
55
|
+
<p className="text-muted-foreground mt-0.5 text-xs">{offer.description}</p>
|
|
56
|
+
)}
|
|
57
|
+
</div>
|
|
159
58
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
59
|
+
<ul className="space-y-2">
|
|
60
|
+
{offered.map((p) => {
|
|
61
|
+
const firstImage = p.images?.[0];
|
|
62
|
+
const imageUrl = firstImage?.url ?? null;
|
|
63
|
+
return (
|
|
64
|
+
<li key={p.id} className="flex items-center gap-3">
|
|
65
|
+
<div className="bg-muted relative h-12 w-12 flex-shrink-0 overflow-hidden rounded">
|
|
66
|
+
{imageUrl ? (
|
|
67
|
+
<Image src={imageUrl} alt={p.name} fill sizes="48px" className="object-cover" />
|
|
68
|
+
) : null}
|
|
69
|
+
</div>
|
|
70
|
+
<div className="min-w-0 flex-1">
|
|
71
|
+
<p className="text-foreground truncate text-sm">{p.name}</p>
|
|
72
|
+
<div className="mt-0.5 flex items-center gap-2">
|
|
73
|
+
<span className="text-muted-foreground text-xs line-through">
|
|
74
|
+
{formatPrice(parseFloat(p.originalPrice), { currency }) as string}
|
|
75
|
+
</span>
|
|
76
|
+
<span className="text-foreground text-xs font-semibold">
|
|
77
|
+
{formatPrice(parseFloat(p.discountedPrice), { currency }) as string}
|
|
78
|
+
</span>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
</li>
|
|
82
|
+
);
|
|
83
|
+
})}
|
|
84
|
+
</ul>
|
|
85
|
+
|
|
86
|
+
<div className="border-border mt-3 flex items-center justify-between border-t pt-3">
|
|
87
|
+
<div className="flex items-center gap-2">
|
|
88
|
+
<span className="text-muted-foreground text-sm line-through">
|
|
89
|
+
{formatPrice(totalOriginal, { currency }) as string}
|
|
90
|
+
</span>
|
|
91
|
+
<span className="text-foreground text-sm font-semibold">
|
|
92
|
+
{formatPrice(totalDiscounted, { currency }) as string}
|
|
93
|
+
</span>
|
|
94
|
+
<span className="bg-destructive/10 text-destructive rounded px-1.5 py-0.5 text-xs font-medium">
|
|
95
|
+
-{discountLabel}
|
|
96
|
+
</span>
|
|
178
97
|
</div>
|
|
179
|
-
|
|
180
|
-
{/* Add button */}
|
|
181
98
|
<button
|
|
182
99
|
type="button"
|
|
183
100
|
onClick={handleAdd}
|
|
184
|
-
disabled={adding
|
|
101
|
+
disabled={adding}
|
|
185
102
|
className={cn(
|
|
186
103
|
'bg-primary text-primary-foreground flex-shrink-0 rounded px-4 py-2 text-xs font-medium transition-opacity hover:opacity-90',
|
|
187
104
|
'disabled:cursor-not-allowed disabled:opacity-50'
|
|
188
105
|
)}
|
|
189
106
|
>
|
|
190
|
-
{adding
|
|
191
|
-
? t('addingBundle')
|
|
192
|
-
: requiresSelection && !canAdd
|
|
193
|
-
? t('selectOptions') || 'Select options'
|
|
194
|
-
: t('addBundleItem')}
|
|
107
|
+
{adding ? t('addingBundle') : t('addBundleItem')}
|
|
195
108
|
</button>
|
|
196
109
|
</div>
|
|
197
|
-
|
|
198
|
-
{/* Compact variant selector */}
|
|
199
|
-
{requiresSelection && (
|
|
200
|
-
<div className="mt-3 space-y-1.5 ps-20">
|
|
201
|
-
{attributeGroups.map((group) => (
|
|
202
|
-
<div key={group.name} className="flex flex-wrap items-center gap-1.5">
|
|
203
|
-
<span className="text-muted-foreground text-xs">{group.name}:</span>
|
|
204
|
-
{group.values.map((value) => {
|
|
205
|
-
const isSelected = selectedAttrs[group.name] === value;
|
|
206
|
-
const variantForValue = variants?.find((v) => {
|
|
207
|
-
const opts = getVariantOptions(v as any);
|
|
208
|
-
const matchesValue = opts.some((o) => o.name === group.name && o.value === value);
|
|
209
|
-
if (!matchesValue) return false;
|
|
210
|
-
return Object.entries(selectedAttrs).every(([k, sv]) => {
|
|
211
|
-
if (k === group.name) return true;
|
|
212
|
-
return opts.some((o) => o.name === k && o.value === sv);
|
|
213
|
-
});
|
|
214
|
-
});
|
|
215
|
-
const isVariantOos =
|
|
216
|
-
variantForValue?.inventory?.trackingMode !== 'NOT_TRACKED' &&
|
|
217
|
-
variantForValue?.inventory?.available != null &&
|
|
218
|
-
variantForValue.inventory.available <= 0;
|
|
219
|
-
|
|
220
|
-
return (
|
|
221
|
-
<button
|
|
222
|
-
key={value}
|
|
223
|
-
type="button"
|
|
224
|
-
onClick={() => handleAttrSelect(group.name, value)}
|
|
225
|
-
disabled={isVariantOos}
|
|
226
|
-
className={cn(
|
|
227
|
-
'rounded-full border px-2.5 py-0.5 text-xs transition-colors',
|
|
228
|
-
isSelected
|
|
229
|
-
? 'border-primary bg-primary text-primary-foreground'
|
|
230
|
-
: 'border-border text-foreground hover:border-primary/50',
|
|
231
|
-
isVariantOos && 'cursor-not-allowed line-through opacity-40'
|
|
232
|
-
)}
|
|
233
|
-
>
|
|
234
|
-
{value}
|
|
235
|
-
</button>
|
|
236
|
-
);
|
|
237
|
-
})}
|
|
238
|
-
</div>
|
|
239
|
-
))}
|
|
240
|
-
{isOos && effectiveVariantId && (
|
|
241
|
-
<p className="text-destructive text-xs">{t('outOfStock') || 'Out of stock'}</p>
|
|
242
|
-
)}
|
|
243
|
-
</div>
|
|
244
|
-
)}
|
|
245
110
|
</div>
|
|
246
111
|
);
|
|
247
112
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* <AllergenChips> — informational allergen markers for storefront PDPs.
|
|
5
|
+
* Pure presentation; no interactivity. Pairs with `<ModifierGroupSelector>`
|
|
6
|
+
* but works standalone next to a product as well.
|
|
7
|
+
*
|
|
8
|
+
* Allergens are descriptive only: the system surfaces them but does not
|
|
9
|
+
* auto-block purchase. Customer-side allergen filtering is a deferred SDK
|
|
10
|
+
* feature (PRD §16).
|
|
11
|
+
*
|
|
12
|
+
* Icon resolution (PRD §15 Q4): when `allergen.iconUrl` is set, use it.
|
|
13
|
+
* Otherwise fall back to a built-in emoji keyed by `allergen.code` for the
|
|
14
|
+
* 8 common allergens listed in PRD §5.7. Stores that want branded icons
|
|
15
|
+
* upload via `iconUrl` per allergen; the rest get a sensible default
|
|
16
|
+
* without any setup.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { Allergen } from 'brainerce';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Default emoji per canonical allergen code. The schema's @@unique on
|
|
23
|
+
* `(accountId, storeId, code)` means a store may have at most one of each;
|
|
24
|
+
* any code not in this map renders without an icon (the chip still shows
|
|
25
|
+
* the localized name, so the customer is informed regardless).
|
|
26
|
+
*/
|
|
27
|
+
const DEFAULT_EMOJI_BY_CODE: Record<string, string> = {
|
|
28
|
+
gluten: '🌾',
|
|
29
|
+
dairy: '🥛',
|
|
30
|
+
nuts: '🥜',
|
|
31
|
+
eggs: '🥚',
|
|
32
|
+
soy: '🫘',
|
|
33
|
+
fish: '🐟',
|
|
34
|
+
shellfish: '🦐',
|
|
35
|
+
sesame: '🌱',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
interface AllergenChipsProps {
|
|
39
|
+
allergens: Allergen[];
|
|
40
|
+
/**
|
|
41
|
+
* When the storefront has more allergen icons than fit nicely, condense
|
|
42
|
+
* to "Contains: X, Y, +N more" instead of rendering every chip.
|
|
43
|
+
*/
|
|
44
|
+
maxVisible?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function AllergenChips({ allergens, maxVisible = 6 }: AllergenChipsProps) {
|
|
48
|
+
if (!allergens || allergens.length === 0) return null;
|
|
49
|
+
|
|
50
|
+
const visible = allergens.slice(0, maxVisible);
|
|
51
|
+
const overflow = Math.max(0, allergens.length - visible.length);
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<span className="allergen-chips" role="list" aria-label="Contains">
|
|
55
|
+
{visible.map((allergen) => {
|
|
56
|
+
const fallbackEmoji = DEFAULT_EMOJI_BY_CODE[allergen.code];
|
|
57
|
+
return (
|
|
58
|
+
<span key={allergen.id} className="allergen-chip" role="listitem" title={allergen.name}>
|
|
59
|
+
{allergen.iconUrl ? (
|
|
60
|
+
<img
|
|
61
|
+
src={allergen.iconUrl}
|
|
62
|
+
alt=""
|
|
63
|
+
aria-hidden="true"
|
|
64
|
+
className="allergen-chip__icon"
|
|
65
|
+
/>
|
|
66
|
+
) : fallbackEmoji ? (
|
|
67
|
+
<span aria-hidden="true" className="allergen-chip__emoji">
|
|
68
|
+
{fallbackEmoji}
|
|
69
|
+
</span>
|
|
70
|
+
) : null}
|
|
71
|
+
<span className="allergen-chip__name">{allergen.name}</span>
|
|
72
|
+
</span>
|
|
73
|
+
);
|
|
74
|
+
})}
|
|
75
|
+
{overflow > 0 && <span className="allergen-chip allergen-chip--more">+{overflow} more</span>}
|
|
76
|
+
</span>
|
|
77
|
+
);
|
|
78
|
+
}
|