From eeea47831557199dcb51b2020dad37ab40571d4d Mon Sep 17 00:00:00 2001 From: Ivan Mercedes Date: Tue, 7 Jul 2026 20:50:57 -0400 Subject: [PATCH] feat(i18n): localize settings templates and profile modul --- messages/en.json | 263 ++++++++++++++++++ .../settings/custom-fields-settings.tsx | 10 +- .../settings/fields-and-tags-panel.tsx | 7 +- .../settings/invite-member-dialog.tsx | 94 +++---- src/components/settings/members-tab.tsx | 92 +++--- src/components/settings/password-form.tsx | 29 +- src/components/settings/profile-form.tsx | 62 +++-- src/components/settings/sessions-card.tsx | 23 +- src/components/settings/tag-manager.tsx | 59 ++-- src/components/settings/template-manager.tsx | 204 +++++++------- 10 files changed, 550 insertions(+), 293 deletions(-) diff --git a/messages/en.json b/messages/en.json index d897391..bb441c9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1179,6 +1179,216 @@ "yourAccount": "Your account", "loading": "Loading…" }, + "members": { + "title": "Team members", + "description": "People with access to this account. Roles control what each teammate can do.", + "inviteMember": "Invite member", + "online": "online", + "away": "away", + "offline": "offline", + "memberCount": "{count} {count, plural, =1 {member} other {members}}", + "you": "You", + "unnamed": "Unnamed", + "joined": "Joined {date}", + "remove": "Remove", + "removedToast": "Removed {name}", + "updatedToast": "Updated {name} to {role}", + "pendingInvitations": "Pending invitations", + "inviteHint": "The plaintext invite URL is only shown once at creation for security — to re-share, revoke the invite below and create a new one.", + "noPendingTitle": "No pending invitations.", + "noPendingDesc": "Click Invite member above to generate a shareable link.", + "untitledInvite": "Untitled invite", + "created": "Created {date}", + "expiresInDays": "expires in {days} {days, plural, =1 {day} other {days}}", + "expiresInHours": "expires in {hours} {hours, plural, =1 {hour} other {hours}}", + "expired": "expired", + "revoke": "Revoke", + "revokedToast": "Invitation revoked", + "removeDialogTitle": "Remove member", + "removeDialogDesc": "Remove {name} from the account? They'll be signed out of this account and given a fresh personal account on their next sign-in. Their login isn't deleted.", + "cancel": "Cancel", + "removing": "Removing...", + "removeBtn": "Remove member" + }, + "invite": { + "inviteCreated": "Invite created", + "inviteCreatedDesc": "Share this link with your new teammate. They'll be able to sign up (or sign in) and join the account as {role}. The link is valid for {days} {days, plural, =1 {day} other {days}}.", + "inviteLink": "Invite link", + "copy": "Copy", + "saveLinkNow": "Save this link now.", + "saveLinkHint": "We never store the plaintext — once you close this dialog the URL is gone. To re-share, revoke this invite and create a new one.", + "sendViaWhatsApp": "Send via WhatsApp", + "done": "Done", + "dialogTitle": "Invite a teammate", + "dialogDesc": "Generate a one-time invite link. Share it via WhatsApp, Slack, or any channel you like — no email service required.", + "roleLabel": "Role", + "validForLabel": "Link valid for", + "days1": "1 day", + "days7": "7 days", + "days30": "30 days", + "labelTitle": "Label", + "optional": "(optional)", + "labelPlaceholder": "e.g. Sara — support team", + "labelHint": "Helps you remember who you sent the link to in the pending list below.", + "cancel": "Cancel", + "creating": "Creating...", + "generateLink": "Generate link", + "labelTooLong": "Label must be {max} characters or fewer", + "copied": "Invite link copied", + "clipboardBlocked": "Clipboard blocked — copy the link manually", + "whatsappMessage": "Join {accountName} on wacrm using this link (valid for {expiresInDays} days): {url}" + }, + "tagsAndFields": { + "title": "Fields & tags", + "description": "Two ways to organize contacts: colour-coded tags for quick grouping, and custom fields for structured data.", + "tagsTitle": "Tags", + "tagsDesc": "Colour-coded labels for grouping and filtering contacts.", + "fieldsTitle": "Custom fields", + "fieldsDesc": "Extra contact fields (e.g. ZIP code, lead source). They appear on every contact and in the “Update Contact Field” automation action.", + "adminRole": "Admin", + "failedToLoadTags": "Failed to load tags", + "nameRequired": "Tag name is required", + "notAuthenticated": "Not authenticated", + "tagCreated": "Tag created", + "failedToCreateTag": "Failed to create tag", + "tagDeleted": "Tag deleted", + "failedToDeleteTag": "Failed to delete tag", + "deleteAria": "Delete {name}", + "noTags": "No tags yet — create your first one below.", + "placeholder": "e.g. Newsletter", + "useColor": "Use {color}", + "addTag": "Add tag", + "deleteTag": "Delete tag", + "deleteConfirm": "Delete the tag \"{name}\"? This removes it from all contacts and cannot be undone.", + "cancel": "Cancel", + "deleting": "Deleting...", + "colors": { + "red": "Red", + "orange": "Orange", + "amber": "Amber", + "emerald": "Emerald", + "cyan": "Cyan", + "blue": "Blue", + "violet": "Violet", + "pink": "Pink" + } + }, + "templates": { + "title": "Message templates", + "description": "Create templates and submit them to Meta for approval. Use \"Sync from Meta\" to pull templates approved elsewhere.", + "syncFromMeta": "Sync from Meta", + "syncing": "Syncing…", + "syncTitle": "Pull approved templates from your Meta WhatsApp Business Account", + "newTemplate": "New Template", + "noTemplates": "No templates yet.", + "createFirst": "Create your first message template to get started.", + "editTitle": "Editing triggers Meta re-review — status flips to PENDING.", + "editLabel": "Edit template", + "edit": "Edit", + "resubmitTitle": "Edit the template and resubmit to Meta for review.", + "resubmitLabel": "Edit and resubmit template", + "resubmit": "Resubmit", + "deleteMetaLocallyAria": "Delete template from Meta and locally", + "deleteLocallyAria": "Delete template locally", + "deleteMetaLocallyTitle": "Delete from Meta and locally", + "deleteLocallyTitle": "Delete locally", + + "dialogEditTitle": "Edit Message Template", + "dialogNewTitle": "New Message Template", + "dialogEditDesc": "Save your changes to re-submit to Meta. Status will flip back to PENDING during review.", + "dialogNewDesc": "Build a template and submit it to Meta for approval. Once approved, you can use it in broadcasts and the inbox.", + "authWarning": "AUTHENTICATION templates have a fixed body + OTP button shape that needs a different builder. Create them in Meta WhatsApp Manager for now and use Sync from Meta to bring them in.", + + "templateName": "Template Name", + "namePlaceholder": "e.g. order_confirmation", + "nameFixed": "Name is fixed once a template exists on Meta — create a new template to change it.", + "nameHint": "Lowercase letters, digits, and underscores only.", + "category": "Category", + "language": "Language", + "langFixed": "Language is fixed once a template exists on Meta.", + "langHint": "Must match the exact code on Meta — en_US and en are distinct.", + + "header": "Header", + "headerNone": "None", + "headerText": "Text", + "headerImage": "Image", + "headerVideo": "Video", + "headerDocument": "Document", + "headerTextPlaceholder": "Header text (max 60 chars, optional {{1}})", + "headerSampleAria": "Sample value for header variable", + "headerSamplePlaceholder": "Sample value for {{1}} (required for Meta review)", + + "uploadImage": "Upload image", + "uploadHint": "JPEG or PNG, ≤5 MB", + "mediaUrlPlaceholder": "https://… (or paste a public {format} link)", + "imageHint": "Upload a JPEG/PNG (≤5 MB, ≥800×418 px recommended) or paste a public HTTPS link — we upload it to Meta for review automatically.", + "mediaHint": "Must be a publicly accessible HTTPS link. Meta fetches it once during review, so it needs to stay live for ~24 hrs.", + "videoHint": " Recommended: MP4 / 3GPP, ≤16 MB, ≤60 seconds.", + "documentHint": " Recommended: PDF, ≤100 MB.", + + "bodyText": "Body Text", + "bodyPlaceholder": "Hello {{1}}, your order {{2}} is confirmed.", + "bodyHint": "Use {{1}}, {{2}} for variables (must be contiguous starting at {{1}}).", + "sampleValues": "Sample values (Meta uses these to review your template)", + "sampleAria": "Sample value for body variable {var}", + "samplePlaceholder": "Sample for {var}", + + "footer": "Footer (optional)", + "footerPlaceholder": "Optional footer text (max 60 chars)", + + "buttons": "Buttons (optional)", + "addButton": "Add Button", + "buttonsLimit": "Up to {max} buttons. QUICK_REPLY buttons must come before URL / phone / copy-code buttons.", + "btnQuickReply": "Quick Reply", + "btnUrl": "URL", + "btnPhone": "Phone", + "btnCopyCode": "Copy Code", + "btnLabelPlaceholder": "Button label", + "urlPlaceholder": "https://example.com/path or with {{1}} suffix", + "urlSamplePlaceholder": "Example value for {{1}} (required when URL has a variable)", + "phonePlaceholder": "+15551234567", + "codePlaceholder": "Example code (e.g. SUMMER20)", + + "cancel": "Cancel", + "saving": "Saving…", + "submitting": "Submitting…", + "saveResubmit": "Save & Resubmit", + "submitApproval": "Submit for Approval", + + "deleteDialogTitle": "Delete template?", + "deleteMetaDesc": "\"{name}\" will be deleted from Meta and from wacrm. Active broadcasts using this template will start failing on their next send. This can't be undone.", + "deleteLocalDesc": "\"{name}\" will be deleted from wacrm. It was never submitted to Meta, so no remote cleanup is needed.", + "deleting": "Deleting…", + "delete": "Delete", + + "toastLoadFailed": "Failed to load templates", + "toastSubmitFailed": "Failed to submit", + "toastSubmitEditSuccess": "Edit submitted — Meta typically reviews within 24 hours.", + "toastSubmitNewSuccess": "Submitted to Meta — typical review time is 24 hours. Status updates automatically.", + "toastSaveEditDry": "Template updated (dry-run — no Meta call)", + "toastSaveNewDry": "Template saved (dry-run — no Meta call)", + "toastSyncCount": "Synced {total} {total, plural, =1 {template} other {templates}} from Meta", + "toastSyncDetails": " ({inserted} new, {updated} updated)", + "toastSyncFailed": "Failed to sync: {preview}", + "toastSyncTruncated": "Synced the first 2000 templates only — your account has more. Sync again to continue, or contact support if this persists.", + "toastSyncError": "Failed to sync templates", + "toastDeleteSuccess": "Template deleted", + "toastDeleteError": "Failed to delete template", + "toastInvalidImage": "Header image must be a JPEG or PNG.", + "toastImageTooLarge": "Image is {size} MB — Meta's limit is 5 MB.", + "toastUploadSuccess": "Image uploaded.", + "toastUploadFailed": "Upload failed." + }, + "roles": { + "admin": "Admin", + "adminHint": "Manage members + everything", + "agent": "Agent", + "agentHint": "Use features; no settings", + "viewer": "Viewer", + "viewerHint": "Read-only across the app", + "owner": "Owner", + "ownerHint": "Full control over account and billing" + }, "whatsapp": { "title": "WhatsApp connection", "description": "Connect your Meta WhatsApp Business API. Credentials, webhook, and setup steps all live here.", @@ -1260,6 +1470,59 @@ "groups": { "account": "Account", "workspace": "Workspace" + }, + "profile": { + "title": "Your profile", + "description": "How you show up across the app. Your avatar and name appear in the header, sidebar, and anywhere your teammates see you.", + "changePhoto": "Change photo", + "uploadPhoto": "Upload photo", + "remove": "Remove", + "photoHint": "PNG, JPG, WebP, or GIF. Up to 2 MB.", + "displayName": "Display name", + "email": "Email", + "emailChangeHint": "Check the inbox for {oldEmail} and {newEmail} — both need to confirm before the change takes effect.", + "accountDetails": "Account details", + "role": "Role", + "joined": "Joined", + "userId": "User ID", + "loading": "Loading your profile…", + "saving": "Saving…", + "saveChanges": "Save changes", + "unsupportedImage": "Unsupported image type", + "unsupportedImageDesc": "Use PNG, JPG, WebP, or GIF.", + "imageTooLarge": "Image is too large", + "imageTooLargeDesc": "Maximum 2 MB.", + "nameRequired": "Display name is required", + "invalidEmail": "Enter a valid email address", + "uploadFailed": "Upload failed: {message}", + "saveFailed": "Save failed: {message}", + "profileSaved": "Profile saved", + "emailChangeFailed": "Email change failed: {message}", + "profileSavedEmailCheck": "Profile saved — check your email to confirm the address change", + + "passwordTitle": "Password", + "passwordDesc": "Use at least {min} characters. You will stay signed in on this device after changing it.", + "currentPassword": "Current password", + "newPassword": "New password", + "confirmPassword": "Confirm new password", + "updating": "Updating…", + "updatePassword": "Update password", + "cannotChangeNoEmail": "Cannot change password without a current email", + "passwordTooShort": "Password must be at least {min} characters", + "passwordMismatch": "New password and confirmation do not match", + "currentPasswordIncorrect": "Current password is incorrect", + "passwordUpdateFailed": "Password update failed: {message}", + "passwordUpdated": "Password updated", + + "sessionsTitle": "Active sessions", + "sessionsDesc": "Sign out of every device where you're logged in — including this one. Useful if you lost a laptop or shared your password.", + "signOutAll": "Sign out of all devices", + "signOutConfirmTitle": "Sign out everywhere?", + "signOutConfirmDesc": "Every device logged into this account will be signed out and will need to log in again. You will be redirected to the login page.", + "cancel": "Cancel", + "signingOut": "Signing out…", + "signOutEverywhere": "Sign out everywhere", + "signOutFailed": "Sign-out failed: {message}" } } } diff --git a/src/components/settings/custom-fields-settings.tsx b/src/components/settings/custom-fields-settings.tsx index 48232f5..c78c677 100644 --- a/src/components/settings/custom-fields-settings.tsx +++ b/src/components/settings/custom-fields-settings.tsx @@ -9,6 +9,7 @@ import { CardHeader, CardTitle, } from '@/components/ui/card'; +import { useTranslations } from 'next-intl'; import { CustomFieldsPanel } from '@/components/contacts/custom-fields-manager'; import { SettingsChip } from './settings-chip'; @@ -19,20 +20,21 @@ import { SettingsChip } from './settings-chip'; * `custom_fields` RLS. */ export function CustomFieldsSettings() { + const t = useTranslations('Settings.tagsAndFields'); + return ( - Custom fields + {t('fieldsTitle')} - Admin + {t('adminRole')} - Extra contact fields (e.g. ZIP code, lead source). They appear on - every contact and in the “Update Contact Field” automation action. + {t('fieldsDesc')} diff --git a/src/components/settings/fields-and-tags-panel.tsx b/src/components/settings/fields-and-tags-panel.tsx index 3e7f276..cf482a3 100644 --- a/src/components/settings/fields-and-tags-panel.tsx +++ b/src/components/settings/fields-and-tags-panel.tsx @@ -2,6 +2,8 @@ import { useCan } from '@/hooks/use-can'; +import { useTranslations } from 'next-intl'; + import { CustomFieldsSettings } from './custom-fields-settings'; import { SettingsPanelHead } from './settings-panel-head'; import { TagManager } from './tag-manager'; @@ -14,13 +16,14 @@ import { TagManager } from './tag-manager'; * regardless. */ export function FieldsAndTagsPanel() { + const t = useTranslations('Settings.tagsAndFields'); const canEditSettings = useCan('edit-settings'); return (
{canEditSettings ? : null} diff --git a/src/components/settings/invite-member-dialog.tsx b/src/components/settings/invite-member-dialog.tsx index a767a6d..53541c3 100644 --- a/src/components/settings/invite-member-dialog.tsx +++ b/src/components/settings/invite-member-dialog.tsx @@ -36,6 +36,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { useTranslations } from 'next-intl'; import { useAuth } from '@/hooks/use-auth'; type InviteRole = 'admin' | 'agent' | 'viewer'; @@ -48,19 +49,17 @@ interface InviteMemberDialogProps { onCreated: () => void; } -const EXPIRY_OPTIONS: { value: string; label: string }[] = [ - { value: '1', label: '1 day' }, - { value: '7', label: '7 days' }, - { value: '30', label: '30 days' }, +const EXPIRY_OPTIONS = [ + { value: '1', labelKey: 'days1' }, + { value: '7', labelKey: 'days7' }, + { value: '30', labelKey: 'days30' }, ]; -const ROLE_DESCRIPTIONS: Record = { - admin: - 'Can invite teammates, manage settings, send messages, and edit data.', - agent: - 'Can use the inbox, contacts, broadcasts, automations, and flows. No settings or member access.', - viewer: 'Read-only access across every page. Cannot send or edit anything.', -}; +const ROLE_DESCRIPTIONS = [ + 'adminHint', + 'agentHint', + 'viewerHint', +]; // Server caps label at 80 chars (see src/app/api/account/invitations/route.ts). // Mirror it on the client so we short-circuit before the round-trip @@ -81,6 +80,8 @@ export function InviteMemberDialog({ onOpenChange, onCreated, }: InviteMemberDialogProps) { + const t = useTranslations('Settings.invite'); + const tRoles = useTranslations('Settings.roles'); const { account } = useAuth(); const [role, setRole] = useState('agent'); const [expiry, setExpiry] = useState('7'); @@ -105,7 +106,7 @@ export function InviteMemberDialog({ // net for that path. const trimmedLabel = label.trim(); if (trimmedLabel.length > MAX_LABEL_LEN) { - toast.error(`Label must be ${MAX_LABEL_LEN} characters or fewer`); + toast.error(t('labelTooLong', { max: MAX_LABEL_LEN })); return; } setSubmitting(true); @@ -155,12 +156,12 @@ export function InviteMemberDialog({ if (!result) return; try { await navigator.clipboard.writeText(result.url); - toast.success('Invite link copied'); + toast.success(t('copied')); } catch { // Most likely "not in a secure context" — happens on http:// // local IPs. Surface the link in the toast so the admin can // hand-copy it. - toast.error('Clipboard blocked — copy the link manually'); + toast.error(t('clipboardBlocked')); } } @@ -170,7 +171,7 @@ export function InviteMemberDialog({ // for users in multi-team contexts where "our wacrm account" // wouldn't be enough to disambiguate. const accountName = result?.accountName ?? 'our wacrm account'; - const message = `Join ${accountName} on wacrm using this link (valid for ${result?.expiresInDays} days): ${url}`; + const message = t('whatsappMessage', { accountName, expiresInDays: result?.expiresInDays ?? 0, url }); return `https://wa.me/?text=${encodeURIComponent(message)}`; } @@ -191,22 +192,19 @@ export function InviteMemberDialog({ - Invite created + {t('inviteCreated')} - Share this link with your new teammate. They'll be able - to sign up (or sign in) and join the account as{' '} - {result.role} - . The link is valid for{' '} - - {result.expiresInDays} day{result.expiresInDays === 1 ? '' : 's'} - - . + {t.rich('inviteCreatedDesc', { + role: tRoles(result.role), + days: result.expiresInDays, + bold: (chunks: any) => {chunks} + })}
- +
- Copy + {t('copy')}
@@ -231,11 +229,9 @@ export function InviteMemberDialog({ intro, amber-200 for the body. */}
- Save this link now. + {t('saveLinkNow')} {' '} - We never store the plaintext — once you close this dialog - the URL is gone. To re-share, revoke this invite and create - a new one. + {t('saveLinkHint')}
{/* Anchor styled with `buttonVariants` rather than wrapping @@ -254,7 +250,7 @@ export function InviteMemberDialog({ })} > - Send via WhatsApp + {t('sendViaWhatsApp')}
@@ -263,23 +259,22 @@ export function InviteMemberDialog({ onClick={() => onOpenChange(false)} className="bg-primary hover:bg-primary/90 text-primary-foreground" > - Done + {t('done')} ) : ( <> - Invite a teammate + {t('dialogTitle')} - Generate a one-time invite link. Share it via WhatsApp, - Slack, or any channel you like — no email service required. + {t('dialogDesc')}
- +

- {ROLE_DESCRIPTIONS[role]} + {tRoles(`${role}Hint` as 'adminHint' | 'agentHint' | 'viewerHint')}

- + setLabel(e.target.value)} maxLength={MAX_LABEL_LEN} className="bg-muted border-border text-foreground placeholder:text-muted-foreground" />

- Helps you remember who you sent the link to in the pending - list below. + {t('labelHint')}

@@ -342,7 +336,7 @@ export function InviteMemberDialog({ onClick={() => onOpenChange(false)} className="border-border text-muted-foreground hover:bg-muted" > - Cancel + {t('cancel')} diff --git a/src/components/settings/members-tab.tsx b/src/components/settings/members-tab.tsx index 840e06f..7584440 100644 --- a/src/components/settings/members-tab.tsx +++ b/src/components/settings/members-tab.tsx @@ -62,6 +62,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { useTranslations } from 'next-intl'; import { RequireRole } from '@/components/auth/require-role'; import { useAuth } from '@/hooks/use-auth'; import { usePresence } from '@/hooks/use-presence'; @@ -92,12 +93,11 @@ interface Invitation { expires_at: string; } -// Editable roles in the inline dropdown. Owner is never an option — -// promotions go through the (deferred) Transfer Ownership flow. -const EDITABLE_ROLES: { value: AccountRole; label: string; hint: string }[] = [ - { value: 'admin', label: 'Admin', hint: 'Manage members + everything' }, - { value: 'agent', label: 'Agent', hint: 'Use features; no settings' }, - { value: 'viewer', label: 'Viewer', hint: 'Read-only across the app' }, +// These roles are translated via `useTranslations("Settings.roles")` where they are used. +const EDITABLE_ROLES: { value: AccountRole }[] = [ + { value: 'admin' }, + { value: 'agent' }, + { value: 'viewer' }, ]; // Per-role chip metadata (icon / label / colour) lives in the shared @@ -115,16 +115,18 @@ function fmtDate(iso: string): string { }); } -function fmtExpiresIn(iso: string): string { +function fmtExpiresIn(iso: string, t: any): string { const ms = new Date(iso).getTime() - Date.now(); - if (ms <= 0) return 'expired'; + if (ms <= 0) return t('expired'); const days = Math.floor(ms / (24 * 60 * 60 * 1000)); - if (days >= 1) return `expires in ${days} day${days === 1 ? '' : 's'}`; + if (days >= 1) return t('expiresInDays', { days }); const hours = Math.max(1, Math.floor(ms / (60 * 60 * 1000))); - return `expires in ${hours} hour${hours === 1 ? '' : 's'}`; + return t('expiresInHours', { hours }); } export function MembersTab() { + const t = useTranslations('Settings.members'); + const tRoles = useTranslations('Settings.roles'); const { user, canManageMembers } = useAuth(); const { getPresence, getRow, now } = usePresence(); @@ -211,7 +213,7 @@ export function MembersTab() { toast.error(payload.error || 'Failed to update role'); return; } - toast.success(`Updated ${member.full_name || 'member'} to ${nextRole}`); + toast.success(t('updatedToast', { name: member.full_name || t('unnamed'), role: tRoles(nextRole) })); } catch (err) { // Same revert on network failure. setMembers((prev) => @@ -239,7 +241,7 @@ export function MembersTab() { toast.error(payload.error || 'Failed to remove member'); return; } - toast.success(`Removed ${removingMember.full_name || 'member'}`); + toast.success(t('removedToast', { name: removingMember.full_name || t('unnamed') })); setMembers((prev) => prev.filter((m) => m.user_id !== removingMember.user_id), ); @@ -262,7 +264,7 @@ export function MembersTab() { toast.error(payload.error || 'Failed to revoke invitation'); return; } - toast.success('Invitation revoked'); + toast.success(t('revokedToast')); setInvitations((prev) => prev.filter((i) => i.id !== invite.id)); } catch (err) { console.error('[MembersTab] revoke error:', err); @@ -281,13 +283,13 @@ export function MembersTab() { return (
} @@ -302,18 +304,18 @@ export function MembersTab() {
- {counts.online} online + {counts.online} {t('online')} - {counts.away} away + {counts.away} {t('away')} - {counts.offline} offline + {counts.offline} {t('offline')} - · {members.length} member{members.length === 1 ? '' : 's'} + · {t('memberCount', { count: members.length })}
); @@ -381,11 +383,11 @@ export function MembersTab() {
- {member.full_name || 'Unnamed'} + {member.full_name || t('unnamed')} {isSelf && ( - You + {t('you')} )}
@@ -400,7 +402,7 @@ export function MembersTab() { {/* Joined date stays desktop-only. The mobile row's vertical density makes the joined date noise. */}
- Joined {fmtDate(member.joined_at)} + {t('joined', { date: fmtDate(member.joined_at) })}
{/* Actions cluster. On mobile this is its own row @@ -431,7 +433,7 @@ export function MembersTab() { {EDITABLE_ROLES.map((r) => ( - {r.label} + {tRoles(r.value)} ))} @@ -441,7 +443,7 @@ export function MembersTab() { className={`inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium ${roleMeta.className}`} > - {roleMeta.label} + {tRoles(member.role)} )} @@ -477,7 +479,7 @@ export function MembersTab() {

- Pending invitations + {t('pendingInvitations')}

{invitations.length} @@ -490,9 +492,7 @@ export function MembersTab() { looking for a button) keeps it from feeling like a bug. */} {invitations.length > 0 ? (

- The plaintext invite URL is only shown once at creation - for security — to re-share, revoke the invite below and - create a new one. + {t('inviteHint')}

) : null} @@ -501,12 +501,9 @@ export function MembersTab() {

- No pending invitations. -

-

- Click Invite member{' '} - above to generate a shareable link. + {t('noPendingTitle')}

+

) : ( @@ -524,17 +521,17 @@ export function MembersTab() {

- {inv.label || 'Untitled invite'} + {inv.label || t('untitledInvite')} - {inviteRoleMeta.label} + {tRoles(inv.role)}

- Created {fmtDate(inv.created_at)} · {fmtExpiresIn(inv.expires_at)} + {t('created', { date: fmtDate(inv.created_at) })} · {fmtExpiresIn(inv.expires_at, t)}

@@ -549,7 +546,7 @@ export function MembersTab() { className="border-red-500/40 bg-red-500/10 text-red-300 hover:bg-red-500/20 hover:border-red-500/60 hover:text-red-200" > - Revoke + {t('revoke')} ); @@ -577,16 +574,13 @@ export function MembersTab() { - Remove member + {t('removeDialogTitle')} - Remove{' '} - - {removingMember?.full_name || 'this teammate'} - {' '} - from the account? They'll be signed out of this account - and given a fresh personal account on their next sign-in. Their - login isn't deleted. + {t.rich('removeDialogDesc', { + name: removingMember?.full_name || t('unnamed'), + bold: (chunks: any) => {chunks} + })} @@ -595,7 +589,7 @@ export function MembersTab() { onClick={() => setRemovingMember(null)} className="border-border text-muted-foreground hover:bg-muted" > - Cancel + {t('cancel')} diff --git a/src/components/settings/password-form.tsx b/src/components/settings/password-form.tsx index 6f6513b..3112555 100644 --- a/src/components/settings/password-form.tsx +++ b/src/components/settings/password-form.tsx @@ -16,10 +16,12 @@ import { CardTitle, CardDescription, } from '@/components/ui/card'; +import { useTranslations } from 'next-intl'; const MIN_PASSWORD = 8; export function PasswordForm() { + const t = useTranslations('Settings.profile'); const { profile } = useAuth(); const supabase = createClient(); @@ -32,15 +34,15 @@ export function PasswordForm() { const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!profile?.email) { - toast.error('Cannot change password without a current email'); + toast.error(t('cannotChangeNoEmail')); return; } if (next.length < MIN_PASSWORD) { - setConfirmError(`Password must be at least ${MIN_PASSWORD} characters`); + setConfirmError(t('passwordTooShort', { min: MIN_PASSWORD })); return; } if (next !== confirm) { - setConfirmError('New password and confirmation do not match'); + setConfirmError(t('passwordMismatch')); return; } setConfirmError(null); @@ -56,7 +58,7 @@ export function PasswordForm() { password: current, }); if (signInError) { - toast.error('Current password is incorrect'); + toast.error(t('currentPasswordIncorrect')); return; } @@ -64,14 +66,14 @@ export function PasswordForm() { password: next, }); if (updateError) { - toast.error(`Password update failed: ${updateError.message}`); + toast.error(t('passwordUpdateFailed', { message: updateError.message })); return; } setCurrent(''); setNext(''); setConfirm(''); - toast.success('Password updated'); + toast.success(t('passwordUpdated')); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; toast.error(msg); @@ -85,11 +87,10 @@ export function PasswordForm() { - Password + {t('passwordTitle')} - Use at least {MIN_PASSWORD} characters. You will stay signed in on - this device after changing it. + {t('passwordDesc', { min: MIN_PASSWORD })} @@ -97,7 +98,7 @@ export function PasswordForm() {
- Updating… + {t('updating')} ) : ( - 'Update password' + t('updatePassword') )}
diff --git a/src/components/settings/profile-form.tsx b/src/components/settings/profile-form.tsx index c6f0eae..802a73b 100644 --- a/src/components/settings/profile-form.tsx +++ b/src/components/settings/profile-form.tsx @@ -15,6 +15,7 @@ import { AvatarImage, } from '@/components/ui/avatar'; import { Card, CardContent } from '@/components/ui/card'; +import { useTranslations } from 'next-intl'; import { SettingsPanelHead } from './settings-panel-head'; const MAX_AVATAR_BYTES = 2 * 1024 * 1024; @@ -31,6 +32,7 @@ const ALLOWED_MIME = new Set([ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; export function ProfileForm() { + const t = useTranslations('Settings.profile'); const { user, profile, refreshProfile } = useAuth(); const supabase = createClient(); const fileInputRef = useRef(null); @@ -70,14 +72,14 @@ export function ProfileForm() { if (!file) return; if (!ALLOWED_MIME.has(file.type)) { - toast.error('Unsupported image type', { - description: 'Use PNG, JPG, WebP, or GIF.', + toast.error(t('unsupportedImage'), { + description: t('unsupportedImageDesc'), }); return; } if (file.size > MAX_AVATAR_BYTES) { - toast.error('Image is too large', { - description: 'Maximum 2 MB.', + toast.error(t('imageTooLarge'), { + description: t('imageTooLargeDesc'), }); return; } @@ -101,12 +103,12 @@ export function ProfileForm() { const trimmedName = fullName.trim(); if (!trimmedName) { - toast.error('Display name is required'); + toast.error(t('nameRequired')); return; } const trimmedEmail = email.trim(); if (!EMAIL_RE.test(trimmedEmail)) { - toast.error('Enter a valid email address'); + toast.error(t('invalidEmail')); return; } @@ -127,7 +129,7 @@ export function ProfileForm() { contentType: pendingAvatar.type, }); if (uploadError) { - throw new Error(`Upload failed: ${uploadError.message}`); + throw new Error(t('uploadFailed', { message: uploadError.message })); } const { data: { publicUrl }, @@ -146,7 +148,7 @@ export function ProfileForm() { }) .eq('user_id', user.id); if (updateError) { - throw new Error(`Save failed: ${updateError.message}`); + throw new Error(t('saveFailed', { message: updateError.message })); } // Email change goes through Supabase Auth, which emails a @@ -161,8 +163,8 @@ export function ProfileForm() { }); if (emailError) { // Partial success: name/avatar saved but email didn't. - toast.success('Profile saved'); - toast.error(`Email change failed: ${emailError.message}`); + toast.success(t('profileSaved')); + toast.error(t('emailChangeFailed', { message: emailError.message })); setSaving(false); await refreshProfile(); return; @@ -178,8 +180,8 @@ export function ProfileForm() { toast.success( emailSent - ? 'Profile saved — check your email to confirm the address change' - : 'Profile saved', + ? t('profileSavedEmailCheck') + : t('profileSaved'), ); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; @@ -207,8 +209,8 @@ export function ProfileForm() { return (
@@ -239,7 +241,7 @@ export function ProfileForm() { disabled={saving} > - {currentAvatar ? 'Change photo' : 'Upload photo'} + {currentAvatar ? t('changePhoto') : t('uploadPhoto')} {currentAvatar && ( )}

- PNG, JPG, WebP, or GIF. Up to 2 MB. + {t('photoHint')}

@@ -262,7 +264,7 @@ export function ProfileForm() { {/* Name */}
- Check the inbox for {profile?.email} and{' '} - {email} — both need to confirm before the - change takes effect. + {t.rich('emailChangeHint', { + oldEmail: profile?.email || '', + newEmail: email, + bold: (chunks: any) => {chunks} + })}

)} @@ -303,21 +307,21 @@ export function ProfileForm() { {/* Read-only block */}

- Account details + {t('accountDetails')}

-
Role
+
{t('role')}
{profile?.role ?? 'user'}
-
Joined
+
{t('joined')}
{joined}
-
User ID
+
{t('userId')}
{user?.id ?? '—'}
@@ -328,7 +332,7 @@ export function ProfileForm() { {!profile && (

- Loading your profile… + {t('loading')}

)} @@ -340,10 +344,10 @@ export function ProfileForm() { {saving ? ( <> - Saving… + {t('saving')} ) : ( - 'Save changes' + t('saveChanges') )}
diff --git a/src/components/settings/sessions-card.tsx b/src/components/settings/sessions-card.tsx index 6d63898..ebc6d5c 100644 --- a/src/components/settings/sessions-card.tsx +++ b/src/components/settings/sessions-card.tsx @@ -21,8 +21,10 @@ import { DialogDescription, DialogFooter, } from '@/components/ui/dialog'; +import { useTranslations } from 'next-intl'; export function SessionsCard() { + const t = useTranslations('Settings.profile'); const supabase = createClient(); const [open, setOpen] = useState(false); const [signingOut, setSigningOut] = useState(false); @@ -35,7 +37,7 @@ export function SessionsCard() { // triggers the usual redirect. const { error } = await supabase.auth.signOut({ scope: 'global' }); if (error) { - toast.error(`Sign-out failed: ${error.message}`); + toast.error(t('signOutFailed', { message: error.message })); return; } window.location.href = '/login'; @@ -53,11 +55,10 @@ export function SessionsCard() { - Active sessions + {t('sessionsTitle')} - Sign out of every device where you're logged in — including - this one. Useful if you lost a laptop or shared your password. + {t('sessionsDesc')} @@ -67,7 +68,7 @@ export function SessionsCard() { onClick={() => setOpen(true)} > - Sign out of all devices + {t('signOutAll')} @@ -75,11 +76,9 @@ export function SessionsCard() { - Sign out everywhere? + {t('signOutConfirmTitle')} - Every device logged into this account will be signed out and - will need to log in again. You will be redirected to the login - page. + {t('signOutConfirmDesc')} @@ -89,16 +88,16 @@ export function SessionsCard() { onClick={() => setOpen(false)} disabled={signingOut} > - Cancel + {t('cancel')} diff --git a/src/components/settings/tag-manager.tsx b/src/components/settings/tag-manager.tsx index 26e883f..cb7f86e 100644 --- a/src/components/settings/tag-manager.tsx +++ b/src/components/settings/tag-manager.tsx @@ -23,17 +23,18 @@ import { DialogFooter, } from '@/components/ui/dialog'; import { cn } from '@/lib/utils'; +import { useTranslations } from 'next-intl'; import type { Tag } from '@/types'; const PRESET_COLORS = [ - { name: 'Red', value: '#ef4444' }, - { name: 'Orange', value: '#f97316' }, - { name: 'Amber', value: '#f59e0b' }, - { name: 'Emerald', value: '#10b981' }, - { name: 'Cyan', value: '#06b6d4' }, - { name: 'Blue', value: '#3b82f6' }, - { name: 'Violet', value: '#8b5cf6' }, - { name: 'Pink', value: '#ec4899' }, + { name: 'red', value: '#ef4444' }, + { name: 'orange', value: '#f97316' }, + { name: 'amber', value: '#f59e0b' }, + { name: 'emerald', value: '#10b981' }, + { name: 'cyan', value: '#06b6d4' }, + { name: 'blue', value: '#3b82f6' }, + { name: 'violet', value: '#8b5cf6' }, + { name: 'pink', value: '#ec4899' }, ]; /** @@ -42,6 +43,7 @@ const PRESET_COLORS = [ * dialog since it detaches the tag from every contact. */ export function TagManager() { + const t = useTranslations('Settings.tagsAndFields'); const supabase = createClient(); const { user, accountId, loading: authLoading } = useAuth(); @@ -77,7 +79,7 @@ export function TagManager() { setTags(data || []); } catch (err) { console.error('Failed to fetch tags:', err); - toast.error('Failed to load tags'); + toast.error(t('failedToLoadTags')); } finally { setLoading(false); } @@ -85,14 +87,14 @@ export function TagManager() { async function handleCreate() { if (!newTagName.trim()) { - toast.error('Tag name is required'); + toast.error(t('nameRequired')); return; } try { setSaving(true); if (!user || !accountId) { - toast.error('Not authenticated'); + toast.error(t('notAuthenticated')); return; } @@ -107,13 +109,13 @@ export function TagManager() { if (error) throw error; - toast.success('Tag created'); + toast.success(t('tagCreated')); setNewTagName(''); setSelectedColor(PRESET_COLORS[3].value); await fetchTags(user.id); } catch (err) { console.error('Create error:', err); - toast.error('Failed to create tag'); + toast.error(t('failedToCreateTag')); } finally { setSaving(false); } @@ -136,13 +138,13 @@ export function TagManager() { if (error) throw error; - toast.success('Tag deleted'); + toast.success(t('tagDeleted')); setTags((prev) => prev.filter((t) => t.id !== tagToDelete.id)); setDeleteDialogOpen(false); setTagToDelete(null); } catch (err) { console.error('Delete error:', err); - toast.error('Failed to delete tag'); + toast.error(t('failedToDeleteTag')); } finally { setDeleting(false); } @@ -153,10 +155,10 @@ export function TagManager() { - Tags + {t('tagsTitle')} - Colour-coded labels for grouping and filtering contacts. + {t('tagsDesc')} @@ -186,7 +188,7 @@ export function TagManager() {
) : (

- No tags yet — create your first one below. + {t('noTags')}

)} {/* Inline create row */}
setNewTagName(e.target.value)} onKeyDown={(e) => { @@ -219,7 +221,7 @@ export function TagManager() { key={color.value} type="button" onClick={() => setSelectedColor(color.value)} - aria-label={`Use ${color.name}`} + aria-label={t('useColor', { color: t(`colors.${color.name}` as any) })} aria-pressed={selectedColor === color.value} className={cn( 'size-6 rounded-md transition-transform hover:scale-110', @@ -227,7 +229,7 @@ export function TagManager() { 'outline outline-2 outline-offset-2 outline-primary', )} style={{ backgroundColor: color.value }} - title={color.name} + title={t(`colors.${color.name}` as any)} /> ))}
@@ -242,7 +244,7 @@ export function TagManager() { ) : ( )} - Add tag + {t('addTag')}
@@ -253,10 +255,9 @@ export function TagManager() { - Delete tag + {t('deleteTag')} - Delete the tag "{tagToDelete?.name}"? This removes it - from all contacts and cannot be undone. + {tagToDelete ? t('deleteConfirm', { name: tagToDelete.name }) : null} @@ -265,7 +266,7 @@ export function TagManager() { onClick={() => setDeleteDialogOpen(false)} disabled={deleting} > - Cancel + {t('cancel')} diff --git a/src/components/settings/template-manager.tsx b/src/components/settings/template-manager.tsx index 0d6ab27..d80c825 100644 --- a/src/components/settings/template-manager.tsx +++ b/src/components/settings/template-manager.tsx @@ -24,6 +24,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Badge } from '@/components/ui/badge'; +import { useTranslations } from 'next-intl'; import { Card, CardContent } from '@/components/ui/card'; import { SettingsPanelHead } from './settings-panel-head'; import { @@ -124,6 +125,7 @@ function emptyButton(type: TemplateButton['type']): TemplateButton { } export function TemplateManager() { + const t = useTranslations('Settings.templates'); const supabase = createClient(); const { user, loading: authLoading } = useAuth(); @@ -197,7 +199,7 @@ export function TemplateManager() { setTemplates(data || []); } catch (err) { console.error('Failed to fetch templates:', err); - toast.error('Failed to load templates'); + toast.error(t('toastLoadFailed')); } finally { setLoading(false); } @@ -282,18 +284,18 @@ export function TemplateManager() { toast.success( data.dry_run ? isEdit - ? 'Template updated (dry-run — no Meta call)' - : 'Template saved (dry-run — no Meta call)' + ? t('toastSaveEditDry') + : t('toastSaveNewDry') : isEdit - ? 'Edit submitted — Meta typically reviews within 24 hours.' - : 'Submitted to Meta — typical review time is 24 hours. Status updates automatically.', + ? t('toastSubmitEditSuccess') + : t('toastSubmitNewSuccess'), ); setDialogOpen(false); setForm(emptyForm); setEditingId(null); } catch (err) { console.error('Submit error:', err); - toast.error(err instanceof Error ? err.message : 'Failed to submit'); + toast.error(err instanceof Error ? err.message : t('toastSubmitFailed')); } finally { setSubmitting(false); } @@ -309,9 +311,9 @@ export function TemplateManager() { throw new Error(data?.error || `Sync failed (HTTP ${res.status})`); } toast.success( - `Synced ${data.total} template${data.total === 1 ? '' : 's'} from Meta` + + t('toastSyncCount', { total: data.total }) + (data.inserted || data.updated - ? ` (${data.inserted} new, ${data.updated} updated)` + ? t('toastSyncDetails', { inserted: data.inserted, updated: data.updated }) : ''), ); if (Array.isArray(data.errors) && data.errors.length > 0) { @@ -321,21 +323,21 @@ export function TemplateManager() { ); const suffix = data.errors.length > 3 ? `, +${data.errors.length - 3} more` : ''; - toast.error(`Failed to sync: ${preview.join(', ')}${suffix}`); + toast.error(t('toastSyncFailed', { preview: preview.join(', ') + suffix })); } if (data.truncated) { // Use error (not warning) so the message survives long // enough to read — sonner's `warning` auto-dismisses on // the same short timer as `success`. toast.error( - 'Synced the first 2000 templates only — your account has more. Sync again to continue, or contact support if this persists.', + t('toastSyncTruncated'), { duration: 10000 }, ); } await fetchTemplates(user.id); } catch (err) { console.error('Template sync error:', err); - toast.error(err instanceof Error ? err.message : 'Failed to sync templates'); + toast.error(err instanceof Error ? err.message : t('toastSyncError')); } finally { setSyncing(false); } @@ -356,12 +358,12 @@ export function TemplateManager() { if (!res.ok) { throw new Error(data?.error || `Delete failed (HTTP ${res.status})`); } - toast.success('Template deleted'); + toast.success(t('toastDeleteSuccess')); setTemplates((prev) => prev.filter((t) => t.id !== target.id)); setTemplateToDelete(null); } catch (err) { console.error('Delete error:', err); - toast.error(err instanceof Error ? err.message : 'Failed to delete template'); + toast.error(err instanceof Error ? err.message : t('toastDeleteError')); } finally { setDeletingId(null); } @@ -458,12 +460,12 @@ export function TemplateManager() { async function handleHeaderImageFile(file: File) { if (!['image/jpeg', 'image/png'].includes(file.type)) { - toast.error('Header image must be a JPEG or PNG.'); + toast.error(t('toastInvalidImage')); return; } if (file.size > MEDIA_MAX_BYTES_BY_KIND.image) { toast.error( - `Image is ${(file.size / 1024 / 1024).toFixed(1)} MB — Meta's limit is 5 MB.`, + t('toastImageTooLarge', { size: (file.size / 1024 / 1024).toFixed(1) }), ); return; } @@ -471,9 +473,9 @@ export function TemplateManager() { try { const { publicUrl } = await uploadAccountMedia('chat-media', file); setForm((f) => ({ ...f, header_media_url: publicUrl })); - toast.success('Image uploaded.'); + toast.success(t('toastUploadSuccess')); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Upload failed.'); + toast.error(err instanceof Error ? err.message : t('toastUploadFailed')); } finally { setUploadingHeader(false); } @@ -482,24 +484,22 @@ export function TemplateManager() { return (
} @@ -508,9 +508,9 @@ export function TemplateManager() { {templates.length === 0 ? ( -

No templates yet.

+

{t('noTemplates')}

- Create your first message template to get started. + {t('createFirst')}

@@ -576,12 +576,12 @@ export function TemplateManager() { variant="ghost" size="sm" onClick={() => openEdit(template)} - title="Editing triggers Meta re-review — status flips to PENDING." - aria-label="Edit template" + title={t('editTitle')} + aria-label={t('editLabel')} className="text-muted-foreground hover:text-primary hover:bg-primary/10 h-8 px-2" > - Edit + {t('edit')} )} {(statusKey === 'REJECTED' || statusKey === 'PAUSED') && ( @@ -589,12 +589,12 @@ export function TemplateManager() { variant="ghost" size="sm" onClick={() => openEdit(template)} - title="Edit the template and resubmit to Meta for review." - aria-label="Edit and resubmit template" + title={t('resubmitTitle')} + aria-label={t('resubmitLabel')} className="text-muted-foreground hover:text-primary hover:bg-primary/10 h-8 px-2" > - Resubmit + {t('resubmit')} )} - JPEG or PNG, ≤5 MB + {t('uploadHint')}
)} setForm({ ...form, header_media_url: e.target.value }) @@ -853,21 +851,21 @@ export function TemplateManager() { )}

{form.header_format === 'image' - ? 'Upload a JPEG/PNG (≤5 MB, ≥800×418 px recommended) or paste a public HTTPS link — we upload it to Meta for review automatically.' - : 'Must be a publicly accessible HTTPS link. Meta fetches it once during review, so it needs to stay live for ~24 hrs.'} + ? t('imageHint') + : t('mediaHint')} {form.header_format === 'video' && - ' Recommended: MP4 / 3GPP, ≤16 MB, ≤60 seconds.'} + t('videoHint')} {form.header_format === 'document' && - ' Recommended: PDF, ≤100 MB.'} + t('documentHint')}

)}
- +