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,220 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { useTranslations } from 'next-intl';
|
|
5
|
+
import { useCart } from '@/providers/CartProvider';
|
|
6
|
+
import type { Meal } from 'bestraw-sdk';
|
|
7
|
+
|
|
8
|
+
interface MealOrderCardProps {
|
|
9
|
+
meal: Meal;
|
|
10
|
+
apiUrl: string;
|
|
11
|
+
onShowDetail?: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function formatPrice(price: number): string {
|
|
15
|
+
return price.toFixed(2).replace('.', ',') + ' \u20AC';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function MealOrderCard({ meal, apiUrl, onShowDetail }: MealOrderCardProps) {
|
|
19
|
+
const t = useTranslations('order');
|
|
20
|
+
const { addItem } = useCart();
|
|
21
|
+
const [quantity, setQuantity] = useState(1);
|
|
22
|
+
const [selectedSauces, setSelectedSauces] = useState<string[]>([]);
|
|
23
|
+
const [selectedSides, setSelectedSides] = useState<string[]>([]);
|
|
24
|
+
const [specialInstructions, setSpecialInstructions] = useState('');
|
|
25
|
+
const [showDetails, setShowDetails] = useState(false);
|
|
26
|
+
|
|
27
|
+
const picture = meal.pictures?.[0];
|
|
28
|
+
const imageUrl = picture ? `${apiUrl}${picture.url}` : null;
|
|
29
|
+
const hasSauces = meal.sauces && meal.sauces.length > 0;
|
|
30
|
+
const hasSides = meal.sides && meal.sides.length > 0;
|
|
31
|
+
const hasOptions = hasSauces || hasSides;
|
|
32
|
+
|
|
33
|
+
function handleSauceToggle(sauceName: string) {
|
|
34
|
+
setSelectedSauces((prev) =>
|
|
35
|
+
prev.includes(sauceName)
|
|
36
|
+
? prev.filter((s) => s !== sauceName)
|
|
37
|
+
: [...prev, sauceName]
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function handleSideToggle(sideName: string) {
|
|
42
|
+
setSelectedSides((prev) =>
|
|
43
|
+
prev.includes(sideName)
|
|
44
|
+
? prev.filter((s) => s !== sideName)
|
|
45
|
+
: [...prev, sideName]
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function handleAddToCart() {
|
|
50
|
+
addItem({
|
|
51
|
+
mealDocumentId: meal.documentId,
|
|
52
|
+
mealName: meal.name,
|
|
53
|
+
unitPrice: meal.price,
|
|
54
|
+
quantity,
|
|
55
|
+
selectedSauces,
|
|
56
|
+
selectedSides,
|
|
57
|
+
specialInstructions: specialInstructions.trim(),
|
|
58
|
+
picture: imageUrl || undefined,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Reset form
|
|
62
|
+
setQuantity(1);
|
|
63
|
+
setSelectedSauces([]);
|
|
64
|
+
setSelectedSides([]);
|
|
65
|
+
setSpecialInstructions('');
|
|
66
|
+
setShowDetails(false);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div className="bg-white rounded-lg border border-gray-100 overflow-hidden hover:shadow-md transition-shadow">
|
|
71
|
+
{imageUrl && (
|
|
72
|
+
<div className="aspect-[4/3] overflow-hidden bg-gray-100">
|
|
73
|
+
<img
|
|
74
|
+
src={imageUrl}
|
|
75
|
+
alt={picture?.alternativeText || meal.name}
|
|
76
|
+
className="w-full h-full object-cover"
|
|
77
|
+
/>
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
|
|
81
|
+
<div className="p-4">
|
|
82
|
+
<div className="flex items-start justify-between gap-2">
|
|
83
|
+
<h3 className="font-semibold text-[var(--color-primary)]">
|
|
84
|
+
{meal.name}
|
|
85
|
+
</h3>
|
|
86
|
+
<span className="text-sm font-medium text-[var(--color-accent)] whitespace-nowrap">
|
|
87
|
+
{formatPrice(meal.price)}
|
|
88
|
+
</span>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
{meal.description && (
|
|
92
|
+
<p className="mt-1 text-sm text-gray-600 line-clamp-2">
|
|
93
|
+
{meal.description}
|
|
94
|
+
</p>
|
|
95
|
+
)}
|
|
96
|
+
|
|
97
|
+
{/* Options toggle */}
|
|
98
|
+
{hasOptions && (
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
onClick={() => setShowDetails(!showDetails)}
|
|
102
|
+
className="mt-2 text-xs text-[var(--color-accent)] hover:underline"
|
|
103
|
+
>
|
|
104
|
+
{showDetails ? t('hideOptions') : t('showOptions')}
|
|
105
|
+
</button>
|
|
106
|
+
)}
|
|
107
|
+
|
|
108
|
+
{/* Expanded options */}
|
|
109
|
+
{showDetails && (
|
|
110
|
+
<div className="mt-3 space-y-3">
|
|
111
|
+
{hasSauces && (
|
|
112
|
+
<div>
|
|
113
|
+
<p className="text-xs font-medium text-gray-700 mb-1">
|
|
114
|
+
{t('sauces')}
|
|
115
|
+
</p>
|
|
116
|
+
<div className="space-y-1">
|
|
117
|
+
{meal.sauces!.map((sauce) => (
|
|
118
|
+
<label
|
|
119
|
+
key={sauce.name}
|
|
120
|
+
className="flex items-center gap-2 text-sm text-gray-600 cursor-pointer"
|
|
121
|
+
>
|
|
122
|
+
<input
|
|
123
|
+
type="checkbox"
|
|
124
|
+
checked={selectedSauces.includes(sauce.name)}
|
|
125
|
+
onChange={() => handleSauceToggle(sauce.name)}
|
|
126
|
+
className="rounded border-gray-300 text-[var(--color-accent)] focus:ring-[var(--color-accent)]"
|
|
127
|
+
/>
|
|
128
|
+
{sauce.name}
|
|
129
|
+
</label>
|
|
130
|
+
))}
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
|
|
135
|
+
{hasSides && (
|
|
136
|
+
<div>
|
|
137
|
+
<p className="text-xs font-medium text-gray-700 mb-1">
|
|
138
|
+
{t('sides')}
|
|
139
|
+
</p>
|
|
140
|
+
<div className="space-y-1">
|
|
141
|
+
{meal.sides!.map((side) => (
|
|
142
|
+
<label
|
|
143
|
+
key={side.name}
|
|
144
|
+
className="flex items-center gap-2 text-sm text-gray-600 cursor-pointer"
|
|
145
|
+
>
|
|
146
|
+
<input
|
|
147
|
+
type="checkbox"
|
|
148
|
+
checked={selectedSides.includes(side.name)}
|
|
149
|
+
onChange={() => handleSideToggle(side.name)}
|
|
150
|
+
className="rounded border-gray-300 text-[var(--color-accent)] focus:ring-[var(--color-accent)]"
|
|
151
|
+
/>
|
|
152
|
+
{side.name}
|
|
153
|
+
</label>
|
|
154
|
+
))}
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
|
|
161
|
+
{/* Special instructions */}
|
|
162
|
+
<div className="mt-3">
|
|
163
|
+
<input
|
|
164
|
+
type="text"
|
|
165
|
+
placeholder={t('specialInstructions')}
|
|
166
|
+
value={specialInstructions}
|
|
167
|
+
onChange={(e) => setSpecialInstructions(e.target.value)}
|
|
168
|
+
className="w-full text-sm px-3 py-1.5 border border-gray-200 rounded-md focus:outline-none focus:ring-1 focus:ring-[var(--color-accent)] focus:border-[var(--color-accent)]"
|
|
169
|
+
/>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
{/* Quantity + Detail + Add to cart */}
|
|
173
|
+
<div className="mt-3 flex items-center gap-2">
|
|
174
|
+
<div className="flex items-center border border-gray-200 rounded-md">
|
|
175
|
+
<button
|
|
176
|
+
type="button"
|
|
177
|
+
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
|
178
|
+
className="px-2 py-1 text-gray-500 hover:text-gray-700"
|
|
179
|
+
aria-label={t('decreaseQuantity')}
|
|
180
|
+
>
|
|
181
|
+
-
|
|
182
|
+
</button>
|
|
183
|
+
<span className="px-3 py-1 text-sm font-medium min-w-[2rem] text-center">
|
|
184
|
+
{quantity}
|
|
185
|
+
</span>
|
|
186
|
+
<button
|
|
187
|
+
type="button"
|
|
188
|
+
onClick={() => setQuantity(quantity + 1)}
|
|
189
|
+
className="px-2 py-1 text-gray-500 hover:text-gray-700"
|
|
190
|
+
aria-label={t('increaseQuantity')}
|
|
191
|
+
>
|
|
192
|
+
+
|
|
193
|
+
</button>
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
{onShowDetail && (
|
|
197
|
+
<button
|
|
198
|
+
type="button"
|
|
199
|
+
onClick={onShowDetail}
|
|
200
|
+
className="p-2 rounded-md border border-gray-200 text-gray-500 hover:text-[var(--color-primary)] hover:border-[var(--color-primary)] transition-colors"
|
|
201
|
+
aria-label={t('viewDetails')}
|
|
202
|
+
>
|
|
203
|
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
204
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
205
|
+
</svg>
|
|
206
|
+
</button>
|
|
207
|
+
)}
|
|
208
|
+
|
|
209
|
+
<button
|
|
210
|
+
type="button"
|
|
211
|
+
onClick={handleAddToCart}
|
|
212
|
+
className="flex-1 px-4 py-2 rounded-md bg-[var(--color-accent)] text-white text-sm font-medium hover:opacity-90 transition-opacity"
|
|
213
|
+
>
|
|
214
|
+
{t('addToCart')}
|
|
215
|
+
</button>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { useTranslations } from 'next-intl';
|
|
5
|
+
import type { OrderStatus } from 'bestraw-sdk';
|
|
6
|
+
|
|
7
|
+
interface OrderStatusTrackerProps {
|
|
8
|
+
orderId: string;
|
|
9
|
+
initialStatus: string;
|
|
10
|
+
apiUrl: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const STATUS_STEPS: OrderStatus[] = [
|
|
14
|
+
'pending',
|
|
15
|
+
'confirmed',
|
|
16
|
+
'preparing',
|
|
17
|
+
'ready',
|
|
18
|
+
'completed',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export function OrderStatusTracker({
|
|
22
|
+
orderId,
|
|
23
|
+
initialStatus,
|
|
24
|
+
apiUrl,
|
|
25
|
+
}: OrderStatusTrackerProps) {
|
|
26
|
+
const t = useTranslations('orderStatus');
|
|
27
|
+
const [currentStatus, setCurrentStatus] = useState<string>(initialStatus);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
const eventSource = new EventSource(
|
|
31
|
+
`${apiUrl}/api/realtime/events`
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
function handleStatusChanged(event: MessageEvent) {
|
|
35
|
+
try {
|
|
36
|
+
const data = JSON.parse(event.data);
|
|
37
|
+
if (data.order?.documentId === orderId && data.order?.status) {
|
|
38
|
+
setCurrentStatus(data.order.status);
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
// Ignore parse errors
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function handleCancelled(event: MessageEvent) {
|
|
46
|
+
try {
|
|
47
|
+
const data = JSON.parse(event.data);
|
|
48
|
+
if (data.order?.documentId === orderId) {
|
|
49
|
+
setCurrentStatus('cancelled');
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// Ignore parse errors
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
eventSource.addEventListener('order.statusChanged', handleStatusChanged);
|
|
57
|
+
eventSource.addEventListener('order.cancelled', handleCancelled);
|
|
58
|
+
|
|
59
|
+
return () => {
|
|
60
|
+
eventSource.close();
|
|
61
|
+
};
|
|
62
|
+
}, [orderId, apiUrl]);
|
|
63
|
+
|
|
64
|
+
const currentIndex = STATUS_STEPS.indexOf(currentStatus as OrderStatus);
|
|
65
|
+
const isCancelled = currentStatus === 'cancelled';
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className="py-4">
|
|
69
|
+
{isCancelled ? (
|
|
70
|
+
<div className="text-center p-4 bg-red-50 rounded-lg border border-red-200">
|
|
71
|
+
<p className="text-red-600 font-medium">{t('cancelled')}</p>
|
|
72
|
+
</div>
|
|
73
|
+
) : (
|
|
74
|
+
<div className="flex items-center justify-between">
|
|
75
|
+
{STATUS_STEPS.map((step, index) => {
|
|
76
|
+
const isCompleted = index < currentIndex;
|
|
77
|
+
const isActive = index === currentIndex;
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div key={step} className="flex-1 flex flex-col items-center relative">
|
|
81
|
+
{/* Connector line */}
|
|
82
|
+
{index > 0 && (
|
|
83
|
+
<div
|
|
84
|
+
className={`absolute top-4 right-1/2 w-full h-0.5 -translate-y-1/2 ${
|
|
85
|
+
index <= currentIndex ? 'bg-[var(--color-accent)]' : 'bg-gray-200'
|
|
86
|
+
}`}
|
|
87
|
+
/>
|
|
88
|
+
)}
|
|
89
|
+
|
|
90
|
+
{/* Step circle */}
|
|
91
|
+
<div
|
|
92
|
+
className={`relative z-10 w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-all ${
|
|
93
|
+
isCompleted
|
|
94
|
+
? 'bg-[var(--color-accent)] text-white'
|
|
95
|
+
: isActive
|
|
96
|
+
? 'bg-[var(--color-accent)] text-white ring-4 ring-[var(--color-accent)]/20'
|
|
97
|
+
: 'bg-gray-200 text-gray-500'
|
|
98
|
+
}`}
|
|
99
|
+
>
|
|
100
|
+
{isCompleted ? (
|
|
101
|
+
<svg
|
|
102
|
+
className="w-4 h-4"
|
|
103
|
+
fill="none"
|
|
104
|
+
stroke="currentColor"
|
|
105
|
+
viewBox="0 0 24 24"
|
|
106
|
+
>
|
|
107
|
+
<path
|
|
108
|
+
strokeLinecap="round"
|
|
109
|
+
strokeLinejoin="round"
|
|
110
|
+
strokeWidth={2}
|
|
111
|
+
d="M5 13l4 4L19 7"
|
|
112
|
+
/>
|
|
113
|
+
</svg>
|
|
114
|
+
) : (
|
|
115
|
+
<span>{index + 1}</span>
|
|
116
|
+
)}
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
{/* Step label */}
|
|
120
|
+
<span
|
|
121
|
+
className={`mt-2 text-xs text-center ${
|
|
122
|
+
isActive
|
|
123
|
+
? 'font-semibold text-[var(--color-accent)]'
|
|
124
|
+
: isCompleted
|
|
125
|
+
? 'font-medium text-gray-700'
|
|
126
|
+
: 'text-gray-400'
|
|
127
|
+
}`}
|
|
128
|
+
>
|
|
129
|
+
{t(step)}
|
|
130
|
+
</span>
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
})}
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { loadStripe } from '@stripe/stripe-js';
|
|
5
|
+
import { useTranslations } from 'next-intl';
|
|
6
|
+
|
|
7
|
+
const stripePromise = loadStripe(
|
|
8
|
+
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
type Status = 'loading' | 'succeeded' | 'processing' | 'failed';
|
|
12
|
+
|
|
13
|
+
interface PaymentStatusProps {
|
|
14
|
+
clientSecret: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function PaymentStatus({ clientSecret }: PaymentStatusProps) {
|
|
18
|
+
const t = useTranslations('confirmation');
|
|
19
|
+
const [status, setStatus] = useState<Status>('loading');
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
stripePromise.then(async (stripe) => {
|
|
23
|
+
if (!stripe) return;
|
|
24
|
+
const { paymentIntent } = await stripe.retrievePaymentIntent(clientSecret);
|
|
25
|
+
if (!paymentIntent) {
|
|
26
|
+
setStatus('failed');
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
switch (paymentIntent.status) {
|
|
30
|
+
case 'succeeded':
|
|
31
|
+
setStatus('succeeded');
|
|
32
|
+
break;
|
|
33
|
+
case 'processing':
|
|
34
|
+
setStatus('processing');
|
|
35
|
+
break;
|
|
36
|
+
default:
|
|
37
|
+
setStatus('failed');
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}, [clientSecret]);
|
|
41
|
+
|
|
42
|
+
if (status === 'loading') return null;
|
|
43
|
+
|
|
44
|
+
if (status === 'failed') {
|
|
45
|
+
return (
|
|
46
|
+
<div className="p-4 bg-red-50 rounded-lg border border-red-200 text-center mb-8">
|
|
47
|
+
<p className="text-red-600 font-medium">{t('paymentFailed')}</p>
|
|
48
|
+
<p className="text-red-500 text-sm mt-1">{t('paymentFailedHint')}</p>
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (status === 'processing') {
|
|
54
|
+
return (
|
|
55
|
+
<div className="p-4 bg-amber-50 rounded-lg border border-amber-200 text-center mb-8">
|
|
56
|
+
<p className="text-amber-600 font-medium">{t('paymentProcessing')}</p>
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
type ButtonVariant = 'primary' | 'secondary' | 'outline';
|
|
2
|
+
|
|
3
|
+
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
4
|
+
variant?: ButtonVariant;
|
|
5
|
+
children: React.ReactNode;
|
|
6
|
+
href?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const variantClasses: Record<ButtonVariant, string> = {
|
|
10
|
+
primary:
|
|
11
|
+
'bg-[var(--color-primary)] text-white hover:opacity-90',
|
|
12
|
+
secondary:
|
|
13
|
+
'bg-[var(--color-accent)] text-white hover:opacity-90',
|
|
14
|
+
outline:
|
|
15
|
+
'border-2 border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-white',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function Button({
|
|
19
|
+
variant = 'primary',
|
|
20
|
+
children,
|
|
21
|
+
href,
|
|
22
|
+
className = '',
|
|
23
|
+
...props
|
|
24
|
+
}: ButtonProps) {
|
|
25
|
+
const classes = `inline-block px-6 py-3 rounded-md font-medium transition-all duration-200 text-center ${variantClasses[variant]} ${className}`;
|
|
26
|
+
|
|
27
|
+
if (href) {
|
|
28
|
+
return (
|
|
29
|
+
<a href={href} className={classes}>
|
|
30
|
+
{children}
|
|
31
|
+
</a>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<button className={classes} {...props}>
|
|
37
|
+
{children}
|
|
38
|
+
</button>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
interface ErrorAlertProps {
|
|
4
|
+
message: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function ErrorAlert({ message }: ErrorAlertProps) {
|
|
8
|
+
if (!message) return null;
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<div className="p-3 bg-red-50 border border-red-200 rounded-md">
|
|
12
|
+
<p className="text-sm text-red-600">{message}</p>
|
|
13
|
+
</div>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { getRequestConfig } from 'next-intl/server';
|
|
2
|
+
import { routing } from './routing';
|
|
3
|
+
|
|
4
|
+
export default getRequestConfig(async ({ requestLocale }) => {
|
|
5
|
+
let locale = await requestLocale;
|
|
6
|
+
if (!locale || !routing.locales.includes(locale as any)) {
|
|
7
|
+
locale = routing.defaultLocale;
|
|
8
|
+
}
|
|
9
|
+
return {
|
|
10
|
+
locale,
|
|
11
|
+
messages: (await import(`../messages/${locale}.json`)).default,
|
|
12
|
+
};
|
|
13
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { defineRouting } from 'next-intl/routing';
|
|
2
|
+
import { createNavigation } from 'next-intl/navigation';
|
|
3
|
+
|
|
4
|
+
export const routing = defineRouting({
|
|
5
|
+
locales: ['fr', 'en'],
|
|
6
|
+
defaultLocale: 'fr',
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export const { Link, redirect, usePathname, useRouter } =
|
|
10
|
+
createNavigation(routing);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { BestrawError } from 'bestraw-sdk';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Extracts an error code from any caught error.
|
|
5
|
+
* Returns the structured code from BestrawError, or 'UNKNOWN'.
|
|
6
|
+
*/
|
|
7
|
+
export function getErrorCode(error: unknown): string {
|
|
8
|
+
if (error instanceof BestrawError && error.code) {
|
|
9
|
+
return error.code;
|
|
10
|
+
}
|
|
11
|
+
return 'UNKNOWN';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Maps a caught error to a user-facing translated message.
|
|
16
|
+
* Uses the error code to look up the i18n key under "errors.<CODE>".
|
|
17
|
+
*
|
|
18
|
+
* @param error - The caught error (BestrawError, Error, or unknown)
|
|
19
|
+
* @param t - The next-intl translation function scoped to "errors"
|
|
20
|
+
* @returns A localized, user-friendly error message
|
|
21
|
+
*/
|
|
22
|
+
export function getErrorMessage(
|
|
23
|
+
error: unknown,
|
|
24
|
+
t: { (key: string): string; has(key: string): boolean },
|
|
25
|
+
): string {
|
|
26
|
+
const code = getErrorCode(error);
|
|
27
|
+
if (t.has(code)) {
|
|
28
|
+
return t(code);
|
|
29
|
+
}
|
|
30
|
+
return t('unknown');
|
|
31
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { config } from '@/bestraw.config';
|
|
2
|
+
|
|
3
|
+
export const features = config.features;
|
|
4
|
+
export const hasOrdering = features.ordering;
|
|
5
|
+
export const hasLoyalty = features.loyalty;
|
|
6
|
+
export const hasPayments = features.payments;
|
|
7
|
+
export const hasPhoneAuth = features.auth.phone;
|
|
8
|
+
export const hasEmailAuth = features.auth.email;
|
|
9
|
+
export const hasAuth = hasPhoneAuth || hasEmailAuth;
|
|
10
|
+
export const hasBlog = features.blog;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
import { BestrawClient } from 'bestraw-sdk';
|
|
5
|
+
|
|
6
|
+
const STORAGE_KEY = 'bestraw-customer-token';
|
|
7
|
+
|
|
8
|
+
export function getCustomerToken(): string | null {
|
|
9
|
+
if (typeof window === 'undefined') return null;
|
|
10
|
+
return localStorage.getItem(STORAGE_KEY);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function setCustomerToken(token: string): void {
|
|
14
|
+
localStorage.setItem(STORAGE_KEY, token);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function removeCustomerToken(): void {
|
|
18
|
+
localStorage.removeItem(STORAGE_KEY);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function useCustomerClient(): BestrawClient {
|
|
22
|
+
return useMemo(() => {
|
|
23
|
+
return new BestrawClient({
|
|
24
|
+
baseUrl: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:1338',
|
|
25
|
+
getAuthToken: () => getCustomerToken(),
|
|
26
|
+
});
|
|
27
|
+
}, []);
|
|
28
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
import type { Variant } from 'bestraw-sdk';
|
|
5
|
+
import { BestrawClient } from 'bestraw-sdk';
|
|
6
|
+
|
|
7
|
+
const client = new BestrawClient({
|
|
8
|
+
baseUrl: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:1337',
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
interface UseMenuResult {
|
|
12
|
+
variant: Variant | null;
|
|
13
|
+
loading: boolean;
|
|
14
|
+
error: string | null;
|
|
15
|
+
refetch: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function useMenu(menuDocumentId: string): UseMenuResult {
|
|
19
|
+
const [variant, setVariant] = useState<Variant | null>(null);
|
|
20
|
+
const [loading, setLoading] = useState(true);
|
|
21
|
+
const [error, setError] = useState<string | null>(null);
|
|
22
|
+
|
|
23
|
+
const fetch = () => {
|
|
24
|
+
setLoading(true);
|
|
25
|
+
setError(null);
|
|
26
|
+
|
|
27
|
+
client.menu
|
|
28
|
+
.getActiveVariant(menuDocumentId)
|
|
29
|
+
.then((data) => {
|
|
30
|
+
setVariant(data);
|
|
31
|
+
})
|
|
32
|
+
.catch((err) => {
|
|
33
|
+
setError(err instanceof Error ? err.message : 'Unknown error');
|
|
34
|
+
})
|
|
35
|
+
.finally(() => {
|
|
36
|
+
setLoading(false);
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
fetch();
|
|
42
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
43
|
+
}, [menuDocumentId]);
|
|
44
|
+
|
|
45
|
+
return { variant, loading, error, refetch: fetch };
|
|
46
|
+
}
|