@fayz-ai/plugin-shop 0.9.0 → 0.9.1
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/README.md +4 -4
- package/dist/{ShopPage-JP27MZS2.js → ShopPage-PVHFKDF3.js} +7 -7
- package/dist/ShopPage-PVHFKDF3.js.map +1 -0
- package/dist/data/categoryDataProvider.d.ts +5 -0
- package/dist/data/categoryDataProvider.d.ts.map +1 -0
- package/dist/data/collectionDataProvider.d.ts +5 -0
- package/dist/data/collectionDataProvider.d.ts.map +1 -0
- package/dist/data/customerGroupDataProvider.d.ts +5 -0
- package/dist/data/customerGroupDataProvider.d.ts.map +1 -0
- package/dist/data/customerSegmentDataProvider.d.ts +16 -0
- package/dist/data/customerSegmentDataProvider.d.ts.map +1 -0
- package/dist/data/giftCardDataProvider.d.ts +5 -0
- package/dist/data/giftCardDataProvider.d.ts.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1088 -86
- package/dist/index.js.map +1 -1
- package/dist/views/ChannelsPage.d.ts +1 -1
- package/dist/views/ChannelsPage.d.ts.map +1 -1
- package/dist/views/ContentPage.d.ts +7 -0
- package/dist/views/ContentPage.d.ts.map +1 -0
- package/dist/views/EmailTemplatePage.d.ts +9 -0
- package/dist/views/EmailTemplatePage.d.ts.map +1 -0
- package/dist/views/EmailsPage.d.ts +7 -0
- package/dist/views/EmailsPage.d.ts.map +1 -0
- package/dist/views/ShopPage.d.ts.map +1 -0
- package/dist/views/categoryEntity.d.ts +4 -0
- package/dist/views/categoryEntity.d.ts.map +1 -0
- package/dist/views/collectionEntity.d.ts +4 -0
- package/dist/views/collectionEntity.d.ts.map +1 -0
- package/dist/views/customerGroupEntity.d.ts +4 -0
- package/dist/views/customerGroupEntity.d.ts.map +1 -0
- package/dist/views/customerSegmentEntity.d.ts +13 -0
- package/dist/views/customerSegmentEntity.d.ts.map +1 -0
- package/dist/views/giftCardEntity.d.ts +4 -0
- package/dist/views/giftCardEntity.d.ts.map +1 -0
- package/package.json +6 -8
- package/dist/ShopPage-JP27MZS2.js.map +0 -1
- package/dist/ShopPage.d.ts.map +0 -1
- package/src/ShopPage.tsx +0 -425
- package/src/components/ShopSettings.tsx +0 -108
- package/src/data/customerDataProvider.ts +0 -31
- package/src/data/discountDataProvider.ts +0 -42
- package/src/data/productDataProvider.ts +0 -54
- package/src/data/shared.ts +0 -42
- package/src/data/shippingZoneDataProvider.ts +0 -49
- package/src/index.ts +0 -270
- package/src/views/ChannelsPage.tsx +0 -274
- package/src/views/OrderDetailView.tsx +0 -319
- package/src/views/ProductImagesTab.tsx +0 -183
- package/src/views/customerEntity.tsx +0 -110
- package/src/views/customerLookup.ts +0 -31
- package/src/views/discountEntity.tsx +0 -144
- package/src/views/productEntity.tsx +0 -229
- package/src/views/shippingZoneEntity.tsx +0 -123
- /package/dist/{ShopPage.d.ts → views/ShopPage.d.ts} +0 -0
package/src/ShopPage.tsx
DELETED
|
@@ -1,425 +0,0 @@
|
|
|
1
|
-
import React, { useEffect, useState, useCallback, useMemo } from 'react'
|
|
2
|
-
import type { ColumnDef } from '@tanstack/react-table'
|
|
3
|
-
import { DataTable } from '@fayz-ai/ui'
|
|
4
|
-
import { getShopProvider } from '@fayz-ai/shop'
|
|
5
|
-
import { useAdminPath, adminNavigateTo } from '@fayz-ai/saas'
|
|
6
|
-
import { PersonLink } from '@fayz-ai/saas'
|
|
7
|
-
import { shopCustomerLookup } from './views/customerLookup'
|
|
8
|
-
import { OrderDetailView } from './views/OrderDetailView'
|
|
9
|
-
import type { Product, Order, ShopCustomer, Discount, ListProductsOptions, ShopProvider } from '@fayz-ai/shop'
|
|
10
|
-
|
|
11
|
-
export type ShopProviderResolver = ShopProvider | (() => ShopProvider)
|
|
12
|
-
|
|
13
|
-
export interface ShopPageProps {
|
|
14
|
-
provider?: ShopProviderResolver
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
// ---------------------------------------------------------------------------
|
|
18
|
-
// Shared helpers
|
|
19
|
-
// ---------------------------------------------------------------------------
|
|
20
|
-
|
|
21
|
-
function currency(value: number, code = 'BRL', locale = 'pt-BR') {
|
|
22
|
-
return new Intl.NumberFormat(locale, { style: 'currency', currency: code }).format(value)
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function badge(label: string, color: string) {
|
|
26
|
-
return (
|
|
27
|
-
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${color}`}>
|
|
28
|
-
{label}
|
|
29
|
-
</span>
|
|
30
|
-
)
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const statusColors: Record<string, string> = {
|
|
34
|
-
active: 'bg-emerald-100 text-emerald-800',
|
|
35
|
-
draft: 'bg-gray-100 text-gray-700',
|
|
36
|
-
archived: 'bg-orange-100 text-orange-700',
|
|
37
|
-
open: 'bg-blue-100 text-blue-800',
|
|
38
|
-
cancelled: 'bg-red-100 text-red-700',
|
|
39
|
-
paid: 'bg-emerald-100 text-emerald-800',
|
|
40
|
-
pending: 'bg-yellow-100 text-yellow-800',
|
|
41
|
-
fulfilled: 'bg-emerald-100 text-emerald-800',
|
|
42
|
-
unfulfilled: 'bg-gray-100 text-gray-700',
|
|
43
|
-
'partially_fulfilled': 'bg-blue-100 text-blue-800',
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function resolveShopProvider(provider?: ShopProviderResolver): ShopProvider {
|
|
47
|
-
return typeof provider === 'function' ? provider() : provider ?? getShopProvider()
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Every tab used to load with `.then(setState).finally(stopLoading)` and no
|
|
52
|
-
* catch. Against a pool where the shop provider reads as `anon`, listOrders and
|
|
53
|
-
* listCustomers reject with "permission denied" — the rejection escaped React
|
|
54
|
-
* and blanked the whole admin, so a permissions problem looked like a crash.
|
|
55
|
-
* The tab now reports what actually failed and the rest of the shell survives.
|
|
56
|
-
*/
|
|
57
|
-
function useShopList<T>(load: () => Promise<T[]>, deps: React.DependencyList) {
|
|
58
|
-
const [items, setItems] = useState<T[]>([])
|
|
59
|
-
const [loading, setLoading] = useState(true)
|
|
60
|
-
const [error, setError] = useState<string | null>(null)
|
|
61
|
-
|
|
62
|
-
useEffect(() => {
|
|
63
|
-
let cancelled = false
|
|
64
|
-
setLoading(true)
|
|
65
|
-
setError(null)
|
|
66
|
-
load()
|
|
67
|
-
.then((data) => { if (!cancelled) setItems(data) })
|
|
68
|
-
.catch((err: unknown) => {
|
|
69
|
-
if (!cancelled) setError(err instanceof Error ? err.message : String(err))
|
|
70
|
-
})
|
|
71
|
-
.finally(() => { if (!cancelled) setLoading(false) })
|
|
72
|
-
return () => { cancelled = true }
|
|
73
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
74
|
-
}, deps)
|
|
75
|
-
|
|
76
|
-
return { items, loading, error }
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function ErrorState({ message }: { message: string }) {
|
|
80
|
-
return (
|
|
81
|
-
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
|
82
|
-
<p className="font-medium">Não foi possível carregar estes dados.</p>
|
|
83
|
-
<p className="mt-1 text-red-700">{message}</p>
|
|
84
|
-
</div>
|
|
85
|
-
)
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// ---------------------------------------------------------------------------
|
|
89
|
-
// Products tab
|
|
90
|
-
// ---------------------------------------------------------------------------
|
|
91
|
-
|
|
92
|
-
function ProductsTab({ provider }: ShopPageProps) {
|
|
93
|
-
const [products, setProducts] = useState<Product[]>([])
|
|
94
|
-
const [loading, setLoading] = useState(true)
|
|
95
|
-
const [search, setSearch] = useState('')
|
|
96
|
-
|
|
97
|
-
const [error, setError] = useState<string | null>(null)
|
|
98
|
-
|
|
99
|
-
const load = useCallback(async (opts?: ListProductsOptions) => {
|
|
100
|
-
setLoading(true)
|
|
101
|
-
setError(null)
|
|
102
|
-
try {
|
|
103
|
-
const data = await resolveShopProvider(provider).listProducts(opts)
|
|
104
|
-
setProducts(data)
|
|
105
|
-
} catch (err: unknown) {
|
|
106
|
-
setError(err instanceof Error ? err.message : String(err))
|
|
107
|
-
} finally {
|
|
108
|
-
setLoading(false)
|
|
109
|
-
}
|
|
110
|
-
}, [provider])
|
|
111
|
-
|
|
112
|
-
useEffect(() => { load() }, [load])
|
|
113
|
-
|
|
114
|
-
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
115
|
-
const q = e.target.value
|
|
116
|
-
setSearch(q)
|
|
117
|
-
load(q ? { search: q } : undefined)
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const columns = useMemo<ColumnDef<Product, any>[]>(() => [
|
|
121
|
-
{ accessorKey: 'name', header: 'Produto', cell: ({ getValue }) => <span className="font-medium">{getValue() as string}</span> },
|
|
122
|
-
{ accessorKey: 'sku', header: 'SKU', cell: ({ getValue }) => <span className="text-muted-foreground">{(getValue() as string | undefined) ?? '—'}</span> },
|
|
123
|
-
{
|
|
124
|
-
accessorKey: 'price', header: () => <span className="block text-right">Preço</span>,
|
|
125
|
-
cell: ({ getValue, row }) => <div className="text-right">{currency(getValue() as number, row.original.currency)}</div>,
|
|
126
|
-
},
|
|
127
|
-
{
|
|
128
|
-
accessorKey: 'inventoryCount', header: () => <span className="block text-right">Estoque</span>,
|
|
129
|
-
cell: ({ getValue }) => {
|
|
130
|
-
const count = getValue() as number
|
|
131
|
-
return <div className="text-right"><span className={count === 0 ? 'text-red-600 font-medium' : ''}>{count}</span></div>
|
|
132
|
-
},
|
|
133
|
-
},
|
|
134
|
-
{
|
|
135
|
-
accessorKey: 'status', header: 'Status',
|
|
136
|
-
cell: ({ getValue }) => {
|
|
137
|
-
const status = getValue() as string
|
|
138
|
-
return badge(status, statusColors[status] ?? 'bg-gray-100 text-gray-700')
|
|
139
|
-
},
|
|
140
|
-
},
|
|
141
|
-
], [])
|
|
142
|
-
|
|
143
|
-
return (
|
|
144
|
-
<div>
|
|
145
|
-
<div className="flex items-center justify-between mb-4">
|
|
146
|
-
<input
|
|
147
|
-
type="search" value={search} onChange={handleSearch}
|
|
148
|
-
placeholder="Buscar produtos..."
|
|
149
|
-
className="border rounded-lg px-3 py-2 text-sm w-64 focus:outline-none focus:ring-2 focus:ring-primary/50"
|
|
150
|
-
/>
|
|
151
|
-
<span className="text-sm text-muted-foreground">{products.length} produtos</span>
|
|
152
|
-
</div>
|
|
153
|
-
|
|
154
|
-
{error ? (
|
|
155
|
-
<ErrorState message={error} />
|
|
156
|
-
) : (
|
|
157
|
-
<DataTable
|
|
158
|
-
loading={loading}
|
|
159
|
-
columns={columns}
|
|
160
|
-
data={products}
|
|
161
|
-
variant="card"
|
|
162
|
-
emptyMessage="Nenhum produto encontrado."
|
|
163
|
-
/>
|
|
164
|
-
)}
|
|
165
|
-
</div>
|
|
166
|
-
)
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// ---------------------------------------------------------------------------
|
|
170
|
-
// Orders tab
|
|
171
|
-
// ---------------------------------------------------------------------------
|
|
172
|
-
|
|
173
|
-
function OrdersTab({ provider }: ShopPageProps) {
|
|
174
|
-
const { items: orders, loading, error } = useShopList<Order>(
|
|
175
|
-
() => resolveShopProvider(provider).listOrders({ limit: 50 }),
|
|
176
|
-
[provider],
|
|
177
|
-
)
|
|
178
|
-
|
|
179
|
-
const columns = useMemo<ColumnDef<Order, any>[]>(() => [
|
|
180
|
-
{ accessorKey: 'orderNumber', header: 'Pedido', cell: ({ getValue }) => <span className="font-medium">#{getValue() as string}</span> },
|
|
181
|
-
{
|
|
182
|
-
id: 'customer', accessorFn: (o) => o.customerName ?? o.customerEmail ?? '—', header: 'Cliente',
|
|
183
|
-
// Links to the customer record. stopPropagation because the row itself
|
|
184
|
-
// opens the order — without it the click would do both.
|
|
185
|
-
cell: ({ getValue, row }) => {
|
|
186
|
-
const label = getValue() as string
|
|
187
|
-
const id = row.original.customerId
|
|
188
|
-
if (!id) return <span>{label}</span>
|
|
189
|
-
return (
|
|
190
|
-
<PersonLink
|
|
191
|
-
personId={id}
|
|
192
|
-
name={label}
|
|
193
|
-
lookup={shopCustomerLookup(() => resolveShopProvider(provider))}
|
|
194
|
-
profileHref={`#/customers/${id}`}
|
|
195
|
-
/>
|
|
196
|
-
)
|
|
197
|
-
},
|
|
198
|
-
},
|
|
199
|
-
{
|
|
200
|
-
accessorKey: 'total', header: () => <span className="block text-right">Total</span>,
|
|
201
|
-
cell: ({ getValue, row }) => <div className="text-right font-medium">{currency(getValue() as number, row.original.currency)}</div>,
|
|
202
|
-
},
|
|
203
|
-
{
|
|
204
|
-
accessorKey: 'financialStatus', header: 'Pagamento',
|
|
205
|
-
cell: ({ getValue }) => {
|
|
206
|
-
const status = getValue() as string
|
|
207
|
-
return badge(status, statusColors[status] ?? 'bg-gray-100 text-gray-700')
|
|
208
|
-
},
|
|
209
|
-
},
|
|
210
|
-
{
|
|
211
|
-
accessorKey: 'fulfillmentStatus', header: 'Entrega',
|
|
212
|
-
cell: ({ getValue }) => {
|
|
213
|
-
const status = getValue() as string
|
|
214
|
-
return badge(status, statusColors[status] ?? 'bg-gray-100 text-gray-700')
|
|
215
|
-
},
|
|
216
|
-
},
|
|
217
|
-
{
|
|
218
|
-
accessorKey: 'createdAt', header: 'Data',
|
|
219
|
-
cell: ({ getValue }) => {
|
|
220
|
-
// Time matters operationally: two orders on the same day are picked,
|
|
221
|
-
// packed and settled in the order they arrived.
|
|
222
|
-
const d = new Date(getValue() as string)
|
|
223
|
-
return (
|
|
224
|
-
<span className="whitespace-nowrap text-muted-foreground">
|
|
225
|
-
{d.toLocaleDateString('pt-BR')}
|
|
226
|
-
<span className="ml-1.5 text-xs opacity-70">{d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}</span>
|
|
227
|
-
</span>
|
|
228
|
-
)
|
|
229
|
-
},
|
|
230
|
-
},
|
|
231
|
-
], [])
|
|
232
|
-
|
|
233
|
-
return (
|
|
234
|
-
<div>
|
|
235
|
-
{error ? (
|
|
236
|
-
<ErrorState message={error} />
|
|
237
|
-
) : (
|
|
238
|
-
<DataTable
|
|
239
|
-
loading={loading}
|
|
240
|
-
columns={columns}
|
|
241
|
-
data={orders}
|
|
242
|
-
variant="card"
|
|
243
|
-
emptyMessage="Nenhum pedido ainda."
|
|
244
|
-
onRowClick={(row: Order) => adminNavigateTo(`/shop/orders/${row.id}`)}
|
|
245
|
-
/>
|
|
246
|
-
)}
|
|
247
|
-
</div>
|
|
248
|
-
)
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// ---------------------------------------------------------------------------
|
|
252
|
-
// Customers tab
|
|
253
|
-
// ---------------------------------------------------------------------------
|
|
254
|
-
|
|
255
|
-
function CustomersTab({ provider }: ShopPageProps) {
|
|
256
|
-
const { items: customers, loading, error } = useShopList<ShopCustomer>(
|
|
257
|
-
() => resolveShopProvider(provider).listCustomers({ limit: 50 }),
|
|
258
|
-
[provider],
|
|
259
|
-
)
|
|
260
|
-
|
|
261
|
-
const columns = useMemo<ColumnDef<ShopCustomer, any>[]>(() => [
|
|
262
|
-
{
|
|
263
|
-
id: 'name', accessorFn: (c) => `${c.firstName} ${c.lastName}`, header: 'Nome',
|
|
264
|
-
cell: ({ row }) => <span className="font-medium">{row.original.firstName} {row.original.lastName}</span>,
|
|
265
|
-
},
|
|
266
|
-
{ accessorKey: 'email', header: 'Email', cell: ({ getValue }) => <span className="text-muted-foreground">{(getValue() as string | undefined) ?? '—'}</span> },
|
|
267
|
-
{ accessorKey: 'phone', header: 'Telefone', cell: ({ getValue }) => <span className="text-muted-foreground">{(getValue() as string | undefined) ?? '—'}</span> },
|
|
268
|
-
{
|
|
269
|
-
accessorKey: 'ordersCount', header: () => <span className="block text-right">Pedidos</span>,
|
|
270
|
-
cell: ({ getValue }) => <div className="text-right">{getValue() as number}</div>,
|
|
271
|
-
},
|
|
272
|
-
{
|
|
273
|
-
accessorKey: 'totalSpent', header: () => <span className="block text-right">Total Gasto</span>,
|
|
274
|
-
cell: ({ getValue }) => <div className="text-right">{currency(getValue() as number)}</div>,
|
|
275
|
-
},
|
|
276
|
-
], [])
|
|
277
|
-
|
|
278
|
-
return (
|
|
279
|
-
<div>
|
|
280
|
-
{error ? (
|
|
281
|
-
<ErrorState message={error} />
|
|
282
|
-
) : (
|
|
283
|
-
<DataTable
|
|
284
|
-
loading={loading}
|
|
285
|
-
columns={columns}
|
|
286
|
-
data={customers}
|
|
287
|
-
variant="card"
|
|
288
|
-
emptyMessage="Nenhum cliente ainda."
|
|
289
|
-
/>
|
|
290
|
-
)}
|
|
291
|
-
</div>
|
|
292
|
-
)
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
// ---------------------------------------------------------------------------
|
|
296
|
-
// Discounts tab
|
|
297
|
-
// ---------------------------------------------------------------------------
|
|
298
|
-
|
|
299
|
-
function DiscountsTab({ provider }: ShopPageProps) {
|
|
300
|
-
const { items: discounts, loading, error } = useShopList<Discount>(
|
|
301
|
-
() => resolveShopProvider(provider).listDiscounts({ limit: 50 }),
|
|
302
|
-
[provider],
|
|
303
|
-
)
|
|
304
|
-
|
|
305
|
-
const columns = useMemo<ColumnDef<Discount, any>[]>(() => [
|
|
306
|
-
{ accessorKey: 'title', header: 'Título', cell: ({ getValue }) => <span className="font-medium">{getValue() as string}</span> },
|
|
307
|
-
{ accessorKey: 'code', header: 'Código', cell: ({ getValue }) => <span className="font-mono text-sm">{(getValue() as string | undefined) ?? '—'}</span> },
|
|
308
|
-
{ accessorKey: 'type', header: 'Tipo', cell: ({ getValue }) => <span className="text-muted-foreground">{getValue() as string}</span> },
|
|
309
|
-
{
|
|
310
|
-
accessorKey: 'value', header: () => <span className="block text-right">Valor</span>,
|
|
311
|
-
cell: ({ row }) => <div className="text-right">{row.original.type === 'percentage' ? `${row.original.value}%` : currency(row.original.value)}</div>,
|
|
312
|
-
},
|
|
313
|
-
{
|
|
314
|
-
id: 'usos', accessorKey: 'timesUsed', header: () => <span className="block text-right">Usos</span>,
|
|
315
|
-
cell: ({ row }) => <div className="text-right">{row.original.timesUsed}{row.original.usageLimit ? ` / ${row.original.usageLimit}` : ''}</div>,
|
|
316
|
-
},
|
|
317
|
-
{
|
|
318
|
-
accessorKey: 'status', header: 'Status',
|
|
319
|
-
cell: ({ getValue }) => {
|
|
320
|
-
const status = getValue() as string
|
|
321
|
-
return badge(status, statusColors[status] ?? 'bg-gray-100 text-gray-700')
|
|
322
|
-
},
|
|
323
|
-
},
|
|
324
|
-
], [])
|
|
325
|
-
|
|
326
|
-
return (
|
|
327
|
-
<div>
|
|
328
|
-
{error ? (
|
|
329
|
-
<ErrorState message={error} />
|
|
330
|
-
) : (
|
|
331
|
-
<DataTable
|
|
332
|
-
loading={loading}
|
|
333
|
-
columns={columns}
|
|
334
|
-
data={discounts}
|
|
335
|
-
variant="card"
|
|
336
|
-
emptyMessage="Nenhum desconto cadastrado."
|
|
337
|
-
/>
|
|
338
|
-
)}
|
|
339
|
-
</div>
|
|
340
|
-
)
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
// ---------------------------------------------------------------------------
|
|
344
|
-
// Main page
|
|
345
|
-
// ---------------------------------------------------------------------------
|
|
346
|
-
|
|
347
|
-
const TABS = [
|
|
348
|
-
{ id: 'products', label: 'Produtos' },
|
|
349
|
-
{ id: 'orders', label: 'Pedidos' },
|
|
350
|
-
{ id: 'customers', label: 'Clientes' },
|
|
351
|
-
{ id: 'discounts', label: 'Descontos' },
|
|
352
|
-
] as const
|
|
353
|
-
|
|
354
|
-
type TabId = typeof TABS[number]['id']
|
|
355
|
-
|
|
356
|
-
const TAB_COPY: Record<TabId, { title: string; subtitle: string }> = {
|
|
357
|
-
products: { title: 'Produtos', subtitle: 'Catálogo da loja: preço, estoque e status.' },
|
|
358
|
-
orders: { title: 'Pedidos', subtitle: 'Pedidos recebidos, pagamento e entrega.' },
|
|
359
|
-
customers: { title: 'Clientes', subtitle: 'Quem comprou, quanto e com que frequência.' },
|
|
360
|
-
discounts: { title: 'Descontos', subtitle: 'Cupons, regras de uso e desempenho.' },
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
/**
|
|
364
|
-
* `section` pins the page to one area, which is how the sidebar routes
|
|
365
|
-
* /shop/products, /shop/orders, … as separate entries. Without it the page
|
|
366
|
-
* keeps its own tab bar — the single-route behaviour, still used by /shop.
|
|
367
|
-
*/
|
|
368
|
-
export const ShopPage: React.FC<ShopPageProps & { section?: TabId }> = ({ provider, section }) => {
|
|
369
|
-
const [tab, setTab] = useState<TabId>(section ?? 'products')
|
|
370
|
-
const active = section ?? tab
|
|
371
|
-
|
|
372
|
-
// /shop/orders/<id> renders the detail. Orders deliberately do not go through
|
|
373
|
-
// createCrudPage: an order is not an editable record, it is a document with
|
|
374
|
-
// items, money and a shipment attached.
|
|
375
|
-
const path = useAdminPath()
|
|
376
|
-
const orderId = section === 'orders' ? path.match(/^\/shop\/orders\/([^/]+)$/)?.[1] : undefined
|
|
377
|
-
if (orderId) {
|
|
378
|
-
return (
|
|
379
|
-
<div className="p-6 max-w-7xl mx-auto">
|
|
380
|
-
<OrderDetailView
|
|
381
|
-
orderId={orderId}
|
|
382
|
-
provider={() => resolveShopProvider(provider)}
|
|
383
|
-
onBack={() => adminNavigateTo('/shop/orders')}
|
|
384
|
-
/>
|
|
385
|
-
</div>
|
|
386
|
-
)
|
|
387
|
-
}
|
|
388
|
-
const copy = section ? TAB_COPY[section] : { title: 'Loja', subtitle: 'Gerencie produtos, pedidos, clientes e descontos.' }
|
|
389
|
-
|
|
390
|
-
return (
|
|
391
|
-
<div className="p-6 max-w-7xl mx-auto">
|
|
392
|
-
<div className="mb-6">
|
|
393
|
-
<h1 className="text-2xl font-semibold">{copy.title}</h1>
|
|
394
|
-
<p className="text-muted-foreground mt-1">{copy.subtitle}</p>
|
|
395
|
-
</div>
|
|
396
|
-
|
|
397
|
-
{!section && (
|
|
398
|
-
<div className="border-b mb-6">
|
|
399
|
-
<nav className="flex gap-1">
|
|
400
|
-
{TABS.map(t => (
|
|
401
|
-
<button
|
|
402
|
-
key={t.id}
|
|
403
|
-
onClick={() => setTab(t.id)}
|
|
404
|
-
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
|
405
|
-
tab === t.id
|
|
406
|
-
? 'border-primary text-primary'
|
|
407
|
-
: 'border-transparent text-muted-foreground hover:text-foreground'
|
|
408
|
-
}`}
|
|
409
|
-
>
|
|
410
|
-
{t.label}
|
|
411
|
-
</button>
|
|
412
|
-
))}
|
|
413
|
-
</nav>
|
|
414
|
-
</div>
|
|
415
|
-
)}
|
|
416
|
-
|
|
417
|
-
{active === 'products' && <ProductsTab provider={provider} />}
|
|
418
|
-
{active === 'orders' && <OrdersTab provider={provider} />}
|
|
419
|
-
{active === 'customers' && <CustomersTab provider={provider} />}
|
|
420
|
-
{active === 'discounts' && <DiscountsTab provider={provider} />}
|
|
421
|
-
</div>
|
|
422
|
-
)
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
ShopPage.displayName = 'ShopPage'
|
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
import React from 'react'
|
|
2
|
-
import { SettingsGroup, ToggleRow, SelectRow } from '@fayz-ai/saas'
|
|
3
|
-
|
|
4
|
-
export interface ShopSettingsProps {
|
|
5
|
-
currency?: { code?: string; locale?: string; symbol?: string }
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
const CURRENCIES = [
|
|
9
|
-
{ value: 'BRL', label: 'Real (R$)' },
|
|
10
|
-
{ value: 'USD', label: 'US Dollar ($)' },
|
|
11
|
-
{ value: 'EUR', label: 'Euro (€)' },
|
|
12
|
-
{ value: 'GBP', label: 'British Pound (£)' },
|
|
13
|
-
]
|
|
14
|
-
|
|
15
|
-
const LOCALES = [
|
|
16
|
-
{ value: 'pt-BR', label: 'Português (Brasil)' },
|
|
17
|
-
{ value: 'en-US', label: 'English (US)' },
|
|
18
|
-
{ value: 'es-ES', label: 'Español' },
|
|
19
|
-
]
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Shop plugin settings — store, catalog, checkout and notification config.
|
|
23
|
-
* Seeded from the plugin's currency option; interactive (local state). Wire to
|
|
24
|
-
* persisted plugin prefs when the shop settings store lands.
|
|
25
|
-
*/
|
|
26
|
-
export function ShopSettings({ currency }: ShopSettingsProps) {
|
|
27
|
-
const [code, setCode] = React.useState(currency?.code ?? 'BRL')
|
|
28
|
-
const [locale, setLocale] = React.useState(currency?.locale ?? 'pt-BR')
|
|
29
|
-
const [showOutOfStock, setShowOutOfStock] = React.useState(true)
|
|
30
|
-
const [trackInventory, setTrackInventory] = React.useState(true)
|
|
31
|
-
const [requireSku, setRequireSku] = React.useState(false)
|
|
32
|
-
const [guestCheckout, setGuestCheckout] = React.useState(true)
|
|
33
|
-
const [enableDiscounts, setEnableDiscounts] = React.useState(true)
|
|
34
|
-
const [orderEmails, setOrderEmails] = React.useState(true)
|
|
35
|
-
const [lowStockAlerts, setLowStockAlerts] = React.useState(false)
|
|
36
|
-
|
|
37
|
-
return (
|
|
38
|
-
<div className="space-y-4">
|
|
39
|
-
<SettingsGroup title="Loja" description="Moeda e formatação da vitrine.">
|
|
40
|
-
<SelectRow
|
|
41
|
-
label="Moeda"
|
|
42
|
-
description="Moeda padrão para preços e checkout."
|
|
43
|
-
value={code}
|
|
44
|
-
options={CURRENCIES}
|
|
45
|
-
onChange={setCode}
|
|
46
|
-
/>
|
|
47
|
-
<SelectRow
|
|
48
|
-
label="Idioma / formato"
|
|
49
|
-
description="Formatação de números, preços e datas."
|
|
50
|
-
value={locale}
|
|
51
|
-
options={LOCALES}
|
|
52
|
-
onChange={setLocale}
|
|
53
|
-
/>
|
|
54
|
-
</SettingsGroup>
|
|
55
|
-
|
|
56
|
-
<SettingsGroup title="Catálogo" description="Como os produtos aparecem e são gerenciados.">
|
|
57
|
-
<ToggleRow
|
|
58
|
-
label="Mostrar produtos esgotados"
|
|
59
|
-
description="Exibir itens sem estoque na vitrine."
|
|
60
|
-
checked={showOutOfStock}
|
|
61
|
-
onChange={setShowOutOfStock}
|
|
62
|
-
/>
|
|
63
|
-
<ToggleRow
|
|
64
|
-
label="Controlar estoque"
|
|
65
|
-
description="Acompanhar quantidade e dar baixa no checkout."
|
|
66
|
-
checked={trackInventory}
|
|
67
|
-
onChange={setTrackInventory}
|
|
68
|
-
/>
|
|
69
|
-
<ToggleRow
|
|
70
|
-
label="Exigir SKU"
|
|
71
|
-
description="Tornar o SKU obrigatório ao criar produtos."
|
|
72
|
-
checked={requireSku}
|
|
73
|
-
onChange={setRequireSku}
|
|
74
|
-
/>
|
|
75
|
-
</SettingsGroup>
|
|
76
|
-
|
|
77
|
-
<SettingsGroup title="Checkout" description="Opções de compra e pagamento.">
|
|
78
|
-
<ToggleRow
|
|
79
|
-
label="Checkout sem cadastro"
|
|
80
|
-
description="Permitir compra como convidado."
|
|
81
|
-
checked={guestCheckout}
|
|
82
|
-
onChange={setGuestCheckout}
|
|
83
|
-
/>
|
|
84
|
-
<ToggleRow
|
|
85
|
-
label="Cupons de desconto"
|
|
86
|
-
description="Habilitar códigos de desconto no carrinho."
|
|
87
|
-
checked={enableDiscounts}
|
|
88
|
-
onChange={setEnableDiscounts}
|
|
89
|
-
/>
|
|
90
|
-
</SettingsGroup>
|
|
91
|
-
|
|
92
|
-
<SettingsGroup title="Notificações" description="Avisos por e-mail da loja.">
|
|
93
|
-
<ToggleRow
|
|
94
|
-
label="E-mail de novo pedido"
|
|
95
|
-
description="Notificar a equipe a cada novo pedido."
|
|
96
|
-
checked={orderEmails}
|
|
97
|
-
onChange={setOrderEmails}
|
|
98
|
-
/>
|
|
99
|
-
<ToggleRow
|
|
100
|
-
label="Alerta de estoque baixo"
|
|
101
|
-
description="Avisar quando produtos atingirem o mínimo."
|
|
102
|
-
checked={lowStockAlerts}
|
|
103
|
-
onChange={setLowStockAlerts}
|
|
104
|
-
/>
|
|
105
|
-
</SettingsGroup>
|
|
106
|
-
</div>
|
|
107
|
-
)
|
|
108
|
-
}
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import type { CrudQuery, CrudResult, DataProvider } from '@fayz-ai/core'
|
|
2
|
-
import type { CreateCustomerInput, ShopCustomer, UpdateCustomerInput } from '@fayz-ai/shop'
|
|
3
|
-
import { applyQuery, resolve, type ShopProviderResolver } from './shared'
|
|
4
|
-
|
|
5
|
-
export function createShopCustomerDataProvider(
|
|
6
|
-
provider: ShopProviderResolver,
|
|
7
|
-
): DataProvider<ShopCustomer> {
|
|
8
|
-
return {
|
|
9
|
-
async list(query: CrudQuery): Promise<CrudResult<ShopCustomer>> {
|
|
10
|
-
const rows = await resolve(provider).listCustomers(
|
|
11
|
-
query.search ? { search: query.search } : undefined,
|
|
12
|
-
)
|
|
13
|
-
return applyQuery(rows, query)
|
|
14
|
-
},
|
|
15
|
-
|
|
16
|
-
async create(data): Promise<ShopCustomer> {
|
|
17
|
-
return resolve(provider).createCustomer(data as unknown as CreateCustomerInput)
|
|
18
|
-
},
|
|
19
|
-
|
|
20
|
-
async update(id: string, data: Partial<ShopCustomer>): Promise<ShopCustomer> {
|
|
21
|
-
// ordersCount / totalSpent are trigger-maintained (shop_refresh_customer_stats);
|
|
22
|
-
// never send them back or a stale form value would fight the database.
|
|
23
|
-
const { ordersCount: _o, totalSpent: _t, ...patch } = data
|
|
24
|
-
return resolve(provider).updateCustomer(id, patch as UpdateCustomerInput)
|
|
25
|
-
},
|
|
26
|
-
|
|
27
|
-
async remove(id: string): Promise<void> {
|
|
28
|
-
await resolve(provider).deleteCustomer(id)
|
|
29
|
-
},
|
|
30
|
-
}
|
|
31
|
-
}
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import type { CrudQuery, CrudResult, DataProvider } from '@fayz-ai/core'
|
|
2
|
-
import type { CreateDiscountInput, Discount, UpdateDiscountInput } from '@fayz-ai/shop'
|
|
3
|
-
import { applyQuery, resolve, type ShopProviderResolver } from './shared'
|
|
4
|
-
|
|
5
|
-
export function createShopDiscountDataProvider(
|
|
6
|
-
provider: ShopProviderResolver,
|
|
7
|
-
): DataProvider<Discount> {
|
|
8
|
-
return {
|
|
9
|
-
async list(query: CrudQuery): Promise<CrudResult<Discount>> {
|
|
10
|
-
const rows = await resolve(provider).listDiscounts()
|
|
11
|
-
const term = query.search?.trim().toLowerCase()
|
|
12
|
-
const filtered = term
|
|
13
|
-
? rows.filter((d) =>
|
|
14
|
-
d.title.toLowerCase().includes(term) || (d.code ?? '').toLowerCase().includes(term))
|
|
15
|
-
: rows
|
|
16
|
-
return applyQuery(filtered, query, 'status')
|
|
17
|
-
},
|
|
18
|
-
|
|
19
|
-
async create(data): Promise<Discount> {
|
|
20
|
-
const input = data as unknown as Partial<Discount>
|
|
21
|
-
return resolve(provider).createDiscount({
|
|
22
|
-
...(input as CreateDiscountInput),
|
|
23
|
-
// Codes are matched case-insensitively server-side; store them upper so
|
|
24
|
-
// the admin list reads consistently.
|
|
25
|
-
code: input.code ? String(input.code).trim().toUpperCase() : undefined,
|
|
26
|
-
} as CreateDiscountInput)
|
|
27
|
-
},
|
|
28
|
-
|
|
29
|
-
async update(id: string, data: Partial<Discount>): Promise<Discount> {
|
|
30
|
-
// timesUsed is incremented by shop_place_order inside the order transaction.
|
|
31
|
-
const { timesUsed: _used, ...patch } = data
|
|
32
|
-
if (patch.code !== undefined) {
|
|
33
|
-
patch.code = patch.code ? String(patch.code).trim().toUpperCase() : null
|
|
34
|
-
}
|
|
35
|
-
return resolve(provider).updateDiscount(id, patch as UpdateDiscountInput)
|
|
36
|
-
},
|
|
37
|
-
|
|
38
|
-
async remove(id: string): Promise<void> {
|
|
39
|
-
await resolve(provider).deleteDiscount(id)
|
|
40
|
-
},
|
|
41
|
-
}
|
|
42
|
-
}
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
import type { CrudQuery, CrudResult, DataProvider } from '@fayz-ai/core'
|
|
2
|
-
import type { CreateProductInput, Product, UpdateProductInput } from '@fayz-ai/shop'
|
|
3
|
-
import { slugify } from '../views/productEntity'
|
|
4
|
-
import { applyQuery, resolve, type ShopProviderResolver } from './shared'
|
|
5
|
-
|
|
6
|
-
export type { ShopProviderResolver }
|
|
7
|
-
|
|
8
|
-
// ---------------------------------------------------------------------------
|
|
9
|
-
// Bridges the generic CRUD scaffolding (DataProvider) to the shop provider, so
|
|
10
|
-
// the product screens are the same createCrudPage machinery every other module
|
|
11
|
-
// uses instead of a bespoke form.
|
|
12
|
-
//
|
|
13
|
-
// Which shop provider is injected decides whether writes work at all:
|
|
14
|
-
// • createSupabaseShopProvider — authenticates as the merchant (their JWT),
|
|
15
|
-
// full CRUD, subject to the tenant-member RLS policies.
|
|
16
|
-
// • createFayzShopProvider — the storefront's anonymous REST client. Reads
|
|
17
|
-
// work; every write throws FayzShopError 501 by design. The form surfaces
|
|
18
|
-
// that message rather than pretending the save succeeded.
|
|
19
|
-
// ---------------------------------------------------------------------------
|
|
20
|
-
|
|
21
|
-
export function createShopProductDataProvider(provider: ShopProviderResolver): DataProvider<Product> {
|
|
22
|
-
return {
|
|
23
|
-
async list(query: CrudQuery): Promise<CrudResult<Product>> {
|
|
24
|
-
const rows = await resolve(provider).listProducts(
|
|
25
|
-
query.search ? { search: query.search } : undefined,
|
|
26
|
-
)
|
|
27
|
-
return applyQuery(rows, query, 'status')
|
|
28
|
-
},
|
|
29
|
-
|
|
30
|
-
async create(data): Promise<Product> {
|
|
31
|
-
const input = data as unknown as Partial<Product>
|
|
32
|
-
// The storefront URL is required by the table's UNIQUE(tenant_id, slug);
|
|
33
|
-
// deriving it from the name keeps the merchant from having to know that.
|
|
34
|
-
const slug = input.slug?.trim() || slugify(String(input.name ?? ''))
|
|
35
|
-
return resolve(provider).createProduct({
|
|
36
|
-
...(input as CreateProductInput),
|
|
37
|
-
slug,
|
|
38
|
-
} as CreateProductInput)
|
|
39
|
-
},
|
|
40
|
-
|
|
41
|
-
async update(id: string, data: Partial<Product>): Promise<Product> {
|
|
42
|
-
const patch = { ...data }
|
|
43
|
-
if (patch.slug !== undefined && !String(patch.slug).trim()) {
|
|
44
|
-
// Cleared on purpose → regenerate rather than write an empty slug.
|
|
45
|
-
patch.slug = slugify(String(patch.name ?? ''))
|
|
46
|
-
}
|
|
47
|
-
return resolve(provider).updateProduct(id, patch as UpdateProductInput)
|
|
48
|
-
},
|
|
49
|
-
|
|
50
|
-
async remove(id: string): Promise<void> {
|
|
51
|
-
await resolve(provider).deleteProduct(id)
|
|
52
|
-
},
|
|
53
|
-
}
|
|
54
|
-
}
|