@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,688 @@
1
+ import React, { useEffect, useMemo, useState } from 'react'
2
+ import {
3
+ Plus,
4
+ Search,
5
+ GripVertical,
6
+ ChevronDown,
7
+ ChevronRight,
8
+ MoreHorizontal,
9
+ Star,
10
+ Loader2,
11
+ UtensilsCrossed,
12
+ } from 'lucide-react'
13
+ import {
14
+ Modal, ModalContent, ModalHeader, ModalTitle, ModalBody, ModalFooter,
15
+ Button, Input,
16
+ Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
17
+ } from '@fayz-ai/ui'
18
+ import { useMenuConfig, useMenuStore, formatCurrency } from '../MenuContext'
19
+ import { useTranslation } from '@fayz-ai/core'
20
+ import type { MenuCategory, MenuItem } from '../types'
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Create forms (category / item) — design-system Modal
24
+ // ---------------------------------------------------------------------------
25
+
26
+ const UNCATEGORIZED = '__none'
27
+ const fieldLabel = 'text-sm font-medium text-foreground'
28
+
29
+ function CategoryModal({ open, onClose }: { open: boolean; onClose: () => void }) {
30
+ const t = useTranslation()
31
+ const createCategory = useMenuStore((s) => s.createCategory)
32
+ const [name, setName] = useState('')
33
+ const [saving, setSaving] = useState(false)
34
+
35
+ const submit = async () => {
36
+ if (!name.trim()) return
37
+ setSaving(true)
38
+ try {
39
+ await createCategory({ name: name.trim() })
40
+ setName('')
41
+ onClose()
42
+ } catch {
43
+ // store surfaces a toast on failure
44
+ } finally {
45
+ setSaving(false)
46
+ }
47
+ }
48
+
49
+ return (
50
+ <Modal open={open} onOpenChange={(o) => { if (!o) onClose() }}>
51
+ <ModalContent size="sm">
52
+ <ModalHeader>
53
+ <ModalTitle>{t('menu.manager.addCategory')}</ModalTitle>
54
+ </ModalHeader>
55
+ <ModalBody className="space-y-4">
56
+ <label className="block space-y-1.5">
57
+ <span className={fieldLabel}>{t('menu.form.categoryName')}</span>
58
+ <Input
59
+ autoFocus
60
+ value={name}
61
+ onChange={(e) => setName(e.target.value)}
62
+ onKeyDown={(e) => { if (e.key === 'Enter') submit() }}
63
+ />
64
+ </label>
65
+ </ModalBody>
66
+ <ModalFooter>
67
+ <Button variant="outline" onClick={onClose}>{t('menu.form.cancel')}</Button>
68
+ <Button onClick={submit} disabled={saving || !name.trim()}>
69
+ {saving && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />}
70
+ {t('menu.form.save')}
71
+ </Button>
72
+ </ModalFooter>
73
+ </ModalContent>
74
+ </Modal>
75
+ )
76
+ }
77
+
78
+ function ItemModal({
79
+ open,
80
+ categories,
81
+ defaultCategoryId,
82
+ onClose,
83
+ }: {
84
+ open: boolean
85
+ categories: MenuCategory[]
86
+ defaultCategoryId?: string
87
+ onClose: () => void
88
+ }) {
89
+ const t = useTranslation()
90
+ const config = useMenuConfig()
91
+ const createMenuItem = useMenuStore((s) => s.createMenuItem)
92
+ const [name, setName] = useState('')
93
+ const [price, setPrice] = useState('')
94
+ const [categoryId, setCategoryId] = useState(defaultCategoryId ?? UNCATEGORIZED)
95
+ const [description, setDescription] = useState('')
96
+ const [saving, setSaving] = useState(false)
97
+
98
+ const submit = async () => {
99
+ if (!name.trim()) return
100
+ setSaving(true)
101
+ try {
102
+ await createMenuItem({
103
+ name: name.trim(),
104
+ price: Number(price) || 0,
105
+ categoryId: categoryId === UNCATEGORIZED ? undefined : categoryId,
106
+ description: description.trim() || undefined,
107
+ })
108
+ onClose()
109
+ } catch {
110
+ // store surfaces a toast on failure
111
+ } finally {
112
+ setSaving(false)
113
+ }
114
+ }
115
+
116
+ return (
117
+ <Modal open={open} onOpenChange={(o) => { if (!o) onClose() }}>
118
+ <ModalContent size="md">
119
+ <ModalHeader>
120
+ <ModalTitle>{t('menu.manager.addProduct')}</ModalTitle>
121
+ </ModalHeader>
122
+ <ModalBody className="space-y-4">
123
+ <label className="block space-y-1.5">
124
+ <span className={fieldLabel}>{t('menu.form.itemName')}</span>
125
+ <Input autoFocus value={name} onChange={(e) => setName(e.target.value)} />
126
+ </label>
127
+ <div className="grid grid-cols-2 gap-3">
128
+ <label className="block space-y-1.5">
129
+ <span className={fieldLabel}>{t('menu.manager.price')} ({config.currency.symbol})</span>
130
+ <Input
131
+ type="number"
132
+ min={0}
133
+ step="0.01"
134
+ value={price}
135
+ onChange={(e) => setPrice(e.target.value)}
136
+ placeholder="0"
137
+ />
138
+ </label>
139
+ <label className="block space-y-1.5">
140
+ <span className={fieldLabel}>{t('menu.form.category')}</span>
141
+ <Select value={categoryId} onValueChange={setCategoryId}>
142
+ <SelectTrigger><SelectValue /></SelectTrigger>
143
+ <SelectContent>
144
+ <SelectItem value={UNCATEGORIZED}>{t('menu.form.uncategorized')}</SelectItem>
145
+ {categories.map((cat) => (
146
+ <SelectItem key={cat.id} value={cat.id}>{cat.name}</SelectItem>
147
+ ))}
148
+ </SelectContent>
149
+ </Select>
150
+ </label>
151
+ </div>
152
+ <label className="block space-y-1.5">
153
+ <span className={fieldLabel}>
154
+ {t('menu.form.description')}{' '}
155
+ <span className="text-muted-foreground font-normal">({t('menu.form.optional')})</span>
156
+ </span>
157
+ <textarea
158
+ value={description}
159
+ onChange={(e) => setDescription(e.target.value)}
160
+ rows={2}
161
+ className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
162
+ />
163
+ </label>
164
+ </ModalBody>
165
+ <ModalFooter>
166
+ <Button variant="outline" onClick={onClose}>{t('menu.form.cancel')}</Button>
167
+ <Button onClick={submit} disabled={saving || !name.trim()}>
168
+ {saving && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />}
169
+ {t('menu.form.save')}
170
+ </Button>
171
+ </ModalFooter>
172
+ </ModalContent>
173
+ </Modal>
174
+ )
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // Toggle — inline switch component
179
+ // ---------------------------------------------------------------------------
180
+
181
+ function Toggle({
182
+ checked,
183
+ onChange,
184
+ size = 'sm',
185
+ }: {
186
+ checked: boolean
187
+ onChange: (v: boolean) => void
188
+ size?: 'sm' | 'xs'
189
+ }) {
190
+ const h = size === 'xs' ? 'h-4 w-7' : 'h-5 w-9'
191
+ const dot = size === 'xs' ? 'h-3 w-3' : 'h-4 w-4'
192
+ const translate = size === 'xs' ? 'translate-x-[13px]' : 'translate-x-[18px]'
193
+ return (
194
+ <button
195
+ type="button"
196
+ onClick={() => onChange(!checked)}
197
+ className={`relative inline-flex ${h} items-center rounded-full transition-colors ${
198
+ checked ? 'bg-primary' : 'bg-muted-foreground/30'
199
+ }`}
200
+ >
201
+ <span
202
+ className={`pointer-events-none block ${dot} rounded-full bg-white shadow-sm transition-transform ${
203
+ checked ? translate : 'translate-x-[2px]'
204
+ }`}
205
+ />
206
+ </button>
207
+ )
208
+ }
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // Skeleton loader
212
+ // ---------------------------------------------------------------------------
213
+
214
+ function CategorySkeleton() {
215
+ return (
216
+ <div className="rounded-lg border bg-card animate-pulse">
217
+ <div className="flex items-center gap-3 px-4 py-3">
218
+ <div className="h-4 w-4 rounded bg-muted" />
219
+ <div className="h-4 w-4 rounded bg-muted" />
220
+ <div className="h-4 w-32 rounded bg-muted flex-1" />
221
+ <div className="h-4 w-16 rounded bg-muted" />
222
+ </div>
223
+ </div>
224
+ )
225
+ }
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // Category section
229
+ // ---------------------------------------------------------------------------
230
+
231
+ function CategorySection({
232
+ category,
233
+ items,
234
+ expanded,
235
+ onToggle,
236
+ onAddProduct,
237
+ }: {
238
+ category: MenuCategory
239
+ items: MenuItem[]
240
+ expanded: boolean
241
+ onToggle: () => void
242
+ onAddProduct: () => void
243
+ }) {
244
+ const t = useTranslation()
245
+ const config = useMenuConfig()
246
+ const toggleAvailability = useMenuStore((s) => s.toggleAvailability)
247
+
248
+ const handleToggle = async (
249
+ id: string,
250
+ field: 'availableForPos' | 'availableForDelivery',
251
+ current: boolean,
252
+ ) => {
253
+ await toggleAvailability(id, field, !current)
254
+ }
255
+
256
+ const handleFeatureToggle = async (id: string, current: boolean) => {
257
+ await toggleAvailability(id, 'isFeatured' as any, !current)
258
+ }
259
+
260
+ return (
261
+ <div className="rounded-lg border bg-card">
262
+ {/* Category header */}
263
+ <div className="flex items-center gap-3 px-4 py-3">
264
+ <GripVertical className="h-4 w-4 text-muted-foreground/40 cursor-grab" />
265
+ <button type="button" onClick={onToggle} className="p-0.5">
266
+ {expanded ? (
267
+ <ChevronDown className="h-4 w-4" />
268
+ ) : (
269
+ <ChevronRight className="h-4 w-4" />
270
+ )}
271
+ </button>
272
+ <h3 className="text-sm font-semibold flex-1">{category.name}</h3>
273
+ <div className="flex items-center gap-4 text-xs text-muted-foreground">
274
+ <span>{t('menu.manager.itemsCount', { count: items.length })}</span>
275
+ </div>
276
+ <button className="p-1 text-muted-foreground hover:text-foreground rounded">
277
+ <MoreHorizontal className="h-4 w-4" />
278
+ </button>
279
+ </div>
280
+
281
+ {expanded && (
282
+ <>
283
+ {/* Add product button */}
284
+ <div className="px-4 pb-2">
285
+ <button
286
+ type="button"
287
+ onClick={onAddProduct}
288
+ className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground border border-dashed rounded-md px-3 py-1.5 hover:bg-muted/30 transition-colors"
289
+ >
290
+ <Plus className="h-3 w-3" />
291
+ {t('menu.manager.addProduct')}
292
+ </button>
293
+ </div>
294
+
295
+ {/* Items table */}
296
+ {/* drift-allow: raw-table — interactive cells (drag handles, availability toggles, inline price/feature controls) don't fit DataTable */}
297
+ {items.length > 0 ? (
298
+ <div className="overflow-x-auto">
299
+ <table className="w-full">
300
+ <thead>
301
+ <tr className="border-t bg-muted/30 text-[10px] uppercase tracking-wider text-muted-foreground">
302
+ <th className="text-left py-2 pl-4 pr-2 font-medium">
303
+ {t('menu.manager.item')}
304
+ </th>
305
+ <th className="py-2 px-2 font-medium text-center w-16">
306
+ {t('menu.manager.pos')}
307
+ </th>
308
+ <th className="py-2 px-2 font-medium text-center w-16">
309
+ {t('menu.manager.delivery')}
310
+ </th>
311
+ <th className="py-2 px-2 font-medium text-center w-28">
312
+ {t('menu.manager.price')}
313
+ </th>
314
+ {config.modules.deliveryPricing && (
315
+ <th className="py-2 px-2 font-medium text-center w-28">
316
+ {t('menu.manager.deliveryPrice')}
317
+ </th>
318
+ )}
319
+ <th className="py-2 px-2 font-medium text-center w-16">
320
+ {t('menu.manager.featured')}
321
+ </th>
322
+ <th className="py-2 pr-4 w-8" />
323
+ </tr>
324
+ </thead>
325
+ <tbody className="divide-y">
326
+ {items.map((item) => (
327
+ <tr
328
+ key={item.id}
329
+ className="hover:bg-muted/20 transition-colors"
330
+ >
331
+ <td className="py-2.5 pl-4 pr-2">
332
+ <div className="flex items-center gap-3">
333
+ <GripVertical className="h-3.5 w-3.5 text-muted-foreground/30 cursor-grab shrink-0" />
334
+ <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted/50 overflow-hidden">
335
+ {item.imageUrl ? (
336
+ <img
337
+ src={item.imageUrl}
338
+ alt=""
339
+ className="h-full w-full object-cover"
340
+ />
341
+ ) : (
342
+ <UtensilsCrossed className="h-4 w-4 text-muted-foreground/40" />
343
+ )}
344
+ </div>
345
+ <div className="min-w-0">
346
+ <p className="text-sm font-medium truncate">
347
+ {item.name}
348
+ </p>
349
+ {item.description && (
350
+ <p className="text-xs text-muted-foreground truncate max-w-[280px]">
351
+ {item.description}
352
+ </p>
353
+ )}
354
+ </div>
355
+ </div>
356
+ </td>
357
+ <td className="py-2.5 px-2 text-center">
358
+ <Toggle
359
+ checked={item.availableForPos}
360
+ onChange={() =>
361
+ handleToggle(
362
+ item.id,
363
+ 'availableForPos',
364
+ item.availableForPos,
365
+ )
366
+ }
367
+ size="xs"
368
+ />
369
+ </td>
370
+ <td className="py-2.5 px-2 text-center">
371
+ <Toggle
372
+ checked={item.availableForDelivery}
373
+ onChange={() =>
374
+ handleToggle(
375
+ item.id,
376
+ 'availableForDelivery',
377
+ item.availableForDelivery,
378
+ )
379
+ }
380
+ size="xs"
381
+ />
382
+ </td>
383
+ <td className="py-2.5 px-2 text-center">
384
+ <span className="text-sm font-medium">
385
+ {formatCurrency(item.price, config.currency)}
386
+ </span>
387
+ </td>
388
+ {config.modules.deliveryPricing && (
389
+ <td className="py-2.5 px-2 text-center">
390
+ <span className="text-sm text-muted-foreground">
391
+ {item.deliveryPrice != null
392
+ ? formatCurrency(
393
+ item.deliveryPrice,
394
+ config.currency,
395
+ )
396
+ : '\u2014'}
397
+ </span>
398
+ </td>
399
+ )}
400
+ <td className="py-2.5 px-2 text-center">
401
+ <button
402
+ onClick={() =>
403
+ handleFeatureToggle(item.id, item.isFeatured)
404
+ }
405
+ className={`p-1 rounded ${
406
+ item.isFeatured
407
+ ? 'text-amber-500'
408
+ : 'text-muted-foreground/30 hover:text-muted-foreground'
409
+ }`}
410
+ >
411
+ <Star
412
+ className={`h-4 w-4 ${
413
+ item.isFeatured ? 'fill-current' : ''
414
+ }`}
415
+ />
416
+ </button>
417
+ </td>
418
+ <td className="py-2.5 pr-4">
419
+ <button className="p-1 text-muted-foreground hover:text-foreground rounded">
420
+ <MoreHorizontal className="h-3.5 w-3.5" />
421
+ </button>
422
+ </td>
423
+ </tr>
424
+ ))}
425
+ </tbody>
426
+ </table>
427
+ </div>
428
+ ) : (
429
+ <div className="border-t px-4 py-6 text-center text-xs text-muted-foreground">
430
+ {t('menu.manager.noItems')}
431
+ </div>
432
+ )}
433
+ </>
434
+ )}
435
+ </div>
436
+ )
437
+ }
438
+
439
+ // ---------------------------------------------------------------------------
440
+ // Main view
441
+ // ---------------------------------------------------------------------------
442
+
443
+ export function MenuManagerView() {
444
+ const t = useTranslation()
445
+ const config = useMenuConfig()
446
+
447
+ const categories = useMenuStore((s) => s.categories)
448
+ const categoriesLoading = useMenuStore((s) => s.categoriesLoading)
449
+ const menuItems = useMenuStore((s) => s.menuItems)
450
+ const menuItemsLoading = useMenuStore((s) => s.menuItemsLoading)
451
+ const fetchCategories = useMenuStore((s) => s.fetchCategories)
452
+ const fetchMenuItems = useMenuStore((s) => s.fetchMenuItems)
453
+
454
+ const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
455
+ new Set(),
456
+ )
457
+ const [search, setSearch] = useState('')
458
+ const [categoryFilter, setCategoryFilter] = useState('all')
459
+ const [showCategoryModal, setShowCategoryModal] = useState(false)
460
+ const [showItemModal, setShowItemModal] = useState(false)
461
+ const [itemModalCategory, setItemModalCategory] = useState<string | undefined>(undefined)
462
+
463
+ const openItemModal = (categoryId?: string) => {
464
+ setItemModalCategory(categoryId)
465
+ setShowItemModal(true)
466
+ }
467
+
468
+ // ---- Load data on mount ----
469
+ useEffect(() => {
470
+ fetchCategories()
471
+ fetchMenuItems({})
472
+ }, []) // eslint-disable-line react-hooks/exhaustive-deps
473
+
474
+ // ---- Expand all once categories load ----
475
+ useEffect(() => {
476
+ if (categories.length > 0 && expandedCategories.size === 0) {
477
+ setExpandedCategories(new Set(categories.map((c) => c.id)))
478
+ }
479
+ }, [categories]) // eslint-disable-line react-hooks/exhaustive-deps
480
+
481
+ // ---- Group items by categoryId ----
482
+ const grouped = useMemo(() => {
483
+ const map = new Map<string, MenuItem[]>()
484
+ for (const item of menuItems) {
485
+ const key = item.categoryId ?? '__uncategorized'
486
+ const list = map.get(key) ?? []
487
+ list.push(item)
488
+ map.set(key, list)
489
+ }
490
+ return map
491
+ }, [menuItems])
492
+
493
+ // ---- Client-side filtering ----
494
+ const filteredCategories = useMemo(() => {
495
+ const lowerSearch = search.toLowerCase()
496
+
497
+ return categories.filter((cat) => {
498
+ // Category dropdown filter
499
+ if (categoryFilter !== 'all' && cat.id !== categoryFilter) return false
500
+
501
+ // Search filter — match category items by name or description
502
+ if (lowerSearch) {
503
+ const items = grouped.get(cat.id) ?? []
504
+ return items.some(
505
+ (item) =>
506
+ item.name.toLowerCase().includes(lowerSearch) ||
507
+ (item.description?.toLowerCase().includes(lowerSearch) ?? false),
508
+ )
509
+ }
510
+ return true
511
+ })
512
+ }, [categories, categoryFilter, search, grouped])
513
+
514
+ // Get filtered items for a category (applying search within it)
515
+ const getFilteredItems = (categoryId: string): MenuItem[] => {
516
+ const items = grouped.get(categoryId) ?? []
517
+ if (!search) return items
518
+ const lowerSearch = search.toLowerCase()
519
+ return items.filter(
520
+ (item) =>
521
+ item.name.toLowerCase().includes(lowerSearch) ||
522
+ (item.description?.toLowerCase().includes(lowerSearch) ?? false),
523
+ )
524
+ }
525
+
526
+ const toggleCategory = (id: string) => {
527
+ setExpandedCategories((prev) => {
528
+ const next = new Set(prev)
529
+ next.has(id) ? next.delete(id) : next.add(id)
530
+ return next
531
+ })
532
+ }
533
+
534
+ const isLoading = categoriesLoading || menuItemsLoading
535
+
536
+ // ---- Loading skeleton ----
537
+ if (isLoading && categories.length === 0) {
538
+ return (
539
+ <div className="space-y-6">
540
+ <div className="flex items-center justify-between">
541
+ <div>
542
+ <div className="h-7 w-40 rounded bg-muted animate-pulse" />
543
+ <div className="h-4 w-64 rounded bg-muted animate-pulse mt-2" />
544
+ </div>
545
+ </div>
546
+ <div className="flex items-center gap-3">
547
+ <div className="h-9 w-40 rounded bg-muted animate-pulse" />
548
+ <div className="h-9 flex-1 max-w-sm rounded bg-muted animate-pulse" />
549
+ </div>
550
+ <div className="space-y-3">
551
+ <CategorySkeleton />
552
+ <CategorySkeleton />
553
+ <CategorySkeleton />
554
+ </div>
555
+ </div>
556
+ )
557
+ }
558
+
559
+ return (
560
+ <div className="space-y-6">
561
+ {/* Header */}
562
+ <div className="flex items-center justify-between">
563
+ <div>
564
+ <h1 className="text-2xl font-bold">{t('menu.manager.title')}</h1>
565
+ <p className="text-sm text-muted-foreground">
566
+ {t('menu.manager.subtitle')}
567
+ </p>
568
+ </div>
569
+ <div className="flex items-center gap-2">
570
+ {isLoading && (
571
+ <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
572
+ )}
573
+ <button
574
+ type="button"
575
+ onClick={() => setShowCategoryModal(true)}
576
+ className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm hover:bg-muted/50 transition-colors"
577
+ >
578
+ <Plus className="h-4 w-4" />
579
+ {t('menu.manager.addCategory')}
580
+ </button>
581
+ <button
582
+ type="button"
583
+ onClick={() => openItemModal(undefined)}
584
+ className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
585
+ >
586
+ <Plus className="h-4 w-4" />
587
+ {t('menu.manager.addProduct')}
588
+ </button>
589
+ </div>
590
+ </div>
591
+
592
+ {/* Filters */}
593
+ <div className="flex items-center gap-3">
594
+ <select
595
+ value={categoryFilter}
596
+ onChange={(e) => setCategoryFilter(e.target.value)}
597
+ className="h-9 rounded-md border border-input bg-background px-3 text-sm"
598
+ >
599
+ <option value="all">{t('menu.manager.allCategories')}</option>
600
+ {categories.map((cat) => (
601
+ <option key={cat.id} value={cat.id}>
602
+ {cat.name}
603
+ </option>
604
+ ))}
605
+ </select>
606
+ <div className="relative flex-1 max-w-sm">
607
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
608
+ <input
609
+ type="text"
610
+ value={search}
611
+ onChange={(e) => setSearch(e.target.value)}
612
+ placeholder={t('menu.manager.search')}
613
+ className="h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
614
+ />
615
+ </div>
616
+ </div>
617
+
618
+ {/* Categories */}
619
+ <div className="space-y-3">
620
+ {filteredCategories.map((category) => (
621
+ <CategorySection
622
+ key={category.id}
623
+ category={category}
624
+ items={getFilteredItems(category.id)}
625
+ expanded={expandedCategories.has(category.id)}
626
+ onToggle={() => toggleCategory(category.id)}
627
+ onAddProduct={() => openItemModal(category.id)}
628
+ />
629
+ ))}
630
+ </div>
631
+
632
+ {/* Empty state — no results after filtering */}
633
+ {filteredCategories.length === 0 && !isLoading && (
634
+ <div className="text-center py-12">
635
+ {categories.length === 0 ? (
636
+ <>
637
+ <UtensilsCrossed className="mx-auto h-10 w-10 text-muted-foreground/40 mb-3" />
638
+ <p className="text-sm font-medium">{t('menu.manager.noItems')}</p>
639
+ <p className="text-xs text-muted-foreground mt-1">
640
+ {t('menu.manager.noItemsDesc')}
641
+ </p>
642
+ <div className="mt-4 flex items-center justify-center gap-2">
643
+ <button
644
+ type="button"
645
+ onClick={() => setShowCategoryModal(true)}
646
+ className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm hover:bg-muted/50"
647
+ >
648
+ <Plus className="h-4 w-4" />
649
+ {t('menu.manager.addCategory')}
650
+ </button>
651
+ <button
652
+ type="button"
653
+ onClick={() => openItemModal(undefined)}
654
+ className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
655
+ >
656
+ <Plus className="h-4 w-4" />
657
+ {t('menu.manager.addProduct')}
658
+ </button>
659
+ </div>
660
+ </>
661
+ ) : (
662
+ <>
663
+ <Search className="mx-auto h-10 w-10 text-muted-foreground/40 mb-3" />
664
+ <p className="text-sm font-medium">
665
+ {t('menu.manager.noResults')}
666
+ </p>
667
+ <p className="text-xs text-muted-foreground mt-1">
668
+ {t('menu.manager.noResultsDesc')}
669
+ </p>
670
+ </>
671
+ )}
672
+ </div>
673
+ )}
674
+
675
+ {showCategoryModal && (
676
+ <CategoryModal open onClose={() => setShowCategoryModal(false)} />
677
+ )}
678
+ {showItemModal && (
679
+ <ItemModal
680
+ open
681
+ categories={categories}
682
+ defaultCategoryId={itemModalCategory}
683
+ onClose={() => setShowItemModal(false)}
684
+ />
685
+ )}
686
+ </div>
687
+ )
688
+ }