create-brainerce-store 1.50.1 → 1.50.5
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 +1 -1
- package/package.json +1 -1
- package/templates/nextjs/base/next.config.ts +18 -10
- package/templates/nextjs/base/package.json.ejs +5 -1
- package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +1 -1
- package/templates/nextjs/base/src/app/blog/page.tsx.ejs +1 -1
- package/templates/nextjs/base/src/app/checkout/page.tsx +1 -1
- package/templates/nextjs/base/src/app/layout.tsx.ejs +4 -2
- package/templates/nextjs/base/src/app/products/[slug]/product-client-section.tsx +1 -1
- package/templates/nextjs/base/src/components/account/order-customizations.tsx +1 -1
- package/templates/nextjs/base/src/components/account/order-history.tsx +1 -1
- package/templates/nextjs/base/src/components/cart/cart-bundle-offer.tsx +1 -1
- package/templates/nextjs/base/src/components/cart/cart-item.tsx +1 -1
- package/templates/nextjs/base/src/components/cart/cart-upgrade-banner.tsx +1 -1
- package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +1 -1
- package/templates/nextjs/base/src/components/products/frequently-bought-together.tsx +1 -1
- package/templates/nextjs/base/src/components/products/modifier-group-selector.tsx +0 -4
- package/templates/nextjs/base/src/components/products/product-card.tsx +1 -1
- package/templates/nextjs/base/src/components/products/recommendation-section.tsx +1 -1
- package/templates/nextjs/base/src/components/shared/cdn-image.tsx +26 -0
- package/templates/nextjs/base/src/lib/image-hosts.ts +7 -0
- package/templates/nextjs/base/src/lib/sanitize-html.ts +12 -0
- package/templates/nextjs/base/src/middleware.ts.ejs +2 -2
- package/templates/nextjs/base/src/components/products/allergen-chips.tsx +0 -78
package/dist/index.js
CHANGED
|
@@ -31,7 +31,7 @@ var require_package = __commonJS({
|
|
|
31
31
|
"package.json"(exports2, module2) {
|
|
32
32
|
module2.exports = {
|
|
33
33
|
name: "create-brainerce-store",
|
|
34
|
-
version: "1.50.
|
|
34
|
+
version: "1.50.5",
|
|
35
35
|
description: "Scaffold a production-ready e-commerce storefront connected to Brainerce",
|
|
36
36
|
bin: {
|
|
37
37
|
"create-brainerce-store": "dist/index.js"
|
package/package.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { NextConfig } from 'next';
|
|
2
|
+
import { OPTIMIZED_IMAGE_HOSTS } from './src/lib/image-hosts';
|
|
2
3
|
|
|
3
4
|
// Build-time invariant — fail loud if NEXT_PUBLIC_STORE_CURRENCY is missing
|
|
4
5
|
// for a production build. Next.js inlines NEXT_PUBLIC_* into the client
|
|
@@ -28,16 +29,23 @@ const nextConfig: NextConfig = {
|
|
|
28
29
|
serverExternalPackages: ['isomorphic-dompurify'],
|
|
29
30
|
images: {
|
|
30
31
|
// The storefront is a consumer of the Brainerce API — it has to render
|
|
31
|
-
// whatever image URLs the API returns. In practice those URLs
|
|
32
|
-
// cdn.brainerce.com
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
32
|
+
// whatever image URLs the API returns. In practice those URLs are
|
|
33
|
+
// usually on cdn.brainerce.com, but a product/variant can still carry a
|
|
34
|
+
// raw upstream-merchant URL (WooCommerce, Shopify, self-hosted) while
|
|
35
|
+
// its image-import job is pending or failed. Rather than allowlist every
|
|
36
|
+
// possible merchant host (or hard-fail on them), only cdn.brainerce.com
|
|
37
|
+
// goes through the server-side optimizer; components render product/blog
|
|
38
|
+
// images via `CdnImage` (src/components/shared/cdn-image.tsx), which
|
|
39
|
+
// detects any other host and renders it `unoptimized` — an unresized
|
|
40
|
+
// direct-from-origin fetch, same as this template's prior behavior —
|
|
41
|
+
// instead of next/image throwing on an unconfigured hostname. No
|
|
42
|
+
// server-side fetching of unknown hosts → no SSRF/DoS surface on this
|
|
43
|
+
// Next server.
|
|
44
|
+
remotePatterns: OPTIMIZED_IMAGE_HOSTS.map((hostname) => ({
|
|
45
|
+
protocol: 'https' as const,
|
|
46
|
+
hostname,
|
|
47
|
+
pathname: '/**',
|
|
48
|
+
})),
|
|
41
49
|
},
|
|
42
50
|
async headers() {
|
|
43
51
|
return [
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"react-dom": "^19.0.0",
|
|
17
17
|
"clsx": "^2.1.0",
|
|
18
18
|
"tailwind-merge": "^2.2.0",
|
|
19
|
-
"isomorphic-dompurify": "<%= isomorphicDompurifyVersion %>"
|
|
19
|
+
"isomorphic-dompurify": "<%= isomorphicDompurifyVersion %>",
|
|
20
|
+
"sharp": "^0.35.3"
|
|
20
21
|
},
|
|
21
22
|
"devDependencies": {
|
|
22
23
|
"@types/node": "^20.0.0",
|
|
@@ -31,5 +32,8 @@
|
|
|
31
32
|
},
|
|
32
33
|
"engines": {
|
|
33
34
|
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
|
|
35
|
+
},
|
|
36
|
+
"pnpm": {
|
|
37
|
+
"onlyBuiltDependencies": ["sharp"]
|
|
34
38
|
}
|
|
35
39
|
}
|
|
@@ -12,7 +12,7 @@ import * as React from 'react';
|
|
|
12
12
|
import type { Metadata } from 'next';
|
|
13
13
|
import { notFound } from 'next/navigation';
|
|
14
14
|
import Link from 'next/link';
|
|
15
|
-
import Image from '
|
|
15
|
+
import { CdnImage as Image } from '@/components/shared/cdn-image';
|
|
16
16
|
|
|
17
17
|
import { getServerClient } from '@/lib/brainerce';
|
|
18
18
|
import { sanitizeHtml } from '@/lib/sanitize';
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import * as React from 'react';
|
|
11
11
|
import type { Metadata } from 'next';
|
|
12
12
|
import Link from 'next/link';
|
|
13
|
-
import Image from '
|
|
13
|
+
import { CdnImage as Image } from '@/components/shared/cdn-image';
|
|
14
14
|
|
|
15
15
|
import { getServerClient } from '@/lib/brainerce';
|
|
16
16
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { Suspense, useEffect, useState, useCallback, useRef } from 'react';
|
|
4
4
|
import { useSearchParams } from 'next/navigation';
|
|
5
|
-
import Image from '
|
|
5
|
+
import { CdnImage as Image } from '@/components/shared/cdn-image';
|
|
6
6
|
import { Link } from '@/lib/navigation';
|
|
7
7
|
import type {
|
|
8
8
|
Checkout,
|
|
@@ -91,11 +91,12 @@ export default async function RootLayout({
|
|
|
91
91
|
merchant can toggle it per sales channel in the dashboard. */}
|
|
92
92
|
<script
|
|
93
93
|
defer
|
|
94
|
-
src={`${process.env.
|
|
94
|
+
src={`${process.env.NEXT_PUBLIC_BRAINERCE_PIXEL_URL || 'https://api.brainerce.com'}/t.js`}
|
|
95
95
|
data-channel={
|
|
96
96
|
process.env.NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID || '<%= connectionId %>'
|
|
97
97
|
}
|
|
98
98
|
nonce={nonce}
|
|
99
|
+
suppressHydrationWarning
|
|
99
100
|
/>
|
|
100
101
|
</head>
|
|
101
102
|
<body className={font.className}>
|
|
@@ -197,11 +198,12 @@ export default async function RootLayout({
|
|
|
197
198
|
merchant can toggle it per sales channel in the dashboard. */}
|
|
198
199
|
<script
|
|
199
200
|
defer
|
|
200
|
-
src={`${process.env.
|
|
201
|
+
src={`${process.env.NEXT_PUBLIC_BRAINERCE_PIXEL_URL || 'https://api.brainerce.com'}/t.js`}
|
|
201
202
|
data-channel={
|
|
202
203
|
process.env.NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID || '<%= connectionId %>'
|
|
203
204
|
}
|
|
204
205
|
nonce={nonce}
|
|
206
|
+
suppressHydrationWarning
|
|
205
207
|
/>
|
|
206
208
|
</head>
|
|
207
209
|
<body className={font.className}>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect } from 'react';
|
|
4
|
-
import Image from '
|
|
4
|
+
import { CdnImage as Image } from '@/components/shared/cdn-image';
|
|
5
5
|
import type { Order, OrderStatus, OrderDownloadLink } from 'brainerce';
|
|
6
6
|
import { formatPrice } from 'brainerce';
|
|
7
7
|
import { getClient } from '@/lib/brainerce';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState } from 'react';
|
|
4
|
-
import Image from '
|
|
4
|
+
import { CdnImage as Image } from '@/components/shared/cdn-image';
|
|
5
5
|
import type { CartBundleOffer as CartBundleOfferType } from 'brainerce';
|
|
6
6
|
import { formatPrice } from 'brainerce';
|
|
7
7
|
import { useCurrency } from '@/lib/use-currency';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState } from 'react';
|
|
4
|
-
import Image from '
|
|
4
|
+
import { CdnImage as Image } from '@/components/shared/cdn-image';
|
|
5
5
|
import type { CartItem as CartItemType } from 'brainerce';
|
|
6
6
|
import { getCartItemImage, formatPrice } from 'brainerce';
|
|
7
7
|
import { getClient } from '@/lib/brainerce';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect } from 'react';
|
|
4
|
-
import Image from '
|
|
4
|
+
import { CdnImage as Image } from '@/components/shared/cdn-image';
|
|
5
5
|
import type { CartUpgradeSuggestion, CartItem as CartItemType } from 'brainerce';
|
|
6
6
|
import { formatPrice } from 'brainerce';
|
|
7
7
|
import { getClient } from '@/lib/brainerce';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState, useMemo } from 'react';
|
|
4
|
-
import Image from '
|
|
4
|
+
import { CdnImage as Image } from '@/components/shared/cdn-image';
|
|
5
5
|
import type { OrderBump, RecommendationVariant } from 'brainerce';
|
|
6
6
|
import { formatPrice, getVariantOptions } from 'brainerce';
|
|
7
7
|
import { useCurrency } from '@/lib/use-currency';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState } from 'react';
|
|
4
|
-
import Image from '
|
|
4
|
+
import { CdnImage as Image } from '@/components/shared/cdn-image';
|
|
5
5
|
import type { Product, ProductRecommendation } from 'brainerce';
|
|
6
6
|
import { formatPrice } from 'brainerce';
|
|
7
7
|
import { useCart, useStoreInfo } from '@/providers/store-provider';
|
|
@@ -24,7 +24,6 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import type { Modifier, ModifierGroup, ModifierSelection } from 'brainerce';
|
|
27
|
-
import { AllergenChips } from './allergen-chips';
|
|
28
27
|
|
|
29
28
|
interface ModifierGroupSelectorProps {
|
|
30
29
|
/**
|
|
@@ -121,9 +120,6 @@ export function ModifierGroupSelector({
|
|
|
121
120
|
Sold out
|
|
122
121
|
</span>
|
|
123
122
|
)}
|
|
124
|
-
{modifier.allergens && modifier.allergens.length > 0 && (
|
|
125
|
-
<AllergenChips allergens={modifier.allergens} />
|
|
126
|
-
)}
|
|
127
123
|
</label>
|
|
128
124
|
{modifier.description && (
|
|
129
125
|
<p className="modifier-option__description">{modifier.description}</p>
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState } from 'react';
|
|
4
4
|
import { Link, useRouter } from '@/lib/navigation';
|
|
5
|
-
import Image from '
|
|
5
|
+
import { CdnImage as Image } from '@/components/shared/cdn-image';
|
|
6
6
|
import type { Product } from 'brainerce';
|
|
7
7
|
import { getProductPriceInfo, getVariantPrice, formatPrice } from 'brainerce';
|
|
8
8
|
import { useTranslations } from '@/lib/translations';
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from 'react';
|
|
4
4
|
import { Link } from '@/lib/navigation';
|
|
5
|
-
import Image from '
|
|
5
|
+
import { CdnImage as Image } from '@/components/shared/cdn-image';
|
|
6
6
|
import type { ProductRecommendation } from 'brainerce';
|
|
7
7
|
import { PriceDisplay } from '@/components/shared/price-display';
|
|
8
8
|
import { cn } from '@/lib/utils';
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import Image, { type ImageProps } from 'next/image';
|
|
2
|
+
import { OPTIMIZED_IMAGE_HOSTS } from '@/lib/image-hosts';
|
|
3
|
+
|
|
4
|
+
const OPTIMIZED_HOSTS = new Set(OPTIMIZED_IMAGE_HOSTS);
|
|
5
|
+
|
|
6
|
+
function isOptimizedHost(src: ImageProps['src']): boolean {
|
|
7
|
+
if (typeof src !== 'string') return true; // StaticImageData — always local
|
|
8
|
+
try {
|
|
9
|
+
return OPTIMIZED_HOSTS.has(new URL(src).hostname);
|
|
10
|
+
} catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Drop-in `next/image` replacement for Brainerce-sourced images (product
|
|
17
|
+
* photos, blog covers). Uploaded images get re-hosted onto cdn.brainerce.com
|
|
18
|
+
* by the backend's async image-import pipeline, but a product/variant can
|
|
19
|
+
* still carry a raw upstream-merchant URL while mid-import, or if that job
|
|
20
|
+
* failed. next/image throws if `src`'s host isn't in `images.remotePatterns`,
|
|
21
|
+
* so any host outside our known CDN falls back to `unoptimized` — an
|
|
22
|
+
* unresized, direct-from-origin fetch — instead of crashing the page.
|
|
23
|
+
*/
|
|
24
|
+
export function CdnImage({ unoptimized, ...props }: ImageProps) {
|
|
25
|
+
return <Image {...props} unoptimized={unoptimized ?? !isOptimizedHost(props.src)} />;
|
|
26
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for which hostnames Brainerce serves optimized
|
|
3
|
+
* (CDN-hosted, WebP) images from. Shared between `next.config.ts`
|
|
4
|
+
* (`images.remotePatterns`) and `CdnImage` (per-instance `unoptimized`
|
|
5
|
+
* fallback) so the two lists can't drift apart.
|
|
6
|
+
*/
|
|
7
|
+
export const OPTIMIZED_IMAGE_HOSTS = ['cdn.brainerce.com'];
|
|
@@ -71,6 +71,9 @@ export function sanitizeProductHtml(html: string): string {
|
|
|
71
71
|
'colgroup',
|
|
72
72
|
'col',
|
|
73
73
|
'iframe',
|
|
74
|
+
// Self-hosted product videos (uploaded via the dashboard media library).
|
|
75
|
+
'video',
|
|
76
|
+
'source',
|
|
74
77
|
],
|
|
75
78
|
ALLOWED_ATTR: [
|
|
76
79
|
'href',
|
|
@@ -87,6 +90,15 @@ export function sanitizeProductHtml(html: string): string {
|
|
|
87
90
|
'allowfullscreen',
|
|
88
91
|
'frameborder',
|
|
89
92
|
'loading',
|
|
93
|
+
'title',
|
|
94
|
+
// <video> / <source> playback attributes.
|
|
95
|
+
'controls',
|
|
96
|
+
'poster',
|
|
97
|
+
'preload',
|
|
98
|
+
'muted',
|
|
99
|
+
'loop',
|
|
100
|
+
'playsinline',
|
|
101
|
+
'type',
|
|
90
102
|
],
|
|
91
103
|
ALLOW_DATA_ATTR: false,
|
|
92
104
|
});
|
|
@@ -43,7 +43,7 @@ function buildCsp(nonce: string): string {
|
|
|
43
43
|
"style-src 'self' 'unsafe-inline' https://cdn.meshulam.co.il",
|
|
44
44
|
"img-src 'self' data: blob: https:",
|
|
45
45
|
"font-src 'self' data:",
|
|
46
|
-
"frame-src 'self' https://meshulam.co.il https://*.meshulam.co.il https://grow.link https://*.grow.link https://grow.security https://*.grow.security https://creditguard.co.il https://*.creditguard.co.il https://js.stripe.com https://hooks.stripe.com https://pay.google.com https://secure.cardcom.solutions https://checkout.stripe.com https://www.paypal.com https://www.sandbox.paypal.com https://*.brainerce.com",
|
|
46
|
+
"frame-src 'self' https://meshulam.co.il https://*.meshulam.co.il https://grow.link https://*.grow.link https://grow.security https://*.grow.security https://creditguard.co.il https://*.creditguard.co.il https://js.stripe.com https://hooks.stripe.com https://pay.google.com https://secure.cardcom.solutions https://checkout.stripe.com https://www.paypal.com https://www.sandbox.paypal.com https://www.youtube.com https://www.youtube-nocookie.com https://player.vimeo.com https://*.brainerce.com",
|
|
47
47
|
`connect-src 'self' ${brainerceApiOrigin()} https://api.brainerce.com https://*.meshulam.co.il https://grow.link https://*.grow.link https://*.grow.security https://pay.google.com https://*.stripe.com https://*.creditguard.co.il`,
|
|
48
48
|
"worker-src 'self' blob:",
|
|
49
49
|
// 'self' (not 'none') so iframe-based payment providers (e.g. Cardcom)
|
|
@@ -171,7 +171,7 @@ function buildCsp(nonce: string): string {
|
|
|
171
171
|
"style-src 'self' 'unsafe-inline' https://cdn.meshulam.co.il",
|
|
172
172
|
"img-src 'self' data: blob: https:",
|
|
173
173
|
"font-src 'self' data:",
|
|
174
|
-
"frame-src 'self' https://meshulam.co.il https://*.meshulam.co.il https://grow.link https://*.grow.link https://grow.security https://*.grow.security https://creditguard.co.il https://*.creditguard.co.il https://js.stripe.com https://hooks.stripe.com https://pay.google.com https://secure.cardcom.solutions https://checkout.stripe.com https://www.paypal.com https://www.sandbox.paypal.com https://*.brainerce.com",
|
|
174
|
+
"frame-src 'self' https://meshulam.co.il https://*.meshulam.co.il https://grow.link https://*.grow.link https://grow.security https://*.grow.security https://creditguard.co.il https://*.creditguard.co.il https://js.stripe.com https://hooks.stripe.com https://pay.google.com https://secure.cardcom.solutions https://checkout.stripe.com https://www.paypal.com https://www.sandbox.paypal.com https://www.youtube.com https://www.youtube-nocookie.com https://player.vimeo.com https://*.brainerce.com",
|
|
175
175
|
`connect-src 'self' ${brainerceApiOrigin()} https://api.brainerce.com https://*.meshulam.co.il https://grow.link https://*.grow.link https://*.grow.security https://pay.google.com https://*.stripe.com https://*.creditguard.co.il`,
|
|
176
176
|
"worker-src 'self' blob:",
|
|
177
177
|
// 'self' (not 'none') so iframe-based payment providers (e.g. Cardcom)
|
|
@@ -1,78 +0,0 @@
|
|
|
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
|
-
}
|