diff --git a/messages/en.json b/messages/en.json index 0987a94..1e7b98f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -13,5 +13,1666 @@ "signIn": "Sign in", "noAccount": "Don't have an account?", "createAccount": "Create account" + }, + "Sidebar": { + "title": "CRM Template for WhatsApp", + "dashboard": "Dashboard", + "inbox": "Inbox", + "notifications": "Notifications", + "contacts": "Contacts", + "pipelines": "Pipelines", + "broadcasts": "Broadcasts", + "automations": "Automations", + "flows": "Flows", + "aiAgents": "AI Agents", + "settings": "Settings", + "beta": "Beta", + "unreadConversations": "{count} unread {count, plural, =1 {conversation} other {conversations}}", + "unreadNotifications": "{count} unread {count, plural, =1 {notification} other {notifications}}", + "roleOwner": "Owner", + "roleAdmin": "Admin", + "roleAgent": "Agent", + "roleViewer": "Viewer", + "closeMenu": "Close menu", + "defaultUser": "User", + "defaultAvatar": "Avatar", + "menuProfile": "Profile", + "menuSettings": "Settings", + "menuSignOut": "Sign out" + }, + "Header": { + "dashboard": "Dashboard", + "inbox": "Inbox", + "notifications": "Notifications", + "contacts": "Contacts", + "pipelines": "Pipelines", + "broadcasts": "Broadcasts", + "automations": "Automations", + "settings": "Settings", + "openMenu": "Open menu", + "openAccountMenu": "Open account menu", + "defaultUser": "User", + "defaultAvatar": "Avatar", + "menuProfile": "Profile", + "menuSettings": "Settings", + "menuSignOut": "Sign out" + }, + "ModeToggle": { + "switchMode": "Switch to {mode} mode" + }, + "Dashboard": { + "page": { + "title": "Dashboard", + "description": "Live analytics across conversations, contacts, deals, broadcasts, and automations.", + "activeConversations": "Active Conversations", + "newContactsToday": "New Contacts Today", + "openDealsValue": "Open Deals Value", + "messagesSentToday": "Messages Sent Today", + "newTodayVsYesterday": "new today vs yesterday", + "vsYesterday": "vs yesterday", + "openDeals": "{count} open {count, plural, =1 {deal} other {deals}}", + "noChange": "No change {suffix}" + }, + "quickActions": { + "newContact": "New Contact", + "newDeal": "New Deal", + "newBroadcast": "New Broadcast", + "newAutomation": "New Automation" + }, + "activityFeed": { + "title": "Recent Activity", + "viewAll": "View all →", + "noActivity": "No activity yet", + "noActivityHint": "Activity from messages, deals, broadcasts, and automations will appear here.", + "showingOf": "Showing {visible} of {totalLoaded}{plus}", + "show": "Show", + "timeS": "{sec}s ago", + "timeM": "{min}m ago", + "timeH": "{hr}h ago", + "timeD": "{day}d ago" + }, + "conversationsChart": { + "title": "Conversations Over Time", + "description": "Daily message volume by direction", + "days": "{count} days", + "noActivity": "No message activity in this range", + "noActivityHint": "Send or receive messages to start populating this chart.", + "incoming": "Incoming", + "outgoing": "Outgoing", + "tooltipIncoming": "{count} incoming", + "tooltipOutgoing": "{count} outgoing", + "ariaLabel": "Conversations per day" + }, + "pipelineDonut": { + "title": "Pipeline Value", + "description": "Open deals by stage", + "noOpenDeals": "No open deals yet", + "noOpenDealsHint": "Create deals in Pipelines to see stage breakdowns here.", + "dealCount": "{count} {count, plural, =1 {deal} other {deals}}", + "total": "Total", + "ariaLabel": "Pipeline value by stage" + }, + "responseTimeChart": { + "title": "Average First Response Time", + "description": "Minutes to reply to a customer's first unreplied message, by weekday", + "target": "target {minutes}m", + "thisWeek": "This week:", + "lastWeek": "Last week:", + "noReplies": "No replies recorded yet", + "noRepliesHint": "This chart fills in as you reply to customer messages." + }, + "emptyState": { + "title": "Not enough data yet" + } + }, + "Inbox": { + "page": { + "whatsappNotConnected": "WhatsApp® is not connected. Go to Settings to connect your account." + }, + "conversationList": { + "searchPlaceholder": "Search conversations...", + "filterAll": "All", + "filterUnread": "Unread", + "filterOpen": "Open", + "filterPending": "Pending", + "filterClosed": "Closed", + "tags": "Tags", + "company": "Company", + "allCompanies": "All companies", + "clearAll": "Clear all", + "noConversations": "No conversations found", + "noMessagesYet": "No messages yet", + "unknown": "Unknown" + }, + "messageThread": { + "backToConversations": "Back to conversations", + "hideContactPanel": "Hide contact panel", + "showContactPanel": "Show contact panel", + "hideContact": "Hide contact", + "showContact": "Show contact", + "refresh": "Refresh", + "refreshConversation": "Refresh conversation", + "status": "Status", + "statusOpen": "Open", + "statusPending": "Pending", + "statusClosed": "Closed", + "assign": "Assign", + "assigned": "Assigned", + "unassign": "Unassign", + "noTeammates": "No teammates available", + "me": " (me)", + "noMessagesYet": "No messages yet", + "sendTemplateHint": "Send a template to start the conversation", + "selectConversation": "Select a conversation", + "selectConversationHint": "Choose a conversation from the left to start messaging", + "today": "Today", + "yesterday": "Yesterday" + }, + "sessionTimer": { + "expired": "Expired", + "xhRemaining": "{hours}h remaining", + "xmRemaining": "{minutes}m remaining", + "noCustomerMessages": "No customer messages" + }, + "composer": { + "draftHint": "Tap the ✨ to draft a reply with AI — you can edit it before sending", + "sessionExpiredHint": "24-hour session expired. Use a template to re-engage.", + "templates": "Templates", + "readOnlyPlaceholder": "Read-only — viewers can browse but not reply", + "sessionExpiredPlaceholder": "Session expired - use a template", + "typeMessagePlaceholder": "Type a message... (Shift+Enter for new line)", + "readOnlyTitle": "Read-only — your role can't send messages", + "recording": "Recording… {current} / {max}", + "cancel": "Cancel", + "stopAndAttach": "Stop and attach", + "attachMedia": "Attach media", + "photo": "Photo", + "video": "Video", + "document": "Document", + "voiceNote": "Voice note", + "sendTemplate": "Send template", + "draftWithAI": "Draft a reply with AI", + "addCaption": "Add a caption…", + "removeAttachment": "Remove attachment" + }, + "bubble": { + "photo": "Photo", + "video": "Video", + "audio": "Audio", + "unavailable": "{label} unavailable", + "document": "Document", + "locationShared": "Location shared", + "template": "Template", + "buttonReply": "Button reply", + "interactiveReply": "[Interactive reply]", + "unsupported": "[Unsupported message type]", + "aiBadge": "AI", + "aiBadgeTitle": "Sent automatically by the AI assistant" + }, + "actions": { + "reply": "Reply", + "copyText": "Copy text", + "delete": "Delete", + "react": "React", + "reactWith": "React with {emoji}", + "nothingToCopy": "Nothing to copy", + "copied": "Copied", + "copyFailed": "Copy failed" + }, + "replyQuote": { + "photo": "[Photo]", + "video": "[Video]", + "document": "[Document]", + "audio": "[Audio]", + "voiceMessage": "[Voice message]", + "location": "[Location]", + "template": "[Template]", + "message": "[Message]", + "cancelReply": "Cancel reply" + }, + "templatePicker": { + "sendTemplate": "Send template", + "fillPlaceholders": "Fill in the placeholders to render this template. Meta requires every variable to be set.", + "pickTemplate": "Pick an approved WhatsApp template to send to this contact.", + "noApprovedTemplates": "No approved templates", + "noApprovedTemplatesHint": "Approve a template in Meta WhatsApp Manager, then sync it from Settings → Templates.", + "preview": "Preview", + "headerValuePlaceholder": "Value for the header variable", + "bodyValuePlaceholder": "Value for {val}", + "urlSuffixValuePlaceholder": "URL suffix value", + "finalUrl": "Final URL: {url}", + "back": "Back", + "cancel": "Cancel", + "send": "Send template" + }, + "sidebar": { + "contactInfo": "Contact info", + "tags": "Tags", + "notes": "Notes", + "deals": "Deals", + "noTags": "No tags", + "noDeals": "No deals", + "addNotePlaceholder": "Add a note..." + }, + "aiBanner": { + "pausedTitle": "AI assistant is paused here", + "activeText": "AI assistant is replying automatically", + "takeOver": "Take over", + "resume": "Resume AI", + "tookOver": "You’ve taken over this chat.", + "resumed": "AI resumed.", + "updateError": "Couldn’t update the AI assistant.", + "networkError": "Couldn’t reach the server." + } + }, + "Contacts": { + "page": { + "title": "Contacts", + "subtitle": "Manage your contact list. {count} total contacts.", + "subtitleZero": "Manage your contact list.", + "customFieldsBtn": "Custom fields", + "importBtn": "Import", + "addContactBtn": "Add Contact", + "searchPlaceholder": "Search by name, phone, or email...", + "filterByTags": "Filter by tags", + "clearAll": "Clear all", + "noTagsYet": "No tags yet.", + "selectedCount": "{count} selected", + "clearSelection": "Clear", + "deleteSelected": "Delete selected", + "tableColumns": { + "name": "Name", + "phone": "Phone", + "email": "Email", + "company": "Company", + "tags": "Tags", + "createdAt": "Created" + }, + "loading": "Loading contacts...", + "noContactsMatch": "No contacts match your filters.", + "noContactsYet": "No contacts yet.", + "addFirstContact": "Add your first contact", + "unnamed": "Unnamed", + "editAction": "Edit", + "deleteAction": "Delete", + "showingPagination": "Showing {start}-{end} of {total}", + "pageCount": "Page {page} of {total}", + "deleteContactTitle": "Delete Contact", + "deleteContactDesc": "Are you sure you want to delete {name}? This action cannot be undone.", + "cancel": "Cancel", + "deleteBtn": "Delete", + "deleteBulkTitle": "Delete Contacts", + "deleteBulkDesc": "Are you sure you want to delete {count} selected contacts? This action cannot be undone.", + "toastFailedLoad": "Failed to load contacts", + "toastFailedDelete": "Failed to delete contact", + "toastDeleted": "Contact deleted", + "toastBulkFailedDelete": "Failed to delete contacts", + "toastBulkDeleted": "{count} contacts deleted" + }, + "form": { + "addTitle": "Add Contact", + "addDesc": "Create a new contact. Name is required.", + "editTitle": "Edit Contact", + "editDesc": "Update contact details and tags.", + "nameLabel": "Name", + "namePlaceholder": "Jane Doe", + "phoneLabel": "Phone Number", + "phonePlaceholder": "+1234567890", + "phoneHint": "Include country code, e.g. +1 for US", + "emailLabel": "Email", + "emailPlaceholder": "jane@example.com", + "companyLabel": "Company", + "companyPlaceholder": "Acme Corp", + "tagsLabel": "Tags", + "tagsPlaceholder": "Select tags...", + "noTagsFound": "No tags found.", + "createTagAction": "Create tag \"{value}\"", + "cancel": "Cancel", + "save": "Save Contact", + "update": "Update", + "create": "Create", + "toastSuccessAdd": "Contact created successfully", + "toastSuccessEdit": "Contact updated successfully", + "toastError": "Failed to save contact", + "toastConflict": "A contact with this phone number already exists", + "viewExisting": "View {name}", + "dupExact": "A contact with this phone number already exists.", + "dupSimilar": "A contact with a very similar number already exists.", + "loadingTags": "Loading tags...", + "noTagsAvailable": "No tags available. Create tags in Settings.", + "phoneRequired": "Phone number is required" + }, + "detailView": { + "tabs": { + "details": "Details", + "activity": "Activity", + "notes": "Notes", + "custom": "Custom Fields", + "deals": "Deals", + "noTags": "No tags", + "noDeals": "No deals", + "addNotePlaceholder": "Add a note..." + }, + "actions": { + "edit": "Edit", + "message": "Message", + "delete": "Delete" + }, + "contactInfo": "Contact Information", + "unnamed": "Unknown", + "contactDetailsDesc": "Contact details", + "phone": "Phone", + "email": "Email", + "company": "Company", + "createdAt": "Created At", + "customFields": "Custom Fields", + "noCustomFields": "No custom fields defined. Create them in Settings.", + "manageFields": "Manage fields", + "enterCustomField": "Enter {name}...", + "activityTab": { + "title": "Activity History", + "noActivity": "No activity yet." + }, + "notesTab": { + "title": "Internal Notes", + "placeholder": "Write a note...", + "save": "Add Note", + "noNotes": "No notes yet." + }, + "dealsTab": { + "noDeals": "No deals yet" + }, + "tagsTab": { + "clickTagDesc": "Click a tag to add or remove it from this contact.", + "noTagsAvailable": "No tags available. Create tags in Settings." + }, + "sendTemplateBtn": "Send template", + "saveChangesBtn": "Save Changes", + "saveCustomFieldsBtn": "Save Custom Fields", + "toastPhoneRequired": "Phone number is required", + "toastUpdateFailed": "Failed to update contact", + "toastUpdated": "Contact updated", + "toastNotAuthenticated": "Not authenticated", + "toastNoteAddFailed": "Failed to add note", + "toastNoteAdded": "Note added", + "toastNoteDeleteFailed": "Failed to delete note", + "toastNoteDeleted": "Note deleted", + "toastCustomFieldsSaved": "Custom fields saved", + "toastCustomFieldsFailed": "Failed to save custom fields", + "toastTemplateSent": "Template \"{name}\" sent", + "toastTemplateFailed": "Failed to send template: {reason}" + }, + "importModal": { + "title": "Import Contacts", + "desc": "Upload a CSV with a required phone column. Optional: name, email, company, tags (comma-separated; quote multi-tag cells).", + "uploadDropzone": "Click to choose a CSV file", + "uploadHint": ".csv up to your browser limit", + "rowsReady": "{count} row ready", + "rowsReady_plural": "{count} rows ready", + "preview": "Preview · first {count}", + "previewTags": "{tags} tag · {contacts} contact", + "previewTags_plural": "{tags} tags · {contacts} contacts", + "moreRows": "+ {count} more row not shown", + "moreRows_plural": "+ {count} more rows not shown", + "importComplete": "Import complete", + "resultImported": "{count} imported", + "resultTags": "{count} tag assigned", + "resultTags_plural": "{count} tags assigned", + "resultSkipped": "{count} skipped", + "resultFailed": "{count} failed", + "willBeCreated": "{name} (will be created on import)", + "columns": { + "phone": "Phone", + "name": "Name", + "email": "Email", + "company": "Company", + "tags": "Tags" + }, + "cancel": "Cancel", + "close": "Close", + "importBtn": "Import {count} contact", + "importBtn_plural": "Import {count} contacts", + "toastNoValidRows": "No valid rows found. Ensure CSV has a \"phone\" column header.", + "toastTagsWarning": "Contacts imported, but some tag assignments failed.", + "toastImported": "{count} contact imported", + "toastImported_plural": "{count} contacts imported", + "toastTagsAssigned": "{count} tag assignment applied", + "toastTagsAssigned_plural": "{count} tag assignments applied", + "toastTagsSkipped": "Unknown tags skipped (create them in Settings first): {sample}{more}", + "toastSkipped": "{count} duplicate skipped", + "toastSkipped_plural": "{count} duplicates skipped", + "toastFailed": "{count} contact failed to import", + "toastFailed_plural": "{count} contacts failed to import", + "toastError": "Import failed" + }, + "customFields": { + "title": "Custom fields", + "desc": "Define extra contact fields (e.g. ZIP code, lead source). They appear on every contact and in the “Update Contact Field” automation action.", + "addField": "Add", + "fieldName": "New field name…", + "loading": "Loading…", + "empty": "No custom fields yet.", + "renameAria": "Rename {name}", + "deleteTitle": "Delete field", + "deleteConfirm": "Delete \"{name}\"? This also removes its stored value on every contact. This cannot be undone.", + "toastNoAccount": "Your profile is not linked to an account.", + "toastDuplicate": "A field named \"{name}\" already exists.", + "toastCreateFailed": "Could not create field. You may not have permission.", + "toastCreated": "Created \"{name}\".", + "toastRenameFailed": "Could not rename field.", + "toastDeleteFailed": "Could not delete field.", + "toastDeleted": "Deleted \"{name}\"." + } + }, + "Pipelines": { + "page": { + "selectPipeline": "Select Pipeline", + "noPipelinesYet": "No pipelines yet", + "managePipelines": "Manage Pipelines", + "addPipeline": "Add Pipeline", + "addDeal": "Add Deal", + "createToStartTracking": "Create a pipeline to start tracking deals", + "createPipeline": "Create Pipeline", + "newPipeline": "New Pipeline", + "pipelineName": "Pipeline Name", + "pipelineNamePlaceholder": "e.g., Enterprise Sales", + "defaultStagesDesc": "Default stages (New Lead → Won) will be created automatically.", + "cancel": "Cancel", + "creating": "Creating...", + "createPipelineBtn": "Create Pipeline", + "toastFailedLoadPipelines": "Failed to load pipelines", + "toastFailedMoveDeal": "Failed to move deal", + "toastFailedCreatePipeline": "Failed to create pipeline", + "toastPipelineCreated": "Pipeline created", + "toastNotLinkedToAccount": "Your profile is not linked to an account." + }, + "board": { + "dropDealHere": "Drop a deal here", + "addDeal": "Add Deal" + }, + "card": { + "noContact": "No contact", + "won": "Won", + "lost": "Lost" + }, + "form": { + "editDeal": "Edit Deal", + "newDeal": "New Deal", + "title": "Title", + "titlePlaceholder": "Deal title", + "contact": "Contact", + "selectContact": "Select a contact", + "linkToConversation": "Link to Conversation", + "value": "Value", + "currency": "Currency", + "expectedCloseDate": "Expected Close Date", + "stage": "Stage", + "assignedTo": "Assigned To", + "unassigned": "Unassigned", + "notes": "Notes", + "notesPlaceholder": "Add notes...", + "status": "Status", + "markAsWon": "Mark as Won", + "markAsLost": "Mark as Lost", + "reopenDeal": "Reopen deal", + "cancel": "Cancel", + "saving": "Saving...", + "saveChanges": "Save Changes", + "createDeal": "Create Deal", + "deletePrompt": "Delete this deal?", + "confirm": "Confirm", + "deleting": "Deleting...", + "deleteDeal": "Delete Deal", + "toastRequired": "Title, contact, and stage are required", + "toastFailedSave": "Failed to save deal", + "toastNotSignedIn": "Not signed in", + "toastNotLinked": "Your profile is not linked to an account.", + "toastFailedCreate": "Failed to create deal", + "toastUpdated": "Deal updated", + "toastCreated": "Deal created", + "toastFailedStatus": "Failed to update deal status", + "toastMarkedWon": "Marked as won", + "toastMarkedLost": "Marked as lost", + "toastReopened": "Deal reopened", + "toastFailedDelete": "Failed to delete deal", + "toastDeleted": "Deal deleted" + }, + "settings": { + "managePipeline": "Manage Pipeline", + "deletePipeline": "Delete Pipeline", + "deletePipelineDesc": "This will archive all deals in this pipeline. This cannot be undone.", + "cancel": "Cancel", + "deleting": "Deleting...", + "deletePipelineBtn": "Delete Pipeline", + "pipelineName": "Pipeline Name", + "stages": "Stages", + "newStageNamePlaceholder": "New stage name", + "add": "Add", + "createNewPipeline": "Create a new pipeline", + "saving": "Saving...", + "saveChanges": "Save Changes", + "dragToReorder": "Drag to reorder", + "changeColor": "Change color", + "toastFailedSave": "Failed to save pipeline", + "toastSaved": "Pipeline saved", + "toastFailedAddStage": "Failed to add stage", + "toastMoveOrDeleteDeals": "Move or delete deals in this stage first", + "toastFailedDeleteStage": "Failed to delete stage", + "toastFailedDeletePipeline": "Failed to delete pipeline", + "toastDeleted": "Pipeline deleted" + }, + "analytics": { + "totalDeals": "Total Deals", + "totalDealsTooltip": "Count of every deal in this pipeline that isn't marked as Lost. Won deals are still included.", + "pipelineValue": "Pipeline Value", + "pipelineValueTooltip": "Sum of the dollar values of all deals in this pipeline, excluding deals marked as Lost.", + "avgDealSize": "Avg Deal Size", + "avgDealSizeTooltip": "Pipeline Value divided by Total Deals — the average value of a single non-lost deal.", + "weightedValue": "Weighted Value", + "weightedValueTooltip": "Expected revenue: each open deal's value × its stage probability. First stage ≈ 10%, stages progress up to 90%, Won = 100%. Lost deals are excluded.", + "wonThisMonth": "Won This Month", + "wonThisMonthTooltip": "Deals marked as Won since the first day of the current month.", + "lostThisMonth": "Lost This Month", + "lostThisMonthTooltip": "Deals marked as Lost since the first day of the current month.", + "howCalculated": "How {label} is calculated" + } + }, + "Broadcasts": { + "page": { + "title": "Broadcasts", + "subtitle": "Send bulk messages to your contacts using approved templates.", + "newBroadcast": "New Broadcast", + "noBroadcastsYet": "No broadcasts yet", + "createFirst": "Create your first broadcast to reach your contacts at scale.", + "table": { + "name": "Name", + "template": "Template", + "recipients": "Recipients", + "delivery": "Delivery", + "read": "Read", + "status": "Status", + "date": "Date" + }, + "errorLoad": "Failed to load broadcasts", + "retry": "Retry" + }, + "status": { + "draft": "Draft", + "scheduled": "Scheduled", + "sending": "Sending", + "sent": "Sent", + "failed": "Failed", + "pending": "Pending", + "delivered": "Delivered", + "read": "Read", + "replied": "Replied", + "unknown": "Unknown" + }, + "detail": { + "backToBroadcasts": "Back to Broadcasts", + "notFound": "Broadcast not found", + "template": "Template: {name}", + "createdAt": "Created {date}", + "deletePrompt": "Delete this broadcast?", + "cancel": "Cancel", + "confirm": "Confirm", + "deleting": "Deleting…", + "delete": "Delete", + "cannotDeleteSending": "Cannot delete while a broadcast is actively sending", + "deleteHover": "Delete this broadcast", + "funnel": "Funnel", + "recipientsHeader": "Recipients ({filtered} of {total})", + "recipientsHeaderAll": "Recipients ({total})", + "allStatuses": "All statuses", + "exportCsv": "Export CSV", + "noRecipients": "No recipients found.", + "noRecipientsFilter": "No recipients match this filter.", + "table": { + "contact": "Contact", + "phone": "Phone", + "status": "Status", + "sent": "Sent", + "delivered": "Delivered", + "read": "Read", + "error": "Error" + }, + "stats": { + "totalRecipients": "Total Recipients", + "sent": "Sent", + "delivered": "Delivered", + "read": "Read", + "replied": "Replied", + "failed": "Failed" + }, + "toastFailedDelete": "Failed to delete: {error}", + "toastDeleted": "Broadcast deleted" + }, + "new": { + "title": "New Broadcast", + "subtitle": "Create and send a broadcast message to your contacts.", + "steps": { + "template": "Template", + "audience": "Audience", + "personalize": "Personalize", + "send": "Send" + }, + "toastGiveName": "Give the broadcast a name before saving a draft.", + "toastNotSignedIn": "Not signed in.", + "toastNotLinked": "Your profile is not linked to an account.", + "toastFailedDraft": "Failed to save draft: {error}", + "toastDraftSaved": "Draft saved" + }, + "wizard": { + "next": "Next", + "back": "Back", + "cancel": "Cancel", + "chooseTemplate": { + "title": "Choose a Template", + "subtitle": "Select an approved message template for your broadcast.", + "noTemplates": "No templates available.", + "createFirst": "Create a template in Settings first.", + "errorLoad": "Failed to load templates" + }, + "selectAudience": { + "title": "Select Audience", + "subtitle": "Choose who will receive this broadcast.", + "method": { + "all": "All Contacts", + "tags": "Filter by Tags", + "customField": "Custom Field", + "csv": "Upload CSV" + }, + "allDesc": "Send to all {count} contacts in your database.", + "allDescLoading": "Send to all contacts in your database.", + "tagDesc": "Send to contacts matching specific tags.", + "customFieldDesc": "Send to contacts matching a custom field rule.", + "csvDesc": "Upload a CSV with phone numbers (and optional names).", + "selectTags": "Select Tags", + "searchTags": "Search tags...", + "noTagsFound": "No tags found.", + "excludeTags": "Exclude Tags (Optional)", + "searchExcludeTags": "Search tags to exclude...", + "field": "Field", + "selectField": "Select a field...", + "operator": "Operator", + "operatorIs": "Is", + "operatorIsNot": "Is not", + "operatorContains": "Contains", + "value": "Value", + "valuePlaceholder": "Value to match", + "uploadCsv": "Upload CSV File", + "csvFormatDesc": "Must contain a 'phone' column (E.164 format). 'name' column is optional.", + "csvContactsFound": "{count} valid contact(s) found in CSV.", + "errorLoadTags": "Failed to load tags", + "errorLoadFields": "Failed to load custom fields", + "errorCsvParse": "Could not parse CSV file.", + "errorCsvMissingPhone": "CSV must have a 'phone' column." + }, + "personalize": { + "title": "Personalize Variables", + "subtitle": "Map your template variables to contact fields or provide static values.", + "headerImage": "Header Image", + "headerImageDesc": "This template requires a header image.", + "imageUrl": "Image URL", + "imageUrlPlaceholder": "https://example.com/image.jpg", + "variables": "Variables", + "varDesc": "Set values for {{name}}", + "type": "Type", + "typeStatic": "Static Value", + "typeContact": "Contact Field", + "typeCustom": "Custom Field", + "staticValue": "Static Value", + "contactField": "Contact Field", + "selectContactField": "Select field...", + "customField": "Custom Field", + "selectCustomField": "Select custom field...", + "preview": "Preview", + "previewSample": "Showing preview with sample data.", + "noPreview": "Preview not available.", + "fieldMap": { + "name": "Name", + "phone": "Phone", + "email": "Email" + } + }, + "scheduleSend": { + "title": "Review & Send", + "subtitle": "Give your broadcast a name and confirm sending.", + "broadcastName": "Broadcast Name", + "broadcastNamePlaceholder": "e.g., Summer Promo", + "summary": "Summary", + "template": "Template:", + "audience": "Audience:", + "audienceAll": "All contacts", + "audienceTags": "Filtered by tags", + "audienceField": "Filtered by custom field", + "audienceCsv": "CSV upload", + "variables": "Variables:", + "variablesMapped": "{count} mapped", + "saveDraft": "Save as Draft", + "sendNow": "Send Broadcast", + "sending": "Sending...", + "preparing": "Preparing...", + "progressDesc": "Processing {current} of {total} recipients...", + "toastFailedPreview": "Failed to load preview data" + } + } + }, + "Automations": { + "list": { + "title": "Automations", + "subtitle": "Build workflows that react to WhatsApp® events automatically.", + "create": "Create Automation", + "retry": "Retry", + "templatesTitle": "Quick-start templates", + "emptyTitle": "No automations yet", + "emptyDesc": "Pick a template above or create one from scratch.", + "runs": "{count} run", + "runsPlural": "{count} runs", + "lastRun": "last {time}", + "activate": "Activate", + "deactivate": "Deactivate", + "edit": "Edit", + "duplicate": "Duplicate", + "viewLogs": "View Logs", + "delete": "Delete", + "cancel": "Cancel", + "deleteTitle": "Delete automation", + "deleteDesc": "This permanently removes {name} and its execution history. This cannot be undone.", + "toasts": { + "activated": "Automation activated", + "paused": "Automation paused", + "duplicated": "Automation duplicated", + "deleted": "Automation deleted", + "updateError": "Failed to update", + "duplicateError": "Failed to duplicate", + "deleteError": "Failed to delete" + } + }, + "edit": { + "loading": "Loading...", + "loadError": "Failed to load ({status})", + "back": "Back to Automations" + }, + "logs": { + "title": "Execution logs", + "loadError": "Failed to load logs", + "emptyTitle": "No executions yet", + "emptyDesc": "Trigger this automation to see runs here.", + "unknownContact": "Unknown contact", + "step": "{count} step", + "stepPlural": "{count} steps", + "noSteps": "No steps recorded.", + "status": { + "success": "success", + "partial": "partial", + "failed": "failed" + }, + "back": "Back", + "backAria": "Back" + }, + "builder": { + "steps": { + "send_message": "Send Message", + "send_template": "Send Template", + "add_tag": "Add Tag", + "remove_tag": "Remove Tag", + "assign_conversation": "Assign Conversation", + "update_contact_field": "Update Contact Field", + "create_deal": "Create Deal", + "wait": "Wait", + "condition": "Condition (If/Else)", + "send_webhook": "Send Webhook", + "close_conversation": "Close Conversation" + }, + "triggers": { + "new_message_received": { + "label": "New Message Received", + "hint": "Any incoming message" + }, + "first_inbound_message": { + "label": "First Message from Contact", + "hint": "First time this contact ever messages you (works for manually-added contacts too)" + }, + "keyword_match": { + "label": "Keyword Match", + "hint": "Message contains specific keyword(s)" + }, + "new_contact_created": { + "label": "New Contact Created", + "hint": "When a contact is auto-created from an incoming message" + }, + "conversation_assigned": { + "label": "Conversation Assigned", + "hint": "When assigned to an agent" + }, + "tag_added": { + "label": "Tag Added", + "hint": "When a tag is added to a contact" + }, + "time_based": { + "label": "Time-Based", + "hint": "On a recurring schedule" + } + }, + "trigger": "Trigger", + "triggerType": "Trigger type", + "keywords": "Keywords", + "keywordsHint": "Comma-separated list (e.g. hello, help, buy)", + "schedule": "Schedule", + "scheduleHint": "Cron expression (e.g. 0 9 * * 1-5)", + "active": "Active", + "activeAria": "Active", + "save": "Save", + "saveDraft": "Save Draft", + "untitled": "Untitled automation", + "backToAutomations": "Back to automations", + "toasts": { + "saved": "Automation saved", + "created": "Automation created", + "saveFailed": "Save failed" + }, + "tags": { + "select": "Select a tag…", + "placeholder": "Tag id", + "unknown": "{id} (unknown tag)" + }, + "fields": { + "name": "Name", + "email": "Email", + "company": "Company", + "customFields": "Custom fields", + "unknown": "{id} (unknown field)" + }, + "agents": { + "select": "Select an agent…", + "placeholder": "Agent id", + "unknown": "{id} (unknown agent)" + }, + "pipelines": { + "pipelineLabel": "Pipeline", + "pipelineIdLabel": "Pipeline id", + "stageLabel": "Stage", + "stageIdLabel": "Stage id", + "selectPipeline": "Select a pipeline…", + "selectStage": "Select a stage…", + "selectPipelineFirst": "Select a pipeline first…", + "unknownPipeline": "{id} (unknown pipeline)", + "unknownStage": "{id} (unknown stage)" + }, + "templates": { + "templateLabel": "Template", + "templateNameLabel": "Template name", + "languageLabel": "Language", + "select": "Select a template…", + "unknown": "{name} ({lang}) — not in approved list", + "unknownLang": "unknown" + }, + "branches": { + "yes": "Yes", + "no": "No" + }, + "addStep": "Add step", + "config": { + "messageText": "Message text", + "placeholderMessageText": "Hi! Thanks for reaching out…", + "tagLabel": "Tag", + "modeLabel": "Mode", + "modes": { + "round_robin": "Round-robin", + "specific": "Specific agent" + }, + "agentLabel": "Agent", + "fieldLabel": "Field", + "valueLabel": "Value", + "placeholderValue": "Text or {{ vars.x }} / {{ message.text }}", + "titleLabel": "Title", + "amountLabel": "Amount", + "unitLabel": "Unit", + "units": { + "minutes": "Minutes", + "hours": "Hours", + "days": "Days" + }, + "subjectLabel": "Subject", + "subjects": { + "tag_presence": "Tag presence", + "contact_field": "Contact field", + "message_content": "Message content", + "time_of_day": "Time of day" + }, + "operandLabel": "Operand", + "placeholderTime": "HH:mm-HH:mm", + "placeholderContact": "name / email / company", + "placeholderTag": "tag id", + "urlLabel": "URL", + "headersLabel": "Headers", + "placeholderHeaders": "{\"Authorization\": \"Bearer ...\"}", + "bodyTemplateLabel": "Body template", + "placeholderBody": "{\"id\": \"{{ contact.id }}\"}", + "matchType": "Match type", + "matchContains": "Contains", + "matchExact": "Exact", + "closeConversationHint": "Sets the conversation status to \"closed\". No configuration needed." + } + } + }, + "Flows": { + "list": { + "title": "Flows", + "beta": "Beta", + "description": "Build branching, button-driven WhatsApp conversations. Useful for menus, FAQs, and triage before a human steps in.", + "newFlow": "New flow", + "statusDraft": "Draft", + "statusActive": "Active", + "statusArchived": "Archived", + "loadError": "Couldn't load flows.", + "createError": "Couldn't create flow.", + "cloneError": "Clone failed", + "deleteConfirm": "Delete \"{name}\"? Any active runs will end immediately.", + "deleteSuccess": "Flow deleted.", + "deleteError": "Couldn't delete flow.", + "emptyTitle": "No flows yet", + "emptyDesc": "Build your first conversation — a welcome menu, an order lookup, an FAQ bot. Customers tap buttons; the bot routes them to the right answer (or the right agent).", + "createFirst": "Create your first flow", + "createTitle": "Create a new flow", + "createDesc": "Start from a template or build from scratch.", + "startTemplate": "Start from a template", + "nodeCount": "{count} {count, plural, =1 {node} other {nodes}}", + "startBlank": "Or start blank", + "placeholderName": "e.g. Welcome menu", + "cancel": "Cancel", + "createBlank": "Create blank flow", + "runCount": "{count} {count, plural, =1 {run} other {runs}}", + "edit": "Edit", + "delete": "Delete", + "triggerKeywordNone": "Triggers on keyword (none set)", + "triggerKeyword": "Triggers on: {keywords}", + "triggerFirstInbound": "Triggers on a contact's first-ever inbound message", + "triggerManual": "Manual trigger" + }, + "edit": { + "notFound": "Flow not found.", + "backToFlows": "← Back to flows", + "loadError": "Couldn't load flow." + }, + "logs": { + "title": "Runs", + "description": "The 50 most recent times this flow ran. Expand a row to see the engine's per-step log.", + "emptyState": "No runs yet. Trigger the flow from a personal WhatsApp number to see it appear here.", + "loadError": "Couldn't load runs.", + "unknownContact": "Unknown contact", + "atNode": "at {node}", + "started": "Started {time}", + "reprompts": "{count} re-prompts", + "ranFor": "ran for {duration}", + "capturedVars": "Captured vars ({count})", + "noEvents": "No events recorded for this run.", + "statusActive": "Active", + "statusCompleted": "Completed", + "statusHandedOff": "Handed off", + "statusTimedOut": "Timed out", + "statusPaused": "Paused by agent", + "statusFailed": "Failed" + }, + "builder": { + "triggerTitle": "Trigger", + "whenLabel": "When…", + "triggerKeywordTitle": "A message contains a keyword", + "triggerFirstInboundTitle": "Customer's first ever inbound message", + "triggerManualTitle": "Manual only (no auto-trigger)", + "keywordsLabel": "Keywords (comma-separated)", + "keywordsPlaceholder": "support, help, hi", + "entryNodeTitle": "Entry node:", + "entryNodePlaceholder": "Pick the first node…", + "nodesTitle": "Nodes ({count})", + "nodesEmpty": "Add a Start node, then a Send buttons node, then a Handoff — that's the welcome-menu shape from the brief.", + "addNode": "Add node", + "setAsEntry": "Set as entry", + "removeNode": "Remove node", + "showAdvanced": "Show advanced", + "hideAdvanced": "Hide advanced", + "nodeKeyLabel": "Node key (internal identifier — keep stable for analytics)", + "replyIdsHint": "Reply IDs for each option are shown inline above. They're returned by WhatsApp when a customer taps; you usually don't need to touch them.", + "badgeEntry": "Entry", + "noNodesYet": "No nodes yet.", + "deleteNode": "Delete node", + "canvasView": "Canvas", + "listView": "List", + "noNodeSelected": "No node selected", + "noNodeDesc": "Select a node on the canvas to edit its configuration.", + "form": { + "advancesTo": "Advances to", + "textToCustomer": "Text sent to the customer", + "promptToCustomer": "Prompt sent to the customer", + "varKeyLabel": "Variable key (stored in flow_runs.vars; alphanumeric + underscore)", + "varKeyPlaceholder": "e.g. name, email, company", + "varKeyHelp": "Interpolate in downstream prompts and handoff notes with", + "advanceAfterCapture": "After capturing, advance to", + "internalNote": "Internal note (for the agent picking up)", + "endNodeHelp": "Terminal node. When the runner reaches this node the run is marked complete. No config needed.", + "bodyText": "Body text", + "footerText": "Footer (optional, 60 chars)", + "buttonsHelp": "Buttons (1–3) — each one routes to a different next node", + "optionTitlePlaceholder": "Visible title (≤20 chars)", + "nextNodePlaceholder": "Next node…", + "addButton": "Add button", + "buttonLabel": "Tap-to-expand button label (≤20 chars)", + "rowsHelp": "Rows (1–10 total across all sections)", + "sectionTitlePlaceholder": "Section {count} title (optional)", + "rowTitlePlaceholder": "Row title (≤24)", + "addRow": "Add row", + "addSection": "Add section", + "ifLabel": "If", + "capturedVariable": "Captured variable", + "contactHasTag": "Contact has tag", + "contactField": "Contact field", + "varName": "var name", + "tagLabel": "Tag", + "fieldLabel": "Field", + "pickTag": "Pick a tag…", + "pickField": "Pick a field…", + "operatorLabel": "Operator", + "isPresent": "is present", + "isAbsent": "is absent", + "equals": "equals", + "contains": "contains", + "valueLabel": "Value", + "ifTrueAdvance": "If true → advance to", + "ifFalseAdvance": "If false → advance to", + "actionLabel": "Action", + "addTag": "Add tag", + "removeTag": "Remove tag", + "tagUuidPlaceholder": "Tag UUID", + "thenAdvanceTo": "Then advance to", + "mediaTypeLabel": "Media type", + "imageLabel": "Image (PNG, JPEG, WebP)", + "videoLabel": "Video (MP4, 3GP)", + "documentLabel": "Document (PDF, Word, Excel, PowerPoint, TXT)", + "fileLabel": "File", + "removeFile": "Remove file", + "uploading": "Uploading…", + "clickToUpload": "Click to upload (max 16 MB)", + "captionLabel": "Caption (optional, shown under the media)", + "filenameLabel": "Filename shown to the customer (documents only)", + "filenamePlaceholder": "invoice.pdf", + "advanceAfterSending": "After sending, advance to", + "pickNextNode": "Pick a next node…", + "none": "— None —" + }, + "categories": { + "messaging": "Messaging", + "logic": "Logic & data", + "flow": "Flow control" + }, + "nodes": { + "start": { + "label": "Start", + "blurb": "Entry point of the flow" + }, + "send_message": { + "label": "Send message", + "blurb": "Sends a WhatsApp text message" + }, + "send_buttons": { + "label": "Send buttons", + "blurb": "Sends quick-reply buttons" + }, + "send_list": { + "label": "Send list", + "blurb": "Sends a tappable list of options" + }, + "send_media": { + "label": "Send media", + "blurb": "Sends an image, video, or document" + }, + "collect_input": { + "label": "Collect input", + "blurb": "Asks a question, saves the reply" + }, + "condition": { + "label": "If / else", + "blurb": "Branches on a rule" + }, + "set_tag": { + "label": "Tag contact", + "blurb": "Adds or removes a contact tag" + }, + "handoff": { + "label": "Handoff to agent", + "blurb": "Hands the conversation to a human" + }, + "end": { + "label": "End", + "blurb": "Ends the flow" + } + } + }, + "validation": { + "noIssues": "No issues. Ready to activate.", + "summary": "{errorCount} {errorCount, plural, =1 {error} other {errors}}, {warningCount} {warningCount, plural, =1 {warning} other {warnings}}", + "jumpToNode": "Jump to node {key}" + }, + "editorState": { + "saved": "Saved.", + "fixIssues": "Fix the issues below before activating.", + "statusActivated": "Flow activated.", + "statusArchived": "Archived.", + "statusDraft": "Saved as draft." + }, + "summary": { + "options": "{count} {count, plural, =1 {option} other {options}}", + "optionsAcrossSections": "{rowCount} {rowCount, plural, =1 {option} other {options}} across {sectionCount} {sectionCount, plural, =1 {section} other {sections}}", + "noFile": "{label} (no file uploaded)", + "media": "Media", + "image": "Image", + "video": "Video", + "document": "Document", + "audio": "Audio", + "hasTag": "has tag {tag}", + "opContains": "contains", + "opExists": "exists", + "opMissing": "missing", + "modeAdd": "Add", + "modeRemove": "Remove", + "tagPicked": "{mode} tag {tag}…", + "tagNone": "{mode} tag (none picked)" + } + }, + "Settings": { + "pageTitle": "Settings", + "pageDesc": "Everything in one place — your account and your workspace. Pick a section to manage it.", + "overview": { + "notSetup": "Not set up yet", + "connected": "Connected", + "needsReconnecting": "Needs reconnecting", + "viewTeamMembers": "View team members", + "membersCount": "{count} {count, plural, =1 {member} other {members}}", + "pendingInvites": "{count} pending {count, plural, =1 {invite} other {invites}}", + "manageTemplates": "Manage message templates", + "templatesCount": "{count} {count, plural, =1 {template} other {templates}}", + "pendingReview": "{count} pending review", + "tagsAndFields": "Tags and custom fields", + "tagsCount": "{count} {count, plural, =1 {tag} other {tags}}", + "fieldsCount": "{count} custom {count, plural, =1 {field} other {fields}}", + "appearance": "{mode} mode · {theme} accent", + "yourAccount": "Your account", + "loading": "Loading…" + }, + "members": { + "title": "Team members", + "description": "People with access to this account. Roles control what each teammate can do.", + "inviteMember": "Invite member", + "online": "online", + "away": "away", + "offline": "offline", + "memberCount": "{count} {count, plural, =1 {member} other {members}}", + "you": "You", + "unnamed": "Unnamed", + "joined": "Joined {date}", + "remove": "Remove", + "removedToast": "Removed {name}", + "updatedToast": "Updated {name} to {role}", + "pendingInvitations": "Pending invitations", + "inviteHint": "The plaintext invite URL is only shown once at creation for security — to re-share, revoke the invite below and create a new one.", + "noPendingTitle": "No pending invitations.", + "noPendingDesc": "Click Invite member above to generate a shareable link.", + "untitledInvite": "Untitled invite", + "created": "Created {date}", + "expiresInDays": "expires in {days} {days, plural, =1 {day} other {days}}", + "expiresInHours": "expires in {hours} {hours, plural, =1 {hour} other {hours}}", + "expired": "expired", + "revoke": "Revoke", + "revokedToast": "Invitation revoked", + "removeDialogTitle": "Remove member", + "removeDialogDesc": "Remove {name} from the account? They'll be signed out of this account and given a fresh personal account on their next sign-in. Their login isn't deleted.", + "cancel": "Cancel", + "removing": "Removing...", + "removeBtn": "Remove member" + }, + "invite": { + "inviteCreated": "Invite created", + "inviteCreatedDesc": "Share this link with your new teammate. They'll be able to sign up (or sign in) and join the account as {role}. The link is valid for {days} {days, plural, =1 {day} other {days}}.", + "inviteLink": "Invite link", + "copy": "Copy", + "saveLinkNow": "Save this link now.", + "saveLinkHint": "We never store the plaintext — once you close this dialog the URL is gone. To re-share, revoke this invite and create a new one.", + "sendViaWhatsApp": "Send via WhatsApp", + "done": "Done", + "dialogTitle": "Invite a teammate", + "dialogDesc": "Generate a one-time invite link. Share it via WhatsApp, Slack, or any channel you like — no email service required.", + "roleLabel": "Role", + "validForLabel": "Link valid for", + "days1": "1 day", + "days7": "7 days", + "days30": "30 days", + "labelTitle": "Label", + "optional": "(optional)", + "labelPlaceholder": "e.g. Sara — support team", + "labelHint": "Helps you remember who you sent the link to in the pending list below.", + "cancel": "Cancel", + "creating": "Creating...", + "generateLink": "Generate link", + "labelTooLong": "Label must be {max} characters or fewer", + "copied": "Invite link copied", + "clipboardBlocked": "Clipboard blocked — copy the link manually", + "whatsappMessage": "Join {accountName} on wacrm using this link (valid for {expiresInDays} days): {url}" + }, + "tagsAndFields": { + "title": "Fields & tags", + "description": "Two ways to organize contacts: colour-coded tags for quick grouping, and custom fields for structured data.", + "tagsTitle": "Tags", + "tagsDesc": "Colour-coded labels for grouping and filtering contacts.", + "fieldsTitle": "Custom fields", + "fieldsDesc": "Extra contact fields (e.g. ZIP code, lead source). They appear on every contact and in the “Update Contact Field” automation action.", + "adminRole": "Admin", + "failedToLoadTags": "Failed to load tags", + "nameRequired": "Tag name is required", + "notAuthenticated": "Not authenticated", + "tagCreated": "Tag created", + "failedToCreateTag": "Failed to create tag", + "tagDeleted": "Tag deleted", + "failedToDeleteTag": "Failed to delete tag", + "deleteAria": "Delete {name}", + "noTags": "No tags yet — create your first one below.", + "placeholder": "e.g. Newsletter", + "useColor": "Use {color}", + "addTag": "Add tag", + "deleteTag": "Delete tag", + "deleteConfirm": "Delete the tag \"{name}\"? This removes it from all contacts and cannot be undone.", + "cancel": "Cancel", + "deleting": "Deleting...", + "colors": { + "red": "Red", + "orange": "Orange", + "amber": "Amber", + "emerald": "Emerald", + "cyan": "Cyan", + "blue": "Blue", + "violet": "Violet", + "pink": "Pink" + } + }, + "templates": { + "title": "Message templates", + "description": "Create templates and submit them to Meta for approval. Use \"Sync from Meta\" to pull templates approved elsewhere.", + "syncFromMeta": "Sync from Meta", + "syncing": "Syncing…", + "syncTitle": "Pull approved templates from your Meta WhatsApp Business Account", + "newTemplate": "New Template", + "noTemplates": "No templates yet.", + "createFirst": "Create your first message template to get started.", + "editTitle": "Editing triggers Meta re-review — status flips to PENDING.", + "editLabel": "Edit template", + "edit": "Edit", + "resubmitTitle": "Edit the template and resubmit to Meta for review.", + "resubmitLabel": "Edit and resubmit template", + "resubmit": "Resubmit", + "deleteMetaLocallyAria": "Delete template from Meta and locally", + "deleteLocallyAria": "Delete template locally", + "deleteMetaLocallyTitle": "Delete from Meta and locally", + "deleteLocallyTitle": "Delete locally", + + "dialogEditTitle": "Edit Message Template", + "dialogNewTitle": "New Message Template", + "dialogEditDesc": "Save your changes to re-submit to Meta. Status will flip back to PENDING during review.", + "dialogNewDesc": "Build a template and submit it to Meta for approval. Once approved, you can use it in broadcasts and the inbox.", + "authWarning": "AUTHENTICATION templates have a fixed body + OTP button shape that needs a different builder. Create them in Meta WhatsApp Manager for now and use Sync from Meta to bring them in.", + + "templateName": "Template Name", + "namePlaceholder": "e.g. order_confirmation", + "nameFixed": "Name is fixed once a template exists on Meta — create a new template to change it.", + "nameHint": "Lowercase letters, digits, and underscores only.", + "category": "Category", + "language": "Language", + "langFixed": "Language is fixed once a template exists on Meta.", + "langHint": "Must match the exact code on Meta — en_US and en are distinct.", + + "header": "Header", + "headerNone": "None", + "headerText": "Text", + "headerImage": "Image", + "headerVideo": "Video", + "headerDocument": "Document", + "headerTextPlaceholder": "Header text (max 60 chars, optional {{1}})", + "headerSampleAria": "Sample value for header variable", + "headerSamplePlaceholder": "Sample value for {{1}} (required for Meta review)", + + "uploadImage": "Upload image", + "uploadHint": "JPEG or PNG, ≤5 MB", + "mediaUrlPlaceholder": "https://… (or paste a public {format} link)", + "imageHint": "Upload a JPEG/PNG (≤5 MB, ≥800×418 px recommended) or paste a public HTTPS link — we upload it to Meta for review automatically.", + "mediaHint": "Must be a publicly accessible HTTPS link. Meta fetches it once during review, so it needs to stay live for ~24 hrs.", + "videoHint": " Recommended: MP4 / 3GPP, ≤16 MB, ≤60 seconds.", + "documentHint": " Recommended: PDF, ≤100 MB.", + + "bodyText": "Body Text", + "bodyPlaceholder": "Hello {{1}}, your order {{2}} is confirmed.", + "bodyHint": "Use {{1}}, {{2}} for variables (must be contiguous starting at {{1}}).", + "sampleValues": "Sample values (Meta uses these to review your template)", + "sampleAria": "Sample value for body variable {var}", + "samplePlaceholder": "Sample for {var}", + + "footer": "Footer (optional)", + "footerPlaceholder": "Optional footer text (max 60 chars)", + + "buttons": "Buttons (optional)", + "addButton": "Add Button", + "buttonsLimit": "Up to {max} buttons. QUICK_REPLY buttons must come before URL / phone / copy-code buttons.", + "btnQuickReply": "Quick Reply", + "btnUrl": "URL", + "btnPhone": "Phone", + "btnCopyCode": "Copy Code", + "btnLabelPlaceholder": "Button label", + "urlPlaceholder": "https://example.com/path or with {{1}} suffix", + "urlSamplePlaceholder": "Example value for {{1}} (required when URL has a variable)", + "phonePlaceholder": "+15551234567", + "codePlaceholder": "Example code (e.g. SUMMER20)", + + "cancel": "Cancel", + "saving": "Saving…", + "submitting": "Submitting…", + "saveResubmit": "Save & Resubmit", + "submitApproval": "Submit for Approval", + + "deleteDialogTitle": "Delete template?", + "deleteMetaDesc": "\"{name}\" will be deleted from Meta and from wacrm. Active broadcasts using this template will start failing on their next send. This can't be undone.", + "deleteLocalDesc": "\"{name}\" will be deleted from wacrm. It was never submitted to Meta, so no remote cleanup is needed.", + "deleting": "Deleting…", + "delete": "Delete", + + "toastLoadFailed": "Failed to load templates", + "toastSubmitFailed": "Failed to submit", + "toastSubmitEditSuccess": "Edit submitted — Meta typically reviews within 24 hours.", + "toastSubmitNewSuccess": "Submitted to Meta — typical review time is 24 hours. Status updates automatically.", + "toastSaveEditDry": "Template updated (dry-run — no Meta call)", + "toastSaveNewDry": "Template saved (dry-run — no Meta call)", + "toastSyncCount": "Synced {total} {total, plural, =1 {template} other {templates}} from Meta", + "toastSyncDetails": " ({inserted} new, {updated} updated)", + "toastSyncFailed": "Failed to sync: {preview}", + "toastSyncTruncated": "Synced the first 2000 templates only — your account has more. Sync again to continue, or contact support if this persists.", + "toastSyncError": "Failed to sync templates", + "toastDeleteSuccess": "Template deleted", + "toastDeleteError": "Failed to delete template", + "toastInvalidImage": "Header image must be a JPEG or PNG.", + "toastImageTooLarge": "Image is {size} MB — Meta's limit is 5 MB.", + "toastUploadSuccess": "Image uploaded.", + "toastUploadFailed": "Upload failed." + }, + "roles": { + "admin": "Admin", + "adminHint": "Manage members + everything", + "agent": "Agent", + "agentHint": "Use features; no settings", + "viewer": "Viewer", + "viewerHint": "Read-only across the app", + "owner": "Owner", + "ownerHint": "Full control over account and billing" + }, + "whatsapp": { + "title": "WhatsApp connection", + "description": "Connect your Meta WhatsApp Business API. Credentials, webhook, and setup steps all live here.", + "tokenCorrupted": "Stored token can't be decrypted", + "resetting": "Resetting...", + "resetConfig": "Reset Configuration", + "credentialsValid": "Credentials valid", + "notConnected": "Not Connected", + "connectedDesc": "Your access token authenticates with Meta. See Registration status below for whether webhooks are actually wired.", + "notConnectedDesc": "Configure your Meta API credentials below to connect your WhatsApp Business account.", + "registered": "Registered — Meta will deliver events to wacrm", + "notRegistered": "Not registered — Meta will not deliver events", + "verifyWithMeta": "Verify with Meta", + "subscribedSince": "Subscribed since {date}. Click Verify with Meta if events stop arriving.", + "unknownDate": "unknown", + "lastAttemptFailed": "Last attempt failed with: ", + "retryHint": "Enter (or correct) the 2-step PIN below and click Save Configuration to retry.", + "noRegistrationHint": "This number was saved before registration tracking existed, or registration was skipped. Enter the 2-step PIN below and click Save Configuration to subscribe it.", + "diagnosticLastRun": "Diagnostic — last run: ", + "live": "live", + "notLive": "not live", + "apiCredentialsTitle": "API Credentials", + "apiCredentialsDesc": "Enter your Meta WhatsApp Business API credentials.", + "phoneNumberId": "Phone Number ID", + "wabaId": "WhatsApp Business Account ID", + "accessToken": "Permanent Access Token", + "accessTokenPlaceholder": "Enter your access token", + "tokenHidden": "Token is hidden for security. Re-enter it to update configuration.", + "webhookVerifyToken": "Webhook Verify Token", + "webhookVerifyTokenPlaceholder": "Create a custom verify token", + "webhookVerifyTokenHint": "A custom string you create. Must match the token you set in Meta webhook settings.", + "twoStepPin": "Two-step verification PIN", + "optional": "(optional)", + "pinPlaceholder": "6-digit PIN from Meta WhatsApp Manager", + "pinHint": "Needed only to wire inbound messages for a production number. Set it in Meta Business Manager → WhatsApp Accounts → Phone Numbers → Two-step verification, then paste it here so wacrm can subscribe the number — otherwise Meta routes inbound events to whichever app last claimed it (the symptom that hits second numbers under a shared WABA). Meta test numbers have no PIN and are pre-registered — leave this blank for them. Leaving it blank also keeps an existing registration untouched.", + "webhookTitle": "Webhook Configuration", + "webhookDesc": "Use this URL as your webhook callback in the Meta App Dashboard.", + "webhookUrl": "Webhook Callback URL", + "saving": "Saving...", + "saveConfig": "Save Configuration", + "testing": "Testing...", + "testConnection": "Test API Connection", + "setupInstructions": "Setup Instructions", + "setupInstructionsDesc": "Follow these steps to connect your WhatsApp Business API.", + "step1": "Create a Meta App", + "step1_1": "Go to developers.facebook.com", + "step1_2": "Click \"My Apps\" and then \"Create App\"", + "step1_3": "Select \"Business\" as the app type", + "step1_4": "Fill in app details and create", + "step2": "Add WhatsApp Product", + "step2_1": "In your app dashboard, click \"Add Product\"", + "step2_2": "Find \"WhatsApp\" and click \"Set Up\"", + "step2_3": "Follow the setup wizard to link your business", + "step3": "Get API Credentials", + "step3_1": "Go to WhatsApp > API Setup", + "step3_2": "Copy your Phone Number ID", + "step3_3": "Copy your WhatsApp Business Account ID", + "step3_4": "Generate a Permanent Access Token from Business Settings > System Users", + "step4": "Configure Webhooks", + "step4_1": "Go to WhatsApp > Configuration", + "step4_2": "Click \"Edit\" on the Webhook section", + "step4_3": "Paste the Webhook Callback URL from above", + "step4_4": "Enter the same Verify Token you set here", + "step4_5": "Subscribe to \"messages\" webhook field", + "metaDocs": "Meta WhatsApp API Documentation" + }, + "sections": { + "overview": "Overview", + "profile": "Your profile", + "security": "Login & security", + "appearance": "Appearance", + "whatsapp": "WhatsApp", + "templates": "Templates", + "fields": "Fields & tags", + "deals": "Deals & currency", + "members": "Team members", + "api": "API keys" + }, + "groups": { + "account": "Account", + "workspace": "Workspace" + }, + "profile": { + "title": "Your profile", + "description": "How you show up across the app. Your avatar and name appear in the header, sidebar, and anywhere your teammates see you.", + "changePhoto": "Change photo", + "uploadPhoto": "Upload photo", + "remove": "Remove", + "photoHint": "PNG, JPG, WebP, or GIF. Up to 2 MB.", + "displayName": "Display name", + "email": "Email", + "emailChangeHint": "Check the inbox for {oldEmail} and {newEmail} — both need to confirm before the change takes effect.", + "accountDetails": "Account details", + "role": "Role", + "joined": "Joined", + "userId": "User ID", + "loading": "Loading your profile…", + "saving": "Saving…", + "saveChanges": "Save changes", + "unsupportedImage": "Unsupported image type", + "unsupportedImageDesc": "Use PNG, JPG, WebP, or GIF.", + "imageTooLarge": "Image is too large", + "imageTooLargeDesc": "Maximum 2 MB.", + "nameRequired": "Display name is required", + "invalidEmail": "Enter a valid email address", + "uploadFailed": "Upload failed: {message}", + "saveFailed": "Save failed: {message}", + "profileSaved": "Profile saved", + "emailChangeFailed": "Email change failed: {message}", + "profileSavedEmailCheck": "Profile saved — check your email to confirm the address change", + + "passwordTitle": "Password", + "passwordDesc": "Use at least {min} characters. You will stay signed in on this device after changing it.", + "currentPassword": "Current password", + "newPassword": "New password", + "confirmPassword": "Confirm new password", + "updating": "Updating…", + "updatePassword": "Update password", + "cannotChangeNoEmail": "Cannot change password without a current email", + "passwordTooShort": "Password must be at least {min} characters", + "passwordMismatch": "New password and confirmation do not match", + "currentPasswordIncorrect": "Current password is incorrect", + "passwordUpdateFailed": "Password update failed: {message}", + "passwordUpdated": "Password updated", + + "sessionsTitle": "Active sessions", + "sessionsDesc": "Sign out of every device where you're logged in — including this one. Useful if you lost a laptop or shared your password.", + "signOutAll": "Sign out of all devices", + "signOutConfirmTitle": "Sign out everywhere?", + "signOutConfirmDesc": "Every device logged into this account will be signed out and will need to log in again. You will be redirected to the login page.", + "cancel": "Cancel", + "signingOut": "Signing out…", + "signOutEverywhere": "Sign out everywhere", + "signOutFailed": "Sign-out failed: {message}" + }, + "appearance": { + "title": "Appearance", + "description": "Set the mode and accent colour used across the app. Saved to this device — try it, it changes live.", + "mode": "Mode", + "useMode": "Use {mode} mode", + "active": "Active", + "accentColor": "Accent color", + "useTheme": "Use {name} theme" + }, + "security": { + "title": "Login & security", + "description": "Change your password and sign out of your devices. These keep your account safe." + }, + "apiKeys": { + "title": "API keys", + "description": "Keys authenticate the public REST API (/api/v1) so you can build your own automations. Send them as Authorization: Bearer <key>.", + "newApiKey": "New API key", + "noApiKeys": "No API keys yet.", + "createOneHint": "Click New API key to create one.", + "askAdminHint": "Ask an admin to create one.", + "revoked": "Revoked", + "expired": "Expired", + "noScopes": "No scopes", + "created": "Created {date}", + "lastUsed": "last used {date}", + "neverUsed": "never used", + "expires": "expires {date}", + "revoke": "Revoke", + "revokeSuccess": "Revoked \"{name}\"", + "revokeFailed": "Failed to revoke key", + "loadFailed": "Failed to load API keys", + "createError": "Failed to create key", + "nameRequired": "Give the key a name", + "nameLabel": "Name", + "namePlaceholder": "e.g. Zapier automation", + "scopesLabel": "Scopes", + "scopesHint": "A key with no scopes can still call GET /api/v1/me to verify it works.", + "cancel": "Cancel", + "creating": "Creating…", + "createKey": "Create key", + "copyTitle": "Copy your API key", + "copyDesc": "This is the only time the full key is shown. Store it somewhere safe — if you lose it, revoke it and create a new one.", + "apiKeyLabel": "API key", + "copy": "Copy", + "copySuccess": "API key copied", + "copyFailed": "Copy failed — select and copy manually", + "newKeyTitle": "New API key", + "newKeyDesc": "Name it after the integration that will use it, and grant only the scopes it needs.", + "done": "Done", + "networkError": "Could not reach the server" + }, + "deals": { + "title": "Deals & currency", + "description": "The currency used for new deals and for pipeline and dashboard totals.", + "defaultCurrency": "Default currency", + "defaultCurrencyDesc": "New deals default to this currency, and pipeline and dashboard totals are shown in it. Existing deals keep the currency they were saved with.", + "currencyLabel": "Currency", + "adminOnlyHint": "Only account admins can change the default currency.", + "save": "Save", + "saving": "Saving...", + "saveFailed": "Failed to save default currency", + "saveSuccess": "Default currency updated" + }, + "aiConfig": { + "title": "Agent setup", + "description": "Bring your own OpenAI or Anthropic key. wacrm calls the provider directly with your key — no per-seat AI fees, and your data stays yours. This powers AI-drafted replies in the inbox, the auto-reply bot, and the Playground.", + "adminOnlyConfig": "Only admins and owners can change the AI configuration.", + "loadFailed": "Failed to load AI configuration", + "providerAndKey": "Provider & key", + "encryptionNotice": "Your key is encrypted at rest (AES-256-GCM) and never shown again after saving.", + "provider": "Provider", + "model": "Model", + "apiKey": "API key", + "testKey": "Test key", + "testSuccess": "Key works — the provider responded.", + "testRejected": "The provider rejected the request.", + "testNetworkError": "Could not reach the provider.", + "embeddingsKey": "Embeddings key", + "optionalSemanticSearch": "(optional — enables semantic knowledge-base search)", + "embeddingsHint": "An OpenAI key used only to embed your knowledge base (text-embedding-3-small){sameKeyText}. Leave blank to use keyword search instead. Clear it to turn semantic search off.", + "sameKeyText": " — can be the same key as above", + "behaviour": "Behaviour", + "behaviourDesc": "Tell the assistant about your business — products, tone, what it may and may not promise. This context feeds both drafts and auto-replies.", + "businessContext": "Business context & instructions", + "promptPlaceholder": "e.g. We are Acme, a coffee-equipment store. Be warm and concise. Never quote prices or delivery dates — hand off to a human for those.", + "enableAssistant": "Enable AI assistant", + "enableAssistantDesc": "Master switch. Turns on the “Draft with AI” button in the inbox.", + "autoReply": "Auto-reply to inbound messages", + "autoReplyDesc": "The bot answers new inbound messages automatically (only when no flow handles them and no agent is assigned). Hands off to a human when it can’t help.", + "maxAutoReplies": "Max auto-replies per conversation", + "maxAutoRepliesDesc": "After this many bot replies in one thread, the bot goes quiet.", + "handoffTo": "Hand off to", + "handoffToDesc": "When the bot can’t help — or hits the reply cap — it pauses and routes the chat here, with a short note on what happened.", + "handoffQueue": "Unassigned queue (any agent can pick it up)", + "remove": "Remove", + "save": "Save", + "missingModel": "Enter a model name.", + "missingApiKey": "Enter your API key.", + "saveSuccess": "AI assistant saved.", + "saveFailed": "Failed to save.", + "removeSuccess": "AI configuration removed.", + "removeFailed": "Failed to remove." + }, + "aiKnowledge": { + "loadFailed": "Failed to load knowledge base", + "openFailed": "Failed to open document", + "titleContentRequired": "Title and content are required.", + "saveSuccessNew": "Document added.", + "saveSuccessUpdate": "Document updated.", + "saveFailed": "Failed to save.", + "removeSuccess": "Document removed.", + "removeFailed": "Failed to remove.", + "reindexSuccess": "Reindexed {count} document(s).", + "reindexFailed": "Reindex failed.", + "title": "Knowledge base", + "description": "Add FAQs, policies, or product details. The assistant retrieves the relevant pieces when drafting and auto-replying, so it can answer instead of handing off.{searchType}", + "semanticSearchOn": " Semantic search is on (embeddings key set).", + "keywordSearchOn": " Using keyword search — add an embeddings key above for semantic search.", + "noDocs": "No documents yet.", + "editDocTitle": "Title", + "editDocTitlePlaceholder": "e.g. Returns & refunds policy", + "editDocContent": "Content", + "editDocContentPlaceholder": "Paste the FAQ answer, policy text, or product details…", + "cancel": "Cancel", + "saveDoc": "Save document", + "addDoc": "Add document", + "reindex": "Reindex", + "reindexTooltip": "Re-embed all documents (e.g. after adding an embeddings key)", + "loading": "Loading…" + } } } diff --git a/src/app/(dashboard)/automations/[id]/edit/page.tsx b/src/app/(dashboard)/automations/[id]/edit/page.tsx index 7c40c79..eefda5d 100644 --- a/src/app/(dashboard)/automations/[id]/edit/page.tsx +++ b/src/app/(dashboard)/automations/[id]/edit/page.tsx @@ -3,6 +3,7 @@ import { use, useEffect, useState } from "react" import { useRouter } from "next/navigation" import { Loader2 } from "lucide-react" +import { useTranslations } from "next-intl" import { AutomationBuilder, @@ -19,6 +20,7 @@ export default function EditAutomationPage({ }) { const { id } = use(params) const router = useRouter() + const t = useTranslations("Automations.edit") const [initial, setInitial] = useState(null) const [error, setError] = useState(null) @@ -27,7 +29,7 @@ export default function EditAutomationPage({ async function load() { const res = await fetch(`/api/automations/${id}`) if (!res.ok) { - if (!cancelled) setError(`Failed to load (${res.status})`) + if (!cancelled) setError(t("loadError", { status: res.status })) return } const body = await res.json() @@ -56,7 +58,7 @@ export default function EditAutomationPage({ onClick={() => router.push("/automations")} className="text-sm text-primary hover:text-primary/80" > - Back to Automations + {t("back")} ) diff --git a/src/app/(dashboard)/automations/[id]/logs/page.tsx b/src/app/(dashboard)/automations/[id]/logs/page.tsx index bf13be2..6f5c5fb 100644 --- a/src/app/(dashboard)/automations/[id]/logs/page.tsx +++ b/src/app/(dashboard)/automations/[id]/logs/page.tsx @@ -10,6 +10,7 @@ import { ChevronDown, ChevronRight, } from "lucide-react" +import { useTranslations } from "next-intl" import { createClient } from "@/lib/supabase/client" import type { @@ -28,6 +29,7 @@ export default function AutomationLogsPage({ }) { const { id } = use(params) const router = useRouter() + const t = useTranslations("Automations.logs") const [automation, setAutomation] = useState(null) const [logs, setLogs] = useState(null) @@ -56,7 +58,7 @@ export default function AutomationLogsPage({ setAutomation(autRes.data as Automation | null) setLogs((logRes.data ?? []) as AutomationLog[]) } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load logs") + setError(err instanceof Error ? err.message : t("loadError")) } } load() @@ -67,7 +69,7 @@ export default function AutomationLogsPage({

{error}

) @@ -88,21 +90,21 @@ export default function AutomationLogsPage({ type="button" onClick={() => router.push("/automations")} className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" - aria-label="Back" + aria-label={t("backAria")} >

{automation.name}

-

Execution logs

+

{t("title")}

{logs.length === 0 ? (
-

No executions yet

+

{t("emptyTitle")}

- Trigger this automation to see runs here. + {t("emptyDesc")}

) : ( @@ -124,14 +126,14 @@ export default function AutomationLogsPage({ ) : ( )} - +
- {log.contact?.name ?? log.contact?.phone ?? "Unknown contact"} + {log.contact?.name ?? log.contact?.phone ?? t("unknownContact")}
- {log.trigger_event} · {log.steps_executed?.length ?? 0} step - {log.steps_executed?.length === 1 ? "" : "s"} + {log.trigger_event} · {log.steps_executed?.length ?? 0}{" "} + {log.steps_executed?.length === 1 ? t("step", { count: 1 }).replace("1 ", "") : t("stepPlural", { count: log.steps_executed?.length ?? 0 }).replace(/^[0-9]+ /, "")}
@@ -150,7 +152,7 @@ export default function AutomationLogsPage({ ))} {(log.steps_executed ?? []).length === 0 && ( -
  • No steps recorded.
  • +
  • {t("noSteps")}
  • )}
    @@ -164,7 +166,7 @@ export default function AutomationLogsPage({ ) } -function StatusBadge({ status }: { status: AutomationLog["status"] }) { +function StatusBadge({ status, t }: { status: AutomationLog["status"], t: ReturnType }) { const classes = status === "success" ? "border-primary/30 bg-primary/10 text-primary" @@ -178,7 +180,7 @@ function StatusBadge({ status }: { status: AutomationLog["status"] }) { classes, )} > - {status} + {t(`status.${status}`)} ) } diff --git a/src/app/(dashboard)/automations/page.tsx b/src/app/(dashboard)/automations/page.tsx index 28760ae..4a87a88 100644 --- a/src/app/(dashboard)/automations/page.tsx +++ b/src/app/(dashboard)/automations/page.tsx @@ -20,6 +20,7 @@ import { import { createClient } from "@/lib/supabase/client" import { useCan } from "@/hooks/use-can" +import { useTranslations } from "next-intl" import type { Automation } from "@/types" import { Button } from "@/components/ui/button" import { GatedButton } from "@/components/ui/gated-button" @@ -60,6 +61,7 @@ const TEMPLATE_ICON: Record = { export default function AutomationsPage() { const router = useRouter() const canCreate = useCan("send-messages") + const t = useTranslations("Automations.list") const [automations, setAutomations] = useState(null) const [error, setError] = useState(null) const [pendingDelete, setPendingDelete] = useState(null) @@ -99,20 +101,20 @@ export default function AutomationsPage() { prev?.map((x) => (x.id === a.id ? { ...x, is_active: !next } : x)) ?? prev, ) const body = await res.json().catch(() => ({})) - toast.error(body?.error ?? "Failed to update") + toast.error(body?.error ?? t("toasts.updateError")) return } - toast.success(next ? "Automation activated" : "Automation paused") + toast.success(next ? t("toasts.activated") : t("toasts.paused")) } async function duplicate(a: Automation) { const res = await fetch(`/api/automations/${a.id}/duplicate`, { method: "POST" }) if (!res.ok) { const body = await res.json().catch(() => ({})) - toast.error(body?.error ?? "Failed to duplicate") + toast.error(body?.error ?? t("toasts.duplicateError")) return } - toast.success("Automation duplicated") + toast.success(t("toasts.duplicated")) load() } @@ -123,10 +125,10 @@ export default function AutomationsPage() { setDeleting(false) if (!res.ok) { const body = await res.json().catch(() => ({})) - toast.error(body?.error ?? "Failed to delete") + toast.error(body?.error ?? t("toasts.deleteError")) return } - toast.success("Automation deleted") + toast.success(t("toasts.deleted")) setPendingDelete(null) load() } @@ -140,7 +142,7 @@ export default function AutomationsPage() {

    {error}

    ) @@ -160,9 +162,9 @@ export default function AutomationsPage() {
    -

    Automations

    +

    {t("title")}

    - Build workflows that react to WhatsApp® events automatically. + {t("subtitle")}

    - Create Automation + {t("create")}
    {showTemplates && (
    -

    Quick-start templates

    +

    {t("templatesTitle")}

    {TEMPLATE_ORDER.map((slug) => { const t = AUTOMATION_TEMPLATES[slug] @@ -206,9 +208,9 @@ export default function AutomationsPage() {
    -

    No automations yet

    +

    {t("emptyTitle")}

    - Pick a template above or create one from scratch. + {t("emptyDesc")}

    ) : ( @@ -222,6 +224,7 @@ export default function AutomationsPage() { onDuplicate={() => duplicate(a)} onLogs={() => router.push(`/automations/${a.id}/logs`)} onDelete={() => setPendingDelete(a)} + t={t} /> ))} @@ -230,11 +233,9 @@ export default function AutomationsPage() { !v && setPendingDelete(null)}> - Delete automation + {t("deleteTitle")} - This permanently removes{" "} - {pendingDelete?.name} and its execution - history. This cannot be undone. + {t("deleteDesc", { name: pendingDelete?.name ?? "" })} @@ -243,7 +244,7 @@ export default function AutomationsPage() { onClick={() => setPendingDelete(null)} disabled={deleting} > - Cancel + {t("cancel")} @@ -267,6 +268,7 @@ function AutomationCard({ onDuplicate, onLogs, onDelete, + t, }: { automation: Automation onToggle: (next: boolean) => void @@ -274,6 +276,7 @@ function AutomationCard({ onDuplicate: () => void onLogs: () => void onDelete: () => void + t: ReturnType }) { const meta = triggerMeta(automation.trigger_type) return ( @@ -315,10 +318,12 @@ function AutomationCard({ {meta.label} - {automation.execution_count} run{automation.execution_count === 1 ? "" : "s"} + {automation.execution_count === 1 + ? t("runs", { count: automation.execution_count }) + : t("runsPlural", { count: automation.execution_count })} · - last {formatRelative(automation.last_executed_at)} + {t("lastRun", { time: formatRelative(automation.last_executed_at) })}
    @@ -326,7 +331,7 @@ function AutomationCard({ onToggle(!!v)} - aria-label={automation.is_active ? "Deactivate" : "Activate"} + aria-label={automation.is_active ? t("deactivate") : t("activate")} /> @@ -339,20 +344,20 @@ function AutomationCard({ - Edit + {t("edit")} - Duplicate + {t("duplicate")} - View Logs + {t("viewLogs")} - Delete + {t("delete")} diff --git a/src/app/(dashboard)/broadcasts/[id]/page.tsx b/src/app/(dashboard)/broadcasts/[id]/page.tsx index 7449d51..4dd4241 100644 --- a/src/app/(dashboard)/broadcasts/[id]/page.tsx +++ b/src/app/(dashboard)/broadcasts/[id]/page.tsx @@ -38,6 +38,7 @@ import { getBroadcastStatus, getRecipientStatus, } from '@/lib/broadcast-status'; +import { useTranslations } from 'next-intl'; interface StatCardProps { label: string; @@ -144,6 +145,8 @@ function downloadBlob(filename: string, content: string) { export default function BroadcastDetailPage() { const params = useParams(); const router = useRouter(); + const t = useTranslations('Broadcasts.detail'); + const tStatus = useTranslations('Broadcasts.status'); const broadcastId = params.id as string; const [broadcast, setBroadcast] = useState(null); @@ -179,7 +182,7 @@ export default function BroadcastDetailPage() { if (recsError) throw recsError; setRecipients(recs ?? []); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load broadcast'); + setError(err instanceof Error ? err.message : t('notFound')); } finally { setLoading(false); } @@ -199,14 +202,13 @@ export default function BroadcastDetailPage() { function handleExport() { if (!broadcast) return; const header = [ - 'Contact', - 'Phone', - 'Status', - 'Sent At', - 'Delivered At', - 'Read At', - 'Replied At', - 'Error', + t('table.contact'), + t('table.phone'), + t('table.status'), + t('table.sent'), + t('table.delivered'), + t('table.read'), + t('table.error'), ]; const rows = recipients.map((r) => [ r.contact?.name ?? '', @@ -215,7 +217,6 @@ export default function BroadcastDetailPage() { r.sent_at ?? '', r.delivered_at ?? '', r.read_at ?? '', - r.replied_at ?? '', r.error_message ?? '', ]); const csv = toCsv([header, ...rows]); @@ -236,10 +237,10 @@ export default function BroadcastDetailPage() { .eq('id', broadcastId); setDeleting(false); if (delErr) { - toast.error(`Failed to delete: ${delErr.message}`); + toast.error(t('toastFailedDelete', { error: delErr.message })); return; } - toast.success('Broadcast deleted'); + toast.success(t('toastDeleted')); router.push('/broadcasts'); } @@ -254,9 +255,9 @@ export default function BroadcastDetailPage() { if (error || !broadcast) { return (
    -

    {error ?? 'Broadcast not found'}

    +

    {error ?? t('notFound')}

    ); @@ -265,10 +266,10 @@ export default function BroadcastDetailPage() { const status = getBroadcastStatus(broadcast.status); const funnelSteps: FunnelStep[] = [ - { label: 'Sent', value: broadcast.sent_count, color: 'bg-primary' }, - { label: 'Delivered', value: broadcast.delivered_count, color: 'bg-teal-500' }, - { label: 'Read', value: broadcast.read_count, color: 'bg-blue-500' }, - { label: 'Replied', value: broadcast.replied_count, color: 'bg-indigo-500' }, + { label: t('stats.sent'), value: broadcast.sent_count, color: 'bg-primary' }, + { label: t('stats.delivered'), value: broadcast.delivered_count, color: 'bg-teal-500' }, + { label: t('stats.read'), value: broadcast.read_count, color: 'bg-blue-500' }, + { label: t('stats.replied'), value: broadcast.replied_count, color: 'bg-indigo-500' }, ]; return ( @@ -290,14 +291,14 @@ export default function BroadcastDetailPage() { - {status.label} + {tStatus(status.label)}
    - Template: {broadcast.template_name} + {t('template', { name: broadcast.template_name })} - - Created {new Date(broadcast.created_at).toLocaleDateString()} + {t('createdAt', { date: new Date(broadcast.created_at).toLocaleDateString() })}
    @@ -309,7 +310,7 @@ export default function BroadcastDetailPage() { funnel inconsistent. */} {confirmDelete ? (
    - Delete this broadcast? + {t('deletePrompt')}
    ) : ( @@ -336,13 +337,13 @@ export default function BroadcastDetailPage() { onClick={() => setConfirmDelete(true)} title={ broadcast.status === 'sending' - ? 'Cannot delete while a broadcast is actively sending' - : 'Delete this broadcast' + ? t('cannotDeleteSending') + : t('deleteHover') } className="border-red-500/30 bg-transparent text-red-400 hover:bg-red-500/10 disabled:opacity-40" > - Delete + {t('delete')} )} @@ -350,42 +351,42 @@ export default function BroadcastDetailPage() { {/* Stats — 6 cards: Total / Sent / Delivered / Read / Replied / Failed */}
    } color="bg-muted text-muted-foreground" /> } color="bg-primary/10 text-primary" /> } color="bg-teal-500/10 text-teal-400" /> } color="bg-blue-500/10 text-blue-400" /> } color="bg-indigo-500/10 text-indigo-400" /> } @@ -399,8 +400,9 @@ export default function BroadcastDetailPage() {

    - Recipients ({filteredRecipients.length} - {statusFilter !== 'all' ? ` of ${recipients.length}` : ''}) + {statusFilter !== 'all' + ? t('recipientsHeader', { filtered: filteredRecipients.length, total: recipients.length }) + : t('recipientsHeaderAll', { total: recipients.length })}

    @@ -415,8 +417,8 @@ export default function BroadcastDetailPage() { > {statusFilter === 'all' - ? 'All statuses' - : getRecipientStatus(statusFilter).label} + ? t('allStatuses') + : tStatus(getRecipientStatus(statusFilter).label)} @@ -426,7 +428,7 @@ export default function BroadcastDetailPage() { statusFilter === 'all' ? 'text-primary' : 'text-popover-foreground' } > - All statuses + {t('allStatuses')} {RECIPIENT_STATUSES.map((s) => ( - {getRecipientStatus(s).label} + {tStatus(getRecipientStatus(s).label)} ))} @@ -452,7 +454,7 @@ export default function BroadcastDetailPage() { className="border-border text-muted-foreground hover:bg-muted" > - Export CSV + {t('exportCsv')}
    @@ -461,8 +463,8 @@ export default function BroadcastDetailPage() {

    {recipients.length === 0 - ? 'No recipients found.' - : 'No recipients match this filter.'} + ? t('noRecipients') + : t('noRecipientsFilter')}

    ) : ( @@ -470,13 +472,13 @@ export default function BroadcastDetailPage() { - Contact - Phone - Status - Sent - Delivered - Read - Error + {t('table.contact')} + {t('table.phone')} + {t('table.status')} + {t('table.sent')} + {t('table.delivered')} + {t('table.read')} + {t('table.error')} @@ -494,7 +496,7 @@ export default function BroadcastDetailPage() { - {rStatus.label} + {tStatus(rStatus.label)} diff --git a/src/app/(dashboard)/broadcasts/new/page.tsx b/src/app/(dashboard)/broadcasts/new/page.tsx index 2eba6a8..50d1af2 100644 --- a/src/app/(dashboard)/broadcasts/new/page.tsx +++ b/src/app/(dashboard)/broadcasts/new/page.tsx @@ -12,16 +12,18 @@ import { Step3Personalize } from '@/components/broadcasts/step3-personalize'; import { Step4ScheduleSend } from '@/components/broadcasts/step4-schedule-send'; import { useBroadcastSending } from '@/hooks/use-broadcast-sending'; import { Check } from 'lucide-react'; +import { useTranslations } from 'next-intl'; const steps = [ - { label: 'Template', key: 'template' }, - { label: 'Audience', key: 'audience' }, - { label: 'Personalize', key: 'personalize' }, - { label: 'Send', key: 'send' }, + { label: 'template', key: 'template' }, + { label: 'audience', key: 'audience' }, + { label: 'personalize', key: 'personalize' }, + { label: 'send', key: 'send' }, ] as const; export default function NewBroadcastPage() { const router = useRouter(); + const t = useTranslations('Broadcasts.new'); const { accountId } = useAuth(); const { createAndSendBroadcast, isProcessing, progress } = useBroadcastSending(); @@ -82,7 +84,7 @@ export default function NewBroadcastPage() { */ async function handleSaveDraft() { if (!template || !name.trim()) { - toast.error('Give the broadcast a name before saving a draft.'); + toast.error(t('toastGiveName')); return; } const supabase = createClient(); @@ -91,11 +93,11 @@ export default function NewBroadcastPage() { } = await supabase.auth.getSession(); const user = session?.user; if (!user) { - toast.error('Not signed in.'); + toast.error(t('toastNotSignedIn')); return; } if (!accountId) { - toast.error('Your profile is not linked to an account.'); + toast.error(t('toastNotLinked')); return; } @@ -120,10 +122,10 @@ export default function NewBroadcastPage() { }); if (error) { - toast.error(`Failed to save draft: ${error.message}`); + toast.error(t('toastFailedDraft', { error: error.message })); return; } - toast.success('Draft saved'); + toast.success(t('toastDraftSaved')); router.push('/broadcasts'); } @@ -131,9 +133,9 @@ export default function NewBroadcastPage() {
    {/* Header */}
    -

    New Broadcast

    +

    {t('title')}

    - Create and send a broadcast message to your contacts. + {t('subtitle')}

    @@ -162,7 +164,7 @@ export default function NewBroadcastPage() { isActive ? 'text-foreground' : isCompleted ? 'text-primary' : 'text-muted-foreground' }`} > - {step.label} + {t(`steps.${step.label}`)}
    {index < steps.length - 1 && ( diff --git a/src/app/(dashboard)/broadcasts/page.tsx b/src/app/(dashboard)/broadcasts/page.tsx index 986000e..ca89c81 100644 --- a/src/app/(dashboard)/broadcasts/page.tsx +++ b/src/app/(dashboard)/broadcasts/page.tsx @@ -17,6 +17,7 @@ import { Radio, Plus, Loader2 } from 'lucide-react'; import { useCan } from '@/hooks/use-can'; import { GatedButton } from '@/components/ui/gated-button'; import { getBroadcastStatus } from '@/lib/broadcast-status'; +import { useTranslations } from 'next-intl'; /** * Poll cadence while any broadcast is sending. Kept modest so we don't @@ -58,6 +59,8 @@ function RateCell({ export default function BroadcastsPage() { const router = useRouter(); + const t = useTranslations('Broadcasts.page'); + const tStatus = useTranslations('Broadcasts.status'); const canCreate = useCan('send-messages'); const [broadcasts, setBroadcasts] = useState([]); const [loading, setLoading] = useState(true); @@ -77,7 +80,7 @@ export default function BroadcastsPage() { if (fetchError) throw fetchError; setBroadcasts(data ?? []); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load broadcasts'); + setError(err instanceof Error ? err.message : t('errorLoad')); } finally { setLoading(false); } @@ -141,7 +144,7 @@ export default function BroadcastsPage() {

    {error}

    ); @@ -179,9 +182,9 @@ export default function BroadcastsPage() {
    -

    Broadcasts

    +

    {t('title')}

    - Send bulk messages to your contacts using approved templates. + {t('subtitle')}

    - New Broadcast + {t('newBroadcast')}
    {broadcasts.length === 0 ? (
    -

    No broadcasts yet

    +

    {t('noBroadcastsYet')}

    - Create your first broadcast to reach your contacts at scale. + {t('createFirst')}

    - New Broadcast + {t('newBroadcast')}
    ) : ( @@ -217,15 +220,15 @@ export default function BroadcastsPage() {
    - Name - Template + {t('table.name')} + {t('table.template')} - Recipients + {t('table.recipients')} - Delivery - Read - Status - Date + {t('table.delivery')} + {t('table.read')} + {t('table.status')} + {t('table.date')} @@ -270,7 +273,7 @@ export default function BroadcastsPage() { )} - {status.label} + {tStatus(status.label)} diff --git a/src/app/(dashboard)/contacts/page.tsx b/src/app/(dashboard)/contacts/page.tsx index 5d0bc9c..0facba6 100644 --- a/src/app/(dashboard)/contacts/page.tsx +++ b/src/app/(dashboard)/contacts/page.tsx @@ -14,6 +14,7 @@ import { TableHeader, TableRow, } from '@/components/ui/table'; +import { Checkbox } from '@/components/ui/checkbox'; import { DropdownMenu, DropdownMenuTrigger, @@ -55,7 +56,7 @@ import { ImportModal } from '@/components/contacts/import-modal'; import { CustomFieldsManager } from '@/components/contacts/custom-fields-manager'; import { useCan } from '@/hooks/use-can'; import { GatedButton } from '@/components/ui/gated-button'; -import { Checkbox } from '@/components/ui/checkbox'; +import { useTranslations } from 'next-intl'; const PAGE_SIZE = 25; @@ -64,6 +65,7 @@ interface ContactWithTags extends Contact { } export default function ContactsPage() { + const t = useTranslations('Contacts.page'); const supabase = createClient(); const canEdit = useCan('send-messages'); const canEditSettings = useCan('edit-settings'); @@ -144,7 +146,7 @@ export default function ContactsPage() { }); if (seq !== fetchSeq.current) return; // superseded by a newer fetch if (error) { - toast.error('Failed to load contacts'); + toast.error(t('toastFailedLoad')); setLoading(false); return; } @@ -166,7 +168,7 @@ export default function ContactsPage() { const { data, count: exactCount, error } = await query; if (seq !== fetchSeq.current) return; // superseded by a newer fetch if (error) { - toast.error('Failed to load contacts'); + toast.error(t('toastFailedLoad')); setLoading(false); return; } @@ -205,7 +207,7 @@ export default function ContactsPage() { setContacts(enriched); setLoading(false); - }, [supabase, page, search, selectedTagIds, tagsMap]); + }, [supabase, page, search, selectedTagIds, tagsMap, t]); // Load-once-on-mount-ish data fetches. Each setter inside runs // inside an async promise completion (Supabase await), not @@ -257,9 +259,9 @@ export default function ContactsPage() { .eq('id', deleteTarget.id); if (error) { - toast.error('Failed to delete contact'); + toast.error(t('toastFailedDelete')); } else { - toast.success('Contact deleted'); + toast.success(t('toastDeleted')); fetchContacts(); } @@ -301,9 +303,9 @@ export default function ContactsPage() { const { error } = await supabase.from('contacts').delete().in('id', ids); if (error) { - toast.error('Failed to delete contacts'); + toast.error(t('toastBulkFailedDelete')); } else { - toast.success(`${ids.length} contact${ids.length === 1 ? '' : 's'} deleted`); + toast.success(t('toastBulkDeleted', { count: ids.length })); setSelected(new Set()); fetchContacts(); } @@ -342,9 +344,9 @@ export default function ContactsPage() { {/* Header */}
    -

    Contacts

    +

    {t('title')}

    - Manage your contact list. {totalCount > 0 && `${totalCount} total contacts.`} + {totalCount > 0 ? t('subtitle', { count: totalCount }) : t('subtitleZero')}

    @@ -355,7 +357,7 @@ export default function ContactsPage() { className="border-border text-muted-foreground hover:bg-muted" > - Custom fields + {t('customFieldsBtn')} )} - Import + {t('importBtn')} - Add Contact + {t('addContactBtn')}
    @@ -393,7 +395,7 @@ export default function ContactsPage() { // set shrinks/grows, page N may no longer be valid. setPage(0); }} - placeholder="Search by name, phone, or email..." + placeholder={t('searchPlaceholder')} className="pl-8 bg-card border-border text-foreground placeholder:text-muted-foreground" /> @@ -408,7 +410,7 @@ export default function ContactsPage() { } > - Filter by tags + {t('filterByTags')} {selectedTagIds.length > 0 && ( {selectedTagIds.length} @@ -418,20 +420,20 @@ export default function ContactsPage() {
    - Filter by tags + {t('filterByTags')} {selectedTagIds.length > 0 && ( )}
    {allTags.length === 0 ? (

    - No tags yet. + {t('noTagsYet')}

    ) : (
    @@ -490,7 +492,7 @@ export default function ContactsPage() { onClick={clearTagFilters} className="text-xs text-muted-foreground hover:text-foreground px-1" > - Clear all + {t('clearAll')}
    )} @@ -500,8 +502,7 @@ export default function ContactsPage() { {selected.size > 0 && (

    - {selected.size}{' '} - {selected.size === 1 ? 'contact' : 'contacts'} selected + {t('selectedCount', { count: selected.size })}

    setBulkDeleteOpen(true)} > - Delete selected + {t('deleteSelected')}
    @@ -540,12 +541,12 @@ export default function ContactsPage() { aria-label="Select all contacts on this page" /> - Name - Phone - Email - Company - Tags - Created + {t('tableColumns.name')} + {t('tableColumns.phone')} + {t('tableColumns.email')} + {t('tableColumns.company')} + {t('tableColumns.tags')} + {t('tableColumns.createdAt')} @@ -555,7 +556,7 @@ export default function ContactsPage() {
    -

    Loading contacts...

    +

    {t('loading')}

    @@ -566,8 +567,8 @@ export default function ContactsPage() {

    {hasActiveFilters - ? 'No contacts match your filters.' - : 'No contacts yet.'} + ? t('noContactsMatch') + : t('noContactsYet')}

    {!hasActiveFilters && ( - Add your first contact + {t('addFirstContact')} )} @@ -600,7 +601,7 @@ export default function ContactsPage() { />
    - {contact.name || Unnamed} + {contact.name || {t('unnamed')}} {contact.phone} @@ -669,7 +670,7 @@ export default function ContactsPage() { className="text-popover-foreground focus:bg-muted focus:text-foreground" > - Edit + {t('editAction')} - Delete + {t('deleteAction')} @@ -696,8 +697,11 @@ export default function ContactsPage() { {totalPages > 1 && (

    - Showing {page * PAGE_SIZE + 1}-{Math.min((page + 1) * PAGE_SIZE, totalCount)} of{' '} - {totalCount} + {t('showingPagination', { + start: page * PAGE_SIZE + 1, + end: Math.min((page + 1) * PAGE_SIZE, totalCount), + total: totalCount + })}

    - Page {page + 1} of {totalPages} + {t('pageCount', { page: page + 1, total: totalPages })} @@ -802,14 +802,10 @@ export default function ContactsPage() { - Delete {selected.size} {selected.size === 1 ? 'Contact' : 'Contacts'} + {t('deleteBulkTitle')} - Are you sure you want to delete{' '} - - {selected.size} {selected.size === 1 ? 'contact' : 'contacts'} - - ? This action cannot be undone. + {t('deleteBulkDesc', { count: selected.size })} @@ -818,7 +814,7 @@ export default function ContactsPage() { onClick={() => setBulkDeleteOpen(false)} className="border-border text-muted-foreground hover:bg-muted" > - Cancel + {t('cancel')} diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx index fd2227e..5b8caa4 100644 --- a/src/app/(dashboard)/dashboard/page.tsx +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -34,9 +34,12 @@ import { PipelineDonut } from '@/components/dashboard/pipeline-donut' import { ResponseTimeChart } from '@/components/dashboard/response-time-chart' import { ActivityFeed } from '@/components/dashboard/activity-feed' +import { useTranslations } from 'next-intl' + type RangeDays = 7 | 30 | 90 export default function DashboardPage() { + const t = useTranslations('Dashboard.page') const { defaultCurrency } = useAuth() const [metrics, setMetrics] = useState(null) const [metricsLoading, setMetricsLoading] = useState(true) @@ -122,9 +125,9 @@ export default function DashboardPage() {
    {/* Header */}
    -

    Dashboard

    +

    {t('title')}

    - Live analytics across conversations, contacts, deals, broadcasts, and automations. + {t('description')}

    @@ -135,16 +138,20 @@ export default function DashboardPage() { ) : ( <> @@ -218,8 +227,8 @@ export default function DashboardPage() { // ------------------------------------------------------------ -function deltaLabel(delta: number, suffix: string): string { - if (delta === 0) return `No change ${suffix}` +function deltaLabel(delta: number, suffix: string, noChangeLabel: string): string { + if (delta === 0) return noChangeLabel const sign = delta > 0 ? '+' : '' return `${sign}${delta.toLocaleString()} ${suffix}` } diff --git a/src/app/(dashboard)/flows/[id]/page.tsx b/src/app/(dashboard)/flows/[id]/page.tsx index acf27fe..917fc82 100644 --- a/src/app/(dashboard)/flows/[id]/page.tsx +++ b/src/app/(dashboard)/flows/[id]/page.tsx @@ -5,6 +5,8 @@ import { useParams, useRouter } from "next/navigation"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { useTranslations } from "next-intl"; + import { FlowEditorShell } from "@/components/flows/flow-editor-shell"; import type { FlowRow, FlowNodeRow } from "@/lib/flows/types"; @@ -23,6 +25,7 @@ import type { FlowRow, FlowNodeRow } from "@/lib/flows/types"; export default function FlowEditorPage() { const router = useRouter(); const params = useParams<{ id: string }>(); + const t = useTranslations("Flows.edit"); const [flow, setFlow] = useState(null); const [nodes, setNodes] = useState([]); @@ -51,7 +54,7 @@ export default function FlowEditorPage() { } catch (err) { if (!cancelled) { console.error(err); - toast.error("Couldn't load flow."); + toast.error(t("loadError")); } } finally { if (!cancelled) setLoading(false); @@ -72,13 +75,13 @@ export default function FlowEditorPage() { if (notFound || !flow) { return (
    -

    Flow not found.

    +

    {t("notFound")}

    ); diff --git a/src/app/(dashboard)/flows/[id]/runs/page.tsx b/src/app/(dashboard)/flows/[id]/runs/page.tsx index 3c43621..87a0d48 100644 --- a/src/app/(dashboard)/flows/[id]/runs/page.tsx +++ b/src/app/(dashboard)/flows/[id]/runs/page.tsx @@ -17,6 +17,8 @@ import { import { toast } from "sonner"; import { format, formatDistanceToNow } from "date-fns"; +import { useTranslations } from "next-intl"; + import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; @@ -96,6 +98,8 @@ const STATUS_META: Record< export default function FlowRunsPage() { const router = useRouter(); const params = useParams<{ id: string }>(); + const t = useTranslations("Flows.logs"); + const tEdit = useTranslations("Flows.edit"); const [flow, setFlow] = useState<{ id: string; name: string } | null>(null); const [runs, setRuns] = useState([]); @@ -128,7 +132,7 @@ export default function FlowRunsPage() { } catch (err) { if (!cancelled) { console.error(err); - toast.error("Couldn't load runs."); + toast.error(t("loadError")); } } finally { if (!cancelled) setLoading(false); @@ -158,13 +162,13 @@ export default function FlowRunsPage() { if (notFound || !flow) { return (
    -

    Flow not found.

    +

    {tEdit("notFound")}

    ); @@ -180,16 +184,14 @@ export default function FlowRunsPage() { {flow.name} -

    Runs

    +

    {t("title")}

    - The 50 most recent times this flow ran. Expand a row to see the engine's - per-step log. + {t("description")}

    {runs.length === 0 ? (
    - No runs yet. Trigger the flow from a personal WhatsApp number to see - it appear here. + {t("emptyState")}
    ) : (
    @@ -200,6 +202,7 @@ export default function FlowRunsPage() { events={events.filter((e) => e.flow_run_id === run.id)} expanded={expanded.has(run.id)} onToggle={() => toggle(run.id)} + t={t} /> ))}
    @@ -213,16 +216,18 @@ function RunCard({ events, expanded, onToggle, + t, }: { run: RunRow; events: EventRow[]; expanded: boolean; onToggle: () => void; + t: ReturnType; }) { const meta = STATUS_META[run.status]; const StatusIcon = meta.icon; const contactLabel = - run.contact?.name?.trim() || run.contact?.phone || "Unknown contact"; + run.contact?.name?.trim() || run.contact?.phone || t("unknownContact"); const duration = run.ended_at ? formatDistanceToNow(new Date(run.ended_at), { addSuffix: false, @@ -247,20 +252,32 @@ function RunCard({ - {meta.label} + {t( + run.status === "active" + ? "statusActive" + : run.status === "completed" + ? "statusCompleted" + : run.status === "handed_off" + ? "statusHandedOff" + : run.status === "timed_out" + ? "statusTimedOut" + : run.status === "paused_by_agent" + ? "statusPaused" + : "statusFailed" + )} {run.status === "active" && run.current_node_key && ( - at {run.current_node_key} + {t("atNode", { node: run.current_node_key })} )}
    - Started {format(new Date(run.started_at), "PP p")} + {t("started", { time: format(new Date(run.started_at), "PP p") })} {run.reprompt_count > 0 && ( - · {run.reprompt_count} re-prompts + · {t("reprompts", { count: run.reprompt_count })} )} - {duration && · ran for {duration}} + {duration && · {t("ranFor", { duration })}}
    @@ -269,7 +286,7 @@ function RunCard({ {Object.keys(run.vars).length > 0 && (
    - Captured vars ({Object.keys(run.vars).length}) + {t("capturedVars", { count: Object.keys(run.vars).length })}
                     {JSON.stringify(run.vars, null, 2)}
    @@ -279,7 +296,7 @@ function RunCard({
               
    {events.length === 0 ? (

    - No events recorded for this run. + {t("noEvents")}

    ) : ( events.map((ev, ix) => ) diff --git a/src/app/(dashboard)/flows/page.tsx b/src/app/(dashboard)/flows/page.tsx index 418acb2..0753f5a 100644 --- a/src/app/(dashboard)/flows/page.tsx +++ b/src/app/(dashboard)/flows/page.tsx @@ -18,6 +18,7 @@ import { FileText, } from "lucide-react"; +import { useTranslations } from "next-intl"; import { useCan } from "@/hooks/use-can"; import { Button } from "@/components/ui/button"; import { GatedButton } from "@/components/ui/gated-button"; @@ -54,11 +55,11 @@ interface FlowRow { updated_at: string; } -const STATUS_LABELS: Record = { - draft: "Draft", - active: "Active", - archived: "Archived", -}; +const STATUS_LABELS = (t: ReturnType): Record => ({ + draft: t("statusDraft"), + active: t("statusActive"), + archived: t("statusArchived"), +}); const STATUS_COLORS: Record = { draft: "border-border bg-muted text-muted-foreground", @@ -84,6 +85,7 @@ const TEMPLATE_ICONS = { export default function FlowsPage() { const router = useRouter(); const canCreate = useCan("send-messages"); + const t = useTranslations("Flows.list"); const [flows, setFlows] = useState([]); const [loading, setLoading] = useState(true); const [createOpen, setCreateOpen] = useState(false); @@ -115,7 +117,7 @@ export default function FlowsPage() { } catch (err) { if (!cancelled) { console.error(err); - toast.error("Couldn't load flows."); + toast.error(t("loadError")); } } finally { if (!cancelled) setLoading(false); @@ -146,7 +148,7 @@ export default function FlowsPage() { router.push(`/flows/${json.flow.id}`); } catch (err) { console.error(err); - toast.error("Couldn't create flow."); + toast.error(t("createError")); } finally { setCreating(false); } @@ -168,7 +170,7 @@ export default function FlowsPage() { setCreateOpen(false); router.push(`/flows/${json.flow.id}`); } catch (err) { - const msg = err instanceof Error ? err.message : "Clone failed"; + const msg = err instanceof Error ? err.message : t("cloneError"); toast.error(msg); } finally { setCreating(false); @@ -176,18 +178,16 @@ export default function FlowsPage() { } async function handleDelete(flow: FlowRow) { - const yes = window.confirm( - `Delete "${flow.name}"? Any active runs will end immediately.`, - ); + const yes = window.confirm(t("deleteConfirm", { name: flow.name })); if (!yes) return; try { const res = await fetch(`/api/flows/${flow.id}`, { method: "DELETE" }); if (!res.ok) throw new Error(`Delete failed: ${res.status}`); setFlows((prev) => prev.filter((f) => f.id !== flow.id)); - toast.success("Flow deleted."); + toast.success(t("deleteSuccess")); } catch (err) { console.error(err); - toast.error("Couldn't delete flow."); + toast.error(t("deleteError")); } } @@ -204,14 +204,13 @@ export default function FlowsPage() {
    -

    Flows

    +

    {t("title")}

    - Beta + {t("beta")}

    - Build branching, button-driven WhatsApp conversations. Useful for - menus, FAQs, and triage before a human steps in. + {t("description")}

    setCreateOpen(true)} > - New flow + {t("newFlow")}
    @@ -228,6 +227,7 @@ export default function FlowsPage() { setCreateOpen(true)} canCreate={canCreate} + t={t} /> ) : (
    @@ -237,6 +237,7 @@ export default function FlowsPage() { flow={flow} onEdit={() => router.push(`/flows/${flow.id}`)} onDelete={() => handleDelete(flow)} + t={t} /> ))}
    @@ -249,37 +250,37 @@ export default function FlowsPage() { sm-scoped 384px wins at every real desktop breakpoint. */} - Create a new flow + {t("createTitle")} - Start from a template or build from scratch. + {t("createDesc")} {templates.length > 0 && (

    - Start from a template + {t("startTemplate")}

    - {templates.map((t) => { - const Icon = TEMPLATE_ICONS[t.icon] ?? FileText; + {templates.map((template) => { + const Icon = TEMPLATE_ICONS[template.icon] ?? FileText; return ( ); @@ -290,12 +291,12 @@ export default function FlowsPage() {

    - Or start blank + {t("startBlank")}

    setNewName(e.target.value)} - placeholder="e.g. Welcome menu" + placeholder={t("placeholderName")} className="bg-muted" onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); @@ -309,11 +310,11 @@ export default function FlowsPage() { onClick={() => setCreateOpen(false)} disabled={creating} > - Cancel + {t("cancel")} @@ -325,9 +326,11 @@ export default function FlowsPage() { function EmptyState({ onCreate, canCreate, + t, }: { onCreate: () => void; canCreate: boolean; + t: ReturnType; }) { return (
    @@ -335,12 +338,10 @@ function EmptyState({

    - No flows yet + {t("emptyTitle")}

    - Build your first conversation — a welcome menu, an order lookup, an FAQ - bot. Customers tap buttons; the bot routes them to the right answer (or - the right agent). + {t("emptyDesc")}

    - Create your first flow + {t("createFirst")}
    ); @@ -359,12 +360,14 @@ function FlowCard({ flow, onEdit, onDelete, + t, }: { flow: FlowRow; onEdit: () => void; onDelete: () => void; + t: ReturnType; }) { - const triggerSummary = describeTrigger(flow); + const triggerSummary = describeTrigger(flow, t); const StatusIcon = flow.status === "active" ? PlayCircle @@ -388,7 +391,7 @@ function FlowCard({ )} > - {STATUS_LABELS[flow.status]} + {STATUS_LABELS(t)[flow.status]}
    @@ -399,14 +402,14 @@ function FlowCard({
    - {flow.execution_count} {flow.execution_count === 1 ? "run" : "runs"} + {t("runCount", { count: flow.execution_count })}
    ); } -function describeTrigger(flow: FlowRow): string { +function describeTrigger(flow: FlowRow, t: ReturnType): string { if (flow.trigger_type === "keyword") { const keywords = Array.isArray(flow.trigger_config.keywords) ? (flow.trigger_config.keywords as string[]) : []; - if (keywords.length === 0) return "Triggers on keyword (none set)"; - return `Triggers on: ${keywords.join(", ")}`; + if (keywords.length === 0) return t("triggerKeywordNone"); + return t("triggerKeyword", { keywords: keywords.join(", ") }); } if (flow.trigger_type === "first_inbound_message") { - return "Triggers on a contact's first-ever inbound message"; + return t("triggerFirstInbound"); } - return "Manual trigger"; + return t("triggerManual"); } diff --git a/src/app/(dashboard)/inbox/page.tsx b/src/app/(dashboard)/inbox/page.tsx index 70480ec..b32e74b 100644 --- a/src/app/(dashboard)/inbox/page.tsx +++ b/src/app/(dashboard)/inbox/page.tsx @@ -2,6 +2,7 @@ import { useState, useCallback, useEffect, useRef } from "react"; import { useRouter, useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; import { createClient } from "@/lib/supabase/client"; import { CONVERSATION_SELECT, @@ -21,6 +22,7 @@ import { cn } from "@/lib/utils"; const CONTACT_PANEL_STORAGE_KEY = "wacrm:inbox:contact-panel-open"; export default function InboxPage() { + const t = useTranslations("Inbox.page"); const router = useRouter(); const searchParams = useSearchParams(); /** @@ -556,7 +558,7 @@ export default function InboxPage() {

    - WhatsApp® is not connected. Go to Settings to connect your account. + {t("whatsappNotConnected")}

    )} diff --git a/src/app/(dashboard)/pipelines/page.tsx b/src/app/(dashboard)/pipelines/page.tsx index b7292df..fff6f98 100644 --- a/src/app/(dashboard)/pipelines/page.tsx +++ b/src/app/(dashboard)/pipelines/page.tsx @@ -29,6 +29,7 @@ import { toast } from "sonner"; import { useCan } from "@/hooks/use-can"; import { useAuth } from "@/hooks/use-auth"; import { GatedButton } from "@/components/ui/gated-button"; +import { useTranslations } from "next-intl"; // Pipeline creation is admin-class (settings-tier write under // the new RLS); deal creation is operational and only requires @@ -45,6 +46,7 @@ const SPEC_DEFAULT_STAGES = [ ]; export default function PipelinesPage() { + const t = useTranslations("Pipelines.page"); const supabase = createClient(); const canEditSettings = useCan("edit-settings"); const canCreateDeals = useCan("send-messages"); @@ -223,11 +225,11 @@ export default function PipelinesPage() { .update({ stage_id: newStageId }) .eq("id", dealId); if (error) { - toast.error("Failed to move deal"); + toast.error(t("toastFailedMoveDeal")); refreshDeals(); } }, - [supabase, refreshDeals], + [supabase, refreshDeals, t], ); const handleAddDeal = useCallback( @@ -260,7 +262,7 @@ export default function PipelinesPage() { } // pipelines.account_id is NOT NULL post-017 with no DB default. if (!accountId) { - toast.error("Your profile is not linked to an account."); + toast.error(t("toastNotLinkedToAccount")); setCreating(false); return; } @@ -272,7 +274,7 @@ export default function PipelinesPage() { .single(); if (error || !pipeline) { - toast.error("Failed to create pipeline"); + toast.error(t("toastFailedCreatePipeline")); setCreating(false); return; } @@ -290,7 +292,7 @@ export default function PipelinesPage() { setSelectedPipelineId(pipeline.id); await refreshPipelines(); setCreating(false); - toast.success("Pipeline created"); + toast.success(t("toastPipelineCreated")); } const selectedPipeline = pipelines.find((p) => p.id === selectedPipelineId); @@ -323,7 +325,7 @@ export default function PipelinesPage() { > - {selectedPipeline?.name ?? "Select Pipeline"} + {selectedPipeline?.name ?? t("selectPipeline")} @@ -333,7 +335,7 @@ export default function PipelinesPage() { > {pipelines.length === 0 && ( - No pipelines yet + {t("noPipelinesYet")} )} {pipelines.map((p) => ( @@ -357,7 +359,7 @@ export default function PipelinesPage() { className="text-popover-foreground" > - Manage Pipelines + {t("managePipelines")} )} @@ -373,7 +375,7 @@ export default function PipelinesPage() { className="border-border bg-card text-foreground hover:bg-muted" > - Add Pipeline + {t("addPipeline")} - Add Deal + {t("addDeal")}
    @@ -393,10 +395,10 @@ export default function PipelinesPage() {

    - No pipelines yet + {t("noPipelinesYet")}

    - Create a pipeline to start tracking deals + {t("createToStartTracking")}

    - Create Pipeline + {t("createPipeline")}
    ) : ( @@ -425,21 +427,21 @@ export default function PipelinesPage() { - New Pipeline + {t("newPipeline")}
    - + setNewPipelineName(e.target.value)} - placeholder="e.g., Enterprise Sales" + placeholder={t("pipelineNamePlaceholder")} className="mt-2 bg-muted border-border text-foreground" onKeyDown={(e) => { if (e.key === "Enter") handleCreatePipeline(); }} />

    - Default stages (New Lead → Won) will be created automatically. + {t("defaultStagesDesc")}

    @@ -448,14 +450,14 @@ export default function PipelinesPage() { onClick={() => setNewPipelineOpen(false)} className="border-border text-muted-foreground hover:bg-muted" > - Cancel + {t("cancel")}
    diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 9f6971d..2954666 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -2,6 +2,7 @@ import { useMemo, type ReactNode } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; +import { useTranslations } from 'next-intl'; import { useAuth } from '@/hooks/use-auth'; import { useTheme } from '@/hooks/use-theme'; @@ -26,6 +27,7 @@ export default function SettingsPage() { const searchParams = useSearchParams(); const { defaultCurrency } = useAuth(); const { mode } = useTheme(); + const t = useTranslations('Settings'); // The URL (`?tab=`) is the single source of truth for the active // section — deep-linkable, and it keeps the existing links in the @@ -67,11 +69,10 @@ export default function SettingsPage() {

    - Settings + {t('pageTitle')}

    - Everything in one place — your account and your workspace. Pick a - section to manage it. + {t('pageDesc')}

    diff --git a/src/components/automations/automation-builder.tsx b/src/components/automations/automation-builder.tsx index 7632166..d887b35 100644 --- a/src/components/automations/automation-builder.tsx +++ b/src/components/automations/automation-builder.tsx @@ -8,6 +8,7 @@ import { type ReactNode, } from "react" import { useRouter } from "next/navigation" +import { useTranslations } from "next-intl" import { toast } from "sonner" import { ArrowLeft, @@ -88,17 +89,17 @@ interface StepMeta { } const STEP_META: Record = { - send_message: { label: "Send Message", icon: MessageSquare, border: "border-l-primary" }, - send_template: { label: "Send Template", icon: FileText, border: "border-l-primary" }, - add_tag: { label: "Add Tag", icon: Tag, border: "border-l-primary" }, - remove_tag: { label: "Remove Tag", icon: TagIcon, border: "border-l-primary" }, - assign_conversation: { label: "Assign Conversation", icon: UserCheck, border: "border-l-primary" }, - update_contact_field: { label: "Update Contact Field", icon: PencilLine, border: "border-l-primary" }, - create_deal: { label: "Create Deal", icon: Briefcase, border: "border-l-primary" }, - wait: { label: "Wait", icon: Hourglass, border: "border-l-border" }, - condition: { label: "Condition (If/Else)", icon: GitBranch, border: "border-l-amber-500" }, - send_webhook: { label: "Send Webhook", icon: Webhook, border: "border-l-primary" }, - close_conversation: { label: "Close Conversation", icon: CircleSlash, border: "border-l-primary" }, + send_message: { label: "send_message", icon: MessageSquare, border: "border-l-primary" }, + send_template: { label: "send_template", icon: FileText, border: "border-l-primary" }, + add_tag: { label: "add_tag", icon: Tag, border: "border-l-primary" }, + remove_tag: { label: "remove_tag", icon: TagIcon, border: "border-l-primary" }, + assign_conversation: { label: "assign_conversation", icon: UserCheck, border: "border-l-primary" }, + update_contact_field: { label: "update_contact_field", icon: PencilLine, border: "border-l-primary" }, + create_deal: { label: "create_deal", icon: Briefcase, border: "border-l-primary" }, + wait: { label: "wait", icon: Hourglass, border: "border-l-border" }, + condition: { label: "condition", icon: GitBranch, border: "border-l-amber-500" }, + send_webhook: { label: "send_webhook", icon: Webhook, border: "border-l-primary" }, + close_conversation: { label: "close_conversation", icon: CircleSlash, border: "border-l-primary" }, } const ADDABLE_STEPS: AutomationStepType[] = [ @@ -115,18 +116,14 @@ const ADDABLE_STEPS: AutomationStepType[] = [ "close_conversation", ] -const TRIGGER_OPTIONS: { value: AutomationTriggerType; label: string; hint: string }[] = [ - { value: "new_message_received", label: "New Message Received", hint: "Any incoming message" }, - { - value: "first_inbound_message", - label: "First Message from Contact", - hint: "First time this contact ever messages you (works for manually-added contacts too)", - }, - { value: "keyword_match", label: "Keyword Match", hint: "Message contains specific keyword(s)" }, - { value: "new_contact_created", label: "New Contact Created", hint: "When a contact is auto-created from an incoming message" }, - { value: "conversation_assigned", label: "Conversation Assigned", hint: "When assigned to an agent" }, - { value: "tag_added", label: "Tag Added", hint: "When a tag is added to a contact" }, - { value: "time_based", label: "Time-Based", hint: "On a recurring schedule" }, +const TRIGGER_OPTIONS: { value: AutomationTriggerType }[] = [ + { value: "new_message_received" }, + { value: "first_inbound_message" }, + { value: "keyword_match" }, + { value: "new_contact_created" }, + { value: "conversation_assigned" }, + { value: "tag_added" }, + { value: "time_based" }, ] function cid(): string { @@ -286,15 +283,17 @@ const SELECT_CLASS = function TagSelect({ value, onChange, + t, }: { value: string onChange: (v: string) => void + t: ReturnType }) { const { tags } = useResources() if (tags.length === 0) { return ( onChange(e.target.value)} className="bg-muted text-foreground" @@ -314,16 +313,16 @@ function TagSelect({ onChange={(e) => onChange(e.target.value)} className={SELECT_CLASS} > - - {tags.map((t) => ( - + {tags.map((tg) => ( + ))} {/* Preserve a saved tag that's since been deleted so editing an existing automation doesn't silently drop it. */} {value && !selected && ( - + )}
    @@ -337,9 +336,11 @@ function TagSelect({ function ContactFieldSelect({ value, onChange, + t, }: { value: string onChange: (v: string) => void + t: ReturnType }) { const { customFields } = useResources() const customValue = value.startsWith("custom:") ? value : "" @@ -351,11 +352,11 @@ function ContactFieldSelect({ onChange={(e) => onChange(e.target.value)} className={SELECT_CLASS} > - - - + + + {customFields.length > 0 && ( - + {customFields.map((f) => ( )} {customValue && !knownCustom && ( - + )} ) @@ -375,15 +376,17 @@ function ContactFieldSelect({ function AgentSelect({ value, onChange, + t, }: { value: string onChange: (v: string) => void + t: ReturnType }) { const { members } = useResources() if (members.length === 0) { return ( onChange(e.target.value)} className="bg-muted text-foreground" @@ -397,14 +400,14 @@ function AgentSelect({ onChange={(e) => onChange(e.target.value)} className={SELECT_CLASS} > - + {members.map((m) => ( ))} {value && !selected && ( - + )} ) @@ -416,17 +419,19 @@ function DealPipelineFields({ pipelineId, stageId, onChange, + t, }: { pipelineId: string stageId: string onChange: (patch: { pipeline_id: string; stage_id: string }) => void + t: ReturnType }) { const { pipelines, stages } = useResources() if (pipelines.length === 0) { return ( <> - + @@ -435,7 +440,7 @@ function DealPipelineFields({ className="bg-muted text-foreground" /> - + @@ -454,7 +459,7 @@ function DealPipelineFields({ return ( <> - + - + @@ -513,17 +518,19 @@ function SendTemplateFields({ templateName, language, onChange, + t, }: { templateName: string language: string onChange: (patch: { template_name: string; language: string }) => void + t: ReturnType }) { const { templates } = useResources() if (templates.length === 0) { return ( <> - + @@ -532,7 +539,7 @@ function SendTemplateFields({ className="bg-muted text-foreground" /> - + @@ -554,7 +561,7 @@ function SendTemplateFields({ ) return ( - + @@ -588,6 +595,7 @@ function SendTemplateFields({ export function AutomationBuilder({ initial }: { initial: BuilderInitial }) { const router = useRouter() + const t = useTranslations("Automations.builder") const isEditing = !!initial.id const [state, setState] = useState(initial) const [saving, setSaving] = useState(false) @@ -658,11 +666,11 @@ export function AutomationBuilder({ initial }: { initial: BuilderInitial }) { description: firstIssue.path ? `at ${firstIssue.path}` : undefined, }) } else { - toast.error(body?.error ?? "Save failed") + toast.error(body?.error ?? t("toasts.saveFailed")) } return } - toast.success(isEditing ? "Automation saved" : "Automation created") + toast.success(isEditing ? t("toasts.saved") : t("toasts.created")) if (!isEditing && body?.automation?.id) { router.replace(`/automations/${body.automation.id}/edit`) } @@ -681,22 +689,22 @@ export function AutomationBuilder({ initial }: { initial: BuilderInitial }) { type="button" onClick={() => router.push("/automations")} className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" - aria-label="Back to automations" + aria-label={t("backToAutomations")} > patchTop("name", e.target.value)} - placeholder="Untitled automation" + placeholder={t("untitled")} className="min-w-0 flex-1 rounded-md bg-transparent px-2 py-1 text-sm font-semibold text-foreground placeholder:text-muted-foreground focus:bg-muted focus:outline-none sm:text-base" />
    - Active + {t("active")} patchTop("is_active", !!v)} - aria-label="Active" + aria-label={t("activeAria")} />
    @@ -717,8 +725,9 @@ export function AutomationBuilder({ initial }: { initial: BuilderInitial }) { patchTop("trigger_type", t)} + onTypeChange={(tVal) => patchTop("trigger_type", tVal)} onConfigChange={(c) => patchTop("trigger_config", c)} + t={t} /> onTypeChange: (t: AutomationTriggerType) => void onConfigChange: (c: Record) => void + t: ReturnType }) { const [open, setOpen] = useState(false) return ( @@ -767,9 +778,9 @@ function TriggerCard({
    -
    Trigger
    +
    {t("trigger")}
    - {TRIGGER_OPTIONS.find((o) => o.value === type)?.label ?? type} + {t(`triggers.${type}.label`)}

    - {TRIGGER_OPTIONS.find((o) => o.value === type)?.hint} + {t(`triggers.${type}.hint`)}

    {type === "keyword_match" && ( )} {type === "tag_added" && ( @@ -811,18 +823,27 @@ function TriggerCard({ onConfigChange({ ...config, tag_id: v })} + t={t} /> )} {type === "time_based" && ( - - onConfigChange({ ...config, schedule: e.target.value }) - } - className="bg-muted text-foreground" - /> +
    + + + onConfigChange({ ...config, schedule: e.target.value }) + } + className="bg-muted text-foreground" + /> +

    + {t("scheduleHint")} +

    +
    )} )} @@ -834,9 +855,11 @@ function TriggerCard({ function KeywordMatchConfig({ config, onChange, + t, }: { config: KeywordMatchTriggerConfig onChange: (c: Record) => void + t: ReturnType }) { const keywords = config?.keywords ?? [] // Keep a local draft string so the comma and trailing space aren't @@ -872,7 +895,7 @@ function KeywordMatchConfig({
    @@ -972,6 +995,7 @@ function StepRenderer({ parentScope: ParentScope parentPath: StepPath } & Omit) { + const t = useTranslations("Automations.builder") const path: StepPath = [ ...parentPath, parentScope.kind === "root" @@ -1011,7 +1035,7 @@ function StepRenderer({
    {isCondition ? "Condition" : step.step_type === "wait" ? "Wait" : "Action"}
    -
    {meta.label}
    +
    {t(`steps.${meta.label}`)}
    {previewFor(step)}
    props.deleteStepAt(path)} > - Delete + {t("delete", { defaultValue: "Delete" })} @@ -1083,6 +1107,7 @@ function ConditionBranches({ step: BuilderStep parentPath: StepPath } & Omit) { + const t = useTranslations("Automations.builder") const yes = step.branches?.yes ?? [] const no = step.branches?.no ?? [] // Build the child scope by appending a branch marker. The scope the @@ -1101,10 +1126,10 @@ function ConditionBranches({ // cram each branch to ~170px which is too narrow for the nested // cards. Two-column grid returns on sm+.
    - + - +
    @@ -1129,13 +1154,14 @@ function BranchColumn({ } function AddButton({ onPick }: { onPick: (t: AutomationStepType) => void }) { + const t = useTranslations("Automations.builder") return (
    @@ -1143,12 +1169,12 @@ function AddButton({ onPick }: { onPick: (t: AutomationStepType) => void }) { align="start" className="max-h-80 min-w-56 overflow-y-auto border-border bg-popover" > - {ADDABLE_STEPS.map((t) => { - const Icon = STEP_META[t].icon + {ADDABLE_STEPS.map((tp) => { + const Icon = STEP_META[tp].icon return ( - onPick(t)}> + onPick(tp)}> - {STEP_META[t].label} + {t(`steps.${STEP_META[tp].label}`)} ) })} @@ -1170,6 +1196,7 @@ function StepEditor({ step: BuilderStep onChange: (s: BuilderStep) => void }) { + const t = useTranslations("Automations.builder") const cfg = step.step_config const set = (patch: Record) => onChange({ ...step, step_config: { ...cfg, ...patch } }) @@ -1177,11 +1204,11 @@ function StepEditor({ switch (step.step_type) { case "send_message": return ( - +