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