bestraw 1.0.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 (66) hide show
  1. package/index.mjs +436 -0
  2. package/package.json +17 -0
  3. package/templates/.env.example +51 -0
  4. package/templates/Caddyfile +21 -0
  5. package/templates/docker-compose.yml +80 -0
  6. package/templates/web/Dockerfile +19 -0
  7. package/templates/web/next-env.d.ts +6 -0
  8. package/templates/web/next.config.ts +10 -0
  9. package/templates/web/node_modules/.bin/next +17 -0
  10. package/templates/web/node_modules/.bin/tsc +17 -0
  11. package/templates/web/node_modules/.bin/tsserver +17 -0
  12. package/templates/web/package.json +28 -0
  13. package/templates/web/postcss.config.mjs +8 -0
  14. package/templates/web/public/images/.gitkeep +0 -0
  15. package/templates/web/src/app/[locale]/auth/page.tsx +222 -0
  16. package/templates/web/src/app/[locale]/blog/[slug]/page.tsx +104 -0
  17. package/templates/web/src/app/[locale]/blog/page.tsx +90 -0
  18. package/templates/web/src/app/[locale]/error.tsx +41 -0
  19. package/templates/web/src/app/[locale]/info/page.tsx +186 -0
  20. package/templates/web/src/app/[locale]/layout.tsx +86 -0
  21. package/templates/web/src/app/[locale]/loyalty/page.tsx +135 -0
  22. package/templates/web/src/app/[locale]/menu/page.tsx +69 -0
  23. package/templates/web/src/app/[locale]/order/cart/page.tsx +199 -0
  24. package/templates/web/src/app/[locale]/order/checkout/page.tsx +489 -0
  25. package/templates/web/src/app/[locale]/order/confirmation/[id]/page.tsx +159 -0
  26. package/templates/web/src/app/[locale]/order/page.tsx +207 -0
  27. package/templates/web/src/app/[locale]/page.tsx +119 -0
  28. package/templates/web/src/app/globals.css +11 -0
  29. package/templates/web/src/app/robots.ts +14 -0
  30. package/templates/web/src/app/sitemap.ts +56 -0
  31. package/templates/web/src/bestraw.config.ts +9 -0
  32. package/templates/web/src/components/auth/OtpForm.tsx +98 -0
  33. package/templates/web/src/components/blog/ArticleCard.tsx +67 -0
  34. package/templates/web/src/components/blog/ArticleContent.tsx +14 -0
  35. package/templates/web/src/components/cart/CartDrawer.tsx +152 -0
  36. package/templates/web/src/components/cart/CartItem.tsx +111 -0
  37. package/templates/web/src/components/checkout/StripePaymentForm.tsx +54 -0
  38. package/templates/web/src/components/layout/Footer.tsx +40 -0
  39. package/templates/web/src/components/layout/Header.tsx +240 -0
  40. package/templates/web/src/components/layout/LocaleSwitcher.tsx +34 -0
  41. package/templates/web/src/components/loyalty/PointsBalance.tsx +96 -0
  42. package/templates/web/src/components/loyalty/RewardCard.tsx +73 -0
  43. package/templates/web/src/components/loyalty/TransactionHistory.tsx +108 -0
  44. package/templates/web/src/components/menu/CategorySection.tsx +42 -0
  45. package/templates/web/src/components/menu/MealCard.tsx +55 -0
  46. package/templates/web/src/components/menu/MealDetailModal.tsx +355 -0
  47. package/templates/web/src/components/menu/MenuContent.tsx +216 -0
  48. package/templates/web/src/components/order/MealOrderCard.tsx +220 -0
  49. package/templates/web/src/components/order/OrderStatusTracker.tsx +138 -0
  50. package/templates/web/src/components/order/PaymentStatus.tsx +62 -0
  51. package/templates/web/src/components/ui/Button.tsx +40 -0
  52. package/templates/web/src/components/ui/ErrorAlert.tsx +15 -0
  53. package/templates/web/src/i18n/config.ts +3 -0
  54. package/templates/web/src/i18n/request.ts +13 -0
  55. package/templates/web/src/i18n/routing.ts +10 -0
  56. package/templates/web/src/lib/client.ts +5 -0
  57. package/templates/web/src/lib/errors.ts +31 -0
  58. package/templates/web/src/lib/features.ts +10 -0
  59. package/templates/web/src/lib/hooks/useCustomerClient.ts +28 -0
  60. package/templates/web/src/lib/hooks/useMenu.ts +46 -0
  61. package/templates/web/src/messages/en.json +283 -0
  62. package/templates/web/src/messages/fr.json +283 -0
  63. package/templates/web/src/middleware.ts +8 -0
  64. package/templates/web/src/providers/CartProvider.tsx +162 -0
  65. package/templates/web/src/providers/StripeProvider.tsx +21 -0
  66. package/templates/web/tsconfig.json +27 -0
@@ -0,0 +1,355 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import { useTranslations } from 'next-intl';
5
+ import type { Meal } from 'bestraw-sdk';
6
+ import type { CartItem } from '@/providers/CartProvider';
7
+
8
+ const ALLERGEN_LABELS: Record<number, string> = {
9
+ 1: 'gluten',
10
+ 2: 'crustaceans',
11
+ 3: 'eggs',
12
+ 4: 'fish',
13
+ 5: 'peanuts',
14
+ 6: 'soy',
15
+ 7: 'dairy',
16
+ 8: 'nuts',
17
+ 9: 'celery',
18
+ 10: 'mustard',
19
+ 11: 'sesame',
20
+ 12: 'sulphites',
21
+ 13: 'lupin',
22
+ 14: 'molluscs',
23
+ };
24
+
25
+ interface MealDetailModalProps {
26
+ meal: Meal;
27
+ apiUrl: string;
28
+ onClose: () => void;
29
+ onAddToCart?: (item: CartItem) => void;
30
+ }
31
+
32
+ function formatPrice(price: number): string {
33
+ return price.toFixed(2).replace('.', ',') + ' \u20AC';
34
+ }
35
+
36
+ export function MealDetailModal({ meal, apiUrl, onClose, onAddToCart }: MealDetailModalProps) {
37
+ const t = useTranslations('mealDetail');
38
+ const tOrder = useTranslations('order');
39
+ const [imageIndex, setImageIndex] = useState(0);
40
+ const [quantity, setQuantity] = useState(1);
41
+ const [selectedSauces, setSelectedSauces] = useState<string[]>([]);
42
+ const [selectedSides, setSelectedSides] = useState<string[]>([]);
43
+ const [specialInstructions, setSpecialInstructions] = useState('');
44
+
45
+ const pictures = meal.pictures ?? [];
46
+ const hasSauces = meal.sauces && meal.sauces.length > 0;
47
+ const hasSides = meal.sides && meal.sides.length > 0;
48
+ const hasFields = meal.fields && meal.fields.length > 0;
49
+ const hasAllergens = meal.allergens && meal.allergens.length > 0;
50
+
51
+ // Lock body scroll
52
+ useEffect(() => {
53
+ document.body.style.overflow = 'hidden';
54
+ return () => {
55
+ document.body.style.overflow = '';
56
+ };
57
+ }, []);
58
+
59
+ // Close on Escape
60
+ useEffect(() => {
61
+ function handleKey(e: KeyboardEvent) {
62
+ if (e.key === 'Escape') onClose();
63
+ }
64
+ window.addEventListener('keydown', handleKey);
65
+ return () => window.removeEventListener('keydown', handleKey);
66
+ }, [onClose]);
67
+
68
+ function handleAddToCart() {
69
+ if (!onAddToCart) return;
70
+ onAddToCart({
71
+ mealDocumentId: meal.documentId,
72
+ mealName: meal.name,
73
+ unitPrice: meal.price,
74
+ quantity,
75
+ selectedSauces,
76
+ selectedSides,
77
+ specialInstructions: specialInstructions.trim(),
78
+ picture: pictures[0] ? `${apiUrl}${pictures[0].url}` : undefined,
79
+ });
80
+ setQuantity(1);
81
+ setSelectedSauces([]);
82
+ setSelectedSides([]);
83
+ setSpecialInstructions('');
84
+ onClose();
85
+ }
86
+
87
+ return (
88
+ <div
89
+ className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/50"
90
+ onClick={(e) => {
91
+ if (e.target === e.currentTarget) onClose();
92
+ }}
93
+ >
94
+ <div className="relative bg-white rounded-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto shadow-2xl">
95
+ {/* Close button */}
96
+ <button
97
+ type="button"
98
+ onClick={onClose}
99
+ className="absolute top-3 right-3 z-10 w-8 h-8 flex items-center justify-center rounded-full bg-white/80 hover:bg-white text-gray-600 hover:text-gray-900 transition-colors shadow"
100
+ >
101
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
102
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
103
+ </svg>
104
+ </button>
105
+
106
+ {/* Image gallery */}
107
+ {pictures.length > 0 && (
108
+ <div className="relative">
109
+ <div className="aspect-[16/9] overflow-hidden rounded-t-xl bg-gray-100">
110
+ <img
111
+ src={`${apiUrl}${pictures[imageIndex].url}`}
112
+ alt={pictures[imageIndex].alternativeText || meal.name}
113
+ className="w-full h-full object-cover"
114
+ />
115
+ </div>
116
+ {pictures.length > 1 && (
117
+ <div className="flex gap-2 p-3 justify-center">
118
+ {pictures.map((pic, i) => (
119
+ <button
120
+ key={pic.id}
121
+ type="button"
122
+ onClick={() => setImageIndex(i)}
123
+ className={`w-12 h-12 rounded-md overflow-hidden border-2 transition-colors ${
124
+ i === imageIndex
125
+ ? 'border-[var(--color-accent)]'
126
+ : 'border-transparent hover:border-gray-300'
127
+ }`}
128
+ >
129
+ <img
130
+ src={`${apiUrl}${pic.formats?.thumbnail?.url || pic.url}`}
131
+ alt=""
132
+ className="w-full h-full object-cover"
133
+ />
134
+ </button>
135
+ ))}
136
+ </div>
137
+ )}
138
+ </div>
139
+ )}
140
+
141
+ <div className="p-6">
142
+ {/* Header */}
143
+ <div className="flex items-start justify-between gap-4 mb-4">
144
+ <h2 className="text-2xl font-bold text-[var(--color-primary)]">
145
+ {meal.name}
146
+ </h2>
147
+ <span className="text-xl font-semibold text-[var(--color-accent)] whitespace-nowrap">
148
+ {formatPrice(meal.price)}
149
+ </span>
150
+ </div>
151
+
152
+ {/* Description */}
153
+ {meal.description && (
154
+ <p className="text-gray-600 mb-4">{meal.description}</p>
155
+ )}
156
+
157
+ {/* Fields (metadata) */}
158
+ {hasFields && (
159
+ <div className="mb-4">
160
+ <div className="grid grid-cols-2 gap-2">
161
+ {meal.fields!.map((field) => (
162
+ <div
163
+ key={field.name}
164
+ className="text-sm bg-gray-50 rounded-md px-3 py-2"
165
+ >
166
+ <span className="text-gray-500">{field.name}</span>
167
+ <span className="ml-2 font-medium text-gray-800">{field.value}</span>
168
+ </div>
169
+ ))}
170
+ </div>
171
+ </div>
172
+ )}
173
+
174
+ {/* Allergens */}
175
+ {hasAllergens && (
176
+ <div className="mb-4">
177
+ <h3 className="text-sm font-semibold text-gray-700 mb-2">
178
+ {t('allergens')}
179
+ </h3>
180
+ <div className="flex flex-wrap gap-2">
181
+ {meal.allergens!.map((id) => (
182
+ <span
183
+ key={id}
184
+ className="text-xs px-2 py-1 rounded-full bg-amber-100 text-amber-800 font-medium"
185
+ >
186
+ {t(`allergen.${ALLERGEN_LABELS[id] || id}`)}
187
+ </span>
188
+ ))}
189
+ </div>
190
+ </div>
191
+ )}
192
+
193
+ {/* Sauces (info) */}
194
+ {hasSauces && (
195
+ <div className="mb-4">
196
+ <h3 className="text-sm font-semibold text-gray-700 mb-2">
197
+ {tOrder('sauces')}
198
+ </h3>
199
+ <div className="grid grid-cols-2 gap-2">
200
+ {meal.sauces!.map((s) => (
201
+ <div
202
+ key={s.name}
203
+ className="text-sm bg-gray-50 rounded-md px-3 py-2"
204
+ >
205
+ <span className="text-gray-500">{s.name}</span>
206
+ {s.description && (
207
+ <span className="ml-2 font-medium text-gray-800">{s.description}</span>
208
+ )}
209
+ </div>
210
+ ))}
211
+ </div>
212
+ </div>
213
+ )}
214
+
215
+ {/* Sides (info) */}
216
+ {hasSides && (
217
+ <div className="mb-4">
218
+ <h3 className="text-sm font-semibold text-gray-700 mb-2">
219
+ {tOrder('sides')}
220
+ </h3>
221
+ <div className="grid grid-cols-2 gap-2">
222
+ {meal.sides!.map((s) => (
223
+ <div
224
+ key={s.name}
225
+ className="text-sm bg-gray-50 rounded-md px-3 py-2"
226
+ >
227
+ <span className="text-gray-500">{s.name}</span>
228
+ {s.description && (
229
+ <span className="ml-2 font-medium text-gray-800">{s.description}</span>
230
+ )}
231
+ </div>
232
+ ))}
233
+ </div>
234
+ </div>
235
+ )}
236
+
237
+ {/* Availability */}
238
+ {!meal.available && (
239
+ <div className="mb-4 px-3 py-2 rounded-md bg-red-50 text-red-600 text-sm font-medium">
240
+ {t('unavailable')}
241
+ </div>
242
+ )}
243
+
244
+ {/* Ordering section */}
245
+ {onAddToCart && meal.available && (
246
+ <div className="border-t border-gray-100 pt-4 mt-4">
247
+ {/* Sauce/Side selection (checkboxes) */}
248
+ {hasSauces && (
249
+ <div className="mb-3">
250
+ <p className="text-sm font-semibold text-gray-700 mb-2">
251
+ {tOrder('sauces')}
252
+ </p>
253
+ <div className="space-y-1.5">
254
+ {meal.sauces!.map((sauce) => (
255
+ <label
256
+ key={sauce.name}
257
+ className="flex items-center gap-2 text-sm text-gray-600 cursor-pointer"
258
+ >
259
+ <input
260
+ type="checkbox"
261
+ checked={selectedSauces.includes(sauce.name)}
262
+ onChange={() =>
263
+ setSelectedSauces((prev) =>
264
+ prev.includes(sauce.name)
265
+ ? prev.filter((s) => s !== sauce.name)
266
+ : [...prev, sauce.name]
267
+ )
268
+ }
269
+ className="rounded border-gray-300 text-[var(--color-accent)] focus:ring-[var(--color-accent)]"
270
+ />
271
+ {sauce.name}
272
+ </label>
273
+ ))}
274
+ </div>
275
+ </div>
276
+ )}
277
+
278
+ {hasSides && (
279
+ <div className="mb-3">
280
+ <p className="text-sm font-semibold text-gray-700 mb-2">
281
+ {tOrder('sides')}
282
+ </p>
283
+ <div className="space-y-1.5">
284
+ {meal.sides!.map((side) => (
285
+ <label
286
+ key={side.name}
287
+ className="flex items-center gap-2 text-sm text-gray-600 cursor-pointer"
288
+ >
289
+ <input
290
+ type="checkbox"
291
+ checked={selectedSides.includes(side.name)}
292
+ onChange={() =>
293
+ setSelectedSides((prev) =>
294
+ prev.includes(side.name)
295
+ ? prev.filter((s) => s !== side.name)
296
+ : [...prev, side.name]
297
+ )
298
+ }
299
+ className="rounded border-gray-300 text-[var(--color-accent)] focus:ring-[var(--color-accent)]"
300
+ />
301
+ {side.name}
302
+ </label>
303
+ ))}
304
+ </div>
305
+ </div>
306
+ )}
307
+
308
+ {/* Special instructions */}
309
+ <div className="mb-4">
310
+ <input
311
+ type="text"
312
+ placeholder={tOrder('specialInstructions')}
313
+ value={specialInstructions}
314
+ onChange={(e) => setSpecialInstructions(e.target.value)}
315
+ className="w-full text-sm px-3 py-2 border border-gray-200 rounded-md focus:outline-none focus:ring-1 focus:ring-[var(--color-accent)] focus:border-[var(--color-accent)]"
316
+ />
317
+ </div>
318
+
319
+ {/* Quantity + Add to cart */}
320
+ <div className="flex items-center gap-3">
321
+ <div className="flex items-center border border-gray-200 rounded-md">
322
+ <button
323
+ type="button"
324
+ onClick={() => setQuantity(Math.max(1, quantity - 1))}
325
+ className="px-3 py-2 text-gray-500 hover:text-gray-700"
326
+ >
327
+ -
328
+ </button>
329
+ <span className="px-4 py-2 text-sm font-medium min-w-[2.5rem] text-center">
330
+ {quantity}
331
+ </span>
332
+ <button
333
+ type="button"
334
+ onClick={() => setQuantity(quantity + 1)}
335
+ className="px-3 py-2 text-gray-500 hover:text-gray-700"
336
+ >
337
+ +
338
+ </button>
339
+ </div>
340
+
341
+ <button
342
+ type="button"
343
+ onClick={handleAddToCart}
344
+ className="flex-1 px-4 py-2.5 rounded-md bg-[var(--color-accent)] text-white font-medium hover:opacity-90 transition-opacity"
345
+ >
346
+ {tOrder('addToCart')} — {formatPrice(meal.price * quantity)}
347
+ </button>
348
+ </div>
349
+ </div>
350
+ )}
351
+ </div>
352
+ </div>
353
+ </div>
354
+ );
355
+ }
@@ -0,0 +1,216 @@
1
+ 'use client';
2
+
3
+ import { useState, useMemo, useEffect } from 'react';
4
+ import { useTranslations } from 'next-intl';
5
+ import { BestrawClient } from 'bestraw-sdk';
6
+ import type { Category, Meal } from 'bestraw-sdk';
7
+ import { MealCard } from './MealCard';
8
+ import { MealOrderCard } from '@/components/order/MealOrderCard';
9
+ import { MealDetailModal } from './MealDetailModal';
10
+ import { useCart } from '@/providers/CartProvider';
11
+ import { hasOrdering } from '@/lib/features';
12
+ import type { CartItem } from '@/providers/CartProvider';
13
+
14
+ interface MenuContentProps {
15
+ categories: Category[];
16
+ apiUrl: string;
17
+ }
18
+
19
+ function normalize(str: string): string {
20
+ return str
21
+ .toLowerCase()
22
+ .normalize('NFD')
23
+ .replace(/[\u0300-\u036f]/g, '');
24
+ }
25
+
26
+ /**
27
+ * When ordering is disabled, CartProvider is not mounted.
28
+ * This wrapper safely extracts addItem only when CartProvider exists.
29
+ */
30
+ function WithCart({ children }: { children: (addItem: (item: CartItem) => void) => React.ReactNode }) {
31
+ const { addItem } = useCart();
32
+ return <>{children(addItem)}</>;
33
+ }
34
+
35
+ export function MenuContent({ categories, apiUrl }: MenuContentProps) {
36
+ if (hasOrdering) {
37
+ return (
38
+ <WithCart>
39
+ {(addItem) => (
40
+ <MenuContentInner
41
+ categories={categories}
42
+ apiUrl={apiUrl}
43
+ addItem={addItem}
44
+ />
45
+ )}
46
+ </WithCart>
47
+ );
48
+ }
49
+ return <MenuContentInner categories={categories} apiUrl={apiUrl} />;
50
+ }
51
+
52
+ function MenuContentInner({
53
+ categories,
54
+ apiUrl,
55
+ addItem,
56
+ }: MenuContentProps & { addItem?: (item: CartItem) => void }) {
57
+ const t = useTranslations('menu');
58
+ const [searchQuery, setSearchQuery] = useState('');
59
+ const [selectedMeal, setSelectedMeal] = useState<Meal | null>(null);
60
+ const [orderingActive, setOrderingActive] = useState(true);
61
+
62
+ const [restaurantOpen, setRestaurantOpen] = useState(true);
63
+ const [closureReason, setClosureReason] = useState<string | undefined>();
64
+
65
+ // Check backend ordering settings and restaurant status at runtime
66
+ useEffect(() => {
67
+ if (!hasOrdering) return;
68
+ const client = new BestrawClient({
69
+ baseUrl: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:1338',
70
+ });
71
+ client.ordering.getPublicOrderSettings()
72
+ .then((settings) => {
73
+ setOrderingActive(settings.orderingEnabled);
74
+ })
75
+ .catch(() => {});
76
+ client.restaurant.getStatus()
77
+ .then((status) => {
78
+ setRestaurantOpen(status.isOpen);
79
+ if (!status.isOpen) setClosureReason(status.closureReason);
80
+ })
81
+ .catch(() => {});
82
+ }, []);
83
+
84
+ const canOrder = hasOrdering && orderingActive && restaurantOpen;
85
+
86
+ const filteredCategories = useMemo(() => {
87
+ const sorted = [...categories].sort((a, b) => a.sortOrder - b.sortOrder);
88
+ if (!searchQuery.trim()) return sorted;
89
+
90
+ const q = normalize(searchQuery.trim());
91
+ return sorted
92
+ .map((cat) => ({
93
+ ...cat,
94
+ meals: (cat.meals ?? []).filter(
95
+ (meal) =>
96
+ meal.show &&
97
+ (normalize(meal.name).includes(q) ||
98
+ normalize(meal.description || '').includes(q))
99
+ ),
100
+ }))
101
+ .filter((cat) => cat.meals && cat.meals.length > 0);
102
+ }, [categories, searchQuery]);
103
+
104
+ return (
105
+ <>
106
+ {/* Closed banner */}
107
+ {hasOrdering && !canOrder && (
108
+ <div className="mb-6 p-4 bg-amber-50 border border-amber-200 rounded-lg flex items-center gap-3">
109
+ <svg className="w-5 h-5 text-amber-600 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
110
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
111
+ </svg>
112
+ <p className="text-amber-800 text-sm font-medium">
113
+ {closureReason
114
+ ? `${t('closedReason', { reason: closureReason })}`
115
+ : t('closed')}
116
+ </p>
117
+ </div>
118
+ )}
119
+
120
+ {/* Search bar */}
121
+ <div className="mb-8">
122
+ <div className="relative max-w-md">
123
+ <svg
124
+ className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400"
125
+ fill="none"
126
+ stroke="currentColor"
127
+ viewBox="0 0 24 24"
128
+ >
129
+ <path
130
+ strokeLinecap="round"
131
+ strokeLinejoin="round"
132
+ strokeWidth={2}
133
+ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
134
+ />
135
+ </svg>
136
+ <input
137
+ type="text"
138
+ value={searchQuery}
139
+ onChange={(e) => setSearchQuery(e.target.value)}
140
+ placeholder={t('searchPlaceholder')}
141
+ className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-accent)] focus:border-transparent"
142
+ />
143
+ {searchQuery && (
144
+ <button
145
+ type="button"
146
+ onClick={() => setSearchQuery('')}
147
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
148
+ >
149
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
150
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
151
+ </svg>
152
+ </button>
153
+ )}
154
+ </div>
155
+ </div>
156
+
157
+ {/* Categories & meals */}
158
+ {filteredCategories.length === 0 ? (
159
+ <p className="text-gray-500">
160
+ {searchQuery ? t('noResults') : t('empty')}
161
+ </p>
162
+ ) : (
163
+ filteredCategories.map((category) => {
164
+ const visibleMeals = (category.meals ?? [])
165
+ .filter((meal) => meal.show)
166
+ .sort((a, b) => a.sortOrder - b.sortOrder);
167
+
168
+ if (visibleMeals.length === 0) return null;
169
+
170
+ return (
171
+ <section key={category.documentId} className="mb-12">
172
+ <div className="mb-6">
173
+ <h2 className="text-2xl font-bold text-[var(--color-primary)]">
174
+ {category.name}
175
+ </h2>
176
+ {category.description && (
177
+ <p className="mt-1 text-gray-600">{category.description}</p>
178
+ )}
179
+ </div>
180
+
181
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
182
+ {visibleMeals.map((meal) =>
183
+ canOrder ? (
184
+ <MealOrderCard
185
+ key={meal.documentId}
186
+ meal={meal}
187
+ apiUrl={apiUrl}
188
+ onShowDetail={() => setSelectedMeal(meal)}
189
+ />
190
+ ) : (
191
+ <MealCard
192
+ key={meal.documentId}
193
+ meal={meal}
194
+ apiUrl={apiUrl}
195
+ onClick={() => setSelectedMeal(meal)}
196
+ />
197
+ )
198
+ )}
199
+ </div>
200
+ </section>
201
+ );
202
+ })
203
+ )}
204
+
205
+ {/* Detail modal */}
206
+ {selectedMeal && (
207
+ <MealDetailModal
208
+ meal={selectedMeal}
209
+ apiUrl={apiUrl}
210
+ onClose={() => setSelectedMeal(null)}
211
+ onAddToCart={canOrder ? addItem : undefined}
212
+ />
213
+ )}
214
+ </>
215
+ );
216
+ }