@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.
- 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 +986 -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 +987 -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 +8 -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 +104 -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
|
@@ -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
|
+
}
|