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",
"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": {
"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 <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,
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 (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-foreground">
<SlidersHorizontal className="size-4 text-primary" />
Custom fields
{t('fieldsTitle')}
<SettingsChip variant="admin" className="font-medium">
<Shield />
Admin
{t('adminRole')}
</SettingsChip>
</CardTitle>
<CardDescription className="text-muted-foreground">
Extra contact fields (e.g. ZIP code, lead source). They appear on
every contact and in the “Update Contact Field” automation action.
{t('fieldsDesc')}
</CardDescription>
</CardHeader>
<CardContent>

View File

@ -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 (
<section className="max-w-3xl animate-in fade-in-50 space-y-4 duration-200">
<SettingsPanelHead
title="Fields & tags"
description="Two ways to organize contacts: colour-coded tags for quick grouping, and custom fields for structured data."
title={t('title')}
description={t('description')}
/>
<TagManager />
{canEditSettings ? <CustomFieldsSettings /> : null}

View File

@ -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<InviteRole, string> = {
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<InviteRole>('agent');
const [expiry, setExpiry] = useState<string>('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({
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-popover-foreground">
<Sparkles className="size-4 text-primary" />
Invite created
{t('inviteCreated')}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
Share this link with your new teammate. They&apos;ll be able
to sign up (or sign in) and join the account as{' '}
<span className="font-medium text-muted-foreground">{result.role}</span>
. The link is valid for{' '}
<span className="font-medium text-muted-foreground">
{result.expiresInDays} day{result.expiresInDays === 1 ? '' : 's'}
</span>
.
{t.rich('inviteCreatedDesc', {
role: tRoles(result.role),
days: result.expiresInDays,
bold: (chunks: any) => <strong>{chunks}</strong>
})}
</DialogDescription>
</DialogHeader>
<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">
<Input
readOnly
@ -220,7 +218,7 @@ export function InviteMemberDialog({
className="bg-primary hover:bg-primary/90 text-primary-foreground shrink-0"
>
<Copy className="size-4" />
Copy
{t('copy')}
</Button>
</div>
@ -231,11 +229,9 @@ export function InviteMemberDialog({
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">
<strong className="font-semibold text-amber-100">
Save this link now.
{t('saveLinkNow')}
</strong>{' '}
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')}
</div>
{/* Anchor styled with `buttonVariants` rather than wrapping
@ -254,7 +250,7 @@ export function InviteMemberDialog({
})}
>
<MessageCircle className="size-4" />
Send via WhatsApp
{t('sendViaWhatsApp')}
</a>
</div>
@ -263,23 +259,22 @@ export function InviteMemberDialog({
onClick={() => onOpenChange(false)}
className="bg-primary hover:bg-primary/90 text-primary-foreground"
>
Done
{t('done')}
</Button>
</DialogFooter>
</>
) : (
<>
<DialogHeader>
<DialogTitle className="text-popover-foreground">Invite a teammate</DialogTitle>
<DialogTitle className="text-popover-foreground">{t('dialogTitle')}</DialogTitle>
<DialogDescription className="text-muted-foreground">
Generate a one-time invite link. Share it via WhatsApp,
Slack, or any channel you like — no email service required.
{t('dialogDesc')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label className="text-muted-foreground">Role</Label>
<Label className="text-muted-foreground">{t('roleLabel')}</Label>
<Select
value={role}
onValueChange={(v) => v && setRole(v as InviteRole)}
@ -288,18 +283,18 @@ export function InviteMemberDialog({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="agent">Agent</SelectItem>
<SelectItem value="viewer">Viewer</SelectItem>
<SelectItem value="admin">{tRoles('admin')}</SelectItem>
<SelectItem value="agent">{tRoles('agent')}</SelectItem>
<SelectItem value="viewer">{tRoles('viewer')}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{ROLE_DESCRIPTIONS[role]}
{tRoles(`${role}Hint` as 'adminHint' | 'agentHint' | 'viewerHint')}
</p>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">Link valid for</Label>
<Label className="text-muted-foreground">{t('validForLabel')}</Label>
<Select
value={expiry}
onValueChange={(v) => v && setExpiry(v)}
@ -310,7 +305,7 @@ export function InviteMemberDialog({
<SelectContent>
{EXPIRY_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
{t(opt.labelKey as any)}
</SelectItem>
))}
</SelectContent>
@ -319,19 +314,18 @@ export function InviteMemberDialog({
<div className="space-y-2">
<Label className="text-muted-foreground">
Label{' '}
<span className="text-xs text-muted-foreground">(optional)</span>
{t('labelTitle')}{' '}
<span className="text-xs text-muted-foreground">{t('optional')}</span>
</Label>
<Input
placeholder="e.g. Sara — support team"
placeholder={t('labelPlaceholder')}
value={label}
onChange={(e) => setLabel(e.target.value)}
maxLength={MAX_LABEL_LEN}
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
<p className="text-xs text-muted-foreground">
Helps you remember who you sent the link to in the pending
list below.
{t('labelHint')}
</p>
</div>
</div>
@ -342,7 +336,7 @@ export function InviteMemberDialog({
onClick={() => onOpenChange(false)}
className="border-border text-muted-foreground hover:bg-muted"
>
Cancel
{t('cancel')}
</Button>
<Button
onClick={handleCreate}
@ -352,10 +346,10 @@ export function InviteMemberDialog({
{submitting ? (
<>
<Loader2 className="size-4 animate-spin" />
Creating...
{t('creating')}
</>
) : (
'Generate link'
t('generateLink')
)}
</Button>
</DialogFooter>

View File

@ -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 (
<section className="animate-in fade-in-50 space-y-6 duration-200">
<SettingsPanelHead
title="Team members"
description="People with access to this account. Roles control what each teammate can do."
title={t('title')}
description={t('description')}
action={
<RequireRole min="admin">
<Button onClick={() => setInviteOpen(true)}>
<Plus className="size-4" />
Invite member
{t('inviteMember')}
</Button>
</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">
<span className="inline-flex items-center gap-1.5">
<PresenceDot status="online" />
{counts.online} online
{counts.online} {t('online')}
</span>
<span className="inline-flex items-center gap-1.5">
<PresenceDot status="away" />
{counts.away} away
{counts.away} {t('away')}
</span>
<span className="inline-flex items-center gap-1.5">
<PresenceDot status="offline" />
{counts.offline} offline
{counts.offline} {t('offline')}
</span>
<span className="text-muted-foreground/70">
· {members.length} member{members.length === 1 ? '' : 's'}
· {t('memberCount', { count: members.length })}
</span>
</div>
);
@ -381,11 +383,11 @@ export function MembersTab() {
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-foreground">
{member.full_name || 'Unnamed'}
{member.full_name || t('unnamed')}
</span>
{isSelf && (
<Badge className="bg-muted text-muted-foreground border-border text-[10px] uppercase tracking-wide">
You
{t('you')}
</Badge>
)}
</div>
@ -400,7 +402,7 @@ export function MembersTab() {
{/* Joined date stays desktop-only. The mobile row's
vertical density makes the joined date noise. */}
<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>
{/* Actions cluster. On mobile this is its own row
@ -431,7 +433,7 @@ export function MembersTab() {
<SelectContent>
{EDITABLE_ROLES.map((r) => (
<SelectItem key={r.value} value={r.value}>
{r.label}
{tRoles(r.value)}
</SelectItem>
))}
</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}`}
>
<RoleIcon className="size-3.5" />
{roleMeta.label}
{tRoles(member.role)}
</span>
)}
@ -477,7 +479,7 @@ export function MembersTab() {
<div className="mb-2 flex items-center gap-2">
<UsersRound className="size-4 text-muted-foreground" />
<h3 className="text-sm font-semibold text-foreground">
Pending invitations
{t('pendingInvitations')}
</h3>
<Badge className="bg-muted text-muted-foreground border-border">
{invitations.length}
@ -490,9 +492,7 @@ export function MembersTab() {
looking for a button) keeps it from feeling like a bug. */}
{invitations.length > 0 ? (
<p className="mb-3 text-xs text-muted-foreground">
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')}
</p>
) : null}
@ -501,12 +501,9 @@ export function MembersTab() {
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
<Mail className="size-6 text-muted-foreground" />
<p className="mt-2 text-sm text-muted-foreground">
No pending invitations.
</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.
{t('noPendingTitle')}
</p>
<p className="mt-1 text-xs text-muted-foreground" dangerouslySetInnerHTML={{ __html: t.raw('noPendingDesc') }} />
</CardContent>
</Card>
) : (
@ -524,17 +521,17 @@ export function MembersTab() {
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">
{inv.label || 'Untitled invite'}
{inv.label || t('untitledInvite')}
</span>
<span
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" />
{inviteRoleMeta.label}
{tRoles(inv.role)}
</span>
</div>
<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>
</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"
>
<MailX className="size-4" />
Revoke
{t('revoke')}
</Button>
</li>
);
@ -577,16 +574,13 @@ export function MembersTab() {
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-popover-foreground">
<AlertTriangle className="size-4 text-amber-400" />
Remove member
{t('removeDialogTitle')}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
Remove{' '}
<span className="font-medium text-muted-foreground">
{removingMember?.full_name || 'this teammate'}
</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.
{t.rich('removeDialogDesc', {
name: removingMember?.full_name || t('unnamed'),
bold: (chunks: any) => <strong>{chunks}</strong>
})}
</DialogDescription>
</DialogHeader>
<DialogFooter className="bg-popover border-border">
@ -595,7 +589,7 @@ export function MembersTab() {
onClick={() => setRemovingMember(null)}
className="border-border text-muted-foreground hover:bg-muted"
>
Cancel
{t('cancel')}
</Button>
<Button
onClick={handleRemove}
@ -605,10 +599,10 @@ export function MembersTab() {
{pendingMemberAction ? (
<>
<Loader2 className="size-4 animate-spin" />
Removing...
{t('removing')}
</>
) : (
'Remove member'
t('removeBtn')
)}
</Button>
</DialogFooter>

View File

@ -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() {
<CardHeader>
<CardTitle className="flex items-center gap-2 text-foreground">
<KeyRound className="size-4 text-primary" />
Password
{t('passwordTitle')}
</CardTitle>
<CardDescription className="text-muted-foreground">
Use at least {MIN_PASSWORD} characters. You will stay signed in on
this device after changing it.
{t('passwordDesc', { min: MIN_PASSWORD })}
</CardDescription>
</CardHeader>
@ -97,7 +98,7 @@ export function PasswordForm() {
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="current-password" className="text-foreground">
Current password
{t('currentPassword')}
</Label>
<Input
id="current-password"
@ -113,7 +114,7 @@ export function PasswordForm() {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="new-password" className="text-foreground">
New password
{t('newPassword')}
</Label>
<Input
id="new-password"
@ -128,7 +129,7 @@ export function PasswordForm() {
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password" className="text-foreground">
Confirm new password
{t('confirmPassword')}
</Label>
<Input
id="confirm-password"
@ -157,10 +158,10 @@ export function PasswordForm() {
{saving ? (
<>
<Loader2 className="size-4 animate-spin" />
Updating…
{t('updating')}
</>
) : (
'Update password'
t('updatePassword')
)}
</Button>
</div>

View File

@ -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<HTMLInputElement>(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 (
<section className="max-w-2xl animate-in fade-in-50 duration-200">
<SettingsPanelHead
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."
title={t('title')}
description={t('description')}
/>
<form onSubmit={onSubmit} className="space-y-4">
<Card>
@ -239,7 +241,7 @@ export function ProfileForm() {
disabled={saving}
>
<Upload className="size-4" />
{currentAvatar ? 'Change photo' : 'Upload photo'}
{currentAvatar ? t('changePhoto') : t('uploadPhoto')}
</Button>
{currentAvatar && (
<Button
@ -250,11 +252,11 @@ export function ProfileForm() {
className="text-muted-foreground hover:text-foreground"
>
<Trash2 className="size-4" />
Remove
{t('remove')}
</Button>
)}
<p className="w-full text-xs text-muted-foreground">
PNG, JPG, WebP, or GIF. Up to 2 MB.
{t('photoHint')}
</p>
</div>
</div>
@ -262,7 +264,7 @@ export function ProfileForm() {
{/* Name */}
<div className="space-y-2">
<Label htmlFor="profile-full-name" className="text-foreground">
Display name
{t('displayName')}
</Label>
<Input
id="profile-full-name"
@ -278,7 +280,7 @@ export function ProfileForm() {
{/* Email */}
<div className="space-y-2">
<Label htmlFor="profile-email" className="text-foreground">
Email
{t('email')}
</Label>
<Input
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">
<Mail className="mt-0.5 size-3.5 shrink-0" />
<span>
Check the inbox for <strong>{profile?.email}</strong> and{' '}
<strong>{email}</strong> — both need to confirm before the
change takes effect.
{t.rich('emailChangeHint', {
oldEmail: profile?.email || '',
newEmail: email,
bold: (chunks: any) => <strong>{chunks}</strong>
})}
</span>
</p>
)}
@ -303,21 +307,21 @@ export function ProfileForm() {
{/* Read-only block */}
<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">
Account details
{t('accountDetails')}
</p>
<dl className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<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">
{profile?.role ?? 'user'}
</dd>
</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>
</div>
<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">
{user?.id ?? '—'}
</dd>
@ -328,7 +332,7 @@ export function ProfileForm() {
{!profile && (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<CircleAlert className="size-4" />
Loading your profile…
{t('loading')}
</p>
)}
@ -340,10 +344,10 @@ export function ProfileForm() {
{saving ? (
<>
<Loader2 className="size-4 animate-spin" />
Saving…
{t('saving')}
</>
) : (
'Save changes'
t('saveChanges')
)}
</Button>
</div>

View File

@ -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() {
<CardHeader>
<CardTitle className="flex items-center gap-2 text-foreground">
<LogOut className="size-4 text-primary" />
Active sessions
{t('sessionsTitle')}
</CardTitle>
<CardDescription className="text-muted-foreground">
Sign out of every device where you&apos;re logged in — including
this one. Useful if you lost a laptop or shared your password.
{t('sessionsDesc')}
</CardDescription>
</CardHeader>
<CardContent>
@ -67,7 +68,7 @@ export function SessionsCard() {
onClick={() => setOpen(true)}
>
<LogOut className="size-4" />
Sign out of all devices
{t('signOutAll')}
</Button>
</CardContent>
</Card>
@ -75,11 +76,9 @@ export function SessionsCard() {
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Sign out everywhere?</DialogTitle>
<DialogTitle>{t('signOutConfirmTitle')}</DialogTitle>
<DialogDescription>
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')}
</DialogDescription>
</DialogHeader>
<DialogFooter>
@ -89,16 +88,16 @@ export function SessionsCard() {
onClick={() => setOpen(false)}
disabled={signingOut}
>
Cancel
{t('cancel')}
</Button>
<Button type="button" onClick={onConfirm} disabled={signingOut}>
{signingOut ? (
<>
<Loader2 className="size-4 animate-spin" />
Signing out…
{t('signingOut')}
</>
) : (
'Sign out everywhere'
t('signOutEverywhere')
)}
</Button>
</DialogFooter>

View File

@ -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() {
<CardHeader>
<CardTitle className="flex items-center gap-2 text-foreground">
<TagIcon className="size-4 text-primary" />
Tags
{t('tagsTitle')}
</CardTitle>
<CardDescription className="text-muted-foreground">
Colour-coded labels for grouping and filtering contacts.
{t('tagsDesc')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@ -186,7 +188,7 @@ export function TagManager() {
<button
type="button"
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"
>
<X className="size-3" />
@ -196,14 +198,14 @@ export function TagManager() {
</div>
) : (
<p className="text-sm text-muted-foreground">
No tags yet — create your first one below.
{t('noTags')}
</p>
)}
{/* Inline create row */}
<div className="flex flex-wrap items-center gap-2.5">
<Input
placeholder="e.g. Newsletter"
placeholder={t('placeholder')}
value={newTagName}
onChange={(e) => 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)}
/>
))}
</div>
@ -242,7 +244,7 @@ export function TagManager() {
) : (
<Plus className="size-4" />
)}
Add tag
{t('addTag')}
</Button>
</div>
</>
@ -253,10 +255,9 @@ export function TagManager() {
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Delete tag</DialogTitle>
<DialogTitle>{t('deleteTag')}</DialogTitle>
<DialogDescription>
Delete the tag &quot;{tagToDelete?.name}&quot;? This removes it
from all contacts and cannot be undone.
{tagToDelete ? t('deleteConfirm', { name: tagToDelete.name }) : null}
</DialogDescription>
</DialogHeader>
<DialogFooter>
@ -265,7 +266,7 @@ export function TagManager() {
onClick={() => setDeleteDialogOpen(false)}
disabled={deleting}
>
Cancel
{t('cancel')}
</Button>
<Button
variant="destructive"
@ -275,10 +276,10 @@ export function TagManager() {
{deleting ? (
<>
<Loader2 className="size-4 animate-spin" />
Deleting...
{t('deleting')}
</>
) : (
'Delete tag'
t('deleteTag')
)}
</Button>
</DialogFooter>

View File

@ -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 (
<section className="animate-in fade-in-50 space-y-4 duration-200">
<SettingsPanelHead
title="Message templates"
description={
'Create templates and submit them to Meta for approval. Use "Sync from Meta" to pull templates approved elsewhere.'
}
title={t('title')}
description={t('description')}
action={
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={handleSyncFromMeta}
disabled={syncing}
title="Pull approved templates from your Meta WhatsApp Business Account"
title={t('syncTitle')}
>
<RefreshCw className={`size-4 ${syncing ? 'animate-spin' : ''}`} />
{syncing ? 'Syncing…' : 'Sync from Meta'}
{syncing ? t('syncing') : t('syncFromMeta')}
</Button>
<Button onClick={openCreate}>
<Plus className="size-4" />
New Template
{t('newTemplate')}
</Button>
</div>
}
@ -508,9 +508,9 @@ export function TemplateManager() {
{templates.length === 0 ? (
<Card>
<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">
Create your first message template to get started.
{t('createFirst')}
</p>
</CardContent>
</Card>
@ -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"
>
<Pencil className="size-3.5" />
Edit
{t('edit')}
</Button>
)}
{(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"
>
<RotateCcw className="size-3.5" />
Resubmit
{t('resubmit')}
</Button>
)}
<Button
@ -604,13 +604,13 @@ export function TemplateManager() {
disabled={deletingId === template.id}
aria-label={
template.meta_template_id
? 'Delete template from Meta and locally'
: 'Delete template locally'
? t('deleteMetaLocallyAria')
: t('deleteLocallyAria')
}
title={
template.meta_template_id
? 'Delete from Meta and locally'
: 'Delete locally'
? t('deleteMetaLocallyTitle')
: t('deleteLocallyTitle')
}
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">
<DialogHeader>
<DialogTitle className="text-popover-foreground">
{editingId ? 'Edit Message Template' : 'New Message Template'}
{editingId ? t('dialogEditTitle') : t('dialogNewTitle')}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
{editingId
? 'Save your changes to re-submit to Meta. Status will flip back to PENDING during review.'
: 'Build a template and submit it to Meta for approval. Once approved, you can use it in broadcasts and the inbox.'}
? t('dialogEditDesc')
: t('dialogNewDesc')}
</DialogDescription>
</DialogHeader>
{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">
<AlertCircle className="size-4 mt-0.5 shrink-0" />
<p>
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>
<p dangerouslySetInnerHTML={{ __html: t.raw('authWarning') }} />
</div>
)}
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label className="text-muted-foreground">Template Name</Label>
<Label className="text-muted-foreground">{t('templateName')}</Label>
<Input
placeholder="e.g. order_confirmation"
placeholder={t('namePlaceholder')}
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
disabled={editingId !== null}
@ -674,14 +669,14 @@ export function TemplateManager() {
/>
<p className="text-[11px] text-muted-foreground">
{editingId
? 'Name is fixed once a template exists on Meta — create a new template to change it.'
: 'Lowercase letters, digits, and underscores only.'}
? t('nameFixed')
: t('nameHint')}
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-muted-foreground">Category</Label>
<Label className="text-muted-foreground">{t('category')}</Label>
<Select
value={form.category}
onValueChange={(val) =>
@ -709,7 +704,7 @@ export function TemplateManager() {
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">Language</Label>
<Label className="text-muted-foreground">{t('language')}</Label>
<Input
list="template-language-codes"
placeholder="en_US"
@ -726,20 +721,17 @@ export function TemplateManager() {
))}
</datalist>
<p className="text-[11px] text-muted-foreground">
{editingId
? 'Language is fixed once a template exists on Meta.'
: (
<>
Must match the exact code on Meta — <code>en_US</code>{' '}
and <code>en</code> are distinct.
</>
{editingId ? (
t('langFixed')
) : (
<span dangerouslySetInnerHTML={{ __html: t.raw('langHint') }} />
)}
</p>
</div>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">Header</Label>
<Label className="text-muted-foreground">{t('header')}</Label>
<Select
value={form.header_format}
onValueChange={(val) =>
@ -766,8 +758,14 @@ export function TemplateManager() {
className="text-popover-foreground focus:bg-muted focus:text-popover-foreground"
>
{type === 'none'
? 'None'
: type.charAt(0).toUpperCase() + type.slice(1)}
? t('headerNone')
: type === 'text'
? t('headerText')
: type === 'image'
? t('headerImage')
: type === 'video'
? t('headerVideo')
: t('headerDocument')}
</SelectItem>
))}
</SelectContent>
@ -778,7 +776,7 @@ export function TemplateManager() {
<Input
id="template-header-text"
aria-label="Header text"
placeholder="Header text (max 60 chars, optional {{1}})"
placeholder={t('headerTextPlaceholder')}
value={form.header_content}
onChange={(e) =>
setForm({ ...form, header_content: e.target.value })
@ -789,8 +787,8 @@ export function TemplateManager() {
{headerVarCount > 0 && (
<Input
id="template-header-sample"
aria-label="Sample value for header variable"
placeholder="Sample value for {{1}} (required for Meta review)"
aria-label={t('headerSampleAria')}
placeholder={t('headerSamplePlaceholder')}
value={form.header_sample}
onChange={(e) =>
setForm({ ...form, header_sample: e.target.value })
@ -828,15 +826,15 @@ export function TemplateManager() {
) : (
<Upload className="h-3.5 w-3.5" />
)}
Upload image
{t('uploadImage')}
</Button>
<span className="text-[11px] text-muted-foreground">
JPEG or PNG, ≤5 MB
{t('uploadHint')}
</span>
</div>
)}
<Input
placeholder={`https://… (or paste a public ${form.header_format} link)`}
placeholder={t('mediaUrlPlaceholder', { format: form.header_format })}
value={form.header_media_url}
onChange={(e) =>
setForm({ ...form, header_media_url: e.target.value })
@ -853,21 +851,21 @@ export function TemplateManager() {
)}
<p className="text-[11px] text-muted-foreground leading-relaxed">
{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')}
</p>
</div>
)}
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">Body Text</Label>
<Label className="text-muted-foreground">{t('bodyText')}</Label>
<Textarea
placeholder="Hello {{1}}, your order {{2}} is confirmed."
placeholder={t('bodyPlaceholder')}
value={form.body_text}
onChange={(e) =>
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"
/>
<p className="text-[11px] text-muted-foreground">
Use {`{{1}}`}, {`{{2}}`} for variables (must be contiguous
starting at {`{{1}}`}).
{t('bodyHint')}
</p>
{bodyVarCount > 0 && (
<div className="space-y-1.5 pt-1">
<Label className="text-[11px] text-muted-foreground">
Sample values (Meta uses these to review your template)
{t('sampleValues')}
</Label>
{form.body_samples.map((val, i) => {
const inputId = `template-body-sample-${i}`;
@ -892,8 +889,8 @@ export function TemplateManager() {
<Input
key={i}
id={inputId}
aria-label={`Sample value for body variable {{${i + 1}}}`}
placeholder={`Sample for {{${i + 1}}}`}
aria-label={t('sampleAria', { var: `{{${i + 1}}}` })}
placeholder={t('samplePlaceholder', { var: `{{${i + 1}}}` })}
value={val}
onChange={(e) => {
const next = [...form.body_samples];
@ -909,9 +906,9 @@ export function TemplateManager() {
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">Footer (optional)</Label>
<Label className="text-muted-foreground">{t('footer')}</Label>
<Input
placeholder="Optional footer text (max 60 chars)"
placeholder={t('footerPlaceholder')}
value={form.footer_text}
onChange={(e) =>
setForm({ ...form, footer_text: e.target.value })
@ -923,7 +920,7 @@ export function TemplateManager() {
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-muted-foreground">Buttons (optional)</Label>
<Label className="text-muted-foreground">{t('buttons')}</Label>
<Button
type="button"
variant="outline"
@ -933,13 +930,12 @@ export function TemplateManager() {
className="border-border bg-transparent text-muted-foreground hover:bg-muted h-7 text-xs"
>
<Plus className="size-3" />
Add Button
{t('addButton')}
</Button>
</div>
{form.buttons.length === 0 ? (
<p className="text-[11px] text-muted-foreground">
Up to {TEMPLATE_LIMITS.maxButtonsTotal} buttons. QUICK_REPLY
buttons must come before URL / phone / copy-code buttons.
{t('buttonsLimit', { max: TEMPLATE_LIMITS.maxButtonsTotal })}
</p>
) : (
<div className="space-y-2">
@ -967,30 +963,30 @@ export function TemplateManager() {
value="QUICK_REPLY"
className="text-popover-foreground focus:bg-muted focus:text-popover-foreground"
>
Quick Reply
{t('btnQuickReply')}
</SelectItem>
<SelectItem
value="URL"
className="text-popover-foreground focus:bg-muted focus:text-popover-foreground"
>
URL
{t('btnUrl')}
</SelectItem>
<SelectItem
value="PHONE_NUMBER"
className="text-popover-foreground focus:bg-muted focus:text-popover-foreground"
>
Phone
{t('btnPhone')}
</SelectItem>
<SelectItem
value="COPY_CODE"
className="text-popover-foreground focus:bg-muted focus:text-popover-foreground"
>
Copy Code
{t('btnCopyCode')}
</SelectItem>
</SelectContent>
</Select>
<Input
placeholder="Button label"
placeholder={t('btnLabelPlaceholder')}
value={btn.text}
maxLength={TEMPLATE_LIMITS.buttonTextMaxLength}
onChange={(e) =>
@ -1011,7 +1007,7 @@ export function TemplateManager() {
{btn.type === 'URL' && (
<div className="space-y-1 pl-1">
<Input
placeholder="https://example.com/path or with {{1}} suffix"
placeholder={t('urlPlaceholder')}
value={btn.url}
onChange={(e) =>
updateButton(i, { url: e.target.value })
@ -1020,7 +1016,7 @@ export function TemplateManager() {
/>
{extractVariableIndices(btn.url).length > 0 && (
<Input
placeholder="Example value for {{1}} (required when URL has a variable)"
placeholder={t('urlSamplePlaceholder')}
value={btn.example ?? ''}
onChange={(e) =>
updateButton(i, { example: e.target.value })
@ -1032,7 +1028,7 @@ export function TemplateManager() {
)}
{btn.type === 'PHONE_NUMBER' && (
<Input
placeholder="+15551234567"
placeholder={t('phonePlaceholder')}
value={btn.phone_number}
onChange={(e) =>
updateButton(i, { phone_number: e.target.value })
@ -1042,7 +1038,7 @@ export function TemplateManager() {
)}
{btn.type === 'COPY_CODE' && (
<Input
placeholder="Example code (e.g. SUMMER20)"
placeholder={t('codePlaceholder')}
value={btn.example}
onChange={(e) =>
updateButton(i, { example: e.target.value })
@ -1063,7 +1059,7 @@ export function TemplateManager() {
onClick={() => setDialogOpen(false)}
className="border-border text-muted-foreground hover:bg-muted"
>
Cancel
{t('cancel')}
</Button>
<Button
onClick={handleSubmit}
@ -1073,12 +1069,12 @@ export function TemplateManager() {
{submitting ? (
<>
<Loader2 className="size-4 animate-spin" />
{editingId ? 'Saving…' : 'Submitting…'}
{editingId ? t('saving') : t('submitting')}
</>
) : editingId ? (
'Save & Resubmit'
t('saveResubmit')
) : (
'Submit for Approval'
t('submitApproval')
)}
</Button>
</DialogFooter>
@ -1096,11 +1092,11 @@ export function TemplateManager() {
>
<DialogContent className="bg-popover border-border sm:max-w-sm">
<DialogHeader>
<DialogTitle className="text-popover-foreground">Delete template?</DialogTitle>
<DialogTitle className="text-popover-foreground">{t('deleteDialogTitle')}</DialogTitle>
<DialogDescription className="text-muted-foreground">
{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.`
: `"${templateToDelete?.name}" will be deleted from wacrm. It was never submitted to Meta, so no remote cleanup is needed.`}
? t('deleteMetaDesc', { name: templateToDelete.name })
: t('deleteLocalDesc', { name: templateToDelete?.name || '' })}
</DialogDescription>
</DialogHeader>
<DialogFooter className="bg-popover border-border">
@ -1110,7 +1106,7 @@ export function TemplateManager() {
disabled={deletingId !== null}
className="border-border text-muted-foreground hover:bg-muted"
>
Cancel
{t('cancel')}
</Button>
<Button
onClick={confirmDelete}
@ -1120,10 +1116,10 @@ export function TemplateManager() {
{deletingId !== null ? (
<>
<Loader2 className="size-4 animate-spin" />
Deleting…
{t('deleting')}
</>
) : (
'Delete'
t('delete')
)}
</Button>
</DialogFooter>