@thorprovider/blocks 0.1.3 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +155 -0
- package/README.md +228 -68
- package/docs/standards/data-contracts.md +13 -2
- package/package.json +26 -10
- package/scripts/fix-esm-extensions.mjs +40 -0
- package/src/blocks/application/login-forms/login-page.test.tsx +190 -0
- package/src/blocks/application/login-forms/login-page.tsx +240 -0
- package/src/blocks/application/password-recovery/forgot-password-page-1.tsx +6 -8
- package/src/blocks/application/signup-forms/signup-page-1.tsx +127 -129
- package/src/blocks/application/verification/verify-1.tsx +2 -2
- package/src/blocks/application/viewport-ownership.test.tsx +41 -0
- package/src/blocks/e-commerce/category-filters/category-listing-filter-fields.tsx +218 -0
- package/src/blocks/e-commerce/category-filters/{category-filter-2.test.tsx → category-listing.test.tsx} +287 -199
- package/src/blocks/e-commerce/category-filters/category-listing.tsx +934 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-contact-step.tsx +149 -0
- package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-country-field.tsx → checkout-country-field.tsx} +10 -8
- package/src/blocks/e-commerce/checkout-forms/checkout-order-summary.tsx +230 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-payment-step.tsx +100 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-progress.tsx +62 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-review-step.tsx +59 -0
- package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-shipping-step.tsx → checkout-shipping-step.tsx} +65 -41
- package/src/blocks/e-commerce/checkout-forms/checkout.test.tsx +478 -0
- package/src/blocks/e-commerce/checkout-forms/checkout.tsx +550 -0
- package/src/blocks/e-commerce/navigation/storefront-navbar-account-menu.test.tsx +56 -0
- package/src/blocks/{marketing/navbars/navbar-4-account-menu.tsx → e-commerce/navigation/storefront-navbar-account-menu.tsx} +3 -3
- package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.test.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.test.tsx} +22 -22
- package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.tsx} +3 -3
- package/src/blocks/{marketing/navbars/navbar-4-mobile-nav.tsx → e-commerce/navigation/storefront-navbar-mobile-nav.tsx} +4 -12
- package/src/blocks/{marketing/navbars/navbar-4-search-bar.tsx → e-commerce/navigation/storefront-navbar-search-bar.tsx} +3 -3
- package/src/blocks/{marketing/navbars/navbar-4.test.tsx → e-commerce/navigation/storefront-navbar.test.tsx} +67 -36
- package/src/blocks/{marketing/navbars/navbar-4.tsx → e-commerce/navigation/storefront-navbar.tsx} +43 -25
- package/src/blocks/e-commerce/product-categories/category-card-grid.test.tsx +122 -0
- package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
- package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
- package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
- package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
- package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
- package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
- package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
- package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
- package/src/blocks/e-commerce/product-list/product-card.tsx +447 -0
- package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
- package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
- package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
- package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
- package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
- package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
- package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
- package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
- package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
- package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
- package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +119 -85
- package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
- package/src/blocks/marketing/features/feature.test.tsx +153 -0
- package/src/blocks/marketing/features/feature.tsx +195 -0
- package/src/blocks/marketing/footers/footer-section-accessibility.test.tsx +118 -0
- package/src/blocks/marketing/footers/footer-section-actions.test.tsx +131 -0
- package/src/blocks/marketing/footers/footer-section-data.test.tsx +127 -0
- package/src/blocks/marketing/footers/footer-section-native-links.test.tsx +100 -0
- package/src/blocks/marketing/footers/footer-section-public-api.test.tsx +11 -0
- package/src/blocks/marketing/footers/footer-section.test.tsx +46 -0
- package/src/blocks/marketing/footers/footer-section.tsx +532 -0
- package/src/blocks/marketing/navbars/navbar-public-api.test.tsx +57 -0
- package/src/blocks/marketing/navbars/navbar.mega.test.tsx +149 -0
- package/src/blocks/marketing/navbars/navbar.test.tsx +155 -0
- package/src/blocks/marketing/navbars/navbar.tsx +735 -0
- package/src/blocks/marketing/testimonials/testimonial-section.test.tsx +171 -0
- package/src/blocks/marketing/testimonials/testimonial-section.tsx +237 -0
- package/src/index.ts +156 -128
- package/src/lib/contracts-curated.test.ts +47 -29
- package/src/lib/contracts.test.ts +198 -2
- package/src/lib/contracts.ts +410 -16
- package/src/ui/button-group.tsx +1 -1
- package/src/ui/carousel.tsx +1 -1
- package/src/ui/dialog.tsx +1 -1
- package/src/ui/field.tsx +2 -2
- package/src/ui/input-group.tsx +3 -3
- package/src/ui/pagination.tsx +1 -1
- package/src/ui/sidebar.tsx +7 -7
- package/src/ui/toggle-group.tsx +1 -1
- package/tsconfig.build.json +12 -0
- package/tsup.config.ts +11 -7
- package/.turbo/turbo-build.log +0 -24
- package/.turbo/turbo-test.log +0 -47
- package/.turbo/turbo-type-check.log +0 -0
- package/dist/.tsbuildinfo +0 -1
- package/dist/contracts.d.mts +0 -1732
- package/dist/contracts.d.ts +0 -1732
- package/dist/contracts.js +0 -423
- package/dist/contracts.js.map +0 -1
- package/dist/contracts.mjs +0 -365
- package/dist/contracts.mjs.map +0 -1
- package/dist/index.d.mts +0 -3068
- package/dist/index.d.ts +0 -3068
- package/dist/index.js +0 -17834
- package/dist/index.js.map +0 -1
- package/dist/index.mjs +0 -17740
- package/dist/index.mjs.map +0 -1
- package/src/blocks/application/login-forms/login-page-1.tsx +0 -246
- package/src/blocks/application/login-forms/login-page-2.tsx +0 -207
- package/src/blocks/e-commerce/category-filters/category-filter-1.tsx +0 -260
- package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +0 -222
- package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +0 -921
- package/src/blocks/e-commerce/category-filters/category-filter-4-chip-rail.tsx +0 -57
- package/src/blocks/e-commerce/category-filters/category-filter-4-drawer.tsx +0 -124
- package/src/blocks/e-commerce/category-filters/category-filter-4-fields.tsx +0 -170
- package/src/blocks/e-commerce/category-filters/category-filter-4-sidebar.tsx +0 -52
- package/src/blocks/e-commerce/category-filters/category-filter-4-sort-sheet.tsx +0 -57
- package/src/blocks/e-commerce/category-filters/category-filter-4-state.ts +0 -68
- package/src/blocks/e-commerce/category-filters/category-filter-4.tsx +0 -298
- package/src/blocks/e-commerce/checkout-forms/card-formatting.test.ts +0 -47
- package/src/blocks/e-commerce/checkout-forms/card-formatting.ts +0 -9
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-contact-step.tsx +0 -96
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +0 -252
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +0 -188
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-progress.tsx +0 -36
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +0 -216
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +0 -338
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2-order-summary.tsx +0 -130
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2-payment-card.tsx +0 -175
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2-shipping-card.tsx +0 -190
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2.tsx +0 -312
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-information-step.tsx +0 -91
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-order-summary.tsx +0 -110
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-payment-step.tsx +0 -137
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-progress.tsx +0 -68
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-review-step.tsx +0 -79
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-shipping-step.tsx +0 -172
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3.tsx +0 -392
- package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
- package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
- package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
- package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
- package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
- package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
- package/src/blocks/e-commerce/product-list/product-card-1.test.tsx +0 -88
- package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -192
- package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
- package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
- package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
- package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
- package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
- package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
- package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
- package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
- package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
- package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
- package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -229
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -72
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
- package/src/blocks/marketing/features/feature-1.tsx +0 -97
- package/src/blocks/marketing/features/feature-2.tsx +0 -82
- package/src/blocks/marketing/footers/footer-section-1.test.tsx +0 -117
- package/src/blocks/marketing/footers/footer-section-1.tsx +0 -178
- package/src/blocks/marketing/footers/footer-section-3.tsx +0 -192
- package/src/blocks/marketing/footers/footer-section-4.tsx +0 -355
- package/src/blocks/marketing/navbars/navbar-1.tsx +0 -267
- package/src/blocks/marketing/navbars/navbar-2.tsx +0 -351
- package/src/blocks/marketing/navbars/navbar-3.tsx +0 -303
- package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +0 -38
- package/src/blocks/marketing/navbars/navbar-4-like-button.tsx +0 -177
- package/src/blocks/marketing/navbars/navbar-4-product-grid.tsx +0 -219
- package/src/blocks/marketing/testimonials/testimonial-1.tsx +0 -141
- package/src/blocks/marketing/testimonials/testimonial-2.tsx +0 -108
|
@@ -1,182 +0,0 @@
|
|
|
1
|
-
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
-
import { describe, expect, it } from 'vitest'
|
|
3
|
-
import ProductOverview1, {
|
|
4
|
-
galleryImages,
|
|
5
|
-
resolveAddToCartPayload,
|
|
6
|
-
variantLabelOf,
|
|
7
|
-
type ProductDetailProduct,
|
|
8
|
-
} from './product-overview-1'
|
|
9
|
-
|
|
10
|
-
const product: ProductDetailProduct = {
|
|
11
|
-
id: 'p1',
|
|
12
|
-
handle: 'taladro',
|
|
13
|
-
title: 'Taladro inalámbrico',
|
|
14
|
-
description: 'Un taladro',
|
|
15
|
-
vendor: 'Acme',
|
|
16
|
-
options: [{ id: 'o1', name: 'Color', values: ['Rojo', 'Azul'] }],
|
|
17
|
-
variants: [
|
|
18
|
-
{
|
|
19
|
-
id: 'v-red',
|
|
20
|
-
title: 'Rojo',
|
|
21
|
-
availableForSale: true,
|
|
22
|
-
selectedOptions: [{ name: 'Color', value: 'Rojo' }],
|
|
23
|
-
price: { amount: '10.00', currencyCode: 'USD' },
|
|
24
|
-
},
|
|
25
|
-
{
|
|
26
|
-
id: 'v-blue',
|
|
27
|
-
title: 'Azul',
|
|
28
|
-
availableForSale: true,
|
|
29
|
-
selectedOptions: [{ name: 'Color', value: 'Azul' }],
|
|
30
|
-
price: { amount: '25.50', currencyCode: 'USD' },
|
|
31
|
-
},
|
|
32
|
-
{
|
|
33
|
-
id: 'v-sold',
|
|
34
|
-
title: 'Verde',
|
|
35
|
-
availableForSale: false,
|
|
36
|
-
selectedOptions: [{ name: 'Color', value: 'Verde' }],
|
|
37
|
-
price: { amount: '9.00', currencyCode: 'USD' },
|
|
38
|
-
},
|
|
39
|
-
{
|
|
40
|
-
id: 'v-noprice',
|
|
41
|
-
title: 'Gris',
|
|
42
|
-
availableForSale: true,
|
|
43
|
-
selectedOptions: [{ name: 'Color', value: 'Gris' }],
|
|
44
|
-
price: null,
|
|
45
|
-
},
|
|
46
|
-
{
|
|
47
|
-
id: 'v-free',
|
|
48
|
-
title: 'Blanco',
|
|
49
|
-
availableForSale: true,
|
|
50
|
-
selectedOptions: [{ name: 'Color', value: 'Blanco' }],
|
|
51
|
-
price: { amount: '0', currencyCode: 'USD' },
|
|
52
|
-
},
|
|
53
|
-
],
|
|
54
|
-
images: [],
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const formatPrice = (money: { amount: string; currencyCode: string }) =>
|
|
58
|
-
`${money.currencyCode} ${money.amount}`
|
|
59
|
-
|
|
60
|
-
describe('ProductOverview1 — explicit, controlled variant selection', () => {
|
|
61
|
-
it('does not default to the first variant and prompts for an explicit selection', () => {
|
|
62
|
-
const html = renderToStaticMarkup(
|
|
63
|
-
<ProductOverview1 product={product} formatPrice={formatPrice} onAddToCart={() => {}} />,
|
|
64
|
-
)
|
|
65
|
-
expect(html).toContain('Selecciona una variante')
|
|
66
|
-
expect(html).not.toContain('USD 10.00')
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
it('shows the selected non-default variant at its current price', () => {
|
|
70
|
-
const html = renderToStaticMarkup(
|
|
71
|
-
<ProductOverview1
|
|
72
|
-
product={product}
|
|
73
|
-
selectedVariantId='v-blue'
|
|
74
|
-
formatPrice={formatPrice}
|
|
75
|
-
onAddToCart={() => {}}
|
|
76
|
-
/>,
|
|
77
|
-
)
|
|
78
|
-
expect(html).toContain('USD 25.50')
|
|
79
|
-
expect(html).not.toContain('USD 10.00')
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
it('reports a missing price as unavailable instead of zero', () => {
|
|
83
|
-
const html = renderToStaticMarkup(
|
|
84
|
-
<ProductOverview1 product={product} selectedVariantId='v-noprice' formatPrice={formatPrice} />,
|
|
85
|
-
)
|
|
86
|
-
expect(html).toContain('Precio no disponible')
|
|
87
|
-
expect(html).not.toContain('USD 0')
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
it('preserves a backend-resolved valid zero', () => {
|
|
91
|
-
const html = renderToStaticMarkup(
|
|
92
|
-
<ProductOverview1 product={product} selectedVariantId='v-free' formatPrice={formatPrice} />,
|
|
93
|
-
)
|
|
94
|
-
expect(html).toContain('USD 0')
|
|
95
|
-
expect(html).not.toContain('Precio no disponible')
|
|
96
|
-
})
|
|
97
|
-
|
|
98
|
-
it('enables purchase only for an available, priced, explicitly selected variant', () => {
|
|
99
|
-
const onlyAvailable: ProductDetailProduct = {
|
|
100
|
-
...product,
|
|
101
|
-
variants: [product.variants[0]],
|
|
102
|
-
}
|
|
103
|
-
const enabled = renderToStaticMarkup(
|
|
104
|
-
<ProductOverview1
|
|
105
|
-
product={onlyAvailable}
|
|
106
|
-
selectedVariantId='v-red'
|
|
107
|
-
formatPrice={formatPrice}
|
|
108
|
-
onAddToCart={() => {}}
|
|
109
|
-
/>,
|
|
110
|
-
)
|
|
111
|
-
expect(enabled).not.toContain('disabled=""')
|
|
112
|
-
|
|
113
|
-
const noSelection = renderToStaticMarkup(
|
|
114
|
-
<ProductOverview1 product={onlyAvailable} formatPrice={formatPrice} onAddToCart={() => {}} />,
|
|
115
|
-
)
|
|
116
|
-
expect(noSelection).toContain('disabled=""')
|
|
117
|
-
})
|
|
118
|
-
})
|
|
119
|
-
|
|
120
|
-
describe('ProductOverview1 — truthful optional data', () => {
|
|
121
|
-
it('renders no rating, delivery estimate, or shipping when the backend supplies none', () => {
|
|
122
|
-
const html = renderToStaticMarkup(<ProductOverview1 product={product} formatPrice={formatPrice} />)
|
|
123
|
-
expect(html).not.toContain('Reseñas')
|
|
124
|
-
expect(html).not.toContain('Entrega estimada')
|
|
125
|
-
expect(html).not.toContain('Envío')
|
|
126
|
-
})
|
|
127
|
-
|
|
128
|
-
it('renders rating, delivery estimate, and shipping only when supplied', () => {
|
|
129
|
-
const html = renderToStaticMarkup(
|
|
130
|
-
<ProductOverview1
|
|
131
|
-
product={product}
|
|
132
|
-
formatPrice={formatPrice}
|
|
133
|
-
rating={{ value: 4, count: 12 }}
|
|
134
|
-
deliveryEstimate='3-5 días'
|
|
135
|
-
shipping={{ amount: '4.99', currencyCode: 'USD' }}
|
|
136
|
-
/>,
|
|
137
|
-
)
|
|
138
|
-
expect(html).toContain('Reseñas')
|
|
139
|
-
expect(html).toContain('3-5 días')
|
|
140
|
-
expect(html).toContain('USD 4.99')
|
|
141
|
-
})
|
|
142
|
-
|
|
143
|
-
it('never emits an empty image src when the product has no images', () => {
|
|
144
|
-
const html = renderToStaticMarkup(<ProductOverview1 product={product} formatPrice={formatPrice} />)
|
|
145
|
-
expect(html).not.toMatch(/src=""/)
|
|
146
|
-
})
|
|
147
|
-
})
|
|
148
|
-
|
|
149
|
-
describe('resolveAddToCartPayload', () => {
|
|
150
|
-
const variant = product.variants[1]
|
|
151
|
-
|
|
152
|
-
it('returns null when no variant is selected (no first-variant shortcut)', () => {
|
|
153
|
-
expect(resolveAddToCartPayload(null, 2)).toBeNull()
|
|
154
|
-
})
|
|
155
|
-
|
|
156
|
-
it('uses the selected backend variant id and defaults the quantity to 1', () => {
|
|
157
|
-
expect(resolveAddToCartPayload(variant, undefined)).toEqual({ variantId: 'v-blue', quantity: 1 })
|
|
158
|
-
})
|
|
159
|
-
|
|
160
|
-
it('carries the requested quantity', () => {
|
|
161
|
-
expect(resolveAddToCartPayload(variant, 3)).toEqual({ variantId: 'v-blue', quantity: 3 })
|
|
162
|
-
})
|
|
163
|
-
})
|
|
164
|
-
|
|
165
|
-
describe('variant helpers', () => {
|
|
166
|
-
it('prefers the variant title', () => {
|
|
167
|
-
expect(variantLabelOf(product.variants[0])).toBe('Rojo')
|
|
168
|
-
})
|
|
169
|
-
|
|
170
|
-
it('falls back to option values when a variant has no title', () => {
|
|
171
|
-
expect(variantLabelOf({ ...product.variants[0], title: '' })).toBe('Rojo')
|
|
172
|
-
})
|
|
173
|
-
|
|
174
|
-
it('uses the product images when present and never a fabricated URL', () => {
|
|
175
|
-
const withImages: ProductDetailProduct = {
|
|
176
|
-
...product,
|
|
177
|
-
images: [{ url: 'https://cdn.example/p1.jpg', altText: 'p1' }],
|
|
178
|
-
}
|
|
179
|
-
expect(galleryImages(withImages, 'FALLBACK')).toEqual(['https://cdn.example/p1.jpg'])
|
|
180
|
-
expect(galleryImages(product, 'FALLBACK')).toEqual(['FALLBACK'])
|
|
181
|
-
})
|
|
182
|
-
})
|
|
@@ -1,370 +0,0 @@
|
|
|
1
|
-
'use client'
|
|
2
|
-
|
|
3
|
-
import { useState } from 'react'
|
|
4
|
-
import { Heart, Minus, Plus, Star } from 'lucide-react'
|
|
5
|
-
import type { Image, Money, ProductOption, SelectedOption } from '@thorprovider/types'
|
|
6
|
-
import { Button } from '../../../ui/button'
|
|
7
|
-
import { cn } from '../../../lib/utils'
|
|
8
|
-
import { PRODUCT_IMAGE_FALLBACK } from '../../../lib/contracts'
|
|
9
|
-
import { formatMoney, isAvailableMoney } from '../../../lib/money'
|
|
10
|
-
import { ReactionToggle } from '../../shared/reaction-toggle'
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Canonical product-detail projection.
|
|
14
|
-
*
|
|
15
|
-
* The block consumes the canonical `@thorprovider/types` shapes (`Money`,
|
|
16
|
-
* `ProductOption`, `SelectedOption`, `Image`) so adapter output maps without a
|
|
17
|
-
* second, drifting product type. The only deviation is `price`: the canon marks
|
|
18
|
-
* it required, but a missing/unresolved backend price is a real, distinct state
|
|
19
|
-
* that must disable purchase — so the projection allows `price` to be absent
|
|
20
|
-
* and the block treats absence as "unavailable", never as zero.
|
|
21
|
-
*/
|
|
22
|
-
export interface ProductDetailVariant {
|
|
23
|
-
id: string
|
|
24
|
-
title: string
|
|
25
|
-
sku?: string
|
|
26
|
-
availableForSale: boolean
|
|
27
|
-
selectedOptions: SelectedOption[]
|
|
28
|
-
/** Canonical Money; omit/null when the backend has no resolved price. */
|
|
29
|
-
price?: Money | null
|
|
30
|
-
compareAtPrice?: Money
|
|
31
|
-
image?: Image
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export interface ProductDetailProduct {
|
|
35
|
-
id: string
|
|
36
|
-
handle?: string
|
|
37
|
-
title: string
|
|
38
|
-
description?: string
|
|
39
|
-
vendor?: string
|
|
40
|
-
options: ProductOption[]
|
|
41
|
-
variants: ProductDetailVariant[]
|
|
42
|
-
images: Image[]
|
|
43
|
-
featuredImage?: Image
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export interface ProductOverview1Rating {
|
|
47
|
-
value: number
|
|
48
|
-
count: number
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export interface ProductOverview1Props {
|
|
52
|
-
product: ProductDetailProduct
|
|
53
|
-
/** Controlled selection: the backend variant id, or null/undefined when nothing is explicitly selected. */
|
|
54
|
-
selectedVariantId?: string | null
|
|
55
|
-
onSelectVariant?: (variantId: string) => void
|
|
56
|
-
/** Controlled quantity for the add-to-cart action; defaults to 1 when omitted. */
|
|
57
|
-
quantity?: number
|
|
58
|
-
onQuantityChange?: (quantity: number) => void
|
|
59
|
-
/** Add-to-cart identity is the selected backend variant id, with the requested quantity. */
|
|
60
|
-
onAddToCart?: (variantId: string, quantity: number) => void
|
|
61
|
-
isWishlisted?: boolean
|
|
62
|
-
onToggleWishlist?: (variantId: string, isWishlisted: boolean) => void
|
|
63
|
-
/** Rendered only when the backend supplies it. */
|
|
64
|
-
rating?: ProductOverview1Rating
|
|
65
|
-
/** Rendered only when the backend supplies it. */
|
|
66
|
-
deliveryEstimate?: string
|
|
67
|
-
/** Rendered only when the backend supplies it. */
|
|
68
|
-
shipping?: Money
|
|
69
|
-
formatPrice?: (money: Money) => string
|
|
70
|
-
sizesLabel?: string
|
|
71
|
-
variantLabel?: string
|
|
72
|
-
reviewsLabel?: string
|
|
73
|
-
addToCartLabel?: string
|
|
74
|
-
outOfStockLabel?: string
|
|
75
|
-
unavailablePriceLabel?: string
|
|
76
|
-
selectVariantLabel?: string
|
|
77
|
-
quantityLabel?: string
|
|
78
|
-
deliveryEstimateLabel?: string
|
|
79
|
-
shippingLabel?: string
|
|
80
|
-
removeFromWishlistLabel?: string
|
|
81
|
-
addToWishlistLabel?: string
|
|
82
|
-
imageFallback?: string
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function variantLabelOf(variant: ProductDetailVariant): string {
|
|
86
|
-
if (variant.title.trim() !== '') return variant.title
|
|
87
|
-
return variant.selectedOptions.map(option => option.value).join(' / ')
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* The add-to-cart payload contract: the explicitly selected backend variant id
|
|
92
|
-
* and a positive quantity (defaults to 1). Returns `null` when no variant has
|
|
93
|
-
* been selected — the block never substitutes the first variant.
|
|
94
|
-
*/
|
|
95
|
-
export function resolveAddToCartPayload(
|
|
96
|
-
variant: ProductDetailVariant | null,
|
|
97
|
-
quantity: number | undefined,
|
|
98
|
-
): { variantId: string; quantity: number } | null {
|
|
99
|
-
if (!variant) return null
|
|
100
|
-
return {
|
|
101
|
-
variantId: variant.id,
|
|
102
|
-
quantity: typeof quantity === 'number' && quantity > 0 ? quantity : 1,
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function galleryImages(product: ProductDetailProduct, fallback: string): string[] {
|
|
107
|
-
const images = product.images
|
|
108
|
-
.map(image => image.url)
|
|
109
|
-
.filter((url): url is string => typeof url === 'string' && url.trim() !== '')
|
|
110
|
-
if (images.length > 0) return images
|
|
111
|
-
const featured = product.featuredImage?.url
|
|
112
|
-
if (typeof featured === 'string' && featured.trim() !== '') return [featured]
|
|
113
|
-
return [fallback]
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export default function ProductOverview1({
|
|
117
|
-
product,
|
|
118
|
-
selectedVariantId,
|
|
119
|
-
onSelectVariant,
|
|
120
|
-
quantity,
|
|
121
|
-
onQuantityChange,
|
|
122
|
-
onAddToCart,
|
|
123
|
-
isWishlisted = false,
|
|
124
|
-
onToggleWishlist,
|
|
125
|
-
rating,
|
|
126
|
-
deliveryEstimate,
|
|
127
|
-
shipping,
|
|
128
|
-
formatPrice,
|
|
129
|
-
sizesLabel = 'Variantes',
|
|
130
|
-
variantLabel = 'Variante',
|
|
131
|
-
reviewsLabel = 'Reseñas',
|
|
132
|
-
addToCartLabel = 'Agregar al carrito',
|
|
133
|
-
outOfStockLabel = 'Agotado',
|
|
134
|
-
unavailablePriceLabel = 'Precio no disponible',
|
|
135
|
-
selectVariantLabel = 'Selecciona una variante',
|
|
136
|
-
quantityLabel = 'Cantidad',
|
|
137
|
-
deliveryEstimateLabel = 'Entrega estimada',
|
|
138
|
-
shippingLabel = 'Envío',
|
|
139
|
-
removeFromWishlistLabel = 'Quitar de favoritos',
|
|
140
|
-
addToWishlistLabel = 'Agregar a favoritos',
|
|
141
|
-
imageFallback = PRODUCT_IMAGE_FALLBACK,
|
|
142
|
-
}: ProductOverview1Props) {
|
|
143
|
-
const [selectedImage, setSelectedImage] = useState(0)
|
|
144
|
-
|
|
145
|
-
const selectedVariant =
|
|
146
|
-
selectedVariantId != null
|
|
147
|
-
? product.variants.find(variant => variant.id === selectedVariantId) ?? null
|
|
148
|
-
: null
|
|
149
|
-
|
|
150
|
-
const selectedPrice = selectedVariant?.price
|
|
151
|
-
const selectedCompareAt = selectedVariant?.compareAtPrice
|
|
152
|
-
const priceAvailable = isAvailableMoney(selectedPrice)
|
|
153
|
-
const compareAtAvailable = isAvailableMoney(selectedCompareAt)
|
|
154
|
-
const canPurchase = Boolean(selectedVariant?.availableForSale) && priceAvailable
|
|
155
|
-
const quantityValue = quantity ?? 1
|
|
156
|
-
const addPayload = resolveAddToCartPayload(selectedVariant, quantityValue)
|
|
157
|
-
|
|
158
|
-
const images = galleryImages(product, imageFallback)
|
|
159
|
-
const hasRealImages = images[0] !== imageFallback || product.featuredImage != null
|
|
160
|
-
const activeImage = images[Math.min(selectedImage, images.length - 1)]
|
|
161
|
-
|
|
162
|
-
const toggleWishlist = () => {
|
|
163
|
-
if (!selectedVariant || !onToggleWishlist) return
|
|
164
|
-
onToggleWishlist(selectedVariant.id, !isWishlisted)
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
return (
|
|
168
|
-
<div>
|
|
169
|
-
<section className='@container mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
|
|
170
|
-
<div className='grid grid-cols-1 gap-6 py-4 lg:grid-cols-2 lg:gap-8 lg:py-6 xl:grid-cols-3 xl:gap-12 xl:py-12'>
|
|
171
|
-
<div className='flex flex-col justify-between gap-6 lg:gap-8'>
|
|
172
|
-
<div className='flex flex-col gap-2 lg:gap-4'>
|
|
173
|
-
{product.vendor ? (
|
|
174
|
-
<span className='text-sm font-semibold tracking-wide uppercase'>{product.vendor}</span>
|
|
175
|
-
) : null}
|
|
176
|
-
<h2 className='text-xl font-bold tracking-tight text-balance lg:text-3xl'>{product.title}</h2>
|
|
177
|
-
{product.description ? (
|
|
178
|
-
<p className='text-muted-foreground text-balance'>{product.description}</p>
|
|
179
|
-
) : null}
|
|
180
|
-
|
|
181
|
-
{selectedVariant ? (
|
|
182
|
-
priceAvailable ? (
|
|
183
|
-
<p className='text-2xl font-bold tracking-tight'>
|
|
184
|
-
{formatMoney(selectedPrice, formatPrice)}
|
|
185
|
-
{compareAtAvailable ? (
|
|
186
|
-
<span className='text-muted-foreground ms-2 text-base font-normal line-through'>
|
|
187
|
-
{formatMoney(selectedCompareAt, formatPrice)}
|
|
188
|
-
</span>
|
|
189
|
-
) : null}
|
|
190
|
-
</p>
|
|
191
|
-
) : (
|
|
192
|
-
<p className='text-muted-foreground text-sm'>{unavailablePriceLabel}</p>
|
|
193
|
-
)
|
|
194
|
-
) : (
|
|
195
|
-
<p className='text-muted-foreground text-sm'>{selectVariantLabel}</p>
|
|
196
|
-
)}
|
|
197
|
-
</div>
|
|
198
|
-
|
|
199
|
-
{hasRealImages ? (
|
|
200
|
-
<div className='flex flex-wrap gap-4'>
|
|
201
|
-
{images.map((url, index) => (
|
|
202
|
-
<button
|
|
203
|
-
key={`${product.id}:image:${index}`}
|
|
204
|
-
type='button'
|
|
205
|
-
onClick={() => setSelectedImage(index)}
|
|
206
|
-
aria-label={`${product.title} ${index + 1}`}
|
|
207
|
-
aria-pressed={selectedImage === index}
|
|
208
|
-
className={cn(
|
|
209
|
-
'ring-offset-background size-16 cursor-pointer overflow-hidden rounded-sm ring-offset-2 transition-all lg:size-18',
|
|
210
|
-
selectedImage === index && 'ring-foreground ring-2',
|
|
211
|
-
)}
|
|
212
|
-
>
|
|
213
|
-
<img src={url || undefined} alt='' className='size-full object-cover' />
|
|
214
|
-
</button>
|
|
215
|
-
))}
|
|
216
|
-
</div>
|
|
217
|
-
) : null}
|
|
218
|
-
</div>
|
|
219
|
-
|
|
220
|
-
<div className='row-span-2 row-start-1 lg:col-start-2'>
|
|
221
|
-
<div className='bg-muted aspect-square w-full overflow-hidden rounded-lg'>
|
|
222
|
-
<img
|
|
223
|
-
src={activeImage || undefined}
|
|
224
|
-
alt={product.title}
|
|
225
|
-
className='h-90 w-full object-cover'
|
|
226
|
-
/>
|
|
227
|
-
</div>
|
|
228
|
-
</div>
|
|
229
|
-
|
|
230
|
-
<div className='flex flex-col gap-6 lg:gap-10'>
|
|
231
|
-
{product.variants.length > 0 ? (
|
|
232
|
-
<div className='flex flex-col gap-2'>
|
|
233
|
-
<h3 className='font-bold'>{sizesLabel}</h3>
|
|
234
|
-
<div className='flex flex-wrap gap-3'>
|
|
235
|
-
{product.variants.map(variant => {
|
|
236
|
-
const isSelected = variant.id === selectedVariantId
|
|
237
|
-
return (
|
|
238
|
-
<Button
|
|
239
|
-
key={variant.id}
|
|
240
|
-
variant={isSelected ? 'default' : 'outline'}
|
|
241
|
-
onClick={onSelectVariant ? () => onSelectVariant(variant.id) : undefined}
|
|
242
|
-
disabled={!variant.availableForSale}
|
|
243
|
-
aria-pressed={isSelected}
|
|
244
|
-
className='h-9 cursor-pointer rounded-full px-4'
|
|
245
|
-
>
|
|
246
|
-
{variantLabelOf(variant)}
|
|
247
|
-
{!variant.availableForSale ? (
|
|
248
|
-
<span className='text-muted-foreground ms-2 text-xs font-normal'>
|
|
249
|
-
{outOfStockLabel}
|
|
250
|
-
</span>
|
|
251
|
-
) : null}
|
|
252
|
-
</Button>
|
|
253
|
-
)
|
|
254
|
-
})}
|
|
255
|
-
</div>
|
|
256
|
-
{selectedVariant && selectedVariant.selectedOptions.length > 0 ? (
|
|
257
|
-
<p className='text-muted-foreground text-sm'>
|
|
258
|
-
{selectedVariant.selectedOptions
|
|
259
|
-
.map((option: SelectedOption) => `${option.name}: ${option.value}`)
|
|
260
|
-
.join(' · ')}
|
|
261
|
-
</p>
|
|
262
|
-
) : null}
|
|
263
|
-
</div>
|
|
264
|
-
) : null}
|
|
265
|
-
|
|
266
|
-
{rating ? (
|
|
267
|
-
<div className='flex flex-col gap-2'>
|
|
268
|
-
<h3 className='font-bold'>{reviewsLabel}</h3>
|
|
269
|
-
<div className='flex items-center gap-2'>
|
|
270
|
-
<div className='flex gap-1' aria-hidden='true'>
|
|
271
|
-
{[0, 1, 2, 3, 4].map(index => (
|
|
272
|
-
<Star
|
|
273
|
-
key={index}
|
|
274
|
-
className='text-foreground size-5'
|
|
275
|
-
fill={index < rating.value ? 'currentColor' : 'none'}
|
|
276
|
-
/>
|
|
277
|
-
))}
|
|
278
|
-
</div>
|
|
279
|
-
<span className='text-muted-foreground text-sm tabular-nums'>
|
|
280
|
-
{`${rating.value} (${rating.count})`}
|
|
281
|
-
</span>
|
|
282
|
-
</div>
|
|
283
|
-
</div>
|
|
284
|
-
) : null}
|
|
285
|
-
|
|
286
|
-
{onQuantityChange ? (
|
|
287
|
-
<div className='flex flex-col gap-2'>
|
|
288
|
-
<h3 className='font-bold'>{quantityLabel}</h3>
|
|
289
|
-
<div className='border-border flex w-fit items-center rounded-md border'>
|
|
290
|
-
<Button
|
|
291
|
-
type='button'
|
|
292
|
-
variant='ghost'
|
|
293
|
-
size='icon'
|
|
294
|
-
className='size-9 rounded-r-none'
|
|
295
|
-
aria-label={`${quantityLabel} -1`}
|
|
296
|
-
onClick={() => onQuantityChange(Math.max(1, quantityValue - 1))}
|
|
297
|
-
disabled={quantityValue <= 1}
|
|
298
|
-
>
|
|
299
|
-
<Minus />
|
|
300
|
-
</Button>
|
|
301
|
-
<span className='w-10 text-center text-sm tabular-nums'>{quantityValue}</span>
|
|
302
|
-
<Button
|
|
303
|
-
type='button'
|
|
304
|
-
variant='ghost'
|
|
305
|
-
size='icon'
|
|
306
|
-
className='size-9 rounded-l-none'
|
|
307
|
-
aria-label={`${quantityLabel} +1`}
|
|
308
|
-
onClick={() => onQuantityChange(quantityValue + 1)}
|
|
309
|
-
>
|
|
310
|
-
<Plus />
|
|
311
|
-
</Button>
|
|
312
|
-
</div>
|
|
313
|
-
</div>
|
|
314
|
-
) : null}
|
|
315
|
-
|
|
316
|
-
{deliveryEstimate || shipping ? (
|
|
317
|
-
<dl className='text-muted-foreground flex flex-col gap-1 text-sm'>
|
|
318
|
-
{deliveryEstimate ? (
|
|
319
|
-
<div className='flex gap-2'>
|
|
320
|
-
<dt>{deliveryEstimateLabel}</dt>
|
|
321
|
-
<dd className='text-foreground'>{deliveryEstimate}</dd>
|
|
322
|
-
</div>
|
|
323
|
-
) : null}
|
|
324
|
-
{isAvailableMoney(shipping) ? (
|
|
325
|
-
<div className='flex gap-2'>
|
|
326
|
-
<dt>{shippingLabel}</dt>
|
|
327
|
-
<dd className='text-foreground'>{formatMoney(shipping, formatPrice)}</dd>
|
|
328
|
-
</div>
|
|
329
|
-
) : null}
|
|
330
|
-
</dl>
|
|
331
|
-
) : null}
|
|
332
|
-
|
|
333
|
-
<div className='flex gap-4'>
|
|
334
|
-
<Button
|
|
335
|
-
className='h-10 flex-1 cursor-pointer rounded-full px-8'
|
|
336
|
-
size='lg'
|
|
337
|
-
disabled={!canPurchase}
|
|
338
|
-
onClick={
|
|
339
|
-
onAddToCart && addPayload
|
|
340
|
-
? () => onAddToCart(addPayload.variantId, addPayload.quantity)
|
|
341
|
-
: undefined
|
|
342
|
-
}
|
|
343
|
-
>
|
|
344
|
-
{addToCartLabel}
|
|
345
|
-
</Button>
|
|
346
|
-
<Button
|
|
347
|
-
variant='outline'
|
|
348
|
-
size='icon'
|
|
349
|
-
className='size-9 cursor-pointer rounded-full'
|
|
350
|
-
disabled={!selectedVariant}
|
|
351
|
-
onClick={onToggleWishlist ? toggleWishlist : undefined}
|
|
352
|
-
aria-pressed={isWishlisted}
|
|
353
|
-
aria-label={isWishlisted ? removeFromWishlistLabel : addToWishlistLabel}
|
|
354
|
-
>
|
|
355
|
-
<ReactionToggle
|
|
356
|
-
active={isWishlisted}
|
|
357
|
-
icon={Heart}
|
|
358
|
-
iconClassName='size-5'
|
|
359
|
-
activeColorClassName='text-primary'
|
|
360
|
-
/>
|
|
361
|
-
</Button>
|
|
362
|
-
</div>
|
|
363
|
-
</div>
|
|
364
|
-
</div>
|
|
365
|
-
</section>
|
|
366
|
-
</div>
|
|
367
|
-
)
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
export { variantLabelOf, galleryImages }
|