@thorprovider/blocks 0.1.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 (184) hide show
  1. package/.turbo/turbo-build.log +24 -0
  2. package/.turbo/turbo-test.log +10 -0
  3. package/.turbo/turbo-type-check.log +0 -0
  4. package/AGENTS.md +1 -0
  5. package/README.md +161 -0
  6. package/components.json +33 -0
  7. package/dist/.tsbuildinfo +1 -0
  8. package/dist/contracts.d.mts +563 -0
  9. package/dist/contracts.d.ts +563 -0
  10. package/dist/contracts.js +207 -0
  11. package/dist/contracts.js.map +1 -0
  12. package/dist/contracts.mjs +168 -0
  13. package/dist/contracts.mjs.map +1 -0
  14. package/dist/index.d.mts +2408 -0
  15. package/dist/index.d.ts +2408 -0
  16. package/dist/index.js +16422 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/index.mjs +16352 -0
  19. package/dist/index.mjs.map +1 -0
  20. package/docs/standards/data-contracts.md +70 -0
  21. package/docs/standards/theming.md +85 -0
  22. package/package.json +62 -0
  23. package/src/blocks/application/app-shells/app-shell-1-sidebar.tsx +198 -0
  24. package/src/blocks/application/app-shells/app-shell-1.tsx +196 -0
  25. package/src/blocks/application/login-forms/login-page-1.tsx +246 -0
  26. package/src/blocks/application/login-forms/login-page-2.tsx +207 -0
  27. package/src/blocks/application/password-recovery/forgot-password-page-1-brand-mark.tsx +38 -0
  28. package/src/blocks/application/password-recovery/forgot-password-page-1.tsx +320 -0
  29. package/src/blocks/application/signup-forms/signup-page-1.tsx +231 -0
  30. package/src/blocks/application/verification/verify-1.tsx +307 -0
  31. package/src/blocks/application/verification/verify-confetti.tsx +192 -0
  32. package/src/blocks/e-commerce/category-filters/category-filter-1.tsx +260 -0
  33. package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +179 -0
  34. package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +341 -0
  35. package/src/blocks/e-commerce/category-filters/category-filter-4-chip-rail.tsx +57 -0
  36. package/src/blocks/e-commerce/category-filters/category-filter-4-drawer.tsx +124 -0
  37. package/src/blocks/e-commerce/category-filters/category-filter-4-fields.tsx +170 -0
  38. package/src/blocks/e-commerce/category-filters/category-filter-4-sidebar.tsx +52 -0
  39. package/src/blocks/e-commerce/category-filters/category-filter-4-sort-sheet.tsx +57 -0
  40. package/src/blocks/e-commerce/category-filters/category-filter-4-state.ts +68 -0
  41. package/src/blocks/e-commerce/category-filters/category-filter-4.tsx +298 -0
  42. package/src/blocks/e-commerce/checkout-forms/card-formatting.test.ts +47 -0
  43. package/src/blocks/e-commerce/checkout-forms/card-formatting.ts +9 -0
  44. package/src/blocks/e-commerce/checkout-forms/checkbox-check.tsx +74 -0
  45. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation-fulfilment-progress.tsx +189 -0
  46. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.tsx +283 -0
  47. package/src/blocks/e-commerce/checkout-forms/checkout-field-feedback.tsx +29 -0
  48. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-contact-step.tsx +96 -0
  49. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-country-field.tsx +51 -0
  50. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +139 -0
  51. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +184 -0
  52. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-progress.tsx +36 -0
  53. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-shipping-step.tsx +107 -0
  54. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +235 -0
  55. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-order-summary.tsx +130 -0
  56. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-payment-card.tsx +175 -0
  57. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-shipping-card.tsx +190 -0
  58. package/src/blocks/e-commerce/checkout-forms/checkout-form-2.tsx +312 -0
  59. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-information-step.tsx +91 -0
  60. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-order-summary.tsx +110 -0
  61. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-payment-step.tsx +137 -0
  62. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-progress.tsx +68 -0
  63. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-review-step.tsx +79 -0
  64. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-shipping-step.tsx +172 -0
  65. package/src/blocks/e-commerce/checkout-forms/checkout-form-3.tsx +392 -0
  66. package/src/blocks/e-commerce/checkout-forms/checkout-form-types.ts +7 -0
  67. package/src/blocks/e-commerce/navigation/category-icon.test.tsx +54 -0
  68. package/src/blocks/e-commerce/navigation/category-icon.tsx +94 -0
  69. package/src/blocks/e-commerce/navigation/mega-menu.test.tsx +139 -0
  70. package/src/blocks/e-commerce/navigation/mega-menu.tsx +271 -0
  71. package/src/blocks/e-commerce/navigation/mobile-category-drawer.test.tsx +152 -0
  72. package/src/blocks/e-commerce/navigation/mobile-category-drawer.tsx +276 -0
  73. package/src/blocks/e-commerce/order-history/order-history-1.tsx +159 -0
  74. package/src/blocks/e-commerce/order-history/order-history-2.tsx +222 -0
  75. package/src/blocks/e-commerce/product-categories/avatar-group.tsx +86 -0
  76. package/src/blocks/e-commerce/product-categories/product-category-1.tsx +126 -0
  77. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +100 -0
  78. package/src/blocks/e-commerce/product-categories/product-category-4.tsx +129 -0
  79. package/src/blocks/e-commerce/product-categories/product-category-5.tsx +218 -0
  80. package/src/blocks/e-commerce/product-list/product-card-1.tsx +131 -0
  81. package/src/blocks/e-commerce/product-list/product-card-2.tsx +71 -0
  82. package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +166 -0
  83. package/src/blocks/e-commerce/product-list/product-card-3.tsx +215 -0
  84. package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +98 -0
  85. package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +171 -0
  86. package/src/blocks/e-commerce/product-list/product-card-5.tsx +367 -0
  87. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +201 -0
  88. package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +272 -0
  89. package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +59 -0
  90. package/src/blocks/e-commerce/product-overview/product-overview-5-lightbox-tab.tsx +104 -0
  91. package/src/blocks/e-commerce/product-overview/product-overview-5-reviews-tab.tsx +82 -0
  92. package/src/blocks/e-commerce/product-overview/product-overview-5-tabs.tsx +69 -0
  93. package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +274 -0
  94. package/src/blocks/e-commerce/product-overview/product-quick-view-card.tsx +135 -0
  95. package/src/blocks/e-commerce/product-overview/product-quick-view-dialog.tsx +393 -0
  96. package/src/blocks/e-commerce/product-overview/product-quick-view.tsx +136 -0
  97. package/src/blocks/e-commerce/reviews/review-rating-1-review-form.tsx +133 -0
  98. package/src/blocks/e-commerce/reviews/review-rating-1.tsx +178 -0
  99. package/src/blocks/e-commerce/reviews/review-rating-2.tsx +246 -0
  100. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +142 -0
  101. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +291 -0
  102. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +110 -0
  103. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +51 -0
  104. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +146 -0
  105. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +295 -0
  106. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +142 -0
  107. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +342 -0
  108. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +146 -0
  109. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +391 -0
  110. package/src/blocks/e-commerce/store-containers/store-container-1.tsx +29 -0
  111. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +244 -0
  112. package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +283 -0
  113. package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +318 -0
  114. package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +205 -0
  115. package/src/blocks/marketing/banners/avatar-group.tsx +69 -0
  116. package/src/blocks/marketing/banners/banner-1.tsx +188 -0
  117. package/src/blocks/marketing/cta/cta-section-1.tsx +112 -0
  118. package/src/blocks/marketing/faq/faq-block-1.tsx +45 -0
  119. package/src/blocks/marketing/features/feature-1.tsx +97 -0
  120. package/src/blocks/marketing/features/feature-2.tsx +82 -0
  121. package/src/blocks/marketing/footers/footer-section-1.tsx +141 -0
  122. package/src/blocks/marketing/footers/footer-section-3.tsx +192 -0
  123. package/src/blocks/marketing/footers/footer-section-4.tsx +355 -0
  124. package/src/blocks/marketing/hero-sections/avatar-group.tsx +86 -0
  125. package/src/blocks/marketing/hero-sections/hero-section-1.tsx +135 -0
  126. package/src/blocks/marketing/logo-clouds/logo-cloud-1-logo-icon.tsx +52 -0
  127. package/src/blocks/marketing/logo-clouds/logo-cloud-1.tsx +56 -0
  128. package/src/blocks/marketing/navbars/navbar-1.tsx +267 -0
  129. package/src/blocks/marketing/navbars/navbar-2.tsx +351 -0
  130. package/src/blocks/marketing/navbars/navbar-3.tsx +303 -0
  131. package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +118 -0
  132. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +248 -0
  133. package/src/blocks/marketing/navbars/navbar-4-like-button.tsx +177 -0
  134. package/src/blocks/marketing/navbars/navbar-4-mobile-nav.tsx +104 -0
  135. package/src/blocks/marketing/navbars/navbar-4-product-grid.tsx +219 -0
  136. package/src/blocks/marketing/navbars/navbar-4-search-bar.tsx +52 -0
  137. package/src/blocks/marketing/navbars/navbar-4.tsx +411 -0
  138. package/src/blocks/marketing/pricing/pricing-section-1.tsx +94 -0
  139. package/src/blocks/marketing/statistics/statistic-1.tsx +93 -0
  140. package/src/blocks/marketing/testimonials/testimonial-1.tsx +141 -0
  141. package/src/blocks/marketing/testimonials/testimonial-2.tsx +108 -0
  142. package/src/blocks/shared/reaction-toggle.tsx +145 -0
  143. package/src/hooks/use-mobile.ts +19 -0
  144. package/src/index.ts +200 -0
  145. package/src/lib/contracts.test.ts +32 -0
  146. package/src/lib/contracts.ts +235 -0
  147. package/src/lib/types/menu.ts +43 -0
  148. package/src/lib/types.ts +4 -0
  149. package/src/lib/utils.ts +10 -0
  150. package/src/ui/accordion.tsx +65 -0
  151. package/src/ui/avatar.tsx +108 -0
  152. package/src/ui/badge.tsx +47 -0
  153. package/src/ui/breadcrumb.tsx +108 -0
  154. package/src/ui/button-group.tsx +83 -0
  155. package/src/ui/button.tsx +63 -0
  156. package/src/ui/card.tsx +91 -0
  157. package/src/ui/carousel.tsx +241 -0
  158. package/src/ui/dialog.tsx +158 -0
  159. package/src/ui/drawer.tsx +134 -0
  160. package/src/ui/dropdown-menu.tsx +256 -0
  161. package/src/ui/empty.tsx +103 -0
  162. package/src/ui/field.tsx +248 -0
  163. package/src/ui/input-group.tsx +170 -0
  164. package/src/ui/input-otp.tsx +76 -0
  165. package/src/ui/input.tsx +20 -0
  166. package/src/ui/label.tsx +23 -0
  167. package/src/ui/navigation-menu.tsx +167 -0
  168. package/src/ui/pagination.tsx +127 -0
  169. package/src/ui/progress.tsx +30 -0
  170. package/src/ui/radio-group.tsx +44 -0
  171. package/src/ui/select.tsx +189 -0
  172. package/src/ui/separator.tsx +27 -0
  173. package/src/ui/sheet.tsx +142 -0
  174. package/src/ui/sidebar.tsx +726 -0
  175. package/src/ui/skeleton.tsx +13 -0
  176. package/src/ui/table.tsx +115 -0
  177. package/src/ui/tabs.tsx +90 -0
  178. package/src/ui/textarea.tsx +17 -0
  179. package/src/ui/toggle-group.tsx +83 -0
  180. package/src/ui/toggle.tsx +46 -0
  181. package/src/ui/tooltip.tsx +56 -0
  182. package/tsconfig.json +20 -0
  183. package/tsup.config.ts +16 -0
  184. package/vitest.config.ts +9 -0
@@ -0,0 +1,271 @@
1
+ 'use client'
2
+
3
+ import { ChevronRight } from 'lucide-react'
4
+ import { useState } from 'react'
5
+ import { z } from 'zod'
6
+
7
+ import {
8
+ collectionItemSchema,
9
+ menuItemSchema,
10
+ type MenuItem,
11
+ } from '../../../lib/types/menu'
12
+ import { cn } from '../../../lib/utils'
13
+ import { CategoryIcon, categoryIconNameSchema } from './category-icon'
14
+
15
+ /**
16
+ * Three-level category flyout: L1 categories, L2 subcategories, L3 sub-sub
17
+ * categories, plus a right panel with a featured image and collection links.
18
+ *
19
+ * Framework-agnostic and prop-driven: plain anchors replace router links,
20
+ * `loading="lazy"` images replace next/image, and Spanish copy/hrefs are
21
+ * optional props with ES defaults. `onNavigate` lets the tenant close the
22
+ * flyout on navigation; `collectionsHref` controls the "view all" link
23
+ * (pass `''` to hide it).
24
+ *
25
+ * Ported from the legacy `@thorprovider/components` navigation library
26
+ * (source: branch `feat/storefronts`).
27
+ */
28
+
29
+ export const megaMenuPropsSchema = z.object({
30
+ categories: z.array(menuItemSchema),
31
+ collections: z.array(collectionItemSchema),
32
+ onNavigate: z.custom<() => void>().optional(),
33
+ categoryColumnTitle: z.string().optional(),
34
+ collectionsTitle: z.string().optional(),
35
+ viewAllLabel: z.string().optional(),
36
+ collectionsHref: z.string().optional(),
37
+ ariaLabel: z.string().optional(),
38
+ })
39
+
40
+ export type MegaMenuProps = z.infer<typeof megaMenuPropsSchema>
41
+
42
+ function MegaMenuColumn({
43
+ title,
44
+ items,
45
+ activeId,
46
+ onHover,
47
+ onNavigate,
48
+ level,
49
+ scrollable = false,
50
+ }: {
51
+ title?: string
52
+ items: MenuItem[]
53
+ activeId?: string
54
+ onHover?: (item: MenuItem) => void
55
+ onNavigate?: () => void
56
+ level: 1 | 2 | 3
57
+ scrollable?: boolean
58
+ }) {
59
+ return (
60
+ <div className="flex min-h-0 flex-1 flex-col">
61
+ {title ? (
62
+ <p className="shrink-0 px-3 pb-1 pt-3 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
63
+ {title}
64
+ </p>
65
+ ) : null}
66
+ <ul
67
+ className={cn('flex-1 py-1', scrollable ? 'overflow-y-auto' : '')}
68
+ role="list"
69
+ >
70
+ {items.map((item) => {
71
+ const isActive = item.id === activeId
72
+ const hasChildren = Boolean(item.children && item.children.length > 0)
73
+ const parsedIcon = categoryIconNameSchema.safeParse(item.icon)
74
+ const iconName = parsedIcon.success ? parsedIcon.data : undefined
75
+ return (
76
+ <li key={item.id}>
77
+ <a
78
+ href={item.href ?? '#'}
79
+ className={cn(
80
+ 'group/item flex items-center gap-2.5 px-3 py-2 text-sm transition-colors',
81
+ isActive
82
+ ? 'bg-secondary font-semibold text-primary'
83
+ : 'text-foreground hover:bg-secondary/60',
84
+ )}
85
+ onMouseEnter={() => onHover?.(item)}
86
+ onClick={() => {
87
+ onHover?.(item)
88
+ onNavigate?.()
89
+ }}
90
+ aria-current={isActive ? 'true' : undefined}
91
+ >
92
+ {level === 1 ? (
93
+ item.image ? (
94
+ <img
95
+ src={item.image || undefined}
96
+ alt=""
97
+ loading="lazy"
98
+ className="size-7 shrink-0 rounded object-cover"
99
+ />
100
+ ) : (
101
+ <CategoryIcon
102
+ name={iconName}
103
+ className="size-4 shrink-0 text-muted-foreground group-hover/item:text-primary"
104
+ />
105
+ )
106
+ ) : null}
107
+ {level === 2 && item.image ? (
108
+ <img
109
+ src={item.image || undefined}
110
+ alt=""
111
+ loading="lazy"
112
+ className="size-6 shrink-0 rounded object-cover"
113
+ />
114
+ ) : null}
115
+ <span className="flex-1 leading-tight">{item.label}</span>
116
+ {item.badge ? (
117
+ <span className="rounded bg-primary px-1.5 py-0.5 text-[10px] font-bold text-primary-foreground">
118
+ {item.badge}
119
+ </span>
120
+ ) : null}
121
+ {hasChildren ? (
122
+ <ChevronRight
123
+ className={cn(
124
+ 'size-3.5 shrink-0 transition-transform',
125
+ isActive ? 'text-primary' : 'text-muted-foreground',
126
+ )}
127
+ />
128
+ ) : null}
129
+ </a>
130
+ </li>
131
+ )
132
+ })}
133
+ </ul>
134
+ </div>
135
+ )
136
+ }
137
+
138
+ export function MegaMenu({
139
+ categories,
140
+ collections,
141
+ onNavigate,
142
+ categoryColumnTitle = 'Comprar por categorías',
143
+ collectionsTitle = 'Colecciones',
144
+ viewAllLabel = 'Ver todas las colecciones',
145
+ collectionsHref = '/colecciones',
146
+ ariaLabel = 'Menú de categorías',
147
+ }: MegaMenuProps) {
148
+ const [activeL1, setActiveL1] = useState<MenuItem>(categories[0])
149
+ const [activeL2, setActiveL2] = useState<MenuItem | undefined>(
150
+ categories[0]?.children?.[0],
151
+ )
152
+
153
+ const handleL1Hover = (item: MenuItem) => {
154
+ setActiveL1(item)
155
+ setActiveL2(item.children?.[0])
156
+ }
157
+
158
+ const l2Items = activeL1?.children ?? []
159
+ const l3Items = activeL2?.children ?? []
160
+
161
+ const featuredImage = activeL2?.image ?? activeL1?.image
162
+
163
+ return (
164
+ <div
165
+ className="flex h-[520px] overflow-hidden rounded-b-lg border border-t-0 bg-card shadow-xl"
166
+ role="dialog"
167
+ aria-label={ariaLabel}
168
+ >
169
+ <div className="flex w-56 min-w-0 shrink-0 flex-col overflow-hidden border-r">
170
+ <MegaMenuColumn
171
+ title={categoryColumnTitle}
172
+ items={categories}
173
+ activeId={activeL1?.id}
174
+ onHover={handleL1Hover}
175
+ onNavigate={onNavigate}
176
+ level={1}
177
+ scrollable
178
+ />
179
+ </div>
180
+
181
+ {l2Items.length > 0 ? (
182
+ <div className="flex w-56 min-w-0 shrink-0 flex-col overflow-hidden border-r">
183
+ <MegaMenuColumn
184
+ title={activeL1?.label}
185
+ items={l2Items}
186
+ activeId={activeL2?.id}
187
+ onHover={(item) => setActiveL2(item)}
188
+ onNavigate={onNavigate}
189
+ level={2}
190
+ scrollable
191
+ />
192
+ </div>
193
+ ) : null}
194
+
195
+ {l3Items.length > 0 ? (
196
+ <div className="flex w-52 min-w-0 shrink-0 flex-col overflow-hidden border-r">
197
+ <MegaMenuColumn
198
+ title={activeL2?.label}
199
+ items={l3Items}
200
+ onNavigate={onNavigate}
201
+ level={3}
202
+ scrollable
203
+ />
204
+ </div>
205
+ ) : null}
206
+
207
+ <div className="flex min-w-0 flex-1 flex-col">
208
+ {featuredImage ? (
209
+ <a
210
+ href={activeL2?.href ?? activeL1?.href ?? '#'}
211
+ onClick={onNavigate}
212
+ className="group relative block h-44 shrink-0 overflow-hidden"
213
+ >
214
+ <img
215
+ src={featuredImage || undefined}
216
+ alt={activeL2?.label ?? activeL1?.label ?? ''}
217
+ loading="lazy"
218
+ className="size-full object-cover transition-transform duration-300 group-hover:scale-105"
219
+ />
220
+ <div className="absolute inset-0 bg-gradient-to-t from-black/70 to-transparent" />
221
+ <p className="absolute bottom-2 left-3 right-3 text-xs font-bold leading-tight text-white">
222
+ {activeL2?.label ?? activeL1?.label}
223
+ </p>
224
+ </a>
225
+ ) : null}
226
+
227
+ <div className="flex flex-1 flex-col overflow-y-auto">
228
+ <p className="shrink-0 px-3 pb-2 pt-3 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
229
+ {collectionsTitle}
230
+ </p>
231
+ <div className="flex flex-col gap-1 px-3 pb-3">
232
+ {collections.map((col) => (
233
+ <a
234
+ key={col.id}
235
+ href={col.href}
236
+ className="group flex items-center gap-3 rounded-md p-1.5 transition-colors hover:bg-secondary/60"
237
+ >
238
+ <img
239
+ src={col.image || undefined}
240
+ alt=""
241
+ loading="lazy"
242
+ className="size-12 shrink-0 rounded-md object-cover transition-transform duration-300 group-hover:scale-105"
243
+ />
244
+ <div className="min-w-0">
245
+ <p className="truncate text-xs font-semibold leading-tight text-foreground">
246
+ {col.label}
247
+ </p>
248
+ {col.description ? (
249
+ <p className="truncate text-[11px] text-muted-foreground">
250
+ {col.description}
251
+ </p>
252
+ ) : null}
253
+ </div>
254
+ </a>
255
+ ))}
256
+ </div>
257
+ </div>
258
+ {collectionsHref ? (
259
+ <div className="border-t p-3">
260
+ <a
261
+ href={collectionsHref}
262
+ className="block rounded-md bg-secondary px-3 py-2.5 text-center text-xs font-bold text-foreground hover:bg-secondary/70"
263
+ >
264
+ {viewAllLabel}
265
+ </a>
266
+ </div>
267
+ ) : null}
268
+ </div>
269
+ </div>
270
+ )
271
+ }
@@ -0,0 +1,152 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import type { CollectionItem, MenuItem } from '../../../lib/types/menu'
5
+ import {
6
+ MobileCategoryDrawer,
7
+ mobileCategoryDrawerPropsSchema,
8
+ } from './mobile-category-drawer'
9
+
10
+ const categories: MenuItem[] = [
11
+ {
12
+ id: 'cat-1',
13
+ label: 'Electrodomésticos',
14
+ icon: 'refrigerator',
15
+ children: [
16
+ { id: 'sub-1', label: 'Neveras', href: '/neveras' },
17
+ { id: 'sub-2', label: 'Lavadoras', href: '/lavadoras' },
18
+ ],
19
+ },
20
+ { id: 'cat-2', label: 'Herramientas', icon: 'wrench', href: '/herramientas' },
21
+ ]
22
+
23
+ const collections: CollectionItem[] = [
24
+ {
25
+ id: 'col-1',
26
+ label: 'Ofertas',
27
+ image: '/ofertas.jpg',
28
+ href: '/colecciones/ofertas',
29
+ },
30
+ {
31
+ id: 'col-2',
32
+ label: 'Novedades',
33
+ image: '/novedades.jpg',
34
+ href: '/colecciones/novedades',
35
+ },
36
+ ]
37
+
38
+ describe('MobileCategoryDrawer', () => {
39
+ it('keeps the drawer mounted off-screen with aria-hidden when closed', () => {
40
+ const html = renderToStaticMarkup(
41
+ <MobileCategoryDrawer
42
+ categories={categories}
43
+ collections={collections}
44
+ open={false}
45
+ onClose={() => {}}
46
+ />,
47
+ )
48
+ // Drawer stays mounted so it can slide in; hidden + translated off-screen.
49
+ expect(html).toContain('role="dialog"')
50
+ expect(html).toContain('aria-hidden="true"')
51
+ expect(html).toContain('-translate-x-full')
52
+ // No backdrop when closed.
53
+ expect(html).not.toContain('backdrop-blur-sm')
54
+ })
55
+
56
+ it('renders the category drill-down when open=true', () => {
57
+ const html = renderToStaticMarkup(
58
+ <MobileCategoryDrawer
59
+ categories={categories}
60
+ collections={collections}
61
+ open
62
+ onClose={() => {}}
63
+ />,
64
+ )
65
+ expect(html).toContain('role="dialog"')
66
+ expect(html).toContain('aria-modal="true"')
67
+ expect(html).toContain('aria-label="Menú de categorías"')
68
+ expect(html).toContain('translate-x-0')
69
+ expect(html).toContain('Electrodomésticos')
70
+ expect(html).toContain('Herramientas')
71
+ expect(html).toContain('role="list"')
72
+ expect(html).toContain('href="/herramientas"')
73
+ // Category with children renders as a push button (drill-down affordance)
74
+ expect(html).toContain('<button')
75
+ })
76
+
77
+ it('renders close control with aria-label and no back control at root level', () => {
78
+ const html = renderToStaticMarkup(
79
+ <MobileCategoryDrawer
80
+ categories={categories}
81
+ collections={collections}
82
+ open
83
+ onClose={() => {}}
84
+ />,
85
+ )
86
+ expect(html).toContain('aria-label="Cerrar menú"')
87
+ // Back control only appears after drilling in (stack.length > 1), which
88
+ // requires interaction not reachable via renderToStaticMarkup.
89
+ expect(html).not.toContain('aria-label="Volver"')
90
+ })
91
+
92
+ it('renders the collections grid', () => {
93
+ const html = renderToStaticMarkup(
94
+ <MobileCategoryDrawer
95
+ categories={categories}
96
+ collections={collections}
97
+ open
98
+ onClose={() => {}}
99
+ />,
100
+ )
101
+ expect(html).toContain('Colecciones')
102
+ expect(html).toContain('Ofertas')
103
+ expect(html).toContain('Novedades')
104
+ expect(html).toContain('href="/colecciones/ofertas"')
105
+ expect(html).toContain('href="/colecciones"')
106
+ expect(html).toContain('Ver todas')
107
+ expect(html).toContain('grid grid-cols-2')
108
+ })
109
+
110
+ it('applies copy overrides', () => {
111
+ const html = renderToStaticMarkup(
112
+ <MobileCategoryDrawer
113
+ categories={categories}
114
+ collections={collections}
115
+ open
116
+ onClose={() => {}}
117
+ rootTitle="Mis Categorías"
118
+ collectionsTitle="Mis Colecciones"
119
+ viewAllLabel="Explorar todo"
120
+ viewAllHref="/tienda/todas"
121
+ closeAriaLabel="Cerrar"
122
+ menuAriaLabel="Navegación"
123
+ />,
124
+ )
125
+ expect(html).toContain('Mis Categorías')
126
+ expect(html).toContain('Mis Colecciones')
127
+ expect(html).toContain('Explorar todo')
128
+ expect(html).toContain('href="/tienda/todas"')
129
+ expect(html).toContain('aria-label="Cerrar"')
130
+ expect(html).toContain('aria-label="Navegación"')
131
+ })
132
+
133
+ it('parses a valid props object', () => {
134
+ const result = mobileCategoryDrawerPropsSchema.parse({
135
+ categories,
136
+ collections,
137
+ open: true,
138
+ onClose: () => {},
139
+ })
140
+ expect(result.categories).toHaveLength(2)
141
+ })
142
+
143
+ it('rejects props missing categories at the schema boundary', () => {
144
+ expect(() =>
145
+ mobileCategoryDrawerPropsSchema.parse({
146
+ collections,
147
+ open: true,
148
+ onClose: () => {},
149
+ }),
150
+ ).toThrow()
151
+ })
152
+ })
@@ -0,0 +1,276 @@
1
+ 'use client'
2
+
3
+ import { ChevronLeft, ChevronRight, X } from 'lucide-react'
4
+ import { useEffect, useState } from 'react'
5
+ import { z } from 'zod'
6
+
7
+ import {
8
+ collectionItemSchema,
9
+ menuItemSchema,
10
+ type CollectionItem,
11
+ type MenuItem,
12
+ } from '../../../lib/types/menu'
13
+ import { cn } from '../../../lib/utils'
14
+ import { type CategoryIconName, CategoryIcon } from './category-icon'
15
+
16
+ type Level = {
17
+ items: MenuItem[]
18
+ title: string
19
+ image?: string
20
+ href?: string
21
+ }
22
+
23
+ /**
24
+ * Slide-in drill-down category drawer: a stack-based menu where selecting a
25
+ * category with children pushes a new level, and a back control pops it.
26
+ *
27
+ * Framework-agnostic: plain anchors (no router), `<img>` (no image
28
+ * optimization), copy + hrefs as optional props with ES defaults. The drawer
29
+ * stays MOUNTED while closed and slides in/out with a 200ms transform
30
+ * (reproducing the original behavior); the backdrop renders only while open.
31
+ * Locks body scroll while open. No focus trap (out of scope).
32
+ *
33
+ * Ported from the legacy `@thorprovider/components` navigation library
34
+ * (source: branch `feat/storefronts`).
35
+ */
36
+ export const mobileCategoryDrawerPropsSchema = z.object({
37
+ categories: z.array(menuItemSchema),
38
+ collections: z.array(collectionItemSchema),
39
+ open: z.boolean(),
40
+ onClose: z.function().returns(z.void()),
41
+ rootTitle: z.string().optional(),
42
+ collectionsTitle: z.string().optional(),
43
+ viewAllLabel: z.string().optional(),
44
+ viewAllHref: z.string().optional(),
45
+ closeAriaLabel: z.string().optional(),
46
+ backAriaLabel: z.string().optional(),
47
+ menuAriaLabel: z.string().optional(),
48
+ })
49
+
50
+ export type MobileCategoryDrawerProps = z.infer<
51
+ typeof mobileCategoryDrawerPropsSchema
52
+ >
53
+
54
+ export function MobileCategoryDrawer({
55
+ categories,
56
+ collections,
57
+ open,
58
+ onClose,
59
+ rootTitle = 'Categorías',
60
+ collectionsTitle = 'Colecciones',
61
+ viewAllLabel = 'Ver todas',
62
+ viewAllHref = '/colecciones',
63
+ closeAriaLabel = 'Cerrar menú',
64
+ backAriaLabel = 'Volver',
65
+ menuAriaLabel = 'Menú de categorías',
66
+ }: MobileCategoryDrawerProps) {
67
+ const root: Level = { items: categories, title: rootTitle }
68
+ const [stack, setStack] = useState<Level[]>([root])
69
+ const current = stack[stack.length - 1]
70
+
71
+ useEffect(() => {
72
+ if (!open) return
73
+ const previous = document.body.style.overflow
74
+ document.body.style.overflow = 'hidden'
75
+ return () => {
76
+ document.body.style.overflow = previous
77
+ }
78
+ }, [open])
79
+
80
+ const push = (item: MenuItem) => {
81
+ if (item.children?.length) {
82
+ setStack((levels) => [
83
+ ...levels,
84
+ {
85
+ items: item.children!,
86
+ title: item.label,
87
+ image: item.image ?? current.image,
88
+ href: item.href,
89
+ },
90
+ ])
91
+ }
92
+ }
93
+
94
+ const handleClose = () => {
95
+ setStack([root])
96
+ onClose()
97
+ }
98
+
99
+ return (
100
+ <>
101
+ {open ? (
102
+ <button
103
+ type="button"
104
+ className="fixed inset-0 z-40 bg-foreground/50 backdrop-blur-sm"
105
+ aria-label={closeAriaLabel}
106
+ onClick={handleClose}
107
+ />
108
+ ) : null}
109
+ <div
110
+ role="dialog"
111
+ aria-label={menuAriaLabel}
112
+ aria-modal="true"
113
+ aria-hidden={!open}
114
+ className={cn(
115
+ 'fixed inset-y-0 left-0 z-50 flex w-full max-w-sm flex-col overflow-hidden bg-card text-foreground shadow-2xl transition-transform duration-200',
116
+ open ? 'translate-x-0' : '-translate-x-full',
117
+ )}
118
+ >
119
+ <div className="flex min-h-14 shrink-0 items-center gap-2 border-b bg-primary px-3 text-primary-foreground">
120
+ {stack.length > 1 && (
121
+ <button
122
+ onClick={() => setStack((levels) => levels.slice(0, -1))}
123
+ aria-label={backAriaLabel}
124
+ className="rounded-md p-2 hover:bg-primary-foreground/10"
125
+ >
126
+ <ChevronLeft className="size-5" />
127
+ </button>
128
+ )}
129
+ <span className="flex-1 truncate text-sm font-bold">
130
+ {current.title}
131
+ </span>
132
+ <button
133
+ onClick={handleClose}
134
+ aria-label={closeAriaLabel}
135
+ className="rounded-md p-2 hover:bg-primary-foreground/10"
136
+ >
137
+ <X className="size-5" />
138
+ </button>
139
+ </div>
140
+
141
+ {current.image && (
142
+ <div className="relative h-28 shrink-0">
143
+ <img
144
+ src={current.image || undefined}
145
+ alt=""
146
+ loading="lazy"
147
+ className="absolute inset-0 h-full w-full object-cover"
148
+ />
149
+ <div className="absolute inset-0 bg-foreground/35" />
150
+ <p className="absolute inset-x-4 bottom-3 text-base font-black text-primary-foreground">
151
+ {current.title}
152
+ </p>
153
+ </div>
154
+ )}
155
+
156
+ <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
157
+ {current.href && (
158
+ <a
159
+ href={current.href}
160
+ onClick={handleClose}
161
+ className="mx-4 mt-3 flex h-11 items-center justify-between rounded-md bg-primary px-4 text-sm font-bold text-primary-foreground"
162
+ >
163
+ Ver todos en {current.title}
164
+ <ChevronRight className="size-4" aria-hidden="true" />
165
+ </a>
166
+ )}
167
+ <ul className="py-2" role="list">
168
+ {current.items.map((item) => {
169
+ const hasChildren = Boolean(item.children?.length)
170
+ const image = item.image ?? current.image
171
+ const content = (
172
+ <>
173
+ {image ? (
174
+ <span className="relative size-12 shrink-0 overflow-hidden rounded-md">
175
+ <img
176
+ src={image || undefined}
177
+ alt=""
178
+ loading="lazy"
179
+ className="absolute inset-0 h-full w-full object-cover"
180
+ />
181
+ </span>
182
+ ) : (
183
+ <span className="flex size-12 shrink-0 items-center justify-center rounded-md bg-secondary">
184
+ <CategoryIcon
185
+ name={item.icon as CategoryIconName}
186
+ className="size-5 text-muted-foreground"
187
+ />
188
+ </span>
189
+ )}
190
+ <span className="min-w-0 flex-1 text-pretty font-semibold leading-snug">
191
+ {item.label}
192
+ </span>
193
+ {item.badge && (
194
+ <span className="rounded bg-primary px-1.5 py-0.5 text-[10px] font-bold text-primary-foreground">
195
+ {item.badge}
196
+ </span>
197
+ )}
198
+ {hasChildren && (
199
+ <ChevronRight className="size-4 shrink-0 text-muted-foreground" />
200
+ )}
201
+ </>
202
+ )
203
+ return (
204
+ <li key={`${item.id}:${item.label}`}>
205
+ {hasChildren ? (
206
+ <button
207
+ onClick={() => push(item)}
208
+ className="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm hover:bg-secondary"
209
+ >
210
+ {content}
211
+ </button>
212
+ ) : (
213
+ <a
214
+ href={item.href ?? '#'}
215
+ onClick={handleClose}
216
+ className="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-secondary"
217
+ >
218
+ {content}
219
+ </a>
220
+ )}
221
+ </li>
222
+ )
223
+ })}
224
+ </ul>
225
+
226
+ {stack.length === 1 && (
227
+ <section
228
+ className="border-t px-4 py-4"
229
+ aria-labelledby="mobile-collections-title"
230
+ >
231
+ <div className="mb-3 flex items-center justify-between gap-3">
232
+ <h2
233
+ id="mobile-collections-title"
234
+ className="text-xs font-black uppercase tracking-wider text-muted-foreground"
235
+ >
236
+ {collectionsTitle}
237
+ </h2>
238
+ <a
239
+ href={viewAllHref}
240
+ onClick={handleClose}
241
+ className="text-xs font-bold text-primary"
242
+ >
243
+ {viewAllLabel}
244
+ </a>
245
+ </div>
246
+ <div className="grid grid-cols-2 gap-3">
247
+ {collections.map((collection) => (
248
+ <a
249
+ key={`${collection.id}:${collection.label}`}
250
+ href={collection.href}
251
+ onClick={handleClose}
252
+ className="group overflow-hidden rounded-lg border bg-background"
253
+ >
254
+ <div className="relative aspect-square">
255
+ <img
256
+ src={collection.image || undefined}
257
+ alt=""
258
+ loading="lazy"
259
+ className="absolute inset-0 h-full w-full object-cover transition-transform group-hover:scale-105"
260
+ />
261
+ </div>
262
+ <div className="p-2">
263
+ <p className="text-pretty text-xs font-bold leading-snug">
264
+ {collection.label}
265
+ </p>
266
+ </div>
267
+ </a>
268
+ ))}
269
+ </div>
270
+ </section>
271
+ )}
272
+ </div>
273
+ </div>
274
+ </>
275
+ )
276
+ }