@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.
Files changed (178) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/README.md +228 -68
  3. package/docs/standards/data-contracts.md +13 -2
  4. package/package.json +26 -10
  5. package/scripts/fix-esm-extensions.mjs +40 -0
  6. package/src/blocks/application/login-forms/login-page.test.tsx +190 -0
  7. package/src/blocks/application/login-forms/login-page.tsx +240 -0
  8. package/src/blocks/application/password-recovery/forgot-password-page-1.tsx +6 -8
  9. package/src/blocks/application/signup-forms/signup-page-1.tsx +127 -129
  10. package/src/blocks/application/verification/verify-1.tsx +2 -2
  11. package/src/blocks/application/viewport-ownership.test.tsx +41 -0
  12. package/src/blocks/e-commerce/category-filters/category-listing-filter-fields.tsx +218 -0
  13. package/src/blocks/e-commerce/category-filters/{category-filter-2.test.tsx → category-listing.test.tsx} +287 -199
  14. package/src/blocks/e-commerce/category-filters/category-listing.tsx +934 -0
  15. package/src/blocks/e-commerce/checkout-forms/checkout-contact-step.tsx +149 -0
  16. package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-country-field.tsx → checkout-country-field.tsx} +10 -8
  17. package/src/blocks/e-commerce/checkout-forms/checkout-order-summary.tsx +230 -0
  18. package/src/blocks/e-commerce/checkout-forms/checkout-payment-step.tsx +100 -0
  19. package/src/blocks/e-commerce/checkout-forms/checkout-progress.tsx +62 -0
  20. package/src/blocks/e-commerce/checkout-forms/checkout-review-step.tsx +59 -0
  21. package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-shipping-step.tsx → checkout-shipping-step.tsx} +65 -41
  22. package/src/blocks/e-commerce/checkout-forms/checkout.test.tsx +478 -0
  23. package/src/blocks/e-commerce/checkout-forms/checkout.tsx +550 -0
  24. package/src/blocks/e-commerce/navigation/storefront-navbar-account-menu.test.tsx +56 -0
  25. package/src/blocks/{marketing/navbars/navbar-4-account-menu.tsx → e-commerce/navigation/storefront-navbar-account-menu.tsx} +3 -3
  26. package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.test.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.test.tsx} +22 -22
  27. package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.tsx} +3 -3
  28. package/src/blocks/{marketing/navbars/navbar-4-mobile-nav.tsx → e-commerce/navigation/storefront-navbar-mobile-nav.tsx} +4 -12
  29. package/src/blocks/{marketing/navbars/navbar-4-search-bar.tsx → e-commerce/navigation/storefront-navbar-search-bar.tsx} +3 -3
  30. package/src/blocks/{marketing/navbars/navbar-4.test.tsx → e-commerce/navigation/storefront-navbar.test.tsx} +67 -36
  31. package/src/blocks/{marketing/navbars/navbar-4.tsx → e-commerce/navigation/storefront-navbar.tsx} +43 -25
  32. package/src/blocks/e-commerce/product-categories/category-card-grid.test.tsx +122 -0
  33. package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
  34. package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
  35. package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
  36. package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
  37. package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
  38. package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
  39. package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
  40. package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
  41. package/src/blocks/e-commerce/product-list/product-card.tsx +447 -0
  42. package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
  43. package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
  44. package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
  45. package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
  46. package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
  47. package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
  48. package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
  49. package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
  50. package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
  51. package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
  52. package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +119 -85
  53. package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
  54. package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
  55. package/src/blocks/marketing/features/feature.test.tsx +153 -0
  56. package/src/blocks/marketing/features/feature.tsx +195 -0
  57. package/src/blocks/marketing/footers/footer-section-accessibility.test.tsx +118 -0
  58. package/src/blocks/marketing/footers/footer-section-actions.test.tsx +131 -0
  59. package/src/blocks/marketing/footers/footer-section-data.test.tsx +127 -0
  60. package/src/blocks/marketing/footers/footer-section-native-links.test.tsx +100 -0
  61. package/src/blocks/marketing/footers/footer-section-public-api.test.tsx +11 -0
  62. package/src/blocks/marketing/footers/footer-section.test.tsx +46 -0
  63. package/src/blocks/marketing/footers/footer-section.tsx +532 -0
  64. package/src/blocks/marketing/navbars/navbar-public-api.test.tsx +57 -0
  65. package/src/blocks/marketing/navbars/navbar.mega.test.tsx +149 -0
  66. package/src/blocks/marketing/navbars/navbar.test.tsx +155 -0
  67. package/src/blocks/marketing/navbars/navbar.tsx +735 -0
  68. package/src/blocks/marketing/testimonials/testimonial-section.test.tsx +171 -0
  69. package/src/blocks/marketing/testimonials/testimonial-section.tsx +237 -0
  70. package/src/index.ts +156 -128
  71. package/src/lib/contracts-curated.test.ts +47 -29
  72. package/src/lib/contracts.test.ts +198 -2
  73. package/src/lib/contracts.ts +410 -16
  74. package/src/ui/button-group.tsx +1 -1
  75. package/src/ui/carousel.tsx +1 -1
  76. package/src/ui/dialog.tsx +1 -1
  77. package/src/ui/field.tsx +2 -2
  78. package/src/ui/input-group.tsx +3 -3
  79. package/src/ui/pagination.tsx +1 -1
  80. package/src/ui/sidebar.tsx +7 -7
  81. package/src/ui/toggle-group.tsx +1 -1
  82. package/tsconfig.build.json +12 -0
  83. package/tsup.config.ts +11 -7
  84. package/.turbo/turbo-build.log +0 -24
  85. package/.turbo/turbo-test.log +0 -47
  86. package/.turbo/turbo-type-check.log +0 -0
  87. package/dist/.tsbuildinfo +0 -1
  88. package/dist/contracts.d.mts +0 -1732
  89. package/dist/contracts.d.ts +0 -1732
  90. package/dist/contracts.js +0 -423
  91. package/dist/contracts.js.map +0 -1
  92. package/dist/contracts.mjs +0 -365
  93. package/dist/contracts.mjs.map +0 -1
  94. package/dist/index.d.mts +0 -3068
  95. package/dist/index.d.ts +0 -3068
  96. package/dist/index.js +0 -17834
  97. package/dist/index.js.map +0 -1
  98. package/dist/index.mjs +0 -17740
  99. package/dist/index.mjs.map +0 -1
  100. package/src/blocks/application/login-forms/login-page-1.tsx +0 -246
  101. package/src/blocks/application/login-forms/login-page-2.tsx +0 -207
  102. package/src/blocks/e-commerce/category-filters/category-filter-1.tsx +0 -260
  103. package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +0 -222
  104. package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +0 -921
  105. package/src/blocks/e-commerce/category-filters/category-filter-4-chip-rail.tsx +0 -57
  106. package/src/blocks/e-commerce/category-filters/category-filter-4-drawer.tsx +0 -124
  107. package/src/blocks/e-commerce/category-filters/category-filter-4-fields.tsx +0 -170
  108. package/src/blocks/e-commerce/category-filters/category-filter-4-sidebar.tsx +0 -52
  109. package/src/blocks/e-commerce/category-filters/category-filter-4-sort-sheet.tsx +0 -57
  110. package/src/blocks/e-commerce/category-filters/category-filter-4-state.ts +0 -68
  111. package/src/blocks/e-commerce/category-filters/category-filter-4.tsx +0 -298
  112. package/src/blocks/e-commerce/checkout-forms/card-formatting.test.ts +0 -47
  113. package/src/blocks/e-commerce/checkout-forms/card-formatting.ts +0 -9
  114. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-contact-step.tsx +0 -96
  115. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +0 -252
  116. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +0 -188
  117. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-progress.tsx +0 -36
  118. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +0 -216
  119. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +0 -338
  120. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-order-summary.tsx +0 -130
  121. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-payment-card.tsx +0 -175
  122. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-shipping-card.tsx +0 -190
  123. package/src/blocks/e-commerce/checkout-forms/checkout-form-2.tsx +0 -312
  124. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-information-step.tsx +0 -91
  125. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-order-summary.tsx +0 -110
  126. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-payment-step.tsx +0 -137
  127. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-progress.tsx +0 -68
  128. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-review-step.tsx +0 -79
  129. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-shipping-step.tsx +0 -172
  130. package/src/blocks/e-commerce/checkout-forms/checkout-form-3.tsx +0 -392
  131. package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
  132. package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
  133. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
  134. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
  135. package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
  136. package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
  137. package/src/blocks/e-commerce/product-list/product-card-1.test.tsx +0 -88
  138. package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -192
  139. package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
  140. package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
  141. package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
  142. package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
  143. package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
  144. package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
  145. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
  146. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
  147. package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
  148. package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
  149. package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
  150. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
  151. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
  152. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
  153. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
  154. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
  155. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
  156. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
  157. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
  158. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
  159. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -229
  160. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -72
  161. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
  162. package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
  163. package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
  164. package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
  165. package/src/blocks/marketing/features/feature-1.tsx +0 -97
  166. package/src/blocks/marketing/features/feature-2.tsx +0 -82
  167. package/src/blocks/marketing/footers/footer-section-1.test.tsx +0 -117
  168. package/src/blocks/marketing/footers/footer-section-1.tsx +0 -178
  169. package/src/blocks/marketing/footers/footer-section-3.tsx +0 -192
  170. package/src/blocks/marketing/footers/footer-section-4.tsx +0 -355
  171. package/src/blocks/marketing/navbars/navbar-1.tsx +0 -267
  172. package/src/blocks/marketing/navbars/navbar-2.tsx +0 -351
  173. package/src/blocks/marketing/navbars/navbar-3.tsx +0 -303
  174. package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +0 -38
  175. package/src/blocks/marketing/navbars/navbar-4-like-button.tsx +0 -177
  176. package/src/blocks/marketing/navbars/navbar-4-product-grid.tsx +0 -219
  177. package/src/blocks/marketing/testimonials/testimonial-1.tsx +0 -141
  178. 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-09-27"
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.1.3",
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": "^5.8.0",
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
- <section className="from-background to-muted/50 relative isolate flex min-h-dvh w-full items-center justify-center overflow-hidden bg-linear-to-br">
310
- <div className="relative z-10 container mx-auto flex min-h-dvh items-center justify-center px-4 py-12 sm:py-16">
311
- <Card className="bg-background/80 relative w-full max-w-md ring-0 p-6 shadow-2xl backdrop-blur-md sm:p-8">
312
- <ForgotPasswordPage1BrandMark />
313
- {renderStepContent()}
314
- </Card>
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