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