@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,41 @@
1
+ import { useCallback, useEffect, useState } from 'react'
2
+ import { getShopProvider } from '@fayz-ai/shop/runtime'
3
+ import type { Order } from '@fayz-ai/shop/types'
4
+ import { useSessionStore } from '../stores/session.store'
5
+
6
+ export function useMyOrders(): { orders: Order[]; loading: boolean; refresh: () => void } {
7
+ const customerId = useSessionStore((s) => s.customerId)
8
+ const email = useSessionStore((s) => s.email)
9
+ const [orders, setOrders] = useState<Order[]>([])
10
+ const [loading, setLoading] = useState(true)
11
+ const [tick, setTick] = useState(0)
12
+
13
+ const refresh = useCallback(() => setTick((t) => t + 1), [])
14
+
15
+ useEffect(() => {
16
+ let cancelled = false
17
+ if (!customerId && !email) {
18
+ setOrders([])
19
+ setLoading(false)
20
+ return
21
+ }
22
+ setLoading(true)
23
+ // Scope by customerId (RLS-enforced to the owner on Supabase) when available;
24
+ // fall back to email only for legacy sessions without a linked customer.
25
+ const query = customerId ? { customerId, limit: 50 } : { customerEmail: email!, limit: 50 }
26
+ getShopProvider()
27
+ .listOrders(query)
28
+ .then((data) => {
29
+ if (!cancelled) setOrders(data)
30
+ })
31
+ .catch(() => {})
32
+ .finally(() => {
33
+ if (!cancelled) setLoading(false)
34
+ })
35
+ return () => {
36
+ cancelled = true
37
+ }
38
+ }, [customerId, email, tick])
39
+
40
+ return { orders, loading, refresh }
41
+ }
@@ -0,0 +1,36 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { getShopProvider } from '@fayz-ai/shop/runtime'
3
+ import type { Product } from '@fayz-ai/shop/types'
4
+
5
+ export function useProduct(slug: string): {
6
+ product: Product | null
7
+ loading: boolean
8
+ error: string | null
9
+ } {
10
+ const [product, setProduct] = useState<Product | null>(null)
11
+ const [loading, setLoading] = useState(true)
12
+ const [error, setError] = useState<string | null>(null)
13
+
14
+ useEffect(() => {
15
+ let cancelled = false
16
+ setLoading(true)
17
+ getShopProvider()
18
+ .listProducts({ slug, status: 'active', limit: 1 })
19
+ .then((data) => {
20
+ if (cancelled) return
21
+ setProduct(data[0] ?? null)
22
+ setError(null)
23
+ })
24
+ .catch((e: unknown) => {
25
+ if (!cancelled) setError(e instanceof Error ? e.message : String(e))
26
+ })
27
+ .finally(() => {
28
+ if (!cancelled) setLoading(false)
29
+ })
30
+ return () => {
31
+ cancelled = true
32
+ }
33
+ }, [slug])
34
+
35
+ return { product, loading, error }
36
+ }
@@ -0,0 +1,39 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { getShopProvider } from '@fayz-ai/shop/runtime'
3
+ import type { Product, ListProductsOptions } from '@fayz-ai/shop/types'
4
+
5
+ export function useProducts(opts: ListProductsOptions): {
6
+ products: Product[]
7
+ loading: boolean
8
+ error: string | null
9
+ reload: () => void
10
+ } {
11
+ const [products, setProducts] = useState<Product[]>([])
12
+ const [loading, setLoading] = useState(true)
13
+ const [error, setError] = useState<string | null>(null)
14
+ const [nonce, setNonce] = useState(0)
15
+ const key = JSON.stringify(opts)
16
+
17
+ useEffect(() => {
18
+ let cancelled = false
19
+ setLoading(true)
20
+ getShopProvider()
21
+ .listProducts(JSON.parse(key) as ListProductsOptions)
22
+ .then((data) => {
23
+ if (cancelled) return
24
+ setProducts(data)
25
+ setError(null)
26
+ })
27
+ .catch((e: unknown) => {
28
+ if (!cancelled) setError(e instanceof Error ? e.message : String(e))
29
+ })
30
+ .finally(() => {
31
+ if (!cancelled) setLoading(false)
32
+ })
33
+ return () => {
34
+ cancelled = true
35
+ }
36
+ }, [key, nonce])
37
+
38
+ return { products, loading, error, reload: () => setNonce((n) => n + 1) }
39
+ }
@@ -0,0 +1,25 @@
1
+ import { useEffect } from 'react'
2
+
3
+ /**
4
+ * Minimal per-route head manager: sets document.title and meta description so
5
+ * each storefront page (product, catalog, account, checkout) has its own title
6
+ * and shareable description instead of one static SPA title. A real SSR/prerender
7
+ * pass (M12) layers OG/canonical on top; this covers the client-rendered baseline.
8
+ */
9
+ export function useStorefrontHead(input: { title?: string; description?: string | null }): void {
10
+ const { title, description } = input
11
+ useEffect(() => {
12
+ if (title) document.title = title
13
+ }, [title])
14
+
15
+ useEffect(() => {
16
+ if (!description) return
17
+ let tag = document.head.querySelector<HTMLMetaElement>('meta[name="description"]')
18
+ if (!tag) {
19
+ tag = document.createElement('meta')
20
+ tag.setAttribute('name', 'description')
21
+ document.head.appendChild(tag)
22
+ }
23
+ tag.setAttribute('content', description.slice(0, 160))
24
+ }, [description])
25
+ }
package/src/index.ts ADDED
@@ -0,0 +1,132 @@
1
+ // Factory
2
+ export { createStorefrontApp, initStorefrontRuntime, StorefrontShell } from './createStorefrontApp'
3
+ export type {
4
+ StorefrontConfig,
5
+ ResolvedStorefrontConfig,
6
+ StorefrontRouteComponentProps,
7
+ StorefrontRouteChrome,
8
+ StorefrontRouteDefinition,
9
+ StorefrontRouteKind,
10
+ StorefrontRouteParams,
11
+ } from './config'
12
+ export { useStorefrontConfig } from './config'
13
+
14
+ // Manifest path (renderApp(defineStorefront(config)) / storefront scaffold)
15
+ export { defineStorefront, StorefrontScaffold } from './scaffold'
16
+
17
+ // Block system (storefront sections as registry blocks)
18
+ export { registerStorefrontBlocks, sectionsToBlocks } from './blocks'
19
+
20
+ // Routing
21
+ export { Link, navigateTo, useHashPath, matchPath } from './router'
22
+ export type { LinkProps } from './router'
23
+
24
+ // Stores
25
+ export {
26
+ useCartStore,
27
+ selectCount,
28
+ selectSubtotal,
29
+ selectDiscountTotal,
30
+ selectShipping,
31
+ selectTotal,
32
+ } from './stores/cart.store'
33
+ export type { CartLine, CartState } from './stores/cart.store'
34
+ export { useSessionStore } from './stores/session.store'
35
+ export type { SessionState } from './stores/session.store'
36
+ export {
37
+ signInByEmail,
38
+ signUpCustomer,
39
+ establishCustomerSession,
40
+ signOutCustomer,
41
+ getCustomerAuthAdapter,
42
+ resolveAuthAdapter,
43
+ } from './auth'
44
+ export type { StorefrontAuthAdapter, EstablishSessionOptions } from './auth'
45
+ export { useCatalogStore } from './stores/catalog.store'
46
+ export type { CatalogState, CatalogSort } from './stores/catalog.store'
47
+
48
+ // Hooks
49
+ export { useProducts, useProduct, useCategories, useMyOrders, useDiscountValidator } from './hooks'
50
+
51
+ // Components (for custom layouts)
52
+ export { StorefrontHeader } from './components/StorefrontHeader'
53
+ export { ProductCard } from './components/ProductCard'
54
+ export type { ProductCardProps } from './components/ProductCard'
55
+ export { OrderTrackingTimeline } from './components/OrderTrackingTimeline'
56
+ export { ProductGrid } from './components/ProductGrid'
57
+ export { FiltersPanel } from './components/FiltersPanel'
58
+ export { CartDrawer } from './components/CartDrawer'
59
+ export { Price } from './components/Price'
60
+ export { QuantityInput } from './components/QuantityInput'
61
+ export { ProductOptionSelector } from './components/ProductOptionSelector'
62
+ export type { ProductOptionSelectorProps } from './components/ProductOptionSelector'
63
+ export {
64
+ getProductOptionGroups,
65
+ normalizeProductOptionSelection,
66
+ formatProductOptionSelection,
67
+ productOptionSelectionKey,
68
+ } from './product-options'
69
+ export type { ProductOptionGroup, ProductOptionSelection } from './product-options'
70
+
71
+ // Pages (for custom routing)
72
+ export { CatalogPage } from './pages/CatalogPage'
73
+ export { ProductDetailPage } from './pages/ProductDetailPage'
74
+ export { CheckoutPage } from './pages/CheckoutPage'
75
+ export { OrderConfirmationPage } from './pages/OrderConfirmationPage'
76
+ export { MyPurchasesPage } from './pages/MyPurchasesPage'
77
+ export { placeStorefrontOrder } from './workflows/checkout'
78
+ export type {
79
+ PlaceStorefrontOrderInput,
80
+ PlaceStorefrontOrderResult,
81
+ StorefrontCheckoutAddress,
82
+ StorefrontCheckoutCustomer,
83
+ } from './workflows/checkout'
84
+
85
+ // Theming + templates
86
+ export { StorefrontThemeStyle, themeToCss } from './theme'
87
+ export type {
88
+ StorefrontTheme,
89
+ StorefrontThemeColors,
90
+ StorefrontThemeFont,
91
+ StorefrontRadius,
92
+ HeaderVariant,
93
+ HeroVariant,
94
+ CardStyle,
95
+ } from './theme'
96
+ export type { HomeSection, HomeConfig, HeroSlide, NavLink, FooterConfig } from './sections'
97
+ export { bannerPlaceholder } from './sections'
98
+ export {
99
+ storefrontTemplates,
100
+ mareTemplate,
101
+ sertaoTemplate,
102
+ voltTemplate,
103
+ atelierTemplate,
104
+ } from './presets'
105
+ export type { StorefrontTemplate, StorefrontTemplateId } from './presets'
106
+
107
+ // Home sections (for custom compositions)
108
+ export { HomePage } from './pages/HomePage'
109
+ export { HeroSection } from './components/sections/HeroSection'
110
+ export { CategoryShowcase } from './components/sections/CategoryShowcase'
111
+ export { ProductRail } from './components/sections/ProductRail'
112
+ export {
113
+ BenefitsRow,
114
+ PromoBanner,
115
+ ManifestoBlock,
116
+ Testimonials,
117
+ NewsletterBand,
118
+ } from './components/sections/MiscSections'
119
+ export { StorefrontFooter } from './components/StorefrontFooter'
120
+
121
+ // Motion (core animation primitives)
122
+ export { Reveal, useInView, useScrolled, usePopOnChange, prefersReducedMotion } from './motion'
123
+ export type { RevealProps } from './motion'
124
+
125
+ // Utilities
126
+ export { formatMoney, roundCents } from './format'
127
+ export { TID } from './testids'
128
+ export {
129
+ productCardSlotContract,
130
+ storefrontSlotContracts,
131
+ } from './slot-contracts'
132
+ export type { ProductCardSlotContract } from './slot-contracts'
package/src/motion.tsx ADDED
@@ -0,0 +1,94 @@
1
+ import React, { useEffect, useRef, useState } from 'react'
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Core motion primitives — every storefront inherits these; stores never
5
+ // implement animation ad hoc. All effects respect prefers-reduced-motion.
6
+ // ---------------------------------------------------------------------------
7
+
8
+ export function prefersReducedMotion(): boolean {
9
+ if (typeof window === 'undefined' || !window.matchMedia) return false
10
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches
11
+ }
12
+
13
+ /** True once the element has entered the viewport (one-shot). */
14
+ export function useInView<T extends HTMLElement>(margin = '0px 0px -8% 0px'): [React.RefObject<T>, boolean] {
15
+ const ref = useRef<T>(null)
16
+ const [inView, setInView] = useState(() => prefersReducedMotion())
17
+
18
+ useEffect(() => {
19
+ if (inView) return
20
+ const el = ref.current
21
+ if (!el || typeof IntersectionObserver === 'undefined') {
22
+ setInView(true)
23
+ return
24
+ }
25
+ const observer = new IntersectionObserver(
26
+ (entries) => {
27
+ if (entries.some((e) => e.isIntersecting)) {
28
+ setInView(true)
29
+ observer.disconnect()
30
+ }
31
+ },
32
+ { rootMargin: margin },
33
+ )
34
+ observer.observe(el)
35
+ return () => observer.disconnect()
36
+ }, [inView, margin])
37
+
38
+ return [ref, inView]
39
+ }
40
+
41
+ export interface RevealProps extends React.HTMLAttributes<HTMLDivElement> {
42
+ /** Stagger delay in ms */
43
+ delay?: number
44
+ children: React.ReactNode
45
+ }
46
+
47
+ /** Plays a fade-up entrance when the element scrolls into view.
48
+ * Content is VISIBLE by default — never pre-hidden — so screenshots,
49
+ * crawlers and environments without IntersectionObserver always see it;
50
+ * the animation's own from-state handles the fade. */
51
+ export function Reveal({ delay = 0, children, className, style, ...rest }: RevealProps) {
52
+ const [ref, inView] = useInView<HTMLDivElement>()
53
+ return (
54
+ <div
55
+ ref={ref}
56
+ className={`${inView ? 'animate-fade-up' : ''} ${className ?? ''}`}
57
+ style={{ ...style, animationDelay: delay ? `${delay}ms` : undefined }}
58
+ {...rest}
59
+ >
60
+ {children}
61
+ </div>
62
+ )
63
+ }
64
+
65
+ /** True after the window has scrolled past the threshold (header treatments). */
66
+ export function useScrolled(threshold = 8): boolean {
67
+ const [scrolled, setScrolled] = useState(false)
68
+ useEffect(() => {
69
+ const onScroll = () => setScrolled(window.scrollY > threshold)
70
+ onScroll()
71
+ window.addEventListener('scroll', onScroll, { passive: true })
72
+ return () => window.removeEventListener('scroll', onScroll)
73
+ }, [threshold])
74
+ return scrolled
75
+ }
76
+
77
+ /** Re-triggers a CSS animation class whenever `value` changes (cart badge pop). */
78
+ export function usePopOnChange(value: number): boolean {
79
+ const [pop, setPop] = useState(false)
80
+ const prev = useRef(value)
81
+ useEffect(() => {
82
+ if (prev.current !== value && value > 0) {
83
+ setPop(true)
84
+ const t = setTimeout(() => setPop(false), 400)
85
+ return () => clearTimeout(t)
86
+ }
87
+ prev.current = value
88
+ return undefined
89
+ }, [value])
90
+ useEffect(() => {
91
+ prev.current = value
92
+ })
93
+ return pop
94
+ }
@@ -0,0 +1,33 @@
1
+ import type { FinancialStatus } from '@fayz-ai/shop/types'
2
+
3
+ type Tone = 'positive' | 'pending' | 'negative'
4
+
5
+ const FINANCIAL: Record<FinancialStatus, { label: string; tone: Tone }> = {
6
+ pending: { label: 'Pendente', tone: 'pending' },
7
+ paid: { label: 'Pago', tone: 'positive' },
8
+ partially_paid: { label: 'Parcialmente pago', tone: 'pending' },
9
+ partially_refunded: { label: 'Parcialmente reembolsado', tone: 'negative' },
10
+ refunded: { label: 'Reembolsado', tone: 'negative' },
11
+ voided: { label: 'Cancelado', tone: 'negative' },
12
+ }
13
+
14
+ const TONE_CLASS: Record<Tone, string> = {
15
+ positive: 'bg-emerald-100 text-emerald-800',
16
+ pending: 'bg-amber-100 text-amber-800',
17
+ negative: 'bg-rose-100 text-rose-800',
18
+ }
19
+
20
+ /** One source of truth for a financial-status pill — label + color by status,
21
+ * so a pending or refunded order never renders inside a green "success" pill. */
22
+ export function financialStatusBadge(status: FinancialStatus): { label: string; className: string } {
23
+ const entry = FINANCIAL[status] ?? { label: status, tone: 'pending' as Tone }
24
+ return { label: entry.label, className: TONE_CLASS[entry.tone] }
25
+ }
26
+
27
+ export function isPaid(status: FinancialStatus): boolean {
28
+ return status === 'paid' || status === 'partially_paid'
29
+ }
30
+
31
+ export function isRefunded(status: FinancialStatus): boolean {
32
+ return status === 'refunded' || status === 'partially_refunded'
33
+ }
@@ -0,0 +1,118 @@
1
+ import React, { useEffect, useMemo, useRef } from 'react'
2
+ import { useCatalogStore, type CatalogSort } from '../stores/catalog.store'
3
+ import { useProducts } from '../hooks/useProducts'
4
+ import { ProductGrid } from '../components/ProductGrid'
5
+ import { FiltersPanel } from '../components/FiltersPanel'
6
+ import { useStorefrontConfig } from '../config'
7
+ import { useStorefrontHead } from '../hooks/useStorefrontHead'
8
+ import type { ListProductsOptions } from '@fayz-ai/shop/types'
9
+
10
+ const SORT_MAP: Record<string, Pick<ListProductsOptions, 'orderBy' | 'order'>> = {
11
+ newest: { orderBy: 'created_at', order: 'desc' },
12
+ 'price-asc': { orderBy: 'price', order: 'asc' },
13
+ 'price-desc': { orderBy: 'price', order: 'desc' },
14
+ name: { orderBy: 'name', order: 'asc' },
15
+ }
16
+
17
+ const SORTS: CatalogSort[] = ['newest', 'price-asc', 'price-desc', 'name']
18
+
19
+ /**
20
+ * Two-way sync between catalog filters and the URL hash query, so a filtered/
21
+ * searched catalog is shareable, bookmarkable, and survives a refresh. Uses
22
+ * history.replaceState (no navigation / no scroll jump).
23
+ */
24
+ function useCatalogUrlSync() {
25
+ const hydrated = useRef(false)
26
+ const catalog = useCatalogStore()
27
+
28
+ useEffect(() => {
29
+ if (hydrated.current) return
30
+ hydrated.current = true
31
+ const q = window.location.hash.split('?')[1]
32
+ if (!q) return
33
+ const p = new URLSearchParams(q)
34
+ const s = useCatalogStore.getState()
35
+ if (p.has('search')) s.setSearch(p.get('search') ?? '')
36
+ if (p.get('category')) s.setCategoryId(p.get('category'))
37
+ const sort = p.get('sort')
38
+ if (sort && (SORTS as string[]).includes(sort)) s.setSort(sort as CatalogSort)
39
+ if (p.get('stock') === '1') s.setInStockOnly(true)
40
+ if (p.has('min')) s.setPriceMin(Number(p.get('min')) || null)
41
+ if (p.has('max')) s.setPriceMax(Number(p.get('max')) || null)
42
+ }, [])
43
+
44
+ useEffect(() => {
45
+ const p = new URLSearchParams()
46
+ if (catalog.search) p.set('search', catalog.search)
47
+ if (catalog.categoryId) p.set('category', catalog.categoryId)
48
+ if (catalog.sort !== 'newest') p.set('sort', catalog.sort)
49
+ if (catalog.inStockOnly) p.set('stock', '1')
50
+ if (catalog.priceMin != null) p.set('min', String(catalog.priceMin))
51
+ if (catalog.priceMax != null) p.set('max', String(catalog.priceMax))
52
+ const base = window.location.hash.split('?')[0] || '#/catalog'
53
+ const qs = p.toString()
54
+ const next = qs ? `${base}?${qs}` : base
55
+ if (next !== window.location.hash) window.history.replaceState(null, '', next)
56
+ }, [catalog.search, catalog.categoryId, catalog.sort, catalog.inStockOnly, catalog.priceMin, catalog.priceMax])
57
+ }
58
+
59
+ export function CatalogPage() {
60
+ const config = useStorefrontConfig()
61
+ const catalog = useCatalogStore()
62
+ useCatalogUrlSync()
63
+ useStorefrontHead({
64
+ title: catalog.search ? `“${catalog.search}” — ${config.name}` : `${config.name} — Loja`,
65
+ })
66
+
67
+ const providerOpts: ListProductsOptions = {
68
+ status: 'active',
69
+ categoryId: catalog.categoryId ?? undefined,
70
+ search: catalog.search || undefined,
71
+ ...SORT_MAP[catalog.sort],
72
+ }
73
+ const { products, loading, error, reload } = useProducts(providerOpts)
74
+
75
+ // Price range + availability are client-side refinements over the fetched set
76
+ const filtered = useMemo(
77
+ () =>
78
+ products.filter((p) => {
79
+ if (catalog.priceMin != null && p.price < catalog.priceMin) return false
80
+ if (catalog.priceMax != null && p.price > catalog.priceMax) return false
81
+ if (catalog.inStockOnly && p.inventoryCount <= 0) return false
82
+ return true
83
+ }),
84
+ [products, catalog.priceMin, catalog.priceMax, catalog.inStockOnly],
85
+ )
86
+
87
+ return (
88
+ <main className="mx-auto max-w-7xl px-4 py-8 sm:px-6">
89
+ <div className="mb-8">
90
+ <h1 className="text-2xl font-bold tracking-tight">{config.name}</h1>
91
+ <p className="mt-1 text-muted-foreground">
92
+ {catalog.search
93
+ ? `Resultados para “${catalog.search}”`
94
+ : 'Produtos selecionados com qualidade e entrega rápida.'}
95
+ </p>
96
+ </div>
97
+ <div className="flex flex-col gap-8 lg:flex-row">
98
+ <FiltersPanel />
99
+ <div className="flex-1">
100
+ {error ? (
101
+ <div className="rounded-2xl border bg-card py-16 text-center">
102
+ <p className="text-muted-foreground">Não foi possível carregar os produtos.</p>
103
+ <button
104
+ type="button"
105
+ onClick={reload}
106
+ className="mt-4 rounded-lg border px-4 py-2 text-sm font-semibold transition hover:bg-muted"
107
+ >
108
+ Tentar novamente
109
+ </button>
110
+ </div>
111
+ ) : (
112
+ <ProductGrid products={filtered} loading={loading} />
113
+ )}
114
+ </div>
115
+ </div>
116
+ </main>
117
+ )
118
+ }