create-brainerce-store 1.49.0 → 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.
Files changed (27) hide show
  1. package/dist/index.js +1 -1
  2. package/messages/en.json +1 -0
  3. package/messages/he.json +1 -0
  4. package/package.json +1 -1
  5. package/templates/nextjs/base/next.config.ts +18 -10
  6. package/templates/nextjs/base/package.json.ejs +5 -1
  7. package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +1 -1
  8. package/templates/nextjs/base/src/app/blog/page.tsx.ejs +1 -1
  9. package/templates/nextjs/base/src/app/checkout/page.tsx +1 -1
  10. package/templates/nextjs/base/src/app/layout.tsx.ejs +24 -0
  11. package/templates/nextjs/base/src/app/products/[slug]/product-client-section.tsx +1 -1
  12. package/templates/nextjs/base/src/components/account/order-customizations.tsx +1 -1
  13. package/templates/nextjs/base/src/components/account/order-history.tsx +12 -3
  14. package/templates/nextjs/base/src/components/cart/cart-bundle-offer.tsx +1 -1
  15. package/templates/nextjs/base/src/components/cart/cart-item.tsx +1 -1
  16. package/templates/nextjs/base/src/components/cart/cart-upgrade-banner.tsx +1 -1
  17. package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +1 -1
  18. package/templates/nextjs/base/src/components/content/site-header.tsx.ejs +10 -0
  19. package/templates/nextjs/base/src/components/products/frequently-bought-together.tsx +1 -1
  20. package/templates/nextjs/base/src/components/products/modifier-group-selector.tsx +0 -4
  21. package/templates/nextjs/base/src/components/products/product-card.tsx +1 -1
  22. package/templates/nextjs/base/src/components/products/recommendation-section.tsx +1 -1
  23. package/templates/nextjs/base/src/components/shared/cdn-image.tsx +26 -0
  24. package/templates/nextjs/base/src/lib/image-hosts.ts +7 -0
  25. package/templates/nextjs/base/src/lib/sanitize-html.ts +12 -0
  26. package/templates/nextjs/base/src/middleware.ts.ejs +2 -2
  27. 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.49.0",
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/messages/en.json CHANGED
@@ -18,6 +18,7 @@
18
18
  "shipping": "Shipping",
19
19
  "pickup": "Pickup",
20
20
  "tax": "Tax",
21
+ "taxIncl": "Tax (incl.)",
21
22
  "sale": "Sale",
22
23
  "remove": "Remove",
23
24
  "removing": "Removing...",
package/messages/he.json CHANGED
@@ -18,6 +18,7 @@
18
18
  "shipping": "משלוח",
19
19
  "pickup": "איסוף",
20
20
  "tax": "מע\"מ",
21
+ "taxIncl": "כולל מע\"מ",
21
22
  "sale": "מבצע",
22
23
  "remove": "הסרה",
23
24
  "removing": "מסיר...",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-brainerce-store",
3
- "version": "1.49.0",
3
+ "version": "1.50.5",
4
4
  "description": "Scaffold a production-ready e-commerce storefront connected to Brainerce",
5
5
  "bin": {
6
6
  "create-brainerce-store": "dist/index.js"
@@ -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 can be on
32
- // cdn.brainerce.com OR on an upstream merchant host (WooCommerce, Shopify,
33
- // self-hosted) depending on whether the product's image-import job has
34
- // completed on the backend. Rather than hard-fail on unknown hosts, skip
35
- // the server-side optimizer entirely and let the browser fetch each image
36
- // directly from origin. No server-side fetching no SSRF or DoS surface
37
- // on this Next server. Trade-off: no webp/resize/lazy optimization, so
38
- // LCP is marginally worse. Acceptable; the storefront is not the right
39
- // layer to enforce a hostname policy.
40
- unoptimized: true,
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 'next/image';
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 'next/image';
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 'next/image';
5
+ import { CdnImage as Image } from '@/components/shared/cdn-image';
6
6
  import { Link } from '@/lib/navigation';
7
7
  import type {
8
8
  Checkout,
@@ -86,6 +86,18 @@ export default async function RootLayout({
86
86
  .replace(/&/g, '\\u0026'),
87
87
  }}
88
88
  />
89
+ {/* Brainerce cookieless traffic analytics — auto-tracks pageviews +
90
+ SPA route changes. Cookieless (no consent banner needed); the
91
+ merchant can toggle it per sales channel in the dashboard. */}
92
+ <script
93
+ defer
94
+ src={`${process.env.NEXT_PUBLIC_BRAINERCE_PIXEL_URL || 'https://api.brainerce.com'}/t.js`}
95
+ data-channel={
96
+ process.env.NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID || '<%= connectionId %>'
97
+ }
98
+ nonce={nonce}
99
+ suppressHydrationWarning
100
+ />
89
101
  </head>
90
102
  <body className={font.className}>
91
103
  <StoreProvider locale={locale} initialStoreInfo={storeInfo}>
@@ -181,6 +193,18 @@ export default async function RootLayout({
181
193
  .replace(/&/g, '\\u0026'),
182
194
  }}
183
195
  />
196
+ {/* Brainerce cookieless traffic analytics — auto-tracks pageviews +
197
+ SPA route changes. Cookieless (no consent banner needed); the
198
+ merchant can toggle it per sales channel in the dashboard. */}
199
+ <script
200
+ defer
201
+ src={`${process.env.NEXT_PUBLIC_BRAINERCE_PIXEL_URL || 'https://api.brainerce.com'}/t.js`}
202
+ data-channel={
203
+ process.env.NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID || '<%= connectionId %>'
204
+ }
205
+ nonce={nonce}
206
+ suppressHydrationWarning
207
+ />
184
208
  </head>
185
209
  <body className={font.className}>
186
210
  <StoreProvider initialStoreInfo={storeInfo}>
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useEffect, useState, useMemo } from 'react';
4
- import Image from 'next/image';
4
+ import { CdnImage as Image } from '@/components/shared/cdn-image';
5
5
  import type {
6
6
  Product,
7
7
  ProductVariant,
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import Image from 'next/image';
3
+ import { CdnImage as Image } from '@/components/shared/cdn-image';
4
4
  import type { OrderItem } from 'brainerce';
5
5
  import { useTranslations } from '@/lib/translations';
6
6
  import { cn } from '@/lib/utils';
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useState, useEffect } from 'react';
4
- import Image from 'next/image';
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';
@@ -291,7 +291,16 @@ function OrderFinancialSummary({ order, currency }: { order: Order; currency: st
291
291
  const ruleAmt = order.ruleDiscountAmount ? parseFloat(order.ruleDiscountAmount) : 0;
292
292
  const couponAmt = order.couponDiscount ? parseFloat(order.couponDiscount) : 0;
293
293
  const shipping = order.shippingAmount ? parseFloat(order.shippingAmount) : 0;
294
- const tax = order.taxAmount ? parseFloat(order.taxAmount) : 0;
294
+ // Inclusive (VAT) orders persist taxAmount=0; the real VAT lives on the
295
+ // breakdown. Surface whichever is present (mirror of TaxDisplay).
296
+ const explicitTax = order.taxAmount ? parseFloat(order.taxAmount) : 0;
297
+ const taxIncluded = !!order.taxBreakdown?.pricesIncludeTax;
298
+ const tax =
299
+ explicitTax > 0
300
+ ? explicitTax
301
+ : typeof order.taxBreakdown?.totalTax === 'number'
302
+ ? order.taxBreakdown.totalTax
303
+ : 0;
295
304
  const rules = order.appliedDiscounts;
296
305
 
297
306
  const hasBreakdown = subtotal !== null && subtotal > 0;
@@ -352,7 +361,7 @@ function OrderFinancialSummary({ order, currency }: { order: Order; currency: st
352
361
 
353
362
  {tax > 0 && (
354
363
  <div className="flex items-center justify-between">
355
- <span className="text-muted-foreground">{tc('tax')}</span>
364
+ <span className="text-muted-foreground">{taxIncluded ? tc('taxIncl') : tc('tax')}</span>
356
365
  <span className="text-foreground">{formatPrice(tax, { currency }) as string}</span>
357
366
  </div>
358
367
  )}
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useState } from 'react';
4
- import Image from 'next/image';
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 'next/image';
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 'next/image';
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 'next/image';
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';
@@ -18,6 +18,9 @@
18
18
  import * as React from 'react';
19
19
  import type { Content } from 'brainerce';
20
20
  import { HeaderAccount } from './header-account';
21
+ <% if (i18nEnabled) { %>
22
+ import { LanguageSwitcher } from '@/components/layout/language-switcher';
23
+ <% } %>
21
24
 
22
25
  interface SiteHeaderProps {
23
26
  /** Pre-fetched header payload (server-side). `null` triggers static fallback. */
@@ -79,6 +82,9 @@ export function SiteHeader({ header, storeName }: SiteHeaderProps) {
79
82
  {brandLabel}
80
83
  </a>
81
84
  <div className="flex items-center gap-2">
85
+ <% if (i18nEnabled) { %>
86
+ <LanguageSwitcher />
87
+ <% } %>
82
88
  <HeaderAccount />
83
89
  <a
84
90
  href="/cart"
@@ -133,6 +139,10 @@ export function SiteHeader({ header, storeName }: SiteHeaderProps) {
133
139
  </a>
134
140
  ) : null}
135
141
 
142
+ <% if (i18nEnabled) { %>
143
+ <LanguageSwitcher />
144
+ <% } %>
145
+
136
146
  <HeaderAccount />
137
147
 
138
148
  <a
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useState } from 'react';
4
- import Image from 'next/image';
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 'next/image';
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 'next/image';
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
- }