@fayz-ai/plugin-shop 0.2.5 → 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.
Files changed (55) hide show
  1. package/dist/ShopPage-HCLXMN7Q.js +743 -0
  2. package/dist/ShopPage-HCLXMN7Q.js.map +1 -0
  3. package/dist/ShopPage-JF2SQBKO.cjs +745 -0
  4. package/dist/ShopPage-JF2SQBKO.cjs.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 +986 -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 +987 -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 +8 -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 +104 -12
  45. package/src/views/OrderDetailView.tsx +575 -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
@@ -0,0 +1,183 @@
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
+ }
@@ -0,0 +1,110 @@
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
+ }
@@ -0,0 +1,31 @@
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
+ }
@@ -0,0 +1,137 @@
1
+ import React from 'react'
2
+ import type { EntityDef, FieldDef } from '@fayz-ai/core'
3
+ import type { Discount } from '@fayz-ai/shop'
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Discount — declarative CRUD, same contract as products and customers.
7
+ //
8
+ // `timesUsed` is read-only: shop_place_order increments it inside the same
9
+ // transaction that validates the coupon, so the admin must never write it.
10
+ //
11
+ // `buy_x_get_y` is deliberately absent from the type options: the enum value
12
+ // exists in the database but shop_place_order records the code and applies no
13
+ // monetary effect (0003:116), so offering it would create coupons that silently
14
+ // do nothing.
15
+ // ---------------------------------------------------------------------------
16
+
17
+ const STATUS_TONE: Record<string, string> = {
18
+ active: 'bg-emerald-100 text-emerald-800',
19
+ draft: 'bg-gray-100 text-gray-700',
20
+ expired: 'bg-orange-100 text-orange-700',
21
+ disabled: 'bg-red-100 text-red-700',
22
+ }
23
+
24
+ export function buildShopDiscountEntity(): EntityDef<Discount> {
25
+ const fields: FieldDef[] = [
26
+ // — Cupom —
27
+ {
28
+ key: 'title', label: 'Nome interno', type: 'text', required: true,
29
+ group: 'coupon', placeholder: 'Bem-vindo 10%',
30
+ hint: 'Só o admin vê. Serve para você identificar a campanha.',
31
+ searchable: true, showInTable: true, sortable: true,
32
+ renderCell: (_v, row: any) => (
33
+ <div className="min-w-0">
34
+ <p className="truncate font-medium">{row.title}</p>
35
+ {row.code && <p className="font-mono text-xs text-muted-foreground">{row.code}</p>}
36
+ </div>
37
+ ),
38
+ },
39
+ {
40
+ key: 'code', label: 'Código', type: 'text', group: 'coupon',
41
+ placeholder: 'ARTORIUS10',
42
+ hint: 'O que o cliente digita no carrinho. Maiúsculas e minúsculas não importam.',
43
+ searchable: true, showInTable: false,
44
+ },
45
+
46
+ // — Regra —
47
+ {
48
+ key: 'type', label: 'Tipo', type: 'segmented', required: true, group: 'rule', span: 2,
49
+ options: [
50
+ { label: 'Percentual', value: 'percentage', description: 'Desconta uma % do subtotal.' },
51
+ { label: 'Valor fixo', value: 'fixed_amount', description: 'Desconta um valor em reais.' },
52
+ { label: 'Frete grátis', value: 'free_shipping', description: 'Zera o frete do pedido.' },
53
+ ],
54
+ showInTable: true, sortable: false,
55
+ renderCell: (v) => {
56
+ const labels: Record<string, string> = {
57
+ percentage: 'Percentual', fixed_amount: 'Valor fixo', free_shipping: 'Frete grátis', buy_x_get_y: 'Leve X pague Y',
58
+ }
59
+ return <span className="text-muted-foreground">{labels[String(v)] ?? String(v)}</span>
60
+ },
61
+ },
62
+ {
63
+ key: 'value', label: 'Valor', type: 'number', group: 'rule', min: 0,
64
+ placeholder: '10',
65
+ hint: 'Percentual: 10 = 10%. Valor fixo: 10 = R$ 10,00. Frete grátis ignora este campo.',
66
+ showInTable: true, sortable: true,
67
+ renderCell: (_v, row: any) => (
68
+ <span className="block text-right font-medium">
69
+ {row.type === 'percentage'
70
+ ? `${row.value}%`
71
+ : row.type === 'free_shipping'
72
+ ? '—'
73
+ : new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(Number(row.value) || 0)}
74
+ </span>
75
+ ),
76
+ },
77
+
78
+ // — Limites —
79
+ {
80
+ key: 'usageLimit', label: 'Limite de usos', type: 'number', group: 'limits', min: 0,
81
+ placeholder: 'sem limite', hint: 'Deixe vazio para uso ilimitado.', showInTable: false,
82
+ },
83
+ {
84
+ key: 'oncePerCustomer', label: 'Uma vez por cliente', type: 'boolean', group: 'limits',
85
+ hint: 'Só vale para quem se identifica no checkout — compra como visitante não é rastreada.',
86
+ showInTable: false,
87
+ },
88
+ {
89
+ key: 'timesUsed', label: 'Usos', type: 'number',
90
+ showInForm: false, showInTable: true, sortable: true,
91
+ renderCell: (_v, row: any) => (
92
+ <span className="block text-right text-muted-foreground">
93
+ {row.timesUsed}{row.usageLimit ? ` / ${row.usageLimit}` : ''}
94
+ </span>
95
+ ),
96
+ },
97
+
98
+ // — Vigência —
99
+ { key: 'startsAt', label: 'Início', type: 'date', group: 'schedule', showInTable: false },
100
+ {
101
+ key: 'endsAt', label: 'Fim', type: 'date', group: 'schedule',
102
+ hint: 'Vazio = sem data de expiração.', showInTable: false,
103
+ },
104
+ {
105
+ key: 'status', label: 'Status', type: 'segmented', required: true, group: 'schedule', span: 2,
106
+ options: [
107
+ { label: 'Rascunho', value: 'draft', description: 'Não aceito no carrinho.' },
108
+ { label: 'Ativo', value: 'active', description: 'Valendo agora.' },
109
+ { label: 'Desativado', value: 'disabled', description: 'Suspenso sem apagar o histórico.' },
110
+ ],
111
+ showInTable: true, sortable: false,
112
+ renderCell: (v) => {
113
+ const s = String(v ?? 'draft')
114
+ return (
115
+ <span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${STATUS_TONE[s] ?? STATUS_TONE.draft}`}>
116
+ {s}
117
+ </span>
118
+ )
119
+ },
120
+ },
121
+ ]
122
+
123
+ return {
124
+ name: 'Desconto',
125
+ namePlural: 'Descontos',
126
+ icon: 'Tag',
127
+ displayField: 'title',
128
+ fields,
129
+ fieldGroups: [
130
+ { id: 'coupon', label: 'Cupom', columns: 2 },
131
+ { id: 'rule', label: 'Regra', description: 'O desconto é recalculado no servidor a cada pedido.', columns: 2 },
132
+ { id: 'limits', label: 'Limites de uso', columns: 2 },
133
+ { id: 'schedule', label: 'Vigência', columns: 2 },
134
+ ],
135
+ facets: [{ field: 'status', allLabel: 'Todos' }],
136
+ } as EntityDef<Discount>
137
+ }