@cartbase/storefront 0.17.0 → 0.18.1
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/LICENSE +21 -21
- package/package.json +3 -1
- package/src/api/auth.ts +108 -108
- package/src/api/carts.ts +523 -523
- package/src/api/categories.ts +184 -184
- package/src/api/checkout.ts +526 -526
- package/src/api/collections.ts +130 -130
- package/src/api/consent.ts +75 -75
- package/src/api/content.ts +125 -125
- package/src/api/customers.ts +303 -303
- package/src/api/gift-cards.ts +112 -112
- package/src/api/http.ts +180 -180
- package/src/api/index.ts +30 -30
- package/src/api/menus.ts +77 -77
- package/src/api/metaobjects.ts +136 -136
- package/src/api/orders.ts +290 -290
- package/src/api/products.ts +43 -1
- package/src/api/redirects.ts +37 -37
- package/src/api/regions.ts +200 -200
- package/src/api/reviews.ts +259 -259
- package/src/api/search.ts +163 -163
- package/src/api/store.ts +35 -35
- package/src/api/types.ts +91 -91
- package/src/cart-drawer/context.tsx +367 -168
- package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
- package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
- package/src/cart-drawer/gift-wrap.tsx +82 -82
- package/src/cart-drawer/index.ts +73 -68
- package/src/cart-drawer/item/index.tsx +162 -162
- package/src/cart-drawer/item/quantity.tsx +8 -14
- package/src/cart-drawer/item/upsell.tsx +110 -110
- package/src/cart-drawer/labels.ts +123 -123
- package/src/cart-drawer/mutation-queue.ts +78 -0
- package/src/cart-drawer/notes.tsx +131 -131
- package/src/cart-drawer/sticky-footer.tsx +73 -73
- package/src/cart-drawer/summary-breakdown.tsx +197 -197
- package/src/checkout/address-error-copy.ts +117 -117
- package/src/checkout/boxnow-locker-selector.tsx +410 -410
- package/src/checkout/compare-addresses.ts +40 -40
- package/src/checkout/discount-section.tsx +218 -218
- package/src/checkout/error-copy-codes.ts +63 -63
- package/src/checkout/geocode.ts +154 -154
- package/src/checkout/gift-card-section.tsx +224 -224
- package/src/checkout/index.ts +72 -72
- package/src/checkout/labels.ts +495 -495
- package/src/checkout/payment-button.tsx +372 -372
- package/src/checkout/payment-error-copy.ts +154 -154
- package/src/checkout/promotion-error-copy.ts +91 -91
- package/src/checkout/use-checkout-orchestration.ts +1604 -1592
- package/src/common/country-flag.tsx +52 -52
- package/src/common/country-select.tsx +11 -11
- package/src/common/index.ts +20 -20
- package/src/common/market-select.tsx +57 -57
- package/src/index.ts +12 -12
- package/src/lib/cookie-names.ts +14 -0
- package/src/lib/country-name.ts +59 -59
- package/src/lib/get-product-price.ts +133 -133
- package/src/lib/media-image.tsx +39 -39
- package/src/lib/payment-constants.ts +53 -53
- package/src/lib/platform.ts +13 -13
- package/src/lib/price.tsx +39 -39
- package/src/lib/store-api-error.ts +36 -36
- package/src/lib/variant-caption.ts +32 -32
- package/src/locales/bg.ts +467 -467
- package/src/locales/context.ts +37 -37
- package/src/locales/en.ts +26 -26
- package/src/locales/es.ts +466 -466
- package/src/locales/index.ts +19 -19
- package/src/locales/provider.tsx +59 -59
- package/src/locales/types.ts +77 -77
- package/src/order/index.ts +62 -62
- package/src/order/labels.ts +79 -79
- package/src/order/order-totals.tsx +250 -250
- package/src/primitives/select-field.tsx +93 -93
- package/src/products/image-gallery.tsx +43 -43
- package/src/products/index.ts +68 -59
- package/src/products/option-select.tsx +67 -67
- package/src/products/product-actions-wrapper.tsx +63 -62
- package/src/products/product-actions.tsx +151 -150
- package/src/products/product-price.tsx +69 -69
- package/src/products/product-promises.tsx +61 -61
- package/src/products/product-specs.tsx +65 -65
- package/src/products/product-tabs.tsx +123 -123
- package/src/products/product-template.tsx +163 -162
- package/src/products/purchase-options.tsx +130 -130
- package/src/products/sets.ts +110 -0
- package/src/products/use-product-actions.ts +199 -164
- package/src/products/variant-matching.ts +71 -71
- package/src/products/variant-url.ts +74 -74
- package/src/reviews-ui/index.ts +73 -73
- package/src/store/category-template.tsx +136 -136
- package/src/store/index.ts +40 -40
- package/src/tracking/chatgpt-pixel.tsx +99 -99
- package/src/tracking/consent-init.tsx +62 -62
- package/src/tracking/events.ts +348 -348
- package/src/tracking/ga4.tsx +93 -93
- package/src/tracking/google-ads.ts +84 -84
- package/src/tracking/gtm.tsx +60 -60
- package/src/tracking/inline-script.ts +49 -49
- package/src/tracking/oaiq.ts +206 -206
- package/src/tracking/tiktok-pixel.tsx +91 -91
- package/src/tracking/track-init.tsx +56 -56
- package/src/tracking/track-order-purchase.tsx +122 -122
- package/src/tracking/ttq.ts +180 -180
- package/src/tracking/use-tracking-config.ts +54 -54
- package/theme/index.css +25 -25
- package/theme/theme.css +150 -150
- package/theme/tokens.css +106 -106
|
@@ -1,61 +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
|
-
}
|
|
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
|
+
}
|
|
@@ -1,65 +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
|
+
"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,123 +1,123 @@
|
|
|
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 }
|
|
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 }
|