create-cartbase 0.1.20 → 0.1.22
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/README.md +25 -25
- package/dist/index.js +20 -20
- package/package.json +24 -24
- package/template/app/docs/auth.md +105 -105
- package/template/app/docs/carts.md +23 -4
- package/template/app/docs/categories.md +194 -194
- package/template/app/docs/checkout.md +714 -714
- package/template/app/docs/components.md +270 -24
- package/template/app/docs/consent.md +91 -91
- package/template/app/docs/deploy.md +197 -197
- package/template/app/docs/gift-cards.md +153 -153
- package/template/app/docs/metaobjects.md +126 -126
- package/template/app/docs/orders.md +221 -221
- package/template/app/docs/regions.md +269 -269
- package/template/app/docs/reviews.md +258 -238
- package/template/app/docs/search.md +227 -227
- package/template/app/docs/store.md +23 -1
- package/template/app/docs/subscriptions.md +148 -148
- package/template/app/docs/variables.md +331 -315
- package/template/app/package.json +1 -1
- package/template/app/postcss.config.cjs +11 -11
- package/template/app/src/app/checkout/checkout-empty.tsx +36 -0
- package/template/app/src/app/checkout/checkout-page-client.tsx +80 -73
- package/template/app/src/app/checkout/error.tsx +23 -0
- package/template/app/src/app/checkout/page.tsx +16 -7
- package/template/app/src/app/globals.css +26 -26
- package/template/app/src/app/layout.tsx +126 -126
- package/template/app/src/app/page.tsx +37 -37
- package/template/app/src/app/products/[handle]/page.tsx +89 -89
- package/template/app/src/app/search/page.tsx +33 -33
- package/template/app/src/lib/browser-client.ts +35 -35
- package/template/app/src/lib/catalog.ts +120 -120
- package/template/app/src/lib/server-client.ts +25 -25
- package/template/app/src/lib/store-client.ts +16 -16
|
@@ -177,8 +177,22 @@ before anyone notices.
|
|
|
177
177
|
| Product view | `trackProductView({ line, currency, value })` |
|
|
178
178
|
| Add to cart | `trackCartAdd({ line, currency, value })` |
|
|
179
179
|
| Checkout start | `trackCheckoutStart({ lines, currency, value, coupon? })` |
|
|
180
|
+
| Address complete | `trackCheckoutShippingInfo({ lines, currency, value, cartId, shippingTier?, coupon? })` |
|
|
181
|
+
| Tender chosen | `trackCheckoutPaymentInfo({ lines, currency, value, cartId, paymentType?, coupon? })` |
|
|
180
182
|
| Order confirmed | `trackOrderPurchase(order, trackingConfig)` |
|
|
181
183
|
|
|
184
|
+
**The three checkout moments fire on their own.** `useCheckoutOrchestration`
|
|
185
|
+
mounts `useCheckoutFunnel`, so a checkout built on the kit reports the whole
|
|
186
|
+
funnel with no wiring, and a store with its own layout gets it from the
|
|
187
|
+
hook. Call the two above by hand only in a checkout that uses neither, and
|
|
188
|
+
pass `trackFunnel: false` to the hook if you do, so nothing counts twice.
|
|
189
|
+
|
|
190
|
+
`cartId` is not decoration: the mid-funnel steps are driven by form state
|
|
191
|
+
rather than by a click, so their Meta event id is DERIVED from the cart
|
|
192
|
+
(`checkoutStepEventId`, `tracking/once`). Two tabs on one cart, a restored
|
|
193
|
+
session or a retried request then collapse into one event instead of three.
|
|
194
|
+
Pair it with `markFiredOnce(event, cartId)` if you fire them yourself.
|
|
195
|
+
|
|
182
196
|
A `line` is `{ productId, variantId?, title, quantity, price }`. Pass the
|
|
183
197
|
PRODUCT id: Meta's content_ids, TikTok's content_id and the catalogue
|
|
184
198
|
feed's `<g:id>` must be the same value or the event matches no catalogue
|
|
@@ -202,9 +216,16 @@ race (10s cap, v2.2.6 production fix).
|
|
|
202
216
|
| Product view | `trackViewContent` | `trackTikTokViewContent` | `trackGAViewItem` | `trackRybbitViewItem` |
|
|
203
217
|
| Add to cart | `trackAddToCart` | `trackTikTokAddToCart` | `trackGAAddToCart` | `trackRybbitAddToCart` |
|
|
204
218
|
| Checkout start | `trackInitiateCheckout` | `trackTikTokInitiateCheckout` | `trackGABeginCheckout` | `trackRybbitBeginCheckout` |
|
|
219
|
+
| Address complete | `trackAddShippingInfo` | — | `trackGAAddShippingInfo` | `trackRybbitAddShippingInfo` |
|
|
220
|
+
| Tender chosen | `trackAddPaymentInfo` | `trackTikTokAddPaymentInfo` | `trackGAAddPaymentInfo` | `trackRybbitAddPaymentInfo` |
|
|
205
221
|
| Order confirmed | `trackPurchase(data, order.display_id)` | `trackTikTokPurchase({…, displayId})` | `trackGAPurchase({transaction_id: String(order.display_id), …})` | `trackRybbitPurchase` |
|
|
206
222
|
| Newsletter/popup signup | `trackLead` | — | — | — |
|
|
207
223
|
|
|
224
|
+
Two gaps in that table are deliberate. AddShippingInfo is not in Meta's
|
|
225
|
+
standard vocabulary, so `fbq` takes it through `trackCustom`; TikTok has no
|
|
226
|
+
shipping-step event at all, and a custom one there would optimise nothing
|
|
227
|
+
and report nowhere.
|
|
228
|
+
|
|
208
229
|
Google Ads has one event, the purchase conversion:
|
|
209
230
|
`trackGoogleAdsPurchase({ sendTo, value, currency, transactionId, newCustomer? })`,
|
|
210
231
|
with `sendTo` from `googleAdsPurchaseSendTo(config)` (null when the label
|
|
@@ -256,6 +277,22 @@ that fails silently and costs match rate with nothing in any log.
|
|
|
256
277
|
`{ engagementTimeMsec: getEngagementTimeMsec() }` as clientHints, and
|
|
257
278
|
either `opts.ga4MeasurementId` or `opts.client` so the `_ga_<id>` session
|
|
258
279
|
cookie can be located.
|
|
280
|
+
- Browser side, at the checkout: `readBrowserAttribution()` returns the same
|
|
281
|
+
flat keys from the cookies a client component can read (the Facebook
|
|
282
|
+
pair, the visitor id, both UTM tuples, every ad-click id). The checkout
|
|
283
|
+
funnel writes them onto the cart for you at checkout start, so a store
|
|
284
|
+
needs none of this by hand; it exists because a checkout is a client
|
|
285
|
+
component and the server reader cannot run there, which is why no order
|
|
286
|
+
carried an ad-click id before 2026-09-18. The server reader stays the
|
|
287
|
+
authority for the request facts it alone sees (user agent, IP, referer,
|
|
288
|
+
GA session), and the two payloads merge by key.
|
|
289
|
+
- **Whether any of it is written** is `consentDecision()` (`tracking/consent`):
|
|
290
|
+
the visitor's stored choice, and before they make one, the store's own
|
|
291
|
+
default (a banner means nothing until they agree; no banner means the
|
|
292
|
+
store collects none). `CONSENT_CHANGED_EVENT` fires on `window` whenever a
|
|
293
|
+
decision is applied, by the built-in banner or an external CMP through
|
|
294
|
+
`setConsent`, so a surface that must ACT on consent can hear a later
|
|
295
|
+
Accept instead of reading once and missing it.
|
|
259
296
|
- **Settings** — Integrations hub rows (which signals exist), consent card
|
|
260
297
|
(whether capture happens).
|
|
261
298
|
|
|
@@ -370,6 +407,28 @@ code-first through the error-copy maps, never raw API strings.
|
|
|
370
407
|
pre-Buy), carrier metadata, optimistic totals, the atomic Buy click,
|
|
371
408
|
3DS-return handling, completed-cart detection. Every production race
|
|
372
409
|
guard from production is preserved.
|
|
410
|
+
- **The typed form survives a refresh** (2026-09-18) — the form is kept in
|
|
411
|
+
the tab's own sessionStorage (`cartbase:checkout-form`), restored after
|
|
412
|
+
mount into EMPTY fields only, so what the server knows (the cart's
|
|
413
|
+
address, a signed-in customer's) always wins, and cleared when the order
|
|
414
|
+
is placed. Storage that throws never breaks a checkout.
|
|
415
|
+
- **A completed cart is forgotten everywhere** (2026-09-18) — the tab that
|
|
416
|
+
placed the order already cleared the cart; a second tab or the back
|
|
417
|
+
button now clears it too the moment the hook sees `completed_at`, so the
|
|
418
|
+
next Add starts a new cart instead of being refused by a placed one.
|
|
419
|
+
- **The funnel fires itself** (2026-09-18) — the hook mounts
|
|
420
|
+
`useCheckoutFunnel`: begin_checkout on arrival, add_shipping_info when
|
|
421
|
+
contact and address are complete, add_payment_info when the tender is
|
|
422
|
+
settled, each once per cart to every configured tag; the ad platforms'
|
|
423
|
+
identity signals (`updatePixelAdvancedMatching` +
|
|
424
|
+
`setEnhancedConversions`) as the fields are typed, with the shopper's own
|
|
425
|
+
country rather than a constant; and the ad-click ids written onto the
|
|
426
|
+
cart so they reach the order. `trackFunnel: false` for a store that fires
|
|
427
|
+
its own.
|
|
428
|
+
- **Nothing left to pay** (2026-09-18) — `nothingToPay` / `amountDue` on the
|
|
429
|
+
returned surface: a promotion that covered the order, or gift cards that
|
|
430
|
+
cover the whole total. The Buy click then skips Stripe from the start,
|
|
431
|
+
and `<CheckoutPaymentMethodList nothingToPay>` offers no tender at all.
|
|
373
432
|
- **SDK calls** — `carts.updateCart` (address autosave + tracking metadata),
|
|
374
433
|
`customers.updateMe` (best-effort profile sync incl. Cartbase's first-class
|
|
375
434
|
`company_name`/`company_eik`), `checkout.calculateShippingOption`
|
|
@@ -426,13 +485,25 @@ code-first through the error-copy maps, never raw API strings.
|
|
|
426
485
|
`StripeElementsScope` mounts `<Elements mode:"payment">` where needed
|
|
427
486
|
(`passthrough` renders children scope-less on COD-only stores);
|
|
428
487
|
`StripeContext` boolean = "Stripe.js ready".
|
|
429
|
-
- **SDK calls** — none
|
|
430
|
-
|
|
488
|
+
- **SDK calls** — none. The KEY comes from the store: the `pp_stripe`
|
|
489
|
+
entry of `GET /api/store/payment-providers` carries `publishable_key`
|
|
490
|
+
and, for a Connect store, `account_id`, and `CheckoutClient` passes them
|
|
491
|
+
as `publishableKey`/`stripeAccountId`. Under Connect the key is
|
|
492
|
+
Cartbase's and the account is the merchant's, so this pair only exists
|
|
493
|
+
on the platform: connecting Stripe in the admin is the whole setup and a
|
|
494
|
+
storefront configures nothing (2026-09-18). `NEXT_PUBLIC_STRIPE_KEY` and
|
|
495
|
+
`NEXT_PUBLIC_STRIPE_ACCOUNT_ID` remain the fallback, as a PAIR, for a
|
|
496
|
+
storefront running its own Stripe outside the platform.
|
|
431
497
|
- **Mount rules** — `PaymentWrapper` wraps the page ONCE with
|
|
432
498
|
`amount={optimisticTotalCents}` (cents at this Stripe boundary only);
|
|
433
499
|
`StripeElementsScope` lives INSIDE the payment section so a session
|
|
434
500
|
rotation never tears down the form/tracking tree.
|
|
435
501
|
- **Settings** — Stripe integration (whether `pp_stripe` is ever listed).
|
|
502
|
+
- **A card with no key is not offered.** `resolveCardOffer`
|
|
503
|
+
(`checkout/card-offer`) reads the entry and the fallback together;
|
|
504
|
+
without a publishable key Stripe.js never loads, so the tab would sit on
|
|
505
|
+
a skeleton for ever. The orchestration hook hides it and reports
|
|
506
|
+
`card_unmountable` to the merchant's checkout error log instead.
|
|
436
507
|
|
|
437
508
|
### `<CheckoutAddressForm />` (+ `<AddressSelect />`, `<CompanyDetails />`) — `checkout/address-form`, `checkout/address-select`, `checkout/company-details`
|
|
438
509
|
|
|
@@ -469,18 +540,74 @@ code-first through the error-copy maps, never raw API strings.
|
|
|
469
540
|
- **Settings** — checkout rules (`target_type=shipping_option`), method
|
|
470
541
|
ordering, carrier integrations (which options exist at all).
|
|
471
542
|
|
|
472
|
-
###
|
|
473
|
-
|
|
543
|
+
### Which destination an option books, and the carrier's mark — `checkout/fulfillment-option`, `checkout/carrier-marks`
|
|
544
|
+
|
|
545
|
+
- **`fulfillmentOptionId(option)`** — the merchant's choice on the shipping
|
|
546
|
+
option: `econt-office`, `speedy-address`, `boxnow-locker`,
|
|
547
|
+
`pigeon-office` and the rest of the carrier registry. Cartbase writes it
|
|
548
|
+
as `data.fulfillment_option` (the Shipping option drawer); `data.id` is
|
|
549
|
+
read as a fallback for a cart carried over from the ported platform.
|
|
550
|
+
Every picker, every mark and the hook's own readiness go through this one
|
|
551
|
+
function: the kit read `data.id` alone until 2026-09-18, and since nothing
|
|
552
|
+
on this side writes that key, no office or locker picker had ever opened
|
|
553
|
+
on a Cartbase store.
|
|
554
|
+
- **`carrierMarks(origin)`** — the default `logoByFulfillmentOptionId` for
|
|
555
|
+
the shipping list: the platform's own carrier marks
|
|
556
|
+
(`{origin}/integrations/{carrier}.png`, the files the admin wears), both
|
|
557
|
+
modes of a carrier on one mark. `CheckoutClient` passes it unless the
|
|
558
|
+
store passes a map of its own, so a store gets branded rows with no files
|
|
559
|
+
to find and a new carrier arrives already dressed.
|
|
560
|
+
|
|
561
|
+
### `<PickupPointSelector />` — `checkout/pickup-point-selector`
|
|
562
|
+
|
|
563
|
+
- **Purpose** — THE pickup picker, every carrier, mounted by
|
|
564
|
+
`CheckoutClient` for any option whose fulfillment option ends `-office` or
|
|
565
|
+
`-locker`. Nearest three by haversine distance to the typed address, each
|
|
566
|
+
with its distance, and a door to search the whole catalogue. The shape
|
|
567
|
+
Alenika's checkout has carried since it was built.
|
|
568
|
+
- **SDK calls** — `integrations.listPickupPoints(client, provider, {mode,
|
|
569
|
+
q, limit})`, one door for all of them. Where the SEARCH happens is the
|
|
570
|
+
platform's business: carriers that search are asked (Speedy by office
|
|
571
|
+
name and by town, Pigeon compensated with town and postcode), carriers
|
|
572
|
+
that publish whole catalogues are held per store and ranked there (Econt,
|
|
573
|
+
BoxNow). The component types into one query parameter either way.
|
|
574
|
+
- **Props contract** — `{client, provider, mode, userCity, userAddress,
|
|
575
|
+
selectedPoint, onSelect}`. `onSelect` receives the point AND the carrier's
|
|
576
|
+
own key names, which arrive with the answer from the platform's
|
|
577
|
+
destination registry, so the checkout writes the point under the names
|
|
578
|
+
this carrier's booking reads without carrying a copy of that map. The
|
|
579
|
+
choice is remembered on the cart under the checkout's own `_checkout_*`
|
|
580
|
+
keys so a reload restores it; the carrier's REAL keys are still written
|
|
581
|
+
exactly once, at prepare.
|
|
582
|
+
- **Why it replaced three components (2026-09-18)** — the kit asked "is
|
|
583
|
+
this a pickup option?" once per carrier, so a fourth carrier was invisible
|
|
584
|
+
to the checkout however well the platform served its catalogue: a store
|
|
585
|
+
could offer delivery to a Speedy office and give the shopper no way to
|
|
586
|
+
name one. `pickupOptionOf` (`checkout/pickup-option`) reads the carrier
|
|
587
|
+
and the mode off the option's own id, so a carrier connected tomorrow
|
|
588
|
+
opens its picker with no line added anywhere.
|
|
589
|
+
- **Settings** — the carrier integrations (connected; the market's options).
|
|
590
|
+
|
|
591
|
+
### `<EcontOfficeSelector />` / `<BoxNowLockerSelector />` / `<PigeonOfficeSelector />` — `checkout/econt-office-selector`, `checkout/boxnow-locker-selector`, `checkout/pigeon-office-selector`
|
|
592
|
+
|
|
593
|
+
- **Superseded by `<PickupPointSelector />`** (2026-09-18) and no longer
|
|
594
|
+
mounted by `CheckoutClient`. Exported still, for a store that mounts its
|
|
595
|
+
own; the `econt`/`boxnow`/`pigeon` props of
|
|
596
|
+
`<CheckoutShippingMethodList />` keep working and win for their own
|
|
597
|
+
carrier when a store passes them.
|
|
474
598
|
- **Purpose** — Bulgarian office/locker pickers: nearest-3 by haversine
|
|
475
599
|
distance (Nominatim geocode of the typed address), city-locked search
|
|
476
600
|
with Cyrillic↔Latin normalization, selected pill + change.
|
|
477
601
|
- **SDK calls** — Econt: none (public Econt Nomenclatures endpoint,
|
|
478
|
-
page-level cache). BoxNow: `integrations.listBoxNowLockers(client)
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
602
|
+
page-level cache). BoxNow: `integrations.listBoxNowLockers(client)`.
|
|
603
|
+
Pigeon: `integrations.listPigeonOffices(client, {type:"office"})` — the
|
|
604
|
+
carrier catalogue is credentialed, so the platform serves it and the
|
|
605
|
+
browser never asks Pigeon; its id arrives as a NUMBER and is stringified
|
|
606
|
+
when it is written. All three: 503/502/network render one "temporarily
|
|
607
|
+
unavailable" state, and a town with no point of that carrier says so
|
|
608
|
+
instead of showing an empty box ([integrations.md](integrations.md)).
|
|
482
609
|
- **Props contract** — `{userCity, userAddress, selectedOffice|Locker,
|
|
483
|
-
onSelect}` (+ `client` for BoxNow). The CHOSEN office/locker is client
|
|
610
|
+
onSelect}` (+ `client` for BoxNow and Pigeon). The CHOSEN office/locker is client
|
|
484
611
|
state; the hook writes it into `carrier_metadata` exactly once at
|
|
485
612
|
prepare (previous carrier keys are server-side swept —
|
|
486
613
|
`_prepared_carrier_keys`).
|
|
@@ -498,41 +625,124 @@ code-first through the error-copy maps, never raw API strings.
|
|
|
498
625
|
- **SDK calls** — renders `checkout.listPaymentProviders` results via the
|
|
499
626
|
hook's `hasCard`/`hasCod`; the click path runs the hook's calls.
|
|
500
627
|
- **Props contract** — hook state + `buyButtonNotReady(Reason?)`,
|
|
501
|
-
`gatePaymentUntilDelivery?` (false
|
|
628
|
+
`gatePaymentUntilDelivery?` (DEFAULT false since 2026-09-18: everything
|
|
629
|
+
is on screen from arrival and the Buy button carries the gate, naming the
|
|
630
|
+
missing step; pass true for the older dimmed-until-delivery flow),
|
|
502
631
|
`beforePaymentButton?` slot, `total` (pass `optimisticTotal`),
|
|
503
|
-
`logError?`.
|
|
632
|
+
`nothingToPay?` (pass the hook's), `logError?`.
|
|
633
|
+
- **An order that asks for no money** (2026-09-18) — with `nothingToPay` the
|
|
634
|
+
rail is replaced by one line saying the order is covered and the button
|
|
635
|
+
becomes **Complete order**, carrying no amount and waiting on no card
|
|
636
|
+
form. The platform already behaved this way (prepare mints no session at
|
|
637
|
+
a zero charge); the screen did not, so a fully discounted cart demanded
|
|
638
|
+
card details for a charge that would never happen.
|
|
504
639
|
- **Settings** — COD integration (`labels.codNote` + fee timing), Stripe
|
|
505
640
|
credentials, checkout rules (`target_type=payment_method`) — hidden
|
|
506
641
|
methods are also re-enforced at complete (`checkout_method_hidden`).
|
|
507
642
|
|
|
643
|
+
### `<CheckoutErrorScreen error reset client? cartId? />` — `checkout/checkout-error-screen`
|
|
644
|
+
|
|
645
|
+
- **Purpose** — the checkout route's error boundary. Next renders a route's
|
|
646
|
+
`error.tsx` in place of the page when anything in it throws, a server
|
|
647
|
+
component included; without one the shopper meets the framework's error
|
|
648
|
+
page at the moment of paying and nobody is told. Mount it as the default
|
|
649
|
+
export of `app/checkout/error.tsx` (the reference storefront, and so the
|
|
650
|
+
seed and every scaffolded store, ships exactly that).
|
|
651
|
+
- **SDK calls** — `checkout.reportCheckoutError` as
|
|
652
|
+
`error_boundary_checkout` when a `client` is passed; without one the
|
|
653
|
+
screen still renders and the failure reaches nobody.
|
|
654
|
+
- **Props contract** — Next's `{error, reset}` plus `client?`, `cartId?` and
|
|
655
|
+
`title?` / `body?` / `retryLabel?`. The copy comes from the label pack
|
|
656
|
+
when a `CheckoutProvider` stands ABOVE the boundary (one in the route's
|
|
657
|
+
layout does; one inside the page does not, because the page is what Next
|
|
658
|
+
replaced) — pass the three strings otherwise. The technical message is
|
|
659
|
+
never shown to the shopper, only logged.
|
|
660
|
+
|
|
508
661
|
### `<OrderSummary />` (+ `CheckoutLineItem`, `<LineItemCard />`) — `checkout/order-summary`, `checkout/line-item-card`
|
|
509
662
|
|
|
510
|
-
- **Purpose** — items (flat rows
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
663
|
+
- **Purpose** — items (flat rows, READ-ONLY), promo + gift-card widgets,
|
|
664
|
+
totals breakdown (subtotal / shipping / COD fee / discount / total +
|
|
665
|
+
gift-card tender rows UNDER the unchanged total), secure badge.
|
|
666
|
+
`LineItemCard` is the standalone card variant.
|
|
667
|
+
- **A CHECKOUT IS A CHECKOUT** (2026-09-18) — the rows carry no quantity
|
|
668
|
+
control and call no cart endpoint. The order is stated here and changed in
|
|
669
|
+
the cart; the typed form survives the walk back and forth. The quantity is
|
|
670
|
+
still shown, as a count on the picture and a "Qty n" line on the card.
|
|
671
|
+
- **A free line says so** (2026-09-18) — a line whose total is zero prints
|
|
672
|
+
`labels.lineFree`, never €0.00, which reads as a pricing fault where a
|
|
673
|
+
shopper is deciding to pay. It is a SEPARATE label from `shippingFree`:
|
|
674
|
+
Bulgarian agrees the adjective with its noun, so the rows differ
|
|
675
|
+
("Безплатно" on a line, "Безплатна" on shipping).
|
|
676
|
+
- **The VAT row is for a company** (2026-09-18) — `isCompanyOrder(cart)`
|
|
677
|
+
decides it, on the platform's own precedence (`invoice_to`, then
|
|
678
|
+
`company_id`, plus the Company invoice fields the kit writes into
|
|
679
|
+
metadata), and it prints only when the figure is known. A consumer sees
|
|
680
|
+
the price they pay: the VAT is inside it and a row for it is noise. It
|
|
681
|
+
used to print a flat 0.00 before an address, beside prices containing 20%.
|
|
514
682
|
- **SDK calls** — `carts.updateLineItem` (quantity); child widgets below.
|
|
515
683
|
Totals are rendered STRAIGHT from the cart decoration: `item_total`,
|
|
516
684
|
`shipping_total`, `payment_method_fee_total`/`payment_method_fee_label`, `discount_total`,
|
|
517
685
|
`tax_total`, `total`, `gift_card_total`, `gift_card_remainder`.
|
|
518
686
|
- **Props contract** — `{client, cart, optimisticShippingCost,
|
|
519
|
-
onOptimisticShippingClear?,
|
|
520
|
-
methodFeeLabel?, showGiftCards?,
|
|
521
|
-
automatically once the server
|
|
522
|
-
|
|
687
|
+
onOptimisticShippingClear?, optimisticMethodFee?,
|
|
688
|
+
onOptimisticMethodFeeClear?, methodFeeLabel?, showGiftCards?,
|
|
689
|
+
onCartChange?}`. Optimistic values clear automatically once the server
|
|
690
|
+
cart catches up.
|
|
691
|
+
- **The numbers** — every figure comes from `checkoutTotals`
|
|
692
|
+
(`checkout/summary-math`), the pure function the two mobile bars call
|
|
693
|
+
too, so the total in the column and the total on a phone are the same
|
|
694
|
+
number by construction. It takes `{cart, optimisticShippingCost,
|
|
695
|
+
optimisticMethodFee?}` and answers the product rows (newest first, fee
|
|
696
|
+
lines filtered), the unit count, the subtotal, the shipping figure and
|
|
697
|
+
whether it is known, the method fee, the grand total and the gift-card
|
|
698
|
+
tender pair.
|
|
699
|
+
- **Settings** — the payment method's own fee (fee row), gift cards,
|
|
700
|
+
promotions.
|
|
701
|
+
|
|
702
|
+
### The phone's order overview — `checkout/mobile-checkout-top-bar`, `checkout/mobile-checkout-bottom-bar`, `checkout/mobile-order-summary-body`
|
|
703
|
+
|
|
704
|
+
- **Purpose** — the summary column is a desktop shape; on a phone the same
|
|
705
|
+
content is two bars, each closed on arrival and one tap from everything.
|
|
706
|
+
`<MobileCheckoutTopBar />` is the strip above the contact form: the words
|
|
707
|
+
"Order summary", a chevron and the total. `<MobileCheckoutBottomBar />`
|
|
708
|
+
is the card directly above Place order: the newest item's picture with
|
|
709
|
+
the unit count on it, the word Total, the count, the figure, a chevron,
|
|
710
|
+
and it opens upwards so the row and the button stay under the thumb; with
|
|
711
|
+
no product line it renders nothing. Both reveal
|
|
712
|
+
`<MobileOrderSummaryBody />`: the items with their quantity pills, the
|
|
713
|
+
code and gift-card doors, then subtotal, shipping, the method fee,
|
|
714
|
+
discount, VAT and the gift-card tender pair. The grand total is not
|
|
715
|
+
repeated inside, because the bar's own row carries it open or closed.
|
|
716
|
+
- **Mount rules** — `CheckoutClient` mounts both (the bottom one through
|
|
717
|
+
`beforePaymentButton`, with `hideDiscount`, beside an always-open
|
|
718
|
+
`DiscountSection`, because a code field two taps deep inside a closed bar
|
|
719
|
+
is a code field nobody finds). Every piece is `sm:hidden`, so a viewport
|
|
720
|
+
never shows a bar and the column at once. A store with its own layout
|
|
721
|
+
imports them and composes the same way.
|
|
722
|
+
- **Props contract** — `{client, cart, optimisticShippingCost,
|
|
723
|
+
optimisticMethodFee?, methodFeeLabel?, showGiftCards?, onCartChange?}`,
|
|
724
|
+
plus `hideDiscount?` on the bottom bar and the body.
|
|
725
|
+
- **The fee is Cartbase's own** — cart-level decoration
|
|
726
|
+
(`payment_method_fee_total` / `payment_method_fee_label`, already inside
|
|
727
|
+
`cart.total`), never a flagged line item, so no surface subtracts a fee
|
|
728
|
+
row out of the item list. Any method may carry a fee, and the row wears
|
|
729
|
+
the merchant's name for it.
|
|
523
730
|
|
|
524
731
|
### `<DiscountSection />` — `checkout/discount-section`
|
|
525
732
|
|
|
526
|
-
- **Purpose** —
|
|
527
|
-
|
|
733
|
+
- **Purpose** — promo-code input + applied-promotion list (percentage or
|
|
734
|
+
fixed amount display). Collapsed behind its own door by default;
|
|
735
|
+
`alwaysOpen` renders the field itself, which is what the checkout passes
|
|
736
|
+
on a phone.
|
|
528
737
|
- **SDK calls** — `POST /api/store/carts/:id/promotions {promo_codes}`
|
|
529
738
|
via the client transport (additive apply; the carts SDK module ships no
|
|
530
739
|
wrapper for this route yet — see [carts.md](carts.md) for the cart
|
|
531
740
|
shape; `cart.promotions` arrives as the `{promotion:{…}}` pivot embed,
|
|
532
741
|
unwrapped here).
|
|
533
|
-
- **Props contract** — `{client, cart, onCartChange?}`.
|
|
534
|
-
via `promotion-error-copy`
|
|
535
|
-
+ the no-email
|
|
742
|
+
- **Props contract** — `{client, cart, alwaysOpen?, onCartChange?}`. Enter
|
|
743
|
+
applies the code. Errors code-first via `promotion-error-copy`
|
|
744
|
+
(`promotion_not_found`/`promotion_inactive`/… + the no-email
|
|
745
|
+
campaign-budget heuristic).
|
|
536
746
|
- **Settings** — promotions admin (codes, status, application method).
|
|
537
747
|
|
|
538
748
|
### `<GiftCardSection />` — `checkout/gift-card-section`
|
|
@@ -1214,6 +1424,42 @@ reaches, so the page passes `STORE_LOCALE.order`.
|
|
|
1214
1424
|
## Family: common (`@cartbase/storefront/common/*`)
|
|
1215
1425
|
Shared storefront chrome, production-proven.
|
|
1216
1426
|
|
|
1427
|
+
### `<SocialLinks links label />` + `<SocialIcon network />` — `common/social-links`
|
|
1428
|
+
|
|
1429
|
+
- **Purpose**: the store's social profiles as round links with each
|
|
1430
|
+
network's mark in the text colour, for a footer or a header. Networks:
|
|
1431
|
+
Facebook, Instagram, TikTok, YouTube, X, LinkedIn, Pinterest, Threads,
|
|
1432
|
+
Snapchat, WhatsApp, Viber, Telegram (`SOCIAL_NETWORKS`).
|
|
1433
|
+
- **Data**: `links` is `store.brand.social` from `getStore` (Settings,
|
|
1434
|
+
Brand, Social links); a network with no address is left out, none
|
|
1435
|
+
renders nothing. `label` names the list for screen readers
|
|
1436
|
+
(`STORE_LOCALE.store.socialProfiles`).
|
|
1437
|
+
- **Style**: `linkClassName` styles the round hit area (colour, hover),
|
|
1438
|
+
`iconClassName` sizes the mark. Server-safe.
|
|
1439
|
+
|
|
1440
|
+
### `<PaymentIcons methods label />` + `<PaymentIcon method />` — `common/payment-icons`
|
|
1441
|
+
|
|
1442
|
+
- **Purpose**: the payment marks in their brands' colours on a 38 by 24
|
|
1443
|
+
card: Visa, Mastercard, Maestro, American Express, UnionPay, Discover,
|
|
1444
|
+
Diners Club, JCB, Apple Pay, Google Pay, PayPal, Revolut, Klarna,
|
|
1445
|
+
Bancontact, iDEAL (`PAYMENT_METHODS`). No Shop Pay.
|
|
1446
|
+
- **Data**: `methods` is `payment_marks` from `getPaymentMarks(client)`
|
|
1447
|
+
(`api/store`), what the store's Stripe allows, in order; empty renders
|
|
1448
|
+
nothing. `label` is `STORE_LOCALE.store.paymentMethods`.
|
|
1449
|
+
- **Style**: `iconClassName` sizes a mark by height (24px default).
|
|
1450
|
+
`CartPaymentBadges` draws the same marks. Server-safe.
|
|
1451
|
+
|
|
1452
|
+
### `<PoweredByCartbase label />` — `common/powered-by-cartbase`
|
|
1453
|
+
|
|
1454
|
+
- **Purpose**: "Powered by" and the Cartbase wordmark, a link to
|
|
1455
|
+
cartbase.ai, for the footer's last line. Drawn in the text colour.
|
|
1456
|
+
- **Data**: `label` is `STORE_LOCALE.store.poweredBy`. `markClassName`
|
|
1457
|
+
sizes the wordmark by height (14px default). Server-safe.
|
|
1458
|
+
|
|
1459
|
+
The marks are generated: SVG files in `assets/icons`, built by
|
|
1460
|
+
`scripts/build-icons.mjs` (sources and licences in
|
|
1461
|
+
`assets/icons/README.md`).
|
|
1462
|
+
|
|
1217
1463
|
### `<LocalizedLink href … />` — `common/localized-link`
|
|
1218
1464
|
|
|
1219
1465
|
- `next/link` that persists the URL locale/country segment when the route
|
|
@@ -1,91 +1,91 @@
|
|
|
1
|
-
# Consent
|
|
2
|
-
|
|
3
|
-
The store's CMP configuration for the built-in Consent Mode v2 banner
|
|
4
|
-
(consent-management card). Defaults are **always applied server-side**, so
|
|
5
|
-
the payload is complete and renderable even for an unconfigured store
|
|
6
|
-
(compliant built-in modal, BG + EN copy shipped).
|
|
7
|
-
|
|
8
|
-
## Storefront wiring (the trap that matters)
|
|
9
|
-
|
|
10
|
-
- Mount `<ConsentInit required={consent.enabled}>` as the **first child of
|
|
11
|
-
`<body>`** — it sets the synchronous Consent Mode v2 DEFAULT and must
|
|
12
|
-
**never wait on this fetch in the browser** (async default = first-hit
|
|
13
|
-
consent race). Resolve this config server-side (RSC) and inline the
|
|
14
|
-
setting into the document; the prop is required, a layout without it
|
|
15
|
-
does not compile.
|
|
16
|
-
- **The switch decides the default; a stored choice wins.** `enabled: true`
|
|
17
|
-
is a store that collects consent: every visitor starts DENIED until they
|
|
18
|
-
choose on the banner. `enabled: false` is a store with no consent gate:
|
|
19
|
-
every visitor starts GRANTED, and every configured pixel fires. Either
|
|
20
|
-
way a choice already in the `_1c_consent` cookie is what counts, so a
|
|
21
|
-
visitor who declined keeps that decision if the banner is switched off.
|
|
22
|
-
- Render the built-in banner only when `enabled && mode === "builtin"`.
|
|
23
|
-
- `mode: "external"` = the merchant's CMP owns the UI and must write the
|
|
24
|
-
same `_1c_consent` cookie (or call `setConsent()`) — all Cartbase-side tag
|
|
25
|
-
gating works off that one seam.
|
|
26
|
-
- Choices persist 12 months in the cookie. Rybbit (platform analytics)
|
|
27
|
-
stays outside consent by design.
|
|
28
|
-
- Pair with [integrations.md](integrations.md): `tracking.consent_required`
|
|
29
|
-
mirrors `enabled` here, and `<StorefrontTags>` hands it to every pixel
|
|
30
|
-
that gates its own SDK (Meta, TikTok, ChatGPT). Google reads the
|
|
31
|
-
`<ConsentInit>` default.
|
|
32
|
-
|
|
33
|
-
## GET /api/store/consent — the CMP config
|
|
34
|
-
|
|
35
|
-
- **Purpose**: everything the banner needs to render, per locale.
|
|
36
|
-
- **Auth**: anon (`x-client-id`).
|
|
37
|
-
- **Request**: no params.
|
|
38
|
-
- **Response** — the EXACT allowlist (nothing else will ever appear here):
|
|
39
|
-
|
|
40
|
-
```jsonc
|
|
41
|
-
{
|
|
42
|
-
"consent": {
|
|
43
|
-
"enabled": true,
|
|
44
|
-
"mode": "builtin", // "builtin" | "external"
|
|
45
|
-
"layout": "modal", // "modal" (blocking) | "banner-bottom" (non-blocking)
|
|
46
|
-
"privacy_href": "/cookies",
|
|
47
|
-
"reject_on_first_layer": false,
|
|
48
|
-
"copy": {
|
|
49
|
-
"bg": {
|
|
50
|
-
"title": "Преди да продължиш",
|
|
51
|
-
"body": "…",
|
|
52
|
-
"privacy_link_label": "…",
|
|
53
|
-
"accept_label": "…", "settings_label": "…", "reject_label": "…",
|
|
54
|
-
"settings_title": "…",
|
|
55
|
-
"accept_all_label": "…", "save_label": "…", "reject_all_label": "…",
|
|
56
|
-
"necessary_label": "…", "necessary_description": "…",
|
|
57
|
-
"analytics_label": "…", "analytics_description": "…",
|
|
58
|
-
"ads_label": "…", "ads_description": "…"
|
|
59
|
-
},
|
|
60
|
-
"en": { /* same 16 keys — every field always present per locale */ }
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
- **Errors**: `400 missing_client_id` only — the payload itself always
|
|
67
|
-
succeeds (a corrupt/missing stored config degrades to defaults, never to
|
|
68
|
-
a broken banner).
|
|
69
|
-
- **SDK**: `consent.getConsent(client)`
|
|
70
|
-
- **Components**: `<ConsentInit required={consent.enabled}>` +
|
|
71
|
-
`<ConsentBanner copy={copy[locale]} layout privacyHref
|
|
72
|
-
rejectOnFirstLayer>` (consent family; reference impl
|
|
73
|
-
`src/components/storefront/consent/`).
|
|
74
|
-
- **Settings**: admin → Settings → Consent (enabled/mode/layout/
|
|
75
|
-
privacy_href/copy per locale).
|
|
76
|
-
|
|
77
|
-
```bash
|
|
78
|
-
# Complete, renderable config — even on an unconfigured store the defaults
|
|
79
|
-
# make every documented key present.
|
|
80
|
-
BODY=$(curl -sf "$BASE/api/store/consent" -H "x-client-id: $CLIENT_ID")
|
|
81
|
-
echo "$BODY" | grep -q '"consent"'
|
|
82
|
-
echo "$BODY" | grep -q '"mode"'
|
|
83
|
-
echo "$BODY" | grep -q '"layout"'
|
|
84
|
-
echo "$BODY" | grep -q '"privacy_href"'
|
|
85
|
-
echo "$BODY" | grep -q '"reject_on_first_layer"'
|
|
86
|
-
echo "$BODY" | grep -q '"analytics_label"'
|
|
87
|
-
echo "$BODY" | grep -q '"ads_description"'
|
|
88
|
-
# Missing tenant header → the standard 400.
|
|
89
|
-
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/store/consent")
|
|
90
|
-
test "$STATUS" = 400
|
|
91
|
-
```
|
|
1
|
+
# Consent
|
|
2
|
+
|
|
3
|
+
The store's CMP configuration for the built-in Consent Mode v2 banner
|
|
4
|
+
(consent-management card). Defaults are **always applied server-side**, so
|
|
5
|
+
the payload is complete and renderable even for an unconfigured store
|
|
6
|
+
(compliant built-in modal, BG + EN copy shipped).
|
|
7
|
+
|
|
8
|
+
## Storefront wiring (the trap that matters)
|
|
9
|
+
|
|
10
|
+
- Mount `<ConsentInit required={consent.enabled}>` as the **first child of
|
|
11
|
+
`<body>`** — it sets the synchronous Consent Mode v2 DEFAULT and must
|
|
12
|
+
**never wait on this fetch in the browser** (async default = first-hit
|
|
13
|
+
consent race). Resolve this config server-side (RSC) and inline the
|
|
14
|
+
setting into the document; the prop is required, a layout without it
|
|
15
|
+
does not compile.
|
|
16
|
+
- **The switch decides the default; a stored choice wins.** `enabled: true`
|
|
17
|
+
is a store that collects consent: every visitor starts DENIED until they
|
|
18
|
+
choose on the banner. `enabled: false` is a store with no consent gate:
|
|
19
|
+
every visitor starts GRANTED, and every configured pixel fires. Either
|
|
20
|
+
way a choice already in the `_1c_consent` cookie is what counts, so a
|
|
21
|
+
visitor who declined keeps that decision if the banner is switched off.
|
|
22
|
+
- Render the built-in banner only when `enabled && mode === "builtin"`.
|
|
23
|
+
- `mode: "external"` = the merchant's CMP owns the UI and must write the
|
|
24
|
+
same `_1c_consent` cookie (or call `setConsent()`) — all Cartbase-side tag
|
|
25
|
+
gating works off that one seam.
|
|
26
|
+
- Choices persist 12 months in the cookie. Rybbit (platform analytics)
|
|
27
|
+
stays outside consent by design.
|
|
28
|
+
- Pair with [integrations.md](integrations.md): `tracking.consent_required`
|
|
29
|
+
mirrors `enabled` here, and `<StorefrontTags>` hands it to every pixel
|
|
30
|
+
that gates its own SDK (Meta, TikTok, ChatGPT). Google reads the
|
|
31
|
+
`<ConsentInit>` default.
|
|
32
|
+
|
|
33
|
+
## GET /api/store/consent — the CMP config
|
|
34
|
+
|
|
35
|
+
- **Purpose**: everything the banner needs to render, per locale.
|
|
36
|
+
- **Auth**: anon (`x-client-id`).
|
|
37
|
+
- **Request**: no params.
|
|
38
|
+
- **Response** — the EXACT allowlist (nothing else will ever appear here):
|
|
39
|
+
|
|
40
|
+
```jsonc
|
|
41
|
+
{
|
|
42
|
+
"consent": {
|
|
43
|
+
"enabled": true,
|
|
44
|
+
"mode": "builtin", // "builtin" | "external"
|
|
45
|
+
"layout": "modal", // "modal" (blocking) | "banner-bottom" (non-blocking)
|
|
46
|
+
"privacy_href": "/cookies",
|
|
47
|
+
"reject_on_first_layer": false,
|
|
48
|
+
"copy": {
|
|
49
|
+
"bg": {
|
|
50
|
+
"title": "Преди да продължиш",
|
|
51
|
+
"body": "…",
|
|
52
|
+
"privacy_link_label": "…",
|
|
53
|
+
"accept_label": "…", "settings_label": "…", "reject_label": "…",
|
|
54
|
+
"settings_title": "…",
|
|
55
|
+
"accept_all_label": "…", "save_label": "…", "reject_all_label": "…",
|
|
56
|
+
"necessary_label": "…", "necessary_description": "…",
|
|
57
|
+
"analytics_label": "…", "analytics_description": "…",
|
|
58
|
+
"ads_label": "…", "ads_description": "…"
|
|
59
|
+
},
|
|
60
|
+
"en": { /* same 16 keys — every field always present per locale */ }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
- **Errors**: `400 missing_client_id` only — the payload itself always
|
|
67
|
+
succeeds (a corrupt/missing stored config degrades to defaults, never to
|
|
68
|
+
a broken banner).
|
|
69
|
+
- **SDK**: `consent.getConsent(client)`
|
|
70
|
+
- **Components**: `<ConsentInit required={consent.enabled}>` +
|
|
71
|
+
`<ConsentBanner copy={copy[locale]} layout privacyHref
|
|
72
|
+
rejectOnFirstLayer>` (consent family; reference impl
|
|
73
|
+
`src/components/storefront/consent/`).
|
|
74
|
+
- **Settings**: admin → Settings → Consent (enabled/mode/layout/
|
|
75
|
+
privacy_href/copy per locale).
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
# Complete, renderable config — even on an unconfigured store the defaults
|
|
79
|
+
# make every documented key present.
|
|
80
|
+
BODY=$(curl -sf "$BASE/api/store/consent" -H "x-client-id: $CLIENT_ID")
|
|
81
|
+
echo "$BODY" | grep -q '"consent"'
|
|
82
|
+
echo "$BODY" | grep -q '"mode"'
|
|
83
|
+
echo "$BODY" | grep -q '"layout"'
|
|
84
|
+
echo "$BODY" | grep -q '"privacy_href"'
|
|
85
|
+
echo "$BODY" | grep -q '"reject_on_first_layer"'
|
|
86
|
+
echo "$BODY" | grep -q '"analytics_label"'
|
|
87
|
+
echo "$BODY" | grep -q '"ads_description"'
|
|
88
|
+
# Missing tenant header → the standard 400.
|
|
89
|
+
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/store/consent")
|
|
90
|
+
test "$STATUS" = 400
|
|
91
|
+
```
|