diff --git a/.gitignore b/.gitignore index f17c4d512..f8077b6cd 100644 --- a/.gitignore +++ b/.gitignore @@ -73,4 +73,5 @@ start-collector.sh ## Helm Chart Tests helm/tradinggoose/test i18n.cache +i18n.lock *.react-email diff --git a/apps/tradinggoose/app/(auth)/auth-locale-redirects.test.tsx b/apps/tradinggoose/app/(auth)/auth-locale-redirects.test.tsx new file mode 100644 index 000000000..3d7499e60 --- /dev/null +++ b/apps/tradinggoose/app/(auth)/auth-locale-redirects.test.tsx @@ -0,0 +1,296 @@ +/** + * @vitest-environment jsdom + */ + +import type React from 'react' +import { act } from 'react' +import { NextIntlClientProvider } from 'next-intl' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getPublicCopy } from '@/i18n/public-copy' +import LoginPage from './login/login-form' +import SignupPage from './signup/signup-form' +import { VerifyContent } from './verify/verify-content' + +const mockPush = vi.hoisted(() => vi.fn()) +const mockSignUpEmail = vi.hoisted(() => vi.fn()) +const mockSignInEmail = vi.hoisted(() => vi.fn()) +const mockSendVerificationOtp = vi.hoisted(() => vi.fn()) +const mockRefetchSession = vi.hoisted(() => vi.fn()) +const mockUseVerification = vi.hoisted(() => vi.fn()) +const mockFetch = vi.hoisted(() => vi.fn()) +const testState = vi.hoisted(() => ({ + searchParams: new URLSearchParams(), +})) + +vi.mock('next/navigation', () => ({ + useSearchParams: () => ({ + get: (key: string) => testState.searchParams.get(key), + }), +})) + +vi.mock('@/i18n/navigation', () => ({ + Link: ({ + children, + href, + ...props + }: React.AnchorHTMLAttributes & { + children?: React.ReactNode + href: string + }) => ( + + {children} + + ), + useRouter: () => ({ + push: mockPush, + }), +})) + +vi.mock('@/lib/auth-client', () => ({ + client: { + signUp: { + email: mockSignUpEmail, + }, + signIn: { + email: mockSignInEmail, + }, + emailOtp: { + sendVerificationOtp: mockSendVerificationOtp, + }, + }, + useSession: () => ({ + refetch: mockRefetchSession, + }), +})) + +vi.mock('@/app/(auth)/verify/use-verification', () => ({ + useVerification: mockUseVerification, +})) + +vi.mock('@/app/(auth)/components/social-login-buttons', () => ({ + SocialLoginButtons: ({ children }: { children?: React.ReactNode }) =>
{children}
, +})) + +vi.mock('@/app/(auth)/components/sso-login-button', () => ({ + SSOLoginButton: () => null, +})) + +vi.mock('@/app/(auth)/components/auth-page-header', () => ({ + AuthPageHeader: () => null, +})) + +vi.mock('@/app/(auth)/components/auth-waitlist-note', () => ({ + AuthWaitlistNote: () => null, +})) + +vi.mock('@/app/fonts/inter', () => ({ + inter: { className: '' }, +})) + +vi.mock('@/components/ui/button', () => ({ + Button: ({ + children, + ...props + }: React.ButtonHTMLAttributes & { + children?: React.ReactNode + }) => , +})) + +vi.mock('@/components/ui/input', () => ({ + Input: (props: React.InputHTMLAttributes) => , +})) + +vi.mock('@/components/ui/label', () => ({ + Label: ({ + children, + ...props + }: React.LabelHTMLAttributes & { + children?: React.ReactNode + }) => , +})) + +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ children }: { children?: React.ReactNode }) =>
{children}
, + DialogContent: ({ children }: { children?: React.ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children?: React.ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children?: React.ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children?: React.ReactNode }) =>
{children}
, +})) + +vi.mock('@/components/ui/input-otp', () => ({ + InputOTP: ({ children }: { children?: React.ReactNode }) =>
{children}
, + InputOTPGroup: ({ children }: { children?: React.ReactNode }) =>
{children}
, + InputOTPSlot: ({ index }: { index: number }) =>
, +})) + +vi.mock('@/lib/env', () => ({ + env: { + NODE_ENV: 'test', + EMAIL_VERIFICATION_ENABLED: false, + }, + getEnv: vi.fn(() => undefined), + isTruthy: vi.fn(() => false), +})) + +describe('auth locale redirects', () => { + let container: HTMLDivElement + let root: Root + const reactActEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean + } + + beforeEach(() => { + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + testState.searchParams = new URLSearchParams() + mockPush.mockReset() + mockSignUpEmail.mockReset() + mockSignInEmail.mockReset() + mockSendVerificationOtp.mockReset() + mockRefetchSession.mockReset() + mockUseVerification.mockReset() + mockFetch.mockReset() + mockFetch.mockResolvedValue(new Response('{}', { status: 200 })) + global.fetch = mockFetch + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + vi.restoreAllMocks() + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = false + }) + + async function renderWithLocale(locale: 'en' | 'es' | 'zh', element: React.ReactElement) { + await act(async () => { + root.render( + + {element} + + ) + }) + } + + async function setInputValue(selector: string, value: string) { + const input = container.querySelector(selector) + + if (!(input instanceof HTMLInputElement)) { + throw new Error(`Expected input ${selector} to render`) + } + + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + valueSetter?.call(input, value) + + await act(async () => { + input.dispatchEvent(new Event('input', { bubbles: true })) + input.dispatchEvent(new Event('change', { bubbles: true })) + }) + } + + async function submitRenderedForm() { + const form = container.querySelector('form') + + if (!(form instanceof HTMLFormElement)) { + throw new Error('Expected auth form to render') + } + + await act(async () => { + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) + }) + } + + it.each(['es', 'zh'] as const)( + 'pushes the canonical verify path after signup for %s', + async (locale) => { + mockSignUpEmail.mockResolvedValue({ user: { id: 'user-1' } }) + mockRefetchSession.mockResolvedValue(undefined) + mockSendVerificationOtp.mockResolvedValue(undefined) + + await renderWithLocale( + locale, + + ) + + await setInputValue('#name', 'Ada Lovelace') + await setInputValue('#email', 'ada@example.com') + await setInputValue('#password', 'Password1!') + await submitRenderedForm() + + expect(mockPush).toHaveBeenCalledWith('/verify?fromSignup=true') + } + ) + + it.each(['es', 'zh'] as const)( + 'pushes the canonical verify path after an unverified login for %s', + async (locale) => { + mockSignInEmail.mockRejectedValue({ code: 'EMAIL_NOT_VERIFIED' }) + + await renderWithLocale( + locale, + + ) + + await setInputValue('#email', 'ada@example.com') + await setInputValue('#password', 'Password1!') + await submitRenderedForm() + + expect(mockPush).toHaveBeenCalledWith('/verify') + } + ) + + it('pushes the canonical signup path from the verify screen back action', async () => { + mockUseVerification.mockReturnValue({ + otp: '', + email: 'ada@example.com', + isLoading: false, + isVerified: false, + isInvalidOtp: false, + errorMessage: '', + isOtpComplete: false, + hasEmailService: true, + isProduction: false, + isEmailVerificationEnabled: true, + verifyCode: vi.fn(), + resendCode: vi.fn(), + handleOtpChange: vi.fn(), + }) + + await renderWithLocale( + 'en', + + ) + + const backButton = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === getPublicCopy('en').auth.common.backToSignup + ) + + if (!(backButton instanceof HTMLButtonElement)) { + throw new Error('Expected back to signup button to render') + } + + await act(async () => { + backButton.click() + }) + + expect(mockPush).toHaveBeenCalledWith('/signup') + }) +}) diff --git a/apps/tradinggoose/app/(auth)/components/auth-waitlist-note.tsx b/apps/tradinggoose/app/(auth)/components/auth-waitlist-note.tsx index 3e2fa7cfa..35da211d9 100644 --- a/apps/tradinggoose/app/(auth)/components/auth-waitlist-note.tsx +++ b/apps/tradinggoose/app/(auth)/components/auth-waitlist-note.tsx @@ -1,11 +1,19 @@ +'use client' + +import { useLocale } from 'next-intl' import { inter } from '@/app/fonts/inter' +import { useMessages } from 'next-intl' +import { type LocaleCode } from '@/i18n/utils' export function AuthWaitlistNote() { + const locale = useLocale() as LocaleCode + const copy = useMessages() + return (
- Use the same waitlist-approved email for any sign-in method. + {copy.auth.note.waitlistApprovedEmail}
) } diff --git a/apps/tradinggoose/app/(auth)/components/social-login-buttons.tsx b/apps/tradinggoose/app/(auth)/components/social-login-buttons.tsx index 602326f92..2bcfeddbf 100644 --- a/apps/tradinggoose/app/(auth)/components/social-login-buttons.tsx +++ b/apps/tradinggoose/app/(auth)/components/social-login-buttons.tsx @@ -2,9 +2,16 @@ import { type ReactNode, useEffect, useState } from 'react' import { GithubIcon, GoogleIcon } from '@/components/icons/icons' +import { Alert, AlertDescription } from '@/components/ui/alert' import { Button } from '@/components/ui/button' +import { useAuthRedirectUrls } from '@/lib/auth/redirect-urls' import { client } from '@/lib/auth-client' +import { createLogger } from '@/lib/logs/console/logger' import { inter } from '@/app/fonts/inter' +import { useMessages } from 'next-intl' +import { formatTemplate } from '@/i18n/utils' + +const logger = createLogger('SocialLoginButtons') interface SocialLoginButtonsProps { githubAvailable: boolean @@ -17,40 +24,76 @@ interface SocialLoginButtonsProps { export function SocialLoginButtons({ githubAvailable, googleAvailable, - callbackURL = '/workspace', - isProduction, + callbackURL, + isProduction: _isProduction, children, }: SocialLoginButtonsProps) { const [isGithubLoading, setIsGithubLoading] = useState(false) const [isGoogleLoading, setIsGoogleLoading] = useState(false) + const [errorMessage, setErrorMessage] = useState('') const [mounted, setMounted] = useState(false) + const authRedirectUrls = useAuthRedirectUrls() + const copy = useMessages() + const socialCopy = copy.auth.social + const resolvedCallbackURL = authRedirectUrls.providerCallbackPath(callbackURL) - // Set mounted state to true on client-side useEffect(() => { setMounted(true) }, []) - // Only render on the client side to avoid hydration errors if (!mounted) return null + function resolveSocialErrorMessage(providerLabel: string, err: any) { + const errorText = [ + err?.code, + err?.message, + err?.error, + err?.response?.statusText, + err?.response?.data?.error, + err?.response?.data?.message, + ] + .filter(Boolean) + .join(' ') + .toLowerCase() + + if ( + errorText.includes('account exists') || + errorText.includes('already exists') || + errorText.includes('user already exists') + ) { + return copy.auth.signup.errors.accountExists + } + if (errorText.includes('cancelled') || errorText.includes('canceled')) { + return formatTemplate(socialCopy.cancelled, { provider: providerLabel }) + } + if (errorText.includes('network')) { + return copy.auth.login.errors.network + } + if (errorText.includes('rate limit') || errorText.includes('too many')) { + return copy.auth.login.errors.rateLimit + } + + return copy.auth.error.default.description + } + async function signInWithGithub() { if (!githubAvailable) return setIsGithubLoading(true) + setErrorMessage('') try { - await client.signIn.social({ provider: 'github', callbackURL }) - } catch (err: any) { - let errorMessage = 'Failed to sign in with GitHub' - - if (err.message?.includes('account exists')) { - errorMessage = 'An account with this email already exists. Please sign in instead.' - } else if (err.message?.includes('cancelled')) { - errorMessage = 'GitHub sign in was cancelled. Please try again.' - } else if (err.message?.includes('network')) { - errorMessage = 'Network error. Please check your connection and try again.' - } else if (err.message?.includes('rate limit')) { - errorMessage = 'Too many attempts. Please try again later.' + const result = await client.signIn.social({ + provider: 'github', + callbackURL: resolvedCallbackURL, + }) + + if (result?.error) { + logger.error('GitHub social sign-in failed', { error: result.error }) + setErrorMessage(resolveSocialErrorMessage(socialCopy.github, result.error)) } + } catch (err: any) { + logger.error('GitHub social sign-in failed', { error: err }) + setErrorMessage(resolveSocialErrorMessage(socialCopy.github, err)) } finally { setIsGithubLoading(false) } @@ -60,20 +103,20 @@ export function SocialLoginButtons({ if (!googleAvailable) return setIsGoogleLoading(true) + setErrorMessage('') try { - await client.signIn.social({ provider: 'google', callbackURL }) - } catch (err: any) { - let errorMessage = 'Failed to sign in with Google' - - if (err.message?.includes('account exists')) { - errorMessage = 'An account with this email already exists. Please sign in instead.' - } else if (err.message?.includes('cancelled')) { - errorMessage = 'Google sign in was cancelled. Please try again.' - } else if (err.message?.includes('network')) { - errorMessage = 'Network error. Please check your connection and try again.' - } else if (err.message?.includes('rate limit')) { - errorMessage = 'Too many attempts. Please try again later.' + const result = await client.signIn.social({ + provider: 'google', + callbackURL: resolvedCallbackURL, + }) + + if (result?.error) { + logger.error('Google social sign-in failed', { error: result.error }) + setErrorMessage(resolveSocialErrorMessage(socialCopy.google, result.error)) } + } catch (err: any) { + logger.error('Google social sign-in failed', { error: err }) + setErrorMessage(resolveSocialErrorMessage(socialCopy.google, err)) } finally { setIsGoogleLoading(false) } @@ -87,7 +130,7 @@ export function SocialLoginButtons({ onClick={signInWithGithub} > - {isGithubLoading ? 'Connecting...' : 'GitHub'} + {isGithubLoading ? socialCopy.connecting : socialCopy.github} ) @@ -99,7 +142,7 @@ export function SocialLoginButtons({ onClick={signInWithGoogle} > - {isGoogleLoading ? 'Connecting...' : 'Google'} + {isGoogleLoading ? socialCopy.connecting : socialCopy.google} ) @@ -113,6 +156,11 @@ export function SocialLoginButtons({
{googleAvailable && googleButton} {githubAvailable && githubButton} + {errorMessage ? ( + + {errorMessage} + + ) : null} {children}
) diff --git a/apps/tradinggoose/app/(auth)/components/sso-login-button.tsx b/apps/tradinggoose/app/(auth)/components/sso-login-button.tsx index f4ad6e969..dd2e79e67 100644 --- a/apps/tradinggoose/app/(auth)/components/sso-login-button.tsx +++ b/apps/tradinggoose/app/(auth)/components/sso-login-button.tsx @@ -1,9 +1,11 @@ 'use client' -import { useRouter } from 'next/navigation' import { Button } from '@/components/ui/button' import { getEnv, isTruthy } from '@/lib/env' import { cn } from '@/lib/utils' +import { useMessages } from 'next-intl' +import { useRouter } from '@/i18n/navigation' +import { normalizeCallbackUrl } from '@/i18n/utils' interface SSOLoginButtonProps { callbackURL?: string @@ -20,13 +22,19 @@ export function SSOLoginButton({ variant = 'outline', }: SSOLoginButtonProps) { const router = useRouter() + const copy = useMessages() + const commonCopy = copy.auth.common if (!isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))) { return null } + const resolvedCallbackURL = callbackURL ? normalizeCallbackUrl(callbackURL) : undefined + const handleSSOClick = () => { - const ssoUrl = `/sso${callbackURL ? `?callbackUrl=${encodeURIComponent(callbackURL)}` : ''}` + const ssoUrl = `/sso${ + resolvedCallbackURL ? `?callbackUrl=${encodeURIComponent(resolvedCallbackURL)}` : '' + }` router.push(ssoUrl) } @@ -42,7 +50,7 @@ export function SSOLoginButton({ variant={variant === 'outline' ? 'outline' : undefined} className={cn(variant === 'outline' ? outlineBtnClasses : primaryBtnClasses, className)} > - Sign in with SSO + {commonCopy.signInWithSso} ) } diff --git a/apps/tradinggoose/app/(auth)/layout.tsx b/apps/tradinggoose/app/(auth)/layout.tsx deleted file mode 100644 index e92a7f060..000000000 --- a/apps/tradinggoose/app/(auth)/layout.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import type React from 'react' -import AuthLayoutClient from './layout-client' - -export default function AuthLayout({ children }: { children: React.ReactNode }) { - return {children} -} diff --git a/apps/tradinggoose/app/(auth)/login/login-form.tsx b/apps/tradinggoose/app/(auth)/login/login-form.tsx index 372b54b49..226ff2c56 100644 --- a/apps/tradinggoose/app/(auth)/login/login-form.tsx +++ b/apps/tradinggoose/app/(auth)/login/login-form.tsx @@ -2,8 +2,7 @@ import { useEffect, useState } from 'react' import { Eye, EyeOff } from 'lucide-react' -import Link from 'next/link' -import { useRouter, useSearchParams } from 'next/navigation' +import { useSearchParams } from 'next/navigation' import { Button } from '@/components/ui/button' import { Dialog, @@ -14,81 +13,68 @@ import { } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { client } from '@/lib/auth-client' +import { normalizeAuthErrorCode } from '@/lib/auth/auth-error-copy' import { handleAuthError } from '@/lib/auth/auth-error-handler' +import { useAuthRedirectUrls } from '@/lib/auth/redirect-urls' +import { client } from '@/lib/auth-client' import { quickValidateEmail } from '@/lib/email/validation' import { getEnv, isTruthy } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' -import { - getAuthRegistrationHref, - getAuthRegistrationLabel, - type RegistrationMode, -} from '@/lib/registration/shared' -import { getBaseUrl } from '@/lib/urls/utils' +import { getAuthRegistrationHref, type RegistrationMode } from '@/lib/registration/shared' import { cn } from '@/lib/utils' -import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons' -import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button' import { AuthPageHeader } from '@/app/(auth)/components/auth-page-header' import { AuthWaitlistNote } from '@/app/(auth)/components/auth-waitlist-note' +import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons' +import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button' import { inter } from '@/app/fonts/inter' +import { useMessages } from 'next-intl' +import { Link, useRouter } from '@/i18n/navigation' +import { normalizeCallbackUrl } from '@/i18n/utils' const logger = createLogger('LoginForm') -const validateEmailField = (emailValue: string): string[] => { +const validateEmailField = ( + emailValue: string, + messages: { + required: string + invalid: string + } +): string[] => { const errors: string[] = [] if (!emailValue || !emailValue.trim()) { - errors.push('Email is required.') + errors.push(messages.required) return errors } - const validation = quickValidateEmail(emailValue.trim().toLowerCase()) - if (!validation.isValid) { - errors.push(validation.reason || 'Please enter a valid email address.') + if (!quickValidateEmail(emailValue.trim().toLowerCase()).isValid) { + errors.push(messages.invalid) } return errors } const PASSWORD_VALIDATIONS = { - required: { - test: (value: string) => Boolean(value && typeof value === 'string'), - message: 'Password is required.', - }, - notEmpty: { - test: (value: string) => value.trim().length > 0, - message: 'Password cannot be empty.', - }, + required: { test: (value: string) => Boolean(value && typeof value === 'string') }, + notEmpty: { test: (value: string) => value.trim().length > 0 }, } -const validateCallbackUrl = (url: string): boolean => { - try { - if (url.startsWith('/')) { - return true - } - - const currentOrigin = typeof window !== 'undefined' ? window.location.origin : '' - if (url.startsWith(currentOrigin)) { - return true - } - - return false - } catch (error) { - logger.error('Error validating callback URL:', { error, url }) - return false +const validatePassword = ( + passwordValue: string, + messages: { + required: string + empty: string } -} - -const validatePassword = (passwordValue: string): string[] => { +): string[] => { const errors: string[] = [] if (!PASSWORD_VALIDATIONS.required.test(passwordValue)) { - errors.push(PASSWORD_VALIDATIONS.required.message) + errors.push(messages.required) return errors } if (!PASSWORD_VALIDATIONS.notEmpty.test(passwordValue)) { - errors.push(PASSWORD_VALIDATIONS.notEmpty.message) + errors.push(messages.empty) return errors } @@ -107,6 +93,12 @@ export default function LoginPage({ registrationMode: RegistrationMode }) { const router = useRouter() + const authRedirectUrls = useAuthRedirectUrls() + const copy = useMessages() + const loginCopy = copy.auth.login + const commonCopy = copy.auth.common + const authRegistrationLabel = copy.registration[registrationMode].auth + const defaultCallbackPath = '/workspace' const searchParams = useSearchParams() const [isLoading, setIsLoading] = useState(false) const [showPassword, setShowPassword] = useState(false) @@ -116,7 +108,7 @@ export default function LoginPage({ const primaryButtonClasses = 'bg-primary text-primary-foreground flex w-full items-center justify-center gap-2 rounded-md border border-transparent font-medium text-[15px] transition-all duration-200' - const [callbackUrl, setCallbackUrl] = useState('/workspace') + const [callbackUrl, setCallbackUrl] = useState(defaultCallbackPath) const [isInviteFlow, setIsInviteFlow] = useState(false) const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false) @@ -135,8 +127,13 @@ export default function LoginPage({ if (searchParams) { const callback = searchParams.get('callbackUrl') if (callback) { - if (validateCallbackUrl(callback)) { - setCallbackUrl(callback) + const normalizedCallback = normalizeCallbackUrl( + callback, + typeof window !== 'undefined' ? window.location.origin : undefined + ) + + if (normalizedCallback) { + setCallbackUrl(normalizedCallback) } else { logger.warn('Invalid callback URL detected and blocked:', { url: callback }) } @@ -164,7 +161,10 @@ export default function LoginPage({ const newEmail = e.target.value setEmail(newEmail) - const errors = validateEmailField(newEmail) + const errors = validateEmailField(newEmail, { + required: loginCopy.validation.emailRequired, + invalid: loginCopy.validation.emailInvalid, + }) setEmailErrors(errors) setShowEmailValidationError(false) } @@ -173,11 +173,83 @@ export default function LoginPage({ const newPassword = e.target.value setPassword(newPassword) - const errors = validatePassword(newPassword) + const errors = validatePassword(newPassword, { + required: loginCopy.validation.passwordRequired, + empty: loginCopy.validation.passwordEmpty, + }) setPasswordErrors(errors) setShowValidationError(false) } + const resolveLoginErrorMessage = (error: any) => { + const rawMessage = + error?.message ?? + error?.response?.statusText ?? + error?.response?.data?.error ?? + error?.response?.data?.message + const message = typeof rawMessage === 'string' && rawMessage.trim() ? rawMessage.trim() : null + const authErrorCode = + normalizeAuthErrorCode(error?.code) ?? + normalizeAuthErrorCode(message) ?? + normalizeAuthErrorCode(error?.error) + const searchable = [authErrorCode, message, error?.code, error?.error] + .filter(Boolean) + .join(' ') + .toLowerCase() + + if (authErrorCode?.includes('EMAIL_NOT_VERIFIED')) { + return null + } + if ( + authErrorCode === 'EMAIL_AND_PASSWORD_SIGN_IN_IS_NOT_ENABLED' || + authErrorCode === 'BAD_REQUEST' || + searchable.includes('email and password sign in is not enabled') + ) { + return loginCopy.errors.emailSignInDisabled + } + if ( + authErrorCode === 'INVALID_CREDENTIALS' || + authErrorCode === 'INVALID_PASSWORD' || + searchable.includes('invalid password') + ) { + return loginCopy.errors.invalidCredentials + } + if ( + authErrorCode === 'USER_NOT_FOUND' || + authErrorCode === 'NOT_FOUND' || + searchable.includes('not found') + ) { + return loginCopy.errors.noAccount + } + if (authErrorCode === 'MISSING_CREDENTIALS') { + return loginCopy.errors.missingCredentials + } + if (authErrorCode === 'EMAIL_PASSWORD_DISABLED') { + return loginCopy.errors.emailPasswordDisabled + } + if (authErrorCode === 'FAILED_TO_CREATE_SESSION') { + return loginCopy.errors.failedToCreateSession + } + if (authErrorCode === 'TOO_MANY_ATTEMPTS' || searchable.includes('too many attempts')) { + return loginCopy.errors.tooManyAttempts + } + if (authErrorCode === 'ACCOUNT_LOCKED' || searchable.includes('account locked')) { + return loginCopy.errors.accountLocked + } + if (authErrorCode === 'NETWORK_ERROR' || searchable.includes('network')) { + return loginCopy.errors.network + } + if ( + authErrorCode === 'RATE_LIMIT' || + authErrorCode === 'TOO_MANY_REQUESTS' || + searchable.includes('rate limit') + ) { + return loginCopy.errors.rateLimit + } + + return message ?? undefined + } + async function onSubmit(e: React.FormEvent) { e.preventDefault() setIsLoading(true) @@ -186,11 +258,17 @@ export default function LoginPage({ const emailRaw = formData.get('email') as string const email = emailRaw.trim().toLowerCase() - const emailValidationErrors = validateEmailField(email) + const emailValidationErrors = validateEmailField(email, { + required: loginCopy.validation.emailRequired, + invalid: loginCopy.validation.emailInvalid, + }) setEmailErrors(emailValidationErrors) setShowEmailValidationError(emailValidationErrors.length > 0) - const passwordValidationErrors = validatePassword(password) + const passwordValidationErrors = validatePassword(password, { + required: loginCopy.validation.passwordRequired, + empty: loginCopy.validation.passwordEmpty, + }) setPasswordErrors(passwordValidationErrors) setShowValidationError(passwordValidationErrors.length > 0) @@ -200,76 +278,39 @@ export default function LoginPage({ } try { - const safeCallbackUrl = validateCallbackUrl(callbackUrl) ? callbackUrl : '/workspace' - const result = await client.signIn.email( { email, password, - callbackURL: safeCallbackUrl, + callbackURL: authRedirectUrls.providerCallbackPath(callbackUrl), }, { onError: (ctx) => { console.error('Login error:', ctx.error) const errorMessage: string[] = [] + const resolvedMessage = resolveLoginErrorMessage(ctx.error) const status = (ctx.error as any)?.status ?? (ctx.error as any)?.statusCode ?? (ctx.error as any)?.response?.status - const message = - (ctx.error as any)?.message ?? - (ctx.error as any)?.response?.statusText ?? - (ctx.error as any)?.response?.data?.error + + if (resolvedMessage === null) { + return + } // If the backend rejected the request due to an invalid/expired auth state, hard reset auth. if (status === 401) { handleAuthError('login-unauthorized').catch(() => {}) - errorMessage.push('Your session expired. Please try signing in again.') + errorMessage.push(loginCopy.errors.sessionExpired) } - if (ctx.error.code?.includes('EMAIL_NOT_VERIFIED')) { - return - } - if ( - ctx.error.code?.includes('BAD_REQUEST') || - ctx.error.message?.includes('Email and password sign in is not enabled') - ) { - errorMessage.push('Email sign in is currently disabled.') - } else if ( - ctx.error.code?.includes('INVALID_CREDENTIALS') || - ctx.error.message?.includes('invalid password') - ) { - errorMessage.push('Invalid email or password. Please try again.') - } else if ( - ctx.error.code?.includes('USER_NOT_FOUND') || - ctx.error.message?.includes('not found') - ) { - errorMessage.push('No account found with this email. Please sign up first.') - } else if (ctx.error.code?.includes('MISSING_CREDENTIALS')) { - errorMessage.push('Please enter both email and password.') - } else if (ctx.error.code?.includes('EMAIL_PASSWORD_DISABLED')) { - errorMessage.push('Email and password login is disabled.') - } else if (ctx.error.code?.includes('FAILED_TO_CREATE_SESSION')) { - errorMessage.push('Failed to create session. Please try again later.') - } else if (ctx.error.code?.includes('too many attempts')) { - errorMessage.push( - 'Too many login attempts. Please try again later or reset your password.' - ) - } else if (ctx.error.code?.includes('account locked')) { - errorMessage.push( - 'Your account has been locked for security. Please reset your password.' - ) - } else if (ctx.error.code?.includes('network')) { - errorMessage.push('Network error. Please check your connection and try again.') - } else if (ctx.error.message?.includes('rate limit')) { - errorMessage.push('Too many requests. Please wait a moment before trying again.') - } else if (message) { - errorMessage.push(typeof message === 'string' ? message : 'Unable to sign in.') + if (resolvedMessage) { + errorMessage.push(resolvedMessage) } if (errorMessage.length === 0) { - errorMessage.push('Unable to sign in right now. Please try again.') + errorMessage.push(loginCopy.errors.unableToSignInNow) } setPasswordErrors(errorMessage) @@ -280,10 +321,7 @@ export default function LoginPage({ if (!result || result.error) { const message = - result?.error?.message || - (result?.error as any)?.response?.statusText || - (result?.error as any)?.response?.data?.error || - 'Unable to sign in right now. Please try again.' + resolveLoginErrorMessage(result?.error) ?? loginCopy.errors.unableToSignInNow setPasswordErrors([message]) setShowValidationError(true) @@ -309,7 +347,7 @@ export default function LoginPage({ if (!forgotPasswordEmail) { setResetStatus({ type: 'error', - message: 'Please enter your email address', + message: loginCopy.resetDialog.emailRequired, }) return } @@ -318,7 +356,7 @@ export default function LoginPage({ if (!emailValidation.isValid) { setResetStatus({ type: 'error', - message: 'Please enter a valid email address', + message: loginCopy.resetDialog.emailInvalid, }) return } @@ -334,26 +372,32 @@ export default function LoginPage({ }, body: JSON.stringify({ email: forgotPasswordEmail, - redirectTo: `${getBaseUrl()}/reset-password`, + redirectTo: authRedirectUrls.passwordResetUrl(), }), }) if (!response.ok) { - const errorData = await response.json() - let errorMessage = errorData.message || 'Failed to request password reset' + const errorData = await response.json().catch(() => ({})) + const rawMessage = + errorData?.message ?? + errorData?.error?.message ?? + errorData?.error ?? + loginCopy.resetDialog.error + const errorMessage = + typeof rawMessage === 'string' ? rawMessage : loginCopy.resetDialog.error + const normalizedErrorMessage = errorMessage.toLowerCase() if ( - errorMessage.includes('Invalid body parameters') || - errorMessage.includes('invalid email') - ) { - errorMessage = 'Please enter a valid email address' - } else if (errorMessage.includes('Email is required')) { - errorMessage = 'Please enter your email address' - } else if ( - errorMessage.includes('user not found') || - errorMessage.includes('User not found') + normalizedErrorMessage.includes('invalid body parameters') || + normalizedErrorMessage.includes('invalid email') ) { - errorMessage = 'No account found with this email address' + throw new Error(loginCopy.resetDialog.emailInvalid) + } + if (normalizedErrorMessage.includes('email is required')) { + throw new Error(loginCopy.resetDialog.emailRequired) + } + if (normalizedErrorMessage.includes('user not found')) { + throw new Error(loginCopy.errors.noAccount) } throw new Error(errorMessage) @@ -361,7 +405,7 @@ export default function LoginPage({ setResetStatus({ type: 'success', - message: 'Password reset link sent to your email', + message: loginCopy.resetDialog.success, }) setTimeout(() => { @@ -372,7 +416,7 @@ export default function LoginPage({ logger.error('Error requesting password reset:', { error }) setResetStatus({ type: 'error', - message: error instanceof Error ? error.message : 'Failed to request password reset', + message: error instanceof Error ? error.message : loginCopy.resetDialog.error, }) } finally { setIsSubmittingReset(false) @@ -385,13 +429,17 @@ export default function LoginPage({ const showDivider = showBottomSection const showWaitlistNote = registrationMode === 'waitlist' && !isInviteFlow const registrationHref = isInviteFlow - ? `/signup?invite_flow=true&callbackUrl=${callbackUrl}` + ? `/signup?invite_flow=true&callbackUrl=${encodeURIComponent(callbackUrl)}` : getAuthRegistrationHref(registrationMode) - const registrationLabel = isInviteFlow ? 'Sign up' : getAuthRegistrationLabel(registrationMode) + const registrationLabel = isInviteFlow ? commonCopy.signUp : authRegistrationLabel return ( <> - + {showWaitlistNote ? : null} @@ -399,13 +447,13 @@ export default function LoginPage({
- +
- +
@@ -448,7 +496,7 @@ export default function LoginPage({ autoCapitalize='none' autoComplete='current-password' autoCorrect='off' - placeholder='Enter your password' + placeholder={commonCopy.enterYourPassword} value={password} onChange={handlePasswordChange} className={cn( @@ -462,7 +510,7 @@ export default function LoginPage({ type='button' onClick={() => setShowPassword(!showPassword)} className='-translate-y-1/2 absolute top-1/2 right-3 text-gray-500 transition hover:text-gray-700' - aria-label={showPassword ? 'Hide password' : 'Show password'} + aria-label={showPassword ? commonCopy.hidePassword : commonCopy.showPassword} > {showPassword ? : } @@ -478,7 +526,7 @@ export default function LoginPage({
@@ -490,7 +538,7 @@ export default function LoginPage({
- Or continue with + {loginCopy.divider}
@@ -511,7 +559,7 @@ export default function LoginPage({ {registrationHref && registrationLabel && (
- Don't have an account? + {commonCopy.dontHaveAccount} - By signing in, you agree to our{' '} + {commonCopy.termsLeadSigningIn}{' '} - Terms of Service + {commonCopy.termsOfService} {' '} - and{' '} + {commonCopy.and}{' '} - Privacy Policy + {commonCopy.privacyPolicy}
- - Reset Password + + {loginCopy.resetDialog.title} - Enter your email address and we'll send you a link to reset your password if your - account exists. + {loginCopy.resetDialog.description}
- +
setForgotPasswordEmail(e.target.value)} - placeholder='Enter your email' + placeholder={loginCopy.resetDialog.emailPlaceholder} required type='email' className={cn( @@ -590,7 +637,7 @@ export default function LoginPage({ className={primaryButtonClasses} disabled={isSubmittingReset} > - {isSubmittingReset ? 'Sending...' : 'Send Reset Link'} + {isSubmittingReset ? loginCopy.resetDialog.submitting : loginCopy.resetDialog.submit}
diff --git a/apps/tradinggoose/app/(auth)/reset-password/reset-password-form.tsx b/apps/tradinggoose/app/(auth)/reset-password/reset-password-form.tsx index c5cd74e71..3a57a9310 100644 --- a/apps/tradinggoose/app/(auth)/reset-password/reset-password-form.tsx +++ b/apps/tradinggoose/app/(auth)/reset-password/reset-password-form.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { Eye, EyeOff } from 'lucide-react' +import { useMessages } from 'next-intl' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -30,6 +31,8 @@ export function RequestResetForm({ statusMessage, className, }: RequestResetFormProps) { + const copy = useMessages().auth.resetPassword + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() onSubmit(email) @@ -40,24 +43,21 @@ export function RequestResetForm({
- +
onEmailChange(e.target.value)} - placeholder='Enter your email' + placeholder={copy.request.emailPlaceholder} type='email' disabled={isSubmitting} required className='rounded-md shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100' /> -

- We'll send a password reset link to this email address. -

+

{copy.request.helperText}

- {/* Status message display */} {statusType && statusMessage && (
) @@ -91,6 +91,7 @@ export function SetNewPasswordForm({ statusMessage, className, }: SetNewPasswordFormProps) { + const copy = useMessages().auth.resetPassword const [password, setPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [validationMessage, setValidationMessage] = useState('') @@ -101,12 +102,12 @@ export function SetNewPasswordForm({ e.preventDefault() if (password.length < 8) { - setValidationMessage('Password must be at least 8 characters long') + setValidationMessage(copy.setNew.validation.passwordTooShort) return } if (password !== confirmPassword) { - setValidationMessage('Passwords do not match') + setValidationMessage(copy.setNew.validation.passwordMismatch) return } @@ -119,7 +120,7 @@ export function SetNewPasswordForm({
- +
setPassword(e.target.value)} required - placeholder='Enter new password' + placeholder={copy.setNew.passwordPlaceholder} className={cn( 'rounded-md pr-10 shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100', validationMessage && @@ -143,7 +144,7 @@ export function SetNewPasswordForm({ type='button' onClick={() => setShowPassword(!showPassword)} className='-translate-y-1/2 absolute top-1/2 right-3 text-gray-500 transition hover:text-gray-700' - aria-label={showPassword ? 'Hide password' : 'Show password'} + aria-label={showPassword ? copy.setNew.hidePassword : copy.setNew.showPassword} > {showPassword ? : } @@ -151,7 +152,7 @@ export function SetNewPasswordForm({
- +
setConfirmPassword(e.target.value)} required - placeholder='Confirm new password' + placeholder={copy.setNew.confirmPasswordPlaceholder} className={cn( 'rounded-md pr-10 shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100', validationMessage && @@ -175,7 +176,7 @@ export function SetNewPasswordForm({ type='button' onClick={() => setShowConfirmPassword(!showConfirmPassword)} className='-translate-y-1/2 absolute top-1/2 right-3 text-gray-500 transition hover:text-gray-700' - aria-label={showConfirmPassword ? 'Hide password' : 'Show password'} + aria-label={showConfirmPassword ? copy.setNew.hidePassword : copy.setNew.showPassword} > {showConfirmPassword ? : } @@ -201,7 +202,7 @@ export function SetNewPasswordForm({
) diff --git a/apps/tradinggoose/app/(auth)/signup/signup-form.tsx b/apps/tradinggoose/app/(auth)/signup/signup-form.tsx index fd3b86f51..9e6f5325d 100644 --- a/apps/tradinggoose/app/(auth)/signup/signup-form.tsx +++ b/apps/tradinggoose/app/(auth)/signup/signup-form.tsx @@ -2,8 +2,8 @@ import { Suspense, useEffect, useState } from 'react' import { Eye, EyeOff } from 'lucide-react' -import Link from 'next/link' -import { useRouter, useSearchParams } from 'next/navigation' +import { useSearchParams } from 'next/navigation' +import { useLocale } from 'next-intl' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -12,11 +12,15 @@ import { quickValidateEmail } from '@/lib/email/validation' import { getEnv, isTruthy } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' import { - REGISTRATION_DISABLED_MESSAGE, - REGISTRATION_WAITLIST_MESSAGE, - type RegistrationMode, -} from '@/lib/registration/shared' + isRegistrationDisabledReason, + isRegistrationWaitlistReason, + normalizeAuthErrorCode, +} from '@/lib/auth/auth-error-copy' +import { type RegistrationMode } from '@/lib/registration/shared' import { cn } from '@/lib/utils' +import { Link, useRouter } from '@/i18n/navigation' +import { useMessages } from 'next-intl' +import { normalizeCallbackUrl, type LocaleCode } from '@/i18n/utils' import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons' import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button' import { AuthPageHeader } from '@/app/(auth)/components/auth-page-header' @@ -25,53 +29,44 @@ import { inter } from '@/app/fonts/inter' const logger = createLogger('SignupForm') +function SignupFormLoadingFallback() { + const copy = useMessages() + + return
{copy.auth.common.loading}
+} + const PASSWORD_VALIDATIONS = { - minLength: { regex: /.{8,}/, message: 'Password must be at least 8 characters long.' }, - uppercase: { - regex: /(?=.*?[A-Z])/, - message: 'Password must include at least one uppercase letter.', - }, - lowercase: { - regex: /(?=.*?[a-z])/, - message: 'Password must include at least one lowercase letter.', - }, - number: { regex: /(?=.*?[0-9])/, message: 'Password must include at least one number.' }, - special: { - regex: /(?=.*?[#?!@$%^&*-])/, - message: 'Password must include at least one special character.', - }, + minLength: /.{8,}/, + uppercase: /(?=.*?[A-Z])/, + lowercase: /(?=.*?[a-z])/, + number: /(?=.*?[0-9])/, + special: /(?=.*?[#?!@$%^&*-])/, } const NAME_VALIDATIONS = { - required: { - test: (value: string) => Boolean(value && typeof value === 'string'), - message: 'Name is required.', - }, - notEmpty: { - test: (value: string) => value.trim().length > 0, - message: 'Name cannot be empty.', - }, - validCharacters: { - regex: /^[\p{L}\s\-']+$/u, - message: 'Name can only contain letters, spaces, hyphens, and apostrophes.', - }, - noConsecutiveSpaces: { - regex: /^(?!.*\s\s).*$/, - message: 'Name cannot contain consecutive spaces.', - }, + required: (value: string) => Boolean(value && typeof value === 'string'), + notEmpty: (value: string) => value.trim().length > 0, + validCharacters: /^[\p{L}\s\-']+$/u, + noConsecutiveSpaces: /^(?!.*\s\s).*$/, } -const validateEmailField = (emailValue: string): string[] => { +const validateEmailField = ( + emailValue: string, + messages: { + required: string + invalid: string + } +): string[] => { const errors: string[] = [] if (!emailValue || !emailValue.trim()) { - errors.push('Email is required.') + errors.push(messages.required) return errors } const validation = quickValidateEmail(emailValue.trim().toLowerCase()) if (!validation.isValid) { - errors.push(validation.reason || 'Please enter a valid email address.') + errors.push(messages.invalid) } return errors @@ -89,6 +84,11 @@ function SignupFormContent({ registrationMode: RegistrationMode }) { const router = useRouter() + const locale = useLocale() as LocaleCode + const copy = useMessages() + const commonCopy = copy.auth.common + const signupCopy = copy.auth.signup + const defaultCallbackPath = '/workspace' const searchParams = useSearchParams() const { refetch: refetchSession } = useSession() const [isLoading, setIsLoading] = useState(false) @@ -101,7 +101,7 @@ function SignupFormContent({ const [emailError, setEmailError] = useState('') const [emailErrors, setEmailErrors] = useState([]) const [showEmailValidationError, setShowEmailValidationError] = useState(false) - const [redirectUrl, setRedirectUrl] = useState('') + const [redirectUrl, setRedirectUrl] = useState(null) const [isInviteFlow, setIsInviteFlow] = useState(false) const primaryButtonClasses = 'bg-primary text-primary-foreground flex w-full items-center justify-center gap-2 rounded-md border border-transparent font-medium text-[15px] transition-all duration-200' @@ -117,12 +117,23 @@ function SignupFormContent({ setEmail(emailParam) } - const redirectParam = searchParams.get('redirect') + const redirectParam = searchParams.get('redirect') ?? searchParams.get('callbackUrl') if (redirectParam) { - setRedirectUrl(redirectParam) + const normalizedRedirectUrl = normalizeCallbackUrl( + redirectParam, + typeof window !== 'undefined' ? window.location.origin : undefined + ) - if (redirectParam.startsWith('/invite/')) { - setIsInviteFlow(true) + if (normalizedRedirectUrl) { + setRedirectUrl(normalizedRedirectUrl) + + const redirectPathname = new URL(normalizedRedirectUrl, 'http://tradinggoose.local') + .pathname + if (redirectPathname.startsWith('/invite/')) { + setIsInviteFlow(true) + } + } else { + logger.warn('Invalid signup redirect URL detected and blocked:', { url: redirectParam }) } } @@ -135,24 +146,24 @@ function SignupFormContent({ const validatePassword = (passwordValue: string): string[] => { const errors: string[] = [] - if (!PASSWORD_VALIDATIONS.minLength.regex.test(passwordValue)) { - errors.push(PASSWORD_VALIDATIONS.minLength.message) + if (!PASSWORD_VALIDATIONS.minLength.test(passwordValue)) { + errors.push(signupCopy.validation.passwordMinLength) } - if (!PASSWORD_VALIDATIONS.uppercase.regex.test(passwordValue)) { - errors.push(PASSWORD_VALIDATIONS.uppercase.message) + if (!PASSWORD_VALIDATIONS.uppercase.test(passwordValue)) { + errors.push(signupCopy.validation.passwordUppercase) } - if (!PASSWORD_VALIDATIONS.lowercase.regex.test(passwordValue)) { - errors.push(PASSWORD_VALIDATIONS.lowercase.message) + if (!PASSWORD_VALIDATIONS.lowercase.test(passwordValue)) { + errors.push(signupCopy.validation.passwordLowercase) } - if (!PASSWORD_VALIDATIONS.number.regex.test(passwordValue)) { - errors.push(PASSWORD_VALIDATIONS.number.message) + if (!PASSWORD_VALIDATIONS.number.test(passwordValue)) { + errors.push(signupCopy.validation.passwordNumber) } - if (!PASSWORD_VALIDATIONS.special.regex.test(passwordValue)) { - errors.push(PASSWORD_VALIDATIONS.special.message) + if (!PASSWORD_VALIDATIONS.special.test(passwordValue)) { + errors.push(signupCopy.validation.passwordSpecial) } return errors @@ -161,22 +172,22 @@ function SignupFormContent({ const validateName = (nameValue: string): string[] => { const errors: string[] = [] - if (!NAME_VALIDATIONS.required.test(nameValue)) { - errors.push(NAME_VALIDATIONS.required.message) + if (!NAME_VALIDATIONS.required(nameValue)) { + errors.push(signupCopy.validation.nameRequired) return errors } - if (!NAME_VALIDATIONS.notEmpty.test(nameValue)) { - errors.push(NAME_VALIDATIONS.notEmpty.message) + if (!NAME_VALIDATIONS.notEmpty(nameValue)) { + errors.push(signupCopy.validation.nameEmpty) return errors } - if (!NAME_VALIDATIONS.validCharacters.regex.test(nameValue.trim())) { - errors.push(NAME_VALIDATIONS.validCharacters.message) + if (!NAME_VALIDATIONS.validCharacters.test(nameValue.trim())) { + errors.push(signupCopy.validation.nameCharacters) } - if (!NAME_VALIDATIONS.noConsecutiveSpaces.regex.test(nameValue)) { - errors.push(NAME_VALIDATIONS.noConsecutiveSpaces.message) + if (!NAME_VALIDATIONS.noConsecutiveSpaces.test(nameValue)) { + errors.push(signupCopy.validation.nameSpaces) } return errors @@ -204,7 +215,10 @@ function SignupFormContent({ const newEmail = e.target.value setEmail(newEmail) - const errors = validateEmailField(newEmail) + const errors = validateEmailField(newEmail, { + required: signupCopy.validation.emailRequired, + invalid: signupCopy.validation.emailInvalid, + }) setEmailErrors(errors) setShowEmailValidationError(false) @@ -229,7 +243,10 @@ function SignupFormContent({ setNameErrors(nameValidationErrors) setShowNameValidationError(nameValidationErrors.length > 0) - const emailValidationErrors = validateEmailField(emailValue) + const emailValidationErrors = validateEmailField(emailValue, { + required: signupCopy.validation.emailRequired, + invalid: signupCopy.validation.emailInvalid, + }) setEmailErrors(emailValidationErrors) setShowEmailValidationError(emailValidationErrors.length > 0) @@ -261,7 +278,7 @@ function SignupFormContent({ } if (trimmedName.length > 100) { - setNameErrors(['Name will be truncated to 100 characters. Please shorten your name.']) + setNameErrors([signupCopy.validation.nameTooLong]) setShowNameValidationError(true) setIsLoading(false) return @@ -278,44 +295,44 @@ function SignupFormContent({ { onError: (ctx) => { logger.error('Signup error:', ctx.error) - const errorMessage: string[] = ['Failed to create account'] + const errorMessage: string[] = [signupCopy.errors.failedToCreateAccount] + const authErrorCode = + normalizeAuthErrorCode(ctx.error.code) ?? normalizeAuthErrorCode(ctx.error.message) - if (ctx.error.code?.includes('USER_ALREADY_EXISTS')) { - errorMessage.push( - 'An account with this email already exists. Please sign in instead.' - ) + if (authErrorCode === 'USER_ALREADY_EXISTS') { + errorMessage.push(signupCopy.errors.accountExists) setEmailError(errorMessage[errorMessage.length - 1]) } else if ( - ctx.error.code?.includes('BAD_REQUEST') || - ctx.error.message?.includes('Email and password sign up is not enabled') + authErrorCode === 'EMAIL_AND_PASSWORD_SIGN_UP_IS_NOT_ENABLED' || + authErrorCode === 'BAD_REQUEST' || + isRegistrationDisabledReason(ctx.error.message) || + isRegistrationWaitlistReason(ctx.error.message) ) { - if (ctx.error.message?.includes(REGISTRATION_DISABLED_MESSAGE)) { - errorMessage.push(REGISTRATION_DISABLED_MESSAGE) - } else if (ctx.error.message?.includes(REGISTRATION_WAITLIST_MESSAGE)) { - errorMessage.push( - 'This email is not approved for signup yet. Join the waitlist first.' - ) + if (isRegistrationDisabledReason(ctx.error.message)) { + errorMessage.push(signupCopy.errors.emailSignupDisabled) + } else if (isRegistrationWaitlistReason(ctx.error.message)) { + errorMessage.push(signupCopy.errors.waitlistRequired) } else { - errorMessage.push('Email signup is currently disabled.') + errorMessage.push(signupCopy.errors.signupNotEnabled) } setEmailError(errorMessage[errorMessage.length - 1]) - } else if (ctx.error.code?.includes('INVALID_EMAIL')) { - errorMessage.push('Please enter a valid email address.') + } else if (authErrorCode === 'INVALID_EMAIL') { + errorMessage.push(signupCopy.errors.invalidEmail) setEmailError(errorMessage[errorMessage.length - 1]) - } else if (ctx.error.code?.includes('PASSWORD_TOO_SHORT')) { - errorMessage.push('Password must be at least 8 characters long.') + } else if (authErrorCode === 'PASSWORD_TOO_SHORT') { + errorMessage.push(signupCopy.errors.passwordTooShort) setPasswordErrors(errorMessage) setShowValidationError(true) - } else if (ctx.error.code?.includes('PASSWORD_TOO_LONG')) { - errorMessage.push('Password must be less than 128 characters long.') + } else if (authErrorCode === 'PASSWORD_TOO_LONG') { + errorMessage.push(signupCopy.errors.passwordTooLong) setPasswordErrors(errorMessage) setShowValidationError(true) - } else if (ctx.error.code?.includes('network')) { - errorMessage.push('Network error. Please check your connection and try again.') + } else if (authErrorCode === 'NETWORK_ERROR') { + errorMessage.push(signupCopy.errors.network) setPasswordErrors(errorMessage) setShowValidationError(true) - } else if (ctx.error.code?.includes('rate limit')) { - errorMessage.push('Too many requests. Please wait a moment before trying again.') + } else if (authErrorCode === 'RATE_LIMIT' || authErrorCode === 'TOO_MANY_REQUESTS') { + errorMessage.push(signupCopy.errors.rateLimit) setPasswordErrors(errorMessage) setShowValidationError(true) } else { @@ -333,9 +350,17 @@ function SignupFormContent({ try { await refetchSession() + const localeResponse = await fetch('/api/users/me/settings', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ preferredLocale: locale }), + }) + if (!localeResponse.ok) { + throw new Error('Failed to persist preferred locale after signup') + } logger.info('Session refreshed after successful signup') } catch (sessionError) { - logger.error('Failed to refresh session after signup:', sessionError) + logger.error('Failed to refresh session or persist locale after signup:', sessionError) } if (typeof window !== 'undefined') { @@ -370,12 +395,12 @@ function SignupFormContent({ return ( <> @@ -385,16 +410,16 @@ function SignupFormContent({
- +
- +
- +
setShowPassword(!showPassword)} className='-translate-y-1/2 absolute top-1/2 right-3 text-gray-500 transition hover:text-gray-700' - aria-label={showPassword ? 'Hide password' : 'Show password'} + aria-label={showPassword ? commonCopy.hidePassword : commonCopy.showPassword} > {showPassword ? : } @@ -486,7 +511,7 @@ function SignupFormContent({
@@ -497,7 +522,7 @@ function SignupFormContent({
- Or continue with + {signupCopy.divider}
@@ -508,46 +533,50 @@ function SignupFormContent({ {ssoEnabled && ( - + )}
)}
- Already have an account? + {commonCopy.alreadyHaveAccount} - Sign in + {commonCopy.signIn}
- By creating an account, you agree to our{' '} + {commonCopy.termsLeadCreatingAccount}{' '} - Terms of Service + {commonCopy.termsOfService} {' '} - and{' '} + {commonCopy.and}{' '} - Privacy Policy + {commonCopy.privacyPolicy}
@@ -566,9 +595,7 @@ export default function SignupPage({ registrationMode: RegistrationMode }) { return ( - Loading...
} - > + }> { +const validateEmailField = ( + emailValue: string, + messages: { + required: string + invalid: string + } +): string[] => { const errors: string[] = [] if (!emailValue || !emailValue.trim()) { - errors.push('Email is required.') + errors.push(messages.required) return errors } const validation = quickValidateEmail(emailValue.trim().toLowerCase()) if (!validation.isValid) { - errors.push(validation.reason || 'Please enter a valid email address.') + errors.push(messages.invalid) } return errors } -const validateCallbackUrl = (url: string): boolean => { - try { - if (url.startsWith('/')) { - return true - } - - const currentOrigin = typeof window !== 'undefined' ? window.location.origin : '' - if (url.startsWith(currentOrigin)) { - return true - } - - return false - } catch (error) { - logger.error('Error validating callback URL:', { error, url }) - return false - } -} - export default function SSOForm({ registrationMode }: { registrationMode: RegistrationMode }) { - const router = useRouter() + const authRedirectUrls = useAuthRedirectUrls() + const copy = useMessages() + const commonCopy = copy.auth.common + const ssoCopy = copy.auth.sso + const defaultCallbackPath = '/workspace' const searchParams = useSearchParams() const [isLoading, setIsLoading] = useState(false) const [email, setEmail] = useState('') @@ -64,47 +56,58 @@ export default function SSOForm({ registrationMode }: { registrationMode: Regist const [showEmailValidationError, setShowEmailValidationError] = useState(false) const primaryButtonClasses = 'bg-primary text-primary-foreground flex w-full items-center justify-center gap-2 rounded-md border border-transparent font-medium text-[15px] transition-all duration-200' - const [callbackUrl, setCallbackUrl] = useState('/workspace') + const [callbackUrl, setCallbackUrl] = useState(defaultCallbackPath) const registrationHref = getAuthRegistrationHref(registrationMode) - const registrationLabel = getAuthRegistrationLabel(registrationMode) + const registrationLabel = copy.registration[registrationMode].auth + const callbackUrlParam = encodeURIComponent(callbackUrl) useEffect(() => { if (searchParams) { const callback = searchParams.get('callbackUrl') if (callback) { - if (validateCallbackUrl(callback)) { - setCallbackUrl(callback) + const normalizedCallback = normalizeCallbackUrl( + callback, + typeof window !== 'undefined' ? window.location.origin : undefined + ) + + if (normalizedCallback) { + setCallbackUrl(normalizedCallback) } else { logger.warn('Invalid callback URL detected and blocked:', { url: callback }) } } - // Pre-fill email if provided in URL (e.g., from deployed chat SSO) const emailParam = searchParams.get('email') if (emailParam) { setEmail(emailParam) } - // Check for SSO error from redirect const error = searchParams.get('error') if (error) { const errorMessages: Record = { - account_not_found: - 'No account found. Please contact your administrator to set up SSO access.', - sso_failed: 'SSO authentication failed. Please try again.', - invalid_provider: 'SSO provider not configured correctly.', + account_not_found: ssoCopy.errors.accountNotFound, + sso_failed: ssoCopy.errors.ssoFailed, + invalid_provider: ssoCopy.errors.providerNotConfigured, } - setEmailErrors([errorMessages[error] || 'SSO authentication failed. Please try again.']) + setEmailErrors([errorMessages[error] || ssoCopy.errors.ssoFailed]) setShowEmailValidationError(true) } } - }, [searchParams]) + }, [ + searchParams, + ssoCopy.errors.accountNotFound, + ssoCopy.errors.providerNotConfigured, + ssoCopy.errors.ssoFailed, + ]) const handleEmailChange = (e: React.ChangeEvent) => { const newEmail = e.target.value setEmail(newEmail) - const errors = validateEmailField(newEmail) + const errors = validateEmailField(newEmail, { + required: ssoCopy.validation.emailRequired, + invalid: ssoCopy.validation.emailInvalid, + }) setEmailErrors(errors) setShowEmailValidationError(false) } @@ -117,7 +120,10 @@ export default function SSOForm({ registrationMode }: { registrationMode: Regist const emailRaw = formData.get('email') as string const emailValue = emailRaw.trim().toLowerCase() - const emailValidationErrors = validateEmailField(emailValue) + const emailValidationErrors = validateEmailField(emailValue, { + required: ssoCopy.validation.emailRequired, + invalid: ssoCopy.validation.emailInvalid, + }) setEmailErrors(emailValidationErrors) setShowEmailValidationError(emailValidationErrors.length > 0) @@ -127,30 +133,31 @@ export default function SSOForm({ registrationMode }: { registrationMode: Regist } try { - const safeCallbackUrl = validateCallbackUrl(callbackUrl) ? callbackUrl : '/workspace' - await client.signIn.sso({ email: emailValue, - callbackURL: safeCallbackUrl, - errorCallbackURL: `/sso?error=sso_failed&callbackUrl=${encodeURIComponent(safeCallbackUrl)}`, + callbackURL: authRedirectUrls.providerCallbackPath(callbackUrl), + errorCallbackURL: authRedirectUrls.providerErrorPath( + `/sso?error=sso_failed&callbackUrl=${callbackUrlParam}` + ), }) } catch (err) { logger.error('SSO sign-in failed', { error: err, email: emailValue }) + const authErrorCode = err instanceof Error ? normalizeAuthErrorCode(err.message) : null - let errorMessage = 'SSO sign-in failed. Please try again.' + let errorMessage = ssoCopy.errors.failed if (err instanceof Error) { - if (err.message.includes('NO_PROVIDER_FOUND')) { - errorMessage = 'SSO provider not found. Please check your configuration.' - } else if (err.message.includes('INVALID_EMAIL_DOMAIN')) { - errorMessage = 'Email domain not configured for SSO. Please contact your administrator.' - } else if (err.message.includes('network')) { - errorMessage = 'Network error. Please check your connection and try again.' - } else if (err.message.includes('rate limit')) { - errorMessage = 'Too many requests. Please wait a moment before trying again.' - } else if (err.message.includes('SSO_DISABLED')) { - errorMessage = 'SSO authentication is disabled. Please use another sign-in method.' + if (authErrorCode === 'NO_PROVIDER_FOUND' || authErrorCode === 'INVALID_PROVIDER') { + errorMessage = ssoCopy.errors.providerNotConfigured + } else if (authErrorCode === 'INVALID_EMAIL_DOMAIN') { + errorMessage = ssoCopy.errors.invalidEmailDomain + } else if (authErrorCode === 'NETWORK_ERROR') { + errorMessage = ssoCopy.errors.network + } else if (authErrorCode === 'RATE_LIMIT' || authErrorCode === 'TOO_MANY_REQUESTS') { + errorMessage = ssoCopy.errors.rateLimit + } else if (authErrorCode === 'SSO_DISABLED') { + errorMessage = ssoCopy.errors.ssoDisabled } else { - errorMessage = err.message + errorMessage = ssoCopy.errors.failed } } @@ -163,9 +170,9 @@ export default function SSOForm({ registrationMode }: { registrationMode: Regist return ( <> {registrationMode === 'waitlist' ? : null} @@ -174,12 +181,12 @@ export default function SSOForm({ registrationMode }: { registrationMode: Regist
- +
@@ -214,29 +221,29 @@ export default function SSOForm({ registrationMode }: { registrationMode: Regist
- Or + + {ssoCopy.divider} +
- +
{registrationHref && registrationLabel && (
- Don't have an account? + {commonCopy.dontHaveAccount} {registrationLabel} @@ -247,23 +254,23 @@ export default function SSOForm({ registrationMode }: { registrationMode: Regist
- By signing in, you agree to our{' '} + {commonCopy.termsLeadSigningIn}{' '} - Terms of Service + {commonCopy.termsOfService} {' '} - and{' '} + {commonCopy.and}{' '} - Privacy Policy + {commonCopy.privacyPolicy}
diff --git a/apps/tradinggoose/app/(auth)/verify/use-verification.test.tsx b/apps/tradinggoose/app/(auth)/verify/use-verification.test.tsx new file mode 100644 index 000000000..a14cc5538 --- /dev/null +++ b/apps/tradinggoose/app/(auth)/verify/use-verification.test.tsx @@ -0,0 +1,201 @@ +/** + * @vitest-environment jsdom + */ + +import { act, useEffect } from 'react' +import { NextIntlClientProvider } from 'next-intl' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getPublicCopy } from '@/i18n/public-copy' +import { useVerification } from './use-verification' + +const mockPush = vi.hoisted(() => vi.fn()) +const mockEmailOtpSignIn = vi.hoisted(() => vi.fn()) +const mockSendVerificationOtp = vi.hoisted(() => vi.fn()) +const mockRefetchSession = vi.hoisted(() => vi.fn()) +const testState = vi.hoisted(() => ({ + searchParams: new URLSearchParams(), +})) + +vi.mock('next/navigation', () => ({ + useSearchParams: () => ({ + get: (key: string) => testState.searchParams.get(key), + }), +})) + +vi.mock('@/i18n/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + }), +})) + +vi.mock('@/lib/auth-client', () => ({ + client: { + signIn: { + emailOtp: mockEmailOtpSignIn, + }, + emailOtp: { + sendVerificationOtp: mockSendVerificationOtp, + }, + }, + useSession: () => ({ + refetch: mockRefetchSession, + }), +})) + +interface VerificationControls { + handleOtpChange: (value: string) => void +} + +interface VerificationHarnessProps { + locale: 'en' | 'es' | 'zh' + hasEmailService?: boolean + isProduction?: boolean + isEmailVerificationEnabled?: boolean + onReady: (controls: VerificationControls) => void +} + +function VerificationHarness({ + locale, + hasEmailService = true, + isProduction = false, + isEmailVerificationEnabled = true, + onReady, +}: VerificationHarnessProps) { + const controls = useVerification({ + hasEmailService, + isProduction, + isEmailVerificationEnabled, + copy: getPublicCopy(locale).auth.verify, + }) + + useEffect(() => { + onReady(controls) + }, [controls, onReady]) + + return null +} + +describe('useVerification', () => { + let container: HTMLDivElement + let root: Root + let sessionStore: Map + const reactActEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean + } + + beforeEach(() => { + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true + vi.useFakeTimers() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + sessionStore = new Map() + + const sessionStorageMock = { + getItem: vi.fn((key: string) => sessionStore.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => { + sessionStore.set(key, value) + }), + removeItem: vi.fn((key: string) => { + sessionStore.delete(key) + }), + clear: vi.fn(() => { + sessionStore.clear() + }), + key: vi.fn((index: number) => Array.from(sessionStore.keys())[index] ?? null), + get length() { + return sessionStore.size + }, + } + + Object.defineProperty(window, 'sessionStorage', { + configurable: true, + value: sessionStorageMock, + }) + Object.defineProperty(globalThis, 'sessionStorage', { + configurable: true, + value: sessionStorageMock, + }) + + mockPush.mockReset() + mockEmailOtpSignIn.mockReset() + mockSendVerificationOtp.mockReset() + mockRefetchSession.mockReset() + testState.searchParams = new URLSearchParams() + window.history.replaceState({}, '', '/') + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + vi.useRealTimers() + vi.restoreAllMocks() + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = false + }) + + async function renderHarness( + locale: 'en' | 'es' | 'zh', + onReady: (controls: VerificationControls) => void + ) { + await act(async () => { + root.render( + + + + ) + }) + } + + it('pushes the canonical workspace path after successful verification', async () => { + sessionStore.set('verificationEmail', 'ada@example.com') + mockEmailOtpSignIn.mockResolvedValue({}) + mockRefetchSession.mockResolvedValue(undefined) + + let controls!: VerificationControls + + await renderHarness('zh', (value) => { + controls = value + }) + + await act(async () => { + controls.handleOtpChange('123456') + }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(1300) + }) + + expect(mockEmailOtpSignIn).toHaveBeenCalledWith({ + email: 'ada@example.com', + otp: '123456', + }) + expect(mockPush).toHaveBeenCalledWith('/workspace') + }) + + it('pushes canonical invite redirects through generated navigation', async () => { + sessionStore.set('verificationEmail', 'ada@example.com') + sessionStore.set('inviteRedirectUrl', '/workspace/ws-1/dashboard') + sessionStore.set('isInviteFlow', 'true') + mockEmailOtpSignIn.mockResolvedValue({}) + mockRefetchSession.mockResolvedValue(undefined) + + let controls!: VerificationControls + + await renderHarness('zh', (value) => { + controls = value + }) + + await act(async () => { + controls.handleOtpChange('123456') + }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(1300) + }) + + expect(mockPush).toHaveBeenCalledWith('/workspace/ws-1/dashboard') + }) +}) diff --git a/apps/tradinggoose/app/(auth)/verify/use-verification.ts b/apps/tradinggoose/app/(auth)/verify/use-verification.ts index af88eb428..bfed0ca5a 100644 --- a/apps/tradinggoose/app/(auth)/verify/use-verification.ts +++ b/apps/tradinggoose/app/(auth)/verify/use-verification.ts @@ -1,16 +1,75 @@ 'use client' import { useEffect, useState } from 'react' -import { useRouter, useSearchParams } from 'next/navigation' +import { useSearchParams } from 'next/navigation' +import { useRouter } from '@/i18n/navigation' +import { normalizeAuthErrorCode } from '@/lib/auth/auth-error-copy' import { client, useSession } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console/logger' +import { normalizeCallbackUrl } from '@/i18n/utils' +import type { Messages } from 'next-intl' const logger = createLogger('useVerification') +type VerifyCopy = Messages['auth']['verify'] + +const VERIFICATION_ERROR_CODE_GROUPS = { + expired: new Set([ + 'TOKEN_EXPIRED', + 'EXPIRED_TOKEN', + 'VERIFICATION_CODE_EXPIRED', + 'EXPIRED_VERIFICATION_CODE', + 'OTP_EXPIRED', + 'CODE_EXPIRED', + ]), + invalid: new Set([ + 'INVALID_TOKEN', + 'INVALID_VERIFICATION_CODE', + 'INVALID_OTP', + 'OTP_INVALID', + 'INVALID_CODE', + ]), + attempts: new Set([ + 'TOO_MANY_ATTEMPTS', + 'TOO_MANY_FAILED_ATTEMPTS', + 'MAX_ATTEMPTS_EXCEEDED', + 'OTP_TOO_MANY_ATTEMPTS', + 'RATE_LIMIT', + ]), +} as const + +export function getVerificationErrorMessage(copy: VerifyCopy, error: unknown) { + const code = + error && typeof error === 'object' && 'code' in error + ? String((error as { code?: unknown }).code ?? '') + : '' + const message = + error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : error && typeof error === 'object' && 'message' in error + ? String((error as { message?: unknown }).message ?? '') + : '' + + const normalizedErrorCode = normalizeAuthErrorCode(code) ?? normalizeAuthErrorCode(message) + if (normalizedErrorCode && VERIFICATION_ERROR_CODE_GROUPS.expired.has(normalizedErrorCode)) { + return copy.errors.expired + } + if (normalizedErrorCode && VERIFICATION_ERROR_CODE_GROUPS.invalid.has(normalizedErrorCode)) { + return copy.errors.invalid + } + if (normalizedErrorCode && VERIFICATION_ERROR_CODE_GROUPS.attempts.has(normalizedErrorCode)) { + return copy.errors.attempts + } + + return copy.errors.generic +} interface UseVerificationParams { hasEmailService: boolean isProduction: boolean isEmailVerificationEnabled: boolean + copy: VerifyCopy } interface UseVerificationReturn { @@ -33,6 +92,7 @@ export function useVerification({ hasEmailService, isProduction, isEmailVerificationEnabled, + copy, }: UseVerificationParams): UseVerificationReturn { const router = useRouter() const searchParams = useSearchParams() @@ -56,7 +116,16 @@ export function useVerification({ const storedRedirectUrl = sessionStorage.getItem('inviteRedirectUrl') if (storedRedirectUrl) { - setRedirectUrl(storedRedirectUrl) + const normalizedRedirectUrl = normalizeCallbackUrl( + storedRedirectUrl, + window.location.origin + ) + + if (normalizedRedirectUrl) { + setRedirectUrl(normalizedRedirectUrl) + } else { + logger.warn('Invalid stored verification redirect blocked', { url: storedRedirectUrl }) + } } const storedIsInviteFlow = sessionStorage.getItem('isInviteFlow') @@ -67,7 +136,16 @@ export function useVerification({ const redirectParam = searchParams.get('redirectAfter') if (redirectParam) { - setRedirectUrl(redirectParam) + const normalizedRedirectUrl = normalizeCallbackUrl( + redirectParam, + typeof window !== 'undefined' ? window.location.origin : undefined + ) + + if (normalizedRedirectUrl) { + setRedirectUrl(normalizedRedirectUrl) + } else { + logger.warn('Invalid verification redirect blocked', { url: redirectParam }) + } } const inviteFlowParam = searchParams.get('invite_flow') @@ -118,14 +196,14 @@ export function useVerification({ setTimeout(() => { if (isInviteFlow && redirectUrl) { - window.location.href = redirectUrl + router.push(redirectUrl) } else { - window.location.href = '/workspace' + router.push('/workspace') } }, 1000) } else { logger.info('Setting invalid OTP state - API error response') - const message = 'Invalid verification code. Please check and try again.' + const message = copy.errors.invalid setIsInvalidOtp(true) setErrorMessage(message) logger.info('Error state after API error:', { @@ -134,17 +212,8 @@ export function useVerification({ }) setOtp('') } - } catch (error: any) { - let message = 'Verification failed. Please check your code and try again.' - - if (error.message?.includes('expired')) { - message = 'The verification code has expired. Please request a new one.' - } else if (error.message?.includes('invalid')) { - logger.info('Setting invalid OTP state - caught error') - message = 'Invalid verification code. Please check and try again.' - } else if (error.message?.includes('attempts')) { - message = 'Too many failed attempts. Please request a new code.' - } + } catch (error: unknown) { + const message = getVerificationErrorMessage(copy, error) setIsInvalidOtp(true) setErrorMessage(message) @@ -171,9 +240,8 @@ export function useVerification({ email: normalizedEmail, type: 'sign-in', }) - .then(() => {}) .catch(() => { - setErrorMessage('Failed to resend verification code. Please try again later.') + setErrorMessage(copy.errors.resendFailed) }) .finally(() => { setIsLoading(false) @@ -211,7 +279,7 @@ export function useVerification({ } if (isInviteFlow && redirectUrl) { - window.location.href = redirectUrl + router.push(redirectUrl) } else { router.push('/workspace') } @@ -220,7 +288,7 @@ export function useVerification({ handleRedirect() } } - }, [isEmailVerificationEnabled, router, isInviteFlow, redirectUrl]) + }, [isEmailVerificationEnabled, redirectUrl, router, isInviteFlow]) return { otp, diff --git a/apps/tradinggoose/app/(auth)/verify/verify-content.tsx b/apps/tradinggoose/app/(auth)/verify/verify-content.tsx index 219c4d186..e3cdfac2d 100644 --- a/apps/tradinggoose/app/(auth)/verify/verify-content.tsx +++ b/apps/tradinggoose/app/(auth)/verify/verify-content.tsx @@ -1,10 +1,12 @@ 'use client' import { Suspense, useEffect, useState } from 'react' -import { useRouter } from 'next/navigation' import { Button } from '@/components/ui/button' import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp' import { cn } from '@/lib/utils' +import { useRouter } from '@/i18n/navigation' +import { useMessages } from 'next-intl' +import { formatTemplate } from '@/i18n/utils' import { AuthPageHeader } from '@/app/(auth)/components/auth-page-header' import { useVerification } from '@/app/(auth)/verify/use-verification' import { inter } from '@/app/fonts/inter' @@ -24,6 +26,9 @@ function VerificationForm({ isProduction: boolean isEmailVerificationEnabled: boolean }) { + const copy = useMessages() + const verifyCopy = copy.auth.verify + const commonCopy = copy.auth.common const { otp, email, @@ -35,7 +40,12 @@ function VerificationForm({ verifyCode, resendCode, handleOtpChange, - } = useVerification({ hasEmailService, isProduction, isEmailVerificationEnabled }) + } = useVerification({ + hasEmailService, + isProduction, + isEmailVerificationEnabled, + copy: verifyCopy, + }) const [countdown, setCountdown] = useState(0) const [isResendDisabled, setIsResendDisabled] = useState(false) @@ -64,18 +74,20 @@ function VerificationForm({ return ( <> @@ -83,8 +95,9 @@ function VerificationForm({

- Enter the 6-digit code to verify your account. - {hasEmailService ? " If you don't see it in your inbox, check your spam folder." : ''} + {hasEmailService + ? verifyCopy.instructionsWithService + : verifyCopy.instructionsWithoutService}

@@ -154,7 +167,6 @@ function VerificationForm({
- {/* Error message */} {errorMessage && (

{errorMessage}

@@ -167,24 +179,22 @@ function VerificationForm({ className={primaryButtonClasses} disabled={!isOtpComplete || isLoading} > - {isLoading ? 'Verifying...' : 'Verify Email'} + {isLoading ? verifyCopy.verifyingButton : verifyCopy.verifyButton} {hasEmailService && (

- Didn't receive a code?{' '} + {verifyCopy.resendPrompt}{' '} {countdown > 0 ? ( - - Resend in {countdown}s - + {formatTemplate(verifyCopy.resendIn, { countdown })} ) : ( )}

@@ -203,7 +213,7 @@ function VerificationForm({ }} className='font-medium text-primary underline-offset-4 transition hover:text-primary-hover hover:underline' > - Back to signup + {commonCopy.backToSignup}
diff --git a/apps/tradinggoose/app/(auth)/waitlist/page.tsx b/apps/tradinggoose/app/(auth)/waitlist/page.tsx deleted file mode 100644 index b5bfa8131..000000000 --- a/apps/tradinggoose/app/(auth)/waitlist/page.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import Link from 'next/link' -import { redirect } from 'next/navigation' -import { Button } from '@/components/ui/button' -import { getRegistrationModeForRender } from '@/lib/registration/service' -import { REGISTRATION_DISABLED_MESSAGE } from '@/lib/registration/shared' -import { AuthPageHeader } from '@/app/(auth)/components/auth-page-header' -import { WaitlistForm } from './waitlist-form' - -export const dynamic = 'force-dynamic' - -export default async function WaitlistPage() { - const registrationMode = await getRegistrationModeForRender() - - if (registrationMode === 'open') { - redirect('/signup') - } - - if (registrationMode === 'disabled') { - return ( -
- -
- - -
-
- ) - } - - return ( -
- - -
- ) -} diff --git a/apps/tradinggoose/app/(auth)/waitlist/waitlist-form.test.tsx b/apps/tradinggoose/app/(auth)/waitlist/waitlist-form.test.tsx new file mode 100644 index 000000000..417ac4e58 --- /dev/null +++ b/apps/tradinggoose/app/(auth)/waitlist/waitlist-form.test.tsx @@ -0,0 +1,138 @@ +/** + * @vitest-environment jsdom + */ + +import type React from 'react' +import { act } from 'react' +import { NextIntlClientProvider } from 'next-intl' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getPublicCopy } from '@/i18n/public-copy' +import { WaitlistForm } from './waitlist-form' + +vi.mock('@/app/fonts/inter', () => ({ + inter: { className: '' }, +})) + +vi.mock('@/i18n/navigation', () => ({ + Link: ({ + children, + href, + ...props + }: React.AnchorHTMLAttributes & { + children?: React.ReactNode + href: string + }) => ( + + {children} + + ), +})) + +describe('WaitlistForm', () => { + let container: HTMLDivElement + let root: Root + const originalFetch = globalThis.fetch + const reactActEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean + } + + beforeEach(() => { + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + if (root) { + act(() => { + root.unmount() + }) + } + container?.remove() + vi.restoreAllMocks() + globalThis.fetch = originalFetch + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = false + }) + + it('preserves the disabled registration error returned by the API', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + json: async () => ({ code: 'REGISTRATION_DISABLED' }), + }) + vi.stubGlobal('fetch', fetchMock) + + await act(async () => { + root.render( + + + + ) + }) + + const input = container.querySelector('#waitlist-email') + if (!(input instanceof HTMLInputElement)) { + throw new Error('Expected waitlist input to render') + } + + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + valueSetter?.call(input, 'user@example.com') + + await act(async () => { + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + + const form = container.querySelector('form') + if (!(form instanceof HTMLFormElement)) { + throw new Error('Expected waitlist form to render') + } + + await act(async () => { + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) + }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(container.textContent).toContain(getPublicCopy('en').auth.disabled.description) + }) + + it('falls back to the generic rejected copy for non-specific failures', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + json: async () => ({ code: 'UNEXPECTED_FAILURE' }), + }) + vi.stubGlobal('fetch', fetchMock) + + await act(async () => { + root.render( + + + + ) + }) + + const input = container.querySelector('#waitlist-email') + if (!(input instanceof HTMLInputElement)) { + throw new Error('Expected waitlist input to render') + } + + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + valueSetter?.call(input, 'user@example.com') + + await act(async () => { + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + + const form = container.querySelector('form') + if (!(form instanceof HTMLFormElement)) { + throw new Error('Expected waitlist form to render') + } + + await act(async () => { + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) + }) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(container.textContent).toContain(getPublicCopy('en').auth.waitlist.rejected) + }) +}) diff --git a/apps/tradinggoose/app/(auth)/waitlist/waitlist-form.tsx b/apps/tradinggoose/app/(auth)/waitlist/waitlist-form.tsx index b809a329a..08a7f4bce 100644 --- a/apps/tradinggoose/app/(auth)/waitlist/waitlist-form.tsx +++ b/apps/tradinggoose/app/(auth)/waitlist/waitlist-form.tsx @@ -1,15 +1,22 @@ 'use client' import { useState } from 'react' -import Link from 'next/link' +import { useLocale } from 'next-intl' import { Alert, AlertDescription, Button, Input, Label } from '@/components/ui' import { quickValidateEmail } from '@/lib/email/validation' import { cn } from '@/lib/utils' +import { Link } from '@/i18n/navigation' +import { useMessages } from 'next-intl' +import { type LocaleCode } from '@/i18n/utils' import { inter } from '@/app/fonts/inter' type WaitlistResponseStatus = 'pending' | 'approved' | 'rejected' | 'signed_up' export function WaitlistForm() { + const locale = useLocale() as LocaleCode + const copy = useMessages() + const commonCopy = copy.auth.common + const waitlistCopy = copy.auth.waitlist const [email, setEmail] = useState('') const [error, setError] = useState('') const [status, setStatus] = useState(null) @@ -17,14 +24,27 @@ export function WaitlistForm() { const primaryButtonClasses = 'bg-primary text-primary-foreground flex w-full items-center justify-center gap-2 rounded-md border border-transparent font-medium text-[15px] transition-all duration-200' + const validateEmailField = (emailValue: string): string => { + if (!emailValue || !emailValue.trim()) { + return waitlistCopy.validation.emailRequired + } + + const validation = quickValidateEmail(emailValue.trim().toLowerCase()) + if (!validation.isValid) { + return waitlistCopy.validation.emailInvalid + } + + return '' + } + async function onSubmit(event: React.FormEvent) { event.preventDefault() setError('') const normalizedEmail = email.trim().toLowerCase() - const validation = quickValidateEmail(normalizedEmail) - if (!validation.isValid) { - setError(validation.reason || 'Please enter a valid email address.') + const validationMessage = validateEmailField(normalizedEmail) + if (validationMessage) { + setError(validationMessage) return } @@ -34,23 +54,25 @@ export function WaitlistForm() { const response = await fetch('/api/waitlist', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email: normalizedEmail }), + body: JSON.stringify({ email: normalizedEmail, locale }), }) const payload = (await response.json().catch(() => null)) as - | { status?: WaitlistResponseStatus; error?: string } + | { status?: WaitlistResponseStatus; error?: string; code?: string } | null if (!response.ok) { - throw new Error(payload?.error || 'Failed to join the waitlist') + if (payload?.code === 'REGISTRATION_DISABLED') { + throw new Error(copy.auth.disabled.description) + } + + throw new Error(waitlistCopy.rejected) } setStatus(payload?.status ?? 'pending') setEmail(normalizedEmail) } catch (submissionError) { - setError( - submissionError instanceof Error ? submissionError.message : 'Failed to join the waitlist' - ) + setError(submissionError instanceof Error ? submissionError.message : waitlistCopy.rejected) } finally { setIsSubmitting(false) } @@ -61,14 +83,14 @@ export function WaitlistForm() {
- + setEmail(event.target.value)} - placeholder='Enter your email' + placeholder={commonCopy.enterYourEmail} autoComplete='email' autoCapitalize='none' autoCorrect='off' @@ -78,14 +100,12 @@ export function WaitlistForm() { error && 'border-red-500 focus:border-red-500 focus:ring-red-100 focus-visible:ring-red-500' )} /> -

- Use the email address you want reviewed for platform access. -

+

{waitlistCopy.helperText}

@@ -97,19 +117,19 @@ export function WaitlistForm() { {status === 'pending' ? ( - - You are on the waitlist. We will review your request and let you know when access is - available. - + {waitlistCopy.pending} ) : null} {status === 'approved' ? ( - Your email is approved. Continue to{' '} - - sign up + {waitlistCopy.approvedPrefix}{' '} + + {waitlistCopy.signUpLink} . @@ -119,9 +139,9 @@ export function WaitlistForm() { {status === 'signed_up' ? ( - This email already has access. Continue to{' '} + {waitlistCopy.signedUpPrefix}{' '} - login + {waitlistCopy.loginLink} . @@ -130,17 +150,17 @@ export function WaitlistForm() { {status === 'rejected' ? ( - This waitlist request is not approved for access. + {waitlistCopy.rejected} ) : null}
- Already have an account? + {commonCopy.alreadyHaveAccount} - Sign in + {commonCopy.signIn}
diff --git a/apps/tradinggoose/app/(landing)/blog/components/ai-summarize.tsx b/apps/tradinggoose/app/(landing)/blog/components/ai-summarize.tsx index b0b102bbc..873f165b5 100644 --- a/apps/tradinggoose/app/(landing)/blog/components/ai-summarize.tsx +++ b/apps/tradinggoose/app/(landing)/blog/components/ai-summarize.tsx @@ -1,11 +1,12 @@ 'use client' import { useEffect, useState } from 'react' -import Link from 'next/link' import { OpenAIIcon, AnthropicIcon, GeminiIcon, xAIIcon as XAIIcon } from '@/components/icons/provider-icons' import { PerplexityIcon } from '@/components/icons/icons' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { useMessages } from 'next-intl' +import { formatTemplate } from '@/i18n/utils' interface AiSummarizeProps { path: string @@ -14,6 +15,8 @@ interface AiSummarizeProps { export default function AiSummarize({ path, title }: AiSummarizeProps) { const [url, setUrl] = useState(path) + const copy = useMessages() + const blogCopy = copy.blog useEffect(() => { setUrl(`${window.location.origin}${path}`) @@ -51,21 +54,23 @@ export default function AiSummarize({ path, title }: AiSummarizeProps) { return (
-

Summarize with AI

+

{blogCopy.summarizeTitle}

{platforms.map((platform) => ( {platform.label} diff --git a/apps/tradinggoose/app/(landing)/blog/components/breadcrumb-nav.tsx b/apps/tradinggoose/app/(landing)/blog/components/breadcrumb-nav.tsx index 40c260590..81732950e 100644 --- a/apps/tradinggoose/app/(landing)/blog/components/breadcrumb-nav.tsx +++ b/apps/tradinggoose/app/(landing)/blog/components/breadcrumb-nav.tsx @@ -1,7 +1,8 @@ 'use client' -import Link from 'next/link' import { Home } from 'lucide-react' +import { useLocale } from 'next-intl' +import { Link } from '@/i18n/navigation' import { Breadcrumb, BreadcrumbItem, @@ -10,26 +11,32 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from '@/components/ui/breadcrumb' +import { useMessages } from 'next-intl' +import { type LocaleCode } from '@/i18n/utils' interface BreadcrumbNavProps { pageTitle: string } export default function BreadcrumbNav({ pageTitle }: Readonly) { + const locale = useLocale() as LocaleCode + const copy = useMessages() + const blogCopy = copy.blog + return ( - Home + {blogCopy.home} - Blog + {blogCopy.breadcrumbBlog} diff --git a/apps/tradinggoose/app/(landing)/blog/components/filtered-posts.tsx b/apps/tradinggoose/app/(landing)/blog/components/filtered-posts.tsx index 0c64e5179..ee6f3473c 100644 --- a/apps/tradinggoose/app/(landing)/blog/components/filtered-posts.tsx +++ b/apps/tradinggoose/app/(landing)/blog/components/filtered-posts.tsx @@ -1,10 +1,14 @@ 'use client' import { useState } from 'react' +import { useLocale } from 'next-intl' import { FileText, SearchIcon, SearchX } from 'lucide-react' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty' +import { useMessages } from 'next-intl' +import { formatTemplate } from '@/i18n/utils' +import { type LocaleCode } from '@/i18n/utils' import PostCard from './post-card' import type { Post } from '../lib/types' @@ -14,6 +18,9 @@ interface FilteredPostProps { export default function FilteredPosts({ posts }: FilteredPostProps) { const [searchValue, setSearchValue] = useState('') + const locale = useLocale() as LocaleCode + const copy = useMessages() + const blogCopy = copy.blog if (posts.length === 0) { return ( @@ -22,8 +29,8 @@ export default function FilteredPosts({ posts }: FilteredPostProps) { - No posts yet - Check back soon — new articles are on the way. + {blogCopy.emptyTitle} + {blogCopy.emptyDescription} ) @@ -40,8 +47,8 @@ export default function FilteredPosts({ posts }: FilteredPostProps) { type="text" value={searchValue} onChange={(e) => setSearchValue(e.target.value)} - placeholder="Search articles" - aria-label="Search articles" + placeholder={blogCopy.searchPlaceholder} + aria-label={blogCopy.searchPlaceholder} className="w-full pl-12" id="search" /> @@ -62,8 +69,8 @@ export default function FilteredPosts({ posts }: FilteredPostProps) { - No posts matching “{searchValue}” - Try a different search term. + {formatTemplate(blogCopy.noMatches, { query: searchValue })} + {blogCopy.noMatchesDescription} )} diff --git a/apps/tradinggoose/app/(landing)/blog/components/markdown-content.tsx b/apps/tradinggoose/app/(landing)/blog/components/markdown-content.tsx index a92f2279d..e6b77b214 100644 --- a/apps/tradinggoose/app/(landing)/blog/components/markdown-content.tsx +++ b/apps/tradinggoose/app/(landing)/blog/components/markdown-content.tsx @@ -3,8 +3,8 @@ import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import Image from 'next/image' -import Link from 'next/link' import { CodeBlock } from '@/components/ui/code-block' +import { Link } from '@/i18n/navigation' import { flattenNodeText, textToSlug } from '../lib/heading-slugs' interface MarkdownContentProps { @@ -65,7 +65,8 @@ export default function MarkdownContent({ content }: MarkdownContentProps) { ), a: ({ href, children, ...props }) => { const isNonRoute = href ? /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(href) || href.startsWith('//') : false - if (isNonRoute) { + const isAnchor = href?.startsWith('#') ?? false + if (isNonRoute || isAnchor) { return ( {post.image && ( @@ -43,12 +50,14 @@ export default function PostCard({ post, index }: PostCardProps) { )}
- {formatBlogDate(post.date, 'short')} + {formatBlogDate(post.date, 'short', locale)}
- {post.readingTime} min read + + {post.readingTime} {blogCopy.readTimeSuffix} +
{post.tags && post.tags.length > 0 && ( @@ -61,7 +70,7 @@ export default function PostCard({ post, index }: PostCardProps) {
- View Article + {blogCopy.viewArticle} ) diff --git a/apps/tradinggoose/app/(landing)/blog/components/social-share.tsx b/apps/tradinggoose/app/(landing)/blog/components/social-share.tsx index c881612d2..85e4df5f6 100644 --- a/apps/tradinggoose/app/(landing)/blog/components/social-share.tsx +++ b/apps/tradinggoose/app/(landing)/blog/components/social-share.tsx @@ -1,7 +1,6 @@ 'use client' import { useEffect, useState } from 'react' -import Link from 'next/link' import { Check, LinkIcon } from 'lucide-react' import { xIcon as XIcon, @@ -11,6 +10,8 @@ import { } from '@/components/icons/icons' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { useMessages } from 'next-intl' +import { formatTemplate } from '@/i18n/utils' interface SocialShareProps { path: string @@ -20,6 +21,8 @@ interface SocialShareProps { export default function SocialShare({ path, text }: SocialShareProps) { const [copied, setCopied] = useState(false) const [url, setUrl] = useState(path) + const copy = useMessages() + const blogCopy = copy.blog useEffect(() => { setUrl(`${window.location.origin}${path}`) @@ -59,21 +62,21 @@ export default function SocialShare({ path, text }: SocialShareProps) { return (
-

Share This Article

+

{blogCopy.shareTitle}

{links.map((link) => ( {link.label} @@ -81,7 +84,12 @@ export default function SocialShare({ path, text }: SocialShareProps) { ))} - - {copied ? 'Copied!' : 'Copy link'} + {copied ? blogCopy.copied : blogCopy.copyLink}
diff --git a/apps/tradinggoose/app/(landing)/blog/components/table-of-contents.tsx b/apps/tradinggoose/app/(landing)/blog/components/table-of-contents.tsx index caa38b13a..a62aea3fb 100644 --- a/apps/tradinggoose/app/(landing)/blog/components/table-of-contents.tsx +++ b/apps/tradinggoose/app/(landing)/blog/components/table-of-contents.tsx @@ -1,7 +1,10 @@ 'use client' import { useEffect, useState } from 'react' +import { useLocale } from 'next-intl' import { cn } from '@/lib/utils' +import { useMessages } from 'next-intl' +import { type LocaleCode } from '@/i18n/utils' import type { TOC } from '../lib/types' interface TableOfContentsProps { @@ -36,6 +39,9 @@ function useActiveItem(itemIds: string[]) { export default function TableOfContents({ toc }: TableOfContentsProps) { const [mounted, setMounted] = useState(false) + const locale = useLocale() as LocaleCode + const copy = useMessages() + const blogCopy = copy.blog const itemIds = toc.map((item) => item.url) const activeHeading = useActiveItem(itemIds) @@ -49,7 +55,7 @@ export default function TableOfContents({ toc }: TableOfContentsProps) { return (
-

On This Page

+

{blogCopy.tableOfContents}

    {toc.map((item) => (
  • diff --git a/apps/tradinggoose/app/(landing)/blog/lib/heading-slugs.ts b/apps/tradinggoose/app/(landing)/blog/lib/heading-slugs.ts index 74a19c76a..643b4750f 100644 --- a/apps/tradinggoose/app/(landing)/blog/lib/heading-slugs.ts +++ b/apps/tradinggoose/app/(landing)/blog/lib/heading-slugs.ts @@ -29,8 +29,12 @@ export function flattenNodeText(node: React.ReactNode): string { return '' } -export function formatBlogDate(dateStr: string, style: 'long' | 'short' = 'long'): string { - return new Date(dateStr).toLocaleDateString('en-US', { +export function formatBlogDate( + dateStr: string, + style: 'long' | 'short' = 'long', + locale: string +): string { + return new Date(dateStr).toLocaleDateString(locale, { month: style === 'long' ? 'long' : 'short', day: 'numeric', year: 'numeric', diff --git a/apps/tradinggoose/app/(landing)/blog/page.tsx b/apps/tradinggoose/app/(landing)/blog/page.tsx deleted file mode 100644 index 55e85783c..000000000 --- a/apps/tradinggoose/app/(landing)/blog/page.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Metadata } from 'next' -import BlogLayout from '@/app/(landing)/components/blog-layout' -import { getAllPosts } from './lib/posts' -import PageHeading from './components/page-heading' -import FilteredPosts from './components/filtered-posts' - -export const metadata: Metadata = { - title: 'Blog | TradingGoose', - description: 'Articles about trading automation, workflow design, and building smarter strategies.', - alternates: { - canonical: '/blog', - }, -} - -export default async function BlogPage() { - const posts = await getAllPosts() - - return ( - - - - - ) -} diff --git a/apps/tradinggoose/app/(landing)/careers/careers-form.tsx b/apps/tradinggoose/app/(landing)/careers/careers-form.tsx index cb538a7c3..f50f07817 100644 --- a/apps/tradinggoose/app/(landing)/careers/careers-form.tsx +++ b/apps/tradinggoose/app/(landing)/careers/careers-form.tsx @@ -2,6 +2,7 @@ import { useRef, useState } from 'react' import { Loader2, X } from 'lucide-react' +import { useLocale } from 'next-intl' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -16,75 +17,80 @@ import { Textarea } from '@/components/ui/textarea' import { quickValidateEmail } from '@/lib/email/validation' import { cn } from '@/lib/utils' import { soehne } from '@/app/fonts/soehne/soehne' +import { useMessages } from 'next-intl' +import { type LocaleCode } from '@/i18n/utils' -const validateName = (name: string): string[] => { +const validateName = (name: string, message: string): string[] => { const errors: string[] = [] if (!name || name.trim().length < 2) { - errors.push('Name must be at least 2 characters') + errors.push(message) } return errors } -const validateEmail = (email: string): string[] => { +const validateEmail = (email: string, requiredMessage: string, invalidMessage: string): string[] => { const errors: string[] = [] if (!email || !email.trim()) { - errors.push('Email is required') + errors.push(requiredMessage) return errors } const validation = quickValidateEmail(email.trim().toLowerCase()) if (!validation.isValid) { - errors.push(validation.reason || 'Please enter a valid email address') + errors.push(validation.reason || invalidMessage) } return errors } -const validatePosition = (position: string): string[] => { +const validatePosition = (position: string, message: string): string[] => { const errors: string[] = [] if (!position || position.trim().length < 2) { - errors.push('Please specify the position you are interested in') + errors.push(message) } return errors } -const validateLinkedIn = (url: string): string[] => { +const validateLinkedIn = (url: string, message: string): string[] => { if (!url || url.trim() === '') return [] const errors: string[] = [] try { new URL(url) } catch { - errors.push('Please enter a valid LinkedIn URL') + errors.push(message) } return errors } -const validatePortfolio = (url: string): string[] => { +const validatePortfolio = (url: string, message: string): string[] => { if (!url || url.trim() === '') return [] const errors: string[] = [] try { new URL(url) } catch { - errors.push('Please enter a valid portfolio URL') + errors.push(message) } return errors } -const validateLocation = (location: string): string[] => { +const validateLocation = (location: string, message: string): string[] => { const errors: string[] = [] if (!location || location.trim().length < 2) { - errors.push('Please enter your location') + errors.push(message) } return errors } -const validateMessage = (message: string): string[] => { +const validateMessage = (message: string, validationMessage: string): string[] => { const errors: string[] = [] if (!message || message.trim().length < 50) { - errors.push('Please tell us more about yourself (at least 50 characters)') + errors.push(validationMessage) } return errors } export function CareersForm() { + const locale = useLocale() as LocaleCode + const copy = useMessages().careers.form + const contactEmail = copy.helpers.contactEmail const [isSubmitting, setIsSubmitting] = useState(false) const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle') const [showErrors, setShowErrors] = useState(false) @@ -123,15 +129,19 @@ export function CareersForm() { e.preventDefault() setShowErrors(true) - const nameErrs = validateName(name) - const emailErrs = validateEmail(email) - const positionErrs = validatePosition(position) - const linkedinErrs = validateLinkedIn(linkedin) - const portfolioErrs = validatePortfolio(portfolio) - const experienceErrs = experience ? [] : ['Please select your years of experience'] - const locationErrs = validateLocation(location) - const messageErrs = validateMessage(message) - const resumeErrs = resume ? [] : ['Resume is required'] + const nameErrs = validateName(name, copy.validation.nameTooShort) + const emailErrs = validateEmail( + email, + copy.validation.emailRequired, + copy.validation.emailInvalid + ) + const positionErrs = validatePosition(position, copy.validation.positionRequired) + const linkedinErrs = validateLinkedIn(linkedin, copy.validation.linkedinInvalid) + const portfolioErrs = validatePortfolio(portfolio, copy.validation.portfolioInvalid) + const experienceErrs = experience ? [] : [copy.validation.experienceRequired] + const locationErrs = validateLocation(location, copy.validation.locationRequired) + const messageErrs = validateMessage(message, copy.validation.messageRequired) + const resumeErrs = resume ? [] : [copy.validation.resumeRequired] setNameErrors(nameErrs) setEmailErrors(emailErrs) @@ -171,6 +181,7 @@ export function CareersForm() { formData.append('experience', experience) formData.append('location', location) formData.append('message', message) + formData.append('locale', locale) if (resume) formData.append('resume', resume) const response = await fetch('/api/careers/submit', { @@ -179,7 +190,7 @@ export function CareersForm() { }) if (!response.ok) { - throw new Error('Failed to submit application') + throw new Error(copy.errors.submitFailed) } setSubmitStatus('success') @@ -194,20 +205,18 @@ export function CareersForm() { return (
    -

    Apply Now

    -

    - Help us build the future of AI workflows -

    +

    {copy.title}

    +

    {copy.description}

    setName(e.target.value)} className={cn( @@ -227,12 +236,12 @@ export function CareersForm() {
    setEmail(e.target.value)} className={cn( @@ -254,12 +263,12 @@ export function CareersForm() {
    setPhone(e.target.value)} /> @@ -267,11 +276,11 @@ export function CareersForm() {
    setPosition(e.target.value)} className={cn( @@ -293,11 +302,11 @@ export function CareersForm() {
    setLinkedin(e.target.value)} className={cn( @@ -317,11 +326,11 @@ export function CareersForm() {
    setPortfolio(e.target.value)} className={cn( @@ -343,7 +352,7 @@ export function CareersForm() {
    {showErrors && experienceErrors.length > 0 && ( @@ -374,11 +383,11 @@ export function CareersForm() {
    setLocation(e.target.value)} className={cn( @@ -399,11 +408,11 @@ export function CareersForm() {