@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
|
@@ -12,11 +12,15 @@ import {
|
|
|
12
12
|
logoAccent,
|
|
13
13
|
navbarClassName,
|
|
14
14
|
newsletter,
|
|
15
|
+
shippingNote,
|
|
15
16
|
} from './content'
|
|
16
17
|
|
|
17
18
|
export const navbarShellProps = {
|
|
18
19
|
logoAccent,
|
|
19
20
|
className: navbarClassName,
|
|
21
|
+
// Optional utility-strip note. Empty by default, so the strip renders only
|
|
22
|
+
// once a real note (or a utility link) is configured.
|
|
23
|
+
shippingNote,
|
|
20
24
|
// Global navigation search is held until a verified backend search contract
|
|
21
25
|
// exists (E2 task 5.4), so the generated storefront presents NO search
|
|
22
26
|
// control. A merchant may set this to `true` to opt in once the contract is
|
|
@@ -6,6 +6,8 @@ import type { NextAppSpec } from '@json-render/next'
|
|
|
6
6
|
import { storefrontInitialState } from '../state'
|
|
7
7
|
import { views } from '../views'
|
|
8
8
|
import { footerShellProps, navbarShellProps } from '../shell/props'
|
|
9
|
+
import { mobileBottomNavItems } from '../shell/content'
|
|
10
|
+
import { mountedFeatureConfigs, mountedFeatureNames } from '../composition'
|
|
9
11
|
import type { StorefrontElement } from './types'
|
|
10
12
|
|
|
11
13
|
export type { StorefrontElement }
|
|
@@ -34,12 +36,49 @@ const routes = Object.fromEntries(
|
|
|
34
36
|
|
|
35
37
|
// The default layout is the storefront chrome shared by every page: the
|
|
36
38
|
// system notice region sits in the notices slot (always first, above the
|
|
37
|
-
// header), the announcement banner and navbar live in the header slot, the
|
|
38
|
-
// footer in the footer slot
|
|
39
|
-
//
|
|
39
|
+
// header), the announcement banner and navbar live in the header slot, and the
|
|
40
|
+
// footer in the footer slot. The cart is a lateral panel owned by the navbar
|
|
41
|
+
// (opened by the navbar icon or a successful add), never an inline element of
|
|
42
|
+
// the shell, so page content is never followed by a duplicated cart. This
|
|
40
43
|
// mirrors the reference app so a fresh scaffold ships a real storefront, not a
|
|
41
44
|
// bare page.
|
|
45
|
+
// The navigability feature contributes its chrome only when it is installed.
|
|
46
|
+
// `mountedFeatureNames` is the general presence seam (unfiltered by the catalog
|
|
47
|
+
// boundary), so a removed feature leaves no dangling skip link, back-to-top, or
|
|
48
|
+
// bottom navigation behind.
|
|
49
|
+
const hasNavigability = mountedFeatureNames.includes('navigability')
|
|
50
|
+
const navigabilityConfig = (mountedFeatureConfigs['navigability'] ?? {}) as {
|
|
51
|
+
showBackToTop?: boolean
|
|
52
|
+
}
|
|
53
|
+
const showBackToTop = navigabilityConfig.showBackToTop !== false
|
|
54
|
+
|
|
42
55
|
const shellElements: Record<string, StorefrontElement> = {
|
|
56
|
+
// Navigability chrome, present only when the feature is installed. The skip
|
|
57
|
+
// link is the first focusable element (rendered before the notices); the
|
|
58
|
+
// back-to-top and mobile bottom navigation are fixed-position aids.
|
|
59
|
+
...(hasNavigability
|
|
60
|
+
? {
|
|
61
|
+
skipToContent: {
|
|
62
|
+
type: 'SkipToContent',
|
|
63
|
+
props: { targetId: 'main-content' },
|
|
64
|
+
},
|
|
65
|
+
...(showBackToTop
|
|
66
|
+
? {
|
|
67
|
+
backToTop: {
|
|
68
|
+
type: 'BackToTop',
|
|
69
|
+
props: { label: 'Volver arriba' },
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
: {}),
|
|
73
|
+
mobileBottomNav: {
|
|
74
|
+
type: 'MobileBottomNav',
|
|
75
|
+
props: {
|
|
76
|
+
items: mobileBottomNavItems,
|
|
77
|
+
ariaLabel: 'Navegación principal',
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
: {}),
|
|
43
82
|
// Reserved top region for system/status messages. Only the shell layout
|
|
44
83
|
// references it, so it renders once and always as the first element.
|
|
45
84
|
notice: {
|
|
@@ -51,7 +90,7 @@ const shellElements: Record<string, StorefrontElement> = {
|
|
|
51
90
|
props: {
|
|
52
91
|
badge: 'Novedades',
|
|
53
92
|
message: 'Descubre el catálogo de __SITE_NAME__.',
|
|
54
|
-
href: '/
|
|
93
|
+
href: '/categories',
|
|
55
94
|
linkLabel: 'Ver catálogo',
|
|
56
95
|
},
|
|
57
96
|
visible: { $state: '/bannerDismissed', not: true },
|
|
@@ -61,8 +100,9 @@ const shellElements: Record<string, StorefrontElement> = {
|
|
|
61
100
|
type: 'StorefrontNavbar',
|
|
62
101
|
props: {
|
|
63
102
|
storeCategories: { $state: '/navCategories' },
|
|
64
|
-
//
|
|
65
|
-
|
|
103
|
+
// No shipping/delivery/COD note is shipped by default; the note comes
|
|
104
|
+
// from the user-owned shell seam (`...navbarShellProps` below) and is
|
|
105
|
+
// empty unless the merchant sets one, so no placeholder strip renders.
|
|
66
106
|
utilityLinks: [],
|
|
67
107
|
logoText: '__SITE_NAME__',
|
|
68
108
|
logoHref: '/',
|
|
@@ -72,29 +112,12 @@ const shellElements: Record<string, StorefrontElement> = {
|
|
|
72
112
|
},
|
|
73
113
|
on: {
|
|
74
114
|
openCart: { action: 'openCart' },
|
|
115
|
+
goToCart: { action: 'goToCart' },
|
|
75
116
|
goToCheckout: { action: 'goToCheckout' },
|
|
76
117
|
applyQuantityDelta: { action: 'applyQuantityDelta' },
|
|
77
118
|
removeFromCart: { action: 'removeFromCart' },
|
|
78
119
|
},
|
|
79
120
|
},
|
|
80
|
-
'cart.drawer': {
|
|
81
|
-
type: 'CartDrawer',
|
|
82
|
-
props: {
|
|
83
|
-
// No fabricated free-shipping threshold or reward: the backend is the only
|
|
84
|
-
// source of shipping amounts, and a promotion promise is not supported by
|
|
85
|
-
// the default fixture. A merchant may add promotions as customization.
|
|
86
|
-
promotions: [],
|
|
87
|
-
taxRate: 0.0,
|
|
88
|
-
},
|
|
89
|
-
visible: { $state: '/cartOpen', eq: true },
|
|
90
|
-
on: {
|
|
91
|
-
updateQuantity: { action: 'updateQuantity' },
|
|
92
|
-
removeFromCart: { action: 'removeFromCart' },
|
|
93
|
-
goToCheckout: { action: 'goToCheckout' },
|
|
94
|
-
closeCart: { action: 'closeCart' },
|
|
95
|
-
refreshCart: { action: 'refreshCart' },
|
|
96
|
-
},
|
|
97
|
-
},
|
|
98
121
|
footer: {
|
|
99
122
|
type: 'Footer',
|
|
100
123
|
props: {
|
|
@@ -123,8 +146,15 @@ export const __STOREFRONT_CAMEL__Spec: NextAppSpec = {
|
|
|
123
146
|
layout: {
|
|
124
147
|
type: 'Layout',
|
|
125
148
|
props: {},
|
|
126
|
-
slots: {
|
|
127
|
-
|
|
149
|
+
slots: {
|
|
150
|
+
...(hasNavigability
|
|
151
|
+
? { chrome: ['skipToContent', 'backToTop', 'mobileBottomNav'] }
|
|
152
|
+
: {}),
|
|
153
|
+
notices: ['notice'],
|
|
154
|
+
header: ['banner', 'navbar'],
|
|
155
|
+
footer: ['footer'],
|
|
156
|
+
},
|
|
157
|
+
children: ['slot'],
|
|
128
158
|
},
|
|
129
159
|
// The page content is injected at the Slot element by @json-render/next.
|
|
130
160
|
// Without it the rendered layout drops the page and the store looks empty.
|
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
// host files. Kept free of Next.js, React, and env imports so the logic is
|
|
4
4
|
// testable at the source and the host files stay thin.
|
|
5
5
|
|
|
6
|
+
import type { EntitySeo } from '@thorprovider/runtime/contracts'
|
|
7
|
+
import {
|
|
8
|
+
mapCategoryEntityToSeo,
|
|
9
|
+
mapProductEntityToSeo,
|
|
10
|
+
} from '@thorprovider/runtime/contracts'
|
|
11
|
+
|
|
6
12
|
/** The routes the storefront can serve as fixed pages (no dynamic `[` segment). */
|
|
7
13
|
export function fixedRoutePaths(routes: Record<string, unknown>): string[] {
|
|
8
14
|
return Object.keys(routes).filter((path) => !path.includes('['))
|
|
@@ -24,3 +30,162 @@ export function sitemapUrls(
|
|
|
24
30
|
export function robotsSitemapUrl(siteUrl: string | undefined): string | undefined {
|
|
25
31
|
return siteUrl ? new URL('/sitemap.xml', siteUrl).toString() : undefined
|
|
26
32
|
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Surfaces that are never public: they require a shopper session or hold
|
|
36
|
+
* checkout state and must not be indexed or listed in the sitemap.
|
|
37
|
+
*/
|
|
38
|
+
export const PRIVATE_ROUTE_PREFIXES = ['/account', '/checkout'] as const
|
|
39
|
+
|
|
40
|
+
/** True when a path is (or is nested under) a private surface. */
|
|
41
|
+
export function isPrivatePath(path: string): boolean {
|
|
42
|
+
return PRIVATE_ROUTE_PREFIXES.some(
|
|
43
|
+
(prefix) => path === prefix || path.startsWith(`${prefix}/`),
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The paths `robots.txt` disallows: the fixed private surfaces plus any private
|
|
49
|
+
* path the known route set declares. Public routes are never returned.
|
|
50
|
+
*/
|
|
51
|
+
export function robotsDisallowPaths(routes: Record<string, unknown>): string[] {
|
|
52
|
+
const paths = new Set<string>(PRIVATE_ROUTE_PREFIXES)
|
|
53
|
+
for (const path of Object.keys(routes)) {
|
|
54
|
+
if (isPrivatePath(path)) paths.add(path)
|
|
55
|
+
}
|
|
56
|
+
return [...paths]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Per-route entity-SEO extractors keyed by the route's loader name. Each runs
|
|
61
|
+
* after the same request-scoped loader as the page and maps the real loaded
|
|
62
|
+
* entity through the pure `@thorprovider/runtime/contracts` mappers. The route
|
|
63
|
+
* prefix is supplied here because the runtime stays route-agnostic.
|
|
64
|
+
*/
|
|
65
|
+
export const entitySeoExtractors: Record<
|
|
66
|
+
string,
|
|
67
|
+
(data: Record<string, unknown>) => EntitySeo | undefined
|
|
68
|
+
> = {
|
|
69
|
+
product: (data) =>
|
|
70
|
+
mapProductEntityToSeo(data.selectedProduct, { pathPrefix: '/products' }),
|
|
71
|
+
category: (data) =>
|
|
72
|
+
mapCategoryEntityToSeo(data.categoryEntity, { pathPrefix: '/categories' }),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A public catalog entity referenced by its stable id and public path. */
|
|
76
|
+
export interface PublicEntityRef {
|
|
77
|
+
id: string
|
|
78
|
+
path: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** One page of public entities plus the cursor for the next page, if any. */
|
|
82
|
+
export interface PublicEntityPage {
|
|
83
|
+
items: PublicEntityRef[]
|
|
84
|
+
nextCursor?: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A page-fetching callback for the bounded entity enumeration. */
|
|
88
|
+
export type PublicEntityPageFetcher = (
|
|
89
|
+
cursor: string | undefined,
|
|
90
|
+
) => Promise<PublicEntityPage>
|
|
91
|
+
|
|
92
|
+
/** Explicit reason an enumeration did not complete. */
|
|
93
|
+
export type SitemapEnumerationFailure =
|
|
94
|
+
| { kind: 'enumeration'; message: string }
|
|
95
|
+
| { kind: 'bound'; maxPages: number }
|
|
96
|
+
| { kind: 'cycle'; id: string }
|
|
97
|
+
|
|
98
|
+
export type SitemapEnumerationResult =
|
|
99
|
+
| { ok: true; entities: PublicEntityRef[] }
|
|
100
|
+
| { ok: false; failure: SitemapEnumerationFailure }
|
|
101
|
+
|
|
102
|
+
/** The traversal bound: an enumeration that exceeds it fails, never truncates. */
|
|
103
|
+
export const DEFAULT_SITEMAP_MAX_PAGES = 100
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Enumerate public entities across pagination, de-duplicated by id, with a
|
|
107
|
+
* visited-id cycle guard and a maximum-page bound. A fetch failure, a bound hit,
|
|
108
|
+
* or a repeated id/cursor returns an explicit typed failure instead of an
|
|
109
|
+
* apparently-complete or truncated list.
|
|
110
|
+
*/
|
|
111
|
+
export async function enumeratePublicEntities(
|
|
112
|
+
fetchPage: PublicEntityPageFetcher,
|
|
113
|
+
options: { maxPages?: number } = {},
|
|
114
|
+
): Promise<SitemapEnumerationResult> {
|
|
115
|
+
const maxPages = options.maxPages ?? DEFAULT_SITEMAP_MAX_PAGES
|
|
116
|
+
const seenIds = new Set<string>()
|
|
117
|
+
const seenCursors = new Set<string>()
|
|
118
|
+
const entities: PublicEntityRef[] = []
|
|
119
|
+
let cursor: string | undefined
|
|
120
|
+
|
|
121
|
+
for (let pages = 0; ; pages += 1) {
|
|
122
|
+
if (pages >= maxPages) {
|
|
123
|
+
return { ok: false, failure: { kind: 'bound', maxPages } }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let page: PublicEntityPage
|
|
127
|
+
try {
|
|
128
|
+
page = await fetchPage(cursor)
|
|
129
|
+
} catch (error) {
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
failure: {
|
|
133
|
+
kind: 'enumeration',
|
|
134
|
+
message: error instanceof Error ? error.message : String(error),
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const item of page.items) {
|
|
140
|
+
if (seenIds.has(item.id)) {
|
|
141
|
+
return { ok: false, failure: { kind: 'cycle', id: item.id } }
|
|
142
|
+
}
|
|
143
|
+
seenIds.add(item.id)
|
|
144
|
+
entities.push(item)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const next = page.nextCursor
|
|
148
|
+
if (next === undefined || next === '') {
|
|
149
|
+
return { ok: true, entities }
|
|
150
|
+
}
|
|
151
|
+
if (seenCursors.has(next)) {
|
|
152
|
+
return { ok: false, failure: { kind: 'cycle', id: next } }
|
|
153
|
+
}
|
|
154
|
+
seenCursors.add(next)
|
|
155
|
+
cursor = next
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Raised by `app/sitemap.ts` when enumeration fails, so the route responds with
|
|
161
|
+
* a non-success instead of an apparently-complete sitemap.
|
|
162
|
+
*/
|
|
163
|
+
export class SitemapEnumerationError extends Error {
|
|
164
|
+
readonly failure: SitemapEnumerationFailure
|
|
165
|
+
constructor(failure: SitemapEnumerationFailure) {
|
|
166
|
+
super(`Sitemap enumeration failed (${failure.kind})`)
|
|
167
|
+
this.name = 'SitemapEnumerationError'
|
|
168
|
+
this.failure = failure
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Combine the fixed public routes with the enumerated public entities into
|
|
174
|
+
* de-duplicated absolute sitemap entries. Private surfaces are never listed.
|
|
175
|
+
*/
|
|
176
|
+
export function buildSitemapEntries(input: {
|
|
177
|
+
routes: Record<string, unknown>
|
|
178
|
+
siteUrl: string
|
|
179
|
+
entities: readonly PublicEntityRef[]
|
|
180
|
+
}): { url: string }[] {
|
|
181
|
+
const urls = new Set<string>()
|
|
182
|
+
for (const path of fixedRoutePaths(input.routes)) {
|
|
183
|
+
if (!isPrivatePath(path)) urls.add(new URL(path, input.siteUrl).toString())
|
|
184
|
+
}
|
|
185
|
+
for (const entity of input.entities) {
|
|
186
|
+
if (!isPrivatePath(entity.path)) {
|
|
187
|
+
urls.add(new URL(entity.path, input.siteUrl).toString())
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return [...urls].map((url) => ({ url }))
|
|
191
|
+
}
|
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
// owns the environment contract; the commerce bridge owns the backend calls.
|
|
10
10
|
//
|
|
11
11
|
// The values are the multitenancy seam: a future tenancy layer varies
|
|
12
|
-
// storeId/channel/region/currency without redefining the commerce shapes.
|
|
13
|
-
// currency is NEVER hardcoded — it
|
|
12
|
+
// storeId/channel/region/currency without redefining the commerce shapes. In
|
|
13
|
+
// real mode the currency is NEVER hardcoded — it comes from the host
|
|
14
|
+
// environment; mock mode resolves a labelled fixture context so the store runs
|
|
15
|
+
// with no environment configured at all.
|
|
14
16
|
|
|
15
17
|
import type { StoreContext } from '@thorprovider/types'
|
|
16
18
|
import { storeContextSchema } from '@thorprovider/runtime/contracts'
|
|
@@ -23,7 +25,7 @@ export const STORE_CONTEXT_ENV = {
|
|
|
23
25
|
storeId: 'THOR_STORE_ID',
|
|
24
26
|
salesChannelId: 'MEDUSA_SALES_CHANNEL_ID',
|
|
25
27
|
regionId: 'MEDUSA_REGION_ID',
|
|
26
|
-
currencyCode: '
|
|
28
|
+
currencyCode: 'MEDUSA_CURRENCY_CODE',
|
|
27
29
|
} as const
|
|
28
30
|
|
|
29
31
|
/** Real-mode backend configuration required before any provider call. */
|
|
@@ -90,6 +92,19 @@ export function getSiteUrl(): string | undefined {
|
|
|
90
92
|
return parseSiteUrl(process.env[SITE_URL_ENV])
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Fixture context resolved in mock mode. It satisfies `storeContextSchema` and
|
|
97
|
+
* mirrors the values the bundled mock provider serves, so a store with no
|
|
98
|
+
* environment configured still has a valid context to render and operate its
|
|
99
|
+
* cart against. It is a labelled fixture, never a real backend identity.
|
|
100
|
+
*/
|
|
101
|
+
export const MOCK_STORE_CONTEXT: StoreContext = {
|
|
102
|
+
storeId: 'mock-store',
|
|
103
|
+
salesChannelId: 'mock-sales-channel',
|
|
104
|
+
regionId: 'mock-region-us',
|
|
105
|
+
currencyCode: 'USD',
|
|
106
|
+
}
|
|
107
|
+
|
|
93
108
|
function requireEnv(name: string): string {
|
|
94
109
|
const value = process.env[name]
|
|
95
110
|
if (typeof value !== 'string' || value.trim() === '') {
|
|
@@ -101,13 +116,17 @@ function requireEnv(name: string): string {
|
|
|
101
116
|
}
|
|
102
117
|
|
|
103
118
|
/**
|
|
104
|
-
* Resolve and validate the single-store context
|
|
119
|
+
* Resolve and validate the single-store context.
|
|
105
120
|
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
121
|
+
* Mock mode returns the labelled fixture context and reads no environment
|
|
122
|
+
* variables, so a created store runs with no configuration. Real mode resolves
|
|
123
|
+
* the four variables from the host environment and validates them through the
|
|
124
|
+
* shared runtime schema, so an invalid context is rejected before any commerce
|
|
125
|
+
* call. Throws `StoreConfigError` naming the missing variable — never a secret
|
|
126
|
+
* value.
|
|
109
127
|
*/
|
|
110
128
|
export function getStoreContext(): StoreContext {
|
|
129
|
+
if (!isRealMode()) return MOCK_STORE_CONTEXT
|
|
111
130
|
const candidate = {
|
|
112
131
|
storeId: requireEnv(STORE_CONTEXT_ENV.storeId),
|
|
113
132
|
salesChannelId: requireEnv(STORE_CONTEXT_ENV.salesChannelId),
|
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
import type { ViewCatalogEntry } from '../types'
|
|
9
9
|
import { ctaSection } from './cta'
|
|
10
10
|
import { faqSection } from './faq'
|
|
11
|
+
import { productsSection } from './products'
|
|
11
12
|
|
|
12
|
-
export const sectionModules = [faqSection, ctaSection] as const
|
|
13
|
+
export const sectionModules = [faqSection, ctaSection, productsSection] as const
|
|
13
14
|
|
|
14
15
|
export const sectionCatalog: Record<string, ViewCatalogEntry> = Object.assign(
|
|
15
16
|
{},
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
// Optional section fragment: products (mirrors recipes/sections.json) — client registry.
|
|
4
|
+
// The matching server-side catalog lives in products.tsx. A data-bound section
|
|
5
|
+
// renders one of two verified presentations (ProductCard1 grid, or the combined
|
|
6
|
+
// grid-and-carousel ProductShowcase1) chosen by the resolved `variant`. The
|
|
7
|
+
// selection and resolved products arrive through the view state; this boundary
|
|
8
|
+
// only validates them and wires navigation/purchase callbacks.
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
ProductCard1 as ProductCard1Block,
|
|
12
|
+
ProductShowcase1 as ProductShowcase1Block,
|
|
13
|
+
type ProductCard1Product,
|
|
14
|
+
} from '@thorprovider/blocks'
|
|
15
|
+
import { useStateStore } from '@json-render/react'
|
|
16
|
+
import { useSectionObservation } from '../../section-observation'
|
|
17
|
+
import { productsPropsSchema, type ProductsStatus } from './schema.products'
|
|
18
|
+
import type { ViewRegistry } from '../types'
|
|
19
|
+
|
|
20
|
+
// Truthful, actionable messages for the non-`ready` states. A failed or
|
|
21
|
+
// unconfigured section never fabricates products.
|
|
22
|
+
function sectionStatusMessage(status: ProductsStatus): string {
|
|
23
|
+
switch (status) {
|
|
24
|
+
case 'unconfigured':
|
|
25
|
+
return 'Esta sección de productos aún no tiene una selección configurada.'
|
|
26
|
+
case 'empty':
|
|
27
|
+
return 'Esta selección no tiene productos disponibles por ahora.'
|
|
28
|
+
case 'invalid-query':
|
|
29
|
+
return 'La selección de productos de esta sección no es válida.'
|
|
30
|
+
case 'unsupported-query':
|
|
31
|
+
return 'Esta selección usa criterios que el catálogo no admite.'
|
|
32
|
+
case 'unmounted-capability':
|
|
33
|
+
return 'Esta selección requiere una capacidad que este catálogo no tiene montada.'
|
|
34
|
+
case 'data-error':
|
|
35
|
+
return 'No se pudieron cargar los productos de esta sección.'
|
|
36
|
+
default:
|
|
37
|
+
return ''
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const productsRegistry: ViewRegistry = {
|
|
42
|
+
Products: ({ props, emit }) => {
|
|
43
|
+
const { set } = useStateStore()
|
|
44
|
+
const parsed = productsPropsSchema.parse(props)
|
|
45
|
+
const { containerRef, noteProductClick } = useSectionObservation({
|
|
46
|
+
sectionId: parsed.sectionId ?? '',
|
|
47
|
+
sectionType: parsed.sectionType ?? 'products',
|
|
48
|
+
...(parsed.variant ? { sectionVariant: parsed.variant } : {}),
|
|
49
|
+
...(parsed.objective ? { objective: parsed.objective } : {}),
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
if (parsed.status !== 'ready') {
|
|
53
|
+
return (
|
|
54
|
+
<section
|
|
55
|
+
aria-label={parsed.heading ?? 'Productos'}
|
|
56
|
+
className="mx-auto w-full max-w-7xl px-4 py-12"
|
|
57
|
+
>
|
|
58
|
+
{parsed.heading ? (
|
|
59
|
+
<h2 className="mb-2 text-2xl font-semibold tracking-tight">{parsed.heading}</h2>
|
|
60
|
+
) : null}
|
|
61
|
+
<p className="text-sm text-muted-foreground">{sectionStatusMessage(parsed.status)}</p>
|
|
62
|
+
</section>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The detail affordance opens the product so a variant is chosen explicitly.
|
|
67
|
+
// It is also the section's observation origin (disabled by default).
|
|
68
|
+
const openProduct = (product: ProductCard1Product) => {
|
|
69
|
+
noteProductClick(product)
|
|
70
|
+
set('/lastClicked', product)
|
|
71
|
+
emit('openProduct')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Product-card shortcut: the add affordance adds the product's first
|
|
75
|
+
// purchasable variant (resolved server-side from the product id), then opens
|
|
76
|
+
// the mini-cart. It is not the section's observation origin.
|
|
77
|
+
const addToCart = (product: ProductCard1Product) => {
|
|
78
|
+
set('/lastClicked', product)
|
|
79
|
+
emit('addClickedToCart')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const shared = {
|
|
83
|
+
products: parsed.products,
|
|
84
|
+
heading: parsed.heading,
|
|
85
|
+
description: parsed.description,
|
|
86
|
+
onAddToCart: addToCart,
|
|
87
|
+
onProductClick: openProduct,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<div ref={containerRef}>
|
|
92
|
+
{parsed.variant === 'ProductShowcase1' ? (
|
|
93
|
+
<ProductShowcase1Block {...shared} />
|
|
94
|
+
) : (
|
|
95
|
+
<ProductCard1Block {...shared} />
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
)
|
|
99
|
+
},
|
|
100
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Optional section fragment: products (mirrors recipes/sections.json)
|
|
2
|
+
// Server-side part: element type + zod catalog entry. The component mappings
|
|
3
|
+
// live in products.client.tsx ('use client'). Unlike the stateless editorial
|
|
4
|
+
// fragments (faq/cta), the products section is data-bound: its per-instance
|
|
5
|
+
// selection is resolved by the host loader and delivered through the view state,
|
|
6
|
+
// so this catalog entry describes the *resolved* props shape.
|
|
7
|
+
|
|
8
|
+
import { productsPropsSchema } from './schema.products'
|
|
9
|
+
|
|
10
|
+
export const productsSection = {
|
|
11
|
+
elementType: 'Products',
|
|
12
|
+
catalog: {
|
|
13
|
+
Products: {
|
|
14
|
+
props: productsPropsSchema,
|
|
15
|
+
description: 'Data-bound product presentation (grid or combined grid-and-carousel).',
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
registryModule: './products.client',
|
|
19
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Optional section fragment: products (mirrors recipes/sections.json) — shared contract.
|
|
2
|
+
// Zod mirror of the resolved Products section props, shared by the server catalog
|
|
3
|
+
// (products.tsx) and the client registry (products.client.tsx). The product list
|
|
4
|
+
// reuses the canonical presentation contract owned by @thorprovider/blocks so the
|
|
5
|
+
// section renders exactly what the host mapped from the catalog result.
|
|
6
|
+
|
|
7
|
+
import { z } from 'zod'
|
|
8
|
+
import { productPresentationSchema } from '@thorprovider/blocks'
|
|
9
|
+
|
|
10
|
+
// The per-instance resolution status the host loader computes for a data-bound
|
|
11
|
+
// section. `ready`/`empty` carry resolved products; the remaining states carry no
|
|
12
|
+
// products and render an actionable, truthful message instead.
|
|
13
|
+
export const productsStatusSchema = z.enum([
|
|
14
|
+
'unconfigured',
|
|
15
|
+
'ready',
|
|
16
|
+
'empty',
|
|
17
|
+
'invalid-query',
|
|
18
|
+
'unsupported-query',
|
|
19
|
+
'unmounted-capability',
|
|
20
|
+
'data-error',
|
|
21
|
+
])
|
|
22
|
+
|
|
23
|
+
export const productsPropsSchema = z.object({
|
|
24
|
+
sectionId: z.string().min(1).optional(),
|
|
25
|
+
sectionType: z.string().min(1).optional(),
|
|
26
|
+
variant: z.string().optional(),
|
|
27
|
+
// Defensive defaults: a missing resolved state (e.g. the view loader could not
|
|
28
|
+
// run) degrades to a truthful error instead of crashing the render.
|
|
29
|
+
status: productsStatusSchema.default('data-error'),
|
|
30
|
+
products: z.array(productPresentationSchema).default([]),
|
|
31
|
+
heading: z.string().optional(),
|
|
32
|
+
description: z.string().optional(),
|
|
33
|
+
objective: z.string().optional(),
|
|
34
|
+
ctaLabel: z.string().optional(),
|
|
35
|
+
ctaHref: z.string().optional(),
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
export type ProductsProps = z.infer<typeof productsPropsSchema>
|
|
39
|
+
export type ProductsStatus = z.infer<typeof productsStatusSchema>
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
syncCheckoutAddressAction,
|
|
16
16
|
} from '../../actions'
|
|
17
17
|
import type { CartStatePatch, PlaceOrderResult } from '../../commerce'
|
|
18
|
+
import { recordConfirmedTransition } from '../../section-observation'
|
|
18
19
|
|
|
19
20
|
// The always-required core of an address. Optional fields (province, phone,
|
|
20
21
|
// postal code) are left to the runtime validator so the host does not impose a
|
|
@@ -49,6 +50,9 @@ export function useCheckoutHandlers() {
|
|
|
49
50
|
const applyResult = (result: PlaceOrderResult) => {
|
|
50
51
|
set('/checkoutPending', false)
|
|
51
52
|
if (result.status === 'order') {
|
|
53
|
+
// A confirmed, persisted order — observed only on a genuine order result
|
|
54
|
+
// (not captured funds) and only when a section origin exists.
|
|
55
|
+
recordConfirmedTransition('order_confirmed')
|
|
52
56
|
// ONE atomic write carries the order and its backend lines.
|
|
53
57
|
set('/lastReceipt', { order: result.order, lines: result.lines })
|
|
54
58
|
set('/orderStatus', 'complete')
|
|
@@ -93,6 +97,8 @@ export function useCheckoutHandlers() {
|
|
|
93
97
|
|
|
94
98
|
const handlers: Record<string, () => void> = {
|
|
95
99
|
goToCheckout: () => {
|
|
100
|
+
// A confirmed checkout transition (the shopper started checkout).
|
|
101
|
+
recordConfirmedTransition('checkout_started')
|
|
96
102
|
set('/cartOpen', false)
|
|
97
103
|
navigate('/checkout')
|
|
98
104
|
},
|
|
@@ -19,7 +19,13 @@ export interface CustomerAuthState {
|
|
|
19
19
|
accountOrdersPending: boolean
|
|
20
20
|
/** The order-history load failure, or null when it loaded. */
|
|
21
21
|
accountOrdersError: string | null
|
|
22
|
-
/** The
|
|
22
|
+
/** The backend's real total for the authenticated customer on the store channel. */
|
|
23
|
+
accountOrdersCount: number
|
|
24
|
+
/** The 1-based order-history page currently loaded. */
|
|
25
|
+
accountOrdersPage: number
|
|
26
|
+
/** The bounded page size used for the order-history window. */
|
|
27
|
+
accountOrdersPageSize: number
|
|
28
|
+
/** The real backend order id selected for the inline detail; null when none is open. */
|
|
23
29
|
accountSelectedOrderId: string | null
|
|
24
30
|
}
|
|
25
31
|
|
|
@@ -32,5 +38,8 @@ export const customerAuthState: CustomerAuthState = {
|
|
|
32
38
|
accountOrders: [],
|
|
33
39
|
accountOrdersPending: false,
|
|
34
40
|
accountOrdersError: null,
|
|
41
|
+
accountOrdersCount: 0,
|
|
42
|
+
accountOrdersPage: 1,
|
|
43
|
+
accountOrdersPageSize: 10,
|
|
35
44
|
accountSelectedOrderId: null,
|
|
36
45
|
}
|