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,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
|
+
}
|