feature: localization's first implmentation
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" ADD COLUMN "language" TEXT NOT NULL DEFAULT 'en';
|
||||||
@@ -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[]
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
14
backend/src/modules/auth/dto/update-language.dto.ts
Normal file
14
backend/src/modules/auth/dto/update-language.dto.ts
Normal 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
110
frontend/messages/en.json
Normal 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
110
frontend/messages/fa.json
Normal 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
110
frontend/messages/nl.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
698
frontend/package-lock.json
generated
698
frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -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 (
|
||||||
@@ -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() {
|
||||||
@@ -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';
|
||||||
@@ -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,
|
||||||
@@ -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';
|
||||||
|
|
||||||
@@ -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';
|
||||||
@@ -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';
|
||||||
144
frontend/src/app/[locale]/(public)/login/page.tsx
Normal file
144
frontend/src/app/[locale]/(public)/login/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
221
frontend/src/app/[locale]/(public)/register/page.tsx
Normal file
221
frontend/src/app/[locale]/(public)/register/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
frontend/src/app/[locale]/layout.tsx
Normal file
56
frontend/src/app/[locale]/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
27
frontend/src/components/i18n/LocaleSync.tsx
Normal file
27
frontend/src/components/i18n/LocaleSync.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
94
frontend/src/components/ui/shared/LanguageToggle.tsx
Normal file
94
frontend/src/components/ui/shared/LanguageToggle.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
13
frontend/src/components/ui/shared/TopBarControls.tsx
Normal file
13
frontend/src/components/ui/shared/TopBarControls.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
frontend/src/i18n/navigation.ts
Normal file
5
frontend/src/i18n/navigation.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { createNavigation } from 'next-intl/navigation';
|
||||||
|
import { routing } from './routing';
|
||||||
|
|
||||||
|
export const { Link, redirect, usePathname, useRouter, getPathname } =
|
||||||
|
createNavigation(routing);
|
||||||
15
frontend/src/i18n/request.ts
Normal file
15
frontend/src/i18n/request.ts
Normal 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,
|
||||||
|
};
|
||||||
|
});
|
||||||
31
frontend/src/i18n/routing.ts
Normal file
31
frontend/src/i18n/routing.ts
Normal 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 || '/';
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
8
frontend/src/middleware.ts
Normal file
8
frontend/src/middleware.ts
Normal 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*'],
|
||||||
|
};
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user