diff --git a/frontend/messages/en.json b/frontend/messages/en.json index e0a269b..f58cb41 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -7,7 +7,30 @@ "continue": "Continue", "back": "Back", "save": "Save", - "cancel": "Cancel" + "cancel": "Cancel", + "delete": "Delete", + "edit": "Edit", + "next": "Next", + "dismiss": "Dismiss", + "or": "Or", + "and": "and", + "close": "Close", + "redirecting": "Redirecting…", + "readOnlyAccess": "Read-only access for this organization.", + "errorGeneric": "Something went wrong", + "loadingEllipsis": "Loading...", + "search": "Search", + "action": "Action", + "status": "Status", + "name": "Name", + "email": "Email", + "date": "Date", + "organization": "Organization", + "backToApp": "← Back to app", + "copied": "Copied", + "copyLink": "Copy link", + "none": "None", + "preview": "Preview" }, "language": { "label": "Language", @@ -67,7 +90,47 @@ "signedIn": "Signed in", "switchOrganization": "Switch organization", "subscriptions": "Subscriptions", - "account": "Account" + "account": "Account", + "emailPlaceholder": "you@example.com", + "passwordPlaceholder": "••••••••", + "namePlaceholder": "John Doe", + "termsIntro": "By signing up, you agree to our", + "errorRegistrationFailed": "Registration failed", + "errorLoginFailed": "Login failed", + "errorCreateOrganization": "Failed to create organization", + "acceptInviteTitle": "Accept invitation", + "loadingInvitation": "Loading invitation...", + "invalidInvitationLink": "Invalid invitation link", + "invitationAlreadyAccepted": "This invitation is already accepted. You can log in now.", + "errorLoadInvitation": "Could not load invitation", + "organizationLabel": "Organization:", + "emailLabel": "Email:", + "nameRequired": "Name is required", + "passwordMinLength8": "Password must be at least 8 characters", + "passwordsDoNotMatch": "Passwords do not match", + "labelName": "Name", + "labelCreatePassword": "Create password", + "labelConfirmPassword": "Confirm password", + "activateAccount": "Activate account", + "invitationAcceptedRedirect": "Invitation Accepted. Redirecting to login...", + "errorAcceptInvitation": "Could not accept invitation", + "alreadyHaveAccess": "Already have access?", + "goToLogin": "Go to login", + "acceptOrganizationTitle": "Accept organization invitation", + "alreadyHaveAccount": "Already have an account?", + "invitedBy": "Invited by:", + "ownerEmail": "Owner email", + "activateOrganization": "Activate organization", + "organizationAcceptedRedirect": "Invitation accepted. Redirecting to login...", + "stepAccount": "Account", + "stepOrganization": "Organization", + "organizationName": "Organization name", + "organizationNamePlaceholder": "Sunshine Dental Clinic", + "organizationEmail": "Organization email", + "organizationEmailPlaceholder": "contact@sunshineclinic.com", + "organizationType": "Organization type", + "dentalClinic": "Dental Clinic", + "dentalLab": "Dental Lab" }, "landing": { "heroTitle": "Connect Dental Clinics & Labs", @@ -106,5 +169,398 @@ "organizationEmailInvalid": "Please enter a valid organization email", "organizationTypeRequired": "Please select organization type", "passwordsDoNotMatch": "Passwords don't match" + }, + "today": { + "welcomeBack": "Welcome back!!", + "noSubscriptionNotice": "This organization does not have an active subscription yet.", + "choosePlanLink": "Choose a plan", + "noSubscriptionCta": "to start the purchase process.", + "cardTodaysAppointments": "Today's Appointments", + "cardActivePatients": "Active Patients", + "cardNewLabCase": "New Lab Case", + "cardTodayInvoices": "Today invoices" + }, + "staff": { + "redirecting": "Redirecting…", + "title": "Staff Management", + "subtitle": "Invite teammates, set tab access, and stay within your plan seat limit.", + "inviteMember": "Invite member", + "seatsLabel": "Seats:", + "unlimitedPlan": "(unlimited plan)", + "seatLimitReached": "Plan seat limit reached for this organization.", + "noActivePlan": "No active plan selected for this organization. Choose a subscription plan to invite members.", + "invitedPending": "Invitation is pending until they open the link, set a password, and log in.", + "invitedAccepted": "Invitation was accepted immediately.", + "inviteLinkHeading": "Invite link", + "shareLinkHint": "Share this link manually via SMS or email. A new link is generated if the previous one expired or was lost.", + "loadingTeam": "Loading team…", + "tableName": "Name", + "tableEmail": "Email", + "tableRole": "Role", + "tableStatus": "Status", + "tableAccess": "Access", + "tableAction": "Action", + "roleOwner": "Owner", + "roleStaff": "Staff", + "statusActive": "Active", + "statusPending": "Pending", + "statusDisabled": "Disabled", + "statusExpired": "Expired", + "allFeatures": "All features", + "inviteModalTitle": "Invite team member", + "stepOf": "Step {step} of 2", + "permissionView": "View", + "permissionEdit": "Edit", + "labelEmail": "Email", + "labelDisplayName": "Display name", + "tabAccess": "Tab access", + "sendInvite": "Send invite", + "skipForNow": "Skip for now", + "enableModalTitle": "Enable team member", + "enableConfirm": "Enable {name} ({email})?", + "enableBullet1": "They can sign in to this organization again with their existing account.", + "enableBullet2": "No new invitation is sent and no data was removed while they were disabled.", + "enableBullet3": "Enabling uses one seat on your plan.", + "noSeatsAvailable": "No seats are available. Disable another member or upgrade your plan before enabling this person.", + "enableMemberButton": "Enable member", + "disableModalTitle": "Disable team member", + "disableConfirm": "Disable {name} ({email})?", + "disableBullet1": "They will not be able to sign in to this organization.", + "disableBullet2": "No data will be removed.", + "disableBullet3": "Disabling frees one seat on your plan so you can invite someone else.", + "disableMemberButton": "Disable member", + "editModalTitle": "Edit member", + "loadingWorkingHours": "Loading working hours…", + "errorLoadStaff": "Failed to load staff.", + "errorCopyInvite": "Could not copy invitation link.", + "errorSendInvite": "Failed to send invitation.", + "errorLoadWorkingHours": "Failed to load working hours.", + "successMemberUpdated": "Member updated.", + "errorUpdateMember": "Failed to update member.", + "errorDeleteNotImplemented": "Delete is not implemented yet.", + "successMemberDisabled": "{name} was disabled. A seat is now available.", + "errorDisableMember": "Failed to disable member.", + "successMemberEnabled": "{name} was enabled and can sign in again.", + "errorEnableMember": "Failed to enable member.", + "successInvited": "{name} ({email}) was invited.", + "copyInviteLink": "Copy invitation link", + "copyInviteLinkTitle": "Copy invitation link (generates a new link if needed)", + "enableMemberAria": "Enable member", + "enableMemberTitle": "Enable member (uses a seat)", + "disableMemberAria": "Disable member", + "disableMemberTitle": "Disable member (frees a seat)", + "editMemberAria": "Edit member", + "deleteMemberAria": "Delete member", + "deleteMemberTitle": "Delete member (not implemented)", + "features": { + "featureToday": "Today", + "featureStaff": "Staff", + "featureOrganizations": "Organizations", + "featureClinics": "Clinics", + "featureLabs": "Labs", + "featurePatients": "Patients", + "featureAppointment": "Appointment", + "featureTreatment": "Treatment", + "featureBilling": "Billing", + "featureReports": "Reports", + "noTabAccess": "No tab access", + "readOnlySuffix": "(Read only)" + }, + "workingHours": { + "recommendedTitle": "Working hours recommended", + "recommendedBody": "Staff with treatment edit access appear as provider columns in Appointments. Set their weekly hours so the schedule grid shows the right bookable times.", + "intro": "Set weekly working hours for this provider. The appointments grid uses these hours to show bookable time slots.", + "workingDay": "Working day", + "start": "Start", + "end": "End", + "removeShift": "Remove shift", + "addShift": "Add shift", + "autoRepeatWeekly": "Repeat these hours at the start of each week (copy forward on Monday)", + "weekdayMon": "Mon", + "weekdayTue": "Tue", + "weekdayWed": "Wed", + "weekdayThu": "Thu", + "weekdayFri": "Fri", + "weekdaySat": "Sat", + "weekdaySun": "Sun", + "validationNeedsShift": "{day} needs at least one shift or should be marked off.", + "validationEndAfterStart": "{day} shift end time must be after start time.", + "validationOverlap": "{day} shifts cannot overlap." + } + }, + "patients": { + "title": "Patients", + "newPatient": "New Patient", + "errorLoadPatients": "Failed to load patients.", + "successPatientSaved": "Patient {firstName} {lastName} was saved successfully.", + "errorSavePatient": "Failed to save patient.", + "firstName": "First name", + "lastName": "Last name", + "phone": "Phone", + "savePatient": "Save Patient", + "dialogTitle": "New patient", + "searchPlaceholder": "Search patients by name, phone, email", + "loadingPatients": "Loading patients...", + "noResults": "No patients found for this search.", + "noContact": "No contact", + "selectPatient": "Select a patient to view details.", + "phoneLabel": "Phone:", + "emailLabel": "Email:", + "statusLabel": "Status:", + "statusActive": "Active", + "statusInactive": "Inactive", + "emptyValue": "-" + }, + "appointments": { + "title": "Appointments", + "subtitle": "Search a patient, pick a date, then click a time slot under a provider to book.", + "loadingSchedule": "Loading schedule…", + "infoPastViewOnly": "Past appointments are view-only.", + "infoSelectPatient": "Select a patient before booking.", + "errorOutsideHours": "This appointment falls outside the provider's current working hours and cannot be edited.", + "successUpdated": "Appointment updated.", + "successSaved": "Appointment saved.", + "errorUpdate": "Could not update appointment.", + "errorSave": "Could not save appointment.", + "confirmRemove": "Remove this appointment?", + "successRemoved": "Appointment removed.", + "errorDelete": "Could not delete appointment.", + "errorLoadSchedule": "Failed to load schedule.", + "successPatientSaved": "Patient {firstName} {lastName} was saved.", + "searchPlaceholder": "Search existing patients", + "searching": "Searching…", + "searchHint": "Type to search patients by name, phone, or email.", + "noPermissionAdd": "You do not have permission to add patients.", + "editTitle": "Edit appointment", + "newTitle": "New appointment", + "providerLabel": "Provider:", + "patientLabel": "Patient", + "startLabel": "Start", + "endLabel": "End", + "purposeLabel": "Purpose", + "errorSelectPatient": "Select a patient first.", + "errorEndAfterStart": "End time must be after start time.", + "errorPastSchedule": "Cannot schedule in the past.", + "errorPastViewOnly": "Past appointments are view-only.", + "errorMissingDetails": "Missing appointment details.", + "noProviders": "No providers available. Add staff with treatment edit access to see columns here.", + "noWorkingHours": "No working hours are configured for this day. Set provider working hours in Staff management.", + "noHoursSet": "No hours set", + "offToday": "Off today", + "slotOffToday": "Provider is off today", + "slotHoursNotConfigured": "Working hours not configured", + "slotOutsideHours": "Outside working hours", + "slotCannotCreate": "You cannot create appointments", + "slotBookAt": "Book {time}", + "outsideHoursBlocked": "Outside working hours — editing blocked", + "overlappingChoose": "{count} overlapping — click to choose", + "overlapping": "{count} overlapping", + "legend": "Legend", + "overlappingTitle": "Overlapping appointments ({count})", + "purposeConsultation": "Consultation", + "purposeFilling": "Filling", + "purposeEndo": "Endo", + "purposeVisit": "Visit", + "purposeHygiene": "Hygiene" + }, + "treatment": { + "loading": "Loading…", + "noPermissionTitle": "Treatment workspace", + "noPermissionBody": "You do not have permission to view the Treatment tab for this organization.", + "title": "Treatment", + "subtitleEdit": "Document cases for your appointments, save drafts, and send work to linked organizations.", + "subtitleReadOnly": "View-only access — you can review appointments and treatment history but cannot edit.", + "pastDayNotice": "Past days are view-only. You can review appointments and history, but treatment cases cannot be added or changed.", + "selectedPatient": "Selected patient", + "purposeLabel": "Purpose:", + "loadingAppointments": "Loading appointments…", + "selectDayWithAppointment": "Select a day with at least one appointment.", + "confirmDiscard": "You have unsaved changes. Discard them and continue?", + "successDraftSaved": "Treatment draft saved.", + "errorChooseOrg": "Choose at least one active organization to send this case.", + "successCaseSent": "Case sent to selected organizations.", + "successFilesUploaded": "{count} file(s) uploaded successfully.", + "errorLoadAppointments": "Failed to load appointments.", + "errorLoadOrgs": "Failed to load linked organizations.", + "errorLoadHistory": "Failed to load treatment history.", + "errorLoadDraft": "Failed to load treatment draft.", + "errorUpload": "Failed to upload attachments.", + "errorSaveDraft": "Failed to save treatment draft.", + "errorSendCase": "Failed to send case.", + "errorCaseMustSave": "Case must be saved before sending.", + "draftTitle": "Draft · {patientName}", + "hiddenMessage": "Appointments are hidden.", + "showAppointments": "Show appointments", + "appointmentsTitle": "My appointments", + "hideAppointments": "Hide appointments", + "emptyDay": "No appointments assigned to you on this day.", + "casesTitle": "Treatment cases", + "casesSubtitle": "Each case has its own teeth, notes, attachments, and destinations for send.", + "addCase": "Add case", + "caseLabel": "Case {n}", + "comments": "Comments", + "commentsPlaceholder": "Write clinical notes for this case…", + "treatmentType": "Treatment type", + "typeConsultation": "consultation", + "typeFilling": "filling", + "typeEndo": "endo", + "typeVisit": "visit", + "typeHygiene": "hygiene", + "attachments": "Attachments", + "attachFiles": "Attach files for this treatment case", + "chooseFiles": "Choose files", + "sendToOrgs": "Send this case to linked organizations", + "searchOrgsPlaceholder": "Search active organizations...", + "recent": "Recent:", + "noOrgMatch": "No active organization matches your search.", + "sendThisCase": "Send this case", + "saveDraft": "Save treatment draft", + "unsavedChanges": "Unsaved changes", + "draftSaved": "Draft saved", + "sendSavesFirst": "Sending is per case and saves first automatically.", + "historyTitle": "Previous treatments", + "historySubtitle": "Completed treatments for this patient. Each case is listed separately.", + "loadingHistory": "Loading history…", + "historyEmpty": "No prior treatments for this patient.", + "statusLabel": "Status:", + "historyCaseLabel": "Case {n} · {type}", + "teethLabel": "Teeth:", + "teethNone": "None selected", + "reviewDetails": "Review details", + "previewTitle": "Treatment preview", + "previewDraft": "Preview current draft", + "selectAppointment": "Select an appointment to preview its draft.", + "caseCount": "{n} case(s)", + "attachmentCount": "{n} attachment(s)", + "caseSummary": "Case {n}: {type}", + "teethPrefix": "· Teeth", + "moreCases": "+ {n} more case(s)", + "previewDialogTitle": "Treatment preview", + "previewDialogSubtitle": "Review cases, attachments, and send destinations.", + "noCases": "No cases in this treatment.", + "typeLabel": "Type:", + "commentsLabel": "Comments:", + "commentsEmpty": "Comments: —", + "attachFilesShort": "Attach files", + "sendCase": "Send this case", + "sendToLinkedOrgs": "Send to linked organizations", + "noActiveOrgs": "No active linked organizations.", + "confirmSend": "Confirm send", + "toothChartTitle": "FDI tooth chart", + "toothChartHint": "Tap teeth to multi-select. Applies to the active case.", + "selectedLabel": "Selected:", + "selectedEmpty": "—", + "upperArch": "Upper arch", + "lowerArch": "Lower arch", + "toothAria": "FDI tooth {fdi}", + "toothSelectedSuffix": ", selected", + "sentToAt": "Sent to {orgName} at {datetime}", + "fallbackOrgName": "organization" + }, + "organizations": { + "loadingOrganization": "Loading organization...", + "subtitle": "Search organizations, send connection requests to existing accounts, or invitation links when they are not on DyoLink yet.", + "invitationHistory": "Invitation History", + "searchPlaceholder": "Search {counterpart} by name, email, or phone...", + "backToList": "Back to list", + "tableOrganization": "Organization", + "tableOwnerEmail": "Owner email", + "tableDate": "Date", + "tableStatus": "Status", + "tableAction": "Action", + "emptyConnections": "No connections yet. Search to send a connection request or an invitation link.", + "statusInvitationPending": "Invitation pending", + "statusConnectionPending": "Connection request pending", + "statusConnected": "Connected", + "statusDeclined": "Connection request declined", + "statusFound": "Found", + "statusToday": "Today", + "acceptRequest": "Accept connection request", + "declineRequest": "Decline connection request", + "removeConnection": "Remove connection", + "sendRequest": "Send connection request", + "noDirectoryResults": "No organization found in directory search.", + "hideInvitationFields": "Hide invitation fields", + "sendInvitationLink": "Send invitation link", + "counterpartNameLabel": "{counterpart} name", + "ownerEmailLabel": "Owner email", + "sendInvitation": "Send invitation", + "successConnectionSent": "{counterpart} connection request sent.", + "successInviteCreated": "Invitation link created for {email}", + "successLinkCopied": "Invitation link copied to clipboard.", + "successAccepted": "Connection request accepted.", + "successDeclined": "Connection request declined.", + "successRemoved": "Connection removed.", + "historyTitle": "Invitation History", + "loadingHistory": "Loading invitation history...", + "historyEmpty": "No invitations yet.", + "tableInvitationLink": "Invitation link", + "statusPending": "Invitation pending", + "statusAccepted": "Invitation accepted", + "statusRejected": "Invitation rejected", + "statusExpired": "Invitation expired", + "copyInvitationLink": "Copy invitation link", + "copyInvitationLinkTitle": "Copy invitation link (generates a new link if needed)", + "selectorTitle": "Organizations", + "selectorSubtitleWithCreate": "Select an organization to continue, or create a new one.", + "selectorSubtitleSelectOnly": "Select an organization to continue.", + "createOrganization": "Create Organization", + "createAndContinue": "Create and Continue", + "emptyCanCreate": "No organizations found. Create your first one to continue.", + "emptyAskOwner": "No organizations found. Ask an organization owner to invite you.", + "continueArrow": "Continue →", + "planLabel": "Plan: {name} • {maxUsers} users", + "counterpartClinic": "Clinic", + "counterpartLab": "Lab" + }, + "settings": { + "accountTitle": "Account", + "accountSubtitle": "Profile and security settings for your login.", + "accountPlaceholder": "Password change and profile editing will be wired here next (e.g. invite flow, reset password).", + "subscriptionsTitle": "Subscriptions", + "subscriptionsSubtitle": "Your DyoLink workspace plan and seats for {orgName}. Clinic and lab income tracking stays under the sidebar Billing tab.", + "noSubscriptionNotice": "This organization has no active subscription. Select a plan below to start the purchase process.", + "currentPlan": "Current plan", + "planPrice": "Plan price", + "seatsUsed": "Seats used", + "seatsRemaining": "Seats remaining", + "daysRemaining": "Days remaining", + "unlimited": "Unlimited", + "unlimitedSeats": "Unlimited seats", + "seatsCount": "{n} seats", + "pricePerMonth": "${price} / month", + "noActiveSubscription": "No active subscription for this organization.", + "trialEnded": "Trial period has ended. Choose a plan when checkout is available.", + "trialEndsIn": "Trial ends in {days} day(s).", + "seatsLow": "Seat usage is high for this organization.", + "choosePlanIntro": "Choose a plan to continue. Purchase integration is not active yet, so this currently prepares the selection step only.", + "planSolo": "Solo", + "planSmall": "Small", + "planMedium": "Medium", + "planLarge": "Large", + "planEnterprise": "Enterprise", + "startPurchase": "Start purchase process", + "purchaseNotice": "Purchase flow will be enabled soon. {plan} is selected and ready for checkout setup." + }, + "schedule": { + "defaultLabel": "Schedule date", + "previousDay": "Previous day", + "nextDay": "Next day", + "chooseDate": "Choose schedule date", + "year": "Year", + "month": "Month", + "day": "Day", + "monthJanuary": "January", + "monthFebruary": "February", + "monthMarch": "March", + "monthApril": "April", + "monthMay": "May", + "monthJune": "June", + "monthJuly": "July", + "monthAugust": "August", + "monthSeptember": "September", + "monthOctober": "October", + "monthNovember": "November", + "monthDecember": "December" } } diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index e0a269b..e87d189 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -1,110 +1,566 @@ { "common": { "appName": "DyoLink", - "loading": "Loading...", - "loadingApp": "Loading app...", - "loadingWorkspace": "Loading workspace...", - "continue": "Continue", - "back": "Back", - "save": "Save", - "cancel": "Cancel" + "loading": "در حال بارگذاری...", + "loadingApp": "در حال بارگذاری برنامه...", + "loadingWorkspace": "در حال بارگذاری فضای کاری...", + "continue": "ادامه", + "back": "بازگشت", + "save": "ذخیره", + "cancel": "لغو", + "delete": "حذف", + "edit": "ویرایش", + "next": "بعدی", + "dismiss": "رد کردن", + "or": "یا", + "and": "و", + "close": "بستن", + "redirecting": "در حال انتقال...", + "readOnlyAccess": "دسترسی فقط خواندنی برای این سازمان.", + "errorGeneric": "خطایی رخ داده است", + "loadingEllipsis": "در حال بارگذاری...", + "search": "جستجو", + "action": "عملیات", + "status": "وضعیت", + "name": "نام", + "email": "ایمیل", + "date": "تاریخ", + "organization": "سازمان", + "backToApp": "← بازگشت به برنامه", + "copied": "کپی شد", + "copyLink": "کپی لینک", + "none": "هیچکدام", + "preview": "پیش‌نمایش" }, "language": { - "label": "Language", - "selectLanguage": "Select language", - "en": "English", - "fa": "Persian", - "nl": "Dutch" + "label": "زبان", + "selectLanguage": "انتخاب زبان", + "en": "انگلیسی", + "fa": "فارسی", + "nl": "هلندی" }, "theme": { - "switchToLight": "Switch to light mode", - "switchToDark": "Switch to dark mode", - "lightMode": "Light mode", - "darkMode": "Dark mode" + "switchToLight": "تغییر به حالت روشن", + "switchToDark": "تغییر به حالت تیره", + "lightMode": "حالت روشن", + "darkMode": "حالت تیره" }, "nav": { - "dashboard": "Dashboard", - "staff": "Staff", - "patients": "Patients", - "appointment": "Appointment", - "treatment": "Treatment", - "billing": "Billing", - "reports": "Reports", - "clinics": "Clinics", - "labs": "Labs" + "dashboard": "داشبورد", + "staff": "کارکنان", + "patients": "بیماران", + "appointment": "نوبت‌ها", + "treatment": "درمان", + "billing": "صورتحساب", + "reports": "گزارش‌ها", + "clinics": "کلینیک‌ها", + "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" + "login": "ورود", + "signIn": "ورود به حساب", + "signOut": "خروج", + "register": "ثبت‌نام", + "startTrial": "شروع دوره آزمایشی", + "startFreeTrial": "شروع دوره آزمایشی رایگان", + "dashboard": "داشبورد", + "signInTitle": "به حساب کاربری خود وارد شوید", + "signInPrompt": "یا {link}", + "startTrialLink": "دوره آزمایشی رایگان خود را شروع کنید", + "registerTitle": "دوره آزمایشی رایگان ۳۰ روزه خود را آغاز کنید", + "registerPrompt": "از قبل حساب کاربری دارید؟", + "signInLink": "وارد شوید", + "email": "آدرس ایمیل", + "password": "رمز عبور", + "confirmPassword": "تأیید رمز عبور", + "fullName": "نام و نام خانوادگی", + "rememberMe": "مرا به خاطر بسپار", + "forgotPassword": "رمز عبور خود را فراموش کرده‌اید؟", + "invalidCredentials": "ایمیل یا رمز عبور نامعتبر است", + "loginFailed": "ورود ناموفق بود", + "registrationFailed": "ثبت‌نام ناموفق بود. لطفاً دوباره تلاش کنید.", + "startMyFreeTrial": "دوره آزمایشی رایگان من را شروع کن", + "trialIncludes": "دوره آزمایشی شما شامل موارد زیر است:", + "trialTeamMembers": "تا ۵ عضو تیم", + "trialFullAccess": "دسترسی کامل به تمام امکانات", + "trialNoCard": "۳۰ روز رایگان، بدون نیاز به کارت اعتباری", + "termsAgreement": "با ثبت‌نام، شما با {terms} و {privacy} ما موافقت می‌کنید", + "termsOfService": "شرایط استفاده از خدمات", + "privacyPolicy": "سیاست حفظ حریم خصوصی", + "signedIn": "وارد شده‌اید", + "switchOrganization": "تغییر سازمان", + "subscriptions": "اشتراک‌ها", + "account": "حساب کاربری", + "emailPlaceholder": "you@example.com", + "passwordPlaceholder": "••••••••", + "namePlaceholder": "John Doe", + "termsIntro": "با ثبت‌نام، شما با", + "errorRegistrationFailed": "ثبت‌نام ناموفق بود", + "errorLoginFailed": "ورود ناموفق بود", + "errorCreateOrganization": "ایجاد سازمان ناموفق بود", + "acceptInviteTitle": "پذیرش دعوتنامه", + "loadingInvitation": "در حال بارگذاری دعوتنامه...", + "invalidInvitationLink": "لینک دعوتنامه نامعتبر است", + "invitationAlreadyAccepted": "این دعوتنامه قبلاً پذیرفته شده است. اکنون می‌توانید وارد شوید.", + "errorLoadInvitation": "بارگذاری دعوتنامه امکان‌پذیر نبود", + "organizationLabel": "سازمان:", + "emailLabel": "ایمیل:", + "nameRequired": "نام الزامی است", + "passwordMinLength8": "رمز عبور باید حداقل ۸ کاراکتر باشد", + "passwordsDoNotMatch": "رمزهای عبور مطابقت ندارند", + "labelName": "نام", + "labelCreatePassword": "ایجاد رمز عبور", + "labelConfirmPassword": "تأیید رمز عبور", + "activateAccount": "فعال‌سازی حساب", + "invitationAcceptedRedirect": "دعوتنامه پذیرفته شد. در حال انتقال به صفحه ورود...", + "errorAcceptInvitation": "پذیرش دعوتنامه امکان‌پذیر نبود", + "alreadyHaveAccess": "از قبل دسترسی دارید؟", + "goToLogin": "رفتن به ورود", + "acceptOrganizationTitle": "پذیرش دعوتنامه سازمان", + "alreadyHaveAccount": "از قبل حساب کاربری دارید؟", + "invitedBy": "دعوت‌کننده:", + "ownerEmail": "ایمیل مالک", + "activateOrganization": "فعال‌سازی سازمان", + "organizationAcceptedRedirect": "دعوتنامه پذیرفته شد. در حال انتقال به صفحه ورود...", + "stepAccount": "حساب", + "stepOrganization": "سازمان", + "organizationName": "نام سازمان", + "organizationNamePlaceholder": "کلینیک دندانپزشکی خورشید", + "organizationEmail": "ایمیل سازمان", + "organizationEmailPlaceholder": "contact@sunshineclinic.com", + "organizationType": "نوع سازمان", + "dentalClinic": "کلینیک دندانپزشکی", + "dentalLab": "لابراتوار دندانپزشکی" }, "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" + "heroTitle": "اتصال کلینیک‌ها و لابراتوارهای دندانپزشکی", + "heroHighlight": "به‌صورت یکپارچه", + "heroSubtitle": "ارتباط بین متخصصان دندانپزشکی را ساده و سریع کنید. با یک دوره آزمایشی رایگان ۳۰ روزه، بدون نیاز به کارت اعتباری، شروع کنید.", + "featureClinicsTitle": "برای کلینیک‌ها", + "featureClinicsDescription": "بیماران و نوبت‌ها را مدیریت کنید و پرونده‌ها را فوراً به لابراتوارها ارسال کنید.", + "featureLabsTitle": "برای لابراتوارها", + "featureLabsDescription": "پرونده‌ها را دریافت کنید، روند پیشرفت را پیگیری کنید و با کلینیک‌ها در ارتباط باشید.", + "featureTeamTitle": "مدیریت تیم", + "featureTeamDescription": "در طول دوره آزمایشی تا ۵ عضو تیم اضافه کنید. همگام با رشد خود مقیاس‌پذیر باشید.", + "featureTrialTitle": "دوره آزمایشی ۳۰ روزه", + "featureTrialDescription": "دسترسی کامل به تمامی امکانات. بدون نیاز به کارت اعتباری.", + "featureRealtimeTitle": "به‌روزرسانی‌های لحظه‌ای", + "featureRealtimeDescription": "اعلان‌های فوری درباره تغییر وضعیت پرونده‌ها دریافت کنید.", + "featureSecurityTitle": "امن و منطبق با استانداردها", + "featureSecurityDescription": "منطبق با HIPAA و دارای امنیت در سطح سازمانی.", + "footerCopyright": "© ۲۰۲۶ DyoLink. تمامی حقوق محفوظ است.", + "termsAndConditions": "شرایط و ضوابط" }, "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" + "noActiveSubscription": "اشتراک فعالی وجود ندارد — اشتراک‌ها را بررسی کنید", + "trialEnded": "دوره آزمایشی به پایان رسیده است — اشتراک‌ها را بررسی کنید", + "trialEndingSoon": "دوره آزمایشی به‌زودی به پایان می‌رسد — اشتراک‌ها را بررسی کنید", + "seatsLow": "ظرفیت کاربران رو به اتمام است — اشتراک‌ها را بررسی کنید", + "reviewSubscriptions": "بررسی اشتراک‌ها" }, "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" + "emailInvalid": "لطفاً یک آدرس ایمیل معتبر وارد کنید", + "passwordRequired": "رمز عبور الزامی است", + "nameMinLength": "نام باید حداقل ۲ کاراکتر باشد", + "passwordMinLength": "رمز عبور باید حداقل ۸ کاراکتر باشد", + "passwordUppercase": "رمز عبور باید حداقل شامل یک حرف بزرگ باشد", + "passwordNumber": "رمز عبور باید حداقل شامل یک عدد باشد", + "organizationNameMinLength": "نام سازمان باید حداقل ۲ کاراکتر باشد", + "organizationEmailInvalid": "لطفاً یک ایمیل سازمانی معتبر وارد کنید", + "organizationTypeRequired": "لطفاً نوع سازمان را انتخاب کنید", + "passwordsDoNotMatch": "رمزهای عبور مطابقت ندارند" + }, + "today": { + "welcomeBack": "خوش آمدید!!", + "noSubscriptionNotice": "این سازمان هنوز اشتراک فعالی ندارد.", + "choosePlanLink": "انتخاب طرح", + "noSubscriptionCta": "برای شروع فرآیند خرید.", + "cardTodaysAppointments": "نوبت‌های امروز", + "cardActivePatients": "بیماران فعال", + "cardNewLabCase": "پرونده جدید لابراتوار", + "cardTodayInvoices": "صورتحساب‌های امروز" + }, + "staff": { + "redirecting": "در حال انتقال...", + "title": "مدیریت کارکنان", + "subtitle": "از همکاران خود دعوت کنید، دسترسی به برگه‌ها را تنظیم کنید و در محدوده طرح خود باقی بمانید.", + "inviteMember": "دعوت از عضو", + "seatsLabel": "مجوزها:", + "unlimitedPlan": "(طرح نامحدود)", + "seatLimitReached": "ظرفیت مجوزهای طرح برای این سازمان تکمیل شده است.", + "noActivePlan": "هیچ طرح فعالی برای این سازمان انتخاب نشده است. برای دعوت از اعضا، یک طرح اشتراک انتخاب کنید.", + "invitedPending": "دعوتنامه تا زمانی که لینک را باز کنند، رمز عبور تنظیم کنند و وارد شوند، در حالت انتظار است.", + "invitedAccepted": "دعوتنامه بلافاصله پذیرفته شد.", + "inviteLinkHeading": "لینک دعوت", + "shareLinkHint": "این لینک را به صورت دستی از طریق پیامک یا ایمیل به اشتراک بگذارید. در صورت منقضی شدن یا گم شدن لینک قبلی، لینک جدیدی تولید می‌شود.", + "loadingTeam": "در حال بارگذاری تیم...", + "tableName": "نام", + "tableEmail": "ایمیل", + "tableRole": "نقش", + "tableStatus": "وضعیت", + "tableAccess": "دسترسی", + "tableAction": "عملیات", + "roleOwner": "مالک", + "roleStaff": "کارمند", + "statusActive": "فعال", + "statusPending": "در انتظار", + "statusDisabled": "غیرفعال", + "statusExpired": "منقضی شده", + "allFeatures": "همه امکانات", + "inviteModalTitle": "دعوت از عضو تیم", + "stepOf": "مرحله {step} از ۲", + "permissionView": "نمایش", + "permissionEdit": "ویرایش", + "labelEmail": "ایمیل", + "labelDisplayName": "نام نمایشی", + "tabAccess": "دسترسی به برگه‌ها", + "sendInvite": "ارسال دعوتنامه", + "skipForNow": "فعلاً رد کردن", + "enableModalTitle": "فعال‌سازی عضو تیم", + "enableConfirm": "{name} ({email}) فعال شود؟", + "enableBullet1": "آنها می‌توانند دوباره با حساب موجود خود وارد این سازمان شوند.", + "enableBullet2": "هیچ دعوتنامه جدیدی ارسال نمی‌شود و در زمان غیرفعال بودن هیچ داده‌ای حذف نشده است.", + "enableBullet3": "فعال‌سازی از یک مجوز در طرح شما استفاده می‌کند.", + "noSeatsAvailable": "هیچ مجوزی در دسترس نیست. قبل از فعال‌سازی این شخص، عضو دیگری را غیرفعال کنید یا طرح خود را ارتقا دهید.", + "enableMemberButton": "فعال‌سازی عضو", + "disableModalTitle": "غیرفعال‌سازی عضو تیم", + "disableConfirm": "{name} ({email}) غیرفعال شود؟", + "disableBullet1": "آنها نمی‌توانند وارد این سازمان شوند.", + "disableBullet2": "هیچ داده‌ای حذف نمی‌شود.", + "disableBullet3": "غیرفعال‌سازی یک مجوز در طرح شما را آزاد می‌کند تا بتوانید شخص دیگری را دعوت کنید.", + "disableMemberButton": "غیرفعال‌سازی عضو", + "editModalTitle": "ویرایش عضو", + "loadingWorkingHours": "در حال بارگذاری ساعات کاری...", + "errorLoadStaff": "بارگذاری کارکنان ناموفق بود.", + "errorCopyInvite": "کپی لینک دعوتنامه امکان‌پذیر نبود.", + "errorSendInvite": "ارسال دعوتنامه ناموفق بود.", + "errorLoadWorkingHours": "بارگذاری ساعات کاری ناموفق بود.", + "successMemberUpdated": "عضو به‌روزرسانی شد.", + "errorUpdateMember": "به‌روزرسانی عضو ناموفق بود.", + "errorDeleteNotImplemented": "حذف هنوز پیاده‌سازی نشده است.", + "successMemberDisabled": "{name} غیرفعال شد. یک مجوز در دسترس قرار گرفت.", + "errorDisableMember": "غیرفعال‌سازی عضو ناموفق بود.", + "successMemberEnabled": "{name} فعال شد و می‌تواند دوباره وارد شود.", + "errorEnableMember": "فعال‌سازی عضو ناموفق بود.", + "successInvited": "{name} ({email}) دعوت شد.", + "copyInviteLink": "کپی لینک دعوتنامه", + "copyInviteLinkTitle": "کپی لینک دعوتنامه (در صورت نیاز لینک جدیدی تولید می‌کند)", + "enableMemberAria": "فعال‌سازی عضو", + "enableMemberTitle": "فعال‌سازی عضو (از یک مجوز استفاده می‌کند)", + "disableMemberAria": "غیرفعال‌سازی عضو", + "disableMemberTitle": "غیرفعال‌سازی عضو (یک مجوز را آزاد می‌کند)", + "editMemberAria": "ویرایش عضو", + "deleteMemberAria": "حذف عضو", + "deleteMemberTitle": "حذف عضو (پیاده‌سازی نشده)", + "features": { + "featureToday": "امروز", + "featureStaff": "کارکنان", + "featureOrganizations": "سازمان‌ها", + "featureClinics": "کلینیک‌ها", + "featureLabs": "لابراتوارها", + "featurePatients": "بیماران", + "featureAppointment": "نوبت‌ها", + "featureTreatment": "درمان", + "featureBilling": "صورتحساب", + "featureReports": "گزارش‌ها", + "noTabAccess": "دسترسی به برگه‌ها وجود ندارد", + "readOnlySuffix": "(فقط خواندنی)" + }, + "workingHours": { + "recommendedTitle": "ساعات کاری توصیه می‌شود", + "recommendedBody": "کارکنانی که دسترسی ویرایش درمان دارند، به عنوان ستون‌های ارائه‌دهنده در نوبت‌ها ظاهر می‌شوند. ساعات هفتگی آنها را تنظیم کنید تا جدول زمان‌بندی زمان‌های قابل رزرو صحیح را نشان دهد.", + "intro": "ساعات کاری هفتگی این ارائه‌دهنده را تنظیم کنید. جدول نوبت‌ها از این ساعات برای نمایش زمان‌های قابل رزرو استفاده می‌کند.", + "workingDay": "روز کاری", + "start": "شروع", + "end": "پایان", + "removeShift": "حذف شیفت", + "addShift": "افزودن شیفت", + "autoRepeatWeekly": "این ساعات را در ابتدای هر هفته تکرار کنید (کپی به جلو در روز دوشنبه)", + "weekdayMon": "دوشنبه", + "weekdayTue": "سه‌شنبه", + "weekdayWed": "چهارشنبه", + "weekdayThu": "پنج‌شنبه", + "weekdayFri": "جمعه", + "weekdaySat": "شنبه", + "weekdaySun": "یک‌شنبه", + "validationNeedsShift": "{day} حداقل به یک شیفت نیاز دارد یا باید به عنوان تعطیل علامت‌گذاری شود.", + "validationEndAfterStart": "زمان پایان شیفت {day} باید بعد از زمان شروع باشد.", + "validationOverlap": "شیفت‌های {day} نمی‌توانند همپوشانی داشته باشند." + } + }, + "patients": { + "title": "بیماران", + "newPatient": "بیمار جدید", + "errorLoadPatients": "بارگذاری بیماران ناموفق بود.", + "successPatientSaved": "بیمار {firstName} {lastName} با موفقیت ذخیره شد.", + "errorSavePatient": "ذخیره بیمار ناموفق بود.", + "firstName": "نام", + "lastName": "نام خانوادگی", + "phone": "تلفن", + "savePatient": "ذخیره بیمار", + "dialogTitle": "بیمار جدید", + "searchPlaceholder": "جستجوی بیماران بر اساس نام، تلفن، ایمیل", + "loadingPatients": "در حال بارگذاری بیماران...", + "noResults": "هیچ بیماری برای این جستجو یافت نشد.", + "noContact": "بدون اطلاعات تماس", + "selectPatient": "برای مشاهده جزئیات، یک بیمار را انتخاب کنید.", + "phoneLabel": "تلفن:", + "emailLabel": "ایمیل:", + "statusLabel": "وضعیت:", + "statusActive": "فعال", + "statusInactive": "غیرفعال", + "emptyValue": "-" + }, + "appointments": { + "title": "نوبت‌ها", + "subtitle": "یک بیمار را جستجو کنید، تاریخ را انتخاب کنید، سپس روی یک زمان در زیر ارائه‌دهنده کلیک کنید تا رزرو کنید.", + "loadingSchedule": "در حال بارگذاری برنامه...", + "infoPastViewOnly": "نوبت‌های گذشته فقط قابل مشاهده هستند.", + "infoSelectPatient": "قبل از رزرو، یک بیمار را انتخاب کنید.", + "errorOutsideHours": "این نوبت خارج از ساعات کاری فعلی ارائه‌دهنده است و قابل ویرایش نیست.", + "successUpdated": "نوبت به‌روزرسانی شد.", + "successSaved": "نوبت ذخیره شد.", + "errorUpdate": "به‌روزرسانی نوبت امکان‌پذیر نبود.", + "errorSave": "ذخیره نوبت امکان‌پذیر نبود.", + "confirmRemove": "این نوبت حذف شود؟", + "successRemoved": "نوبت حذف شد.", + "errorDelete": "حذف نوبت امکان‌پذیر نبود.", + "errorLoadSchedule": "بارگذاری برنامه ناموفق بود.", + "successPatientSaved": "بیمار {firstName} {lastName} ذخیره شد.", + "searchPlaceholder": "جستجوی بیماران موجود", + "searching": "در حال جستجو...", + "searchHint": "برای جستجوی بیماران بر اساس نام، تلفن یا ایمیل، تایپ کنید.", + "noPermissionAdd": "شما مجوز اضافه کردن بیمار را ندارید.", + "editTitle": "ویرایش نوبت", + "newTitle": "نوبت جدید", + "providerLabel": "ارائه‌دهنده:", + "patientLabel": "بیمار", + "startLabel": "شروع", + "endLabel": "پایان", + "purposeLabel": "هدف", + "errorSelectPatient": "ابتدا یک بیمار را انتخاب کنید.", + "errorEndAfterStart": "زمان پایان باید بعد از زمان شروع باشد.", + "errorPastSchedule": "نمی‌توان در گذشته زمان‌بندی کرد.", + "errorPastViewOnly": "نوبت‌های گذشته فقط قابل مشاهده هستند.", + "errorMissingDetails": "جزئیات نوبت وجود ندارد.", + "noProviders": "هیچ ارائه‌دهنده‌ای در دسترس نیست. برای مشاهده ستون‌ها در اینجا، کارکنانی با دسترسی ویرایش درمان اضافه کنید.", + "noWorkingHours": "ساعات کاری برای این روز پیکربندی نشده است. ساعات کاری ارائه‌دهنده را در مدیریت کارکنان تنظیم کنید.", + "noHoursSet": "ساعتی تنظیم نشده", + "offToday": "امروز تعطیل است", + "slotOffToday": "ارائه‌دهنده امروز تعطیل است", + "slotHoursNotConfigured": "ساعات کاری پیکربندی نشده است", + "slotOutsideHours": "خارج از ساعات کاری", + "slotCannotCreate": "شما نمی‌توانید نوبت ایجاد کنید", + "slotBookAt": "رزرو {time}", + "outsideHoursBlocked": "خارج از ساعات کاری — ویرایش مسدود شده است", + "overlappingChoose": "{count} همپوشانی — برای انتخاب کلیک کنید", + "overlapping": "{count} همپوشانی", + "legend": "راهنما", + "overlappingTitle": "نوبت‌های همپوشانی ({count})", + "purposeConsultation": "مشاوره", + "purposeFilling": "پر کردن", + "purposeEndo": "درمان ریشه", + "purposeVisit": "ویزیت", + "purposeHygiene": "بهداشت" + }, + "treatment": { + "loading": "در حال بارگذاری...", + "noPermissionTitle": "فضای کاری درمان", + "noPermissionBody": "شما مجوز مشاهده برگه درمان برای این سازمان را ندارید.", + "title": "درمان", + "subtitleEdit": "پرونده‌های نوبت‌های خود را مستند کنید، پیش‌نویس‌ها را ذخیره کنید و کار را به سازمان‌های مرتبط ارسال کنید.", + "subtitleReadOnly": "دسترسی فقط خواندنی — می‌توانید نوبت‌ها و تاریخچه درمان را بررسی کنید اما نمی‌توانید ویرایش کنید.", + "pastDayNotice": "روزهای گذشته فقط قابل مشاهده هستند. می‌توانید نوبت‌ها و تاریخچه را بررسی کنید، اما پرونده‌های درمانی قابل اضافه یا تغییر نیستند.", + "selectedPatient": "بیمار انتخاب شده", + "purposeLabel": "هدف:", + "loadingAppointments": "در حال بارگذاری نوبت‌ها...", + "selectDayWithAppointment": "روزی را انتخاب کنید که حداقل یک نوبت داشته باشد.", + "confirmDiscard": "تغییرات ذخیره‌نشده دارید. آنها را کنار بگذارید و ادامه دهید؟", + "successDraftSaved": "پیش‌نویس درمان ذخیره شد.", + "errorChooseOrg": "حداقل یک سازمان فعال را برای ارسال این پرونده انتخاب کنید.", + "successCaseSent": "پرونده به سازمان‌های انتخاب شده ارسال شد.", + "successFilesUploaded": "{count} فایل با موفقیت بارگذاری شد.", + "errorLoadAppointments": "بارگذاری نوبت‌ها ناموفق بود.", + "errorLoadOrgs": "بارگذاری سازمان‌های مرتبط ناموفق بود.", + "errorLoadHistory": "بارگذاری تاریخچه درمان ناموفق بود.", + "errorLoadDraft": "بارگذاری پیش‌نویس درمان ناموفق بود.", + "errorUpload": "بارگذاری پیوست‌ها ناموفق بود.", + "errorSaveDraft": "ذخیره پیش‌نویس درمان ناموفق بود.", + "errorSendCase": "ارسال پرونده ناموفق بود.", + "errorCaseMustSave": "پرونده باید قبل از ارسال ذخیره شود.", + "draftTitle": "پیش‌نویس · {patientName}", + "hiddenMessage": "نوبت‌ها پنهان هستند.", + "showAppointments": "نمایش نوبت‌ها", + "appointmentsTitle": "نوبت‌های من", + "hideAppointments": "پنهان کردن نوبت‌ها", + "emptyDay": "هیچ نوبتی به شما در این روز اختصاص داده نشده است.", + "casesTitle": "پرونده‌های درمانی", + "casesSubtitle": "هر پرونده دارای دندان‌ها، یادداشت‌ها، پیوست‌ها و مقصدهای ارسال خود است.", + "addCase": "افزودن پرونده", + "caseLabel": "پرونده {n}", + "comments": "نظرات", + "commentsPlaceholder": "یادداشت‌های بالینی این پرونده را بنویسید...", + "treatmentType": "نوع درمان", + "typeConsultation": "مشاوره", + "typeFilling": "پر کردن", + "typeEndo": "درمان ریشه", + "typeVisit": "ویزیت", + "typeHygiene": "بهداشت", + "attachments": "پیوست‌ها", + "attachFiles": "فایل‌ها را برای این پرونده درمانی پیوست کنید", + "chooseFiles": "انتخاب فایل‌ها", + "sendToOrgs": "ارسال این پرونده به سازمان‌های مرتبط", + "searchOrgsPlaceholder": "جستجوی سازمان‌های فعال...", + "recent": "اخیر:", + "noOrgMatch": "هیچ سازمان فعالی با جستجوی شما مطابقت ندارد.", + "sendThisCase": "ارسال این پرونده", + "saveDraft": "ذخیره پیش‌نویس درمان", + "unsavedChanges": "تغییرات ذخیره‌نشده", + "draftSaved": "پیش‌نویس ذخیره شد", + "sendSavesFirst": "ارسال برای هر پرونده به صورت جداگانه است و ابتدا به طور خودکار ذخیره می‌کند.", + "historyTitle": "درمان‌های قبلی", + "historySubtitle": "درمان‌های تکمیل شده برای این بیمار. هر پرونده به طور جداگانه فهرست شده است.", + "loadingHistory": "در حال بارگذاری تاریخچه...", + "historyEmpty": "هیچ درمان قبلی برای این بیمار وجود ندارد.", + "statusLabel": "وضعیت:", + "historyCaseLabel": "پرونده {n} · {type}", + "teethLabel": "دندان‌ها:", + "teethNone": "هیچکدام انتخاب نشده", + "reviewDetails": "بررسی جزئیات", + "previewTitle": "پیش‌نمایش درمان", + "previewDraft": "پیش‌نمایش پیش‌نویس فعلی", + "selectAppointment": "یک نوبت را برای پیش‌نمایش پیش‌نویس آن انتخاب کنید.", + "caseCount": "{n} پرونده", + "attachmentCount": "{n} پیوست", + "caseSummary": "پرونده {n}: {type}", + "teethPrefix": "· دندان‌ها", + "moreCases": "+ {n} پرونده دیگر", + "previewDialogTitle": "پیش‌نمایش درمان", + "previewDialogSubtitle": "بررسی پرونده‌ها، پیوست‌ها و مقصدهای ارسال.", + "noCases": "هیچ پرونده‌ای در این درمان وجود ندارد.", + "typeLabel": "نوع:", + "commentsLabel": "نظرات:", + "commentsEmpty": "نظرات: —", + "attachFilesShort": "پیوست فایل‌ها", + "sendCase": "ارسال این پرونده", + "sendToLinkedOrgs": "ارسال به سازمان‌های مرتبط", + "noActiveOrgs": "هیچ سازمان مرتبط فعالی وجود ندارد.", + "confirmSend": "تأیید ارسال", + "toothChartTitle": "نمودار دندان‌ها FDI", + "toothChartHint": "برای انتخاب چندگانه روی دندان‌ها ضربه بزنید. برای پرونده فعال اعمال می‌شود.", + "selectedLabel": "انتخاب شده:", + "selectedEmpty": "—", + "upperArch": "قوس بالا", + "lowerArch": "قوس پایین", + "toothAria": "دندان FDI {fdi}", + "toothSelectedSuffix": "، انتخاب شده", + "sentToAt": "ارسال به {orgName} در {datetime}", + "fallbackOrgName": "سازمان" + }, + "organizations": { + "loadingOrganization": "در حال بارگذاری سازمان...", + "subtitle": "سازمان‌ها را جستجو کنید، درخواست اتصال به حساب‌های موجود ارسال کنید، یا اگر هنوز در DyoLink نیستند، لینک دعوت ارسال کنید.", + "invitationHistory": "تاریخچه دعوت‌نامه‌ها", + "searchPlaceholder": "جستجوی {counterpart} بر اساس نام، ایمیل یا تلفن...", + "backToList": "بازگشت به لیست", + "tableOrganization": "سازمان", + "tableOwnerEmail": "ایمیل مالک", + "tableDate": "تاریخ", + "tableStatus": "وضعیت", + "tableAction": "عملیات", + "emptyConnections": "هنوز هیچ اتصالی وجود ندارد. برای ارسال درخواست اتصال یا لینک دعوت، جستجو کنید.", + "statusInvitationPending": "دعوتنامه در انتظار", + "statusConnectionPending": "درخواست اتصال در انتظار", + "statusConnected": "متصل", + "statusDeclined": "درخواست اتصال رد شد", + "statusFound": "یافت شد", + "statusToday": "امروز", + "acceptRequest": "پذیرش درخواست اتصال", + "declineRequest": "رد درخواست اتصال", + "removeConnection": "قطع اتصال", + "sendRequest": "ارسال درخواست اتصال", + "noDirectoryResults": "هیچ سازمانی در جستجوی دایرکتوری یافت نشد.", + "hideInvitationFields": "پنهان کردن فیلدهای دعوت", + "sendInvitationLink": "ارسال لینک دعوت", + "counterpartNameLabel": "نام {counterpart}", + "ownerEmailLabel": "ایمیل مالک", + "sendInvitation": "ارسال دعوتنامه", + "successConnectionSent": "درخواست اتصال {counterpart} ارسال شد.", + "successInviteCreated": "لینک دعوت برای {email} ایجاد شد", + "successLinkCopied": "لینک دعوت در کلیپ‌بورد کپی شد.", + "successAccepted": "درخواست اتصال پذیرفته شد.", + "successDeclined": "درخواست اتصال رد شد.", + "successRemoved": "اتصال قطع شد.", + "historyTitle": "تاریخچه دعوت‌نامه‌ها", + "loadingHistory": "در حال بارگذاری تاریخچه دعوت‌نامه‌ها...", + "historyEmpty": "هنوز هیچ دعوتنامه‌ای وجود ندارد.", + "tableInvitationLink": "لینک دعوت", + "statusPending": "دعوتنامه در انتظار", + "statusAccepted": "دعوتنامه پذیرفته شد", + "statusRejected": "دعوتنامه رد شد", + "statusExpired": "دعوتنامه منقضی شد", + "copyInvitationLink": "کپی لینک دعوتنامه", + "copyInvitationLinkTitle": "کپی لینک دعوتنامه (در صورت نیاز لینک جدیدی تولید می‌کند)", + "selectorTitle": "سازمان‌ها", + "selectorSubtitleWithCreate": "یک سازمان را برای ادامه انتخاب کنید، یا یک سازمان جدید ایجاد کنید.", + "selectorSubtitleSelectOnly": "یک سازمان را برای ادامه انتخاب کنید.", + "createOrganization": "ایجاد سازمان", + "createAndContinue": "ایجاد و ادامه", + "emptyCanCreate": "هیچ سازمانی یافت نشد. اولین سازمان خود را ایجاد کنید تا ادامه دهید.", + "emptyAskOwner": "هیچ سازمانی یافت نشد. از مالک سازمان بخواهید تا شما را دعوت کند.", + "continueArrow": "ادامه →", + "planLabel": "طرح: {name} • {maxUsers} کاربر", + "counterpartClinic": "کلینیک", + "counterpartLab": "لابراتوار" + }, + "settings": { + "accountTitle": "حساب کاربری", + "accountSubtitle": "تنظیمات پروفایل و امنیت برای ورود شما.", + "accountPlaceholder": "تغییر رمز عبور و ویرایش پروفایل در مرحله بعدی در اینجا قرار می‌گیرند (مثلاً فرآیند دعوت، بازنشانی رمز عبور).", + "subscriptionsTitle": "اشتراک‌ها", + "subscriptionsSubtitle": "طرح و مجوزهای فضای کاری DyoLink شما برای {orgName}. پیگیری درآمد کلینیک و لابراتوار در برگه صورتحساب در نوار کناری قرار دارد.", + "noSubscriptionNotice": "این سازمان اشتراک فعالی ندارد. برای شروع فرآیند خرید، یک طرح زیر را انتخاب کنید.", + "currentPlan": "طرح فعلی", + "planPrice": "قیمت طرح", + "seatsUsed": "مجوزهای استفاده شده", + "seatsRemaining": "مجوزهای باقی‌مانده", + "daysRemaining": "روزهای باقی‌مانده", + "unlimited": "نامحدود", + "unlimitedSeats": "مجوزهای نامحدود", + "seatsCount": "{n} مجوز", + "pricePerMonth": "${price} / ماه", + "noActiveSubscription": "هیچ اشتراک فعالی برای این سازمان وجود ندارد.", + "trialEnded": "دوره آزمایشی به پایان رسیده است. زمانی که تسویه حساب در دسترس قرار گرفت، یک طرح انتخاب کنید.", + "trialEndsIn": "دوره آزمایشی در {days} روز به پایان می‌رسد.", + "seatsLow": "استفاده از مجوزها برای این سازمان بالا است.", + "choosePlanIntro": "یک طرح را برای ادامه انتخاب کنید. یکپارچه‌سازی خرید هنوز فعال نیست، بنابراین این مرحله فقط مرحله انتخاب را آماده می‌کند.", + "planSolo": "انفرادی", + "planSmall": "کوچک", + "planMedium": "متوسط", + "planLarge": "بزرگ", + "planEnterprise": "سازمانی", + "startPurchase": "شروع فرآیند خرید", + "purchaseNotice": "جریان خرید به زودی فعال می‌شود. {plan} انتخاب شده و برای راه‌اندازی تسویه حساب آماده است." + }, + "schedule": { + "defaultLabel": "تاریخ برنامه", + "previousDay": "روز قبل", + "nextDay": "روز بعد", + "chooseDate": "انتخاب تاریخ برنامه", + "year": "سال", + "month": "ماه", + "day": "روز", + "monthJanuary": "ژانویه", + "monthFebruary": "فوریه", + "monthMarch": "مارس", + "monthApril": "آوریل", + "monthMay": "مه", + "monthJune": "ژوئن", + "monthJuly": "ژوئیه", + "monthAugust": "اوت", + "monthSeptember": "سپتامبر", + "monthOctober": "اکتبر", + "monthNovember": "نوامبر", + "monthDecember": "دسامبر" } -} +} \ No newline at end of file diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index e0a269b..517a742 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -1,110 +1,566 @@ { "common": { "appName": "DyoLink", - "loading": "Loading...", - "loadingApp": "Loading app...", - "loadingWorkspace": "Loading workspace...", - "continue": "Continue", - "back": "Back", - "save": "Save", - "cancel": "Cancel" + "loading": "Laden...", + "loadingApp": "Applicatie laden...", + "loadingWorkspace": "Werkruimte laden...", + "continue": "Doorgaan", + "back": "Terug", + "save": "Opslaan", + "cancel": "Annuleren", + "delete": "Verwijderen", + "edit": "Bewerken", + "next": "Volgende", + "dismiss": "Afwijzen", + "or": "Of", + "and": "en", + "close": "Sluiten", + "redirecting": "Bezig met doorsturen...", + "readOnlyAccess": "Alleen-lezen toegang voor deze organisatie.", + "errorGeneric": "Er is iets misgegaan", + "loadingEllipsis": "Laden...", + "search": "Zoeken", + "action": "Actie", + "status": "Status", + "name": "Naam", + "email": "E-mail", + "date": "Datum", + "organization": "Organisatie", + "backToApp": "← Terug naar app", + "copied": "Gekopieerd", + "copyLink": "Link kopiëren", + "none": "Geen", + "preview": "Voorbeeld" }, "language": { - "label": "Language", - "selectLanguage": "Select language", - "en": "English", - "fa": "Persian", - "nl": "Dutch" + "label": "Taal", + "selectLanguage": "Selecteer taal", + "en": "Engels", + "fa": "Perzisch", + "nl": "Nederlands" }, "theme": { - "switchToLight": "Switch to light mode", - "switchToDark": "Switch to dark mode", - "lightMode": "Light mode", - "darkMode": "Dark mode" + "switchToLight": "Overschakelen naar lichte modus", + "switchToDark": "Overschakelen naar donkere modus", + "lightMode": "Lichte modus", + "darkMode": "Donkere modus" }, "nav": { "dashboard": "Dashboard", - "staff": "Staff", - "patients": "Patients", - "appointment": "Appointment", - "treatment": "Treatment", - "billing": "Billing", - "reports": "Reports", - "clinics": "Clinics", - "labs": "Labs" + "staff": "Medewerkers", + "patients": "Patiënten", + "appointment": "Afspraak", + "treatment": "Behandeling", + "billing": "Facturatie", + "reports": "Rapporten", + "clinics": "Klinieken", + "labs": "Laboratoria" }, "auth": { - "login": "Login", - "signIn": "Sign in", - "signOut": "Log out", - "register": "Register", - "startTrial": "Start Trial", - "startFreeTrial": "Start Free Trial", + "login": "Inloggen", + "signIn": "Aanmelden", + "signOut": "Uitloggen", + "register": "Registreren", + "startTrial": "Proefperiode starten", + "startFreeTrial": "Gratis proefperiode starten", "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" + "signInTitle": "Meld u aan bij uw account", + "signInPrompt": "Of {link}", + "startTrialLink": "start uw gratis proefperiode", + "registerTitle": "Start uw gratis proefperiode van 30 dagen", + "registerPrompt": "Heeft u al een account?", + "signInLink": "Aanmelden", + "email": "E-mailadres", + "password": "Wachtwoord", + "confirmPassword": "Bevestig wachtwoord", + "fullName": "Volledige naam", + "rememberMe": "Onthoud mij", + "forgotPassword": "Wachtwoord vergeten?", + "invalidCredentials": "Ongeldig e-mailadres of wachtwoord", + "loginFailed": "Inloggen mislukt", + "registrationFailed": "Registratie mislukt. Probeer het opnieuw.", + "startMyFreeTrial": "Start mijn gratis proefperiode", + "trialIncludes": "Uw proefperiode omvat:", + "trialTeamMembers": "Tot 5 teamleden", + "trialFullAccess": "Volledige toegang tot alle functies", + "trialNoCard": "30 dagen gratis, geen creditcard vereist", + "termsAgreement": "Door u aan te melden gaat u akkoord met onze {terms} en {privacy}", + "termsOfService": "Servicevoorwaarden", + "privacyPolicy": "Privacybeleid", + "signedIn": "Aangemeld", + "switchOrganization": "Organisatie wisselen", + "subscriptions": "Abonnementen", + "account": "Account", + "emailPlaceholder": "you@example.com", + "passwordPlaceholder": "••••••••", + "namePlaceholder": "John Doe", + "termsIntro": "Door u te registreren gaat u akkoord met onze", + "errorRegistrationFailed": "Registratie mislukt", + "errorLoginFailed": "Inloggen mislukt", + "errorCreateOrganization": "Organisatie aanmaken mislukt", + "acceptInviteTitle": "Uitnodiging accepteren", + "loadingInvitation": "Uitnodiging laden...", + "invalidInvitationLink": "Ongeldige uitnodigingslink", + "invitationAlreadyAccepted": "Deze uitnodiging is al geaccepteerd. U kunt nu inloggen.", + "errorLoadInvitation": "Kon uitnodiging niet laden", + "organizationLabel": "Organisatie:", + "emailLabel": "E-mail:", + "nameRequired": "Naam is verplicht", + "passwordMinLength8": "Wachtwoord moet minimaal 8 tekens bevatten", + "passwordsDoNotMatch": "Wachtwoorden komen niet overeen", + "labelName": "Naam", + "labelCreatePassword": "Wachtwoord aanmaken", + "labelConfirmPassword": "Bevestig wachtwoord", + "activateAccount": "Account activeren", + "invitationAcceptedRedirect": "Uitnodiging geaccepteerd. Doorsturen naar inloggen...", + "errorAcceptInvitation": "Kon uitnodiging niet accepteren", + "alreadyHaveAccess": "Heeft u al toegang?", + "goToLogin": "Ga naar inloggen", + "acceptOrganizationTitle": "Organisatie-uitnodiging accepteren", + "alreadyHaveAccount": "Heeft u al een account?", + "invitedBy": "Uitgenodigd door:", + "ownerEmail": "E-mail eigenaar", + "activateOrganization": "Organisatie activeren", + "organizationAcceptedRedirect": "Uitnodiging geaccepteerd. Doorsturen naar inloggen...", + "stepAccount": "Account", + "stepOrganization": "Organisatie", + "organizationName": "Organisatienaam", + "organizationNamePlaceholder": "Sunshine Tandartspraktijk", + "organizationEmail": "Organisatie e-mail", + "organizationEmailPlaceholder": "contact@sunshineclinic.com", + "organizationType": "Organisatietype", + "dentalClinic": "Tandartspraktijk", + "dentalLab": "Tandtechnisch Laboratorium" }, "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" + "heroTitle": "Verbind Tandheelkundige Klinieken & Laboratoria", + "heroHighlight": "Naadloos", + "heroSubtitle": "Stroomlijn de communicatie tussen tandheelkundige professionals. Start met een gratis proefperiode van 30 dagen, zonder creditcard.", + "featureClinicsTitle": "Voor Klinieken", + "featureClinicsDescription": "Beheer patiënten, afspraken en stuur casussen direct naar laboratoria.", + "featureLabsTitle": "Voor Laboratoria", + "featureLabsDescription": "Ontvang casussen, volg de voortgang en communiceer met klinieken.", + "featureTeamTitle": "Teambeheer", + "featureTeamDescription": "Voeg tijdens de proefperiode maximaal 5 teamleden toe. Schaal mee met uw groei.", + "featureTrialTitle": "Proefperiode van 30 dagen", + "featureTrialDescription": "Volledige toegang tot alle functies. Geen creditcard vereist.", + "featureRealtimeTitle": "Realtime Updates", + "featureRealtimeDescription": "Ontvang direct meldingen bij statuswijzigingen van casussen.", + "featureSecurityTitle": "Veilig & Conform", + "featureSecurityDescription": "HIPAA-conform met beveiliging op ondernemingsniveau.", + "footerCopyright": "© 2026 DyoLink. Alle rechten voorbehouden.", + "termsAndConditions": "Algemene Voorwaarden" }, "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" + "noActiveSubscription": "Geen actief abonnement — bekijk Abonnementen", + "trialEnded": "Proefperiode beëindigd — bekijk Abonnementen", + "trialEndingSoon": "Proefperiode eindigt binnenkort — bekijk Abonnementen", + "seatsLow": "Aantal beschikbare plaatsen raakt op — bekijk Abonnementen", + "reviewSubscriptions": "Abonnementen bekijken" }, "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" + "emailInvalid": "Voer een geldig e-mailadres in", + "passwordRequired": "Wachtwoord is verplicht", + "nameMinLength": "Naam moet minimaal 2 tekens bevatten", + "passwordMinLength": "Wachtwoord moet minimaal 8 tekens bevatten", + "passwordUppercase": "Wachtwoord moet minimaal één hoofdletter bevatten", + "passwordNumber": "Wachtwoord moet minimaal één cijfer bevatten", + "organizationNameMinLength": "Organisatienaam moet minimaal 2 tekens bevatten", + "organizationEmailInvalid": "Voer een geldig organisatie-e-mailadres in", + "organizationTypeRequired": "Selecteer een organisatietype", + "passwordsDoNotMatch": "Wachtwoorden komen niet overeen" + }, + "today": { + "welcomeBack": "Welkom terug!!", + "noSubscriptionNotice": "Deze organisatie heeft nog geen actief abonnement.", + "choosePlanLink": "Kies een abonnement", + "noSubscriptionCta": "om het aankoopproces te starten.", + "cardTodaysAppointments": "Afspraken van vandaag", + "cardActivePatients": "Actieve patiënten", + "cardNewLabCase": "Nieuwe laboratoriumcase", + "cardTodayInvoices": "Facturen van vandaag" + }, + "staff": { + "redirecting": "Bezig met doorsturen...", + "title": "Medewerkersbeheer", + "subtitle": "Nodig teamleden uit, stel tabbladtoegang in en blijf binnen het limiet van uw abonnementsplaatsen.", + "inviteMember": "Lid uitnodigen", + "seatsLabel": "Plaatsen:", + "unlimitedPlan": "(onbeperkt abonnement)", + "seatLimitReached": "Plaatslimiet van het abonnement voor deze organisatie is bereikt.", + "noActivePlan": "Geen actief abonnement geselecteerd voor deze organisatie. Kies een abonnementsplan om leden uit te nodigen.", + "invitedPending": "Uitnodiging is in afwachting totdat ze de link openen, een wachtwoord instellen en inloggen.", + "invitedAccepted": "Uitnodiging is onmiddellijk geaccepteerd.", + "inviteLinkHeading": "Uitnodigingslink", + "shareLinkHint": "Deel deze link handmatig via SMS of e-mail. Er wordt een nieuwe link gegenereerd als de vorige is verlopen of verloren is gegaan.", + "loadingTeam": "Team laden...", + "tableName": "Naam", + "tableEmail": "E-mail", + "tableRole": "Rol", + "tableStatus": "Status", + "tableAccess": "Toegang", + "tableAction": "Actie", + "roleOwner": "Eigenaar", + "roleStaff": "Medewerker", + "statusActive": "Actief", + "statusPending": "In afwachting", + "statusDisabled": "Uitgeschakeld", + "statusExpired": "Verlopen", + "allFeatures": "Alle functies", + "inviteModalTitle": "Teamlid uitnodigen", + "stepOf": "Stap {step} van 2", + "permissionView": "Bekijken", + "permissionEdit": "Bewerken", + "labelEmail": "E-mail", + "labelDisplayName": "Weergavenaam", + "tabAccess": "Tabbladtoegang", + "sendInvite": "Uitnodiging verzenden", + "skipForNow": "Nu overslaan", + "enableModalTitle": "Teamlid inschakelen", + "enableConfirm": "{name} ({email}) inschakelen?", + "enableBullet1": "Ze kunnen opnieuw inloggen op deze organisatie met hun bestaande account.", + "enableBullet2": "Er wordt geen nieuwe uitnodiging verzonden en er zijn geen gegevens verwijderd terwijl ze waren uitgeschakeld.", + "enableBullet3": "Inschakelen gebruikt één plaats in uw abonnement.", + "noSeatsAvailable": "Geen plaatsen beschikbaar. Schakel een ander lid uit of upgrade uw abonnement voordat u deze persoon inschakelt.", + "enableMemberButton": "Lid inschakelen", + "disableModalTitle": "Teamlid uitschakelen", + "disableConfirm": "{name} ({email}) uitschakelen?", + "disableBullet1": "Ze kunnen niet inloggen op deze organisatie.", + "disableBullet2": "Er worden geen gegevens verwijderd.", + "disableBullet3": "Uitschakelen maakt één plaats vrij in uw abonnement, zodat u iemand anders kunt uitnodigen.", + "disableMemberButton": "Lid uitschakelen", + "editModalTitle": "Lid bewerken", + "loadingWorkingHours": "Werktijden laden...", + "errorLoadStaff": "Medewerkers laden mislukt.", + "errorCopyInvite": "Kon uitnodigingslink niet kopiëren.", + "errorSendInvite": "Uitnodiging verzenden mislukt.", + "errorLoadWorkingHours": "Werktijden laden mislukt.", + "successMemberUpdated": "Lid bijgewerkt.", + "errorUpdateMember": "Lid bijwerken mislukt.", + "errorDeleteNotImplemented": "Verwijderen is nog niet geïmplementeerd.", + "successMemberDisabled": "{name} is uitgeschakeld. Er is nu een plaats beschikbaar.", + "errorDisableMember": "Lid uitschakelen mislukt.", + "successMemberEnabled": "{name} is ingeschakeld en kan opnieuw inloggen.", + "errorEnableMember": "Lid inschakelen mislukt.", + "successInvited": "{name} ({email}) is uitgenodigd.", + "copyInviteLink": "Uitnodigingslink kopiëren", + "copyInviteLinkTitle": "Uitnodigingslink kopiëren (genereert indien nodig een nieuwe link)", + "enableMemberAria": "Lid inschakelen", + "enableMemberTitle": "Lid inschakelen (gebruikt een plaats)", + "disableMemberAria": "Lid uitschakelen", + "disableMemberTitle": "Lid uitschakelen (maakt een plaats vrij)", + "editMemberAria": "Lid bewerken", + "deleteMemberAria": "Lid verwijderen", + "deleteMemberTitle": "Lid verwijderen (niet geïmplementeerd)", + "features": { + "featureToday": "Vandaag", + "featureStaff": "Medewerkers", + "featureOrganizations": "Organisaties", + "featureClinics": "Klinieken", + "featureLabs": "Laboratoria", + "featurePatients": "Patiënten", + "featureAppointment": "Afspraak", + "featureTreatment": "Behandeling", + "featureBilling": "Facturatie", + "featureReports": "Rapporten", + "noTabAccess": "Geen tabbladtoegang", + "readOnlySuffix": "(Alleen lezen)" + }, + "workingHours": { + "recommendedTitle": "Werktijden aanbevolen", + "recommendedBody": "Medewerkers met bewerkingsrechten voor behandelingen verschijnen als aanbiederkolommen in Afspraken. Stel hun wekelijkse uren in, zodat het rooster de juiste boekbare tijden weergeeft.", + "intro": "Stel wekelijkse werktijden in voor deze aanbieder. Het afsprakenrooster gebruikt deze uren om boekbare tijdsloten weer te geven.", + "workingDay": "Werkdag", + "start": "Start", + "end": "Einde", + "removeShift": "Dienst verwijderen", + "addShift": "Dienst toevoegen", + "autoRepeatWeekly": "Deze uren wekelijks herhalen aan het begin van elke week (kopieer door op maandag)", + "weekdayMon": "Ma", + "weekdayTue": "Di", + "weekdayWed": "Wo", + "weekdayThu": "Do", + "weekdayFri": "Vr", + "weekdaySat": "Za", + "weekdaySun": "Zo", + "validationNeedsShift": "{day} heeft minstens één dienst nodig of moet worden gemarkeerd als vrij.", + "validationEndAfterStart": "Eindtijd van dienst op {day} moet na de starttijd liggen.", + "validationOverlap": "Diensten op {day} mogen niet overlappen." + } + }, + "patients": { + "title": "Patiënten", + "newPatient": "Nieuwe patiënt", + "errorLoadPatients": "Patiënten laden mislukt.", + "successPatientSaved": "Patiënt {firstName} {lastName} is succesvol opgeslagen.", + "errorSavePatient": "Patiënt opslaan mislukt.", + "firstName": "Voornaam", + "lastName": "Achternaam", + "phone": "Telefoon", + "savePatient": "Patiënt opslaan", + "dialogTitle": "Nieuwe patiënt", + "searchPlaceholder": "Zoek patiënten op naam, telefoon, e-mail", + "loadingPatients": "Patiënten laden...", + "noResults": "Geen patiënten gevonden voor deze zoekopdracht.", + "noContact": "Geen contact", + "selectPatient": "Selecteer een patiënt om details te bekijken.", + "phoneLabel": "Telefoon:", + "emailLabel": "E-mail:", + "statusLabel": "Status:", + "statusActive": "Actief", + "statusInactive": "Inactief", + "emptyValue": "-" + }, + "appointments": { + "title": "Afspraken", + "subtitle": "Zoek een patiënt, kies een datum en klik vervolgens op een tijdslot onder een aanbieder om te boeken.", + "loadingSchedule": "Rooster laden...", + "infoPastViewOnly": "Afspraken uit het verleden zijn alleen-lezen.", + "infoSelectPatient": "Selecteer een patiënt voor het boeken.", + "errorOutsideHours": "Deze afspraak valt buiten de huidige werktijden van de aanbieder en kan niet worden bewerkt.", + "successUpdated": "Afspraak bijgewerkt.", + "successSaved": "Afspraak opgeslagen.", + "errorUpdate": "Kon afspraak niet bijwerken.", + "errorSave": "Kon afspraak niet opslaan.", + "confirmRemove": "Deze afspraak verwijderen?", + "successRemoved": "Afspraak verwijderd.", + "errorDelete": "Kon afspraak niet verwijderen.", + "errorLoadSchedule": "Rooster laden mislukt.", + "successPatientSaved": "Patiënt {firstName} {lastName} is opgeslagen.", + "searchPlaceholder": "Bestaande patiënten zoeken", + "searching": "Zoeken...", + "searchHint": "Typ om te zoeken naar patiënten op naam, telefoon of e-mail.", + "noPermissionAdd": "U heeft geen toestemming om patiënten toe te voegen.", + "editTitle": "Afspraak bewerken", + "newTitle": "Nieuwe afspraak", + "providerLabel": "Aanbieder:", + "patientLabel": "Patiënt", + "startLabel": "Start", + "endLabel": "Einde", + "purposeLabel": "Doel", + "errorSelectPatient": "Selecteer eerst een patiënt.", + "errorEndAfterStart": "Eindtijd moet na de starttijd liggen.", + "errorPastSchedule": "Kan niet in het verleden plannen.", + "errorPastViewOnly": "Afspraken uit het verleden zijn alleen-lezen.", + "errorMissingDetails": "Ontbrekende afspraakgegevens.", + "noProviders": "Geen aanbieders beschikbaar. Voeg medewerkers met bewerkingsrechten voor behandelingen toe om hier kolommen te zien.", + "noWorkingHours": "Er zijn geen werktijden geconfigureerd voor deze dag. Stel werktijden van de aanbieder in bij Medewerkersbeheer.", + "noHoursSet": "Geen uren ingesteld", + "offToday": "Vandaag vrij", + "slotOffToday": "Aanbieder is vandaag vrij", + "slotHoursNotConfigured": "Werktijden niet geconfigureerd", + "slotOutsideHours": "Buiten werktijden", + "slotCannotCreate": "U kunt geen afspraken maken", + "slotBookAt": "Boeken om {time}", + "outsideHoursBlocked": "Buiten werktijden — bewerken geblokkeerd", + "overlappingChoose": "{count} overlappend — klik om te kiezen", + "overlapping": "{count} overlappend", + "legend": "Legenda", + "overlappingTitle": "Overlappende afspraken ({count})", + "purposeConsultation": "Consult", + "purposeFilling": "Vulling", + "purposeEndo": "Endo", + "purposeVisit": "Bezoek", + "purposeHygiene": "Hygiëne" + }, + "treatment": { + "loading": "Laden...", + "noPermissionTitle": "Behandelwerkruimte", + "noPermissionBody": "U heeft geen toestemming om het tabblad Behandeling voor deze organisatie te bekijken.", + "title": "Behandeling", + "subtitleEdit": "Documenteer casussen voor uw afspraken, sla concepten op en stuur werk naar gekoppelde organisaties.", + "subtitleReadOnly": "Alleen-lezen toegang — u kunt afspraken en behandelgeschiedenis bekijken, maar niet bewerken.", + "pastDayNotice": "Dagen in het verleden zijn alleen-lezen. U kunt afspraken en geschiedenis bekijken, maar behandelcasussen kunnen niet worden toegevoegd of gewijzigd.", + "selectedPatient": "Geselecteerde patiënt", + "purposeLabel": "Doel:", + "loadingAppointments": "Afspraken laden...", + "selectDayWithAppointment": "Selecteer een dag met ten minste één afspraak.", + "confirmDiscard": "U heeft niet-opgeslagen wijzigingen. Wilt u deze negeren en doorgaan?", + "successDraftSaved": "Behandelconcept opgeslagen.", + "errorChooseOrg": "Kies ten minste één actieve organisatie om deze case te verzenden.", + "successCaseSent": "Case verzonden naar geselecteerde organisaties.", + "successFilesUploaded": "{count} bestand(en) succesvol geüpload.", + "errorLoadAppointments": "Afspraken laden mislukt.", + "errorLoadOrgs": "Gekoppelde organisaties laden mislukt.", + "errorLoadHistory": "Behandelgeschiedenis laden mislukt.", + "errorLoadDraft": "Behandelconcept laden mislukt.", + "errorUpload": "Bijlagen uploaden mislukt.", + "errorSaveDraft": "Behandelconcept opslaan mislukt.", + "errorSendCase": "Case verzenden mislukt.", + "errorCaseMustSave": "Case moet worden opgeslagen voor verzending.", + "draftTitle": "Concept · {patientName}", + "hiddenMessage": "Afspraken zijn verborgen.", + "showAppointments": "Afspraken tonen", + "appointmentsTitle": "Mijn afspraken", + "hideAppointments": "Afspraken verbergen", + "emptyDay": "Geen afspraken aan u toegewezen op deze dag.", + "casesTitle": "Behandelcasussen", + "casesSubtitle": "Elke case heeft zijn eigen tanden, notities, bijlagen en verzendbestemmingen.", + "addCase": "Case toevoegen", + "caseLabel": "Case {n}", + "comments": "Opmerkingen", + "commentsPlaceholder": "Schrijf klinische notities voor deze case...", + "treatmentType": "Behandeltype", + "typeConsultation": "consult", + "typeFilling": "vulling", + "typeEndo": "endo", + "typeVisit": "bezoek", + "typeHygiene": "hygiëne", + "attachments": "Bijlagen", + "attachFiles": "Voeg bestanden toe voor deze behandelscase", + "chooseFiles": "Kies bestanden", + "sendToOrgs": "Verzend deze case naar gekoppelde organisaties", + "searchOrgsPlaceholder": "Zoek actieve organisaties...", + "recent": "Recent:", + "noOrgMatch": "Geen actieve organisatie komt overeen met uw zoekopdracht.", + "sendThisCase": "Verzend deze case", + "saveDraft": "Behandelconcept opslaan", + "unsavedChanges": "Niet-opgeslagen wijzigingen", + "draftSaved": "Concept opgeslagen", + "sendSavesFirst": "Verzenden is per case en slaat eerst automatisch op.", + "historyTitle": "Eerdere behandelingen", + "historySubtitle": "Voltooide behandelingen voor deze patiënt. Elke case wordt afzonderlijk weergegeven.", + "loadingHistory": "Geschiedenis laden...", + "historyEmpty": "Geen eerdere behandelingen voor deze patiënt.", + "statusLabel": "Status:", + "historyCaseLabel": "Case {n} · {type}", + "teethLabel": "Tanden:", + "teethNone": "Geen geselecteerd", + "reviewDetails": "Details bekijken", + "previewTitle": "Behandelvoorbeeld", + "previewDraft": "Bekijk huidig concept", + "selectAppointment": "Selecteer een afspraak om het concept te bekijken.", + "caseCount": "{n} case(s)", + "attachmentCount": "{n} bijlage(n)", + "caseSummary": "Case {n}: {type}", + "teethPrefix": "· Tanden", + "moreCases": "+ {n} meer case(s)", + "previewDialogTitle": "Behandelvoorbeeld", + "previewDialogSubtitle": "Bekijk casussen, bijlagen en verzendbestemmingen.", + "noCases": "Geen casussen in deze behandeling.", + "typeLabel": "Type:", + "commentsLabel": "Opmerkingen:", + "commentsEmpty": "Opmerkingen: —", + "attachFilesShort": "Bestanden bijvoegen", + "sendCase": "Verzend deze case", + "sendToLinkedOrgs": "Verzenden naar gekoppelde organisaties", + "noActiveOrgs": "Geen actieve gekoppelde organisaties.", + "confirmSend": "Bevestig verzending", + "toothChartTitle": "FDI-tanddiagram", + "toothChartHint": "Tik op tanden om meerdere te selecteren. Geldt voor de actieve case.", + "selectedLabel": "Geselecteerd:", + "selectedEmpty": "—", + "upperArch": "Bovenboog", + "lowerArch": "Onderboog", + "toothAria": "FDI-tand {fdi}", + "toothSelectedSuffix": ", geselecteerd", + "sentToAt": "Verzonden naar {orgName} op {datetime}", + "fallbackOrgName": "organisatie" + }, + "organizations": { + "loadingOrganization": "Organisatie laden...", + "subtitle": "Zoek organisaties, stuur verbindingsverzoeken naar bestaande accounts, of uitnodigingslinks wanneer ze nog niet op DyoLink staan.", + "invitationHistory": "Uitnodigingsgeschiedenis", + "searchPlaceholder": "Zoek {counterpart} op naam, e-mail of telefoon...", + "backToList": "Terug naar lijst", + "tableOrganization": "Organisatie", + "tableOwnerEmail": "E-mail eigenaar", + "tableDate": "Datum", + "tableStatus": "Status", + "tableAction": "Actie", + "emptyConnections": "Nog geen verbindingen. Zoek om een verbindingsverzoek of een uitnodigingslink te verzenden.", + "statusInvitationPending": "Uitnodiging in afwachting", + "statusConnectionPending": "Verbindingsverzoek in afwachting", + "statusConnected": "Verbonden", + "statusDeclined": "Verbindingsverzoek afgewezen", + "statusFound": "Gevonden", + "statusToday": "Vandaag", + "acceptRequest": "Verbindingsverzoek accepteren", + "declineRequest": "Verbindingsverzoek afwijzen", + "removeConnection": "Verbinding verwijderen", + "sendRequest": "Verbindingsverzoek verzenden", + "noDirectoryResults": "Geen organisatie gevonden in directoryzoekopdracht.", + "hideInvitationFields": "Uitnodigingsvelden verbergen", + "sendInvitationLink": "Uitnodigingslink verzenden", + "counterpartNameLabel": "Naam {counterpart}", + "ownerEmailLabel": "E-mail eigenaar", + "sendInvitation": "Uitnodiging verzenden", + "successConnectionSent": "Verbindingsverzoek voor {counterpart} verzonden.", + "successInviteCreated": "Uitnodigingslink aangemaakt voor {email}", + "successLinkCopied": "Uitnodigingslink gekopieerd naar klembord.", + "successAccepted": "Verbindingsverzoek geaccepteerd.", + "successDeclined": "Verbindingsverzoek afgewezen.", + "successRemoved": "Verbinding verwijderd.", + "historyTitle": "Uitnodigingsgeschiedenis", + "loadingHistory": "Uitnodigingsgeschiedenis laden...", + "historyEmpty": "Nog geen uitnodigingen.", + "tableInvitationLink": "Uitnodigingslink", + "statusPending": "Uitnodiging in afwachting", + "statusAccepted": "Uitnodiging geaccepteerd", + "statusRejected": "Uitnodiging afgewezen", + "statusExpired": "Uitnodiging verlopen", + "copyInvitationLink": "Uitnodigingslink kopiëren", + "copyInvitationLinkTitle": "Uitnodigingslink kopiëren (genereert indien nodig een nieuwe link)", + "selectorTitle": "Organisaties", + "selectorSubtitleWithCreate": "Selecteer een organisatie om door te gaan, of maak een nieuwe aan.", + "selectorSubtitleSelectOnly": "Selecteer een organisatie om door te gaan.", + "createOrganization": "Organisatie aanmaken", + "createAndContinue": "Aanmaken en doorgaan", + "emptyCanCreate": "Geen organisaties gevonden. Maak uw eerste organisatie aan om door te gaan.", + "emptyAskOwner": "Geen organisaties gevonden. Vraag een organisatie-eigenaar om u uit te nodigen.", + "continueArrow": "Doorgaan →", + "planLabel": "Plan: {name} • {maxUsers} gebruikers", + "counterpartClinic": "Kliniek", + "counterpartLab": "Laboratorium" + }, + "settings": { + "accountTitle": "Account", + "accountSubtitle": "Profiel- en beveiligingsinstellingen voor uw login.", + "accountPlaceholder": "Wachtwoordwijziging en profielbewerking worden hierna hier aangesloten (bijv. uitnodigingsflow, wachtwoord herstellen).", + "subscriptionsTitle": "Abonnementen", + "subscriptionsSubtitle": "Uw DyoLink-werkruimteplan en plaatsen voor {orgName}. Kliniek- en laboratoriuminkomsten blijven onder het tabblad Facturatie in de zijbalk.", + "noSubscriptionNotice": "Deze organisatie heeft geen actief abonnement. Selecteer hieronder een abonnement om het aankoopproces te starten.", + "currentPlan": "Huidig abonnement", + "planPrice": "Abonnementsprijs", + "seatsUsed": "Plaatsen gebruikt", + "seatsRemaining": "Plaatsen over", + "daysRemaining": "Dagen over", + "unlimited": "Onbeperkt", + "unlimitedSeats": "Onbeperkte plaatsen", + "seatsCount": "{n} plaatsen", + "pricePerMonth": "${price} / maand", + "noActiveSubscription": "Geen actief abonnement voor deze organisatie.", + "trialEnded": "Proefperiode is beëindigd. Kies een abonnement wanneer afrekenen beschikbaar is.", + "trialEndsIn": "Proefperiode eindigt over {days} dag(en).", + "seatsLow": "Plaatsgebruik is hoog voor deze organisatie.", + "choosePlanIntro": "Kies een abonnement om door te gaan. Aankoopintegratie is nog niet actief, dus dit bereidt momenteel alleen de selectiestap voor.", + "planSolo": "Solo", + "planSmall": "Klein", + "planMedium": "Middel", + "planLarge": "Groot", + "planEnterprise": "Onderneming", + "startPurchase": "Aankoopproces starten", + "purchaseNotice": "Aankoopstroom wordt binnenkort ingeschakeld. {plan} is geselecteerd en klaar voor afrekenconfiguratie." + }, + "schedule": { + "defaultLabel": "Roosterdatum", + "previousDay": "Vorige dag", + "nextDay": "Volgende dag", + "chooseDate": "Kies roosterdatum", + "year": "Jaar", + "month": "Maand", + "day": "Dag", + "monthJanuary": "Januari", + "monthFebruary": "Februari", + "monthMarch": "Maart", + "monthApril": "April", + "monthMay": "Mei", + "monthJune": "Juni", + "monthJuly": "Juli", + "monthAugust": "Augustus", + "monthSeptember": "September", + "monthOctober": "Oktober", + "monthNovember": "November", + "monthDecember": "December" } -} +} \ No newline at end of file diff --git a/frontend/scripts/build-messages.mjs b/frontend/scripts/build-messages.mjs new file mode 100644 index 0000000..d832eee --- /dev/null +++ b/frontend/scripts/build-messages.mjs @@ -0,0 +1,619 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const messagesDir = path.join(__dirname, '..', 'messages'); + +const en = { + common: { + appName: 'DyoLink', + loading: 'Loading...', + loadingApp: 'Loading app...', + loadingWorkspace: 'Loading workspace...', + continue: 'Continue', + back: 'Back', + save: 'Save', + cancel: 'Cancel', + delete: 'Delete', + edit: 'Edit', + next: 'Next', + dismiss: 'Dismiss', + or: 'Or', + and: 'and', + close: 'Close', + redirecting: 'Redirecting…', + readOnlyAccess: 'Read-only access for this organization.', + errorGeneric: 'Something went wrong', + loadingEllipsis: 'Loading...', + search: 'Search', + action: 'Action', + status: 'Status', + name: 'Name', + email: 'Email', + date: 'Date', + organization: 'Organization', + backToApp: '← Back to app', + copied: 'Copied', + copyLink: 'Copy link', + none: 'None', + preview: 'Preview', + }, + 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', + emailPlaceholder: 'you@example.com', + passwordPlaceholder: '••••••••', + namePlaceholder: 'John Doe', + termsIntro: 'By signing up, you agree to our', + errorRegistrationFailed: 'Registration failed', + errorLoginFailed: 'Login failed', + errorCreateOrganization: 'Failed to create organization', + acceptInviteTitle: 'Accept invitation', + loadingInvitation: 'Loading invitation...', + invalidInvitationLink: 'Invalid invitation link', + invitationAlreadyAccepted: 'This invitation is already accepted. You can log in now.', + errorLoadInvitation: 'Could not load invitation', + organizationLabel: 'Organization:', + emailLabel: 'Email:', + nameRequired: 'Name is required', + passwordMinLength8: 'Password must be at least 8 characters', + passwordsDoNotMatch: 'Passwords do not match', + labelName: 'Name', + labelCreatePassword: 'Create password', + labelConfirmPassword: 'Confirm password', + activateAccount: 'Activate account', + invitationAcceptedRedirect: 'Invitation Accepted. Redirecting to login...', + errorAcceptInvitation: 'Could not accept invitation', + alreadyHaveAccess: 'Already have access?', + goToLogin: 'Go to login', + acceptOrganizationTitle: 'Accept organization invitation', + alreadyHaveAccount: 'Already have an account?', + invitedBy: 'Invited by:', + ownerEmail: 'Owner email', + activateOrganization: 'Activate organization', + organizationAcceptedRedirect: 'Invitation accepted. Redirecting to login...', + stepAccount: 'Account', + stepOrganization: 'Organization', + organizationName: 'Organization name', + organizationNamePlaceholder: 'Sunshine Dental Clinic', + organizationEmail: 'Organization email', + organizationEmailPlaceholder: 'contact@sunshineclinic.com', + organizationType: 'Organization type', + dentalClinic: 'Dental Clinic', + dentalLab: 'Dental Lab', + }, + 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", + }, + today: { + welcomeBack: 'Welcome back!!', + noSubscriptionNotice: 'This organization does not have an active subscription yet.', + choosePlanLink: 'Choose a plan', + noSubscriptionCta: 'to start the purchase process.', + cardTodaysAppointments: "Today's Appointments", + cardActivePatients: 'Active Patients', + cardNewLabCase: 'New Lab Case', + cardTodayInvoices: 'Today invoices', + }, + staff: { + redirecting: 'Redirecting…', + title: 'Staff Management', + subtitle: 'Invite teammates, set tab access, and stay within your plan seat limit.', + inviteMember: 'Invite member', + seatsLabel: 'Seats:', + unlimitedPlan: '(unlimited plan)', + seatLimitReached: 'Plan seat limit reached for this organization.', + noActivePlan: + 'No active plan selected for this organization. Choose a subscription plan to invite members.', + invitedPending: + 'Invitation is pending until they open the link, set a password, and log in.', + invitedAccepted: 'Invitation was accepted immediately.', + inviteLinkHeading: 'Invite link', + shareLinkHint: + 'Share this link manually via SMS or email. A new link is generated if the previous one expired or was lost.', + loadingTeam: 'Loading team…', + tableName: 'Name', + tableEmail: 'Email', + tableRole: 'Role', + tableStatus: 'Status', + tableAccess: 'Access', + tableAction: 'Action', + roleOwner: 'Owner', + roleStaff: 'Staff', + statusActive: 'Active', + statusPending: 'Pending', + statusDisabled: 'Disabled', + statusExpired: 'Expired', + allFeatures: 'All features', + inviteModalTitle: 'Invite team member', + stepOf: 'Step {step} of 2', + permissionView: 'View', + permissionEdit: 'Edit', + labelEmail: 'Email', + labelDisplayName: 'Display name', + tabAccess: 'Tab access', + sendInvite: 'Send invite', + skipForNow: 'Skip for now', + enableModalTitle: 'Enable team member', + enableConfirm: 'Enable {name} ({email})?', + enableBullet1: 'They can sign in to this organization again with their existing account.', + enableBullet2: 'No new invitation is sent and no data was removed while they were disabled.', + enableBullet3: 'Enabling uses one seat on your plan.', + noSeatsAvailable: + 'No seats are available. Disable another member or upgrade your plan before enabling this person.', + enableMemberButton: 'Enable member', + disableModalTitle: 'Disable team member', + disableConfirm: 'Disable {name} ({email})?', + disableBullet1: 'They will not be able to sign in to this organization.', + disableBullet2: 'No data will be removed.', + disableBullet3: 'Disabling frees one seat on your plan so you can invite someone else.', + disableMemberButton: 'Disable member', + editModalTitle: 'Edit member', + loadingWorkingHours: 'Loading working hours…', + errorLoadStaff: 'Failed to load staff.', + errorCopyInvite: 'Could not copy invitation link.', + errorSendInvite: 'Failed to send invitation.', + errorLoadWorkingHours: 'Failed to load working hours.', + successMemberUpdated: 'Member updated.', + errorUpdateMember: 'Failed to update member.', + errorDeleteNotImplemented: 'Delete is not implemented yet.', + successMemberDisabled: '{name} was disabled. A seat is now available.', + errorDisableMember: 'Failed to disable member.', + successMemberEnabled: '{name} was enabled and can sign in again.', + errorEnableMember: 'Failed to enable member.', + successInvited: '{name} ({email}) was invited.', + copyInviteLink: 'Copy invitation link', + copyInviteLinkTitle: 'Copy invitation link (generates a new link if needed)', + enableMemberAria: 'Enable member', + enableMemberTitle: 'Enable member (uses a seat)', + disableMemberAria: 'Disable member', + disableMemberTitle: 'Disable member (frees a seat)', + editMemberAria: 'Edit member', + deleteMemberAria: 'Delete member', + deleteMemberTitle: 'Delete member (not implemented)', + features: { + featureToday: 'Today', + featureStaff: 'Staff', + featureOrganizations: 'Organizations', + featureClinics: 'Clinics', + featureLabs: 'Labs', + featurePatients: 'Patients', + featureAppointment: 'Appointment', + featureTreatment: 'Treatment', + featureBilling: 'Billing', + featureReports: 'Reports', + noTabAccess: 'No tab access', + readOnlySuffix: '(Read only)', + }, + workingHours: { + recommendedTitle: 'Working hours recommended', + recommendedBody: + 'Staff with treatment edit access appear as provider columns in Appointments. Set their weekly hours so the schedule grid shows the right bookable times.', + intro: + 'Set weekly working hours for this provider. The appointments grid uses these hours to show bookable time slots.', + workingDay: 'Working day', + start: 'Start', + end: 'End', + removeShift: 'Remove shift', + addShift: 'Add shift', + autoRepeatWeekly: 'Repeat these hours at the start of each week (copy forward on Monday)', + weekdayMon: 'Mon', + weekdayTue: 'Tue', + weekdayWed: 'Wed', + weekdayThu: 'Thu', + weekdayFri: 'Fri', + weekdaySat: 'Sat', + weekdaySun: 'Sun', + validationNeedsShift: '{day} needs at least one shift or should be marked off.', + validationEndAfterStart: '{day} shift end time must be after start time.', + validationOverlap: '{day} shifts cannot overlap.', + }, + }, + patients: { + title: 'Patients', + newPatient: 'New Patient', + errorLoadPatients: 'Failed to load patients.', + successPatientSaved: 'Patient {firstName} {lastName} was saved successfully.', + errorSavePatient: 'Failed to save patient.', + firstName: 'First name', + lastName: 'Last name', + phone: 'Phone', + savePatient: 'Save Patient', + dialogTitle: 'New patient', + searchPlaceholder: 'Search patients by name, phone, email', + loadingPatients: 'Loading patients...', + noResults: 'No patients found for this search.', + noContact: 'No contact', + selectPatient: 'Select a patient to view details.', + phoneLabel: 'Phone:', + emailLabel: 'Email:', + statusLabel: 'Status:', + statusActive: 'Active', + statusInactive: 'Inactive', + emptyValue: '-', + }, + appointments: { + title: 'Appointments', + subtitle: 'Search a patient, pick a date, then click a time slot under a provider to book.', + loadingSchedule: 'Loading schedule…', + infoPastViewOnly: 'Past appointments are view-only.', + infoSelectPatient: 'Select a patient before booking.', + errorOutsideHours: + "This appointment falls outside the provider's current working hours and cannot be edited.", + successUpdated: 'Appointment updated.', + successSaved: 'Appointment saved.', + errorUpdate: 'Could not update appointment.', + errorSave: 'Could not save appointment.', + confirmRemove: 'Remove this appointment?', + successRemoved: 'Appointment removed.', + errorDelete: 'Could not delete appointment.', + errorLoadSchedule: 'Failed to load schedule.', + successPatientSaved: 'Patient {firstName} {lastName} was saved.', + searchPlaceholder: 'Search existing patients', + searching: 'Searching…', + searchHint: 'Type to search patients by name, phone, or email.', + noPermissionAdd: 'You do not have permission to add patients.', + editTitle: 'Edit appointment', + newTitle: 'New appointment', + providerLabel: 'Provider:', + patientLabel: 'Patient', + startLabel: 'Start', + endLabel: 'End', + purposeLabel: 'Purpose', + errorSelectPatient: 'Select a patient first.', + errorEndAfterStart: 'End time must be after start time.', + errorPastSchedule: 'Cannot schedule in the past.', + errorPastViewOnly: 'Past appointments are view-only.', + errorMissingDetails: 'Missing appointment details.', + noProviders: + 'No providers available. Add staff with treatment edit access to see columns here.', + noWorkingHours: + 'No working hours are configured for this day. Set provider working hours in Staff management.', + noHoursSet: 'No hours set', + offToday: 'Off today', + slotOffToday: 'Provider is off today', + slotHoursNotConfigured: 'Working hours not configured', + slotOutsideHours: 'Outside working hours', + slotCannotCreate: 'You cannot create appointments', + slotBookAt: 'Book {time}', + outsideHoursBlocked: 'Outside working hours — editing blocked', + overlappingChoose: '{count} overlapping — click to choose', + overlapping: '{count} overlapping', + legend: 'Legend', + overlappingTitle: 'Overlapping appointments ({count})', + purposeConsultation: 'Consultation', + purposeFilling: 'Filling', + purposeEndo: 'Endo', + purposeVisit: 'Visit', + purposeHygiene: 'Hygiene', + }, + treatment: { + loading: 'Loading…', + noPermissionTitle: 'Treatment workspace', + noPermissionBody: 'You do not have permission to view the Treatment tab for this organization.', + title: 'Treatment', + subtitleEdit: + 'Document cases for your appointments, save drafts, and send work to linked organizations.', + subtitleReadOnly: + 'View-only access — you can review appointments and treatment history but cannot edit.', + pastDayNotice: + 'Past days are view-only. You can review appointments and history, but treatment cases cannot be added or changed.', + selectedPatient: 'Selected patient', + purposeLabel: 'Purpose:', + loadingAppointments: 'Loading appointments…', + selectDayWithAppointment: 'Select a day with at least one appointment.', + confirmDiscard: 'You have unsaved changes. Discard them and continue?', + successDraftSaved: 'Treatment draft saved.', + errorChooseOrg: 'Choose at least one active organization to send this case.', + successCaseSent: 'Case sent to selected organizations.', + successFilesUploaded: '{count} file(s) uploaded successfully.', + errorLoadAppointments: 'Failed to load appointments.', + errorLoadOrgs: 'Failed to load linked organizations.', + errorLoadHistory: 'Failed to load treatment history.', + errorLoadDraft: 'Failed to load treatment draft.', + errorUpload: 'Failed to upload attachments.', + errorSaveDraft: 'Failed to save treatment draft.', + errorSendCase: 'Failed to send case.', + errorCaseMustSave: 'Case must be saved before sending.', + draftTitle: 'Draft · {patientName}', + hiddenMessage: 'Appointments are hidden.', + showAppointments: 'Show appointments', + appointmentsTitle: 'My appointments', + hideAppointments: 'Hide appointments', + emptyDay: 'No appointments assigned to you on this day.', + casesTitle: 'Treatment cases', + casesSubtitle: 'Each case has its own teeth, notes, attachments, and destinations for send.', + addCase: 'Add case', + caseLabel: 'Case {n}', + comments: 'Comments', + commentsPlaceholder: 'Write clinical notes for this case…', + treatmentType: 'Treatment type', + typeConsultation: 'consultation', + typeFilling: 'filling', + typeEndo: 'endo', + typeVisit: 'visit', + typeHygiene: 'hygiene', + attachments: 'Attachments', + attachFiles: 'Attach files for this treatment case', + chooseFiles: 'Choose files', + sendToOrgs: 'Send this case to linked organizations', + searchOrgsPlaceholder: 'Search active organizations...', + recent: 'Recent:', + noOrgMatch: 'No active organization matches your search.', + sendThisCase: 'Send this case', + saveDraft: 'Save treatment draft', + unsavedChanges: 'Unsaved changes', + draftSaved: 'Draft saved', + sendSavesFirst: 'Sending is per case and saves first automatically.', + historyTitle: 'Previous treatments', + historySubtitle: 'Completed treatments for this patient. Each case is listed separately.', + loadingHistory: 'Loading history…', + historyEmpty: 'No prior treatments for this patient.', + statusLabel: 'Status:', + historyCaseLabel: 'Case {n} · {type}', + teethLabel: 'Teeth:', + teethNone: 'None selected', + reviewDetails: 'Review details', + previewTitle: 'Treatment preview', + previewDraft: 'Preview current draft', + selectAppointment: 'Select an appointment to preview its draft.', + caseCount: '{n} case(s)', + attachmentCount: '{n} attachment(s)', + caseSummary: 'Case {n}: {type}', + teethPrefix: '· Teeth', + moreCases: '+ {n} more case(s)', + previewDialogTitle: 'Treatment preview', + previewDialogSubtitle: 'Review cases, attachments, and send destinations.', + noCases: 'No cases in this treatment.', + typeLabel: 'Type:', + commentsLabel: 'Comments:', + commentsEmpty: 'Comments: —', + attachFilesShort: 'Attach files', + sendCase: 'Send this case', + sendToLinkedOrgs: 'Send to linked organizations', + noActiveOrgs: 'No active linked organizations.', + confirmSend: 'Confirm send', + toothChartTitle: 'FDI tooth chart', + toothChartHint: 'Tap teeth to multi-select. Applies to the active case.', + selectedLabel: 'Selected:', + selectedEmpty: '—', + upperArch: 'Upper arch', + lowerArch: 'Lower arch', + toothAria: 'FDI tooth {fdi}', + toothSelectedSuffix: ', selected', + sentToAt: 'Sent to {orgName} at {datetime}', + fallbackOrgName: 'organization', + }, + organizations: { + loadingOrganization: 'Loading organization...', + subtitle: + 'Search organizations, send connection requests to existing accounts, or invitation links when they are not on DyoLink yet.', + invitationHistory: 'Invitation History', + searchPlaceholder: 'Search {counterpart} by name, email, or phone...', + backToList: 'Back to list', + tableOrganization: 'Organization', + tableOwnerEmail: 'Owner email', + tableDate: 'Date', + tableStatus: 'Status', + tableAction: 'Action', + emptyConnections: 'No connections yet. Search to send a connection request or an invitation link.', + statusInvitationPending: 'Invitation pending', + statusConnectionPending: 'Connection request pending', + statusConnected: 'Connected', + statusDeclined: 'Connection request declined', + statusFound: 'Found', + statusToday: 'Today', + acceptRequest: 'Accept connection request', + declineRequest: 'Decline connection request', + removeConnection: 'Remove connection', + sendRequest: 'Send connection request', + noDirectoryResults: 'No organization found in directory search.', + hideInvitationFields: 'Hide invitation fields', + sendInvitationLink: 'Send invitation link', + counterpartNameLabel: '{counterpart} name', + ownerEmailLabel: 'Owner email', + sendInvitation: 'Send invitation', + successConnectionSent: '{counterpart} connection request sent.', + successInviteCreated: 'Invitation link created for {email}', + successLinkCopied: 'Invitation link copied to clipboard.', + successAccepted: 'Connection request accepted.', + successDeclined: 'Connection request declined.', + successRemoved: 'Connection removed.', + historyTitle: 'Invitation History', + loadingHistory: 'Loading invitation history...', + historyEmpty: 'No invitations yet.', + tableInvitationLink: 'Invitation link', + statusPending: 'Invitation pending', + statusAccepted: 'Invitation accepted', + statusRejected: 'Invitation rejected', + statusExpired: 'Invitation expired', + copyInvitationLink: 'Copy invitation link', + copyInvitationLinkTitle: 'Copy invitation link (generates a new link if needed)', + selectorTitle: 'Organizations', + selectorSubtitleWithCreate: 'Select an organization to continue, or create a new one.', + selectorSubtitleSelectOnly: 'Select an organization to continue.', + createOrganization: 'Create Organization', + createAndContinue: 'Create and Continue', + emptyCanCreate: 'No organizations found. Create your first one to continue.', + emptyAskOwner: 'No organizations found. Ask an organization owner to invite you.', + continueArrow: 'Continue →', + planLabel: 'Plan: {name} • {maxUsers} users', + }, + settings: { + accountTitle: 'Account', + accountSubtitle: 'Profile and security settings for your login.', + accountPlaceholder: + 'Password change and profile editing will be wired here next (e.g. invite flow, reset password).', + subscriptionsTitle: 'Subscriptions', + subscriptionsSubtitle: + 'Your DyoLink workspace plan and seats for {orgName}. Clinic and lab income tracking stays under the sidebar Billing tab.', + noSubscriptionNotice: + 'This organization has no active subscription. Select a plan below to start the purchase process.', + currentPlan: 'Current plan', + planPrice: 'Plan price', + seatsUsed: 'Seats used', + seatsRemaining: 'Seats remaining', + daysRemaining: 'Days remaining', + unlimited: 'Unlimited', + unlimitedSeats: 'Unlimited seats', + seatsCount: '{n} seats', + pricePerMonth: '${price} / month', + noActiveSubscription: 'No active subscription for this organization.', + trialEnded: 'Trial period has ended. Choose a plan when checkout is available.', + trialEndsIn: 'Trial ends in {days} day(s).', + seatsLow: 'Seat usage is high for this organization.', + choosePlanIntro: + 'Choose a plan to continue. Purchase integration is not active yet, so this currently prepares the selection step only.', + planSolo: 'Solo', + planSmall: 'Small', + planMedium: 'Medium', + planLarge: 'Large', + planEnterprise: 'Enterprise', + startPurchase: 'Start purchase process', + purchaseNotice: + 'Purchase flow will be enabled soon. {plan} is selected and ready for checkout setup.', + }, + schedule: { + defaultLabel: 'Schedule date', + previousDay: 'Previous day', + nextDay: 'Next day', + chooseDate: 'Choose schedule date', + year: 'Year', + month: 'Month', + day: 'Day', + monthJanuary: 'January', + monthFebruary: 'February', + monthMarch: 'March', + monthApril: 'April', + monthMay: 'May', + monthJune: 'June', + monthJuly: 'July', + monthAugust: 'August', + monthSeptember: 'September', + monthOctober: 'October', + monthNovember: 'November', + monthDecember: 'December', + }, +}; + +function deepMerge(base, overlay) { + const result = { ...base }; + for (const key of Object.keys(base)) { + const baseVal = base[key]; + const overlayVal = overlay?.[key]; + if (baseVal && typeof baseVal === 'object' && !Array.isArray(baseVal)) { + result[key] = deepMerge(baseVal, overlayVal ?? {}); + } else if (overlayVal !== undefined) { + result[key] = overlayVal; + } + } + return result; +} + +function writeJson(file, data) { + fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, 'utf8'); +} + +writeJson(path.join(messagesDir, 'en.json'), en); + +for (const locale of ['fa', 'nl']) { + const file = path.join(messagesDir, `${locale}.json`); + const existing = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {}; + writeJson(file, deepMerge(en, existing)); +} + +console.log('Messages built: en.json updated; fa.json and nl.json merged with existing translations.'); diff --git a/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx b/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx index f7ec1ad..10207e0 100644 --- a/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslations } from 'next-intl'; import { appointmentsApi } from '@/lib/api/appointments'; import { patientsApi } from '@/lib/api/patients'; import { useAuth } from '@/lib/hooks/useAuth'; @@ -28,6 +29,8 @@ const EMPTY_PATIENT_FORM: CreatePatientInput = { }; export default function AppointmentsPage() { + const t = useTranslations('appointments'); + const tPatients = useTranslations('patients'); const { currentOrganization } = useAuth(); const [scheduleDate, setScheduleDate] = useState(() => startOfLocalDay(new Date())); @@ -99,13 +102,13 @@ export default function AppointmentsPage() { if (gen !== scheduleLoadGen.current) { return; } - toast.showError(formatApiErrorMessage(err, 'Failed to load schedule.')); + toast.showError(formatApiErrorMessage(err, t('errorLoadSchedule'))); } finally { if (gen === scheduleLoadGen.current) { setLoadingSchedule(false); } } - }, [currentOrganization?.id, scheduleDate]); + }, [currentOrganization?.id, scheduleDate, t]); useEffect(() => { void loadSchedule(); @@ -149,12 +152,17 @@ export default function AppointmentsPage() { setPatientForm(EMPTY_PATIENT_FORM); await loadPatientsSearch(search); setSelectedPatient(response.data); - toast.showSuccess(`Patient ${response.data.firstName} ${response.data.lastName} was saved.`); + toast.showSuccess( + t('successPatientSaved', { + firstName: response.data.firstName, + lastName: response.data.lastName, + }), + ); } catch (err: unknown) { const message = err && typeof err === 'object' && 'message' in err ? String((err as { message: unknown }).message) - : 'Failed to save patient.'; + : tPatients('errorSavePatient'); toast.showError(message); } finally { setSavingPatient(false); @@ -163,11 +171,11 @@ export default function AppointmentsPage() { function handleSlotClick(startMinute: number, providerUserId: string, providerName: string) { if (isViewingPastDay) { - toast.showInfo('Past appointments are view-only.'); + toast.showInfo(t('infoPastViewOnly')); return; } if (!selectedPatient) { - toast.showInfo('Select a patient before booking.'); + toast.showInfo(t('infoSelectPatient')); return; } setBookingStartMinute(startMinute); @@ -179,7 +187,7 @@ export default function AppointmentsPage() { function handleAppointmentClick(appointment: AppointmentRecord) { if (isViewingPastDay) { - toast.showInfo('Past appointments are view-only.'); + toast.showInfo(t('infoPastViewOnly')); return; } const provider = providers.find((p) => p.userId === appointment.providerUserId); @@ -192,9 +200,7 @@ export default function AppointmentsPage() { } function handleAppointmentOutsideHours(appointment: AppointmentRecord) { - toast.showError( - 'This appointment falls outside the provider’s current working hours and cannot be edited.', - ); + toast.showError(t('errorOutsideHours')); } async function handleSaveAppointment(payload: { @@ -214,15 +220,15 @@ export default function AppointmentsPage() { } setBookingOpen(false); setEditingAppointmentId(null); - toast.showSuccess(activeEditingAppointment ? 'Appointment updated.' : 'Appointment saved.'); + toast.showSuccess(activeEditingAppointment ? t('successUpdated') : t('successSaved')); await loadSchedule(); } catch (err: unknown) { const message = err && typeof err === 'object' && 'message' in err ? String((err as { message: unknown }).message) : activeEditingAppointment - ? 'Could not update appointment.' - : 'Could not save appointment.'; + ? t('errorUpdate') + : t('errorSave'); toast.showError(message); } finally { setSavingAppointment(false); @@ -233,7 +239,7 @@ export default function AppointmentsPage() { if (!activeEditingAppointment) { return; } - if (!window.confirm('Remove this appointment?')) { + if (!window.confirm(t('confirmRemove'))) { return; } setDeletingAppointment(true); @@ -242,13 +248,13 @@ export default function AppointmentsPage() { await appointmentsApi.remove(activeEditingAppointment.id); setBookingOpen(false); setEditingAppointmentId(null); - toast.showSuccess('Appointment removed.'); + toast.showSuccess(t('successRemoved')); await loadSchedule(); } catch (err: unknown) { const message = err && typeof err === 'object' && 'message' in err ? String((err as { message: unknown }).message) - : 'Could not delete appointment.'; + : t('errorDelete'); toast.showError(message); } finally { setDeletingAppointment(false); @@ -258,10 +264,8 @@ export default function AppointmentsPage() { return (
-

Appointments

-

- Search a patient, pick a date, then click a time slot under a provider to book. -

+

{t('title')}

+

{t('subtitle')}

@@ -296,7 +300,7 @@ export default function AppointmentsPage() { onChange={(d) => setScheduleDate(startOfLocalDay(d))} /> {loadingSchedule && ( -

Loading schedule…

+

{t('loadingSchedule')}

)}
diff --git a/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx b/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx index b3c9ddb..87b9f47 100644 --- a/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; import { useToast } from '@/lib/hooks/useToast'; import { Check, Trash2, UserPlus, X } from 'lucide-react'; import { useAuth } from '@/lib/hooks/useAuth'; @@ -28,32 +29,6 @@ function formatOrganizationStatusLabel(status: string): string { return lower.charAt(0).toUpperCase() + lower.slice(1); } -function formatConnectionStatusLabel( - row: CounterpartItemDto, - currentOrganizationId: string, -): string { - if (row.status === 'PENDING') { - if ( - row.pendingInvitationId && - row.requestedByOrganizationId === currentOrganizationId - ) { - return 'Invitation pending'; - } - return 'Connection request pending'; - } - if (row.status === 'ACTIVE') return 'Connected'; - if (row.status === 'REJECTED') return 'Connection request declined'; - return formatOrganizationStatusLabel(row.status); -} - -function formatApiMessage(err: unknown): string { - if (!err || typeof err !== 'object') return 'Something went wrong'; - const m = (err as ApiError).message; - if (Array.isArray(m)) return m.join(', '); - if (typeof m === 'string') return m; - return 'Something went wrong'; -} - function formatTableDate(value: string): string { const d = new Date(value); if (Number.isNaN(d.getTime())) return '\u2014'; @@ -63,10 +38,42 @@ function formatTableDate(value: string): string { type TableMode = 'existing' | 'search'; export default function OrganizationsPage() { + const t = useTranslations('organizations'); + const tNav = useTranslations('nav'); + const tCommon = useTranslations('common'); const { currentOrganization } = useAuth(); const [loading, setLoading] = useState(true); const toast = useToast(); + const formatApiMessage = useCallback( + (err: unknown): string => { + if (!err || typeof err !== 'object') return tCommon('errorGeneric'); + const m = (err as ApiError).message; + if (Array.isArray(m)) return m.join(', '); + if (typeof m === 'string') return m; + return tCommon('errorGeneric'); + }, + [tCommon], + ); + + const formatConnectionStatusLabel = useCallback( + (row: CounterpartItemDto, currentOrganizationId: string): string => { + if (row.status === 'PENDING') { + if ( + row.pendingInvitationId && + row.requestedByOrganizationId === currentOrganizationId + ) { + return t('statusInvitationPending'); + } + return t('statusConnectionPending'); + } + if (row.status === 'ACTIVE') return t('statusConnected'); + if (row.status === 'REJECTED') return t('statusDeclined'); + return formatOrganizationStatusLabel(row.status); + }, + [t], + ); + const [query, setQuery] = useState(''); const [mode, setMode] = useState('existing'); const [searching, setSearching] = useState(false); @@ -91,8 +98,9 @@ export default function OrganizationsPage() { pruneAcceptedLinks, } = useOrganizationInviteLinkCopy(currentOrganization?.id); - const counterpartLabel = currentOrganization?.type === 'LAB' ? 'Clinic' : 'Lab'; - const tabLabel = currentOrganization?.type === 'LAB' ? 'Clinics' : 'Labs'; + const counterpart = + currentOrganization?.type === 'LAB' ? t('counterpartClinic') : t('counterpartLab'); + const tabLabel = currentOrganization?.type === 'LAB' ? tNav('clinics') : tNav('labs'); const existingRows = items; @@ -142,7 +150,7 @@ export default function OrganizationsPage() { toast.setError(''); try { await organizationApi.createConnectionRequest(targetOrganizationId); - toast.showSuccess(`${counterpartLabel} connection request sent.`); + toast.showSuccess(t('successConnectionSent', { counterpart })); setSearchResults([]); setQuery(''); setMode('existing'); @@ -163,7 +171,7 @@ export default function OrganizationsPage() { ownerEmail: manualOwnerEmail.trim(), }); storeInviteLink(res.data.invitationId, manualOwnerEmail, res.data.invitationUrl); - toast.showSuccess(`Invitation link created for ${manualOwnerEmail.trim()}`); + toast.showSuccess(t('successInviteCreated', { email: manualOwnerEmail.trim() })); setManualOrganizationName(''); setManualOwnerEmail(''); setShowInviteForm(false); @@ -206,7 +214,7 @@ export default function OrganizationsPage() { await loadInvitationHistory(); }, }); - toast.showSuccess('Invitation link copied to clipboard.'); + toast.showSuccess(t('successLinkCopied')); } catch (e) { toast.showError(formatApiMessage(e)); } @@ -232,7 +240,7 @@ export default function OrganizationsPage() { }, }, ); - toast.showSuccess('Invitation link copied to clipboard.'); + toast.showSuccess(t('successLinkCopied')); } catch (e) { toast.showError(formatApiMessage(e)); } @@ -244,7 +252,7 @@ export default function OrganizationsPage() { try { await organizationApi.respondToConnectionRequest(connectionId, action); toast.showSuccess( - action === 'ACCEPT' ? 'Connection request accepted.' : 'Connection request declined.', + action === 'ACCEPT' ? t('successAccepted') : t('successDeclined'), ); await loadList(); } catch (e) { @@ -259,7 +267,7 @@ export default function OrganizationsPage() { toast.setError(''); try { await organizationApi.deleteConnection(connectionId); - toast.showSuccess('Connection removed.'); + toast.showSuccess(t('successRemoved')); await loadList(); } catch (e) { toast.showError(formatApiMessage(e)); @@ -276,7 +284,7 @@ export default function OrganizationsPage() { } if (!currentOrganization) { - return

Loading organization...

; + return

{t('loadingOrganization')}

; } return ( @@ -284,13 +292,10 @@ export default function OrganizationsPage() {

{tabLabel}

-

- Search organizations, send connection requests to existing accounts, or invitation - links when they are not on DyoLink yet. -

+

{t('subtitle')}

@@ -300,7 +305,7 @@ export default function OrganizationsPage() { value={query} onChange={setQuery} onSubmit={() => void runSearch()} - placeholder={`Search ${counterpartLabel.toLowerCase()} by name, email, or phone...`} + placeholder={t('searchPlaceholder', { counterpart: counterpart.toLowerCase() })} actions={ <> {mode === 'search' && ( )} @@ -328,19 +333,19 @@ export default function OrganizationsPage() { headers={ - Organization + {t('tableOrganization')} - Owner email + {t('tableOwnerEmail')} - Date + {t('tableDate')} - Status + {t('tableStatus')} - Action + {t('tableAction')} } @@ -349,14 +354,14 @@ export default function OrganizationsPage() { {loading ? ( - Loading... + {tCommon('loadingEllipsis')} ) : mode === 'existing' ? ( existingRows.length === 0 ? ( - No connections yet. Search to send a connection request or an invitation link. + {t('emptyConnections')} ) : ( @@ -401,8 +406,8 @@ export default function OrganizationsPage() { className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:text-text-muted disabled:opacity-50" disabled={pendingConnectionRowId !== null && pendingConnectionRowId !== row.id} onClick={() => void respondToPendingConnection(row.id, 'ACCEPT')} - aria-label="Accept connection request" - title="Accept connection request" + aria-label={t('acceptRequest')} + title={t('acceptRequest')} > @@ -411,8 +416,8 @@ export default function OrganizationsPage() { className="p-2 rounded-md text-text-secondary hover:bg-red-500/15 hover:text-red-600 disabled:text-text-muted disabled:opacity-50" disabled={pendingConnectionRowId !== null && pendingConnectionRowId !== row.id} onClick={() => void respondToPendingConnection(row.id, 'REJECT')} - aria-label="Decline connection request" - title="Decline connection request" + aria-label={t('declineRequest')} + title={t('declineRequest')} > @@ -424,8 +429,8 @@ export default function OrganizationsPage() { className="p-2 rounded-md text-text-secondary hover:bg-red-500/15 hover:text-red-600 disabled:text-text-muted disabled:opacity-50" disabled={deleteConnectionRowId !== null && deleteConnectionRowId !== row.id} onClick={() => void deleteConnection(row.id)} - aria-label="Remove connection" - title="Remove connection" + aria-label={t('removeConnection')} + title={t('removeConnection')} > @@ -441,9 +446,9 @@ export default function OrganizationsPage() { {r.name} {r.owner.email} - Today + {t('statusToday')} - Found + {t('statusFound')} @@ -466,22 +471,22 @@ export default function OrganizationsPage() {

- No organization found in directory search. + {t('noDirectoryResults')}

{showInviteForm && (
setManualOrganizationName(e.target.value)} /> setManualOwnerEmail(e.target.value)} @@ -494,7 +499,7 @@ export default function OrganizationsPage() { onClick={() => void sendInvite()} className="w-full" > - Send invitation + {t('sendInvitation')}
diff --git a/frontend/src/app/[locale]/(dashboard)/patients/page.tsx b/frontend/src/app/[locale]/(dashboard)/patients/page.tsx index 744c193..61a5f21 100644 --- a/frontend/src/app/[locale]/(dashboard)/patients/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/patients/page.tsx @@ -1,145 +1,151 @@ -'use client'; - -import { useEffect, useMemo, useState } from 'react'; -import { Button } from '@/components/ui/shared/Button'; -import { ToastStack } from '@/components/ui/shared/Toast'; -import { patientsApi } from '@/lib/api/patients'; -import { formatApiErrorMessage } from '@/components/shared/formatApiError'; -import { useAuth } from '@/lib/hooks/useAuth'; -import { useToast } from '@/lib/hooks/useToast'; -import { hasPermission } from '@/components/shared/permissions'; -import { CreatePatientInput, Patient } from '@/types/patient'; -import { PatientSearchSelect } from '../../../components/ui/patient/PatientSearchSelect'; -import { CreatePatientModal } from '../../../components/ui/patient/CreatePatientModal'; -import { PatientSummaryCard } from '../../../components/ui/patient/PatientSummaryCard'; - -const EMPTY_PATIENT_FORM: CreatePatientInput = { - firstName: '', - lastName: '', - phone: '', - email: '', -}; - -export default function PatientsPage() { - const { currentOrganization } = useAuth(); - const toast = useToast(); - const [search, setSearch] = useState(''); - const [patients, setPatients] = useState([]); - const [selectedPatient, setSelectedPatient] = useState(); - const [loadingPatients, setLoadingPatients] = useState(false); - const [isCreateOpen, setIsCreateOpen] = useState(false); - const [savingPatient, setSavingPatient] = useState(false); - const [patientForm, setPatientForm] = useState(EMPTY_PATIENT_FORM); - const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT'); - - const sortedPatients = useMemo( - () => - [...patients].sort((a, b) => - `${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`), - ), - [patients], - ); - - useEffect(() => { - const timeout = setTimeout(() => { - void loadPatients(search); - }, 300); - return () => clearTimeout(timeout); - }, [search]); - - useEffect(() => { - void loadPatients(''); - }, []); - - async function loadPatients(q: string) { - setLoadingPatients(true); - toast.setError(''); - try { - const response = await patientsApi.list({ q, page: 1, limit: 25 }); - const items = response.data.items; - setPatients(items); - - if (selectedPatient) { - const freshSelected = items.find((item) => item.id === selectedPatient.id); - setSelectedPatient(freshSelected); - } - } catch (error: unknown) { - toast.showError(formatApiErrorMessage(error, 'Failed to load patients.')); - } finally { - setLoadingPatients(false); - } - } - - async function handleCreatePatient() { - setSavingPatient(true); - toast.setError(''); - try { - const response = await patientsApi.create(patientForm); - setIsCreateOpen(false); - setPatientForm(EMPTY_PATIENT_FORM); - await loadPatients(search); - setSelectedPatient(response.data); - toast.showSuccess( - `Patient ${response.data.firstName} ${response.data.lastName} was saved successfully.`, - ); - } catch (error: unknown) { - toast.showError(formatApiErrorMessage(error, 'Failed to save patient.')); - } finally { - setSavingPatient(false); - } - } - - return ( -
-
-

Patients

- -
- - - - {isCreateOpen && ( - setPatientForm((prev) => ({ ...prev, ...patch }))} - onSubmit={() => void handleCreatePatient()} - onClose={() => { - setIsCreateOpen(false); - setPatientForm(EMPTY_PATIENT_FORM); - }} - loading={savingPatient} - /> - )} - -
-
- -
- -
- -
-
-
- ); -} +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { Button } from '@/components/ui/shared/Button'; +import { ToastStack } from '@/components/ui/shared/Toast'; +import { patientsApi } from '@/lib/api/patients'; +import { formatApiErrorMessage } from '@/components/shared/formatApiError'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { useToast } from '@/lib/hooks/useToast'; +import { hasPermission } from '@/components/shared/permissions'; +import { CreatePatientInput, Patient } from '@/types/patient'; +import { PatientSearchSelect } from '@/components/ui/patient/PatientSearchSelect'; +import { CreatePatientModal } from '@/components/ui/patient/CreatePatientModal'; +import { PatientSummaryCard } from '@/components/ui/patient/PatientSummaryCard'; + +const EMPTY_PATIENT_FORM: CreatePatientInput = { + firstName: '', + lastName: '', + phone: '', + email: '', +}; + +export default function PatientsPage() { + const t = useTranslations('patients'); + const tCommon = useTranslations('common'); + const { currentOrganization } = useAuth(); + const toast = useToast(); + const [search, setSearch] = useState(''); + const [patients, setPatients] = useState([]); + const [selectedPatient, setSelectedPatient] = useState(); + const [loadingPatients, setLoadingPatients] = useState(false); + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [savingPatient, setSavingPatient] = useState(false); + const [patientForm, setPatientForm] = useState(EMPTY_PATIENT_FORM); + const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT'); + + const sortedPatients = useMemo( + () => + [...patients].sort((a, b) => + `${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`), + ), + [patients], + ); + + useEffect(() => { + const timeout = setTimeout(() => { + void loadPatients(search); + }, 300); + return () => clearTimeout(timeout); + }, [search]); + + useEffect(() => { + void loadPatients(''); + }, []); + + async function loadPatients(q: string) { + setLoadingPatients(true); + toast.setError(''); + try { + const response = await patientsApi.list({ q, page: 1, limit: 25 }); + const items = response.data.items; + setPatients(items); + + if (selectedPatient) { + const freshSelected = items.find((item) => item.id === selectedPatient.id); + setSelectedPatient(freshSelected); + } + } catch (error: unknown) { + toast.showError(formatApiErrorMessage(error, t('errorLoadPatients'))); + } finally { + setLoadingPatients(false); + } + } + + async function handleCreatePatient() { + setSavingPatient(true); + toast.setError(''); + try { + const response = await patientsApi.create(patientForm); + setIsCreateOpen(false); + setPatientForm(EMPTY_PATIENT_FORM); + await loadPatients(search); + setSelectedPatient(response.data); + toast.showSuccess( + t('successPatientSaved', { + firstName: response.data.firstName, + lastName: response.data.lastName, + }), + ); + } catch (error: unknown) { + toast.showError(formatApiErrorMessage(error, t('errorSavePatient'))); + } finally { + setSavingPatient(false); + } + } + + return ( +
+
+

{t('title')}

+ +
+ + + + {isCreateOpen && ( + setPatientForm((prev) => ({ ...prev, ...patch }))} + onSubmit={() => void handleCreatePatient()} + onClose={() => { + setIsCreateOpen(false); + setPatientForm(EMPTY_PATIENT_FORM); + }} + loading={savingPatient} + /> + )} + +
+
+ +
+ +
+ +
+
+
+ ); +} diff --git a/frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx b/frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx index cc755d4..e52411a 100644 --- a/frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx @@ -1,8 +1,12 @@ 'use client'; +import { useTranslations } from 'next-intl'; import { Link } from '@/i18n/navigation'; export default function AccountSettingsPage() { + const t = useTranslations('settings'); + const tCommon = useTranslations('common'); + return (
@@ -10,19 +14,14 @@ export default function AccountSettingsPage() { href="/today" className="text-sm text-primary hover:opacity-90" > - ← Back to app + {tCommon('backToApp')} -

Account

-

- Profile and security settings for your login. -

+

{t('accountTitle')}

+

{t('accountSubtitle')}

-

- Password change and profile editing will be wired here next (e.g. invite - flow, reset password). -

+

{t('accountPlaceholder')}

); diff --git a/frontend/src/app/[locale]/(dashboard)/settings/organizations/page.tsx b/frontend/src/app/[locale]/(dashboard)/settings/organizations/page.tsx index f1e7696..7ad39d8 100644 --- a/frontend/src/app/[locale]/(dashboard)/settings/organizations/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/settings/organizations/page.tsx @@ -1,9 +1,12 @@ 'use client'; +import { useTranslations } from 'next-intl'; import { Link } from '@/i18n/navigation'; import { OrganizationSelectorContent } from '@/components/ui/organizations/OrganizationSelectorContent'; export default function DashboardOrganizationsSettingsPage() { + const tCommon = useTranslations('common'); + return (
@@ -11,7 +14,7 @@ export default function DashboardOrganizationsSettingsPage() { href="/today" className="text-sm text-primary hover:opacity-90" > - ← Back to app + {tCommon('backToApp')}
diff --git a/frontend/src/app/[locale]/(dashboard)/settings/subscriptions/page.tsx b/frontend/src/app/[locale]/(dashboard)/settings/subscriptions/page.tsx index 9a2b670..01836c4 100644 --- a/frontend/src/app/[locale]/(dashboard)/settings/subscriptions/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/settings/subscriptions/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; import { Link, useRouter } from '@/i18n/navigation'; import { useAuth } from '@/lib/hooks/useAuth'; import { authApi } from '@/lib/api/auth'; @@ -9,14 +10,16 @@ import { Toast } from '@/components/ui/shared/Toast'; import type { SubscriptionAlertData } from '@/types/subscription'; const PLAN_OPTIONS = [ - { id: 'solo', name: 'Solo', maxUsers: 1, price: 19 }, - { id: 'small', name: 'Small', maxUsers: 5, price: 49 }, - { id: 'medium', name: 'Medium', maxUsers: 10, price: 89 }, - { id: 'large', name: 'Large', maxUsers: 15, price: 129 }, - { id: 'enterprise', name: 'Enterprise', maxUsers: null, price: 199 }, + { id: 'solo', nameKey: 'planSolo' as const, maxUsers: 1, price: 19 }, + { id: 'small', nameKey: 'planSmall' as const, maxUsers: 5, price: 49 }, + { id: 'medium', nameKey: 'planMedium' as const, maxUsers: 10, price: 89 }, + { id: 'large', nameKey: 'planLarge' as const, maxUsers: 15, price: 129 }, + { id: 'enterprise', nameKey: 'planEnterprise' as const, maxUsers: null, price: 199 }, ] as const; export default function SubscriptionsSettingsPage() { + const t = useTranslations('settings'); + const tCommon = useTranslations('common'); const { currentOrganization } = useAuth(); const router = useRouter(); const [alert, setAlert] = useState(null); @@ -38,13 +41,13 @@ export default function SubscriptionsSettingsPage() { if (!currentOrganization) { return ( -

Loading...

+

{tCommon('loadingEllipsis')}

); } if (!currentOrganization.isOwner) { return ( -

Redirecting...

+

{tCommon('redirecting')}

); } @@ -75,55 +78,51 @@ export default function SubscriptionsSettingsPage() { href="/today" className="text-sm text-primary hover:opacity-90" > - ← Back to app + {tCommon('backToApp')} -

Subscriptions

+

{t('subscriptionsTitle')}

- Your DyoLink workspace plan and seats for{' '} - {currentOrganization.name}. - Clinic and lab income tracking stays under the sidebar{' '} - Billing tab. + {t('subscriptionsSubtitle', { orgName: currentOrganization.name })}

{!hasActiveSubscription && (
-

- This organization has no active subscription. Select a plan below to start - the purchase process. -

+

{t('noSubscriptionNotice')}

)}
-

Current plan

+

{t('currentPlan')}

{plan?.name ?? '—'}

-

Plan price

+

{t('planPrice')}

{typeof plan?.price === 'number' ? `$${plan.price}` : '—'}

-

Seats used

+

{t('seatsUsed')}

{typeof seatsUsed === 'number' ? seatsUsed : '—'} - {typeof maxUsers === 'number' ? ` / ${isUnlimited ? 'Unlimited' : maxUsers}` : ''} + {typeof maxUsers === 'number' + ? ` / ${isUnlimited ? t('unlimited') : maxUsers}` + : ''}

-

Seats remaining

+

{t('seatsRemaining')}

- {isUnlimited ? 'Unlimited' : seatsRemaining ?? '—'} + {isUnlimited ? t('unlimited') : seatsRemaining ?? '—'}

-

Days remaining

+

{t('daysRemaining')}

{daysUntilPlanEnd ?? '—'}

@@ -133,27 +132,24 @@ export default function SubscriptionsSettingsPage() { {alert?.showWarning && (
{alert.noActiveSubscription && ( -

No active subscription for this organization.

+

{t('noActiveSubscription')}

)} {alert.trialExpired && ( -

Trial period has ended. Choose a plan when checkout is available.

+

{t('trialEnded')}

)} {!alert.trialExpired && alert.trialEndingSoon && (

- Trial ends in {alert.daysUntilTrialEnd ?? '—'} day(s). + {t('trialEndsIn', { days: alert.daysUntilTrialEnd ?? '—' })}

)} {!alert.trialExpired && !alert.trialEndingSoon && alert.seatsLow && ( -

Seat usage is high for this organization.

+

{t('seatsLow')}

)}
)}
-

- Choose a plan to continue. Purchase integration is not active yet, so this - currently prepares the selection step only. -

+

{t('choosePlanIntro')}

{PLAN_OPTIONS.map((option) => { const selected = selectedPlanId === option.id; @@ -168,11 +164,15 @@ export default function SubscriptionsSettingsPage() { : 'border-border hover:border-border-strong' }`} > -

{option.name}

+

{t(option.nameKey)}

- {option.maxUsers == null ? 'Unlimited seats' : `${option.maxUsers} seats`} + {option.maxUsers == null + ? t('unlimitedSeats') + : t('seatsCount', { n: option.maxUsers })} +

+

+ {t('pricePerMonth', { price: option.price })}

-

${option.price} / month

); })} @@ -181,13 +181,13 @@ export default function SubscriptionsSettingsPage() { type="button" variant="primary" onClick={() => { - const selectedPlanLabel = selectedPlan?.name ?? 'the selected plan'; - setPurchaseNotice( - `Purchase flow will be enabled soon. ${selectedPlanLabel} is selected and ready for checkout setup.`, - ); + const selectedPlanLabel = selectedPlan + ? t(selectedPlan.nameKey) + : t('planSolo'); + setPurchaseNotice(t('purchaseNotice', { plan: selectedPlanLabel })); }} > - Start purchase process + {t('startPurchase')}
diff --git a/frontend/src/app/[locale]/(dashboard)/staff/page.tsx b/frontend/src/app/[locale]/(dashboard)/staff/page.tsx index 03d5a96..73a0171 100644 --- a/frontend/src/app/[locale]/(dashboard)/staff/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/staff/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslations } from 'next-intl'; import { useRouter } from '@/i18n/navigation'; import { firstAccessibleDashboardPath, @@ -16,7 +17,7 @@ import { resolveStaffFeatureLabel, formatAccessSummary, type FeaturePermState, -} from '../../../components/staff/staff-permission-form'; +} from '@/components/staff/staff-permission-form'; import { StaffWorkingHoursStep, createDefaultWorkingHoursState, @@ -90,6 +91,9 @@ function PermissionGrid({ disabled?: boolean; organizationType?: 'CLINIC' | 'LAB'; }) { + const t = useTranslations('staff'); + const tFeatures = useTranslations('staff.features'); + const setRead = (editKey: string, read: boolean) => { const cur = state[editKey] ?? { read: false, edit: false }; onChange({ @@ -116,19 +120,19 @@ function PermissionGrid({ className="flex flex-col gap-3 rounded-[var(--radius-md)] border border-border/60 bg-background-card/50 px-3 py-3" > - {resolveStaffFeatureLabel(g, organizationType)} + {resolveStaffFeatureLabel(g, organizationType, tFeatures)}
setRead(g.edit, v)} /> setEdit(g.edit, v)} />
@@ -141,6 +145,10 @@ function PermissionGrid({ export default function StaffPage() { const router = useRouter(); + const t = useTranslations('staff'); + const tCommon = useTranslations('common'); + const tFeatures = useTranslations('staff.features'); + const tWorkingHours = useTranslations('staff.workingHours'); const { currentOrganization, user } = useAuth(); const [members, setMembers] = useState([]); const [seats, setSeats] = useState<{ @@ -216,11 +224,11 @@ export default function StaffPage() { setMembers(res.data.members); setSeats(res.data.seats); } catch (e) { - toast.showError(formatApiErrorMessage(e, 'Failed to load staff.')); + toast.showError(formatApiErrorMessage(e, t('errorLoadStaff'))); } finally { setLoading(false); } - }, []); + }, [t]); useEffect(() => { if (!currentOrganization?.id) return; @@ -291,7 +299,7 @@ export default function StaffPage() { await load(); } } catch (e) { - toast.showError(formatApiErrorMessage(e, 'Could not copy invitation link.')); + toast.showError(formatApiErrorMessage(e, t('errorCopyInvite'))); } finally { setCopyingInviteMembershipId(null); } @@ -312,7 +320,7 @@ export default function StaffPage() { if (!includeHours || !inviteHasTreatmentEdit) { return; } - const validationError = validateEditorDays(inviteWorkingHoursDays); + const validationError = validateEditorDays(inviteWorkingHoursDays, tWorkingHours); if (validationError) { throw new Error(validationError); } @@ -333,7 +341,7 @@ export default function StaffPage() { const displayEmail = inviteEmail.trim(); try { if (includeWorkingHours && inviteHasTreatmentEdit) { - const validationError = validateEditorDays(inviteWorkingHoursDays); + const validationError = validateEditorDays(inviteWorkingHoursDays, tWorkingHours); if (validationError) { toast.showError(validationError); return; @@ -374,7 +382,7 @@ export default function StaffPage() { resetInviteForm(); await load(); } catch (e) { - toast.showError(formatApiErrorMessage(e, 'Failed to send invitation.')); + toast.showError(formatApiErrorMessage(e, t('errorSendInvite'))); } finally { setInviteLoading(false); } @@ -397,7 +405,7 @@ export default function StaffPage() { setEditWorkingHoursDays(state.days); setEditAutoRepeatWeekly(state.autoRepeatWeekly); } catch (e) { - toast.showError(formatApiErrorMessage(e, 'Failed to load working hours.')); + toast.showError(formatApiErrorMessage(e, t('errorLoadWorkingHours'))); } finally { setEditLoadingWorkingHours(false); } @@ -406,7 +414,7 @@ export default function StaffPage() { async function submitEdit() { if (!editing) return; if (editHasTreatmentEdit) { - const validationError = validateEditorDays(editWorkingHoursDays); + const validationError = validateEditorDays(editWorkingHoursDays, tWorkingHours); if (validationError) { toast.showError(validationError); return; @@ -431,19 +439,19 @@ export default function StaffPage() { permissionNames: permissionNamesFromFeatureState(editPerms), }); - toast.showSuccess('Member updated.'); + toast.showSuccess(t('successMemberUpdated')); setEditing(null); setEditStep(1); await load(); } catch (e) { - toast.showError(formatApiErrorMessage(e, 'Failed to update member.')); + toast.showError(formatApiErrorMessage(e, t('errorUpdateMember'))); } finally { setEditLoading(false); } } function handleDeleteMember() { - toast.showError('Delete is not implemented yet.'); + toast.showError(t('errorDeleteNotImplemented')); } async function confirmDisableMember() { @@ -453,11 +461,11 @@ export default function StaffPage() { toast.setError(''); try { await staffApi.disableMember(disableTarget.id); - toast.showSuccess(`${disableTarget.name} was disabled. A seat is now available.`); + toast.showSuccess(t('successMemberDisabled', { name: disableTarget.name })); setDisableTarget(null); await load(); } catch (e) { - toast.showError(formatApiErrorMessage(e, 'Failed to disable member.')); + toast.showError(formatApiErrorMessage(e, t('errorDisableMember'))); } finally { setDisablingMembershipId(null); } @@ -470,11 +478,11 @@ export default function StaffPage() { toast.setError(''); try { await staffApi.enableMember(enableTarget.id); - toast.showSuccess(`${enableTarget.name} was enabled and can sign in again.`); + toast.showSuccess(t('successMemberEnabled', { name: enableTarget.name })); setEnableTarget(null); await load(); } catch (e) { - toast.showError(formatApiErrorMessage(e, 'Failed to enable member.')); + toast.showError(formatApiErrorMessage(e, t('errorEnableMember'))); } finally { setEnablingMembershipId(null); } @@ -482,7 +490,7 @@ export default function StaffPage() { if (!currentOrganization || !canViewStaff(currentOrganization)) { return ( -

Redirecting…

+

{t('redirecting')}

); } @@ -490,10 +498,8 @@ export default function StaffPage() {
-

Staff Management

-

- Invite teammates, set tab access, and stay within your plan seat limit. -

+

{t('title')}

+

{t('subtitle')}

@@ -515,16 +521,14 @@ export default function StaffPage() { {seats && (

- Seats:{' '} + {t('seatsLabel')}{' '} {seats.used} - {seats.unlimited ? ' (unlimited plan)' : ` / ${seats.limit}`} + {seats.unlimited ? ` ${t('unlimitedPlan')}` : ` / ${seats.limit}`} {!seats.unlimited && atSeatLimit && ( - {hasActivePlan - ? 'Plan seat limit reached for this organization.' - : 'No active plan selected for this organization. Choose a subscription plan to invite members.'} + {hasActivePlan ? t('seatLimitReached') : t('noActivePlan')} )}

@@ -535,7 +539,7 @@ export default function StaffPage() {

- {lastInviteInfo.name} ({lastInviteInfo.email}) was invited. + {t('successInvited', { name: lastInviteInfo.name, email: lastInviteInfo.email })} {lastInviteInfo.invitationStatus === 'PENDING' - ? ' Invitation is pending until they open the link, set a password, and log in.' - : ' Invitation was accepted immediately.'} + ? ` ${t('invitedPending')}` + : ` ${t('invitedAccepted')}`}

{lastInviteInfo.invitationStatus === 'PENDING' && (

- Invite link + {t('inviteLinkHeading')}

{lastInviteInfo.invitationUrl && ( @@ -591,36 +595,36 @@ export default function StaffPage() { setCopiedInviteMembershipId(lastInviteInfo.membershipId); setTimeout(() => setCopiedInviteMembershipId(null), 1500); } catch (e) { - toast.showError(formatApiErrorMessage(e, 'Could not copy invitation link.')); + toast.showError(formatApiErrorMessage(e, t('errorCopyInvite'))); } finally { setCopyingInviteMembershipId(null); } })(); }} > - {copiedInviteMembershipId === lastInviteInfo.membershipId ? 'Copied' : 'Copy link'} + {copiedInviteMembershipId === lastInviteInfo.membershipId + ? tCommon('copied') + : tCommon('copyLink')} -

- Share this link manually via SMS or email. A new link is generated if the previous one expired or was lost. -

+

{t('shareLinkHint')}

)}
)} {loading ? ( -

Loading team…

+

{t('loadingTeam')}

) : ( - - - - - + + + + + } @@ -632,28 +636,28 @@ export default function StaffPage() { @@ -666,8 +670,8 @@ export default function StaffPage() { className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:opacity-50" disabled={copyingInviteMembershipId === m.id} onClick={() => void copyStaffInviteLink(m)} - aria-label="Copy invitation link" - title="Copy invitation link (generates a new link if needed)" + aria-label={t('copyInviteLink')} + title={t('copyInviteLinkTitle')} > {copiedInviteMembershipId === m.id ? ( @@ -684,9 +688,9 @@ export default function StaffPage() { ? 'text-text-secondary hover:bg-background-card/80 hover:text-primary' : 'text-text-muted opacity-50 cursor-not-allowed' }`} - aria-label="Enable member" + aria-label={t('enableMemberAria')} disabled={!canEdit || enablingMembershipId === m.id} - title="Enable member (uses a seat)" + title={t('enableMemberTitle')} onClick={() => { if (!canEdit) return; setEnableTarget(m); @@ -703,9 +707,9 @@ export default function StaffPage() { ? 'text-text-secondary hover:bg-background-card/80 hover:text-amber-600' : 'text-text-muted opacity-50 cursor-not-allowed' }`} - aria-label="Disable member" + aria-label={t('disableMemberAria')} disabled={!canEdit || disablingMembershipId === m.id} - title="Disable member (frees a seat)" + title={t('disableMemberTitle')} onClick={() => { if (!canEdit) return; setDisableTarget(m); @@ -721,7 +725,7 @@ export default function StaffPage() { ? 'text-text-secondary hover:bg-background-card/80 hover:text-text-primary' : 'text-text-muted opacity-50 cursor-not-allowed' }`} - aria-label="Edit member" + aria-label={t('editMemberAria')} disabled={!canEdit} onClick={() => { if (!canEdit) return; @@ -737,9 +741,9 @@ export default function StaffPage() { ? 'text-text-secondary hover:bg-red-500/15 hover:text-red-600' : 'text-text-muted opacity-50 cursor-not-allowed' }`} - aria-label="Delete member" + aria-label={t('deleteMemberAria')} disabled={!canEdit} - title="Delete member (not implemented)" + title={t('deleteMemberTitle')} onClick={() => { if (!canEdit) return; handleDeleteMember(); @@ -768,10 +772,10 @@ export default function StaffPage() {

- Invite team member + {t('inviteModalTitle')}

{inviteHasTreatmentEdit && ( -

Step {inviteStep} of 2

+

{t('stepOf', { step: inviteStep })}

)}
setInviteEmail(e.target.value)} autoComplete="off" /> setInviteName(e.target.value)} />
-

Tab access

+

{t('tabAccess')}

- {inviteStep === 2 ? 'Back' : 'Cancel'} + {inviteStep === 2 ? tCommon('back') : tCommon('cancel')} {inviteStep === 1 ? ( inviteHasTreatmentEdit ? ( @@ -838,7 +842,7 @@ export default function StaffPage() { disabled={!inviteEmail.trim() || !inviteName.trim()} onClick={() => setInviteStep(2)} > - Next + {tCommon('next')} ) : ( ) ) : ( @@ -858,7 +862,7 @@ export default function StaffPage() { isLoading={inviteLoading} onClick={() => void submitInvite(false)} > - Skip for now + {t('skipForNow')} )} @@ -885,7 +889,7 @@ export default function StaffPage() { >

- Enable team member + {t('enableModalTitle')}

{ @@ -895,22 +899,15 @@ export default function StaffPage() { />

- Enable {enableTarget.name} ( - {enableTarget.email})? + {t('enableConfirm', { name: enableTarget.name, email: enableTarget.email })}

    -
  • They can sign in to this organization again with their existing account.
  • -
  • No new invitation is sent and no data was removed while they were disabled.
  • -
  • - Enabling uses one seat on your - plan. -
  • +
  • {t('enableBullet1')}
  • +
  • {t('enableBullet2')}
  • +
  • {t('enableBullet3')}
{!hasAvailableSeat && ( -

- No seats are available. Disable another member or upgrade your plan before enabling - this person. -

+

{t('noSeatsAvailable')}

)}
@@ -945,7 +942,7 @@ export default function StaffPage() { >

- Disable team member + {t('disableModalTitle')}

{ @@ -955,16 +952,12 @@ export default function StaffPage() { />

- Disable {disableTarget.name} ( - {disableTarget.email})? + {t('disableConfirm', { name: disableTarget.name, email: disableTarget.email })}

    -
  • They will not be able to sign in to this organization.
  • -
  • No data will be removed.
  • -
  • - Disabling frees one seat on your - plan so you can invite someone else. -
  • +
  • {t('disableBullet1')}
  • +
  • {t('disableBullet2')}
  • +
  • {t('disableBullet3')}
@@ -998,9 +991,9 @@ export default function StaffPage() { >
-

Edit member

+

{t('editModalTitle')}

{editHasTreatmentEdit && ( -

Step {editStep} of 2

+

{t('stepOf', { step: editStep })}

)}
setEditName(e.target.value)} />
-

Tab access

+

{t('tabAccess')}

) : editLoadingWorkingHours ? ( -

Loading working hours…

+

{t('loadingWorkingHours')}

) : ( - {editStep === 2 ? 'Back' : 'Cancel'} + {editStep === 2 ? tCommon('back') : tCommon('cancel')} {editStep === 1 ? ( editHasTreatmentEdit ? ( ) : ( ) ) : ( @@ -1073,7 +1066,7 @@ export default function StaffPage() { disabled={Boolean(editHoursValidationError)} onClick={() => void submitEdit()} > - Save + {tCommon('save')} )}
diff --git a/frontend/src/app/[locale]/(dashboard)/today/page.tsx b/frontend/src/app/[locale]/(dashboard)/today/page.tsx index 47c2b3e..107a1c4 100644 --- a/frontend/src/app/[locale]/(dashboard)/today/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/today/page.tsx @@ -1,10 +1,12 @@ 'use client'; +import { useTranslations } from 'next-intl'; import { Link } from '@/i18n/navigation'; import { useAuth } from '@/lib/hooks/useAuth'; import { Card } from '@/components/ui/shared/Card'; export default function TodayPage() { + const t = useTranslations('today'); const { currentOrganization } = useAuth(); const showNoSubscriptionNotice = Boolean(currentOrganization?.isOwner) && !currentOrganization?.plan; @@ -12,42 +14,42 @@ export default function TodayPage() { return (

- Welcome back!! + {t('welcomeBack')}

{showNoSubscriptionNotice && (

- This organization does not have an active subscription yet.{' '} + {t('noSubscriptionNotice')}{' '} - Choose a plan + {t('choosePlanLink')} {' '} - to start the purchase process. + {t('noSubscriptionCta')}

)}
-

Today's Appointments

+

{t('cardTodaysAppointments')}

12

Monday 2/5/2026

-

Active Patients

+

{t('cardActivePatients')}

675

-

New Lab Case

+

{t('cardNewLabCase')}

5

35 ↑

-

Today invoices

+

{t('cardTodayInvoices')}

1200$

21,300 $

); -} \ No newline at end of file +} diff --git a/frontend/src/app/[locale]/(dashboard)/treatment/page.tsx b/frontend/src/app/[locale]/(dashboard)/treatment/page.tsx index 1014b41..6b45904 100644 --- a/frontend/src/app/[locale]/(dashboard)/treatment/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/treatment/page.tsx @@ -1,14 +1,16 @@ 'use client'; +import { useTranslations } from 'next-intl'; import { TreatmentWorkspace } from '@/components/ui/treatment/TreatmentWorkspace'; import { useAuth } from '@/lib/hooks/useAuth'; export default function TreatmentPage() { + const t = useTranslations('treatment'); const { user, currentOrganization, isAuthReady } = useAuth(); if (!isAuthReady || !user) { return ( -
Loading…
+
{t('loading')}
); } diff --git a/frontend/src/app/[locale]/(public)/accept-invite/page.tsx b/frontend/src/app/[locale]/(public)/accept-invite/page.tsx index c1bdc43..5cce4df 100644 --- a/frontend/src/app/[locale]/(public)/accept-invite/page.tsx +++ b/frontend/src/app/[locale]/(public)/accept-invite/page.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Suspense } from 'react'; +import { useTranslations } from 'next-intl'; import { Link, useRouter } from '@/i18n/navigation'; import { useSearchParams } from 'next/navigation'; import { Button } from '@/components/ui/shared/Button'; @@ -9,6 +10,7 @@ import { Input } from '@/components/ui/shared/Input'; import { staffApi } from '@/lib/api/staff'; function AcceptInviteContent() { + const t = useTranslations('auth'); const params = useSearchParams(); const router = useRouter(); const token = useMemo(() => params.get('token') || '', [params]); @@ -32,7 +34,7 @@ function AcceptInviteContent() { useEffect(() => { if (!token) { setLoading(false); - setError('Invalid invitation link'); + setError(t('invalidInvitationLink')); return; } @@ -44,30 +46,31 @@ function AcceptInviteContent() { setInviteInfo(res.data); setName(res.data.name || ''); if (res.data.status === 'ACCEPTED') { - setSuccess('This invitation is already accepted. You can log in now.'); + setSuccess(t('invitationAlreadyAccepted')); } - } catch (e: any) { - setError(e?.message || 'Could not load invitation'); + } catch (e: unknown) { + const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : ''; + setError(message || t('errorLoadInvitation')); } finally { setLoading(false); } })(); - }, [token]); + }, [token, t]); async function onAccept() { if (!token) return; setError(''); setSuccess(''); if (!name.trim()) { - setError('Name is required'); + setError(t('nameRequired')); return; } if (password.length < 8) { - setError('Password must be at least 8 characters'); + setError(t('passwordMinLength8')); return; } if (password !== confirmPassword) { - setError('Passwords do not match'); + setError(t('passwordsDoNotMatch')); return; } @@ -78,12 +81,13 @@ function AcceptInviteContent() { name: name.trim(), password, }); - setSuccess('Invitation Accepted. Redirecting to login...'); + setSuccess(t('invitationAcceptedRedirect')); setTimeout(() => { router.replace('/login'); }, 1000); - } catch (e: any) { - setError(e?.message || 'Could not accept invitation'); + } catch (e: unknown) { + const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : ''; + setError(message || t('errorAcceptInvitation')); } finally { setSubmitting(false); } @@ -92,19 +96,21 @@ function AcceptInviteContent() { return (
-

Accept invitation

+

{t('acceptInviteTitle')}

{loading ? ( -

Loading invitation...

+

{t('loadingInvitation')}

) : ( <> {inviteInfo && (

- Organization: {inviteInfo.organizationName} + {t('organizationLabel')}{' '} + {inviteInfo.organizationName}

- Email: {inviteInfo.email} + {t('emailLabel')}{' '} + {inviteInfo.email}

)} @@ -122,27 +128,28 @@ function AcceptInviteContent() { {inviteInfo?.status !== 'ACCEPTED' && (
- setName(e.target.value)} /> + setName(e.target.value)} /> setPassword(e.target.value)} /> setConfirmPassword(e.target.value)} />
)}

- Already have access? Go to login + {t('alreadyHaveAccess')}{' '} + {t('goToLogin')}

)} @@ -151,15 +158,18 @@ function AcceptInviteContent() { ); } +function AcceptInviteFallback() { + const t = useTranslations('auth'); + return ( +
+

{t('loadingInvitation')}

+
+ ); +} + export default function AcceptInvitePage() { return ( - -

Loading invitation...

-
- } - > + }> ); diff --git a/frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx b/frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx index 6c87eb5..a6434d5 100644 --- a/frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx +++ b/frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { Suspense, useEffect, useMemo, useState } from 'react'; +import { useTranslations } from 'next-intl'; import { Link, useRouter } from '@/i18n/navigation'; import { useSearchParams } from 'next/navigation'; import { useForm, type FieldErrors, type UseFormRegister, type UseFormSetValue } from 'react-hook-form'; @@ -14,33 +15,47 @@ import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDeta import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps'; import { organizationApi } from '@/lib/api/organization'; -const acceptOrganizationInviteSchema = z - .object({ - ownerName: z.string().min(2, 'Name must be at least 2 characters'), - 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 AcceptOrganizationInviteForm = z.infer; +type AcceptOrganizationInviteForm = { + ownerName: string; + password: string; + confirmPassword: string; + organizationName: string; + organizationEmail: string; + organizationType: 'CLINIC' | 'LAB'; +}; function AcceptOrganizationInviteContent() { + const t = useTranslations('auth'); + const tCommon = useTranslations('common'); + const tValidation = useTranslations('validation'); const params = useSearchParams(); const router = useRouter(); const token = useMemo(() => params.get('token') || '', [params]); + const acceptOrganizationInviteSchema = useMemo( + () => + z + .object({ + ownerName: z.string().min(2, tValidation('nameMinLength')), + 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 [step, setStep] = useState(1); const [loading, setLoading] = useState(true); const [submitting, setSubmitting] = useState(false); @@ -77,7 +92,7 @@ function AcceptOrganizationInviteContent() { useEffect(() => { if (!token) { setLoading(false); - setError('Invalid invitation link'); + setError(t('invalidInvitationLink')); return; } @@ -96,16 +111,16 @@ function AcceptOrganizationInviteContent() { organizationType: res.data.organizationType, }); if (res.data.status === 'ACCEPTED') { - setSuccess('This invitation is already accepted. You can log in now.'); + setSuccess(t('invitationAlreadyAccepted')); } } catch (e: unknown) { const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : ''; - setError(message || 'Could not load invitation'); + setError(message || t('errorLoadInvitation')); } finally { setLoading(false); } })(); - }, [token, reset]); + }, [token, reset, t]); const handleNext = async () => { const isValid = await trigger(['ownerName', 'password', 'confirmPassword']); @@ -129,11 +144,11 @@ function AcceptOrganizationInviteContent() { organizationEmail: data.organizationEmail.trim(), organizationType: data.organizationType, }); - setSuccess('Invitation accepted. Redirecting to login...'); + setSuccess(t('organizationAcceptedRedirect')); setTimeout(() => router.replace('/login'), 1000); } catch (e: unknown) { const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : ''; - setError(message || 'Could not accept invitation'); + setError(message || t('errorAcceptInvitation')); } finally { setSubmitting(false); } @@ -143,15 +158,15 @@ function AcceptOrganizationInviteContent() {
- DyoLink + {tCommon('appName')}

- Accept organization invitation + {t('acceptOrganizationTitle')}

- Already have an account?{' '} + {t('alreadyHaveAccount')}{' '} - Sign in + {t('signInLink')}

@@ -159,13 +174,13 @@ function AcceptOrganizationInviteContent() {
{loading ? ( -

Loading invitation...

+

{t('loadingInvitation')}

) : ( <> {inviteInfo && (

- Invited by:{' '} + {t('invitedBy')}{' '} {inviteInfo.inviterOrganizationName}

@@ -191,37 +206,37 @@ function AcceptOrganizationInviteContent() { {step === 1 && ( <> } /> } /> } /> } /> )} @@ -236,10 +251,10 @@ function AcceptOrganizationInviteContent() { />
@@ -254,15 +269,18 @@ function AcceptOrganizationInviteContent() { ); } +function AcceptOrganizationInviteFallback() { + const t = useTranslations('auth'); + return ( +
+

{t('loadingInvitation')}

+
+ ); +} + export default function AcceptOrganizationInvitePage() { return ( - -

Loading invitation...

-
- } - > + }> ); diff --git a/frontend/src/app/[locale]/(public)/login/page.tsx b/frontend/src/app/[locale]/(public)/login/page.tsx index aaacf4c..fc2f8ff 100644 --- a/frontend/src/app/[locale]/(public)/login/page.tsx +++ b/frontend/src/app/[locale]/(public)/login/page.tsx @@ -81,7 +81,7 @@ export default function LoginPage() { {t('signInTitle')}

- Or{' '} + {tCommon('or')}{' '} {t('startTrialLink')} @@ -95,7 +95,7 @@ export default function LoginPage() { label={t('email')} {...register('email')} type="email" - placeholder="you@example.com" + placeholder={t('emailPlaceholder')} error={errors.email?.message} icon={} /> @@ -103,7 +103,7 @@ export default function LoginPage() { label={t('password')} {...register('password')} type="password" - placeholder="••••••••" + placeholder={t('passwordPlaceholder')} error={errors.password?.message} icon={} /> diff --git a/frontend/src/app/[locale]/(public)/register/page.tsx b/frontend/src/app/[locale]/(public)/register/page.tsx index 2923e8d..6523dd2 100644 --- a/frontend/src/app/[locale]/(public)/register/page.tsx +++ b/frontend/src/app/[locale]/(public)/register/page.tsx @@ -145,7 +145,7 @@ export default function RegisterPage() { } /> @@ -153,7 +153,7 @@ export default function RegisterPage() { label={t('email')} {...register('email')} type="email" - placeholder="you@example.com" + placeholder={t('emailPlaceholder')} error={errors.email?.message} icon={} /> @@ -161,7 +161,7 @@ export default function RegisterPage() { label={t('password')} {...register('password')} type="password" - placeholder="••••••••" + placeholder={t('passwordPlaceholder')} error={errors.password?.message} icon={} /> @@ -169,7 +169,7 @@ export default function RegisterPage() { label={t('confirmPassword')} {...register('confirmPassword')} type="password" - placeholder="••••••••" + placeholder={t('passwordPlaceholder')} error={errors.confirmPassword?.message} icon={} /> @@ -205,11 +205,11 @@ export default function RegisterPage() {

- By signing up, you agree to our{' '} + {t('termsIntro')}{' '} {t('termsOfService')} {' '} - and{' '} + {tCommon('and')}{' '} {t('privacyPolicy')} diff --git a/frontend/src/components/staff/StaffWorkingHoursStep.tsx b/frontend/src/components/staff/StaffWorkingHoursStep.tsx index 069f653..e972af7 100644 --- a/frontend/src/components/staff/StaffWorkingHoursStep.tsx +++ b/frontend/src/components/staff/StaffWorkingHoursStep.tsx @@ -1,6 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; import { WorkingHoursEditor } from '@/components/staff/WorkingHoursEditor'; import { blocksFromEditorDays, @@ -27,18 +28,17 @@ export function StaffWorkingHoursStep({ onValidationChange, disabled, }: StaffWorkingHoursStepProps) { + const t = useTranslations('staff.workingHours'); + useEffect(() => { - onValidationChange?.(validateEditorDays(days)); - }, [days, onValidationChange]); + onValidationChange?.(validateEditorDays(days, t)); + }, [days, onValidationChange, t]); return (

-

Working hours recommended

-

- Staff with treatment edit access appear as provider columns in Appointments. Set their - weekly hours so the schedule grid shows the right bookable times. -

+

{t('recommendedTitle')}

+

{t('recommendedBody')}

) { onDaysChange( days.map((day) => (day.dayOfWeek === dayOfWeek ? { ...day, ...patch } : day)), @@ -71,10 +74,7 @@ export function WorkingHoursEditor({ return (
-

- Set weekly working hours for this provider. The appointments grid uses these hours to show - bookable time slots. -

+

{t('intro')}

{days.map((day) => ( @@ -84,12 +84,12 @@ export function WorkingHoursEditor({ >
- {WEEKDAY_LABELS[day.dayOfWeek]} + {t(WEEKDAY_KEYS[day.dayOfWeek])} { updateDay(day.dayOfWeek, { isWorking: checked, @@ -108,7 +108,7 @@ export function WorkingHoursEditor({ {day.shifts.map((shift, shiftIndex) => (
- +
- + removeShift(day.dayOfWeek, shiftIndex)} > @@ -152,7 +152,7 @@ export function WorkingHoursEditor({ onClick={() => addShift(day.dayOfWeek)} > - Add shift + {t('addShift')}
)} @@ -163,7 +163,7 @@ export function WorkingHoursEditor({
diff --git a/frontend/src/components/staff/staff-permission-form.ts b/frontend/src/components/staff/staff-permission-form.ts index 0ec4a32..a14ff02 100644 --- a/frontend/src/components/staff/staff-permission-form.ts +++ b/frontend/src/components/staff/staff-permission-form.ts @@ -4,27 +4,30 @@ */ export const STAFF_FEATURE_GROUPS = [ - { label: 'Today', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT' }, - { label: 'Staff', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT' }, - { label: 'Organizations', read: 'TAB_ORGANIZATIONS_READ', edit: 'TAB_ORGANIZATIONS_EDIT' }, - { label: 'Patients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT' }, - { label: 'Appointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' }, - { label: 'Treatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT' }, - { label: 'Billing', read: 'TAB_BILLING_READ', edit: 'TAB_BILLING_EDIT' }, - { label: 'Reports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT' }, + { labelKey: 'featureToday', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT' }, + { labelKey: 'featureStaff', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT' }, + { labelKey: 'featureOrganizations', read: 'TAB_ORGANIZATIONS_READ', edit: 'TAB_ORGANIZATIONS_EDIT' }, + { labelKey: 'featurePatients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT' }, + { labelKey: 'featureAppointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' }, + { labelKey: 'featureTreatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT' }, + { labelKey: 'featureBilling', read: 'TAB_BILLING_READ', edit: 'TAB_BILLING_EDIT' }, + { labelKey: 'featureReports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT' }, ] as const; export type FeaturePermState = Record; export type OrgType = 'CLINIC' | 'LAB' | null | undefined; +type StaffFeaturesTranslate = (key: string) => string; + export function resolveStaffFeatureLabel( group: (typeof STAFF_FEATURE_GROUPS)[number], organizationType: OrgType, + t: StaffFeaturesTranslate, ): string { if (group.read === 'TAB_ORGANIZATIONS_READ') { - return organizationType === 'LAB' ? 'Clinics' : 'Labs'; + return organizationType === 'LAB' ? t('featureClinics') : t('featureLabs'); } - return group.label; + return t(group.labelKey); } export function emptyFeaturePermissionState(): FeaturePermState { @@ -64,17 +67,18 @@ export function featureStateHasTreatmentEdit(state: FeaturePermState): boolean { /** Human-readable access for the team table — feature name, or "Feature (Read only)" */ export function formatAccessSummary( permissionNames: string[] | null | undefined, - organizationType?: OrgType, + organizationType: OrgType, + t: StaffFeaturesTranslate, ): string { - if (!permissionNames?.length) return 'No tab access'; + if (!permissionNames?.length) return t('noTabAccess'); const set = new Set(permissionNames); const parts: string[] = []; for (const g of STAFF_FEATURE_GROUPS) { const hasEdit = set.has(g.edit); const hasRead = set.has(g.read) || hasEdit; if (!hasRead) continue; - const label = resolveStaffFeatureLabel(g, organizationType); - parts.push(hasEdit ? label : `${label} (Read only)`); + const label = resolveStaffFeatureLabel(g, organizationType, t); + parts.push(hasEdit ? label : `${label} ${t('readOnlySuffix')}`); } - return parts.length ? parts.join(' · ') : 'No tab access'; + return parts.length ? parts.join(' · ') : t('noTabAccess'); } diff --git a/frontend/src/components/staff/workingHours.ts b/frontend/src/components/staff/workingHours.ts index 87b7af7..f4f646c 100644 --- a/frontend/src/components/staff/workingHours.ts +++ b/frontend/src/components/staff/workingHours.ts @@ -1,7 +1,17 @@ export const MINUTES_PER_DAY = 24 * 60; export const SCHEDULE_SLOT_MINUTES = 15; -export const WEEKDAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as const; +export const WEEKDAY_KEYS = [ + 'weekdayMon', + 'weekdayTue', + 'weekdayWed', + 'weekdayThu', + 'weekdayFri', + 'weekdaySat', + 'weekdaySun', +] as const; + +type WorkingHoursTranslate = (key: string, values?: { day: string }) => string; export type WorkingHoursBlock = { dayOfWeek: number; @@ -46,7 +56,7 @@ export function formatMinuteLabel(minute: number): string { } export function emptyWorkingHoursEditorDays(): WorkingHoursEditorDay[] { - return WEEKDAY_LABELS.map((_, dayOfWeek) => ({ + return WEEKDAY_KEYS.map((_, dayOfWeek) => ({ dayOfWeek, isWorking: false, shifts: [{ startMinute: 9 * 60, endMinute: 17 * 60 }], @@ -61,7 +71,7 @@ export function editorDaysFromBlocks(blocks: WorkingHoursBlock[]): WorkingHoursE byDay.set(block.dayOfWeek, list); } - return WEEKDAY_LABELS.map((_, dayOfWeek) => { + return WEEKDAY_KEYS.map((_, dayOfWeek) => { const shifts = (byDay.get(dayOfWeek) ?? []).sort( (a, b) => a.startMinute - b.startMinute || a.endMinute - b.endMinute, ); @@ -89,21 +99,25 @@ export function blocksFromEditorDays(days: WorkingHoursEditorDay[]): WorkingHour return blocks; } -export function validateEditorDays(days: WorkingHoursEditorDay[]): string | null { +export function validateEditorDays( + days: WorkingHoursEditorDay[], + t: WorkingHoursTranslate, +): string | null { for (const day of days) { if (!day.isWorking) continue; + const dayLabel = t(WEEKDAY_KEYS[day.dayOfWeek]); if (day.shifts.length === 0) { - return `${WEEKDAY_LABELS[day.dayOfWeek]} needs at least one shift or should be marked off.`; + return t('validationNeedsShift', { day: dayLabel }); } const sorted = [...day.shifts].sort((a, b) => a.startMinute - b.startMinute); for (const shift of sorted) { if (shift.endMinute <= shift.startMinute) { - return `${WEEKDAY_LABELS[day.dayOfWeek]} shift end time must be after start time.`; + return t('validationEndAfterStart', { day: dayLabel }); } } for (let i = 1; i < sorted.length; i += 1) { if (sorted[i].startMinute < sorted[i - 1].endMinute) { - return `${WEEKDAY_LABELS[day.dayOfWeek]} shifts cannot overlap.`; + return t('validationOverlap', { day: dayLabel }); } } } diff --git a/frontend/src/components/treatment/caseSendLabel.ts b/frontend/src/components/treatment/caseSendLabel.ts index 029dc3e..c2856c9 100644 --- a/frontend/src/components/treatment/caseSendLabel.ts +++ b/frontend/src/components/treatment/caseSendLabel.ts @@ -1,17 +1,23 @@ import type { LinkedOrganizationOption, TreatmentCaseSendInfo } from '@/types/treatment'; +export type CaseSendLabelT = ( + key: 'sentToAt' | 'fallbackOrgName', + values?: { orgName: string; datetime: string }, +) => string; + export function formatCaseSentLines( sends: TreatmentCaseSendInfo[] | undefined, - fallback?: { + fallback: { organizationIds: string[]; sentAt: string | null; orgs?: LinkedOrganizationOption[]; - }, + } | undefined, + t: CaseSendLabelT, ): string[] { if (sends?.length) { return sends.map((s) => { const at = new Date(s.sentAt).toLocaleString(); - return `Sent to ${s.organizationName} at ${at}`; + return t('sentToAt', { orgName: s.organizationName, datetime: at }); }); } @@ -19,8 +25,8 @@ export function formatCaseSentLines( const at = new Date(fallback.sentAt).toLocaleString(); const nameById = new Map(fallback.orgs?.map((o) => [o.id, o.name]) ?? []); return fallback.organizationIds.map((id) => { - const name = nameById.get(id) ?? 'organization'; - return `Sent to ${name} at ${at}`; + const name = nameById.get(id) ?? t('fallbackOrgName'); + return t('sentToAt', { orgName: name, datetime: at }); }); } @@ -29,12 +35,13 @@ export function formatCaseSentLines( export function formatCaseSentSummary( sends: TreatmentCaseSendInfo[] | undefined, - fallback?: { + fallback: { organizationIds: string[]; sentAt: string | null; orgs?: LinkedOrganizationOption[]; - }, + } | undefined, + t: CaseSendLabelT, ): string | null { - const lines = formatCaseSentLines(sends, fallback); + const lines = formatCaseSentLines(sends, fallback, t); return lines.length > 0 ? lines.join(' · ') : null; } diff --git a/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx b/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx index 16b61d0..0d38723 100644 --- a/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx +++ b/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx @@ -1,11 +1,13 @@ 'use client'; import { useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; import { Button } from '@/components/ui/shared/Button'; import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; import { Dropdown } from '@/components/ui/shared/Dropdown'; import type { AppointmentPurpose, AppointmentRecord } from '@/types/appointment'; -import { APPOINTMENT_PURPOSE_LABEL } from '@/components/ui/appointments/appointmentPurposeStyles'; +import { APPOINTMENT_PURPOSES } from '@/types/appointment'; +import { getPurposeLabel } from '@/components/ui/appointments/appointmentPurposeStyles'; import type { Patient } from '@/types/patient'; import { combineLocalDateAndTime, @@ -36,6 +38,14 @@ interface AppointmentBookingModalProps { deleting?: boolean; } +const PURPOSE_OPTION_COLORS: Record = { + consultation: '#ddd6fe', + filling: '#fed7aa', + endo: '#fecaca', + visit: '#bae6fd', + hygiene: '#d9f99d', +}; + export function AppointmentBookingModal({ open, scheduleDate, @@ -51,20 +61,15 @@ export function AppointmentBookingModal({ onDelete, deleting = false, }: AppointmentBookingModalProps) { + const t = useTranslations('appointments'); + const tCommon = useTranslations('common'); + const tPatients = useTranslations('patients'); + const [startTime, setStartTime] = useState('09:00'); const [endTime, setEndTime] = useState('10:00'); const [purpose, setPurpose] = useState('consultation'); const [error, setError] = useState(''); - const purposeTextColor = - purpose === 'consultation' - ? '#ddd6fe' - : purpose === 'filling' - ? '#fed7aa' - : purpose === 'endo' - ? '#fecaca' - : purpose === 'visit' - ? '#bae6fd' - : '#d9f99d'; + const purposeTextColor = PURPOSE_OPTION_COLORS[purpose]; useEffect(() => { if (!open) { @@ -116,7 +121,7 @@ export function AppointmentBookingModal({ return; } if (!editingAppointment && !patient) { - setError('Select a patient first.'); + setError(t('errorSelectPatient')); return; } @@ -124,26 +129,26 @@ export function AppointmentBookingModal({ const endAt = combineLocalDateAndTime(scheduleDate, endTime); if (endAt <= startAt) { - setError('End time must be after start time.'); + setError(t('errorEndAfterStart')); return; } const now = new Date(); if (isSameLocalCalendarDay(scheduleDate, now) && startAt.getTime() < now.getTime()) { - setError('Cannot schedule in the past.'); + setError(t('errorPastSchedule')); return; } const today = new Date(); if (compareLocalDayStart(scheduleDate, today) < 0) { - setError('Past appointments are view-only.'); + setError(t('errorPastViewOnly')); return; } const effectivePatientId = editingAppointment?.patientId ?? patient?.id; const effectiveProviderId = editingAppointment?.providerUserId ?? providerUserId; if (!effectivePatientId || !effectiveProviderId) { - setError('Missing appointment details.'); + setError(t('errorMissingDetails')); return; } @@ -166,29 +171,34 @@ export function AppointmentBookingModal({ >

- {editingAppointment ? 'Edit appointment' : 'New appointment'} + {editingAppointment ? t('editTitle') : t('newTitle')}

- Provider: {providerName} + {t('providerLabel')}{' '} + {providerName}

- +

{editingAppointment ? `${editingAppointment.patient.firstName} ${editingAppointment.patient.lastName}` : patient ? `${patient.firstName} ${patient.lastName}` - : '—'} + : tPatients('emptyValue')}

- +
- + setPurpose(e.target.value as AppointmentPurpose)} style={{ color: purposeTextColor }} > - - - - - + {APPOINTMENT_PURPOSES.map((purposeOption) => ( + + ))} {error &&

{error}

} @@ -243,14 +249,14 @@ export function AppointmentBookingModal({ disabled={loading || deleting} isLoading={deleting} > - Delete + {tCommon('delete')} ) : ( )}
diff --git a/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx b/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx index 49cd6ad..5d18b14 100644 --- a/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx +++ b/frontend/src/components/ui/appointments/AppointmentOverlapPopover.tsx @@ -1,12 +1,13 @@ 'use client'; import { useEffect, useRef } from 'react'; +import { useTranslations } from 'next-intl'; import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; import { - APPOINTMENT_PURPOSE_LABEL, + getPurposeLabel, purposeStyle, } from '@/components/ui/appointments/appointmentPurposeStyles'; -import type { AppointmentRecord } from '@/types/appointment'; +import type { AppointmentPurpose, AppointmentRecord } from '@/types/appointment'; type AppointmentOverlapPopoverProps = { appointments: AppointmentRecord[]; @@ -28,6 +29,7 @@ export function AppointmentOverlapPopover({ onSelect, onClose, }: AppointmentOverlapPopoverProps) { + const t = useTranslations('appointments'); const panelRef = useRef(null); useEffect(() => { @@ -75,13 +77,13 @@ export function AppointmentOverlapPopover({ >

- Overlapping appointments ({sorted.length}) + {t('overlappingTitle', { count: sorted.length })}

    {sorted.map((apt) => { - const purpose = apt.purpose as keyof typeof APPOINTMENT_PURPOSE_LABEL; + const purpose = apt.purpose as AppointmentPurpose; return (
  • diff --git a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx index 4a3a0d5..2f90b9d 100644 --- a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx +++ b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx @@ -1,6 +1,7 @@ 'use client'; import { useMemo, useState } from 'react'; +import { useTranslations } from 'next-intl'; import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment'; import { SCHEDULE_SLOT_MINUTES, @@ -91,6 +92,7 @@ export function AppointmentScheduleGrid({ onAppointmentClick, onAppointmentOutsideHours, }: AppointmentScheduleGridProps) { + const t = useTranslations('appointments'); const [overlapPopover, setOverlapPopover] = useState(null); const visibleRange = useMemo(() => { @@ -159,18 +161,13 @@ export function AppointmentScheduleGrid({ if (providers.length === 0) { return ( -
    - No providers available. Add staff with treatment edit access to see columns here. -
    +
    {t('noProviders')}
    ); } if (!snappedRange || slotStarts.length === 0) { return ( -
    - No working hours are configured for this day. Set provider working hours in Staff - management. -
    +
    {t('noWorkingHours')}
    ); } @@ -188,12 +185,12 @@ export function AppointmentScheduleGrid({ {p.name} {!p.hasWorkingHours && ( - No hours set + {t('noHoursSet')} )} {p.hasWorkingHours && p.dayBlocks.length === 0 && ( - Off today + {t('offToday')} )}
@@ -252,13 +249,15 @@ export function AppointmentScheduleGrid({ title={ columnFullyDisabled ? p.hasWorkingHours - ? 'Provider is off today' - : 'Working hours not configured' + ? t('slotOffToday') + : t('slotHoursNotConfigured') : !slotActive - ? 'Outside working hours' + ? t('slotOutsideHours') : slotDisabled - ? 'You cannot create appointments' - : `Book ${formatMinuteLabel(slotStartMinute)}` + ? t('slotCannotCreate') + : t('slotBookAt', { + time: formatMinuteLabel(slotStartMinute), + }) } className={`absolute left-0 right-0 border-b border-border/50 transition-colors ${ slotDisabled @@ -297,8 +296,10 @@ export function AppointmentScheduleGrid({ const patientName = `${apt.patient.firstName} ${apt.patient.lastName}`; const bannerTitle = [ patientName, - outsideHours ? 'Outside working hours — editing blocked' : null, - clusterSize > 1 ? `${clusterSize} overlapping — click to choose` : null, + outsideHours ? t('outsideHoursBlocked') : null, + clusterSize > 1 + ? t('overlappingChoose', { count: clusterSize }) + : null, !isUnderOneHour && apt.patient.phone ? apt.patient.phone : null, ] .filter(Boolean) @@ -345,7 +346,7 @@ export function AppointmentScheduleGrid({ )} {!isUnderOneHour && clusterSize > 1 && ( - {clusterSize} overlapping + {t('overlapping', { count: clusterSize })} )} diff --git a/frontend/src/components/ui/appointments/AppointmentScheduleLegend.tsx b/frontend/src/components/ui/appointments/AppointmentScheduleLegend.tsx index 6e36de3..8ab970f 100644 --- a/frontend/src/components/ui/appointments/AppointmentScheduleLegend.tsx +++ b/frontend/src/components/ui/appointments/AppointmentScheduleLegend.tsx @@ -1,20 +1,25 @@ +'use client'; + +import { useTranslations } from 'next-intl'; import { - APPOINTMENT_PURPOSE_LABEL, APPOINTMENT_PURPOSE_LEGEND_SWATCH, + getPurposeLabel, } from '@/components/ui/appointments/appointmentPurposeStyles'; import { APPOINTMENT_PURPOSES } from '@/types/appointment'; export function AppointmentScheduleLegend() { + const t = useTranslations('appointments'); + return (
-

Legend

+

{t('legend')}

{APPOINTMENT_PURPOSES.map((p) => (
- {APPOINTMENT_PURPOSE_LABEL[p]} + {getPurposeLabel(p, t)}
))}
diff --git a/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx b/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx index e0817fd..5dce8a1 100644 --- a/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx +++ b/frontend/src/components/ui/appointments/AppointmentsPatientSearch.tsx @@ -1,6 +1,7 @@ 'use client'; import { Search } from 'lucide-react'; +import { useTranslations } from 'next-intl'; import { Button } from '@/components/ui/shared/Button'; import { Input } from '@/components/ui/shared/Input'; import type { Patient } from '@/types/patient'; @@ -26,6 +27,9 @@ export function AppointmentsPatientSearch({ canAddPatient, onAddPatient, }: AppointmentsPatientSearchProps) { + const t = useTranslations('appointments'); + const tPatients = useTranslations('patients'); + const trimmed = search.trim(); const showAddForEmptyResults = trimmed.length > 0 && !loading && patients.length === 0; @@ -35,7 +39,7 @@ export function AppointmentsPatientSearch({
onSearchChange(e.target.value)} icon={} @@ -47,18 +51,18 @@ export function AppointmentsPatientSearch({ variant="primary" disabled={!canAddPatient} onClick={onAddPatient} - title={!canAddPatient ? 'You do not have permission to add patients.' : undefined} + title={!canAddPatient ? t('noPermissionAdd') : undefined} > - New Patient + {tPatients('newPatient')} )}
- {loading &&

Searching…

} + {loading &&

{t('searching')}

} {!loading && trimmed.length === 0 && ( -

Type to search patients by name, phone, or email.

+

{t('searchHint')}

)} {patients.map((patient) => { @@ -77,7 +81,9 @@ export function AppointmentsPatientSearch({

{patient.firstName} {patient.lastName}

-

{patient.phone || patient.email || 'No contact'}

+

+ {patient.phone || patient.email || tPatients('noContact')} +

); })} diff --git a/frontend/src/components/ui/appointments/appointmentPurposeStyles.ts b/frontend/src/components/ui/appointments/appointmentPurposeStyles.ts index c0803ba..813befa 100644 --- a/frontend/src/components/ui/appointments/appointmentPurposeStyles.ts +++ b/frontend/src/components/ui/appointments/appointmentPurposeStyles.ts @@ -1,12 +1,25 @@ import type { AppointmentPurpose } from '@/types/appointment'; -export const APPOINTMENT_PURPOSE_LABEL: Record = { - consultation: 'Consultation', - filling: 'Filling', - endo: 'Endo', - visit: 'Visit', - hygiene: 'Hygiene', -}; +export const APPOINTMENT_PURPOSE_LABEL_KEYS = { + consultation: 'purposeConsultation', + filling: 'purposeFilling', + endo: 'purposeEndo', + visit: 'purposeVisit', + hygiene: 'purposeHygiene', +} as const satisfies Record; + +export type AppointmentPurposeLabelKey = + (typeof APPOINTMENT_PURPOSE_LABEL_KEYS)[AppointmentPurpose]; + +export type AppointmentPurposeTranslate = (key: AppointmentPurposeLabelKey) => string; + +export function getPurposeLabel( + purpose: AppointmentPurpose, + t: AppointmentPurposeTranslate, +): string { + const key = APPOINTMENT_PURPOSE_LABEL_KEYS[purpose]; + return key ? t(key) : purpose; +} /** Background + border for blocks / legend (matches reference palette). */ export const APPOINTMENT_PURPOSE_STYLES: Record = { diff --git a/frontend/src/components/ui/auth/OrganizationDetailsFields.tsx b/frontend/src/components/ui/auth/OrganizationDetailsFields.tsx index d4fd1de..4b9fa1e 100644 --- a/frontend/src/components/ui/auth/OrganizationDetailsFields.tsx +++ b/frontend/src/components/ui/auth/OrganizationDetailsFields.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useTranslations } from 'next-intl'; import { Building2, Mail } from 'lucide-react'; import type { FieldErrors, UseFormRegister, UseFormSetValue } from 'react-hook-form'; import { Input } from '@/components/ui/shared/Input'; @@ -23,26 +24,28 @@ export function OrganizationDetailsFields({ organizationType, setValue, }: OrganizationDetailsFieldsProps) { + const t = useTranslations('auth'); + return ( <> } /> } />
@@ -58,7 +61,7 @@ export function OrganizationDetailsFields({ }`} > - Dental Clinic + {t('dentalClinic')}
{errors.organizationType && ( diff --git a/frontend/src/components/ui/auth/RegistrationProgressSteps.tsx b/frontend/src/components/ui/auth/RegistrationProgressSteps.tsx index 16d91de..a4ee8f1 100644 --- a/frontend/src/components/ui/auth/RegistrationProgressSteps.tsx +++ b/frontend/src/components/ui/auth/RegistrationProgressSteps.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useTranslations } from 'next-intl'; import { ChevronRight } from 'lucide-react'; type RegistrationProgressStepsProps = { @@ -10,9 +11,11 @@ type RegistrationProgressStepsProps = { export function RegistrationProgressSteps({ step, - firstLabel = 'Account', - secondLabel = 'Organization', + firstLabel, + secondLabel, }: RegistrationProgressStepsProps) { + const t = useTranslations('auth'); + return (
@@ -31,7 +34,7 @@ export function RegistrationProgressSteps({ step >= 1 ? 'text-primary' : 'text-text-muted' }`} > - {firstLabel} + {firstLabel ?? t('stepAccount')}
@@ -50,7 +53,7 @@ export function RegistrationProgressSteps({ step >= 2 ? 'text-primary' : 'text-text-muted' }`} > - {secondLabel} + {secondLabel ?? t('stepOrganization')}
diff --git a/frontend/src/components/ui/organizations/CopyInvitationLinkButton.tsx b/frontend/src/components/ui/organizations/CopyInvitationLinkButton.tsx index a8df1c8..734f000 100644 --- a/frontend/src/components/ui/organizations/CopyInvitationLinkButton.tsx +++ b/frontend/src/components/ui/organizations/CopyInvitationLinkButton.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useTranslations } from 'next-intl'; import { Check, Copy } from 'lucide-react'; import { canShareOrganizationInviteLink, @@ -19,6 +20,8 @@ export function CopyInvitationLinkButton({ copying, onCopy, }: CopyInvitationLinkButtonProps) { + const t = useTranslations('organizations'); + if (!canShareOrganizationInviteLink(invitation)) { return ; } @@ -29,8 +32,8 @@ export function CopyInvitationLinkButton({ className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:opacity-50" disabled={copying} onClick={onCopy} - aria-label="Copy invitation link" - title="Copy invitation link (generates a new link if needed)" + aria-label={t('copyInvitationLink')} + title={t('copyInvitationLinkTitle')} > {copied ? : } diff --git a/frontend/src/components/ui/organizations/InvitationHistoryDialog.tsx b/frontend/src/components/ui/organizations/InvitationHistoryDialog.tsx index 10cd2bc..4bf5af4 100644 --- a/frontend/src/components/ui/organizations/InvitationHistoryDialog.tsx +++ b/frontend/src/components/ui/organizations/InvitationHistoryDialog.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useTranslations } from 'next-intl'; import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; import { ToastStack, type ToastMessages } from '@/components/ui/shared/Toast'; import type { OrganizationInvitationHistoryItemDto } from '@/lib/api/organization'; @@ -7,14 +8,6 @@ import { Badge, organizationConnectionStatusVariant } from '@/components/ui/shar import { Table } from '@/components/ui/shared/Table'; import { CopyInvitationLinkButton } from '@/components/ui/organizations/CopyInvitationLinkButton'; -function formatInvitationStatusLabel(status: OrganizationInvitationHistoryItemDto['status']): string { - if (status === 'PENDING') return 'Invitation pending'; - if (status === 'ACTIVE') return 'Invitation accepted'; - if (status === 'REJECTED') return 'Invitation rejected'; - if (status === 'EXPIRED') return 'Invitation expired'; - return status; -} - function formatTableDate(value: string): string { const d = new Date(value); if (Number.isNaN(d.getTime())) return '—'; @@ -43,6 +36,18 @@ export function InvitationHistoryDialog({ onCopy, toastMessages, }: InvitationHistoryDialogProps) { + const t = useTranslations('organizations'); + + function formatInvitationStatusLabel( + status: OrganizationInvitationHistoryItemDto['status'], + ): string { + if (status === 'PENDING') return t('statusPending'); + if (status === 'ACTIVE') return t('statusAccepted'); + if (status === 'REJECTED') return t('statusRejected'); + if (status === 'EXPIRED') return t('statusExpired'); + return status; + } + if (!open) return null; return ( @@ -55,7 +60,7 @@ export function InvitationHistoryDialog({ >

- Invitation History + {t('historyTitle')}

@@ -63,27 +68,27 @@ export function InvitationHistoryDialog({ {toastMessages && } {loading ? ( -

Loading invitation history...

+

{t('loadingHistory')}

) : items.length === 0 ? ( -

No invitations yet.

+

{t('historyEmpty')}

) : (
NameEmailRoleStatusAccess{t('tableName')}{t('tableEmail')}{t('tableRole')}{t('tableStatus')}{t('tableAccess')} - Action + {t('tableAction')}
{m.email} {m.isOwner ? ( - Owner + {t('roleOwner')} ) : ( - Staff + {t('roleStaff')} )} {m.isOwner || m.invitationStatus === 'ACTIVE' ? ( - Active + {t('statusActive')} ) : m.invitationStatus === 'PENDING' ? ( - Pending + {t('statusPending')} ) : m.invitationStatus === 'DISABLED' ? ( - Disabled + {t('statusDisabled')} ) : ( - Expired + {t('statusExpired')} )} {m.isOwner ? ( - All features + {t('allFeatures')} ) : ( - {formatAccessSummary(m.permissions, currentOrganization?.type)} + {formatAccessSummary(m.permissions, currentOrganization?.type, tFeatures)} )}
} diff --git a/frontend/src/components/ui/organizations/OrganizationSelectorContent.tsx b/frontend/src/components/ui/organizations/OrganizationSelectorContent.tsx index bfe1140..4d1f225 100644 --- a/frontend/src/components/ui/organizations/OrganizationSelectorContent.tsx +++ b/frontend/src/components/ui/organizations/OrganizationSelectorContent.tsx @@ -1,6 +1,7 @@ 'use client'; import { useMemo, useState } from 'react'; +import { useTranslations } from 'next-intl'; import { useAuth } from '@/lib/hooks/useAuth'; import { canCreateOrganizationFromCurrentOrg } from '@/components/shared/permissions'; import { Building2, Beaker, Mail } from 'lucide-react'; @@ -8,6 +9,9 @@ import { Input } from '@/components/ui/shared/Input'; import { Button } from '@/components/ui/shared/Button'; export function OrganizationSelectorContent() { + const t = useTranslations('organizations'); + const tAuth = useTranslations('auth'); + const tCommon = useTranslations('common'); const { organizations, currentOrganization, @@ -29,6 +33,9 @@ export function OrganizationSelectorContent() { const getIcon = (type: string) => type === 'CLINIC' ? : ; + const getTypeLabel = (type: string) => + type === 'CLINIC' ? tAuth('dentalClinic') : tAuth('dentalLab'); + const handleCreateOrganization = async () => { try { clearError(); @@ -48,18 +55,16 @@ export function OrganizationSelectorContent() { }; if (isLoading) { - return

Loading...

; + return

{tCommon('loadingEllipsis')}

; } return (
-

Organizations

+

{t('selectorTitle')}

- {canCreateOrganization - ? 'Select an organization to continue, or create a new one.' - : 'Select an organization to continue.'} + {canCreateOrganization ? t('selectorSubtitleWithCreate') : t('selectorSubtitleSelectOnly')}

{canCreateOrganization && ( @@ -71,7 +76,7 @@ export function OrganizationSelectorContent() { setIsCreateOpen((prev) => !prev); }} > - {isCreateOpen ? 'Cancel' : 'Create Organization'} + {isCreateOpen ? tCommon('cancel') : t('createOrganization')} )}
@@ -79,23 +84,23 @@ export function OrganizationSelectorContent() { {canCreateOrganization && isCreateOpen && (
setOrganizationName(event.target.value)} - placeholder="Sunshine Dental Clinic" + placeholder={tAuth('organizationNamePlaceholder')} icon={} /> setOrganizationEmail(event.target.value)} - placeholder="contact@sunshineclinic.com" + placeholder={tAuth('organizationEmailPlaceholder')} type="email" icon={} />
@@ -135,7 +140,7 @@ export function OrganizationSelectorContent() { isLoading={isLoading} disabled={!organizationName.trim() || !organizationEmail.trim()} > - Create and Continue + {t('createAndContinue')}
@@ -144,9 +149,7 @@ export function OrganizationSelectorContent() { {!organizations.length ? (

- {canCreateOrganization - ? 'No organizations found. Create your first one to continue.' - : 'No organizations found. Ask an organization owner to invite you.'} + {canCreateOrganization ? t('emptyCanCreate') : t('emptyAskOwner')}

) : ( @@ -166,12 +169,12 @@ export function OrganizationSelectorContent() { {org.name}

- {org.type === 'CLINIC' ? 'Dental Clinic' : 'Dental Lab'} + {getTypeLabel(org.type)}

- Continue → + {t('continueArrow')}
))} diff --git a/frontend/src/components/ui/patient/CreatePatientModal.tsx b/frontend/src/components/ui/patient/CreatePatientModal.tsx index 2a52439..e6f3e33 100644 --- a/frontend/src/components/ui/patient/CreatePatientModal.tsx +++ b/frontend/src/components/ui/patient/CreatePatientModal.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useTranslations } from 'next-intl'; import { Button } from '@/components/ui/shared/Button'; import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; import { Input } from '@/components/ui/shared/Input'; @@ -31,26 +32,29 @@ function CreatePatientFormFields({ loading: boolean; showCancel: boolean; }) { + const t = useTranslations('patients'); + const tCommon = useTranslations('common'); + return ( <>
onChange({ firstName: e.target.value })} /> onChange({ lastName: e.target.value })} /> onChange({ phone: e.target.value })} /> onChange({ email: e.target.value })} @@ -64,11 +68,11 @@ function CreatePatientFormFields({ isLoading={loading} disabled={!formData.firstName || !formData.lastName} > - Save Patient + {t('savePatient')} {showCancel && ( )}
@@ -85,6 +89,8 @@ export function CreatePatientModal({ loading = false, variant = 'inline', }: CreatePatientModalProps) { + const t = useTranslations('patients'); + if (!isOpen) { return null; } @@ -112,7 +118,7 @@ export function CreatePatientModal({ id="create-patient-dialog-title" className="text-lg font-semibold text-text-primary pr-2" > - New patient + {t('dialogTitle')} diff --git a/frontend/src/components/ui/patient/PatientSearchSelect.tsx b/frontend/src/components/ui/patient/PatientSearchSelect.tsx index 1041080..4f4d707 100644 --- a/frontend/src/components/ui/patient/PatientSearchSelect.tsx +++ b/frontend/src/components/ui/patient/PatientSearchSelect.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useTranslations } from 'next-intl'; import { Search } from 'lucide-react'; import { Input } from '@/components/ui/shared/Input'; import { Patient } from '@/types/patient'; @@ -21,20 +22,22 @@ export function PatientSearchSelect({ onSelectPatient, loading = false, }: PatientSearchSelectProps) { + const t = useTranslations('patients'); + return (
onSearchChange(e.target.value)} icon={} />
- {loading &&

Loading patients...

} + {loading &&

{t('loadingPatients')}

} {!loading && patients.length === 0 && ( -

No patients found for this search.

+

{t('noResults')}

)} {patients.map((patient) => { @@ -53,7 +56,7 @@ export function PatientSearchSelect({

{patient.firstName} {patient.lastName}

-

{patient.phone || patient.email || 'No contact'}

+

{patient.phone || patient.email || t('noContact')}

); })} diff --git a/frontend/src/components/ui/patient/PatientSummaryCard.tsx b/frontend/src/components/ui/patient/PatientSummaryCard.tsx index 1e0d844..fb2e1aa 100644 --- a/frontend/src/components/ui/patient/PatientSummaryCard.tsx +++ b/frontend/src/components/ui/patient/PatientSummaryCard.tsx @@ -1,3 +1,6 @@ +'use client'; + +import { useTranslations } from 'next-intl'; import { Patient } from '@/types/patient'; interface PatientSummaryCardProps { @@ -5,10 +8,12 @@ interface PatientSummaryCardProps { } export function PatientSummaryCard({ patient }: PatientSummaryCardProps) { + const t = useTranslations('patients'); + if (!patient) { return (
-

Select a patient to view details.

+

{t('selectPatient')}

); } @@ -18,10 +23,15 @@ export function PatientSummaryCard({ patient }: PatientSummaryCardProps) {

{patient.firstName} {patient.lastName}

-

Phone: {patient.phone || '-'}

-

Email: {patient.email || '-'}

- Status: {patient.isActive ? 'Active' : 'Inactive'} + {t('phoneLabel')} {patient.phone || t('emptyValue')} +

+

+ {t('emailLabel')} {patient.email || t('emptyValue')} +

+

+ {t('statusLabel')}{' '} + {patient.isActive ? t('statusActive') : t('statusInactive')}

); diff --git a/frontend/src/components/ui/shared/DialogCloseButton.tsx b/frontend/src/components/ui/shared/DialogCloseButton.tsx index 07631d9..cac7e94 100644 --- a/frontend/src/components/ui/shared/DialogCloseButton.tsx +++ b/frontend/src/components/ui/shared/DialogCloseButton.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useTranslations } from 'next-intl'; import { X } from 'lucide-react'; type DialogCloseButtonProps = { @@ -8,12 +9,14 @@ type DialogCloseButtonProps = { }; export function DialogCloseButton({ onClick, className = '' }: DialogCloseButtonProps) { + const t = useTranslations('common'); + return ( diff --git a/frontend/src/components/ui/shared/OrganizationCard.tsx b/frontend/src/components/ui/shared/OrganizationCard.tsx index 2d785f1..68e3e41 100644 --- a/frontend/src/components/ui/shared/OrganizationCard.tsx +++ b/frontend/src/components/ui/shared/OrganizationCard.tsx @@ -1,5 +1,7 @@ -// src/components/ui/OrganizationCard.tsx +'use client'; + import React from 'react'; +import { useTranslations } from 'next-intl'; import { ChevronRight } from 'lucide-react'; import type { Organization } from '@/types/organization'; import { organizationTypeIcon } from '@/components/shared/organizationTypeIcon'; @@ -13,8 +15,11 @@ export const OrganizationCard: React.FC = ({ organization, onSelect, }) => { + const t = useTranslations('organizations'); + const tAuth = useTranslations('auth'); const Icon = organizationTypeIcon(organization.type); - const typeText = organization.type === 'CLINIC' ? 'Dental Clinic' : 'Dental Lab'; + const typeText = + organization.type === 'CLINIC' ? tAuth('dentalClinic') : tAuth('dentalLab'); return (
); -}; \ No newline at end of file +}; diff --git a/frontend/src/components/ui/shared/ScheduleDayPicker.tsx b/frontend/src/components/ui/shared/ScheduleDayPicker.tsx index e91d297..2316703 100644 --- a/frontend/src/components/ui/shared/ScheduleDayPicker.tsx +++ b/frontend/src/components/ui/shared/ScheduleDayPicker.tsx @@ -1,6 +1,7 @@ 'use client'; import { useEffect, useId, useRef, useState } from 'react'; +import { useTranslations } from 'next-intl'; import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react'; import { addCalendarDays, startOfLocalDay } from '@/components/appointments/appointmentTime'; @@ -10,19 +11,19 @@ interface ScheduleDayPickerProps { label?: string; } -const MONTH_LABELS = [ - 'January', - 'February', - 'March', - 'April', - 'May', - 'June', - 'July', - 'August', - 'September', - 'October', - 'November', - 'December', +const MONTH_KEYS = [ + 'monthJanuary', + 'monthFebruary', + 'monthMarch', + 'monthApril', + 'monthMay', + 'monthJune', + 'monthJuly', + 'monthAugust', + 'monthSeptember', + 'monthOctober', + 'monthNovember', + 'monthDecember', ] as const; function daysInMonth(year: number, month: number): number { @@ -55,12 +56,14 @@ const selectClassName = ` * Calendar day navigator (arrows + year/month/day panel). * Does not restrict past dates — parent pages enforce read-only vs editable for schedule grids/forms. */ -export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }: ScheduleDayPickerProps) { +export function ScheduleDayPicker({ value, onChange, label }: ScheduleDayPickerProps) { + const t = useTranslations('schedule'); const panelId = useId(); const rootRef = useRef(null); const [panelOpen, setPanelOpen] = useState(false); const normalizedValue = startOfLocalDay(value); + const resolvedLabel = label ?? t('defaultLabel'); const labelText = normalizedValue.toLocaleDateString(undefined, { weekday: 'short', @@ -108,13 +111,13 @@ export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }: return (
-

{label}

+

{resolvedLabel}

@@ -138,7 +141,7 @@ export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }: type="button" onClick={() => onChange(addCalendarDays(normalizedValue, 1))} className="shrink-0 rounded-[var(--radius-sm)] p-2 text-text-muted hover:text-text-primary hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35" - aria-label="Next day" + aria-label={t('nextDay')} > @@ -148,7 +151,7 @@ export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }:
- Organization + {t('tableOrganization')} - Owner email + {t('tableOwnerEmail')} - Date + {t('tableDate')} - Status + {t('tableStatus')} - Invitation link + {t('tableInvitationLink')}