@fayz-ai/plugin-menu 0.2.0

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 (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +49 -0
  3. package/dist/MenuContext.d.ts +30 -0
  4. package/dist/MenuContext.d.ts.map +1 -0
  5. package/dist/MenuPage-OABV6JYB.js +520 -0
  6. package/dist/MenuPage-OABV6JYB.js.map +1 -0
  7. package/dist/MenuPage-VMI2YAPY.cjs +522 -0
  8. package/dist/MenuPage-VMI2YAPY.cjs.map +1 -0
  9. package/dist/MenuPage.d.ts +13 -0
  10. package/dist/MenuPage.d.ts.map +1 -0
  11. package/dist/data/fayz.d.ts +12 -0
  12. package/dist/data/fayz.d.ts.map +1 -0
  13. package/dist/data/mock.d.ts +3 -0
  14. package/dist/data/mock.d.ts.map +1 -0
  15. package/dist/data/types.d.ts +22 -0
  16. package/dist/data/types.d.ts.map +1 -0
  17. package/dist/index.cjs +1053 -0
  18. package/dist/index.cjs.map +1 -0
  19. package/dist/index.d.ts +34 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +1046 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/locales/en.d.ts +2 -0
  24. package/dist/locales/en.d.ts.map +1 -0
  25. package/dist/locales/index.d.ts +2 -0
  26. package/dist/locales/index.d.ts.map +1 -0
  27. package/dist/locales/pt-BR.d.ts +2 -0
  28. package/dist/locales/pt-BR.d.ts.map +1 -0
  29. package/dist/registries.d.ts +3 -0
  30. package/dist/registries.d.ts.map +1 -0
  31. package/dist/store.d.ts +26 -0
  32. package/dist/store.d.ts.map +1 -0
  33. package/dist/types.d.ts +112 -0
  34. package/dist/types.d.ts.map +1 -0
  35. package/dist/views/MenuManagerView.d.ts +3 -0
  36. package/dist/views/MenuManagerView.d.ts.map +1 -0
  37. package/package.json +55 -0
  38. package/src/MenuContext.tsx +40 -0
  39. package/src/MenuPage.tsx +21 -0
  40. package/src/data/fayz.ts +429 -0
  41. package/src/data/mock.ts +289 -0
  42. package/src/data/types.ts +33 -0
  43. package/src/index.ts +184 -0
  44. package/src/locales/en.ts +64 -0
  45. package/src/locales/index.ts +4 -0
  46. package/src/locales/pt-BR.ts +64 -0
  47. package/src/registries.ts +84 -0
  48. package/src/store.ts +161 -0
  49. package/src/types.ts +145 -0
  50. package/src/views/MenuManagerView.tsx +688 -0
@@ -0,0 +1,429 @@
1
+ import { createFayzClient, type FayzClientOptions, type FayzTableFilter } from '@fayz-ai/sdk'
2
+ import type {
3
+ CreateMenuCategoryInput,
4
+ CreateMenuItemInput,
5
+ MenuCategory,
6
+ MenuItem,
7
+ MenuItemQuery,
8
+ MenuItemStatus,
9
+ MenuSummary,
10
+ ModifierGroup,
11
+ PaginatedResult,
12
+ UpdateMenuItemInput,
13
+ } from '../types'
14
+ import type { MenuDataProvider } from './types'
15
+
16
+ export interface FayzMenuProviderOptions extends FayzClientOptions {
17
+ projectId?: string
18
+ schema?: string
19
+ runtime?: boolean
20
+ categoriesTable?: string
21
+ productsTable?: string
22
+ categoryKind?: string
23
+ }
24
+
25
+ interface CategoryRow {
26
+ id: string
27
+ tenant_id?: string | null
28
+ kind?: string | null
29
+ name: string
30
+ slug?: string | null
31
+ parent_id?: string | null
32
+ icon?: string | null
33
+ color?: string | null
34
+ sort_order?: number | null
35
+ is_active?: boolean | null
36
+ metadata?: Record<string, unknown> | null
37
+ created_at?: string | null
38
+ updated_at?: string | null
39
+ }
40
+
41
+ interface ProductRow {
42
+ id: string
43
+ tenant_id?: string | null
44
+ category_id?: string | null
45
+ name: string
46
+ description?: string | null
47
+ sku?: string | null
48
+ price?: number | string | null
49
+ cost?: number | string | null
50
+ currency?: string | null
51
+ image_url?: string | null
52
+ status?: string | null
53
+ is_active?: boolean | null
54
+ tags?: string[] | null
55
+ metadata?: Record<string, unknown> | null
56
+ created_at?: string | null
57
+ updated_at?: string | null
58
+ }
59
+
60
+ const DEFAULT_CATEGORY_KIND = 'menu_category'
61
+ const MENU_METADATA_KEY = 'fayzMenu'
62
+
63
+ function nowIso(): string {
64
+ return new Date().toISOString()
65
+ }
66
+
67
+ function slugify(value: string): string {
68
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
69
+ }
70
+
71
+ function numberValue(value: number | string | null | undefined): number {
72
+ if (typeof value === 'number') return value
73
+ if (typeof value === 'string') return Number(value) || 0
74
+ return 0
75
+ }
76
+
77
+ function menuMetadata(row: ProductRow): Record<string, unknown> {
78
+ const raw = row.metadata?.[MENU_METADATA_KEY]
79
+ return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw as Record<string, unknown> : {}
80
+ }
81
+
82
+ function rowToCategory(row: CategoryRow, itemCount = 0): MenuCategory {
83
+ return {
84
+ id: row.id,
85
+ name: row.name,
86
+ parentId: row.parent_id ?? undefined,
87
+ icon: row.icon ?? undefined,
88
+ color: row.color ?? undefined,
89
+ sortOrder: row.sort_order ?? 0,
90
+ isActive: row.is_active ?? true,
91
+ itemCount,
92
+ tenantId: row.tenant_id ?? 'runtime-tenant',
93
+ createdAt: row.created_at ?? nowIso(),
94
+ updatedAt: row.updated_at ?? row.created_at ?? nowIso(),
95
+ }
96
+ }
97
+
98
+ function rowToItem(row: ProductRow, categoryName?: string): MenuItem {
99
+ const metadata = menuMetadata(row)
100
+ const status = (metadata.status ?? row.status ?? 'available') as MenuItemStatus
101
+ return {
102
+ id: row.id,
103
+ name: row.name,
104
+ description: row.description ?? undefined,
105
+ price: numberValue(row.price),
106
+ cost: numberValue(row.cost),
107
+ currency: row.currency ?? 'BRL',
108
+ sku: row.sku ?? undefined,
109
+ imageUrl: row.image_url ?? undefined,
110
+ categoryId: row.category_id ?? undefined,
111
+ categoryName,
112
+ status,
113
+ isActive: row.is_active ?? true,
114
+ prepTimeMinutes: typeof metadata.prepTimeMinutes === 'number' ? metadata.prepTimeMinutes : undefined,
115
+ allergens: Array.isArray(metadata.allergens) ? metadata.allergens as string[] : undefined,
116
+ dietaryTags: Array.isArray(metadata.dietaryTags) ? metadata.dietaryTags as string[] : row.tags ?? undefined,
117
+ availableForPos: typeof metadata.availableForPos === 'boolean' ? metadata.availableForPos : true,
118
+ availableForDelivery: typeof metadata.availableForDelivery === 'boolean' ? metadata.availableForDelivery : true,
119
+ deliveryPrice: typeof metadata.deliveryPrice === 'number' ? metadata.deliveryPrice : undefined,
120
+ sortOrder: typeof metadata.sortOrder === 'number' ? metadata.sortOrder : 0,
121
+ isFeatured: typeof metadata.isFeatured === 'boolean' ? metadata.isFeatured : false,
122
+ modifierGroupIds: Array.isArray(metadata.modifierGroupIds) ? metadata.modifierGroupIds as string[] : undefined,
123
+ metadata: row.metadata ?? {},
124
+ tenantId: row.tenant_id ?? 'runtime-tenant',
125
+ createdAt: row.created_at ?? nowIso(),
126
+ updatedAt: row.updated_at ?? row.created_at ?? nowIso(),
127
+ }
128
+ }
129
+
130
+ function categoryPayload(input: CreateMenuCategoryInput, kind: string): Record<string, unknown> {
131
+ return {
132
+ kind,
133
+ name: input.name,
134
+ slug: slugify(input.name),
135
+ parent_id: input.parentId ?? null,
136
+ icon: input.icon ?? null,
137
+ color: input.color ?? null,
138
+ sort_order: input.sortOrder ?? 0,
139
+ is_active: true,
140
+ }
141
+ }
142
+
143
+ function itemMetadata(input: Partial<CreateMenuItemInput>, existing?: Record<string, unknown>): Record<string, unknown> {
144
+ return {
145
+ ...(existing ?? {}),
146
+ [MENU_METADATA_KEY]: {
147
+ ...((existing?.[MENU_METADATA_KEY] && typeof existing[MENU_METADATA_KEY] === 'object') ? existing[MENU_METADATA_KEY] as Record<string, unknown> : {}),
148
+ status: input.status,
149
+ prepTimeMinutes: input.prepTimeMinutes,
150
+ allergens: input.allergens,
151
+ dietaryTags: input.dietaryTags,
152
+ availableForPos: input.availableForPos ?? true,
153
+ availableForDelivery: input.availableForDelivery ?? true,
154
+ deliveryPrice: input.deliveryPrice,
155
+ isFeatured: input.isFeatured ?? false,
156
+ modifierGroupIds: input.modifierGroupIds,
157
+ },
158
+ }
159
+ }
160
+
161
+ function itemCreatePayload(input: CreateMenuItemInput): Record<string, unknown> {
162
+ return {
163
+ name: input.name,
164
+ description: input.description ?? null,
165
+ price: input.price,
166
+ cost: input.cost ?? null,
167
+ currency: 'BRL',
168
+ image_url: input.imageUrl ?? null,
169
+ category_id: input.categoryId ?? null,
170
+ status: input.status === 'hidden' ? 'archived' : 'active',
171
+ is_active: input.status !== 'hidden',
172
+ tags: input.dietaryTags ?? [],
173
+ metadata: itemMetadata(input),
174
+ }
175
+ }
176
+
177
+ function itemUpdatePayload(input: UpdateMenuItemInput, existing?: MenuItem): Record<string, unknown> {
178
+ const payload: Record<string, unknown> = {}
179
+ if (input.name !== undefined) payload.name = input.name
180
+ if (input.description !== undefined) payload.description = input.description
181
+ if (input.price !== undefined) payload.price = input.price
182
+ if (input.cost !== undefined) payload.cost = input.cost
183
+ if (input.imageUrl !== undefined) payload.image_url = input.imageUrl
184
+ if (input.categoryId !== undefined) payload.category_id = input.categoryId
185
+ if (input.status !== undefined) {
186
+ payload.status = input.status === 'hidden' ? 'archived' : 'active'
187
+ payload.is_active = input.status !== 'hidden'
188
+ }
189
+ if (input.dietaryTags !== undefined) payload.tags = input.dietaryTags
190
+ payload.metadata = itemMetadata(input, existing?.metadata)
191
+ return payload
192
+ }
193
+
194
+ function filtersFromQuery(query: MenuItemQuery): FayzTableFilter[] {
195
+ const filters: FayzTableFilter[] = [{ column: 'is_active', operator: 'eq', value: true }]
196
+ if (query.categoryId) filters.push({ column: 'category_id', operator: 'eq', value: query.categoryId })
197
+ return filters
198
+ }
199
+
200
+ function matchesClientQuery(item: MenuItem, query: MenuItemQuery): boolean {
201
+ if (query.status) {
202
+ const statuses = Array.isArray(query.status) ? query.status : [query.status]
203
+ if (!statuses.includes(item.status)) return false
204
+ }
205
+ if (query.availableForPos !== undefined && item.availableForPos !== query.availableForPos) return false
206
+ if (query.availableForDelivery !== undefined && item.availableForDelivery !== query.availableForDelivery) return false
207
+ if (query.isFeatured !== undefined && item.isFeatured !== query.isFeatured) return false
208
+ if (query.search) {
209
+ const search = query.search.toLowerCase()
210
+ return item.name.toLowerCase().includes(search)
211
+ || (item.description?.toLowerCase().includes(search) ?? false)
212
+ }
213
+ return true
214
+ }
215
+
216
+ function paginate<T>(items: T[], page?: number, pageSize?: number): PaginatedResult<T> {
217
+ const currentPage = page ?? 1
218
+ const limit = pageSize ?? 50
219
+ const start = (currentPage - 1) * limit
220
+ return { data: items.slice(start, start + limit), total: items.length }
221
+ }
222
+
223
+ export function createFayzMenuProvider(options: FayzMenuProviderOptions = {}): MenuDataProvider {
224
+ const client = createFayzClient(options)
225
+ const projectId = options.projectId
226
+ const schema = options.schema
227
+ const runtime = options.runtime
228
+ const categoriesTable = options.categoriesTable ?? 'categories'
229
+ const productsTable = options.productsTable ?? 'products'
230
+ const categoryKind = options.categoryKind ?? DEFAULT_CATEGORY_KIND
231
+
232
+ const tableOptions = { projectId, schema, runtime }
233
+
234
+ async function getCategoriesRaw(): Promise<CategoryRow[]> {
235
+ const response = await client.data.listRows<CategoryRow>({
236
+ ...tableOptions,
237
+ table: categoriesTable,
238
+ filters: [
239
+ { column: 'kind', operator: 'eq', value: categoryKind },
240
+ { column: 'is_active', operator: 'eq', value: true },
241
+ ],
242
+ sortColumn: 'sort_order',
243
+ sortDirection: 'asc',
244
+ limit: 500,
245
+ })
246
+ return response.rows
247
+ }
248
+
249
+ async function getItemsRaw(): Promise<ProductRow[]> {
250
+ const response = await client.data.listRows<ProductRow>({
251
+ ...tableOptions,
252
+ table: productsTable,
253
+ filters: [{ column: 'is_active', operator: 'eq', value: true }],
254
+ sortColumn: 'name',
255
+ sortDirection: 'asc',
256
+ limit: 1000,
257
+ })
258
+ return response.rows.filter((row) => {
259
+ const metadata = menuMetadata(row)
260
+ return metadata.kind === 'menu_item' || metadata.status != null || row.category_id != null
261
+ })
262
+ }
263
+
264
+ async function getCategoryMap() {
265
+ const categories = await getCategoriesRaw()
266
+ return new Map(categories.map((category) => [category.id, category]))
267
+ }
268
+
269
+ async function getItems(): Promise<MenuItem[]> {
270
+ const [rows, categoryMap] = await Promise.all([getItemsRaw(), getCategoryMap()])
271
+ return rows.map((row) => rowToItem(row, row.category_id ? categoryMap.get(row.category_id)?.name : undefined))
272
+ .sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name))
273
+ }
274
+
275
+ return {
276
+ async getCategories() {
277
+ const [categories, items] = await Promise.all([getCategoriesRaw(), getItemsRaw()])
278
+ const counts = new Map<string, number>()
279
+ for (const item of items) {
280
+ if (item.category_id) counts.set(item.category_id, (counts.get(item.category_id) ?? 0) + 1)
281
+ }
282
+ return categories.map((category) => rowToCategory(category, counts.get(category.id) ?? 0))
283
+ },
284
+
285
+ async getCategoryById(id: string) {
286
+ const response = await client.data.listRows<CategoryRow>({
287
+ ...tableOptions,
288
+ table: categoriesTable,
289
+ filters: [{ column: 'id', operator: 'eq', value: id }],
290
+ limit: 1,
291
+ })
292
+ return response.rows[0] ? rowToCategory(response.rows[0]) : null
293
+ },
294
+
295
+ async createCategory(input: CreateMenuCategoryInput) {
296
+ const row = await client.data.createRow<CategoryRow>({
297
+ ...tableOptions,
298
+ table: categoriesTable,
299
+ row: categoryPayload(input, categoryKind),
300
+ })
301
+ return rowToCategory(row)
302
+ },
303
+
304
+ async updateCategory(id: string, data: Partial<MenuCategory>) {
305
+ const row = await client.data.updateRow<CategoryRow>({
306
+ ...tableOptions,
307
+ table: categoriesTable,
308
+ primaryKeys: { id },
309
+ row: {
310
+ name: data.name,
311
+ parent_id: data.parentId,
312
+ icon: data.icon,
313
+ color: data.color,
314
+ sort_order: data.sortOrder,
315
+ is_active: data.isActive,
316
+ },
317
+ })
318
+ return rowToCategory(row)
319
+ },
320
+
321
+ async deleteCategory(id: string) {
322
+ await client.data.updateRow<CategoryRow>({
323
+ ...tableOptions,
324
+ table: categoriesTable,
325
+ primaryKeys: { id },
326
+ row: { is_active: false },
327
+ })
328
+ },
329
+
330
+ async reorderCategories(ids: string[]) {
331
+ await Promise.all(ids.map((id, sortOrder) => this.updateCategory(id, { sortOrder })))
332
+ },
333
+
334
+ async getMenuItems(query: MenuItemQuery) {
335
+ const items = (await getItems()).filter((item) => matchesClientQuery(item, query))
336
+ return paginate(items, query.page, query.pageSize)
337
+ },
338
+
339
+ async getMenuItemById(id: string) {
340
+ const response = await client.data.listRows<ProductRow>({
341
+ ...tableOptions,
342
+ table: productsTable,
343
+ filters: [{ column: 'id', operator: 'eq', value: id }],
344
+ limit: 1,
345
+ })
346
+ if (!response.rows[0]) return null
347
+ const categoryMap = await getCategoryMap()
348
+ const row = response.rows[0]
349
+ return rowToItem(row, row.category_id ? categoryMap.get(row.category_id)?.name : undefined)
350
+ },
351
+
352
+ async createMenuItem(input: CreateMenuItemInput) {
353
+ const row = await client.data.createRow<ProductRow>({
354
+ ...tableOptions,
355
+ table: productsTable,
356
+ row: itemCreatePayload(input),
357
+ })
358
+ const categoryMap = await getCategoryMap()
359
+ return rowToItem(row, row.category_id ? categoryMap.get(row.category_id)?.name : undefined)
360
+ },
361
+
362
+ async updateMenuItem(id: string, data: UpdateMenuItemInput) {
363
+ const existing = await this.getMenuItemById(id)
364
+ const row = await client.data.updateRow<ProductRow>({
365
+ ...tableOptions,
366
+ table: productsTable,
367
+ primaryKeys: { id },
368
+ row: itemUpdatePayload(data, existing ?? undefined),
369
+ })
370
+ const categoryMap = await getCategoryMap()
371
+ return rowToItem(row, row.category_id ? categoryMap.get(row.category_id)?.name : undefined)
372
+ },
373
+
374
+ async deleteMenuItem(id: string) {
375
+ await client.data.updateRow<ProductRow>({
376
+ ...tableOptions,
377
+ table: productsTable,
378
+ primaryKeys: { id },
379
+ row: { is_active: false },
380
+ })
381
+ },
382
+
383
+ async toggleAvailability(id: string, field: 'status' | 'availableForPos' | 'availableForDelivery', value: unknown) {
384
+ return this.updateMenuItem(id, { [field]: value } as UpdateMenuItemInput)
385
+ },
386
+
387
+ async reorderItems(categoryId: string, ids: string[]) {
388
+ await Promise.all(ids.map(async (id, sortOrder) => {
389
+ const existing = await this.getMenuItemById(id)
390
+ await client.data.updateRow<ProductRow>({
391
+ ...tableOptions,
392
+ table: productsTable,
393
+ primaryKeys: { id },
394
+ row: { metadata: itemMetadata({ categoryId }, { ...existing?.metadata, [MENU_METADATA_KEY]: { ...existing?.metadata?.[MENU_METADATA_KEY] as Record<string, unknown>, sortOrder } }) },
395
+ })
396
+ }))
397
+ },
398
+
399
+ async getModifierGroups(): Promise<ModifierGroup[]> {
400
+ return []
401
+ },
402
+
403
+ async createModifierGroup(): Promise<ModifierGroup> {
404
+ throw new Error('Menu modifier groups require a dedicated Fayz table/broker contract.')
405
+ },
406
+
407
+ async updateModifierGroup(): Promise<ModifierGroup> {
408
+ throw new Error('Menu modifier groups require a dedicated Fayz table/broker contract.')
409
+ },
410
+
411
+ async deleteModifierGroup(): Promise<void> {
412
+ throw new Error('Menu modifier groups require a dedicated Fayz table/broker contract.')
413
+ },
414
+
415
+ async getSummary(): Promise<MenuSummary> {
416
+ const [items, categories] = await Promise.all([getItems(), this.getCategories()])
417
+ const prices = items.filter((item) => item.price > 0).map((item) => item.price)
418
+ return {
419
+ totalItems: items.length,
420
+ availableItems: items.filter((item) => item.status === 'available').length,
421
+ soldOutItems: items.filter((item) => item.status === 'sold_out').length,
422
+ hiddenItems: items.filter((item) => item.status === 'hidden').length,
423
+ categoryCount: categories.length,
424
+ averagePrice: prices.length ? prices.reduce((sum, price) => sum + price, 0) / prices.length : 0,
425
+ featuredCount: items.filter((item) => item.isFeatured).length,
426
+ }
427
+ },
428
+ }
429
+ }