@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
|
@@ -5,115 +5,50 @@
|
|
|
5
5
|
// on the server. Registered by importing it in ../index.ts. Recipe: recipes.json -> "home".
|
|
6
6
|
|
|
7
7
|
import { getCatalog } from '@thorprovider/runtime/commerce'
|
|
8
|
-
import {
|
|
8
|
+
import { getDataSource } from '../../store-context'
|
|
9
9
|
import { pageRoot } from '../../spec/page-root'
|
|
10
10
|
import type { StorefrontElement } from '../../spec/types'
|
|
11
11
|
import type { ViewModule } from '../types'
|
|
12
12
|
import { registry } from './registry'
|
|
13
|
-
import {
|
|
13
|
+
import { benefitsProps, categoriesProps, heroProps } from './props'
|
|
14
|
+
import { selectHeroProducts, toNavCategories } from './data'
|
|
15
|
+
import { toCategoryCard, toFeaturedProduct } from '../../mappers'
|
|
16
|
+
import {
|
|
17
|
+
categoryCardsPropsSchema,
|
|
18
|
+
featurePropsSchema,
|
|
19
|
+
storefrontHeroPropsSchema,
|
|
20
|
+
} from './schema'
|
|
14
21
|
|
|
22
|
+
// The element tree is derived wiring; the rendered props come from the
|
|
23
|
+
// user-owned ./props.ts module so they survive sync and unit removal.
|
|
15
24
|
const elements: Record<string, StorefrontElement> = {
|
|
16
25
|
'home.hero': {
|
|
17
26
|
type: 'StorefrontHero',
|
|
18
|
-
props:
|
|
19
|
-
badge: 'Bienvenido',
|
|
20
|
-
title: '__SITE_NAME__',
|
|
21
|
-
titleAccent: 'para cada proyecto',
|
|
22
|
-
description: 'Productos seleccionados con precios justos y entrega a tu puerta.',
|
|
23
|
-
products: { $state: '/heroProducts' },
|
|
24
|
-
primaryCta: { label: 'Comprar ahora' },
|
|
25
|
-
secondaryCta: { label: 'Ver categorías' },
|
|
26
|
-
},
|
|
27
|
+
props: heroProps,
|
|
27
28
|
on: { addToCart: { action: 'addToCart' }, openProduct: { action: 'openProduct' } },
|
|
28
29
|
},
|
|
29
30
|
'home.benefits': {
|
|
30
31
|
type: 'Feature',
|
|
31
|
-
props:
|
|
32
|
-
badge: '__SITE_NAME__',
|
|
33
|
-
title: 'Compra con confianza',
|
|
34
|
-
description: 'Beneficios pensados para que tu compra llegue segura y a tiempo.',
|
|
35
|
-
items: [
|
|
36
|
-
{
|
|
37
|
-
id: 'entrega',
|
|
38
|
-
title: 'Entrega segura',
|
|
39
|
-
description: 'Seguimiento de tu pedido hasta la puerta de tu casa.',
|
|
40
|
-
icon: 'truck',
|
|
41
|
-
},
|
|
42
|
-
{
|
|
43
|
-
id: 'pago',
|
|
44
|
-
title: 'Pago protegido',
|
|
45
|
-
description: 'Compra con confianza, múltiples opciones de pago.',
|
|
46
|
-
icon: 'shield-check',
|
|
47
|
-
},
|
|
48
|
-
{
|
|
49
|
-
id: 'soporte',
|
|
50
|
-
title: 'Soporte local',
|
|
51
|
-
description: 'Atención personalizada en todo el país.',
|
|
52
|
-
icon: 'headphones',
|
|
53
|
-
},
|
|
54
|
-
],
|
|
55
|
-
},
|
|
32
|
+
props: benefitsProps,
|
|
56
33
|
},
|
|
57
34
|
'home.categories': {
|
|
58
35
|
type: 'CategoryCards',
|
|
59
|
-
props:
|
|
60
|
-
title: 'Compra por categoría',
|
|
61
|
-
description: 'Explora nuestro catálogo completo.',
|
|
62
|
-
categories: { $state: '/categories' },
|
|
63
|
-
},
|
|
36
|
+
props: categoriesProps,
|
|
64
37
|
on: { openCategory: { action: 'openCategory' } },
|
|
65
38
|
},
|
|
66
39
|
}
|
|
67
40
|
|
|
68
|
-
const ctaSchema = z.object({ label: z.string(), href: z.string().optional() })
|
|
69
|
-
|
|
70
|
-
// Mirrors the StorefrontHero block contract (FeaturedProduct) so a product-shape
|
|
71
|
-
// mismatch is a type error at the catalog boundary, not a runtime crash. The
|
|
72
|
-
// block requires every field — including `colors` — to be defined at render.
|
|
73
|
-
const featuredProductSchema = z.object({
|
|
74
|
-
id: z.string(),
|
|
75
|
-
name: z.string(),
|
|
76
|
-
brand: z.string(),
|
|
77
|
-
price: z.number(),
|
|
78
|
-
originalPrice: z.number(),
|
|
79
|
-
rating: z.number(),
|
|
80
|
-
reviewCount: z.number(),
|
|
81
|
-
inStock: z.boolean(),
|
|
82
|
-
isNew: z.boolean(),
|
|
83
|
-
isOnSale: z.boolean(),
|
|
84
|
-
image: z.string(),
|
|
85
|
-
colors: z.array(z.string()),
|
|
86
|
-
})
|
|
87
|
-
|
|
88
41
|
const catalog = {
|
|
89
42
|
StorefrontHero: {
|
|
90
|
-
props:
|
|
91
|
-
title: z.string(),
|
|
92
|
-
description: z.string(),
|
|
93
|
-
titleAccent: z.string().optional(),
|
|
94
|
-
badge: z.string().optional(),
|
|
95
|
-
products: z.array(featuredProductSchema),
|
|
96
|
-
primaryCta: ctaSchema.optional(),
|
|
97
|
-
secondaryCta: ctaSchema.optional(),
|
|
98
|
-
}),
|
|
43
|
+
props: storefrontHeroPropsSchema,
|
|
99
44
|
description: 'Landing hero.',
|
|
100
45
|
},
|
|
101
46
|
Feature: {
|
|
102
|
-
props:
|
|
103
|
-
items: z.array(z.record(z.string(), z.unknown())),
|
|
104
|
-
badge: z.string().optional(),
|
|
105
|
-
title: z.string().optional(),
|
|
106
|
-
description: z.string().optional(),
|
|
107
|
-
}),
|
|
47
|
+
props: featurePropsSchema,
|
|
108
48
|
description: 'Benefit highlights.',
|
|
109
49
|
},
|
|
110
50
|
CategoryCards: {
|
|
111
|
-
props:
|
|
112
|
-
title: z.string().optional(),
|
|
113
|
-
description: z.string().optional(),
|
|
114
|
-
categories: z.array(z.record(z.string(), z.unknown())),
|
|
115
|
-
cta: ctaSchema.optional(),
|
|
116
|
-
}),
|
|
51
|
+
props: categoryCardsPropsSchema,
|
|
117
52
|
description: 'Category grid.',
|
|
118
53
|
},
|
|
119
54
|
}
|
|
@@ -135,8 +70,27 @@ export const home: ViewModule = {
|
|
|
135
70
|
},
|
|
136
71
|
loader: {
|
|
137
72
|
home: async () => {
|
|
138
|
-
|
|
139
|
-
|
|
73
|
+
try {
|
|
74
|
+
const { products, categories, source } = await getCatalog()
|
|
75
|
+
return {
|
|
76
|
+
heroProducts: selectHeroProducts(products).map(toFeaturedProduct),
|
|
77
|
+
categories: categories.map(toCategoryCard),
|
|
78
|
+
navCategories: toNavCategories(categories),
|
|
79
|
+
dataSource: source,
|
|
80
|
+
dataError: null,
|
|
81
|
+
}
|
|
82
|
+
} catch (error) {
|
|
83
|
+
// A real-mode failure is surfaced as a visible error, never as fictional
|
|
84
|
+
// data. Mock mode is labelled by `dataSource`.
|
|
85
|
+
return {
|
|
86
|
+
heroProducts: [],
|
|
87
|
+
categories: [],
|
|
88
|
+
navCategories: [],
|
|
89
|
+
dataSource: getDataSource(),
|
|
90
|
+
dataError:
|
|
91
|
+
error instanceof Error ? error.message : 'No se pudo cargar el catálogo.',
|
|
92
|
+
}
|
|
93
|
+
}
|
|
140
94
|
},
|
|
141
95
|
},
|
|
142
96
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Thor Commerce view props: home
|
|
2
|
+
// User-owned element props for the home view. Reconcile seeds this file once and
|
|
3
|
+
// never rewrites it; the derived view module (./index.tsx) imports these objects
|
|
4
|
+
// and binds them into the element tree, so edits here survive `storefront sync`
|
|
5
|
+
// and unit removal. Plain data only: no React and no server imports.
|
|
6
|
+
|
|
7
|
+
import { benefitsContent, categoriesContent, heroContent, heroImage } from './content'
|
|
8
|
+
|
|
9
|
+
export const heroProps = {
|
|
10
|
+
...heroContent,
|
|
11
|
+
products: { $state: '/heroProducts' },
|
|
12
|
+
heroImage,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const benefitsProps = {
|
|
16
|
+
...benefitsContent,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const categoriesProps = {
|
|
20
|
+
...categoriesContent,
|
|
21
|
+
categories: { $state: '/categories' },
|
|
22
|
+
}
|
|
@@ -5,31 +5,43 @@
|
|
|
5
5
|
// @json-render/react and components from @thorprovider/blocks — both must stay
|
|
6
6
|
// on the client side of the RSC graph. Kept separate from index.tsx so the view
|
|
7
7
|
// spec (elements/catalog/route/loader) stays a pure Server Component module.
|
|
8
|
+
//
|
|
9
|
+
// Props arrive as untyped spec data: every entry parses them through the view's
|
|
10
|
+
// schema (./schema.ts) before binding, so a contract mismatch fails loudly at
|
|
11
|
+
// the boundary instead of rendering undefined fields or literal icon tags.
|
|
8
12
|
|
|
9
13
|
import {
|
|
10
14
|
Feature1 as FeatureBlock,
|
|
11
15
|
ProductCategory2 as CategoryCardsBlock,
|
|
12
16
|
StorefrontHero1 as StorefrontHeroBlock,
|
|
13
17
|
} from '@thorprovider/blocks'
|
|
14
|
-
import type { FeaturedProduct } from '@thorprovider/blocks'
|
|
15
18
|
import { useStateStore } from '@json-render/react'
|
|
19
|
+
import { resolveIcon } from '../../icon-map'
|
|
16
20
|
import type { ViewRegistry } from '../types'
|
|
21
|
+
import {
|
|
22
|
+
categoryCardsPropsSchema,
|
|
23
|
+
featurePropsSchema,
|
|
24
|
+
storefrontHeroPropsSchema,
|
|
25
|
+
} from './schema'
|
|
17
26
|
|
|
18
27
|
export const registry: ViewRegistry = {
|
|
19
28
|
StorefrontHero: ({ props, emit }) => {
|
|
20
29
|
const { set } = useStateStore()
|
|
30
|
+
const hero = storefrontHeroPropsSchema.parse(props)
|
|
21
31
|
return (
|
|
22
32
|
<StorefrontHeroBlock
|
|
23
|
-
title={
|
|
24
|
-
description={
|
|
25
|
-
products={
|
|
26
|
-
titleAccent={
|
|
27
|
-
badge={
|
|
28
|
-
primaryCta={
|
|
29
|
-
secondaryCta={
|
|
33
|
+
title={hero.title}
|
|
34
|
+
description={hero.description}
|
|
35
|
+
products={hero.products}
|
|
36
|
+
titleAccent={hero.titleAccent}
|
|
37
|
+
badge={hero.badge}
|
|
38
|
+
primaryCta={hero.primaryCta}
|
|
39
|
+
secondaryCta={hero.secondaryCta}
|
|
40
|
+
heroImage={hero.heroImage}
|
|
30
41
|
onAddToCart={(product) => {
|
|
42
|
+
// No first-variant shortcut: open the detail so a variant is chosen.
|
|
31
43
|
set('/lastClicked', product)
|
|
32
|
-
emit('
|
|
44
|
+
emit('openProduct')
|
|
33
45
|
}}
|
|
34
46
|
onBuyNow={(product) => {
|
|
35
47
|
set('/lastClicked', product)
|
|
@@ -39,24 +51,28 @@ export const registry: ViewRegistry = {
|
|
|
39
51
|
/>
|
|
40
52
|
)
|
|
41
53
|
},
|
|
42
|
-
Feature: ({ props }) =>
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
54
|
+
Feature: ({ props }) => {
|
|
55
|
+
const feature = featurePropsSchema.parse(props)
|
|
56
|
+
return (
|
|
57
|
+
<FeatureBlock
|
|
58
|
+
items={feature.items.map((item) => ({ ...item, icon: resolveIcon(item.icon) }))}
|
|
59
|
+
badge={feature.badge}
|
|
60
|
+
title={feature.title}
|
|
61
|
+
description={feature.description}
|
|
62
|
+
/>
|
|
63
|
+
)
|
|
64
|
+
},
|
|
50
65
|
CategoryCards: ({ props, emit }) => {
|
|
51
66
|
const { set } = useStateStore()
|
|
67
|
+
const cards = categoryCardsPropsSchema.parse(props)
|
|
52
68
|
return (
|
|
53
69
|
<CategoryCardsBlock
|
|
54
|
-
title={
|
|
55
|
-
description={
|
|
56
|
-
categories={
|
|
57
|
-
cta={
|
|
70
|
+
title={cards.title}
|
|
71
|
+
description={cards.description}
|
|
72
|
+
categories={cards.categories}
|
|
73
|
+
cta={cards.cta}
|
|
58
74
|
onBrowse={(category) => {
|
|
59
|
-
set('/lastCategory',
|
|
75
|
+
set('/lastCategory', category.handle ?? String(category.id))
|
|
60
76
|
emit('openCategory')
|
|
61
77
|
}}
|
|
62
78
|
/>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Thor Commerce view schema fragment: home
|
|
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.
|
|
7
|
+
|
|
8
|
+
import { z } from 'zod'
|
|
9
|
+
|
|
10
|
+
export const ctaSchema = z.object({
|
|
11
|
+
label: z.string(),
|
|
12
|
+
href: z.string().optional(),
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
// Mirrors the StorefrontHero block contract (FeaturedProduct): the block
|
|
16
|
+
// requires every field — including `colors` — to be defined at render.
|
|
17
|
+
export const featuredProductSchema = z.object({
|
|
18
|
+
id: z.string(),
|
|
19
|
+
name: z.string(),
|
|
20
|
+
brand: z.string(),
|
|
21
|
+
price: z.number(),
|
|
22
|
+
originalPrice: z.number(),
|
|
23
|
+
rating: z.number(),
|
|
24
|
+
reviewCount: z.number(),
|
|
25
|
+
inStock: z.boolean(),
|
|
26
|
+
isNew: z.boolean(),
|
|
27
|
+
isOnSale: z.boolean(),
|
|
28
|
+
image: z.string(),
|
|
29
|
+
colors: z.array(z.string()),
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// Mirrors the Feature1 block contract; `icon` is a NAME here and only becomes
|
|
33
|
+
// a component when the registry resolves it through the shared icon map.
|
|
34
|
+
export const featureItemSchema = z.object({
|
|
35
|
+
id: z.string(),
|
|
36
|
+
title: z.string(),
|
|
37
|
+
description: z.string(),
|
|
38
|
+
icon: z.string(),
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// Mirrors the ProductCategory2 block contract (ProductCategory2Category).
|
|
42
|
+
// `itemCount` is optional: when the backend count is unknown the projection
|
|
43
|
+
// omits it (the block renders no count) rather than fabricating a `0`. A real
|
|
44
|
+
// `0` is preserved.
|
|
45
|
+
export const categoryCardSchema = z.object({
|
|
46
|
+
id: z.string(),
|
|
47
|
+
handle: z.string().optional(),
|
|
48
|
+
name: z.string(),
|
|
49
|
+
description: z.string(),
|
|
50
|
+
itemCount: z.number().optional(),
|
|
51
|
+
image: z.string(),
|
|
52
|
+
trending: z.boolean(),
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
// Optional editorial hero image (mirrors the StorefrontHero block contract).
|
|
56
|
+
// Rendered as a native `<img>` only when `src` is a non-empty string; omitted
|
|
57
|
+
// entirely when absent, so a blank `src` is never emitted.
|
|
58
|
+
export const heroImageSchema = z.object({
|
|
59
|
+
src: z.string(),
|
|
60
|
+
alt: z.string(),
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
export const storefrontHeroPropsSchema = z.object({
|
|
64
|
+
title: z.string(),
|
|
65
|
+
description: z.string(),
|
|
66
|
+
titleAccent: z.string().optional(),
|
|
67
|
+
badge: z.string().optional(),
|
|
68
|
+
products: z.array(featuredProductSchema),
|
|
69
|
+
primaryCta: ctaSchema.optional(),
|
|
70
|
+
secondaryCta: ctaSchema.optional(),
|
|
71
|
+
heroImage: heroImageSchema.optional(),
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
export const featurePropsSchema = z.object({
|
|
75
|
+
items: z.array(featureItemSchema),
|
|
76
|
+
badge: z.string().optional(),
|
|
77
|
+
title: z.string().optional(),
|
|
78
|
+
description: z.string().optional(),
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
export const categoryCardsPropsSchema = z.object({
|
|
82
|
+
title: z.string().optional(),
|
|
83
|
+
description: z.string().optional(),
|
|
84
|
+
categories: z.array(categoryCardSchema),
|
|
85
|
+
cta: ctaSchema.optional(),
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
export type FeaturedProduct = z.infer<typeof featuredProductSchema>
|
|
89
|
+
export type FeatureItem = z.infer<typeof featureItemSchema>
|
|
90
|
+
export type CategoryCard = z.infer<typeof categoryCardSchema>
|
|
91
|
+
export type StorefrontHeroProps = z.infer<typeof storefrontHeroPropsSchema>
|
|
92
|
+
export type FeatureProps = z.infer<typeof featurePropsSchema>
|
|
93
|
+
export type CategoryCardsProps = z.infer<typeof categoryCardsPropsSchema>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Thor Commerce view content: product
|
|
2
|
+
// User-owned view content — the labels rendered by the product overview. No
|
|
3
|
+
// reviews are seeded: reviews are rendered only when the backend supplies real
|
|
4
|
+
// ones, so this file must never carry fabricated review data. Reconcile seeds
|
|
5
|
+
// this file once and never rewrites it; the derived view module (./index.tsx)
|
|
6
|
+
// imports it and wires it into the element tree, so your edits here survive
|
|
7
|
+
// `storefront sync` and unit removal. Plain data only: no React and no server
|
|
8
|
+
// imports.
|
|
9
|
+
|
|
10
|
+
export const productLabels = {
|
|
11
|
+
sizesLabel: 'Variantes',
|
|
12
|
+
variantLabel: 'Variante',
|
|
13
|
+
reviewsLabel: 'Reseñas',
|
|
14
|
+
addToCartLabel: 'Agregar al carrito',
|
|
15
|
+
outOfStockLabel: 'Agotado',
|
|
16
|
+
unavailablePriceLabel: 'Precio no disponible',
|
|
17
|
+
selectVariantLabel: 'Selecciona una variante',
|
|
18
|
+
quantityLabel: 'Cantidad',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Copy for the reviews region. It renders only when the backend supplies real
|
|
22
|
+
// reviews (`/hasProductReviews`); the list itself is never seeded here.
|
|
23
|
+
export const reviewsCopy = {
|
|
24
|
+
pageTitle: 'Opiniones de clientes',
|
|
25
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Thor Commerce view data: product
|
|
2
|
+
// User-owned loader shaping for the product 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
|
+
import type { CatalogProductDetail } from '@thorprovider/runtime/commerce'
|
|
8
|
+
import type { ProductDetailProduct } from '@thorprovider/blocks'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Map the runtime's canonical product detail (real variants, options,
|
|
12
|
+
* availability, Money) to the ProductOverview1 block contract.
|
|
13
|
+
*
|
|
14
|
+
* Price availability is preserved, not collapsed: a variant whose backend price
|
|
15
|
+
* is unresolved carries `price: null` (the block treats that as unavailable and
|
|
16
|
+
* disables purchase), while a backend-resolved valid zero stays `"0"`.
|
|
17
|
+
*/
|
|
18
|
+
export function buildSelectedProduct(
|
|
19
|
+
detail: CatalogProductDetail | null | undefined,
|
|
20
|
+
): ProductDetailProduct | null {
|
|
21
|
+
if (!detail) return null
|
|
22
|
+
return {
|
|
23
|
+
id: detail.id,
|
|
24
|
+
handle: detail.handle,
|
|
25
|
+
title: detail.title,
|
|
26
|
+
description: detail.description,
|
|
27
|
+
options: detail.options,
|
|
28
|
+
variants: detail.variants.map((variant) => ({
|
|
29
|
+
id: variant.id,
|
|
30
|
+
title: variant.title,
|
|
31
|
+
sku: variant.sku,
|
|
32
|
+
availableForSale: variant.availableForSale,
|
|
33
|
+
selectedOptions: variant.options,
|
|
34
|
+
price: variant.price.status === 'available' ? variant.price.money : null,
|
|
35
|
+
image: variant.image,
|
|
36
|
+
})),
|
|
37
|
+
images: detail.images,
|
|
38
|
+
featuredImage: detail.featuredImage,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* No first-variant shortcut: nothing is selected until the shopper explicitly
|
|
44
|
+
* chooses a variant, so `onAddToCart` can never silently add the first one.
|
|
45
|
+
*/
|
|
46
|
+
export function initialSelectedVariantId(): string | null {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Thor Commerce view module: product
|
|
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 -> "product".
|
|
6
|
+
|
|
7
|
+
import { notFound } from 'next/navigation'
|
|
8
|
+
import { loadProductDetail } from '../../commerce'
|
|
9
|
+
import { getDataSource } from '../../store-context'
|
|
10
|
+
import { pageRoot } from '../../spec/page-root'
|
|
11
|
+
import type { StorefrontElement } from '../../spec/types'
|
|
12
|
+
import type { ViewModule } from '../types'
|
|
13
|
+
import { registry } from './registry'
|
|
14
|
+
import { productOverviewProps, productReviewsProps } from './props'
|
|
15
|
+
import { buildSelectedProduct, initialSelectedVariantId } from './data'
|
|
16
|
+
import { productOverviewPropsSchema, reviewsPropsSchema } from './schema'
|
|
17
|
+
|
|
18
|
+
// The element tree is derived wiring; the rendered props come from the
|
|
19
|
+
// user-owned ./props.ts module so they survive sync and unit removal.
|
|
20
|
+
const elements: Record<string, StorefrontElement> = {
|
|
21
|
+
'product.overview': {
|
|
22
|
+
type: 'ProductOverview',
|
|
23
|
+
visible: { $state: '/selectedProduct' },
|
|
24
|
+
props: productOverviewProps,
|
|
25
|
+
on: { addToCart: { action: 'addToCart' } },
|
|
26
|
+
},
|
|
27
|
+
// Rendered only when the backend supplies real reviews; the list is never
|
|
28
|
+
// seeded, so an unsupplied review is not shown and no placeholder is invented.
|
|
29
|
+
'product.reviews': {
|
|
30
|
+
type: 'Reviews',
|
|
31
|
+
visible: { $state: '/hasProductReviews' },
|
|
32
|
+
props: productReviewsProps,
|
|
33
|
+
on: {
|
|
34
|
+
submitReview: { action: 'submitReview' },
|
|
35
|
+
toggleReviewLike: { action: 'toggleReviewLike' },
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const catalog = {
|
|
41
|
+
ProductOverview: {
|
|
42
|
+
props: productOverviewPropsSchema,
|
|
43
|
+
description: 'Detalle de producto: galería, precio, variantes y añadir al carrito.',
|
|
44
|
+
},
|
|
45
|
+
Reviews: {
|
|
46
|
+
props: reviewsPropsSchema,
|
|
47
|
+
description: 'Opiniones de clientes con resumen de valoración.',
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const product: ViewModule = {
|
|
52
|
+
name: 'product',
|
|
53
|
+
elements,
|
|
54
|
+
catalog,
|
|
55
|
+
registry,
|
|
56
|
+
route: {
|
|
57
|
+
path: '/productos/[id]',
|
|
58
|
+
loaderName: 'product',
|
|
59
|
+
layout: 'default',
|
|
60
|
+
metadata: {
|
|
61
|
+
title: 'Producto',
|
|
62
|
+
description: 'Detalles del producto seleccionado.',
|
|
63
|
+
},
|
|
64
|
+
entry: pageRoot('product', ['product.overview', 'product.reviews'], elements),
|
|
65
|
+
},
|
|
66
|
+
loader: {
|
|
67
|
+
product: async (params) => {
|
|
68
|
+
const handle = Array.isArray(params.id) ? params.id[0] : params.id
|
|
69
|
+
const dataSource = getDataSource()
|
|
70
|
+
|
|
71
|
+
let result: Awaited<ReturnType<typeof loadProductDetail>>
|
|
72
|
+
try {
|
|
73
|
+
result = await loadProductDetail(String(handle))
|
|
74
|
+
} catch (error) {
|
|
75
|
+
// A real data-source failure is surfaced visibly (never demo data).
|
|
76
|
+
return {
|
|
77
|
+
selectedProduct: null,
|
|
78
|
+
selectedVariantId: null,
|
|
79
|
+
productQuantity: 1,
|
|
80
|
+
productReviews: [],
|
|
81
|
+
hasProductReviews: false,
|
|
82
|
+
dataSource,
|
|
83
|
+
dataError:
|
|
84
|
+
error instanceof Error ? error.message : 'No se pudo cargar el producto.',
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// An unknown handle is a real 404 (the catch-all page renders not-found);
|
|
89
|
+
// it is never a demo product.
|
|
90
|
+
if (!result.detail) notFound()
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
selectedProduct: buildSelectedProduct(result.detail),
|
|
94
|
+
// No first-variant shortcut: the shopper must select explicitly.
|
|
95
|
+
selectedVariantId: initialSelectedVariantId(),
|
|
96
|
+
productQuantity: 1,
|
|
97
|
+
productReviews: [],
|
|
98
|
+
hasProductReviews: false,
|
|
99
|
+
dataSource: result.source,
|
|
100
|
+
dataError: null,
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Thor Commerce view props: product
|
|
2
|
+
// User-owned element props for the product 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 { productLabels, reviewsCopy } from './content'
|
|
9
|
+
|
|
10
|
+
export const productOverviewProps = {
|
|
11
|
+
...productLabels,
|
|
12
|
+
product: { $state: '/selectedProduct' },
|
|
13
|
+
selectedVariantId: { $state: '/selectedVariantId' },
|
|
14
|
+
quantity: { $state: '/productQuantity' },
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const productReviewsProps = {
|
|
18
|
+
...reviewsCopy,
|
|
19
|
+
reviews: { $state: '/productReviews' },
|
|
20
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
// Thor Commerce view registry fragment: product
|
|
4
|
+
// Client boundary for the product 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 {
|
|
10
|
+
ProductOverview1 as ProductOverviewBlock,
|
|
11
|
+
ReviewRating1 as ReviewsBlock,
|
|
12
|
+
} from '@thorprovider/blocks'
|
|
13
|
+
import { isAvailableMoney } from '@thorprovider/blocks/contracts'
|
|
14
|
+
import { useStateStore } from '@json-render/react'
|
|
15
|
+
import type { ViewRegistry } from '../types'
|
|
16
|
+
import { productOverviewPropsSchema, reviewsPropsSchema } from './schema'
|
|
17
|
+
|
|
18
|
+
export const registry: ViewRegistry = {
|
|
19
|
+
ProductOverview: ({ props, emit }) => {
|
|
20
|
+
const { set } = useStateStore()
|
|
21
|
+
const parsed = productOverviewPropsSchema.parse(props)
|
|
22
|
+
return (
|
|
23
|
+
<ProductOverviewBlock
|
|
24
|
+
product={parsed.product}
|
|
25
|
+
selectedVariantId={parsed.selectedVariantId ?? null}
|
|
26
|
+
quantity={parsed.quantity}
|
|
27
|
+
sizesLabel={parsed.sizesLabel}
|
|
28
|
+
variantLabel={parsed.variantLabel}
|
|
29
|
+
reviewsLabel={parsed.reviewsLabel}
|
|
30
|
+
addToCartLabel={parsed.addToCartLabel}
|
|
31
|
+
outOfStockLabel={parsed.outOfStockLabel}
|
|
32
|
+
unavailablePriceLabel={parsed.unavailablePriceLabel}
|
|
33
|
+
selectVariantLabel={parsed.selectVariantLabel}
|
|
34
|
+
quantityLabel={parsed.quantityLabel}
|
|
35
|
+
onSelectVariant={(variantId) => {
|
|
36
|
+
set('/selectedVariantId', variantId)
|
|
37
|
+
}}
|
|
38
|
+
onQuantityChange={(quantity) => {
|
|
39
|
+
set('/productQuantity', quantity)
|
|
40
|
+
}}
|
|
41
|
+
onAddToCart={(variantId, quantity) => {
|
|
42
|
+
// Preserve the selected backend variant id and quantity the block
|
|
43
|
+
// emits — the handler must never drop them or substitute the SKU.
|
|
44
|
+
const variant = parsed.product.variants.find((candidate) => candidate.id === variantId)
|
|
45
|
+
const price = isAvailableMoney(variant?.price) ? Number(variant.price.amount) : 0
|
|
46
|
+
set('/lastAddToCart', {
|
|
47
|
+
variantId,
|
|
48
|
+
quantity,
|
|
49
|
+
name: parsed.product.title,
|
|
50
|
+
variant: variant?.title ?? '',
|
|
51
|
+
price,
|
|
52
|
+
image: variant?.image?.url ?? parsed.product.featuredImage?.url,
|
|
53
|
+
})
|
|
54
|
+
emit('addToCart')
|
|
55
|
+
}}
|
|
56
|
+
onToggleWishlist={() => {}}
|
|
57
|
+
/>
|
|
58
|
+
)
|
|
59
|
+
},
|
|
60
|
+
Reviews: ({ props, emit }) => {
|
|
61
|
+
const parsed = reviewsPropsSchema.parse(props)
|
|
62
|
+
return (
|
|
63
|
+
<ReviewsBlock
|
|
64
|
+
pageTitle={parsed.pageTitle}
|
|
65
|
+
pageDescription={parsed.pageDescription}
|
|
66
|
+
reviews={parsed.reviews}
|
|
67
|
+
form={parsed.form}
|
|
68
|
+
reviewsPerPage={parsed.reviewsPerPage}
|
|
69
|
+
onToggleReviewLike={() => {
|
|
70
|
+
emit('toggleReviewLike')
|
|
71
|
+
}}
|
|
72
|
+
onSubmitReview={() => {
|
|
73
|
+
emit('submitReview')
|
|
74
|
+
}}
|
|
75
|
+
/>
|
|
76
|
+
)
|
|
77
|
+
},
|
|
78
|
+
}
|