@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
|
@@ -22,7 +22,7 @@ import { useStateStore } from '@json-render/react'
|
|
|
22
22
|
import type { Customer } from '@thorprovider/types'
|
|
23
23
|
import {
|
|
24
24
|
loadAccountAction,
|
|
25
|
-
|
|
25
|
+
loadOrdersPageAction,
|
|
26
26
|
loginAction,
|
|
27
27
|
logoutAction,
|
|
28
28
|
registerAction,
|
|
@@ -39,6 +39,9 @@ import {
|
|
|
39
39
|
type AuthResult = { ok: true; customer: Customer | null } | { ok: false; error: string }
|
|
40
40
|
type SetState = (path: string, value: unknown) => void
|
|
41
41
|
|
|
42
|
+
/** Bounded page size for the account order-history window. */
|
|
43
|
+
const ORDERS_PAGE_SIZE = 10
|
|
44
|
+
|
|
42
45
|
// Apply a settled auth result truthfully: a failure sets an error and NEVER a
|
|
43
46
|
// logged-in state; a success stores the customer (or null) and nothing else.
|
|
44
47
|
function applyAuthResult(set: SetState, result: AuthResult): void {
|
|
@@ -100,9 +103,11 @@ interface BlockOrder {
|
|
|
100
103
|
items: BlockOrderItem[]
|
|
101
104
|
}
|
|
102
105
|
|
|
103
|
-
// Map a backend-sourced account order row to the block's order shape. The
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
+
// Map a backend-sourced account order row to the block's order shape. The block
|
|
107
|
+
// has a single `id` slot which it also renders ("Pedido {id}"), so it carries the
|
|
108
|
+
// display number; the registry keeps a number→backend-id map so selection is
|
|
109
|
+
// keyed by the REAL backend order id. An absent variant/image becomes '' because
|
|
110
|
+
// the block contract is a string — never an invented value.
|
|
106
111
|
function toBlockOrder(order: AccountOrderView): BlockOrder {
|
|
107
112
|
return {
|
|
108
113
|
id: order.number,
|
|
@@ -295,26 +300,30 @@ export const registry: ViewRegistry = {
|
|
|
295
300
|
)
|
|
296
301
|
},
|
|
297
302
|
/**
|
|
298
|
-
* Signed-in order history. The
|
|
299
|
-
* Server Action;
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
* backend
|
|
303
|
+
* Signed-in order history. The first page loads once from the cookie-authorized
|
|
304
|
+
* Server Action; later pages load on demand, each scoped to the authenticated
|
|
305
|
+
* customer's channel. The backend's real `count` drives pagination, so a page
|
|
306
|
+
* length is never presented as the total. Selection is keyed by the REAL
|
|
307
|
+
* backend order id (via a number→id map), so an opened detail stays stable
|
|
308
|
+
* across paging and reload. No token is read, stored, or rendered here.
|
|
303
309
|
*/
|
|
304
310
|
AccountOrders: ({ props }) => {
|
|
305
311
|
const { state, set } = useStateStore()
|
|
306
312
|
const parsed = accountOrdersPropsSchema.parse(props)
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
313
|
+
const authenticated = Boolean(state.accountAuthenticated)
|
|
314
|
+
const loadedForAuth = useRef(false)
|
|
315
|
+
|
|
316
|
+
const loadPage = (page: number) => {
|
|
311
317
|
set('/accountOrdersPending', true)
|
|
312
318
|
set('/accountOrdersError', null)
|
|
313
|
-
void
|
|
319
|
+
void loadOrdersPageAction({ page, pageSize: ORDERS_PAGE_SIZE })
|
|
314
320
|
.then((result) => {
|
|
315
321
|
set('/accountOrdersPending', false)
|
|
316
322
|
if (result.ok) {
|
|
317
323
|
set('/accountOrders', result.orders)
|
|
324
|
+
set('/accountOrdersCount', result.count)
|
|
325
|
+
set('/accountOrdersPage', page)
|
|
326
|
+
set('/accountOrdersPageSize', result.limit)
|
|
318
327
|
set('/accountOrdersError', null)
|
|
319
328
|
return
|
|
320
329
|
}
|
|
@@ -328,19 +337,40 @@ export const registry: ViewRegistry = {
|
|
|
328
337
|
set('/accountOrdersPending', false)
|
|
329
338
|
set('/accountOrdersError', parsed.errorLabel ?? 'No se pudieron cargar tus pedidos.')
|
|
330
339
|
})
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
useEffect(() => {
|
|
343
|
+
// Load the first page when the customer becomes authenticated, and reload
|
|
344
|
+
// after a re-login, so the history appears without a manual reload. A
|
|
345
|
+
// guest load is never attempted; the flag resets when the session ends.
|
|
346
|
+
if (!authenticated) {
|
|
347
|
+
loadedForAuth.current = false
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
if (loadedForAuth.current) return
|
|
351
|
+
loadedForAuth.current = true
|
|
352
|
+
loadPage(1)
|
|
331
353
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
332
|
-
}, [])
|
|
354
|
+
}, [authenticated])
|
|
333
355
|
|
|
334
356
|
const pending = Boolean(state.accountOrdersPending)
|
|
335
357
|
const error = typeof state.accountOrdersError === 'string' ? state.accountOrdersError : null
|
|
336
358
|
const orders = Array.isArray(state.accountOrders)
|
|
337
359
|
? (state.accountOrders as AccountOrderView[])
|
|
338
360
|
: []
|
|
361
|
+
const count =
|
|
362
|
+
typeof state.accountOrdersCount === 'number' ? state.accountOrdersCount : orders.length
|
|
363
|
+
const page = typeof state.accountOrdersPage === 'number' ? state.accountOrdersPage : 1
|
|
364
|
+
const pageSize =
|
|
365
|
+
typeof state.accountOrdersPageSize === 'number' && state.accountOrdersPageSize > 0
|
|
366
|
+
? state.accountOrdersPageSize
|
|
367
|
+
: ORDERS_PAGE_SIZE
|
|
339
368
|
const selectedId =
|
|
340
369
|
typeof state.accountSelectedOrderId === 'string' ? state.accountSelectedOrderId : null
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
370
|
+
// Selection is keyed by the REAL backend order id, never the display number.
|
|
371
|
+
const selected = selectedId ? orders.find((order) => order.id === selectedId) ?? null : null
|
|
372
|
+
// The block row carries the display number; resolve it back to the backend id.
|
|
373
|
+
const backendIdByBlockId = new Map(orders.map((order) => [order.number, order.id]))
|
|
344
374
|
|
|
345
375
|
if (pending) {
|
|
346
376
|
return (
|
|
@@ -363,14 +393,45 @@ export const registry: ViewRegistry = {
|
|
|
363
393
|
</p>
|
|
364
394
|
)
|
|
365
395
|
}
|
|
396
|
+
const totalPages = Math.max(1, Math.ceil(count / pageSize))
|
|
366
397
|
return (
|
|
367
398
|
<>
|
|
368
399
|
<OrderHistory2
|
|
369
400
|
orders={orders.map(toBlockOrder)}
|
|
370
401
|
titleLabel={parsed.titleLabel}
|
|
371
402
|
descriptionLabel={parsed.descriptionLabel}
|
|
372
|
-
|
|
403
|
+
// The block renders "Total de pedidos: {n}" from the PAGE length; pass a
|
|
404
|
+
// label without `{n}` so a page length is never shown as the total.
|
|
405
|
+
totalOrdersLabel={parsed.totalOrdersLabel ?? 'Pedidos'}
|
|
406
|
+
onViewDetails={(order) =>
|
|
407
|
+
set('/accountSelectedOrderId', backendIdByBlockId.get(order.id) ?? null)
|
|
408
|
+
}
|
|
373
409
|
/>
|
|
410
|
+
{count > pageSize ? (
|
|
411
|
+
<div className="mx-auto flex w-full max-w-7xl items-center justify-between gap-4 px-4 pb-6 sm:px-6 lg:px-8">
|
|
412
|
+
<p className="text-muted-foreground text-sm" role="status" aria-live="polite">
|
|
413
|
+
{`${count} pedidos · página ${page} de ${totalPages}`}
|
|
414
|
+
</p>
|
|
415
|
+
<div className="flex gap-2">
|
|
416
|
+
<Button
|
|
417
|
+
variant="outline"
|
|
418
|
+
size="sm"
|
|
419
|
+
disabled={pending || page <= 1}
|
|
420
|
+
onClick={() => loadPage(page - 1)}
|
|
421
|
+
>
|
|
422
|
+
Anterior
|
|
423
|
+
</Button>
|
|
424
|
+
<Button
|
|
425
|
+
variant="outline"
|
|
426
|
+
size="sm"
|
|
427
|
+
disabled={pending || page >= totalPages}
|
|
428
|
+
onClick={() => loadPage(page + 1)}
|
|
429
|
+
>
|
|
430
|
+
Siguiente
|
|
431
|
+
</Button>
|
|
432
|
+
</div>
|
|
433
|
+
</div>
|
|
434
|
+
) : null}
|
|
374
435
|
{selected ? <OrderDetail order={selected} /> : null}
|
|
375
436
|
</>
|
|
376
437
|
)
|
|
@@ -38,6 +38,7 @@ export const accountRegisterPropsSchema = z.object({
|
|
|
38
38
|
export const accountOrdersPropsSchema = z.object({
|
|
39
39
|
titleLabel: z.string().optional(),
|
|
40
40
|
descriptionLabel: z.string().optional(),
|
|
41
|
+
totalOrdersLabel: z.string().optional(),
|
|
41
42
|
emptyLabel: z.string().optional(),
|
|
42
43
|
errorLabel: z.string().optional(),
|
|
43
44
|
loadingLabel: z.string().optional(),
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Thor Commerce view content: cart (user-owned)
|
|
2
|
+
// Merchant-editable copy for the standalone cart review page. Seeded once by the
|
|
3
|
+
// scaffolder and never overwritten on sync.
|
|
4
|
+
|
|
5
|
+
export const cartContent = {
|
|
6
|
+
titleLabel: 'Tu carrito',
|
|
7
|
+
checkoutLabel: 'Finalizar compra',
|
|
8
|
+
continueShoppingLabel: 'Seguir comprando',
|
|
9
|
+
emptyLabel: 'Tu carrito está vacío',
|
|
10
|
+
readErrorLabel: 'No se pudo cargar tu carrito.',
|
|
11
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Thor Commerce view data: cart (user-owned)
|
|
2
|
+
// Pure mapping from the shared host cart state to the ShoppingCart4 block props.
|
|
3
|
+
// No React and no server imports so it can be imported from either boundary.
|
|
4
|
+
|
|
5
|
+
import type { ShoppingCart4Item } from '@thorprovider/blocks'
|
|
6
|
+
import type { CartLineState } from '../../state'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Maps backend cart lines to the cart block's item shape. `price` stays 0 (the
|
|
10
|
+
* legacy numeric field) because the block renders the canonical `lineTotal`
|
|
11
|
+
* Money; an absent image is left undefined rather than serialized as "".
|
|
12
|
+
*/
|
|
13
|
+
export const toCartViewItems = (cart: CartLineState[]): ShoppingCart4Item[] =>
|
|
14
|
+
cart.map(line => ({
|
|
15
|
+
id: line.id,
|
|
16
|
+
name: line.name,
|
|
17
|
+
price: 0,
|
|
18
|
+
quantity: line.quantity,
|
|
19
|
+
image: line.image,
|
|
20
|
+
variant: line.variant,
|
|
21
|
+
lineTotal: line.lineTotal,
|
|
22
|
+
unitPrice: line.unitPrice,
|
|
23
|
+
}))
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Thor Commerce view: cart
|
|
2
|
+
// The standalone cart review route. Its data comes from the shared host cart
|
|
3
|
+
// state injected on every route (readCartStateForRender), so the loader only
|
|
4
|
+
// reports the data source — no dedicated cart fetch happens here.
|
|
5
|
+
|
|
6
|
+
import { getDataSource } from '../../store-context'
|
|
7
|
+
import { pageRoot } from '../../spec/page-root'
|
|
8
|
+
import type { StorefrontElement } from '../../spec/types'
|
|
9
|
+
import type { ViewModule } from '../types'
|
|
10
|
+
import { cartViewProps } from './props'
|
|
11
|
+
import { registry } from './registry'
|
|
12
|
+
import { cartViewPropsSchema } from './schema'
|
|
13
|
+
|
|
14
|
+
const elements: Record<string, StorefrontElement> = {
|
|
15
|
+
'cart.view': {
|
|
16
|
+
type: 'CartView',
|
|
17
|
+
props: cartViewProps,
|
|
18
|
+
on: {
|
|
19
|
+
refreshCart: { action: 'refreshCart' },
|
|
20
|
+
updateQuantity: { action: 'updateQuantity' },
|
|
21
|
+
removeFromCart: { action: 'removeFromCart' },
|
|
22
|
+
goToCheckout: { action: 'goToCheckout' },
|
|
23
|
+
goHome: { action: 'goHome' },
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const catalog = {
|
|
29
|
+
CartView: {
|
|
30
|
+
props: cartViewPropsSchema,
|
|
31
|
+
description: 'Standalone cart review page with backend-authoritative totals.',
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const cart: ViewModule = {
|
|
36
|
+
name: 'cart',
|
|
37
|
+
elements,
|
|
38
|
+
catalog,
|
|
39
|
+
registry,
|
|
40
|
+
route: {
|
|
41
|
+
path: '/cart',
|
|
42
|
+
loaderName: 'cart',
|
|
43
|
+
layout: 'default',
|
|
44
|
+
metadata: {
|
|
45
|
+
title: 'Carrito',
|
|
46
|
+
description: 'Revisa los artículos de tu carrito antes de finalizar la compra.',
|
|
47
|
+
},
|
|
48
|
+
entry: pageRoot('cart', ['cart.view'], elements),
|
|
49
|
+
},
|
|
50
|
+
loader: {
|
|
51
|
+
cart: async () => ({ dataSource: getDataSource(), dataError: null }),
|
|
52
|
+
},
|
|
53
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Thor Commerce view props: cart (user-owned)
|
|
2
|
+
// Element props for the cart review view. The cart lines and backend-authoritative
|
|
3
|
+
// totals are read from the shared host state (injected for every route), so this
|
|
4
|
+
// view needs no dedicated data fetch.
|
|
5
|
+
|
|
6
|
+
import { cartContent } from './content'
|
|
7
|
+
|
|
8
|
+
export const cartViewProps = {
|
|
9
|
+
...cartContent,
|
|
10
|
+
items: { $state: '/cart' },
|
|
11
|
+
totals: { $state: '/cartTotals' },
|
|
12
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
// Thor Commerce view registry: cart
|
|
4
|
+
// Renders the standalone cart review page (page-mode ShoppingCart4) from the
|
|
5
|
+
// shared host cart state. Quantity updates use the ABSOLUTE line quantity keyed
|
|
6
|
+
// by backend line id; removal uses the same line id. Checkout is only offered
|
|
7
|
+
// when the checkout capability is installed and the cart is ready.
|
|
8
|
+
|
|
9
|
+
import { Button, ShoppingCart4 } from '@thorprovider/blocks'
|
|
10
|
+
import { isAvailableMoney } from '@thorprovider/blocks/contracts'
|
|
11
|
+
import { useStateStore } from '@json-render/react'
|
|
12
|
+
import { checkoutAvailable } from '../../composition'
|
|
13
|
+
import type { ViewRegistry } from '../types'
|
|
14
|
+
import { toCartViewItems } from './data'
|
|
15
|
+
import { cartViewPropsSchema } from './schema'
|
|
16
|
+
|
|
17
|
+
export const registry: ViewRegistry = {
|
|
18
|
+
CartView: ({ props, emit }) => {
|
|
19
|
+
const { state, set } = useStateStore()
|
|
20
|
+
const parsed = cartViewPropsSchema.parse(props)
|
|
21
|
+
const cart = parsed.items
|
|
22
|
+
const cartError = typeof state.cartError === 'string' ? state.cartError : undefined
|
|
23
|
+
const readError = typeof state.cartReadError === 'string' ? state.cartReadError : undefined
|
|
24
|
+
const isPending = state.cartPending === true
|
|
25
|
+
const errorLabel = cartError ?? readError
|
|
26
|
+
const totalsState = isPending ? 'pending' : errorLabel ? 'error' : 'idle'
|
|
27
|
+
// Checkout is offered only when the required backend amounts are present and
|
|
28
|
+
// valid: a missing/malformed subtotal, total, or line amount blocks it, while
|
|
29
|
+
// an undetermined shipping amount does not (AC-CART-4.4 / UC-CART-7).
|
|
30
|
+
const amountsValid =
|
|
31
|
+
cart.every((line) => isAvailableMoney(line.lineTotal)) &&
|
|
32
|
+
parsed.totals != null &&
|
|
33
|
+
isAvailableMoney(parsed.totals.subtotal) &&
|
|
34
|
+
isAvailableMoney(parsed.totals.total)
|
|
35
|
+
|
|
36
|
+
// A read failure with no confirmed lines is not an empty cart: surface it as
|
|
37
|
+
// a recoverable error rather than the empty state.
|
|
38
|
+
if (readError && cart.length === 0) {
|
|
39
|
+
return (
|
|
40
|
+
<section className='mx-auto max-w-3xl px-4 py-8'>
|
|
41
|
+
<div role='alert' className='flex flex-col items-start gap-3'>
|
|
42
|
+
<p className='text-destructive text-sm'>{parsed.readErrorLabel ?? readError}</p>
|
|
43
|
+
<Button variant='outline' size='sm' onClick={() => emit('refreshCart')}>
|
|
44
|
+
Reintentar
|
|
45
|
+
</Button>
|
|
46
|
+
</div>
|
|
47
|
+
</section>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<ShoppingCart4
|
|
53
|
+
presentation='page'
|
|
54
|
+
items={toCartViewItems(cart)}
|
|
55
|
+
promotions={[]}
|
|
56
|
+
totals={parsed.totals ?? undefined}
|
|
57
|
+
totalsState={totalsState}
|
|
58
|
+
totalsErrorLabel={errorLabel}
|
|
59
|
+
titleLabel={parsed.titleLabel}
|
|
60
|
+
checkoutLabel={parsed.checkoutLabel}
|
|
61
|
+
continueShoppingLabel={parsed.continueShoppingLabel}
|
|
62
|
+
emptyLabel={parsed.emptyLabel}
|
|
63
|
+
onRetryTotals={() => emit('refreshCart')}
|
|
64
|
+
onQuantityChange={(id, quantity) => {
|
|
65
|
+
const line = cart.find(item => item.id === id)
|
|
66
|
+
if (!line) return
|
|
67
|
+
set('/qtyUpdate', { id, merchandiseId: line.merchandiseId, quantity })
|
|
68
|
+
emit('updateQuantity')
|
|
69
|
+
}}
|
|
70
|
+
onRemove={id => {
|
|
71
|
+
set('/lastRemoved', id)
|
|
72
|
+
emit('removeFromCart')
|
|
73
|
+
}}
|
|
74
|
+
onCheckout={() => emit('goToCheckout')}
|
|
75
|
+
onContinueShopping={() => emit('goHome')}
|
|
76
|
+
checkoutUnavailable={!checkoutAvailable}
|
|
77
|
+
checkoutDisabled={isPending || Boolean(errorLabel) || cart.length === 0 || !amountsValid}
|
|
78
|
+
/>
|
|
79
|
+
)
|
|
80
|
+
},
|
|
81
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Thor Commerce view schema fragment: cart
|
|
2
|
+
// Zod mirrors of the block contracts for this view — the single source for the
|
|
3
|
+
// catalog entry and the client-side registry parsing (json-render does not
|
|
4
|
+
// validate props against the catalog itself, so the registry parses here).
|
|
5
|
+
// Kept free of React and server imports so the Server Component module and the
|
|
6
|
+
// 'use client' registry can share one contract. Block zod contracts are
|
|
7
|
+
// mirrored, not imported: @thorprovider/blocks ships zod v3 while the scaffold
|
|
8
|
+
// runs zod v4 and cross-major schema assignment breaks the catalog contract.
|
|
9
|
+
|
|
10
|
+
import { z } from 'zod'
|
|
11
|
+
|
|
12
|
+
// Canonical Money: a decimal string in major units ("69.99"), never cents.
|
|
13
|
+
const moneySchema = z.object({
|
|
14
|
+
amount: z.string(),
|
|
15
|
+
currencyCode: z.string(),
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
// Mirrors CartLineState: `id` is the backend line-item id used for absolute
|
|
19
|
+
// updates and removal; `lineTotal`/`unitPrice` are the authoritative backend
|
|
20
|
+
// amounts. An absent image stays absent (never serialized as "").
|
|
21
|
+
export const cartLineSchema = z.object({
|
|
22
|
+
id: z.string(),
|
|
23
|
+
merchandiseId: z.string(),
|
|
24
|
+
name: z.string(),
|
|
25
|
+
variant: z.string().optional(),
|
|
26
|
+
quantity: z.number(),
|
|
27
|
+
lineTotal: moneySchema,
|
|
28
|
+
unitPrice: moneySchema.optional(),
|
|
29
|
+
image: z.string().optional(),
|
|
30
|
+
handle: z.string().optional(),
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
// Mirrors CartTotalsState: backend-authoritative totals rendered verbatim.
|
|
34
|
+
export const cartTotalsSchema = z.object({
|
|
35
|
+
subtotal: moneySchema,
|
|
36
|
+
discount: moneySchema.optional(),
|
|
37
|
+
tax: moneySchema.optional(),
|
|
38
|
+
shipping: moneySchema.optional(),
|
|
39
|
+
total: moneySchema,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
export const cartViewPropsSchema = z.object({
|
|
43
|
+
items: z.array(cartLineSchema),
|
|
44
|
+
totals: cartTotalsSchema.nullish(),
|
|
45
|
+
titleLabel: z.string().optional(),
|
|
46
|
+
checkoutLabel: z.string().optional(),
|
|
47
|
+
continueShoppingLabel: z.string().optional(),
|
|
48
|
+
emptyLabel: z.string().optional(),
|
|
49
|
+
readErrorLabel: z.string().optional(),
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
export type CartViewProps = z.infer<typeof cartViewPropsSchema>
|
|
@@ -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'@@SECTION_IMPORTS@@
|
|
32
|
+
import { categoryBreadcrumbProps, categoryFilterProps } from './props'
|
|
33
|
+
import { breadcrumbPropsSchema, categoryFilterPropsSchema } from './schema'@@SECTION_IMPORTS@@
|
|
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'@@SECTION_IMPORTS@@
|
|
|
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[]
|
|
@@ -75,15 +79,67 @@ const emptyFilterState = () => ({
|
|
|
75
79
|
categoryFilterCategories: [],
|
|
76
80
|
categoryFilterCollections: [],
|
|
77
81
|
categoryFilterPriceRange: undefined,
|
|
82
|
+
breadcrumbItems: [] as { label: string; href?: string }[],
|
|
78
83
|
})
|
|
79
84
|
|
|
85
|
+
/**
|
|
86
|
+
* The real category entity for the current route scope, resolved from the
|
|
87
|
+
* backend catalog. Entity SEO reads this; an unknown/absent scope yields no
|
|
88
|
+
* entity (no metadata or structured data is fabricated).
|
|
89
|
+
*/
|
|
90
|
+
function resolveCategoryEntity(
|
|
91
|
+
slug: string | undefined,
|
|
92
|
+
catalog: Awaited<ReturnType<typeof getCatalog>>,
|
|
93
|
+
): { handle: string; name: string; description?: string } | undefined {
|
|
94
|
+
if (!slug) return undefined
|
|
95
|
+
const match = catalog.categoriesRaw.find((entry) => entry.handle === slug)
|
|
96
|
+
if (!match) return undefined
|
|
97
|
+
const description =
|
|
98
|
+
typeof match.description === 'string' && match.description.trim() !== ''
|
|
99
|
+
? match.description
|
|
100
|
+
: undefined
|
|
101
|
+
return {
|
|
102
|
+
handle: match.handle,
|
|
103
|
+
name: match.name,
|
|
104
|
+
...(description !== undefined ? { description } : {}),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Entity-derived breadcrumb trail for the current category scope: Home, the
|
|
110
|
+
* real ancestor chain (root -> leaf), and the current category. Derived from
|
|
111
|
+
* the backend category hierarchy (`parentCategoryId`); no level is fabricated,
|
|
112
|
+
* a cycle cannot loop forever, and no trail is produced when the navigability
|
|
113
|
+
* feature is not installed or the scope is unknown.
|
|
114
|
+
*/
|
|
115
|
+
function resolveBreadcrumbItems(
|
|
116
|
+
slug: string | undefined,
|
|
117
|
+
catalog: Awaited<ReturnType<typeof getCatalog>>,
|
|
118
|
+
): { label: string; href?: string }[] {
|
|
119
|
+
if (!hasNavigability || !slug) return []
|
|
120
|
+
const byHandle = new Map(catalog.categoriesRaw.map((entry) => [entry.handle, entry]))
|
|
121
|
+
const byId = new Map(catalog.categoriesRaw.map((entry) => [entry.id, entry]))
|
|
122
|
+
const chain: { label: string; href: string }[] = []
|
|
123
|
+
const visited = new Set<string>()
|
|
124
|
+
let current = byHandle.get(slug)
|
|
125
|
+
while (current && !visited.has(current.handle)) {
|
|
126
|
+
visited.add(current.handle)
|
|
127
|
+
chain.unshift({ label: current.name, href: `/categories/${current.handle}` })
|
|
128
|
+
current = current.parentCategoryId ? byId.get(current.parentCategoryId) : undefined
|
|
129
|
+
}
|
|
130
|
+
if (chain.length === 0) return []
|
|
131
|
+
const leaf = chain[chain.length - 1]
|
|
132
|
+
// The current (leaf) item is not a link; only ancestors are navigable.
|
|
133
|
+
return [{ label: 'Inicio', href: '/' }, ...chain.slice(0, -1), { label: leaf.label }]
|
|
134
|
+
}
|
|
135
|
+
|
|
80
136
|
export const category: ViewModule = {
|
|
81
137
|
name: 'category',
|
|
82
138
|
elements,
|
|
83
139
|
catalog,
|
|
84
140
|
registry,
|
|
85
141
|
route: {
|
|
86
|
-
path: '/
|
|
142
|
+
path: '/categories/[[...slug]]',
|
|
87
143
|
loaderName: 'category',
|
|
88
144
|
layout: 'default',
|
|
89
145
|
metadata: {
|
|
@@ -144,13 +200,13 @@ export const category: ViewModule = {
|
|
|
144
200
|
const presentation = mapCatalogResultsToFilterProps({
|
|
145
201
|
query: listing.query,
|
|
146
202
|
results: listing.results,
|
|
147
|
-
// A scoped listing (`/
|
|
203
|
+
// A scoped listing (`/categories/<handle>`) must not offer the
|
|
148
204
|
// category facet: the scope already fixes the category. The root
|
|
149
205
|
// catalog passes no scope and keeps it.
|
|
150
206
|
...(slug ? { categoryScope: slug } : {}),
|
|
151
207
|
// Product links point at the product-detail route. The runtime stays
|
|
152
208
|
// route-agnostic, so this view supplies the href builder.
|
|
153
|
-
productHref: (handle: string) => `/
|
|
209
|
+
productHref: (handle: string) => `/products/${handle}`,
|
|
154
210
|
...(categories ? { categories } : {}),
|
|
155
211
|
...(collections ? { collections } : {}),
|
|
156
212
|
sortOptions,
|
|
@@ -162,6 +218,8 @@ export const category: ViewModule = {
|
|
|
162
218
|
|
|
163
219
|
return {
|
|
164
220
|
storeCategories: catalog.categories,
|
|
221
|
+
categoryEntity: resolveCategoryEntity(slug, catalog),
|
|
222
|
+
breadcrumbItems: resolveBreadcrumbItems(slug, catalog),
|
|
165
223
|
dataSource: catalog.source,
|
|
166
224
|
dataError: null,
|
|
167
225
|
categoryFilterProducts: presentation.products,
|
|
@@ -14,7 +14,8 @@ import {
|
|
|
14
14
|
type CategoryFilter2ActiveFilter,
|
|
15
15
|
type CategoryFilter2Filters,
|
|
16
16
|
type CategoryFilter2Props,
|
|
17
|
-
} from '@thorprovider/blocks'
|
|
17
|
+
} from '@thorprovider/blocks'
|
|
18
|
+
@@BLOCK_IMPORTS@@@@SECTION_REGISTRY_IMPORTS@@
|
|
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'
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Breadcrumb: ({ props }) => {
|
|
2
|
+
const p = props as {
|
|
3
|
+
items: { label: string; href?: string }[]
|
|
4
|
+
maxItems?: number
|
|
5
|
+
ariaLabel?: string
|
|
6
|
+
backLabel?: string
|
|
7
|
+
className?: string
|
|
8
|
+
}
|
|
9
|
+
return (
|
|
10
|
+
<BreadcrumbBlock
|
|
11
|
+
items={p.items}
|
|
12
|
+
maxItems={p.maxItems}
|
|
13
|
+
ariaLabel={p.ariaLabel}
|
|
14
|
+
backLabel={p.backLabel}
|
|
15
|
+
className={p.className}
|
|
16
|
+
/>
|
|
17
|
+
)
|
|
18
|
+
},
|