@cartbase/storefront 0.11.0 → 0.13.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cartbase/storefront",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Storefront SDK + UI component library for Cartbase stores: typed API client, checkout orchestration, cart drawer, product/catalog components, tracking. Source-shipped TypeScript — add it to transpilePackages.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -51,6 +51,7 @@
51
51
  "./lib/sort-products": "./src/lib/sort-products.ts",
52
52
  "./lib/price": "./src/lib/price.tsx",
53
53
  "./lib/payment-constants": "./src/lib/payment-constants.ts",
54
+ "./lib/country-name": "./src/lib/country-name.ts",
54
55
  "./lib/store-api-error": "./src/lib/store-api-error.ts",
55
56
  "./lib/hooks/use-intersection": "./src/lib/hooks/use-intersection.ts",
56
57
  "./lib/hooks/use-toggle-state": "./src/lib/hooks/use-toggle-state.ts",
@@ -157,6 +158,8 @@
157
158
  "./products/product-actions": "./src/products/product-actions.tsx",
158
159
  "./products/mobile-actions": "./src/products/mobile-actions.tsx",
159
160
  "./products/product-tabs": "./src/products/product-tabs.tsx",
161
+ "./products/product-specs": "./src/products/product-specs.tsx",
162
+ "./products/product-promises": "./src/products/product-promises.tsx",
160
163
  "./products/product-info": "./src/products/product-info.tsx",
161
164
  "./products/product-preview": "./src/products/product-preview.tsx",
162
165
  "./products/related-products": "./src/products/related-products.tsx",
@@ -99,7 +99,11 @@ export const defaultCartDrawerLabels: CartDrawerLabels = {
99
99
  addToCart: "Add",
100
100
 
101
101
  subtotal: "Subtotal",
102
- taxAndShipping: "VAT included. Shipping calculated at checkout.",
102
+ // Neutral and true on every store: it states WHEN the numbers are worked
103
+ // out, not which tax regime applies. It read "VAT included. Shipping
104
+ // calculated at checkout." until 2026-09-13, and the first half is false
105
+ // wherever tax is added at checkout instead of sitting in the price.
106
+ taxAndShipping: "Taxes and shipping calculated at checkout.",
103
107
  shipping: "Shipping",
104
108
  free: "FREE",
105
109
  calculatedAtCheckout: "Calculated at checkout",
@@ -5,6 +5,7 @@ import type { ChangeEvent } from "react"
5
5
  import type { CartAddress } from "../api/carts"
6
6
  import type { CustomerAddress } from "../api/customers"
7
7
  import { CountryFlag } from "../common/country-flag"
8
+ import { countryName } from "../lib/country-name"
8
9
  import { Field } from "../primitives/field"
9
10
  import { SelectField } from "../primitives/select-field"
10
11
  import { AddressSelect } from "./address-select"
@@ -105,20 +106,26 @@ export function CheckoutAddressForm({
105
106
 
106
107
  {!hideCountry && (
107
108
  countries.length === 1 ? (
108
- // Single-country region render a readonly field showing
109
- // the localized country name. (Library-default rendering
110
- // when `hideCountry` isn't passed.) The actual country_code
111
- // remains in formData so submission still carries it.
109
+ // One country — a read-only field showing its name, with its
110
+ // flag, so a fixed country reads the same as a chosen one. The
111
+ // actual country_code stays in formData, so submission still
112
+ // carries it.
113
+ //
114
+ // The name has THREE sources and never falls through to blank
115
+ // (it did until 2026-09-13, which is how evoo showed an empty
116
+ // Country box): the store's own word for it, the catalogue's
117
+ // name, then the code resolved through the runtime.
112
118
  <Field
113
119
  label={labels.country}
114
120
  name="shipping_address.country_code_display"
115
121
  value={
116
- labels.singleCountryName ??
117
- countries[0]?.display_name ??
118
- ""
122
+ labels.singleCountryName ||
123
+ countries[0]?.display_name ||
124
+ countryName(countries[0]?.iso_2)
119
125
  }
120
126
  onChange={() => {}}
121
127
  readOnly
128
+ leading={<CountryFlag code={countries[0]?.iso_2} />}
122
129
  />
123
130
  ) : (
124
131
  <SelectField
@@ -50,6 +50,22 @@ export function useOrderConfirmedPath(): string {
50
50
  return useContext(CheckoutContext).orderConfirmedPath
51
51
  }
52
52
 
53
+ /**
54
+ * A NESTED PROVIDER INHERITS, it does not reset (fixed 2026-09-13).
55
+ *
56
+ * This used to merge over `defaultCheckoutLabels`, which are English, so a
57
+ * store that mounted a second provider purely to set `orderConfirmedPath`
58
+ * silently threw away its whole language. That is not a hypothetical: the
59
+ * reference storefront does exactly that on its checkout page, so every
60
+ * scaffolded store ran an English checkout no matter which locale it
61
+ * mounted, and the Bulgarian `singleCountryName` vanished with the rest,
62
+ * leaving an EMPTY country field. Alexander found it on evoo.
63
+ *
64
+ * Reading the enclosing value first makes both cases right: with no
65
+ * provider above, the context default IS the English baseline, so a single
66
+ * provider behaves exactly as before; with one above, only the keys this
67
+ * one names change.
68
+ */
53
69
  export function CheckoutProvider({
54
70
  labels: labelOverrides,
55
71
  orderConfirmedPath,
@@ -59,15 +75,16 @@ export function CheckoutProvider({
59
75
  orderConfirmedPath?: string
60
76
  children: ReactNode
61
77
  }) {
78
+ const inherited = useContext(CheckoutContext)
62
79
  const labels: CheckoutLabels = {
63
- ...defaultCheckoutLabels,
80
+ ...inherited.labels,
64
81
  ...labelOverrides,
65
82
  }
66
83
  return (
67
84
  <CheckoutContext.Provider
68
85
  value={{
69
86
  labels,
70
- orderConfirmedPath: orderConfirmedPath ?? DEFAULT_CONFIRMED_PATH,
87
+ orderConfirmedPath: orderConfirmedPath ?? inherited.orderConfirmedPath,
71
88
  }}
72
89
  >
73
90
  {children}
@@ -81,7 +81,13 @@ export type CheckoutLabels = {
81
81
  shippingCalc: string
82
82
  shippingFree: string
83
83
  tax: string
84
- taxTooltip: string
84
+ /**
85
+ * What the tax is, in the store's words. OPTIONAL and undefined by
86
+ * default: a rate ("20% VAT") or a regime ("included in prices") is the
87
+ * store's fact, and the tooltip is simply absent when the store says
88
+ * nothing.
89
+ */
90
+ taxTooltip?: string
85
91
  total: string
86
92
  discount: string
87
93
  discountCode: string
@@ -306,8 +312,16 @@ export const defaultCheckoutLabels: CheckoutLabels = {
306
312
  shipping: "Shipping",
307
313
  shippingCalc: "Calculated at checkout",
308
314
  shippingFree: "Free",
309
- tax: "VAT incl.",
310
- taxTooltip: "20% VAT is included in prices",
315
+ // The row NAME, neutral on purpose: it sits beside a number, so it must
316
+ // say something, but "VAT incl." names one tax regime and asserts that
317
+ // prices include it. That is Bulgaria's answer, not Germany's, and not any
318
+ // US state's. A store that wants its own word sets it in its pack.
319
+ tax: "Tax",
320
+ // NO DEFAULT. The explanation used to read "20% VAT is included in
321
+ // prices", a rate and a regime the library cannot know, translated into
322
+ // three languages. A store writes it or the tooltip does not appear at
323
+ // all, which costs a shopper nothing.
324
+ taxTooltip: undefined,
311
325
  total: "Total",
312
326
  discount: "Discount",
313
327
  discountCode: "Discount code",
@@ -431,24 +431,33 @@ export function OrderSummary({
431
431
  )}
432
432
 
433
433
  <div className="flex justify-between text-sm">
434
+ {/* The tax row's NAME always shows: it names the number beside
435
+ it. The explanation only shows if the store wrote one, because
436
+ what a tax is and whether it is included in the price is the
437
+ store's fact, not ours (2026-09-13: the default said "20% VAT
438
+ is included in prices", Bulgaria's rate, to every merchant). */}
434
439
  <span className="text-muted-foreground flex items-center gap-1 relative group">
435
440
  {labels.tax}
436
- <svg
437
- className="w-3.5 h-3.5 text-muted-foreground cursor-help"
438
- fill="none"
439
- viewBox="0 0 24 24"
440
- stroke="currentColor"
441
- strokeWidth={2}
442
- >
443
- <path
444
- strokeLinecap="round"
445
- strokeLinejoin="round"
446
- d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
447
- />
448
- </svg>
449
- <span className="absolute left-0 bottom-full mb-1.5 px-2.5 py-1.5 text-xs text-card bg-foreground rounded-md whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity">
450
- {labels.taxTooltip}
451
- </span>
441
+ {labels.taxTooltip && (
442
+ <>
443
+ <svg
444
+ className="w-3.5 h-3.5 text-muted-foreground cursor-help"
445
+ fill="none"
446
+ viewBox="0 0 24 24"
447
+ stroke="currentColor"
448
+ strokeWidth={2}
449
+ >
450
+ <path
451
+ strokeLinecap="round"
452
+ strokeLinejoin="round"
453
+ d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
454
+ />
455
+ </svg>
456
+ <span className="absolute left-0 bottom-full mb-1.5 px-2.5 py-1.5 text-xs text-card bg-foreground rounded-md whitespace-nowrap opacity-0 pointer-events-none group-hover:opacity-100 transition-opacity">
457
+ {labels.taxTooltip}
458
+ </span>
459
+ </>
460
+ )}
452
461
  </span>
453
462
  <Price
454
463
  amount={cart.tax_total ?? 0}
@@ -10,6 +10,7 @@ import {
10
10
  } from "react"
11
11
 
12
12
  import type { StorefrontClient } from "../api/http"
13
+ import { countryName } from "../lib/country-name"
13
14
  import { listCountries, type StoreCountry } from "../api/regions"
14
15
  import {
15
16
  completeCart,
@@ -408,7 +409,10 @@ export function useCheckoutOrchestration({
408
409
  : storeCountries && storeCountries.length
409
410
  ? storeCountries
410
411
  : countryCode
411
- ? [{ iso_2: countryCode, display_name: "" }]
412
+ ? // A fallback country still needs a NAME: this used to be an
413
+ // empty string, and the address form rendered a blank country
414
+ // field for it (evoo, 2026-09-13).
415
+ [{ iso_2: countryCode, display_name: countryName(countryCode) }]
412
416
  : [],
413
417
  [countries, storeCountries, countryCode]
414
418
  )
@@ -1132,9 +1136,11 @@ export function useCheckoutOrchestration({
1132
1136
  )
1133
1137
  })
1134
1138
  } else {
1135
- setPaymentError(
1136
- "Плащането не беше потвърдено. Моля, опитайте отново или изберете друг метод."
1137
- )
1139
+ // The store's own words. This was a hardcoded BULGARIAN sentence until
1140
+ // 2026-09-13: the 2026-09-13 morning sweep fixed the three error-copy
1141
+ // modules and missed this one, because it sits in the 3DS-return
1142
+ // effect rather than in a copy module. Same meaning, from the pack.
1143
+ setPaymentError(checkoutLabels.paymentErrors.paymentNotAuthorized)
1138
1144
  logError?.("place_order_error", "redirect_not_succeeded", {
1139
1145
  via: "3ds_return",
1140
1146
  cartId: cart.id,
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The name of a country, from its ISO-3166 alpha-2 code.
3
+ *
4
+ * A code is a machine string: "bg" is how a cart address stores a country,
5
+ * never how a shopper reads it. The catalogue from
6
+ * `GET /api/store/countries` carries real names, so this is for the one case
7
+ * where a code arrives WITHOUT one: the checkout hook's fallback when the
8
+ * country list cannot be loaded. It used to hand the address form
9
+ * `{iso_2: "bg", display_name: ""}`, and the form rendered an EMPTY country
10
+ * field (found on evoo, 2026-09-13).
11
+ *
12
+ * `Intl.DisplayNames` is in the runtime, so this needs no bundled table and
13
+ * knows every country in every language the browser does.
14
+ *
15
+ * THE LOCALE IS EXPLICIT AND DEFAULTS TO ENGLISH ON PURPOSE. Letting Intl
16
+ * pick the runtime default would name the country in the SERVER's language
17
+ * during a server render and in the BROWSER's language after hydration,
18
+ * which is a hydration mismatch on any store whose visitors are not in the
19
+ * server's locale. Pass the store's own language to translate it:
20
+ *
21
+ * const locale = useStorefrontLocale()
22
+ * countryName("bg", locale.code) // "България"
23
+ *
24
+ * Three behaviours verified against the runtime rather than assumed
25
+ * (2026-09-13): a LOWERCASE code returns itself unchanged, so the code is
26
+ * uppercased here; a malformed code throws `RangeError`, so it is shape
27
+ * checked and wrapped; and an unknown-but-well-formed code resolves to
28
+ * "Unknown Region" unless `fallback: "none"` is set, which is why it is set
29
+ * and why the uppercase code is the last resort.
30
+ */
31
+
32
+ const cache = new Map<string, Intl.DisplayNames>()
33
+
34
+ function namer(locale: string): Intl.DisplayNames | null {
35
+ const hit = cache.get(locale)
36
+ if (hit) return hit
37
+ try {
38
+ const made = new Intl.DisplayNames([locale], { type: "region", fallback: "none" })
39
+ cache.set(locale, made)
40
+ return made
41
+ } catch {
42
+ return null
43
+ }
44
+ }
45
+
46
+ export function countryName(
47
+ code: string | null | undefined,
48
+ locale: string = "en"
49
+ ): string {
50
+ const iso2 = code?.trim()
51
+ if (!iso2 || !/^[a-z]{2}$/i.test(iso2)) return ""
52
+
53
+ const upper = iso2.toUpperCase()
54
+ try {
55
+ return namer(locale)?.of(upper) || upper
56
+ } catch {
57
+ return upper
58
+ }
59
+ }
package/src/locales/bg.ts CHANGED
@@ -36,16 +36,8 @@ export const bg: StorefrontLocale = {
36
36
  countryOfOrigin: "Произход",
37
37
  type: "Тип",
38
38
  weight: "Тегло",
39
+ weightUnit: "г",
39
40
  dimensions: "Размери",
40
- fastDelivery: "Бърза доставка",
41
- fastDeliveryDescription:
42
- "Вашата пратка ще пристигне в рамките на 3-5 работни дни до офис на куриер или до вашия адрес.",
43
- simpleExchanges: "Лесна замяна",
44
- simpleExchangesDescription:
45
- "Размерът не е точен? Без проблем — ще заменим продукта с нов.",
46
- easyReturns: "Лесно връщане",
47
- easyReturnsDescription:
48
- "Просто върнете продукта и ще ви възстановим парите. Без въпроси — ще направим всичко възможно връщането да бъде безпроблемно.",
49
41
  relatedProducts: "Подобни продукти",
50
42
  relatedProductsDescription:
51
43
  "Може да ви заинтересуват и тези продукти.",
@@ -160,8 +152,12 @@ export const bg: StorefrontLocale = {
160
152
  shipping: "Доставка",
161
153
  shippingCalc: "Ще бъде изчислена",
162
154
  shippingFree: "Безплатна",
155
+ // The WORD is the pack's (a Bulgarian store's prices include ДДС). The
156
+ // RATE is not: it is the store's fact and it changes by law. Declared
157
+ // and deliberately empty, so the tooltip does not appear until a store
158
+ // writes its own.
163
159
  tax: "Вкл. ДДС",
164
- taxTooltip: "ДДС 20% е включен в цените",
160
+ taxTooltip: undefined,
165
161
  total: "Обща сума",
166
162
  discount: "Отстъпка",
167
163
  discountCode: "Код за отстъпка",
package/src/locales/es.ts CHANGED
@@ -42,16 +42,8 @@ export const es: StorefrontLocale = {
42
42
  countryOfOrigin: "País de origen",
43
43
  type: "Tipo",
44
44
  weight: "Peso",
45
+ weightUnit: "g",
45
46
  dimensions: "Dimensiones",
46
- fastDelivery: "Entrega rápida",
47
- fastDeliveryDescription:
48
- "Tu paquete llegará en 3-5 días laborables a tu punto de recogida o a la comodidad de tu casa.",
49
- simpleExchanges: "Cambios sencillos",
50
- simpleExchangesDescription:
51
- "¿No te queda del todo bien? Sin problema, cambiamos tu producto por uno nuevo.",
52
- easyReturns: "Devoluciones fáciles",
53
- easyReturnsDescription:
54
- "Solo tienes que devolver tu producto y te reembolsamos el dinero. Sin preguntas: haremos todo lo posible para que tu devolución sea sencilla.",
55
47
  relatedProducts: "Productos relacionados",
56
48
  relatedProductsDescription:
57
49
  "Quizá también te interesen estos productos.",
@@ -164,8 +156,12 @@ export const es: StorefrontLocale = {
164
156
  shipping: "Envío",
165
157
  shippingCalc: "Se calcula en el pago",
166
158
  shippingFree: "Gratis",
159
+ // The WORD is the pack's. The RATE is not, and this one was WRONG: it
160
+ // said 20%, which is Bulgaria's rate, while Spain's IVA is 21%. It came
161
+ // from translating the old English default instead of asking whose fact
162
+ // it was. Declared and deliberately empty.
167
163
  tax: "IVA incl.",
168
- taxTooltip: "El IVA del 20% está incluido en los precios",
164
+ taxTooltip: undefined,
169
165
  total: "Total",
170
166
  discount: "Descuento",
171
167
  discountCode: "Código de descuento",
@@ -9,6 +9,13 @@ import { defaultOrderLabels, type OrderLabels } from "./labels"
9
9
 
10
10
  const OrderLabelsContext = createContext<OrderLabels>(defaultOrderLabels)
11
11
 
12
+ /**
13
+ * A nested provider INHERITS the one above it rather than resetting to the
14
+ * English defaults (fixed 2026-09-13, the same defect that made evoo's
15
+ * checkout English: see `checkout/context.tsx`). With no provider above,
16
+ * the context default IS the English baseline, so a single provider behaves
17
+ * exactly as before.
18
+ */
12
19
  export function OrderLabelsProvider({
13
20
  labels,
14
21
  children,
@@ -16,9 +23,8 @@ export function OrderLabelsProvider({
16
23
  labels?: Partial<OrderLabels>
17
24
  children: ReactNode
18
25
  }) {
19
- const merged = labels
20
- ? { ...defaultOrderLabels, ...labels }
21
- : defaultOrderLabels
26
+ const inherited = useContext(OrderLabelsContext)
27
+ const merged = labels ? { ...inherited, ...labels } : inherited
22
28
 
23
29
  return (
24
30
  <OrderLabelsContext.Provider value={merged}>
@@ -39,6 +39,13 @@ export type FieldProps = React.InputHTMLAttributes<HTMLInputElement> & {
39
39
  * confusing it for an error.
40
40
  */
41
41
  pulse?: boolean
42
+ /**
43
+ * A mark shown inside the control, before the value: a flag, a currency
44
+ * symbol. Decorative and click-through. The value and the floating label
45
+ * make room for it. Same slot name and behaviour as `SelectField`, so a
46
+ * country reads the same whether it is chosen or fixed.
47
+ */
48
+ leading?: React.ReactNode
42
49
  }
43
50
 
44
51
  export const Field = React.forwardRef<HTMLInputElement, FieldProps>(
@@ -52,6 +59,7 @@ export const Field = React.forwardRef<HTMLInputElement, FieldProps>(
52
59
  onFocus,
53
60
  onBlur,
54
61
  pulse,
62
+ leading,
55
63
  ...props
56
64
  },
57
65
  ref
@@ -94,7 +102,8 @@ export const Field = React.forwardRef<HTMLInputElement, FieldProps>(
94
102
  onBlur?.(e)
95
103
  }}
96
104
  className={cn(
97
- "w-full h-[44px] px-3 pt-[14px] pb-[2px] text-sm rounded-lg border",
105
+ "w-full h-[44px] pt-[14px] pb-[2px] text-sm rounded-lg border",
106
+ leading ? "pl-10 pr-3" : "px-3",
98
107
  "transition-colors duration-300 outline-none",
99
108
  "focus:border-primary focus:bg-primary/5",
100
109
  disabled
@@ -109,9 +118,15 @@ export const Field = React.forwardRef<HTMLInputElement, FieldProps>(
109
118
  )}
110
119
  {...props}
111
120
  />
121
+ {leading && (
122
+ <span className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none flex items-center">
123
+ {leading}
124
+ </span>
125
+ )}
112
126
  <span
113
127
  className={cn(
114
- "absolute pointer-events-none left-3 top-[14px] text-sm leading-4 origin-top-left",
128
+ "absolute pointer-events-none top-[14px] text-sm leading-4 origin-top-left",
129
+ leading ? "left-10" : "left-3",
115
130
  "transition-transform transition-colors duration-300 ease-out",
116
131
  showPulse ? "text-sky-600" : "text-muted-foreground",
117
132
  isActive ? "-translate-y-2 scale-[0.77]" : "translate-y-0 scale-100"
@@ -11,6 +11,13 @@ import { defaultProductLabels, type ProductLabels } from "./labels"
11
11
 
12
12
  const ProductLabelsContext = createContext<ProductLabels>(defaultProductLabels)
13
13
 
14
+ /**
15
+ * A nested provider INHERITS the one above it rather than resetting to the
16
+ * English defaults (fixed 2026-09-13, the same defect that made evoo's
17
+ * checkout English: see `checkout/context.tsx`). With no provider above,
18
+ * the context default IS the English baseline, so a single provider behaves
19
+ * exactly as before.
20
+ */
14
21
  export function ProductLabelsProvider({
15
22
  labels,
16
23
  children,
@@ -18,9 +25,8 @@ export function ProductLabelsProvider({
18
25
  labels?: Partial<ProductLabels>
19
26
  children: ReactNode
20
27
  }) {
21
- const merged = labels
22
- ? { ...defaultProductLabels, ...labels }
23
- : defaultProductLabels
28
+ const inherited = useContext(ProductLabelsContext)
29
+ const merged = labels ? { ...inherited, ...labels } : inherited
24
30
 
25
31
  return (
26
32
  <ProductLabelsContext.Provider value={merged}>
@@ -32,7 +32,9 @@ export {
32
32
  type AddToCartInput,
33
33
  } from "./product-actions"
34
34
  export { MobileActions, type MobileActionsProps } from "./mobile-actions"
35
- export { ProductTabs } from "./product-tabs"
35
+ export { ProductTabs, type ProductSection } from "./product-tabs"
36
+ export { ProductSpecs, hasProductSpecs } from "./product-specs"
37
+ export { ProductPromises, type ProductPromise } from "./product-promises"
36
38
  export { ProductInfo, type ProductInfoProps } from "./product-info"
37
39
  export { ProductPreview, type ProductPreviewProps } from "./product-preview"
38
40
  export { RelatedProducts, type RelatedProductsProps } from "./related-products"
@@ -17,13 +17,19 @@ export type ProductLabels = {
17
17
  countryOfOrigin: string
18
18
  type: string
19
19
  weight: string
20
+ /**
21
+ * The unit the weight is shown in. The platform stores a bare number, so
22
+ * the library has to name a unit and "g" is its guess; a store selling in
23
+ * pounds or kilos says so here rather than editing a component.
24
+ */
25
+ weightUnit: string
20
26
  dimensions: string
21
- fastDelivery: string
22
- fastDeliveryDescription: string
23
- simpleExchanges: string
24
- simpleExchangesDescription: string
25
- easyReturns: string
26
- easyReturnsDescription: string
27
+ // NO DELIVERY, EXCHANGE OR RETURN COPY HERE (removed 2026-09-13). Those
28
+ // six keys carried the store's PROMISES ("Your package will arrive in 3-5
29
+ // business days", "we'll refund your money"), which the library cannot
30
+ // know and must never assert for a merchant. They are now the store's own
31
+ // content: `ProductPromise[]` passed to `ProductTabs`, and the section
32
+ // does not render when the store gives none.
27
33
  relatedProducts: string
28
34
  relatedProductsDescription: string
29
35
  }
@@ -41,16 +47,8 @@ export const defaultProductLabels: ProductLabels = {
41
47
  countryOfOrigin: "Country of origin",
42
48
  type: "Type",
43
49
  weight: "Weight",
50
+ weightUnit: "g",
44
51
  dimensions: "Dimensions",
45
- fastDelivery: "Fast delivery",
46
- fastDeliveryDescription:
47
- "Your package will arrive in 3-5 business days at your pick up location or in the comfort of your home.",
48
- simpleExchanges: "Simple exchanges",
49
- simpleExchangesDescription:
50
- "Is the fit not quite right? No worries - we'll exchange your product for a new one.",
51
- easyReturns: "Easy returns",
52
- easyReturnsDescription:
53
- "Just return your product and we'll refund your money. No questions asked – we'll do our best to make sure your return is hassle-free.",
54
52
  relatedProducts: "Related products",
55
53
  relatedProductsDescription:
56
54
  "You might also want to check out these products.",
@@ -29,12 +29,43 @@ export function ProductInfo({ product }: ProductInfoProps) {
29
29
  >
30
30
  {product.title}
31
31
  </h2>
32
- <p
33
- className="text-sm text-muted-foreground whitespace-pre-line"
34
- data-testid="product-description"
35
- >
36
- {product.description}
37
- </p>
32
+ {product.description && (
33
+ <div
34
+ className={[
35
+ "text-sm text-muted-foreground",
36
+ // The editor's own structure, styled: paragraphs, lists,
37
+ // headings and links as the merchant wrote them.
38
+ "[&_p]:mb-3 [&_p:last-child]:mb-0",
39
+ "[&_ul]:list-disc [&_ul]:pl-5 [&_ul]:mb-3",
40
+ "[&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:mb-3",
41
+ "[&_li]:mb-1",
42
+ "[&_strong]:font-semibold [&_strong]:text-foreground",
43
+ "[&_b]:font-semibold [&_b]:text-foreground",
44
+ "[&_em]:italic",
45
+ "[&_h1]:text-base [&_h1]:font-semibold [&_h1]:text-foreground [&_h1]:mt-4 [&_h1]:mb-2",
46
+ "[&_h2]:text-base [&_h2]:font-semibold [&_h2]:text-foreground [&_h2]:mt-4 [&_h2]:mb-2",
47
+ "[&_h3]:text-sm [&_h3]:font-semibold [&_h3]:text-foreground [&_h3]:mt-3 [&_h3]:mb-1",
48
+ "[&_a]:underline [&_a]:text-foreground",
49
+ "[&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:italic",
50
+ "[&_table]:w-full [&_table]:text-left [&_td]:py-1 [&_th]:py-1 [&_th]:font-semibold",
51
+ "[&_img]:max-w-full [&_img]:h-auto [&_img]:rounded-lg",
52
+ ].join(" ")}
53
+ data-testid="product-description"
54
+ // The description is RICH TEXT, written in the admin's editor
55
+ // and stored as HTML. It used to render as {product.description}
56
+ // inside a <p>, so a shopper read the tags instead of the words
57
+ // (Alexander, on evoo, 2026-09-13).
58
+ //
59
+ // Rendering it raw is the platform's documented contract, the
60
+ // same one `api/content` states for pages and blog posts: every
61
+ // write path sanitizes on the way IN
62
+ // (`sanitizeContentHtml`: scripts, styles, event handlers and
63
+ // javascript: URLs are stripped, never escaped in), so the
64
+ // stored value is safe to render and sanitizing again on every
65
+ // read would only cost time.
66
+ dangerouslySetInnerHTML={{ __html: product.description }}
67
+ />
68
+ )}
38
69
  </div>
39
70
  </div>
40
71
  )
@@ -0,0 +1,61 @@
1
+ "use client"
2
+
3
+ import { RefreshCw, Truck, Undo2 } from "lucide-react"
4
+
5
+ /**
6
+ * ONE PROMISE THE STORE MAKES: a delivery time, an exchange, a return.
7
+ *
8
+ * A PROMISE IS NOT A LABEL. A label names a piece of the interface, so the
9
+ * library owns it and translates it. A promise states a fact about the
10
+ * merchant's business, which the library cannot know and must never assert
11
+ * for them. Until 2026-09-13 it did: this section shipped "Your package
12
+ * will arrive in 3-5 business days" and "we'll refund your money" as label
13
+ * DEFAULTS, translated into three languages, on behalf of every merchant on
14
+ * earth. Alexander found it on evoo's product page.
15
+ *
16
+ * So there is no default here and none is not an error: a store that says
17
+ * nothing shows nothing.
18
+ */
19
+ export type ProductPromise = {
20
+ /** A built-in mark, or none. */
21
+ icon?: "delivery" | "exchange" | "return"
22
+ title: string
23
+ body?: string
24
+ }
25
+
26
+ const ICONS = {
27
+ delivery: Truck,
28
+ exchange: RefreshCw,
29
+ return: Undo2,
30
+ } as const
31
+
32
+ /**
33
+ * The store's promises, in the order the store gave them. Any number: three
34
+ * is a convention, not a shape. Returns nothing for an empty list.
35
+ */
36
+ export function ProductPromises({ promises }: { promises: ProductPromise[] }) {
37
+ if (!promises.length) return null
38
+
39
+ return (
40
+ <div className="text-sm py-8">
41
+ <div className="grid grid-cols-1 gap-y-8">
42
+ {promises.map((promise, i) => {
43
+ const Icon = promise.icon ? ICONS[promise.icon] : null
44
+ return (
45
+ <div key={`${promise.title}-${i}`} className="flex items-start gap-x-2">
46
+ {Icon && (
47
+ <Icon className="w-5 h-5 text-muted-foreground flex-shrink-0 mt-0.5" />
48
+ )}
49
+ <div>
50
+ <span className="font-semibold text-foreground">{promise.title}</span>
51
+ {promise.body && (
52
+ <p className="max-w-sm text-muted-foreground">{promise.body}</p>
53
+ )}
54
+ </div>
55
+ </div>
56
+ )
57
+ })}
58
+ </div>
59
+ </div>
60
+ )
61
+ }
@@ -0,0 +1,65 @@
1
+ "use client"
2
+
3
+ import type { StoreProduct } from "../api/products"
4
+ import { useProductLabels } from "./context"
5
+
6
+ /**
7
+ * The product's physical facts: material, origin, type, weight, dimensions.
8
+ *
9
+ * IT RENDERS ONLY WHAT THE PRODUCT HAS, and nothing at all when it has
10
+ * none. That is the whole point of this file (2026-09-13). It used to be a
11
+ * fixed two-column grid of all five fields with "-" in every empty one, so
12
+ * a store selling food showed a table reading Material "-", Origin "-",
13
+ * Weight "-", Dimensions "-". Alexander found it on evoo. The admin has had
14
+ * an empty-cell law since 2026-08 for exactly this reason: a fact the
15
+ * merchant never entered is not a fact worth a row.
16
+ *
17
+ * A store mounts it if it wants it. `ProductTabs` offers it as a section
18
+ * and skips the section when this returns nothing.
19
+ */
20
+ export function ProductSpecs({ product }: { product: StoreProduct }) {
21
+ const labels = useProductLabels()
22
+
23
+ const rows: Array<{ label: string; value: string }> = []
24
+ const add = (label: string, value: string | null | undefined) => {
25
+ const v = typeof value === "string" ? value.trim() : value
26
+ if (v) rows.push({ label, value: String(v) })
27
+ }
28
+
29
+ add(labels.material, product.material)
30
+ add(labels.countryOfOrigin, product.origin_country)
31
+ add(labels.type, product.type?.value)
32
+ add(labels.weight, product.weight ? `${product.weight} ${labels.weightUnit}` : null)
33
+ add(
34
+ labels.dimensions,
35
+ product.length && product.width && product.height
36
+ ? `${product.length}L x ${product.width}W x ${product.height}H`
37
+ : null
38
+ )
39
+
40
+ if (rows.length === 0) return null
41
+
42
+ return (
43
+ <div className="text-sm py-8">
44
+ <div className="grid grid-cols-2 gap-x-8 gap-y-4">
45
+ {rows.map((row) => (
46
+ <div key={row.label}>
47
+ <span className="font-semibold text-foreground">{row.label}</span>
48
+ <p className="text-muted-foreground">{row.value}</p>
49
+ </div>
50
+ ))}
51
+ </div>
52
+ </div>
53
+ )
54
+ }
55
+
56
+ /** True when this product has at least one physical fact to show. */
57
+ export function hasProductSpecs(product: StoreProduct): boolean {
58
+ return !!(
59
+ product.material?.trim() ||
60
+ product.origin_country?.trim() ||
61
+ product.type?.value?.trim() ||
62
+ product.weight ||
63
+ (product.length && product.width && product.height)
64
+ )
65
+ }
@@ -1,169 +1,123 @@
1
- "use client"
2
-
3
- /**
4
- * PDP info/shipping accordion ported from
5
- * `@1click/ui/src/products/product-tabs.tsx` (v2.3.1). Data seam:
6
- * `HttpTypes.StoreProduct` Cartbase `StoreProduct` (same physical fields:
7
- * material, origin_country, type.value, weight, length/width/height).
8
- * Layout, morphing +/− trigger, and label wiring unchanged.
9
- */
10
- import * as AccordionPrimitive from "@radix-ui/react-accordion"
11
- import { Truck, RefreshCw, Undo2 } from "lucide-react"
12
-
13
- import type { StoreProduct } from "../api/products"
14
- import { useProductLabels } from "./context"
15
-
16
- type ProductTabsProps = {
17
- product: StoreProduct
18
- }
19
-
20
- export function ProductTabs({ product }: ProductTabsProps) {
21
- const labels = useProductLabels()
22
-
23
- const tabs = [
24
- {
25
- label: labels.productInformation,
26
- component: <ProductInfoTab product={product} />,
27
- },
28
- {
29
- label: labels.shippingAndReturns,
30
- component: <ShippingInfoTab />,
31
- },
32
- ]
33
-
34
- return (
35
- <div className="w-full">
36
- <AccordionPrimitive.Root type="multiple">
37
- {tabs.map((tab, i) => (
38
- <AccordionPrimitive.Item
39
- key={i}
40
- value={tab.label}
41
- className="border-t border-border py-3 last:border-b"
42
- >
43
- <AccordionPrimitive.Header className="px-1">
44
- <div className="flex w-full items-center justify-between">
45
- <span className="text-muted-foreground text-sm">{tab.label}</span>
46
- <AccordionPrimitive.Trigger>
47
- <MorphingTrigger />
48
- </AccordionPrimitive.Trigger>
49
- </div>
50
- </AccordionPrimitive.Header>
51
- <AccordionPrimitive.Content className="overflow-hidden data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down px-1">
52
- <div className="w-full">{tab.component}</div>
53
- </AccordionPrimitive.Content>
54
- </AccordionPrimitive.Item>
55
- ))}
56
- </AccordionPrimitive.Root>
57
- </div>
58
- )
59
- }
60
-
61
- function ProductInfoTab({ product }: ProductTabsProps) {
62
- const labels = useProductLabels()
63
-
64
- return (
65
- <div className="text-sm py-8">
66
- <div className="grid grid-cols-2 gap-x-8">
67
- <div className="flex flex-col gap-y-4">
68
- <div>
69
- <span className="font-semibold text-foreground">
70
- {labels.material}
71
- </span>
72
- <p className="text-muted-foreground">
73
- {product.material ? product.material : "-"}
74
- </p>
75
- </div>
76
- <div>
77
- <span className="font-semibold text-foreground">
78
- {labels.countryOfOrigin}
79
- </span>
80
- <p className="text-muted-foreground">
81
- {product.origin_country ? product.origin_country : "-"}
82
- </p>
83
- </div>
84
- <div>
85
- <span className="font-semibold text-foreground">{labels.type}</span>
86
- <p className="text-muted-foreground">
87
- {product.type ? product.type.value : "-"}
88
- </p>
89
- </div>
90
- </div>
91
- <div className="flex flex-col gap-y-4">
92
- <div>
93
- <span className="font-semibold text-foreground">
94
- {labels.weight}
95
- </span>
96
- <p className="text-muted-foreground">
97
- {product.weight ? `${product.weight} g` : "-"}
98
- </p>
99
- </div>
100
- <div>
101
- <span className="font-semibold text-foreground">
102
- {labels.dimensions}
103
- </span>
104
- <p className="text-muted-foreground">
105
- {product.length && product.width && product.height
106
- ? `${product.length}L x ${product.width}W x ${product.height}H`
107
- : "-"}
108
- </p>
109
- </div>
110
- </div>
111
- </div>
112
- </div>
113
- )
114
- }
115
-
116
- function ShippingInfoTab() {
117
- const labels = useProductLabels()
118
-
119
- return (
120
- <div className="text-sm py-8">
121
- <div className="grid grid-cols-1 gap-y-8">
122
- <div className="flex items-start gap-x-2">
123
- <Truck className="w-5 h-5 text-muted-foreground flex-shrink-0 mt-0.5" />
124
- <div>
125
- <span className="font-semibold text-foreground">
126
- {labels.fastDelivery}
127
- </span>
128
- <p className="max-w-sm text-muted-foreground">
129
- {labels.fastDeliveryDescription}
130
- </p>
131
- </div>
132
- </div>
133
- <div className="flex items-start gap-x-2">
134
- <RefreshCw className="w-5 h-5 text-muted-foreground flex-shrink-0 mt-0.5" />
135
- <div>
136
- <span className="font-semibold text-foreground">
137
- {labels.simpleExchanges}
138
- </span>
139
- <p className="max-w-sm text-muted-foreground">
140
- {labels.simpleExchangesDescription}
141
- </p>
142
- </div>
143
- </div>
144
- <div className="flex items-start gap-x-2">
145
- <Undo2 className="w-5 h-5 text-muted-foreground flex-shrink-0 mt-0.5" />
146
- <div>
147
- <span className="font-semibold text-foreground">
148
- {labels.easyReturns}
149
- </span>
150
- <p className="max-w-sm text-muted-foreground">
151
- {labels.easyReturnsDescription}
152
- </p>
153
- </div>
154
- </div>
155
- </div>
156
- </div>
157
- )
158
- }
159
-
160
- function MorphingTrigger() {
161
- return (
162
- <div className="text-muted-foreground hover:bg-muted rounded-lg relative p-1.5">
163
- <div className="h-5 w-5">
164
- <span className="bg-muted-foreground rounded-full absolute inset-y-[31.75%] left-[48%] right-1/2 w-[1.5px] transition-transform duration-300 group-data-[state=open]:rotate-90" />
165
- <span className="bg-muted-foreground rounded-full absolute inset-x-[31.75%] top-[48%] bottom-1/2 h-[1.5px] transition-transform duration-300 group-data-[state=open]:rotate-90 group-data-[state=open]:left-1/2 group-data-[state=open]:right-1/2" />
166
- </div>
167
- </div>
168
- )
169
- }
1
+ "use client"
2
+
3
+ /**
4
+ * The PDP accordion: ONE structure, and the sections are the store's.
5
+ *
6
+ * Rewritten 2026-09-13, on Alexander's reading of it: *"Why do we have the
7
+ * same component twice, one for product information and one for shipping
8
+ * and delivery? This is useless and redundant for a package that is for
9
+ * global use."* He is right, twice over.
10
+ *
11
+ * What was here: two bespoke tabs, always both, for one visual pattern. The
12
+ * first was a fixed grid of five physical fields with "-" in every empty
13
+ * one, so a store selling food rendered a table of dashes. The second
14
+ * asserted delivery, exchange and refund PROMISES out of label defaults,
15
+ * which the library cannot know for any merchant.
16
+ *
17
+ * What is here now: an accordion that renders the sections it is given, and
18
+ * NOTHING when it has none. The package still ships the two pieces stores
19
+ * usually want, `ProductSpecs` (only the facts the product has) and
20
+ * `ProductPromises` (only what the store says), both importable on their
21
+ * own, both skipped when they would be empty. Structure is ours; what a
22
+ * page says is the store's.
23
+ *
24
+ * Ported from `@1click/ui/src/products/product-tabs.tsx` (v2.3.1); the
25
+ * morphing +/− trigger is unchanged.
26
+ */
27
+ import * as AccordionPrimitive from "@radix-ui/react-accordion"
28
+ import type { ReactNode } from "react"
29
+
30
+ import type { StoreProduct } from "../api/products"
31
+ import { useProductLabels } from "./context"
32
+ import { ProductPromises, type ProductPromise } from "./product-promises"
33
+ import { ProductSpecs, hasProductSpecs } from "./product-specs"
34
+
35
+ export type ProductSection = {
36
+ title: string
37
+ content: ReactNode
38
+ }
39
+
40
+ type ProductTabsProps = {
41
+ product: StoreProduct
42
+ /**
43
+ * The store's delivery, exchange and return promises. No default: with
44
+ * none, the section does not exist, because an absent section is better
45
+ * than a promise the store never made.
46
+ */
47
+ promises?: ProductPromise[]
48
+ /**
49
+ * Drop the physical-facts section even when the product has facts. It is
50
+ * offered by default because most catalogues want it; it is already
51
+ * skipped when the product has nothing to put in it.
52
+ */
53
+ hideSpecs?: boolean
54
+ /** Anything else the store wants in the accordion, appended in order. */
55
+ sections?: ProductSection[]
56
+ }
57
+
58
+ export function ProductTabs({
59
+ product,
60
+ promises,
61
+ hideSpecs,
62
+ sections,
63
+ }: ProductTabsProps) {
64
+ const labels = useProductLabels()
65
+
66
+ const items: ProductSection[] = []
67
+
68
+ if (!hideSpecs && hasProductSpecs(product)) {
69
+ items.push({
70
+ title: labels.productInformation,
71
+ content: <ProductSpecs product={product} />,
72
+ })
73
+ }
74
+ if (promises?.length) {
75
+ items.push({
76
+ title: labels.shippingAndReturns,
77
+ content: <ProductPromises promises={promises} />,
78
+ })
79
+ }
80
+ if (sections?.length) items.push(...sections)
81
+
82
+ // Nothing to say, nothing drawn: no stray borders, no empty accordion.
83
+ if (items.length === 0) return null
84
+
85
+ return (
86
+ <div className="w-full">
87
+ <AccordionPrimitive.Root type="multiple">
88
+ {items.map((item, i) => (
89
+ <AccordionPrimitive.Item
90
+ key={`${item.title}-${i}`}
91
+ value={`${item.title}-${i}`}
92
+ className="border-t border-border py-3 last:border-b"
93
+ >
94
+ <AccordionPrimitive.Header className="px-1">
95
+ <div className="flex w-full items-center justify-between">
96
+ <span className="text-muted-foreground text-sm">{item.title}</span>
97
+ <AccordionPrimitive.Trigger>
98
+ <MorphingTrigger />
99
+ </AccordionPrimitive.Trigger>
100
+ </div>
101
+ </AccordionPrimitive.Header>
102
+ <AccordionPrimitive.Content className="overflow-hidden data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down px-1">
103
+ <div className="w-full">{item.content}</div>
104
+ </AccordionPrimitive.Content>
105
+ </AccordionPrimitive.Item>
106
+ ))}
107
+ </AccordionPrimitive.Root>
108
+ </div>
109
+ )
110
+ }
111
+
112
+ function MorphingTrigger() {
113
+ return (
114
+ <div className="text-muted-foreground hover:bg-muted rounded-lg relative p-1.5">
115
+ <div className="h-5 w-5">
116
+ <span className="bg-muted-foreground rounded-full absolute inset-y-[31.75%] left-[48%] right-1/2 w-[1.5px] transition-transform duration-300 group-data-[state=open]:rotate-90" />
117
+ <span className="bg-muted-foreground rounded-full absolute inset-x-[31.75%] top-[48%] bottom-1/2 h-[1.5px] transition-transform duration-300 group-data-[state=open]:rotate-90 group-data-[state=open]:left-1/2 group-data-[state=open]:right-1/2" />
118
+ </div>
119
+ </div>
120
+ )
121
+ }
122
+
123
+ export { type ProductTabsProps, type ProductPromise }
@@ -19,7 +19,8 @@ import type { PricingContextQuery } from "../api/types"
19
19
  import type { StoreProduct, StoreProductVariant } from "../api/products"
20
20
  import { ImageGallery } from "./image-gallery"
21
21
  import { ProductActions, type AddToCartInput } from "./product-actions"
22
- import { ProductTabs } from "./product-tabs"
22
+ import { ProductTabs, type ProductSection } from "./product-tabs"
23
+ import type { ProductPromise } from "./product-promises"
23
24
  import { RelatedProducts } from "./related-products"
24
25
  import { ProductInfo } from "./product-info"
25
26
  import { ProductActionsWrapper } from "./product-actions-wrapper"
@@ -32,6 +33,16 @@ type ProductTemplateProps = {
32
33
  addToCart: (input: AddToCartInput) => Promise<void>
33
34
  onAddToCart?: (product: StoreProduct, variant: StoreProductVariant) => void
34
35
  openCart?: () => void
36
+ /**
37
+ * The store's delivery, exchange and return promises, shown in the
38
+ * accordion. No default: with none, that section does not exist, because
39
+ * the library cannot promise anything on a merchant's behalf.
40
+ */
41
+ promises?: ProductPromise[]
42
+ /** Drop the physical-facts section even for a product that has facts. */
43
+ hideSpecs?: boolean
44
+ /** Anything else the store wants in the accordion, appended in order. */
45
+ sections?: ProductSection[]
35
46
  }
36
47
 
37
48
  export function ProductTemplate({
@@ -41,6 +52,9 @@ export function ProductTemplate({
41
52
  addToCart,
42
53
  onAddToCart,
43
54
  openCart,
55
+ promises,
56
+ hideSpecs,
57
+ sections,
44
58
  }: ProductTemplateProps) {
45
59
  if (!product || !product.id) {
46
60
  return notFound()
@@ -56,7 +70,12 @@ export function ProductTemplate({
56
70
  >
57
71
  <div className="flex flex-col sm:sticky sm:top-48 sm:py-0 sm:max-w-[300px] w-full py-8 gap-y-6">
58
72
  <ProductInfo product={product} />
59
- <ProductTabs product={product} />
73
+ <ProductTabs
74
+ product={product}
75
+ promises={promises}
76
+ hideSpecs={hideSpecs}
77
+ sections={sections}
78
+ />
60
79
  </div>
61
80
  <div className="block w-full relative">
62
81
  <ImageGallery images={images} />
@@ -52,7 +52,12 @@ const Thumbnail: React.FC<ThumbnailProps> = ({
52
52
  alt="Thumbnail"
53
53
  className="absolute inset-0 object-cover object-center"
54
54
  draggable={false}
55
- quality={50}
55
+ // NO `quality` here (removed 2026-09-13). It asked for 50 while
56
+ // the scaffolded `next.config` allows [75], so Next warned on
57
+ // EVERY product image of every scaffolded store, both halves
58
+ // shipping from us. Next's own default is 75, which the scaffold
59
+ // allows, and a store that wants a different number sets it in
60
+ // its own config rather than having the library pick for it.
56
61
  sizes="(max-width: 576px) 280px, (max-width: 768px) 360px, (max-width: 992px) 480px, 800px"
57
62
  fill
58
63
  />
@@ -178,9 +178,9 @@ export const defaultReviewsUiLabels: ReviewsUiLabels = {
178
178
  copied: "Copied",
179
179
  copyAria: "Copy the code",
180
180
  rewardNote:
181
- "We also emailed you the code. Use it on your next order it is valid for a single use.",
181
+ "We also emailed you the code. Use it on your next order. It is valid for a single use.",
182
182
  codeUnavailable:
183
- "The photo was saved. Something went wrong generating your discount code write to us at {email} and we will send it right away.",
183
+ "The photo was saved. Something went wrong generating your discount code. Write to us at {email} and we will send it right away.",
184
184
  viewProduct: "View product",
185
185
  toStore: "Back to the store",
186
186
  errUnsupported: "The file {name} is not a supported format.",