mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +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",
|
"yourAccount": "Your account",
|
||||||
"loading": "Loading…"
|
"loading": "Loading…"
|
||||||
},
|
},
|
||||||
|
"members": {
|
||||||
|
"title": "Team members",
|
||||||
|
"description": "People with access to this account. Roles control what each teammate can do.",
|
||||||
|
"inviteMember": "Invite member",
|
||||||
|
"online": "online",
|
||||||
|
"away": "away",
|
||||||
|
"offline": "offline",
|
||||||
|
"memberCount": "{count} {count, plural, =1 {member} other {members}}",
|
||||||
|
"you": "You",
|
||||||
|
"unnamed": "Unnamed",
|
||||||
|
"joined": "Joined {date}",
|
||||||
|
"remove": "Remove",
|
||||||
|
"removedToast": "Removed {name}",
|
||||||
|
"updatedToast": "Updated {name} to {role}",
|
||||||
|
"pendingInvitations": "Pending invitations",
|
||||||
|
"inviteHint": "The plaintext invite URL is only shown once at creation for security — to re-share, revoke the invite below and create a new one.",
|
||||||
|
"noPendingTitle": "No pending invitations.",
|
||||||
|
"noPendingDesc": "Click <bold>Invite member</bold> above to generate a shareable link.",
|
||||||
|
"untitledInvite": "Untitled invite",
|
||||||
|
"created": "Created {date}",
|
||||||
|
"expiresInDays": "expires in {days} {days, plural, =1 {day} other {days}}",
|
||||||
|
"expiresInHours": "expires in {hours} {hours, plural, =1 {hour} other {hours}}",
|
||||||
|
"expired": "expired",
|
||||||
|
"revoke": "Revoke",
|
||||||
|
"revokedToast": "Invitation revoked",
|
||||||
|
"removeDialogTitle": "Remove member",
|
||||||
|
"removeDialogDesc": "Remove <bold>{name}</bold> from the account? They'll be signed out of this account and given a fresh personal account on their next sign-in. Their login isn't deleted.",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"removing": "Removing...",
|
||||||
|
"removeBtn": "Remove member"
|
||||||
|
},
|
||||||
|
"invite": {
|
||||||
|
"inviteCreated": "Invite created",
|
||||||
|
"inviteCreatedDesc": "Share this link with your new teammate. They'll be able to sign up (or sign in) and join the account as <bold>{role}</bold>. The link is valid for <bold>{days} {days, plural, =1 {day} other {days}}</bold>.",
|
||||||
|
"inviteLink": "Invite link",
|
||||||
|
"copy": "Copy",
|
||||||
|
"saveLinkNow": "Save this link now.",
|
||||||
|
"saveLinkHint": "We never store the plaintext — once you close this dialog the URL is gone. To re-share, revoke this invite and create a new one.",
|
||||||
|
"sendViaWhatsApp": "Send via WhatsApp",
|
||||||
|
"done": "Done",
|
||||||
|
"dialogTitle": "Invite a teammate",
|
||||||
|
"dialogDesc": "Generate a one-time invite link. Share it via WhatsApp, Slack, or any channel you like — no email service required.",
|
||||||
|
"roleLabel": "Role",
|
||||||
|
"validForLabel": "Link valid for",
|
||||||
|
"days1": "1 day",
|
||||||
|
"days7": "7 days",
|
||||||
|
"days30": "30 days",
|
||||||
|
"labelTitle": "Label",
|
||||||
|
"optional": "(optional)",
|
||||||
|
"labelPlaceholder": "e.g. Sara — support team",
|
||||||
|
"labelHint": "Helps you remember who you sent the link to in the pending list below.",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"creating": "Creating...",
|
||||||
|
"generateLink": "Generate link",
|
||||||
|
"labelTooLong": "Label must be {max} characters or fewer",
|
||||||
|
"copied": "Invite link copied",
|
||||||
|
"clipboardBlocked": "Clipboard blocked — copy the link manually",
|
||||||
|
"whatsappMessage": "Join {accountName} on wacrm using this link (valid for {expiresInDays} days): {url}"
|
||||||
|
},
|
||||||
|
"tagsAndFields": {
|
||||||
|
"title": "Fields & tags",
|
||||||
|
"description": "Two ways to organize contacts: colour-coded tags for quick grouping, and custom fields for structured data.",
|
||||||
|
"tagsTitle": "Tags",
|
||||||
|
"tagsDesc": "Colour-coded labels for grouping and filtering contacts.",
|
||||||
|
"fieldsTitle": "Custom fields",
|
||||||
|
"fieldsDesc": "Extra contact fields (e.g. ZIP code, lead source). They appear on every contact and in the “Update Contact Field” automation action.",
|
||||||
|
"adminRole": "Admin",
|
||||||
|
"failedToLoadTags": "Failed to load tags",
|
||||||
|
"nameRequired": "Tag name is required",
|
||||||
|
"notAuthenticated": "Not authenticated",
|
||||||
|
"tagCreated": "Tag created",
|
||||||
|
"failedToCreateTag": "Failed to create tag",
|
||||||
|
"tagDeleted": "Tag deleted",
|
||||||
|
"failedToDeleteTag": "Failed to delete tag",
|
||||||
|
"deleteAria": "Delete {name}",
|
||||||
|
"noTags": "No tags yet — create your first one below.",
|
||||||
|
"placeholder": "e.g. Newsletter",
|
||||||
|
"useColor": "Use {color}",
|
||||||
|
"addTag": "Add tag",
|
||||||
|
"deleteTag": "Delete tag",
|
||||||
|
"deleteConfirm": "Delete the tag \"{name}\"? This removes it from all contacts and cannot be undone.",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"deleting": "Deleting...",
|
||||||
|
"colors": {
|
||||||
|
"red": "Red",
|
||||||
|
"orange": "Orange",
|
||||||
|
"amber": "Amber",
|
||||||
|
"emerald": "Emerald",
|
||||||
|
"cyan": "Cyan",
|
||||||
|
"blue": "Blue",
|
||||||
|
"violet": "Violet",
|
||||||
|
"pink": "Pink"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"templates": {
|
||||||
|
"title": "Message templates",
|
||||||
|
"description": "Create templates and submit them to Meta for approval. Use \"Sync from Meta\" to pull templates approved elsewhere.",
|
||||||
|
"syncFromMeta": "Sync from Meta",
|
||||||
|
"syncing": "Syncing…",
|
||||||
|
"syncTitle": "Pull approved templates from your Meta WhatsApp Business Account",
|
||||||
|
"newTemplate": "New Template",
|
||||||
|
"noTemplates": "No templates yet.",
|
||||||
|
"createFirst": "Create your first message template to get started.",
|
||||||
|
"editTitle": "Editing triggers Meta re-review — status flips to PENDING.",
|
||||||
|
"editLabel": "Edit template",
|
||||||
|
"edit": "Edit",
|
||||||
|
"resubmitTitle": "Edit the template and resubmit to Meta for review.",
|
||||||
|
"resubmitLabel": "Edit and resubmit template",
|
||||||
|
"resubmit": "Resubmit",
|
||||||
|
"deleteMetaLocallyAria": "Delete template from Meta and locally",
|
||||||
|
"deleteLocallyAria": "Delete template locally",
|
||||||
|
"deleteMetaLocallyTitle": "Delete from Meta and locally",
|
||||||
|
"deleteLocallyTitle": "Delete locally",
|
||||||
|
|
||||||
|
"dialogEditTitle": "Edit Message Template",
|
||||||
|
"dialogNewTitle": "New Message Template",
|
||||||
|
"dialogEditDesc": "Save your changes to re-submit to Meta. Status will flip back to PENDING during review.",
|
||||||
|
"dialogNewDesc": "Build a template and submit it to Meta for approval. Once approved, you can use it in broadcasts and the inbox.",
|
||||||
|
"authWarning": "AUTHENTICATION templates have a fixed body + OTP button shape that needs a different builder. Create them in Meta WhatsApp Manager for now and use <bold>Sync from Meta</bold> to bring them in.",
|
||||||
|
|
||||||
|
"templateName": "Template Name",
|
||||||
|
"namePlaceholder": "e.g. order_confirmation",
|
||||||
|
"nameFixed": "Name is fixed once a template exists on Meta — create a new template to change it.",
|
||||||
|
"nameHint": "Lowercase letters, digits, and underscores only.",
|
||||||
|
"category": "Category",
|
||||||
|
"language": "Language",
|
||||||
|
"langFixed": "Language is fixed once a template exists on Meta.",
|
||||||
|
"langHint": "Must match the exact code on Meta — <code>en_US</code> and <code>en</code> are distinct.",
|
||||||
|
|
||||||
|
"header": "Header",
|
||||||
|
"headerNone": "None",
|
||||||
|
"headerText": "Text",
|
||||||
|
"headerImage": "Image",
|
||||||
|
"headerVideo": "Video",
|
||||||
|
"headerDocument": "Document",
|
||||||
|
"headerTextPlaceholder": "Header text (max 60 chars, optional {{1}})",
|
||||||
|
"headerSampleAria": "Sample value for header variable",
|
||||||
|
"headerSamplePlaceholder": "Sample value for {{1}} (required for Meta review)",
|
||||||
|
|
||||||
|
"uploadImage": "Upload image",
|
||||||
|
"uploadHint": "JPEG or PNG, ≤5 MB",
|
||||||
|
"mediaUrlPlaceholder": "https://… (or paste a public {format} link)",
|
||||||
|
"imageHint": "Upload a JPEG/PNG (≤5 MB, ≥800×418 px recommended) or paste a public HTTPS link — we upload it to Meta for review automatically.",
|
||||||
|
"mediaHint": "Must be a publicly accessible HTTPS link. Meta fetches it once during review, so it needs to stay live for ~24 hrs.",
|
||||||
|
"videoHint": " Recommended: MP4 / 3GPP, ≤16 MB, ≤60 seconds.",
|
||||||
|
"documentHint": " Recommended: PDF, ≤100 MB.",
|
||||||
|
|
||||||
|
"bodyText": "Body Text",
|
||||||
|
"bodyPlaceholder": "Hello {{1}}, your order {{2}} is confirmed.",
|
||||||
|
"bodyHint": "Use {{1}}, {{2}} for variables (must be contiguous starting at {{1}}).",
|
||||||
|
"sampleValues": "Sample values (Meta uses these to review your template)",
|
||||||
|
"sampleAria": "Sample value for body variable {var}",
|
||||||
|
"samplePlaceholder": "Sample for {var}",
|
||||||
|
|
||||||
|
"footer": "Footer (optional)",
|
||||||
|
"footerPlaceholder": "Optional footer text (max 60 chars)",
|
||||||
|
|
||||||
|
"buttons": "Buttons (optional)",
|
||||||
|
"addButton": "Add Button",
|
||||||
|
"buttonsLimit": "Up to {max} buttons. QUICK_REPLY buttons must come before URL / phone / copy-code buttons.",
|
||||||
|
"btnQuickReply": "Quick Reply",
|
||||||
|
"btnUrl": "URL",
|
||||||
|
"btnPhone": "Phone",
|
||||||
|
"btnCopyCode": "Copy Code",
|
||||||
|
"btnLabelPlaceholder": "Button label",
|
||||||
|
"urlPlaceholder": "https://example.com/path or with {{1}} suffix",
|
||||||
|
"urlSamplePlaceholder": "Example value for {{1}} (required when URL has a variable)",
|
||||||
|
"phonePlaceholder": "+15551234567",
|
||||||
|
"codePlaceholder": "Example code (e.g. SUMMER20)",
|
||||||
|
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"saving": "Saving…",
|
||||||
|
"submitting": "Submitting…",
|
||||||
|
"saveResubmit": "Save & Resubmit",
|
||||||
|
"submitApproval": "Submit for Approval",
|
||||||
|
|
||||||
|
"deleteDialogTitle": "Delete template?",
|
||||||
|
"deleteMetaDesc": "\"{name}\" will be deleted from Meta and from wacrm. Active broadcasts using this template will start failing on their next send. This can't be undone.",
|
||||||
|
"deleteLocalDesc": "\"{name}\" will be deleted from wacrm. It was never submitted to Meta, so no remote cleanup is needed.",
|
||||||
|
"deleting": "Deleting…",
|
||||||
|
"delete": "Delete",
|
||||||
|
|
||||||
|
"toastLoadFailed": "Failed to load templates",
|
||||||
|
"toastSubmitFailed": "Failed to submit",
|
||||||
|
"toastSubmitEditSuccess": "Edit submitted — Meta typically reviews within 24 hours.",
|
||||||
|
"toastSubmitNewSuccess": "Submitted to Meta — typical review time is 24 hours. Status updates automatically.",
|
||||||
|
"toastSaveEditDry": "Template updated (dry-run — no Meta call)",
|
||||||
|
"toastSaveNewDry": "Template saved (dry-run — no Meta call)",
|
||||||
|
"toastSyncCount": "Synced {total} {total, plural, =1 {template} other {templates}} from Meta",
|
||||||
|
"toastSyncDetails": " ({inserted} new, {updated} updated)",
|
||||||
|
"toastSyncFailed": "Failed to sync: {preview}",
|
||||||
|
"toastSyncTruncated": "Synced the first 2000 templates only — your account has more. Sync again to continue, or contact support if this persists.",
|
||||||
|
"toastSyncError": "Failed to sync templates",
|
||||||
|
"toastDeleteSuccess": "Template deleted",
|
||||||
|
"toastDeleteError": "Failed to delete template",
|
||||||
|
"toastInvalidImage": "Header image must be a JPEG or PNG.",
|
||||||
|
"toastImageTooLarge": "Image is {size} MB — Meta's limit is 5 MB.",
|
||||||
|
"toastUploadSuccess": "Image uploaded.",
|
||||||
|
"toastUploadFailed": "Upload failed."
|
||||||
|
},
|
||||||
|
"roles": {
|
||||||
|
"admin": "Admin",
|
||||||
|
"adminHint": "Manage members + everything",
|
||||||
|
"agent": "Agent",
|
||||||
|
"agentHint": "Use features; no settings",
|
||||||
|
"viewer": "Viewer",
|
||||||
|
"viewerHint": "Read-only across the app",
|
||||||
|
"owner": "Owner",
|
||||||
|
"ownerHint": "Full control over account and billing"
|
||||||
|
},
|
||||||
"whatsapp": {
|
"whatsapp": {
|
||||||
"title": "WhatsApp connection",
|
"title": "WhatsApp connection",
|
||||||
"description": "Connect your Meta WhatsApp Business API. Credentials, webhook, and setup steps all live here.",
|
"description": "Connect your Meta WhatsApp Business API. Credentials, webhook, and setup steps all live here.",
|
||||||
@ -1260,6 +1470,59 @@
|
|||||||
"groups": {
|
"groups": {
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
"workspace": "Workspace"
|
"workspace": "Workspace"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "Your profile",
|
||||||
|
"description": "How you show up across the app. Your avatar and name appear in the header, sidebar, and anywhere your teammates see you.",
|
||||||
|
"changePhoto": "Change photo",
|
||||||
|
"uploadPhoto": "Upload photo",
|
||||||
|
"remove": "Remove",
|
||||||
|
"photoHint": "PNG, JPG, WebP, or GIF. Up to 2 MB.",
|
||||||
|
"displayName": "Display name",
|
||||||
|
"email": "Email",
|
||||||
|
"emailChangeHint": "Check the inbox for <bold>{oldEmail}</bold> and <bold>{newEmail}</bold> — both need to confirm before the change takes effect.",
|
||||||
|
"accountDetails": "Account details",
|
||||||
|
"role": "Role",
|
||||||
|
"joined": "Joined",
|
||||||
|
"userId": "User ID",
|
||||||
|
"loading": "Loading your profile…",
|
||||||
|
"saving": "Saving…",
|
||||||
|
"saveChanges": "Save changes",
|
||||||
|
"unsupportedImage": "Unsupported image type",
|
||||||
|
"unsupportedImageDesc": "Use PNG, JPG, WebP, or GIF.",
|
||||||
|
"imageTooLarge": "Image is too large",
|
||||||
|
"imageTooLargeDesc": "Maximum 2 MB.",
|
||||||
|
"nameRequired": "Display name is required",
|
||||||
|
"invalidEmail": "Enter a valid email address",
|
||||||
|
"uploadFailed": "Upload failed: {message}",
|
||||||
|
"saveFailed": "Save failed: {message}",
|
||||||
|
"profileSaved": "Profile saved",
|
||||||
|
"emailChangeFailed": "Email change failed: {message}",
|
||||||
|
"profileSavedEmailCheck": "Profile saved — check your email to confirm the address change",
|
||||||
|
|
||||||
|
"passwordTitle": "Password",
|
||||||
|
"passwordDesc": "Use at least {min} characters. You will stay signed in on this device after changing it.",
|
||||||
|
"currentPassword": "Current password",
|
||||||
|
"newPassword": "New password",
|
||||||
|
"confirmPassword": "Confirm new password",
|
||||||
|
"updating": "Updating…",
|
||||||
|
"updatePassword": "Update password",
|
||||||
|
"cannotChangeNoEmail": "Cannot change password without a current email",
|
||||||
|
"passwordTooShort": "Password must be at least {min} characters",
|
||||||
|
"passwordMismatch": "New password and confirmation do not match",
|
||||||
|
"currentPasswordIncorrect": "Current password is incorrect",
|
||||||
|
"passwordUpdateFailed": "Password update failed: {message}",
|
||||||
|
"passwordUpdated": "Password updated",
|
||||||
|
|
||||||
|
"sessionsTitle": "Active sessions",
|
||||||
|
"sessionsDesc": "Sign out of every device where you're logged in — including this one. Useful if you lost a laptop or shared your password.",
|
||||||
|
"signOutAll": "Sign out of all devices",
|
||||||
|
"signOutConfirmTitle": "Sign out everywhere?",
|
||||||
|
"signOutConfirmDesc": "Every device logged into this account will be signed out and will need to log in again. You will be redirected to the login page.",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"signingOut": "Signing out…",
|
||||||
|
"signOutEverywhere": "Sign out everywhere",
|
||||||
|
"signOutFailed": "Sign-out failed: {message}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import { CustomFieldsPanel } from '@/components/contacts/custom-fields-manager';
|
import { CustomFieldsPanel } from '@/components/contacts/custom-fields-manager';
|
||||||
import { SettingsChip } from './settings-chip';
|
import { SettingsChip } from './settings-chip';
|
||||||
|
|
||||||
@ -19,20 +20,21 @@ import { SettingsChip } from './settings-chip';
|
|||||||
* `custom_fields` RLS.
|
* `custom_fields` RLS.
|
||||||
*/
|
*/
|
||||||
export function CustomFieldsSettings() {
|
export function CustomFieldsSettings() {
|
||||||
|
const t = useTranslations('Settings.tagsAndFields');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-foreground">
|
<CardTitle className="flex items-center gap-2 text-foreground">
|
||||||
<SlidersHorizontal className="size-4 text-primary" />
|
<SlidersHorizontal className="size-4 text-primary" />
|
||||||
Custom fields
|
{t('fieldsTitle')}
|
||||||
<SettingsChip variant="admin" className="font-medium">
|
<SettingsChip variant="admin" className="font-medium">
|
||||||
<Shield />
|
<Shield />
|
||||||
Admin
|
{t('adminRole')}
|
||||||
</SettingsChip>
|
</SettingsChip>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-muted-foreground">
|
<CardDescription className="text-muted-foreground">
|
||||||
Extra contact fields (e.g. ZIP code, lead source). They appear on
|
{t('fieldsDesc')}
|
||||||
every contact and in the “Update Contact Field” automation action.
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { useCan } from '@/hooks/use-can';
|
import { useCan } from '@/hooks/use-can';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
import { CustomFieldsSettings } from './custom-fields-settings';
|
import { CustomFieldsSettings } from './custom-fields-settings';
|
||||||
import { SettingsPanelHead } from './settings-panel-head';
|
import { SettingsPanelHead } from './settings-panel-head';
|
||||||
import { TagManager } from './tag-manager';
|
import { TagManager } from './tag-manager';
|
||||||
@ -14,13 +16,14 @@ import { TagManager } from './tag-manager';
|
|||||||
* regardless.
|
* regardless.
|
||||||
*/
|
*/
|
||||||
export function FieldsAndTagsPanel() {
|
export function FieldsAndTagsPanel() {
|
||||||
|
const t = useTranslations('Settings.tagsAndFields');
|
||||||
const canEditSettings = useCan('edit-settings');
|
const canEditSettings = useCan('edit-settings');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="max-w-3xl animate-in fade-in-50 space-y-4 duration-200">
|
<section className="max-w-3xl animate-in fade-in-50 space-y-4 duration-200">
|
||||||
<SettingsPanelHead
|
<SettingsPanelHead
|
||||||
title="Fields & tags"
|
title={t('title')}
|
||||||
description="Two ways to organize contacts: colour-coded tags for quick grouping, and custom fields for structured data."
|
description={t('description')}
|
||||||
/>
|
/>
|
||||||
<TagManager />
|
<TagManager />
|
||||||
{canEditSettings ? <CustomFieldsSettings /> : null}
|
{canEditSettings ? <CustomFieldsSettings /> : null}
|
||||||
|
|||||||
@ -36,6 +36,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import { useAuth } from '@/hooks/use-auth';
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
|
|
||||||
type InviteRole = 'admin' | 'agent' | 'viewer';
|
type InviteRole = 'admin' | 'agent' | 'viewer';
|
||||||
@ -48,19 +49,17 @@ interface InviteMemberDialogProps {
|
|||||||
onCreated: () => void;
|
onCreated: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EXPIRY_OPTIONS: { value: string; label: string }[] = [
|
const EXPIRY_OPTIONS = [
|
||||||
{ value: '1', label: '1 day' },
|
{ value: '1', labelKey: 'days1' },
|
||||||
{ value: '7', label: '7 days' },
|
{ value: '7', labelKey: 'days7' },
|
||||||
{ value: '30', label: '30 days' },
|
{ value: '30', labelKey: 'days30' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ROLE_DESCRIPTIONS: Record<InviteRole, string> = {
|
const ROLE_DESCRIPTIONS = [
|
||||||
admin:
|
'adminHint',
|
||||||
'Can invite teammates, manage settings, send messages, and edit data.',
|
'agentHint',
|
||||||
agent:
|
'viewerHint',
|
||||||
'Can use the inbox, contacts, broadcasts, automations, and flows. No settings or member access.',
|
];
|
||||||
viewer: 'Read-only access across every page. Cannot send or edit anything.',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Server caps label at 80 chars (see src/app/api/account/invitations/route.ts).
|
// Server caps label at 80 chars (see src/app/api/account/invitations/route.ts).
|
||||||
// Mirror it on the client so we short-circuit before the round-trip
|
// Mirror it on the client so we short-circuit before the round-trip
|
||||||
@ -81,6 +80,8 @@ export function InviteMemberDialog({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
onCreated,
|
onCreated,
|
||||||
}: InviteMemberDialogProps) {
|
}: InviteMemberDialogProps) {
|
||||||
|
const t = useTranslations('Settings.invite');
|
||||||
|
const tRoles = useTranslations('Settings.roles');
|
||||||
const { account } = useAuth();
|
const { account } = useAuth();
|
||||||
const [role, setRole] = useState<InviteRole>('agent');
|
const [role, setRole] = useState<InviteRole>('agent');
|
||||||
const [expiry, setExpiry] = useState<string>('7');
|
const [expiry, setExpiry] = useState<string>('7');
|
||||||
@ -105,7 +106,7 @@ export function InviteMemberDialog({
|
|||||||
// net for that path.
|
// net for that path.
|
||||||
const trimmedLabel = label.trim();
|
const trimmedLabel = label.trim();
|
||||||
if (trimmedLabel.length > MAX_LABEL_LEN) {
|
if (trimmedLabel.length > MAX_LABEL_LEN) {
|
||||||
toast.error(`Label must be ${MAX_LABEL_LEN} characters or fewer`);
|
toast.error(t('labelTooLong', { max: MAX_LABEL_LEN }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@ -155,12 +156,12 @@ export function InviteMemberDialog({
|
|||||||
if (!result) return;
|
if (!result) return;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(result.url);
|
await navigator.clipboard.writeText(result.url);
|
||||||
toast.success('Invite link copied');
|
toast.success(t('copied'));
|
||||||
} catch {
|
} catch {
|
||||||
// Most likely "not in a secure context" — happens on http://
|
// Most likely "not in a secure context" — happens on http://
|
||||||
// local IPs. Surface the link in the toast so the admin can
|
// local IPs. Surface the link in the toast so the admin can
|
||||||
// hand-copy it.
|
// hand-copy it.
|
||||||
toast.error('Clipboard blocked — copy the link manually');
|
toast.error(t('clipboardBlocked'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -170,7 +171,7 @@ export function InviteMemberDialog({
|
|||||||
// for users in multi-team contexts where "our wacrm account"
|
// for users in multi-team contexts where "our wacrm account"
|
||||||
// wouldn't be enough to disambiguate.
|
// wouldn't be enough to disambiguate.
|
||||||
const accountName = result?.accountName ?? 'our wacrm account';
|
const accountName = result?.accountName ?? 'our wacrm account';
|
||||||
const message = `Join ${accountName} on wacrm using this link (valid for ${result?.expiresInDays} days): ${url}`;
|
const message = t('whatsappMessage', { accountName, expiresInDays: result?.expiresInDays ?? 0, url });
|
||||||
return `https://wa.me/?text=${encodeURIComponent(message)}`;
|
return `https://wa.me/?text=${encodeURIComponent(message)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -191,22 +192,19 @@ export function InviteMemberDialog({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2 text-popover-foreground">
|
<DialogTitle className="flex items-center gap-2 text-popover-foreground">
|
||||||
<Sparkles className="size-4 text-primary" />
|
<Sparkles className="size-4 text-primary" />
|
||||||
Invite created
|
{t('inviteCreated')}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-muted-foreground">
|
<DialogDescription className="text-muted-foreground">
|
||||||
Share this link with your new teammate. They'll be able
|
{t.rich('inviteCreatedDesc', {
|
||||||
to sign up (or sign in) and join the account as{' '}
|
role: tRoles(result.role),
|
||||||
<span className="font-medium text-muted-foreground">{result.role}</span>
|
days: result.expiresInDays,
|
||||||
. The link is valid for{' '}
|
bold: (chunks: any) => <strong>{chunks}</strong>
|
||||||
<span className="font-medium text-muted-foreground">
|
})}
|
||||||
{result.expiresInDays} day{result.expiresInDays === 1 ? '' : 's'}
|
|
||||||
</span>
|
|
||||||
.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-3 py-2">
|
<div className="space-y-3 py-2">
|
||||||
<Label className="text-muted-foreground">Invite link</Label>
|
<Label className="text-muted-foreground">{t('inviteLink')}</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
@ -220,7 +218,7 @@ export function InviteMemberDialog({
|
|||||||
className="bg-primary hover:bg-primary/90 text-primary-foreground shrink-0"
|
className="bg-primary hover:bg-primary/90 text-primary-foreground shrink-0"
|
||||||
>
|
>
|
||||||
<Copy className="size-4" />
|
<Copy className="size-4" />
|
||||||
Copy
|
{t('copy')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -231,11 +229,9 @@ export function InviteMemberDialog({
|
|||||||
intro, amber-200 for the body. */}
|
intro, amber-200 for the body. */}
|
||||||
<div className="rounded-md border border-amber-500/50 bg-amber-500/15 px-3 py-2 text-xs text-amber-200">
|
<div className="rounded-md border border-amber-500/50 bg-amber-500/15 px-3 py-2 text-xs text-amber-200">
|
||||||
<strong className="font-semibold text-amber-100">
|
<strong className="font-semibold text-amber-100">
|
||||||
Save this link now.
|
{t('saveLinkNow')}
|
||||||
</strong>{' '}
|
</strong>{' '}
|
||||||
We never store the plaintext — once you close this dialog
|
{t('saveLinkHint')}
|
||||||
the URL is gone. To re-share, revoke this invite and create
|
|
||||||
a new one.
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Anchor styled with `buttonVariants` rather than wrapping
|
{/* Anchor styled with `buttonVariants` rather than wrapping
|
||||||
@ -254,7 +250,7 @@ export function InviteMemberDialog({
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<MessageCircle className="size-4" />
|
<MessageCircle className="size-4" />
|
||||||
Send via WhatsApp
|
{t('sendViaWhatsApp')}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -263,23 +259,22 @@ export function InviteMemberDialog({
|
|||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
className="bg-primary hover:bg-primary/90 text-primary-foreground"
|
className="bg-primary hover:bg-primary/90 text-primary-foreground"
|
||||||
>
|
>
|
||||||
Done
|
{t('done')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-popover-foreground">Invite a teammate</DialogTitle>
|
<DialogTitle className="text-popover-foreground">{t('dialogTitle')}</DialogTitle>
|
||||||
<DialogDescription className="text-muted-foreground">
|
<DialogDescription className="text-muted-foreground">
|
||||||
Generate a one-time invite link. Share it via WhatsApp,
|
{t('dialogDesc')}
|
||||||
Slack, or any channel you like — no email service required.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-2">
|
<div className="space-y-4 py-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-muted-foreground">Role</Label>
|
<Label className="text-muted-foreground">{t('roleLabel')}</Label>
|
||||||
<Select
|
<Select
|
||||||
value={role}
|
value={role}
|
||||||
onValueChange={(v) => v && setRole(v as InviteRole)}
|
onValueChange={(v) => v && setRole(v as InviteRole)}
|
||||||
@ -288,18 +283,18 @@ export function InviteMemberDialog({
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="admin">Admin</SelectItem>
|
<SelectItem value="admin">{tRoles('admin')}</SelectItem>
|
||||||
<SelectItem value="agent">Agent</SelectItem>
|
<SelectItem value="agent">{tRoles('agent')}</SelectItem>
|
||||||
<SelectItem value="viewer">Viewer</SelectItem>
|
<SelectItem value="viewer">{tRoles('viewer')}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{ROLE_DESCRIPTIONS[role]}
|
{tRoles(`${role}Hint` as 'adminHint' | 'agentHint' | 'viewerHint')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-muted-foreground">Link valid for</Label>
|
<Label className="text-muted-foreground">{t('validForLabel')}</Label>
|
||||||
<Select
|
<Select
|
||||||
value={expiry}
|
value={expiry}
|
||||||
onValueChange={(v) => v && setExpiry(v)}
|
onValueChange={(v) => v && setExpiry(v)}
|
||||||
@ -310,7 +305,7 @@ export function InviteMemberDialog({
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{EXPIRY_OPTIONS.map((opt) => (
|
{EXPIRY_OPTIONS.map((opt) => (
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
{opt.label}
|
{t(opt.labelKey as any)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@ -319,19 +314,18 @@ export function InviteMemberDialog({
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-muted-foreground">
|
<Label className="text-muted-foreground">
|
||||||
Label{' '}
|
{t('labelTitle')}{' '}
|
||||||
<span className="text-xs text-muted-foreground">(optional)</span>
|
<span className="text-xs text-muted-foreground">{t('optional')}</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="e.g. Sara — support team"
|
placeholder={t('labelPlaceholder')}
|
||||||
value={label}
|
value={label}
|
||||||
onChange={(e) => setLabel(e.target.value)}
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
maxLength={MAX_LABEL_LEN}
|
maxLength={MAX_LABEL_LEN}
|
||||||
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
|
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Helps you remember who you sent the link to in the pending
|
{t('labelHint')}
|
||||||
list below.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -342,7 +336,7 @@ export function InviteMemberDialog({
|
|||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
className="border-border text-muted-foreground hover:bg-muted"
|
className="border-border text-muted-foreground hover:bg-muted"
|
||||||
>
|
>
|
||||||
Cancel
|
{t('cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
@ -352,10 +346,10 @@ export function InviteMemberDialog({
|
|||||||
{submitting ? (
|
{submitting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
Creating...
|
{t('creating')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Generate link'
|
t('generateLink')
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@ -62,6 +62,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import { RequireRole } from '@/components/auth/require-role';
|
import { RequireRole } from '@/components/auth/require-role';
|
||||||
import { useAuth } from '@/hooks/use-auth';
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
import { usePresence } from '@/hooks/use-presence';
|
import { usePresence } from '@/hooks/use-presence';
|
||||||
@ -92,12 +93,11 @@ interface Invitation {
|
|||||||
expires_at: string;
|
expires_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Editable roles in the inline dropdown. Owner is never an option —
|
// These roles are translated via `useTranslations("Settings.roles")` where they are used.
|
||||||
// promotions go through the (deferred) Transfer Ownership flow.
|
const EDITABLE_ROLES: { value: AccountRole }[] = [
|
||||||
const EDITABLE_ROLES: { value: AccountRole; label: string; hint: string }[] = [
|
{ value: 'admin' },
|
||||||
{ value: 'admin', label: 'Admin', hint: 'Manage members + everything' },
|
{ value: 'agent' },
|
||||||
{ value: 'agent', label: 'Agent', hint: 'Use features; no settings' },
|
{ value: 'viewer' },
|
||||||
{ value: 'viewer', label: 'Viewer', hint: 'Read-only across the app' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Per-role chip metadata (icon / label / colour) lives in the shared
|
// Per-role chip metadata (icon / label / colour) lives in the shared
|
||||||
@ -115,16 +115,18 @@ function fmtDate(iso: string): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtExpiresIn(iso: string): string {
|
function fmtExpiresIn(iso: string, t: any): string {
|
||||||
const ms = new Date(iso).getTime() - Date.now();
|
const ms = new Date(iso).getTime() - Date.now();
|
||||||
if (ms <= 0) return 'expired';
|
if (ms <= 0) return t('expired');
|
||||||
const days = Math.floor(ms / (24 * 60 * 60 * 1000));
|
const days = Math.floor(ms / (24 * 60 * 60 * 1000));
|
||||||
if (days >= 1) return `expires in ${days} day${days === 1 ? '' : 's'}`;
|
if (days >= 1) return t('expiresInDays', { days });
|
||||||
const hours = Math.max(1, Math.floor(ms / (60 * 60 * 1000)));
|
const hours = Math.max(1, Math.floor(ms / (60 * 60 * 1000)));
|
||||||
return `expires in ${hours} hour${hours === 1 ? '' : 's'}`;
|
return t('expiresInHours', { hours });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MembersTab() {
|
export function MembersTab() {
|
||||||
|
const t = useTranslations('Settings.members');
|
||||||
|
const tRoles = useTranslations('Settings.roles');
|
||||||
const { user, canManageMembers } = useAuth();
|
const { user, canManageMembers } = useAuth();
|
||||||
const { getPresence, getRow, now } = usePresence();
|
const { getPresence, getRow, now } = usePresence();
|
||||||
|
|
||||||
@ -211,7 +213,7 @@ export function MembersTab() {
|
|||||||
toast.error(payload.error || 'Failed to update role');
|
toast.error(payload.error || 'Failed to update role');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.success(`Updated ${member.full_name || 'member'} to ${nextRole}`);
|
toast.success(t('updatedToast', { name: member.full_name || t('unnamed'), role: tRoles(nextRole) }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Same revert on network failure.
|
// Same revert on network failure.
|
||||||
setMembers((prev) =>
|
setMembers((prev) =>
|
||||||
@ -239,7 +241,7 @@ export function MembersTab() {
|
|||||||
toast.error(payload.error || 'Failed to remove member');
|
toast.error(payload.error || 'Failed to remove member');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.success(`Removed ${removingMember.full_name || 'member'}`);
|
toast.success(t('removedToast', { name: removingMember.full_name || t('unnamed') }));
|
||||||
setMembers((prev) =>
|
setMembers((prev) =>
|
||||||
prev.filter((m) => m.user_id !== removingMember.user_id),
|
prev.filter((m) => m.user_id !== removingMember.user_id),
|
||||||
);
|
);
|
||||||
@ -262,7 +264,7 @@ export function MembersTab() {
|
|||||||
toast.error(payload.error || 'Failed to revoke invitation');
|
toast.error(payload.error || 'Failed to revoke invitation');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.success('Invitation revoked');
|
toast.success(t('revokedToast'));
|
||||||
setInvitations((prev) => prev.filter((i) => i.id !== invite.id));
|
setInvitations((prev) => prev.filter((i) => i.id !== invite.id));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[MembersTab] revoke error:', err);
|
console.error('[MembersTab] revoke error:', err);
|
||||||
@ -281,13 +283,13 @@ export function MembersTab() {
|
|||||||
return (
|
return (
|
||||||
<section className="animate-in fade-in-50 space-y-6 duration-200">
|
<section className="animate-in fade-in-50 space-y-6 duration-200">
|
||||||
<SettingsPanelHead
|
<SettingsPanelHead
|
||||||
title="Team members"
|
title={t('title')}
|
||||||
description="People with access to this account. Roles control what each teammate can do."
|
description={t('description')}
|
||||||
action={
|
action={
|
||||||
<RequireRole min="admin">
|
<RequireRole min="admin">
|
||||||
<Button onClick={() => setInviteOpen(true)}>
|
<Button onClick={() => setInviteOpen(true)}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
Invite member
|
{t('inviteMember')}
|
||||||
</Button>
|
</Button>
|
||||||
</RequireRole>
|
</RequireRole>
|
||||||
}
|
}
|
||||||
@ -302,18 +304,18 @@ export function MembersTab() {
|
|||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<PresenceDot status="online" />
|
<PresenceDot status="online" />
|
||||||
{counts.online} online
|
{counts.online} {t('online')}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<PresenceDot status="away" />
|
<PresenceDot status="away" />
|
||||||
{counts.away} away
|
{counts.away} {t('away')}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<PresenceDot status="offline" />
|
<PresenceDot status="offline" />
|
||||||
{counts.offline} offline
|
{counts.offline} {t('offline')}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground/70">
|
<span className="text-muted-foreground/70">
|
||||||
· {members.length} member{members.length === 1 ? '' : 's'}
|
· {t('memberCount', { count: members.length })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -381,11 +383,11 @@ export function MembersTab() {
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="truncate text-sm font-medium text-foreground">
|
<span className="truncate text-sm font-medium text-foreground">
|
||||||
{member.full_name || 'Unnamed'}
|
{member.full_name || t('unnamed')}
|
||||||
</span>
|
</span>
|
||||||
{isSelf && (
|
{isSelf && (
|
||||||
<Badge className="bg-muted text-muted-foreground border-border text-[10px] uppercase tracking-wide">
|
<Badge className="bg-muted text-muted-foreground border-border text-[10px] uppercase tracking-wide">
|
||||||
You
|
{t('you')}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -400,7 +402,7 @@ export function MembersTab() {
|
|||||||
{/* Joined date stays desktop-only. The mobile row's
|
{/* Joined date stays desktop-only. The mobile row's
|
||||||
vertical density makes the joined date noise. */}
|
vertical density makes the joined date noise. */}
|
||||||
<div className="hidden sm:block text-right text-xs text-muted-foreground">
|
<div className="hidden sm:block text-right text-xs text-muted-foreground">
|
||||||
Joined {fmtDate(member.joined_at)}
|
{t('joined', { date: fmtDate(member.joined_at) })}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions cluster. On mobile this is its own row
|
{/* Actions cluster. On mobile this is its own row
|
||||||
@ -431,7 +433,7 @@ export function MembersTab() {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{EDITABLE_ROLES.map((r) => (
|
{EDITABLE_ROLES.map((r) => (
|
||||||
<SelectItem key={r.value} value={r.value}>
|
<SelectItem key={r.value} value={r.value}>
|
||||||
{r.label}
|
{tRoles(r.value)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@ -441,7 +443,7 @@ export function MembersTab() {
|
|||||||
className={`inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium ${roleMeta.className}`}
|
className={`inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium ${roleMeta.className}`}
|
||||||
>
|
>
|
||||||
<RoleIcon className="size-3.5" />
|
<RoleIcon className="size-3.5" />
|
||||||
{roleMeta.label}
|
{tRoles(member.role)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -477,7 +479,7 @@ export function MembersTab() {
|
|||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<UsersRound className="size-4 text-muted-foreground" />
|
<UsersRound className="size-4 text-muted-foreground" />
|
||||||
<h3 className="text-sm font-semibold text-foreground">
|
<h3 className="text-sm font-semibold text-foreground">
|
||||||
Pending invitations
|
{t('pendingInvitations')}
|
||||||
</h3>
|
</h3>
|
||||||
<Badge className="bg-muted text-muted-foreground border-border">
|
<Badge className="bg-muted text-muted-foreground border-border">
|
||||||
{invitations.length}
|
{invitations.length}
|
||||||
@ -490,9 +492,7 @@ export function MembersTab() {
|
|||||||
looking for a button) keeps it from feeling like a bug. */}
|
looking for a button) keeps it from feeling like a bug. */}
|
||||||
{invitations.length > 0 ? (
|
{invitations.length > 0 ? (
|
||||||
<p className="mb-3 text-xs text-muted-foreground">
|
<p className="mb-3 text-xs text-muted-foreground">
|
||||||
The plaintext invite URL is only shown once at creation
|
{t('inviteHint')}
|
||||||
for security — to re-share, revoke the invite below and
|
|
||||||
create a new one.
|
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@ -501,12 +501,9 @@ export function MembersTab() {
|
|||||||
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
<Mail className="size-6 text-muted-foreground" />
|
<Mail className="size-6 text-muted-foreground" />
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
No pending invitations.
|
{t('noPendingTitle')}
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
Click <span className="text-muted-foreground">Invite member</span>{' '}
|
|
||||||
above to generate a shareable link.
|
|
||||||
</p>
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground" dangerouslySetInnerHTML={{ __html: t.raw('noPendingDesc') }} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
@ -524,17 +521,17 @@ export function MembersTab() {
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-medium text-foreground">
|
<span className="text-sm font-medium text-foreground">
|
||||||
{inv.label || 'Untitled invite'}
|
{inv.label || t('untitledInvite')}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[11px] font-medium ${inviteRoleMeta.className}`}
|
className={`inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[11px] font-medium ${inviteRoleMeta.className}`}
|
||||||
>
|
>
|
||||||
<InviteRoleIcon className="size-3" />
|
<InviteRoleIcon className="size-3" />
|
||||||
{inviteRoleMeta.label}
|
{tRoles(inv.role)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
Created {fmtDate(inv.created_at)} · {fmtExpiresIn(inv.expires_at)}
|
{t('created', { date: fmtDate(inv.created_at) })} · {fmtExpiresIn(inv.expires_at, t)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -549,7 +546,7 @@ export function MembersTab() {
|
|||||||
className="border-red-500/40 bg-red-500/10 text-red-300 hover:bg-red-500/20 hover:border-red-500/60 hover:text-red-200"
|
className="border-red-500/40 bg-red-500/10 text-red-300 hover:bg-red-500/20 hover:border-red-500/60 hover:text-red-200"
|
||||||
>
|
>
|
||||||
<MailX className="size-4" />
|
<MailX className="size-4" />
|
||||||
Revoke
|
{t('revoke')}
|
||||||
</Button>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@ -577,16 +574,13 @@ export function MembersTab() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2 text-popover-foreground">
|
<DialogTitle className="flex items-center gap-2 text-popover-foreground">
|
||||||
<AlertTriangle className="size-4 text-amber-400" />
|
<AlertTriangle className="size-4 text-amber-400" />
|
||||||
Remove member
|
{t('removeDialogTitle')}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-muted-foreground">
|
<DialogDescription className="text-muted-foreground">
|
||||||
Remove{' '}
|
{t.rich('removeDialogDesc', {
|
||||||
<span className="font-medium text-muted-foreground">
|
name: removingMember?.full_name || t('unnamed'),
|
||||||
{removingMember?.full_name || 'this teammate'}
|
bold: (chunks: any) => <strong>{chunks}</strong>
|
||||||
</span>{' '}
|
})}
|
||||||
from the account? They'll be signed out of this account
|
|
||||||
and given a fresh personal account on their next sign-in. Their
|
|
||||||
login isn't deleted.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter className="bg-popover border-border">
|
<DialogFooter className="bg-popover border-border">
|
||||||
@ -595,7 +589,7 @@ export function MembersTab() {
|
|||||||
onClick={() => setRemovingMember(null)}
|
onClick={() => setRemovingMember(null)}
|
||||||
className="border-border text-muted-foreground hover:bg-muted"
|
className="border-border text-muted-foreground hover:bg-muted"
|
||||||
>
|
>
|
||||||
Cancel
|
{t('cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleRemove}
|
onClick={handleRemove}
|
||||||
@ -605,10 +599,10 @@ export function MembersTab() {
|
|||||||
{pendingMemberAction ? (
|
{pendingMemberAction ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
Removing...
|
{t('removing')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Remove member'
|
t('removeBtn')
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@ -16,10 +16,12 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
const MIN_PASSWORD = 8;
|
const MIN_PASSWORD = 8;
|
||||||
|
|
||||||
export function PasswordForm() {
|
export function PasswordForm() {
|
||||||
|
const t = useTranslations('Settings.profile');
|
||||||
const { profile } = useAuth();
|
const { profile } = useAuth();
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
|
|
||||||
@ -32,15 +34,15 @@ export function PasswordForm() {
|
|||||||
const onSubmit = async (e: React.FormEvent) => {
|
const onSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!profile?.email) {
|
if (!profile?.email) {
|
||||||
toast.error('Cannot change password without a current email');
|
toast.error(t('cannotChangeNoEmail'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (next.length < MIN_PASSWORD) {
|
if (next.length < MIN_PASSWORD) {
|
||||||
setConfirmError(`Password must be at least ${MIN_PASSWORD} characters`);
|
setConfirmError(t('passwordTooShort', { min: MIN_PASSWORD }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (next !== confirm) {
|
if (next !== confirm) {
|
||||||
setConfirmError('New password and confirmation do not match');
|
setConfirmError(t('passwordMismatch'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setConfirmError(null);
|
setConfirmError(null);
|
||||||
@ -56,7 +58,7 @@ export function PasswordForm() {
|
|||||||
password: current,
|
password: current,
|
||||||
});
|
});
|
||||||
if (signInError) {
|
if (signInError) {
|
||||||
toast.error('Current password is incorrect');
|
toast.error(t('currentPasswordIncorrect'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -64,14 +66,14 @@ export function PasswordForm() {
|
|||||||
password: next,
|
password: next,
|
||||||
});
|
});
|
||||||
if (updateError) {
|
if (updateError) {
|
||||||
toast.error(`Password update failed: ${updateError.message}`);
|
toast.error(t('passwordUpdateFailed', { message: updateError.message }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrent('');
|
setCurrent('');
|
||||||
setNext('');
|
setNext('');
|
||||||
setConfirm('');
|
setConfirm('');
|
||||||
toast.success('Password updated');
|
toast.success(t('passwordUpdated'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : 'Unknown error';
|
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||||
toast.error(msg);
|
toast.error(msg);
|
||||||
@ -85,11 +87,10 @@ export function PasswordForm() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-foreground">
|
<CardTitle className="flex items-center gap-2 text-foreground">
|
||||||
<KeyRound className="size-4 text-primary" />
|
<KeyRound className="size-4 text-primary" />
|
||||||
Password
|
{t('passwordTitle')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-muted-foreground">
|
<CardDescription className="text-muted-foreground">
|
||||||
Use at least {MIN_PASSWORD} characters. You will stay signed in on
|
{t('passwordDesc', { min: MIN_PASSWORD })}
|
||||||
this device after changing it.
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
@ -97,7 +98,7 @@ export function PasswordForm() {
|
|||||||
<form onSubmit={onSubmit} className="space-y-4">
|
<form onSubmit={onSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="current-password" className="text-foreground">
|
<Label htmlFor="current-password" className="text-foreground">
|
||||||
Current password
|
{t('currentPassword')}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="current-password"
|
id="current-password"
|
||||||
@ -113,7 +114,7 @@ export function PasswordForm() {
|
|||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="new-password" className="text-foreground">
|
<Label htmlFor="new-password" className="text-foreground">
|
||||||
New password
|
{t('newPassword')}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="new-password"
|
id="new-password"
|
||||||
@ -128,7 +129,7 @@ export function PasswordForm() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="confirm-password" className="text-foreground">
|
<Label htmlFor="confirm-password" className="text-foreground">
|
||||||
Confirm new password
|
{t('confirmPassword')}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="confirm-password"
|
id="confirm-password"
|
||||||
@ -157,10 +158,10 @@ export function PasswordForm() {
|
|||||||
{saving ? (
|
{saving ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
Updating…
|
{t('updating')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Update password'
|
t('updatePassword')
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
AvatarImage,
|
AvatarImage,
|
||||||
} from '@/components/ui/avatar';
|
} from '@/components/ui/avatar';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import { SettingsPanelHead } from './settings-panel-head';
|
import { SettingsPanelHead } from './settings-panel-head';
|
||||||
|
|
||||||
const MAX_AVATAR_BYTES = 2 * 1024 * 1024;
|
const MAX_AVATAR_BYTES = 2 * 1024 * 1024;
|
||||||
@ -31,6 +32,7 @@ const ALLOWED_MIME = new Set([
|
|||||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
export function ProfileForm() {
|
export function ProfileForm() {
|
||||||
|
const t = useTranslations('Settings.profile');
|
||||||
const { user, profile, refreshProfile } = useAuth();
|
const { user, profile, refreshProfile } = useAuth();
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@ -70,14 +72,14 @@ export function ProfileForm() {
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
if (!ALLOWED_MIME.has(file.type)) {
|
if (!ALLOWED_MIME.has(file.type)) {
|
||||||
toast.error('Unsupported image type', {
|
toast.error(t('unsupportedImage'), {
|
||||||
description: 'Use PNG, JPG, WebP, or GIF.',
|
description: t('unsupportedImageDesc'),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (file.size > MAX_AVATAR_BYTES) {
|
if (file.size > MAX_AVATAR_BYTES) {
|
||||||
toast.error('Image is too large', {
|
toast.error(t('imageTooLarge'), {
|
||||||
description: 'Maximum 2 MB.',
|
description: t('imageTooLargeDesc'),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -101,12 +103,12 @@ export function ProfileForm() {
|
|||||||
|
|
||||||
const trimmedName = fullName.trim();
|
const trimmedName = fullName.trim();
|
||||||
if (!trimmedName) {
|
if (!trimmedName) {
|
||||||
toast.error('Display name is required');
|
toast.error(t('nameRequired'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const trimmedEmail = email.trim();
|
const trimmedEmail = email.trim();
|
||||||
if (!EMAIL_RE.test(trimmedEmail)) {
|
if (!EMAIL_RE.test(trimmedEmail)) {
|
||||||
toast.error('Enter a valid email address');
|
toast.error(t('invalidEmail'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,7 +129,7 @@ export function ProfileForm() {
|
|||||||
contentType: pendingAvatar.type,
|
contentType: pendingAvatar.type,
|
||||||
});
|
});
|
||||||
if (uploadError) {
|
if (uploadError) {
|
||||||
throw new Error(`Upload failed: ${uploadError.message}`);
|
throw new Error(t('uploadFailed', { message: uploadError.message }));
|
||||||
}
|
}
|
||||||
const {
|
const {
|
||||||
data: { publicUrl },
|
data: { publicUrl },
|
||||||
@ -146,7 +148,7 @@ export function ProfileForm() {
|
|||||||
})
|
})
|
||||||
.eq('user_id', user.id);
|
.eq('user_id', user.id);
|
||||||
if (updateError) {
|
if (updateError) {
|
||||||
throw new Error(`Save failed: ${updateError.message}`);
|
throw new Error(t('saveFailed', { message: updateError.message }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Email change goes through Supabase Auth, which emails a
|
// Email change goes through Supabase Auth, which emails a
|
||||||
@ -161,8 +163,8 @@ export function ProfileForm() {
|
|||||||
});
|
});
|
||||||
if (emailError) {
|
if (emailError) {
|
||||||
// Partial success: name/avatar saved but email didn't.
|
// Partial success: name/avatar saved but email didn't.
|
||||||
toast.success('Profile saved');
|
toast.success(t('profileSaved'));
|
||||||
toast.error(`Email change failed: ${emailError.message}`);
|
toast.error(t('emailChangeFailed', { message: emailError.message }));
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
await refreshProfile();
|
await refreshProfile();
|
||||||
return;
|
return;
|
||||||
@ -178,8 +180,8 @@ export function ProfileForm() {
|
|||||||
|
|
||||||
toast.success(
|
toast.success(
|
||||||
emailSent
|
emailSent
|
||||||
? 'Profile saved — check your email to confirm the address change'
|
? t('profileSavedEmailCheck')
|
||||||
: 'Profile saved',
|
: t('profileSaved'),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : 'Unknown error';
|
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||||
@ -207,8 +209,8 @@ export function ProfileForm() {
|
|||||||
return (
|
return (
|
||||||
<section className="max-w-2xl animate-in fade-in-50 duration-200">
|
<section className="max-w-2xl animate-in fade-in-50 duration-200">
|
||||||
<SettingsPanelHead
|
<SettingsPanelHead
|
||||||
title="Your profile"
|
title={t('title')}
|
||||||
description="How you show up across the app. Your avatar and name appear in the header, sidebar, and anywhere your teammates see you."
|
description={t('description')}
|
||||||
/>
|
/>
|
||||||
<form onSubmit={onSubmit} className="space-y-4">
|
<form onSubmit={onSubmit} className="space-y-4">
|
||||||
<Card>
|
<Card>
|
||||||
@ -239,7 +241,7 @@ export function ProfileForm() {
|
|||||||
disabled={saving}
|
disabled={saving}
|
||||||
>
|
>
|
||||||
<Upload className="size-4" />
|
<Upload className="size-4" />
|
||||||
{currentAvatar ? 'Change photo' : 'Upload photo'}
|
{currentAvatar ? t('changePhoto') : t('uploadPhoto')}
|
||||||
</Button>
|
</Button>
|
||||||
{currentAvatar && (
|
{currentAvatar && (
|
||||||
<Button
|
<Button
|
||||||
@ -250,11 +252,11 @@ export function ProfileForm() {
|
|||||||
className="text-muted-foreground hover:text-foreground"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
Remove
|
{t('remove')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<p className="w-full text-xs text-muted-foreground">
|
<p className="w-full text-xs text-muted-foreground">
|
||||||
PNG, JPG, WebP, or GIF. Up to 2 MB.
|
{t('photoHint')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -262,7 +264,7 @@ export function ProfileForm() {
|
|||||||
{/* Name */}
|
{/* Name */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="profile-full-name" className="text-foreground">
|
<Label htmlFor="profile-full-name" className="text-foreground">
|
||||||
Display name
|
{t('displayName')}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="profile-full-name"
|
id="profile-full-name"
|
||||||
@ -278,7 +280,7 @@ export function ProfileForm() {
|
|||||||
{/* Email */}
|
{/* Email */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="profile-email" className="text-foreground">
|
<Label htmlFor="profile-email" className="text-foreground">
|
||||||
Email
|
{t('email')}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="profile-email"
|
id="profile-email"
|
||||||
@ -292,9 +294,11 @@ export function ProfileForm() {
|
|||||||
<p className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
|
<p className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
|
||||||
<Mail className="mt-0.5 size-3.5 shrink-0" />
|
<Mail className="mt-0.5 size-3.5 shrink-0" />
|
||||||
<span>
|
<span>
|
||||||
Check the inbox for <strong>{profile?.email}</strong> and{' '}
|
{t.rich('emailChangeHint', {
|
||||||
<strong>{email}</strong> — both need to confirm before the
|
oldEmail: profile?.email || '',
|
||||||
change takes effect.
|
newEmail: email,
|
||||||
|
bold: (chunks: any) => <strong>{chunks}</strong>
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@ -303,21 +307,21 @@ export function ProfileForm() {
|
|||||||
{/* Read-only block */}
|
{/* Read-only block */}
|
||||||
<div className="rounded-lg border border-border bg-muted p-4">
|
<div className="rounded-lg border border-border bg-muted p-4">
|
||||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
Account details
|
{t('accountDetails')}
|
||||||
</p>
|
</p>
|
||||||
<dl className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
|
<dl className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-muted-foreground">Role</dt>
|
<dt className="text-muted-foreground">{t('role')}</dt>
|
||||||
<dd className="mt-0.5 font-mono text-foreground">
|
<dd className="mt-0.5 font-mono text-foreground">
|
||||||
{profile?.role ?? 'user'}
|
{profile?.role ?? 'user'}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-muted-foreground">Joined</dt>
|
<dt className="text-muted-foreground">{t('joined')}</dt>
|
||||||
<dd className="mt-0.5 text-foreground">{joined}</dd>
|
<dd className="mt-0.5 text-foreground">{joined}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<dt className="text-muted-foreground">User ID</dt>
|
<dt className="text-muted-foreground">{t('userId')}</dt>
|
||||||
<dd className="mt-0.5 break-all font-mono text-xs text-muted-foreground">
|
<dd className="mt-0.5 break-all font-mono text-xs text-muted-foreground">
|
||||||
{user?.id ?? '—'}
|
{user?.id ?? '—'}
|
||||||
</dd>
|
</dd>
|
||||||
@ -328,7 +332,7 @@ export function ProfileForm() {
|
|||||||
{!profile && (
|
{!profile && (
|
||||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<CircleAlert className="size-4" />
|
<CircleAlert className="size-4" />
|
||||||
Loading your profile…
|
{t('loading')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -340,10 +344,10 @@ export function ProfileForm() {
|
|||||||
{saving ? (
|
{saving ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
Saving…
|
{t('saving')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Save changes'
|
t('saveChanges')
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -21,8 +21,10 @@ import {
|
|||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
export function SessionsCard() {
|
export function SessionsCard() {
|
||||||
|
const t = useTranslations('Settings.profile');
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [signingOut, setSigningOut] = useState(false);
|
const [signingOut, setSigningOut] = useState(false);
|
||||||
@ -35,7 +37,7 @@ export function SessionsCard() {
|
|||||||
// triggers the usual redirect.
|
// triggers the usual redirect.
|
||||||
const { error } = await supabase.auth.signOut({ scope: 'global' });
|
const { error } = await supabase.auth.signOut({ scope: 'global' });
|
||||||
if (error) {
|
if (error) {
|
||||||
toast.error(`Sign-out failed: ${error.message}`);
|
toast.error(t('signOutFailed', { message: error.message }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
@ -53,11 +55,10 @@ export function SessionsCard() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-foreground">
|
<CardTitle className="flex items-center gap-2 text-foreground">
|
||||||
<LogOut className="size-4 text-primary" />
|
<LogOut className="size-4 text-primary" />
|
||||||
Active sessions
|
{t('sessionsTitle')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-muted-foreground">
|
<CardDescription className="text-muted-foreground">
|
||||||
Sign out of every device where you're logged in — including
|
{t('sessionsDesc')}
|
||||||
this one. Useful if you lost a laptop or shared your password.
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@ -67,7 +68,7 @@ export function SessionsCard() {
|
|||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
>
|
>
|
||||||
<LogOut className="size-4" />
|
<LogOut className="size-4" />
|
||||||
Sign out of all devices
|
{t('signOutAll')}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -75,11 +76,9 @@ export function SessionsCard() {
|
|||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Sign out everywhere?</DialogTitle>
|
<DialogTitle>{t('signOutConfirmTitle')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Every device logged into this account will be signed out and
|
{t('signOutConfirmDesc')}
|
||||||
will need to log in again. You will be redirected to the login
|
|
||||||
page.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@ -89,16 +88,16 @@ export function SessionsCard() {
|
|||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
disabled={signingOut}
|
disabled={signingOut}
|
||||||
>
|
>
|
||||||
Cancel
|
{t('cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" onClick={onConfirm} disabled={signingOut}>
|
<Button type="button" onClick={onConfirm} disabled={signingOut}>
|
||||||
{signingOut ? (
|
{signingOut ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
Signing out…
|
{t('signingOut')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Sign out everywhere'
|
t('signOutEverywhere')
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@ -23,17 +23,18 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import type { Tag } from '@/types';
|
import type { Tag } from '@/types';
|
||||||
|
|
||||||
const PRESET_COLORS = [
|
const PRESET_COLORS = [
|
||||||
{ name: 'Red', value: '#ef4444' },
|
{ name: 'red', value: '#ef4444' },
|
||||||
{ name: 'Orange', value: '#f97316' },
|
{ name: 'orange', value: '#f97316' },
|
||||||
{ name: 'Amber', value: '#f59e0b' },
|
{ name: 'amber', value: '#f59e0b' },
|
||||||
{ name: 'Emerald', value: '#10b981' },
|
{ name: 'emerald', value: '#10b981' },
|
||||||
{ name: 'Cyan', value: '#06b6d4' },
|
{ name: 'cyan', value: '#06b6d4' },
|
||||||
{ name: 'Blue', value: '#3b82f6' },
|
{ name: 'blue', value: '#3b82f6' },
|
||||||
{ name: 'Violet', value: '#8b5cf6' },
|
{ name: 'violet', value: '#8b5cf6' },
|
||||||
{ name: 'Pink', value: '#ec4899' },
|
{ name: 'pink', value: '#ec4899' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -42,6 +43,7 @@ const PRESET_COLORS = [
|
|||||||
* dialog since it detaches the tag from every contact.
|
* dialog since it detaches the tag from every contact.
|
||||||
*/
|
*/
|
||||||
export function TagManager() {
|
export function TagManager() {
|
||||||
|
const t = useTranslations('Settings.tagsAndFields');
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const { user, accountId, loading: authLoading } = useAuth();
|
const { user, accountId, loading: authLoading } = useAuth();
|
||||||
|
|
||||||
@ -77,7 +79,7 @@ export function TagManager() {
|
|||||||
setTags(data || []);
|
setTags(data || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch tags:', err);
|
console.error('Failed to fetch tags:', err);
|
||||||
toast.error('Failed to load tags');
|
toast.error(t('failedToLoadTags'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -85,14 +87,14 @@ export function TagManager() {
|
|||||||
|
|
||||||
async function handleCreate() {
|
async function handleCreate() {
|
||||||
if (!newTagName.trim()) {
|
if (!newTagName.trim()) {
|
||||||
toast.error('Tag name is required');
|
toast.error(t('nameRequired'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
if (!user || !accountId) {
|
if (!user || !accountId) {
|
||||||
toast.error('Not authenticated');
|
toast.error(t('notAuthenticated'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,13 +109,13 @@ export function TagManager() {
|
|||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
toast.success('Tag created');
|
toast.success(t('tagCreated'));
|
||||||
setNewTagName('');
|
setNewTagName('');
|
||||||
setSelectedColor(PRESET_COLORS[3].value);
|
setSelectedColor(PRESET_COLORS[3].value);
|
||||||
await fetchTags(user.id);
|
await fetchTags(user.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Create error:', err);
|
console.error('Create error:', err);
|
||||||
toast.error('Failed to create tag');
|
toast.error(t('failedToCreateTag'));
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@ -136,13 +138,13 @@ export function TagManager() {
|
|||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
toast.success('Tag deleted');
|
toast.success(t('tagDeleted'));
|
||||||
setTags((prev) => prev.filter((t) => t.id !== tagToDelete.id));
|
setTags((prev) => prev.filter((t) => t.id !== tagToDelete.id));
|
||||||
setDeleteDialogOpen(false);
|
setDeleteDialogOpen(false);
|
||||||
setTagToDelete(null);
|
setTagToDelete(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Delete error:', err);
|
console.error('Delete error:', err);
|
||||||
toast.error('Failed to delete tag');
|
toast.error(t('failedToDeleteTag'));
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
}
|
}
|
||||||
@ -153,10 +155,10 @@ export function TagManager() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-foreground">
|
<CardTitle className="flex items-center gap-2 text-foreground">
|
||||||
<TagIcon className="size-4 text-primary" />
|
<TagIcon className="size-4 text-primary" />
|
||||||
Tags
|
{t('tagsTitle')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-muted-foreground">
|
<CardDescription className="text-muted-foreground">
|
||||||
Colour-coded labels for grouping and filtering contacts.
|
{t('tagsDesc')}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
@ -186,7 +188,7 @@ export function TagManager() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => confirmDelete(tag)}
|
onClick={() => confirmDelete(tag)}
|
||||||
aria-label={`Delete ${tag.name}`}
|
aria-label={t('deleteAria', { name: tag.name })}
|
||||||
className="ml-0.5 rounded-full p-0.5 opacity-60 transition-opacity hover:bg-black/10 hover:opacity-100 dark:hover:bg-white/10"
|
className="ml-0.5 rounded-full p-0.5 opacity-60 transition-opacity hover:bg-black/10 hover:opacity-100 dark:hover:bg-white/10"
|
||||||
>
|
>
|
||||||
<X className="size-3" />
|
<X className="size-3" />
|
||||||
@ -196,14 +198,14 @@ export function TagManager() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
No tags yet — create your first one below.
|
{t('noTags')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Inline create row */}
|
{/* Inline create row */}
|
||||||
<div className="flex flex-wrap items-center gap-2.5">
|
<div className="flex flex-wrap items-center gap-2.5">
|
||||||
<Input
|
<Input
|
||||||
placeholder="e.g. Newsletter"
|
placeholder={t('placeholder')}
|
||||||
value={newTagName}
|
value={newTagName}
|
||||||
onChange={(e) => setNewTagName(e.target.value)}
|
onChange={(e) => setNewTagName(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@ -219,7 +221,7 @@ export function TagManager() {
|
|||||||
key={color.value}
|
key={color.value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelectedColor(color.value)}
|
onClick={() => setSelectedColor(color.value)}
|
||||||
aria-label={`Use ${color.name}`}
|
aria-label={t('useColor', { color: t(`colors.${color.name}` as any) })}
|
||||||
aria-pressed={selectedColor === color.value}
|
aria-pressed={selectedColor === color.value}
|
||||||
className={cn(
|
className={cn(
|
||||||
'size-6 rounded-md transition-transform hover:scale-110',
|
'size-6 rounded-md transition-transform hover:scale-110',
|
||||||
@ -227,7 +229,7 @@ export function TagManager() {
|
|||||||
'outline outline-2 outline-offset-2 outline-primary',
|
'outline outline-2 outline-offset-2 outline-primary',
|
||||||
)}
|
)}
|
||||||
style={{ backgroundColor: color.value }}
|
style={{ backgroundColor: color.value }}
|
||||||
title={color.name}
|
title={t(`colors.${color.name}` as any)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -242,7 +244,7 @@ export function TagManager() {
|
|||||||
) : (
|
) : (
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
)}
|
)}
|
||||||
Add tag
|
{t('addTag')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@ -253,10 +255,9 @@ export function TagManager() {
|
|||||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
<DialogContent className="sm:max-w-sm">
|
<DialogContent className="sm:max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Delete tag</DialogTitle>
|
<DialogTitle>{t('deleteTag')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Delete the tag "{tagToDelete?.name}"? This removes it
|
{tagToDelete ? t('deleteConfirm', { name: tagToDelete.name }) : null}
|
||||||
from all contacts and cannot be undone.
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@ -265,7 +266,7 @@ export function TagManager() {
|
|||||||
onClick={() => setDeleteDialogOpen(false)}
|
onClick={() => setDeleteDialogOpen(false)}
|
||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
>
|
>
|
||||||
Cancel
|
{t('cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
@ -275,10 +276,10 @@ export function TagManager() {
|
|||||||
{deleting ? (
|
{deleting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
Deleting...
|
{t('deleting')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Delete tag'
|
t('deleteTag')
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { SettingsPanelHead } from './settings-panel-head';
|
import { SettingsPanelHead } from './settings-panel-head';
|
||||||
import {
|
import {
|
||||||
@ -124,6 +125,7 @@ function emptyButton(type: TemplateButton['type']): TemplateButton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TemplateManager() {
|
export function TemplateManager() {
|
||||||
|
const t = useTranslations('Settings.templates');
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const { user, loading: authLoading } = useAuth();
|
const { user, loading: authLoading } = useAuth();
|
||||||
|
|
||||||
@ -197,7 +199,7 @@ export function TemplateManager() {
|
|||||||
setTemplates(data || []);
|
setTemplates(data || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch templates:', err);
|
console.error('Failed to fetch templates:', err);
|
||||||
toast.error('Failed to load templates');
|
toast.error(t('toastLoadFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -282,18 +284,18 @@ export function TemplateManager() {
|
|||||||
toast.success(
|
toast.success(
|
||||||
data.dry_run
|
data.dry_run
|
||||||
? isEdit
|
? isEdit
|
||||||
? 'Template updated (dry-run — no Meta call)'
|
? t('toastSaveEditDry')
|
||||||
: 'Template saved (dry-run — no Meta call)'
|
: t('toastSaveNewDry')
|
||||||
: isEdit
|
: isEdit
|
||||||
? 'Edit submitted — Meta typically reviews within 24 hours.'
|
? t('toastSubmitEditSuccess')
|
||||||
: 'Submitted to Meta — typical review time is 24 hours. Status updates automatically.',
|
: t('toastSubmitNewSuccess'),
|
||||||
);
|
);
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
setForm(emptyForm);
|
setForm(emptyForm);
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Submit error:', err);
|
console.error('Submit error:', err);
|
||||||
toast.error(err instanceof Error ? err.message : 'Failed to submit');
|
toast.error(err instanceof Error ? err.message : t('toastSubmitFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@ -309,9 +311,9 @@ export function TemplateManager() {
|
|||||||
throw new Error(data?.error || `Sync failed (HTTP ${res.status})`);
|
throw new Error(data?.error || `Sync failed (HTTP ${res.status})`);
|
||||||
}
|
}
|
||||||
toast.success(
|
toast.success(
|
||||||
`Synced ${data.total} template${data.total === 1 ? '' : 's'} from Meta` +
|
t('toastSyncCount', { total: data.total }) +
|
||||||
(data.inserted || data.updated
|
(data.inserted || data.updated
|
||||||
? ` (${data.inserted} new, ${data.updated} updated)`
|
? t('toastSyncDetails', { inserted: data.inserted, updated: data.updated })
|
||||||
: ''),
|
: ''),
|
||||||
);
|
);
|
||||||
if (Array.isArray(data.errors) && data.errors.length > 0) {
|
if (Array.isArray(data.errors) && data.errors.length > 0) {
|
||||||
@ -321,21 +323,21 @@ export function TemplateManager() {
|
|||||||
);
|
);
|
||||||
const suffix =
|
const suffix =
|
||||||
data.errors.length > 3 ? `, +${data.errors.length - 3} more` : '';
|
data.errors.length > 3 ? `, +${data.errors.length - 3} more` : '';
|
||||||
toast.error(`Failed to sync: ${preview.join(', ')}${suffix}`);
|
toast.error(t('toastSyncFailed', { preview: preview.join(', ') + suffix }));
|
||||||
}
|
}
|
||||||
if (data.truncated) {
|
if (data.truncated) {
|
||||||
// Use error (not warning) so the message survives long
|
// Use error (not warning) so the message survives long
|
||||||
// enough to read — sonner's `warning` auto-dismisses on
|
// enough to read — sonner's `warning` auto-dismisses on
|
||||||
// the same short timer as `success`.
|
// the same short timer as `success`.
|
||||||
toast.error(
|
toast.error(
|
||||||
'Synced the first 2000 templates only — your account has more. Sync again to continue, or contact support if this persists.',
|
t('toastSyncTruncated'),
|
||||||
{ duration: 10000 },
|
{ duration: 10000 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await fetchTemplates(user.id);
|
await fetchTemplates(user.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Template sync error:', err);
|
console.error('Template sync error:', err);
|
||||||
toast.error(err instanceof Error ? err.message : 'Failed to sync templates');
|
toast.error(err instanceof Error ? err.message : t('toastSyncError'));
|
||||||
} finally {
|
} finally {
|
||||||
setSyncing(false);
|
setSyncing(false);
|
||||||
}
|
}
|
||||||
@ -356,12 +358,12 @@ export function TemplateManager() {
|
|||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(data?.error || `Delete failed (HTTP ${res.status})`);
|
throw new Error(data?.error || `Delete failed (HTTP ${res.status})`);
|
||||||
}
|
}
|
||||||
toast.success('Template deleted');
|
toast.success(t('toastDeleteSuccess'));
|
||||||
setTemplates((prev) => prev.filter((t) => t.id !== target.id));
|
setTemplates((prev) => prev.filter((t) => t.id !== target.id));
|
||||||
setTemplateToDelete(null);
|
setTemplateToDelete(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Delete error:', err);
|
console.error('Delete error:', err);
|
||||||
toast.error(err instanceof Error ? err.message : 'Failed to delete template');
|
toast.error(err instanceof Error ? err.message : t('toastDeleteError'));
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingId(null);
|
setDeletingId(null);
|
||||||
}
|
}
|
||||||
@ -458,12 +460,12 @@ export function TemplateManager() {
|
|||||||
|
|
||||||
async function handleHeaderImageFile(file: File) {
|
async function handleHeaderImageFile(file: File) {
|
||||||
if (!['image/jpeg', 'image/png'].includes(file.type)) {
|
if (!['image/jpeg', 'image/png'].includes(file.type)) {
|
||||||
toast.error('Header image must be a JPEG or PNG.');
|
toast.error(t('toastInvalidImage'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (file.size > MEDIA_MAX_BYTES_BY_KIND.image) {
|
if (file.size > MEDIA_MAX_BYTES_BY_KIND.image) {
|
||||||
toast.error(
|
toast.error(
|
||||||
`Image is ${(file.size / 1024 / 1024).toFixed(1)} MB — Meta's limit is 5 MB.`,
|
t('toastImageTooLarge', { size: (file.size / 1024 / 1024).toFixed(1) }),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -471,9 +473,9 @@ export function TemplateManager() {
|
|||||||
try {
|
try {
|
||||||
const { publicUrl } = await uploadAccountMedia('chat-media', file);
|
const { publicUrl } = await uploadAccountMedia('chat-media', file);
|
||||||
setForm((f) => ({ ...f, header_media_url: publicUrl }));
|
setForm((f) => ({ ...f, header_media_url: publicUrl }));
|
||||||
toast.success('Image uploaded.');
|
toast.success(t('toastUploadSuccess'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'Upload failed.');
|
toast.error(err instanceof Error ? err.message : t('toastUploadFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setUploadingHeader(false);
|
setUploadingHeader(false);
|
||||||
}
|
}
|
||||||
@ -482,24 +484,22 @@ export function TemplateManager() {
|
|||||||
return (
|
return (
|
||||||
<section className="animate-in fade-in-50 space-y-4 duration-200">
|
<section className="animate-in fade-in-50 space-y-4 duration-200">
|
||||||
<SettingsPanelHead
|
<SettingsPanelHead
|
||||||
title="Message templates"
|
title={t('title')}
|
||||||
description={
|
description={t('description')}
|
||||||
'Create templates and submit them to Meta for approval. Use "Sync from Meta" to pull templates approved elsewhere.'
|
|
||||||
}
|
|
||||||
action={
|
action={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleSyncFromMeta}
|
onClick={handleSyncFromMeta}
|
||||||
disabled={syncing}
|
disabled={syncing}
|
||||||
title="Pull approved templates from your Meta WhatsApp Business Account"
|
title={t('syncTitle')}
|
||||||
>
|
>
|
||||||
<RefreshCw className={`size-4 ${syncing ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`size-4 ${syncing ? 'animate-spin' : ''}`} />
|
||||||
{syncing ? 'Syncing…' : 'Sync from Meta'}
|
{syncing ? t('syncing') : t('syncFromMeta')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={openCreate}>
|
<Button onClick={openCreate}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
New Template
|
{t('newTemplate')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@ -508,9 +508,9 @@ export function TemplateManager() {
|
|||||||
{templates.length === 0 ? (
|
{templates.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<p className="text-muted-foreground text-sm">No templates yet.</p>
|
<p className="text-muted-foreground text-sm">{t('noTemplates')}</p>
|
||||||
<p className="text-muted-foreground text-xs mt-1">
|
<p className="text-muted-foreground text-xs mt-1">
|
||||||
Create your first message template to get started.
|
{t('createFirst')}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -576,12 +576,12 @@ export function TemplateManager() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => openEdit(template)}
|
onClick={() => openEdit(template)}
|
||||||
title="Editing triggers Meta re-review — status flips to PENDING."
|
title={t('editTitle')}
|
||||||
aria-label="Edit template"
|
aria-label={t('editLabel')}
|
||||||
className="text-muted-foreground hover:text-primary hover:bg-primary/10 h-8 px-2"
|
className="text-muted-foreground hover:text-primary hover:bg-primary/10 h-8 px-2"
|
||||||
>
|
>
|
||||||
<Pencil className="size-3.5" />
|
<Pencil className="size-3.5" />
|
||||||
Edit
|
{t('edit')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{(statusKey === 'REJECTED' || statusKey === 'PAUSED') && (
|
{(statusKey === 'REJECTED' || statusKey === 'PAUSED') && (
|
||||||
@ -589,12 +589,12 @@ export function TemplateManager() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => openEdit(template)}
|
onClick={() => openEdit(template)}
|
||||||
title="Edit the template and resubmit to Meta for review."
|
title={t('resubmitTitle')}
|
||||||
aria-label="Edit and resubmit template"
|
aria-label={t('resubmitLabel')}
|
||||||
className="text-muted-foreground hover:text-primary hover:bg-primary/10 h-8 px-2"
|
className="text-muted-foreground hover:text-primary hover:bg-primary/10 h-8 px-2"
|
||||||
>
|
>
|
||||||
<RotateCcw className="size-3.5" />
|
<RotateCcw className="size-3.5" />
|
||||||
Resubmit
|
{t('resubmit')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
@ -604,13 +604,13 @@ export function TemplateManager() {
|
|||||||
disabled={deletingId === template.id}
|
disabled={deletingId === template.id}
|
||||||
aria-label={
|
aria-label={
|
||||||
template.meta_template_id
|
template.meta_template_id
|
||||||
? 'Delete template from Meta and locally'
|
? t('deleteMetaLocallyAria')
|
||||||
: 'Delete template locally'
|
: t('deleteLocallyAria')
|
||||||
}
|
}
|
||||||
title={
|
title={
|
||||||
template.meta_template_id
|
template.meta_template_id
|
||||||
? 'Delete from Meta and locally'
|
? t('deleteMetaLocallyTitle')
|
||||||
: 'Delete locally'
|
: t('deleteLocallyTitle')
|
||||||
}
|
}
|
||||||
className="text-muted-foreground hover:text-red-400 hover:bg-red-950/30 h-8 w-8"
|
className="text-muted-foreground hover:text-red-400 hover:bg-red-950/30 h-8 w-8"
|
||||||
>
|
>
|
||||||
@ -641,32 +641,27 @@ export function TemplateManager() {
|
|||||||
<DialogContent className="bg-popover border-border sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
<DialogContent className="bg-popover border-border sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-popover-foreground">
|
<DialogTitle className="text-popover-foreground">
|
||||||
{editingId ? 'Edit Message Template' : 'New Message Template'}
|
{editingId ? t('dialogEditTitle') : t('dialogNewTitle')}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-muted-foreground">
|
<DialogDescription className="text-muted-foreground">
|
||||||
{editingId
|
{editingId
|
||||||
? 'Save your changes to re-submit to Meta. Status will flip back to PENDING during review.'
|
? t('dialogEditDesc')
|
||||||
: 'Build a template and submit it to Meta for approval. Once approved, you can use it in broadcasts and the inbox.'}
|
: t('dialogNewDesc')}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{form.category === 'Authentication' && (
|
{form.category === 'Authentication' && (
|
||||||
<div className="flex items-start gap-2 rounded border border-amber-700/40 bg-amber-950/30 px-3 py-2 text-xs text-amber-300">
|
<div className="flex items-start gap-2 rounded border border-amber-700/40 bg-amber-950/30 px-3 py-2 text-xs text-amber-300">
|
||||||
<AlertCircle className="size-4 mt-0.5 shrink-0" />
|
<AlertCircle className="size-4 mt-0.5 shrink-0" />
|
||||||
<p>
|
<p dangerouslySetInnerHTML={{ __html: t.raw('authWarning') }} />
|
||||||
AUTHENTICATION templates have a fixed body + OTP button shape
|
|
||||||
that needs a different builder. Create them in Meta WhatsApp
|
|
||||||
Manager for now and use <strong>Sync from Meta</strong> to
|
|
||||||
bring them in.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-4 py-2">
|
<div className="space-y-4 py-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-muted-foreground">Template Name</Label>
|
<Label className="text-muted-foreground">{t('templateName')}</Label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="e.g. order_confirmation"
|
placeholder={t('namePlaceholder')}
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
disabled={editingId !== null}
|
disabled={editingId !== null}
|
||||||
@ -674,14 +669,14 @@ export function TemplateManager() {
|
|||||||
/>
|
/>
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
{editingId
|
{editingId
|
||||||
? 'Name is fixed once a template exists on Meta — create a new template to change it.'
|
? t('nameFixed')
|
||||||
: 'Lowercase letters, digits, and underscores only.'}
|
: t('nameHint')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-muted-foreground">Category</Label>
|
<Label className="text-muted-foreground">{t('category')}</Label>
|
||||||
<Select
|
<Select
|
||||||
value={form.category}
|
value={form.category}
|
||||||
onValueChange={(val) =>
|
onValueChange={(val) =>
|
||||||
@ -709,7 +704,7 @@ export function TemplateManager() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-muted-foreground">Language</Label>
|
<Label className="text-muted-foreground">{t('language')}</Label>
|
||||||
<Input
|
<Input
|
||||||
list="template-language-codes"
|
list="template-language-codes"
|
||||||
placeholder="en_US"
|
placeholder="en_US"
|
||||||
@ -726,20 +721,17 @@ export function TemplateManager() {
|
|||||||
))}
|
))}
|
||||||
</datalist>
|
</datalist>
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
{editingId
|
{editingId ? (
|
||||||
? 'Language is fixed once a template exists on Meta.'
|
t('langFixed')
|
||||||
: (
|
) : (
|
||||||
<>
|
<span dangerouslySetInnerHTML={{ __html: t.raw('langHint') }} />
|
||||||
Must match the exact code on Meta — <code>en_US</code>{' '}
|
)}
|
||||||
and <code>en</code> are distinct.
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-muted-foreground">Header</Label>
|
<Label className="text-muted-foreground">{t('header')}</Label>
|
||||||
<Select
|
<Select
|
||||||
value={form.header_format}
|
value={form.header_format}
|
||||||
onValueChange={(val) =>
|
onValueChange={(val) =>
|
||||||
@ -766,8 +758,14 @@ export function TemplateManager() {
|
|||||||
className="text-popover-foreground focus:bg-muted focus:text-popover-foreground"
|
className="text-popover-foreground focus:bg-muted focus:text-popover-foreground"
|
||||||
>
|
>
|
||||||
{type === 'none'
|
{type === 'none'
|
||||||
? 'None'
|
? t('headerNone')
|
||||||
: type.charAt(0).toUpperCase() + type.slice(1)}
|
: type === 'text'
|
||||||
|
? t('headerText')
|
||||||
|
: type === 'image'
|
||||||
|
? t('headerImage')
|
||||||
|
: type === 'video'
|
||||||
|
? t('headerVideo')
|
||||||
|
: t('headerDocument')}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@ -778,7 +776,7 @@ export function TemplateManager() {
|
|||||||
<Input
|
<Input
|
||||||
id="template-header-text"
|
id="template-header-text"
|
||||||
aria-label="Header text"
|
aria-label="Header text"
|
||||||
placeholder="Header text (max 60 chars, optional {{1}})"
|
placeholder={t('headerTextPlaceholder')}
|
||||||
value={form.header_content}
|
value={form.header_content}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setForm({ ...form, header_content: e.target.value })
|
setForm({ ...form, header_content: e.target.value })
|
||||||
@ -789,8 +787,8 @@ export function TemplateManager() {
|
|||||||
{headerVarCount > 0 && (
|
{headerVarCount > 0 && (
|
||||||
<Input
|
<Input
|
||||||
id="template-header-sample"
|
id="template-header-sample"
|
||||||
aria-label="Sample value for header variable"
|
aria-label={t('headerSampleAria')}
|
||||||
placeholder="Sample value for {{1}} (required for Meta review)"
|
placeholder={t('headerSamplePlaceholder')}
|
||||||
value={form.header_sample}
|
value={form.header_sample}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setForm({ ...form, header_sample: e.target.value })
|
setForm({ ...form, header_sample: e.target.value })
|
||||||
@ -828,15 +826,15 @@ export function TemplateManager() {
|
|||||||
) : (
|
) : (
|
||||||
<Upload className="h-3.5 w-3.5" />
|
<Upload className="h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
Upload image
|
{t('uploadImage')}
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-[11px] text-muted-foreground">
|
<span className="text-[11px] text-muted-foreground">
|
||||||
JPEG or PNG, ≤5 MB
|
{t('uploadHint')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Input
|
<Input
|
||||||
placeholder={`https://… (or paste a public ${form.header_format} link)`}
|
placeholder={t('mediaUrlPlaceholder', { format: form.header_format })}
|
||||||
value={form.header_media_url}
|
value={form.header_media_url}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setForm({ ...form, header_media_url: e.target.value })
|
setForm({ ...form, header_media_url: e.target.value })
|
||||||
@ -853,21 +851,21 @@ export function TemplateManager() {
|
|||||||
)}
|
)}
|
||||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||||
{form.header_format === 'image'
|
{form.header_format === 'image'
|
||||||
? 'Upload a JPEG/PNG (≤5 MB, ≥800×418 px recommended) or paste a public HTTPS link — we upload it to Meta for review automatically.'
|
? t('imageHint')
|
||||||
: 'Must be a publicly accessible HTTPS link. Meta fetches it once during review, so it needs to stay live for ~24 hrs.'}
|
: t('mediaHint')}
|
||||||
{form.header_format === 'video' &&
|
{form.header_format === 'video' &&
|
||||||
' Recommended: MP4 / 3GPP, ≤16 MB, ≤60 seconds.'}
|
t('videoHint')}
|
||||||
{form.header_format === 'document' &&
|
{form.header_format === 'document' &&
|
||||||
' Recommended: PDF, ≤100 MB.'}
|
t('documentHint')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-muted-foreground">Body Text</Label>
|
<Label className="text-muted-foreground">{t('bodyText')}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="Hello {{1}}, your order {{2}} is confirmed."
|
placeholder={t('bodyPlaceholder')}
|
||||||
value={form.body_text}
|
value={form.body_text}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setForm({ ...form, body_text: e.target.value })
|
setForm({ ...form, body_text: e.target.value })
|
||||||
@ -877,14 +875,13 @@ export function TemplateManager() {
|
|||||||
className="bg-muted border-border text-foreground placeholder:text-muted-foreground resize-none"
|
className="bg-muted border-border text-foreground placeholder:text-muted-foreground resize-none"
|
||||||
/>
|
/>
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
Use {`{{1}}`}, {`{{2}}`} for variables (must be contiguous
|
{t('bodyHint')}
|
||||||
starting at {`{{1}}`}).
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{bodyVarCount > 0 && (
|
{bodyVarCount > 0 && (
|
||||||
<div className="space-y-1.5 pt-1">
|
<div className="space-y-1.5 pt-1">
|
||||||
<Label className="text-[11px] text-muted-foreground">
|
<Label className="text-[11px] text-muted-foreground">
|
||||||
Sample values (Meta uses these to review your template)
|
{t('sampleValues')}
|
||||||
</Label>
|
</Label>
|
||||||
{form.body_samples.map((val, i) => {
|
{form.body_samples.map((val, i) => {
|
||||||
const inputId = `template-body-sample-${i}`;
|
const inputId = `template-body-sample-${i}`;
|
||||||
@ -892,8 +889,8 @@ export function TemplateManager() {
|
|||||||
<Input
|
<Input
|
||||||
key={i}
|
key={i}
|
||||||
id={inputId}
|
id={inputId}
|
||||||
aria-label={`Sample value for body variable {{${i + 1}}}`}
|
aria-label={t('sampleAria', { var: `{{${i + 1}}}` })}
|
||||||
placeholder={`Sample for {{${i + 1}}}`}
|
placeholder={t('samplePlaceholder', { var: `{{${i + 1}}}` })}
|
||||||
value={val}
|
value={val}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = [...form.body_samples];
|
const next = [...form.body_samples];
|
||||||
@ -909,9 +906,9 @@ export function TemplateManager() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-muted-foreground">Footer (optional)</Label>
|
<Label className="text-muted-foreground">{t('footer')}</Label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Optional footer text (max 60 chars)"
|
placeholder={t('footerPlaceholder')}
|
||||||
value={form.footer_text}
|
value={form.footer_text}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setForm({ ...form, footer_text: e.target.value })
|
setForm({ ...form, footer_text: e.target.value })
|
||||||
@ -923,7 +920,7 @@ export function TemplateManager() {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-muted-foreground">Buttons (optional)</Label>
|
<Label className="text-muted-foreground">{t('buttons')}</Label>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -933,13 +930,12 @@ export function TemplateManager() {
|
|||||||
className="border-border bg-transparent text-muted-foreground hover:bg-muted h-7 text-xs"
|
className="border-border bg-transparent text-muted-foreground hover:bg-muted h-7 text-xs"
|
||||||
>
|
>
|
||||||
<Plus className="size-3" />
|
<Plus className="size-3" />
|
||||||
Add Button
|
{t('addButton')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{form.buttons.length === 0 ? (
|
{form.buttons.length === 0 ? (
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
Up to {TEMPLATE_LIMITS.maxButtonsTotal} buttons. QUICK_REPLY
|
{t('buttonsLimit', { max: TEMPLATE_LIMITS.maxButtonsTotal })}
|
||||||
buttons must come before URL / phone / copy-code buttons.
|
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@ -967,30 +963,30 @@ export function TemplateManager() {
|
|||||||
value="QUICK_REPLY"
|
value="QUICK_REPLY"
|
||||||
className="text-popover-foreground focus:bg-muted focus:text-popover-foreground"
|
className="text-popover-foreground focus:bg-muted focus:text-popover-foreground"
|
||||||
>
|
>
|
||||||
Quick Reply
|
{t('btnQuickReply')}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem
|
<SelectItem
|
||||||
value="URL"
|
value="URL"
|
||||||
className="text-popover-foreground focus:bg-muted focus:text-popover-foreground"
|
className="text-popover-foreground focus:bg-muted focus:text-popover-foreground"
|
||||||
>
|
>
|
||||||
URL
|
{t('btnUrl')}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem
|
<SelectItem
|
||||||
value="PHONE_NUMBER"
|
value="PHONE_NUMBER"
|
||||||
className="text-popover-foreground focus:bg-muted focus:text-popover-foreground"
|
className="text-popover-foreground focus:bg-muted focus:text-popover-foreground"
|
||||||
>
|
>
|
||||||
Phone
|
{t('btnPhone')}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem
|
<SelectItem
|
||||||
value="COPY_CODE"
|
value="COPY_CODE"
|
||||||
className="text-popover-foreground focus:bg-muted focus:text-popover-foreground"
|
className="text-popover-foreground focus:bg-muted focus:text-popover-foreground"
|
||||||
>
|
>
|
||||||
Copy Code
|
{t('btnCopyCode')}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Button label"
|
placeholder={t('btnLabelPlaceholder')}
|
||||||
value={btn.text}
|
value={btn.text}
|
||||||
maxLength={TEMPLATE_LIMITS.buttonTextMaxLength}
|
maxLength={TEMPLATE_LIMITS.buttonTextMaxLength}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@ -1011,7 +1007,7 @@ export function TemplateManager() {
|
|||||||
{btn.type === 'URL' && (
|
{btn.type === 'URL' && (
|
||||||
<div className="space-y-1 pl-1">
|
<div className="space-y-1 pl-1">
|
||||||
<Input
|
<Input
|
||||||
placeholder="https://example.com/path or with {{1}} suffix"
|
placeholder={t('urlPlaceholder')}
|
||||||
value={btn.url}
|
value={btn.url}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateButton(i, { url: e.target.value })
|
updateButton(i, { url: e.target.value })
|
||||||
@ -1020,7 +1016,7 @@ export function TemplateManager() {
|
|||||||
/>
|
/>
|
||||||
{extractVariableIndices(btn.url).length > 0 && (
|
{extractVariableIndices(btn.url).length > 0 && (
|
||||||
<Input
|
<Input
|
||||||
placeholder="Example value for {{1}} (required when URL has a variable)"
|
placeholder={t('urlSamplePlaceholder')}
|
||||||
value={btn.example ?? ''}
|
value={btn.example ?? ''}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateButton(i, { example: e.target.value })
|
updateButton(i, { example: e.target.value })
|
||||||
@ -1032,7 +1028,7 @@ export function TemplateManager() {
|
|||||||
)}
|
)}
|
||||||
{btn.type === 'PHONE_NUMBER' && (
|
{btn.type === 'PHONE_NUMBER' && (
|
||||||
<Input
|
<Input
|
||||||
placeholder="+15551234567"
|
placeholder={t('phonePlaceholder')}
|
||||||
value={btn.phone_number}
|
value={btn.phone_number}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateButton(i, { phone_number: e.target.value })
|
updateButton(i, { phone_number: e.target.value })
|
||||||
@ -1042,7 +1038,7 @@ export function TemplateManager() {
|
|||||||
)}
|
)}
|
||||||
{btn.type === 'COPY_CODE' && (
|
{btn.type === 'COPY_CODE' && (
|
||||||
<Input
|
<Input
|
||||||
placeholder="Example code (e.g. SUMMER20)"
|
placeholder={t('codePlaceholder')}
|
||||||
value={btn.example}
|
value={btn.example}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateButton(i, { example: e.target.value })
|
updateButton(i, { example: e.target.value })
|
||||||
@ -1063,7 +1059,7 @@ export function TemplateManager() {
|
|||||||
onClick={() => setDialogOpen(false)}
|
onClick={() => setDialogOpen(false)}
|
||||||
className="border-border text-muted-foreground hover:bg-muted"
|
className="border-border text-muted-foreground hover:bg-muted"
|
||||||
>
|
>
|
||||||
Cancel
|
{t('cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
@ -1073,12 +1069,12 @@ export function TemplateManager() {
|
|||||||
{submitting ? (
|
{submitting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
{editingId ? 'Saving…' : 'Submitting…'}
|
{editingId ? t('saving') : t('submitting')}
|
||||||
</>
|
</>
|
||||||
) : editingId ? (
|
) : editingId ? (
|
||||||
'Save & Resubmit'
|
t('saveResubmit')
|
||||||
) : (
|
) : (
|
||||||
'Submit for Approval'
|
t('submitApproval')
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
@ -1096,11 +1092,11 @@ export function TemplateManager() {
|
|||||||
>
|
>
|
||||||
<DialogContent className="bg-popover border-border sm:max-w-sm">
|
<DialogContent className="bg-popover border-border sm:max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-popover-foreground">Delete template?</DialogTitle>
|
<DialogTitle className="text-popover-foreground">{t('deleteDialogTitle')}</DialogTitle>
|
||||||
<DialogDescription className="text-muted-foreground">
|
<DialogDescription className="text-muted-foreground">
|
||||||
{templateToDelete?.meta_template_id
|
{templateToDelete?.meta_template_id
|
||||||
? `"${templateToDelete?.name}" will be deleted from Meta and from wacrm. Active broadcasts using this template will start failing on their next send. This can't be undone.`
|
? t('deleteMetaDesc', { name: templateToDelete.name })
|
||||||
: `"${templateToDelete?.name}" will be deleted from wacrm. It was never submitted to Meta, so no remote cleanup is needed.`}
|
: t('deleteLocalDesc', { name: templateToDelete?.name || '' })}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter className="bg-popover border-border">
|
<DialogFooter className="bg-popover border-border">
|
||||||
@ -1110,7 +1106,7 @@ export function TemplateManager() {
|
|||||||
disabled={deletingId !== null}
|
disabled={deletingId !== null}
|
||||||
className="border-border text-muted-foreground hover:bg-muted"
|
className="border-border text-muted-foreground hover:bg-muted"
|
||||||
>
|
>
|
||||||
Cancel
|
{t('cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={confirmDelete}
|
onClick={confirmDelete}
|
||||||
@ -1120,10 +1116,10 @@ export function TemplateManager() {
|
|||||||
{deletingId !== null ? (
|
{deletingId !== null ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
Deleting…
|
{t('deleting')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Delete'
|
t('delete')
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user