@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,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: "Block data contracts"
|
|
3
3
|
purpose: "Warn block authors and hosts about the two boundary data-hygiene rules that prevent empty-src and duplicate-key console errors"
|
|
4
|
-
lastReviewed: "2026-
|
|
4
|
+
lastReviewed: "2026-10-17"
|
|
5
5
|
type: standards
|
|
6
6
|
tags: [blocks, contracts, data-boundary, images, react-keys]
|
|
7
7
|
---
|
|
@@ -60,6 +60,16 @@ Even with correct boundaries, blocks should render
|
|
|
60
60
|
`src={value || undefined}` for every image prop. This is a cheap invariant
|
|
61
61
|
that makes the whole catalog immune to one misbehaving host.
|
|
62
62
|
|
|
63
|
+
## Canonical product card: evidence comes from the host
|
|
64
|
+
|
|
65
|
+
`ProductCard` renders ratings, badges, discount labels, and stock claims only
|
|
66
|
+
from host-supplied data (`rating`, `badge` copy, `compareAtPrice`); it never
|
|
67
|
+
invents stars, chips, scarcity copy, or crossed-out prices, and a compare-at
|
|
68
|
+
price renders only when it is valid and strictly higher. Boundaries must not
|
|
69
|
+
compensate by fabricating evidence: if the host lacks a rating, the card
|
|
70
|
+
renders none — do not serialize a default `5` or a stock-urgency string to
|
|
71
|
+
"fill" the UI.
|
|
72
|
+
|
|
63
73
|
## Review checklist (boundary code)
|
|
64
74
|
|
|
65
75
|
- [ ] No `?? ''` or `|| ''` on visual fields (image, avatar, icon) in mappers/transforms.
|
|
@@ -67,4 +77,5 @@ that makes the whole catalog immune to one misbehaving host.
|
|
|
67
77
|
- [ ] Optional fields emit `undefined`, never `""`.
|
|
68
78
|
- [ ] Arrays passed as React children have unique keys guaranteed by contract or deduped at the boundary.
|
|
69
79
|
- [ ] Concatenated arrays (categories + links) are deduped before render.
|
|
70
|
-
- [ ] Every `<img>`/`<AvatarImage>` in the block uses `src={expr || undefined}`.
|
|
80
|
+
- [ ] Every `<img>`/`<AvatarImage>` in the block uses `src={expr || undefined}`.
|
|
81
|
+
- [ ] No fabricated ratings, badges, stock claims, or crossed-out prices are serialized to fill absent evidence.
|
package/package.json
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thorprovider/blocks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Reusable, framework-agnostic React component blocks for Thor Commerce storefronts.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
|
+
"sideEffects": [
|
|
8
|
+
"**/*.css",
|
|
9
|
+
"./styles.css"
|
|
10
|
+
],
|
|
7
11
|
"exports": {
|
|
8
12
|
".": {
|
|
9
13
|
"types": "./dist/index.d.ts",
|
|
@@ -13,16 +17,23 @@
|
|
|
13
17
|
"default": "./dist/index.mjs"
|
|
14
18
|
},
|
|
15
19
|
"./contracts": {
|
|
16
|
-
"types": "./dist/contracts.d.ts",
|
|
17
|
-
"development": "./dist/contracts.mjs",
|
|
18
|
-
"import": "./dist/contracts.mjs",
|
|
19
|
-
"require": "./dist/contracts.js",
|
|
20
|
-
"default": "./dist/contracts.mjs"
|
|
20
|
+
"types": "./dist/lib/contracts.d.ts",
|
|
21
|
+
"development": "./dist/lib/contracts.mjs",
|
|
22
|
+
"import": "./dist/lib/contracts.mjs",
|
|
23
|
+
"require": "./dist/lib/contracts.js",
|
|
24
|
+
"default": "./dist/lib/contracts.mjs"
|
|
21
25
|
},
|
|
22
|
-
"./styles.css": "./styles.css"
|
|
26
|
+
"./styles.css": "./styles.css",
|
|
27
|
+
"./*": {
|
|
28
|
+
"types": "./dist/*.d.ts",
|
|
29
|
+
"development": "./dist/*.mjs",
|
|
30
|
+
"import": "./dist/*.mjs",
|
|
31
|
+
"require": "./dist/*.js",
|
|
32
|
+
"default": "./dist/*.mjs"
|
|
33
|
+
}
|
|
23
34
|
},
|
|
24
35
|
"scripts": {
|
|
25
|
-
"build": "tsup",
|
|
36
|
+
"build": "tsup && tsc -p tsconfig.build.json && node scripts/fix-esm-extensions.mjs",
|
|
26
37
|
"dev": "tsup --watch",
|
|
27
38
|
"type-check": "tsc --noEmit",
|
|
28
39
|
"test": "vitest run --config vitest.config.ts",
|
|
@@ -40,7 +51,7 @@
|
|
|
40
51
|
"license": "MIT",
|
|
41
52
|
"dependencies": {
|
|
42
53
|
"@base-ui/react": "^1.8.0",
|
|
43
|
-
"@thorprovider/types": "
|
|
54
|
+
"@thorprovider/types": "workspace:^",
|
|
44
55
|
"class-variance-authority": "^0.7.1",
|
|
45
56
|
"clsx": "^2.1.1",
|
|
46
57
|
"cn": "^0.4.0",
|
|
@@ -54,10 +65,15 @@
|
|
|
54
65
|
"zod": "^3.25.0"
|
|
55
66
|
},
|
|
56
67
|
"devDependencies": {
|
|
68
|
+
"@testing-library/dom": "^10",
|
|
69
|
+
"@testing-library/react": "^16",
|
|
70
|
+
"@testing-library/user-event": "^14",
|
|
71
|
+
"@types/jsdom": "^30",
|
|
57
72
|
"@types/react": "^19",
|
|
58
73
|
"@types/react-dom": "^19",
|
|
74
|
+
"jsdom": "^30.1.3",
|
|
59
75
|
"tsup": "^8.0.0",
|
|
60
76
|
"typescript": "^5.0.0",
|
|
61
77
|
"vitest": "^3.2.4"
|
|
62
78
|
}
|
|
63
|
-
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join, extname } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const distDir = fileURLToPath(new URL("../dist/", import.meta.url));
|
|
6
|
+
|
|
7
|
+
const RELATIVE = /^\.{1,2}\//;
|
|
8
|
+
const HAS_EXT = /\.(mjs|cjs|js|jsx|ts|tsx|css|json|svg|png|webp|jpg|jpeg|gif|woff2?)$/;
|
|
9
|
+
|
|
10
|
+
const SPECIFIER =
|
|
11
|
+
/(\bfrom\s*|\bimport\s*\(|\brequire\s*\(|\bimport\s+)(["'])(\.{1,2}\/[^"']*?)\2/g;
|
|
12
|
+
|
|
13
|
+
async function* walk(dir) {
|
|
14
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
15
|
+
const full = join(dir, entry.name);
|
|
16
|
+
if (entry.isDirectory()) yield* walk(full);
|
|
17
|
+
else yield full;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function fixFile(file) {
|
|
22
|
+
const ext = extname(file);
|
|
23
|
+
if (ext !== ".mjs" && ext !== ".js") return 0;
|
|
24
|
+
const target = ext === ".mjs" ? ".mjs" : ".js";
|
|
25
|
+
const source = await readFile(file, "utf8");
|
|
26
|
+
let changed = 0;
|
|
27
|
+
const out = source.replace(SPECIFIER, (match, prefix, quote, spec) => {
|
|
28
|
+
if (!RELATIVE.test(spec) || HAS_EXT.test(spec)) return match;
|
|
29
|
+
changed += 1;
|
|
30
|
+
return `${prefix}${quote}${spec}${target}${quote}`;
|
|
31
|
+
});
|
|
32
|
+
if (changed > 0) await writeFile(file, out);
|
|
33
|
+
return changed;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let total = 0;
|
|
37
|
+
for await (const file of walk(distDir)) {
|
|
38
|
+
total += await fixFile(file);
|
|
39
|
+
}
|
|
40
|
+
console.log(`fix-esm-extensions: rewrote ${total} relative specifier(s)`);
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { beforeAll, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { render, screen } from '@testing-library/react'
|
|
4
|
+
import userEvent from '@testing-library/user-event'
|
|
5
|
+
import { AtSign } from 'lucide-react'
|
|
6
|
+
import * as blockExports from '../../../index'
|
|
7
|
+
import { LoginPage, type LoginPageProps } from './login-page'
|
|
8
|
+
|
|
9
|
+
beforeAll(() => {
|
|
10
|
+
Object.assign(SVGElement.prototype, { getTotalLength: () => 15 })
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
describe('LoginPage', () => {
|
|
14
|
+
it('is the only exported login page API', () => {
|
|
15
|
+
expect(blockExports.LoginPage).toBe(LoginPage)
|
|
16
|
+
expect('LoginPage1' in blockExports).toBe(false)
|
|
17
|
+
expect('LoginPage2' in blockExports).toBe(false)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it.each(['card', 'social'] as const)('submits the same structured credentials for the %s presentation', async presentation => {
|
|
21
|
+
const user = userEvent.setup()
|
|
22
|
+
const onSubmit = vi.fn()
|
|
23
|
+
|
|
24
|
+
render(
|
|
25
|
+
<LoginPage
|
|
26
|
+
presentation={presentation}
|
|
27
|
+
emailLabel='Email'
|
|
28
|
+
passwordLabel='Password'
|
|
29
|
+
rememberMeLabel='Remember me'
|
|
30
|
+
signInLabel='Sign in'
|
|
31
|
+
onSubmit={onSubmit}
|
|
32
|
+
/>,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
await user.type(screen.getByLabelText('Email'), 'shopper@example.com')
|
|
36
|
+
await user.type(screen.getByLabelText('Password'), 'correct-horse')
|
|
37
|
+
await user.click(screen.getByRole('checkbox', { name: 'Remember me' }))
|
|
38
|
+
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
|
39
|
+
|
|
40
|
+
expect(onSubmit).toHaveBeenCalledWith({
|
|
41
|
+
email: 'shopper@example.com',
|
|
42
|
+
password: 'correct-horse',
|
|
43
|
+
remember: true,
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('rejects a missing presentation instead of silently choosing a layout', () => {
|
|
48
|
+
expect(() => render(<LoginPage {...({} as LoginPageProps)} />)).toThrow(/presentation/i)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('round-trips serializable provider data and reports the selected provider id', async () => {
|
|
52
|
+
const user = userEvent.setup()
|
|
53
|
+
const onProviderSelect = vi.fn()
|
|
54
|
+
const providers = JSON.parse(JSON.stringify([{ id: 'github', label: 'GitHub' }])) as LoginPageProps['providers']
|
|
55
|
+
|
|
56
|
+
render(
|
|
57
|
+
<LoginPage
|
|
58
|
+
presentation='social'
|
|
59
|
+
providers={providers}
|
|
60
|
+
providerIcons={{ github: AtSign }}
|
|
61
|
+
onProviderSelect={onProviderSelect}
|
|
62
|
+
/>,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
await user.click(screen.getByRole('button', { name: 'GitHub' }))
|
|
66
|
+
|
|
67
|
+
expect(onProviderSelect).toHaveBeenCalledWith('github')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('renders provider labels when an icon is not mapped', () => {
|
|
71
|
+
render(
|
|
72
|
+
<LoginPage
|
|
73
|
+
presentation='card'
|
|
74
|
+
providers={[{ id: 'custom', label: 'Custom provider' }]}
|
|
75
|
+
onProviderSelect={vi.fn()}
|
|
76
|
+
/>,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
expect(screen.getByRole('button', { name: 'Custom provider' }).textContent).toBe('Custom provider')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('omits the provider group when there are no providers', () => {
|
|
83
|
+
render(<LoginPage presentation='card' onProviderSelect={vi.fn()} />)
|
|
84
|
+
|
|
85
|
+
expect(screen.queryByRole('group', { name: 'O continúa con' })).toBeNull()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('rejects duplicate provider IDs so provider React keys remain unique', () => {
|
|
89
|
+
expect(() =>
|
|
90
|
+
render(
|
|
91
|
+
<LoginPage
|
|
92
|
+
presentation='card'
|
|
93
|
+
providers={[
|
|
94
|
+
{ id: 'duplicate', label: 'First' },
|
|
95
|
+
{ id: 'duplicate', label: 'Second' },
|
|
96
|
+
]}
|
|
97
|
+
onProviderSelect={vi.fn()}
|
|
98
|
+
/>,
|
|
99
|
+
),
|
|
100
|
+
).toThrow(/provider ids must be unique/i)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('omits optional actions and providers when their handlers are absent', () => {
|
|
104
|
+
render(
|
|
105
|
+
<LoginPage
|
|
106
|
+
presentation='card'
|
|
107
|
+
forgotPasswordLabel='Forgot password'
|
|
108
|
+
createAccountLabel='Create account'
|
|
109
|
+
providers={[{ id: 'github', label: 'GitHub' }]}
|
|
110
|
+
/>,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
expect(screen.queryByRole('button', { name: 'Forgot password' })).toBeNull()
|
|
114
|
+
expect(screen.queryByRole('button', { name: 'Create account' })).toBeNull()
|
|
115
|
+
expect(screen.queryByRole('group', { name: 'O continúa con' })).toBeNull()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('invokes optional action handlers', async () => {
|
|
119
|
+
const user = userEvent.setup()
|
|
120
|
+
const onForgotPassword = vi.fn()
|
|
121
|
+
const onCreateAccount = vi.fn()
|
|
122
|
+
|
|
123
|
+
render(
|
|
124
|
+
<LoginPage
|
|
125
|
+
presentation='card'
|
|
126
|
+
forgotPasswordLabel='Forgot password'
|
|
127
|
+
createAccountLabel='Create account'
|
|
128
|
+
onForgotPassword={onForgotPassword}
|
|
129
|
+
onCreateAccount={onCreateAccount}
|
|
130
|
+
/>,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
await user.click(screen.getByRole('button', { name: 'Forgot password' }))
|
|
134
|
+
await user.click(screen.getByRole('button', { name: 'Create account' }))
|
|
135
|
+
|
|
136
|
+
expect(onForgotPassword).toHaveBeenCalledOnce()
|
|
137
|
+
expect(onCreateAccount).toHaveBeenCalledOnce()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('updates the password toggle accessible name with its state', async () => {
|
|
141
|
+
const user = userEvent.setup()
|
|
142
|
+
|
|
143
|
+
render(
|
|
144
|
+
<LoginPage
|
|
145
|
+
presentation='card'
|
|
146
|
+
passwordLabel='Password'
|
|
147
|
+
showPasswordAriaLabel='Show password'
|
|
148
|
+
hidePasswordAriaLabel='Hide password'
|
|
149
|
+
/>,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
expect(screen.getByRole('button', { name: 'Show password' })).toBeTruthy()
|
|
153
|
+
await user.click(screen.getByRole('button', { name: 'Show password' }))
|
|
154
|
+
expect(screen.getByRole('button', { name: 'Hide password' })).toBeTruthy()
|
|
155
|
+
expect(screen.getByLabelText('Password').getAttribute('type')).toBe('text')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('keeps fields and secondary actions reachable in keyboard order', async () => {
|
|
159
|
+
const user = userEvent.setup()
|
|
160
|
+
|
|
161
|
+
render(
|
|
162
|
+
<LoginPage
|
|
163
|
+
presentation='card'
|
|
164
|
+
emailLabel='Email'
|
|
165
|
+
passwordLabel='Password'
|
|
166
|
+
rememberMeLabel='Remember me'
|
|
167
|
+
forgotPasswordLabel='Forgot password'
|
|
168
|
+
signInLabel='Sign in'
|
|
169
|
+
createAccountLabel='Create account'
|
|
170
|
+
onForgotPassword={vi.fn()}
|
|
171
|
+
onCreateAccount={vi.fn()}
|
|
172
|
+
/>,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
const expectedFocusOrder = [
|
|
176
|
+
screen.getByLabelText('Email'),
|
|
177
|
+
screen.getByLabelText('Password'),
|
|
178
|
+
screen.getByRole('button', { name: 'Mostrar contraseña' }),
|
|
179
|
+
screen.getByRole('checkbox', { name: 'Remember me' }),
|
|
180
|
+
screen.getByRole('button', { name: 'Forgot password' }),
|
|
181
|
+
screen.getByRole('button', { name: 'Sign in' }),
|
|
182
|
+
screen.getByRole('button', { name: 'Create account' }),
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
for (const element of expectedFocusOrder) {
|
|
186
|
+
await user.tab()
|
|
187
|
+
expect(document.activeElement).toBe(element)
|
|
188
|
+
}
|
|
189
|
+
})
|
|
190
|
+
})
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useId, useState, type FormEvent } from 'react'
|
|
4
|
+
import { Eye, EyeOff, Lock, type LucideIcon } from 'lucide-react'
|
|
5
|
+
|
|
6
|
+
import { Button } from '../../../ui/button'
|
|
7
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../../ui/card'
|
|
8
|
+
import { Input } from '../../../ui/input'
|
|
9
|
+
import { Label } from '../../../ui/label'
|
|
10
|
+
import { CheckboxCheck } from '../../e-commerce/checkout-forms/checkbox-check'
|
|
11
|
+
|
|
12
|
+
export interface LoginPageFormData {
|
|
13
|
+
email: string
|
|
14
|
+
password: string
|
|
15
|
+
remember: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface LoginPageProvider {
|
|
19
|
+
/** Must be unique within one `providers` list. */
|
|
20
|
+
id: string
|
|
21
|
+
label: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface LoginPageProps {
|
|
25
|
+
presentation: 'card' | 'social'
|
|
26
|
+
logoText?: string
|
|
27
|
+
welcomeTitleLabel?: string
|
|
28
|
+
welcomeDescriptionLabel?: string
|
|
29
|
+
emailLabel?: string
|
|
30
|
+
emailPlaceholder?: string
|
|
31
|
+
passwordLabel?: string
|
|
32
|
+
passwordPlaceholder?: string
|
|
33
|
+
rememberMeLabel?: string
|
|
34
|
+
forgotPasswordLabel?: string
|
|
35
|
+
signInLabel?: string
|
|
36
|
+
orContinueWithLabel?: string
|
|
37
|
+
dontHaveAccountLabel?: string
|
|
38
|
+
createAccountLabel?: string
|
|
39
|
+
showPasswordAriaLabel?: string
|
|
40
|
+
hidePasswordAriaLabel?: string
|
|
41
|
+
providers?: LoginPageProvider[]
|
|
42
|
+
providerIcons?: Record<string, LucideIcon>
|
|
43
|
+
onSubmit?: (data: { email: string; password: string; remember: boolean }) => void
|
|
44
|
+
onForgotPassword?: () => void
|
|
45
|
+
onCreateAccount?: () => void
|
|
46
|
+
onProviderSelect?: (providerId: string) => void
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function LoginPage({
|
|
50
|
+
presentation,
|
|
51
|
+
logoText,
|
|
52
|
+
welcomeTitleLabel = 'Bienvenido de nuevo',
|
|
53
|
+
welcomeDescriptionLabel = 'Inicia sesión para continuar',
|
|
54
|
+
emailLabel = 'Correo electrónico',
|
|
55
|
+
emailPlaceholder = 'taller@correo.com',
|
|
56
|
+
passwordLabel = 'Contraseña',
|
|
57
|
+
passwordPlaceholder = '••••••••',
|
|
58
|
+
rememberMeLabel = 'Recuérdame',
|
|
59
|
+
forgotPasswordLabel = '¿Olvidaste tu contraseña?',
|
|
60
|
+
signInLabel = 'Iniciar sesión',
|
|
61
|
+
orContinueWithLabel = 'O continúa con',
|
|
62
|
+
dontHaveAccountLabel = '¿No tienes una cuenta?',
|
|
63
|
+
createAccountLabel = 'Crea una cuenta',
|
|
64
|
+
showPasswordAriaLabel = 'Mostrar contraseña',
|
|
65
|
+
hidePasswordAriaLabel = 'Ocultar contraseña',
|
|
66
|
+
providers = [],
|
|
67
|
+
providerIcons = {},
|
|
68
|
+
onSubmit,
|
|
69
|
+
onForgotPassword,
|
|
70
|
+
onCreateAccount,
|
|
71
|
+
onProviderSelect,
|
|
72
|
+
}: LoginPageProps) {
|
|
73
|
+
if (presentation !== 'card' && presentation !== 'social') {
|
|
74
|
+
throw new Error('LoginPage requires a valid presentation: "card" or "social".')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const providerIds = new Set<string>()
|
|
78
|
+
for (const provider of providers ?? []) {
|
|
79
|
+
if (providerIds.has(provider.id)) {
|
|
80
|
+
throw new Error('LoginPage provider ids must be unique.')
|
|
81
|
+
}
|
|
82
|
+
providerIds.add(provider.id)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const id = useId()
|
|
86
|
+
const [email, setEmail] = useState('')
|
|
87
|
+
const [password, setPassword] = useState('')
|
|
88
|
+
const [remember, setRemember] = useState(false)
|
|
89
|
+
const [showPassword, setShowPassword] = useState(false)
|
|
90
|
+
|
|
91
|
+
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
|
92
|
+
event.preventDefault()
|
|
93
|
+
onSubmit?.({ email, password, remember })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const fields = (
|
|
97
|
+
<form onSubmit={handleSubmit} className='flex flex-col gap-5'>
|
|
98
|
+
<div className='flex flex-col gap-2'>
|
|
99
|
+
<Label htmlFor={`${id}-email`}>{emailLabel}</Label>
|
|
100
|
+
<Input
|
|
101
|
+
id={`${id}-email`}
|
|
102
|
+
type='email'
|
|
103
|
+
autoComplete='email'
|
|
104
|
+
placeholder={emailPlaceholder}
|
|
105
|
+
value={email}
|
|
106
|
+
onChange={event => setEmail(event.target.value)}
|
|
107
|
+
/>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
<div className='flex flex-col gap-2'>
|
|
111
|
+
<Label htmlFor={`${id}-password`}>{passwordLabel}</Label>
|
|
112
|
+
<div className='relative'>
|
|
113
|
+
<Input
|
|
114
|
+
id={`${id}-password`}
|
|
115
|
+
type={showPassword ? 'text' : 'password'}
|
|
116
|
+
autoComplete='current-password'
|
|
117
|
+
placeholder={passwordPlaceholder}
|
|
118
|
+
value={password}
|
|
119
|
+
onChange={event => setPassword(event.target.value)}
|
|
120
|
+
className='pe-10'
|
|
121
|
+
/>
|
|
122
|
+
<Button
|
|
123
|
+
type='button'
|
|
124
|
+
variant='ghost'
|
|
125
|
+
size='icon'
|
|
126
|
+
aria-label={showPassword ? hidePasswordAriaLabel : showPasswordAriaLabel}
|
|
127
|
+
className='absolute end-0 top-1/2 size-9 -translate-y-1/2 cursor-pointer hover:bg-transparent'
|
|
128
|
+
onClick={() => setShowPassword(value => !value)}
|
|
129
|
+
>
|
|
130
|
+
{showPassword ? <EyeOff aria-hidden='true' /> : <Eye aria-hidden='true' />}
|
|
131
|
+
</Button>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<div className='flex flex-wrap items-center justify-between gap-3'>
|
|
136
|
+
<div className='flex items-center gap-2'>
|
|
137
|
+
<CheckboxCheck
|
|
138
|
+
id={`${id}-remember`}
|
|
139
|
+
checked={remember}
|
|
140
|
+
onCheckedChange={value => setRemember(value === true)}
|
|
141
|
+
/>
|
|
142
|
+
<Label htmlFor={`${id}-remember`} className='text-sm font-normal text-muted-foreground'>
|
|
143
|
+
{rememberMeLabel}
|
|
144
|
+
</Label>
|
|
145
|
+
</div>
|
|
146
|
+
{onForgotPassword && (
|
|
147
|
+
<Button type='button' variant='link' className='h-auto cursor-pointer p-0 text-sm' onClick={onForgotPassword}>
|
|
148
|
+
{forgotPasswordLabel}
|
|
149
|
+
</Button>
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<Button type='submit' className='h-9 w-full cursor-pointer px-4 py-2'>
|
|
154
|
+
{signInLabel}
|
|
155
|
+
</Button>
|
|
156
|
+
</form>
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
const providerSection = providers.length > 0 && onProviderSelect && (
|
|
160
|
+
<div className='flex flex-col gap-4'>
|
|
161
|
+
<div className='flex items-center gap-3' aria-hidden='true'>
|
|
162
|
+
<span className='h-px flex-1 bg-border' />
|
|
163
|
+
<span className='text-muted-foreground text-xs uppercase'>{orContinueWithLabel}</span>
|
|
164
|
+
<span className='h-px flex-1 bg-border' />
|
|
165
|
+
</div>
|
|
166
|
+
<div
|
|
167
|
+
className={presentation === 'card' ? 'grid grid-cols-3 gap-3' : 'grid grid-cols-2 gap-4'}
|
|
168
|
+
role='group'
|
|
169
|
+
aria-label={orContinueWithLabel}
|
|
170
|
+
>
|
|
171
|
+
{providers.map(provider => {
|
|
172
|
+
const Icon = providerIcons[provider.id] as LucideIcon | undefined
|
|
173
|
+
return (
|
|
174
|
+
<Button
|
|
175
|
+
key={provider.id}
|
|
176
|
+
type='button'
|
|
177
|
+
variant='outline'
|
|
178
|
+
aria-label={provider.label}
|
|
179
|
+
className='h-9 w-full cursor-pointer px-3 py-2'
|
|
180
|
+
onClick={() => onProviderSelect(provider.id)}
|
|
181
|
+
>
|
|
182
|
+
{Icon ? <Icon aria-hidden='true' className='size-5' /> : provider.label}
|
|
183
|
+
{presentation === 'social' && Icon ? <span>{provider.label}</span> : null}
|
|
184
|
+
</Button>
|
|
185
|
+
)
|
|
186
|
+
})}
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
const createAccount = onCreateAccount && (
|
|
192
|
+
<p className='flex justify-center gap-1 text-center text-sm'>
|
|
193
|
+
<span>{dontHaveAccountLabel}</span>
|
|
194
|
+
<Button type='button' variant='link' className='h-auto cursor-pointer p-0 text-sm' onClick={onCreateAccount}>
|
|
195
|
+
{createAccountLabel}
|
|
196
|
+
</Button>
|
|
197
|
+
</p>
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
if (presentation === 'card') {
|
|
201
|
+
return (
|
|
202
|
+
<div className='w-full max-w-md'>
|
|
203
|
+
<Card className='w-full p-8 shadow-2xl'>
|
|
204
|
+
<header className='mb-8 flex flex-col items-center text-center'>
|
|
205
|
+
<div className='bg-secondary mb-4 flex size-14 items-center justify-center rounded-full border'>
|
|
206
|
+
<Lock aria-hidden='true' className='size-6' />
|
|
207
|
+
</div>
|
|
208
|
+
{logoText && <p className='text-muted-foreground mb-2 text-sm font-semibold'>{logoText}</p>}
|
|
209
|
+
<h1 className='mb-2 text-2xl font-bold tracking-tight'>{welcomeTitleLabel}</h1>
|
|
210
|
+
<p className='text-muted-foreground text-sm'>{welcomeDescriptionLabel}</p>
|
|
211
|
+
</header>
|
|
212
|
+
<div className='flex flex-col gap-6'>
|
|
213
|
+
{fields}
|
|
214
|
+
{providerSection}
|
|
215
|
+
{createAccount}
|
|
216
|
+
</div>
|
|
217
|
+
</Card>
|
|
218
|
+
</div>
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<div className='flex w-full max-w-md flex-col gap-6'>
|
|
224
|
+
{logoText && <div className='text-center text-2xl font-medium'>{logoText}</div>}
|
|
225
|
+
<Card>
|
|
226
|
+
<CardHeader className='text-center'>
|
|
227
|
+
<CardTitle className='text-2xl font-bold text-balance'>{welcomeTitleLabel}</CardTitle>
|
|
228
|
+
<CardDescription>{welcomeDescriptionLabel}</CardDescription>
|
|
229
|
+
</CardHeader>
|
|
230
|
+
<CardContent className='flex flex-col gap-5'>
|
|
231
|
+
{fields}
|
|
232
|
+
{providerSection}
|
|
233
|
+
{createAccount}
|
|
234
|
+
</CardContent>
|
|
235
|
+
</Card>
|
|
236
|
+
</div>
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export default LoginPage
|
|
@@ -306,14 +306,12 @@ export function ForgotPasswordPage1({
|
|
|
306
306
|
}
|
|
307
307
|
|
|
308
308
|
return (
|
|
309
|
-
<
|
|
310
|
-
<
|
|
311
|
-
<
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
</div>
|
|
316
|
-
</section>
|
|
309
|
+
<div className="w-full max-w-md">
|
|
310
|
+
<Card className="bg-background/80 relative w-full max-w-md ring-0 p-6 shadow-2xl backdrop-blur-md sm:p-8">
|
|
311
|
+
<ForgotPasswordPage1BrandMark />
|
|
312
|
+
{renderStepContent()}
|
|
313
|
+
</Card>
|
|
314
|
+
</div>
|
|
317
315
|
)
|
|
318
316
|
}
|
|
319
317
|
|