create-brainerce-store 1.84.0 → 1.85.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.
Files changed (42) hide show
  1. package/README.md +130 -92
  2. package/dist/index.js +39 -17
  3. package/messages/en.json +3 -0
  4. package/messages/he.json +3 -0
  5. package/package.json +1 -1
  6. package/templates/nextjs/base/.env.local.ejs +10 -1
  7. package/templates/nextjs/base/AGENTS.md.ejs +229 -207
  8. package/templates/nextjs/base/AI-GUIDE.md +4 -0
  9. package/templates/nextjs/base/CLAUDE.md.ejs +240 -218
  10. package/templates/nextjs/base/scripts/connect.mjs +115 -18
  11. package/templates/nextjs/base/scripts/env-file.mjs +93 -0
  12. package/templates/nextjs/base/scripts/fetch-store-info.mjs +109 -83
  13. package/templates/nextjs/base/src/app/api/auth/reset-callback/route.ts +2 -1
  14. package/templates/nextjs/base/src/app/forgot-password/page.tsx +3 -2
  15. package/templates/nextjs/base/src/app/layout.tsx.ejs +50 -21
  16. package/templates/nextjs/base/src/app/opengraph-image.tsx +5 -1
  17. package/templates/nextjs/base/src/app/reset-password/page.tsx +25 -138
  18. package/templates/nextjs/base/src/app/reset-password/reset-password-form.tsx +138 -0
  19. package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +174 -137
  20. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +82 -11
  21. package/templates/nextjs/base/src/core/lib/store-name.ts.ejs +56 -0
  22. package/templates/nextjs/base/src/core/lib/use-store-name.ts +18 -0
  23. package/templates/nextjs/base/src/ui/home/hero-section.tsx +27 -27
  24. package/templates/nextjs/base/src/ui/layout/header-search.tsx +2 -2
  25. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +160 -160
  26. package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +2 -2
  27. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +374 -345
  28. package/templates/nextjs/designs/atelier/globals.css +408 -402
  29. package/templates/nextjs/designs/atelier/messages-patch/en.json +91 -96
  30. package/templates/nextjs/designs/atelier/messages-patch/he.json +91 -96
  31. package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +131 -128
  32. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +2 -1
  33. package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +222 -219
  34. package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +178 -151
  35. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +61 -31
  36. package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +2 -2
  37. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +161 -155
  38. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +232 -227
  39. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +271 -268
  40. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +558 -553
  41. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +113 -110
  42. package/templates/nextjs/designs/atelier/ui/shared/no-photo-tile.tsx +73 -0
@@ -13,6 +13,8 @@ import { resolveRegion } from '@/core/lib/region.server';
13
13
  import { getDirection, getMessages, supportedLocales } from '@/i18n';
14
14
  import { getNonce } from '@/core/lib/nonce';
15
15
  import { getCanonicalSiteUrl } from '@/core/lib/site-url';
16
+ import { resolveStoreName, buildTitleTemplate } from '@/core/lib/store-name';
17
+ import { buildMetaDescription } from '@/core/lib/seo';
16
18
  import '../globals.css';
17
19
 
18
20
  <%- fontVariable %>
@@ -20,20 +22,32 @@ import '../globals.css';
20
22
  // Resolved per request rather than read from a hardcoded env default: the
21
23
  // scaffolder cannot know the deploy domain, so `metadataBase` is the one
22
24
  // value that must never be guessed. See core/lib/site-url.ts.
23
- export async function generateMetadata(): Promise<Metadata> {
24
- const baseUrl = await getCanonicalSiteUrl();
25
+ //
26
+ // The store NAME is resolved the same way, and for the same reason: under
27
+ // `--defer-connection` the scaffolder only knew the directory name. Live
28
+ // `storeInfo.name` wins (`fetchStoreInfo` is `cache()`-wrapped, so this shares
29
+ // the layout's fetch), then the build-time NEXT_PUBLIC_STORE_NAME, then the
30
+ // scaffold literal — see core/lib/store-name.ts.
31
+ export async function generateMetadata({
32
+ params,
33
+ }: {
34
+ params: Promise<{ locale: string }>;
35
+ }): Promise<Metadata> {
36
+ const { locale } = await params;
37
+ const [baseUrl, storeInfo] = await Promise.all([getCanonicalSiteUrl(), fetchStoreInfo(locale)]);
38
+ const storeName = resolveStoreName(storeInfo);
25
39
  return {
26
40
  metadataBase: new URL(baseUrl),
27
41
  title: {
28
- default: <%- storeNameJs %>,
29
- template: <%- titleTemplateJs %>,
42
+ default: storeName,
43
+ template: buildTitleTemplate(storeName),
30
44
  },
31
- description: <%- storeNameJs %>,
45
+ description: buildMetaDescription(storeInfo?.metaDescription) || storeName,
32
46
  alternates: {
33
47
  canonical: '/',
34
48
  },
35
49
  openGraph: {
36
- siteName: <%- storeNameJs %>,
50
+ siteName: storeName,
37
51
  type: 'website',
38
52
  },
39
53
  robots: {
@@ -43,11 +57,11 @@ export async function generateMetadata(): Promise<Metadata> {
43
57
  };
44
58
  }
45
59
 
46
- function buildOrganizationJsonLd(baseUrl: string) {
60
+ function buildOrganizationJsonLd(baseUrl: string, storeName: string) {
47
61
  return {
48
62
  '@context': 'https://schema.org',
49
63
  '@type': 'Organization',
50
- name: <%- storeNameJs %>,
64
+ name: storeName,
51
65
  url: baseUrl,
52
66
  };
53
67
  }
@@ -99,6 +113,9 @@ export default async function RootLayout({
99
113
  // `regionId`, which is what this storefront did before regions existed.
100
114
  resolveRegion(),
101
115
  ]);
116
+ // One resolved name for the JSON-LD, the header and the footer. Live data
117
+ // first; the scaffold-time literal is only the last fallback.
118
+ const storeName = resolveStoreName(storeInfo);
102
119
 
103
120
  return (
104
121
  <html lang={locale} dir={dir}>
@@ -108,7 +125,7 @@ export default async function RootLayout({
108
125
  nonce={nonce}
109
126
  suppressHydrationWarning
110
127
  dangerouslySetInnerHTML={{
111
- __html: JSON.stringify(buildOrganizationJsonLd(baseUrl))
128
+ __html: JSON.stringify(buildOrganizationJsonLd(baseUrl, storeName))
112
129
  .replace(/</g, '\\u003c')
113
130
  .replace(/>/g, '\\u003e')
114
131
  .replace(/&/g, '\\u0026'),
@@ -152,10 +169,10 @@ export default async function RootLayout({
152
169
  <TrackingBootstrap />
153
170
  <div className="min-h-screen flex flex-col">
154
171
  <AnnouncementBar announcements={announcements} />
155
- <SiteHeader header={siteHeader} storeName={<%- storeNameJs %>} />
172
+ <SiteHeader header={siteHeader} storeName={storeName} />
156
173
  <DiscountBannerStrip banners={discountBanners} />
157
174
  <main className="flex-1">{children}</main>
158
- <SiteFooter footer={siteFooter} storeName={<%- storeNameJs %>} />
175
+ <SiteFooter footer={siteFooter} storeName={storeName} />
159
176
  </div>
160
177
  <BrainerceBotWidget />
161
178
  </StoreProvider>
@@ -177,6 +194,8 @@ import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce.server';
177
194
  import { resolveRegion } from '@/core/lib/region.server';
178
195
  import { getNonce } from '@/core/lib/nonce';
179
196
  import { getCanonicalSiteUrl } from '@/core/lib/site-url';
197
+ import { resolveStoreName, buildTitleTemplate } from '@/core/lib/store-name';
198
+ import { buildMetaDescription } from '@/core/lib/seo';
180
199
  import './globals.css';
181
200
 
182
201
  <%- fontVariable %>
@@ -184,20 +203,27 @@ import './globals.css';
184
203
  // Resolved per request rather than read from a hardcoded env default: the
185
204
  // scaffolder cannot know the deploy domain, so `metadataBase` is the one
186
205
  // value that must never be guessed. See core/lib/site-url.ts.
206
+ //
207
+ // The store NAME is resolved the same way, and for the same reason: under
208
+ // `--defer-connection` the scaffolder only knew the directory name. Live
209
+ // `storeInfo.name` wins (`fetchStoreInfo` is `cache()`-wrapped, so this shares
210
+ // the layout's fetch), then the build-time NEXT_PUBLIC_STORE_NAME, then the
211
+ // scaffold literal — see core/lib/store-name.ts.
187
212
  export async function generateMetadata(): Promise<Metadata> {
188
- const baseUrl = await getCanonicalSiteUrl();
213
+ const [baseUrl, storeInfo] = await Promise.all([getCanonicalSiteUrl(), fetchStoreInfo()]);
214
+ const storeName = resolveStoreName(storeInfo);
189
215
  return {
190
216
  metadataBase: new URL(baseUrl),
191
217
  title: {
192
- default: <%- storeNameJs %>,
193
- template: <%- titleTemplateJs %>,
218
+ default: storeName,
219
+ template: buildTitleTemplate(storeName),
194
220
  },
195
- description: <%- storeNameJs %>,
221
+ description: buildMetaDescription(storeInfo?.metaDescription) || storeName,
196
222
  alternates: {
197
223
  canonical: '/',
198
224
  },
199
225
  openGraph: {
200
- siteName: <%- storeNameJs %>,
226
+ siteName: storeName,
201
227
  locale: '<%= ogLocale %>',
202
228
  type: 'website',
203
229
  },
@@ -208,11 +234,11 @@ export async function generateMetadata(): Promise<Metadata> {
208
234
  };
209
235
  }
210
236
 
211
- function buildOrganizationJsonLd(baseUrl: string) {
237
+ function buildOrganizationJsonLd(baseUrl: string, storeName: string) {
212
238
  return {
213
239
  '@context': 'https://schema.org',
214
240
  '@type': 'Organization',
215
- name: <%- storeNameJs %>,
241
+ name: storeName,
216
242
  url: baseUrl,
217
243
  };
218
244
  }
@@ -252,6 +278,9 @@ export default async function RootLayout({
252
278
  // `regionId`, which is what this storefront did before regions existed.
253
279
  resolveRegion(),
254
280
  ]);
281
+ // One resolved name for the JSON-LD, the header and the footer. Live data
282
+ // first; the scaffold-time literal is only the last fallback.
283
+ const storeName = resolveStoreName(storeInfo);
255
284
 
256
285
  return (
257
286
  <html lang="<%= language %>" dir="<%= direction %>">
@@ -261,7 +290,7 @@ export default async function RootLayout({
261
290
  nonce={nonce}
262
291
  suppressHydrationWarning
263
292
  dangerouslySetInnerHTML={{
264
- __html: JSON.stringify(buildOrganizationJsonLd(baseUrl))
293
+ __html: JSON.stringify(buildOrganizationJsonLd(baseUrl, storeName))
265
294
  .replace(/</g, '\\u003c')
266
295
  .replace(/>/g, '\\u003e')
267
296
  .replace(/&/g, '\\u0026'),
@@ -303,10 +332,10 @@ export default async function RootLayout({
303
332
  <TrackingBootstrap />
304
333
  <div className="min-h-screen flex flex-col">
305
334
  <AnnouncementBar announcements={announcements} />
306
- <SiteHeader header={siteHeader} storeName={<%- storeNameJs %>} />
335
+ <SiteHeader header={siteHeader} storeName={storeName} />
307
336
  <DiscountBannerStrip banners={discountBanners} />
308
337
  <main className="flex-1">{children}</main>
309
- <SiteFooter footer={siteFooter} storeName={<%- storeNameJs %>} />
338
+ <SiteFooter footer={siteFooter} storeName={storeName} />
310
339
  </div>
311
340
  <BrainerceBotWidget />
312
341
  </StoreProvider>
@@ -1,5 +1,6 @@
1
1
  import { ImageResponse } from 'next/og';
2
2
  import { fetchStoreInfo } from '@/core/lib/brainerce.server';
3
+ import { resolveStoreName } from '@/core/lib/store-name';
3
4
 
4
5
  /**
5
6
  * Default Open Graph card for every page that doesn't set its own og:image
@@ -41,7 +42,10 @@ const LATIN_ONLY = /^[ -ɏ\s\p{P}\p{N}]*$/u;
41
42
 
42
43
  export default async function OpenGraphImage() {
43
44
  const info = await fetchStoreInfo();
44
- const name = info?.name?.trim() || '';
45
+ // Live name first, then the build-time NEXT_PUBLIC_STORE_NAME, then the
46
+ // scaffold literal — the card must never read a stale baked name while the
47
+ // <title> next to it reads the live one.
48
+ const name = resolveStoreName(info);
45
49
  const description = info?.metaDescription?.trim() || '';
46
50
  const logo = info?.logo || null;
47
51
 
@@ -1,138 +1,25 @@
1
- 'use client';
2
-
3
- import { useState } from 'react';
4
- import { useRouter, Link } from '@/core/lib/navigation';
5
- import { proxyResetPassword } from '@/core/lib/auth';
6
- import { LoadingSpinner } from '@/ui/shared/loading-spinner';
7
- import { useTranslations } from '@/core/lib/translations';
8
- import { getPasswordError } from '@/core/lib/validation';
9
-
10
- export default function ResetPasswordPage() {
11
- const router = useRouter();
12
- const t = useTranslations('auth');
13
-
14
- const [newPassword, setNewPassword] = useState('');
15
- const [confirmPassword, setConfirmPassword] = useState('');
16
- const [loading, setLoading] = useState(false);
17
- const [error, setError] = useState<string | null>(null);
18
- const [success, setSuccess] = useState(false);
19
-
20
- async function handleSubmit(e: React.FormEvent) {
21
- e.preventDefault();
22
- if (loading) return;
23
-
24
- const pwCode = getPasswordError(newPassword);
25
- if (pwCode) {
26
- setError(t(pwCode));
27
- return;
28
- }
29
-
30
- if (newPassword !== confirmPassword) {
31
- setError(t('passwordsMustMatch'));
32
- return;
33
- }
34
-
35
- try {
36
- setLoading(true);
37
- setError(null);
38
- await proxyResetPassword(newPassword);
39
- setSuccess(true);
40
- setTimeout(() => router.push('/login'), 2000);
41
- } catch (err) {
42
- const message =
43
- err instanceof Error ? err.message : 'Something went wrong. Please try again.';
44
- setError(message);
45
- } finally {
46
- setLoading(false);
47
- }
48
- }
49
-
50
- return (
51
- <div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
52
- <div className="w-full max-w-md space-y-6">
53
- <div className="text-center">
54
- <h1 className="text-foreground text-2xl font-bold">{t('resetPasswordTitle')}</h1>
55
- <p className="text-muted-foreground mt-1 text-sm">{t('resetPasswordSubtitle')}</p>
56
- </div>
57
-
58
- {error && (
59
- <div className="bg-destructive/10 border-destructive/20 text-destructive space-y-2 rounded-lg border px-4 py-3 text-sm">
60
- <p>{error}</p>
61
- <Link
62
- href="/forgot-password"
63
- className="text-primary block font-medium hover:underline"
64
- >
65
- {t('sendResetLink')}
66
- </Link>
67
- </div>
68
- )}
69
-
70
- {success ? (
71
- <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">
72
- {t('passwordResetSuccess')}
73
- </div>
74
- ) : (
75
- <form onSubmit={handleSubmit} className="space-y-4">
76
- <div>
77
- <label
78
- htmlFor="new-password"
79
- className="text-foreground mb-1.5 block text-sm font-medium"
80
- >
81
- {t('newPassword')}
82
- </label>
83
- <input
84
- id="new-password"
85
- type="password"
86
- required
87
- minLength={8}
88
- value={newPassword}
89
- onChange={(e) => setNewPassword(e.target.value)}
90
- placeholder={t('newPasswordPlaceholder')}
91
- autoComplete="new-password"
92
- 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"
93
- />
94
- </div>
95
-
96
- <div>
97
- <label
98
- htmlFor="confirm-password"
99
- className="text-foreground mb-1.5 block text-sm font-medium"
100
- >
101
- {t('confirmPassword')}
102
- </label>
103
- <input
104
- id="confirm-password"
105
- type="password"
106
- required
107
- minLength={8}
108
- value={confirmPassword}
109
- onChange={(e) => setConfirmPassword(e.target.value)}
110
- placeholder={t('confirmPasswordPlaceholder')}
111
- autoComplete="new-password"
112
- 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"
113
- />
114
- </div>
115
-
116
- <button
117
- type="submit"
118
- disabled={loading}
119
- 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"
120
- >
121
- {loading ? (
122
- <>
123
- <LoadingSpinner
124
- size="sm"
125
- className="border-primary-foreground/30 border-t-primary-foreground"
126
- />
127
- {t('resettingPassword')}
128
- </>
129
- ) : (
130
- t('resetPassword')
131
- )}
132
- </button>
133
- </form>
134
- )}
135
- </div>
136
- </div>
137
- );
138
- }
1
+ import { redirect } from 'next/navigation';
2
+ import { ResetPasswordForm } from './reset-password-form';
3
+
4
+ /**
5
+ * The email Brainerce sends links to `<store domain>/reset-password?token=...`.
6
+ * That token must never reach client JavaScript, so this server component hands
7
+ * it to `/api/auth/reset-callback`, which stores it in an httpOnly cookie and
8
+ * returns here with a clean URL. The form then posts the new password through
9
+ * `/api/auth/reset-password`, which reads the cookie server-side.
10
+ *
11
+ * Without this hop the page rendered with no cookie and every reset failed with
12
+ * "No reset token found", because the callback route was only ever reached by a
13
+ * `resetUrl` option the SDK does not send.
14
+ */
15
+ export default async function ResetPasswordPage({
16
+ searchParams,
17
+ }: {
18
+ searchParams: Promise<{ token?: string }>;
19
+ }) {
20
+ const { token } = await searchParams;
21
+ if (token) {
22
+ redirect(`/api/auth/reset-callback?token=${encodeURIComponent(token)}`);
23
+ }
24
+ return <ResetPasswordForm />;
25
+ }
@@ -0,0 +1,138 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { useRouter, Link } from '@/core/lib/navigation';
5
+ import { proxyResetPassword } from '@/core/lib/auth';
6
+ import { LoadingSpinner } from '@/ui/shared/loading-spinner';
7
+ import { useTranslations } from '@/core/lib/translations';
8
+ import { getPasswordError } from '@/core/lib/validation';
9
+
10
+ export function ResetPasswordForm() {
11
+ const router = useRouter();
12
+ const t = useTranslations('auth');
13
+
14
+ const [newPassword, setNewPassword] = useState('');
15
+ const [confirmPassword, setConfirmPassword] = useState('');
16
+ const [loading, setLoading] = useState(false);
17
+ const [error, setError] = useState<string | null>(null);
18
+ const [success, setSuccess] = useState(false);
19
+
20
+ async function handleSubmit(e: React.FormEvent) {
21
+ e.preventDefault();
22
+ if (loading) return;
23
+
24
+ const pwCode = getPasswordError(newPassword);
25
+ if (pwCode) {
26
+ setError(t(pwCode));
27
+ return;
28
+ }
29
+
30
+ if (newPassword !== confirmPassword) {
31
+ setError(t('passwordsMustMatch'));
32
+ return;
33
+ }
34
+
35
+ try {
36
+ setLoading(true);
37
+ setError(null);
38
+ await proxyResetPassword(newPassword);
39
+ setSuccess(true);
40
+ setTimeout(() => router.push('/login'), 2000);
41
+ } catch (err) {
42
+ const message =
43
+ err instanceof Error ? err.message : 'Something went wrong. Please try again.';
44
+ setError(message);
45
+ } finally {
46
+ setLoading(false);
47
+ }
48
+ }
49
+
50
+ return (
51
+ <div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
52
+ <div className="w-full max-w-md space-y-6">
53
+ <div className="text-center">
54
+ <h1 className="text-foreground text-2xl font-bold">{t('resetPasswordTitle')}</h1>
55
+ <p className="text-muted-foreground mt-1 text-sm">{t('resetPasswordSubtitle')}</p>
56
+ </div>
57
+
58
+ {error && (
59
+ <div className="bg-destructive/10 border-destructive/20 text-destructive space-y-2 rounded-lg border px-4 py-3 text-sm">
60
+ <p>{error}</p>
61
+ <Link
62
+ href="/forgot-password"
63
+ className="text-primary block font-medium hover:underline"
64
+ >
65
+ {t('sendResetLink')}
66
+ </Link>
67
+ </div>
68
+ )}
69
+
70
+ {success ? (
71
+ <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">
72
+ {t('passwordResetSuccess')}
73
+ </div>
74
+ ) : (
75
+ <form onSubmit={handleSubmit} className="space-y-4">
76
+ <div>
77
+ <label
78
+ htmlFor="new-password"
79
+ className="text-foreground mb-1.5 block text-sm font-medium"
80
+ >
81
+ {t('newPassword')}
82
+ </label>
83
+ <input
84
+ id="new-password"
85
+ type="password"
86
+ required
87
+ minLength={8}
88
+ value={newPassword}
89
+ onChange={(e) => setNewPassword(e.target.value)}
90
+ placeholder={t('newPasswordPlaceholder')}
91
+ autoComplete="new-password"
92
+ 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"
93
+ />
94
+ </div>
95
+
96
+ <div>
97
+ <label
98
+ htmlFor="confirm-password"
99
+ className="text-foreground mb-1.5 block text-sm font-medium"
100
+ >
101
+ {t('confirmPassword')}
102
+ </label>
103
+ <input
104
+ id="confirm-password"
105
+ type="password"
106
+ required
107
+ minLength={8}
108
+ value={confirmPassword}
109
+ onChange={(e) => setConfirmPassword(e.target.value)}
110
+ placeholder={t('confirmPasswordPlaceholder')}
111
+ autoComplete="new-password"
112
+ 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"
113
+ />
114
+ </div>
115
+
116
+ <button
117
+ type="submit"
118
+ disabled={loading}
119
+ 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"
120
+ >
121
+ {loading ? (
122
+ <>
123
+ <LoadingSpinner
124
+ size="sm"
125
+ className="border-primary-foreground/30 border-t-primary-foreground"
126
+ />
127
+ {t('resettingPassword')}
128
+ </>
129
+ ) : (
130
+ t('resetPassword')
131
+ )}
132
+ </button>
133
+ </form>
134
+ )}
135
+ </div>
136
+ </div>
137
+ );
138
+ }