@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,184 @@
1
+ import React, { useEffect, useState } from 'react'
2
+ import { ChevronLeft, RefreshCcw, ShieldCheck, Truck } from 'lucide-react'
3
+ import { useProduct } from '../hooks/useProduct'
4
+ import { useCartStore } from '../stores/cart.store'
5
+ import { useStorefrontConfig } from '../config'
6
+ import { useStorefrontHead } from '../hooks/useStorefrontHead'
7
+ import { Link } from '../router'
8
+ import { Price } from '../components/Price'
9
+ import { QuantityInput } from '../components/QuantityInput'
10
+ import { ProductOptionSelector } from '../components/ProductOptionSelector'
11
+ import { ProductSpecs } from '../components/ProductSpecs'
12
+ import { RelatedProducts } from '../components/RelatedProducts'
13
+ import { getProductOptionGroups, type ProductOptionSelection } from '../product-options'
14
+ import { TID } from '../testids'
15
+
16
+ export function ProductDetailPage({ slug }: { slug: string }) {
17
+ const config = useStorefrontConfig()
18
+ const { product, loading } = useProduct(slug)
19
+ const addItem = useCartStore((s) => s.addItem)
20
+ const [qty, setQty] = useState(1)
21
+ const [selectedOptions, setSelectedOptions] = useState<ProductOptionSelection>({})
22
+
23
+ const optionGroups = product ? getProductOptionGroups(product) : []
24
+ useStorefrontHead({
25
+ title: product ? `${product.name} — ${config.name}` : config.name,
26
+ description: product?.description,
27
+ })
28
+
29
+ useEffect(() => {
30
+ if (!product) return
31
+ const defaults = Object.fromEntries(
32
+ getProductOptionGroups(product).map((group) => [group.label, group.values[0] ?? '']),
33
+ )
34
+ setSelectedOptions(defaults)
35
+ }, [product?.id])
36
+
37
+ if (loading) {
38
+ return (
39
+ <main className="mx-auto max-w-5xl px-4 py-8 sm:px-6">
40
+ <div className="grid animate-pulse gap-10 md:grid-cols-2">
41
+ <div className="aspect-square rounded-2xl bg-muted" />
42
+ <div className="space-y-4 py-4">
43
+ <div className="h-8 w-3/4 rounded bg-muted" />
44
+ <div className="h-6 w-1/3 rounded bg-muted" />
45
+ <div className="h-24 rounded bg-muted" />
46
+ </div>
47
+ </div>
48
+ </main>
49
+ )
50
+ }
51
+
52
+ if (!product) {
53
+ return (
54
+ <main className="mx-auto max-w-5xl px-4 py-20 text-center sm:px-6">
55
+ <p className="text-lg text-muted-foreground">Produto não encontrado.</p>
56
+ <Link to="/" className="mt-4 inline-block text-primary underline">Voltar à loja</Link>
57
+ </main>
58
+ )
59
+ }
60
+
61
+ const soldOut = product.inventoryCount <= 0
62
+ const lowStock = !soldOut && product.inventoryCount <= 5
63
+ const image = product.images.find((i) => i.isPrimary) ?? product.images[0]
64
+ const discountPct =
65
+ product.compareAtPrice && product.compareAtPrice > product.price
66
+ ? Math.round((1 - product.price / product.compareAtPrice) * 100)
67
+ : 0
68
+
69
+ return (
70
+ <main className="mx-auto max-w-5xl px-4 py-8 sm:px-6">
71
+ <Link to="/" className="mb-6 inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
72
+ <ChevronLeft className="h-4 w-4" /> Continuar comprando
73
+ </Link>
74
+
75
+ <div className="grid animate-fade-up gap-10 md:grid-cols-2">
76
+ <div className="group overflow-hidden border bg-muted" style={{ borderRadius: 'var(--sf-radius-card)' }}>
77
+ {image && (
78
+ <img
79
+ src={image.url}
80
+ alt={image.altText ?? product.name}
81
+ className="aspect-square w-full object-cover transition-transform duration-700 ease-out group-hover:scale-105"
82
+ />
83
+ )}
84
+ </div>
85
+
86
+ <div className="flex flex-col py-2 lg:sticky lg:top-24 lg:self-start">
87
+ <nav className="mb-3 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
88
+ <Link to="/" className="transition-colors hover:text-foreground">Início</Link>
89
+ {product.categoryName && (
90
+ <>
91
+ <span aria-hidden>/</span>
92
+ <span>{product.categoryName}</span>
93
+ </>
94
+ )}
95
+ <span aria-hidden>/</span>
96
+ <span className="text-foreground">{product.name}</span>
97
+ </nav>
98
+
99
+ <h1 data-testid={TID.pdpName} className="sf-heading text-3xl font-bold tracking-tight">
100
+ {product.name}
101
+ </h1>
102
+
103
+ <div className="mt-3 flex items-center gap-3">
104
+ <div className="text-xl">
105
+ <Price
106
+ value={product.price}
107
+ compareAt={product.compareAtPrice}
108
+ testId={TID.pdpPrice}
109
+ compareTestId={TID.pdpComparePrice}
110
+ />
111
+ </div>
112
+ {discountPct > 0 && (
113
+ <span className="rounded-full bg-primary/10 px-2.5 py-1 text-xs font-bold text-primary">
114
+ −{discountPct}%
115
+ </span>
116
+ )}
117
+ </div>
118
+
119
+ <p data-testid={TID.pdpDescription} className="mt-5 leading-relaxed text-muted-foreground">
120
+ {product.description}
121
+ </p>
122
+
123
+ <div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
124
+ {soldOut ? (
125
+ <span className="inline-flex items-center gap-2 font-medium text-muted-foreground">
126
+ <span className="h-2 w-2 rounded-full bg-muted-foreground" /> Sem estoque
127
+ </span>
128
+ ) : lowStock ? (
129
+ <span className="inline-flex items-center gap-2 font-semibold text-amber-600">
130
+ <span className="h-2 w-2 animate-pulse rounded-full bg-amber-500" />
131
+ Últimas unidades — só {product.inventoryCount} restantes
132
+ </span>
133
+ ) : (
134
+ <span className="inline-flex items-center gap-2 font-medium text-emerald-600">
135
+ <span className="h-2 w-2 rounded-full bg-emerald-500" /> Em estoque
136
+ </span>
137
+ )}
138
+ {product.sku && <span className="text-muted-foreground">SKU: {product.sku}</span>}
139
+ </div>
140
+
141
+ <ProductOptionSelector
142
+ groups={optionGroups}
143
+ value={selectedOptions}
144
+ onChange={setSelectedOptions}
145
+ />
146
+
147
+ <div className="mt-8 flex items-center gap-4">
148
+ <QuantityInput
149
+ value={qty}
150
+ onChange={setQty}
151
+ max={soldOut ? 1 : product.inventoryCount}
152
+ testId={TID.pdpQtyInput}
153
+ />
154
+ <button
155
+ type="button"
156
+ data-testid={TID.pdpAddToCart}
157
+ disabled={soldOut}
158
+ onClick={() => addItem(product, qty, selectedOptions)}
159
+ className="sf-cta flex-1 bg-primary py-3.5 font-semibold text-primary-foreground shadow-md transition-all hover:-translate-y-0.5 hover:shadow-lg disabled:cursor-not-allowed disabled:opacity-40 disabled:shadow-none disabled:hover:translate-y-0"
160
+ style={{ borderRadius: 'var(--sf-radius-button)' }}
161
+ >
162
+ {soldOut ? 'Esgotado' : 'Adicionar ao carrinho'}
163
+ </button>
164
+ </div>
165
+
166
+ <div className="mt-6 grid grid-cols-3 gap-3 border-t pt-6 text-center">
167
+ <div className="flex flex-col items-center gap-1.5 text-[11px] font-medium text-muted-foreground">
168
+ <Truck className="h-5 w-5 text-primary" /> Envio rápido
169
+ </div>
170
+ <div className="flex flex-col items-center gap-1.5 text-[11px] font-medium text-muted-foreground">
171
+ <RefreshCcw className="h-5 w-5 text-primary" /> Troca fácil
172
+ </div>
173
+ <div className="flex flex-col items-center gap-1.5 text-[11px] font-medium text-muted-foreground">
174
+ <ShieldCheck className="h-5 w-5 text-primary" /> Pagamento seguro
175
+ </div>
176
+ </div>
177
+ </div>
178
+ </div>
179
+
180
+ <ProductSpecs product={product} />
181
+ <RelatedProducts product={product} />
182
+ </main>
183
+ )
184
+ }
package/src/presets.ts ADDED
@@ -0,0 +1,258 @@
1
+ import type { StorefrontTheme } from './theme'
2
+ import type { HomeConfig } from './sections'
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Template presets — recreations of the four Nuvemshop patterns selected in
6
+ // docs/storefront-templates-research.md. Each is { theme, home() } so an app
7
+ // (or the Fayz agent) starts from a complete professional store and overrides
8
+ // copy, colors, hero images and sections — the saas-core theming workflow.
9
+ // ---------------------------------------------------------------------------
10
+
11
+ /** Real imagery for a template home — resolved at build time (e.g. via
12
+ * scripts/fetch-unsplash.mjs); falls back to gradient placeholders when omitted. */
13
+ export interface TemplateImages {
14
+ hero?: Array<string | undefined>
15
+ banners?: Array<string | undefined>
16
+ }
17
+
18
+ export interface StorefrontTemplate {
19
+ id: string
20
+ /** The Nuvemshop pattern this recreates */
21
+ inspiration: string
22
+ theme: StorefrontTheme
23
+ /** Default home blueprint — pass the store name for copy interpolation */
24
+ home: (storeName: string, images?: TemplateImages) => HomeConfig
25
+ announcement?: string
26
+ }
27
+
28
+ /** maré — elegant fashion (Rio): airy warm paper, olive ink, sand CTA, centered logo. */
29
+ export const mareTemplate: StorefrontTemplate = {
30
+ id: 'mare',
31
+ inspiration: 'Nuvemshop Rio',
32
+ announcement: 'FRETE GRÁTIS EM COMPRAS ACIMA DE R$ 300',
33
+ theme: {
34
+ name: 'mare',
35
+ colors: {
36
+ background: '40 14% 93%',
37
+ foreground: '90 8% 38%',
38
+ primary: '42 32% 61%',
39
+ primaryForeground: '40 30% 12%',
40
+ card: '40 20% 97%',
41
+ cardForeground: '90 10% 25%',
42
+ muted: '40 12% 88%',
43
+ mutedForeground: '90 6% 50%',
44
+ border: '40 10% 82%',
45
+ announcementBackground: '90 10% 30%',
46
+ announcementForeground: '40 20% 95%',
47
+ },
48
+ font: { heading: 'Plus Jakarta Sans', body: 'Plus Jakarta Sans' },
49
+ radius: 'soft',
50
+ header: { variant: 'centered' },
51
+ productCard: { style: 'editorial', imageAspect: 'portrait' },
52
+ },
53
+ home: (storeName, images) => ({
54
+ sections: [
55
+ {
56
+ type: 'hero',
57
+ variant: 'banner',
58
+ height: 'tall',
59
+ slides: [
60
+ { title: 'Feito para durar', subtitle: `A nova coleção ${storeName} chegou — peças atemporais em materiais honestos.`, cta: 'Ver coleção', hue: 75, image: images?.hero?.[0] },
61
+ ],
62
+ },
63
+ { type: 'categories', style: 'bubbles' },
64
+ { type: 'products', title: 'Destaques', filter: 'all', limit: 4 },
65
+ { type: 'banner', title: 'Até 30% off', eyebrow: 'por tempo limitado', subtitle: 'Peças selecionadas da estação', cta: 'Aproveitar', hue: 40, image: images?.banners?.[0] },
66
+ { type: 'products', title: 'Promoções', filter: 'sale', limit: 4 },
67
+ { type: 'products', title: 'Lançamentos', filter: 'new', limit: 4 },
68
+ { type: 'newsletter', title: 'Assine nossa newsletter', subtitle: 'Novidades e ofertas exclusivas, sem spam.' },
69
+ ],
70
+ }),
71
+ }
72
+
73
+ /** sertão — editorial organic (Uyuni): cream paper, coffee serif ink, boxed hero, manifesto. */
74
+ export const sertaoTemplate: StorefrontTemplate = {
75
+ id: 'sertao',
76
+ inspiration: 'Nuvemshop Uyuni',
77
+ theme: {
78
+ name: 'sertao',
79
+ colors: {
80
+ background: '30 100% 98%',
81
+ foreground: '28 42% 32%',
82
+ primary: '28 42% 32%',
83
+ primaryForeground: '30 100% 97%',
84
+ card: '32 60% 96%',
85
+ cardForeground: '28 40% 26%',
86
+ muted: '32 40% 92%',
87
+ mutedForeground: '28 22% 48%',
88
+ border: '28 30% 80%',
89
+ },
90
+ font: { heading: 'Marcellus', body: 'Marcellus', fallback: 'serif' },
91
+ radius: 'none',
92
+ header: { variant: 'minimal', uppercaseNav: true },
93
+ productCard: { style: 'editorial', imageAspect: 'portrait' },
94
+ uppercaseButtons: true,
95
+ },
96
+ home: (storeName, images) => ({
97
+ sections: [
98
+ {
99
+ type: 'hero',
100
+ variant: 'banner',
101
+ height: 'tall',
102
+ slides: [
103
+ { title: 'Essenciais naturais', subtitle: 'Conheça nossa coleção', cta: 'Ver mais', hue: 28, boxed: true, image: images?.hero?.[0] },
104
+ ],
105
+ },
106
+ {
107
+ type: 'manifesto',
108
+ text: `Num mundo de pressa e excesso, ${storeName} escolhe o caminho lento: ingredientes honestos, produção pequena e cuidado em cada detalhe.`,
109
+ },
110
+ { type: 'categories', style: 'tiles' },
111
+ { type: 'products', title: 'ESSENCIAIS', filter: 'all', limit: 4 },
112
+ {
113
+ type: 'testimonials',
114
+ title: 'Nossos clientes',
115
+ items: [
116
+ { quote: 'Qualidade impecável e um cheiro que abraça.', author: 'Helena M.' },
117
+ { quote: 'Compro há dois anos e nunca me decepcionou.', author: 'Rafael T.' },
118
+ { quote: 'Entrega rápida e embalagem linda, dá gosto de presentear.', author: 'Cláudia P.' },
119
+ ],
120
+ },
121
+ { type: 'products', title: 'NOVIDADES', filter: 'new', limit: 4 },
122
+ {
123
+ type: 'benefits',
124
+ items: [
125
+ { icon: 'Truck', title: 'Frete para todo o Brasil', text: 'Envio em até 48h' },
126
+ { icon: 'Leaf', title: '100% vegano', text: 'Sem testes em animais' },
127
+ { icon: 'RotateCcw', title: 'Troca fácil', text: '30 dias para trocar' },
128
+ ],
129
+ },
130
+ { type: 'newsletter', title: 'NEWSLETTER', subtitle: 'Histórias, lançamentos e cuidado — direto no seu e-mail.' },
131
+ ],
132
+ }),
133
+ }
134
+
135
+ /** volt — bold tech (Brasília): white canvas, near-black ink, vivid CTA, dark search-first header, split hero. */
136
+ export const voltTemplate: StorefrontTemplate = {
137
+ id: 'volt',
138
+ inspiration: 'Nuvemshop Brasília',
139
+ announcement: 'CUPOM BEMVINDO10 — 10% DE DESCONTO NA PRIMEIRA COMPRA',
140
+ theme: {
141
+ name: 'volt',
142
+ colors: {
143
+ background: '0 0% 100%',
144
+ foreground: '0 0% 7%',
145
+ primary: '341 100% 45%',
146
+ primaryForeground: '0 0% 100%',
147
+ card: '0 0% 98%',
148
+ cardForeground: '0 0% 10%',
149
+ muted: '0 0% 94%',
150
+ mutedForeground: '0 0% 40%',
151
+ border: '0 0% 88%',
152
+ headerBackground: '0 0% 6%',
153
+ headerForeground: '0 0% 98%',
154
+ announcementBackground: '341 100% 45%',
155
+ announcementForeground: '0 0% 100%',
156
+ },
157
+ font: { heading: 'Chakra Petch', body: 'Rubik' },
158
+ radius: 'soft',
159
+ header: { variant: 'search', uppercaseNav: true },
160
+ productCard: { style: 'card', imageAspect: 'square' },
161
+ },
162
+ home: (storeName, images) => ({
163
+ sections: [
164
+ {
165
+ type: 'hero',
166
+ variant: 'split',
167
+ slides: [
168
+ { title: 'Tecnologia', subtitle: 'O melhor do hardware e dos acessórios', hue: 250, image: images?.hero?.[0] },
169
+ { title: 'Ofertas', subtitle: `Descontos semanais ${storeName}`, hue: 341, href: '/catalog', image: images?.hero?.[1] },
170
+ ],
171
+ },
172
+ {
173
+ type: 'benefits',
174
+ items: [
175
+ { icon: 'CreditCard', title: '3x sem juros', text: 'Em todo o site' },
176
+ { icon: 'Truck', title: 'Envio em 24h', text: 'Para todo o Brasil' },
177
+ { icon: 'ShieldCheck', title: 'Compra segura', text: 'Seus dados protegidos' },
178
+ ],
179
+ },
180
+ { type: 'products', title: 'NOVIDADES', filter: 'new', limit: 4 },
181
+ { type: 'banner', title: 'DESCONTOS SEMANAIS', eyebrow: 'toda sexta', subtitle: 'Ofertas novas, estoque limitado', cta: 'Conferir', hue: 341, image: images?.banners?.[0] },
182
+ { type: 'products', title: 'PROMOÇÕES', filter: 'sale', limit: 4 },
183
+ { type: 'newsletter', title: 'Entre para o clube', subtitle: 'Cupons e lançamentos antes de todo mundo.' },
184
+ ],
185
+ }),
186
+ }
187
+
188
+ /** atelier — classic premium retail (Flex): white, near-black, gold accent, slider hero, trust badges. */
189
+ export const atelierTemplate: StorefrontTemplate = {
190
+ id: 'atelier',
191
+ inspiration: 'Nuvemshop Flex',
192
+ theme: {
193
+ name: 'atelier',
194
+ colors: {
195
+ background: '0 0% 100%',
196
+ foreground: '0 0% 9%',
197
+ primary: '40 45% 48%',
198
+ primaryForeground: '0 0% 100%',
199
+ card: '0 0% 99%',
200
+ cardForeground: '0 0% 9%',
201
+ muted: '40 10% 95%',
202
+ mutedForeground: '0 0% 42%',
203
+ border: '0 0% 88%',
204
+ announcementBackground: '0 0% 9%',
205
+ announcementForeground: '0 0% 98%',
206
+ },
207
+ font: { heading: 'Outfit', body: 'Outfit' },
208
+ radius: 'none',
209
+ header: { variant: 'centered', uppercaseNav: true },
210
+ productCard: { style: 'card', imageAspect: 'square' },
211
+ uppercaseButtons: true,
212
+ },
213
+ announcement: 'PARCELE EM ATÉ 10X · 5% OFF NO PIX',
214
+ home: (storeName, images) => ({
215
+ sections: [
216
+ {
217
+ type: 'hero',
218
+ variant: 'slider',
219
+ height: 'medium',
220
+ slides: [
221
+ { title: 'Feito para quem valoriza detalhes', subtitle: `Coleção exclusiva ${storeName}`, cta: 'APROVEITE', hue: 40, image: images?.hero?.[0] },
222
+ { title: 'Clássicos que não saem de moda', subtitle: 'Peças autorais, produção limitada', cta: 'VER COLEÇÃO', hue: 210, image: images?.hero?.[1] },
223
+ ],
224
+ },
225
+ {
226
+ type: 'benefits',
227
+ items: [
228
+ { icon: 'CreditCard', title: 'Parcele em até 10x', text: 'Ou 5% off no PIX' },
229
+ { icon: 'Lock', title: 'Site 100% seguro', text: 'Seus dados protegidos' },
230
+ { icon: 'Award', title: 'Peças exclusivas', text: 'Produtos 100% autorais' },
231
+ ],
232
+ },
233
+ { type: 'categories', style: 'tiles' },
234
+ { type: 'products', title: 'Novidades', filter: 'new', limit: 4 },
235
+ { type: 'banner', title: 'Promoções', eyebrow: 'seleção especial', subtitle: 'Selecionados com até 25% off', cta: 'Ver ofertas', hue: 40, image: images?.banners?.[0] },
236
+ { type: 'products', title: 'Promoções', filter: 'sale', limit: 4 },
237
+ {
238
+ type: 'testimonials',
239
+ title: 'O que dizem sobre nós',
240
+ items: [
241
+ { quote: 'Acabamento perfeito, superou a expectativa.', author: 'Mariana C.' },
242
+ { quote: 'Atendimento impecável do pedido à entrega.', author: 'Jorge L.' },
243
+ { quote: 'Virou minha loja favorita para presentes.', author: 'Paula R.' },
244
+ ],
245
+ },
246
+ { type: 'newsletter' },
247
+ ],
248
+ }),
249
+ }
250
+
251
+ export const storefrontTemplates = {
252
+ mare: mareTemplate,
253
+ sertao: sertaoTemplate,
254
+ volt: voltTemplate,
255
+ atelier: atelierTemplate,
256
+ } as const
257
+
258
+ export type StorefrontTemplateId = keyof typeof storefrontTemplates
@@ -0,0 +1,63 @@
1
+ import type { Product } from '@fayz-ai/shop/types'
2
+
3
+ export interface ProductOptionGroup {
4
+ id: string
5
+ label: string
6
+ values: string[]
7
+ }
8
+
9
+ export type ProductOptionSelection = Record<string, string>
10
+
11
+ function cleanValues(value: unknown): string[] {
12
+ if (!Array.isArray(value)) return []
13
+ return Array.from(new Set(value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)))
14
+ }
15
+
16
+ function cleanOptionGroup(value: unknown): ProductOptionGroup | null {
17
+ if (!value || typeof value !== 'object') return null
18
+ const record = value as Record<string, unknown>
19
+ const id = typeof record.id === 'string' ? record.id : typeof record.name === 'string' ? record.name : null
20
+ const label = typeof record.label === 'string' ? record.label : typeof record.name === 'string' ? record.name : id
21
+ const values = cleanValues(record.values)
22
+ if (!id || !label || values.length === 0) return null
23
+ return { id, label, values }
24
+ }
25
+
26
+ export function getProductOptionGroups(product: Product): ProductOptionGroup[] {
27
+ const options = product.metadata.options
28
+ const explicit = Array.isArray(options)
29
+ ? options.map(cleanOptionGroup).filter((group): group is ProductOptionGroup => Boolean(group))
30
+ : []
31
+
32
+ if (explicit.length > 0) return explicit
33
+
34
+ const groups: ProductOptionGroup[] = []
35
+ const sizes = cleanValues(product.metadata.sizes)
36
+ const colors = cleanValues(product.metadata.colors)
37
+
38
+ if (sizes.length > 0) groups.push({ id: 'Tamanho', label: 'Tamanho', values: sizes })
39
+ if (colors.length > 0) groups.push({ id: 'Cor', label: 'Cor', values: colors })
40
+
41
+ return groups
42
+ }
43
+
44
+ export function normalizeProductOptionSelection(selection?: ProductOptionSelection | null): ProductOptionSelection {
45
+ if (!selection) return {}
46
+ return Object.fromEntries(
47
+ Object.entries(selection)
48
+ .filter((entry): entry is [string, string] => entry[0].trim().length > 0 && entry[1].trim().length > 0)
49
+ .sort(([a], [b]) => a.localeCompare(b)),
50
+ )
51
+ }
52
+
53
+ export function formatProductOptionSelection(selection?: ProductOptionSelection | null): string {
54
+ return Object.entries(normalizeProductOptionSelection(selection))
55
+ .map(([label, value]) => `${label}: ${value}`)
56
+ .join(' · ')
57
+ }
58
+
59
+ export function productOptionSelectionKey(selection?: ProductOptionSelection | null): string {
60
+ const normalized = normalizeProductOptionSelection(selection)
61
+ const entries = Object.entries(normalized)
62
+ return entries.length > 0 ? JSON.stringify(entries) : ''
63
+ }
package/src/router.ts ADDED
@@ -0,0 +1,49 @@
1
+ import React, { useEffect, useState } from 'react'
2
+ import { hashRouterAdapter } from '@fayz-ai/core'
3
+
4
+ // Minimal hash router for the storefront. Deliberately not react-router:
5
+ // an SDK package shipping a router dependency creates singleton hazards
6
+ // under source-alias dev setups, and six routes don't justify one.
7
+
8
+ const adapter = hashRouterAdapter()
9
+
10
+ export function navigateTo(to: string): void {
11
+ adapter.navigate(to)
12
+ }
13
+
14
+ export function useHashPath(): string {
15
+ const [path, setPath] = useState(() => adapter.getCurrentPath() || '/')
16
+ useEffect(() => {
17
+ const unsub = adapter.onPathChange((p) => {
18
+ setPath(p || '/')
19
+ window.scrollTo(0, 0)
20
+ })
21
+ return unsub
22
+ }, [])
23
+ return path
24
+ }
25
+
26
+ /** Match '/product/:slug' against '/product/leather-backpack' → { slug: 'leather-backpack' } */
27
+ export function matchPath(pattern: string, path: string): Record<string, string> | null {
28
+ const cleanPath = (path.split('?')[0] ?? '').replace(/\/+$/, '') || '/'
29
+ const patternParts = pattern.split('/').filter(Boolean)
30
+ const pathParts = cleanPath.split('/').filter(Boolean)
31
+ if (patternParts.length !== pathParts.length) return null
32
+ const params: Record<string, string> = {}
33
+ for (let i = 0; i < patternParts.length; i++) {
34
+ const pat = patternParts[i]!
35
+ const part = pathParts[i]!
36
+ if (pat.startsWith(':')) params[pat.slice(1)] = decodeURIComponent(part)
37
+ else if (pat !== part) return null
38
+ }
39
+ return params
40
+ }
41
+
42
+ export interface LinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
43
+ to: string
44
+ }
45
+
46
+ /** Real anchor (#/path) — middle-click/cmd-click friendly, Playwright friendly. */
47
+ export function Link({ to, children, ...rest }: LinkProps) {
48
+ return React.createElement('a', { href: `#${to}`, ...rest }, children)
49
+ }
@@ -0,0 +1,109 @@
1
+ import React from 'react'
2
+ import { registerScaffold, defineApp } from '@fayz-ai/core'
3
+ import type { AppManifest, BackendProvider } from '@fayz-ai/core'
4
+ import { StorefrontConfigProvider, resolveConfig } from './config'
5
+ import type { StorefrontConfig } from './config'
6
+ import type { StorefrontTheme } from './theme'
7
+ import type { HomeConfig, NavLink, FooterConfig } from './sections'
8
+ import { initStorefrontRuntime, StorefrontShell } from './createStorefrontApp'
9
+ import './blocks' // ensure section blocks are registered
10
+
11
+ type StorefrontBackendProvider = NonNullable<StorefrontConfig['backend']>['provider']
12
+ const runtimeConfigRegistry = new Map<string, StorefrontConfig>()
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Storefront scaffold — renders a store from a pure-data AppManifest. The
16
+ // storefront surface keeps its declarative config under surface.options, so the
17
+ // mapping is loss-free for the serializable fields (the code escape hatches —
18
+ // custom provider/adapter/logo — arrive via the registries instead).
19
+ // ---------------------------------------------------------------------------
20
+
21
+ function slug(name: string): string {
22
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'app'
23
+ }
24
+
25
+ /** StorefrontConfig → AppManifest (the sugar-to-data direction). */
26
+ export function defineStorefront(config: StorefrontConfig): AppManifest {
27
+ const id = slug(config.name)
28
+ runtimeConfigRegistry.set(id, config)
29
+ const backendProvider = (config.backend?.provider ?? (config.provider ? 'custom' : 'mock')) as BackendProvider
30
+
31
+ return defineApp({
32
+ id,
33
+ name: config.name,
34
+ backend: {
35
+ provider: backendProvider,
36
+ url: config.backend?.url,
37
+ },
38
+ locale: {
39
+ default: config.locale ?? 'pt-BR',
40
+ supported: [config.locale ?? 'pt-BR'],
41
+ currency: config.currency ?? 'BRL',
42
+ },
43
+ theme: config.theme as Record<string, unknown> | undefined,
44
+ surfaces: {
45
+ storefront: {
46
+ scaffold: 'storefront',
47
+ options: {
48
+ announcement: config.announcement,
49
+ home: config.home,
50
+ nav: config.nav,
51
+ footer: config.footer,
52
+ shipping: config.shipping,
53
+ features: config.features,
54
+ catalog: config.catalog,
55
+ },
56
+ },
57
+ },
58
+ })
59
+ }
60
+
61
+ /** AppManifest → StorefrontConfig (the data-to-render direction). */
62
+ function manifestToStorefrontConfig(manifest: AppManifest, surfaceName: string): StorefrontConfig {
63
+ const runtimeConfig = runtimeConfigRegistry.get(manifest.id)
64
+ const surface = manifest.surfaces[surfaceName]
65
+ const o = (surface?.options ?? {}) as Record<string, unknown>
66
+ return {
67
+ ...runtimeConfig,
68
+ name: manifest.name,
69
+ currency: (manifest.locale as { currency?: string } | undefined)?.currency,
70
+ locale: (manifest.locale as { default?: string } | undefined)?.default,
71
+ theme: manifest.theme as StorefrontTheme | undefined,
72
+ announcement: o.announcement as string | undefined,
73
+ home: o.home as HomeConfig | undefined,
74
+ nav: o.nav as NavLink[] | undefined,
75
+ footer: o.footer as FooterConfig | undefined,
76
+ shipping: o.shipping as StorefrontConfig['shipping'],
77
+ features: o.features as StorefrontConfig['features'],
78
+ catalog: o.catalog as StorefrontConfig['catalog'],
79
+ backend: {
80
+ provider: manifest.backend?.provider as StorefrontBackendProvider,
81
+ url: manifest.backend?.url,
82
+ },
83
+ slots: runtimeConfig?.slots,
84
+ routes: runtimeConfig?.routes,
85
+ provider: runtimeConfig?.provider,
86
+ auth: runtimeConfig?.auth,
87
+ logo: runtimeConfig?.logo,
88
+ }
89
+ }
90
+
91
+ export function StorefrontScaffold({ manifest, surface }: { manifest: AppManifest; surface: string }) {
92
+ const config = React.useMemo(() => manifestToStorefrontConfig(manifest, surface), [manifest, surface])
93
+ const resolved = React.useMemo(() => resolveConfig(config), [config])
94
+ const inited = React.useRef(false)
95
+ if (!inited.current) {
96
+ initStorefrontRuntime(config)
97
+ inited.current = true
98
+ }
99
+ return (
100
+ <StorefrontConfigProvider value={resolved}>
101
+ <StorefrontShell />
102
+ </StorefrontConfigProvider>
103
+ )
104
+ }
105
+ StorefrontScaffold.displayName = 'StorefrontScaffold'
106
+
107
+ // Self-register so `renderApp(manifest)` can resolve the 'storefront' scaffold
108
+ // whenever this package is imported.
109
+ registerScaffold('storefront', StorefrontScaffold, { source: 'sdk', label: 'Storefront' })