create-brainerce-store 1.6.1 → 1.8.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.js +1 -1
- package/package.json +1 -1
- package/templates/nextjs/base/.env.local.ejs +3 -1
- package/templates/nextjs/base/src/app/api/auth/logout/route.ts +14 -0
- package/templates/nextjs/base/src/app/api/auth/me/route.ts +49 -0
- package/templates/nextjs/base/src/app/api/auth/oauth-callback/route.ts +59 -0
- package/templates/nextjs/base/src/app/api/auth/reset-callback/route.ts +41 -0
- package/templates/nextjs/base/src/app/api/auth/reset-password/route.ts +68 -0
- package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +190 -0
- package/templates/nextjs/base/src/app/auth/callback/page.tsx +92 -90
- package/templates/nextjs/base/src/app/forgot-password/page.tsx +112 -111
- package/templates/nextjs/base/src/app/login/page.tsx +59 -58
- package/templates/nextjs/base/src/app/products/page.tsx +204 -16
- package/templates/nextjs/base/src/app/register/page.tsx +64 -68
- package/templates/nextjs/base/src/app/reset-password/page.tsx +132 -161
- package/templates/nextjs/base/src/app/verify-email/page.tsx +258 -293
- package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +137 -137
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +379 -372
- package/templates/nextjs/base/src/lib/auth.ts +148 -0
- package/templates/nextjs/base/src/lib/brainerce.ts.ejs +6 -26
- package/templates/nextjs/base/src/middleware.ts +25 -0
- package/templates/nextjs/base/src/providers/store-provider.tsx.ejs +50 -27
|
@@ -1,111 +1,112 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useState } from 'react';
|
|
4
|
-
import Link from 'next/link';
|
|
5
|
-
import { getClient } from '@/lib/brainerce';
|
|
6
|
-
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
7
|
-
import { useTranslations } from '@/lib/translations';
|
|
8
|
-
|
|
9
|
-
export default function ForgotPasswordPage() {
|
|
10
|
-
const t = useTranslations('auth');
|
|
11
|
-
const [email, setEmail] = useState('');
|
|
12
|
-
const [loading, setLoading] = useState(false);
|
|
13
|
-
const [sent, setSent] = useState(false);
|
|
14
|
-
const [error, setError] = useState<string | null>(null);
|
|
15
|
-
|
|
16
|
-
async function handleSubmit(e: React.FormEvent) {
|
|
17
|
-
e.preventDefault();
|
|
18
|
-
if (loading) return;
|
|
19
|
-
|
|
20
|
-
try {
|
|
21
|
-
setLoading(true);
|
|
22
|
-
setError(null);
|
|
23
|
-
const client = getClient();
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
<
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import { getClient } from '@/lib/brainerce';
|
|
6
|
+
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
7
|
+
import { useTranslations } from '@/lib/translations';
|
|
8
|
+
|
|
9
|
+
export default function ForgotPasswordPage() {
|
|
10
|
+
const t = useTranslations('auth');
|
|
11
|
+
const [email, setEmail] = useState('');
|
|
12
|
+
const [loading, setLoading] = useState(false);
|
|
13
|
+
const [sent, setSent] = useState(false);
|
|
14
|
+
const [error, setError] = useState<string | null>(null);
|
|
15
|
+
|
|
16
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
17
|
+
e.preventDefault();
|
|
18
|
+
if (loading) return;
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
setLoading(true);
|
|
22
|
+
setError(null);
|
|
23
|
+
const client = getClient();
|
|
24
|
+
const resetUrl = `${window.location.origin}/api/auth/reset-callback`;
|
|
25
|
+
await client.forgotPassword(email, { resetUrl });
|
|
26
|
+
setSent(true);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
const message =
|
|
29
|
+
err instanceof Error ? err.message : 'Something went wrong. Please try again.';
|
|
30
|
+
setError(message);
|
|
31
|
+
} finally {
|
|
32
|
+
setLoading(false);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
|
|
38
|
+
<div className="w-full max-w-md space-y-6">
|
|
39
|
+
<div className="text-center">
|
|
40
|
+
<h1 className="text-foreground text-2xl font-bold">{t('forgotPasswordTitle')}</h1>
|
|
41
|
+
<p className="text-muted-foreground mt-1 text-sm">{t('forgotPasswordSubtitle')}</p>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
{error && (
|
|
45
|
+
<div className="bg-destructive/10 border-destructive/20 text-destructive rounded-lg border px-4 py-3 text-sm">
|
|
46
|
+
{error}
|
|
47
|
+
</div>
|
|
48
|
+
)}
|
|
49
|
+
|
|
50
|
+
{sent ? (
|
|
51
|
+
<div className="space-y-4">
|
|
52
|
+
<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">
|
|
53
|
+
{t('resetLinkSent')}
|
|
54
|
+
</div>
|
|
55
|
+
<Link
|
|
56
|
+
href="/login"
|
|
57
|
+
className="text-primary block text-center text-sm font-medium hover:underline"
|
|
58
|
+
>
|
|
59
|
+
{t('backToLogin')}
|
|
60
|
+
</Link>
|
|
61
|
+
</div>
|
|
62
|
+
) : (
|
|
63
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
64
|
+
<div>
|
|
65
|
+
<label
|
|
66
|
+
htmlFor="forgot-email"
|
|
67
|
+
className="text-foreground mb-1.5 block text-sm font-medium"
|
|
68
|
+
>
|
|
69
|
+
{t('email')}
|
|
70
|
+
</label>
|
|
71
|
+
<input
|
|
72
|
+
id="forgot-email"
|
|
73
|
+
type="email"
|
|
74
|
+
required
|
|
75
|
+
value={email}
|
|
76
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
77
|
+
placeholder={t('emailPlaceholder')}
|
|
78
|
+
autoComplete="email"
|
|
79
|
+
className="border-border bg-background text-foreground placeholder:text-muted-foreground focus:ring-primary/20 focus:border-primary h-10 w-full rounded border px-3 text-sm focus:outline-none focus:ring-2"
|
|
80
|
+
/>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<button
|
|
84
|
+
type="submit"
|
|
85
|
+
disabled={loading}
|
|
86
|
+
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"
|
|
87
|
+
>
|
|
88
|
+
{loading ? (
|
|
89
|
+
<>
|
|
90
|
+
<LoadingSpinner
|
|
91
|
+
size="sm"
|
|
92
|
+
className="border-primary-foreground/30 border-t-primary-foreground"
|
|
93
|
+
/>
|
|
94
|
+
{t('sendingResetLink')}
|
|
95
|
+
</>
|
|
96
|
+
) : (
|
|
97
|
+
t('sendResetLink')
|
|
98
|
+
)}
|
|
99
|
+
</button>
|
|
100
|
+
|
|
101
|
+
<Link
|
|
102
|
+
href="/login"
|
|
103
|
+
className="text-muted-foreground block text-center text-sm hover:underline"
|
|
104
|
+
>
|
|
105
|
+
{t('backToLogin')}
|
|
106
|
+
</Link>
|
|
107
|
+
</form>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -1,58 +1,59 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useState } from 'react';
|
|
4
|
-
import { useRouter } from 'next/navigation';
|
|
5
|
-
import Link from 'next/link';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { LoginForm } from '@/components/auth/login-form';
|
|
9
|
-
import { OAuthButtons } from '@/components/auth/oauth-buttons';
|
|
10
|
-
import { useTranslations } from '@/lib/translations';
|
|
11
|
-
|
|
12
|
-
export default function LoginPage() {
|
|
13
|
-
const router = useRouter();
|
|
14
|
-
const auth = useAuth();
|
|
15
|
-
const t = useTranslations('auth');
|
|
16
|
-
const [error, setError] = useState<string | null>(null);
|
|
17
|
-
|
|
18
|
-
async function handleLogin(email: string, password: string) {
|
|
19
|
-
try {
|
|
20
|
-
setError(null);
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
router.push(
|
|
26
|
-
return;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
auth
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
<
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
import Link from 'next/link';
|
|
6
|
+
import { useAuth } from '@/providers/store-provider';
|
|
7
|
+
import { proxyLogin } from '@/lib/auth';
|
|
8
|
+
import { LoginForm } from '@/components/auth/login-form';
|
|
9
|
+
import { OAuthButtons } from '@/components/auth/oauth-buttons';
|
|
10
|
+
import { useTranslations } from '@/lib/translations';
|
|
11
|
+
|
|
12
|
+
export default function LoginPage() {
|
|
13
|
+
const router = useRouter();
|
|
14
|
+
const auth = useAuth();
|
|
15
|
+
const t = useTranslations('auth');
|
|
16
|
+
const [error, setError] = useState<string | null>(null);
|
|
17
|
+
|
|
18
|
+
async function handleLogin(email: string, password: string) {
|
|
19
|
+
try {
|
|
20
|
+
setError(null);
|
|
21
|
+
const result = await proxyLogin(email, password);
|
|
22
|
+
|
|
23
|
+
if (result.requiresVerification) {
|
|
24
|
+
// Verification token is NOT the auth JWT — safe to pass in URL
|
|
25
|
+
router.push('/verify-email');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Cookie was set by the proxy; refresh auth state
|
|
30
|
+
await auth.login();
|
|
31
|
+
router.push('/');
|
|
32
|
+
} catch (err) {
|
|
33
|
+
const message = err instanceof Error ? err.message : 'Login failed. Please try again.';
|
|
34
|
+
setError(message);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
|
|
40
|
+
<div className="w-full max-w-md space-y-6">
|
|
41
|
+
<div className="text-center">
|
|
42
|
+
<h1 className="text-foreground text-2xl font-bold">{t('welcomeBack')}</h1>
|
|
43
|
+
<p className="text-muted-foreground mt-1 text-sm">{t('signInSubtitle')}</p>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<LoginForm onSubmit={handleLogin} error={error} />
|
|
47
|
+
|
|
48
|
+
<OAuthButtons />
|
|
49
|
+
|
|
50
|
+
<p className="text-muted-foreground text-center text-sm">
|
|
51
|
+
{t('noAccount')}{' '}
|
|
52
|
+
<Link href="/register" className="text-primary font-medium hover:underline">
|
|
53
|
+
{t('createOne')}
|
|
54
|
+
</Link>
|
|
55
|
+
</p>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { Suspense, useEffect, useState, useCallback } from 'react';
|
|
3
|
+
import { Suspense, useEffect, useState, useCallback, useRef } from 'react';
|
|
4
4
|
import { useSearchParams, useRouter } from 'next/navigation';
|
|
5
5
|
import type { Product } from 'brainerce';
|
|
6
6
|
import type { ProductQueryParams } from 'brainerce';
|
|
@@ -26,9 +26,198 @@ const sortOptions: SortOption[] = [
|
|
|
26
26
|
{ labelKey: 'sortPriceHigh', sortBy: 'price', sortOrder: 'desc' },
|
|
27
27
|
];
|
|
28
28
|
|
|
29
|
-
interface
|
|
29
|
+
interface CategoryNode {
|
|
30
30
|
id: string;
|
|
31
31
|
name: string;
|
|
32
|
+
parentId?: string | null;
|
|
33
|
+
children: CategoryNode[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Collect all descendant IDs (including self) */
|
|
37
|
+
function getAllDescendantIds(node: CategoryNode): string[] {
|
|
38
|
+
const ids = [node.id];
|
|
39
|
+
for (const child of node.children) {
|
|
40
|
+
ids.push(...getAllDescendantIds(child));
|
|
41
|
+
}
|
|
42
|
+
return ids;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Check if a category or any of its descendants matches the selected ID */
|
|
46
|
+
function isActiveInTree(node: CategoryNode, selectedId: string): boolean {
|
|
47
|
+
if (node.id === selectedId) return true;
|
|
48
|
+
return node.children.some((child) => isActiveInTree(child, selectedId));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Chevron down SVG */
|
|
52
|
+
function ChevronDown({ className }: { className?: string }) {
|
|
53
|
+
return (
|
|
54
|
+
<svg
|
|
55
|
+
className={className}
|
|
56
|
+
width="12"
|
|
57
|
+
height="12"
|
|
58
|
+
viewBox="0 0 12 12"
|
|
59
|
+
fill="none"
|
|
60
|
+
stroke="currentColor"
|
|
61
|
+
strokeWidth="2"
|
|
62
|
+
strokeLinecap="round"
|
|
63
|
+
strokeLinejoin="round"
|
|
64
|
+
>
|
|
65
|
+
<path d="M3 4.5L6 7.5L9 4.5" />
|
|
66
|
+
</svg>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Recursive dropdown items for nested categories */
|
|
71
|
+
function CategoryDropdownItems({
|
|
72
|
+
children,
|
|
73
|
+
depth,
|
|
74
|
+
selectedId,
|
|
75
|
+
onSelect,
|
|
76
|
+
}: {
|
|
77
|
+
children: CategoryNode[];
|
|
78
|
+
depth: number;
|
|
79
|
+
selectedId: string;
|
|
80
|
+
onSelect: (id: string) => void;
|
|
81
|
+
}) {
|
|
82
|
+
return (
|
|
83
|
+
<>
|
|
84
|
+
{children.map((child) => (
|
|
85
|
+
<div key={child.id}>
|
|
86
|
+
<button
|
|
87
|
+
onClick={() => onSelect(child.id)}
|
|
88
|
+
className={cn(
|
|
89
|
+
'w-full text-start px-4 py-2 text-sm transition-colors hover:bg-muted',
|
|
90
|
+
selectedId === child.id && 'bg-primary/10 text-primary font-medium'
|
|
91
|
+
)}
|
|
92
|
+
style={{ paddingInlineStart: `${(depth + 1) * 16}px` }}
|
|
93
|
+
>
|
|
94
|
+
{child.name}
|
|
95
|
+
</button>
|
|
96
|
+
{child.children.length > 0 && (
|
|
97
|
+
<CategoryDropdownItems
|
|
98
|
+
children={child.children}
|
|
99
|
+
depth={depth + 1}
|
|
100
|
+
selectedId={selectedId}
|
|
101
|
+
onSelect={onSelect}
|
|
102
|
+
/>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
))}
|
|
106
|
+
</>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Category chip with dropdown for subcategories */
|
|
111
|
+
function CategoryChip({
|
|
112
|
+
category,
|
|
113
|
+
selectedId,
|
|
114
|
+
onSelect,
|
|
115
|
+
tc,
|
|
116
|
+
}: {
|
|
117
|
+
category: CategoryNode;
|
|
118
|
+
selectedId: string;
|
|
119
|
+
onSelect: (id: string) => void;
|
|
120
|
+
tc: (key: string) => string;
|
|
121
|
+
}) {
|
|
122
|
+
const [open, setOpen] = useState(false);
|
|
123
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
124
|
+
const hasChildren = category.children.length > 0;
|
|
125
|
+
const isActive = isActiveInTree(category, selectedId);
|
|
126
|
+
|
|
127
|
+
// Find the display name for the selected subcategory
|
|
128
|
+
function findName(nodes: CategoryNode[], id: string): string | null {
|
|
129
|
+
for (const n of nodes) {
|
|
130
|
+
if (n.id === id) return n.name;
|
|
131
|
+
const found = findName(n.children, id);
|
|
132
|
+
if (found) return found;
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const selectedChildName =
|
|
138
|
+
isActive && selectedId !== category.id ? findName(category.children, selectedId) : null;
|
|
139
|
+
|
|
140
|
+
// Close dropdown on outside click
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
if (!open) return;
|
|
143
|
+
function handleClick(e: MouseEvent) {
|
|
144
|
+
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
145
|
+
setOpen(false);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
document.addEventListener('mousedown', handleClick);
|
|
149
|
+
return () => document.removeEventListener('mousedown', handleClick);
|
|
150
|
+
}, [open]);
|
|
151
|
+
|
|
152
|
+
if (!hasChildren) {
|
|
153
|
+
return (
|
|
154
|
+
<button
|
|
155
|
+
onClick={() => onSelect(category.id)}
|
|
156
|
+
className={cn(
|
|
157
|
+
'rounded-full border px-3 py-1.5 text-sm transition-colors',
|
|
158
|
+
selectedId === category.id
|
|
159
|
+
? 'bg-primary text-primary-foreground border-primary'
|
|
160
|
+
: 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
|
|
161
|
+
)}
|
|
162
|
+
>
|
|
163
|
+
{category.name}
|
|
164
|
+
</button>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<div ref={ref} className="relative">
|
|
170
|
+
<button
|
|
171
|
+
onClick={() => setOpen((prev) => !prev)}
|
|
172
|
+
className={cn(
|
|
173
|
+
'inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-sm transition-colors',
|
|
174
|
+
isActive
|
|
175
|
+
? 'bg-primary text-primary-foreground border-primary'
|
|
176
|
+
: 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
|
|
177
|
+
)}
|
|
178
|
+
>
|
|
179
|
+
{category.name}
|
|
180
|
+
{selectedChildName && (
|
|
181
|
+
<span className="opacity-80">
|
|
182
|
+
{'·'} {selectedChildName}
|
|
183
|
+
</span>
|
|
184
|
+
)}
|
|
185
|
+
<ChevronDown
|
|
186
|
+
className={cn('transition-transform ms-0.5', open && 'rotate-180')}
|
|
187
|
+
/>
|
|
188
|
+
</button>
|
|
189
|
+
|
|
190
|
+
{open && (
|
|
191
|
+
<div className="bg-background border-border absolute start-0 top-full z-50 mt-1 min-w-[180px] overflow-hidden rounded-lg border shadow-lg">
|
|
192
|
+
{/* "All in [category]" option */}
|
|
193
|
+
<button
|
|
194
|
+
onClick={() => {
|
|
195
|
+
onSelect(category.id);
|
|
196
|
+
setOpen(false);
|
|
197
|
+
}}
|
|
198
|
+
className={cn(
|
|
199
|
+
'w-full text-start px-4 py-2 text-sm font-medium transition-colors hover:bg-muted',
|
|
200
|
+
selectedId === category.id && 'bg-primary/10 text-primary'
|
|
201
|
+
)}
|
|
202
|
+
>
|
|
203
|
+
{tc('all')} {category.name}
|
|
204
|
+
</button>
|
|
205
|
+
<div className="bg-border mx-2 h-px" />
|
|
206
|
+
{/* Recursive children */}
|
|
207
|
+
<div
|
|
208
|
+
onClick={() => setOpen(false)}
|
|
209
|
+
>
|
|
210
|
+
<CategoryDropdownItems
|
|
211
|
+
children={category.children}
|
|
212
|
+
depth={0}
|
|
213
|
+
selectedId={selectedId}
|
|
214
|
+
onSelect={onSelect}
|
|
215
|
+
/>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
)}
|
|
219
|
+
</div>
|
|
220
|
+
);
|
|
32
221
|
}
|
|
33
222
|
|
|
34
223
|
function ProductsContent() {
|
|
@@ -47,18 +236,18 @@ function ProductsContent() {
|
|
|
47
236
|
const [page, setPage] = useState(1);
|
|
48
237
|
const [totalPages, setTotalPages] = useState(1);
|
|
49
238
|
const [total, setTotal] = useState(0);
|
|
50
|
-
const [categories, setCategories] = useState<
|
|
239
|
+
const [categories, setCategories] = useState<CategoryNode[]>([]);
|
|
51
240
|
|
|
52
241
|
const sortIndex = parseInt(sortParam, 10) || 0;
|
|
53
242
|
const currentSort = sortOptions[sortIndex] || sortOptions[0];
|
|
54
243
|
|
|
55
|
-
// Load categories
|
|
244
|
+
// Load categories (keep tree structure)
|
|
56
245
|
useEffect(() => {
|
|
57
246
|
async function loadCategories() {
|
|
58
247
|
try {
|
|
59
248
|
const client = getClient();
|
|
60
249
|
const result = await client.getCategories();
|
|
61
|
-
setCategories(result.categories
|
|
250
|
+
setCategories(result.categories as CategoryNode[]);
|
|
62
251
|
} catch {
|
|
63
252
|
// Categories endpoint may not be available in all modes
|
|
64
253
|
}
|
|
@@ -127,6 +316,10 @@ function ProductsContent() {
|
|
|
127
316
|
router.push(`/products?${params.toString()}`);
|
|
128
317
|
}
|
|
129
318
|
|
|
319
|
+
function handleCategorySelect(id: string) {
|
|
320
|
+
updateParam('category', id);
|
|
321
|
+
}
|
|
322
|
+
|
|
130
323
|
return (
|
|
131
324
|
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
|
132
325
|
{/* Page Header */}
|
|
@@ -158,18 +351,13 @@ function ProductsContent() {
|
|
|
158
351
|
{tc('all')}
|
|
159
352
|
</button>
|
|
160
353
|
{categories.map((cat) => (
|
|
161
|
-
<
|
|
354
|
+
<CategoryChip
|
|
162
355
|
key={cat.id}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
: 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
|
|
169
|
-
)}
|
|
170
|
-
>
|
|
171
|
-
{cat.name}
|
|
172
|
-
</button>
|
|
356
|
+
category={cat}
|
|
357
|
+
selectedId={categoryId}
|
|
358
|
+
onSelect={handleCategorySelect}
|
|
359
|
+
tc={tc as (key: string) => string}
|
|
360
|
+
/>
|
|
173
361
|
))}
|
|
174
362
|
</div>
|
|
175
363
|
)}
|