@fayz-ai/plugin-shop 0.8.0 → 0.8.3

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.
Files changed (55) hide show
  1. package/dist/ShopPage-ALBPZAEW.cjs +745 -0
  2. package/dist/ShopPage-ALBPZAEW.cjs.map +1 -0
  3. package/dist/ShopPage-TVNY74FF.js +743 -0
  4. package/dist/ShopPage-TVNY74FF.js.map +1 -0
  5. package/dist/ShopPage.d.ts +23 -1
  6. package/dist/ShopPage.d.ts.map +1 -1
  7. package/dist/data/customerDataProvider.d.ts +5 -0
  8. package/dist/data/customerDataProvider.d.ts.map +1 -0
  9. package/dist/data/discountDataProvider.d.ts +5 -0
  10. package/dist/data/discountDataProvider.d.ts.map +1 -0
  11. package/dist/data/productDataProvider.d.ts +6 -0
  12. package/dist/data/productDataProvider.d.ts.map +1 -0
  13. package/dist/data/shared.d.ts +12 -0
  14. package/dist/data/shared.d.ts.map +1 -0
  15. package/dist/data/shippingZoneDataProvider.d.ts +13 -0
  16. package/dist/data/shippingZoneDataProvider.d.ts.map +1 -0
  17. package/dist/index.cjs +983 -26
  18. package/dist/index.cjs.map +1 -1
  19. package/dist/index.d.ts +2 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +984 -27
  22. package/dist/index.js.map +1 -1
  23. package/dist/views/OrderDetailView.d.ts +10 -0
  24. package/dist/views/OrderDetailView.d.ts.map +1 -0
  25. package/dist/views/ProductImagesTab.d.ts +19 -0
  26. package/dist/views/ProductImagesTab.d.ts.map +1 -0
  27. package/dist/views/customerEntity.d.ts +4 -0
  28. package/dist/views/customerEntity.d.ts.map +1 -0
  29. package/dist/views/customerLookup.d.ts +22 -0
  30. package/dist/views/customerLookup.d.ts.map +1 -0
  31. package/dist/views/discountEntity.d.ts +4 -0
  32. package/dist/views/discountEntity.d.ts.map +1 -0
  33. package/dist/views/productEntity.d.ts +6 -0
  34. package/dist/views/productEntity.d.ts.map +1 -0
  35. package/dist/views/shippingZoneEntity.d.ts +4 -0
  36. package/dist/views/shippingZoneEntity.d.ts.map +1 -0
  37. package/package.json +5 -5
  38. package/src/ShopPage.tsx +156 -54
  39. package/src/data/customerDataProvider.ts +31 -0
  40. package/src/data/discountDataProvider.ts +42 -0
  41. package/src/data/productDataProvider.ts +54 -0
  42. package/src/data/shared.ts +42 -0
  43. package/src/data/shippingZoneDataProvider.ts +49 -0
  44. package/src/index.ts +101 -12
  45. package/src/views/OrderDetailView.tsx +579 -0
  46. package/src/views/ProductImagesTab.tsx +183 -0
  47. package/src/views/customerEntity.tsx +110 -0
  48. package/src/views/customerLookup.ts +31 -0
  49. package/src/views/discountEntity.tsx +137 -0
  50. package/src/views/productEntity.tsx +229 -0
  51. package/src/views/shippingZoneEntity.tsx +123 -0
  52. package/dist/ShopPage-65PPVKBD.js +0 -271
  53. package/dist/ShopPage-65PPVKBD.js.map +0 -1
  54. package/dist/ShopPage-PG5GKOZU.cjs +0 -273
  55. 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
- {loading ? (
108
- <div className="py-12 text-center text-sm text-muted-foreground">Carregando...</div>
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 [orders, setOrders] = useState<Order[]>([])
127
- const [loading, setLoading] = useState(true)
128
-
129
- useEffect(() => {
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
- cell: ({ getValue }) => <span>{getValue() as string}</span>,
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 }) => <span className="text-muted-foreground">{new Date(getValue() as string).toLocaleDateString('pt-BR')}</span>,
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
- {loading ? (
166
- <div className="py-12 text-center text-sm text-muted-foreground">Carregando...</div>
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 [customers, setCustomers] = useState<ShopCustomer[]>([])
185
- const [loading, setLoading] = useState(true)
186
-
187
- useEffect(() => {
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
- {loading ? (
211
- <div className="py-12 text-center text-sm text-muted-foreground">Carregando...</div>
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 [discounts, setDiscounts] = useState<Discount[]>([])
230
- const [loading, setLoading] = useState(true)
231
-
232
- useEffect(() => {
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
- {loading ? (
260
- <div className="py-12 text-center text-sm text-muted-foreground">Carregando...</div>
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
- export const ShopPage: React.FC<ShopPageProps> = ({ provider }) => {
287
- const [tab, setTab] = useState<TabId>('products')
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">Loja</h1>
293
- <p className="text-muted-foreground mt-1">Gerencie produtos, pedidos, clientes e descontos.</p>
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
- {/* Tabs */}
297
- <div className="border-b mb-6">
298
- <nav className="flex gap-1">
299
- {TABS.map(t => (
300
- <button
301
- key={t.id}
302
- onClick={() => setTab(t.id)}
303
- className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
304
- tab === t.id
305
- ? 'border-primary text-primary'
306
- : 'border-transparent text-muted-foreground hover:text-foreground'
307
- }`}
308
- >
309
- {t.label}
310
- </button>
311
- ))}
312
- </nav>
313
- </div>
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
- {tab === 'products' && <ProductsTab provider={provider} />}
316
- {tab === 'orders' && <OrdersTab provider={provider} />}
317
- {tab === 'customers' && <CustomersTab provider={provider} />}
318
- {tab === 'discounts' && <DiscountsTab provider={provider} />}
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
+ }