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,186 @@
1
+ import { getTranslations } from 'next-intl/server';
2
+ import { bestraw } from '@/lib/client';
3
+ import type { RestaurantInfo, OpeningHours } from 'bestraw-sdk';
4
+
5
+ export default async function InfoPage({
6
+ params,
7
+ }: {
8
+ params: Promise<{ locale: string }>;
9
+ }) {
10
+ const { locale } = await params;
11
+ const t = await getTranslations('info');
12
+
13
+ let info: RestaurantInfo | null = null;
14
+ let hours: OpeningHours | null = null;
15
+
16
+ try {
17
+ [info, hours] = await Promise.all([
18
+ bestraw.restaurant.getInfo(locale),
19
+ bestraw.restaurant.getHours(),
20
+ ]);
21
+ } catch {
22
+ // API unavailable
23
+ }
24
+
25
+ return (
26
+ <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
27
+ <h1 className="text-3xl sm:text-4xl font-bold text-[var(--color-primary)] mb-10">
28
+ {t('title')}
29
+ </h1>
30
+
31
+ {!info ? (
32
+ <p className="text-gray-500">{t('unavailable')}</p>
33
+ ) : (
34
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-10">
35
+ {/* Contact */}
36
+ <section>
37
+ <h2 className="text-xl font-semibold text-[var(--color-primary)] mb-4">
38
+ {t('contact')}
39
+ </h2>
40
+ <div className="space-y-3 text-gray-700">
41
+ {info.address && (
42
+ <p>
43
+ {info.address.street}
44
+ <br />
45
+ {info.address.postalCode} {info.address.city}
46
+ {info.address.country && `, ${info.address.country}`}
47
+ </p>
48
+ )}
49
+ {info.phone && (
50
+ <p>
51
+ <a
52
+ href={`tel:${info.phone}`}
53
+ className="hover:text-[var(--color-primary)] transition-colors"
54
+ >
55
+ {info.phone}
56
+ </a>
57
+ </p>
58
+ )}
59
+ {info.email && (
60
+ <p>
61
+ <a
62
+ href={`mailto:${info.email}`}
63
+ className="hover:text-[var(--color-primary)] transition-colors"
64
+ >
65
+ {info.email}
66
+ </a>
67
+ </p>
68
+ )}
69
+ {info.website && (
70
+ <p>
71
+ <a
72
+ href={info.website}
73
+ target="_blank"
74
+ rel="noopener noreferrer"
75
+ className="hover:text-[var(--color-primary)] transition-colors"
76
+ >
77
+ {info.website}
78
+ </a>
79
+ </p>
80
+ )}
81
+ </div>
82
+
83
+ {info.socialLinks && info.socialLinks.length > 0 && (
84
+ <div className="mt-6">
85
+ <h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-2">
86
+ {t('findUs')}
87
+ </h3>
88
+ <div className="flex gap-4">
89
+ {info.socialLinks.map((link) => (
90
+ <a
91
+ key={link.platform}
92
+ href={link.url}
93
+ target="_blank"
94
+ rel="noopener noreferrer"
95
+ className="text-sm text-[var(--color-accent)] hover:underline capitalize"
96
+ >
97
+ {link.platform}
98
+ </a>
99
+ ))}
100
+ </div>
101
+ </div>
102
+ )}
103
+ </section>
104
+
105
+ {/* Opening hours */}
106
+ <section>
107
+ <h2 className="text-xl font-semibold text-[var(--color-primary)] mb-4">
108
+ {t('openingHours')}
109
+ </h2>
110
+
111
+ {hours?.slots && hours.slots.length > 0 ? (() => {
112
+ const sorted = [...hours.slots].sort((a, b) => a.dayOfWeek - b.dayOfWeek);
113
+ const grouped: Record<number, typeof sorted> = {};
114
+ for (const slot of sorted) {
115
+ (grouped[slot.dayOfWeek] ??= []).push(slot);
116
+ }
117
+ return (
118
+ <div className="space-y-3">
119
+ {Object.entries(grouped).map(([day, slots]) => (
120
+ <div key={day} className="border-b border-gray-100 pb-3">
121
+ <p className="font-medium text-gray-700 mb-1">
122
+ {t(`days.${day}`)}
123
+ </p>
124
+ <div className="space-y-0.5 pl-3">
125
+ {slots.map((slot, i) => (
126
+ <div key={i} className="flex justify-between text-gray-600">
127
+ <span className="text-xs text-gray-400">
128
+ {slot.label
129
+ ? typeof slot.label === 'object'
130
+ ? (slot.label as Record<string, string>)[locale] ?? Object.values(slot.label)[0]
131
+ : slot.label
132
+ : ''}
133
+ </span>
134
+ <span>{slot.openTime.slice(0, 5)} – {slot.closeTime.slice(0, 5)}</span>
135
+ </div>
136
+ ))}
137
+ </div>
138
+ </div>
139
+ ))}
140
+ </div>
141
+ );
142
+ })() : (
143
+ <p className="text-gray-500">{t('hoursUnavailable')}</p>
144
+ )}
145
+
146
+ {/* Special closures */}
147
+ {hours?.specialClosures && hours.specialClosures.length > 0 && (() => {
148
+ const today = new Date().toISOString().slice(0, 10);
149
+ const upcoming = hours.specialClosures
150
+ .filter((c) => c.date >= today)
151
+ .sort((a, b) => a.date.localeCompare(b.date));
152
+
153
+ if (upcoming.length === 0) return null;
154
+ return (
155
+ <div className="mt-6">
156
+ <h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
157
+ {t('specialClosures')}
158
+ </h3>
159
+ <div className="space-y-2">
160
+ {upcoming.map((closure) => {
161
+ const reason = closure.reason
162
+ ? typeof closure.reason === 'object'
163
+ ? (closure.reason as Record<string, string>)[locale] ?? Object.values(closure.reason)[0]
164
+ : closure.reason
165
+ : null;
166
+ const dateStr = new Date(closure.date + 'T00:00:00').toLocaleDateString(locale, {
167
+ weekday: 'long', day: 'numeric', month: 'long',
168
+ });
169
+ return (
170
+ <div key={closure.date} className="flex items-center gap-2 text-sm">
171
+ <span className="inline-block w-2 h-2 rounded-full bg-red-400 shrink-0" />
172
+ <span className="text-gray-700 capitalize">{dateStr}</span>
173
+ {reason && <span className="text-gray-400">— {reason}</span>}
174
+ </div>
175
+ );
176
+ })}
177
+ </div>
178
+ </div>
179
+ );
180
+ })()}
181
+ </section>
182
+ </div>
183
+ )}
184
+ </div>
185
+ );
186
+ }
@@ -0,0 +1,86 @@
1
+ import { NextIntlClientProvider } from 'next-intl';
2
+ import { getMessages, getTranslations } from 'next-intl/server';
3
+ import { notFound } from 'next/navigation';
4
+ import { routing } from '@/i18n/routing';
5
+ import { bestraw } from '@/lib/client';
6
+ import { Header } from '@/components/layout/Header';
7
+ import { Footer } from '@/components/layout/Footer';
8
+ import { CartProvider } from '@/providers/CartProvider';
9
+ import { CartDrawer } from '@/components/cart/CartDrawer';
10
+ import { hasOrdering } from '@/lib/features';
11
+ import '../globals.css';
12
+
13
+ export async function generateMetadata({
14
+ params,
15
+ }: {
16
+ params: Promise<{ locale: string }>;
17
+ }) {
18
+ const { locale } = await params;
19
+ const t = await getTranslations({ locale, namespace: 'metadata' });
20
+ return { title: t('title'), description: t('description') };
21
+ }
22
+
23
+ export default async function LocaleLayout({
24
+ children,
25
+ params,
26
+ }: {
27
+ children: React.ReactNode;
28
+ params: Promise<{ locale: string }>;
29
+ }) {
30
+ const { locale } = await params;
31
+
32
+ if (!routing.locales.includes(locale as any)) {
33
+ notFound();
34
+ }
35
+
36
+ const messages = await getMessages();
37
+
38
+ let restaurantName = 'Mon Restaurant';
39
+ let socialLinks: { platform: string; url: string }[] = [];
40
+ let themeStyle: Record<string, string> = {};
41
+
42
+ try {
43
+ const [info, theme] = await Promise.all([
44
+ bestraw.restaurant.getInfo(locale),
45
+ bestraw.restaurant.getTheme(),
46
+ ]);
47
+
48
+ restaurantName = info.name || restaurantName;
49
+ socialLinks = info.socialLinks ?? [];
50
+
51
+ themeStyle = {
52
+ '--color-primary': theme.primaryColor || '#000000',
53
+ '--color-accent': theme.accentColor || '#D4AF37',
54
+ '--font-family': theme.fontFamily || 'Inter, sans-serif',
55
+ };
56
+ } catch {
57
+ // API unavailable
58
+ }
59
+
60
+ return (
61
+ <html lang={locale} style={themeStyle as React.CSSProperties}>
62
+ <head>
63
+ <meta charSet="utf-8" />
64
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
65
+ </head>
66
+ <body className="min-h-screen flex flex-col bg-white text-gray-900">
67
+ <NextIntlClientProvider messages={messages}>
68
+ {hasOrdering ? (
69
+ <CartProvider>
70
+ <Header restaurantName={restaurantName} />
71
+ <CartDrawer />
72
+ <main className="flex-1">{children}</main>
73
+ <Footer restaurantName={restaurantName} socialLinks={socialLinks} />
74
+ </CartProvider>
75
+ ) : (
76
+ <>
77
+ <Header restaurantName={restaurantName} />
78
+ <main className="flex-1">{children}</main>
79
+ <Footer restaurantName={restaurantName} socialLinks={socialLinks} />
80
+ </>
81
+ )}
82
+ </NextIntlClientProvider>
83
+ </body>
84
+ </html>
85
+ );
86
+ }
@@ -0,0 +1,135 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import { useTranslations } from 'next-intl';
5
+ import { notFound } from 'next/navigation';
6
+ import { useRouter } from '@/i18n/routing';
7
+ import { useCustomerClient } from '@/lib/hooks/useCustomerClient';
8
+ import { hasLoyalty } from '@/lib/features';
9
+ import { PointsBalance } from '@/components/loyalty/PointsBalance';
10
+ import { RewardCard } from '@/components/loyalty/RewardCard';
11
+ import { TransactionHistory } from '@/components/loyalty/TransactionHistory';
12
+
13
+ interface LoyaltyAccount {
14
+ points: number;
15
+ tier: string;
16
+ totalPointsEarned: number;
17
+ totalPointsRedeemed: number;
18
+ }
19
+
20
+ interface Reward {
21
+ name: string;
22
+ description: string;
23
+ pointsCost: number;
24
+ type: string;
25
+ value: number;
26
+ active: boolean;
27
+ }
28
+
29
+ interface Transaction {
30
+ type: string;
31
+ points: number;
32
+ description: string;
33
+ createdAt: string;
34
+ }
35
+
36
+ export default function LoyaltyPage() {
37
+ if (!hasLoyalty) notFound();
38
+ const t = useTranslations('loyalty');
39
+ const router = useRouter();
40
+ const bestraw = useCustomerClient();
41
+
42
+ const [account, setAccount] = useState<LoyaltyAccount | null>(null);
43
+ const [rewards, setRewards] = useState<Reward[]>([]);
44
+ const [transactions, setTransactions] = useState<Transaction[]>([]);
45
+ const [loading, setLoading] = useState(true);
46
+
47
+ useEffect(() => {
48
+ const token = localStorage.getItem('bestraw-customer-token');
49
+ if (!token) {
50
+ router.push('/auth?redirect=/loyalty' as '/auth');
51
+ return;
52
+ }
53
+
54
+ async function fetchData() {
55
+ try {
56
+ const [accountData, transactionsData, programData] = await Promise.all([
57
+ bestraw.loyalty.getMyAccount(),
58
+ bestraw.loyalty.getMyTransactions(),
59
+ bestraw.loyalty.getProgram(),
60
+ ]);
61
+
62
+ setAccount(accountData as unknown as LoyaltyAccount);
63
+ setTransactions(
64
+ (transactionsData as unknown as Transaction[]) || [],
65
+ );
66
+
67
+ const program = programData as unknown as { rewards?: Reward[] };
68
+ setRewards(program.rewards || []);
69
+ } catch {
70
+ // If auth fails, redirect to login
71
+ localStorage.removeItem('bestraw-customer-token');
72
+ router.push('/auth?redirect=/loyalty' as '/auth');
73
+ } finally {
74
+ setLoading(false);
75
+ }
76
+ }
77
+
78
+ fetchData();
79
+ }, [router]);
80
+
81
+ // Rewards are now redeemed at checkout, not standalone
82
+
83
+ if (loading) {
84
+ return (
85
+ <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
86
+ <div className="animate-pulse space-y-6">
87
+ <div className="h-8 w-48 bg-gray-200 rounded" />
88
+ <div className="h-48 bg-gray-200 rounded-2xl" />
89
+ <div className="h-32 bg-gray-200 rounded-2xl" />
90
+ </div>
91
+ </div>
92
+ );
93
+ }
94
+
95
+ if (!account) return null;
96
+
97
+ const activeRewards = rewards.filter((r) => r.active);
98
+
99
+ return (
100
+ <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
101
+ <h1 className="text-3xl sm:text-4xl font-bold text-[var(--color-primary)] mb-8">
102
+ {t('title')}
103
+ </h1>
104
+
105
+ <div className="space-y-8">
106
+ <PointsBalance
107
+ points={account.points}
108
+ tier={account.tier}
109
+ totalEarned={account.totalPointsEarned}
110
+ totalRedeemed={account.totalPointsRedeemed}
111
+ />
112
+
113
+ {activeRewards.length > 0 && (
114
+ <div>
115
+ <h2 className="text-lg font-semibold text-gray-900 mb-4">
116
+ {t('rewards')}
117
+ </h2>
118
+ <div className="grid gap-4 sm:grid-cols-2">
119
+ {activeRewards.map((reward, index) => (
120
+ <RewardCard
121
+ key={index}
122
+ reward={reward}
123
+ currentPoints={account.points}
124
+ checkoutMode
125
+ />
126
+ ))}
127
+ </div>
128
+ </div>
129
+ )}
130
+
131
+ <TransactionHistory transactions={transactions} />
132
+ </div>
133
+ </div>
134
+ );
135
+ }
@@ -0,0 +1,69 @@
1
+ import { getTranslations } from 'next-intl/server';
2
+ import { bestraw } from '@/lib/client';
3
+ import { MenuContent } from '@/components/menu/MenuContent';
4
+ import { hasOrdering } from '@/lib/features';
5
+
6
+ export default async function MenuPage({
7
+ params,
8
+ }: {
9
+ params: Promise<{ locale: string }>;
10
+ }) {
11
+ const { locale } = await params;
12
+ const t = await getTranslations('menu');
13
+ const apiUrl =
14
+ process.env.API_URL ||
15
+ process.env.NEXT_PUBLIC_API_URL ||
16
+ 'http://localhost:1338';
17
+
18
+ let categories: Awaited<
19
+ ReturnType<typeof bestraw.menu.getActiveVariant>
20
+ >['categories'] = [];
21
+ let variantName = '';
22
+
23
+ try {
24
+ const menus = await bestraw.menu.getMenus(locale);
25
+
26
+ if (menus.length > 0) {
27
+ const menu = menus[0];
28
+ const variant = await bestraw.menu.getActiveVariant(
29
+ menu.documentId,
30
+ locale
31
+ );
32
+ variantName = variant.name;
33
+ categories = variant.categories ?? [];
34
+ }
35
+ } catch {
36
+ // API unavailable
37
+ }
38
+
39
+ return (
40
+ <div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
41
+ <div className="mb-10 flex items-center justify-between">
42
+ <div>
43
+ <h1 className="text-3xl sm:text-4xl font-bold text-[var(--color-primary)]">
44
+ {t('title')}
45
+ </h1>
46
+ {variantName && (
47
+ <p className="mt-2 text-gray-600">{variantName}</p>
48
+ )}
49
+ </div>
50
+ {hasOrdering && (
51
+ <a
52
+ href={`/${locale}/order/cart`}
53
+ className="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-[var(--color-accent)] text-white font-medium hover:opacity-90 transition-opacity"
54
+ >
55
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
56
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 100 4 2 2 0 000-4z" />
57
+ </svg>
58
+ {t('viewCart')}
59
+ </a>
60
+ )}
61
+ </div>
62
+
63
+ <MenuContent
64
+ categories={categories ?? []}
65
+ apiUrl={apiUrl}
66
+ />
67
+ </div>
68
+ );
69
+ }
@@ -0,0 +1,199 @@
1
+ 'use client';
2
+
3
+ import { useTranslations } from 'next-intl';
4
+ import { notFound } from 'next/navigation';
5
+ import { useCart } from '@/providers/CartProvider';
6
+ import { Link } from '@/i18n/routing';
7
+ import { useEffect, useState } from 'react';
8
+ import { BestrawClient } from 'bestraw-sdk';
9
+ import { hasOrdering } from '@/lib/features';
10
+
11
+ function formatPrice(price: number): string {
12
+ return price.toFixed(2).replace('.', ',') + ' \u20AC';
13
+ }
14
+
15
+ export default function CartPage() {
16
+ if (!hasOrdering) notFound();
17
+ const t = useTranslations('cart');
18
+ const { items, removeItem, updateQuantity, clearCart, total, itemCount } = useCart();
19
+ const [isAuthenticated, setIsAuthenticated] = useState(false);
20
+ const [orderingDisabled, setOrderingDisabled] = useState(false);
21
+
22
+ useEffect(() => {
23
+ const token = localStorage.getItem('bestraw-customer-token');
24
+ setIsAuthenticated(!!token);
25
+
26
+ const client = new BestrawClient({
27
+ baseUrl: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:1338',
28
+ });
29
+ client.ordering.getPublicOrderSettings()
30
+ .then((settings) => {
31
+ if (!settings.orderingEnabled || (!settings.takeawayEnabled && !settings.dineInEnabled)) {
32
+ setOrderingDisabled(true);
33
+ }
34
+ })
35
+ .catch(() => {});
36
+ client.restaurant.getStatus()
37
+ .then((status) => {
38
+ if (!status.isOpen) setOrderingDisabled(true);
39
+ })
40
+ .catch(() => {});
41
+ }, []);
42
+
43
+ if (itemCount === 0) {
44
+ return (
45
+ <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
46
+ <h1 className="text-3xl sm:text-4xl font-bold text-[var(--color-primary)]">
47
+ {t('title')}
48
+ </h1>
49
+ <div className="mt-8 text-center py-12">
50
+ <p className="text-gray-500 text-lg">{t('empty')}</p>
51
+ <Link
52
+ href="/menu"
53
+ className="mt-4 inline-block px-6 py-3 rounded-md bg-[var(--color-primary)] text-white font-medium hover:opacity-90 transition-opacity"
54
+ >
55
+ {t('continueShopping')}
56
+ </Link>
57
+ </div>
58
+ </div>
59
+ );
60
+ }
61
+
62
+ return (
63
+ <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
64
+ <div className="flex items-center justify-between mb-10">
65
+ <h1 className="text-3xl sm:text-4xl font-bold text-[var(--color-primary)]">
66
+ {t('title')}
67
+ </h1>
68
+ <button
69
+ type="button"
70
+ onClick={clearCart}
71
+ className="text-sm text-gray-500 hover:text-red-500 transition-colors"
72
+ >
73
+ {t('clearCart')}
74
+ </button>
75
+ </div>
76
+
77
+ <div className="space-y-4">
78
+ {items.map((item, index) => (
79
+ <div
80
+ key={`${item.mealDocumentId}-${index}`}
81
+ className="bg-white rounded-lg border border-gray-100 p-4 flex items-start gap-4"
82
+ >
83
+ {item.picture && (
84
+ <div className="w-20 h-20 rounded-lg overflow-hidden bg-gray-100 shrink-0">
85
+ <img
86
+ src={item.picture}
87
+ alt={item.mealName}
88
+ className="w-full h-full object-cover"
89
+ />
90
+ </div>
91
+ )}
92
+ <div className="flex-1 min-w-0">
93
+ <div className="flex items-start justify-between gap-2">
94
+ <h3 className="font-semibold text-[var(--color-primary)]">
95
+ {item.mealName}
96
+ </h3>
97
+ <span className="text-sm font-medium text-[var(--color-accent)] whitespace-nowrap">
98
+ {formatPrice(item.unitPrice * item.quantity)}
99
+ </span>
100
+ </div>
101
+
102
+ <p className="mt-0.5 text-xs text-gray-500">
103
+ {formatPrice(item.unitPrice)} x {item.quantity}
104
+ </p>
105
+
106
+ {item.selectedSauces && item.selectedSauces.length > 0 && (
107
+ <p className="mt-1 text-xs text-gray-500">
108
+ {t('sauces')}: {item.selectedSauces.join(', ')}
109
+ </p>
110
+ )}
111
+
112
+ {item.selectedSides && item.selectedSides.length > 0 && (
113
+ <p className="mt-0.5 text-xs text-gray-500">
114
+ {t('sides')}: {item.selectedSides.join(', ')}
115
+ </p>
116
+ )}
117
+
118
+ {item.specialInstructions && (
119
+ <p className="mt-0.5 text-xs text-gray-400 italic">
120
+ {item.specialInstructions}
121
+ </p>
122
+ )}
123
+
124
+ <div className="mt-3 flex items-center gap-3">
125
+ <div className="flex items-center border border-gray-200 rounded-md">
126
+ <button
127
+ type="button"
128
+ onClick={() =>
129
+ updateQuantity(index, Math.max(1, item.quantity - 1))
130
+ }
131
+ className="px-2 py-1 text-gray-500 hover:text-gray-700"
132
+ >
133
+ -
134
+ </button>
135
+ <span className="px-3 py-1 text-sm font-medium min-w-[2rem] text-center">
136
+ {item.quantity}
137
+ </span>
138
+ <button
139
+ type="button"
140
+ onClick={() => updateQuantity(index, item.quantity + 1)}
141
+ className="px-2 py-1 text-gray-500 hover:text-gray-700"
142
+ >
143
+ +
144
+ </button>
145
+ </div>
146
+
147
+ <button
148
+ type="button"
149
+ onClick={() => removeItem(index)}
150
+ className="text-sm text-red-400 hover:text-red-600 transition-colors"
151
+ >
152
+ {t('remove')}
153
+ </button>
154
+ </div>
155
+ </div>
156
+ </div>
157
+ ))}
158
+ </div>
159
+
160
+ {/* Totals */}
161
+ <div className="mt-8 border-t border-gray-200 pt-6">
162
+ <div className="flex items-center justify-between text-lg font-semibold">
163
+ <span className="text-[var(--color-primary)]">{t('total')}</span>
164
+ <span className="text-[var(--color-accent)]">{formatPrice(total)}</span>
165
+ </div>
166
+ </div>
167
+
168
+ {/* Actions */}
169
+ <div className="mt-8 flex flex-col sm:flex-row gap-3">
170
+ <Link
171
+ href="/menu"
172
+ className="flex-1 inline-block px-6 py-3 rounded-md border-2 border-[var(--color-primary)] text-[var(--color-primary)] font-medium text-center hover:bg-[var(--color-primary)] hover:text-white transition-all"
173
+ >
174
+ {t('continueShopping')}
175
+ </Link>
176
+
177
+ {orderingDisabled ? (
178
+ <div className="flex-1 px-6 py-3 rounded-md bg-amber-50 border border-amber-200 text-amber-800 text-sm font-medium text-center">
179
+ {t('orderingDisabled')}
180
+ </div>
181
+ ) : isAuthenticated ? (
182
+ <Link
183
+ href="/order/checkout"
184
+ className="flex-1 inline-block px-6 py-3 rounded-md bg-[var(--color-accent)] text-white font-medium text-center hover:opacity-90 transition-opacity"
185
+ >
186
+ {t('checkout')}
187
+ </Link>
188
+ ) : (
189
+ <Link
190
+ href="/auth"
191
+ className="flex-1 inline-block px-6 py-3 rounded-md bg-[var(--color-accent)] text-white font-medium text-center hover:opacity-90 transition-opacity"
192
+ >
193
+ {t('loginToOrder')}
194
+ </Link>
195
+ )}
196
+ </div>
197
+ </div>
198
+ );
199
+ }