@thorprovider/blocks 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.turbo/turbo-build.log +15 -15
  2. package/.turbo/turbo-test.log +32 -5
  3. package/AGENTS.md +1 -1
  4. package/README.md +52 -0
  5. package/dist/.tsbuildinfo +1 -1
  6. package/dist/contracts.d.mts +1111 -90
  7. package/dist/contracts.d.ts +1111 -90
  8. package/dist/contracts.js +189 -6
  9. package/dist/contracts.js.map +1 -1
  10. package/dist/contracts.mjs +173 -7
  11. package/dist/contracts.mjs.map +1 -1
  12. package/dist/index.d.mts +467 -89
  13. package/dist/index.d.ts +467 -89
  14. package/dist/index.js +2967 -2250
  15. package/dist/index.js.map +1 -1
  16. package/dist/index.mjs +2989 -2282
  17. package/dist/index.mjs.map +1 -1
  18. package/docs/standards/theming.md +32 -5
  19. package/package.json +4 -3
  20. package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +136 -93
  21. package/src/blocks/e-commerce/category-filters/category-filter-2.test.tsx +513 -0
  22. package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +728 -148
  23. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation-fulfilment-progress.tsx +14 -8
  24. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.test.tsx +136 -0
  25. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.tsx +145 -99
  26. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +215 -102
  27. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +6 -2
  28. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-shipping-step.tsx +51 -2
  29. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +216 -0
  30. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +114 -11
  31. package/src/blocks/e-commerce/navigation/mobile-category-drawer.test.tsx +42 -2
  32. package/src/blocks/e-commerce/navigation/mobile-category-drawer.tsx +10 -8
  33. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +51 -0
  34. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +56 -36
  35. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +182 -0
  36. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +298 -129
  37. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +137 -0
  38. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +231 -89
  39. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +65 -0
  40. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +19 -0
  41. package/src/blocks/marketing/footers/footer-section-1.test.tsx +117 -0
  42. package/src/blocks/marketing/footers/footer-section-1.tsx +46 -9
  43. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +54 -0
  44. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +43 -13
  45. package/src/blocks/marketing/navbars/navbar-4.test.tsx +112 -0
  46. package/src/blocks/marketing/navbars/navbar-4.tsx +101 -28
  47. package/src/index.ts +38 -0
  48. package/src/lib/cart.test.ts +15 -0
  49. package/src/lib/cart.ts +11 -0
  50. package/src/lib/contracts-curated.test.ts +207 -0
  51. package/src/lib/contracts-server-boundary.test.ts +52 -0
  52. package/src/lib/contracts.ts +182 -4
  53. package/src/lib/money.test.ts +64 -0
  54. package/src/lib/money.ts +65 -0
  55. package/styles.css +1 -0
  56. package/tsup.config.ts +0 -1
  57. package/vitest.config.ts +9 -1
@@ -0,0 +1,216 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+ import type { PaymentMethod } from '@thorprovider/types'
4
+ import { CheckoutForm1PaymentStep } from './checkout-form-1-payment-step'
5
+ import { CheckoutForm1ShippingStep } from './checkout-form-1-shipping-step'
6
+ import { CheckoutForm1OrderSummary } from './checkout-form-1-order-summary'
7
+ import type { CheckoutForm1FormData } from './checkout-form-1'
8
+
9
+ const formData: CheckoutForm1FormData = {
10
+ email: '',
11
+ firstName: '',
12
+ lastName: '',
13
+ phone: '',
14
+ address: '',
15
+ city: '',
16
+ state: '',
17
+ zipCode: '',
18
+ country: 'US',
19
+ paymentMethod: 'pp_card',
20
+ cardNumber: '',
21
+ expiryDate: '',
22
+ cvv: '',
23
+ cardName: '',
24
+ saveInfo: false,
25
+ sameAsBilling: true,
26
+ newsletter: false,
27
+ promoCode: '',
28
+ }
29
+
30
+ const cardMethod: PaymentMethod = {
31
+ id: 'pp_card',
32
+ name: 'Tarjeta',
33
+ type: 'card',
34
+ isEnabled: true,
35
+ requiresSetup: true,
36
+ description: 'Paga con tarjeta',
37
+ }
38
+
39
+ const manualMethod: PaymentMethod = {
40
+ id: 'pp_system_default',
41
+ name: 'Pago manual TEST',
42
+ type: 'cash_on_delivery',
43
+ isEnabled: true,
44
+ requiresSetup: false,
45
+ description: 'Pago de prueba, sin tarjeta',
46
+ }
47
+
48
+ const noop = () => {}
49
+
50
+ const formatMoney = (money: { amount: string; currencyCode: string }) =>
51
+ `${money.currencyCode} ${money.amount}`
52
+
53
+ describe('CheckoutForm1 payment step — launch flow collects no raw card data', () => {
54
+ it('renders no card fields by default even for a card-type method', () => {
55
+ const html = renderToStaticMarkup(
56
+ <CheckoutForm1PaymentStep
57
+ formData={formData}
58
+ onChange={noop}
59
+ onCardNumberChange={noop}
60
+ onExpiryChange={noop}
61
+ paymentMethods={[cardMethod]}
62
+ />,
63
+ )
64
+ expect(html).not.toContain('checkout-card-number')
65
+ expect(html).not.toContain('checkout-cvv')
66
+ expect(html).toContain('Paga con tarjeta')
67
+ })
68
+
69
+ it('renders the approved manual TEST provider label only', () => {
70
+ const html = renderToStaticMarkup(
71
+ <CheckoutForm1PaymentStep
72
+ formData={{ ...formData, paymentMethod: 'pp_system_default' }}
73
+ onChange={noop}
74
+ onCardNumberChange={noop}
75
+ onExpiryChange={noop}
76
+ paymentMethods={[manualMethod]}
77
+ />,
78
+ )
79
+ expect(html).toContain('Pago manual TEST')
80
+ expect(html).toContain('Pago de prueba, sin tarjeta')
81
+ expect(html).not.toContain('checkout-card-number')
82
+ })
83
+
84
+ it('renders card fields only in the explicit legacy mode', () => {
85
+ const html = renderToStaticMarkup(
86
+ <CheckoutForm1PaymentStep
87
+ formData={formData}
88
+ onChange={noop}
89
+ onCardNumberChange={noop}
90
+ onExpiryChange={noop}
91
+ paymentMethods={[cardMethod]}
92
+ collectCardFields
93
+ />,
94
+ )
95
+ expect(html).toContain('checkout-card-number')
96
+ expect(html).toContain('checkout-cvv')
97
+ })
98
+ })
99
+
100
+ describe('CheckoutForm1 shipping step — controlled eligible options', () => {
101
+ it('renders only the supplied eligible shipping options with their prices', () => {
102
+ const html = renderToStaticMarkup(
103
+ <CheckoutForm1ShippingStep
104
+ formData={formData}
105
+ onChange={noop}
106
+ countries={[{ value: 'US', label: 'Estados Unidos' }]}
107
+ shippingOptions={[
108
+ { id: 'sm_std', name: 'Estándar', price: { amount: '5.00', currencyCode: 'USD' } },
109
+ { id: 'sm_exp', name: 'Exprés', price: { amount: '15.00', currencyCode: 'USD' } },
110
+ ]}
111
+ selectedShippingOptionId='sm_exp'
112
+ onSelectShippingOption={noop}
113
+ formatMoney={formatMoney}
114
+ />,
115
+ )
116
+ expect(html).toContain('Estándar')
117
+ expect(html).toContain('Exprés')
118
+ expect(html).toContain('USD 5.00')
119
+ expect(html).toContain('USD 15.00')
120
+ })
121
+
122
+ it('renders no shipping section when no options are supplied', () => {
123
+ const html = renderToStaticMarkup(
124
+ <CheckoutForm1ShippingStep formData={formData} onChange={noop} />,
125
+ )
126
+ expect(html).not.toContain('Shipping method')
127
+ })
128
+ })
129
+
130
+ describe('CheckoutForm1 order summary — authoritative totals and states', () => {
131
+ const items = [
132
+ {
133
+ id: 'line_1',
134
+ name: 'Taladro',
135
+ variant: 'Rojo',
136
+ quantity: 2,
137
+ lineTotal: { amount: '90.00', currencyCode: 'USD' },
138
+ },
139
+ ]
140
+
141
+ it('renders backend-authoritative totals verbatim', () => {
142
+ const html = renderToStaticMarkup(
143
+ <CheckoutForm1OrderSummary
144
+ orderSummary={{
145
+ items,
146
+ totals: {
147
+ subtotal: { amount: '90.00', currencyCode: 'USD' },
148
+ tax: { amount: '7.20', currencyCode: 'USD' },
149
+ shipping: { amount: '5.00', currencyCode: 'USD' },
150
+ total: { amount: '102.20', currencyCode: 'USD' },
151
+ },
152
+ }}
153
+ formatMoney={formatMoney}
154
+ />,
155
+ )
156
+ expect(html).toContain('USD 90.00')
157
+ expect(html).toContain('USD 7.20')
158
+ expect(html).toContain('USD 5.00')
159
+ expect(html).toContain('USD 102.20')
160
+ expect(html).not.toContain('Free shipping on orders over')
161
+ })
162
+
163
+ it('renders a controlled pending state without inventing amounts', () => {
164
+ const html = renderToStaticMarkup(
165
+ <CheckoutForm1OrderSummary orderSummary={{ items }} totalsState='pending' />,
166
+ )
167
+ expect(html).toContain('Actualizando totales')
168
+ expect(html).not.toContain('Total')
169
+ })
170
+
171
+ it('never renders a fake zero for absent or malformed required totals', () => {
172
+ const html = renderToStaticMarkup(
173
+ <CheckoutForm1OrderSummary
174
+ orderSummary={{
175
+ items,
176
+ totals: {
177
+ subtotal: { amount: '', currencyCode: 'USD' },
178
+ total: { amount: 'abc', currencyCode: 'USD' },
179
+ },
180
+ }}
181
+ formatMoney={formatMoney}
182
+ />,
183
+ )
184
+ expect(html).not.toContain('USD 0')
185
+ expect(html).toContain('Totales no disponibles')
186
+ })
187
+
188
+ it('preserves a legitimate zero total', () => {
189
+ const html = renderToStaticMarkup(
190
+ <CheckoutForm1OrderSummary
191
+ orderSummary={{
192
+ items,
193
+ totals: {
194
+ subtotal: { amount: '0', currencyCode: 'USD' },
195
+ total: { amount: '0', currencyCode: 'USD' },
196
+ },
197
+ }}
198
+ formatMoney={formatMoney}
199
+ />,
200
+ )
201
+ expect(html).toContain('USD 0')
202
+ expect(html).not.toContain('Totales no disponibles')
203
+ })
204
+
205
+ it('renders a controlled error state with a recovery action', () => {
206
+ const html = renderToStaticMarkup(
207
+ <CheckoutForm1OrderSummary
208
+ orderSummary={{ items }}
209
+ totalsState='error'
210
+ onRetryTotals={noop}
211
+ />,
212
+ )
213
+ expect(html).toContain('No se pudieron actualizar los totales')
214
+ expect(html).toContain('Reintentar')
215
+ })
216
+ })
@@ -11,7 +11,7 @@ import { CheckoutForm1PaymentStep } from './checkout-form-1-payment-step'
11
11
  import { CheckoutForm1OrderSummary } from './checkout-form-1-order-summary'
12
12
  import { CheckoutForm1Progress } from './checkout-form-1-progress'
13
13
  import { formatCardNumber, formatExpiryDate } from './card-formatting'
14
- import type { PaymentMethod } from '@thorprovider/types'
14
+ import type { Money, PaymentMethod } from '@thorprovider/types'
15
15
  import type { CheckoutFieldErrors } from './checkout-form-types'
16
16
 
17
17
  export interface CheckoutForm1FormData {
@@ -44,17 +44,37 @@ export type CheckoutForm1FieldDescriptions = Partial<
44
44
  export interface CheckoutForm1OrderSummaryItem {
45
45
  id: string | number
46
46
  name: string
47
- variant: string
48
- price: number
47
+ variant?: string
49
48
  quantity: number
50
- image: string
49
+ /** Canonical backend line total; preferred over `price * quantity`. */
50
+ lineTotal?: Money
51
+ /** @deprecated legacy numeric price, used only when `lineTotal` is absent. */
52
+ price?: number
53
+ /** Optional; an absent image is omitted rather than serialized as `""`. */
54
+ image?: string
51
55
  }
52
56
 
57
+ export interface CheckoutForm1Totals {
58
+ subtotal: Money
59
+ discount?: Money
60
+ tax?: Money
61
+ shipping?: Money
62
+ total: Money
63
+ }
64
+
65
+ export type CheckoutForm1TotalsState = 'idle' | 'pending' | 'error'
66
+
53
67
  export interface CheckoutForm1OrderSummary {
54
68
  items: CheckoutForm1OrderSummaryItem[]
55
- shipping: number
56
- tax: number
69
+ /** Backend-authoritative totals; rendered verbatim when present. */
70
+ totals?: CheckoutForm1Totals
71
+ /** @deprecated legacy numeric amounts, used only when `totals` is absent. */
72
+ shipping?: number
73
+ /** @deprecated */
74
+ tax?: number
75
+ /** @deprecated */
57
76
  discount?: number
77
+ /** @deprecated */
58
78
  promoDiscount?: number
59
79
  }
60
80
 
@@ -63,6 +83,15 @@ export interface CheckoutForm1CountryOption {
63
83
  label: string
64
84
  }
65
85
 
86
+ export interface CheckoutForm1ShippingOption {
87
+ /** Backend shipping method id — the controlled selection identity. */
88
+ id: string
89
+ name: string
90
+ price?: Money
91
+ description?: string
92
+ estimatedDays?: string
93
+ }
94
+
66
95
  export type CheckoutForm1ChangeHandler = (
67
96
  field: keyof CheckoutForm1FormData,
68
97
  value: string | boolean
@@ -78,11 +107,35 @@ export interface CheckoutForm1Props {
78
107
  title?: string
79
108
  description?: string
80
109
  orderSummary?: CheckoutForm1OrderSummary
110
+ /** Controlled state of the totals fetch/mutation. */
111
+ totalsState?: CheckoutForm1TotalsState
112
+ totalsErrorLabel?: string
113
+ retryLabel?: string
114
+ onRetryTotals?: () => void
81
115
  countries?: CheckoutForm1CountryOption[]
116
+ /** Eligible shipping options for the cart's region/address. */
117
+ shippingOptions?: CheckoutForm1ShippingOption[]
118
+ /** Controlled selection of a shipping option id. */
119
+ selectedShippingOptionId?: string
120
+ onSelectShippingOption?: (id: string) => void
82
121
  paymentMethods?: PaymentMethod[]
83
122
  noPaymentMethodsLabel?: string
123
+ /** Override money formatting (locale, currency). Defaults to `Money.currencyCode`. */
124
+ formatMoney?: (money: Money) => string
125
+ /**
126
+ * Raw card fields are legacy-only and never collected in the launch flow.
127
+ * Defaults to `false`: the curated surface renders no card inputs.
128
+ */
129
+ collectCardFields?: boolean
130
+ onApplyPromo?: (code: string) => void
131
+ promoPending?: boolean
132
+ promoApplyLabel?: string
133
+ promoPlaceholder?: string
134
+ /** Optional trust copy; rendered only when supplied (never fabricated). */
135
+ trustNotes?: string[]
84
136
  backLabel?: string
85
137
  completeOrderLabel?: string
138
+ processingLabel?: string
86
139
  continueLabel?: string
87
140
  values: CheckoutForm1FormData
88
141
  onChange: CheckoutForm1ChangeHandler
@@ -94,17 +147,35 @@ export interface CheckoutForm1Props {
94
147
  submitting?: boolean
95
148
  /** Order-level error surfaced above the footer actions. */
96
149
  submitError?: string
150
+ /** Optional recovery action for a failed submission. */
151
+ onRetrySubmit?: () => void
152
+ submitErrorActionLabel?: string
97
153
  }
98
154
 
99
155
  export default function CheckoutForm1({
100
156
  title,
101
157
  description,
102
158
  orderSummary,
159
+ totalsState = 'idle',
160
+ totalsErrorLabel,
161
+ retryLabel,
162
+ onRetryTotals,
103
163
  countries,
164
+ shippingOptions,
165
+ selectedShippingOptionId,
166
+ onSelectShippingOption,
104
167
  paymentMethods,
105
168
  noPaymentMethodsLabel,
169
+ formatMoney,
170
+ collectCardFields = false,
171
+ onApplyPromo,
172
+ promoPending = false,
173
+ promoApplyLabel,
174
+ promoPlaceholder,
175
+ trustNotes,
106
176
  backLabel = 'Atrás',
107
177
  completeOrderLabel = 'Completar pedido',
178
+ processingLabel = 'Procesando pedido…',
108
179
  continueLabel = 'Continuar',
109
180
  values,
110
181
  onChange,
@@ -114,6 +185,8 @@ export default function CheckoutForm1({
114
185
  onSubmit,
115
186
  submitting = false,
116
187
  submitError,
188
+ onRetrySubmit,
189
+ submitErrorActionLabel = 'Reintentar',
117
190
  }: CheckoutForm1Props) {
118
191
  const [step, setStep] = useState(1)
119
192
 
@@ -175,6 +248,10 @@ export default function CheckoutForm1({
175
248
  formData={values}
176
249
  onChange={onChange}
177
250
  countries={countries}
251
+ shippingOptions={shippingOptions}
252
+ selectedShippingOptionId={selectedShippingOptionId}
253
+ onSelectShippingOption={onSelectShippingOption}
254
+ formatMoney={formatMoney}
178
255
  fieldErrors={fieldErrors}
179
256
  fieldDescriptions={fieldDescriptions}
180
257
  />
@@ -187,6 +264,7 @@ export default function CheckoutForm1({
187
264
  onExpiryChange={handleExpiryChange}
188
265
  paymentMethods={paymentMethods}
189
266
  noPaymentMethodsLabel={noPaymentMethodsLabel}
267
+ collectCardFields={collectCardFields}
190
268
  fieldErrors={fieldErrors}
191
269
  fieldDescriptions={fieldDescriptions}
192
270
  />
@@ -195,9 +273,20 @@ export default function CheckoutForm1({
195
273
  {submitError && (
196
274
  <div
197
275
  role="alert"
198
- className="mt-6 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
276
+ className="mt-6 flex flex-wrap items-center justify-between gap-3 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
199
277
  >
200
- {submitError}
278
+ <span>{submitError}</span>
279
+ {onRetrySubmit ? (
280
+ <Button
281
+ type="button"
282
+ variant="outline"
283
+ size="sm"
284
+ onClick={onRetrySubmit}
285
+ disabled={submitting}
286
+ >
287
+ {submitErrorActionLabel}
288
+ </Button>
289
+ ) : null}
201
290
  </div>
202
291
  )}
203
292
 
@@ -213,7 +302,7 @@ export default function CheckoutForm1({
213
302
  {isLastStep ? (
214
303
  <Button type="button" onClick={handleSubmit} disabled={submitting}>
215
304
  <Lock className="size-4" />
216
- {submitting ? 'Procesando pedido…' : completeOrderLabel}
305
+ {submitting ? processingLabel : completeOrderLabel}
217
306
  </Button>
218
307
  ) : (
219
308
  <Button type="button" onClick={handleContinue} disabled={submitting}>
@@ -226,10 +315,24 @@ export default function CheckoutForm1({
226
315
 
227
316
  {orderSummary && (
228
317
  <div className="lg:col-span-1">
229
- <CheckoutForm1OrderSummary orderSummary={orderSummary} />
318
+ <CheckoutForm1OrderSummary
319
+ orderSummary={orderSummary}
320
+ totalsState={totalsState}
321
+ totalsErrorLabel={totalsErrorLabel}
322
+ retryLabel={retryLabel}
323
+ onRetryTotals={onRetryTotals}
324
+ promoCode={values.promoCode}
325
+ onPromoCodeChange={(value) => onChange('promoCode', value)}
326
+ onApplyPromo={onApplyPromo}
327
+ promoPending={promoPending}
328
+ promoApplyLabel={promoApplyLabel}
329
+ promoPlaceholder={promoPlaceholder}
330
+ trustNotes={trustNotes}
331
+ formatMoney={formatMoney}
332
+ />
230
333
  </div>
231
334
  )}
232
335
  </div>
233
336
  </div>
234
337
  )
235
- }
338
+ }
@@ -102,8 +102,6 @@ describe('MobileCategoryDrawer', () => {
102
102
  expect(html).toContain('Ofertas')
103
103
  expect(html).toContain('Novedades')
104
104
  expect(html).toContain('href="/colecciones/ofertas"')
105
- expect(html).toContain('href="/colecciones"')
106
- expect(html).toContain('Ver todas')
107
105
  expect(html).toContain('grid grid-cols-2')
108
106
  })
109
107
 
@@ -149,4 +147,46 @@ describe('MobileCategoryDrawer', () => {
149
147
  }),
150
148
  ).toThrow()
151
149
  })
150
+ })
151
+
152
+ describe('MobileCategoryDrawer — view-all link requires a served href', () => {
153
+ const base = { categories, collections, open: true, onClose: () => {} }
154
+
155
+ it('omits the link and never emits an empty href when viewAllHref is omitted', () => {
156
+ const html = renderToStaticMarkup(<MobileCategoryDrawer {...base} />)
157
+ expect(html).not.toContain('Ver todas')
158
+ expect(html).not.toContain('href=""')
159
+ })
160
+
161
+ it('omits the link when viewAllHref is an empty string', () => {
162
+ const html = renderToStaticMarkup(<MobileCategoryDrawer {...base} viewAllHref="" />)
163
+ expect(html).not.toContain('Ver todas')
164
+ expect(html).not.toContain('href=""')
165
+ })
166
+
167
+ it('omits the link when viewAllHref is whitespace only', () => {
168
+ const html = renderToStaticMarkup(<MobileCategoryDrawer {...base} viewAllHref=" " />)
169
+ expect(html).not.toContain('Ver todas')
170
+ expect(html).not.toContain('href=""')
171
+ })
172
+
173
+ it('renders the link when a served path is supplied', () => {
174
+ const html = renderToStaticMarkup(
175
+ <MobileCategoryDrawer {...base} viewAllHref="/tienda/todas" />,
176
+ )
177
+ expect(html).toContain('href="/tienda/todas"')
178
+ expect(html).toContain('Ver todas')
179
+ })
180
+
181
+ it('renders a custom label with the served path', () => {
182
+ const html = renderToStaticMarkup(
183
+ <MobileCategoryDrawer
184
+ {...base}
185
+ viewAllHref="/tienda/todas"
186
+ viewAllLabel="Explorar todo"
187
+ />,
188
+ )
189
+ expect(html).toContain('href="/tienda/todas"')
190
+ expect(html).toContain('Explorar todo')
191
+ })
152
192
  })
@@ -59,7 +59,7 @@ export function MobileCategoryDrawer({
59
59
  rootTitle = 'Categorías',
60
60
  collectionsTitle = 'Colecciones',
61
61
  viewAllLabel = 'Ver todas',
62
- viewAllHref = '/colecciones',
62
+ viewAllHref,
63
63
  closeAriaLabel = 'Cerrar menú',
64
64
  backAriaLabel = 'Volver',
65
65
  menuAriaLabel = 'Menú de categorías',
@@ -235,13 +235,15 @@ export function MobileCategoryDrawer({
235
235
  >
236
236
  {collectionsTitle}
237
237
  </h2>
238
- <a
239
- href={viewAllHref}
240
- onClick={handleClose}
241
- className="text-xs font-bold text-primary"
242
- >
243
- {viewAllLabel}
244
- </a>
238
+ {viewAllHref && viewAllHref.trim() ? (
239
+ <a
240
+ href={viewAllHref}
241
+ onClick={handleClose}
242
+ className="text-xs font-bold text-primary"
243
+ >
244
+ {viewAllLabel}
245
+ </a>
246
+ ) : null}
245
247
  </div>
246
248
  <div className="grid grid-cols-2 gap-3">
247
249
  {collections.map((collection) => (
@@ -0,0 +1,51 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+ import {
4
+ ProductCategory2,
5
+ type ProductCategory2Category,
6
+ } from './product-category-2'
7
+
8
+ const base: ProductCategory2Category = {
9
+ id: 'cat-1',
10
+ name: 'Herramientas',
11
+ description: 'Taladros y accesorios',
12
+ image: '/products/drill.png',
13
+ trending: false,
14
+ }
15
+
16
+ const render = (category: ProductCategory2Category) =>
17
+ renderToStaticMarkup(<ProductCategory2 categories={[category]} />)
18
+
19
+ // The count is the only text node that ends in "items" inside a span (the
20
+ // `items-center` utility lives in a class attribute and is never followed by
21
+ // `</span>`).
22
+ const hasItemCount = (html: string) => html.includes('items</span>')
23
+
24
+ describe('ProductCategory2 — optional itemCount', () => {
25
+ it('renders no count when itemCount is omitted (unknown projection)', () => {
26
+ const html = render(base)
27
+ expect(hasItemCount(html)).toBe(false)
28
+ // The tile itself still renders.
29
+ expect(html).toContain('Herramientas')
30
+ expect(html).toContain('Browse')
31
+ })
32
+
33
+ it('preserves a real zero count', () => {
34
+ const html = render({ ...base, itemCount: 0 })
35
+ expect(html).toContain('0 items</span>')
36
+ })
37
+
38
+ it('renders a positive count', () => {
39
+ const html = render({ ...base, itemCount: 7 })
40
+ expect(html).toContain('7 items</span>')
41
+ })
42
+
43
+ it('omits a negative count', () => {
44
+ expect(hasItemCount(render({ ...base, itemCount: -1 }))).toBe(false)
45
+ })
46
+
47
+ it('omits a non-finite count', () => {
48
+ expect(hasItemCount(render({ ...base, itemCount: Number.NaN }))).toBe(false)
49
+ expect(hasItemCount(render({ ...base, itemCount: Number.POSITIVE_INFINITY }))).toBe(false)
50
+ })
51
+ })