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