diff --git a/backend/prisma/migrations/20260620120000_add_user_language/migration.sql b/backend/prisma/migrations/20260620120000_add_user_language/migration.sql new file mode 100644 index 0000000..7c369a4 --- /dev/null +++ b/backend/prisma/migrations/20260620120000_add_user_language/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "language" TEXT NOT NULL DEFAULT 'en'; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index fcc9408..ffe59bc 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -15,6 +15,7 @@ model User { googleId String? @unique facebookId String? @unique name String + language String @default("en") trialUsedAt DateTime? memberships Membership[] diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index 1e68517..b490e9d 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -9,7 +9,8 @@ import { Res, HttpCode, HttpStatus, - Get + Get, + Patch, } from '@nestjs/common'; import type { Response } from 'express'; import { @@ -28,6 +29,7 @@ import { RegisterDto } from './dto/register.dto'; import { CreateOrganizationDto } from './dto/create-organization.dto'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { LocalAuthGuard } from './guards/local-auth.guard'; +import { UpdateLanguageDto } from './dto/update-language.dto'; @ApiTags('auth') @Controller('auth') @@ -149,6 +151,14 @@ export class AuthController { return this.authService.getProfile(req.user.id); } + @Patch('profile/language') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Update user language preference' }) + async updateLanguage(@Req() req, @Body() dto: UpdateLanguageDto) { + return this.authService.updateLanguage(req.user.id, dto); + } + @Get('subscription-alert') @UseGuards(JwtAuthGuard) @ApiBearerAuth('JWT-auth') diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 102e59c..17fcac4 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -15,6 +15,10 @@ import { PrismaService } from '../../../prisma/prisma.service'; import { LoginDto } from './dto/login.dto'; import { RegisterDto } from './dto/register.dto'; import { CreateOrganizationDto } from './dto/create-organization.dto'; +import { + SUPPORTED_USER_LANGUAGES, + UpdateLanguageDto, +} from './dto/update-language.dto'; import { JwtPayload } from './interfaces/jwt-payload.interface'; const ALL_PERMISSIONS = [ @@ -179,11 +183,7 @@ export class AuthService { data: { accessToken, refreshToken, - user: { - id: user.id, - email: user.email, - name: user.name, - }, + user: this.toPublicUser(user), organizations, }, }; @@ -343,11 +343,6 @@ export class AuthService { }; } - /** - * Get user profile with all memberships and permissions - * @param userId - User ID from JWT token - * @returns User profile with organizations and permissions - */ async getProfile(userId: string) { try { const user = await this.prisma.user.findUnique({ @@ -516,11 +511,7 @@ export class AuthService { success: true, data: { accessToken: newAccessToken, - user: { - id: session.user.id, - email: session.user.email, - name: session.user.name, - }, + user: this.toPublicUser(session.user), organizations, }, }; @@ -931,4 +922,44 @@ export class AuthService { }, }; } + + async updateLanguage(userId: string, dto: UpdateLanguageDto) { + const language = dto.language; + + if (!SUPPORTED_USER_LANGUAGES.includes(language)) { + throw new BadRequestException('Language must be one of: en, fa, nl'); + } + + const user = await this.prisma.user.update({ + where: { id: userId }, + data: { language }, + select: { + id: true, + email: true, + name: true, + language: true, + }, + }); + + return { + success: true, + data: { + user: this.toPublicUser(user), + }, + }; + } + + private toPublicUser(user: { + id: string; + email: string; + name: string; + language?: string | null; + }) { + return { + id: user.id, + email: user.email, + name: user.name, + language: user.language ?? 'en', + }; + } } \ No newline at end of file diff --git a/backend/src/modules/auth/dto/update-language.dto.ts b/backend/src/modules/auth/dto/update-language.dto.ts new file mode 100644 index 0000000..5bc91c1 --- /dev/null +++ b/backend/src/modules/auth/dto/update-language.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsIn, IsString } from 'class-validator'; + +export const SUPPORTED_USER_LANGUAGES = ['en', 'fa', 'nl'] as const; +export type SupportedUserLanguage = (typeof SUPPORTED_USER_LANGUAGES)[number]; + +export class UpdateLanguageDto { + @ApiProperty({ enum: SUPPORTED_USER_LANGUAGES, example: 'en' }) + @IsString() + @IsIn(SUPPORTED_USER_LANGUAGES, { + message: 'Language must be one of: en, fa, nl', + }) + language: SupportedUserLanguage; +} diff --git a/frontend/messages/en.json b/frontend/messages/en.json new file mode 100644 index 0000000..e0a269b --- /dev/null +++ b/frontend/messages/en.json @@ -0,0 +1,110 @@ +{ + "common": { + "appName": "DyoLink", + "loading": "Loading...", + "loadingApp": "Loading app...", + "loadingWorkspace": "Loading workspace...", + "continue": "Continue", + "back": "Back", + "save": "Save", + "cancel": "Cancel" + }, + "language": { + "label": "Language", + "selectLanguage": "Select language", + "en": "English", + "fa": "Persian", + "nl": "Dutch" + }, + "theme": { + "switchToLight": "Switch to light mode", + "switchToDark": "Switch to dark mode", + "lightMode": "Light mode", + "darkMode": "Dark mode" + }, + "nav": { + "dashboard": "Dashboard", + "staff": "Staff", + "patients": "Patients", + "appointment": "Appointment", + "treatment": "Treatment", + "billing": "Billing", + "reports": "Reports", + "clinics": "Clinics", + "labs": "Labs" + }, + "auth": { + "login": "Login", + "signIn": "Sign in", + "signOut": "Log out", + "register": "Register", + "startTrial": "Start Trial", + "startFreeTrial": "Start Free Trial", + "dashboard": "Dashboard", + "signInTitle": "Sign in to your account", + "signInPrompt": "Or {link}", + "startTrialLink": "start your free trial", + "registerTitle": "Start your 30-day free trial", + "registerPrompt": "Already have an account?", + "signInLink": "Sign in", + "email": "Email address", + "password": "Password", + "confirmPassword": "Confirm password", + "fullName": "Full name", + "rememberMe": "Remember me", + "forgotPassword": "Forgot your password?", + "invalidCredentials": "Invalid email or password", + "loginFailed": "Login failed", + "registrationFailed": "Registration failed. Please try again.", + "startMyFreeTrial": "Start my free trial", + "trialIncludes": "Your trial includes:", + "trialTeamMembers": "Up to 5 team members", + "trialFullAccess": "Full access to all features", + "trialNoCard": "30 days free, no credit card required", + "termsAgreement": "By signing up, you agree to our {terms} and {privacy}", + "termsOfService": "Terms of Service", + "privacyPolicy": "Privacy Policy", + "signedIn": "Signed in", + "switchOrganization": "Switch organization", + "subscriptions": "Subscriptions", + "account": "Account" + }, + "landing": { + "heroTitle": "Connect Dental Clinics & Labs", + "heroHighlight": "Seamlessly", + "heroSubtitle": "Streamline communication between dental professionals. Start with a 30-day free trial, no credit card required.", + "featureClinicsTitle": "For Clinics", + "featureClinicsDescription": "Manage patients, appointments, and send cases to labs instantly.", + "featureLabsTitle": "For Labs", + "featureLabsDescription": "Receive cases, track progress, and communicate with clinics.", + "featureTeamTitle": "Team Management", + "featureTeamDescription": "Add up to 5 team members during trial. Scale as you grow.", + "featureTrialTitle": "30-Day Trial", + "featureTrialDescription": "Full access to all features. No credit card required.", + "featureRealtimeTitle": "Real-time Updates", + "featureRealtimeDescription": "Get instant notifications on case status changes.", + "featureSecurityTitle": "Secure & Compliant", + "featureSecurityDescription": "HIPAA-compliant with enterprise-grade security.", + "footerCopyright": "© 2026 DyoLink. All rights reserved.", + "termsAndConditions": "Terms & Conditions" + }, + "accountMenu": { + "noActiveSubscription": "No active subscription — review Subscriptions", + "trialEnded": "Trial ended — review Subscriptions", + "trialEndingSoon": "Trial ending soon — review Subscriptions", + "seatsLow": "Seats running low — review Subscriptions", + "reviewSubscriptions": "Review Subscriptions" + }, + "validation": { + "emailInvalid": "Please enter a valid email address", + "passwordRequired": "Password is required", + "nameMinLength": "Name must be at least 2 characters", + "passwordMinLength": "Password must be at least 8 characters", + "passwordUppercase": "Password must contain at least one uppercase letter", + "passwordNumber": "Password must contain at least one number", + "organizationNameMinLength": "Organization name must be at least 2 characters", + "organizationEmailInvalid": "Please enter a valid organization email", + "organizationTypeRequired": "Please select organization type", + "passwordsDoNotMatch": "Passwords don't match" + } +} diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json new file mode 100644 index 0000000..e0a269b --- /dev/null +++ b/frontend/messages/fa.json @@ -0,0 +1,110 @@ +{ + "common": { + "appName": "DyoLink", + "loading": "Loading...", + "loadingApp": "Loading app...", + "loadingWorkspace": "Loading workspace...", + "continue": "Continue", + "back": "Back", + "save": "Save", + "cancel": "Cancel" + }, + "language": { + "label": "Language", + "selectLanguage": "Select language", + "en": "English", + "fa": "Persian", + "nl": "Dutch" + }, + "theme": { + "switchToLight": "Switch to light mode", + "switchToDark": "Switch to dark mode", + "lightMode": "Light mode", + "darkMode": "Dark mode" + }, + "nav": { + "dashboard": "Dashboard", + "staff": "Staff", + "patients": "Patients", + "appointment": "Appointment", + "treatment": "Treatment", + "billing": "Billing", + "reports": "Reports", + "clinics": "Clinics", + "labs": "Labs" + }, + "auth": { + "login": "Login", + "signIn": "Sign in", + "signOut": "Log out", + "register": "Register", + "startTrial": "Start Trial", + "startFreeTrial": "Start Free Trial", + "dashboard": "Dashboard", + "signInTitle": "Sign in to your account", + "signInPrompt": "Or {link}", + "startTrialLink": "start your free trial", + "registerTitle": "Start your 30-day free trial", + "registerPrompt": "Already have an account?", + "signInLink": "Sign in", + "email": "Email address", + "password": "Password", + "confirmPassword": "Confirm password", + "fullName": "Full name", + "rememberMe": "Remember me", + "forgotPassword": "Forgot your password?", + "invalidCredentials": "Invalid email or password", + "loginFailed": "Login failed", + "registrationFailed": "Registration failed. Please try again.", + "startMyFreeTrial": "Start my free trial", + "trialIncludes": "Your trial includes:", + "trialTeamMembers": "Up to 5 team members", + "trialFullAccess": "Full access to all features", + "trialNoCard": "30 days free, no credit card required", + "termsAgreement": "By signing up, you agree to our {terms} and {privacy}", + "termsOfService": "Terms of Service", + "privacyPolicy": "Privacy Policy", + "signedIn": "Signed in", + "switchOrganization": "Switch organization", + "subscriptions": "Subscriptions", + "account": "Account" + }, + "landing": { + "heroTitle": "Connect Dental Clinics & Labs", + "heroHighlight": "Seamlessly", + "heroSubtitle": "Streamline communication between dental professionals. Start with a 30-day free trial, no credit card required.", + "featureClinicsTitle": "For Clinics", + "featureClinicsDescription": "Manage patients, appointments, and send cases to labs instantly.", + "featureLabsTitle": "For Labs", + "featureLabsDescription": "Receive cases, track progress, and communicate with clinics.", + "featureTeamTitle": "Team Management", + "featureTeamDescription": "Add up to 5 team members during trial. Scale as you grow.", + "featureTrialTitle": "30-Day Trial", + "featureTrialDescription": "Full access to all features. No credit card required.", + "featureRealtimeTitle": "Real-time Updates", + "featureRealtimeDescription": "Get instant notifications on case status changes.", + "featureSecurityTitle": "Secure & Compliant", + "featureSecurityDescription": "HIPAA-compliant with enterprise-grade security.", + "footerCopyright": "© 2026 DyoLink. All rights reserved.", + "termsAndConditions": "Terms & Conditions" + }, + "accountMenu": { + "noActiveSubscription": "No active subscription — review Subscriptions", + "trialEnded": "Trial ended — review Subscriptions", + "trialEndingSoon": "Trial ending soon — review Subscriptions", + "seatsLow": "Seats running low — review Subscriptions", + "reviewSubscriptions": "Review Subscriptions" + }, + "validation": { + "emailInvalid": "Please enter a valid email address", + "passwordRequired": "Password is required", + "nameMinLength": "Name must be at least 2 characters", + "passwordMinLength": "Password must be at least 8 characters", + "passwordUppercase": "Password must contain at least one uppercase letter", + "passwordNumber": "Password must contain at least one number", + "organizationNameMinLength": "Organization name must be at least 2 characters", + "organizationEmailInvalid": "Please enter a valid organization email", + "organizationTypeRequired": "Please select organization type", + "passwordsDoNotMatch": "Passwords don't match" + } +} diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json new file mode 100644 index 0000000..e0a269b --- /dev/null +++ b/frontend/messages/nl.json @@ -0,0 +1,110 @@ +{ + "common": { + "appName": "DyoLink", + "loading": "Loading...", + "loadingApp": "Loading app...", + "loadingWorkspace": "Loading workspace...", + "continue": "Continue", + "back": "Back", + "save": "Save", + "cancel": "Cancel" + }, + "language": { + "label": "Language", + "selectLanguage": "Select language", + "en": "English", + "fa": "Persian", + "nl": "Dutch" + }, + "theme": { + "switchToLight": "Switch to light mode", + "switchToDark": "Switch to dark mode", + "lightMode": "Light mode", + "darkMode": "Dark mode" + }, + "nav": { + "dashboard": "Dashboard", + "staff": "Staff", + "patients": "Patients", + "appointment": "Appointment", + "treatment": "Treatment", + "billing": "Billing", + "reports": "Reports", + "clinics": "Clinics", + "labs": "Labs" + }, + "auth": { + "login": "Login", + "signIn": "Sign in", + "signOut": "Log out", + "register": "Register", + "startTrial": "Start Trial", + "startFreeTrial": "Start Free Trial", + "dashboard": "Dashboard", + "signInTitle": "Sign in to your account", + "signInPrompt": "Or {link}", + "startTrialLink": "start your free trial", + "registerTitle": "Start your 30-day free trial", + "registerPrompt": "Already have an account?", + "signInLink": "Sign in", + "email": "Email address", + "password": "Password", + "confirmPassword": "Confirm password", + "fullName": "Full name", + "rememberMe": "Remember me", + "forgotPassword": "Forgot your password?", + "invalidCredentials": "Invalid email or password", + "loginFailed": "Login failed", + "registrationFailed": "Registration failed. Please try again.", + "startMyFreeTrial": "Start my free trial", + "trialIncludes": "Your trial includes:", + "trialTeamMembers": "Up to 5 team members", + "trialFullAccess": "Full access to all features", + "trialNoCard": "30 days free, no credit card required", + "termsAgreement": "By signing up, you agree to our {terms} and {privacy}", + "termsOfService": "Terms of Service", + "privacyPolicy": "Privacy Policy", + "signedIn": "Signed in", + "switchOrganization": "Switch organization", + "subscriptions": "Subscriptions", + "account": "Account" + }, + "landing": { + "heroTitle": "Connect Dental Clinics & Labs", + "heroHighlight": "Seamlessly", + "heroSubtitle": "Streamline communication between dental professionals. Start with a 30-day free trial, no credit card required.", + "featureClinicsTitle": "For Clinics", + "featureClinicsDescription": "Manage patients, appointments, and send cases to labs instantly.", + "featureLabsTitle": "For Labs", + "featureLabsDescription": "Receive cases, track progress, and communicate with clinics.", + "featureTeamTitle": "Team Management", + "featureTeamDescription": "Add up to 5 team members during trial. Scale as you grow.", + "featureTrialTitle": "30-Day Trial", + "featureTrialDescription": "Full access to all features. No credit card required.", + "featureRealtimeTitle": "Real-time Updates", + "featureRealtimeDescription": "Get instant notifications on case status changes.", + "featureSecurityTitle": "Secure & Compliant", + "featureSecurityDescription": "HIPAA-compliant with enterprise-grade security.", + "footerCopyright": "© 2026 DyoLink. All rights reserved.", + "termsAndConditions": "Terms & Conditions" + }, + "accountMenu": { + "noActiveSubscription": "No active subscription — review Subscriptions", + "trialEnded": "Trial ended — review Subscriptions", + "trialEndingSoon": "Trial ending soon — review Subscriptions", + "seatsLow": "Seats running low — review Subscriptions", + "reviewSubscriptions": "Review Subscriptions" + }, + "validation": { + "emailInvalid": "Please enter a valid email address", + "passwordRequired": "Password is required", + "nameMinLength": "Name must be at least 2 characters", + "passwordMinLength": "Password must be at least 8 characters", + "passwordUppercase": "Password must contain at least one uppercase letter", + "passwordNumber": "Password must contain at least one number", + "organizationNameMinLength": "Organization name must be at least 2 characters", + "organizationEmailInvalid": "Please enter a valid organization email", + "organizationTypeRequired": "Please select organization type", + "passwordsDoNotMatch": "Passwords don't match" + } +} diff --git a/frontend/next.config.ts b/frontend/next.config.ts index f18b314..dc7e89f 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,4 +1,7 @@ -import type { NextConfig } from "next"; +import type { NextConfig } from 'next'; +import createNextIntlPlugin from 'next-intl/plugin'; + +const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts'); function publicAppHostname(): string | null { const url = process.env.NEXT_PUBLIC_APP_URL; @@ -12,41 +15,29 @@ function publicAppHostname(): string | null { const appHost = publicAppHostname(); -/** @type {import('next').NextConfig} */ -const nextConfig = { - // Enable React strict mode +const nextConfig: NextConfig = { reactStrictMode: true, - - // Disable x-powered-by header for security poweredByHeader: false, - - // Configure allowed remote image sources (hostname derived from NEXT_PUBLIC_APP_URL at build time) images: { remotePatterns: [ - { protocol: "http", hostname: "localhost" }, + { protocol: 'http', hostname: 'localhost' }, ...(appHost ? [ - { protocol: "http" as const, hostname: appHost }, - { protocol: "https" as const, hostname: appHost }, + { protocol: 'http' as const, hostname: appHost }, + { protocol: 'https' as const, hostname: appHost }, ] : []), - { protocol: "https", hostname: "dyolink.com" }, - { protocol: "https", hostname: "www.dyolink.com" }, + { protocol: 'https', hostname: 'dyolink.com' }, + { protocol: 'https', hostname: 'www.dyolink.com' }, ], }, - - // Environment variables that will be available at build time env: { NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, }, - - // Output configuration - output: 'standalone', // Reduces Docker image size - - // Compress with gzip + output: 'standalone', compress: true, -} +}; -module.exports = nextConfig +export default withNextIntl(nextConfig); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0ca90ec..1c3bae0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "js-cookie": "^3.0.5", "lucide-react": "^0.577.0", "next": "16.1.6", + "next-intl": "^4.13.0", "react": "19.2.3", "react-dom": "19.2.3", "react-hook-form": "^7.71.2", @@ -463,6 +464,36 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@formatjs/fast-memoize": { + "version": "3.1.6", + "resolved": "https://registry.npmmirror.com/@formatjs/fast-memoize/-/fast-memoize-3.1.6.tgz", + "integrity": "sha512-H5aexk1Le7T9TPmscacZ+1pR6CTa2n1wq+HDVGXhH8TzUlQQpeXzZs91dRtmFHrbeNbjPFPfQujUqm7MHgVoXQ==", + "license": "MIT" + }, + "node_modules/@formatjs/icu-messageformat-parser": { + "version": "3.5.11", + "resolved": "https://registry.npmmirror.com/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.11.tgz", + "integrity": "sha512-NVsuNsc2dUVG9+4HBJ/srScxtA/18LqGgwtop/tuN/OIBjVl6QA+0KhfZQddDD9sEh2LeVjLFPGVU3ixa3blcA==", + "license": "MIT", + "dependencies": { + "@formatjs/icu-skeleton-parser": "2.1.10" + } + }, + "node_modules/@formatjs/icu-skeleton-parser": { + "version": "2.1.10", + "resolved": "https://registry.npmmirror.com/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.10.tgz", + "integrity": "sha512-XuSva+8ZGawk8VnD5VD6UeH8KarQ/Z022zgjHDoHmlNiAewstXuuzXc0Hk5pGFSdG+nNw5bfJKXqj1ZXHn9yUA==", + "license": "MIT" + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.8.10", + "resolved": "https://registry.npmmirror.com/@formatjs/intl-localematcher/-/intl-localematcher-0.8.10.tgz", + "integrity": "sha512-P/IC3qws3jH+1fEs+o0RIFgXKRaQlFehjS5W0FPAqdo6hgzawLl+eD0q0JjheQ3XtoOe5n8WSYfX06KQZI/QJA==", + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "3.1.6" + } + }, "node_modules/@hookform/resolvers": { "version": "5.2.2", "resolved": "https://registry.npmmirror.com/@hookform/resolvers/-/resolvers-5.2.2.tgz", @@ -1248,6 +1279,313 @@ "node": ">=12.4.0" } }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1255,12 +1593,216 @@ "dev": true, "license": "MIT" }, + "node_modules/@schummar/icu-type-parser": { + "version": "1.21.5", + "resolved": "https://registry.npmmirror.com/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz", + "integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==", + "license": "MIT" + }, "node_modules/@standard-schema/utils": { "version": "0.3.0", "resolved": "https://registry.npmmirror.com/@standard-schema/utils/-/utils-0.3.0.tgz", "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.15.41", + "resolved": "https://registry.npmmirror.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.41.tgz", + "integrity": "sha512-kREh6J5paQFvP3i7f/4FbqRNOJREutVFVOkder4GVyCBQ39YmER55cW/y1NNjwrchzFqgYswFn0mMDCqbqKzrw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.15.41", + "resolved": "https://registry.npmmirror.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.41.tgz", + "integrity": "sha512-N8B56ESFazZAWZyIkecADSPCwlLEinW7QLMEeotCpv4J7VXwfH+OLkmRL8o96UZ+1355fwHxDTS6/wK7yucvkA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.15.41", + "resolved": "https://registry.npmmirror.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.41.tgz", + "integrity": "sha512-6XrId2fyle0mS5xxON8rU84mPd2Cq1kDJRj+4BnQKTd7u+2kSA6Ww+JkOP0iTNqOqt9OXhPOEAjBHAuonWcdCg==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.15.41", + "resolved": "https://registry.npmmirror.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.41.tgz", + "integrity": "sha512-ynLIarxlkVnqHn1D0fKOVht6mNU5ks6lrH+MY3kkS+XFaGGgDxFZVjWKJlkYTKm3RCvBTfA8Ng5fLufXheMRKQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.15.41", + "resolved": "https://registry.npmmirror.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.41.tgz", + "integrity": "sha512-dXu/5vd4gh8symyhRF+4G7gOPkjmb4pONhh7sl+6GSiW0LOKZlfu5kXmyFbTz9smOT7jgr002qY9b1nujjXt2A==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-ppc64-gnu": { + "version": "1.15.41", + "resolved": "https://registry.npmmirror.com/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.41.tgz", + "integrity": "sha512-XGO6zVPXoPE0gf/XnI4jBbafNT13AYgoh6ns0JCSdOetI/kqVf0vhpz7NuNgAzZrMVCsmieqjPoTwViDgh4mOQ==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-s390x-gnu": { + "version": "1.15.41", + "resolved": "https://registry.npmmirror.com/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.41.tgz", + "integrity": "sha512-0WUglRwyZtW+iMi7J3iFdrCxreZZIKf4egTwEQfIYRsqFax69A0OrFj+NIoFSE03xBT/IFRrg+S8K6f9Ky+4hA==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.41", + "resolved": "https://registry.npmmirror.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.41.tgz", + "integrity": "sha512-VxkuQK59c0tHm6uJZCUrS3cyA2JhGGfdU6e41SZz0x/JS+4Sm7C1mIc97In14vkZJopEt7yXA2TouCqZDSygEA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.41", + "resolved": "https://registry.npmmirror.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.41.tgz", + "integrity": "sha512-/0qXIu1ZxggLuovLb22vFfKHq2AA4n6Whw5UwmVCHk4pkw7KWnPIQpMCEqUMPsNkFJig7PPp/TSYFu8ZEb2rtQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.41", + "resolved": "https://registry.npmmirror.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.41.tgz", + "integrity": "sha512-Y481sMNZM6rECh9VO4+y26N1lWEDAyxnBZskUf37fl90uHE946VHfmiVQWT0uMFOhyJJFovGTRuF4W82dwewUg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.41", + "resolved": "https://registry.npmmirror.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.41.tgz", + "integrity": "sha512-BAchBD5qeUzy3hiPSLJtaaoSm4blCLyYffOF1bGE4ETcV+OisqjUAwDQMJj++4bTpvMCDzwC+Bj3PmQyBCtscw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.41", + "resolved": "https://registry.npmmirror.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.41.tgz", + "integrity": "sha512-WOkA+fJ/ViVBQDsSV9JC52NACTe5PhlurA6viASDZGb7HR3KS01ZG7RZ+Bg6SVQFIoq3gSbTsskQVe6EbHFAYw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmmirror.com/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1270,6 +1812,15 @@ "tslib": "^2.8.0" } }, + "node_modules/@swc/types": { + "version": "0.1.27", + "resolved": "https://registry.npmmirror.com/@swc/types/-/types-0.1.27.tgz", + "integrity": "sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==", + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, "node_modules/@tailwindcss/node": { "version": "4.2.1", "resolved": "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.2.1.tgz", @@ -2900,7 +3451,6 @@ "version": "2.1.2", "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -4065,6 +4615,21 @@ "hermes-estree": "0.25.1" } }, + "node_modules/icu-minify": { + "version": "4.13.0", + "resolved": "https://registry.npmmirror.com/icu-minify/-/icu-minify-4.13.0.tgz", + "integrity": "sha512-SIFMeUHZJjzS5RvIGvybKvWoHjDm9cGVEs2EpJ8PmywOdJLWyblPm7TdPLLoUtkJtwQD7iGhl2WMptZ+N0on+w==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "license": "MIT", + "dependencies": { + "@formatjs/icu-messageformat-parser": "^3.4.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", @@ -4117,6 +4682,16 @@ "node": ">= 0.4" } }, + "node_modules/intl-messageformat": { + "version": "11.2.8", + "resolved": "https://registry.npmmirror.com/intl-messageformat/-/intl-messageformat-11.2.8.tgz", + "integrity": "sha512-l323RCl3qJDVQ8U9j74ut/hVMdg3VPsOHpVMDvFfz9qiq4dPO5ooVYFNVUzzrpgG39a+RLzcXyJb8VFgIU+tUA==", + "license": "BSD-3-Clause", + "dependencies": { + "@formatjs/fast-memoize": "3.1.6", + "@formatjs/icu-messageformat-parser": "3.5.11" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmmirror.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -4279,7 +4854,6 @@ "version": "2.1.1", "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4325,7 +4899,6 @@ "version": "4.0.3", "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -5161,6 +5734,15 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/next": { "version": "16.1.6", "resolved": "https://registry.npmmirror.com/next/-/next-16.1.6.tgz", @@ -5214,6 +5796,83 @@ } } }, + "node_modules/next-intl": { + "version": "4.13.0", + "resolved": "https://registry.npmmirror.com/next-intl/-/next-intl-4.13.0.tgz", + "integrity": "sha512-OvNq2v5XLx4EkQOsAhVE9g+6zdb83XHusADCXXtIW4LILYnjEVaeINdr1lkVWKSjzwNUiMSlH5N4K0OQTRiv6A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "license": "MIT", + "dependencies": { + "@formatjs/intl-localematcher": "^0.8.1", + "@parcel/watcher": "^2.4.1", + "@swc/core": "^1.15.2", + "icu-minify": "^4.13.0", + "negotiator": "^1.0.0", + "next-intl-swc-plugin-extractor": "^4.13.0", + "po-parser": "^2.1.1", + "use-intl": "^4.13.0" + }, + "peerDependencies": { + "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/next-intl-swc-plugin-extractor": { + "version": "4.13.0", + "resolved": "https://registry.npmmirror.com/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.13.0.tgz", + "integrity": "sha512-6S/fJI0KXvLCL8nhBo9P8eGaJPzmwJBTCzX0NaUIj0VyU8U89d//T+vjMLdNIXl5MlLaYH7B9MbAjb8Mvu+tqQ==", + "license": "MIT" + }, + "node_modules/next-intl/node_modules/@swc/core": { + "version": "1.15.41", + "resolved": "https://registry.npmmirror.com/@swc/core/-/core-1.15.41.tgz", + "integrity": "sha512-03nQq/082QRJJiOvp3FGbgxTGyyxMxohPTjhk/W9bD2J0tk4ukITI7goOhOO2WbaHn/lsPmo/zf8+DIXhwpgYQ==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.26" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.15.41", + "@swc/core-darwin-x64": "1.15.41", + "@swc/core-linux-arm-gnueabihf": "1.15.41", + "@swc/core-linux-arm64-gnu": "1.15.41", + "@swc/core-linux-arm64-musl": "1.15.41", + "@swc/core-linux-ppc64-gnu": "1.15.41", + "@swc/core-linux-s390x-gnu": "1.15.41", + "@swc/core-linux-x64-gnu": "1.15.41", + "@swc/core-linux-x64-musl": "1.15.41", + "@swc/core-win32-arm64-msvc": "1.15.41", + "@swc/core-win32-ia32-msvc": "1.15.41", + "@swc/core-win32-x64-msvc": "1.15.41" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.4.31.tgz", @@ -5242,6 +5901,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmmirror.com/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -5518,6 +6183,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/po-parser": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/po-parser/-/po-parser-2.1.1.tgz", + "integrity": "sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==", + "license": "MIT" + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -6635,6 +7306,27 @@ "punycode": "^2.1.0" } }, + "node_modules/use-intl": { + "version": "4.13.0", + "resolved": "https://registry.npmmirror.com/use-intl/-/use-intl-4.13.0.tgz", + "integrity": "sha512-fAFDrWaASxlhXOipcOyb5VDD+YONqj6+8O8EcG/J7RBoOUF3A8YahRWLN+mBxYMrlMQB8N6Voqk5X+YC+HSL0A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "^3.1.0", + "@schummar/icu-type-parser": "1.21.5", + "icu-minify": "^4.13.0", + "intl-messageformat": "^11.1.0" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index f07681e..647a797 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "js-cookie": "^3.0.5", "lucide-react": "^0.577.0", "next": "16.1.6", + "next-intl": "^4.13.0", "react": "19.2.3", "react-dom": "19.2.3", "react-hook-form": "^7.71.2", @@ -32,4 +33,4 @@ "tailwindcss": "^4", "typescript": "^5" } -} \ No newline at end of file +} diff --git a/frontend/src/app/(public)/login/page.tsx b/frontend/src/app/(public)/login/page.tsx deleted file mode 100644 index a0c21ee..0000000 --- a/frontend/src/app/(public)/login/page.tsx +++ /dev/null @@ -1,241 +0,0 @@ -// src/app/login/page.tsx -// 'use client'; -// import { useState } from 'react'; -// import { useForm } from 'react-hook-form'; -// import { zodResolver } from '@hookform/resolvers/zod'; -// import * as z from 'zod'; -// import Link from 'next/link'; -// import { Mail, Lock } from 'lucide-react'; -// import { useAuth } from '@/lib/hooks/useAuth'; -// import { Button } from '@/components/ui/Button'; -// import { Input } from '@/components/ui/Input'; -// const loginSchema = z.object({ -// email: z.string().email('Please enter a valid email address'), -// password: z.string().min(1, 'Password is required'), -// }); -// type LoginForm = z.infer; -// export default function LoginPage() { -// const { login, isLoading } = useAuth(); -// const [error, setError] = useState(null); -// const { -// register, -// handleSubmit, -// formState: { errors }, -// } = useForm({ -// resolver: zodResolver(loginSchema), -// }); -// const onSubmit = async (data: LoginForm) => { -// try { -// setError(null); -// await login(data.email, data.password); -// } catch (err: any) { -// setError(err.message || 'Invalid email or password'); -// } -// }; - -// return ( -//
-//
-// -// DyoLink -// -//

-// Sign in to your account -//

-//

-// Or{' '} -// -// start your free trial -// -//

-//
-//
-//
-//
-// } -// /> -// } -// /> -//
-//
-// -// -//
-//
-// -// Forgot your password? -// -//
-//
-// {error && ( -//
-//

{error}

-//
-// )} -// -//
-//
-//
-//
-// ); -// } -'use client'; - -import { useState, useEffect } from 'react'; -import { useRouter } from 'next/navigation'; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import * as z from 'zod'; -import Link from 'next/link'; -import { Mail, Lock } from 'lucide-react'; - -import { useAuth } from '@/lib/hooks/useAuth'; -import { Button } from '@/components/ui/shared/Button'; -import { Input } from '@/components/ui/shared/Input'; - -const loginSchema = z.object({ - email: z.string().email('Please enter a valid email address'), - password: z.string().min(1, 'Password is required'), -}); - -type LoginForm = z.infer; - -export default function LoginPage() { - const { login, isLoading, user, isAuthReady } = useAuth(); - const router = useRouter(); - - const [error, setError] = useState(null); - - useEffect(() => { - if (isAuthReady && user) { - router.push('/today'); - } - }, [user, isAuthReady, router]); - - const { - register, - handleSubmit, - formState: { errors }, - } = useForm({ - resolver: zodResolver(loginSchema), - }); - - const onSubmit = async (data: LoginForm) => { - try { - setError(null); - await login(data.email, data.password); - } catch (err: any) { - setError(err.message || 'Invalid email or password'); - } - }; - - if (!isAuthReady) { - return ( -
-

Loading...

-
- ); - } - - return ( -
-
- - DyoLink - -

- Sign in to your account -

-

- Or{' '} - - start your free trial - -

-
- -
-
-
- } - /> - } - /> - -
-
- - -
-
- - Forgot your password? - -
-
- - {error && ( -
-

{error}

-
- )} - - -
-
-
-
- ); -} \ No newline at end of file diff --git a/frontend/src/app/(public)/register/page.tsx b/frontend/src/app/(public)/register/page.tsx deleted file mode 100644 index c1372fe..0000000 --- a/frontend/src/app/(public)/register/page.tsx +++ /dev/null @@ -1,206 +0,0 @@ -// src/app/register/page.tsx\ -'use client'; -import { useState } from 'react'; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import * as z from 'zod'; -import Link from 'next/link'; -import { Mail, Lock, User } from 'lucide-react'; -import { useAuth } from '@/lib/hooks/useAuth'; -import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields'; -import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps'; -import { Button } from '@/components/ui/shared/Button'; -import { Input } from '@/components/ui/shared/Input'; -const registerSchema = z.object({ - name: z.string().min(2, 'Name must be at least 2 characters'), - email: z.string().email('Please enter a valid email address'), - password: z.string() - .min(8, 'Password must be at least 8 characters') - .regex(/[A-Z]/, 'Password must contain at least one uppercase letter') - .regex(/[0-9]/, 'Password must contain at least one number'), - confirmPassword: z.string(), - organizationName: z.string().min(2, 'Organization name must be at least 2 characters'), - organizationEmail: z.string().email('Please enter a valid organization email'), - organizationType: z.enum(['CLINIC', 'LAB'], { - message: 'Please select organization type', - }), -}).refine((data) => data.password === data.confirmPassword, { - message: "Passwords don't match", - path: ['confirmPassword'], -}); -type RegisterForm = z.infer; - -export default function RegisterPage() { - const { registerTrial, isLoading } = useAuth(); - const [step, setStep] = useState(1); - const [error, setError] = useState(null); - - const { - register, - handleSubmit, - watch, - formState: { errors }, - trigger, - setValue, - } = useForm({ - resolver: zodResolver(registerSchema), - mode: 'onChange', - }); - const organizationType = watch('organizationType'); - const handleNext = async () => { - const fieldsToValidate = step === 1 - ? ['name', 'email', 'password', 'confirmPassword'] - : ['organizationName', 'organizationEmail', 'organizationType']; - - const isValid = await trigger(fieldsToValidate as any); - if (isValid) { - setStep(step + 1); - } - }; - const onSubmit = async (data: RegisterForm) => { - try { - setError(null); - await registerTrial( - data.email, - data.password, - data.name, - data.organizationName, - data.organizationEmail, - data.organizationType - ); - // No need to redirect - auth context will handle it - } catch (err: any) { - setError(err.message || 'Registration failed. Please try again.'); - } - }; - return ( -
-
- - DyoLink - -

- Start your 30-day free trial -

-

- Already have an account?{' '} - - Sign in - -

-
-
-
- - {/* Trial Info Banner */} -
-

Your trial - includes:

-
    -
  • - Up to 5 team members -
  • -
  • - Full access to all features -
  • -
  • - 30 days free, no credit card - required -
  • -
-
-
- {step === 1 && ( - <> - } - /> - } - /> - } - /> - } - /> - - - )} - {step === 2 && ( - <> - - {error && ( -
-

{error}

-
- )} -
- - -
- - )} - -

- By signing up, you agree to our{' '} - - Terms of Service - {' '} - and{' '} - - Privacy Policy - -

-
-
- -
- ); -} - - diff --git a/frontend/src/app/(dashboard)/appointments/page.tsx b/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx similarity index 100% rename from frontend/src/app/(dashboard)/appointments/page.tsx rename to frontend/src/app/[locale]/(dashboard)/appointments/page.tsx diff --git a/frontend/src/app/(dashboard)/billing/page.tsx b/frontend/src/app/[locale]/(dashboard)/billing/page.tsx similarity index 100% rename from frontend/src/app/(dashboard)/billing/page.tsx rename to frontend/src/app/[locale]/(dashboard)/billing/page.tsx diff --git a/frontend/src/app/(dashboard)/layout.tsx b/frontend/src/app/[locale]/(dashboard)/layout.tsx similarity index 89% rename from frontend/src/app/(dashboard)/layout.tsx rename to frontend/src/app/[locale]/(dashboard)/layout.tsx index 4c0978c..d3b4c39 100644 --- a/frontend/src/app/(dashboard)/layout.tsx +++ b/frontend/src/app/[locale]/(dashboard)/layout.tsx @@ -1,10 +1,11 @@ 'use client'; import { memo, useEffect } from 'react'; -import { usePathname, useRouter } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +import { usePathname, useRouter } from '@/i18n/navigation'; import { useAuth } from '@/lib/hooks/useAuth'; import Sidebar from '@/components/ui/shared/Sidebar'; -import { ThemeToggle } from '@/components/ui/shared/ThemeToggle'; +import { TopBarControls } from '@/components/ui/shared/TopBarControls'; import { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu'; import { canAccessAppointmentsSection, @@ -14,11 +15,11 @@ import { } from '@/components/shared/permissions'; export default function DashboardLayout({ children }: { children: React.ReactNode }) { + const t = useTranslations('common'); const { user, currentOrganization, isAuthReady } = useAuth(); const router = useRouter(); const pathname = usePathname(); - // ✅ AUTH GUARD (runs once per navigation group) useEffect(() => { if (!isAuthReady) return; @@ -44,11 +45,10 @@ export default function DashboardLayout({ children }: { children: React.ReactNod } }, [isAuthReady, user, currentOrganization, router, pathname]); - // ✅ LOADING ONLY FOR INITIAL LOAD if (!isAuthReady) { return (
- Loading app... + {t('loadingApp')}
); } @@ -56,7 +56,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod if (!user || !currentOrganization) { return (
- Loading workspace... + {t('loadingWorkspace')}
); } @@ -88,9 +88,9 @@ const DashboardHeader = memo(function DashboardHeader({

{organizationName}

- +
); -}); \ No newline at end of file +}); diff --git a/frontend/src/app/(dashboard)/organizations/page.tsx b/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx similarity index 100% rename from frontend/src/app/(dashboard)/organizations/page.tsx rename to frontend/src/app/[locale]/(dashboard)/organizations/page.tsx diff --git a/frontend/src/app/(dashboard)/patients/page.tsx b/frontend/src/app/[locale]/(dashboard)/patients/page.tsx similarity index 100% rename from frontend/src/app/(dashboard)/patients/page.tsx rename to frontend/src/app/[locale]/(dashboard)/patients/page.tsx diff --git a/frontend/src/app/(dashboard)/reports/page.tsx b/frontend/src/app/[locale]/(dashboard)/reports/page.tsx similarity index 100% rename from frontend/src/app/(dashboard)/reports/page.tsx rename to frontend/src/app/[locale]/(dashboard)/reports/page.tsx diff --git a/frontend/src/app/(dashboard)/settings/account/page.tsx b/frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx similarity index 94% rename from frontend/src/app/(dashboard)/settings/account/page.tsx rename to frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx index 357f4f9..cc755d4 100644 --- a/frontend/src/app/(dashboard)/settings/account/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import Link from 'next/link'; +import { Link } from '@/i18n/navigation'; export default function AccountSettingsPage() { return ( diff --git a/frontend/src/app/(dashboard)/settings/organizations/page.tsx b/frontend/src/app/[locale]/(dashboard)/settings/organizations/page.tsx similarity index 91% rename from frontend/src/app/(dashboard)/settings/organizations/page.tsx rename to frontend/src/app/[locale]/(dashboard)/settings/organizations/page.tsx index ccbdb3b..f1e7696 100644 --- a/frontend/src/app/(dashboard)/settings/organizations/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/settings/organizations/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import Link from 'next/link'; +import { Link } from '@/i18n/navigation'; import { OrganizationSelectorContent } from '@/components/ui/organizations/OrganizationSelectorContent'; export default function DashboardOrganizationsSettingsPage() { diff --git a/frontend/src/app/(dashboard)/settings/subscriptions/page.tsx b/frontend/src/app/[locale]/(dashboard)/settings/subscriptions/page.tsx similarity index 99% rename from frontend/src/app/(dashboard)/settings/subscriptions/page.tsx rename to frontend/src/app/[locale]/(dashboard)/settings/subscriptions/page.tsx index 7116c8c..9a2b670 100644 --- a/frontend/src/app/(dashboard)/settings/subscriptions/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/settings/subscriptions/page.tsx @@ -1,8 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; -import Link from 'next/link'; -import { useRouter } from 'next/navigation'; +import { Link, useRouter } from '@/i18n/navigation'; import { useAuth } from '@/lib/hooks/useAuth'; import { authApi } from '@/lib/api/auth'; import { Button } from '@/components/ui/shared/Button'; diff --git a/frontend/src/app/(dashboard)/staff/page.tsx b/frontend/src/app/[locale]/(dashboard)/staff/page.tsx similarity index 99% rename from frontend/src/app/(dashboard)/staff/page.tsx rename to frontend/src/app/[locale]/(dashboard)/staff/page.tsx index b305f65..03d5a96 100644 --- a/frontend/src/app/(dashboard)/staff/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/staff/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useRouter } from 'next/navigation'; +import { useRouter } from '@/i18n/navigation'; import { firstAccessibleDashboardPath, canEditStaff, diff --git a/frontend/src/app/(dashboard)/today/page.tsx b/frontend/src/app/[locale]/(dashboard)/today/page.tsx similarity index 97% rename from frontend/src/app/(dashboard)/today/page.tsx rename to frontend/src/app/[locale]/(dashboard)/today/page.tsx index 9e7ecf7..47c2b3e 100644 --- a/frontend/src/app/(dashboard)/today/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/today/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import Link from 'next/link'; +import { Link } from '@/i18n/navigation'; import { useAuth } from '@/lib/hooks/useAuth'; import { Card } from '@/components/ui/shared/Card'; diff --git a/frontend/src/app/(dashboard)/treatment/page.tsx b/frontend/src/app/[locale]/(dashboard)/treatment/page.tsx similarity index 100% rename from frontend/src/app/(dashboard)/treatment/page.tsx rename to frontend/src/app/[locale]/(dashboard)/treatment/page.tsx diff --git a/frontend/src/app/(public)/accept-invite/page.tsx b/frontend/src/app/[locale]/(public)/accept-invite/page.tsx similarity index 98% rename from frontend/src/app/(public)/accept-invite/page.tsx rename to frontend/src/app/[locale]/(public)/accept-invite/page.tsx index 67f6fbb..c1bdc43 100644 --- a/frontend/src/app/(public)/accept-invite/page.tsx +++ b/frontend/src/app/[locale]/(public)/accept-invite/page.tsx @@ -2,8 +2,8 @@ import { useEffect, useMemo, useState } from 'react'; import { Suspense } from 'react'; -import Link from 'next/link'; -import { useRouter, useSearchParams } from 'next/navigation'; +import { Link, useRouter } from '@/i18n/navigation'; +import { useSearchParams } from 'next/navigation'; import { Button } from '@/components/ui/shared/Button'; import { Input } from '@/components/ui/shared/Input'; import { staffApi } from '@/lib/api/staff'; diff --git a/frontend/src/app/(public)/accept-organization-invite/page.tsx b/frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx similarity index 98% rename from frontend/src/app/(public)/accept-organization-invite/page.tsx rename to frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx index 782ea2f..6c87eb5 100644 --- a/frontend/src/app/(public)/accept-organization-invite/page.tsx +++ b/frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx @@ -1,8 +1,8 @@ 'use client'; import { Suspense, useEffect, useMemo, useState } from 'react'; -import Link from 'next/link'; -import { useRouter, useSearchParams } from 'next/navigation'; +import { Link, useRouter } from '@/i18n/navigation'; +import { useSearchParams } from 'next/navigation'; import { useForm, type FieldErrors, type UseFormRegister, type UseFormSetValue } from 'react-hook-form'; import type { OrganizationDetailsFormValues } from '@/components/ui/auth/OrganizationDetailsFields'; import { zodResolver } from '@hookform/resolvers/zod'; diff --git a/frontend/src/app/[locale]/(public)/login/page.tsx b/frontend/src/app/[locale]/(public)/login/page.tsx new file mode 100644 index 0000000..aaacf4c --- /dev/null +++ b/frontend/src/app/[locale]/(public)/login/page.tsx @@ -0,0 +1,144 @@ +'use client'; + +import { useState, useEffect, useMemo } from 'react'; +import { useRouter } from '@/i18n/navigation'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { useTranslations } from 'next-intl'; +import { Link } from '@/i18n/navigation'; +import { Mail, Lock } from 'lucide-react'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { Button } from '@/components/ui/shared/Button'; +import { Input } from '@/components/ui/shared/Input'; +import { TopBarControls } from '@/components/ui/shared/TopBarControls'; + +type LoginForm = { + email: string; + password: string; +}; + +export default function LoginPage() { + const t = useTranslations('auth'); + const tCommon = useTranslations('common'); + const tValidation = useTranslations('validation'); + const { login, isLoading, user, isAuthReady } = useAuth(); + const router = useRouter(); + const [error, setError] = useState(null); + + const loginSchema = useMemo( + () => + z.object({ + email: z.string().email(tValidation('emailInvalid')), + password: z.string().min(1, tValidation('passwordRequired')), + }), + [tValidation], + ); + + useEffect(() => { + if (isAuthReady && user) { + router.push('/today'); + } + }, [user, isAuthReady, router]); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(loginSchema), + }); + + const onSubmit = async (data: LoginForm) => { + try { + setError(null); + await login(data.email, data.password); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : t('invalidCredentials'); + setError(message || t('invalidCredentials')); + } + }; + + if (!isAuthReady) { + return ( +
+

{tCommon('loading')}

+
+ ); + } + + return ( +
+
+ +
+ +
+ + {tCommon('appName')} + +

+ {t('signInTitle')} +

+

+ Or{' '} + + {t('startTrialLink')} + +

+
+ +
+
+
+ } + /> + } + /> + +
+
+ + +
+
+ + {t('forgotPassword')} + +
+
+ + {error && ( +
+

{error}

+
+ )} + + +
+
+
+
+ ); +} diff --git a/frontend/src/app/(public)/page.tsx b/frontend/src/app/[locale]/(public)/page.tsx similarity index 64% rename from frontend/src/app/(public)/page.tsx rename to frontend/src/app/[locale]/(public)/page.tsx index a654924..4ce81db 100644 --- a/frontend/src/app/(public)/page.tsx +++ b/frontend/src/app/[locale]/(public)/page.tsx @@ -1,120 +1,112 @@ 'use client'; -import Link from 'next/link'; +import { useTranslations } from 'next-intl'; +import { Link } from '@/i18n/navigation'; import { useAuth } from '@/lib/hooks/useAuth'; import { Button } from '@/components/ui/shared/Button'; -import { ThemeToggle } from '@/components/ui/shared/ThemeToggle'; +import { TopBarControls } from '@/components/ui/shared/TopBarControls'; import { Building2, Beaker, Calendar, Shield, Clock, Users } from 'lucide-react'; export default function HomePage() { + const t = useTranslations('landing'); + const tAuth = useTranslations('auth'); + const tCommon = useTranslations('common'); const { user } = useAuth(); return (
- - {/* Header */}
- DyoLink + {tCommon('appName')}
- + {user ? ( - + ) : ( <> - + - + )}
-
- {/* Hero Section */}
-
-

- Connect Dental Clinics & Labs - Seamlessly + {t('heroTitle')} + {t('heroHighlight')}

- Streamline communication between dental professionals. Start with - a 30-day free trial, no credit card required. + {t('heroSubtitle')}

{!user && ( )}
- {/* Features */}
} - title="For Clinics" - description="Manage patients, appointments, and send cases to labs instantly." + title={t('featureClinicsTitle')} + description={t('featureClinicsDescription')} /> } - title="For Labs" - description="Receive cases, track progress, and communicate with clinics." + title={t('featureLabsTitle')} + description={t('featureLabsDescription')} /> } - title="Team Management" - description="Add up to 5 team members during trial. Scale as you grow." + title={t('featureTeamTitle')} + description={t('featureTeamDescription')} /> } - title="30-Day Trial" - description="Full access to all features. No credit card required." + title={t('featureTrialTitle')} + description={t('featureTrialDescription')} /> } - title="Real-time Updates" - description="Get instant notifications on case status changes." + title={t('featureRealtimeTitle')} + description={t('featureRealtimeDescription')} /> } - title="Secure & Compliant" - description="HIPAA-compliant with enterprise-grade security." + title={t('featureSecurityTitle')} + description={t('featureSecurityDescription')} />
-
- {/* Footer */}
- -
© 2026 DyoLink. All rights reserved.
+
{t('footerCopyright')}
- Terms & Conditions + {t('termsAndConditions')} - Privacy Policy + {tAuth('privacyPolicy')}
-
@@ -132,19 +124,9 @@ function FeatureCard({ }) { return (
- -
- {icon} -
- -

- {title} -

- -

- {description} -

- +
{icon}
+

{title}

+

{description}

); -} \ No newline at end of file +} diff --git a/frontend/src/app/[locale]/(public)/register/page.tsx b/frontend/src/app/[locale]/(public)/register/page.tsx new file mode 100644 index 0000000..2923e8d --- /dev/null +++ b/frontend/src/app/[locale]/(public)/register/page.tsx @@ -0,0 +1,221 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { useTranslations } from 'next-intl'; +import { Link } from '@/i18n/navigation'; +import { Mail, Lock, User } from 'lucide-react'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields'; +import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps'; +import { Button } from '@/components/ui/shared/Button'; +import { Input } from '@/components/ui/shared/Input'; +import { TopBarControls } from '@/components/ui/shared/TopBarControls'; + +type RegisterForm = { + name: string; + email: string; + password: string; + confirmPassword: string; + organizationName: string; + organizationEmail: string; + organizationType: 'CLINIC' | 'LAB'; +}; + +export default function RegisterPage() { + const t = useTranslations('auth'); + const tCommon = useTranslations('common'); + const tValidation = useTranslations('validation'); + const { registerTrial, isLoading } = useAuth(); + const [step, setStep] = useState(1); + const [error, setError] = useState(null); + + const registerSchema = useMemo( + () => + z + .object({ + name: z.string().min(2, tValidation('nameMinLength')), + email: z.string().email(tValidation('emailInvalid')), + password: z + .string() + .min(8, tValidation('passwordMinLength')) + .regex(/[A-Z]/, tValidation('passwordUppercase')) + .regex(/[0-9]/, tValidation('passwordNumber')), + confirmPassword: z.string(), + organizationName: z.string().min(2, tValidation('organizationNameMinLength')), + organizationEmail: z.string().email(tValidation('organizationEmailInvalid')), + organizationType: z.enum(['CLINIC', 'LAB'], { + message: tValidation('organizationTypeRequired'), + }), + }) + .refine((data) => data.password === data.confirmPassword, { + message: tValidation('passwordsDoNotMatch'), + path: ['confirmPassword'], + }), + [tValidation], + ); + + const { + register, + handleSubmit, + watch, + formState: { errors }, + trigger, + setValue, + } = useForm({ + resolver: zodResolver(registerSchema), + mode: 'onChange', + }); + + const organizationType = watch('organizationType'); + + const handleNext = async () => { + const fieldsToValidate = + step === 1 + ? (['name', 'email', 'password', 'confirmPassword'] as const) + : (['organizationName', 'organizationEmail', 'organizationType'] as const); + + const isValid = await trigger([...fieldsToValidate]); + if (isValid) { + setStep(step + 1); + } + }; + + const onSubmit = async (data: RegisterForm) => { + try { + setError(null); + await registerTrial( + data.email, + data.password, + data.name, + data.organizationName, + data.organizationEmail, + data.organizationType, + ); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : t('registrationFailed'); + setError(message || t('registrationFailed')); + } + }; + + return ( +
+
+ +
+ +
+ + {tCommon('appName')} + +

+ {t('registerTitle')} +

+

+ {t('registerPrompt')}{' '} + + {t('signInLink')} + +

+
+ +
+
+ +
+

{t('trialIncludes')}

+
    +
  • + {t('trialTeamMembers')} +
  • +
  • + {t('trialFullAccess')} +
  • +
  • + {t('trialNoCard')} +
  • +
+
+ +
+ {step === 1 && ( + <> + } + /> + } + /> + } + /> + } + /> + + + )} + + {step === 2 && ( + <> + + {error && ( +
+

{error}

+
+ )} +
+ + +
+ + )} + + +

+ By signing up, you agree to our{' '} + + {t('termsOfService')} + {' '} + and{' '} + + {t('privacyPolicy')} + +

+
+
+
+ ); +} diff --git a/frontend/src/app/(public)/select-organization/page.tsx b/frontend/src/app/[locale]/(public)/select-organization/page.tsx similarity index 100% rename from frontend/src/app/(public)/select-organization/page.tsx rename to frontend/src/app/[locale]/(public)/select-organization/page.tsx diff --git a/frontend/src/app/[locale]/layout.tsx b/frontend/src/app/[locale]/layout.tsx new file mode 100644 index 0000000..570d0ab --- /dev/null +++ b/frontend/src/app/[locale]/layout.tsx @@ -0,0 +1,56 @@ +import type { Metadata } from 'next'; +import { NextIntlClientProvider } from 'next-intl'; +import { getMessages, setRequestLocale } from 'next-intl/server'; +import { hasLocale } from 'next-intl'; +import { notFound } from 'next/navigation'; +import Script from 'next/script'; +import '@/styles/globals.css'; +import '@/styles/background-web.css'; +import { AuthProvider } from '@/lib/hooks/useAuth'; +import { THEME_STORAGE_KEY } from '@/lib/theme'; +import { routing, localeHtmlLang } from '@/i18n/routing'; +import { LocaleSync } from '@/components/i18n/LocaleSync'; + +export const metadata: Metadata = { + title: 'DyoLink - Dental Clinic & Lab Communication Hub', + description: 'Connect dental clinics and laboratories seamlessly', +}; + +export function generateStaticParams() { + return routing.locales.map((locale) => ({ locale })); +} + +export default async function LocaleLayout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + + if (!hasLocale(routing.locales, locale)) { + notFound(); + } + + setRequestLocale(locale); + const messages = await getMessages(); + + const themeInit = `(function(){try{var k=${JSON.stringify(THEME_STORAGE_KEY)};var t=localStorage.getItem(k);document.documentElement.setAttribute('data-theme',t==='light'||t==='dark'?t:'dark');}catch(e){document.documentElement.setAttribute('data-theme','dark');}})();`; + + return ( + + + + + + + {children} + + + + + ); +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index eecf822..cd4a053 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,33 +1,7 @@ -// src/app/layout.tsx -import type { Metadata } from 'next'; -import Script from 'next/script'; -import '@/styles/globals.css'; -import '@/styles/background-web.css'; -import { AuthProvider } from '@/lib/hooks/useAuth'; -import { THEME_STORAGE_KEY } from '@/lib/theme'; - -export const metadata: Metadata = { - title: 'DyoLink - Dental Clinic & Lab Communication Hub', - description: 'Connect dental clinics and laboratories seamlessly', -}; - export default function RootLayout({ children, }: { children: React.ReactNode; }) { - const themeInit = `(function(){try{var k=${JSON.stringify(THEME_STORAGE_KEY)};var t=localStorage.getItem(k);document.documentElement.setAttribute('data-theme',t==='light'||t==='dark'?t:'dark');}catch(e){document.documentElement.setAttribute('data-theme','dark');}})();`; - - return ( - - - - - {children} - - - - ); -} \ No newline at end of file + return children; +} diff --git a/frontend/src/components/i18n/LocaleSync.tsx b/frontend/src/components/i18n/LocaleSync.tsx new file mode 100644 index 0000000..cc59e66 --- /dev/null +++ b/frontend/src/components/i18n/LocaleSync.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { useEffect } from 'react'; +import { useLocale } from 'next-intl'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { usePathname, useRouter } from '@/i18n/navigation'; +import type { AppLocale } from '@/i18n/routing'; +import { isAppLocale } from '@/i18n/routing'; + +/** Redirect authenticated users to their saved profile language when it differs from the URL. */ +export function LocaleSync() { + const locale = useLocale(); + const router = useRouter(); + const pathname = usePathname(); + const { user, isAuthReady } = useAuth(); + + useEffect(() => { + if (!isAuthReady || !user?.language) return; + + const preferred = user.language; + if (!isAppLocale(preferred) || preferred === locale) return; + + router.replace(pathname, { locale: preferred as AppLocale }); + }, [isAuthReady, user?.language, locale, pathname, router]); + + return null; +} diff --git a/frontend/src/components/ui/dashboard/DashboardAccountMenu.tsx b/frontend/src/components/ui/dashboard/DashboardAccountMenu.tsx index c9ee022..661d8a9 100644 --- a/frontend/src/components/ui/dashboard/DashboardAccountMenu.tsx +++ b/frontend/src/components/ui/dashboard/DashboardAccountMenu.tsx @@ -1,7 +1,8 @@ 'use client'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import Link from 'next/link'; +import { useTranslations } from 'next-intl'; +import { Link } from '@/i18n/navigation'; import { Settings, AlertTriangle, @@ -15,16 +16,21 @@ import { useAuth } from '@/lib/hooks/useAuth'; import { authApi } from '@/lib/api/auth'; import type { SubscriptionAlertData } from '@/types/subscription'; -function warningTooltip(data: SubscriptionAlertData | null): string { +function warningTooltip( + data: SubscriptionAlertData | null, + t: ReturnType>, +): string { if (!data?.showWarning) return ''; - if (data.noActiveSubscription) return 'No active subscription — review Subscriptions'; - if (data.trialExpired) return 'Trial ended — review Subscriptions'; - if (data.trialEndingSoon) return 'Trial ending soon — review Subscriptions'; - if (data.seatsLow) return 'Seats running low — review Subscriptions'; - return 'Review Subscriptions'; + if (data.noActiveSubscription) return t('noActiveSubscription'); + if (data.trialExpired) return t('trialEnded'); + if (data.trialEndingSoon) return t('trialEndingSoon'); + if (data.seatsLow) return t('seatsLow'); + return t('reviewSubscriptions'); } export function DashboardAccountMenu() { + const t = useTranslations('auth'); + const tAccount = useTranslations('accountMenu'); const { user, currentOrganization, logout } = useAuth(); const [open, setOpen] = useState(false); const menuRef = useRef(null); @@ -61,7 +67,7 @@ export function DashboardAccountMenu() { }, [isOwner, currentOrganization?.id]); const showWarning = Boolean(isOwner && alert?.showWarning); - const tooltip = useMemo(() => warningTooltip(alert), [alert]); + const tooltip = useMemo(() => warningTooltip(alert, tAccount), [alert, tAccount]); const handleLogout = useCallback(() => { setOpen(false); @@ -98,7 +104,7 @@ export function DashboardAccountMenu() { className="absolute right-0 mt-2 w-72 rounded-[var(--radius-md)] border border-border bg-background-secondary/95 py-2 shadow-lg z-[200] backdrop-blur-sm" >
-

Signed in

+

{t('signedIn')}

{user?.email}

{currentOrganization?.name} @@ -113,7 +119,7 @@ export function DashboardAccountMenu() { onClick={() => setOpen(false)} > - Switch organization + {t('switchOrganization')} {isOwner && ( @@ -124,7 +130,7 @@ export function DashboardAccountMenu() { onClick={() => setOpen(false)} > - Subscriptions + {t('subscriptions')} )} @@ -135,7 +141,7 @@ export function DashboardAccountMenu() { onClick={() => setOpen(false)} > - Account + {t('account')}

@@ -147,7 +153,7 @@ export function DashboardAccountMenu() { onClick={handleLogout} > - Log out + {t('signOut')} diff --git a/frontend/src/components/ui/shared/LanguageToggle.tsx b/frontend/src/components/ui/shared/LanguageToggle.tsx new file mode 100644 index 0000000..db8a1a8 --- /dev/null +++ b/frontend/src/components/ui/shared/LanguageToggle.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Globe, Check } from 'lucide-react'; +import { useLocale, useTranslations } from 'next-intl'; +import { usePathname, useRouter } from '@/i18n/navigation'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { authApi } from '@/lib/api/auth'; +import { locales, type AppLocale } from '@/i18n/routing'; + +const LOCALE_OPTIONS: AppLocale[] = [...locales]; + +export function LanguageToggle() { + const t = useTranslations('language'); + const locale = useLocale() as AppLocale; + const router = useRouter(); + const pathname = usePathname(); + const { user, setUserLanguage } = useAuth(); + const [open, setOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + const onDocClick = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener('mousedown', onDocClick); + return () => document.removeEventListener('mousedown', onDocClick); + }, []); + + const switchLocale = useCallback( + async (next: AppLocale) => { + if (next === locale) { + setOpen(false); + return; + } + + if (user) { + setUserLanguage(next); + try { + await authApi.updateLanguage(next); + } catch { + /* keep optimistic locale in client state */ + } + } + + router.replace(pathname, { locale: next }); + setOpen(false); + }, + [locale, pathname, router, setUserLanguage, user], + ); + + return ( +
+ + + {open && ( +
    + {LOCALE_OPTIONS.map((option) => { + const selected = option === locale; + return ( +
  • + +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/frontend/src/components/ui/shared/Sidebar.tsx b/frontend/src/components/ui/shared/Sidebar.tsx index 80bc7f8..ce64b41 100644 --- a/frontend/src/components/ui/shared/Sidebar.tsx +++ b/frontend/src/components/ui/shared/Sidebar.tsx @@ -1,8 +1,8 @@ 'use client'; -import Link from 'next/link'; import { memo, useMemo } from 'react'; -import { usePathname } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +import { Link, usePathname } from '@/i18n/navigation'; import { LayoutDashboard, Users, @@ -19,55 +19,46 @@ import { organizationTypeIcon, } from '@/components/shared/organizationTypeIcon'; -const menu = [ - { name: 'Dashboard', path: '/today', icon: LayoutDashboard, read: 'TAB_TODAY_READ' as const }, - { name: 'Staff', path: '/staff', icon: UserCog, read: 'TAB_STAFF_READ' as const }, - { name: 'Patients', path: '/patients', icon: Users, read: 'TAB_PATIENTS_READ' as const }, - { name: 'Appointment', path: '/appointments', icon: Calendar, read: 'TAB_APPOINTMENTS_READ' as const }, - { name: 'Treatment', path: '/treatment', icon: FlaskConical, read: 'TAB_TREATMENT_READ' as const }, - { name: 'Billing', path: '/billing', icon: CreditCard, read: 'TAB_BILLING_READ' as const }, - { name: 'Reports', path: '/reports', icon: FileText, read: 'TAB_REPORTS_READ' as const }, -]; - function Sidebar() { + const t = useTranslations('nav'); + const tCommon = useTranslations('common'); const pathname = usePathname(); const { currentOrganization } = useAuth(); - const counterpartLabel = currentOrganization?.type === 'LAB' ? 'Clinics' : 'Labs'; - const organizationsTabIcon = organizationTypeIcon( - counterpartOrganizationType(currentOrganization?.type), + + const menu = useMemo( + () => [ + { name: t('dashboard'), path: '/today', icon: LayoutDashboard, read: 'TAB_TODAY_READ' as const }, + { name: t('staff'), path: '/staff', icon: UserCog, read: 'TAB_STAFF_READ' as const }, + { + name: currentOrganization?.type === 'LAB' ? t('clinics') : t('labs'), + path: '/organizations', + icon: organizationTypeIcon(counterpartOrganizationType(currentOrganization?.type)), + read: 'TAB_ORGANIZATIONS_READ' as const, + }, + { name: t('patients'), path: '/patients', icon: Users, read: 'TAB_PATIENTS_READ' as const }, + { name: t('appointment'), path: '/appointments', icon: Calendar, read: 'TAB_APPOINTMENTS_READ' as const }, + { name: t('treatment'), path: '/treatment', icon: FlaskConical, read: 'TAB_TREATMENT_READ' as const }, + { name: t('billing'), path: '/billing', icon: CreditCard, read: 'TAB_BILLING_READ' as const }, + { name: t('reports'), path: '/reports', icon: FileText, read: 'TAB_REPORTS_READ' as const }, + ], + [currentOrganization?.type, t], ); const visibleMenu = useMemo( - () => { - const withCounterpartTab = [ - menu[0], - menu[1], - { - name: counterpartLabel, - path: '/organizations', - icon: organizationsTabIcon, - read: 'TAB_ORGANIZATIONS_READ' as const, - }, - menu[2], - menu[3], - menu[4], - menu[5], - menu[6], - ]; - return withCounterpartTab.filter((item) => { + () => + menu.filter((item) => { if (item.path === '/appointments') { return canAccessAppointmentsSection(currentOrganization); } return canViewTab(currentOrganization, item.read); - }); - }, - [counterpartLabel, organizationsTabIcon, currentOrganization], + }), + [currentOrganization, menu], ); return (