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,17 @@
1
+ #!/bin/sh
2
+ basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
3
+
4
+ case `uname` in
5
+ *CYGWIN*) basedir=`cygpath -w "$basedir"`;;
6
+ esac
7
+
8
+ if [ -z "$NODE_PATH" ]; then
9
+ export NODE_PATH="/home/runner/work/bestraw/bestraw/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/home/runner/work/bestraw/bestraw/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/home/runner/work/bestraw/bestraw/node_modules/.pnpm/typescript@5.9.3/node_modules:/home/runner/work/bestraw/bestraw/node_modules/.pnpm/node_modules"
10
+ else
11
+ export NODE_PATH="/home/runner/work/bestraw/bestraw/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/node_modules:/home/runner/work/bestraw/bestraw/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/home/runner/work/bestraw/bestraw/node_modules/.pnpm/typescript@5.9.3/node_modules:/home/runner/work/bestraw/bestraw/node_modules/.pnpm/node_modules:$NODE_PATH"
12
+ fi
13
+ if [ -x "$basedir/node" ]; then
14
+ exec "$basedir/node" "$basedir/../typescript/bin/tsserver" "$@"
15
+ else
16
+ exec node "$basedir/../typescript/bin/tsserver" "$@"
17
+ fi
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "bestraw-web",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev --port 3000",
7
+ "build": "next build",
8
+ "start": "next start",
9
+ "lint": "next lint"
10
+ },
11
+ "dependencies": {
12
+ "@stripe/react-stripe-js": "^3.1.0",
13
+ "@stripe/stripe-js": "^5.5.0",
14
+ "bestraw-sdk": "workspace:*",
15
+ "next": "^15.0.0",
16
+ "next-intl": "^4.8.3",
17
+ "react": "^19.0.0",
18
+ "react-dom": "^19.0.0"
19
+ },
20
+ "devDependencies": {
21
+ "@tailwindcss/postcss": "^4.0.0",
22
+ "@types/node": "^22.0.0",
23
+ "@types/react": "^19.0.0",
24
+ "@types/react-dom": "^19.0.0",
25
+ "tailwindcss": "^4.0.0",
26
+ "typescript": "^5.0.0"
27
+ }
28
+ }
@@ -0,0 +1,8 @@
1
+ /** @type {import('postcss-load-config').Config} */
2
+ const config = {
3
+ plugins: {
4
+ '@tailwindcss/postcss': {},
5
+ },
6
+ };
7
+
8
+ export default config;
File without changes
@@ -0,0 +1,222 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { useTranslations } from 'next-intl';
5
+ import { notFound } from 'next/navigation';
6
+ import { useSearchParams } from 'next/navigation';
7
+ import { useRouter } from '@/i18n/routing';
8
+ import { useCustomerClient, setCustomerToken } from '@/lib/hooks/useCustomerClient';
9
+ import { OtpForm } from '@/components/auth/OtpForm';
10
+ import { ErrorAlert } from '@/components/ui/ErrorAlert';
11
+ import { getErrorMessage } from '@/lib/errors';
12
+ import { hasAuth, hasPhoneAuth, hasEmailAuth } from '@/lib/features';
13
+
14
+ type Step = 'identifier' | 'otp';
15
+ type AuthMethod = 'phone' | 'email';
16
+
17
+ export default function AuthPage() {
18
+ if (!hasAuth) notFound();
19
+
20
+ const t = useTranslations('auth');
21
+ const tErrors = useTranslations('errors');
22
+ const router = useRouter();
23
+ const searchParams = useSearchParams();
24
+ const bestraw = useCustomerClient();
25
+ const redirectTo = searchParams.get('redirect') || '/order';
26
+
27
+ const defaultMethod: AuthMethod = hasEmailAuth ? 'email' : 'phone';
28
+
29
+ const [step, setStep] = useState<Step>('identifier');
30
+ const [authMethod, setAuthMethod] = useState<AuthMethod>(defaultMethod);
31
+ const [phone, setPhone] = useState('');
32
+ const [countryCode, setCountryCode] = useState('+33');
33
+ const [email, setEmail] = useState('');
34
+ const [error, setError] = useState('');
35
+ const [loading, setLoading] = useState(false);
36
+
37
+ const fullPhone = `${countryCode}${phone.replace(/\s/g, '')}`;
38
+
39
+ async function handleRequestOtp(e: React.FormEvent) {
40
+ e.preventDefault();
41
+ setError('');
42
+ setLoading(true);
43
+
44
+ try {
45
+ if (authMethod === 'phone') {
46
+ const cleaned = phone.replace(/\s/g, '');
47
+ if (!cleaned || cleaned.length < 6) {
48
+ setError(tErrors('INVALID_PHONE'));
49
+ setLoading(false);
50
+ return;
51
+ }
52
+ await bestraw.customer.requestOtp(fullPhone);
53
+ } else {
54
+ if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
55
+ setError(tErrors('INVALID_EMAIL'));
56
+ setLoading(false);
57
+ return;
58
+ }
59
+ await bestraw.customer.requestEmailOtp(email);
60
+ }
61
+ setStep('otp');
62
+ } catch (err: unknown) {
63
+ setError(getErrorMessage(err, tErrors));
64
+ } finally {
65
+ setLoading(false);
66
+ }
67
+ }
68
+
69
+ async function handleVerifyOtp(code: string) {
70
+ setError('');
71
+ setLoading(true);
72
+ try {
73
+ let result;
74
+ if (authMethod === 'phone') {
75
+ result = await bestraw.customer.verifyOtp(fullPhone, code);
76
+ } else {
77
+ result = await bestraw.customer.verifyEmailOtp(email, code);
78
+ }
79
+ const token = (result as { token: string }).token;
80
+ setCustomerToken(token);
81
+ router.push(redirectTo as '/order');
82
+ } catch (err: unknown) {
83
+ setError(getErrorMessage(err, tErrors));
84
+ setLoading(false);
85
+ }
86
+ }
87
+
88
+ const showToggle = hasPhoneAuth && hasEmailAuth;
89
+
90
+ return (
91
+ <div className="min-h-[calc(100vh-8rem)] flex items-center justify-center px-4">
92
+ <div className="w-full max-w-sm">
93
+ <h1 className="text-2xl font-bold text-center text-[var(--color-primary)] mb-8">
94
+ {t('title')}
95
+ </h1>
96
+
97
+ {/* Auth method toggle */}
98
+ {showToggle && step === 'identifier' && (
99
+ <div className="flex rounded-lg border border-gray-200 mb-6 overflow-hidden">
100
+ <button
101
+ type="button"
102
+ onClick={() => { setAuthMethod('email'); setError(''); }}
103
+ className={`flex-1 py-2.5 text-sm font-medium transition-colors ${
104
+ authMethod === 'email'
105
+ ? 'bg-[var(--color-primary)] text-white'
106
+ : 'text-gray-600 hover:bg-gray-50'
107
+ }`}
108
+ >
109
+ {t('useEmail')}
110
+ </button>
111
+ <button
112
+ type="button"
113
+ onClick={() => { setAuthMethod('phone'); setError(''); }}
114
+ className={`flex-1 py-2.5 text-sm font-medium transition-colors ${
115
+ authMethod === 'phone'
116
+ ? 'bg-[var(--color-primary)] text-white'
117
+ : 'text-gray-600 hover:bg-gray-50'
118
+ }`}
119
+ >
120
+ {t('usePhone')}
121
+ </button>
122
+ </div>
123
+ )}
124
+
125
+ {step === 'identifier' && (
126
+ <form onSubmit={handleRequestOtp} className="space-y-6">
127
+ {authMethod === 'phone' ? (
128
+ <div>
129
+ <label
130
+ htmlFor="phone"
131
+ className="block text-sm font-medium text-gray-700 mb-2"
132
+ >
133
+ {t('phone')}
134
+ </label>
135
+ <div className="flex gap-2">
136
+ <select
137
+ value={countryCode}
138
+ onChange={(e) => setCountryCode(e.target.value)}
139
+ className="w-24 px-3 py-3 border border-gray-300 rounded-lg text-sm focus:border-[var(--color-accent)] focus:outline-none focus:ring-2 focus:ring-[var(--color-accent)]/20"
140
+ >
141
+ <option value="+33">+33</option>
142
+ <option value="+1">+1</option>
143
+ <option value="+44">+44</option>
144
+ <option value="+49">+49</option>
145
+ <option value="+34">+34</option>
146
+ <option value="+39">+39</option>
147
+ <option value="+81">+81</option>
148
+ </select>
149
+ <input
150
+ id="phone"
151
+ type="tel"
152
+ value={phone}
153
+ onChange={(e) => setPhone(e.target.value)}
154
+ placeholder={t('phonePlaceholder')}
155
+ className="flex-1 px-4 py-3 border border-gray-300 rounded-lg text-sm focus:border-[var(--color-accent)] focus:outline-none focus:ring-2 focus:ring-[var(--color-accent)]/20"
156
+ />
157
+ </div>
158
+ </div>
159
+ ) : (
160
+ <div>
161
+ <label
162
+ htmlFor="email"
163
+ className="block text-sm font-medium text-gray-700 mb-2"
164
+ >
165
+ {t('email')}
166
+ </label>
167
+ <input
168
+ id="email"
169
+ type="email"
170
+ value={email}
171
+ onChange={(e) => setEmail(e.target.value)}
172
+ placeholder={t('emailPlaceholder')}
173
+ className="w-full px-4 py-3 border border-gray-300 rounded-lg text-sm focus:border-[var(--color-accent)] focus:outline-none focus:ring-2 focus:ring-[var(--color-accent)]/20"
174
+ />
175
+ </div>
176
+ )}
177
+
178
+ {error && <ErrorAlert message={error} />}
179
+
180
+ <button
181
+ type="submit"
182
+ disabled={loading}
183
+ className="w-full py-3 bg-[var(--color-primary)] text-white rounded-lg font-medium hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
184
+ >
185
+ {loading ? '...' : t('sendCode')}
186
+ </button>
187
+ </form>
188
+ )}
189
+
190
+ {step === 'otp' && (
191
+ <div className="space-y-6">
192
+ <p className="text-sm text-gray-600 text-center">
193
+ {authMethod === 'phone' ? t('otpSent') : t('otpSentEmail')}
194
+ </p>
195
+ <p className="text-sm font-medium text-center">
196
+ {authMethod === 'phone' ? t('enterCode') : t('enterCodeEmail')}
197
+ </p>
198
+
199
+ <OtpForm length={6} onComplete={handleVerifyOtp} />
200
+
201
+ {error && <ErrorAlert message={error} />}
202
+
203
+ {loading && (
204
+ <p className="text-sm text-gray-500 text-center">...</p>
205
+ )}
206
+
207
+ <button
208
+ type="button"
209
+ onClick={() => {
210
+ setStep('identifier');
211
+ setError('');
212
+ }}
213
+ className="w-full py-3 text-sm text-gray-600 hover:text-[var(--color-primary)] transition-colors"
214
+ >
215
+ {t('back')}
216
+ </button>
217
+ </div>
218
+ )}
219
+ </div>
220
+ </div>
221
+ );
222
+ }
@@ -0,0 +1,104 @@
1
+ import { getTranslations } from 'next-intl/server';
2
+ import { notFound } from 'next/navigation';
3
+ import { bestraw } from '@/lib/client';
4
+ import { ArticleContent } from '@/components/blog/ArticleContent';
5
+ import { hasBlog } from '@/lib/features';
6
+ import type { BlogArticle } from 'bestraw-sdk';
7
+
8
+ export default async function BlogArticlePage({
9
+ params,
10
+ }: {
11
+ params: Promise<{ locale: string; slug: string }>;
12
+ }) {
13
+ if (!hasBlog) notFound();
14
+
15
+ const { locale, slug } = await params;
16
+ const t = await getTranslations('blog');
17
+ const apiUrl =
18
+ process.env.API_URL ||
19
+ process.env.NEXT_PUBLIC_API_URL ||
20
+ 'http://localhost:1338';
21
+
22
+ let article: BlogArticle | null = null;
23
+
24
+ try {
25
+ article = await bestraw.blog.getArticle(slug, locale);
26
+ } catch {
27
+ // API unavailable or article not found
28
+ }
29
+
30
+ if (!article) {
31
+ notFound();
32
+ }
33
+
34
+ const date = article.publishDate
35
+ ? new Date(article.publishDate).toLocaleDateString()
36
+ : '';
37
+
38
+ return (
39
+ <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
40
+ <a
41
+ href={`/${locale}/blog`}
42
+ className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-[var(--color-primary)] transition-colors mb-8"
43
+ >
44
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
45
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
46
+ </svg>
47
+ {t('backToBlog')}
48
+ </a>
49
+
50
+ {/* Cover image */}
51
+ {article.coverImage && (
52
+ <div className="rounded-xl overflow-hidden mb-8">
53
+ <img
54
+ src={`${apiUrl}${article.coverImage.url}`}
55
+ alt={article.coverImage.alternativeText || article.title}
56
+ className="w-full h-64 sm:h-96 object-cover"
57
+ />
58
+ </div>
59
+ )}
60
+
61
+ {/* Header */}
62
+ <header className="mb-8">
63
+ {article.category && (
64
+ <span className="text-sm font-medium text-[var(--color-accent)] uppercase tracking-wide">
65
+ {article.category.name}
66
+ </span>
67
+ )}
68
+ <h1 className="mt-2 text-3xl sm:text-4xl font-bold text-gray-900">
69
+ {article.title}
70
+ </h1>
71
+ <div className="mt-4 flex flex-wrap items-center gap-4 text-sm text-gray-500">
72
+ {article.authorName && (
73
+ <span>
74
+ {t('by')} {article.authorName}
75
+ </span>
76
+ )}
77
+ {date && (
78
+ <span>
79
+ {t('publishedOn')} {date}
80
+ </span>
81
+ )}
82
+ {article.readingTime && (
83
+ <span>{t('readingTime', { minutes: article.readingTime })}</span>
84
+ )}
85
+ </div>
86
+ {article.tags && article.tags.length > 0 && (
87
+ <div className="mt-4 flex flex-wrap gap-2">
88
+ {article.tags.map((tag) => (
89
+ <span
90
+ key={tag}
91
+ className="px-3 py-1 bg-gray-100 text-gray-600 text-xs font-medium rounded-full"
92
+ >
93
+ {tag}
94
+ </span>
95
+ ))}
96
+ </div>
97
+ )}
98
+ </header>
99
+
100
+ {/* Content */}
101
+ {article.content && <ArticleContent content={article.content} />}
102
+ </div>
103
+ );
104
+ }
@@ -0,0 +1,90 @@
1
+ import { getTranslations } from 'next-intl/server';
2
+ import { notFound } from 'next/navigation';
3
+ import { bestraw } from '@/lib/client';
4
+ import { ArticleCard } from '@/components/blog/ArticleCard';
5
+ import { hasBlog } from '@/lib/features';
6
+ import type { BlogArticle, BlogCategory } from 'bestraw-sdk';
7
+
8
+ export default async function BlogPage({
9
+ params,
10
+ searchParams,
11
+ }: {
12
+ params: Promise<{ locale: string }>;
13
+ searchParams: Promise<{ category?: string }>;
14
+ }) {
15
+ if (!hasBlog) notFound();
16
+
17
+ const { locale } = await params;
18
+ const { category: categoryFilter } = await searchParams;
19
+ const t = await getTranslations('blog');
20
+ const apiUrl =
21
+ process.env.API_URL ||
22
+ process.env.NEXT_PUBLIC_API_URL ||
23
+ 'http://localhost:1338';
24
+
25
+ let articles: BlogArticle[] = [];
26
+ let categories: BlogCategory[] = [];
27
+
28
+ try {
29
+ const [fetchedArticles, fetchedCategories] = await Promise.all([
30
+ bestraw.blog.getArticles({ locale, category: categoryFilter }),
31
+ bestraw.blog.getCategories(locale),
32
+ ]);
33
+ articles = fetchedArticles;
34
+ categories = fetchedCategories;
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
+ <h1 className="text-3xl sm:text-4xl font-bold text-[var(--color-primary)] mb-8">
42
+ {t('title')}
43
+ </h1>
44
+
45
+ {/* Category filter */}
46
+ {categories.length > 0 && (
47
+ <div className="flex flex-wrap gap-2 mb-8">
48
+ <a
49
+ href={`/${locale}/blog`}
50
+ className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
51
+ !categoryFilter
52
+ ? 'bg-[var(--color-primary)] text-white'
53
+ : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
54
+ }`}
55
+ >
56
+ {t('allCategories')}
57
+ </a>
58
+ {categories.map((cat) => (
59
+ <a
60
+ key={cat.documentId}
61
+ href={`/${locale}/blog?category=${cat.slug}`}
62
+ className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
63
+ categoryFilter === cat.slug
64
+ ? 'bg-[var(--color-primary)] text-white'
65
+ : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
66
+ }`}
67
+ >
68
+ {cat.name}
69
+ </a>
70
+ ))}
71
+ </div>
72
+ )}
73
+
74
+ {/* Articles grid */}
75
+ {articles.length > 0 ? (
76
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
77
+ {articles.map((article) => (
78
+ <ArticleCard
79
+ key={article.documentId}
80
+ article={article}
81
+ apiUrl={apiUrl}
82
+ />
83
+ ))}
84
+ </div>
85
+ ) : (
86
+ <p className="text-center text-gray-500 py-12">{t('noArticles')}</p>
87
+ )}
88
+ </div>
89
+ );
90
+ }
@@ -0,0 +1,41 @@
1
+ 'use client';
2
+
3
+ import { useTranslations } from 'next-intl';
4
+
5
+ export default function ErrorPage({
6
+ reset,
7
+ }: {
8
+ error: Error & { digest?: string };
9
+ reset: () => void;
10
+ }) {
11
+ const t = useTranslations('errors');
12
+
13
+ return (
14
+ <div className="min-h-[calc(100vh-8rem)] flex items-center justify-center px-4">
15
+ <div className="text-center max-w-sm">
16
+ <div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-red-100 mb-4">
17
+ <svg
18
+ className="w-8 h-8 text-red-500"
19
+ fill="none"
20
+ stroke="currentColor"
21
+ viewBox="0 0 24 24"
22
+ >
23
+ <path
24
+ strokeLinecap="round"
25
+ strokeLinejoin="round"
26
+ strokeWidth={2}
27
+ d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
28
+ />
29
+ </svg>
30
+ </div>
31
+ <p className="text-gray-600 mb-6">{t('unknown')}</p>
32
+ <button
33
+ onClick={reset}
34
+ className="px-6 py-3 rounded-md bg-[var(--color-accent)] text-white font-medium hover:opacity-90 transition-opacity"
35
+ >
36
+ {t('retry')}
37
+ </button>
38
+ </div>
39
+ </div>
40
+ );
41
+ }