create-brainerce-store 1.5.7 → 1.6.1

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 CHANGED
@@ -31,7 +31,7 @@ var require_package = __commonJS({
31
31
  "package.json"(exports2, module2) {
32
32
  module2.exports = {
33
33
  name: "create-brainerce-store",
34
- version: "1.5.6",
34
+ version: "1.6.1",
35
35
  description: "Scaffold a production-ready e-commerce storefront connected to Brainerce",
36
36
  bin: {
37
37
  "create-brainerce-store": "dist/index.js"
package/messages/en.json CHANGED
@@ -212,7 +212,25 @@
212
212
  "authFailed": "Authentication failed",
213
213
  "authFailedDesc": "Missing authentication parameters. Please try again.",
214
214
  "backToLogin": "Back to Login",
215
- "completingSignIn": "Completing sign in..."
215
+ "completingSignIn": "Completing sign in...",
216
+ "forgotPassword": "Forgot password?",
217
+ "forgotPasswordTitle": "Reset your password",
218
+ "forgotPasswordSubtitle": "Enter your email and we'll send you a reset link.",
219
+ "sendResetLink": "Send Reset Link",
220
+ "sendingResetLink": "Sending...",
221
+ "resetLinkSent": "If an account exists with this email, you'll receive a password reset link shortly.",
222
+ "resetPasswordTitle": "Set new password",
223
+ "resetPasswordSubtitle": "Enter your new password below.",
224
+ "newPassword": "New Password",
225
+ "newPasswordPlaceholder": "Enter new password",
226
+ "confirmPassword": "Confirm Password",
227
+ "confirmPasswordPlaceholder": "Re-enter new password",
228
+ "resetPassword": "Reset Password",
229
+ "resettingPassword": "Resetting...",
230
+ "passwordResetSuccess": "Password reset successfully! Redirecting to login...",
231
+ "passwordsMustMatch": "Passwords must match",
232
+ "invalidResetLink": "Invalid reset link",
233
+ "invalidResetLinkDesc": "This password reset link is invalid or has expired. Please request a new one."
216
234
  },
217
235
  "account": {
218
236
  "pageTitle": "My Account",
package/messages/he.json CHANGED
@@ -212,7 +212,25 @@
212
212
  "authFailed": "ההתחברות נכשלה",
213
213
  "authFailedDesc": "חסרים פרמטרי התחברות. אנא נסו שוב.",
214
214
  "backToLogin": "חזרה להתחברות",
215
- "completingSignIn": "...משלים התחברות"
215
+ "completingSignIn": "...משלים התחברות",
216
+ "forgotPassword": "שכחת סיסמא?",
217
+ "forgotPasswordTitle": "איפוס סיסמא",
218
+ "forgotPasswordSubtitle": "הזינו את האימייל שלכם ונשלח לכם קישור לאיפוס.",
219
+ "sendResetLink": "שליחת קישור איפוס",
220
+ "sendingResetLink": "...שולח",
221
+ "resetLinkSent": "אם קיים חשבון עם האימייל הזה, תקבלו קישור לאיפוס סיסמא בקרוב.",
222
+ "resetPasswordTitle": "הגדרת סיסמא חדשה",
223
+ "resetPasswordSubtitle": "הזינו את הסיסמא החדשה שלכם.",
224
+ "newPassword": "סיסמא חדשה",
225
+ "newPasswordPlaceholder": "הזינו סיסמא חדשה",
226
+ "confirmPassword": "אישור סיסמא",
227
+ "confirmPasswordPlaceholder": "הזינו שוב את הסיסמא",
228
+ "resetPassword": "איפוס סיסמא",
229
+ "resettingPassword": "...מאפס",
230
+ "passwordResetSuccess": "...הסיסמא אופסה בהצלחה! מעביר להתחברות",
231
+ "passwordsMustMatch": "הסיסמאות חייבות להיות זהות",
232
+ "invalidResetLink": "קישור איפוס לא תקין",
233
+ "invalidResetLinkDesc": "קישור האיפוס הזה אינו תקין או שפג תוקפו. בקשו קישור חדש."
216
234
  },
217
235
  "account": {
218
236
  "pageTitle": "החשבון שלי",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-brainerce-store",
3
- "version": "1.5.7",
3
+ "version": "1.6.1",
4
4
  "description": "Scaffold a production-ready e-commerce storefront connected to Brainerce",
5
5
  "bin": {
6
6
  "create-brainerce-store": "dist/index.js"
@@ -1,9 +1,31 @@
1
- import type { NextConfig } from 'next';
2
-
3
- const nextConfig: NextConfig = {
4
- images: {
5
- remotePatterns: [{ protocol: 'https', hostname: '**' }],
6
- },
7
- };
8
-
9
- export default nextConfig;
1
+ import type { NextConfig } from 'next';
2
+
3
+ const nextConfig: NextConfig = {
4
+ images: {
5
+ remotePatterns: [{ protocol: 'https', hostname: '**' }],
6
+ },
7
+ async headers() {
8
+ return [
9
+ {
10
+ source: '/(.*)',
11
+ headers: [
12
+ {
13
+ key: 'Content-Security-Policy',
14
+ value: [
15
+ "default-src 'self'",
16
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.meshulam.co.il https://meshulam.co.il https://*.meshulam.co.il https://grow.link https://*.grow.link https://*.grow.security https://js.stripe.com https://pay.google.com",
17
+ "style-src 'self' 'unsafe-inline'",
18
+ "img-src 'self' data: blob: https:",
19
+ "font-src 'self' data:",
20
+ "frame-src 'self' https://*.meshulam.co.il https://grow.link https://*.grow.link https://*.grow.security https://*.creditguard.co.il https://js.stripe.com https://hooks.stripe.com https://pay.google.com",
21
+ "connect-src 'self' https://*.meshulam.co.il https://grow.link https://*.grow.link https://*.grow.security https://google.com https://pay.google.com https://*.stripe.com https://*.creditguard.co.il",
22
+ "worker-src 'self' blob:",
23
+ ].join('; '),
24
+ },
25
+ ],
26
+ },
27
+ ];
28
+ },
29
+ };
30
+
31
+ export default nextConfig;
@@ -0,0 +1,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
+ 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
+ }
@@ -0,0 +1,161 @@
1
+ 'use client';
2
+
3
+ import { Suspense, useState } from 'react';
4
+ import { useRouter, useSearchParams } from 'next/navigation';
5
+ import Link from 'next/link';
6
+ import { getClient } from '@/lib/brainerce';
7
+ import { LoadingSpinner } from '@/components/shared/loading-spinner';
8
+ import { useTranslations } from '@/lib/translations';
9
+
10
+ function ResetPasswordContent() {
11
+ const router = useRouter();
12
+ const searchParams = useSearchParams();
13
+ const t = useTranslations('auth');
14
+
15
+ const token = searchParams.get('token');
16
+
17
+ const [newPassword, setNewPassword] = useState('');
18
+ const [confirmPassword, setConfirmPassword] = useState('');
19
+ const [loading, setLoading] = useState(false);
20
+ const [error, setError] = useState<string | null>(null);
21
+ const [success, setSuccess] = useState(false);
22
+
23
+ async function handleSubmit(e: React.FormEvent) {
24
+ e.preventDefault();
25
+ if (loading || !token) return;
26
+
27
+ if (newPassword !== confirmPassword) {
28
+ setError(t('passwordsMustMatch'));
29
+ return;
30
+ }
31
+
32
+ try {
33
+ setLoading(true);
34
+ setError(null);
35
+ const client = getClient();
36
+ await client.resetPassword(token, newPassword);
37
+ setSuccess(true);
38
+ setTimeout(() => router.push('/login'), 2000);
39
+ } catch (err) {
40
+ const message =
41
+ err instanceof Error ? err.message : 'Something went wrong. Please try again.';
42
+ setError(message);
43
+ } finally {
44
+ setLoading(false);
45
+ }
46
+ }
47
+
48
+ if (!token) {
49
+ return (
50
+ <div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
51
+ <div className="w-full max-w-md space-y-4 text-center">
52
+ <h1 className="text-foreground text-2xl font-bold">{t('invalidResetLink')}</h1>
53
+ <p className="text-muted-foreground text-sm">{t('invalidResetLinkDesc')}</p>
54
+ <Link
55
+ href="/forgot-password"
56
+ className="bg-primary text-primary-foreground inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
57
+ >
58
+ {t('sendResetLink')}
59
+ </Link>
60
+ </div>
61
+ </div>
62
+ );
63
+ }
64
+
65
+ return (
66
+ <div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
67
+ <div className="w-full max-w-md space-y-6">
68
+ <div className="text-center">
69
+ <h1 className="text-foreground text-2xl font-bold">{t('resetPasswordTitle')}</h1>
70
+ <p className="text-muted-foreground mt-1 text-sm">{t('resetPasswordSubtitle')}</p>
71
+ </div>
72
+
73
+ {error && (
74
+ <div className="bg-destructive/10 border-destructive/20 text-destructive rounded-lg border px-4 py-3 text-sm">
75
+ {error}
76
+ </div>
77
+ )}
78
+
79
+ {success ? (
80
+ <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">
81
+ {t('passwordResetSuccess')}
82
+ </div>
83
+ ) : (
84
+ <form onSubmit={handleSubmit} className="space-y-4">
85
+ <div>
86
+ <label
87
+ htmlFor="new-password"
88
+ className="text-foreground mb-1.5 block text-sm font-medium"
89
+ >
90
+ {t('newPassword')}
91
+ </label>
92
+ <input
93
+ id="new-password"
94
+ type="password"
95
+ required
96
+ minLength={8}
97
+ value={newPassword}
98
+ onChange={(e) => setNewPassword(e.target.value)}
99
+ placeholder={t('newPasswordPlaceholder')}
100
+ autoComplete="new-password"
101
+ 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"
102
+ />
103
+ </div>
104
+
105
+ <div>
106
+ <label
107
+ htmlFor="confirm-password"
108
+ className="text-foreground mb-1.5 block text-sm font-medium"
109
+ >
110
+ {t('confirmPassword')}
111
+ </label>
112
+ <input
113
+ id="confirm-password"
114
+ type="password"
115
+ required
116
+ minLength={8}
117
+ value={confirmPassword}
118
+ onChange={(e) => setConfirmPassword(e.target.value)}
119
+ placeholder={t('confirmPasswordPlaceholder')}
120
+ autoComplete="new-password"
121
+ 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"
122
+ />
123
+ </div>
124
+
125
+ <button
126
+ type="submit"
127
+ disabled={loading}
128
+ 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"
129
+ >
130
+ {loading ? (
131
+ <>
132
+ <LoadingSpinner
133
+ size="sm"
134
+ className="border-primary-foreground/30 border-t-primary-foreground"
135
+ />
136
+ {t('resettingPassword')}
137
+ </>
138
+ ) : (
139
+ t('resetPassword')
140
+ )}
141
+ </button>
142
+ </form>
143
+ )}
144
+ </div>
145
+ </div>
146
+ );
147
+ }
148
+
149
+ export default function ResetPasswordPage() {
150
+ return (
151
+ <Suspense
152
+ fallback={
153
+ <div className="flex min-h-[60vh] items-center justify-center">
154
+ <LoadingSpinner size="lg" />
155
+ </div>
156
+ }
157
+ >
158
+ <ResetPasswordContent />
159
+ </Suspense>
160
+ );
161
+ }
@@ -1,276 +1,278 @@
1
- 'use client';
2
-
3
- import { useState } from 'react';
4
- import Image from 'next/image';
5
- import type { Order, OrderStatus } from 'brainerce';
6
- import { formatPrice } from 'brainerce';
7
- import { useTranslations } from '@/lib/translations';
8
- import { cn } from '@/lib/utils';
9
-
10
- const STATUS_CONFIG: Record<OrderStatus, { labelKey: string; className: string }> = {
11
- pending: {
12
- labelKey: 'statusPending',
13
- className: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-950/30 dark:text-yellow-400',
14
- },
15
- processing: {
16
- labelKey: 'statusProcessing',
17
- className: 'bg-blue-100 text-blue-800 dark:bg-blue-950/30 dark:text-blue-400',
18
- },
19
- shipped: {
20
- labelKey: 'statusShipped',
21
- className: 'bg-purple-100 text-purple-800 dark:bg-purple-950/30 dark:text-purple-400',
22
- },
23
- delivered: {
24
- labelKey: 'statusDelivered',
25
- className: 'bg-green-100 text-green-800 dark:bg-green-950/30 dark:text-green-400',
26
- },
27
- cancelled: {
28
- labelKey: 'statusCancelled',
29
- className: 'bg-red-100 text-red-800 dark:bg-red-950/30 dark:text-red-400',
30
- },
31
- refunded: {
32
- labelKey: 'statusRefunded',
33
- className: 'bg-orange-100 text-orange-800 dark:bg-orange-950/30 dark:text-orange-400',
34
- },
35
- };
36
-
37
- interface OrderHistoryProps {
38
- orders: Order[];
39
- className?: string;
40
- }
41
-
42
- export function OrderHistory({ orders, className }: OrderHistoryProps) {
43
- const t = useTranslations('account');
44
- if (orders.length === 0) {
45
- return (
46
- <div className={cn('py-12 text-center', className)}>
47
- <svg
48
- className="text-muted-foreground mx-auto mb-3 h-12 w-12"
49
- fill="none"
50
- viewBox="0 0 24 24"
51
- stroke="currentColor"
52
- >
53
- <path
54
- strokeLinecap="round"
55
- strokeLinejoin="round"
56
- strokeWidth={1.5}
57
- d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
58
- />
59
- </svg>
60
- <h3 className="text-foreground text-lg font-semibold">{t('noOrders')}</h3>
61
- <p className="text-muted-foreground mt-1 text-sm">{t('noOrdersDesc')}</p>
62
- </div>
63
- );
64
- }
65
-
66
- return (
67
- <div className={cn('space-y-4', className)}>
68
- {orders.map((order) => (
69
- <OrderCard key={order.id} order={order} />
70
- ))}
71
- </div>
72
- );
73
- }
74
-
75
- function OrderCard({ order }: { order: Order }) {
76
- const t = useTranslations('account');
77
- const tc = useTranslations('common');
78
- const [expanded, setExpanded] = useState(false);
79
- const statusConfig = STATUS_CONFIG[order.status] || STATUS_CONFIG.pending;
80
- const currency = order.currency || 'USD';
81
- const totalAmount = order.totalAmount || order.total || '0';
82
-
83
- return (
84
- <div className="border-border overflow-hidden rounded-lg border">
85
- {/* Order header */}
86
- <button
87
- type="button"
88
- onClick={() => setExpanded(!expanded)}
89
- className="hover:bg-muted/50 flex w-full items-center justify-between p-4 text-start transition-colors"
90
- >
91
- <div className="min-w-0 flex-1">
92
- <div className="flex flex-wrap items-center gap-3">
93
- <span className="text-foreground text-sm font-semibold">
94
- {order.orderNumber || `${t('orderPrefix')} ${order.id.slice(0, 8)}`}
95
- </span>
96
- <span
97
- className={cn(
98
- 'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
99
- statusConfig.className
100
- )}
101
- >
102
- {t(
103
- statusConfig.labelKey as
104
- | 'statusPending'
105
- | 'statusProcessing'
106
- | 'statusShipped'
107
- | 'statusDelivered'
108
- | 'statusCancelled'
109
- | 'statusRefunded'
110
- )}
111
- </span>
112
- </div>
113
- <div className="text-muted-foreground mt-1 flex items-center gap-4 text-xs">
114
- <span>
115
- {new Date(order.createdAt).toLocaleDateString(undefined, {
116
- year: 'numeric',
117
- month: 'short',
118
- day: 'numeric',
119
- })}
120
- </span>
121
- <span>
122
- {order.items.length} {order.items.length === 1 ? tc('item') : tc('items')}
123
- </span>
124
- </div>
125
- </div>
126
-
127
- <div className="flex flex-shrink-0 items-center gap-3">
128
- <span className="text-foreground text-sm font-semibold">
129
- {formatPrice(parseFloat(totalAmount), { currency }) as string}
130
- </span>
131
- <svg
132
- className={cn(
133
- 'text-muted-foreground h-4 w-4 transition-transform',
134
- expanded && 'rotate-180'
135
- )}
136
- fill="none"
137
- viewBox="0 0 24 24"
138
- stroke="currentColor"
139
- >
140
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
141
- </svg>
142
- </div>
143
- </button>
144
-
145
- {/* Expanded order items */}
146
- {expanded && (
147
- <div className="border-border bg-muted/30 space-y-3 border-t px-4 py-3">
148
- {order.items.map((item, index) => (
149
- <div key={`${item.productId}-${index}`} className="flex items-center gap-3">
150
- <div className="bg-muted relative h-10 w-10 flex-shrink-0 overflow-hidden rounded">
151
- {item.image ? (
152
- <Image
153
- src={item.image}
154
- alt={item.name || t('productFallback')}
155
- fill
156
- sizes="40px"
157
- className="object-cover"
158
- />
159
- ) : (
160
- <div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
161
- <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
162
- <path
163
- strokeLinecap="round"
164
- strokeLinejoin="round"
165
- strokeWidth={1.5}
166
- d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
167
- />
168
- </svg>
169
- </div>
170
- )}
171
- </div>
172
-
173
- <div className="min-w-0 flex-1">
174
- <p className="text-foreground truncate text-sm">
175
- {item.name || t('productFallback')}
176
- </p>
177
- <p className="text-muted-foreground text-xs">
178
- {tc('qty')} {item.quantity}
179
- </p>
180
- </div>
181
-
182
- <span className="text-foreground flex-shrink-0 text-sm">
183
- {formatPrice(parseFloat(item.price), { currency }) as string}
184
- </span>
185
- </div>
186
- ))}
187
-
188
- <OrderFinancialSummary order={order} currency={currency} />
189
- </div>
190
- )}
191
- </div>
192
- );
193
- }
194
-
195
- function OrderFinancialSummary({ order, currency }: { order: Order; currency: string }) {
196
- const tc = useTranslations('common');
197
- const totalAmount = order.totalAmount || order.total || '0';
198
- const subtotal = order.subtotal ? parseFloat(order.subtotal) : null;
199
- const ruleAmt = order.ruleDiscountAmount ? parseFloat(order.ruleDiscountAmount) : 0;
200
- const couponAmt = order.couponDiscount ? parseFloat(order.couponDiscount) : 0;
201
- const shipping = order.shippingAmount ? parseFloat(order.shippingAmount) : 0;
202
- const tax = order.taxAmount ? parseFloat(order.taxAmount) : 0;
203
- const rules = order.appliedDiscounts;
204
-
205
- const hasBreakdown = subtotal !== null && subtotal > 0;
206
-
207
- if (!hasBreakdown) {
208
- return (
209
- <div className="border-border flex items-center justify-between border-t pt-2">
210
- <span className="text-muted-foreground text-sm font-medium">{tc('total')}</span>
211
- <span className="text-foreground text-sm font-semibold">
212
- {formatPrice(parseFloat(totalAmount), { currency }) as string}
213
- </span>
214
- </div>
215
- );
216
- }
217
-
218
- return (
219
- <div className="border-border space-y-1 border-t pt-2 text-sm">
220
- <div className="flex items-center justify-between">
221
- <span className="text-muted-foreground">{tc('subtotal')}</span>
222
- <span className="text-foreground">{formatPrice(subtotal, { currency }) as string}</span>
223
- </div>
224
-
225
- {rules && rules.length > 0
226
- ? rules.map((rule) => (
227
- <div key={rule.ruleId} className="flex items-center justify-between">
228
- <span className="text-muted-foreground">{rule.ruleName}</span>
229
- <span className="text-destructive">
230
- -{formatPrice(parseFloat(rule.discountAmount || '0'), { currency }) as string}
231
- </span>
232
- </div>
233
- ))
234
- : ruleAmt > 0 && (
235
- <div className="flex items-center justify-between">
236
- <span className="text-muted-foreground">{tc('generalDiscount')}</span>
237
- <span className="text-destructive">
238
- -{formatPrice(ruleAmt, { currency }) as string}
239
- </span>
240
- </div>
241
- )}
242
-
243
- {order.couponCode && couponAmt > 0 && (
244
- <div className="flex items-center justify-between">
245
- <span className="text-muted-foreground">
246
- {tc('couponDiscount')} ({order.couponCode})
247
- </span>
248
- <span className="text-destructive">
249
- -{formatPrice(couponAmt, { currency }) as string}
250
- </span>
251
- </div>
252
- )}
253
-
254
- {shipping > 0 && (
255
- <div className="flex items-center justify-between">
256
- <span className="text-muted-foreground">{tc('shipping')}</span>
257
- <span className="text-foreground">{formatPrice(shipping, { currency }) as string}</span>
258
- </div>
259
- )}
260
-
261
- {tax > 0 && (
262
- <div className="flex items-center justify-between">
263
- <span className="text-muted-foreground">{tc('tax')}</span>
264
- <span className="text-foreground">{formatPrice(tax, { currency }) as string}</span>
265
- </div>
266
- )}
267
-
268
- <div className="border-border flex items-center justify-between border-t pt-1">
269
- <span className="text-foreground font-medium">{tc('total')}</span>
270
- <span className="text-foreground font-semibold">
271
- {formatPrice(parseFloat(totalAmount), { currency }) as string}
272
- </span>
273
- </div>
274
- </div>
275
- );
276
- }
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import Image from 'next/image';
5
+ import type { Order, OrderStatus } from 'brainerce';
6
+ import { formatPrice } from 'brainerce';
7
+ import { useTranslations } from '@/lib/translations';
8
+ import { cn } from '@/lib/utils';
9
+
10
+ const STATUS_CONFIG: Record<OrderStatus, { labelKey: string; className: string }> = {
11
+ pending: {
12
+ labelKey: 'statusPending',
13
+ className: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-950/30 dark:text-yellow-400',
14
+ },
15
+ processing: {
16
+ labelKey: 'statusProcessing',
17
+ className: 'bg-blue-100 text-blue-800 dark:bg-blue-950/30 dark:text-blue-400',
18
+ },
19
+ shipped: {
20
+ labelKey: 'statusShipped',
21
+ className: 'bg-purple-100 text-purple-800 dark:bg-purple-950/30 dark:text-purple-400',
22
+ },
23
+ delivered: {
24
+ labelKey: 'statusDelivered',
25
+ className: 'bg-green-100 text-green-800 dark:bg-green-950/30 dark:text-green-400',
26
+ },
27
+ cancelled: {
28
+ labelKey: 'statusCancelled',
29
+ className: 'bg-red-100 text-red-800 dark:bg-red-950/30 dark:text-red-400',
30
+ },
31
+ refunded: {
32
+ labelKey: 'statusRefunded',
33
+ className: 'bg-orange-100 text-orange-800 dark:bg-orange-950/30 dark:text-orange-400',
34
+ },
35
+ };
36
+
37
+ interface OrderHistoryProps {
38
+ orders: Order[];
39
+ className?: string;
40
+ }
41
+
42
+ export function OrderHistory({ orders, className }: OrderHistoryProps) {
43
+ const t = useTranslations('account');
44
+ if (orders.length === 0) {
45
+ return (
46
+ <div className={cn('py-12 text-center', className)}>
47
+ <svg
48
+ className="text-muted-foreground mx-auto mb-3 h-12 w-12"
49
+ fill="none"
50
+ viewBox="0 0 24 24"
51
+ stroke="currentColor"
52
+ >
53
+ <path
54
+ strokeLinecap="round"
55
+ strokeLinejoin="round"
56
+ strokeWidth={1.5}
57
+ d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
58
+ />
59
+ </svg>
60
+ <h3 className="text-foreground text-lg font-semibold">{t('noOrders')}</h3>
61
+ <p className="text-muted-foreground mt-1 text-sm">{t('noOrdersDesc')}</p>
62
+ </div>
63
+ );
64
+ }
65
+
66
+ return (
67
+ <div className={cn('space-y-4', className)}>
68
+ {orders.map((order) => (
69
+ <OrderCard key={order.id} order={order} />
70
+ ))}
71
+ </div>
72
+ );
73
+ }
74
+
75
+ function OrderCard({ order }: { order: Order }) {
76
+ const t = useTranslations('account');
77
+ const tc = useTranslations('common');
78
+ const [expanded, setExpanded] = useState(false);
79
+ const statusConfig = STATUS_CONFIG[order.status] || STATUS_CONFIG.pending;
80
+ const currency = order.currency || 'USD';
81
+ const totalAmount = order.totalAmount || order.total || '0';
82
+
83
+ return (
84
+ <div className="border-border overflow-hidden rounded-lg border">
85
+ {/* Order header */}
86
+ <button
87
+ type="button"
88
+ onClick={() => setExpanded(!expanded)}
89
+ className="hover:bg-muted/50 flex w-full items-center justify-between p-4 text-start transition-colors"
90
+ >
91
+ <div className="min-w-0 flex-1">
92
+ <div className="flex flex-wrap items-center gap-3">
93
+ <span className="text-foreground text-sm font-semibold">
94
+ {order.orderNumber || `${t('orderPrefix')} ${order.id.slice(0, 8)}`}
95
+ </span>
96
+ <span
97
+ className={cn(
98
+ 'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
99
+ statusConfig.className
100
+ )}
101
+ >
102
+ {t(
103
+ statusConfig.labelKey as
104
+ | 'statusPending'
105
+ | 'statusProcessing'
106
+ | 'statusShipped'
107
+ | 'statusDelivered'
108
+ | 'statusCancelled'
109
+ | 'statusRefunded'
110
+ )}
111
+ </span>
112
+ </div>
113
+ <div className="text-muted-foreground mt-1 flex items-center gap-4 text-xs">
114
+ <span>
115
+ {order.createdAt && !isNaN(new Date(order.createdAt).getTime())
116
+ ? new Date(order.createdAt).toLocaleDateString(undefined, {
117
+ year: 'numeric',
118
+ month: 'short',
119
+ day: 'numeric',
120
+ })
121
+ : '—'}
122
+ </span>
123
+ <span>
124
+ {order.items.length} {order.items.length === 1 ? tc('item') : tc('items')}
125
+ </span>
126
+ </div>
127
+ </div>
128
+
129
+ <div className="flex flex-shrink-0 items-center gap-3">
130
+ <span className="text-foreground text-sm font-semibold">
131
+ {formatPrice(parseFloat(totalAmount), { currency }) as string}
132
+ </span>
133
+ <svg
134
+ className={cn(
135
+ 'text-muted-foreground h-4 w-4 transition-transform',
136
+ expanded && 'rotate-180'
137
+ )}
138
+ fill="none"
139
+ viewBox="0 0 24 24"
140
+ stroke="currentColor"
141
+ >
142
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
143
+ </svg>
144
+ </div>
145
+ </button>
146
+
147
+ {/* Expanded order items */}
148
+ {expanded && (
149
+ <div className="border-border bg-muted/30 space-y-3 border-t px-4 py-3">
150
+ {order.items.map((item, index) => (
151
+ <div key={`${item.productId}-${index}`} className="flex items-center gap-3">
152
+ <div className="bg-muted relative h-10 w-10 flex-shrink-0 overflow-hidden rounded">
153
+ {item.image ? (
154
+ <Image
155
+ src={item.image}
156
+ alt={item.name || t('productFallback')}
157
+ fill
158
+ sizes="40px"
159
+ className="object-cover"
160
+ />
161
+ ) : (
162
+ <div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
163
+ <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
164
+ <path
165
+ strokeLinecap="round"
166
+ strokeLinejoin="round"
167
+ strokeWidth={1.5}
168
+ d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
169
+ />
170
+ </svg>
171
+ </div>
172
+ )}
173
+ </div>
174
+
175
+ <div className="min-w-0 flex-1">
176
+ <p className="text-foreground truncate text-sm">
177
+ {item.name || t('productFallback')}
178
+ </p>
179
+ <p className="text-muted-foreground text-xs">
180
+ {tc('qty')} {item.quantity}
181
+ </p>
182
+ </div>
183
+
184
+ <span className="text-foreground flex-shrink-0 text-sm">
185
+ {formatPrice(parseFloat(item.price), { currency }) as string}
186
+ </span>
187
+ </div>
188
+ ))}
189
+
190
+ <OrderFinancialSummary order={order} currency={currency} />
191
+ </div>
192
+ )}
193
+ </div>
194
+ );
195
+ }
196
+
197
+ function OrderFinancialSummary({ order, currency }: { order: Order; currency: string }) {
198
+ const tc = useTranslations('common');
199
+ const totalAmount = order.totalAmount || order.total || '0';
200
+ const subtotal = order.subtotal ? parseFloat(order.subtotal) : null;
201
+ const ruleAmt = order.ruleDiscountAmount ? parseFloat(order.ruleDiscountAmount) : 0;
202
+ const couponAmt = order.couponDiscount ? parseFloat(order.couponDiscount) : 0;
203
+ const shipping = order.shippingAmount ? parseFloat(order.shippingAmount) : 0;
204
+ const tax = order.taxAmount ? parseFloat(order.taxAmount) : 0;
205
+ const rules = order.appliedDiscounts;
206
+
207
+ const hasBreakdown = subtotal !== null && subtotal > 0;
208
+
209
+ if (!hasBreakdown) {
210
+ return (
211
+ <div className="border-border flex items-center justify-between border-t pt-2">
212
+ <span className="text-muted-foreground text-sm font-medium">{tc('total')}</span>
213
+ <span className="text-foreground text-sm font-semibold">
214
+ {formatPrice(parseFloat(totalAmount), { currency }) as string}
215
+ </span>
216
+ </div>
217
+ );
218
+ }
219
+
220
+ return (
221
+ <div className="border-border space-y-1 border-t pt-2 text-sm">
222
+ <div className="flex items-center justify-between">
223
+ <span className="text-muted-foreground">{tc('subtotal')}</span>
224
+ <span className="text-foreground">{formatPrice(subtotal, { currency }) as string}</span>
225
+ </div>
226
+
227
+ {rules && rules.length > 0
228
+ ? rules.map((rule) => (
229
+ <div key={rule.ruleId} className="flex items-center justify-between">
230
+ <span className="text-muted-foreground">{rule.ruleName}</span>
231
+ <span className="text-destructive">
232
+ -{formatPrice(parseFloat(rule.discountAmount || '0'), { currency }) as string}
233
+ </span>
234
+ </div>
235
+ ))
236
+ : ruleAmt > 0 && (
237
+ <div className="flex items-center justify-between">
238
+ <span className="text-muted-foreground">{tc('generalDiscount')}</span>
239
+ <span className="text-destructive">
240
+ -{formatPrice(ruleAmt, { currency }) as string}
241
+ </span>
242
+ </div>
243
+ )}
244
+
245
+ {order.couponCode && couponAmt > 0 && (
246
+ <div className="flex items-center justify-between">
247
+ <span className="text-muted-foreground">
248
+ {tc('couponDiscount')} ({order.couponCode})
249
+ </span>
250
+ <span className="text-destructive">
251
+ -{formatPrice(couponAmt, { currency }) as string}
252
+ </span>
253
+ </div>
254
+ )}
255
+
256
+ {shipping > 0 && (
257
+ <div className="flex items-center justify-between">
258
+ <span className="text-muted-foreground">{tc('shipping')}</span>
259
+ <span className="text-foreground">{formatPrice(shipping, { currency }) as string}</span>
260
+ </div>
261
+ )}
262
+
263
+ {tax > 0 && (
264
+ <div className="flex items-center justify-between">
265
+ <span className="text-muted-foreground">{tc('tax')}</span>
266
+ <span className="text-foreground">{formatPrice(tax, { currency }) as string}</span>
267
+ </div>
268
+ )}
269
+
270
+ <div className="border-border flex items-center justify-between border-t pt-1">
271
+ <span className="text-foreground font-medium">{tc('total')}</span>
272
+ <span className="text-foreground font-semibold">
273
+ {formatPrice(parseFloat(totalAmount), { currency }) as string}
274
+ </span>
275
+ </div>
276
+ </div>
277
+ );
278
+ }
@@ -193,14 +193,16 @@ export function ProfileSection({ profile, onProfileUpdate, className }: ProfileS
193
193
  <p className="text-muted-foreground mt-2 text-sm">{profile.phone}</p>
194
194
  )}
195
195
 
196
- <p className="text-muted-foreground mt-3 text-xs">
197
- {t('memberSince')}{' '}
198
- {new Date(profile.createdAt).toLocaleDateString(undefined, {
199
- year: 'numeric',
200
- month: 'long',
201
- day: 'numeric',
202
- })}
203
- </p>
196
+ {profile.createdAt && !isNaN(new Date(profile.createdAt).getTime()) && (
197
+ <p className="text-muted-foreground mt-3 text-xs">
198
+ {t('memberSince')}{' '}
199
+ {new Date(profile.createdAt).toLocaleDateString(undefined, {
200
+ year: 'numeric',
201
+ month: 'long',
202
+ day: 'numeric',
203
+ })}
204
+ </p>
205
+ )}
204
206
  </>
205
207
  )}
206
208
 
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useState } from 'react';
4
+ import Link from 'next/link';
4
5
  import { useTranslations } from '@/lib/translations';
5
6
  import { cn } from '@/lib/utils';
6
7
  import { LoadingSpinner } from '@/components/shared/loading-spinner';
@@ -72,6 +73,12 @@ export function LoginForm({ onSubmit, error, className }: LoginFormProps) {
72
73
  />
73
74
  </div>
74
75
 
76
+ <div className="flex justify-end">
77
+ <Link href="/forgot-password" className="text-primary text-sm hover:underline">
78
+ {t('forgotPassword')}
79
+ </Link>
80
+ </div>
81
+
75
82
  <button
76
83
  type="submit"
77
84
  disabled={loading}
@@ -120,9 +120,21 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
120
120
  }, []);
121
121
 
122
122
  const handleSdkPaymentError = useCallback((response: unknown) => {
123
+ const TRANSIENT_SDK_ERRORS = [
124
+ 'Wallet not initialized',
125
+ "SDK was not loaded as needed and therefore can't run",
126
+ ];
127
+
128
+ const msg = (response as { message?: string })?.message || '';
129
+ // Grow SDK fires transient errors during startup before its internal
130
+ // state is ready. The polling mechanism in Step 3 retries
131
+ // renderPaymentOptions() until the SDK is fully initialized.
132
+ if (TRANSIENT_SDK_ERRORS.some((e) => msg.includes(e))) {
133
+ console.info('Payment SDK: transient startup error, waiting for retry...', msg);
134
+ return;
135
+ }
123
136
  console.error('Payment SDK error:', response);
124
- const msg = (response as { message?: string })?.message || t('paymentError');
125
- setError(msg);
137
+ setError(msg || t('paymentError'));
126
138
  }, []);
127
139
 
128
140
  // Step 1: Load SDK scripts — just load, don't init yet
@@ -214,6 +226,7 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
214
226
  : paymentIntent.clientSecret;
215
227
 
216
228
  let rendered = false;
229
+ let walletReady = false;
217
230
  let pollId: ReturnType<typeof setInterval>;
218
231
 
219
232
  function tryRenderPaymentOptions() {
@@ -239,20 +252,29 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
239
252
  onError: handleSdkPaymentError,
240
253
  onWalletChange: (state: string) => {
241
254
  console.info('Payment SDK wallet state:', state);
255
+ walletReady = true;
242
256
  tryRenderPaymentOptions();
243
257
  },
244
258
  },
245
259
  });
246
260
 
247
- // Poll as fallback: wait 1s for init() to finish, then poll every 500ms
261
+ // Poll as fallback but respect the SDK's readiness signal:
262
+ // - First 4s: only render if onWalletChange has fired (SDK says it's ready)
263
+ // - After 4s: force-attempt even without wallet signal (onWalletChange may not fire)
264
+ const WALLET_GRACE_PERIOD = 4000;
265
+ const initTime = Date.now();
266
+
248
267
  const timeoutId = setTimeout(() => {
249
- tryRenderPaymentOptions();
268
+ if (walletReady) tryRenderPaymentOptions();
250
269
  if (!rendered) {
251
270
  let attempts = 0;
252
- const maxAttempts = 16; // 16 * 500ms = 8 seconds after initial delay
271
+ const maxAttempts = 16; // 16 * 500ms = 8s after initial 1s delay
253
272
  pollId = setInterval(() => {
254
273
  attempts++;
255
- tryRenderPaymentOptions();
274
+ const elapsed = Date.now() - initTime;
275
+ if (walletReady || elapsed > WALLET_GRACE_PERIOD) {
276
+ tryRenderPaymentOptions();
277
+ }
256
278
  if (!rendered && attempts >= maxAttempts) {
257
279
  clearInterval(pollId);
258
280
  console.error('Payment SDK: renderPaymentOptions failed after max attempts');