@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.
Files changed (56) hide show
  1. package/README.md +4 -4
  2. package/dist/{ShopPage-JP27MZS2.js → ShopPage-PVHFKDF3.js} +7 -7
  3. package/dist/ShopPage-PVHFKDF3.js.map +1 -0
  4. package/dist/data/categoryDataProvider.d.ts +5 -0
  5. package/dist/data/categoryDataProvider.d.ts.map +1 -0
  6. package/dist/data/collectionDataProvider.d.ts +5 -0
  7. package/dist/data/collectionDataProvider.d.ts.map +1 -0
  8. package/dist/data/customerGroupDataProvider.d.ts +5 -0
  9. package/dist/data/customerGroupDataProvider.d.ts.map +1 -0
  10. package/dist/data/customerSegmentDataProvider.d.ts +16 -0
  11. package/dist/data/customerSegmentDataProvider.d.ts.map +1 -0
  12. package/dist/data/giftCardDataProvider.d.ts +5 -0
  13. package/dist/data/giftCardDataProvider.d.ts.map +1 -0
  14. package/dist/index.d.ts +3 -3
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +1088 -86
  17. package/dist/index.js.map +1 -1
  18. package/dist/views/ChannelsPage.d.ts +1 -1
  19. package/dist/views/ChannelsPage.d.ts.map +1 -1
  20. package/dist/views/ContentPage.d.ts +7 -0
  21. package/dist/views/ContentPage.d.ts.map +1 -0
  22. package/dist/views/EmailTemplatePage.d.ts +9 -0
  23. package/dist/views/EmailTemplatePage.d.ts.map +1 -0
  24. package/dist/views/EmailsPage.d.ts +7 -0
  25. package/dist/views/EmailsPage.d.ts.map +1 -0
  26. package/dist/views/ShopPage.d.ts.map +1 -0
  27. package/dist/views/categoryEntity.d.ts +4 -0
  28. package/dist/views/categoryEntity.d.ts.map +1 -0
  29. package/dist/views/collectionEntity.d.ts +4 -0
  30. package/dist/views/collectionEntity.d.ts.map +1 -0
  31. package/dist/views/customerGroupEntity.d.ts +4 -0
  32. package/dist/views/customerGroupEntity.d.ts.map +1 -0
  33. package/dist/views/customerSegmentEntity.d.ts +13 -0
  34. package/dist/views/customerSegmentEntity.d.ts.map +1 -0
  35. package/dist/views/giftCardEntity.d.ts +4 -0
  36. package/dist/views/giftCardEntity.d.ts.map +1 -0
  37. package/package.json +6 -8
  38. package/dist/ShopPage-JP27MZS2.js.map +0 -1
  39. package/dist/ShopPage.d.ts.map +0 -1
  40. package/src/ShopPage.tsx +0 -425
  41. package/src/components/ShopSettings.tsx +0 -108
  42. package/src/data/customerDataProvider.ts +0 -31
  43. package/src/data/discountDataProvider.ts +0 -42
  44. package/src/data/productDataProvider.ts +0 -54
  45. package/src/data/shared.ts +0 -42
  46. package/src/data/shippingZoneDataProvider.ts +0 -49
  47. package/src/index.ts +0 -270
  48. package/src/views/ChannelsPage.tsx +0 -274
  49. package/src/views/OrderDetailView.tsx +0 -319
  50. package/src/views/ProductImagesTab.tsx +0 -183
  51. package/src/views/customerEntity.tsx +0 -110
  52. package/src/views/customerLookup.ts +0 -31
  53. package/src/views/discountEntity.tsx +0 -144
  54. package/src/views/productEntity.tsx +0 -229
  55. package/src/views/shippingZoneEntity.tsx +0 -123
  56. /package/dist/{ShopPage.d.ts → views/ShopPage.d.ts} +0 -0
@@ -1,319 +0,0 @@
1
- import React, { useEffect, useState } from 'react'
2
- import {
3
- adminNavigateTo, PersonLink,
4
- OrderDetailView as SharedOrderDetailView, OrderDetailSkeleton, OrderDetailBlock, OrderDetailRow,
5
- type OrderDetailData, type OrderDetailInstallment, type OrderDetailTone,
6
- } from '@fayz-ai/saas'
7
- import { shopCustomerLookup } from './customerLookup'
8
- import type { Order, ShopProvider } from '@fayz-ai/shop'
9
- import { getSupabaseClientOptional } from '@fayz-ai/core'
10
-
11
- /** A shipment as stored in public.fulfillments (0018). */
12
- type Fulfillment = {
13
- id: string
14
- status: string
15
- carrier: string | null
16
- service: string | null
17
- tracking_code: string | null
18
- tracking_url: string | null
19
- shipped_at: string | null
20
- estimated_delivery_at: string | null
21
- delivered_at: string | null
22
- }
23
-
24
- type Bill = {
25
- id: string
26
- amount: number
27
- paid_amount: number | null
28
- status: string
29
- due_date: string
30
- installment_number: number | null
31
- }
32
- type Receivable = {
33
- balance: { amount: number; paid: number; balance: number; status: string; reference_number: string | null }
34
- bills: Bill[]
35
- }
36
-
37
- const money = (v: number, currency = 'BRL') =>
38
- new Intl.NumberFormat('pt-BR', { style: 'currency', currency }).format(Number(v) || 0)
39
- const dateTime = (iso: string) =>
40
- new Intl.DateTimeFormat('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(iso))
41
- const dateOnly = (iso: string) => new Date(iso).toLocaleDateString('pt-BR')
42
-
43
- const PAYMENT_LABEL: Record<string, string> = {
44
- pix: 'Pix', credit_card: 'Cartão de crédito', debit_card: 'Cartão de débito',
45
- boleto: 'Boleto', cash: 'Dinheiro', other: 'Outro',
46
- }
47
- const LABEL: Record<string, string> = {
48
- paid: 'Pago', pending: 'Aguardando pagamento', refunded: 'Estornado',
49
- partially_refunded: 'Estorno parcial', voided: 'Cancelado',
50
- fulfilled: 'Enviado', unfulfilled: 'Não enviado', partially_fulfilled: 'Envio parcial',
51
- open: 'Aberto', cancelled: 'Cancelado', archived: 'Arquivado',
52
- }
53
- const RECEIVABLE_LABEL: Record<string, string> = {
54
- open: 'Em aberto', partial: 'Parcialmente recebido', paid: 'Recebido', overdue: 'Vencido', cancelled: 'Cancelado',
55
- }
56
- const SHIPMENT_LABEL: Record<string, string> = {
57
- pending: 'Aguardando coleta', in_transit: 'Em trânsito', delivered: 'Entregue',
58
- failed: 'Falhou', returned: 'Devolvido', cancelled: 'Cancelado',
59
- }
60
-
61
- /** Map a shop/receivable status string to the shared component's tone. */
62
- function toneOf(value: string): OrderDetailTone {
63
- switch (value) {
64
- case 'paid': case 'fulfilled': case 'delivered': return 'success'
65
- case 'pending': case 'partial': case 'partially_fulfilled': case 'open': case 'in_transit': return 'warning'
66
- case 'refunded': case 'partially_refunded': case 'voided': case 'cancelled': case 'overdue': case 'failed': case 'returned': return 'destructive'
67
- default: return 'muted'
68
- }
69
- }
70
-
71
- /** Small inline badge for the shop-specific blocks (Entrega/Histórico). */
72
- function InlineBadge({ value }: { value: string }) {
73
- const tone = toneOf(value)
74
- const cls: Record<OrderDetailTone, string> = {
75
- success: 'bg-emerald-100 text-emerald-800', warning: 'bg-amber-100 text-amber-800',
76
- destructive: 'bg-red-100 text-red-700', info: 'bg-sky-100 text-sky-800', muted: 'bg-muted text-muted-foreground',
77
- }
78
- return <span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${cls[tone]}`}>{LABEL[value] ?? value}</span>
79
- }
80
-
81
- function deliveryFromNotes(notes: string | null): string | null {
82
- if (!notes) return null
83
- const match = notes.match(/entrega:\s*(.+)/i)
84
- return (match?.[1] ?? '').trim() || null
85
- }
86
-
87
- // Cache por id (fora do componente) — trocar de aba remonta a view; sem cache
88
- // isso refaz o getOrder (skeleton + request) toda vez. Cache-first evita o refetch;
89
- // "Registrar recebimento" atualiza o cache.
90
- const shopOrderCache = new Map<string, Order>()
91
- const shopShipmentsCache = new Map<string, Fulfillment[]>()
92
- const shopReceivableCache = new Map<string, Receivable | null>()
93
-
94
- export function OrderDetailView({ orderId, provider, onBack }: {
95
- orderId: string
96
- provider: () => ShopProvider
97
- onBack: () => void
98
- }) {
99
- const [order, setOrder] = useState<Order | null>(() => shopOrderCache.get(orderId) ?? null)
100
- const [shipments, setShipments] = useState<Fulfillment[]>(() => shopShipmentsCache.get(orderId) ?? [])
101
- const [loading, setLoading] = useState(() => !shopOrderCache.has(orderId))
102
- const [error, setError] = useState<string | null>(null)
103
- const [confirming, setConfirming] = useState(false)
104
- const [confirmError, setConfirmError] = useState<string | null>(null)
105
- const [receivable, setReceivable] = useState<Receivable | null>(() => shopReceivableCache.get(orderId) ?? null)
106
-
107
- useEffect(() => {
108
- let cancelled = false
109
- const cached = shopOrderCache.get(orderId)
110
- if (cached) { setOrder(cached); setLoading(false); return () => { cancelled = true } } // cache hit → sem request
111
- setLoading(true); setError(null)
112
- provider().getOrder(orderId)
113
- .then((o) => { if (!cancelled) { if (o) shopOrderCache.set(orderId, o); setOrder(o) } })
114
- .catch((err: unknown) => { if (!cancelled) setError(err instanceof Error ? err.message : String(err)) })
115
- .finally(() => { if (!cancelled) setLoading(false) })
116
- return () => { cancelled = true }
117
- }, [orderId, provider])
118
-
119
- useEffect(() => {
120
- let cancelled = false
121
- const db = getSupabaseClientOptional() as unknown as { from: (t: string) => any } | null
122
- if (!db) return
123
- if (shopShipmentsCache.has(orderId)) return () => { cancelled = true } // cache hit → sem request
124
- db.from('fulfillments').select('*').eq('order_id', orderId).order('created_at', { ascending: false })
125
- .then(({ data }: { data: Fulfillment[] | null }) => { if (!cancelled) { shopShipmentsCache.set(orderId, data ?? []); setShipments(data ?? []) } })
126
- return () => { cancelled = true }
127
- }, [orderId])
128
-
129
- // The order does not decide whether it was paid — the receivable does. Keep this
130
- // hook ABOVE the early returns (hook order must be stable).
131
- useEffect(() => {
132
- let cancelled = false
133
- const db = getSupabaseClientOptional() as unknown as { from: (t: string) => any } | null
134
- if (!db) return
135
- if (shopReceivableCache.has(orderId)) { setReceivable(shopReceivableCache.get(orderId) ?? null); return () => { cancelled = true } } // cache hit → sem request
136
- void (async () => {
137
- const [balance, bills] = await Promise.all([
138
- db.from('v_invoice_balances').select('*').eq('invoice_id', orderId).maybeSingle(),
139
- db.from('plg_financial_movements').select('*').eq('invoice_id', orderId).eq('movement_kind', 'bill').order('installment_number', { ascending: true }),
140
- ])
141
- if (cancelled || !balance?.data) return
142
- const rec = { balance: balance.data, bills: bills?.data ?? [] }
143
- shopReceivableCache.set(orderId, rec)
144
- setReceivable(rec)
145
- })()
146
- return () => { cancelled = true }
147
- }, [orderId, confirming])
148
-
149
- if (loading) return <OrderDetailSkeleton />
150
- if (error) {
151
- return (
152
- <div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
153
- <p className="font-medium">Não foi possível carregar o pedido.</p>
154
- <p className="mt-1 text-red-700">{error}</p>
155
- </div>
156
- )
157
- }
158
- if (!order) return <div className="py-12 text-center text-sm text-muted-foreground">Pedido não encontrado.</div>
159
-
160
- const cur = order.currency
161
- const delivery = deliveryFromNotes(order.notes)
162
- const addr = order.shippingAddress
163
-
164
- async function registerReceipt() {
165
- if (!order) return
166
- shopReceivableCache.delete(order.id) // pagamento muda o recebível → força refetch
167
- setConfirming(true); setConfirmError(null)
168
- try {
169
- const fresh = (await provider().confirmPayment?.(order.id)) ?? (await provider().getOrder(order.id))
170
- if (fresh) { shopOrderCache.set(order.id, fresh); setOrder(fresh) } // atualiza o cache
171
- } catch (err: unknown) {
172
- setConfirmError(err instanceof Error ? err.message : String(err))
173
- } finally { setConfirming(false) }
174
- }
175
-
176
- const installments: OrderDetailInstallment[] = (receivable && receivable.bills.length > 1)
177
- ? receivable.bills.map((b) => ({
178
- number: b.installment_number ?? 0,
179
- amount: `${money(Number(b.paid_amount ?? 0), cur)} / ${money(Number(b.amount), cur)}`,
180
- status: RECEIVABLE_LABEL[b.status] ?? b.status,
181
- statusTone: toneOf(b.status),
182
- dueDate: `vence ${dateOnly(b.due_date)}`,
183
- }))
184
- : []
185
-
186
- const data: OrderDetailData = {
187
- title: `Pedido #${order.orderNumber}`,
188
- subtitle: dateTime(order.createdAt),
189
- badges: [
190
- { label: LABEL[order.financialStatus] ?? order.financialStatus, tone: toneOf(order.financialStatus) },
191
- { label: LABEL[order.fulfillmentStatus] ?? order.fulfillmentStatus, tone: toneOf(order.fulfillmentStatus) },
192
- ],
193
- customer: { name: order.customerName, email: order.customerEmail },
194
- customerNode: (
195
- <div className="space-y-1 text-sm">
196
- {order.customerId ? (
197
- <PersonLink
198
- personId={order.customerId}
199
- name={order.customerName ?? order.customerEmail ?? 'Cliente'}
200
- lookup={shopCustomerLookup(provider)}
201
- profileHref={`#/customers/${order.customerId}`}
202
- className="font-medium"
203
- />
204
- ) : (
205
- <p className="font-medium">{order.customerName ?? '—'}</p>
206
- )}
207
- {order.customerEmail && <p className="text-muted-foreground">{order.customerEmail}</p>}
208
- {!order.customerId && <p className="text-xs text-muted-foreground">Compra sem cadastro — não há ficha de cliente para abrir.</p>}
209
- </div>
210
- ),
211
- items: order.items.map((it) => ({
212
- id: it.id, name: it.name, description: it.sku, imageUrl: it.imageUrl,
213
- quantity: it.quantity, unitPrice: money(it.unitPrice, cur), total: money(it.total, cur),
214
- })),
215
- totals: [
216
- { label: 'Subtotal', value: money(order.subtotal, cur) },
217
- ...(order.discountTotal > 0 ? [{ label: `Desconto${order.discountCode ? ` (${order.discountCode})` : ''}`, value: `− ${money(order.discountTotal, cur)}`, muted: true }] : []),
218
- { label: 'Entrega', value: money(order.shippingTotal, cur) },
219
- { label: 'Impostos', value: money(order.taxTotal, cur) },
220
- { label: 'Total', value: money(order.total, cur), strong: true },
221
- ],
222
- paymentMethod: order.paymentMethodKind ? (PAYMENT_LABEL[order.paymentMethodKind] ?? order.paymentMethodKind) : undefined,
223
- paidAt: order.paidAt ? dateTime(order.paidAt) : undefined,
224
- receivable: receivable ? {
225
- referenceNumber: receivable.balance.reference_number ?? undefined,
226
- statusBadge: { label: RECEIVABLE_LABEL[receivable.balance.status] ?? receivable.balance.status, tone: toneOf(receivable.balance.status) },
227
- rows: [
228
- { label: 'Faturado', value: money(receivable.balance.amount, cur) },
229
- { label: 'Recebido', value: money(receivable.balance.paid, cur) },
230
- { label: 'Saldo', value: money(receivable.balance.balance, cur), strong: true },
231
- ],
232
- installments,
233
- actions: (
234
- <>
235
- {receivable.balance.balance > 0 && receivable.balance.status !== 'cancelled' && (
236
- <button type="button" onClick={registerReceipt} disabled={confirming} data-testid="order-register-receipt"
237
- className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition hover:opacity-90 disabled:opacity-60">
238
- {confirming ? 'Registrando…' : 'Registrar recebimento'}
239
- </button>
240
- )}
241
- <button type="button" onClick={() => adminNavigateTo('/financial')}
242
- className="rounded-md border px-3 py-1.5 text-sm font-medium transition hover:bg-muted">
243
- Abrir no Financeiro
244
- </button>
245
- {confirmError && <p className="mt-2 w-full rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-800">{confirmError}</p>}
246
- </>
247
- ),
248
- emptyLabel: 'Sem conta a receber para este pedido.',
249
- } : { rows: [], installments: [], emptyLabel: 'Sem conta a receber para este pedido.' },
250
- extraSections: (
251
- <>
252
- <OrderDetailBlock title="Entrega" hint="O que a logística precisa">
253
- <div className="space-y-2 text-sm">
254
- {addr ? (
255
- <div className="space-y-0.5">
256
- <p>{[addr.street, addr.number].filter(Boolean).join(', ')}{addr.complement ? ` — ${addr.complement}` : ''}</p>
257
- {addr.district && <p className="text-muted-foreground">{addr.district}</p>}
258
- <p className="text-muted-foreground">
259
- {[addr.city, addr.state].filter((v) => v && v !== '—').join('/')}{addr.postal_code ? ` · ${addr.postal_code}` : ''}
260
- </p>
261
- {(addr.recipient || addr.phone) && <p className="text-xs text-muted-foreground">{[addr.recipient, addr.phone].filter(Boolean).join(' · ')}</p>}
262
- </div>
263
- ) : (
264
- <p className={delivery ? '' : 'text-muted-foreground'}>{delivery ?? 'Nenhum endereço registrado neste pedido.'}</p>
265
- )}
266
- <div className="border-t pt-2">
267
- <OrderDetailRow label="Status" value={<InlineBadge value={order.fulfillmentStatus} />} />
268
- {shipments.length === 0 ? (
269
- <p className="mt-1 text-xs text-muted-foreground">Nenhum envio registrado.</p>
270
- ) : (
271
- <div className="mt-2 space-y-2">
272
- {shipments.map((s) => (
273
- <div key={s.id} className="rounded-md border px-2 py-1.5">
274
- <div className="flex items-center justify-between gap-2">
275
- <span className="text-sm font-medium">{[s.carrier, s.service].filter(Boolean).join(' · ') || 'Envio'}</span>
276
- <span className="text-[10px] text-muted-foreground">{SHIPMENT_LABEL[s.status] ?? s.status}</span>
277
- </div>
278
- {s.tracking_code && (
279
- <p className="mt-0.5 font-mono text-xs">
280
- {s.tracking_url ? <a href={s.tracking_url} target="_blank" rel="noreferrer" className="text-primary hover:underline">{s.tracking_code}</a> : s.tracking_code}
281
- </p>
282
- )}
283
- <div className="mt-1 space-y-0.5 text-[11px] text-muted-foreground">
284
- {s.shipped_at && <p>Despachado em {dateTime(s.shipped_at)}</p>}
285
- {s.estimated_delivery_at && <p>Previsão: {dateTime(s.estimated_delivery_at)}</p>}
286
- {s.delivered_at && <p>Entregue em {dateTime(s.delivered_at)}</p>}
287
- </div>
288
- </div>
289
- ))}
290
- </div>
291
- )}
292
- </div>
293
- </div>
294
- </OrderDetailBlock>
295
-
296
- <OrderDetailBlock title="Histórico">
297
- <div className="space-y-1 text-sm">
298
- <OrderDetailRow label="Criado" value={dateTime(order.createdAt)} />
299
- <OrderDetailRow label="Atualizado" value={dateTime(order.updatedAt)} />
300
- <OrderDetailRow label="Situação" value={<InlineBadge value={order.status} />} />
301
- </div>
302
- </OrderDetailBlock>
303
-
304
- {order.notes && (
305
- <OrderDetailBlock title="Observações">
306
- <p className="whitespace-pre-wrap text-sm text-muted-foreground">{order.notes}</p>
307
- </OrderDetailBlock>
308
- )}
309
- </>
310
- ),
311
- onBack,
312
- backLabel: 'Pedidos',
313
- }
314
-
315
- return <SharedOrderDetailView {...data} />
316
- }
317
-
318
- export const orderDetailPath = (id: string) => `/shop/orders/${id}`
319
- export const goToOrder = (id: string) => adminNavigateTo(orderDetailPath(id))
@@ -1,183 +0,0 @@
1
- import React, { useEffect, useRef, useState } from 'react'
2
- import type { Product, ProductImage, ShopProvider } from '@fayz-ai/shop'
3
-
4
- /**
5
- * The product's whole gallery, not just its cover.
6
- *
7
- * plg_shop_product_images has always been a collection — sort_order, is_primary
8
- * — but the admin only ever exposed the single `EntityImageConfig` slot on the
9
- * form, so a merchant could set one photo and no more, and the storefront had
10
- * nothing to build a carousel from. This is the missing half.
11
- *
12
- * Deliberately not drag-and-drop: ordering is done with arrows because a
13
- * keyboard and a screen reader can use them, and because reordering four
14
- * photos of a rib is not worth a drag library.
15
- */
16
- export function ProductImagesTab({ item, getProvider }: {
17
- item: unknown
18
- getProvider: () => ShopProvider
19
- }) {
20
- const product = item as Product
21
- const [images, setImages] = useState<ProductImage[]>([])
22
- const [busy, setBusy] = useState(false)
23
- const [error, setError] = useState<string | null>(null)
24
- const fileRef = useRef<HTMLInputElement>(null)
25
-
26
- async function reload() {
27
- const fresh = await getProvider().getProduct(product.id)
28
- setImages(sortImages(fresh?.images ?? []))
29
- }
30
-
31
- useEffect(() => { void reload().catch(() => {}) }, [product.id])
32
-
33
- async function run(action: () => Promise<unknown>) {
34
- setBusy(true)
35
- setError(null)
36
- try {
37
- await action()
38
- await reload()
39
- } catch (err: unknown) {
40
- setError(err instanceof Error ? err.message : String(err))
41
- } finally {
42
- setBusy(false)
43
- }
44
- }
45
-
46
- async function upload(files: FileList | null) {
47
- if (!files?.length) return
48
- // Sequential, not Promise.all: each upload reads the current max sort_order
49
- // to place itself last, and running them together makes them all read the
50
- // same value and land on the same position.
51
- await run(async () => {
52
- for (const file of Array.from(files)) {
53
- await getProvider().uploadProductImage(product.id, file)
54
- }
55
- if (fileRef.current) fileRef.current.value = ''
56
- })
57
- }
58
-
59
- function move(index: number, delta: number) {
60
- const target = index + delta
61
- if (target < 0 || target >= images.length) return
62
- const a = images[index]!
63
- const b = images[target]!
64
- // Swap the two positions rather than renumbering the list: fewer writes, and
65
- // a failure halfway leaves an order that is still coherent.
66
- void run(async () => {
67
- const update = getProvider().updateProductImage
68
- if (!update) throw new Error('Este provider não suporta reordenar imagens.')
69
- await update(a.id, { sortOrder: b.sortOrder ?? target })
70
- await update(b.id, { sortOrder: a.sortOrder ?? index })
71
- })
72
- }
73
-
74
- const update = getProvider().updateProductImage
75
-
76
- return (
77
- <div className="space-y-4">
78
- <div className="flex flex-wrap items-center justify-between gap-3">
79
- <div>
80
- <h3 className="text-sm font-semibold">Fotos do produto</h3>
81
- <p className="text-xs text-muted-foreground">
82
- A primeira foto é a capa — aparece no catálogo, no carrinho e no pedido.
83
- As demais viram o carrossel na página do produto.
84
- </p>
85
- </div>
86
- <label className="inline-flex cursor-pointer items-center rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition hover:opacity-90">
87
- {busy ? 'Enviando…' : 'Adicionar fotos'}
88
- <input
89
- ref={fileRef}
90
- type="file"
91
- accept="image/*"
92
- multiple
93
- disabled={busy}
94
- className="hidden"
95
- onChange={(event) => void upload(event.target.files)}
96
- />
97
- </label>
98
- </div>
99
-
100
- {error && (
101
- <p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800">{error}</p>
102
- )}
103
-
104
- {images.length === 0 ? (
105
- <p className="rounded-lg border border-dashed px-4 py-10 text-center text-sm text-muted-foreground">
106
- Nenhuma foto ainda. Produtos com mais de uma foto vendem melhor — mostre a
107
- embalagem, o produto pronto e uma referência de tamanho.
108
- </p>
109
- ) : (
110
- <ul className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
111
- {images.map((image, index) => (
112
- <li key={image.id} className="overflow-hidden rounded-lg border">
113
- <div className="relative aspect-square bg-muted">
114
- <img src={image.url} alt={image.altText ?? ''} className="h-full w-full object-cover" />
115
- {image.isPrimary && (
116
- <span className="absolute left-2 top-2 rounded-full bg-primary px-2 py-0.5 text-[11px] font-bold text-primary-foreground">
117
- Capa
118
- </span>
119
- )}
120
- </div>
121
- <div className="flex items-center justify-between gap-1 px-2 py-1.5">
122
- <div className="flex gap-1">
123
- <IconButton label="Mover para trás" disabled={busy || index === 0}
124
- onClick={() => move(index, -1)}>←</IconButton>
125
- <IconButton label="Mover para frente" disabled={busy || index === images.length - 1}
126
- onClick={() => move(index, 1)}>→</IconButton>
127
- </div>
128
- <div className="flex gap-1">
129
- {!image.isPrimary && update && (
130
- <button
131
- type="button"
132
- disabled={busy}
133
- onClick={() => void run(() => update(image.id, { isPrimary: true }))}
134
- className="rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-50"
135
- >
136
- Usar como capa
137
- </button>
138
- )}
139
- <button
140
- type="button"
141
- disabled={busy}
142
- onClick={() => void run(() => getProvider().deleteProductImage(image.id))}
143
- className="rounded px-1.5 py-0.5 text-xs text-red-600 hover:bg-red-50 disabled:opacity-50"
144
- >
145
- Excluir
146
- </button>
147
- </div>
148
- </div>
149
- </li>
150
- ))}
151
- </ul>
152
- )}
153
- </div>
154
- )
155
- }
156
-
157
- function IconButton({ label, disabled, onClick, children }: {
158
- label: string
159
- disabled?: boolean
160
- onClick: () => void
161
- children: React.ReactNode
162
- }) {
163
- return (
164
- <button
165
- type="button"
166
- aria-label={label}
167
- title={label}
168
- disabled={disabled}
169
- onClick={onClick}
170
- className="rounded border px-1.5 py-0.5 text-xs text-muted-foreground transition hover:bg-muted hover:text-foreground disabled:opacity-40"
171
- >
172
- {children}
173
- </button>
174
- )
175
- }
176
-
177
- /** Cover first, then the merchant's order — the same rule the storefront uses. */
178
- function sortImages(images: ProductImage[]): ProductImage[] {
179
- return [...images].sort((a, b) => {
180
- if (a.isPrimary !== b.isPrimary) return a.isPrimary ? -1 : 1
181
- return (a.sortOrder ?? 0) - (b.sortOrder ?? 0)
182
- })
183
- }
@@ -1,110 +0,0 @@
1
- import React from 'react'
2
- import type { EntityDef, FieldDef } from '@fayz-ai/core'
3
- import type { ShopCustomer } from '@fayz-ai/shop'
4
- import { ClientOrdersTab, createClientOrdersProvider, adminNavigateTo } from '@fayz-ai/saas'
5
- import type { ClientDocument } from '@fayz-ai/saas'
6
-
7
- // ---------------------------------------------------------------------------
8
- // Shop customer — same declarative contract as the product entity, so the list
9
- // and the form both come from createCrudPage instead of a hand-rolled table.
10
- //
11
- // ordersCount / totalSpent are read-only: they are maintained server-side by the
12
- // shop_refresh_customer_stats trigger on every order insert/update/delete, so
13
- // putting them in the form would offer an edit the database would overwrite.
14
- // ---------------------------------------------------------------------------
15
-
16
- function money(value: unknown) {
17
- const n = Number(value)
18
- if (!Number.isFinite(n)) return 'R$ 0,00'
19
- return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(n)
20
- }
21
-
22
- export function buildShopCustomerEntity(): EntityDef<ShopCustomer> {
23
- const fields: FieldDef[] = [
24
- // — Contato —
25
- {
26
- key: 'firstName', label: 'Nome', type: 'text', required: true,
27
- group: 'contact', placeholder: 'Maria', searchable: true,
28
- showInTable: true, sortable: true,
29
- renderCell: (_v, row: any) => (
30
- <div className="min-w-0">
31
- <p className="truncate font-medium">{[row.firstName, row.lastName].filter(Boolean).join(' ')}</p>
32
- {row.email && <p className="truncate text-xs text-muted-foreground">{row.email}</p>}
33
- </div>
34
- ),
35
- },
36
- { key: 'lastName', label: 'Sobrenome', type: 'text', group: 'contact', placeholder: 'Silva', searchable: true, showInTable: false },
37
- {
38
- key: 'email', label: 'E-mail', type: 'text', group: 'contact',
39
- placeholder: 'maria@exemplo.com', searchable: true,
40
- hint: 'É por ele que o pedido é vinculado ao cliente no checkout.',
41
- showInTable: false,
42
- },
43
- {
44
- key: 'phone', label: 'Telefone', type: 'text', group: 'contact',
45
- placeholder: '(21) 99999-0000', searchable: true,
46
- showInTable: true, sortable: false,
47
- renderCell: (v) => <span className="text-muted-foreground">{(v as string) || '—'}</span>,
48
- },
49
-
50
- // — Histórico (somente leitura) —
51
- {
52
- key: 'ordersCount', label: 'Pedidos', type: 'number',
53
- showInForm: false, showInTable: true, sortable: true,
54
- renderCell: (v) => <span className="block text-right">{Number(v) || 0}</span>,
55
- },
56
- {
57
- key: 'totalSpent', label: 'Total gasto', type: 'number',
58
- showInForm: false, showInTable: true, sortable: true,
59
- renderCell: (v) => <span className="block text-right font-medium">{money(v)}</span>,
60
- },
61
-
62
- // — Interno —
63
- {
64
- key: 'notes', label: 'Observações', type: 'textarea', group: 'internal', span: 2,
65
- placeholder: 'Preferências, restrições, combinados de entrega…',
66
- hint: 'Visível só no admin. O cliente nunca vê.',
67
- showInTable: false,
68
- },
69
- ]
70
-
71
- return {
72
- name: 'Cliente',
73
- namePlural: 'Clientes',
74
- icon: 'Users',
75
- displayField: 'firstName',
76
- // `person` layout brings the shared archetype tabs — including Endereços,
77
- // which reads the core addresses table and therefore works in every app.
78
- layout: 'person',
79
- // Without archetypeKind the shell shows every person tab, including the
80
- // staff-only Agenda (its visibleFor filter only runs when a kind is set).
81
- data: { table: 'plg_shop_customers', archetypeKind: 'customer' },
82
- fields,
83
- // The SAME tab and the SAME provider beauty-saas uses. Since the shop's
84
- // orders now live in public.orders with party_id set, the canonical provider
85
- // already returns them — a shop-specific adapter would be a second
86
- // implementation of "list this person's orders", which is how two apps end
87
- // up with two different answers to the same question.
88
- detailTabs: [{
89
- id: 'orders',
90
- label: 'Pedidos',
91
- icon: 'ShoppingBag',
92
- aliases: ['appointments', 'quotes'],
93
- component: ClientOrdersTab as never,
94
- props: {
95
- provider: createClientOrdersProvider(),
96
- currency: { code: 'BRL', locale: 'pt-BR' },
97
- // The one part that legitimately differs per app: where a document
98
- // leads. Beauty sends a booking to the agenda and an invoice to
99
- // receivables; a shop order goes to the order screen.
100
- navigator: {
101
- onNavigate: (doc: ClientDocument) => adminNavigateTo(`/shop/orders/${doc.id}`),
102
- },
103
- },
104
- }],
105
- fieldGroups: [
106
- { id: 'contact', label: 'Contato', columns: 2 },
107
- { id: 'internal', label: 'Notas internas', columns: 1 },
108
- ],
109
- } as EntityDef<ShopCustomer>
110
- }
@@ -1,31 +0,0 @@
1
- import type { ShopProvider } from '@fayz-ai/shop'
2
-
3
- /**
4
- * Feeds the shared PersonLink popover from the shop's own customer table.
5
- * The default lookup reads the `person` archetype (`people`), where a shop
6
- * customer does not exist — so without this the card would come up empty.
7
- */
8
- export function shopCustomerLookup(provider: () => ShopProvider) {
9
- return {
10
- async getById(id: string) {
11
- const c = await provider().getCustomer(id)
12
- if (!c) return null
13
- const name = [c.firstName, c.lastName].filter(Boolean).join(' ')
14
- return {
15
- id: c.id,
16
- label: name || c.email || 'Cliente',
17
- subtitle: c.email ?? undefined,
18
- group: 'shop_customer',
19
- // PersonLink reads contact fields off `data`, so the keys must match the
20
- // shape it expects (email/phone), not our camelCase type.
21
- data: {
22
- email: c.email ?? undefined,
23
- phone: c.phone ?? undefined,
24
- kind: 'shop_customer',
25
- orders_count: c.ordersCount,
26
- total_spent: c.totalSpent,
27
- },
28
- }
29
- },
30
- }
31
- }