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,489 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+ import { useTranslations } from 'next-intl';
5
+ import { useLocale } from 'next-intl';
6
+ import { notFound } from 'next/navigation';
7
+ import { useCart } from '@/providers/CartProvider';
8
+ import { hasOrdering, hasPayments, hasLoyalty } from '@/lib/features';
9
+ import { StripeProvider } from '@/providers/StripeProvider';
10
+ import { StripePaymentForm } from '@/components/checkout/StripePaymentForm';
11
+ import { ErrorAlert } from '@/components/ui/ErrorAlert';
12
+ import { getErrorMessage } from '@/lib/errors';
13
+ import { BestrawClient, BestrawError } from 'bestraw-sdk';
14
+ import type { CreateOrderInput, OrderType, LoyaltyReward, LoyaltyAccount, PublicOrderSettings, RestaurantStatus } from 'bestraw-sdk';
15
+
16
+ function formatPrice(price: number): string {
17
+ return price.toFixed(2).replace('.', ',') + ' \u20AC';
18
+ }
19
+
20
+ function calculateDiscountPreview(
21
+ reward: LoyaltyReward,
22
+ cartItems: Array<{ mealDocumentId: string; unitPrice: number; quantity: number }>,
23
+ subtotal: number,
24
+ ): number {
25
+ switch (reward.type) {
26
+ case 'free-item': {
27
+ const match = cartItems.find((i) => i.mealDocumentId === reward.mealDocumentId);
28
+ return match ? match.unitPrice : 0;
29
+ }
30
+ case 'discount-percent':
31
+ return Math.round(subtotal * ((reward.value ?? 0) / 100) * 100) / 100;
32
+ case 'discount-fixed':
33
+ return Math.min(reward.value ?? 0, subtotal);
34
+ default:
35
+ return 0;
36
+ }
37
+ }
38
+
39
+ export default function CheckoutPage() {
40
+ if (!hasOrdering) notFound();
41
+ const t = useTranslations('checkout');
42
+ const tLoyalty = useTranslations('loyalty');
43
+ const locale = useLocale();
44
+ const { items, total, clearCart } = useCart();
45
+ const [orderType, setOrderType] = useState<OrderType>('takeaway');
46
+ const [tableNumber, setTableNumber] = useState('');
47
+ const [notes, setNotes] = useState('');
48
+ const [loading, setLoading] = useState(false);
49
+ const [error, setError] = useState('');
50
+
51
+ // Payment step state
52
+ const [clientSecret, setClientSecret] = useState<string | null>(null);
53
+ const [orderId, setOrderId] = useState<string | null>(null);
54
+ const [orderItems, setOrderItems] = useState<typeof items>([]);
55
+ const [orderTotal, setOrderTotal] = useState(0);
56
+
57
+ // Loyalty reward state
58
+ const [loyaltyAccount, setLoyaltyAccount] = useState<LoyaltyAccount | null>(null);
59
+ const [allRewards, setAllRewards] = useState<Array<LoyaltyReward & { programIndex: number }>>([]);
60
+ const [selectedProgramIndex, setSelectedProgramIndex] = useState<number | null>(null);
61
+
62
+ // Order settings state
63
+ const [orderSettings, setOrderSettings] = useState<PublicOrderSettings | null>(null);
64
+ const [restaurantStatus, setRestaurantStatus] = useState<RestaurantStatus | null>(null);
65
+
66
+ const apiUrl =
67
+ process.env.NEXT_PUBLIC_API_URL || 'http://localhost:1338';
68
+
69
+ const tErrors = useTranslations('errors');
70
+
71
+ // Fetch order settings + loyalty data on mount
72
+ useEffect(() => {
73
+ const client = new BestrawClient({ baseUrl: apiUrl });
74
+
75
+ // Fetch public order settings (no auth needed)
76
+ client.ordering.getPublicOrderSettings()
77
+ .then((settings) => {
78
+ setOrderSettings(settings);
79
+ // Auto-select the only available order type
80
+ if (settings.takeawayEnabled && !settings.dineInEnabled) setOrderType('takeaway');
81
+ if (settings.dineInEnabled && !settings.takeawayEnabled) setOrderType('dine-in');
82
+ })
83
+ .catch(() => {
84
+ // If settings fetch fails, allow both types (graceful fallback)
85
+ setOrderSettings({ orderingEnabled: true, takeawayEnabled: true, dineInEnabled: true, estimatedPrepMinutes: 20 });
86
+ });
87
+
88
+ // Fetch restaurant open/closed status
89
+ client.restaurant.getStatus()
90
+ .then((status) => setRestaurantStatus(status))
91
+ .catch(() => {});
92
+
93
+ // Fetch loyalty data (needs auth)
94
+ if (hasLoyalty) {
95
+ const token = localStorage.getItem('bestraw-customer-token');
96
+ if (token) {
97
+ const authClient = new BestrawClient({
98
+ baseUrl: apiUrl,
99
+ getAuthToken: () => token,
100
+ });
101
+ Promise.all([
102
+ authClient.loyalty.getMyAccount(),
103
+ authClient.loyalty.getProgram(),
104
+ ])
105
+ .then(([account, program]) => {
106
+ setLoyaltyAccount(account);
107
+ if (program.enabled && program.rewards) {
108
+ setAllRewards(
109
+ program.rewards
110
+ .map((r, i) => ({ ...r, programIndex: i }))
111
+ .filter((r) => r.active && account.points >= r.pointsCost),
112
+ );
113
+ }
114
+ })
115
+ .catch(() => {
116
+ // Loyalty not available, silently ignore
117
+ });
118
+ }
119
+ }
120
+ }, [apiUrl]);
121
+
122
+ const selectedReward = selectedProgramIndex !== null
123
+ ? allRewards.find((r) => r.programIndex === selectedProgramIndex) ?? null
124
+ : null;
125
+
126
+ const currentDiscount = selectedReward
127
+ ? calculateDiscountPreview(selectedReward, items, total)
128
+ : 0;
129
+
130
+ const displayTotal = Math.max(0, total - currentDiscount);
131
+
132
+ async function handleCreateOrder(e: React.FormEvent) {
133
+ e.preventDefault();
134
+ setLoading(true);
135
+ setError('');
136
+
137
+ const token = localStorage.getItem('bestraw-customer-token');
138
+ if (!token) {
139
+ setError(tErrors('AUTH_REQUIRED'));
140
+ setLoading(false);
141
+ return;
142
+ }
143
+
144
+ try {
145
+ const orderData: CreateOrderInput = {
146
+ items: items.map((item) => ({
147
+ mealDocumentId: item.mealDocumentId,
148
+ quantity: item.quantity,
149
+ selectedSauces: item.selectedSauces,
150
+ selectedSides: item.selectedSides,
151
+ specialInstructions: item.specialInstructions,
152
+ })),
153
+ type: orderType,
154
+ tableNumber: orderType === 'dine-in' ? tableNumber : undefined,
155
+ notes: notes.trim() || undefined,
156
+ rewardIndex: selectedReward ? selectedReward.programIndex : undefined,
157
+ };
158
+
159
+ // Create order
160
+ const orderResponse = await fetch(`${apiUrl}/api/ordering/orders`, {
161
+ method: 'POST',
162
+ headers: {
163
+ 'Content-Type': 'application/json',
164
+ Authorization: `Bearer ${token}`,
165
+ },
166
+ body: JSON.stringify(orderData),
167
+ });
168
+
169
+ if (!orderResponse.ok) {
170
+ const errorBody = await orderResponse.json().catch(() => null);
171
+ throw new BestrawError(orderResponse.status, errorBody?.error?.message || 'Order failed', errorBody);
172
+ }
173
+
174
+ const orderJson = await orderResponse.json();
175
+ const order = orderJson.data ?? orderJson;
176
+
177
+ if (!hasPayments) {
178
+ clearCart();
179
+ window.location.href = `/${locale}/order/confirmation/${order.documentId}`;
180
+ return;
181
+ }
182
+
183
+ // Create payment intent
184
+ const paymentResponse = await fetch(
185
+ `${apiUrl}/api/payment/payments/create-intent`,
186
+ {
187
+ method: 'POST',
188
+ headers: {
189
+ 'Content-Type': 'application/json',
190
+ Authorization: `Bearer ${token}`,
191
+ },
192
+ body: JSON.stringify({ orderDocumentId: order.documentId }),
193
+ }
194
+ );
195
+
196
+ if (!paymentResponse.ok) {
197
+ const errorBody = await paymentResponse.json().catch(() => null);
198
+ throw new BestrawError(paymentResponse.status, errorBody?.error?.message || 'Payment failed', errorBody);
199
+ }
200
+
201
+ const paymentData = await paymentResponse.json();
202
+ setOrderItems([...items]);
203
+ setOrderTotal(order.total ?? total);
204
+ setClientSecret(paymentData.data.clientSecret);
205
+ setOrderId(order.documentId);
206
+ clearCart();
207
+ } catch (err: unknown) {
208
+ setError(getErrorMessage(err, tErrors));
209
+ } finally {
210
+ setLoading(false);
211
+ }
212
+ }
213
+
214
+ const orderingDisabled = orderSettings !== null && (
215
+ !orderSettings.orderingEnabled || (!orderSettings.takeawayEnabled && !orderSettings.dineInEnabled)
216
+ );
217
+ const restaurantClosed = restaurantStatus !== null && !restaurantStatus.isOpen;
218
+
219
+ if (!clientSecret && items.length === 0) {
220
+ return (
221
+ <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
222
+ <h1 className="text-3xl sm:text-4xl font-bold text-[var(--color-primary)]">
223
+ {t('title')}
224
+ </h1>
225
+ <p className="mt-4 text-gray-500">{t('emptyCart')}</p>
226
+ </div>
227
+ );
228
+ }
229
+
230
+ const returnUrl = typeof window !== 'undefined'
231
+ ? `${window.location.origin}/${locale}/order/confirmation/${orderId}`
232
+ : '';
233
+
234
+ // Filter free-item rewards to only show ones whose meal is in the cart
235
+ const eligibleRewards = allRewards.filter((reward) => {
236
+ if (reward.type === 'free-item') {
237
+ return items.some((item) => item.mealDocumentId === reward.mealDocumentId);
238
+ }
239
+ return true;
240
+ });
241
+
242
+ return (
243
+ <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
244
+ <h1 className="text-3xl sm:text-4xl font-bold text-[var(--color-primary)] mb-10">
245
+ {t('title')}
246
+ </h1>
247
+
248
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
249
+ <div className="lg:col-span-2">
250
+ {/* Step 1: Order form */}
251
+ {!clientSecret && (orderingDisabled || restaurantClosed) && (
252
+ <div className="p-4 bg-amber-50 border border-amber-200 rounded-lg">
253
+ <p className="text-amber-800 text-sm font-medium">
254
+ {restaurantClosed
255
+ ? restaurantStatus?.closureReason
256
+ ? t('restaurantClosedReason', { reason: restaurantStatus.closureReason })
257
+ : t('restaurantClosed')
258
+ : t('orderingDisabled')}
259
+ </p>
260
+ </div>
261
+ )}
262
+
263
+ {!clientSecret && !orderingDisabled && !restaurantClosed && (
264
+ <form onSubmit={handleCreateOrder} className="space-y-6">
265
+ {/* Order type */}
266
+ {orderSettings && (orderSettings.takeawayEnabled || orderSettings.dineInEnabled) && (
267
+ <div>
268
+ <label className="block text-sm font-medium text-gray-700 mb-2">
269
+ {t('orderType')}
270
+ </label>
271
+ <div className="flex gap-3">
272
+ {(!orderSettings || orderSettings.takeawayEnabled) && (
273
+ <button
274
+ type="button"
275
+ onClick={() => setOrderType('takeaway')}
276
+ className={`flex-1 px-4 py-3 rounded-md border-2 text-sm font-medium transition-all ${
277
+ orderType === 'takeaway'
278
+ ? 'border-[var(--color-accent)] bg-[var(--color-accent)] text-white'
279
+ : 'border-gray-200 text-gray-600 hover:border-gray-300'
280
+ }`}
281
+ >
282
+ {t('takeaway')}
283
+ </button>
284
+ )}
285
+ {(!orderSettings || orderSettings.dineInEnabled) && (
286
+ <button
287
+ type="button"
288
+ onClick={() => setOrderType('dine-in')}
289
+ className={`flex-1 px-4 py-3 rounded-md border-2 text-sm font-medium transition-all ${
290
+ orderType === 'dine-in'
291
+ ? 'border-[var(--color-accent)] bg-[var(--color-accent)] text-white'
292
+ : 'border-gray-200 text-gray-600 hover:border-gray-300'
293
+ }`}
294
+ >
295
+ {t('dineIn')}
296
+ </button>
297
+ )}
298
+ </div>
299
+ </div>
300
+ )}
301
+
302
+ {/* Table number */}
303
+ {orderType === 'dine-in' && (
304
+ <div>
305
+ <label
306
+ htmlFor="tableNumber"
307
+ className="block text-sm font-medium text-gray-700 mb-1"
308
+ >
309
+ {t('tableNumber')}
310
+ </label>
311
+ <input
312
+ id="tableNumber"
313
+ type="text"
314
+ value={tableNumber}
315
+ onChange={(e) => setTableNumber(e.target.value)}
316
+ className="w-full 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)]"
317
+ required
318
+ />
319
+ </div>
320
+ )}
321
+
322
+ {/* Notes */}
323
+ <div>
324
+ <label
325
+ htmlFor="notes"
326
+ className="block text-sm font-medium text-gray-700 mb-1"
327
+ >
328
+ {t('notes')}
329
+ </label>
330
+ <textarea
331
+ id="notes"
332
+ value={notes}
333
+ onChange={(e) => setNotes(e.target.value)}
334
+ rows={3}
335
+ className="w-full 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)]"
336
+ placeholder={t('notesPlaceholder')}
337
+ />
338
+ </div>
339
+
340
+ {/* Loyalty rewards */}
341
+ {hasLoyalty && eligibleRewards.length > 0 && (
342
+ <div>
343
+ <label className="block text-sm font-medium text-gray-700 mb-2">
344
+ {t('applyReward')}
345
+ {loyaltyAccount && (
346
+ <span className="ml-2 text-xs text-gray-400 font-normal">
347
+ ({loyaltyAccount.points} {tLoyalty('points')})
348
+ </span>
349
+ )}
350
+ </label>
351
+ <div className="space-y-2">
352
+ {/* No reward option */}
353
+ <label
354
+ className={`flex items-center gap-3 p-3 rounded-md border-2 cursor-pointer transition-all ${
355
+ selectedProgramIndex === null
356
+ ? 'border-[var(--color-accent)] bg-[var(--color-accent)]/5'
357
+ : 'border-gray-200 hover:border-gray-300'
358
+ }`}
359
+ >
360
+ <input
361
+ type="radio"
362
+ name="reward"
363
+ checked={selectedProgramIndex === null}
364
+ onChange={() => setSelectedProgramIndex(null)}
365
+ className="accent-[var(--color-accent)]"
366
+ />
367
+ <span className="text-sm text-gray-600">{t('noReward')}</span>
368
+ </label>
369
+
370
+ {eligibleRewards.map((reward) => {
371
+ const preview = calculateDiscountPreview(reward, items, total);
372
+ return (
373
+ <label
374
+ key={reward.programIndex}
375
+ className={`flex items-center gap-3 p-3 rounded-md border-2 cursor-pointer transition-all ${
376
+ selectedProgramIndex === reward.programIndex
377
+ ? 'border-[var(--color-accent)] bg-[var(--color-accent)]/5'
378
+ : 'border-gray-200 hover:border-gray-300'
379
+ }`}
380
+ >
381
+ <input
382
+ type="radio"
383
+ name="reward"
384
+ checked={selectedProgramIndex === reward.programIndex}
385
+ onChange={() => setSelectedProgramIndex(reward.programIndex)}
386
+ className="accent-[var(--color-accent)]"
387
+ />
388
+ <div className="flex-1">
389
+ <span className="text-sm font-medium">{reward.name}</span>
390
+ <span className="ml-2 text-xs text-gray-400">
391
+ {reward.pointsCost} pts
392
+ </span>
393
+ </div>
394
+ <span className="text-sm text-green-600 font-medium">
395
+ -{formatPrice(preview)}
396
+ </span>
397
+ </label>
398
+ );
399
+ })}
400
+ </div>
401
+ </div>
402
+ )}
403
+
404
+ {error && <ErrorAlert message={error} />}
405
+
406
+ <button
407
+ type="submit"
408
+ disabled={loading}
409
+ className="w-full px-6 py-3 rounded-md bg-[var(--color-accent)] text-white font-medium hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
410
+ >
411
+ {loading ? t('processing') : t('placeOrder')}
412
+ </button>
413
+ </form>
414
+ )}
415
+
416
+ {/* Step 2: Stripe payment */}
417
+ {clientSecret && (
418
+ <div>
419
+ <h2 className="text-lg font-semibold text-[var(--color-primary)] mb-6">
420
+ {t('payment')}
421
+ </h2>
422
+ <StripeProvider clientSecret={clientSecret}>
423
+ <StripePaymentForm returnUrl={returnUrl} />
424
+ </StripeProvider>
425
+ </div>
426
+ )}
427
+ </div>
428
+
429
+ {/* Order summary sidebar */}
430
+ <div className="lg:col-span-1">
431
+ <div className="bg-gray-50 rounded-lg p-6 sticky top-24">
432
+ <h2 className="text-lg font-semibold text-[var(--color-primary)] mb-4">
433
+ {t('summary')}
434
+ </h2>
435
+
436
+ <div className="space-y-3">
437
+ {(clientSecret ? orderItems : items).map((item, index) => (
438
+ <div
439
+ key={`${item.mealDocumentId}-${index}`}
440
+ className="flex justify-between text-sm"
441
+ >
442
+ <span className="text-gray-600">
443
+ {item.quantity}x {item.mealName}
444
+ </span>
445
+ <span className="text-gray-800 font-medium">
446
+ {formatPrice(item.unitPrice * item.quantity)}
447
+ </span>
448
+ </div>
449
+ ))}
450
+ </div>
451
+
452
+ <div className="mt-4 pt-4 border-t border-gray-200 space-y-2">
453
+ {/* Show subtotal + discount when a reward is selected */}
454
+ {!clientSecret && currentDiscount > 0 && (
455
+ <>
456
+ <div className="flex justify-between text-sm">
457
+ <span className="text-gray-500">{t('subtotal')}</span>
458
+ <span className="text-gray-700">{formatPrice(total)}</span>
459
+ </div>
460
+ <div className="flex justify-between text-sm">
461
+ <span className="text-green-600">{t('discount')}</span>
462
+ <span className="text-green-600 font-medium">
463
+ -{formatPrice(currentDiscount)}
464
+ </span>
465
+ </div>
466
+ </>
467
+ )}
468
+ <div className="flex justify-between font-semibold">
469
+ <span className="text-[var(--color-primary)]">{t('total')}</span>
470
+ <span className="text-[var(--color-accent)]">
471
+ {formatPrice(
472
+ clientSecret
473
+ ? orderTotal
474
+ : displayTotal,
475
+ )}
476
+ </span>
477
+ </div>
478
+ {!clientSecret && orderSettings && orderSettings.estimatedPrepMinutes > 0 && (
479
+ <p className="mt-3 text-xs text-gray-400 text-center">
480
+ {t('estimatedPrep', { minutes: orderSettings.estimatedPrepMinutes })}
481
+ </p>
482
+ )}
483
+ </div>
484
+ </div>
485
+ </div>
486
+ </div>
487
+ </div>
488
+ );
489
+ }
@@ -0,0 +1,159 @@
1
+ import { getTranslations } from 'next-intl/server';
2
+ import { notFound } from 'next/navigation';
3
+ import { bestraw } from '@/lib/client';
4
+ import { OrderStatusTracker } from '@/components/order/OrderStatusTracker';
5
+ import { PaymentStatus } from '@/components/order/PaymentStatus';
6
+ import { hasOrdering, hasPayments } from '@/lib/features';
7
+
8
+ function formatPrice(price: number): string {
9
+ return price.toFixed(2).replace('.', ',') + ' \u20AC';
10
+ }
11
+
12
+ export default async function ConfirmationPage({
13
+ params,
14
+ searchParams,
15
+ }: {
16
+ params: Promise<{ locale: string; id: string }>;
17
+ searchParams: Promise<Record<string, string | string[] | undefined>>;
18
+ }) {
19
+ if (!hasOrdering) notFound();
20
+ const { id } = await params;
21
+ const query = await searchParams;
22
+ const t = await getTranslations('confirmation');
23
+
24
+ const paymentClientSecret =
25
+ hasPayments && typeof query.payment_intent_client_secret === 'string'
26
+ ? query.payment_intent_client_secret
27
+ : null;
28
+ const apiUrl =
29
+ process.env.API_URL ||
30
+ process.env.NEXT_PUBLIC_API_URL ||
31
+ 'http://localhost:1338';
32
+
33
+ let order: Awaited<ReturnType<typeof bestraw.ordering.getOrder>> | null = null;
34
+
35
+ try {
36
+ order = await bestraw.ordering.getOrder(id);
37
+ } catch {
38
+ // Order not found or API unavailable
39
+ }
40
+
41
+ if (!order) {
42
+ return (
43
+ <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
44
+ <h1 className="text-3xl sm:text-4xl font-bold text-[var(--color-primary)]">
45
+ {t('title')}
46
+ </h1>
47
+ <p className="mt-4 text-gray-500">{t('notFound')}</p>
48
+ </div>
49
+ );
50
+ }
51
+
52
+ return (
53
+ <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
54
+ <div className="text-center mb-10">
55
+ <div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-green-100 mb-4">
56
+ <svg
57
+ className="w-8 h-8 text-green-500"
58
+ fill="none"
59
+ stroke="currentColor"
60
+ viewBox="0 0 24 24"
61
+ >
62
+ <path
63
+ strokeLinecap="round"
64
+ strokeLinejoin="round"
65
+ strokeWidth={2}
66
+ d="M5 13l4 4L19 7"
67
+ />
68
+ </svg>
69
+ </div>
70
+ <h1 className="text-3xl sm:text-4xl font-bold text-[var(--color-primary)]">
71
+ {t('thankYou')}
72
+ </h1>
73
+ <p className="mt-2 text-gray-600">{t('orderReceived')}</p>
74
+ </div>
75
+
76
+ {/* Order number */}
77
+ <div className="bg-gray-50 rounded-lg p-6 mb-8 text-center">
78
+ <p className="text-sm text-gray-500 mb-1">{t('orderNumber')}</p>
79
+ <p className="text-2xl font-bold text-[var(--color-primary)]">
80
+ {order.orderNumber}
81
+ </p>
82
+ {order.estimatedReadyAt && (
83
+ <p className="mt-2 text-sm text-gray-600">
84
+ {t('estimatedReady')}{' '}
85
+ <span className="font-medium">
86
+ {new Date(order.estimatedReadyAt).toLocaleTimeString([], {
87
+ hour: '2-digit',
88
+ minute: '2-digit',
89
+ })}
90
+ </span>
91
+ </p>
92
+ )}
93
+ </div>
94
+
95
+ {/* Payment status (Stripe redirect) */}
96
+ {paymentClientSecret && (
97
+ <PaymentStatus clientSecret={paymentClientSecret} />
98
+ )}
99
+
100
+ {/* Status tracker */}
101
+ <div className="mb-8">
102
+ <OrderStatusTracker
103
+ orderId={order.documentId}
104
+ initialStatus={order.status}
105
+ apiUrl={apiUrl}
106
+ />
107
+ </div>
108
+
109
+ {/* Order details */}
110
+ <div className="bg-white rounded-lg border border-gray-100 p-6">
111
+ <h2 className="text-lg font-semibold text-[var(--color-primary)] mb-4">
112
+ {t('orderDetails')}
113
+ </h2>
114
+
115
+ <div className="space-y-3">
116
+ {order.items.map((item, index) => (
117
+ <div key={index} className="flex justify-between text-sm">
118
+ <span className="text-gray-600">
119
+ {item.quantity}x {item.mealName}
120
+ </span>
121
+ <span className="text-gray-800 font-medium">
122
+ {formatPrice(item.totalPrice)}
123
+ </span>
124
+ </div>
125
+ ))}
126
+ </div>
127
+
128
+ <div className="mt-4 pt-4 border-t border-gray-200 space-y-2">
129
+ <div className="flex justify-between text-sm">
130
+ <span className="text-gray-500">{t('subtotal')}</span>
131
+ <span className="text-gray-700">{formatPrice(order.subtotal)}</span>
132
+ </div>
133
+ {(order.discount ?? 0) > 0 && (
134
+ <div className="flex justify-between text-sm">
135
+ <span className="text-green-600">
136
+ {t('discount')} ({order.discountLabel})
137
+ </span>
138
+ <span className="text-green-600 font-medium">
139
+ -{formatPrice(order.discount!)}
140
+ </span>
141
+ </div>
142
+ )}
143
+ {order.tax > 0 && (
144
+ <div className="flex justify-between text-sm">
145
+ <span className="text-gray-500">{t('tax')}</span>
146
+ <span className="text-gray-700">{formatPrice(order.tax)}</span>
147
+ </div>
148
+ )}
149
+ <div className="flex justify-between font-semibold pt-2 border-t border-gray-100">
150
+ <span className="text-[var(--color-primary)]">{t('total')}</span>
151
+ <span className="text-[var(--color-accent)]">
152
+ {formatPrice(order.total)}
153
+ </span>
154
+ </div>
155
+ </div>
156
+ </div>
157
+ </div>
158
+ );
159
+ }