@thorprovider/blocks 0.1.3 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +73 -29
  3. package/docs/standards/data-contracts.md +13 -2
  4. package/package.json +26 -10
  5. package/scripts/fix-esm-extensions.mjs +40 -0
  6. package/src/blocks/e-commerce/product-categories/category-card-grid.test.tsx +122 -0
  7. package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
  8. package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
  9. package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
  10. package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
  11. package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
  12. package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
  13. package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
  14. package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
  15. package/src/blocks/e-commerce/product-list/product-card.tsx +424 -0
  16. package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
  17. package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
  18. package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
  19. package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
  20. package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
  21. package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
  22. package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
  23. package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
  24. package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
  25. package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
  26. package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +119 -85
  27. package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
  28. package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
  29. package/src/blocks/marketing/features/feature.test.tsx +153 -0
  30. package/src/blocks/marketing/features/feature.tsx +195 -0
  31. package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +27 -9
  32. package/src/blocks/marketing/navbars/navbar-4.test.tsx +33 -2
  33. package/src/blocks/marketing/navbars/navbar-4.tsx +29 -11
  34. package/src/index.ts +77 -48
  35. package/src/lib/contracts-curated.test.ts +42 -24
  36. package/src/lib/contracts.test.ts +143 -2
  37. package/src/lib/contracts.ts +346 -14
  38. package/src/ui/button-group.tsx +1 -1
  39. package/src/ui/carousel.tsx +1 -1
  40. package/src/ui/dialog.tsx +1 -1
  41. package/src/ui/field.tsx +2 -2
  42. package/src/ui/input-group.tsx +3 -3
  43. package/src/ui/pagination.tsx +1 -1
  44. package/src/ui/sidebar.tsx +7 -7
  45. package/src/ui/toggle-group.tsx +1 -1
  46. package/tsconfig.build.json +12 -0
  47. package/tsup.config.ts +11 -7
  48. package/.turbo/turbo-build.log +0 -24
  49. package/.turbo/turbo-test.log +0 -47
  50. package/.turbo/turbo-type-check.log +0 -0
  51. package/dist/.tsbuildinfo +0 -1
  52. package/dist/contracts.d.mts +0 -1732
  53. package/dist/contracts.d.ts +0 -1732
  54. package/dist/contracts.js +0 -423
  55. package/dist/contracts.js.map +0 -1
  56. package/dist/contracts.mjs +0 -365
  57. package/dist/contracts.mjs.map +0 -1
  58. package/dist/index.d.mts +0 -3068
  59. package/dist/index.d.ts +0 -3068
  60. package/dist/index.js +0 -17834
  61. package/dist/index.js.map +0 -1
  62. package/dist/index.mjs +0 -17740
  63. package/dist/index.mjs.map +0 -1
  64. package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
  65. package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
  66. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
  67. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
  68. package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
  69. package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
  70. package/src/blocks/e-commerce/product-list/product-card-1.test.tsx +0 -88
  71. package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -192
  72. package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
  73. package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
  74. package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
  75. package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
  76. package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
  77. package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
  78. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
  79. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
  80. package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
  81. package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
  82. package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
  83. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
  84. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
  85. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
  86. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
  87. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
  88. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
  89. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
  90. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
  91. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
  92. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -229
  93. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -72
  94. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
  95. package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
  96. package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
  97. package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
  98. package/src/blocks/marketing/features/feature-1.tsx +0 -97
  99. package/src/blocks/marketing/features/feature-2.tsx +0 -82
package/CHANGELOG.md ADDED
@@ -0,0 +1,107 @@
1
+ # @thorprovider/blocks
2
+
3
+ ## 1.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - f011717: Consolidate the `home.benefits` feature family into one canonical `Feature` block.
8
+
9
+ - Adds `Feature` (export + `featurePropsSchema` / `FeatureProps` in the contracts surface) with a bounded `presentation` prop (`cards` | `split`, default `cards`). `cards` is the former header-badge card grid with per-item learn-more and an optional section CTA; `split` is the former split layout with the optional grayscale image column. `split` deliberately ignores `badge` and per-item learn-more; an absent or empty image `src` renders no image column (never a blank `src`).
10
+ - The storefront catalog declares the two presentations as the `home.benefits` variants with `legacyAliases` (`Feature1` → `cards`, `Feature2` → `split`), so existing manifests storing the numbered names keep regenerating to the same presentation; the benefits feature's `compact` / `detailed` configuration values are unchanged and map onto `cards` / `split`.
11
+
12
+ BREAKING: removes the `Feature1` and `Feature2` exports (components and their `Feature1Props`/`Feature1Item`/`Feature2Props`/`Feature2Item` types) that this canonical block supersedes. Consumers must migrate to `Feature` with the `presentation` prop.
13
+
14
+ - f011717: Consolidate the product-card family into one canonical `ProductCard` block.
15
+
16
+ - Adds `ProductCard` (export + `productCardPropsSchema` / `ProductCardSpecProps` in the contracts surface) with a bounded `presentation` prop (`classic` | `minimal` | `rich`, default `classic`). `classic` is the former `ProductCard1` structure (badge, optional rating, compare-at, add-to-cart footer, reduced-motion-safe motion); `minimal` is the former `ProductCard2` lean look; `rich` is the former `ProductCard3` overlay actions (wishlist heart with host-owned controlled state or uncontrolled visual fallback, and view-similar rendered only when a handler is supplied). The comparison-table surface of the former `ProductCard5` is deliberately retired (a listing controller, not an item cell).
17
+ - Items are the canonical `ProductPresentation` contract only (identity, title, optional image with alt, canonical `Money` price, optional `compareAtPrice`/`rating`/`badge`). An unresolved price renders an unavailable state with add-to-cart disabled — never a zero. Ratings, badges and crossed-out prices render only from host-supplied data; the block invents no evidence. Prices render through the canonical `formatMoney` path (no hardcoded currency symbol) and all user-facing copy is overridable with the previous Spanish defaults.
18
+ - Removes the numbered exports `ProductCard1`, `ProductCard2`, `ProductCard3`, `ProductCard5`, their types `ProductCard1Product` / `ProductCard1Props`, the contracts surface `productCard1PropsSchema` / `ProductCard1SpecProps`, and the `product-card-3-reaction-toggle` / `product-card-5-reaction-toggle` / `product-card-5-checkbox-check` subcomponents.
19
+ - Migration: supply item data in the canonical presentation contract (canonical `Money` amounts as decimal major-unit strings plus `currencyCode`). The former numeric-price props (`price: number`, `originalPrice?: number`, mandatory demo merchandising fields) are not retained; map them to `Money` at your boundary. Consumer code importing a numbered card selects the equivalent bounded presentation (`1` → `classic`, `2` → `minimal`, `3` → `rich`); the former comparison table has no replacement in this block.
20
+
21
+ - f011717: Consolidate the `home.categories` family into one canonical `ProductCategory` block.
22
+
23
+ - Adds `ProductCategory` (export + `productCategoryPropsSchema` / `ProductCategoryProps`) built on the canonical `CategoryCard` shape. It exposes a bounded `presentation` prop (`grid` | `icons` | `list`); optional per-category data (`icon` as a serializable key, `subcategories`, `featured`, `itemCount`) degrades gracefully when absent.
24
+ - Interaction is controlled: `onBrowse` / `onViewAll` / `onQueryChange` report from handlers with no private query state; a category count is omitted when absent or invalid and a real zero is preserved; a missing image renders a neutral placeholder, never `src=""`; no copy, currency, or demo metrics are hardcoded.
25
+
26
+ BREAKING: removes the `ProductCategory1`, `ProductCategory2`, `ProductCategory4`, and `ProductCategory5` exports (and the `product-categories/avatar-group` helper) that this canonical block supersedes. Consumers must migrate to `ProductCategory` with the `presentation` prop.
27
+
28
+ - ece154d: Consolidate the `product.overview` family into one canonical `ProductOverview` block.
29
+
30
+ - Adds `ProductOverview` (export + `productOverviewPropsSchema` / `ProductOverviewProps`) built on the canonical `ProductDetailProduct` projection and canonical `Money`. It exposes a bounded `presentation` prop (`standard` | `gallery` | `rich`); `rich` accepts optional `specifications` / `features` / `reviews` props and degrades gracefully when they are absent.
31
+ - Selection and quantity are controlled; `onAddToCart` emits the explicitly selected backend variant id and quantity and never substitutes the first variant. Missing price renders as unavailable, never `$0`; currency is never hardcoded.
32
+
33
+ BREAKING: removes the `ProductOverview1`, `ProductOverview2`, and `ProductOverview5` exports (and the `product-overview-5-*` subcomponents) that this canonical block supersedes. Consumers must migrate to `ProductOverview` with the `presentation` prop.
34
+
35
+ - b41cc50: Consolidate the numbered shopping-cart family into one canonical `ShoppingCart` block.
36
+
37
+ - Adds `ShoppingCart` (export + `shoppingCartPropsSchema` / `ShoppingCartSpecProps` in the contracts surface) with a bounded `presentation` prop (`compact` | `page`). `compact` is the former `ShoppingCart4` review card; `page` is the same contract with the page-level structure. The optional promo-entry affordance reports the entered code via `onApplyPromo(code)` and renders only authoritative promotion/discount data — it is hidden when the callback is absent, and the block never validates promotion codes itself. Host-supplied `recentlyViewed` suggestions replace the former `ShoppingCart2` recommendations, priced with canonical `Money`.
38
+ - Items are the canonical contract only: identity, title, quantity, optional image (omitted when absent, never `src=""`), and canonical `Money` amounts. Each line carries the backend-authoritative `lineTotal` (rendered verbatim; a malformed amount renders as unavailable — never a zero or free-shipping claim) and an optional `unitPrice` carried verbatim but never used for local arithmetic. `totals` come from the backend and are rendered verbatim; no local tax/shipping/threshold math, no fabricated merchandising, no hardcoded currency.
39
+ - Removes the numbered exports `ShoppingCart1`, `ShoppingCart2`, `ShoppingCart3`, `ShoppingCart4` (and their `ShoppingCart1CartItem`, `ShoppingCart2Props`/`ShoppingCart2Item`, `ShoppingCart3Props`/`ShoppingCart3CartItem`, `ShoppingCart4Props`/`ShoppingCart4Item` types), the contracts surface `shoppingCart4PropsSchema` / `ShoppingCart4SpecProps`, and the `shopping-cart-{1,2,3,4}-spinning-counter`, `shopping-cart-2-recommendations`, and `shopping-cart-2-item-row` subcomponents.
40
+
41
+ BREAKING: consumers importing a numbered cart must migrate to `ShoppingCart` with the `presentation` prop. The former numeric-price props (`price: number`, local `taxRate`/shipping-threshold arithmetic) are not retained; map item amounts to canonical `Money` (`{ amount, currencyCode }`) at your boundary and pass backend `totals` verbatim.
42
+
43
+ - f011717: Consolidate the `home.hero` family into one canonical `StorefrontHero` block.
44
+
45
+ - Adds `StorefrontHero` (export + `storefrontHeroPropsSchema` / `StorefrontHeroProps` / `StorefrontHeroProduct` in the contracts surface) with a bounded `presentation` prop (`compact` | `showcase` | `editorial` | `promotional`) reusing the registry's archetype intent vocabulary. `compact` is the former `StorefrontHero1` text-forward hero with product grid and editorial image seam; `showcase` the former `StorefrontHero2` search + rotating product carousel (the search input renders only when `onSearch` is supplied); `editorial` the former `StorefrontHero5` department grid with optional featured deals and store locator; `promotional` the former `StorefrontHero6` deals bar + category grid.
46
+ - Truthful evidence only: ratings, review counts, category/department counts, deal descriptions, and countdowns render only from host-supplied data; discount treatments render only from a real `originalPrice > price` pair (`isOnSale` is dropped); `currency` (ISO 4217) formats all amounts via `Intl.NumberFormat` — no hardcoded `$`. Rotation respects reduced motion and is presentation-internal.
47
+ - The storefront catalog declares `StorefrontHero` as the single `home.hero` variant with `legacyAliases` (`StorefrontHero1`/`2`/`5`/`6` → the canonical), so existing manifests storing the numbered names keep regenerating to a working composition; `archetypes.json` intents map each intent to its matching presentation.
48
+
49
+ BREAKING: removes the `StorefrontHero1`, `StorefrontHero2`, `StorefrontHero5`, and `StorefrontHero6` exports (and the `StorefrontHero1Props` type) that this canonical block supersedes. The `FeaturedProduct` type is now exported from the canonical source and its evidence fields (`brand`, `colors`, `rating`, `reviewCount`, `originalPrice`) are optional. Consumers must migrate to `StorefrontHero` with the `presentation` prop.
50
+
51
+ - dc2b11b: Split the category collection into an item cell and a listing.
52
+
53
+ - `CategoryCard` is the item cell: one `category` at a time, with the existing `grid`, `icons` and `list` presentations. It renders no section, heading, search, CTA or empty state.
54
+ - `CategoryGrid` is the listing: it owns the collection, `maxItems`, section copy, controlled search (list presentation), loading skeletons and the empty state, and renders `CategoryCard` for every item.
55
+ - BREAKING: `ProductCategory` (collection) is replaced by `CategoryGrid`; the single-item surface is `CategoryCard`. `ProductCategoryProps` is replaced by `CategoryGridProps` / `CategoryCardProps`.
56
+
57
+ Migration: replace `<ProductCategory categories={items} ... />` with `<CategoryGrid categories={items} ... />`. For a single category, render `<CategoryCard category={item} ... />`.
58
+
59
+ - 3e43eb0: BREAKING: `ProductCard` is now an item cell and a new `ProductGrid` listing owns the collection.
60
+
61
+ - `ProductCard` (`packages/blocks/src/blocks/e-commerce/product-list/product-card.tsx`) now takes a single `item: ProductPresentation` (plus `presentation?` and `wishlisted?`) and renders exactly one cell — no section wrapper, no section heading, no grid, and no item cap. The `items`, `heading`, and `maxItems` props and the `wishlistedIds` collection prop are removed; `wishlisted` is now a single host-resolved boolean.
62
+ - The serializable contract `productCardPropsSchema` / `ProductCardSpecProps` changes accordingly (`{ item, presentation?, wishlisted? }`), still without callbacks or label strings.
63
+ - Adds `ProductGrid` (export + `productGridPropsSchema` / `ProductGridSpecProps`) as the canonical listing: it takes `items: ProductPresentation[]`, an optional `presentation`, `heading`, `maxItems`, `wishlistedIds`, and owns the `loading` (skeletons), `empty` (overridable copy), and ready states. It renders `ProductCard` cells (single source of cell markup) and reports actions through the same callbacks. `error` remains the host's responsibility.
64
+ - Migration: pass a listing's data to `ProductGrid` with `items`, `heading`, and `maxItems`; pass a single item to `ProductCard` via `item`. Resolve rich-presentation wishlist state per cell through `wishlistedIds` on the grid or `wishlisted` on the cell. The delimited showcase `ProductShowcase1` is unchanged.
65
+
66
+ - b41cc50: Require an image for the `Feature` block's `split` presentation.
67
+
68
+ - `featureSplitSpecSchema` now requires `presentation: 'split'` and a non-empty, trimmed `image.src`; `featureCardsSpecSchema` keeps `image` out of its contract. `featurePropsSchema` is the union of both branches, so TypeScript narrows `image` only after checking `presentation`.
69
+ - The component throws a configuration error when an untyped caller renders `split` without a non-empty image, instead of silently dropping the media column. `cards` is unchanged.
70
+ - Editorial fallbacks belong to the consumer, not the block library. The showcase now owns `public/demo/editorial-fallback.svg`; the storefront template seeds a neutral image in user-owned `content.ts`.
71
+
72
+ Migration: pass `{ image: { src, alt } }` when using `presentation="split"`, or switch to `presentation="cards"` if the section is icon-only. Replace the storefront's neutral seed with real editorial imagery.
73
+
74
+ ### Minor Changes
75
+
76
+ - 80d4137: Add optional, backward-compatible brand customization to existing surfaces (M5):
77
+
78
+ - `@thorprovider/blocks`: `Navbar4` and `FooterSection1` accept an optional wordmark accent `{ text, className }` (trailing segment of the brand name) and an optional surface `className`; a supplied surface `className` overrides the default surface paint (default background/backdrop not emitted) so a branded surface paints correctly. `StorefrontHero1` accepts an optional editorial `heroImage { src, alt }` rendered with a native, guarded image. `ProductCategory2` accepts an optional `itemCount`: an unknown count is omitted (no fabricated `0`), a real `0` is preserved, and non-finite/negative values render no count. All props are optional and omit to the current rendering, so existing consumers are unchanged; no new block type or variant is added, and the blocks stay framework-, backend-, and data-free.
79
+ - `@thorprovider/storefront`: the scaffold generates a user-owned `lib/<name>/shell/{content,props}.ts` seam (brand accents, surface classes, footer description/links, newsletter) that the derived spec composes last, so `storefront sync` regenerates only derived wiring and preserves user-owned edits. The scaffold ships no newsletter submit callback, so the newsletter is omitted by default rather than rendering a fake form; default footer copy makes no delivery/shipping promise and links only to routes the storefront serves (no assumed route). No new identity CLI command is added — `create --site-name` already covers the site name.
80
+
81
+ This changeset adds release metadata only; it does not bump versions or publish packages.
82
+
83
+ - 3b3c24c: Add validated single-store commerce contracts, explicit real/mock policy, directed variant pricing, persistent guest carts, backend-authoritative totals, manual TEST checkout, and capability-authorized receipt reconciliation in generated storefronts.
84
+
85
+ Breaking migration: `@thorprovider/types` is now entirely type-only. Import `SupportedProviderType`, `PROVIDER_METADATA`, `isSupportedProviderType`, `getProviderMetadata`, and `StorefrontConfigError` from `@thorprovider/adapters` instead. The pre-1.0 curated blocks/runtime APIs also change: product detail uses canonical options/variants/Money and explicit variant selection; confirmation totals and lines use Money; `formatMoney` returns `null` for unavailable values. Real-mode failures no longer silently return mock data.
86
+
87
+ The local packed MVP candidate retains its exact current version pins until a separate release updates the compatible toolset. This changeset does not publish packages or establish public-sales readiness.
88
+
89
+ - b41cc50: Deliver the global shopper account experience (M5, change `storefront-navbar-account-menu`):
90
+
91
+ - `@thorprovider/blocks`: `Navbar4` renders a session-driven account affordance from consumer props only. An authenticated `user` (optional `avatar`) with `accountMenuItems` gets an account menu (identity + configured items + consumer-supplied sign-out); no `user` with a configured `accountHref` gets a signed-out entry point on desktop and mobile; neither configured renders no account affordance or empty container. Optional `accountPending`/`accountUnavailable` drive a pending/disabled state and an accessible unavailable/retry state (`onAccountRetry`) without presenting either as anonymous, and sign-out is suppressed while pending. Absent avatar uses the initials fallback (no empty image `src`). All callbacks stay consumer bindings outside the serializable JSON prop schema; the block never authenticates.
92
+ - `@thorprovider/storefront`: a mounted `customer-auth` resolves the shopper server-side per request and seeds an allowlisted display identity (`id`, `email`, `firstName`, `lastName`; the auth token stays in its httpOnly cookie). One shared client lifecycle (`account-lifecycle.client.tsx`) owns identity, guarded claims, login/register/logout, and retry with no identity fetch on mount or navigation; it reconciles same-store tabs through non-secret store-scoped signals (`BroadcastChannel` with a storage-event fallback) and on foreground/resume, discards stale generations, and shows a neutral pending state instead of stale identity/orders or a fake sign-out. `/account` is the only account surface — sign-in, registration, identity, sign-out, and real paginated orders + detail (route-scoped commerce, no direct HTTP); no address book/profile/wishlist/social/recovery/cart-merge. Account wiring is delivered through derived files (`account-session*.ts`, `account-lifecycle/account-boundary/account-mutation/account-reconcile.client.*`, `account-private.core.ts`, `account-user.core.ts`, `projection-ownership*.ts`, `registry.tsx`, `schema.ts`, `spec/index.ts`); user-owned files (`views/*/{schema,content,props,data}.ts`, `mappers.ts`, `icon-map.ts`, `shell/**`) survive `sync`. `storefront feature add|remove customer-auth` toggles the `/account` route, session reads, listeners, and navbar chrome together; without the feature the generated registry carries no account-lifecycle/account-reconcile reference and reads no session.
93
+ - `@thorprovider/adapters`: `getCurrentCustomer()` resolves `null` only for a confirmed unauthenticated response (HTTP 401 / `"Unauthorized"`) and throws a typed `ProviderAPIError` (`GET_CURRENT_CUSTOMER_FAILED`) on operational failures (network, timeout, 5xx), so a host can distinguish an anonymous shopper from a backend outage and render the unavailable state instead of a false sign-out. `isAuthenticated()` delegates to it and can reject. Signature and framework-agnostic boundary are unchanged.
94
+
95
+ This changeset adds release metadata only; it does not bump versions or publish packages.
96
+
97
+ - ece154d: Gate the `ProductOverview` wishlist affordance on a wired handler.
98
+
99
+ - `ProductOverview` renders its wishlist button only when `onToggleWishlist` is supplied; an unwired button is a dead control and is no longer rendered in any presentation (`standard`, `gallery`, `rich`).
100
+ - The storefront product template and the demo/electrostore registries drop their no-op `onToggleWishlist` wiring, so a storefront without wishlist backend support no longer shows a non-functional heart.
101
+
102
+ ### Patch Changes
103
+
104
+ - ece154d: Align the `Breadcrumb` block with the page content: the trail now renders inside the shared responsive container (`mx-auto w-full max-w-7xl px-4 py-4 sm:px-6 lg:px-8`) that sibling blocks use, instead of sitting flush against the viewport edge. The container is merged with the incoming `className` via `cn`, so a custom `className` can still override it.
105
+ - Updated dependencies [23f876f]
106
+ - Updated dependencies [3b3c24c]
107
+ - @thorprovider/types@6.0.0
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: "@thorprovider/blocks"
3
3
  purpose: "Reusable, framework-agnostic storefront blocks built from ShadcnStore UI, decoupled from data and backend"
4
- lastReviewed: "2026-09-23"
4
+ lastReviewed: "2026-10-17"
5
5
  ---
6
6
 
7
7
  # @thorprovider/blocks
@@ -30,7 +30,7 @@ src/
30
30
  │ └── hero-sections/hero-section-1.tsx
31
31
  └── e-commerce/
32
32
  ├── product-list/product-card-1.tsx
33
- └── storefront-hero/storefront-hero-1.tsx
33
+ └── storefront-hero/storefront-hero.tsx
34
34
  ```
35
35
 
36
36
  Blocks follow `src/blocks/<category>/<section>/<block>.tsx`. Each block co-locates its own prop types unless a contract is shared by two or more blocks.
@@ -46,8 +46,7 @@ Blocks follow `src/blocks/<category>/<section>/<block>.tsx`. Each block co-locat
46
46
  | `Banner1` | Marketing | Announcement banner (bar or pill) with dismiss |
47
47
  | `HeroSection1` | Marketing | Hero with badge, headline, CTA, reviewer avatars, rating and image |
48
48
  | `CtaSection1` | Marketing | Full-width call-to-action with badge, headline and two CTAs |
49
- | `Feature1` | Marketing | Icon feature grid |
50
- | `Feature2` | Marketing | Split features with image |
49
+ | `Feature` | Marketing | Canonical benefits section: `cards` (icon feature grid) or `split` (editorial split with a required image) presentation |
51
50
  | `Testimonial1` | Marketing | Testimonial carousel with rating stars |
52
51
  | `Testimonial2` | Marketing | Bento testimonials grid |
53
52
  | `FaqBlock1` | Marketing | FAQ accordion (single or multiple open) |
@@ -56,33 +55,22 @@ Blocks follow `src/blocks/<category>/<section>/<block>.tsx`. Each block co-locat
56
55
  | `LogoCloud1` | Marketing | Logo cloud with skeleton fallback |
57
56
  | `FooterSection1` | Marketing | Footer with brand, link columns and newsletter |
58
57
  | `FooterSection3` | Marketing | Newsletter + six-column footer with social links |
59
- | `ProductCard1` | E-commerce | Deals grid of product cards with add-to-cart |
60
- | `ProductCard2` | E-commerce | Clean three-up grid with ratings |
61
- | `ProductCard3` | E-commerce | Deals grid with badges, low-stock and wishlist |
62
- | `ProductCard5` | E-commerce | Table-style comparison with sorting and filtering |
58
+ | `ProductCard` | E-commerce | Canonical product card (item cell of a listing); `presentation` `classic`/`minimal`/`rich`, canonical `ProductPresentation` items with `Money` prices, evidence-only merchandising |
63
59
  | `ProductShowcase1` | E-commerce | Combined grid-and-carousel of product cards with manual paging |
64
- | `ProductOverview1` | E-commerce | Product detail with gallery, variants and add-to-cart |
60
+ | `ProductOverview` | E-commerce | Canonical product detail; bounded `presentation` `standard`/`gallery`/`rich` (rich adds optional specs/features/reviews tabs) |
65
61
  | `ProductQuickView` | E-commerce | Quick-view dialog with gallery, variants and quantity |
66
62
  | `ReviewRating1` | E-commerce | Review list with rating summary |
67
63
  | `ReviewRating2` | E-commerce | Rating summary with distribution and review carousel |
68
- | `ProductCategory1` | E-commerce | Category grid with view-all |
69
- | `ProductCategory2` | E-commerce | Image-based category cards |
70
- | `ProductCategory4` | E-commerce | Icon grid with customer/product counts |
71
- | `ProductCategory5` | E-commerce | Category list with search and subcategories |
64
+ | `ProductCategory` | E-commerce | Canonical category block; bounded `presentation` `grid` (image cards) / `icons` (compact icon grid) / `list` (rows with subcategory chips and optional controlled search) |
72
65
  | `CategoryFilter1` | E-commerce | Category filter bar |
73
66
  | `CategoryFilter2` | E-commerce | Filter sidebar with mobile sheet |
74
- | `ShoppingCart1` | E-commerce | Detailed cart summary |
75
- | `ShoppingCart2` | E-commerce | Cart with promo code and shipping |
76
- | `ShoppingCart4` | E-commerce | Cart review with backend-authoritative totals; `presentation` `compact` (collapsible card) or `page` (full-width review) |
67
+ | `ShoppingCart` | E-commerce | Canonical cart; backend-authoritative `Money` totals and item lines, optional promo entry and recently-viewed suggestions; `presentation` `compact` (collapsible card) or `page` (full-width review) |
77
68
  | `CheckoutForm1` | E-commerce | Essential checkout fields |
78
69
  | `CheckoutForm2` | E-commerce | Single-page checkout with order summary beside |
79
70
  | `CheckoutConfirmation` | E-commerce | Order confirmation with fulfilment timeline |
80
71
  | `OrderHistory1` | E-commerce | Orders list |
81
72
  | `OrderHistory2` | E-commerce | Order cards with carousel items |
82
- | `StorefrontHero1` | E-commerce | Storefront hero with gradient headline, CTAs and a product grid |
83
- | `StorefrontHero2` | E-commerce | Search field above a trending product carousel |
84
- | `StorefrontHero5` | E-commerce | Departments grid with featured deals |
85
- | `StorefrontHero6` | E-commerce | Rotating deals bar beside category cards |
73
+ | `StorefrontHero` | E-commerce | Canonical storefront hero; `presentation` `compact` (gradient headline, CTAs, product grid), `showcase` (search + rotating product carousel), `editorial` (departments, featured deals, store locator) or `promotional` (rotating deals bar, category cards) |
86
74
  | `AppShell1` | Application | Application shell with navigation slots |
87
75
  | `LoginPage1` | Application | Login card with remember-me and social buttons |
88
76
  | `LoginPage2` | Application | Login with prominent social options |
@@ -90,7 +78,7 @@ Blocks follow `src/blocks/<category>/<section>/<block>.tsx`. Each block co-locat
90
78
  | `ForgotPasswordPage1` | Application | Four-step password recovery wizard |
91
79
  | `Verify1` | Application | Two-factor verification (OTP or backup code) |
92
80
 
93
- Interactions are exposed as callbacks (e.g. `onAddToCart`, `onBuyNow`, `onSelectColor`). Elements stay in the DOM with no-op behavior when a callback is absent.
81
+ Interactions are exposed as callbacks (e.g. `onAddToCart`, `onBuyNow`, `onSelectColor`). `StorefrontHero` renders each affordance only when its handler is supplied (no dead controls); other blocks keep elements in the DOM with no-op behavior when a callback is absent.
94
82
 
95
83
  ## Optional brand props
96
84
 
@@ -104,7 +92,7 @@ the block's current output unchanged; no demo content is added.
104
92
  segment of `logoText`, the wordmark renders unchanged.
105
93
  - `FooterSection1` — `brandAccent?: { text: string; className?: string }` applies
106
94
  the same trailing-segment accent to `brandName`.
107
- - `StorefrontHero1` — `heroImage?: { src: string; alt: string }` renders a native
95
+ - `StorefrontHero` — `heroImage?: { src: string; alt: string }` renders a native
108
96
  `<img>` when `src` is a non-empty string, and nothing when the prop is absent
109
97
  or `src` is blank (never an empty `src`). The product grid is unaffected.
110
98
 
@@ -132,6 +120,24 @@ supplied surface. `Navbar4` adapts its primary nav links: they inherit the
132
120
  surface text color and add `hover:text-foreground` so `hover:bg-muted` never
133
121
  paints white-on-light. No magic colors are introduced by the block.
134
122
 
123
+ ## Navbar4 account affordance
124
+
125
+ `Navbar4` renders a **session-driven** account affordance exclusively from
126
+ consumer-supplied props — it never resolves a session or authenticates itself.
127
+ Given a `user` (`StoreUser`; `avatar` is optional) and `accountMenuItems`, it
128
+ renders an authenticated account menu with the identity, the configured items,
129
+ and the consumer's sign-out action on desktop and mobile. Given no `user` but a
130
+ configured `accountHref`, it renders a signed-out account entry point on both
131
+ viewports; with neither configured it renders no account affordance and no empty
132
+ container. An absent `avatar` uses the initials fallback and emits no empty image
133
+ `src`. Optional `accountPending` and `accountUnavailable` props drive a
134
+ pending/disabled state and an accessible unavailable/retry state (via
135
+ `onAccountRetry`) without presenting either as an anonymous session, and sign-out
136
+ is suppressed while pending. All callbacks (`onSignOut`, `onAccountRetry`,
137
+ `onHelp`) are consumer bindings kept outside the serializable JSON prop schema;
138
+ the sign-out action is supplied by the consumer, so the block never performs
139
+ authentication.
140
+
135
141
  ## Footer newsletter
136
142
 
137
143
  `FooterSection1` renders the interactive newsletter form (input + submit button)
@@ -144,15 +150,16 @@ paints white-on-light. No magic colors are introduced by the block.
144
150
  The cart is presented in two shapes that share one data contract, so a change in
145
151
  either is visible in the other.
146
152
 
147
- - `ShoppingCart4` is the cart block. Its optional `presentation` prop selects
153
+ - `ShoppingCart` is the cart block. Its optional `presentation` prop selects
148
154
  `'compact'` (default: a collapsible `max-w-md` card with an internal scroller —
149
155
  the navbar lateral) or `'page'` (always expanded, full-width `max-w-3xl`, no
150
156
  collapse control, ordinary page scrolling — the `/cart` review route).
151
157
  Omitting `presentation` renders the compact card byte-identically to before.
152
- - Line amounts prefer the canonical `lineTotal?: Money` and fall back to the
153
- legacy numeric `price * quantity` only when `lineTotal` is absent. A missing or
154
- malformed `Money` renders the `amountUnavailableLabel` (never a fabricated
155
- zero or a currency fallback). `unitPrice?: Money` is carried verbatim.
158
+ - Line amounts render the authoritative `lineTotal: Money` verbatim (backend
159
+ line total, never a local `unitPrice × quantity` computation); a malformed
160
+ amount renders the `amountUnavailableLabel` (never a fabricated zero or a
161
+ currency fallback). An optional `unitPrice: Money` is carried verbatim but
162
+ never used for any local arithmetic.
156
163
  - `totalsState` (`'idle' | 'pending' | 'error'`) plus `onRetryTotals` make the
157
164
  totals controlled: pending retains the last confirmed totals and disables
158
165
  conflicting mutations; error shows the error label and a retry.
@@ -160,6 +167,13 @@ either is visible in the other.
160
167
  by backend line id) and `onRemove(id)` removes by the same id.
161
168
  `checkoutUnavailable` hides the checkout action (e.g. no checkout installed)
162
169
  and `checkoutDisabled` disables it (e.g. pending or invalid totals).
170
+ - Promo entry is an optional affordance: with `onApplyPromo(code)` it renders an
171
+ input that reports the entered code and shows only the authoritative applied
172
+ promotion/discount already present in `totals`; without the callback it is not
173
+ rendered, and the block never validates or resolves a code itself.
174
+ `onApplyPromo` never gates `recentlyViewed`, an optional host-supplied
175
+ suggestion list (name, optional image, optional `Money` price) rendered only
176
+ when supplied.
163
177
 
164
178
  `Navbar4` owns the lateral. Pass `cartOpen` + `onCartOpenChange` to control the
165
179
  sheet's visibility from host state (omit both to keep the legacy uncontrolled
@@ -170,6 +184,32 @@ canonical `lineTotal` (never the placeholder numeric price), plus
170
184
  serve, and a `state` (`'idle' | 'pending' | 'error'`) with retry for read/mutation
171
185
  feedback.
172
186
 
187
+ ## Product card (canonical)
188
+
189
+ `ProductCard` is the item cell of a product listing and the canonical member of
190
+ the product-card family. Its items are `ProductPresentation` only (canonical
191
+ `Money`): a missing price renders the `unavailablePriceLabel` with add-to-cart
192
+ disabled — never a zero amount — and optional image, rating, badge, and
193
+ compare-at price are omitted when absent (no empty `src`, no empty stars, no
194
+ empty chip). A compare-at price renders only when it is valid and strictly
195
+ higher than the selling price.
196
+
197
+ The bounded `presentation` option selects the structure — `classic` (boxed card
198
+ with an add-to-cart footer), `minimal` (lean image-forward tile with an
199
+ icon-only cart action), `rich` (overlay actions: wishlist heart and
200
+ view-similar) — while the data and action contract stays identical. Ratings,
201
+ badges, and scarcity copy render only from host-supplied data (the `badge`
202
+ copy and `compareAtPrice` are the evidence channels); the block never invents
203
+ stars, chips, or urgency.
204
+
205
+ The wishlist heart is host-owned: pass `wishlistedIds` to control it, and
206
+ `onToggleWishlist(item, nextState)` receives every toggle from the click
207
+ handler (never from an effect). Without host state the heart keeps an
208
+ uncontrolled visual fallback and still reports. `onViewSimilar` renders its
209
+ affordance only when supplied. Prices render through the canonical
210
+ `formatMoney` path (no hardcoded currency), and every label is overridable
211
+ with Spanish defaults (`Agregar al carrito`, `Precio no disponible`, …).
212
+
173
213
  ## Checkout blocks (controlled)
174
214
 
175
215
  `CheckoutForm1` (3-step wizard) and `CheckoutForm2` (single page) are **controlled components**: they receive the current form values through a `values` prop and report every edit through `onChange`. They hold no internal form state, so a renderer's validation context can read field values from its own state model. `CheckoutForm3` (4-step wizard) keeps the same contract with internal step state.
@@ -229,11 +269,11 @@ unique by contract, not by convention. See
229
269
  rules and the review checklist.
230
270
 
231
271
  Optional numeric projections are omitted rather than fabricated: e.g.
232
- `ProductCategory2Category.itemCount` renders a count only when it is a supplied
272
+ `ProductCategoryItem.itemCount` renders a count only when it is a supplied
233
273
  finite non-negative number (a real `0` is preserved); an unknown count renders
234
274
  no count text, never a fabricated `0`.
235
275
 
236
- Product presentations (`ProductCard1`, `ProductShowcase1`) accept either the
276
+ Product presentations (`ProductGrid`, `ProductShowcase1`) accept either the
237
277
  legacy numeric-price props or a canonical `ProductPresentation` (`price`/
238
278
  `compareAtPrice` as `Money`, `{ amount: string, currencyCode: string }` in
239
279
  decimal major units). The canonical path formats with the store currency and
@@ -242,6 +282,10 @@ locale, renders a truthful "unavailable" state for an unresolved price (never
242
282
  valid and strictly higher. Optional rating/badge/image appear only when
243
283
  supplied; a missing image is omitted, never `src=""`.
244
284
 
285
+ The canonical `ProductCard` accepts only the canonical `ProductPresentation`
286
+ contract — the legacy numeric-price props of the numbered cards are retired
287
+ (migrate item data to canonical `Money`).
288
+
245
289
  ## Block interaction contracts
246
290
 
247
291
  State and events between a block, its host registry, and the store must follow
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: "Block data contracts"
3
3
  purpose: "Warn block authors and hosts about the two boundary data-hygiene rules that prevent empty-src and duplicate-key console errors"
4
- lastReviewed: "2026-09-27"
4
+ lastReviewed: "2026-10-17"
5
5
  type: standards
6
6
  tags: [blocks, contracts, data-boundary, images, react-keys]
7
7
  ---
@@ -60,6 +60,16 @@ Even with correct boundaries, blocks should render
60
60
  `src={value || undefined}` for every image prop. This is a cheap invariant
61
61
  that makes the whole catalog immune to one misbehaving host.
62
62
 
63
+ ## Canonical product card: evidence comes from the host
64
+
65
+ `ProductCard` renders ratings, badges, discount labels, and stock claims only
66
+ from host-supplied data (`rating`, `badge` copy, `compareAtPrice`); it never
67
+ invents stars, chips, scarcity copy, or crossed-out prices, and a compare-at
68
+ price renders only when it is valid and strictly higher. Boundaries must not
69
+ compensate by fabricating evidence: if the host lacks a rating, the card
70
+ renders none — do not serialize a default `5` or a stock-urgency string to
71
+ "fill" the UI.
72
+
63
73
  ## Review checklist (boundary code)
64
74
 
65
75
  - [ ] No `?? ''` or `|| ''` on visual fields (image, avatar, icon) in mappers/transforms.
@@ -67,4 +77,5 @@ that makes the whole catalog immune to one misbehaving host.
67
77
  - [ ] Optional fields emit `undefined`, never `""`.
68
78
  - [ ] Arrays passed as React children have unique keys guaranteed by contract or deduped at the boundary.
69
79
  - [ ] Concatenated arrays (categories + links) are deduped before render.
70
- - [ ] Every `<img>`/`<AvatarImage>` in the block uses `src={expr || undefined}`.
80
+ - [ ] Every `<img>`/`<AvatarImage>` in the block uses `src={expr || undefined}`.
81
+ - [ ] No fabricated ratings, badges, stock claims, or crossed-out prices are serialized to fill absent evidence.
package/package.json CHANGED
@@ -1,9 +1,13 @@
1
1
  {
2
2
  "name": "@thorprovider/blocks",
3
- "version": "0.1.3",
3
+ "version": "1.0.0",
4
4
  "description": "Reusable, framework-agnostic React component blocks for Thor Commerce storefronts.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
+ "sideEffects": [
8
+ "**/*.css",
9
+ "./styles.css"
10
+ ],
7
11
  "exports": {
8
12
  ".": {
9
13
  "types": "./dist/index.d.ts",
@@ -13,16 +17,23 @@
13
17
  "default": "./dist/index.mjs"
14
18
  },
15
19
  "./contracts": {
16
- "types": "./dist/contracts.d.ts",
17
- "development": "./dist/contracts.mjs",
18
- "import": "./dist/contracts.mjs",
19
- "require": "./dist/contracts.js",
20
- "default": "./dist/contracts.mjs"
20
+ "types": "./dist/lib/contracts.d.ts",
21
+ "development": "./dist/lib/contracts.mjs",
22
+ "import": "./dist/lib/contracts.mjs",
23
+ "require": "./dist/lib/contracts.js",
24
+ "default": "./dist/lib/contracts.mjs"
21
25
  },
22
- "./styles.css": "./styles.css"
26
+ "./styles.css": "./styles.css",
27
+ "./*": {
28
+ "types": "./dist/*.d.ts",
29
+ "development": "./dist/*.mjs",
30
+ "import": "./dist/*.mjs",
31
+ "require": "./dist/*.js",
32
+ "default": "./dist/*.mjs"
33
+ }
23
34
  },
24
35
  "scripts": {
25
- "build": "tsup",
36
+ "build": "tsup && tsc -p tsconfig.build.json && node scripts/fix-esm-extensions.mjs",
26
37
  "dev": "tsup --watch",
27
38
  "type-check": "tsc --noEmit",
28
39
  "test": "vitest run --config vitest.config.ts",
@@ -40,7 +51,7 @@
40
51
  "license": "MIT",
41
52
  "dependencies": {
42
53
  "@base-ui/react": "^1.8.0",
43
- "@thorprovider/types": "^5.8.0",
54
+ "@thorprovider/types": "workspace:^",
44
55
  "class-variance-authority": "^0.7.1",
45
56
  "clsx": "^2.1.1",
46
57
  "cn": "^0.4.0",
@@ -54,10 +65,15 @@
54
65
  "zod": "^3.25.0"
55
66
  },
56
67
  "devDependencies": {
68
+ "@testing-library/dom": "^10",
69
+ "@testing-library/react": "^16",
70
+ "@testing-library/user-event": "^14",
71
+ "@types/jsdom": "^30",
57
72
  "@types/react": "^19",
58
73
  "@types/react-dom": "^19",
74
+ "jsdom": "^30.1.3",
59
75
  "tsup": "^8.0.0",
60
76
  "typescript": "^5.0.0",
61
77
  "vitest": "^3.2.4"
62
78
  }
63
- }
79
+ }
@@ -0,0 +1,40 @@
1
+ import { readdir, readFile, writeFile } from "node:fs/promises";
2
+ import { join, extname } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const distDir = fileURLToPath(new URL("../dist/", import.meta.url));
6
+
7
+ const RELATIVE = /^\.{1,2}\//;
8
+ const HAS_EXT = /\.(mjs|cjs|js|jsx|ts|tsx|css|json|svg|png|webp|jpg|jpeg|gif|woff2?)$/;
9
+
10
+ const SPECIFIER =
11
+ /(\bfrom\s*|\bimport\s*\(|\brequire\s*\(|\bimport\s+)(["'])(\.{1,2}\/[^"']*?)\2/g;
12
+
13
+ async function* walk(dir) {
14
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
15
+ const full = join(dir, entry.name);
16
+ if (entry.isDirectory()) yield* walk(full);
17
+ else yield full;
18
+ }
19
+ }
20
+
21
+ async function fixFile(file) {
22
+ const ext = extname(file);
23
+ if (ext !== ".mjs" && ext !== ".js") return 0;
24
+ const target = ext === ".mjs" ? ".mjs" : ".js";
25
+ const source = await readFile(file, "utf8");
26
+ let changed = 0;
27
+ const out = source.replace(SPECIFIER, (match, prefix, quote, spec) => {
28
+ if (!RELATIVE.test(spec) || HAS_EXT.test(spec)) return match;
29
+ changed += 1;
30
+ return `${prefix}${quote}${spec}${target}${quote}`;
31
+ });
32
+ if (changed > 0) await writeFile(file, out);
33
+ return changed;
34
+ }
35
+
36
+ let total = 0;
37
+ for await (const file of walk(distDir)) {
38
+ total += await fixFile(file);
39
+ }
40
+ console.log(`fix-esm-extensions: rewrote ${total} relative specifier(s)`);
@@ -0,0 +1,122 @@
1
+ // @vitest-environment jsdom
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+ import { beforeAll, describe, expect, it, vi } from 'vitest'
4
+ import { render, screen } from '@testing-library/react'
5
+ import userEvent from '@testing-library/user-event'
6
+ import { CategoryCard } from './category-card'
7
+ import { CategoryGrid } from './product-category-grid'
8
+ import type { ProductCategoryItem } from '../../../lib/contracts'
9
+
10
+ beforeAll(() => {
11
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
12
+ })
13
+
14
+ const category: ProductCategoryItem = {
15
+ id: 'pcat_herramientas',
16
+ handle: 'herramientas',
17
+ name: 'Herramientas',
18
+ description: 'Taladros y accesorios',
19
+ image: '/products/drill.png',
20
+ itemCount: 12,
21
+ trending: true,
22
+ featured: true,
23
+ subcategories: ['Taladros', 'Accesorios'],
24
+ }
25
+
26
+ const categories: ProductCategoryItem[] = [
27
+ category,
28
+ { ...category, id: 'pcat_pintura', handle: 'pintura', name: 'Pintura', image: undefined },
29
+ { ...category, id: 'pcat_jardin', handle: 'jardin', name: 'Jardín' },
30
+ ]
31
+
32
+ const card = (extra: Record<string, unknown> = {}) =>
33
+ renderToStaticMarkup(<CategoryCard category={category} {...extra} />)
34
+
35
+ const grid = (extra: Record<string, unknown> = {}) =>
36
+ renderToStaticMarkup(<CategoryGrid categories={categories} {...extra} />)
37
+
38
+ describe('CategoryCard — item cell', () => {
39
+ it('renders a single category without section chrome', () => {
40
+ const html = card()
41
+ expect(html).toContain('Herramientas')
42
+ expect(html).not.toContain('<section')
43
+ expect(html).not.toContain('<h2')
44
+ })
45
+
46
+ it('renders the image for the grid presentation and omits it for icons', () => {
47
+ expect(card()).toContain('/products/drill.png')
48
+ expect(card({ presentation: 'icons' })).not.toContain('<img')
49
+ })
50
+
51
+ it('keeps counts honest (zero and unknown are not invented)', () => {
52
+ expect(card()).toContain('12')
53
+ expect(renderToStaticMarkup(<CategoryCard category={{ ...category, itemCount: undefined }} />)).not.toContain('items</span>')
54
+ expect(renderToStaticMarkup(<CategoryCard category={{ ...category, itemCount: 0 }} />)).not.toContain('items</span>')
55
+ })
56
+
57
+ it('only exposes browse when a handler exists', () => {
58
+ expect(card({ browseLabel: 'Ver' })).not.toContain('Ver Herramientas')
59
+ const html = card({ browseLabel: 'Ver', onBrowse: vi.fn() })
60
+ expect(html).toContain('aria-label="Ver Herramientas"')
61
+ })
62
+
63
+ it('renders the list cell with description, chips and featured badge', () => {
64
+ const html = card({ presentation: 'list', featuredLabel: 'Destacada', itemsLabel: 'productos' })
65
+ expect(html).toContain('Taladros y accesorios')
66
+ expect(html).toContain('Taladros')
67
+ expect(html).toContain('Destacada')
68
+ expect(html).toContain('productos')
69
+ })
70
+ })
71
+
72
+ describe('CategoryGrid — listing', () => {
73
+ it('renders one cell per category', () => {
74
+ const html = grid()
75
+ expect(html).toContain('Herramientas')
76
+ expect(html).toContain('Pintura')
77
+ expect(html).toContain('Jardín')
78
+ expect(html.match(/data-slot="card"/g) ?? []).toHaveLength(3)
79
+ })
80
+
81
+ it('caps the rendered collection with maxItems', () => {
82
+ const html = grid({ maxItems: 2 })
83
+ expect(html).toContain('Herramientas')
84
+ expect(html).toContain('Pintura')
85
+ expect(html).not.toContain('Jardín')
86
+ })
87
+
88
+ it('owns its loading state with skeletons and no cells', () => {
89
+ const html = grid({ loading: true, skeletonCount: 3 })
90
+ expect(html).toContain('aria-busy="true"')
91
+ expect(html).not.toContain('data-slot="card"')
92
+ })
93
+
94
+ it('owns its empty state', () => {
95
+ expect(renderToStaticMarkup(<CategoryGrid categories={[]} emptyLabel="Sin categorías" />)).toContain('Sin categorías')
96
+ })
97
+
98
+ it('renders the search control only in the list presentation', () => {
99
+ expect(grid({ presentation: 'grid', onQueryChange: vi.fn() })).not.toContain('type="search"')
100
+ expect(grid({ presentation: 'list', onQueryChange: vi.fn(), searchPlaceholder: 'Buscar' })).toContain('type="search"')
101
+ })
102
+
103
+ it('renders view-all only outside the grid and the section cta only outside the list', () => {
104
+ const withBoth = { cta: { label: 'Comprar', href: '/tienda' }, onViewAll: vi.fn(), viewAllLabel: 'Ver todo' }
105
+ const gridHtml = grid({ presentation: 'grid', ...withBoth })
106
+ expect(gridHtml).toContain('Comprar')
107
+ expect(gridHtml).not.toContain('Ver todo')
108
+ const listHtml = grid({ presentation: 'list', ...withBoth })
109
+ expect(listHtml).toContain('Ver todo')
110
+ expect(listHtml).not.toContain('Comprar')
111
+ })
112
+
113
+ it('forwards the controlled query to the search input', async () => {
114
+ const user = userEvent.setup()
115
+ const onQueryChange = vi.fn()
116
+ render(<CategoryGrid categories={categories} presentation="list" query="tal" onQueryChange={onQueryChange} searchLabel="Buscar categorías" />)
117
+ const input = screen.getByLabelText('Buscar categorías') as HTMLInputElement
118
+ expect(input.value).toBe('tal')
119
+ await user.type(input, 'a')
120
+ expect(onQueryChange).toHaveBeenCalled()
121
+ })
122
+ })