@fayz-ai/storefront 0.2.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 (175) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +39 -0
  3. package/dist/auth.d.ts +29 -0
  4. package/dist/auth.d.ts.map +1 -0
  5. package/dist/blocks.d.ts +6 -0
  6. package/dist/blocks.d.ts.map +1 -0
  7. package/dist/components/CartDrawer.d.ts +3 -0
  8. package/dist/components/CartDrawer.d.ts.map +1 -0
  9. package/dist/components/CustomerAccountShell.d.ts +13 -0
  10. package/dist/components/CustomerAccountShell.d.ts.map +1 -0
  11. package/dist/components/FiltersPanel.d.ts +3 -0
  12. package/dist/components/FiltersPanel.d.ts.map +1 -0
  13. package/dist/components/OrderTrackingTimeline.d.ts +7 -0
  14. package/dist/components/OrderTrackingTimeline.d.ts.map +1 -0
  15. package/dist/components/Price.d.ts +11 -0
  16. package/dist/components/Price.d.ts.map +1 -0
  17. package/dist/components/ProductCard.d.ts +7 -0
  18. package/dist/components/ProductCard.d.ts.map +1 -0
  19. package/dist/components/ProductGrid.d.ts +7 -0
  20. package/dist/components/ProductGrid.d.ts.map +1 -0
  21. package/dist/components/ProductOptionSelector.d.ts +9 -0
  22. package/dist/components/ProductOptionSelector.d.ts.map +1 -0
  23. package/dist/components/ProductSpecs.d.ts +8 -0
  24. package/dist/components/ProductSpecs.d.ts.map +1 -0
  25. package/dist/components/QuantityInput.d.ts +12 -0
  26. package/dist/components/QuantityInput.d.ts.map +1 -0
  27. package/dist/components/RelatedProducts.d.ts +9 -0
  28. package/dist/components/RelatedProducts.d.ts.map +1 -0
  29. package/dist/components/SignInModal.d.ts +19 -0
  30. package/dist/components/SignInModal.d.ts.map +1 -0
  31. package/dist/components/StorefrontErrorBoundary.d.ts +20 -0
  32. package/dist/components/StorefrontErrorBoundary.d.ts.map +1 -0
  33. package/dist/components/StorefrontFooter.d.ts +3 -0
  34. package/dist/components/StorefrontFooter.d.ts.map +1 -0
  35. package/dist/components/StorefrontHeader.d.ts +3 -0
  36. package/dist/components/StorefrontHeader.d.ts.map +1 -0
  37. package/dist/components/Toaster.d.ts +3 -0
  38. package/dist/components/Toaster.d.ts.map +1 -0
  39. package/dist/components/sections/CategoryShowcase.d.ts +6 -0
  40. package/dist/components/sections/CategoryShowcase.d.ts.map +1 -0
  41. package/dist/components/sections/HeroSection.d.ts +9 -0
  42. package/dist/components/sections/HeroSection.d.ts.map +1 -0
  43. package/dist/components/sections/MiscSections.d.ts +33 -0
  44. package/dist/components/sections/MiscSections.d.ts.map +1 -0
  45. package/dist/components/sections/ProductRail.d.ts +9 -0
  46. package/dist/components/sections/ProductRail.d.ts.map +1 -0
  47. package/dist/config.d.ts +149 -0
  48. package/dist/config.d.ts.map +1 -0
  49. package/dist/createStorefrontApp.d.ts +16 -0
  50. package/dist/createStorefrontApp.d.ts.map +1 -0
  51. package/dist/format.d.ts +3 -0
  52. package/dist/format.d.ts.map +1 -0
  53. package/dist/hooks/index.d.ts +6 -0
  54. package/dist/hooks/index.d.ts.map +1 -0
  55. package/dist/hooks/useCategories.d.ts +6 -0
  56. package/dist/hooks/useCategories.d.ts.map +1 -0
  57. package/dist/hooks/useDiscountValidator.d.ts +14 -0
  58. package/dist/hooks/useDiscountValidator.d.ts.map +1 -0
  59. package/dist/hooks/useMyOrders.d.ts +7 -0
  60. package/dist/hooks/useMyOrders.d.ts.map +1 -0
  61. package/dist/hooks/useProduct.d.ts +7 -0
  62. package/dist/hooks/useProduct.d.ts.map +1 -0
  63. package/dist/hooks/useProducts.d.ts +8 -0
  64. package/dist/hooks/useProducts.d.ts.map +1 -0
  65. package/dist/hooks/useStorefrontHead.d.ts +11 -0
  66. package/dist/hooks/useStorefrontHead.d.ts.map +1 -0
  67. package/dist/index.cjs +4186 -0
  68. package/dist/index.cjs.map +1 -0
  69. package/dist/index.d.ts +55 -0
  70. package/dist/index.d.ts.map +1 -0
  71. package/dist/index.js +4085 -0
  72. package/dist/index.js.map +1 -0
  73. package/dist/motion.d.ts +19 -0
  74. package/dist/motion.d.ts.map +1 -0
  75. package/dist/order-status.d.ts +10 -0
  76. package/dist/order-status.d.ts.map +1 -0
  77. package/dist/pages/CatalogPage.d.ts +3 -0
  78. package/dist/pages/CatalogPage.d.ts.map +1 -0
  79. package/dist/pages/CheckoutPage.d.ts +3 -0
  80. package/dist/pages/CheckoutPage.d.ts.map +1 -0
  81. package/dist/pages/HomePage.d.ts +6 -0
  82. package/dist/pages/HomePage.d.ts.map +1 -0
  83. package/dist/pages/MyPurchasesPage.d.ts +3 -0
  84. package/dist/pages/MyPurchasesPage.d.ts.map +1 -0
  85. package/dist/pages/NotFoundPage.d.ts +4 -0
  86. package/dist/pages/NotFoundPage.d.ts.map +1 -0
  87. package/dist/pages/OrderConfirmationPage.d.ts +5 -0
  88. package/dist/pages/OrderConfirmationPage.d.ts.map +1 -0
  89. package/dist/pages/PolicyPage.d.ts +15 -0
  90. package/dist/pages/PolicyPage.d.ts.map +1 -0
  91. package/dist/pages/ProductDetailPage.d.ts +5 -0
  92. package/dist/pages/ProductDetailPage.d.ts.map +1 -0
  93. package/dist/presets.d.ts +33 -0
  94. package/dist/presets.d.ts.map +1 -0
  95. package/dist/product-options.d.ts +12 -0
  96. package/dist/product-options.d.ts.map +1 -0
  97. package/dist/router.d.ts +285 -0
  98. package/dist/router.d.ts.map +1 -0
  99. package/dist/scaffold.d.ts +14 -0
  100. package/dist/scaffold.d.ts.map +1 -0
  101. package/dist/sections.d.ts +84 -0
  102. package/dist/sections.d.ts.map +1 -0
  103. package/dist/slot-contracts.d.ts +34 -0
  104. package/dist/slot-contracts.d.ts.map +1 -0
  105. package/dist/stores/cart.store.d.ts +58 -0
  106. package/dist/stores/cart.store.d.ts.map +1 -0
  107. package/dist/stores/catalog.store.d.ts +18 -0
  108. package/dist/stores/catalog.store.d.ts.map +1 -0
  109. package/dist/stores/session.store.d.ts +23 -0
  110. package/dist/stores/session.store.d.ts.map +1 -0
  111. package/dist/stores/toast.store.d.ts +27 -0
  112. package/dist/stores/toast.store.d.ts.map +1 -0
  113. package/dist/testids.d.ts +93 -0
  114. package/dist/testids.d.ts.map +1 -0
  115. package/dist/theme.d.ts +59 -0
  116. package/dist/theme.d.ts.map +1 -0
  117. package/dist/workflows/checkout.d.ts +33 -0
  118. package/dist/workflows/checkout.d.ts.map +1 -0
  119. package/package.json +56 -0
  120. package/src/auth.ts +134 -0
  121. package/src/blocks.tsx +47 -0
  122. package/src/components/CartDrawer.tsx +250 -0
  123. package/src/components/CustomerAccountShell.tsx +102 -0
  124. package/src/components/FiltersPanel.tsx +109 -0
  125. package/src/components/OrderTrackingTimeline.tsx +135 -0
  126. package/src/components/Price.tsx +32 -0
  127. package/src/components/ProductCard.tsx +104 -0
  128. package/src/components/ProductGrid.tsx +34 -0
  129. package/src/components/ProductOptionSelector.tsx +50 -0
  130. package/src/components/ProductSpecs.tsx +75 -0
  131. package/src/components/QuantityInput.tsx +52 -0
  132. package/src/components/RelatedProducts.tsx +31 -0
  133. package/src/components/SignInModal.tsx +120 -0
  134. package/src/components/StorefrontErrorBoundary.tsx +51 -0
  135. package/src/components/StorefrontFooter.tsx +100 -0
  136. package/src/components/StorefrontHeader.tsx +342 -0
  137. package/src/components/Toaster.tsx +60 -0
  138. package/src/components/sections/CategoryShowcase.tsx +79 -0
  139. package/src/components/sections/HeroSection.tsx +177 -0
  140. package/src/components/sections/MiscSections.tsx +157 -0
  141. package/src/components/sections/ProductRail.tsx +73 -0
  142. package/src/config.ts +176 -0
  143. package/src/createStorefrontApp.tsx +171 -0
  144. package/src/format.ts +7 -0
  145. package/src/hooks/index.ts +5 -0
  146. package/src/hooks/useCategories.ts +26 -0
  147. package/src/hooks/useDiscountValidator.ts +43 -0
  148. package/src/hooks/useMyOrders.ts +41 -0
  149. package/src/hooks/useProduct.ts +36 -0
  150. package/src/hooks/useProducts.ts +39 -0
  151. package/src/hooks/useStorefrontHead.ts +25 -0
  152. package/src/index.ts +132 -0
  153. package/src/motion.tsx +94 -0
  154. package/src/order-status.ts +33 -0
  155. package/src/pages/CatalogPage.tsx +118 -0
  156. package/src/pages/CheckoutPage.tsx +578 -0
  157. package/src/pages/HomePage.tsx +12 -0
  158. package/src/pages/MyPurchasesPage.tsx +310 -0
  159. package/src/pages/NotFoundPage.tsx +25 -0
  160. package/src/pages/OrderConfirmationPage.tsx +189 -0
  161. package/src/pages/PolicyPage.tsx +47 -0
  162. package/src/pages/ProductDetailPage.tsx +184 -0
  163. package/src/presets.ts +258 -0
  164. package/src/product-options.ts +63 -0
  165. package/src/router.ts +49 -0
  166. package/src/scaffold.tsx +109 -0
  167. package/src/sections.ts +69 -0
  168. package/src/slot-contracts.ts +20 -0
  169. package/src/stores/cart.store.ts +149 -0
  170. package/src/stores/catalog.store.ts +39 -0
  171. package/src/stores/session.store.ts +28 -0
  172. package/src/stores/toast.store.ts +43 -0
  173. package/src/testids.ts +103 -0
  174. package/src/theme.ts +160 -0
  175. package/src/workflows/checkout.ts +91 -0
@@ -0,0 +1,69 @@
1
+ import type { HeroVariant } from './theme'
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Home-page section blueprint — the storefront's PageConfig equivalent.
5
+ // A template (or the Fayz agent) declares the home as an ordered list of
6
+ // typed sections; HomePage renders them. Every recurring block found in the
7
+ // Nuvemshop research maps to one section type.
8
+ // ---------------------------------------------------------------------------
9
+
10
+ export interface HeroSlide {
11
+ title: string
12
+ subtitle?: string
13
+ cta?: string
14
+ href?: string
15
+ /** Image URL; omit for a gradient placeholder derived from `hue` */
16
+ image?: string
17
+ hue?: number
18
+ /** Editorial boxed copy block (Uyuni) instead of free overlay */
19
+ boxed?: boolean
20
+ }
21
+
22
+ export type HomeSection =
23
+ | { type: 'hero'; variant: HeroVariant; slides: HeroSlide[]; height?: 'tall' | 'medium' }
24
+ | { type: 'categories'; style: 'bubbles' | 'tiles'; title?: string }
25
+ | {
26
+ type: 'products'
27
+ title: string
28
+ subtitle?: string
29
+ /** Small-caps label above the title (e.g. 'curadoria') */
30
+ eyebrow?: string
31
+ /** 'sale' = compareAtPrice set; 'new' = newest first; 'all' = catalog order */
32
+ filter?: 'sale' | 'new' | 'all'
33
+ limit?: number
34
+ }
35
+ | { type: 'benefits'; items: Array<{ icon: string; title: string; text?: string }> }
36
+ | { type: 'banner'; title: string; subtitle?: string; eyebrow?: string; cta?: string; href?: string; image?: string; hue?: number }
37
+ | { type: 'manifesto'; title?: string; text: string }
38
+ | { type: 'testimonials'; title?: string; items: Array<{ quote: string; author: string }> }
39
+ | { type: 'newsletter'; title?: string; subtitle?: string }
40
+
41
+ export interface HomeConfig {
42
+ sections: HomeSection[]
43
+ }
44
+
45
+ export interface NavLink {
46
+ label: string
47
+ to: string
48
+ }
49
+
50
+ export interface FooterConfig {
51
+ about?: string
52
+ contact?: { phone?: string; email?: string; address?: string }
53
+ social?: Array<{ label: string; href: string }>
54
+ }
55
+
56
+ /** Wide gradient SVG data-URI — deterministic hero/banner art, no network. */
57
+ export function bannerPlaceholder(label: string, hueA: number, hueB: number, w = 1600, h = 700): string {
58
+ const svg =
59
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}">` +
60
+ `<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">` +
61
+ `<stop offset="0" stop-color="hsl(${hueA},40%,78%)"/>` +
62
+ `<stop offset="1" stop-color="hsl(${hueB},45%,55%)"/>` +
63
+ `</linearGradient></defs>` +
64
+ `<rect width="${w}" height="${h}" fill="url(#g)"/>` +
65
+ `<circle cx="${w * 0.8}" cy="${h * 0.3}" r="${h * 0.45}" fill="hsla(${hueB},50%,90%,0.25)"/>` +
66
+ `<circle cx="${w * 0.15}" cy="${h * 0.85}" r="${h * 0.35}" fill="hsla(${hueA},45%,30%,0.12)"/>` +
67
+ `</svg>`
68
+ return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`
69
+ }
@@ -0,0 +1,20 @@
1
+ import { TID } from './testids'
2
+
3
+ /**
4
+ * Stable selectors that custom storefront slots must preserve.
5
+ *
6
+ * Apps can fully customize the visual tree, but catalog/product-rail slots still
7
+ * need these anchors so checkout smoke tests, QA, and agents can operate stores.
8
+ */
9
+ export const productCardSlotContract = {
10
+ root: { 'data-testid': TID.productCard },
11
+ name: { 'data-testid': TID.productCardName },
12
+ priceTestId: TID.productCardPrice,
13
+ addButton: { 'data-testid': TID.productCardAdd },
14
+ } as const
15
+
16
+ export const storefrontSlotContracts = {
17
+ productCard: productCardSlotContract,
18
+ } as const
19
+
20
+ export type ProductCardSlotContract = typeof productCardSlotContract
@@ -0,0 +1,149 @@
1
+ import { create } from 'zustand'
2
+ import { persist } from 'zustand/middleware'
3
+ import type { Product } from '@fayz-ai/shop/types'
4
+ import { roundCents } from '../format'
5
+ import type { ResolvedStorefrontConfig } from '../config'
6
+ import {
7
+ formatProductOptionSelection,
8
+ normalizeProductOptionSelection,
9
+ productOptionSelectionKey,
10
+ type ProductOptionSelection,
11
+ } from '../product-options'
12
+
13
+ export interface CartLine {
14
+ lineId: string
15
+ productId: string
16
+ slug: string
17
+ name: string
18
+ sku: string | null
19
+ options: ProductOptionSelection
20
+ optionsLabel: string
21
+ unitPrice: number
22
+ quantity: number
23
+ imageUrl: string | null
24
+ /** inventoryCount at add time — clamps quantity */
25
+ maxQuantity: number
26
+ }
27
+
28
+ export interface CartState {
29
+ lines: CartLine[]
30
+ discountCode: string | null
31
+ discountPercent: number
32
+ isOpen: boolean
33
+ /** Line most recently added — lets the drawer briefly highlight it. */
34
+ justAddedLineId: string | null
35
+ addItem(product: Product, qty?: number, options?: ProductOptionSelection): void
36
+ setQuantity(lineId: string, qty: number): void
37
+ removeItem(lineId: string): void
38
+ applyDiscount(code: string, percent: number): void
39
+ clearDiscount(): void
40
+ clear(): void
41
+ openDrawer(): void
42
+ closeDrawer(): void
43
+ }
44
+
45
+ export const useCartStore = create<CartState>()(
46
+ persist(
47
+ (set) => ({
48
+ lines: [],
49
+ discountCode: null,
50
+ discountPercent: 0,
51
+ isOpen: false,
52
+ justAddedLineId: null,
53
+
54
+ addItem: (product, qty = 1, options) =>
55
+ set((state) => {
56
+ const normalizedOptions = normalizeProductOptionSelection(options)
57
+ const optionKey = productOptionSelectionKey(normalizedOptions)
58
+ const lineId = optionKey ? `${product.id}:${optionKey}` : product.id
59
+ const existing = state.lines.find((l) => (l.lineId ?? l.productId) === lineId)
60
+ if (existing) {
61
+ const nextQty = Math.min(existing.quantity + qty, existing.maxQuantity || Infinity)
62
+ return {
63
+ // Auto-open the cart on add — the standard premium "slide-in cart"
64
+ // signal — and flag the line so the drawer can highlight it.
65
+ isOpen: true,
66
+ justAddedLineId: lineId,
67
+ lines: state.lines.map((l) =>
68
+ (l.lineId ?? l.productId) === lineId ? { ...l, lineId, quantity: nextQty } : l,
69
+ ),
70
+ }
71
+ }
72
+ const image = product.images.find((i) => i.isPrimary) ?? product.images[0]
73
+ const line: CartLine = {
74
+ lineId,
75
+ productId: product.id,
76
+ slug: product.slug,
77
+ name: product.name,
78
+ sku: product.sku,
79
+ options: normalizedOptions,
80
+ optionsLabel: formatProductOptionSelection(normalizedOptions),
81
+ unitPrice: product.price,
82
+ quantity: Math.min(qty, product.inventoryCount || Infinity),
83
+ imageUrl: image?.url ?? null,
84
+ maxQuantity: product.inventoryCount,
85
+ }
86
+ return { isOpen: true, justAddedLineId: lineId, lines: [...state.lines, line] }
87
+ }),
88
+
89
+ setQuantity: (lineId, qty) =>
90
+ set((state) => {
91
+ if (qty <= 0) return { lines: state.lines.filter((l) => (l.lineId ?? l.productId) !== lineId) }
92
+ return {
93
+ lines: state.lines.map((l) =>
94
+ (l.lineId ?? l.productId) === lineId
95
+ ? { ...l, quantity: Math.min(qty, l.maxQuantity || Infinity) }
96
+ : l,
97
+ ),
98
+ }
99
+ }),
100
+
101
+ removeItem: (lineId) =>
102
+ set((state) => ({ lines: state.lines.filter((l) => (l.lineId ?? l.productId) !== lineId) })),
103
+
104
+ applyDiscount: (code, percent) => set({ discountCode: code, discountPercent: percent }),
105
+ clearDiscount: () => set({ discountCode: null, discountPercent: 0 }),
106
+ clear: () => set({ lines: [], discountCode: null, discountPercent: 0 }),
107
+ openDrawer: () => set({ isOpen: true }),
108
+ closeDrawer: () => set({ isOpen: false, justAddedLineId: null }),
109
+ }),
110
+ {
111
+ name: 'fayz.storefront.cart.v1',
112
+ partialize: (state) => ({
113
+ lines: state.lines,
114
+ discountCode: state.discountCode,
115
+ discountPercent: state.discountPercent,
116
+ }),
117
+ },
118
+ ),
119
+ )
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Money selectors — the single place cart math happens. Checkout passes these
123
+ // exact values to createOrder so drawer totals always match order totals.
124
+ // ---------------------------------------------------------------------------
125
+
126
+ export const selectCount = (s: Pick<CartState, 'lines'>): number =>
127
+ s.lines.reduce((n, l) => n + l.quantity, 0)
128
+
129
+ export const selectSubtotal = (s: Pick<CartState, 'lines'>): number =>
130
+ roundCents(s.lines.reduce((sum, l) => sum + l.unitPrice * l.quantity, 0))
131
+
132
+ export const selectDiscountTotal = (s: Pick<CartState, 'lines' | 'discountPercent'>): number =>
133
+ roundCents(selectSubtotal(s) * (s.discountPercent / 100))
134
+
135
+ export const selectShipping = (
136
+ s: Pick<CartState, 'lines'>,
137
+ cfg: ResolvedStorefrontConfig,
138
+ ): number => {
139
+ if (s.lines.length === 0) return 0
140
+ const subtotal = selectSubtotal(s)
141
+ if (cfg.shipping.freeAbove != null && subtotal >= cfg.shipping.freeAbove) return 0
142
+ return cfg.shipping.flatRate
143
+ }
144
+
145
+ export const selectTotal = (
146
+ s: Pick<CartState, 'lines' | 'discountPercent'>,
147
+ cfg: ResolvedStorefrontConfig,
148
+ ): number =>
149
+ roundCents(selectSubtotal(s) - selectDiscountTotal(s) + selectShipping(s, cfg))
@@ -0,0 +1,39 @@
1
+ import { create } from 'zustand'
2
+
3
+ export type CatalogSort = 'newest' | 'price-asc' | 'price-desc' | 'name'
4
+
5
+ export interface CatalogState {
6
+ search: string
7
+ categoryId: string | null
8
+ priceMin: number | null
9
+ priceMax: number | null
10
+ inStockOnly: boolean
11
+ sort: CatalogSort
12
+ setSearch(search: string): void
13
+ setCategoryId(categoryId: string | null): void
14
+ setPriceMin(priceMin: number | null): void
15
+ setPriceMax(priceMax: number | null): void
16
+ setInStockOnly(inStockOnly: boolean): void
17
+ setSort(sort: CatalogSort): void
18
+ reset(): void
19
+ }
20
+
21
+ const initial = {
22
+ search: '',
23
+ categoryId: null,
24
+ priceMin: null,
25
+ priceMax: null,
26
+ inStockOnly: false,
27
+ sort: 'newest' as CatalogSort,
28
+ }
29
+
30
+ export const useCatalogStore = create<CatalogState>()((set) => ({
31
+ ...initial,
32
+ setSearch: (search) => set({ search }),
33
+ setCategoryId: (categoryId) => set({ categoryId }),
34
+ setPriceMin: (priceMin) => set({ priceMin }),
35
+ setPriceMax: (priceMax) => set({ priceMax }),
36
+ setInStockOnly: (inStockOnly) => set({ inStockOnly }),
37
+ setSort: (sort) => set({ sort }),
38
+ reset: () => set(initial),
39
+ }))
@@ -0,0 +1,28 @@
1
+ import { create } from 'zustand'
2
+ import { persist } from 'zustand/middleware'
3
+
4
+ // Customer session state. Identity is established through the shared
5
+ // @fayz-ai/auth AuthAdapter (see ../auth.ts) — this store only mirrors the
6
+ // linked ShopCustomer for synchronous UI access.
7
+
8
+ export interface SessionState {
9
+ customerId: string | null
10
+ email: string | null
11
+ name: string | null
12
+ setSession(session: { customerId: string; email: string; name?: string | null }): void
13
+ signOut(): void
14
+ }
15
+
16
+ export const useSessionStore = create<SessionState>()(
17
+ persist(
18
+ (set) => ({
19
+ customerId: null,
20
+ email: null,
21
+ name: null,
22
+ setSession: ({ customerId, email, name }) =>
23
+ set({ customerId, email, name: name ?? null }),
24
+ signOut: () => set({ customerId: null, email: null, name: null }),
25
+ }),
26
+ { name: 'fayz.storefront.session.v1' },
27
+ ),
28
+ )
@@ -0,0 +1,43 @@
1
+ import { create } from 'zustand'
2
+
3
+ export type ToastVariant = 'success' | 'error' | 'info'
4
+
5
+ export interface Toast {
6
+ id: string
7
+ title: string
8
+ description?: string
9
+ variant: ToastVariant
10
+ durationMs: number
11
+ }
12
+
13
+ interface ToastState {
14
+ toasts: Toast[]
15
+ push(input: { title: string; description?: string; variant?: ToastVariant; durationMs?: number }): string
16
+ dismiss(id: string): void
17
+ }
18
+
19
+ let seq = 0
20
+
21
+ export const useToastStore = create<ToastState>((set) => ({
22
+ toasts: [],
23
+ push: (input) => {
24
+ const id = `toast-${++seq}-${Date.now()}`
25
+ const toast: Toast = {
26
+ id,
27
+ title: input.title,
28
+ description: input.description,
29
+ variant: input.variant ?? 'info',
30
+ durationMs: input.durationMs ?? 3400,
31
+ }
32
+ set((s) => ({ toasts: [...s.toasts, toast] }))
33
+ return id
34
+ },
35
+ dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
36
+ }))
37
+
38
+ /** Imperative toast API — callable from components, store actions, or workflows. */
39
+ export const toast = {
40
+ success: (title: string, description?: string) => useToastStore.getState().push({ title, description, variant: 'success' }),
41
+ error: (title: string, description?: string) => useToastStore.getState().push({ title, description, variant: 'error' }),
42
+ info: (title: string, description?: string) => useToastStore.getState().push({ title, description, variant: 'info' }),
43
+ }
package/src/testids.ts ADDED
@@ -0,0 +1,103 @@
1
+ // Single source of truth for e2e selectors. Components import from here;
2
+ // Playwright specs assert against the same constants — they cannot drift.
3
+ export const TID = {
4
+ // header
5
+ headerSearch: 'header-search',
6
+ cartButton: 'cart-button',
7
+ cartCount: 'cart-count',
8
+ accountLink: 'account-link',
9
+ accountDropdown: 'account-dropdown',
10
+ announcementBar: 'announcement-bar',
11
+ navLink: (label: string) => `nav-${label.toLowerCase().replace(/\s+/g, '-')}`,
12
+ navCategory: (slug: string) => `nav-cat-${slug}`,
13
+ accountName: 'account-name',
14
+ // home sections
15
+ hero: 'home-hero',
16
+ heroPrev: 'hero-prev',
17
+ heroNext: 'hero-next',
18
+ categoryShowcase: 'category-showcase',
19
+ productRail: 'product-rail',
20
+ benefits: 'benefits-row',
21
+ promoBanner: 'promo-banner',
22
+ manifesto: 'manifesto',
23
+ testimonials: 'testimonials',
24
+ newsletter: 'newsletter',
25
+ newsletterEmail: 'newsletter-email',
26
+ newsletterSubmit: 'newsletter-submit',
27
+ newsletterDone: 'newsletter-done',
28
+ // catalog
29
+ productGrid: 'product-grid',
30
+ productCard: 'product-card',
31
+ productCardName: 'product-card-name',
32
+ productCardPrice: 'product-card-price',
33
+ productCardAdd: 'product-card-add',
34
+ badgeSale: 'badge-sale',
35
+ badgeSoldout: 'badge-soldout',
36
+ // filters
37
+ filterCategory: (slug: string) => `filter-category-${slug}`,
38
+ filterPriceMin: 'filter-price-min',
39
+ filterPriceMax: 'filter-price-max',
40
+ filterInstock: 'filter-instock',
41
+ sortSelect: 'sort-select',
42
+ filtersClear: 'filters-clear',
43
+ // product detail
44
+ pdpName: 'pdp-name',
45
+ pdpPrice: 'pdp-price',
46
+ pdpComparePrice: 'pdp-compare-price',
47
+ pdpDescription: 'pdp-description',
48
+ productOptionGroup: (id: string) => `product-option-${id.toLowerCase().replace(/\s+/g, '-')}`,
49
+ productOptionValue: (id: string, value: string) => `product-option-${id.toLowerCase().replace(/\s+/g, '-')}-${value.toLowerCase().replace(/\s+/g, '-')}`,
50
+ pdpQtyInput: 'pdp-qty-input',
51
+ pdpAddToCart: 'pdp-add-to-cart',
52
+ // cart
53
+ cartDrawer: 'cart-drawer',
54
+ cartLine: 'cart-line',
55
+ lineQty: 'line-qty',
56
+ lineInc: 'line-inc',
57
+ lineDec: 'line-dec',
58
+ lineRemove: 'line-remove',
59
+ cartSubtotal: 'cart-subtotal',
60
+ discountInput: 'discount-input',
61
+ discountApply: 'discount-apply',
62
+ discountRow: 'discount-row',
63
+ discountError: 'discount-error',
64
+ cartShipping: 'cart-shipping',
65
+ shippingProgress: 'shipping-progress',
66
+ cartTotal: 'cart-total',
67
+ goCheckout: 'go-checkout',
68
+ cartEmpty: 'cart-empty',
69
+ // checkout
70
+ checkoutEmail: 'checkout-email',
71
+ checkoutName: 'checkout-name',
72
+ checkoutStreet: 'checkout-street',
73
+ checkoutCity: 'checkout-city',
74
+ checkoutZip: 'checkout-zip',
75
+ checkoutCard: 'checkout-card',
76
+ checkoutExpiry: 'checkout-expiry',
77
+ checkoutCvc: 'checkout-cvc',
78
+ placeOrder: 'place-order',
79
+ checkoutError: 'checkout-error',
80
+ checkoutProcessing: 'checkout-processing',
81
+ // confirmation
82
+ orderNumber: 'order-number',
83
+ orderStatus: 'order-status',
84
+ orderTracking: 'order-tracking',
85
+ orderTrackingStep: (step: string) => `order-tracking-step-${step}`,
86
+ orderTotal: 'order-total',
87
+ viewPurchases: 'view-purchases',
88
+ // account
89
+ customerAccountShell: 'customer-account-shell',
90
+ customerAccountMenu: 'customer-account-menu',
91
+ orderDetail: 'order-detail',
92
+ authTabSignin: 'auth-tab-signin',
93
+ authTabSignup: 'auth-tab-signup',
94
+ signinEmail: 'signin-email',
95
+ signinPassword: 'signin-password',
96
+ signinName: 'signin-name',
97
+ signinSubmit: 'signin-submit',
98
+ authError: 'auth-error',
99
+ purchasesList: 'purchases-list',
100
+ purchaseItem: 'purchase-item',
101
+ purchasesEmpty: 'purchases-empty',
102
+ signout: 'signout',
103
+ } as const
package/src/theme.ts ADDED
@@ -0,0 +1,160 @@
1
+ import React, { useEffect } from 'react'
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // StorefrontTheme — the storefront counterpart of SaasTheme.
5
+ // Personalizes colors (HSL token triplets), typography (Google Fonts pairing),
6
+ // radius and layout properties via CSS custom properties, so every @fayz-ai/ui
7
+ // token class (bg-background, text-primary, ...) picks the brand up for free.
8
+ //
9
+ // Derived from the Nuvemshop template research (docs/storefront-templates-research.md):
10
+ // the personalization axes are palette, font pairing, radius, header treatment,
11
+ // hero style and product-card style.
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export type StorefrontRadius = 'none' | 'soft' | 'round' | 'pill'
15
+ export type HeaderVariant = 'classic' | 'centered' | 'search' | 'minimal'
16
+ export type HeroVariant = 'banner' | 'split' | 'slider'
17
+ export type CardStyle = 'card' | 'editorial'
18
+
19
+ export interface StorefrontThemeColors {
20
+ /** HSL triplets, e.g. '40 20% 94%' — same convention as SaasTheme */
21
+ background?: string
22
+ foreground?: string
23
+ primary?: string
24
+ primaryForeground?: string
25
+ card?: string
26
+ cardForeground?: string
27
+ muted?: string
28
+ mutedForeground?: string
29
+ border?: string
30
+ /** Header treatment — dark headers (Brasília) set these */
31
+ headerBackground?: string
32
+ headerForeground?: string
33
+ /** Announcement bar */
34
+ announcementBackground?: string
35
+ announcementForeground?: string
36
+ }
37
+
38
+ export interface StorefrontThemeFont {
39
+ /** Display font for headings, e.g. 'Marcellus' */
40
+ heading: string
41
+ /** Body font, e.g. 'Rubik' */
42
+ body: string
43
+ /** Google Fonts families to load (defaults to heading+body) */
44
+ googleFonts?: string[]
45
+ /** Fallback stack — default sans-serif; use 'serif' for editorial themes */
46
+ fallback?: 'sans-serif' | 'serif'
47
+ }
48
+
49
+ export interface StorefrontTheme {
50
+ name: string
51
+ colors?: StorefrontThemeColors
52
+ font?: StorefrontThemeFont
53
+ radius?: StorefrontRadius
54
+ /** Layout personality */
55
+ header?: {
56
+ variant?: HeaderVariant
57
+ /** Uppercase nav links (premium retail tone) */
58
+ uppercaseNav?: boolean
59
+ }
60
+ productCard?: {
61
+ style?: CardStyle
62
+ imageAspect?: 'square' | 'portrait'
63
+ }
64
+ /** Uppercase CTA buttons with letter-spacing (Uyuni/Flex tone) */
65
+ uppercaseButtons?: boolean
66
+ }
67
+
68
+ const RADIUS_MAP: Record<StorefrontRadius, { button: string; card: string; input: string }> = {
69
+ none: { button: '0px', card: '0px', input: '0px' },
70
+ soft: { button: '6px', card: '10px', input: '6px' },
71
+ round: { button: '10px', card: '16px', input: '10px' },
72
+ pill: { button: '999px', card: '20px', input: '999px' },
73
+ }
74
+
75
+ const COLOR_VAR_MAP: Record<keyof StorefrontThemeColors, string> = {
76
+ background: '--background',
77
+ foreground: '--foreground',
78
+ primary: '--primary',
79
+ primaryForeground: '--primary-foreground',
80
+ card: '--card',
81
+ cardForeground: '--card-foreground',
82
+ muted: '--muted',
83
+ mutedForeground: '--muted-foreground',
84
+ border: '--border',
85
+ headerBackground: '--sf-header-bg',
86
+ headerForeground: '--sf-header-fg',
87
+ announcementBackground: '--sf-announcement-bg',
88
+ announcementForeground: '--sf-announcement-fg',
89
+ }
90
+
91
+ export function themeToCss(theme: StorefrontTheme): string {
92
+ const lines: string[] = []
93
+
94
+ for (const [key, cssVar] of Object.entries(COLOR_VAR_MAP) as [keyof StorefrontThemeColors, string][]) {
95
+ const value = theme.colors?.[key]
96
+ if (value) lines.push(`${cssVar}: ${value};`)
97
+ }
98
+ // Header defaults to the page background unless the theme sets one
99
+ if (!theme.colors?.headerBackground && theme.colors?.background) {
100
+ lines.push(`--sf-header-bg: ${theme.colors.background};`)
101
+ }
102
+ if (!theme.colors?.headerForeground && theme.colors?.foreground) {
103
+ lines.push(`--sf-header-fg: ${theme.colors.foreground};`)
104
+ }
105
+
106
+ if (theme.font) {
107
+ const fallback = theme.font.fallback ?? 'sans-serif'
108
+ lines.push(`--font-family: '${theme.font.body}', ${fallback};`)
109
+ lines.push(`--sf-font-heading: '${theme.font.heading}', ${fallback};`)
110
+ }
111
+
112
+ const radius = RADIUS_MAP[theme.radius ?? 'soft']
113
+ lines.push(`--button-radius: ${radius.button};`)
114
+ lines.push(`--card-radius: ${radius.card};`)
115
+ lines.push(`--input-radius: ${radius.input};`)
116
+ lines.push(`--sf-radius-button: ${radius.button};`)
117
+ lines.push(`--sf-radius-card: ${radius.card};`)
118
+ lines.push(`--sf-radius-input: ${radius.input};`)
119
+
120
+ let css = `:root {\n ${lines.join('\n ')}\n}\n`
121
+ css += `body { font-family: var(--font-family); }\n`
122
+ css += `h1, h2, h3, h4, .sf-heading { font-family: var(--sf-font-heading, var(--font-family)); }\n`
123
+ if (theme.uppercaseButtons) {
124
+ css += `.sf-cta { text-transform: uppercase; letter-spacing: 0.08em; }\n`
125
+ }
126
+ if (theme.header?.uppercaseNav) {
127
+ css += `.sf-nav-link { text-transform: uppercase; letter-spacing: 0.06em; font-size: 0.8rem; }\n`
128
+ }
129
+ return css
130
+ }
131
+
132
+ function googleFontsHref(theme: StorefrontTheme): string | null {
133
+ if (!theme.font) return null
134
+ const families = theme.font.googleFonts ?? [theme.font.heading, theme.font.body]
135
+ const unique = [...new Set(families)]
136
+ const params = unique
137
+ .map((f) => `family=${encodeURIComponent(f).replace(/%20/g, '+')}:wght@300;400;500;600;700`)
138
+ .join('&')
139
+ return `https://fonts.googleapis.com/css2?${params}&display=swap`
140
+ }
141
+
142
+ /** Injects theme CSS variables + Google Fonts. Mount once inside the app root. */
143
+ export function StorefrontThemeStyle({ theme }: { theme: StorefrontTheme }) {
144
+ const fontsHref = googleFontsHref(theme)
145
+
146
+ useEffect(() => {
147
+ if (!fontsHref) return
148
+ const id = 'sf-theme-fonts'
149
+ let link = document.getElementById(id) as HTMLLinkElement | null
150
+ if (!link) {
151
+ link = document.createElement('link')
152
+ link.id = id
153
+ link.rel = 'stylesheet'
154
+ document.head.appendChild(link)
155
+ }
156
+ link.href = fontsHref
157
+ }, [fontsHref])
158
+
159
+ return React.createElement('style', { 'data-sf-theme': theme.name }, themeToCss(theme))
160
+ }