@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,157 @@
1
+ import React, { useState } from 'react'
2
+ import * as LucideIcons from 'lucide-react'
3
+ import { bannerPlaceholder } from '../../sections'
4
+ import { Reveal } from '../../motion'
5
+ import { Link } from '../../router'
6
+ import { TID } from '../../testids'
7
+
8
+ function Icon({ name, className }: { name: string; className?: string }) {
9
+ const C = (LucideIcons as unknown as Record<string, React.ComponentType<{ className?: string }>>)[name]
10
+ return C ? <C className={className} /> : null
11
+ }
12
+
13
+ export function BenefitsRow({ items }: { items: Array<{ icon: string; title: string; text?: string }> }) {
14
+ return (
15
+ <section data-testid={TID.benefits} className="border-y bg-card">
16
+ <div className="mx-auto grid max-w-7xl grid-cols-1 gap-6 px-4 py-9 sm:grid-cols-3 sm:px-6">
17
+ {items.slice(0, 4).map((item, i) => (
18
+ <Reveal key={i} delay={i * 120} className="flex items-center justify-center gap-3 text-center sm:text-left">
19
+ <span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-primary/10">
20
+ <Icon name={item.icon} className="h-6 w-6 text-primary" />
21
+ </span>
22
+ <div>
23
+ <p className="font-semibold">{item.title}</p>
24
+ {item.text && <p className="text-sm text-muted-foreground">{item.text}</p>}
25
+ </div>
26
+ </Reveal>
27
+ ))}
28
+ </div>
29
+ </section>
30
+ )
31
+ }
32
+
33
+ export function PromoBanner({
34
+ title,
35
+ subtitle,
36
+ eyebrow,
37
+ cta,
38
+ href,
39
+ image,
40
+ hue = 160,
41
+ }: {
42
+ title: string
43
+ subtitle?: string
44
+ eyebrow?: string
45
+ cta?: string
46
+ href?: string
47
+ image?: string
48
+ hue?: number
49
+ }) {
50
+ return (
51
+ <section data-testid={TID.promoBanner} className="group relative my-12 overflow-hidden">
52
+ <img
53
+ src={image ?? bannerPlaceholder(title, hue, hue + 50, 1600, 420)}
54
+ alt={title}
55
+ className="h-80 w-full object-cover transition-transform duration-700 ease-out group-hover:scale-105"
56
+ />
57
+ <div className="absolute inset-0 flex flex-col items-center justify-center bg-gradient-to-t from-black/65 via-black/35 to-black/20 p-6 text-center text-white">
58
+ <Reveal>
59
+ {eyebrow && (
60
+ <p className="mb-2 text-xs font-semibold uppercase tracking-[0.25em] text-white/80">{eyebrow}</p>
61
+ )}
62
+ <h2 className="sf-heading text-3xl font-bold drop-shadow sm:text-5xl">{title}</h2>
63
+ {subtitle && <p className="mt-3 max-w-lg text-white/90">{subtitle}</p>}
64
+ {cta && (
65
+ <Link
66
+ to={href ?? '/catalog'}
67
+ className="sf-cta mt-6 inline-block bg-white px-8 py-3 font-semibold text-gray-900 shadow-lg transition-all hover:-translate-y-0.5 hover:shadow-xl"
68
+ style={{ borderRadius: 'var(--sf-radius-button)' }}
69
+ >
70
+ {cta}
71
+ </Link>
72
+ )}
73
+ </Reveal>
74
+ </div>
75
+ </section>
76
+ )
77
+ }
78
+
79
+ export function ManifestoBlock({ title, text }: { title?: string; text: string }) {
80
+ return (
81
+ <section data-testid={TID.manifesto} className="mx-auto max-w-3xl px-4 py-14 text-center sm:px-6">
82
+ {title && <h2 className="sf-heading mb-5 text-2xl font-bold">{title}</h2>}
83
+ <p className="sf-heading text-xl leading-relaxed text-muted-foreground sm:text-2xl">{text}</p>
84
+ </section>
85
+ )
86
+ }
87
+
88
+ export function Testimonials({ title, items }: { title?: string; items: Array<{ quote: string; author: string }> }) {
89
+ return (
90
+ <section data-testid={TID.testimonials} className="bg-muted/50 py-14">
91
+ <div className="mx-auto max-w-7xl px-4 sm:px-6">
92
+ {title && (
93
+ <Reveal>
94
+ <h2 className="sf-heading mb-10 text-center text-3xl font-bold">{title}</h2>
95
+ </Reveal>
96
+ )}
97
+ <div className="grid gap-6 md:grid-cols-3">
98
+ {items.slice(0, 3).map((t, i) => (
99
+ <Reveal key={i} delay={i * 130}>
100
+ <figure
101
+ className="h-full bg-card p-7 shadow-sm transition-shadow hover:shadow-md"
102
+ style={{ borderRadius: 'var(--sf-radius-card)' }}
103
+ >
104
+ <div className="mb-3 text-primary" aria-hidden>★★★★★</div>
105
+ <blockquote className="leading-relaxed">“{t.quote}”</blockquote>
106
+ <figcaption className="mt-4 text-sm font-semibold text-muted-foreground">— {t.author}</figcaption>
107
+ </figure>
108
+ </Reveal>
109
+ ))}
110
+ </div>
111
+ </div>
112
+ </section>
113
+ )
114
+ }
115
+
116
+ export function NewsletterBand({ title, subtitle }: { title?: string; subtitle?: string }) {
117
+ const [email, setEmail] = useState('')
118
+ const [done, setDone] = useState(false)
119
+ return (
120
+ <section data-testid={TID.newsletter} className="bg-primary py-16 text-primary-foreground">
121
+ <Reveal className="mx-auto max-w-xl px-4 text-center sm:px-6">
122
+ <h2 className="sf-heading text-3xl font-bold">{title ?? 'Receba nossas novidades'}</h2>
123
+ {subtitle && <p className="mt-2 opacity-90">{subtitle}</p>}
124
+ {done ? (
125
+ <p data-testid={TID.newsletterDone} className="mt-6 font-medium">Inscrição confirmada! 🎉</p>
126
+ ) : (
127
+ <form
128
+ className="mt-6 flex gap-2"
129
+ onSubmit={(e) => {
130
+ e.preventDefault()
131
+ if (email.trim()) setDone(true)
132
+ }}
133
+ >
134
+ <input
135
+ data-testid={TID.newsletterEmail}
136
+ type="email"
137
+ required
138
+ placeholder="Seu e-mail"
139
+ value={email}
140
+ onChange={(e) => setEmail(e.target.value)}
141
+ className="flex-1 border-0 bg-white/95 px-4 py-3 text-sm text-gray-900 outline-none"
142
+ style={{ borderRadius: 'var(--sf-radius-input)' }}
143
+ />
144
+ <button
145
+ type="submit"
146
+ data-testid={TID.newsletterSubmit}
147
+ className="sf-cta bg-foreground px-6 py-3 text-sm font-semibold text-background"
148
+ style={{ borderRadius: 'var(--sf-radius-button)' }}
149
+ >
150
+ Assinar
151
+ </button>
152
+ </form>
153
+ )}
154
+ </Reveal>
155
+ </section>
156
+ )
157
+ }
@@ -0,0 +1,73 @@
1
+ import React, { useMemo } from 'react'
2
+ import { useProducts } from '../../hooks/useProducts'
3
+ import { useStorefrontConfig } from '../../config'
4
+ import { ProductCard } from '../ProductCard'
5
+ import { Reveal } from '../../motion'
6
+ import { Link } from '../../router'
7
+ import { TID } from '../../testids'
8
+
9
+ export function ProductRail({
10
+ title,
11
+ subtitle,
12
+ eyebrow,
13
+ filter = 'all',
14
+ limit = 4,
15
+ }: {
16
+ title: string
17
+ subtitle?: string
18
+ eyebrow?: string
19
+ filter?: 'sale' | 'new' | 'all'
20
+ limit?: number
21
+ }) {
22
+ const config = useStorefrontConfig()
23
+ const ProductCardComponent = config.slots?.ProductCard ?? ProductCard
24
+ const { products, loading } = useProducts(
25
+ filter === 'new'
26
+ ? { status: 'active', orderBy: 'created_at', order: 'desc' }
27
+ : { status: 'active' },
28
+ )
29
+
30
+ const visible = useMemo(() => {
31
+ let list = products
32
+ if (filter === 'sale') list = list.filter((p) => p.compareAtPrice != null && p.compareAtPrice > p.price)
33
+ return list.slice(0, limit)
34
+ }, [products, filter, limit])
35
+
36
+ if (!loading && visible.length === 0) return null
37
+
38
+ return (
39
+ <section data-testid={TID.productRail} className="mx-auto max-w-7xl px-4 py-14 sm:px-6">
40
+ <Reveal className="mb-10 text-center">
41
+ {eyebrow && (
42
+ <p className="mb-2 text-xs font-semibold uppercase tracking-[0.2em] text-primary">{eyebrow}</p>
43
+ )}
44
+ <h2 className="sf-heading text-3xl font-bold tracking-tight">{title}</h2>
45
+ {subtitle && <p className="mt-2 text-muted-foreground">{subtitle}</p>}
46
+ </Reveal>
47
+ {loading ? (
48
+ <div className="grid grid-cols-2 gap-5 md:grid-cols-4">
49
+ {Array.from({ length: limit }, (_, i) => (
50
+ <div key={i} className="aspect-[3/4] animate-pulse bg-muted" style={{ borderRadius: 'var(--sf-radius-card)' }} />
51
+ ))}
52
+ </div>
53
+ ) : (
54
+ <div className="grid grid-cols-2 gap-5 md:grid-cols-4">
55
+ {visible.map((p, i) => (
56
+ <Reveal key={p.id} delay={i * 90}>
57
+ <ProductCardComponent product={p} />
58
+ </Reveal>
59
+ ))}
60
+ </div>
61
+ )}
62
+ <Reveal className="mt-10 text-center" delay={200}>
63
+ <Link
64
+ to={config.catalogPath}
65
+ className="sf-cta inline-block border border-foreground/30 px-7 py-2.5 text-sm font-semibold transition-all hover:-translate-y-0.5 hover:bg-foreground hover:text-background hover:shadow"
66
+ style={{ borderRadius: 'var(--sf-radius-button)' }}
67
+ >
68
+ Ver tudo
69
+ </Link>
70
+ </Reveal>
71
+ </section>
72
+ )
73
+ }
package/src/config.ts ADDED
@@ -0,0 +1,176 @@
1
+ import React, { createContext, useContext } from 'react'
2
+ import type { Product } from '@fayz-ai/shop/types'
3
+ import type { ShopProvider } from '@fayz-ai/shop/provider'
4
+ import type { MockShopSeed } from '@fayz-ai/shop/mock'
5
+ import type { StorefrontTheme } from './theme'
6
+ import type { HomeConfig, NavLink, FooterConfig } from './sections'
7
+ import type { StorefrontAuthAdapter } from './auth'
8
+
9
+ export interface ProductCardSlotProps {
10
+ product: Product
11
+ }
12
+
13
+ export type StorefrontRouteKind =
14
+ | 'home'
15
+ | 'catalog'
16
+ | 'product'
17
+ | 'checkout'
18
+ | 'account'
19
+ | 'order'
20
+ | 'custom'
21
+
22
+ export type StorefrontRouteParams = Record<string, string>
23
+
24
+ export interface StorefrontRouteComponentProps {
25
+ path: string
26
+ params: StorefrontRouteParams
27
+ config: ResolvedStorefrontConfig
28
+ }
29
+
30
+ export type StorefrontRouteChrome = 'default' | 'focused'
31
+
32
+ export interface StorefrontRouteDefinition {
33
+ /**
34
+ * Stable identifier for generated-code diffs, QA reports, and route ownership.
35
+ */
36
+ id: string
37
+ /**
38
+ * Hash route pattern. Supports the same params as matchPath, e.g. '/checkout'
39
+ * or '/collections/:slug'. Custom routes are matched before SDK defaults.
40
+ */
41
+ path: string
42
+ /**
43
+ * Intent label for agents and manifests. It does not change matching behavior.
44
+ */
45
+ kind?: StorefrontRouteKind
46
+ /**
47
+ * Shell chrome for the route. `focused` hides storefront nav/footer/cart
48
+ * around high-intent flows such as checkout.
49
+ */
50
+ chrome?: StorefrontRouteChrome
51
+ /**
52
+ * App-owned screen/workflow that can still use storefront/shop primitives.
53
+ */
54
+ component: React.ComponentType<StorefrontRouteComponentProps>
55
+ }
56
+
57
+ export interface StorefrontSlots {
58
+ /**
59
+ * Product card renderer used by catalog grids and product rails.
60
+ *
61
+ * Custom renderers must preserve `productCardSlotContract` selectors so
62
+ * checkout smoke tests, QA, and agents can still operate the storefront.
63
+ */
64
+ ProductCard?: React.ComponentType<ProductCardSlotProps>
65
+ }
66
+
67
+ export interface StorefrontDiscountConfig {
68
+ code: string
69
+ percent: number
70
+ title?: string
71
+ }
72
+
73
+ export interface StorefrontConfig {
74
+ /** Store display name (header, page title) */
75
+ name: string
76
+ /** Optional logo node; defaults to the store name */
77
+ logo?: React.ReactNode
78
+ /** ISO 4217 — default 'BRL' */
79
+ currency?: string
80
+ /** Intl locale for money/date formatting — default 'pt-BR' */
81
+ locale?: string
82
+ /** Visual identity: colors, fonts, radius, header/card personality */
83
+ theme?: StorefrontTheme
84
+ /** Promo message above the header (e.g. 'FRETE GRÁTIS ACIMA DE R$ 300') */
85
+ announcement?: string
86
+ /** Home page blueprint. When set, `/` renders it and the catalog moves to /catalog. */
87
+ home?: HomeConfig
88
+ /** Header nav links — defaults derived from home/catalog routing */
89
+ nav?: NavLink[]
90
+ /** Footer content (about text, contact, social) */
91
+ footer?: FooterConfig
92
+ /** Preferred backend descriptor for manifest/scaffold metadata. */
93
+ backend?: { provider?: 'mock' | 'fayz-api' | 'fayz-shop' | 'custom'; url?: string }
94
+ /** @deprecated Use backend/provider adapters. Storefront no longer creates Supabase clients. */
95
+ supabaseUrl?: string
96
+ /** @deprecated Use backend/provider adapters. Storefront no longer creates Supabase clients. */
97
+ supabaseAnonKey?: string
98
+ /** Explicit provider override (tests, custom backends) */
99
+ provider?: ShopProvider
100
+ /** Per-store mock catalog (products/categories/discounts) for mock mode */
101
+ catalog?: MockShopSeed
102
+ /** Store-level discount codes available regardless of provider backend. */
103
+ discounts?: StorefrontDiscountConfig[]
104
+ /**
105
+ * Institutional/legal page content (privacy, terms, returns). Plain text —
106
+ * blank lines separate paragraphs. Unset pages render a placeholder so the
107
+ * routes (and footer links) exist even before the store writes its policy.
108
+ */
109
+ legal?: { privacy?: string; terms?: string; returns?: string }
110
+ /** Customer auth — same AuthAdapter contract as createSaasApp. Default: mock. */
111
+ auth?: { adapter?: StorefrontAuthAdapter }
112
+ /** Shipping pricing — default { flatRate: 0 } */
113
+ shipping?: { flatRate?: number; freeAbove?: number }
114
+ /**
115
+ * Payment behavior. v1 default 'mock' instantly marks orders paid with NO
116
+ * real charge (demo). M4 sets 'pix-mercadopago' so orders stay pending until
117
+ * a real payment webhook confirms settlement.
118
+ */
119
+ payments?: { mode?: 'mock' | 'pix-mercadopago' }
120
+ /** Feature toggles — discounts default off; accounts default on */
121
+ features?: { discounts?: boolean; accounts?: boolean }
122
+ /** Code-level customization slots. Kept out of serialized manifests. */
123
+ slots?: StorefrontSlots
124
+ /** Code-level route overrides/custom routes. Kept out of serialized manifests. */
125
+ routes?: StorefrontRouteDefinition[]
126
+ }
127
+
128
+ export interface ResolvedStorefrontConfig extends StorefrontConfig {
129
+ currency: string
130
+ locale: string
131
+ shipping: { flatRate: number; freeAbove?: number }
132
+ payments: { mode: 'mock' | 'pix-mercadopago' }
133
+ features: { discounts: boolean; accounts: boolean }
134
+ /** Where the full product list lives ('/' without a home page, '/catalog' with one) */
135
+ catalogPath: string
136
+ nav: NavLink[]
137
+ }
138
+
139
+ export function resolveConfig(config: StorefrontConfig): ResolvedStorefrontConfig {
140
+ const catalogPath = config.home ? '/catalog' : '/'
141
+ return {
142
+ ...config,
143
+ currency: config.currency ?? 'BRL',
144
+ locale: config.locale ?? 'pt-BR',
145
+ shipping: { flatRate: config.shipping?.flatRate ?? 0, freeAbove: config.shipping?.freeAbove },
146
+ payments: { mode: config.payments?.mode ?? 'mock' },
147
+ features: {
148
+ // Auto-enable the coupon UI when the store actually ships discount codes
149
+ // (via config.discounts or a seeded catalog), unless explicitly overridden —
150
+ // otherwise a store can advertise a coupon with no field to enter it.
151
+ discounts:
152
+ config.features?.discounts ??
153
+ Boolean(config.discounts?.length || config.catalog?.discounts?.length),
154
+ accounts: config.features?.accounts ?? true,
155
+ },
156
+ catalogPath,
157
+ nav:
158
+ config.nav ??
159
+ (config.home
160
+ ? [
161
+ { label: 'Início', to: '/' },
162
+ { label: 'Loja', to: '/catalog' },
163
+ ]
164
+ : [{ label: 'Loja', to: '/' }]),
165
+ }
166
+ }
167
+
168
+ const StorefrontConfigContext = createContext<ResolvedStorefrontConfig | null>(null)
169
+
170
+ export const StorefrontConfigProvider = StorefrontConfigContext.Provider
171
+
172
+ export function useStorefrontConfig(): ResolvedStorefrontConfig {
173
+ const ctx = useContext(StorefrontConfigContext)
174
+ if (!ctx) throw new Error('useStorefrontConfig must be used inside createStorefrontApp')
175
+ return ctx
176
+ }
@@ -0,0 +1,171 @@
1
+ import React from 'react'
2
+ import { setShopProvider } from '@fayz-ai/shop/runtime'
3
+ import { createMockShopProvider } from '@fayz-ai/shop/mock'
4
+ import type { MockShopSeed } from '@fayz-ai/shop/mock'
5
+ import type { Discount } from '@fayz-ai/shop/types'
6
+ import { initCustomerAuth, resolveAuthAdapter } from './auth'
7
+ import { StorefrontConfigProvider, resolveConfig, useStorefrontConfig } from './config'
8
+ import type { StorefrontConfig, StorefrontDiscountConfig } from './config'
9
+ import { useHashPath, matchPath } from './router'
10
+ import { StorefrontThemeStyle } from './theme'
11
+ import { StorefrontHeader } from './components/StorefrontHeader'
12
+ import { StorefrontFooter } from './components/StorefrontFooter'
13
+ import { CartDrawer } from './components/CartDrawer'
14
+ import { CatalogPage } from './pages/CatalogPage'
15
+ import { HomePage } from './pages/HomePage'
16
+ import { ProductDetailPage } from './pages/ProductDetailPage'
17
+ import { CheckoutPage } from './pages/CheckoutPage'
18
+ import { OrderConfirmationPage } from './pages/OrderConfirmationPage'
19
+ import { MyPurchasesPage } from './pages/MyPurchasesPage'
20
+ import { NotFoundPage } from './pages/NotFoundPage'
21
+ import { PolicyPage } from './pages/PolicyPage'
22
+ import { StorefrontErrorBoundary } from './components/StorefrontErrorBoundary'
23
+ import { Toaster } from './components/Toaster'
24
+
25
+ function getCustomRouteMatch(config: ReturnType<typeof useStorefrontConfig>, path: string) {
26
+ for (const route of config.routes ?? []) {
27
+ const params = matchPath(route.path, path)
28
+ if (params) return { route, params }
29
+ }
30
+ return null
31
+ }
32
+
33
+ function isFocusedRoute(config: ReturnType<typeof useStorefrontConfig>, path: string): boolean {
34
+ const custom = getCustomRouteMatch(config, path)
35
+ if (custom) return custom.route.chrome === 'focused' || custom.route.kind === 'checkout'
36
+ return Boolean(matchPath('/checkout', path))
37
+ }
38
+
39
+ function RouteSwitch({ path }: { path: string }) {
40
+ const config = useStorefrontConfig()
41
+
42
+ const custom = getCustomRouteMatch(config, path)
43
+ if (custom) {
44
+ const CustomRoute = custom.route.component
45
+ return <CustomRoute path={path} params={custom.params} config={config} />
46
+ }
47
+
48
+ const product = matchPath('/product/:slug', path)
49
+ if (product?.slug) return <ProductDetailPage slug={product.slug} />
50
+
51
+ const order = matchPath('/order/:id', path)
52
+ if (order?.id) return <OrderConfirmationPage orderId={order.id} />
53
+
54
+ if (matchPath('/checkout', path)) return <CheckoutPage />
55
+ if (matchPath('/account', path)) return <MyPurchasesPage />
56
+ if (matchPath('/catalog', path)) return <CatalogPage />
57
+ if (matchPath('/privacy', path)) return <PolicyPage kind="privacy" />
58
+ if (matchPath('/terms', path)) return <PolicyPage kind="terms" />
59
+ if (matchPath('/refunds', path)) return <PolicyPage kind="returns" />
60
+
61
+ // Root renders home (or catalog when there's no home). Any other unmatched
62
+ // path is a genuine 404 — don't silently fall back to the storefront.
63
+ if (path === '/' || path === '') {
64
+ return config.home ? <HomePage sections={config.home.sections} /> : <CatalogPage />
65
+ }
66
+ return <NotFoundPage />
67
+ }
68
+
69
+ /**
70
+ * Side-effect runtime init shared by the factory and the manifest scaffold:
71
+ * wires customer auth and the shop provider. Idempotent and safe to call once
72
+ * before first render. Provider resolution: explicit > catalog mock > empty mock.
73
+ */
74
+ /**
75
+ * config.discounts are simple {code, percent} entries the UI validator trusts
76
+ * (useDiscountValidator). They must ALSO be seeded into the provider, or
77
+ * placeOrder won't find the code server-side and will charge full price — the
78
+ * shopper sees a discounted total but is billed the undiscounted one.
79
+ */
80
+ function storefrontDiscountToSeed(discount: StorefrontDiscountConfig): Discount {
81
+ const ts = new Date().toISOString()
82
+ return {
83
+ id: `cfg-discount-${discount.code.trim().toLowerCase()}`,
84
+ tenantId: 'mock',
85
+ title: discount.title ?? `${discount.percent}% off`,
86
+ code: discount.code.trim().toUpperCase(),
87
+ type: 'percentage',
88
+ method: 'code',
89
+ value: discount.percent,
90
+ usageLimit: null,
91
+ oncePerCustomer: false,
92
+ startsAt: ts,
93
+ endsAt: null,
94
+ status: 'active',
95
+ timesUsed: 0,
96
+ createdAt: ts,
97
+ updatedAt: ts,
98
+ }
99
+ }
100
+
101
+ function buildMockSeed(config: StorefrontConfig): MockShopSeed | undefined {
102
+ const configDiscounts = (config.discounts ?? []).map(storefrontDiscountToSeed)
103
+ if (!config.catalog && configDiscounts.length === 0) return undefined
104
+ // Config-level codes win over catalog seeds sharing the same code.
105
+ const catalogDiscounts = (config.catalog?.discounts ?? []).filter(
106
+ (cd) => !configDiscounts.some((cfg) => (cd.code ?? '').toUpperCase() === cfg.code),
107
+ )
108
+ return {
109
+ ...config.catalog,
110
+ discounts: [...catalogDiscounts, ...configDiscounts],
111
+ }
112
+ }
113
+
114
+ export function initStorefrontRuntime(config: StorefrontConfig): void {
115
+ initCustomerAuth(resolveAuthAdapter(config.auth?.adapter))
116
+
117
+ if (config.provider) {
118
+ setShopProvider(config.provider)
119
+ } else {
120
+ setShopProvider(createMockShopProvider(buildMockSeed(config)))
121
+ }
122
+
123
+ if (config.supabaseUrl || config.supabaseAnonKey) {
124
+ console.warn(
125
+ '@fayz-ai/shop: supabaseUrl/supabaseAnonKey are legacy fields. Pass an explicit provider/adapter or use the Fayz SDK broker path.',
126
+ )
127
+ }
128
+ }
129
+
130
+ /** The inner storefront UI. Reads everything from the config context, so it is
131
+ * shared by createStorefrontApp and the manifest-driven StorefrontScaffold. */
132
+ export function StorefrontShell() {
133
+ const config = useStorefrontConfig()
134
+ const path = useHashPath()
135
+ const focused = isFocusedRoute(config, path)
136
+ return (
137
+ <div className="min-h-screen bg-background text-foreground">
138
+ {config.theme && <StorefrontThemeStyle theme={config.theme} />}
139
+ {!focused && <StorefrontHeader />}
140
+ <StorefrontErrorBoundary key={path}>
141
+ <RouteSwitch path={path} />
142
+ </StorefrontErrorBoundary>
143
+ {!focused && <CartDrawer />}
144
+ {!focused && <StorefrontFooter />}
145
+ <Toaster />
146
+ </div>
147
+ )
148
+ }
149
+
150
+ /**
151
+ * Declarative storefront factory — the customer-facing counterpart of
152
+ * createSaasApp. Sugar over the manifest path: it is equivalent to
153
+ * renderApp(defineStorefront(config)), sharing initStorefrontRuntime +
154
+ * StorefrontShell with the scaffold.
155
+ *
156
+ * const App = createStorefrontApp({ name: 'Aurora Goods', currency: 'BRL' })
157
+ */
158
+ export function createStorefrontApp(config: StorefrontConfig): React.ComponentType {
159
+ const resolved = resolveConfig(config)
160
+ initStorefrontRuntime(config)
161
+
162
+ function StorefrontApp() {
163
+ return (
164
+ <StorefrontConfigProvider value={resolved}>
165
+ <StorefrontShell />
166
+ </StorefrontConfigProvider>
167
+ )
168
+ }
169
+ StorefrontApp.displayName = 'StorefrontApp'
170
+ return StorefrontApp
171
+ }
package/src/format.ts ADDED
@@ -0,0 +1,7 @@
1
+ export function formatMoney(value: number, currency = 'BRL', locale = 'pt-BR'): string {
2
+ return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(value)
3
+ }
4
+
5
+ export function roundCents(value: number): number {
6
+ return Math.round(value * 100) / 100
7
+ }
@@ -0,0 +1,5 @@
1
+ export { useProducts } from './useProducts'
2
+ export { useProduct } from './useProduct'
3
+ export { useCategories } from './useCategories'
4
+ export { useMyOrders } from './useMyOrders'
5
+ export { useDiscountValidator } from './useDiscountValidator'
@@ -0,0 +1,26 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { getShopProvider } from '@fayz-ai/shop/runtime'
3
+ import type { Category } from '@fayz-ai/shop/types'
4
+
5
+ export function useCategories(): { categories: Category[]; loading: boolean } {
6
+ const [categories, setCategories] = useState<Category[]>([])
7
+ const [loading, setLoading] = useState(true)
8
+
9
+ useEffect(() => {
10
+ let cancelled = false
11
+ getShopProvider()
12
+ .listCategories()
13
+ .then((data) => {
14
+ if (!cancelled) setCategories(data)
15
+ })
16
+ .catch(() => {})
17
+ .finally(() => {
18
+ if (!cancelled) setLoading(false)
19
+ })
20
+ return () => {
21
+ cancelled = true
22
+ }
23
+ }, [])
24
+
25
+ return { categories, loading }
26
+ }
@@ -0,0 +1,43 @@
1
+ import { useCallback } from 'react'
2
+ import { validateDiscount } from '@fayz-ai/shop'
3
+ import { useStorefrontConfig } from '../config'
4
+
5
+ export interface DiscountValidation {
6
+ valid: boolean
7
+ percent: number
8
+ message?: string
9
+ }
10
+
11
+ const REASON_MESSAGE: Record<string, string> = {
12
+ expired: 'Cupom expirado.',
13
+ not_started: 'Cupom ainda não está ativo.',
14
+ usage_limit: 'Cupom esgotado.',
15
+ not_found: 'Cupom inválido ou expirado.',
16
+ }
17
+
18
+ /**
19
+ * Storefront coupon validator. The store's own config.discounts (simple percent
20
+ * codes) are trusted directly; everything else goes through the provider-backed
21
+ * validateDiscount primitive, which enforces status/date-window/usage-limit.
22
+ * The storefront cart models discounts as a percentage, so non-percentage codes
23
+ * are rejected for client-side preview (the server still honors them at checkout).
24
+ */
25
+ export function useDiscountValidator(): (code: string) => Promise<DiscountValidation> {
26
+ const config = useStorefrontConfig()
27
+ return useCallback(async (code: string) => {
28
+ const normalized = code.trim().toUpperCase()
29
+ if (!normalized) return { valid: false, percent: 0, message: 'Informe um cupom.' }
30
+
31
+ const configDiscount = config.discounts?.find((d) => d.code.trim().toUpperCase() === normalized)
32
+ if (configDiscount) return { valid: true, percent: configDiscount.percent }
33
+
34
+ const validation = await validateDiscount({ code: normalized })
35
+ if (!validation.valid) {
36
+ return { valid: false, percent: 0, message: REASON_MESSAGE[validation.reason ?? 'not_found'] ?? 'Cupom inválido ou expirado.' }
37
+ }
38
+ if (validation.type !== 'percentage') {
39
+ return { valid: false, percent: 0, message: 'Este cupom não é suportado na loja.' }
40
+ }
41
+ return { valid: true, percent: validation.value }
42
+ }, [config.discounts])
43
+ }