@cartbase/storefront 0.12.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 +3 -1
- package/src/cart-drawer/labels.ts +5 -1
- package/src/checkout/labels.ts +17 -3
- package/src/checkout/order-summary.tsx +25 -16
- package/src/checkout/use-checkout-orchestration.ts +5 -3
- package/src/locales/bg.ts +6 -10
- package/src/locales/es.ts +6 -10
- package/src/products/index.ts +3 -1
- package/src/products/labels.ts +13 -15
- package/src/products/product-info.tsx +37 -6
- package/src/products/product-promises.tsx +61 -0
- package/src/products/product-specs.tsx +65 -0
- package/src/products/product-tabs.tsx +123 -169
- package/src/products/product-template.tsx +21 -2
- package/src/products/thumbnail.tsx +6 -1
- package/src/reviews-ui/labels.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cartbase/storefront",
|
|
3
|
-
"version": "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": {
|
|
@@ -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
|
-
|
|
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",
|
package/src/checkout/labels.ts
CHANGED
|
@@ -81,7 +81,13 @@ export type CheckoutLabels = {
|
|
|
81
81
|
shippingCalc: string
|
|
82
82
|
shippingFree: string
|
|
83
83
|
tax: string
|
|
84
|
-
|
|
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
|
-
|
|
310
|
-
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
164
|
+
taxTooltip: undefined,
|
|
169
165
|
total: "Total",
|
|
170
166
|
discount: "Descuento",
|
|
171
167
|
discountCode: "Código de descuento",
|
package/src/products/index.ts
CHANGED
|
@@ -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"
|
package/src/products/labels.ts
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
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
|
|
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
|
/>
|
package/src/reviews-ui/labels.ts
CHANGED
|
@@ -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
|
|
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
|
|
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.",
|