@thorprovider/storefront 0.4.0-mvp.0 → 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 +173 -153
- 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 +42 -1
- 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 +1 -5
- package/assets/templates/app/app/robots.ts +9 -3
- package/assets/templates/app/app/sitemap.ts +64 -5
- package/assets/templates/app/lib/__STOREFRONT__/actions.ts +70 -0
- package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +15 -5
- package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +71 -0
- package/assets/templates/app/lib/__STOREFRONT__/composition.ts +26 -0
- package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +36 -4
- package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +82 -50
- package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +3 -0
- package/assets/templates/app/lib/__STOREFRONT__/schema.ts +29 -14
- 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__/shell/content.ts +31 -7
- package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +4 -0
- package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +56 -26
- package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +165 -0
- package/assets/templates/app/lib/__STOREFRONT__/store-context.ts +26 -7
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/index.ts +2 -1
- 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.products.ts +39 -0
- package/assets/templates/features/checkout/handlers.ts +6 -0
- package/assets/templates/features/customer-auth/state.ts +10 -1
- package/assets/templates/views/account/index.tsx +1 -1
- package/assets/templates/views/account/registry.tsx +80 -19
- package/assets/templates/views/account/schema.ts +1 -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 +1 -0
- package/assets/templates/views/category/assembly/index.skeleton.tsx +64 -6
- package/assets/templates/views/category/assembly/registry.skeleton.tsx +2 -1
- 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/index.tsx +76 -7
- package/assets/templates/views/category/props.ts +7 -0
- package/assets/templates/views/category/registry.tsx +19 -0
- package/assets/templates/views/category/schema.ts +13 -0
- package/assets/templates/views/home/assembly/assembly.json +9 -0
- package/assets/templates/views/home/assembly/index.skeleton.tsx +22 -4
- package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/element.part +1 -1
- package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/registry.part +2 -2
- package/assets/templates/views/home/data.ts +9 -0
- package/assets/templates/views/home/index.tsx +23 -5
- package/assets/templates/views/home/registry.tsx +2 -2
- package/assets/templates/views/product/data.ts +4 -2
- package/assets/templates/views/product/index.tsx +49 -5
- package/assets/templates/views/product/props.ts +7 -0
- package/assets/templates/views/product/registry.tsx +25 -0
- package/assets/templates/views/product/schema.ts +13 -0
- package/assets/templates/views/static/content.ts +4 -4
- package/assets/templates/views/static/index.tsx +1 -1
- package/bin/storefront.js +1231 -154
- package/examples/merchandising/category-led.json +37 -0
- package/examples/merchandising/collection-led.json +37 -0
- package/package.json +11 -7
- package/recipes/archetypes.json +9 -22
- package/recipes/catalog.json +40 -1
- package/recipes/features.json +25 -2
- package/recipes/recipe.schema.json +1 -1
- package/recipes/recipes.json +32 -30
- package/recipes/sections.json +10 -0
- package/recipes/validate.mjs +54 -0
- package/scripts/candidate.mjs +4 -4
- package/scripts/composers.mjs +25 -5
- package/scripts/env-file.mjs +86 -0
- package/scripts/file-classes.mjs +5 -0
- package/scripts/migrate-english-routes.mjs +276 -0
- package/scripts/pinning.mjs +74 -5
- package/scripts/plan-apply.mjs +149 -5
- package/scripts/reconcile.mjs +49 -11
- package/scripts/render-templates.mjs +97 -44
- package/scripts/section-config.mjs +253 -0
- package/scripts/starter.mjs +17 -0
- 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 +47 -1
- package/skills/sf-feature/SKILL.md +32 -19
- package/skills/sf-setup/SKILL.md +79 -0
- package/skills/sf-theme-gen/SKILL.md +99 -36
- 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 +63 -40
- package/skills/sf-view-gen/assets/recipes/archetypes.json +9 -22
- package/skills/sf-view-gen/assets/recipes/catalog.json +40 -1
- package/skills/sf-view-gen/assets/recipes/features.json +25 -2
- package/skills/sf-view-gen/assets/recipes/recipes.json +32 -30
- package/skills/sf-view-gen/assets/recipes/sections.json +10 -0
- 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.products.ts +39 -0
- 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 +28 -37
- package/skills/sf-view-gen/scripts/validate.mjs +54 -0
- package/skills/storefront-workflow/SKILL.md +94 -31
- package/skills/{storefront-best-practices/reference/design.md → storefront-workflow/references/design-consistency.md} +13 -29
- package/CHANGELOG.md +0 -84
- package/assets/templates/views/user-panel/index.tsx +0 -106
- package/assets/templates/views/user-panel/registry.tsx +0 -54
- package/assets/templates/views/user-panel/schema.ts +0 -79
- package/commands/sf-add-view.md +0 -57
- package/commands/sf-feature.md +0 -28
- package/commands/sf-theme.md +0 -26
- package/commands/sf-view.md +0 -67
- 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/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
|
@@ -24,13 +24,13 @@ import {
|
|
|
24
24
|
mapCatalogResultsToFilterProps,
|
|
25
25
|
} from '@thorprovider/runtime/commerce'
|
|
26
26
|
import { getDataSource, getStoreContext, isRealMode } from '../../store-context'
|
|
27
|
-
import {
|
|
27
|
+
import { mountedFeatureConfigs, mountedFeatureNames, mountedFeatures } from '../../composition'
|
|
28
28
|
import { pageRoot } from '../../spec/page-root'
|
|
29
29
|
import type { StorefrontElement } from '../../spec/types'
|
|
30
30
|
import type { ViewModule } from '../types'
|
|
31
31
|
import { registry } from './registry'
|
|
32
|
-
import { categoryFilterProps } from './props'
|
|
33
|
-
import { categoryFilterPropsSchema } from './schema'
|
|
32
|
+
import { categoryBreadcrumbProps, categoryFilterProps } from './props'
|
|
33
|
+
import { breadcrumbPropsSchema, categoryFilterPropsSchema } from './schema'
|
|
34
34
|
|
|
35
35
|
// The single controlled `CategoryFilter2` base listing. The search/filter
|
|
36
36
|
// controls are shown only when the matching feature is installed; the base
|
|
@@ -38,6 +38,10 @@ import { categoryFilterPropsSchema } from './schema'
|
|
|
38
38
|
// and pagination.
|
|
39
39
|
const hasSearch = mountedFeatures.includes('catalog-search')
|
|
40
40
|
const hasFilters = mountedFeatures.includes('category-filters')
|
|
41
|
+
// The navigability feature contributes the entity-derived breadcrumb trail. When
|
|
42
|
+
// it is not installed the trail is empty and the Breadcrumb block renders
|
|
43
|
+
// nothing, so removal leaves no dangling navigation aid.
|
|
44
|
+
const hasNavigability = mountedFeatureNames.includes('navigability')
|
|
41
45
|
const searchConfig = (mountedFeatureConfigs['catalog-search'] ?? {}) as { placeholder?: string }
|
|
42
46
|
const filtersConfig = (mountedFeatureConfigs['category-filters'] ?? {}) as {
|
|
43
47
|
filterSet?: string[]
|
|
@@ -53,6 +57,13 @@ const SORT_LABELS: Record<string, string> = {
|
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
const elements: Record<string, StorefrontElement> = {
|
|
60
|
+
'category.breadcrumb': {
|
|
61
|
+
type: 'Breadcrumb',
|
|
62
|
+
props: {
|
|
63
|
+
...categoryBreadcrumbProps,
|
|
64
|
+
items: { $state: '/breadcrumbItems' },
|
|
65
|
+
},
|
|
66
|
+
},
|
|
56
67
|
'category.filter': {
|
|
57
68
|
type: 'CategoryFilter2',
|
|
58
69
|
props: {
|
|
@@ -90,6 +101,10 @@ const elements: Record<string, StorefrontElement> = {
|
|
|
90
101
|
}
|
|
91
102
|
|
|
92
103
|
const catalog = {
|
|
104
|
+
Breadcrumb: {
|
|
105
|
+
props: breadcrumbPropsSchema,
|
|
106
|
+
description: 'Migas de pan derivadas de la jerarquía real de categorías.',
|
|
107
|
+
},
|
|
93
108
|
CategoryFilter2: {
|
|
94
109
|
props: categoryFilterPropsSchema,
|
|
95
110
|
description:
|
|
@@ -112,22 +127,74 @@ const emptyFilterState = () => ({
|
|
|
112
127
|
categoryFilterCategories: [],
|
|
113
128
|
categoryFilterCollections: [],
|
|
114
129
|
categoryFilterPriceRange: undefined,
|
|
130
|
+
breadcrumbItems: [] as { label: string; href?: string }[],
|
|
115
131
|
})
|
|
116
132
|
|
|
133
|
+
/**
|
|
134
|
+
* The real category entity for the current route scope, resolved from the
|
|
135
|
+
* backend catalog. Entity SEO reads this; an unknown/absent scope yields no
|
|
136
|
+
* entity (no metadata or structured data is fabricated).
|
|
137
|
+
*/
|
|
138
|
+
function resolveCategoryEntity(
|
|
139
|
+
slug: string | undefined,
|
|
140
|
+
catalog: Awaited<ReturnType<typeof getCatalog>>,
|
|
141
|
+
): { handle: string; name: string; description?: string } | undefined {
|
|
142
|
+
if (!slug) return undefined
|
|
143
|
+
const match = catalog.categoriesRaw.find((entry) => entry.handle === slug)
|
|
144
|
+
if (!match) return undefined
|
|
145
|
+
const description =
|
|
146
|
+
typeof match.description === 'string' && match.description.trim() !== ''
|
|
147
|
+
? match.description
|
|
148
|
+
: undefined
|
|
149
|
+
return {
|
|
150
|
+
handle: match.handle,
|
|
151
|
+
name: match.name,
|
|
152
|
+
...(description !== undefined ? { description } : {}),
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Entity-derived breadcrumb trail for the current category scope: Home, the
|
|
158
|
+
* real ancestor chain (root -> leaf), and the current category. Derived from
|
|
159
|
+
* the backend category hierarchy (`parentCategoryId`); no level is fabricated,
|
|
160
|
+
* a cycle cannot loop forever, and no trail is produced when the navigability
|
|
161
|
+
* feature is not installed or the scope is unknown.
|
|
162
|
+
*/
|
|
163
|
+
function resolveBreadcrumbItems(
|
|
164
|
+
slug: string | undefined,
|
|
165
|
+
catalog: Awaited<ReturnType<typeof getCatalog>>,
|
|
166
|
+
): { label: string; href?: string }[] {
|
|
167
|
+
if (!hasNavigability || !slug) return []
|
|
168
|
+
const byHandle = new Map(catalog.categoriesRaw.map((entry) => [entry.handle, entry]))
|
|
169
|
+
const byId = new Map(catalog.categoriesRaw.map((entry) => [entry.id, entry]))
|
|
170
|
+
const chain: { label: string; href: string }[] = []
|
|
171
|
+
const visited = new Set<string>()
|
|
172
|
+
let current = byHandle.get(slug)
|
|
173
|
+
while (current && !visited.has(current.handle)) {
|
|
174
|
+
visited.add(current.handle)
|
|
175
|
+
chain.unshift({ label: current.name, href: `/categories/${current.handle}` })
|
|
176
|
+
current = current.parentCategoryId ? byId.get(current.parentCategoryId) : undefined
|
|
177
|
+
}
|
|
178
|
+
if (chain.length === 0) return []
|
|
179
|
+
const leaf = chain[chain.length - 1]
|
|
180
|
+
// The current (leaf) item is not a link; only ancestors are navigable.
|
|
181
|
+
return [{ label: 'Inicio', href: '/' }, ...chain.slice(0, -1), { label: leaf.label }]
|
|
182
|
+
}
|
|
183
|
+
|
|
117
184
|
export const category: ViewModule = {
|
|
118
185
|
name: 'category',
|
|
119
186
|
elements,
|
|
120
187
|
catalog,
|
|
121
188
|
registry,
|
|
122
189
|
route: {
|
|
123
|
-
path: '/
|
|
190
|
+
path: '/categories/[[...slug]]',
|
|
124
191
|
loaderName: 'category',
|
|
125
192
|
layout: 'default',
|
|
126
193
|
metadata: {
|
|
127
194
|
title: 'Categoría',
|
|
128
195
|
description: 'Explora los productos de esta categoría en __SITE_NAME__.',
|
|
129
196
|
},
|
|
130
|
-
entry: pageRoot('category', ['category.filter'], elements),
|
|
197
|
+
entry: pageRoot('category', ['category.breadcrumb', 'category.filter'], elements),
|
|
131
198
|
},
|
|
132
199
|
loader: {
|
|
133
200
|
category: async (params, searchParams) => {
|
|
@@ -181,13 +248,13 @@ export const category: ViewModule = {
|
|
|
181
248
|
const presentation = mapCatalogResultsToFilterProps({
|
|
182
249
|
query: listing.query,
|
|
183
250
|
results: listing.results,
|
|
184
|
-
// A scoped listing (`/
|
|
251
|
+
// A scoped listing (`/categories/<handle>`) must not offer the
|
|
185
252
|
// category facet: the scope already fixes the category. The root
|
|
186
253
|
// catalog passes no scope and keeps it.
|
|
187
254
|
...(slug ? { categoryScope: slug } : {}),
|
|
188
255
|
// Product links point at the product-detail route. The runtime stays
|
|
189
256
|
// route-agnostic, so this view supplies the href builder.
|
|
190
|
-
productHref: (handle: string) => `/
|
|
257
|
+
productHref: (handle: string) => `/products/${handle}`,
|
|
191
258
|
...(categories ? { categories } : {}),
|
|
192
259
|
...(collections ? { collections } : {}),
|
|
193
260
|
sortOptions,
|
|
@@ -199,6 +266,8 @@ export const category: ViewModule = {
|
|
|
199
266
|
|
|
200
267
|
return {
|
|
201
268
|
storeCategories: catalog.categories,
|
|
269
|
+
categoryEntity: resolveCategoryEntity(slug, catalog),
|
|
270
|
+
breadcrumbItems: resolveBreadcrumbItems(slug, catalog),
|
|
202
271
|
dataSource: catalog.source,
|
|
203
272
|
dataError: null,
|
|
204
273
|
categoryFilterProducts: presentation.products,
|
|
@@ -14,3 +14,10 @@ import { filterContent } from './content'
|
|
|
14
14
|
export const categoryFilterProps = {
|
|
15
15
|
...filterContent,
|
|
16
16
|
}
|
|
17
|
+
|
|
18
|
+
// Presentational defaults for the breadcrumb; the trail itself is bound to
|
|
19
|
+
// `$state` from the loader (entity-derived, never invented).
|
|
20
|
+
export const categoryBreadcrumbProps = {
|
|
21
|
+
ariaLabel: 'Migas de pan',
|
|
22
|
+
backLabel: 'Volver',
|
|
23
|
+
}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
type CategoryFilter2Filters,
|
|
16
16
|
type CategoryFilter2Props,
|
|
17
17
|
} from '@thorprovider/blocks'
|
|
18
|
+
import { Breadcrumb as BreadcrumbBlock } from '@thorprovider/blocks'
|
|
18
19
|
import { buildCategoryQueryHref, catalogHistoryMode, type CategoryQueryChange } from '@thorprovider/runtime/commerce'
|
|
19
20
|
import type { CatalogFeatureName } from '@thorprovider/runtime/contracts'
|
|
20
21
|
import { useStateStore } from '@json-render/react'
|
|
@@ -29,6 +30,24 @@ type CategoryFilter2RegistryProps = CategoryFilter2Props & {
|
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
export const registry: ViewRegistry = {
|
|
33
|
+
Breadcrumb: ({ props }) => {
|
|
34
|
+
const p = props as {
|
|
35
|
+
items: { label: string; href?: string }[]
|
|
36
|
+
maxItems?: number
|
|
37
|
+
ariaLabel?: string
|
|
38
|
+
backLabel?: string
|
|
39
|
+
className?: string
|
|
40
|
+
}
|
|
41
|
+
return (
|
|
42
|
+
<BreadcrumbBlock
|
|
43
|
+
items={p.items}
|
|
44
|
+
maxItems={p.maxItems}
|
|
45
|
+
ariaLabel={p.ariaLabel}
|
|
46
|
+
backLabel={p.backLabel}
|
|
47
|
+
className={p.className}
|
|
48
|
+
/>
|
|
49
|
+
)
|
|
50
|
+
},
|
|
32
51
|
CategoryFilter2: ({ props, emit }) => {
|
|
33
52
|
const router = useRouter()
|
|
34
53
|
const pathname = usePathname()
|
|
@@ -75,4 +75,17 @@ export const categoryFilterPropsSchema = z
|
|
|
75
75
|
})
|
|
76
76
|
.catchall(z.unknown())
|
|
77
77
|
|
|
78
|
+
export const breadcrumbItemSchema = z.object({
|
|
79
|
+
label: z.string(),
|
|
80
|
+
href: z.string().optional(),
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
export const breadcrumbPropsSchema = z.object({
|
|
84
|
+
items: z.array(breadcrumbItemSchema),
|
|
85
|
+
maxItems: z.number().int().min(2).optional(),
|
|
86
|
+
ariaLabel: z.string().optional(),
|
|
87
|
+
backLabel: z.string().optional(),
|
|
88
|
+
className: z.string().optional(),
|
|
89
|
+
})
|
|
90
|
+
|
|
78
91
|
export type CategoryFilterProps = z.infer<typeof categoryFilterPropsSchema>
|
|
@@ -19,6 +19,15 @@
|
|
|
19
19
|
"registryExport": "ctaRegistry",
|
|
20
20
|
"propsModule": "./content",
|
|
21
21
|
"defaultProps": "{}"
|
|
22
|
+
},
|
|
23
|
+
"products": {
|
|
24
|
+
"elementType": "Products",
|
|
25
|
+
"registryModule": "../sections/products.client",
|
|
26
|
+
"registryExport": "productsRegistry",
|
|
27
|
+
"propsModule": "./content",
|
|
28
|
+
"defaultProps": "{}",
|
|
29
|
+
"dataBound": true,
|
|
30
|
+
"defaultVariant": "ProductCard1"
|
|
22
31
|
}
|
|
23
32
|
}
|
|
24
33
|
}
|
|
@@ -5,14 +5,15 @@
|
|
|
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 { getDataSource } from '../../store-context'
|
|
8
|
+
import { getDataSource, getStoreContext } 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
13
|
import { benefitsProps, categoriesProps, heroProps } from './props'
|
|
14
|
-
import
|
|
14
|
+
import * as homeData from './data'
|
|
15
15
|
import { toCategoryCard, toFeaturedProduct } from '../../mappers'
|
|
16
|
+
import { resolveSectionSources, resolveSectionViewStates } from '../../section-sources'
|
|
16
17
|
import {
|
|
17
18
|
categoryCardsPropsSchema,
|
|
18
19
|
featurePropsSchema,
|
|
@@ -46,14 +47,30 @@ export const home: ViewModule = {
|
|
|
46
47
|
},
|
|
47
48
|
loader: {
|
|
48
49
|
home: async () => {
|
|
50
|
+
// Data-bound sections resolve independently through the canonical catalog
|
|
51
|
+
// bridge; equal queries share request-local work and differing contexts
|
|
52
|
+
// stay isolated. A section failure never erases its siblings, and it is
|
|
53
|
+
// resolved separately so a catalog failure still yields truthful
|
|
54
|
+
// per-section state instead of a missing one.
|
|
55
|
+
let sections: Record<string, unknown> = {}
|
|
56
|
+
try {
|
|
57
|
+
sections = await resolveSectionViewStates(
|
|
58
|
+
@@SECTION_LOADERS@@,
|
|
59
|
+
resolveSectionSources(homeData),
|
|
60
|
+
getStoreContext(),
|
|
61
|
+
)
|
|
62
|
+
} catch {
|
|
63
|
+
sections = {}
|
|
64
|
+
}
|
|
49
65
|
try {
|
|
50
66
|
const { products, categories, source } = await getCatalog()
|
|
51
67
|
return {
|
|
52
|
-
heroProducts: selectHeroProducts(products).map(toFeaturedProduct),
|
|
68
|
+
heroProducts: homeData.selectHeroProducts(products).map(toFeaturedProduct),
|
|
53
69
|
categories: categories.map(toCategoryCard),
|
|
54
|
-
navCategories: toNavCategories(categories),
|
|
70
|
+
navCategories: homeData.toNavCategories(categories),
|
|
55
71
|
dataSource: source,
|
|
56
72
|
dataError: null,
|
|
73
|
+
sections,
|
|
57
74
|
}
|
|
58
75
|
} catch (error) {
|
|
59
76
|
// A real-mode failure is surfaced as a visible error, never as fictional
|
|
@@ -65,6 +82,7 @@ export const home: ViewModule = {
|
|
|
65
82
|
dataSource: getDataSource(),
|
|
66
83
|
dataError:
|
|
67
84
|
error instanceof Error ? error.message : 'No se pudo cargar el catálogo.',
|
|
85
|
+
sections,
|
|
68
86
|
}
|
|
69
87
|
}
|
|
70
88
|
},
|
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
secondaryCta={hero.secondaryCta}
|
|
13
13
|
heroImage={hero.heroImage}
|
|
14
14
|
onAddToCart={(product) => {
|
|
15
|
-
//
|
|
15
|
+
// Product-card shortcut: add the first purchasable variant.
|
|
16
16
|
set('/lastClicked', product)
|
|
17
|
-
emit('
|
|
17
|
+
emit('addClickedToCart')
|
|
18
18
|
}}
|
|
19
19
|
onBuyNow={(product) => {
|
|
20
20
|
set('/lastClicked', product)
|
|
@@ -13,3 +13,12 @@ export function toNavCategories(
|
|
|
13
13
|
): { label: string; href: string }[] {
|
|
14
14
|
return categories.map((category) => ({ label: category.label, href: category.href }))
|
|
15
15
|
}
|
|
16
|
+
|
|
17
|
+
// Per-instance selection for data-bound sections (for example `products`), keyed
|
|
18
|
+
// by the section's stable id from the composition manifest. Each value is a
|
|
19
|
+
// canonical catalog selection (category/collection/search/ordering) that the
|
|
20
|
+
// generated host loader resolves through the runtime catalog bridge. Left empty
|
|
21
|
+
// by default: a data-bound section with no entry renders an `unconfigured`
|
|
22
|
+
// state. Entries whose id is no longer referenced by the composition are kept
|
|
23
|
+
// but never queried, so re-adding the section with the same id restores them.
|
|
24
|
+
export const sectionSources: Record<string, unknown> = {}
|
|
@@ -5,14 +5,15 @@
|
|
|
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 { getDataSource } from '../../store-context'
|
|
8
|
+
import { getDataSource, getStoreContext } 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
13
|
import { benefitsProps, categoriesProps, heroProps } from './props'
|
|
14
|
-
import
|
|
14
|
+
import * as homeData from './data'
|
|
15
15
|
import { toCategoryCard, toFeaturedProduct } from '../../mappers'
|
|
16
|
+
import { resolveSectionSources, resolveSectionViewStates } from '../../section-sources'
|
|
16
17
|
import {
|
|
17
18
|
categoryCardsPropsSchema,
|
|
18
19
|
featurePropsSchema,
|
|
@@ -25,7 +26,7 @@ const elements: Record<string, StorefrontElement> = {
|
|
|
25
26
|
'home.hero': {
|
|
26
27
|
type: 'StorefrontHero',
|
|
27
28
|
props: heroProps,
|
|
28
|
-
on: {
|
|
29
|
+
on: { addClickedToCart: { action: 'addClickedToCart' }, openProduct: { action: 'openProduct' } },
|
|
29
30
|
},
|
|
30
31
|
'home.benefits': {
|
|
31
32
|
type: 'Feature',
|
|
@@ -70,14 +71,30 @@ export const home: ViewModule = {
|
|
|
70
71
|
},
|
|
71
72
|
loader: {
|
|
72
73
|
home: async () => {
|
|
74
|
+
// Data-bound sections resolve independently through the canonical catalog
|
|
75
|
+
// bridge; equal queries share request-local work and differing contexts
|
|
76
|
+
// stay isolated. A section failure never erases its siblings, and it is
|
|
77
|
+
// resolved separately so a catalog failure still yields truthful
|
|
78
|
+
// per-section state instead of a missing one.
|
|
79
|
+
let sections: Record<string, unknown> = {}
|
|
80
|
+
try {
|
|
81
|
+
sections = await resolveSectionViewStates(
|
|
82
|
+
[],
|
|
83
|
+
resolveSectionSources(homeData),
|
|
84
|
+
getStoreContext(),
|
|
85
|
+
)
|
|
86
|
+
} catch {
|
|
87
|
+
sections = {}
|
|
88
|
+
}
|
|
73
89
|
try {
|
|
74
90
|
const { products, categories, source } = await getCatalog()
|
|
75
91
|
return {
|
|
76
|
-
heroProducts: selectHeroProducts(products).map(toFeaturedProduct),
|
|
92
|
+
heroProducts: homeData.selectHeroProducts(products).map(toFeaturedProduct),
|
|
77
93
|
categories: categories.map(toCategoryCard),
|
|
78
|
-
navCategories: toNavCategories(categories),
|
|
94
|
+
navCategories: homeData.toNavCategories(categories),
|
|
79
95
|
dataSource: source,
|
|
80
96
|
dataError: null,
|
|
97
|
+
sections,
|
|
81
98
|
}
|
|
82
99
|
} catch (error) {
|
|
83
100
|
// A real-mode failure is surfaced as a visible error, never as fictional
|
|
@@ -89,6 +106,7 @@ export const home: ViewModule = {
|
|
|
89
106
|
dataSource: getDataSource(),
|
|
90
107
|
dataError:
|
|
91
108
|
error instanceof Error ? error.message : 'No se pudo cargar el catálogo.',
|
|
109
|
+
sections,
|
|
92
110
|
}
|
|
93
111
|
}
|
|
94
112
|
},
|
|
@@ -39,9 +39,9 @@ export const registry: ViewRegistry = {
|
|
|
39
39
|
secondaryCta={hero.secondaryCta}
|
|
40
40
|
heroImage={hero.heroImage}
|
|
41
41
|
onAddToCart={(product) => {
|
|
42
|
-
//
|
|
42
|
+
// Product-card shortcut: add the first purchasable variant.
|
|
43
43
|
set('/lastClicked', product)
|
|
44
|
-
emit('
|
|
44
|
+
emit('addClickedToCart')
|
|
45
45
|
}}
|
|
46
46
|
onBuyNow={(product) => {
|
|
47
47
|
set('/lastClicked', product)
|
|
@@ -40,8 +40,10 @@ export function buildSelectedProduct(
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
* No first-variant shortcut: nothing is selected
|
|
44
|
-
* chooses a variant, so `onAddToCart`
|
|
43
|
+
* No first-variant shortcut on the product detail surface: nothing is selected
|
|
44
|
+
* until the shopper explicitly chooses a variant, so the detail's `onAddToCart`
|
|
45
|
+
* can never silently add the first one. Listing cards use the separate
|
|
46
|
+
* first-purchasable-variant shortcut instead.
|
|
45
47
|
*/
|
|
46
48
|
export function initialSelectedVariantId(): string | null {
|
|
47
49
|
return null
|
|
@@ -7,17 +7,29 @@
|
|
|
7
7
|
import { notFound } from 'next/navigation'
|
|
8
8
|
import { loadProductDetail } from '../../commerce'
|
|
9
9
|
import { getDataSource } from '../../store-context'
|
|
10
|
+
import { mountedFeatureNames } from '../../composition'
|
|
10
11
|
import { pageRoot } from '../../spec/page-root'
|
|
11
12
|
import type { StorefrontElement } from '../../spec/types'
|
|
12
13
|
import type { ViewModule } from '../types'
|
|
13
14
|
import { registry } from './registry'
|
|
14
|
-
import { productOverviewProps, productReviewsProps } from './props'
|
|
15
|
+
import { productBreadcrumbProps, productOverviewProps, productReviewsProps } from './props'
|
|
15
16
|
import { buildSelectedProduct, initialSelectedVariantId } from './data'
|
|
16
|
-
import { productOverviewPropsSchema, reviewsPropsSchema } from './schema'
|
|
17
|
+
import { breadcrumbPropsSchema, productOverviewPropsSchema, reviewsPropsSchema } from './schema'
|
|
17
18
|
|
|
18
19
|
// The element tree is derived wiring; the rendered props come from the
|
|
19
20
|
// user-owned ./props.ts module so they survive sync and unit removal.
|
|
20
21
|
const elements: Record<string, StorefrontElement> = {
|
|
22
|
+
// The navigability feature contributes the entity-derived breadcrumb trail.
|
|
23
|
+
// The element is always wired; the loader returns an empty trail when the
|
|
24
|
+
// feature is not installed or the backend supplies no category, so the block
|
|
25
|
+
// renders nothing and removal leaves no dangling navigation aid.
|
|
26
|
+
'product.breadcrumb': {
|
|
27
|
+
type: 'Breadcrumb',
|
|
28
|
+
props: {
|
|
29
|
+
...productBreadcrumbProps,
|
|
30
|
+
items: { $state: '/breadcrumbItems' },
|
|
31
|
+
},
|
|
32
|
+
},
|
|
21
33
|
'product.overview': {
|
|
22
34
|
type: 'ProductOverview',
|
|
23
35
|
visible: { $state: '/selectedProduct' },
|
|
@@ -38,6 +50,10 @@ const elements: Record<string, StorefrontElement> = {
|
|
|
38
50
|
}
|
|
39
51
|
|
|
40
52
|
const catalog = {
|
|
53
|
+
Breadcrumb: {
|
|
54
|
+
props: breadcrumbPropsSchema,
|
|
55
|
+
description: 'Migas de pan derivadas de la jerarquía real de categorías del producto.',
|
|
56
|
+
},
|
|
41
57
|
ProductOverview: {
|
|
42
58
|
props: productOverviewPropsSchema,
|
|
43
59
|
description: 'Detalle de producto: galería, precio, variantes y añadir al carrito.',
|
|
@@ -48,20 +64,42 @@ const catalog = {
|
|
|
48
64
|
},
|
|
49
65
|
}
|
|
50
66
|
|
|
67
|
+
const hasNavigability = mountedFeatureNames.includes('navigability')
|
|
68
|
+
|
|
69
|
+
// Entity-derived trail only: the product's real ancestor categories. When the
|
|
70
|
+
// feature is absent or the backend supplies no category, the trail is empty and
|
|
71
|
+
// the Breadcrumb block renders nothing (no level is ever invented).
|
|
72
|
+
function resolveProductBreadcrumbItems(
|
|
73
|
+
title: string,
|
|
74
|
+
categoryPath: { handle: string; name: string }[] | undefined,
|
|
75
|
+
): { label: string; href?: string }[] {
|
|
76
|
+
if (!hasNavigability || !categoryPath || categoryPath.length === 0) return []
|
|
77
|
+
const chain = categoryPath.map((entry) => ({
|
|
78
|
+
label: entry.name,
|
|
79
|
+
href: `/categories/${entry.handle}`,
|
|
80
|
+
}))
|
|
81
|
+
// Home > ancestor categories (links) > the product itself (current page).
|
|
82
|
+
return [{ label: 'Inicio', href: '/' }, ...chain, { label: title }]
|
|
83
|
+
}
|
|
84
|
+
|
|
51
85
|
export const product: ViewModule = {
|
|
52
86
|
name: 'product',
|
|
53
87
|
elements,
|
|
54
88
|
catalog,
|
|
55
89
|
registry,
|
|
56
90
|
route: {
|
|
57
|
-
path: '/
|
|
91
|
+
path: '/products/[id]',
|
|
58
92
|
loaderName: 'product',
|
|
59
93
|
layout: 'default',
|
|
60
94
|
metadata: {
|
|
61
95
|
title: 'Producto',
|
|
62
96
|
description: 'Detalles del producto seleccionado.',
|
|
63
97
|
},
|
|
64
|
-
entry: pageRoot(
|
|
98
|
+
entry: pageRoot(
|
|
99
|
+
'product',
|
|
100
|
+
['product.breadcrumb', 'product.overview', 'product.reviews'],
|
|
101
|
+
elements,
|
|
102
|
+
),
|
|
65
103
|
},
|
|
66
104
|
loader: {
|
|
67
105
|
product: async (params) => {
|
|
@@ -79,6 +117,7 @@ export const product: ViewModule = {
|
|
|
79
117
|
productQuantity: 1,
|
|
80
118
|
productReviews: [],
|
|
81
119
|
hasProductReviews: false,
|
|
120
|
+
breadcrumbItems: [],
|
|
82
121
|
dataSource,
|
|
83
122
|
dataError:
|
|
84
123
|
error instanceof Error ? error.message : 'No se pudo cargar el producto.',
|
|
@@ -91,11 +130,16 @@ export const product: ViewModule = {
|
|
|
91
130
|
|
|
92
131
|
return {
|
|
93
132
|
selectedProduct: buildSelectedProduct(result.detail),
|
|
94
|
-
// No first-variant shortcut: the shopper must
|
|
133
|
+
// No first-variant shortcut on the detail surface: the shopper must
|
|
134
|
+
// select explicitly (listing cards use the card shortcut instead).
|
|
95
135
|
selectedVariantId: initialSelectedVariantId(),
|
|
96
136
|
productQuantity: 1,
|
|
97
137
|
productReviews: [],
|
|
98
138
|
hasProductReviews: false,
|
|
139
|
+
breadcrumbItems: resolveProductBreadcrumbItems(
|
|
140
|
+
result.detail.title,
|
|
141
|
+
result.detail.categoryPath,
|
|
142
|
+
),
|
|
99
143
|
dataSource: result.source,
|
|
100
144
|
dataError: null,
|
|
101
145
|
}
|
|
@@ -18,3 +18,10 @@ export const productReviewsProps = {
|
|
|
18
18
|
...reviewsCopy,
|
|
19
19
|
reviews: { $state: '/productReviews' },
|
|
20
20
|
}
|
|
21
|
+
|
|
22
|
+
// Presentational defaults only; the entity-derived trail is bound to
|
|
23
|
+
// `$state: '/productBreadcrumbItems'` in the element tree, never invented here.
|
|
24
|
+
export const productBreadcrumbProps = {
|
|
25
|
+
ariaLabel: 'Migas de pan',
|
|
26
|
+
backLabel: 'Volver',
|
|
27
|
+
}
|
|
@@ -7,18 +7,43 @@
|
|
|
7
7
|
// spec (elements/catalog/route/loader) stays a pure Server Component module.
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
|
+
Breadcrumb as BreadcrumbBlock,
|
|
10
11
|
ProductOverview1 as ProductOverviewBlock,
|
|
11
12
|
ReviewRating1 as ReviewsBlock,
|
|
12
13
|
} from '@thorprovider/blocks'
|
|
13
14
|
import { isAvailableMoney } from '@thorprovider/blocks/contracts'
|
|
14
15
|
import { useStateStore } from '@json-render/react'
|
|
16
|
+
import { useProductVisitObservation } from '../../section-observation'
|
|
15
17
|
import type { ViewRegistry } from '../types'
|
|
16
18
|
import { productOverviewPropsSchema, reviewsPropsSchema } from './schema'
|
|
17
19
|
|
|
18
20
|
export const registry: ViewRegistry = {
|
|
21
|
+
Breadcrumb: ({ props }) => {
|
|
22
|
+
const p = props as {
|
|
23
|
+
items: { label: string; href?: string }[]
|
|
24
|
+
maxItems?: number
|
|
25
|
+
ariaLabel?: string
|
|
26
|
+
backLabel?: string
|
|
27
|
+
className?: string
|
|
28
|
+
}
|
|
29
|
+
return (
|
|
30
|
+
<BreadcrumbBlock
|
|
31
|
+
items={p.items}
|
|
32
|
+
maxItems={p.maxItems}
|
|
33
|
+
ariaLabel={p.ariaLabel}
|
|
34
|
+
backLabel={p.backLabel}
|
|
35
|
+
className={p.className}
|
|
36
|
+
/>
|
|
37
|
+
)
|
|
38
|
+
},
|
|
19
39
|
ProductOverview: ({ props, emit }) => {
|
|
20
40
|
const { set } = useStateStore()
|
|
21
41
|
const parsed = productOverviewPropsSchema.parse(props)
|
|
42
|
+
// A successfully rendered product destination: recorded only when it matches
|
|
43
|
+
// the section origin (disabled by default; never attributes a guess). The
|
|
44
|
+
// stable public identity is the handle, which is what section clicks record.
|
|
45
|
+
const productHandle = parsed.product.handle ?? parsed.product.id
|
|
46
|
+
useProductVisitObservation(productHandle, `/products/${productHandle}`)
|
|
22
47
|
return (
|
|
23
48
|
<ProductOverviewBlock
|
|
24
49
|
product={parsed.product}
|
|
@@ -101,6 +101,19 @@ export const reviewsPropsSchema = z.object({
|
|
|
101
101
|
reviewsPerPage: z.number().optional(),
|
|
102
102
|
})
|
|
103
103
|
|
|
104
|
+
export const breadcrumbItemSchema = z.object({
|
|
105
|
+
label: z.string(),
|
|
106
|
+
href: z.string().optional(),
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
export const breadcrumbPropsSchema = z.object({
|
|
110
|
+
items: z.array(breadcrumbItemSchema),
|
|
111
|
+
maxItems: z.number().int().min(2).optional(),
|
|
112
|
+
ariaLabel: z.string().optional(),
|
|
113
|
+
backLabel: z.string().optional(),
|
|
114
|
+
className: z.string().optional(),
|
|
115
|
+
})
|
|
116
|
+
|
|
104
117
|
export type ProductDetailProductSpec = z.infer<typeof productDetailProductSchema>
|
|
105
118
|
export type ProductOverviewProps = z.infer<typeof productOverviewPropsSchema>
|
|
106
119
|
export type Review = z.infer<typeof reviewSchema>
|
|
@@ -13,7 +13,7 @@ import type { StaticPageContent } from './schema'
|
|
|
13
13
|
|
|
14
14
|
export const staticPagesContent: StaticPageContent[] = [
|
|
15
15
|
{
|
|
16
|
-
path: '/
|
|
16
|
+
path: '/contact',
|
|
17
17
|
title: 'Contacto',
|
|
18
18
|
description: 'Cómo comunicarte con la tienda.',
|
|
19
19
|
sections: [
|
|
@@ -33,7 +33,7 @@ export const staticPagesContent: StaticPageContent[] = [
|
|
|
33
33
|
],
|
|
34
34
|
},
|
|
35
35
|
{
|
|
36
|
-
path: '/
|
|
36
|
+
path: '/terms',
|
|
37
37
|
title: 'Términos y condiciones',
|
|
38
38
|
description: 'Condiciones de uso de la tienda en línea.',
|
|
39
39
|
sections: [
|
|
@@ -57,7 +57,7 @@ export const staticPagesContent: StaticPageContent[] = [
|
|
|
57
57
|
],
|
|
58
58
|
},
|
|
59
59
|
{
|
|
60
|
-
path: '/
|
|
60
|
+
path: '/privacy',
|
|
61
61
|
title: 'Política de privacidad',
|
|
62
62
|
description: 'Cómo se tratan los datos en la tienda en línea.',
|
|
63
63
|
sections: [
|
|
@@ -81,7 +81,7 @@ export const staticPagesContent: StaticPageContent[] = [
|
|
|
81
81
|
],
|
|
82
82
|
},
|
|
83
83
|
{
|
|
84
|
-
path: '/
|
|
84
|
+
path: '/about',
|
|
85
85
|
title: 'Sobre nosotros',
|
|
86
86
|
description: 'Información sobre la tienda.',
|
|
87
87
|
sections: [
|
|
@@ -33,7 +33,7 @@ const catalog = {
|
|
|
33
33
|
},
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
// A stable, unique root id per page: `/
|
|
36
|
+
// A stable, unique root id per page: `/contact` -> `static-contact`,
|
|
37
37
|
// `/` -> `static-index`.
|
|
38
38
|
const pageRootId = (path: string): string =>
|
|
39
39
|
`static-${path.replace(/^\//, '').replace(/\//g, '-') || 'index'}`
|