@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 +4 -1
- package/src/cart-drawer/labels.ts +5 -1
- package/src/checkout/address-form.tsx +14 -7
- package/src/checkout/context.tsx +19 -2
- package/src/checkout/labels.ts +17 -3
- package/src/checkout/order-summary.tsx +25 -16
- package/src/checkout/use-checkout-orchestration.ts +10 -4
- package/src/lib/country-name.ts +59 -0
- package/src/locales/bg.ts +6 -10
- package/src/locales/es.ts +6 -10
- package/src/order/context.tsx +9 -3
- package/src/primitives/field.tsx +17 -2
- package/src/products/context.tsx +9 -3
- 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": {
|
|
@@ -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
|
-
|
|
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
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
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
|
package/src/checkout/context.tsx
CHANGED
|
@@ -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
|
-
...
|
|
80
|
+
...inherited.labels,
|
|
64
81
|
...labelOverrides,
|
|
65
82
|
}
|
|
66
83
|
return (
|
|
67
84
|
<CheckoutContext.Provider
|
|
68
85
|
value={{
|
|
69
86
|
labels,
|
|
70
|
-
orderConfirmedPath: orderConfirmedPath ??
|
|
87
|
+
orderConfirmedPath: orderConfirmedPath ?? inherited.orderConfirmedPath,
|
|
71
88
|
}}
|
|
72
89
|
>
|
|
73
90
|
{children}
|
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}
|
|
@@ -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
|
-
?
|
|
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
|
-
|
|
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:
|
|
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/order/context.tsx
CHANGED
|
@@ -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
|
|
20
|
-
|
|
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}>
|
package/src/primitives/field.tsx
CHANGED
|
@@ -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]
|
|
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
|
|
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"
|
package/src/products/context.tsx
CHANGED
|
@@ -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
|
|
22
|
-
|
|
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}>
|
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.",
|