@cartbase/storefront 0.12.0 → 0.14.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.12.0",
3
+ "version": "0.14.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": {
@@ -158,6 +158,8 @@
158
158
  "./products/product-actions": "./src/products/product-actions.tsx",
159
159
  "./products/mobile-actions": "./src/products/mobile-actions.tsx",
160
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",
161
163
  "./products/product-info": "./src/products/product-info.tsx",
162
164
  "./products/product-preview": "./src/products/product-preview.tsx",
163
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",
@@ -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}
@@ -1136,9 +1136,11 @@ export function useCheckoutOrchestration({
1136
1136
  )
1137
1137
  })
1138
1138
  } else {
1139
- setPaymentError(
1140
- "Плащането не беше потвърдено. Моля, опитайте отново или изберете друг метод."
1141
- )
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)
1142
1144
  logError?.("place_order_error", "redirect_not_succeeded", {
1143
1145
  via: "3ds_return",
1144
1146
  cartId: cart.id,
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",
@@ -40,10 +40,35 @@ export interface StorefrontLocale {
40
40
  }
41
41
 
42
42
  /**
43
- * The two areas that read labels from a PROP rather than a context
44
- * (`store` and `reviews`) are handed their pack by the app:
45
- * `<StoreTemplate labels={locale.store} />`. The other four are served by
46
- * `StorefrontLocaleProvider`. That split is the components' existing
47
- * shape, not a decision of this layer.
43
+ * THE AREAS WHOSE PACK THE APP MUST HAND OVER AS A PROP.
44
+ *
45
+ * Not a style choice and not a gap in this layer: a React SERVER component
46
+ * cannot read a client context, and these areas are rendered on the server.
47
+ * `StorefrontLocaleProvider` therefore reaches the client components and
48
+ * nothing else, and a server-rendered heading stays English however many
49
+ * providers are mounted above it.
50
+ *
51
+ * Corrected 2026-09-13: this used to list `store` and `reviews` only, and
52
+ * Alexander found the consequence on evoo, a Bulgarian store whose product
53
+ * page ended in the English "Related products / You might also want to
54
+ * check out these products". `products` and `order` belong here too, and
55
+ * `ProductTemplate` was not even passing the pack down to its own
56
+ * related-products strip, which made that strip untranslatable without
57
+ * ejecting a file.
58
+ *
59
+ * Hand the pack to each page-level template once and everything inside it
60
+ * follows:
61
+ *
62
+ * <ProductTemplate labels={locale.products} /> // + the related strip
63
+ * <StoreTemplate labels={locale.store} />
64
+ * <SearchTemplate labels={locale.store} />
65
+ * <OrderCompletedTemplate labels={locale.order} />
66
+ * <ReviewWidget labels={locale.reviews} />
67
+ *
68
+ * The cart drawer is the same case for a different reason: it owns the
69
+ * cart's state, so the app mounts it and hands it `locale.cartDrawer`.
48
70
  */
49
- export type PropLabelAreas = Pick<StorefrontLocale, "store" | "reviews">
71
+ export type PropLabelAreas = Pick<
72
+ StorefrontLocale,
73
+ "products" | "store" | "order" | "reviews" | "cartDrawer"
74
+ >
@@ -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,9 @@ 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"
24
+ import type { ProductLabels } from "./labels"
23
25
  import { RelatedProducts } from "./related-products"
24
26
  import { ProductInfo } from "./product-info"
25
27
  import { ProductActionsWrapper } from "./product-actions-wrapper"
@@ -32,6 +34,27 @@ type ProductTemplateProps = {
32
34
  addToCart: (input: AddToCartInput) => Promise<void>
33
35
  onAddToCart?: (product: StoreProduct, variant: StoreProductVariant) => void
34
36
  openCart?: () => void
37
+ /**
38
+ * The store's delivery, exchange and return promises, shown in the
39
+ * accordion. No default: with none, that section does not exist, because
40
+ * the library cannot promise anything on a merchant's behalf.
41
+ */
42
+ promises?: ProductPromise[]
43
+ /**
44
+ * The store's product pack. REQUIRED FOR A TRANSLATED PAGE even when a
45
+ * `StorefrontLocaleProvider` is mounted: the related-products strip is a
46
+ * SERVER component and a server component cannot read a client context,
47
+ * so its heading stays English unless the pack arrives as a prop. Until
48
+ * 2026-09-13 this template did not accept one at all and did not pass one
49
+ * down, which made that strip untranslatable without ejecting the file
50
+ * (Alexander found it on evoo: a Bulgarian page ending in "Related
51
+ * products / You might also want to check out these products").
52
+ */
53
+ labels?: Partial<ProductLabels>
54
+ /** Drop the physical-facts section even for a product that has facts. */
55
+ hideSpecs?: boolean
56
+ /** Anything else the store wants in the accordion, appended in order. */
57
+ sections?: ProductSection[]
35
58
  }
36
59
 
37
60
  export function ProductTemplate({
@@ -41,6 +64,10 @@ export function ProductTemplate({
41
64
  addToCart,
42
65
  onAddToCart,
43
66
  openCart,
67
+ promises,
68
+ labels,
69
+ hideSpecs,
70
+ sections,
44
71
  }: ProductTemplateProps) {
45
72
  if (!product || !product.id) {
46
73
  return notFound()
@@ -56,7 +83,12 @@ export function ProductTemplate({
56
83
  >
57
84
  <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
85
  <ProductInfo product={product} />
59
- <ProductTabs product={product} />
86
+ <ProductTabs
87
+ product={product}
88
+ promises={promises}
89
+ hideSpecs={hideSpecs}
90
+ sections={sections}
91
+ />
60
92
  </div>
61
93
  <div className="block w-full relative">
62
94
  <ImageGallery images={images} />
@@ -104,6 +136,7 @@ export function ProductTemplate({
104
136
  client={client}
105
137
  product={product}
106
138
  pricingContext={pricingContext}
139
+ labels={labels}
107
140
  />
108
141
  </Suspense>
109
142
  </div>
@@ -31,7 +31,13 @@ type RelatedProductsProps = {
31
31
  pricingContext?: PricingContextQuery
32
32
  /** 1–24, server default 12. */
33
33
  limit?: number
34
- labels?: Pick<ProductLabels, "relatedProducts" | "relatedProductsDescription">
34
+ /**
35
+ * The store's product pack, or just the two keys this strip uses. It has
36
+ * to arrive as a PROP: this is a server component, so it cannot read
37
+ * `ProductLabelsProvider`, and its heading stays English without one.
38
+ * `ProductTemplate` passes the pack it is given straight through.
39
+ */
40
+ labels?: Partial<ProductLabels>
35
41
  /** Override the default ProductPreview with a store-specific card. */
36
42
  renderProduct?: ComponentType<{ product: StoreProduct }>
37
43
  }
@@ -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.",