@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,440 @@
1
+ /**
2
+ * @cartbase/storefront/api/checkout — the checkout sequence.
3
+ *
4
+ * Two ways to drive checkout, both wrapped here:
5
+ *
6
+ * 1. **Orchestrated (recommended)** — `prepareCheckout()` is the atomic
7
+ * Buy-click: ONE call writes address (+billing mirror), shipping method
8
+ * (+ carrier metadata), payment collection and the payment session at
9
+ * the FINAL amount (real Stripe PaymentIntent, or a plain row for
10
+ * pp_cod/pp_manual). Then `completeCart()` (see `./carts`). Amount
11
+ * drift/repair between the two: `syncPaymentAmount()` and
12
+ * `refreshPaymentIfTerminal()`.
13
+ *
14
+ * 2. **Manual (Medusa-style)** — `listShippingOptions()` →
15
+ * `addShippingMethod()` → `createPaymentCollection()` →
16
+ * `initiatePaymentSession()` → `completeCart()`.
17
+ *
18
+ * Amount authority everywhere: the SERVER totals engine. The client never
19
+ * supplies an amount; sessions charge `total − gift_card_total`. Money is
20
+ * EUR decimal major units. Route ground truth: src/app/api/store/
21
+ * {shipping-options,payment-providers,payment-collections}/**, carts/[id]/
22
+ * {shipping-methods,prepare-checkout,sync-payment-amount,
23
+ * refresh-payment-if-terminal} + src/lib/checkout-orchestration/*.
24
+ * Doc: docs/storefront/checkout.md.
25
+ */
26
+ import type { StorefrontClient } from "./http"
27
+ import type { ListEnvelope, MajorUnitAmount } from "./types"
28
+ import type { CartResponse } from "./carts"
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Shipping options
32
+ // ---------------------------------------------------------------------------
33
+
34
+ export interface ListShippingOptionsQuery {
35
+ /**
36
+ * Prices the options in the cart's currency AND gives the checkout-rules
37
+ * engine its evaluation context. Without it `amount` is null and
38
+ * cart-dependent hide rules cannot match (the completion guard remains
39
+ * the boundary).
40
+ */
41
+ cart_id?: string
42
+ }
43
+
44
+ /** Row of GET /api/store/shipping-options. */
45
+ export interface StoreShippingOption {
46
+ id: string
47
+ name: string
48
+ provider_id: string | null
49
+ service_zone_id: string | null
50
+ shipping_option_type_id: string | null
51
+ shipping_profile_id: string | null
52
+ data: Record<string, unknown> | null
53
+ /** Embedded `shipping_option_types` row (label/description/code) or null. */
54
+ type: Record<string, unknown> | null
55
+ /** Flat price in the cart currency; null without `cart_id` or when no price row matches. */
56
+ amount: MajorUnitAmount | null
57
+ /** Always "flat" today — calculated-rate carriers are not wired yet. */
58
+ price_type: "flat"
59
+ [key: string]: unknown
60
+ }
61
+
62
+ export interface ShippingOptionListResponse extends ListEnvelope {
63
+ shipping_options: StoreShippingOption[]
64
+ }
65
+
66
+ /**
67
+ * List shipping options (rule-filtered, display-ordered). → 200 list
68
+ * envelope; `count`/`limit` = full filtered list (no pagination).
69
+ * Auth: anon `x-client-id`.
70
+ * Errors: 404 `cart_not_found` (bad `cart_id`).
71
+ * Settings: checkout rules (`target_type=shipping_option`) hide options
72
+ * server-side; `checkout_method_order` orders them; rules evaluate the cart
73
+ * context (customer tags/groups, totals, weight, channel, country) when
74
+ * `cart_id` is passed. Fail-open: a broken rule never bricks the listing.
75
+ */
76
+ export async function listShippingOptions(
77
+ client: StorefrontClient,
78
+ query: ListShippingOptionsQuery = {}
79
+ ): Promise<ShippingOptionListResponse> {
80
+ return client.get("/api/store/shipping-options", { query: { ...query } })
81
+ }
82
+
83
+ export interface CalculateShippingOptionInput {
84
+ cart_id: string
85
+ /** Provider-specific fields — accepted and currently ignored (flat prices only). */
86
+ data?: Record<string, unknown>
87
+ }
88
+
89
+ export interface CalculateShippingOptionResponse {
90
+ shipping_option: StoreShippingOption & { amount: MajorUnitAmount }
91
+ }
92
+
93
+ /**
94
+ * Price one option for a cart. → 200 `{shipping_option}` with `amount` in
95
+ * the cart currency. Today this returns the flat price-set match —
96
+ * calculated-rate carriers are not wired (the `data` body is reserved).
97
+ * Auth: anon `x-client-id`.
98
+ * Errors: 404 `cart_not_found` | `shipping_option_not_found`, 400
99
+ * `shipping_price_missing` (no price row in the cart currency) |
100
+ * `validation_failed`.
101
+ */
102
+ export async function calculateShippingOption(
103
+ client: StorefrontClient,
104
+ shippingOptionId: string,
105
+ input: CalculateShippingOptionInput
106
+ ): Promise<CalculateShippingOptionResponse> {
107
+ return client.post(`/api/store/shipping-options/${shippingOptionId}/calculate`, input)
108
+ }
109
+
110
+ /**
111
+ * Add/replace the cart's shipping method (manual checkout path — the
112
+ * orchestrated path does this inside `prepareCheckout`). → 200 `{cart}`.
113
+ * Body: `{option_id, data?}` (`.strict()`).
114
+ * Auth: anon `x-client-id`.
115
+ * Errors: 404 `cart_not_found` | `shipping_option_not_found`, 409
116
+ * `cart_completed`, 400 `shipping_price_missing` | `validation_failed`.
117
+ */
118
+ export async function addShippingMethod(
119
+ client: StorefrontClient,
120
+ cartId: string,
121
+ input: { option_id: string; data?: Record<string, unknown> }
122
+ ): Promise<CartResponse> {
123
+ return client.post(`/api/store/carts/${cartId}/shipping-methods`, input)
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Payment providers
128
+ // ---------------------------------------------------------------------------
129
+
130
+ export interface ListPaymentProvidersQuery {
131
+ /** Scope to providers linked to this region (`region_payment_provider`). */
132
+ region_id?: string
133
+ /**
134
+ * Checkout-rules evaluation context — storefronts SHOULD pass it during
135
+ * checkout; without it cart-dependent hide rules cannot match here.
136
+ */
137
+ cart_id?: string
138
+ }
139
+
140
+ /** Row of GET /api/store/payment-providers. */
141
+ export interface StorePaymentProvider {
142
+ /** e.g. `pp_stripe`, `pp_cod`, `pp_manual`. `pp_giftcard` is internal and never listed. */
143
+ id: string
144
+ is_enabled: boolean
145
+ created_at?: string
146
+ }
147
+
148
+ export interface PaymentProviderListResponse extends ListEnvelope {
149
+ payment_providers: StorePaymentProvider[]
150
+ }
151
+
152
+ /**
153
+ * List enabled payment providers (rule-filtered, display-ordered). → 200
154
+ * list envelope; `count`/`limit` = full filtered list (no pagination).
155
+ * Without `region_id`: the tenant's enabled providers
156
+ * (`tenant_payment_providers` ∩ enabled catalog rows). With `region_id`:
157
+ * providers linked to that region.
158
+ * Auth: anon `x-client-id`.
159
+ * Settings: provider enablement (admin integrations — enabling the COD or
160
+ * Stripe integration provisions `pp_cod`/`pp_stripe`), checkout rules
161
+ * (`target_type=payment_method`) + `checkout_method_order`.
162
+ */
163
+ export async function listPaymentProviders(
164
+ client: StorefrontClient,
165
+ query: ListPaymentProvidersQuery = {}
166
+ ): Promise<PaymentProviderListResponse> {
167
+ return client.get("/api/store/payment-providers", { query: { ...query } })
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Payment collections + sessions (manual checkout path)
172
+ // ---------------------------------------------------------------------------
173
+
174
+ /**
175
+ * Body of POST /api/store/payment-collections.
176
+ * NOTE (code wins over store-api.md): ONLY `cart_id` is read — the
177
+ * contract's `provider_id`/`data` fields are ignored by the route; the
178
+ * provider is chosen when initiating the payment session.
179
+ */
180
+ export interface CreatePaymentCollectionInput {
181
+ cart_id: string
182
+ }
183
+
184
+ export interface StorePaymentCollection {
185
+ id: string
186
+ /** Always the cart's CURRENT decorated total (refreshed on every call). */
187
+ amount: MajorUnitAmount
188
+ currency_code: string
189
+ /** `not_paid` until complete authorizes. */
190
+ status: string
191
+ payment_sessions?: StorePaymentSession[]
192
+ [key: string]: unknown
193
+ }
194
+
195
+ export interface CreatePaymentCollectionResponse {
196
+ payment_collection: StorePaymentCollection
197
+ }
198
+
199
+ /**
200
+ * Ensure the cart's payment collection (ONE per cart, idempotent). → 201
201
+ * `{payment_collection}` when created, 200 when the existing one was
202
+ * refreshed to the current total.
203
+ * Auth: anon `x-client-id`.
204
+ * Errors: 404 `cart_not_found`, 400 `validation_failed`.
205
+ * Behavior: the moment a collection exists, any applied gift-card tender is
206
+ * composed as an internal `pp_giftcard` session (gift-cards contract).
207
+ */
208
+ export async function createPaymentCollection(
209
+ client: StorefrontClient,
210
+ input: CreatePaymentCollectionInput
211
+ ): Promise<CreatePaymentCollectionResponse> {
212
+ return client.post("/api/store/payment-collections", input)
213
+ }
214
+
215
+ export interface InitiatePaymentSessionInput {
216
+ /** `pp_stripe` | `pp_cod` | `pp_manual` — NEVER `pp_giftcard` (→ 400 `invalid_provider`). */
217
+ provider_id: string
218
+ /**
219
+ * Merged into the session's `data` blob. Provider-state keys
220
+ * (`payment_intent_id`, `client_secret`, `status`, `stripe_customer_id`,
221
+ * `setup_future_usage`) are server-owned and stripped.
222
+ */
223
+ data?: Record<string, unknown>
224
+ /**
225
+ * Subscription carts only — saves the card for future off-session renewal
226
+ * charges. Requires a logged-in customer (guest → 400 `customer_required`);
227
+ * the server resolves WHICH customer from the cart, never from the client.
228
+ * Setting this IS the mandate moment: render the saved-card consent text
229
+ * next to the payment element when you set it. No-op on non-card providers.
230
+ */
231
+ save_payment_method?: boolean
232
+ }
233
+
234
+ export interface StorePaymentSession {
235
+ id: string
236
+ provider_id: string
237
+ /** `collection.amount − gift_card_total` — the remainder this provider charges. */
238
+ amount: MajorUnitAmount
239
+ currency_code: string
240
+ /** `pending` until authorized at complete. */
241
+ status: string
242
+ authorized_at: string | null
243
+ /**
244
+ * Provider blob. For Stripe: `payment_intent_id`, `client_secret`
245
+ * (mount Stripe Elements with it), `status`. Plain row for pp_cod/pp_manual.
246
+ */
247
+ data: Record<string, unknown> | null
248
+ [key: string]: unknown
249
+ }
250
+
251
+ export interface InitiatePaymentSessionResponse {
252
+ payment_session: StorePaymentSession
253
+ }
254
+
255
+ /**
256
+ * Mint (or repair) the payment session for a provider on a collection —
257
+ * idempotent per provider. → 201 `{payment_session}` when created, 200 when
258
+ * the existing session was returned/repaired. For Stripe the PaymentIntent
259
+ * is minted FIRST (idempotency key = session id); amount drift is synced
260
+ * in place; terminal PIs self-heal by rotation.
261
+ * Auth: anon `x-client-id`.
262
+ * Errors: 404 `payment_collection_not_found`, 400 `invalid_provider`
263
+ * (pp_giftcard) | `stripe_not_configured` | `validation_failed`.
264
+ * Settings: Stripe credentials (admin integrations); gift-card tender
265
+ * shrinks the session amount; COD fee appears on the cart the moment a live
266
+ * pp_cod session exists.
267
+ */
268
+ export async function initiatePaymentSession(
269
+ client: StorefrontClient,
270
+ paymentCollectionId: string,
271
+ input: InitiatePaymentSessionInput
272
+ ): Promise<InitiatePaymentSessionResponse> {
273
+ return client.post(
274
+ `/api/store/payment-collections/${paymentCollectionId}/payment-sessions`,
275
+ input
276
+ )
277
+ }
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // Orchestrated Buy-click (prepare → confirm → complete)
281
+ // ---------------------------------------------------------------------------
282
+
283
+ /**
284
+ * Address for prepare-checkout — STRICTER than the generic cart address:
285
+ * every field required except `address_2`/`company`/`province` (min 1 char;
286
+ * phone included — it's the courier's primary recovery channel).
287
+ */
288
+ export interface PrepareCheckoutAddress {
289
+ first_name: string
290
+ last_name: string
291
+ address_1: string
292
+ address_2?: string
293
+ company?: string
294
+ province?: string
295
+ city: string
296
+ postal_code: string
297
+ country_code: string
298
+ phone: string
299
+ }
300
+
301
+ /** Body of POST /api/store/carts/:id/prepare-checkout (`.strict()`). */
302
+ export interface PrepareCheckoutInput {
303
+ shipping_address: PrepareCheckoutAddress
304
+ /** A shipping option id (billing address mirrors the shipping address). */
305
+ shipping_method_id: string
306
+ /** Stored on the cart_shipping_methods row. */
307
+ shipping_method_data?: Record<string, unknown>
308
+ /**
309
+ * Opaque per-carrier keys (locker/office pickers) merged into
310
+ * `cart.metadata`; keys written by the PREVIOUS prepare call are removed
311
+ * first (tracked under the reserved `_prepared_carrier_keys` marker) so
312
+ * switching carriers never leaks stale fields into the order.
313
+ */
314
+ carrier_metadata?: Record<string, unknown>
315
+ /** `pp_stripe` | `pp_cod` | `pp_manual` — never `pp_giftcard` (→ 400 `invalid_provider`). */
316
+ payment_provider: string
317
+ }
318
+
319
+ /** Response of prepare-checkout (verbatim src/lib/checkout-orchestration/prepare.ts). */
320
+ export interface PrepareCheckoutResult {
321
+ cart_id: string
322
+ payment_collection_id: string | null
323
+ /** Stripe Elements secret; null for pp_cod/pp_manual and zero-remainder carts. */
324
+ client_secret: string | null
325
+ /** The provider whose session was minted; null when zero-remainder skipped the session. */
326
+ provider_id: string | null
327
+ }
328
+
329
+ /**
330
+ * Atomic Buy-click step 1 of 2. ONE call writes address (+billing mirror) +
331
+ * carrier metadata → shipping method (priced against the new destination) →
332
+ * payment collection at the shipped total → payment session at the FINAL
333
+ * amount. For pp_cod the native fee applies once the session exists, so
334
+ * amounts are re-synced after it. Zero-remainder carts (gift cards cover
335
+ * the total) skip the provider session — `client_secret`/`provider_id`
336
+ * come back null and the cart completes on the gift session.
337
+ *
338
+ * Fully compensated (wire-pattern): any failure rolls back session →
339
+ * collection → shipping method → addresses/metadata to the pre-call
340
+ * snapshot and records a reverted execution-ledger row; failures land in
341
+ * `checkout_error_logs` (step `prepare-checkout`).
342
+ *
343
+ * After prepare: Stripe → `stripe.confirmPayment(client_secret)` →
344
+ * `completeCart()`; COD/manual → `completeCart()` directly.
345
+ *
346
+ * Auth: anon `x-client-id`.
347
+ * Errors: 404 `cart_not_found` | `shipping_option_not_found`, 409
348
+ * `cart_completed`, 400 `validation_failed` | `invalid_provider` |
349
+ * `shipping_price_missing` | `stripe_not_configured`.
350
+ * Settings: Stripe credentials, COD integration fee, gift-card tender,
351
+ * checkout rules (enforced at listings + complete, not here).
352
+ */
353
+ export async function prepareCheckout(
354
+ client: StorefrontClient,
355
+ cartId: string,
356
+ input: PrepareCheckoutInput
357
+ ): Promise<PrepareCheckoutResult> {
358
+ return client.post(`/api/store/carts/${cartId}/prepare-checkout`, input)
359
+ }
360
+
361
+ /** Body of POST /api/store/carts/:id/sync-payment-amount (`.strict()`). */
362
+ export interface SyncPaymentAmountInput {
363
+ /** Rotate to this provider when it differs from the pending session's. */
364
+ provider_id?: string
365
+ }
366
+
367
+ /** Response matrix (verbatim src/lib/checkout-orchestration/sync.ts). */
368
+ export interface SyncPaymentAmountResult {
369
+ synced: boolean
370
+ /** true = old session retired + fresh session minted (new client_secret). */
371
+ rotated?: boolean
372
+ client_secret?: string | null
373
+ provider_id?: string | null
374
+ /** No-op reasons: `cart-completed` | `no_payment_collection` | `no_pending_session`. */
375
+ reason?: string
376
+ }
377
+
378
+ /**
379
+ * Align the pending provider session with the cart's CURRENT total, in
380
+ * place when possible — the happy path returns the SAME `client_secret` so
381
+ * `<Elements>` never remounts. Call after anything that changes the total
382
+ * (line/shipping/gift-card changes) while checkout is mounted.
383
+ *
384
+ * Matrix: completed cart → `{synced:false, reason:"cart-completed"}`; no
385
+ * collection → `no_payment_collection`; no pending provider session (gift
386
+ * sessions excluded) → `no_pending_session`; provider matches + amount
387
+ * current → no-op `{synced:true, rotated:false}`; amount drifted →
388
+ * in-place update (same secret); provider mismatch OR the Stripe update
389
+ * refused (terminal PI) → rotation `{synced:true, rotated:true}` with a
390
+ * fresh secret. The old PI is voided best-effort on rotation.
391
+ *
392
+ * Auth: anon `x-client-id`.
393
+ * Errors: 404 `cart_not_found`, 400 `validation_failed` |
394
+ * `stripe_not_configured`. Failures land in `checkout_error_logs`
395
+ * (step `sync-payment-amount`).
396
+ */
397
+ export async function syncPaymentAmount(
398
+ client: StorefrontClient,
399
+ cartId: string,
400
+ input: SyncPaymentAmountInput = {}
401
+ ): Promise<SyncPaymentAmountResult> {
402
+ return client.post(`/api/store/carts/${cartId}/sync-payment-amount`, input)
403
+ }
404
+
405
+ /** Response (verbatim src/lib/checkout-orchestration/refresh.ts). */
406
+ export interface RefreshPaymentResult {
407
+ rotated: boolean
408
+ /**
409
+ * Rotated: `pi-terminal` | `pi-missing`. Not rotated: `cart-completed` |
410
+ * `no-stripe-session` | `no-pi-id` | `stripe-not-configured` |
411
+ * `still-usable` (+ `status`) | `stripe-error` (+ `error`).
412
+ */
413
+ reason: string
414
+ /** Present with `still-usable` — the live PI status. */
415
+ status?: string
416
+ /** Present after rotation — the dead PI's status (or "missing"). */
417
+ previous_status?: string
418
+ /** Present with `stripe-error`. */
419
+ error?: string
420
+ }
421
+
422
+ /**
423
+ * Reactive dead-PI recovery — reconcile the pending STRIPE session against
424
+ * Stripe's actual PaymentIntent and rotate a fresh session/PI when the
425
+ * intent is terminal (`succeeded`/`canceled`/`requires_capture`) or
426
+ * missing. Call from Elements `loaderror` or on page mount for aged carts —
427
+ * NEVER proactively per render (the proactive variant caused a production
428
+ * reload loop). Any transient Stripe error returns
429
+ * `{rotated:false, reason:"stripe-error"}` WITHOUT rotating. No body.
430
+ *
431
+ * Auth: anon `x-client-id`.
432
+ * Errors: 404 `cart_not_found`. Every rotation writes an audit row
433
+ * (`checkout_error_logs` step `refresh-payment`, code `rotated`).
434
+ */
435
+ export async function refreshPaymentIfTerminal(
436
+ client: StorefrontClient,
437
+ cartId: string
438
+ ): Promise<RefreshPaymentResult> {
439
+ return client.post(`/api/store/carts/${cartId}/refresh-payment-if-terminal`)
440
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * @cartbase/storefront/api/collections — collections + membership listings.
3
+ *
4
+ * Collections are the merchant's curated product groupings
5
+ * (collections-upgrade card): manual or smart (`conditions`/`match`), with a
6
+ * per-collection `default_sort` the membership listing honors and an
7
+ * optional per-request `order` override. Docs:
8
+ * docs/storefront/collections.md.
9
+ */
10
+ import type { StorefrontClient } from "./http"
11
+ import type { IsoDateString, ListEnvelope, PaginationQuery, PricingContextQuery } from "./types"
12
+ import type { StoreProduct } from "./products"
13
+
14
+ /** Sort modes accepted by `default_sort` and the `order` override. */
15
+ export type CollectionSortMode =
16
+ | "manual"
17
+ | "title_asc"
18
+ | "title_desc"
19
+ | "price_asc"
20
+ | "price_desc"
21
+ | "newest"
22
+ | "oldest"
23
+ | "best_selling"
24
+
25
+ export interface StoreCollection {
26
+ id: string
27
+ title: string
28
+ handle: string
29
+ /** "manual" | "smart" (smart membership is computed from `conditions`). */
30
+ type: string
31
+ description: string | null
32
+ image_url: string | null
33
+ /** The sort the membership listing uses when no `order` override is sent. */
34
+ default_sort: string
35
+ /** Smart-collection condition rows (admin-authored; opaque to render). */
36
+ conditions: unknown
37
+ /** Smart matching: "all" | "any". */
38
+ match: string
39
+ /** SEO override; null = fall back to `title`. */
40
+ seo_title: string | null
41
+ /** SEO override; null = fall back to `description`. */
42
+ seo_description: string | null
43
+ metadata: Record<string, unknown> | null
44
+ created_at: IsoDateString
45
+ updated_at: IsoDateString
46
+ }
47
+
48
+ export interface ListCollectionsQuery extends PaginationQuery {
49
+ /** Case-insensitive substring match on title. */
50
+ q?: string
51
+ handle?: string
52
+ /**
53
+ * Channel scope: a collection with channel links is visible ONLY on those
54
+ * channels (excluded from other channels' lists); a collection with no
55
+ * links is visible everywhere.
56
+ */
57
+ sales_channel_id?: string
58
+ }
59
+
60
+ export interface CollectionListResponse extends ListEnvelope {
61
+ collections: StoreCollection[]
62
+ }
63
+
64
+ export interface CollectionResponse {
65
+ collection: StoreCollection
66
+ }
67
+
68
+ export interface ListCollectionProductsQuery extends PricingContextQuery, PaginationQuery {
69
+ /** Sort override — any CollectionSortMode; unknown values are ignored. */
70
+ order?: CollectionSortMode | string
71
+ /** Channel scope: a collection scoped to OTHER channels 404s here. */
72
+ sales_channel_id?: string
73
+ }
74
+
75
+ export interface CollectionProductsResponse extends ListEnvelope {
76
+ products: StoreProduct[]
77
+ }
78
+
79
+ /**
80
+ * List collections, ordered by title.
81
+ *
82
+ * Auth: anon (`x-client-id`). Errors: 400 `missing_client_id`,
83
+ * 400 `validation_failed`. Settings: collection channel links
84
+ * (`sales_channel_id` scope), smart-collection conditions.
85
+ */
86
+ export async function listCollections(
87
+ client: StorefrontClient,
88
+ query?: ListCollectionsQuery
89
+ ): Promise<CollectionListResponse> {
90
+ return client.get("/api/store/collections", { query: { ...query } })
91
+ }
92
+
93
+ /**
94
+ * Retrieve one collection by id. (Look up by handle via
95
+ * `listCollections({ handle })`.)
96
+ *
97
+ * Auth: anon (`x-client-id`). Errors: 404 `not_found`. NOTE: the single read
98
+ * takes no `sales_channel_id` — channel scope applies to the list and to the
99
+ * membership listing, not here.
100
+ */
101
+ export async function retrieveCollection(
102
+ client: StorefrontClient,
103
+ collectionId: string
104
+ ): Promise<CollectionResponse> {
105
+ return client.get(`/api/store/collections/${encodeURIComponent(collectionId)}`)
106
+ }
107
+
108
+ /**
109
+ * List a collection's products — the MEMBERSHIP join (multi-collection
110
+ * products appear in every collection they belong to), published products
111
+ * only, ordered by the collection's `default_sort` unless `order` overrides
112
+ * (manual position, title, EUR price asc/desc, newest/oldest, best_selling
113
+ * 90-day aggregate). Products carry the full `StoreProduct` shape incl.
114
+ * `calculated_price` when a pricing context is given.
115
+ *
116
+ * Auth: anon (`x-client-id`); optional Bearer JWT for group-aware pricing.
117
+ * Errors: 404 `not_found` (unknown collection, or scoped away from
118
+ * `sales_channel_id`), 400 `validation_failed`, 400 `invalid_region`.
119
+ * `limit` 1–100 (default 50). Settings: collection default_sort + manual
120
+ * order, channel links, price lists.
121
+ */
122
+ export async function listCollectionProducts(
123
+ client: StorefrontClient,
124
+ collectionId: string,
125
+ query?: ListCollectionProductsQuery
126
+ ): Promise<CollectionProductsResponse> {
127
+ return client.get(`/api/store/collections/${encodeURIComponent(collectionId)}/products`, {
128
+ query: { ...query },
129
+ })
130
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @cartbase/storefront/api/consent — the store's CMP configuration for the
3
+ * built-in Consent Mode v2 banner (consent-management card).
4
+ *
5
+ * Ground truth: src/app/api/store/consent/route.ts +
6
+ * src/lib/consent/model.ts (publicConsentPayload — exact allowlist).
7
+ *
8
+ * WIRING TRAP (the one that matters): the synchronous Consent Mode v2
9
+ * DEFAULT (`<ConsentInit>`, first child of <body>) must NEVER wait on this
10
+ * fetch — resolve the config server-side (RSC) and inline it. Render the
11
+ * built-in banner only when `enabled && mode === "builtin"`. In `external`
12
+ * mode the merchant's CMP owns the UI and must write the same `_1c_consent`
13
+ * cookie (or call `setConsent()`) — all barter-side tag gating works off
14
+ * that one seam. Choices persist 12 months.
15
+ */
16
+
17
+ import type { StorefrontClient } from "./http"
18
+
19
+ /** Per-locale banner copy — every field present (defaults merged server-side). */
20
+ export interface ConsentCopy {
21
+ // Layer 1
22
+ title: string
23
+ body: string
24
+ privacy_link_label: string
25
+ accept_label: string
26
+ settings_label: string
27
+ reject_label: string
28
+ // Layer 2 (preferences)
29
+ settings_title: string
30
+ accept_all_label: string
31
+ save_label: string
32
+ reject_all_label: string
33
+ necessary_label: string
34
+ necessary_description: string
35
+ analytics_label: string
36
+ analytics_description: string
37
+ ads_label: string
38
+ ads_description: string
39
+ }
40
+
41
+ /**
42
+ * The EXACT public payload — an allowlist; nothing else will ever appear.
43
+ * Defaults are ALWAYS applied server-side, so this is complete and
44
+ * renderable even for an unconfigured store (BG + EN copy shipped).
45
+ */
46
+ export interface ConsentConfig {
47
+ enabled: boolean
48
+ /** `builtin` = barter renders the banner; `external` = merchant CMP owns UI. */
49
+ mode: "builtin" | "external"
50
+ /** `modal` = blocking centered modal; `banner-bottom` = non-blocking bar. */
51
+ layout: "modal" | "banner-bottom"
52
+ /** Storefront-relative privacy/cookies page link. */
53
+ privacy_href: string
54
+ /** Show a reject-all button on the first layer. */
55
+ reject_on_first_layer: boolean
56
+ /** Keyed by locale code (at least `bg` + `en`). */
57
+ copy: Record<string, ConsentCopy>
58
+ }
59
+
60
+ export interface ConsentResponse {
61
+ consent: ConsentConfig
62
+ }
63
+
64
+ /**
65
+ * GET /api/store/consent — the complete, renderable consent configuration.
66
+ *
67
+ * Auth: anon (x-client-id).
68
+ * Errors: 400 missing_client_id only — the payload always succeeds
69
+ * (corrupt/missing config degrades to defaults, never to a broken banner).
70
+ * Settings: admin → Settings → Consent (enabled/mode/layout/copy);
71
+ * `integrations.tracking.consent_required` mirrors `enabled`.
72
+ */
73
+ export async function getConsent(client: StorefrontClient): Promise<ConsentResponse> {
74
+ return client.get("/api/store/consent")
75
+ }