mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
feat(i18n): localize settings templates and profile modul
This commit is contained in:
parent
a91b8a7ba7
commit
eeea478315
263
messages/en.json
263
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 <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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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'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>
|
||||
|
||||
@ -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'll be signed out of this account
|
||||
and given a fresh personal account on their next sign-in. Their
|
||||
login isn't deleted.
|
||||
{t.rich('removeDialogDesc', {
|
||||
name: removingMember?.full_name || t('unnamed'),
|
||||
bold: (chunks: any) => <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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'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>
|
||||
|
||||
@ -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 "{tagToDelete?.name}"? 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>
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user