@fayz-ai/plugin-shop 0.8.0 → 0.8.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/dist/ShopPage-HCLXMN7Q.js +743 -0
- package/dist/ShopPage-HCLXMN7Q.js.map +1 -0
- package/dist/ShopPage-JF2SQBKO.cjs +745 -0
- package/dist/ShopPage-JF2SQBKO.cjs.map +1 -0
- package/dist/ShopPage.d.ts +23 -1
- package/dist/ShopPage.d.ts.map +1 -1
- package/dist/data/customerDataProvider.d.ts +5 -0
- package/dist/data/customerDataProvider.d.ts.map +1 -0
- package/dist/data/discountDataProvider.d.ts +5 -0
- package/dist/data/discountDataProvider.d.ts.map +1 -0
- package/dist/data/productDataProvider.d.ts +6 -0
- package/dist/data/productDataProvider.d.ts.map +1 -0
- package/dist/data/shared.d.ts +12 -0
- package/dist/data/shared.d.ts.map +1 -0
- package/dist/data/shippingZoneDataProvider.d.ts +13 -0
- package/dist/data/shippingZoneDataProvider.d.ts.map +1 -0
- package/dist/index.cjs +983 -26
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +984 -27
- package/dist/index.js.map +1 -1
- package/dist/views/OrderDetailView.d.ts +10 -0
- package/dist/views/OrderDetailView.d.ts.map +1 -0
- package/dist/views/ProductImagesTab.d.ts +19 -0
- package/dist/views/ProductImagesTab.d.ts.map +1 -0
- package/dist/views/customerEntity.d.ts +4 -0
- package/dist/views/customerEntity.d.ts.map +1 -0
- package/dist/views/customerLookup.d.ts +22 -0
- package/dist/views/customerLookup.d.ts.map +1 -0
- package/dist/views/discountEntity.d.ts +4 -0
- package/dist/views/discountEntity.d.ts.map +1 -0
- package/dist/views/productEntity.d.ts +6 -0
- package/dist/views/productEntity.d.ts.map +1 -0
- package/dist/views/shippingZoneEntity.d.ts +4 -0
- package/dist/views/shippingZoneEntity.d.ts.map +1 -0
- package/package.json +5 -5
- package/src/ShopPage.tsx +156 -54
- package/src/data/customerDataProvider.ts +31 -0
- package/src/data/discountDataProvider.ts +42 -0
- package/src/data/productDataProvider.ts +54 -0
- package/src/data/shared.ts +42 -0
- package/src/data/shippingZoneDataProvider.ts +49 -0
- package/src/index.ts +101 -12
- package/src/views/OrderDetailView.tsx +575 -0
- package/src/views/ProductImagesTab.tsx +183 -0
- package/src/views/customerEntity.tsx +110 -0
- package/src/views/customerLookup.ts +31 -0
- package/src/views/discountEntity.tsx +137 -0
- package/src/views/productEntity.tsx +229 -0
- package/src/views/shippingZoneEntity.tsx +123 -0
- package/dist/ShopPage-65PPVKBD.js +0 -271
- package/dist/ShopPage-65PPVKBD.js.map +0 -1
- package/dist/ShopPage-PG5GKOZU.cjs +0 -273
- package/dist/ShopPage-PG5GKOZU.cjs.map +0 -1
package/src/ShopPage.tsx
CHANGED
|
@@ -2,6 +2,10 @@ import React, { useEffect, useState, useCallback, useMemo } from 'react'
|
|
|
2
2
|
import type { ColumnDef } from '@tanstack/react-table'
|
|
3
3
|
import { DataTable } from '@fayz-ai/ui'
|
|
4
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'
|
|
5
9
|
import type { Product, Order, ShopCustomer, Discount, ListProductsOptions, ShopProvider } from '@fayz-ai/shop'
|
|
6
10
|
|
|
7
11
|
export type ShopProviderResolver = ShopProvider | (() => ShopProvider)
|
|
@@ -43,6 +47,44 @@ function resolveShopProvider(provider?: ShopProviderResolver): ShopProvider {
|
|
|
43
47
|
return typeof provider === 'function' ? provider() : provider ?? getShopProvider()
|
|
44
48
|
}
|
|
45
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
|
+
|
|
46
88
|
// ---------------------------------------------------------------------------
|
|
47
89
|
// Products tab
|
|
48
90
|
// ---------------------------------------------------------------------------
|
|
@@ -52,11 +94,16 @@ function ProductsTab({ provider }: ShopPageProps) {
|
|
|
52
94
|
const [loading, setLoading] = useState(true)
|
|
53
95
|
const [search, setSearch] = useState('')
|
|
54
96
|
|
|
97
|
+
const [error, setError] = useState<string | null>(null)
|
|
98
|
+
|
|
55
99
|
const load = useCallback(async (opts?: ListProductsOptions) => {
|
|
56
100
|
setLoading(true)
|
|
101
|
+
setError(null)
|
|
57
102
|
try {
|
|
58
103
|
const data = await resolveShopProvider(provider).listProducts(opts)
|
|
59
104
|
setProducts(data)
|
|
105
|
+
} catch (err: unknown) {
|
|
106
|
+
setError(err instanceof Error ? err.message : String(err))
|
|
60
107
|
} finally {
|
|
61
108
|
setLoading(false)
|
|
62
109
|
}
|
|
@@ -104,10 +151,11 @@ function ProductsTab({ provider }: ShopPageProps) {
|
|
|
104
151
|
<span className="text-sm text-muted-foreground">{products.length} produtos</span>
|
|
105
152
|
</div>
|
|
106
153
|
|
|
107
|
-
{
|
|
108
|
-
<
|
|
154
|
+
{error ? (
|
|
155
|
+
<ErrorState message={error} />
|
|
109
156
|
) : (
|
|
110
157
|
<DataTable
|
|
158
|
+
loading={loading}
|
|
111
159
|
columns={columns}
|
|
112
160
|
data={products}
|
|
113
161
|
variant="card"
|
|
@@ -123,18 +171,30 @@ function ProductsTab({ provider }: ShopPageProps) {
|
|
|
123
171
|
// ---------------------------------------------------------------------------
|
|
124
172
|
|
|
125
173
|
function OrdersTab({ provider }: ShopPageProps) {
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
resolveShopProvider(provider).listOrders({ limit: 50 }).then(setOrders).finally(() => setLoading(false))
|
|
131
|
-
}, [provider])
|
|
174
|
+
const { items: orders, loading, error } = useShopList<Order>(
|
|
175
|
+
() => resolveShopProvider(provider).listOrders({ limit: 50 }),
|
|
176
|
+
[provider],
|
|
177
|
+
)
|
|
132
178
|
|
|
133
179
|
const columns = useMemo<ColumnDef<Order, any>[]>(() => [
|
|
134
180
|
{ accessorKey: 'orderNumber', header: 'Pedido', cell: ({ getValue }) => <span className="font-medium">#{getValue() as string}</span> },
|
|
135
181
|
{
|
|
136
182
|
id: 'customer', accessorFn: (o) => o.customerName ?? o.customerEmail ?? '—', header: 'Cliente',
|
|
137
|
-
|
|
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={`#/shop/customers/${id}`}
|
|
195
|
+
/>
|
|
196
|
+
)
|
|
197
|
+
},
|
|
138
198
|
},
|
|
139
199
|
{
|
|
140
200
|
accessorKey: 'total', header: () => <span className="block text-right">Total</span>,
|
|
@@ -156,20 +216,32 @@ function OrdersTab({ provider }: ShopPageProps) {
|
|
|
156
216
|
},
|
|
157
217
|
{
|
|
158
218
|
accessorKey: 'createdAt', header: 'Data',
|
|
159
|
-
cell: ({ getValue }) =>
|
|
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
|
+
},
|
|
160
230
|
},
|
|
161
231
|
], [])
|
|
162
232
|
|
|
163
233
|
return (
|
|
164
234
|
<div>
|
|
165
|
-
{
|
|
166
|
-
<
|
|
235
|
+
{error ? (
|
|
236
|
+
<ErrorState message={error} />
|
|
167
237
|
) : (
|
|
168
238
|
<DataTable
|
|
239
|
+
loading={loading}
|
|
169
240
|
columns={columns}
|
|
170
241
|
data={orders}
|
|
171
242
|
variant="card"
|
|
172
243
|
emptyMessage="Nenhum pedido ainda."
|
|
244
|
+
onRowClick={(row: Order) => adminNavigateTo(`/shop/orders/${row.id}`)}
|
|
173
245
|
/>
|
|
174
246
|
)}
|
|
175
247
|
</div>
|
|
@@ -181,12 +253,10 @@ function OrdersTab({ provider }: ShopPageProps) {
|
|
|
181
253
|
// ---------------------------------------------------------------------------
|
|
182
254
|
|
|
183
255
|
function CustomersTab({ provider }: ShopPageProps) {
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
resolveShopProvider(provider).listCustomers({ limit: 50 }).then(setCustomers).finally(() => setLoading(false))
|
|
189
|
-
}, [provider])
|
|
256
|
+
const { items: customers, loading, error } = useShopList<ShopCustomer>(
|
|
257
|
+
() => resolveShopProvider(provider).listCustomers({ limit: 50 }),
|
|
258
|
+
[provider],
|
|
259
|
+
)
|
|
190
260
|
|
|
191
261
|
const columns = useMemo<ColumnDef<ShopCustomer, any>[]>(() => [
|
|
192
262
|
{
|
|
@@ -207,10 +277,11 @@ function CustomersTab({ provider }: ShopPageProps) {
|
|
|
207
277
|
|
|
208
278
|
return (
|
|
209
279
|
<div>
|
|
210
|
-
{
|
|
211
|
-
<
|
|
280
|
+
{error ? (
|
|
281
|
+
<ErrorState message={error} />
|
|
212
282
|
) : (
|
|
213
283
|
<DataTable
|
|
284
|
+
loading={loading}
|
|
214
285
|
columns={columns}
|
|
215
286
|
data={customers}
|
|
216
287
|
variant="card"
|
|
@@ -226,12 +297,10 @@ function CustomersTab({ provider }: ShopPageProps) {
|
|
|
226
297
|
// ---------------------------------------------------------------------------
|
|
227
298
|
|
|
228
299
|
function DiscountsTab({ provider }: ShopPageProps) {
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
resolveShopProvider(provider).listDiscounts({ limit: 50 }).then(setDiscounts).finally(() => setLoading(false))
|
|
234
|
-
}, [provider])
|
|
300
|
+
const { items: discounts, loading, error } = useShopList<Discount>(
|
|
301
|
+
() => resolveShopProvider(provider).listDiscounts({ limit: 50 }),
|
|
302
|
+
[provider],
|
|
303
|
+
)
|
|
235
304
|
|
|
236
305
|
const columns = useMemo<ColumnDef<Discount, any>[]>(() => [
|
|
237
306
|
{ accessorKey: 'title', header: 'Título', cell: ({ getValue }) => <span className="font-medium">{getValue() as string}</span> },
|
|
@@ -256,10 +325,11 @@ function DiscountsTab({ provider }: ShopPageProps) {
|
|
|
256
325
|
|
|
257
326
|
return (
|
|
258
327
|
<div>
|
|
259
|
-
{
|
|
260
|
-
<
|
|
328
|
+
{error ? (
|
|
329
|
+
<ErrorState message={error} />
|
|
261
330
|
) : (
|
|
262
331
|
<DataTable
|
|
332
|
+
loading={loading}
|
|
263
333
|
columns={columns}
|
|
264
334
|
data={discounts}
|
|
265
335
|
variant="card"
|
|
@@ -283,39 +353,71 @@ const TABS = [
|
|
|
283
353
|
|
|
284
354
|
type TabId = typeof TABS[number]['id']
|
|
285
355
|
|
|
286
|
-
|
|
287
|
-
|
|
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.' }
|
|
288
389
|
|
|
289
390
|
return (
|
|
290
391
|
<div className="p-6 max-w-7xl mx-auto">
|
|
291
392
|
<div className="mb-6">
|
|
292
|
-
<h1 className="text-2xl font-semibold">
|
|
293
|
-
<p className="text-muted-foreground mt-1">
|
|
393
|
+
<h1 className="text-2xl font-semibold">{copy.title}</h1>
|
|
394
|
+
<p className="text-muted-foreground mt-1">{copy.subtitle}</p>
|
|
294
395
|
</div>
|
|
295
396
|
|
|
296
|
-
{
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
+
)}
|
|
314
416
|
|
|
315
|
-
{
|
|
316
|
-
{
|
|
317
|
-
{
|
|
318
|
-
{
|
|
417
|
+
{active === 'products' && <ProductsTab provider={provider} />}
|
|
418
|
+
{active === 'orders' && <OrdersTab provider={provider} />}
|
|
419
|
+
{active === 'customers' && <CustomersTab provider={provider} />}
|
|
420
|
+
{active === 'discounts' && <DiscountsTab provider={provider} />}
|
|
319
421
|
</div>
|
|
320
422
|
)
|
|
321
423
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { CrudQuery, CrudResult } from '@fayz-ai/core'
|
|
2
|
+
import type { ShopProvider } from '@fayz-ai/shop'
|
|
3
|
+
|
|
4
|
+
export type ShopProviderResolver = ShopProvider | (() => ShopProvider)
|
|
5
|
+
|
|
6
|
+
export const resolve = (p: ShopProviderResolver): ShopProvider => (typeof p === 'function' ? p() : p)
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Paging, sorting and facet filtering applied client-side: the shop provider's
|
|
10
|
+
* list methods take a search term but no offset, sort or filter parameters.
|
|
11
|
+
* Fine at storefront catalogue sizes; revisit if a tenant grows past a few
|
|
12
|
+
* thousand rows, where it should move into the query.
|
|
13
|
+
*/
|
|
14
|
+
export function applyQuery<T>(
|
|
15
|
+
rows: T[],
|
|
16
|
+
query: CrudQuery,
|
|
17
|
+
facetKey?: keyof T & string,
|
|
18
|
+
): CrudResult<T> {
|
|
19
|
+
const prop = (row: T, key: string): unknown => (row as Record<string, unknown>)[key]
|
|
20
|
+
let data = rows
|
|
21
|
+
|
|
22
|
+
if (facetKey) {
|
|
23
|
+
const wanted = query.filters?.[facetKey]
|
|
24
|
+
if (wanted && wanted !== 'all') data = data.filter((row) => prop(row, facetKey) === wanted)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (query.sortBy) {
|
|
28
|
+
const dir = query.sortDir === 'desc' ? -1 : 1
|
|
29
|
+
const key = query.sortBy
|
|
30
|
+
data = [...data].sort((a, b) => {
|
|
31
|
+
const av = prop(a, key)
|
|
32
|
+
const bv = prop(b, key)
|
|
33
|
+
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir
|
|
34
|
+
return String(av ?? '').localeCompare(String(bv ?? ''), 'pt-BR') * dir
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const total = data.length
|
|
39
|
+
const page = query.page ?? 1
|
|
40
|
+
const size = query.pageSize ?? total
|
|
41
|
+
return { data: data.slice((page - 1) * size, page * size), total }
|
|
42
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { CrudQuery, CrudResult, DataProvider } from '@fayz-ai/core'
|
|
2
|
+
import type { CreateShippingZoneInput, ShippingZone, UpdateShippingZoneInput } from '@fayz-ai/shop'
|
|
3
|
+
import { applyQuery, resolve, type ShopProviderResolver } from './shared'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Delivery zones CRUD.
|
|
7
|
+
*
|
|
8
|
+
* The form takes CEPs however the merchant types them ('22.041-001', '22041001')
|
|
9
|
+
* and the provider normalises to the 8 bare digits shipping_zones compares on —
|
|
10
|
+
* asking a shop owner to strip punctuation is how a range silently stops
|
|
11
|
+
* matching and a whole city stops being served.
|
|
12
|
+
*/
|
|
13
|
+
export function createShopShippingZoneDataProvider(
|
|
14
|
+
provider: ShopProviderResolver,
|
|
15
|
+
): DataProvider<ShippingZone> {
|
|
16
|
+
const shop = () => {
|
|
17
|
+
const p = resolve(provider)
|
|
18
|
+
if (!p.listShippingZones) {
|
|
19
|
+
throw new Error('Este provider de loja não suporta zonas de entrega.')
|
|
20
|
+
}
|
|
21
|
+
return p
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
async list(query: CrudQuery): Promise<CrudResult<ShippingZone>> {
|
|
26
|
+
const rows = await shop().listShippingZones!()
|
|
27
|
+
const term = query.search?.trim().toLowerCase()
|
|
28
|
+
const filtered = term
|
|
29
|
+
? rows.filter((z) =>
|
|
30
|
+
z.name.toLowerCase().includes(term) || (z.carrier ?? '').toLowerCase().includes(term))
|
|
31
|
+
: rows
|
|
32
|
+
return applyQuery(filtered, query)
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
async create(data): Promise<ShippingZone> {
|
|
36
|
+
return shop().createShippingZone!(data as unknown as CreateShippingZoneInput)
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
async update(id: string, data: Partial<ShippingZone>): Promise<ShippingZone> {
|
|
40
|
+
// tenantId is set from the session on the server side; never from the form.
|
|
41
|
+
const { id: _id, tenantId: _tenant, ...patch } = data
|
|
42
|
+
return shop().updateShippingZone!(id, patch as UpdateShippingZoneInput)
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
async remove(id: string): Promise<void> {
|
|
46
|
+
await shop().deleteShippingZone!(id)
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
}
|