@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
|
@@ -5,25 +5,34 @@
|
|
|
5
5
|
//
|
|
6
6
|
// The shell renders the live backend cart: the Navbar mini-cart and the cart
|
|
7
7
|
// drawer read the authoritative cart state and dispatch cart mutations to the
|
|
8
|
-
// Server Actions.
|
|
9
|
-
//
|
|
8
|
+
// Server Actions. A listing card's add-to-cart adds the product's first
|
|
9
|
+
// purchasable variant (the card shortcut); opening a product from a listing
|
|
10
|
+
// navigates to the detail, where a variant is chosen explicitly.
|
|
10
11
|
|
|
11
12
|
import { defineRegistry, useStateStore } from '@json-render/react'
|
|
13
|
+
import { useEffect } from 'react'
|
|
14
|
+
import { usePathname } from 'next/navigation'
|
|
12
15
|
import {
|
|
16
|
+
BackToTop as BackToTopBlock,
|
|
13
17
|
Banner1,
|
|
14
18
|
FooterSection1,
|
|
19
|
+
MobileBottomNav as MobileBottomNavBlock,
|
|
15
20
|
Navbar1,
|
|
16
21
|
Navbar4,
|
|
17
22
|
ProductCard1,
|
|
18
|
-
|
|
23
|
+
SkipToContent as SkipToContentBlock,
|
|
24
|
+
type BackToTopProps,
|
|
25
|
+
type MobileBottomNavProps,
|
|
26
|
+
type SkipToContentProps,
|
|
19
27
|
} from '@thorprovider/blocks'
|
|
28
|
+
import { isAvailableMoney } from '@thorprovider/blocks/contracts'
|
|
29
|
+
import type { Money } from '@thorprovider/types'
|
|
20
30
|
import { catalog } from './catalog'
|
|
21
31
|
import type { CartLineState, CartTotalsState } from './state'
|
|
22
32
|
import { useStorefrontHandlers } from './handlers'
|
|
23
|
-
import {
|
|
33
|
+
import { cartViewAvailable, checkoutAvailable, mountedFeatureNames } from './composition'
|
|
24
34
|
import {
|
|
25
35
|
bannerPropsSchema,
|
|
26
|
-
cartDrawerPropsSchema,
|
|
27
36
|
footerPropsSchema,
|
|
28
37
|
navbarPropsSchema,
|
|
29
38
|
productCardPropsSchema,
|
|
@@ -40,10 +49,12 @@ interface NavCartItem {
|
|
|
40
49
|
price: number
|
|
41
50
|
qty: number
|
|
42
51
|
image?: string
|
|
52
|
+
lineTotal?: Money
|
|
43
53
|
}
|
|
44
54
|
|
|
45
55
|
/** CartLineState -> Navbar4 cart line. `price` is legacy; the authoritative
|
|
46
|
-
* subtotal
|
|
56
|
+
* subtotal and per-line total are passed separately, so neither is computed
|
|
57
|
+
* from the line. */
|
|
47
58
|
function toNavCartItems(cart: CartLineState[]): NavCartItem[] {
|
|
48
59
|
return cart.map((line) => ({
|
|
49
60
|
id: line.id,
|
|
@@ -52,19 +63,36 @@ function toNavCartItems(cart: CartLineState[]): NavCartItem[] {
|
|
|
52
63
|
price: 0,
|
|
53
64
|
qty: line.quantity,
|
|
54
65
|
image: line.image,
|
|
66
|
+
lineTotal: line.lineTotal,
|
|
55
67
|
}))
|
|
56
68
|
}
|
|
57
69
|
|
|
58
70
|
const baseComponents = {
|
|
59
71
|
Layout: ({ children, slots }: { children?: React.ReactNode; slots?: Record<string, React.ReactNode> }) => {
|
|
60
72
|
useStorefrontHandlers()
|
|
73
|
+
const { set } = useStateStore()
|
|
74
|
+
const pathname = usePathname()
|
|
75
|
+
// The mobile bottom navigation is feature-gated; when it is installed the
|
|
76
|
+
// main content reserves bottom padding so the fixed bar never obscures it.
|
|
77
|
+
const hasBottomNav = mountedFeatureNames.includes('navigability')
|
|
78
|
+
// Central dismissal: any route or history change closes the shared cart
|
|
79
|
+
// lateral, so it never lingers across navigation (forward/back included).
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
set('/cartOpen', false)
|
|
82
|
+
}, [pathname, set])
|
|
61
83
|
return (
|
|
62
84
|
<div className="flex min-h-screen flex-col">
|
|
85
|
+
{slots?.chrome}
|
|
63
86
|
{slots?.notices}
|
|
64
87
|
<header className="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
|
65
88
|
{slots?.header}
|
|
66
89
|
</header>
|
|
67
|
-
<main
|
|
90
|
+
<main
|
|
91
|
+
id="main-content"
|
|
92
|
+
className={hasBottomNav ? 'flex-1 pb-16 md:pb-0' : 'flex-1'}
|
|
93
|
+
>
|
|
94
|
+
{children}
|
|
95
|
+
</main>
|
|
68
96
|
<footer>{slots?.footer}</footer>
|
|
69
97
|
</div>
|
|
70
98
|
)
|
|
@@ -72,6 +100,22 @@ const baseComponents = {
|
|
|
72
100
|
// Neutral page root: renders the route page without shell chrome. Using it
|
|
73
101
|
// instead of `Layout` keeps the shell from mounting twice per page.
|
|
74
102
|
Page: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
|
103
|
+
// Navigability chrome, feature-gated by the shell spec (only referenced when
|
|
104
|
+
// the feature is installed). Props are cast to the block contracts; the
|
|
105
|
+
// catalog schema is the runtime guard.
|
|
106
|
+
SkipToContent: ({ props }: { props: Record<string, unknown> }) => {
|
|
107
|
+
const p = props as unknown as SkipToContentProps
|
|
108
|
+
return <SkipToContentBlock {...p} />
|
|
109
|
+
},
|
|
110
|
+
BackToTop: ({ props }: { props: Record<string, unknown> }) => {
|
|
111
|
+
const p = props as unknown as BackToTopProps
|
|
112
|
+
return <BackToTopBlock {...p} />
|
|
113
|
+
},
|
|
114
|
+
MobileBottomNav: ({ props }: { props: Record<string, unknown> }) => {
|
|
115
|
+
const pathname = usePathname()
|
|
116
|
+
const p = props as unknown as MobileBottomNavProps
|
|
117
|
+
return <MobileBottomNavBlock {...p} currentHref={p.currentHref ?? pathname} />
|
|
118
|
+
},
|
|
75
119
|
// Reserved top region for system/status messages. Self-gates on state and
|
|
76
120
|
// collapses to nothing when there is no notice. A configured mock is labelled
|
|
77
121
|
// as demo data; a real-mode failure is surfaced visibly, never hidden.
|
|
@@ -112,6 +156,18 @@ const baseComponents = {
|
|
|
112
156
|
const { state, set } = useStateStore()
|
|
113
157
|
const cart = (state.cart ?? []) as CartLineState[]
|
|
114
158
|
const totals = state.cartTotals as CartTotalsState | null
|
|
159
|
+
const cartError = typeof state.cartError === 'string' ? state.cartError : undefined
|
|
160
|
+
const readError = typeof state.cartReadError === 'string' ? state.cartReadError : undefined
|
|
161
|
+
const errorLabel = cartError ?? readError
|
|
162
|
+
const cartState = state.cartPending ? 'pending' : errorLabel ? 'error' : 'idle'
|
|
163
|
+
// Checkout is offered only when the required backend amounts are present and
|
|
164
|
+
// valid: a missing/malformed subtotal, total, or line amount blocks it, while
|
|
165
|
+
// an undetermined shipping amount does not (AC-CART-4.4 / UC-CART-7).
|
|
166
|
+
const amountsValid =
|
|
167
|
+
cart.every((line) => isAvailableMoney(line.lineTotal)) &&
|
|
168
|
+
totals !== null &&
|
|
169
|
+
isAvailableMoney(totals.subtotal) &&
|
|
170
|
+
isAvailableMoney(totals.total)
|
|
115
171
|
return (
|
|
116
172
|
<Navbar4
|
|
117
173
|
storeCategories={parsed.storeCategories}
|
|
@@ -122,13 +178,30 @@ const baseComponents = {
|
|
|
122
178
|
logoAccent={parsed.logoAccent}
|
|
123
179
|
className={parsed.className}
|
|
124
180
|
accountHref={parsed.accountHref}
|
|
181
|
+
// The current route marks the matching category link as the current page
|
|
182
|
+
// (accessibly, beyond color alone); no link is marked when it does not
|
|
183
|
+
// match any destination.
|
|
184
|
+
currentPath={usePathname()}
|
|
125
185
|
// Held global search (E2 task 5.4): default to hidden so no global
|
|
126
186
|
// search is presented while the contract is unverified; the user-owned
|
|
127
187
|
// shell seam may opt in with `showSearch: true`.
|
|
128
188
|
showSearch={parsed.showSearch ?? false}
|
|
129
189
|
items={toNavCartItems(cart)}
|
|
130
190
|
subtotal={totals?.subtotal}
|
|
131
|
-
|
|
191
|
+
// One shared lateral: visibility is driven by the shared `cartOpen`
|
|
192
|
+
// state (a successful add opens it; navigation dismisses it). The
|
|
193
|
+
// canonical line amounts and lifecycle states come from the same cart
|
|
194
|
+
// state the standalone `/cart` view reads.
|
|
195
|
+
canonicalCart
|
|
196
|
+
cartState={cartState}
|
|
197
|
+
cartErrorLabel={errorLabel}
|
|
198
|
+
onCartRetry={() => emit('refreshCart')}
|
|
199
|
+
cartOpen={state.cartOpen === true}
|
|
200
|
+
onCartOpenChange={(open) => set('/cartOpen', open)}
|
|
201
|
+
checkoutUnavailable={!checkoutAvailable}
|
|
202
|
+
checkoutDisabled={cartState !== 'idle' || cart.length === 0 || !amountsValid}
|
|
203
|
+
viewCartUnavailable={!cartViewAvailable}
|
|
204
|
+
onViewCart={() => emit('goToCart')}
|
|
132
205
|
onCheckout={() => emit('goToCheckout')}
|
|
133
206
|
onQtyChange={(id, delta) => {
|
|
134
207
|
set('/qtyDelta', { id, delta })
|
|
@@ -141,47 +214,6 @@ const baseComponents = {
|
|
|
141
214
|
/>
|
|
142
215
|
)
|
|
143
216
|
},
|
|
144
|
-
CartDrawer: ({ props, emit }: { props: Record<string, unknown>; emit: (event: string) => void }) => {
|
|
145
|
-
const parsed = cartDrawerPropsSchema.parse(props)
|
|
146
|
-
const { state, set } = useStateStore()
|
|
147
|
-
const cart = (state.cart ?? []) as CartLineState[]
|
|
148
|
-
const totals = state.cartTotals as CartTotalsState | null
|
|
149
|
-
const cartError = typeof state.cartError === 'string' ? state.cartError : undefined
|
|
150
|
-
return (
|
|
151
|
-
<ShoppingCart4
|
|
152
|
-
items={cart.map((line) => ({
|
|
153
|
-
id: line.id,
|
|
154
|
-
name: line.name,
|
|
155
|
-
price: 0,
|
|
156
|
-
quantity: line.quantity,
|
|
157
|
-
image: line.image,
|
|
158
|
-
variant: line.variant,
|
|
159
|
-
lineTotal: line.lineTotal,
|
|
160
|
-
unitPrice: line.unitPrice,
|
|
161
|
-
}))}
|
|
162
|
-
promotions={parsed.promotions.map((promo) => ({
|
|
163
|
-
threshold: promo.threshold,
|
|
164
|
-
reward: promo.reward,
|
|
165
|
-
icon: resolveIcon(promo.icon),
|
|
166
|
-
}))}
|
|
167
|
-
totals={totals ?? undefined}
|
|
168
|
-
totalsState={state.cartPending ? 'pending' : cartError ? 'error' : 'idle'}
|
|
169
|
-
totalsErrorLabel={cartError}
|
|
170
|
-
onRetryTotals={() => emit('refreshCart')}
|
|
171
|
-
onQuantityChange={(id, quantity) => {
|
|
172
|
-
const line = cart.find((candidate) => candidate.id === id)
|
|
173
|
-
if (!line) return
|
|
174
|
-
set('/qtyUpdate', { id, merchandiseId: line.merchandiseId, quantity })
|
|
175
|
-
emit('updateQuantity')
|
|
176
|
-
}}
|
|
177
|
-
onRemove={(id) => {
|
|
178
|
-
set('/lastRemoved', id)
|
|
179
|
-
emit('removeFromCart')
|
|
180
|
-
}}
|
|
181
|
-
onCheckout={() => emit('goToCheckout')}
|
|
182
|
-
/>
|
|
183
|
-
)
|
|
184
|
-
},
|
|
185
217
|
Banner: ({ props, emit }: { props: Record<string, unknown>; emit: (event: string) => void }) => {
|
|
186
218
|
const parsed = bannerPropsSchema.parse(props)
|
|
187
219
|
const { set } = useStateStore()
|
|
@@ -231,7 +263,7 @@ const baseComponents = {
|
|
|
231
263
|
products={parsed.products}
|
|
232
264
|
heading={parsed.heading}
|
|
233
265
|
maxItems={parsed.maxItems}
|
|
234
|
-
//
|
|
266
|
+
// This base card opens the detail so a variant is chosen explicitly.
|
|
235
267
|
onAddToCart={(product) => {
|
|
236
268
|
select(product)
|
|
237
269
|
emit('openProduct')
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
import type { StorefrontLoaderFn } from '@thorprovider/runtime/render-core/app'
|
|
6
6
|
import { createStorefrontApp } from '@thorprovider/runtime/render-core/app'
|
|
7
7
|
import { __STOREFRONT_CAMEL__Spec } from './spec'
|
|
8
|
+
import { entitySeoExtractors } from './spec/seo'
|
|
9
|
+
import { getSiteUrl } from './store-context'
|
|
8
10
|
import { views } from './views'
|
|
9
11
|
|
|
10
12
|
const loaders: Record<string, StorefrontLoaderFn> = Object.assign({}, ...views.map((view) => view.loader))
|
|
@@ -12,6 +14,7 @@ const loaders: Record<string, StorefrontLoaderFn> = Object.assign({}, ...views.m
|
|
|
12
14
|
export const __STOREFRONT_CAMEL__App = createStorefrontApp({
|
|
13
15
|
spec: __STOREFRONT_CAMEL__Spec,
|
|
14
16
|
loaders,
|
|
17
|
+
entitySeo: { siteUrl: getSiteUrl(), extract: entitySeoExtractors },
|
|
15
18
|
})
|
|
16
19
|
|
|
17
20
|
export type { StorefrontLoaderFn }
|
|
@@ -70,7 +70,7 @@ export const shellAccentSchema = z.object({
|
|
|
70
70
|
|
|
71
71
|
export const storefrontNavbarPropsSchema = z.object({
|
|
72
72
|
storeCategories: z.array(navCategorySchema),
|
|
73
|
-
shippingNote: z.string(),
|
|
73
|
+
shippingNote: z.string().optional(),
|
|
74
74
|
utilityLinks: z.array(z.object({ label: z.string(), href: z.string() })),
|
|
75
75
|
logoText: z.string(),
|
|
76
76
|
logoHref: z.string().optional(),
|
|
@@ -82,19 +82,6 @@ export const storefrontNavbarPropsSchema = z.object({
|
|
|
82
82
|
showSearch: z.boolean().optional(),
|
|
83
83
|
})
|
|
84
84
|
|
|
85
|
-
// Mirrors ShoppingCart4 (block) minus the runtime-bound `items`; the registry
|
|
86
|
-
// fills items from /cart. promotions carry icon NAMES resolved via icon-map.
|
|
87
|
-
export const cartPromotionSchema = z.object({
|
|
88
|
-
threshold: z.number(),
|
|
89
|
-
reward: z.string(),
|
|
90
|
-
icon: z.string(),
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
export const cartDrawerPropsSchema = z.object({
|
|
94
|
-
promotions: z.array(cartPromotionSchema),
|
|
95
|
-
taxRate: z.number().optional(),
|
|
96
|
-
})
|
|
97
|
-
|
|
98
85
|
export const bannerPropsSchema = z.object({
|
|
99
86
|
badge: z.string().optional(),
|
|
100
87
|
message: z.string(),
|
|
@@ -119,3 +106,31 @@ export const footerPropsSchema = z.object({
|
|
|
119
106
|
.object({ placeholder: z.string().optional(), buttonLabel: z.string().optional() })
|
|
120
107
|
.optional(),
|
|
121
108
|
})
|
|
109
|
+
|
|
110
|
+
// Navigability chrome contracts, mirrored from @thorprovider/blocks so the
|
|
111
|
+
// shell catalog/registry share the scaffold's zod version.
|
|
112
|
+
export const skipToContentPropsSchema = z.object({
|
|
113
|
+
targetId: z.string(),
|
|
114
|
+
label: z.string().optional(),
|
|
115
|
+
className: z.string().optional(),
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
export const backToTopPropsSchema = z.object({
|
|
119
|
+
threshold: z.number().optional(),
|
|
120
|
+
label: z.string().optional(),
|
|
121
|
+
className: z.string().optional(),
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
export const mobileBottomNavItemSchema = z.object({
|
|
125
|
+
id: z.string().optional(),
|
|
126
|
+
label: z.string(),
|
|
127
|
+
href: z.string(),
|
|
128
|
+
icon: z.string().optional(),
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
export const mobileBottomNavPropsSchema = z.object({
|
|
132
|
+
items: z.array(mobileBottomNavItemSchema),
|
|
133
|
+
currentHref: z.string().optional(),
|
|
134
|
+
ariaLabel: z.string().optional(),
|
|
135
|
+
className: z.string().optional(),
|
|
136
|
+
})
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
// Generated by Thor Commerce storefront (host: section observation)
|
|
4
|
+
//
|
|
5
|
+
// Optional, host-owned merchandising observation. DISABLED BY DEFAULT: nothing is
|
|
6
|
+
// observed, transmitted, or stored until the host registers an adapter through
|
|
7
|
+
// `configureSectionObservation`. The pure controller (validation, impression
|
|
8
|
+
// dedupe, conservative lineage) lives in @thorprovider/runtime/contracts; this
|
|
9
|
+
// module only adds the browser glue (visibility, per-view-visit identity) and the
|
|
10
|
+
// adapter seam. Adapter failures never interrupt navigation, cart, or checkout.
|
|
11
|
+
|
|
12
|
+
import { useEffect, useRef, useState } from 'react'
|
|
13
|
+
import {
|
|
14
|
+
createInMemoryRecorder,
|
|
15
|
+
createObservationController,
|
|
16
|
+
type ConfirmedEvent,
|
|
17
|
+
type ObservationContext,
|
|
18
|
+
type ObservationController,
|
|
19
|
+
type SectionObservationAdapter,
|
|
20
|
+
} from '@thorprovider/runtime/contracts'
|
|
21
|
+
|
|
22
|
+
let controller: ObservationController | null = null
|
|
23
|
+
let viewVisitId: string | null = null
|
|
24
|
+
let viewVisitSeq = 0
|
|
25
|
+
|
|
26
|
+
function nextViewVisitId(): string {
|
|
27
|
+
viewVisitSeq += 1
|
|
28
|
+
return `${Date.now().toString(36)}-${viewVisitSeq}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Stable for one view visit (page load). */
|
|
32
|
+
function getViewVisitId(): string {
|
|
33
|
+
if (!viewVisitId) viewVisitId = nextViewVisitId()
|
|
34
|
+
return viewVisitId
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function reportFailure(scope: string, error: unknown): void {
|
|
38
|
+
console.error(
|
|
39
|
+
`[storefront/observation] ${scope} failed`,
|
|
40
|
+
error instanceof Error ? error.message : error,
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Register (or clear) the host adapter. Null disables observation entirely. */
|
|
45
|
+
export function configureSectionObservation(adapter: SectionObservationAdapter | null): void {
|
|
46
|
+
controller = adapter
|
|
47
|
+
? createObservationController({
|
|
48
|
+
adapter,
|
|
49
|
+
onError: (error) => reportFailure('adapter', error),
|
|
50
|
+
})
|
|
51
|
+
: null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function isSectionObservationEnabled(): boolean {
|
|
55
|
+
return controller !== null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function getSectionObservationController(): ObservationController | null {
|
|
59
|
+
return controller
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** An in-memory recorder for verification/tests (never a production sink). */
|
|
63
|
+
export function createInMemorySectionRecorder(): ReturnType<typeof createInMemoryRecorder> {
|
|
64
|
+
return createInMemoryRecorder()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function setSectionObservationContext(context?: ObservationContext): void {
|
|
68
|
+
try {
|
|
69
|
+
controller?.setContext(context)
|
|
70
|
+
} catch (error) {
|
|
71
|
+
reportFailure('setContext', error)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Observe a confirmed host transition. Only recorded when a section origin
|
|
77
|
+
* exists for the current session/context; otherwise it stays unattributed.
|
|
78
|
+
*/
|
|
79
|
+
export function recordConfirmedTransition(
|
|
80
|
+
event: ConfirmedEvent,
|
|
81
|
+
input?: { productHandle?: string; destination?: string },
|
|
82
|
+
): void {
|
|
83
|
+
try {
|
|
84
|
+
controller?.noteConfirmed(event, input)
|
|
85
|
+
} catch (error) {
|
|
86
|
+
reportFailure(event, error)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface UseSectionObservationInput {
|
|
91
|
+
sectionId: string
|
|
92
|
+
sectionType: string
|
|
93
|
+
sectionVariant?: string
|
|
94
|
+
objective?: string
|
|
95
|
+
context?: ObservationContext
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Observe a section's exposure and product clicks. When observation is disabled
|
|
100
|
+
* no IntersectionObserver is attached and the returned callback is a no-op.
|
|
101
|
+
*/
|
|
102
|
+
export function useSectionObservation(input: UseSectionObservationInput) {
|
|
103
|
+
const enabled = isSectionObservationEnabled() && input.sectionId.length > 0
|
|
104
|
+
const containerRef = useRef<HTMLDivElement | null>(null)
|
|
105
|
+
const [inView, setInView] = useState(false)
|
|
106
|
+
const { sectionId, sectionType, sectionVariant, objective, context } = input
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!enabled) return
|
|
110
|
+
const element = containerRef.current
|
|
111
|
+
if (!element || typeof IntersectionObserver === 'undefined') return
|
|
112
|
+
const observer = new IntersectionObserver(
|
|
113
|
+
(entries) => {
|
|
114
|
+
for (const entry of entries) {
|
|
115
|
+
if (entry.intersectionRatio >= 0.5) setInView(true)
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
{ threshold: [0.5] },
|
|
119
|
+
)
|
|
120
|
+
observer.observe(element)
|
|
121
|
+
return () => observer.disconnect()
|
|
122
|
+
}, [enabled])
|
|
123
|
+
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (!enabled || !inView) return
|
|
126
|
+
const timer = setTimeout(() => {
|
|
127
|
+
controller?.observeImpression({
|
|
128
|
+
viewVisitId: getViewVisitId(),
|
|
129
|
+
sectionId,
|
|
130
|
+
sectionType,
|
|
131
|
+
...(sectionVariant ? { sectionVariant } : {}),
|
|
132
|
+
...(objective ? { objective } : {}),
|
|
133
|
+
...(context ? { context } : {}),
|
|
134
|
+
})
|
|
135
|
+
}, 1000)
|
|
136
|
+
return () => clearTimeout(timer)
|
|
137
|
+
}, [enabled, inView, sectionId, sectionType, sectionVariant, objective, context])
|
|
138
|
+
|
|
139
|
+
const noteProductClick = (product: { id: string | number; href?: string }): void => {
|
|
140
|
+
if (!enabled) return
|
|
141
|
+
try {
|
|
142
|
+
controller?.noteClick({
|
|
143
|
+
viewVisitId: getViewVisitId(),
|
|
144
|
+
sectionId,
|
|
145
|
+
sectionType,
|
|
146
|
+
...(sectionVariant ? { sectionVariant } : {}),
|
|
147
|
+
...(objective ? { objective } : {}),
|
|
148
|
+
...(context ? { context } : {}),
|
|
149
|
+
productHandle: String(product.id),
|
|
150
|
+
...(product.href ? { destination: product.href } : {}),
|
|
151
|
+
})
|
|
152
|
+
} catch (error) {
|
|
153
|
+
reportFailure('noteClick', error)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return { containerRef, noteProductClick, enabled }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Observe a successfully rendered product destination. Recorded only when it
|
|
162
|
+
* matches the current section origin; otherwise it stays unattributed.
|
|
163
|
+
*/
|
|
164
|
+
export function useProductVisitObservation(
|
|
165
|
+
productHandle: string | null | undefined,
|
|
166
|
+
destination?: string,
|
|
167
|
+
): void {
|
|
168
|
+
const enabled = isSectionObservationEnabled()
|
|
169
|
+
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
if (!enabled || !productHandle) return
|
|
172
|
+
try {
|
|
173
|
+
controller?.noteProductVisit({
|
|
174
|
+
viewVisitId: getViewVisitId(),
|
|
175
|
+
productHandle: String(productHandle),
|
|
176
|
+
...(destination ? { destination } : {}),
|
|
177
|
+
})
|
|
178
|
+
} catch (error) {
|
|
179
|
+
reportFailure('noteProductVisit', error)
|
|
180
|
+
}
|
|
181
|
+
}, [enabled, productHandle, destination])
|
|
182
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Derived host helper: per-instance resolution for data-bound sections.
|
|
2
|
+
//
|
|
3
|
+
// A data-bound section (for example `products`) keeps its *selection* in a
|
|
4
|
+
// user-owned, per-instance config keyed by the section's stable id
|
|
5
|
+
// (`views/<view>/data.ts` -> `sectionSources`). This module is the single host
|
|
6
|
+
// boundary that turns that config into resolved, validated view state:
|
|
7
|
+
//
|
|
8
|
+
// * it validates each source with the canonical `sectionSourceSchema` (an
|
|
9
|
+
// invalid or missing source yields an `unconfigured` state, never a guess);
|
|
10
|
+
// * it resolves each selection through the existing canonical catalog bridge
|
|
11
|
+
// (`getCatalogResults`), so equal queries share request-local work, differing
|
|
12
|
+
// store/channel/region/currency contexts stay isolated, and unsupported
|
|
13
|
+
// criteria are reported rather than dropped;
|
|
14
|
+
// * it maps each canonical product to the shared presentation contract via the
|
|
15
|
+
// host mapper, so an unresolved price stays truthful instead of becoming $0;
|
|
16
|
+
// * it returns one discriminated state per section id, so a failing section
|
|
17
|
+
// never erases its siblings and a real failure is never replaced by mock data.
|
|
18
|
+
//
|
|
19
|
+
// Orphan sources (ids no longer referenced by the composition) are ignored.
|
|
20
|
+
|
|
21
|
+
import { getCatalogResults } from '@thorprovider/runtime/commerce'
|
|
22
|
+
import {
|
|
23
|
+
MERCHANDISING_SECTION_MAX_ITEMS,
|
|
24
|
+
sectionSourceSchema,
|
|
25
|
+
} from '@thorprovider/runtime/contracts'
|
|
26
|
+
import {
|
|
27
|
+
PRODUCT_IMAGE_FALLBACK,
|
|
28
|
+
isCompleteFiniteDecimal,
|
|
29
|
+
type ProductPresentation,
|
|
30
|
+
} from '@thorprovider/blocks'
|
|
31
|
+
import type { Product, StoreContext } from '@thorprovider/types'
|
|
32
|
+
|
|
33
|
+
/** Per-instance section selection config, keyed by the section's stable id. */
|
|
34
|
+
export type SectionSources = Record<string, unknown>
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Canonical `Product` (unified provider output) → `ProductPresentation` for the
|
|
38
|
+
* merchandising product sections. Unlike the card mapper, this NEVER drops a
|
|
39
|
+
* product with an unresolved price: the canonical `Money` is passed through and
|
|
40
|
+
* the block renders a truthful "unavailable" state instead of inventing `$0`.
|
|
41
|
+
* Optional fields (image alt, rating, badge) are omitted — never `""` or a
|
|
42
|
+
* fabricated value — and a missing image falls back to the shared placeholder so
|
|
43
|
+
* no `<img>` is rendered without a `src`.
|
|
44
|
+
*
|
|
45
|
+
* This lives in the derived host helper (not the user-owned mappers module) so an
|
|
46
|
+
* upgraded home whose `mappers.ts` predates this capability still compiles.
|
|
47
|
+
*/
|
|
48
|
+
export function toProductPresentation(product: Product): ProductPresentation {
|
|
49
|
+
const min = product.priceRange?.minVariantPrice
|
|
50
|
+
const compareAt = product.variants.find(
|
|
51
|
+
(variant) => variant.compareAtPrice && isCompleteFiniteDecimal(variant.compareAtPrice.amount),
|
|
52
|
+
)?.compareAtPrice
|
|
53
|
+
const image = product.featuredImage?.url || product.images[0]?.url || PRODUCT_IMAGE_FALLBACK
|
|
54
|
+
const imageAlt = product.featuredImage?.altText || undefined
|
|
55
|
+
return {
|
|
56
|
+
id: product.handle,
|
|
57
|
+
title: product.title,
|
|
58
|
+
image,
|
|
59
|
+
imageAlt,
|
|
60
|
+
href: `/products/${product.handle}`,
|
|
61
|
+
price: min ?? { amount: '', currencyCode: '' },
|
|
62
|
+
compareAtPrice: compareAt,
|
|
63
|
+
rating: undefined,
|
|
64
|
+
badge: undefined,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The resolved state the view state model carries for one data-bound section. */
|
|
69
|
+
export interface SectionViewState {
|
|
70
|
+
status:
|
|
71
|
+
| 'unconfigured'
|
|
72
|
+
| 'ready'
|
|
73
|
+
| 'empty'
|
|
74
|
+
| 'invalid-query'
|
|
75
|
+
| 'unsupported-query'
|
|
76
|
+
| 'unmounted-capability'
|
|
77
|
+
| 'data-error'
|
|
78
|
+
products: ProductPresentation[]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Read the optional `sectionSources` export from a user-owned data module
|
|
83
|
+
* without hard-importing it, so a home scaffolded before this capability (whose
|
|
84
|
+
* `data.ts` has no such export) still compiles and simply renders every
|
|
85
|
+
* data-bound section as `unconfigured`.
|
|
86
|
+
*/
|
|
87
|
+
export function resolveSectionSources(module: unknown): SectionSources {
|
|
88
|
+
const candidate = (module as { sectionSources?: unknown } | null | undefined)?.sectionSources
|
|
89
|
+
return candidate && typeof candidate === 'object' ? (candidate as SectionSources) : {}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Resolve the state for every referenced data-bound section id. Sections resolve
|
|
94
|
+
* concurrently and independently; each entry is always present so the registry
|
|
95
|
+
* can render a truthful state for its section.
|
|
96
|
+
*/
|
|
97
|
+
export async function resolveSectionViewStates(
|
|
98
|
+
ids: readonly string[],
|
|
99
|
+
sources: SectionSources,
|
|
100
|
+
context: StoreContext,
|
|
101
|
+
): Promise<Record<string, SectionViewState>> {
|
|
102
|
+
const states: Record<string, SectionViewState> = {}
|
|
103
|
+
await Promise.all(
|
|
104
|
+
ids.map(async (id) => {
|
|
105
|
+
const parsed = sectionSourceSchema.safeParse(sources[id])
|
|
106
|
+
if (!parsed.success) {
|
|
107
|
+
states[id] = { status: 'unconfigured', products: [] }
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
const source = parsed.data
|
|
111
|
+
try {
|
|
112
|
+
const outcome = await getCatalogResults(source.query, context)
|
|
113
|
+
if (!outcome.ok) {
|
|
114
|
+
states[id] = { status: outcome.error.code, products: [] }
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
const maxItems = source.maxItems ?? MERCHANDISING_SECTION_MAX_ITEMS
|
|
118
|
+
const products = outcome.results.products
|
|
119
|
+
.slice(0, maxItems)
|
|
120
|
+
.map(toProductPresentation)
|
|
121
|
+
states[id] = { status: products.length > 0 ? 'ready' : 'empty', products }
|
|
122
|
+
} catch {
|
|
123
|
+
// A transport/provider failure is surfaced as a per-section data error,
|
|
124
|
+
// never as fabricated or mock products, and never discards siblings.
|
|
125
|
+
states[id] = { status: 'data-error', products: [] }
|
|
126
|
+
}
|
|
127
|
+
}),
|
|
128
|
+
)
|
|
129
|
+
return states
|
|
130
|
+
}
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Neutral defaults only: no delivery, shipping, or promo promise is made here,
|
|
8
8
|
// and footer links point only at routes the generated storefront actually
|
|
9
|
-
// serves (`/`, `/
|
|
10
|
-
// `/
|
|
9
|
+
// serves (`/`, `/categories`, `/checkout`, and the static pages `/contact`,
|
|
10
|
+
// `/about`, `/terms`, `/privacy`). Brand colors and copy belong
|
|
11
11
|
// in your own content, never in the package.
|
|
12
12
|
//
|
|
13
13
|
// The scaffold ships no newsletter submit callback, so the footer newsletter is
|
|
@@ -67,23 +67,31 @@ export const footerClassName: string | undefined = undefined
|
|
|
67
67
|
export const brandDescription: string =
|
|
68
68
|
'Catálogo en línea de productos para tu hogar y tu negocio.'
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Optional navbar utility-strip note (e.g. `'Nuevo horario de atención de 9 a
|
|
72
|
+
* 18 h'`). Empty by default: while it is empty and there are no utility links,
|
|
73
|
+
* the strip is not rendered, so a fresh storefront shows no note. Set a real
|
|
74
|
+
* note here to display it.
|
|
75
|
+
*/
|
|
76
|
+
export const shippingNote: string = ''
|
|
77
|
+
|
|
70
78
|
/** Neutral footer links — only routes the storefront serves; no dead `#` links. */
|
|
71
79
|
export const linkColumns: ShellLinkColumn[] = [
|
|
72
80
|
{
|
|
73
81
|
title: 'Comprar',
|
|
74
82
|
links: [
|
|
75
83
|
{ label: 'Inicio', href: '/' },
|
|
76
|
-
{ label: 'Todas las categorías', href: '/
|
|
84
|
+
{ label: 'Todas las categorías', href: '/categories' },
|
|
77
85
|
{ label: 'Finalizar compra', href: '/checkout' },
|
|
78
86
|
],
|
|
79
87
|
},
|
|
80
88
|
{
|
|
81
89
|
title: 'Información',
|
|
82
90
|
links: [
|
|
83
|
-
{ label: 'Contacto', href: '/
|
|
84
|
-
{ label: 'Sobre nosotros', href: '/
|
|
85
|
-
{ label: 'Términos y condiciones', href: '/
|
|
86
|
-
{ label: 'Política de privacidad', href: '/
|
|
91
|
+
{ label: 'Contacto', href: '/contact' },
|
|
92
|
+
{ label: 'Sobre nosotros', href: '/about' },
|
|
93
|
+
{ label: 'Términos y condiciones', href: '/terms' },
|
|
94
|
+
{ label: 'Política de privacidad', href: '/privacy' },
|
|
87
95
|
],
|
|
88
96
|
},
|
|
89
97
|
]
|
|
@@ -94,3 +102,19 @@ export const linkColumns: ShellLinkColumn[] = [
|
|
|
94
102
|
* renders no form — never a fake one.
|
|
95
103
|
*/
|
|
96
104
|
export const newsletter: false | ShellNewsletter = false
|
|
105
|
+
|
|
106
|
+
// Primary destinations for the mobile bottom navigation. Only routes the
|
|
107
|
+
// storefront actually serves; the icons come from the shared category icon set.
|
|
108
|
+
export interface ShellBottomNavItem {
|
|
109
|
+
id?: string
|
|
110
|
+
label: string
|
|
111
|
+
href: string
|
|
112
|
+
icon?: string
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const mobileBottomNavItems: ShellBottomNavItem[] = [
|
|
116
|
+
{ id: 'home', label: 'Inicio', href: '/', icon: 'home' },
|
|
117
|
+
{ id: 'categories', label: 'Categorías', href: '/categories', icon: 'store' },
|
|
118
|
+
{ id: 'cart', label: 'Carrito', href: '/cart', icon: 'boxes' },
|
|
119
|
+
{ id: 'account', label: 'Cuenta', href: '/account', icon: 'shield' },
|
|
120
|
+
]
|