@cartbase/storefront 0.1.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 +226 -0
- package/src/api/auth.ts +108 -0
- package/src/api/carts.ts +506 -0
- package/src/api/categories.ts +184 -0
- package/src/api/checkout.ts +440 -0
- package/src/api/collections.ts +130 -0
- package/src/api/consent.ts +75 -0
- package/src/api/content.ts +125 -0
- package/src/api/customers.ts +307 -0
- package/src/api/gift-cards.ts +112 -0
- package/src/api/http.ts +122 -0
- package/src/api/index.ts +29 -0
- package/src/api/integrations.ts +130 -0
- package/src/api/menus.ts +77 -0
- package/src/api/metaobjects.ts +136 -0
- package/src/api/orders.ts +290 -0
- package/src/api/products.ts +303 -0
- package/src/api/redirects.ts +37 -0
- package/src/api/regions.ts +141 -0
- package/src/api/reviews.ts +259 -0
- package/src/api/search.ts +133 -0
- package/src/api/types.ts +91 -0
- package/src/cart-drawer/cart-drawer.tsx +86 -0
- package/src/cart-drawer/context.tsx +569 -0
- package/src/cart-drawer/continue-shopping.tsx +28 -0
- package/src/cart-drawer/cross-sell-carousel.tsx +212 -0
- package/src/cart-drawer/cross-sell-sidebar.tsx +158 -0
- package/src/cart-drawer/empty.tsx +52 -0
- package/src/cart-drawer/free-gift.tsx +71 -0
- package/src/cart-drawer/gift-wrap.tsx +83 -0
- package/src/cart-drawer/header.tsx +52 -0
- package/src/cart-drawer/index.ts +69 -0
- package/src/cart-drawer/item/index.tsx +164 -0
- package/src/cart-drawer/item/quantity.tsx +100 -0
- package/src/cart-drawer/item/upsell.tsx +110 -0
- package/src/cart-drawer/item/variant.tsx +46 -0
- package/src/cart-drawer/labels-bg.ts +72 -0
- package/src/cart-drawer/labels.ts +119 -0
- package/src/cart-drawer/notes.tsx +131 -0
- package/src/cart-drawer/payment-badges.tsx +96 -0
- package/src/cart-drawer/promo-banner.tsx +43 -0
- package/src/cart-drawer/rewards-points.tsx +78 -0
- package/src/cart-drawer/sticky-footer.tsx +73 -0
- package/src/cart-drawer/summary-breakdown.tsx +196 -0
- package/src/cart-drawer/template.tsx +225 -0
- package/src/cart-drawer/tiered-progress.tsx +168 -0
- package/src/checkout/address-error-copy.ts +119 -0
- package/src/checkout/address-form.tsx +224 -0
- package/src/checkout/address-select.tsx +79 -0
- package/src/checkout/boxnow-locker-selector.tsx +410 -0
- package/src/checkout/checkout-client.tsx +222 -0
- package/src/checkout/company-details.tsx +94 -0
- package/src/checkout/compare-addresses.ts +40 -0
- package/src/checkout/context.tsx +76 -0
- package/src/checkout/discount-section.tsx +218 -0
- package/src/checkout/econt-office-selector.tsx +332 -0
- package/src/checkout/error-message.tsx +25 -0
- package/src/checkout/geocode.ts +154 -0
- package/src/checkout/gift-card-section.tsx +224 -0
- package/src/checkout/index.ts +74 -0
- package/src/checkout/labels-bg.ts +128 -0
- package/src/checkout/labels.ts +263 -0
- package/src/checkout/line-item-card.tsx +152 -0
- package/src/checkout/order-summary.tsx +524 -0
- package/src/checkout/payment-button.tsx +373 -0
- package/src/checkout/payment-error-copy.ts +269 -0
- package/src/checkout/payment-method-list.tsx +365 -0
- package/src/checkout/payment-wrapper.tsx +102 -0
- package/src/checkout/promotion-error-copy.ts +124 -0
- package/src/checkout/shipping-method-list.tsx +335 -0
- package/src/checkout/stripe-wrapper.tsx +165 -0
- package/src/checkout/use-checkout-orchestration.ts +1504 -0
- package/src/common/cart-button-client.tsx +39 -0
- package/src/common/cart-button.tsx +28 -0
- package/src/common/country-select.tsx +65 -0
- package/src/common/delete-button.tsx +66 -0
- package/src/common/index.ts +17 -0
- package/src/common/language-select.tsx +78 -0
- package/src/common/localized-link.tsx +45 -0
- package/src/common/skeleton.tsx +29 -0
- package/src/index.ts +12 -0
- package/src/lib/cart-helpers.ts +113 -0
- package/src/lib/dual-price.tsx +73 -0
- package/src/lib/get-percentage-diff.ts +5 -0
- package/src/lib/get-product-price.ts +133 -0
- package/src/lib/hooks/use-intersection.ts +30 -0
- package/src/lib/hooks/use-toggle-state.ts +25 -0
- package/src/lib/money.ts +73 -0
- package/src/lib/payment-constants.ts +66 -0
- package/src/lib/product.ts +22 -0
- package/src/lib/sort-products.ts +63 -0
- package/src/lib/store-api-error.ts +36 -0
- package/src/lib/utils.ts +16 -0
- package/src/order/context.tsx +32 -0
- package/src/order/index.ts +63 -0
- package/src/order/labels-bg.ts +39 -0
- package/src/order/labels.ts +79 -0
- package/src/order/order-address-card.tsx +47 -0
- package/src/order/order-completed-template.tsx +165 -0
- package/src/order/order-confirmation-header.tsx +65 -0
- package/src/order/order-delivery-card.tsx +258 -0
- package/src/order/order-help-section.tsx +47 -0
- package/src/order/order-item.tsx +201 -0
- package/src/order/order-items-list.tsx +52 -0
- package/src/order/order-payment-card.tsx +95 -0
- package/src/order/order-timeline.tsx +141 -0
- package/src/order/order-totals.tsx +245 -0
- package/src/primitives/field.tsx +125 -0
- package/src/primitives/select-field.tsx +77 -0
- package/src/primitives/ui/accordion.tsx +61 -0
- package/src/primitives/ui/button.tsx +68 -0
- package/src/primitives/ui/collapsible.tsx +16 -0
- package/src/primitives/ui/dialog.tsx +112 -0
- package/src/primitives/ui/input.tsx +30 -0
- package/src/primitives/ui/label.tsx +31 -0
- package/src/primitives/ui/popover.tsx +38 -0
- package/src/primitives/ui/select.tsx +163 -0
- package/src/primitives/ui/sheet.tsx +131 -0
- package/src/primitives/ui/tabs.tsx +62 -0
- package/src/products/context.tsx +34 -0
- package/src/products/image-gallery.tsx +43 -0
- package/src/products/index.ts +44 -0
- package/src/products/labels-bg.ts +35 -0
- package/src/products/labels.ts +57 -0
- package/src/products/mobile-actions.tsx +180 -0
- package/src/products/option-select.tsx +67 -0
- package/src/products/preview-price.tsx +36 -0
- package/src/products/product-actions-wrapper.tsx +58 -0
- package/src/products/product-actions.tsx +217 -0
- package/src/products/product-info.tsx +43 -0
- package/src/products/product-preview.tsx +49 -0
- package/src/products/product-price.tsx +69 -0
- package/src/products/product-tabs.tsx +169 -0
- package/src/products/product-template.tsx +114 -0
- package/src/products/purchase-options.tsx +130 -0
- package/src/products/related-products.tsx +86 -0
- package/src/products/thumbnail.tsx +71 -0
- package/src/products/variant-matching.ts +71 -0
- package/src/reviews-ui/helpers.ts +174 -0
- package/src/reviews-ui/index.ts +74 -0
- package/src/reviews-ui/labels-bg.ts +91 -0
- package/src/reviews-ui/labels.ts +199 -0
- package/src/reviews-ui/photo-upload.tsx +345 -0
- package/src/reviews-ui/review-list.tsx +249 -0
- package/src/reviews-ui/review-widget.tsx +224 -0
- package/src/reviews-ui/review-wizard.tsx +560 -0
- package/src/reviews-ui/star-badge.tsx +104 -0
- package/src/reviews-ui/wizard-state.ts +81 -0
- package/src/store/category-template.tsx +129 -0
- package/src/store/collection-template.tsx +139 -0
- package/src/store/index.ts +41 -0
- package/src/store/labels-bg.ts +22 -0
- package/src/store/labels.ts +52 -0
- package/src/store/paginated-products.tsx +116 -0
- package/src/store/pagination.tsx +103 -0
- package/src/store/search-params.ts +256 -0
- package/src/store/search-template.tsx +249 -0
- package/src/store/skeleton-product-grid.tsx +26 -0
- package/src/store/sort-select.tsx +81 -0
- package/src/store/store-template.tsx +65 -0
- package/src/tracking/attribution.ts +418 -0
- package/src/tracking/consent-banner.tsx +355 -0
- package/src/tracking/consent-init.tsx +44 -0
- package/src/tracking/consent.ts +243 -0
- package/src/tracking/fbq.ts +168 -0
- package/src/tracking/ga4.tsx +49 -0
- package/src/tracking/get-tracking-attribution.ts +224 -0
- package/src/tracking/get-tracking-config.ts +50 -0
- package/src/tracking/gtag.ts +200 -0
- package/src/tracking/index.ts +133 -0
- package/src/tracking/meta-pixel.tsx +166 -0
- package/src/tracking/rybbit-events.ts +242 -0
- package/src/tracking/rybbit.tsx +40 -0
- package/src/tracking/types.ts +185 -0
- package/src/tracking/use-engagement-time.ts +58 -0
- package/tailwind-preset.cjs +72 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from "react"
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
CONSENT_OPEN_EVENT,
|
|
7
|
+
applyConsent,
|
|
8
|
+
openConsentSettings,
|
|
9
|
+
readConsentCookie,
|
|
10
|
+
writeConsentCookie,
|
|
11
|
+
type ConsentChoices,
|
|
12
|
+
type ConsentCopy,
|
|
13
|
+
type ConsentLayout,
|
|
14
|
+
} from "./consent"
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* ConsentBanner — the built-in Consent Mode v2 UI.
|
|
18
|
+
*
|
|
19
|
+
* Ported from the barter reference implementation
|
|
20
|
+
* `src/components/storefront/consent/consent-banner.tsx` (itself from
|
|
21
|
+
* `@1click/ui/src/tracking/consent-banner.tsx` v2.3.1, shipped on Alenika
|
|
22
|
+
* d3d3d39), parameterized per docs/cards/consent-management.md: copy +
|
|
23
|
+
* layout + privacyHref + rejectOnFirstLayer come from the store's config
|
|
24
|
+
* (`GET /api/store/consent` — `copy` is REQUIRED here because the server
|
|
25
|
+
* payload is always complete; pick the locale with `pickConsentCopy()`).
|
|
26
|
+
* Class names use STOREFRONT theme tokens (bg-card / text-foreground / …)
|
|
27
|
+
* — no hardcoded colors; a storefront's theme defines them, which is how
|
|
28
|
+
* per-store accents work.
|
|
29
|
+
*
|
|
30
|
+
* Two layouts (merchant setting):
|
|
31
|
+
* - `modal` (default, per merchant decision 2026-07-18: bars get
|
|
32
|
+
* ignored, and an ignored banner = permanently denied consent): a
|
|
33
|
+
* centered blocking modal over a dark backdrop, body scroll locked,
|
|
34
|
+
* no click-outside dismiss and no X — the only exits are a consent
|
|
35
|
+
* choice. Shown once per stored choice (12-month cookie).
|
|
36
|
+
* - `banner-bottom`: a non-blocking bottom banner — NO backdrop, NO
|
|
37
|
+
* scroll lock, NOT aria-modal, and it must not trap focus (card
|
|
38
|
+
* trap). Same two layers, same exits.
|
|
39
|
+
*
|
|
40
|
+
* Layer 1: short text + accept (primary) + settings (ghost). A
|
|
41
|
+
* first-layer reject renders only when `rejectOnFirstLayer` is true —
|
|
42
|
+
* default follows the prevailing EU e-commerce pattern where declining
|
|
43
|
+
* lives one layer deeper. The settings layer ALWAYS carries a functional
|
|
44
|
+
* reject-all — reject exists and is reachable (not a cookie wall), which
|
|
45
|
+
* is what keeps the pattern defensible.
|
|
46
|
+
*
|
|
47
|
+
* Layer 2: purpose toggles + save + accept-all + reject-all.
|
|
48
|
+
* Re-openable any time via `openConsentSettings()` (see
|
|
49
|
+
* <ConsentSettingsLink>) so consent can be changed/withdrawn.
|
|
50
|
+
*
|
|
51
|
+
* The synchronous consent DEFAULT is <ConsentInit>'s job — this
|
|
52
|
+
* component only collects the choice and applies the live update.
|
|
53
|
+
* In `mode: "external"` the storefront simply does not mount this
|
|
54
|
+
* component (see `shouldRenderBanner()`) — gating keeps working off
|
|
55
|
+
* the shared cookie seam.
|
|
56
|
+
*
|
|
57
|
+
* z-[70]: above the cart drawer (z-[60]) — a blocking modal that can
|
|
58
|
+
* be covered isn't blocking. In practice it shows on first landing,
|
|
59
|
+
* before any drawer interaction.
|
|
60
|
+
*/
|
|
61
|
+
export function ConsentBanner({
|
|
62
|
+
copy,
|
|
63
|
+
layout = "modal",
|
|
64
|
+
privacyHref = "/cookies",
|
|
65
|
+
rejectOnFirstLayer = false,
|
|
66
|
+
}: {
|
|
67
|
+
/** Resolved per-locale copy from the store's consent config
|
|
68
|
+
* (`pickConsentCopy(consent.copy, locale)`). */
|
|
69
|
+
copy: ConsentCopy
|
|
70
|
+
layout?: ConsentLayout
|
|
71
|
+
/** Where the privacy link points — the store's cookie/privacy page. */
|
|
72
|
+
privacyHref?: string
|
|
73
|
+
/** Render a visible reject on layer 1 (strict-compliance mode). */
|
|
74
|
+
rejectOnFirstLayer?: boolean
|
|
75
|
+
}) {
|
|
76
|
+
const [visible, setVisible] = useState(false)
|
|
77
|
+
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
78
|
+
const [analytics, setAnalytics] = useState(true)
|
|
79
|
+
const [ads, setAds] = useState(true)
|
|
80
|
+
|
|
81
|
+
// First render decides visibility from the cookie — in an effect, not
|
|
82
|
+
// during render, so SSR HTML never includes the banner (no hydration
|
|
83
|
+
// mismatch, no flash for already-consented visitors).
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
const stored = readConsentCookie()
|
|
86
|
+
if (!stored) {
|
|
87
|
+
setVisible(true)
|
|
88
|
+
} else {
|
|
89
|
+
setAnalytics(stored.analytics)
|
|
90
|
+
setAds(stored.ads)
|
|
91
|
+
}
|
|
92
|
+
}, [])
|
|
93
|
+
|
|
94
|
+
// Footer settings link → re-open on the settings layer with the stored
|
|
95
|
+
// choice pre-loaded.
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
const onOpen = () => {
|
|
98
|
+
const stored = readConsentCookie()
|
|
99
|
+
if (stored) {
|
|
100
|
+
setAnalytics(stored.analytics)
|
|
101
|
+
setAds(stored.ads)
|
|
102
|
+
}
|
|
103
|
+
setSettingsOpen(true)
|
|
104
|
+
setVisible(true)
|
|
105
|
+
}
|
|
106
|
+
window.addEventListener(CONSENT_OPEN_EVENT, onOpen)
|
|
107
|
+
return () => window.removeEventListener(CONSENT_OPEN_EVENT, onOpen)
|
|
108
|
+
}, [])
|
|
109
|
+
|
|
110
|
+
// Body scroll lock — MODAL layout only. The bottom banner must leave
|
|
111
|
+
// the page fully usable (non-blocking by design).
|
|
112
|
+
const blocking = layout === "modal"
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (!visible || !blocking) return
|
|
115
|
+
const prev = document.body.style.overflow
|
|
116
|
+
document.body.style.overflow = "hidden"
|
|
117
|
+
return () => {
|
|
118
|
+
document.body.style.overflow = prev
|
|
119
|
+
}
|
|
120
|
+
}, [visible, blocking])
|
|
121
|
+
|
|
122
|
+
const decide = useCallback((choices: Omit<ConsentChoices, "ts">) => {
|
|
123
|
+
const full: ConsentChoices = { ...choices, ts: Date.now() }
|
|
124
|
+
writeConsentCookie(full)
|
|
125
|
+
applyConsent(full)
|
|
126
|
+
setVisible(false)
|
|
127
|
+
setSettingsOpen(false)
|
|
128
|
+
}, [])
|
|
129
|
+
|
|
130
|
+
if (!visible) return null
|
|
131
|
+
|
|
132
|
+
const card = (
|
|
133
|
+
<ConsentBannerCard
|
|
134
|
+
copy={copy}
|
|
135
|
+
privacyHref={privacyHref}
|
|
136
|
+
rejectOnFirstLayer={rejectOnFirstLayer}
|
|
137
|
+
settingsOpen={settingsOpen}
|
|
138
|
+
onOpenSettings={() => setSettingsOpen(true)}
|
|
139
|
+
analytics={analytics}
|
|
140
|
+
ads={ads}
|
|
141
|
+
onAnalyticsChange={setAnalytics}
|
|
142
|
+
onAdsChange={setAds}
|
|
143
|
+
onDecide={decide}
|
|
144
|
+
/>
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
if (blocking) {
|
|
148
|
+
return (
|
|
149
|
+
<div
|
|
150
|
+
role="dialog"
|
|
151
|
+
aria-modal="true"
|
|
152
|
+
aria-label={copy.settings_title}
|
|
153
|
+
className="fixed inset-0 z-[70] flex items-end justify-center bg-black/60 p-0 sm:items-center sm:p-6"
|
|
154
|
+
>
|
|
155
|
+
<div className="w-full rounded-t-2xl bg-card p-6 pb-[max(1.5rem,env(safe-area-inset-bottom))] shadow-2xl sm:max-w-lg sm:rounded-2xl sm:p-8">
|
|
156
|
+
{card}
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// banner-bottom: non-blocking region pinned to the viewport bottom.
|
|
163
|
+
return (
|
|
164
|
+
<div
|
|
165
|
+
role="region"
|
|
166
|
+
aria-label={copy.settings_title}
|
|
167
|
+
className="fixed inset-x-0 bottom-0 z-[70] flex justify-center p-0 sm:p-4"
|
|
168
|
+
>
|
|
169
|
+
<div className="w-full rounded-t-2xl border border-border bg-card p-6 pb-[max(1.5rem,env(safe-area-inset-bottom))] shadow-2xl sm:max-w-lg sm:rounded-2xl">
|
|
170
|
+
{card}
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* ConsentBannerCard — the pure two-layer card (no cookie, no window),
|
|
178
|
+
* split out so the layer markup is reusable/testable on its own. NOTE:
|
|
179
|
+
* its classes resolve against STOREFRONT theme tokens; the admin
|
|
180
|
+
* settings screen renders its live preview as a token-mapped mirror of
|
|
181
|
+
* this markup because the admin bundle deliberately does not define
|
|
182
|
+
* storefront theme colors.
|
|
183
|
+
*/
|
|
184
|
+
export function ConsentBannerCard({
|
|
185
|
+
copy,
|
|
186
|
+
privacyHref,
|
|
187
|
+
rejectOnFirstLayer,
|
|
188
|
+
settingsOpen,
|
|
189
|
+
onOpenSettings,
|
|
190
|
+
analytics,
|
|
191
|
+
ads,
|
|
192
|
+
onAnalyticsChange,
|
|
193
|
+
onAdsChange,
|
|
194
|
+
onDecide,
|
|
195
|
+
}: {
|
|
196
|
+
copy: ConsentCopy
|
|
197
|
+
privacyHref: string
|
|
198
|
+
rejectOnFirstLayer: boolean
|
|
199
|
+
settingsOpen: boolean
|
|
200
|
+
onOpenSettings: () => void
|
|
201
|
+
analytics: boolean
|
|
202
|
+
ads: boolean
|
|
203
|
+
onAnalyticsChange: (next: boolean) => void
|
|
204
|
+
onAdsChange: (next: boolean) => void
|
|
205
|
+
onDecide: (choices: Omit<ConsentChoices, "ts">) => void
|
|
206
|
+
}) {
|
|
207
|
+
if (!settingsOpen) {
|
|
208
|
+
return (
|
|
209
|
+
<div className="flex flex-col gap-5">
|
|
210
|
+
<div className="flex flex-col gap-2">
|
|
211
|
+
<p className="text-lg font-semibold text-foreground">{copy.title}</p>
|
|
212
|
+
<p className="text-sm leading-relaxed text-muted-foreground">
|
|
213
|
+
{copy.body}{" "}
|
|
214
|
+
<a
|
|
215
|
+
href={privacyHref}
|
|
216
|
+
className="underline underline-offset-2 hover:text-foreground"
|
|
217
|
+
>
|
|
218
|
+
{copy.privacy_link_label}
|
|
219
|
+
</a>
|
|
220
|
+
</p>
|
|
221
|
+
</div>
|
|
222
|
+
<div className="flex flex-col gap-2">
|
|
223
|
+
<button
|
|
224
|
+
type="button"
|
|
225
|
+
onClick={() => onDecide({ analytics: true, ads: true })}
|
|
226
|
+
className="h-12 w-full rounded-md bg-foreground text-sm font-bold uppercase tracking-[0.02em] text-card transition-colors hover:bg-foreground/90"
|
|
227
|
+
>
|
|
228
|
+
{copy.accept_label}
|
|
229
|
+
</button>
|
|
230
|
+
<button
|
|
231
|
+
type="button"
|
|
232
|
+
onClick={onOpenSettings}
|
|
233
|
+
className="h-12 w-full rounded-md border border-border text-sm font-medium text-foreground transition-colors hover:bg-muted"
|
|
234
|
+
>
|
|
235
|
+
{copy.settings_label}
|
|
236
|
+
</button>
|
|
237
|
+
{rejectOnFirstLayer ? (
|
|
238
|
+
<button
|
|
239
|
+
type="button"
|
|
240
|
+
onClick={() => onDecide({ analytics: false, ads: false })}
|
|
241
|
+
className="h-10 w-full rounded-md text-[13px] font-medium text-muted-foreground hover:text-foreground"
|
|
242
|
+
>
|
|
243
|
+
{copy.reject_label}
|
|
244
|
+
</button>
|
|
245
|
+
) : null}
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return (
|
|
252
|
+
<div className="flex flex-col gap-5">
|
|
253
|
+
<p className="text-lg font-semibold text-foreground">{copy.settings_title}</p>
|
|
254
|
+
|
|
255
|
+
<div className="flex flex-col gap-4">
|
|
256
|
+
<ConsentRow
|
|
257
|
+
label={copy.necessary_label}
|
|
258
|
+
description={copy.necessary_description}
|
|
259
|
+
checked
|
|
260
|
+
disabled
|
|
261
|
+
/>
|
|
262
|
+
<ConsentRow
|
|
263
|
+
label={copy.analytics_label}
|
|
264
|
+
description={copy.analytics_description}
|
|
265
|
+
checked={analytics}
|
|
266
|
+
onChange={onAnalyticsChange}
|
|
267
|
+
/>
|
|
268
|
+
<ConsentRow
|
|
269
|
+
label={copy.ads_label}
|
|
270
|
+
description={copy.ads_description}
|
|
271
|
+
checked={ads}
|
|
272
|
+
onChange={onAdsChange}
|
|
273
|
+
/>
|
|
274
|
+
</div>
|
|
275
|
+
|
|
276
|
+
<div className="flex flex-col gap-2">
|
|
277
|
+
<button
|
|
278
|
+
type="button"
|
|
279
|
+
onClick={() => onDecide({ analytics: true, ads: true })}
|
|
280
|
+
className="h-12 w-full rounded-md bg-foreground text-sm font-bold uppercase tracking-[0.02em] text-card transition-colors hover:bg-foreground/90"
|
|
281
|
+
>
|
|
282
|
+
{copy.accept_all_label}
|
|
283
|
+
</button>
|
|
284
|
+
<button
|
|
285
|
+
type="button"
|
|
286
|
+
onClick={() => onDecide({ analytics, ads })}
|
|
287
|
+
className="h-12 w-full rounded-md border border-border text-sm font-medium text-foreground transition-colors hover:bg-muted"
|
|
288
|
+
>
|
|
289
|
+
{copy.save_label}
|
|
290
|
+
</button>
|
|
291
|
+
<button
|
|
292
|
+
type="button"
|
|
293
|
+
onClick={() => onDecide({ analytics: false, ads: false })}
|
|
294
|
+
className="h-10 w-full rounded-md text-[13px] font-medium text-muted-foreground hover:text-foreground"
|
|
295
|
+
>
|
|
296
|
+
{copy.reject_all_label}
|
|
297
|
+
</button>
|
|
298
|
+
</div>
|
|
299
|
+
</div>
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function ConsentRow({
|
|
304
|
+
label,
|
|
305
|
+
description,
|
|
306
|
+
checked,
|
|
307
|
+
disabled = false,
|
|
308
|
+
onChange,
|
|
309
|
+
}: {
|
|
310
|
+
label: string
|
|
311
|
+
description: string
|
|
312
|
+
checked: boolean
|
|
313
|
+
disabled?: boolean
|
|
314
|
+
onChange?: (next: boolean) => void
|
|
315
|
+
}) {
|
|
316
|
+
return (
|
|
317
|
+
<label
|
|
318
|
+
className={`flex items-start justify-between gap-4 ${
|
|
319
|
+
disabled ? "" : "cursor-pointer"
|
|
320
|
+
}`}
|
|
321
|
+
>
|
|
322
|
+
<span className="flex-1">
|
|
323
|
+
<span className="block text-sm font-medium text-foreground">{label}</span>
|
|
324
|
+
<span className="block text-[13px] leading-relaxed text-muted-foreground">
|
|
325
|
+
{description}
|
|
326
|
+
</span>
|
|
327
|
+
</span>
|
|
328
|
+
<input
|
|
329
|
+
type="checkbox"
|
|
330
|
+
checked={checked}
|
|
331
|
+
disabled={disabled}
|
|
332
|
+
onChange={(e) => onChange?.(e.target.checked)}
|
|
333
|
+
className="mt-0.5 h-4 w-4 shrink-0 accent-foreground"
|
|
334
|
+
/>
|
|
335
|
+
</label>
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* ConsentSettingsLink — drop-in footer link that re-opens the banner on
|
|
341
|
+
* its settings layer ("withdraw consent as easily as it was given").
|
|
342
|
+
*/
|
|
343
|
+
export function ConsentSettingsLink({
|
|
344
|
+
children = "Настройки на бисквитките",
|
|
345
|
+
className,
|
|
346
|
+
}: {
|
|
347
|
+
children?: React.ReactNode
|
|
348
|
+
className?: string
|
|
349
|
+
}) {
|
|
350
|
+
return (
|
|
351
|
+
<button type="button" onClick={openConsentSettings} className={className}>
|
|
352
|
+
{children}
|
|
353
|
+
</button>
|
|
354
|
+
)
|
|
355
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { CONSENT_INIT_SNIPPET } from "./consent"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ConsentInit — the synchronous Consent Mode v2 "default" snippet.
|
|
5
|
+
*
|
|
6
|
+
* Ported from the barter reference implementation
|
|
7
|
+
* `src/components/storefront/consent/consent-init.tsx` (itself from
|
|
8
|
+
* `@1click/ui/src/tracking/consent-init.tsx` v2.3.1); the snippet lives in
|
|
9
|
+
* ./consent.ts (CONSENT_INIT_SNIPPET, character-identical) so its ordering
|
|
10
|
+
* contract is unit-testable.
|
|
11
|
+
*
|
|
12
|
+
* Render it as the FIRST child of <body> (App Router layouts don't
|
|
13
|
+
* expose <head> for inline scripts). A plain inline <script>
|
|
14
|
+
* (deliberately NOT next/script) executes at HTML-parse time — long
|
|
15
|
+
* before the afterInteractive <GA4>/<MetaPixel> loaders, which run
|
|
16
|
+
* post-hydration. Google's consent-mode docs require the default to be
|
|
17
|
+
* set synchronously ahead of the tag; an async default races the
|
|
18
|
+
* loader and the first hit of a returning consented visitor would
|
|
19
|
+
* ship denied.
|
|
20
|
+
*
|
|
21
|
+
* Behavior:
|
|
22
|
+
* - No stored choice (first visit) → default ALL DENIED. Tags still
|
|
23
|
+
* load (advanced consent mode): gtag sends cookieless pings, the
|
|
24
|
+
* Pixel queues client-side. The <ConsentBanner> then collects the
|
|
25
|
+
* choice and applies the live 'update'.
|
|
26
|
+
* - Stored choice → default mirrors it, so returning visitors are
|
|
27
|
+
* correct from the very first hit with no banner and no race.
|
|
28
|
+
*
|
|
29
|
+
* `ads_data_redaction` strips ad-click identifiers (gclid et al.) from
|
|
30
|
+
* the cookieless pings while ad_storage is denied, per Google's docs.
|
|
31
|
+
*
|
|
32
|
+
* The script is static (no props interpolated beyond the shared cookie
|
|
33
|
+
* name) — safe as a server-component inline script with a stable id.
|
|
34
|
+
* CARD TRAP: it must NEVER wait on the /api/store/consent fetch — the
|
|
35
|
+
* per-store config drives the BANNER, not this default.
|
|
36
|
+
*/
|
|
37
|
+
export function ConsentInit() {
|
|
38
|
+
return (
|
|
39
|
+
<script
|
|
40
|
+
id="1click-consent-init"
|
|
41
|
+
dangerouslySetInnerHTML={{ __html: CONSENT_INIT_SNIPPET }}
|
|
42
|
+
/>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consent Mode v2 — shared state + appliers.
|
|
3
|
+
*
|
|
4
|
+
* Ported from the barter reference implementation
|
|
5
|
+
* `src/components/storefront/consent/consent.ts` (itself a verbatim port of
|
|
6
|
+
* `@1click/ui/src/tracking/consent.ts` v2.3.1, shipped on Alenika d3d3d39).
|
|
7
|
+
* THE COOKIE SEAM IS THE CONTRACT: the `_1c_consent` cookie name, JSON
|
|
8
|
+
* shape `{analytics, ads, ts}` and 12-month lifetime must stay IDENTICAL
|
|
9
|
+
* across the reference impl, this package, and any external CMP — every
|
|
10
|
+
* barter-side gate reads it.
|
|
11
|
+
*
|
|
12
|
+
* One first-party cookie (`_1c_consent`) is the single source of truth
|
|
13
|
+
* for the visitor's choice. Three consumers read it:
|
|
14
|
+
*
|
|
15
|
+
* 1. `<ConsentInit>` (consent-init.tsx) — synchronous top-of-body script
|
|
16
|
+
* that sets gtag's consent DEFAULT (granted when a stored choice says
|
|
17
|
+
* so, denied otherwise) BEFORE any Google tag loads. Google's docs
|
|
18
|
+
* require the default to be set synchronously ahead of the tag;
|
|
19
|
+
* async = a race where the first hit ships unconsented.
|
|
20
|
+
* 2. `<MetaPixel>` (meta-pixel.tsx) — pushes fbq('consent', …) onto the
|
|
21
|
+
* queue before fbq('init'), gating the Pixel the same way.
|
|
22
|
+
* 3. `<ConsentBanner>` (consent-banner.tsx) — writes the cookie on the
|
|
23
|
+
* visitor's choice and applies the live update via `applyConsent()`.
|
|
24
|
+
*
|
|
25
|
+
* Advanced consent mode by design: tags always LOAD; while denied,
|
|
26
|
+
* - gtag sends cookieless pings (GA4 behavioral modeling stays alive)
|
|
27
|
+
* - fbq queues events client-side and sends nothing until 'grant'
|
|
28
|
+
*
|
|
29
|
+
* External-CMP escape hatch (consent-management card §4): in
|
|
30
|
+
* `mode: "external"` barter renders no banner — the merchant's CMP must
|
|
31
|
+
* write the SAME consent state, either by setting the cookie itself or by
|
|
32
|
+
* calling `setConsent()` below. That one seam keeps every barter-side
|
|
33
|
+
* gate working with any outside app.
|
|
34
|
+
*
|
|
35
|
+
* Per-store configuration comes from `GET /api/store/consent`
|
|
36
|
+
* (store-api.md "Consent") — defaults are ALWAYS applied server-side, so
|
|
37
|
+
* the payload is complete and renderable even for an unconfigured store.
|
|
38
|
+
* The structural `ConsentSettings` mirror + `shouldRenderBanner()` below
|
|
39
|
+
* let the storefront consume that payload without importing app code.
|
|
40
|
+
*
|
|
41
|
+
* Rybbit is deliberately OUTSIDE this system — it is the platform's
|
|
42
|
+
* self-hosted, cookieless analytics and needs no consent gate.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
export const CONSENT_COOKIE = "_1c_consent"
|
|
46
|
+
|
|
47
|
+
/** 12 months — the conventional consent-choice lifetime in the EU. */
|
|
48
|
+
export const CONSENT_MAX_AGE_SECONDS = 365 * 24 * 60 * 60
|
|
49
|
+
|
|
50
|
+
/** window CustomEvent name that re-opens the banner's settings layer
|
|
51
|
+
* (dispatched by `openConsentSettings()`, e.g. from a footer link). */
|
|
52
|
+
export const CONSENT_OPEN_EVENT = "1click:consent:open"
|
|
53
|
+
|
|
54
|
+
export type ConsentChoices = {
|
|
55
|
+
/** analytics_storage */
|
|
56
|
+
analytics: boolean
|
|
57
|
+
/** ad_storage + ad_user_data + ad_personalization (Consent Mode v2
|
|
58
|
+
* treats the three as one merchant-facing "advertising" purpose). */
|
|
59
|
+
ads: boolean
|
|
60
|
+
/** epoch ms of the choice — lets a future policy change re-prompt. */
|
|
61
|
+
ts: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function readConsentCookie(): ConsentChoices | null {
|
|
65
|
+
if (typeof document === "undefined") return null
|
|
66
|
+
try {
|
|
67
|
+
const match = document.cookie.match(
|
|
68
|
+
new RegExp(`(?:^|; )${CONSENT_COOKIE}=([^;]*)`)
|
|
69
|
+
)
|
|
70
|
+
if (!match?.[1]) return null
|
|
71
|
+
const parsed = JSON.parse(decodeURIComponent(match[1]))
|
|
72
|
+
if (typeof parsed !== "object" || parsed === null) return null
|
|
73
|
+
return {
|
|
74
|
+
analytics: Boolean(parsed.analytics),
|
|
75
|
+
ads: Boolean(parsed.ads),
|
|
76
|
+
ts: Number(parsed.ts) || 0,
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function writeConsentCookie(choices: ConsentChoices): void {
|
|
84
|
+
if (typeof document === "undefined") return
|
|
85
|
+
const value = encodeURIComponent(JSON.stringify(choices))
|
|
86
|
+
document.cookie = `${CONSENT_COOKIE}=${value}; Max-Age=${CONSENT_MAX_AGE_SECONDS}; Path=/; SameSite=Lax`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Push the visitor's (new) choice to both vendors at runtime.
|
|
91
|
+
*
|
|
92
|
+
* gtag: 'update' after the synchronous 'default' — the documented CMP
|
|
93
|
+
* flow. On grant, subsequent hits carry consented state and gtag writes
|
|
94
|
+
* its cookies; the pre-choice pageview stays cookieless (modeled).
|
|
95
|
+
* fbq: 'grant' flushes the queued events, 'revoke' resumes queueing.
|
|
96
|
+
*/
|
|
97
|
+
export function applyConsent(choices: ConsentChoices): void {
|
|
98
|
+
if (typeof window === "undefined") return
|
|
99
|
+
|
|
100
|
+
const w = window as any
|
|
101
|
+
w.dataLayer = w.dataLayer || []
|
|
102
|
+
// Command-style push — gtag() must receive `arguments`, not an array.
|
|
103
|
+
function gtag(..._args: unknown[]) {
|
|
104
|
+
// eslint-disable-next-line prefer-rest-params
|
|
105
|
+
w.dataLayer.push(arguments)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const ad = choices.ads ? "granted" : "denied"
|
|
109
|
+
gtag("consent", "update", {
|
|
110
|
+
ad_storage: ad,
|
|
111
|
+
ad_user_data: ad,
|
|
112
|
+
ad_personalization: ad,
|
|
113
|
+
analytics_storage: choices.analytics ? "granted" : "denied",
|
|
114
|
+
})
|
|
115
|
+
gtag("set", "ads_data_redaction", !choices.ads)
|
|
116
|
+
|
|
117
|
+
if (typeof w.fbq === "function") {
|
|
118
|
+
w.fbq("consent", choices.ads ? "grant" : "revoke")
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The documented external-CMP entry point: write the shared consent state
|
|
124
|
+
* (cookie) AND apply the live vendor update in one call. An outside CMP
|
|
125
|
+
* that calls this — or writes the cookie in the same shape — drives every
|
|
126
|
+
* barter-side gate exactly like the built-in banner does.
|
|
127
|
+
*/
|
|
128
|
+
export function setConsent(choices: Omit<ConsentChoices, "ts"> & { ts?: number }): void {
|
|
129
|
+
const full: ConsentChoices = {
|
|
130
|
+
analytics: choices.analytics,
|
|
131
|
+
ads: choices.ads,
|
|
132
|
+
ts: choices.ts ?? Date.now(),
|
|
133
|
+
}
|
|
134
|
+
writeConsentCookie(full)
|
|
135
|
+
applyConsent(full)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Re-open the consent banner (settings layer) from anywhere — e.g. a
|
|
139
|
+
* "Настройки на бисквитките" footer link. No-op if no banner mounted. */
|
|
140
|
+
export function openConsentSettings(): void {
|
|
141
|
+
if (typeof window === "undefined") return
|
|
142
|
+
window.dispatchEvent(new CustomEvent(CONSENT_OPEN_EVENT))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The synchronous Consent Mode v2 "default" snippet — the crown jewel
|
|
147
|
+
* (card trap: NEVER async, NEVER config-dependent-late). Exported as a
|
|
148
|
+
* string so the ordering contract is unit-testable without a DOM:
|
|
149
|
+
* character-identical to the shipped @1click/ui v2.3.1 snippet.
|
|
150
|
+
* `<ConsentInit>` renders it as a plain inline <script>.
|
|
151
|
+
*/
|
|
152
|
+
export const CONSENT_INIT_SNIPPET = `
|
|
153
|
+
(function(){
|
|
154
|
+
var c=null;
|
|
155
|
+
try{
|
|
156
|
+
var m=document.cookie.match(/(?:^|; )${CONSENT_COOKIE}=([^;]*)/);
|
|
157
|
+
if(m){c=JSON.parse(decodeURIComponent(m[1]));}
|
|
158
|
+
}catch(e){}
|
|
159
|
+
var ads=!!(c&&c.ads), an=!!(c&&c.analytics);
|
|
160
|
+
window.dataLayer=window.dataLayer||[];
|
|
161
|
+
function gtag(){dataLayer.push(arguments);}
|
|
162
|
+
gtag('consent','default',{
|
|
163
|
+
'ad_storage':ads?'granted':'denied',
|
|
164
|
+
'ad_user_data':ads?'granted':'denied',
|
|
165
|
+
'ad_personalization':ads?'granted':'denied',
|
|
166
|
+
'analytics_storage':an?'granted':'denied'
|
|
167
|
+
});
|
|
168
|
+
gtag('set','ads_data_redaction',!ads);
|
|
169
|
+
})();
|
|
170
|
+
`.trim()
|
|
171
|
+
|
|
172
|
+
// ── Per-store consent config (GET /api/store/consent) ────────────────
|
|
173
|
+
//
|
|
174
|
+
// Structural mirror of the store consent payload — the exact allowlist
|
|
175
|
+
// `publicConsentPayload()` serves (code truth: `src/lib/consent/model.ts`;
|
|
176
|
+
// contract: store-api.md "Consent"). Declared locally so the package
|
|
177
|
+
// never imports app code; the shapes are asserted against the live
|
|
178
|
+
// endpoint by the contract tests.
|
|
179
|
+
|
|
180
|
+
/** `modal` = blocking centered modal (default — bars get ignored);
|
|
181
|
+
* `banner-bottom` = non-blocking bottom banner (must NOT trap focus). */
|
|
182
|
+
export type ConsentLayout = "modal" | "banner-bottom"
|
|
183
|
+
|
|
184
|
+
/** `builtin` = the package renders its own banner. `external` = the
|
|
185
|
+
* merchant embeds an outside CMP; render NO banner — the CMP writes the
|
|
186
|
+
* same `_1c_consent` cookie (or calls `setConsent()`). */
|
|
187
|
+
export type ConsentMode = "builtin" | "external"
|
|
188
|
+
|
|
189
|
+
/** Per-locale banner copy — complete in the server payload (defaults are
|
|
190
|
+
* merged server-side; no client-side fallback logic is needed). */
|
|
191
|
+
export type ConsentCopy = {
|
|
192
|
+
/** Layer 1 */
|
|
193
|
+
title: string
|
|
194
|
+
body: string
|
|
195
|
+
privacy_link_label: string
|
|
196
|
+
accept_label: string
|
|
197
|
+
settings_label: string
|
|
198
|
+
reject_label: string
|
|
199
|
+
/** Layer 2 (preferences) */
|
|
200
|
+
settings_title: string
|
|
201
|
+
accept_all_label: string
|
|
202
|
+
save_label: string
|
|
203
|
+
reject_all_label: string
|
|
204
|
+
necessary_label: string
|
|
205
|
+
necessary_description: string
|
|
206
|
+
analytics_label: string
|
|
207
|
+
analytics_description: string
|
|
208
|
+
ads_label: string
|
|
209
|
+
ads_description: string
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The `consent` object of `GET /api/store/consent`. */
|
|
213
|
+
export type ConsentSettings = {
|
|
214
|
+
enabled: boolean
|
|
215
|
+
mode: ConsentMode
|
|
216
|
+
layout: ConsentLayout
|
|
217
|
+
privacy_href: string
|
|
218
|
+
reject_on_first_layer: boolean
|
|
219
|
+
copy: Record<string, ConsentCopy>
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Mount `<ConsentBanner>` only when this is true. `external` mode renders
|
|
224
|
+
* NO barter banner — the outside CMP owns the UI while `<ConsentInit>` +
|
|
225
|
+
* cookie gating keep working (the escape-hatch seam). Mirrors
|
|
226
|
+
* `shouldRenderBanner` in `src/lib/consent/model.ts`.
|
|
227
|
+
*/
|
|
228
|
+
export function shouldRenderBanner(settings: ConsentSettings): boolean {
|
|
229
|
+
return settings.enabled && settings.mode === "builtin"
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Pick the copy for a locale from the server payload, falling back to
|
|
234
|
+
* `en` then to the first available locale. The server always ships
|
|
235
|
+
* complete copy per locale (defaults merged server-side), so this is
|
|
236
|
+
* pure selection — no field-level merging.
|
|
237
|
+
*/
|
|
238
|
+
export function pickConsentCopy(
|
|
239
|
+
copy: Record<string, ConsentCopy>,
|
|
240
|
+
locale: string
|
|
241
|
+
): ConsentCopy | null {
|
|
242
|
+
return copy[locale] ?? copy["en"] ?? Object.values(copy)[0] ?? null
|
|
243
|
+
}
|