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.
Files changed (35) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +25 -25
  3. package/dist/index.js +20 -20
  4. package/package.json +24 -24
  5. package/template/app/docs/auth.md +105 -105
  6. package/template/app/docs/carts.md +23 -4
  7. package/template/app/docs/categories.md +194 -194
  8. package/template/app/docs/checkout.md +714 -714
  9. package/template/app/docs/components.md +270 -24
  10. package/template/app/docs/consent.md +91 -91
  11. package/template/app/docs/deploy.md +197 -197
  12. package/template/app/docs/gift-cards.md +153 -153
  13. package/template/app/docs/metaobjects.md +126 -126
  14. package/template/app/docs/orders.md +221 -221
  15. package/template/app/docs/regions.md +269 -269
  16. package/template/app/docs/reviews.md +258 -238
  17. package/template/app/docs/search.md +227 -227
  18. package/template/app/docs/store.md +23 -1
  19. package/template/app/docs/subscriptions.md +148 -148
  20. package/template/app/docs/variables.md +331 -315
  21. package/template/app/package.json +1 -1
  22. package/template/app/postcss.config.cjs +11 -11
  23. package/template/app/src/app/checkout/checkout-empty.tsx +36 -0
  24. package/template/app/src/app/checkout/checkout-page-client.tsx +80 -73
  25. package/template/app/src/app/checkout/error.tsx +23 -0
  26. package/template/app/src/app/checkout/page.tsx +16 -7
  27. package/template/app/src/app/globals.css +26 -26
  28. package/template/app/src/app/layout.tsx +126 -126
  29. package/template/app/src/app/page.tsx +37 -37
  30. package/template/app/src/app/products/[handle]/page.tsx +89 -89
  31. package/template/app/src/app/search/page.tsx +33 -33
  32. package/template/app/src/lib/browser-client.ts +35 -35
  33. package/template/app/src/lib/catalog.ts +120 -120
  34. package/template/app/src/lib/server-client.ts +25 -25
  35. 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 (env: `NEXT_PUBLIC_STRIPE_KEY`; legacy env names
430
- kept as fallbacks).
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
- ### `<EcontOfficeSelector />` / `<BoxNowLockerSelector />` — `checkout/econt-office-selector`, `checkout/boxnow-locker-selector`
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
- ([integrations.md](integrations.md)); 503/502/network all render one
480
- "temporarily unavailable" state discover availability via
481
- `carriers.boxnow.lockers_url`, don't probe.
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 = always-visible payment section),
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 with qty pill), promo + gift-card
511
- widgets, totals breakdown (subtotal / shipping / COD fee / discount /
512
- VAT / total + gift-card tender rows UNDER the unchanged total), secure
513
- badge. `LineItemCard` is the standalone card variant.
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?, optimisticCodFee?, onOptimisticCodFeeClear?,
520
- methodFeeLabel?, showGiftCards?, onCartChange?}`. Optimistic values clear
521
- automatically once the server cart catches up.
522
- - **Settings** — COD integration (fee row), gift cards, promotions.
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** — collapsible promo-code input + applied-promotion list
527
- (percentage or fixed amount display).
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?}`. Errors code-first
534
- via `promotion-error-copy` (`promotion_not_found`/`promotion_inactive`/…
535
- + the no-email campaign-budget heuristic).
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
+ ```