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,207 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import { useTranslations } from 'next-intl';
5
+ import { notFound } from 'next/navigation';
6
+ import { Link } from '@/i18n/routing';
7
+ import { BestrawClient } from 'bestraw-sdk';
8
+ import type { Order } from 'bestraw-sdk';
9
+ import { OrderStatusTracker } from '@/components/order/OrderStatusTracker';
10
+ import { hasOrdering } from '@/lib/features';
11
+
12
+ const ACTIVE_STATUSES = ['pending', 'confirmed', 'preparing', 'ready'];
13
+
14
+ function formatPrice(price: number): string {
15
+ return price.toFixed(2).replace('.', ',') + ' \u20AC';
16
+ }
17
+
18
+ function formatDate(dateStr: string): string {
19
+ return new Date(dateStr).toLocaleDateString(undefined, {
20
+ day: 'numeric',
21
+ month: 'short',
22
+ hour: '2-digit',
23
+ minute: '2-digit',
24
+ });
25
+ }
26
+
27
+ export default function OrderPage() {
28
+ if (!hasOrdering) notFound();
29
+
30
+ const t = useTranslations('order');
31
+ const apiUrl =
32
+ process.env.NEXT_PUBLIC_API_URL || 'http://localhost:1338';
33
+
34
+ const [isAuthenticated, setIsAuthenticated] = useState(false);
35
+ const [orders, setOrders] = useState<Order[]>([]);
36
+ const [loading, setLoading] = useState(true);
37
+
38
+ useEffect(() => {
39
+ const token = localStorage.getItem('bestraw-customer-token');
40
+ if (!token) {
41
+ setIsAuthenticated(false);
42
+ setLoading(false);
43
+ return;
44
+ }
45
+
46
+ setIsAuthenticated(true);
47
+
48
+ const client = new BestrawClient({ baseUrl: apiUrl, apiToken: token });
49
+ client.ordering
50
+ .getMyOrders()
51
+ .then((data) => setOrders(data))
52
+ .catch(() => setOrders([]))
53
+ .finally(() => setLoading(false));
54
+ }, [apiUrl]);
55
+
56
+ const activeOrders = orders.filter((o) => ACTIVE_STATUSES.includes(o.status));
57
+ const pastOrders = orders.filter((o) => !ACTIVE_STATUSES.includes(o.status));
58
+
59
+ if (!isAuthenticated && !loading) {
60
+ return (
61
+ <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
62
+ <h1 className="text-3xl sm:text-4xl font-bold text-[var(--color-primary)]">
63
+ {t('myOrders')}
64
+ </h1>
65
+ <div className="mt-8 text-center py-12">
66
+ <p className="text-gray-500 text-lg">{t('loginRequired')}</p>
67
+ <Link
68
+ href="/auth"
69
+ className="mt-4 inline-block px-6 py-3 rounded-md bg-[var(--color-accent)] text-white font-medium hover:opacity-90 transition-opacity"
70
+ >
71
+ {t('loginRequired')}
72
+ </Link>
73
+ </div>
74
+ </div>
75
+ );
76
+ }
77
+
78
+ return (
79
+ <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
80
+ <div className="mb-10 flex items-center justify-between">
81
+ <h1 className="text-3xl sm:text-4xl font-bold text-[var(--color-primary)]">
82
+ {t('myOrders')}
83
+ </h1>
84
+ <Link
85
+ href="/menu"
86
+ 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"
87
+ >
88
+ {t('orderAgain')}
89
+ </Link>
90
+ </div>
91
+
92
+ {loading ? (
93
+ <div className="text-center py-12">
94
+ <div className="inline-block w-8 h-8 border-4 border-gray-200 border-t-[var(--color-accent)] rounded-full animate-spin" />
95
+ </div>
96
+ ) : orders.length === 0 ? (
97
+ <div className="text-center py-12">
98
+ <p className="text-gray-500 text-lg">{t('noOrders')}</p>
99
+ <Link
100
+ href="/menu"
101
+ className="mt-4 inline-block px-6 py-3 rounded-md bg-[var(--color-primary)] text-white font-medium hover:opacity-90 transition-opacity"
102
+ >
103
+ {t('orderAgain')}
104
+ </Link>
105
+ </div>
106
+ ) : (
107
+ <>
108
+ {/* Active orders */}
109
+ {activeOrders.length > 0 && (
110
+ <section className="mb-12">
111
+ <h2 className="text-xl font-semibold text-[var(--color-primary)] mb-4">
112
+ {t('activeOrders')}
113
+ </h2>
114
+ <div className="space-y-4">
115
+ {activeOrders.map((order) => (
116
+ <div
117
+ key={order.documentId}
118
+ className="bg-white rounded-lg border border-gray-100 p-6"
119
+ >
120
+ <div className="flex items-start justify-between mb-4">
121
+ <div>
122
+ <p className="font-semibold text-[var(--color-primary)]">
123
+ {order.orderNumber}
124
+ </p>
125
+ <p className="text-sm text-gray-500">
126
+ {formatDate(order.createdAt)}
127
+ </p>
128
+ </div>
129
+ <span className="text-sm font-medium text-[var(--color-accent)]">
130
+ {formatPrice(order.total)}
131
+ </span>
132
+ </div>
133
+
134
+ <div className="text-sm text-gray-600 mb-4">
135
+ {order.items.map((item, i) => (
136
+ <span key={i}>
137
+ {i > 0 && ', '}
138
+ {item.quantity}x {item.mealName}
139
+ </span>
140
+ ))}
141
+ </div>
142
+
143
+ <OrderStatusTracker
144
+ orderId={order.documentId}
145
+ initialStatus={order.status}
146
+ apiUrl={apiUrl}
147
+ />
148
+
149
+ <div className="mt-4 text-right">
150
+ <Link
151
+ href={`/order/confirmation/${order.documentId}`}
152
+ className="text-sm text-[var(--color-accent)] hover:underline"
153
+ >
154
+ {t('viewDetails')}
155
+ </Link>
156
+ </div>
157
+ </div>
158
+ ))}
159
+ </div>
160
+ </section>
161
+ )}
162
+
163
+ {/* Past orders */}
164
+ {pastOrders.length > 0 && (
165
+ <section>
166
+ <h2 className="text-xl font-semibold text-[var(--color-primary)] mb-4">
167
+ {t('pastOrders')}
168
+ </h2>
169
+ <div className="space-y-3">
170
+ {pastOrders.map((order) => (
171
+ <div
172
+ key={order.documentId}
173
+ className="bg-white rounded-lg border border-gray-100 p-4 flex items-center justify-between"
174
+ >
175
+ <div className="flex-1 min-w-0">
176
+ <div className="flex items-center gap-3">
177
+ <p className="font-medium text-[var(--color-primary)]">
178
+ {order.orderNumber}
179
+ </p>
180
+ <span
181
+ className={`text-xs px-2 py-0.5 rounded-full ${
182
+ order.status === 'completed'
183
+ ? 'bg-green-100 text-green-700'
184
+ : 'bg-red-100 text-red-600'
185
+ }`}
186
+ >
187
+ {t(`status.${order.status}`)}
188
+ </span>
189
+ </div>
190
+ <p className="text-sm text-gray-500 mt-0.5">
191
+ {formatDate(order.createdAt)} — {order.items.length}{' '}
192
+ {order.items.length > 1 ? 'articles' : 'article'}
193
+ </p>
194
+ </div>
195
+ <span className="text-sm font-medium text-gray-700 ml-4">
196
+ {formatPrice(order.total)}
197
+ </span>
198
+ </div>
199
+ ))}
200
+ </div>
201
+ </section>
202
+ )}
203
+ </>
204
+ )}
205
+ </div>
206
+ );
207
+ }
@@ -0,0 +1,119 @@
1
+ import { getTranslations } from 'next-intl/server';
2
+ import { bestraw } from '@/lib/client';
3
+ import { Link } from '@/i18n/routing';
4
+ import { hasBlog } from '@/lib/features';
5
+ import { ArticleCard } from '@/components/blog/ArticleCard';
6
+ import type { BlogArticle } from 'bestraw-sdk';
7
+
8
+ export default async function HomePage({
9
+ params,
10
+ }: {
11
+ params: Promise<{ locale: string }>;
12
+ }) {
13
+ const { locale } = await params;
14
+ const t = await getTranslations('home');
15
+
16
+ let restaurantName = 'Mon Restaurant';
17
+ let description = t('defaultDescription');
18
+ let coverImageUrl: string | null = null;
19
+
20
+ const apiUrl =
21
+ process.env.API_URL ||
22
+ process.env.NEXT_PUBLIC_API_URL ||
23
+ 'http://localhost:1338';
24
+
25
+ try {
26
+ const info = await bestraw.restaurant.getInfo(locale);
27
+ restaurantName = info.name || restaurantName;
28
+ description = info.description || description;
29
+
30
+ if (info.coverImage?.url) {
31
+ coverImageUrl = `${apiUrl}${info.coverImage.url}`;
32
+ }
33
+ } catch {
34
+ // API unavailable
35
+ }
36
+
37
+ let featuredArticles: BlogArticle[] = [];
38
+ if (hasBlog) {
39
+ try {
40
+ const articles = await bestraw.blog.getArticles({ locale, featured: true });
41
+ featuredArticles = articles.slice(0, 3);
42
+ } catch {
43
+ // API unavailable
44
+ }
45
+ }
46
+
47
+ return (
48
+ <>
49
+ {/* Hero section */}
50
+ <section className="relative bg-gray-900 text-white">
51
+ {coverImageUrl && (
52
+ <img
53
+ src={coverImageUrl}
54
+ alt={restaurantName}
55
+ className="absolute inset-0 w-full h-full object-cover opacity-50"
56
+ />
57
+ )}
58
+ <div className="relative max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-24 sm:py-32 text-center">
59
+ <h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight">
60
+ {restaurantName}
61
+ </h1>
62
+ <p className="mt-6 max-w-2xl mx-auto text-lg text-gray-200">
63
+ {description}
64
+ </p>
65
+ <div className="mt-10 flex flex-col sm:flex-row items-center justify-center gap-4">
66
+ <Link
67
+ href="/menu"
68
+ className="inline-flex items-center justify-center px-6 py-3 rounded-lg bg-[var(--color-accent)] text-white font-medium hover:opacity-90 transition-opacity"
69
+ >
70
+ {t('viewMenu')}
71
+ </Link>
72
+ <Link
73
+ href="/info"
74
+ className="inline-flex items-center justify-center px-6 py-3 rounded-lg border border-white text-white font-medium hover:bg-white/10 transition-colors"
75
+ >
76
+ {t('ourInfo')}
77
+ </Link>
78
+ </div>
79
+ </div>
80
+ </section>
81
+
82
+ {/* Intro section */}
83
+ <section className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center">
84
+ <h2 className="text-2xl sm:text-3xl font-bold text-[var(--color-primary)]">
85
+ {t('ourCuisine')}
86
+ </h2>
87
+ <p className="mt-4 text-gray-600 leading-relaxed">
88
+ {t('cuisineDescription')}
89
+ </p>
90
+ </section>
91
+
92
+ {/* Featured blog articles */}
93
+ {featuredArticles.length > 0 && (
94
+ <section className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 pb-16">
95
+ <div className="flex items-center justify-between mb-6">
96
+ <h2 className="text-2xl sm:text-3xl font-bold text-[var(--color-primary)]">
97
+ {t('latestArticles')}
98
+ </h2>
99
+ <Link
100
+ href="/blog"
101
+ className="text-sm font-medium text-[var(--color-accent)] hover:opacity-80 transition-opacity"
102
+ >
103
+ {t('viewAllArticles')}
104
+ </Link>
105
+ </div>
106
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
107
+ {featuredArticles.map((article) => (
108
+ <ArticleCard
109
+ key={article.documentId}
110
+ article={article}
111
+ apiUrl={apiUrl}
112
+ />
113
+ ))}
114
+ </div>
115
+ </section>
116
+ )}
117
+ </>
118
+ );
119
+ }
@@ -0,0 +1,11 @@
1
+ @import "tailwindcss";
2
+
3
+ :root {
4
+ --color-primary: #000000;
5
+ --color-accent: #D4AF37;
6
+ --font-family: 'Inter', sans-serif;
7
+ }
8
+
9
+ body {
10
+ font-family: var(--font-family), sans-serif;
11
+ }
@@ -0,0 +1,14 @@
1
+ import type { MetadataRoute } from 'next';
2
+
3
+ const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
4
+
5
+ export default function robots(): MetadataRoute.Robots {
6
+ return {
7
+ rules: {
8
+ userAgent: '*',
9
+ allow: '/',
10
+ disallow: ['/*/order/cart', '/*/order/checkout', '/*/order/confirmation/'],
11
+ },
12
+ sitemap: `${baseUrl}/sitemap.xml`,
13
+ };
14
+ }
@@ -0,0 +1,56 @@
1
+ import type { MetadataRoute } from 'next';
2
+ import { locales, defaultLocale } from '@/i18n/config';
3
+ import { hasOrdering, hasLoyalty, hasBlog, hasAuth } from '@/lib/features';
4
+ import { bestraw } from '@/lib/client';
5
+
6
+ const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
7
+
8
+ function localizedEntry(path: string, priority: number, changeFrequency: MetadataRoute.Sitemap[number]['changeFrequency'] = 'weekly'): MetadataRoute.Sitemap[number] {
9
+ const languages: Record<string, string> = {};
10
+ for (const locale of locales) {
11
+ languages[locale] = `${baseUrl}/${locale}${path}`;
12
+ }
13
+
14
+ return {
15
+ url: `${baseUrl}/${defaultLocale}${path}`,
16
+ lastModified: new Date(),
17
+ changeFrequency,
18
+ priority,
19
+ alternates: { languages },
20
+ };
21
+ }
22
+
23
+ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
24
+ const entries: MetadataRoute.Sitemap = [
25
+ localizedEntry('', 1.0, 'daily'),
26
+ localizedEntry('/menu', 0.9, 'daily'),
27
+ localizedEntry('/info', 0.7, 'monthly'),
28
+ ];
29
+
30
+ if (hasOrdering) {
31
+ entries.push(localizedEntry('/order', 0.6, 'weekly'));
32
+ }
33
+
34
+ if (hasLoyalty) {
35
+ entries.push(localizedEntry('/loyalty', 0.5, 'weekly'));
36
+ }
37
+
38
+ if (hasAuth) {
39
+ entries.push(localizedEntry('/auth', 0.3, 'monthly'));
40
+ }
41
+
42
+ if (hasBlog) {
43
+ entries.push(localizedEntry('/blog', 0.8, 'daily'));
44
+
45
+ try {
46
+ const articles = await bestraw.blog.getArticles();
47
+ for (const article of articles) {
48
+ entries.push(localizedEntry(`/blog/${article.slug}`, 0.7, 'weekly'));
49
+ }
50
+ } catch {
51
+ // API unavailable — static pages only
52
+ }
53
+ }
54
+
55
+ return entries;
56
+ }
@@ -0,0 +1,9 @@
1
+ export const config = {
2
+ features: {
3
+ ordering: true,
4
+ loyalty: true,
5
+ payments: true,
6
+ blog: true,
7
+ auth: { phone: true, email: true },
8
+ },
9
+ } as const;
@@ -0,0 +1,98 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
+
5
+ interface OtpFormProps {
6
+ length?: number;
7
+ onComplete: (code: string) => void;
8
+ }
9
+
10
+ export function OtpForm({ length = 6, onComplete }: OtpFormProps) {
11
+ const [values, setValues] = useState<string[]>(Array(length).fill(''));
12
+ const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
13
+
14
+ useEffect(() => {
15
+ inputRefs.current[0]?.focus();
16
+ }, []);
17
+
18
+ const handleChange = useCallback(
19
+ (index: number, value: string) => {
20
+ if (!/^\d*$/.test(value)) return;
21
+
22
+ const digit = value.slice(-1);
23
+ const newValues = [...values];
24
+ newValues[index] = digit;
25
+ setValues(newValues);
26
+
27
+ if (digit && index < length - 1) {
28
+ inputRefs.current[index + 1]?.focus();
29
+ }
30
+
31
+ if (digit && newValues.every((v) => v !== '')) {
32
+ onComplete(newValues.join(''));
33
+ }
34
+ },
35
+ [values, length, onComplete],
36
+ );
37
+
38
+ const handleKeyDown = useCallback(
39
+ (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
40
+ if (e.key === 'Backspace' && !values[index] && index > 0) {
41
+ const newValues = [...values];
42
+ newValues[index - 1] = '';
43
+ setValues(newValues);
44
+ inputRefs.current[index - 1]?.focus();
45
+ }
46
+ },
47
+ [values],
48
+ );
49
+
50
+ const handlePaste = useCallback(
51
+ (e: React.ClipboardEvent<HTMLInputElement>) => {
52
+ e.preventDefault();
53
+ const pasted = e.clipboardData
54
+ .getData('text')
55
+ .replace(/\D/g, '')
56
+ .slice(0, length);
57
+
58
+ if (pasted.length === 0) return;
59
+
60
+ const newValues = [...values];
61
+ for (let i = 0; i < pasted.length; i++) {
62
+ newValues[i] = pasted[i];
63
+ }
64
+ setValues(newValues);
65
+
66
+ if (pasted.length === length) {
67
+ onComplete(newValues.join(''));
68
+ } else {
69
+ inputRefs.current[pasted.length]?.focus();
70
+ }
71
+ },
72
+ [values, length, onComplete],
73
+ );
74
+
75
+ return (
76
+ <div className="flex gap-3 justify-center">
77
+ {values.map((value, index) => (
78
+ <input
79
+ key={index}
80
+ ref={(el) => {
81
+ inputRefs.current[index] = el;
82
+ }}
83
+ type="text"
84
+ inputMode="numeric"
85
+ autoComplete="one-time-code"
86
+ maxLength={1}
87
+ value={value}
88
+ onChange={(e) => handleChange(index, e.target.value)}
89
+ onKeyDown={(e) => handleKeyDown(index, e)}
90
+ onPaste={handlePaste}
91
+ className="w-12 h-14 text-center text-xl font-semibold border-2 border-gray-300 rounded-lg
92
+ focus:border-[var(--color-accent)] focus:outline-none focus:ring-2 focus:ring-[var(--color-accent)]/20
93
+ transition-colors"
94
+ />
95
+ ))}
96
+ </div>
97
+ );
98
+ }
@@ -0,0 +1,67 @@
1
+ 'use client';
2
+
3
+ import { useTranslations } from 'next-intl';
4
+ import { Link } from '@/i18n/routing';
5
+ import type { BlogArticle } from 'bestraw-sdk';
6
+
7
+ interface ArticleCardProps {
8
+ article: BlogArticle;
9
+ apiUrl: string;
10
+ }
11
+
12
+ export function ArticleCard({ article, apiUrl }: ArticleCardProps) {
13
+ const t = useTranslations('blog');
14
+ const date = article.publishDate
15
+ ? new Date(article.publishDate).toLocaleDateString()
16
+ : '';
17
+
18
+ return (
19
+ <Link
20
+ href={`/blog/${article.slug}`}
21
+ className="group block bg-white rounded-lg border border-gray-200 overflow-hidden hover:shadow-md transition-shadow"
22
+ >
23
+ {article.coverImage && (
24
+ <div className="relative aspect-video overflow-hidden">
25
+ <img
26
+ src={`${apiUrl}${article.coverImage.url}`}
27
+ alt={article.coverImage.alternativeText || article.title}
28
+ className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
29
+ />
30
+ {article.featured && (
31
+ <span className="absolute top-3 left-3 px-2 py-1 bg-[var(--color-accent)] text-white text-xs font-bold rounded-full">
32
+ {t('featured')}
33
+ </span>
34
+ )}
35
+ </div>
36
+ )}
37
+ <div className="p-5">
38
+ <div className="flex items-center gap-2">
39
+ {article.category && (
40
+ <span className="text-xs font-medium text-[var(--color-accent)] uppercase tracking-wide">
41
+ {article.category.name}
42
+ </span>
43
+ )}
44
+ {!article.coverImage && article.featured && (
45
+ <span className="px-2 py-0.5 bg-[var(--color-accent)] text-white text-xs font-bold rounded-full">
46
+ {t('featured')}
47
+ </span>
48
+ )}
49
+ </div>
50
+ <h3 className="mt-1 text-lg font-semibold text-gray-900 group-hover:text-[var(--color-primary)] transition-colors line-clamp-2">
51
+ {article.title}
52
+ </h3>
53
+ {article.excerpt && (
54
+ <p className="mt-2 text-sm text-gray-600 line-clamp-3">
55
+ {article.excerpt}
56
+ </p>
57
+ )}
58
+ <div className="mt-3 flex items-center gap-3 text-xs text-gray-500">
59
+ {date && <span>{date}</span>}
60
+ {article.readingTime && (
61
+ <span>{t('readingTime', { minutes: article.readingTime })}</span>
62
+ )}
63
+ </div>
64
+ </div>
65
+ </Link>
66
+ );
67
+ }
@@ -0,0 +1,14 @@
1
+ 'use client';
2
+
3
+ interface ArticleContentProps {
4
+ content: string;
5
+ }
6
+
7
+ export function ArticleContent({ content }: ArticleContentProps) {
8
+ return (
9
+ <div
10
+ className="prose prose-lg max-w-none prose-headings:text-gray-900 prose-a:text-[var(--color-primary)] prose-img:rounded-lg"
11
+ dangerouslySetInnerHTML={{ __html: content }}
12
+ />
13
+ );
14
+ }