feature: localization's first implmentation

This commit is contained in:
2026-06-20 12:37:38 +03:30
parent b314a5fa11
commit 284fbd08aa
46 changed files with 1860 additions and 643 deletions

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "language" TEXT NOT NULL DEFAULT 'en';

View File

@@ -15,6 +15,7 @@ model User {
googleId String? @unique googleId String? @unique
facebookId String? @unique facebookId String? @unique
name String name String
language String @default("en")
trialUsedAt DateTime? trialUsedAt DateTime?
memberships Membership[] memberships Membership[]

View File

@@ -9,7 +9,8 @@ import {
Res, Res,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Get Get,
Patch,
} from '@nestjs/common'; } from '@nestjs/common';
import type { Response } from 'express'; import type { Response } from 'express';
import { import {
@@ -28,6 +29,7 @@ import { RegisterDto } from './dto/register.dto';
import { CreateOrganizationDto } from './dto/create-organization.dto'; import { CreateOrganizationDto } from './dto/create-organization.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { LocalAuthGuard } from './guards/local-auth.guard'; import { LocalAuthGuard } from './guards/local-auth.guard';
import { UpdateLanguageDto } from './dto/update-language.dto';
@ApiTags('auth') @ApiTags('auth')
@Controller('auth') @Controller('auth')
@@ -149,6 +151,14 @@ export class AuthController {
return this.authService.getProfile(req.user.id); 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') @Get('subscription-alert')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth') @ApiBearerAuth('JWT-auth')

View File

@@ -15,6 +15,10 @@ import { PrismaService } from '../../../prisma/prisma.service';
import { LoginDto } from './dto/login.dto'; import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto'; import { RegisterDto } from './dto/register.dto';
import { CreateOrganizationDto } from './dto/create-organization.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'; import { JwtPayload } from './interfaces/jwt-payload.interface';
const ALL_PERMISSIONS = [ const ALL_PERMISSIONS = [
@@ -179,11 +183,7 @@ export class AuthService {
data: { data: {
accessToken, accessToken,
refreshToken, refreshToken,
user: { user: this.toPublicUser(user),
id: user.id,
email: user.email,
name: user.name,
},
organizations, 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) { async getProfile(userId: string) {
try { try {
const user = await this.prisma.user.findUnique({ const user = await this.prisma.user.findUnique({
@@ -516,11 +511,7 @@ export class AuthService {
success: true, success: true,
data: { data: {
accessToken: newAccessToken, accessToken: newAccessToken,
user: { user: this.toPublicUser(session.user),
id: session.user.id,
email: session.user.email,
name: session.user.name,
},
organizations, 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',
};
}
} }

View File

@@ -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;
}

110
frontend/messages/en.json Normal file
View File

@@ -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"
}
}

110
frontend/messages/fa.json Normal file
View File

@@ -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"
}
}

110
frontend/messages/nl.json Normal file
View File

@@ -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"
}
}

View File

@@ -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 { function publicAppHostname(): string | null {
const url = process.env.NEXT_PUBLIC_APP_URL; const url = process.env.NEXT_PUBLIC_APP_URL;
@@ -12,41 +15,29 @@ function publicAppHostname(): string | null {
const appHost = publicAppHostname(); const appHost = publicAppHostname();
/** @type {import('next').NextConfig} */ const nextConfig: NextConfig = {
const nextConfig = {
// Enable React strict mode
reactStrictMode: true, reactStrictMode: true,
// Disable x-powered-by header for security
poweredByHeader: false, poweredByHeader: false,
// Configure allowed remote image sources (hostname derived from NEXT_PUBLIC_APP_URL at build time)
images: { images: {
remotePatterns: [ remotePatterns: [
{ protocol: "http", hostname: "localhost" }, { protocol: 'http', hostname: 'localhost' },
...(appHost ...(appHost
? [ ? [
{ protocol: "http" as const, hostname: appHost }, { protocol: 'http' as const, hostname: appHost },
{ protocol: "https" as const, hostname: appHost }, { protocol: 'https' as const, hostname: appHost },
] ]
: []), : []),
{ protocol: "https", hostname: "dyolink.com" }, { protocol: 'https', hostname: 'dyolink.com' },
{ protocol: "https", hostname: "www.dyolink.com" }, { protocol: 'https', hostname: 'www.dyolink.com' },
], ],
}, },
// Environment variables that will be available at build time
env: { env: {
NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME, NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
}, },
output: 'standalone',
// Output configuration
output: 'standalone', // Reduces Docker image size
// Compress with gzip
compress: true, compress: true,
} };
module.exports = nextConfig export default withNextIntl(nextConfig);

View File

@@ -14,6 +14,7 @@
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"next": "16.1.6", "next": "16.1.6",
"next-intl": "^4.13.0",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-hook-form": "^7.71.2", "react-hook-form": "^7.71.2",
@@ -463,6 +464,36 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "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": { "node_modules/@hookform/resolvers": {
"version": "5.2.2", "version": "5.2.2",
"resolved": "https://registry.npmmirror.com/@hookform/resolvers/-/resolvers-5.2.2.tgz", "resolved": "https://registry.npmmirror.com/@hookform/resolvers/-/resolvers-5.2.2.tgz",
@@ -1248,6 +1279,313 @@
"node": ">=12.4.0" "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": { "node_modules/@rtsao/scc": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmmirror.com/@rtsao/scc/-/scc-1.1.0.tgz", "resolved": "https://registry.npmmirror.com/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1255,12 +1593,216 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@standard-schema/utils": {
"version": "0.3.0", "version": "0.3.0",
"resolved": "https://registry.npmmirror.com/@standard-schema/utils/-/utils-0.3.0.tgz", "resolved": "https://registry.npmmirror.com/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT" "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": { "node_modules/@swc/helpers": {
"version": "0.5.15", "version": "0.5.15",
"resolved": "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.15.tgz", "resolved": "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -1270,6 +1812,15 @@
"tslib": "^2.8.0" "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": { "node_modules/@tailwindcss/node": {
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.2.1.tgz", "resolved": "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.2.1.tgz",
@@ -2900,7 +3451,6 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -4065,6 +4615,21 @@
"hermes-estree": "0.25.1" "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": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz",
@@ -4117,6 +4682,16 @@
"node": ">= 0.4" "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": { "node_modules/is-array-buffer": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmmirror.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz", "resolved": "https://registry.npmmirror.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -4279,7 +4854,6 @@
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -4325,7 +4899,6 @@
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"is-extglob": "^2.1.1" "is-extglob": "^2.1.1"
@@ -5161,6 +5734,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/next": {
"version": "16.1.6", "version": "16.1.6",
"resolved": "https://registry.npmmirror.com/next/-/next-16.1.6.tgz", "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": { "node_modules/next/node_modules/postcss": {
"version": "8.4.31", "version": "8.4.31",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.4.31.tgz", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.4.31.tgz",
@@ -5242,6 +5901,12 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/node-exports-info": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmmirror.com/node-exports-info/-/node-exports-info-1.6.0.tgz", "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" "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": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmmirror.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "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" "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": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz",

View File

@@ -15,6 +15,7 @@
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"lucide-react": "^0.577.0", "lucide-react": "^0.577.0",
"next": "16.1.6", "next": "16.1.6",
"next-intl": "^4.13.0",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-hook-form": "^7.71.2", "react-hook-form": "^7.71.2",
@@ -32,4 +33,4 @@
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5"
} }
} }

View File

@@ -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<typeof loginSchema>;
// export default function LoginPage() {
// const { login, isLoading } = useAuth();
// const [error, setError] = useState<string | null>(null);
// const {
// register,
// handleSubmit,
// formState: { errors },
// } = useForm<LoginForm>({
// 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 (
// <div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
// <div className="sm:mx-auto sm:w-full sm:max-w-md">
// <Link href="/" className="flex justify-center">
// <span className="text-3xl font-bold text-primary-600">DyoLink</span>
// </Link>
// <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
// Sign in to your account
// </h2>
// <p className="mt-2 text-center text-sm text-gray-600">
// Or{' '}
// <Link href="/register" className="font-medium text-primary-600 hover:text-primary-500">
// start your free trial
// </Link>
// </p>
// </div>
// <div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
// <div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
// <form className="space-y-6" onSubmit={handleSubmit(onSubmit)}>
// <Input
// label="Email address"
// {...register('email')}
// type="email"
// placeholder="you@example.com"
// error={errors.email?.message}
// icon={<Mail className="h-5 w-5 text-gray-400" />}
// />
// <Input
// label="Password"
// {...register('password')}
// type="password"
// placeholder="••••••••"
// error={errors.password?.message}
// icon={<Lock className="h-5 w-5 text-gray-400" />}
// />
// <div className="flex items-center justify-between">
// <div className="flex items-center">
// <input
// id="remember-me"
// name="remember-me"
// type="checkbox"
// className="h-4 w-4 text-primary-600 focus:ring-primary-500border-gray-300 rounded"
// />
// <label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900">
// Remember me
// </label>
// </div>
// <div className="text-sm">
// <Link href="/forgot-password" className="font-medium text-primary-600 hover:text-primary-500">
// Forgot your password?
// </Link>
// </div>
// </div>
// {error && (
// <div className="p-3 bg-red-50 border border-red-200 rounded-lg">
// <p className="text-sm text-red-600">{error}</p>
// </div>
// )}
// <Button
// type="submit"
// variant="primary"
// isLoading={isLoading}
// fullWidth
// >
// Sign in
// </Button>
// </form>
// </div>
// </div>
// </div>
// );
// }
'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<typeof loginSchema>;
export default function LoginPage() {
const { login, isLoading, user, isAuthReady } = useAuth();
const router = useRouter();
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (isAuthReady && user) {
router.push('/today');
}
}, [user, isAuthReady, router]);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginForm>({
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 (
<div className="min-h-screen app-web-bg flex items-center justify-center">
<p className="text-text-secondary">Loading...</p>
</div>
);
}
return (
<div className="min-h-screen app-web-bg flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<Link href="/" className="flex justify-center">
<span className="text-3xl font-semibold text-text-primary">DyoLink</span>
</Link>
<h2 className="mt-6 text-center text-3xl font-semibold text-text-primary">
Sign in to your account
</h2>
<p className="mt-2 text-center text-sm text-text-secondary">
Or{' '}
<Link href="/register" className="font-medium text-primary hover:opacity-90">
start your free trial
</Link>
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="surface-card py-8 px-4 sm:px-10">
<form className="space-y-6" onSubmit={handleSubmit(onSubmit)}>
<Input
label="Email address"
{...register('email')}
type="email"
placeholder="you@example.com"
error={errors.email?.message}
icon={<Mail className="h-5 w-5 icon-flat" />}
/>
<Input
label="Password"
{...register('password')}
type="password"
placeholder="••••••••"
error={errors.password?.message}
icon={<Lock className="h-5 w-5 icon-flat" />}
/>
<div className="flex items-center justify-between">
<div className="flex items-center">
<input
id="remember-me"
name="remember-me"
type="checkbox"
className="h-4 w-4 rounded border-border bg-background-secondary text-primary focus:ring-primary/40"
/>
<label htmlFor="remember-me" className="ml-2 block text-sm text-text-secondary">
Remember me
</label>
</div>
<div className="text-sm">
<Link href="/forgot-password" className="font-medium text-primary hover:opacity-90">
Forgot your password?
</Link>
</div>
</div>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-sm text-red-600">{error}</p>
</div>
)}
<Button
type="submit"
variant="primary"
isLoading={isLoading}
fullWidth
>
Sign in
</Button>
</form>
</div>
</div>
</div>
);
}

View File

@@ -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<typeof registerSchema>;
export default function RegisterPage() {
const { registerTrial, isLoading } = useAuth();
const [step, setStep] = useState(1);
const [error, setError] = useState<string | null>(null);
const {
register,
handleSubmit,
watch,
formState: { errors },
trigger,
setValue,
} = useForm<RegisterForm>({
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 (
<div className="min-h-screen app-web-bg flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<Link href="/" className="flex justify-center">
<span className="text-3xl font-semibold text-text-primary">DyoLink</span>
</Link>
<h2 className="mt-6 text-center text-3xl font-semibold text-text-primary">
Start your 30-day free trial
</h2>
<p className="mt-2 text-center text-sm text-text-secondary">
Already have an account?{' '}
<Link href="/login" className="font-medium text-primary hover:opacity-90">
Sign in
</Link>
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="surface-card py-8 px-4 sm:px-10">
<RegistrationProgressSteps step={step} />
{/* Trial Info Banner */}
<div className="mb-6 p-4 bg-primary-soft rounded-[var(--radius-md)] border border-primary/35">
<h3 className="text-sm font-medium text-text-primary mb-2">Your trial
includes:</h3>
<ul className="text-sm text-text-secondary space-y-1">
<li className="flex items-center">
<span className="mr-2"></span> Up to 5 team members
</li>
<li className="flex items-center">
<span className="mr-2"></span> Full access to all features
</li>
<li className="flex items-center">
<span className="mr-2"></span> 30 days free, no credit card
required
</li>
</ul>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{step === 1 && (
<>
<Input
label="Full name"
{...register('name')}
placeholder="John Doe"
error={errors.name?.message}
icon={<User className="h-5 w-5 icon-flat" />}
/>
<Input
label="Email address"
{...register('email')}
type="email"
placeholder="you@example.com"
error={errors.email?.message}
icon={<Mail className="h-5 w-5 icon-flat" />}
/>
<Input
label="Password"
{...register('password')}
type="password"
placeholder="••••••••"
error={errors.password?.message}
icon={<Lock className="h-5 w-5 icon-flat" />}
/>
<Input
label="Confirm password"
{...register('confirmPassword')}
type="password"
placeholder="••••••••"
error={errors.confirmPassword?.message}
icon={<Lock className="h-5 w-5 icon-flat" />}
/>
<Button
type="button"
variant="primary"
onClick={handleNext}
fullWidth
>
Continue
</Button>
</>
)}
{step === 2 && (
<>
<OrganizationDetailsFields
register={register as never}
errors={errors as never}
organizationType={organizationType}
setValue={setValue as never}
/>
{error && (
<div className="p-3 bg-red-950/30 border border-red-600/40 rounded-[var(--radius-md)]">
<p className="text-sm text-red-600">{error}</p>
</div>
)}
<div className="flex gap-3">
<Button
type="button"
variant="outline"
onClick={() => setStep(1)}
>
Back
</Button>
<Button
type="submit"
variant="primary"
isLoading={isLoading}
fullWidth
>
Start my free trial
</Button>
</div>
</>
)}
</form>
<p className="mt-6 text-xs text-center text-text-muted">
By signing up, you agree to our{' '}
<Link href="/terms" className="text-primary hover:opacity-90">
Terms of Service
</Link>{' '}
and{' '}
<Link href="/privacy" className="text-primary hover:opacity-90">
Privacy Policy
</Link>
</p>
</div>
</div>
</div>
);
}

View File

@@ -1,10 +1,11 @@
'use client'; 'use client';
import { memo, useEffect } from 'react'; 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 { useAuth } from '@/lib/hooks/useAuth';
import Sidebar from '@/components/ui/shared/Sidebar'; 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 { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu';
import { import {
canAccessAppointmentsSection, canAccessAppointmentsSection,
@@ -14,11 +15,11 @@ import {
} from '@/components/shared/permissions'; } from '@/components/shared/permissions';
export default function DashboardLayout({ children }: { children: React.ReactNode }) { export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const t = useTranslations('common');
const { user, currentOrganization, isAuthReady } = useAuth(); const { user, currentOrganization, isAuthReady } = useAuth();
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
// ✅ AUTH GUARD (runs once per navigation group)
useEffect(() => { useEffect(() => {
if (!isAuthReady) return; if (!isAuthReady) return;
@@ -44,11 +45,10 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
} }
}, [isAuthReady, user, currentOrganization, router, pathname]); }, [isAuthReady, user, currentOrganization, router, pathname]);
// ✅ LOADING ONLY FOR INITIAL LOAD
if (!isAuthReady) { if (!isAuthReady) {
return ( return (
<div className="h-screen flex items-center justify-center app-web-bg"> <div className="h-screen flex items-center justify-center app-web-bg">
Loading app... {t('loadingApp')}
</div> </div>
); );
} }
@@ -56,7 +56,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
if (!user || !currentOrganization) { if (!user || !currentOrganization) {
return ( return (
<div className="h-screen flex items-center justify-center app-web-bg"> <div className="h-screen flex items-center justify-center app-web-bg">
Loading workspace... {t('loadingWorkspace')}
</div> </div>
); );
} }
@@ -88,9 +88,9 @@ const DashboardHeader = memo(function DashboardHeader({
<h2 className="text-lg font-medium truncate min-w-0">{organizationName}</h2> <h2 className="text-lg font-medium truncate min-w-0">{organizationName}</h2>
<div className="flex items-center gap-3 shrink-0"> <div className="flex items-center gap-3 shrink-0">
<ThemeToggle /> <TopBarControls />
<DashboardAccountMenu /> <DashboardAccountMenu />
</div> </div>
</header> </header>
); );
}); });

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import Link from 'next/link'; import { Link } from '@/i18n/navigation';
export default function AccountSettingsPage() { export default function AccountSettingsPage() {
return ( return (

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import Link from 'next/link'; import { Link } from '@/i18n/navigation';
import { OrganizationSelectorContent } from '@/components/ui/organizations/OrganizationSelectorContent'; import { OrganizationSelectorContent } from '@/components/ui/organizations/OrganizationSelectorContent';
export default function DashboardOrganizationsSettingsPage() { export default function DashboardOrganizationsSettingsPage() {

View File

@@ -1,8 +1,7 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import Link from 'next/link'; import { Link, useRouter } from '@/i18n/navigation';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/hooks/useAuth'; import { useAuth } from '@/lib/hooks/useAuth';
import { authApi } from '@/lib/api/auth'; import { authApi } from '@/lib/api/auth';
import { Button } from '@/components/ui/shared/Button'; import { Button } from '@/components/ui/shared/Button';

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from '@/i18n/navigation';
import { import {
firstAccessibleDashboardPath, firstAccessibleDashboardPath,
canEditStaff, canEditStaff,

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import Link from 'next/link'; import { Link } from '@/i18n/navigation';
import { useAuth } from '@/lib/hooks/useAuth'; import { useAuth } from '@/lib/hooks/useAuth';
import { Card } from '@/components/ui/shared/Card'; import { Card } from '@/components/ui/shared/Card';

View File

@@ -2,8 +2,8 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Suspense } from 'react'; import { Suspense } from 'react';
import Link from 'next/link'; import { Link, useRouter } from '@/i18n/navigation';
import { useRouter, useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { Button } from '@/components/ui/shared/Button'; import { Button } from '@/components/ui/shared/Button';
import { Input } from '@/components/ui/shared/Input'; import { Input } from '@/components/ui/shared/Input';
import { staffApi } from '@/lib/api/staff'; import { staffApi } from '@/lib/api/staff';

View File

@@ -1,8 +1,8 @@
'use client'; 'use client';
import { Suspense, useEffect, useMemo, useState } from 'react'; import { Suspense, useEffect, useMemo, useState } from 'react';
import Link from 'next/link'; import { Link, useRouter } from '@/i18n/navigation';
import { useRouter, useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { useForm, type FieldErrors, type UseFormRegister, type UseFormSetValue } from 'react-hook-form'; import { useForm, type FieldErrors, type UseFormRegister, type UseFormSetValue } from 'react-hook-form';
import type { OrganizationDetailsFormValues } from '@/components/ui/auth/OrganizationDetailsFields'; import type { OrganizationDetailsFormValues } from '@/components/ui/auth/OrganizationDetailsFields';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';

View File

@@ -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<string | null>(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<LoginForm>({
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 (
<div className="min-h-screen app-web-bg flex items-center justify-center">
<p className="text-text-secondary">{tCommon('loading')}</p>
</div>
);
}
return (
<div className="relative min-h-screen app-web-bg flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="absolute top-4 right-4">
<TopBarControls />
</div>
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<Link href="/" className="flex justify-center">
<span className="text-3xl font-semibold text-text-primary">{tCommon('appName')}</span>
</Link>
<h2 className="mt-6 text-center text-3xl font-semibold text-text-primary">
{t('signInTitle')}
</h2>
<p className="mt-2 text-center text-sm text-text-secondary">
Or{' '}
<Link href="/register" className="font-medium text-primary hover:opacity-90">
{t('startTrialLink')}
</Link>
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="surface-card py-8 px-4 sm:px-10">
<form className="space-y-6" onSubmit={handleSubmit(onSubmit)}>
<Input
label={t('email')}
{...register('email')}
type="email"
placeholder="you@example.com"
error={errors.email?.message}
icon={<Mail className="h-5 w-5 icon-flat" />}
/>
<Input
label={t('password')}
{...register('password')}
type="password"
placeholder="••••••••"
error={errors.password?.message}
icon={<Lock className="h-5 w-5 icon-flat" />}
/>
<div className="flex items-center justify-between">
<div className="flex items-center">
<input
id="remember-me"
name="remember-me"
type="checkbox"
className="h-4 w-4 rounded border-border bg-background-secondary text-primary focus:ring-primary/40"
/>
<label htmlFor="remember-me" className="ml-2 block text-sm text-text-secondary">
{t('rememberMe')}
</label>
</div>
<div className="text-sm">
<Link href="/forgot-password" className="font-medium text-primary hover:opacity-90">
{t('forgotPassword')}
</Link>
</div>
</div>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-sm text-red-600">{error}</p>
</div>
)}
<Button type="submit" variant="primary" isLoading={isLoading} fullWidth>
{t('signIn')}
</Button>
</form>
</div>
</div>
</div>
);
}

View File

@@ -1,120 +1,112 @@
'use client'; 'use client';
import Link from 'next/link'; import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
import { useAuth } from '@/lib/hooks/useAuth'; import { useAuth } from '@/lib/hooks/useAuth';
import { Button } from '@/components/ui/shared/Button'; 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'; import { Building2, Beaker, Calendar, Shield, Clock, Users } from 'lucide-react';
export default function HomePage() { export default function HomePage() {
const t = useTranslations('landing');
const tAuth = useTranslations('auth');
const tCommon = useTranslations('common');
const { user } = useAuth(); const { user } = useAuth();
return ( return (
<div className="min-h-screen app-web-bg text-text-primary"> <div className="min-h-screen app-web-bg text-text-primary">
{/* Header */}
<header className="border-b border-border/70 bg-background-secondary/65 backdrop-blur-sm fixed top-0 w-full z-10"> <header className="border-b border-border/70 bg-background-secondary/65 backdrop-blur-sm fixed top-0 w-full z-10">
<div className="container mx-auto px-4 py-4 flex justify-between items-center"> <div className="container mx-auto px-4 py-4 flex justify-between items-center">
<div className="text-2xl font-semibold text-text-primary"> <div className="text-2xl font-semibold text-text-primary">
DyoLink {tCommon('appName')}
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<ThemeToggle /> <TopBarControls />
{user ? ( {user ? (
<Link href="/today"> <Link href="/today">
<Button variant="primary">Dashboard</Button> <Button variant="primary">{tAuth('dashboard')}</Button>
</Link> </Link>
) : ( ) : (
<> <>
<Link href="/login"> <Link href="/login">
<Button variant="outline">Login</Button> <Button variant="outline">{tAuth('login')}</Button>
</Link> </Link>
<Link href="/register"> <Link href="/register">
<Button variant="primary">Start Trial</Button> <Button variant="primary">{tAuth('startTrial')}</Button>
</Link> </Link>
</> </>
)} )}
</div> </div>
</div> </div>
</header> </header>
{/* Hero Section */}
<main className="container mx-auto px-4 pt-32 pb-20"> <main className="container mx-auto px-4 pt-32 pb-20">
<div className="max-w-4xl mx-auto text-center"> <div className="max-w-4xl mx-auto text-center">
<h1 className="text-5xl md:text-6xl font-semibold mb-6 leading-tight"> <h1 className="text-5xl md:text-6xl font-semibold mb-6 leading-tight">
Connect Dental Clinics & Labs {t('heroTitle')}
<span className="text-primary"> Seamlessly</span> <span className="text-primary"> {t('heroHighlight')}</span>
</h1> </h1>
<p className="text-lg text-text-secondary mb-8 max-w-2xl mx-auto"> <p className="text-lg text-text-secondary mb-8 max-w-2xl mx-auto">
Streamline communication between dental professionals. Start with {t('heroSubtitle')}
a 30-day free trial, no credit card required.
</p> </p>
{!user && ( {!user && (
<Link href="/register"> <Link href="/register">
<Button size="lg" variant="primary" className="px-8"> <Button size="lg" variant="primary" className="px-8">
Start Free Trial {tAuth('startFreeTrial')}
</Button> </Button>
</Link> </Link>
)} )}
</div> </div>
{/* Features */}
<div className="mt-20 grid md:grid-cols-3 gap-6"> <div className="mt-20 grid md:grid-cols-3 gap-6">
<FeatureCard <FeatureCard
icon={<Building2 className="h-6 w-6 icon-flat" />} icon={<Building2 className="h-6 w-6 icon-flat" />}
title="For Clinics" title={t('featureClinicsTitle')}
description="Manage patients, appointments, and send cases to labs instantly." description={t('featureClinicsDescription')}
/> />
<FeatureCard <FeatureCard
icon={<Beaker className="h-6 w-6 icon-flat" />} icon={<Beaker className="h-6 w-6 icon-flat" />}
title="For Labs" title={t('featureLabsTitle')}
description="Receive cases, track progress, and communicate with clinics." description={t('featureLabsDescription')}
/> />
<FeatureCard <FeatureCard
icon={<Users className="h-6 w-6 icon-flat" />} icon={<Users className="h-6 w-6 icon-flat" />}
title="Team Management" title={t('featureTeamTitle')}
description="Add up to 5 team members during trial. Scale as you grow." description={t('featureTeamDescription')}
/> />
<FeatureCard <FeatureCard
icon={<Calendar className="h-6 w-6 icon-flat" />} icon={<Calendar className="h-6 w-6 icon-flat" />}
title="30-Day Trial" title={t('featureTrialTitle')}
description="Full access to all features. No credit card required." description={t('featureTrialDescription')}
/> />
<FeatureCard <FeatureCard
icon={<Clock className="h-6 w-6 icon-flat" />} icon={<Clock className="h-6 w-6 icon-flat" />}
title="Real-time Updates" title={t('featureRealtimeTitle')}
description="Get instant notifications on case status changes." description={t('featureRealtimeDescription')}
/> />
<FeatureCard <FeatureCard
icon={<Shield className="h-6 w-6 icon-flat" />} icon={<Shield className="h-6 w-6 icon-flat" />}
title="Secure & Compliant" title={t('featureSecurityTitle')}
description="HIPAA-compliant with enterprise-grade security." description={t('featureSecurityDescription')}
/> />
</div> </div>
</main> </main>
{/* Footer */}
<footer className="border-t border-border/70 bg-background-secondary/80"> <footer className="border-t border-border/70 bg-background-secondary/80">
<div className="container mx-auto px-4 py-8 flex flex-col md:flex-row justify-between items-center text-sm text-text-secondary"> <div className="container mx-auto px-4 py-8 flex flex-col md:flex-row justify-between items-center text-sm text-text-secondary">
<div>{t('footerCopyright')}</div>
<div>© 2026 DyoLink. All rights reserved.</div>
<div className="flex gap-6 mt-4 md:mt-0"> <div className="flex gap-6 mt-4 md:mt-0">
<Link href="/terms" className="hover:text-primary transition-colors"> <Link href="/terms" className="hover:text-primary transition-colors">
Terms & Conditions {t('termsAndConditions')}
</Link> </Link>
<Link href="/privacy" className="hover:text-primary transition-colors"> <Link href="/privacy" className="hover:text-primary transition-colors">
Privacy Policy {tAuth('privacyPolicy')}
</Link> </Link>
</div> </div>
</div> </div>
</footer> </footer>
</div> </div>
@@ -132,19 +124,9 @@ function FeatureCard({
}) { }) {
return ( return (
<div className="surface-card p-5 transition-all hover:border-primary/70 hover:shadow-[0_0_20px_rgba(0,194,255,0.12)]"> <div className="surface-card p-5 transition-all hover:border-primary/70 hover:shadow-[0_0_20px_rgba(0,194,255,0.12)]">
<div className="text-primary mb-4">{icon}</div>
<div className="text-primary mb-4"> <h3 className="text-base font-medium text-text-primary mb-2">{title}</h3>
{icon} <p className="text-sm text-text-secondary">{description}</p>
</div>
<h3 className="text-base font-medium text-text-primary mb-2">
{title}
</h3>
<p className="text-sm text-text-secondary">
{description}
</p>
</div> </div>
); );
} }

View File

@@ -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<string | null>(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<RegisterForm>({
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 (
<div className="relative min-h-screen app-web-bg flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="absolute top-4 right-4">
<TopBarControls />
</div>
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<Link href="/" className="flex justify-center">
<span className="text-3xl font-semibold text-text-primary">{tCommon('appName')}</span>
</Link>
<h2 className="mt-6 text-center text-3xl font-semibold text-text-primary">
{t('registerTitle')}
</h2>
<p className="mt-2 text-center text-sm text-text-secondary">
{t('registerPrompt')}{' '}
<Link href="/login" className="font-medium text-primary hover:opacity-90">
{t('signInLink')}
</Link>
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="surface-card py-8 px-4 sm:px-10">
<RegistrationProgressSteps step={step} />
<div className="mb-6 p-4 bg-primary-soft rounded-[var(--radius-md)] border border-primary/35">
<h3 className="text-sm font-medium text-text-primary mb-2">{t('trialIncludes')}</h3>
<ul className="text-sm text-text-secondary space-y-1">
<li className="flex items-center">
<span className="mr-2"></span> {t('trialTeamMembers')}
</li>
<li className="flex items-center">
<span className="mr-2"></span> {t('trialFullAccess')}
</li>
<li className="flex items-center">
<span className="mr-2"></span> {t('trialNoCard')}
</li>
</ul>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{step === 1 && (
<>
<Input
label={t('fullName')}
{...register('name')}
placeholder="John Doe"
error={errors.name?.message}
icon={<User className="h-5 w-5 icon-flat" />}
/>
<Input
label={t('email')}
{...register('email')}
type="email"
placeholder="you@example.com"
error={errors.email?.message}
icon={<Mail className="h-5 w-5 icon-flat" />}
/>
<Input
label={t('password')}
{...register('password')}
type="password"
placeholder="••••••••"
error={errors.password?.message}
icon={<Lock className="h-5 w-5 icon-flat" />}
/>
<Input
label={t('confirmPassword')}
{...register('confirmPassword')}
type="password"
placeholder="••••••••"
error={errors.confirmPassword?.message}
icon={<Lock className="h-5 w-5 icon-flat" />}
/>
<Button type="button" variant="primary" onClick={handleNext} fullWidth>
{tCommon('continue')}
</Button>
</>
)}
{step === 2 && (
<>
<OrganizationDetailsFields
register={register as never}
errors={errors as never}
organizationType={organizationType}
setValue={setValue as never}
/>
{error && (
<div className="p-3 bg-red-950/30 border border-red-600/40 rounded-[var(--radius-md)]">
<p className="text-sm text-red-600">{error}</p>
</div>
)}
<div className="flex gap-3">
<Button type="button" variant="outline" onClick={() => setStep(1)}>
{tCommon('back')}
</Button>
<Button type="submit" variant="primary" isLoading={isLoading} fullWidth>
{t('startMyFreeTrial')}
</Button>
</div>
</>
)}
</form>
<p className="mt-6 text-xs text-center text-text-muted">
By signing up, you agree to our{' '}
<Link href="/terms" className="text-primary hover:opacity-90">
{t('termsOfService')}
</Link>{' '}
and{' '}
<Link href="/privacy" className="text-primary hover:opacity-90">
{t('privacyPolicy')}
</Link>
</p>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<html lang={localeHtmlLang(locale)} dir="ltr" suppressHydrationWarning>
<body>
<Script id="theme-init" strategy="beforeInteractive">
{themeInit}
</Script>
<NextIntlClientProvider messages={messages}>
<AuthProvider>
<LocaleSync />
{children}
</AuthProvider>
</NextIntlClientProvider>
</body>
</html>
);
}

View File

@@ -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({ export default function RootLayout({
children, children,
}: { }: {
children: React.ReactNode; 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;
}
return (
<html lang="en" suppressHydrationWarning>
<body>
<Script id="theme-init" strategy="beforeInteractive">
{themeInit}
</Script>
<AuthProvider>
{children}
</AuthProvider>
</body>
</html>
);
}

View File

@@ -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;
}

View File

@@ -1,7 +1,8 @@
'use client'; 'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Link from 'next/link'; import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
import { import {
Settings, Settings,
AlertTriangle, AlertTriangle,
@@ -15,16 +16,21 @@ import { useAuth } from '@/lib/hooks/useAuth';
import { authApi } from '@/lib/api/auth'; import { authApi } from '@/lib/api/auth';
import type { SubscriptionAlertData } from '@/types/subscription'; import type { SubscriptionAlertData } from '@/types/subscription';
function warningTooltip(data: SubscriptionAlertData | null): string { function warningTooltip(
data: SubscriptionAlertData | null,
t: ReturnType<typeof useTranslations<'accountMenu'>>,
): string {
if (!data?.showWarning) return ''; if (!data?.showWarning) return '';
if (data.noActiveSubscription) return 'No active subscription — review Subscriptions'; if (data.noActiveSubscription) return t('noActiveSubscription');
if (data.trialExpired) return 'Trial ended — review Subscriptions'; if (data.trialExpired) return t('trialEnded');
if (data.trialEndingSoon) return 'Trial ending soon — review Subscriptions'; if (data.trialEndingSoon) return t('trialEndingSoon');
if (data.seatsLow) return 'Seats running low — review Subscriptions'; if (data.seatsLow) return t('seatsLow');
return 'Review Subscriptions'; return t('reviewSubscriptions');
} }
export function DashboardAccountMenu() { export function DashboardAccountMenu() {
const t = useTranslations('auth');
const tAccount = useTranslations('accountMenu');
const { user, currentOrganization, logout } = useAuth(); const { user, currentOrganization, logout } = useAuth();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
@@ -61,7 +67,7 @@ export function DashboardAccountMenu() {
}, [isOwner, currentOrganization?.id]); }, [isOwner, currentOrganization?.id]);
const showWarning = Boolean(isOwner && alert?.showWarning); const showWarning = Boolean(isOwner && alert?.showWarning);
const tooltip = useMemo(() => warningTooltip(alert), [alert]); const tooltip = useMemo(() => warningTooltip(alert, tAccount), [alert, tAccount]);
const handleLogout = useCallback(() => { const handleLogout = useCallback(() => {
setOpen(false); 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" 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"
> >
<div className="px-3 py-2 border-b border-border/60"> <div className="px-3 py-2 border-b border-border/60">
<p className="text-xs text-text-muted">Signed in</p> <p className="text-xs text-text-muted">{t('signedIn')}</p>
<p className="text-sm font-medium truncate">{user?.email}</p> <p className="text-sm font-medium truncate">{user?.email}</p>
<p className="text-xs text-text-secondary mt-1 truncate"> <p className="text-xs text-text-secondary mt-1 truncate">
{currentOrganization?.name} {currentOrganization?.name}
@@ -113,7 +119,7 @@ export function DashboardAccountMenu() {
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
> >
<Building2 className="h-4 w-4 icon-flat shrink-0" /> <Building2 className="h-4 w-4 icon-flat shrink-0" />
Switch organization {t('switchOrganization')}
</Link> </Link>
{isOwner && ( {isOwner && (
@@ -124,7 +130,7 @@ export function DashboardAccountMenu() {
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
> >
<CreditCard className="h-4 w-4 icon-flat shrink-0" /> <CreditCard className="h-4 w-4 icon-flat shrink-0" />
Subscriptions {t('subscriptions')}
</Link> </Link>
)} )}
@@ -135,7 +141,7 @@ export function DashboardAccountMenu() {
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
> >
<User className="h-4 w-4 icon-flat shrink-0" /> <User className="h-4 w-4 icon-flat shrink-0" />
Account {t('account')}
</Link> </Link>
</div> </div>
@@ -147,7 +153,7 @@ export function DashboardAccountMenu() {
onClick={handleLogout} onClick={handleLogout}
> >
<LogOut className="h-4 w-4 icon-flat shrink-0" /> <LogOut className="h-4 w-4 icon-flat shrink-0" />
Log out {t('signOut')}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -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<HTMLDivElement>(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 (
<div className="relative" ref={menuRef}>
<button
type="button"
onClick={() => setOpen((value) => !value)}
className="inline-flex h-9 shrink-0 items-center justify-center gap-1.5 rounded-[var(--radius-md)] border border-border/60 bg-background-secondary/80 px-2.5 text-text-primary hover:border-border-strong hover:bg-background-card/80 transition-colors"
aria-label={t('selectLanguage')}
aria-expanded={open}
aria-haspopup="listbox"
title={t('label')}
>
<Globe className="h-[18px] w-[18px] icon-flat" />
<span className="text-xs font-medium uppercase">{locale}</span>
</button>
{open && (
<ul
role="listbox"
aria-label={t('selectLanguage')}
className="absolute right-0 z-[200] mt-2 min-w-[10rem] rounded-[var(--radius-md)] border border-border bg-background-secondary/95 py-1 shadow-lg backdrop-blur-sm"
>
{LOCALE_OPTIONS.map((option) => {
const selected = option === locale;
return (
<li key={option} role="option" aria-selected={selected}>
<button
type="button"
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-sm text-text-primary hover:bg-background-card/70"
onClick={() => void switchLocale(option)}
>
<span>{t(option)}</span>
{selected && <Check className="h-4 w-4 text-primary shrink-0" />}
</button>
</li>
);
})}
</ul>
)}
</div>
);
}

View File

@@ -1,8 +1,8 @@
'use client'; 'use client';
import Link from 'next/link';
import { memo, useMemo } from 'react'; import { memo, useMemo } from 'react';
import { usePathname } from 'next/navigation'; import { useTranslations } from 'next-intl';
import { Link, usePathname } from '@/i18n/navigation';
import { import {
LayoutDashboard, LayoutDashboard,
Users, Users,
@@ -19,55 +19,46 @@ import {
organizationTypeIcon, organizationTypeIcon,
} from '@/components/shared/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() { function Sidebar() {
const t = useTranslations('nav');
const tCommon = useTranslations('common');
const pathname = usePathname(); const pathname = usePathname();
const { currentOrganization } = useAuth(); const { currentOrganization } = useAuth();
const counterpartLabel = currentOrganization?.type === 'LAB' ? 'Clinics' : 'Labs';
const organizationsTabIcon = organizationTypeIcon( const menu = useMemo(
counterpartOrganizationType(currentOrganization?.type), () => [
{ 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 visibleMenu = useMemo(
() => { () =>
const withCounterpartTab = [ menu.filter((item) => {
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) => {
if (item.path === '/appointments') { if (item.path === '/appointments') {
return canAccessAppointmentsSection(currentOrganization); return canAccessAppointmentsSection(currentOrganization);
} }
return canViewTab(currentOrganization, item.read); return canViewTab(currentOrganization, item.read);
}); }),
}, [currentOrganization, menu],
[counterpartLabel, organizationsTabIcon, currentOrganization],
); );
return ( return (
<aside className="w-64 bg-background-secondary/90 border-r border-border text-text-primary flex flex-col"> <aside className="w-64 bg-background-secondary/90 border-r border-border text-text-primary flex flex-col">
<div className="h-[71px] px-4 flex items-center"> <div className="h-[71px] px-4 flex items-center">
<h1 className="text-lg font-medium tracking-tight">DyoLink</h1> <h1 className="text-lg font-medium tracking-tight">{tCommon('appName')}</h1>
</div> </div>
<div className="mx-4 border-b border-border/70" /> <div className="mx-4 border-b border-border/70" />
@@ -78,7 +69,7 @@ function Sidebar() {
return ( return (
<Link <Link
key={item.name} key={item.path}
href={item.path} href={item.path}
prefetch prefetch
className={`flex items-center gap-3 px-3 py-2.5 rounded-[var(--radius-sm)] border transition-colors ${ className={`flex items-center gap-3 px-3 py-2.5 rounded-[var(--radius-sm)] border transition-colors ${
@@ -97,4 +88,4 @@ function Sidebar() {
); );
} }
export default memo(Sidebar); export default memo(Sidebar);

View File

@@ -2,9 +2,11 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Moon, Sun } from 'lucide-react'; import { Moon, Sun } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { applyTheme, getStoredTheme, type ThemeMode } from '@/lib/theme'; import { applyTheme, getStoredTheme, type ThemeMode } from '@/lib/theme';
export function ThemeToggle() { export function ThemeToggle() {
const t = useTranslations('theme');
const [mode, setMode] = useState<ThemeMode | null>(null); const [mode, setMode] = useState<ThemeMode | null>(null);
useEffect(() => { useEffect(() => {
@@ -34,8 +36,8 @@ export function ThemeToggle() {
type="button" type="button"
onClick={handleClick} onClick={handleClick}
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-[var(--radius-md)] border border-border/60 bg-background-secondary/80 text-text-primary hover:border-border-strong hover:bg-background-card/80 transition-colors" className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-[var(--radius-md)] border border-border/60 bg-background-secondary/80 text-text-primary hover:border-border-strong hover:bg-background-card/80 transition-colors"
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'} aria-label={isDark ? t('switchToLight') : t('switchToDark')}
title={isDark ? 'Light mode' : 'Dark mode'} title={isDark ? t('lightMode') : t('darkMode')}
> >
{isDark ? ( {isDark ? (
<Sun className="h-[18px] w-[18px] icon-flat" /> <Sun className="h-[18px] w-[18px] icon-flat" />

View File

@@ -0,0 +1,13 @@
'use client';
import { LanguageToggle } from '@/components/ui/shared/LanguageToggle';
import { ThemeToggle } from '@/components/ui/shared/ThemeToggle';
export function TopBarControls({ className = '' }: { className?: string }) {
return (
<div className={`flex items-center gap-3 shrink-0 ${className}`.trim()}>
<LanguageToggle />
<ThemeToggle />
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { createNavigation } from 'next-intl/navigation';
import { routing } from './routing';
export const { Link, redirect, usePathname, useRouter, getPathname } =
createNavigation(routing);

View File

@@ -0,0 +1,15 @@
import { getRequestConfig } from 'next-intl/server';
import { hasLocale } from 'next-intl';
import { routing } from './routing';
export default getRequestConfig(async ({ requestLocale }) => {
const requested = await requestLocale;
const locale = hasLocale(routing.locales, requested)
? requested
: routing.defaultLocale;
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default,
};
});

View File

@@ -0,0 +1,31 @@
import { defineRouting } from 'next-intl/routing';
export const locales = ['en', 'fa', 'nl'] as const;
export type AppLocale = (typeof locales)[number];
export const defaultLocale: AppLocale = 'en';
export const routing = defineRouting({
locales,
defaultLocale,
localePrefix: 'always',
});
export function localeHtmlLang(locale: string): string {
if (locale === 'fa') return 'fa-IR';
if (locale === 'nl') return 'nl';
return 'en';
}
export function isAppLocale(value: string): value is AppLocale {
return locales.includes(value as AppLocale);
}
export function stripLocaleFromPathname(pathname: string): string {
const segments = pathname.split('/').filter(Boolean);
if (segments.length > 0 && isAppLocale(segments[0])) {
const rest = segments.slice(1).join('/');
return rest ? `/${rest}` : '/';
}
return pathname || '/';
}

View File

@@ -53,6 +53,13 @@ export const authApi = {
await apiClient.post('/auth/logout'); await apiClient.post('/auth/logout');
}, },
updateLanguage: async (
language: string,
): Promise<{ success: boolean; data: { user: { language: string } } }> => {
const response = await apiClient.patch('/auth/profile/language', { language });
return response.data;
},
// Refresh token // Refresh token
refreshToken: async (refreshToken: string): Promise<{ accessToken: string }> => { refreshToken: async (refreshToken: string): Promise<{ accessToken: string }> => {
const response = await apiClient.post('/auth/refresh', { refreshToken }); const response = await apiClient.post('/auth/refresh', { refreshToken });

View File

@@ -1,16 +1,17 @@
'use client'; 'use client';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from '@/i18n/navigation';
import { authApi } from '@/lib/api/auth'; import { authApi } from '@/lib/api/auth';
import { User, Organization } from '@/types/organization'; import { User, Organization } from '@/types/organization';
import { isAppLocale } from '@/i18n/routing';
interface AuthContextType { interface AuthContextType {
user: User | null; user: User | null;
organizations: Organization[]; organizations: Organization[];
currentOrganization: Organization | null; currentOrganization: Organization | null;
isLoading: boolean; isLoading: boolean;
isAuthReady: boolean; // ✅ NEW isAuthReady: boolean;
error: string | null; error: string | null;
registerTrial: ( registerTrial: (
email: string, email: string,
@@ -29,6 +30,7 @@ interface AuthContextType {
organizationType: 'CLINIC' | 'LAB', organizationType: 'CLINIC' | 'LAB',
planName?: string, planName?: string,
) => Promise<string>; ) => Promise<string>;
setUserLanguage: (language: string) => void;
clearError: () => void; clearError: () => void;
} }
@@ -48,7 +50,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const organizations = payload?.organizations || []; const organizations = payload?.organizations || [];
if (payload?.user) { if (payload?.user) {
return { user: payload.user as User, organizations }; return {
user: {
...(payload.user as User),
language: (payload.user as User).language ?? 'en',
},
organizations,
};
} }
if (payload?.id && payload?.email && payload?.name) { if (payload?.id && payload?.email && payload?.name) {
@@ -57,6 +65,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
id: payload.id, id: payload.id,
email: payload.email, email: payload.email,
name: payload.name, name: payload.name,
language: payload.language ?? 'en',
}, },
organizations, organizations,
}; };
@@ -272,6 +281,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const clearError = useCallback(() => setError(null), []); const clearError = useCallback(() => setError(null), []);
const setUserLanguage = useCallback((language: string) => {
const normalized = isAppLocale(language) ? language : 'en';
setUser((current) => (current ? { ...current, language: normalized } : current));
}, []);
const contextValue = useMemo( const contextValue = useMemo(
() => ({ () => ({
user, user,
@@ -285,6 +299,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
logout, logout,
selectOrganization, selectOrganization,
createOrganization, createOrganization,
setUserLanguage,
clearError, clearError,
}), }),
[ [
@@ -299,6 +314,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
logout, logout,
selectOrganization, selectOrganization,
createOrganization, createOrganization,
setUserLanguage,
clearError, clearError,
], ],
); );

View File

@@ -0,0 +1,8 @@
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
export default createMiddleware(routing);
export const config = {
matcher: ['/', '/(en|fa|nl)/:path*'],
};

View File

@@ -2,6 +2,7 @@ export interface User {
id: string; id: string;
email: string; email: string;
name: string; name: string;
language?: string;
} }
export interface OrganizationPlan { export interface OrganizationPlan {