@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,542 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
// Generated by Thor Commerce storefront (template: assets/templates/app/lib/__STOREFRONT__/actions.ts)
|
|
4
|
+
//
|
|
5
|
+
// Host Server Actions — the only place the generated storefront mutates the
|
|
6
|
+
// backend cart and the session cookie. Next.js 16 `cookies()` may only be
|
|
7
|
+
// written from a Server Action or Route Handler, so the guest cart identity and
|
|
8
|
+
// the completed-session cookie are created and updated here, never during a
|
|
9
|
+
// render.
|
|
10
|
+
//
|
|
11
|
+
// Every action resolves and validates the store context before the commerce
|
|
12
|
+
// call. Cart mutations use the backend variant id for adds and the backend
|
|
13
|
+
// line-item id for updates/removals; the Navbar's DELTA is normalized against
|
|
14
|
+
// the authoritative cart line quantity. Completion is discriminated and never
|
|
15
|
+
// blindly retried; reconciliation uses the active cart id when no completed
|
|
16
|
+
// receipt cookie exists yet.
|
|
17
|
+
|
|
18
|
+
import type { AccountOrderView, CheckoutFieldErrors, CheckoutShippingOption } from './state.core'
|
|
19
|
+
import type { Customer, PaymentMethod } from '@thorprovider/types'
|
|
20
|
+
import {
|
|
21
|
+
EMPTY_CART_STATE,
|
|
22
|
+
addVariant,
|
|
23
|
+
applyLineDelta,
|
|
24
|
+
applyPayment,
|
|
25
|
+
authenticate,
|
|
26
|
+
completeOrder,
|
|
27
|
+
createGuestCart,
|
|
28
|
+
endSession,
|
|
29
|
+
loadCheckoutCountries,
|
|
30
|
+
loadCustomer,
|
|
31
|
+
loadOrders,
|
|
32
|
+
loadPaymentMethods,
|
|
33
|
+
loadShippingMethods,
|
|
34
|
+
readCart,
|
|
35
|
+
reconcileOrder,
|
|
36
|
+
registerShopper,
|
|
37
|
+
removeLine,
|
|
38
|
+
saveShippingAddress,
|
|
39
|
+
selectShipping,
|
|
40
|
+
setLineQuantity,
|
|
41
|
+
toCheckoutFieldErrors,
|
|
42
|
+
toCheckoutShippingOptions,
|
|
43
|
+
validateAddress,
|
|
44
|
+
type AddressInput,
|
|
45
|
+
type CartStatePatch,
|
|
46
|
+
type PlaceOrderResult,
|
|
47
|
+
} from './commerce'
|
|
48
|
+
import { getStoreContext } from './store-context'
|
|
49
|
+
import {
|
|
50
|
+
bindHostSessionCart,
|
|
51
|
+
clearAuthToken,
|
|
52
|
+
completeHostSessionCart,
|
|
53
|
+
readAuthToken,
|
|
54
|
+
readHostSession,
|
|
55
|
+
writeAuthToken,
|
|
56
|
+
} from './session'
|
|
57
|
+
|
|
58
|
+
// A `'use server'` module may export ONLY async functions. Next/Turbopack's
|
|
59
|
+
// server-actions transform registers every export as a runtime server
|
|
60
|
+
// reference, so a type export (even `export type`) is emitted as a value and
|
|
61
|
+
// crashes at runtime (`X is not defined`). The result types below are local;
|
|
62
|
+
// the shared shapes are imported from `./commerce` and never re-exported here.
|
|
63
|
+
|
|
64
|
+
/** Structured result for a cart-mutating action. */
|
|
65
|
+
type CartActionResult =
|
|
66
|
+
| { ok: true; cart: CartStatePatch }
|
|
67
|
+
| { ok: false; error: string }
|
|
68
|
+
|
|
69
|
+
/** Structured result for a checkout action. */
|
|
70
|
+
type CheckoutActionResult =
|
|
71
|
+
| {
|
|
72
|
+
ok: true
|
|
73
|
+
cart: CartStatePatch
|
|
74
|
+
countries?: { value: string; label: string }[]
|
|
75
|
+
paymentMethods?: PaymentMethod[]
|
|
76
|
+
shippingOptions?: CheckoutShippingOption[]
|
|
77
|
+
fieldErrors?: CheckoutFieldErrors
|
|
78
|
+
}
|
|
79
|
+
| { ok: false; error: string; fieldErrors?: CheckoutFieldErrors }
|
|
80
|
+
|
|
81
|
+
/** Structured result for an auth action that may carry the current customer. */
|
|
82
|
+
type AuthActionResult =
|
|
83
|
+
| { ok: true; customer: Customer | null }
|
|
84
|
+
| { ok: false; error: string }
|
|
85
|
+
|
|
86
|
+
/** Structured result for logout; the authoritative cookie clear is the outcome. */
|
|
87
|
+
type LogoutActionResult = { ok: true } | { ok: false; error: string }
|
|
88
|
+
|
|
89
|
+
/** Structured result for the customer order-history load. */
|
|
90
|
+
type OrdersActionResult =
|
|
91
|
+
| { ok: true; orders: AccountOrderView[] }
|
|
92
|
+
| { ok: false; error: string }
|
|
93
|
+
|
|
94
|
+
/** Basic email shape check; a boundary guard, not a full RFC validation. */
|
|
95
|
+
const EMAIL_PATTERN = /\S+@\S+\.\S+/
|
|
96
|
+
|
|
97
|
+
/** Log a non-secret context line for a caught server error (never a secret). */
|
|
98
|
+
function logServerError(scope: string, error: unknown): void {
|
|
99
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
100
|
+
const code =
|
|
101
|
+
error && typeof error === 'object' && 'code' in error && typeof (error as { code?: unknown }).code === 'string'
|
|
102
|
+
? (error as { code: string }).code
|
|
103
|
+
: undefined
|
|
104
|
+
console.error(`[storefront/actions] ${scope} failed`, { message, code })
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function toError(error: unknown, fallback: string): string {
|
|
108
|
+
return error instanceof Error && error.message.trim() !== '' ? error.message : fallback
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Resolve the active cart id, creating and binding a new backend cart when the
|
|
113
|
+
* session has none or its cart was completed. A new cart after a completed one
|
|
114
|
+
* keeps the SAME shopper and preserves the prior receipt cart id, so the
|
|
115
|
+
* receipt proof cookie is never lost.
|
|
116
|
+
*/
|
|
117
|
+
async function ensureActiveCartId(): Promise<{ cartId: string; context: ReturnType<typeof getStoreContext> }> {
|
|
118
|
+
const context = getStoreContext()
|
|
119
|
+
const session = await readHostSession(context)
|
|
120
|
+
if (session?.cartId) {
|
|
121
|
+
const existing = await readCart(session.cartId, context)
|
|
122
|
+
if (existing && !existing.cartCompleted) return { cartId: session.cartId, context }
|
|
123
|
+
}
|
|
124
|
+
const created = await createGuestCart(context)
|
|
125
|
+
if (!created.cartId) throw new Error('Cart creation returned no id')
|
|
126
|
+
await bindHostSessionCart(context, created.cartId)
|
|
127
|
+
return { cartId: created.cartId, context }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Ensure a guest cart exists and return its authoritative snapshot. */
|
|
131
|
+
export async function ensureCartAction(): Promise<CartActionResult> {
|
|
132
|
+
try {
|
|
133
|
+
const { cartId, context } = await ensureActiveCartId()
|
|
134
|
+
return { ok: true, cart: (await readCart(cartId, context)) ?? EMPTY_CART_STATE }
|
|
135
|
+
} catch (error) {
|
|
136
|
+
return { ok: false, error: toError(error, 'No se pudo iniciar el carrito.') }
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Add the explicitly selected backend variant id at the requested quantity. */
|
|
141
|
+
export async function addToCartAction(input: {
|
|
142
|
+
variantId: string
|
|
143
|
+
quantity: number
|
|
144
|
+
}): Promise<CartActionResult> {
|
|
145
|
+
try {
|
|
146
|
+
if (!input?.variantId) return { ok: false, error: 'Selecciona una variante antes de agregar.' }
|
|
147
|
+
const quantity = Number.isFinite(input.quantity) && input.quantity > 0 ? Math.floor(input.quantity) : 1
|
|
148
|
+
const { cartId, context } = await ensureActiveCartId()
|
|
149
|
+
return { ok: true, cart: await addVariant(cartId, input.variantId, quantity, context) }
|
|
150
|
+
} catch (error) {
|
|
151
|
+
return { ok: false, error: toError(error, 'No se pudo agregar el producto.') }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Set a backend cart line to an ABSOLUTE quantity by its line-item id. */
|
|
156
|
+
export async function setLineQuantityAction(input: {
|
|
157
|
+
lineId: string
|
|
158
|
+
merchandiseId: string
|
|
159
|
+
quantity: number
|
|
160
|
+
}): Promise<CartActionResult> {
|
|
161
|
+
try {
|
|
162
|
+
const quantity = Math.max(1, Math.floor(input.quantity))
|
|
163
|
+
const { cartId, context } = await ensureActiveCartId()
|
|
164
|
+
return {
|
|
165
|
+
ok: true,
|
|
166
|
+
cart: await setLineQuantity(cartId, input.lineId, input.merchandiseId, quantity, context),
|
|
167
|
+
}
|
|
168
|
+
} catch (error) {
|
|
169
|
+
return { ok: false, error: toError(error, 'No se pudo actualizar la cantidad.') }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Apply a quantity DELTA from the Navbar mini-cart. The delta is normalized
|
|
175
|
+
* against the authoritative current line quantity (the cart page emits an
|
|
176
|
+
* absolute quantity and uses `setLineQuantityAction`).
|
|
177
|
+
*/
|
|
178
|
+
export async function applyQuantityDeltaAction(input: {
|
|
179
|
+
lineId: string
|
|
180
|
+
delta: number
|
|
181
|
+
}): Promise<CartActionResult> {
|
|
182
|
+
try {
|
|
183
|
+
const { cartId, context } = await ensureActiveCartId()
|
|
184
|
+
return { ok: true, cart: await applyLineDelta(cartId, input.lineId, input.delta, context) }
|
|
185
|
+
} catch (error) {
|
|
186
|
+
return { ok: false, error: toError(error, 'No se pudo actualizar la cantidad.') }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Remove a backend cart line by its line-item id. */
|
|
191
|
+
export async function removeLineAction(input: { lineId: string }): Promise<CartActionResult> {
|
|
192
|
+
try {
|
|
193
|
+
const { cartId, context } = await ensureActiveCartId()
|
|
194
|
+
return { ok: true, cart: await removeLine(cartId, input.lineId, context) }
|
|
195
|
+
} catch (error) {
|
|
196
|
+
return { ok: false, error: toError(error, 'No se pudo quitar el producto.') }
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Read the current cart snapshot (no creation). */
|
|
201
|
+
export async function loadCartStateAction(): Promise<CartStatePatch> {
|
|
202
|
+
try {
|
|
203
|
+
const context = getStoreContext()
|
|
204
|
+
const session = await readHostSession(context)
|
|
205
|
+
if (!session?.cartId) return EMPTY_CART_STATE
|
|
206
|
+
return (await readCart(session.cartId, context)) ?? EMPTY_CART_STATE
|
|
207
|
+
} catch {
|
|
208
|
+
return EMPTY_CART_STATE
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function toAddressInput(values: Record<string, unknown>): AddressInput {
|
|
213
|
+
const str = (key: string) => (typeof values[key] === 'string' ? (values[key] as string) : '')
|
|
214
|
+
return {
|
|
215
|
+
email: str('email'),
|
|
216
|
+
firstName: str('firstName'),
|
|
217
|
+
lastName: str('lastName'),
|
|
218
|
+
address1: str('address'),
|
|
219
|
+
city: str('city'),
|
|
220
|
+
province: str('state'),
|
|
221
|
+
postalCode: str('zipCode'),
|
|
222
|
+
countryCode: str('country'),
|
|
223
|
+
phone: str('phone'),
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Validate and persist the checkout address against the cart region's eligible
|
|
229
|
+
* countries, then load the eligible shipping options and recalculated totals.
|
|
230
|
+
* A malformed or ineligible address returns structured per-field errors and
|
|
231
|
+
* never advances.
|
|
232
|
+
*/
|
|
233
|
+
export async function syncCheckoutAddressAction(
|
|
234
|
+
values: Record<string, unknown>,
|
|
235
|
+
): Promise<CheckoutActionResult> {
|
|
236
|
+
try {
|
|
237
|
+
const { cartId, context } = await ensureActiveCartId()
|
|
238
|
+
const countries = await loadCheckoutCountries(cartId, context)
|
|
239
|
+
const validation = validateAddress(toAddressInput(values), countries)
|
|
240
|
+
if (!validation.success) {
|
|
241
|
+
return {
|
|
242
|
+
ok: false,
|
|
243
|
+
error: 'Revisa los datos de la dirección.',
|
|
244
|
+
fieldErrors: toCheckoutFieldErrors(validation.errors),
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const cart = await saveShippingAddress(cartId, validation.address, context)
|
|
248
|
+
const methods = await loadShippingMethods(cartId, context)
|
|
249
|
+
return { ok: true, cart, shippingOptions: toCheckoutShippingOptions(methods) }
|
|
250
|
+
} catch (error) {
|
|
251
|
+
return { ok: false, error: toError(error, 'No se pudo validar la dirección.') }
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Apply an eligible shipping method; the backend recalculates the totals. */
|
|
256
|
+
export async function selectShippingMethodAction(input: {
|
|
257
|
+
methodId: string
|
|
258
|
+
}): Promise<CheckoutActionResult> {
|
|
259
|
+
try {
|
|
260
|
+
const { cartId, context } = await ensureActiveCartId()
|
|
261
|
+
const cart = await selectShipping(cartId, input.methodId, context)
|
|
262
|
+
return { ok: true, cart }
|
|
263
|
+
} catch (error) {
|
|
264
|
+
return { ok: false, error: toError(error, 'No se pudo seleccionar el envío.') }
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Load the checkout data (countries, payment methods, cart) for the form. */
|
|
269
|
+
export async function loadCheckoutAction(): Promise<CheckoutActionResult> {
|
|
270
|
+
try {
|
|
271
|
+
const { cartId, context } = await ensureActiveCartId()
|
|
272
|
+
const [regionCountries, paymentMethods, methods, cart] = await Promise.all([
|
|
273
|
+
loadCheckoutCountries(cartId, context),
|
|
274
|
+
loadPaymentMethods(context),
|
|
275
|
+
loadShippingMethods(cartId, context),
|
|
276
|
+
readCart(cartId, context),
|
|
277
|
+
])
|
|
278
|
+
return {
|
|
279
|
+
ok: true,
|
|
280
|
+
cart: cart ?? EMPTY_CART_STATE,
|
|
281
|
+
countries: regionCountries.map((country) => ({ value: country.code, label: country.name })),
|
|
282
|
+
paymentMethods,
|
|
283
|
+
shippingOptions: toCheckoutShippingOptions(methods),
|
|
284
|
+
}
|
|
285
|
+
} catch (error) {
|
|
286
|
+
return { ok: false, error: toError(error, 'No se pudo cargar el checkout.') }
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Clear the active cart after a confirmed order. A cookie-write failure must
|
|
292
|
+
* NOT be reported as an ordinary failure — the order already exists — so it is
|
|
293
|
+
* logged (non-secret) and reported to the caller, which returns `unresolved`
|
|
294
|
+
* and leaves the committed cart reconcilable.
|
|
295
|
+
*/
|
|
296
|
+
async function clearConfirmedSession(
|
|
297
|
+
context: ReturnType<typeof getStoreContext>,
|
|
298
|
+
cartId: string,
|
|
299
|
+
): Promise<boolean> {
|
|
300
|
+
try {
|
|
301
|
+
await completeHostSessionCart(context, cartId)
|
|
302
|
+
return true
|
|
303
|
+
} catch (error) {
|
|
304
|
+
logServerError('completeHostSessionCart', error)
|
|
305
|
+
return false
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Place the order exactly once. The approved manual TEST payment is applied
|
|
311
|
+
* after shipping totals are final, then the cart is completed with its native
|
|
312
|
+
* discriminator. On a confirmed order the active cart is cleared and the
|
|
313
|
+
* receipt recorded. If the order committed but the session cookie could not be
|
|
314
|
+
* updated, the result is `unresolved` (reconcile the active committed cart) so
|
|
315
|
+
* the client never treats a committed order as a retryable failure.
|
|
316
|
+
*/
|
|
317
|
+
export async function placeOrderAction(): Promise<PlaceOrderResult> {
|
|
318
|
+
try {
|
|
319
|
+
const context = getStoreContext()
|
|
320
|
+
const session = await readHostSession(context)
|
|
321
|
+
if (!session?.cartId) {
|
|
322
|
+
return { status: 'error', message: 'No hay un carrito activo.' }
|
|
323
|
+
}
|
|
324
|
+
const cartId = session.cartId
|
|
325
|
+
try {
|
|
326
|
+
await applyPayment(cartId, context)
|
|
327
|
+
} catch (error) {
|
|
328
|
+
logServerError('applyPayment', error)
|
|
329
|
+
return { status: 'error', message: toError(error, 'No se pudo iniciar el pago.') }
|
|
330
|
+
}
|
|
331
|
+
const result = await completeOrder(cartId, context)
|
|
332
|
+
if (result.status !== 'order') return result
|
|
333
|
+
if (!(await clearConfirmedSession(context, cartId))) {
|
|
334
|
+
return {
|
|
335
|
+
status: 'unresolved',
|
|
336
|
+
message: 'El pedido se registró pero no se pudo actualizar la sesión. Reconcílialo.',
|
|
337
|
+
code: 'SESSION_CLEAR_FAILED',
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return result
|
|
341
|
+
} catch (error) {
|
|
342
|
+
// An unexpected failure during completion is ambiguous: never a blind retry.
|
|
343
|
+
logServerError('placeOrderAction', error)
|
|
344
|
+
return {
|
|
345
|
+
status: 'unresolved',
|
|
346
|
+
message: 'No se pudo confirmar el pedido. Reconcílialo antes de reintentar.',
|
|
347
|
+
code: 'COMPLETE_UNRESOLVED',
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Recover the receipt for a cart the verified cookie authorizes. A client
|
|
354
|
+
* order/cart id is never accepted. An ACTIVE cart the backend already committed
|
|
355
|
+
* is an unresolved completion and is reconciled first; otherwise the completed
|
|
356
|
+
* receipt cart carried by the cookie is reconciled. Read/network failures stay
|
|
357
|
+
* `unresolved` (reconcile-only) and are never a blind retry.
|
|
358
|
+
*/
|
|
359
|
+
export async function reconcileReceiptAction(): Promise<PlaceOrderResult> {
|
|
360
|
+
try {
|
|
361
|
+
const context = getStoreContext()
|
|
362
|
+
const session = await readHostSession(context)
|
|
363
|
+
if (!session) {
|
|
364
|
+
return { status: 'unresolved', message: 'No hay un comprobante autorizado para esta sesión.' }
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// An active cart is reconciled first: the runtime does a single GET and
|
|
368
|
+
// only POSTs complete when the backend committed. A cart the backend did
|
|
369
|
+
// NOT commit returns CART_NOT_COMPLETED, surfaced as a controlled
|
|
370
|
+
// (retryable) error instead of a generic unresolved that never resolves.
|
|
371
|
+
// The active cart is only cleared when it is the committed cart; a new cart
|
|
372
|
+
// started after an earlier order is preserved by the session core.
|
|
373
|
+
if (session.cartId) {
|
|
374
|
+
const result = await reconcileOrder(session.cartId, context)
|
|
375
|
+
if (result.status === 'order') {
|
|
376
|
+
await clearConfirmedSession(context, session.cartId)
|
|
377
|
+
return result
|
|
378
|
+
}
|
|
379
|
+
if (result.status === 'unresolved' && result.code === 'CART_NOT_COMPLETED') {
|
|
380
|
+
return { status: 'error', message: result.message, code: result.code }
|
|
381
|
+
}
|
|
382
|
+
return result
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const candidate = session.receiptCartId
|
|
386
|
+
if (!candidate) {
|
|
387
|
+
return { status: 'unresolved', message: 'No hay un comprobante autorizado para esta sesión.' }
|
|
388
|
+
}
|
|
389
|
+
const result = await reconcileOrder(candidate, context)
|
|
390
|
+
if (result.status === 'order') {
|
|
391
|
+
await clearConfirmedSession(context, candidate)
|
|
392
|
+
return result
|
|
393
|
+
}
|
|
394
|
+
// A reconcile that proves the backend never committed is definitive, not
|
|
395
|
+
// ambiguous: surface it as a controlled failure so the shopper may retry
|
|
396
|
+
// explicitly (never a blind retry of an ambiguous mutation).
|
|
397
|
+
if (result.status === 'unresolved' && result.code === 'CART_NOT_COMPLETED') {
|
|
398
|
+
return { status: 'error', message: result.message, code: result.code }
|
|
399
|
+
}
|
|
400
|
+
return result
|
|
401
|
+
} catch (error) {
|
|
402
|
+
logServerError('reconcileReceiptAction', error)
|
|
403
|
+
return {
|
|
404
|
+
status: 'unresolved',
|
|
405
|
+
message: 'No se pudo verificar el pedido. Reintenta la reconciliación.',
|
|
406
|
+
code: 'RECONCILE_UNRESOLVED',
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
// Customer auth actions (change: storefront-customer-auth, task 2.2)
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
//
|
|
415
|
+
// Auth actions touch ONLY the auth cookie: they never read or write the guest
|
|
416
|
+
// session cookie or the cart (guest continuity by construction, design D5).
|
|
417
|
+
// Input is validated before any backend call, and no token or password is ever
|
|
418
|
+
// logged or returned.
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Authenticate a shopper. Validates input first, then authenticates; a token
|
|
422
|
+
* from the backend is persisted in the httpOnly auth cookie. `requiresAction`
|
|
423
|
+
* is surfaced truthfully rather than faking a logged-in state.
|
|
424
|
+
*/
|
|
425
|
+
export async function loginAction(input: {
|
|
426
|
+
email: string
|
|
427
|
+
password: string
|
|
428
|
+
}): Promise<AuthActionResult> {
|
|
429
|
+
const email = typeof input?.email === 'string' ? input.email.trim() : ''
|
|
430
|
+
const password = typeof input?.password === 'string' ? input.password : ''
|
|
431
|
+
if (!EMAIL_PATTERN.test(email)) return { ok: false, error: 'Ingresa un correo válido.' }
|
|
432
|
+
if (password.trim() === '') return { ok: false, error: 'Ingresa tu contraseña.' }
|
|
433
|
+
try {
|
|
434
|
+
const context = getStoreContext()
|
|
435
|
+
const outcome = await authenticate({ email, password })
|
|
436
|
+
if (outcome.requiresAction) {
|
|
437
|
+
return { ok: false, error: 'Se requiere un paso adicional para iniciar sesión.' }
|
|
438
|
+
}
|
|
439
|
+
if (outcome.token) await writeAuthToken(context, outcome.token)
|
|
440
|
+
return { ok: true, customer: outcome.customer }
|
|
441
|
+
} catch (error) {
|
|
442
|
+
logServerError('loginAction', error)
|
|
443
|
+
return { ok: false, error: toError(error, 'No se pudo iniciar sesión.') }
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Register a shopper. Validates input first, then registers; a token from the
|
|
449
|
+
* backend is persisted in the httpOnly auth cookie.
|
|
450
|
+
*/
|
|
451
|
+
export async function registerAction(input: {
|
|
452
|
+
email: string
|
|
453
|
+
password: string
|
|
454
|
+
first_name: string
|
|
455
|
+
last_name: string
|
|
456
|
+
phone?: string
|
|
457
|
+
}): Promise<AuthActionResult> {
|
|
458
|
+
const email = typeof input?.email === 'string' ? input.email.trim() : ''
|
|
459
|
+
const password = typeof input?.password === 'string' ? input.password : ''
|
|
460
|
+
const firstName = typeof input?.first_name === 'string' ? input.first_name.trim() : ''
|
|
461
|
+
const lastName = typeof input?.last_name === 'string' ? input.last_name.trim() : ''
|
|
462
|
+
const phone =
|
|
463
|
+
typeof input?.phone === 'string' && input.phone.trim() !== '' ? input.phone.trim() : undefined
|
|
464
|
+
if (!EMAIL_PATTERN.test(email)) return { ok: false, error: 'Ingresa un correo válido.' }
|
|
465
|
+
if (password.trim() === '') return { ok: false, error: 'Ingresa una contraseña.' }
|
|
466
|
+
if (firstName === '') return { ok: false, error: 'Ingresa tu nombre.' }
|
|
467
|
+
if (lastName === '') return { ok: false, error: 'Ingresa tu apellido.' }
|
|
468
|
+
try {
|
|
469
|
+
const context = getStoreContext()
|
|
470
|
+
const outcome = await registerShopper({
|
|
471
|
+
email,
|
|
472
|
+
password,
|
|
473
|
+
first_name: firstName,
|
|
474
|
+
last_name: lastName,
|
|
475
|
+
phone,
|
|
476
|
+
})
|
|
477
|
+
if (outcome.requiresAction) {
|
|
478
|
+
return { ok: false, error: 'Se requiere un paso adicional para iniciar sesión.' }
|
|
479
|
+
}
|
|
480
|
+
if (outcome.token) await writeAuthToken(context, outcome.token)
|
|
481
|
+
return { ok: true, customer: outcome.customer }
|
|
482
|
+
} catch (error) {
|
|
483
|
+
logServerError('registerAction', error)
|
|
484
|
+
return { ok: false, error: toError(error, 'No se pudo crear la cuenta.') }
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Log out. The backend logout is best-effort: the authoritative clear is the
|
|
490
|
+
* auth cookie, so a backend failure is tolerated and still reported as success.
|
|
491
|
+
*/
|
|
492
|
+
export async function logoutAction(): Promise<LogoutActionResult> {
|
|
493
|
+
try {
|
|
494
|
+
const context = getStoreContext()
|
|
495
|
+
try {
|
|
496
|
+
await endSession()
|
|
497
|
+
} catch (error) {
|
|
498
|
+
logServerError('endSession', error)
|
|
499
|
+
}
|
|
500
|
+
await clearAuthToken(context)
|
|
501
|
+
return { ok: true }
|
|
502
|
+
} catch (error) {
|
|
503
|
+
logServerError('logoutAction', error)
|
|
504
|
+
return { ok: false, error: toError(error, 'No se pudo cerrar la sesión.') }
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** Resolve the current customer from the verified auth cookie, or null. */
|
|
509
|
+
export async function loadAccountAction(): Promise<AuthActionResult> {
|
|
510
|
+
try {
|
|
511
|
+
const context = getStoreContext()
|
|
512
|
+
const token = await readAuthToken(context)
|
|
513
|
+
const customer = await loadCustomer(token)
|
|
514
|
+
return { ok: true, customer: customer ?? null }
|
|
515
|
+
} catch (error) {
|
|
516
|
+
logServerError('loadAccountAction', error)
|
|
517
|
+
return { ok: false, error: toError(error, 'No se pudo cargar la cuenta.') }
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ---------------------------------------------------------------------------
|
|
522
|
+
// Customer orders action (change: storefront-customer-orders, task 2.1)
|
|
523
|
+
// ---------------------------------------------------------------------------
|
|
524
|
+
//
|
|
525
|
+
// This action touches ONLY the auth cookie: it never reads or writes the guest
|
|
526
|
+
// session cookie or the cart (guest continuity by construction), and no token
|
|
527
|
+
// is ever logged or returned. An unauthenticated caller resolves to an empty
|
|
528
|
+
// history without contacting the backend.
|
|
529
|
+
|
|
530
|
+
/** Load the authenticated customer's order history from their verified token. */
|
|
531
|
+
export async function loadOrdersAction(): Promise<OrdersActionResult> {
|
|
532
|
+
const context = getStoreContext()
|
|
533
|
+
const token = await readAuthToken(context)
|
|
534
|
+
if (!token) return { ok: true, orders: [] }
|
|
535
|
+
try {
|
|
536
|
+
const orders = await loadOrders(token, context)
|
|
537
|
+
return { ok: true, orders }
|
|
538
|
+
} catch (error) {
|
|
539
|
+
logServerError('loadOrdersAction', error)
|
|
540
|
+
return { ok: false, error: toError(error, 'No se pudieron cargar tus pedidos.') }
|
|
541
|
+
}
|
|
542
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Generated by Thor Commerce storefront (template: assets/templates/app/lib/__STOREFRONT__/cart-state.ts)
|
|
2
|
+
//
|
|
3
|
+
// Read-only cart state for Server Component renders. It reads the signed host
|
|
4
|
+
// cookie and loads the backend cart, but NEVER creates one: a guest identity is
|
|
5
|
+
// created only by a Server Action (`ensureCartAction`). A reload therefore
|
|
6
|
+
// reattaches to the same persisted backend cart through the cookie identity.
|
|
7
|
+
//
|
|
8
|
+
// Server-only and Next-bound (it reads cookies through `./session`). It is
|
|
9
|
+
// imported ONLY by the catch-all page (`app/[[...slug]]/page.tsx`), never by a
|
|
10
|
+
// view module, so the client catalog graph stays free of `next/headers`.
|
|
11
|
+
|
|
12
|
+
import type { CartStatePatch } from './commerce'
|
|
13
|
+
import { EMPTY_CART_STATE, readCart } from './commerce'
|
|
14
|
+
import { getStoreContext } from './store-context'
|
|
15
|
+
import { readHostSession } from './session'
|
|
16
|
+
|
|
17
|
+
/** Cart state for the initial render, plus recovery/read-failure flags. */
|
|
18
|
+
export type CartRenderState = CartStatePatch & {
|
|
19
|
+
receiptAvailable: boolean
|
|
20
|
+
cartReadError: string | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function message(error: unknown, fallback: string): string {
|
|
24
|
+
return error instanceof Error && error.message.trim() !== '' ? error.message : fallback
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Read the shopper's active backend cart for rendering, or the empty cart slice
|
|
29
|
+
* when there is no trusted session or no active cart.
|
|
30
|
+
*
|
|
31
|
+
* A real backend read failure is NOT hidden behind a fake empty cart: the
|
|
32
|
+
* shopper identity is preserved and `cartReadError` is surfaced so the UI can
|
|
33
|
+
* show a visible error. `receiptAvailable` is true when the verified session
|
|
34
|
+
* still carries a completed receipt cart (even after a new cart was started).
|
|
35
|
+
*/
|
|
36
|
+
export async function readCartStateForRender(): Promise<CartRenderState> {
|
|
37
|
+
let context: ReturnType<typeof getStoreContext>
|
|
38
|
+
try {
|
|
39
|
+
context = getStoreContext()
|
|
40
|
+
} catch (error) {
|
|
41
|
+
return { ...EMPTY_CART_STATE, receiptAvailable: false, cartReadError: message(error, 'Configuración de tienda inválida.') }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let session: Awaited<ReturnType<typeof readHostSession>>
|
|
45
|
+
try {
|
|
46
|
+
session = await readHostSession(context)
|
|
47
|
+
} catch (error) {
|
|
48
|
+
return { ...EMPTY_CART_STATE, receiptAvailable: false, cartReadError: message(error, 'No se pudo leer la sesión.') }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const receiptAvailable = Boolean(session?.receiptCartId)
|
|
52
|
+
if (!session?.cartId) return { ...EMPTY_CART_STATE, receiptAvailable, cartReadError: null }
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const cart = await readCart(session.cartId, context)
|
|
56
|
+
return { ...(cart ?? EMPTY_CART_STATE), receiptAvailable, cartReadError: null }
|
|
57
|
+
} catch (error) {
|
|
58
|
+
// Preserve the identity and surface the failure — never a fake empty cart.
|
|
59
|
+
return {
|
|
60
|
+
...EMPTY_CART_STATE,
|
|
61
|
+
cartId: session.cartId,
|
|
62
|
+
receiptAvailable,
|
|
63
|
+
cartReadError: message(error, 'No se pudo cargar el carrito.'),
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|