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.
@@ -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
- await client.forgotPassword(email);
25
- setSent(true);
26
- } catch (err) {
27
- const message =
28
- err instanceof Error ? err.message : 'Something went wrong. Please try again.';
29
- setError(message);
30
- } finally {
31
- setLoading(false);
32
- }
33
- }
34
-
35
- return (
36
- <div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
37
- <div className="w-full max-w-md space-y-6">
38
- <div className="text-center">
39
- <h1 className="text-foreground text-2xl font-bold">{t('forgotPasswordTitle')}</h1>
40
- <p className="text-muted-foreground mt-1 text-sm">{t('forgotPasswordSubtitle')}</p>
41
- </div>
42
-
43
- {error && (
44
- <div className="bg-destructive/10 border-destructive/20 text-destructive rounded-lg border px-4 py-3 text-sm">
45
- {error}
46
- </div>
47
- )}
48
-
49
- {sent ? (
50
- <div className="space-y-4">
51
- <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">
52
- {t('resetLinkSent')}
53
- </div>
54
- <Link
55
- href="/login"
56
- className="text-primary block text-center text-sm font-medium hover:underline"
57
- >
58
- {t('backToLogin')}
59
- </Link>
60
- </div>
61
- ) : (
62
- <form onSubmit={handleSubmit} className="space-y-4">
63
- <div>
64
- <label
65
- htmlFor="forgot-email"
66
- className="text-foreground mb-1.5 block text-sm font-medium"
67
- >
68
- {t('email')}
69
- </label>
70
- <input
71
- id="forgot-email"
72
- type="email"
73
- required
74
- value={email}
75
- onChange={(e) => setEmail(e.target.value)}
76
- placeholder={t('emailPlaceholder')}
77
- autoComplete="email"
78
- 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"
79
- />
80
- </div>
81
-
82
- <button
83
- type="submit"
84
- disabled={loading}
85
- 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"
86
- >
87
- {loading ? (
88
- <>
89
- <LoadingSpinner
90
- size="sm"
91
- className="border-primary-foreground/30 border-t-primary-foreground"
92
- />
93
- {t('sendingResetLink')}
94
- </>
95
- ) : (
96
- t('sendResetLink')
97
- )}
98
- </button>
99
-
100
- <Link
101
- href="/login"
102
- className="text-muted-foreground block text-center text-sm hover:underline"
103
- >
104
- {t('backToLogin')}
105
- </Link>
106
- </form>
107
- )}
108
- </div>
109
- </div>
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 { getClient } from '@/lib/brainerce';
7
- import { useAuth } from '@/providers/store-provider';
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 client = getClient();
22
- const result = await client.loginCustomer(email, password);
23
-
24
- if (result.requiresVerification) {
25
- router.push(`/verify-email?token=${encodeURIComponent(result.token)}`);
26
- return;
27
- }
28
-
29
- auth.login(result.token);
30
- router.push('/');
31
- } catch (err) {
32
- const message = err instanceof Error ? err.message : 'Login failed. Please try again.';
33
- setError(message);
34
- }
35
- }
36
-
37
- return (
38
- <div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
39
- <div className="w-full max-w-md space-y-6">
40
- <div className="text-center">
41
- <h1 className="text-foreground text-2xl font-bold">{t('welcomeBack')}</h1>
42
- <p className="text-muted-foreground mt-1 text-sm">{t('signInSubtitle')}</p>
43
- </div>
44
-
45
- <LoginForm onSubmit={handleLogin} error={error} />
46
-
47
- <OAuthButtons />
48
-
49
- <p className="text-muted-foreground text-center text-sm">
50
- {t('noAccount')}{' '}
51
- <Link href="/register" className="text-primary font-medium hover:underline">
52
- {t('createOne')}
53
- </Link>
54
- </p>
55
- </div>
56
- </div>
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 CategoryFilter {
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<CategoryFilter[]>([]);
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.map((c) => ({ id: c.id, name: c.name })));
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
- <button
354
+ <CategoryChip
162
355
  key={cat.id}
163
- onClick={() => updateParam('category', cat.id)}
164
- className={cn(
165
- 'rounded-full border px-3 py-1.5 text-sm transition-colors',
166
- categoryId === cat.id
167
- ? 'bg-primary text-primary-foreground border-primary'
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
  )}