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