@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.
- package/.turbo/turbo-build.log +24 -0
- package/.turbo/turbo-test.log +10 -0
- package/.turbo/turbo-type-check.log +0 -0
- package/AGENTS.md +1 -0
- package/README.md +161 -0
- package/components.json +33 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/contracts.d.mts +563 -0
- package/dist/contracts.d.ts +563 -0
- package/dist/contracts.js +207 -0
- package/dist/contracts.js.map +1 -0
- package/dist/contracts.mjs +168 -0
- package/dist/contracts.mjs.map +1 -0
- package/dist/index.d.mts +2408 -0
- package/dist/index.d.ts +2408 -0
- package/dist/index.js +16422 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +16352 -0
- package/dist/index.mjs.map +1 -0
- package/docs/standards/data-contracts.md +70 -0
- package/docs/standards/theming.md +85 -0
- package/package.json +62 -0
- package/src/blocks/application/app-shells/app-shell-1-sidebar.tsx +198 -0
- package/src/blocks/application/app-shells/app-shell-1.tsx +196 -0
- package/src/blocks/application/login-forms/login-page-1.tsx +246 -0
- package/src/blocks/application/login-forms/login-page-2.tsx +207 -0
- package/src/blocks/application/password-recovery/forgot-password-page-1-brand-mark.tsx +38 -0
- package/src/blocks/application/password-recovery/forgot-password-page-1.tsx +320 -0
- package/src/blocks/application/signup-forms/signup-page-1.tsx +231 -0
- package/src/blocks/application/verification/verify-1.tsx +307 -0
- package/src/blocks/application/verification/verify-confetti.tsx +192 -0
- package/src/blocks/e-commerce/category-filters/category-filter-1.tsx +260 -0
- package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +179 -0
- package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +341 -0
- package/src/blocks/e-commerce/category-filters/category-filter-4-chip-rail.tsx +57 -0
- package/src/blocks/e-commerce/category-filters/category-filter-4-drawer.tsx +124 -0
- package/src/blocks/e-commerce/category-filters/category-filter-4-fields.tsx +170 -0
- package/src/blocks/e-commerce/category-filters/category-filter-4-sidebar.tsx +52 -0
- package/src/blocks/e-commerce/category-filters/category-filter-4-sort-sheet.tsx +57 -0
- package/src/blocks/e-commerce/category-filters/category-filter-4-state.ts +68 -0
- package/src/blocks/e-commerce/category-filters/category-filter-4.tsx +298 -0
- package/src/blocks/e-commerce/checkout-forms/card-formatting.test.ts +47 -0
- package/src/blocks/e-commerce/checkout-forms/card-formatting.ts +9 -0
- package/src/blocks/e-commerce/checkout-forms/checkbox-check.tsx +74 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation-fulfilment-progress.tsx +189 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.tsx +283 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-field-feedback.tsx +29 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-contact-step.tsx +96 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-country-field.tsx +51 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +139 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +184 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-progress.tsx +36 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-shipping-step.tsx +107 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +235 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2-order-summary.tsx +130 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2-payment-card.tsx +175 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2-shipping-card.tsx +190 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2.tsx +312 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-information-step.tsx +91 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-order-summary.tsx +110 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-payment-step.tsx +137 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-progress.tsx +68 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-review-step.tsx +79 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-shipping-step.tsx +172 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3.tsx +392 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-types.ts +7 -0
- package/src/blocks/e-commerce/navigation/category-icon.test.tsx +54 -0
- package/src/blocks/e-commerce/navigation/category-icon.tsx +94 -0
- package/src/blocks/e-commerce/navigation/mega-menu.test.tsx +139 -0
- package/src/blocks/e-commerce/navigation/mega-menu.tsx +271 -0
- package/src/blocks/e-commerce/navigation/mobile-category-drawer.test.tsx +152 -0
- package/src/blocks/e-commerce/navigation/mobile-category-drawer.tsx +276 -0
- package/src/blocks/e-commerce/order-history/order-history-1.tsx +159 -0
- package/src/blocks/e-commerce/order-history/order-history-2.tsx +222 -0
- package/src/blocks/e-commerce/product-categories/avatar-group.tsx +86 -0
- package/src/blocks/e-commerce/product-categories/product-category-1.tsx +126 -0
- package/src/blocks/e-commerce/product-categories/product-category-2.tsx +100 -0
- package/src/blocks/e-commerce/product-categories/product-category-4.tsx +129 -0
- package/src/blocks/e-commerce/product-categories/product-category-5.tsx +218 -0
- package/src/blocks/e-commerce/product-list/product-card-1.tsx +131 -0
- package/src/blocks/e-commerce/product-list/product-card-2.tsx +71 -0
- package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +166 -0
- package/src/blocks/e-commerce/product-list/product-card-3.tsx +215 -0
- package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +98 -0
- package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +171 -0
- package/src/blocks/e-commerce/product-list/product-card-5.tsx +367 -0
- package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +201 -0
- package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +272 -0
- package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +59 -0
- package/src/blocks/e-commerce/product-overview/product-overview-5-lightbox-tab.tsx +104 -0
- package/src/blocks/e-commerce/product-overview/product-overview-5-reviews-tab.tsx +82 -0
- package/src/blocks/e-commerce/product-overview/product-overview-5-tabs.tsx +69 -0
- package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +274 -0
- package/src/blocks/e-commerce/product-overview/product-quick-view-card.tsx +135 -0
- package/src/blocks/e-commerce/product-overview/product-quick-view-dialog.tsx +393 -0
- package/src/blocks/e-commerce/product-overview/product-quick-view.tsx +136 -0
- package/src/blocks/e-commerce/reviews/review-rating-1-review-form.tsx +133 -0
- package/src/blocks/e-commerce/reviews/review-rating-1.tsx +178 -0
- package/src/blocks/e-commerce/reviews/review-rating-2.tsx +246 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +142 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +291 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +110 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +51 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +146 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +295 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +142 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +342 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +146 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +391 -0
- package/src/blocks/e-commerce/store-containers/store-container-1.tsx +29 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +244 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +283 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +318 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +205 -0
- package/src/blocks/marketing/banners/avatar-group.tsx +69 -0
- package/src/blocks/marketing/banners/banner-1.tsx +188 -0
- package/src/blocks/marketing/cta/cta-section-1.tsx +112 -0
- package/src/blocks/marketing/faq/faq-block-1.tsx +45 -0
- package/src/blocks/marketing/features/feature-1.tsx +97 -0
- package/src/blocks/marketing/features/feature-2.tsx +82 -0
- package/src/blocks/marketing/footers/footer-section-1.tsx +141 -0
- package/src/blocks/marketing/footers/footer-section-3.tsx +192 -0
- package/src/blocks/marketing/footers/footer-section-4.tsx +355 -0
- package/src/blocks/marketing/hero-sections/avatar-group.tsx +86 -0
- package/src/blocks/marketing/hero-sections/hero-section-1.tsx +135 -0
- package/src/blocks/marketing/logo-clouds/logo-cloud-1-logo-icon.tsx +52 -0
- package/src/blocks/marketing/logo-clouds/logo-cloud-1.tsx +56 -0
- package/src/blocks/marketing/navbars/navbar-1.tsx +267 -0
- package/src/blocks/marketing/navbars/navbar-2.tsx +351 -0
- package/src/blocks/marketing/navbars/navbar-3.tsx +303 -0
- package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +118 -0
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +248 -0
- package/src/blocks/marketing/navbars/navbar-4-like-button.tsx +177 -0
- package/src/blocks/marketing/navbars/navbar-4-mobile-nav.tsx +104 -0
- package/src/blocks/marketing/navbars/navbar-4-product-grid.tsx +219 -0
- package/src/blocks/marketing/navbars/navbar-4-search-bar.tsx +52 -0
- package/src/blocks/marketing/navbars/navbar-4.tsx +411 -0
- package/src/blocks/marketing/pricing/pricing-section-1.tsx +94 -0
- package/src/blocks/marketing/statistics/statistic-1.tsx +93 -0
- package/src/blocks/marketing/testimonials/testimonial-1.tsx +141 -0
- package/src/blocks/marketing/testimonials/testimonial-2.tsx +108 -0
- package/src/blocks/shared/reaction-toggle.tsx +145 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/index.ts +200 -0
- package/src/lib/contracts.test.ts +32 -0
- package/src/lib/contracts.ts +235 -0
- package/src/lib/types/menu.ts +43 -0
- package/src/lib/types.ts +4 -0
- package/src/lib/utils.ts +10 -0
- package/src/ui/accordion.tsx +65 -0
- package/src/ui/avatar.tsx +108 -0
- package/src/ui/badge.tsx +47 -0
- package/src/ui/breadcrumb.tsx +108 -0
- package/src/ui/button-group.tsx +83 -0
- package/src/ui/button.tsx +63 -0
- package/src/ui/card.tsx +91 -0
- package/src/ui/carousel.tsx +241 -0
- package/src/ui/dialog.tsx +158 -0
- package/src/ui/drawer.tsx +134 -0
- package/src/ui/dropdown-menu.tsx +256 -0
- package/src/ui/empty.tsx +103 -0
- package/src/ui/field.tsx +248 -0
- package/src/ui/input-group.tsx +170 -0
- package/src/ui/input-otp.tsx +76 -0
- package/src/ui/input.tsx +20 -0
- package/src/ui/label.tsx +23 -0
- package/src/ui/navigation-menu.tsx +167 -0
- package/src/ui/pagination.tsx +127 -0
- package/src/ui/progress.tsx +30 -0
- package/src/ui/radio-group.tsx +44 -0
- package/src/ui/select.tsx +189 -0
- package/src/ui/separator.tsx +27 -0
- package/src/ui/sheet.tsx +142 -0
- package/src/ui/sidebar.tsx +726 -0
- package/src/ui/skeleton.tsx +13 -0
- package/src/ui/table.tsx +115 -0
- package/src/ui/tabs.tsx +90 -0
- package/src/ui/textarea.tsx +17 -0
- package/src/ui/toggle-group.tsx +83 -0
- package/src/ui/toggle.tsx +46 -0
- package/src/ui/tooltip.tsx +56 -0
- package/tsconfig.json +20 -0
- package/tsup.config.ts +16 -0
- 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
|
+
}
|