@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,105 @@
|
|
|
1
|
+
// Thor Commerce view content: checkout
|
|
2
|
+
// User-owned view content — checkout copy, field validation rules, field
|
|
3
|
+
// descriptions, and the confirmation fulfilment timeline. Reconcile seeds this
|
|
4
|
+
// file once and never rewrites it; the derived view module (./index.tsx)
|
|
5
|
+
// imports it and wires it into the element tree, so your edits here survive
|
|
6
|
+
// `storefront sync` and unit removal. Plain data only: no React and no server
|
|
7
|
+
// imports.
|
|
8
|
+
|
|
9
|
+
export const checkoutContent = {
|
|
10
|
+
title: 'Finalizar compra',
|
|
11
|
+
description: 'Completa tus datos para recibir tu pedido.',
|
|
12
|
+
validation: {
|
|
13
|
+
email: {
|
|
14
|
+
validateOn: 'blur',
|
|
15
|
+
checks: [
|
|
16
|
+
{ type: 'required', message: 'Ingresa tu correo electrónico.' },
|
|
17
|
+
{ type: 'email', message: 'Ingresa un correo válido.' },
|
|
18
|
+
],
|
|
19
|
+
},
|
|
20
|
+
firstName: {
|
|
21
|
+
checks: [{ type: 'required', message: 'Ingresa tu nombre.' }],
|
|
22
|
+
},
|
|
23
|
+
lastName: {
|
|
24
|
+
checks: [{ type: 'required', message: 'Ingresa tus apellidos.' }],
|
|
25
|
+
},
|
|
26
|
+
phone: {
|
|
27
|
+
checks: [
|
|
28
|
+
{
|
|
29
|
+
type: 'pattern',
|
|
30
|
+
args: { pattern: '^[+]?[0-9]{7,15}$' },
|
|
31
|
+
message: 'Ingresa un teléfono válido.',
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
address: {
|
|
36
|
+
checks: [{ type: 'required', message: 'Ingresa tu dirección.' }],
|
|
37
|
+
},
|
|
38
|
+
city: {
|
|
39
|
+
checks: [{ type: 'required', message: 'Ingresa la ciudad.' }],
|
|
40
|
+
},
|
|
41
|
+
state: {
|
|
42
|
+
checks: [{ type: 'required', message: 'Ingresa la provincia.' }],
|
|
43
|
+
},
|
|
44
|
+
country: {
|
|
45
|
+
checks: [{ type: 'required', message: 'Selecciona tu país.' }],
|
|
46
|
+
},
|
|
47
|
+
zipCode: {
|
|
48
|
+
checks: [
|
|
49
|
+
{ type: 'required', message: 'Ingresa el código postal.' },
|
|
50
|
+
{
|
|
51
|
+
type: 'pattern',
|
|
52
|
+
args: { pattern: '^[0-9]{4,5}$' },
|
|
53
|
+
message: 'Código postal inválido.',
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
paymentMethod: {
|
|
58
|
+
checks: [{ type: 'required', message: 'Selecciona un método de pago.' }],
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
fieldDescriptions: {
|
|
62
|
+
email: 'Usaremos este correo para el seguimiento del pedido.',
|
|
63
|
+
phone: 'Te contactaremos para coordinar la entrega.',
|
|
64
|
+
promoCode: 'Aplica códigos promocionales válidos.',
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const confirmationContent = {
|
|
69
|
+
// Neutral default copy. The launch flow must not claim a delivery, a shipping
|
|
70
|
+
// promise, or that an email was sent: the backend order may be pending, and
|
|
71
|
+
// no email is guaranteed. The email line only shows the contact the backend
|
|
72
|
+
// actually supplied (`{email}` is filled only when `order.email` is present).
|
|
73
|
+
titleLabel: 'Pedido registrado',
|
|
74
|
+
orderPlacedLabel: 'Pedido {number} · {date} · Correo de contacto: {email}',
|
|
75
|
+
orderPlacedNoEmailLabel: 'Pedido {number} · {date}',
|
|
76
|
+
itemsLabel: 'Comprobante del pedido',
|
|
77
|
+
// Stage descriptions are generic expectations, rendered only when the backend
|
|
78
|
+
// reports a fulfilment stage; `at: null` keeps them as expectations, not facts.
|
|
79
|
+
fulfilmentDetails: {
|
|
80
|
+
Confirmed: {
|
|
81
|
+
stage: 'Confirmed',
|
|
82
|
+
summary: 'Pedido registrado en el sistema.',
|
|
83
|
+
location: 'Tienda',
|
|
84
|
+
at: null,
|
|
85
|
+
},
|
|
86
|
+
Packed: {
|
|
87
|
+
stage: 'Packed',
|
|
88
|
+
summary: 'Preparación del pedido.',
|
|
89
|
+
location: 'Almacén',
|
|
90
|
+
at: null,
|
|
91
|
+
},
|
|
92
|
+
Shipped: {
|
|
93
|
+
stage: 'Shipped',
|
|
94
|
+
summary: 'Pedido despachado.',
|
|
95
|
+
location: 'En tránsito',
|
|
96
|
+
at: null,
|
|
97
|
+
},
|
|
98
|
+
Delivered: {
|
|
99
|
+
stage: 'Delivered',
|
|
100
|
+
summary: 'Pedido entregado.',
|
|
101
|
+
location: 'Destino',
|
|
102
|
+
at: null,
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Thor Commerce view data: checkout
|
|
2
|
+
// User-owned loader shaping for the checkout view. Reconcile seeds this file once
|
|
3
|
+
// and never rewrites it; the derived view module (./index.tsx) imports these
|
|
4
|
+
// helpers and calls them from its loader, so edits here survive `storefront sync`
|
|
5
|
+
// and unit removal. Pure functions only: no React and no server imports.
|
|
6
|
+
|
|
7
|
+
export function buildCheckoutData(input: {
|
|
8
|
+
countries: readonly { code: string; name: string }[]
|
|
9
|
+
paymentMethods: readonly unknown[]
|
|
10
|
+
source: 'mock' | 'medusa'
|
|
11
|
+
}) {
|
|
12
|
+
return {
|
|
13
|
+
checkoutCountries: input.countries.map((country) => ({
|
|
14
|
+
value: country.code,
|
|
15
|
+
label: country.name,
|
|
16
|
+
})),
|
|
17
|
+
checkoutPaymentMethods: input.paymentMethods,
|
|
18
|
+
// The explicit data source: a configured mock is labelled mock; a real-mode
|
|
19
|
+
// failure is surfaced separately through `dataError`, never hidden.
|
|
20
|
+
dataSource: input.source,
|
|
21
|
+
dataError: null,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Thor Commerce view module: checkout
|
|
2
|
+
// Server Component module: elements + catalog entries + route + loader only.
|
|
3
|
+
// The client-side component mappings live in ./registry.tsx ('use client'); this
|
|
4
|
+
// module imports them as data so the RSC graph never evaluates @json-render/react
|
|
5
|
+
// on the server. Registered by importing it in ../index.ts. Recipe: recipes.json -> "checkout".
|
|
6
|
+
//
|
|
7
|
+
// The initial render does NOT read cookies here (this module is part of the
|
|
8
|
+
// client catalog graph). The catch-all page injects the read-only cart state;
|
|
9
|
+
// `CheckoutBoot` then loads the checkout data or reconciles a completed receipt
|
|
10
|
+
// through Server Actions.
|
|
11
|
+
|
|
12
|
+
import { getDataSource } from '../../store-context'
|
|
13
|
+
import { pageRoot } from '../../spec/page-root'
|
|
14
|
+
import type { StorefrontElement } from '../../spec/types'
|
|
15
|
+
import type { ViewModule } from '../types'
|
|
16
|
+
import { registry } from './registry'
|
|
17
|
+
import { checkoutConfirmationProps, checkoutViewProps } from './props'
|
|
18
|
+
import {
|
|
19
|
+
checkoutConfirmationPropsSchema,
|
|
20
|
+
checkoutEmptyPropsSchema,
|
|
21
|
+
checkoutPropsSchema,
|
|
22
|
+
} from './schema'
|
|
23
|
+
|
|
24
|
+
// The element tree is derived wiring; the rendered props and validation rules
|
|
25
|
+
// come from the user-owned ./props.ts module so they survive sync and removal.
|
|
26
|
+
// Four states: bootstrap (headless), confirmation (order), empty cart, or form.
|
|
27
|
+
const elements: Record<string, StorefrontElement> = {
|
|
28
|
+
'checkout.boot': {
|
|
29
|
+
type: 'CheckoutBoot',
|
|
30
|
+
props: {},
|
|
31
|
+
},
|
|
32
|
+
'checkout.confirmation': {
|
|
33
|
+
type: 'CheckoutConfirmation',
|
|
34
|
+
visible: { $state: '/lastReceipt' },
|
|
35
|
+
props: checkoutConfirmationProps,
|
|
36
|
+
on: { continueShopping: { action: 'continueShopping' } },
|
|
37
|
+
},
|
|
38
|
+
'checkout.empty': {
|
|
39
|
+
type: 'CheckoutEmpty',
|
|
40
|
+
visible: {
|
|
41
|
+
$and: [
|
|
42
|
+
{ $state: '/lastReceipt', not: true },
|
|
43
|
+
{ $state: '/cartQuantity', eq: 0 },
|
|
44
|
+
// Show the empty state when there is no active cart and no receipt to
|
|
45
|
+
// recover, or when an active cart exists (even empty). A signed receipt
|
|
46
|
+
// with no active cart is reconciled by the boot, not shown as empty.
|
|
47
|
+
{
|
|
48
|
+
$or: [{ $state: '/receiptAvailable', not: true }, { $state: '/cartId' }],
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
props: {},
|
|
53
|
+
on: { continueShopping: { action: 'continueShopping' } },
|
|
54
|
+
},
|
|
55
|
+
'checkout.view': {
|
|
56
|
+
type: 'Checkout',
|
|
57
|
+
visible: {
|
|
58
|
+
$and: [{ $state: '/lastReceipt', not: true }, { $state: '/cartQuantity', gt: 0 }],
|
|
59
|
+
},
|
|
60
|
+
props: checkoutViewProps,
|
|
61
|
+
on: {
|
|
62
|
+
placeOrder: { action: 'placeOrder' },
|
|
63
|
+
reconcileOrder: { action: 'reconcileOrder' },
|
|
64
|
+
syncCheckoutAddress: { action: 'syncCheckoutAddress' },
|
|
65
|
+
selectShippingMethod: { action: 'selectShippingMethod' },
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const catalog = {
|
|
71
|
+
CheckoutBoot: {
|
|
72
|
+
props: checkoutEmptyPropsSchema,
|
|
73
|
+
description: 'Headless checkout bootstrap: loads form data or reconciles a receipt.',
|
|
74
|
+
},
|
|
75
|
+
Checkout: {
|
|
76
|
+
props: checkoutPropsSchema,
|
|
77
|
+
description: 'Checkout form with order summary, shipping and payment steps.',
|
|
78
|
+
},
|
|
79
|
+
CheckoutEmpty: {
|
|
80
|
+
props: checkoutEmptyPropsSchema,
|
|
81
|
+
description: 'Empty-cart state for the checkout route.',
|
|
82
|
+
},
|
|
83
|
+
CheckoutConfirmation: {
|
|
84
|
+
props: checkoutConfirmationPropsSchema,
|
|
85
|
+
description: 'Order confirmation with fulfilment progress.',
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const checkout: ViewModule = {
|
|
90
|
+
name: 'checkout',
|
|
91
|
+
elements,
|
|
92
|
+
catalog,
|
|
93
|
+
registry,
|
|
94
|
+
route: {
|
|
95
|
+
path: '/checkout',
|
|
96
|
+
loaderName: 'checkout',
|
|
97
|
+
layout: 'default',
|
|
98
|
+
metadata: {
|
|
99
|
+
title: 'Finalizar compra',
|
|
100
|
+
description: 'Completa tus datos de envío y pago para recibir tu pedido.',
|
|
101
|
+
},
|
|
102
|
+
entry: pageRoot(
|
|
103
|
+
'checkout',
|
|
104
|
+
['checkout.boot', 'checkout.confirmation', 'checkout.empty', 'checkout.view'],
|
|
105
|
+
elements,
|
|
106
|
+
),
|
|
107
|
+
},
|
|
108
|
+
loader: {
|
|
109
|
+
checkout: async () => ({ dataSource: getDataSource(), dataError: null }),
|
|
110
|
+
},
|
|
111
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Thor Commerce view props: checkout
|
|
2
|
+
// User-owned element props for the checkout view. Reconcile seeds this file once
|
|
3
|
+
// and never rewrites it; the derived view module (./index.tsx) imports these
|
|
4
|
+
// objects and binds them into the element tree, so edits here survive
|
|
5
|
+
// `storefront sync` and unit removal. Plain data only: no React and no server
|
|
6
|
+
// imports.
|
|
7
|
+
|
|
8
|
+
import { checkoutContent, confirmationContent } from './content'
|
|
9
|
+
|
|
10
|
+
export const checkoutViewProps = {
|
|
11
|
+
...checkoutContent,
|
|
12
|
+
// Backend-authoritative summary: line totals and cart totals come from the
|
|
13
|
+
// backend cart state, never computed here.
|
|
14
|
+
orderSummary: {
|
|
15
|
+
items: { $state: '/cart' },
|
|
16
|
+
totals: { $state: '/cartTotals' },
|
|
17
|
+
},
|
|
18
|
+
countries: { $state: '/checkoutCountries' },
|
|
19
|
+
shippingOptions: { $state: '/checkoutShippingOptions' },
|
|
20
|
+
selectedShippingOptionId: { $state: '/checkoutSelectedShippingOptionId' },
|
|
21
|
+
paymentMethods: { $state: '/checkoutPaymentMethods' },
|
|
22
|
+
fieldErrors: { $state: '/checkoutFieldErrors' },
|
|
23
|
+
values: {
|
|
24
|
+
email: { $state: '/checkout/email' },
|
|
25
|
+
firstName: { $state: '/checkout/firstName' },
|
|
26
|
+
lastName: { $state: '/checkout/lastName' },
|
|
27
|
+
phone: { $state: '/checkout/phone' },
|
|
28
|
+
address: { $state: '/checkout/address' },
|
|
29
|
+
city: { $state: '/checkout/city' },
|
|
30
|
+
state: { $state: '/checkout/state' },
|
|
31
|
+
zipCode: { $state: '/checkout/zipCode' },
|
|
32
|
+
country: { $state: '/checkout/country' },
|
|
33
|
+
paymentMethod: { $state: '/checkout/paymentMethod' },
|
|
34
|
+
cardNumber: { $state: '/checkout/cardNumber' },
|
|
35
|
+
expiryDate: { $state: '/checkout/expiryDate' },
|
|
36
|
+
cvv: { $state: '/checkout/cvv' },
|
|
37
|
+
cardName: { $state: '/checkout/cardName' },
|
|
38
|
+
saveInfo: { $state: '/checkout/saveInfo' },
|
|
39
|
+
sameAsBilling: { $state: '/checkout/sameAsBilling' },
|
|
40
|
+
newsletter: { $state: '/checkout/newsletter' },
|
|
41
|
+
promoCode: { $state: '/checkout/promoCode' },
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const checkoutConfirmationProps = {
|
|
46
|
+
// One atomic state path carries the order and its backend lines together.
|
|
47
|
+
receipt: { $state: '/lastReceipt' },
|
|
48
|
+
...confirmationContent,
|
|
49
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
// Thor Commerce view registry fragment: checkout
|
|
4
|
+
// Client boundary for the checkout view. Imports value bindings from
|
|
5
|
+
// @json-render/react and components from @thorprovider/blocks — both must stay
|
|
6
|
+
// on the client side of the RSC graph. Kept separate from index.tsx so the view
|
|
7
|
+
// spec (elements/catalog/route/loader) stays a pure Server Component module.
|
|
8
|
+
|
|
9
|
+
import { useEffect, useRef } from 'react'
|
|
10
|
+
import {
|
|
11
|
+
Button,
|
|
12
|
+
CheckoutForm1 as CheckoutBlock,
|
|
13
|
+
CheckoutConfirmation as CheckoutConfirmationBlock,
|
|
14
|
+
} from '@thorprovider/blocks'
|
|
15
|
+
import { useStateStore } from '@json-render/react'
|
|
16
|
+
import { loadCheckoutAction, reconcileReceiptAction } from '../../actions'
|
|
17
|
+
import type { CartStatePatch, PlaceOrderResult } from '../../commerce'
|
|
18
|
+
import type { ViewRegistry } from '../types'
|
|
19
|
+
import { checkoutConfirmationPropsSchema, checkoutPropsSchema } from './schema'
|
|
20
|
+
|
|
21
|
+
// Address fields whose change can complete the address; when the full address
|
|
22
|
+
// is present the handler syncs it to the backend and loads eligible shipping.
|
|
23
|
+
const ADDRESS_FIELDS = new Set([
|
|
24
|
+
'email',
|
|
25
|
+
'firstName',
|
|
26
|
+
'lastName',
|
|
27
|
+
'phone',
|
|
28
|
+
'address',
|
|
29
|
+
'city',
|
|
30
|
+
'state',
|
|
31
|
+
'zipCode',
|
|
32
|
+
'country',
|
|
33
|
+
])
|
|
34
|
+
|
|
35
|
+
export const registry: ViewRegistry = {
|
|
36
|
+
/**
|
|
37
|
+
* Headless bootstrap rendered on the checkout route. It loads the checkout
|
|
38
|
+
* data (countries/payment methods/cart) for an active cart, or reconciles a
|
|
39
|
+
* completed receipt through the cookie-authorized Server Action. It never
|
|
40
|
+
* reads a bare order/cart id.
|
|
41
|
+
*/
|
|
42
|
+
CheckoutBoot: () => {
|
|
43
|
+
const { state, set } = useStateStore()
|
|
44
|
+
const booted = useRef(false)
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (booted.current) return
|
|
47
|
+
booted.current = true
|
|
48
|
+
|
|
49
|
+
const applyResult = (result: PlaceOrderResult) => {
|
|
50
|
+
set('/checkoutPending', false)
|
|
51
|
+
if (result.status === 'order') {
|
|
52
|
+
// ONE atomic write carries the order and its backend lines, so an SSR
|
|
53
|
+
// refresh cannot drop the lines.
|
|
54
|
+
set('/lastReceipt', { order: result.order, lines: result.lines })
|
|
55
|
+
set('/orderStatus', 'complete')
|
|
56
|
+
set('/checkoutError', null)
|
|
57
|
+
set('/checkoutErrorKind', null)
|
|
58
|
+
set('/cart', [])
|
|
59
|
+
set('/cartTotals', null)
|
|
60
|
+
set('/cartId', null)
|
|
61
|
+
set('/cartQuantity', 0)
|
|
62
|
+
set('/cartCompleted', true)
|
|
63
|
+
set('/checkoutSelectedShippingOptionId', null)
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
set('/orderStatus', result.status === 'cart-error' ? 'error' : result.status)
|
|
67
|
+
set('/checkoutErrorKind', result.status)
|
|
68
|
+
set('/checkoutError', result.message)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const bootError = (scope: string) => (error: unknown) => {
|
|
72
|
+
// Never swallow: log a non-secret context line and surface a message.
|
|
73
|
+
console.error(
|
|
74
|
+
`[storefront/checkout] ${scope} failed`,
|
|
75
|
+
error instanceof Error ? error.message : error,
|
|
76
|
+
)
|
|
77
|
+
set('/checkoutPending', false)
|
|
78
|
+
set('/checkoutError', 'No se pudo cargar el checkout. Inténtalo de nuevo.')
|
|
79
|
+
set('/checkoutErrorKind', 'error')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 1. An active cart the backend already committed is an unresolved
|
|
83
|
+
// completion: reconcile IT first (never a blind retry).
|
|
84
|
+
// 2. Otherwise ANY active cart (even empty) loads the checkout form; the
|
|
85
|
+
// signed receipt is NOT auto-recovered while an active cart exists.
|
|
86
|
+
// 3. Only with no active cart does a signed receipt proof recover the
|
|
87
|
+
// completed order.
|
|
88
|
+
if (state.cartId && state.cartCompleted) {
|
|
89
|
+
set('/checkoutPending', true)
|
|
90
|
+
void reconcileReceiptAction().then(applyResult).catch(bootError('reconcileReceipt'))
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
if (state.cartId) {
|
|
94
|
+
set('/checkoutPending', true)
|
|
95
|
+
void loadCheckoutAction()
|
|
96
|
+
.then((result) => {
|
|
97
|
+
set('/checkoutPending', false)
|
|
98
|
+
if (!result.ok) {
|
|
99
|
+
set('/checkoutError', result.error)
|
|
100
|
+
set('/checkoutErrorKind', 'error')
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
const cart = result.cart as CartStatePatch
|
|
104
|
+
set('/cart', cart.cart)
|
|
105
|
+
set('/cartTotals', cart.cartTotals)
|
|
106
|
+
set('/cartId', cart.cartId)
|
|
107
|
+
set('/cartQuantity', cart.cartQuantity)
|
|
108
|
+
set('/cartSelectedShippingOptionId', cart.cartSelectedShippingOptionId)
|
|
109
|
+
set('/checkoutSelectedShippingOptionId', cart.cartSelectedShippingOptionId)
|
|
110
|
+
if (result.countries) set('/checkoutCountries', result.countries)
|
|
111
|
+
if (result.paymentMethods) set('/checkoutPaymentMethods', result.paymentMethods)
|
|
112
|
+
if (result.shippingOptions) set('/checkoutShippingOptions', result.shippingOptions)
|
|
113
|
+
set('/checkoutError', null)
|
|
114
|
+
})
|
|
115
|
+
.catch(bootError('loadCheckout'))
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
if (state.receiptAvailable) {
|
|
119
|
+
set('/checkoutPending', true)
|
|
120
|
+
void reconcileReceiptAction().then(applyResult).catch(bootError('reconcileReceipt'))
|
|
121
|
+
}
|
|
122
|
+
}, [])
|
|
123
|
+
return null
|
|
124
|
+
},
|
|
125
|
+
Checkout: ({ props, emit }) => {
|
|
126
|
+
const { state, set } = useStateStore()
|
|
127
|
+
const parsed = checkoutPropsSchema.parse(props)
|
|
128
|
+
const unresolved = state.checkoutErrorKind === 'unresolved'
|
|
129
|
+
const cartError = typeof state.cartError === 'string' ? state.cartError : undefined
|
|
130
|
+
const checkoutError = typeof state.checkoutError === 'string' ? state.checkoutError : undefined
|
|
131
|
+
return (
|
|
132
|
+
<CheckoutBlock
|
|
133
|
+
title={parsed.title}
|
|
134
|
+
description={parsed.description}
|
|
135
|
+
orderSummary={parsed.orderSummary}
|
|
136
|
+
totalsState={state.cartPending ? 'pending' : cartError ? 'error' : 'idle'}
|
|
137
|
+
totalsErrorLabel={cartError}
|
|
138
|
+
onRetryTotals={() => emit('syncCheckoutAddress')}
|
|
139
|
+
countries={parsed.countries}
|
|
140
|
+
shippingOptions={parsed.shippingOptions}
|
|
141
|
+
selectedShippingOptionId={parsed.selectedShippingOptionId ?? undefined}
|
|
142
|
+
onSelectShippingOption={(id) => {
|
|
143
|
+
set('/checkoutSelectedShippingOptionId', id)
|
|
144
|
+
emit('selectShippingMethod')
|
|
145
|
+
}}
|
|
146
|
+
paymentMethods={parsed.paymentMethods}
|
|
147
|
+
values={parsed.values}
|
|
148
|
+
fieldErrors={parsed.fieldErrors}
|
|
149
|
+
fieldDescriptions={parsed.fieldDescriptions}
|
|
150
|
+
collectCardFields={false}
|
|
151
|
+
onChange={(field, value) => {
|
|
152
|
+
set(`/checkout/${field}`, value)
|
|
153
|
+
if (ADDRESS_FIELDS.has(field)) emit('syncCheckoutAddress')
|
|
154
|
+
}}
|
|
155
|
+
onSubmit={() => emit('placeOrder')}
|
|
156
|
+
submitting={Boolean(state.checkoutPending)}
|
|
157
|
+
submitError={checkoutError}
|
|
158
|
+
// An ambiguous completion offers RECONCILE only; a definite failure
|
|
159
|
+
// (cart-with-error or pre-completion error) offers a controlled retry.
|
|
160
|
+
onRetrySubmit={unresolved ? () => emit('reconcileOrder') : () => emit('placeOrder')}
|
|
161
|
+
submitErrorActionLabel={unresolved ? 'Reconciliar' : 'Reintentar'}
|
|
162
|
+
/>
|
|
163
|
+
)
|
|
164
|
+
},
|
|
165
|
+
CheckoutEmpty: ({ emit }) => (
|
|
166
|
+
<div className="mx-auto max-w-md px-4 py-16 text-center">
|
|
167
|
+
<h1 className="text-2xl font-bold">Tu carrito está vacío</h1>
|
|
168
|
+
<p className="text-muted-foreground mt-2 text-sm">
|
|
169
|
+
Agrega productos para finalizar la compra.
|
|
170
|
+
</p>
|
|
171
|
+
<Button className="mt-6" onClick={() => emit('continueShopping')}>
|
|
172
|
+
Seguir comprando
|
|
173
|
+
</Button>
|
|
174
|
+
</div>
|
|
175
|
+
),
|
|
176
|
+
CheckoutConfirmation: ({ props, emit }) => {
|
|
177
|
+
const parsed = checkoutConfirmationPropsSchema.parse(props)
|
|
178
|
+
// Forward every neutral label from user-owned content to the block, so the
|
|
179
|
+
// curated flow never falls back to the block's non-neutral defaults.
|
|
180
|
+
return (
|
|
181
|
+
<CheckoutConfirmationBlock
|
|
182
|
+
order={parsed.receipt.order}
|
|
183
|
+
orderLines={parsed.receipt.lines}
|
|
184
|
+
fulfilmentDetails={parsed.fulfilmentDetails}
|
|
185
|
+
titleLabel={parsed.titleLabel}
|
|
186
|
+
orderPlacedLabel={parsed.orderPlacedLabel}
|
|
187
|
+
orderPlacedNoEmailLabel={parsed.orderPlacedNoEmailLabel}
|
|
188
|
+
itemsLabel={parsed.itemsLabel}
|
|
189
|
+
quantityLabel={parsed.quantityLabel}
|
|
190
|
+
subtotalLabel={parsed.subtotalLabel}
|
|
191
|
+
discountLabel={parsed.discountLabel}
|
|
192
|
+
shippingLabel={parsed.shippingLabel}
|
|
193
|
+
taxLabel={parsed.taxLabel}
|
|
194
|
+
totalLabel={parsed.totalLabel}
|
|
195
|
+
freeLabel={parsed.freeLabel}
|
|
196
|
+
amountUnavailableLabel={parsed.amountUnavailableLabel}
|
|
197
|
+
shippingAddressLabel={parsed.shippingAddressLabel}
|
|
198
|
+
billingAddressLabel={parsed.billingAddressLabel}
|
|
199
|
+
continueShoppingLabel={parsed.continueShoppingLabel}
|
|
200
|
+
contactSupportLabel={parsed.contactSupportLabel}
|
|
201
|
+
supportLabel={parsed.supportLabel}
|
|
202
|
+
progressLabel={parsed.progressLabel}
|
|
203
|
+
estimatedDeliveryLabel={parsed.estimatedDeliveryLabel}
|
|
204
|
+
currentStageLabel={parsed.currentStageLabel}
|
|
205
|
+
inProgressLabel={parsed.inProgressLabel}
|
|
206
|
+
expectedByLabel={parsed.expectedByLabel}
|
|
207
|
+
completedLabel={parsed.completedLabel}
|
|
208
|
+
statusLabel={parsed.statusLabel}
|
|
209
|
+
locationLabel={parsed.locationLabel}
|
|
210
|
+
trackOrderLabel={parsed.trackOrderLabel}
|
|
211
|
+
continueShoppingHref={parsed.continueShoppingHref ?? '#'}
|
|
212
|
+
supportHref={parsed.supportHref}
|
|
213
|
+
onContinueShopping={() => emit('continueShopping')}
|
|
214
|
+
/>
|
|
215
|
+
)
|
|
216
|
+
},
|
|
217
|
+
}
|