@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.
Files changed (176) hide show
  1. package/package.json +226 -0
  2. package/src/api/auth.ts +108 -0
  3. package/src/api/carts.ts +506 -0
  4. package/src/api/categories.ts +184 -0
  5. package/src/api/checkout.ts +440 -0
  6. package/src/api/collections.ts +130 -0
  7. package/src/api/consent.ts +75 -0
  8. package/src/api/content.ts +125 -0
  9. package/src/api/customers.ts +307 -0
  10. package/src/api/gift-cards.ts +112 -0
  11. package/src/api/http.ts +122 -0
  12. package/src/api/index.ts +29 -0
  13. package/src/api/integrations.ts +130 -0
  14. package/src/api/menus.ts +77 -0
  15. package/src/api/metaobjects.ts +136 -0
  16. package/src/api/orders.ts +290 -0
  17. package/src/api/products.ts +303 -0
  18. package/src/api/redirects.ts +37 -0
  19. package/src/api/regions.ts +141 -0
  20. package/src/api/reviews.ts +259 -0
  21. package/src/api/search.ts +133 -0
  22. package/src/api/types.ts +91 -0
  23. package/src/cart-drawer/cart-drawer.tsx +86 -0
  24. package/src/cart-drawer/context.tsx +569 -0
  25. package/src/cart-drawer/continue-shopping.tsx +28 -0
  26. package/src/cart-drawer/cross-sell-carousel.tsx +212 -0
  27. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -0
  28. package/src/cart-drawer/empty.tsx +52 -0
  29. package/src/cart-drawer/free-gift.tsx +71 -0
  30. package/src/cart-drawer/gift-wrap.tsx +83 -0
  31. package/src/cart-drawer/header.tsx +52 -0
  32. package/src/cart-drawer/index.ts +69 -0
  33. package/src/cart-drawer/item/index.tsx +164 -0
  34. package/src/cart-drawer/item/quantity.tsx +100 -0
  35. package/src/cart-drawer/item/upsell.tsx +110 -0
  36. package/src/cart-drawer/item/variant.tsx +46 -0
  37. package/src/cart-drawer/labels-bg.ts +72 -0
  38. package/src/cart-drawer/labels.ts +119 -0
  39. package/src/cart-drawer/notes.tsx +131 -0
  40. package/src/cart-drawer/payment-badges.tsx +96 -0
  41. package/src/cart-drawer/promo-banner.tsx +43 -0
  42. package/src/cart-drawer/rewards-points.tsx +78 -0
  43. package/src/cart-drawer/sticky-footer.tsx +73 -0
  44. package/src/cart-drawer/summary-breakdown.tsx +196 -0
  45. package/src/cart-drawer/template.tsx +225 -0
  46. package/src/cart-drawer/tiered-progress.tsx +168 -0
  47. package/src/checkout/address-error-copy.ts +119 -0
  48. package/src/checkout/address-form.tsx +224 -0
  49. package/src/checkout/address-select.tsx +79 -0
  50. package/src/checkout/boxnow-locker-selector.tsx +410 -0
  51. package/src/checkout/checkout-client.tsx +222 -0
  52. package/src/checkout/company-details.tsx +94 -0
  53. package/src/checkout/compare-addresses.ts +40 -0
  54. package/src/checkout/context.tsx +76 -0
  55. package/src/checkout/discount-section.tsx +218 -0
  56. package/src/checkout/econt-office-selector.tsx +332 -0
  57. package/src/checkout/error-message.tsx +25 -0
  58. package/src/checkout/geocode.ts +154 -0
  59. package/src/checkout/gift-card-section.tsx +224 -0
  60. package/src/checkout/index.ts +74 -0
  61. package/src/checkout/labels-bg.ts +128 -0
  62. package/src/checkout/labels.ts +263 -0
  63. package/src/checkout/line-item-card.tsx +152 -0
  64. package/src/checkout/order-summary.tsx +524 -0
  65. package/src/checkout/payment-button.tsx +373 -0
  66. package/src/checkout/payment-error-copy.ts +269 -0
  67. package/src/checkout/payment-method-list.tsx +365 -0
  68. package/src/checkout/payment-wrapper.tsx +102 -0
  69. package/src/checkout/promotion-error-copy.ts +124 -0
  70. package/src/checkout/shipping-method-list.tsx +335 -0
  71. package/src/checkout/stripe-wrapper.tsx +165 -0
  72. package/src/checkout/use-checkout-orchestration.ts +1504 -0
  73. package/src/common/cart-button-client.tsx +39 -0
  74. package/src/common/cart-button.tsx +28 -0
  75. package/src/common/country-select.tsx +65 -0
  76. package/src/common/delete-button.tsx +66 -0
  77. package/src/common/index.ts +17 -0
  78. package/src/common/language-select.tsx +78 -0
  79. package/src/common/localized-link.tsx +45 -0
  80. package/src/common/skeleton.tsx +29 -0
  81. package/src/index.ts +12 -0
  82. package/src/lib/cart-helpers.ts +113 -0
  83. package/src/lib/dual-price.tsx +73 -0
  84. package/src/lib/get-percentage-diff.ts +5 -0
  85. package/src/lib/get-product-price.ts +133 -0
  86. package/src/lib/hooks/use-intersection.ts +30 -0
  87. package/src/lib/hooks/use-toggle-state.ts +25 -0
  88. package/src/lib/money.ts +73 -0
  89. package/src/lib/payment-constants.ts +66 -0
  90. package/src/lib/product.ts +22 -0
  91. package/src/lib/sort-products.ts +63 -0
  92. package/src/lib/store-api-error.ts +36 -0
  93. package/src/lib/utils.ts +16 -0
  94. package/src/order/context.tsx +32 -0
  95. package/src/order/index.ts +63 -0
  96. package/src/order/labels-bg.ts +39 -0
  97. package/src/order/labels.ts +79 -0
  98. package/src/order/order-address-card.tsx +47 -0
  99. package/src/order/order-completed-template.tsx +165 -0
  100. package/src/order/order-confirmation-header.tsx +65 -0
  101. package/src/order/order-delivery-card.tsx +258 -0
  102. package/src/order/order-help-section.tsx +47 -0
  103. package/src/order/order-item.tsx +201 -0
  104. package/src/order/order-items-list.tsx +52 -0
  105. package/src/order/order-payment-card.tsx +95 -0
  106. package/src/order/order-timeline.tsx +141 -0
  107. package/src/order/order-totals.tsx +245 -0
  108. package/src/primitives/field.tsx +125 -0
  109. package/src/primitives/select-field.tsx +77 -0
  110. package/src/primitives/ui/accordion.tsx +61 -0
  111. package/src/primitives/ui/button.tsx +68 -0
  112. package/src/primitives/ui/collapsible.tsx +16 -0
  113. package/src/primitives/ui/dialog.tsx +112 -0
  114. package/src/primitives/ui/input.tsx +30 -0
  115. package/src/primitives/ui/label.tsx +31 -0
  116. package/src/primitives/ui/popover.tsx +38 -0
  117. package/src/primitives/ui/select.tsx +163 -0
  118. package/src/primitives/ui/sheet.tsx +131 -0
  119. package/src/primitives/ui/tabs.tsx +62 -0
  120. package/src/products/context.tsx +34 -0
  121. package/src/products/image-gallery.tsx +43 -0
  122. package/src/products/index.ts +44 -0
  123. package/src/products/labels-bg.ts +35 -0
  124. package/src/products/labels.ts +57 -0
  125. package/src/products/mobile-actions.tsx +180 -0
  126. package/src/products/option-select.tsx +67 -0
  127. package/src/products/preview-price.tsx +36 -0
  128. package/src/products/product-actions-wrapper.tsx +58 -0
  129. package/src/products/product-actions.tsx +217 -0
  130. package/src/products/product-info.tsx +43 -0
  131. package/src/products/product-preview.tsx +49 -0
  132. package/src/products/product-price.tsx +69 -0
  133. package/src/products/product-tabs.tsx +169 -0
  134. package/src/products/product-template.tsx +114 -0
  135. package/src/products/purchase-options.tsx +130 -0
  136. package/src/products/related-products.tsx +86 -0
  137. package/src/products/thumbnail.tsx +71 -0
  138. package/src/products/variant-matching.ts +71 -0
  139. package/src/reviews-ui/helpers.ts +174 -0
  140. package/src/reviews-ui/index.ts +74 -0
  141. package/src/reviews-ui/labels-bg.ts +91 -0
  142. package/src/reviews-ui/labels.ts +199 -0
  143. package/src/reviews-ui/photo-upload.tsx +345 -0
  144. package/src/reviews-ui/review-list.tsx +249 -0
  145. package/src/reviews-ui/review-widget.tsx +224 -0
  146. package/src/reviews-ui/review-wizard.tsx +560 -0
  147. package/src/reviews-ui/star-badge.tsx +104 -0
  148. package/src/reviews-ui/wizard-state.ts +81 -0
  149. package/src/store/category-template.tsx +129 -0
  150. package/src/store/collection-template.tsx +139 -0
  151. package/src/store/index.ts +41 -0
  152. package/src/store/labels-bg.ts +22 -0
  153. package/src/store/labels.ts +52 -0
  154. package/src/store/paginated-products.tsx +116 -0
  155. package/src/store/pagination.tsx +103 -0
  156. package/src/store/search-params.ts +256 -0
  157. package/src/store/search-template.tsx +249 -0
  158. package/src/store/skeleton-product-grid.tsx +26 -0
  159. package/src/store/sort-select.tsx +81 -0
  160. package/src/store/store-template.tsx +65 -0
  161. package/src/tracking/attribution.ts +418 -0
  162. package/src/tracking/consent-banner.tsx +355 -0
  163. package/src/tracking/consent-init.tsx +44 -0
  164. package/src/tracking/consent.ts +243 -0
  165. package/src/tracking/fbq.ts +168 -0
  166. package/src/tracking/ga4.tsx +49 -0
  167. package/src/tracking/get-tracking-attribution.ts +224 -0
  168. package/src/tracking/get-tracking-config.ts +50 -0
  169. package/src/tracking/gtag.ts +200 -0
  170. package/src/tracking/index.ts +133 -0
  171. package/src/tracking/meta-pixel.tsx +166 -0
  172. package/src/tracking/rybbit-events.ts +242 -0
  173. package/src/tracking/rybbit.tsx +40 -0
  174. package/src/tracking/types.ts +185 -0
  175. package/src/tracking/use-engagement-time.ts +58 -0
  176. 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
+ }