create-brainerce-store 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/dist/index.d.ts +1 -0
- package/dist/index.js +502 -0
- package/package.json +44 -0
- package/templates/nextjs/base/.env.local.ejs +3 -0
- package/templates/nextjs/base/.eslintrc.json +3 -0
- package/templates/nextjs/base/gitignore +30 -0
- package/templates/nextjs/base/next.config.ts +9 -0
- package/templates/nextjs/base/package.json.ejs +30 -0
- package/templates/nextjs/base/postcss.config.mjs +9 -0
- package/templates/nextjs/base/src/app/account/page.tsx +105 -0
- package/templates/nextjs/base/src/app/auth/callback/page.tsx +99 -0
- package/templates/nextjs/base/src/app/cart/page.tsx +263 -0
- package/templates/nextjs/base/src/app/checkout/page.tsx +463 -0
- package/templates/nextjs/base/src/app/globals.css +30 -0
- package/templates/nextjs/base/src/app/layout.tsx.ejs +33 -0
- package/templates/nextjs/base/src/app/login/page.tsx +56 -0
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +191 -0
- package/templates/nextjs/base/src/app/page.tsx +95 -0
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +346 -0
- package/templates/nextjs/base/src/app/products/page.tsx +243 -0
- package/templates/nextjs/base/src/app/register/page.tsx +66 -0
- package/templates/nextjs/base/src/app/verify-email/page.tsx +291 -0
- package/templates/nextjs/base/src/components/account/order-history.tsx +184 -0
- package/templates/nextjs/base/src/components/account/profile-section.tsx +73 -0
- package/templates/nextjs/base/src/components/auth/login-form.tsx +92 -0
- package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +134 -0
- package/templates/nextjs/base/src/components/auth/register-form.tsx +177 -0
- package/templates/nextjs/base/src/components/cart/cart-item.tsx +150 -0
- package/templates/nextjs/base/src/components/cart/cart-nudges.tsx +39 -0
- package/templates/nextjs/base/src/components/cart/cart-summary.tsx +67 -0
- package/templates/nextjs/base/src/components/cart/coupon-input.tsx +131 -0
- package/templates/nextjs/base/src/components/cart/reservation-countdown.tsx +100 -0
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +273 -0
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +124 -0
- package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +111 -0
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +62 -0
- package/templates/nextjs/base/src/components/layout/footer.tsx +35 -0
- package/templates/nextjs/base/src/components/layout/header.tsx +329 -0
- package/templates/nextjs/base/src/components/products/discount-badge.tsx +36 -0
- package/templates/nextjs/base/src/components/products/product-card.tsx +94 -0
- package/templates/nextjs/base/src/components/products/product-grid.tsx +33 -0
- package/templates/nextjs/base/src/components/products/stock-badge.tsx +34 -0
- package/templates/nextjs/base/src/components/products/variant-selector.tsx +147 -0
- package/templates/nextjs/base/src/components/shared/loading-spinner.tsx +30 -0
- package/templates/nextjs/base/src/components/shared/price-display.tsx +62 -0
- package/templates/nextjs/base/src/hooks/use-search.ts +77 -0
- package/templates/nextjs/base/src/lib/brainerce.ts.ejs +59 -0
- package/templates/nextjs/base/src/lib/utils.ts +6 -0
- package/templates/nextjs/base/src/providers/store-provider.tsx.ejs +168 -0
- package/templates/nextjs/base/tailwind.config.ts +30 -0
- package/templates/nextjs/base/tsconfig.json +23 -0
- package/templates/nextjs/themes/minimal/globals.css +30 -0
- package/templates/nextjs/themes/minimal/theme.json +23 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Suspense, useEffect, useState, useCallback } from 'react';
|
|
4
|
+
import { useSearchParams, useRouter } from 'next/navigation';
|
|
5
|
+
import type { Product } from 'brainerce';
|
|
6
|
+
import type { ProductQueryParams } from 'brainerce';
|
|
7
|
+
import { getClient } from '@/lib/brainerce';
|
|
8
|
+
import { ProductGrid } from '@/components/products/product-grid';
|
|
9
|
+
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
10
|
+
import { cn } from '@/lib/utils';
|
|
11
|
+
|
|
12
|
+
const PAGE_SIZE = 20;
|
|
13
|
+
|
|
14
|
+
type SortOption = {
|
|
15
|
+
label: string;
|
|
16
|
+
sortBy: ProductQueryParams['sortBy'];
|
|
17
|
+
sortOrder: ProductQueryParams['sortOrder'];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const sortOptions: SortOption[] = [
|
|
21
|
+
{ label: 'Newest', sortBy: 'createdAt', sortOrder: 'desc' },
|
|
22
|
+
{ label: 'Name A-Z', sortBy: 'name', sortOrder: 'asc' },
|
|
23
|
+
{ label: 'Name Z-A', sortBy: 'name', sortOrder: 'desc' },
|
|
24
|
+
{ label: 'Price: Low to High', sortBy: 'price', sortOrder: 'asc' },
|
|
25
|
+
{ label: 'Price: High to Low', sortBy: 'price', sortOrder: 'desc' },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
interface CategoryFilter {
|
|
29
|
+
id: string;
|
|
30
|
+
name: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function ProductsContent() {
|
|
34
|
+
const searchParams = useSearchParams();
|
|
35
|
+
const router = useRouter();
|
|
36
|
+
|
|
37
|
+
const searchQuery = searchParams.get('search') || '';
|
|
38
|
+
const categoryId = searchParams.get('category') || '';
|
|
39
|
+
const sortParam = searchParams.get('sort') || '0';
|
|
40
|
+
|
|
41
|
+
const [products, setProducts] = useState<Product[]>([]);
|
|
42
|
+
const [loading, setLoading] = useState(true);
|
|
43
|
+
const [loadingMore, setLoadingMore] = useState(false);
|
|
44
|
+
const [page, setPage] = useState(1);
|
|
45
|
+
const [totalPages, setTotalPages] = useState(1);
|
|
46
|
+
const [total, setTotal] = useState(0);
|
|
47
|
+
const [categories, setCategories] = useState<CategoryFilter[]>([]);
|
|
48
|
+
|
|
49
|
+
const sortIndex = parseInt(sortParam, 10) || 0;
|
|
50
|
+
const currentSort = sortOptions[sortIndex] || sortOptions[0];
|
|
51
|
+
|
|
52
|
+
// Load categories
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
async function loadCategories() {
|
|
55
|
+
try {
|
|
56
|
+
const client = getClient();
|
|
57
|
+
const result = await client.getCategories();
|
|
58
|
+
setCategories(result.categories.map((c) => ({ id: c.id, name: c.name })));
|
|
59
|
+
} catch {
|
|
60
|
+
// Categories endpoint may not be available in all modes
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
loadCategories();
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
// Load products when filters change
|
|
67
|
+
const loadProducts = useCallback(
|
|
68
|
+
async (pageNum: number, append: boolean) => {
|
|
69
|
+
try {
|
|
70
|
+
if (append) {
|
|
71
|
+
setLoadingMore(true);
|
|
72
|
+
} else {
|
|
73
|
+
setLoading(true);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const client = getClient();
|
|
77
|
+
const params: ProductQueryParams = {
|
|
78
|
+
page: pageNum,
|
|
79
|
+
limit: PAGE_SIZE,
|
|
80
|
+
sortBy: currentSort.sortBy,
|
|
81
|
+
sortOrder: currentSort.sortOrder,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
if (searchQuery) params.search = searchQuery;
|
|
85
|
+
if (categoryId) params.categories = categoryId;
|
|
86
|
+
|
|
87
|
+
const result = await client.getProducts(params);
|
|
88
|
+
|
|
89
|
+
if (append) {
|
|
90
|
+
setProducts((prev) => [...prev, ...result.data]);
|
|
91
|
+
} else {
|
|
92
|
+
setProducts(result.data);
|
|
93
|
+
}
|
|
94
|
+
setTotalPages(result.meta.totalPages);
|
|
95
|
+
setTotal(result.meta.total);
|
|
96
|
+
setPage(pageNum);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.error('Failed to load products:', err);
|
|
99
|
+
} finally {
|
|
100
|
+
setLoading(false);
|
|
101
|
+
setLoadingMore(false);
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
[searchQuery, categoryId, currentSort.sortBy, currentSort.sortOrder]
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
loadProducts(1, false);
|
|
109
|
+
}, [loadProducts]);
|
|
110
|
+
|
|
111
|
+
function handleLoadMore() {
|
|
112
|
+
if (page < totalPages && !loadingMore) {
|
|
113
|
+
loadProducts(page + 1, true);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function updateParam(key: string, value: string) {
|
|
118
|
+
const params = new URLSearchParams(searchParams.toString());
|
|
119
|
+
if (value) {
|
|
120
|
+
params.set(key, value);
|
|
121
|
+
} else {
|
|
122
|
+
params.delete(key);
|
|
123
|
+
}
|
|
124
|
+
router.push(`/products?${params.toString()}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
|
129
|
+
{/* Page Header */}
|
|
130
|
+
<div className="mb-8">
|
|
131
|
+
<h1 className="text-foreground text-3xl font-bold">
|
|
132
|
+
{searchQuery ? `Search: "${searchQuery}"` : 'All Products'}
|
|
133
|
+
</h1>
|
|
134
|
+
{!loading && (
|
|
135
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
136
|
+
{total} {total === 1 ? 'product' : 'products'} found
|
|
137
|
+
</p>
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
{/* Filters and Sort */}
|
|
142
|
+
<div className="mb-6 flex flex-col items-start gap-4 sm:flex-row sm:items-center">
|
|
143
|
+
{/* Category Filter */}
|
|
144
|
+
{categories.length > 0 && (
|
|
145
|
+
<div className="flex flex-wrap gap-2">
|
|
146
|
+
<button
|
|
147
|
+
onClick={() => updateParam('category', '')}
|
|
148
|
+
className={cn(
|
|
149
|
+
'rounded-full border px-3 py-1.5 text-sm transition-colors',
|
|
150
|
+
!categoryId
|
|
151
|
+
? 'bg-primary text-primary-foreground border-primary'
|
|
152
|
+
: 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
|
|
153
|
+
)}
|
|
154
|
+
>
|
|
155
|
+
All
|
|
156
|
+
</button>
|
|
157
|
+
{categories.map((cat) => (
|
|
158
|
+
<button
|
|
159
|
+
key={cat.id}
|
|
160
|
+
onClick={() => updateParam('category', cat.id)}
|
|
161
|
+
className={cn(
|
|
162
|
+
'rounded-full border px-3 py-1.5 text-sm transition-colors',
|
|
163
|
+
categoryId === cat.id
|
|
164
|
+
? 'bg-primary text-primary-foreground border-primary'
|
|
165
|
+
: 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
|
|
166
|
+
)}
|
|
167
|
+
>
|
|
168
|
+
{cat.name}
|
|
169
|
+
</button>
|
|
170
|
+
))}
|
|
171
|
+
</div>
|
|
172
|
+
)}
|
|
173
|
+
|
|
174
|
+
{/* Sort */}
|
|
175
|
+
<div className="flex items-center gap-2 sm:ms-auto">
|
|
176
|
+
<label htmlFor="sort" className="text-muted-foreground whitespace-nowrap text-sm">
|
|
177
|
+
Sort by:
|
|
178
|
+
</label>
|
|
179
|
+
<select
|
|
180
|
+
id="sort"
|
|
181
|
+
value={sortIndex}
|
|
182
|
+
onChange={(e) => updateParam('sort', e.target.value)}
|
|
183
|
+
className="border-border bg-background text-foreground focus:ring-primary/20 focus:border-primary h-9 rounded border px-3 text-sm focus:outline-none focus:ring-2"
|
|
184
|
+
>
|
|
185
|
+
{sortOptions.map((opt, idx) => (
|
|
186
|
+
<option key={idx} value={idx}>
|
|
187
|
+
{opt.label}
|
|
188
|
+
</option>
|
|
189
|
+
))}
|
|
190
|
+
</select>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
{/* Products Grid */}
|
|
195
|
+
{loading ? (
|
|
196
|
+
<div className="flex items-center justify-center py-20">
|
|
197
|
+
<LoadingSpinner size="lg" />
|
|
198
|
+
</div>
|
|
199
|
+
) : (
|
|
200
|
+
<>
|
|
201
|
+
<ProductGrid products={products} />
|
|
202
|
+
|
|
203
|
+
{/* Load More */}
|
|
204
|
+
{page < totalPages && (
|
|
205
|
+
<div className="mt-10 flex justify-center">
|
|
206
|
+
<button
|
|
207
|
+
onClick={handleLoadMore}
|
|
208
|
+
disabled={loadingMore}
|
|
209
|
+
className="bg-primary text-primary-foreground inline-flex items-center gap-2 rounded px-6 py-2.5 font-medium transition-opacity hover:opacity-90 disabled:opacity-50"
|
|
210
|
+
>
|
|
211
|
+
{loadingMore ? (
|
|
212
|
+
<>
|
|
213
|
+
<LoadingSpinner
|
|
214
|
+
size="sm"
|
|
215
|
+
className="border-primary-foreground/30 border-t-primary-foreground"
|
|
216
|
+
/>
|
|
217
|
+
Loading...
|
|
218
|
+
</>
|
|
219
|
+
) : (
|
|
220
|
+
'Load More'
|
|
221
|
+
)}
|
|
222
|
+
</button>
|
|
223
|
+
</div>
|
|
224
|
+
)}
|
|
225
|
+
</>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export default function ProductsPage() {
|
|
232
|
+
return (
|
|
233
|
+
<Suspense
|
|
234
|
+
fallback={
|
|
235
|
+
<div className="flex min-h-[60vh] items-center justify-center">
|
|
236
|
+
<LoadingSpinner size="lg" />
|
|
237
|
+
</div>
|
|
238
|
+
}
|
|
239
|
+
>
|
|
240
|
+
<ProductsContent />
|
|
241
|
+
</Suspense>
|
|
242
|
+
);
|
|
243
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
import Link from 'next/link';
|
|
6
|
+
import { getClient } from '@/lib/brainerce';
|
|
7
|
+
import { useAuth } from '@/providers/store-provider';
|
|
8
|
+
import { RegisterForm } from '@/components/auth/register-form';
|
|
9
|
+
import { OAuthButtons } from '@/components/auth/oauth-buttons';
|
|
10
|
+
|
|
11
|
+
export default function RegisterPage() {
|
|
12
|
+
const router = useRouter();
|
|
13
|
+
const auth = useAuth();
|
|
14
|
+
const [error, setError] = useState<string | null>(null);
|
|
15
|
+
|
|
16
|
+
async function handleRegister(data: {
|
|
17
|
+
firstName: string;
|
|
18
|
+
lastName: string;
|
|
19
|
+
email: string;
|
|
20
|
+
password: string;
|
|
21
|
+
}) {
|
|
22
|
+
try {
|
|
23
|
+
setError(null);
|
|
24
|
+
const client = getClient();
|
|
25
|
+
const result = await client.registerCustomer({
|
|
26
|
+
firstName: data.firstName,
|
|
27
|
+
lastName: data.lastName,
|
|
28
|
+
email: data.email,
|
|
29
|
+
password: data.password,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (result.requiresVerification) {
|
|
33
|
+
router.push(`/verify-email?token=${encodeURIComponent(result.token)}`);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
auth.login(result.token);
|
|
38
|
+
router.push('/');
|
|
39
|
+
} catch (err) {
|
|
40
|
+
const message = err instanceof Error ? err.message : 'Registration failed. Please try again.';
|
|
41
|
+
setError(message);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
|
|
47
|
+
<div className="w-full max-w-md space-y-6">
|
|
48
|
+
<div className="text-center">
|
|
49
|
+
<h1 className="text-foreground text-2xl font-bold">Create an account</h1>
|
|
50
|
+
<p className="text-muted-foreground mt-1 text-sm">Join us to start shopping</p>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<RegisterForm onSubmit={handleRegister} error={error} />
|
|
54
|
+
|
|
55
|
+
<OAuthButtons />
|
|
56
|
+
|
|
57
|
+
<p className="text-muted-foreground text-center text-sm">
|
|
58
|
+
Already have an account?{' '}
|
|
59
|
+
<Link href="/login" className="text-primary font-medium hover:underline">
|
|
60
|
+
Sign in
|
|
61
|
+
</Link>
|
|
62
|
+
</p>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Suspense, useState, useRef, useEffect, useCallback } from 'react';
|
|
4
|
+
import { useRouter, useSearchParams } from 'next/navigation';
|
|
5
|
+
import Link from 'next/link';
|
|
6
|
+
import { getClient } from '@/lib/brainerce';
|
|
7
|
+
import { useAuth } from '@/providers/store-provider';
|
|
8
|
+
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
9
|
+
|
|
10
|
+
const CODE_LENGTH = 6;
|
|
11
|
+
const RESEND_COOLDOWN_SECONDS = 60;
|
|
12
|
+
|
|
13
|
+
function VerifyEmailContent() {
|
|
14
|
+
const router = useRouter();
|
|
15
|
+
const searchParams = useSearchParams();
|
|
16
|
+
const auth = useAuth();
|
|
17
|
+
|
|
18
|
+
const token = searchParams.get('token');
|
|
19
|
+
|
|
20
|
+
const [digits, setDigits] = useState<string[]>(Array(CODE_LENGTH).fill(''));
|
|
21
|
+
const [loading, setLoading] = useState(false);
|
|
22
|
+
const [resending, setResending] = useState(false);
|
|
23
|
+
const [error, setError] = useState<string | null>(null);
|
|
24
|
+
const [success, setSuccess] = useState<string | null>(null);
|
|
25
|
+
const [cooldown, setCooldown] = useState(0);
|
|
26
|
+
|
|
27
|
+
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
|
28
|
+
|
|
29
|
+
// Auto-focus first input on mount
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
inputRefs.current[0]?.focus();
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
// Cooldown timer
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (cooldown <= 0) return;
|
|
37
|
+
const timer = setInterval(() => {
|
|
38
|
+
setCooldown((prev) => prev - 1);
|
|
39
|
+
}, 1000);
|
|
40
|
+
return () => clearInterval(timer);
|
|
41
|
+
}, [cooldown]);
|
|
42
|
+
|
|
43
|
+
const handleSubmit = useCallback(
|
|
44
|
+
async (code: string) => {
|
|
45
|
+
if (!token || code.length !== CODE_LENGTH || loading) return;
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
setLoading(true);
|
|
49
|
+
setError(null);
|
|
50
|
+
const client = getClient();
|
|
51
|
+
const result = await client.verifyEmail(code, token);
|
|
52
|
+
|
|
53
|
+
if (result.verified) {
|
|
54
|
+
// token field exists on newer SDK versions
|
|
55
|
+
const authToken = (result as unknown as { token?: string }).token || token;
|
|
56
|
+
auth.login(authToken);
|
|
57
|
+
setSuccess('Email verified successfully! Redirecting...');
|
|
58
|
+
setTimeout(() => router.push('/'), 1500);
|
|
59
|
+
} else {
|
|
60
|
+
setError(result.message || 'Verification failed. Please try again.');
|
|
61
|
+
}
|
|
62
|
+
} catch (err) {
|
|
63
|
+
const message =
|
|
64
|
+
err instanceof Error ? err.message : 'Verification failed. Please try again.';
|
|
65
|
+
setError(message);
|
|
66
|
+
} finally {
|
|
67
|
+
setLoading(false);
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
[token, loading, auth, router]
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
function handleDigitChange(index: number, value: string) {
|
|
74
|
+
// Allow only single digits
|
|
75
|
+
const digit = value.replace(/\D/g, '').slice(-1);
|
|
76
|
+
|
|
77
|
+
const newDigits = [...digits];
|
|
78
|
+
newDigits[index] = digit;
|
|
79
|
+
setDigits(newDigits);
|
|
80
|
+
|
|
81
|
+
// Auto-focus next input
|
|
82
|
+
if (digit && index < CODE_LENGTH - 1) {
|
|
83
|
+
inputRefs.current[index + 1]?.focus();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Auto-submit when all digits are filled
|
|
87
|
+
const fullCode = newDigits.join('');
|
|
88
|
+
if (fullCode.length === CODE_LENGTH) {
|
|
89
|
+
handleSubmit(fullCode);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function handleKeyDown(index: number, e: React.KeyboardEvent<HTMLInputElement>) {
|
|
94
|
+
if (e.key === 'Backspace' && !digits[index] && index > 0) {
|
|
95
|
+
// Move focus to previous input on backspace when current is empty
|
|
96
|
+
inputRefs.current[index - 1]?.focus();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function handlePaste(e: React.ClipboardEvent) {
|
|
101
|
+
e.preventDefault();
|
|
102
|
+
const pastedText = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, CODE_LENGTH);
|
|
103
|
+
|
|
104
|
+
if (pastedText.length === 0) return;
|
|
105
|
+
|
|
106
|
+
const newDigits = [...digits];
|
|
107
|
+
for (let i = 0; i < pastedText.length; i++) {
|
|
108
|
+
newDigits[i] = pastedText[i];
|
|
109
|
+
}
|
|
110
|
+
setDigits(newDigits);
|
|
111
|
+
|
|
112
|
+
// Focus the next empty input, or the last filled one
|
|
113
|
+
const nextEmptyIndex = newDigits.findIndex((d) => !d);
|
|
114
|
+
const focusIndex = nextEmptyIndex === -1 ? CODE_LENGTH - 1 : nextEmptyIndex;
|
|
115
|
+
inputRefs.current[focusIndex]?.focus();
|
|
116
|
+
|
|
117
|
+
// Auto-submit if all digits pasted
|
|
118
|
+
if (pastedText.length === CODE_LENGTH) {
|
|
119
|
+
handleSubmit(pastedText);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function handleResend() {
|
|
124
|
+
if (!token || resending || cooldown > 0) return;
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
setResending(true);
|
|
128
|
+
setError(null);
|
|
129
|
+
const client = getClient();
|
|
130
|
+
await client.resendVerificationEmail(token);
|
|
131
|
+
setSuccess('Verification code sent! Check your email.');
|
|
132
|
+
setCooldown(RESEND_COOLDOWN_SECONDS);
|
|
133
|
+
// Clear digits for fresh entry
|
|
134
|
+
setDigits(Array(CODE_LENGTH).fill(''));
|
|
135
|
+
inputRefs.current[0]?.focus();
|
|
136
|
+
} catch (err) {
|
|
137
|
+
const message = err instanceof Error ? err.message : 'Failed to resend code.';
|
|
138
|
+
setError(message);
|
|
139
|
+
} finally {
|
|
140
|
+
setResending(false);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function handleFormSubmit(e: React.FormEvent) {
|
|
145
|
+
e.preventDefault();
|
|
146
|
+
const code = digits.join('');
|
|
147
|
+
handleSubmit(code);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// No token provided
|
|
151
|
+
if (!token) {
|
|
152
|
+
return (
|
|
153
|
+
<div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
|
|
154
|
+
<div className="w-full max-w-md space-y-4 text-center">
|
|
155
|
+
<svg
|
|
156
|
+
className="text-muted-foreground mx-auto h-12 w-12"
|
|
157
|
+
fill="none"
|
|
158
|
+
viewBox="0 0 24 24"
|
|
159
|
+
stroke="currentColor"
|
|
160
|
+
>
|
|
161
|
+
<path
|
|
162
|
+
strokeLinecap="round"
|
|
163
|
+
strokeLinejoin="round"
|
|
164
|
+
strokeWidth={1.5}
|
|
165
|
+
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
|
166
|
+
/>
|
|
167
|
+
</svg>
|
|
168
|
+
<h1 className="text-foreground text-2xl font-bold">Verification link invalid</h1>
|
|
169
|
+
<p className="text-muted-foreground text-sm">
|
|
170
|
+
This verification link is missing required information. Please try registering again.
|
|
171
|
+
</p>
|
|
172
|
+
<Link
|
|
173
|
+
href="/register"
|
|
174
|
+
className="bg-primary text-primary-foreground inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
|
|
175
|
+
>
|
|
176
|
+
Go to Register
|
|
177
|
+
</Link>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
|
|
185
|
+
<div className="w-full max-w-md space-y-6">
|
|
186
|
+
<div className="text-center">
|
|
187
|
+
<svg
|
|
188
|
+
className="text-primary mx-auto mb-3 h-12 w-12"
|
|
189
|
+
fill="none"
|
|
190
|
+
viewBox="0 0 24 24"
|
|
191
|
+
stroke="currentColor"
|
|
192
|
+
>
|
|
193
|
+
<path
|
|
194
|
+
strokeLinecap="round"
|
|
195
|
+
strokeLinejoin="round"
|
|
196
|
+
strokeWidth={1.5}
|
|
197
|
+
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
|
198
|
+
/>
|
|
199
|
+
</svg>
|
|
200
|
+
<h1 className="text-foreground text-2xl font-bold">Verify your email</h1>
|
|
201
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
202
|
+
Enter the 6-digit code we sent to your email address.
|
|
203
|
+
</p>
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
{error && (
|
|
207
|
+
<div className="bg-destructive/10 border-destructive/20 text-destructive rounded-lg border px-4 py-3 text-sm">
|
|
208
|
+
{error}
|
|
209
|
+
</div>
|
|
210
|
+
)}
|
|
211
|
+
|
|
212
|
+
{success && (
|
|
213
|
+
<div className="rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800 dark:border-green-800 dark:bg-green-950/30 dark:text-green-300">
|
|
214
|
+
{success}
|
|
215
|
+
</div>
|
|
216
|
+
)}
|
|
217
|
+
|
|
218
|
+
<form onSubmit={handleFormSubmit} className="space-y-6">
|
|
219
|
+
{/* Digit inputs */}
|
|
220
|
+
<div className="flex justify-center gap-2 sm:gap-3" onPaste={handlePaste}>
|
|
221
|
+
{digits.map((digit, index) => (
|
|
222
|
+
<input
|
|
223
|
+
key={index}
|
|
224
|
+
ref={(el) => {
|
|
225
|
+
inputRefs.current[index] = el;
|
|
226
|
+
}}
|
|
227
|
+
type="text"
|
|
228
|
+
inputMode="numeric"
|
|
229
|
+
autoComplete="one-time-code"
|
|
230
|
+
maxLength={1}
|
|
231
|
+
value={digit}
|
|
232
|
+
onChange={(e) => handleDigitChange(index, e.target.value)}
|
|
233
|
+
onKeyDown={(e) => handleKeyDown(index, e)}
|
|
234
|
+
disabled={loading}
|
|
235
|
+
className="border-border bg-background text-foreground focus:ring-primary/20 focus:border-primary h-12 w-11 rounded border text-center text-xl font-semibold focus:outline-none focus:ring-2 disabled:cursor-not-allowed disabled:opacity-50 sm:h-14 sm:w-12"
|
|
236
|
+
aria-label={`Digit ${index + 1}`}
|
|
237
|
+
/>
|
|
238
|
+
))}
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
<button
|
|
242
|
+
type="submit"
|
|
243
|
+
disabled={loading || digits.join('').length !== CODE_LENGTH}
|
|
244
|
+
className="bg-primary text-primary-foreground flex h-10 w-full items-center justify-center gap-2 rounded text-sm font-medium transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
|
245
|
+
>
|
|
246
|
+
{loading ? (
|
|
247
|
+
<>
|
|
248
|
+
<LoadingSpinner
|
|
249
|
+
size="sm"
|
|
250
|
+
className="border-primary-foreground/30 border-t-primary-foreground"
|
|
251
|
+
/>
|
|
252
|
+
Verifying...
|
|
253
|
+
</>
|
|
254
|
+
) : (
|
|
255
|
+
'Verify Email'
|
|
256
|
+
)}
|
|
257
|
+
</button>
|
|
258
|
+
</form>
|
|
259
|
+
|
|
260
|
+
{/* Resend code */}
|
|
261
|
+
<div className="text-center">
|
|
262
|
+
<p className="text-muted-foreground text-sm">
|
|
263
|
+
Didn't receive the code?{' '}
|
|
264
|
+
<button
|
|
265
|
+
type="button"
|
|
266
|
+
onClick={handleResend}
|
|
267
|
+
disabled={resending || cooldown > 0}
|
|
268
|
+
className="text-primary font-medium hover:underline disabled:cursor-not-allowed disabled:no-underline disabled:opacity-50"
|
|
269
|
+
>
|
|
270
|
+
{resending ? 'Sending...' : cooldown > 0 ? `Resend in ${cooldown}s` : 'Resend code'}
|
|
271
|
+
</button>
|
|
272
|
+
</p>
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export default function VerifyEmailPage() {
|
|
280
|
+
return (
|
|
281
|
+
<Suspense
|
|
282
|
+
fallback={
|
|
283
|
+
<div className="flex min-h-[60vh] items-center justify-center">
|
|
284
|
+
<LoadingSpinner size="lg" />
|
|
285
|
+
</div>
|
|
286
|
+
}
|
|
287
|
+
>
|
|
288
|
+
<VerifyEmailContent />
|
|
289
|
+
</Suspense>
|
|
290
|
+
);
|
|
291
|
+
}
|