@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,42 +0,0 @@
1
- import type { CrudQuery, CrudResult } from '@fayz-ai/core'
2
- import type { ShopProvider } from '@fayz-ai/shop'
3
-
4
- export type ShopProviderResolver = ShopProvider | (() => ShopProvider)
5
-
6
- export const resolve = (p: ShopProviderResolver): ShopProvider => (typeof p === 'function' ? p() : p)
7
-
8
- /**
9
- * Paging, sorting and facet filtering applied client-side: the shop provider's
10
- * list methods take a search term but no offset, sort or filter parameters.
11
- * Fine at storefront catalogue sizes; revisit if a tenant grows past a few
12
- * thousand rows, where it should move into the query.
13
- */
14
- export function applyQuery<T>(
15
- rows: T[],
16
- query: CrudQuery,
17
- facetKey?: keyof T & string,
18
- ): CrudResult<T> {
19
- const prop = (row: T, key: string): unknown => (row as Record<string, unknown>)[key]
20
- let data = rows
21
-
22
- if (facetKey) {
23
- const wanted = query.filters?.[facetKey]
24
- if (wanted && wanted !== 'all') data = data.filter((row) => prop(row, facetKey) === wanted)
25
- }
26
-
27
- if (query.sortBy) {
28
- const dir = query.sortDir === 'desc' ? -1 : 1
29
- const key = query.sortBy
30
- data = [...data].sort((a, b) => {
31
- const av = prop(a, key)
32
- const bv = prop(b, key)
33
- if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir
34
- return String(av ?? '').localeCompare(String(bv ?? ''), 'pt-BR') * dir
35
- })
36
- }
37
-
38
- const total = data.length
39
- const page = query.page ?? 1
40
- const size = query.pageSize ?? total
41
- return { data: data.slice((page - 1) * size, page * size), total }
42
- }
@@ -1,49 +0,0 @@
1
- import type { CrudQuery, CrudResult, DataProvider } from '@fayz-ai/core'
2
- import type { CreateShippingZoneInput, ShippingZone, UpdateShippingZoneInput } from '@fayz-ai/shop'
3
- import { applyQuery, resolve, type ShopProviderResolver } from './shared'
4
-
5
- /**
6
- * Delivery zones CRUD.
7
- *
8
- * The form takes CEPs however the merchant types them ('22.041-001', '22041001')
9
- * and the provider normalises to the 8 bare digits shipping_zones compares on —
10
- * asking a shop owner to strip punctuation is how a range silently stops
11
- * matching and a whole city stops being served.
12
- */
13
- export function createShopShippingZoneDataProvider(
14
- provider: ShopProviderResolver,
15
- ): DataProvider<ShippingZone> {
16
- const shop = () => {
17
- const p = resolve(provider)
18
- if (!p.listShippingZones) {
19
- throw new Error('Este provider de loja não suporta zonas de entrega.')
20
- }
21
- return p
22
- }
23
-
24
- return {
25
- async list(query: CrudQuery): Promise<CrudResult<ShippingZone>> {
26
- const rows = await shop().listShippingZones!()
27
- const term = query.search?.trim().toLowerCase()
28
- const filtered = term
29
- ? rows.filter((z) =>
30
- z.name.toLowerCase().includes(term) || (z.carrier ?? '').toLowerCase().includes(term))
31
- : rows
32
- return applyQuery(filtered, query)
33
- },
34
-
35
- async create(data): Promise<ShippingZone> {
36
- return shop().createShippingZone!(data as unknown as CreateShippingZoneInput)
37
- },
38
-
39
- async update(id: string, data: Partial<ShippingZone>): Promise<ShippingZone> {
40
- // tenantId is set from the session on the server side; never from the form.
41
- const { id: _id, tenantId: _tenant, ...patch } = data
42
- return shop().updateShippingZone!(id, patch as UpdateShippingZoneInput)
43
- },
44
-
45
- async remove(id: string): Promise<void> {
46
- await shop().deleteShippingZone!(id)
47
- },
48
- }
49
- }
package/src/index.ts DELETED
@@ -1,270 +0,0 @@
1
- import React from 'react'
2
- import type { PluginManifest, PluginScope, VerticalId } from '@fayz-ai/core'
3
- import { setShopTenantResolver, getShopProvider } from '@fayz-ai/shop'
4
- import { useOrganizationStore, createCrudPage, registerEntityPath } from '@fayz-ai/saas'
5
- import { buildShopProductEntity } from './views/productEntity'
6
- import { buildShopCustomerEntity } from './views/customerEntity'
7
- import { buildShopDiscountEntity } from './views/discountEntity'
8
- import { buildShopShippingZoneEntity } from './views/shippingZoneEntity'
9
- import { createShopProductDataProvider } from './data/productDataProvider'
10
- import { createShopCustomerDataProvider } from './data/customerDataProvider'
11
- import { createShopDiscountDataProvider } from './data/discountDataProvider'
12
- import { createShopShippingZoneDataProvider } from './data/shippingZoneDataProvider'
13
- import type { ShopProviderResolver } from './ShopPage'
14
- import { ShopSettings } from './components/ShopSettings'
15
- import { ChannelsPage } from './views/ChannelsPage'
16
-
17
- const ShopPage = React.lazy(() => import('./ShopPage').then((m) => ({ default: m.ShopPage })))
18
-
19
- // ---------------------------------------------------------------------------
20
- // Options
21
- // ---------------------------------------------------------------------------
22
-
23
- export interface ShopPluginOptions {
24
- navPosition?: number
25
- navSection?: 'main' | 'secondary' | 'settings'
26
- navLabel?: string
27
- /** Override the accordion child labels (Produtos / Pedidos / Clientes / Descontos). */
28
- sectionLabels?: Partial<Record<'products' | 'orders' | 'customers' | 'discounts' | 'shipping', string>>
29
- scope?: PluginScope
30
- verticalId?: VerticalId
31
- currency?: { code?: string; locale?: string; symbol?: string }
32
- provider?: ShopProviderResolver
33
- }
34
-
35
- // ---------------------------------------------------------------------------
36
- // Factory
37
- // ---------------------------------------------------------------------------
38
-
39
- export function createShopPlugin(options?: ShopPluginOptions): PluginManifest {
40
- const curr = { code: 'BRL', locale: 'pt-BR', symbol: 'R$', ...options?.currency }
41
-
42
- // Scope fallback shop queries to the active organization. Apps can inject a
43
- // provider when they want SDK-owned data access instead of the global runtime.
44
- setShopTenantResolver(() => useOrganizationStore.getState().currentOrg?.id)
45
-
46
- // Teach the shared person-path registry where a SHOP customer lives. The
47
- // built-in map ships veterinary defaults (customer → /registry/tutors), so
48
- // without this PersonLink would resolve a shop customer to the wrong module.
49
- // Clientes now lives at the top level (/customers), out of the Loja accordion.
50
- registerEntityPath('shop_customer', '/customers')
51
-
52
- type ShopSection = 'products' | 'orders' | 'customers' | 'discounts' | 'shipping'
53
-
54
- // One component per section so each gets its own route. The shell nests any
55
- // entry whose route is a sub-path of another (same rule the courses modules
56
- // use), so /shop/products … /shop/discounts render as an accordion under
57
- // "Loja" instead of one link with everything hidden behind tabs.
58
- const pageFor = (section?: ShopSection): React.ComponentType<unknown> => {
59
- const Wrapped: React.ComponentType<unknown> = () =>
60
- React.createElement(React.Suspense, { fallback: null },
61
- React.createElement(
62
- ShopPage as React.ComponentType<{ provider?: ShopProviderResolver; section?: ShopSection }>,
63
- { provider: options?.provider, section },
64
- )
65
- )
66
- Wrapped.displayName = section ? `ShopPage:${section}` : 'ShopPageWrapper'
67
- return Wrapped
68
- }
69
-
70
- // Products, customers and discounts all get the full generic CRUD (list +
71
- // form + detail + delete) from their EntityDef, rather than three bespoke
72
- // read-only tables. Orders stay on the custom page: an order is not a CRUD
73
- // record — it needs fulfilment, refunds and a line-item view, which is its
74
- // own screen.
75
- const resolveProvider = () =>
76
- typeof options?.provider === 'function'
77
- ? options.provider()
78
- : options?.provider ?? getShopProvider()
79
-
80
- const ProductsCrudPage = createCrudPage(buildShopProductEntity(curr.code, resolveProvider), {
81
- dataProvider: createShopProductDataProvider(resolveProvider),
82
- feature: 'shop',
83
- })
84
-
85
- const CustomersCrudPage = createCrudPage(buildShopCustomerEntity(), {
86
- dataProvider: createShopCustomerDataProvider(resolveProvider),
87
- feature: 'shop',
88
- })
89
-
90
- const DiscountsCrudPage = createCrudPage(buildShopDiscountEntity(), {
91
- dataProvider: createShopDiscountDataProvider(resolveProvider),
92
- feature: 'shop',
93
- })
94
-
95
- const ShippingZonesCrudPage = createCrudPage(buildShopShippingZoneEntity(curr.code), {
96
- dataProvider: createShopShippingZoneDataProvider(resolveProvider),
97
- feature: 'shop',
98
- })
99
-
100
- const CRUD_PAGES: Partial<Record<ShopSection, React.ComponentType<unknown>>> = {
101
- products: ProductsCrudPage as React.ComponentType<unknown>,
102
- customers: CustomersCrudPage as React.ComponentType<unknown>,
103
- discounts: DiscountsCrudPage as React.ComponentType<unknown>,
104
- shipping: ShippingZonesCrudPage as React.ComponentType<unknown>,
105
- }
106
-
107
- // "Clientes" is intentionally NOT here: it moved out of the Loja accordion to
108
- // a top-level nav item (/customers), registered separately below. The shell
109
- // nests by route prefix, so a route under /shop is what put it inside "Loja".
110
- const SECTIONS: Array<{ id: ShopSection; label: string; icon: string }> = [
111
- { id: 'products', label: options?.sectionLabels?.products ?? 'Produtos', icon: 'Package' },
112
- { id: 'orders', label: options?.sectionLabels?.orders ?? 'Pedidos', icon: 'ShoppingCart' },
113
- { id: 'discounts', label: options?.sectionLabels?.discounts ?? 'Descontos', icon: 'Tag' },
114
- { id: 'shipping', label: options?.sectionLabels?.shipping ?? 'Entrega', icon: 'Truck' },
115
- ]
116
-
117
- // Standalone Customers page — same CRUD component, now at /customers so it
118
- // reads as its own top-level destination instead of a Loja sub-tab.
119
- const CustomersNavLabel = options?.sectionLabels?.customers ?? 'Clientes'
120
-
121
- // Canais de venda — wraps the ChannelsPage with the app's shop provider.
122
- const ChannelsPageWrapped: React.ComponentType<unknown> = () =>
123
- React.createElement(ChannelsPage, { resolveProvider: options?.provider })
124
- ChannelsPageWrapped.displayName = 'ChannelsPage'
125
-
126
- const navSection = options?.navSection ?? 'main'
127
- const navPosition = options?.navPosition ?? 6
128
-
129
- return {
130
- id: 'shop',
131
- name: options?.navLabel ?? 'Shop',
132
- icon: 'ShoppingBag',
133
- version: '1.0.0',
134
- scope: options?.scope ?? 'vertical',
135
- verticalId: options?.verticalId,
136
- // Storefront/commerce-operator-console surface — targets the ecommerce
137
- // Panel world, not a saas admin app.
138
- scaffolds: ['ecommerce'],
139
- defaultEnabled: true,
140
- dependencies: [],
141
- navigation: [
142
- {
143
- section: navSection,
144
- position: navPosition,
145
- label: options?.navLabel ?? 'Shop',
146
- route: '/shop',
147
- icon: 'ShoppingBag',
148
- permission: { feature: 'shop', action: 'read' as const },
149
- // "Loja" and "Produtos" are the same screen — don't list both.
150
- indexChild: false,
151
- },
152
- ...SECTIONS.map((s, i) => ({
153
- section: navSection,
154
- position: navPosition + i + 1,
155
- label: s.label,
156
- route: `/shop/${s.id}`,
157
- icon: s.icon,
158
- permission: { feature: 'shop', action: 'read' as const },
159
- })),
160
- // Clientes — top-level (route not under /shop, so it leaves the accordion).
161
- // Fractional position keeps it right below "Loja", above other plugins.
162
- {
163
- section: navSection,
164
- position: navPosition + 0.5,
165
- label: CustomersNavLabel,
166
- route: '/customers',
167
- icon: 'Users',
168
- permission: { feature: 'shop', action: 'read' as const },
169
- },
170
- // Canais de venda — inside the "Loja" group (route under /shop nests it in
171
- // the accordion). The store's points of sale (Loja Online, WhatsApp, …).
172
- {
173
- section: navSection,
174
- position: navPosition + SECTIONS.length + 1,
175
- label: 'Canais de venda',
176
- route: '/shop/channels',
177
- icon: 'Radio',
178
- permission: { feature: 'shop', action: 'read' as const },
179
- },
180
- ],
181
- routes: [
182
- {
183
- // Kept so existing links to /shop still resolve; renders the tabbed page.
184
- path: '/shop',
185
- component: pageFor(),
186
- permission: { feature: 'shop', action: 'read' as const },
187
- },
188
- ...SECTIONS.map((s) => ({
189
- path: `/shop/${s.id}`,
190
- component: CRUD_PAGES[s.id] ?? pageFor(s.id),
191
- permission: { feature: 'shop', action: 'read' as const },
192
- })),
193
- // Clientes at the top level, same CRUD page as before.
194
- {
195
- path: '/customers',
196
- component: CustomersCrudPage as React.ComponentType<unknown>,
197
- permission: { feature: 'shop', action: 'read' as const },
198
- },
199
- // Canais de venda management page (under the Loja group).
200
- {
201
- path: '/shop/channels',
202
- component: ChannelsPageWrapped,
203
- permission: { feature: 'shop', action: 'read' as const },
204
- },
205
- ],
206
- widgets: [],
207
- aiTools: [
208
- {
209
- id: 'shop.list-products',
210
- name: 'listProducts',
211
- description: 'Lists products in the shop catalog.',
212
- icon: 'Package',
213
- mode: 'read' as const,
214
- category: 'Shop',
215
- parameters: {
216
- type: 'object' as const,
217
- properties: {
218
- search: { type: 'string' as const, description: 'Filter by name' },
219
- status: { type: 'string' as const, enum: ['draft', 'active', 'archived'] },
220
- },
221
- },
222
- suggestions: [
223
- { label: 'What products are available?' },
224
- { label: 'Which products are out of stock?' },
225
- ],
226
- },
227
- {
228
- id: 'shop.list-orders',
229
- name: 'listOrders',
230
- description: 'Lists recent shop orders.',
231
- icon: 'ShoppingCart',
232
- mode: 'read' as const,
233
- category: 'Shop',
234
- parameters: {
235
- type: 'object' as const,
236
- properties: {
237
- financialStatus: { type: 'string' as const, enum: ['pending', 'paid', 'refunded', 'voided'] },
238
- fulfillmentStatus: { type: 'string' as const, enum: ['unfulfilled', 'partially_fulfilled', 'fulfilled'] },
239
- },
240
- },
241
- suggestions: [
242
- { label: 'Show me pending orders' },
243
- { label: 'How many orders were paid today?' },
244
- ],
245
- },
246
- ],
247
- settings: [
248
- {
249
- id: 'shop',
250
- label: 'Shop',
251
- icon: 'ShoppingBag',
252
- component: (() => {
253
- const ShopSettingsTab: React.ComponentType<unknown> = () =>
254
- React.createElement(ShopSettings, { currency: curr })
255
- ShopSettingsTab.displayName = 'ShopSettingsTab'
256
- return ShopSettingsTab
257
- })(),
258
- order: 30,
259
- permission: { feature: 'shop', action: 'read' as const },
260
- },
261
- ],
262
- }
263
- }
264
-
265
- // Re-export shop types so consumer apps only need @fayz-ai/plugin-shop
266
- export type {
267
- Product, Order, ShopCustomer, Discount, Category,
268
- CreateProductInput, UpdateProductInput, ListProductsOptions,
269
- CreateOrderInput, UpdateOrderInput, ListOrdersOptions,
270
- } from '@fayz-ai/shop'
@@ -1,274 +0,0 @@
1
- import React from 'react'
2
- import { Globe, MessageCircle, Camera, Music, ShoppingBag, MapPin, Link2, Plus, ExternalLink, Trash2, Check, X } from 'lucide-react'
3
- import { Button, Input } from '@fayz-ai/ui'
4
- import type { ShopProvider, SalesChannel, SalesChannelKind } from '@fayz-ai/shop'
5
- import { getShopProvider } from '@fayz-ai/shop'
6
- import type { ShopProviderResolver } from '../ShopPage'
7
-
8
- // ---------------------------------------------------------------------------
9
- // Canais de venda — the store's points of sale, Shopify-style. The online_store
10
- // channel points at the merchant's fayz storefront; the rest are external links
11
- // the merchant maintains (WhatsApp, Instagram, TikTok Shop…). Every enabled
12
- // channel with a URL also surfaces as an external link in the left menu (see
13
- // the "Canais de venda" nav group), so this page is where they are configured.
14
- // ---------------------------------------------------------------------------
15
-
16
- interface KindMeta {
17
- label: string
18
- icon: React.ComponentType<{ className?: string }>
19
- hint: string
20
- placeholder: string
21
- }
22
-
23
- const KIND_META: Record<SalesChannelKind, KindMeta> = {
24
- online_store: { label: 'Loja Online', icon: Globe, hint: 'Seu storefront fayz — a vitrine pública da loja.', placeholder: 'https://minhaloja.com.br/' },
25
- whatsapp: { label: 'WhatsApp', icon: MessageCircle, hint: 'Link direto para o WhatsApp Business.', placeholder: 'https://wa.me/5521999999999' },
26
- instagram: { label: 'Instagram', icon: Camera, hint: 'Perfil ou loja no Instagram.', placeholder: 'https://instagram.com/sualoja' },
27
- tiktok_shop: { label: 'TikTok Shop', icon: Music, hint: 'Sua vitrine no TikTok Shop.', placeholder: 'https://shop.tiktok.com/@sualoja' },
28
- marketplace: { label: 'Marketplace', icon: ShoppingBag, hint: 'Mercado Livre, Amazon, Shopee…', placeholder: 'https://…' },
29
- physical: { label: 'Loja Física', icon: MapPin, hint: 'Endereço ou ponto de venda físico.', placeholder: 'https://maps.google.com/…' },
30
- custom: { label: 'Outro canal', icon: Link2, hint: 'Qualquer outro ponto de venda com um link.', placeholder: 'https://…' },
31
- }
32
-
33
- const ADDABLE_KINDS: SalesChannelKind[] = ['whatsapp', 'instagram', 'tiktok_shop', 'marketplace', 'physical', 'custom']
34
-
35
- function resolve(resolver?: ShopProviderResolver): ShopProvider {
36
- return typeof resolver === 'function' ? resolver() : resolver ?? getShopProvider()
37
- }
38
-
39
- // Let any runtime nav contributor (e.g. the app's ChannelsNav) refresh the
40
- // sidebar the moment channels change here, without coupling the two.
41
- function notifyChannelsChanged() {
42
- if (typeof window !== 'undefined') window.dispatchEvent(new Event('fayz:channels-changed'))
43
- }
44
-
45
- export interface ChannelsPageProps {
46
- resolveProvider?: ShopProviderResolver
47
- }
48
-
49
- export function ChannelsPage({ resolveProvider }: ChannelsPageProps) {
50
- const provider = React.useMemo(() => resolve(resolveProvider), [resolveProvider])
51
- const supported = typeof provider.listSalesChannels === 'function'
52
-
53
- const [channels, setChannels] = React.useState<SalesChannel[]>([])
54
- const [loading, setLoading] = React.useState(true)
55
- const [adding, setAdding] = React.useState<SalesChannelKind | null>(null)
56
-
57
- const reload = React.useCallback(async () => {
58
- if (!provider.listSalesChannels) { setLoading(false); return }
59
- setLoading(true)
60
- try {
61
- const rows = await provider.listSalesChannels()
62
- // Guarantee a Loja Online row exists so the storefront link is always
63
- // configurable — seed one on first visit if the store has none.
64
- if (!rows.some((c) => c.kind === 'online_store') && provider.createSalesChannel) {
65
- await provider.createSalesChannel({ kind: 'online_store', label: 'Loja Online', enabled: true, position: 0 })
66
- setChannels(await provider.listSalesChannels())
67
- } else {
68
- setChannels(rows)
69
- }
70
- } finally {
71
- setLoading(false)
72
- }
73
- }, [provider])
74
-
75
- React.useEffect(() => { void reload() }, [reload])
76
-
77
- const patch = async (id: string, input: Parameters<NonNullable<ShopProvider['updateSalesChannel']>>[1]) => {
78
- if (!provider.updateSalesChannel) return
79
- const updated = await provider.updateSalesChannel(id, input)
80
- setChannels((prev) => prev.map((c) => (c.id === id ? updated : c)))
81
- notifyChannelsChanged()
82
- }
83
-
84
- const remove = async (id: string) => {
85
- if (!provider.deleteSalesChannel) return
86
- await provider.deleteSalesChannel(id)
87
- setChannels((prev) => prev.filter((c) => c.id !== id))
88
- notifyChannelsChanged()
89
- }
90
-
91
- const add = async (kind: SalesChannelKind, label: string, url: string) => {
92
- if (!provider.createSalesChannel) return
93
- const created = await provider.createSalesChannel({ kind, label: label || KIND_META[kind].label, url: url || null, enabled: true, position: channels.length })
94
- setChannels((prev) => [...prev, created])
95
- setAdding(null)
96
- notifyChannelsChanged()
97
- }
98
-
99
- if (!supported) {
100
- return (
101
- <div className="mx-auto max-w-4xl p-6">
102
- <h1 className="text-xl font-semibold text-foreground">Canais de venda</h1>
103
- <p className="mt-2 text-sm text-muted-foreground">
104
- Este provedor de loja ainda não expõe canais de venda.
105
- </p>
106
- </div>
107
- )
108
- }
109
-
110
- const online = channels.find((c) => c.kind === 'online_store')
111
- const others = channels.filter((c) => c.kind !== 'online_store')
112
-
113
- return (
114
- <div className="mx-auto max-w-4xl p-6">
115
- <div className="mb-6">
116
- <h1 className="text-xl font-semibold text-foreground">Canais de venda</h1>
117
- <p className="mt-1 text-sm text-muted-foreground">
118
- Onde sua loja vende. A Loja Online é o seu storefront fayz; os demais são links que aparecem no menu lateral.
119
- </p>
120
- </div>
121
-
122
- {loading ? (
123
- <p className="text-sm text-muted-foreground">Carregando…</p>
124
- ) : (
125
- <div className="space-y-6">
126
- {online && <OnlineStoreCard channel={online} onSave={(url) => patch(online.id, { url })} onToggle={(enabled) => patch(online.id, { enabled })} />}
127
-
128
- <div>
129
- <div className="mb-2 flex items-center justify-between">
130
- <h2 className="text-sm font-semibold text-foreground">Outros canais</h2>
131
- </div>
132
- <div className="space-y-2">
133
- {others.map((c) => (
134
- <ChannelRow key={c.id} channel={c} onSave={(url) => patch(c.id, { url })} onToggle={(enabled) => patch(c.id, { enabled })} onRemove={() => remove(c.id)} />
135
- ))}
136
- {others.length === 0 && (
137
- <p className="rounded-card border border-dashed border-border p-4 text-sm text-muted-foreground">
138
- Nenhum canal adicional ainda. Adicione WhatsApp, Instagram, TikTok Shop e outros pontos de venda.
139
- </p>
140
- )}
141
- </div>
142
-
143
- {adding ? (
144
- <AddChannelForm kind={adding} onCancel={() => setAdding(null)} onAdd={(label, url) => add(adding, label, url)} onKind={setAdding} />
145
- ) : (
146
- <div className="mt-3 flex flex-wrap gap-2">
147
- {ADDABLE_KINDS.map((k) => {
148
- const Icon = KIND_META[k].icon
149
- return (
150
- <Button key={k} variant="outline" size="sm" onClick={() => setAdding(k)}>
151
- <Icon className="mr-1.5 h-3.5 w-3.5" /> {KIND_META[k].label}
152
- </Button>
153
- )
154
- })}
155
- </div>
156
- )}
157
- </div>
158
- </div>
159
- )}
160
- </div>
161
- )
162
- }
163
-
164
- function OnlineStoreCard({ channel, onSave, onToggle }: { channel: SalesChannel; onSave: (url: string | null) => void; onToggle: (enabled: boolean) => void }) {
165
- const [url, setUrl] = React.useState(channel.url ?? '')
166
- const dirty = (channel.url ?? '') !== url
167
- const meta = KIND_META.online_store
168
- return (
169
- <div className="rounded-card border border-border bg-card p-5">
170
- <div className="flex items-start gap-3">
171
- <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary/10">
172
- <meta.icon className="h-5 w-5 text-primary" />
173
- </div>
174
- <div className="min-w-0 flex-1">
175
- <div className="flex items-center gap-2">
176
- <span className="font-semibold text-foreground">Loja Online</span>
177
- <span className={`rounded-full px-2 py-0.5 text-xs font-medium ${channel.enabled ? 'bg-emerald-100 text-emerald-800' : 'bg-muted text-muted-foreground'}`}>
178
- {channel.enabled ? 'Ativa' : 'Inativa'}
179
- </span>
180
- </div>
181
- <p className="mt-0.5 text-sm text-muted-foreground">{meta.hint}</p>
182
-
183
- <div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center">
184
- <Input value={url} placeholder={meta.placeholder} onChange={(e) => setUrl(e.target.value)} className="flex-1" />
185
- <div className="flex gap-2">
186
- <Button size="sm" disabled={!dirty} onClick={() => onSave(url.trim() || null)}>Salvar</Button>
187
- <Button size="sm" variant="outline" disabled={!channel.url} onClick={() => channel.url && window.open(channel.url, '_blank', 'noopener,noreferrer')}>
188
- <ExternalLink className="mr-1.5 h-3.5 w-3.5" /> Ver loja
189
- </Button>
190
- </div>
191
- </div>
192
- <div className="mt-3">
193
- <button type="button" onClick={() => onToggle(!channel.enabled)} className="text-xs font-medium text-muted-foreground hover:text-foreground">
194
- {channel.enabled ? 'Desativar canal' : 'Ativar canal'}
195
- </button>
196
- </div>
197
- </div>
198
- </div>
199
- </div>
200
- )
201
- }
202
-
203
- function ChannelRow({ channel, onSave, onToggle, onRemove }: { channel: SalesChannel; onSave: (url: string | null) => void; onToggle: (enabled: boolean) => void; onRemove: () => void }) {
204
- const [url, setUrl] = React.useState(channel.url ?? '')
205
- const [editing, setEditing] = React.useState(false)
206
- const meta = KIND_META[channel.kind] ?? KIND_META.custom
207
- const Icon = meta.icon
208
- return (
209
- <div className="rounded-card border border-border bg-card p-3">
210
- <div className="flex items-center gap-3">
211
- <div className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full ${channel.enabled ? 'bg-primary/10' : 'bg-muted'}`}>
212
- <Icon className={`h-4 w-4 ${channel.enabled ? 'text-primary' : 'text-muted-foreground'}`} />
213
- </div>
214
- <div className="min-w-0 flex-1">
215
- <p className="truncate text-sm font-medium text-foreground">{channel.label}</p>
216
- {editing ? (
217
- <Input value={url} placeholder={meta.placeholder} onChange={(e) => setUrl(e.target.value)} className="mt-1 h-8" />
218
- ) : (
219
- <p className="truncate text-xs text-muted-foreground">{channel.url || 'Sem link configurado'}</p>
220
- )}
221
- </div>
222
- <div className="flex shrink-0 items-center gap-1">
223
- {editing ? (
224
- <>
225
- <Button size="sm" variant="ghost" onClick={() => { onSave(url.trim() || null); setEditing(false) }}><Check className="h-4 w-4" /></Button>
226
- <Button size="sm" variant="ghost" onClick={() => { setUrl(channel.url ?? ''); setEditing(false) }}><X className="h-4 w-4" /></Button>
227
- </>
228
- ) : (
229
- <>
230
- {channel.url && (
231
- <Button size="sm" variant="ghost" onClick={() => window.open(channel.url!, '_blank', 'noopener,noreferrer')}><ExternalLink className="h-4 w-4" /></Button>
232
- )}
233
- <button type="button" onClick={() => onToggle(!channel.enabled)} title={channel.enabled ? 'Desativar' : 'Ativar'} className={`rounded-full px-2 py-0.5 text-xs font-medium ${channel.enabled ? 'bg-emerald-100 text-emerald-800' : 'bg-muted text-muted-foreground'}`}>
234
- {channel.enabled ? 'Ativo' : 'Inativo'}
235
- </button>
236
- <Button size="sm" variant="ghost" onClick={() => setEditing(true)}>Editar</Button>
237
- <Button size="sm" variant="ghost" onClick={onRemove}><Trash2 className="h-4 w-4 text-red-500" /></Button>
238
- </>
239
- )}
240
- </div>
241
- </div>
242
- </div>
243
- )
244
- }
245
-
246
- function AddChannelForm({ kind, onKind, onCancel, onAdd }: { kind: SalesChannelKind; onKind: (k: SalesChannelKind) => void; onCancel: () => void; onAdd: (label: string, url: string) => void }) {
247
- const meta = KIND_META[kind]
248
- const [label, setLabel] = React.useState(meta.label)
249
- const [url, setUrl] = React.useState('')
250
- React.useEffect(() => { setLabel(KIND_META[kind].label) }, [kind])
251
- return (
252
- <div className="mt-3 rounded-card border border-border bg-card p-4">
253
- <div className="mb-3 flex flex-wrap gap-2">
254
- {ADDABLE_KINDS.map((k) => {
255
- const Icon = KIND_META[k].icon
256
- return (
257
- <button key={k} type="button" onClick={() => onKind(k)} className={`flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium ${k === kind ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:text-foreground'}`}>
258
- <Icon className="h-3.5 w-3.5" /> {KIND_META[k].label}
259
- </button>
260
- )
261
- })}
262
- </div>
263
- <p className="mb-2 text-xs text-muted-foreground">{meta.hint}</p>
264
- <div className="flex flex-col gap-2 sm:flex-row">
265
- <Input value={label} placeholder="Nome do canal" onChange={(e) => setLabel(e.target.value)} className="sm:w-48" />
266
- <Input value={url} placeholder={meta.placeholder} onChange={(e) => setUrl(e.target.value)} className="flex-1" />
267
- </div>
268
- <div className="mt-3 flex gap-2">
269
- <Button size="sm" onClick={() => onAdd(label, url)}><Plus className="mr-1.5 h-3.5 w-3.5" /> Adicionar</Button>
270
- <Button size="sm" variant="outline" onClick={onCancel}>Cancelar</Button>
271
- </div>
272
- </div>
273
- )
274
- }