feat(i18n): localize settings templates and profile modul

This commit is contained in:
Ivan Mercedes 2026-07-07 20:50:57 -04:00
parent a91b8a7ba7
commit eeea478315
10 changed files with 550 additions and 293 deletions

View File

@ -1179,6 +1179,216 @@
"yourAccount": "Your account", "yourAccount": "Your account",
"loading": "Loading…" "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 <bold>Invite member</bold> 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 <bold>{name}</bold> 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 <bold>{role}</bold>. The link is valid for <bold>{days} {days, plural, =1 {day} other {days}}</bold>.",
"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 <bold>Sync from Meta</bold> 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 — <code>en_US</code> and <code>en</code> 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": { "whatsapp": {
"title": "WhatsApp connection", "title": "WhatsApp connection",
"description": "Connect your Meta WhatsApp Business API. Credentials, webhook, and setup steps all live here.", "description": "Connect your Meta WhatsApp Business API. Credentials, webhook, and setup steps all live here.",
@ -1260,6 +1470,59 @@
"groups": { "groups": {
"account": "Account", "account": "Account",
"workspace": "Workspace" "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 <bold>{oldEmail}</bold> and <bold>{newEmail}</bold> — 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}"
} }
} }
} }

View File

@ -9,6 +9,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/components/ui/card'; } from '@/components/ui/card';
import { useTranslations } from 'next-intl';
import { CustomFieldsPanel } from '@/components/contacts/custom-fields-manager'; import { CustomFieldsPanel } from '@/components/contacts/custom-fields-manager';
import { SettingsChip } from './settings-chip'; import { SettingsChip } from './settings-chip';
@ -19,20 +20,21 @@ import { SettingsChip } from './settings-chip';
* `custom_fields` RLS. * `custom_fields` RLS.
*/ */
export function CustomFieldsSettings() { export function CustomFieldsSettings() {
const t = useTranslations('Settings.tagsAndFields');
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-foreground"> <CardTitle className="flex items-center gap-2 text-foreground">
<SlidersHorizontal className="size-4 text-primary" /> <SlidersHorizontal className="size-4 text-primary" />
Custom fields {t('fieldsTitle')}
<SettingsChip variant="admin" className="font-medium"> <SettingsChip variant="admin" className="font-medium">
<Shield /> <Shield />
Admin {t('adminRole')}
</SettingsChip> </SettingsChip>
</CardTitle> </CardTitle>
<CardDescription className="text-muted-foreground"> <CardDescription className="text-muted-foreground">
Extra contact fields (e.g. ZIP code, lead source). They appear on {t('fieldsDesc')}
every contact and in the “Update Contact Field” automation action.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>

View File

@ -2,6 +2,8 @@
import { useCan } from '@/hooks/use-can'; import { useCan } from '@/hooks/use-can';
import { useTranslations } from 'next-intl';
import { CustomFieldsSettings } from './custom-fields-settings'; import { CustomFieldsSettings } from './custom-fields-settings';
import { SettingsPanelHead } from './settings-panel-head'; import { SettingsPanelHead } from './settings-panel-head';
import { TagManager } from './tag-manager'; import { TagManager } from './tag-manager';
@ -14,13 +16,14 @@ import { TagManager } from './tag-manager';
* regardless. * regardless.
*/ */
export function FieldsAndTagsPanel() { export function FieldsAndTagsPanel() {
const t = useTranslations('Settings.tagsAndFields');
const canEditSettings = useCan('edit-settings'); const canEditSettings = useCan('edit-settings');
return ( return (
<section className="max-w-3xl animate-in fade-in-50 space-y-4 duration-200"> <section className="max-w-3xl animate-in fade-in-50 space-y-4 duration-200">
<SettingsPanelHead <SettingsPanelHead
title="Fields & tags" title={t('title')}
description="Two ways to organize contacts: colour-coded tags for quick grouping, and custom fields for structured data." description={t('description')}
/> />
<TagManager /> <TagManager />
{canEditSettings ? <CustomFieldsSettings /> : null} {canEditSettings ? <CustomFieldsSettings /> : null}

View File

@ -36,6 +36,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { useTranslations } from 'next-intl';
import { useAuth } from '@/hooks/use-auth'; import { useAuth } from '@/hooks/use-auth';
type InviteRole = 'admin' | 'agent' | 'viewer'; type InviteRole = 'admin' | 'agent' | 'viewer';
@ -48,19 +49,17 @@ interface InviteMemberDialogProps {
onCreated: () => void; onCreated: () => void;
} }
const EXPIRY_OPTIONS: { value: string; label: string }[] = [ const EXPIRY_OPTIONS = [
{ value: '1', label: '1 day' }, { value: '1', labelKey: 'days1' },
{ value: '7', label: '7 days' }, { value: '7', labelKey: 'days7' },
{ value: '30', label: '30 days' }, { value: '30', labelKey: 'days30' },
]; ];
const ROLE_DESCRIPTIONS: Record<InviteRole, string> = { const ROLE_DESCRIPTIONS = [
admin: 'adminHint',
'Can invite teammates, manage settings, send messages, and edit data.', 'agentHint',
agent: 'viewerHint',
'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.',
};
// Server caps label at 80 chars (see src/app/api/account/invitations/route.ts). // 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 // Mirror it on the client so we short-circuit before the round-trip
@ -81,6 +80,8 @@ export function InviteMemberDialog({
onOpenChange, onOpenChange,
onCreated, onCreated,
}: InviteMemberDialogProps) { }: InviteMemberDialogProps) {
const t = useTranslations('Settings.invite');
const tRoles = useTranslations('Settings.roles');
const { account } = useAuth(); const { account } = useAuth();
const [role, setRole] = useState<InviteRole>('agent'); const [role, setRole] = useState<InviteRole>('agent');
const [expiry, setExpiry] = useState<string>('7'); const [expiry, setExpiry] = useState<string>('7');
@ -105,7 +106,7 @@ export function InviteMemberDialog({
// net for that path. // net for that path.
const trimmedLabel = label.trim(); const trimmedLabel = label.trim();
if (trimmedLabel.length > MAX_LABEL_LEN) { 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; return;
} }
setSubmitting(true); setSubmitting(true);
@ -155,12 +156,12 @@ export function InviteMemberDialog({
if (!result) return; if (!result) return;
try { try {
await navigator.clipboard.writeText(result.url); await navigator.clipboard.writeText(result.url);
toast.success('Invite link copied'); toast.success(t('copied'));
} catch { } catch {
// Most likely "not in a secure context" — happens on http:// // Most likely "not in a secure context" — happens on http://
// local IPs. Surface the link in the toast so the admin can // local IPs. Surface the link in the toast so the admin can
// hand-copy it. // 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" // for users in multi-team contexts where "our wacrm account"
// wouldn't be enough to disambiguate. // wouldn't be enough to disambiguate.
const accountName = result?.accountName ?? 'our wacrm account'; 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)}`; return `https://wa.me/?text=${encodeURIComponent(message)}`;
} }
@ -191,22 +192,19 @@ export function InviteMemberDialog({
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2 text-popover-foreground"> <DialogTitle className="flex items-center gap-2 text-popover-foreground">
<Sparkles className="size-4 text-primary" /> <Sparkles className="size-4 text-primary" />
Invite created {t('inviteCreated')}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-muted-foreground"> <DialogDescription className="text-muted-foreground">
Share this link with your new teammate. They&apos;ll be able {t.rich('inviteCreatedDesc', {
to sign up (or sign in) and join the account as{' '} role: tRoles(result.role),
<span className="font-medium text-muted-foreground">{result.role}</span> days: result.expiresInDays,
. The link is valid for{' '} bold: (chunks: any) => <strong>{chunks}</strong>
<span className="font-medium text-muted-foreground"> })}
{result.expiresInDays} day{result.expiresInDays === 1 ? '' : 's'}
</span>
.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-3 py-2"> <div className="space-y-3 py-2">
<Label className="text-muted-foreground">Invite link</Label> <Label className="text-muted-foreground">{t('inviteLink')}</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
readOnly readOnly
@ -220,7 +218,7 @@ export function InviteMemberDialog({
className="bg-primary hover:bg-primary/90 text-primary-foreground shrink-0" className="bg-primary hover:bg-primary/90 text-primary-foreground shrink-0"
> >
<Copy className="size-4" /> <Copy className="size-4" />
Copy {t('copy')}
</Button> </Button>
</div> </div>
@ -231,11 +229,9 @@ export function InviteMemberDialog({
intro, amber-200 for the body. */} intro, amber-200 for the body. */}
<div className="rounded-md border border-amber-500/50 bg-amber-500/15 px-3 py-2 text-xs text-amber-200"> <div className="rounded-md border border-amber-500/50 bg-amber-500/15 px-3 py-2 text-xs text-amber-200">
<strong className="font-semibold text-amber-100"> <strong className="font-semibold text-amber-100">
Save this link now. {t('saveLinkNow')}
</strong>{' '} </strong>{' '}
We never store the plaintext — once you close this dialog {t('saveLinkHint')}
the URL is gone. To re-share, revoke this invite and create
a new one.
</div> </div>
{/* Anchor styled with `buttonVariants` rather than wrapping {/* Anchor styled with `buttonVariants` rather than wrapping
@ -254,7 +250,7 @@ export function InviteMemberDialog({
})} })}
> >
<MessageCircle className="size-4" /> <MessageCircle className="size-4" />
Send via WhatsApp {t('sendViaWhatsApp')}
</a> </a>
</div> </div>
@ -263,23 +259,22 @@ export function InviteMemberDialog({
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
className="bg-primary hover:bg-primary/90 text-primary-foreground" className="bg-primary hover:bg-primary/90 text-primary-foreground"
> >
Done {t('done')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</> </>
) : ( ) : (
<> <>
<DialogHeader> <DialogHeader>
<DialogTitle className="text-popover-foreground">Invite a teammate</DialogTitle> <DialogTitle className="text-popover-foreground">{t('dialogTitle')}</DialogTitle>
<DialogDescription className="text-muted-foreground"> <DialogDescription className="text-muted-foreground">
Generate a one-time invite link. Share it via WhatsApp, {t('dialogDesc')}
Slack, or any channel you like — no email service required.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-muted-foreground">Role</Label> <Label className="text-muted-foreground">{t('roleLabel')}</Label>
<Select <Select
value={role} value={role}
onValueChange={(v) => v && setRole(v as InviteRole)} onValueChange={(v) => v && setRole(v as InviteRole)}
@ -288,18 +283,18 @@ export function InviteMemberDialog({
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="admin">Admin</SelectItem> <SelectItem value="admin">{tRoles('admin')}</SelectItem>
<SelectItem value="agent">Agent</SelectItem> <SelectItem value="agent">{tRoles('agent')}</SelectItem>
<SelectItem value="viewer">Viewer</SelectItem> <SelectItem value="viewer">{tRoles('viewer')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{ROLE_DESCRIPTIONS[role]} {tRoles(`${role}Hint` as 'adminHint' | 'agentHint' | 'viewerHint')}
</p> </p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-muted-foreground">Link valid for</Label> <Label className="text-muted-foreground">{t('validForLabel')}</Label>
<Select <Select
value={expiry} value={expiry}
onValueChange={(v) => v && setExpiry(v)} onValueChange={(v) => v && setExpiry(v)}
@ -310,7 +305,7 @@ export function InviteMemberDialog({
<SelectContent> <SelectContent>
{EXPIRY_OPTIONS.map((opt) => ( {EXPIRY_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}> <SelectItem key={opt.value} value={opt.value}>
{opt.label} {t(opt.labelKey as any)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@ -319,19 +314,18 @@ export function InviteMemberDialog({
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-muted-foreground"> <Label className="text-muted-foreground">
Label{' '} {t('labelTitle')}{' '}
<span className="text-xs text-muted-foreground">(optional)</span> <span className="text-xs text-muted-foreground">{t('optional')}</span>
</Label> </Label>
<Input <Input
placeholder="e.g. Sara — support team" placeholder={t('labelPlaceholder')}
value={label} value={label}
onChange={(e) => setLabel(e.target.value)} onChange={(e) => setLabel(e.target.value)}
maxLength={MAX_LABEL_LEN} maxLength={MAX_LABEL_LEN}
className="bg-muted border-border text-foreground placeholder:text-muted-foreground" className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Helps you remember who you sent the link to in the pending {t('labelHint')}
list below.
</p> </p>
</div> </div>
</div> </div>
@ -342,7 +336,7 @@ export function InviteMemberDialog({
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
className="border-border text-muted-foreground hover:bg-muted" className="border-border text-muted-foreground hover:bg-muted"
> >
Cancel {t('cancel')}
</Button> </Button>
<Button <Button
onClick={handleCreate} onClick={handleCreate}
@ -352,10 +346,10 @@ export function InviteMemberDialog({
{submitting ? ( {submitting ? (
<> <>
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
Creating... {t('creating')}
</> </>
) : ( ) : (
'Generate link' t('generateLink')
)} )}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@ -62,6 +62,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { useTranslations } from 'next-intl';
import { RequireRole } from '@/components/auth/require-role'; import { RequireRole } from '@/components/auth/require-role';
import { useAuth } from '@/hooks/use-auth'; import { useAuth } from '@/hooks/use-auth';
import { usePresence } from '@/hooks/use-presence'; import { usePresence } from '@/hooks/use-presence';
@ -92,12 +93,11 @@ interface Invitation {
expires_at: string; expires_at: string;
} }
// Editable roles in the inline dropdown. Owner is never an option — // These roles are translated via `useTranslations("Settings.roles")` where they are used.
// promotions go through the (deferred) Transfer Ownership flow. const EDITABLE_ROLES: { value: AccountRole }[] = [
const EDITABLE_ROLES: { value: AccountRole; label: string; hint: string }[] = [ { value: 'admin' },
{ value: 'admin', label: 'Admin', hint: 'Manage members + everything' }, { value: 'agent' },
{ value: 'agent', label: 'Agent', hint: 'Use features; no settings' }, { value: 'viewer' },
{ value: 'viewer', label: 'Viewer', hint: 'Read-only across the app' },
]; ];
// Per-role chip metadata (icon / label / colour) lives in the shared // 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(); 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)); 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))); 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() { export function MembersTab() {
const t = useTranslations('Settings.members');
const tRoles = useTranslations('Settings.roles');
const { user, canManageMembers } = useAuth(); const { user, canManageMembers } = useAuth();
const { getPresence, getRow, now } = usePresence(); const { getPresence, getRow, now } = usePresence();
@ -211,7 +213,7 @@ export function MembersTab() {
toast.error(payload.error || 'Failed to update role'); toast.error(payload.error || 'Failed to update role');
return; 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) { } catch (err) {
// Same revert on network failure. // Same revert on network failure.
setMembers((prev) => setMembers((prev) =>
@ -239,7 +241,7 @@ export function MembersTab() {
toast.error(payload.error || 'Failed to remove member'); toast.error(payload.error || 'Failed to remove member');
return; return;
} }
toast.success(`Removed ${removingMember.full_name || 'member'}`); toast.success(t('removedToast', { name: removingMember.full_name || t('unnamed') }));
setMembers((prev) => setMembers((prev) =>
prev.filter((m) => m.user_id !== removingMember.user_id), prev.filter((m) => m.user_id !== removingMember.user_id),
); );
@ -262,7 +264,7 @@ export function MembersTab() {
toast.error(payload.error || 'Failed to revoke invitation'); toast.error(payload.error || 'Failed to revoke invitation');
return; return;
} }
toast.success('Invitation revoked'); toast.success(t('revokedToast'));
setInvitations((prev) => prev.filter((i) => i.id !== invite.id)); setInvitations((prev) => prev.filter((i) => i.id !== invite.id));
} catch (err) { } catch (err) {
console.error('[MembersTab] revoke error:', err); console.error('[MembersTab] revoke error:', err);
@ -281,13 +283,13 @@ export function MembersTab() {
return ( return (
<section className="animate-in fade-in-50 space-y-6 duration-200"> <section className="animate-in fade-in-50 space-y-6 duration-200">
<SettingsPanelHead <SettingsPanelHead
title="Team members" title={t('title')}
description="People with access to this account. Roles control what each teammate can do." description={t('description')}
action={ action={
<RequireRole min="admin"> <RequireRole min="admin">
<Button onClick={() => setInviteOpen(true)}> <Button onClick={() => setInviteOpen(true)}>
<Plus className="size-4" /> <Plus className="size-4" />
Invite member {t('inviteMember')}
</Button> </Button>
</RequireRole> </RequireRole>
} }
@ -302,18 +304,18 @@ export function MembersTab() {
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground"> <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<PresenceDot status="online" /> <PresenceDot status="online" />
{counts.online} online {counts.online} {t('online')}
</span> </span>
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<PresenceDot status="away" /> <PresenceDot status="away" />
{counts.away} away {counts.away} {t('away')}
</span> </span>
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<PresenceDot status="offline" /> <PresenceDot status="offline" />
{counts.offline} offline {counts.offline} {t('offline')}
</span> </span>
<span className="text-muted-foreground/70"> <span className="text-muted-foreground/70">
· {members.length} member{members.length === 1 ? '' : 's'} · {t('memberCount', { count: members.length })}
</span> </span>
</div> </div>
); );
@ -381,11 +383,11 @@ export function MembersTab() {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-foreground"> <span className="truncate text-sm font-medium text-foreground">
{member.full_name || 'Unnamed'} {member.full_name || t('unnamed')}
</span> </span>
{isSelf && ( {isSelf && (
<Badge className="bg-muted text-muted-foreground border-border text-[10px] uppercase tracking-wide"> <Badge className="bg-muted text-muted-foreground border-border text-[10px] uppercase tracking-wide">
You {t('you')}
</Badge> </Badge>
)} )}
</div> </div>
@ -400,7 +402,7 @@ export function MembersTab() {
{/* Joined date stays desktop-only. The mobile row's {/* Joined date stays desktop-only. The mobile row's
vertical density makes the joined date noise. */} vertical density makes the joined date noise. */}
<div className="hidden sm:block text-right text-xs text-muted-foreground"> <div className="hidden sm:block text-right text-xs text-muted-foreground">
Joined {fmtDate(member.joined_at)} {t('joined', { date: fmtDate(member.joined_at) })}
</div> </div>
{/* Actions cluster. On mobile this is its own row {/* Actions cluster. On mobile this is its own row
@ -431,7 +433,7 @@ export function MembersTab() {
<SelectContent> <SelectContent>
{EDITABLE_ROLES.map((r) => ( {EDITABLE_ROLES.map((r) => (
<SelectItem key={r.value} value={r.value}> <SelectItem key={r.value} value={r.value}>
{r.label} {tRoles(r.value)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@ -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}`} className={`inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium ${roleMeta.className}`}
> >
<RoleIcon className="size-3.5" /> <RoleIcon className="size-3.5" />
{roleMeta.label} {tRoles(member.role)}
</span> </span>
)} )}
@ -477,7 +479,7 @@ export function MembersTab() {
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
<UsersRound className="size-4 text-muted-foreground" /> <UsersRound className="size-4 text-muted-foreground" />
<h3 className="text-sm font-semibold text-foreground"> <h3 className="text-sm font-semibold text-foreground">
Pending invitations {t('pendingInvitations')}
</h3> </h3>
<Badge className="bg-muted text-muted-foreground border-border"> <Badge className="bg-muted text-muted-foreground border-border">
{invitations.length} {invitations.length}
@ -490,9 +492,7 @@ export function MembersTab() {
looking for a button) keeps it from feeling like a bug. */} looking for a button) keeps it from feeling like a bug. */}
{invitations.length > 0 ? ( {invitations.length > 0 ? (
<p className="mb-3 text-xs text-muted-foreground"> <p className="mb-3 text-xs text-muted-foreground">
The plaintext invite URL is only shown once at creation {t('inviteHint')}
for security — to re-share, revoke the invite below and
create a new one.
</p> </p>
) : null} ) : null}
@ -501,12 +501,9 @@ export function MembersTab() {
<CardContent className="flex flex-col items-center justify-center py-8 text-center"> <CardContent className="flex flex-col items-center justify-center py-8 text-center">
<Mail className="size-6 text-muted-foreground" /> <Mail className="size-6 text-muted-foreground" />
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
No pending invitations. {t('noPendingTitle')}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Click <span className="text-muted-foreground">Invite member</span>{' '}
above to generate a shareable link.
</p> </p>
<p className="mt-1 text-xs text-muted-foreground" dangerouslySetInnerHTML={{ __html: t.raw('noPendingDesc') }} />
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
@ -524,17 +521,17 @@ export function MembersTab() {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground"> <span className="text-sm font-medium text-foreground">
{inv.label || 'Untitled invite'} {inv.label || t('untitledInvite')}
</span> </span>
<span <span
className={`inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[11px] font-medium ${inviteRoleMeta.className}`} className={`inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[11px] font-medium ${inviteRoleMeta.className}`}
> >
<InviteRoleIcon className="size-3" /> <InviteRoleIcon className="size-3" />
{inviteRoleMeta.label} {tRoles(inv.role)}
</span> </span>
</div> </div>
<p className="mt-0.5 text-xs text-muted-foreground"> <p className="mt-0.5 text-xs text-muted-foreground">
Created {fmtDate(inv.created_at)} · {fmtExpiresIn(inv.expires_at)} {t('created', { date: fmtDate(inv.created_at) })} · {fmtExpiresIn(inv.expires_at, t)}
</p> </p>
</div> </div>
@ -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" 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"
> >
<MailX className="size-4" /> <MailX className="size-4" />
Revoke {t('revoke')}
</Button> </Button>
</li> </li>
); );
@ -577,16 +574,13 @@ export function MembersTab() {
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2 text-popover-foreground"> <DialogTitle className="flex items-center gap-2 text-popover-foreground">
<AlertTriangle className="size-4 text-amber-400" /> <AlertTriangle className="size-4 text-amber-400" />
Remove member {t('removeDialogTitle')}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-muted-foreground"> <DialogDescription className="text-muted-foreground">
Remove{' '} {t.rich('removeDialogDesc', {
<span className="font-medium text-muted-foreground"> name: removingMember?.full_name || t('unnamed'),
{removingMember?.full_name || 'this teammate'} bold: (chunks: any) => <strong>{chunks}</strong>
</span>{' '} })}
from the account? They&apos;ll be signed out of this account
and given a fresh personal account on their next sign-in. Their
login isn&apos;t deleted.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter className="bg-popover border-border"> <DialogFooter className="bg-popover border-border">
@ -595,7 +589,7 @@ export function MembersTab() {
onClick={() => setRemovingMember(null)} onClick={() => setRemovingMember(null)}
className="border-border text-muted-foreground hover:bg-muted" className="border-border text-muted-foreground hover:bg-muted"
> >
Cancel {t('cancel')}
</Button> </Button>
<Button <Button
onClick={handleRemove} onClick={handleRemove}
@ -605,10 +599,10 @@ export function MembersTab() {
{pendingMemberAction ? ( {pendingMemberAction ? (
<> <>
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
Removing... {t('removing')}
</> </>
) : ( ) : (
'Remove member' t('removeBtn')
)} )}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@ -16,10 +16,12 @@ import {
CardTitle, CardTitle,
CardDescription, CardDescription,
} from '@/components/ui/card'; } from '@/components/ui/card';
import { useTranslations } from 'next-intl';
const MIN_PASSWORD = 8; const MIN_PASSWORD = 8;
export function PasswordForm() { export function PasswordForm() {
const t = useTranslations('Settings.profile');
const { profile } = useAuth(); const { profile } = useAuth();
const supabase = createClient(); const supabase = createClient();
@ -32,15 +34,15 @@ export function PasswordForm() {
const onSubmit = async (e: React.FormEvent) => { const onSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!profile?.email) { if (!profile?.email) {
toast.error('Cannot change password without a current email'); toast.error(t('cannotChangeNoEmail'));
return; return;
} }
if (next.length < MIN_PASSWORD) { if (next.length < MIN_PASSWORD) {
setConfirmError(`Password must be at least ${MIN_PASSWORD} characters`); setConfirmError(t('passwordTooShort', { min: MIN_PASSWORD }));
return; return;
} }
if (next !== confirm) { if (next !== confirm) {
setConfirmError('New password and confirmation do not match'); setConfirmError(t('passwordMismatch'));
return; return;
} }
setConfirmError(null); setConfirmError(null);
@ -56,7 +58,7 @@ export function PasswordForm() {
password: current, password: current,
}); });
if (signInError) { if (signInError) {
toast.error('Current password is incorrect'); toast.error(t('currentPasswordIncorrect'));
return; return;
} }
@ -64,14 +66,14 @@ export function PasswordForm() {
password: next, password: next,
}); });
if (updateError) { if (updateError) {
toast.error(`Password update failed: ${updateError.message}`); toast.error(t('passwordUpdateFailed', { message: updateError.message }));
return; return;
} }
setCurrent(''); setCurrent('');
setNext(''); setNext('');
setConfirm(''); setConfirm('');
toast.success('Password updated'); toast.success(t('passwordUpdated'));
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error'; const msg = err instanceof Error ? err.message : 'Unknown error';
toast.error(msg); toast.error(msg);
@ -85,11 +87,10 @@ export function PasswordForm() {
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-foreground"> <CardTitle className="flex items-center gap-2 text-foreground">
<KeyRound className="size-4 text-primary" /> <KeyRound className="size-4 text-primary" />
Password {t('passwordTitle')}
</CardTitle> </CardTitle>
<CardDescription className="text-muted-foreground"> <CardDescription className="text-muted-foreground">
Use at least {MIN_PASSWORD} characters. You will stay signed in on {t('passwordDesc', { min: MIN_PASSWORD })}
this device after changing it.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
@ -97,7 +98,7 @@ export function PasswordForm() {
<form onSubmit={onSubmit} className="space-y-4"> <form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="current-password" className="text-foreground"> <Label htmlFor="current-password" className="text-foreground">
Current password {t('currentPassword')}
</Label> </Label>
<Input <Input
id="current-password" id="current-password"
@ -113,7 +114,7 @@ export function PasswordForm() {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="new-password" className="text-foreground"> <Label htmlFor="new-password" className="text-foreground">
New password {t('newPassword')}
</Label> </Label>
<Input <Input
id="new-password" id="new-password"
@ -128,7 +129,7 @@ export function PasswordForm() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="confirm-password" className="text-foreground"> <Label htmlFor="confirm-password" className="text-foreground">
Confirm new password {t('confirmPassword')}
</Label> </Label>
<Input <Input
id="confirm-password" id="confirm-password"
@ -157,10 +158,10 @@ export function PasswordForm() {
{saving ? ( {saving ? (
<> <>
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
Updating… {t('updating')}
</> </>
) : ( ) : (
'Update password' t('updatePassword')
)} )}
</Button> </Button>
</div> </div>

View File

@ -15,6 +15,7 @@ import {
AvatarImage, AvatarImage,
} from '@/components/ui/avatar'; } from '@/components/ui/avatar';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { useTranslations } from 'next-intl';
import { SettingsPanelHead } from './settings-panel-head'; import { SettingsPanelHead } from './settings-panel-head';
const MAX_AVATAR_BYTES = 2 * 1024 * 1024; const MAX_AVATAR_BYTES = 2 * 1024 * 1024;
@ -31,6 +32,7 @@ const ALLOWED_MIME = new Set([
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function ProfileForm() { export function ProfileForm() {
const t = useTranslations('Settings.profile');
const { user, profile, refreshProfile } = useAuth(); const { user, profile, refreshProfile } = useAuth();
const supabase = createClient(); const supabase = createClient();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@ -70,14 +72,14 @@ export function ProfileForm() {
if (!file) return; if (!file) return;
if (!ALLOWED_MIME.has(file.type)) { if (!ALLOWED_MIME.has(file.type)) {
toast.error('Unsupported image type', { toast.error(t('unsupportedImage'), {
description: 'Use PNG, JPG, WebP, or GIF.', description: t('unsupportedImageDesc'),
}); });
return; return;
} }
if (file.size > MAX_AVATAR_BYTES) { if (file.size > MAX_AVATAR_BYTES) {
toast.error('Image is too large', { toast.error(t('imageTooLarge'), {
description: 'Maximum 2 MB.', description: t('imageTooLargeDesc'),
}); });
return; return;
} }
@ -101,12 +103,12 @@ export function ProfileForm() {
const trimmedName = fullName.trim(); const trimmedName = fullName.trim();
if (!trimmedName) { if (!trimmedName) {
toast.error('Display name is required'); toast.error(t('nameRequired'));
return; return;
} }
const trimmedEmail = email.trim(); const trimmedEmail = email.trim();
if (!EMAIL_RE.test(trimmedEmail)) { if (!EMAIL_RE.test(trimmedEmail)) {
toast.error('Enter a valid email address'); toast.error(t('invalidEmail'));
return; return;
} }
@ -127,7 +129,7 @@ export function ProfileForm() {
contentType: pendingAvatar.type, contentType: pendingAvatar.type,
}); });
if (uploadError) { if (uploadError) {
throw new Error(`Upload failed: ${uploadError.message}`); throw new Error(t('uploadFailed', { message: uploadError.message }));
} }
const { const {
data: { publicUrl }, data: { publicUrl },
@ -146,7 +148,7 @@ export function ProfileForm() {
}) })
.eq('user_id', user.id); .eq('user_id', user.id);
if (updateError) { 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 // Email change goes through Supabase Auth, which emails a
@ -161,8 +163,8 @@ export function ProfileForm() {
}); });
if (emailError) { if (emailError) {
// Partial success: name/avatar saved but email didn't. // Partial success: name/avatar saved but email didn't.
toast.success('Profile saved'); toast.success(t('profileSaved'));
toast.error(`Email change failed: ${emailError.message}`); toast.error(t('emailChangeFailed', { message: emailError.message }));
setSaving(false); setSaving(false);
await refreshProfile(); await refreshProfile();
return; return;
@ -178,8 +180,8 @@ export function ProfileForm() {
toast.success( toast.success(
emailSent emailSent
? 'Profile saved — check your email to confirm the address change' ? t('profileSavedEmailCheck')
: 'Profile saved', : t('profileSaved'),
); );
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error'; const msg = err instanceof Error ? err.message : 'Unknown error';
@ -207,8 +209,8 @@ export function ProfileForm() {
return ( return (
<section className="max-w-2xl animate-in fade-in-50 duration-200"> <section className="max-w-2xl animate-in fade-in-50 duration-200">
<SettingsPanelHead <SettingsPanelHead
title="Your profile" title={t('title')}
description="How you show up across the app. Your avatar and name appear in the header, sidebar, and anywhere your teammates see you." description={t('description')}
/> />
<form onSubmit={onSubmit} className="space-y-4"> <form onSubmit={onSubmit} className="space-y-4">
<Card> <Card>
@ -239,7 +241,7 @@ export function ProfileForm() {
disabled={saving} disabled={saving}
> >
<Upload className="size-4" /> <Upload className="size-4" />
{currentAvatar ? 'Change photo' : 'Upload photo'} {currentAvatar ? t('changePhoto') : t('uploadPhoto')}
</Button> </Button>
{currentAvatar && ( {currentAvatar && (
<Button <Button
@ -250,11 +252,11 @@ export function ProfileForm() {
className="text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground"
> >
<Trash2 className="size-4" /> <Trash2 className="size-4" />
Remove {t('remove')}
</Button> </Button>
)} )}
<p className="w-full text-xs text-muted-foreground"> <p className="w-full text-xs text-muted-foreground">
PNG, JPG, WebP, or GIF. Up to 2 MB. {t('photoHint')}
</p> </p>
</div> </div>
</div> </div>
@ -262,7 +264,7 @@ export function ProfileForm() {
{/* Name */} {/* Name */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="profile-full-name" className="text-foreground"> <Label htmlFor="profile-full-name" className="text-foreground">
Display name {t('displayName')}
</Label> </Label>
<Input <Input
id="profile-full-name" id="profile-full-name"
@ -278,7 +280,7 @@ export function ProfileForm() {
{/* Email */} {/* Email */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="profile-email" className="text-foreground"> <Label htmlFor="profile-email" className="text-foreground">
Email {t('email')}
</Label> </Label>
<Input <Input
id="profile-email" id="profile-email"
@ -292,9 +294,11 @@ export function ProfileForm() {
<p className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300"> <p className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
<Mail className="mt-0.5 size-3.5 shrink-0" /> <Mail className="mt-0.5 size-3.5 shrink-0" />
<span> <span>
Check the inbox for <strong>{profile?.email}</strong> and{' '} {t.rich('emailChangeHint', {
<strong>{email}</strong> — both need to confirm before the oldEmail: profile?.email || '',
change takes effect. newEmail: email,
bold: (chunks: any) => <strong>{chunks}</strong>
})}
</span> </span>
</p> </p>
)} )}
@ -303,21 +307,21 @@ export function ProfileForm() {
{/* Read-only block */} {/* Read-only block */}
<div className="rounded-lg border border-border bg-muted p-4"> <div className="rounded-lg border border-border bg-muted p-4">
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground"> <p className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Account details {t('accountDetails')}
</p> </p>
<dl className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2"> <dl className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<div> <div>
<dt className="text-muted-foreground">Role</dt> <dt className="text-muted-foreground">{t('role')}</dt>
<dd className="mt-0.5 font-mono text-foreground"> <dd className="mt-0.5 font-mono text-foreground">
{profile?.role ?? 'user'} {profile?.role ?? 'user'}
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-muted-foreground">Joined</dt> <dt className="text-muted-foreground">{t('joined')}</dt>
<dd className="mt-0.5 text-foreground">{joined}</dd> <dd className="mt-0.5 text-foreground">{joined}</dd>
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<dt className="text-muted-foreground">User ID</dt> <dt className="text-muted-foreground">{t('userId')}</dt>
<dd className="mt-0.5 break-all font-mono text-xs text-muted-foreground"> <dd className="mt-0.5 break-all font-mono text-xs text-muted-foreground">
{user?.id ?? '—'} {user?.id ?? '—'}
</dd> </dd>
@ -328,7 +332,7 @@ export function ProfileForm() {
{!profile && ( {!profile && (
<p className="flex items-center gap-2 text-sm text-muted-foreground"> <p className="flex items-center gap-2 text-sm text-muted-foreground">
<CircleAlert className="size-4" /> <CircleAlert className="size-4" />
Loading your profile… {t('loading')}
</p> </p>
)} )}
@ -340,10 +344,10 @@ export function ProfileForm() {
{saving ? ( {saving ? (
<> <>
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
Saving… {t('saving')}
</> </>
) : ( ) : (
'Save changes' t('saveChanges')
)} )}
</Button> </Button>
</div> </div>

View File

@ -21,8 +21,10 @@ import {
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { useTranslations } from 'next-intl';
export function SessionsCard() { export function SessionsCard() {
const t = useTranslations('Settings.profile');
const supabase = createClient(); const supabase = createClient();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [signingOut, setSigningOut] = useState(false); const [signingOut, setSigningOut] = useState(false);
@ -35,7 +37,7 @@ export function SessionsCard() {
// triggers the usual redirect. // triggers the usual redirect.
const { error } = await supabase.auth.signOut({ scope: 'global' }); const { error } = await supabase.auth.signOut({ scope: 'global' });
if (error) { if (error) {
toast.error(`Sign-out failed: ${error.message}`); toast.error(t('signOutFailed', { message: error.message }));
return; return;
} }
window.location.href = '/login'; window.location.href = '/login';
@ -53,11 +55,10 @@ export function SessionsCard() {
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-foreground"> <CardTitle className="flex items-center gap-2 text-foreground">
<LogOut className="size-4 text-primary" /> <LogOut className="size-4 text-primary" />
Active sessions {t('sessionsTitle')}
</CardTitle> </CardTitle>
<CardDescription className="text-muted-foreground"> <CardDescription className="text-muted-foreground">
Sign out of every device where you&apos;re logged in — including {t('sessionsDesc')}
this one. Useful if you lost a laptop or shared your password.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -67,7 +68,7 @@ export function SessionsCard() {
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
> >
<LogOut className="size-4" /> <LogOut className="size-4" />
Sign out of all devices {t('signOutAll')}
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
@ -75,11 +76,9 @@ export function SessionsCard() {
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Sign out everywhere?</DialogTitle> <DialogTitle>{t('signOutConfirmTitle')}</DialogTitle>
<DialogDescription> <DialogDescription>
Every device logged into this account will be signed out and {t('signOutConfirmDesc')}
will need to log in again. You will be redirected to the login
page.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
@ -89,16 +88,16 @@ export function SessionsCard() {
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
disabled={signingOut} disabled={signingOut}
> >
Cancel {t('cancel')}
</Button> </Button>
<Button type="button" onClick={onConfirm} disabled={signingOut}> <Button type="button" onClick={onConfirm} disabled={signingOut}>
{signingOut ? ( {signingOut ? (
<> <>
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
Signing out… {t('signingOut')}
</> </>
) : ( ) : (
'Sign out everywhere' t('signOutEverywhere')
)} )}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@ -23,17 +23,18 @@ import {
DialogFooter, DialogFooter,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslations } from 'next-intl';
import type { Tag } from '@/types'; import type { Tag } from '@/types';
const PRESET_COLORS = [ const PRESET_COLORS = [
{ name: 'Red', value: '#ef4444' }, { name: 'red', value: '#ef4444' },
{ name: 'Orange', value: '#f97316' }, { name: 'orange', value: '#f97316' },
{ name: 'Amber', value: '#f59e0b' }, { name: 'amber', value: '#f59e0b' },
{ name: 'Emerald', value: '#10b981' }, { name: 'emerald', value: '#10b981' },
{ name: 'Cyan', value: '#06b6d4' }, { name: 'cyan', value: '#06b6d4' },
{ name: 'Blue', value: '#3b82f6' }, { name: 'blue', value: '#3b82f6' },
{ name: 'Violet', value: '#8b5cf6' }, { name: 'violet', value: '#8b5cf6' },
{ name: 'Pink', value: '#ec4899' }, { name: 'pink', value: '#ec4899' },
]; ];
/** /**
@ -42,6 +43,7 @@ const PRESET_COLORS = [
* dialog since it detaches the tag from every contact. * dialog since it detaches the tag from every contact.
*/ */
export function TagManager() { export function TagManager() {
const t = useTranslations('Settings.tagsAndFields');
const supabase = createClient(); const supabase = createClient();
const { user, accountId, loading: authLoading } = useAuth(); const { user, accountId, loading: authLoading } = useAuth();
@ -77,7 +79,7 @@ export function TagManager() {
setTags(data || []); setTags(data || []);
} catch (err) { } catch (err) {
console.error('Failed to fetch tags:', err); console.error('Failed to fetch tags:', err);
toast.error('Failed to load tags'); toast.error(t('failedToLoadTags'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -85,14 +87,14 @@ export function TagManager() {
async function handleCreate() { async function handleCreate() {
if (!newTagName.trim()) { if (!newTagName.trim()) {
toast.error('Tag name is required'); toast.error(t('nameRequired'));
return; return;
} }
try { try {
setSaving(true); setSaving(true);
if (!user || !accountId) { if (!user || !accountId) {
toast.error('Not authenticated'); toast.error(t('notAuthenticated'));
return; return;
} }
@ -107,13 +109,13 @@ export function TagManager() {
if (error) throw error; if (error) throw error;
toast.success('Tag created'); toast.success(t('tagCreated'));
setNewTagName(''); setNewTagName('');
setSelectedColor(PRESET_COLORS[3].value); setSelectedColor(PRESET_COLORS[3].value);
await fetchTags(user.id); await fetchTags(user.id);
} catch (err) { } catch (err) {
console.error('Create error:', err); console.error('Create error:', err);
toast.error('Failed to create tag'); toast.error(t('failedToCreateTag'));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@ -136,13 +138,13 @@ export function TagManager() {
if (error) throw error; if (error) throw error;
toast.success('Tag deleted'); toast.success(t('tagDeleted'));
setTags((prev) => prev.filter((t) => t.id !== tagToDelete.id)); setTags((prev) => prev.filter((t) => t.id !== tagToDelete.id));
setDeleteDialogOpen(false); setDeleteDialogOpen(false);
setTagToDelete(null); setTagToDelete(null);
} catch (err) { } catch (err) {
console.error('Delete error:', err); console.error('Delete error:', err);
toast.error('Failed to delete tag'); toast.error(t('failedToDeleteTag'));
} finally { } finally {
setDeleting(false); setDeleting(false);
} }
@ -153,10 +155,10 @@ export function TagManager() {
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-foreground"> <CardTitle className="flex items-center gap-2 text-foreground">
<TagIcon className="size-4 text-primary" /> <TagIcon className="size-4 text-primary" />
Tags {t('tagsTitle')}
</CardTitle> </CardTitle>
<CardDescription className="text-muted-foreground"> <CardDescription className="text-muted-foreground">
Colour-coded labels for grouping and filtering contacts. {t('tagsDesc')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
@ -186,7 +188,7 @@ export function TagManager() {
<button <button
type="button" type="button"
onClick={() => confirmDelete(tag)} onClick={() => confirmDelete(tag)}
aria-label={`Delete ${tag.name}`} aria-label={t('deleteAria', { name: tag.name })}
className="ml-0.5 rounded-full p-0.5 opacity-60 transition-opacity hover:bg-black/10 hover:opacity-100 dark:hover:bg-white/10" className="ml-0.5 rounded-full p-0.5 opacity-60 transition-opacity hover:bg-black/10 hover:opacity-100 dark:hover:bg-white/10"
> >
<X className="size-3" /> <X className="size-3" />
@ -196,14 +198,14 @@ export function TagManager() {
</div> </div>
) : ( ) : (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
No tags yet — create your first one below. {t('noTags')}
</p> </p>
)} )}
{/* Inline create row */} {/* Inline create row */}
<div className="flex flex-wrap items-center gap-2.5"> <div className="flex flex-wrap items-center gap-2.5">
<Input <Input
placeholder="e.g. Newsletter" placeholder={t('placeholder')}
value={newTagName} value={newTagName}
onChange={(e) => setNewTagName(e.target.value)} onChange={(e) => setNewTagName(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
@ -219,7 +221,7 @@ export function TagManager() {
key={color.value} key={color.value}
type="button" type="button"
onClick={() => setSelectedColor(color.value)} 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} aria-pressed={selectedColor === color.value}
className={cn( className={cn(
'size-6 rounded-md transition-transform hover:scale-110', 'size-6 rounded-md transition-transform hover:scale-110',
@ -227,7 +229,7 @@ export function TagManager() {
'outline outline-2 outline-offset-2 outline-primary', 'outline outline-2 outline-offset-2 outline-primary',
)} )}
style={{ backgroundColor: color.value }} style={{ backgroundColor: color.value }}
title={color.name} title={t(`colors.${color.name}` as any)}
/> />
))} ))}
</div> </div>
@ -242,7 +244,7 @@ export function TagManager() {
) : ( ) : (
<Plus className="size-4" /> <Plus className="size-4" />
)} )}
Add tag {t('addTag')}
</Button> </Button>
</div> </div>
</> </>
@ -253,10 +255,9 @@ export function TagManager() {
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent className="sm:max-w-sm"> <DialogContent className="sm:max-w-sm">
<DialogHeader> <DialogHeader>
<DialogTitle>Delete tag</DialogTitle> <DialogTitle>{t('deleteTag')}</DialogTitle>
<DialogDescription> <DialogDescription>
Delete the tag &quot;{tagToDelete?.name}&quot;? This removes it {tagToDelete ? t('deleteConfirm', { name: tagToDelete.name }) : null}
from all contacts and cannot be undone.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
@ -265,7 +266,7 @@ export function TagManager() {
onClick={() => setDeleteDialogOpen(false)} onClick={() => setDeleteDialogOpen(false)}
disabled={deleting} disabled={deleting}
> >
Cancel {t('cancel')}
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
@ -275,10 +276,10 @@ export function TagManager() {
{deleting ? ( {deleting ? (
<> <>
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
Deleting... {t('deleting')}
</> </>
) : ( ) : (
'Delete tag' t('deleteTag')
)} )}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@ -24,6 +24,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { useTranslations } from 'next-intl';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { SettingsPanelHead } from './settings-panel-head'; import { SettingsPanelHead } from './settings-panel-head';
import { import {
@ -124,6 +125,7 @@ function emptyButton(type: TemplateButton['type']): TemplateButton {
} }
export function TemplateManager() { export function TemplateManager() {
const t = useTranslations('Settings.templates');
const supabase = createClient(); const supabase = createClient();
const { user, loading: authLoading } = useAuth(); const { user, loading: authLoading } = useAuth();
@ -197,7 +199,7 @@ export function TemplateManager() {
setTemplates(data || []); setTemplates(data || []);
} catch (err) { } catch (err) {
console.error('Failed to fetch templates:', err); console.error('Failed to fetch templates:', err);
toast.error('Failed to load templates'); toast.error(t('toastLoadFailed'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -282,18 +284,18 @@ export function TemplateManager() {
toast.success( toast.success(
data.dry_run data.dry_run
? isEdit ? isEdit
? 'Template updated (dry-run — no Meta call)' ? t('toastSaveEditDry')
: 'Template saved (dry-run — no Meta call)' : t('toastSaveNewDry')
: isEdit : isEdit
? 'Edit submitted — Meta typically reviews within 24 hours.' ? t('toastSubmitEditSuccess')
: 'Submitted to Meta — typical review time is 24 hours. Status updates automatically.', : t('toastSubmitNewSuccess'),
); );
setDialogOpen(false); setDialogOpen(false);
setForm(emptyForm); setForm(emptyForm);
setEditingId(null); setEditingId(null);
} catch (err) { } catch (err) {
console.error('Submit error:', 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 { } finally {
setSubmitting(false); setSubmitting(false);
} }
@ -309,9 +311,9 @@ export function TemplateManager() {
throw new Error(data?.error || `Sync failed (HTTP ${res.status})`); throw new Error(data?.error || `Sync failed (HTTP ${res.status})`);
} }
toast.success( toast.success(
`Synced ${data.total} template${data.total === 1 ? '' : 's'} from Meta` + t('toastSyncCount', { total: data.total }) +
(data.inserted || data.updated (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) { if (Array.isArray(data.errors) && data.errors.length > 0) {
@ -321,21 +323,21 @@ export function TemplateManager() {
); );
const suffix = const suffix =
data.errors.length > 3 ? `, +${data.errors.length - 3} more` : ''; 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) { if (data.truncated) {
// Use error (not warning) so the message survives long // Use error (not warning) so the message survives long
// enough to read — sonner's `warning` auto-dismisses on // enough to read — sonner's `warning` auto-dismisses on
// the same short timer as `success`. // the same short timer as `success`.
toast.error( 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 }, { duration: 10000 },
); );
} }
await fetchTemplates(user.id); await fetchTemplates(user.id);
} catch (err) { } catch (err) {
console.error('Template sync error:', 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 { } finally {
setSyncing(false); setSyncing(false);
} }
@ -356,12 +358,12 @@ export function TemplateManager() {
if (!res.ok) { if (!res.ok) {
throw new Error(data?.error || `Delete failed (HTTP ${res.status})`); 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)); setTemplates((prev) => prev.filter((t) => t.id !== target.id));
setTemplateToDelete(null); setTemplateToDelete(null);
} catch (err) { } catch (err) {
console.error('Delete error:', 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 { } finally {
setDeletingId(null); setDeletingId(null);
} }
@ -458,12 +460,12 @@ export function TemplateManager() {
async function handleHeaderImageFile(file: File) { async function handleHeaderImageFile(file: File) {
if (!['image/jpeg', 'image/png'].includes(file.type)) { if (!['image/jpeg', 'image/png'].includes(file.type)) {
toast.error('Header image must be a JPEG or PNG.'); toast.error(t('toastInvalidImage'));
return; return;
} }
if (file.size > MEDIA_MAX_BYTES_BY_KIND.image) { if (file.size > MEDIA_MAX_BYTES_BY_KIND.image) {
toast.error( 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; return;
} }
@ -471,9 +473,9 @@ export function TemplateManager() {
try { try {
const { publicUrl } = await uploadAccountMedia('chat-media', file); const { publicUrl } = await uploadAccountMedia('chat-media', file);
setForm((f) => ({ ...f, header_media_url: publicUrl })); setForm((f) => ({ ...f, header_media_url: publicUrl }));
toast.success('Image uploaded.'); toast.success(t('toastUploadSuccess'));
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'Upload failed.'); toast.error(err instanceof Error ? err.message : t('toastUploadFailed'));
} finally { } finally {
setUploadingHeader(false); setUploadingHeader(false);
} }
@ -482,24 +484,22 @@ export function TemplateManager() {
return ( return (
<section className="animate-in fade-in-50 space-y-4 duration-200"> <section className="animate-in fade-in-50 space-y-4 duration-200">
<SettingsPanelHead <SettingsPanelHead
title="Message templates" title={t('title')}
description={ description={t('description')}
'Create templates and submit them to Meta for approval. Use "Sync from Meta" to pull templates approved elsewhere.'
}
action={ action={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
variant="outline" variant="outline"
onClick={handleSyncFromMeta} onClick={handleSyncFromMeta}
disabled={syncing} disabled={syncing}
title="Pull approved templates from your Meta WhatsApp Business Account" title={t('syncTitle')}
> >
<RefreshCw className={`size-4 ${syncing ? 'animate-spin' : ''}`} /> <RefreshCw className={`size-4 ${syncing ? 'animate-spin' : ''}`} />
{syncing ? 'Syncing…' : 'Sync from Meta'} {syncing ? t('syncing') : t('syncFromMeta')}
</Button> </Button>
<Button onClick={openCreate}> <Button onClick={openCreate}>
<Plus className="size-4" /> <Plus className="size-4" />
New Template {t('newTemplate')}
</Button> </Button>
</div> </div>
} }
@ -508,9 +508,9 @@ export function TemplateManager() {
{templates.length === 0 ? ( {templates.length === 0 ? (
<Card> <Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center"> <CardContent className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-muted-foreground text-sm">No templates yet.</p> <p className="text-muted-foreground text-sm">{t('noTemplates')}</p>
<p className="text-muted-foreground text-xs mt-1"> <p className="text-muted-foreground text-xs mt-1">
Create your first message template to get started. {t('createFirst')}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@ -576,12 +576,12 @@ export function TemplateManager() {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => openEdit(template)} onClick={() => openEdit(template)}
title="Editing triggers Meta re-review — status flips to PENDING." title={t('editTitle')}
aria-label="Edit template" aria-label={t('editLabel')}
className="text-muted-foreground hover:text-primary hover:bg-primary/10 h-8 px-2" className="text-muted-foreground hover:text-primary hover:bg-primary/10 h-8 px-2"
> >
<Pencil className="size-3.5" /> <Pencil className="size-3.5" />
Edit {t('edit')}
</Button> </Button>
)} )}
{(statusKey === 'REJECTED' || statusKey === 'PAUSED') && ( {(statusKey === 'REJECTED' || statusKey === 'PAUSED') && (
@ -589,12 +589,12 @@ export function TemplateManager() {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => openEdit(template)} onClick={() => openEdit(template)}
title="Edit the template and resubmit to Meta for review." title={t('resubmitTitle')}
aria-label="Edit and resubmit template" aria-label={t('resubmitLabel')}
className="text-muted-foreground hover:text-primary hover:bg-primary/10 h-8 px-2" className="text-muted-foreground hover:text-primary hover:bg-primary/10 h-8 px-2"
> >
<RotateCcw className="size-3.5" /> <RotateCcw className="size-3.5" />
Resubmit {t('resubmit')}
</Button> </Button>
)} )}
<Button <Button
@ -604,13 +604,13 @@ export function TemplateManager() {
disabled={deletingId === template.id} disabled={deletingId === template.id}
aria-label={ aria-label={
template.meta_template_id template.meta_template_id
? 'Delete template from Meta and locally' ? t('deleteMetaLocallyAria')
: 'Delete template locally' : t('deleteLocallyAria')
} }
title={ title={
template.meta_template_id template.meta_template_id
? 'Delete from Meta and locally' ? t('deleteMetaLocallyTitle')
: 'Delete locally' : t('deleteLocallyTitle')
} }
className="text-muted-foreground hover:text-red-400 hover:bg-red-950/30 h-8 w-8" className="text-muted-foreground hover:text-red-400 hover:bg-red-950/30 h-8 w-8"
> >
@ -641,32 +641,27 @@ export function TemplateManager() {
<DialogContent className="bg-popover border-border sm:max-w-2xl max-h-[90vh] overflow-y-auto"> <DialogContent className="bg-popover border-border sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-popover-foreground"> <DialogTitle className="text-popover-foreground">
{editingId ? 'Edit Message Template' : 'New Message Template'} {editingId ? t('dialogEditTitle') : t('dialogNewTitle')}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-muted-foreground"> <DialogDescription className="text-muted-foreground">
{editingId {editingId
? 'Save your changes to re-submit to Meta. Status will flip back to PENDING during review.' ? t('dialogEditDesc')
: 'Build a template and submit it to Meta for approval. Once approved, you can use it in broadcasts and the inbox.'} : t('dialogNewDesc')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{form.category === 'Authentication' && ( {form.category === 'Authentication' && (
<div className="flex items-start gap-2 rounded border border-amber-700/40 bg-amber-950/30 px-3 py-2 text-xs text-amber-300"> <div className="flex items-start gap-2 rounded border border-amber-700/40 bg-amber-950/30 px-3 py-2 text-xs text-amber-300">
<AlertCircle className="size-4 mt-0.5 shrink-0" /> <AlertCircle className="size-4 mt-0.5 shrink-0" />
<p> <p dangerouslySetInnerHTML={{ __html: t.raw('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 <strong>Sync from Meta</strong> to
bring them in.
</p>
</div> </div>
)} )}
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-muted-foreground">Template Name</Label> <Label className="text-muted-foreground">{t('templateName')}</Label>
<Input <Input
placeholder="e.g. order_confirmation" placeholder={t('namePlaceholder')}
value={form.name} value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })} onChange={(e) => setForm({ ...form, name: e.target.value })}
disabled={editingId !== null} disabled={editingId !== null}
@ -674,14 +669,14 @@ export function TemplateManager() {
/> />
<p className="text-[11px] text-muted-foreground"> <p className="text-[11px] text-muted-foreground">
{editingId {editingId
? 'Name is fixed once a template exists on Meta — create a new template to change it.' ? t('nameFixed')
: 'Lowercase letters, digits, and underscores only.'} : t('nameHint')}
</p> </p>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-muted-foreground">Category</Label> <Label className="text-muted-foreground">{t('category')}</Label>
<Select <Select
value={form.category} value={form.category}
onValueChange={(val) => onValueChange={(val) =>
@ -709,7 +704,7 @@ export function TemplateManager() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-muted-foreground">Language</Label> <Label className="text-muted-foreground">{t('language')}</Label>
<Input <Input
list="template-language-codes" list="template-language-codes"
placeholder="en_US" placeholder="en_US"
@ -726,20 +721,17 @@ export function TemplateManager() {
))} ))}
</datalist> </datalist>
<p className="text-[11px] text-muted-foreground"> <p className="text-[11px] text-muted-foreground">
{editingId {editingId ? (
? 'Language is fixed once a template exists on Meta.' t('langFixed')
: ( ) : (
<> <span dangerouslySetInnerHTML={{ __html: t.raw('langHint') }} />
Must match the exact code on Meta — <code>en_US</code>{' '} )}
and <code>en</code> are distinct.
</>
)}
</p> </p>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-muted-foreground">Header</Label> <Label className="text-muted-foreground">{t('header')}</Label>
<Select <Select
value={form.header_format} value={form.header_format}
onValueChange={(val) => onValueChange={(val) =>
@ -766,8 +758,14 @@ export function TemplateManager() {
className="text-popover-foreground focus:bg-muted focus:text-popover-foreground" className="text-popover-foreground focus:bg-muted focus:text-popover-foreground"
> >
{type === 'none' {type === 'none'
? 'None' ? t('headerNone')
: type.charAt(0).toUpperCase() + type.slice(1)} : type === 'text'
? t('headerText')
: type === 'image'
? t('headerImage')
: type === 'video'
? t('headerVideo')
: t('headerDocument')}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@ -778,7 +776,7 @@ export function TemplateManager() {
<Input <Input
id="template-header-text" id="template-header-text"
aria-label="Header text" aria-label="Header text"
placeholder="Header text (max 60 chars, optional {{1}})" placeholder={t('headerTextPlaceholder')}
value={form.header_content} value={form.header_content}
onChange={(e) => onChange={(e) =>
setForm({ ...form, header_content: e.target.value }) setForm({ ...form, header_content: e.target.value })
@ -789,8 +787,8 @@ export function TemplateManager() {
{headerVarCount > 0 && ( {headerVarCount > 0 && (
<Input <Input
id="template-header-sample" id="template-header-sample"
aria-label="Sample value for header variable" aria-label={t('headerSampleAria')}
placeholder="Sample value for {{1}} (required for Meta review)" placeholder={t('headerSamplePlaceholder')}
value={form.header_sample} value={form.header_sample}
onChange={(e) => onChange={(e) =>
setForm({ ...form, header_sample: e.target.value }) setForm({ ...form, header_sample: e.target.value })
@ -828,15 +826,15 @@ export function TemplateManager() {
) : ( ) : (
<Upload className="h-3.5 w-3.5" /> <Upload className="h-3.5 w-3.5" />
)} )}
Upload image {t('uploadImage')}
</Button> </Button>
<span className="text-[11px] text-muted-foreground"> <span className="text-[11px] text-muted-foreground">
JPEG or PNG, ≤5 MB {t('uploadHint')}
</span> </span>
</div> </div>
)} )}
<Input <Input
placeholder={`https://… (or paste a public ${form.header_format} link)`} placeholder={t('mediaUrlPlaceholder', { format: form.header_format })}
value={form.header_media_url} value={form.header_media_url}
onChange={(e) => onChange={(e) =>
setForm({ ...form, header_media_url: e.target.value }) setForm({ ...form, header_media_url: e.target.value })
@ -853,21 +851,21 @@ export function TemplateManager() {
)} )}
<p className="text-[11px] text-muted-foreground leading-relaxed"> <p className="text-[11px] text-muted-foreground leading-relaxed">
{form.header_format === 'image' {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.' ? t('imageHint')
: 'Must be a publicly accessible HTTPS link. Meta fetches it once during review, so it needs to stay live for ~24 hrs.'} : t('mediaHint')}
{form.header_format === 'video' && {form.header_format === 'video' &&
' Recommended: MP4 / 3GPP, ≤16 MB, ≤60 seconds.'} t('videoHint')}
{form.header_format === 'document' && {form.header_format === 'document' &&
' Recommended: PDF, ≤100 MB.'} t('documentHint')}
</p> </p>
</div> </div>
)} )}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-muted-foreground">Body Text</Label> <Label className="text-muted-foreground">{t('bodyText')}</Label>
<Textarea <Textarea
placeholder="Hello {{1}}, your order {{2}} is confirmed." placeholder={t('bodyPlaceholder')}
value={form.body_text} value={form.body_text}
onChange={(e) => onChange={(e) =>
setForm({ ...form, body_text: e.target.value }) setForm({ ...form, body_text: e.target.value })
@ -877,14 +875,13 @@ export function TemplateManager() {
className="bg-muted border-border text-foreground placeholder:text-muted-foreground resize-none" className="bg-muted border-border text-foreground placeholder:text-muted-foreground resize-none"
/> />
<p className="text-[11px] text-muted-foreground"> <p className="text-[11px] text-muted-foreground">
Use {`{{1}}`}, {`{{2}}`} for variables (must be contiguous {t('bodyHint')}
starting at {`{{1}}`}).
</p> </p>
{bodyVarCount > 0 && ( {bodyVarCount > 0 && (
<div className="space-y-1.5 pt-1"> <div className="space-y-1.5 pt-1">
<Label className="text-[11px] text-muted-foreground"> <Label className="text-[11px] text-muted-foreground">
Sample values (Meta uses these to review your template) {t('sampleValues')}
</Label> </Label>
{form.body_samples.map((val, i) => { {form.body_samples.map((val, i) => {
const inputId = `template-body-sample-${i}`; const inputId = `template-body-sample-${i}`;
@ -892,8 +889,8 @@ export function TemplateManager() {
<Input <Input
key={i} key={i}
id={inputId} id={inputId}
aria-label={`Sample value for body variable {{${i + 1}}}`} aria-label={t('sampleAria', { var: `{{${i + 1}}}` })}
placeholder={`Sample for {{${i + 1}}}`} placeholder={t('samplePlaceholder', { var: `{{${i + 1}}}` })}
value={val} value={val}
onChange={(e) => { onChange={(e) => {
const next = [...form.body_samples]; const next = [...form.body_samples];
@ -909,9 +906,9 @@ export function TemplateManager() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-muted-foreground">Footer (optional)</Label> <Label className="text-muted-foreground">{t('footer')}</Label>
<Input <Input
placeholder="Optional footer text (max 60 chars)" placeholder={t('footerPlaceholder')}
value={form.footer_text} value={form.footer_text}
onChange={(e) => onChange={(e) =>
setForm({ ...form, footer_text: e.target.value }) setForm({ ...form, footer_text: e.target.value })
@ -923,7 +920,7 @@ export function TemplateManager() {
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-muted-foreground">Buttons (optional)</Label> <Label className="text-muted-foreground">{t('buttons')}</Label>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@ -933,13 +930,12 @@ export function TemplateManager() {
className="border-border bg-transparent text-muted-foreground hover:bg-muted h-7 text-xs" className="border-border bg-transparent text-muted-foreground hover:bg-muted h-7 text-xs"
> >
<Plus className="size-3" /> <Plus className="size-3" />
Add Button {t('addButton')}
</Button> </Button>
</div> </div>
{form.buttons.length === 0 ? ( {form.buttons.length === 0 ? (
<p className="text-[11px] text-muted-foreground"> <p className="text-[11px] text-muted-foreground">
Up to {TEMPLATE_LIMITS.maxButtonsTotal} buttons. QUICK_REPLY {t('buttonsLimit', { max: TEMPLATE_LIMITS.maxButtonsTotal })}
buttons must come before URL / phone / copy-code buttons.
</p> </p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
@ -967,30 +963,30 @@ export function TemplateManager() {
value="QUICK_REPLY" value="QUICK_REPLY"
className="text-popover-foreground focus:bg-muted focus:text-popover-foreground" className="text-popover-foreground focus:bg-muted focus:text-popover-foreground"
> >
Quick Reply {t('btnQuickReply')}
</SelectItem> </SelectItem>
<SelectItem <SelectItem
value="URL" value="URL"
className="text-popover-foreground focus:bg-muted focus:text-popover-foreground" className="text-popover-foreground focus:bg-muted focus:text-popover-foreground"
> >
URL {t('btnUrl')}
</SelectItem> </SelectItem>
<SelectItem <SelectItem
value="PHONE_NUMBER" value="PHONE_NUMBER"
className="text-popover-foreground focus:bg-muted focus:text-popover-foreground" className="text-popover-foreground focus:bg-muted focus:text-popover-foreground"
> >
Phone {t('btnPhone')}
</SelectItem> </SelectItem>
<SelectItem <SelectItem
value="COPY_CODE" value="COPY_CODE"
className="text-popover-foreground focus:bg-muted focus:text-popover-foreground" className="text-popover-foreground focus:bg-muted focus:text-popover-foreground"
> >
Copy Code {t('btnCopyCode')}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Input <Input
placeholder="Button label" placeholder={t('btnLabelPlaceholder')}
value={btn.text} value={btn.text}
maxLength={TEMPLATE_LIMITS.buttonTextMaxLength} maxLength={TEMPLATE_LIMITS.buttonTextMaxLength}
onChange={(e) => onChange={(e) =>
@ -1011,7 +1007,7 @@ export function TemplateManager() {
{btn.type === 'URL' && ( {btn.type === 'URL' && (
<div className="space-y-1 pl-1"> <div className="space-y-1 pl-1">
<Input <Input
placeholder="https://example.com/path or with {{1}} suffix" placeholder={t('urlPlaceholder')}
value={btn.url} value={btn.url}
onChange={(e) => onChange={(e) =>
updateButton(i, { url: e.target.value }) updateButton(i, { url: e.target.value })
@ -1020,7 +1016,7 @@ export function TemplateManager() {
/> />
{extractVariableIndices(btn.url).length > 0 && ( {extractVariableIndices(btn.url).length > 0 && (
<Input <Input
placeholder="Example value for {{1}} (required when URL has a variable)" placeholder={t('urlSamplePlaceholder')}
value={btn.example ?? ''} value={btn.example ?? ''}
onChange={(e) => onChange={(e) =>
updateButton(i, { example: e.target.value }) updateButton(i, { example: e.target.value })
@ -1032,7 +1028,7 @@ export function TemplateManager() {
)} )}
{btn.type === 'PHONE_NUMBER' && ( {btn.type === 'PHONE_NUMBER' && (
<Input <Input
placeholder="+15551234567" placeholder={t('phonePlaceholder')}
value={btn.phone_number} value={btn.phone_number}
onChange={(e) => onChange={(e) =>
updateButton(i, { phone_number: e.target.value }) updateButton(i, { phone_number: e.target.value })
@ -1042,7 +1038,7 @@ export function TemplateManager() {
)} )}
{btn.type === 'COPY_CODE' && ( {btn.type === 'COPY_CODE' && (
<Input <Input
placeholder="Example code (e.g. SUMMER20)" placeholder={t('codePlaceholder')}
value={btn.example} value={btn.example}
onChange={(e) => onChange={(e) =>
updateButton(i, { example: e.target.value }) updateButton(i, { example: e.target.value })
@ -1063,7 +1059,7 @@ export function TemplateManager() {
onClick={() => setDialogOpen(false)} onClick={() => setDialogOpen(false)}
className="border-border text-muted-foreground hover:bg-muted" className="border-border text-muted-foreground hover:bg-muted"
> >
Cancel {t('cancel')}
</Button> </Button>
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
@ -1073,12 +1069,12 @@ export function TemplateManager() {
{submitting ? ( {submitting ? (
<> <>
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
{editingId ? 'Saving…' : 'Submitting…'} {editingId ? t('saving') : t('submitting')}
</> </>
) : editingId ? ( ) : editingId ? (
'Save & Resubmit' t('saveResubmit')
) : ( ) : (
'Submit for Approval' t('submitApproval')
)} )}
</Button> </Button>
</DialogFooter> </DialogFooter>
@ -1096,11 +1092,11 @@ export function TemplateManager() {
> >
<DialogContent className="bg-popover border-border sm:max-w-sm"> <DialogContent className="bg-popover border-border sm:max-w-sm">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-popover-foreground">Delete template?</DialogTitle> <DialogTitle className="text-popover-foreground">{t('deleteDialogTitle')}</DialogTitle>
<DialogDescription className="text-muted-foreground"> <DialogDescription className="text-muted-foreground">
{templateToDelete?.meta_template_id {templateToDelete?.meta_template_id
? `"${templateToDelete?.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.` ? t('deleteMetaDesc', { name: templateToDelete.name })
: `"${templateToDelete?.name}" will be deleted from wacrm. It was never submitted to Meta, so no remote cleanup is needed.`} : t('deleteLocalDesc', { name: templateToDelete?.name || '' })}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter className="bg-popover border-border"> <DialogFooter className="bg-popover border-border">
@ -1110,7 +1106,7 @@ export function TemplateManager() {
disabled={deletingId !== null} disabled={deletingId !== null}
className="border-border text-muted-foreground hover:bg-muted" className="border-border text-muted-foreground hover:bg-muted"
> >
Cancel {t('cancel')}
</Button> </Button>
<Button <Button
onClick={confirmDelete} onClick={confirmDelete}
@ -1120,10 +1116,10 @@ export function TemplateManager() {
{deletingId !== null ? ( {deletingId !== null ? (
<> <>
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
Deleting… {t('deleting')}
</> </>
) : ( ) : (
'Delete' t('delete')
)} )}
</Button> </Button>
</DialogFooter> </DialogFooter>