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,152 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
import { useTranslations } from 'next-intl';
|
|
5
|
+
import { useCart } from '@/providers/CartProvider';
|
|
6
|
+
import { CartItem } from './CartItem';
|
|
7
|
+
import { Button } from '@/components/ui/Button';
|
|
8
|
+
|
|
9
|
+
function formatPrice(price: number): string {
|
|
10
|
+
return price.toFixed(2).replace('.', ',') + ' \u20AC';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function CartDrawer() {
|
|
14
|
+
const t = useTranslations('order');
|
|
15
|
+
const {
|
|
16
|
+
items,
|
|
17
|
+
removeItem,
|
|
18
|
+
updateQuantity,
|
|
19
|
+
total,
|
|
20
|
+
isOpen,
|
|
21
|
+
closeCart,
|
|
22
|
+
} = useCart();
|
|
23
|
+
|
|
24
|
+
// Lock body scroll when drawer is open
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (isOpen) {
|
|
27
|
+
document.body.style.overflow = 'hidden';
|
|
28
|
+
} else {
|
|
29
|
+
document.body.style.overflow = '';
|
|
30
|
+
}
|
|
31
|
+
return () => {
|
|
32
|
+
document.body.style.overflow = '';
|
|
33
|
+
};
|
|
34
|
+
}, [isOpen]);
|
|
35
|
+
|
|
36
|
+
// Close on Escape
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
39
|
+
if (e.key === 'Escape') closeCart();
|
|
40
|
+
}
|
|
41
|
+
if (isOpen) {
|
|
42
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
43
|
+
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
44
|
+
}
|
|
45
|
+
}, [isOpen, closeCart]);
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<>
|
|
49
|
+
{/* Overlay */}
|
|
50
|
+
<div
|
|
51
|
+
className={`fixed inset-0 z-40 bg-black/40 transition-opacity duration-300 ${
|
|
52
|
+
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
|
53
|
+
}`}
|
|
54
|
+
onClick={closeCart}
|
|
55
|
+
aria-hidden="true"
|
|
56
|
+
/>
|
|
57
|
+
|
|
58
|
+
{/* Drawer */}
|
|
59
|
+
<aside
|
|
60
|
+
className={`fixed top-0 right-0 z-50 h-full w-full max-w-md bg-white shadow-xl flex flex-col transition-transform duration-300 ease-in-out ${
|
|
61
|
+
isOpen ? 'translate-x-0' : 'translate-x-full'
|
|
62
|
+
}`}
|
|
63
|
+
role="dialog"
|
|
64
|
+
aria-modal="true"
|
|
65
|
+
aria-label={t('cart')}
|
|
66
|
+
>
|
|
67
|
+
{/* Header */}
|
|
68
|
+
<div className="flex items-center justify-between px-4 py-4 border-b border-gray-100">
|
|
69
|
+
<h2 className="text-lg font-bold text-[var(--color-primary)]">
|
|
70
|
+
{t('cart')}
|
|
71
|
+
</h2>
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
onClick={closeCart}
|
|
75
|
+
className="p-2 text-gray-500 hover:text-gray-800 transition-colors rounded-md hover:bg-gray-50"
|
|
76
|
+
aria-label={t('close')}
|
|
77
|
+
>
|
|
78
|
+
<svg
|
|
79
|
+
className="w-5 h-5"
|
|
80
|
+
fill="none"
|
|
81
|
+
stroke="currentColor"
|
|
82
|
+
viewBox="0 0 24 24"
|
|
83
|
+
>
|
|
84
|
+
<path
|
|
85
|
+
strokeLinecap="round"
|
|
86
|
+
strokeLinejoin="round"
|
|
87
|
+
strokeWidth={2}
|
|
88
|
+
d="M6 18L18 6M6 6l12 12"
|
|
89
|
+
/>
|
|
90
|
+
</svg>
|
|
91
|
+
</button>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
{/* Items */}
|
|
95
|
+
<div className="flex-1 overflow-y-auto px-4">
|
|
96
|
+
{items.length === 0 ? (
|
|
97
|
+
<div className="flex flex-col items-center justify-center h-full text-gray-400">
|
|
98
|
+
<svg
|
|
99
|
+
className="w-12 h-12 mb-3"
|
|
100
|
+
fill="none"
|
|
101
|
+
stroke="currentColor"
|
|
102
|
+
viewBox="0 0 24 24"
|
|
103
|
+
>
|
|
104
|
+
<path
|
|
105
|
+
strokeLinecap="round"
|
|
106
|
+
strokeLinejoin="round"
|
|
107
|
+
strokeWidth={1.5}
|
|
108
|
+
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"
|
|
109
|
+
/>
|
|
110
|
+
</svg>
|
|
111
|
+
<p className="text-sm">{t('emptyCart')}</p>
|
|
112
|
+
</div>
|
|
113
|
+
) : (
|
|
114
|
+
<div className="py-2">
|
|
115
|
+
{items.map((item, index) => (
|
|
116
|
+
<CartItem
|
|
117
|
+
key={`${item.mealDocumentId}-${index}`}
|
|
118
|
+
item={item}
|
|
119
|
+
index={index}
|
|
120
|
+
onUpdateQuantity={updateQuantity}
|
|
121
|
+
onRemove={removeItem}
|
|
122
|
+
/>
|
|
123
|
+
))}
|
|
124
|
+
</div>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
{/* Footer */}
|
|
129
|
+
{items.length > 0 && (
|
|
130
|
+
<div className="border-t border-gray-100 px-4 py-4 space-y-3">
|
|
131
|
+
<div className="flex items-center justify-between">
|
|
132
|
+
<span className="text-sm font-medium text-gray-700">
|
|
133
|
+
{t('subtotal')}
|
|
134
|
+
</span>
|
|
135
|
+
<span className="text-lg font-bold text-[var(--color-primary)]">
|
|
136
|
+
{formatPrice(total)}
|
|
137
|
+
</span>
|
|
138
|
+
</div>
|
|
139
|
+
<Button
|
|
140
|
+
variant="primary"
|
|
141
|
+
href="/order/cart"
|
|
142
|
+
className="w-full"
|
|
143
|
+
onClick={closeCart}
|
|
144
|
+
>
|
|
145
|
+
{t('checkout')}
|
|
146
|
+
</Button>
|
|
147
|
+
</div>
|
|
148
|
+
)}
|
|
149
|
+
</aside>
|
|
150
|
+
</>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { CartItem as CartItemType } from '@/providers/CartProvider';
|
|
4
|
+
|
|
5
|
+
interface CartItemProps {
|
|
6
|
+
item: CartItemType;
|
|
7
|
+
index: number;
|
|
8
|
+
onUpdateQuantity: (index: number, quantity: number) => void;
|
|
9
|
+
onRemove: (index: number) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function formatPrice(price: number): string {
|
|
13
|
+
return price.toFixed(2).replace('.', ',') + ' \u20AC';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function CartItem({
|
|
17
|
+
item,
|
|
18
|
+
index,
|
|
19
|
+
onUpdateQuantity,
|
|
20
|
+
onRemove,
|
|
21
|
+
}: CartItemProps) {
|
|
22
|
+
const lineTotal = item.unitPrice * item.quantity;
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className="flex gap-3 py-3 border-b border-gray-100 last:border-b-0">
|
|
26
|
+
{/* Thumbnail */}
|
|
27
|
+
{item.picture && (
|
|
28
|
+
<div className="w-14 h-14 rounded-md overflow-hidden bg-gray-100 shrink-0">
|
|
29
|
+
<img
|
|
30
|
+
src={item.picture}
|
|
31
|
+
alt={item.mealName}
|
|
32
|
+
className="w-full h-full object-cover"
|
|
33
|
+
/>
|
|
34
|
+
</div>
|
|
35
|
+
)}
|
|
36
|
+
|
|
37
|
+
{/* Details */}
|
|
38
|
+
<div className="flex-1 min-w-0">
|
|
39
|
+
<div className="flex items-start justify-between gap-2">
|
|
40
|
+
<h4 className="text-sm font-semibold text-gray-900 truncate">
|
|
41
|
+
{item.mealName}
|
|
42
|
+
</h4>
|
|
43
|
+
<button
|
|
44
|
+
type="button"
|
|
45
|
+
onClick={() => onRemove(index)}
|
|
46
|
+
className="shrink-0 p-1 text-gray-400 hover:text-red-500 transition-colors"
|
|
47
|
+
aria-label="Remove"
|
|
48
|
+
>
|
|
49
|
+
<svg
|
|
50
|
+
className="w-4 h-4"
|
|
51
|
+
fill="none"
|
|
52
|
+
stroke="currentColor"
|
|
53
|
+
viewBox="0 0 24 24"
|
|
54
|
+
>
|
|
55
|
+
<path
|
|
56
|
+
strokeLinecap="round"
|
|
57
|
+
strokeLinejoin="round"
|
|
58
|
+
strokeWidth={2}
|
|
59
|
+
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
|
60
|
+
/>
|
|
61
|
+
</svg>
|
|
62
|
+
</button>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<p className="text-xs text-gray-500 mt-0.5">
|
|
66
|
+
{formatPrice(item.unitPrice)}
|
|
67
|
+
</p>
|
|
68
|
+
|
|
69
|
+
{/* Selected sauces */}
|
|
70
|
+
{item.selectedSauces.length > 0 && (
|
|
71
|
+
<p className="text-xs text-gray-400 mt-0.5 truncate">
|
|
72
|
+
{item.selectedSauces.join(', ')}
|
|
73
|
+
</p>
|
|
74
|
+
)}
|
|
75
|
+
|
|
76
|
+
{/* Selected sides */}
|
|
77
|
+
{item.selectedSides.length > 0 && (
|
|
78
|
+
<p className="text-xs text-gray-400 truncate">
|
|
79
|
+
{item.selectedSides.join(', ')}
|
|
80
|
+
</p>
|
|
81
|
+
)}
|
|
82
|
+
|
|
83
|
+
{/* Quantity controls and line total */}
|
|
84
|
+
<div className="flex items-center justify-between mt-2">
|
|
85
|
+
<div className="flex items-center gap-2">
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
onClick={() => onUpdateQuantity(index, item.quantity - 1)}
|
|
89
|
+
className="w-7 h-7 flex items-center justify-center rounded-md border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors text-sm font-medium"
|
|
90
|
+
>
|
|
91
|
+
-
|
|
92
|
+
</button>
|
|
93
|
+
<span className="text-sm font-medium text-gray-900 w-5 text-center">
|
|
94
|
+
{item.quantity}
|
|
95
|
+
</span>
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
onClick={() => onUpdateQuantity(index, item.quantity + 1)}
|
|
99
|
+
className="w-7 h-7 flex items-center justify-center rounded-md border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors text-sm font-medium"
|
|
100
|
+
>
|
|
101
|
+
+
|
|
102
|
+
</button>
|
|
103
|
+
</div>
|
|
104
|
+
<span className="text-sm font-semibold text-[var(--color-accent)]">
|
|
105
|
+
{formatPrice(lineTotal)}
|
|
106
|
+
</span>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { useStripe, useElements, PaymentElement } from '@stripe/react-stripe-js';
|
|
5
|
+
import { useTranslations } from 'next-intl';
|
|
6
|
+
import { ErrorAlert } from '@/components/ui/ErrorAlert';
|
|
7
|
+
|
|
8
|
+
interface StripePaymentFormProps {
|
|
9
|
+
returnUrl: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function StripePaymentForm({ returnUrl }: StripePaymentFormProps) {
|
|
13
|
+
const t = useTranslations('checkout');
|
|
14
|
+
const stripe = useStripe();
|
|
15
|
+
const elements = useElements();
|
|
16
|
+
const [loading, setLoading] = useState(false);
|
|
17
|
+
const [error, setError] = useState('');
|
|
18
|
+
|
|
19
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
20
|
+
e.preventDefault();
|
|
21
|
+
if (!stripe || !elements) return;
|
|
22
|
+
|
|
23
|
+
setLoading(true);
|
|
24
|
+
setError('');
|
|
25
|
+
|
|
26
|
+
const { error: stripeError } = await stripe.confirmPayment({
|
|
27
|
+
elements,
|
|
28
|
+
confirmParams: {
|
|
29
|
+
return_url: returnUrl,
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
if (stripeError) {
|
|
34
|
+
setError(stripeError.message || t('paymentError'));
|
|
35
|
+
setLoading(false);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<form onSubmit={handleSubmit} className="space-y-6">
|
|
41
|
+
<PaymentElement />
|
|
42
|
+
|
|
43
|
+
{error && <ErrorAlert message={error} />}
|
|
44
|
+
|
|
45
|
+
<button
|
|
46
|
+
type="submit"
|
|
47
|
+
disabled={!stripe || loading}
|
|
48
|
+
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"
|
|
49
|
+
>
|
|
50
|
+
{loading ? t('processing') : t('payNow')}
|
|
51
|
+
</button>
|
|
52
|
+
</form>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { getTranslations } from 'next-intl/server';
|
|
2
|
+
import type { SocialLink } from 'bestraw-sdk';
|
|
3
|
+
|
|
4
|
+
interface FooterProps {
|
|
5
|
+
restaurantName: string;
|
|
6
|
+
socialLinks?: SocialLink[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function Footer({ restaurantName, socialLinks }: FooterProps) {
|
|
10
|
+
const t = await getTranslations('footer');
|
|
11
|
+
const year = new Date().getFullYear();
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<footer className="bg-gray-50 border-t border-gray-100">
|
|
15
|
+
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
16
|
+
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
|
17
|
+
<p className="text-sm text-gray-500">
|
|
18
|
+
{year} {restaurantName}. {t('rights')}
|
|
19
|
+
</p>
|
|
20
|
+
|
|
21
|
+
{socialLinks && socialLinks.length > 0 && (
|
|
22
|
+
<div className="flex items-center gap-4">
|
|
23
|
+
{socialLinks.map((link) => (
|
|
24
|
+
<a
|
|
25
|
+
key={link.platform}
|
|
26
|
+
href={link.url}
|
|
27
|
+
target="_blank"
|
|
28
|
+
rel="noopener noreferrer"
|
|
29
|
+
className="text-sm text-gray-500 hover:text-[var(--color-primary)] transition-colors capitalize"
|
|
30
|
+
>
|
|
31
|
+
{link.platform}
|
|
32
|
+
</a>
|
|
33
|
+
))}
|
|
34
|
+
</div>
|
|
35
|
+
)}
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
</footer>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { useTranslations } from 'next-intl';
|
|
5
|
+
import { Link, useRouter } from '@/i18n/routing';
|
|
6
|
+
import { LocaleSwitcher } from './LocaleSwitcher';
|
|
7
|
+
import { useCart } from '@/providers/CartProvider';
|
|
8
|
+
import { hasOrdering, hasLoyalty, hasBlog, hasAuth } from '@/lib/features';
|
|
9
|
+
import { getCustomerToken, removeCustomerToken } from '@/lib/hooks/useCustomerClient';
|
|
10
|
+
|
|
11
|
+
interface HeaderProps {
|
|
12
|
+
restaurantName: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function CartButton({ onClick }: { onClick: () => void }) {
|
|
16
|
+
const { itemCount } = useCart();
|
|
17
|
+
return (
|
|
18
|
+
<button
|
|
19
|
+
type="button"
|
|
20
|
+
onClick={onClick}
|
|
21
|
+
className="relative p-2 text-gray-700 hover:text-[var(--color-primary)] transition-colors"
|
|
22
|
+
aria-label="Cart"
|
|
23
|
+
>
|
|
24
|
+
<svg
|
|
25
|
+
className="w-5 h-5"
|
|
26
|
+
fill="none"
|
|
27
|
+
stroke="currentColor"
|
|
28
|
+
viewBox="0 0 24 24"
|
|
29
|
+
strokeWidth={2}
|
|
30
|
+
strokeLinecap="round"
|
|
31
|
+
strokeLinejoin="round"
|
|
32
|
+
>
|
|
33
|
+
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z" />
|
|
34
|
+
<line x1="3" y1="6" x2="21" y2="6" />
|
|
35
|
+
<path d="M16 10a4 4 0 01-8 0" />
|
|
36
|
+
</svg>
|
|
37
|
+
{itemCount > 0 && (
|
|
38
|
+
<span className="absolute -top-1 -right-1 w-5 h-5 bg-[var(--color-accent)] text-white text-xs font-bold rounded-full flex items-center justify-center">
|
|
39
|
+
{itemCount}
|
|
40
|
+
</span>
|
|
41
|
+
)}
|
|
42
|
+
</button>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function Header({ restaurantName }: HeaderProps) {
|
|
47
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
48
|
+
const [isLoggedIn, setIsLoggedIn] = useState(() => !!getCustomerToken());
|
|
49
|
+
const t = useTranslations('nav');
|
|
50
|
+
const router = useRouter();
|
|
51
|
+
const { openCart } = useCart();
|
|
52
|
+
|
|
53
|
+
function handleLogout() {
|
|
54
|
+
removeCustomerToken();
|
|
55
|
+
setIsLoggedIn(false);
|
|
56
|
+
setMenuOpen(false);
|
|
57
|
+
router.push('/');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<header className="sticky top-0 z-50 bg-white border-b border-gray-100">
|
|
62
|
+
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
63
|
+
<div className="flex items-center justify-between h-16">
|
|
64
|
+
<Link
|
|
65
|
+
href="/"
|
|
66
|
+
className="text-xl font-bold text-[var(--color-primary)] tracking-tight"
|
|
67
|
+
>
|
|
68
|
+
{restaurantName}
|
|
69
|
+
</Link>
|
|
70
|
+
|
|
71
|
+
{/* Desktop nav */}
|
|
72
|
+
<nav className="hidden md:flex items-center gap-8">
|
|
73
|
+
<Link
|
|
74
|
+
href="/menu"
|
|
75
|
+
className="text-sm font-medium text-gray-700 hover:text-[var(--color-primary)] transition-colors"
|
|
76
|
+
>
|
|
77
|
+
{t('menu')}
|
|
78
|
+
</Link>
|
|
79
|
+
{hasOrdering && (
|
|
80
|
+
<Link
|
|
81
|
+
href="/order"
|
|
82
|
+
className="text-sm font-medium text-gray-700 hover:text-[var(--color-primary)] transition-colors"
|
|
83
|
+
>
|
|
84
|
+
{t('order')}
|
|
85
|
+
</Link>
|
|
86
|
+
)}
|
|
87
|
+
{hasLoyalty && (
|
|
88
|
+
<Link
|
|
89
|
+
href="/loyalty"
|
|
90
|
+
className="text-sm font-medium text-gray-700 hover:text-[var(--color-primary)] transition-colors"
|
|
91
|
+
>
|
|
92
|
+
{t('loyalty')}
|
|
93
|
+
</Link>
|
|
94
|
+
)}
|
|
95
|
+
{hasBlog && (
|
|
96
|
+
<Link
|
|
97
|
+
href="/blog"
|
|
98
|
+
className="text-sm font-medium text-gray-700 hover:text-[var(--color-primary)] transition-colors"
|
|
99
|
+
>
|
|
100
|
+
{t('blog')}
|
|
101
|
+
</Link>
|
|
102
|
+
)}
|
|
103
|
+
<Link
|
|
104
|
+
href="/info"
|
|
105
|
+
className="text-sm font-medium text-gray-700 hover:text-[var(--color-primary)] transition-colors"
|
|
106
|
+
>
|
|
107
|
+
{t('info')}
|
|
108
|
+
</Link>
|
|
109
|
+
|
|
110
|
+
{hasOrdering && <CartButton onClick={openCart} />}
|
|
111
|
+
|
|
112
|
+
<LocaleSwitcher />
|
|
113
|
+
|
|
114
|
+
{hasAuth && (
|
|
115
|
+
isLoggedIn ? (
|
|
116
|
+
<button
|
|
117
|
+
type="button"
|
|
118
|
+
onClick={handleLogout}
|
|
119
|
+
className="text-sm font-medium text-gray-700 hover:text-[var(--color-primary)] transition-colors"
|
|
120
|
+
>
|
|
121
|
+
{t('logout')}
|
|
122
|
+
</button>
|
|
123
|
+
) : (
|
|
124
|
+
<Link
|
|
125
|
+
href="/auth"
|
|
126
|
+
className="text-sm font-medium text-gray-700 hover:text-[var(--color-primary)] transition-colors"
|
|
127
|
+
>
|
|
128
|
+
{t('login')}
|
|
129
|
+
</Link>
|
|
130
|
+
)
|
|
131
|
+
)}
|
|
132
|
+
</nav>
|
|
133
|
+
|
|
134
|
+
{/* Mobile: cart + menu button */}
|
|
135
|
+
<div className="md:hidden flex items-center gap-2">
|
|
136
|
+
{hasOrdering && <CartButton onClick={openCart} />}
|
|
137
|
+
|
|
138
|
+
<button
|
|
139
|
+
type="button"
|
|
140
|
+
className="p-2 text-gray-700"
|
|
141
|
+
onClick={() => setMenuOpen(!menuOpen)}
|
|
142
|
+
aria-label="Menu"
|
|
143
|
+
>
|
|
144
|
+
<svg
|
|
145
|
+
className="w-6 h-6"
|
|
146
|
+
fill="none"
|
|
147
|
+
stroke="currentColor"
|
|
148
|
+
viewBox="0 0 24 24"
|
|
149
|
+
>
|
|
150
|
+
{menuOpen ? (
|
|
151
|
+
<path
|
|
152
|
+
strokeLinecap="round"
|
|
153
|
+
strokeLinejoin="round"
|
|
154
|
+
strokeWidth={2}
|
|
155
|
+
d="M6 18L18 6M6 6l12 12"
|
|
156
|
+
/>
|
|
157
|
+
) : (
|
|
158
|
+
<path
|
|
159
|
+
strokeLinecap="round"
|
|
160
|
+
strokeLinejoin="round"
|
|
161
|
+
strokeWidth={2}
|
|
162
|
+
d="M4 6h16M4 12h16M4 18h16"
|
|
163
|
+
/>
|
|
164
|
+
)}
|
|
165
|
+
</svg>
|
|
166
|
+
</button>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
{/* Mobile nav */}
|
|
171
|
+
{menuOpen && (
|
|
172
|
+
<nav className="md:hidden pb-4 flex flex-col gap-3">
|
|
173
|
+
<Link
|
|
174
|
+
href="/menu"
|
|
175
|
+
className="text-sm font-medium text-gray-700 hover:text-[var(--color-primary)] transition-colors"
|
|
176
|
+
onClick={() => setMenuOpen(false)}
|
|
177
|
+
>
|
|
178
|
+
{t('menu')}
|
|
179
|
+
</Link>
|
|
180
|
+
{hasOrdering && (
|
|
181
|
+
<Link
|
|
182
|
+
href="/order"
|
|
183
|
+
className="text-sm font-medium text-gray-700 hover:text-[var(--color-primary)] transition-colors"
|
|
184
|
+
onClick={() => setMenuOpen(false)}
|
|
185
|
+
>
|
|
186
|
+
{t('order')}
|
|
187
|
+
</Link>
|
|
188
|
+
)}
|
|
189
|
+
{hasLoyalty && (
|
|
190
|
+
<Link
|
|
191
|
+
href="/loyalty"
|
|
192
|
+
className="text-sm font-medium text-gray-700 hover:text-[var(--color-primary)] transition-colors"
|
|
193
|
+
onClick={() => setMenuOpen(false)}
|
|
194
|
+
>
|
|
195
|
+
{t('loyalty')}
|
|
196
|
+
</Link>
|
|
197
|
+
)}
|
|
198
|
+
{hasBlog && (
|
|
199
|
+
<Link
|
|
200
|
+
href="/blog"
|
|
201
|
+
className="text-sm font-medium text-gray-700 hover:text-[var(--color-primary)] transition-colors"
|
|
202
|
+
onClick={() => setMenuOpen(false)}
|
|
203
|
+
>
|
|
204
|
+
{t('blog')}
|
|
205
|
+
</Link>
|
|
206
|
+
)}
|
|
207
|
+
<Link
|
|
208
|
+
href="/info"
|
|
209
|
+
className="text-sm font-medium text-gray-700 hover:text-[var(--color-primary)] transition-colors"
|
|
210
|
+
onClick={() => setMenuOpen(false)}
|
|
211
|
+
>
|
|
212
|
+
{t('info')}
|
|
213
|
+
</Link>
|
|
214
|
+
<LocaleSwitcher />
|
|
215
|
+
|
|
216
|
+
{hasAuth && (
|
|
217
|
+
isLoggedIn ? (
|
|
218
|
+
<button
|
|
219
|
+
type="button"
|
|
220
|
+
onClick={handleLogout}
|
|
221
|
+
className="text-sm font-medium text-gray-700 hover:text-[var(--color-primary)] transition-colors"
|
|
222
|
+
>
|
|
223
|
+
{t('logout')}
|
|
224
|
+
</button>
|
|
225
|
+
) : (
|
|
226
|
+
<Link
|
|
227
|
+
href="/auth"
|
|
228
|
+
className="text-sm font-medium text-gray-700 hover:text-[var(--color-primary)] transition-colors"
|
|
229
|
+
onClick={() => setMenuOpen(false)}
|
|
230
|
+
>
|
|
231
|
+
{t('login')}
|
|
232
|
+
</Link>
|
|
233
|
+
)
|
|
234
|
+
)}
|
|
235
|
+
</nav>
|
|
236
|
+
)}
|
|
237
|
+
</div>
|
|
238
|
+
</header>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useLocale } from 'next-intl';
|
|
4
|
+
import { usePathname, useRouter } from '@/i18n/routing';
|
|
5
|
+
import { routing } from '@/i18n/routing';
|
|
6
|
+
|
|
7
|
+
export function LocaleSwitcher() {
|
|
8
|
+
const locale = useLocale();
|
|
9
|
+
const pathname = usePathname();
|
|
10
|
+
const router = useRouter();
|
|
11
|
+
|
|
12
|
+
function onChange(nextLocale: string) {
|
|
13
|
+
router.replace(pathname, { locale: nextLocale });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<div className="flex items-center gap-1">
|
|
18
|
+
{routing.locales.map((l) => (
|
|
19
|
+
<button
|
|
20
|
+
key={l}
|
|
21
|
+
onClick={() => onChange(l)}
|
|
22
|
+
disabled={l === locale}
|
|
23
|
+
className={`px-2 py-1 text-xs font-medium rounded transition-colors ${
|
|
24
|
+
l === locale
|
|
25
|
+
? 'bg-[var(--color-primary)] text-white'
|
|
26
|
+
: 'text-gray-500 hover:text-[var(--color-primary)]'
|
|
27
|
+
}`}
|
|
28
|
+
>
|
|
29
|
+
{l.toUpperCase()}
|
|
30
|
+
</button>
|
|
31
|
+
))}
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|