@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.
- package/LICENSE +21 -0
- package/README.md +39 -0
- package/dist/auth.d.ts +29 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/blocks.d.ts +6 -0
- package/dist/blocks.d.ts.map +1 -0
- package/dist/components/CartDrawer.d.ts +3 -0
- package/dist/components/CartDrawer.d.ts.map +1 -0
- package/dist/components/CustomerAccountShell.d.ts +13 -0
- package/dist/components/CustomerAccountShell.d.ts.map +1 -0
- package/dist/components/FiltersPanel.d.ts +3 -0
- package/dist/components/FiltersPanel.d.ts.map +1 -0
- package/dist/components/OrderTrackingTimeline.d.ts +7 -0
- package/dist/components/OrderTrackingTimeline.d.ts.map +1 -0
- package/dist/components/Price.d.ts +11 -0
- package/dist/components/Price.d.ts.map +1 -0
- package/dist/components/ProductCard.d.ts +7 -0
- package/dist/components/ProductCard.d.ts.map +1 -0
- package/dist/components/ProductGrid.d.ts +7 -0
- package/dist/components/ProductGrid.d.ts.map +1 -0
- package/dist/components/ProductOptionSelector.d.ts +9 -0
- package/dist/components/ProductOptionSelector.d.ts.map +1 -0
- package/dist/components/ProductSpecs.d.ts +8 -0
- package/dist/components/ProductSpecs.d.ts.map +1 -0
- package/dist/components/QuantityInput.d.ts +12 -0
- package/dist/components/QuantityInput.d.ts.map +1 -0
- package/dist/components/RelatedProducts.d.ts +9 -0
- package/dist/components/RelatedProducts.d.ts.map +1 -0
- package/dist/components/SignInModal.d.ts +19 -0
- package/dist/components/SignInModal.d.ts.map +1 -0
- package/dist/components/StorefrontErrorBoundary.d.ts +20 -0
- package/dist/components/StorefrontErrorBoundary.d.ts.map +1 -0
- package/dist/components/StorefrontFooter.d.ts +3 -0
- package/dist/components/StorefrontFooter.d.ts.map +1 -0
- package/dist/components/StorefrontHeader.d.ts +3 -0
- package/dist/components/StorefrontHeader.d.ts.map +1 -0
- package/dist/components/Toaster.d.ts +3 -0
- package/dist/components/Toaster.d.ts.map +1 -0
- package/dist/components/sections/CategoryShowcase.d.ts +6 -0
- package/dist/components/sections/CategoryShowcase.d.ts.map +1 -0
- package/dist/components/sections/HeroSection.d.ts +9 -0
- package/dist/components/sections/HeroSection.d.ts.map +1 -0
- package/dist/components/sections/MiscSections.d.ts +33 -0
- package/dist/components/sections/MiscSections.d.ts.map +1 -0
- package/dist/components/sections/ProductRail.d.ts +9 -0
- package/dist/components/sections/ProductRail.d.ts.map +1 -0
- package/dist/config.d.ts +149 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/createStorefrontApp.d.ts +16 -0
- package/dist/createStorefrontApp.d.ts.map +1 -0
- package/dist/format.d.ts +3 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/hooks/index.d.ts +6 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/useCategories.d.ts +6 -0
- package/dist/hooks/useCategories.d.ts.map +1 -0
- package/dist/hooks/useDiscountValidator.d.ts +14 -0
- package/dist/hooks/useDiscountValidator.d.ts.map +1 -0
- package/dist/hooks/useMyOrders.d.ts +7 -0
- package/dist/hooks/useMyOrders.d.ts.map +1 -0
- package/dist/hooks/useProduct.d.ts +7 -0
- package/dist/hooks/useProduct.d.ts.map +1 -0
- package/dist/hooks/useProducts.d.ts +8 -0
- package/dist/hooks/useProducts.d.ts.map +1 -0
- package/dist/hooks/useStorefrontHead.d.ts +11 -0
- package/dist/hooks/useStorefrontHead.d.ts.map +1 -0
- package/dist/index.cjs +4186 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4085 -0
- package/dist/index.js.map +1 -0
- package/dist/motion.d.ts +19 -0
- package/dist/motion.d.ts.map +1 -0
- package/dist/order-status.d.ts +10 -0
- package/dist/order-status.d.ts.map +1 -0
- package/dist/pages/CatalogPage.d.ts +3 -0
- package/dist/pages/CatalogPage.d.ts.map +1 -0
- package/dist/pages/CheckoutPage.d.ts +3 -0
- package/dist/pages/CheckoutPage.d.ts.map +1 -0
- package/dist/pages/HomePage.d.ts +6 -0
- package/dist/pages/HomePage.d.ts.map +1 -0
- package/dist/pages/MyPurchasesPage.d.ts +3 -0
- package/dist/pages/MyPurchasesPage.d.ts.map +1 -0
- package/dist/pages/NotFoundPage.d.ts +4 -0
- package/dist/pages/NotFoundPage.d.ts.map +1 -0
- package/dist/pages/OrderConfirmationPage.d.ts +5 -0
- package/dist/pages/OrderConfirmationPage.d.ts.map +1 -0
- package/dist/pages/PolicyPage.d.ts +15 -0
- package/dist/pages/PolicyPage.d.ts.map +1 -0
- package/dist/pages/ProductDetailPage.d.ts +5 -0
- package/dist/pages/ProductDetailPage.d.ts.map +1 -0
- package/dist/presets.d.ts +33 -0
- package/dist/presets.d.ts.map +1 -0
- package/dist/product-options.d.ts +12 -0
- package/dist/product-options.d.ts.map +1 -0
- package/dist/router.d.ts +285 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/scaffold.d.ts +14 -0
- package/dist/scaffold.d.ts.map +1 -0
- package/dist/sections.d.ts +84 -0
- package/dist/sections.d.ts.map +1 -0
- package/dist/slot-contracts.d.ts +34 -0
- package/dist/slot-contracts.d.ts.map +1 -0
- package/dist/stores/cart.store.d.ts +58 -0
- package/dist/stores/cart.store.d.ts.map +1 -0
- package/dist/stores/catalog.store.d.ts +18 -0
- package/dist/stores/catalog.store.d.ts.map +1 -0
- package/dist/stores/session.store.d.ts +23 -0
- package/dist/stores/session.store.d.ts.map +1 -0
- package/dist/stores/toast.store.d.ts +27 -0
- package/dist/stores/toast.store.d.ts.map +1 -0
- package/dist/testids.d.ts +93 -0
- package/dist/testids.d.ts.map +1 -0
- package/dist/theme.d.ts +59 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/workflows/checkout.d.ts +33 -0
- package/dist/workflows/checkout.d.ts.map +1 -0
- package/package.json +56 -0
- package/src/auth.ts +134 -0
- package/src/blocks.tsx +47 -0
- package/src/components/CartDrawer.tsx +250 -0
- package/src/components/CustomerAccountShell.tsx +102 -0
- package/src/components/FiltersPanel.tsx +109 -0
- package/src/components/OrderTrackingTimeline.tsx +135 -0
- package/src/components/Price.tsx +32 -0
- package/src/components/ProductCard.tsx +104 -0
- package/src/components/ProductGrid.tsx +34 -0
- package/src/components/ProductOptionSelector.tsx +50 -0
- package/src/components/ProductSpecs.tsx +75 -0
- package/src/components/QuantityInput.tsx +52 -0
- package/src/components/RelatedProducts.tsx +31 -0
- package/src/components/SignInModal.tsx +120 -0
- package/src/components/StorefrontErrorBoundary.tsx +51 -0
- package/src/components/StorefrontFooter.tsx +100 -0
- package/src/components/StorefrontHeader.tsx +342 -0
- package/src/components/Toaster.tsx +60 -0
- package/src/components/sections/CategoryShowcase.tsx +79 -0
- package/src/components/sections/HeroSection.tsx +177 -0
- package/src/components/sections/MiscSections.tsx +157 -0
- package/src/components/sections/ProductRail.tsx +73 -0
- package/src/config.ts +176 -0
- package/src/createStorefrontApp.tsx +171 -0
- package/src/format.ts +7 -0
- package/src/hooks/index.ts +5 -0
- package/src/hooks/useCategories.ts +26 -0
- package/src/hooks/useDiscountValidator.ts +43 -0
- package/src/hooks/useMyOrders.ts +41 -0
- package/src/hooks/useProduct.ts +36 -0
- package/src/hooks/useProducts.ts +39 -0
- package/src/hooks/useStorefrontHead.ts +25 -0
- package/src/index.ts +132 -0
- package/src/motion.tsx +94 -0
- package/src/order-status.ts +33 -0
- package/src/pages/CatalogPage.tsx +118 -0
- package/src/pages/CheckoutPage.tsx +578 -0
- package/src/pages/HomePage.tsx +12 -0
- package/src/pages/MyPurchasesPage.tsx +310 -0
- package/src/pages/NotFoundPage.tsx +25 -0
- package/src/pages/OrderConfirmationPage.tsx +189 -0
- package/src/pages/PolicyPage.tsx +47 -0
- package/src/pages/ProductDetailPage.tsx +184 -0
- package/src/presets.ts +258 -0
- package/src/product-options.ts +63 -0
- package/src/router.ts +49 -0
- package/src/scaffold.tsx +109 -0
- package/src/sections.ts +69 -0
- package/src/slot-contracts.ts +20 -0
- package/src/stores/cart.store.ts +149 -0
- package/src/stores/catalog.store.ts +39 -0
- package/src/stores/session.store.ts +28 -0
- package/src/stores/toast.store.ts +43 -0
- package/src/testids.ts +103 -0
- package/src/theme.ts +160 -0
- 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
|
+
}
|