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