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.
- package/index.mjs +436 -0
- package/package.json +17 -0
- package/templates/.env.example +51 -0
- package/templates/Caddyfile +21 -0
- package/templates/docker-compose.yml +80 -0
- package/templates/web/Dockerfile +19 -0
- package/templates/web/next-env.d.ts +6 -0
- package/templates/web/next.config.ts +10 -0
- package/templates/web/node_modules/.bin/next +17 -0
- package/templates/web/node_modules/.bin/tsc +17 -0
- package/templates/web/node_modules/.bin/tsserver +17 -0
- package/templates/web/package.json +28 -0
- package/templates/web/postcss.config.mjs +8 -0
- package/templates/web/public/images/.gitkeep +0 -0
- package/templates/web/src/app/[locale]/auth/page.tsx +222 -0
- package/templates/web/src/app/[locale]/blog/[slug]/page.tsx +104 -0
- package/templates/web/src/app/[locale]/blog/page.tsx +90 -0
- package/templates/web/src/app/[locale]/error.tsx +41 -0
- package/templates/web/src/app/[locale]/info/page.tsx +186 -0
- package/templates/web/src/app/[locale]/layout.tsx +86 -0
- package/templates/web/src/app/[locale]/loyalty/page.tsx +135 -0
- package/templates/web/src/app/[locale]/menu/page.tsx +69 -0
- package/templates/web/src/app/[locale]/order/cart/page.tsx +199 -0
- package/templates/web/src/app/[locale]/order/checkout/page.tsx +489 -0
- package/templates/web/src/app/[locale]/order/confirmation/[id]/page.tsx +159 -0
- package/templates/web/src/app/[locale]/order/page.tsx +207 -0
- package/templates/web/src/app/[locale]/page.tsx +119 -0
- package/templates/web/src/app/globals.css +11 -0
- package/templates/web/src/app/robots.ts +14 -0
- package/templates/web/src/app/sitemap.ts +56 -0
- package/templates/web/src/bestraw.config.ts +9 -0
- package/templates/web/src/components/auth/OtpForm.tsx +98 -0
- package/templates/web/src/components/blog/ArticleCard.tsx +67 -0
- package/templates/web/src/components/blog/ArticleContent.tsx +14 -0
- package/templates/web/src/components/cart/CartDrawer.tsx +152 -0
- package/templates/web/src/components/cart/CartItem.tsx +111 -0
- package/templates/web/src/components/checkout/StripePaymentForm.tsx +54 -0
- package/templates/web/src/components/layout/Footer.tsx +40 -0
- package/templates/web/src/components/layout/Header.tsx +240 -0
- package/templates/web/src/components/layout/LocaleSwitcher.tsx +34 -0
- package/templates/web/src/components/loyalty/PointsBalance.tsx +96 -0
- package/templates/web/src/components/loyalty/RewardCard.tsx +73 -0
- package/templates/web/src/components/loyalty/TransactionHistory.tsx +108 -0
- package/templates/web/src/components/menu/CategorySection.tsx +42 -0
- package/templates/web/src/components/menu/MealCard.tsx +55 -0
- package/templates/web/src/components/menu/MealDetailModal.tsx +355 -0
- package/templates/web/src/components/menu/MenuContent.tsx +216 -0
- package/templates/web/src/components/order/MealOrderCard.tsx +220 -0
- package/templates/web/src/components/order/OrderStatusTracker.tsx +138 -0
- package/templates/web/src/components/order/PaymentStatus.tsx +62 -0
- package/templates/web/src/components/ui/Button.tsx +40 -0
- package/templates/web/src/components/ui/ErrorAlert.tsx +15 -0
- package/templates/web/src/i18n/config.ts +3 -0
- package/templates/web/src/i18n/request.ts +13 -0
- package/templates/web/src/i18n/routing.ts +10 -0
- package/templates/web/src/lib/client.ts +5 -0
- package/templates/web/src/lib/errors.ts +31 -0
- package/templates/web/src/lib/features.ts +10 -0
- package/templates/web/src/lib/hooks/useCustomerClient.ts +28 -0
- package/templates/web/src/lib/hooks/useMenu.ts +46 -0
- package/templates/web/src/messages/en.json +283 -0
- package/templates/web/src/messages/fr.json +283 -0
- package/templates/web/src/middleware.ts +8 -0
- package/templates/web/src/providers/CartProvider.tsx +162 -0
- package/templates/web/src/providers/StripeProvider.tsx +21 -0
- 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
|
+
}
|