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