@thorprovider/blocks 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.turbo/turbo-build.log +15 -15
  2. package/.turbo/turbo-test.log +32 -5
  3. package/AGENTS.md +1 -1
  4. package/README.md +52 -0
  5. package/dist/.tsbuildinfo +1 -1
  6. package/dist/contracts.d.mts +1111 -90
  7. package/dist/contracts.d.ts +1111 -90
  8. package/dist/contracts.js +189 -6
  9. package/dist/contracts.js.map +1 -1
  10. package/dist/contracts.mjs +173 -7
  11. package/dist/contracts.mjs.map +1 -1
  12. package/dist/index.d.mts +467 -89
  13. package/dist/index.d.ts +467 -89
  14. package/dist/index.js +2967 -2250
  15. package/dist/index.js.map +1 -1
  16. package/dist/index.mjs +2989 -2282
  17. package/dist/index.mjs.map +1 -1
  18. package/docs/standards/theming.md +32 -5
  19. package/package.json +4 -3
  20. package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +136 -93
  21. package/src/blocks/e-commerce/category-filters/category-filter-2.test.tsx +513 -0
  22. package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +728 -148
  23. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation-fulfilment-progress.tsx +14 -8
  24. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.test.tsx +136 -0
  25. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.tsx +145 -99
  26. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +215 -102
  27. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +6 -2
  28. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-shipping-step.tsx +51 -2
  29. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +216 -0
  30. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +114 -11
  31. package/src/blocks/e-commerce/navigation/mobile-category-drawer.test.tsx +42 -2
  32. package/src/blocks/e-commerce/navigation/mobile-category-drawer.tsx +10 -8
  33. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +51 -0
  34. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +56 -36
  35. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +182 -0
  36. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +298 -129
  37. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +137 -0
  38. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +231 -89
  39. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +65 -0
  40. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +19 -0
  41. package/src/blocks/marketing/footers/footer-section-1.test.tsx +117 -0
  42. package/src/blocks/marketing/footers/footer-section-1.tsx +46 -9
  43. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +54 -0
  44. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +43 -13
  45. package/src/blocks/marketing/navbars/navbar-4.test.tsx +112 -0
  46. package/src/blocks/marketing/navbars/navbar-4.tsx +101 -28
  47. package/src/index.ts +38 -0
  48. package/src/lib/cart.test.ts +15 -0
  49. package/src/lib/cart.ts +11 -0
  50. package/src/lib/contracts-curated.test.ts +207 -0
  51. package/src/lib/contracts-server-boundary.test.ts +52 -0
  52. package/src/lib/contracts.ts +182 -4
  53. package/src/lib/money.test.ts +64 -0
  54. package/src/lib/money.ts +65 -0
  55. package/styles.css +1 -0
  56. package/tsup.config.ts +0 -1
  57. package/vitest.config.ts +9 -1
@@ -40,6 +40,81 @@ export const PRODUCT_IMAGE_FALLBACK =
40
40
  // Data contracts
41
41
  // ---------------------------------------------------------------------------
42
42
 
43
+ /**
44
+ * Canonical `Money` (`@thorprovider/types`): a decimal string in major units
45
+ * (`"69.99"`), never cents. A missing price is represented by omitting the
46
+ * field (or `null` on a nullable field), not by an empty string.
47
+ *
48
+ * The amount stays a permissive `z.string()` on purpose: an unresolved backend
49
+ * price may arrive as the canonical sentinel `""`, and tightening the schema
50
+ * would reject the whole view instead of letting the presentation layer render
51
+ * an unavailable state. Validity is enforced at render time by
52
+ * `isAvailableMoney`/`formatMoney` (a `""`/malformed amount never becomes `$0`).
53
+ */
54
+ export const moneySchema: z.ZodType<Money> = z.object({
55
+ amount: z.string(),
56
+ currencyCode: z.string(),
57
+ })
58
+
59
+ const imageSchema = z.object({
60
+ id: z.string().optional(),
61
+ url: z.string(),
62
+ altText: z.string(),
63
+ width: z.number().optional(),
64
+ height: z.number().optional(),
65
+ })
66
+
67
+ const selectedOptionSchema = z.object({
68
+ name: z.string(),
69
+ value: z.string(),
70
+ })
71
+
72
+ export const productDetailVariantSchema = z.object({
73
+ id: z.string(),
74
+ title: z.string(),
75
+ sku: z.string().optional(),
76
+ availableForSale: z.boolean(),
77
+ selectedOptions: z.array(selectedOptionSchema),
78
+ /** Omit or null when the backend has no resolved price (purchase disabled). */
79
+ price: moneySchema.nullish(),
80
+ compareAtPrice: moneySchema.optional(),
81
+ image: imageSchema.optional(),
82
+ })
83
+ export type ProductDetailVariantSpec = z.infer<typeof productDetailVariantSchema>
84
+
85
+ export const productDetailProductSchema = z.object({
86
+ id: z.string(),
87
+ handle: z.string().optional(),
88
+ title: z.string(),
89
+ description: z.string().optional(),
90
+ vendor: z.string().optional(),
91
+ options: z.array(z.object({ id: z.string(), name: z.string(), values: z.array(z.string()) })),
92
+ variants: z.array(productDetailVariantSchema),
93
+ images: z.array(imageSchema),
94
+ featuredImage: imageSchema.optional(),
95
+ })
96
+ export type ProductDetailProductSpec = z.infer<typeof productDetailProductSchema>
97
+
98
+ export const productOverview1PropsSchema = z.object({
99
+ product: productDetailProductSchema,
100
+ selectedVariantId: z.string().nullable().optional(),
101
+ quantity: z.number().optional(),
102
+ rating: z.object({ value: z.number(), count: z.number() }).optional(),
103
+ deliveryEstimate: z.string().optional(),
104
+ shipping: moneySchema.optional(),
105
+ sizesLabel: z.string().optional(),
106
+ variantLabel: z.string().optional(),
107
+ reviewsLabel: z.string().optional(),
108
+ addToCartLabel: z.string().optional(),
109
+ outOfStockLabel: z.string().optional(),
110
+ unavailablePriceLabel: z.string().optional(),
111
+ selectVariantLabel: z.string().optional(),
112
+ quantityLabel: z.string().optional(),
113
+ deliveryEstimateLabel: z.string().optional(),
114
+ shippingLabel: z.string().optional(),
115
+ })
116
+ export type ProductOverview1SpecProps = z.infer<typeof productOverview1PropsSchema>
117
+
43
118
  export const cardProductSchema = z.object({
44
119
  id: z.union([z.string(), z.number()]),
45
120
  title: z.string(),
@@ -55,11 +130,25 @@ export const cartItemSchema = z.object({
55
130
  name: z.string(),
56
131
  price: z.number(),
57
132
  quantity: z.number().int().min(0),
58
- image: z.string(),
133
+ /** Optional; absent images are omitted, never serialized as `""`. */
134
+ image: z.string().optional(),
59
135
  variant: z.string().optional(),
136
+ /** Canonical backend per-unit price, preferred for display when present. */
137
+ unitPrice: moneySchema.optional(),
138
+ /** Canonical backend line total, preferred over `price * quantity`. */
139
+ lineTotal: moneySchema.optional(),
60
140
  })
61
141
  export type CartItem = z.infer<typeof cartItemSchema>
62
142
 
143
+ export const cartTotalsSchema = z.object({
144
+ subtotal: moneySchema,
145
+ discount: moneySchema.optional(),
146
+ tax: moneySchema.optional(),
147
+ shipping: moneySchema.optional(),
148
+ total: moneySchema,
149
+ })
150
+ export type CartTotals = z.infer<typeof cartTotalsSchema>
151
+
63
152
  export const recentlyViewedItemSchema = z.object({
64
153
  id: z.string(),
65
154
  name: z.string(),
@@ -103,10 +192,11 @@ export type UtilityLink = z.infer<typeof utilityLinkSchema>
103
192
  export const navCartItemSchema = z.object({
104
193
  id: z.string(),
105
194
  name: z.string(),
106
- variant: z.string(),
195
+ variant: z.string().optional(),
107
196
  price: z.number(),
108
197
  qty: z.number().int().min(0),
109
- image: z.string(),
198
+ /** Optional; absent images are omitted, never serialized as `""`. */
199
+ image: z.string().optional(),
110
200
  })
111
201
  export type NavCartItem = z.infer<typeof navCartItemSchema>
112
202
 
@@ -196,13 +286,22 @@ export const shoppingCart4PropsSchema = z.object({
196
286
  items: z.array(cartItemSchema),
197
287
  promotions: z.array(promotionSchema).default([]),
198
288
  recentlyViewed: z.array(recentlyViewedItemSchema).optional(),
289
+ /** Backend-authoritative totals; rendered verbatim, never computed locally. */
290
+ totals: cartTotalsSchema.optional(),
291
+ totalsState: z.enum(['idle', 'pending', 'error']).optional(),
292
+ totalsErrorLabel: z.string().optional(),
293
+ totalsUnavailableLabel: z.string().optional(),
294
+ amountUnavailableLabel: z.string().optional(),
295
+ retryLabel: z.string().optional(),
199
296
  taxRate: z.number().optional(),
200
297
  titleLabel: z.string().optional(),
201
298
  subtotalLabel: z.string().optional(),
299
+ discountLabel: z.string().optional(),
202
300
  taxLabel: z.string().optional(),
203
301
  shippingLabel: z.string().optional(),
204
302
  shippingFreeLabel: z.string().optional(),
205
303
  totalLabel: z.string().optional(),
304
+ totalsPendingLabel: z.string().optional(),
206
305
  awayFromLabel: z.string().optional(),
207
306
  activatedLabel: z.string().optional(),
208
307
  itemsLabel: z.string().optional(),
@@ -217,19 +316,98 @@ export const shoppingCart4PropsSchema = z.object({
217
316
  checkoutLabel: z.string().optional(),
218
317
  quickCheckoutLabel: z.string().optional(),
219
318
  checkoutNowLabel: z.string().optional(),
319
+ emptyLabel: z.string().optional(),
320
+ removeLabel: z.string().optional(),
321
+ decreaseLabel: z.string().optional(),
322
+ increaseLabel: z.string().optional(),
220
323
  })
221
324
  export type ShoppingCart4SpecProps = z.infer<typeof shoppingCart4PropsSchema>
222
325
 
326
+ /**
327
+ * Optional brand seams shared by the existing blocks. These mirror the
328
+ * components' optional props as a serializable subset (no React): an accent
329
+ * marks a trailing segment of the wordmark/brand name, and an editorial image
330
+ * is a native `<img>` source. Omitting each keeps the block's current output.
331
+ */
332
+ export const logoAccentSchema = z.object({
333
+ text: z.string(),
334
+ className: z.string().optional(),
335
+ })
336
+ export type LogoAccent = z.infer<typeof logoAccentSchema>
337
+
338
+ export const brandAccentSchema = z.object({
339
+ text: z.string(),
340
+ className: z.string().optional(),
341
+ })
342
+ export type BrandAccent = z.infer<typeof brandAccentSchema>
343
+
344
+ export const heroImageSchema = z.object({
345
+ src: z.string(),
346
+ alt: z.string(),
347
+ })
348
+ export type HeroImage = z.infer<typeof heroImageSchema>
349
+
223
350
  export const navbar4PropsSchema = z.object({
224
351
  storeCategories: z.array(storeCategorySchema),
225
352
  shippingNote: z.string(),
226
353
  utilityLinks: z.array(utilityLinkSchema),
227
354
  logoText: z.string(),
228
355
  logoHref: z.string().optional(),
356
+ /** Optional accent for a trailing segment of `logoText`; omitted → unchanged. */
357
+ logoAccent: logoAccentSchema.optional(),
358
+ /**
359
+ * Consumer-supplied classes merged onto the block's outer surface as the
360
+ * final argument (e.g. a semantic surface token). Omitted → unchanged.
361
+ */
362
+ className: z.string().optional(),
229
363
  accountHref: z.string().optional(),
230
364
  searchQuery: z.string().optional(),
231
365
  items: z.array(navCartItemSchema).optional(),
366
+ /** Authoritative backend subtotal for the mini-cart. */
367
+ subtotal: moneySchema.optional(),
368
+ subtotalUnavailableLabel: z.string().optional(),
232
369
  user: storeUserSchema.optional(),
233
370
  accountMenuItems: z.array(accountMenuItemSchema).optional(),
234
371
  })
235
- export type Navbar4SpecProps = z.infer<typeof navbar4PropsSchema>
372
+ export type Navbar4SpecProps = z.infer<typeof navbar4PropsSchema>
373
+
374
+ /**
375
+ * Serializable contracts for the two bounded editorial sections (`faq`, `cta`).
376
+ * These are the serializable subset of `FaqBlock1Props`/`CtaSection1Props`: the
377
+ * React-only callbacks (`onPrimaryClick`/`onSecondaryClick`) are intentionally
378
+ * excluded — the catalog adapter binds them (see design D3).
379
+ */
380
+ export const ctaSchema = z.object({
381
+ label: z.string(),
382
+ href: z.string().optional(),
383
+ })
384
+ export type CtaSpec = z.infer<typeof ctaSchema>
385
+
386
+ export const faqItemSchema = z.object({
387
+ value: z.string(),
388
+ question: z.string(),
389
+ answer: z.string(),
390
+ })
391
+ export type FaqItemSpec = z.infer<typeof faqItemSchema>
392
+
393
+ export const faqBlock1PropsSchema = z.object({
394
+ items: z.array(faqItemSchema),
395
+ title: z.string().optional(),
396
+ description: z.string().optional(),
397
+ accordionType: z.enum(['single', 'multiple']).optional(),
398
+ })
399
+ export type FaqBlock1SpecProps = z.infer<typeof faqBlock1PropsSchema>
400
+
401
+ export const ctaSection1PropsSchema = z.object({
402
+ badgeText: z.string().optional(),
403
+ title: z.string().optional(),
404
+ description: z.string().optional(),
405
+ primaryCta: ctaSchema.optional(),
406
+ secondaryCta: ctaSchema.optional(),
407
+ })
408
+ export type CtaSection1SpecProps = z.infer<typeof ctaSection1PropsSchema>
409
+
410
+ // Pure monetary helpers, re-exported from the server-safe contracts entry so a
411
+ // host boundary can validate/format `Money` without importing the React entry.
412
+ export { isCompleteFiniteDecimal, isAvailableMoney, formatMoney } from './money'
413
+ export { resolveAbsoluteQuantity } from './cart'
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { formatMoney, isAvailableMoney, isCompleteFiniteDecimal } from './money'
3
+
4
+ describe('isCompleteFiniteDecimal', () => {
5
+ it('accepts complete finite decimals, including zero and negatives', () => {
6
+ for (const value of ['0', '0.00', '69.99', '-3.5', '1000']) {
7
+ expect(isCompleteFiniteDecimal(value)).toBe(true)
8
+ }
9
+ })
10
+
11
+ it('rejects partial, malformed, empty, and non-string input', () => {
12
+ for (const value of ['', ' ', '1.', '.5', '1e3', 'abc', '1,5', 'Infinity', 'NaN', null, 42, undefined]) {
13
+ expect(isCompleteFiniteDecimal(value)).toBe(false)
14
+ }
15
+ })
16
+ })
17
+
18
+ describe('isAvailableMoney', () => {
19
+ it('treats a backend-resolved valid zero as available', () => {
20
+ expect(isAvailableMoney({ amount: '0', currencyCode: 'USD' })).toBe(true)
21
+ })
22
+
23
+ it('treats a malformed amount as unavailable', () => {
24
+ expect(isAvailableMoney({ amount: 'n/a', currencyCode: 'USD' })).toBe(false)
25
+ })
26
+
27
+ it('treats a missing currency as unavailable', () => {
28
+ expect(isAvailableMoney({ amount: '10', currencyCode: ' ' })).toBe(false)
29
+ })
30
+
31
+ it('treats null/undefined as unavailable', () => {
32
+ expect(isAvailableMoney(null)).toBe(false)
33
+ expect(isAvailableMoney(undefined)).toBe(false)
34
+ })
35
+ })
36
+
37
+ describe('formatMoney', () => {
38
+ it('uses the money currency rather than a hardcoded one', () => {
39
+ const text = formatMoney({ amount: '69.99', currencyCode: 'EUR' })
40
+ expect(text).toContain('69.99')
41
+ expect(text).not.toContain('USD')
42
+ })
43
+
44
+ it('defers to a supplied formatter', () => {
45
+ expect(
46
+ formatMoney({ amount: '5', currencyCode: 'USD' }, money => `<<${money.amount} ${money.currencyCode}>>`),
47
+ ).toBe('<<5 USD>>')
48
+ })
49
+
50
+ it('returns null for absent or malformed amounts instead of a fake zero', () => {
51
+ expect(formatMoney({ amount: '', currencyCode: 'USD' })).toBeNull()
52
+ expect(formatMoney({ amount: ' ', currencyCode: 'USD' })).toBeNull()
53
+ expect(formatMoney({ amount: 'abc', currencyCode: 'USD' })).toBeNull()
54
+ expect(formatMoney({ amount: '10', currencyCode: '' })).toBeNull()
55
+ expect(formatMoney(null)).toBeNull()
56
+ expect(formatMoney(undefined)).toBeNull()
57
+ })
58
+
59
+ it('preserves a legitimate zero', () => {
60
+ expect(
61
+ formatMoney({ amount: '0', currencyCode: 'USD' }, money => `${money.currencyCode} ${money.amount}`),
62
+ ).toBe('USD 0')
63
+ })
64
+ })
@@ -0,0 +1,65 @@
1
+ import type { Money } from '@thorprovider/types'
2
+
3
+ /**
4
+ * Pure monetary helpers shared by the curated commerce blocks.
5
+ *
6
+ * Canonical `Money` (`@thorprovider/types`) carries a decimal string in major
7
+ * units (e.g. `"69.99"`) — never cents. A missing, invalid, or unresolved
8
+ * amount is *unavailable*, which is a different state from a legitimate `"0"`.
9
+ * These helpers keep that distinction in the presentation layer: they validate
10
+ * a complete finite decimal before any display conversion, and they never
11
+ * invent a currency.
12
+ */
13
+
14
+ const COMPLETE_FINITE_DECIMAL = /^-?(?:\d+)(?:\.\d+)?$/
15
+
16
+ /**
17
+ * True only for a complete, finite decimal string (`"0"`, `"69.99"`, `"-3.5"`).
18
+ * Rejects `""`, `" "`, `"1."`, `".5"`, `"1e3"`, `"abc"`, and non-strings.
19
+ */
20
+ export function isCompleteFiniteDecimal(value: unknown): value is string {
21
+ if (typeof value !== 'string') return false
22
+ const trimmed = value.trim()
23
+ if (trimmed === '' || !COMPLETE_FINITE_DECIMAL.test(trimmed)) return false
24
+ return Number.isFinite(Number(trimmed))
25
+ }
26
+
27
+ /**
28
+ * True when a `Money` value is safe to display and price a purchase with: a
29
+ * complete finite decimal amount and a non-empty currency code. A valid zero
30
+ * (`{ amount: '0', currencyCode: 'USD' }`) is available — zero alone does not
31
+ * make a price unavailable.
32
+ */
33
+ export function isAvailableMoney(money: Money | null | undefined): money is Money {
34
+ return (
35
+ !!money &&
36
+ typeof money.currencyCode === 'string' &&
37
+ money.currencyCode.trim() !== '' &&
38
+ isCompleteFiniteDecimal(money.amount)
39
+ )
40
+ }
41
+
42
+ /**
43
+ * Formats a `Money` for display, or `null` when the value is unavailable.
44
+ *
45
+ * This is the safety gate the whole curated surface relies on: an absent or
46
+ * malformed amount (`""`, `" "`, `"abc"`, a non-string, a missing currency)
47
+ * MUST NOT reach `Intl.NumberFormat`, because `Number("")` is `0` and would
48
+ * render a fake `$0.00` for missing data. A backend-resolved valid zero
49
+ * (`{ amount: "0", currencyCode: "USD" }`) is available and formats as zero.
50
+ *
51
+ * When no `formatter` is supplied the currency comes from
52
+ * `money.currencyCode`, so no currency is hardcoded. Callers render their own
53
+ * unavailable/pending/recovery state when this returns `null`.
54
+ */
55
+ export function formatMoney(
56
+ money: Money | null | undefined,
57
+ formatter?: (money: Money) => string,
58
+ ): string | null {
59
+ if (!isAvailableMoney(money)) return null
60
+ if (formatter) return formatter(money)
61
+ return new Intl.NumberFormat(undefined, {
62
+ style: 'currency',
63
+ currency: money.currencyCode,
64
+ }).format(Number(money.amount))
65
+ }
package/styles.css ADDED
@@ -0,0 +1 @@
1
+ @source "./dist";
package/tsup.config.ts CHANGED
@@ -11,6 +11,5 @@ export default defineConfig({
11
11
  sourcemap: true,
12
12
  clean: true,
13
13
  external: ['react', 'react-dom', 'clsx', 'tailwind-merge', 'lucide-react'],
14
- banner: { js: "'use client';" },
15
14
  onSuccess: process.env.SKIP_TYPE_CHECK === 'true' ? undefined : 'tsc --noEmit --skipLibCheck'
16
15
  })
package/vitest.config.ts CHANGED
@@ -1,9 +1,17 @@
1
+ import { fileURLToPath } from 'node:url'
1
2
  import { defineConfig } from 'vitest/config'
2
3
 
3
4
  export default defineConfig({
5
+ resolve: {
6
+ // Vendored shadcn primitives under src/ui use the `@/` alias; mirror the
7
+ // tsconfig paths so component tests can import them.
8
+ alias: {
9
+ '@': fileURLToPath(new URL('./src', import.meta.url)),
10
+ },
11
+ },
4
12
  test: {
5
13
  environment: 'node',
6
14
  globals: true,
7
15
  include: ['src/**/*.test.{ts,tsx}'],
8
16
  },
9
- })
17
+ })