@thorprovider/storefront 0.3.1 → 0.4.0-mvp.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/CHANGELOG.md +23 -0
- package/README.md +248 -99
- package/assets/templates/app/app/[[...slug]]/page.tsx +13 -2
- package/assets/templates/app/app/layout.tsx +18 -4
- package/assets/templates/app/app/robots.ts +13 -0
- package/assets/templates/app/app/sitemap.ts +12 -0
- package/assets/templates/app/lib/__STOREFRONT__/actions.ts +542 -0
- package/assets/templates/app/lib/__STOREFRONT__/cart-state.ts +66 -0
- package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +40 -57
- package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +608 -0
- package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +165 -0
- package/assets/templates/app/lib/__STOREFRONT__/handlers.ts +5 -36
- package/assets/templates/app/lib/__STOREFRONT__/icon-map.ts +27 -0
- package/assets/templates/app/lib/__STOREFRONT__/mappers.ts +13 -11
- package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +232 -51
- package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +3 -3
- package/assets/templates/app/lib/__STOREFRONT__/schema.ts +121 -0
- package/assets/templates/app/lib/__STOREFRONT__/session.core.ts +470 -0
- package/assets/templates/app/lib/__STOREFRONT__/session.ts +169 -0
- package/assets/templates/app/lib/__STOREFRONT__/shell/content.ts +96 -0
- package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +34 -0
- package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +100 -11
- package/assets/templates/app/lib/__STOREFRONT__/spec/page-root.ts +5 -1
- package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +26 -0
- package/assets/templates/app/lib/__STOREFRONT__/state.core.ts +257 -0
- package/assets/templates/app/lib/__STOREFRONT__/state.ts +7 -31
- package/assets/templates/app/lib/__STOREFRONT__/store-context.ts +142 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.client.tsx +25 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.tsx +16 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.client.tsx +24 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.tsx +5 -18
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/index.ts +17 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/logoCloud.tsx +26 -14
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/pricing.tsx +36 -18
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.cta.ts +19 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.faq.ts +18 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/statistics.tsx +35 -14
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/testimonials.tsx +31 -13
- package/assets/templates/app/lib/__STOREFRONT__/views/types.ts +5 -3
- package/assets/templates/features/checkout/handlers.ts +153 -0
- package/assets/templates/features/checkout/state.ts +61 -0
- package/assets/templates/features/customer-auth/handlers.ts +59 -0
- package/assets/templates/features/customer-auth/state.ts +36 -0
- package/assets/templates/features/recommendations/state.ts +11 -0
- package/assets/templates/views/account/content.ts +35 -0
- package/assets/templates/views/account/index.tsx +114 -0
- package/assets/templates/views/account/registry.tsx +378 -0
- package/assets/templates/views/account/schema.ts +52 -0
- package/assets/templates/views/category/assembly/assembly.json +28 -0
- package/assets/templates/views/category/assembly/index.skeleton.tsx +193 -0
- package/assets/templates/views/category/assembly/registry.skeleton.tsx +33 -0
- package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/catalog.part +5 -0
- package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/element.part +34 -0
- package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/registry.part +71 -0
- package/assets/templates/views/category/content.ts +30 -0
- package/assets/templates/views/category/data.ts +12 -0
- package/assets/templates/views/category/index.tsx +230 -0
- package/assets/templates/views/category/props.ts +16 -0
- package/assets/templates/views/category/registry.tsx +103 -0
- package/assets/templates/views/category/schema.ts +78 -0
- package/assets/templates/views/checkout/content.ts +105 -0
- package/assets/templates/views/checkout/data.ts +23 -0
- package/assets/templates/views/checkout/index.tsx +111 -0
- package/assets/templates/views/checkout/props.ts +49 -0
- package/assets/templates/views/checkout/registry.tsx +217 -0
- package/assets/templates/views/checkout/schema.ts +229 -0
- package/assets/templates/views/home/assembly/assembly.json +24 -0
- package/assets/templates/views/home/assembly/index.skeleton.tsx +72 -0
- package/assets/templates/views/home/assembly/registry.skeleton.tsx +25 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature1/binding.json +5 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature1/catalog.part +4 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature1/element.part +4 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature1/registry.part +11 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature2/binding.json +5 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature2/catalog.part +4 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature2/element.part +4 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature2/registry.part +10 -0
- package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/binding.json +5 -0
- package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/catalog.part +4 -0
- package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/element.part +5 -0
- package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/registry.part +16 -0
- package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/binding.json +5 -0
- package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/catalog.part +4 -0
- package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/element.part +5 -0
- package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/registry.part +26 -0
- package/assets/templates/views/home/content.ts +70 -0
- package/assets/templates/views/home/data.ts +15 -0
- package/assets/templates/views/home/index.tsx +38 -84
- package/assets/templates/views/home/props.ts +22 -0
- package/assets/templates/views/home/registry.tsx +38 -22
- package/assets/templates/views/home/schema.ts +93 -0
- package/assets/templates/views/product/content.ts +25 -0
- package/assets/templates/views/product/data.ts +48 -0
- package/assets/templates/views/product/index.tsx +104 -0
- package/assets/templates/views/product/props.ts +20 -0
- package/assets/templates/views/product/registry.tsx +78 -0
- package/assets/templates/views/product/schema.ts +108 -0
- package/assets/templates/views/static/content.ts +99 -0
- package/assets/templates/views/static/index.tsx +60 -0
- package/assets/templates/views/static/registry.tsx +66 -0
- package/assets/templates/views/static/schema.ts +45 -0
- package/{skills/sf-view-gen/assets → assets}/templates/views/user-panel/index.tsx +8 -70
- package/{skills/sf-view-gen/assets → assets}/templates/views/user-panel/registry.tsx +23 -18
- package/assets/templates/views/user-panel/schema.ts +79 -0
- package/bin/storefront.js +1278 -79
- package/commands/sf-add-view.md +44 -13
- package/commands/sf-feature.md +28 -0
- package/commands/sf-theme.md +14 -8
- package/commands/sf-view.md +54 -12
- package/package.json +6 -4
- package/recipes/archetypes.json +28 -1
- package/recipes/catalog.json +361 -0
- package/recipes/catalog.mjs +128 -0
- package/recipes/catalog.schema.json +64 -0
- package/recipes/feature.schema.json +89 -0
- package/recipes/features.json +139 -0
- package/recipes/manifest.schema.json +69 -0
- package/recipes/operations.schema.json +43 -0
- package/recipes/recipe.schema.json +11 -2
- package/recipes/recipes.json +60 -5
- package/recipes/sections.json +24 -5
- package/recipes/validate.mjs +784 -32
- package/scripts/candidate.mjs +236 -0
- package/scripts/composers.mjs +140 -0
- package/scripts/file-classes.mjs +116 -0
- package/scripts/logical-controls.mjs +61 -0
- package/scripts/manifest.mjs +298 -0
- package/scripts/operation-registry.mjs +256 -0
- package/scripts/operations.mjs +720 -0
- package/scripts/ownership-conflict.mjs +356 -0
- package/scripts/pinning.mjs +504 -0
- package/scripts/plan-apply.mjs +843 -0
- package/scripts/preflight.mjs +51 -0
- package/scripts/reconcile.mjs +511 -0
- package/scripts/render-templates.mjs +245 -28
- package/scripts/schema-validate.mjs +132 -0
- package/scripts/section-compat.mjs +300 -0
- package/scripts/section-operations.mjs +508 -0
- package/scripts/sync-assets.mjs +22 -15
- package/scripts/view-assembly.mjs +291 -0
- package/scripts/view-selection.mjs +211 -0
- package/skills/sf-feature/SKILL.md +67 -0
- package/skills/sf-theme-gen/SKILL.md +12 -5
- package/skills/sf-view-gen/SKILL.md +123 -93
- package/skills/sf-view-gen/assets/recipes/archetypes.json +28 -1
- package/skills/sf-view-gen/assets/recipes/catalog.json +361 -0
- package/skills/sf-view-gen/assets/recipes/catalog.schema.json +64 -0
- package/skills/sf-view-gen/assets/recipes/features.json +139 -0
- package/skills/sf-view-gen/assets/recipes/recipes.json +60 -5
- package/skills/sf-view-gen/assets/recipes/sections.json +24 -5
- package/skills/sf-view-gen/assets/templates/views/sections/cta.client.tsx +25 -0
- package/skills/sf-view-gen/assets/templates/views/sections/cta.tsx +16 -0
- package/skills/sf-view-gen/assets/templates/views/sections/faq.client.tsx +13 -9
- package/skills/sf-view-gen/assets/templates/views/sections/faq.tsx +2 -7
- package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.client.tsx +12 -8
- package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.tsx +2 -7
- package/skills/sf-view-gen/assets/templates/views/sections/pricing.client.tsx +14 -10
- package/skills/sf-view-gen/assets/templates/views/sections/pricing.tsx +2 -9
- package/skills/sf-view-gen/assets/templates/views/sections/schema.cta.ts +19 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.faq.ts +18 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.logoCloud.ts +19 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.pricing.ts +26 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.statistics.ts +25 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.testimonials.ts +23 -0
- package/skills/sf-view-gen/assets/templates/views/sections/statistics.client.tsx +16 -8
- package/skills/sf-view-gen/assets/templates/views/sections/statistics.tsx +2 -7
- package/skills/sf-view-gen/assets/templates/views/sections/testimonials.client.tsx +13 -9
- package/skills/sf-view-gen/assets/templates/views/sections/testimonials.tsx +2 -5
- package/skills/sf-view-gen/references/views.md +44 -10
- package/skills/sf-view-gen/scripts/catalog.mjs +128 -0
- package/skills/sf-view-gen/scripts/schema-validate.mjs +132 -0
- package/skills/sf-view-gen/scripts/validate.mjs +784 -32
- package/skills/storefront-workflow/SKILL.md +116 -0
- package/skills/sf-view-gen/assets/templates/views/category/index.tsx +0 -86
- package/skills/sf-view-gen/assets/templates/views/category/registry.tsx +0 -48
- package/skills/sf-view-gen/assets/templates/views/checkout/index.tsx +0 -192
- package/skills/sf-view-gen/assets/templates/views/checkout/registry.tsx +0 -45
- package/skills/sf-view-gen/assets/templates/views/product/index.tsx +0 -122
- package/skills/sf-view-gen/assets/templates/views/product/registry.tsx +0 -49
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
// Generated by Thor Commerce storefront (template: assets/templates/app/lib/__STOREFRONT__/commerce.ts)
|
|
2
|
+
//
|
|
3
|
+
// Host commerce boundary — the single seam between the generated storefront and
|
|
4
|
+
// `@thorprovider/runtime/commerce`. Every backend call the host makes goes
|
|
5
|
+
// through here so the contract is owned once, the layer boundary is explicit,
|
|
6
|
+
// and a future change to the runtime provider is absorbed in one file.
|
|
7
|
+
//
|
|
8
|
+
// Boundary rules (change B):
|
|
9
|
+
// - Server-only, JSX-free, Next-free. No `'use client'`, no React import.
|
|
10
|
+
// - Context-scoped: every cart/checkout call takes the validated single-store
|
|
11
|
+
// context, and the runtime verifies the backend cart belongs to it.
|
|
12
|
+
// - The backend is authoritative: totals are mapped from the backend cart and
|
|
13
|
+
// never computed locally; a missing amount is omitted, never invented.
|
|
14
|
+
// - Optional images are normalized (an absent image is omitted, never `""`).
|
|
15
|
+
// - Add uses the selected backend VARIANT id; update/remove use the backend
|
|
16
|
+
// cart LINE-ITEM id. The Navbar's DELTA is normalized to an absolute quantity
|
|
17
|
+
// against the authoritative line via `toAbsoluteLineUpdate`.
|
|
18
|
+
// - Completion is discriminated and never blind-retried; reconciliation reads
|
|
19
|
+
// the committed cart (`completedAt`) and completes once only when committed.
|
|
20
|
+
|
|
21
|
+
import type {
|
|
22
|
+
Cart,
|
|
23
|
+
Customer,
|
|
24
|
+
LoginCredentials,
|
|
25
|
+
Money,
|
|
26
|
+
Order,
|
|
27
|
+
PaymentMethod,
|
|
28
|
+
RegisterData,
|
|
29
|
+
ShippingMethod,
|
|
30
|
+
StoreContext,
|
|
31
|
+
} from '@thorprovider/types'
|
|
32
|
+
import {
|
|
33
|
+
addCartLines,
|
|
34
|
+
applyManualTestPayment,
|
|
35
|
+
APPROVED_MANUAL_TEST_PROVIDER_ID,
|
|
36
|
+
completeCart,
|
|
37
|
+
createCartForContext,
|
|
38
|
+
getCartForContext,
|
|
39
|
+
getCheckoutCountries,
|
|
40
|
+
getCurrentCustomer,
|
|
41
|
+
getCustomerOrders,
|
|
42
|
+
getEligibleShippingMethods,
|
|
43
|
+
getPaymentMethodsByRegion,
|
|
44
|
+
getProductDetailByHandle,
|
|
45
|
+
loginCustomer,
|
|
46
|
+
logoutCustomer,
|
|
47
|
+
reconcileCart,
|
|
48
|
+
registerCustomer,
|
|
49
|
+
removeCartLines,
|
|
50
|
+
selectShippingMethod,
|
|
51
|
+
toAbsoluteLineUpdate,
|
|
52
|
+
updateCartLines,
|
|
53
|
+
updateShippingAddress,
|
|
54
|
+
validateCheckoutAddressForRegion,
|
|
55
|
+
type AuthOutcome,
|
|
56
|
+
type CartCompletionResult,
|
|
57
|
+
type CatalogProductDetail,
|
|
58
|
+
type CheckoutAddress,
|
|
59
|
+
type CheckoutFieldError,
|
|
60
|
+
type RegionCountry,
|
|
61
|
+
} from '@thorprovider/runtime/commerce'
|
|
62
|
+
import type {
|
|
63
|
+
AccountOrderItemView,
|
|
64
|
+
AccountOrderView,
|
|
65
|
+
CartLineState,
|
|
66
|
+
CartTotalsState,
|
|
67
|
+
CheckoutFieldErrors,
|
|
68
|
+
CheckoutShippingOption,
|
|
69
|
+
StorefrontOrder,
|
|
70
|
+
StorefrontOrderLineView,
|
|
71
|
+
} from './state.core'
|
|
72
|
+
import { assertBackendConfigured, getDataSource, getStoreContext, type StoreDataSource } from './store-context'
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Projections (serializable; the UI state holds these)
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
/** The cart slice of the storefront state. */
|
|
79
|
+
export interface CartStatePatch {
|
|
80
|
+
cart: CartLineState[]
|
|
81
|
+
cartTotals: CartTotalsState | null
|
|
82
|
+
cartId: string | null
|
|
83
|
+
cartQuantity: number
|
|
84
|
+
cartCompleted: boolean
|
|
85
|
+
/** The shipping OPTION id the checkout UI selects (`so_`), not the applied
|
|
86
|
+
* shipping-method record id (`sm_`) used as payment proof. */
|
|
87
|
+
cartSelectedShippingOptionId: string | null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The empty cart slice, used when there is no active cart. */
|
|
91
|
+
export const EMPTY_CART_STATE: CartStatePatch = {
|
|
92
|
+
cart: [],
|
|
93
|
+
cartTotals: null,
|
|
94
|
+
cartId: null,
|
|
95
|
+
cartQuantity: 0,
|
|
96
|
+
cartCompleted: false,
|
|
97
|
+
cartSelectedShippingOptionId: null,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Result of a completion attempt. `unresolved` must never be blindly retried. */
|
|
101
|
+
export type PlaceOrderResult =
|
|
102
|
+
| { status: 'order'; order: StorefrontOrder; lines: StorefrontOrderLineView[] }
|
|
103
|
+
| { status: 'error'; message: string; code?: string }
|
|
104
|
+
| { status: 'cart-error'; message: string; code?: string }
|
|
105
|
+
| { status: 'unresolved'; message: string; code?: string }
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Mapping helpers (pure; no I/O)
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
/** Normalize an optional image URL: absent/blank is omitted, never `""`. */
|
|
112
|
+
export function normalizeImageUrl(url: string | null | undefined): string | undefined {
|
|
113
|
+
if (typeof url !== 'string') return undefined
|
|
114
|
+
const trimmed = url.trim()
|
|
115
|
+
return trimmed === '' ? undefined : trimmed
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function lineVariantLabel(line: Cart['lines'][number]): string | undefined {
|
|
119
|
+
const options = line.merchandise.selectedOptions
|
|
120
|
+
.map((option) => option.value)
|
|
121
|
+
.filter((value) => value.trim() !== '')
|
|
122
|
+
if (options.length > 0) return options.join(' / ')
|
|
123
|
+
return line.merchandise.title.trim() === '' ? undefined : line.merchandise.title
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A stable, unique line id. The backend line id is authoritative; when a
|
|
128
|
+
* provider omits it, a deterministic composite keeps list keys unique without
|
|
129
|
+
* pretending the synthetic id is a valid mutation identity.
|
|
130
|
+
*/
|
|
131
|
+
export function stableLineId(line: Cart['lines'][number], index: number): string {
|
|
132
|
+
if (typeof line.id === 'string' && line.id.trim() !== '') return line.id
|
|
133
|
+
return `${line.merchandise.id}::${index}`
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function toCartLine(line: Cart['lines'][number], index: number): CartLineState {
|
|
137
|
+
return {
|
|
138
|
+
id: stableLineId(line, index),
|
|
139
|
+
merchandiseId: line.merchandise.id,
|
|
140
|
+
name: line.merchandise.product.title || line.merchandise.title,
|
|
141
|
+
variant: lineVariantLabel(line),
|
|
142
|
+
quantity: line.quantity,
|
|
143
|
+
lineTotal: line.cost.totalAmount,
|
|
144
|
+
// The backend unit price is carried through when supplied (including a
|
|
145
|
+
// legitimate zero). It is never derived from `total / quantity`; when the
|
|
146
|
+
// backend omits it, the field is omitted so the UI shows an unavailable
|
|
147
|
+
// unit price rather than an invented one.
|
|
148
|
+
unitPrice: line.cost.unitPrice,
|
|
149
|
+
image: normalizeImageUrl(line.merchandise.product.featuredImage?.url),
|
|
150
|
+
handle: line.merchandise.product.handle || undefined,
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Map the canonical cart to the host state slice. Totals come from the backend
|
|
156
|
+
* cart; only the fields the backend supplies are set, the rest are omitted.
|
|
157
|
+
*/
|
|
158
|
+
export function toCartState(cart: Cart): CartStatePatch {
|
|
159
|
+
// The backend may expose a zero shipping amount as a placeholder before a
|
|
160
|
+
// method is selected. Showing it would render a false "free shipping"; omit
|
|
161
|
+
// shipping from the breakdown until the cart actually has a selected method.
|
|
162
|
+
const hasSelectedShipping = Boolean(
|
|
163
|
+
cart.selectedShippingMethodId || cart.selectedShippingOptionId,
|
|
164
|
+
)
|
|
165
|
+
return {
|
|
166
|
+
cart: cart.lines.map(toCartLine),
|
|
167
|
+
cartTotals: {
|
|
168
|
+
subtotal: cart.cost.subtotalAmount,
|
|
169
|
+
total: cart.cost.totalAmount,
|
|
170
|
+
tax: cart.cost.totalTaxAmount,
|
|
171
|
+
discount: cart.cost.discountAmount,
|
|
172
|
+
shipping: hasSelectedShipping ? cart.cost.shippingAmount : undefined,
|
|
173
|
+
},
|
|
174
|
+
cartId: cart.id ?? null,
|
|
175
|
+
cartQuantity: cart.totalQuantity,
|
|
176
|
+
cartCompleted: Boolean(cart.completedAt),
|
|
177
|
+
// The UI selects/highlights by the shipping OPTION id (`so_`). The applied
|
|
178
|
+
// shipping-method record id (`selectedShippingMethodId`, `sm_`) is the
|
|
179
|
+
// runtime's payment proof and is never exposed to the selection UI.
|
|
180
|
+
cartSelectedShippingOptionId: cart.selectedShippingOptionId ?? null,
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function toStorefrontOrder(order: Order): StorefrontOrder {
|
|
185
|
+
return {
|
|
186
|
+
number: order.orderNumber,
|
|
187
|
+
placedAt: order.createdAt,
|
|
188
|
+
email: order.email,
|
|
189
|
+
shipping: order.shippingAddress
|
|
190
|
+
? {
|
|
191
|
+
name: `${order.shippingAddress.firstName} ${order.shippingAddress.lastName}`.trim(),
|
|
192
|
+
lines: [order.shippingAddress.address1, order.shippingAddress.city, order.shippingAddress.countryCode]
|
|
193
|
+
.filter((value): value is string => typeof value === 'string' && value.trim() !== ''),
|
|
194
|
+
}
|
|
195
|
+
: undefined,
|
|
196
|
+
totals: {
|
|
197
|
+
subtotal: order.subtotal,
|
|
198
|
+
discount: order.discount,
|
|
199
|
+
tax: order.tax,
|
|
200
|
+
shipping: order.shipping,
|
|
201
|
+
total: order.total,
|
|
202
|
+
},
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function toOrderLines(order: Order): StorefrontOrderLineView[] {
|
|
207
|
+
return order.items.map((item) => ({
|
|
208
|
+
id: item.id,
|
|
209
|
+
name: item.title,
|
|
210
|
+
variant: item.variantTitle,
|
|
211
|
+
quantity: item.quantity,
|
|
212
|
+
lineTotal: item.total,
|
|
213
|
+
unitPrice: item.price,
|
|
214
|
+
image: normalizeImageUrl(item.image?.url),
|
|
215
|
+
}))
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function toCheckoutShippingOptions(methods: ShippingMethod[]): CheckoutShippingOption[] {
|
|
219
|
+
return methods.map((method) => ({
|
|
220
|
+
id: method.id,
|
|
221
|
+
name: method.name,
|
|
222
|
+
price: method.price,
|
|
223
|
+
description: method.description,
|
|
224
|
+
estimatedDays: method.estimatedDays,
|
|
225
|
+
}))
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Group the runtime's structured field errors into the block's error shape. */
|
|
229
|
+
export function toCheckoutFieldErrors(errors: CheckoutFieldError[]): CheckoutFieldErrors {
|
|
230
|
+
const grouped: CheckoutFieldErrors = {}
|
|
231
|
+
for (const error of errors) {
|
|
232
|
+
grouped[error.field] = [...(grouped[error.field] ?? []), { message: error.message }]
|
|
233
|
+
}
|
|
234
|
+
return grouped
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Map the runtime's discriminated completion to the host result union. */
|
|
238
|
+
export function toPlaceOrderResult(result: CartCompletionResult): PlaceOrderResult {
|
|
239
|
+
if (result.type === 'order') {
|
|
240
|
+
return { status: 'order', order: toStorefrontOrder(result.order), lines: toOrderLines(result.order) }
|
|
241
|
+
}
|
|
242
|
+
if (result.type === 'cart') {
|
|
243
|
+
return { status: 'cart-error', message: result.error.message, code: result.error.code }
|
|
244
|
+
}
|
|
245
|
+
return { status: 'unresolved', message: result.error.message, code: result.error.code }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// Reads
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
export interface CatalogDetailResult {
|
|
253
|
+
detail: CatalogProductDetail | undefined
|
|
254
|
+
source: StoreDataSource
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Directed product-detail lookup by handle, priced in the store context region. */
|
|
258
|
+
export async function loadProductDetail(handle: string): Promise<CatalogDetailResult> {
|
|
259
|
+
const context = getStoreContext()
|
|
260
|
+
const source = getDataSource()
|
|
261
|
+
assertBackendConfigured(source)
|
|
262
|
+
const detail = await getProductDetailByHandle(handle, context)
|
|
263
|
+
return { detail, source }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** The context-scoped backend cart projected for the UI, or undefined when absent. */
|
|
267
|
+
export async function readCart(
|
|
268
|
+
cartId: string,
|
|
269
|
+
context: StoreContext = getStoreContext(),
|
|
270
|
+
): Promise<CartStatePatch | undefined> {
|
|
271
|
+
assertBackendConfigured()
|
|
272
|
+
const cart = await getCartForContext(cartId, context)
|
|
273
|
+
return cart ? toCartState(cart) : undefined
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export async function loadCheckoutCountries(
|
|
277
|
+
cartId: string,
|
|
278
|
+
context: StoreContext = getStoreContext(),
|
|
279
|
+
): Promise<RegionCountry[]> {
|
|
280
|
+
assertBackendConfigured()
|
|
281
|
+
return getCheckoutCountries(cartId, context)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export async function loadShippingMethods(
|
|
285
|
+
cartId: string,
|
|
286
|
+
context: StoreContext = getStoreContext(),
|
|
287
|
+
): Promise<ShippingMethod[]> {
|
|
288
|
+
assertBackendConfigured()
|
|
289
|
+
return getEligibleShippingMethods(cartId, context)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Label the approved manual TEST provider explicitly as a test provider rather
|
|
294
|
+
* than letting a legacy adapter name (for example "Cash on Delivery") imply a
|
|
295
|
+
* delivery/COD guarantee. Other methods pass through unchanged.
|
|
296
|
+
*/
|
|
297
|
+
export function toDisplayPaymentMethods(methods: PaymentMethod[]): PaymentMethod[] {
|
|
298
|
+
return methods.map((method) =>
|
|
299
|
+
method.id === APPROVED_MANUAL_TEST_PROVIDER_ID
|
|
300
|
+
? {
|
|
301
|
+
...method,
|
|
302
|
+
name: 'Pago manual TEST',
|
|
303
|
+
description: 'Proveedor manual de prueba; no cobra fondos reales.',
|
|
304
|
+
}
|
|
305
|
+
: method,
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export async function loadPaymentMethods(
|
|
310
|
+
context: StoreContext = getStoreContext(),
|
|
311
|
+
): Promise<PaymentMethod[]> {
|
|
312
|
+
assertBackendConfigured()
|
|
313
|
+
const methods = await getPaymentMethodsByRegion(context.regionId)
|
|
314
|
+
return toDisplayPaymentMethods(methods)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
// Cart mutations (context-scoped)
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
/** Create a fresh backend guest cart in the store context region. */
|
|
322
|
+
export async function createGuestCart(
|
|
323
|
+
context: StoreContext = getStoreContext(),
|
|
324
|
+
): Promise<CartStatePatch> {
|
|
325
|
+
assertBackendConfigured()
|
|
326
|
+
const cart = await createCartForContext(context)
|
|
327
|
+
return toCartState(cart)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Add the explicitly selected backend variant at its requested quantity. */
|
|
331
|
+
export async function addVariant(
|
|
332
|
+
cartId: string,
|
|
333
|
+
variantId: string,
|
|
334
|
+
quantity: number,
|
|
335
|
+
context: StoreContext = getStoreContext(),
|
|
336
|
+
): Promise<CartStatePatch> {
|
|
337
|
+
assertBackendConfigured()
|
|
338
|
+
const cart = await addCartLines(cartId, [{ merchandiseId: variantId, quantity }], context)
|
|
339
|
+
return toCartState(cart)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Set a line to an ABSOLUTE quantity by its backend line-item id. */
|
|
343
|
+
export async function setLineQuantity(
|
|
344
|
+
cartId: string,
|
|
345
|
+
lineId: string,
|
|
346
|
+
merchandiseId: string,
|
|
347
|
+
quantity: number,
|
|
348
|
+
context: StoreContext = getStoreContext(),
|
|
349
|
+
): Promise<CartStatePatch> {
|
|
350
|
+
assertBackendConfigured()
|
|
351
|
+
const cart = await updateCartLines(cartId, [{ id: lineId, merchandiseId, quantity }], context)
|
|
352
|
+
return toCartState(cart)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Apply a relative DELTA by normalizing it against the authoritative line. */
|
|
356
|
+
export async function applyLineDelta(
|
|
357
|
+
cartId: string,
|
|
358
|
+
lineId: string,
|
|
359
|
+
delta: number,
|
|
360
|
+
context: StoreContext = getStoreContext(),
|
|
361
|
+
): Promise<CartStatePatch> {
|
|
362
|
+
assertBackendConfigured()
|
|
363
|
+
const current = await getCartForContext(cartId, context)
|
|
364
|
+
if (!current) throw new Error(`Cart not found: ${cartId}`)
|
|
365
|
+
const update = toAbsoluteLineUpdate(current, lineId, delta)
|
|
366
|
+
const cart = await updateCartLines(cartId, [update], context)
|
|
367
|
+
return toCartState(cart)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Remove a line by its backend line-item id. */
|
|
371
|
+
export async function removeLine(
|
|
372
|
+
cartId: string,
|
|
373
|
+
lineId: string,
|
|
374
|
+
context: StoreContext = getStoreContext(),
|
|
375
|
+
): Promise<CartStatePatch> {
|
|
376
|
+
assertBackendConfigured()
|
|
377
|
+
const cart = await removeCartLines(cartId, [lineId], context)
|
|
378
|
+
return toCartState(cart)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
// Checkout mutations (context-scoped)
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
/** The validated checkout address shape (runtime contract). */
|
|
386
|
+
export type AddressInput = CheckoutAddress
|
|
387
|
+
|
|
388
|
+
/** Validate an untrusted address against the cart region's eligible countries. */
|
|
389
|
+
export function validateAddress(
|
|
390
|
+
input: unknown,
|
|
391
|
+
countries: RegionCountry[],
|
|
392
|
+
): { success: true; address: CheckoutAddress } | { success: false; errors: CheckoutFieldError[] } {
|
|
393
|
+
return validateCheckoutAddressForRegion(input, countries)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** True when the provider simply does not persist an address. */
|
|
397
|
+
function isUnsupportedAddressUpdate(error: unknown): boolean {
|
|
398
|
+
return error instanceof Error && /does not support updateShippingAddress/i.test(error.message)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Persist the cart's address when the provider supports it; the backend then
|
|
403
|
+
* recalculates totals on the next read. A provider that does not persist an
|
|
404
|
+
* address (for example the mock fixture) is tolerated: the address has already
|
|
405
|
+
* been validated and shipping eligibility is resolved from the cart region.
|
|
406
|
+
*/
|
|
407
|
+
export async function saveShippingAddress(
|
|
408
|
+
cartId: string,
|
|
409
|
+
address: AddressInput,
|
|
410
|
+
context: StoreContext = getStoreContext(),
|
|
411
|
+
): Promise<CartStatePatch> {
|
|
412
|
+
assertBackendConfigured()
|
|
413
|
+
const current = await getCartForContext(cartId, context)
|
|
414
|
+
if (!current) throw new Error(`Cart not found before address update: ${cartId}`)
|
|
415
|
+
try {
|
|
416
|
+
// The runtime address validator treats province/phone as optional; the
|
|
417
|
+
// address-setter input still declares them, so pass an empty string when the
|
|
418
|
+
// shopper did not supply one rather than failing the type contract.
|
|
419
|
+
await updateShippingAddress(cartId, {
|
|
420
|
+
email: address.email,
|
|
421
|
+
firstName: address.firstName,
|
|
422
|
+
lastName: address.lastName,
|
|
423
|
+
address1: address.address1,
|
|
424
|
+
address2: address.address2,
|
|
425
|
+
company: address.company,
|
|
426
|
+
city: address.city,
|
|
427
|
+
province: address.province ?? '',
|
|
428
|
+
postalCode: address.postalCode,
|
|
429
|
+
countryCode: address.countryCode,
|
|
430
|
+
phone: address.phone ?? '',
|
|
431
|
+
})
|
|
432
|
+
} catch (error) {
|
|
433
|
+
if (isUnsupportedAddressUpdate(error)) return toCartState(current)
|
|
434
|
+
throw error
|
|
435
|
+
}
|
|
436
|
+
const cart = await getCartForContext(cartId, context)
|
|
437
|
+
return toCartState(cart ?? current)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Apply an eligible shipping method and return the recalculated cart. */
|
|
441
|
+
export async function selectShipping(
|
|
442
|
+
cartId: string,
|
|
443
|
+
methodId: string,
|
|
444
|
+
context: StoreContext = getStoreContext(),
|
|
445
|
+
): Promise<CartStatePatch> {
|
|
446
|
+
assertBackendConfigured()
|
|
447
|
+
const cart = await selectShippingMethod(cartId, methodId, context)
|
|
448
|
+
return toCartState(cart)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Apply the single approved manual TEST payment provider (requires shipping). */
|
|
452
|
+
export async function applyPayment(
|
|
453
|
+
cartId: string,
|
|
454
|
+
context: StoreContext = getStoreContext(),
|
|
455
|
+
): Promise<void> {
|
|
456
|
+
assertBackendConfigured()
|
|
457
|
+
await applyManualTestPayment(cartId, context)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Complete the cart exactly once, preserving the native discriminator. */
|
|
461
|
+
export async function completeOrder(
|
|
462
|
+
cartId: string,
|
|
463
|
+
context: StoreContext = getStoreContext(),
|
|
464
|
+
): Promise<PlaceOrderResult> {
|
|
465
|
+
assertBackendConfigured()
|
|
466
|
+
return toPlaceOrderResult(await completeCart(cartId, context))
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Reconcile a committed cart into its order. The caller MUST have authorized
|
|
471
|
+
* this cart through the verified session cookie; this never accepts a bare
|
|
472
|
+
* order/cart id as authorization. Reads `completedAt` first and completes once
|
|
473
|
+
* only when the backend committed.
|
|
474
|
+
*/
|
|
475
|
+
export async function reconcileOrder(
|
|
476
|
+
cartId: string,
|
|
477
|
+
context: StoreContext = getStoreContext(),
|
|
478
|
+
): Promise<PlaceOrderResult> {
|
|
479
|
+
assertBackendConfigured()
|
|
480
|
+
return toPlaceOrderResult(await reconcileCart(cartId, context))
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ---------------------------------------------------------------------------
|
|
484
|
+
// Customer auth boundary (change: storefront-customer-auth, task 2.2)
|
|
485
|
+
// ---------------------------------------------------------------------------
|
|
486
|
+
//
|
|
487
|
+
// The host never talks to the backend SDK directly. These helpers delegate to
|
|
488
|
+
// `@thorprovider/runtime/commerce`, which builds a request-scoped provider
|
|
489
|
+
// carrying exactly the caller's token. No token is logged or returned to the
|
|
490
|
+
// client; the runtime outcome is passed through unchanged.
|
|
491
|
+
|
|
492
|
+
/** Authenticate a shopper; the caller persists the returned token in its cookie. */
|
|
493
|
+
export async function authenticate(credentials: LoginCredentials): Promise<AuthOutcome> {
|
|
494
|
+
return loginCustomer(credentials)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Register a shopper; the caller persists the returned token in its cookie. */
|
|
498
|
+
export async function registerShopper(data: RegisterData): Promise<AuthOutcome> {
|
|
499
|
+
return registerCustomer(data)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Best-effort backend logout; the authoritative clear is the host cookie. */
|
|
503
|
+
export async function endSession(): Promise<void> {
|
|
504
|
+
return logoutCustomer()
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Resolve the current customer for a request token, or null when absent. */
|
|
508
|
+
export async function loadCustomer(token: string | null): Promise<Customer | null> {
|
|
509
|
+
return getCurrentCustomer(token)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// ---------------------------------------------------------------------------
|
|
513
|
+
// Customer orders boundary (change: storefront-customer-orders, task 2.1)
|
|
514
|
+
// ---------------------------------------------------------------------------
|
|
515
|
+
//
|
|
516
|
+
// The host never talks to the backend SDK directly. `getCustomerOrders`
|
|
517
|
+
// delegates to `@thorprovider/runtime/commerce`, which builds a request-scoped
|
|
518
|
+
// provider carrying exactly the caller's token. The boundary maps the backend
|
|
519
|
+
// orders to a serializable display shape: Money is formatted from the order's
|
|
520
|
+
// own `currencyCode` (never hardcoded, never divided by 100) and a field the
|
|
521
|
+
// backend omits is omitted, never invented.
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Format backend Money for display from its own currency, or undefined when the
|
|
525
|
+
* amount is absent/blank/non-finite. The decimal-string amount is already in
|
|
526
|
+
* major units, so it is never divided by 100.
|
|
527
|
+
*/
|
|
528
|
+
function formatMoney(money?: Money): string | undefined {
|
|
529
|
+
if (!money) return undefined
|
|
530
|
+
const amount = money.amount
|
|
531
|
+
if (typeof amount !== 'string' || amount.trim() === '') return undefined
|
|
532
|
+
const value = Number(amount)
|
|
533
|
+
if (!Number.isFinite(value)) return undefined
|
|
534
|
+
return new Intl.NumberFormat(undefined, {
|
|
535
|
+
style: 'currency',
|
|
536
|
+
currency: money.currencyCode,
|
|
537
|
+
}).format(value)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Format an ISO timestamp as a locale date, or return the raw value unchanged. */
|
|
541
|
+
function formatOrderDate(value: string): string {
|
|
542
|
+
const parsed = new Date(value)
|
|
543
|
+
if (Number.isNaN(parsed.getTime())) return value
|
|
544
|
+
return parsed.toLocaleDateString()
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Join the present address parts, filtering blanks; undefined when none apply. */
|
|
548
|
+
function formatAddress(address: Order['shippingAddress']): string | undefined {
|
|
549
|
+
if (!address) return undefined
|
|
550
|
+
const name = `${address.firstName ?? ''} ${address.lastName ?? ''}`.trim()
|
|
551
|
+
const parts = [name, address.address1, address.city, address.countryCode].filter(
|
|
552
|
+
(value): value is string => typeof value === 'string' && value.trim() !== '',
|
|
553
|
+
)
|
|
554
|
+
if (parts.length === 0) return undefined
|
|
555
|
+
return parts.join(', ')
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** Map one backend order line to its display view, omitting absent optional fields. */
|
|
559
|
+
export function toAccountOrderItemView(item: Order['items'][number]): AccountOrderItemView {
|
|
560
|
+
const view: AccountOrderItemView = {
|
|
561
|
+
id: item.id,
|
|
562
|
+
name: item.title,
|
|
563
|
+
quantity: item.quantity,
|
|
564
|
+
lineTotal: formatMoney(item.total) ?? '',
|
|
565
|
+
}
|
|
566
|
+
if (typeof item.variantTitle === 'string' && item.variantTitle.trim() !== '') {
|
|
567
|
+
view.variant = item.variantTitle
|
|
568
|
+
}
|
|
569
|
+
const image = normalizeImageUrl(item.image?.url)
|
|
570
|
+
if (image !== undefined) view.image = image
|
|
571
|
+
return view
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** Map a backend order to the account view row, omitting absent optional fields. */
|
|
575
|
+
export function toAccountOrderView(order: Order): AccountOrderView {
|
|
576
|
+
const view: AccountOrderView = {
|
|
577
|
+
id: order.id,
|
|
578
|
+
number: order.orderNumber,
|
|
579
|
+
date: formatOrderDate(order.createdAt),
|
|
580
|
+
status: order.status,
|
|
581
|
+
total: formatMoney(order.total) ?? '',
|
|
582
|
+
items: order.items.map(toAccountOrderItemView),
|
|
583
|
+
}
|
|
584
|
+
const subtotal = formatMoney(order.subtotal)
|
|
585
|
+
if (subtotal !== undefined) view.subtotal = subtotal
|
|
586
|
+
const tax = formatMoney(order.tax)
|
|
587
|
+
if (tax !== undefined) view.tax = tax
|
|
588
|
+
const shipping = formatMoney(order.shipping)
|
|
589
|
+
if (shipping !== undefined) view.shipping = shipping
|
|
590
|
+
const address = formatAddress(order.shippingAddress)
|
|
591
|
+
if (address !== undefined) view.address = address
|
|
592
|
+
return view
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Load the authenticated customer's order history for a request token, mapped
|
|
597
|
+
* to the account view rows. A missing token yields an empty list (the runtime
|
|
598
|
+
* builds no provider); it never fabricates an order.
|
|
599
|
+
*/
|
|
600
|
+
export async function loadOrders(
|
|
601
|
+
token: string | null,
|
|
602
|
+
context: StoreContext = getStoreContext(),
|
|
603
|
+
): Promise<AccountOrderView[]> {
|
|
604
|
+
const orders = await getCustomerOrders(token, { salesChannelId: context.salesChannelId })
|
|
605
|
+
return orders.map(toAccountOrderView)
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
export type { Money }
|