@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.
- package/README.md +4 -4
- package/dist/{ShopPage-JP27MZS2.js → ShopPage-PVHFKDF3.js} +7 -7
- package/dist/ShopPage-PVHFKDF3.js.map +1 -0
- package/dist/data/categoryDataProvider.d.ts +5 -0
- package/dist/data/categoryDataProvider.d.ts.map +1 -0
- package/dist/data/collectionDataProvider.d.ts +5 -0
- package/dist/data/collectionDataProvider.d.ts.map +1 -0
- package/dist/data/customerGroupDataProvider.d.ts +5 -0
- package/dist/data/customerGroupDataProvider.d.ts.map +1 -0
- package/dist/data/customerSegmentDataProvider.d.ts +16 -0
- package/dist/data/customerSegmentDataProvider.d.ts.map +1 -0
- package/dist/data/giftCardDataProvider.d.ts +5 -0
- package/dist/data/giftCardDataProvider.d.ts.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1088 -86
- package/dist/index.js.map +1 -1
- package/dist/views/ChannelsPage.d.ts +1 -1
- package/dist/views/ChannelsPage.d.ts.map +1 -1
- package/dist/views/ContentPage.d.ts +7 -0
- package/dist/views/ContentPage.d.ts.map +1 -0
- package/dist/views/EmailTemplatePage.d.ts +9 -0
- package/dist/views/EmailTemplatePage.d.ts.map +1 -0
- package/dist/views/EmailsPage.d.ts +7 -0
- package/dist/views/EmailsPage.d.ts.map +1 -0
- package/dist/views/ShopPage.d.ts.map +1 -0
- package/dist/views/categoryEntity.d.ts +4 -0
- package/dist/views/categoryEntity.d.ts.map +1 -0
- package/dist/views/collectionEntity.d.ts +4 -0
- package/dist/views/collectionEntity.d.ts.map +1 -0
- package/dist/views/customerGroupEntity.d.ts +4 -0
- package/dist/views/customerGroupEntity.d.ts.map +1 -0
- package/dist/views/customerSegmentEntity.d.ts +13 -0
- package/dist/views/customerSegmentEntity.d.ts.map +1 -0
- package/dist/views/giftCardEntity.d.ts +4 -0
- package/dist/views/giftCardEntity.d.ts.map +1 -0
- package/package.json +6 -8
- package/dist/ShopPage-JP27MZS2.js.map +0 -1
- package/dist/ShopPage.d.ts.map +0 -1
- package/src/ShopPage.tsx +0 -425
- package/src/components/ShopSettings.tsx +0 -108
- package/src/data/customerDataProvider.ts +0 -31
- package/src/data/discountDataProvider.ts +0 -42
- package/src/data/productDataProvider.ts +0 -54
- package/src/data/shared.ts +0 -42
- package/src/data/shippingZoneDataProvider.ts +0 -49
- package/src/index.ts +0 -270
- package/src/views/ChannelsPage.tsx +0 -274
- package/src/views/OrderDetailView.tsx +0 -319
- package/src/views/ProductImagesTab.tsx +0 -183
- package/src/views/customerEntity.tsx +0 -110
- package/src/views/customerLookup.ts +0 -31
- package/src/views/discountEntity.tsx +0 -144
- package/src/views/productEntity.tsx +0 -229
- package/src/views/shippingZoneEntity.tsx +0 -123
- /package/dist/{ShopPage.d.ts → views/ShopPage.d.ts} +0 -0
|
@@ -1,144 +0,0 @@
|
|
|
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: 'minSubtotal', label: 'Pedido mínimo', type: 'currency', group: 'limits', min: 0,
|
|
81
|
-
currency: 'BRL', currencySymbol: 'R$', currencyLocale: 'pt-BR',
|
|
82
|
-
placeholder: 'sem mínimo',
|
|
83
|
-
hint: 'Valor mínimo do pedido para o cupom valer. Deixe vazio para não exigir mínimo.',
|
|
84
|
-
showInTable: false,
|
|
85
|
-
},
|
|
86
|
-
{
|
|
87
|
-
key: 'usageLimit', label: 'Limite de usos', type: 'number', group: 'limits', min: 0,
|
|
88
|
-
placeholder: 'sem limite', hint: 'Deixe vazio para uso ilimitado.', showInTable: false,
|
|
89
|
-
},
|
|
90
|
-
{
|
|
91
|
-
key: 'oncePerCustomer', label: 'Uma vez por cliente', type: 'boolean', group: 'limits',
|
|
92
|
-
hint: 'Só vale para quem se identifica no checkout — compra como visitante não é rastreada.',
|
|
93
|
-
showInTable: false,
|
|
94
|
-
},
|
|
95
|
-
{
|
|
96
|
-
key: 'timesUsed', label: 'Usos', type: 'number',
|
|
97
|
-
showInForm: false, showInTable: true, sortable: true,
|
|
98
|
-
renderCell: (_v, row: any) => (
|
|
99
|
-
<span className="block text-right text-muted-foreground">
|
|
100
|
-
{row.timesUsed}{row.usageLimit ? ` / ${row.usageLimit}` : ''}
|
|
101
|
-
</span>
|
|
102
|
-
),
|
|
103
|
-
},
|
|
104
|
-
|
|
105
|
-
// — Vigência —
|
|
106
|
-
{ key: 'startsAt', label: 'Início', type: 'date', group: 'schedule', showInTable: false },
|
|
107
|
-
{
|
|
108
|
-
key: 'endsAt', label: 'Fim', type: 'date', group: 'schedule',
|
|
109
|
-
hint: 'Vazio = sem data de expiração.', showInTable: false,
|
|
110
|
-
},
|
|
111
|
-
{
|
|
112
|
-
key: 'status', label: 'Status', type: 'segmented', required: true, group: 'schedule', span: 2,
|
|
113
|
-
options: [
|
|
114
|
-
{ label: 'Rascunho', value: 'draft', description: 'Não aceito no carrinho.' },
|
|
115
|
-
{ label: 'Ativo', value: 'active', description: 'Valendo agora.' },
|
|
116
|
-
{ label: 'Desativado', value: 'disabled', description: 'Suspenso sem apagar o histórico.' },
|
|
117
|
-
],
|
|
118
|
-
showInTable: true, sortable: false,
|
|
119
|
-
renderCell: (v) => {
|
|
120
|
-
const s = String(v ?? 'draft')
|
|
121
|
-
return (
|
|
122
|
-
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${STATUS_TONE[s] ?? STATUS_TONE.draft}`}>
|
|
123
|
-
{s}
|
|
124
|
-
</span>
|
|
125
|
-
)
|
|
126
|
-
},
|
|
127
|
-
},
|
|
128
|
-
]
|
|
129
|
-
|
|
130
|
-
return {
|
|
131
|
-
name: 'Desconto',
|
|
132
|
-
namePlural: 'Descontos',
|
|
133
|
-
icon: 'Tag',
|
|
134
|
-
displayField: 'title',
|
|
135
|
-
fields,
|
|
136
|
-
fieldGroups: [
|
|
137
|
-
{ id: 'coupon', label: 'Cupom', columns: 2 },
|
|
138
|
-
{ id: 'rule', label: 'Regra', description: 'O desconto é recalculado no servidor a cada pedido.', columns: 2 },
|
|
139
|
-
{ id: 'limits', label: 'Limites de uso', columns: 2 },
|
|
140
|
-
{ id: 'schedule', label: 'Vigência', columns: 2 },
|
|
141
|
-
],
|
|
142
|
-
facets: [{ field: 'status', allLabel: 'Todos' }],
|
|
143
|
-
} as EntityDef<Discount>
|
|
144
|
-
}
|
|
@@ -1,229 +0,0 @@
|
|
|
1
|
-
import React from 'react'
|
|
2
|
-
import type { EntityDef, EntityImageConfig, FieldDef } from '@fayz-ai/core'
|
|
3
|
-
import type { Product, ShopProvider } from '@fayz-ai/shop'
|
|
4
|
-
import { ProductImagesTab } from './ProductImagesTab'
|
|
5
|
-
|
|
6
|
-
// ---------------------------------------------------------------------------
|
|
7
|
-
// Storefront Product — the single source of truth for the shop product CRUD.
|
|
8
|
-
// Drives the list columns AND the form sections via the generic CRUD scaffolding
|
|
9
|
-
// (createCrudPage → CrudListView + CrudFormPage), same pattern plugin-inventory
|
|
10
|
-
// uses for its ERP product.
|
|
11
|
-
//
|
|
12
|
-
// This is deliberately the STOREFRONT shape (what a shopper sees: name, price,
|
|
13
|
-
// compare-at, images, publication status), not the ERP shape plugin-inventory
|
|
14
|
-
// models (cost, margin, min/max levels). They are different products in
|
|
15
|
-
// different tables — see the shop/inventory bridge note in the roadmap.
|
|
16
|
-
//
|
|
17
|
-
// Not modelled here because the schema has nowhere to put them yet:
|
|
18
|
-
// • variants (options/SKU/stock per variant) — needs plg_shop_variants
|
|
19
|
-
// • SEO title/description — needs columns on plg_shop_products
|
|
20
|
-
// • multi-category — the column is a single category_id
|
|
21
|
-
// ---------------------------------------------------------------------------
|
|
22
|
-
|
|
23
|
-
const STATUS_TONE: Record<string, string> = {
|
|
24
|
-
active: 'bg-emerald-100 text-emerald-800',
|
|
25
|
-
draft: 'bg-gray-100 text-gray-700',
|
|
26
|
-
archived: 'bg-orange-100 text-orange-700',
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const STATUS_LABEL: Record<string, string> = {
|
|
30
|
-
active: 'Ativo',
|
|
31
|
-
draft: 'Rascunho',
|
|
32
|
-
archived: 'Arquivado',
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function money(value: unknown, currency = 'BRL') {
|
|
36
|
-
const n = Number(value)
|
|
37
|
-
if (!Number.isFinite(n)) return '—'
|
|
38
|
-
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency }).format(n)
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** `name` → `name-do-produto`, so the storefront URL is filled in for you. */
|
|
42
|
-
export function slugify(value: string): string {
|
|
43
|
-
return value
|
|
44
|
-
.normalize('NFD')
|
|
45
|
-
.replace(/[\u0300-\u036f]/g, '')
|
|
46
|
-
.toLowerCase()
|
|
47
|
-
.trim()
|
|
48
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
49
|
-
.replace(/^-+|-+$/g, '')
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Wires the form's image slot to the product's own images. `uploadProductImage`
|
|
54
|
-
* has existed on the provider all along with nothing calling it — the slot was a
|
|
55
|
-
* decorative square, so a product with a photo still showed an empty placeholder.
|
|
56
|
-
*/
|
|
57
|
-
function productImage(getProvider: () => ShopProvider): EntityImageConfig {
|
|
58
|
-
return {
|
|
59
|
-
get: (row) => {
|
|
60
|
-
const images = (row as Partial<Product>).images
|
|
61
|
-
return images?.find((i) => i.isPrimary)?.url ?? images?.[0]?.url
|
|
62
|
-
},
|
|
63
|
-
upload: async (file, row) => {
|
|
64
|
-
if (!row.id) throw new Error('Salve o produto antes de enviar a imagem.')
|
|
65
|
-
const image = await getProvider().uploadProductImage(row.id, file)
|
|
66
|
-
return image.url
|
|
67
|
-
},
|
|
68
|
-
accept: 'image/*',
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export function buildShopProductEntity(
|
|
73
|
-
currencyCode = 'BRL',
|
|
74
|
-
getProvider?: () => ShopProvider,
|
|
75
|
-
): EntityDef<Product> {
|
|
76
|
-
const fields: FieldDef[] = [
|
|
77
|
-
// — Geral —
|
|
78
|
-
{
|
|
79
|
-
key: 'name', label: 'Nome do produto', type: 'text', required: true,
|
|
80
|
-
group: 'general', placeholder: 'Costela Bovina Defumada 400g',
|
|
81
|
-
searchable: true, showInTable: true, sortable: true,
|
|
82
|
-
renderCell: (_v, row: any) => (
|
|
83
|
-
<div className="flex items-center gap-3">
|
|
84
|
-
{row.images?.[0]?.url && (
|
|
85
|
-
<img
|
|
86
|
-
src={row.images[0].url}
|
|
87
|
-
alt=""
|
|
88
|
-
className="h-9 w-9 rounded object-cover"
|
|
89
|
-
loading="lazy"
|
|
90
|
-
/>
|
|
91
|
-
)}
|
|
92
|
-
<div className="min-w-0">
|
|
93
|
-
<p className="truncate font-medium">{row.name}</p>
|
|
94
|
-
{row.sku && <p className="text-xs text-muted-foreground">{row.sku}</p>}
|
|
95
|
-
</div>
|
|
96
|
-
</div>
|
|
97
|
-
),
|
|
98
|
-
},
|
|
99
|
-
{
|
|
100
|
-
key: 'slug', label: 'URL', type: 'text', group: 'general',
|
|
101
|
-
placeholder: 'gerado a partir do nome',
|
|
102
|
-
hint: 'Endereço do produto na loja. Deixe em branco para gerar automaticamente.',
|
|
103
|
-
showInTable: false,
|
|
104
|
-
},
|
|
105
|
-
{
|
|
106
|
-
key: 'description', label: 'Descrição', type: 'textarea', group: 'general', span: 2,
|
|
107
|
-
placeholder: 'Como é feito, como preparar, o que acompanha…',
|
|
108
|
-
showInTable: false,
|
|
109
|
-
},
|
|
110
|
-
|
|
111
|
-
// — Preço —
|
|
112
|
-
{
|
|
113
|
-
key: 'price', label: 'Preço de venda', type: 'currency', required: true,
|
|
114
|
-
group: 'pricing', currency: currencyCode, currencySymbol: 'R$', currencyLocale: 'pt-BR',
|
|
115
|
-
showInTable: true, sortable: true,
|
|
116
|
-
renderCell: (v, row: any) => (
|
|
117
|
-
<div className="text-right">
|
|
118
|
-
<span className="font-medium">{money(v, row.currency)}</span>
|
|
119
|
-
{row.compareAtPrice > row.price && (
|
|
120
|
-
<span className="ml-2 text-xs text-muted-foreground line-through">
|
|
121
|
-
{money(row.compareAtPrice, row.currency)}
|
|
122
|
-
</span>
|
|
123
|
-
)}
|
|
124
|
-
</div>
|
|
125
|
-
),
|
|
126
|
-
},
|
|
127
|
-
{
|
|
128
|
-
key: 'compareAtPrice', label: 'Preço comparativo', type: 'currency', group: 'pricing',
|
|
129
|
-
currency: currencyCode, currencySymbol: 'R$', currencyLocale: 'pt-BR',
|
|
130
|
-
hint: 'Preço "de" riscado. Deixe vazio se não estiver em promoção.',
|
|
131
|
-
showInTable: false,
|
|
132
|
-
},
|
|
133
|
-
{
|
|
134
|
-
key: 'discount', label: 'Desconto', type: 'computed', group: 'pricing', showInTable: false,
|
|
135
|
-
compute: (vals) => {
|
|
136
|
-
const price = Number(vals.price) || 0
|
|
137
|
-
const compare = Number(vals.compareAtPrice) || 0
|
|
138
|
-
if (compare <= 0 || price <= 0 || compare <= price) return null
|
|
139
|
-
const pct = ((compare - price) / compare) * 100
|
|
140
|
-
return { display: `-${pct.toFixed(0)}%`, tone: 'positive' }
|
|
141
|
-
},
|
|
142
|
-
},
|
|
143
|
-
|
|
144
|
-
// — Estoque —
|
|
145
|
-
{
|
|
146
|
-
key: 'sku', label: 'SKU', type: 'text', group: 'inventory',
|
|
147
|
-
placeholder: 'ART-001', searchable: true, showInTable: false,
|
|
148
|
-
},
|
|
149
|
-
{
|
|
150
|
-
key: 'inventoryCount', label: 'Estoque', type: 'number', group: 'inventory', min: 0,
|
|
151
|
-
placeholder: '0', hint: 'Chega a zero e o produto aparece como esgotado na loja.',
|
|
152
|
-
showInTable: true, sortable: true,
|
|
153
|
-
renderCell: (v) => {
|
|
154
|
-
const n = Number(v) || 0
|
|
155
|
-
return (
|
|
156
|
-
<span className={`block text-right ${n === 0 ? 'font-medium text-destructive' : n <= 3 ? 'text-orange-600' : ''}`}>
|
|
157
|
-
{n}
|
|
158
|
-
</span>
|
|
159
|
-
)
|
|
160
|
-
},
|
|
161
|
-
},
|
|
162
|
-
|
|
163
|
-
// — Publicação —
|
|
164
|
-
{
|
|
165
|
-
key: 'status', label: 'Status', type: 'segmented', required: true,
|
|
166
|
-
group: 'publishing', span: 2,
|
|
167
|
-
options: [
|
|
168
|
-
{ label: 'Rascunho', value: 'draft', description: 'Só você vê. Não aparece na loja.' },
|
|
169
|
-
{ label: 'Ativo', value: 'active', description: 'Publicado e à venda.' },
|
|
170
|
-
{ label: 'Arquivado', value: 'archived', description: 'Fora da loja, histórico preservado.' },
|
|
171
|
-
],
|
|
172
|
-
showInTable: true, sortable: false,
|
|
173
|
-
renderCell: (v) => {
|
|
174
|
-
const s = String(v ?? 'draft')
|
|
175
|
-
return (
|
|
176
|
-
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${STATUS_TONE[s] ?? STATUS_TONE.draft}`}>
|
|
177
|
-
{STATUS_LABEL[s] ?? s}
|
|
178
|
-
</span>
|
|
179
|
-
)
|
|
180
|
-
},
|
|
181
|
-
},
|
|
182
|
-
{
|
|
183
|
-
key: 'sortOrder', label: 'Ordem na vitrine', type: 'number', group: 'publishing',
|
|
184
|
-
placeholder: '0', hint: 'Menor aparece primeiro.', showInTable: false,
|
|
185
|
-
},
|
|
186
|
-
|
|
187
|
-
// — Envio —
|
|
188
|
-
{
|
|
189
|
-
key: 'isPhysical', label: 'Produto físico', type: 'boolean', group: 'shipping',
|
|
190
|
-
hint: 'Desmarque para itens digitais, que não calculam frete.', showInTable: false,
|
|
191
|
-
},
|
|
192
|
-
{ key: 'weight', label: 'Peso', type: 'number', group: 'shipping', min: 0, placeholder: '0.4', showInTable: false },
|
|
193
|
-
{
|
|
194
|
-
key: 'weightUnit', label: 'Unidade', type: 'select', group: 'shipping', showInTable: false,
|
|
195
|
-
options: [{ label: 'kg', value: 'kg' }, { label: 'g', value: 'g' }, { label: 'lb', value: 'lb' }],
|
|
196
|
-
},
|
|
197
|
-
]
|
|
198
|
-
|
|
199
|
-
return {
|
|
200
|
-
name: 'Produto',
|
|
201
|
-
namePlural: 'Produtos',
|
|
202
|
-
icon: 'Package',
|
|
203
|
-
displayField: 'name',
|
|
204
|
-
fields,
|
|
205
|
-
fieldGroups: [
|
|
206
|
-
// imageSlot renders the scaffolding's image uploader alongside the group.
|
|
207
|
-
{ id: 'general', label: 'Informações', columns: 2, imageSlot: true },
|
|
208
|
-
{ id: 'pricing', label: 'Preço', description: 'O preço comparativo vira o "de" riscado na vitrine.', columns: 3 },
|
|
209
|
-
{ id: 'inventory', label: 'Estoque', columns: 2 },
|
|
210
|
-
{ id: 'publishing', label: 'Publicação', description: 'Controla se o produto aparece na loja.', columns: 2 },
|
|
211
|
-
{ id: 'shipping', label: 'Envio', columns: 3 },
|
|
212
|
-
],
|
|
213
|
-
facets: [{ field: 'status', allLabel: 'Todos' }],
|
|
214
|
-
image: getProvider ? productImage(getProvider) : undefined,
|
|
215
|
-
// The form's image slot sets the COVER; this tab manages the rest of the
|
|
216
|
-
// gallery. plg_shop_product_images has always held a collection, and until
|
|
217
|
-
// now nothing in the admin could add a second row to it — so the storefront
|
|
218
|
-
// carousel had exactly one slide for every product in the pool.
|
|
219
|
-
detailTabs: getProvider
|
|
220
|
-
? [{
|
|
221
|
-
id: 'images',
|
|
222
|
-
label: 'Fotos',
|
|
223
|
-
icon: 'Image',
|
|
224
|
-
component: ProductImagesTab as never,
|
|
225
|
-
props: { getProvider },
|
|
226
|
-
}]
|
|
227
|
-
: undefined,
|
|
228
|
-
} as EntityDef<Product>
|
|
229
|
-
}
|
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
import React from 'react'
|
|
2
|
-
import type { EntityDef, FieldDef } from '@fayz-ai/core'
|
|
3
|
-
import type { ShippingZone } from '@fayz-ai/shop'
|
|
4
|
-
|
|
5
|
-
// ---------------------------------------------------------------------------
|
|
6
|
-
// Delivery zone — declarative CRUD, same contract as products and discounts.
|
|
7
|
-
//
|
|
8
|
-
// A zone is a range of postal codes with its own price and estimate. Ranges may
|
|
9
|
-
// overlap on purpose: the cheapest match wins, which is how a merchant layers a
|
|
10
|
-
// promotion over a broader zone without deleting the broader one.
|
|
11
|
-
//
|
|
12
|
-
// The two things worth being loud about in the UI:
|
|
13
|
-
// · a store with NO zones keeps charging its flat rate — zones are opt-in;
|
|
14
|
-
// · once the first zone exists, an address outside every zone can no longer
|
|
15
|
-
// order (shop_place_order refuses it), so the first save changes who can buy.
|
|
16
|
-
// ---------------------------------------------------------------------------
|
|
17
|
-
|
|
18
|
-
const cep = (value: string | undefined): string => {
|
|
19
|
-
const digits = String(value ?? '').replace(/\D/g, '').padStart(8, '0')
|
|
20
|
-
return `${digits.slice(0, 5)}-${digits.slice(5)}`
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function buildShopShippingZoneEntity(currency = 'BRL'): EntityDef<ShippingZone> {
|
|
24
|
-
const money = (value: number | null | undefined) =>
|
|
25
|
-
value == null
|
|
26
|
-
? '—'
|
|
27
|
-
: new Intl.NumberFormat('pt-BR', { style: 'currency', currency }).format(Number(value))
|
|
28
|
-
|
|
29
|
-
const fields: FieldDef[] = [
|
|
30
|
-
{
|
|
31
|
-
key: 'name', label: 'Nome da zona', type: 'text', required: true, group: 'zone',
|
|
32
|
-
placeholder: 'Rio de Janeiro — capital',
|
|
33
|
-
hint: 'Aparece para o cliente na hora de escolher a entrega.',
|
|
34
|
-
searchable: true, showInTable: true, sortable: true,
|
|
35
|
-
},
|
|
36
|
-
{
|
|
37
|
-
key: 'carrier', label: 'Transportadora', type: 'text', group: 'zone',
|
|
38
|
-
placeholder: 'Correios, motoboy próprio…',
|
|
39
|
-
hint: 'Opcional. Mostrado antes do nome da zona quando preenchido.',
|
|
40
|
-
showInTable: true,
|
|
41
|
-
},
|
|
42
|
-
{
|
|
43
|
-
key: 'postalFrom', label: 'CEP inicial', type: 'text', required: true, group: 'zone',
|
|
44
|
-
placeholder: '20000-000',
|
|
45
|
-
hint: 'Início da faixa. Pode digitar com ou sem traço.',
|
|
46
|
-
showInTable: true,
|
|
47
|
-
renderCell: (_v, row: any) => (
|
|
48
|
-
<span className="whitespace-nowrap font-mono text-xs">
|
|
49
|
-
{cep(row.postalFrom)} → {cep(row.postalTo)}
|
|
50
|
-
</span>
|
|
51
|
-
),
|
|
52
|
-
},
|
|
53
|
-
{
|
|
54
|
-
key: 'postalTo', label: 'CEP final', type: 'text', required: true, group: 'zone',
|
|
55
|
-
placeholder: '23799-999',
|
|
56
|
-
hint: 'Fim da faixa, inclusive.',
|
|
57
|
-
showInTable: false,
|
|
58
|
-
},
|
|
59
|
-
{
|
|
60
|
-
key: 'rate', label: 'Valor do frete', type: 'number', required: true, group: 'pricing',
|
|
61
|
-
hint: 'Cobrado nos pedidos entregues nessa faixa.',
|
|
62
|
-
showInTable: true, sortable: true,
|
|
63
|
-
renderCell: (value: any) => <span className="tabular-nums">{money(value)}</span>,
|
|
64
|
-
},
|
|
65
|
-
{
|
|
66
|
-
key: 'freeAbove', label: 'Frete grátis acima de', type: 'number', group: 'pricing',
|
|
67
|
-
hint: 'Deixe vazio para nunca dar frete grátis nessa zona. Vale mais que a regra geral da loja.',
|
|
68
|
-
showInTable: true,
|
|
69
|
-
renderCell: (value: any) => <span className="tabular-nums">{money(value)}</span>,
|
|
70
|
-
},
|
|
71
|
-
{
|
|
72
|
-
key: 'etaMinDays', label: 'Prazo mínimo (dias)', type: 'number', group: 'delivery',
|
|
73
|
-
hint: '0 = no mesmo dia.',
|
|
74
|
-
showInTable: false,
|
|
75
|
-
},
|
|
76
|
-
{
|
|
77
|
-
key: 'etaMaxDays', label: 'Prazo máximo (dias)', type: 'number', group: 'delivery',
|
|
78
|
-
hint: 'O cliente vê a faixa "de X a Y dias úteis" na página do produto.',
|
|
79
|
-
showInTable: true,
|
|
80
|
-
renderCell: (_v, row: any) =>
|
|
81
|
-
row.etaMinDays == null && row.etaMaxDays == null
|
|
82
|
-
? <span className="text-muted-foreground">—</span>
|
|
83
|
-
: <span>{row.etaMinDays ?? 0}–{row.etaMaxDays ?? row.etaMinDays} dias</span>,
|
|
84
|
-
},
|
|
85
|
-
{
|
|
86
|
-
key: 'active', label: 'Ativa', type: 'boolean', group: 'delivery',
|
|
87
|
-
hint: 'Desativar para de cotar essa faixa sem apagar a configuração.',
|
|
88
|
-
showInTable: true,
|
|
89
|
-
renderCell: (value: any) => (
|
|
90
|
-
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
|
91
|
-
value ? 'bg-emerald-100 text-emerald-800' : 'bg-gray-100 text-gray-700'}`}>
|
|
92
|
-
{value ? 'Ativa' : 'Inativa'}
|
|
93
|
-
</span>
|
|
94
|
-
),
|
|
95
|
-
},
|
|
96
|
-
{
|
|
97
|
-
key: 'sortOrder', label: 'Ordem', type: 'number', group: 'delivery',
|
|
98
|
-
hint: 'Desempate quando duas zonas custam o mesmo. Menor aparece primeiro.',
|
|
99
|
-
showInTable: false,
|
|
100
|
-
},
|
|
101
|
-
]
|
|
102
|
-
|
|
103
|
-
return {
|
|
104
|
-
name: 'Zona de entrega',
|
|
105
|
-
namePlural: 'Entrega',
|
|
106
|
-
icon: 'Truck',
|
|
107
|
-
displayField: 'name',
|
|
108
|
-
fields,
|
|
109
|
-
fieldGroups: [
|
|
110
|
-
{
|
|
111
|
-
id: 'zone', label: 'Faixa de CEP', columns: 2,
|
|
112
|
-
description:
|
|
113
|
-
'Faixas podem se sobrepor: quando duas cobrem o mesmo CEP, o cliente paga a mais barata.',
|
|
114
|
-
},
|
|
115
|
-
{
|
|
116
|
-
id: 'pricing', label: 'Preço', columns: 2,
|
|
117
|
-
description: 'O frete cobrado é recalculado no servidor a cada pedido, a partir daqui.',
|
|
118
|
-
},
|
|
119
|
-
{ id: 'delivery', label: 'Prazo e status', columns: 2 },
|
|
120
|
-
],
|
|
121
|
-
facets: [{ field: 'active', allLabel: 'Todas' }],
|
|
122
|
-
} as EntityDef<ShippingZone>
|
|
123
|
-
}
|
|
File without changes
|