@cartbase/storefront 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +226 -0
- package/src/api/auth.ts +108 -0
- package/src/api/carts.ts +506 -0
- package/src/api/categories.ts +184 -0
- package/src/api/checkout.ts +440 -0
- package/src/api/collections.ts +130 -0
- package/src/api/consent.ts +75 -0
- package/src/api/content.ts +125 -0
- package/src/api/customers.ts +307 -0
- package/src/api/gift-cards.ts +112 -0
- package/src/api/http.ts +122 -0
- package/src/api/index.ts +29 -0
- package/src/api/integrations.ts +130 -0
- package/src/api/menus.ts +77 -0
- package/src/api/metaobjects.ts +136 -0
- package/src/api/orders.ts +290 -0
- package/src/api/products.ts +303 -0
- package/src/api/redirects.ts +37 -0
- package/src/api/regions.ts +141 -0
- package/src/api/reviews.ts +259 -0
- package/src/api/search.ts +133 -0
- package/src/api/types.ts +91 -0
- package/src/cart-drawer/cart-drawer.tsx +86 -0
- package/src/cart-drawer/context.tsx +569 -0
- package/src/cart-drawer/continue-shopping.tsx +28 -0
- package/src/cart-drawer/cross-sell-carousel.tsx +212 -0
- package/src/cart-drawer/cross-sell-sidebar.tsx +158 -0
- package/src/cart-drawer/empty.tsx +52 -0
- package/src/cart-drawer/free-gift.tsx +71 -0
- package/src/cart-drawer/gift-wrap.tsx +83 -0
- package/src/cart-drawer/header.tsx +52 -0
- package/src/cart-drawer/index.ts +69 -0
- package/src/cart-drawer/item/index.tsx +164 -0
- package/src/cart-drawer/item/quantity.tsx +100 -0
- package/src/cart-drawer/item/upsell.tsx +110 -0
- package/src/cart-drawer/item/variant.tsx +46 -0
- package/src/cart-drawer/labels-bg.ts +72 -0
- package/src/cart-drawer/labels.ts +119 -0
- package/src/cart-drawer/notes.tsx +131 -0
- package/src/cart-drawer/payment-badges.tsx +96 -0
- package/src/cart-drawer/promo-banner.tsx +43 -0
- package/src/cart-drawer/rewards-points.tsx +78 -0
- package/src/cart-drawer/sticky-footer.tsx +73 -0
- package/src/cart-drawer/summary-breakdown.tsx +196 -0
- package/src/cart-drawer/template.tsx +225 -0
- package/src/cart-drawer/tiered-progress.tsx +168 -0
- package/src/checkout/address-error-copy.ts +119 -0
- package/src/checkout/address-form.tsx +224 -0
- package/src/checkout/address-select.tsx +79 -0
- package/src/checkout/boxnow-locker-selector.tsx +410 -0
- package/src/checkout/checkout-client.tsx +222 -0
- package/src/checkout/company-details.tsx +94 -0
- package/src/checkout/compare-addresses.ts +40 -0
- package/src/checkout/context.tsx +76 -0
- package/src/checkout/discount-section.tsx +218 -0
- package/src/checkout/econt-office-selector.tsx +332 -0
- package/src/checkout/error-message.tsx +25 -0
- package/src/checkout/geocode.ts +154 -0
- package/src/checkout/gift-card-section.tsx +224 -0
- package/src/checkout/index.ts +74 -0
- package/src/checkout/labels-bg.ts +128 -0
- package/src/checkout/labels.ts +263 -0
- package/src/checkout/line-item-card.tsx +152 -0
- package/src/checkout/order-summary.tsx +524 -0
- package/src/checkout/payment-button.tsx +373 -0
- package/src/checkout/payment-error-copy.ts +269 -0
- package/src/checkout/payment-method-list.tsx +365 -0
- package/src/checkout/payment-wrapper.tsx +102 -0
- package/src/checkout/promotion-error-copy.ts +124 -0
- package/src/checkout/shipping-method-list.tsx +335 -0
- package/src/checkout/stripe-wrapper.tsx +165 -0
- package/src/checkout/use-checkout-orchestration.ts +1504 -0
- package/src/common/cart-button-client.tsx +39 -0
- package/src/common/cart-button.tsx +28 -0
- package/src/common/country-select.tsx +65 -0
- package/src/common/delete-button.tsx +66 -0
- package/src/common/index.ts +17 -0
- package/src/common/language-select.tsx +78 -0
- package/src/common/localized-link.tsx +45 -0
- package/src/common/skeleton.tsx +29 -0
- package/src/index.ts +12 -0
- package/src/lib/cart-helpers.ts +113 -0
- package/src/lib/dual-price.tsx +73 -0
- package/src/lib/get-percentage-diff.ts +5 -0
- package/src/lib/get-product-price.ts +133 -0
- package/src/lib/hooks/use-intersection.ts +30 -0
- package/src/lib/hooks/use-toggle-state.ts +25 -0
- package/src/lib/money.ts +73 -0
- package/src/lib/payment-constants.ts +66 -0
- package/src/lib/product.ts +22 -0
- package/src/lib/sort-products.ts +63 -0
- package/src/lib/store-api-error.ts +36 -0
- package/src/lib/utils.ts +16 -0
- package/src/order/context.tsx +32 -0
- package/src/order/index.ts +63 -0
- package/src/order/labels-bg.ts +39 -0
- package/src/order/labels.ts +79 -0
- package/src/order/order-address-card.tsx +47 -0
- package/src/order/order-completed-template.tsx +165 -0
- package/src/order/order-confirmation-header.tsx +65 -0
- package/src/order/order-delivery-card.tsx +258 -0
- package/src/order/order-help-section.tsx +47 -0
- package/src/order/order-item.tsx +201 -0
- package/src/order/order-items-list.tsx +52 -0
- package/src/order/order-payment-card.tsx +95 -0
- package/src/order/order-timeline.tsx +141 -0
- package/src/order/order-totals.tsx +245 -0
- package/src/primitives/field.tsx +125 -0
- package/src/primitives/select-field.tsx +77 -0
- package/src/primitives/ui/accordion.tsx +61 -0
- package/src/primitives/ui/button.tsx +68 -0
- package/src/primitives/ui/collapsible.tsx +16 -0
- package/src/primitives/ui/dialog.tsx +112 -0
- package/src/primitives/ui/input.tsx +30 -0
- package/src/primitives/ui/label.tsx +31 -0
- package/src/primitives/ui/popover.tsx +38 -0
- package/src/primitives/ui/select.tsx +163 -0
- package/src/primitives/ui/sheet.tsx +131 -0
- package/src/primitives/ui/tabs.tsx +62 -0
- package/src/products/context.tsx +34 -0
- package/src/products/image-gallery.tsx +43 -0
- package/src/products/index.ts +44 -0
- package/src/products/labels-bg.ts +35 -0
- package/src/products/labels.ts +57 -0
- package/src/products/mobile-actions.tsx +180 -0
- package/src/products/option-select.tsx +67 -0
- package/src/products/preview-price.tsx +36 -0
- package/src/products/product-actions-wrapper.tsx +58 -0
- package/src/products/product-actions.tsx +217 -0
- package/src/products/product-info.tsx +43 -0
- package/src/products/product-preview.tsx +49 -0
- package/src/products/product-price.tsx +69 -0
- package/src/products/product-tabs.tsx +169 -0
- package/src/products/product-template.tsx +114 -0
- package/src/products/purchase-options.tsx +130 -0
- package/src/products/related-products.tsx +86 -0
- package/src/products/thumbnail.tsx +71 -0
- package/src/products/variant-matching.ts +71 -0
- package/src/reviews-ui/helpers.ts +174 -0
- package/src/reviews-ui/index.ts +74 -0
- package/src/reviews-ui/labels-bg.ts +91 -0
- package/src/reviews-ui/labels.ts +199 -0
- package/src/reviews-ui/photo-upload.tsx +345 -0
- package/src/reviews-ui/review-list.tsx +249 -0
- package/src/reviews-ui/review-widget.tsx +224 -0
- package/src/reviews-ui/review-wizard.tsx +560 -0
- package/src/reviews-ui/star-badge.tsx +104 -0
- package/src/reviews-ui/wizard-state.ts +81 -0
- package/src/store/category-template.tsx +129 -0
- package/src/store/collection-template.tsx +139 -0
- package/src/store/index.ts +41 -0
- package/src/store/labels-bg.ts +22 -0
- package/src/store/labels.ts +52 -0
- package/src/store/paginated-products.tsx +116 -0
- package/src/store/pagination.tsx +103 -0
- package/src/store/search-params.ts +256 -0
- package/src/store/search-template.tsx +249 -0
- package/src/store/skeleton-product-grid.tsx +26 -0
- package/src/store/sort-select.tsx +81 -0
- package/src/store/store-template.tsx +65 -0
- package/src/tracking/attribution.ts +418 -0
- package/src/tracking/consent-banner.tsx +355 -0
- package/src/tracking/consent-init.tsx +44 -0
- package/src/tracking/consent.ts +243 -0
- package/src/tracking/fbq.ts +168 -0
- package/src/tracking/ga4.tsx +49 -0
- package/src/tracking/get-tracking-attribution.ts +224 -0
- package/src/tracking/get-tracking-config.ts +50 -0
- package/src/tracking/gtag.ts +200 -0
- package/src/tracking/index.ts +133 -0
- package/src/tracking/meta-pixel.tsx +166 -0
- package/src/tracking/rybbit-events.ts +242 -0
- package/src/tracking/rybbit.tsx +40 -0
- package/src/tracking/types.ts +185 -0
- package/src/tracking/use-engagement-time.ts +58 -0
- package/tailwind-preset.cjs +72 -0
|
@@ -0,0 +1,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
|
+
}
|