@thorprovider/storefront 0.3.1 → 0.4.0-mvp.2
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/README.md +321 -152
- package/VENDOR.md +69 -0
- package/assets/openspec/config.yaml +38 -0
- package/assets/templates/AGENTS.md +76 -0
- package/assets/templates/app/app/[[...slug]]/page.tsx +54 -2
- package/assets/templates/app/app/[[...slug]]/renderer.tsx +13 -2
- package/assets/templates/app/app/globals.css +5 -8
- package/assets/templates/app/app/layout.tsx +17 -7
- package/assets/templates/app/app/robots.ts +19 -0
- package/assets/templates/app/app/sitemap.ts +71 -0
- package/assets/templates/app/lib/__STOREFRONT__/actions.ts +612 -0
- package/assets/templates/app/lib/__STOREFRONT__/cart-state.ts +66 -0
- package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +50 -57
- package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +679 -0
- package/assets/templates/app/lib/__STOREFRONT__/composition.ts +26 -0
- package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +197 -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 +264 -51
- package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +6 -3
- package/assets/templates/app/lib/__STOREFRONT__/schema.ts +136 -0
- package/assets/templates/app/lib/__STOREFRONT__/section-observation.ts +182 -0
- package/assets/templates/app/lib/__STOREFRONT__/section-sources.ts +130 -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 +120 -0
- package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +38 -0
- package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +129 -10
- package/assets/templates/app/lib/__STOREFRONT__/spec/page-root.ts +5 -1
- package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +191 -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 +161 -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 +18 -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/products.client.tsx +100 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.tsx +19 -0
- 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/schema.products.ts +39 -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 +159 -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 +45 -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 +439 -0
- package/assets/templates/views/account/schema.ts +53 -0
- package/assets/templates/views/cart/content.ts +11 -0
- package/assets/templates/views/cart/data.ts +23 -0
- package/assets/templates/views/cart/index.tsx +53 -0
- package/assets/templates/views/cart/props.ts +12 -0
- package/assets/templates/views/cart/registry.tsx +81 -0
- package/assets/templates/views/cart/schema.ts +52 -0
- package/assets/templates/views/category/assembly/assembly.json +29 -0
- package/assets/templates/views/category/assembly/index.skeleton.tsx +251 -0
- package/assets/templates/views/category/assembly/registry.skeleton.tsx +34 -0
- package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/binding.json +5 -0
- package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/catalog.part +4 -0
- package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/element.part +7 -0
- package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/registry.part +18 -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 +299 -0
- package/assets/templates/views/category/props.ts +23 -0
- package/assets/templates/views/category/registry.tsx +122 -0
- package/assets/templates/views/category/schema.ts +91 -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 +33 -0
- package/assets/templates/views/home/assembly/index.skeleton.tsx +90 -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 +24 -0
- package/assets/templates/views/home/index.tsx +57 -85
- 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 +50 -0
- package/assets/templates/views/product/index.tsx +148 -0
- package/assets/templates/views/product/props.ts +27 -0
- package/assets/templates/views/product/registry.tsx +103 -0
- package/assets/templates/views/product/schema.ts +121 -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/bin/storefront.js +2424 -148
- package/examples/merchandising/category-led.json +37 -0
- package/examples/merchandising/collection-led.json +37 -0
- package/package.json +15 -9
- package/recipes/archetypes.json +28 -14
- package/recipes/catalog.json +400 -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 +162 -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 +76 -19
- package/recipes/sections.json +34 -5
- package/recipes/validate.mjs +838 -32
- package/scripts/candidate.mjs +236 -0
- package/scripts/composers.mjs +160 -0
- package/scripts/env-file.mjs +86 -0
- package/scripts/file-classes.mjs +121 -0
- package/scripts/logical-controls.mjs +61 -0
- package/scripts/manifest.mjs +298 -0
- package/scripts/migrate-english-routes.mjs +276 -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 +573 -0
- package/scripts/plan-apply.mjs +987 -0
- package/scripts/preflight.mjs +51 -0
- package/scripts/reconcile.mjs +549 -0
- package/scripts/render-templates.mjs +338 -68
- package/scripts/schema-validate.mjs +132 -0
- package/scripts/section-compat.mjs +300 -0
- package/scripts/section-config.mjs +253 -0
- package/scripts/section-operations.mjs +508 -0
- package/scripts/starter.mjs +17 -0
- package/scripts/sync-assets.mjs +22 -15
- package/scripts/theme/color.mjs +185 -0
- package/scripts/theme/css-theme.mjs +233 -0
- package/scripts/theme/fonts.mjs +123 -0
- package/scripts/theme/import-export.mjs +125 -0
- package/scripts/theme/preset.mjs +93 -0
- package/scripts/theme/theme-plan.mjs +292 -0
- package/scripts/theme/tokens.mjs +137 -0
- package/scripts/view-assembly.mjs +337 -0
- package/scripts/view-selection.mjs +211 -0
- package/skills/sf-feature/SKILL.md +80 -0
- package/skills/sf-setup/SKILL.md +79 -0
- package/skills/sf-theme-gen/SKILL.md +100 -30
- package/skills/sf-theme-gen/references/shadcn-theming.md +87 -0
- package/skills/{shadcn-theming/references/templates → sf-theme-gen/references}/theme-setup.md +15 -19
- package/skills/{shadcn-theming → sf-theme-gen}/references/theming-guide.md +6 -11
- package/skills/sf-view-gen/SKILL.md +146 -93
- package/skills/sf-view-gen/assets/recipes/archetypes.json +28 -14
- package/skills/sf-view-gen/assets/recipes/catalog.json +400 -0
- package/skills/sf-view-gen/assets/recipes/catalog.schema.json +64 -0
- package/skills/sf-view-gen/assets/recipes/features.json +162 -0
- package/skills/sf-view-gen/assets/recipes/recipes.json +76 -19
- package/skills/sf-view-gen/assets/recipes/sections.json +34 -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/products.client.tsx +100 -0
- package/skills/sf-view-gen/assets/templates/views/sections/products.tsx +19 -0
- 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.products.ts +39 -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/{json-render-core/SKILL.md → sf-view-gen/references/json-render-core.md} +4 -27
- package/skills/{json-render-next/SKILL.md → sf-view-gen/references/json-render-next.md} +5 -8
- package/skills/{json-render-react/SKILL.md → sf-view-gen/references/json-render-react.md} +3 -5
- package/skills/{json-render-shadcn/SKILL.md → sf-view-gen/references/json-render-shadcn.md} +3 -5
- package/skills/sf-view-gen/references/views.md +58 -33
- 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 +838 -32
- package/skills/storefront-workflow/SKILL.md +179 -0
- package/skills/{storefront-best-practices/reference/design.md → storefront-workflow/references/design-consistency.md} +13 -29
- package/CHANGELOG.md +0 -61
- package/commands/sf-add-view.md +0 -26
- package/commands/sf-theme.md +0 -20
- package/commands/sf-view.md +0 -25
- package/skills/building-storefronts/SKILL.md +0 -178
- package/skills/building-storefronts/references/frontend-integration.md +0 -229
- package/skills/json-render-remotion/SKILL.md +0 -111
- package/skills/json-render-solid/SKILL.md +0 -204
- package/skills/nextjs-shadcn/SKILL.md +0 -367
- package/skills/nextjs-shadcn/references/architecture.md +0 -499
- package/skills/nextjs-shadcn/references/project-setup.md +0 -162
- package/skills/nextjs-shadcn/references/shadcn-platform.md +0 -261
- package/skills/nextjs-shadcn/references/sidebar.md +0 -274
- package/skills/nextjs-shadcn/references/styling.md +0 -555
- 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
- package/skills/sf-view-gen/assets/templates/views/user-panel/index.tsx +0 -168
- package/skills/sf-view-gen/assets/templates/views/user-panel/registry.tsx +0 -49
- package/skills/shadcn-component-discovery/SKILL.md +0 -273
- package/skills/shadcn-component-discovery/references/registries.md +0 -226
- package/skills/shadcn-theming/SKILL.md +0 -104
- package/skills/storefront-best-practices/SKILL.md +0 -421
- package/skills/storefront-best-practices/reference/components/breadcrumbs.md +0 -123
- package/skills/storefront-best-practices/reference/components/cart-popup.md +0 -189
- package/skills/storefront-best-practices/reference/components/country-selector.md +0 -298
- package/skills/storefront-best-practices/reference/components/footer.md +0 -112
- package/skills/storefront-best-practices/reference/components/hero.md +0 -241
- package/skills/storefront-best-practices/reference/components/megamenu.md +0 -239
- package/skills/storefront-best-practices/reference/components/navbar.md +0 -397
- package/skills/storefront-best-practices/reference/components/popups.md +0 -221
- package/skills/storefront-best-practices/reference/components/product-card.md +0 -125
- package/skills/storefront-best-practices/reference/components/product-reviews.md +0 -217
- package/skills/storefront-best-practices/reference/components/product-slider.md +0 -174
- package/skills/storefront-best-practices/reference/components/search.md +0 -101
- package/skills/storefront-best-practices/reference/connecting-to-backend.md +0 -391
- package/skills/storefront-best-practices/reference/features/promotions.md +0 -307
- package/skills/storefront-best-practices/reference/features/wishlist.md +0 -230
- package/skills/storefront-best-practices/reference/layouts/account.md +0 -380
- package/skills/storefront-best-practices/reference/layouts/cart.md +0 -316
- package/skills/storefront-best-practices/reference/layouts/checkout.md +0 -486
- package/skills/storefront-best-practices/reference/layouts/home-page.md +0 -264
- package/skills/storefront-best-practices/reference/layouts/order-confirmation.md +0 -231
- package/skills/storefront-best-practices/reference/layouts/product-details.md +0 -527
- package/skills/storefront-best-practices/reference/layouts/product-listing.md +0 -520
- package/skills/storefront-best-practices/reference/layouts/static-pages.md +0 -356
- package/skills/storefront-best-practices/reference/medusa.md +0 -307
- /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/mobile-responsiveness.md +0 -0
- /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/seo.md +0 -0
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
// Thor Commerce view registry fragment: account
|
|
4
|
+
// Client boundary for the account 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
|
+
// Security invariant: the block-owned credential payload (email/password) is
|
|
10
|
+
// passed straight from the block callback to the Server Action and is NEVER
|
|
11
|
+
// written to the json-render state store, so no password reaches client state,
|
|
12
|
+
// HTML, or logs. Only the non-secret result (customer/error) is stored.
|
|
13
|
+
|
|
14
|
+
import { useEffect, useRef } from 'react'
|
|
15
|
+
import {
|
|
16
|
+
Button,
|
|
17
|
+
LoginPage1 as LoginBlock,
|
|
18
|
+
OrderHistory2,
|
|
19
|
+
SignUpPage1 as SignUpBlock,
|
|
20
|
+
} from '@thorprovider/blocks'
|
|
21
|
+
import { useStateStore } from '@json-render/react'
|
|
22
|
+
import type { Customer } from '@thorprovider/types'
|
|
23
|
+
import {
|
|
24
|
+
loadAccountAction,
|
|
25
|
+
loadOrdersPageAction,
|
|
26
|
+
loginAction,
|
|
27
|
+
logoutAction,
|
|
28
|
+
registerAction,
|
|
29
|
+
} from '../../actions'
|
|
30
|
+
import type { AccountOrderView } from '../../state.core'
|
|
31
|
+
import type { ViewRegistry } from '../types'
|
|
32
|
+
import {
|
|
33
|
+
accountLoginPropsSchema,
|
|
34
|
+
accountOrdersPropsSchema,
|
|
35
|
+
accountRegisterPropsSchema,
|
|
36
|
+
} from './schema'
|
|
37
|
+
|
|
38
|
+
// The structured result the auth Server Actions return (see ../../actions).
|
|
39
|
+
type AuthResult = { ok: true; customer: Customer | null } | { ok: false; error: string }
|
|
40
|
+
type SetState = (path: string, value: unknown) => void
|
|
41
|
+
|
|
42
|
+
/** Bounded page size for the account order-history window. */
|
|
43
|
+
const ORDERS_PAGE_SIZE = 10
|
|
44
|
+
|
|
45
|
+
// Apply a settled auth result truthfully: a failure sets an error and NEVER a
|
|
46
|
+
// logged-in state; a success stores the customer (or null) and nothing else.
|
|
47
|
+
function applyAuthResult(set: SetState, result: AuthResult): void {
|
|
48
|
+
set('/accountPending', false)
|
|
49
|
+
if (result.ok) {
|
|
50
|
+
set('/accountCustomer', result.customer)
|
|
51
|
+
set('/accountAuthenticated', result.customer !== null)
|
|
52
|
+
set('/accountError', null)
|
|
53
|
+
set('/accountMode', 'login')
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
set('/accountError', result.error)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// A rejected Server Action (network/RPC) must clear pending and surface a
|
|
60
|
+
// message; never swallow it. Only a non-secret context line is logged.
|
|
61
|
+
function authFailure(set: SetState, scope: string) {
|
|
62
|
+
return (error: unknown) => {
|
|
63
|
+
console.error(
|
|
64
|
+
`[storefront/account] ${scope} failed`,
|
|
65
|
+
error instanceof Error ? error.message : error,
|
|
66
|
+
)
|
|
67
|
+
set('/accountPending', false)
|
|
68
|
+
set('/accountError', 'No se pudo completar la operación. Inténtalo de nuevo.')
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Non-secret status surface for the block pages, which expose no error/pending
|
|
73
|
+
// prop of their own. Renders nothing when there is no pending work or error.
|
|
74
|
+
function AuthNotice({ pending, error }: { pending: boolean; error: string | null }) {
|
|
75
|
+
if (!pending && !error) return null
|
|
76
|
+
return (
|
|
77
|
+
<div
|
|
78
|
+
role="alert"
|
|
79
|
+
aria-live="polite"
|
|
80
|
+
className="bg-background text-foreground fixed inset-x-0 top-4 z-50 mx-auto w-fit max-w-md rounded-md border px-4 py-2 text-center text-sm shadow-lg"
|
|
81
|
+
>
|
|
82
|
+
{pending ? 'Procesando…' : error}
|
|
83
|
+
</div>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The OrderHistory2 block's order shape (see @thorprovider/blocks). Kept local
|
|
88
|
+
// so the registry maps the account order rows to the block contract without a
|
|
89
|
+
// shared dependency on block internals.
|
|
90
|
+
interface BlockOrderItem {
|
|
91
|
+
id: string
|
|
92
|
+
name: string
|
|
93
|
+
image: string
|
|
94
|
+
details: string
|
|
95
|
+
price: string
|
|
96
|
+
quantity: number
|
|
97
|
+
}
|
|
98
|
+
interface BlockOrder {
|
|
99
|
+
id: string
|
|
100
|
+
date: string
|
|
101
|
+
status: string
|
|
102
|
+
total: string
|
|
103
|
+
items: BlockOrderItem[]
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Map a backend-sourced account order row to the block's order shape. The block
|
|
107
|
+
// has a single `id` slot which it also renders ("Pedido {id}"), so it carries the
|
|
108
|
+
// display number; the registry keeps a number→backend-id map so selection is
|
|
109
|
+
// keyed by the REAL backend order id. An absent variant/image becomes '' because
|
|
110
|
+
// the block contract is a string — never an invented value.
|
|
111
|
+
function toBlockOrder(order: AccountOrderView): BlockOrder {
|
|
112
|
+
return {
|
|
113
|
+
id: order.number,
|
|
114
|
+
date: order.date,
|
|
115
|
+
status: order.status,
|
|
116
|
+
total: order.total,
|
|
117
|
+
items: order.items.map((item) => ({
|
|
118
|
+
id: item.id,
|
|
119
|
+
name: item.name,
|
|
120
|
+
image: item.image ?? '',
|
|
121
|
+
details: item.variant ?? '',
|
|
122
|
+
price: item.lineTotal,
|
|
123
|
+
quantity: item.quantity,
|
|
124
|
+
})),
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Inline order detail assembled from the already-fetched list row (no second
|
|
129
|
+
// backend request). Only fields the backend supplied are shown.
|
|
130
|
+
function OrderDetail({ order }: { order: AccountOrderView }) {
|
|
131
|
+
const totals = (
|
|
132
|
+
[
|
|
133
|
+
{ label: 'Subtotal', value: order.subtotal },
|
|
134
|
+
{ label: 'Impuestos', value: order.tax },
|
|
135
|
+
{ label: 'Envío', value: order.shipping },
|
|
136
|
+
{ label: 'Total', value: order.total },
|
|
137
|
+
] as { label: string; value?: string }[]
|
|
138
|
+
).filter((row): row is { label: string; value: string } => row.value !== undefined)
|
|
139
|
+
return (
|
|
140
|
+
<div className="mx-auto w-full max-w-7xl px-4 pb-12 sm:px-6 lg:px-8">
|
|
141
|
+
<div className="rounded-lg border p-6">
|
|
142
|
+
<h3 className="text-lg font-semibold">Pedido {order.number}</h3>
|
|
143
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
144
|
+
{order.date} · {order.status}
|
|
145
|
+
</p>
|
|
146
|
+
{order.address ? (
|
|
147
|
+
<p className="text-muted-foreground mt-2 text-sm">{order.address}</p>
|
|
148
|
+
) : null}
|
|
149
|
+
<ul className="mt-4 divide-y">
|
|
150
|
+
{order.items.map((item) => (
|
|
151
|
+
<li key={item.id} className="flex items-center justify-between py-2 text-sm">
|
|
152
|
+
<span>
|
|
153
|
+
{item.name}
|
|
154
|
+
{item.variant ? (
|
|
155
|
+
<span className="text-muted-foreground"> · {item.variant}</span>
|
|
156
|
+
) : null}
|
|
157
|
+
<span className="text-muted-foreground"> × {item.quantity}</span>
|
|
158
|
+
</span>
|
|
159
|
+
<span>{item.lineTotal}</span>
|
|
160
|
+
</li>
|
|
161
|
+
))}
|
|
162
|
+
</ul>
|
|
163
|
+
<dl className="mt-4 space-y-1 border-t pt-4 text-sm">
|
|
164
|
+
{totals.map((row) => (
|
|
165
|
+
<div key={row.label} className="flex justify-between">
|
|
166
|
+
<dt className="text-muted-foreground">{row.label}</dt>
|
|
167
|
+
<dd>{row.value}</dd>
|
|
168
|
+
</div>
|
|
169
|
+
))}
|
|
170
|
+
</dl>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export const registry: ViewRegistry = {
|
|
177
|
+
/**
|
|
178
|
+
* Headless bootstrap rendered on the account route. It resolves the current
|
|
179
|
+
* customer from the cookie-authorized Server Action once, so a reload keeps
|
|
180
|
+
* the signed-in state without re-entering credentials. It never reads a token.
|
|
181
|
+
*/
|
|
182
|
+
AccountBoot: () => {
|
|
183
|
+
const { set } = useStateStore()
|
|
184
|
+
const booted = useRef(false)
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
if (booted.current) return
|
|
187
|
+
booted.current = true
|
|
188
|
+
void loadAccountAction()
|
|
189
|
+
.then((result) => {
|
|
190
|
+
if (!result.ok) {
|
|
191
|
+
set('/accountCustomer', null)
|
|
192
|
+
set('/accountAuthenticated', false)
|
|
193
|
+
set('/accountError', result.error)
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
set('/accountCustomer', result.customer)
|
|
197
|
+
set('/accountAuthenticated', result.customer !== null)
|
|
198
|
+
set('/accountError', null)
|
|
199
|
+
})
|
|
200
|
+
.catch((error) => {
|
|
201
|
+
console.error(
|
|
202
|
+
'[storefront/account] loadAccount failed',
|
|
203
|
+
error instanceof Error ? error.message : error,
|
|
204
|
+
)
|
|
205
|
+
set('/accountCustomer', null)
|
|
206
|
+
set('/accountAuthenticated', false)
|
|
207
|
+
set('/accountError', 'No se pudo cargar la cuenta. Inténtalo de nuevo.')
|
|
208
|
+
})
|
|
209
|
+
}, [])
|
|
210
|
+
return null
|
|
211
|
+
},
|
|
212
|
+
AccountLogin: ({ props, emit }) => {
|
|
213
|
+
const { state, set } = useStateStore()
|
|
214
|
+
const parsed = accountLoginPropsSchema.parse(props)
|
|
215
|
+
const error = typeof state.accountError === 'string' ? state.accountError : null
|
|
216
|
+
const pending = Boolean(state.accountPending)
|
|
217
|
+
return (
|
|
218
|
+
<>
|
|
219
|
+
<AuthNotice pending={pending} error={error} />
|
|
220
|
+
<LoginBlock
|
|
221
|
+
logoText={parsed.logoText}
|
|
222
|
+
welcomeTitleLabel={parsed.welcomeTitleLabel}
|
|
223
|
+
welcomeDescriptionLabel={parsed.welcomeDescriptionLabel}
|
|
224
|
+
emailLabel={parsed.emailLabel}
|
|
225
|
+
passwordLabel={parsed.passwordLabel}
|
|
226
|
+
signInLabel={parsed.signInLabel}
|
|
227
|
+
createAccountLabel={parsed.createAccountLabel}
|
|
228
|
+
// The credentials stay in the block's local state and go straight to
|
|
229
|
+
// the Server Action; they are never written to the store.
|
|
230
|
+
onSubmit={(data) => {
|
|
231
|
+
set('/accountPending', true)
|
|
232
|
+
set('/accountError', null)
|
|
233
|
+
void loginAction({ email: data.email, password: data.password })
|
|
234
|
+
.then((result) => applyAuthResult(set, result))
|
|
235
|
+
.catch(authFailure(set, 'login'))
|
|
236
|
+
}}
|
|
237
|
+
onCreateAccount={() => emit('showRegister')}
|
|
238
|
+
/>
|
|
239
|
+
</>
|
|
240
|
+
)
|
|
241
|
+
},
|
|
242
|
+
AccountRegister: ({ props, emit }) => {
|
|
243
|
+
const { state, set } = useStateStore()
|
|
244
|
+
const parsed = accountRegisterPropsSchema.parse(props)
|
|
245
|
+
const error = typeof state.accountError === 'string' ? state.accountError : null
|
|
246
|
+
const pending = Boolean(state.accountPending)
|
|
247
|
+
return (
|
|
248
|
+
<>
|
|
249
|
+
<AuthNotice pending={pending} error={error} />
|
|
250
|
+
<SignUpBlock
|
|
251
|
+
logoText={parsed.logoText}
|
|
252
|
+
createAccountTitleLabel={parsed.createAccountTitleLabel}
|
|
253
|
+
createAccountDescriptionLabel={parsed.createAccountDescriptionLabel}
|
|
254
|
+
fullNameLabel={parsed.fullNameLabel}
|
|
255
|
+
emailLabel={parsed.emailLabel}
|
|
256
|
+
passwordLabel={parsed.passwordLabel}
|
|
257
|
+
createAccountLabel={parsed.createAccountLabel}
|
|
258
|
+
signInLabel={parsed.signInLabel}
|
|
259
|
+
onSubmit={(data) => {
|
|
260
|
+
const parts = data.fullName.trim().split(/\s+/).filter(Boolean)
|
|
261
|
+
set('/accountPending', true)
|
|
262
|
+
set('/accountError', null)
|
|
263
|
+
void registerAction({
|
|
264
|
+
email: data.email,
|
|
265
|
+
password: data.password,
|
|
266
|
+
first_name: parts[0] ?? '',
|
|
267
|
+
last_name: parts.slice(1).join(' '),
|
|
268
|
+
})
|
|
269
|
+
.then((result) => applyAuthResult(set, result))
|
|
270
|
+
.catch(authFailure(set, 'register'))
|
|
271
|
+
}}
|
|
272
|
+
onSignIn={() => emit('showLogin')}
|
|
273
|
+
/>
|
|
274
|
+
</>
|
|
275
|
+
)
|
|
276
|
+
},
|
|
277
|
+
AccountPanel: ({ emit }) => {
|
|
278
|
+
const { state } = useStateStore()
|
|
279
|
+
const customer = (state.accountCustomer ?? null) as Customer | null
|
|
280
|
+
const error = typeof state.accountError === 'string' ? state.accountError : null
|
|
281
|
+
const pending = Boolean(state.accountPending)
|
|
282
|
+
const name = customer
|
|
283
|
+
? [customer.firstName, customer.lastName].filter(Boolean).join(' ') || customer.email
|
|
284
|
+
: ''
|
|
285
|
+
return (
|
|
286
|
+
<div className="mx-auto max-w-md px-4 py-16 text-center">
|
|
287
|
+
<h1 className="text-2xl font-bold">Mi cuenta</h1>
|
|
288
|
+
<p className="text-muted-foreground mt-2 text-sm">Has iniciado sesión como</p>
|
|
289
|
+
<p className="mt-1 text-lg font-medium">{name}</p>
|
|
290
|
+
{customer?.email ? <p className="text-muted-foreground text-sm">{customer.email}</p> : null}
|
|
291
|
+
{error ? (
|
|
292
|
+
<p role="alert" className="text-destructive mt-4 text-sm">
|
|
293
|
+
{error}
|
|
294
|
+
</p>
|
|
295
|
+
) : null}
|
|
296
|
+
<Button className="mt-6" disabled={pending} onClick={() => emit('logout')}>
|
|
297
|
+
{pending ? 'Cerrando sesión…' : 'Cerrar sesión'}
|
|
298
|
+
</Button>
|
|
299
|
+
</div>
|
|
300
|
+
)
|
|
301
|
+
},
|
|
302
|
+
/**
|
|
303
|
+
* Signed-in order history. The first page loads once from the cookie-authorized
|
|
304
|
+
* Server Action; later pages load on demand, each scoped to the authenticated
|
|
305
|
+
* customer's channel. The backend's real `count` drives pagination, so a page
|
|
306
|
+
* length is never presented as the total. Selection is keyed by the REAL
|
|
307
|
+
* backend order id (via a number→id map), so an opened detail stays stable
|
|
308
|
+
* across paging and reload. No token is read, stored, or rendered here.
|
|
309
|
+
*/
|
|
310
|
+
AccountOrders: ({ props }) => {
|
|
311
|
+
const { state, set } = useStateStore()
|
|
312
|
+
const parsed = accountOrdersPropsSchema.parse(props)
|
|
313
|
+
const authenticated = Boolean(state.accountAuthenticated)
|
|
314
|
+
const loadedForAuth = useRef(false)
|
|
315
|
+
|
|
316
|
+
const loadPage = (page: number) => {
|
|
317
|
+
set('/accountOrdersPending', true)
|
|
318
|
+
set('/accountOrdersError', null)
|
|
319
|
+
void loadOrdersPageAction({ page, pageSize: ORDERS_PAGE_SIZE })
|
|
320
|
+
.then((result) => {
|
|
321
|
+
set('/accountOrdersPending', false)
|
|
322
|
+
if (result.ok) {
|
|
323
|
+
set('/accountOrders', result.orders)
|
|
324
|
+
set('/accountOrdersCount', result.count)
|
|
325
|
+
set('/accountOrdersPage', page)
|
|
326
|
+
set('/accountOrdersPageSize', result.limit)
|
|
327
|
+
set('/accountOrdersError', null)
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
set('/accountOrdersError', result.error)
|
|
331
|
+
})
|
|
332
|
+
.catch((error) => {
|
|
333
|
+
console.error(
|
|
334
|
+
'[storefront/account] loadOrders failed',
|
|
335
|
+
error instanceof Error ? error.message : error,
|
|
336
|
+
)
|
|
337
|
+
set('/accountOrdersPending', false)
|
|
338
|
+
set('/accountOrdersError', parsed.errorLabel ?? 'No se pudieron cargar tus pedidos.')
|
|
339
|
+
})
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
useEffect(() => {
|
|
343
|
+
// Load the first page when the customer becomes authenticated, and reload
|
|
344
|
+
// after a re-login, so the history appears without a manual reload. A
|
|
345
|
+
// guest load is never attempted; the flag resets when the session ends.
|
|
346
|
+
if (!authenticated) {
|
|
347
|
+
loadedForAuth.current = false
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
if (loadedForAuth.current) return
|
|
351
|
+
loadedForAuth.current = true
|
|
352
|
+
loadPage(1)
|
|
353
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
354
|
+
}, [authenticated])
|
|
355
|
+
|
|
356
|
+
const pending = Boolean(state.accountOrdersPending)
|
|
357
|
+
const error = typeof state.accountOrdersError === 'string' ? state.accountOrdersError : null
|
|
358
|
+
const orders = Array.isArray(state.accountOrders)
|
|
359
|
+
? (state.accountOrders as AccountOrderView[])
|
|
360
|
+
: []
|
|
361
|
+
const count =
|
|
362
|
+
typeof state.accountOrdersCount === 'number' ? state.accountOrdersCount : orders.length
|
|
363
|
+
const page = typeof state.accountOrdersPage === 'number' ? state.accountOrdersPage : 1
|
|
364
|
+
const pageSize =
|
|
365
|
+
typeof state.accountOrdersPageSize === 'number' && state.accountOrdersPageSize > 0
|
|
366
|
+
? state.accountOrdersPageSize
|
|
367
|
+
: ORDERS_PAGE_SIZE
|
|
368
|
+
const selectedId =
|
|
369
|
+
typeof state.accountSelectedOrderId === 'string' ? state.accountSelectedOrderId : null
|
|
370
|
+
// Selection is keyed by the REAL backend order id, never the display number.
|
|
371
|
+
const selected = selectedId ? orders.find((order) => order.id === selectedId) ?? null : null
|
|
372
|
+
// The block row carries the display number; resolve it back to the backend id.
|
|
373
|
+
const backendIdByBlockId = new Map(orders.map((order) => [order.number, order.id]))
|
|
374
|
+
|
|
375
|
+
if (pending) {
|
|
376
|
+
return (
|
|
377
|
+
<p className="text-muted-foreground mx-auto max-w-7xl px-4 py-8 text-sm">
|
|
378
|
+
{parsed.loadingLabel ?? 'Cargando pedidos…'}
|
|
379
|
+
</p>
|
|
380
|
+
)
|
|
381
|
+
}
|
|
382
|
+
if (error) {
|
|
383
|
+
return (
|
|
384
|
+
<p role="alert" className="text-destructive mx-auto max-w-7xl px-4 py-8 text-sm">
|
|
385
|
+
{error}
|
|
386
|
+
</p>
|
|
387
|
+
)
|
|
388
|
+
}
|
|
389
|
+
if (orders.length === 0) {
|
|
390
|
+
return (
|
|
391
|
+
<p className="text-muted-foreground mx-auto max-w-7xl px-4 py-8 text-sm">
|
|
392
|
+
{parsed.emptyLabel ?? 'Aún no tienes pedidos.'}
|
|
393
|
+
</p>
|
|
394
|
+
)
|
|
395
|
+
}
|
|
396
|
+
const totalPages = Math.max(1, Math.ceil(count / pageSize))
|
|
397
|
+
return (
|
|
398
|
+
<>
|
|
399
|
+
<OrderHistory2
|
|
400
|
+
orders={orders.map(toBlockOrder)}
|
|
401
|
+
titleLabel={parsed.titleLabel}
|
|
402
|
+
descriptionLabel={parsed.descriptionLabel}
|
|
403
|
+
// The block renders "Total de pedidos: {n}" from the PAGE length; pass a
|
|
404
|
+
// label without `{n}` so a page length is never shown as the total.
|
|
405
|
+
totalOrdersLabel={parsed.totalOrdersLabel ?? 'Pedidos'}
|
|
406
|
+
onViewDetails={(order) =>
|
|
407
|
+
set('/accountSelectedOrderId', backendIdByBlockId.get(order.id) ?? null)
|
|
408
|
+
}
|
|
409
|
+
/>
|
|
410
|
+
{count > pageSize ? (
|
|
411
|
+
<div className="mx-auto flex w-full max-w-7xl items-center justify-between gap-4 px-4 pb-6 sm:px-6 lg:px-8">
|
|
412
|
+
<p className="text-muted-foreground text-sm" role="status" aria-live="polite">
|
|
413
|
+
{`${count} pedidos · página ${page} de ${totalPages}`}
|
|
414
|
+
</p>
|
|
415
|
+
<div className="flex gap-2">
|
|
416
|
+
<Button
|
|
417
|
+
variant="outline"
|
|
418
|
+
size="sm"
|
|
419
|
+
disabled={pending || page <= 1}
|
|
420
|
+
onClick={() => loadPage(page - 1)}
|
|
421
|
+
>
|
|
422
|
+
Anterior
|
|
423
|
+
</Button>
|
|
424
|
+
<Button
|
|
425
|
+
variant="outline"
|
|
426
|
+
size="sm"
|
|
427
|
+
disabled={pending || page >= totalPages}
|
|
428
|
+
onClick={() => loadPage(page + 1)}
|
|
429
|
+
>
|
|
430
|
+
Siguiente
|
|
431
|
+
</Button>
|
|
432
|
+
</div>
|
|
433
|
+
</div>
|
|
434
|
+
) : null}
|
|
435
|
+
{selected ? <OrderDetail order={selected} /> : null}
|
|
436
|
+
</>
|
|
437
|
+
)
|
|
438
|
+
},
|
|
439
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Thor Commerce view schema fragment: account
|
|
2
|
+
// Zod mirrors of the block contracts for this view — the single source for the
|
|
3
|
+
// catalog entries and the client-side registry parsing (json-render does not
|
|
4
|
+
// validate props against the catalog itself, so the registry parses here).
|
|
5
|
+
// Kept free of React and server imports so the Server Component module and the
|
|
6
|
+
// 'use client' registry can share one contract. Block zod contracts are
|
|
7
|
+
// mirrored, not imported: @thorprovider/blocks ships zod v3 while the scaffold
|
|
8
|
+
// runs zod v4 and cross-major schema assignment breaks the catalog contract.
|
|
9
|
+
|
|
10
|
+
import { z } from 'zod'
|
|
11
|
+
|
|
12
|
+
// Mirrors the LoginPage1 optional props. Every prop is optional with a neutral
|
|
13
|
+
// default in the block, so the curated view forwards only the labels it owns.
|
|
14
|
+
export const accountLoginPropsSchema = z.object({
|
|
15
|
+
logoText: z.string().optional(),
|
|
16
|
+
welcomeTitleLabel: z.string().optional(),
|
|
17
|
+
welcomeDescriptionLabel: z.string().optional(),
|
|
18
|
+
emailLabel: z.string().optional(),
|
|
19
|
+
passwordLabel: z.string().optional(),
|
|
20
|
+
signInLabel: z.string().optional(),
|
|
21
|
+
createAccountLabel: z.string().optional(),
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
// Mirrors the SignUpPage1 optional props.
|
|
25
|
+
export const accountRegisterPropsSchema = z.object({
|
|
26
|
+
logoText: z.string().optional(),
|
|
27
|
+
createAccountTitleLabel: z.string().optional(),
|
|
28
|
+
createAccountDescriptionLabel: z.string().optional(),
|
|
29
|
+
fullNameLabel: z.string().optional(),
|
|
30
|
+
emailLabel: z.string().optional(),
|
|
31
|
+
passwordLabel: z.string().optional(),
|
|
32
|
+
createAccountLabel: z.string().optional(),
|
|
33
|
+
signInLabel: z.string().optional(),
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// Mirrors the OrderHistory2 label props the curated view forwards, plus the
|
|
37
|
+
// explicit loading/empty/error copy the registry renders for the orders states.
|
|
38
|
+
export const accountOrdersPropsSchema = z.object({
|
|
39
|
+
titleLabel: z.string().optional(),
|
|
40
|
+
descriptionLabel: z.string().optional(),
|
|
41
|
+
totalOrdersLabel: z.string().optional(),
|
|
42
|
+
emptyLabel: z.string().optional(),
|
|
43
|
+
errorLabel: z.string().optional(),
|
|
44
|
+
loadingLabel: z.string().optional(),
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// The headless bootstrap and the signed-in panel read their data from the
|
|
48
|
+
// feature state, not from element props; declared so the catalog is complete.
|
|
49
|
+
export const accountEmptyPropsSchema = z.object({})
|
|
50
|
+
|
|
51
|
+
export type AccountLoginProps = z.infer<typeof accountLoginPropsSchema>
|
|
52
|
+
export type AccountRegisterProps = z.infer<typeof accountRegisterPropsSchema>
|
|
53
|
+
export type AccountOrdersProps = z.infer<typeof accountOrdersPropsSchema>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Thor Commerce view content: cart (user-owned)
|
|
2
|
+
// Merchant-editable copy for the standalone cart review page. Seeded once by the
|
|
3
|
+
// scaffolder and never overwritten on sync.
|
|
4
|
+
|
|
5
|
+
export const cartContent = {
|
|
6
|
+
titleLabel: 'Tu carrito',
|
|
7
|
+
checkoutLabel: 'Finalizar compra',
|
|
8
|
+
continueShoppingLabel: 'Seguir comprando',
|
|
9
|
+
emptyLabel: 'Tu carrito está vacío',
|
|
10
|
+
readErrorLabel: 'No se pudo cargar tu carrito.',
|
|
11
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Thor Commerce view data: cart (user-owned)
|
|
2
|
+
// Pure mapping from the shared host cart state to the ShoppingCart4 block props.
|
|
3
|
+
// No React and no server imports so it can be imported from either boundary.
|
|
4
|
+
|
|
5
|
+
import type { ShoppingCart4Item } from '@thorprovider/blocks'
|
|
6
|
+
import type { CartLineState } from '../../state'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Maps backend cart lines to the cart block's item shape. `price` stays 0 (the
|
|
10
|
+
* legacy numeric field) because the block renders the canonical `lineTotal`
|
|
11
|
+
* Money; an absent image is left undefined rather than serialized as "".
|
|
12
|
+
*/
|
|
13
|
+
export const toCartViewItems = (cart: CartLineState[]): ShoppingCart4Item[] =>
|
|
14
|
+
cart.map(line => ({
|
|
15
|
+
id: line.id,
|
|
16
|
+
name: line.name,
|
|
17
|
+
price: 0,
|
|
18
|
+
quantity: line.quantity,
|
|
19
|
+
image: line.image,
|
|
20
|
+
variant: line.variant,
|
|
21
|
+
lineTotal: line.lineTotal,
|
|
22
|
+
unitPrice: line.unitPrice,
|
|
23
|
+
}))
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Thor Commerce view: cart
|
|
2
|
+
// The standalone cart review route. Its data comes from the shared host cart
|
|
3
|
+
// state injected on every route (readCartStateForRender), so the loader only
|
|
4
|
+
// reports the data source — no dedicated cart fetch happens here.
|
|
5
|
+
|
|
6
|
+
import { getDataSource } from '../../store-context'
|
|
7
|
+
import { pageRoot } from '../../spec/page-root'
|
|
8
|
+
import type { StorefrontElement } from '../../spec/types'
|
|
9
|
+
import type { ViewModule } from '../types'
|
|
10
|
+
import { cartViewProps } from './props'
|
|
11
|
+
import { registry } from './registry'
|
|
12
|
+
import { cartViewPropsSchema } from './schema'
|
|
13
|
+
|
|
14
|
+
const elements: Record<string, StorefrontElement> = {
|
|
15
|
+
'cart.view': {
|
|
16
|
+
type: 'CartView',
|
|
17
|
+
props: cartViewProps,
|
|
18
|
+
on: {
|
|
19
|
+
refreshCart: { action: 'refreshCart' },
|
|
20
|
+
updateQuantity: { action: 'updateQuantity' },
|
|
21
|
+
removeFromCart: { action: 'removeFromCart' },
|
|
22
|
+
goToCheckout: { action: 'goToCheckout' },
|
|
23
|
+
goHome: { action: 'goHome' },
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const catalog = {
|
|
29
|
+
CartView: {
|
|
30
|
+
props: cartViewPropsSchema,
|
|
31
|
+
description: 'Standalone cart review page with backend-authoritative totals.',
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const cart: ViewModule = {
|
|
36
|
+
name: 'cart',
|
|
37
|
+
elements,
|
|
38
|
+
catalog,
|
|
39
|
+
registry,
|
|
40
|
+
route: {
|
|
41
|
+
path: '/cart',
|
|
42
|
+
loaderName: 'cart',
|
|
43
|
+
layout: 'default',
|
|
44
|
+
metadata: {
|
|
45
|
+
title: 'Carrito',
|
|
46
|
+
description: 'Revisa los artículos de tu carrito antes de finalizar la compra.',
|
|
47
|
+
},
|
|
48
|
+
entry: pageRoot('cart', ['cart.view'], elements),
|
|
49
|
+
},
|
|
50
|
+
loader: {
|
|
51
|
+
cart: async () => ({ dataSource: getDataSource(), dataError: null }),
|
|
52
|
+
},
|
|
53
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Thor Commerce view props: cart (user-owned)
|
|
2
|
+
// Element props for the cart review view. The cart lines and backend-authoritative
|
|
3
|
+
// totals are read from the shared host state (injected for every route), so this
|
|
4
|
+
// view needs no dedicated data fetch.
|
|
5
|
+
|
|
6
|
+
import { cartContent } from './content'
|
|
7
|
+
|
|
8
|
+
export const cartViewProps = {
|
|
9
|
+
...cartContent,
|
|
10
|
+
items: { $state: '/cart' },
|
|
11
|
+
totals: { $state: '/cartTotals' },
|
|
12
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
// Thor Commerce view registry: cart
|
|
4
|
+
// Renders the standalone cart review page (page-mode ShoppingCart4) from the
|
|
5
|
+
// shared host cart state. Quantity updates use the ABSOLUTE line quantity keyed
|
|
6
|
+
// by backend line id; removal uses the same line id. Checkout is only offered
|
|
7
|
+
// when the checkout capability is installed and the cart is ready.
|
|
8
|
+
|
|
9
|
+
import { Button, ShoppingCart4 } from '@thorprovider/blocks'
|
|
10
|
+
import { isAvailableMoney } from '@thorprovider/blocks/contracts'
|
|
11
|
+
import { useStateStore } from '@json-render/react'
|
|
12
|
+
import { checkoutAvailable } from '../../composition'
|
|
13
|
+
import type { ViewRegistry } from '../types'
|
|
14
|
+
import { toCartViewItems } from './data'
|
|
15
|
+
import { cartViewPropsSchema } from './schema'
|
|
16
|
+
|
|
17
|
+
export const registry: ViewRegistry = {
|
|
18
|
+
CartView: ({ props, emit }) => {
|
|
19
|
+
const { state, set } = useStateStore()
|
|
20
|
+
const parsed = cartViewPropsSchema.parse(props)
|
|
21
|
+
const cart = parsed.items
|
|
22
|
+
const cartError = typeof state.cartError === 'string' ? state.cartError : undefined
|
|
23
|
+
const readError = typeof state.cartReadError === 'string' ? state.cartReadError : undefined
|
|
24
|
+
const isPending = state.cartPending === true
|
|
25
|
+
const errorLabel = cartError ?? readError
|
|
26
|
+
const totalsState = isPending ? 'pending' : errorLabel ? 'error' : 'idle'
|
|
27
|
+
// Checkout is offered only when the required backend amounts are present and
|
|
28
|
+
// valid: a missing/malformed subtotal, total, or line amount blocks it, while
|
|
29
|
+
// an undetermined shipping amount does not (AC-CART-4.4 / UC-CART-7).
|
|
30
|
+
const amountsValid =
|
|
31
|
+
cart.every((line) => isAvailableMoney(line.lineTotal)) &&
|
|
32
|
+
parsed.totals != null &&
|
|
33
|
+
isAvailableMoney(parsed.totals.subtotal) &&
|
|
34
|
+
isAvailableMoney(parsed.totals.total)
|
|
35
|
+
|
|
36
|
+
// A read failure with no confirmed lines is not an empty cart: surface it as
|
|
37
|
+
// a recoverable error rather than the empty state.
|
|
38
|
+
if (readError && cart.length === 0) {
|
|
39
|
+
return (
|
|
40
|
+
<section className='mx-auto max-w-3xl px-4 py-8'>
|
|
41
|
+
<div role='alert' className='flex flex-col items-start gap-3'>
|
|
42
|
+
<p className='text-destructive text-sm'>{parsed.readErrorLabel ?? readError}</p>
|
|
43
|
+
<Button variant='outline' size='sm' onClick={() => emit('refreshCart')}>
|
|
44
|
+
Reintentar
|
|
45
|
+
</Button>
|
|
46
|
+
</div>
|
|
47
|
+
</section>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<ShoppingCart4
|
|
53
|
+
presentation='page'
|
|
54
|
+
items={toCartViewItems(cart)}
|
|
55
|
+
promotions={[]}
|
|
56
|
+
totals={parsed.totals ?? undefined}
|
|
57
|
+
totalsState={totalsState}
|
|
58
|
+
totalsErrorLabel={errorLabel}
|
|
59
|
+
titleLabel={parsed.titleLabel}
|
|
60
|
+
checkoutLabel={parsed.checkoutLabel}
|
|
61
|
+
continueShoppingLabel={parsed.continueShoppingLabel}
|
|
62
|
+
emptyLabel={parsed.emptyLabel}
|
|
63
|
+
onRetryTotals={() => emit('refreshCart')}
|
|
64
|
+
onQuantityChange={(id, quantity) => {
|
|
65
|
+
const line = cart.find(item => item.id === id)
|
|
66
|
+
if (!line) return
|
|
67
|
+
set('/qtyUpdate', { id, merchandiseId: line.merchandiseId, quantity })
|
|
68
|
+
emit('updateQuantity')
|
|
69
|
+
}}
|
|
70
|
+
onRemove={id => {
|
|
71
|
+
set('/lastRemoved', id)
|
|
72
|
+
emit('removeFromCart')
|
|
73
|
+
}}
|
|
74
|
+
onCheckout={() => emit('goToCheckout')}
|
|
75
|
+
onContinueShopping={() => emit('goHome')}
|
|
76
|
+
checkoutUnavailable={!checkoutAvailable}
|
|
77
|
+
checkoutDisabled={isPending || Boolean(errorLabel) || cart.length === 0 || !amountsValid}
|
|
78
|
+
/>
|
|
79
|
+
)
|
|
80
|
+
},
|
|
81
|
+
}
|