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