diff --git a/apps/builder/messages/en.json b/apps/builder/messages/en.json
index cd4079bc6..fe90b0453 100644
--- a/apps/builder/messages/en.json
+++ b/apps/builder/messages/en.json
@@ -18,6 +18,8 @@
"archive": "Archive",
"unarchive": "Unarchive",
"archiveConversation": "Archive Conversation",
+ "closeConversation": "Close",
+ "openConversation": "Open",
"assign": "Assign",
"assignConversation": "Assign Conversation",
"back": "Back",
@@ -109,15 +111,96 @@
"deactivate": "Deactivate",
"onFailure": "On failure",
"onSkip": "On skip",
- "onSuccess": "On success"
+ "onSuccess": "On success",
+ "trigger": "Trigger",
+ "close": "Close",
+ "remove": "Remove"
},
"states": {
"success": "Success",
"error": "Error",
- "skip": "Skip"
+ "skip": "Skip",
+ "closed": "Closed",
+ "blocked": "Blocked"
},
"admins": {
- "title": "Admins"
+ "title": "User settings",
+ "subtitle": "Manage and invite team members to your Workspace.",
+ "addUser": "Add User",
+ "searchPlaceholder": "Search User settings",
+ "revoke": "Revoke",
+ "edit": "Edit",
+ "roles": {
+ "owner": "Owner",
+ "manager": "Manager",
+ "agent": "Agent"
+ },
+ "lastSeen": "Last seen",
+ "inviteDialog": {
+ "title": "Add User",
+ "description": "Enter the email address and select an access level to invite a new user to your Workspace.",
+ "emailLabel": "Email Address",
+ "emailPlaceholder": "user@company.com",
+ "accessLevelLabel": "Access Level",
+ "submit": "Add"
+ },
+ "editDialog": {
+ "title": "Edit User",
+ "description": "To edit this user select a new access level and click \"Edit\". The Access Level defines what the user can do on the workspace.",
+ "submit": "Update"
+ },
+ "revokeDialog": {
+ "title": "Revoke Access",
+ "description": "If you revoke this user all contacts assigned to this user will be marked unassigned. Are you sure you want to revoke this user's access?",
+ "confirm": "Revoke"
+ },
+ "roleDescriptions": {
+ "owner": "Owners can access all modules within the workspace and have full control of the settings with no restrictions.",
+ "manager": "Managers have access to all modules within the workspace including Settings. However, they are not able to invite/delete other managers or delete channels.",
+ "agent": "Agents only have access to messages within the workspace. Optionally, you can limit visibility in Contacts."
+ },
+ "advancedRestrictions": "Advanced Restrictions",
+ "managerRestrictions": {
+ "restrictDataExport": {
+ "label": "Restrict Data Export",
+ "description": "This user will not be able to export data from the workspace."
+ },
+ "restrictContactDeletion": {
+ "label": "Restrict Contact Deletion",
+ "description": "This user will not be able to delete any contacts from the workspace."
+ },
+ "restrictWorkspaceSettings": {
+ "label": "Restrict Workspace Settings Access",
+ "description": "This user will not be able to access the workspace settings."
+ },
+ "restrictIntegrationSettings": {
+ "label": "Restrict Integration Settings Access",
+ "description": "This user will not be able to access the integration settings."
+ }
+ },
+ "agentRestrictions": {
+ "contactVisibility": {
+ "label": "Restrict Contacts Visibility",
+ "description": "This user only view contacts assigned to",
+ "options": {
+ "all": "anyone",
+ "team": "anyone in the user's team",
+ "self": "user"
+ }
+ },
+ "restrictCalling": {
+ "label": "Restrict Calls Capabilities",
+ "description": "This user will not be able to receive or initiate calls."
+ },
+ "restrictWorkflows": {
+ "label": "Restrict Workflows Button",
+ "description": "The Workflows button in the Messages module will be disabled for this user. This includes Shortcuts and stopping a Workflow for a Contact."
+ },
+ "maskPhoneAndEmail": {
+ "label": "Mask Phone Number and Email Address",
+ "description": "This user will not be able to view all Contacts' phone numbers and email addresses."
+ }
+ }
},
"assignAdmin": {
"assignConversation": "Assign conversation",
@@ -215,6 +298,9 @@
"label": "Automated Responses"
}
},
+ "automation": {
+ "title": "Automation"
+ },
"billing": {
"title": "Billing"
},
@@ -314,9 +400,93 @@
"channels": {
"title": "Channels"
},
+ "organization": {
+ "manage": "Manage Organization"
+ },
+ "personalSettings": {
+ "title": "Personal settings",
+ "account": "Account",
+ "profile": "Profile",
+ "notifications": "Notification settings",
+ "activityStatus": {
+ "sectionLabel": "My status",
+ "available": "Available",
+ "busy": "Busy",
+ "offline": "Offline",
+ "updateSuccess": "Status updated to {status}",
+ "updateError": "Failed to update status"
+ },
+ "profileSection": {
+ "title": "Profile",
+ "subtitle": "Manage your profile and login details.",
+ "changeAvatar": "Change photo",
+ "avatarUpdated": "Photo updated.",
+ "firstNameLabel": "First Name",
+ "lastNameLabel": "Last Name",
+ "languageLabel": "Language",
+ "changePassword": "Change password",
+ "saveProfile": "Save Profile",
+ "savedSuccess": "Profile updated.",
+ "twoFA": {
+ "title": "Two-factor authentication (2FA)",
+ "toggle": "Enable two-factor authentication (2FA)"
+ },
+ "darkMode": {
+ "title": "Dark mode",
+ "system": "Use system settings",
+ "light": "Off",
+ "dark": "On"
+ }
+ },
+ "notificationsSection": {
+ "title": "Notification settings",
+ "subtitle": "Choose how you receive workspace alerts."
+ }
+ },
+ "manageWorkspaces": {
+ "sidebarTitle": "Workspaces",
+ "title": "Workspaces",
+ "subtitle": "Manage your organization's workspaces.",
+ "addWorkspace": "Add Workspace",
+ "searchPlaceholder": "Search workspace",
+ "open": "Open",
+ "rename": "Rename",
+ "delete": "Delete",
+ "empty": "No workspaces created yet.",
+ "columns": {
+ "name": "Name",
+ "members": "Users",
+ "createdAt": "Created",
+ "actions": "Actions"
+ },
+ "renameDialog": {
+ "title": "Rename Workspace",
+ "description": "Update the workspace name.",
+ "nameLabel": "Workspace Name",
+ "submit": "Save",
+ "success": "Workspace renamed."
+ },
+ "deleteDialog": {
+ "title": "Delete Workspace",
+ "description": "Deleting the workspace \"{name}\" permanently removes all contacts, channels, fields and associated data. This action cannot be undone.",
+ "confirmLabel": "Type \"{name}\" to confirm",
+ "submit": "Delete",
+ "success": "Workspace deleted."
+ }
+ },
"workspaces": {
"list": {
"title": "Workspaces List"
+ },
+ "searchPlaceholder": "Search project",
+ "addWorkspace": "Add Workspace",
+ "createDialog": {
+ "title": "Add Workspace",
+ "description": "Create a new workspace to separate channels, contacts, teams and settings.",
+ "nameLabel": "Workspace Name",
+ "namePlaceholder": "Ex: Sales, Collections, Support",
+ "submit": "Create",
+ "success": "Workspace \"{name}\" created."
}
},
"workspaceToken": {
@@ -707,11 +877,15 @@
"isDefault": {
"label": "Mark as default"
},
+ "country": {
+ "label": "Country"
+ },
"language": {
"description": "Contacts are assigned the default language when the contact language is unknown.",
"english": "English",
"label": "Language",
"placeholder": "Select language",
+ "portuguese": "Português (Brasil)",
"vietnamese": "Tiếng Việt"
},
"lastName": {
@@ -989,6 +1163,12 @@
"datetime": {
"label": "Datetime"
},
+ "time": {
+ "label": "Time"
+ },
+ "list": {
+ "label": "List (multi-select)"
+ },
"boolean": {
"label": "Boolean",
"placeholder": "Select true or false",
@@ -1233,6 +1413,21 @@
},
"webhookUrl": {
"label": "Webhook URL"
+ },
+ "addedAt": {
+ "label": "Date added"
+ },
+ "lastEditedAt": {
+ "label": "Last edited"
+ },
+ "visibility": {
+ "label": "Visibility",
+ "alwaysShow": "Always show",
+ "alwaysHide": "Always hide",
+ "hideWhenEmpty": "Hide when empty"
+ },
+ "fieldId": {
+ "label": "Field ID"
}
},
"flows": {
@@ -1311,7 +1506,8 @@
"sequenceActions": "Sequence Actions",
"subscribeSequence": "Subscribe Sequence",
"unsubscribeSequence": "Unsubscribe Sequence",
- "splitTraffic": "Split Traffic"
+ "splitTraffic": "Split Traffic",
+ "updateContactLifecycleStage": "Update Contact Lifecycle Stage"
},
"splitTraffic": {
"balanceHint": "The total sum must equal 100%.",
@@ -1423,7 +1619,59 @@
"whatsappSystemUserTokenRequired": "System User Token is required to configure WhatsApp."
}
},
+ "conversations": {
+ "searchInConversation": "Search in this conversation",
+ "searchInConversationPlaceholder": "Search messages…",
+ "searchNoResults": "No results",
+ "searchPrevious": "Previous result",
+ "searchNext": "Next result",
+ "composerHint": "Use '/' for snippets, '$' for variables, ':' for emoji",
+ "composerPlaceholder": "Type a message",
+ "channelPrefix": "to:",
+ "todayLabel": "Today",
+ "yesterdayLabel": "Yesterday",
+ "unassignedAgent": "Unassigned",
+ "unnamedContact": "Unnamed contact"
+ },
+ "inboxSideRail": {
+ "details": "Contact details",
+ "channels": "Channels",
+ "activities": "Activities",
+ "attachments": "Attachments"
+ },
"messages": {
+ "actions": {
+ "menu": "More options",
+ "copy": "Copy text",
+ "copyLink": "Copy message link",
+ "reply": "Reply",
+ "copied": "Message copied",
+ "linkCopied": "Link copied"
+ },
+ "tooltip": {
+ "sent": "Sent",
+ "delivered": "Delivered",
+ "read": "Read",
+ "received": "Received",
+ "failed": "Failed"
+ },
+ "composer": {
+ "internalComment": "Internal comment",
+ "addComment": "Add Comment",
+ "exitInternal": "Back to message",
+ "internalLabel": "Internal comment",
+ "internalPlaceholder": "Use @ to mention a teammate. Comments are only visible to your team.",
+ "internalHint": "Comment visible only to your team",
+ "channelHint": "Channel used to send the message",
+ "formLabel": "Message form",
+ "textareaLabel": "Type your message",
+ "emoji": "Emoji",
+ "emojiSearch": "Search",
+ "attach": "Attach file",
+ "snippets": "Quick replies",
+ "send": "Send message"
+ },
+ "runningWorkflows": "Running workflows",
"moveFeature": "Move {feature}",
"poweredBy": "Powered by {name}",
"noGiphyApiKey": "No GIPHY API key found. Please add a GIPHY API key in the organization settings.",
@@ -1536,6 +1784,83 @@
"settings": {
"title": "Settings"
},
+ "settingsGroups": {
+ "general": "General settings",
+ "workspace": "Workspace",
+ "apps": "Apps",
+ "advanced": "Advanced",
+ "inbox": "Inbox",
+ "data": "Data"
+ },
+ "lifecycle": {
+ "title": "Lifecycle",
+ "subtitle": "Track and manage contacts through each stage of your sales process",
+ "learnMore": "Learn more",
+ "toggleTitle": "Show/hide lifecycle stages",
+ "toggleSubtitle": "Display lifecycle stages in the inbox and contacts modules.",
+ "configureTitle": "Configure lifecycle stages",
+ "activeTitle": "Lifecycle stages",
+ "activeSubtitle": "Organize and track contacts through your active funnel stages.",
+ "lostTitle": "Lost stages",
+ "lostSubtitle": "Track where contacts drop out of the funnel.",
+ "addStage": "Add stage",
+ "maxStagesReached": "Maximum of {max} stages reached (active + lost).",
+ "stageNumber": "Stage {n}",
+ "stageName": "Stage name",
+ "newStage": "New stage",
+ "defaultBadge": "Default",
+ "saved": "Stages saved",
+ "saveError": "Error saving stages",
+ "saving": "Saving…",
+ "emptyNameError": "All stages need a name",
+ "emojiSearch": "Search",
+ "emojiCategories": {
+ "suggested": "Recently used",
+ "smileys": "Smileys & emotion",
+ "animals": "Animals & nature",
+ "food": "Food & drink",
+ "travel": "Travel & places",
+ "activities": "Activities",
+ "objects": "Objects",
+ "symbols": "Symbols",
+ "flags": "Flags"
+ },
+ "deleteConfirm": "Delete stage \"{name}\"? Contacts on this stage will be unassigned.",
+ "selectPlaceholder": "Select stage",
+ "noStage": "No stage",
+ "nextStageTooltip": "Next stage: {name}",
+ "lastStageTooltip": "Already at the last stage of the funnel",
+ "contactLifecycle": "Contact lifecycle",
+ "activeStages": "Active stages",
+ "lostStages": "Lost stages",
+ "contactUpdated": "Contact stage updated",
+ "contactUpdateError": "Error updating stage",
+ "bulkMove": "Move stage",
+ "bulkMoved": "{count} contact(s) moved",
+ "deleteModalTitle": "Delete stage \"{name}\"",
+ "deleteModalDescription": "This stage has {count} contact(s). Choose which stage to move them to before deleting.",
+ "deleteModalReassignTo": "Move contacts to",
+ "deleteModalNoContacts": "This stage has no contacts. Confirm to delete.",
+ "deleteModalNoTarget": "Leave unassigned",
+ "deleteModalConfirm": "Delete and migrate",
+ "deleteModalCancel": "Cancel",
+ "stageBadgeLabel": "Stage",
+ "activityLog": "Stage history",
+ "activityLogEmpty": "No stage changes yet",
+ "menu": {
+ "setAsDefault": "Set as default",
+ "delete": "Delete"
+ },
+ "anyStage": "Any stage",
+ "triggerFromStage": "From (origin)",
+ "triggerToStage": "To (destination)",
+ "stepHint": "Update the contact's lifecycle stage to a new stage.",
+ "stepUpdateAction": "Update stage",
+ "stepUpdateActionHint": "Update contact stage to another stage",
+ "stepRemoveAction": "Remove stage",
+ "stepRemoveActionHint": "Removes the stage from the contact",
+ "stepStageLabel": "Lifecycle stage"
+ },
"auth": {
"orContinueWith": "Or continue with",
"dontHaveAnAccount": "Don't have an account?",
@@ -1565,7 +1890,23 @@
"signUpTitle": "Sign up to {name}"
},
"tags": {
- "title": "Tags"
+ "title": "Tags",
+ "subtitle": "Create and manage tags to organize conversations and contacts",
+ "formDescription": "Configure the tag appearance — pick an emoji, color and description.",
+ "namePlaceholder": "Ex: Hot lead",
+ "emojiLabel": "Emoji",
+ "clearEmoji": "Clear emoji",
+ "colorLabel": "Color",
+ "descriptionLabel": "Description",
+ "descriptionPlaceholder": "Optional — what is this tag for?",
+ "preview": "Preview",
+ "addTag": "Add tag",
+ "searchPlaceholder": "Search tags",
+ "emptyState": "No tags found",
+ "delete": {
+ "usageDescription": "You're about to delete {tagCount, plural, =1 {1 tag} other {# tags}} assigned to {contactCount, plural, =1 {1 contact} other {# contacts}}. This removes the tag from all contacts and cannot be undone.",
+ "confirmInputLabel": "To confirm, type {count} below:"
+ }
},
"texts": {
"or": "or"
@@ -1586,6 +1927,10 @@
},
"whatsapp": {
"category": {
+ "authentication": {
+ "description": "OTP codes, 2-step verification, and authentication messages.",
+ "label": "Authentication"
+ },
"marketing": {
"description": "Marketing category description",
"label": "Marketing"
@@ -1595,6 +1940,31 @@
"label": "Utility"
}
},
+ "connectionType": {
+ "embedded": "Embedded Signup",
+ "manual": "Manual connection"
+ },
+ "list": {
+ "empty": "No WhatsApp connected yet."
+ },
+ "templatePicker": {
+ "empty": "No approved templates available.",
+ "preview": "Preview",
+ "sent": "Template sent",
+ "title": "Send template",
+ "variables": "Variables"
+ },
+ "webhook": {
+ "pending": "Webhook pending",
+ "verified": "Webhook verified"
+ },
+ "windowAlert": {
+ "description": "Meta does not allow sending messages 24 hours after the contact's last reply. To continue, send an approved message template or wait for a new reply from the contact to resume sending messages normally.",
+ "learnMore": "Learn more",
+ "openTemplate": "Open template list",
+ "sendTemplateButton": "Send message template",
+ "title": "WhatsApp messaging window closed"
+ },
"commands": {
"description": "These are special keywords that tell the WhatsApp bot what to do",
"label": "Commands"
@@ -1825,7 +2195,35 @@
"organizationInvitationDescription2": "{userName} has invited you to join {organizationName}."
},
"inboxTeams": {
- "title": "Inbox Teams"
+ "title": "Team settings",
+ "subtitle": "Create and manage teams to group the users in your Workspace.",
+ "addTeam": "Add Team",
+ "searchPlaceholder": "Search Team settings",
+ "manage": "Manage",
+ "delete": "Delete",
+ "createDialog": {
+ "title": "Add Team",
+ "description": "Create a Team to group users. Teams can be used in place of Users to share resources like Views."
+ },
+ "manageDialog": {
+ "title": "Manage Team",
+ "description": "Manage team by renaming it, change its description and adding or removing of team members."
+ },
+ "deleteDialog": {
+ "title": "Delete Team",
+ "description": "Are you sure you want to delete the team \"{name}\"? This action cannot be undone. Members will not be deleted.",
+ "confirm": "Delete"
+ },
+ "fields": {
+ "name": "Team Name",
+ "namePlaceholder": "Add team name here e.g. Marketing Team",
+ "description": "Team Description",
+ "descriptionPlaceholder": "Add team description here e.g. Manage marketing contacts.",
+ "members": "Team Members",
+ "membersPlaceholder": "Add a user here e.g. John."
+ },
+ "membersCount": "{count, plural, =0 {No members} =1 {1 member} other {# members}}",
+ "save": "Save"
},
"webhooks": {
"title": "Webhooks"
@@ -1880,7 +2278,8 @@
"productRemovedFromCart": "Product Removed From Cart",
"productOrdered": "Product Ordered",
"contactReferredANewContact": "Contact Referred A New Contact",
- "contactReferredExistingContact": "Contact Referred Existing Contact"
+ "contactReferredExistingContact": "Contact Referred Existing Contact",
+ "lifecycleStageChanged": "Lifecycle stage changed"
},
"actions": {
"addTag": "Add Tag",
@@ -1898,7 +2297,64 @@
"day": "Day",
"hour": "Hour",
"minute": "Minute",
- "at": "At"
+ "at": "At",
+ "shortcutMenu": {
+ "button": "Shortcuts",
+ "title": "Fire flow",
+ "loading": "Loading shortcuts...",
+ "empty": "No shortcut configured. Create a flow with a Shortcut trigger in Flows.",
+ "fired": "Shortcut fired",
+ "fireError": "Failed to fire shortcut"
+ },
+ "flowNode": {
+ "helperText": "Every flow starts with a trigger. When the configured event happens, this flow is fired automatically for the contact.",
+ "typeLabel": "Trigger type",
+ "typePlaceholder": "Select a trigger",
+ "noFilters": "No filters — fires on any event of this type. Refine using a condition step after the trigger.",
+ "anyOption": "Any",
+ "conversationOpened": {
+ "label": "Conversation Opened",
+ "hint": "Fires when a conversation is opened with the contact (new or re-opened after archive)."
+ },
+ "conversationClosed": {
+ "label": "Conversation Closed",
+ "hint": "Fires when a conversation is closed."
+ },
+ "contactFieldUpdated": {
+ "label": "Contact Field Updated",
+ "hint": "Fires when any contact field is changed.",
+ "fieldsLabel": "Fields that fire the trigger",
+ "empty": "No custom fields configured in this workspace."
+ },
+ "contactTagUpdated": {
+ "label": "Contact Tag Updated",
+ "hint": "Fires when a tag is added or removed from the contact.",
+ "actionLabel": "Tag action",
+ "actionAny": "Any (added or removed)",
+ "actionAdded": "When tag is added",
+ "actionRemoved": "When tag is removed",
+ "tagsLabel": "Tags that fire the trigger",
+ "empty": "No tags configured in this workspace."
+ },
+ "shortcut": {
+ "label": "Shortcut",
+ "hint": "Fires when an agent clicks a shortcut inside the Inbox.",
+ "iconLabel": "Icon (emoji)",
+ "iconHint": "Shown on the shortcut button inside the Inbox.",
+ "labelLabel": "Button text",
+ "labelPlaceholder": "Ex: Resend offer",
+ "note": "This trigger does not fire automatically. It appears as a button in the Inbox for agents to click when they want to run this flow manually."
+ },
+ "lifecycleStageChanged": {
+ "label": "Lifecycle Stage Changed",
+ "hint": "Fires when the contact's lifecycle stage changes.",
+ "toStagesLabel": "To stages (destination)",
+ "fromStagesLabel": "From stages (origin)",
+ "triggerOnCleared": "Also fire when stage is cleared",
+ "loading": "Loading stages...",
+ "emptyStages": "No lifecycle stages configured in this workspace."
+ }
+ }
},
"errorLogs": {
"title": "Error Logs"
@@ -1908,13 +2364,108 @@
"description": "Generate a token to allow your workspace to access ChatbotX APIs. Keep this token secret and do not share it with anyone."
},
"contacts": {
- "title": "Contacts"
+ "title": "Contacts",
+ "fieldsTitle": "Contact fields",
+ "admin": "Administrate",
+ "details": "Contact details",
+ "channels": "Channels",
+ "tagsTitle": "Tags",
+ "ownerLabel": "Administrator",
+ "noTags": "No tags",
+ "addTag": "Add tag",
+ "searchTag": "Search and select tags",
+ "hiddenFields": "Hidden contact fields",
+ "delete": {
+ "usageDescription": "You are about to delete {contactCount, plural, one {# contact} other {# contacts}} with {tagCount, plural, one {# tag} other {# tags}} and {conversationCount, plural, one {# conversation} other {# conversations}} attached. This action also removes the linked messages, notes and events. Type the total to confirm.",
+ "confirmInputLabel": "Type {count, number} to confirm"
+ },
+ "columnsPicker": {
+ "button": "Columns",
+ "title": "Show columns",
+ "showAll": "Show all",
+ "hideAll": "Hide all"
+ },
+ "unmerge": {
+ "loading": "Loading merged contacts…",
+ "bannerTitle": "{count, plural, one {# contact merged here} other {# contacts merged here}}",
+ "warning": "Restoring a merged contact recovers the contact card. Conversations, tags and custom fields that were transferred stay with this primary contact — move them back manually if needed.",
+ "button": "Unmerge",
+ "successToast": "Contact restored"
+ },
+ "visibility": {
+ "customize": "Customize view",
+ "title": "Customize view",
+ "description": "Customize the visibility of contact fields in the Inbox right panel. The change will apply to all workspace users.",
+ "fieldHeader": "Contact field",
+ "visibilityHeader": "Visibility",
+ "showAlways": "Always show",
+ "alwaysHide": "Always hide",
+ "saved": "View updated"
+ },
+ "column": {
+ "channels": "Channel(s)",
+ "country": "Country/region",
+ "language": "Language",
+ "conversationStatus": "Conversation Status",
+ "lastMessage": "Last Message",
+ "statusOpen": "Open",
+ "statusClosed": "Closed"
+ },
+ "merge": {
+ "action": "Merge contacts",
+ "title": "Merge contacts",
+ "description": "Choose which contact stays as primary. The others get merged under it (tags, custom fields and conversations move to the primary) and then deleted.",
+ "selectPrimary": "Primary contact",
+ "unnamed": "unnamed",
+ "warning": "{count} duplicate(s) will be deleted after merging. This action cannot be undone.",
+ "confirm": "Merge",
+ "success": "{count} contact(s) merged"
+ },
+ "sidebar": {
+ "all": "All contacts",
+ "blocked": "Blocked contacts"
+ },
+ "drawer": {
+ "title": "Contact details",
+ "openInInbox": "Open in Inbox"
+ },
+ "placeholders": {
+ "addFirstName": "Add a first name",
+ "addLastName": "Add a last name",
+ "addEmail": "Add an email",
+ "selectAssignee": "Select an assignee"
+ }
},
"auditLogs": {
- "title": "Audit Logs"
+ "title": "Activity Logs",
+ "subtitle": "Full history of actions performed in the workspace.",
+ "searchPlaceholder": "Search by detail, action or user",
+ "filterUser": "All users",
+ "filterUserAll": "All users",
+ "filterAction": "All actions",
+ "filterActionAll": "All actions",
+ "exportCsv": "Export CSV",
+ "exportSuccess": "Logs exported successfully.",
+ "exportError": "Failed to export logs.",
+ "empty": "No activity recorded yet.",
+ "totalRows": "{count, plural, =0 {No records} =1 {1 record} other {# records}}",
+ "columns": {
+ "user": "User",
+ "action": "Action",
+ "detail": "Detail",
+ "when": "When"
+ }
},
"customFields": {
- "title": "Custom Fields"
+ "title": "Custom Fields",
+ "subtitle": "Create and manage custom fields to enrich your contact profiles",
+ "addValue": "Add a value for {field}",
+ "valuesLabel": "Options",
+ "valuesPlaceholder": "Type and press Enter to add",
+ "emptyOptions": "No options configured",
+ "defaultsTitle": "Default contact fields",
+ "defaultsTooltip": "These 7 fields come from the system and are available on every contact. They can't be edited or removed — only Inbox visibility can be adjusted in \"Customize view\".",
+ "defaultsLockTooltip": "Default field — not editable"
},
"keywords": {
"title": "Keywords"
@@ -1925,6 +2476,43 @@
"users": {
"title": "Users"
},
+ "organizationUsers": {
+ "pageTitle": "Admin settings",
+ "pageSubtitle": "Manage your organization's users here.",
+ "addButton": "Add User",
+ "searchPlaceholder": "Search users",
+ "empty": "No users found.",
+ "edit": "Edit",
+ "delete": "Delete",
+ "addTitle": "Add User",
+ "addDescription": "Set this user's access to the organization and workspaces.",
+ "addSubmit": "Add",
+ "created": "User added.",
+ "editTitle": "Edit User",
+ "editDescription": "Update this user's access to the organization and workspaces.",
+ "editSubmit": "Save",
+ "updated": "User updated.",
+ "deleteTitle": "Delete User",
+ "deleteConfirm": "Are you sure you want to delete {name}? This action cannot be undone.",
+ "deleteSubmit": "Delete",
+ "deleted": "User deleted.",
+ "orgAccess": "Organization Access",
+ "workspaceAccess": "Workspace access",
+ "selectWorkspace": "Select workspace",
+ "selectRole": "Select access",
+ "addWorkspace": "Add workspace",
+ "noWorkspaces": "No workspaces assigned.",
+ "orgRoles": {
+ "owner": "Owner",
+ "manager": "Manager",
+ "agent": "Member"
+ },
+ "wsRoles": {
+ "owner": "Owner",
+ "manager": "Manager",
+ "agent": "Agent"
+ }
+ },
"plans": {
"title": "Billing Plans"
},
@@ -2068,5 +2656,119 @@
"qrCodeGenerator": {
"title": "QR Code Generator",
"description": "Create and manage your QR codes."
+ },
+ "flowEditor": {
+ "startBadge": "Start",
+ "copySuffix": "Copy",
+ "addNode": "Add block",
+ "zoomIn": "Zoom in",
+ "zoomOut": "Zoom out",
+ "fitView": "Fit to view"
+ },
+ "conversationFilter": {
+ "all": "All",
+ "human": "Human",
+ "bot": "Bot"
+ },
+ "inboxFilters": {
+ "all": "All",
+ "mine": "Mine",
+ "unassigned": "Unassigned",
+ "teamInbox": "Team Inbox",
+ "collapseSidebar": "Collapse sidebar",
+ "expandSidebar": "Expand sidebar",
+ "newMessageIndicator": "New message",
+ "blockedContacts": "Blocked Contacts"
+ },
+ "inboxDisplayFilter": {
+ "showLabel": "Show",
+ "sortLabel": "Sort by",
+ "advanced": "Advanced filters",
+ "noReply": "No reply",
+ "show": {
+ "all": "All",
+ "open": "Open",
+ "closed": "Closed"
+ },
+ "sort": {
+ "recent": "Recent",
+ "oldest": "Oldest"
+ },
+ "searchContact": "Search contact"
+ },
+ "snippets": {
+ "title": "Snippets",
+ "subtitle": "Create and manage reusable replies to speed up your conversations",
+ "feature": "Snippet",
+ "addButton": "Add Snippet",
+ "createTitle": "Add Snippet",
+ "createConfirm": "Create",
+ "searchPlaceholder": "Search Snippets",
+ "nameLabel": "Name",
+ "namePlaceholder": "Name your Snippet (e.g. greeting1)",
+ "shortcutLabel": "Shortcut",
+ "shortcutPlaceholder": "e.g. hi",
+ "topicsLabel": "Topics",
+ "topicsPlaceholder": "Add topic (e.g. sales, support)",
+ "topicsLimitReached": "Topic limit reached",
+ "filesLabel": "Files",
+ "addFile": "Add file",
+ "filesLimitReached": "File limit reached",
+ "messageLabel": "Message",
+ "messagePlaceholder": "Type the snippet message here",
+ "formDescription": "Snippets are reusable pre-written replies. Use \"/\" in the composer to insert them in conversations.",
+ "deleteTitle": "Delete Snippet",
+ "deleteDescription": "Are you sure you want to delete snippet \"{name}\"? This action cannot be undone."
+ },
+ "botFields": {
+ "title": "Bot Fields",
+ "subtitle": "Global workspace variables used in flows and automated messages"
+ },
+ "closingNotes": {
+ "title": "Closing Notes",
+ "subtitle": "Configure summary + category that agent fills in when closing a conversation in the Inbox.",
+ "modeTitle": "Operation mode",
+ "modeSubtitle": "Decide if agent is required to categorize and/or summarize the conversation before closing.",
+ "modeSaved": "Mode updated",
+ "savingMode": "Saving mode…",
+ "categoriesTitle": "Conversation categories",
+ "categoriesSubtitle": "List of categories the agent can choose from when closing (e.g.: Resolved, No response, Spam).",
+ "addCategory": "Add category",
+ "editCategoryTitle": "Edit category",
+ "categoryFormDescription": "Category becomes available to the agent in the close-conversation dialog.",
+ "namePlaceholder": "Ex.: Resolved",
+ "descriptionPlaceholder": "When to use this category (optional)",
+ "categoryCreated": "Category created",
+ "categoryUpdated": "Category updated",
+ "categoryDeleted": "Category removed",
+ "emptyCategories": "No categories yet. Add the first one so the agent can categorize conversations when closing.",
+ "deleteCategoryTitle": "Delete category",
+ "confirmDelete": "Delete category \"{name}\"? Conversations that used this category will no longer have it.",
+ "mode": {
+ "disabled": "Disabled",
+ "disabledDesc": "Agent closes conversation directly, no dialog.",
+ "optional": "Optional dialog",
+ "optionalDesc": "Dialog opens when closing but agent can skip without filling anything.",
+ "mandatoryDialog": "Category required",
+ "mandatoryDialogDesc": "Dialog opens, agent must choose a category. Summary optional.",
+ "mandatoryBoth": "Category + Summary required",
+ "mandatoryBothDesc": "Dialog opens, agent must choose category AND write conversation summary."
+ },
+ "dialog": {
+ "title": "Close conversation",
+ "description": {
+ "optional": "Categorize and/or summarize this conversation. Both fields are optional.",
+ "mandatoryDialog": "Choose a category before closing this conversation.",
+ "mandatoryBoth": "Choose a category and write a short summary before closing."
+ },
+ "categoryLabel": "Category",
+ "categoryPlaceholder": "Select a category",
+ "summaryLabel": "Summary",
+ "summaryPlaceholder": "Briefly describe what happened in this conversation…",
+ "noCategories": "No categories registered. Ask the admin to add categories in Settings → Closing Notes.",
+ "skip": "Skip and close",
+ "confirm": "Close conversation",
+ "successToast": "Conversation closed"
+ }
}
}
diff --git a/apps/builder/messages/pt-BR.json b/apps/builder/messages/pt-BR.json
new file mode 100644
index 000000000..0d201cefd
--- /dev/null
+++ b/apps/builder/messages/pt-BR.json
@@ -0,0 +1,2774 @@
+{
+ "actions": {
+ "copy": "Copiar",
+ "copyUrl": "Copiar URL",
+ "actions": "Ações",
+ "add": "Adicionar",
+ "addVariable": "Adicionar Variável",
+ "addFeature": "Adicionar {feature}",
+ "addFlowReply": "Adicionar resposta de fluxo",
+ "addMore": "Adicionar mais",
+ "addTag": "Adicionar Tag",
+ "addAction": "Adicionar Ação",
+ "addCondition": "Adicionar Condição",
+ "addTextReply": "Adicionar resposta de texto",
+ "markAsFollowUp": "Marcar como Follow Up",
+ "removeFromFollowUp": "Remover do Follow Up",
+ "markAsUnread": "Marcar como não lido",
+ "archive": "Arquivar",
+ "unarchive": "Desarquivar",
+ "archiveConversation": "Arquivar Conversa",
+ "closeConversation": "Fechar",
+ "openConversation": "Abrir",
+ "assign": "Atribuir",
+ "assignConversation": "Atribuir Conversa",
+ "back": "Voltar",
+ "blockContact": "Bloquear Contato",
+ "unblockContact": "Desbloquear Contato",
+ "cancel": "Cancelar",
+ "clear": "Limpar",
+ "clearCustomField": "Limpar Campo Personalizado",
+ "confirm": "Confirmar",
+ "connect": "Conectar",
+ "continue": "Continuar",
+ "create": "Criar",
+ "createFeature": "Criar {feature}",
+ "delete": "Excluir",
+ "deleteContact": "Excluir Contato",
+ "disableBot": "Desativar Bot",
+ "disconnect": "Desconectar",
+ "download": "Baixar",
+ "edit": "Editar",
+ "enableBot": "Ativar Bot",
+ "export": "Exportar",
+ "getEmbedCode": "Obter Código de Incorporação",
+ "getID": "Obter ID",
+ "getTools": "Obter Ferramentas",
+ "import": "Importar",
+ "exportContacts": "Exportar Contatos",
+ "filter": "Filtrar",
+ "selectFile": "Selecionar arquivo",
+ "insertLink": "Inserir link",
+ "addNew": "Adicionar novo",
+ "send": "Enviar",
+ "loginWithMagicLink": "Entrar com Magic Link",
+ "manage": "Gerenciar",
+ "more": "Mais",
+ "moreOptions": "Mais opções",
+ "moreSettings": "Mais configurações",
+ "openMenu": "Abrir menu",
+ "openWebchat": "Abrir Webchat",
+ "removeTag": "Remover Tag",
+ "rename": "Renomear",
+ "save": "Salvar",
+ "selectAll": "Selecionar todos",
+ "selectRow": "Selecionar linha",
+ "sendFlow": "Enviar Fluxo",
+ "sendMessage": "Enviar Mensagem",
+ "setCustomField": "Definir Campo Personalizado",
+ "synchronize": "Sincronizar",
+ "testNow": "Testar Agora",
+ "toggle": "Alternar",
+ "toggleTheme": "Alternar tema",
+ "transferConversationToBot": "Transferir Conversa para o Bot",
+ "update": "Atualizar",
+ "upgradeToPro": "Fazer Upgrade para Pro",
+ "uploadFile": "Enviar Arquivo",
+ "view": "Visualizar",
+ "duplicate": "Duplicar",
+ "getDraftLink": "Obter Link do Rascunho",
+ "getPublishedLink": "Obter Link Publicado",
+ "analytics": "Análises",
+ "flowVersions": "Versões do Fluxo",
+ "revertToPublished": "Reverter para Publicado",
+ "search": "Buscar",
+ "signOut": "Sair",
+ "backToSignIn": "Voltar para entrar",
+ "connectFeature": "Conectar {feature}",
+ "scanQRCode": "Escaneie com sua câmera",
+ "inviteFeature": "Convidar {feature}",
+ "joinTheTeam": "Entrar no time",
+ "chooseChannel": "Escolher Canal",
+ "chooseSubaction": "Escolher Subação",
+ "resend": "Reenviar",
+ "publish": "Publicar",
+ "setStartNode": "Definir como Nó Inicial",
+ "getNodeId": "Obter ID do Nó",
+ "setAsDefaultAgent": "Definir como Padrão",
+ "unsetDefaultAgent": "Desfazer Padrão",
+ "clickToEdit": "Clique para editar",
+ "move": "Mover",
+ "removeAssignee": "Remover Responsável",
+ "sendMail": "Enviar E-mail",
+ "wait": "Aguardar",
+ "landingPage": "Página de Captura",
+ "generate": "Gerar",
+ "regenerate": "Regenerar",
+ "qrCode": "QR Code",
+ "addSequence": "Adicionar Sequência",
+ "removeSequence": "Remover Sequência",
+ "activate": "Ativar",
+ "deactivate": "Desativar",
+ "onFailure": "Em caso de falha",
+ "onSkip": "Em caso de pulo",
+ "onSuccess": "Em caso de sucesso",
+ "trigger": "Gatilho",
+ "close": "Fechar",
+ "remove": "Remover"
+ },
+ "states": {
+ "success": "Sucesso",
+ "error": "Erro",
+ "skip": "Pular",
+ "closed": "Fechada",
+ "blocked": "Bloqueado"
+ },
+ "admins": {
+ "title": "Configurações do usuário",
+ "subtitle": "Gerencie e convide membros da equipe para seu espaço de trabalho.",
+ "addUser": "Adicionar Usuário",
+ "searchPlaceholder": "Pesquisar Configurações do usuário",
+ "revoke": "Revogar",
+ "edit": "Alterar",
+ "roles": {
+ "owner": "Proprietário",
+ "manager": "Administrador",
+ "agent": "Representante"
+ },
+ "lastSeen": "Última atividade",
+ "inviteDialog": {
+ "title": "Adicionar Usuário",
+ "description": "Digite o endereço de e-mail e selecione um nível de acesso para convidar um novo usuário para seu espaço de trabalho.",
+ "emailLabel": "Endereço de E-mail",
+ "emailPlaceholder": "usuario@empresa.com",
+ "accessLevelLabel": "Nível de Acesso",
+ "submit": "Adicionar"
+ },
+ "editDialog": {
+ "title": "Editar Usuário",
+ "description": "Para editar este usuário, selecione um novo nível de acesso e clique em \"Editar\". O Nível de Acesso define o que o usuário pode fazer no espaço de trabalho.",
+ "submit": "Atualização"
+ },
+ "revokeDialog": {
+ "title": "Revogar Acesso",
+ "description": "Se você revogar este usuário, todos os contatos atribuídos a ele serão marcados como não atribuídos. Tem certeza que deseja revogar o acesso deste usuário?",
+ "confirm": "Revogar"
+ },
+ "roleDescriptions": {
+ "owner": "Os proprietários podem acessar todos os módulos do espaço de trabalho e têm controle total das configurações sem restrições.",
+ "manager": "Os administradores têm acesso a todos os módulos do espaço de trabalho, incluindo as Configurações. No entanto, não podem convidar/excluir outros administradores ou excluir canais.",
+ "agent": "Os representantes só têm acesso a mensagens dentro do espaço de trabalho. Opcionalmente, você pode limitar a visibilidade em Contatos."
+ },
+ "advancedRestrictions": "Restrições avançadas",
+ "managerRestrictions": {
+ "restrictDataExport": {
+ "label": "Restringir Exportação de Dados",
+ "description": "Este usuário não será capaz de exportar dados do espaço de trabalho."
+ },
+ "restrictContactDeletion": {
+ "label": "Restringir exclusão de contato",
+ "description": "Este usuário não será capaz de excluir nenhum contato do espaço de trabalho."
+ },
+ "restrictWorkspaceSettings": {
+ "label": "Restringir acesso às configurações do Workspace",
+ "description": "Este usuário não poderá acessar as configurações da área de trabalho."
+ },
+ "restrictIntegrationSettings": {
+ "label": "Restringir acesso às configurações de integração",
+ "description": "Este usuário não poderá acessar as configurações de integração."
+ }
+ },
+ "agentRestrictions": {
+ "contactVisibility": {
+ "label": "Restringir visibilidade de contato",
+ "description": "Este usuário só vê contatos atribuídos a",
+ "options": {
+ "all": "todos",
+ "team": "equipe do usuário",
+ "self": "usuário"
+ }
+ },
+ "restrictCalling": {
+ "label": "Restringir recursos de chamadas",
+ "description": "Este usuário não conseguirá receber ou iniciar chamadas."
+ },
+ "restrictWorkflows": {
+ "label": "Botão de restringir fluxos de trabalho",
+ "description": "O botão Fluxos de Trabalho no módulo Mensagens ficará desativado para este usuário. Isso inclui os atalhos e a opção de interromper um fluxo de trabalho para um contato."
+ },
+ "maskPhoneAndEmail": {
+ "label": "Mascaramento do número de telefone e endereço de e-mail",
+ "description": "Este usuário não conseguirá ver os números de telefone e endereços de e-mail de todos os contatos."
+ }
+ }
+ },
+ "assignAdmin": {
+ "assignConversation": "Atribuir conversa",
+ "assignedToMe": "Atribuído a mim",
+ "assignedTo": "Atribuído a {name}",
+ "unAssigned": "Não atribuído"
+ },
+ "aiAgent": {
+ "defaultAgent": "Agente Padrão",
+ "description": "Gerencie e configure Agentes de IA para potencializar seu workspace com automação inteligente e respostas automatizadas.",
+ "title": "Agentes de IA",
+ "name": "Agentes"
+ },
+ "aiFiles": {
+ "description": "Dê aos seus agentes acesso a PDFs, documentos e mais",
+ "title": "Conhecimento"
+ },
+ "aiFunctions": {
+ "description": "Configure funções de LLM para tornar seu agente de IA mais inteligente",
+ "title": "Funções de IA"
+ },
+ "aiMcpServers": {
+ "description": "Conecte a IA a sistemas externos via MCP",
+ "title": "Servidores MCP",
+ "tools": {
+ "label": "Ferramentas Disponíveis"
+ }
+ },
+ "analytics": {
+ "contacts": "Contatos",
+ "newContacts": "Novos Contatos",
+ "activeContacts": "Contatos Ativos",
+ "botMessagesByResult": "Mensagens recebidas pelo bot",
+ "botMessagesNoResponse": "Mensagens recebidas sem resposta",
+ "botMessagesWithResponse": "Mensagens recebidas com resposta",
+ "aiProvider": "Provedor de IA",
+ "responseCount": "Quantidade de Respostas",
+ "percentage": "Porcentagem",
+ "responseTime": "Tempo de Resposta",
+ "firstResponseTime": "Tempo da Primeira Resposta",
+ "totalContacts": "Total de Contatos",
+ "averageResponseTime": "Tempo Médio de Resposta (Minutos)",
+ "averageResponseTimeHelp": "O tempo médio que um atendente humano leva para responder a mensagem de um cliente.",
+ "averageFirstResponseTimeByAdmin": "Tempo médio da primeira resposta por atendentes humanos (Minutos)",
+ "averageFirstResponseTimeByAdminHelp": "O tempo médio que um atendente humano leva para enviar a primeira resposta à mensagem de um cliente.",
+ "averageResponseTimeByAdmin": "Tempo médio de resposta por atendentes humanos (Minutos)",
+ "averageDurationOfConversation": "Duração média de uma conversa (Minutos)",
+ "averageDurationOfConversationHelp": "Tempo médio que uma conversa fica em mãos de um humano antes de ser passada para o bot.",
+ "success": "Sucesso",
+ "fallback": "Fallback",
+ "admins": "Atendentes Humanos",
+ "messages": "Mensagens",
+ "conversations": "Conversas",
+ "assignedConversations": {
+ "title": "Conversas Atribuídas",
+ "helpText": "Conversas atribuídas pela caixa de entrada ou pelo bot."
+ },
+ "messagesSentByHumanOrBot": "Mensagens enviadas por humano/bot",
+ "human": "Humano",
+ "bot": "Bot",
+ "conversationsMovedToHumanOrBot": "Conversas transferidas para humano/bot",
+ "uniqueConversationsByAdmins": "Conversas únicas por atendentes humanos",
+ "uniqueConversationsByAdminsHelp": "Número de contatos para quem o atendente humano enviou ao menos uma mensagem.",
+ "messagesSentByAdmins": "Mensagens enviadas por atendentes humanos",
+ "messagesSentByAdminsHelp": "Mensagens enviadas pela caixa de entrada.",
+ "allContactsByChannel": "Todos os contatos por canal",
+ "newContactsByChannel": "Novos contatos por canal",
+ "newContactsBySource": "Novos contatos por origem",
+ "assignedConversationsByAdmins": "Conversas atribuídas por atendentes humanos",
+ "assignedConversationsByAdminsHelp": "Conversas atribuídas pela caixa de entrada ou pelo bot.",
+ "followUpConversations": "Conversas em follow up",
+ "followUpConversationsHelp": "Conversas marcadas como follow up pela caixa de entrada ou pelo bot.",
+ "archivedConversations": "Conversas arquivadas",
+ "blockedConversations": "Conversas bloqueadas",
+ "blockedConversationsHelp": "Conversas que sua conta bloqueou.",
+ "blockedContacts": "Contatos bloqueados",
+ "blockedContactsHelp": "Contatos que não querem receber mensagens da sua conta",
+ "newContactsByCountry": "Novos contatos por país"
+ },
+ "autoAssignConversation": {
+ "rule": {
+ "allTime": "Distribuição igual de conversas (Todo o período)",
+ "label": "Regra de Atribuição",
+ "last24Hours": "Distribuição igual de conversas (Últimas 24 horas)",
+ "last8Hours": "Distribuição igual de conversas (Últimas 8 horas)",
+ "lastHour": "Distribuição igual de conversas (Última hora)"
+ },
+ "users": {
+ "label": "Usuários"
+ }
+ },
+ "automatedResponse": {
+ "setting": {
+ "description": "Descrição das respostas automatizadas",
+ "label": "Respostas Automatizadas"
+ }
+ },
+ "automation": {
+ "title": "Automação"
+ },
+ "billing": {
+ "title": "Faturamento"
+ },
+ "broadcasts": {
+ "title": "Transmissões",
+ "status": {
+ "scheduled": "Agendado",
+ "sent": "Enviado",
+ "sending": "Enviando"
+ },
+ "stats": {
+ "sent": "Enviado",
+ "delivered": "Entregue",
+ "seen": "Visualizado",
+ "clicked": "Clicado",
+ "failed": "Falhou",
+ "noContacts": "Nenhum contato encontrado",
+ "pageInfo": "Página {page} de {pageCount}"
+ },
+ "messengerList": {
+ "title": "Lista do Messenger",
+ "description": "Pode conter promoções e será enviado apenas para pessoas inscritas na sua lista do Messenger."
+ },
+ "messengerActiveContacts": {
+ "title": "Contatos Ativos do Messenger",
+ "description": "Pode conter promoções e será enviado apenas para usuários que mandaram mensagem ao seu bot nas últimas 24h."
+ },
+ "messengerAccountUpdate": {
+ "title": "Atualização de Conta do Messenger",
+ "description": "Notifica o usuário sobre uma alteração não recorrente em seu aplicativo ou conta."
+ },
+ "messengerConfirmedEventUpdate": {
+ "title": "Atualização de Evento Confirmado do Messenger",
+ "description": "Envia lembretes ou atualizações ao usuário sobre um evento para o qual ele se registrou (ex: compra de ingressos). Esta tag pode ser usada para eventos futuros e em andamento."
+ },
+ "messengerPostPurchaseUpdate": {
+ "title": "Atualização Pós-Compra do Messenger",
+ "description": "Notifica o usuário sobre uma atualização em uma compra recente. Confirmação de transação como faturas ou recibos, notificações de status de envio como produto em trânsito, despachado, entregue ou atrasado."
+ },
+ "allContacts": {
+ "title": "Todos os Contatos",
+ "description": "Envia um fluxo para todos os contatos."
+ },
+ "receiversCount": "Destinatários: {count}",
+ "whatsappTemplateMessage": {
+ "title": "Mensagem de Template",
+ "description": "Envia uma mensagem template do WhatsApp para seus contatos"
+ },
+ "whatsappWithin24Hours": {
+ "title": "Contatos ativos nas últimas 24 horas",
+ "description": "Pode conter promoções e será enviado apenas para usuários que mandaram mensagem ao seu bot nas últimas 24h."
+ },
+ "flowType": {
+ "flow": {
+ "title": "Fluxo",
+ "description": "Envia um fluxo para seus contatos"
+ },
+ "template": {
+ "title": "Template",
+ "description": "Envia um template para seus contatos"
+ }
+ }
+ },
+ "condition": {
+ "yes": "Sim",
+ "no": "Não",
+ "fields": {
+ "locale": "Idioma",
+ "fullName": "Nome completo",
+ "country": "País",
+ "continent": "Continente",
+ "gender": "Gênero",
+ "subscribedToBroadcast": "Inscrito em transmissão",
+ "contactCreatedDate": "Data de criação do contato",
+ "contactCreatedDateMinutesAgo": "Data de criação do contato (minutos atrás)",
+ "source": "Origem",
+ "conversationTransferredToHuman": "Conversa transferida para humano",
+ "interactedInLast24H": "Interagiu nas últimas 24 horas",
+ "isGuestUser": "Usuário visitante",
+ "existingContact": "Contato existente",
+ "currentChannel": "Canal atual",
+ "timezone": "Fuso horário",
+ "tags": "Tags",
+ "customFields": "Campos personalizados",
+ "phone": "Telefone",
+ "executedFlow": "Fluxo executado",
+ "email": "E-mail",
+ "followUp": "Follow up",
+ "unread": "Não lido",
+ "archived": "Arquivado",
+ "blocked": "Bloqueado",
+ "noAdminReply": "Sem resposta do admin",
+ "contactCreatedAt": "Contato criado em",
+ "interactedInLast24h": "Interagiu nas últimas 24 horas"
+ }
+ },
+ "channels": {
+ "title": "Canais"
+ },
+ "organization": {
+ "manage": "Gerenciar Organização"
+ },
+ "personalSettings": {
+ "title": "Configurações pessoais",
+ "account": "Conta",
+ "profile": "Perfil",
+ "notifications": "Configurações de notificações",
+ "activityStatus": {
+ "sectionLabel": "Meu status",
+ "available": "Disponível",
+ "busy": "Ocupado",
+ "offline": "Desconectado",
+ "updateSuccess": "Status atualizado para {status}",
+ "updateError": "Falha ao atualizar status"
+ },
+ "profileSection": {
+ "title": "Perfil",
+ "subtitle": "Gerencie seu perfil e detalhes de login.",
+ "changeAvatar": "Alterar foto",
+ "avatarUpdated": "Foto atualizada.",
+ "firstNameLabel": "Primeiro Nome",
+ "lastNameLabel": "Último Nome",
+ "languageLabel": "Idioma",
+ "changePassword": "Mudar a senha",
+ "saveProfile": "Salvar Perfil",
+ "savedSuccess": "Perfil atualizado.",
+ "twoFA": {
+ "title": "Autenticação de dois fatores (2FA)",
+ "toggle": "Habilitar autenticação de dois fatores (2FA)"
+ },
+ "darkMode": {
+ "title": "Modo escuro",
+ "system": "Usar configurações do sistema",
+ "light": "Desligado",
+ "dark": "Ligado"
+ }
+ },
+ "notificationsSection": {
+ "title": "Configurações de notificações",
+ "subtitle": "Escolha como receber alertas do seu workspace."
+ }
+ },
+ "manageWorkspaces": {
+ "sidebarTitle": "Workspaces",
+ "title": "Workspaces",
+ "subtitle": "Gerencie os workspaces da sua organização.",
+ "addWorkspace": "Adicionar Workspace",
+ "searchPlaceholder": "Pesquisar workspace",
+ "open": "Abrir",
+ "rename": "Renomear",
+ "delete": "Apagar",
+ "empty": "Nenhum workspace criado ainda.",
+ "columns": {
+ "name": "Nome",
+ "members": "Usuários",
+ "createdAt": "Data de criação",
+ "actions": "Ações"
+ },
+ "renameDialog": {
+ "title": "Renomear Workspace",
+ "description": "Atualize o nome do workspace.",
+ "nameLabel": "Nome do Workspace",
+ "submit": "Guardar",
+ "success": "Workspace renomeado."
+ },
+ "deleteDialog": {
+ "title": "Apagar Workspace",
+ "description": "Apagar o workspace \"{name}\" remove todos os contatos, canais, campos e dados associados de forma permanente. Esta ação não pode ser desfeita.",
+ "confirmLabel": "Digite \"{name}\" para confirmar",
+ "submit": "Apagar",
+ "success": "Workspace apagado."
+ }
+ },
+ "workspaces": {
+ "list": {
+ "title": "Lista de Workspaces"
+ },
+ "searchPlaceholder": "Pesquisar projeto",
+ "addWorkspace": "Adicionar Workspace",
+ "createDialog": {
+ "title": "Adicionar Workspace",
+ "description": "Crie um novo workspace para separar canais, contatos, equipes e configurações.",
+ "nameLabel": "Nome do Workspace",
+ "namePlaceholder": "Ex: Vendas, Cobrança, Suporte",
+ "submit": "Criar",
+ "success": "Workspace \"{name}\" criado."
+ }
+ },
+ "workspaceToken": {
+ "title": "Token do Workspace"
+ },
+ "fields": {
+ "omnichannel": {
+ "label": "Omnichannel"
+ },
+ "messenger": {
+ "label": "Messenger"
+ },
+ "instagram": {
+ "label": "Instagram"
+ },
+ "zalo": {
+ "label": "Zalo OA"
+ },
+ "telegram": {
+ "label": "Telegram",
+ "botToken": "Token do Bot",
+ "connectInstructions": {
+ "step1": "Abra @BotFather no Telegram.",
+ "step2": "Envie /newbot e siga as instruções.",
+ "step3": "Após criar o bot, você receberá o token da API. Copie e cole abaixo."
+ }
+ },
+ "matchAll": {
+ "label": "Atender todas as condições abaixo"
+ },
+ "matchAny": {
+ "label": "Atender uma das condições abaixo"
+ },
+ "condition": {
+ "label": "Condição"
+ },
+ "actions": {
+ "label": "Ações"
+ },
+ "tags": {
+ "label": "Tags"
+ },
+ "customFields": {
+ "label": "Campos Personalizados"
+ },
+ "pipelines": {
+ "label": "Pipelines"
+ },
+ "sequences": {
+ "label": "Sequências"
+ },
+ "ecommerce": {
+ "label": "E-commerce"
+ },
+ "entryPointLink": {
+ "label": "Link de Entrada"
+ },
+ "assignedId": {
+ "label": "Atribuir a"
+ },
+ "operator": {
+ "label": "Operador",
+ "is": "É",
+ "isNot": "Não é",
+ "in": "Em",
+ "notIn": "Não em",
+ "isEmpty": "Está vazio",
+ "isNotEmpty": "Não está vazio",
+ "gt": "Maior que",
+ "lt": "Menor que",
+ "gte": "Maior ou igual a",
+ "lte": "Menor ou igual a",
+ "contains": "Contém",
+ "notContains": "Não contém",
+ "startsWith": "Começa com",
+ "endsWith": "Termina com",
+ "isBetween": "Está entre",
+ "notBetween": "Não está entre"
+ },
+ "contactFilter": {
+ "label": "Filtro de contato"
+ },
+ "contactNote": {
+ "label": "Nota do Contato"
+ },
+ "chooseTime": {
+ "label": "Escolher horário"
+ },
+ "notificationType": {
+ "label": "Tipo de Notificação",
+ "notifyAdmin": "Notificar Admin",
+ "newMessageToHuman": "Nova Mensagem para Humano",
+ "newOrder": "Novo Pedido"
+ },
+ "notificationChannel": {
+ "label": "Canal de Notificação",
+ "messenger": "Messenger",
+ "email": "E-mail",
+ "telegram": "Telegram",
+ "browser": "Navegador"
+ },
+ "accessToken": {
+ "label": "Token de Acesso"
+ },
+ "botField": {
+ "label": "Campo do Bot"
+ },
+ "agent": {
+ "label": "Agente"
+ },
+ "aiAgent": {
+ "label": "Agente de IA"
+ },
+ "aiFile": {
+ "label": "Arquivo de IA"
+ },
+ "aiFunction": {
+ "label": "Função de IA"
+ },
+ "aiTrigger": {
+ "label": "Gatilho de IA"
+ },
+ "alphanumericLength": {
+ "label": "Comprimento Alfanumérico"
+ },
+ "analytics": {
+ "label": "Análises"
+ },
+ "apiKey": {
+ "label": "API Key"
+ },
+ "auth": {
+ "label": "Autenticação"
+ },
+ "authorizedDomain": {
+ "description": "Os domínios ou subdomínios autorizados a acessar seu webchat.",
+ "label": "Domínio{plural, select, 1 {} other {s}} Autorizado{plural, select, 1 {} other {s}}"
+ },
+ "authToken": {
+ "label": "Token"
+ },
+ "authType": {
+ "headers": "Header Personalizado",
+ "none": "Nenhum",
+ "token": "Token"
+ },
+ "automatedResponse": {
+ "label": "Resposta Automatizada"
+ },
+ "avatar": {
+ "label": "Avatar"
+ },
+ "reflink": {
+ "label": "Link de Entrada"
+ },
+ "savePayloadToCustomField": {
+ "label": "Salvar payload em campo personalizado"
+ },
+ "bot": {
+ "label": "Bot"
+ },
+ "botResponse": {
+ "label": "Resposta do Bot"
+ },
+ "brandColor": {
+ "description": "Esta cor é usada em botões para combinar com sua marca na landing page, e-mails e webchat.",
+ "label": "Cor da Marca"
+ },
+ "broadcast": {
+ "label": "Transmissão"
+ },
+ "trigger": {
+ "label": "Gatilho"
+ },
+ "webhook": {
+ "label": "Webhook"
+ },
+ "button": {
+ "label": "Botão",
+ "whenPressed": "Quando esse botão for pressionado"
+ },
+ "buttonLabel": {
+ "label": "Rótulo do Botão"
+ },
+ "category": {
+ "label": "Categoria"
+ },
+ "workspace": {
+ "label": "Workspace"
+ },
+ "workspaceId": {
+ "description": "Identificador único do seu workspace.",
+ "label": "ID do Workspace"
+ },
+ "workspaceName": {
+ "label": "Nome da Conta"
+ },
+ "contact": {
+ "label": "Contato"
+ },
+ "contacts": {
+ "label": "Contatos"
+ },
+ "conversation": {
+ "label": "Conversa"
+ },
+ "conversationStarter": {
+ "description": "Os iniciadores de conversa são usados para começar uma conversa com um usuário.",
+ "label": "Iniciador{plural, select, 1 {} other {es}} de Conversa",
+ "type": {
+ "openWebsite": "Abrir Site",
+ "sendFlow": "Enviar Fluxo",
+ "sendText": "Enviar Texto"
+ }
+ },
+ "createdAt": {
+ "label": "Criado em"
+ },
+ "currentTime": {
+ "label": "Horário Atual"
+ },
+ "customRange": {
+ "label": "Intervalo personalizado"
+ },
+ "customCss": {
+ "label": "CSS Personalizado"
+ },
+ "theme": {
+ "label": "Tema",
+ "placeholder": "Selecione um tema"
+ },
+ "customJS": {
+ "label": "JS Personalizado",
+ "placeholder": "Insira JavaScript personalizado"
+ },
+ "customCSS": {
+ "label": "CSS Personalizado",
+ "placeholder": "Insira CSS personalizado"
+ },
+ "customField": {
+ "label": "Campo Personalizado",
+ "savePayloadTo": "Salvar resposta em Campo Personalizado",
+ "set_value": "Definir valor",
+ "append": "Adicionar ao final",
+ "prepend": "Adicionar ao início",
+ "increase": "Aumentar em",
+ "decrease": "Diminuir em"
+ },
+ "dataCollect": {
+ "label": "Coleta de Dados"
+ },
+ "defaultReply": {
+ "description": "É a resposta padrão que seu workspace enviará aos usuários quando o workspace não souber como responder à mensagem do usuário.",
+ "label": "Resposta Padrão"
+ },
+ "delayType": {
+ "label": "Tipo de Atraso",
+ "placeholder": "Tipo de Atraso"
+ },
+ "delayUnit": {
+ "days": "Dias",
+ "hours": "Horas",
+ "minutes": "Minutos",
+ "seconds": "Segundos"
+ },
+ "description": {
+ "label": "Descrição",
+ "placeholder": "Insira a descrição"
+ },
+ "developmentMode": {
+ "description": "Seu bot funcionará apenas para administradores. Ative essa opção se você está construindo seu bot e não quer que não-admins usem.",
+ "label": "Modo de Desenvolvimento"
+ },
+ "email": {
+ "label": "E-mail",
+ "placeholder": "Insira o e-mail"
+ },
+ "embedCode": {
+ "description": "Copie e cole esse código no seu site para incorporar o widget de chat.",
+ "label": "Código de Incorporação",
+ "securityNote": "Nota de Segurança",
+ "securityNoteDescription": "Este widget só funcionará em domínios autorizados na configuração do seu webchat. Certifique-se de adicionar seu domínio à lista de domínios autorizados."
+ },
+ "processingStatus": {
+ "label": "Status do Processamento"
+ },
+ "enable": {
+ "label": "Ativar"
+ },
+ "file": {
+ "label": "Arquivo"
+ },
+ "filter": {
+ "label": "Filtro"
+ },
+ "finalMessage": {
+ "label": "Mensagem Final"
+ },
+ "firstName": {
+ "label": "Nome",
+ "placeholder": "Insira o nome"
+ },
+ "countryCode": {
+ "label": "Código do País"
+ },
+ "contactId": {
+ "label": "ID do Contato / ID do Assinante / PSID"
+ },
+ "flow": {
+ "label": "Fluxo"
+ },
+ "flowId": {
+ "label": "ID do Fluxo"
+ },
+ "flows": {
+ "aiGenerateText": {
+ "label": "{aiName}: Gerar Texto"
+ },
+ "label": "Fluxos",
+ "placeholder": "Selecione o fluxo"
+ },
+ "steps": {
+ "label": "Passos",
+ "placeholder": "Selecione o passo"
+ },
+ "folder": {
+ "label": "Pasta"
+ },
+ "format": {
+ "label": "Formato"
+ },
+ "framework": {
+ "angular": "Angular",
+ "ember": "Ember",
+ "react": "React",
+ "svelte": "Svelte",
+ "vue": "Vue"
+ },
+ "function": {
+ "label": "Função"
+ },
+ "gemini": {
+ "label": "Gemini"
+ },
+ "geminiModel": {
+ "label": "Modelo Gemini"
+ },
+ "gender": {
+ "female": "Feminino",
+ "label": "Gênero",
+ "male": "Masculino",
+ "unknown": "Desconhecido"
+ },
+ "googleSheets": {
+ "label": "Google Sheets"
+ },
+ "host": {
+ "label": "Host",
+ "placeholder": "smtp.example.com"
+ },
+ "hideHeader": {
+ "label": "Mostrar Cabeçalho"
+ },
+ "hideMessageInput": {
+ "label": "Mostrar Campo de Mensagem"
+ },
+ "inbox": {
+ "label": "Caixa de Entrada"
+ },
+ "inboxTeam": {
+ "label": "Time da Caixa de Entrada"
+ },
+ "teamMembers": {
+ "label": "Membros do Time"
+ },
+ "inboxTeamMember": {
+ "label": "Membro do Time da Caixa de Entrada"
+ },
+ "inputCustomField": {
+ "label": "Campo Personalizado de Entrada"
+ },
+ "insertLink": {
+ "label": "Inserir link"
+ },
+ "instructions": {
+ "label": "Mensagem do Sistema (Prompt)"
+ },
+ "isDefault": {
+ "label": "Marcar como padrão"
+ },
+ "country": {
+ "label": "País"
+ },
+ "language": {
+ "description": "Os contatos recebem o idioma padrão quando o idioma do contato é desconhecido.",
+ "english": "Inglês",
+ "label": "Idioma",
+ "placeholder": "Selecione o idioma",
+ "portuguese": "Português (Brasil)",
+ "vietnamese": "Vietnamita"
+ },
+ "lastName": {
+ "label": "Sobrenome",
+ "placeholder": "Insira o sobrenome"
+ },
+ "last30days": {
+ "label": "Últimos 30 dias"
+ },
+ "last7days": {
+ "label": "Últimos 7 dias"
+ },
+ "lastInput": {
+ "label": "Última Entrada"
+ },
+ "lastMonth": {
+ "label": "Mês passado"
+ },
+ "lifeTime": {
+ "label": "Vitalício"
+ },
+ "locale": {
+ "label": "Idioma"
+ },
+ "errorLog": {
+ "label": "Log de Erros"
+ },
+ "max": {
+ "label": "Máximo"
+ },
+ "maxOutputTokens": {
+ "label": "Máximo de Tokens"
+ },
+ "mcpServer": {
+ "label": "Servidor MCP"
+ },
+ "systemFunction": {
+ "label": "Função do Sistema"
+ },
+ "min": {
+ "label": "Mínimo"
+ },
+ "model": {
+ "label": "Modelo"
+ },
+ "name": {
+ "label": "Nome",
+ "placeholder": "Insira o nome",
+ "searchPlaceholder": "Buscar nome..."
+ },
+ "node": {
+ "label": "Nó"
+ },
+ "noResults": {
+ "label": "Nenhum resultado encontrado"
+ },
+ "notes": {
+ "label": "Notas"
+ },
+ "numericLength": {
+ "label": "Comprimento Numérico"
+ },
+ "numericValue": {
+ "label": "Valor Numérico"
+ },
+ "openai": {
+ "label": "OpenAI"
+ },
+ "operation": {
+ "label": "Operação"
+ },
+ "outputCustomField": {
+ "label": "Campo Personalizado de Saída"
+ },
+ "outputMessage": {
+ "label": "Mensagem de Saída",
+ "placeholder": "Qual é a mensagem de saída?"
+ },
+ "paymentHistory": {
+ "label": "Histórico de Pagamento"
+ },
+ "paymentMethods": {
+ "label": "Métodos de Pagamento"
+ },
+ "persistentMenu": {
+ "description": "Os menus persistentes são usados para iniciar uma conversa com um usuário.",
+ "label": "Menu{plural, select, 1 {} other {s}} Persistente{plural, select, 1 {} other {s}}",
+ "type": {
+ "openWebsite": "Abrir Site",
+ "sendFlow": "Enviar Fluxo"
+ }
+ },
+ "phoneNumber": {
+ "label": "Número de telefone"
+ },
+ "phoneNumberId": {
+ "label": "Número de telefone",
+ "noPhoneNumbersFound": "Nenhum número de telefone encontrado para esta conta"
+ },
+ "port": {
+ "label": "Porta",
+ "placeholder": "587"
+ },
+ "provider": {
+ "label": "Provedor"
+ },
+ "prompt": {
+ "label": "Prompt",
+ "placeholder": "Insira o prompt"
+ },
+ "userMessage": {
+ "label": "Mensagem do usuário"
+ },
+ "pageUserName": {
+ "label": "Nome de Usuário da Página"
+ },
+ "purpose": {
+ "label": "Propósito",
+ "placeholder": "O que esta função faz?"
+ },
+ "question": {
+ "label": "Pergunta",
+ "placeholder": "Você está aberto hoje?"
+ },
+ "imageProfileUrl": {
+ "label": "URL da Imagem de Perfil"
+ },
+ "persona": {
+ "label": "Persona"
+ },
+ "quickReply": {
+ "label": "Resposta Rápida"
+ },
+ "search": {
+ "placeholder": "Buscar..."
+ },
+ "logo": {
+ "label": "Logo"
+ },
+ "showLogo": {
+ "label": "Mostrar Logo Pessoal"
+ },
+ "size": {
+ "label": "Tamanho"
+ },
+ "status": {
+ "label": "Status",
+ "pending": "Pendente",
+ "processing": "Processando",
+ "success": "Sucesso",
+ "error": "Erro"
+ },
+ "subtitle": {
+ "placeholder": "Insira o subtítulo"
+ },
+ "syncToMessenger": {
+ "label": "Sincronizar com Messenger"
+ },
+ "tag": {
+ "label": "Etiqueta"
+ },
+ "targetCountry": {
+ "description": "O país onde a maioria dos seus contatos vive.",
+ "label": "País Alvo"
+ },
+ "team": {
+ "label": "Time"
+ },
+ "rememberConversation": {
+ "label": "Lembrar conversa"
+ },
+ "temperature": {
+ "label": "Temperatura"
+ },
+ "templateMessages": {
+ "label": "Mensagens de Template"
+ },
+ "text": {
+ "label": "Texto",
+ "placeholder": "Insira o texto"
+ },
+ "thisMonth": {
+ "label": "Este mês"
+ },
+ "timezone": {
+ "description": "Os contatos recebem este fuso horário quando o fuso do contato é desconhecido.",
+ "label": "Fuso Horário"
+ },
+ "title": {
+ "placeholder": "Insira o título"
+ },
+ "today": {
+ "label": "Hoje"
+ },
+ "tools": {
+ "label": "Ferramentas",
+ "placeholder": "Selecione ferramentas"
+ },
+ "triggerFlowId": {
+ "label": "ID do Fluxo Gatilho",
+ "placeholder": "Qual fluxo é disparado?"
+ },
+ "type": {
+ "label": "Tipo",
+ "placeholder": "Selecione o tipo"
+ },
+ "lastRead": {
+ "label": "Última Leitura"
+ },
+ "assignee": {
+ "label": "Responsável"
+ },
+ "modified": {
+ "label": "Modificado"
+ },
+ "estimatedContacts": {
+ "label": "Contatos Estimados"
+ },
+ "scheduledAt": {
+ "label": "Agendado para"
+ },
+ "channel": {
+ "label": "Canal"
+ },
+ "updatedAt": {
+ "label": "Atualizado em"
+ },
+ "url": {
+ "label": "URL",
+ "placeholder": "Insira a URL"
+ },
+ "userId": {
+ "label": "ID do Usuário"
+ },
+ "userTags": {
+ "label": "Tags do Usuário"
+ },
+ "username": {
+ "label": "Usuário",
+ "placeholder": "user@example.com"
+ },
+ "keywords": {
+ "label": "Palavras-chave"
+ },
+ "value": {
+ "label": "Valor"
+ },
+ "wabaId": {
+ "label": "WABA ID"
+ },
+ "webchat": {
+ "label": "Webchat"
+ },
+ "welcomeFlowId": {
+ "description": "A mensagem de boas-vindas é enviada automaticamente quando o usuário abre seu webchat.",
+ "label": "Fluxo de Boas-vindas"
+ },
+ "whatsapp": {
+ "label": "WhatsApp"
+ },
+ "whatsappFlow": {
+ "label": "WhatsApp Flow"
+ },
+ "shortText": {
+ "label": "Texto Curto",
+ "placeholder": "Insira o texto"
+ },
+ "number": {
+ "label": "Número",
+ "placeholder": "Insira o número"
+ },
+ "date": {
+ "label": "Data"
+ },
+ "datetime": {
+ "label": "Data e Hora"
+ },
+ "time": {
+ "label": "Hora"
+ },
+ "list": {
+ "label": "Lista (multi-seleção)"
+ },
+ "boolean": {
+ "label": "Booleano",
+ "placeholder": "Selecione verdadeiro ou falso",
+ "true": "Verdadeiro",
+ "false": "Falso"
+ },
+ "longText": {
+ "label": "Texto Longo"
+ },
+ "replyFormat": {
+ "label": "Formato da Resposta",
+ "number": "Número",
+ "text": "Texto",
+ "email": "E-mail",
+ "phone": "Telefone",
+ "image": "Imagem",
+ "file": "Arquivo",
+ "link": "Link",
+ "date": "Data",
+ "datetime": "Data e Hora",
+ "location": "Localização"
+ },
+ "workspaceMember": {
+ "label": "Membro do Workspace"
+ },
+ "yesterday": {
+ "label": "Ontem"
+ },
+ "permissions": {
+ "label": "Permissões",
+ "superAdmin": "Super Admin",
+ "analytics": "Análises",
+ "flows": "Fluxos",
+ "contacts": "Contatos",
+ "onlyAssignedContacts": "Apenas Contatos Atribuídos",
+ "emailAndPhone": "E-mail e Telefone",
+ "broadcast": "Transmissão",
+ "ecommerce": "E-commerce"
+ },
+ "member": {
+ "label": "Membro"
+ },
+ "schedule": {
+ "label": "Agendar",
+ "now": "Agora",
+ "scheduled": "Agendar para depois"
+ },
+ "inputFieldId": {
+ "label": "Campo Personalizado de Entrada"
+ },
+ "outputFieldId": {
+ "label": "Salvar resultado em campo personalizado"
+ },
+ "jsonPath": {
+ "placeholder": "Insira o caminho JSON"
+ },
+ "spreadsheets": {
+ "label": "Planilhas"
+ },
+ "retryMessage": {
+ "label": "Mensagem de Nova Tentativa"
+ },
+ "skipButtonLabel": {
+ "label": "Rótulo do Botão Pular"
+ },
+ "autoSkipTime": {
+ "label": "Tempo de Pulo Automático"
+ },
+ "autoSkipFailAttempts": {
+ "label": "Tentativas Falhas para Pulo Automático"
+ },
+ "autoSkip": {
+ "label": "Pulo Automático"
+ },
+ "spreadsheet": {
+ "label": "Planilha"
+ },
+ "source": {
+ "label": "Origem"
+ },
+ "password": {
+ "label": "Senha",
+ "placeholder": "••••••••"
+ },
+ "passwordConfirmation": {
+ "label": "Repita a senha"
+ },
+ "newPassword": {
+ "label": "Nova Senha"
+ },
+ "developerAccessToken": {
+ "label": "Token de Acesso da API"
+ },
+ "fullName": {
+ "label": "Nome Completo"
+ },
+ "botFields": {
+ "label": "Campos do Bot"
+ },
+ "keyword": {
+ "label": "Palavra-chave",
+ "searchPlaceholder": "Buscar palavra-chave..."
+ },
+ "templateId": {
+ "label": "Template do WhatsApp"
+ },
+ "whatsappChannel": {
+ "label": "Canal do WhatsApp"
+ },
+ "messages": {
+ "label": "Mensagens"
+ },
+ "shortcut": {
+ "label": "Atalho"
+ },
+ "savedReplies": {
+ "label": "Respostas Salvas"
+ },
+ "role": {
+ "label": "Função"
+ },
+ "plan": {
+ "label": "Plano"
+ },
+ "price": {
+ "label": "Preço"
+ },
+ "annualPrice": {
+ "label": "Preço Anual"
+ },
+ "limitContacts": {
+ "label": "Máximo de Contatos"
+ },
+ "marketingFeatures": {
+ "label": "Lista de recursos de marketing",
+ "description": "Uma lista de recursos do produto visíveis aos clientes. Exibida nas tabelas de preço."
+ },
+ "currency": {
+ "label": "Moeda"
+ },
+ "clientId": {
+ "label": "Client ID"
+ },
+ "clientSecret": {
+ "label": "Client Secret"
+ },
+ "verifyToken": {
+ "label": "Token de Verificação do Webhook"
+ },
+ "apiVersion": {
+ "label": "Versão da API"
+ },
+ "configId": {
+ "label": "ID de Configuração do App"
+ },
+ "systemUserId": {
+ "label": "ID do Usuário do Sistema"
+ },
+ "systemUserToken": {
+ "label": "Token do Usuário do Sistema"
+ },
+ "businessId": {
+ "label": "ID do Negócio"
+ },
+ "businessName": {
+ "label": "Nome do Negócio"
+ },
+ "publishableKey": {
+ "label": "Publishable Key"
+ },
+ "secretKey": {
+ "label": "Secret Key"
+ },
+ "freeTrial": {
+ "label": "Teste Grátis",
+ "suffix": "dias"
+ },
+ "pricing": {
+ "label": "Preços"
+ },
+ "sequence": {
+ "label": "Sequência"
+ },
+ "from": {
+ "label": "De"
+ },
+ "fromAddress": {
+ "label": "Endereço de Origem",
+ "placeholder": "noreply@example.com"
+ },
+ "messageTemplate": {
+ "label": "Template de Mensagem"
+ },
+ "preheader": {
+ "label": "Pré-cabeçalho"
+ },
+ "subject": {
+ "label": "Assunto"
+ },
+ "to": {
+ "label": "Para"
+ },
+ "topicId": {
+ "label": "ID do Tópico"
+ },
+ "smtpChannel": {
+ "label": "Canal SMTP"
+ },
+ "magicLink": {
+ "label": "Magic Link",
+ "nameHint": "Adicionado depois de /r/{workspaceId}/ na URL pública de rastreamento. Use apenas letras, números, underscore e hífen.",
+ "urlHint": "Use chaves duplas na URL, ex: https://example.com/page?utm_campaign='{{campaign}}'. Os valores vêm da query string do link de rastreamento."
+ },
+ "appId": {
+ "label": "ID do App"
+ },
+ "appSecret": {
+ "label": "App Secret"
+ },
+ "webhookVerifyToken": {
+ "label": "Token de Verificação do Webhook"
+ },
+ "heading": {
+ "label": "Título",
+ "placeholder": "Insira o título"
+ },
+ "image": {
+ "label": "Imagem"
+ },
+ "line": {
+ "label": "Linha"
+ },
+ "spacing": {
+ "label": "Espaçamento"
+ },
+ "code": {
+ "label": "Código",
+ "placeholder": "Insira o código"
+ },
+ "authCallbackUrl": {
+ "label": "URL de Callback de Autenticação"
+ },
+ "webhookUrl": {
+ "label": "URL do Webhook"
+ },
+ "addedAt": {
+ "label": "Data de adição"
+ },
+ "lastEditedAt": {
+ "label": "Última edição em"
+ },
+ "visibility": {
+ "label": "Visibilidade",
+ "alwaysShow": "Exibir sempre",
+ "alwaysHide": "Sempre ocultar",
+ "hideWhenEmpty": "Ocultar quando vazio"
+ },
+ "fieldId": {
+ "label": "ID do campo"
+ }
+ },
+ "flows": {
+ "title": "Fluxos",
+ "aiGenerateText": {
+ "label": "{name}: Gerar Texto com IA"
+ },
+ "actions": {
+ "addContactNotes": "Adicionar Notas ao Contato",
+ "addContactTag": "Adicionar Tag ao Contato",
+ "aiAnalyzeImage": "Analisar Imagem com IA",
+ "aiGenerateImage": "Gerar Imagem com IA",
+ "aiGenerateTextAgent": "Gerar Texto com Agente IA",
+ "aiSpeechToText": "IA: Fala para Texto",
+ "aiTextToSpeech": "IA: Texto para Fala",
+ "archiveConversation": "Arquivar Conversa",
+ "assignConversation": "Atribuir Conversa",
+ "autoAssignConversation": "Atribuir Conversa Automaticamente",
+ "blockContact": "Bloquear Contato",
+ "broadcastActions": "Ações de Transmissão",
+ "chooseChannel": "Escolher Canal",
+ "clearCustomField": "Limpar Campo Personalizado",
+ "contactActions": "Ações do Contato",
+ "countCharacters": "Contar Caracteres",
+ "deleteContact": "Excluir Contato",
+ "emailActions": "Ações de E-mail",
+ "flowActions": "Ações de Fluxo",
+ "followConversation": "Seguir Conversa",
+ "formatDate": "Formatar Data",
+ "generateCode": "Gerar Código",
+ "getDataFromJson": "Obter Dados do JSON",
+ "inboxActions": "Ações da Caixa de Entrada",
+ "markEmailVerified": "Marcar E-mail como Verificado",
+ "openWebsite": "Abrir Site",
+ "optInEmail": "Aceitar E-mail",
+ "optOutEmail": "Recusar E-mail",
+ "performAction": "Executar Ação",
+ "removeContactTag": "Remover Tag do Contato",
+ "sendAudio": "Enviar Áudio",
+ "sendCard": "Enviar Card",
+ "sendExternalFlow": "Iniciar Fluxo Externo",
+ "sendExternalNode": "Iniciar Nó Externo",
+ "sendFile": "Enviar Arquivo",
+ "sendGif": "Enviar Gif",
+ "sendImage": "Enviar Imagem",
+ "sendMessage": "Enviar Mensagem",
+ "sendNode": "Iniciar Outro Nó",
+ "sendText": "Enviar Texto",
+ "sendVideo": "Enviar Vídeo",
+ "sendWaTemplateMessage": "Mensagem de Template",
+ "whatsappOptionList": "Lista de Opções",
+ "noTemplatesAvailable": "Nenhum template disponível",
+ "setCustomField": "Definir Campo Personalizado",
+ "startAnotherNode": "Iniciar Outro Nó",
+ "startExternalFlow": "Iniciar Fluxo Externo",
+ "startExternalNode": "Iniciar Nó Externo",
+ "startFlow": "Iniciar Fluxo",
+ "subscribeBroadcast": "Inscrever em Transmissão",
+ "tools": "Ferramentas",
+ "transferConversationToBot": "Transferir Conversa para o Bot",
+ "transferConversationToHuman": "Transferir Conversa para Humano",
+ "unarchiveConversation": "Desarquivar Conversa",
+ "unassignConversation": "Desatribuir Conversa",
+ "unfollowConversation": "Deixar de Seguir Conversa",
+ "unsubscribeBroadcast": "Cancelar Inscrição da Transmissão",
+ "getUserData": "Obter Dados do Usuário",
+ "sendCarousel": "Enviar Carrossel",
+ "actions": "Ações",
+ "findGif": "Encontrar Gif",
+ "spreadsheetGetRow": "Obter Linha",
+ "spreadsheetUpdateRow": "Atualizar Linha",
+ "spreadsheetSendData": "Enviar Dados",
+ "spreadsheetGetRandomRow": "Obter Linha Aleatória",
+ "spreadsheetClearRow": "Limpar Linha",
+ "typing": "Digitando",
+ "sequenceActions": "Ações de Sequência",
+ "subscribeSequence": "Inscrever em Sequência",
+ "unsubscribeSequence": "Cancelar Inscrição da Sequência",
+ "splitTraffic": "Dividir Tráfego",
+ "updateContactLifecycleStage": "Atualizar Ciclo de Vida do Contato"
+ },
+ "splitTraffic": {
+ "balanceHint": "A soma total deve ser 100%.",
+ "addVariation": "Nova Variação"
+ },
+ "whatsappOptionList": {
+ "buttonLabel": "Rótulo do Botão",
+ "optionTitle": "Título",
+ "optionDescription": "Descrição",
+ "addOption": "Adicionar Nova",
+ "editButtonTitle": "Editar Botão",
+ "editOptionTitle": "Editar Opção"
+ },
+ "additionalSteps": "Passos adicionais",
+ "delayType": {
+ "datetimeCustomField": "Campo Personalizado de Data e Hora",
+ "datetimeCustomFieldValue": "{customField}",
+ "duration": "Duração",
+ "durationValue": "{duration}",
+ "date": "Data",
+ "specificDate": "Data Específica",
+ "specificDateValue": "{date}",
+ "random": "Aleatório"
+ },
+ "formatTimezone": {
+ "timezone": "Fuso Horário da Conta",
+ "contactTimezone": "Fuso Horário do Contato"
+ },
+ "fields": {
+ "selectTemplatePlaceholder": "Escolha um template do WhatsApp",
+ "preview": "Pré-visualização"
+ },
+ "operators": {
+ "append": "Adicionar ao final",
+ "prepend": "Adicionar ao início",
+ "set": "Definir como"
+ },
+ "wait": {
+ "datetimeTooltip": "Data e hora em que o fluxo será disparado.",
+ "setInterval": "Definir Intervalo",
+ "delayTypeLabel": "Este é um",
+ "fixed": "Fixo",
+ "randomized": "Aleatório",
+ "delay": "atraso",
+ "durationDetailPrefix": "Aguardar",
+ "dateDetailPrefix": "Aguardar até",
+ "customFieldDetailPrefix": "Aguardar até",
+ "randomDetailPrefix": "Aguardar entre",
+ "randomDetailAnd": "e",
+ "intervalDetailPrefix": "Ativo entre",
+ "offsetAdd": "+",
+ "offsetSubtract": "-",
+ "durationLabel": "Duração",
+ "activeHours": "Horas ativas",
+ "dateTypeLabel": "Tipo de data",
+ "datetimeLabel": "Data e Hora",
+ "customFieldLabel": "Campo personalizado",
+ "offset": "Adicionar deslocamento",
+ "offsetLabel": "Deslocamento",
+ "randomRangeLabel": "Intervalo aleatório",
+ "minLabel": "Mín",
+ "maxLabel": "Máx",
+ "unitLabel": "Unidade",
+ "specific": "Específico",
+ "dynamic": "Dinâmico"
+ }
+ },
+ "folders": {
+ "heading": {
+ "title": "Pastas"
+ }
+ },
+ "gemini": {
+ "autoReply": {
+ "description": "Ative a resposta automática com IA para responder naturalmente aos usuários com IA potencializada pelos seus dados de negócio.",
+ "label": "Resposta Automática com IA"
+ },
+ "connect": {
+ "description": "Responda aos usuários naturalmente com IA potencializada pelos seus dados de negócio.",
+ "label": "Google Gemini"
+ },
+ "title": "Google Gemini"
+ },
+ "general": {
+ "title": "Geral"
+ },
+ "googleSheets": {
+ "setting": {
+ "label": "Google Sheets",
+ "description": "Configuração da integração com Google Sheets"
+ },
+ "header": "Cabeçalho da Coluna",
+ "title": "Google Sheets"
+ },
+ "integrations": {
+ "title": "Integrações"
+ },
+ "organizationSettings": {
+ "title": "Configurações da Organização",
+ "errors": {
+ "giphyApiKeyRequired": "A API Key é obrigatória para configurar o GIPHY.",
+ "giphyApiKeyInvalid": "API Key do GIPHY inválida",
+ "googleClientSecretRequired": "O Client Secret é obrigatório para configurar o Google.",
+ "googleVerifyTokenRequired": "O Token de Verificação é obrigatório para configurar o Google.",
+ "messengerAppSecretRequired": "O App Secret é obrigatório para configurar o Messenger.",
+ "zaloAppSecretRequired": "O App Secret é obrigatório para configurar o Zalo.",
+ "stripeSecretKeyRequired": "A Secret Key é obrigatória para configurar o Stripe.",
+ "whatsappAppSecretRequired": "O App Secret é obrigatório para configurar o WhatsApp.",
+ "whatsappSystemUserTokenRequired": "O Token do Usuário do Sistema é obrigatório para configurar o WhatsApp."
+ }
+ },
+ "conversations": {
+ "searchInConversation": "Buscar nesta conversa",
+ "searchInConversationPlaceholder": "Buscar mensagens…",
+ "searchNoResults": "Sem resultados",
+ "searchPrevious": "Resultado anterior",
+ "searchNext": "Próximo resultado",
+ "composerHint": "Use '/' para snippets, '$' para variáveis, ':' para emoji",
+ "composerPlaceholder": "Digite uma mensagem",
+ "channelPrefix": "para:",
+ "todayLabel": "Hoje",
+ "yesterdayLabel": "Ontem",
+ "unassignedAgent": "Não atribuído",
+ "unnamedContact": "Contato sem nome"
+ },
+ "inboxSideRail": {
+ "details": "Detalhes do contato",
+ "channels": "Canais",
+ "activities": "Atividades",
+ "attachments": "Anexos"
+ },
+ "messages": {
+ "actions": {
+ "menu": "Mais opções",
+ "copy": "Copiar texto",
+ "copyLink": "Copiar link da mensagem",
+ "reply": "Responder",
+ "copied": "Mensagem copiada",
+ "linkCopied": "Link copiado"
+ },
+ "tooltip": {
+ "sent": "Enviado",
+ "delivered": "Entregue",
+ "read": "Lido",
+ "received": "Recebido",
+ "failed": "Falha"
+ },
+ "composer": {
+ "internalComment": "Comentário interno",
+ "addComment": "Adicionar comentário",
+ "exitInternal": "Voltar para mensagem",
+ "internalLabel": "Comentário interno",
+ "internalPlaceholder": "Use @ para mencionar um colega de equipe. Comentários só são visíveis para sua equipe.",
+ "internalHint": "Comentário visível apenas para sua equipe",
+ "channelHint": "Canal de envio da mensagem",
+ "formLabel": "Formulário de mensagem",
+ "textareaLabel": "Digite sua mensagem",
+ "emoji": "Emoji",
+ "emojiSearch": "Pesquisa",
+ "attach": "Anexar arquivo",
+ "snippets": "Atalhos rápidos",
+ "send": "Enviar mensagem"
+ },
+ "runningWorkflows": "Workflows em andamento",
+ "moveFeature": "Mover {feature}",
+ "poweredBy": "Desenvolvido por {name}",
+ "noGiphyApiKey": "Nenhuma API Key do GIPHY encontrada. Adicione uma API Key do GIPHY nas configurações da organização.",
+ "connectedSuccess": "{feature} conectado com sucesso",
+ "connectFailed": "Falha ao conectar {feature}",
+ "connectSuccess": "{feature} conectado com sucesso",
+ "success": "Sucesso",
+ "failed": "Falhou",
+ "copiedToClipboard": "Copiado para a área de transferência",
+ "copyFailed": "Falha ao copiar para a área de transferência",
+ "createdFailed": "Falha na criação de {feature}",
+ "deletedSuccess": "{feature} excluído com sucesso",
+ "deleteFileComingSoon": "Funcionalidade de exclusão de arquivo em breve",
+ "disconnectFeature": "Desconectar {feature}",
+ "disconnectFeatureDescription": "Tem certeza que deseja desconectar {feature}?",
+ "disconnectSuccess": "{feature} desconectado com sucesso",
+ "failedToCopy": "Falha ao copiar",
+ "failedToPreviewImage": "Falha ao pré-visualizar imagem",
+ "loadingData": "Carregando dados...",
+ "leaveEmptyToKeepSecret": "Deixe em branco para manter o segredo atual",
+ "needToAddSettings": "Você precisa adicionar configurações para usar esta integração",
+ "noDataAvailable": "Nenhum dado disponível",
+ "savedSuccessfully": "Salvo com sucesso",
+ "syncedSuccessfully": "Sincronizado com sucesso",
+ "nameAlreadyExists": "Já existe um {feature} com este nome",
+ "unknownError": "Erro desconhecido",
+ "updatedSuccess": "{feature} atualizado com sucesso",
+ "updateFileComingSoon": "Funcionalidade de atualização de arquivo em breve",
+ "videoError": "Erro de vídeo",
+ "createdSuccess": "{feature} criado com sucesso",
+ "createFeature": "Criar {feature}",
+ "noItemsFound": "Nenhum item encontrado",
+ "editFeature": "Editar {feature}",
+ "duplicateFeature": "Duplicar {feature}",
+ "deleteFeature": "Excluir {feature}",
+ "deleteConfirmation": "Tem certeza absoluta?\nEsta ação não pode ser desfeita.\nIsso vai excluir permanentemente seu {feature} dos nossos servidores.",
+ "addFeature": "Adicionar {feature}",
+ "signOutConfirmation": "Tem certeza que deseja sair?",
+ "copyInvitationUrlSuccess": "Copie o link abaixo e envie para a pessoa que você quer adicionar como admin.",
+ "noAIIntegrationFound": "Nenhuma integração de IA encontrada. Conecte uma integração de IA para usar agentes de IA.",
+ "resendFeature": "Reenviar {feature}",
+ "resendFeatureDescription": "Reenviar {feature} para os contatos que ainda não receberam.",
+ "resendSuccess": "Reenviado com sucesso",
+ "duplicateTitle": "Duplicar {feature}",
+ "duplicateDescription": "Tem certeza que deseja duplicar {feature}?\nEsta ação não pode ser desfeita.",
+ "duplicateSuccess": "{feature} duplicado com sucesso",
+ "changeDefault": "Alterar Padrão",
+ "changeDefaultDescription": "Tem certeza que deseja alterar o agente de IA padrão?",
+ "unsetDefaultDescription": "Tem certeza que deseja remover o agente de IA padrão?",
+ "botIsActive": "O bot está ativo",
+ "selectConversationTitle": "Selecione uma conversa",
+ "selectConversationDescription": "Escolha uma conversa da lista à esquerda para visualizar as mensagens e começar a conversar.",
+ "selectConversationContactTitle": "Nenhum contato selecionado",
+ "selectConversationContactDescription": "Selecione uma conversa para visualizar os detalhes do contato e as informações da caixa de entrada aqui.",
+ "archiveFeature": "Arquivar {feature}",
+ "archiveFeatureDescription": "Tem certeza que deseja arquivar {feature}?",
+ "disableFeature": "Desativar {feature}",
+ "disableFeatureDescription": "Tem certeza que deseja desativar {feature}?",
+ "enableFeature": "Ativar {feature}",
+ "enableFeatureDescription": "Tem certeza que deseja ativar {feature}?",
+ "exportedSuccess": "{feature} exportado com sucesso",
+ "createSuccess": "{feature} criado com sucesso",
+ "deleteFailed": "Falha ao excluir {feature}",
+ "deleteSuccess": "{feature} excluído com sucesso",
+ "error": "Erro",
+ "moveFolderDescription": "Mover {feature} para pasta",
+ "noData": "Sem dados",
+ "featureNotFound": "{feature} não encontrado",
+ "enableSuccess": "{feature} ativado com sucesso",
+ "userInactive": "Usuário inativo há mais de 7 dias"
+ },
+ "instagram": {
+ "description": "Esta integração permite criar bots no Instagram.",
+ "selectInstagramAccount": "Selecionar Conta do Instagram",
+ "iceBreakers": "Quebra-gelos",
+ "iceBreakersDescription": "Os quebra-gelos permitem que usuários iniciem uma conversa com seu bot através de uma lista de perguntas frequentes.",
+ "refreshPermissions": "Atualizar Permissões",
+ "title": "Instagram"
+ },
+ "messenger": {
+ "description": "Esta integração permite criar bots no Messenger.",
+ "welcomeMessage": "Mensagem de Boas-vindas",
+ "welcomeMessageDescription": "Esta é a primeira mensagem que o usuário receberá do seu bot. É enviada quando o usuário interage pela primeira vez clicando no botão 'Começar'.",
+ "conversationStarters": "Iniciadores de Conversa",
+ "conversationStartersDescription": "Os iniciadores de conversa oferecem uma forma do usuário começar uma conversa com seu negócio através de uma lista de perguntas frequentes.",
+ "persistentMenu": "Menu Persistente",
+ "persistentMenuDescription": "O menu pode ser configurado para ajudar os usuários a descobrir e acessar recursos do seu bot mais facilmente. O menu está sempre disponível e deve conter as ações principais que os usuários podem executar a qualquer momento. Ele comunica as capacidades básicas do bot tanto para iniciantes quanto para usuários recorrentes.",
+ "dynamicPersistentMenu": "Menu Persistente Dinâmico",
+ "dynamicPersistentMenuDescription": "Permite alterar dinamicamente o menu persistente a qualquer momento, em nível de usuário.",
+ "botProfile": "Perfil do Bot",
+ "botProfileDescription": "Esta configuração permite definir um nome e imagem diferentes para o seu bot",
+ "personas": "Personas",
+ "personasDescription": "Esta configuração permite definir um nome e imagem diferentes para o seu bot",
+ "defaultPersona": "Persona Padrão",
+ "refreshPermissions": "Atualizar Permissões",
+ "reconnect": "Reconectar",
+ "reconnectDescription": "Sempre reconecte seu bot se o bot do Messenger não estiver respondendo ou se aparecer algum erro de permissão no log de erros.",
+ "selectFacebookPage": "Selecionar Página do Facebook",
+ "title": "Facebook Messenger"
+ },
+ "omnichannel": {
+ "title": "Omnichannel"
+ },
+ "openai": {
+ "connect": {
+ "description": "Responda aos usuários naturalmente com IA potencializada pelos seus dados de negócio."
+ },
+ "title": "OpenAI"
+ },
+ "settings": {
+ "title": "Configurações"
+ },
+ "settingsGroups": {
+ "general": "Configurações gerais",
+ "workspace": "Workspace",
+ "apps": "Aplicativos",
+ "advanced": "Avançado",
+ "inbox": "Caixa de entrada",
+ "data": "Dados"
+ },
+ "lifecycle": {
+ "title": "Ciclo de vida",
+ "subtitle": "Acompanhe e gerencie contatos em cada etapa do seu processo de vendas",
+ "learnMore": "Saiba mais",
+ "toggleTitle": "Mostrar/ocultar etapas do ciclo de vida",
+ "toggleSubtitle": "Exibir etapas do ciclo de vida na caixa de entrada e nos módulos de contatos.",
+ "configureTitle": "Configurar etapas do ciclo de vida",
+ "activeTitle": "Etapas do ciclo de vida",
+ "activeSubtitle": "Organize e acompanhe contatos pelas etapas do funil ativo.",
+ "lostTitle": "Etapas de perda",
+ "lostSubtitle": "Acompanhe por onde os contatos saem do funil.",
+ "addStage": "Adicionar etapa",
+ "maxStagesReached": "Máximo de {max} etapas atingido (ativas + perdidas).",
+ "stageNumber": "Etapa {n}",
+ "stageName": "Nome da etapa",
+ "newStage": "Nova etapa",
+ "defaultBadge": "Padrão",
+ "saved": "Etapas salvas",
+ "saveError": "Erro ao salvar etapas",
+ "saving": "Salvando…",
+ "emptyNameError": "Todas as etapas precisam de nome",
+ "emojiSearch": "Pesquisar",
+ "emojiCategories": {
+ "suggested": "Usados recentemente",
+ "smileys": "Carinhas e emoções",
+ "animals": "Animais e natureza",
+ "food": "Comida e bebida",
+ "travel": "Viagem e lugares",
+ "activities": "Atividades",
+ "objects": "Objetos",
+ "symbols": "Símbolos",
+ "flags": "Bandeiras"
+ },
+ "deleteConfirm": "Apagar etapa \"{name}\"? Contatos nessa etapa ficarão sem etapa atribuída.",
+ "selectPlaceholder": "Selecionar etapa",
+ "noStage": "Sem etapa",
+ "nextStageTooltip": "Próxima etapa: {name}",
+ "lastStageTooltip": "Já está na última etapa do funil",
+ "contactLifecycle": "Etapa do contato",
+ "activeStages": "Etapas ativas",
+ "lostStages": "Etapas perdidas",
+ "contactUpdated": "Etapa do contato atualizada",
+ "contactUpdateError": "Erro ao atualizar etapa",
+ "bulkMove": "Mover etapa",
+ "bulkMoved": "{count} contato(s) movido(s)",
+ "deleteModalTitle": "Apagar etapa \"{name}\"",
+ "deleteModalDescription": "Esta etapa tem {count} contato(s). Escolha pra qual etapa migrar antes de apagar.",
+ "deleteModalReassignTo": "Migrar contatos para",
+ "deleteModalNoContacts": "Esta etapa não tem contatos. Confirme pra apagar.",
+ "deleteModalNoTarget": "Deixar sem etapa",
+ "deleteModalConfirm": "Apagar e migrar",
+ "deleteModalCancel": "Cancelar",
+ "stageBadgeLabel": "Etapa",
+ "activityLog": "Histórico de etapas",
+ "activityLogEmpty": "Sem mudanças de etapa registradas",
+ "menu": {
+ "setAsDefault": "Definir como padrão",
+ "delete": "Apagar"
+ },
+ "anyStage": "Qualquer estágio",
+ "triggerFromStage": "De (origem)",
+ "triggerToStage": "Para (destino)",
+ "stepHint": "Atualizar a etapa do ciclo de vida do contato para uma nova etapa.",
+ "stepUpdateAction": "Atualizar etapa",
+ "stepUpdateActionHint": "Atualizar etapa do contato para outra etapa",
+ "stepRemoveAction": "Remover etapa",
+ "stepRemoveActionHint": "Remove a etapa do contato",
+ "stepStageLabel": "Estágio do ciclo de vida"
+ },
+ "auth": {
+ "orContinueWith": "Ou continue com",
+ "dontHaveAnAccount": "Não tem uma conta?",
+ "alreadyHaveAnAccount": "Já tem uma conta?",
+ "signUp": "Cadastre-se",
+ "acceptTermsAndPolicy": "Ao clicar em continuar, você concorda com nossos",
+ "and": "e",
+ "privacyPolicy": "Política de Privacidade",
+ "termsOfService": "Termos de Serviço",
+ "signInTitle": "Entrar no {name}",
+ "forgotPasswordTitle": "Esqueceu sua senha?",
+ "forgotPasswordDescription": "Sem problemas! Vamos enviar um link com instruções para redefinir sua senha.",
+ "checkYourEmail": "Verifique seu e-mail",
+ "magicLinkSent": "Enviamos uma URL de verificação para seu e-mail",
+ "magicLinkSentDescription": "Clique no link no seu e-mail para continuar.",
+ "didNotReceiveEmail": "Não recebeu o e-mail? Verifique sua caixa de spam.",
+ "trySigningInAgain": "Você também pode tentar entrar novamente com um e-mail diferente.",
+ "signIn": "Entrar",
+ "signUpSuccess": "Cadastro realizado com sucesso. Verifique seu e-mail para confirmação.",
+ "forgotPassword": "Esqueceu a senha?",
+ "rememberedPassword": "Lembrou sua senha?",
+ "forgotPasswordEmailSent": "Enviamos um e-mail com instruções para redefinir sua senha.",
+ "magicLinkSentTitle": "Magic link enviado",
+ "passwordResetSuccess": "Senha redefinida com sucesso",
+ "resetPasswordTitle": "Redefinir sua senha",
+ "resetPasswordDescription": "Insira sua nova senha abaixo.",
+ "signUpTitle": "Cadastre-se em {name}"
+ },
+ "tags": {
+ "title": "Etiquetas",
+ "subtitle": "Crie e gerencie etiquetas para organizar conversas e contatos",
+ "formDescription": "Configure a aparência da etiqueta — escolha emoji, cor e descrição.",
+ "namePlaceholder": "Ex: Lead quente",
+ "emojiLabel": "Emoji",
+ "clearEmoji": "Remover emoji",
+ "colorLabel": "Cor",
+ "descriptionLabel": "Descrição",
+ "descriptionPlaceholder": "Opcional — pra que serve essa etiqueta?",
+ "preview": "Pré-visualização",
+ "addTag": "Adicionar etiqueta",
+ "searchPlaceholder": "Pesquisar etiquetas",
+ "emptyState": "Nenhuma etiqueta encontrada",
+ "delete": {
+ "usageDescription": "Você está prestes a excluir {tagCount, plural, =1 {1 etiqueta} other {# etiquetas}} atribuída(s) a {contactCount, plural, =1 {1 contato} other {# contatos}}. Essa ação remove a etiqueta de todos os contatos e não pode ser desfeita.",
+ "confirmInputLabel": "Para confirmar, digite {count} abaixo:"
+ }
+ },
+ "texts": {
+ "or": "ou"
+ },
+ "theme": {
+ "dark": "Escuro",
+ "light": "Claro",
+ "system": "Sistema"
+ },
+ "validation": {
+ "maxSize": "O tamanho do arquivo excede o limite máximo",
+ "maxItemsReached": "Máximo de {max} {feature} permitido por workspace",
+ "invalidApiKey": "API Key inválida"
+ },
+ "webchat": {
+ "description": "Permita que seus clientes recebam respostas automáticas instantâneas direto do seu site",
+ "title": "Webchat"
+ },
+ "whatsapp": {
+ "category": {
+ "authentication": {
+ "description": "Códigos OTP, verificação em 2 etapas e mensagens de autenticação.",
+ "label": "Autenticação"
+ },
+ "marketing": {
+ "description": "Descrição da categoria Marketing",
+ "label": "Marketing"
+ },
+ "utility": {
+ "description": "Descrição da categoria Utilidade",
+ "label": "Utilidade"
+ }
+ },
+ "connectionType": {
+ "embedded": "Embedded Signup",
+ "manual": "Conexão manual"
+ },
+ "list": {
+ "empty": "Nenhum WhatsApp conectado ainda."
+ },
+ "templatePicker": {
+ "empty": "Nenhum template aprovado disponível.",
+ "preview": "Pré-visualização",
+ "sent": "Template enviado",
+ "title": "Enviar template",
+ "variables": "Variáveis"
+ },
+ "webhook": {
+ "pending": "Webhook pendente",
+ "verified": "Webhook verificado"
+ },
+ "windowAlert": {
+ "description": "A Meta não permite o envio de mensagens após 24 horas da última resposta do contato. Para continuar, envie um modelo de mensagem aprovado ou aguarde uma nova resposta do contato para voltar a enviar mensagens normalmente.",
+ "learnMore": "Saiba mais",
+ "openTemplate": "Abrir lista de templates",
+ "sendTemplateButton": "Enviar modelo de mensagem",
+ "title": "Janela de mensagens do WhatsApp fechada"
+ },
+ "commands": {
+ "description": "São palavras-chave especiais que dizem ao bot do WhatsApp o que fazer",
+ "label": "Comandos"
+ },
+ "connectExisting": "Conectar uma Conta Business do WhatsApp existente",
+ "icebreakers": {
+ "description": "São perguntas comuns que as pessoas podem facilmente fazer a você.",
+ "label": "Quebra-gelos"
+ },
+ "manualConnect": "Conexão manual usando token de acesso do Usuário do Sistema.",
+ "manualOnboarding": {
+ "title": "Sua caixa de entrada do WhatsApp está pronta!",
+ "description": "Configure a URL do webhook e o token de verificação no seu Meta App. Use os valores abaixo.",
+ "webhookUrl": "URL do Webhook",
+ "verifyToken": "Token de verificação do webhook",
+ "qrCodeHint": "Escaneie o QR code para abrir a URL do webhook",
+ "moreSettings": "Mais configurações",
+ "goToInbox": "Ir para a caixa de entrada"
+ },
+ "marketingMessageLite": "Marketing Message Lite",
+ "messageTemplate": {
+ "carouselImage": {
+ "label": "Imagem do Carrossel"
+ },
+ "carouselVideo": {
+ "label": "Vídeo do Carrossel"
+ },
+ "document": {
+ "label": "Documento"
+ },
+ "image": {
+ "label": "Imagem"
+ },
+ "location": {
+ "label": "Localização"
+ },
+ "text": {
+ "label": "Texto"
+ },
+ "video": {
+ "label": "Vídeo"
+ },
+ "viewCatalog": {
+ "label": "Ver Catálogo"
+ },
+ "viewProduct": {
+ "label": "Ver Produto"
+ },
+ "params": {
+ "couponCode": "Código de Cupom",
+ "couponCodePlaceholder": "Insira o código do cupom",
+ "quickReplyPayload": "Payload de Resposta Rápida",
+ "quickReplyPayloadPlaceholder": "Insira o payload (opcional)",
+ "flowToken": "Token do Flow",
+ "flowTokenPlaceholder": "Insira o token do flow",
+ "catalogProductId": "ID do Produto",
+ "catalogProductIdPlaceholder": "Insira o retailer ID do produto",
+ "carouselCard": "Card {index}",
+ "limitedTimeOffer": "Hora de Expiração da Oferta",
+ "limitedTimeOfferPlaceholder": "Insira a hora de expiração em milissegundos",
+ "limitedTimeOfferHelp": "Timestamp Unix em milissegundos de quando a oferta expira",
+ "location": "Localização",
+ "latitude": "Latitude",
+ "longitude": "Longitude",
+ "locationName": "Nome do local (opcional)",
+ "locationAddress": "Endereço (opcional)"
+ }
+ },
+ "sampleBodyCardContent": {
+ "label": "Conteúdo de exemplo do corpo do card"
+ },
+ "sampleBodyContent": {
+ "label": "Conteúdo de exemplo do corpo"
+ },
+ "sampleHeaderContent": {
+ "label": "Conteúdo de exemplo do cabeçalho"
+ },
+ "showFooter": {
+ "label": "Mostrar rodapé"
+ },
+ "tabs": {
+ "accountHealths": "Saúde da Conta",
+ "automation": "Automação",
+ "ecommerce": "E-commerce",
+ "flows": "Fluxos",
+ "messageTemplates": "Templates de Mensagem",
+ "profile": "Perfil",
+ "usefulLinks": "Links Úteis"
+ },
+ "accountHealths": {
+ "title": "Gerencie sua conta do WhatsApp",
+ "description": "Revise o status da sua conta WhatsApp, limites de mensagens e qualidade. Atualize configurações ou resolva problemas se necessário",
+ "goToBusinessManager": "Ir para o Meta Business Manager",
+ "fields": {
+ "displayPhoneNumber": {
+ "label": "Número de telefone exibido",
+ "tooltip": "O número de telefone mostrado aos clientes quando eles enviam mensagem ao seu negócio."
+ },
+ "businessName": {
+ "label": "Nome do negócio",
+ "tooltip": "O nome de exibição verificado do WhatsApp Business."
+ },
+ "displayNameStatus": {
+ "label": "Status do nome de exibição",
+ "tooltip": "Status de aprovação do nome de exibição do WhatsApp Business."
+ },
+ "qualityRating": {
+ "label": "Avaliação de qualidade",
+ "tooltip": "Avaliação de qualidade baseada no feedback de clientes nos últimos 7 dias."
+ },
+ "messagingLimitTier": {
+ "label": "Nível de limite de mensagens",
+ "tooltip": "Número máximo de clientes únicos que seu negócio pode enviar mensagens em uma janela de 24 horas."
+ },
+ "accountMode": {
+ "label": "Modo da conta",
+ "tooltip": "Indica se a Conta WhatsApp Business está ativa ou em modo sandbox."
+ },
+ "marketingMessages": {
+ "label": "Marketing Messages (MM Lite)",
+ "tooltip": "Status de onboarding da Conta WhatsApp Business na API Marketing Messages Lite.",
+ "status": {
+ "INELIGIBLE_ON_BEHALF_OF_WABA": "Inelegível: modelo OBO",
+ "INELIGIBLE_INACTIVE_OR_RESTRICTED": "Inelegível: inativo ou restrito",
+ "INELIGIBLE_COUNTRY_NOT_SUPPORTED": "Inelegível: país não suportado",
+ "INELIGIBLE_USING_WHATSAPP_BUSINESS_APP": "Inelegível: usando WhatsApp Business app",
+ "ELIGIBLE": "Elegível",
+ "PENDING_VALID_PAYMENT_METHOD": "Aguardando método de pagamento válido",
+ "PENDING_INTERNAL_SETUP": "Configuração interna pendente",
+ "ONBOARDED": "Onboarding concluído"
+ },
+ "description": {
+ "INELIGIBLE_ON_BEHALF_OF_WABA": "A Conta WhatsApp Business usa o modelo OBO, que não é suportado. Você deve primeiro transferir a propriedade da WABA para o cliente ou fazer onboarding via Intent API.",
+ "INELIGIBLE_INACTIVE_OR_RESTRICTED": "A Conta WhatsApp Business está inativa ou restrita de enviar mensagens devido a um problema de aplicação de política.",
+ "INELIGIBLE_COUNTRY_NOT_SUPPORTED": "A Conta WhatsApp Business está em uma região que não suporta a API MM Lite.",
+ "INELIGIBLE_USING_WHATSAPP_BUSINESS_APP": "O número de telefone do WhatsApp Business está sendo usado dentro do app WhatsApp Business.",
+ "ELIGIBLE": "A Conta WhatsApp Business é elegível para fazer onboarding e usar a API MM Lite.",
+ "PENDING_VALID_PAYMENT_METHOD": "A Conta WhatsApp Business precisa configurar um método de pagamento válido.",
+ "PENDING_INTERNAL_SETUP": "A Conta WhatsApp Business está sendo configurada para usar a API MM Lite e não requer mais nenhuma ação do cliente ou parceiro. O processo automatizado leva menos de dez minutos. Inscreva-se no webhook account_update e ouça o evento AD_ACCOUNT_LINKED. Abra um ticket de suporte se a configuração demorar mais de dez minutos e você não receber um webhook.",
+ "ONBOARDED": "A Conta WhatsApp Business fez onboarding com sucesso e está pronta para usar a API MM Lite."
+ }
+ },
+ "webhookConfiguration": {
+ "label": "Configuração do Webhook",
+ "tooltip": "Status do webhook configurado para receber eventos do WhatsApp.",
+ "configured": "Webhook configurado com sucesso",
+ "notConfigured": "Webhook não configurado"
+ },
+ "phoneNumberHealth": {
+ "label": "Saúde do Número de Telefone",
+ "tooltip": "Indica se este número pode enviar mensagens e quais problemas o afetam.",
+ "headline": "Número de Telefone - {status}"
+ },
+ "wabaHealth": {
+ "label": "Saúde da Conta WhatsApp Business",
+ "tooltip": "Indica se a Conta WhatsApp Business pode enviar mensagens e quais problemas a afetam.",
+ "headline": "Conta WhatsApp Business (WABA ID: {id}) - {status}",
+ "headlineNoId": "Conta WhatsApp Business - {status}"
+ }
+ },
+ "messagingLimitTier": {
+ "TIER_50": "50 clientes por 24h",
+ "TIER_250": "250 clientes por 24h",
+ "TIER_1K": "1.000 clientes por 24h",
+ "TIER_10K": "10.000 clientes por 24h",
+ "TIER_100K": "100.000 clientes por 24h",
+ "TIER_UNLIMITED": "Clientes ilimitados por 24h",
+ "UNKNOWN": "Desconhecido"
+ },
+ "displayNameStatus": {
+ "APPROVED": "Aprovado",
+ "AVAILABLE_WITHOUT_REVIEW": "Disponível Sem Revisão",
+ "DECLINED": "Recusado",
+ "EXPIRED": "Expirado",
+ "NONE": "Nenhum",
+ "PENDING_REVIEW": "Aguardando Revisão"
+ },
+ "accountMode": {
+ "LIVE": "Ativo",
+ "SANDBOX": "Sandbox"
+ },
+ "canSendMessage": {
+ "AVAILABLE": "DISPONÍVEL",
+ "LIMITED": "LIMITADO",
+ "BLOCKED": "BLOQUEADO",
+ "UNKNOWN": "DESCONHECIDO"
+ },
+ "health": {
+ "label": "Status do Negócio",
+ "tooltip": "Visão geral da saúde da sua conta WhatsApp e capacidade de envio de mensagens.",
+ "blockedMessage": "Seu número de telefone foi bloqueado de enviar mensagens.",
+ "problem": "Problema:",
+ "possibleSolution": "Solução possível:",
+ "noIssues": "Nenhum problema detectado",
+ "entityHeadline": "{type} (ID: {id})",
+ "entityHeadlineNoId": "{type}",
+ "entityTooltip": "Indica se este {type} pode enviar mensagens e quais problemas o afetam.",
+ "entityTypes": {
+ "PHONE_NUMBER": "Número de Telefone",
+ "WABA": "Conta WhatsApp Business",
+ "BUSINESS": "Negócio",
+ "MESSAGE_TEMPLATE": "Template de Mensagem",
+ "APP": "App"
+ }
+ }
+ },
+ "templateFooter": {
+ "label": "Rodapé do Template"
+ },
+ "templateHeader": {
+ "label": "Cabeçalho do Template"
+ },
+ "transferPhoneNumber": "Transferir telefone de outro provedor WhatsApp"
+ },
+ "zalo": {
+ "title": "Zalo OA",
+ "refreshPermissions": "Atualizar permissões",
+ "duplicated": "Esta conta Zalo OA já está conectada a outro workspace."
+ },
+ "telegram": {
+ "description": "Esta integração permite criar bots no Telegram.",
+ "title": "Telegram"
+ },
+ "invitation": {
+ "chatbotInvitationDescription1": "Entre em {chatbotName} em {organizationName}",
+ "chatbotInvitationDescription2": "{userName} convidou você para entrar em {chatbotName} em {organizationName}.",
+ "organizationInvitationDescription1": "Entre em {organizationName}",
+ "organizationInvitationDescription2": "{userName} convidou você para entrar em {organizationName}."
+ },
+ "inboxTeams": {
+ "title": "Configurações de equipe",
+ "subtitle": "Criar e gerenciar equipes para agrupar os usuários em seu espaço de trabalho.",
+ "addTeam": "Adicionar Equipe",
+ "searchPlaceholder": "Pesquisar Configurações de equipe",
+ "manage": "Administrar",
+ "delete": "Apagar",
+ "createDialog": {
+ "title": "Adicionar Equipe",
+ "description": "Crie uma equipe para agrupar usuários. As equipes podem ser usadas no lugar de usuários para compartilhar recursos como Visualizações."
+ },
+ "manageDialog": {
+ "title": "Administrar Equipe",
+ "description": "Gerencie a equipe renomeando-a, alterando sua descrição e adicionando ou removendo membros da equipe."
+ },
+ "deleteDialog": {
+ "title": "Apagar Equipe",
+ "description": "Tem certeza que deseja apagar a equipe \"{name}\"? Esta ação não pode ser desfeita. Os membros não serão excluídos.",
+ "confirm": "Apagar"
+ },
+ "fields": {
+ "name": "Nome da Equipe",
+ "namePlaceholder": "Adicionar o nome da equipe aqui, por exemplo, Equipe de Marketing",
+ "description": "Descrição da equipe",
+ "descriptionPlaceholder": "Adicione a descrição da equipe aqui, por exemplo, Gerencie contatos de marketing.",
+ "members": "Membros da equipe",
+ "membersPlaceholder": "Adicione um usuário aqui, por exemplo, John."
+ },
+ "membersCount": "{count, plural, =0 {Nenhum membro} =1 {1 membro} other {# membros}}",
+ "save": "Guardar"
+ },
+ "webhooks": {
+ "title": "Webhooks"
+ },
+ "reflinks": {
+ "title": "Links de Entrada",
+ "description": "Links com palavras-chave que iniciam fluxos e capturam payloads de ref dos canais."
+ },
+ "magicLinks": {
+ "title": "Magic Links",
+ "description": "Crie deep links que redirecionam para sua URL com parâmetros de query."
+ },
+ "QRCode": {
+ "title": "QR Code",
+ "description": "Compartilhe este código para que as pessoas abram seu link de rastreamento."
+ },
+ "trigger": {
+ "when": "Quando isso acontecer",
+ "shortcutMenu": {
+ "button": "Atalhos",
+ "title": "Disparar fluxo",
+ "loading": "Carregando atalhos...",
+ "empty": "Nenhum atalho configurado. Crie um fluxo com gatilho de atalho em Fluxos.",
+ "fired": "Atalho disparado",
+ "fireError": "Falha ao disparar o atalho"
+ },
+ "conditions": {
+ "tagApplied": "Tag Aplicada",
+ "tagRemoved": "Tag Removida",
+ "dateTimeBasedTrigger": "Gatilho Baseado em Data/Hora",
+ "customFieldValueChanged": "Campo Personalizado Alterado",
+ "conversationTransferredToHuman": "Conversa transferida para humano",
+ "conversationTransferredToBot": "Conversa transferida para bot",
+ "newContact": "Novo Contato",
+ "contactUnsubscribedFormBroadcast": "Contato cancelou inscrição em Transmissão",
+ "archived": "Arquivado",
+ "followUp": "Follow Up",
+ "conversationAssigned": "Conversa Atribuída",
+ "conversationUnassigned": "Conversa Desatribuída",
+ "incomingCall": "Chamada Recebida",
+ "missedAudioCall": "Chamada de Áudio Perdida",
+ "callEnded": "Chamada Encerrada",
+ "ticketCreated": "Ticket Criado",
+ "ticketMovedToStage": "Ticket Movido para Etapa",
+ "ticketValueChanged": "Valor do Ticket Alterado",
+ "ticketStatusChanged": "Status do Ticket Alterado",
+ "ticketPriorityChanged": "Prioridade do Ticket Alterada",
+ "subscribedToSequence": "Inscrito em Sequência",
+ "unsubscribedFromSequence": "Cancelou Inscrição da Sequência",
+ "WhatsappShoppingCartSent": "Carrinho de Compras do WhatsApp Enviado",
+ "userAskedAboutProduct": "Usuário Perguntou Sobre Produto",
+ "cartAbandoned": "Carrinho Abandonado",
+ "newOrder": "Novo Pedido",
+ "orderAccepted": "Pedido Aceito",
+ "orderShipped": "Pedido Enviado",
+ "orderConcluded": "Pedido Concluído",
+ "orderCancelled": "Pedido Cancelado",
+ "categoryAddedToCart": "Categoria Adicionada ao Carrinho",
+ "productAddedToCart": "Produto Adicionado ao Carrinho",
+ "productRemovedFromCart": "Produto Removido do Carrinho",
+ "productOrdered": "Produto Pedido",
+ "contactReferredANewContact": "Contato Indicou um Novo Contato",
+ "contactReferredExistingContact": "Contato Indicou Contato Existente",
+ "lifecycleStageChanged": "Estágio do ciclo de vida alterado"
+ },
+ "actions": {
+ "addTag": "Adicionar Tag",
+ "removeTag": "Remover Tag",
+ "setCustomField": "Definir Campo Personalizado",
+ "clearCustomField": "Limpar Campo Personalizado",
+ "startAnotherFlow": "Iniciar Outro Fluxo",
+ "notifyAdmins": "Notificar Admins",
+ "transferConversationToHuman": "Transferir Conversa para Humano"
+ },
+ "triggerGoesOff": "Gatilho dispara",
+ "before": "Antes",
+ "after": "Depois",
+ "atTheDayOf": "No dia de",
+ "day": "Dia",
+ "hour": "Hora",
+ "minute": "Minuto",
+ "at": "Às",
+ "flowNode": {
+ "helperText": "Todo fluxo começa com um gatilho. Quando o evento configurado acontece, este fluxo é disparado automaticamente para o contato.",
+ "typeLabel": "Tipo de gatilho",
+ "typePlaceholder": "Selecione um gatilho",
+ "noFilters": "Sem filtros — dispara em qualquer evento deste tipo. Para refinar, mude o tipo ou use um passo de condição depois do gatilho.",
+ "anyOption": "Qualquer",
+ "conversationOpened": {
+ "label": "Conversa aberta",
+ "hint": "Dispara quando uma conversa é aberta com o contato (nova ou reaberta após arquivamento)."
+ },
+ "conversationClosed": {
+ "label": "Conversa fechada",
+ "hint": "Dispara quando uma conversa é encerrada."
+ },
+ "contactFieldUpdated": {
+ "label": "Campo de contato atualizado",
+ "hint": "Dispara quando qualquer campo de contato é alterado.",
+ "fieldsLabel": "Campos que disparam o gatilho",
+ "empty": "Nenhum campo personalizado configurado neste workspace."
+ },
+ "contactTagUpdated": {
+ "label": "Etiqueta de contato atualizada",
+ "hint": "Dispara quando uma etiqueta é adicionada ou removida do contato.",
+ "actionLabel": "Ação na etiqueta",
+ "actionAny": "Qualquer (adicionada ou removida)",
+ "actionAdded": "Quando etiqueta for adicionada",
+ "actionRemoved": "Quando etiqueta for removida",
+ "tagsLabel": "Etiquetas que disparam o gatilho",
+ "empty": "Nenhuma etiqueta cadastrada neste workspace."
+ },
+ "shortcut": {
+ "label": "Atalho",
+ "hint": "Dispara quando um atendente clica num atalho dentro do Inbox.",
+ "iconLabel": "Ícone (emoji)",
+ "iconHint": "Aparece no botão do atalho dentro do Inbox.",
+ "labelLabel": "Texto do botão",
+ "labelPlaceholder": "Ex: Reenviar oferta",
+ "note": "Este gatilho não dispara automaticamente. Ele aparece como um botão no Inbox para o atendente clicar quando quiser rodar este fluxo manualmente."
+ },
+ "lifecycleStageChanged": {
+ "label": "Ciclo de vida atualizado",
+ "hint": "Dispara quando a etapa do ciclo de vida do contato muda.",
+ "toStagesLabel": "Para etapas (destino)",
+ "fromStagesLabel": "De etapas (origem)",
+ "triggerOnCleared": "Disparar também quando a etapa for removida (limpa)",
+ "loading": "Carregando etapas...",
+ "emptyStages": "Nenhuma etapa de ciclo de vida configurada neste workspace."
+ }
+ }
+ },
+ "errorLogs": {
+ "title": "Logs de Erro"
+ },
+ "developerAccessToken": {
+ "title": "Token de Acesso da API ChatbotX",
+ "description": "Gere um token para permitir que seu workspace acesse as APIs do ChatbotX. Mantenha este token em segredo e não compartilhe com ninguém."
+ },
+ "contacts": {
+ "title": "Contatos",
+ "fieldsTitle": "Campos de contatos",
+ "admin": "Administrar",
+ "details": "Detalhes do contato",
+ "channels": "Canais",
+ "tagsTitle": "Etiquetas",
+ "ownerLabel": "Administrador",
+ "noTags": "Sem etiquetas",
+ "addTag": "Adicionar etiqueta",
+ "searchTag": "Pesquisar e selecionar tags",
+ "hiddenFields": "Campos de contato ocultos",
+ "delete": {
+ "usageDescription": "Você vai excluir {contactCount, plural, one {# contato} other {# contatos}} com {tagCount, plural, one {# etiqueta} other {# etiquetas}} e {conversationCount, plural, one {# conversa} other {# conversas}} vinculadas. Mensagens, notas e eventos relacionados também serão removidos. Digite o total para confirmar.",
+ "confirmInputLabel": "Digite {count, number} para confirmar"
+ },
+ "columnsPicker": {
+ "button": "Colunas",
+ "title": "Exibir colunas",
+ "showAll": "Mostrar todas",
+ "hideAll": "Ocultar todas"
+ },
+ "unmerge": {
+ "loading": "Carregando contatos fundidos…",
+ "bannerTitle": "{count, plural, one {# contato fundido aqui} other {# contatos fundidos aqui}}",
+ "warning": "Desfazer a fusão restaura o contato. Conversas, etiquetas e campos personalizados que foram transferidos continuam neste contato principal — mova de volta manualmente se necessário.",
+ "button": "Desfazer fusão",
+ "successToast": "Contato restaurado"
+ },
+ "visibility": {
+ "customize": "Personalizar visualização",
+ "title": "Personalizar visualização",
+ "description": "Personalize a visibilidade dos campos de contato no painel direito do Inbox. A alteração será aplicada a todos os usuários do espaço de trabalho.",
+ "fieldHeader": "Campo de contato",
+ "visibilityHeader": "Visibilidade",
+ "showAlways": "Exibir sempre",
+ "alwaysHide": "Sempre ocultar",
+ "saved": "Visualização atualizada"
+ },
+ "column": {
+ "channels": "Canal(ais)",
+ "country": "País/região",
+ "language": "Idioma",
+ "conversationStatus": "Status da conversa",
+ "lastMessage": "Última mensagem",
+ "statusOpen": "Abertas",
+ "statusClosed": "Fechadas"
+ },
+ "merge": {
+ "action": "Mesclar contatos",
+ "title": "Mesclar contatos",
+ "description": "Escolha qual contato fica como principal. Os outros serão mesclados sob ele (tags, campos personalizados e conversas migram pro principal) e depois excluídos.",
+ "selectPrimary": "Contato principal",
+ "unnamed": "sem nome",
+ "warning": "{count} duplicata(s) será(ão) excluída(s) após a mesclagem. Esta ação não pode ser desfeita.",
+ "confirm": "Mesclar",
+ "success": "{count} contato(s) mesclado(s)"
+ },
+ "sidebar": {
+ "all": "Todos os contatos",
+ "blocked": "Contatos bloqueados"
+ },
+ "drawer": {
+ "title": "Detalhes do contato",
+ "openInInbox": "Abrir no Inbox"
+ },
+ "placeholders": {
+ "addFirstName": "Adicione um nome",
+ "addLastName": "Adicione um sobrenome",
+ "addEmail": "Adicione um e-mail",
+ "selectAssignee": "Selecione um responsável"
+ }
+ },
+ "auditLogs": {
+ "title": "Logs de Atividade",
+ "subtitle": "Histórico completo de ações realizadas no espaço de trabalho.",
+ "searchPlaceholder": "Pesquisar por detalhe, ação ou usuário",
+ "filterUser": "Todos os usuários",
+ "filterUserAll": "Todos os usuários",
+ "filterAction": "Todas as ações",
+ "filterActionAll": "Todas as ações",
+ "exportCsv": "Exportar CSV",
+ "exportSuccess": "Logs exportados com sucesso.",
+ "exportError": "Falha ao exportar logs.",
+ "empty": "Nenhuma atividade registrada ainda.",
+ "totalRows": "{count, plural, =0 {Nenhum registro} =1 {1 registro} other {# registros}}",
+ "columns": {
+ "user": "Usuário",
+ "action": "Ação",
+ "detail": "Detalhe",
+ "when": "Data/Hora"
+ }
+ },
+ "customFields": {
+ "title": "Campos Personalizados",
+ "subtitle": "Crie e gerencie campos personalizados para enriquecer os perfis dos seus contatos",
+ "addValue": "Adicione um valor para {field}",
+ "valuesLabel": "Opções",
+ "valuesPlaceholder": "Digite e Enter para adicionar",
+ "emptyOptions": "Sem opções configuradas",
+ "defaultsTitle": "Campos padrão do contato",
+ "defaultsTooltip": "Esses 7 campos vêm do sistema e estão disponíveis em todo contato. Não podem ser editados ou removidos — apenas a visibilidade no Inbox pode ser ajustada em \"Personalizar visualização\".",
+ "defaultsLockTooltip": "Campo padrão — não editável"
+ },
+ "keywords": {
+ "title": "Palavras-chave"
+ },
+ "triggers": {
+ "title": "Gatilhos"
+ },
+ "users": {
+ "title": "Usuários"
+ },
+ "organizationUsers": {
+ "pageTitle": "Configurações de administrador",
+ "pageSubtitle": "Gerencie os usuários da sua organização aqui.",
+ "addButton": "Adicionar Usuário",
+ "searchPlaceholder": "Pesquisar usuários",
+ "empty": "Nenhum usuário encontrado.",
+ "edit": "Alterar",
+ "delete": "Apagar",
+ "addTitle": "Adicionar Usuário",
+ "addDescription": "Defina o acesso desse usuário à organização e aos workspaces.",
+ "addSubmit": "Adicionar",
+ "created": "Usuário adicionado.",
+ "editTitle": "Editar Usuário",
+ "editDescription": "Atualize o acesso desse usuário à organização e aos workspaces.",
+ "editSubmit": "Guardar",
+ "updated": "Usuário atualizado.",
+ "deleteTitle": "Apagar Usuário",
+ "deleteConfirm": "Tem certeza que deseja apagar {name}? Esta ação não pode ser desfeita.",
+ "deleteSubmit": "Apagar",
+ "deleted": "Usuário apagado.",
+ "orgAccess": "Acesso da Organização",
+ "workspaceAccess": "Acesso ao workspace",
+ "selectWorkspace": "Selecionar workspace",
+ "selectRole": "Selecionar acesso",
+ "addWorkspace": "Adicionar workspace",
+ "noWorkspaces": "Nenhum workspace atribuído.",
+ "orgRoles": {
+ "owner": "Proprietário",
+ "manager": "Administrador",
+ "agent": "Membro"
+ },
+ "wsRoles": {
+ "owner": "Proprietário",
+ "manager": "Administrador",
+ "agent": "Representante"
+ }
+ },
+ "plans": {
+ "title": "Planos de Faturamento"
+ },
+ "subscriptions": {
+ "title": "Assinaturas"
+ },
+ "sequences": {
+ "title": "Sequências",
+ "subscribers": "Inscritos",
+ "step": "Mensagem",
+ "addStep": "Adicionar Mensagem",
+ "addFirstStep": "Adicionar Primeira Mensagem",
+ "noSteps": "Nenhuma mensagem ainda. Adicione sua primeira mensagem para começar.",
+ "selectFlow": "Selecionar Fluxo",
+ "confirmDeleteStep": "Tem certeza que deseja excluir esta mensagem?",
+ "delayUnits": {
+ "immediate": "Imediatamente",
+ "minutes": "Minutos",
+ "hours": "Horas",
+ "days": "Dias",
+ "specificTime": "Horário Específico"
+ },
+ "timeValidation": "O horário da mensagem deve ser depois da mensagem anterior",
+ "validation": {
+ "exception": "Exceção de Validação",
+ "nameExists": "Já existe uma sequência com esse nome"
+ },
+ "sendLabel": "Enviar",
+ "selectFlowFirst": "Selecione um fluxo antes de ativar a mensagem",
+ "invalidTimeRange": "O horário de início deve ser antes do horário de término",
+ "schedule": "Agenda",
+ "scheduleDescription": "Configure quando as mensagens devem ser enviadas",
+ "sendTime": "Horário de envio",
+ "sendTimeDescription": "As mensagens serão enviadas apenas neste intervalo de horário",
+ "timezone": "Fuso horário",
+ "timezoneDescription": "Todos os horários estão no fuso horário do seu chatbot",
+ "enableSchedule": "Ativar agenda",
+ "startTime": "Horário de início",
+ "endTime": "Horário de término",
+ "allDay": "Dia todo",
+ "customTime": "Horário personalizado",
+ "enrollmentSettings": "Configurações de Inscrição",
+ "enrollmentDescription": "Controle como os contatos são inscritos nesta sequência",
+ "allowReEnrollment": "Permitir reinscrição",
+ "allowReEnrollmentDescription": "Contatos podem ser inscritos múltiplas vezes",
+ "skipWeekends": "Pular finais de semana",
+ "skipWeekendsDescription": "Não enviar mensagens aos sábados e domingos",
+ "unenrollOnReply": "Cancelar inscrição ao responder",
+ "unenrollOnReplyDescription": "Remover contato da sequência quando ele responder",
+ "performance": "Desempenho",
+ "enrolled": "Inscritos",
+ "completed": "Concluídos",
+ "openRate": "Taxa de abertura",
+ "clickRate": "Taxa de clique",
+ "replyRate": "Taxa de resposta",
+ "unsubscribeRate": "Taxa de cancelamento",
+ "overview": "Visão Geral",
+ "analytics": "Análises",
+ "stats": {
+ "sent": "Enviado",
+ "delivered": "Entregue",
+ "seen": "Visualizado",
+ "clicked": "Clicado",
+ "failed": "Falhou",
+ "noContacts": "Nenhum contato encontrado",
+ "pageInfo": "Página {page} de {pageCount}"
+ },
+ "settings": "Configurações",
+ "flow": "Fluxo",
+ "active": "Ativo",
+ "messageSentAtLeast": "Esta mensagem será enviada pelo menos",
+ "afterPreviousMessage": "depois da mensagem anterior (dia = 24h)",
+ "anyTime": "Qualquer horário",
+ "setTimeRange": "Definir intervalo de horário",
+ "selectDays": "Selecionar dias",
+ "allDays": "Todos os Dias",
+ "deselectAll": "Desmarcar Todos",
+ "monday": "Segunda-feira",
+ "tuesday": "Terça-feira",
+ "wednesday": "Quarta-feira",
+ "thursday": "Quinta-feira",
+ "friday": "Sexta-feira",
+ "saturday": "Sábado",
+ "sunday": "Domingo",
+ "afterText": "Depois"
+ },
+ "tools": {
+ "title": "Ferramentas"
+ },
+ "aiProviders": {
+ "openai": "OpenAI",
+ "gemini": "Gemini",
+ "claude": "Claude",
+ "deepseek": "DeepSeek"
+ },
+ "smtp": {
+ "setting": {
+ "description": "Envie e-mails usando seu servidor SMTP.",
+ "label": "(E-mail) SMTP"
+ },
+ "errors": {
+ "connectionFailed": "Não foi possível conectar ao servidor SMTP. Verifique suas credenciais e tente novamente."
+ }
+ },
+ "botSimulator": {
+ "title": "Simulador de Bot",
+ "description": "Simule o comportamento do seu bot em ambiente de tempo real."
+ },
+ "pollManager": {
+ "title": "Gerenciador de Enquetes",
+ "description": "Crie e gerencie enquetes para seus contatos."
+ },
+ "placesNearMe": {
+ "title": "Locais Próximos a Mim",
+ "description": "Encontre locais próximos aos seus contatos."
+ },
+ "questionnaires": {
+ "title": "Questionários",
+ "description": "Crie e gerencie questionários para seus contatos."
+ },
+ "facebookCommentAutomation": {
+ "title": "Automação de Comentários no Facebook",
+ "description": "Automatize comentários no Facebook para seus contatos."
+ },
+ "facebookLeadAdsAutomation": {
+ "title": "Automação de Lead Ads do Facebook",
+ "description": "Automatize lead ads do Facebook para seus contatos."
+ },
+ "ecommerce": {
+ "title": "E-commerce",
+ "description": "Crie e gerencie sua loja de e-commerce."
+ },
+ "appointmentScheduling": {
+ "title": "Agendamento de Compromissos",
+ "description": "Crie e gerencie seu agendamento de compromissos."
+ },
+ "templates": {
+ "title": "Templates",
+ "description": "Crie e gerencie seus templates."
+ },
+ "qrCodeGenerator": {
+ "title": "Gerador de QR Code",
+ "description": "Crie e gerencie seus QR codes."
+ },
+ "flowEditor": {
+ "startBadge": "Início",
+ "copySuffix": "Cópia",
+ "addNode": "Adicionar bloco",
+ "zoomIn": "Aumentar zoom",
+ "zoomOut": "Diminuir zoom",
+ "fitView": "Centralizar fluxo"
+ },
+ "conversationFilter": {
+ "all": "Todos",
+ "human": "Humano",
+ "bot": "Bot"
+ },
+ "inboxFilters": {
+ "all": "Todos",
+ "mine": "Minhas",
+ "unassigned": "Não atribuídas",
+ "teamInbox": "Caixa de entrada de equipe",
+ "collapseSidebar": "Recolher barra lateral",
+ "expandSidebar": "Expandir barra lateral",
+ "newMessageIndicator": "Nova mensagem",
+ "blockedContacts": "Contatos bloqueados"
+ },
+ "inboxDisplayFilter": {
+ "showLabel": "Mostrar",
+ "sortLabel": "Classificar por",
+ "advanced": "Filtros avançados",
+ "noReply": "Sem resposta",
+ "show": {
+ "all": "Todas",
+ "open": "Abertas",
+ "closed": "Encerradas"
+ },
+ "sort": {
+ "recent": "Recentes",
+ "oldest": "Antigos"
+ },
+ "searchContact": "Pesquisar contato"
+ },
+ "snippets": {
+ "title": "Trechos",
+ "subtitle": "Crie e gerencie respostas reutilizáveis para agilizar suas conversas",
+ "feature": "Trecho",
+ "addButton": "Adicionar Snippet",
+ "createTitle": "Adicionar Snippet",
+ "createConfirm": "Criar",
+ "searchPlaceholder": "Pesquisar Trechos",
+ "nameLabel": "Nome",
+ "namePlaceholder": "Nomeie seu Snippet (ex: saudação1)",
+ "shortcutLabel": "Atalho",
+ "shortcutPlaceholder": "ex: ola",
+ "topicsLabel": "Tópicos",
+ "topicsPlaceholder": "Adicionar tópico (ex: vendas, suporte)",
+ "topicsLimitReached": "Limite de tópicos atingido",
+ "filesLabel": "Anexos",
+ "addFile": "Adicionar arquivo",
+ "filesLimitReached": "Limite de arquivos atingido",
+ "messageLabel": "Mensagem",
+ "messagePlaceholder": "Digite a mensagem do snippet aqui",
+ "formDescription": "Trechos são respostas pré-escritas reutilizáveis. Use \"/\" no composer para inseri-las nas conversas.",
+ "deleteTitle": "Excluir Trecho",
+ "deleteDescription": "Tem certeza que deseja excluir o trecho \"{name}\"? Esta ação não pode ser desfeita."
+ },
+ "botFields": {
+ "title": "Campos do bot",
+ "subtitle": "Variáveis globais do workspace usadas em fluxos e mensagens automatizadas"
+ },
+ "closingNotes": {
+ "title": "Notas de Fechamento",
+ "subtitle": "Configure resumo + categoria que atendente preenche ao encerrar uma conversa no Inbox.",
+ "modeTitle": "Modo de operação",
+ "modeSubtitle": "Decide se atendente é obrigado a categorizar e/ou resumir a conversa antes de fechar.",
+ "modeSaved": "Modo atualizado",
+ "savingMode": "Salvando modo…",
+ "categoriesTitle": "Categorias de conversa",
+ "categoriesSubtitle": "Lista de categorias disponíveis pro atendente escolher ao fechar (ex.: Resolvido, Sem resposta, Spam).",
+ "addCategory": "Adicionar categoria",
+ "editCategoryTitle": "Editar categoria",
+ "categoryFormDescription": "Categoria fica disponível ao atendente no diálogo de fechamento da conversa.",
+ "namePlaceholder": "Ex.: Resolvido",
+ "descriptionPlaceholder": "Quando usar esta categoria (opcional)",
+ "categoryCreated": "Categoria criada",
+ "categoryUpdated": "Categoria atualizada",
+ "categoryDeleted": "Categoria removida",
+ "emptyCategories": "Nenhuma categoria ainda. Adicione a primeira pra atendente categorizar conversas ao fechar.",
+ "deleteCategoryTitle": "Excluir categoria",
+ "confirmDelete": "Excluir a categoria \"{name}\"? Conversas que usavam esta categoria ficarão sem ela.",
+ "mode": {
+ "disabled": "Desabilitado",
+ "disabledDesc": "Atendente fecha a conversa direto, sem dialog.",
+ "optional": "Diálogo opcional",
+ "optionalDesc": "Abre dialog ao fechar mas atendente pode pular sem preencher.",
+ "mandatoryDialog": "Categoria obrigatória",
+ "mandatoryDialogDesc": "Abre dialog, atendente precisa escolher uma categoria. Resumo opcional.",
+ "mandatoryBoth": "Categoria + Resumo obrigatórios",
+ "mandatoryBothDesc": "Abre dialog, atendente precisa escolher categoria E escrever resumo da conversa."
+ },
+ "dialog": {
+ "title": "Fechar conversa",
+ "description": {
+ "optional": "Categorize e/ou resuma esta conversa. Ambos os campos são opcionais.",
+ "mandatoryDialog": "Escolha uma categoria antes de fechar esta conversa.",
+ "mandatoryBoth": "Escolha uma categoria e escreva um resumo curto antes de fechar."
+ },
+ "categoryLabel": "Categoria",
+ "categoryPlaceholder": "Selecione uma categoria",
+ "summaryLabel": "Resumo",
+ "summaryPlaceholder": "Descreva rapidamente o que aconteceu nesta conversa…",
+ "noCategories": "Nenhuma categoria cadastrada. Peça pro admin adicionar categorias em Configurações → Notas de Fechamento.",
+ "skip": "Pular e fechar",
+ "confirm": "Fechar conversa",
+ "successToast": "Conversa fechada"
+ }
+ }
+}
diff --git a/apps/builder/messages/vi.json b/apps/builder/messages/vi.json
index 65f1c8b61..5ece5eb8a 100644
--- a/apps/builder/messages/vi.json
+++ b/apps/builder/messages/vi.json
@@ -19,6 +19,8 @@
"archive": "Lưu trữ",
"unarchive": "Bỏ lưu trữ",
"archiveConversation": "Lưu trữ cuộc trò chuyện",
+ "closeConversation": "Đóng",
+ "openConversation": "Mở",
"assign": "Gán",
"assignConversation": "Gán cuộc trò chuyện",
"back": "Quay lại",
@@ -109,15 +111,96 @@
"deactivate": "Tắt",
"onFailure": "Khi thất bại",
"onSkip": "Khi bỏ qua",
- "onSuccess": "Khi thành công"
+ "onSuccess": "Khi thành công",
+ "trigger": "Trigger",
+ "close": "Đóng",
+ "remove": "Xóa"
},
"states": {
"success": "Thành công",
"error": "Lỗi",
- "skip": "Bỏ qua"
+ "skip": "Bỏ qua",
+ "closed": "Đã đóng",
+ "blocked": "Đã chặn"
},
"admins": {
- "title": "Quản trị viên"
+ "title": "Cài đặt người dùng",
+ "subtitle": "Quản lý và mời thành viên vào Không gian làm việc của bạn.",
+ "addUser": "Thêm Người dùng",
+ "searchPlaceholder": "Tìm trong Cài đặt người dùng",
+ "revoke": "Thu hồi",
+ "edit": "Sửa",
+ "roles": {
+ "owner": "Chủ sở hữu",
+ "manager": "Quản trị viên",
+ "agent": "Đại diện"
+ },
+ "lastSeen": "Hoạt động gần nhất",
+ "inviteDialog": {
+ "title": "Thêm Người dùng",
+ "description": "Nhập địa chỉ email và chọn cấp truy cập để mời người dùng mới vào Không gian làm việc của bạn.",
+ "emailLabel": "Địa chỉ Email",
+ "emailPlaceholder": "user@company.com",
+ "accessLevelLabel": "Cấp truy cập",
+ "submit": "Thêm"
+ },
+ "editDialog": {
+ "title": "Chỉnh sửa Người dùng",
+ "description": "Để chỉnh sửa người dùng này, chọn cấp truy cập mới và bấm \"Sửa\". Cấp truy cập xác định những gì người dùng có thể làm.",
+ "submit": "Cập nhật"
+ },
+ "revokeDialog": {
+ "title": "Thu hồi truy cập",
+ "description": "Nếu bạn thu hồi người dùng này, tất cả các liên hệ được gán cho họ sẽ bị bỏ gán. Bạn có chắc muốn thu hồi quyền truy cập của người dùng này?",
+ "confirm": "Thu hồi"
+ },
+ "roleDescriptions": {
+ "owner": "Chủ sở hữu có thể truy cập tất cả các mô-đun và có toàn quyền kiểm soát mà không có hạn chế.",
+ "manager": "Quản trị viên có quyền truy cập tất cả các mô-đun bao gồm Cài đặt. Tuy nhiên, họ không thể mời/xóa quản trị viên khác hoặc xóa kênh.",
+ "agent": "Đại diện chỉ có quyền truy cập tin nhắn trong không gian làm việc. Tùy chọn, bạn có thể giới hạn khả năng xem trong Liên hệ."
+ },
+ "advancedRestrictions": "Hạn chế nâng cao",
+ "managerRestrictions": {
+ "restrictDataExport": {
+ "label": "Hạn chế Xuất Dữ liệu",
+ "description": "Người dùng này sẽ không thể xuất dữ liệu từ không gian làm việc."
+ },
+ "restrictContactDeletion": {
+ "label": "Hạn chế Xóa Liên hệ",
+ "description": "Người dùng này sẽ không thể xóa bất kỳ liên hệ nào."
+ },
+ "restrictWorkspaceSettings": {
+ "label": "Hạn chế truy cập Cài đặt Workspace",
+ "description": "Người dùng này sẽ không thể truy cập cài đặt không gian làm việc."
+ },
+ "restrictIntegrationSettings": {
+ "label": "Hạn chế truy cập Cài đặt Tích hợp",
+ "description": "Người dùng này sẽ không thể truy cập cài đặt tích hợp."
+ }
+ },
+ "agentRestrictions": {
+ "contactVisibility": {
+ "label": "Hạn chế Hiển thị Liên hệ",
+ "description": "Người dùng này chỉ xem liên hệ được gán cho",
+ "options": {
+ "all": "tất cả",
+ "team": "bất kỳ ai trong nhóm",
+ "self": "người dùng"
+ }
+ },
+ "restrictCalling": {
+ "label": "Hạn chế Khả năng Gọi",
+ "description": "Người dùng này không thể nhận hoặc thực hiện cuộc gọi."
+ },
+ "restrictWorkflows": {
+ "label": "Nút Hạn chế Luồng công việc",
+ "description": "Nút Luồng công việc trong mô-đun Tin nhắn sẽ bị tắt cho người dùng này. Bao gồm Phím tắt và dừng Luồng cho Liên hệ."
+ },
+ "maskPhoneAndEmail": {
+ "label": "Che Số điện thoại và Địa chỉ Email",
+ "description": "Người dùng này sẽ không thể xem số điện thoại và email của tất cả Liên hệ."
+ }
+ }
},
"assignAdmin": {
"assignConversation": "Gán cuộc trò chuyện",
@@ -215,6 +298,9 @@
"label": "Phản hồi tự động"
}
},
+ "automation": {
+ "title": "Tự động hóa"
+ },
"billing": {
"title": "Thanh toán"
},
@@ -315,9 +401,93 @@
"channels": {
"title": "Kênh"
},
+ "organization": {
+ "manage": "Quản lý Tổ chức"
+ },
+ "personalSettings": {
+ "title": "Cài đặt cá nhân",
+ "account": "Tài khoản",
+ "profile": "Hồ sơ",
+ "notifications": "Cài đặt thông báo",
+ "activityStatus": {
+ "sectionLabel": "Trạng thái của tôi",
+ "available": "Sẵn sàng",
+ "busy": "Bận",
+ "offline": "Ngoại tuyến",
+ "updateSuccess": "Đã cập nhật trạng thái thành {status}",
+ "updateError": "Không thể cập nhật trạng thái"
+ },
+ "profileSection": {
+ "title": "Hồ sơ",
+ "subtitle": "Quản lý hồ sơ và chi tiết đăng nhập của bạn.",
+ "changeAvatar": "Đổi ảnh",
+ "avatarUpdated": "Đã cập nhật ảnh.",
+ "firstNameLabel": "Tên",
+ "lastNameLabel": "Họ",
+ "languageLabel": "Ngôn ngữ",
+ "changePassword": "Đổi mật khẩu",
+ "saveProfile": "Lưu Hồ sơ",
+ "savedSuccess": "Hồ sơ đã cập nhật.",
+ "twoFA": {
+ "title": "Xác thực hai yếu tố (2FA)",
+ "toggle": "Bật xác thực hai yếu tố (2FA)"
+ },
+ "darkMode": {
+ "title": "Chế độ tối",
+ "system": "Dùng cài đặt hệ thống",
+ "light": "Tắt",
+ "dark": "Bật"
+ }
+ },
+ "notificationsSection": {
+ "title": "Cài đặt thông báo",
+ "subtitle": "Chọn cách bạn nhận thông báo workspace."
+ }
+ },
+ "manageWorkspaces": {
+ "sidebarTitle": "Workspaces",
+ "title": "Workspaces",
+ "subtitle": "Quản lý các workspace của tổ chức.",
+ "addWorkspace": "Thêm Workspace",
+ "searchPlaceholder": "Tìm workspace",
+ "open": "Mở",
+ "rename": "Đổi tên",
+ "delete": "Xóa",
+ "empty": "Chưa có workspace nào.",
+ "columns": {
+ "name": "Tên",
+ "members": "Người dùng",
+ "createdAt": "Ngày tạo",
+ "actions": "Hành động"
+ },
+ "renameDialog": {
+ "title": "Đổi tên Workspace",
+ "description": "Cập nhật tên workspace.",
+ "nameLabel": "Tên Workspace",
+ "submit": "Lưu",
+ "success": "Đã đổi tên workspace."
+ },
+ "deleteDialog": {
+ "title": "Xóa Workspace",
+ "description": "Xóa workspace \"{name}\" sẽ xóa vĩnh viễn tất cả liên hệ, kênh, trường và dữ liệu liên quan. Hành động không thể hoàn tác.",
+ "confirmLabel": "Nhập \"{name}\" để xác nhận",
+ "submit": "Xóa",
+ "success": "Đã xóa workspace."
+ }
+ },
"workspaces": {
"list": {
"title": "Danh sách workspace"
+ },
+ "searchPlaceholder": "Tìm kiếm dự án",
+ "addWorkspace": "Thêm Workspace",
+ "createDialog": {
+ "title": "Thêm Workspace",
+ "description": "Tạo một workspace mới để tách kênh, liên hệ, nhóm và cài đặt.",
+ "nameLabel": "Tên Workspace",
+ "namePlaceholder": "Ví dụ: Bán hàng, Thu nợ, Hỗ trợ",
+ "submit": "Tạo",
+ "success": "Đã tạo workspace \"{name}\"."
}
},
"workspaceToken": {
@@ -715,11 +885,15 @@
"isDefault": {
"label": "Đánh dấu làm mặc định"
},
+ "country": {
+ "label": "Quốc gia"
+ },
"language": {
"description": "Liên hệ được gán ngôn ngữ mặc định khi ngôn ngữ liên hệ không xác định.",
"english": "Tiếng Anh",
"label": "Ngôn ngữ",
"placeholder": "Chọn ngôn ngữ",
+ "portuguese": "Tiếng Bồ Đào Nha (Brazil)",
"vietnamese": "Tiếng Việt"
},
"lastName": {
@@ -973,6 +1147,12 @@
"datetime": {
"label": "Ngày giờ"
},
+ "time": {
+ "label": "Giờ"
+ },
+ "list": {
+ "label": "Danh sách (đa chọn)"
+ },
"boolean": {
"label": "Boolean",
"placeholder": "Chọn đúng hoặc sai",
@@ -1249,6 +1429,21 @@
},
"webhookUrl": {
"label": "URL webhook"
+ },
+ "addedAt": {
+ "label": "Ngày thêm"
+ },
+ "lastEditedAt": {
+ "label": "Lần chỉnh sửa cuối"
+ },
+ "visibility": {
+ "label": "Hiển thị",
+ "alwaysShow": "Luôn hiển thị",
+ "alwaysHide": "Luôn ẩn",
+ "hideWhenEmpty": "Ẩn khi rỗng"
+ },
+ "fieldId": {
+ "label": "ID trường"
}
},
"flows": {
@@ -1327,7 +1522,8 @@
"subscribeSequence": "Đăng ký chăm sóc",
"unsubscribeSequence": "Hủy đăng ký chăm sóc",
"noTemplatesAvailable": "No templates available",
- "splitTraffic": "Chia lưu lượng"
+ "splitTraffic": "Chia lưu lượng",
+ "updateContactLifecycleStage": "Cập nhật giai đoạn vòng đời"
},
"splitTraffic": {
"balanceHint": "Tổng phần trăm phải bằng 100%.",
@@ -1439,7 +1635,59 @@
"whatsappSystemUserTokenRequired": "System User Token là bắt buộc để cấu hình WhatsApp."
}
},
+ "conversations": {
+ "searchInConversation": "Tìm trong cuộc trò chuyện",
+ "searchInConversationPlaceholder": "Tìm tin nhắn…",
+ "searchNoResults": "Không có kết quả",
+ "searchPrevious": "Trước",
+ "searchNext": "Sau",
+ "composerHint": "Dùng '/' cho snippets, '$' cho biến, ':' cho emoji",
+ "composerPlaceholder": "Nhập tin nhắn",
+ "channelPrefix": "đến:",
+ "todayLabel": "Hôm nay",
+ "yesterdayLabel": "Hôm qua",
+ "unassignedAgent": "Chưa được phân công",
+ "unnamedContact": "Liên hệ chưa đặt tên"
+ },
+ "inboxSideRail": {
+ "details": "Chi tiết liên hệ",
+ "channels": "Kênh",
+ "activities": "Hoạt động",
+ "attachments": "Tệp đính kèm"
+ },
"messages": {
+ "actions": {
+ "menu": "Tùy chọn khác",
+ "copy": "Sao chép",
+ "copyLink": "Sao chép liên kết",
+ "reply": "Trả lời",
+ "copied": "Đã sao chép",
+ "linkCopied": "Đã sao chép liên kết"
+ },
+ "tooltip": {
+ "sent": "Đã gửi",
+ "delivered": "Đã chuyển",
+ "read": "Đã đọc",
+ "received": "Đã nhận",
+ "failed": "Thất bại"
+ },
+ "composer": {
+ "internalComment": "Ghi chú nội bộ",
+ "addComment": "Thêm bình luận",
+ "exitInternal": "Quay lại tin nhắn",
+ "internalLabel": "Ghi chú nội bộ",
+ "internalPlaceholder": "Dùng @ để nhắc đồng đội. Ghi chú chỉ nhóm của bạn thấy.",
+ "internalHint": "Ghi chú chỉ nhóm của bạn thấy",
+ "channelHint": "Kênh dùng để gửi tin nhắn",
+ "formLabel": "Biểu mẫu tin nhắn",
+ "textareaLabel": "Nhập tin nhắn",
+ "emoji": "Biểu tượng cảm xúc",
+ "emojiSearch": "Tìm kiếm",
+ "attach": "Đính kèm tệp",
+ "snippets": "Trả lời nhanh",
+ "send": "Gửi tin nhắn"
+ },
+ "runningWorkflows": "Quy trình đang chạy",
"moveFeature": "Di chuyển {feature}",
"poweredBy": "Được cung cấp bởi {name}",
"noGiphyApiKey": "Không tìm thấy khóa API GIPHY. Vui lòng thêm khóa API GIPHY trong cài đặt tổ chức.",
@@ -1551,6 +1799,83 @@
"settings": {
"title": "Cài đặt"
},
+ "settingsGroups": {
+ "general": "Cài đặt chung",
+ "workspace": "Workspace",
+ "apps": "Ứng dụng",
+ "advanced": "Nâng cao",
+ "inbox": "Hộp thư",
+ "data": "Dữ liệu"
+ },
+ "lifecycle": {
+ "title": "Vòng đời",
+ "subtitle": "Theo dõi và quản lý liên hệ qua các giai đoạn của quy trình bán hàng",
+ "learnMore": "Tìm hiểu",
+ "toggleTitle": "Hiện/ẩn giai đoạn vòng đời",
+ "toggleSubtitle": "Hiển thị giai đoạn vòng đời trong hộp thư và liên hệ.",
+ "configureTitle": "Cấu hình các giai đoạn vòng đời",
+ "activeTitle": "Giai đoạn vòng đời",
+ "activeSubtitle": "Tổ chức và theo dõi liên hệ qua các giai đoạn của phễu.",
+ "lostTitle": "Giai đoạn thất bại",
+ "lostSubtitle": "Theo dõi nơi liên hệ rời khỏi phễu.",
+ "addStage": "Thêm giai đoạn",
+ "maxStagesReached": "Đã đạt tối đa {max} giai đoạn (đang hoạt động + đã mất).",
+ "stageNumber": "Giai đoạn {n}",
+ "stageName": "Tên giai đoạn",
+ "newStage": "Giai đoạn mới",
+ "defaultBadge": "Mặc định",
+ "saved": "Đã lưu",
+ "saveError": "Lỗi khi lưu",
+ "saving": "Đang lưu…",
+ "emptyNameError": "Tất cả các giai đoạn cần có tên",
+ "emojiSearch": "Tìm",
+ "emojiCategories": {
+ "suggested": "Đã dùng",
+ "smileys": "Mặt cười & cảm xúc",
+ "animals": "Động vật & thiên nhiên",
+ "food": "Đồ ăn & thức uống",
+ "travel": "Du lịch & địa điểm",
+ "activities": "Hoạt động",
+ "objects": "Đồ vật",
+ "symbols": "Ký hiệu",
+ "flags": "Cờ"
+ },
+ "deleteConfirm": "Xóa giai đoạn \"{name}\"?",
+ "selectPlaceholder": "Chọn giai đoạn",
+ "noStage": "Không có giai đoạn",
+ "nextStageTooltip": "Giai đoạn tiếp theo: {name}",
+ "lastStageTooltip": "Đã ở giai đoạn cuối",
+ "contactLifecycle": "Vòng đời liên hệ",
+ "activeStages": "Giai đoạn hoạt động",
+ "lostStages": "Giai đoạn đã mất",
+ "contactUpdated": "Đã cập nhật",
+ "contactUpdateError": "Lỗi",
+ "bulkMove": "Di chuyển",
+ "bulkMoved": "{count} liên hệ đã di chuyển",
+ "deleteModalTitle": "Xóa \"{name}\"",
+ "deleteModalDescription": "Giai đoạn này có {count} liên hệ.",
+ "deleteModalReassignTo": "Di chuyển đến",
+ "deleteModalNoContacts": "Không có liên hệ.",
+ "deleteModalNoTarget": "Bỏ trống",
+ "deleteModalConfirm": "Xóa và di chuyển",
+ "deleteModalCancel": "Hủy",
+ "stageBadgeLabel": "Giai đoạn",
+ "activityLog": "Lịch sử giai đoạn",
+ "activityLogEmpty": "Không có thay đổi",
+ "menu": {
+ "setAsDefault": "Đặt làm mặc định",
+ "delete": "Xóa"
+ },
+ "anyStage": "Bất kỳ giai đoạn nào",
+ "triggerFromStage": "Từ (gốc)",
+ "triggerToStage": "Đến (đích)",
+ "stepHint": "Cập nhật giai đoạn vòng đời của liên hệ sang giai đoạn mới.",
+ "stepUpdateAction": "Cập nhật giai đoạn",
+ "stepUpdateActionHint": "Cập nhật giai đoạn của liên hệ sang giai đoạn khác",
+ "stepRemoveAction": "Xóa giai đoạn",
+ "stepRemoveActionHint": "Xóa giai đoạn của liên hệ",
+ "stepStageLabel": "Giai đoạn vòng đời"
+ },
"auth": {
"orContinueWith": "Hoặc tiếp tục với",
"dontHaveAnAccount": "Chưa có tài khoản?",
@@ -1580,7 +1905,23 @@
"signUpTitle": "Đăng ký vào {name}"
},
"tags": {
- "title": "Thẻ"
+ "title": "Thẻ",
+ "subtitle": "Tạo và quản lý thẻ để sắp xếp cuộc trò chuyện và liên hệ",
+ "formDescription": "Cấu hình giao diện thẻ — chọn emoji, màu sắc và mô tả.",
+ "namePlaceholder": "Ví dụ: Khách tiềm năng",
+ "emojiLabel": "Biểu tượng",
+ "clearEmoji": "Xóa biểu tượng",
+ "colorLabel": "Màu",
+ "descriptionLabel": "Mô tả",
+ "descriptionPlaceholder": "Tùy chọn — thẻ này dùng để làm gì?",
+ "preview": "Xem trước",
+ "addTag": "Thêm nhãn",
+ "searchPlaceholder": "Tìm kiếm nhãn",
+ "emptyState": "Không tìm thấy nhãn",
+ "delete": {
+ "usageDescription": "Bạn sắp xóa {tagCount, plural, =1 {1 thẻ} other {# thẻ}} đã được gán cho {contactCount, plural, =1 {1 liên hệ} other {# liên hệ}}. Hành động này sẽ gỡ thẻ khỏi tất cả các liên hệ và không thể hoàn tác.",
+ "confirmInputLabel": "Để xác nhận, hãy nhập {count} bên dưới:"
+ }
},
"texts": {
"or": "hoặc"
@@ -1601,6 +1942,10 @@
},
"whatsapp": {
"category": {
+ "authentication": {
+ "description": "Mã OTP, xác minh 2 bước và tin nhắn xác thực.",
+ "label": "Xác thực"
+ },
"marketing": {
"description": "Mô tả danh mục Marketing",
"label": "Marketing"
@@ -1610,6 +1955,31 @@
"label": "Tiện ích"
}
},
+ "connectionType": {
+ "embedded": "Embedded Signup",
+ "manual": "Kết nối thủ công"
+ },
+ "list": {
+ "empty": "Chưa có WhatsApp nào được kết nối."
+ },
+ "templatePicker": {
+ "empty": "Không có mẫu nào được phê duyệt.",
+ "preview": "Xem trước",
+ "sent": "Đã gửi mẫu",
+ "title": "Gửi mẫu",
+ "variables": "Biến"
+ },
+ "webhook": {
+ "pending": "Webhook chờ xử lý",
+ "verified": "Webhook đã xác minh"
+ },
+ "windowAlert": {
+ "description": "Meta không cho phép gửi tin nhắn sau 24 giờ kể từ lần phản hồi cuối của liên hệ. Để tiếp tục, hãy gửi một mẫu tin nhắn đã được phê duyệt hoặc chờ phản hồi mới từ liên hệ để tiếp tục gửi tin nhắn bình thường.",
+ "learnMore": "Tìm hiểu thêm",
+ "openTemplate": "Mở danh sách mẫu",
+ "sendTemplateButton": "Gửi mẫu tin nhắn",
+ "title": "Cửa sổ nhắn tin WhatsApp đã đóng"
+ },
"commands": {
"description": "Đây là những từ khóa đặc biệt cho bot WhatsApp biết phải làm gì",
"label": "Lệnh"
@@ -1831,7 +2201,35 @@
"organizationInvitationDescription2": "{userName} đã mời bạn tham gia {organizationName}."
},
"inboxTeams": {
- "title": "Nhóm hộp thư"
+ "title": "Cài đặt nhóm",
+ "subtitle": "Tạo và quản lý các nhóm để gom người dùng trong Không gian làm việc của bạn.",
+ "addTeam": "Thêm Nhóm",
+ "searchPlaceholder": "Tìm trong Cài đặt nhóm",
+ "manage": "Quản lý",
+ "delete": "Xóa",
+ "createDialog": {
+ "title": "Thêm Nhóm",
+ "description": "Tạo một Nhóm để gom người dùng. Nhóm có thể được dùng thay cho người dùng để chia sẻ tài nguyên như Chế độ xem."
+ },
+ "manageDialog": {
+ "title": "Quản lý Nhóm",
+ "description": "Quản lý nhóm bằng cách đổi tên, thay đổi mô tả và thêm hoặc xóa thành viên."
+ },
+ "deleteDialog": {
+ "title": "Xóa Nhóm",
+ "description": "Bạn có chắc muốn xóa nhóm \"{name}\"? Hành động này không thể hoàn tác. Thành viên sẽ không bị xóa.",
+ "confirm": "Xóa"
+ },
+ "fields": {
+ "name": "Tên Nhóm",
+ "namePlaceholder": "Nhập tên nhóm tại đây, ví dụ Nhóm Marketing",
+ "description": "Mô tả Nhóm",
+ "descriptionPlaceholder": "Nhập mô tả nhóm tại đây, ví dụ Quản lý liên hệ marketing.",
+ "members": "Thành viên Nhóm",
+ "membersPlaceholder": "Thêm người dùng tại đây, ví dụ John."
+ },
+ "membersCount": "{count, plural, =0 {Không có thành viên} =1 {1 thành viên} other {# thành viên}}",
+ "save": "Lưu"
},
"trigger": {
"when": "Khi điều này xảy ra",
@@ -1871,7 +2269,8 @@
"productRemovedFromCart": "Sản phẩm xóa khỏi giỏ",
"productOrdered": "Sản phẩm đã đặt",
"contactReferredANewContact": "Liên hệ giới thiệu liên hệ mới",
- "contactReferredExistingContact": "Liên hệ giới thiệu liên hệ hiện có"
+ "contactReferredExistingContact": "Liên hệ giới thiệu liên hệ hiện có",
+ "lifecycleStageChanged": "Giai đoạn vòng đời đã thay đổi"
},
"actions": {
"addTag": "Thêm thẻ",
@@ -1889,23 +2288,175 @@
"day": "Ngày",
"hour": "Giờ",
"minute": "Phút",
- "at": "Lúc"
+ "at": "Lúc",
+ "shortcutMenu": {
+ "button": "Phím tắt",
+ "title": "Kích hoạt luồng",
+ "loading": "Đang tải...",
+ "empty": "Chưa có phím tắt nào.",
+ "fired": "Đã kích hoạt",
+ "fireError": "Không thể kích hoạt"
+ },
+ "flowNode": {
+ "helperText": "Mọi luồng đều bắt đầu với một trigger. Khi sự kiện được cấu hình xảy ra, luồng này tự động được kích hoạt cho liên hệ.",
+ "typeLabel": "Loại trigger",
+ "typePlaceholder": "Chọn một trigger",
+ "noFilters": "Không có bộ lọc — kích hoạt trên mọi sự kiện loại này.",
+ "anyOption": "Bất kỳ",
+ "conversationOpened": {
+ "label": "Cuộc trò chuyện mở",
+ "hint": "Kích hoạt khi mở cuộc trò chuyện (mới hoặc mở lại)."
+ },
+ "conversationClosed": {
+ "label": "Cuộc trò chuyện đóng",
+ "hint": "Kích hoạt khi cuộc trò chuyện đóng."
+ },
+ "contactFieldUpdated": {
+ "label": "Trường liên hệ cập nhật",
+ "hint": "Kích hoạt khi trường thay đổi.",
+ "fieldsLabel": "Trường kích hoạt",
+ "empty": "Không có trường tùy chỉnh."
+ },
+ "contactTagUpdated": {
+ "label": "Thẻ liên hệ cập nhật",
+ "hint": "Kích hoạt khi thẻ được thêm/xóa.",
+ "actionLabel": "Hành động trên thẻ",
+ "actionAny": "Bất kỳ (thêm hoặc xóa)",
+ "actionAdded": "Khi thẻ được thêm",
+ "actionRemoved": "Khi thẻ bị xóa",
+ "tagsLabel": "Thẻ kích hoạt",
+ "empty": "Không có thẻ."
+ },
+ "shortcut": {
+ "label": "Phím tắt",
+ "hint": "Kích hoạt khi nhân viên nhấn phím tắt.",
+ "iconLabel": "Biểu tượng (emoji)",
+ "iconHint": "Hiển thị trên nút phím tắt trong Inbox.",
+ "labelLabel": "Văn bản nút",
+ "labelPlaceholder": "VD: Gửi lại ưu đãi",
+ "note": "Trigger này không kích hoạt tự động. Nó hiển thị như một nút trong Inbox."
+ },
+ "lifecycleStageChanged": {
+ "label": "Giai đoạn vòng đời thay đổi",
+ "hint": "Kích hoạt khi giai đoạn thay đổi.",
+ "toStagesLabel": "Đến giai đoạn",
+ "fromStagesLabel": "Từ giai đoạn",
+ "triggerOnCleared": "Cũng kích hoạt khi giai đoạn bị xóa",
+ "loading": "Đang tải...",
+ "emptyStages": "Không có giai đoạn."
+ }
+ }
},
"developerAccessToken": {
"title": "Token truy cập API ChatbotX",
"description": "Tạo token để cho phép workspace của bạn truy cập API ChatbotX. Giữ token này bí mật và không chia sẻ với ai."
},
"contacts": {
- "title": "Liên hệ"
+ "title": "Liên hệ",
+ "fieldsTitle": "Trường liên hệ",
+ "admin": "Quản lý",
+ "details": "Chi tiết liên hệ",
+ "channels": "Kênh",
+ "tagsTitle": "Thẻ",
+ "ownerLabel": "Quản trị viên",
+ "noTags": "Không có thẻ",
+ "addTag": "Thêm thẻ",
+ "searchTag": "Tìm và chọn thẻ",
+ "hiddenFields": "Trường liên hệ ẩn",
+ "delete": {
+ "usageDescription": "Bạn sắp xóa {contactCount, plural, one {# liên hệ} other {# liên hệ}} với {tagCount, plural, one {# thẻ} other {# thẻ}} và {conversationCount, plural, one {# cuộc trò chuyện} other {# cuộc trò chuyện}}. Tin nhắn, ghi chú và sự kiện liên quan cũng sẽ bị xóa. Nhập tổng số để xác nhận.",
+ "confirmInputLabel": "Nhập {count, number} để xác nhận"
+ },
+ "columnsPicker": {
+ "button": "Cột",
+ "title": "Hiển thị cột",
+ "showAll": "Hiện tất cả",
+ "hideAll": "Ẩn tất cả"
+ },
+ "unmerge": {
+ "loading": "Đang tải danh sách hợp nhất…",
+ "bannerTitle": "{count, plural, one {# liên hệ đã được hợp nhất} other {# liên hệ đã được hợp nhất}}",
+ "warning": "Khôi phục liên hệ đã hợp nhất chỉ phục hồi thẻ liên hệ. Cuộc trò chuyện, thẻ và trường tùy chỉnh được chuyển sẽ ở lại liên hệ chính — chuyển lại thủ công nếu cần.",
+ "button": "Hoàn tác hợp nhất",
+ "successToast": "Liên hệ đã được khôi phục"
+ },
+ "visibility": {
+ "customize": "Tùy chỉnh hiển thị",
+ "title": "Tùy chỉnh hiển thị",
+ "description": "Tùy chỉnh khả năng hiển thị của các trường liên hệ ở khung phải Inbox. Thay đổi áp dụng cho mọi người trong workspace.",
+ "fieldHeader": "Trường liên hệ",
+ "visibilityHeader": "Hiển thị",
+ "showAlways": "Luôn hiển thị",
+ "alwaysHide": "Luôn ẩn",
+ "saved": "Đã cập nhật hiển thị"
+ },
+ "column": {
+ "channels": "Kênh",
+ "country": "Quốc gia/khu vực",
+ "language": "Ngôn ngữ",
+ "conversationStatus": "Trạng thái hội thoại",
+ "lastMessage": "Tin nhắn cuối",
+ "statusOpen": "Mở",
+ "statusClosed": "Đã đóng"
+ },
+ "merge": {
+ "action": "Hợp nhất liên hệ",
+ "title": "Hợp nhất liên hệ",
+ "description": "Chọn liên hệ chính. Các liên hệ khác sẽ được hợp nhất bên dưới (thẻ, trường tùy chỉnh và hội thoại chuyển sang chính) rồi bị xóa.",
+ "selectPrimary": "Liên hệ chính",
+ "unnamed": "chưa đặt tên",
+ "warning": "{count} bản trùng sẽ bị xóa sau khi hợp nhất. Không thể hoàn tác.",
+ "confirm": "Hợp nhất",
+ "success": "Đã hợp nhất {count} liên hệ"
+ },
+ "sidebar": {
+ "all": "Tất cả liên hệ",
+ "blocked": "Liên hệ bị chặn"
+ },
+ "drawer": {
+ "title": "Chi tiết liên hệ",
+ "openInInbox": "Mở trong hộp thư"
+ },
+ "placeholders": {
+ "addFirstName": "Thêm tên",
+ "addLastName": "Thêm họ",
+ "addEmail": "Thêm email",
+ "selectAssignee": "Chọn người phụ trách"
+ }
},
"errorLogs": {
"title": "Nhật ký lỗi"
},
"auditLogs": {
- "title": "Nhật ký kiểm tra"
+ "title": "Nhật ký hoạt động",
+ "subtitle": "Lịch sử đầy đủ các hành động đã thực hiện trong không gian làm việc.",
+ "searchPlaceholder": "Tìm theo chi tiết, hành động hoặc người dùng",
+ "filterUser": "Tất cả người dùng",
+ "filterUserAll": "Tất cả người dùng",
+ "filterAction": "Tất cả hành động",
+ "filterActionAll": "Tất cả hành động",
+ "exportCsv": "Xuất CSV",
+ "exportSuccess": "Xuất nhật ký thành công.",
+ "exportError": "Lỗi xuất nhật ký.",
+ "empty": "Chưa có hoạt động nào.",
+ "totalRows": "{count, plural, =0 {Không có bản ghi} =1 {1 bản ghi} other {# bản ghi}}",
+ "columns": {
+ "user": "Người dùng",
+ "action": "Hành động",
+ "detail": "Chi tiết",
+ "when": "Thời gian"
+ }
},
"customFields": {
- "title": "Trường tùy chỉnh"
+ "title": "Trường tùy chỉnh",
+ "subtitle": "Tạo và quản lý các trường tùy chỉnh để làm phong phú hồ sơ liên hệ",
+ "addValue": "Thêm giá trị cho {field}",
+ "valuesLabel": "Tùy chọn",
+ "valuesPlaceholder": "Nhập và nhấn Enter để thêm",
+ "emptyOptions": "Chưa cấu hình tùy chọn",
+ "defaultsTitle": "Trường liên hệ mặc định",
+ "defaultsTooltip": "7 trường này thuộc hệ thống và có sẵn cho mọi liên hệ. Không thể sửa hoặc xóa — chỉ có thể điều chỉnh hiển thị trong Inbox qua \"Tùy chỉnh hiển thị\".",
+ "defaultsLockTooltip": "Trường mặc định — không thể sửa"
},
"keywords": {
"title": "Từ khóa"
@@ -2015,6 +2566,43 @@
"tools": {
"title": "Công cụ"
},
+ "organizationUsers": {
+ "pageTitle": "Cài đặt quản trị viên",
+ "pageSubtitle": "Quản lý người dùng tổ chức của bạn tại đây.",
+ "addButton": "Thêm người dùng",
+ "searchPlaceholder": "Tìm kiếm người dùng",
+ "empty": "Không tìm thấy người dùng.",
+ "edit": "Chỉnh sửa",
+ "delete": "Xóa",
+ "addTitle": "Thêm người dùng",
+ "addDescription": "Đặt quyền truy cập của người dùng vào tổ chức và workspace.",
+ "addSubmit": "Thêm",
+ "created": "Đã thêm người dùng.",
+ "editTitle": "Chỉnh sửa người dùng",
+ "editDescription": "Cập nhật quyền truy cập của người dùng vào tổ chức và workspace.",
+ "editSubmit": "Lưu",
+ "updated": "Đã cập nhật người dùng.",
+ "deleteTitle": "Xóa người dùng",
+ "deleteConfirm": "Bạn có chắc muốn xóa {name}? Hành động này không thể hoàn tác.",
+ "deleteSubmit": "Xóa",
+ "deleted": "Đã xóa người dùng.",
+ "orgAccess": "Quyền truy cập tổ chức",
+ "workspaceAccess": "Quyền truy cập workspace",
+ "selectWorkspace": "Chọn workspace",
+ "selectRole": "Chọn quyền",
+ "addWorkspace": "Thêm workspace",
+ "noWorkspaces": "Chưa có workspace nào được gán.",
+ "orgRoles": {
+ "owner": "Chủ sở hữu",
+ "manager": "Quản trị viên",
+ "agent": "Thành viên"
+ },
+ "wsRoles": {
+ "owner": "Chủ sở hữu",
+ "manager": "Quản trị viên",
+ "agent": "Đại diện"
+ }
+ },
"plans": {
"title": "Billing Plans"
},
@@ -2082,5 +2670,106 @@
"QRCode": {
"title": "QR Code",
"description": "Share this code so people open your tracking link."
+ },
+ "inboxFilters": {
+ "all": "Tất cả",
+ "mine": "Của tôi",
+ "unassigned": "Chưa gán",
+ "teamInbox": "Hộp thư nhóm",
+ "collapseSidebar": "Thu gọn thanh bên",
+ "expandSidebar": "Mở rộng thanh bên",
+ "newMessageIndicator": "Tin nhắn mới",
+ "blockedContacts": "Liên hệ bị chặn"
+ },
+ "inboxDisplayFilter": {
+ "showLabel": "Hiển thị",
+ "sortLabel": "Sắp xếp",
+ "advanced": "Bộ lọc nâng cao",
+ "noReply": "Chưa trả lời",
+ "show": {
+ "all": "Tất cả",
+ "open": "Đang mở",
+ "closed": "Đã đóng"
+ },
+ "sort": {
+ "recent": "Mới nhất",
+ "oldest": "Cũ nhất"
+ },
+ "searchContact": "Tìm liên hệ"
+ },
+ "snippets": {
+ "title": "Đoạn mẫu",
+ "subtitle": "Tạo và quản lý các câu trả lời tái sử dụng để tăng tốc cuộc trò chuyện",
+ "feature": "Đoạn mẫu",
+ "addButton": "Thêm đoạn mẫu",
+ "createTitle": "Thêm đoạn mẫu",
+ "createConfirm": "Tạo",
+ "searchPlaceholder": "Tìm đoạn mẫu",
+ "nameLabel": "Tên",
+ "namePlaceholder": "Đặt tên đoạn mẫu (ví dụ: chao1)",
+ "shortcutLabel": "Phím tắt",
+ "shortcutPlaceholder": "ví dụ: chao",
+ "topicsLabel": "Chủ đề",
+ "topicsPlaceholder": "Thêm chủ đề (ví dụ: bán hàng, hỗ trợ)",
+ "topicsLimitReached": "Đạt giới hạn chủ đề",
+ "filesLabel": "Tệp đính kèm",
+ "addFile": "Thêm tệp",
+ "filesLimitReached": "Đạt giới hạn tệp",
+ "messageLabel": "Tin nhắn",
+ "messagePlaceholder": "Nhập nội dung đoạn mẫu tại đây",
+ "formDescription": "Đoạn mẫu là các câu trả lời tái sử dụng. Dùng \"/\" trong khung soạn để chèn vào cuộc trò chuyện.",
+ "deleteTitle": "Xoá đoạn mẫu",
+ "deleteDescription": "Bạn có chắc muốn xoá đoạn mẫu \"{name}\"? Thao tác này không thể hoàn tác."
+ },
+ "botFields": {
+ "title": "Trường bot",
+ "subtitle": "Biến toàn cục của workspace dùng trong luồng và tin nhắn tự động"
+ },
+ "closingNotes": {
+ "title": "Ghi chú khi đóng",
+ "subtitle": "Cấu hình tóm tắt + danh mục mà nhân viên điền khi đóng cuộc trò chuyện trong Inbox.",
+ "modeTitle": "Chế độ hoạt động",
+ "modeSubtitle": "Quyết định xem nhân viên có bắt buộc phải phân loại và/hoặc tóm tắt cuộc trò chuyện trước khi đóng hay không.",
+ "modeSaved": "Đã cập nhật chế độ",
+ "savingMode": "Đang lưu chế độ…",
+ "categoriesTitle": "Danh mục cuộc trò chuyện",
+ "categoriesSubtitle": "Danh sách danh mục nhân viên có thể chọn khi đóng (ví dụ: Đã giải quyết, Không phản hồi, Spam).",
+ "addCategory": "Thêm danh mục",
+ "editCategoryTitle": "Sửa danh mục",
+ "categoryFormDescription": "Danh mục sẽ có sẵn cho nhân viên trong hộp thoại đóng cuộc trò chuyện.",
+ "namePlaceholder": "Ví dụ: Đã giải quyết",
+ "descriptionPlaceholder": "Khi nào dùng danh mục này (tùy chọn)",
+ "categoryCreated": "Đã tạo danh mục",
+ "categoryUpdated": "Đã cập nhật danh mục",
+ "categoryDeleted": "Đã xóa danh mục",
+ "emptyCategories": "Chưa có danh mục. Thêm danh mục đầu tiên để nhân viên có thể phân loại cuộc trò chuyện khi đóng.",
+ "deleteCategoryTitle": "Xóa danh mục",
+ "confirmDelete": "Xóa danh mục \"{name}\"? Các cuộc trò chuyện đã dùng danh mục này sẽ không còn nó.",
+ "mode": {
+ "disabled": "Tắt",
+ "disabledDesc": "Nhân viên đóng cuộc trò chuyện trực tiếp, không có hộp thoại.",
+ "optional": "Hộp thoại tùy chọn",
+ "optionalDesc": "Hộp thoại mở khi đóng nhưng nhân viên có thể bỏ qua không cần điền gì.",
+ "mandatoryDialog": "Bắt buộc danh mục",
+ "mandatoryDialogDesc": "Hộp thoại mở, nhân viên phải chọn một danh mục. Tóm tắt tùy chọn.",
+ "mandatoryBoth": "Bắt buộc danh mục + tóm tắt",
+ "mandatoryBothDesc": "Hộp thoại mở, nhân viên phải chọn danh mục VÀ viết tóm tắt cuộc trò chuyện."
+ },
+ "dialog": {
+ "title": "Đóng cuộc trò chuyện",
+ "description": {
+ "optional": "Phân loại và/hoặc tóm tắt cuộc trò chuyện này. Cả hai trường đều tùy chọn.",
+ "mandatoryDialog": "Chọn một danh mục trước khi đóng cuộc trò chuyện này.",
+ "mandatoryBoth": "Chọn một danh mục và viết tóm tắt ngắn trước khi đóng."
+ },
+ "categoryLabel": "Danh mục",
+ "categoryPlaceholder": "Chọn một danh mục",
+ "summaryLabel": "Tóm tắt",
+ "summaryPlaceholder": "Mô tả ngắn gọn điều đã xảy ra trong cuộc trò chuyện này…",
+ "noCategories": "Chưa đăng ký danh mục nào. Yêu cầu quản trị viên thêm danh mục trong Cài đặt → Ghi chú khi đóng.",
+ "skip": "Bỏ qua và đóng",
+ "confirm": "Đóng cuộc trò chuyện",
+ "successToast": "Đã đóng cuộc trò chuyện"
+ }
}
}
diff --git a/apps/builder/next.config.ts b/apps/builder/next.config.ts
index d1128238b..2949633b7 100644
--- a/apps/builder/next.config.ts
+++ b/apps/builder/next.config.ts
@@ -11,6 +11,9 @@ const withNextIntl = createNextIntlPlugin({
const nextConfig: NextConfig = {
reactStrictMode: true,
output: "standalone",
+ // Desliga o popup "Route/Bundler/Turbopack" do Next.js que aparece em dev.
+ // Pedro reclamou 2026-05-24 que o popup atrapalha. Não afeta o app.
+ devIndicators: false,
images: {
// dangerouslyAllowLocalIP: true,
remotePatterns: [
@@ -48,6 +51,27 @@ const nextConfig: NextConfig = {
},
]
},
+ async redirects() {
+ return [
+ // Camada 2 — Dados Mestres movidos pra /settings/* (Respond.io match).
+ // Mantém redirects pras URLs antigas pra não quebrar bookmarks.
+ {
+ source: "/space/:workspaceId/tags",
+ destination: "/space/:workspaceId/settings/tags",
+ permanent: true,
+ },
+ {
+ source: "/space/:workspaceId/custom-fields",
+ destination: "/space/:workspaceId/settings/contact-fields",
+ permanent: true,
+ },
+ {
+ source: "/space/:workspaceId/bot-fields",
+ destination: "/space/:workspaceId/settings/bot-fields",
+ permanent: true,
+ },
+ ]
+ },
headers() {
return [
{
diff --git a/apps/builder/package.json b/apps/builder/package.json
index 9dd79a089..99392412a 100644
--- a/apps/builder/package.json
+++ b/apps/builder/package.json
@@ -85,6 +85,7 @@
"dot-prop": "^10.1.0",
"emoji-picker-react": "^4.19.1",
"html-to-text": "^9.0.5",
+ "iconsax-reactjs": "^0.0.8",
"ky": "^2.0.2",
"libphonenumber-js": "^1.12.42",
"lodash.unset": "^4.18.0",
@@ -93,7 +94,7 @@
"next": "16.2.4",
"next-intl": "^4.11.0",
"next-safe-action": "^8.5.2",
- "next-themes": "^0.4.6",
+ "next-themes": "0.4.6",
"nuqs": "^2.8.9",
"partysocket": "1.1.18",
"pretty-bytes": "^7.1.0",
@@ -131,6 +132,7 @@
"@types/react-dom": "19.2.3",
"@types/swagger-ui-react": "^5.18.0",
"babel-plugin-react-compiler": "^1.0.0",
+ "click-to-react-component": "^1.1.3",
"dotenv-cli": "^11.0.0",
"pg": "^8.20.0",
"pino-pretty": "^13.1.3",
diff --git a/apps/builder/public/brand/icon-ai-agent.svg b/apps/builder/public/brand/icon-ai-agent.svg
new file mode 100644
index 000000000..a296e385d
--- /dev/null
+++ b/apps/builder/public/brand/icon-ai-agent.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/builder/public/brand/icon-flow.svg b/apps/builder/public/brand/icon-flow.svg
new file mode 100644
index 000000000..1b1e4411f
--- /dev/null
+++ b/apps/builder/public/brand/icon-flow.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/builder/public/brand/x1_black.png b/apps/builder/public/brand/x1_black.png
new file mode 100644
index 000000000..25af93ea5
Binary files /dev/null and b/apps/builder/public/brand/x1_black.png differ
diff --git a/apps/builder/public/brand/x1_white.png b/apps/builder/public/brand/x1_white.png
new file mode 100644
index 000000000..231d1bab5
Binary files /dev/null and b/apps/builder/public/brand/x1_white.png differ
diff --git a/apps/builder/public/respond-avatars/amber-lighten-1/1.webp b/apps/builder/public/respond-avatars/amber-lighten-1/1.webp
new file mode 100644
index 000000000..78c455f63
Binary files /dev/null and b/apps/builder/public/respond-avatars/amber-lighten-1/1.webp differ
diff --git a/apps/builder/public/respond-avatars/amber-lighten-1/10.webp b/apps/builder/public/respond-avatars/amber-lighten-1/10.webp
new file mode 100644
index 000000000..fc64baa0f
Binary files /dev/null and b/apps/builder/public/respond-avatars/amber-lighten-1/10.webp differ
diff --git a/apps/builder/public/respond-avatars/amber-lighten-1/11.webp b/apps/builder/public/respond-avatars/amber-lighten-1/11.webp
new file mode 100644
index 000000000..0eef11cad
Binary files /dev/null and b/apps/builder/public/respond-avatars/amber-lighten-1/11.webp differ
diff --git a/apps/builder/public/respond-avatars/amber-lighten-1/12.webp b/apps/builder/public/respond-avatars/amber-lighten-1/12.webp
new file mode 100644
index 000000000..2d1af5cec
Binary files /dev/null and b/apps/builder/public/respond-avatars/amber-lighten-1/12.webp differ
diff --git a/apps/builder/public/respond-avatars/amber-lighten-1/2.webp b/apps/builder/public/respond-avatars/amber-lighten-1/2.webp
new file mode 100644
index 000000000..2f6f8c5a3
Binary files /dev/null and b/apps/builder/public/respond-avatars/amber-lighten-1/2.webp differ
diff --git a/apps/builder/public/respond-avatars/amber-lighten-1/3.webp b/apps/builder/public/respond-avatars/amber-lighten-1/3.webp
new file mode 100644
index 000000000..7d7ea17a4
Binary files /dev/null and b/apps/builder/public/respond-avatars/amber-lighten-1/3.webp differ
diff --git a/apps/builder/public/respond-avatars/amber-lighten-1/4.webp b/apps/builder/public/respond-avatars/amber-lighten-1/4.webp
new file mode 100644
index 000000000..056f120b2
Binary files /dev/null and b/apps/builder/public/respond-avatars/amber-lighten-1/4.webp differ
diff --git a/apps/builder/public/respond-avatars/amber-lighten-1/5.webp b/apps/builder/public/respond-avatars/amber-lighten-1/5.webp
new file mode 100644
index 000000000..048fadc10
Binary files /dev/null and b/apps/builder/public/respond-avatars/amber-lighten-1/5.webp differ
diff --git a/apps/builder/public/respond-avatars/amber-lighten-1/6.webp b/apps/builder/public/respond-avatars/amber-lighten-1/6.webp
new file mode 100644
index 000000000..89c9a6a46
Binary files /dev/null and b/apps/builder/public/respond-avatars/amber-lighten-1/6.webp differ
diff --git a/apps/builder/public/respond-avatars/amber-lighten-1/7.webp b/apps/builder/public/respond-avatars/amber-lighten-1/7.webp
new file mode 100644
index 000000000..7189a66a0
Binary files /dev/null and b/apps/builder/public/respond-avatars/amber-lighten-1/7.webp differ
diff --git a/apps/builder/public/respond-avatars/amber-lighten-1/8.webp b/apps/builder/public/respond-avatars/amber-lighten-1/8.webp
new file mode 100644
index 000000000..075ccab3e
Binary files /dev/null and b/apps/builder/public/respond-avatars/amber-lighten-1/8.webp differ
diff --git a/apps/builder/public/respond-avatars/amber-lighten-1/9.webp b/apps/builder/public/respond-avatars/amber-lighten-1/9.webp
new file mode 100644
index 000000000..5a985974b
Binary files /dev/null and b/apps/builder/public/respond-avatars/amber-lighten-1/9.webp differ
diff --git a/apps/builder/public/respond-avatars/blue-darken-1/1.webp b/apps/builder/public/respond-avatars/blue-darken-1/1.webp
new file mode 100644
index 000000000..8470c1983
Binary files /dev/null and b/apps/builder/public/respond-avatars/blue-darken-1/1.webp differ
diff --git a/apps/builder/public/respond-avatars/blue-darken-1/10.webp b/apps/builder/public/respond-avatars/blue-darken-1/10.webp
new file mode 100644
index 000000000..fa27c354c
Binary files /dev/null and b/apps/builder/public/respond-avatars/blue-darken-1/10.webp differ
diff --git a/apps/builder/public/respond-avatars/blue-darken-1/11.webp b/apps/builder/public/respond-avatars/blue-darken-1/11.webp
new file mode 100644
index 000000000..8c4022cb0
Binary files /dev/null and b/apps/builder/public/respond-avatars/blue-darken-1/11.webp differ
diff --git a/apps/builder/public/respond-avatars/blue-darken-1/12.webp b/apps/builder/public/respond-avatars/blue-darken-1/12.webp
new file mode 100644
index 000000000..277a797ef
Binary files /dev/null and b/apps/builder/public/respond-avatars/blue-darken-1/12.webp differ
diff --git a/apps/builder/public/respond-avatars/blue-darken-1/2.webp b/apps/builder/public/respond-avatars/blue-darken-1/2.webp
new file mode 100644
index 000000000..752d23c0b
Binary files /dev/null and b/apps/builder/public/respond-avatars/blue-darken-1/2.webp differ
diff --git a/apps/builder/public/respond-avatars/blue-darken-1/3.webp b/apps/builder/public/respond-avatars/blue-darken-1/3.webp
new file mode 100644
index 000000000..54dc56cdb
Binary files /dev/null and b/apps/builder/public/respond-avatars/blue-darken-1/3.webp differ
diff --git a/apps/builder/public/respond-avatars/blue-darken-1/4.webp b/apps/builder/public/respond-avatars/blue-darken-1/4.webp
new file mode 100644
index 000000000..090a365ab
Binary files /dev/null and b/apps/builder/public/respond-avatars/blue-darken-1/4.webp differ
diff --git a/apps/builder/public/respond-avatars/blue-darken-1/5.webp b/apps/builder/public/respond-avatars/blue-darken-1/5.webp
new file mode 100644
index 000000000..73aff2adc
Binary files /dev/null and b/apps/builder/public/respond-avatars/blue-darken-1/5.webp differ
diff --git a/apps/builder/public/respond-avatars/blue-darken-1/6.webp b/apps/builder/public/respond-avatars/blue-darken-1/6.webp
new file mode 100644
index 000000000..00ee60b69
Binary files /dev/null and b/apps/builder/public/respond-avatars/blue-darken-1/6.webp differ
diff --git a/apps/builder/public/respond-avatars/blue-darken-1/7.webp b/apps/builder/public/respond-avatars/blue-darken-1/7.webp
new file mode 100644
index 000000000..b3bc5597b
Binary files /dev/null and b/apps/builder/public/respond-avatars/blue-darken-1/7.webp differ
diff --git a/apps/builder/public/respond-avatars/blue-darken-1/8.webp b/apps/builder/public/respond-avatars/blue-darken-1/8.webp
new file mode 100644
index 000000000..fc278821a
Binary files /dev/null and b/apps/builder/public/respond-avatars/blue-darken-1/8.webp differ
diff --git a/apps/builder/public/respond-avatars/blue-darken-1/9.webp b/apps/builder/public/respond-avatars/blue-darken-1/9.webp
new file mode 100644
index 000000000..e35f19520
Binary files /dev/null and b/apps/builder/public/respond-avatars/blue-darken-1/9.webp differ
diff --git a/apps/builder/public/respond-avatars/brown-lighten-2/1.webp b/apps/builder/public/respond-avatars/brown-lighten-2/1.webp
new file mode 100644
index 000000000..9f087e08d
Binary files /dev/null and b/apps/builder/public/respond-avatars/brown-lighten-2/1.webp differ
diff --git a/apps/builder/public/respond-avatars/brown-lighten-2/10.webp b/apps/builder/public/respond-avatars/brown-lighten-2/10.webp
new file mode 100644
index 000000000..82791d6b6
Binary files /dev/null and b/apps/builder/public/respond-avatars/brown-lighten-2/10.webp differ
diff --git a/apps/builder/public/respond-avatars/brown-lighten-2/11.webp b/apps/builder/public/respond-avatars/brown-lighten-2/11.webp
new file mode 100644
index 000000000..bc7588179
Binary files /dev/null and b/apps/builder/public/respond-avatars/brown-lighten-2/11.webp differ
diff --git a/apps/builder/public/respond-avatars/brown-lighten-2/12.webp b/apps/builder/public/respond-avatars/brown-lighten-2/12.webp
new file mode 100644
index 000000000..1a108aaf1
Binary files /dev/null and b/apps/builder/public/respond-avatars/brown-lighten-2/12.webp differ
diff --git a/apps/builder/public/respond-avatars/brown-lighten-2/2.webp b/apps/builder/public/respond-avatars/brown-lighten-2/2.webp
new file mode 100644
index 000000000..79bbb866c
Binary files /dev/null and b/apps/builder/public/respond-avatars/brown-lighten-2/2.webp differ
diff --git a/apps/builder/public/respond-avatars/brown-lighten-2/3.webp b/apps/builder/public/respond-avatars/brown-lighten-2/3.webp
new file mode 100644
index 000000000..60640f311
Binary files /dev/null and b/apps/builder/public/respond-avatars/brown-lighten-2/3.webp differ
diff --git a/apps/builder/public/respond-avatars/brown-lighten-2/4.webp b/apps/builder/public/respond-avatars/brown-lighten-2/4.webp
new file mode 100644
index 000000000..9b569bb20
Binary files /dev/null and b/apps/builder/public/respond-avatars/brown-lighten-2/4.webp differ
diff --git a/apps/builder/public/respond-avatars/brown-lighten-2/5.webp b/apps/builder/public/respond-avatars/brown-lighten-2/5.webp
new file mode 100644
index 000000000..588e6df7e
Binary files /dev/null and b/apps/builder/public/respond-avatars/brown-lighten-2/5.webp differ
diff --git a/apps/builder/public/respond-avatars/brown-lighten-2/6.webp b/apps/builder/public/respond-avatars/brown-lighten-2/6.webp
new file mode 100644
index 000000000..1e383e5fb
Binary files /dev/null and b/apps/builder/public/respond-avatars/brown-lighten-2/6.webp differ
diff --git a/apps/builder/public/respond-avatars/brown-lighten-2/7.webp b/apps/builder/public/respond-avatars/brown-lighten-2/7.webp
new file mode 100644
index 000000000..92049115c
Binary files /dev/null and b/apps/builder/public/respond-avatars/brown-lighten-2/7.webp differ
diff --git a/apps/builder/public/respond-avatars/brown-lighten-2/8.webp b/apps/builder/public/respond-avatars/brown-lighten-2/8.webp
new file mode 100644
index 000000000..9b6c10729
Binary files /dev/null and b/apps/builder/public/respond-avatars/brown-lighten-2/8.webp differ
diff --git a/apps/builder/public/respond-avatars/brown-lighten-2/9.webp b/apps/builder/public/respond-avatars/brown-lighten-2/9.webp
new file mode 100644
index 000000000..61e45d8a2
Binary files /dev/null and b/apps/builder/public/respond-avatars/brown-lighten-2/9.webp differ
diff --git a/apps/builder/public/respond-avatars/deep-orange-lighten-1/1.webp b/apps/builder/public/respond-avatars/deep-orange-lighten-1/1.webp
new file mode 100644
index 000000000..0584dfc76
Binary files /dev/null and b/apps/builder/public/respond-avatars/deep-orange-lighten-1/1.webp differ
diff --git a/apps/builder/public/respond-avatars/deep-orange-lighten-1/10.webp b/apps/builder/public/respond-avatars/deep-orange-lighten-1/10.webp
new file mode 100644
index 000000000..f3b3bc711
Binary files /dev/null and b/apps/builder/public/respond-avatars/deep-orange-lighten-1/10.webp differ
diff --git a/apps/builder/public/respond-avatars/deep-orange-lighten-1/11.webp b/apps/builder/public/respond-avatars/deep-orange-lighten-1/11.webp
new file mode 100644
index 000000000..d55095742
Binary files /dev/null and b/apps/builder/public/respond-avatars/deep-orange-lighten-1/11.webp differ
diff --git a/apps/builder/public/respond-avatars/deep-orange-lighten-1/12.webp b/apps/builder/public/respond-avatars/deep-orange-lighten-1/12.webp
new file mode 100644
index 000000000..06ecbfd9b
Binary files /dev/null and b/apps/builder/public/respond-avatars/deep-orange-lighten-1/12.webp differ
diff --git a/apps/builder/public/respond-avatars/deep-orange-lighten-1/2.webp b/apps/builder/public/respond-avatars/deep-orange-lighten-1/2.webp
new file mode 100644
index 000000000..3d0de415f
Binary files /dev/null and b/apps/builder/public/respond-avatars/deep-orange-lighten-1/2.webp differ
diff --git a/apps/builder/public/respond-avatars/deep-orange-lighten-1/3.webp b/apps/builder/public/respond-avatars/deep-orange-lighten-1/3.webp
new file mode 100644
index 000000000..5a6cb0b81
Binary files /dev/null and b/apps/builder/public/respond-avatars/deep-orange-lighten-1/3.webp differ
diff --git a/apps/builder/public/respond-avatars/deep-orange-lighten-1/4.webp b/apps/builder/public/respond-avatars/deep-orange-lighten-1/4.webp
new file mode 100644
index 000000000..061c87656
Binary files /dev/null and b/apps/builder/public/respond-avatars/deep-orange-lighten-1/4.webp differ
diff --git a/apps/builder/public/respond-avatars/deep-orange-lighten-1/5.webp b/apps/builder/public/respond-avatars/deep-orange-lighten-1/5.webp
new file mode 100644
index 000000000..f27131e3b
Binary files /dev/null and b/apps/builder/public/respond-avatars/deep-orange-lighten-1/5.webp differ
diff --git a/apps/builder/public/respond-avatars/deep-orange-lighten-1/6.webp b/apps/builder/public/respond-avatars/deep-orange-lighten-1/6.webp
new file mode 100644
index 000000000..dfc274b04
Binary files /dev/null and b/apps/builder/public/respond-avatars/deep-orange-lighten-1/6.webp differ
diff --git a/apps/builder/public/respond-avatars/deep-orange-lighten-1/7.webp b/apps/builder/public/respond-avatars/deep-orange-lighten-1/7.webp
new file mode 100644
index 000000000..dc8471573
Binary files /dev/null and b/apps/builder/public/respond-avatars/deep-orange-lighten-1/7.webp differ
diff --git a/apps/builder/public/respond-avatars/deep-orange-lighten-1/8.webp b/apps/builder/public/respond-avatars/deep-orange-lighten-1/8.webp
new file mode 100644
index 000000000..512d0641c
Binary files /dev/null and b/apps/builder/public/respond-avatars/deep-orange-lighten-1/8.webp differ
diff --git a/apps/builder/public/respond-avatars/deep-orange-lighten-1/9.webp b/apps/builder/public/respond-avatars/deep-orange-lighten-1/9.webp
new file mode 100644
index 000000000..aa3a1f9eb
Binary files /dev/null and b/apps/builder/public/respond-avatars/deep-orange-lighten-1/9.webp differ
diff --git a/apps/builder/public/respond-avatars/deep-purple-lighten-2/1.webp b/apps/builder/public/respond-avatars/deep-purple-lighten-2/1.webp
new file mode 100644
index 000000000..90dbd144a
Binary files /dev/null and b/apps/builder/public/respond-avatars/deep-purple-lighten-2/1.webp differ
diff --git a/apps/builder/public/respond-avatars/deep-purple-lighten-2/10.webp b/apps/builder/public/respond-avatars/deep-purple-lighten-2/10.webp
new file mode 100644
index 000000000..14ec4ab1c
Binary files /dev/null and b/apps/builder/public/respond-avatars/deep-purple-lighten-2/10.webp differ
diff --git a/apps/builder/public/respond-avatars/deep-purple-lighten-2/11.webp b/apps/builder/public/respond-avatars/deep-purple-lighten-2/11.webp
new file mode 100644
index 000000000..367146a32
Binary files /dev/null and b/apps/builder/public/respond-avatars/deep-purple-lighten-2/11.webp differ
diff --git a/apps/builder/public/respond-avatars/deep-purple-lighten-2/12.webp b/apps/builder/public/respond-avatars/deep-purple-lighten-2/12.webp
new file mode 100644
index 000000000..e0d44783c
Binary files /dev/null and b/apps/builder/public/respond-avatars/deep-purple-lighten-2/12.webp differ
diff --git a/apps/builder/public/respond-avatars/deep-purple-lighten-2/2.webp b/apps/builder/public/respond-avatars/deep-purple-lighten-2/2.webp
new file mode 100644
index 000000000..5133f181f
Binary files /dev/null and b/apps/builder/public/respond-avatars/deep-purple-lighten-2/2.webp differ
diff --git a/apps/builder/public/respond-avatars/deep-purple-lighten-2/3.webp b/apps/builder/public/respond-avatars/deep-purple-lighten-2/3.webp
new file mode 100644
index 000000000..3369d1d30
Binary files /dev/null and b/apps/builder/public/respond-avatars/deep-purple-lighten-2/3.webp differ
diff --git a/apps/builder/public/respond-avatars/deep-purple-lighten-2/4.webp b/apps/builder/public/respond-avatars/deep-purple-lighten-2/4.webp
new file mode 100644
index 000000000..5695fed77
Binary files /dev/null and b/apps/builder/public/respond-avatars/deep-purple-lighten-2/4.webp differ
diff --git a/apps/builder/public/respond-avatars/deep-purple-lighten-2/5.webp b/apps/builder/public/respond-avatars/deep-purple-lighten-2/5.webp
new file mode 100644
index 000000000..bac3b97f9
Binary files /dev/null and b/apps/builder/public/respond-avatars/deep-purple-lighten-2/5.webp differ
diff --git a/apps/builder/public/respond-avatars/deep-purple-lighten-2/6.webp b/apps/builder/public/respond-avatars/deep-purple-lighten-2/6.webp
new file mode 100644
index 000000000..5dd29b252
Binary files /dev/null and b/apps/builder/public/respond-avatars/deep-purple-lighten-2/6.webp differ
diff --git a/apps/builder/public/respond-avatars/deep-purple-lighten-2/7.webp b/apps/builder/public/respond-avatars/deep-purple-lighten-2/7.webp
new file mode 100644
index 000000000..0d5c26144
Binary files /dev/null and b/apps/builder/public/respond-avatars/deep-purple-lighten-2/7.webp differ
diff --git a/apps/builder/public/respond-avatars/deep-purple-lighten-2/8.webp b/apps/builder/public/respond-avatars/deep-purple-lighten-2/8.webp
new file mode 100644
index 000000000..b4f4a14c8
Binary files /dev/null and b/apps/builder/public/respond-avatars/deep-purple-lighten-2/8.webp differ
diff --git a/apps/builder/public/respond-avatars/deep-purple-lighten-2/9.webp b/apps/builder/public/respond-avatars/deep-purple-lighten-2/9.webp
new file mode 100644
index 000000000..d5928cb4c
Binary files /dev/null and b/apps/builder/public/respond-avatars/deep-purple-lighten-2/9.webp differ
diff --git a/apps/builder/public/respond-avatars/indigo-lighten-2/1.webp b/apps/builder/public/respond-avatars/indigo-lighten-2/1.webp
new file mode 100644
index 000000000..4055226ff
Binary files /dev/null and b/apps/builder/public/respond-avatars/indigo-lighten-2/1.webp differ
diff --git a/apps/builder/public/respond-avatars/indigo-lighten-2/10.webp b/apps/builder/public/respond-avatars/indigo-lighten-2/10.webp
new file mode 100644
index 000000000..5f6a0e803
Binary files /dev/null and b/apps/builder/public/respond-avatars/indigo-lighten-2/10.webp differ
diff --git a/apps/builder/public/respond-avatars/indigo-lighten-2/11.webp b/apps/builder/public/respond-avatars/indigo-lighten-2/11.webp
new file mode 100644
index 000000000..967f6513e
Binary files /dev/null and b/apps/builder/public/respond-avatars/indigo-lighten-2/11.webp differ
diff --git a/apps/builder/public/respond-avatars/indigo-lighten-2/12.webp b/apps/builder/public/respond-avatars/indigo-lighten-2/12.webp
new file mode 100644
index 000000000..ce0085fee
Binary files /dev/null and b/apps/builder/public/respond-avatars/indigo-lighten-2/12.webp differ
diff --git a/apps/builder/public/respond-avatars/indigo-lighten-2/2.webp b/apps/builder/public/respond-avatars/indigo-lighten-2/2.webp
new file mode 100644
index 000000000..b4d09e747
Binary files /dev/null and b/apps/builder/public/respond-avatars/indigo-lighten-2/2.webp differ
diff --git a/apps/builder/public/respond-avatars/indigo-lighten-2/3.webp b/apps/builder/public/respond-avatars/indigo-lighten-2/3.webp
new file mode 100644
index 000000000..c8f9cd8e9
Binary files /dev/null and b/apps/builder/public/respond-avatars/indigo-lighten-2/3.webp differ
diff --git a/apps/builder/public/respond-avatars/indigo-lighten-2/4.webp b/apps/builder/public/respond-avatars/indigo-lighten-2/4.webp
new file mode 100644
index 000000000..fad0b2945
Binary files /dev/null and b/apps/builder/public/respond-avatars/indigo-lighten-2/4.webp differ
diff --git a/apps/builder/public/respond-avatars/indigo-lighten-2/5.webp b/apps/builder/public/respond-avatars/indigo-lighten-2/5.webp
new file mode 100644
index 000000000..302dcdceb
Binary files /dev/null and b/apps/builder/public/respond-avatars/indigo-lighten-2/5.webp differ
diff --git a/apps/builder/public/respond-avatars/indigo-lighten-2/6.webp b/apps/builder/public/respond-avatars/indigo-lighten-2/6.webp
new file mode 100644
index 000000000..8eef4498d
Binary files /dev/null and b/apps/builder/public/respond-avatars/indigo-lighten-2/6.webp differ
diff --git a/apps/builder/public/respond-avatars/indigo-lighten-2/7.webp b/apps/builder/public/respond-avatars/indigo-lighten-2/7.webp
new file mode 100644
index 000000000..ce7025671
Binary files /dev/null and b/apps/builder/public/respond-avatars/indigo-lighten-2/7.webp differ
diff --git a/apps/builder/public/respond-avatars/indigo-lighten-2/8.webp b/apps/builder/public/respond-avatars/indigo-lighten-2/8.webp
new file mode 100644
index 000000000..d70aeada9
Binary files /dev/null and b/apps/builder/public/respond-avatars/indigo-lighten-2/8.webp differ
diff --git a/apps/builder/public/respond-avatars/indigo-lighten-2/9.webp b/apps/builder/public/respond-avatars/indigo-lighten-2/9.webp
new file mode 100644
index 000000000..7555dd7ae
Binary files /dev/null and b/apps/builder/public/respond-avatars/indigo-lighten-2/9.webp differ
diff --git a/apps/builder/public/respond-avatars/light-green-darken-2/1.webp b/apps/builder/public/respond-avatars/light-green-darken-2/1.webp
new file mode 100644
index 000000000..8e5a07968
Binary files /dev/null and b/apps/builder/public/respond-avatars/light-green-darken-2/1.webp differ
diff --git a/apps/builder/public/respond-avatars/light-green-darken-2/10.webp b/apps/builder/public/respond-avatars/light-green-darken-2/10.webp
new file mode 100644
index 000000000..57c0d36ce
Binary files /dev/null and b/apps/builder/public/respond-avatars/light-green-darken-2/10.webp differ
diff --git a/apps/builder/public/respond-avatars/light-green-darken-2/11.webp b/apps/builder/public/respond-avatars/light-green-darken-2/11.webp
new file mode 100644
index 000000000..5aee534c5
Binary files /dev/null and b/apps/builder/public/respond-avatars/light-green-darken-2/11.webp differ
diff --git a/apps/builder/public/respond-avatars/light-green-darken-2/12.webp b/apps/builder/public/respond-avatars/light-green-darken-2/12.webp
new file mode 100644
index 000000000..f694cc6d7
Binary files /dev/null and b/apps/builder/public/respond-avatars/light-green-darken-2/12.webp differ
diff --git a/apps/builder/public/respond-avatars/light-green-darken-2/2.webp b/apps/builder/public/respond-avatars/light-green-darken-2/2.webp
new file mode 100644
index 000000000..19d3f059f
Binary files /dev/null and b/apps/builder/public/respond-avatars/light-green-darken-2/2.webp differ
diff --git a/apps/builder/public/respond-avatars/light-green-darken-2/3.webp b/apps/builder/public/respond-avatars/light-green-darken-2/3.webp
new file mode 100644
index 000000000..59156034e
Binary files /dev/null and b/apps/builder/public/respond-avatars/light-green-darken-2/3.webp differ
diff --git a/apps/builder/public/respond-avatars/light-green-darken-2/4.webp b/apps/builder/public/respond-avatars/light-green-darken-2/4.webp
new file mode 100644
index 000000000..bc5446702
Binary files /dev/null and b/apps/builder/public/respond-avatars/light-green-darken-2/4.webp differ
diff --git a/apps/builder/public/respond-avatars/light-green-darken-2/5.webp b/apps/builder/public/respond-avatars/light-green-darken-2/5.webp
new file mode 100644
index 000000000..73131d5c6
Binary files /dev/null and b/apps/builder/public/respond-avatars/light-green-darken-2/5.webp differ
diff --git a/apps/builder/public/respond-avatars/light-green-darken-2/6.webp b/apps/builder/public/respond-avatars/light-green-darken-2/6.webp
new file mode 100644
index 000000000..a0e3eb813
Binary files /dev/null and b/apps/builder/public/respond-avatars/light-green-darken-2/6.webp differ
diff --git a/apps/builder/public/respond-avatars/light-green-darken-2/7.webp b/apps/builder/public/respond-avatars/light-green-darken-2/7.webp
new file mode 100644
index 000000000..7d927ff5f
Binary files /dev/null and b/apps/builder/public/respond-avatars/light-green-darken-2/7.webp differ
diff --git a/apps/builder/public/respond-avatars/light-green-darken-2/8.webp b/apps/builder/public/respond-avatars/light-green-darken-2/8.webp
new file mode 100644
index 000000000..7e571176e
Binary files /dev/null and b/apps/builder/public/respond-avatars/light-green-darken-2/8.webp differ
diff --git a/apps/builder/public/respond-avatars/light-green-darken-2/9.webp b/apps/builder/public/respond-avatars/light-green-darken-2/9.webp
new file mode 100644
index 000000000..89ef86cfc
Binary files /dev/null and b/apps/builder/public/respond-avatars/light-green-darken-2/9.webp differ
diff --git a/apps/builder/public/respond-avatars/lime-darken-2/1.webp b/apps/builder/public/respond-avatars/lime-darken-2/1.webp
new file mode 100644
index 000000000..d67edfbac
Binary files /dev/null and b/apps/builder/public/respond-avatars/lime-darken-2/1.webp differ
diff --git a/apps/builder/public/respond-avatars/lime-darken-2/10.webp b/apps/builder/public/respond-avatars/lime-darken-2/10.webp
new file mode 100644
index 000000000..d461652f4
Binary files /dev/null and b/apps/builder/public/respond-avatars/lime-darken-2/10.webp differ
diff --git a/apps/builder/public/respond-avatars/lime-darken-2/11.webp b/apps/builder/public/respond-avatars/lime-darken-2/11.webp
new file mode 100644
index 000000000..a3145f103
Binary files /dev/null and b/apps/builder/public/respond-avatars/lime-darken-2/11.webp differ
diff --git a/apps/builder/public/respond-avatars/lime-darken-2/12.webp b/apps/builder/public/respond-avatars/lime-darken-2/12.webp
new file mode 100644
index 000000000..3ec33fc73
Binary files /dev/null and b/apps/builder/public/respond-avatars/lime-darken-2/12.webp differ
diff --git a/apps/builder/public/respond-avatars/lime-darken-2/2.webp b/apps/builder/public/respond-avatars/lime-darken-2/2.webp
new file mode 100644
index 000000000..624543c33
Binary files /dev/null and b/apps/builder/public/respond-avatars/lime-darken-2/2.webp differ
diff --git a/apps/builder/public/respond-avatars/lime-darken-2/3.webp b/apps/builder/public/respond-avatars/lime-darken-2/3.webp
new file mode 100644
index 000000000..74547e287
Binary files /dev/null and b/apps/builder/public/respond-avatars/lime-darken-2/3.webp differ
diff --git a/apps/builder/public/respond-avatars/lime-darken-2/4.webp b/apps/builder/public/respond-avatars/lime-darken-2/4.webp
new file mode 100644
index 000000000..3ed87fbae
Binary files /dev/null and b/apps/builder/public/respond-avatars/lime-darken-2/4.webp differ
diff --git a/apps/builder/public/respond-avatars/lime-darken-2/5.webp b/apps/builder/public/respond-avatars/lime-darken-2/5.webp
new file mode 100644
index 000000000..00d4607ba
Binary files /dev/null and b/apps/builder/public/respond-avatars/lime-darken-2/5.webp differ
diff --git a/apps/builder/public/respond-avatars/lime-darken-2/6.webp b/apps/builder/public/respond-avatars/lime-darken-2/6.webp
new file mode 100644
index 000000000..af814b378
Binary files /dev/null and b/apps/builder/public/respond-avatars/lime-darken-2/6.webp differ
diff --git a/apps/builder/public/respond-avatars/lime-darken-2/7.webp b/apps/builder/public/respond-avatars/lime-darken-2/7.webp
new file mode 100644
index 000000000..b2798d5bf
Binary files /dev/null and b/apps/builder/public/respond-avatars/lime-darken-2/7.webp differ
diff --git a/apps/builder/public/respond-avatars/lime-darken-2/8.webp b/apps/builder/public/respond-avatars/lime-darken-2/8.webp
new file mode 100644
index 000000000..fe74e7ccf
Binary files /dev/null and b/apps/builder/public/respond-avatars/lime-darken-2/8.webp differ
diff --git a/apps/builder/public/respond-avatars/lime-darken-2/9.webp b/apps/builder/public/respond-avatars/lime-darken-2/9.webp
new file mode 100644
index 000000000..ffea6417c
Binary files /dev/null and b/apps/builder/public/respond-avatars/lime-darken-2/9.webp differ
diff --git a/apps/builder/public/respond-avatars/orange-lighten-1/1.webp b/apps/builder/public/respond-avatars/orange-lighten-1/1.webp
new file mode 100644
index 000000000..ef28376ef
Binary files /dev/null and b/apps/builder/public/respond-avatars/orange-lighten-1/1.webp differ
diff --git a/apps/builder/public/respond-avatars/orange-lighten-1/10.webp b/apps/builder/public/respond-avatars/orange-lighten-1/10.webp
new file mode 100644
index 000000000..e60cfc644
Binary files /dev/null and b/apps/builder/public/respond-avatars/orange-lighten-1/10.webp differ
diff --git a/apps/builder/public/respond-avatars/orange-lighten-1/11.webp b/apps/builder/public/respond-avatars/orange-lighten-1/11.webp
new file mode 100644
index 000000000..a3cbe2e04
Binary files /dev/null and b/apps/builder/public/respond-avatars/orange-lighten-1/11.webp differ
diff --git a/apps/builder/public/respond-avatars/orange-lighten-1/12.webp b/apps/builder/public/respond-avatars/orange-lighten-1/12.webp
new file mode 100644
index 000000000..f0fb10b65
Binary files /dev/null and b/apps/builder/public/respond-avatars/orange-lighten-1/12.webp differ
diff --git a/apps/builder/public/respond-avatars/orange-lighten-1/2.webp b/apps/builder/public/respond-avatars/orange-lighten-1/2.webp
new file mode 100644
index 000000000..6253ee639
Binary files /dev/null and b/apps/builder/public/respond-avatars/orange-lighten-1/2.webp differ
diff --git a/apps/builder/public/respond-avatars/orange-lighten-1/3.webp b/apps/builder/public/respond-avatars/orange-lighten-1/3.webp
new file mode 100644
index 000000000..abce59793
Binary files /dev/null and b/apps/builder/public/respond-avatars/orange-lighten-1/3.webp differ
diff --git a/apps/builder/public/respond-avatars/orange-lighten-1/4.webp b/apps/builder/public/respond-avatars/orange-lighten-1/4.webp
new file mode 100644
index 000000000..95cf4bd25
Binary files /dev/null and b/apps/builder/public/respond-avatars/orange-lighten-1/4.webp differ
diff --git a/apps/builder/public/respond-avatars/orange-lighten-1/5.webp b/apps/builder/public/respond-avatars/orange-lighten-1/5.webp
new file mode 100644
index 000000000..4ff8171c4
Binary files /dev/null and b/apps/builder/public/respond-avatars/orange-lighten-1/5.webp differ
diff --git a/apps/builder/public/respond-avatars/orange-lighten-1/6.webp b/apps/builder/public/respond-avatars/orange-lighten-1/6.webp
new file mode 100644
index 000000000..fbb7d7172
Binary files /dev/null and b/apps/builder/public/respond-avatars/orange-lighten-1/6.webp differ
diff --git a/apps/builder/public/respond-avatars/orange-lighten-1/7.webp b/apps/builder/public/respond-avatars/orange-lighten-1/7.webp
new file mode 100644
index 000000000..f0233d7c1
Binary files /dev/null and b/apps/builder/public/respond-avatars/orange-lighten-1/7.webp differ
diff --git a/apps/builder/public/respond-avatars/orange-lighten-1/8.webp b/apps/builder/public/respond-avatars/orange-lighten-1/8.webp
new file mode 100644
index 000000000..25b850173
Binary files /dev/null and b/apps/builder/public/respond-avatars/orange-lighten-1/8.webp differ
diff --git a/apps/builder/public/respond-avatars/orange-lighten-1/9.webp b/apps/builder/public/respond-avatars/orange-lighten-1/9.webp
new file mode 100644
index 000000000..4abba75a4
Binary files /dev/null and b/apps/builder/public/respond-avatars/orange-lighten-1/9.webp differ
diff --git a/apps/builder/public/respond-avatars/pink-lighten-2/1.webp b/apps/builder/public/respond-avatars/pink-lighten-2/1.webp
new file mode 100644
index 000000000..059ec6a7a
Binary files /dev/null and b/apps/builder/public/respond-avatars/pink-lighten-2/1.webp differ
diff --git a/apps/builder/public/respond-avatars/pink-lighten-2/10.webp b/apps/builder/public/respond-avatars/pink-lighten-2/10.webp
new file mode 100644
index 000000000..2489b734e
Binary files /dev/null and b/apps/builder/public/respond-avatars/pink-lighten-2/10.webp differ
diff --git a/apps/builder/public/respond-avatars/pink-lighten-2/11.webp b/apps/builder/public/respond-avatars/pink-lighten-2/11.webp
new file mode 100644
index 000000000..0d760f7ae
Binary files /dev/null and b/apps/builder/public/respond-avatars/pink-lighten-2/11.webp differ
diff --git a/apps/builder/public/respond-avatars/pink-lighten-2/12.webp b/apps/builder/public/respond-avatars/pink-lighten-2/12.webp
new file mode 100644
index 000000000..261d581e6
Binary files /dev/null and b/apps/builder/public/respond-avatars/pink-lighten-2/12.webp differ
diff --git a/apps/builder/public/respond-avatars/pink-lighten-2/2.webp b/apps/builder/public/respond-avatars/pink-lighten-2/2.webp
new file mode 100644
index 000000000..7a636533a
Binary files /dev/null and b/apps/builder/public/respond-avatars/pink-lighten-2/2.webp differ
diff --git a/apps/builder/public/respond-avatars/pink-lighten-2/3.webp b/apps/builder/public/respond-avatars/pink-lighten-2/3.webp
new file mode 100644
index 000000000..239e49f7b
Binary files /dev/null and b/apps/builder/public/respond-avatars/pink-lighten-2/3.webp differ
diff --git a/apps/builder/public/respond-avatars/pink-lighten-2/4.webp b/apps/builder/public/respond-avatars/pink-lighten-2/4.webp
new file mode 100644
index 000000000..98009b07b
Binary files /dev/null and b/apps/builder/public/respond-avatars/pink-lighten-2/4.webp differ
diff --git a/apps/builder/public/respond-avatars/pink-lighten-2/5.webp b/apps/builder/public/respond-avatars/pink-lighten-2/5.webp
new file mode 100644
index 000000000..2f0681b04
Binary files /dev/null and b/apps/builder/public/respond-avatars/pink-lighten-2/5.webp differ
diff --git a/apps/builder/public/respond-avatars/pink-lighten-2/6.webp b/apps/builder/public/respond-avatars/pink-lighten-2/6.webp
new file mode 100644
index 000000000..251dcf927
Binary files /dev/null and b/apps/builder/public/respond-avatars/pink-lighten-2/6.webp differ
diff --git a/apps/builder/public/respond-avatars/pink-lighten-2/7.webp b/apps/builder/public/respond-avatars/pink-lighten-2/7.webp
new file mode 100644
index 000000000..3ec636a90
Binary files /dev/null and b/apps/builder/public/respond-avatars/pink-lighten-2/7.webp differ
diff --git a/apps/builder/public/respond-avatars/pink-lighten-2/8.webp b/apps/builder/public/respond-avatars/pink-lighten-2/8.webp
new file mode 100644
index 000000000..6a2b50f2d
Binary files /dev/null and b/apps/builder/public/respond-avatars/pink-lighten-2/8.webp differ
diff --git a/apps/builder/public/respond-avatars/pink-lighten-2/9.webp b/apps/builder/public/respond-avatars/pink-lighten-2/9.webp
new file mode 100644
index 000000000..e7f1ad728
Binary files /dev/null and b/apps/builder/public/respond-avatars/pink-lighten-2/9.webp differ
diff --git a/apps/builder/public/respond-avatars/preview.html b/apps/builder/public/respond-avatars/preview.html
new file mode 100644
index 000000000..d138d8229
--- /dev/null
+++ b/apps/builder/public/respond-avatars/preview.html
@@ -0,0 +1,89 @@
+
+
+
+
+ Respond.io avatars (156)
+
+
+
+ Respond.io — Galeria de avatares (156)
+
+ 13 cores × 12 expressões — extraídos de cdn.respond.io
+
+
+
+
+
diff --git a/apps/builder/public/respond-avatars/purple-lighten-2/1.webp b/apps/builder/public/respond-avatars/purple-lighten-2/1.webp
new file mode 100644
index 000000000..d0ef16fbc
Binary files /dev/null and b/apps/builder/public/respond-avatars/purple-lighten-2/1.webp differ
diff --git a/apps/builder/public/respond-avatars/purple-lighten-2/10.webp b/apps/builder/public/respond-avatars/purple-lighten-2/10.webp
new file mode 100644
index 000000000..eb7162538
Binary files /dev/null and b/apps/builder/public/respond-avatars/purple-lighten-2/10.webp differ
diff --git a/apps/builder/public/respond-avatars/purple-lighten-2/11.webp b/apps/builder/public/respond-avatars/purple-lighten-2/11.webp
new file mode 100644
index 000000000..e642dd280
Binary files /dev/null and b/apps/builder/public/respond-avatars/purple-lighten-2/11.webp differ
diff --git a/apps/builder/public/respond-avatars/purple-lighten-2/12.webp b/apps/builder/public/respond-avatars/purple-lighten-2/12.webp
new file mode 100644
index 000000000..b05f387b2
Binary files /dev/null and b/apps/builder/public/respond-avatars/purple-lighten-2/12.webp differ
diff --git a/apps/builder/public/respond-avatars/purple-lighten-2/2.webp b/apps/builder/public/respond-avatars/purple-lighten-2/2.webp
new file mode 100644
index 000000000..8a618223c
Binary files /dev/null and b/apps/builder/public/respond-avatars/purple-lighten-2/2.webp differ
diff --git a/apps/builder/public/respond-avatars/purple-lighten-2/3.webp b/apps/builder/public/respond-avatars/purple-lighten-2/3.webp
new file mode 100644
index 000000000..9ca6b69b7
Binary files /dev/null and b/apps/builder/public/respond-avatars/purple-lighten-2/3.webp differ
diff --git a/apps/builder/public/respond-avatars/purple-lighten-2/4.webp b/apps/builder/public/respond-avatars/purple-lighten-2/4.webp
new file mode 100644
index 000000000..76ea5951b
Binary files /dev/null and b/apps/builder/public/respond-avatars/purple-lighten-2/4.webp differ
diff --git a/apps/builder/public/respond-avatars/purple-lighten-2/5.webp b/apps/builder/public/respond-avatars/purple-lighten-2/5.webp
new file mode 100644
index 000000000..03d853f17
Binary files /dev/null and b/apps/builder/public/respond-avatars/purple-lighten-2/5.webp differ
diff --git a/apps/builder/public/respond-avatars/purple-lighten-2/6.webp b/apps/builder/public/respond-avatars/purple-lighten-2/6.webp
new file mode 100644
index 000000000..5088b84a6
Binary files /dev/null and b/apps/builder/public/respond-avatars/purple-lighten-2/6.webp differ
diff --git a/apps/builder/public/respond-avatars/purple-lighten-2/7.webp b/apps/builder/public/respond-avatars/purple-lighten-2/7.webp
new file mode 100644
index 000000000..ed86e1808
Binary files /dev/null and b/apps/builder/public/respond-avatars/purple-lighten-2/7.webp differ
diff --git a/apps/builder/public/respond-avatars/purple-lighten-2/8.webp b/apps/builder/public/respond-avatars/purple-lighten-2/8.webp
new file mode 100644
index 000000000..a1789f05b
Binary files /dev/null and b/apps/builder/public/respond-avatars/purple-lighten-2/8.webp differ
diff --git a/apps/builder/public/respond-avatars/purple-lighten-2/9.webp b/apps/builder/public/respond-avatars/purple-lighten-2/9.webp
new file mode 100644
index 000000000..7c3f4b65b
Binary files /dev/null and b/apps/builder/public/respond-avatars/purple-lighten-2/9.webp differ
diff --git a/apps/builder/public/respond-avatars/red-lighten-2/1.webp b/apps/builder/public/respond-avatars/red-lighten-2/1.webp
new file mode 100644
index 000000000..ce2f18dd6
Binary files /dev/null and b/apps/builder/public/respond-avatars/red-lighten-2/1.webp differ
diff --git a/apps/builder/public/respond-avatars/red-lighten-2/10.webp b/apps/builder/public/respond-avatars/red-lighten-2/10.webp
new file mode 100644
index 000000000..037b4aa86
Binary files /dev/null and b/apps/builder/public/respond-avatars/red-lighten-2/10.webp differ
diff --git a/apps/builder/public/respond-avatars/red-lighten-2/11.webp b/apps/builder/public/respond-avatars/red-lighten-2/11.webp
new file mode 100644
index 000000000..7c6a54ef7
Binary files /dev/null and b/apps/builder/public/respond-avatars/red-lighten-2/11.webp differ
diff --git a/apps/builder/public/respond-avatars/red-lighten-2/12.webp b/apps/builder/public/respond-avatars/red-lighten-2/12.webp
new file mode 100644
index 000000000..6c02777c3
Binary files /dev/null and b/apps/builder/public/respond-avatars/red-lighten-2/12.webp differ
diff --git a/apps/builder/public/respond-avatars/red-lighten-2/2.webp b/apps/builder/public/respond-avatars/red-lighten-2/2.webp
new file mode 100644
index 000000000..f8d2e7bc2
Binary files /dev/null and b/apps/builder/public/respond-avatars/red-lighten-2/2.webp differ
diff --git a/apps/builder/public/respond-avatars/red-lighten-2/3.webp b/apps/builder/public/respond-avatars/red-lighten-2/3.webp
new file mode 100644
index 000000000..c72a0e55a
Binary files /dev/null and b/apps/builder/public/respond-avatars/red-lighten-2/3.webp differ
diff --git a/apps/builder/public/respond-avatars/red-lighten-2/4.webp b/apps/builder/public/respond-avatars/red-lighten-2/4.webp
new file mode 100644
index 000000000..97afd3953
Binary files /dev/null and b/apps/builder/public/respond-avatars/red-lighten-2/4.webp differ
diff --git a/apps/builder/public/respond-avatars/red-lighten-2/5.webp b/apps/builder/public/respond-avatars/red-lighten-2/5.webp
new file mode 100644
index 000000000..8946964ff
Binary files /dev/null and b/apps/builder/public/respond-avatars/red-lighten-2/5.webp differ
diff --git a/apps/builder/public/respond-avatars/red-lighten-2/6.webp b/apps/builder/public/respond-avatars/red-lighten-2/6.webp
new file mode 100644
index 000000000..be179d930
Binary files /dev/null and b/apps/builder/public/respond-avatars/red-lighten-2/6.webp differ
diff --git a/apps/builder/public/respond-avatars/red-lighten-2/7.webp b/apps/builder/public/respond-avatars/red-lighten-2/7.webp
new file mode 100644
index 000000000..459cda5bf
Binary files /dev/null and b/apps/builder/public/respond-avatars/red-lighten-2/7.webp differ
diff --git a/apps/builder/public/respond-avatars/red-lighten-2/8.webp b/apps/builder/public/respond-avatars/red-lighten-2/8.webp
new file mode 100644
index 000000000..aa1a090e0
Binary files /dev/null and b/apps/builder/public/respond-avatars/red-lighten-2/8.webp differ
diff --git a/apps/builder/public/respond-avatars/red-lighten-2/9.webp b/apps/builder/public/respond-avatars/red-lighten-2/9.webp
new file mode 100644
index 000000000..05873b64d
Binary files /dev/null and b/apps/builder/public/respond-avatars/red-lighten-2/9.webp differ
diff --git a/apps/builder/public/respond-avatars/teal-lighten-2/1.webp b/apps/builder/public/respond-avatars/teal-lighten-2/1.webp
new file mode 100644
index 000000000..a9ebb4239
Binary files /dev/null and b/apps/builder/public/respond-avatars/teal-lighten-2/1.webp differ
diff --git a/apps/builder/public/respond-avatars/teal-lighten-2/10.webp b/apps/builder/public/respond-avatars/teal-lighten-2/10.webp
new file mode 100644
index 000000000..3c33ff1f3
Binary files /dev/null and b/apps/builder/public/respond-avatars/teal-lighten-2/10.webp differ
diff --git a/apps/builder/public/respond-avatars/teal-lighten-2/11.webp b/apps/builder/public/respond-avatars/teal-lighten-2/11.webp
new file mode 100644
index 000000000..1201a5504
Binary files /dev/null and b/apps/builder/public/respond-avatars/teal-lighten-2/11.webp differ
diff --git a/apps/builder/public/respond-avatars/teal-lighten-2/12.webp b/apps/builder/public/respond-avatars/teal-lighten-2/12.webp
new file mode 100644
index 000000000..10956e2ce
Binary files /dev/null and b/apps/builder/public/respond-avatars/teal-lighten-2/12.webp differ
diff --git a/apps/builder/public/respond-avatars/teal-lighten-2/2.webp b/apps/builder/public/respond-avatars/teal-lighten-2/2.webp
new file mode 100644
index 000000000..45617bae2
Binary files /dev/null and b/apps/builder/public/respond-avatars/teal-lighten-2/2.webp differ
diff --git a/apps/builder/public/respond-avatars/teal-lighten-2/3.webp b/apps/builder/public/respond-avatars/teal-lighten-2/3.webp
new file mode 100644
index 000000000..0520204be
Binary files /dev/null and b/apps/builder/public/respond-avatars/teal-lighten-2/3.webp differ
diff --git a/apps/builder/public/respond-avatars/teal-lighten-2/4.webp b/apps/builder/public/respond-avatars/teal-lighten-2/4.webp
new file mode 100644
index 000000000..1a0bf90c1
Binary files /dev/null and b/apps/builder/public/respond-avatars/teal-lighten-2/4.webp differ
diff --git a/apps/builder/public/respond-avatars/teal-lighten-2/5.webp b/apps/builder/public/respond-avatars/teal-lighten-2/5.webp
new file mode 100644
index 000000000..763e6dc6c
Binary files /dev/null and b/apps/builder/public/respond-avatars/teal-lighten-2/5.webp differ
diff --git a/apps/builder/public/respond-avatars/teal-lighten-2/6.webp b/apps/builder/public/respond-avatars/teal-lighten-2/6.webp
new file mode 100644
index 000000000..e9399d521
Binary files /dev/null and b/apps/builder/public/respond-avatars/teal-lighten-2/6.webp differ
diff --git a/apps/builder/public/respond-avatars/teal-lighten-2/7.webp b/apps/builder/public/respond-avatars/teal-lighten-2/7.webp
new file mode 100644
index 000000000..4c1403d06
Binary files /dev/null and b/apps/builder/public/respond-avatars/teal-lighten-2/7.webp differ
diff --git a/apps/builder/public/respond-avatars/teal-lighten-2/8.webp b/apps/builder/public/respond-avatars/teal-lighten-2/8.webp
new file mode 100644
index 000000000..96375da41
Binary files /dev/null and b/apps/builder/public/respond-avatars/teal-lighten-2/8.webp differ
diff --git a/apps/builder/public/respond-avatars/teal-lighten-2/9.webp b/apps/builder/public/respond-avatars/teal-lighten-2/9.webp
new file mode 100644
index 000000000..0bd853e80
Binary files /dev/null and b/apps/builder/public/respond-avatars/teal-lighten-2/9.webp differ
diff --git a/apps/builder/public/respond-icons/iconfont.ttf b/apps/builder/public/respond-icons/iconfont.ttf
new file mode 100644
index 000000000..27038f7d5
Binary files /dev/null and b/apps/builder/public/respond-icons/iconfont.ttf differ
diff --git a/apps/builder/public/respond-icons/iconfont.woff b/apps/builder/public/respond-icons/iconfont.woff
new file mode 100644
index 000000000..0606cc415
Binary files /dev/null and b/apps/builder/public/respond-icons/iconfont.woff differ
diff --git a/apps/builder/public/respond-icons/iconfont.woff2 b/apps/builder/public/respond-icons/iconfont.woff2
new file mode 100644
index 000000000..29ea2a3de
Binary files /dev/null and b/apps/builder/public/respond-icons/iconfont.woff2 differ
diff --git a/apps/builder/src/app/api/audit-logs/export/route.ts b/apps/builder/src/app/api/audit-logs/export/route.ts
new file mode 100644
index 000000000..de87b398f
--- /dev/null
+++ b/apps/builder/src/app/api/audit-logs/export/route.ts
@@ -0,0 +1,59 @@
+import { auditLogService } from "@chatbotx.io/business"
+import { NextResponse } from "next/server"
+import { assertCurrentUserCanAccessChatbot } from "@/lib/auth/utils"
+
+// Regex top-level (biome lint useTopLevelRegex).
+const CSV_NEEDS_QUOTING = /[",\n\r]/
+const CSV_QUOTE = /"/g
+
+function escapeCsv(value: string | null | undefined): string {
+ const s = (value ?? "").toString()
+ if (CSV_NEEDS_QUOTING.test(s)) {
+ return `"${s.replace(CSV_QUOTE, '""')}"`
+ }
+ return s
+}
+
+export async function GET(req: Request) {
+ const url = new URL(req.url)
+ const workspaceId = url.searchParams.get("workspaceId")
+ if (!workspaceId) {
+ return NextResponse.json({ error: "workspaceId required" }, { status: 400 })
+ }
+
+ await assertCurrentUserCanAccessChatbot(workspaceId)
+
+ const userId = url.searchParams.get("userId") || undefined
+ const action = url.searchParams.get("action") || undefined
+
+ const { data } = await auditLogService.listByWorkspaceId({
+ workspaceId,
+ userId,
+ action,
+ limit: 10_000,
+ offset: 0,
+ })
+
+ const header = ["Data/Hora", "Ação", "Usuário", "Detalhe"]
+ const lines = [header.join(",")]
+
+ for (const row of data) {
+ lines.push(
+ [
+ escapeCsv(new Date(row.createdAt).toISOString()),
+ escapeCsv(row.action),
+ escapeCsv(row.userId ?? "Sistema"),
+ escapeCsv(row.detail),
+ ].join(","),
+ )
+ }
+
+ const csv = `${lines.join("\n")}`
+
+ return new NextResponse(csv, {
+ headers: {
+ "Content-Type": "text/csv; charset=utf-8",
+ "Content-Disposition": `attachment; filename="audit-logs.csv"`,
+ },
+ })
+}
diff --git a/apps/builder/src/app/api/scalar-ui/route.ts b/apps/builder/src/app/api/scalar-ui/route.ts
index 49b662a5f..a52fe25e9 100644
--- a/apps/builder/src/app/api/scalar-ui/route.ts
+++ b/apps/builder/src/app/api/scalar-ui/route.ts
@@ -3,7 +3,7 @@ export async function GET() {
- My Client
+ Meu Cliente
diff --git a/apps/builder/src/app/globals.css b/apps/builder/src/app/globals.css
index 71bc65b7f..02eece512 100644
--- a/apps/builder/src/app/globals.css
+++ b/apps/builder/src/app/globals.css
@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "@chatbotx.io/ui/styles/default.css";
+@import "./respond-icons.css";
/**
* Tailwind CSS official document:
@@ -9,3 +10,74 @@
* you can always add it with the @source directive.
*/
@source "../../node_modules/@chatbotx.io/ui";
+
+/* Esconder scrollbar visível em TODOS os scrollables — Pedro 2026-05-25:
+ Respond.io não mostra scrollbar em nenhum painel do inbox. Mantém
+ funcionalidade de rolar (apenas oculta visualmente). Funciona em
+ Firefox (scrollbar-width: none), IE/Edge (-ms-overflow-style: none)
+ e WebKit (Chrome/Safari) via pseudo-element ::-webkit-scrollbar. */
+* {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+}
+*::-webkit-scrollbar {
+ display: none;
+ width: 0;
+ height: 0;
+}
+
+/* Esconder scrollbar visível em TODOS os scrollables — Pedro 2026-05-25:
+ Respond.io não mostra scrollbar em nenhum painel do inbox. Mantém
+ funcionalidade de rolar (apenas oculta visualmente). Funciona em
+ Firefox (scrollbar-width: none), IE/Edge (-ms-overflow-style: none)
+ e WebKit (Chrome/Safari) via pseudo-element ::-webkit-scrollbar. */
+* {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+}
+*::-webkit-scrollbar {
+ display: none;
+ width: 0;
+ height: 0;
+}
+
+/* Scrubber de áudio pixel-perfect Respond.io (Pedro 2026-05-26):
+ trilha cinza fina (3 px) + thumb branco pequeno (10 px). Substitui
+ o input[type=range] default do navegador que aparecia azul-system.
+ Webkit (Chrome/Safari) e Firefox precisam de pseudo-classes
+ separadas — sem fallback global, então repete a estilização. */
+.audio-scrubber {
+ height: 14px;
+ -webkit-appearance: none;
+ appearance: none;
+ cursor: pointer;
+ background: transparent;
+}
+.audio-scrubber::-webkit-slider-runnable-track {
+ height: 3px;
+ background: rgba(255, 255, 255, 0.16);
+ border-radius: 2px;
+}
+.audio-scrubber::-webkit-slider-thumb {
+ width: 10px;
+ height: 10px;
+ margin-top: -3.5px;
+ -webkit-appearance: none;
+ appearance: none;
+ background: #ffffff;
+ border-radius: 50%;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
+}
+.audio-scrubber::-moz-range-track {
+ height: 3px;
+ background: rgba(255, 255, 255, 0.16);
+ border-radius: 2px;
+}
+.audio-scrubber::-moz-range-thumb {
+ width: 10px;
+ height: 10px;
+ background: #ffffff;
+ border: none;
+ border-radius: 50%;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
+}
diff --git a/apps/builder/src/app/integrations/[...integration]/callback.ts b/apps/builder/src/app/integrations/[...integration]/callback.ts
index 1877977f7..82dfab928 100644
--- a/apps/builder/src/app/integrations/[...integration]/callback.ts
+++ b/apps/builder/src/app/integrations/[...integration]/callback.ts
@@ -64,7 +64,7 @@ export const handleCallback = async (
: await workspaceService.create({
data: {
organizationId: organization.id,
- name: "New Workspace",
+ name: "Novo Workspace",
},
organization,
createdBy: userId,
diff --git a/apps/builder/src/app/integrations/[...integration]/webhook.ts b/apps/builder/src/app/integrations/[...integration]/webhook.ts
index 12e64454d..306efc80b 100644
--- a/apps/builder/src/app/integrations/[...integration]/webhook.ts
+++ b/apps/builder/src/app/integrations/[...integration]/webhook.ts
@@ -27,7 +27,7 @@ export const handleWebhook = async (
if (!orgSettings?.[integrationType as keyof OrganizationSettings]) {
logger.debug(`Integration ${integrationType} is not configured`)
return new Response(
- JSON.stringify({ message: "Integration is not configured" }),
+ JSON.stringify({ message: "Integração não configurada" }),
{
status: 404,
headers: { "Content-Type": "application/json" },
@@ -38,7 +38,7 @@ export const handleWebhook = async (
const integration = integrations[integrationType as IntegrationKey]
if (!integration?.handleRequest) {
return new Response(
- JSON.stringify({ message: "Method is not implemented" }),
+ JSON.stringify({ message: "Método não implementado" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
@@ -93,7 +93,7 @@ const handleTelegramWebhook = async (req: NextRequest) => {
const botId = req.nextUrl.searchParams.get("botId")
if (!botId) {
return new Response(
- JSON.stringify({ message: "Missing botId query param" }),
+ JSON.stringify({ message: "Parâmetro botId ausente" }),
{ status: 400, headers: { "Content-Type": "application/json" } },
)
}
@@ -101,14 +101,14 @@ const handleTelegramWebhook = async (req: NextRequest) => {
const integration = integrations.telegram
if (!integration?.handleRequest) {
return new Response(
- JSON.stringify({ message: "Method is not implemented" }),
+ JSON.stringify({ message: "Método não implementado" }),
{ status: 400, headers: { "Content-Type": "application/json" } },
)
}
const integrationTelegram = await findIntegrationTelegramByBotId({ botId })
if (!integrationTelegram) {
- return new Response(JSON.stringify({ message: "Bot not found" }), {
+ return new Response(JSON.stringify({ message: "Bot não encontrado" }), {
status: 404,
headers: { "Content-Type": "application/json" },
})
diff --git a/apps/builder/src/app/integrations/whatsapp/webhook/[integrationId]/route.ts b/apps/builder/src/app/integrations/whatsapp/webhook/[integrationId]/route.ts
index 76f3b332e..b92ac2025 100644
--- a/apps/builder/src/app/integrations/whatsapp/webhook/[integrationId]/route.ts
+++ b/apps/builder/src/app/integrations/whatsapp/webhook/[integrationId]/route.ts
@@ -36,7 +36,7 @@ const loadManualIntegration = async (integrationId: string) => {
const handleGet = async (req: NextRequest, integrationId: string) => {
const result = await loadManualIntegration(integrationId)
if (!result) {
- return json({ message: "Integration not found" }, 404)
+ return json({ message: "Integração não encontrada" }, 404)
}
const { auth } = result
@@ -46,11 +46,11 @@ const handleGet = async (req: NextRequest, integrationId: string) => {
const challenge = params.get("hub.challenge")
if (mode !== "subscribe" || !challenge) {
- return json({ message: "Invalid handshake" }, 400)
+ return json({ message: "Handshake inválido" }, 400)
}
if (challenge.length > MAX_CHALLENGE_LENGTH) {
- return json({ message: "Challenge too long" }, 400)
+ return json({ message: "Challenge muito longo" }, 400)
}
if (token !== auth.verifyToken) {
@@ -58,7 +58,7 @@ const handleGet = async (req: NextRequest, integrationId: string) => {
{ integrationId },
"Whatsapp manual webhook verify_token mismatch",
)
- return json({ message: "Forbidden" }, 403)
+ return json({ message: "Proibido" }, 403)
}
await markWhatsappWebhookVerified(integrationId, auth)
@@ -72,26 +72,26 @@ const handleGet = async (req: NextRequest, integrationId: string) => {
const handlePost = async (req: NextRequest, integrationId: string) => {
const result = await loadManualIntegration(integrationId)
if (!result) {
- return json({ message: "Integration not found" }, 404)
+ return json({ message: "Integração não encontrada" }, 404)
}
const { auth } = result
const signature = req.headers.get(SIGNATURE_HEADER)
if (!signature?.startsWith(SIGNATURE_PREFIX)) {
- return json({ message: "Missing signature" }, 401)
+ return json({ message: "Assinatura ausente" }, 401)
}
const verified =
Boolean(auth.metadata?.webhookVerifiedAt) ||
Boolean(auth.metadata?.subscribeOverrideOk)
if (!verified) {
- return json({ message: "Webhook not verified" }, 403)
+ return json({ message: "Webhook não verificado" }, 403)
}
const integration = integrations.whatsapp
if (!integration?.handleRequest) {
- return json({ message: "Method is not implemented" }, 400)
+ return json({ message: "Método não implementado" }, 400)
}
try {
diff --git a/apps/builder/src/app/layout.tsx b/apps/builder/src/app/layout.tsx
index 708eb32c5..bd5dc77a1 100644
--- a/apps/builder/src/app/layout.tsx
+++ b/apps/builder/src/app/layout.tsx
@@ -5,6 +5,7 @@ import "./themes.css"
import { UiProvider } from "@chatbotx.io/ui"
import { NextIntlClientProvider } from "next-intl"
import { getLocale } from "next-intl/server"
+import { DevClickToComponent } from "@/components/dev-click-to-component"
import { PlatformSettingsProvider } from "@/features/platform"
import { getPlatformSettings } from "@/features/platform/utils"
@@ -54,6 +55,9 @@ export default async function RootLayout({ children }: Props) {
{children}
+ {/* Dev only — segurando ⌥ Option e clicando em qualquer elemento abre
+ o arquivo React correspondente no Cursor (linha exata). NULL em prod. */}
+
)
diff --git a/apps/builder/src/app/manage/layout.tsx b/apps/builder/src/app/manage/layout.tsx
index 02a032aad..62e4efef8 100644
--- a/apps/builder/src/app/manage/layout.tsx
+++ b/apps/builder/src/app/manage/layout.tsx
@@ -31,7 +31,8 @@ export default async function ManageLayoutPage({
if (
!organizationMember ||
- organizationMember.role !== organizationMemberRoles.enum.admin
+ (organizationMember.role !== organizationMemberRoles.enum.owner &&
+ organizationMember.role !== organizationMemberRoles.enum.manager)
) {
return notFound()
}
diff --git a/apps/builder/src/app/manage/page.tsx b/apps/builder/src/app/manage/page.tsx
index 67cb5f92a..760c7eb2f 100644
--- a/apps/builder/src/app/manage/page.tsx
+++ b/apps/builder/src/app/manage/page.tsx
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation"
export default function ManagePage() {
- redirect("/manage/integrations")
+ redirect("/manage/settings")
}
diff --git a/apps/builder/src/app/manage/users/page.tsx b/apps/builder/src/app/manage/users/page.tsx
index a29e1f89e..f15fe78d9 100644
--- a/apps/builder/src/app/manage/users/page.tsx
+++ b/apps/builder/src/app/manage/users/page.tsx
@@ -1,9 +1,13 @@
+import { organizationService } from "@chatbotx.io/business"
+import { notFound } from "next/navigation"
import { getTranslations } from "next-intl/server"
import type { SearchParams } from "nuqs/server"
-import { Suspense } from "react"
-import OrganizationMembersTable from "@/enterprise/features/organization-members/components/organization-members-table"
-import { listOrganizationMembersRSC } from "@/enterprise/features/organization-members/queries"
+import { OrganizationMembersList } from "@/enterprise/features/organization-members/components/organization-members-list"
+import { listOrganizationMembers } from "@/enterprise/features/organization-members/queries"
import { listOrganizationMembersSearchParamsCache } from "@/enterprise/features/organization-members/schema/mutation"
+import { listOrgWorkspaces } from "@/features/workspaces/queries/list-org-workspaces"
+import { getCurrentUser } from "@/lib/auth/utils"
+import { getDomainFromHeader } from "@/lib/domain"
type ManageUsersPageProps = {
searchParams: Promise
@@ -11,18 +15,44 @@ type ManageUsersPageProps = {
export default async function ManageUsersPage(props: ManageUsersPageProps) {
const t = await getTranslations()
+ const user = await getCurrentUser()
+ if (!user) {
+ return notFound()
+ }
+
+ const domain = await getDomainFromHeader()
+ const organization = await organizationService.findByDomain(domain)
const searchParams = await props.searchParams
const search = listOrganizationMembersSearchParamsCache.parse(searchParams)
- const promises = Promise.all([listOrganizationMembersRSC(search)])
+
+ const [{ data: members }, workspaces] = await Promise.all([
+ listOrganizationMembers({
+ ...search,
+ organizationId: organization.id,
+ }),
+ listOrgWorkspaces({ organizationId: organization.id }),
+ ])
+
+ const workspaceOptions = workspaces.map((w) => ({ id: w.id, name: w.name }))
return (
-
{t("users.title")}
+
+
+ {t("organizationUsers.pageTitle")}
+
+
+ {t("organizationUsers.pageSubtitle")}
+
+
-
-
-
+
)
}
diff --git a/apps/builder/src/app/manage/workspaces/page.tsx b/apps/builder/src/app/manage/workspaces/page.tsx
new file mode 100644
index 000000000..e671fd469
--- /dev/null
+++ b/apps/builder/src/app/manage/workspaces/page.tsx
@@ -0,0 +1,19 @@
+import { organizationService } from "@chatbotx.io/business"
+import { notFound } from "next/navigation"
+import { ManageWorkspacesList } from "@/features/workspaces/components/manage-workspaces-list"
+import { listOrgWorkspaces } from "@/features/workspaces/queries/list-org-workspaces"
+import { getDomainFromHeader } from "@/lib/domain"
+
+export default async function ManageWorkspacesPage() {
+ const domain = await getDomainFromHeader()
+ const organization = await organizationService.findByDomain(domain)
+ if (!organization) {
+ return notFound()
+ }
+
+ const workspaces = await listOrgWorkspaces({
+ organizationId: organization.id,
+ })
+
+ return
+}
diff --git a/apps/builder/src/app/page.tsx b/apps/builder/src/app/page.tsx
index 7b3b63b26..10759666a 100644
--- a/apps/builder/src/app/page.tsx
+++ b/apps/builder/src/app/page.tsx
@@ -1,12 +1,5 @@
-import { notFound } from "next/navigation"
-import WorkspacesList from "@/features/workspaces/components/workspaces-list"
-import { getCurrentUserAndAllLinkedWorkspaces } from "@/lib/auth/utils"
+import { redirect } from "next/navigation"
-export default async function MainPage() {
- const userAndWorkspaces = await getCurrentUserAndAllLinkedWorkspaces()
- if (!userAndWorkspaces) {
- return notFound()
- }
-
- return
+export default function MainPage() {
+ redirect("/manage")
}
diff --git a/apps/builder/src/app/r/[workspaceId]/[name]/route.ts b/apps/builder/src/app/r/[workspaceId]/[name]/route.ts
index da1c2f1cb..7c34da018 100644
--- a/apps/builder/src/app/r/[workspaceId]/[name]/route.ts
+++ b/apps/builder/src/app/r/[workspaceId]/[name]/route.ts
@@ -25,7 +25,7 @@ export const GET = async (
})
if (!row) {
- return NextResponse.json({ message: "Not found" }, { status: 404 })
+ return NextResponse.json({ message: "Não encontrado" }, { status: 404 })
}
let destination: string
@@ -35,7 +35,7 @@ export const GET = async (
})
} catch {
return NextResponse.json(
- { message: "Invalid link configuration" },
+ { message: "Configuração de link inválida" },
{ status: 400 },
)
}
@@ -49,11 +49,11 @@ export const GET = async (
// Decode the button payload
const decodedButton = decodeButtonPayload(code)
if (!decodedButton) {
- return NextResponse.json({ message: "Invalid code" }, { status: 400 })
+ return NextResponse.json({ message: "Código inválido" }, { status: 400 })
}
if (!decodedButton.contactInboxId) {
return NextResponse.json(
- { message: "Contact inbox ID is missing" },
+ { message: "ID da caixa de entrada do contato ausente" },
{ status: 400 },
)
}
@@ -69,7 +69,7 @@ export const GET = async (
if (!contactInbox) {
return NextResponse.json(
- { message: "Contact inbox not found" },
+ { message: "Caixa de entrada do contato não encontrada" },
{ status: 404 },
)
}
@@ -89,10 +89,13 @@ export const GET = async (
)
if (!foundedButton) {
- return NextResponse.json({ message: "Button not found" }, { status: 404 })
+ return NextResponse.json(
+ { message: "Botão não encontrado" },
+ { status: 404 },
+ )
}
if (!foundedNodeId) {
- return NextResponse.json({ message: "Node ID is missing" }, { status: 400 })
+ return NextResponse.json({ message: "ID do Nó ausente" }, { status: 400 })
}
await emit(flowEventTypeSchema.enum["flow:clicked"], {
diff --git a/apps/builder/src/app/respond-icons.css b/apps/builder/src/app/respond-icons.css
new file mode 100644
index 000000000..b2cdbcf40
--- /dev/null
+++ b/apps/builder/src/app/respond-icons.css
@@ -0,0 +1,321 @@
+/* Iconfont do Respond.io — 848 ícones extraídos do CDN oficial
+ 2026-05-24. Pedro pediu "ícones idênticos ao Respond.io".
+ Arquivos em /public/respond-icons/ (woff2 + woff + ttf).
+
+ Uso:
+ Ou via componente:
+*/
+
+@font-face {
+ font-family: "respond-iconfont";
+ font-display: block;
+ src:
+ url("/respond-icons/iconfont.woff2") format("woff2"),
+ url("/respond-icons/iconfont.woff") format("woff"),
+ url("/respond-icons/iconfont.ttf") format("truetype");
+}
+
+.icon {
+ display: inline-block;
+ font-family: "respond-iconfont", sans-serif !important;
+ font-style: normal;
+ font-weight: normal;
+ font-variant: normal;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ line-height: 1;
+ vertical-align: middle;
+ text-transform: none;
+}
+
+/* Tamanhos canônicos do Respond.io */
+.icon-xs {
+ font-size: 12px;
+}
+.icon-sm {
+ font-size: 14px;
+}
+.icon-md {
+ font-size: 16px;
+}
+.icon-lg {
+ font-size: 18px;
+}
+.icon-xl {
+ font-size: 20px;
+}
+.icon-2xl {
+ font-size: 24px;
+}
+
+/* Mapa de ícones essenciais (~65 dos 848 totais). Ampliar conforme uso. */
+.icon-tick-circle::before {
+ content: "\00f156";
+}
+.icon-tick-square::before {
+ content: "\00f154";
+}
+.icon-tick-square-bold::before {
+ content: "\00f155";
+}
+.icon-tick::before {
+ content: "\00f153";
+}
+.icon-send::before {
+ content: "\00f1ad";
+}
+.icon-search-normal::before {
+ content: "\00f1b6";
+}
+.icon-more::before {
+ content: "\00f215";
+}
+.icon-message-text::before {
+ content: "\00f236";
+}
+.icon-message-question::before {
+ content: "\00f23b";
+}
+.icon-message-2::before {
+ content: "\00f243";
+}
+.icon-emoji-happy::before {
+ content: "\00f352";
+}
+.icon-close-circle::before {
+ content: "\00f3ac";
+}
+.icon-attach-square::before {
+ content: "\00f417";
+}
+.icon-arrow-down::before {
+ content: "\00f42b";
+}
+.icon-arrow-down-1::before {
+ content: "\00f42f";
+}
+.icon-arrow-up::before {
+ content: "\00f41b";
+}
+.icon-arrow-up-1::before {
+ content: "\00f41f";
+}
+.icon-arrow-left::before {
+ content: "\00f427";
+}
+.icon-arrow-right::before {
+ content: "\00f421";
+}
+.icon-ai-assist::before {
+ content: "\00f447";
+}
+.icon-ai-agent::before {
+ content: "\00f449";
+}
+.icon-add::before {
+ content: "\00f44b";
+}
+.icon-workflow::before {
+ content: "\00f105";
+}
+.icon-user::before {
+ content: "\00f11d";
+}
+.icon-user-add::before {
+ content: "\00f128";
+}
+.icon-flash::before {
+ content: "\00f337";
+}
+.icon-sticker::before {
+ content: "\00f17c";
+}
+.icon-document::before {
+ content: "\00f364";
+}
+.icon-star::before {
+ content: "\00f180";
+}
+.icon-star-bold::before {
+ content: "\00f181";
+}
+.icon-volume-high::before {
+ content: "\00f112";
+}
+.icon-eye::before {
+ content: "\00f343";
+}
+.icon-eye-bold::before {
+ content: "\00f344";
+}
+.icon-edit::before {
+ content: "\00f35d";
+}
+.icon-edit-bold::before {
+ content: "\00f35e";
+}
+.icon-trash::before {
+ content: "\00f137";
+}
+.icon-trash-bold::before {
+ content: "\00f138";
+}
+.icon-forbidden::before {
+ content: "\00f333";
+}
+.icon-forbidden-bold::before {
+ content: "\00f334";
+}
+.icon-reply::before {
+ content: "\00f1c9";
+}
+.icon-copy::before {
+ content: "\00f38f";
+}
+.icon-link-1::before {
+ content: "\00f2f0";
+}
+.icon-message-delivered::before {
+ content: "\00f241";
+}
+.icon-magicpen-bold::before {
+ content: "\00f2de";
+}
+.icon-emoji-normal::before {
+ content: "\00f350";
+}
+.icon-i-microphone::before {
+ content: "\00f317";
+}
+.icon-receipt::before {
+ content: "\00f1da";
+}
+.icon-message-programming::before {
+ content: "\00f23d";
+}
+.icon-send-bold::before {
+ content: "\00f1b1";
+}
+.icon-comments-ai::before {
+ content: "\00f399";
+}
+.icon-ai-assist-bold::before {
+ content: "\00f448";
+}
+.icon-channels::before {
+ content: "\00f3c0";
+}
+.icon-at::before {
+ content: "\00f419";
+}
+.icon-close-square::before {
+ content: "\00f3aa";
+}
+.icon-custom-user-square-bold::before {
+ content: "\00f382";
+}
+.icon-translate::before {
+ content: "\00f139";
+}
+.icon-code::before {
+ content: "\00f3a0";
+}
+.icon-clock::before {
+ content: "\00f3ae";
+}
+.icon-clock-bold::before {
+ content: "\00f3af";
+}
+.icon-sms::before {
+ content: "\00f18e";
+}
+.icon-sms-bold::before {
+ content: "\00f18f";
+}
+.icon-phone::before {
+ content: "\00f1f3";
+}
+.icon-call-calling::before {
+ content: "\00f3de";
+}
+.icon-home::before {
+ content: "\00f31d";
+}
+.icon-global::before {
+ content: "\00f327";
+}
+.icon-profile::before {
+ content: "\00f1e5";
+}
+.icon-profile-2user::before {
+ content: "\00f1ea";
+}
+.icon-note::before {
+ content: "\00f20a";
+}
+.icon-notification::before {
+ content: "\00f206";
+}
+.icon-notification-bing::before {
+ content: "\00f208";
+}
+.icon-tag::before {
+ content: "\00f169";
+}
+.icon-tag-bold::before {
+ content: "\00f16a";
+}
+.icon-filter::before {
+ content: "\00f33b";
+}
+.icon-filter-bold::before {
+ content: "\00f33e";
+}
+.icon-sort::before {
+ content: "\00f18c";
+}
+.icon-verify-bold::before {
+ content: "\00f11b";
+}
+.icon-received::before {
+ content: "\00f1d8";
+}
+.icon-whatsapp-bold::before {
+ content: "\00f10a";
+}
+.icon-instagram::before {
+ content: "\00f306";
+}
+.icon-facebook::before {
+ content: "\00f341";
+}
+.icon-email::before {
+ content: "\00f354";
+}
+.icon-menu::before {
+ content: "\00f245";
+}
+.icon-sidebar-left::before {
+ content: "\00f19b";
+}
+.icon-sidebar-right::before {
+ content: "\00f199";
+}
+.icon-refresh::before {
+ content: "\00f1cc";
+}
+.icon-refresh-circle::before {
+ content: "\00f1cd";
+}
+.icon-archive::before {
+ content: "\00f434";
+}
+.icon-minus::before {
+ content: "\00f223";
+}
+.icon-play::before {
+ content: "\00f1f1";
+}
+.icon-pause::before {
+ content: "\00f1fa";
+}
diff --git a/apps/builder/src/app/space/[workspaceId]/(enterprise)/pricing/page.tsx b/apps/builder/src/app/space/[workspaceId]/(enterprise)/pricing/page.tsx
index e0c6dfef7..071430f50 100644
--- a/apps/builder/src/app/space/[workspaceId]/(enterprise)/pricing/page.tsx
+++ b/apps/builder/src/app/space/[workspaceId]/(enterprise)/pricing/page.tsx
@@ -19,9 +19,9 @@ export default function PricingTableFourDemo() {
plans={samplePlans}
showBillingToggle={false}
size="medium"
- subtitle="Simple Pricing"
+ subtitle="Preços Simples"
theme="classic"
- title="Choose Your Perfect Plan"
+ title="Escolha Seu Plano Ideal"
/>
)
}
diff --git a/apps/builder/src/app/(no-sidebar)/space/[workspaceId]/flows/[id]/page.tsx b/apps/builder/src/app/space/[workspaceId]/(full-canvas)/flows/[id]/page.tsx
similarity index 72%
rename from apps/builder/src/app/(no-sidebar)/space/[workspaceId]/flows/[id]/page.tsx
rename to apps/builder/src/app/space/[workspaceId]/(full-canvas)/flows/[id]/page.tsx
index 9020c4b24..cc1cd0ce1 100644
--- a/apps/builder/src/app/(no-sidebar)/space/[workspaceId]/flows/[id]/page.tsx
+++ b/apps/builder/src/app/space/[workspaceId]/(full-canvas)/flows/[id]/page.tsx
@@ -9,6 +9,18 @@ type FlowPageProps = {
params: Promise<{ workspaceId: string; id: string }>
}
+/**
+ * Page do flow editor.
+ *
+ * Mudança 2026-05-23 (Pedro): movido de `(no-sidebar)/` pra dentro do route
+ * group `(full-canvas)/` — assim o AppSidebar global (com ícones: Inbox,
+ * Flows, Contatos, etc.) fica visível também dentro do flow editor (estilo
+ * Respond.io: sidebar fixo em todas as páginas).
+ *
+ * O wrapper agora usa `h-full w-full` em vez de `h-screen w-screen` porque
+ * vive dentro do `` do layout pai — não precisa pegar
+ * o viewport inteiro, basta preencher o espaço disponível.
+ */
export default async function FlowPage({ params }: FlowPageProps) {
const { data } = await withWorkspaceIdAndIdSchema.safeParse(await params)
if (!data) {
@@ -50,7 +62,7 @@ export default async function FlowPage({ params }: FlowPageProps) {
}
return (
-
+
do layout pai.
+ *
+ * Necessário porque o layout `space/[workspaceId]/layout.tsx` aplica
+ * `p-6 gap-4 overflow-auto` no , o que quebra páginas tipo flow
+ * editor que precisam de canvas full-bleed sem padding.
+ */
+body[data-full-canvas="true"] main {
+ gap: 0 !important;
+ padding: 0 !important;
+ overflow: hidden !important;
+}
diff --git a/apps/builder/src/app/space/[workspaceId]/(full-canvas)/layout.tsx b/apps/builder/src/app/space/[workspaceId]/(full-canvas)/layout.tsx
new file mode 100644
index 000000000..2b168d8b5
--- /dev/null
+++ b/apps/builder/src/app/space/[workspaceId]/(full-canvas)/layout.tsx
@@ -0,0 +1,32 @@
+"use client"
+
+import { useEffect } from "react"
+import "./full-canvas.css"
+
+/**
+ * Layout do route group `(full-canvas)` — usado pelo flow editor (e outras
+ * páginas que precisem ocupar todo o espaço sem padding).
+ *
+ * Por que existe: o layout pai `space/[workspaceId]/layout.tsx` aplica
+ * `p-6 gap-4 overflow-auto` no `` (correto pra páginas tipo dashboard,
+ * settings, etc.). Mas o flow editor precisa de canvas full-bleed —
+ * adicionamos `data-full-canvas="true"` no body via useEffect, e o CSS
+ * sobrescreve essas regras só pra essa rota (ver `full-canvas.css`).
+ *
+ * Vantagem versus a rota antiga `(no-sidebar)`: aqui o AppSidebar global
+ * fica visível (Pedro pediu "sidebar fixo em todas as opções, estilo
+ * Respond.io"). Antes o flow editor sumia com o sidebar.
+ */
+export default function FullCanvasLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ useEffect(() => {
+ document.body.dataset.fullCanvas = "true"
+ return () => {
+ delete document.body.dataset.fullCanvas
+ }
+ }, [])
+ return {children}
+}
diff --git a/apps/builder/src/app/space/[workspaceId]/(has-folder)/automated-responses/@folders/page.tsx b/apps/builder/src/app/space/[workspaceId]/(has-folder)/automated-responses/_folders_disabled/page.tsx
similarity index 100%
rename from apps/builder/src/app/space/[workspaceId]/(has-folder)/automated-responses/@folders/page.tsx
rename to apps/builder/src/app/space/[workspaceId]/(has-folder)/automated-responses/_folders_disabled/page.tsx
diff --git a/apps/builder/src/app/space/[workspaceId]/(has-folder)/automated-responses/layout.tsx b/apps/builder/src/app/space/[workspaceId]/(has-folder)/automated-responses/layout.tsx
index 7ab2ca8e8..d337b6458 100644
--- a/apps/builder/src/app/space/[workspaceId]/(has-folder)/automated-responses/layout.tsx
+++ b/apps/builder/src/app/space/[workspaceId]/(has-folder)/automated-responses/layout.tsx
@@ -6,11 +6,9 @@ import { FolderStoreProvider } from "@/features/folders/provider/folder-store-co
export default async function FolderableLayout({
children,
- folders,
params,
}: {
children: ReactNode
- folders: ReactNode
params: Promise<{ workspaceId: string }>
}) {
const workspaceId = getIdFromParams(await params, "workspaceId")
@@ -24,7 +22,6 @@ export default async function FolderableLayout({
workspaceId={workspaceId}
>
- {folders}
{children}
diff --git a/apps/builder/src/app/space/[workspaceId]/(has-folder)/automated-responses/page.tsx b/apps/builder/src/app/space/[workspaceId]/(has-folder)/automated-responses/page.tsx
index 773768b37..8bea69bc6 100644
--- a/apps/builder/src/app/space/[workspaceId]/(has-folder)/automated-responses/page.tsx
+++ b/apps/builder/src/app/space/[workspaceId]/(has-folder)/automated-responses/page.tsx
@@ -3,6 +3,7 @@ import { getIdFromParams } from "@chatbotx.io/utils"
import { notFound } from "next/navigation"
import type { SearchParams } from "nuqs/server"
import { Suspense } from "react"
+import { AutomationTabs } from "@/components/automation-tabs"
import { AutomatedResponsesTable } from "@/features/automated-response/automated-response-table"
import { listAutomatedResponses } from "@/features/automated-response/queries"
import { listAutomatedResponsesSearchParams } from "@/features/automated-response/schema/query"
@@ -29,8 +30,14 @@ export default async function AutomatedResponesPage(props: {
])
return (
-
-
-
+
)
}
diff --git a/apps/builder/src/app/space/[workspaceId]/(has-folder)/custom-fields/layout.tsx b/apps/builder/src/app/space/[workspaceId]/(has-folder)/custom-fields/layout.tsx
deleted file mode 100644
index 9d03f34e0..000000000
--- a/apps/builder/src/app/space/[workspaceId]/(has-folder)/custom-fields/layout.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-import { getIdFromParams } from "@chatbotx.io/utils"
-import { notFound } from "next/navigation"
-import { getTranslations } from "next-intl/server"
-import type { ReactNode } from "react"
-import { AppTab } from "@/components/app-tab"
-import { FolderStoreProvider } from "@/features/folders/provider/folder-store-context"
-
-export default async function FolderableLayout({
- children,
- folders,
- params,
-}: {
- children: ReactNode
- folders: ReactNode
- params: Promise<{ workspaceId: string }>
-}) {
- const workspaceId = getIdFromParams(await params, "workspaceId")
- if (!workspaceId) {
- return notFound()
- }
-
- const t = await getTranslations()
-
- return (
-
-
- {folders}
- {children}
-
- )
-}
diff --git a/apps/builder/src/app/space/[workspaceId]/(has-folder)/flows/layout.tsx b/apps/builder/src/app/space/[workspaceId]/(has-folder)/flows/layout.tsx
index 0bd488c3a..8d6213fe2 100644
--- a/apps/builder/src/app/space/[workspaceId]/(has-folder)/flows/layout.tsx
+++ b/apps/builder/src/app/space/[workspaceId]/(has-folder)/flows/layout.tsx
@@ -1,20 +1,20 @@
"use client"
import { notFound } from "next/navigation"
-import { useTranslations } from "next-intl"
import type { ReactNode } from "react"
-import { AppTab } from "@/components/app-tab"
import { FolderStoreProvider } from "@/features/folders/provider/folder-store-context"
import { useWorkspaceId } from "@/hooks/routing"
+/**
+ * Layout da página /flows — só FolderStoreProvider + children.
+ * Pedro pediu pra remover o painel "Pastas" do topo em TODAS as páginas
+ * (2026-05-24) pra deixar UI flat estilo Respond.io.
+ */
export default function FolderableLayout({
children,
- folders,
}: {
children: ReactNode
folders: ReactNode
}) {
- const t = useTranslations()
-
const workspaceId = useWorkspaceId()
if (!workspaceId) {
return notFound()
@@ -22,31 +22,6 @@ export default function FolderableLayout({
return (
-
- {folders}
{children}
)
diff --git a/apps/builder/src/app/space/[workspaceId]/(has-folder)/bot-fields/@folders/page.tsx b/apps/builder/src/app/space/[workspaceId]/(has-folder)/sequences/_folders_disabled/page.tsx
similarity index 100%
rename from apps/builder/src/app/space/[workspaceId]/(has-folder)/bot-fields/@folders/page.tsx
rename to apps/builder/src/app/space/[workspaceId]/(has-folder)/sequences/_folders_disabled/page.tsx
diff --git a/apps/builder/src/app/space/[workspaceId]/(has-folder)/sequences/layout.tsx b/apps/builder/src/app/space/[workspaceId]/(has-folder)/sequences/layout.tsx
index d901a38fd..4e5f0c83a 100644
--- a/apps/builder/src/app/space/[workspaceId]/(has-folder)/sequences/layout.tsx
+++ b/apps/builder/src/app/space/[workspaceId]/(has-folder)/sequences/layout.tsx
@@ -7,11 +7,9 @@ import { FolderStoreProvider } from "@/features/folders/provider/folder-store-co
export default async function FolderableLayout({
children,
- folders,
params,
}: {
children: ReactNode
- folders: ReactNode
params: Promise<{ workspaceId: string }>
}) {
const workspaceId = getIdFromParams(await params, "workspaceId")
@@ -25,7 +23,6 @@ export default async function FolderableLayout({
folderType={folderTypes.enum.sequence}
workspaceId={workspaceId}
>
- {folders}
{children}
diff --git a/apps/builder/src/app/space/[workspaceId]/(has-folder)/sequences/page.tsx b/apps/builder/src/app/space/[workspaceId]/(has-folder)/sequences/page.tsx
index b2b00fbe1..25ca7e88e 100644
--- a/apps/builder/src/app/space/[workspaceId]/(has-folder)/sequences/page.tsx
+++ b/apps/builder/src/app/space/[workspaceId]/(has-folder)/sequences/page.tsx
@@ -1,17 +1,12 @@
-import {
- Card,
- CardContent,
- CardHeader,
- CardTitle,
-} from "@chatbotx.io/ui/components/ui/card"
import { getIdFromParams } from "@chatbotx.io/utils"
import { notFound } from "next/navigation"
-import { getTranslations } from "next-intl/server"
import type { SearchParams } from "nuqs/server"
+import { AutomationTabs } from "@/components/automation-tabs"
import { listSequences } from "@/features/sequences/queries"
import { listSequencesSearchParamsCache } from "@/features/sequences/schema/action"
import { SequencesTable } from "@/features/sequences/sequences-table"
+// Card wrapper removido — Pedro pediu UI flat estilo Respond.io 2026-05-24.
export default async function SequencesPage(props: {
params: Promise<{ workspaceId: string }>
searchParams: Promise
@@ -23,7 +18,6 @@ export default async function SequencesPage(props: {
const searchParams = await props.searchParams
const search = await listSequencesSearchParamsCache.parse(searchParams)
- const t = await getTranslations()
const promises = Promise.all([
listSequences({
@@ -33,15 +27,9 @@ export default async function SequencesPage(props: {
])
return (
-
-
-
- {t("sequences.title")}
-
-
-
-
-
-
+
)
}
diff --git a/apps/builder/src/app/space/[workspaceId]/(has-folder)/tags/@folders/page.tsx b/apps/builder/src/app/space/[workspaceId]/(has-folder)/tags/@folders/page.tsx
deleted file mode 100644
index 8d3f12de8..000000000
--- a/apps/builder/src/app/space/[workspaceId]/(has-folder)/tags/@folders/page.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import { getIdFromParams } from "@chatbotx.io/utils"
-import { notFound } from "next/navigation"
-import type { SearchParams } from "nuqs/server"
-import SharedFolderSlot from "@/features/folders/shared-folder-slot"
-
-export default async function FolderPage(props: {
- params: Promise<{ workspaceId: string }>
- searchParams: Promise
-}) {
- const workspaceId = getIdFromParams(await props.params, "workspaceId")
- if (!workspaceId) {
- return notFound()
- }
-
- return (
-
- )
-}
diff --git a/apps/builder/src/app/space/[workspaceId]/(has-folder)/tags/layout.tsx b/apps/builder/src/app/space/[workspaceId]/(has-folder)/tags/layout.tsx
deleted file mode 100644
index 92ec31b92..000000000
--- a/apps/builder/src/app/space/[workspaceId]/(has-folder)/tags/layout.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-import { getIdFromParams } from "@chatbotx.io/utils"
-import { notFound } from "next/navigation"
-import { getTranslations } from "next-intl/server"
-import type { ReactNode } from "react"
-import { AppTab } from "@/components/app-tab"
-import { FolderStoreProvider } from "@/features/folders/provider/folder-store-context"
-
-export default async function TagsLayout({
- children,
- folders,
- params,
-}: {
- children: ReactNode
- folders: ReactNode
- params: Promise<{ workspaceId: string }>
-}) {
- const workspaceId = getIdFromParams(await params, "workspaceId")
- if (!workspaceId) {
- return notFound()
- }
-
- const t = await getTranslations()
-
- return (
-
-
- {folders}
- {children}
-
- )
-}
diff --git a/apps/builder/src/app/space/[workspaceId]/(has-folder)/triggers/@folders/page.tsx b/apps/builder/src/app/space/[workspaceId]/(has-folder)/triggers/@folders/page.tsx
deleted file mode 100644
index 8d3f12de8..000000000
--- a/apps/builder/src/app/space/[workspaceId]/(has-folder)/triggers/@folders/page.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import { getIdFromParams } from "@chatbotx.io/utils"
-import { notFound } from "next/navigation"
-import type { SearchParams } from "nuqs/server"
-import SharedFolderSlot from "@/features/folders/shared-folder-slot"
-
-export default async function FolderPage(props: {
- params: Promise<{ workspaceId: string }>
- searchParams: Promise
-}) {
- const workspaceId = getIdFromParams(await props.params, "workspaceId")
- if (!workspaceId) {
- return notFound()
- }
-
- return (
-
- )
-}
diff --git a/apps/builder/src/app/space/[workspaceId]/(has-folder)/custom-fields/@folders/page.tsx b/apps/builder/src/app/space/[workspaceId]/(has-folder)/triggers/_folders_disabled/page.tsx
similarity index 100%
rename from apps/builder/src/app/space/[workspaceId]/(has-folder)/custom-fields/@folders/page.tsx
rename to apps/builder/src/app/space/[workspaceId]/(has-folder)/triggers/_folders_disabled/page.tsx
diff --git a/apps/builder/src/app/space/[workspaceId]/(has-folder)/triggers/layout.tsx b/apps/builder/src/app/space/[workspaceId]/(has-folder)/triggers/layout.tsx
index 5d2a3860f..b58f1091d 100644
--- a/apps/builder/src/app/space/[workspaceId]/(has-folder)/triggers/layout.tsx
+++ b/apps/builder/src/app/space/[workspaceId]/(has-folder)/triggers/layout.tsx
@@ -6,11 +6,9 @@ import { FolderStoreProvider } from "@/features/folders/provider/folder-store-co
export default async function TriggersLayout({
children,
- folders,
params,
}: {
children: ReactNode
- folders: ReactNode
params: Promise<{ workspaceId: string }>
}) {
const workspaceId = getIdFromParams(await params, "workspaceId")
@@ -24,7 +22,6 @@ export default async function TriggersLayout({
folderType={folderTypes.enum.trigger}
workspaceId={workspaceId}
>
- {folders}
{children}
)
diff --git a/apps/builder/src/app/space/[workspaceId]/(has-folder)/triggers/page.tsx b/apps/builder/src/app/space/[workspaceId]/(has-folder)/triggers/page.tsx
index cc4bb7188..d6e777c85 100644
--- a/apps/builder/src/app/space/[workspaceId]/(has-folder)/triggers/page.tsx
+++ b/apps/builder/src/app/space/[workspaceId]/(has-folder)/triggers/page.tsx
@@ -1,9 +1,3 @@
-import {
- Card,
- CardContent,
- CardHeader,
- CardTitle,
-} from "@chatbotx.io/ui/components/ui/card"
import { getIdFromParams } from "@chatbotx.io/utils"
import { notFound } from "next/navigation"
import { getTranslations } from "next-intl/server"
@@ -34,21 +28,15 @@ export default async function TriggersPage(props: {
])
return (
-
-
-
- {t("triggers.title")}
-
-
-
-
-
-
-
-
+
+
{t("triggers.title")}
+
+
+
+
)
}
diff --git a/apps/builder/src/app/space/[workspaceId]/(has-folder)/webhooks/@folders/page.tsx b/apps/builder/src/app/space/[workspaceId]/(has-folder)/webhooks/@folders/page.tsx
deleted file mode 100644
index 8d3f12de8..000000000
--- a/apps/builder/src/app/space/[workspaceId]/(has-folder)/webhooks/@folders/page.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import { getIdFromParams } from "@chatbotx.io/utils"
-import { notFound } from "next/navigation"
-import type { SearchParams } from "nuqs/server"
-import SharedFolderSlot from "@/features/folders/shared-folder-slot"
-
-export default async function FolderPage(props: {
- params: Promise<{ workspaceId: string }>
- searchParams: Promise
-}) {
- const workspaceId = getIdFromParams(await props.params, "workspaceId")
- if (!workspaceId) {
- return notFound()
- }
-
- return (
-
- )
-}
diff --git a/apps/builder/src/app/space/[workspaceId]/(has-folder)/sequences/@folders/page.tsx b/apps/builder/src/app/space/[workspaceId]/(has-folder)/webhooks/_folders_disabled/page.tsx
similarity index 100%
rename from apps/builder/src/app/space/[workspaceId]/(has-folder)/sequences/@folders/page.tsx
rename to apps/builder/src/app/space/[workspaceId]/(has-folder)/webhooks/_folders_disabled/page.tsx
diff --git a/apps/builder/src/app/space/[workspaceId]/(has-folder)/webhooks/layout.tsx b/apps/builder/src/app/space/[workspaceId]/(has-folder)/webhooks/layout.tsx
index 2118f7657..703e08b0e 100644
--- a/apps/builder/src/app/space/[workspaceId]/(has-folder)/webhooks/layout.tsx
+++ b/apps/builder/src/app/space/[workspaceId]/(has-folder)/webhooks/layout.tsx
@@ -6,11 +6,9 @@ import { FolderStoreProvider } from "@/features/folders/provider/folder-store-co
export default async function WebhooksLayout({
children,
- folders,
params,
}: {
children: ReactNode
- folders: ReactNode
params: Promise<{ workspaceId: string }>
}) {
const workspaceId = getIdFromParams(await params, "workspaceId")
@@ -24,7 +22,6 @@ export default async function WebhooksLayout({
folderType={folderTypes.enum.webhook}
workspaceId={workspaceId}
>
- {folders}
{children}
)
diff --git a/apps/builder/src/app/space/[workspaceId]/(has-folder)/webhooks/page.tsx b/apps/builder/src/app/space/[workspaceId]/(has-folder)/webhooks/page.tsx
index aa77bfcee..b4620c3cb 100644
--- a/apps/builder/src/app/space/[workspaceId]/(has-folder)/webhooks/page.tsx
+++ b/apps/builder/src/app/space/[workspaceId]/(has-folder)/webhooks/page.tsx
@@ -1,9 +1,3 @@
-import {
- Card,
- CardContent,
- CardHeader,
- CardTitle,
-} from "@chatbotx.io/ui/components/ui/card"
import { getIdFromParams } from "@chatbotx.io/utils"
import { notFound } from "next/navigation"
import { getTranslations } from "next-intl/server"
@@ -34,21 +28,15 @@ export default async function WebhooksPage(props: {
])
return (
-
-
-
- {t("webhooks.title")}
-
-
-
-
-
-
-
-
+
+
{t("webhooks.title")}
+
+
+
+
)
}
diff --git a/apps/builder/src/app/space/[workspaceId]/(integrations)/whatsapps/[id]/automation/page.tsx b/apps/builder/src/app/space/[workspaceId]/(integrations)/whatsapps/[id]/automation/page.tsx
index a51bfb3ad..a60ff3139 100644
--- a/apps/builder/src/app/space/[workspaceId]/(integrations)/whatsapps/[id]/automation/page.tsx
+++ b/apps/builder/src/app/space/[workspaceId]/(integrations)/whatsapps/[id]/automation/page.tsx
@@ -28,7 +28,7 @@ export default async function WhatsappAutomationPage(props: {
error:
err instanceof Error
? err.message
- : "Failed to load automation settings",
+ : "Falha ao carregar configurações de automação",
})),
])
diff --git a/apps/builder/src/app/space/[workspaceId]/(settings)/settings/admins/page.tsx b/apps/builder/src/app/space/[workspaceId]/(settings)/settings/admins/page.tsx
index 8a80e6dd0..a4011abb0 100644
--- a/apps/builder/src/app/space/[workspaceId]/(settings)/settings/admins/page.tsx
+++ b/apps/builder/src/app/space/[workspaceId]/(settings)/settings/admins/page.tsx
@@ -1,9 +1,3 @@
-import {
- Card,
- CardContent,
- CardHeader,
- CardTitle,
-} from "@chatbotx.io/ui/components/ui/card"
import { getIdFromParams } from "@chatbotx.io/utils"
import { notFound } from "next/navigation"
import { getTranslations } from "next-intl/server"
@@ -31,13 +25,9 @@ export default async function SettingsAdminPage({
])
return (
-
-
- {t("admins.title")}
-
-
-
-
-
+
+
{t("admins.title")}
+
+
)
}
diff --git a/apps/builder/src/app/space/[workspaceId]/(enterprise)/audit-logs/page.tsx b/apps/builder/src/app/space/[workspaceId]/(settings)/settings/audit-logs/page.tsx
similarity index 55%
rename from apps/builder/src/app/space/[workspaceId]/(enterprise)/audit-logs/page.tsx
rename to apps/builder/src/app/space/[workspaceId]/(settings)/settings/audit-logs/page.tsx
index 6265c8850..9096b7ab6 100644
--- a/apps/builder/src/app/space/[workspaceId]/(enterprise)/audit-logs/page.tsx
+++ b/apps/builder/src/app/space/[workspaceId]/(settings)/settings/audit-logs/page.tsx
@@ -2,9 +2,14 @@ import { getIdFromParams } from "@chatbotx.io/utils"
import { notFound } from "next/navigation"
import type { SearchParams } from "nuqs/server"
import { Suspense } from "react"
-import { AuditLogsTable } from "@/enterprise/features/audit-logs/audit-logs-table"
-import { listAuditLogs } from "@/enterprise/features/audit-logs/queries"
-import { listAuditLogsSearchParamsCache } from "@/enterprise/features/audit-logs/schemas/query"
+import { AuditLogsTable } from "@/features/audit-logs/audit-logs-table"
+import {
+ listAuditLogActions,
+ listAuditLogs,
+} from "@/features/audit-logs/queries"
+import { listAuditLogsSearchParamsCache } from "@/features/audit-logs/schemas/query"
+import { listWorkspaceMembers } from "@/features/workspace-members/queries"
+import { getWorkspaceMembersSearchParamsCache } from "@/features/workspace-members/schema/query"
export default async function AuditLogsPage(props: {
params: Promise<{ workspaceId: string }>
@@ -19,9 +24,11 @@ export default async function AuditLogsPage(props: {
const search = listAuditLogsSearchParamsCache.parse(searchParams)
const promises = Promise.all([
- listAuditLogs({
- ...search,
+ listAuditLogs({ ...search, workspaceId }),
+ listAuditLogActions(workspaceId),
+ listWorkspaceMembers({
workspaceId,
+ ...getWorkspaceMembersSearchParamsCache.parse({}),
}),
])
diff --git a/apps/builder/src/app/space/[workspaceId]/(has-folder)/bot-fields/layout.tsx b/apps/builder/src/app/space/[workspaceId]/(settings)/settings/bot-fields/layout.tsx
similarity index 85%
rename from apps/builder/src/app/space/[workspaceId]/(has-folder)/bot-fields/layout.tsx
rename to apps/builder/src/app/space/[workspaceId]/(settings)/settings/bot-fields/layout.tsx
index 61324c31e..813f6d556 100644
--- a/apps/builder/src/app/space/[workspaceId]/(has-folder)/bot-fields/layout.tsx
+++ b/apps/builder/src/app/space/[workspaceId]/(settings)/settings/bot-fields/layout.tsx
@@ -3,13 +3,11 @@ import { notFound } from "next/navigation"
import type { ReactNode } from "react"
import { FolderStoreProvider } from "@/features/folders/provider/folder-store-context"
-export default async function FolderableLayout({
+export default async function BotFieldsSettingsLayout({
children,
- folders,
params,
}: {
children: ReactNode
- folders: ReactNode
params: Promise<{ workspaceId: string }>
}) {
const workspaceId = getIdFromParams(await params, "workspaceId")
@@ -19,7 +17,6 @@ export default async function FolderableLayout({
return (
- {folders}
{children}
)
diff --git a/apps/builder/src/app/space/[workspaceId]/(has-folder)/bot-fields/page.tsx b/apps/builder/src/app/space/[workspaceId]/(settings)/settings/bot-fields/page.tsx
similarity index 100%
rename from apps/builder/src/app/space/[workspaceId]/(has-folder)/bot-fields/page.tsx
rename to apps/builder/src/app/space/[workspaceId]/(settings)/settings/bot-fields/page.tsx
diff --git a/apps/builder/src/app/space/[workspaceId]/(settings)/settings/closing-notes/page.tsx b/apps/builder/src/app/space/[workspaceId]/(settings)/settings/closing-notes/page.tsx
new file mode 100644
index 000000000..be4c21827
--- /dev/null
+++ b/apps/builder/src/app/space/[workspaceId]/(settings)/settings/closing-notes/page.tsx
@@ -0,0 +1,30 @@
+import { db, eq } from "@chatbotx.io/database/client"
+import { workspaceModel } from "@chatbotx.io/database/schema"
+import { getIdFromParams } from "@chatbotx.io/utils"
+import { notFound } from "next/navigation"
+import { ClosingNotesSettings } from "@/features/closing-notes/closing-notes-settings"
+import { listConversationCategories } from "@/features/closing-notes/queries/list-categories"
+
+export default async function ClosingNotesPage(props: {
+ params: Promise<{ workspaceId: string }>
+}) {
+ const workspaceId = getIdFromParams(await props.params, "workspaceId")
+ if (!workspaceId) {
+ return notFound()
+ }
+
+ const [workspace] = await db
+ .select({ closingNotesMode: workspaceModel.closingNotesMode })
+ .from(workspaceModel)
+ .where(eq(workspaceModel.id, workspaceId))
+
+ const categories = await listConversationCategories(workspaceId)
+
+ return (
+
+ )
+}
diff --git a/apps/builder/src/app/space/[workspaceId]/(settings)/settings/contact-fields/layout.tsx b/apps/builder/src/app/space/[workspaceId]/(settings)/settings/contact-fields/layout.tsx
new file mode 100644
index 000000000..c2788c3a5
--- /dev/null
+++ b/apps/builder/src/app/space/[workspaceId]/(settings)/settings/contact-fields/layout.tsx
@@ -0,0 +1,23 @@
+import { getIdFromParams } from "@chatbotx.io/utils"
+import { notFound } from "next/navigation"
+import type { ReactNode } from "react"
+import { FolderStoreProvider } from "@/features/folders/provider/folder-store-context"
+
+export default async function ContactFieldsSettingsLayout({
+ children,
+ params,
+}: {
+ children: ReactNode
+ params: Promise<{ workspaceId: string }>
+}) {
+ const workspaceId = getIdFromParams(await params, "workspaceId")
+ if (!workspaceId) {
+ return notFound()
+ }
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/apps/builder/src/app/space/[workspaceId]/(has-folder)/custom-fields/page.tsx b/apps/builder/src/app/space/[workspaceId]/(settings)/settings/contact-fields/page.tsx
similarity index 63%
rename from apps/builder/src/app/space/[workspaceId]/(has-folder)/custom-fields/page.tsx
rename to apps/builder/src/app/space/[workspaceId]/(settings)/settings/contact-fields/page.tsx
index b0772fd9f..9d4c2fcca 100644
--- a/apps/builder/src/app/space/[workspaceId]/(has-folder)/custom-fields/page.tsx
+++ b/apps/builder/src/app/space/[workspaceId]/(settings)/settings/contact-fields/page.tsx
@@ -5,9 +5,10 @@ import type { SearchParams } from "nuqs/server"
import { Suspense } from "react"
import { CustomFieldsTable } from "@/features/custom-fields/custom-field-table"
import { listCustomFieldsRSC } from "@/features/custom-fields/queries"
+import { listHiddenContactFieldKeys } from "@/features/custom-fields/queries/visibility"
import { listCustomFieldsSearchParams } from "@/features/custom-fields/schemas/query"
-export default async function CustomFieldsPage(props: {
+export default async function ContactFieldsPage(props: {
params: Promise<{ workspaceId: string }>
searchParams: Promise
}) {
@@ -28,10 +29,26 @@ export default async function CustomFieldsPage(props: {
}),
])
+ // Pro dialog "Personalizar visualização" precisamos da lista completa
+ // de custom fields (não paginada) pra mostrar todos. Cap em 500 cobre
+ // qualquer workspace realista.
+ const [allCustomFields, initialHiddenKeys] = await Promise.all([
+ listCustomFieldsRSC({
+ ...search,
+ workspaceId,
+ folderId: undefined,
+ perPage: 500,
+ page: 1,
+ }),
+ listHiddenContactFieldKeys(workspaceId),
+ ])
+
return (
diff --git a/apps/builder/src/app/space/[workspaceId]/(settings)/settings/layout.tsx b/apps/builder/src/app/space/[workspaceId]/(settings)/settings/layout.tsx
index 4b62fbad0..441631180 100644
--- a/apps/builder/src/app/space/[workspaceId]/(settings)/settings/layout.tsx
+++ b/apps/builder/src/app/space/[workspaceId]/(settings)/settings/layout.tsx
@@ -1,15 +1,9 @@
import type { ReactNode } from "react"
-import { SettingsTab } from "./tab"
type LayoutSettingProps = {
children: ReactNode
}
export default function SettingLayout({ children }: LayoutSettingProps) {
- return (
- <>
-
- {children}
- >
- )
+ return <>{children}>
}
diff --git a/apps/builder/src/app/space/[workspaceId]/(settings)/settings/lifecycle/page.tsx b/apps/builder/src/app/space/[workspaceId]/(settings)/settings/lifecycle/page.tsx
new file mode 100644
index 000000000..74756ae0c
--- /dev/null
+++ b/apps/builder/src/app/space/[workspaceId]/(settings)/settings/lifecycle/page.tsx
@@ -0,0 +1,28 @@
+import { getIdFromParams } from "@chatbotx.io/utils"
+import { notFound } from "next/navigation"
+import { LifecycleStagesEditor } from "@/features/lifecycle-stages/lifecycle-stages-editor"
+import { listLifecycleStages } from "@/features/lifecycle-stages/queries"
+
+export default async function LifecyclePage(props: {
+ params: Promise<{ workspaceId: string }>
+}) {
+ const workspaceId = getIdFromParams(await props.params, "workspaceId")
+ if (!workspaceId) {
+ return notFound()
+ }
+
+ const stages = await listLifecycleStages(workspaceId)
+
+ const initialStages = stages.map((s) => ({
+ id: s.id,
+ key: s.key,
+ name: s.name,
+ icon: s.icon,
+ color: s.color,
+ position: s.position,
+ isDefault: s.isDefault,
+ isLost: s.isLost,
+ }))
+
+ return
+}
diff --git a/apps/builder/src/app/space/[workspaceId]/(settings)/settings/snippets/page.tsx b/apps/builder/src/app/space/[workspaceId]/(settings)/settings/snippets/page.tsx
new file mode 100644
index 000000000..7b7655174
--- /dev/null
+++ b/apps/builder/src/app/space/[workspaceId]/(settings)/settings/snippets/page.tsx
@@ -0,0 +1,22 @@
+import { getIdFromParams } from "@chatbotx.io/utils"
+import { notFound } from "next/navigation"
+import { Suspense } from "react"
+import { listSnippetsRSC } from "@/features/saved-replies/queries/list-snippets-rsc"
+import { SnippetsTable } from "@/features/saved-replies/snippets-table"
+
+export default async function SnippetsPage(props: {
+ params: Promise<{ workspaceId: string }>
+}) {
+ const workspaceId = getIdFromParams(await props.params, "workspaceId")
+ if (!workspaceId) {
+ return notFound()
+ }
+
+ const promises = Promise.all([listSnippetsRSC({ workspaceId })])
+
+ return (
+
+
+
+ )
+}
diff --git a/apps/builder/src/app/space/[workspaceId]/(settings)/settings/tab.tsx b/apps/builder/src/app/space/[workspaceId]/(settings)/settings/tab.tsx
deleted file mode 100644
index 59919cc4b..000000000
--- a/apps/builder/src/app/space/[workspaceId]/(settings)/settings/tab.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-"use client"
-import { usePathname } from "next/navigation"
-import { useTranslations } from "next-intl"
-import { useMemo } from "react"
-import { AppTab } from "@/components/app-tab"
-import { useWorkspaceId } from "@/hooks/routing"
-
-export function SettingsTab() {
- const t = useTranslations()
- const pathname = usePathname()
-
- const workspaceId = useWorkspaceId()
-
- const tabs = useMemo(
- () => [
- {
- label: t("general.title"),
- value: "general",
- },
- {
- label: t("channels.title"),
- value: "channels",
- },
- {
- label: t("integrations.title"),
- value: "integrations",
- },
- {
- label: t("admins.title"),
- value: "admins",
- },
- {
- label: t("inboxTeams.title"),
- value: "inbox-teams",
- },
- // {
- // label: t("billing.title"),
- // value: "billing",
- // },
- ],
- [t],
- )
-
- const activeTab = useMemo(() => {
- const segments = pathname.split("/")
- return segments.at(-1)
- }, [pathname])
-
- return (
- ({
- label: tab.label,
- href: `/space/${workspaceId}/settings/${tab.value}`,
- isActive: activeTab === tab.value,
- }))}
- />
- )
-}
diff --git a/apps/builder/src/app/space/[workspaceId]/(settings)/settings/tags/layout.tsx b/apps/builder/src/app/space/[workspaceId]/(settings)/settings/tags/layout.tsx
new file mode 100644
index 000000000..d9a1fff48
--- /dev/null
+++ b/apps/builder/src/app/space/[workspaceId]/(settings)/settings/tags/layout.tsx
@@ -0,0 +1,23 @@
+import { getIdFromParams } from "@chatbotx.io/utils"
+import { notFound } from "next/navigation"
+import type { ReactNode } from "react"
+import { FolderStoreProvider } from "@/features/folders/provider/folder-store-context"
+
+export default async function TagsSettingsLayout({
+ children,
+ params,
+}: {
+ children: ReactNode
+ params: Promise<{ workspaceId: string }>
+}) {
+ const workspaceId = getIdFromParams(await params, "workspaceId")
+ if (!workspaceId) {
+ return notFound()
+ }
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/apps/builder/src/app/space/[workspaceId]/(has-folder)/tags/page.tsx b/apps/builder/src/app/space/[workspaceId]/(settings)/settings/tags/page.tsx
similarity index 100%
rename from apps/builder/src/app/space/[workspaceId]/(has-folder)/tags/page.tsx
rename to apps/builder/src/app/space/[workspaceId]/(settings)/settings/tags/page.tsx
diff --git a/apps/builder/src/app/space/[workspaceId]/account/layout.tsx b/apps/builder/src/app/space/[workspaceId]/account/layout.tsx
new file mode 100644
index 000000000..6d80771cf
--- /dev/null
+++ b/apps/builder/src/app/space/[workspaceId]/account/layout.tsx
@@ -0,0 +1,11 @@
+import type { ReactNode } from "react"
+import { PersonalSettingsSidebar } from "@/features/account/personal-settings-sidebar"
+
+export default function AccountLayout({ children }: { children: ReactNode }) {
+ return (
+
+ )
+}
diff --git a/apps/builder/src/app/space/[workspaceId]/account/notifications/page.tsx b/apps/builder/src/app/space/[workspaceId]/account/notifications/page.tsx
new file mode 100644
index 000000000..5157f722a
--- /dev/null
+++ b/apps/builder/src/app/space/[workspaceId]/account/notifications/page.tsx
@@ -0,0 +1,46 @@
+import { getIdFromParams } from "@chatbotx.io/utils"
+import { BellIcon } from "lucide-react"
+import { notFound } from "next/navigation"
+import { getTranslations } from "next-intl/server"
+import { getMyWorkspaceMember } from "@/features/notifications/queries"
+import { UpdateNotificationsForm } from "@/features/notifications/update-notifications-form"
+import { getCurrentUserId } from "@/lib/auth/utils"
+
+export default async function NotificationsPage({
+ params,
+}: {
+ params: Promise<{ workspaceId: string }>
+}) {
+ const workspaceId = getIdFromParams(await params, "workspaceId")
+ if (!workspaceId) {
+ return notFound()
+ }
+
+ const userId = await getCurrentUserId()
+ if (!userId) {
+ return notFound()
+ }
+
+ const workspaceMember = await getMyWorkspaceMember(workspaceId, userId)
+ if (!workspaceMember) {
+ return notFound()
+ }
+
+ const t = await getTranslations("personalSettings.notificationsSection")
+ return (
+
+
+
+
+ {t("title")}
+
+
{t("subtitle")}
+
+
+
+ )
+}
diff --git a/apps/builder/src/app/space/[workspaceId]/account/page.tsx b/apps/builder/src/app/space/[workspaceId]/account/page.tsx
new file mode 100644
index 000000000..5ac8e2006
--- /dev/null
+++ b/apps/builder/src/app/space/[workspaceId]/account/page.tsx
@@ -0,0 +1,19 @@
+import { UserIcon } from "lucide-react"
+import { getTranslations } from "next-intl/server"
+import { ProfileForm } from "@/features/account/profile-form"
+
+export default async function ProfilePage() {
+ const t = await getTranslations("personalSettings.profileSection")
+ return (
+
+
+
+
+ {t("title")}
+
+
{t("subtitle")}
+
+
+
+ )
+}
diff --git a/apps/builder/src/app/space/[workspaceId]/broadcasts/page.tsx b/apps/builder/src/app/space/[workspaceId]/broadcasts/page.tsx
index a5d96d9a2..f9bf3af0b 100644
--- a/apps/builder/src/app/space/[workspaceId]/broadcasts/page.tsx
+++ b/apps/builder/src/app/space/[workspaceId]/broadcasts/page.tsx
@@ -3,6 +3,7 @@ import { notFound } from "next/navigation"
import { getTranslations } from "next-intl/server"
import type { SearchParams } from "nuqs/server"
import { Suspense } from "react"
+import { AutomationTabs } from "@/components/automation-tabs"
import { BroadcastsTable } from "@/features/broadcasts/broadcasts-table"
import { listBroadcasts } from "@/features/broadcasts/queries"
import { getBroadcastsSearchParamsCache } from "@/features/broadcasts/schemas/query"
@@ -29,6 +30,7 @@ export default async function BroadcastsPage(props: {
return (
+
{t("broadcasts.title")}
diff --git a/apps/builder/src/app/space/[workspaceId]/contacts/layout.tsx b/apps/builder/src/app/space/[workspaceId]/contacts/layout.tsx
new file mode 100644
index 000000000..9ab31ae41
--- /dev/null
+++ b/apps/builder/src/app/space/[workspaceId]/contacts/layout.tsx
@@ -0,0 +1,94 @@
+import { getIdFromParams } from "@chatbotx.io/utils"
+import { notFound } from "next/navigation"
+import type { ReactNode } from "react"
+import { listInboxTeams } from "@/enterprise/features/inbox-teams/queries"
+import { InboxSidebarExpandButton } from "@/features/chat/components/inbox-sidebar-expand-button"
+import { ChatStoreProvider } from "@/features/chat/store/chat-store-provider"
+import { ContactsAreaSidebar } from "@/features/contacts/components/contacts-area-sidebar"
+import { countBlockedContacts } from "@/features/contacts/queries/count-blocked-contacts"
+import { CustomFieldStoreProvider } from "@/features/custom-fields/provider/custom-field-store-context"
+import { InboxStoreProvider } from "@/features/inboxes/provider/inbox-store-context"
+import {
+ countOpenConversationsByAssignment,
+ countOpenConversationsByLifecycle,
+ countUnreadConversationsByAssignment,
+ countUnreadConversationsByLifecycle,
+ listLifecycleStages,
+} from "@/features/lifecycle-stages/queries"
+import { TagStoreProvider } from "@/features/tags/provider/tag-store-context"
+import { UserStoreProvider } from "@/features/users/provider/user-store-context"
+import { getCurrentUserId } from "@/lib/auth/utils"
+
+// Layout da página /contacts (Pedro 2026-05-26): sidebar lateral fixo
+// literalmente o mesmo do Inbox (mesmo visual + mesmos items: Caixas,
+// Times, Ciclo de vida, Bloqueados footer). Toda a árvore de Providers
+// (ChatStore, InboxStore, TagStore, UserStore, CustomFieldStore) é
+// idêntica à do /inbox/layout.tsx — pra que o drawer "Detalhes do
+// contato" reuse o componente FULL (com inline edit,
+// custom fields, accordion hidden, tags reais).
+export default async function ContactsLayout({
+ children,
+ params,
+}: {
+ children: ReactNode
+ params: Promise<{ workspaceId: string }>
+}) {
+ const workspaceId = getIdFromParams(await params, "workspaceId")
+ if (!workspaceId) {
+ return notFound()
+ }
+
+ const userId = await getCurrentUserId()
+ if (!userId) {
+ return notFound()
+ }
+
+ const [{ data: inboxTeams }, lifecycleStages] = await Promise.all([
+ listInboxTeams({ workspaceId }),
+ listLifecycleStages(workspaceId),
+ ])
+ const teamIds = (inboxTeams ?? []).map((t) => t.id)
+ const [
+ lifecycleCounts,
+ assignmentCounts,
+ unreadByStage,
+ unreadByAssignment,
+ blockedContactsCount,
+ ] = await Promise.all([
+ countOpenConversationsByLifecycle(workspaceId),
+ countOpenConversationsByAssignment(workspaceId, userId, teamIds),
+ countUnreadConversationsByLifecycle(workspaceId),
+ countUnreadConversationsByAssignment(workspaceId, userId, teamIds),
+ countBlockedContacts(workspaceId),
+ ])
+
+ return (
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/builder/src/app/space/[workspaceId]/contacts/page.tsx b/apps/builder/src/app/space/[workspaceId]/contacts/page.tsx
index 9ad04aab8..26c40610d 100644
--- a/apps/builder/src/app/space/[workspaceId]/contacts/page.tsx
+++ b/apps/builder/src/app/space/[workspaceId]/contacts/page.tsx
@@ -3,11 +3,13 @@ import { notFound } from "next/navigation"
import { getTranslations } from "next-intl/server"
import type { SearchParams } from "nuqs/server"
import { Suspense } from "react"
+import { PageHeader } from "@/components/page-header"
import { ContactsTable } from "@/features/contacts/contacts-table"
import { listContacts } from "@/features/contacts/queries/list-contacts.queries"
import { listContactsRequest } from "@/features/contacts/schemas/query"
import { CustomFieldStoreProvider } from "@/features/custom-fields/provider/custom-field-store-context"
import { InboxStoreProvider } from "@/features/inboxes/provider/inbox-store-context"
+import { listLifecycleStages } from "@/features/lifecycle-stages/queries"
import { TagStoreProvider } from "@/features/tags/provider/tag-store-context"
import { UserStoreProvider } from "@/features/users/provider/user-store-context"
@@ -22,7 +24,16 @@ export default async function ContactsPage(props: {
const t = await getTranslations()
const searchParams = await props.searchParams
- const { data: search } = listContactsRequest.safeParse(searchParams)
+ // safeParse precisa de workspaceId (vem do path, não da query). Normaliza
+ // lifecycleStageId single → array antes de parsear (sidebar manda single).
+ const normalized: Record = {
+ ...searchParams,
+ workspaceId,
+ }
+ if (typeof searchParams.lifecycleStageId === "string") {
+ normalized.lifecycleStageIds = [searchParams.lifecycleStageId]
+ }
+ const { data: search } = listContactsRequest.safeParse(normalized)
const promises = Promise.all([
listContacts({
@@ -30,22 +41,28 @@ export default async function ContactsPage(props: {
workspaceId,
}),
])
+ const lifecycleStages = await listLifecycleStages(workspaceId)
return (
-
-
{t("contacts.title")}
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
)
}
diff --git a/apps/builder/src/app/space/[workspaceId]/error-logs/page.tsx b/apps/builder/src/app/space/[workspaceId]/error-logs/page.tsx
index e2bb2fddf..942600073 100644
--- a/apps/builder/src/app/space/[workspaceId]/error-logs/page.tsx
+++ b/apps/builder/src/app/space/[workspaceId]/error-logs/page.tsx
@@ -1,9 +1,7 @@
import { getIdFromParams } from "@chatbotx.io/utils"
import { notFound } from "next/navigation"
-import { getTranslations } from "next-intl/server"
import type { SearchParams } from "nuqs/server"
import { Suspense } from "react"
-import { AppTab } from "@/components/app-tab"
import { ErrorLogsTable } from "@/features/error-logs/error-logs-table"
import { listErrorLogs } from "@/features/error-logs/queries"
import { listErrorLogsSearchParamsCache } from "@/features/error-logs/schemas/query"
@@ -12,8 +10,6 @@ export default async function ErrorLogsPage(props: {
params: Promise<{ workspaceId: string }>
searchParams: Promise
}) {
- const t = await getTranslations()
-
const workspaceId = getIdFromParams(await props.params, "workspaceId")
if (!workspaceId) {
return notFound()
@@ -29,35 +25,11 @@ export default async function ErrorLogsPage(props: {
}),
])
+ // AppTab removido — Logs de Erro agora aparece no sub-sidebar de
+ // Configurações em vez de tabs na aba Fluxos.
return (
-
+
+
+
)
}
diff --git a/apps/builder/src/app/space/[workspaceId]/inbox/layout.tsx b/apps/builder/src/app/space/[workspaceId]/inbox/layout.tsx
new file mode 100644
index 000000000..4de52c495
--- /dev/null
+++ b/apps/builder/src/app/space/[workspaceId]/inbox/layout.tsx
@@ -0,0 +1,99 @@
+import { getIdFromParams } from "@chatbotx.io/utils"
+import { notFound } from "next/navigation"
+import type { ReactNode } from "react"
+import { InboxAreaSidebar } from "@/components/inbox-area-sidebar"
+import { listInboxTeams } from "@/enterprise/features/inbox-teams/queries"
+import { InboxSidebarExpandButton } from "@/features/chat/components/inbox-sidebar-expand-button"
+import { ChatStoreProvider } from "@/features/chat/store/chat-store-provider"
+import { countBlockedContacts } from "@/features/contacts/queries/count-blocked-contacts"
+import { CustomFieldStoreProvider } from "@/features/custom-fields/provider/custom-field-store-context"
+import { FlowStoreProvider } from "@/features/flows/provider/flow-store-context"
+import { InboxStoreProvider } from "@/features/inboxes/provider/inbox-store-context"
+import {
+ countOpenConversationsByAssignment,
+ countOpenConversationsByLifecycle,
+ countUnreadConversationsByAssignment,
+ countUnreadConversationsByLifecycle,
+ listLifecycleStages,
+} from "@/features/lifecycle-stages/queries"
+import { SavedReplyStoreProvider } from "@/features/saved-replies/provider/saved-reply-store-context"
+import { TagStoreProvider } from "@/features/tags/provider/tag-store-context"
+import { UserStoreProvider } from "@/features/users/provider/user-store-context"
+import { getCurrentUserId } from "@/lib/auth/utils"
+
+export default async function InboxLayout({
+ children,
+ params,
+}: {
+ children: ReactNode
+ params: Promise<{ workspaceId: string }>
+}) {
+ const workspaceId = getIdFromParams(await params, "workspaceId")
+ if (!workspaceId) {
+ return notFound()
+ }
+
+ const userId = await getCurrentUserId()
+ if (!userId) {
+ return notFound()
+ }
+
+ // Carrega tudo paralelo. Os badges do sidebar mostram CONVERSAS ABERTAS
+ // (não contatos) pra alinhar com Respond.io (docs:
+ // markdown-raw/inbox/getting-started-with-inbox.md).
+ const [{ data: inboxTeams }, lifecycleStages] = await Promise.all([
+ listInboxTeams({ workspaceId }),
+ listLifecycleStages(workspaceId),
+ ])
+ const teamIds = (inboxTeams ?? []).map((t) => t.id)
+ const [
+ lifecycleCounts,
+ assignmentCounts,
+ unreadByStage,
+ unreadByAssignment,
+ blockedContactsCount,
+ ] = await Promise.all([
+ countOpenConversationsByLifecycle(workspaceId),
+ countOpenConversationsByAssignment(workspaceId, userId, teamIds),
+ countUnreadConversationsByLifecycle(workspaceId),
+ countUnreadConversationsByAssignment(workspaceId, userId, teamIds),
+ countBlockedContacts(workspaceId),
+ ])
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/builder/src/app/space/[workspaceId]/inbox/page.tsx b/apps/builder/src/app/space/[workspaceId]/inbox/page.tsx
index d6e8417ee..b56c14815 100644
--- a/apps/builder/src/app/space/[workspaceId]/inbox/page.tsx
+++ b/apps/builder/src/app/space/[workspaceId]/inbox/page.tsx
@@ -2,13 +2,9 @@ import { getIdFromParams } from "@chatbotx.io/utils"
import { cookies } from "next/headers"
import { notFound } from "next/navigation"
import { ChatLayout } from "@/features/chat/chat-layout"
-import { ChatStoreProvider } from "@/features/chat/store/chat-store-provider"
-import { CustomFieldStoreProvider } from "@/features/custom-fields/provider/custom-field-store-context"
-import { FlowStoreProvider } from "@/features/flows/provider/flow-store-context"
-import { InboxStoreProvider } from "@/features/inboxes/provider/inbox-store-context"
-import { SavedReplyStoreProvider } from "@/features/saved-replies/provider/saved-reply-store-context"
-import { TagStoreProvider } from "@/features/tags/provider/tag-store-context"
-import { UserStoreProvider } from "@/features/users/provider/user-store-context"
+import { getClosingNotesConfig } from "@/features/closing-notes/queries/get-config"
+import { listHiddenContactFieldKeys } from "@/features/custom-fields/queries/visibility"
+import { listLifecycleStages } from "@/features/lifecycle-stages/queries"
type InboxPageProps = {
params: Promise<{ workspaceId: string }>
@@ -21,31 +17,30 @@ export default async function InboxPage({ params }: InboxPageProps) {
}
const layout = (await cookies()).get("csm:layout:inbox")
- const savedLayout = layout ? JSON.parse(layout.value) : [25, 50, 25]
+ // Default Respond.io (Pedro iter 36, medido via Chrome MCP, viewport 2560
+ // sidebar Inbox colapsada):
+ // - Lista chats: 477px (19.34% da área redimensionável)
+ // - Mensagens: 1640px (66.48%)
+ // - Drawer: 350px (14.18%)
+ // Cookie persistido tem prioridade — usuário mantém ajustes próprios.
+ const savedLayout = layout ? JSON.parse(layout.value) : [19, 67, 14]
+ const [lifecycleStages, hiddenFieldKeys, closingNotesConfig] =
+ await Promise.all([
+ listLifecycleStages(workspaceId),
+ listHiddenContactFieldKeys(workspaceId),
+ getClosingNotesConfig(workspaceId),
+ ])
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
)
}
diff --git a/apps/builder/src/app/space/[workspaceId]/layout.tsx b/apps/builder/src/app/space/[workspaceId]/layout.tsx
index 1ba8427db..37657955b 100644
--- a/apps/builder/src/app/space/[workspaceId]/layout.tsx
+++ b/apps/builder/src/app/space/[workspaceId]/layout.tsx
@@ -1,14 +1,17 @@
-import { workspaceMemberService } from "@chatbotx.io/business"
+import {
+ organizationService,
+ workspaceMemberService,
+} from "@chatbotx.io/business"
import {
SidebarInset,
SidebarProvider,
- SidebarTrigger,
} from "@chatbotx.io/ui/components/ui/sidebar"
import { getIdFromParams } from "@chatbotx.io/utils"
-import { cookies } from "next/headers"
import { notFound } from "next/navigation"
import { AppSidebar } from "@/components/app-sidebar"
+import { SettingsAreaSidebar } from "@/components/settings-area-sidebar"
import { getCurrentUserId } from "@/lib/auth/utils"
+import { getDomainFromHeader } from "@/lib/domain"
export default async function WorkspaceLayout({
children,
@@ -43,16 +46,32 @@ export default async function WorkspaceLayout({
(workspaceMember) => workspaceMember.workspace,
)
- const cookieStore = await cookies()
- const defaultOpen = cookieStore.get("sidebar_state")?.value === "true"
+ const domain = await getDomainFromHeader()
+ let organization: Awaited<
+ ReturnType
+ > | null = null
+ try {
+ organization = await organizationService.findByDomain(domain)
+ } catch {
+ organization = null
+ }
return (
-
-
+
+
-
-
- {children}
+
+
+
+ {children}
+
+
)
diff --git a/apps/builder/src/app/space/[workspaceId]/notifications/page.tsx b/apps/builder/src/app/space/[workspaceId]/notifications/page.tsx
new file mode 100644
index 000000000..a472156e4
--- /dev/null
+++ b/apps/builder/src/app/space/[workspaceId]/notifications/page.tsx
@@ -0,0 +1,10 @@
+import { redirect } from "next/navigation"
+
+export default async function NotificationsRedirect({
+ params,
+}: {
+ params: Promise<{ workspaceId: string }>
+}) {
+ const { workspaceId } = await params
+ redirect(`/space/${workspaceId}/account/notifications`)
+}
diff --git a/apps/builder/src/app/space/[workspaceId]/webchats/[id]/edit/page.tsx b/apps/builder/src/app/space/[workspaceId]/webchats/[id]/edit/page.tsx
index 346ef0a62..2360f0ad2 100644
--- a/apps/builder/src/app/space/[workspaceId]/webchats/[id]/edit/page.tsx
+++ b/apps/builder/src/app/space/[workspaceId]/webchats/[id]/edit/page.tsx
@@ -22,7 +22,7 @@ export default async function WebchatEditPage({
return (
- Loading... }>
+ Carregando... }>
diff --git a/apps/builder/src/app/space/[workspaceId]/webchats/create/page.tsx b/apps/builder/src/app/space/[workspaceId]/webchats/create/page.tsx
index 851801d4f..a6f829694 100644
--- a/apps/builder/src/app/space/[workspaceId]/webchats/create/page.tsx
+++ b/apps/builder/src/app/space/[workspaceId]/webchats/create/page.tsx
@@ -15,7 +15,7 @@ export default async function CreateWebchatPage({
}
return (
-
Loading... }>
+ Carregando... }>
diff --git a/apps/builder/src/components/app-sidebar.tsx b/apps/builder/src/components/app-sidebar.tsx
index c06d617d3..bf793a5cc 100644
--- a/apps/builder/src/components/app-sidebar.tsx
+++ b/apps/builder/src/components/app-sidebar.tsx
@@ -7,26 +7,21 @@ import {
SidebarHeader,
} from "@chatbotx.io/ui/components/ui/sidebar"
import {
- AtomIcon,
- BrainIcon,
- ChartPieIcon,
- ChevronsRight,
- LightbulbIcon,
- MessageCircleMoreIcon,
- RadioIcon,
- SlidersHorizontalIcon,
- UsersIcon,
- WebhookIcon,
- WorkflowIcon,
- WrenchIcon,
-} from "lucide-react"
+ Brodcast,
+ DirectInbox,
+ Element2,
+ Setting2,
+ UserSquare,
+} from "iconsax-reactjs"
import Link from "next/link"
import { useTranslations } from "next-intl"
import type { ComponentProps } from "react"
import { BrandIcon } from "@/components/brand-icon"
+import { type IconsaxComponent, wrapIconsax } from "@/components/iconsax-icon"
import { NavMain } from "@/components/nav-main"
import { NavUser } from "@/components/nav-user"
+import { RespondAIAgentIcon, RespondFlowIcon } from "@/components/respond-icons"
import { WorkspaceSwitcher } from "@/components/workspace-switcher"
import type { WorkspaceResource } from "@/features/workspaces/schema/resource"
import { authClient } from "@/lib/auth/auth-client"
@@ -34,10 +29,12 @@ import { authClient } from "@/lib/auth/auth-client"
export function AppSidebar({
workspaceId,
allWorkspaces,
+ organization,
...props
}: ComponentProps
& {
workspaceId: string
allWorkspaces: WorkspaceResource[]
+ organization?: { id: string; name: string } | null
}) {
const t = useTranslations()
const { data: session } = authClient.useSession()
@@ -48,88 +45,85 @@ export function AppSidebar({
email: session?.user.email ?? "",
avatar: session?.user.image ?? "",
},
+ // Ordem definida pelo Pedro 2026-05-26:
+ // Análises → Inbox → Contatos → Agentes de IA → Transmissão → Fluxos →
+ // Configurações.
navMain: [
{
title: t("fields.analytics.label"),
url: `/space/${workspaceId}/dashboard`,
- icon: ChartPieIcon,
+ // Equivale ao `icon-element-2` (Dashboard no Respond.io)
+ icon: wrapIconsax(Element2 as IconsaxComponent),
},
{
title: t("fields.inbox.label"),
url: `/space/${workspaceId}/inbox`,
- icon: MessageCircleMoreIcon,
- },
- {
- title: t("fields.flows.label"),
- url: `/space/${workspaceId}/flows`,
- icon: WorkflowIcon,
+ // Equivale ao `icon-direct-inbox` (Inbox no Respond.io)
+ icon: wrapIconsax(DirectInbox as IconsaxComponent),
},
{
title: t("fields.contacts.label"),
url: `/space/${workspaceId}/contacts`,
- icon: UsersIcon,
+ icon: wrapIconsax(UserSquare as IconsaxComponent),
},
{
title: t("aiAgent.title"),
url: `/space/${workspaceId}/ai-agents`,
- icon: BrainIcon,
+ // AI Agent — robô do Respond.io extraído da fonte deles (pixel-perfect).
+ // Substitui o CpuCharge da Iconsax.
+ icon: RespondAIAgentIcon,
},
{
- title: t("keywords.title"),
+ title: t("automation.title"),
url: `/space/${workspaceId}/automated-responses`,
- icon: AtomIcon,
- },
- {
- title: t("broadcasts.title"),
- url: `/space/${workspaceId}/broadcasts`,
- icon: RadioIcon,
- },
- {
- title: t("sequences.title"),
- url: `/space/${workspaceId}/sequences`,
- icon: ChevronsRight,
- },
- {
- title: t("triggers.title"),
- url: `/space/${workspaceId}/triggers`,
- icon: LightbulbIcon,
- },
- {
- title: t("webhooks.title"),
- url: `/space/${workspaceId}/webhooks`,
- icon: WebhookIcon,
+ // Equivale ao `icon-custom-broadcast` (Broadcast/Automação).
+ // Usamos `Brodcast` (sic — nome real na lib).
+ icon: wrapIconsax(Brodcast as IconsaxComponent),
},
{
- title: t("tools.title"),
- url: `/space/${workspaceId}/tools`,
- icon: WrenchIcon,
+ title: t("fields.flows.label"),
+ url: `/space/${workspaceId}/flows`,
+ // Flow — ícone do Respond.io extraído da fonte deles (pixel-perfect).
+ // Substitui o Hierarchy3 da Iconsax que era só aproximação visual.
+ icon: RespondFlowIcon,
},
{
title: t("settings.title"),
url: `/space/${workspaceId}/settings/general`,
- icon: SlidersHorizontalIcon,
+ // Equivale ao `icon-settings`. Usamos `Setting2` da Iconsax.
+ icon: wrapIconsax(Setting2 as IconsaxComponent),
},
],
}
return (
-
-
-
-
-
-
+ {/* Header — só o WorkspaceSwitcher. pt-2 (8px) pra subir o AP e
+ alinhar o centro dele com o título do sub-sidebar (Caixa de Entrada).
+ Antes era pt-4 (16px) e o AP ficava 12px abaixo do título do sub. */}
+
+
+
+ {/* Footer — NavUser + BrandIcon (embaixo, com link pra "/").
+ Container do logo um pouco maior (h-12) pra acomodar o ícone de 32px
+ com respiro. */}
+
+
+
)
diff --git a/apps/builder/src/components/arrow-handle.tsx b/apps/builder/src/components/arrow-handle.tsx
new file mode 100644
index 000000000..0c2a4dd2f
--- /dev/null
+++ b/apps/builder/src/components/arrow-handle.tsx
@@ -0,0 +1,60 @@
+import { cn } from "@chatbotx.io/ui/lib/utils"
+import { Handle, type HandleProps, useNodeConnections } from "@xyflow/react"
+import { ArrowRight2 } from "iconsax-reactjs"
+
+/**
+ * Handle estilo BotConversa: bolinha maior (20px) com setinha `>` dentro,
+ * posicionada na borda externa do node (metade dentro, metade fora). Usada
+ * pros source handles do fluxo principal — feedback visual claro de "saída"
+ * pra próxima ação.
+ *
+ * Difere do `BaseHandle` (handles menores 11px sem ícone, usado em handles
+ * internos como botões de quick reply).
+ *
+ * Cor do círculo personalizável via `handleColor` / `handleBorderColor`
+ * (classes Tailwind tipo `!bg-emerald-500 !border-emerald-600`). Pedro pediu
+ * cor distinta por tipo de node.
+ */
+export type ArrowHandleProps = HandleProps & {
+ ref?: React.RefObject
+ /** Classes Tailwind extras pra cor da bolinha (override do default). */
+ colorClassName?: string
+}
+
+export const ArrowHandle = (
+ props: ArrowHandleProps & { type?: "source" | "target" },
+) => {
+ const { ref, className, colorClassName, children, ...rest } = props
+
+ const connections = useNodeConnections({
+ handleType: rest.type,
+ handleId: rest.id ?? "",
+ })
+
+ return (
+ 0 && "!bg-blue-600 !border-blue-700",
+ colorClassName,
+ className,
+ )}
+ {...rest}
+ ref={ref}
+ >
+ {/* Iconsax `ArrowRight2` — setinha estilo BotConversa */}
+
+
+
+ {children}
+
+ )
+}
+
+ArrowHandle.displayName = "ArrowHandle"
diff --git a/apps/builder/src/components/automation-tabs.tsx b/apps/builder/src/components/automation-tabs.tsx
new file mode 100644
index 000000000..900ebcd5b
--- /dev/null
+++ b/apps/builder/src/components/automation-tabs.tsx
@@ -0,0 +1,41 @@
+"use client"
+
+import { Tabs, TabsList, TabsTrigger } from "@chatbotx.io/ui/components/ui/tabs"
+import Link from "next/link"
+import { useParams, usePathname } from "next/navigation"
+import { useTranslations } from "next-intl"
+
+type AutomationTab = {
+ value: "automated-responses" | "broadcasts" | "sequences"
+ labelKey: "keywords.title" | "broadcasts.title" | "sequences.title"
+}
+
+const TABS: AutomationTab[] = [
+ { value: "automated-responses", labelKey: "keywords.title" },
+ { value: "broadcasts", labelKey: "broadcasts.title" },
+ { value: "sequences", labelKey: "sequences.title" },
+]
+
+export function AutomationTabs() {
+ const t = useTranslations()
+ const pathname = usePathname()
+ const params = useParams()
+ const workspaceId = params.workspaceId as string
+
+ const base = `/space/${workspaceId}`
+ const activeTab =
+ TABS.find((tab) => pathname.startsWith(`${base}/${tab.value}`))?.value ??
+ TABS[0].value
+
+ return (
+
+
+ {TABS.map((tab) => (
+
+ {t(tab.labelKey)}
+
+ ))}
+
+
+ )
+}
diff --git a/apps/builder/src/components/brand-icon.tsx b/apps/builder/src/components/brand-icon.tsx
index ea165f323..09ac4cb6a 100644
--- a/apps/builder/src/components/brand-icon.tsx
+++ b/apps/builder/src/components/brand-icon.tsx
@@ -12,7 +12,7 @@ type BrandIconProps = {
}
export const BrandIcon = ({
- alt = "Brand Icon",
+ alt = "Ícone da marca",
className,
}: BrandIconProps) => {
const currentTheme = useCurrentTheme()
@@ -31,8 +31,11 @@ export const BrandIcon = ({
currentTheme === "dark" ? "/brand/logo_white.svg" : "/brand/logo_black.svg"
const logoSrc = logo || baseLogoSrc
+ // Icon collapsed — usa os novos PNGs do logo X1 do Pedro (2026-05-24).
+ // PNGs originais: x1_white.png pro dark, x1_black.png pro light.
+ // Caso o arquivo não exista, cai pros SVGs antigos.
const baseIconSrc =
- currentTheme === "dark" ? "/brand/icon_white.svg" : "/brand/icon_black.svg"
+ currentTheme === "dark" ? "/brand/x1_white.png" : "/brand/x1_black.png"
const iconSrc = logo || baseIconSrc
return (
@@ -48,17 +51,20 @@ export const BrandIcon = ({
src={logoSrc}
width={10}
/>
- {/* Icon - shown when collapsed */}
+ {/* Icon - shown when collapsed (default state do sidebar global do app).
+ h-8/w-8 (32px display) com width/height=128 pra Next/Image gerar uma
+ variant maior e nítida — antes ficava 32×32 borrado parecendo um X
+ de close button. */}
>
)
diff --git a/apps/builder/src/components/dev-click-to-component.tsx b/apps/builder/src/components/dev-click-to-component.tsx
new file mode 100644
index 000000000..9c3f64f43
--- /dev/null
+++ b/apps/builder/src/components/dev-click-to-component.tsx
@@ -0,0 +1,31 @@
+"use client"
+
+/**
+ * Wrapper do `click-to-react-component` que SÓ carrega em desenvolvimento.
+ *
+ * Permite que o Pedro segure **⌥ Option** (Mac) e clique em qualquer elemento
+ * da UI pra abrir o arquivo React correspondente direto no Cursor, com o
+ * cursor posicionado na linha exata. Pra ajustes finos de espaçamento, cor,
+ * etc, basta clicar e pedir "mova 4px pra cima" no chat do Cursor.
+ *
+ * Em produção, retorna `null` e o `dynamic({ ssr: false })` impede que o pkg
+ * seja incluído no bundle.
+ */
+
+import dynamic from "next/dynamic"
+
+const ClickToComponent = dynamic(
+ () =>
+ import("click-to-react-component").then((mod) => ({
+ default: mod.ClickToComponent,
+ })),
+ { ssr: false },
+)
+
+export function DevClickToComponent() {
+ if (process.env.NODE_ENV !== "development") {
+ return null
+ }
+ // editor="cursor" → abre via cursor:// protocol; configurado por padrão no Cursor.
+ return
+}
diff --git a/apps/builder/src/components/file-dropzone.tsx b/apps/builder/src/components/file-dropzone.tsx
index baebf5aeb..6d1191674 100644
--- a/apps/builder/src/components/file-dropzone.tsx
+++ b/apps/builder/src/components/file-dropzone.tsx
@@ -195,7 +195,7 @@ export default function FileDropzone({
return (
<>
@@ -257,7 +257,7 @@ export default function FileDropzone({
- Upload File
+ Enviar Arquivo
diff --git a/apps/builder/src/components/iconsax-icon.tsx b/apps/builder/src/components/iconsax-icon.tsx
new file mode 100644
index 000000000..c6ca31797
--- /dev/null
+++ b/apps/builder/src/components/iconsax-icon.tsx
@@ -0,0 +1,51 @@
+"use client"
+
+import type { ComponentType, ReactElement } from "react"
+
+/**
+ * Helper compartilhado pra usar ícones da biblioteca pública `iconsax-reactjs`
+ * (MIT) com a API "className" que o resto do app usa (estilo Lucide).
+ *
+ * Iconsax usa `size`/`color`/`variant` em vez de className. O wrapper aqui
+ * renderiza um `` com a className recebida + o ícone Iconsax dentro
+ * com `size="100%"` e `color="currentColor"` (herda cor do parent).
+ *
+ * Padrão recomendado de uso em qualquer arquivo:
+ * import { wrapIconsax } from "@/components/iconsax-icon"
+ * import { Add, ArrowLeft2, Edit2 } from "iconsax-reactjs"
+ *
+ * const AddIcon = wrapIconsax(Add)
+ *
+ */
+export type IconsaxComponent = ComponentType<{
+ size?: string | number
+ color?: string
+ variant?: "Linear" | "Outline" | "Broken" | "Bold" | "Bulk" | "TwoTone"
+}>
+
+export type WrappedIcon = (props: { className?: string }) => ReactElement
+
+/**
+ * @param Component componente Iconsax (ex.: `Add`, `ArrowLeft2`, etc.)
+ * @param variant variant default ("Outline" combina com o tema do app)
+ */
+export function wrapIconsax(
+ Component: IconsaxComponent,
+ variant:
+ | "Linear"
+ | "Outline"
+ | "Broken"
+ | "Bold"
+ | "Bulk"
+ | "TwoTone" = "Outline",
+): WrappedIcon {
+ const Wrapped: WrappedIcon = ({ className }) => (
+
+
+
+ )
+ // biome-ignore lint/suspicious/noExplicitAny: precisamos atribuir displayName
+ ;(Wrapped as any).displayName =
+ Component.displayName || Component.name || "IconsaxIcon"
+ return Wrapped
+}
diff --git a/apps/builder/src/components/image-dropzone.tsx b/apps/builder/src/components/image-dropzone.tsx
index d4fe3f6d2..b826f101a 100644
--- a/apps/builder/src/components/image-dropzone.tsx
+++ b/apps/builder/src/components/image-dropzone.tsx
@@ -20,7 +20,7 @@ function AttachedImage({
return (
9999 ? "9,999+" : count.toLocaleString("pt-BR")
+}
+
+function renderCountBadge(
+ count: number,
+ hasUnread: boolean,
+ t: ReturnType,
+) {
+ if (hasUnread) {
+ // O número virou indicador "nova mensagem" — pill azul brilhante.
+ // Quando count for 0 (raro mas possível), mostra o pill mesmo assim
+ // pra sinalizar que TEM unread.
+ return (
+
+ {count > 0 ? formatCount(count) : ""}
+
+ )
+ }
+ if (count > 0) {
+ // Só contagem de conversas abertas, sem unread — número simples negrito.
+ return (
+
+ {formatCount(count)}
+
+ )
+ }
+ return null
+}
+
+type InboxFilterItem = {
+ key: string
+ label: string
+ icon: LucideIcon
+ assignedId: string
+ isActive: (currentAssignedId: string | undefined) => boolean
+ onSelect: () => void
+}
+
+type AssignmentCounts = {
+ all: number
+ mine: number
+ unassigned: number
+ teams: Record
+}
+
+type InboxAreaSidebarProps = {
+ workspaceId: string
+ currentUserId: string
+ inboxTeams: InboxTeamModel[]
+ lifecycleStages: LifecycleStageModel[]
+ // Contagens de CONVERSAS ABERTAS por stage (badge ao lado de cada
+ // "Novo Lead", "Lead Quente" etc) — não é número de contatos. Alinhado
+ // com docs Respond.io ("Contagem de conversas abertas").
+ lifecycleCounts: Record
+ // Contagens dos filtros top (Todos / Minhas / Não atribuídas + teams).
+ assignmentCounts: AssignmentCounts
+ // Recortes COM conversas NÃO LIDAS (contactLastReadAt > agentLastReadAt).
+ // Usados pro indicador "nova mensagem" (bolinha azul que segue prioridade
+ // Minhas > Equipe > Não atribuídas > Todas — doc Respond.io). Some quando
+ // filtro é ativo.
+ unreadByAssignment: AssignmentCounts
+ unreadByStage: Record
+ // Quantidade de contatos com `blockedAt IS NOT NULL`. Usado no item
+ // "Contatos bloqueados" do rodapé do sidebar (pixel-perfect Respond.io).
+ blockedContactsCount: number
+}
+
+export function InboxAreaSidebar({
+ workspaceId,
+ currentUserId,
+ inboxTeams,
+ lifecycleStages,
+ lifecycleCounts,
+ assignmentCounts,
+ unreadByAssignment,
+ unreadByStage,
+ blockedContactsCount,
+}: InboxAreaSidebarProps) {
+ const t = useTranslations()
+ const [collapsed, toggleCollapsed] = useInboxSidebarCollapsed()
+ const filters = useChatStore((state) => state.filters)
+ const setFilters = useChatStore((state) => state.setFilters)
+ const resetState = useChatStore((state) => state.resetState)
+ const loadMoreConversations = useChatStore(
+ (state) => state.loadMoreConversations,
+ )
+
+ // Quando filter "Contatos bloqueados" tá ativo, ele é EXCLUSIVO: os
+ // outros filtros do topo (Todos/Minhas/Não atribuídas), teams e lifecycle
+ // stages perdem o highlight de "ativo" mesmo que o assignedId/lifecycleId
+ // ainda esteja setado no store. Pedro pediu 2026-05-25 — comportamento
+ // de "caixa especial" igual Respond.io.
+ const isBlockedFilterActive = filters.tags?.includes("blocked") ?? false
+ const currentAssignedId = isBlockedFilterActive
+ ? "__blocked__"
+ : (filters.assignedId ?? "all")
+ const currentLifecycleId = isBlockedFilterActive
+ ? null
+ : (filters.lifecycleStageId ?? null)
+ // Lost stages ficam OCULTOS no Inbox (igual Respond.io) — só aparecem nas configurações
+ const activeLifecycleStages = lifecycleStages.filter((s) => !s.isLost)
+
+ // Lê o toggle global do localStorage (controla se a seção Ciclo de vida aparece)
+ const [lifecycleVisible, setLifecycleVisible] = useState(true)
+ useEffect(() => {
+ const stored = localStorage.getItem(LIFECYCLE_VISIBILITY_KEY)
+ if (stored !== null) {
+ setLifecycleVisible(stored === "true")
+ }
+ const handler = (e: StorageEvent) => {
+ if (e.key === LIFECYCLE_VISIBILITY_KEY && e.newValue !== null) {
+ setLifecycleVisible(e.newValue === "true")
+ }
+ }
+ window.addEventListener("storage", handler)
+ return () => window.removeEventListener("storage", handler)
+ }, [])
+
+ // Polling 30s das contagens (mantém sidebar fresca quando uma conversa
+ // muda de stage / é arquivada / é atribuída / leitura). Re-busca todas
+ // as 4 séries (counts + unread por stage e assignment) numa só chamada.
+ const [polledLifecycle, setPolledLifecycle] =
+ useState>(lifecycleCounts)
+ const [polledAssignment, setPolledAssignment] =
+ useState(assignmentCounts)
+ const [polledUnreadStage, setPolledUnreadStage] =
+ useState>(unreadByStage)
+ const [polledUnreadAssignment, setPolledUnreadAssignment] =
+ useState(unreadByAssignment)
+ useEffect(() => {
+ let mounted = true
+ const teamIds = inboxTeams.map((t) => t.id)
+ const boundAction = getLifecycleCountsAction.bind(null, workspaceId)
+ const tick = async () => {
+ try {
+ const result = await boundAction({ teamIds })
+ if (mounted && result?.data) {
+ setPolledLifecycle(result.data.byStage)
+ setPolledAssignment(result.data.byAssignment)
+ setPolledUnreadStage(result.data.unreadByStage)
+ setPolledUnreadAssignment(result.data.unreadByAssignment)
+ }
+ } catch {
+ // silent — manter contagem anterior
+ }
+ }
+ const interval = setInterval(tick, LIFECYCLE_COUNTS_POLL_MS)
+ return () => {
+ mounted = false
+ clearInterval(interval)
+ }
+ }, [workspaceId, inboxTeams.map])
+ const counts = polledLifecycle
+ const assignCounts = polledAssignment
+ const unreadStage = polledUnreadStage
+ const unreadAssign = polledUnreadAssignment
+
+ // Optimistic update da bolinha "nova mensagem": zera o recorte
+ // localmente assim que o user clica. UPDATE no DB roda em background;
+ // polling 30s reconfirma. Doc: "Ao abrir a caixa de entrada, o ponto
+ // azul desaparece."
+ const markAssignmentReadOptimistic = (assignedId: string) => {
+ // Cada filtro zera APENAS seu próprio recorte. "Todos" não toca em
+ // lifecycle stages (decisão Pedro 2026-05-24 — visão "Todos" é só
+ // mudar de visão, não confirma leitura geral).
+ setPolledUnreadAssignment((prev) => {
+ if (assignedId === "all") {
+ return { all: 0, mine: 0, unassigned: 0, teams: {} }
+ }
+ if (assignedId === `u_${currentUserId}`) {
+ return { ...prev, mine: 0, all: Math.max(0, prev.all - prev.mine) }
+ }
+ if (assignedId === "unassigned") {
+ return {
+ ...prev,
+ unassigned: 0,
+ all: Math.max(0, prev.all - prev.unassigned),
+ }
+ }
+ if (assignedId.startsWith("t_")) {
+ const teamId = assignedId.slice(2)
+ const teamCount = prev.teams[teamId] ?? 0
+ return {
+ ...prev,
+ teams: { ...prev.teams, [teamId]: 0 },
+ all: Math.max(0, prev.all - teamCount),
+ }
+ }
+ return prev
+ })
+ }
+
+ const markLifecycleReadOptimistic = (stageId: string) => {
+ setPolledUnreadStage((prev) => ({ ...prev, [stageId]: 0 }))
+ }
+
+ // Hook do next-safe-action (chamada direta sem useAction não dispara
+ // o handler). EXCEÇÃO: "Todos" não confirma leitura — só muda visão.
+ const { execute: executeMarkFilterRead } = useAction(
+ markFilterReadAction.bind(null, workspaceId),
+ )
+
+ // Helper: remove a tag "blocked" do array (preservando outras tags).
+ // Top items / lifecycle / teams SEMPRE chamam isto pra desativar a
+ // "caixa de bloqueados" automaticamente quando o user volta pra um
+ // filtro normal — comportamento exclusivo igual sidebar do Respond.io
+ // (Pedro reforçou 2026-05-25: "quando volto pra Todos, sai do blocked").
+ const stripBlocked = () =>
+ (filters.tags ?? []).filter((tag) => tag !== "blocked")
+
+ const applyFilter = (assignedId: string) => {
+ setFilters({
+ ...filters,
+ assignedId,
+ lifecycleStageId: null,
+ tags: stripBlocked(),
+ })
+ resetState()
+ loadMoreConversations(workspaceId)
+ markAssignmentReadOptimistic(assignedId)
+ if (assignedId === "all") {
+ // no-op
+ } else if (assignedId === `u_${currentUserId}`) {
+ executeMarkFilterRead({ scope: "mine", userId: currentUserId })
+ } else if (assignedId === "unassigned") {
+ executeMarkFilterRead({ scope: "unassigned" })
+ } else if (assignedId.startsWith("t_")) {
+ executeMarkFilterRead({ scope: "team", teamId: assignedId.slice(2) })
+ }
+ }
+
+ const applyLifecycleFilter = (stageId: string | null) => {
+ setFilters({
+ ...filters,
+ lifecycleStageId: stageId,
+ tags: stripBlocked(),
+ })
+ resetState()
+ loadMoreConversations(workspaceId)
+ if (stageId) {
+ markLifecycleReadOptimistic(stageId)
+ executeMarkFilterRead({ scope: "lifecycle", stageId })
+ }
+ }
+
+ // Filtro "Contatos bloqueados" (rodapé) — exclusivo dos filtros
+ // assignment/lifecycle. Clicar nele LIMPA assignedId/lifecycleStageId
+ // e ativa `tags: ["blocked"]`. Clicar de novo (toggle off) volta pra
+ // visão "Todos". Quando o user clica em qualquer outro filtro
+ // (Todos / Minhas / Lifecycle / Team), `stripBlocked()` desativa este
+ // automaticamente.
+ const applyBlockedFilter = () => {
+ if (isBlockedFilterActive) {
+ // Toggle off — volta pra visão padrão "Todos".
+ setFilters({
+ ...filters,
+ tags: stripBlocked(),
+ assignedId: "all",
+ lifecycleStageId: null,
+ })
+ } else {
+ // Toggle on — limpa outros filtros e ativa apenas blocked.
+ setFilters({
+ ...filters,
+ tags: [...stripBlocked(), "blocked" as const],
+ assignedId: "all",
+ lifecycleStageId: null,
+ })
+ }
+ resetState()
+ loadMoreConversations(workspaceId)
+ }
+
+ // Prioridade da bolinha "nova mensagem" (doc Respond.io):
+ // Minhas > Equipe > Não atribuídas > Todas.
+ // Se um nível superior tem unread, esconde dos níveis inferiores.
+ const anyTeamUnread = Object.values(unreadAssign.teams).some((c) => c > 0)
+ const showAllDot =
+ unreadAssign.all > 0 &&
+ unreadAssign.mine === 0 &&
+ unreadAssign.unassigned === 0 &&
+ !anyTeamUnread
+ const showMineDot = unreadAssign.mine > 0
+ const showUnassignedDot =
+ unreadAssign.unassigned > 0 && unreadAssign.mine === 0 && !anyTeamUnread
+ const showTeamDot = (teamId: string): boolean =>
+ (unreadAssign.teams[teamId] ?? 0) > 0 && unreadAssign.mine === 0
+
+ // Cada filtro top vem com a count real de conversas abertas naquele
+ // recorte + flag `showDot` que indica se DEVE renderizar a bolinha azul
+ // "nova mensagem" (segue prioridade Minhas > Equipe > Não atribuídas >
+ // Todas — doc Respond.io). Esconde quando filtro está ativo.
+ const topItems: Array =
+ [
+ {
+ key: "all",
+ label: t("inboxFilters.all"),
+ icon: InboxIcon,
+ assignedId: "all",
+ count: assignCounts.all,
+ showDot: showAllDot,
+ isActive: (current) =>
+ !current ||
+ current === "all" ||
+ (current !== "unassigned" &&
+ !current.startsWith("u_") &&
+ !current.startsWith("t_")),
+ onSelect: () => applyFilter("all"),
+ },
+ {
+ key: "mine",
+ label: t("inboxFilters.mine"),
+ icon: UserIcon,
+ assignedId: `u_${currentUserId}`,
+ count: assignCounts.mine,
+ showDot: showMineDot,
+ isActive: (current) => current === `u_${currentUserId}`,
+ onSelect: () => applyFilter(`u_${currentUserId}`),
+ },
+ {
+ key: "unassigned",
+ label: t("inboxFilters.unassigned"),
+ icon: UserSearchIcon,
+ assignedId: "unassigned",
+ count: assignCounts.unassigned,
+ showDot: showUnassignedDot,
+ isActive: (current) => current === "unassigned",
+ onSelect: () => applyFilter("unassigned"),
+ },
+ ]
+
+ // Collapsed: sidebar 215px some inteira. Botão "expandir" mora no
+ // chat-layout (renderiza só quando collapsed lendo do mesmo hook).
+ if (collapsed) {
+ return null
+ }
+
+ return (
+ // Pixel-perfect Respond.io 2026-05-24:
+ // bg `--app-surface` (#222225), w 215, border-right padrão.
+ // Header 43px com font 14/700 + search + collapse à direita.
+ // Filtros 32px h (medido no Respond ao vivo), lifecycle 32px h.
+ // Section labels uppercase letter-spacing pra parecer respond.io.
+ // Badge contagem com pill bg-primary (azul) igual mapping do Diego.
+ // Layout em 3 partes (Pedro pediu 2026-05-25 — pixel-perfect Respond.io):
+ // 1. Header 43px (título + collapse)
+ // 2. Conteúdo principal scrollável (filtros + teams + lifecycle)
+ // 3. Rodapé fixo com "Contatos bloqueados" (igual Respond.io)
+
+ {/* Header 43px exato — alinhado com altura do topbar do AppSidebar.
+ Só o título + botão collapse (RespondIcon sidebar-left, iconfont
+ Respond.io/iconsax). Lupa removida — search só voltará quando
+ tiver implementação real. */}
+
+
+ {t("fields.inbox.label")}
+
+
toggleCollapsed(true)}
+ type="button"
+ >
+ {/* PanelLeftCloseIcon (lucide) — fallback temporário enquanto
+ investigo por que glyph iconsax sidebar-left renderiza como
+ quadrado simples no 18px. CSS+font tudo OK no debug. */}
+
+
+
+
+ {/* Filtros top: 36px h, padding 0 10, radius 4 (mapping Diego)
+ + badge à direita com conversas abertas naquele recorte. */}
+
+ {topItems.map((item) => {
+ const Icon = item.icon
+ const active = item.isActive(currentAssignedId)
+ // "Nova mensagem" (doc Respond.io getting-started-with-inbox.md):
+ // quando o recorte tem unread, o NÚMERO fica DENTRO de um pill
+ // azul brilhante (não é bolinha separada). Some quando o filtro
+ // é ativo. Quando NÃO tem unread, número fica solto sem pill.
+ const showUnreadBadge = item.showDot && !active
+ return (
+
+
+ {item.label}
+ {renderCountBadge(item.count, showUnreadBadge, t)}
+
+ )
+ })}
+
+
+ {inboxTeams.length > 0 && (
+
+
+ {t("inboxFilters.teamInbox")}
+
+ {inboxTeams.map((team) => {
+ const teamFilterValue = `t_${team.id}`
+ const active = currentAssignedId === teamFilterValue
+ const teamCount = assignCounts.teams[team.id] ?? 0
+ const teamShowDot = showTeamDot(team.id) && !active
+ return (
+ applyFilter(teamFilterValue)}
+ type="button"
+ >
+
+ {team.name}
+ {renderCountBadge(teamCount, teamShowDot, t)}
+
+ )
+ })}
+
+ )}
+
+ {/* Outros sections continuam */}
+ {lifecycleVisible && activeLifecycleStages.length > 0 && (
+ // Seção "Ciclo de vida" — label uppercase 12/600 muted (mapping Diego)
+
+
+ {t("lifecycle.title")}
+
+ {activeLifecycleStages.map((stage) => {
+ const active = currentLifecycleId === stage.id
+ const count = counts[stage.id] ?? 0
+ const stageHasUnread = (unreadStage[stage.id] ?? 0) > 0
+ const stageShowDot = stageHasUnread && !active
+ return (
+ applyLifecycleFilter(active ? null : stage.id)}
+ type="button"
+ >
+
+ {stage.icon ?? "•"}
+
+
+ {stage.name}
+
+ {renderCountBadge(count, stageShowDot, t)}
+
+ )
+ })}
+
+ )}
+
+
+ {/* RODAPÉ FIXO — Item "Contatos bloqueados" (pixel-perfect Respond.io
+ 2026-05-25 — Pedro pediu). Click ativa filter tags: ["blocked"]
+ que muda o backend pra mostrar conversas com `blockedAt IS NOT
+ NULL` ao invés de filtrar fora. Toggle: clicar de novo volta
+ ao normal. */}
+
+
+ {/* Ícone `BanIcon` (sinal proibido — círculo com diagonal) =
+ equivalente Lucide do `mdi-cancel` que o Respond.io usa
+ (inspeção via Chrome MCP 2026-05-25). */}
+
+
+ {t("inboxFilters.blockedContacts")}
+
+ {blockedContactsCount > 0 && (
+
+ {formatCount(blockedContactsCount)}
+
+ )}
+
+
+
+ )
+}
diff --git a/apps/builder/src/components/lang-selector.tsx b/apps/builder/src/components/lang-selector.tsx
index 8edf97f8a..168832e43 100644
--- a/apps/builder/src/components/lang-selector.tsx
+++ b/apps/builder/src/components/lang-selector.tsx
@@ -33,6 +33,7 @@ export const LangSelector: React.FC = () => {
{t("fields.language.english")}
{t("fields.language.vietnamese")}
+ {t("fields.language.portuguese")}
)
diff --git a/apps/builder/src/components/nav-main.tsx b/apps/builder/src/components/nav-main.tsx
index a83ed6acf..50bb77581 100644
--- a/apps/builder/src/components/nav-main.tsx
+++ b/apps/builder/src/components/nav-main.tsx
@@ -6,9 +6,16 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@chatbotx.io/ui/components/ui/sidebar"
-import type { LucideIcon } from "lucide-react"
import Link from "next/link"
import { usePathname } from "next/navigation"
+import type { ComponentType } from "react"
+
+/**
+ * Componente genérico de ícone — aceita qualquer componente que recebe
+ * `className`. Compatível com Lucide (`LucideIcon`) e com wrappers de
+ * outras libs (ex.: Iconsax via wrapIconsax no app-sidebar).
+ */
+type NavIcon = ComponentType<{ className?: string }>
export function NavMain({
items,
@@ -16,7 +23,7 @@ export function NavMain({
items: {
title: string
url: string
- icon?: LucideIcon
+ icon?: NavIcon
isActive?: boolean
items?: {
title: string
diff --git a/apps/builder/src/components/nav-user.tsx b/apps/builder/src/components/nav-user.tsx
index bae18a984..77d525e0a 100644
--- a/apps/builder/src/components/nav-user.tsx
+++ b/apps/builder/src/components/nav-user.tsx
@@ -20,11 +20,31 @@ import {
SidebarMenuItem,
useSidebar,
} from "@chatbotx.io/ui/components/ui/sidebar"
-import { BadgeCheck, Bell, ChevronsUpDown, Crown } from "lucide-react"
+import {
+ ArrowSwapVertical,
+ Buildings2,
+ Crown,
+ ShieldTick,
+} from "iconsax-reactjs"
+import { type IconsaxComponent, wrapIconsax } from "@/components/iconsax-icon"
+
+const BadgeCheck = wrapIconsax(ShieldTick as IconsaxComponent)
+const ChevronsUpDown = wrapIconsax(ArrowSwapVertical as IconsaxComponent)
+const CrownIcon = wrapIconsax(Crown as IconsaxComponent)
+const OrgIcon = wrapIconsax(Buildings2 as IconsaxComponent)
+
+import Link from "next/link"
+import { useParams } from "next/navigation"
import { useTranslations } from "next-intl"
+import { toast } from "sonner"
+import {
+ ACTIVITY_STATUSES,
+ type ActivityStatus,
+ ActivityStatusDot,
+ isActivityStatus,
+} from "@/features/account/activity-status"
import { SignOut } from "@/features/auth/sign-out"
-import { LangSelector } from "./lang-selector"
-import { ThemeSwitcher } from "./theme-switcher"
+import { authClient } from "@/lib/auth/auth-client"
export function NavUser({
user,
@@ -37,6 +57,31 @@ export function NavUser({
}) {
const { isMobile } = useSidebar()
const t = useTranslations()
+ const tStatus = useTranslations("personalSettings.activityStatus")
+ const params = useParams()
+ const workspaceId = params.workspaceId as string
+
+ // Status real do user (session). Default "available" se ainda não setado.
+ const { data: session } = authClient.useSession()
+ const rawStatus = (session?.user as unknown as { activityStatus?: unknown })
+ ?.activityStatus
+ const currentStatus: ActivityStatus = isActivityStatus(rawStatus)
+ ? rawStatus
+ : "available"
+
+ const handleStatusChange = async (next: ActivityStatus) => {
+ if (next === currentStatus) {
+ return
+ }
+ const { error } = await authClient.updateUser({
+ activityStatus: next,
+ } as unknown as Parameters[0])
+ if (error) {
+ toast.error(error.message ?? tStatus("updateError"))
+ return
+ }
+ toast.success(tStatus("updateSuccess", { status: tStatus(next) }))
+ }
return (
@@ -44,15 +89,21 @@ export function NavUser({
-
-
-
- {user.name.slice(0, 2) || " "}
-
-
+
+
+
+
+ {user.name.slice(0, 2) || " "}
+
+
+
+
{user.name}
@@ -70,12 +121,18 @@ export function NavUser({
>
-
-
-
- {user.name.slice(0, 2) || " "}
-
-
+
+
+
+
+ {user.name.slice(0, 2) || " "}
+
+
+
+
{user.name}
@@ -85,35 +142,46 @@ export function NavUser({
+
+ {tStatus("sectionLabel")}
+
-
-
- {t("actions.upgradeToPro")}
-
-
-
-
-
- Language
-
-
+ {ACTIVITY_STATUSES.map((s) => (
+ handleStatusChange(s)}
+ >
+
+ {tStatus(s)}
+ {s === currentStatus && (
+
+ •
+
+ )}
+
+ ))}
- Theme
-
+
+ {t("actions.upgradeToPro")}
-
-
- Account
+
+
+
+ {t("personalSettings.account")}
+
-
-
- Notifications
+
+
+
+ {t("organization.manage")}
+
diff --git a/apps/builder/src/components/node-header.tsx b/apps/builder/src/components/node-header.tsx
index 564976f4c..f679d847c 100644
--- a/apps/builder/src/components/node-header.tsx
+++ b/apps/builder/src/components/node-header.tsx
@@ -197,7 +197,7 @@ export const NodeHeaderDeleteAction = () => {
}, [id, setNodes])
return (
-
+
)
diff --git a/apps/builder/src/components/page-header.tsx b/apps/builder/src/components/page-header.tsx
new file mode 100644
index 000000000..5f1ae96b3
--- /dev/null
+++ b/apps/builder/src/components/page-header.tsx
@@ -0,0 +1,100 @@
+"use client"
+
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@chatbotx.io/ui/components/ui/tooltip"
+import { cn } from "@chatbotx.io/ui/lib/utils"
+import { ArrowLeft2 } from "iconsax-reactjs"
+import Link from "next/link"
+import type { ReactNode } from "react"
+import { type IconsaxComponent, wrapIconsax } from "@/components/iconsax-icon"
+
+const ArrowLeftIcon = wrapIconsax(ArrowLeft2 as IconsaxComponent)
+
+/**
+ * PageHeader — componente padronizado de header de página.
+ *
+ * Altura fixa **52px** (`h-[52px]`) — bate com o padrão do Respond.io
+ * (medido via Chrome MCP: Dashboard/Workflows/Settings = 53px). Pedro
+ * pediu padronização: "no Respond.io toda aba tem essa parte na horizontal
+ * do mesmo tamanho".
+ *
+ * Estrutura:
+ * - Botão de voltar opcional (se `backHref` for passado)
+ * - Título + subtítulo opcional + ações inline opcionais (esquerda)
+ * - Slot `actions` no extremo direito
+ *
+ * Cada página interna deve usar este componente em vez de header próprio
+ * pra manter o "linha imaginária" consistente entre abas.
+ */
+export type PageHeaderProps = {
+ /** Texto principal mostrado em destaque. */
+ title: ReactNode
+ /** Subtítulo opcional ao lado do título (cor mutada). */
+ subtitle?: ReactNode
+ /** Se passado, renderiza botão "voltar" no canto esquerdo. */
+ backHref?: string
+ /** Aria-label do botão voltar (default: "Voltar"). */
+ backLabel?: string
+ /** Conteúdo extra entre o título e o slot de ações (ex.: pencil). */
+ titleAdornment?: ReactNode
+ /** Botões/ações no extremo direito. */
+ actions?: ReactNode
+ /** Classes extras pra customizar (geralmente desnecessário). */
+ className?: string
+}
+
+export function PageHeader({
+ title,
+ subtitle,
+ backHref,
+ backLabel = "Voltar",
+ titleAdornment,
+ actions,
+ className,
+}: PageHeaderProps) {
+ return (
+
+ )
+}
diff --git a/apps/builder/src/components/respond-icon.tsx b/apps/builder/src/components/respond-icon.tsx
new file mode 100644
index 000000000..c9e8dd922
--- /dev/null
+++ b/apps/builder/src/components/respond-icon.tsx
@@ -0,0 +1,120 @@
+import { cn } from "@chatbotx.io/ui/lib/utils"
+import type { HTMLAttributes } from "react"
+
+// Nomes de ícones disponíveis (mapeados em /src/app/respond-icons.css). Se
+// precisar de outro, adicione no CSS + aqui. Lista completa de 848 está em
+// _Projetos/respond-io-avatars/preview.html → consultar pra novos.
+// 2026-05-24 — Pedro pediu "ícones idênticos ao Respond.io".
+export type RespondIconName =
+ | "tick-circle"
+ | "tick-square"
+ | "tick-square-bold"
+ | "tick"
+ | "send"
+ | "search-normal"
+ | "more"
+ | "message-text"
+ | "message-question"
+ | "message-2"
+ | "emoji-happy"
+ | "close-circle"
+ | "attach-square"
+ | "arrow-down"
+ | "arrow-down-1"
+ | "arrow-up"
+ | "arrow-up-1"
+ | "arrow-left"
+ | "arrow-right"
+ | "ai-assist"
+ | "ai-agent"
+ | "add"
+ | "workflow"
+ | "user"
+ | "user-add"
+ | "flash"
+ | "sticker"
+ | "document"
+ | "star"
+ | "star-bold"
+ | "volume-high"
+ | "eye"
+ | "eye-bold"
+ | "edit"
+ | "edit-bold"
+ | "trash"
+ | "trash-bold"
+ | "forbidden"
+ | "forbidden-bold"
+ | "reply"
+ | "copy"
+ | "link-1"
+ | "message-delivered"
+ | "magicpen-bold"
+ | "emoji-normal"
+ | "i-microphone"
+ | "receipt"
+ | "message-programming"
+ | "send-bold"
+ | "comments-ai"
+ | "ai-assist-bold"
+ | "channels"
+ | "at"
+ | "close-square"
+ | "custom-user-square-bold"
+ | "translate"
+ | "code"
+ | "clock"
+ | "clock-bold"
+ | "sms"
+ | "sms-bold"
+ | "phone"
+ | "call-calling"
+ | "home"
+ | "global"
+ | "profile"
+ | "profile-2user"
+ | "note"
+ | "notification"
+ | "notification-bing"
+ | "tag"
+ | "tag-bold"
+ | "filter"
+ | "filter-bold"
+ | "sort"
+ | "verify-bold"
+ | "received"
+ | "whatsapp-bold"
+ | "instagram"
+ | "facebook"
+ | "email"
+ | "menu"
+ | "sidebar-left"
+ | "sidebar-right"
+ | "refresh"
+ | "refresh-circle"
+ | "archive"
+ | "minus"
+ | "play"
+ | "pause"
+
+type RespondIconProps = HTMLAttributes & {
+ name: RespondIconName
+ size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl"
+}
+
+// Wrapper pro iconfont Respond.io. Renderiza um com a classe correta.
+// Sizes mapeados pro CSS: xs=12, sm=14, md=16, lg=18, xl=20, 2xl=24.
+export function RespondIcon({
+ name,
+ size = "lg",
+ className,
+ ...rest
+}: RespondIconProps) {
+ return (
+
+ )
+}
diff --git a/apps/builder/src/components/respond-icons.tsx b/apps/builder/src/components/respond-icons.tsx
new file mode 100644
index 000000000..fd8b96e32
--- /dev/null
+++ b/apps/builder/src/components/respond-icons.tsx
@@ -0,0 +1,61 @@
+/**
+ * Ícones SVG **idênticos ao Respond.io** — extraídos da fonte iconfont.ttf
+ * deles e convertidos pra SVG inline em `~/_Projetos/zenbot/icons/`.
+ *
+ * Usados em `app-sidebar.tsx` em vez dos equivalentes Iconsax (Hierarchy3,
+ * CpuCharge) que eram aproximações visuais. Agora é pixel-perfect.
+ *
+ * O viewBox preserva o sistema de coordenadas original do font (y invertido,
+ * por isso o `transform="scale(1 -1)"` aplicado no interno).
+ *
+ * Os SVGs usam `fill="currentColor"`, então herdam a cor de texto via CSS
+ * (`text-foreground`, `text-sidebar-foreground`, etc) — funciona em light/dark.
+ */
+
+import type { SVGProps } from "react"
+
+type IconProps = SVGProps & {
+ className?: string
+}
+
+/**
+ * Ícone "Fluxos de Trabalho" do Respond.io — 3 quadrados conectados em
+ * pirâmide (1 topo + 2 base), formato workflow/automation visual padrão.
+ */
+export function RespondFlowIcon({ className, ...props }: IconProps) {
+ return (
+
+
+
+
+
+ )
+}
+
+/**
+ * Ícone "AI Agent" do Respond.io — robô estilizado com olhos + antena
+ * (sparkle) no topo, formato chat-bot mascote padrão da plataforma.
+ */
+export function RespondAIAgentIcon({ className, ...props }: IconProps) {
+ return (
+
+
+
+
+
+ )
+}
diff --git a/apps/builder/src/components/settings-area-sidebar.tsx b/apps/builder/src/components/settings-area-sidebar.tsx
new file mode 100644
index 000000000..dbae275df
--- /dev/null
+++ b/apps/builder/src/components/settings-area-sidebar.tsx
@@ -0,0 +1,221 @@
+"use client"
+
+import { cn } from "@chatbotx.io/ui/lib/utils"
+import {
+ Box1,
+ Convert,
+ Cpu,
+ DocumentText,
+ MessageText1,
+ Note,
+ People,
+ Refresh2,
+ SecurityUser,
+ Setting4,
+ ShieldTick,
+ Sms,
+ Tag2,
+ Warning2,
+} from "iconsax-reactjs"
+import Link from "next/link"
+import { useParams, usePathname } from "next/navigation"
+import { useTranslations } from "next-intl"
+import type { ComponentType } from "react"
+import { type IconsaxComponent, wrapIconsax } from "@/components/iconsax-icon"
+
+type SettingsItem = {
+ label: string
+ href: string
+ icon: ComponentType<{ className?: string }>
+}
+
+type SettingsSection = {
+ title: string
+ items: SettingsItem[]
+}
+
+// Rotas onde o sub-sidebar de Configurações deve aparecer.
+// Todas as rotas de "Dados Mestres" agora vivem dentro de /settings/* (Camada 2).
+const SETTINGS_AREA_PREFIXES = [
+ "/settings",
+ "/triggers",
+ "/webhooks",
+ "/tools",
+ "/error-logs",
+] as const
+
+export function SettingsAreaSidebar() {
+ const t = useTranslations()
+ const pathname = usePathname()
+ const params = useParams()
+ const workspaceId = params.workspaceId as string
+
+ if (!workspaceId) {
+ return null
+ }
+
+ // Só renderiza se o pathname estiver dentro da área de Configurações
+ const inSettingsArea = SETTINGS_AREA_PREFIXES.some((prefix) =>
+ pathname.startsWith(`/space/${workspaceId}${prefix}`),
+ )
+ if (!inSettingsArea) {
+ return null
+ }
+
+ const base = `/space/${workspaceId}`
+
+ const sections: SettingsSection[] = [
+ {
+ title: t("settingsGroups.general"),
+ items: [
+ {
+ label: t("general.title"),
+ href: `${base}/settings/general`,
+ icon: wrapIconsax(DocumentText as IconsaxComponent),
+ },
+ ],
+ },
+ {
+ title: t("settingsGroups.workspace"),
+ items: [
+ {
+ label: t("admins.title"),
+ href: `${base}/settings/admins`,
+ icon: wrapIconsax(SecurityUser as IconsaxComponent),
+ },
+ {
+ label: t("inboxTeams.title"),
+ href: `${base}/settings/inbox-teams`,
+ icon: wrapIconsax(People as IconsaxComponent),
+ },
+ ],
+ },
+ {
+ title: t("settingsGroups.apps"),
+ items: [
+ {
+ label: t("channels.title"),
+ href: `${base}/settings/channels`,
+ icon: wrapIconsax(Sms as IconsaxComponent),
+ },
+ {
+ label: t("integrations.title"),
+ href: `${base}/settings/integrations`,
+ icon: wrapIconsax(Box1 as IconsaxComponent),
+ },
+ ],
+ },
+ {
+ title: t("settingsGroups.inbox"),
+ items: [
+ {
+ label: t("lifecycle.title"),
+ href: `${base}/settings/lifecycle`,
+ icon: wrapIconsax(Refresh2 as IconsaxComponent),
+ },
+ {
+ label: t("snippets.title"),
+ href: `${base}/settings/snippets`,
+ icon: wrapIconsax(MessageText1 as IconsaxComponent),
+ },
+ {
+ label: t("closingNotes.title"),
+ href: `${base}/settings/closing-notes`,
+ icon: wrapIconsax(Note as IconsaxComponent),
+ },
+ ],
+ },
+ {
+ // Nova seção "Dados" — antes essas 3 viviam como tabs na aba Fluxos
+ // (Tags, Campos Personalizados, Logs de Erro). Pedro pediu pra mover
+ // pro sub-sidebar de Configurações pra deixar a aba Fluxos focada
+ // só em fluxos.
+ title: t("settingsGroups.data"),
+ items: [
+ {
+ label: t("tags.title"),
+ href: `${base}/settings/tags`,
+ icon: wrapIconsax(Tag2 as IconsaxComponent),
+ },
+ {
+ label: t("customFields.title"),
+ href: `${base}/settings/contact-fields`,
+ icon: wrapIconsax(Note as IconsaxComponent),
+ },
+ {
+ label: t("botFields.title"),
+ href: `${base}/settings/bot-fields`,
+ icon: wrapIconsax(Cpu as IconsaxComponent),
+ },
+ {
+ label: t("errorLogs.title"),
+ href: `${base}/error-logs`,
+ icon: wrapIconsax(Warning2 as IconsaxComponent),
+ },
+ ],
+ },
+ {
+ title: t("settingsGroups.advanced"),
+ items: [
+ // "Gatilhos" foi removido do menu — agora os gatilhos são o primeiro
+ // node de cada flow (TriggerNode no Flow Builder, igual Respond.io).
+ // A rota /triggers continua existindo pra não quebrar links antigos,
+ // mas sem ponto de entrada visual.
+ {
+ label: t("webhooks.title"),
+ href: `${base}/webhooks`,
+ icon: wrapIconsax(Convert as IconsaxComponent),
+ },
+ {
+ label: t("auditLogs.title"),
+ href: `${base}/settings/audit-logs`,
+ icon: wrapIconsax(ShieldTick as IconsaxComponent),
+ },
+ {
+ label: t("tools.title"),
+ href: `${base}/tools`,
+ icon: wrapIconsax(Setting4 as IconsaxComponent),
+ },
+ ],
+ },
+ ]
+
+ return (
+
+ {/* Header — pt-3 alinha com o AP do sidebar global (que usa pt-2). */}
+
+
+ {t("settings.title")}
+
+
+
+ {sections.map((section) => (
+
+
+ {section.title}
+
+ {section.items.map((item) => {
+ const isActive = pathname.startsWith(item.href)
+ const Icon = item.icon
+ return (
+
+
+ {item.label}
+
+ )
+ })}
+
+ ))}
+
+
+ )
+}
diff --git a/apps/builder/src/components/topbar/topbar-slot-context.tsx b/apps/builder/src/components/topbar/topbar-slot-context.tsx
new file mode 100644
index 000000000..d5b1ac12d
--- /dev/null
+++ b/apps/builder/src/components/topbar/topbar-slot-context.tsx
@@ -0,0 +1,136 @@
+"use client"
+
+import {
+ createContext,
+ type ReactNode,
+ useCallback,
+ useContext,
+ useEffect,
+ useId,
+ useMemo,
+ useState,
+} from "react"
+
+/**
+ * Sistema de slots pro topbar global (`WorkspaceTopbar`).
+ *
+ * Cada página pode injetar conteúdo dinâmico em 3 zonas do topbar:
+ * - `pageTitle`: lado esquerdo, depois do brand. Ex.: "Fluxos", "Caixa de
+ * entrada", "< nome do flow + pencil"
+ * - `pageActions`: lado direito, antes do user menu. Ex.: "+ Adicionar
+ * workflow", "Filtros", "Publicar / Teste"
+ *
+ * Como funciona:
+ * - O layout pai (`space/[workspaceId]/layout.tsx`) renderiza
+ * `` no topo
+ * - O `WorkspaceTopbar` lê do Context e renderiza as zonas
+ * - Cada página chama `useTopbarSlot({ pageTitle, pageActions })` pra
+ * registrar seu conteúdo — automaticamente removido ao desmontar
+ *
+ * Suporte a múltiplos consumers: cada consumer tem um ID interno. O slot
+ * exibido é do consumer MAIS RECENTE (LIFO) — útil quando há nested layouts.
+ */
+
+type SlotKey = "pageTitle" | "pageActions"
+type Slot = { id: string; node: ReactNode }
+type SlotStacks = Record
+
+const initialStacks: SlotStacks = {
+ pageTitle: [],
+ pageActions: [],
+}
+
+type TopbarContextValue = {
+ stacks: SlotStacks
+ push: (id: string, slots: Partial>) => void
+ remove: (id: string) => void
+}
+
+const TopbarContext = createContext(null)
+
+export function TopbarSlotProvider({ children }: { children: ReactNode }) {
+ const [stacks, setStacks] = useState(initialStacks)
+
+ const push = useCallback(
+ (id: string, slots: Partial>) => {
+ setStacks((prev) => {
+ const next: SlotStacks = {
+ pageTitle: prev.pageTitle.filter((s) => s.id !== id),
+ pageActions: prev.pageActions.filter((s) => s.id !== id),
+ }
+ if (slots.pageTitle !== undefined) {
+ next.pageTitle.push({ id, node: slots.pageTitle })
+ }
+ if (slots.pageActions !== undefined) {
+ next.pageActions.push({ id, node: slots.pageActions })
+ }
+ return next
+ })
+ },
+ [],
+ )
+
+ const remove = useCallback((id: string) => {
+ setStacks((prev) => ({
+ pageTitle: prev.pageTitle.filter((s) => s.id !== id),
+ pageActions: prev.pageActions.filter((s) => s.id !== id),
+ }))
+ }, [])
+
+ const value = useMemo(
+ () => ({ stacks, push, remove }),
+ [stacks, push, remove],
+ )
+
+ return (
+ {children}
+ )
+}
+
+/**
+ * Hook pra páginas injetarem conteúdo no topbar. Conteúdo registrado é
+ * removido automaticamente quando o componente desmontar.
+ *
+ * Importante: passe `nodes` que são estáveis entre renders (use `useMemo`
+ * ou componentes inteiros, evite criar arrays/objetos inline). Caso
+ * contrário, o useEffect re-registra a cada render — overhead pequeno mas
+ * desnecessário.
+ */
+export function useTopbarSlot(slots: Partial>) {
+ const ctx = useContext(TopbarContext)
+ const id = useId()
+
+ // Memoizar slots estabilizando a referência (chaves ordenadas)
+ const stableSlots = useMemo(
+ () => ({
+ pageTitle: slots.pageTitle,
+ pageActions: slots.pageActions,
+ }),
+ [slots.pageTitle, slots.pageActions],
+ )
+
+ useEffect(() => {
+ if (!ctx) {
+ return
+ }
+ ctx.push(id, stableSlots)
+ return () => {
+ ctx.remove(id)
+ }
+ }, [ctx, id, stableSlots])
+}
+
+/**
+ * Hook usado pelo `` pra ler os slots atuais. Retorna o
+ * conteúdo do topo de cada stack (LIFO) — o consumer mais recente vence.
+ */
+export function useTopbarStacks() {
+ const ctx = useContext(TopbarContext)
+ if (!ctx) {
+ return { pageTitle: null, pageActions: null }
+ }
+ return {
+ pageTitle: ctx.stacks.pageTitle.at(-1)?.node ?? null,
+ pageActions: ctx.stacks.pageActions.at(-1)?.node ?? null,
+ }
+}
diff --git a/apps/builder/src/components/topbar/topbar-user-menu.tsx b/apps/builder/src/components/topbar/topbar-user-menu.tsx
new file mode 100644
index 000000000..90b83c7a0
--- /dev/null
+++ b/apps/builder/src/components/topbar/topbar-user-menu.tsx
@@ -0,0 +1,117 @@
+"use client"
+
+import {
+ Avatar,
+ AvatarFallback,
+ AvatarImage,
+} from "@chatbotx.io/ui/components/ui/avatar"
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@chatbotx.io/ui/components/ui/dropdown-menu"
+import { BadgeCheck, Bell, Crown } from "lucide-react"
+import Link from "next/link"
+import { useParams } from "next/navigation"
+import { useTranslations } from "next-intl"
+import { LangSelector } from "@/components/lang-selector"
+import { ThemeSwitcher } from "@/components/theme-switcher"
+import { SignOut } from "@/features/auth/sign-out"
+
+/**
+ * Versão do NavUser pro ``. Diferente do original:
+ * - Não usa SidebarMenu* (que requer SidebarProvider context)
+ * - Trigger é um Button simples (avatar + dropdown menu)
+ * - Posicionamento via `DropdownMenuContent align="end"` no canto direito
+ */
+export function TopbarUserMenu({
+ user,
+}: {
+ user: { name: string; email: string; avatar: string }
+}) {
+ const t = useTranslations()
+ const params = useParams()
+ const workspaceId = params.workspaceId as string
+
+ return (
+
+
+
+
+
+
+ {user.name.slice(0, 2).toUpperCase() || "··"}
+
+
+
+
+
+
+
+
+
+
+ {user.name.slice(0, 2).toUpperCase() || " "}
+
+
+
+ {user.name}
+
+ {user.email}
+
+
+
+
+
+
+
+
+ {t("actions.upgradeToPro")}
+
+
+
+
+
+ Idioma
+
+
+
+
+
+
+ Tema
+
+
+
+
+
+
+
+
+ Conta
+
+
+
+
+
+ Notificações
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/builder/src/components/topbar/topbar-workspace-switcher.tsx b/apps/builder/src/components/topbar/topbar-workspace-switcher.tsx
new file mode 100644
index 000000000..55bbf036f
--- /dev/null
+++ b/apps/builder/src/components/topbar/topbar-workspace-switcher.tsx
@@ -0,0 +1,112 @@
+"use client"
+
+import {
+ Avatar,
+ AvatarFallback,
+ AvatarImage,
+} from "@chatbotx.io/ui/components/ui/avatar"
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@chatbotx.io/ui/components/ui/dropdown-menu"
+import { cn } from "@chatbotx.io/ui/lib/utils"
+import { ChevronsUpDown, PlusCircle } from "lucide-react"
+import Link from "next/link"
+import { useTranslations } from "next-intl"
+import { useEffect, useState } from "react"
+import type { WorkspaceResource } from "@/features/workspaces/schema/resource"
+import { useWorkspaceId } from "@/hooks/routing"
+
+/**
+ * Versão do WorkspaceSwitcher pro ``. Diferente do original
+ * (que vive no sidebar): trigger é um Button compacto com avatar + nome,
+ * sem dependência de SidebarProvider.
+ */
+export function TopbarWorkspaceSwitcher({
+ workspaces,
+}: {
+ workspaces: WorkspaceResource[]
+}) {
+ const workspaceId = useWorkspaceId()
+ const t = useTranslations()
+
+ const [activeWorkspace, setActiveWorkspace] =
+ useState(null)
+
+ useEffect(() => {
+ const foundWorkspace = workspaces.find(
+ (workspace) => workspace.id === workspaceId,
+ )
+ setActiveWorkspace(foundWorkspace ?? null)
+ }, [workspaces, workspaceId])
+
+ return (
+
+
+
+
+
+
+ {activeWorkspace?.name?.slice(0, 2).toUpperCase() || "··"}
+
+
+
+ {activeWorkspace?.name}
+
+
+
+
+
+
+ {t("workspaces.list.title")}
+
+ {workspaces.map((workspace) => (
+ setActiveWorkspace(workspace)}
+ >
+
+
+
+
+ {workspace.name.slice(0, 2).toUpperCase() || "··"}
+
+
+ {workspace.name}
+
+
+ ))}
+
+
+
+
+ {t("actions.addFeature", {
+ feature: t("fields.workspace.label"),
+ })}
+
+
+
+
+ )
+}
diff --git a/apps/builder/src/components/topbar/workspace-topbar.tsx b/apps/builder/src/components/topbar/workspace-topbar.tsx
new file mode 100644
index 000000000..679ce076a
--- /dev/null
+++ b/apps/builder/src/components/topbar/workspace-topbar.tsx
@@ -0,0 +1,75 @@
+"use client"
+
+import Link from "next/link"
+import { BrandIcon } from "@/components/brand-icon"
+import type { WorkspaceResource } from "@/features/workspaces/schema/resource"
+import { authClient } from "@/lib/auth/auth-client"
+import { useTopbarStacks } from "./topbar-slot-context"
+import { TopbarUserMenu } from "./topbar-user-menu"
+import { TopbarWorkspaceSwitcher } from "./topbar-workspace-switcher"
+
+type WorkspaceTopbarProps = {
+ allWorkspaces: WorkspaceResource[]
+}
+
+/**
+ * Topbar global do workspace — altura padrão `h-14` (56px). Fica fixo no
+ * topo da tela em TODAS as páginas dentro de `/space/[workspaceId]/*`,
+ * estilo Respond.io.
+ *
+ * Layout:
+ * ┌──────────────────────────────────────────────────────────────────┐
+ * │ [Brand X] [WorkspaceSwitcher] | [pageTitle slot] [pageActions]│
+ * │ [NavUser] │
+ * └──────────────────────────────────────────────────────────────────┘
+ *
+ * - `pageTitle` / `pageActions` são slots dinâmicos: cada página registra
+ * seu próprio conteúdo via `useTopbarSlot()`. Quando a página desmonta,
+ * o slot é removido automaticamente.
+ *
+ * Pedro pediu padronização: "no Respond.io toda aba tem essa parte na
+ * horizontal, é padrão do mesmo tamanho". Esse componente garante.
+ */
+export function WorkspaceTopbar({ allWorkspaces }: WorkspaceTopbarProps) {
+ const { data: session } = authClient.useSession()
+ const { pageTitle, pageActions } = useTopbarStacks()
+
+ const user = {
+ name: session?.user.name ?? "",
+ email: session?.user.email ?? "",
+ avatar: session?.user.image ?? "",
+ }
+
+ return (
+
+ {/* Brand fixo no canto esquerdo */}
+
+
+
+
+ {/* Workspace switcher (dropdown pra trocar workspace) */}
+
+
+
+
+
+
+ {/* Slot dinâmico de título / nav contextual da página */}
+ {pageTitle}
+
+ {/* Slot dinâmico de ações da página (publicar, +adicionar, etc) */}
+ {pageActions && (
+ {pageActions}
+ )}
+
+ {/* User menu sempre fixo no extremo direito */}
+
+
+
+
+ )
+}
diff --git a/apps/builder/src/components/workspace-switcher.tsx b/apps/builder/src/components/workspace-switcher.tsx
index 882ad347b..21ac1ae29 100644
--- a/apps/builder/src/components/workspace-switcher.tsx
+++ b/apps/builder/src/components/workspace-switcher.tsx
@@ -5,40 +5,46 @@ import {
AvatarFallback,
AvatarImage,
} from "@chatbotx.io/ui/components/ui/avatar"
-
+import { Input } from "@chatbotx.io/ui/components/ui/input"
import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@chatbotx.io/ui/components/ui/dropdown-menu"
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@chatbotx.io/ui/components/ui/popover"
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
- useSidebar,
} from "@chatbotx.io/ui/components/ui/sidebar"
import { cn } from "@chatbotx.io/ui/lib/utils"
-import { ChevronsUpDown, PlusCircle } from "lucide-react"
+import { PlusIcon, SearchIcon, SettingsIcon } from "lucide-react"
import Link from "next/link"
import { useTranslations } from "next-intl"
-import { useEffect, useState } from "react"
+import { useEffect, useMemo, useState } from "react"
+import { CreateWorkspaceDialog } from "@/features/workspaces/create-workspace-dialog"
import type { WorkspaceResource } from "@/features/workspaces/schema/resource"
import { useWorkspaceId } from "@/hooks/routing"
+type OrganizationLite = {
+ id: string
+ name: string
+} | null
+
export function WorkspaceSwitcher({
workspaces,
+ organization,
}: {
workspaces: WorkspaceResource[]
+ organization?: OrganizationLite
}) {
- const { isMobile } = useSidebar()
const workspaceId = useWorkspaceId()
+ const t = useTranslations("workspaces")
const [activeWorkspace, setActiveWorkspace] =
useState(null)
- const t = useTranslations()
+ const [open, setOpen] = useState(false)
+ const [createOpen, setCreateOpen] = useState(false)
+ const [search, setSearch] = useState("")
useEffect(() => {
const foundWorkspace = workspaces.find(
@@ -47,82 +53,121 @@ export function WorkspaceSwitcher({
setActiveWorkspace(foundWorkspace ?? null)
}, [workspaces, workspaceId])
+ const filtered = useMemo(() => {
+ const q = search.trim().toLowerCase()
+ if (!q) {
+ return workspaces
+ }
+ return workspaces.filter((w) => w.name?.toLowerCase().includes(q))
+ }, [workspaces, search])
+
+ const initials = (activeWorkspace?.name || " ").slice(0, 2).toUpperCase()
+
return (
-
-
-
-
-
+
+
+
+
+
+
+
+
+ {initials}
+
+
+
+
+
-
-
+
+ setSearch(e.target.value)}
+ placeholder={t("searchPlaceholder")}
+ value={search}
/>
-
- {activeWorkspace?.name?.slice(0, 2) || " "}
-
-
-
-
- {activeWorkspace?.name}
-
- {/* {activeWorkspace?.plan} */}
-
-
-
-
-
- {t("workspaces.list.title")}
-
- {workspaces.map((workspace) => (
-
+
+ {organization.name}
+
+ setOpen(false)}
+ >
+
+
+
+ )}
+
+
+ {filtered.length === 0 ? (
+
+ Nenhum workspace encontrado.
+
+ ) : (
+ filtered.map((workspace) => (
+
setOpen(false)}
+ >
+
+
+
+ {(workspace.name || " ").slice(0, 2).toUpperCase()}
+
+
+
{workspace.name}
+
+ ))
)}
- key={workspace.name}
- onClick={() => setActiveWorkspace(workspace)}
- >
-
-
-
-
- {workspace.name.slice(0, 2) || " "}
-
-
- {workspace.name}
-
-
- ))}
-
-
-
-
- {t("actions.addFeature", {
- feature: t("fields.workspace.label"),
- })}
-
-
-
-
-
-
+
+
+
+
{
+ setOpen(false)
+ setCreateOpen(true)
+ }}
+ type="button"
+ >
+
+ {t("addWorkspace")}
+
+
+
+
+
+
+
+
+ >
)
}
diff --git a/apps/builder/src/enterprise/features/audit-logs/audit-logs-table-columns.tsx b/apps/builder/src/enterprise/features/audit-logs/audit-logs-table-columns.tsx
deleted file mode 100644
index 49ad62532..000000000
--- a/apps/builder/src/enterprise/features/audit-logs/audit-logs-table-columns.tsx
+++ /dev/null
@@ -1,94 +0,0 @@
-"use client"
-
-import { DataTableColumnHeader } from "@chatbotx.io/ui/components/data-table/data-table-column-header"
-import {
- Avatar,
- AvatarFallback,
- AvatarImage,
-} from "@chatbotx.io/ui/components/ui/avatar"
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@chatbotx.io/ui/components/ui/tooltip"
-import type { ColumnDef } from "@tanstack/react-table"
-import { format } from "date-fns"
-import type { AuditLogResource } from "./schemas"
-
-export function getAuditColumns(): ColumnDef[] {
- return [
- {
- accessorKey: "userId",
- header: ({ column }) => (
-
- ),
- cell: ({ row }) => (
-
- {row.original.user ? (
-
-
-
- {row.original.user.name?.[0]}
-
-
-
-
- {row.original.user.name}
-
-
-
- {row.original.user.name}
-
-
-
- ) : null}
-
- ),
- size: 150,
- enableSorting: false,
- enableHiding: false,
- },
- {
- accessorKey: "action",
- header: ({ column }) => (
-
- ),
- cell: ({ row }) => {row.original.action}
,
- size: 50,
- enableSorting: false,
- enableHiding: false,
- },
- {
- accessorKey: "detail",
- header: ({ column }) => (
-
- ),
- cell: ({ row }) => (
-
-
- {row.original.detail}
-
-
- {row.original.detail}
-
-
- ),
- size: 400,
- enableSorting: false,
- enableHiding: false,
- },
- {
- accessorKey: "createdAt",
- header: ({ column }) => (
-
- ),
- cell: ({ row }) => format(row.original.createdAt, "yyyy/MM/dd HH:mm"),
- size: 100,
- enableSorting: true,
- enableHiding: false,
- },
- ]
-}
diff --git a/apps/builder/src/enterprise/features/audit-logs/audit-logs-table.tsx b/apps/builder/src/enterprise/features/audit-logs/audit-logs-table.tsx
deleted file mode 100644
index 6a5509a29..000000000
--- a/apps/builder/src/enterprise/features/audit-logs/audit-logs-table.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-"use client"
-
-import { DataTable } from "@chatbotx.io/ui/components/data-table/data-table"
-import { useDataTable } from "@chatbotx.io/ui/hooks/use-data-table"
-import { useTranslations } from "next-intl"
-import { use, useMemo } from "react"
-import { getAuditColumns } from "./audit-logs-table-columns"
-import type { listAuditLogs } from "./queries"
-
-type AuditLogsTableProps = {
- workspaceId: string
- promises: Promise<[Awaited>]>
-}
-
-export function AuditLogsTable({ promises }: AuditLogsTableProps) {
- const t = useTranslations()
- const [{ data, pageCount }] = use(promises)
-
- const columns = useMemo(() => getAuditColumns(), [])
-
- const { table } = useDataTable({
- data,
- columns,
- pageCount,
- initialState: {
- sorting: [{ id: "createdAt", desc: true }],
- columnPinning: { right: ["actions"] },
- },
- getRowId: (originalRow) => originalRow.id,
- shallow: false,
- clearOnDefault: true,
- })
-
- return (
-
-
{t("auditLogs.title")}
-
-
-
- )
-}
diff --git a/apps/builder/src/enterprise/features/audit-logs/queries/index.ts b/apps/builder/src/enterprise/features/audit-logs/queries/index.ts
deleted file mode 100644
index f86dfd30d..000000000
--- a/apps/builder/src/enterprise/features/audit-logs/queries/index.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { db, relationsFilterToSQL } from "@chatbotx.io/database/client"
-import { auditLogModel, errorLogModel } from "@chatbotx.io/database/schema"
-import {
- getPaginationWithDefaults,
- parseOrderByAsObject,
-} from "@chatbotx.io/database/utils"
-import type { PaginatedResponse } from "@/features/common/schemas/pagination"
-import { assertCurrentUserCanAccessChatbot } from "@/lib/auth/utils"
-import type { AuditLogResource } from "../schemas"
-import type { ListAuditLogsRequest } from "../schemas/query"
-
-export async function listAuditLogs(
- input: ListAuditLogsRequest,
-): Promise> {
- await assertCurrentUserCanAccessChatbot(input.workspaceId)
-
- const where = {
- workspaceId: input.workspaceId,
- // userId: input.userId !== null ? input.userId : undefined,
- }
-
- const pagination = getPaginationWithDefaults(input)
- const orderBy = parseOrderByAsObject(errorLogModel, input)
-
- const [data, totalRows] = await Promise.all([
- db.query.auditLogModel.findMany({
- where,
- ...pagination,
- orderBy,
- with: {
- user: true,
- },
- }),
- db.$count(auditLogModel, relationsFilterToSQL(auditLogModel, where)),
- ])
-
- const pageCount = Math.ceil(totalRows / pagination.limit)
-
- return { data, pageCount }
-}
diff --git a/apps/builder/src/enterprise/features/inbox-teams/actions/add-inbox-team-member.action.ts b/apps/builder/src/enterprise/features/inbox-teams/actions/add-inbox-team-member.action.ts
index 0b2011884..795688452 100644
--- a/apps/builder/src/enterprise/features/inbox-teams/actions/add-inbox-team-member.action.ts
+++ b/apps/builder/src/enterprise/features/inbox-teams/actions/add-inbox-team-member.action.ts
@@ -1,5 +1,6 @@
"use server"
+import { auditLogActions, logAudit } from "@chatbotx.io/business"
import { db, findOrFail } from "@chatbotx.io/database/client"
import {
inboxTeamMemberModel,
@@ -8,68 +9,70 @@ import {
import { createId, zodBigintAsString } from "@chatbotx.io/utils"
import { revalidateCacheTags } from "@/lib/cache-helper"
import { workspaceActionClient } from "@/lib/safe-action"
-import {
- type AddInboxTeamMemberRequest,
- addInboxTeamMemberRequest,
-} from "../schema/action"
+import { addInboxTeamMemberRequest } from "../schema/action"
export const addInboxTeamMemberAction = workspaceActionClient
.bindArgsSchemas([zodBigintAsString(), zodBigintAsString()])
.inputSchema(addInboxTeamMemberRequest)
.action(async (props) => {
const {
- bindArgsClientInputs: [workspaceId, inboxTeamId],
+ bindArgsParsedInputs: [workspaceId, inboxTeamId],
+ ctx: { user },
parsedInput,
} = props
- return await addInboxTeamMember({ workspaceId, inboxTeamId }, parsedInput)
- })
-
-export const addInboxTeamMember = async (
- ctx: { workspaceId: string; inboxTeamId: string; userId?: string },
- parsedInput: AddInboxTeamMemberRequest,
-) => {
- const inboxTeam = await findOrFail({
- table: inboxTeamModel,
- where: {
- id: ctx.inboxTeamId,
- workspaceId: ctx.workspaceId,
- },
- message: "Inbox team not found",
- })
-
- await db.transaction(async (tx) => {
- const existingMembers = await tx.query.inboxTeamMemberModel.findMany({
+ const inboxTeam = await findOrFail({
+ table: inboxTeamModel,
where: {
- userId: {
- in: parsedInput.userIds,
- },
- inboxTeamId: inboxTeam.id,
- },
- columns: {
- userId: true,
+ id: inboxTeamId,
+ workspaceId,
},
+ message: "Inbox team not found",
})
- const existingUserIds = new Set(
- existingMembers.map((member) => member.userId),
- )
+ let newUserIds: string[] = []
+ await db.transaction(async (tx) => {
+ const existingMembers = await tx.query.inboxTeamMemberModel.findMany({
+ where: {
+ userId: { in: parsedInput.userIds },
+ inboxTeamId: inboxTeam.id,
+ },
+ columns: { userId: true },
+ })
+ const existingUserIds = new Set(existingMembers.map((m) => m.userId))
+ newUserIds = parsedInput.userIds.filter(
+ (uid) => !existingUserIds.has(uid),
+ )
- const newUserIds = parsedInput.userIds.filter(
- (userId) => !existingUserIds.has(userId),
- )
+ if (newUserIds.length > 0) {
+ await tx.insert(inboxTeamMemberModel).values(
+ newUserIds.map((userId) => ({
+ id: createId(),
+ userId,
+ inboxTeamId: inboxTeam.id,
+ })),
+ )
+ }
+ })
if (newUserIds.length > 0) {
- await tx.insert(inboxTeamMemberModel).values(
- newUserIds.map((userId) => ({
- id: createId(),
- userId,
- workspaceId: ctx.workspaceId,
- inboxTeamId: ctx.inboxTeamId,
- })),
- )
+ const addedUsers = await db.query.userModel.findMany({
+ where: { id: { in: newUserIds } },
+ columns: { name: true },
+ })
+ const names = addedUsers
+ .map((u) => u.name)
+ .filter(Boolean)
+ .join(", ")
+ await logAudit({
+ workspaceId,
+ userId: user.id,
+ action: auditLogActions.TEAM_MEMBER_ADDED,
+ detail: `${addedUsers.length} membro(s) adicionado(s) à equipe "${inboxTeam.name}"${
+ names ? `: ${names}` : ""
+ }`,
+ })
}
- })
- revalidateCacheTags(`workspaces:${ctx.workspaceId}#inboxTeams`)
-}
+ revalidateCacheTags(`workspaces:${workspaceId}#inboxTeams`)
+ })
diff --git a/apps/builder/src/enterprise/features/inbox-teams/actions/create-inbox-team.action.ts b/apps/builder/src/enterprise/features/inbox-teams/actions/create-inbox-team.action.ts
index 2caf1be5d..79b97e241 100644
--- a/apps/builder/src/enterprise/features/inbox-teams/actions/create-inbox-team.action.ts
+++ b/apps/builder/src/enterprise/features/inbox-teams/actions/create-inbox-team.action.ts
@@ -1,5 +1,6 @@
"use server"
+import { auditLogActions, logAudit } from "@chatbotx.io/business"
import { db } from "@chatbotx.io/database/client"
import {
inboxTeamMemberModel,
@@ -17,14 +18,17 @@ export const createInboxTeamAction = workspaceActionClient
.action(async (props) => {
const {
parsedInput,
+ ctx: { user },
bindArgsParsedInputs: [workspaceId],
} = props
+ const inboxTeamId = createId()
+
await db.transaction(async (tx) => {
- const inboxTeamId = createId()
await tx.insert(inboxTeamModel).values({
id: inboxTeamId,
name: parsedInput.name,
+ description: parsedInput.description ?? null,
workspaceId,
})
@@ -33,12 +37,18 @@ export const createInboxTeamAction = workspaceActionClient
parsedInput.userIds.map((userId) => ({
id: createId(),
userId,
- workspaceId,
inboxTeamId,
})),
)
}
})
+ await logAudit({
+ workspaceId,
+ userId: user.id,
+ action: auditLogActions.TEAM_CREATED,
+ detail: `Equipe "${parsedInput.name}" criada com ${parsedInput.userIds.length} membro(s)`,
+ })
+
revalidateCacheTags(`workspaces:${workspaceId}#inboxTeams`)
})
diff --git a/apps/builder/src/enterprise/features/inbox-teams/actions/delete-inbox-team-member.action.ts b/apps/builder/src/enterprise/features/inbox-teams/actions/delete-inbox-team-member.action.ts
index 5e35763cb..5d7f49817 100644
--- a/apps/builder/src/enterprise/features/inbox-teams/actions/delete-inbox-team-member.action.ts
+++ b/apps/builder/src/enterprise/features/inbox-teams/actions/delete-inbox-team-member.action.ts
@@ -1,15 +1,13 @@
"use server"
+import { auditLogActions, logAudit } from "@chatbotx.io/business"
import { and, db, eq, findOrFail, inArray } from "@chatbotx.io/database/client"
import {
inboxTeamMemberModel,
inboxTeamModel,
} from "@chatbotx.io/database/schema"
import { zodBigintAsString } from "@chatbotx.io/utils"
-import {
- type BulkUpdateIdsRequest,
- bulkUpdateIdsRequest,
-} from "@/features/common/schemas"
+import { bulkUpdateIdsRequest } from "@/features/common/schemas"
import { revalidateCacheTags } from "@/lib/cache-helper"
import { workspaceActionClient } from "@/lib/safe-action"
@@ -18,37 +16,49 @@ export const deleteTeamMembersAction = workspaceActionClient
.inputSchema(bulkUpdateIdsRequest)
.action(async (props) => {
const {
- bindArgsClientInputs: [workspaceId, inboxTeamId],
+ bindArgsParsedInputs: [workspaceId, inboxTeamId],
+ ctx: { user },
parsedInput,
} = props
- return await deleteInboxTeamMember(
- { workspaceId, inboxTeamId },
- parsedInput,
- )
- })
+ const inboxTeam = await findOrFail({
+ table: inboxTeamModel,
+ where: {
+ id: inboxTeamId,
+ workspaceId,
+ },
+ message: "Inbox team not found",
+ })
-export const deleteInboxTeamMember = async (
- ctx: { workspaceId: string; inboxTeamId: string },
- parsedInput: BulkUpdateIdsRequest,
-) => {
- const inboxTeam = await findOrFail({
- table: inboxTeamModel,
- where: {
- id: ctx.inboxTeamId,
- workspaceId: ctx.workspaceId,
- },
- message: "Inbox team not found",
- })
+ const removed = await db.query.inboxTeamMemberModel.findMany({
+ where: {
+ inboxTeamId: inboxTeam.id,
+ id: { in: parsedInput.ids },
+ },
+ with: { user: { columns: { name: true } } },
+ })
+
+ await db
+ .delete(inboxTeamMemberModel)
+ .where(
+ and(
+ eq(inboxTeamMemberModel.inboxTeamId, inboxTeam.id),
+ inArray(inboxTeamMemberModel.id, parsedInput.ids),
+ ),
+ )
- await db
- .delete(inboxTeamMemberModel)
- .where(
- and(
- eq(inboxTeamMemberModel.inboxTeamId, inboxTeam.id),
- inArray(inboxTeamMemberModel.id, parsedInput.ids),
- ),
- )
+ const names = removed
+ .map((m) => m.user?.name)
+ .filter(Boolean)
+ .join(", ")
+ await logAudit({
+ workspaceId,
+ userId: user.id,
+ action: auditLogActions.TEAM_MEMBER_REMOVED,
+ detail: `${removed.length} membro(s) removido(s) da equipe "${inboxTeam.name}"${
+ names ? `: ${names}` : ""
+ }`,
+ })
- revalidateCacheTags(`workspaces:${ctx.workspaceId}#inboxTeams`)
-}
+ revalidateCacheTags(`workspaces:${workspaceId}#inboxTeams`)
+ })
diff --git a/apps/builder/src/enterprise/features/inbox-teams/actions/delete-inbox-team.action.ts b/apps/builder/src/enterprise/features/inbox-teams/actions/delete-inbox-team.action.ts
index 119c5e07e..0a1307c30 100644
--- a/apps/builder/src/enterprise/features/inbox-teams/actions/delete-inbox-team.action.ts
+++ b/apps/builder/src/enterprise/features/inbox-teams/actions/delete-inbox-team.action.ts
@@ -1,5 +1,6 @@
"use server"
+import { auditLogActions, logAudit } from "@chatbotx.io/business"
import { and, db, eq, inArray } from "@chatbotx.io/database/client"
import { inboxTeamModel } from "@chatbotx.io/database/schema"
import {
@@ -15,9 +16,18 @@ export const deleteInboxTeamAction = workspaceActionClient
.action(async (props) => {
const {
parsedInput,
+ ctx: { user },
bindArgsParsedInputs: [workspaceId],
} = props
+ const teamsToDelete = await db.query.inboxTeamModel.findMany({
+ where: {
+ workspaceId,
+ id: { in: parsedInput.ids },
+ },
+ columns: { name: true },
+ })
+
await db
.delete(inboxTeamModel)
.where(
@@ -27,5 +37,13 @@ export const deleteInboxTeamAction = workspaceActionClient
),
)
+ const names = teamsToDelete.map((t) => `"${t.name}"`).join(", ")
+ await logAudit({
+ workspaceId,
+ userId: user.id,
+ action: auditLogActions.TEAM_DELETED,
+ detail: `Equipe(s) excluída(s): ${names || "(nenhuma)"}`,
+ })
+
revalidateCacheTags(`workspaces:${workspaceId}#inboxTeams`)
})
diff --git a/apps/builder/src/enterprise/features/inbox-teams/actions/update-inbox-team.action.ts b/apps/builder/src/enterprise/features/inbox-teams/actions/update-inbox-team.action.ts
index 65ed75926..cb2199faf 100644
--- a/apps/builder/src/enterprise/features/inbox-teams/actions/update-inbox-team.action.ts
+++ b/apps/builder/src/enterprise/features/inbox-teams/actions/update-inbox-team.action.ts
@@ -1,14 +1,15 @@
"use server"
-import { db, eq, findOrFail } from "@chatbotx.io/database/client"
-import { inboxTeamModel } from "@chatbotx.io/database/schema"
-import { zodBigintAsString } from "@chatbotx.io/utils"
+import { auditLogActions, logAudit } from "@chatbotx.io/business"
+import { and, db, eq, findOrFail, inArray } from "@chatbotx.io/database/client"
+import {
+ inboxTeamMemberModel,
+ inboxTeamModel,
+} from "@chatbotx.io/database/schema"
+import { createId, zodBigintAsString } from "@chatbotx.io/utils"
import { revalidateCacheTags } from "@/lib/cache-helper"
import { workspaceActionClient } from "@/lib/safe-action"
-import {
- type UpdateInboxTeamRequest,
- updateInboxTeamRequest,
-} from "../schema/action"
+import { updateInboxTeamRequest } from "../schema/action"
export const updateInboxTeamAction = workspaceActionClient
.bindArgsSchemas([zodBigintAsString(), zodBigintAsString()])
@@ -16,29 +17,91 @@ export const updateInboxTeamAction = workspaceActionClient
.action(async (props) => {
const {
bindArgsParsedInputs: [workspaceId, inboxTeamId],
+ ctx: { user },
parsedInput,
} = props
- return await updateInboxTeam({ workspaceId, inboxTeamId }, parsedInput)
- })
+ const inboxTeam = await findOrFail({
+ table: inboxTeamModel,
+ where: {
+ id: inboxTeamId,
+ workspaceId,
+ },
+ message: "Inbox team not found",
+ })
-export const updateInboxTeam = async (
- ctx: { workspaceId: string; inboxTeamId: string },
- parsedInput: UpdateInboxTeamRequest,
-) => {
- const inboxTeam = await findOrFail({
- table: inboxTeamModel,
- where: {
- id: ctx.inboxTeamId,
- workspaceId: ctx.workspaceId,
- },
- message: "Inbox team not found",
- })
+ await db.transaction(async (tx) => {
+ const patch: { name?: string; description?: string | null } = {}
+ if (parsedInput.name !== undefined) {
+ patch.name = parsedInput.name
+ }
+ if (parsedInput.description !== undefined) {
+ patch.description = parsedInput.description
+ }
+
+ if (Object.keys(patch).length > 0) {
+ await tx
+ .update(inboxTeamModel)
+ .set(patch)
+ .where(eq(inboxTeamModel.id, inboxTeam.id))
+ }
- await db
- .update(inboxTeamModel)
- .set(parsedInput)
- .where(eq(inboxTeamModel.id, inboxTeam.id))
+ if (parsedInput.userIds !== undefined) {
+ const existing = await tx.query.inboxTeamMemberModel.findMany({
+ where: { inboxTeamId: inboxTeam.id },
+ columns: { id: true, userId: true },
+ })
+ const desired = new Set(parsedInput.userIds)
+ const existingByUser = new Map(existing.map((m) => [m.userId, m.id]))
- revalidateCacheTags(`workspaces:${ctx.workspaceId}#inboxTeams`)
-}
+ const toRemoveIds = existing
+ .filter((m) => !desired.has(m.userId))
+ .map((m) => m.id)
+ const toAddUserIds = parsedInput.userIds.filter(
+ (uid) => !existingByUser.has(uid),
+ )
+
+ if (toRemoveIds.length > 0) {
+ await tx
+ .delete(inboxTeamMemberModel)
+ .where(
+ and(
+ eq(inboxTeamMemberModel.inboxTeamId, inboxTeam.id),
+ inArray(inboxTeamMemberModel.id, toRemoveIds),
+ ),
+ )
+ }
+ if (toAddUserIds.length > 0) {
+ await tx.insert(inboxTeamMemberModel).values(
+ toAddUserIds.map((uid) => ({
+ id: createId(),
+ userId: uid,
+ inboxTeamId: inboxTeam.id,
+ })),
+ )
+ }
+ }
+ })
+
+ const changes: string[] = []
+ if (parsedInput.name !== undefined && parsedInput.name !== inboxTeam.name) {
+ changes.push(`nome: "${inboxTeam.name}" → "${parsedInput.name}"`)
+ }
+ if (parsedInput.description !== undefined) {
+ changes.push("descrição")
+ }
+ if (parsedInput.userIds !== undefined) {
+ changes.push("membros")
+ }
+
+ await logAudit({
+ workspaceId,
+ userId: user.id,
+ action: auditLogActions.TEAM_UPDATED,
+ detail: `Equipe "${parsedInput.name ?? inboxTeam.name}" atualizada${
+ changes.length ? ` (${changes.join(", ")})` : ""
+ }`,
+ })
+
+ revalidateCacheTags(`workspaces:${workspaceId}#inboxTeams`)
+ })
diff --git a/apps/builder/src/enterprise/features/inbox-teams/add-inbox-team-member-dialog.tsx b/apps/builder/src/enterprise/features/inbox-teams/add-inbox-team-member-dialog.tsx
deleted file mode 100644
index 5922f46ff..000000000
--- a/apps/builder/src/enterprise/features/inbox-teams/add-inbox-team-member-dialog.tsx
+++ /dev/null
@@ -1,122 +0,0 @@
-"use client"
-
-import type { InboxTeamModel } from "@chatbotx.io/database/types"
-import { MultiSelectField } from "@chatbotx.io/ui/components/form/multi-select-field"
-import { Button } from "@chatbotx.io/ui/components/ui/button"
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from "@chatbotx.io/ui/components/ui/dialog"
-import { Form } from "@chatbotx.io/ui/components/ui/form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { useHookFormAction } from "@next-safe-action/adapter-react-hook-form/hooks"
-import { Loader2 } from "lucide-react"
-import { useRouter } from "next/navigation"
-import { useTranslations } from "next-intl"
-import { toast } from "sonner"
-import type { ListWorkspaceMembersResponse } from "@/features/workspace-members/schema/query"
-import { addInboxTeamMemberAction } from "./actions/add-inbox-team-member.action"
-import { addInboxTeamMemberRequest } from "./schema/action"
-
-export function AddInboxTeamMemberDialog({
- open,
- onOpenChange,
- workspaceId,
- inboxTeam,
- workspaceMembers,
-}: {
- open: boolean
- onOpenChange: (val: boolean) => void
- workspaceId: string
- inboxTeam: InboxTeamModel | null
- workspaceMembers: ListWorkspaceMembersResponse["data"]
-}) {
- const t = useTranslations()
- const router = useRouter()
-
- const { form, handleSubmitWithAction } = useHookFormAction(
- addInboxTeamMemberAction.bind(null, workspaceId, inboxTeam?.id ?? ""),
- zodResolver(addInboxTeamMemberRequest),
- {
- actionProps: {
- onSuccess: () => {
- toast.success(
- t("messages.createdSuccess", {
- feature: t("fields.inboxTeamMember.label"),
- }),
- )
- onOpenChange(false)
- router.refresh()
- },
- onError: ({ error }) => {
- if (error.serverError) {
- toast.error(error.serverError)
- }
- },
- },
- formProps: {
- mode: "onChange",
- defaultValues: {
- userIds: [],
- },
- },
- errorMapProps: {},
- },
- )
-
- const userOptions = workspaceMembers.map((cm) => ({
- value: cm.user.id,
- label: cm.user.name ?? "",
- }))
-
- return (
-
-
-
-
- {t("messages.addFeature", {
- feature: t("fields.inboxTeamMember.label"),
- })}
-
-
-
-
-
-
- )
-}
diff --git a/apps/builder/src/enterprise/features/inbox-teams/api/authenticated.ts b/apps/builder/src/enterprise/features/inbox-teams/api/authenticated.ts
index f773099c3..12592f57d 100644
--- a/apps/builder/src/enterprise/features/inbox-teams/api/authenticated.ts
+++ b/apps/builder/src/enterprise/features/inbox-teams/api/authenticated.ts
@@ -8,8 +8,8 @@ export const inboxTeamsAuthenticatedAPI = {
.route({
method: "GET",
path: "/workspaces/{workspaceId}/inbox-teams",
- summary: "List inbox teams",
- tags: ["Inbox Teams"],
+ summary: "Listar times da caixa de entrada",
+ tags: ["Times da Caixa de Entrada"],
})
.input(listInboxTeamsRequest)
.use(workspaceAuthorizedMidddleware, (input) => input.workspaceId)
diff --git a/apps/builder/src/enterprise/features/inbox-teams/api/workspace-token.ts b/apps/builder/src/enterprise/features/inbox-teams/api/workspace-token.ts
index d15b63d1c..2639c7886 100644
--- a/apps/builder/src/enterprise/features/inbox-teams/api/workspace-token.ts
+++ b/apps/builder/src/enterprise/features/inbox-teams/api/workspace-token.ts
@@ -7,8 +7,8 @@ export const inboxTeamsWorkspaceTokenAPIs = {
.route({
method: "GET",
path: "/v1/inbox-teams",
- summary: "List inbox teams",
- tags: ["Inbox Teams"],
+ summary: "Listar times da caixa de entrada",
+ tags: ["Times da Caixa de Entrada"],
})
.output(listInboxTeamsResponse)
.handler(
diff --git a/apps/builder/src/enterprise/features/inbox-teams/create-inbox-team-dialog.tsx b/apps/builder/src/enterprise/features/inbox-teams/create-inbox-team-dialog.tsx
index aa45c0991..225be62dc 100644
--- a/apps/builder/src/enterprise/features/inbox-teams/create-inbox-team-dialog.tsx
+++ b/apps/builder/src/enterprise/features/inbox-teams/create-inbox-team-dialog.tsx
@@ -2,6 +2,7 @@
import { InputField } from "@chatbotx.io/ui/components/form/input-field"
import { MultiSelectField } from "@chatbotx.io/ui/components/form/multi-select-field"
+import { TextareaField } from "@chatbotx.io/ui/components/form/textarea-field"
import { Button } from "@chatbotx.io/ui/components/ui/button"
import {
Dialog,
@@ -31,7 +32,8 @@ export function CreateInboxTeamDialog({
workspaceId: string
workspaceMembers: ListWorkspaceMembersResponse["data"]
}) {
- const t = useTranslations()
+ const t = useTranslations("inboxTeams")
+ const tActions = useTranslations("actions")
const router = useRouter()
const [open, setOpen] = useState(false)
@@ -43,12 +45,7 @@ export function CreateInboxTeamDialog({
{
actionProps: {
onSuccess: () => {
- toast.success(
- t("messages.createdSuccess", {
- feature: t("fields.inboxTeam.label"),
- }),
- )
-
+ toast.success(t("createDialog.title"))
setOpen(false)
resetFormAndAction()
router.refresh()
@@ -63,6 +60,7 @@ export function CreateInboxTeamDialog({
mode: "onChange",
defaultValues: {
name: "",
+ description: "",
userIds: [],
},
},
@@ -80,52 +78,55 @@ export function CreateInboxTeamDialog({
- {t("actions.createFeature", { feature: t("fields.inboxTeam.label") })}
+ {t("addTeam")}
-
+
-
- {t("messages.createFeature", {
- feature: t("fields.inboxTeam.label"),
- })}
-
-
+ {t("createDialog.title")}
+ {t("createDialog.description")}
-
+
+
)
diff --git a/apps/builder/src/enterprise/features/inbox-teams/delete-inbox-team-dialog.tsx b/apps/builder/src/enterprise/features/inbox-teams/delete-inbox-team-dialog.tsx
index def571ed0..d04a5a447 100644
--- a/apps/builder/src/enterprise/features/inbox-teams/delete-inbox-team-dialog.tsx
+++ b/apps/builder/src/enterprise/features/inbox-teams/delete-inbox-team-dialog.tsx
@@ -28,18 +28,15 @@ export function DeleteInboxTeamDialog({
workspaceId: string
inboxTeam: InboxTeamModel | null
}) {
- const t = useTranslations()
+ const t = useTranslations("inboxTeams")
+ const tActions = useTranslations("actions")
const router = useRouter()
const { execute, isPending } = useAction(
deleteInboxTeamAction.bind(null, workspaceId),
{
onSuccess: () => {
- toast.success(
- t("messages.deletedSuccess", {
- feature: t("fields.inboxTeam.label"),
- }),
- )
+ toast.success(t("deleteDialog.title"))
onOpenChange(false)
router.refresh()
},
@@ -53,17 +50,11 @@ export function DeleteInboxTeamDialog({
return (
-
+
-
- {t("messages.deleteFeature", {
- feature: t("fields.inboxTeam.label"),
- })}
-
+ {t("deleteDialog.title")}
- {t("messages.deleteConfirmation", {
- feature: t("fields.inboxTeam.label"),
- })}
+ {t("deleteDialog.description", { name: inboxTeam?.name ?? "" })}
@@ -74,16 +65,17 @@ export function DeleteInboxTeamDialog({
type="button"
variant="ghost"
>
- {t("actions.cancel")}
+ {tActions("cancel")}
execute({ ids: [inboxTeam?.id ?? ""] })}
size="sm"
- type="submit"
+ type="button"
+ variant="destructive"
>
{isPending && }
- {t("actions.delete")}
+ {t("deleteDialog.confirm")}
diff --git a/apps/builder/src/enterprise/features/inbox-teams/delete-inbox-team-member-dialog.tsx b/apps/builder/src/enterprise/features/inbox-teams/delete-inbox-team-member-dialog.tsx
deleted file mode 100644
index 0a5e52af0..000000000
--- a/apps/builder/src/enterprise/features/inbox-teams/delete-inbox-team-member-dialog.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-"use client"
-
-import { Button } from "@chatbotx.io/ui/components/ui/button"
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@chatbotx.io/ui/components/ui/dialog"
-import { Loader } from "lucide-react"
-import { useRouter } from "next/navigation"
-import { useTranslations } from "next-intl"
-import { useAction } from "next-safe-action/hooks"
-import type { ComponentPropsWithoutRef } from "react"
-import { toast } from "sonner"
-import type { InboxTeamMemberResource } from "../inbox-team-members/schema/resource"
-import { deleteTeamMembersAction } from "./actions/delete-inbox-team-member.action"
-
-type DeleteMembersDialogProps = ComponentPropsWithoutRef & {
- onSuccess?: () => void
- onOpenChange: (val: boolean) => void
- workspaceId: string
- teamMember: InboxTeamMemberResource | null
-}
-
-export function DeleteInboxTeamMembersDialog({
- workspaceId,
- onSuccess,
- onOpenChange,
- teamMember,
- ...props
-}: DeleteMembersDialogProps) {
- const t = useTranslations()
- const router = useRouter()
-
- const { execute, isPending } = useAction(
- deleteTeamMembersAction.bind(
- null,
- workspaceId,
- teamMember?.inboxTeamId ?? "",
- ),
- {
- onSuccess: () => {
- toast.success(
- t("messages.deletedSuccess", {
- feature: t("fields.inboxTeamMember.label"),
- }),
- )
- onOpenChange(false)
- router.refresh()
- },
- onError: ({ error }) => {
- if (error.serverError) {
- toast.error(error.serverError)
- }
- },
- },
- )
-
- return (
-
-
-
-
- {t("messages.deleteFeature", {
- feature: t("fields.inboxTeamMember.label"),
- })}
-
-
- {t("messages.deleteConfirmation", {
- feature: t("fields.inboxTeamMember.label"),
- })}
-
-
-
-
-
- onOpenChange(false)}
- size="sm"
- variant="ghost"
- >
- {t("actions.cancel")}
-
-
- execute({ ids: [teamMember?.id ?? ""] })}
- size="sm"
- variant="destructive"
- >
- {isPending && (
-
- )}
- {t("actions.delete")}
-
-
-
-
- )
-}
diff --git a/apps/builder/src/enterprise/features/inbox-teams/list-inbox-teams.tsx b/apps/builder/src/enterprise/features/inbox-teams/list-inbox-teams.tsx
index b1aaf9ace..70a7ffd03 100644
--- a/apps/builder/src/enterprise/features/inbox-teams/list-inbox-teams.tsx
+++ b/apps/builder/src/enterprise/features/inbox-teams/list-inbox-teams.tsx
@@ -1,47 +1,19 @@
"use client"
import { Button } from "@chatbotx.io/ui/components/ui/button"
-import {
- Card,
- CardContent,
- CardHeader,
- CardTitle,
-} from "@chatbotx.io/ui/components/ui/card"
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@chatbotx.io/ui/components/ui/dropdown-menu"
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@chatbotx.io/ui/components/ui/table"
-import {
- ChevronDownIcon,
- ChevronRightIcon,
- MoreHorizontalIcon,
- PencilIcon,
- PlusIcon,
- Trash2Icon,
-} from "lucide-react"
+import { Input } from "@chatbotx.io/ui/components/ui/input"
+import { SearchIcon, SettingsIcon, Trash2Icon, UsersIcon } from "lucide-react"
import { useTranslations } from "next-intl"
-import { use, useState } from "react"
+import { use, useMemo, useState } from "react"
import type { listWorkspaceMembers } from "@/features/workspace-members/queries"
import type { ListWorkspaceMembersResponse } from "@/features/workspace-members/schema/query"
-import type { InboxTeamMemberResource } from "../inbox-team-members/schema/resource"
-import { AddInboxTeamMemberDialog } from "./add-inbox-team-member-dialog"
import { CreateInboxTeamDialog } from "./create-inbox-team-dialog"
import { DeleteInboxTeamDialog } from "./delete-inbox-team-dialog"
-import { DeleteInboxTeamMembersDialog } from "./delete-inbox-team-member-dialog"
+import { ManageInboxTeamDialog } from "./manage-inbox-team-dialog"
import type { listInboxTeams } from "./queries"
-import { RenameInboxTeamDialog } from "./rename-inbox-team-dialog"
import type { ListInboxTeamsResponse } from "./schema/action"
-import type { InboxTeamResource } from "./schema/resource"
+
+type Team = ListInboxTeamsResponse["data"][number]
type ListInboxTeamsProps = {
workspaceId: string
@@ -53,220 +25,143 @@ type ListInboxTeamsProps = {
>
}
-function ListInboxTeamsDetail({
- workspaceId,
- inboxTeams,
- workspaceMembers,
+function TeamRow({
+ team,
+ onManage,
+ onDelete,
}: {
- workspaceId: string
- inboxTeams: ListInboxTeamsResponse["data"]
- workspaceMembers: ListWorkspaceMembersResponse["data"]
+ team: Team
+ onManage: (team: Team) => void
+ onDelete: (team: Team) => void
}) {
- const t = useTranslations()
- const [renameInboxTeam, setRenameInboxTeam] =
- useState(null)
- const [deleteInboxTeam, setDeleteInboxTeam] =
- useState(null)
- const [addInboxTeamMember, setAddInboxTeamMember] =
- useState(null)
- const [deleteInboxTeamMember, setDeleteInboxTeamMember] =
- useState(null)
- const [openTeams, setOpenTeams] = useState>({})
-
- const rows: Array<{
- showMembers: boolean
- team: ListInboxTeamsResponse["data"][number]
- }> = []
- for (const team of inboxTeams) {
- rows.push({ showMembers: true, team })
- if (openTeams[team.id]) {
- rows.push({ showMembers: false, team })
- }
- }
+ const t = useTranslations("inboxTeams")
+ const memberCount = team.inboxTeamMembers?.length ?? 0
+ const description =
+ (team as Team & { description?: string | null }).description ?? null
return (
- <>
-
-
-
-
-
- {t("fields.team.label")}
- {t("fields.teamMembers.label")}
-
-
-
-
- {rows.length ? (
- rows.map((row, _index) => {
- if (row.showMembers) {
- return (
-
-
-
- setOpenTeams((prev) => ({
- ...prev,
- [row.team.id]: !prev[row.team.id],
- }))
- }
- type="button"
- variant="ghost"
- >
- {openTeams[row.team.id] ? (
-
- ) : (
-
- )}
-
-
-
-
- setOpenTeams((prev) => ({
- ...prev,
- [row.team.id]: !prev[row.team.id],
- }))
- }
- type="button"
- variant="ghost"
- >
- {row.team.name}
-
-
-
- {row.team.inboxTeamMembers?.length || 0}
-
-
-
-
-
-
- Open menu
-
-
-
- setRenameInboxTeam(row.team)}
- >
-
- {t("actions.rename")}
-
- setAddInboxTeamMember(row.team)}
- >
-
- {t("actions.addFeature", {
- feature: t("fields.member.label"),
- })}
-
- setDeleteInboxTeam(row.team)}
- >
-
- {t("actions.delete")}
-
-
-
-
-
- )
- }
- return (
-
-
-
-
- {(row.team.inboxTeamMembers || []).map((member) => (
-
- {member.user?.name}
- setDeleteInboxTeamMember(member)}
- size="icon"
- variant="ghost"
- >
-
-
-
- ))}
-
-
-
- )
- })
- ) : (
-
-
- No results.
-
-
- )}
-
-
+
+
+
-
setRenameInboxTeam(null)}
- open={Boolean(renameInboxTeam)}
- workspaceId={workspaceId}
- />
- setAddInboxTeamMember(null)}
- open={Boolean(addInboxTeamMember)}
- workspaceId={workspaceId}
- workspaceMembers={workspaceMembers}
- />
- setDeleteInboxTeam(null)}
- open={Boolean(deleteInboxTeam)}
- workspaceId={workspaceId}
- />
- setDeleteInboxTeamMember(null)}
- open={Boolean(deleteInboxTeamMember)}
- teamMember={deleteInboxTeamMember}
- workspaceId={workspaceId}
- />
- >
+
+
+ {team.name}
+
+ (ID: {team.id.slice(-6)})
+
+
+
+ {description?.trim()
+ ? description
+ : t("membersCount", { count: memberCount })}
+
+
+
+
+ onDelete(team)}
+ size="sm"
+ type="button"
+ variant="ghost"
+ >
+
+ {t("delete")}
+
+ onManage(team)}
+ size="sm"
+ type="button"
+ variant="outline"
+ >
+
+ {t("manage")}
+
+
+
)
}
export function ListInboxTeams({ workspaceId, promises }: ListInboxTeamsProps) {
- const t = useTranslations()
+ const t = useTranslations("inboxTeams")
const [{ data: allInboxTeams }, { data: allWorkspaceMembers }] = use(promises)
+ const [search, setSearch] = useState("")
+ const [manageTeam, setManageTeam] = useState
(null)
+ const [deleteTeam, setDeleteTeam] = useState(null)
+
+ const teams: ListInboxTeamsResponse["data"] = allInboxTeams ?? []
+ const members: ListWorkspaceMembersResponse["data"] =
+ allWorkspaceMembers ?? []
+
+ const filtered = useMemo(() => {
+ if (!search.trim()) {
+ return teams
+ }
+ const q = search.toLowerCase()
+ return teams.filter((tm) => tm.name.toLowerCase().includes(q))
+ }, [teams, search])
+
return (
-
-
-
- {t("fields.inboxTeam.label")}
-
-
-
-
-
-
-
+
+
+
+ {t("title")}
+
+
{t("subtitle")}
+
+
+
+
-
-
+
+
+ setSearch(e.target.value)}
+ placeholder={t("searchPlaceholder")}
+ value={search}
+ />
+
+
+
+
+ {filtered.length === 0 ? (
+
+ {search.trim()
+ ? "Nenhuma equipe encontrada."
+ : "Nenhuma equipe criada ainda."}
+
+ ) : (
+ filtered.map((team) => (
+
+ ))
+ )}
+
+
+ setManageTeam(null)}
+ open={Boolean(manageTeam)}
+ workspaceId={workspaceId}
+ workspaceMembers={members}
+ />
+ setDeleteTeam(null)}
+ open={Boolean(deleteTeam)}
+ workspaceId={workspaceId}
+ />
+
)
}
diff --git a/apps/builder/src/enterprise/features/inbox-teams/manage-inbox-team-dialog.tsx b/apps/builder/src/enterprise/features/inbox-teams/manage-inbox-team-dialog.tsx
new file mode 100644
index 000000000..6bec295a8
--- /dev/null
+++ b/apps/builder/src/enterprise/features/inbox-teams/manage-inbox-team-dialog.tsx
@@ -0,0 +1,149 @@
+"use client"
+
+import { InputField } from "@chatbotx.io/ui/components/form/input-field"
+import { MultiSelectField } from "@chatbotx.io/ui/components/form/multi-select-field"
+import { TextareaField } from "@chatbotx.io/ui/components/form/textarea-field"
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@chatbotx.io/ui/components/ui/dialog"
+import { Form } from "@chatbotx.io/ui/components/ui/form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useHookFormAction } from "@next-safe-action/adapter-react-hook-form/hooks"
+import { Loader2Icon } from "lucide-react"
+import { useRouter } from "next/navigation"
+import { useTranslations } from "next-intl"
+import { useEffect } from "react"
+import { toast } from "sonner"
+import type { ListWorkspaceMembersResponse } from "@/features/workspace-members/schema/query"
+import { updateInboxTeamAction } from "./actions/update-inbox-team.action"
+import { updateInboxTeamRequest } from "./schema/action"
+import type { InboxTeamResource } from "./schema/resource"
+
+type InboxTeamWithMembers = InboxTeamResource & {
+ description?: string | null
+ inboxTeamMembers?: Array<{ userId: unknown }>
+}
+
+export function ManageInboxTeamDialog({
+ open,
+ onOpenChange,
+ workspaceId,
+ inboxTeam,
+ workspaceMembers,
+}: {
+ open: boolean
+ onOpenChange: (val: boolean) => void
+ workspaceId: string
+ inboxTeam: InboxTeamWithMembers | null
+ workspaceMembers: ListWorkspaceMembersResponse["data"]
+}) {
+ const t = useTranslations("inboxTeams")
+ const tActions = useTranslations("actions")
+ const router = useRouter()
+
+ const {
+ form,
+ handleSubmitWithAction,
+ form: { reset },
+ } = useHookFormAction(
+ updateInboxTeamAction.bind(null, workspaceId, inboxTeam?.id ?? ""),
+ zodResolver(updateInboxTeamRequest),
+ {
+ actionProps: {
+ onSuccess: () => {
+ toast.success(t("manageDialog.title"))
+ onOpenChange(false)
+ router.refresh()
+ },
+ onError: ({ error }) => {
+ if (error.serverError) {
+ toast.error(error.serverError)
+ }
+ },
+ },
+ formProps: {
+ mode: "onChange",
+ defaultValues: {
+ name: inboxTeam?.name ?? "",
+ description: inboxTeam?.description ?? "",
+ userIds:
+ inboxTeam?.inboxTeamMembers?.map((m) => String(m.userId)) ?? [],
+ },
+ },
+ errorMapProps: {},
+ },
+ )
+
+ useEffect(() => {
+ if (inboxTeam) {
+ reset({
+ name: inboxTeam.name,
+ description: inboxTeam.description ?? "",
+ userIds: inboxTeam.inboxTeamMembers?.map((m) => String(m.userId)) ?? [],
+ })
+ }
+ }, [inboxTeam, reset])
+
+ const userOptions = workspaceMembers.map((cm) => ({
+ value: cm.user.id,
+ label: cm.user.name ?? "",
+ }))
+
+ return (
+
+
+
+ {t("manageDialog.title")}
+ {t("manageDialog.description")}
+
+
+
+
+
+
+
+
+
+
+ {tActions("cancel")}
+
+
+
+ {form.formState.isSubmitting && (
+
+ )}
+ {t("save")}
+
+
+
+
+
+
+ )
+}
diff --git a/apps/builder/src/enterprise/features/inbox-teams/rename-inbox-team-dialog.tsx b/apps/builder/src/enterprise/features/inbox-teams/rename-inbox-team-dialog.tsx
deleted file mode 100644
index 12fe71c4a..000000000
--- a/apps/builder/src/enterprise/features/inbox-teams/rename-inbox-team-dialog.tsx
+++ /dev/null
@@ -1,126 +0,0 @@
-"use client"
-
-import { InputField } from "@chatbotx.io/ui/components/form/input-field"
-import { Button } from "@chatbotx.io/ui/components/ui/button"
-import {
- Dialog,
- DialogClose,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@chatbotx.io/ui/components/ui/dialog"
-import { Form } from "@chatbotx.io/ui/components/ui/form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { useHookFormAction } from "@next-safe-action/adapter-react-hook-form/hooks"
-import { Loader2Icon } from "lucide-react"
-import { useTranslations } from "next-intl"
-import { useEffect } from "react"
-import "react-day-picker/style.css"
-import { useRouter } from "next/navigation"
-import { toast } from "sonner"
-import { updateInboxTeamAction } from "./actions/update-inbox-team.action"
-import { updateInboxTeamRequest } from "./schema/action"
-import type { InboxTeamResource } from "./schema/resource"
-
-export function RenameInboxTeamDialog({
- open,
- onOpenChange,
- workspaceId,
- inboxTeam,
-}: {
- open: boolean
- onOpenChange: (val: boolean) => void
- workspaceId: string
- inboxTeam: InboxTeamResource | null
-}) {
- const t = useTranslations()
- const router = useRouter()
- const {
- form,
- handleSubmitWithAction,
- form: { reset },
- } = useHookFormAction(
- updateInboxTeamAction.bind(null, workspaceId, inboxTeam?.id ?? ""),
- zodResolver(updateInboxTeamRequest),
- {
- actionProps: {
- onSuccess: () => {
- toast.success(
- t("messages.updatedSuccess", {
- feature: t("fields.inboxTeam.label"),
- }),
- )
-
- onOpenChange(false)
- router.refresh()
- },
- onError: ({ error }) => {
- if (error.serverError) {
- toast.error(error.serverError)
- }
- },
- },
- formProps: {
- mode: "onChange",
- defaultValues: {
- name: inboxTeam?.name || "",
- },
- },
- errorMapProps: {},
- },
- )
-
- useEffect(() => {
- if (inboxTeam) {
- reset({
- name: inboxTeam.name,
- })
- }
- }, [inboxTeam, reset])
-
- return (
-
-
-
-
- {t("messages.editFeature", {
- feature: t("fields.inboxTeam.label"),
- })}
-
-
-
-
-
-
-
-
-
-
-
- {t("actions.cancel")}
-
-
-
- {form.formState.isSubmitting && (
-
- )}
- {t("actions.update")}
-
-
-
-
-
-
-
- )
-}
diff --git a/apps/builder/src/enterprise/features/inbox-teams/schema/action.ts b/apps/builder/src/enterprise/features/inbox-teams/schema/action.ts
index f48ccb161..34f098cb4 100644
--- a/apps/builder/src/enterprise/features/inbox-teams/schema/action.ts
+++ b/apps/builder/src/enterprise/features/inbox-teams/schema/action.ts
@@ -6,12 +6,15 @@ import { inboxTeamResource } from "./resource"
export const createInboxTeamRequest = z.object({
name: z.string().trim().min(1).max(255),
- userIds: z.array(zodBigintAsString()),
+ description: z.string().trim().max(500).optional().nullable(),
+ userIds: z.array(zodBigintAsString()).min(1),
})
export type CreateInboxTeamRequest = z.infer
export const updateInboxTeamRequest = z.object({
name: z.string().trim().min(1).max(255).optional(),
+ description: z.string().trim().max(500).optional().nullable(),
+ userIds: z.array(zodBigintAsString()).optional(),
})
export type UpdateInboxTeamRequest = z.infer
diff --git a/apps/builder/src/enterprise/features/manage/components/manage-sidebar.tsx b/apps/builder/src/enterprise/features/manage/components/manage-sidebar.tsx
index 100509d73..5d37b5e74 100644
--- a/apps/builder/src/enterprise/features/manage/components/manage-sidebar.tsx
+++ b/apps/builder/src/enterprise/features/manage/components/manage-sidebar.tsx
@@ -11,6 +11,7 @@ import {
CoinsIcon,
CreditCardIcon,
Grid2x2PlusIcon,
+ LayersIcon,
Users2Icon,
} from "lucide-react"
import Link from "next/link"
@@ -42,6 +43,11 @@ export function ManageSidebar() {
url: "/manage/settings",
icon: BuildingIcon,
},
+ {
+ title: t("manageWorkspaces.sidebarTitle"),
+ url: "/manage/workspaces",
+ icon: LayersIcon,
+ },
...(isCommunity
? []
: [
@@ -71,7 +77,7 @@ export function ManageSidebar() {
className="flex h-12 items-center justify-center border-b"
href="/"
>
-
+
diff --git a/apps/builder/src/enterprise/features/organization-members/actions/create-organization-user.action.ts b/apps/builder/src/enterprise/features/organization-members/actions/create-organization-user.action.ts
new file mode 100644
index 000000000..747f2cd97
--- /dev/null
+++ b/apps/builder/src/enterprise/features/organization-members/actions/create-organization-user.action.ts
@@ -0,0 +1,116 @@
+"use server"
+
+import {
+ auditLogActions,
+ ChatbotXException,
+ logAudit,
+} from "@chatbotx.io/business"
+import { db } from "@chatbotx.io/database/client"
+import {
+ organizationMemberModel,
+ userModel,
+ workspaceMemberModel,
+} from "@chatbotx.io/database/schema"
+import { createId } from "@chatbotx.io/utils"
+import { organizationMemberService } from "@/features/organization-members/services"
+import { orgAdminActionClient } from "@/lib/safe-action"
+import { upsertOrganizationUserSchema } from "../schema/upsert"
+
+const defaultPermissions = {
+ restrictDataExport: false,
+ restrictContactDeletion: false,
+ restrictWorkspaceSettings: false,
+ restrictIntegrationSettings: false,
+ contactVisibility: "all" as const,
+ restrictCalling: false,
+ restrictWorkflows: false,
+ maskPhoneAndEmail: false,
+}
+
+export const createOrganizationUserAction = orgAdminActionClient
+ .inputSchema(upsertOrganizationUserSchema)
+ .action(async ({ ctx, parsedInput }) => {
+ const { email, name, organizationRole, workspaceAssignments } = parsedInput
+
+ if (organizationRole === "owner" && ctx.callerMember.role !== "owner") {
+ throw new ChatbotXException(
+ "Apenas proprietários podem atribuir o papel de Proprietário.",
+ )
+ }
+
+ await db.transaction(async (tx) => {
+ const existing = await tx.query.userModel.findFirst({
+ where: { email },
+ })
+
+ let userId = existing?.id
+ if (existing) {
+ const alreadyInOrg = await tx.query.organizationMemberModel.findFirst({
+ where: { organizationId: ctx.organization.id, userId: existing.id },
+ })
+ if (alreadyInOrg) {
+ throw new ChatbotXException(
+ "Este e-mail já pertence a um usuário da organização.",
+ )
+ }
+ } else {
+ const created = await tx
+ .insert(userModel)
+ .values({
+ email,
+ name: name || email,
+ emailVerified: false,
+ })
+ .returning({ id: userModel.id })
+ userId = created[0]?.id
+ }
+ if (!userId) {
+ throw new ChatbotXException("Falha ao criar usuário.")
+ }
+
+ await tx.insert(organizationMemberModel).values({
+ id: createId(),
+ organizationId: ctx.organization.id,
+ userId,
+ role: organizationRole,
+ })
+
+ for (const assignment of workspaceAssignments) {
+ await tx.insert(workspaceMemberModel).values({
+ id: createId(),
+ workspaceId: assignment.workspaceId,
+ userId,
+ role: assignment.role,
+ permissions: defaultPermissions,
+ notificationChannels: {
+ messenger: true,
+ email: true,
+ telegram: true,
+ browser: true,
+ },
+ })
+ }
+
+ const fallbackWorkspaceId =
+ workspaceAssignments[0]?.workspaceId ??
+ (
+ await tx.query.workspaceModel.findFirst({
+ where: { organizationId: ctx.organization.id },
+ columns: { id: true },
+ })
+ )?.id
+ if (fallbackWorkspaceId) {
+ await logAudit({
+ tx,
+ workspaceId: fallbackWorkspaceId,
+ userId: ctx.user.id,
+ action: auditLogActions.USER_INVITED,
+ detail: `Usuário ${email} adicionado como ${organizationRole} com acesso a ${workspaceAssignments.length} workspace(s)`,
+ })
+ }
+ })
+
+ await organizationMemberService.invalidateForOrganization(
+ ctx.organization.id,
+ )
+ })
diff --git a/apps/builder/src/enterprise/features/organization-members/actions/delete-organization-user.action.ts b/apps/builder/src/enterprise/features/organization-members/actions/delete-organization-user.action.ts
new file mode 100644
index 000000000..7fa097bd3
--- /dev/null
+++ b/apps/builder/src/enterprise/features/organization-members/actions/delete-organization-user.action.ts
@@ -0,0 +1,95 @@
+"use server"
+
+import {
+ auditLogActions,
+ ChatbotXException,
+ logAudit,
+} from "@chatbotx.io/business"
+import { and, db, eq, inArray } from "@chatbotx.io/database/client"
+import {
+ organizationMemberModel,
+ workspaceMemberModel,
+} from "@chatbotx.io/database/schema"
+import { organizationMemberService } from "@/features/organization-members/services"
+import { orgAdminActionClient } from "@/lib/safe-action"
+import { deleteOrganizationUserSchema } from "../schema/upsert"
+
+export const deleteOrganizationUserAction = orgAdminActionClient
+ .inputSchema(deleteOrganizationUserSchema)
+ .action(async ({ ctx, parsedInput }) => {
+ const { userId } = parsedInput
+
+ if (userId === ctx.user.id) {
+ throw new ChatbotXException("Você não pode remover a si mesmo.")
+ }
+
+ const orgMember = await db.query.organizationMemberModel.findFirst({
+ where: { organizationId: ctx.organization.id, userId },
+ })
+ if (!orgMember) {
+ throw new ChatbotXException("Usuário não pertence a esta organização.")
+ }
+
+ if (orgMember.role === "owner") {
+ const otherOwners = await db.query.organizationMemberModel.findMany({
+ where: {
+ organizationId: ctx.organization.id,
+ role: "owner",
+ NOT: { userId },
+ },
+ columns: { id: true },
+ })
+ if (otherOwners.length === 0) {
+ throw new ChatbotXException(
+ "A organização precisa ter pelo menos um Proprietário.",
+ )
+ }
+ }
+
+ const wsMembers = await db.query.workspaceMemberModel.findMany({
+ where: { userId },
+ with: { workspace: { columns: { id: true, organizationId: true } } },
+ })
+ const wsMembersInOrg = wsMembers.filter(
+ (m) => m.workspace?.organizationId === ctx.organization.id,
+ )
+ const wsIdsInOrg = wsMembersInOrg.map((m) => m.workspaceId)
+
+ await db.transaction(async (tx) => {
+ if (wsIdsInOrg.length > 0) {
+ await tx
+ .delete(workspaceMemberModel)
+ .where(
+ and(
+ eq(workspaceMemberModel.userId, userId),
+ inArray(workspaceMemberModel.workspaceId, wsIdsInOrg),
+ ),
+ )
+ }
+ await tx
+ .delete(organizationMemberModel)
+ .where(eq(organizationMemberModel.id, orgMember.id))
+
+ const fallbackWorkspaceId =
+ wsIdsInOrg[0] ??
+ (
+ await tx.query.workspaceModel.findFirst({
+ where: { organizationId: ctx.organization.id },
+ columns: { id: true },
+ })
+ )?.id
+ if (fallbackWorkspaceId) {
+ await logAudit({
+ tx,
+ workspaceId: fallbackWorkspaceId,
+ userId: ctx.user.id,
+ action: auditLogActions.USER_REVOKED,
+ detail: `Usuário ${userId} removido da organização (revogado em ${wsIdsInOrg.length} workspace(s))`,
+ })
+ }
+ })
+
+ await organizationMemberService.invalidateForOrganization(
+ ctx.organization.id,
+ )
+ })
diff --git a/apps/builder/src/enterprise/features/organization-members/actions/update-organization-user.action.ts b/apps/builder/src/enterprise/features/organization-members/actions/update-organization-user.action.ts
new file mode 100644
index 000000000..08d545893
--- /dev/null
+++ b/apps/builder/src/enterprise/features/organization-members/actions/update-organization-user.action.ts
@@ -0,0 +1,181 @@
+"use server"
+
+import {
+ auditLogActions,
+ ChatbotXException,
+ logAudit,
+} from "@chatbotx.io/business"
+import { and, db, eq, inArray } from "@chatbotx.io/database/client"
+import {
+ organizationMemberModel,
+ workspaceMemberModel,
+} from "@chatbotx.io/database/schema"
+import { createId } from "@chatbotx.io/utils"
+import { organizationMemberService } from "@/features/organization-members/services"
+import { orgAdminActionClient } from "@/lib/safe-action"
+import { editOrganizationUserSchema } from "../schema/upsert"
+
+const defaultPermissions = {
+ restrictDataExport: false,
+ restrictContactDeletion: false,
+ restrictWorkspaceSettings: false,
+ restrictIntegrationSettings: false,
+ contactVisibility: "all" as const,
+ restrictCalling: false,
+ restrictWorkflows: false,
+ maskPhoneAndEmail: false,
+}
+
+export const updateOrganizationUserAction = orgAdminActionClient
+ .inputSchema(editOrganizationUserSchema)
+ .action(async ({ ctx, parsedInput }) => {
+ const { userId, organizationRole, workspaceAssignments } = parsedInput
+
+ const orgMember = await db.query.organizationMemberModel.findFirst({
+ where: { organizationId: ctx.organization.id, userId },
+ })
+ if (!orgMember) {
+ throw new ChatbotXException("Usuário não pertence a esta organização.")
+ }
+
+ if (
+ userId === ctx.user.id &&
+ orgMember.role === "owner" &&
+ organizationRole !== "owner"
+ ) {
+ throw new ChatbotXException(
+ "Você não pode rebaixar a si mesmo como Proprietário.",
+ )
+ }
+
+ if (
+ organizationRole === "owner" &&
+ orgMember.role !== "owner" &&
+ ctx.callerMember.role !== "owner"
+ ) {
+ throw new ChatbotXException(
+ "Apenas proprietários podem atribuir o papel de Proprietário.",
+ )
+ }
+
+ if (orgMember.role === "owner" && organizationRole !== "owner") {
+ const otherOwners = await db.query.organizationMemberModel.findMany({
+ where: {
+ organizationId: ctx.organization.id,
+ role: "owner",
+ NOT: { userId },
+ },
+ columns: { id: true },
+ })
+ if (otherOwners.length === 0) {
+ throw new ChatbotXException(
+ "A organização precisa ter pelo menos um Proprietário.",
+ )
+ }
+ }
+
+ const currentMembers = await db.query.workspaceMemberModel.findMany({
+ where: { userId },
+ with: { workspace: { columns: { organizationId: true } } },
+ })
+ const currentInOrg = currentMembers.filter(
+ (m) => m.workspace?.organizationId === ctx.organization.id,
+ )
+
+ const desiredByWs = new Map(
+ workspaceAssignments.map((a) => [a.workspaceId, a.role]),
+ )
+ const currentByWs = new Map(currentInOrg.map((m) => [m.workspaceId, m]))
+
+ const toCreate = workspaceAssignments.filter(
+ (a) => !currentByWs.has(a.workspaceId),
+ )
+ const toDelete = currentInOrg.filter((m) => !desiredByWs.has(m.workspaceId))
+ const toUpdate = workspaceAssignments
+ .map((a) => {
+ const existing = currentByWs.get(a.workspaceId)
+ if (!existing) {
+ return null
+ }
+ if (existing.role === a.role) {
+ return null
+ }
+ return {
+ existingId: existing.id,
+ role: a.role,
+ workspaceId: a.workspaceId,
+ }
+ })
+ .filter(Boolean) as Array<{
+ existingId: string
+ role: (typeof workspaceAssignments)[number]["role"]
+ workspaceId: string
+ }>
+
+ await db.transaction(async (tx) => {
+ if (orgMember.role !== organizationRole) {
+ await tx
+ .update(organizationMemberModel)
+ .set({ role: organizationRole })
+ .where(eq(organizationMemberModel.id, orgMember.id))
+ }
+
+ for (const a of toCreate) {
+ await tx.insert(workspaceMemberModel).values({
+ id: createId(),
+ workspaceId: a.workspaceId,
+ userId,
+ role: a.role,
+ permissions: defaultPermissions,
+ notificationChannels: {
+ messenger: true,
+ email: true,
+ telegram: true,
+ browser: true,
+ },
+ })
+ }
+
+ for (const u of toUpdate) {
+ await tx
+ .update(workspaceMemberModel)
+ .set({ role: u.role })
+ .where(eq(workspaceMemberModel.id, u.existingId))
+ }
+
+ if (toDelete.length > 0) {
+ await tx.delete(workspaceMemberModel).where(
+ and(
+ eq(workspaceMemberModel.userId, userId),
+ inArray(
+ workspaceMemberModel.workspaceId,
+ toDelete.map((m) => m.workspaceId),
+ ),
+ ),
+ )
+ }
+
+ const fallbackWorkspaceId =
+ workspaceAssignments[0]?.workspaceId ??
+ toDelete[0]?.workspaceId ??
+ (
+ await tx.query.workspaceModel.findFirst({
+ where: { organizationId: ctx.organization.id },
+ columns: { id: true },
+ })
+ )?.id
+ if (fallbackWorkspaceId) {
+ await logAudit({
+ tx,
+ workspaceId: fallbackWorkspaceId,
+ userId: ctx.user.id,
+ action: auditLogActions.USER_ROLE_UPDATED,
+ detail: `Usuário ${userId} atualizado: role=${organizationRole}, +${toCreate.length}/-${toDelete.length}/~${toUpdate.length} workspaces`,
+ })
+ }
+ })
+
+ await organizationMemberService.invalidateForOrganization(
+ ctx.organization.id,
+ )
+ })
diff --git a/apps/builder/src/enterprise/features/organization-members/components/delete-user-dialog.tsx b/apps/builder/src/enterprise/features/organization-members/components/delete-user-dialog.tsx
new file mode 100644
index 000000000..a1ca7af61
--- /dev/null
+++ b/apps/builder/src/enterprise/features/organization-members/components/delete-user-dialog.tsx
@@ -0,0 +1,81 @@
+"use client"
+
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@chatbotx.io/ui/components/ui/dialog"
+import { Loader2Icon } from "lucide-react"
+import { useTranslations } from "next-intl"
+import { useAction } from "next-safe-action/hooks"
+import { toast } from "sonner"
+import { deleteOrganizationUserAction } from "../actions/delete-organization-user.action"
+
+type DeleteUserDialogProps = {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ user: { id: string; name: string; email: string } | null
+ onSuccess?: () => void
+}
+
+export function DeleteUserDialog({
+ open,
+ onOpenChange,
+ user,
+ onSuccess,
+}: DeleteUserDialogProps) {
+ const t = useTranslations()
+ const tActions = useTranslations("actions")
+
+ const { execute, isExecuting } = useAction(deleteOrganizationUserAction, {
+ onSuccess: () => {
+ toast.success(t("organizationUsers.deleted"))
+ onSuccess?.()
+ onOpenChange(false)
+ },
+ onError: ({ error }) => {
+ if (error.serverError) {
+ toast.error(error.serverError)
+ }
+ },
+ })
+
+ if (!user) {
+ return null
+ }
+
+ return (
+
+
+
+ {t("organizationUsers.deleteTitle")}
+
+ {t("organizationUsers.deleteConfirm", { name: user.name })}
+
+
+
+ onOpenChange(false)}
+ type="button"
+ variant="ghost"
+ >
+ {tActions("cancel")}
+
+ execute({ userId: user.id })}
+ type="button"
+ variant="destructive"
+ >
+ {isExecuting && }
+ {t("organizationUsers.deleteSubmit")}
+
+
+
+
+ )
+}
diff --git a/apps/builder/src/enterprise/features/organization-members/components/organization-members-list.tsx b/apps/builder/src/enterprise/features/organization-members/components/organization-members-list.tsx
new file mode 100644
index 000000000..b03d55177
--- /dev/null
+++ b/apps/builder/src/enterprise/features/organization-members/components/organization-members-list.tsx
@@ -0,0 +1,213 @@
+"use client"
+
+import {
+ Avatar,
+ AvatarFallback,
+ AvatarImage,
+} from "@chatbotx.io/ui/components/ui/avatar"
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import { Input } from "@chatbotx.io/ui/components/ui/input"
+import { format, isToday, isYesterday } from "date-fns"
+import { ptBR } from "date-fns/locale"
+import { PlusIcon, SearchIcon, Trash2Icon } from "lucide-react"
+import { useRouter } from "next/navigation"
+import { useTranslations } from "next-intl"
+import { useState } from "react"
+import { useDebouncedCallback } from "use-debounce"
+import {
+ getOrganizationMemberDetail,
+ type OrganizationMemberDetail,
+} from "../queries/get-member-detail"
+import type { ListOrganizationMembersResponse } from "../schema/mutation"
+import { DeleteUserDialog } from "./delete-user-dialog"
+import { UserFormDialog } from "./user-form-dialog"
+
+type WorkspaceOption = { id: string; name: string }
+
+type OrganizationMembersListProps = {
+ members: ListOrganizationMembersResponse["data"]
+ workspaceOptions: WorkspaceOption[]
+ currentUserId: string
+ initialKeyword?: string
+}
+
+export function OrganizationMembersList({
+ members,
+ workspaceOptions,
+ currentUserId,
+ initialKeyword = "",
+}: OrganizationMembersListProps) {
+ const t = useTranslations()
+ const router = useRouter()
+ const [addOpen, setAddOpen] = useState(false)
+ const [editTarget, setEditTarget] = useState(
+ null,
+ )
+ const [deleteTarget, setDeleteTarget] = useState<{
+ id: string
+ name: string
+ email: string
+ } | null>(null)
+ const [keyword, setKeyword] = useState(initialKeyword)
+
+ const refresh = () => router.refresh()
+
+ const onSearch = useDebouncedCallback((value: string) => {
+ const params = new URLSearchParams(window.location.search)
+ if (value) {
+ params.set("keyword", value)
+ } else {
+ params.delete("keyword")
+ }
+ router.replace(`${window.location.pathname}?${params.toString()}`)
+ }, 300)
+
+ const handleEdit = async (userId: string) => {
+ const detail = await getOrganizationMemberDetail({ userId })
+ if (detail) {
+ setEditTarget(detail)
+ }
+ }
+
+ return (
+
+
+
setAddOpen(true)} size="sm">
+
+ {t("organizationUsers.addButton")}
+
+
+
+ {
+ setKeyword(e.target.value)
+ onSearch(e.target.value)
+ }}
+ placeholder={t("organizationUsers.searchPlaceholder")}
+ value={keyword}
+ />
+
+
+
+
+ {members.length === 0 ? (
+
+ {t("organizationUsers.empty")}
+
+ ) : (
+ members.map((m) => (
+
+ setDeleteTarget({
+ id: m.userId,
+ name: m.user.name ?? m.user.email,
+ email: m.user.email,
+ })
+ }
+ onEdit={() => handleEdit(m.userId)}
+ />
+ ))
+ )}
+
+
+
+
{
+ if (!open) {
+ setEditTarget(null)
+ }
+ }}
+ onSuccess={refresh}
+ open={Boolean(editTarget)}
+ workspaceOptions={workspaceOptions}
+ />
+ {
+ if (!open) {
+ setDeleteTarget(null)
+ }
+ }}
+ onSuccess={refresh}
+ open={Boolean(deleteTarget)}
+ user={deleteTarget}
+ />
+
+ )
+}
+
+function MemberRow({
+ member,
+ onEdit,
+ onDelete,
+ currentUserId,
+}: {
+ member: ListOrganizationMembersResponse["data"][number]
+ onEdit: () => void
+ onDelete: () => void
+ currentUserId: string
+}) {
+ const t = useTranslations()
+ const isCurrentUser = member.userId === currentUserId
+ const initials = (member.user.name || member.user.email)
+ .slice(0, 2)
+ .toUpperCase()
+
+ return (
+
+
+
+ {initials}
+
+
+
+ {member.user.name || member.user.email}
+
+
+ {t(`organizationUsers.orgRoles.${member.role}`)} — {member.user.email}
+
+
+
+ {formatLastActive(member.lastActiveAt)}
+
+
+ {isCurrentUser ? null : (
+
+
+ {t("organizationUsers.delete")}
+
+ )}
+
+ {t("organizationUsers.edit")}
+
+
+
+ )
+}
+
+function formatLastActive(date: Date | null): string {
+ if (!date) {
+ return ""
+ }
+ if (isToday(date)) {
+ return format(date, "p", { locale: ptBR })
+ }
+ if (isYesterday(date)) {
+ return "Ontem"
+ }
+ return format(date, "d MMM", { locale: ptBR })
+}
diff --git a/apps/builder/src/enterprise/features/organization-members/components/organization-members-table.tsx b/apps/builder/src/enterprise/features/organization-members/components/organization-members-table.tsx
deleted file mode 100644
index 69f22a935..000000000
--- a/apps/builder/src/enterprise/features/organization-members/components/organization-members-table.tsx
+++ /dev/null
@@ -1,119 +0,0 @@
-"use client"
-
-import { DataTable } from "@chatbotx.io/ui/components/data-table/data-table"
-import { DataTableColumnHeader } from "@chatbotx.io/ui/components/data-table/data-table-column-header"
-import { Checkbox } from "@chatbotx.io/ui/components/ui/checkbox"
-import { useDataTable } from "@chatbotx.io/ui/hooks/use-data-table"
-import type { ColumnDef } from "@tanstack/react-table"
-import { useTranslations } from "next-intl"
-import { use, useMemo } from "react"
-import type { ListOrganizationMembersResponse } from "../schema/mutation"
-
-type OrganizationMembersTableProps = {
- promises: Promise<[ListOrganizationMembersResponse]>
-}
-
-const getOrganizationMembersColumns = ({
- t,
-}: {
- t: ReturnType
-}): ColumnDef[] => [
- {
- id: "select",
- header: ({ table }) => (
-
- table.toggleAllPageRowsSelected(Boolean(value))
- }
- />
- ),
- cell: ({ row }) => (
- row.toggleSelected(Boolean(value))}
- />
- ),
- size: 50,
- enableSorting: false,
- enableHiding: false,
- },
- {
- id: "name",
- accessorKey: "name",
- header: ({ column }) => (
-
- ),
- cell: ({ row }) => {row.original.user.name}
,
- size: 300,
- meta: {
- label: t("fields.name.label"),
- placeholder: t("fields.name.placeholder"),
- variant: "text",
- },
- enableColumnFilter: true,
- enableSorting: true,
- },
- {
- id: "email",
- accessorKey: "email",
- header: ({ column }) => (
-
- ),
- cell: ({ row }) => {row.original.user.email}
,
- size: 300,
- meta: {
- label: t("fields.email.label"),
- placeholder: t("fields.email.placeholder"),
- variant: "text",
- },
- enableColumnFilter: true,
- enableSorting: true,
- },
- {
- id: "role",
- accessorKey: "role",
- header: ({ column }) => (
-
- ),
- cell: ({ row }) => {row.original.role}
,
- size: 300,
- meta: {
- label: t("fields.role.label"),
- variant: "text",
- },
- enableColumnFilter: true,
- enableSorting: false,
- },
-]
-
-const OrganizationMembersTable = (props: OrganizationMembersTableProps) => {
- const [{ data, pageCount }] = use(props.promises)
- const t = useTranslations()
-
- const columns = useMemo(() => getOrganizationMembersColumns({ t }), [t])
-
- const { table } = useDataTable({
- data,
- columns,
- pageCount,
- initialState: {
- sorting: [{ id: "createdAt", desc: true }],
- columnPinning: { right: ["actions"] },
- },
- getRowId: (originalRow) => originalRow.id,
- shallow: false,
- clearOnDefault: true,
- })
-
- return
-}
-
-export default OrganizationMembersTable
diff --git a/apps/builder/src/enterprise/features/organization-members/components/user-form-dialog.tsx b/apps/builder/src/enterprise/features/organization-members/components/user-form-dialog.tsx
new file mode 100644
index 000000000..c49d91b29
--- /dev/null
+++ b/apps/builder/src/enterprise/features/organization-members/components/user-form-dialog.tsx
@@ -0,0 +1,355 @@
+"use client"
+
+import { InputField } from "@chatbotx.io/ui/components/form/input-field"
+import { SelectField } from "@chatbotx.io/ui/components/form/select-field"
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@chatbotx.io/ui/components/ui/dialog"
+import { Form } from "@chatbotx.io/ui/components/ui/form"
+import { Label } from "@chatbotx.io/ui/components/ui/label"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Loader2Icon, PlusIcon, Trash2Icon } from "lucide-react"
+import { useTranslations } from "next-intl"
+import { useAction } from "next-safe-action/hooks"
+import { useEffect } from "react"
+import { useFieldArray, useForm } from "react-hook-form"
+import { toast } from "sonner"
+import { createOrganizationUserAction } from "../actions/create-organization-user.action"
+import { updateOrganizationUserAction } from "../actions/update-organization-user.action"
+import type { OrganizationMemberDetail } from "../queries/get-member-detail"
+import {
+ type EditOrganizationUserSchema,
+ editOrganizationUserSchema,
+ type UpsertOrganizationUserSchema,
+ upsertOrganizationUserSchema,
+} from "../schema/upsert"
+
+type WorkspaceOption = { id: string; name: string }
+
+type UserFormDialogProps = {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ workspaceOptions: WorkspaceOption[]
+ /** When set, dialog is in edit mode. */
+ initial?: OrganizationMemberDetail | null
+ onSuccess?: () => void
+}
+
+export function UserFormDialog({
+ open,
+ onOpenChange,
+ workspaceOptions,
+ initial,
+ onSuccess,
+}: UserFormDialogProps) {
+ const t = useTranslations()
+ const isEdit = Boolean(initial)
+
+ return (
+
+
+
+
+ {isEdit
+ ? t("organizationUsers.editTitle")
+ : t("organizationUsers.addTitle")}
+
+
+ {isEdit
+ ? t("organizationUsers.editDescription")
+ : t("organizationUsers.addDescription")}
+
+
+ {isEdit && initial ? (
+ onOpenChange(false)}
+ onSuccess={onSuccess}
+ workspaceOptions={workspaceOptions}
+ />
+ ) : (
+ onOpenChange(false)}
+ onSuccess={onSuccess}
+ workspaceOptions={workspaceOptions}
+ />
+ )}
+
+
+ )
+}
+
+const orgRoleOptions = (t: ReturnType) => [
+ { value: "owner", label: t("organizationUsers.orgRoles.owner") },
+ { value: "manager", label: t("organizationUsers.orgRoles.manager") },
+ { value: "agent", label: t("organizationUsers.orgRoles.agent") },
+]
+
+const wsRoleOptions = (t: ReturnType) => [
+ { value: "owner", label: t("organizationUsers.wsRoles.owner") },
+ { value: "manager", label: t("organizationUsers.wsRoles.manager") },
+ { value: "agent", label: t("organizationUsers.wsRoles.agent") },
+]
+
+function CreateForm({
+ workspaceOptions,
+ onClose,
+ onSuccess,
+}: {
+ workspaceOptions: WorkspaceOption[]
+ onClose: () => void
+ onSuccess?: () => void
+}) {
+ const t = useTranslations()
+ const tActions = useTranslations("actions")
+
+ const form = useForm({
+ resolver: zodResolver(upsertOrganizationUserSchema) as never,
+ mode: "onChange",
+ defaultValues: {
+ email: "",
+ name: "",
+ organizationRole: "agent",
+ workspaceAssignments: workspaceOptions[0]
+ ? [{ workspaceId: workspaceOptions[0].id, role: "agent" }]
+ : [],
+ },
+ })
+
+ const { fields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "workspaceAssignments",
+ })
+
+ const { execute, isExecuting } = useAction(createOrganizationUserAction, {
+ onSuccess: () => {
+ toast.success(t("organizationUsers.created"))
+ onSuccess?.()
+ onClose()
+ },
+ onError: ({ error }) => {
+ if (error.serverError) {
+ toast.error(error.serverError)
+ }
+ },
+ })
+
+ const onSubmit = form.handleSubmit((values) => execute(values))
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ append({
+ workspaceId: workspaceOptions[0]?.id ?? "",
+ role: "agent",
+ })
+ }
+ onRemove={remove}
+ workspaceOptions={workspaceOptions}
+ />
+
+
+
+ {tActions("cancel")}
+
+
+ {isExecuting && }
+ {t("organizationUsers.addSubmit")}
+
+
+
+
+ )
+}
+
+function EditForm({
+ initial,
+ workspaceOptions,
+ onClose,
+ onSuccess,
+}: {
+ initial: OrganizationMemberDetail
+ workspaceOptions: WorkspaceOption[]
+ onClose: () => void
+ onSuccess?: () => void
+}) {
+ const t = useTranslations()
+ const tActions = useTranslations("actions")
+
+ const form = useForm({
+ resolver: zodResolver(editOrganizationUserSchema) as never,
+ mode: "onChange",
+ defaultValues: {
+ userId: initial.userId,
+ organizationRole: initial.organizationRole as never,
+ workspaceAssignments: initial.workspaceAssignments.map((a) => ({
+ workspaceId: a.workspaceId,
+ role: a.role as never,
+ })),
+ },
+ })
+
+ useEffect(() => {
+ form.reset({
+ userId: initial.userId,
+ organizationRole: initial.organizationRole as never,
+ workspaceAssignments: initial.workspaceAssignments.map((a) => ({
+ workspaceId: a.workspaceId,
+ role: a.role as never,
+ })),
+ })
+ }, [initial, form])
+
+ const { fields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "workspaceAssignments",
+ })
+
+ const { execute, isExecuting } = useAction(updateOrganizationUserAction, {
+ onSuccess: () => {
+ toast.success(t("organizationUsers.updated"))
+ onSuccess?.()
+ onClose()
+ },
+ onError: ({ error }) => {
+ if (error.serverError) {
+ toast.error(error.serverError)
+ }
+ },
+ })
+
+ const onSubmit = form.handleSubmit((values) => execute(values))
+
+ return (
+
+
+
+
{initial.name}
+
{initial.email}
+
+
+
+
+
+ append({
+ workspaceId: workspaceOptions[0]?.id ?? "",
+ role: "agent",
+ })
+ }
+ onRemove={remove}
+ workspaceOptions={workspaceOptions}
+ />
+
+
+
+ {tActions("cancel")}
+
+
+ {isExecuting && }
+ {t("organizationUsers.editSubmit")}
+
+
+
+
+ )
+}
+
+function WorkspaceAssignmentsField({
+ fields,
+ onAppend,
+ onRemove,
+ workspaceOptions,
+}: {
+ fields: Array<{ id: string }>
+ onAppend: () => void
+ onRemove: (idx: number) => void
+ workspaceOptions: WorkspaceOption[]
+}) {
+ const t = useTranslations()
+
+ return (
+
+
{t("organizationUsers.workspaceAccess")}
+
+ {fields.length === 0 ? (
+
+ {t("organizationUsers.noWorkspaces")}
+
+ ) : null}
+ {fields.map((f, idx) => (
+
+ ({
+ value: o.id,
+ label: o.name,
+ }))}
+ placeholder={t("organizationUsers.selectWorkspace")}
+ />
+
+ onRemove(idx)}
+ size="icon"
+ type="button"
+ variant="ghost"
+ >
+
+
+
+ ))}
+
+
+
+ {t("organizationUsers.addWorkspace")}
+
+
+ )
+}
diff --git a/apps/builder/src/enterprise/features/organization-members/queries/get-member-detail.ts b/apps/builder/src/enterprise/features/organization-members/queries/get-member-detail.ts
new file mode 100644
index 000000000..1db61345b
--- /dev/null
+++ b/apps/builder/src/enterprise/features/organization-members/queries/get-member-detail.ts
@@ -0,0 +1,68 @@
+"use server"
+
+import { ChatbotXException, organizationService } from "@chatbotx.io/business"
+import { db } from "@chatbotx.io/database/client"
+import { organizationMemberRoles } from "@chatbotx.io/database/partials"
+import { organizationMemberService } from "@/features/organization-members/services"
+import { getCurrentUser } from "@/lib/auth/utils"
+import { getDomainFromHeader } from "@/lib/domain"
+
+export type OrganizationMemberDetail = {
+ userId: string
+ name: string
+ email: string
+ image: string | null
+ organizationRole: string
+ workspaceAssignments: Array<{ workspaceId: string; role: string }>
+}
+
+export async function getOrganizationMemberDetail(props: {
+ userId: string
+}): Promise {
+ const caller = await getCurrentUser()
+ if (!caller) {
+ throw new ChatbotXException("Não autenticado.")
+ }
+
+ const domain = await getDomainFromHeader()
+ const organization = await organizationService.findByDomain(domain)
+
+ const callerMembership = await organizationMemberService.findBy({
+ where: { organizationId: organization.id, userId: caller.id },
+ })
+ if (
+ !callerMembership ||
+ (callerMembership.role !== organizationMemberRoles.enum.owner &&
+ callerMembership.role !== organizationMemberRoles.enum.manager)
+ ) {
+ throw new ChatbotXException("Você não tem permissão para ver este usuário.")
+ }
+
+ const member = await db.query.organizationMemberModel.findFirst({
+ where: {
+ organizationId: organization.id,
+ userId: props.userId,
+ },
+ with: { user: true },
+ })
+ if (!member?.user) {
+ return null
+ }
+
+ const wsMembers = await db.query.workspaceMemberModel.findMany({
+ where: { userId: props.userId },
+ with: { workspace: { columns: { id: true, organizationId: true } } },
+ })
+ const workspaceAssignments = wsMembers
+ .filter((m) => m.workspace?.organizationId === organization.id)
+ .map((m) => ({ workspaceId: m.workspaceId, role: m.role }))
+
+ return {
+ userId: member.userId,
+ name: member.user.name ?? member.user.email,
+ email: member.user.email,
+ image: member.user.image ?? null,
+ organizationRole: member.role,
+ workspaceAssignments,
+ }
+}
diff --git a/apps/builder/src/enterprise/features/organization-members/queries/index.ts b/apps/builder/src/enterprise/features/organization-members/queries/index.ts
index 4065ec12f..508d080bf 100644
--- a/apps/builder/src/enterprise/features/organization-members/queries/index.ts
+++ b/apps/builder/src/enterprise/features/organization-members/queries/index.ts
@@ -1,6 +1,14 @@
import { organizationService } from "@chatbotx.io/business"
-import { db, relationsFilterToSQL } from "@chatbotx.io/database/client"
-import { organizationMemberModel } from "@chatbotx.io/database/schema"
+import {
+ db,
+ desc,
+ inArray,
+ relationsFilterToSQL,
+} from "@chatbotx.io/database/client"
+import {
+ organizationMemberModel,
+ sessionModel,
+} from "@chatbotx.io/database/schema"
import {
getPaginationWithDefaults,
parseOrderByAsObject,
@@ -56,7 +64,28 @@ export const listOrganizationMembers = async (
),
])
+ const userIds = data.map((d) => d.userId)
+ const lastSessions = userIds.length
+ ? await db
+ .selectDistinctOn([sessionModel.userId], {
+ userId: sessionModel.userId,
+ updatedAt: sessionModel.updatedAt,
+ })
+ .from(sessionModel)
+ .where(inArray(sessionModel.userId, userIds))
+ .orderBy(sessionModel.userId, desc(sessionModel.updatedAt))
+ : []
+
+ const lastActiveByUserId = new Map(
+ lastSessions.map((s) => [s.userId, s.updatedAt]),
+ )
+
+ const dataWithLastActive = data.map((member) => ({
+ ...member,
+ lastActiveAt: lastActiveByUserId.get(member.userId) ?? null,
+ }))
+
const pageCount = Math.ceil(totalRows / pagination.limit)
- return { data, pageCount, ...pagination }
+ return { data: dataWithLastActive, pageCount, ...pagination }
}
diff --git a/apps/builder/src/enterprise/features/organization-members/schema/mutation.ts b/apps/builder/src/enterprise/features/organization-members/schema/mutation.ts
index dea8da49f..4bd3ee98e 100644
--- a/apps/builder/src/enterprise/features/organization-members/schema/mutation.ts
+++ b/apps/builder/src/enterprise/features/organization-members/schema/mutation.ts
@@ -12,7 +12,7 @@ import { organizationMemberResource } from "./resource"
export const listOrganizationMembersSearchParams = {
page: parseAsInteger,
perPage: parseAsInteger,
- name: parseAsString,
+ keyword: parseAsString,
sort: getSortingStateParser<
ListOrganizationMembersResponse["data"][number]
>().withDefault([{ id: "createdAt", desc: true }]),
@@ -32,7 +32,12 @@ export type ListOrganizationMembersRequest = Awaited<
export const listOrganizationMembersResponse = z.object({
data: z.array(
- organizationMemberResource.and(z.object({ user: userResource })),
+ organizationMemberResource.and(
+ z.object({
+ user: userResource,
+ lastActiveAt: z.date().nullable(),
+ }),
+ ),
),
pageCount: z.number(),
})
diff --git a/apps/builder/src/enterprise/features/organization-members/schema/resource.ts b/apps/builder/src/enterprise/features/organization-members/schema/resource.ts
index ba5a2e942..44673fbcb 100644
--- a/apps/builder/src/enterprise/features/organization-members/schema/resource.ts
+++ b/apps/builder/src/enterprise/features/organization-members/schema/resource.ts
@@ -8,6 +8,8 @@ export const organizationMemberResource = createSelectSchema(
organizationMemberModel,
{
id: z.string(),
+ userId: z.string(),
+ organizationId: z.string(),
},
)
export const OrganizationMemberResource = organizationMemberResource
diff --git a/apps/builder/src/enterprise/features/organization-members/schema/upsert.ts b/apps/builder/src/enterprise/features/organization-members/schema/upsert.ts
new file mode 100644
index 000000000..c1b5f5058
--- /dev/null
+++ b/apps/builder/src/enterprise/features/organization-members/schema/upsert.ts
@@ -0,0 +1,37 @@
+import {
+ organizationMemberRoles,
+ workspaceMemberRoles,
+} from "@chatbotx.io/database/partials"
+import { z } from "zod"
+
+export const workspaceAssignmentSchema = z.object({
+ workspaceId: z.string().min(1),
+ role: workspaceMemberRoles,
+})
+export type WorkspaceAssignment = z.infer
+
+export const upsertOrganizationUserSchema = z.object({
+ email: z.email().transform((v) => v.trim().toLowerCase()),
+ name: z.string().trim().max(100).optional().default(""),
+ organizationRole: organizationMemberRoles,
+ workspaceAssignments: z.array(workspaceAssignmentSchema).default([]),
+})
+export type UpsertOrganizationUserSchema = z.infer<
+ typeof upsertOrganizationUserSchema
+>
+
+export const editOrganizationUserSchema = z.object({
+ userId: z.string().min(1),
+ organizationRole: organizationMemberRoles,
+ workspaceAssignments: z.array(workspaceAssignmentSchema).default([]),
+})
+export type EditOrganizationUserSchema = z.infer<
+ typeof editOrganizationUserSchema
+>
+
+export const deleteOrganizationUserSchema = z.object({
+ userId: z.string().min(1),
+})
+export type DeleteOrganizationUserSchema = z.infer<
+ typeof deleteOrganizationUserSchema
+>
diff --git a/apps/builder/src/features/account/actions/update-account-action.ts b/apps/builder/src/features/account/actions/update-account-action.ts
new file mode 100644
index 000000000..b9bfee51d
--- /dev/null
+++ b/apps/builder/src/features/account/actions/update-account-action.ts
@@ -0,0 +1,34 @@
+"use server"
+
+import { db, eq } from "@chatbotx.io/database/client"
+import { userModel } from "@chatbotx.io/database/schema"
+import { revalidatePath } from "next/cache"
+import { authActionClient } from "@/lib/safe-action"
+import {
+ type UpdateAccountRequest,
+ updateAccountSchema,
+} from "../schema/update-account-schema"
+
+export const updateAccountAction = authActionClient
+ .inputSchema(updateAccountSchema)
+ .action(
+ async ({
+ parsedInput,
+ ctx,
+ }: {
+ parsedInput: UpdateAccountRequest
+ ctx: { user: { id: string } }
+ }) => {
+ const fullName = [
+ parsedInput.firstName.trim(),
+ parsedInput.lastName?.trim(),
+ ]
+ .filter(Boolean)
+ .join(" ")
+ await db
+ .update(userModel)
+ .set({ name: fullName })
+ .where(eq(userModel.id, ctx.user.id))
+ revalidatePath("/space", "layout")
+ },
+ )
diff --git a/apps/builder/src/features/account/activity-status.tsx b/apps/builder/src/features/account/activity-status.tsx
new file mode 100644
index 000000000..01f8eba50
--- /dev/null
+++ b/apps/builder/src/features/account/activity-status.tsx
@@ -0,0 +1,133 @@
+"use client"
+
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@chatbotx.io/ui/components/ui/dropdown-menu"
+import { cn } from "@chatbotx.io/ui/lib/utils"
+import { useTranslations } from "next-intl"
+import { type ReactNode, useState } from "react"
+import { toast } from "sonner"
+import { authClient } from "@/lib/auth/auth-client"
+
+export type ActivityStatus = "available" | "busy" | "offline"
+
+export const ACTIVITY_STATUSES: ActivityStatus[] = [
+ "available",
+ "busy",
+ "offline",
+]
+
+const STATUS_COLOR: Record = {
+ available: "bg-emerald-500",
+ busy: "bg-amber-500",
+ offline: "bg-zinc-400",
+}
+
+const STATUS_RING: Record = {
+ available: "ring-emerald-500/30",
+ busy: "ring-amber-500/30",
+ offline: "ring-zinc-400/30",
+}
+
+export function isActivityStatus(value: unknown): value is ActivityStatus {
+ return (
+ typeof value === "string" && (ACTIVITY_STATUSES as string[]).includes(value)
+ )
+}
+
+export function getActivityStatusColor(status: ActivityStatus): string {
+ return STATUS_COLOR[status]
+}
+
+export function ActivityStatusDot({
+ status,
+ className,
+ withRing = false,
+}: {
+ status: ActivityStatus
+ className?: string
+ withRing?: boolean
+}) {
+ return (
+
+ )
+}
+
+type ActivityStatusSelectorProps = {
+ current: ActivityStatus
+ /** Custom trigger; defaults to pill button "● Available". */
+ trigger?: ReactNode
+ /** Where the dropdown opens relative to trigger. */
+ side?: "top" | "right" | "bottom" | "left"
+ align?: "start" | "center" | "end"
+}
+
+export function ActivityStatusSelector({
+ current,
+ trigger,
+ side = "bottom",
+ align = "start",
+}: ActivityStatusSelectorProps) {
+ const t = useTranslations("personalSettings.activityStatus")
+ const [pending, setPending] = useState(false)
+
+ const handlePick = async (next: ActivityStatus) => {
+ if (next === current || pending) {
+ return
+ }
+ setPending(true)
+ const { error } = await authClient.updateUser({
+ activityStatus: next,
+ } as unknown as Parameters[0])
+ setPending(false)
+ if (error) {
+ toast.error(error.message ?? t("updateError"))
+ return
+ }
+ toast.success(t("updateSuccess", { status: t(next) }))
+ }
+
+ return (
+
+
+ {trigger ?? (
+
+
+ {t(current)}
+
+ )}
+
+
+ {ACTIVITY_STATUSES.map((s) => (
+ handlePick(s)}
+ >
+
+ {t(s)}
+ {s === current && (
+ •
+ )}
+
+ ))}
+
+
+ )
+}
diff --git a/apps/builder/src/features/account/personal-settings-sidebar.tsx b/apps/builder/src/features/account/personal-settings-sidebar.tsx
new file mode 100644
index 000000000..4bbd66257
--- /dev/null
+++ b/apps/builder/src/features/account/personal-settings-sidebar.tsx
@@ -0,0 +1,66 @@
+"use client"
+
+import { cn } from "@chatbotx.io/ui/lib/utils"
+import { Notification, UserSquare } from "iconsax-reactjs"
+import Link from "next/link"
+import { useParams, usePathname } from "next/navigation"
+import { useTranslations } from "next-intl"
+import { type IconsaxComponent, wrapIconsax } from "@/components/iconsax-icon"
+
+const ProfileIcon = wrapIconsax(UserSquare as IconsaxComponent)
+const BellIcon = wrapIconsax(Notification as IconsaxComponent)
+
+export function PersonalSettingsSidebar() {
+ const t = useTranslations("personalSettings")
+ const pathname = usePathname()
+ const params = useParams()
+ const workspaceId = params.workspaceId as string
+
+ const base = `/space/${workspaceId}/account`
+
+ const items = [
+ {
+ label: t("profile"),
+ href: base,
+ icon: ProfileIcon,
+ active:
+ pathname === base ||
+ (pathname.startsWith(`${base}/`) &&
+ !pathname.startsWith(`${base}/notifications`)),
+ },
+ {
+ label: t("notifications"),
+ href: `${base}/notifications`,
+ icon: BellIcon,
+ active: pathname.startsWith(`${base}/notifications`),
+ },
+ ]
+
+ return (
+
+
+
{t("title")}
+
+
+ {items.map((item) => {
+ const Icon = item.icon
+ return (
+
+
+ {item.label}
+
+ )
+ })}
+
+
+ )
+}
diff --git a/apps/builder/src/features/account/profile-form.tsx b/apps/builder/src/features/account/profile-form.tsx
new file mode 100644
index 000000000..ef16875f6
--- /dev/null
+++ b/apps/builder/src/features/account/profile-form.tsx
@@ -0,0 +1,288 @@
+"use client"
+
+import { InputField } from "@chatbotx.io/ui/components/form/input-field"
+import { SelectField } from "@chatbotx.io/ui/components/form/select-field"
+import {
+ Avatar,
+ AvatarFallback,
+ AvatarImage,
+} from "@chatbotx.io/ui/components/ui/avatar"
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import { Form } from "@chatbotx.io/ui/components/ui/form"
+import { Label } from "@chatbotx.io/ui/components/ui/label"
+import { Switch } from "@chatbotx.io/ui/components/ui/switch"
+import { DirectUploadButton } from "@chatbotx.io/ui/components/uploader/direct-upload-button"
+import { cn } from "@chatbotx.io/ui/lib/utils"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { CameraIcon, KeyRoundIcon, Loader2Icon } from "lucide-react"
+import Link from "next/link"
+import { useRouter } from "next/navigation"
+import { useLocale, useTranslations } from "next-intl"
+import { useTheme } from "next-themes"
+import { useRef, useTransition } from "react"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+import type { Locale } from "@/i18n/config"
+import { authClient } from "@/lib/auth/auth-client"
+import { setUserLocale } from "@/lib/locale"
+import {
+ type ActivityStatus,
+ ActivityStatusSelector,
+ isActivityStatus,
+} from "./activity-status"
+import {
+ type UpdateAccountRequest,
+ updateAccountSchema,
+} from "./schema/update-account-schema"
+
+export function ProfileForm() {
+ const t = useTranslations("personalSettings.profileSection")
+ const tFields = useTranslations("fields")
+ const session = authClient.useSession()
+ const user = session?.data?.user
+ const router = useRouter()
+
+ const currentLocale = useLocale()
+ const [, startTransition] = useTransition()
+
+ const uploadTriggerRef = useRef(null)
+
+ const initialFirst = (user?.name ?? "").split(" ")[0] ?? ""
+ const initialLast = (user?.name ?? "").split(" ").slice(1).join(" ")
+
+ const form = useForm({
+ // biome-ignore lint/suspicious/noExplicitAny: zod default conflict with RHF input/output types
+ resolver: zodResolver(updateAccountSchema) as any,
+ mode: "onChange",
+ defaultValues: {
+ firstName: initialFirst,
+ lastName: initialLast,
+ locale: (currentLocale as Locale) ?? "en",
+ },
+ values: user
+ ? {
+ firstName: initialFirst,
+ lastName: initialLast,
+ locale: (currentLocale as Locale) ?? "en",
+ }
+ : undefined,
+ })
+
+ const onSubmit = form.handleSubmit(async (values) => {
+ const fullName = [values.firstName.trim(), values.lastName?.trim()]
+ .filter(Boolean)
+ .join(" ")
+ const { error } = await authClient.updateUser({ name: fullName })
+ if (error) {
+ toast.error(error.message ?? "")
+ return
+ }
+ if (values.locale && values.locale !== currentLocale) {
+ startTransition(() => {
+ setUserLocale(values.locale as Locale)
+ })
+ }
+ toast.success(t("savedSuccess"))
+ })
+
+ return (
+
+
+
+ {/* Avatar + status à esquerda */}
+
+
uploadTriggerRef.current?.click()}
+ title={t("changeAvatar")}
+ type="button"
+ >
+
+
+
+ {(user?.name ?? "?").slice(0, 2).toUpperCase()}
+
+
+
+
+ {t("changeAvatar")}
+
+
+
toast.error(error.message)}
+ onUploadSuccess={async (_filePath, _file, finalUrl) => {
+ const { error } = await authClient.updateUser({
+ image: finalUrl,
+ })
+ if (error) {
+ toast.error(error.message ?? "")
+ return
+ }
+ toast.success(t("avatarUpdated"))
+ router.refresh()
+ }}
+ triggerRef={uploadTriggerRef}
+ uploadPath={user ? `public/user/${user.id}/avatar` : undefined}
+ />
+
+
+
+ {/* Campos à direita */}
+
+
+ {tFields("email.label")}
+
+
+
+
+
+
+
+
+
+
+
+
+ {t("changePassword")}
+
+
+
+ {form.formState.isSubmitting && (
+
+ )}
+ {t("saveProfile")}
+
+
+
+
+
+ {/* 2FA */}
+
+
+
+ {t("twoFA.title")}
+
+
+
+ {t("twoFA.toggle")}
+
+
+
+ {/* Modo escuro */}
+
+
+
+
+ )
+}
+
+// Helper top-level pra evitar nested ternary (regra biome noNestedTernary).
+function getThemePreviewBg(opt: {
+ isDark: boolean
+ isSplit?: boolean
+}): string {
+ if (opt.isSplit) {
+ return "bg-gradient-to-r from-50% from-white to-50% to-zinc-900"
+ }
+ return opt.isDark ? "bg-zinc-900" : "bg-white"
+}
+
+function DarkModeCards() {
+ const t = useTranslations("personalSettings.profileSection.darkMode")
+ const { theme, setTheme } = useTheme()
+ const active = theme ?? "system"
+
+ const options: {
+ value: "system" | "light" | "dark"
+ label: string
+ isDark: boolean
+ isSplit?: boolean
+ }[] = [
+ { value: "system", label: t("system"), isDark: false, isSplit: true },
+ { value: "light", label: t("light"), isDark: false },
+ { value: "dark", label: t("dark"), isDark: true },
+ ]
+
+ return (
+
+
{t("title")}
+
+ {options.map((opt) => (
+
setTheme(opt.value)}
+ type="button"
+ >
+
+ {/* placeholder UI */}
+
+
+
+
+
+
+
+
+ {opt.label}
+
+
+ ))}
+
+
+ )
+}
diff --git a/apps/builder/src/features/account/schema/update-account-schema.ts b/apps/builder/src/features/account/schema/update-account-schema.ts
new file mode 100644
index 000000000..dd8de3108
--- /dev/null
+++ b/apps/builder/src/features/account/schema/update-account-schema.ts
@@ -0,0 +1,9 @@
+import { z } from "zod"
+
+export const updateAccountSchema = z.object({
+ firstName: z.string().trim().min(1).max(100),
+ lastName: z.string().trim().max(100).default(""),
+ locale: z.enum(["en", "vi", "pt-BR"]).optional(),
+})
+
+export type UpdateAccountRequest = z.infer
diff --git a/apps/builder/src/features/ai-agents/api/index.ts b/apps/builder/src/features/ai-agents/api/index.ts
index 985543635..f454e95d2 100644
--- a/apps/builder/src/features/ai-agents/api/index.ts
+++ b/apps/builder/src/features/ai-agents/api/index.ts
@@ -8,8 +8,8 @@ const listAIAgentsAPI = authorizedAPI
.route({
method: "GET",
path: "/workspaces/{workspaceId}/ai-agents",
- summary: "List AI agents",
- tags: ["AI"],
+ summary: "Listar agentes de IA",
+ tags: ["IA"],
})
.input(listAIAgentsRequest.and(withWorkspaceIdSchema))
.use(workspaceAuthorizedMidddleware, (input) => input.workspaceId)
diff --git a/apps/builder/src/features/ai-agents/delete-ai-agent.tsx b/apps/builder/src/features/ai-agents/delete-ai-agent.tsx
index 5017ab4a8..a6214274e 100644
--- a/apps/builder/src/features/ai-agents/delete-ai-agent.tsx
+++ b/apps/builder/src/features/ai-agents/delete-ai-agent.tsx
@@ -93,7 +93,7 @@ export function DeleteAIAgentsDialog({
execute({ ids: (agents ?? []).map((agent) => agent.id) })
diff --git a/apps/builder/src/features/ai-agents/table-columns.tsx b/apps/builder/src/features/ai-agents/table-columns.tsx
index e7e408269..125c1d1e1 100644
--- a/apps/builder/src/features/ai-agents/table-columns.tsx
+++ b/apps/builder/src/features/ai-agents/table-columns.tsx
@@ -53,7 +53,7 @@ export function getAIAgentsColumns({
{
accessorKey: "name",
header: ({ column }) => (
-
+
),
cell: ({ row }) => (
@@ -85,7 +85,7 @@ export function getAIAgentsColumns({
{
accessorKey: "modified",
header: ({ column }) => (
-
+
),
cell: ({ row }) => (
@@ -100,12 +100,12 @@ export function getAIAgentsColumns({
},
{
id: "actions",
- header: "Actions",
+ header: "Ações",
cell: ({ row }) => (
diff --git a/apps/builder/src/features/ai-files/ai-files-table.tsx b/apps/builder/src/features/ai-files/ai-files-table.tsx
index 600335518..6fb86bf3b 100644
--- a/apps/builder/src/features/ai-files/ai-files-table.tsx
+++ b/apps/builder/src/features/ai-files/ai-files-table.tsx
@@ -201,7 +201,7 @@ export default function AIFilesTable({ promises }: AIFilesTableProps) {
),
cell: ({ row }) => (
- {format(row.original.createdAt, "MMM dd, yyyy")}
+ {format(row.original.createdAt, "dd/MM/yyyy")}
),
enableSorting: true,
diff --git a/apps/builder/src/features/ai-files/api/authenticated.ts b/apps/builder/src/features/ai-files/api/authenticated.ts
index c9f3b73bc..36a4bec9a 100644
--- a/apps/builder/src/features/ai-files/api/authenticated.ts
+++ b/apps/builder/src/features/ai-files/api/authenticated.ts
@@ -8,8 +8,8 @@ export const aiFileAuthenticatedAPI = {
.route({
method: "GET",
path: "/workspaces/{workspaceId}/ai-files",
- summary: "List AI files",
- tags: ["AI Files"],
+ summary: "Listar arquivos de IA",
+ tags: ["Arquivos de IA"],
})
.input(listAIFilesRequest)
.use(workspaceAuthorizedMidddleware, (input) => input.workspaceId)
diff --git a/apps/builder/src/features/ai-files/delete-ai-file-dialog.tsx b/apps/builder/src/features/ai-files/delete-ai-file-dialog.tsx
index 24179bb9c..5c5234114 100644
--- a/apps/builder/src/features/ai-files/delete-ai-file-dialog.tsx
+++ b/apps/builder/src/features/ai-files/delete-ai-file-dialog.tsx
@@ -85,7 +85,7 @@ export function DeleteAIFileDialog({
{t("actions.cancel")}
execute()}
variant="destructive"
diff --git a/apps/builder/src/features/ai-functions/ai-functions-columns.tsx b/apps/builder/src/features/ai-functions/ai-functions-columns.tsx
index b477498e0..598cce071 100644
--- a/apps/builder/src/features/ai-functions/ai-functions-columns.tsx
+++ b/apps/builder/src/features/ai-functions/ai-functions-columns.tsx
@@ -38,7 +38,7 @@ export const getAIFunctionsColumns = (
id: "select",
header: ({ table: innerTable }) => (
(
row.toggleSelected(Boolean(value))}
/>
diff --git a/apps/builder/src/features/ai-functions/ai-functions-create.tsx b/apps/builder/src/features/ai-functions/ai-functions-create.tsx
index 257b2d5f8..43ad003d7 100644
--- a/apps/builder/src/features/ai-functions/ai-functions-create.tsx
+++ b/apps/builder/src/features/ai-functions/ai-functions-create.tsx
@@ -181,7 +181,7 @@ export function AIFunctionsCreate({
diff --git a/apps/builder/src/features/ai-functions/ai-functions-table.tsx b/apps/builder/src/features/ai-functions/ai-functions-table.tsx
index ad24749b9..713e64a7b 100644
--- a/apps/builder/src/features/ai-functions/ai-functions-table.tsx
+++ b/apps/builder/src/features/ai-functions/ai-functions-table.tsx
@@ -2,13 +2,6 @@
import { DataTable } from "@chatbotx.io/ui/components/data-table/data-table"
import { DataTableToolbar } from "@chatbotx.io/ui/components/data-table/data-table-toolbar"
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from "@chatbotx.io/ui/components/ui/card"
import { useDataTable } from "@chatbotx.io/ui/hooks/use-data-table"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
@@ -52,44 +45,37 @@ export function AIFunctionsTable({
clearOnDefault: true,
})
+ // Sem Card wrapper — Pedro pediu UI flat estilo Respond.io (2026-05-24).
return (
-
-
-
- {t("aiFunctions.title")}
-
- {t("aiFunctions.description")}
-
-
-
-
-
-
-
+ <>
+
+
+
+
+
- !open && setRowAction(null)}
- onSuccess={() => {
- router.refresh()
- }}
- open={
- rowAction?.variant === "edit" || rowAction?.variant === "duplicate"
- }
- workspaceId={workspaceId}
- />
+ !open && setRowAction(null)}
+ onSuccess={() => {
+ router.refresh()
+ }}
+ open={
+ rowAction?.variant === "edit" || rowAction?.variant === "duplicate"
+ }
+ workspaceId={workspaceId}
+ />
- !open && setRowAction(null)}
- onSuccess={() => {
- router.refresh()
- }}
- open={rowAction?.variant === "delete"}
- workspaceId={workspaceId}
- />
-
-
+
!open && setRowAction(null)}
+ onSuccess={() => {
+ router.refresh()
+ }}
+ open={rowAction?.variant === "delete"}
+ workspaceId={workspaceId}
+ />
+ >
)
}
diff --git a/apps/builder/src/features/ai-functions/api/authenticated.ts b/apps/builder/src/features/ai-functions/api/authenticated.ts
index c657af50c..2d5ac512c 100644
--- a/apps/builder/src/features/ai-functions/api/authenticated.ts
+++ b/apps/builder/src/features/ai-functions/api/authenticated.ts
@@ -11,8 +11,8 @@ export const aiFunctionsAuthenticatedAPI = {
.route({
method: "GET",
path: "/workspaces/{workspaceId}/ai-functions",
- summary: "List AI functions",
- tags: ["AI Functions"],
+ summary: "Listar funções de IA",
+ tags: ["Funções de IA"],
})
.input(listAIFunctionsRequest)
.use(workspaceAuthorizedMidddleware, (input) => input.workspaceId)
diff --git a/apps/builder/src/features/ai-mcp-servers/ai-mcp-servers-create.tsx b/apps/builder/src/features/ai-mcp-servers/ai-mcp-servers-create.tsx
index 195880c5f..a056a5268 100644
--- a/apps/builder/src/features/ai-mcp-servers/ai-mcp-servers-create.tsx
+++ b/apps/builder/src/features/ai-mcp-servers/ai-mcp-servers-create.tsx
@@ -245,12 +245,12 @@ export function AIMcpServersCreate({
remove(index)}
diff --git a/apps/builder/src/features/ai-mcp-servers/api/authenticated.ts b/apps/builder/src/features/ai-mcp-servers/api/authenticated.ts
index 413b3e14f..8daa7e772 100644
--- a/apps/builder/src/features/ai-mcp-servers/api/authenticated.ts
+++ b/apps/builder/src/features/ai-mcp-servers/api/authenticated.ts
@@ -17,8 +17,8 @@ export const aiMcpServersAuthenticatedAPI = {
.route({
method: "POST",
path: "/workspaces/{workspaceId}/ai-mcp-servers/validate",
- summary: "Validate an MCP server",
- tags: ["AI"],
+ summary: "Validar servidor MCP",
+ tags: ["IA"],
})
.input(validateAIMcpServerRequest.and(withWorkspaceIdSchema))
.use(workspaceAuthorizedMidddleware, (input) => input.workspaceId)
@@ -36,8 +36,8 @@ export const aiMcpServersAuthenticatedAPI = {
.route({
method: "GET",
path: "/workspaces/{workspaceId}/ai-mcp-servers",
- summary: "List AI MCP servers",
- tags: ["AI MCP Servers"],
+ summary: "Listar servidores MCP de IA",
+ tags: ["Servidores MCP de IA"],
})
.input(listAIMcpServersRequest)
.use(workspaceAuthorizedMidddleware, (input) => input.workspaceId)
diff --git a/apps/builder/src/features/ai-tools/provider/ai-tools-store.ts b/apps/builder/src/features/ai-tools/provider/ai-tools-store.ts
index 1a284134b..238bb474f 100644
--- a/apps/builder/src/features/ai-tools/provider/ai-tools-store.ts
+++ b/apps/builder/src/features/ai-tools/provider/ai-tools-store.ts
@@ -43,7 +43,7 @@ export const createAIToolsStore = (props: Pick) =>
systemFunctions: [
{
id: systemFunctionNames.connectUserToHuman,
- name: "Connect to Human Agent",
+ name: "Conectar com Atendente Humano",
},
],
@@ -81,7 +81,7 @@ export const createAIToolsStore = (props: Pick) =>
error:
error instanceof HTTPError
? error.message
- : "Failed to fetch AI files",
+ : "Falha ao buscar arquivos de IA",
})
} finally {
set({ loadingAIFiles: false })
@@ -110,7 +110,7 @@ export const createAIToolsStore = (props: Pick) =>
error:
error instanceof HTTPError
? error.message
- : "Failed to fetch AI functions",
+ : "Falha ao buscar funções de IA",
})
} finally {
set({ loadingAIFunctions: false })
diff --git a/apps/builder/src/features/ai-triggers/delete.tsx b/apps/builder/src/features/ai-triggers/delete.tsx
index 95e825ef2..a8d72a97b 100644
--- a/apps/builder/src/features/ai-triggers/delete.tsx
+++ b/apps/builder/src/features/ai-triggers/delete.tsx
@@ -91,7 +91,7 @@ export function DeleteAITriggerDialog({
{
execute({
diff --git a/apps/builder/src/features/ai-triggers/table-columns.tsx b/apps/builder/src/features/ai-triggers/table-columns.tsx
index cc720e5e4..de7306d24 100644
--- a/apps/builder/src/features/ai-triggers/table-columns.tsx
+++ b/apps/builder/src/features/ai-triggers/table-columns.tsx
@@ -36,7 +36,7 @@ export function getAITriggersColumns({
id: "select",
header: ({ table }) => (
(
row.toggleSelected(Boolean(value))}
@@ -62,7 +62,7 @@ export function getAITriggersColumns({
{
accessorKey: "name",
header: ({ column }) => (
-
+
),
cell: ({ row }) => (
@@ -81,7 +81,7 @@ export function getAITriggersColumns({
{
accessorKey: "modified",
header: ({ column }) => (
-
+
),
cell: ({ row }) => (
@@ -96,12 +96,12 @@ export function getAITriggersColumns({
},
{
id: "actions",
- header: "Actions",
+ header: "Ações",
cell: ({ row }) => (
diff --git a/apps/builder/src/features/ai-triggers/table.tsx b/apps/builder/src/features/ai-triggers/table.tsx
index 99938a0c4..8cfcacab2 100644
--- a/apps/builder/src/features/ai-triggers/table.tsx
+++ b/apps/builder/src/features/ai-triggers/table.tsx
@@ -43,7 +43,7 @@ export function AITriggersTable({
if (rowAction && rowAction.variant === "duplicate") {
execute()
setRowAction(null)
- toast.success("Duplicate successfully!")
+ toast.success("Duplicado com sucesso!")
router.refresh()
}
}, [rowAction, execute, router])
diff --git a/apps/builder/src/features/audit-logs/audit-logs-table.tsx b/apps/builder/src/features/audit-logs/audit-logs-table.tsx
new file mode 100644
index 000000000..a87fae31d
--- /dev/null
+++ b/apps/builder/src/features/audit-logs/audit-logs-table.tsx
@@ -0,0 +1,375 @@
+"use client"
+
+import {
+ Avatar,
+ AvatarFallback,
+ AvatarImage,
+} from "@chatbotx.io/ui/components/ui/avatar"
+import { Badge } from "@chatbotx.io/ui/components/ui/badge"
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import { Input } from "@chatbotx.io/ui/components/ui/input"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@chatbotx.io/ui/components/ui/select"
+import { format } from "date-fns"
+import { ptBR } from "date-fns/locale"
+import {
+ ChevronLeftIcon,
+ ChevronRightIcon,
+ DownloadIcon,
+ SearchIcon,
+ ShieldCheckIcon,
+} from "lucide-react"
+import { useRouter, useSearchParams } from "next/navigation"
+import { useTranslations } from "next-intl"
+import { use, useMemo, useState, useTransition } from "react"
+import { toast } from "sonner"
+import type { ListWorkspaceMembersResponse } from "@/features/workspace-members/schema/query"
+import type { listAuditLogActions, listAuditLogs } from "./queries"
+import type { AuditLogResource } from "./schemas"
+
+type AuditLogsTableProps = {
+ workspaceId: string
+ promises: Promise<
+ [
+ Awaited>,
+ Awaited>,
+ Awaited<
+ ReturnType<
+ typeof import("@/features/workspace-members/queries").listWorkspaceMembers
+ >
+ >,
+ ]
+ >
+}
+
+const actionToBadge: Record = {
+ "team.created": {
+ label: "Equipe criada",
+ className: "bg-emerald-500/15 text-emerald-300",
+ },
+ "team.updated": {
+ label: "Equipe atualizada",
+ className: "bg-blue-500/15 text-blue-300",
+ },
+ "team.deleted": {
+ label: "Equipe excluída",
+ className: "bg-rose-500/15 text-rose-300",
+ },
+ "team.member.added": {
+ label: "Membro adicionado",
+ className: "bg-emerald-500/15 text-emerald-300",
+ },
+ "team.member.removed": {
+ label: "Membro removido",
+ className: "bg-amber-500/15 text-amber-300",
+ },
+ "user.invited": {
+ label: "Usuário convidado",
+ className: "bg-violet-500/15 text-violet-300",
+ },
+ "user.role.updated": {
+ label: "Nível alterado",
+ className: "bg-blue-500/15 text-blue-300",
+ },
+ "user.permissions.updated": {
+ label: "Permissões alteradas",
+ className: "bg-blue-500/15 text-blue-300",
+ },
+ "user.revoked": {
+ label: "Acesso revogado",
+ className: "bg-rose-500/15 text-rose-300",
+ },
+ "contact.created": {
+ label: "Contato criado",
+ className: "bg-emerald-500/15 text-emerald-300",
+ },
+ "contact.deleted": {
+ label: "Contato excluído",
+ className: "bg-rose-500/15 text-rose-300",
+ },
+ "contact.lifecycle.changed": {
+ label: "Ciclo de vida",
+ className: "bg-blue-500/15 text-blue-300",
+ },
+ "contact.blocked": {
+ label: "Contato bloqueado",
+ className: "bg-rose-500/15 text-rose-300",
+ },
+ "contact.unblocked": {
+ label: "Contato desbloqueado",
+ className: "bg-emerald-500/15 text-emerald-300",
+ },
+}
+
+function ActionBadge({ action }: { action: string }) {
+ const meta = actionToBadge[action]
+ if (meta) {
+ return (
+
+ {meta.label}
+
+ )
+ }
+ return {action}
+}
+
+function LogRow({ log }: { log: AuditLogResource }) {
+ return (
+
+
+
+
+
+
+ {(log.user?.name || log.user?.email || "?")
+ .charAt(0)
+ .toUpperCase()}
+
+
+
+
+ {log.user?.name ?? log.user?.email ?? "Sistema"}
+
+ {log.user?.email && (
+
+ {log.user.email}
+
+ )}
+
+
+
+
+
+
+ {log.detail}
+
+ {format(new Date(log.createdAt), "dd/MM/yyyy HH:mm", { locale: ptBR })}
+
+
+ )
+}
+
+export function AuditLogsTable({ workspaceId, promises }: AuditLogsTableProps) {
+ const t = useTranslations("auditLogs")
+ const router = useRouter()
+ const searchParams = useSearchParams()
+ const [{ data, pageCount, totalRows }, actions, workspaceMembers] =
+ use(promises)
+ const [, startTransition] = useTransition()
+ const [isExporting, setIsExporting] = useState(false)
+
+ const page = Number(searchParams.get("page") ?? "1")
+ const userIdFilter = searchParams.get("userId") ?? ""
+ const actionFilter = searchParams.get("action") ?? ""
+ const keyword = searchParams.get("keyword") ?? ""
+ const [keywordDraft, setKeywordDraft] = useState(keyword)
+
+ const members = (workspaceMembers as ListWorkspaceMembersResponse).data ?? []
+
+ const filtered = useMemo(() => {
+ if (!keyword.trim()) {
+ return data
+ }
+ const q = keyword.toLowerCase()
+ return data.filter(
+ (l) =>
+ l.detail.toLowerCase().includes(q) ||
+ l.action.toLowerCase().includes(q) ||
+ (l.user?.name ?? "").toLowerCase().includes(q) ||
+ (l.user?.email ?? "").toLowerCase().includes(q),
+ )
+ }, [data, keyword])
+
+ const updateParams = (patch: Record) => {
+ const params = new URLSearchParams(searchParams.toString())
+ for (const [k, v] of Object.entries(patch)) {
+ if (v === null || v === "") {
+ params.delete(k)
+ } else {
+ params.set(k, v)
+ }
+ }
+ startTransition(() => {
+ router.push(`?${params.toString()}`)
+ })
+ }
+
+ const handleKeywordSubmit = (e: React.FormEvent) => {
+ e.preventDefault()
+ updateParams({ keyword: keywordDraft || null, page: "1" })
+ }
+
+ const handleExport = async () => {
+ setIsExporting(true)
+ try {
+ const res = await fetch(
+ `/api/audit-logs/export?${searchParams.toString()}&workspaceId=${workspaceId}`,
+ )
+ if (!res.ok) {
+ throw new Error("Falha ao exportar")
+ }
+ const blob = await res.blob()
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement("a")
+ a.href = url
+ a.download = `logs-de-atividade-${format(new Date(), "yyyy-MM-dd")}.csv`
+ document.body.appendChild(a)
+ a.click()
+ a.remove()
+ URL.revokeObjectURL(url)
+ toast.success(t("exportSuccess"))
+ } catch {
+ toast.error(t("exportError"))
+ } finally {
+ setIsExporting(false)
+ }
+ }
+
+ return (
+
+
+
+
+ {t("title")}
+
+
{t("subtitle")}
+
+
+
+
+
+ setKeywordDraft(e.target.value)}
+ placeholder={t("searchPlaceholder")}
+ value={keywordDraft}
+ />
+
+
+
+ updateParams({ userId: v === "__all__" ? null : v, page: "1" })
+ }
+ value={userIdFilter || "__all__"}
+ >
+
+
+
+
+ {t("filterUserAll")}
+ {members.map((m) => (
+
+ {m.user.name || m.user.email}
+
+ ))}
+
+
+
+
+ updateParams({ action: v === "__all__" ? null : v, page: "1" })
+ }
+ value={actionFilter || "__all__"}
+ >
+
+
+
+
+ {t("filterActionAll")}
+ {actions.map((a) => (
+
+ {actionToBadge[a]?.label ?? a}
+
+ ))}
+
+
+
+
+
+
+ {t("exportCsv")}
+
+
+
+
+
+
+
+
+
+ {t("columns.user")}
+
+
+ {t("columns.action")}
+
+
+ {t("columns.detail")}
+
+
+ {t("columns.when")}
+
+
+
+
+ {filtered.length === 0 ? (
+
+
+ {t("empty")}
+
+
+ ) : (
+ filtered.map((log) => )
+ )}
+
+
+
+
+
+
{t("totalRows", { count: totalRows })}
+
+ updateParams({ page: String(page - 1) })}
+ size="sm"
+ type="button"
+ variant="ghost"
+ >
+
+
+
+ {page} / {Math.max(1, pageCount)}
+
+ = pageCount}
+ onClick={() => updateParams({ page: String(page + 1) })}
+ size="sm"
+ type="button"
+ variant="ghost"
+ >
+
+
+
+
+
+ )
+}
diff --git a/apps/builder/src/features/audit-logs/queries/index.ts b/apps/builder/src/features/audit-logs/queries/index.ts
new file mode 100644
index 000000000..211e42305
--- /dev/null
+++ b/apps/builder/src/features/audit-logs/queries/index.ts
@@ -0,0 +1,61 @@
+"use server"
+
+import { db, relationsFilterToSQL } from "@chatbotx.io/database/client"
+import { auditLogModel } from "@chatbotx.io/database/schema"
+import { getPaginationWithDefaults } from "@chatbotx.io/database/utils"
+import { assertCurrentUserCanAccessChatbot } from "@/lib/auth/utils"
+import type { AuditLogResource } from "../schemas"
+import type { ListAuditLogsRequest } from "../schemas/query"
+
+export type ListAuditLogsResponse = {
+ data: AuditLogResource[]
+ pageCount: number
+ totalRows: number
+}
+
+export async function listAuditLogs(
+ input: ListAuditLogsRequest,
+): Promise {
+ await assertCurrentUserCanAccessChatbot(input.workspaceId)
+
+ const where: {
+ workspaceId: string
+ userId?: string
+ action?: string
+ } = { workspaceId: input.workspaceId }
+ if (input.userId) {
+ where.userId = input.userId
+ }
+ if (input.action) {
+ where.action = input.action
+ }
+
+ const pagination = getPaginationWithDefaults(input)
+
+ const [data, totalRows] = await Promise.all([
+ db.query.auditLogModel.findMany({
+ where,
+ ...pagination,
+ orderBy: { createdAt: "desc" },
+ with: {
+ user: { columns: { id: true, name: true, email: true, image: true } },
+ },
+ }),
+ db.$count(auditLogModel, relationsFilterToSQL(auditLogModel, where)),
+ ])
+
+ const pageCount = Math.ceil(totalRows / pagination.limit)
+ return { data, pageCount, totalRows }
+}
+
+export async function listAuditLogActions(
+ workspaceId: string,
+): Promise {
+ await assertCurrentUserCanAccessChatbot(workspaceId)
+ const rows = await db
+ .selectDistinct({ action: auditLogModel.action })
+ .from(auditLogModel)
+ .where(relationsFilterToSQL(auditLogModel, { workspaceId } as never))
+ .orderBy(auditLogModel.action)
+ return rows.map((r) => r.action)
+}
diff --git a/apps/builder/src/enterprise/features/audit-logs/schemas/index.ts b/apps/builder/src/features/audit-logs/schemas/index.ts
similarity index 52%
rename from apps/builder/src/enterprise/features/audit-logs/schemas/index.ts
rename to apps/builder/src/features/audit-logs/schemas/index.ts
index cd50facdd..49b0d559b 100644
--- a/apps/builder/src/enterprise/features/audit-logs/schemas/index.ts
+++ b/apps/builder/src/features/audit-logs/schemas/index.ts
@@ -1,6 +1,10 @@
import type { AuditLogModel } from "@chatbotx.io/database/types"
-import type { UserResource } from "@/features/users/schemas/resource"
export type AuditLogResource = AuditLogModel & {
- user?: UserResource | null
+ user?: {
+ id: string
+ name: string | null
+ email: string
+ image: string | null
+ } | null
}
diff --git a/apps/builder/src/enterprise/features/audit-logs/schemas/query.ts b/apps/builder/src/features/audit-logs/schemas/query.ts
similarity index 88%
rename from apps/builder/src/enterprise/features/audit-logs/schemas/query.ts
rename to apps/builder/src/features/audit-logs/schemas/query.ts
index ff0567179..3270eb272 100644
--- a/apps/builder/src/enterprise/features/audit-logs/schemas/query.ts
+++ b/apps/builder/src/features/audit-logs/schemas/query.ts
@@ -8,12 +8,13 @@ import {
export const listAuditLogsSearchParamsCache = createSearchParamsCache({
page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
+ perPage: parseAsInteger.withDefault(25),
keyword: parseAsString.withDefault(""),
sort: getSortingStateParser().withDefault([
{ id: "createdAt", desc: true },
]),
userId: parseAsString.withDefault(""),
+ action: parseAsString.withDefault(""),
})
export type ListAuditLogsRequest = Awaited<
diff --git a/apps/builder/src/features/auth/components/email-password-sign-in.tsx b/apps/builder/src/features/auth/components/email-password-sign-in.tsx
index 81f43e658..ae218b902 100644
--- a/apps/builder/src/features/auth/components/email-password-sign-in.tsx
+++ b/apps/builder/src/features/auth/components/email-password-sign-in.tsx
@@ -44,7 +44,7 @@ export const EmailPasswordSignIn = () => {
})
if (data) {
- toast.success("Signed in successfully")
+ toast.success("Login realizado com sucesso")
redirect("/")
} else {
toast.error(error.message)
diff --git a/apps/builder/src/features/auth/components/magic-link-signin.tsx b/apps/builder/src/features/auth/components/magic-link-signin.tsx
index cf1b27bd5..4cf147793 100644
--- a/apps/builder/src/features/auth/components/magic-link-signin.tsx
+++ b/apps/builder/src/features/auth/components/magic-link-signin.tsx
@@ -32,7 +32,7 @@ export const MagicLinkSignIn = () => {
})
if (data) {
- toast.success("We sent verification URL to your email")
+ toast.success("Enviamos uma URL de verificação para seu e-mail")
redirect("/auth/magic-link-sent")
} else {
toast.error(error.message)
diff --git a/apps/builder/src/features/auth/schemas/action.ts b/apps/builder/src/features/auth/schemas/action.ts
index 9755eb7a8..7de8557ff 100644
--- a/apps/builder/src/features/auth/schemas/action.ts
+++ b/apps/builder/src/features/auth/schemas/action.ts
@@ -23,7 +23,7 @@ export const emailPasswordSignUpRequest = z
.refine(
(data) => data.password && data.password === data.passwordConfirmation,
{
- message: "Passwords do not match",
+ message: "As senhas não coincidem",
path: ["passwordConfirmation"],
},
)
@@ -46,7 +46,7 @@ export const resetPasswordRequest = z
(data) =>
data.newPassword && data.newPassword === data.passwordConfirmation,
{
- message: "Passwords do not match",
+ message: "As senhas não coincidem",
path: ["passwordConfirmation"],
},
)
diff --git a/apps/builder/src/features/automated-response/actions/create-automated-response-action.ts b/apps/builder/src/features/automated-response/actions/create-automated-response-action.ts
index 74da18ff8..4023a9970 100644
--- a/apps/builder/src/features/automated-response/actions/create-automated-response-action.ts
+++ b/apps/builder/src/features/automated-response/actions/create-automated-response-action.ts
@@ -42,9 +42,9 @@ export const createAutomatedResponseAction = workspaceActionClient
})
if (!exists) {
return returnValidationErrors(createAutomatedResponseRequest, {
- _errors: ["Validation Exception"],
+ _errors: ["Exceção de Validação"],
flowId: {
- _errors: ["Flow not found"],
+ _errors: ["Fluxo não encontrado"],
},
})
}
diff --git a/apps/builder/src/features/automated-response/actions/update-automated-response-action.ts b/apps/builder/src/features/automated-response/actions/update-automated-response-action.ts
index 72675362c..4f91b4b19 100644
--- a/apps/builder/src/features/automated-response/actions/update-automated-response-action.ts
+++ b/apps/builder/src/features/automated-response/actions/update-automated-response-action.ts
@@ -52,9 +52,9 @@ export const updateAutomatedResponse = async (
})
if (!exists) {
return returnValidationErrors(updateAutomatedResponseRequest, {
- _errors: ["Validation Exception"],
+ _errors: ["Exceção de Validação"],
flowId: {
- _errors: ["Flow not found"],
+ _errors: ["Fluxo não encontrado"],
},
})
}
diff --git a/apps/builder/src/features/automated-response/automated-response-table.tsx b/apps/builder/src/features/automated-response/automated-response-table.tsx
index e661f676f..8a2da1bfb 100644
--- a/apps/builder/src/features/automated-response/automated-response-table.tsx
+++ b/apps/builder/src/features/automated-response/automated-response-table.tsx
@@ -4,12 +4,6 @@ import { DataTable } from "@chatbotx.io/ui/components/data-table/data-table"
import { DataTableColumnHeader } from "@chatbotx.io/ui/components/data-table/data-table-column-header"
import { DataTableToolbar } from "@chatbotx.io/ui/components/data-table/data-table-toolbar"
import { Button } from "@chatbotx.io/ui/components/ui/button"
-import {
- Card,
- CardContent,
- CardHeader,
- CardTitle,
-} from "@chatbotx.io/ui/components/ui/card"
import { Checkbox } from "@chatbotx.io/ui/components/ui/checkbox"
import {
DropdownMenu,
@@ -69,7 +63,7 @@ export function AutomatedResponsesTable({
size: 32,
header: ({ table: innerTable }) => (
(
row.toggleSelected(Boolean(value))}
/>
@@ -196,7 +190,7 @@ export function AutomatedResponsesTable({
- Open menu
+ Abrir menu
@@ -245,49 +239,41 @@ export function AutomatedResponsesTable({
clearOnDefault: true,
})
+ // Sem Card wrapper — Pedro pediu UI flat estilo Respond.io (2026-05-24).
return (
-
-
-
- {t("keywords.title")}
-
-
-
-
-
-
-
-
-
+ <>
+
+
+
+
+
+
- setRowAction(null)}
- onSuccess={() => {
- router.refresh()
- }}
- open={rowAction?.variant === "delete"}
- showTrigger={false}
- workspaceId={workspaceId}
- />
+ setRowAction(null)}
+ onSuccess={() => {
+ router.refresh()
+ }}
+ open={rowAction?.variant === "delete"}
+ showTrigger={false}
+ workspaceId={workspaceId}
+ />
- setRowAction(null)}
- open={rowAction?.variant === "move"}
- workspaceId={workspaceId}
- />
-
-
+ setRowAction(null)}
+ open={rowAction?.variant === "move"}
+ workspaceId={workspaceId}
+ />
+ >
)
}
diff --git a/apps/builder/src/features/automated-response/delete-automated-response-dialog.tsx b/apps/builder/src/features/automated-response/delete-automated-response-dialog.tsx
index 3314c4656..7113104b7 100644
--- a/apps/builder/src/features/automated-response/delete-automated-response-dialog.tsx
+++ b/apps/builder/src/features/automated-response/delete-automated-response-dialog.tsx
@@ -95,7 +95,7 @@ export function DeleteAutomatedResponsesDialog({
execute({ ids: automatedResponses.map((f) => f.id) })
diff --git a/apps/builder/src/features/bot-fields/actions/create-bot-field.action.ts b/apps/builder/src/features/bot-fields/actions/create-bot-field.action.ts
index 10c89c18e..a33de3e75 100644
--- a/apps/builder/src/features/bot-fields/actions/create-bot-field.action.ts
+++ b/apps/builder/src/features/bot-fields/actions/create-bot-field.action.ts
@@ -1,6 +1,10 @@
"use server"
-import { botFieldService } from "@chatbotx.io/business"
+import {
+ auditLogActions,
+ botFieldService,
+ logAudit,
+} from "@chatbotx.io/business"
import { workspaceIdrequestParams } from "@/features/common/schemas"
import { workspaceActionClient } from "@/lib/safe-action"
import { createBotFieldRequest } from "../schemas/action"
@@ -12,7 +16,20 @@ export const createBotFieldAction = workspaceActionClient
const {
bindArgsParsedInputs: [workspaceId],
parsedInput,
+ ctx: { user },
} = props
- return await botFieldService.create({ workspaceId, data: parsedInput })
+ const result = await botFieldService.create({
+ workspaceId,
+ data: parsedInput,
+ })
+
+ await logAudit({
+ workspaceId,
+ userId: user.id,
+ action: auditLogActions.BOT_FIELD_CREATED,
+ detail: `Campo do bot "${parsedInput.name}" (${parsedInput.type}) criado`,
+ })
+
+ return result
})
diff --git a/apps/builder/src/features/bot-fields/actions/delete-bot-field.action.ts b/apps/builder/src/features/bot-fields/actions/delete-bot-field.action.ts
index 249672665..3eacedaf7 100644
--- a/apps/builder/src/features/bot-fields/actions/delete-bot-field.action.ts
+++ b/apps/builder/src/features/bot-fields/actions/delete-bot-field.action.ts
@@ -1,6 +1,11 @@
"use server"
-import { botFieldService } from "@chatbotx.io/business"
+import {
+ auditLogActions,
+ botFieldService,
+ logAudit,
+} from "@chatbotx.io/business"
+import { db } from "@chatbotx.io/database/client"
import {
bulkUpdateIdsRequest,
workspaceIdrequestParams,
@@ -14,7 +19,22 @@ export const deleteBotFieldsAction = workspaceActionClient
const {
bindArgsParsedInputs: [workspaceId],
parsedInput,
+ ctx: { user },
} = props
+ const names = await db.query.botFieldModel.findMany({
+ columns: { name: true },
+ where: { workspaceId, id: { in: parsedInput.ids } },
+ })
+
await botFieldService.bulkDelete({ workspaceId, ids: parsedInput.ids })
+
+ if (names.length > 0) {
+ await logAudit({
+ workspaceId,
+ userId: user.id,
+ action: auditLogActions.BOT_FIELD_DELETED,
+ detail: `Campo(s) do bot excluído(s): ${names.map((f) => `"${f.name}"`).join(", ")}`,
+ })
+ }
})
diff --git a/apps/builder/src/features/bot-fields/actions/update-bot-field.action.ts b/apps/builder/src/features/bot-fields/actions/update-bot-field.action.ts
index 586bcb9af..0764e7fcf 100644
--- a/apps/builder/src/features/bot-fields/actions/update-bot-field.action.ts
+++ b/apps/builder/src/features/bot-fields/actions/update-bot-field.action.ts
@@ -1,6 +1,10 @@
"use server"
-import { botFieldService } from "@chatbotx.io/business"
+import {
+ auditLogActions,
+ botFieldService,
+ logAudit,
+} from "@chatbotx.io/business"
import {
type WorkspaceIdAndIdRequestParams,
workspaceIdAndIdRequestParams,
@@ -18,13 +22,25 @@ export const updateBotFieldAction = workspaceActionClient
async ({
parsedInput,
bindArgsParsedInputs: [workspaceId, id],
+ ctx: { user },
}: {
parsedInput: UpdateBotFieldRequest
bindArgsParsedInputs: WorkspaceIdAndIdRequestParams
- }) =>
- await botFieldService.updateByKey({
+ ctx: { user: { id: string } }
+ }) => {
+ const updated = await botFieldService.updateByKey({
workspaceId,
key: id,
data: parsedInput,
- }),
+ })
+
+ await logAudit({
+ workspaceId,
+ userId: user.id,
+ action: auditLogActions.BOT_FIELD_UPDATED,
+ detail: `Campo do bot "${parsedInput.name ?? id}" atualizado`,
+ })
+
+ return updated
+ },
)
diff --git a/apps/builder/src/features/bot-fields/api/workspace-token.ts b/apps/builder/src/features/bot-fields/api/workspace-token.ts
index deb47544d..6d26ef005 100644
--- a/apps/builder/src/features/bot-fields/api/workspace-token.ts
+++ b/apps/builder/src/features/bot-fields/api/workspace-token.ts
@@ -16,8 +16,8 @@ const botFieldWorkspaceTokenAPIs = {
.route({
method: "GET",
path: "/v1/bot-fields",
- summary: "Get all bot fields",
- tags: ["Bot Fields"],
+ summary: "Obter todos os campos do bot",
+ tags: ["Campos do Bot"],
})
.input(z.object({}))
.output(publicListBotFieldsResponse)
@@ -38,9 +38,9 @@ const botFieldWorkspaceTokenAPIs = {
.route({
method: "POST",
path: "/v1/bot-fields",
- summary: "Create a new bot field",
+ summary: "Criar novo campo do bot",
successStatus: 201,
- tags: ["Bot Fields"],
+ tags: ["Campos do Bot"],
})
.input(createBotFieldRequest)
.output(publicBotFieldResource)
@@ -57,8 +57,8 @@ const botFieldWorkspaceTokenAPIs = {
.route({
method: "GET",
path: "/v1/bot-fields/{key}",
- summary: "Search bot field by id or name",
- tags: ["Bot Fields"],
+ summary: "Buscar campo do bot por ID ou nome",
+ tags: ["Campos do Bot"],
})
.input(z.object({ key: z.string().max(255) }))
.output(publicBotFieldResource)
@@ -75,8 +75,8 @@ const botFieldWorkspaceTokenAPIs = {
.route({
method: "PUT",
path: "/v1/bot-fields/{key}",
- summary: "Update bot field by id or name",
- tags: ["Bot Fields"],
+ summary: "Atualizar campo do bot por ID ou nome",
+ tags: ["Campos do Bot"],
})
.input(z.object({ key: z.string().max(255), value: z.string().max(255) }))
.output(publicBotFieldResource)
@@ -94,9 +94,9 @@ const botFieldWorkspaceTokenAPIs = {
.route({
method: "DELETE",
path: "/v1/bot-fields/{key}",
- summary: "Unset the value of the bot field by id or name",
+ summary: "Limpar valor do campo do bot por ID ou nome",
successStatus: 204,
- tags: ["Bot Fields"],
+ tags: ["Campos do Bot"],
})
.input(z.object({ key: z.string().max(255) }))
.errors(posibleErrorsOnDeletingResource)
diff --git a/apps/builder/src/features/bot-fields/bot-field-table.tsx b/apps/builder/src/features/bot-fields/bot-field-table.tsx
index 295d6a1e4..b508daf0e 100644
--- a/apps/builder/src/features/bot-fields/bot-field-table.tsx
+++ b/apps/builder/src/features/bot-fields/bot-field-table.tsx
@@ -5,12 +5,6 @@ import { DataTable } from "@chatbotx.io/ui/components/data-table/data-table"
import { DataTableColumnHeader } from "@chatbotx.io/ui/components/data-table/data-table-column-header"
import { DataTableToolbar } from "@chatbotx.io/ui/components/data-table/data-table-toolbar"
import { Button } from "@chatbotx.io/ui/components/ui/button"
-import {
- Card,
- CardContent,
- CardHeader,
- CardTitle,
-} from "@chatbotx.io/ui/components/ui/card"
import { Checkbox } from "@chatbotx.io/ui/components/ui/checkbox"
import {
DropdownMenu,
@@ -35,7 +29,7 @@ import {
Trash2Icon,
} from "lucide-react"
import { useRouter } from "next/navigation"
-import { useTranslations } from "next-intl"
+import { useLocale, useTranslations } from "next-intl"
import { use, useMemo, useState } from "react"
import { useCopyToClipboard } from "usehooks-ts"
import CustomFieldTypeLabel from "../custom-fields/components/custom-field-label"
@@ -50,6 +44,26 @@ type FieldsTableProps = {
promises: Promise<[{ data: BotFieldResource[]; pageCount: number }]>
}
+// Formata datas no padrão Respond.io ("abr 21, 2026"). Função pura no
+// top-level pra não invalidar o useMemo das columns a cada render.
+function formatRespondDate(
+ value: Date | string | null,
+ locale: string,
+): string {
+ if (!value) {
+ return "—"
+ }
+ const d = typeof value === "string" ? new Date(value) : value
+ if (Number.isNaN(d.getTime())) {
+ return "—"
+ }
+ return new Intl.DateTimeFormat(locale, {
+ month: "short",
+ day: "2-digit",
+ year: "numeric",
+ }).format(d)
+}
+
export function BotFieldsTable({
workspaceId,
folderId,
@@ -57,6 +71,7 @@ export function BotFieldsTable({
}: FieldsTableProps) {
const [{ data, pageCount }] = use(promises)
const router = useRouter()
+ const locale = useLocale()
const [rowAction, setRowAction] =
useState | null>(null)
@@ -69,7 +84,7 @@ export function BotFieldsTable({
id: "select",
header: ({ table: innerTable }) => (
(
row.toggleSelected(Boolean(value))}
/>
@@ -94,12 +109,17 @@ export function BotFieldsTable({
id: "name",
accessorKey: "name",
header: ({ column }) => (
-
+
),
cell: ({ row }) => (
- {row.original.name}
+
+ {row.original.name}
+
{row.original.name}
@@ -109,37 +129,75 @@ export function BotFieldsTable({
enableSorting: true,
enableHiding: false,
meta: {
- placeholder: "Search name...",
+ label: t("fields.name.label"),
+ placeholder: t("fields.name.searchPlaceholder"),
variant: "text",
icon: TextIcon,
},
enableColumnFilter: true,
},
{
- id: "description",
- accessorKey: "description",
+ id: "fieldId",
+ accessorKey: "id",
header: ({ column }) => (
-
+
),
cell: ({ row }) => (
-
- {row.original.description}
-
+
+ {row.original.id}
+
- {row.original.description}
+ {row.original.id}
),
+ size: 140,
+ enableSorting: false,
+ enableHiding: false,
+ },
+ {
+ id: "description",
+ accessorKey: "description",
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => {
+ const description = row.original.description
+ if (!description) {
+ return —
+ }
+ return (
+
+
+
+ {description}
+
+
+
+ {description}
+
+
+ )
+ },
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "type",
header: ({ column }) => (
-
+
),
cell: ({ row }) => (
@@ -149,30 +207,57 @@ export function BotFieldsTable({
{
accessorKey: "value",
header: ({ column }) => (
-
- ),
- cell: ({ row }) => (
-
-
- {row.original.value}
-
-
- {row.original.value}
-
-
+
),
+ cell: ({ row }) => {
+ if (!row.original.value) {
+ return —
+ }
+ return (
+
+
+
+ {row.original.value}
+
+
+
+ {row.original.value}
+
+
+ )
+ },
enableHiding: false,
enableSorting: false,
},
+ {
+ id: "addedAt",
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => (
+
+ {formatRespondDate(row.original.createdAt, locale)}
+
+ ),
+ size: 140,
+ enableSorting: true,
+ },
{
id: "actions",
- header: "Actions",
+ header: t("actions.actions"),
cell: ({ row }) => (
- Open menu
+ Abrir menu
@@ -204,7 +289,7 @@ export function BotFieldsTable({
enableHiding: false,
},
],
- [copyToClipboard, t],
+ [copyToClipboard, t, locale],
)
const { table } = useDataTable({
@@ -220,47 +305,49 @@ export function BotFieldsTable({
clearOnDefault: true,
})
+ // Header pixel-perfect Respond.io (Camada 2 — Dados Mestres). UI flat sem
+ // Card wrapper (regra Pedro 2026-05-23).
return (
-
-
-
- {t("fields.botField.label")}
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
setRowAction(null)}
- onSuccess={() => {
- rowAction?.row.toggleSelected(false)
- router.refresh()
- }}
- open={rowAction?.variant === "delete"}
- records={rowAction?.row.original ? [rowAction?.row.original] : []}
- showTrigger={false}
- workspaceId={workspaceId}
- />
+ setRowAction(null)}
+ onSuccess={() => {
+ rowAction?.row.toggleSelected(false)
+ router.refresh()
+ }}
+ open={rowAction?.variant === "delete"}
+ records={rowAction?.row.original ? [rowAction?.row.original] : []}
+ showTrigger={false}
+ workspaceId={workspaceId}
+ />
- setRowAction(null)}
- onSuccess={() => {
- router.refresh()
- }}
- open={rowAction?.variant === "update"}
- workspaceId={workspaceId}
- />
-
-
+ setRowAction(null)}
+ onSuccess={() => {
+ router.refresh()
+ }}
+ open={rowAction?.variant === "update"}
+ workspaceId={workspaceId}
+ />
+
)
}
diff --git a/apps/builder/src/features/bot-fields/delete-bot-fields-dialog.tsx b/apps/builder/src/features/bot-fields/delete-bot-fields-dialog.tsx
index 034e2338c..3cb6a3bd4 100644
--- a/apps/builder/src/features/bot-fields/delete-bot-fields-dialog.tsx
+++ b/apps/builder/src/features/bot-fields/delete-bot-fields-dialog.tsx
@@ -88,7 +88,7 @@ export function DeleteBotFieldsDialog({
execute({ ids: records.map((f) => f.id) })}
size="sm"
diff --git a/apps/builder/src/features/broadcasts/actions/create-broadcast.action.ts b/apps/builder/src/features/broadcasts/actions/create-broadcast.action.ts
index 3bb511177..93cfde886 100644
--- a/apps/builder/src/features/broadcasts/actions/create-broadcast.action.ts
+++ b/apps/builder/src/features/broadcasts/actions/create-broadcast.action.ts
@@ -28,9 +28,9 @@ export const createBroadcastAction = workspaceActionClient
})
if (!flow) {
return returnValidationErrors(createBroadcastRequest, {
- _errors: ["Validation Exception"],
+ _errors: ["Exceção de Validação"],
flowId: {
- _errors: ["Flow not found"],
+ _errors: ["Fluxo não encontrado"],
},
})
}
@@ -49,7 +49,7 @@ export const createBroadcastAction = workspaceActionClient
})
if (!template) {
return returnValidationErrors(createBroadcastRequest, {
- _errors: ["Validation Exception"],
+ _errors: ["Exceção de Validação"],
templateId: {
_errors: ["Template not found"],
},
diff --git a/apps/builder/src/features/broadcasts/api/private.ts b/apps/builder/src/features/broadcasts/api/private.ts
index 40090533c..c141af60e 100644
--- a/apps/builder/src/features/broadcasts/api/private.ts
+++ b/apps/builder/src/features/broadcasts/api/private.ts
@@ -25,7 +25,7 @@ export const broadcastPrivateAPIs = {
.route({
method: "POST",
path: "/workspaces/{workspaceId}/broadcasts/stats",
- summary: "Get batch broadcast stats",
+ summary: "Obter estatísticas de broadcast em lote",
tags: ["Broadcasts"],
})
.input(getBatchBroadcastStatsRequest)
@@ -43,7 +43,7 @@ export const broadcastPrivateAPIs = {
.route({
method: "GET",
path: "/workspaces/{workspaceId}/broadcasts/{broadcastId}/contacts",
- summary: "List broadcast contacts by event type",
+ summary: "Listar contatos do broadcast por tipo de evento",
tags: ["Broadcasts"],
})
.input(listBroadcastContactsRequest)
diff --git a/apps/builder/src/features/broadcasts/api/workspace-token.ts b/apps/builder/src/features/broadcasts/api/workspace-token.ts
index d41b5b990..971541b26 100644
--- a/apps/builder/src/features/broadcasts/api/workspace-token.ts
+++ b/apps/builder/src/features/broadcasts/api/workspace-token.ts
@@ -7,7 +7,7 @@ export const broadcastWorkspaceTokenAPIs = {
.route({
method: "GET",
path: "/v1/broadcasts",
- summary: "Get all broadcasts",
+ summary: "Obter todos os broadcasts",
tags: ["Broadcasts"],
})
.output(publicListBroadcastsResponse)
diff --git a/apps/builder/src/features/broadcasts/broadcasts-table.tsx b/apps/builder/src/features/broadcasts/broadcasts-table.tsx
index 1b10c0616..9bd373162 100644
--- a/apps/builder/src/features/broadcasts/broadcasts-table.tsx
+++ b/apps/builder/src/features/broadcasts/broadcasts-table.tsx
@@ -266,13 +266,13 @@ export function BroadcastsTable({ promises }: BroadcastsTableProps) {
},
{
id: "actions",
- header: "Actions",
+ header: "Ações",
cell: ({ row }) => (
- Open menu
+ Abrir menu
diff --git a/apps/builder/src/features/broadcasts/components/broadcast-contacts-dialog.tsx b/apps/builder/src/features/broadcasts/components/broadcast-contacts-dialog.tsx
index daee2dfa8..f1805e257 100644
--- a/apps/builder/src/features/broadcasts/components/broadcast-contacts-dialog.tsx
+++ b/apps/builder/src/features/broadcasts/components/broadcast-contacts-dialog.tsx
@@ -85,7 +85,7 @@ export const BroadcastContactsDialog = memo(function BroadcastContactsDialog({
setContacts(result.data)
setPageCount(result.pageCount)
} catch (error) {
- console.error("Failed to fetch broadcast contacts:", error)
+ console.error("Falha ao buscar contatos do broadcast:", error)
} finally {
setIsLoading(false)
}
diff --git a/apps/builder/src/features/broadcasts/provider/broadcast-stats-store.ts b/apps/builder/src/features/broadcasts/provider/broadcast-stats-store.ts
index 9d0b0ce6f..987215b9c 100644
--- a/apps/builder/src/features/broadcasts/provider/broadcast-stats-store.ts
+++ b/apps/builder/src/features/broadcasts/provider/broadcast-stats-store.ts
@@ -71,7 +71,9 @@ export function createBroadcastStatsStore(props: BroadcastStatsStoreProps) {
set({
isLoading: false,
error:
- error instanceof Error ? error.message : "Failed to fetch stats",
+ error instanceof Error
+ ? error.message
+ : "Falha ao buscar estatísticas",
})
}
},
diff --git a/apps/builder/src/features/chat/chat-layout.tsx b/apps/builder/src/features/chat/chat-layout.tsx
index 5545af19b..8fa2b25d9 100644
--- a/apps/builder/src/features/chat/chat-layout.tsx
+++ b/apps/builder/src/features/chat/chat-layout.tsx
@@ -1,74 +1,89 @@
"use client"
-import type { ConversationAttributes } from "@chatbotx.io/database/partials"
-import { Button } from "@chatbotx.io/ui/components/ui/button"
+import type {
+ ClosingNotesMode,
+ ConversationAttributes,
+} from "@chatbotx.io/database/partials"
+import type { LifecycleStageModel } from "@chatbotx.io/database/types"
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@chatbotx.io/ui/components/ui/resizable"
-import {
- BotIcon,
- Loader2Icon,
- MessagesSquareIcon,
- UserRoundIcon,
-} from "lucide-react"
+import { Loader2Icon, MessagesSquareIcon } from "lucide-react"
import { useTranslations } from "next-intl"
-import { useAction } from "next-safe-action/hooks"
import { useEffect, useState } from "react"
-import { toast } from "sonner"
+import type { ClosingNoteCategoryOption } from "@/features/closing-notes/queries/get-config"
import { ContactInboxPanel } from "../contacts/contact-inbox-panel"
-import { disableBotAction } from "../conversations/actions/disable-bot.action"
import ConversationList from "../conversations/conversation-list"
import type { ConversationResource } from "../conversations/schema/resource"
-import {
- BOT_DISABLE_DURATION_MS,
- isConversationActive,
-} from "../conversations/utils/bot-state"
import { MessageInput } from "../messages/components/message-input"
import MessageHead from "../messages/message-head"
import { MessageList } from "../messages/message-list"
import { ChatRealtime } from "./chat-realtime"
+import { InboxSideRail } from "./inbox-side-rail"
import { useChatStore } from "./store/chat-store-provider"
type ChatLayoutProps = {
workspaceId: string
layout?: [number, number, number]
+ lifecycleStages?: LifecycleStageModel[]
+ /** Lista de `fieldKey`s marcadas como `alwaysHide` no workspace. */
+ hiddenFieldKeys?: string[]
+ /** Modo de Closing Notes do workspace (default "disabled"). */
+ closingNotesMode?: ClosingNotesMode
+ /** Categorias de fechamento disponíveis pro Inbox. */
+ closingNoteCategories?: ClosingNoteCategoryOption[]
}
export const ChatLayout = (props: ChatLayoutProps) => {
const t = useTranslations()
- const { workspaceId, layout = [25, 50, 25] } = props
+ const {
+ workspaceId,
+ layout = [25, 50, 25],
+ lifecycleStages = [],
+ hiddenFieldKeys = [],
+ closingNotesMode = "disabled",
+ closingNoteCategories = [],
+ } = props
const {
conversations,
isFirstLoadConversation,
isLoadingConversation,
activeConversationId,
- updateConversation,
} = useChatStore((state) => state)
const [activeConversation, setActiveConversation] =
useState(null)
- const { execute: disableBot, isExecuting: isDisablingBot } = useAction(
- disableBotAction.bind(null, workspaceId),
- {
- onSuccess: () => {
- if (activeConversation) {
- updateConversation(activeConversation.id, {
- botEnabled: false,
- botResumeAt: new Date(Date.now() + BOT_DISABLE_DURATION_MS),
- })
- }
- },
- onError: ({ error }) => {
- if (error.serverError) {
- toast.error(error.serverError)
- }
- },
- },
- )
+ // Pedro iter 40: drawer (Detalhes do contato) abre por default.
+ // Toggle via sideRail (botão único). Persistido em localStorage pra
+ // próxima sessão lembrar do estado.
+ const [drawerOpen, setDrawerOpen] = useState(true)
+ useEffect(() => {
+ const saved =
+ typeof window === "undefined"
+ ? null
+ : window.localStorage.getItem("chatbotx.inbox.drawer.open")
+ if (saved !== null) {
+ setDrawerOpen(saved === "1")
+ }
+ }, [])
+ const toggleDrawer = () => {
+ setDrawerOpen((prev) => {
+ const next = !prev
+ try {
+ window.localStorage.setItem(
+ "chatbotx.inbox.drawer.open",
+ next ? "1" : "0",
+ )
+ } catch {
+ // ignore
+ }
+ return next
+ })
+ }
useEffect(() => {
const selectedConversation = conversations.find(
@@ -85,104 +100,135 @@ export const ChatLayout = (props: ChatLayoutProps) => {
}
}, [activeConversationId, conversations])
+ // Pedro 2026-05-25 iteração 34 (pixel-perfect Respond.io):
+ // O TOPBAR (MessageHead) deve cobrir tanto a coluna de mensagens
+ // QUANTO o drawer de detalhes — ficar full-width na parte direita.
+ // Antes ficava só dentro do panel de msgs; agora a estrutura é:
+ //
+ // [Lista convs] | (MessageHead + [msgs | drawer+sideRail])
+ //
+ // Dois ResizablePanelGroups aninhados pra resize independente entre:
+ // 1. (lista convs) vs (área direita: header+msgs+drawer)
+ // 2. (coluna msgs) vs (coluna drawer+sideRail)
+ //
+ // Layout cookie original era [convs, msgs, drawer]. Convertemos:
+ // - externo: [convs%, 100-convs%]
+ // - interno: [msgs / (msgs+drawer), drawer / (msgs+drawer)] %
+ const convsSize = layout[0] ?? 24
+ const rightSize = 100 - convsSize
+ const inner = (layout[1] ?? 63) + (layout[2] ?? 13)
+ const innerMsgsSize = Math.round(((layout[1] ?? 63) / inner) * 100)
+ const innerDrawerSize = 100 - innerMsgsSize
+
return (
- {/* CONVERSATION LIST */}
+ {/* CONVERSATION LIST — full-height (não é coberto pelo topbar). */}
-
+
- {/* MESSAGE LIST */}
-
- {isFirstLoadConversation && isLoadingConversation && (
-
- )}
- {activeConversation && (
+ {/* ÁREA DIREITA — Pedro iter 40:
+ TOPBAR cobre TUDO (msgs + drawer + sideRail). Antes o
+ sideRail ficava SOBRE o topbar (errado). Agora o topbar é
+ full-width e o sideRail vive na linha de baixo.
+
+ Estrutura:
+ flex column [
+ MessageHead (topbar full-width),
+ flex row [
+ ResizablePanelGroup [msgs, drawer? (toggle)],
+ InboxSideRail (45px botão único)
+ ]
+ ]
+ */}
+
+ {activeConversation ? (
-
- {isConversationActive(activeConversation) && (
- {
- disableBot({ ids: [activeConversation.id] })
- }}
- variant="secondary"
- >
-
- {t("messages.botIsActive")}
-
- )}
-
-
-
- )}
- {!(activeConversation || isFirstLoadConversation) && (
-
-
-
-
-
- {t("messages.selectConversationTitle")}
-
-
- {t("messages.selectConversationDescription")}
-
-
- )}
-
-
+ {/* TOPBAR full-width — cobre msgs + drawer + sideRail */}
+
-
+ {/* LINHA DE BAIXO: msgs | drawer | sideRail */}
+
+
+
+
+
+
+
+
- {/* CONTACT DETAIL */}
-
- {isFirstLoadConversation && isLoadingConversation && (
-
- )}
- {activeConversation && (
-
- )}
- {!(activeConversation || isFirstLoadConversation) && (
-
-
-
+
+
+
+
+ >
+ )}
+
+
+ {/* SIDERAIL — 45px à direita, ABAIXO do topbar (iter 40).
+ Botão único toggle do drawer. */}
+
-
- {t("messages.selectConversationContactTitle")}
-
-
- {t("messages.selectConversationContactDescription")}
-
+
+ ) : (
+
+ {isFirstLoadConversation && isLoadingConversation && (
+
+ )}
+ {!(activeConversation || isFirstLoadConversation) && (
+
+
+
+
+
+ {t("messages.selectConversationTitle")}
+
+
+ {t("messages.selectConversationDescription")}
+
+
+ )}
)}
+
)
diff --git a/apps/builder/src/features/chat/chat-realtime.tsx b/apps/builder/src/features/chat/chat-realtime.tsx
index a6dcc44f4..564c4ea9e 100644
--- a/apps/builder/src/features/chat/chat-realtime.tsx
+++ b/apps/builder/src/features/chat/chat-realtime.tsx
@@ -15,9 +15,13 @@ export function ChatRealtime() {
const workspaceId = useWorkspaceId()
const { realtimeUrl } = usePlatformSettings()
- const { handleNewMessage, updateContact, updateConversations } = useChatStore(
- (state) => state,
- )
+ const {
+ handleNewMessage,
+ updateContact,
+ updateConversations,
+ updateMessageStatus,
+ updateMessageReaction,
+ } = useChatStore((state) => state)
usePartySocket({
host: realtimeUrl,
@@ -41,6 +45,31 @@ export function ChatRealtime() {
case RealtimeEventType.messageCreated:
handleNewMessage(data as MessageResourceWithRelations)
break
+ case RealtimeEventType.messageStatusUpdated:
+ updateMessageStatus(
+ data as {
+ messageId: string
+ conversationId: string
+ deliveredAt: string | null
+ readAt: string | null
+ failedAt: string | null
+ failureReason: string | null
+ },
+ )
+ break
+ case RealtimeEventType.messageReactionUpdated:
+ updateMessageReaction(
+ data as {
+ messageId: string
+ conversationId: string
+ reaction: {
+ emoji: string
+ by: "contact" | "agent"
+ at: string
+ } | null
+ },
+ )
+ break
case RealtimeEventType.contactBlocked:
updateContact(data.contactId, {
blockedAt: new Date(),
@@ -61,7 +90,10 @@ export function ChatRealtime() {
break
}
} catch (error) {
- console.error("Unable to parse realtime message", error)
+ console.error(
+ "Não foi possível processar mensagem em tempo real",
+ error,
+ )
}
},
// onClose() {},
diff --git a/apps/builder/src/features/chat/components/inbox-sidebar-expand-button.tsx b/apps/builder/src/features/chat/components/inbox-sidebar-expand-button.tsx
new file mode 100644
index 000000000..4a725ccfe
--- /dev/null
+++ b/apps/builder/src/features/chat/components/inbox-sidebar-expand-button.tsx
@@ -0,0 +1,30 @@
+"use client"
+
+import { PanelLeftOpenIcon } from "lucide-react"
+import { useTranslations } from "next-intl"
+import { useInboxSidebarCollapsed } from "@/features/chat/hooks/use-inbox-sidebar-collapsed"
+
+// Botão flutuante "expandir sidebar" que aparece quando o InboxAreaSidebar
+// está collapsed. Lê do mesmo hook (sincroniza via localStorage event).
+// Lucide PanelLeftOpen — fallback enquanto investigo glyph iconsax.
+export function InboxSidebarExpandButton() {
+ const t = useTranslations()
+ const [collapsed, toggleCollapsed] = useInboxSidebarCollapsed()
+
+ if (!collapsed) {
+ return null
+ }
+
+ return (
+
+
toggleCollapsed(false)}
+ type="button"
+ >
+
+
+
+ )
+}
diff --git a/apps/builder/src/features/chat/hooks/use-inbox-sidebar-collapsed.ts b/apps/builder/src/features/chat/hooks/use-inbox-sidebar-collapsed.ts
new file mode 100644
index 000000000..9998dcfea
--- /dev/null
+++ b/apps/builder/src/features/chat/hooks/use-inbox-sidebar-collapsed.ts
@@ -0,0 +1,49 @@
+"use client"
+
+import { useCallback, useEffect, useState } from "react"
+
+// Persistência do collapse da InboxAreaSidebar (sidebar 215 px com
+// filtros + lifecycle + teams). Quando collapsed, sidebar some inteira e
+// um botão flutuante "expandir" aparece colado ao nav rail global de 48 px.
+// Sincroniza entre abas via `storage` event.
+const KEY = "chatbotx.inbox.sidebar.collapsed"
+
+function read(): boolean {
+ if (typeof window === "undefined") {
+ return false
+ }
+ return window.localStorage.getItem(KEY) === "true"
+}
+
+export function useInboxSidebarCollapsed(): [
+ boolean,
+ (next?: boolean) => void,
+] {
+ const [collapsed, setCollapsed] = useState(false)
+
+ useEffect(() => {
+ setCollapsed(read())
+ const handler = (e: StorageEvent) => {
+ if (e.key === KEY && e.newValue !== null) {
+ setCollapsed(e.newValue === "true")
+ }
+ }
+ window.addEventListener("storage", handler)
+ return () => window.removeEventListener("storage", handler)
+ }, [])
+
+ const toggle = useCallback((next?: boolean) => {
+ const value = typeof next === "boolean" ? next : !read()
+ window.localStorage.setItem(KEY, String(value))
+ setCollapsed(value)
+ // Dispatch manual pra sincronizar componentes na mesma aba.
+ window.dispatchEvent(
+ new StorageEvent("storage", {
+ key: KEY,
+ newValue: String(value),
+ }),
+ )
+ }, [])
+
+ return [collapsed, toggle]
+}
diff --git a/apps/builder/src/features/chat/inbox-side-rail.tsx b/apps/builder/src/features/chat/inbox-side-rail.tsx
new file mode 100644
index 000000000..567fb83a6
--- /dev/null
+++ b/apps/builder/src/features/chat/inbox-side-rail.tsx
@@ -0,0 +1,58 @@
+"use client"
+
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@chatbotx.io/ui/components/ui/tooltip"
+import { cn } from "@chatbotx.io/ui/lib/utils"
+import { useTranslations } from "next-intl"
+import { RespondIcon } from "@/components/respond-icon"
+
+type InboxSideRailProps = {
+ /** Drawer está aberto? Determina estado visual do botão (active). */
+ drawerOpen: boolean
+ /** Toggle do drawer. */
+ onToggleDrawer: () => void
+}
+
+/**
+ * Coluna fina vertical à direita (45 px), igual Respond.io.
+ *
+ * Pedro 2026-05-25 iter 40: simplificado pra UM ÚNICO BOTÃO
+ * "Detalhes do contato" que faz toggle do drawer. Os 4 ícones antigos
+ * (call, attach, clock) foram REMOVIDOS — Respond.io só tem o botão
+ * do drawer em destaque. Outras abas (Channels/Activities/Attachments)
+ * ficaram pra depois quando essas features existirem.
+ *
+ * Active state (azul) = drawer aberto. Click toggle.
+ */
+export function InboxSideRail({
+ drawerOpen,
+ onToggleDrawer,
+}: InboxSideRailProps) {
+ const t = useTranslations()
+ const label = t("inboxSideRail.details") ?? "Detalhes do contato"
+
+ return (
+
+
+
+
+
+
+
+ {label}
+
+
+ )
+}
diff --git a/apps/builder/src/features/chat/store/chat-store.ts b/apps/builder/src/features/chat/store/chat-store.ts
index 87e1c99cc..1d79694e3 100644
--- a/apps/builder/src/features/chat/store/chat-store.ts
+++ b/apps/builder/src/features/chat/store/chat-store.ts
@@ -8,7 +8,6 @@ import { createStore } from "zustand/vanilla"
import type { ContactFilterRequest } from "@/features/contacts/schemas/contact-filter"
import type { ContactResource } from "@/features/contacts/schemas/resource"
import type {
- ConversationResource,
ListConversationItemResource,
ListConversationsResponse,
} from "@/features/conversations/schema/resource"
@@ -27,6 +26,9 @@ export type ConversationFilters = {
keyword?: string
tags?: ("noAdminReply" | "unread" | "followUp" | "archived" | "blocked")[]
contactFilter?: ContactFilterRequest["contactFilter"]
+ archiveFilter?: "open" | "closed" | "all"
+ sortOrder?: "asc" | "desc"
+ lifecycleStageId?: string | null
}
export type ChatState = {
@@ -53,11 +55,20 @@ export type ChatActions = {
setActiveConversationId: (activeConversationId: string | null) => void
updateConversation: (
conversationId: string,
- data: Partial
,
+ data: Partial,
) => void
updateConversations: (
conversationIds: string[],
- data: Partial,
+ data: Partial,
+ ) => void
+ // Atualiza o objeto `contact` de TODAS as conversations que pertencem
+ // a um contactId. Usado pra propagar mudanças instantâneas (lifecycle
+ // stage, nome, etc) por todos os componentes do Inbox sem precisar
+ // refresh. Pedro pediu 2026-05-25 — "tudo instantâneo, trocou em um
+ // canto vai trocar em todos os outros".
+ updateContactInConversations: (
+ contactId: string,
+ contactData: Record,
) => void
updateConversationViaMessage: (message: MessageResource) => void
@@ -73,6 +84,25 @@ export type ChatActions = {
appendMessage: (message: MessageResourceWithRelations) => void
loadMoreMessages: (workspaceId: string, perPage: number) => Promise
handleNewMessage: (message: MessageResourceWithRelations) => void
+ // Atualiza delivery status (deliveredAt/readAt/failedAt/failureReason)
+ // de uma mensagem outgoing em memória SEM refetch. Recebido via realtime
+ // do listener message-status-persist no worker.
+ updateMessageStatus: (data: {
+ messageId: string
+ conversationId: string
+ deliveredAt: string | null
+ readAt: string | null
+ failedAt: string | null
+ failureReason: string | null
+ }) => void
+ // Atualiza reaction (emoji) de uma Message em memória sem refetch.
+ // Recebido via realtime quando webhook do canal entrega reaction.
+ // Null quando contato removeu a reaction.
+ updateMessageReaction: (data: {
+ messageId: string
+ conversationId: string
+ reaction: { emoji: string; by: "contact" | "agent"; at: string } | null
+ }) => void
// Contact actions
updateContact: (contactId: string, data: Partial) => void
@@ -193,6 +223,10 @@ export const createChatStore = () => {
const updatedConversations = [...conversations]
const conversation = { ...updatedConversations[conversationIndex] }
conversation.agentLastReadAt = new Date()
+ // Zerar contagem unread pro pill azul sumir imediatamente no card
+ // da lista. Pedro 2026-05-24: bug era pill ficar "0" vazio depois
+ // de marcar lida — agora pill some por completo.
+ conversation.unreadCount = 0
updatedConversations[conversationIndex] = conversation
set({ conversations: updatedConversations })
@@ -320,7 +354,7 @@ export const createChatStore = () => {
updateConversation: (
conversationId: string,
- data: Partial,
+ data: Partial,
) => {
const { conversations } = get()
const conversationIndex = conversations.findIndex(
@@ -339,7 +373,7 @@ export const createChatStore = () => {
updateConversations: (
conversationIds: string[],
- data: Partial,
+ data: Partial,
) => {
if (conversationIds.length === 0) {
return
@@ -362,6 +396,32 @@ export const createChatStore = () => {
set({ conversations: updatedConversations })
},
+ // Atualiza o sub-objeto `contact` de TODAS as conversations que
+ // pertencem a um contactId específico. Propaga mudanças (lifecycle
+ // stage, nome, blockedAt, etc) instantaneamente pra card da lista +
+ // header da conversa + drawer direito sem precisar refresh.
+ // Pedro pediu 2026-05-25.
+ updateContactInConversations: (
+ contactId: string,
+ contactData: Record,
+ ) => {
+ const { conversations } = get()
+ let changed = false
+ const updatedConversations = conversations.map((c) => {
+ if (c.contact?.id !== contactId) {
+ return c
+ }
+ changed = true
+ return {
+ ...c,
+ contact: { ...c.contact, ...contactData } as typeof c.contact,
+ }
+ })
+ if (changed) {
+ set({ conversations: updatedConversations })
+ }
+ },
+
handleNewMessage: async (message: MessageResourceWithRelations) => {
const {
messages,
@@ -445,5 +505,47 @@ export const createChatStore = () => {
set({ conversations: updatedConversations })
}
},
+
+ updateMessageStatus: (data) => {
+ const { messages, activeConversationId } = get()
+ if (data.conversationId !== activeConversationId) {
+ return
+ }
+ const idx = messages.findIndex((m) => m.id === data.messageId)
+ if (idx === -1) {
+ return
+ }
+ const next = [...messages]
+ next[idx] = {
+ ...next[idx],
+ deliveredAt: data.deliveredAt ? new Date(data.deliveredAt) : null,
+ readAt: data.readAt ? new Date(data.readAt) : null,
+ failedAt: data.failedAt ? new Date(data.failedAt) : null,
+ failureReason: data.failureReason,
+ }
+ set({ messages: next })
+ },
+
+ updateMessageReaction: (data) => {
+ const { messages, activeConversationId } = get()
+ if (data.conversationId !== activeConversationId) {
+ return
+ }
+ const idx = messages.findIndex((m) => m.id === data.messageId)
+ if (idx === -1) {
+ return
+ }
+ const next = [...messages]
+ const prevAttrs =
+ (next[idx].contentAttributes as Record | null) ?? {}
+ next[idx] = {
+ ...next[idx],
+ contentAttributes: {
+ ...prevAttrs,
+ reaction: data.reaction,
+ },
+ }
+ set({ messages: next })
+ },
}))
}
diff --git a/apps/builder/src/features/closing-notes/actions/create-category.action.ts b/apps/builder/src/features/closing-notes/actions/create-category.action.ts
new file mode 100644
index 000000000..0d7ae3d63
--- /dev/null
+++ b/apps/builder/src/features/closing-notes/actions/create-category.action.ts
@@ -0,0 +1,41 @@
+"use server"
+
+import { auditLogActions, logAudit } from "@chatbotx.io/business"
+import { db } from "@chatbotx.io/database/client"
+import { conversationCategoryModel } from "@chatbotx.io/database/schema"
+import { createId } from "@chatbotx.io/utils"
+import { revalidatePath } from "next/cache"
+import { workspaceIdrequestParams } from "@/features/common/schemas"
+import { workspaceActionClient } from "@/lib/safe-action"
+import { createCategoryRequest } from "../schemas/action"
+
+export const createCategoryAction = workspaceActionClient
+ .bindArgsSchemas(workspaceIdrequestParams)
+ .inputSchema(createCategoryRequest)
+ .action(async (props) => {
+ const {
+ bindArgsParsedInputs: [workspaceId],
+ parsedInput,
+ ctx: { user },
+ } = props
+
+ const [category] = await db
+ .insert(conversationCategoryModel)
+ .values({
+ id: createId(),
+ workspaceId,
+ name: parsedInput.name,
+ description: parsedInput.description ?? null,
+ })
+ .returning()
+
+ await logAudit({
+ workspaceId,
+ userId: user.id,
+ action: auditLogActions.CONVERSATION_CATEGORY_CREATED,
+ detail: `Categoria "${parsedInput.name}" criada`,
+ })
+
+ revalidatePath("/space", "layout")
+ return category
+ })
diff --git a/apps/builder/src/features/closing-notes/actions/delete-category.action.ts b/apps/builder/src/features/closing-notes/actions/delete-category.action.ts
new file mode 100644
index 000000000..8efe69037
--- /dev/null
+++ b/apps/builder/src/features/closing-notes/actions/delete-category.action.ts
@@ -0,0 +1,37 @@
+"use server"
+
+import { auditLogActions, logAudit } from "@chatbotx.io/business"
+import { and, db, eq } from "@chatbotx.io/database/client"
+import { conversationCategoryModel } from "@chatbotx.io/database/schema"
+import { zodBigintAsString } from "@chatbotx.io/utils"
+import { revalidatePath } from "next/cache"
+import { workspaceActionClient } from "@/lib/safe-action"
+
+export const deleteCategoryAction = workspaceActionClient
+ .bindArgsSchemas([zodBigintAsString(), zodBigintAsString()] as const)
+ .action(async (props) => {
+ const {
+ bindArgsParsedInputs: [workspaceId, id],
+ ctx: { user },
+ } = props
+
+ const [deleted] = await db
+ .delete(conversationCategoryModel)
+ .where(
+ and(
+ eq(conversationCategoryModel.id, id),
+ eq(conversationCategoryModel.workspaceId, workspaceId),
+ ),
+ )
+ .returning()
+
+ await logAudit({
+ workspaceId,
+ userId: user.id,
+ action: auditLogActions.CONVERSATION_CATEGORY_DELETED,
+ detail: `Categoria "${deleted?.name ?? id}" deletada`,
+ })
+
+ revalidatePath("/space", "layout")
+ return deleted
+ })
diff --git a/apps/builder/src/features/closing-notes/actions/update-category.action.ts b/apps/builder/src/features/closing-notes/actions/update-category.action.ts
new file mode 100644
index 000000000..cb035fabe
--- /dev/null
+++ b/apps/builder/src/features/closing-notes/actions/update-category.action.ts
@@ -0,0 +1,44 @@
+"use server"
+
+import { auditLogActions, logAudit } from "@chatbotx.io/business"
+import { and, db, eq } from "@chatbotx.io/database/client"
+import { conversationCategoryModel } from "@chatbotx.io/database/schema"
+import { zodBigintAsString } from "@chatbotx.io/utils"
+import { revalidatePath } from "next/cache"
+import { workspaceActionClient } from "@/lib/safe-action"
+import { updateCategoryRequest } from "../schemas/action"
+
+export const updateCategoryAction = workspaceActionClient
+ .bindArgsSchemas([zodBigintAsString(), zodBigintAsString()] as const)
+ .inputSchema(updateCategoryRequest)
+ .action(async (props) => {
+ const {
+ bindArgsParsedInputs: [workspaceId, id],
+ parsedInput,
+ ctx: { user },
+ } = props
+
+ const [updated] = await db
+ .update(conversationCategoryModel)
+ .set({
+ name: parsedInput.name,
+ description: parsedInput.description ?? null,
+ })
+ .where(
+ and(
+ eq(conversationCategoryModel.id, id),
+ eq(conversationCategoryModel.workspaceId, workspaceId),
+ ),
+ )
+ .returning()
+
+ await logAudit({
+ workspaceId,
+ userId: user.id,
+ action: auditLogActions.CONVERSATION_CATEGORY_UPDATED,
+ detail: `Categoria "${parsedInput.name}" atualizada`,
+ })
+
+ revalidatePath("/space", "layout")
+ return updated
+ })
diff --git a/apps/builder/src/features/closing-notes/actions/update-mode.action.ts b/apps/builder/src/features/closing-notes/actions/update-mode.action.ts
new file mode 100644
index 000000000..f2341da01
--- /dev/null
+++ b/apps/builder/src/features/closing-notes/actions/update-mode.action.ts
@@ -0,0 +1,34 @@
+"use server"
+
+import { auditLogActions, logAudit } from "@chatbotx.io/business"
+import { db, eq } from "@chatbotx.io/database/client"
+import { workspaceModel } from "@chatbotx.io/database/schema"
+import { revalidatePath } from "next/cache"
+import { workspaceIdrequestParams } from "@/features/common/schemas"
+import { workspaceActionClient } from "@/lib/safe-action"
+import { updateClosingNotesModeRequest } from "../schemas/action"
+
+export const updateClosingNotesModeAction = workspaceActionClient
+ .bindArgsSchemas(workspaceIdrequestParams)
+ .inputSchema(updateClosingNotesModeRequest)
+ .action(async (props) => {
+ const {
+ bindArgsParsedInputs: [workspaceId],
+ parsedInput,
+ ctx: { user },
+ } = props
+
+ await db
+ .update(workspaceModel)
+ .set({ closingNotesMode: parsedInput.mode })
+ .where(eq(workspaceModel.id, workspaceId))
+
+ await logAudit({
+ workspaceId,
+ userId: user.id,
+ action: auditLogActions.CLOSING_NOTES_MODE_UPDATED,
+ detail: `Modo de Notas de Fechamento alterado para "${parsedInput.mode}"`,
+ })
+
+ revalidatePath("/space", "layout")
+ })
diff --git a/apps/builder/src/features/closing-notes/closing-notes-settings.tsx b/apps/builder/src/features/closing-notes/closing-notes-settings.tsx
new file mode 100644
index 000000000..65bb5192c
--- /dev/null
+++ b/apps/builder/src/features/closing-notes/closing-notes-settings.tsx
@@ -0,0 +1,288 @@
+"use client"
+
+import type { ClosingNotesMode } from "@chatbotx.io/database/partials"
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@chatbotx.io/ui/components/ui/alert-dialog"
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import { Label } from "@chatbotx.io/ui/components/ui/label"
+import {
+ RadioGroup,
+ RadioGroupItem,
+} from "@chatbotx.io/ui/components/ui/radio-group"
+import { Loader2Icon, PencilIcon, PlusIcon, Trash2Icon } from "lucide-react"
+import { useRouter } from "next/navigation"
+import { useTranslations } from "next-intl"
+import { useAction } from "next-safe-action/hooks"
+import { useState } from "react"
+import { toast } from "sonner"
+import { deleteCategoryAction } from "./actions/delete-category.action"
+import { updateClosingNotesModeAction } from "./actions/update-mode.action"
+import { CategoryFormDialog } from "./components/category-form-dialog"
+import type { ConversationCategory } from "./queries/list-categories"
+
+type Props = {
+ workspaceId: string
+ categories: ConversationCategory[]
+ currentMode: string
+}
+
+const MODE_OPTIONS: {
+ value: ClosingNotesMode
+ labelKey: string
+ descKey: string
+}[] = [
+ {
+ value: "disabled",
+ labelKey: "closingNotes.mode.disabled",
+ descKey: "closingNotes.mode.disabledDesc",
+ },
+ {
+ value: "optional",
+ labelKey: "closingNotes.mode.optional",
+ descKey: "closingNotes.mode.optionalDesc",
+ },
+ {
+ value: "mandatoryDialog",
+ labelKey: "closingNotes.mode.mandatoryDialog",
+ descKey: "closingNotes.mode.mandatoryDialogDesc",
+ },
+ {
+ value: "mandatoryBoth",
+ labelKey: "closingNotes.mode.mandatoryBoth",
+ descKey: "closingNotes.mode.mandatoryBothDesc",
+ },
+]
+
+export function ClosingNotesSettings({
+ workspaceId,
+ categories,
+ currentMode,
+}: Props) {
+ const t = useTranslations()
+ const router = useRouter()
+ const [editingCategory, setEditingCategory] =
+ useState(null)
+ const [deletingCategory, setDeletingCategory] =
+ useState(null)
+ const [createOpen, setCreateOpen] = useState(false)
+
+ const { execute: updateMode, isPending: savingMode } = useAction(
+ updateClosingNotesModeAction.bind(null, workspaceId),
+ {
+ onSuccess: () => {
+ toast.success(t("closingNotes.modeSaved"))
+ router.refresh()
+ },
+ onError: ({ error }) => {
+ if (error.serverError) {
+ toast.error(error.serverError)
+ }
+ },
+ },
+ )
+
+ // Delete tem 2 bindArgs (workspaceId + id) — chamada direta async pra evitar
+ // wrapper que useAction não tipa bem.
+ const deleteCategory = async (id: string) => {
+ const res = await deleteCategoryAction.bind(null, workspaceId, id)()
+ if (res?.serverError) {
+ toast.error(res.serverError)
+ return
+ }
+ toast.success(t("closingNotes.categoryDeleted"))
+ router.refresh()
+ }
+
+ return (
+
+
+
+ {/* ─── Modo de operação ─────────────────────────────────────────── */}
+
+
+
+ {t("closingNotes.modeTitle")}
+
+
+ {t("closingNotes.modeSubtitle")}
+
+
+
+ updateMode({ mode: value as ClosingNotesMode })
+ }
+ value={currentMode}
+ >
+ {MODE_OPTIONS.map((opt) => (
+
+
+
+
+ {t(opt.labelKey)}
+
+
+ {t(opt.descKey)}
+
+
+
+ ))}
+
+ {savingMode && (
+
+
+ {t("closingNotes.savingMode")}
+
+ )}
+
+
+ {/* ─── Categorias ───────────────────────────────────────────────── */}
+
+
+
+
+ {t("closingNotes.categoriesTitle")}
+
+
+ {t("closingNotes.categoriesSubtitle")}
+
+
+
setCreateOpen(true)} size="sm" type="button">
+
+ {t("closingNotes.addCategory")}
+
+
+
+ {categories.length === 0 ? (
+
+
+ {t("closingNotes.emptyCategories")}
+
+
+ ) : (
+
+ {categories.map((cat, idx) => (
+
+
+
+ {cat.name}
+
+ {cat.description && (
+
+ {cat.description}
+
+ )}
+
+
setEditingCategory(cat)}
+ size="icon"
+ type="button"
+ variant="ghost"
+ >
+
+
+
setDeletingCategory(cat)}
+ size="icon"
+ type="button"
+ variant="ghost"
+ >
+
+
+
+ ))}
+
+ )}
+
+
+
+
{
+ if (!open) {
+ setEditingCategory(null)
+ }
+ }}
+ open={!!editingCategory}
+ workspaceId={workspaceId}
+ />
+
+ {
+ if (!open) {
+ setDeletingCategory(null)
+ }
+ }}
+ open={!!deletingCategory}
+ >
+
+
+
+ {t("closingNotes.deleteCategoryTitle")}
+
+
+ {deletingCategory
+ ? t("closingNotes.confirmDelete", {
+ name: deletingCategory.name,
+ })
+ : ""}
+
+
+
+ {t("actions.cancel")}
+ {
+ if (deletingCategory) {
+ await deleteCategory(deletingCategory.id)
+ setDeletingCategory(null)
+ }
+ }}
+ >
+ {t("actions.delete")}
+
+
+
+
+
+ )
+}
diff --git a/apps/builder/src/features/closing-notes/components/category-form-dialog.tsx b/apps/builder/src/features/closing-notes/components/category-form-dialog.tsx
new file mode 100644
index 000000000..3591a3aba
--- /dev/null
+++ b/apps/builder/src/features/closing-notes/components/category-form-dialog.tsx
@@ -0,0 +1,186 @@
+"use client"
+
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@chatbotx.io/ui/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@chatbotx.io/ui/components/ui/form"
+import { Input } from "@chatbotx.io/ui/components/ui/input"
+import { Textarea } from "@chatbotx.io/ui/components/ui/textarea"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useHookFormAction } from "@next-safe-action/adapter-react-hook-form/hooks"
+import { Loader2Icon } from "lucide-react"
+import { useRouter } from "next/navigation"
+import { useTranslations } from "next-intl"
+import { useEffect } from "react"
+import { toast } from "sonner"
+import { createCategoryAction } from "../actions/create-category.action"
+import { updateCategoryAction } from "../actions/update-category.action"
+import type { ConversationCategory } from "../queries/list-categories"
+import { createCategoryRequest } from "../schemas/action"
+
+type Props = {
+ workspaceId: string
+ category: ConversationCategory | null
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+export function CategoryFormDialog({
+ workspaceId,
+ category,
+ open,
+ onOpenChange,
+}: Props) {
+ const t = useTranslations()
+ const router = useRouter()
+ const isEdit = !!category
+
+ const createForm = useHookFormAction(
+ createCategoryAction.bind(null, workspaceId),
+ zodResolver(createCategoryRequest),
+ {
+ actionProps: {
+ onSuccess: () => {
+ toast.success(t("closingNotes.categoryCreated"))
+ onOpenChange(false)
+ createForm.resetFormAndAction()
+ router.refresh()
+ },
+ onError: ({ error }: { error: { serverError?: string } }) => {
+ if (error.serverError) {
+ toast.error(error.serverError)
+ }
+ },
+ },
+ formProps: {
+ mode: "onChange",
+ defaultValues: { name: "", description: "" },
+ },
+ errorMapProps: {},
+ },
+ )
+
+ const editForm = useHookFormAction(
+ updateCategoryAction.bind(null, workspaceId, category?.id ?? ""),
+ zodResolver(createCategoryRequest),
+ {
+ actionProps: {
+ onSuccess: () => {
+ toast.success(t("closingNotes.categoryUpdated"))
+ onOpenChange(false)
+ editForm.resetFormAndAction()
+ router.refresh()
+ },
+ onError: ({ error }: { error: { serverError?: string } }) => {
+ if (error.serverError) {
+ toast.error(error.serverError)
+ }
+ },
+ },
+ formProps: { mode: "onChange" },
+ errorMapProps: {},
+ },
+ )
+
+ const { form, handleSubmitWithAction } = isEdit ? editForm : createForm
+
+ useEffect(() => {
+ if (isEdit && category) {
+ editForm.form.reset({
+ name: category.name,
+ description: category.description ?? "",
+ })
+ }
+ }, [isEdit, category, editForm.form])
+
+ return (
+
+
+
+
+ {isEdit
+ ? t("closingNotes.editCategoryTitle")
+ : t("closingNotes.addCategory")}
+
+
+ {t("closingNotes.categoryFormDescription")}
+
+
+
+
+
+ (
+
+ {t("fields.name.label")}
+
+
+
+
+
+ )}
+ />
+ (
+
+ {t("fields.description.label")}
+
+
+
+
+
+ )}
+ />
+
+ onOpenChange(false)}
+ size="sm"
+ type="button"
+ variant="ghost"
+ >
+ {t("actions.cancel")}
+
+
+ {form.formState.isSubmitting && (
+
+ )}
+ {isEdit ? t("actions.confirm") : t("actions.create")}
+
+
+
+
+
+
+ )
+}
diff --git a/apps/builder/src/features/closing-notes/queries/get-config.ts b/apps/builder/src/features/closing-notes/queries/get-config.ts
new file mode 100644
index 000000000..67575f14c
--- /dev/null
+++ b/apps/builder/src/features/closing-notes/queries/get-config.ts
@@ -0,0 +1,42 @@
+"use server"
+
+import { asc, db, eq } from "@chatbotx.io/database/client"
+import type { ClosingNotesMode } from "@chatbotx.io/database/partials"
+import { conversationCategoryModel } from "@chatbotx.io/database/schema"
+
+// Server-side helper usado em /inbox/page.tsx pra propagar { mode, categories }
+// até o CloseConversationButton via prop drilling. Mantém Zustand fora — config
+// muda raramente e é lida no Server Component a cada navegação.
+export async function getClosingNotesConfig(workspaceId: string) {
+ const [workspace, categories] = await Promise.all([
+ db.query.workspaceModel.findFirst({
+ where: { id: workspaceId },
+ columns: { closingNotesMode: true },
+ }),
+ db
+ .select({
+ id: conversationCategoryModel.id,
+ name: conversationCategoryModel.name,
+ description: conversationCategoryModel.description,
+ })
+ .from(conversationCategoryModel)
+ .where(eq(conversationCategoryModel.workspaceId, workspaceId))
+ .orderBy(
+ asc(conversationCategoryModel.position),
+ asc(conversationCategoryModel.name),
+ ),
+ ])
+
+ const mode: ClosingNotesMode =
+ (workspace?.closingNotesMode as ClosingNotesMode | undefined) ?? "disabled"
+
+ return {
+ mode,
+ categories,
+ }
+}
+
+export type ClosingNotesConfig = Awaited<
+ ReturnType
+>
+export type ClosingNoteCategoryOption = ClosingNotesConfig["categories"][number]
diff --git a/apps/builder/src/features/closing-notes/queries/list-categories.ts b/apps/builder/src/features/closing-notes/queries/list-categories.ts
new file mode 100644
index 000000000..ed0d531a6
--- /dev/null
+++ b/apps/builder/src/features/closing-notes/queries/list-categories.ts
@@ -0,0 +1,20 @@
+"use server"
+
+import { asc, db, eq } from "@chatbotx.io/database/client"
+import { conversationCategoryModel } from "@chatbotx.io/database/schema"
+
+export async function listConversationCategories(workspaceId: string) {
+ const rows = await db
+ .select()
+ .from(conversationCategoryModel)
+ .where(eq(conversationCategoryModel.workspaceId, workspaceId))
+ .orderBy(
+ asc(conversationCategoryModel.position),
+ asc(conversationCategoryModel.name),
+ )
+ return rows
+}
+
+export type ConversationCategory = Awaited<
+ ReturnType
+>[number]
diff --git a/apps/builder/src/features/closing-notes/schemas/action.ts b/apps/builder/src/features/closing-notes/schemas/action.ts
new file mode 100644
index 000000000..fc7c766fd
--- /dev/null
+++ b/apps/builder/src/features/closing-notes/schemas/action.ts
@@ -0,0 +1,31 @@
+import { closingNotesModes } from "@chatbotx.io/database/partials"
+import { zodBigintAsString } from "@chatbotx.io/utils"
+import { z } from "zod"
+
+export const createCategoryRequest = z.object({
+ name: z.string().trim().min(1).max(100),
+ description: z.string().trim().max(500).optional(),
+})
+export type CreateCategoryRequest = z.infer
+
+export const updateCategoryRequest = createCategoryRequest
+export type UpdateCategoryRequest = z.infer
+
+export const updateClosingNotesModeRequest = z.object({
+ mode: closingNotesModes,
+})
+export type UpdateClosingNotesModeRequest = z.infer<
+ typeof updateClosingNotesModeRequest
+>
+
+// Validação básica aqui: server faz validação contextual extra baseada
+// no closingNotesMode do workspace (categoryId/summary podem ser exigidos
+// quando modo = mandatoryDialog/mandatoryBoth).
+export const closeConversationWithNoteRequest = z.object({
+ conversationId: zodBigintAsString(),
+ categoryId: zodBigintAsString().optional(),
+ summary: z.string().trim().max(2000).optional(),
+})
+export type CloseConversationWithNoteRequest = z.infer<
+ typeof closeConversationWithNoteRequest
+>
diff --git a/apps/builder/src/features/conditions/custom-field-value-changed.tsx b/apps/builder/src/features/conditions/custom-field-value-changed.tsx
index 035ea2a53..8f489b994 100644
--- a/apps/builder/src/features/conditions/custom-field-value-changed.tsx
+++ b/apps/builder/src/features/conditions/custom-field-value-changed.tsx
@@ -82,7 +82,7 @@ export const CustomFieldValueChanged = ({
value={currentOperator}
>
-
+
{operatorOptions.map((option) => (
@@ -198,8 +198,8 @@ export const CustomFieldValueChanged = ({
- True
- False
+ Verdadeiro
+ Falso
)}
diff --git a/apps/builder/src/features/conditions/editor.tsx b/apps/builder/src/features/conditions/editor.tsx
index 3370ee16c..996ca4eec 100644
--- a/apps/builder/src/features/conditions/editor.tsx
+++ b/apps/builder/src/features/conditions/editor.tsx
@@ -8,6 +8,7 @@ import { useFormContext } from "react-hook-form"
import { useTagSelectOptions } from "@/features/tags/provider/tag-hook"
import { CustomFieldValueChanged } from "./custom-field-value-changed"
import { DateTimeBasedTrigger } from "./date-time-based-trigger"
+import { LifecycleStageChanged } from "./lifecycle-stage-changed"
export const ConditionEditor = ({
parentName,
@@ -30,6 +31,8 @@ export const ConditionEditor = ({
return
case triggerEventTypes.enum.customFieldValueChanged:
return
+ case triggerEventTypes.enum.lifecycleStageChanged:
+ return
default:
return (
<>
diff --git a/apps/builder/src/features/conditions/lifecycle-stage-changed.tsx b/apps/builder/src/features/conditions/lifecycle-stage-changed.tsx
new file mode 100644
index 000000000..d3b82aa12
--- /dev/null
+++ b/apps/builder/src/features/conditions/lifecycle-stage-changed.tsx
@@ -0,0 +1,147 @@
+"use client"
+
+import type { LifecycleStageModel } from "@chatbotx.io/database/types"
+import { Label } from "@chatbotx.io/ui/components/ui/label"
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectTrigger,
+ SelectValue,
+} from "@chatbotx.io/ui/components/ui/select"
+import { useTranslations } from "next-intl"
+import { useEffect, useMemo, useState } from "react"
+import { Controller, useFormContext } from "react-hook-form"
+import { listLifecycleStages } from "@/features/lifecycle-stages/queries"
+import { useWorkspaceId } from "@/hooks/routing"
+
+const ANY_STAGE_VALUE = "__any__"
+
+type LifecycleStageChangedProps = {
+ parentName: string
+}
+
+export const LifecycleStageChanged = ({
+ parentName,
+}: LifecycleStageChangedProps) => {
+ const t = useTranslations()
+ const workspaceId = useWorkspaceId()
+ const { control, register } = useFormContext()
+ const [stages, setStages] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ let cancelled = false
+ listLifecycleStages(workspaceId)
+ .then((data) => {
+ if (!cancelled) {
+ setStages(data)
+ setLoading(false)
+ }
+ })
+ .catch(() => {
+ if (!cancelled) {
+ setLoading(false)
+ }
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [workspaceId])
+
+ const { activeStages, lostStages } = useMemo(
+ () => ({
+ activeStages: stages.filter((s) => !s.isLost),
+ lostStages: stages.filter((s) => s.isLost),
+ }),
+ [stages],
+ )
+
+ const renderStageOptions = () => (
+ <>
+
+ {t("lifecycle.anyStage")}
+
+ {activeStages.length > 0 && (
+
+ {t("lifecycle.activeTitle")}
+ {activeStages.map((stage) => (
+
+ {stage.icon ?? "•"}
+ {stage.name}
+
+ ))}
+
+ )}
+ {lostStages.length > 0 && (
+
+ {t("lifecycle.lostTitle")}
+ {lostStages.map((stage) => (
+
+ {stage.icon ?? "•"}
+ {stage.name}
+
+ ))}
+
+ )}
+ >
+ )
+
+ return (
+
+ {/* Hidden fields to keep RHF wired */}
+
+
+
+
+
+ {t("lifecycle.triggerFromStage")}
+
+ (
+
+ field.onChange(v === ANY_STAGE_VALUE ? "" : v)
+ }
+ value={field.value ? field.value : ANY_STAGE_VALUE}
+ >
+
+
+
+ {renderStageOptions()}
+
+ )}
+ />
+
+
+
+
+ {t("lifecycle.triggerToStage")}
+
+ (
+
+ field.onChange(v === ANY_STAGE_VALUE ? "" : v)
+ }
+ value={field.value ? field.value : ANY_STAGE_VALUE}
+ >
+
+
+
+ {renderStageOptions()}
+
+ )}
+ />
+
+
+ )
+}
diff --git a/apps/builder/src/features/conditions/schemas/custom-field-value-changed.ts b/apps/builder/src/features/conditions/schemas/custom-field-value-changed.ts
index 9612dd3c4..19ab6a6d8 100644
--- a/apps/builder/src/features/conditions/schemas/custom-field-value-changed.ts
+++ b/apps/builder/src/features/conditions/schemas/custom-field-value-changed.ts
@@ -7,7 +7,7 @@ import z from "zod"
export const customFieldValueChanged = z.object({
id: z.string().optional(),
type: z.literal(triggerEventTypes.enum.customFieldValueChanged),
- sourceId: z.string().min(1, "Custom field is required"),
+ sourceId: z.string().min(1, "Campo personalizado é obrigatório"),
operator: z.string(),
value: z.unknown(),
})
diff --git a/apps/builder/src/features/conditions/schemas/date-time-based-trigger.ts b/apps/builder/src/features/conditions/schemas/date-time-based-trigger.ts
index 4855eecf6..51f1db75b 100644
--- a/apps/builder/src/features/conditions/schemas/date-time-based-trigger.ts
+++ b/apps/builder/src/features/conditions/schemas/date-time-based-trigger.ts
@@ -22,7 +22,7 @@ export const dateTimeBasedTrigger = z
if (!data.sourceId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
- message: "Custom field is required",
+ message: "Campo personalizado é obrigatório",
path: ["sourceId"],
})
}
diff --git a/apps/builder/src/features/conditions/schemas/index.ts b/apps/builder/src/features/conditions/schemas/index.ts
index ce32261bd..e0b3d1066 100644
--- a/apps/builder/src/features/conditions/schemas/index.ts
+++ b/apps/builder/src/features/conditions/schemas/index.ts
@@ -1,5 +1,6 @@
import { customFieldValueChanged } from "./custom-field-value-changed"
import { dateTimeBasedTrigger } from "./date-time-based-trigger"
+import { lifecycleStageChanged } from "./lifecycle-stage-changed"
import {
archived,
contactReferredANewContact,
@@ -34,4 +35,5 @@ export const allConditions = {
unsubscribedFromSequence,
contactReferredANewContact,
contactReferredExistingContact,
+ lifecycleStageChanged,
}
diff --git a/apps/builder/src/features/conditions/schemas/lifecycle-stage-changed.ts b/apps/builder/src/features/conditions/schemas/lifecycle-stage-changed.ts
new file mode 100644
index 000000000..21dbe9b60
--- /dev/null
+++ b/apps/builder/src/features/conditions/schemas/lifecycle-stage-changed.ts
@@ -0,0 +1,36 @@
+import { triggerEventTypes } from "@chatbotx.io/database/partials"
+import { zodBigintAsString } from "@chatbotx.io/utils"
+import z from "zod"
+
+/**
+ * Lifecycle stage change condition.
+ *
+ * Semantics:
+ * - `sourceId` is the destination stage id.
+ * - empty string / undefined → any destination (fire on any stage change)
+ * - specific id → fire only when contact moves TO that stage
+ * - `value.fromStageId` (optional) is the origin stage id.
+ * - empty string / undefined → any origin
+ * - specific id → fire only when contact moved FROM that stage
+ *
+ * Combining both lets the user model "from X to Y", "from any to Y", "from X
+ * to any", or "any change at all".
+ */
+export const lifecycleStageChanged = z.object({
+ id: zodBigintAsString().optional(),
+ type: z.literal(triggerEventTypes.enum.lifecycleStageChanged),
+ sourceId: z.string().optional(),
+ value: z
+ .object({
+ fromStageId: z.string().optional(),
+ })
+ .optional(),
+})
+export type LifecycleStageChanged = z.infer
+
+export const defaultFn = (): LifecycleStageChanged => ({
+ type: triggerEventTypes.enum.lifecycleStageChanged,
+ sourceId: "",
+ value: { fromStageId: "" },
+})
+export type DefaultFn = typeof defaultFn
diff --git a/apps/builder/src/features/conditions/schemas/simple-conditions.ts b/apps/builder/src/features/conditions/schemas/simple-conditions.ts
index d17433cc2..38c1f098c 100644
--- a/apps/builder/src/features/conditions/schemas/simple-conditions.ts
+++ b/apps/builder/src/features/conditions/schemas/simple-conditions.ts
@@ -45,7 +45,7 @@ const createConditionWithSourceId = (type: TriggerEventType) =>
z.object({
id: zodBigintAsString().optional(),
type: z.literal(type),
- sourceId: z.string().min(1, "Required"),
+ sourceId: z.string().min(1, "Obrigatório"),
})
export const subscribedToSequence = createConditionWithSourceId(
diff --git a/apps/builder/src/features/contact-inboxes/schema/resource.ts b/apps/builder/src/features/contact-inboxes/schema/resource.ts
index 763447ab6..83dadfbbc 100644
--- a/apps/builder/src/features/contact-inboxes/schema/resource.ts
+++ b/apps/builder/src/features/contact-inboxes/schema/resource.ts
@@ -9,10 +9,24 @@ export const contactInboxResource = createSelectSchema(contactInboxModel, {
contactId: z.string(),
inboxId: z.string(),
channel: z.string(),
-}).pick({
- id: true,
- contactId: true,
- inboxId: true,
- channel: true,
})
+ .pick({
+ id: true,
+ contactId: true,
+ inboxId: true,
+ channel: true,
+ sourceId: true,
+ })
+ .extend({
+ // Inbox relation populada via Drizzle `with` quando query carrega
+ // contactInboxes. Usado no composer pra mostrar o nome verificado do
+ // business (ex: "Renato Silva") em vez do label genérico do canal.
+ inbox: z
+ .object({
+ id: z.string(),
+ name: z.string(),
+ channel: z.string(),
+ })
+ .optional(),
+ })
export type ContactInboxResource = z.infer
diff --git a/apps/builder/src/features/contact-notes/actions/create-contact-note.action.ts b/apps/builder/src/features/contact-notes/actions/create-contact-note.action.ts
index 72635d5dc..d1eed686f 100644
--- a/apps/builder/src/features/contact-notes/actions/create-contact-note.action.ts
+++ b/apps/builder/src/features/contact-notes/actions/create-contact-note.action.ts
@@ -37,7 +37,7 @@ export const createContactNote = async (
workspaceId: ctx.workspaceId,
id: ctx.id,
},
- message: "Contact not found",
+ message: "Contato não encontrado",
})
return await db
diff --git a/apps/builder/src/features/contact-notes/actions/edit-contact-note.action.ts b/apps/builder/src/features/contact-notes/actions/edit-contact-note.action.ts
index 87c706f37..38003ea1e 100644
--- a/apps/builder/src/features/contact-notes/actions/edit-contact-note.action.ts
+++ b/apps/builder/src/features/contact-notes/actions/edit-contact-note.action.ts
@@ -35,7 +35,7 @@ export const editContactNote = async (
workspaceId: ctx.workspaceId,
id: ctx.id,
},
- message: "Contact not found",
+ message: "Contato não encontrado",
})
const foundContactNote = await findOrFail({
diff --git a/apps/builder/src/features/contact-notes/delete-contact-note-dialog.tsx b/apps/builder/src/features/contact-notes/delete-contact-note-dialog.tsx
index 6fd049a2e..ecd63c1c2 100644
--- a/apps/builder/src/features/contact-notes/delete-contact-note-dialog.tsx
+++ b/apps/builder/src/features/contact-notes/delete-contact-note-dialog.tsx
@@ -77,7 +77,7 @@ export function DeleteContactNoteDialog({
execute({ contactNoteId: contactNoteId ?? "" })}
size="sm"
diff --git a/apps/builder/src/features/contact-sequences/actions/update-contact-sequence.action.ts b/apps/builder/src/features/contact-sequences/actions/update-contact-sequence.action.ts
index 63cb39143..835f57ee8 100644
--- a/apps/builder/src/features/contact-sequences/actions/update-contact-sequence.action.ts
+++ b/apps/builder/src/features/contact-sequences/actions/update-contact-sequence.action.ts
@@ -146,7 +146,7 @@ export const updateContactSequenceAction = workspaceActionClient
id: parsedInput.contactId,
workspaceId,
},
- message: "Contact not found",
+ message: "Contato não encontrado",
})
const currentIds = await getCurrentSequenceIds(db, contact.id, workspaceId)
diff --git a/apps/builder/src/features/contacts/actions/add-contact-custom-field.action.ts b/apps/builder/src/features/contacts/actions/add-contact-custom-field.action.ts
index 83e5ec201..0d17d787d 100644
--- a/apps/builder/src/features/contacts/actions/add-contact-custom-field.action.ts
+++ b/apps/builder/src/features/contacts/actions/add-contact-custom-field.action.ts
@@ -1,5 +1,11 @@
"use server"
+import {
+ auditLogActions,
+ contactEventTypes,
+ logAudit,
+ recordContactEventBulk,
+} from "@chatbotx.io/business"
import { db, eq, findOrFail } from "@chatbotx.io/database/client"
import {
contactCustomFieldModel,
@@ -26,12 +32,31 @@ export const addContactCustomFieldAction = workspaceActionClient
const {
bindArgsParsedInputs: [workspaceId],
parsedInput,
+ ctx: { user },
} = props
await addContactCustomFields({
bindArgsParsedInputs: [workspaceId],
parsedInput,
})
+
+ if (parsedInput.ids.length > 0) {
+ await Promise.all([
+ logAudit({
+ workspaceId,
+ userId: user.id,
+ action: auditLogActions.CONTACT_FIELD_UPDATED,
+ detail: `Campo personalizado "${parsedInput.customFieldId}" atualizado em ${parsedInput.ids.length} contato(s)`,
+ }),
+ recordContactEventBulk({
+ contactIds: parsedInput.ids,
+ workspaceId,
+ eventType: contactEventTypes.FIELD_UPDATED,
+ meta: { customFieldId: parsedInput.customFieldId },
+ actorUserId: user.id,
+ }),
+ ])
+ }
})
export const addContactCustomFields = async ({
@@ -129,7 +154,7 @@ export const addContactCustomFields = async ({
parsedInput.value,
)
} catch (error) {
- console.error("Failed to emit customFieldChanged event:", error)
+ console.error("Falha ao emitir evento customFieldChanged:", error)
}
}
@@ -197,7 +222,7 @@ export const setContactCustomFieldValue = async ({
value,
)
} catch (error) {
- console.error("Failed to emit customFieldChanged event:", error)
+ console.error("Falha ao emitir evento customFieldChanged:", error)
}
revalidateCacheTags(`workspaces:${workspaceId}#contacts`)
diff --git a/apps/builder/src/features/contacts/actions/add-contact-tag.action.ts b/apps/builder/src/features/contacts/actions/add-contact-tag.action.ts
index 2bba7167b..174a89050 100644
--- a/apps/builder/src/features/contacts/actions/add-contact-tag.action.ts
+++ b/apps/builder/src/features/contacts/actions/add-contact-tag.action.ts
@@ -1,5 +1,11 @@
"use server"
+import {
+ auditLogActions,
+ contactEventTypes,
+ logAudit,
+ recordContactEventBulk,
+} from "@chatbotx.io/business"
import { and, db, eq, findOrFail } from "@chatbotx.io/database/client"
import {
contactModel,
@@ -26,14 +32,34 @@ export const addContactTagAction = workspaceActionClient
async ({
bindArgsParsedInputs: [workspaceId],
parsedInput,
+ ctx: { user },
}: {
bindArgsParsedInputs: WorkspaceIdRequestParams
parsedInput: AddContactTagRequest
+ ctx: { user: { id: string } }
}) => {
await addContactTags({
workspaceId,
parsedInput,
})
+
+ if (parsedInput.ids.length > 0 && parsedInput.tags.length > 0) {
+ await Promise.all([
+ logAudit({
+ workspaceId,
+ userId: user.id,
+ action: auditLogActions.CONTACT_TAG_ADDED,
+ detail: `Etiqueta(s) ${parsedInput.tags.map((t) => `"${t}"`).join(", ")} aplicada(s) em ${parsedInput.ids.length} contato(s)`,
+ }),
+ recordContactEventBulk({
+ contactIds: parsedInput.ids,
+ workspaceId,
+ eventType: contactEventTypes.TAG_ADDED,
+ meta: { tagNames: parsedInput.tags },
+ actorUserId: user.id,
+ }),
+ ])
+ }
},
)
@@ -110,7 +136,7 @@ export const addContactTags = async ({
try {
await emitTagApplied(workspaceId, contact.id, tag.id)
} catch (error) {
- console.error("Failed to emit tagApplied event:", error)
+ console.error("Falha ao emitir evento tagApplied:", error)
}
}
}
@@ -151,7 +177,7 @@ export const attachContactTag = async ({
try {
await emitTagApplied(workspaceId, contactId, tagId)
} catch (error) {
- console.error("Failed to emit tagApplied event:", error)
+ console.error("Falha ao emitir evento tagApplied:", error)
}
}
@@ -192,6 +218,6 @@ export const detachContactTag = async ({
try {
await emitTagRemoved(workspaceId, contactId, tagId)
} catch (error) {
- console.error("Failed to emit tagRemoved event:", error)
+ console.error("Falha ao emitir evento tagRemoved:", error)
}
}
diff --git a/apps/builder/src/features/contacts/actions/block-contact.action.ts b/apps/builder/src/features/contacts/actions/block-contact.action.ts
index 074e5f27c..3480e1c6b 100644
--- a/apps/builder/src/features/contacts/actions/block-contact.action.ts
+++ b/apps/builder/src/features/contacts/actions/block-contact.action.ts
@@ -1,5 +1,11 @@
"use server"
+import {
+ auditLogActions,
+ contactEventTypes,
+ logAudit,
+ recordContactEvent,
+} from "@chatbotx.io/business"
import { db, eq, findOrFail } from "@chatbotx.io/database/client"
import { contactModel } from "@chatbotx.io/database/schema"
import { emit } from "@chatbotx.io/event-bus"
@@ -16,9 +22,30 @@ export const blockContactAction = workspaceActionClient
.action(async (props) => {
const {
bindArgsParsedInputs: [workspaceId, id],
+ ctx: { user },
} = props
- await blockContact({ workspaceId, id })
+ const contact = await blockContact({ workspaceId, id })
+ const label =
+ [contact.firstName, contact.lastName].filter(Boolean).join(" ") ||
+ contact.phoneNumber ||
+ contact.email ||
+ contact.id
+ await Promise.all([
+ logAudit({
+ workspaceId,
+ userId: user.id,
+ action: auditLogActions.CONTACT_BLOCKED,
+ detail: `Contato "${label}" bloqueado`,
+ }),
+ recordContactEvent({
+ contactId: contact.id,
+ workspaceId,
+ eventType: contactEventTypes.BLOCKED,
+ meta: { label },
+ actorUserId: user.id,
+ }),
+ ])
})
export const blockContact = async (ctx: {
@@ -31,7 +58,7 @@ export const blockContact = async (ctx: {
workspaceId: ctx.workspaceId,
id: ctx.id,
},
- message: "Contact not found",
+ message: "Contato não encontrado",
})
const contact = await db
@@ -75,4 +102,6 @@ export const blockContact = async (ctx: {
contact,
},
})
+
+ return contact
}
diff --git a/apps/builder/src/features/contacts/actions/create-contact.action.ts b/apps/builder/src/features/contacts/actions/create-contact.action.ts
index 798e3bbb0..07bd08113 100644
--- a/apps/builder/src/features/contacts/actions/create-contact.action.ts
+++ b/apps/builder/src/features/contacts/actions/create-contact.action.ts
@@ -1,10 +1,18 @@
"use server"
+import {
+ auditLogActions,
+ contactEventTypes,
+ logAudit,
+ recordContactEvent,
+} from "@chatbotx.io/business"
import { db, eq, findOrFail, sql } from "@chatbotx.io/database/client"
import { channelTypes, contactSources } from "@chatbotx.io/database/partials"
import {
+ contactCustomFieldModel,
contactInboxModel,
contactModel,
+ contactsToTagsModel,
conversationModel,
inboxModel,
workspaceUsageModel,
@@ -33,21 +41,48 @@ export const createContactAction = workspaceActionClient
async ({
bindArgsParsedInputs: [workspaceId],
parsedInput,
+ ctx: { user },
}: {
bindArgsParsedInputs: WorkspaceIdRequestParams
parsedInput: CreateContactRequest
+ ctx: { user: { id: string } }
}) => {
- await createContact({ workspaceId, parsedInput })
+ const result = await createContact({ workspaceId, parsedInput })
+ if (result && "id" in result) {
+ const label =
+ [parsedInput.firstName, parsedInput.lastName]
+ .filter(Boolean)
+ .join(" ") ||
+ parsedInput.phoneNumber ||
+ parsedInput.email ||
+ "(sem nome)"
+ await Promise.all([
+ logAudit({
+ workspaceId,
+ userId: user.id,
+ action: auditLogActions.CONTACT_CREATED,
+ detail: `Contato "${label}" criado`,
+ }),
+ recordContactEvent({
+ contactId: result.id,
+ workspaceId,
+ eventType: contactEventTypes.CREATED,
+ meta: { label },
+ actorUserId: user.id,
+ }),
+ ])
+ }
+ return result
},
)
-export const createContact = async ({
+export async function createContact({
workspaceId,
parsedInput,
}: {
workspaceId: string
parsedInput: CreateContactRequest
-}): Promise => {
+}): Promise {
// Make sure phone number is not exists in the workspace
const existedContact = await db.query.contactModel.findFirst({
where: {
@@ -57,7 +92,7 @@ export const createContact = async ({
})
if (existedContact) {
return returnValidationErrors(createContactRequest, {
- _errors: ["Validation Exception"],
+ _errors: ["Exceção de Validação"],
phoneNumber: {
_errors: ["Phone number is exists"],
},
@@ -77,18 +112,19 @@ export const createContact = async ({
})
if (workspaceUsage.contactsCount >= workspaceUsage.maxContacts) {
return returnValidationErrors(createContactRequest, {
- _errors: ["Validation Exception"],
+ _errors: ["Exceção de Validação"],
phoneNumber: {
_errors: ["Max contacts reached"],
},
})
}
+ const { assigneeUserId, tagIds, customFields, ...contactValues } = parsedInput
const [contact, contactInbox] = await db.transaction(async (tx) => {
const [newContact] = await tx
.insert(contactModel)
.values({
- ...parsedInput,
+ ...contactValues,
workspaceId,
id: createId(),
})
@@ -117,8 +153,33 @@ export const createContact = async ({
workspaceId,
contactId: newContact.id,
id: createId(),
+ ...(assigneeUserId ? { assignedUserId: assigneeUserId } : {}),
})
+ if (tagIds && tagIds.length > 0) {
+ await tx.insert(contactsToTagsModel).values(
+ tagIds.map((tagId) => ({
+ contactId: newContact.id,
+ tagId,
+ })),
+ )
+ }
+
+ if (customFields) {
+ const entries = Object.entries(customFields).filter(
+ ([, value]) => value !== "" && value !== null && value !== undefined,
+ )
+ if (entries.length > 0) {
+ await tx.insert(contactCustomFieldModel).values(
+ entries.map(([customFieldId, value]) => ({
+ contactId: newContact.id,
+ customFieldId,
+ value,
+ })),
+ )
+ }
+ }
+
return [newContact, newContactInbox]
})
diff --git a/apps/builder/src/features/contacts/actions/delete-contact-custom-field.action.ts b/apps/builder/src/features/contacts/actions/delete-contact-custom-field.action.ts
index c07b61a52..1e4ee4cc5 100644
--- a/apps/builder/src/features/contacts/actions/delete-contact-custom-field.action.ts
+++ b/apps/builder/src/features/contacts/actions/delete-contact-custom-field.action.ts
@@ -1,5 +1,11 @@
"use server"
+import {
+ auditLogActions,
+ contactEventTypes,
+ logAudit,
+ recordContactEventBulk,
+} from "@chatbotx.io/business"
import { db } from "@chatbotx.io/database/client"
import {
type WorkspaceIdRequestParams,
@@ -19,15 +25,38 @@ export const deleteContactCustomFieldAction = workspaceActionClient
async ({
bindArgsParsedInputs: [workspaceId],
parsedInput,
+ ctx: { user },
}: {
bindArgsParsedInputs: WorkspaceIdRequestParams
parsedInput: DeleteContactCustomFieldsRequest
+ ctx: { user: { id: string } }
}) => {
await deleteContactCustomFields({
workspaceId,
contactIds: parsedInput.ids,
customFieldId: parsedInput.customFieldId,
})
+
+ if (parsedInput.ids.length > 0) {
+ await Promise.all([
+ logAudit({
+ workspaceId,
+ userId: user.id,
+ action: auditLogActions.CONTACT_FIELD_UPDATED,
+ detail: `Campo personalizado "${parsedInput.customFieldId}" limpo em ${parsedInput.ids.length} contato(s)`,
+ }),
+ recordContactEventBulk({
+ contactIds: parsedInput.ids,
+ workspaceId,
+ eventType: contactEventTypes.FIELD_UPDATED,
+ meta: {
+ customFieldId: parsedInput.customFieldId,
+ cleared: true,
+ },
+ actorUserId: user.id,
+ }),
+ ])
+ }
},
)
diff --git a/apps/builder/src/features/contacts/actions/delete-contact.action.ts b/apps/builder/src/features/contacts/actions/delete-contact.action.ts
index 9d7dedb62..c41a48d5c 100644
--- a/apps/builder/src/features/contacts/actions/delete-contact.action.ts
+++ b/apps/builder/src/features/contacts/actions/delete-contact.action.ts
@@ -1,5 +1,6 @@
"use server"
+import { auditLogActions, logAudit } from "@chatbotx.io/business"
import { and, db, inArray } from "@chatbotx.io/database/client"
import { contactModel } from "@chatbotx.io/database/schema"
import { emit } from "@chatbotx.io/event-bus"
@@ -19,9 +20,11 @@ export const deleteContactAction = workspaceActionClient
async ({
bindArgsParsedInputs: [workspaceId],
parsedInput,
+ ctx: { user },
}: {
bindArgsParsedInputs: WorkspaceIdRequestParams
parsedInput: BulkUpdateIdsRequest
+ ctx: { user: { id: string } }
}) => {
const contacts = await db.query.contactModel.findMany({
where: {
@@ -44,6 +47,25 @@ export const deleteContactAction = workspaceActionClient
),
)
+ if (contacts.length > 0) {
+ const labels = contacts
+ .map(
+ (c) =>
+ [c.firstName, c.lastName].filter(Boolean).join(" ") ||
+ c.phoneNumber ||
+ c.email ||
+ c.id,
+ )
+ .slice(0, 5)
+ const more = contacts.length > 5 ? ` (+${contacts.length - 5})` : ""
+ await logAudit({
+ workspaceId,
+ userId: user.id,
+ action: auditLogActions.CONTACT_DELETED,
+ detail: `Contato(s) excluído(s): ${labels.map((l) => `"${l}"`).join(", ")}${more}`,
+ })
+ }
+
const occurredAt = new Date()
for (const contact of contacts) {
diff --git a/apps/builder/src/features/contacts/actions/export-contacts.action.ts b/apps/builder/src/features/contacts/actions/export-contacts.action.ts
index a4eff6a68..648fc67fc 100644
--- a/apps/builder/src/features/contacts/actions/export-contacts.action.ts
+++ b/apps/builder/src/features/contacts/actions/export-contacts.action.ts
@@ -40,9 +40,9 @@ export const exportContactsAction = workspaceActionClient
)
if (contactsCount === 0) {
return returnValidationErrors(exportContactsRequest, {
- _errors: ["Validation Exception"],
+ _errors: ["Exceção de Validação"],
fields: {
- _errors: ["No contacts found"],
+ _errors: ["Nenhum contato encontrado"],
},
})
}
@@ -65,7 +65,7 @@ export const exportContactsAction = workspaceActionClient
userId: user.id,
workspaceId,
action: "export",
- detail: "Contacts",
+ detail: "Contatos",
},
}),
])
diff --git a/apps/builder/src/features/contacts/actions/import-contacts.action.ts b/apps/builder/src/features/contacts/actions/import-contacts.action.ts
index a2a47f4dc..2698443f8 100644
--- a/apps/builder/src/features/contacts/actions/import-contacts.action.ts
+++ b/apps/builder/src/features/contacts/actions/import-contacts.action.ts
@@ -1,5 +1,16 @@
"use server"
+import { auditLogActions, logAudit } from "@chatbotx.io/business"
+import { db, findOrFail } from "@chatbotx.io/database/client"
+import { channelTypes, contactSources } from "@chatbotx.io/database/partials"
+import {
+ contactCustomFieldModel,
+ contactInboxModel,
+ contactModel,
+ inboxModel,
+} from "@chatbotx.io/database/schema"
+import { createId } from "@chatbotx.io/utils"
+import { randomString } from "remeda"
import {
type WorkspaceIdRequestParams,
workspaceIdrequestParams,
@@ -11,6 +22,65 @@ import {
importContactsRequest,
} from "../schemas/action"
+// MVP inline (até 500 rows). Pra batches maiores, mover pra worker job
+// (`@chatbotx.io/worker-config` IntegrationJobAction.importContacts).
+const MAX_IMPORT_ROWS = 500
+
+// Regex top-level (regra biome useTopLevelRegex).
+const CSV_LINE_SPLIT = /\r?\n/
+
+/**
+ * Parser CSV simples — handle aspas duplas e escape "". Quebra linhas por
+ * \r\n ou \n. Não usa lib pra evitar dep nova. Suficiente pra CSV bem-formado
+ * exportado de Excel/Sheets.
+ *
+ * NÃO suporta multilinha dentro de campo. Suficiente pro MVP do import.
+ */
+function parseCsv(text: string): string[][] {
+ const rows: string[][] = []
+ const lines = text.split(CSV_LINE_SPLIT)
+ for (const line of lines) {
+ if (!line.trim()) {
+ continue
+ }
+ const cells: string[] = []
+ let cur = ""
+ let inQuotes = false
+ for (let i = 0; i < line.length; i++) {
+ const ch = line[i]
+ if (inQuotes) {
+ if (ch === '"' && line[i + 1] === '"') {
+ cur += '"'
+ i++
+ } else if (ch === '"') {
+ inQuotes = false
+ } else {
+ cur += ch
+ }
+ } else if (ch === '"') {
+ inQuotes = true
+ } else if (ch === ",") {
+ cells.push(cur)
+ cur = ""
+ } else {
+ cur += ch
+ }
+ }
+ cells.push(cur)
+ rows.push(cells.map((c) => c.trim()))
+ }
+ return rows
+}
+
+// E.164 simplificado: começa com + e tem 8-15 dígitos.
+const E164_RE = /^\+\d{8,15}$/
+
+type ImportResult = {
+ imported: number
+ skipped: number
+ errors: { row: number; reason: string }[]
+}
+
export const importContactsAction = workspaceActionClient
.bindArgsSchemas(workspaceIdrequestParams)
.inputSchema(importContactsRequest)
@@ -18,17 +88,165 @@ export const importContactsAction = workspaceActionClient
async ({
bindArgsParsedInputs: [workspaceId],
parsedInput,
+ ctx: { user },
}: {
bindArgsParsedInputs: WorkspaceIdRequestParams
parsedInput: ImportContactsRequest
- }) => {
- // TODO
- console.log(JSON.stringify(parsedInput, null, 2))
- await Promise.resolve(parsedInput)
+ ctx: { user: { id: string } }
+ }): Promise => {
+ const text = await parsedInput.file.text()
+ const rows = parseCsv(text)
+
+ if (rows.length < 2) {
+ return {
+ imported: 0,
+ skipped: 0,
+ errors: [{ row: 0, reason: "CSV vazio ou sem header" }],
+ }
+ }
+
+ const headers = rows[0]
+ const dataRows = rows.slice(1, 1 + MAX_IMPORT_ROWS)
+
+ // Resolver índices das colunas pelo nome enviado no parsedInput.
+ const colIdx = (name: string | undefined): number =>
+ name
+ ? headers.findIndex((h) => h.toLowerCase() === name.toLowerCase())
+ : -1
+
+ const phoneIdx = colIdx(parsedInput.phoneNumber)
+ const emailIdx = colIdx(parsedInput.email)
+ const firstNameIdx = colIdx(parsedInput.firstName)
+ const lastNameIdx = colIdx(parsedInput.lastName)
+
+ // fieldMapping é List<{column, customFieldId}> — converte pra Map idx → customFieldId
+ const customFieldByIdx = new Map()
+ for (const m of parsedInput.fieldMapping ?? []) {
+ const idx = headers.findIndex(
+ (h) => h.toLowerCase() === m.column.toLowerCase(),
+ )
+ if (idx >= 0) {
+ customFieldByIdx.set(idx, m.customFieldId)
+ }
+ }
+
+ // Inbox webchat default — match padrão do createContact
+ const inbox = await findOrFail({
+ table: inboxModel,
+ where: { workspaceId, channel: channelTypes.enum.webchat },
+ message: "Webchat inbox not found",
+ })
+
+ const result: ImportResult = { imported: 0, skipped: 0, errors: [] }
+ const seenInThisBatch = new Set() // dedup intra-batch por phone
+
+ for (let i = 0; i < dataRows.length; i++) {
+ const row = dataRows[i]
+ const rowNumber = i + 2 // 1-indexed + header
+
+ const phoneNumber = phoneIdx >= 0 ? (row[phoneIdx] || "").trim() : ""
+ const email = emailIdx >= 0 ? (row[emailIdx] || "").trim() : ""
+ const firstName =
+ firstNameIdx >= 0 ? (row[firstNameIdx] || "").trim() : ""
+ const lastName = lastNameIdx >= 0 ? (row[lastNameIdx] || "").trim() : ""
+
+ // Phone obrigatório E.164 — ChatbotX usa phone como identificador único
+ if (!phoneNumber) {
+ result.errors.push({ row: rowNumber, reason: "phoneNumber vazio" })
+ continue
+ }
+ if (!E164_RE.test(phoneNumber)) {
+ result.errors.push({
+ row: rowNumber,
+ reason: `phoneNumber inválido (precisa formato E.164 +xxx): "${phoneNumber}"`,
+ })
+ continue
+ }
+
+ if (seenInThisBatch.has(phoneNumber)) {
+ result.skipped++
+ continue
+ }
+ seenInThisBatch.add(phoneNumber)
+
+ // Dedup com DB — skip se phoneNumber já existe no workspace
+ const existing = await db.query.contactModel.findFirst({
+ columns: { id: true },
+ where: { workspaceId, phoneNumber },
+ })
+ if (existing) {
+ result.skipped++
+ continue
+ }
+
+ // Insert contact + contactInbox em transaction
+ try {
+ await db.transaction(async (tx) => {
+ const [newContact] = await tx
+ .insert(contactModel)
+ .values({
+ id: createId(),
+ workspaceId,
+ phoneNumber,
+ email: email || null,
+ firstName: firstName || null,
+ lastName: lastName || null,
+ })
+ .returning()
+
+ await tx.insert(contactInboxModel).values({
+ originalContactId: newContact.id,
+ contactId: newContact.id,
+ inboxId: inbox.id,
+ channel: channelTypes.enum.webchat,
+ source: contactSources.enum.imported,
+ sourceId: `${randomString()}${createId()}`,
+ })
+
+ // Custom fields
+ for (const [idx, customFieldId] of customFieldByIdx.entries()) {
+ const value = (row[idx] || "").trim()
+ if (!value) {
+ continue
+ }
+ await tx
+ .insert(contactCustomFieldModel)
+ .values({
+ contactId: newContact.id,
+ customFieldId,
+ value,
+ })
+ .onConflictDoNothing()
+ }
+ })
+ result.imported++
+ } catch (err) {
+ result.errors.push({
+ row: rowNumber,
+ reason: err instanceof Error ? err.message : "Falha no insert",
+ })
+ }
+ }
+
+ if (rows.length - 1 > MAX_IMPORT_ROWS) {
+ result.errors.push({
+ row: MAX_IMPORT_ROWS + 1,
+ reason: `Apenas as primeiras ${MAX_IMPORT_ROWS} linhas foram importadas. Total de linhas no CSV: ${rows.length - 1}.`,
+ })
+ }
+
+ await logAudit({
+ workspaceId,
+ userId: user.id,
+ action: auditLogActions.CONTACT_IMPORTED,
+ detail: `${result.imported} contato(s) importado(s), ${result.skipped} pulado(s), ${result.errors.length} erro(s) (CSV "${parsedInput.file.name}")`,
+ })
revalidateCacheTags([
`workspaces:${workspaceId}#contacts`,
`workspaces:${workspaceId}#conversations`,
])
+
+ return result
},
)
diff --git a/apps/builder/src/features/contacts/actions/merge-contacts.action.ts b/apps/builder/src/features/contacts/actions/merge-contacts.action.ts
new file mode 100644
index 000000000..062425bdd
--- /dev/null
+++ b/apps/builder/src/features/contacts/actions/merge-contacts.action.ts
@@ -0,0 +1,49 @@
+"use server"
+
+import { auditLogActions, logAudit, mergeContacts } from "@chatbotx.io/business"
+import {
+ type WorkspaceIdRequestParams,
+ workspaceIdrequestParams,
+} from "@/features/common/schemas"
+import { revalidateCacheTags } from "@/lib/cache-helper"
+import { workspaceActionClient } from "@/lib/safe-action"
+import {
+ type MergeContactsRequest,
+ mergeContactsRequest,
+} from "../schemas/merge"
+
+export const mergeContactsAction = workspaceActionClient
+ .bindArgsSchemas(workspaceIdrequestParams)
+ .inputSchema(mergeContactsRequest)
+ .action(
+ async ({
+ bindArgsParsedInputs: [workspaceId],
+ parsedInput,
+ ctx: { user },
+ }: {
+ bindArgsParsedInputs: WorkspaceIdRequestParams
+ parsedInput: MergeContactsRequest
+ ctx: { user: { id: string } }
+ }) => {
+ const result = await mergeContacts({
+ workspaceId,
+ primaryId: parsedInput.primaryId,
+ duplicateIds: parsedInput.duplicateIds,
+ actorUserId: user.id,
+ })
+
+ await logAudit({
+ workspaceId,
+ userId: user.id,
+ action: auditLogActions.CONTACT_MERGED,
+ detail: `Contato ${result.primaryId} recebeu merge de ${result.mergedCount} duplicata(s): ${result.mergedIds.join(", ")}`,
+ })
+
+ revalidateCacheTags([
+ `workspaces:${workspaceId}#contacts`,
+ `workspaces:${workspaceId}#conversations`,
+ ])
+
+ return result
+ },
+ )
diff --git a/apps/builder/src/features/contacts/actions/remove-contact-tag.action.ts b/apps/builder/src/features/contacts/actions/remove-contact-tag.action.ts
index 6407322be..e0abbc3c1 100644
--- a/apps/builder/src/features/contacts/actions/remove-contact-tag.action.ts
+++ b/apps/builder/src/features/contacts/actions/remove-contact-tag.action.ts
@@ -1,5 +1,11 @@
"use server"
+import {
+ auditLogActions,
+ contactEventTypes,
+ logAudit,
+ recordContactEventBulk,
+} from "@chatbotx.io/business"
import { and, db, eq, inArray } from "@chatbotx.io/database/client"
import { contactsToTagsModel } from "@chatbotx.io/database/schema"
import { emitTagRemoved } from "@chatbotx.io/events"
@@ -21,14 +27,40 @@ export const removeContactTagAction = workspaceActionClient
async ({
bindArgsParsedInputs: [workspaceId],
parsedInput,
+ ctx: { user },
}: {
bindArgsParsedInputs: WorkspaceIdRequestParams
parsedInput: RemoveContactTagsRequest
+ ctx: { user: { id: string } }
}) => {
await removeContactTags({
workspaceId,
parsedInput,
})
+
+ if (parsedInput.ids.length > 0 && parsedInput.tags.length > 0) {
+ // tags em remove-contact-tag são IDs, então busca nomes pra log
+ const tagNames = await db.query.tagModel.findMany({
+ columns: { name: true },
+ where: { workspaceId, id: { in: parsedInput.tags } },
+ })
+ const names = tagNames.map((t) => t.name)
+ await Promise.all([
+ logAudit({
+ workspaceId,
+ userId: user.id,
+ action: auditLogActions.CONTACT_TAG_REMOVED,
+ detail: `Etiqueta(s) ${names.map((n) => `"${n}"`).join(", ")} removida(s) de ${parsedInput.ids.length} contato(s)`,
+ }),
+ recordContactEventBulk({
+ contactIds: parsedInput.ids,
+ workspaceId,
+ eventType: contactEventTypes.TAG_REMOVED,
+ meta: { tagNames: names },
+ actorUserId: user.id,
+ }),
+ ])
+ }
},
)
@@ -94,7 +126,7 @@ export const removeContactTags = async ({
try {
await emitTagRemoved(workspaceId, contact.id, tag.id)
} catch (error) {
- console.error("Failed to emit tagRemoved event:", error)
+ console.error("Falha ao emitir evento tagRemoved:", error)
}
}
}
diff --git a/apps/builder/src/features/contacts/actions/unblock-contact.action.ts b/apps/builder/src/features/contacts/actions/unblock-contact.action.ts
index cd1c698e1..f07d9b42f 100644
--- a/apps/builder/src/features/contacts/actions/unblock-contact.action.ts
+++ b/apps/builder/src/features/contacts/actions/unblock-contact.action.ts
@@ -1,5 +1,11 @@
"use server"
+import {
+ auditLogActions,
+ contactEventTypes,
+ logAudit,
+ recordContactEvent,
+} from "@chatbotx.io/business"
import { db, eq, findOrFail } from "@chatbotx.io/database/client"
import { contactModel } from "@chatbotx.io/database/schema"
import { zodBigintAsString } from "@chatbotx.io/utils"
@@ -15,9 +21,30 @@ export const unblockContactAction = workspaceActionClient
.action(async (props) => {
const {
bindArgsParsedInputs: [workspaceId, id],
+ ctx: { user },
} = props
- await unblockContact({ workspaceId, id })
+ const contact = await unblockContact({ workspaceId, id })
+ const label =
+ [contact.firstName, contact.lastName].filter(Boolean).join(" ") ||
+ contact.phoneNumber ||
+ contact.email ||
+ contact.id
+ await Promise.all([
+ logAudit({
+ workspaceId,
+ userId: user.id,
+ action: auditLogActions.CONTACT_UNBLOCKED,
+ detail: `Contato "${label}" desbloqueado`,
+ }),
+ recordContactEvent({
+ contactId: contact.id,
+ workspaceId,
+ eventType: contactEventTypes.UNBLOCKED,
+ meta: { label },
+ actorUserId: user.id,
+ }),
+ ])
})
export const unblockContact = async (ctx: {
@@ -30,7 +57,7 @@ export const unblockContact = async (ctx: {
workspaceId: ctx.workspaceId,
id: ctx.id,
},
- message: "Contact not found",
+ message: "Contato não encontrado",
})
const contact = await db
@@ -53,4 +80,6 @@ export const unblockContact = async (ctx: {
contact,
},
})
+
+ return contact
}
diff --git a/apps/builder/src/features/contacts/actions/unmerge-contacts.action.ts b/apps/builder/src/features/contacts/actions/unmerge-contacts.action.ts
new file mode 100644
index 000000000..00fea019e
--- /dev/null
+++ b/apps/builder/src/features/contacts/actions/unmerge-contacts.action.ts
@@ -0,0 +1,115 @@
+"use server"
+
+import {
+ auditLogActions,
+ contactEventTypes,
+ logAudit,
+ recordContactEvent,
+} from "@chatbotx.io/business"
+import { ChatbotXException } from "@chatbotx.io/business/errors"
+import { and, db, eq, inArray, isNotNull } from "@chatbotx.io/database/client"
+import { contactModel } from "@chatbotx.io/database/schema"
+import { workspaceIdrequestParams } from "@/features/common/schemas"
+import { revalidateCacheTags } from "@/lib/cache-helper"
+import { workspaceActionClient } from "@/lib/safe-action"
+import { unmergeContactsRequest } from "../schemas/unmerge"
+
+/**
+ * Desfaz uma fusão de contatos (#17 — 2026-05-27).
+ *
+ * Estratégia: limpa mergedIntoId/mergedAt/mergedByUserId dos duplicates.
+ * Contatos voltam a aparecer na listagem normal.
+ *
+ * NÃO reverte movimentos feitos no momento do merge:
+ * - Conversations que foram movidas continuam no primary
+ * - Tags que foram movidas continuam no primary
+ * - Custom fields que foram movidos continuam no primary
+ *
+ * UI deve avisar o usuário disso. Reversão 100% automática exige snapshot
+ * pré-merge (debt list pra Fase 2).
+ *
+ * Validações:
+ * - Todos os duplicateIds devem ter mergedIntoId = primaryId (não pode
+ * unmergear contato que foi fundido em OUTRO primary).
+ * - primaryId e duplicateIds pertencem ao workspace.
+ */
+export const unmergeContactsAction = workspaceActionClient
+ .bindArgsSchemas(workspaceIdrequestParams)
+ .inputSchema(unmergeContactsRequest)
+ .action(async (props) => {
+ const {
+ bindArgsParsedInputs: [workspaceId],
+ parsedInput,
+ ctx: { user },
+ } = props
+ const { primaryId, duplicateIds } = parsedInput
+
+ if (duplicateIds.includes(primaryId)) {
+ throw new ChatbotXException(
+ "primaryId não pode estar na lista de duplicates.",
+ )
+ }
+
+ // Valida: todos os duplicates existem no workspace E estão fundidos
+ // no primary informado (evita unmerge cruzado entre primaries).
+ const duplicates = await db.query.contactModel.findMany({
+ where: {
+ workspaceId,
+ id: { in: duplicateIds },
+ mergedIntoId: primaryId,
+ },
+ columns: { id: true },
+ })
+ if (duplicates.length !== duplicateIds.length) {
+ throw new ChatbotXException(
+ `Alguns contatos não estão fundidos neste primary (esperado ${duplicateIds.length}, encontrado ${duplicates.length}).`,
+ )
+ }
+
+ await db
+ .update(contactModel)
+ .set({
+ mergedIntoId: null,
+ mergedAt: null,
+ mergedByUserId: null,
+ })
+ .where(
+ and(
+ eq(contactModel.workspaceId, workspaceId),
+ inArray(contactModel.id, duplicateIds),
+ // Defesa em profundidade — espelha o filtro acima.
+ isNotNull(contactModel.mergedIntoId),
+ ),
+ )
+
+ await logAudit({
+ workspaceId,
+ userId: user.id,
+ action: auditLogActions.CONTACT_UNMERGED,
+ detail: `Contato ${primaryId} teve ${duplicateIds.length} fusão(ões) desfeita(s): ${duplicateIds.join(", ")}`,
+ })
+
+ // Registra no PRIMARY (não nos duplicates restaurados) — o primary
+ // é o "agregador" do histórico de merges, igual o evento MERGED faz.
+ recordContactEvent({
+ contactId: primaryId,
+ workspaceId,
+ eventType: contactEventTypes.UNMERGED,
+ actorUserId: user.id,
+ meta: {
+ unmergedIds: duplicateIds,
+ unmergedCount: duplicateIds.length,
+ },
+ })
+
+ revalidateCacheTags([
+ `workspaces:${workspaceId}#contacts`,
+ `workspaces:${workspaceId}#conversations`,
+ ])
+
+ return {
+ primaryId,
+ unmergedCount: duplicateIds.length,
+ unmergedIds: duplicateIds,
+ }
+ })
diff --git a/apps/builder/src/features/contacts/actions/update-contact-field.action.ts b/apps/builder/src/features/contacts/actions/update-contact-field.action.ts
index 74503005f..dd18f25ba 100644
--- a/apps/builder/src/features/contacts/actions/update-contact-field.action.ts
+++ b/apps/builder/src/features/contacts/actions/update-contact-field.action.ts
@@ -1,5 +1,11 @@
"use server"
+import {
+ auditLogActions,
+ contactEventTypes,
+ logAudit,
+ recordContactEvent,
+} from "@chatbotx.io/business"
import { db, eq, findOrFail } from "@chatbotx.io/database/client"
import {
type FillableContactKey,
@@ -27,9 +33,29 @@ export const updateContactFieldAction = workspaceActionClient
const {
bindArgsParsedInputs: [workspaceId, id],
parsedInput,
+ ctx: { user },
} = props
await updateContactFields({ workspaceId, id }, parsedInput)
+
+ const keys = Object.keys(parsedInput)
+ if (keys.length > 0) {
+ await Promise.all([
+ logAudit({
+ workspaceId,
+ userId: user.id,
+ action: auditLogActions.CONTACT_FIELD_UPDATED,
+ detail: `Campo(s) ${keys.map((k) => `"${k}"`).join(", ")} atualizado(s) no contato ${id}`,
+ }),
+ recordContactEvent({
+ contactId: id,
+ workspaceId,
+ eventType: contactEventTypes.FIELD_UPDATED,
+ meta: { keys },
+ actorUserId: user.id,
+ }),
+ ])
+ }
})
export const updateContactFields = async (
@@ -45,7 +71,7 @@ export const updateContactFields = async (
workspaceId: ctx.workspaceId,
id: ctx.id,
},
- message: "Contact not found",
+ message: "Contato não encontrado",
})
const allCustomFields = await listCustomFields({
diff --git a/apps/builder/src/features/contacts/actions/update-contact-tag.action.ts b/apps/builder/src/features/contacts/actions/update-contact-tag.action.ts
index 7844fc7fa..5ba46c0ba 100644
--- a/apps/builder/src/features/contacts/actions/update-contact-tag.action.ts
+++ b/apps/builder/src/features/contacts/actions/update-contact-tag.action.ts
@@ -52,7 +52,7 @@ export const updateContactTags = async ({
id: parsedInput.contactId,
workspaceId,
},
- message: "Contact not found",
+ message: "Contato não encontrado",
})
// Get old tags before update
@@ -126,7 +126,7 @@ export const updateContactTags = async ({
try {
await emitTagApplied(workspaceId, contact.id, tag.id)
} catch (error) {
- console.error("Failed to emit tagApplied event:", error)
+ console.error("Falha ao emitir evento tagApplied:", error)
}
}
@@ -135,7 +135,7 @@ export const updateContactTags = async ({
try {
await emitTagRemoved(workspaceId, contact.id, tagId)
} catch (error) {
- console.error("Failed to emit tagRemoved event:", error)
+ console.error("Falha ao emitir evento tagRemoved:", error)
}
}
diff --git a/apps/builder/src/features/contacts/api/authenticated.ts b/apps/builder/src/features/contacts/api/authenticated.ts
index e21580cd5..86d7b9d76 100644
--- a/apps/builder/src/features/contacts/api/authenticated.ts
+++ b/apps/builder/src/features/contacts/api/authenticated.ts
@@ -37,8 +37,8 @@ export const contactsAuthenticatedAPI = {
.route({
method: "GET",
path: "/workspaces/{workspaceId}/contacts/{contactId}",
- summary: "Get contact",
- tags: ["Contacts"],
+ summary: "Obter contato",
+ tags: ["Contatos"],
})
.input(getContactRequest)
.output(getContactResponse)
@@ -52,8 +52,8 @@ export const contactsAuthenticatedAPI = {
.route({
method: "GET",
path: "/workspaces/{workspaceId}/contacts",
- summary: "List contacts",
- tags: ["Contacts"],
+ summary: "Listar contatos",
+ tags: ["Contatos"],
})
.input(listContactsRequest.and(withWorkspaceIdSchema))
.use(workspaceAuthorizedMidddleware, (input) => input.workspaceId)
@@ -67,8 +67,8 @@ export const contactsAuthenticatedAPI = {
.route({
method: "GET",
path: "/workspaces/{workspaceId}/contacts/count",
- summary: "Count contacts",
- tags: ["Contacts"],
+ summary: "Contar contatos",
+ tags: ["Contatos"],
})
.input(listContactsRequest)
.use(workspaceAuthorizedMidddleware, (input) => input.workspaceId)
@@ -79,8 +79,8 @@ export const contactsAuthenticatedAPI = {
.route({
method: "GET",
path: "/workspaces/{workspaceId}/contacts/inboxes/count",
- summary: "Count contact inboxes",
- tags: ["Contacts"],
+ summary: "Contar caixas de entrada de contato",
+ tags: ["Contatos"],
})
.input(listContactsRequest)
.use(workspaceAuthorizedMidddleware, (input) => input.workspaceId)
@@ -91,8 +91,8 @@ export const contactsAuthenticatedAPI = {
.route({
method: "POST",
path: "/workspaces/{workspaceId}/contacts",
- summary: "Create a contact",
- tags: ["Contacts"],
+ summary: "Criar contato",
+ tags: ["Contatos"],
})
.input(createContactRequest.and(withWorkspaceIdSchema))
.output(createContactResponse)
@@ -106,8 +106,8 @@ export const contactsAuthenticatedAPI = {
.route({
method: "GET",
path: "/workspaces/{workspaceId}/contacts/{contactId}/tags",
- summary: "List contact tags",
- tags: ["Contacts"],
+ summary: "Listar tags do contato",
+ tags: ["Contatos"],
})
.input(listContactTagsRequest)
.output(listContactTagsResponse)
@@ -124,8 +124,8 @@ export const contactsAuthenticatedAPI = {
.route({
method: "POST",
path: "/workspaces/{workspaceId}/contacts/tags",
- summary: "Add tags to contact",
- tags: ["Contacts"],
+ summary: "Adicionar tags ao contato",
+ tags: ["Contatos"],
})
.input(addContactTagRequest.and(withWorkspaceIdSchema))
.use(workspaceAuthorizedMidddleware, (input) => input.workspaceId)
@@ -144,8 +144,8 @@ export const contactsAuthenticatedAPI = {
.route({
method: "DELETE",
path: "/workspaces/{workspaceId}/contacts/{contactId}/tags/{tagId}",
- summary: "Remove tag from contact",
- tags: ["Contacts"],
+ summary: "Remover tag do contato",
+ tags: ["Contatos"],
})
.input(removeContactTagRequest.and(withWorkspaceIdSchema))
.use(workspaceAuthorizedMidddleware, (input) => input.workspaceId)
@@ -164,8 +164,8 @@ export const contactsAuthenticatedAPI = {
.route({
method: "GET",
path: "/workspaces/{workspaceId}/contacts/{contactId}/fields",
- summary: "List contact custom fields",
- tags: ["Contacts"],
+ summary: "Listar campos personalizados do contato",
+ tags: ["Contatos"],
})
.input(listContactCustomFieldsRequest)
.output(listPublicContactCustomFieldsResponse)
@@ -183,8 +183,8 @@ export const contactsAuthenticatedAPI = {
.route({
method: "POST",
path: "/workspaces/{workspaceId}/contacts/{contactId}/fields",
- summary: "Set contact custom field value",
- tags: ["Contacts"],
+ summary: "Definir valor de campo personalizado do contato",
+ tags: ["Contatos"],
})
.input(setContactCustomFieldValueRequest.and(withWorkspaceIdSchema))
.use(workspaceAuthorizedMidddleware, (input) => input.workspaceId)
@@ -202,8 +202,8 @@ export const contactsAuthenticatedAPI = {
.route({
method: "DELETE",
path: "/workspaces/{workspaceId}/contacts/{contactId}/fields/{customFieldId}",
- summary: "Delete contact custom field",
- tags: ["Contacts"],
+ summary: "Excluir campo personalizado do contato",
+ tags: ["Contatos"],
})
.input(deleteContactCustomFieldRequest.and(withWorkspaceIdSchema))
.use(workspaceAuthorizedMidddleware, (input) => input.workspaceId)
diff --git a/apps/builder/src/features/contacts/api/workspace-token.ts b/apps/builder/src/features/contacts/api/workspace-token.ts
index b9d34021a..0e750d785 100644
--- a/apps/builder/src/features/contacts/api/workspace-token.ts
+++ b/apps/builder/src/features/contacts/api/workspace-token.ts
@@ -41,8 +41,8 @@ export const workspaceTokenAuthAPIs = {
.route({
method: "GET",
path: "/v1/contacts/{contactId}",
- summary: "Get contact by contact id",
- tags: ["Contacts"],
+ summary: "Obter contato pelo ID",
+ tags: ["Contatos"],
})
.input(z.object({ contactId: zodBigintAsString() }))
.output(publicFindContactResponse)
@@ -53,7 +53,7 @@ export const workspaceTokenAuthAPIs = {
})
if (!contact) {
- throw notFoundException("Contact not found")
+ throw notFoundException("Contato não encontrado")
}
return contact
@@ -63,8 +63,8 @@ export const workspaceTokenAuthAPIs = {
.route({
method: "POST",
path: "/v1/contacts",
- summary: "Create a contact",
- tags: ["Contacts"],
+ summary: "Criar contato",
+ tags: ["Contatos"],
})
.input(createContactRequest)
.output(publicFindContactResponse)
@@ -76,7 +76,7 @@ export const workspaceTokenAuthAPIs = {
const newContact = await publicFindContact({ id: contact.id })
if (!newContact) {
- throw notFoundException("Contact not found")
+ throw notFoundException("Contato não encontrado")
}
return newContact
}),
@@ -85,10 +85,10 @@ export const workspaceTokenAuthAPIs = {
.route({
method: "GET",
path: "/v1/contacts/find-by-custom-field",
- summary: "List contacts by custom field",
+ summary: "Listar contatos por campo personalizado",
description:
"Find contacts by custom field value. It will return maximum 100 contacts. The results are sorted by the last custom field value update for a contact.",
- tags: ["Contacts"],
+ tags: ["Contatos"],
})
.input(publicListContactsByCustomFieldRequest)
.output(publicListContactsResponse)
@@ -104,8 +104,8 @@ export const workspaceTokenAuthAPIs = {
.route({
method: "GET",
path: "/v1/contacts/{contactId}/tags",
- summary: "Get all tags added to this contact",
- tags: ["Contacts"],
+ summary: "Obter todas as tags do contato",
+ tags: ["Contatos"],
})
.input(z.object({ contactId: zodBigintAsString() }))
.output(publicListTagsResponse)
@@ -121,9 +121,9 @@ export const workspaceTokenAuthAPIs = {
.route({
method: "POST",
path: "/v1/contacts/{contactId}/tags/{tagId}",
- summary: "Add a tag to the contact",
+ summary: "Adicionar tag ao contato",
successStatus: 204,
- tags: ["Contacts"],
+ tags: ["Contatos"],
})
.input(
z.object({ contactId: zodBigintAsString(), tagId: zodBigintAsString() }),
@@ -139,9 +139,9 @@ export const workspaceTokenAuthAPIs = {
.route({
method: "DELETE",
path: "/v1/contacts/{contactId}/tags/{tagId}",
- summary: "Remove a tag from the contact",
+ summary: "Remover tag do contato",
successStatus: 204,
- tags: ["Contacts"],
+ tags: ["Contatos"],
})
.input(removeContactTagRequest)
.handler(async ({ context, input }) => {
@@ -155,8 +155,8 @@ export const workspaceTokenAuthAPIs = {
.route({
method: "GET",
path: "/v1/contacts/{contactId}/custom-fields",
- summary: "Get all custom fields from a contact",
- tags: ["Contacts"],
+ summary: "Obter todos os campos personalizados do contato",
+ tags: ["Contatos"],
})
.input(z.object({ contactId: zodBigintAsString() }))
.output(listPublicContactCustomFieldsResponse)
@@ -172,8 +172,8 @@ export const workspaceTokenAuthAPIs = {
.route({
method: "GET",
path: "/v1/contacts/{contactId}/custom-fields/{customFieldId}",
- summary: "Get contact custom field value",
- tags: ["Contacts"],
+ summary: "Obter valor de campo personalizado do contato",
+ tags: ["Contatos"],
})
.input(
z.object({
@@ -197,8 +197,8 @@ export const workspaceTokenAuthAPIs = {
.route({
method: "POST",
path: "/v1/contacts/{contactId}/custom-fields/{customFieldId}",
- summary: "Set contact custom field value",
- tags: ["Contacts"],
+ summary: "Definir valor de campo personalizado do contato",
+ tags: ["Contatos"],
})
.input(setContactCustomFieldValueRequest)
.handler(async ({ context, input }) => {
@@ -217,9 +217,9 @@ export const workspaceTokenAuthAPIs = {
.route({
method: "DELETE",
path: "/v1/contacts/{contactId}/custom-fields/{customFieldId}",
- summary: "Delete contact custom field",
+ summary: "Excluir campo personalizado do contato",
successStatus: 204,
- tags: ["Contacts"],
+ tags: ["Contatos"],
})
.input(deleteContactCustomFieldRequest)
.handler(async ({ context, input }) => {
@@ -236,8 +236,8 @@ export const workspaceTokenAuthAPIs = {
.route({
method: "POST",
path: "/v1/contacts/{contactId}/messages",
- summary: "Send message to contact",
- tags: ["Contacts"],
+ summary: "Enviar mensagem para o contato",
+ tags: ["Contatos"],
})
.input(
createMessageRequest.and(
@@ -259,14 +259,14 @@ export const workspaceTokenAuthAPIs = {
},
})
if (!conversation) {
- throw notFoundException("Conversation not found")
+ throw notFoundException("Conversa não encontrada")
}
const contactInbox = input.inboxId
? conversation.contactInboxes.find((ci) => ci.inboxId === input.inboxId)
: conversation.contactInboxes[0]
if (!contactInbox) {
- throw notFoundException("Conversation not found")
+ throw notFoundException("Conversa não encontrada")
}
await createMessage({
diff --git a/apps/builder/src/features/contacts/components/contact-activity-log.tsx b/apps/builder/src/features/contacts/components/contact-activity-log.tsx
new file mode 100644
index 000000000..923afafef
--- /dev/null
+++ b/apps/builder/src/features/contacts/components/contact-activity-log.tsx
@@ -0,0 +1,213 @@
+"use client"
+
+import type {
+ ContactEventModel,
+ LifecycleStageModel,
+} from "@chatbotx.io/database/types"
+import { format } from "date-fns"
+import {
+ ArrowRightIcon,
+ BanIcon,
+ CheckCircle2Icon,
+ PencilIcon,
+ ShieldCheckIcon,
+ TagIcon,
+ UserPlusIcon,
+ UsersIcon,
+ XIcon,
+} from "lucide-react"
+import { useTranslations } from "next-intl"
+import { type ReactNode, useEffect, useState } from "react"
+import { useWorkspaceId } from "@/hooks/routing"
+import { listContactEvents } from "../queries/list-contact-events.query"
+
+type ContactActivityLogProps = {
+ contactId: string
+ lifecycleStages?: LifecycleStageModel[]
+}
+
+// Mapping de eventType pra ícone + label render. Mantém em sync com
+// contactEventTypes do @chatbotx.io/business (não importamos pra n criar
+// dep client→business só pelo enum).
+type EventRenderer = {
+ icon: typeof TagIcon
+ iconClass: string
+ buildLabel: (
+ meta: Record,
+ stageById: Map,
+ ) => ReactNode
+}
+
+const EVENT_RENDERERS: Record = {
+ "contact.created": {
+ icon: UserPlusIcon,
+ iconClass: "text-emerald-500",
+ buildLabel: () => "Contato criado",
+ },
+ "contact.blocked": {
+ icon: BanIcon,
+ iconClass: "text-destructive",
+ buildLabel: () => "Contato bloqueado",
+ },
+ "contact.unblocked": {
+ icon: ShieldCheckIcon,
+ iconClass: "text-emerald-500",
+ buildLabel: () => "Contato desbloqueado",
+ },
+ "contact.lifecycle.changed": {
+ icon: ArrowRightIcon,
+ iconClass: "text-primary",
+ buildLabel: (meta, stageById) => {
+ const from = meta.fromStageId
+ ? stageById.get(meta.fromStageId as string)
+ : null
+ const to = meta.toStageId ? stageById.get(meta.toStageId as string) : null
+ return (
+
+
+ {from ? `${from.icon ?? "•"} ${from.name}` : "—"}
+
+
+
+ {to ? `${to.icon ?? "•"} ${to.name}` : "—"}
+
+
+ )
+ },
+ },
+ "contact.tag.added": {
+ icon: TagIcon,
+ iconClass: "text-blue-500",
+ buildLabel: (meta) => {
+ const names = (meta.tagNames as string[] | undefined) ?? []
+ const list = names.map((n) => `"${n}"`).join(", ")
+ return `Etiqueta${names.length > 1 ? "s" : ""} ${list} aplicada${names.length > 1 ? "s" : ""}`
+ },
+ },
+ "contact.tag.removed": {
+ icon: XIcon,
+ iconClass: "text-muted-foreground",
+ buildLabel: (meta) => {
+ const names = (meta.tagNames as string[] | undefined) ?? []
+ const list = names.map((n) => `"${n}"`).join(", ")
+ return `Etiqueta${names.length > 1 ? "s" : ""} ${list} removida${names.length > 1 ? "s" : ""}`
+ },
+ },
+ "contact.field.updated": {
+ icon: PencilIcon,
+ iconClass: "text-amber-500",
+ buildLabel: (meta) => {
+ const keys = (meta.keys as string[] | undefined) ?? []
+ const list = keys.map((k) => `"${k}"`).join(", ")
+ return `Campo${keys.length > 1 ? "s" : ""} ${list} atualizado${keys.length > 1 ? "s" : ""}`
+ },
+ },
+ "contact.merged": {
+ icon: UsersIcon,
+ iconClass: "text-purple-500",
+ buildLabel: (meta) => {
+ const count = (meta.mergedCount as number | undefined) ?? 0
+ return `Mesclado com ${count} contato${count === 1 ? "" : "s"}`
+ },
+ },
+ "contact.conversation.closedWithNote": {
+ icon: CheckCircle2Icon,
+ iconClass: "text-emerald-500",
+ buildLabel: (meta) => {
+ const categoryName = meta.categoryName as string | null | undefined
+ const summary = meta.summary as string | null | undefined
+ const closedBy = meta.closedByUserName as string | null | undefined
+ return (
+
+
+
+ {closedBy ? `${closedBy} fechou a conversa` : "Conversa fechada"}
+
+ {categoryName ? (
+
+ {categoryName}
+
+ ) : null}
+
+ {summary ? (
+
+ "{summary}"
+
+ ) : null}
+
+ )
+ },
+ },
+}
+
+export function ContactActivityLog({
+ contactId,
+ lifecycleStages = [],
+}: ContactActivityLogProps) {
+ const t = useTranslations()
+ const workspaceId = useWorkspaceId()
+ const [events, setEvents] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ let mounted = true
+ setLoading(true)
+ listContactEvents({ contactId, workspaceId, limit: 20 })
+ .then((data) => {
+ if (mounted) {
+ setEvents(data)
+ setLoading(false)
+ }
+ })
+ .catch(() => {
+ if (mounted) {
+ setLoading(false)
+ }
+ })
+ return () => {
+ mounted = false
+ }
+ }, [contactId, workspaceId])
+
+ const stageById = new Map()
+ for (const s of lifecycleStages) {
+ stageById.set(s.id, s)
+ }
+
+ if (loading) {
+ return ...
+ }
+
+ if (events.length === 0) {
+ return (
+
+ {t("lifecycle.activityLogEmpty")}
+
+ )
+ }
+
+ return (
+
+ {events.map((event) => {
+ const meta = (event.meta ?? {}) as Record
+ const renderer = EVENT_RENDERERS[event.eventType]
+ const Icon = renderer?.icon ?? PencilIcon
+ return (
+
+
+
+
+ {format(new Date(event.createdAt), "dd/MM/yyyy HH:mm")}
+
+
+
+ {renderer
+ ? renderer.buildLabel(meta, stageById)
+ : `Evento: ${event.eventType}`}
+
+
+ )
+ })}
+
+ )
+}
diff --git a/apps/builder/src/features/contacts/components/contact-detail-drawer.tsx b/apps/builder/src/features/contacts/components/contact-detail-drawer.tsx
new file mode 100644
index 000000000..9b4e629d9
--- /dev/null
+++ b/apps/builder/src/features/contacts/components/contact-detail-drawer.tsx
@@ -0,0 +1,202 @@
+"use client"
+
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import { cn } from "@chatbotx.io/ui/lib/utils"
+import { ArrowUpRightIcon, XIcon } from "lucide-react"
+import Link from "next/link"
+import { useTranslations } from "next-intl"
+import { LifecycleStagePill } from "../../lifecycle-stages/lifecycle-stage-pill"
+import { getTagChipStyle } from "../../tags/tag-colors"
+import type { ListContactsItem } from "../schemas/query"
+import { getAvatarInitials, getRespondAvatarUrl } from "../utils"
+import { MergedContactsPanel } from "./merged-contacts-panel"
+
+type ContactDetailDrawerProps = {
+ workspaceId: string
+ contact: ListContactsItem | null
+ open: boolean
+ onClose: () => void
+}
+
+// ISO 2-letter country code → bandeira emoji (regional indicator unicode).
+function countryCodeToFlag(code: string | null | undefined): string {
+ if (!code || code.length !== 2) {
+ return "🌐"
+ }
+ const upper = code.toUpperCase()
+ const base = 0x1_f1_e6
+ return (
+ String.fromCodePoint(base + upper.charCodeAt(0) - 65) +
+ String.fromCodePoint(base + upper.charCodeAt(1) - 65)
+ )
+}
+
+/**
+ * Drawer lateral 350px com detalhes do contato — pixel-perfect Respond.io
+ * 2026-05-26 (Pedro pediu "ao clicar no contato abrir Detalhes"). Estrutura:
+ * • Header "Detalhes do contato" + close X
+ * • Avatar 56px + Nome + ID + país (bandeira)
+ * • Phone, Email, Locale (read-only)
+ * • Lifecycle stage pill
+ * • Etiquetas (chips)
+ * • Botão "Ver no Inbox" → /inbox?conversationId=X
+ *
+ * Versão MINIMAL — sem useChatStore, sem accordion. ContactDetail completo
+ * (drawer do Inbox com inline edit, custom fields, sequences) ficou fora pra
+ * evitar acoplamento com ChatStore (que só vive em /inbox).
+ */
+export function ContactDetailDrawer({
+ workspaceId,
+ contact,
+ open,
+ onClose,
+}: ContactDetailDrawerProps) {
+ const t = useTranslations()
+
+ if (!(open && contact)) {
+ return null
+ }
+
+ const name = contact.fullName || contact.phoneNumber || contact.email || "?"
+ const avatar = getRespondAvatarUrl(name)
+ const initials = getAvatarInitials(name)
+ const tags = contact.tags ?? []
+
+ return (
+
+ {/* Header — Pedro padrão Respond.io: título sentence case + close X */}
+
+
+ {t("contacts.drawer.title")}
+
+
+
+
+
+
+ {/* Body scrollável */}
+
+ {/* Avatar + Nome */}
+
+
+ {/* biome-ignore lint/performance/noImgElement: avatar simples sem Next/Image */}
+
+ {initials}
+
+
+
{name}
+
+ #{contact.id}
+ {contact.country && (
+
+ {countryCodeToFlag(contact.country)}
+
+ )}
+
+
+
+
+ {/* Lifecycle stage */}
+ {contact.lifecycleStage && (
+
+
+
+ )}
+
+ {/* Etiquetas */}
+ {tags.length > 0 && (
+
+
+ {t("tags.title")}
+
+
+ {tags.map((tag) => (
+
+ {tag.emoji && {tag.emoji} }
+ {tag.name}
+
+ ))}
+
+
+ )}
+
+ {/* Info estática */}
+
+
+
+
+
+
+
+ {/* Panel "contatos fundidos aqui" — só renderiza se existirem */}
+
+
+ {/* Botão Ver no Inbox */}
+ {contact.conversation?.id && (
+
+
+ {t("contacts.drawer.openInInbox")}
+
+ )}
+
+
+ )
+}
+
+function DrawerField({ label, value }: { label: string; value: string }) {
+ return (
+
+
+ {label}
+
+ {value}
+
+ )
+}
diff --git a/apps/builder/src/features/contacts/components/contact-tags-panel.tsx b/apps/builder/src/features/contacts/components/contact-tags-panel.tsx
new file mode 100644
index 000000000..392ac4d98
--- /dev/null
+++ b/apps/builder/src/features/contacts/components/contact-tags-panel.tsx
@@ -0,0 +1,193 @@
+"use client"
+
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import { Checkbox } from "@chatbotx.io/ui/components/ui/checkbox"
+import {
+ Command,
+ CommandEmpty,
+ CommandInput,
+ CommandList,
+} from "@chatbotx.io/ui/components/ui/command"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@chatbotx.io/ui/components/ui/popover"
+import { PlusIcon, XIcon } from "lucide-react"
+import { useTranslations } from "next-intl"
+import { useAction } from "next-safe-action/hooks"
+import { useMemo, useState } from "react"
+import { toast } from "sonner"
+import { useTagStore } from "@/features/tags/provider/tag-store-context"
+import type { TagResource } from "@/features/tags/schema/resource"
+import { getTagChipStyle } from "@/features/tags/tag-colors"
+import { updateContactTagAction } from "../actions/update-contact-tag.action"
+
+/**
+ * Panel de etiquetas pixel-perfect Respond.io.
+ *
+ * Iteração 38 (Pedro 2026-05-25): refator do popover pra match Respond.io:
+ * - Mostra TODAS as tags (não filtra aplicadas)
+ * - Cada item tem CHECKBOX (marcado = aplicada)
+ * - Click toggle aplica/remove SEM fechar o popover
+ * - Search filtra in-place
+ * - User pode marcar várias antes de fechar
+ *
+ * Persistência: usa `updateContactTagAction` que recebe `tags: string[]`
+ * com os NOMES das tags (idempotente: substitui o conjunto inteiro).
+ */
+export function ContactTagsPanel({
+ workspaceId,
+ contactId,
+ appliedTags,
+ onUpdated,
+}: {
+ workspaceId: string
+ contactId: string
+ appliedTags: TagResource[]
+ onUpdated: (tags: TagResource[]) => void
+}) {
+ const t = useTranslations()
+ const [open, setOpen] = useState(false)
+ const [search, setSearch] = useState("")
+
+ const allTags = useTagStore((state) => state.tags)
+
+ const { execute, isPending } = useAction(
+ updateContactTagAction.bind(null, workspaceId),
+ {
+ onSuccess: ({ data }) => {
+ if (data) {
+ onUpdated(data)
+ }
+ },
+ onError: ({ error }) => {
+ if (error.serverError) {
+ toast.error(error.serverError)
+ }
+ },
+ },
+ )
+
+ const appliedIds = useMemo(
+ () => new Set(appliedTags.map((tg) => tg.id)),
+ [appliedTags],
+ )
+
+ const commit = (nextTags: TagResource[]) => {
+ execute({ contactId, tags: nextTags.map((tg) => tg.name) })
+ }
+
+ const handleToggleTag = (tag: TagResource) => {
+ if (appliedIds.has(tag.id)) {
+ commit(appliedTags.filter((tg) => tg.id !== tag.id))
+ } else {
+ commit([...appliedTags, tag])
+ }
+ }
+
+ const handleRemoveTag = (tagId: string) => {
+ commit(appliedTags.filter((tg) => tg.id !== tagId))
+ }
+
+ const filteredTags = useMemo(() => {
+ const q = search.trim().toLowerCase()
+ if (!q) {
+ return allTags
+ }
+ return allTags.filter((tg) => tg.name.toLowerCase().includes(q))
+ }, [allTags, search])
+
+ return (
+
+ {appliedTags.map((tag) => {
+ const style = getTagChipStyle(tag.color)
+ return (
+
+ {tag.emoji && {tag.emoji} }
+ {tag.name}
+ handleRemoveTag(tag.id)}
+ type="button"
+ >
+
+
+
+ )
+ })}
+
+
+
+
+
+
+
+
+
+
+
+ {filteredTags.length === 0 && (
+
+ {t("contacts.noTags") ?? "Nenhuma etiqueta"}
+
+ )}
+ {filteredTags.length > 0 && (
+
+ {filteredTags.map((tag) => {
+ const checked = appliedIds.has(tag.id)
+ const style = getTagChipStyle(tag.color)
+ return (
+ handleToggleTag(tag)}
+ type="button"
+ >
+
+
+ {tag.emoji && (
+ {tag.emoji}
+ )}
+ {tag.name}
+
+
+ )
+ })}
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/apps/builder/src/features/contacts/components/contacts-area-sidebar.tsx b/apps/builder/src/features/contacts/components/contacts-area-sidebar.tsx
new file mode 100644
index 000000000..9d2a43e59
--- /dev/null
+++ b/apps/builder/src/features/contacts/components/contacts-area-sidebar.tsx
@@ -0,0 +1,381 @@
+"use client"
+
+import type {
+ InboxTeamModel,
+ LifecycleStageModel,
+} from "@chatbotx.io/database/types"
+import { cn } from "@chatbotx.io/ui/lib/utils"
+import {
+ BanIcon,
+ InboxIcon,
+ type LucideIcon,
+ PanelLeftCloseIcon,
+ UserIcon,
+ UserSearchIcon,
+ UsersRoundIcon,
+} from "lucide-react"
+import Link from "next/link"
+import { useSearchParams } from "next/navigation"
+import { useTranslations } from "next-intl"
+import { useEffect, useState } from "react"
+import { useInboxSidebarCollapsed } from "@/features/chat/hooks/use-inbox-sidebar-collapsed"
+
+const LIFECYCLE_VISIBILITY_KEY = "chatbotx.lifecycle.visible"
+
+// Pixel-perfect Respond.io 2026-05-26 (Pedro: "o sidebar tanto da
+// direita quanto da esquerda são exatamente iguais ao do inbox,
+// literalmente o mesmo, corrija isso"). Estrutura visual + JSX +
+// classes Tailwind copiadas LITERALMENTE do
+// com adaptação de comportamento:
+// - Inbox: muta chatStore + loadMoreConversations() + actions de
+// marcar leitura.
+// - Contacts: navega via URL search params (assignedId, lifecycleStageId,
+// blocked) pra refiltar a lista de contatos no servidor. Active state
+// vem de useSearchParams().
+function formatCount(count: number): string {
+ return count > 9999 ? "9,999+" : count.toLocaleString("pt-BR")
+}
+
+function renderCountBadge(
+ count: number,
+ hasUnread: boolean,
+ t: ReturnType,
+) {
+ if (hasUnread) {
+ return (
+
+ {count > 0 ? formatCount(count) : ""}
+
+ )
+ }
+ if (count > 0) {
+ return (
+
+ {formatCount(count)}
+
+ )
+ }
+ return null
+}
+
+type AssignmentCounts = {
+ all: number
+ mine: number
+ unassigned: number
+ teams: Record
+}
+
+type ContactsAreaSidebarProps = {
+ workspaceId: string
+ currentUserId: string
+ inboxTeams: InboxTeamModel[]
+ lifecycleStages: LifecycleStageModel[]
+ lifecycleCounts: Record
+ assignmentCounts: AssignmentCounts
+ unreadByAssignment: AssignmentCounts
+ unreadByStage: Record
+ blockedContactsCount: number
+}
+
+type TopFilterItem = {
+ key: string
+ label: string
+ icon: LucideIcon
+ href: string
+ isActive: boolean
+ count: number
+ showDot: boolean
+}
+
+export function ContactsAreaSidebar({
+ workspaceId,
+ currentUserId,
+ inboxTeams,
+ lifecycleStages,
+ lifecycleCounts,
+ assignmentCounts,
+ unreadByAssignment,
+ unreadByStage,
+ blockedContactsCount,
+}: ContactsAreaSidebarProps) {
+ const t = useTranslations()
+ const searchParams = useSearchParams()
+ const [collapsed, toggleCollapsed] = useInboxSidebarCollapsed()
+
+ const base = `/space/${workspaceId}/contacts`
+ const currentAssignedId = searchParams.get("assignedId") ?? "all"
+ const currentLifecycleId = searchParams.get("lifecycleStageId")
+ const isBlockedFilterActive = searchParams.get("blocked") === "1"
+
+ // Lost stages ocultos (decisão Respond.io — só no Settings).
+ const activeLifecycleStages = lifecycleStages.filter((s) => !s.isLost)
+
+ // Toggle global do localStorage (controla se a seção Ciclo de vida aparece).
+ // Compartilha a mesma chave do Inbox pra estado consistente entre páginas.
+ const [lifecycleVisible, setLifecycleVisible] = useState(true)
+ useEffect(() => {
+ const stored = localStorage.getItem(LIFECYCLE_VISIBILITY_KEY)
+ if (stored !== null) {
+ setLifecycleVisible(stored === "true")
+ }
+ const handler = (e: StorageEvent) => {
+ if (e.key === LIFECYCLE_VISIBILITY_KEY && e.newValue !== null) {
+ setLifecycleVisible(e.newValue === "true")
+ }
+ }
+ window.addEventListener("storage", handler)
+ return () => window.removeEventListener("storage", handler)
+ }, [])
+
+ // Helpers de URL — mesmo padrão das outras páginas que usam URL params.
+ const buildHref = (
+ overrides: Record,
+ ): string => {
+ const params = new URLSearchParams(searchParams.toString())
+ for (const [key, value] of Object.entries(overrides)) {
+ if (value === null || value === undefined || value === "") {
+ params.delete(key)
+ } else {
+ params.set(key, value)
+ }
+ }
+ const qs = params.toString()
+ return qs ? `${base}?${qs}` : base
+ }
+
+ // Prioridade da bolinha "nova mensagem" (doc Respond.io):
+ // Minhas > Equipe > Não atribuídas > Todas.
+ const anyTeamUnread = Object.values(unreadByAssignment.teams).some(
+ (c) => c > 0,
+ )
+ const showAllDot =
+ unreadByAssignment.all > 0 &&
+ unreadByAssignment.mine === 0 &&
+ unreadByAssignment.unassigned === 0 &&
+ !anyTeamUnread
+ const showMineDot = unreadByAssignment.mine > 0
+ const showUnassignedDot =
+ unreadByAssignment.unassigned > 0 &&
+ unreadByAssignment.mine === 0 &&
+ !anyTeamUnread
+ const showTeamDot = (teamId: string): boolean =>
+ (unreadByAssignment.teams[teamId] ?? 0) > 0 && unreadByAssignment.mine === 0
+
+ const topItems: TopFilterItem[] = [
+ {
+ key: "all",
+ label: t("inboxFilters.all"),
+ icon: InboxIcon,
+ href: buildHref({
+ assignedId: null,
+ lifecycleStageId: null,
+ blocked: null,
+ }),
+ isActive:
+ !(isBlockedFilterActive || currentLifecycleId) &&
+ (currentAssignedId === "all" || !searchParams.get("assignedId")),
+ count: assignmentCounts.all,
+ showDot: showAllDot,
+ },
+ {
+ key: "mine",
+ label: t("inboxFilters.mine"),
+ icon: UserIcon,
+ href: buildHref({
+ assignedId: `u_${currentUserId}`,
+ lifecycleStageId: null,
+ blocked: null,
+ }),
+ isActive:
+ !isBlockedFilterActive && currentAssignedId === `u_${currentUserId}`,
+ count: assignmentCounts.mine,
+ showDot: showMineDot,
+ },
+ {
+ key: "unassigned",
+ label: t("inboxFilters.unassigned"),
+ icon: UserSearchIcon,
+ href: buildHref({
+ assignedId: "unassigned",
+ lifecycleStageId: null,
+ blocked: null,
+ }),
+ isActive: !isBlockedFilterActive && currentAssignedId === "unassigned",
+ count: assignmentCounts.unassigned,
+ showDot: showUnassignedDot,
+ },
+ ]
+
+ if (collapsed) {
+ return null
+ }
+
+ return (
+
+
+
+ {t("contacts.title")}
+
+
toggleCollapsed(true)}
+ type="button"
+ >
+
+
+
+
+ {/* Filtros top: Todos / Minhas / Não atribuídas */}
+
+ {topItems.map((item) => {
+ const Icon = item.icon
+ const showUnreadBadge = item.showDot && !item.isActive
+ return (
+
+
+ {item.label}
+ {renderCountBadge(item.count, showUnreadBadge, t)}
+
+ )
+ })}
+
+
+ {/* Times */}
+ {inboxTeams.length > 0 && (
+
+
+ {t("inboxFilters.teamInbox")}
+
+ {inboxTeams.map((team) => {
+ const teamFilterValue = `t_${team.id}`
+ const active =
+ !isBlockedFilterActive && currentAssignedId === teamFilterValue
+ const teamCount = assignmentCounts.teams[team.id] ?? 0
+ const teamShowDot = showTeamDot(team.id) && !active
+ return (
+
+
+ {team.name}
+ {renderCountBadge(teamCount, teamShowDot, t)}
+
+ )
+ })}
+
+ )}
+
+ {/* Ciclo de vida */}
+ {lifecycleVisible && activeLifecycleStages.length > 0 && (
+
+
+ {t("lifecycle.title")}
+
+ {activeLifecycleStages.map((stage) => {
+ const active =
+ !isBlockedFilterActive && currentLifecycleId === stage.id
+ const count = lifecycleCounts[stage.id] ?? 0
+ const stageHasUnread = (unreadByStage[stage.id] ?? 0) > 0
+ const stageShowDot = stageHasUnread && !active
+ return (
+
+
+ {stage.icon ?? "•"}
+
+
+ {stage.name}
+
+ {renderCountBadge(count, stageShowDot, t)}
+
+ )
+ })}
+
+ )}
+
+
+ {/* Rodapé fixo — Contatos bloqueados (pixel-perfect Respond.io) */}
+
+
+
+
+ {t("inboxFilters.blockedContacts")}
+
+ {blockedContactsCount > 0 && (
+
+ {formatCount(blockedContactsCount)}
+
+ )}
+
+
+
+ )
+}
diff --git a/apps/builder/src/features/contacts/components/contacts-columns-picker.tsx b/apps/builder/src/features/contacts/components/contacts-columns-picker.tsx
new file mode 100644
index 000000000..b61006375
--- /dev/null
+++ b/apps/builder/src/features/contacts/components/contacts-columns-picker.tsx
@@ -0,0 +1,94 @@
+"use client"
+
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import { Checkbox } from "@chatbotx.io/ui/components/ui/checkbox"
+import { Label } from "@chatbotx.io/ui/components/ui/label"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@chatbotx.io/ui/components/ui/popover"
+import { Separator } from "@chatbotx.io/ui/components/ui/separator"
+import type { Table } from "@tanstack/react-table"
+import { Columns3Icon } from "lucide-react"
+import { useTranslations } from "next-intl"
+import type { ListContactsItem } from "@/features/contacts/schemas/query"
+
+type ContactsColumnsPickerProps = {
+ table: Table
+}
+
+// Picker de visibilidade de colunas pra tabela /contacts (#20.3).
+// Colunas com enableHiding: false (select, keyword/nome, createdAt) não
+// aparecem aqui. Persistência em localStorage é feita pelo pai (contacts-table)
+// via useEffect — este componente só dispara toggleVisibility do TanStack.
+export function ContactsColumnsPicker({ table }: ContactsColumnsPickerProps) {
+ const t = useTranslations()
+ const hideableColumns = table
+ .getAllLeafColumns()
+ .filter((col) => col.getCanHide())
+
+ if (hideableColumns.length === 0) {
+ return null
+ }
+
+ const allVisible = hideableColumns.every((col) => col.getIsVisible())
+ const noneVisible = hideableColumns.every((col) => !col.getIsVisible())
+
+ const toggleAll = (visible: boolean) => {
+ for (const col of hideableColumns) {
+ col.toggleVisibility(visible)
+ }
+ }
+
+ return (
+
+
+
+
+ {t("contacts.columnsPicker.button")}
+
+
+
+
+
+ {t("contacts.columnsPicker.title")}
+
+ toggleAll(!allVisible || noneVisible)}
+ type="button"
+ >
+ {allVisible
+ ? t("contacts.columnsPicker.hideAll")
+ : t("contacts.columnsPicker.showAll")}
+
+
+
+
+ {hideableColumns.map((col) => {
+ const label =
+ (col.columnDef.meta as { label?: string } | undefined)?.label ??
+ col.id
+ return (
+
+
+ col.toggleVisibility(Boolean(checked))
+ }
+ />
+ {label}
+
+ )
+ })}
+
+
+
+ )
+}
diff --git a/apps/builder/src/features/contacts/components/inline-contact-field.tsx b/apps/builder/src/features/contacts/components/inline-contact-field.tsx
new file mode 100644
index 000000000..fb4009c82
--- /dev/null
+++ b/apps/builder/src/features/contacts/components/inline-contact-field.tsx
@@ -0,0 +1,197 @@
+"use client"
+
+import { Input } from "@chatbotx.io/ui/components/ui/input"
+import { ExternalLinkIcon } from "lucide-react"
+import { useTranslations } from "next-intl"
+import { useAction } from "next-safe-action/hooks"
+import { type KeyboardEvent, useEffect, useRef, useState } from "react"
+import { toast } from "sonner"
+import { updateContactFieldAction } from "../actions/update-contact-field.action"
+import type { ContactEditableField } from "../schemas/resource"
+import { ListContactField } from "./list-contact-field"
+
+type Props = {
+ workspaceId: string
+ contactId: string
+ field: ContactEditableField
+ onUpdated?: (key: string, value: string) => void
+}
+
+function resolveInputType(field: ContactEditableField): string {
+ // Custom field type tem prioridade (gap #10 Fase D — 2026-05-27).
+ switch (field.type) {
+ case "url":
+ return "url"
+ case "time":
+ return "time"
+ case "date":
+ return "date"
+ case "datetime":
+ return "datetime-local"
+ case "email":
+ return "email"
+ case "phoneNumber":
+ return "tel"
+ case "number":
+ return "number"
+ default:
+ break
+ }
+ // Fallback por key pros default fields (firstName/email/phone do contact).
+ if (field.key === "email") {
+ return "email"
+ }
+ if (field.key === "phoneNumber") {
+ return "tel"
+ }
+ return "text"
+}
+
+// Pixel-perfect Respond.io 2026-05-25 (iteração 30 — Pedro):
+// Field editável INLINE. Clica/foca direto no input pra editar. Salva
+// no onBlur OU Enter. Ao perder foco, dispara `updateContactFieldAction`
+// — mesma action usada pelo modal EditContactField antigo. Modal foi
+// REMOVIDO pra match comportamento Respond.io.
+//
+// Layout:
+// - Label 12 px / 600 / muted em cima
+// - Input transparente sem border (hover: bg sutil; focus: border sutil)
+// - Vazio: placeholder "Adicionar {label}"
+export function InlineContactField({
+ workspaceId,
+ contactId,
+ field,
+ onUpdated,
+}: Props) {
+ // Custom field type="list" tem UI completamente diferente (multi-select
+ // popover com checkboxes). Delega pra componente próprio.
+ if (field.type === "list") {
+ return (
+
+ )
+ }
+ return (
+
+ )
+}
+
+function InlineContactFieldInput({
+ workspaceId,
+ contactId,
+ field,
+ onUpdated,
+}: Props) {
+ const t = useTranslations()
+ const [value, setValue] = useState(field.value ?? "")
+ const [isEditing, setIsEditing] = useState(false)
+ const originalRef = useRef(field.value ?? "")
+
+ // Sincroniza quando prop muda externamente (refresh data).
+ useEffect(() => {
+ setValue(field.value ?? "")
+ originalRef.current = field.value ?? ""
+ }, [field.value])
+
+ const { execute, isPending } = useAction(
+ updateContactFieldAction.bind(null, workspaceId, contactId),
+ {
+ onSuccess: () => {
+ originalRef.current = value
+ onUpdated?.(field.key, value)
+ },
+ onError: ({ error }) => {
+ // Reverte ao valor original em caso de erro.
+ setValue(originalRef.current)
+ if (error.serverError) {
+ toast.error(error.serverError)
+ }
+ },
+ },
+ )
+
+ const commit = () => {
+ const trimmed = value.trim()
+ if (trimmed === originalRef.current) {
+ return
+ }
+ execute({ [field.key]: trimmed } as never)
+ }
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Enter") {
+ e.preventDefault()
+ e.currentTarget.blur()
+ } else if (e.key === "Escape") {
+ e.preventDefault()
+ setValue(originalRef.current)
+ e.currentTarget.blur()
+ }
+ }
+
+ return (
+ /* Pedro 2026-05-25 iter 37: `min-w-0` no container e `truncate` no
+ label são OBRIGATÓRIOS pra valores grandes (UUIDs, URLs longas)
+ NÃO estourarem fora do drawer. Sem isso o flex parent expande
+ fora dos limites e o conteúdo escapa.
+ O input nativo trunca naturalmente com text-overflow quando NÃO
+ está focado — quando user clica, o texto rola normalmente pra
+ edição completa (igual Respond.io). */
+
+
+ {field.label}
+
+ {/* URL preenchida e não editando: mostra link clicável (paridade
+ Respond.io). Click no link → edita. Outros tipos: input normal. */}
+ {field.type === "url" && value && !isEditing ? (
+
+
+ {value}
+
+
+
setIsEditing(true)}
+ type="button"
+ >
+ {t("actions.edit") ?? "Editar"}
+
+
+ ) : (
+
{
+ commit()
+ setIsEditing(false)
+ }}
+ onChange={(e) => setValue(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder={`${t("actions.add") ?? "Adicionar"} ${field.label}`}
+ type={resolveInputType(field)}
+ value={value}
+ />
+ )}
+
+ )
+}
diff --git a/apps/builder/src/features/contacts/components/list-contact-field.tsx b/apps/builder/src/features/contacts/components/list-contact-field.tsx
new file mode 100644
index 000000000..db4d707e6
--- /dev/null
+++ b/apps/builder/src/features/contacts/components/list-contact-field.tsx
@@ -0,0 +1,150 @@
+"use client"
+
+import { Badge } from "@chatbotx.io/ui/components/ui/badge"
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import { Checkbox } from "@chatbotx.io/ui/components/ui/checkbox"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@chatbotx.io/ui/components/ui/popover"
+import { ChevronDownIcon, Loader2Icon } from "lucide-react"
+import { useTranslations } from "next-intl"
+import { useAction } from "next-safe-action/hooks"
+import { useMemo, useState } from "react"
+import { toast } from "sonner"
+import { updateContactFieldAction } from "../actions/update-contact-field.action"
+import type { ContactEditableField } from "../schemas/resource"
+
+type Props = {
+ workspaceId: string
+ contactId: string
+ field: ContactEditableField
+ onUpdated?: (key: string, value: string) => void
+}
+
+/**
+ * Render do custom field type="list" no contact drawer (Inbox).
+ * Display: chips dos selecionados (separados por ", " no value armazenado).
+ * Click no trigger: abre Popover com checkboxes das `options` definidas
+ * pelo admin em /settings/contact-fields. Save imediato a cada toggle.
+ * Paridade Respond.io: gap #10 Fase D 2026-05-27.
+ */
+export function ListContactField({
+ workspaceId,
+ contactId,
+ field,
+ onUpdated,
+}: Props) {
+ const t = useTranslations()
+ const [open, setOpen] = useState(false)
+
+ // Value armazenado como CSV ("Básico, Premium"). Split pra array no render.
+ const selected = useMemo(() => {
+ if (!field.value) {
+ return []
+ }
+ return field.value
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean)
+ }, [field.value])
+
+ const options = field.options ?? []
+
+ const { execute, isPending } = useAction(
+ updateContactFieldAction.bind(null, workspaceId, contactId),
+ {
+ onSuccess: () => {
+ onUpdated?.(field.key, selected.join(", "))
+ },
+ onError: ({ error }) => {
+ if (error.serverError) {
+ toast.error(error.serverError)
+ }
+ },
+ },
+ )
+
+ const handleToggle = (item: string, checked: boolean) => {
+ const next = checked
+ ? [...selected, item]
+ : selected.filter((s) => s !== item)
+ const csv = next.join(", ")
+ execute({ [field.key]: csv } as never)
+ }
+
+ return (
+
+
+ {field.label}
+
+
+
+
+
+ {selected.length > 0 ? (
+ selected.map((s) => (
+
+ {s}
+
+ ))
+ ) : (
+
+ {options.length === 0
+ ? t("customFields.emptyOptions")
+ : `${t("actions.add") ?? "Adicionar"} ${field.label}`}
+
+ )}
+
+ {isPending ? (
+
+ ) : (
+
+ )}
+
+
+
+ {options.length === 0 ? (
+
+ {t("customFields.emptyOptions")}
+
+ ) : (
+
+ {options.map((opt) => {
+ const checked = selected.includes(opt)
+ return (
+
+ handleToggle(opt, Boolean(c))}
+ />
+ {opt}
+
+ )
+ })}
+
+ )}
+
+
+
+ )
+}
diff --git a/apps/builder/src/features/contacts/components/merge-contacts-dialog.tsx b/apps/builder/src/features/contacts/components/merge-contacts-dialog.tsx
new file mode 100644
index 000000000..7da3eb678
--- /dev/null
+++ b/apps/builder/src/features/contacts/components/merge-contacts-dialog.tsx
@@ -0,0 +1,163 @@
+"use client"
+
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@chatbotx.io/ui/components/ui/dialog"
+import { Label } from "@chatbotx.io/ui/components/ui/label"
+import {
+ RadioGroup,
+ RadioGroupItem,
+} from "@chatbotx.io/ui/components/ui/radio-group"
+import { Loader2Icon, UsersIcon } from "lucide-react"
+import { useRouter } from "next/navigation"
+import { useTranslations } from "next-intl"
+import { useAction } from "next-safe-action/hooks"
+import { type ReactNode, useState } from "react"
+import { toast } from "sonner"
+import { mergeContactsAction } from "../actions/merge-contacts.action"
+import type { ListContactsItem } from "../schemas/query"
+
+type MergeContactsDialogProps = {
+ workspaceId: string
+ contacts: ListContactsItem[]
+ trigger: ReactNode
+ onSuccess?: () => void
+}
+
+/**
+ * Dialog pra mesclar 2+ contatos selecionados. User escolhe qual fica como
+ * primary (radio); os outros viram "duplicates" e são deletados após o merge
+ * (tags/customFields/conversations/inboxes/notes migram pro primary).
+ *
+ * Backend: `mergeContactsAction` → `mergeContacts()` em business (transaction
+ * Drizzle única). Audit log CONTACT_MERGED + ContactEvent MERGED no primary.
+ */
+export function MergeContactsDialog({
+ workspaceId,
+ contacts,
+ trigger,
+ onSuccess,
+}: MergeContactsDialogProps) {
+ const t = useTranslations()
+ const router = useRouter()
+ const [open, setOpen] = useState(false)
+ const [primaryId, setPrimaryId] = useState("")
+
+ const { execute, isExecuting } = useAction(
+ mergeContactsAction.bind(null, workspaceId),
+ {
+ onSuccess: ({ data }) => {
+ toast.success(
+ t("contacts.merge.success", { count: data?.mergedCount ?? 0 }),
+ )
+ setOpen(false)
+ onSuccess?.()
+ router.refresh()
+ },
+ onError: ({ error }) => {
+ if (error.serverError) {
+ toast.error(error.serverError)
+ }
+ },
+ },
+ )
+
+ const handleConfirm = () => {
+ if (!primaryId) {
+ return
+ }
+ const duplicateIds = contacts
+ .filter((c) => c.id !== primaryId)
+ .map((c) => c.id)
+ execute({ primaryId, duplicateIds })
+ }
+
+ // Inicializa primaryId no 1º contato ao abrir
+ const handleOpenChange = (next: boolean) => {
+ if (next && contacts.length > 0 && !primaryId) {
+ setPrimaryId(contacts[0].id)
+ }
+ setOpen(next)
+ }
+
+ const canMerge = contacts.length >= 2 && primaryId !== ""
+
+ return (
+
+ {trigger}
+
+
+
+
+ {t("contacts.merge.title")}
+
+
+ {t("contacts.merge.description")}
+
+
+
+
+
{t("contacts.merge.selectPrimary")}
+
+ {contacts.map((c) => (
+
+
+
+
+ {c.fullName ||
+ c.phoneNumber ||
+ c.email ||
+ `(${t("contacts.merge.unnamed")})`}
+
+
+ {[c.phoneNumber, c.email].filter(Boolean).join(" · ") ||
+ "—"}
+
+
+
+ ))}
+
+
+
+ {t("contacts.merge.warning", { count: contacts.length - 1 })}
+
+
+
+
+ setOpen(false)}
+ size="sm"
+ type="button"
+ variant="ghost"
+ >
+ {t("actions.cancel")}
+
+
+ {isExecuting && }
+ {t("contacts.merge.confirm")}
+
+
+
+
+ )
+}
diff --git a/apps/builder/src/features/contacts/components/merged-contacts-panel.tsx b/apps/builder/src/features/contacts/components/merged-contacts-panel.tsx
new file mode 100644
index 000000000..838328b0e
--- /dev/null
+++ b/apps/builder/src/features/contacts/components/merged-contacts-panel.tsx
@@ -0,0 +1,138 @@
+"use client"
+
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import { Loader2Icon, UndoIcon, UsersIcon } from "lucide-react"
+import { useTranslations } from "next-intl"
+import { useAction } from "next-safe-action/hooks"
+import { useEffect, useState } from "react"
+import { toast } from "sonner"
+import { unmergeContactsAction } from "../actions/unmerge-contacts.action"
+import {
+ listContactsMergedInto,
+ type MergedIntoContact,
+} from "../queries/list-merged-into"
+
+type MergedContactsPanelProps = {
+ workspaceId: string
+ primaryContactId: string
+}
+
+// Panel exibido no drawer do contato primary quando ele tem N contatos
+// fundidos. Mostra lista + botão "Desfazer fusão" por linha (#17 Fase E).
+// Lazy-fetch via server action — só carrega quando o contato é primary
+// de algum merge (mais comum é estar vazio).
+export function MergedContactsPanel({
+ workspaceId,
+ primaryContactId,
+}: MergedContactsPanelProps) {
+ const t = useTranslations()
+ const [contacts, setContacts] = useState(null)
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ let mounted = true
+ setLoading(true)
+ listContactsMergedInto(workspaceId, primaryContactId)
+ .then((rows) => {
+ if (mounted) {
+ setContacts(rows)
+ setLoading(false)
+ }
+ })
+ .catch(() => {
+ if (mounted) {
+ setContacts([])
+ setLoading(false)
+ }
+ })
+ return () => {
+ mounted = false
+ }
+ }, [workspaceId, primaryContactId])
+
+ const { execute: unmerge, isExecuting } = useAction(
+ unmergeContactsAction.bind(null, workspaceId),
+ {
+ onSuccess: ({ data }) => {
+ toast.success(t("contacts.unmerge.successToast"))
+ if (data) {
+ // Otimista: remove do panel sem refetch
+ setContacts(
+ (prev) =>
+ prev?.filter((c) => !data.unmergedIds.includes(c.id)) ?? null,
+ )
+ }
+ },
+ onError: ({ error }) => {
+ if (error.serverError) {
+ toast.error(error.serverError)
+ }
+ },
+ },
+ )
+
+ if (loading) {
+ return (
+
+
+ {t("contacts.unmerge.loading")}
+
+ )
+ }
+
+ if (!contacts || contacts.length === 0) {
+ return null
+ }
+
+ return (
+
+
+
+
+ {t("contacts.unmerge.bannerTitle", { count: contacts.length })}
+
+
+
+ {t("contacts.unmerge.warning")}
+
+
+ {contacts.map((c) => {
+ const name =
+ c.fullName || c.email || c.phoneNumber || `#${c.id.slice(-6)}`
+ return (
+
+
+
{name}
+ {c.email || c.phoneNumber ? (
+
+ {c.email || c.phoneNumber}
+
+ ) : null}
+
+
+ unmerge({
+ primaryId: primaryContactId,
+ duplicateIds: [c.id],
+ })
+ }
+ size="sm"
+ type="button"
+ variant="outline"
+ >
+
+ {t("contacts.unmerge.button")}
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/apps/builder/src/features/contacts/components/remove-contact-dialog.tsx b/apps/builder/src/features/contacts/components/remove-contact-dialog.tsx
index 93d9543be..e3e22ba44 100644
--- a/apps/builder/src/features/contacts/components/remove-contact-dialog.tsx
+++ b/apps/builder/src/features/contacts/components/remove-contact-dialog.tsx
@@ -11,24 +11,38 @@ import {
DialogTitle,
DialogTrigger,
} from "@chatbotx.io/ui/components/ui/dialog"
+import { Input } from "@chatbotx.io/ui/components/ui/input"
+import { Label } from "@chatbotx.io/ui/components/ui/label"
import { Loader2Icon } from "lucide-react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { useAction } from "next-safe-action/hooks"
-import { type ReactElement, useState } from "react"
+import { type ReactElement, useEffect, useState } from "react"
import { toast } from "sonner"
import { useWorkspaceId } from "@/hooks/routing"
import { deleteContactAction } from "../actions/delete-contact.action"
+export type ContactUsageCounts = {
+ tags: number
+ conversations: number
+}
+
type DeleteContactDialogProps = {
trigger: ReactElement
ids: string[]
+ /**
+ * Total de tags + conversas atribuídas aos contatos a deletar. Quando a
+ * soma > 0, exige confirmação numérica (mesmo padrão de DeleteTagsDialog
+ * #13). Quando omitido ou total = 0, mantém o flow rápido legado.
+ */
+ usageCounts?: ContactUsageCounts
onSuccess?: () => void
}
export default function DeleteContactDialog({
trigger,
ids,
+ usageCounts,
onSuccess,
}: DeleteContactDialogProps) {
const t = useTranslations()
@@ -37,6 +51,20 @@ export default function DeleteContactDialog({
const [open, setOpen] = useState(false)
const workspaceId = useWorkspaceId()
+ const totalLinked =
+ (usageCounts?.tags ?? 0) + (usageCounts?.conversations ?? 0)
+ const requiresConfirm = totalLinked > 0
+ const expectedConfirm = String(totalLinked)
+
+ const [confirmInput, setConfirmInput] = useState("")
+ const isMatch = !requiresConfirm || confirmInput.trim() === expectedConfirm
+
+ useEffect(() => {
+ if (!open) {
+ setConfirmInput("")
+ }
+ }, [open])
+
const { execute, isPending, isExecuting } = useAction(
deleteContactAction.bind(null, workspaceId),
{
@@ -73,11 +101,38 @@ export default function DeleteContactDialog({
})}
- {t("messages.deleteConfirmation", {
- feature: t("fields.contact.label"),
- })}
+ {requiresConfirm
+ ? t("contacts.delete.usageDescription", {
+ contactCount: ids.length,
+ tagCount: usageCounts?.tags ?? 0,
+ conversationCount: usageCounts?.conversations ?? 0,
+ })
+ : t("messages.deleteConfirmation", {
+ feature: t("fields.contact.label"),
+ })}
+
+ {requiresConfirm && (
+
+
+ {t("contacts.delete.confirmInputLabel", {
+ count: totalLinked,
+ })}
+
+
+ setConfirmInput(e.target.value.replace(/[^0-9]/g, ""))
+ }
+ placeholder={expectedConfirm}
+ value={confirmInput}
+ />
+
+ )}
+
@@ -86,7 +141,7 @@ export default function DeleteContactDialog({
execute({ ids })}
size="sm"
type="button"
diff --git a/apps/builder/src/features/contacts/contact-detail.tsx b/apps/builder/src/features/contacts/contact-detail.tsx
index ec90515c0..b7e2ae5fe 100644
--- a/apps/builder/src/features/contacts/contact-detail.tsx
+++ b/apps/builder/src/features/contacts/contact-detail.tsx
@@ -1,31 +1,61 @@
"use client"
import type { CustomFieldType } from "@chatbotx.io/database/partials"
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@chatbotx.io/ui/components/ui/accordion"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@chatbotx.io/ui/components/ui/avatar"
import { Button } from "@chatbotx.io/ui/components/ui/button"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@chatbotx.io/ui/components/ui/tooltip"
import { AtSignIcon, PhoneIcon, TextIcon } from "lucide-react"
+import Link from "next/link"
import { useTranslations } from "next-intl"
-import { useEffect, useMemo, useState } from "react"
+import { useEffect, useState } from "react"
+import { toast } from "sonner"
+import { RespondIcon } from "@/components/respond-icon"
import { useWorkspaceId } from "@/hooks/routing"
import { useChatStore } from "../chat/store/chat-store-provider"
-import { ContactCustomFieldManage } from "../custom-fields/contact-custom-field-manage"
import { customFieldIconsMap } from "../custom-fields/provider/custom-field-hook"
import { useCustomFieldStore } from "../custom-fields/provider/custom-field-store-context"
-import { EditContactField } from "./edit-contact-field"
+import { TagStoreProvider } from "../tags/provider/tag-store-context"
+import type { TagResource } from "../tags/schema/resource"
+import { ContactTagsPanel } from "./components/contact-tags-panel"
+import { InlineContactField } from "./components/inline-contact-field"
import type { GetContactResponse } from "./schemas/query"
import type { ContactEditableField } from "./schemas/resource"
-import { useAvatarUrl } from "./utils"
+import { getAvatarInitials, getRespondAvatarUrl, useAvatarUrl } from "./utils"
+// Painel direito pixel-perfect Respond.io 2026-05-24 — Pedro reclamou que
+// removi tudo (avatar, nome, header, canais, etiquetas) na refatoração
+// anterior. Restaurando estrutura 1:1 com print do amigo:
+// 1. Header "Detalhes do contato"
+// 2. Avatar pequeno + Nome + (ID · 🇧🇷) [menu …]
+// 3. Administrador: [link azul com nome do assignee]
+// 4. Canais (botão expandível com bolinha verde WhatsApp)
+// 5. Campos de contatos [✏️] (lista de campos editáveis inline)
+// 6. Etiquetas (chips coloridos + "Adicionar etiqueta")
export const ContactDetail = ({
activeConversationId,
contact,
+ hiddenFieldKeys = [],
+ onTagsChange,
}: {
activeConversationId: string | null
contact: GetContactResponse | null
+ /** Lista de `fieldKey`s ocultas pelo workspace (Personalizar visualização). */
+ hiddenFieldKeys?: string[]
+ onTagsChange?: (tags: TagResource[]) => void
}) => {
const t = useTranslations()
@@ -33,20 +63,15 @@ export const ContactDetail = ({
const { conversations } = useChatStore((state) => state)
const avatarUrl = useAvatarUrl(contact)
- const [selectedField, setSelectedField] =
- useState(null)
+ const activeConversation = conversations.find(
+ (c) => c.id === activeConversationId,
+ )
const { customFields, initialized: initializedCustomFields } =
useCustomFieldStore((state) => state)
const [contactFields, setContactFields] = useState([])
- const handleCustomFieldDeleted = (customFieldId: string) => {
- setContactFields((previous) =>
- previous.filter((field) => field.key !== customFieldId),
- )
- }
-
const handleCustomFieldUpdated = (customFieldId: string, value: string) => {
setContactFields((previous) =>
previous.map((field) =>
@@ -55,155 +80,350 @@ export const ContactDetail = ({
)
}
- const handleChooseCustomField = (customFieldId: string) => {
- const targetCustomField = customFields.find(
- (field) => field.id.toString() === customFieldId,
+ // Pedro 2026-05-25 iteração 32: pixel-perfect Respond.io.
+ // No Respond.io, TODOS os custom fields do workspace já aparecem
+ // listados na lateral do contato (mesmo sem valor preenchido).
+ // NÃO existe botão "Adicionar Campo Personalizado" no drawer.
+ // Campos vazios mostram placeholder "Adicionar {label}" e clicar
+ // edita inline. Workspace admin gerencia o catálogo via página
+ // /custom-fields (link "Administrar" no header da seção).
+ useEffect(() => {
+ if (!(activeConversationId && initializedCustomFields)) {
+ setContactFields([])
+ return
+ }
+
+ const conversation = conversations.find(
+ (item) => item.id === activeConversationId,
)
- if (!targetCustomField) {
+
+ if (!conversation?.contact) {
+ setContactFields([])
return
}
- setContactFields([
- ...contactFields,
+
+ // Ordem Pedro 2026-05-25 iteração 30: Telefone → Nome → Sobrenome → Email
+ const tmpContactFields: ContactEditableField[] = [
{
- key: customFieldId,
- icon: customFieldIconsMap[targetCustomField.type as CustomFieldType],
- label: targetCustomField.name,
- value: "",
- type: targetCustomField.type as CustomFieldType,
+ key: "phoneNumber",
+ icon: PhoneIcon,
+ label: "Número de Telefone",
+ value: conversation.contact.phoneNumber,
+ type: "shortText",
},
- ])
- }
+ {
+ key: "firstName",
+ icon: TextIcon,
+ label: "Nome",
+ value: conversation.contact.firstName,
+ type: "shortText",
+ },
+ {
+ key: "lastName",
+ icon: TextIcon,
+ label: "Sobrenome",
+ value: conversation.contact.lastName,
+ type: "shortText",
+ },
+ {
+ key: "email",
+ icon: AtSignIcon,
+ label: "Email",
+ value: conversation.contact.email,
+ type: "shortText",
+ },
+ ]
- const customFieldMap = useMemo(() => {
- const map = new Map()
- for (const field of customFields) {
- map.set(field.id, field)
- }
- return map
- }, [customFields])
+ // Map dos valores já preenchidos pelo contato (chave = customField.id)
+ const contactCustomValuesMap = new Map(
+ (contact?.customFields ?? []).map((f) => [f.id, f.value]),
+ )
- useEffect(() => {
- if (activeConversationId && initializedCustomFields) {
- const conversation = conversations.find(
- (item) => item.id === activeConversationId,
- )
-
- if (conversation?.contact) {
- const tmpContactFields: ContactEditableField[] = [
- {
- key: "email",
- icon: AtSignIcon,
- label: "Email",
- value: conversation.contact.email,
- type: "shortText",
- },
- {
- key: "firstName",
- icon: TextIcon,
- label: "First Name",
- value: conversation.contact.firstName,
- type: "shortText",
- },
- {
- key: "lastName",
- icon: TextIcon,
- label: "Last Name",
- value: conversation.contact.lastName,
- type: "shortText",
- },
- {
- key: "phoneNumber",
- icon: PhoneIcon,
- label: "Phone Number",
- value: conversation.contact.phoneNumber,
- type: "shortText",
- },
- ]
-
- for (const contactCustomField of contact?.customFields ?? []) {
- const targetCustomField = customFieldMap.get(contactCustomField.id)
- if (targetCustomField) {
- tmpContactFields.push({
- key: contactCustomField.id,
- icon: customFieldIconsMap[
- targetCustomField.type as CustomFieldType
- ],
- label: targetCustomField.name,
- value: contactCustomField.value,
- type: targetCustomField.type as CustomFieldType,
- })
- }
- }
-
- setContactFields(tmpContactFields)
- } else {
- setContactFields([])
+ // Iteração 32 (Pedro): listar TODOS os custom fields do workspace
+ // (não só os do contato). Campos sem valor mostram placeholder e
+ // ficam editáveis inline — igual Respond.io.
+ for (const targetCustomField of customFields) {
+ const cf = targetCustomField as unknown as {
+ id: string
+ name: string
+ type: string
+ visibility?: "alwaysShow" | "alwaysHide" | "hideWhenEmpty"
+ values?: string[]
}
- } else {
- setContactFields([])
+ tmpContactFields.push({
+ key: cf.id.toString(),
+ icon: customFieldIconsMap[cf.type as CustomFieldType],
+ label: cf.name,
+ value: contactCustomValuesMap.get(cf.id.toString()) ?? "",
+ type: cf.type as CustomFieldType,
+ visibility: cf.visibility,
+ options: cf.values ?? [],
+ })
}
+
+ setContactFields(tmpContactFields)
}, [
activeConversationId,
conversations,
initializedCustomFields,
contact,
- customFieldMap,
+ customFields,
])
- return contact ? (
-
-
-
- {contactFields.map((editable) => (
-
-
-
-
- {editable.label}
-
-
+ // Bandeira por código ISO do país (BR → 🇧🇷). Mais discreto que
.
+ const countryFlag =
+ typeof contact?.country === "string" && contact.country.length === 2
+ ? String.fromCodePoint(
+ ...contact.country
+ .toUpperCase()
+ .split("")
+ .map((c) => 0x1_f1_e6 - 65 + c.charCodeAt(0)),
+ )
+ : null
+
+ if (!contact) {
+ return null
+ }
+
+ const fullName = contact.fullName ?? ""
+ // Iter 41: seed = ID (consistente com conv-list + topbar + bubble).
+ const respondAvatar = getRespondAvatarUrl(contact.id ?? fullName)
+ const initials = getAvatarInitials(fullName)
+
+ const _assignedUserName =
+ activeConversation?.assignedUser?.name ||
+ activeConversation?.assignedUser?.email ||
+ "—"
+
+ // Tags do contato (vem de GetContactResponse já com color/emoji/name)
+ const tags = contact.tags ?? []
-
setSelectedField(editable)}
- size="sm"
- variant="ghost"
+ // Separa fields em visíveis e ocultos (Pedro iter 33 + gap #10 Fase D).
+ // Dois sistemas combinados:
+ // 1. hiddenFieldKeys (workspace-contact-field-visibility) = override
+ // POR USUÁRIO do workspace. Tem prioridade.
+ // 2. customField.visibility (global no field) = setting global do admin.
+ // alwaysHide vai pro accordion. hideWhenEmpty SUMRINDO quando value
+ // vazio (paridade Respond.io).
+ const hiddenSet = new Set(hiddenFieldKeys)
+ const visibleFields = contactFields.filter((f) => {
+ if (hiddenSet.has(f.key)) {
+ return false
+ }
+ if (f.visibility === "alwaysHide") {
+ return false
+ }
+ if (f.visibility === "hideWhenEmpty" && !f.value?.trim()) {
+ return false
+ }
+ return true
+ })
+ const hiddenFields = contactFields.filter(
+ (f) => hiddenSet.has(f.key) || f.visibility === "alwaysHide",
+ )
+
+ return (
+ /* Layout 3 zonas (Pedro 2026-05-25 iteração 31, pixel-perfect Respond.io):
+ - Header fixo no topo (shrink-0): "Detalhes do contato" + avatar+nome
+ - Middle rolável (flex-1 overflow-y-auto): "Campos de contatos"
+ - Footer fixo embaixo (shrink-0): "Etiquetas" + chips
+ O container pai (chat-layout) precisa ter height fixa (h-full) e SEM
+ overflow-y-auto pra que a rolagem aconteça aqui dentro.
+ `min-w-0` (iter 37) é obrigatório pra valores longos não estourarem. */
+
+ {/* ── ZONA 1: HEADER FIXO ───────────────────────────────────────── */}
+
+ {/* "Detalhes do contato" — sentence case + size 16 semibold. */}
+
+
+ {t("contacts.details")}
+
+
+
+ {/* Bloco principal: avatar 40 + nome + ID + bandeira.
+ Pedro 2026-05-25 iteração 30: TUDO FUNCIONAL.
+ - Ícones COPY + EDIT no hover do nome.
+ • copy → copia o nome pra clipboard
+ • edit → foca no input do firstName logo abaixo */}
+
+
+
+
- {editable.value && editable.value.length > 0 ? (
-
- {editable.value}
-
- ) : (
- -- {t("actions.clickToEdit")} --
- )}
-
+ {initials}
+
+
+
+
+ {/* Iter 39: weight 700 (bold) match Respond.io. Antes era
+ font-semibold (600). 14px / leading-[22px] / #CFD3D8. */}
+
+ {fullName || "—"}
+
+
+
+
+ {
+ if (!fullName) {
+ return
+ }
+ navigator.clipboard.writeText(fullName)
+ toast.success(t("messages.actions.copied") ?? "Copiado")
+ }}
+ size="icon"
+ type="button"
+ variant="ghost"
+ >
+
+
+
+
+ {t("messages.actions.copy") ?? "Copiar"}
+
+
+
+
+ {
+ // Foca no input do firstName (que tá renderizado abaixo
+ // como InlineContactField). Usa querySelector pelo
+ // placeholder/type pra encontrar.
+ const el = document.querySelector(
+ 'input[type="text"][placeholder*="Nome"]',
+ )
+ el?.focus()
+ el?.select()
+ }}
+ size="icon"
+ type="button"
+ variant="ghost"
+ >
+
+
+
+
+ {t("actions.edit") ?? "Editar"}
+
+
+
+
+ {/* Iter 39: ID = 12px / weight 400 / cor #CFD3D8 (NÃO muted).
+ Antes era 11px text-muted-foreground (#97A0AA). */}
+
+ ID: {contact.id}
+ {countryFlag && {countryFlag} }
+
- ))}
-
field.key)}
- onChooseCustomField={handleChooseCustomField}
- workspaceId={workspaceId}
- />
+
+
+ {/* ── /ZONA 1 ─────────────────────────────────────────────────── */}
+
+ {/* ── ZONA 2: CAMPOS DE CONTATOS (ROLÁVEL) ────────────────────── */}
+
+ {/* Campos de contatos — link real "Administrar" → /custom-fields
+ (Pedro 2026-05-25 iteração 29: TUDO FUNCIONAL — agora é Link
+ Next que navega pra página de custom fields do workspace). */}
+
+
+ {t("contacts.fieldsTitle")}
+
+
+
+ {t("contacts.admin")}
+
+
+
+
+ {/* Contact fields INLINE editáveis — pixel-perfect Respond.io
+ 2026-05-25 iteração 30 (Pedro): cada field é input direto
+ (não abre modal). Save on blur ou Enter. Custom fields usam
+ InlineContactField padronizado. Label em CIMA muted (12px 600),
+ valor em BAIXO maior (14px). Phone tem bandeira automática. */}
+ {/* Lista os custom fields do workspace VISÍVEIS (Pedro iter 33).
+ Hidden vão pro accordion "Campos de contato ocultos" abaixo. */}
+
+ {visibleFields.map((editable) => (
+
+ ))}
+
+
+ {/* Accordion "Campos de contato ocultos" — Pedro iter 33.
+ Mostra fields marcados como `alwaysHide` no Personalizar
+ visualização (Settings → Campos de contatos). Só aparece
+ se houver pelo menos 1 hidden. Mesmo editor inline dos
+ visíveis quando expandido. */}
+ {hiddenFields.length > 0 && (
+
+
+ {/* Iter 39: weight 500 (medium), match Respond.io.
+ Antes era font-semibold (600) — pareciam negrito. */}
+
+ {t("contacts.hiddenFields") ?? "Campos de contato ocultos"}
+
+
+
+ {hiddenFields.map((editable) => (
+
+ ))}
+
+
+
+
+ )}
+ {/* ── /ZONA 2 ─────────────────────────────────────────────────── */}
-
setSelectedField(null)}
- onUpdated={handleCustomFieldUpdated}
- open={Boolean(selectedField)}
- targetField={selectedField}
- workspaceId={workspaceId}
- />
+ {/* ── ZONA 3: ETIQUETAS (FIXA EMBAIXO) ────────────────────────── */}
+ {/* Pedro 2026-05-25 iteração 31 (pixel-perfect Respond.io):
+ Etiquetas é a última seção do drawer e fica FIXA embaixo.
+ A área Campos de Contatos (Zona 2) rola; Etiquetas não.
+ UpdateContactTagField gerencia add/remove com persistência. */}
+ {onTagsChange && (
+
+ {/* Iter 39: "Etiquetas" h4 = weight 700 (bold) no Respond.io.
+ Antes era font-semibold (600). */}
+
+ {t("contacts.tagsTitle")}
+
+
+
+
+
+ )}
+ {/* ── /ZONA 3 ─────────────────────────────────────────────────── */}
- ) : null
+ )
}
diff --git a/apps/builder/src/features/contacts/contact-inbox-panel.tsx b/apps/builder/src/features/contacts/contact-inbox-panel.tsx
index 3790a6b9f..b0d246f65 100644
--- a/apps/builder/src/features/contacts/contact-inbox-panel.tsx
+++ b/apps/builder/src/features/contacts/contact-inbox-panel.tsx
@@ -1,39 +1,29 @@
"use client"
-import {
- Accordion,
- AccordionContent,
- AccordionItem,
- AccordionTrigger,
-} from "@chatbotx.io/ui/components/ui/accordion"
-import { useTranslations } from "next-intl"
+import type { LifecycleStageModel } from "@chatbotx.io/database/types"
import { useEffect, useMemo, useState } from "react"
import { client } from "@/lib/orpc/orpc"
import { useChatStore } from "../chat/store/chat-store-provider"
-import { ContactNotesManage } from "../contact-notes/contact-notes-manage"
-import type { ContactOnSequenceWithRelations } from "../contact-sequences/schema"
-import UpdateContactSequenceField from "../contact-sequences/update-contact-sequence-field"
-import { SequenceStoreProvider } from "../sequences/provider/sequence-store-context"
-import { TagStoreProvider } from "../tags/provider/tag-store-context"
-import type { TagResource } from "../tags/schema/resource"
-import UpdateContactTagField from "./components/update-contact-tag-field"
import { ContactDetail } from "./contact-detail"
import type { GetContactResponse } from "./schemas/query"
-type AccordionModule = {
- readonly keyName: string
- readonly content: React.ReactNode
-}
-
+// Painel direito pixel-perfect Respond.io 2026-05-25 iteração 31 (Pedro):
+// REMOVIDO: Notas, Ciclo de vida, Histórico de etapas, Sequências.
+// Respond.io só tem Header + Campos de contatos (rolável) + Etiquetas
+// (fixa embaixo). Tudo aqui agora é só wrapper de ``.
export const ContactInboxPanel = ({
workspaceId,
activeConversationId,
+ hiddenFieldKeys = [],
}: {
workspaceId: string
activeConversationId: string | null
+ // Mantido na assinatura por compat com chat-layout, mesmo que esta
+ // versão não use mais (já que removemos lifecycle do drawer).
+ lifecycleStages?: LifecycleStageModel[]
+ /** Keys de fields marcadas como `alwaysHide` no workspace. */
+ hiddenFieldKeys?: string[]
}) => {
- const t = useTranslations()
-
const { conversations } = useChatStore((state) => state)
const storeContact = useMemo(
@@ -69,82 +59,20 @@ export const ContactInboxPanel = ({
})
}, [activeConversationId, storeContact?.id, workspaceId])
- const accordionModules: AccordionModule[] = useMemo(() => {
- if (!contactData) {
- return []
- }
-
- return [
- {
- keyName: t("fields.tags.label"),
- content: (
-
- {
- setContactData({ ...contactData, tags: updatedTags })
- }}
- tags={contactData.tags}
- workspaceId={workspaceId}
- />
-
- ),
- },
- {
- keyName: t("sequences.title"),
- content: (
-
- {
- setContactData({
- ...contactData,
- contactsOnSequences:
- updatedSequences as ContactOnSequenceWithRelations[],
- })
- }}
- sequences={
- contactData.contactsOnSequences as ContactOnSequenceWithRelations[]
- }
- />
-
- ),
- },
- ]
- }, [contactData, workspaceId, t])
-
if (!storeContact) {
return null
}
return (
-
-
-
-
-
-
- {accordionModules.map((module, index) => (
-
-
- {module.keyName}
-
- {module.content}
-
- ))}
-
-
+ {
+ if (contactData) {
+ setContactData({ ...contactData, tags: updatedTags })
+ }
+ }}
+ />
)
}
diff --git a/apps/builder/src/features/contacts/contacts-list-action.tsx b/apps/builder/src/features/contacts/contacts-list-action.tsx
index 5908bf942..0a06ec08d 100644
--- a/apps/builder/src/features/contacts/contacts-list-action.tsx
+++ b/apps/builder/src/features/contacts/contacts-list-action.tsx
@@ -1,5 +1,6 @@
"use client"
+import type { LifecycleStageModel } from "@chatbotx.io/database/types"
import { Button } from "@chatbotx.io/ui/components/ui/button"
import {
DropdownMenu,
@@ -21,14 +22,17 @@ import {
ListIcon,
MessageCirclePlusIcon,
OctagonXIcon,
+ RefreshCwIcon,
SaveIcon,
SaveOffIcon,
TagIcon,
UserIcon,
UserRoundXIcon,
+ UsersIcon,
} from "lucide-react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
+import { BulkMoveLifecycleDialog } from "@/features/lifecycle-stages/bulk-move-lifecycle-dialog"
import { SequenceStoreProvider } from "@/features/sequences/provider/sequence-store-context"
import ArchiveConversationDialog from "../conversations/components/archive-conversation"
import AssignConversationDialog from "../conversations/components/assign-conversation-dialog"
@@ -38,6 +42,7 @@ import AddContactSequenceDialog from "./components/add-contact-sequence-dialog"
import AddContactTagDialog from "./components/add-contact-tag-dialog"
import AddContactCustomFieldDialog from "./components/add-custom-field-dialog"
import ClearContactCustomFieldDialog from "./components/delete-contact-custom-field"
+import { MergeContactsDialog } from "./components/merge-contacts-dialog"
import DeleteContactDialog from "./components/remove-contact-dialog"
import RemoveContactSequenceDialog from "./components/remove-contact-sequence-dialog"
import RemoveContactTagDialog from "./components/remove-contact-tag-dialog"
@@ -47,11 +52,13 @@ import type { ListContactsItem } from "./schemas/query"
type ContactListActionProps = {
workspaceId: string
table: Table
+ lifecycleStages?: LifecycleStageModel[]
}
export function ContactListAction({
workspaceId,
table,
+ lifecycleStages = [],
}: ContactListActionProps) {
const t = useTranslations()
const router = useRouter()
@@ -96,6 +103,22 @@ export function ContactListAction({
}
/>
+ {lifecycleStages.length > 0 && (
+ r.id)}
+ stages={lifecycleStages}
+ trigger={
+ e.preventDefault()}
+ >
+
+ {t("lifecycle.bulkMove")}
+
+ }
+ />
+ )}
+
r.id)}
@@ -124,6 +147,24 @@ export function ContactListAction({
}
/>
+ r.original)}
+ onSuccess={() => {
+ table.toggleAllRowsSelected(false)
+ router.refresh()
+ }}
+ trigger={
+ e.preventDefault()}
+ >
+
+ {t("contacts.merge.action")}
+
+ }
+ workspaceId={workspaceId}
+ />
+
r.id)}
trigger={
@@ -135,6 +176,16 @@ export function ContactListAction({
{t("actions.delete")}
}
+ usageCounts={{
+ tags: rows.reduce(
+ (sum, r) => sum + (r.original.tags?.length ?? 0),
+ 0,
+ ),
+ conversations: rows.reduce(
+ (sum, r) => sum + (r.original.conversation ? 1 : 0),
+ 0,
+ ),
+ }}
/>
>]>
+ lifecycleStages?: LifecycleStageModel[]
+}
+
+// Formato de data no padrão Respond.io ("abr 21, 2026 12:19 PM").
+function formatRespondDate(
+ value: Date | string | null,
+ locale: string,
+): string {
+ if (!value) {
+ return "—"
+ }
+ const d = typeof value === "string" ? new Date(value) : value
+ if (Number.isNaN(d.getTime())) {
+ return "—"
+ }
+ return new Intl.DateTimeFormat(locale, {
+ month: "short",
+ day: "2-digit",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: true,
+ }).format(d)
+}
+
+// ISO 2-letter country code → bandeira emoji (regional indicator unicode).
+function countryCodeToFlag(code: string | null | undefined): string {
+ if (!code || code.length !== 2) {
+ return "🌐"
+ }
+ const upper = code.toUpperCase()
+ const base = 0x1_f1_e6
+ return (
+ String.fromCodePoint(base + upper.charCodeAt(0) - 65) +
+ String.fromCodePoint(base + upper.charCodeAt(1) - 65)
+ )
}
-export function ContactsTable({ workspaceId, promises }: ContactsTableProps) {
+export function ContactsTable({
+ workspaceId,
+ promises,
+ lifecycleStages: _lifecycleStages = [],
+}: ContactsTableProps) {
const t = useTranslations()
+ const locale = useLocale()
const [{ data, pageCount }] = use(promises)
+ const prependConversation = useChatStore((s) => s.prependConversation)
+ const setActiveConversationId = useChatStore((s) => s.setActiveConversationId)
+ // Drawer "Detalhes do contato" — abre ao clicar na linha. Pedro 2026-05-26:
+ // pixel-perfect Respond.io (350px à direita). Drawer reusa o
+ // FULL do Inbox via ChatStoreProvider que mora no layout.
+ const [selectedContact, setSelectedContact] =
+ useState(null)
+
+ const handleRowClick = (contact: ListContactsItem) => {
+ setSelectedContact(contact)
+ if (contact.conversation) {
+ // Popula chatStore com a conversation do contato pra que o
+ // reuse a mesma fonte de verdade que usa no Inbox
+ // (assignedUser etc vêm de useChatStore.conversations). O shape
+ // do .conversation tem subset do que
+ // ListConversationItemResource precisa — fields extras (messages,
+ // contactInboxes, unreadCount) não são lidos pelo ContactDetail,
+ // então passamos defaults.
+ const conversationForStore: ListConversationItemResource = {
+ ...contact.conversation,
+ contact,
+ contactInboxes: contact.contactInboxes ?? [],
+ messages: [],
+ unreadCount: 0,
+ } as unknown as ListConversationItemResource
+ prependConversation(conversationForStore)
+ setActiveConversationId(contact.conversation.id)
+ }
+ }
const columns = useMemo[]>(
() => [
@@ -39,7 +131,7 @@ export function ContactsTable({ workspaceId, promises }: ContactsTableProps) {
id: "select",
header: ({ table: innerTable }) => (
(
row.toggleSelected(Boolean(value))}
/>
@@ -70,23 +162,13 @@ export function ContactsTable({ workspaceId, promises }: ContactsTableProps) {
/>
),
cell: ({ row }) => (
-
-
-
-
- {row.original.fullName}
-
-
-
- {row.original.fullName}
-
-
-
+ // Mesmo padrão do do Inbox (memory:
+ // reference_avatares_consistentes). Seed do CDN = ID do contato
+ // (snowflake imutável) — garante MESMO avatar em qualquer
+ // lugar que renderiza (lista Inbox, topbar, bubble, drawer).
+
),
+ size: 220,
meta: {
label: t("fields.name.label"),
placeholder: t("fields.name.placeholder"),
@@ -96,15 +178,16 @@ export function ContactsTable({ workspaceId, promises }: ContactsTableProps) {
enableHiding: false,
},
{
- accessorKey: "source",
+ id: "channels",
+ accessorKey: "channels",
header: ({ column }: { column: Column }) => (
),
cell: ({ row }) => (
-
+
{row.original.contactInboxes?.map((contactInbox) => (
),
+ meta: { label: t("contacts.column.channels") },
enableSorting: false,
- enableHiding: false,
- meta: {
- label: t("fields.source.label"),
+ },
+ {
+ id: "lifecycleStage",
+ accessorKey: "lifecycleStage",
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => {
+ const stage = row.original.lifecycleStage
+ if (!stage) {
+ return —
+ }
+ return
},
+ meta: { label: t("lifecycle.title") },
+ enableSorting: false,
},
{
- accessorKey: "assignee",
+ id: "email",
+ accessorKey: "email",
header: ({ column }) => (
),
- cell: ({ row }) => (
-
-
-
- {getUserName(
- row.original.conversation?.assignedUser,
- t("assignAdmin.unAssigned"),
- )}
-
-
-
- {getUserName(
- row.original.conversation?.assignedUser,
- t("assignAdmin.unAssigned"),
+ cell: ({ row }) => {
+ const email = row.original.email
+ if (!email) {
+ return —
+ }
+ return (
+
+
+ {email}
+
+
+ {email}
+
+
+ )
+ },
+ meta: { label: t("fields.email.label") },
+ enableSorting: false,
+ },
+ {
+ id: "phoneNumber",
+ accessorKey: "phoneNumber",
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => {
+ const phone = row.original.phoneNumber
+ if (!phone) {
+ return —
+ }
+ return {phone}
+ },
+ meta: { label: t("fields.phoneNumber.label") },
+ enableSorting: false,
+ },
+ {
+ id: "tags",
+ accessorKey: "tags",
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => {
+ const tags = row.original.tags ?? []
+ if (tags.length === 0) {
+ return —
+ }
+ // Pixel-perfect Respond.io 2026-05-26 (Pedro): tags em UMA linha
+ // só (sem wrap). Máximo 3 chips visíveis com nome truncado +
+ // "+N" pra restante. Row da tabela mantém altura fixa.
+ const visible = tags.slice(0, 3)
+ const remaining = tags.length - visible.length
+ return (
+
+ {visible.map((tag) => (
+
+ {tag.emoji && {tag.emoji} }
+ {tag.name}
+
+ ))}
+ {remaining > 0 && (
+
+ +{remaining}
+
)}
-
-
+
+ )
+ },
+ meta: { label: t("tags.title") },
+ enableSorting: false,
+ },
+ {
+ id: "country",
+ accessorKey: "country",
+ header: ({ column }) => (
+
),
- meta: {
- label: t("fields.assignee.label"),
+ cell: ({ row }) => {
+ const country = row.original.country
+ if (!country) {
+ return —
+ }
+ return (
+
+
+ {countryCodeToFlag(country)}
+
+ {country}
+
+ )
},
+ meta: { label: t("contacts.column.country") },
enableSorting: false,
- enableHiding: false,
},
{
- id: "lastReadAt",
- accessorKey: "lastReadAt",
+ id: "locale",
+ accessorKey: "locale",
header: ({ column }) => (
),
- cell: ({ row }) => (
-
- {row.original.conversation?.contactLastReadAt
- ? formatDistance(
- new Date(),
- row.original.conversation.contactLastReadAt,
- {
- addSuffix: true,
- },
- )
- : null}
-
+ cell: ({ row }) => {
+ const loc = row.original.locale
+ if (!loc) {
+ return —
+ }
+ return {loc}
+ },
+ meta: { label: t("contacts.column.language") },
+ enableSorting: false,
+ },
+ {
+ id: "conversationStatus",
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => {
+ const conv = row.original.conversation
+ if (!conv) {
+ return —
+ }
+ const isOpen = !conv.archivedAt
+ return (
+
+ {isOpen
+ ? t("contacts.column.statusOpen")
+ : t("contacts.column.statusClosed")}
+
+ )
+ },
+ meta: { label: t("contacts.column.conversationStatus") },
+ enableSorting: false,
+ },
+ {
+ id: "assignee",
+ accessorKey: "assignee",
+ header: ({ column }) => (
+
),
+ cell: ({ row }) => {
+ const name = getUserName(
+ row.original.conversation?.assignedUser,
+ t("assignAdmin.unAssigned"),
+ )
+ return (
+
+
+ {name}
+
+ {name}
+
+ )
+ },
meta: {
- label: t("fields.lastRead.label"),
+ label: t("fields.assignee.label"),
},
+ enableSorting: false,
+ },
+ {
+ id: "lastMessage",
+ accessorKey: "lastMessageAt",
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => {
+ const lastReadAt = row.original.conversation?.contactLastReadAt
+ return (
+
+ {formatRespondDate(lastReadAt ?? null, locale)}
+
+ )
+ },
+ meta: { label: t("contacts.column.lastMessage") },
enableSorting: true,
- enableHiding: false,
},
{
+ id: "createdAt",
accessorKey: "createdAt",
header: ({ column }) => (
),
- cell: ({ row }) => format(row.original.createdAt, "yyyy/MM/dd"),
+ cell: ({ row }) => (
+
+ {formatRespondDate(row.original.createdAt, locale)}
+
+ ),
meta: {
- label: t("fields.createdAt.label"),
+ label: t("fields.addedAt.label"),
},
enableSorting: true,
enableHiding: false,
},
],
- [workspaceId, t],
+ [workspaceId, t, locale],
)
+ const [initialColumnVisibility] =
+ useState(loadColumnVisibility)
+
const { table } = useDataTable({
data,
columns,
@@ -206,18 +461,120 @@ export function ContactsTable({ workspaceId, promises }: ContactsTableProps) {
initialState: {
sorting: [{ id: "createdAt", desc: true }],
columnPinning: { right: ["actions"] },
+ columnVisibility: initialColumnVisibility,
},
getRowId: (originalRow) => originalRow.id,
shallow: false,
clearOnDefault: true,
})
+ // Persiste visibilidade entre sessões. Lê do state vivo do TanStack
+ // (não do initialState) pra refletir toggles feitos pelo picker.
+ const columnVisibility = table.getState().columnVisibility
+ useEffect(() => {
+ try {
+ window.localStorage.setItem(
+ COLUMN_VISIBILITY_STORAGE_KEY,
+ JSON.stringify(columnVisibility),
+ )
+ } catch {
+ // ignore — localStorage cheio ou private mode
+ }
+ }, [columnVisibility])
+
+ // Pixel-perfect Respond.io 2026-05-26 (Pedro): drawer "Detalhes do
+ // contato" SOBREPÕE a tabela (não empurra encolhendo). Wrapper
+ // `relative` + drawer `absolute right-0 top-0 bottom-0 z-20` cobre o
+ // espaço da tabela. Tabela mantém largura total.
+ // Tipografia + separator pixel-perfect (memory:
+ // reference_fontes_respond_io). Texto = Inter / 14px / weight 400 /
+ // #CFD3D8 (text-text-secondary, NÃO branco puro). Separator entre rows =
+ // `rgba(255,255,255,0.06)` (sutil), substitui `border-border` que ficava
+ // forte demais no dark. Wrapper `[&_...]` targeta tbody/thead/td/th
+ // sem mexer no shared Table.
+ const tableClassName =
+ "[&_tbody_tr]:!border-white/[0.06] [&_thead_tr]:!border-white/[0.06] [&_td]:!text-text-secondary [&_th]:!text-text-secondary [&_th]:!font-medium"
+
+ return (
+
+ handleRowClick(row.original)}
+ table={table}
+ >
+
+
+
+
+
+
+ {
+ setSelectedContact(null)
+ setActiveConversationId(null)
+ }}
+ open={selectedContact !== null}
+ workspaceId={workspaceId}
+ />
+
+ )
+}
+
+// Cell da coluna Nome com avatar pixel-perfect Respond.io (Pedro 2026-05-26:
+// "preciso que seja o mesmo do Inbox"). Usa o mesmo seed do
+// — ID do contato — pra garantir mesma cor + bonequinho
+// em qualquer lugar. `useAvatarUrl` retorna imagem própria do contato
+// (additionalAttributes.avatarUrl) quando existe; senão cai no avatar do
+// CDN local /respond-avatars (mesmo padrão do card do Inbox).
+function ContactNameCell({
+ contact,
+ workspaceId,
+}: {
+ contact: ListContactsItem
+ workspaceId: string
+}) {
+ const name = contact.fullName || contact.phoneNumber || contact.email || "?"
+ const avatarUrl = useAvatarUrl(contact)
+ const respondAvatar = getRespondAvatarUrl(contact.id ?? name)
+ const finalAvatarUrl = avatarUrl || respondAvatar.url
+ const initials = getAvatarInitials(name)
return (
-
-
-
-
-
-
+
+
+ {initials}
+ {/* biome-ignore lint/performance/noImgElement: avatar de fundo simples sem Next/Image */}
+
+
+
+
+
+ {name}
+
+
+
+ {name}
+
+
+
)
}
diff --git a/apps/builder/src/features/contacts/create-contact-dialog.tsx b/apps/builder/src/features/contacts/create-contact-dialog.tsx
index 277e2e591..ed1488506 100644
--- a/apps/builder/src/features/contacts/create-contact-dialog.tsx
+++ b/apps/builder/src/features/contacts/create-contact-dialog.tsx
@@ -25,7 +25,7 @@ export function CreateContactDialog({
const t = useTranslations()
const [open, setOpen] = useState(false)
- const onSubmmited = () => {
+ const onSubmitted = () => {
setOpen(false)
router.refresh()
}
@@ -53,7 +53,7 @@ export function CreateContactDialog({
setOpen(false)}
- onSubmmited={onSubmmited}
+ onSubmitted={onSubmitted}
workspaceId={workspaceId}
/>
diff --git a/apps/builder/src/features/contacts/create-contact-form.tsx b/apps/builder/src/features/contacts/create-contact-form.tsx
index f181511d6..74ff7b790 100644
--- a/apps/builder/src/features/contacts/create-contact-form.tsx
+++ b/apps/builder/src/features/contacts/create-contact-form.tsx
@@ -1,27 +1,73 @@
"use client"
+import { ComboboxField } from "@chatbotx.io/ui/components/form/combobox-field"
import { InputField } from "@chatbotx.io/ui/components/form/input-field"
-import { SelectField } from "@chatbotx.io/ui/components/form/select-field"
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@chatbotx.io/ui/components/ui/accordion"
import { Button } from "@chatbotx.io/ui/components/ui/button"
+import { Checkbox } from "@chatbotx.io/ui/components/ui/checkbox"
+import {
+ Command,
+ CommandEmpty,
+ CommandInput,
+ CommandList,
+} from "@chatbotx.io/ui/components/ui/command"
import { Form } from "@chatbotx.io/ui/components/ui/form"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@chatbotx.io/ui/components/ui/popover"
import { zodResolver } from "@hookform/resolvers/zod"
import { useHookFormAction } from "@next-safe-action/adapter-react-hook-form/hooks"
-import { Loader2Icon } from "lucide-react"
+import { Loader2Icon, PlusIcon, XIcon } from "lucide-react"
import { useTranslations } from "next-intl"
+import { useMemo, useState } from "react"
import { toast } from "sonner"
+import { useCustomFieldStore } from "@/features/custom-fields/provider/custom-field-store-context"
+import { useTagStore } from "@/features/tags/provider/tag-store-context"
+import { getTagChipStyle } from "@/features/tags/tag-colors"
+import { useUserStore } from "@/features/users/provider/user-store-context"
import { createContactAction } from "./actions/create-contact.action"
import { createContactRequest } from "./schemas/action"
+// Pixel-perfect Respond.io 2026-05-26 (Pedro): form do drawer "Novo
+// Contato" segue ordem exata do print:
+// First Name | Last Name | Phone Number* | Email | Assignee |
+// Tags (popover) | Custom Fields (accordion)
+// Footer: Cancel (ghost) + Create (azul) — sticky no rodapé do Sheet.
export function CreateContactForm({
workspaceId,
- onSubmmited,
+ onSubmitted,
onCancelled,
}: {
workspaceId: string
- onSubmmited?: () => void
+ onSubmitted?: () => void
onCancelled?: () => void
}) {
const t = useTranslations()
+ const workspaceMembers = useUserStore((s) => s.workspaceMembers)
+ const allTags = useTagStore((s) => s.tags)
+ const customFields = useCustomFieldStore((s) => s.customFields)
+
+ const [selectedTagIds, setSelectedTagIds] = useState([])
+ const [customFieldValues, setCustomFieldValues] = useState<
+ Record
+ >({})
+ const [tagPopoverOpen, setTagPopoverOpen] = useState(false)
+
+ const assigneeOptions = useMemo(
+ () =>
+ workspaceMembers.map((m) => ({
+ value: m.user?.id ?? "",
+ label: m.user?.name ?? m.user?.email ?? "—",
+ })),
+ [workspaceMembers],
+ )
const { form, handleSubmitWithAction, resetFormAndAction } =
useHookFormAction(
@@ -31,12 +77,14 @@ export function CreateContactForm({
actionProps: {
onSuccess: () => {
resetFormAndAction()
+ setSelectedTagIds([])
+ setCustomFieldValues({})
toast.success(
t("messages.createdSuccess", {
feature: t("fields.contact.label"),
}),
)
- onSubmmited?.()
+ onSubmitted?.()
},
onError: ({ error }) => {
if (error.serverError) {
@@ -51,64 +99,189 @@ export function CreateContactForm({
email: "",
firstName: "",
lastName: "",
- gender: "unknown",
+ assigneeUserId: "",
+ tagIds: [],
+ customFields: {},
},
},
errorMapProps: {},
},
)
- const genderLabels = [
- {
- value: "male",
- label: t("fields.gender.male"),
- },
- {
- value: "female",
- label: t("fields.gender.female"),
- },
- {
- value: "unknown",
- label: t("fields.gender.unknown"),
- },
- ]
+ const toggleTag = (tagId: string) => {
+ setSelectedTagIds((prev) => {
+ const next = prev.includes(tagId)
+ ? prev.filter((id) => id !== tagId)
+ : [...prev, tagId]
+ form.setValue("tagIds", next, { shouldValidate: true })
+ return next
+ })
+ }
- return (
-
-
-
+ const updateCustomField = (customFieldId: string, value: string) => {
+ setCustomFieldValues((prev) => {
+ const next = { ...prev, [customFieldId]: value }
+ form.setValue("customFields", next, { shouldValidate: true })
+ return next
+ })
+ }
-
+ const selectedTags = allTags.filter((tag) => selectedTagIds.includes(tag.id))
-
+ return (
+
+
+ {/* Body scrollável */}
+
+
+
+
+
+
+
-
+ {/* Tags — botão + abre popover */}
+
+
+
+ {t("tags.title")}
+
+
+
+
+
+
+
+
+
+
+
+ {t("tags.emptyState")}
+
+ {allTags.map((tag) => {
+ const checked = selectedTagIds.includes(tag.id)
+ return (
+ toggleTag(tag.id)}
+ type="button"
+ >
+
+
+ {tag.emoji && (
+ {tag.emoji}
+ )}
+ {tag.name}
+
+
+ )
+ })}
+
+
+
+
+
+
+ {selectedTags.length > 0 && (
+
+ {selectedTags.map((tag) => (
+
+ {tag.emoji && {tag.emoji} }
+ {tag.name}
+ toggleTag(tag.id)}
+ type="button"
+ >
+
+
+
+ ))}
+
+ )}
+
-
+ {/* Custom Fields — accordion */}
+ {customFields.length > 0 && (
+
+
+
+ {t("customFields.title")}
+
+
+
+ {customFields.map((cf) => (
+
+
+ {cf.name}
+
+
+ updateCustomField(cf.id, e.target.value)
+ }
+ placeholder={t("customFields.addValue", {
+ field: cf.name,
+ })}
+ type="text"
+ value={customFieldValues[cf.id] ?? ""}
+ />
+
+ ))}
+
+
+
+
+ )}
+
+
-
+ {/* Footer sticky — Cancel + Create */}
+
{t("actions.cancel")}
@@ -119,7 +292,7 @@ export function CreateContactForm({
{form.formState.isSubmitting && (
)}
- {t("actions.confirm")}
+ {t("actions.create")}
diff --git a/apps/builder/src/features/contacts/provider/contact-store.ts b/apps/builder/src/features/contacts/provider/contact-store.ts
index d12537d2a..2bdae34a2 100644
--- a/apps/builder/src/features/contacts/provider/contact-store.ts
+++ b/apps/builder/src/features/contacts/provider/contact-store.ts
@@ -69,7 +69,7 @@ export const createContactStore = (props: Partial
) =>
error:
error instanceof HTTPError
? error.message
- : "Failed to fetch contacts count",
+ : "Falha ao buscar contagem de contatos",
})
} finally {
set({ loadingCounts: false })
@@ -100,7 +100,7 @@ export const createContactStore = (props: Partial) =>
error:
error instanceof HTTPError
? error.message
- : "Failed to fetch contacts count",
+ : "Falha ao buscar contagem de contatos",
})
} finally {
set({ loadingCounts: false })
diff --git a/apps/builder/src/features/contacts/queries/count-blocked-contacts.ts b/apps/builder/src/features/contacts/queries/count-blocked-contacts.ts
new file mode 100644
index 000000000..f4f8da3ad
--- /dev/null
+++ b/apps/builder/src/features/contacts/queries/count-blocked-contacts.ts
@@ -0,0 +1,30 @@
+import {
+ and,
+ count,
+ db,
+ eq,
+ isNotNull,
+ isNull,
+} from "@chatbotx.io/database/client"
+import { contactModel } from "@chatbotx.io/database/schema"
+
+// Pixel-perfect Respond.io 2026-05-25 — count de contatos bloqueados
+// pro item "Contatos bloqueados" no rodapé do InboxAreaSidebar (mostra
+// o número de contatos com `blockedAt IS NOT NULL`). Pedro pediu — print
+// do Respond.io mostra item no fim do sidebar com ícone de cadeado.
+export async function countBlockedContacts(
+ workspaceId: string,
+): Promise {
+ const [row] = await db
+ .select({ count: count() })
+ .from(contactModel)
+ .where(
+ and(
+ eq(contactModel.workspaceId, workspaceId),
+ isNotNull(contactModel.blockedAt),
+ // #17 Unmerge: exclui duplicates fundidos do count do sidebar.
+ isNull(contactModel.mergedIntoId),
+ ),
+ )
+ return row?.count ?? 0
+}
diff --git a/apps/builder/src/features/contacts/queries/get-contact.query.ts b/apps/builder/src/features/contacts/queries/get-contact.query.ts
index 421731d8a..23a8eaeb0 100644
--- a/apps/builder/src/features/contacts/queries/get-contact.query.ts
+++ b/apps/builder/src/features/contacts/queries/get-contact.query.ts
@@ -28,7 +28,7 @@ export async function getContact(
})
if (!contact) {
- throw notFoundException("Contact not found")
+ throw notFoundException("Contato não encontrado")
}
const { contactCustomFields, ...contactFields } = contact
diff --git a/apps/builder/src/features/contacts/queries/list-contact-events.query.ts b/apps/builder/src/features/contacts/queries/list-contact-events.query.ts
new file mode 100644
index 000000000..a14d6de1f
--- /dev/null
+++ b/apps/builder/src/features/contacts/queries/list-contact-events.query.ts
@@ -0,0 +1,17 @@
+"use server"
+
+import { db } from "@chatbotx.io/database/client"
+import type { ContactEventModel } from "@chatbotx.io/database/types"
+
+export async function listContactEvents(props: {
+ contactId: string
+ workspaceId: string
+ limit?: number
+}): Promise {
+ const { contactId, workspaceId, limit = 50 } = props
+ return await db.query.contactEventModel.findMany({
+ where: { contactId, workspaceId },
+ orderBy: { createdAt: "desc" },
+ limit,
+ })
+}
diff --git a/apps/builder/src/features/contacts/queries/list-contact-inboxes.queries.ts b/apps/builder/src/features/contacts/queries/list-contact-inboxes.queries.ts
index b5fd9661b..6ccd135da 100644
--- a/apps/builder/src/features/contacts/queries/list-contact-inboxes.queries.ts
+++ b/apps/builder/src/features/contacts/queries/list-contact-inboxes.queries.ts
@@ -23,7 +23,7 @@ async function parseContactFilters(
input: ListContactsRequest,
): Promise {
if (!input.channels?.length) {
- throw new Error("Channels are required")
+ throw new Error("Os canais são obrigatórios")
}
const isOmnichannel = input.channels.includes("omnichannel")
diff --git a/apps/builder/src/features/contacts/queries/list-contacts.queries.ts b/apps/builder/src/features/contacts/queries/list-contacts.queries.ts
index a7ee52959..daea94320 100644
--- a/apps/builder/src/features/contacts/queries/list-contacts.queries.ts
+++ b/apps/builder/src/features/contacts/queries/list-contacts.queries.ts
@@ -1,5 +1,11 @@
-import { db, relationsFilterToSQL } from "@chatbotx.io/database/client"
-import { contactModel } from "@chatbotx.io/database/schema"
+import {
+ and,
+ db,
+ eq,
+ isNull,
+ relationsFilterToSQL,
+} from "@chatbotx.io/database/client"
+import { contactModel, conversationModel } from "@chatbotx.io/database/schema"
import {
getPaginationWithDefaults,
parseOrderByAsObject,
@@ -11,12 +17,64 @@ import type {
ListContactsResponse,
} from "../schemas/query"
+// Drizzle relational query API não suporta filter aninhado cross-table no
+// `where` (`conversation: { assignedUserId: X }` retorna "Unknown
+// relational filter field"). Solução: pré-buscar IDs dos contatos que
+// têm a conversation no assignment desejado e fazer `{ id: { in: [...] }}`.
+async function resolveContactIdsByAssignment(
+ workspaceId: string,
+ assignedId: string,
+): Promise {
+ const baseFilter = eq(conversationModel.workspaceId, workspaceId)
+ const assignmentWhere = (() => {
+ if (assignedId === "unassigned") {
+ return and(
+ baseFilter,
+ isNull(conversationModel.assignedUserId),
+ isNull(conversationModel.assignedInboxTeamId),
+ )
+ }
+ if (assignedId.startsWith("u_")) {
+ return and(
+ baseFilter,
+ eq(conversationModel.assignedUserId, assignedId.slice(2)),
+ )
+ }
+ if (assignedId.startsWith("t_")) {
+ return and(
+ baseFilter,
+ eq(conversationModel.assignedInboxTeamId, assignedId.slice(2)),
+ )
+ }
+ return null
+ })()
+ if (!assignmentWhere) {
+ return []
+ }
+ const rows = await db
+ .select({ contactId: conversationModel.contactId })
+ .from(conversationModel)
+ .where(assignmentWhere)
+ return rows.map((r) => r.contactId)
+}
+
export async function listContacts(
input: ListContactsRequest & { workspaceId: string },
): Promise {
await assertCurrentUserCanAccessChatbot(input.workspaceId)
- const where = generateWhere(input)
+ let assignmentContactIds: string[] | undefined
+ if (input.assignedId && input.assignedId !== "all") {
+ assignmentContactIds = await resolveContactIdsByAssignment(
+ input.workspaceId,
+ input.assignedId,
+ )
+ if (assignmentContactIds.length === 0) {
+ return { data: [], pageCount: 0 }
+ }
+ }
+
+ const where = generateWhere(input, assignmentContactIds)
const pagination = getPaginationWithDefaults(input)
const orderBy = parseOrderByAsObject(contactModel, input)
@@ -28,6 +86,8 @@ export async function listContacts(
orderBy,
with: {
contactInboxes: true,
+ tags: true,
+ lifecycleStage: true,
conversation: {
with: {
assignedUser: true,
@@ -88,9 +148,15 @@ async function getTotalContactsFromStats(
}
}
-const generateWhere = (input: ListContactsRequest) => {
+const generateWhere = (
+ input: ListContactsRequest,
+ assignmentContactIds?: string[],
+) => {
const where: Record = {
workspaceId: input.workspaceId,
+ // #17 Unmerge: esconde duplicates fundidos da listagem principal.
+ // Reverter pelo unmergeContactAction limpa mergedIntoId.
+ mergedIntoId: { isNull: true },
...(input.keyword
? {
OR: [
@@ -107,5 +173,21 @@ const generateWhere = (input: ListContactsRequest) => {
Object.assign(where, applyContactFilter(input.contactFilter))
}
+ if (input.lifecycleStageIds && input.lifecycleStageIds.length > 0) {
+ where.lifecycleStageId = { in: input.lifecycleStageIds }
+ }
+
+ // Filtro "Contatos bloqueados" (sidebar) — quando blocked=true mostra só
+ // contatos com `blockedAt IS NOT NULL`; quando undefined/false, default
+ // do schema do contact (todos visíveis incluindo bloqueados).
+ if (input.blocked) {
+ where.blockedAt = { isNotNull: true }
+ }
+
+ // Caixas/Times — IDs já resolvidos via resolveContactIdsByAssignment.
+ if (assignmentContactIds && assignmentContactIds.length > 0) {
+ where.id = { in: assignmentContactIds }
+ }
+
return where
}
diff --git a/apps/builder/src/features/contacts/queries/list-merged-into.ts b/apps/builder/src/features/contacts/queries/list-merged-into.ts
new file mode 100644
index 000000000..77435974c
--- /dev/null
+++ b/apps/builder/src/features/contacts/queries/list-merged-into.ts
@@ -0,0 +1,38 @@
+"use server"
+
+import { and, db, desc, eq } from "@chatbotx.io/database/client"
+import { contactModel } from "@chatbotx.io/database/schema"
+
+// Lista os contatos que foram fundidos em um primary (#17 Unmerge).
+// Usado no banner do drawer do contato primary pra mostrar lista
+// expansível com botão "Desfazer fusão" por linha.
+export async function listContactsMergedInto(
+ workspaceId: string,
+ primaryId: string,
+) {
+ const rows = await db
+ .select({
+ id: contactModel.id,
+ firstName: contactModel.firstName,
+ lastName: contactModel.lastName,
+ fullName: contactModel.fullName,
+ email: contactModel.email,
+ phoneNumber: contactModel.phoneNumber,
+ avatar: contactModel.avatar,
+ mergedAt: contactModel.mergedAt,
+ mergedByUserId: contactModel.mergedByUserId,
+ })
+ .from(contactModel)
+ .where(
+ and(
+ eq(contactModel.workspaceId, workspaceId),
+ eq(contactModel.mergedIntoId, primaryId),
+ ),
+ )
+ .orderBy(desc(contactModel.mergedAt))
+ return rows
+}
+
+export type MergedIntoContact = Awaited<
+ ReturnType
+>[number]
diff --git a/apps/builder/src/features/contacts/schemas/action.ts b/apps/builder/src/features/contacts/schemas/action.ts
index 41d855d88..366fc3e11 100644
--- a/apps/builder/src/features/contacts/schemas/action.ts
+++ b/apps/builder/src/features/contacts/schemas/action.ts
@@ -15,7 +15,10 @@ export const createContactRequest = z.object({
email: z.union([z.literal(""), z.email().max(100)]),
firstName: z.optional(z.string().trim().max(100)),
lastName: z.optional(z.string().trim().max(100)),
- gender: genderTypes,
+ gender: z.optional(genderTypes),
+ assigneeUserId: z.optional(z.string()),
+ tagIds: z.optional(z.array(z.string())),
+ customFields: z.optional(z.record(z.string(), z.string())),
})
export type CreateContactRequest = z.infer
diff --git a/apps/builder/src/features/contacts/schemas/merge.ts b/apps/builder/src/features/contacts/schemas/merge.ts
new file mode 100644
index 000000000..d019993a9
--- /dev/null
+++ b/apps/builder/src/features/contacts/schemas/merge.ts
@@ -0,0 +1,12 @@
+import { zodBigintAsString } from "@chatbotx.io/utils"
+import { z } from "zod"
+
+// Schema separado em arquivo próprio — quirk Next 16 standalone exige que
+// `z.object()` em "use server" venha sempre importado, nunca inline.
+// Ver memory: reference_next16_standalone_use_server_quirk.md
+export const mergeContactsRequest = z.object({
+ primaryId: zodBigintAsString(),
+ duplicateIds: z.array(zodBigintAsString()).min(1).max(50),
+})
+
+export type MergeContactsRequest = z.infer
diff --git a/apps/builder/src/features/contacts/schemas/query.ts b/apps/builder/src/features/contacts/schemas/query.ts
index 77ec9d0fc..266284d9f 100644
--- a/apps/builder/src/features/contacts/schemas/query.ts
+++ b/apps/builder/src/features/contacts/schemas/query.ts
@@ -1,4 +1,8 @@
import { channelTypes } from "@chatbotx.io/database/partials"
+import {
+ createSelectSchema,
+ lifecycleStageModel,
+} from "@chatbotx.io/database/schema"
import { zodBigintAsString } from "@chatbotx.io/utils"
import z from "zod"
import { inboxTeamResource } from "@/enterprise/features/inbox-teams/schema/resource"
@@ -18,6 +22,11 @@ import {
import { contactFilterCriteriaSchema } from "./contact-filter"
import { contactResource, publicContactResource } from "./resource"
+const lifecycleStageInResource = createSelectSchema(lifecycleStageModel, {
+ id: z.string(),
+ workspaceId: z.string(),
+})
+
/** Same as contact filter payload (strict discriminated `conditions`). */
export const contactFilterSchema = contactFilterCriteriaSchema
@@ -37,6 +46,9 @@ export const listContactsRequest = basePaginationRequest.extend({
contactFilter: contactFilterCriteriaSchema.optional(),
channels: z.array(channelTypes).optional(),
inboxIds: z.array(zodBigintAsString()).optional(),
+ lifecycleStageIds: z.array(zodBigintAsString()).optional(),
+ blocked: z.coerce.boolean().optional(),
+ assignedId: z.string().optional(),
})
export type ListContactsRequest = z.infer
@@ -44,6 +56,7 @@ export const listContactsItem = contactResource.and(
z.object({
contactCustomFields: z.array(contactCustomFieldResource).optional(),
tags: z.array(tagResource).optional(),
+ lifecycleStage: lifecycleStageInResource.nullish(),
contactNotes: z.array(contactNoteResource).optional(),
contactInboxes: z.array(contactInboxResource).optional(),
conversation: conversationResource
diff --git a/apps/builder/src/features/contacts/schemas/resource.ts b/apps/builder/src/features/contacts/schemas/resource.ts
index 1c3f04528..93f3c51ce 100644
--- a/apps/builder/src/features/contacts/schemas/resource.ts
+++ b/apps/builder/src/features/contacts/schemas/resource.ts
@@ -1,4 +1,7 @@
-import type { CustomFieldType } from "@chatbotx.io/database/partials"
+import type {
+ CustomFieldType,
+ CustomFieldVisibility,
+} from "@chatbotx.io/database/partials"
import { contactModel, createSelectSchema } from "@chatbotx.io/database/schema"
import type { LucideIcon } from "lucide-react"
import { z } from "zod"
@@ -15,6 +18,11 @@ export type ContactEditableField = {
label: string
value: string | null | undefined
type: CustomFieldType
+ // Só populado pra custom fields (defaults firstName/email/phone não usam).
+ // visibility="hideWhenEmpty" + valor vazio = filtrado fora dos visíveis.
+ visibility?: CustomFieldVisibility
+ // Pra type="list" — opções pré-definidas pelo admin (multi-select).
+ options?: string[]
}
export const publicContactResource = contactResource.pick({
diff --git a/apps/builder/src/features/contacts/schemas/unmerge.ts b/apps/builder/src/features/contacts/schemas/unmerge.ts
new file mode 100644
index 000000000..0fa452061
--- /dev/null
+++ b/apps/builder/src/features/contacts/schemas/unmerge.ts
@@ -0,0 +1,10 @@
+import { zodBigintAsString } from "@chatbotx.io/utils"
+import { z } from "zod"
+
+// Schema separado por causa do Next 16 standalone "use server" quirk
+// (schemas Zod não podem ficar em files marcados "use server").
+export const unmergeContactsRequest = z.object({
+ primaryId: zodBigintAsString(),
+ duplicateIds: z.array(zodBigintAsString()).min(1).max(50),
+})
+export type UnmergeContactsRequest = z.infer
diff --git a/apps/builder/src/features/contacts/utils.ts b/apps/builder/src/features/contacts/utils.ts
index c86ecb615..b2e9a8fe3 100644
--- a/apps/builder/src/features/contacts/utils.ts
+++ b/apps/builder/src/features/contacts/utils.ts
@@ -13,3 +13,90 @@ export function useAvatarUrl(
? new URL(contact.avatar, assetUrl).toString()
: undefined
}
+
+// Pasta com 156 avatares (13 cores × 12 expressões) extraídos do CDN do
+// Respond.io. Cor + expressão escolhidas por hash determinístico do nome —
+// mesmo contato sempre mesmo avatar.
+// 2026-05-24 — Pedro pediu pra usar os avatares reais do Respond.
+const RESPOND_AVATAR_COLORS = [
+ "red-lighten-2",
+ "pink-lighten-2",
+ "purple-lighten-2",
+ "deep-purple-lighten-2",
+ "indigo-lighten-2",
+ "blue-darken-1",
+ "teal-lighten-2",
+ "light-green-darken-2",
+ "lime-darken-2",
+ "amber-lighten-1",
+ "orange-lighten-1",
+ "deep-orange-lighten-1",
+ "brown-lighten-2",
+] as const
+
+// Cor base do avatar (pra fallback quando a imagem ainda tá carregando).
+const RESPOND_AVATAR_HEX: Record = {
+ "red-lighten-2": "#E57373",
+ "pink-lighten-2": "#F06292",
+ "purple-lighten-2": "#BA68C8",
+ "deep-purple-lighten-2": "#9575CD",
+ "indigo-lighten-2": "#7986CB",
+ "blue-darken-1": "#1E88E5",
+ "teal-lighten-2": "#4DB6AC",
+ "light-green-darken-2": "#558B2F",
+ "lime-darken-2": "#9E9D24",
+ "amber-lighten-1": "#FFCA28",
+ "orange-lighten-1": "#FFA726",
+ "deep-orange-lighten-1": "#FF7043",
+ "brown-lighten-2": "#A1887F",
+}
+
+// DJB2 hash (sem bitwise — regra biome noBitwiseOperators). Produz inteiro
+// positivo determinístico do seed. Versão anterior usava `hash << 5` + `hash |= 0`.
+function hashOf(seed: string): number {
+ let hash = 5381
+ for (let i = 0; i < seed.length; i++) {
+ hash = (hash * 33 + seed.charCodeAt(i)) % 2_147_483_647
+ }
+ return hash
+}
+
+export function getRespondAvatarUrl(seed: string | null | undefined): {
+ url: string
+ color: string
+} {
+ const text = seed?.trim() || "?"
+ const hash = hashOf(text)
+ const color = RESPOND_AVATAR_COLORS[
+ hash % RESPOND_AVATAR_COLORS.length
+ ] as string
+ const number = (hash % 12) + 1 // 1..12
+ return {
+ url: `/respond-avatars/${color}/${number}.webp`,
+ color: RESPOND_AVATAR_HEX[color] ?? "#0049C7",
+ }
+}
+
+// Mantido por compatibilidade — devolve só a cor.
+export function getAvatarColor(seed: string | null | undefined): string {
+ return getRespondAvatarUrl(seed).color
+}
+
+// Regex top-level (regra biome useTopLevelRegex).
+const WHITESPACE_SPLIT = /\s+/
+
+export function getAvatarInitials(name: string | null | undefined): string {
+ if (!name) {
+ return "?"
+ }
+ const parts = name.trim().split(WHITESPACE_SPLIT).filter(Boolean)
+ if (parts.length === 0) {
+ return "?"
+ }
+ if (parts.length === 1) {
+ return (parts[0] ?? "?").slice(0, 2).toUpperCase()
+ }
+ return (
+ ((parts[0]?.[0] ?? "") + (parts.at(-1)?.[0] ?? "")).toUpperCase() || "?"
+ )
+}
diff --git a/apps/builder/src/features/conversations/actions/archive-conversation.action.ts b/apps/builder/src/features/conversations/actions/archive-conversation.action.ts
index 4b08d55ed..c47539a48 100644
--- a/apps/builder/src/features/conversations/actions/archive-conversation.action.ts
+++ b/apps/builder/src/features/conversations/actions/archive-conversation.action.ts
@@ -4,7 +4,10 @@ import { and, db, eq, inArray } from "@chatbotx.io/database/client"
import { conversationModel } from "@chatbotx.io/database/schema"
import type { UserModel } from "@chatbotx.io/database/types"
import { emit } from "@chatbotx.io/event-bus"
-import { emitConversationArchived } from "@chatbotx.io/events"
+import {
+ emitConversationArchived,
+ emitConversationClosed,
+} from "@chatbotx.io/events"
import {
type BulkUpdateIdsRequest,
bulkUpdateIdsRequest,
@@ -50,7 +53,11 @@ export const archiveConversationAction = workspaceActionClient
),
)
- // Emit conversation archived events
+ // Emit conversation archived + closed events. No ChatbotX o conceito
+ // de "fechada" = archivedAt != null (não há coluna `closedAt`). O
+ // evento `conversationClosed` alimenta o TriggerNode "Conversa Fechada"
+ // dentro de flows; `conversationArchived` continua existindo pro trigger
+ // tradicional (Settings → Gatilhos) que já existia antes.
for (const conv of conversations) {
try {
await emitConversationArchived(
@@ -62,7 +69,20 @@ export const archiveConversationAction = workspaceActionClient
} catch (error) {
logger.error(
{ err: error },
- "Failed to emit conversationArchived event:",
+ "Falha ao emitir evento conversationArchived:",
+ )
+ }
+ try {
+ await emitConversationClosed(
+ workspaceId,
+ conv.contactId,
+ conv.id,
+ "user",
+ )
+ } catch (error) {
+ logger.error(
+ { err: error },
+ "Falha ao emitir evento conversationClosed:",
)
}
}
diff --git a/apps/builder/src/features/conversations/actions/assign-conversation.action.ts b/apps/builder/src/features/conversations/actions/assign-conversation.action.ts
index e0bb05b79..28d484bf4 100644
--- a/apps/builder/src/features/conversations/actions/assign-conversation.action.ts
+++ b/apps/builder/src/features/conversations/actions/assign-conversation.action.ts
@@ -54,7 +54,7 @@ export const assignConversationAction = workspaceActionClient
if (!workspaceMember) {
returnValidationErrors(assignConversationSchema, {
assignedId: {
- _errors: ["User is not valid"],
+ _errors: ["Usuário inválido"],
},
})
}
@@ -70,7 +70,7 @@ export const assignConversationAction = workspaceActionClient
if (!inboxTeam) {
returnValidationErrors(assignConversationSchema, {
assignedId: {
- _errors: ["Inbox Team is not valid"],
+ _errors: ["Time da Caixa de Entrada inválido"],
},
})
}
@@ -119,7 +119,7 @@ export const assignConversationAction = workspaceActionClient
} catch (error) {
logger.error(
{ err: error },
- "Failed to emit conversationAssigned event:",
+ "Falha ao emitir evento conversationAssigned:",
)
}
}
diff --git a/apps/builder/src/features/conversations/actions/close-conversation-with-note.action.ts b/apps/builder/src/features/conversations/actions/close-conversation-with-note.action.ts
new file mode 100644
index 000000000..d8445d3af
--- /dev/null
+++ b/apps/builder/src/features/conversations/actions/close-conversation-with-note.action.ts
@@ -0,0 +1,186 @@
+"use server"
+
+import {
+ auditLogActions,
+ contactEventTypes,
+ logAudit,
+ recordContactEvent,
+} from "@chatbotx.io/business"
+import { ChatbotXException } from "@chatbotx.io/business/errors"
+import { and, db, eq } from "@chatbotx.io/database/client"
+import {
+ conversationClosingNoteModel,
+ conversationModel,
+} from "@chatbotx.io/database/schema"
+import { emit } from "@chatbotx.io/event-bus"
+import {
+ emitConversationArchived,
+ emitConversationClosed,
+} from "@chatbotx.io/events"
+import { createId } from "@chatbotx.io/utils"
+import { closeConversationWithNoteRequest } from "@/features/closing-notes/schemas/action"
+import { workspaceIdrequestParams } from "@/features/common/schemas"
+import { revalidateCacheTags } from "@/lib/cache-helper"
+import { logger } from "@/lib/log"
+import { workspaceActionClient } from "@/lib/safe-action"
+
+// Closing Notes Fase C (#15 — 2026-05-27). Fluxo:
+// 1. Server valida modo do workspace (mandatoryDialog exige categoryId,
+// mandatoryBoth exige categoryId + summary). Validação UI é só UX —
+// server é source-of-truth pra evitar bypass.
+// 2. Insere ConversationClosingNote (1-1 com conversa) se categoryId ou
+// summary vierem preenchidos. Modo "optional" pode pular ambos →
+// comporta-se como archive puro.
+// 3. Atualiza archivedAt da conversa.
+// 4. Emite eventos (mesma sequência de archiveConversationAction) pra
+// triggers/analytics não regredirem.
+export const closeConversationWithNoteAction = workspaceActionClient
+ .bindArgsSchemas(workspaceIdrequestParams)
+ .inputSchema(closeConversationWithNoteRequest)
+ .action(async (props) => {
+ const {
+ bindArgsParsedInputs: [workspaceId],
+ parsedInput,
+ ctx: { user, workspace },
+ } = props
+
+ const { conversationId, categoryId, summary } = parsedInput
+ const mode = workspace.closingNotesMode
+
+ if (mode === "mandatoryDialog" && !categoryId) {
+ throw new ChatbotXException("Categoria é obrigatória.")
+ }
+ if (mode === "mandatoryBoth" && !(categoryId && summary)) {
+ throw new ChatbotXException("Categoria e resumo são obrigatórios.")
+ }
+
+ const conversation = await db.query.conversationModel.findFirst({
+ where: {
+ id: conversationId,
+ workspaceId,
+ },
+ })
+ if (!conversation) {
+ throw new ChatbotXException("Conversa não encontrada.")
+ }
+
+ let categoryName: string | null = null
+ if (categoryId) {
+ const category = await db.query.conversationCategoryModel.findFirst({
+ where: {
+ id: categoryId,
+ workspaceId,
+ },
+ })
+ if (!category) {
+ throw new ChatbotXException("Categoria não encontrada.")
+ }
+ categoryName = category.name
+ }
+
+ const shouldPersistNote = Boolean(categoryId) || Boolean(summary)
+ if (shouldPersistNote) {
+ // 1-1: unique(conversationId). Se já existir nota (caso reabriu + fechou
+ // de novo), apaga a anterior e cria nova — captura intenção atual do
+ // atendente, não histórica.
+ await db
+ .delete(conversationClosingNoteModel)
+ .where(eq(conversationClosingNoteModel.conversationId, conversationId))
+
+ await db.insert(conversationClosingNoteModel).values({
+ id: createId(),
+ workspaceId,
+ conversationId,
+ categoryId: categoryId ?? null,
+ summary: summary ?? null,
+ closedByUserId: user.id,
+ })
+ }
+
+ await db
+ .update(conversationModel)
+ .set({ archivedAt: new Date() })
+ .where(
+ and(
+ eq(conversationModel.workspaceId, workspaceId),
+ eq(conversationModel.id, conversationId),
+ ),
+ )
+
+ try {
+ await emitConversationArchived(
+ workspaceId,
+ conversation.contactId,
+ conversation.id,
+ user.id,
+ )
+ } catch (error) {
+ logger.error(
+ { err: error },
+ "Falha ao emitir evento conversationArchived:",
+ )
+ }
+ try {
+ await emitConversationClosed(
+ workspaceId,
+ conversation.contactId,
+ conversation.id,
+ "user",
+ )
+ } catch (error) {
+ logger.error({ err: error }, "Falha ao emitir evento conversationClosed:")
+ }
+
+ emit("analytics:dashboard", {
+ eventType: "conversation:archived",
+ workspaceId,
+ conversationId: conversation.id,
+ channel: "webchat",
+ occurredAt: new Date(),
+ metadata: {
+ triggerContext: {
+ triggerSource: "api",
+ triggerHandler: "closeConversationWithNoteAction",
+ triggerType: "conversation_archived",
+ },
+ },
+ }).catch((error) => {
+ logger.error(
+ { err: error },
+ "[closeConversationWithNote] Failed to emit analytics",
+ )
+ })
+
+ if (shouldPersistNote) {
+ const detailParts: string[] = []
+ if (categoryName) {
+ detailParts.push(`categoria "${categoryName}"`)
+ }
+ if (summary) {
+ detailParts.push("com resumo")
+ }
+ await logAudit({
+ workspaceId,
+ userId: user.id,
+ action: auditLogActions.CONVERSATION_CLOSED_WITH_NOTE,
+ detail: `Conversa fechada (${detailParts.join(", ")})`,
+ })
+
+ // Timeline do contato (Fase D). Sem await pra não bloquear o
+ // archive — recordContactEvent já é fire-and-forget internamente.
+ recordContactEvent({
+ contactId: conversation.contactId,
+ workspaceId,
+ eventType: contactEventTypes.CONVERSATION_CLOSED_WITH_NOTE,
+ actorUserId: user.id,
+ meta: {
+ conversationId: conversation.id,
+ categoryName,
+ summary: summary ?? null,
+ closedByUserName: user.name ?? null,
+ },
+ })
+ }
+
+ revalidateCacheTags(`workspaces:${workspaceId}#conversations`)
+ })
diff --git a/apps/builder/src/features/conversations/actions/disable-bot.action.ts b/apps/builder/src/features/conversations/actions/disable-bot.action.ts
index d6641079d..66473ee6f 100644
--- a/apps/builder/src/features/conversations/actions/disable-bot.action.ts
+++ b/apps/builder/src/features/conversations/actions/disable-bot.action.ts
@@ -55,7 +55,7 @@ export const disableBotAction = workspaceActionClient
} catch (error) {
logger.error(
{ err: error },
- "Failed to emit conversationTransferredToHuman event:",
+ "Falha ao emitir evento conversationTransferredToHuman:",
)
}
}
diff --git a/apps/builder/src/features/conversations/actions/disable-live-chat-conversation.action.ts b/apps/builder/src/features/conversations/actions/disable-live-chat-conversation.action.ts
index 1bb185f10..102659dcf 100644
--- a/apps/builder/src/features/conversations/actions/disable-live-chat-conversation.action.ts
+++ b/apps/builder/src/features/conversations/actions/disable-live-chat-conversation.action.ts
@@ -53,7 +53,7 @@ export const disableLiveChatConversationAction = workspaceActionClient
} catch (error) {
logger.error(
{ err: error },
- "Failed to emit conversationTransferredToBot event:",
+ "Falha ao emitir evento conversationTransferredToBot:",
)
}
}
diff --git a/apps/builder/src/features/conversations/actions/enable-bot.action.ts b/apps/builder/src/features/conversations/actions/enable-bot.action.ts
index 48e371cad..f267f88cd 100644
--- a/apps/builder/src/features/conversations/actions/enable-bot.action.ts
+++ b/apps/builder/src/features/conversations/actions/enable-bot.action.ts
@@ -55,7 +55,7 @@ export const enableBotAction = workspaceActionClient
} catch (error) {
logger.error(
{ err: error },
- "Failed to emit conversationTransferredToBot event:",
+ "Falha ao emitir evento conversationTransferredToBot:",
)
}
}
diff --git a/apps/builder/src/features/conversations/actions/enable-live-chat-conversation.action.ts b/apps/builder/src/features/conversations/actions/enable-live-chat-conversation.action.ts
index bfd2b2a2d..2168c6503 100644
--- a/apps/builder/src/features/conversations/actions/enable-live-chat-conversation.action.ts
+++ b/apps/builder/src/features/conversations/actions/enable-live-chat-conversation.action.ts
@@ -53,7 +53,7 @@ export const enableLiveChatConversationAction = workspaceActionClient
} catch (error) {
logger.error(
{ err: error },
- "Failed to emit conversationTransferredToHuman event:",
+ "Falha ao emitir evento conversationTransferredToHuman:",
)
}
}
diff --git a/apps/builder/src/features/conversations/actions/follow-conversation.action.ts b/apps/builder/src/features/conversations/actions/follow-conversation.action.ts
index 00bc8f3cd..dcfc7a574 100644
--- a/apps/builder/src/features/conversations/actions/follow-conversation.action.ts
+++ b/apps/builder/src/features/conversations/actions/follow-conversation.action.ts
@@ -34,7 +34,7 @@ export const followConversation = async (ctx: {
})
if (!conversation) {
- throw new Error("Conversation not found")
+ throw new Error("Conversa não encontrada")
}
await db
@@ -52,7 +52,7 @@ export const followConversation = async (ctx: {
ctx.userId,
)
} catch (error) {
- logger.error({ err: error }, "Failed to emit conversationFollowUp event:")
+ logger.error({ err: error }, "Falha ao emitir evento conversationFollowUp:")
}
emit("analytics:dashboard", {
diff --git a/apps/builder/src/features/conversations/actions/mark-filter-read.action.ts b/apps/builder/src/features/conversations/actions/mark-filter-read.action.ts
new file mode 100644
index 000000000..e2129799e
--- /dev/null
+++ b/apps/builder/src/features/conversations/actions/mark-filter-read.action.ts
@@ -0,0 +1,109 @@
+"use server"
+
+import { and, db, eq, inArray, isNull } from "@chatbotx.io/database/client"
+import { contactModel, conversationModel } from "@chatbotx.io/database/schema"
+import {
+ type WorkspaceIdRequestParams,
+ workspaceIdrequestParams,
+} from "@/features/common/schemas"
+import { workspaceActionClient } from "@/lib/safe-action"
+import {
+ type MarkFilterReadRequest,
+ markFilterReadRequest,
+} from "../schema/mark-filter-read"
+
+// Marca como lidas todas as conversas ABERTAS num recorte (filtro top ou
+// lifecycle stage). Usado quando user clica num filtro da sidebar Inbox —
+// faz a bolinha "nova mensagem" sumir (doc Respond.io
+// markdown-raw/inbox/getting-started-with-inbox.md → "Ao abrir a caixa de
+// entrada, o ponto azul desaparece"). Não some até clicar — fica até nova
+// msg incoming bater contactLastReadAt.
+//
+// Conversas individuais ainda mostram unread por conversa na lista até
+// serem abertas (readConversationAction continua valendo).
+//
+// Schema plano (não discriminatedUnion) — Next 16 standalone tem quirk
+// com discriminatedUnion em "use server" files.
+export const markFilterReadAction = workspaceActionClient
+ .bindArgsSchemas(workspaceIdrequestParams)
+ .inputSchema(markFilterReadRequest)
+ .action(
+ async ({
+ bindArgsParsedInputs: [workspaceId],
+ parsedInput,
+ }: {
+ bindArgsParsedInputs: WorkspaceIdRequestParams
+ parsedInput: MarkFilterReadRequest
+ }) => {
+ const baseWhere = and(
+ eq(conversationModel.workspaceId, workspaceId),
+ isNull(conversationModel.archivedAt),
+ )
+
+ if (parsedInput.scope === "all") {
+ await db
+ .update(conversationModel)
+ .set({ agentLastReadAt: new Date() })
+ .where(baseWhere)
+ return
+ }
+ if (parsedInput.scope === "mine" && parsedInput.userId) {
+ await db
+ .update(conversationModel)
+ .set({ agentLastReadAt: new Date() })
+ .where(
+ and(
+ baseWhere,
+ eq(conversationModel.assignedUserId, parsedInput.userId),
+ ),
+ )
+ return
+ }
+ if (parsedInput.scope === "unassigned") {
+ await db
+ .update(conversationModel)
+ .set({ agentLastReadAt: new Date() })
+ .where(
+ and(
+ baseWhere,
+ isNull(conversationModel.assignedUserId),
+ isNull(conversationModel.assignedInboxTeamId),
+ ),
+ )
+ return
+ }
+ if (parsedInput.scope === "team" && parsedInput.teamId) {
+ await db
+ .update(conversationModel)
+ .set({ agentLastReadAt: new Date() })
+ .where(
+ and(
+ baseWhere,
+ eq(conversationModel.assignedInboxTeamId, parsedInput.teamId),
+ ),
+ )
+ return
+ }
+ if (parsedInput.scope === "lifecycle" && parsedInput.stageId) {
+ const contactRows = await db
+ .select({ id: contactModel.id })
+ .from(contactModel)
+ .where(
+ and(
+ eq(contactModel.workspaceId, workspaceId),
+ eq(contactModel.lifecycleStageId, parsedInput.stageId),
+ ),
+ )
+ const contactIds = contactRows.map((r) => r.id)
+ if (contactIds.length === 0) {
+ return
+ }
+ await db
+ .update(conversationModel)
+ .set({ agentLastReadAt: new Date() })
+ .where(
+ and(baseWhere, inArray(conversationModel.contactId, contactIds)),
+ )
+ }
+ },
+ )
diff --git a/apps/builder/src/features/conversations/actions/read-conversation.action.ts b/apps/builder/src/features/conversations/actions/read-conversation.action.ts
index 01ef5c65d..031057f45 100644
--- a/apps/builder/src/features/conversations/actions/read-conversation.action.ts
+++ b/apps/builder/src/features/conversations/actions/read-conversation.action.ts
@@ -2,11 +2,14 @@
import { and, db, eq } from "@chatbotx.io/database/client"
import { conversationModel } from "@chatbotx.io/database/schema"
-import { zodBigintAsString } from "@chatbotx.io/utils"
+import { workspaceIdAndIdRequestParams } from "@/features/common/schemas"
import { workspaceActionClient } from "@/lib/safe-action"
+// bindArgsSchemas usa array exportado de common/schemas (não inline) pra
+// evitar quirk Next 16 standalone. Documentado em
+// memory/reference_next16_standalone_use_server_quirk.md.
export const readConversationAction = workspaceActionClient
- .bindArgsSchemas([zodBigintAsString(), zodBigintAsString()])
+ .bindArgsSchemas(workspaceIdAndIdRequestParams)
.action(async (props) => {
const {
bindArgsParsedInputs: [workspaceId, id],
diff --git a/apps/builder/src/features/conversations/actions/send-whatsapp-template.action.ts b/apps/builder/src/features/conversations/actions/send-whatsapp-template.action.ts
new file mode 100644
index 000000000..6d3ed6a75
--- /dev/null
+++ b/apps/builder/src/features/conversations/actions/send-whatsapp-template.action.ts
@@ -0,0 +1,68 @@
+"use server"
+
+import { db } from "@chatbotx.io/database/client"
+import { ChatJobAction, chatQueue } from "@chatbotx.io/worker-config"
+import { workspaceIdAndIdRequestParams } from "@/features/common/schemas"
+import { workspaceActionClient } from "@/lib/safe-action"
+import { sendWhatsappTemplateRequest } from "../schema/send-whatsapp-template"
+
+// Envia um template HSM aprovado pra uma conversation existente.
+// Usado pelo composer do Inbox quando o agente quer enviar template manual —
+// principalmente fora da janela de 24h em que mensagem free-form é bloqueada
+// pelo Meta. Reusa o worker `sendWhatsappTemplateMessage` que já existe
+// (mesmo handler dos broadcasts).
+export const sendWhatsappTemplateAction = workspaceActionClient
+ .bindArgsSchemas(workspaceIdAndIdRequestParams)
+ .inputSchema(sendWhatsappTemplateRequest)
+ .action(async (props) => {
+ const {
+ bindArgsParsedInputs: [workspaceId, conversationId],
+ parsedInput,
+ } = props
+
+ const conversation = await db.query.conversationModel.findFirst({
+ where: { id: conversationId, workspaceId },
+ })
+ if (!conversation) {
+ throw new Error("Conversa não encontrada")
+ }
+
+ const contactInbox = await db.query.contactInboxModel.findFirst({
+ where: {
+ contactId: conversation.contactId,
+ channel: "whatsapp",
+ },
+ })
+ if (!contactInbox) {
+ throw new Error("ContactInbox WhatsApp não encontrado")
+ }
+
+ const template = await db.query.whatsappMessageTemplateModel.findFirst({
+ where: { id: parsedInput.templateId },
+ })
+ if (!template) {
+ throw new Error("Template não encontrado")
+ }
+ if (template.status !== "APPROVED") {
+ throw new Error("Template não está aprovado pela Meta")
+ }
+
+ // Constrói params do template a partir das variáveis preenchidas pelo agente.
+ // Cada variable preenche um `{{N}}` do body (N começa em 1).
+ const variables = parsedInput.variables ?? []
+ const body =
+ variables.length > 0
+ ? variables.map((text) => ({ type: "text" as const, text }))
+ : undefined
+
+ await chatQueue.add(ChatJobAction.sendWhatsappTemplateMessage, {
+ type: ChatJobAction.sendWhatsappTemplateMessage,
+ data: {
+ conversation,
+ contactInbox,
+ templateId: template.id,
+ broadcastId: "",
+ templateData: body ? { body } : undefined,
+ },
+ })
+ })
diff --git a/apps/builder/src/features/conversations/actions/unarchive-conversation.action.ts b/apps/builder/src/features/conversations/actions/unarchive-conversation.action.ts
index a48997b9b..0f5c5cd6c 100644
--- a/apps/builder/src/features/conversations/actions/unarchive-conversation.action.ts
+++ b/apps/builder/src/features/conversations/actions/unarchive-conversation.action.ts
@@ -3,6 +3,7 @@
import { and, db, eq, inArray } from "@chatbotx.io/database/client"
import { conversationModel } from "@chatbotx.io/database/schema"
import { emit } from "@chatbotx.io/event-bus"
+import { emitConversationOpened } from "@chatbotx.io/events"
import {
type BulkUpdateIdsRequest,
bulkUpdateIdsRequest,
@@ -49,6 +50,24 @@ export const unarchiveConversationAction = workspaceActionClient
)
for (const conv of conversations) {
+ // Emit conversationOpened pro TriggerNode "Conversa Aberta" (source =
+ // user — agente desarquivou manualmente).
+ const firstChannel = conv.contactInboxes[0]?.channel
+ try {
+ await emitConversationOpened(
+ workspaceId,
+ conv.contactId,
+ conv.id,
+ "user",
+ firstChannel,
+ )
+ } catch (error) {
+ logger.error(
+ { err: error },
+ "Falha ao emitir evento conversationOpened (unarchive):",
+ )
+ }
+
for (const contactInbox of conv.contactInboxes) {
emit("analytics:dashboard", {
eventType: "conversation:unarchived",
diff --git a/apps/builder/src/features/conversations/actions/unfollow-conversation.action.ts b/apps/builder/src/features/conversations/actions/unfollow-conversation.action.ts
index 11df6d0b2..11853e592 100644
--- a/apps/builder/src/features/conversations/actions/unfollow-conversation.action.ts
+++ b/apps/builder/src/features/conversations/actions/unfollow-conversation.action.ts
@@ -33,7 +33,7 @@ export const unfollowConversation = async (ctx: {
})
if (!conversation) {
- throw new Error("Conversation not found")
+ throw new Error("Conversa não encontrada")
}
await db
diff --git a/apps/builder/src/features/conversations/actions/unread-conversation.action.ts b/apps/builder/src/features/conversations/actions/unread-conversation.action.ts
index 57f0249e4..00de05c21 100644
--- a/apps/builder/src/features/conversations/actions/unread-conversation.action.ts
+++ b/apps/builder/src/features/conversations/actions/unread-conversation.action.ts
@@ -23,7 +23,7 @@ export const unreadConversation = async (ctx: {
const conversation = await findOrFail({
table: conversationModel,
where: { id: ctx.id, workspaceId: ctx.workspaceId },
- message: "Conversation not found",
+ message: "Conversa não encontrada",
})
const last2Messages = await tx.query.messageModel.findMany({
diff --git a/apps/builder/src/features/conversations/api/authenticated.ts b/apps/builder/src/features/conversations/api/authenticated.ts
index 867ab805a..1b85e0050 100644
--- a/apps/builder/src/features/conversations/api/authenticated.ts
+++ b/apps/builder/src/features/conversations/api/authenticated.ts
@@ -16,8 +16,8 @@ export const conversationsAuthenticatedAPI = {
.route({
method: "POST",
path: "/workspaces/{workspaceId}/conversations",
- summary: "List conversations by cursor pagination",
- tags: ["Conversations"],
+ summary: "Listar conversas com paginação por cursor",
+ tags: ["Conversas"],
})
.input(listConversationsRequest)
.use(workspaceAuthorizedMidddleware, (input) => input.workspaceId)
@@ -28,8 +28,8 @@ export const conversationsAuthenticatedAPI = {
.route({
method: "POST",
path: "/workspaces/{workspaceId}/conversations/list",
- summary: "List conversations by cursor pagination using POST request",
- tags: ["Conversations"],
+ summary: "Listar conversas com paginação por cursor via POST",
+ tags: ["Conversas"],
})
.input(listConversationsRequest)
.use(workspaceAuthorizedMidddleware, (input) => input.workspaceId)
@@ -40,8 +40,8 @@ export const conversationsAuthenticatedAPI = {
.route({
method: "GET",
path: "/workspaces/{workspaceId}/conversations/{id}",
- summary: "Find conversation by conversation id",
- tags: ["Conversations"],
+ summary: "Buscar conversa pelo ID",
+ tags: ["Conversas"],
})
.input(findConversationRequest)
.use(workspaceAuthorizedMidddleware, (input) => input.workspaceId)
diff --git a/apps/builder/src/features/conversations/api/workspace-token.ts b/apps/builder/src/features/conversations/api/workspace-token.ts
index ebade10b5..7c4d73ecb 100644
--- a/apps/builder/src/features/conversations/api/workspace-token.ts
+++ b/apps/builder/src/features/conversations/api/workspace-token.ts
@@ -8,8 +8,8 @@ export const conversationWorkspaceTokenAPIs = {
.route({
method: "POST",
path: "/v1/conversations",
- summary: "List conversations",
- tags: ["Conversations"],
+ summary: "Listar conversas",
+ tags: ["Conversas"],
})
.input(
listConversationsRequest.omit({
diff --git a/apps/builder/src/features/conversations/components/assign-conversation-popover.tsx b/apps/builder/src/features/conversations/components/assign-conversation-popover.tsx
new file mode 100644
index 000000000..a5ffef421
--- /dev/null
+++ b/apps/builder/src/features/conversations/components/assign-conversation-popover.tsx
@@ -0,0 +1,201 @@
+"use client"
+
+import {
+ Avatar,
+ AvatarFallback,
+ AvatarImage,
+} from "@chatbotx.io/ui/components/ui/avatar"
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+} from "@chatbotx.io/ui/components/ui/command"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@chatbotx.io/ui/components/ui/popover"
+import { CheckIcon, UserIcon, UsersRoundIcon } from "lucide-react"
+import { useTranslations } from "next-intl"
+import { useAction } from "next-safe-action/hooks"
+import { type ReactElement, useState } from "react"
+import { toast } from "sonner"
+import {
+ getAvatarInitials,
+ getRespondAvatarUrl,
+} from "@/features/contacts/utils"
+import { useUserStore } from "@/features/users/provider/user-store-context"
+import { useWorkspaceId } from "@/hooks/routing"
+import { assignConversationAction } from "../actions/assign-conversation.action"
+
+type AssignConversationPopoverProps = {
+ trigger: ReactElement
+ assignedId?: string | null
+ contactIds: string[]
+ showRemove?: boolean
+ onSuccess?: (value: string | null) => void
+}
+
+// Popover inline (não modal!) com lista searchable de agentes + equipes.
+// Substitui o `AssignConversationDialog` que abria modal centralizado —
+// Pedro pediu pixel-perfect Respond.io 2026-05-25, onde o dropdown ancora
+// no botão do header com:
+// [Search box]
+// [Eliminar atribuição]
+// ──────
+// Agentes
+// [avatar] Nome 1
+// [avatar] Nome 2
+// Equipes
+// [👥] Time A
+export function AssignConversationPopover({
+ trigger,
+ assignedId,
+ contactIds,
+ showRemove = true,
+ onSuccess,
+}: AssignConversationPopoverProps) {
+ const t = useTranslations()
+ const [open, setOpen] = useState(false)
+ const workspaceId = useWorkspaceId()
+ const { workspaceMembers, inboxTeams } = useUserStore((state) => state)
+
+ const { execute, isExecuting } = useAction(
+ assignConversationAction.bind(null, workspaceId),
+ {
+ onSuccess: () => {
+ setOpen(false)
+ },
+ onError: ({ error }) => {
+ if (error.serverError) {
+ toast.error(error.serverError)
+ }
+ },
+ },
+ )
+
+ const handleSelect = (value: string | null) => {
+ onSuccess?.(value)
+ execute({ contactIds, assignedId: value })
+ }
+
+ return (
+
+ {trigger}
+
+
+
+
+ {t("messages.noResultsFound")}
+
+ {showRemove && (
+
+ handleSelect(null)}
+ >
+
+
+
+
+ {t("actions.removeAssignee")}
+
+ {!assignedId && (
+
+ )}
+
+
+ )}
+
+ {workspaceMembers.length > 0 && (
+ <>
+
+
+ {workspaceMembers.map((member) => {
+ const user = member.user
+ if (!user) {
+ return null
+ }
+ const value = `u_${user.id}`
+ const isCurrent = value === assignedId
+ const displayName: string = user.name || "?"
+ // Iter 41: seed = user.id pra consistência.
+ const avatarSpec = getRespondAvatarUrl(
+ user.id ?? displayName,
+ )
+ const initials = getAvatarInitials(displayName) || "?"
+ return (
+ handleSelect(value)}
+ value={`${displayName} ${value}`}
+ >
+
+ {/* Iter 42: fallback = respond-avatar */}
+
+
+ {initials}
+
+
+ {displayName}
+ {isCurrent && (
+
+ )}
+
+ )
+ })}
+
+ >
+ )}
+
+ {inboxTeams.length > 0 && (
+ <>
+
+
+ {inboxTeams.map((team) => {
+ const value = `t_${team.id}`
+ const isCurrent = value === assignedId
+ return (
+ handleSelect(value)}
+ value={`${team.name} ${value}`}
+ >
+
+
+
+ {team.name}
+ {isCurrent && (
+
+ )}
+
+ )
+ })}
+
+ >
+ )}
+
+
+
+
+ )
+}
diff --git a/apps/builder/src/features/conversations/components/close-conversation-button.tsx b/apps/builder/src/features/conversations/components/close-conversation-button.tsx
new file mode 100644
index 000000000..f8f1ff49f
--- /dev/null
+++ b/apps/builder/src/features/conversations/components/close-conversation-button.tsx
@@ -0,0 +1,145 @@
+"use client"
+
+import type { ClosingNotesMode } from "@chatbotx.io/database/partials"
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import { Loader2Icon } from "lucide-react"
+import { useTranslations } from "next-intl"
+import { useAction } from "next-safe-action/hooks"
+import { useState } from "react"
+import { toast } from "sonner"
+import { RespondIcon } from "@/components/respond-icon"
+import type { ClosingNoteCategoryOption } from "@/features/closing-notes/queries/get-config"
+import { useWorkspaceId } from "@/hooks/routing"
+import { useChatStore } from "../../chat/store/chat-store-provider"
+import { archiveConversationAction } from "../actions/archive-conversation.action"
+import { unarchiveConversationAction } from "../actions/unarchive-conversation.action"
+import type { ListConversationItemResource } from "../schema/resource"
+import { CloseConversationDialog } from "./close-conversation-dialog"
+
+type CloseConversationButtonProps = {
+ conversation: ListConversationItemResource
+ closingNotesMode?: ClosingNotesMode
+ closingNoteCategories?: ClosingNoteCategoryOption[]
+}
+
+// Botão primário "Fechar"/"Reabrir" no canto direito do header da conversa,
+// igual Respond.io. Modo "disabled" mantém comportamento legado (1 clique
+// arquiva). Demais modos abrem CloseConversationDialog (pede categoria +
+// summary conforme política do workspace — #15 Fase C 2026-05-27).
+export function CloseConversationButton({
+ conversation,
+ closingNotesMode = "disabled",
+ closingNoteCategories = [],
+}: CloseConversationButtonProps) {
+ const t = useTranslations()
+ const workspaceId = useWorkspaceId()
+ const { resetState, loadMoreConversations } = useChatStore((state) => state)
+ const [dialogOpen, setDialogOpen] = useState(false)
+
+ const reload = () => {
+ resetState()
+ loadMoreConversations(workspaceId)
+ }
+
+ const { execute: archive, isExecuting: isArchiving } = useAction(
+ archiveConversationAction.bind(null, workspaceId),
+ {
+ onSuccess: reload,
+ onError: ({ error }) => {
+ if (error.serverError) {
+ toast.error(error.serverError)
+ }
+ },
+ },
+ )
+
+ const { execute: unarchive, isExecuting: isUnarchiving } = useAction(
+ unarchiveConversationAction.bind(null, workspaceId),
+ {
+ onSuccess: reload,
+ onError: ({ error }) => {
+ if (error.serverError) {
+ toast.error(error.serverError)
+ }
+ },
+ },
+ )
+
+ // Contato bloqueado não pode reabrir nem fechar (Respond.io desabilita).
+ const isBlocked = Boolean(conversation.contact?.blockedAt)
+ const isClosed = Boolean(conversation.archivedAt)
+ const isPending = isArchiving || isUnarchiving
+
+ // Pixel-perfect Respond.io 2026-05-25 — botão FECHAR/ABRIR é DISCRETO:
+ // bg transparente · border sutil rgba(182,188,195,0.16) · text #CFD3D8
+ // (text-secondary) · NÃO é verde sólido como antes. Validado via
+ // getComputedStyle no Respond.io ao vivo.
+ const sharedClasses =
+ "!h-7 shrink-0 gap-1.5 rounded-md border border-white/[0.16] bg-transparent px-3 font-semibold text-[12px] text-text-secondary uppercase tracking-wide transition-colors hover:bg-white/[0.06] hover:text-foreground"
+
+ if (isClosed) {
+ return (
+ unarchive({ ids: [conversation.id] })}
+ size="sm"
+ title={t("actions.openConversation")}
+ variant="outline"
+ >
+ {isUnarchiving ? (
+
+ ) : (
+
+ )}
+
+ {t("actions.openConversation")}
+
+
+ )
+ }
+
+ const handleClick = () => {
+ if (closingNotesMode === "disabled") {
+ archive({ ids: [conversation.id] })
+ return
+ }
+ setDialogOpen(true)
+ }
+
+ return (
+ <>
+
+ {isArchiving ? (
+
+ ) : (
+
+ )}
+
+ {t("actions.closeConversation")}
+
+
+
+ {closingNotesMode !== "disabled" && (
+
+ )}
+ >
+ )
+}
diff --git a/apps/builder/src/features/conversations/components/close-conversation-dialog.tsx b/apps/builder/src/features/conversations/components/close-conversation-dialog.tsx
new file mode 100644
index 000000000..a8641eb0d
--- /dev/null
+++ b/apps/builder/src/features/conversations/components/close-conversation-dialog.tsx
@@ -0,0 +1,204 @@
+"use client"
+
+import type { ClosingNotesMode } from "@chatbotx.io/database/partials"
+import { SelectField } from "@chatbotx.io/ui/components/form/select-field"
+import { TextareaField } from "@chatbotx.io/ui/components/form/textarea-field"
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@chatbotx.io/ui/components/ui/dialog"
+import { Form } from "@chatbotx.io/ui/components/ui/form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useHookFormAction } from "@next-safe-action/adapter-react-hook-form/hooks"
+import { Loader2Icon } from "lucide-react"
+import { useTranslations } from "next-intl"
+import { useEffect, useMemo } from "react"
+import { toast } from "sonner"
+import type { ClosingNoteCategoryOption } from "@/features/closing-notes/queries/get-config"
+import { closeConversationWithNoteAction } from "../actions/close-conversation-with-note.action"
+import { closeConversationWithNoteFormSchema } from "../schema/close-with-note"
+
+type CloseConversationDialogProps = {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ workspaceId: string
+ conversationId: string
+ mode: Exclude
+ categories: ClosingNoteCategoryOption[]
+ onSuccess?: () => void
+}
+
+// Modal de fechamento que aparece quando closingNotesMode != "disabled"
+// (mode "disabled" continua fechando direto via archiveConversationAction).
+//
+// Regras por modo:
+// optional → categoria + summary opcionais; mostra botão "Pular".
+// mandatoryDialog → categoria obrigatória; summary opcional; sem "Pular".
+// mandatoryBoth → categoria + summary obrigatórios; sem "Pular".
+export function CloseConversationDialog({
+ open,
+ onOpenChange,
+ workspaceId,
+ conversationId,
+ mode,
+ categories,
+ onSuccess,
+}: CloseConversationDialogProps) {
+ const t = useTranslations()
+
+ const categoryOptions = useMemo(
+ () =>
+ categories.map((c) => ({
+ value: c.id,
+ label: c.name,
+ })),
+ [categories],
+ )
+
+ const { form, handleSubmitWithAction, resetFormAndAction } =
+ useHookFormAction(
+ closeConversationWithNoteAction.bind(null, workspaceId),
+ zodResolver(closeConversationWithNoteFormSchema),
+ {
+ actionProps: {
+ onSuccess: () => {
+ toast.success(t("closingNotes.dialog.successToast"))
+ resetFormAndAction()
+ onOpenChange(false)
+ onSuccess?.()
+ },
+ onError: ({ error }) => {
+ if (error.serverError) {
+ toast.error(error.serverError)
+ }
+ },
+ },
+ formProps: {
+ mode: "onChange",
+ defaultValues: {
+ conversationId,
+ categoryId: undefined,
+ summary: "",
+ },
+ },
+ errorMapProps: {},
+ },
+ )
+
+ const { isSubmitting } = form.formState
+ // Validação contextual por modo (server tem espelhamento — UI só facilita).
+ const watchedCategoryId = form.watch("categoryId")
+ const watchedSummary = form.watch("summary")
+ const requiresCategory =
+ mode === "mandatoryDialog" || mode === "mandatoryBoth"
+ const requiresSummary = mode === "mandatoryBoth"
+ const meetsRequirements =
+ (!requiresCategory || Boolean(watchedCategoryId)) &&
+ (!requiresSummary || Boolean(watchedSummary?.trim()))
+
+ // Reset quando reabrir com conversationId diferente.
+ useEffect(() => {
+ if (open) {
+ form.reset({
+ conversationId,
+ categoryId: undefined,
+ summary: "",
+ })
+ }
+ }, [open, conversationId, form])
+
+ const handleSkipAndClose = () => {
+ form.setValue("categoryId", undefined)
+ form.setValue("summary", "")
+ handleSubmitWithAction()
+ }
+
+ const hasCategories = categoryOptions.length > 0
+ const showSkip = mode === "optional"
+
+ return (
+
+
+
+ {t("closingNotes.dialog.title")}
+
+ {t(`closingNotes.dialog.description.${mode}`)}
+
+
+
+
+
+ {hasCategories ? (
+
+ ) : (
+
+ {t("closingNotes.dialog.noCategories")}
+
+ )}
+
+
+
+
+
+
+ {t("actions.cancel")}
+
+
+
+ {showSkip && (
+
+ {isSubmitting && (
+
+ )}
+ {t("closingNotes.dialog.skip")}
+
+ )}
+
+
+ {isSubmitting && (
+
+ )}
+ {t("closingNotes.dialog.confirm")}
+
+
+
+
+
+
+ )
+}
diff --git a/apps/builder/src/features/conversations/components/conversation-search-bar.tsx b/apps/builder/src/features/conversations/components/conversation-search-bar.tsx
new file mode 100644
index 000000000..1702bbb9c
--- /dev/null
+++ b/apps/builder/src/features/conversations/components/conversation-search-bar.tsx
@@ -0,0 +1,157 @@
+"use client"
+
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import { Input } from "@chatbotx.io/ui/components/ui/input"
+import { cn } from "@chatbotx.io/ui/lib/utils"
+import { Loader2Icon } from "lucide-react"
+import { useTranslations } from "next-intl"
+import { useAction } from "next-safe-action/hooks"
+import { useEffect, useRef, useState } from "react"
+import { RespondIcon } from "@/components/respond-icon"
+import { searchMessagesInConversationAction } from "@/features/messages/actions/search-messages-in-conversation.action"
+import type { MessageResourceWithRelations } from "@/features/messages/schema/resource"
+import { useWorkspaceId } from "@/hooks/routing"
+
+type ConversationSearchBarProps = {
+ conversationId: string
+ // Notifica o resultado ativo pro pai (ex: pra scrollar até a mensagem na
+ // lista). Por enquanto MessageList ainda não consome — fica preparado.
+ onActiveResultChange?: (message: MessageResourceWithRelations | null) => void
+}
+
+// Barra de busca dentro da conversa atual (Sprint Inbox 1.2). Estilo
+// Respond.io: aparece abaixo do header quando a lupa é clicada. Digita
+// keyword (debounce 350ms), mostra contagem de resultados e setas ↑↓ pra
+// navegar. ESC fecha a barra. Click no resultado destaca a mensagem (futuro:
+// scroll-to-message via Virtuoso index).
+export function ConversationSearchBar({
+ conversationId,
+ onActiveResultChange,
+}: ConversationSearchBarProps) {
+ const t = useTranslations()
+ const workspaceId = useWorkspaceId()
+
+ const [keyword, setKeyword] = useState("")
+ const [results, setResults] = useState([])
+ const [activeIndex, setActiveIndex] = useState(0)
+ const inputRef = useRef(null)
+
+ const { execute, isExecuting } = useAction(
+ searchMessagesInConversationAction.bind(null, workspaceId),
+ {
+ onSuccess: ({ data }) => {
+ const next = data?.results ?? []
+ setResults(next)
+ setActiveIndex(0)
+ onActiveResultChange?.(next[0] ?? null)
+ },
+ onError: () => {
+ setResults([])
+ onActiveResultChange?.(null)
+ },
+ },
+ )
+
+ // Foca o input ao montar (barra acabou de abrir).
+ useEffect(() => {
+ inputRef.current?.focus()
+ }, [])
+
+ // Debounce de 350ms na keyword pra não disparar action a cada tecla.
+ useEffect(() => {
+ const trimmed = keyword.trim()
+ if (!trimmed) {
+ setResults([])
+ onActiveResultChange?.(null)
+ return
+ }
+ const handle = setTimeout(() => {
+ execute({ conversationId, keyword: trimmed })
+ }, 350)
+ return () => clearTimeout(handle)
+ // execute é estável (next-safe-action); onActiveResultChange é opcional
+ }, [keyword, conversationId, onActiveResultChange, execute])
+
+ function navigate(delta: number) {
+ if (results.length === 0) {
+ return
+ }
+ const next = (activeIndex + delta + results.length) % results.length
+ setActiveIndex(next)
+ onActiveResultChange?.(results[next] ?? null)
+ }
+
+ function handleKeyDown(e: React.KeyboardEvent) {
+ if (e.key === "Enter") {
+ e.preventDefault()
+ navigate(e.shiftKey ? -1 : 1)
+ }
+ if (e.key === "Escape") {
+ setKeyword("")
+ }
+ }
+
+ const hasKeyword = Boolean(keyword.trim())
+
+ return (
+
+
+ setKeyword(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder={t("conversations.searchInConversationPlaceholder")}
+ ref={inputRef}
+ value={keyword}
+ />
+ {isExecuting && (
+
+ )}
+ {hasKeyword && !isExecuting && (
+
+ {results.length === 0
+ ? t("conversations.searchNoResults")
+ : `${activeIndex + 1}/${results.length}`}
+
+ )}
+ navigate(-1)}
+ size="icon"
+ variant="ghost"
+ >
+
+
+ navigate(1)}
+ size="icon"
+ variant="ghost"
+ >
+
+
+ setKeyword("")}
+ size="icon"
+ variant="ghost"
+ >
+
+
+
+ )
+}
diff --git a/apps/builder/src/features/conversations/components/template-picker-sheet.tsx b/apps/builder/src/features/conversations/components/template-picker-sheet.tsx
new file mode 100644
index 000000000..08d5fa390
--- /dev/null
+++ b/apps/builder/src/features/conversations/components/template-picker-sheet.tsx
@@ -0,0 +1,305 @@
+"use client"
+
+import { Badge } from "@chatbotx.io/ui/components/ui/badge"
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import { Input } from "@chatbotx.io/ui/components/ui/input"
+import { Label } from "@chatbotx.io/ui/components/ui/label"
+import { ScrollArea } from "@chatbotx.io/ui/components/ui/scroll-area"
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+} from "@chatbotx.io/ui/components/ui/sheet"
+import { useTranslations } from "next-intl"
+import { useAction } from "next-safe-action/hooks"
+import { useEffect, useMemo, useState } from "react"
+import { toast } from "sonner"
+import { client } from "@/lib/orpc/orpc"
+import { sendWhatsappTemplateAction } from "../actions/send-whatsapp-template.action"
+
+type Template = {
+ id: string
+ name: string
+ language: string
+ category: string
+ status: string
+ components: unknown
+}
+
+type TemplatePickerSheetProps = {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ workspaceId: string
+ conversationId: string
+}
+
+// Regex top-level (regra biome useTopLevelRegex).
+const VARIABLE_RE = /\{\{(\d+)\}\}/g
+
+// Conta placeholders `{{1}}` `{{2}}` no body do template.
+// Retorna o maior índice → número de campos pra renderizar no form.
+function countBodyVariables(components: unknown): number {
+ if (!Array.isArray(components)) {
+ return 0
+ }
+ const body = (components as Array<{ type?: string; text?: string }>).find(
+ (c) => c?.type === "BODY",
+ )
+ if (!body?.text) {
+ return 0
+ }
+ let max = 0
+ for (const match of body.text.matchAll(VARIABLE_RE)) {
+ const idx = Number.parseInt(match[1] ?? "0", 10)
+ if (idx > max) {
+ max = idx
+ }
+ }
+ return max
+}
+
+// Extrai texto do body pra preview (substitui {{N}} pelos valores ou
+// mantém o placeholder se vazio).
+function previewBody(components: unknown, variables: string[]): string {
+ if (!Array.isArray(components)) {
+ return ""
+ }
+ const body = (components as Array<{ type?: string; text?: string }>).find(
+ (c) => c?.type === "BODY",
+ )
+ if (!body?.text) {
+ return ""
+ }
+ return body.text.replace(VARIABLE_RE, (full, idxRaw: string) => {
+ const idx = Number.parseInt(idxRaw, 10) - 1
+ return variables[idx]?.trim() || full
+ })
+}
+
+const CATEGORY_BADGE_CLASS: Record = {
+ MARKETING: "bg-violet-500/15 text-violet-400 border-violet-500/30",
+ UTILITY: "bg-sky-500/15 text-sky-400 border-sky-500/30",
+ AUTHENTICATION: "bg-amber-500/15 text-amber-400 border-amber-500/30",
+}
+
+export function TemplatePickerSheet({
+ open,
+ onOpenChange,
+ workspaceId,
+ conversationId,
+}: TemplatePickerSheetProps) {
+ const t = useTranslations()
+ const [templates, setTemplates] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [selectedTemplateId, setSelectedTemplateId] = useState(
+ null,
+ )
+ const [variables, setVariables] = useState([])
+
+ // Reset state quando sheet abre/fecha.
+ useEffect(() => {
+ if (open) {
+ setSelectedTemplateId(null)
+ setVariables([])
+ setLoading(true)
+ client.whatsappMessageTemplateAPIs
+ .listWhatsappMessageTemplatesInternalAPI({
+ workspaceId,
+ status: "APPROVED",
+ })
+ .then((res: unknown) => {
+ setTemplates(res as Template[])
+ })
+ .catch((err: { message?: string }) => {
+ toast.error(err.message ?? "Erro ao listar templates")
+ })
+ .finally(() => {
+ setLoading(false)
+ })
+ }
+ }, [open, workspaceId])
+
+ const selectedTemplate = useMemo(
+ () => templates.find((tpl) => tpl.id === selectedTemplateId) ?? null,
+ [templates, selectedTemplateId],
+ )
+
+ const varCount = selectedTemplate
+ ? countBodyVariables(selectedTemplate.components)
+ : 0
+
+ // Garante que o array `variables` cresce/encolhe conforme template muda.
+ useEffect(() => {
+ if (varCount === variables.length) {
+ return
+ }
+ if (varCount > variables.length) {
+ setVariables([
+ ...variables,
+ ...Array.from({ length: varCount - variables.length }, () => ""),
+ ])
+ } else {
+ setVariables(variables.slice(0, varCount))
+ }
+ }, [varCount, variables])
+
+ const { execute, isPending } = useAction(
+ sendWhatsappTemplateAction.bind(null, workspaceId, conversationId),
+ {
+ onSuccess: () => {
+ toast.success(t("whatsapp.templatePicker.sent") ?? "Template enviado")
+ onOpenChange(false)
+ },
+ onError: ({ error }) => {
+ toast.error(error.serverError || "Erro ao enviar template")
+ },
+ },
+ )
+
+ const allVariablesFilled = variables.every((v) => v.trim().length > 0)
+ const canSubmit = Boolean(selectedTemplate) && allVariablesFilled
+
+ function handleSend() {
+ if (!selectedTemplate) {
+ return
+ }
+ execute({
+ templateId: selectedTemplate.id,
+ variables: varCount > 0 ? variables : undefined,
+ })
+ }
+
+ return (
+
+
+
+
+ {t("whatsapp.templatePicker.title") ?? "Enviar template"}
+
+
+
+
+
+ {loading && (
+
+ {t("messages.loading")}
+
+ )}
+
+ {!loading && templates.length === 0 && (
+
+ {t("whatsapp.templatePicker.empty") ??
+ "Nenhum template aprovado disponível."}
+
+ )}
+
+ {!loading &&
+ templates.map((tpl) => {
+ const isSelected = tpl.id === selectedTemplateId
+ return (
+
setSelectedTemplateId(tpl.id)}
+ type="button"
+ >
+
+
+ {tpl.name}
+
+
+ {tpl.category}
+
+
+
+ {tpl.language}
+ ·
+ APPROVED
+
+
+ {previewBody(tpl.components, []).slice(0, 200)}
+
+
+ )
+ })}
+
+
+ {selectedTemplate && varCount > 0 && (
+
+
+ {t("whatsapp.templatePicker.variables") ?? "Variáveis"}
+
+
+ {variables.map((value, idx) => (
+
+
+ {`{{${idx + 1}}}`}
+
+ {
+ const next = [...variables]
+ next[idx] = e.target.value
+ setVariables(next)
+ }}
+ placeholder={`Valor para {{${idx + 1}}}`}
+ value={value}
+ />
+
+ ))}
+
+
+
+ {t("whatsapp.templatePicker.preview") ?? "Preview"}
+
+
+ {previewBody(selectedTemplate.components, variables)}
+
+
+
+ )}
+
+
+
+ onOpenChange(false)}
+ type="button"
+ variant="ghost"
+ >
+ {t("actions.cancel")}
+
+
+ {isPending
+ ? (t("messages.sending") ?? "Enviando...")
+ : (t("actions.send") ?? "Enviar")}
+
+
+
+
+ )
+}
diff --git a/apps/builder/src/features/conversations/components/update-conversation-assignee.tsx b/apps/builder/src/features/conversations/components/update-conversation-assignee.tsx
index 5ef617ea8..69d2d9ea6 100644
--- a/apps/builder/src/features/conversations/components/update-conversation-assignee.tsx
+++ b/apps/builder/src/features/conversations/components/update-conversation-assignee.tsx
@@ -1,18 +1,33 @@
"use client"
-import { ChevronDownIcon } from "lucide-react"
+import {
+ Avatar,
+ AvatarFallback,
+ AvatarImage,
+} from "@chatbotx.io/ui/components/ui/avatar"
+import { UserIcon, UsersRoundIcon } from "lucide-react"
import { useTranslations } from "next-intl"
import { useCallback, useEffect, useMemo, useState } from "react"
+import { RespondIcon } from "@/components/respond-icon"
import { authClient } from "@/lib/auth/auth-client"
+import { getAvatarInitials, getRespondAvatarUrl } from "../../contacts/utils"
import { useContactAssigneeOptions } from "../../users/provider/user-hook"
-import type { ConversationResource } from "../schema/resource"
-import AssignConversationDialog from "./assign-conversation-dialog"
+import type { ListConversationItemResource } from "../schema/resource"
+import { AssignConversationPopover } from "./assign-conversation-popover"
type UpdateConversationAssigneeProps = {
- conversation: ConversationResource
+ conversation: ListConversationItemResource
onChange: (user: string | null) => void
}
+// Botão do topbar da conversa que mostra o agente atribuído + abre dialog
+// pra atribuir/reatribuir. Pixel-perfect Respond.io 2026-05-25:
+// [AVATAR PEQUENO] NOME ⌄
+//
+// - Atribuído a USER → avatar real (image OU iniciais coloridas por hash do nome)
+// - Atribuído a TEAM → ícone UsersRound em círculo neutro
+// - NÃO atribuído → avatar vermelho/bordô com silhueta humana (igual mini
+// avatar do ConversationItem)
export function UpdateConversationAssignee({
conversation,
onChange,
@@ -31,20 +46,47 @@ export function UpdateConversationAssignee({
[onChange],
)
- const agentLabel = useMemo(() => {
- if (selectedId) {
- if (selectedId === `u_${session?.user.id}`) {
- return t("assignAdmin.assignedToMe")
- }
- const selected = options.find((option) => option.value === selectedId)
- if (selected) {
- return t("assignAdmin.assignedTo", {
- name: selected.label,
- })
+ // Resolve o "estado" pra renderização: tipo + label + avatar.
+ // Prioriza dados do `conversation` (que já vêm com assignedUser populated
+ // do listConversations) e cai pra `options` se faltar.
+ const resolved = useMemo(() => {
+ // Atribuído a USER
+ if (conversation.assignedUserId) {
+ const user = conversation.assignedUser
+ const isMe = conversation.assignedUserId === session?.user.id
+ const fromOptions = options.find(
+ (o) => o.value === `u_${conversation.assignedUserId}`,
+ )?.label
+ const userName: string = user?.name || user?.email || fromOptions || ""
+ const displayName: string = isMe
+ ? t("assignAdmin.assignedToMe")
+ : userName || t("assignAdmin.assignedToMe")
+ // Iter 41: seed = user.id pra consistência.
+ // String(...) defensivo: assignedUserId é bigintAsString customType,
+ // drizzle-zod infere como {} sem override explícito.
+ const seed: string = String(conversation.assignedUserId ?? userName)
+ const avatarSpec = getRespondAvatarUrl(seed)
+ return {
+ kind: "user" as const,
+ label: displayName,
+ image: user?.image ?? null,
+ initials: getAvatarInitials(userName || "?") || "?",
+ color: avatarSpec.color,
+ seedUrl: avatarSpec.url,
}
}
- return t("assignAdmin.assignConversation")
- }, [options, selectedId, t, session])
+ // Atribuído a TEAM
+ if (conversation.assignedInboxTeamId) {
+ const teamName =
+ conversation.assignedInboxTeam?.name ||
+ options.find((o) => o.value === `t_${conversation.assignedInboxTeamId}`)
+ ?.label ||
+ "Team"
+ return { kind: "team" as const, label: teamName }
+ }
+ // Não atribuído
+ return { kind: "unassigned" as const, label: t("assignAdmin.unAssigned") }
+ }, [conversation, options, session, t])
useEffect(() => {
if (conversation.assignedUserId) {
@@ -57,18 +99,49 @@ export function UpdateConversationAssignee({
}, [conversation.assignedUserId, conversation.assignedInboxTeamId])
return (
-
-
- {agentLabel}
-
-
-
+
+ {resolved.kind === "user" && (
+
+ {/* Iter 42 (Pedro): src de fallback = respond-avatar gerado
+ do user.id. Antes "" → caía pra iniciais "P1". */}
+
+
+ {resolved.initials}
+
+
+ )}
+ {resolved.kind === "team" && (
+
+
+
+ )}
+ {resolved.kind === "unassigned" && (
+
+
+
+ )}
+ {resolved.label}
+
+
}
/>
)
diff --git a/apps/builder/src/features/conversations/conversation-action.tsx b/apps/builder/src/features/conversations/conversation-action.tsx
index 24ac8b1dd..e38228d7d 100644
--- a/apps/builder/src/features/conversations/conversation-action.tsx
+++ b/apps/builder/src/features/conversations/conversation-action.tsx
@@ -7,27 +7,16 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@chatbotx.io/ui/components/ui/dropdown-menu"
-import {
- ArchiveIcon,
- ArchiveXIcon,
- EllipsisVerticalIcon,
- MailIcon,
- StarIcon,
- StarOffIcon,
- TrashIcon,
- UserLockIcon,
-} from "lucide-react"
import { useTranslations } from "next-intl"
import { useAction } from "next-safe-action/hooks"
import { toast } from "sonner"
+import { RespondIcon } from "@/components/respond-icon"
import { useWorkspaceId } from "@/hooks/routing"
import { useChatStore } from "../chat/store/chat-store-provider"
import { blockContactAction } from "../contacts/actions/block-contact.action"
import { unblockContactAction } from "../contacts/actions/unblock-contact.action"
import DeleteContactDialog from "../contacts/components/remove-contact-dialog"
-import { archiveConversationAction } from "./actions/archive-conversation.action"
import { followConversationAction } from "./actions/follow-conversation.action"
-import { unarchiveConversationAction } from "./actions/unarchive-conversation.action"
import { unfollowConversationAction } from "./actions/unfollow-conversation.action"
import { unreadConversationAction } from "./actions/unread-conversation.action"
import type { ListConversationItemResource } from "./schema/resource"
@@ -43,6 +32,7 @@ export function ConversationAction({ conversation }: ConversationActionProps) {
const {
deleteConversation,
updateConversation,
+ setActiveConversationId,
resetState: resetConversationState,
loadMoreConversations,
} = useChatStore((state) => state)
@@ -84,9 +74,21 @@ export function ConversationAction({ conversation }: ConversationActionProps) {
unreadConversationAction.bind(null, workspaceId, conversation.id),
{
onSuccess: (result) => {
+ // Pedro pediu 2026-05-25 — comportamento "marcar como não lido"
+ // igual WhatsApp:
+ // (1) backend retrocede `agentLastReadAt` pro penúltimo incoming
+ // (2) frontend força `unreadCount = 1` na lista de conversas
+ // (pill azul aparece imediatamente no card SEM esperar polling)
+ // (3) **SAI da conversa** (`setActiveConversationId(null)`) — senão
+ // o `useEffect` do ConversationItem dispara `read()` ao reabrir
+ // a conversa ativa e remarca como lida, escondendo a pill.
+ const newReadAt = result.data?.agentLastReadAt
updateConversation(conversation.id, {
- agentLastReadAt: new Date(result.data?.agentLastReadAt ?? new Date()),
+ agentLastReadAt: newReadAt ? new Date(newReadAt) : null,
+ unreadCount: 1,
})
+ setActiveConversationId(null)
+ toast.success(t("actions.markAsUnread"))
},
onError: ({ error }) => {
if (error.serverError) {
@@ -96,45 +98,8 @@ export function ConversationAction({ conversation }: ConversationActionProps) {
},
)
- const { execute: archiveFn, isExecuting: isArchiving } = useAction(
- archiveConversationAction.bind(null, workspaceId),
- {
- onSuccess: () => {
- // updateConversation(conversation.id, {
- // archivedAt: new Date(),
- // })
-
- // Reload conversation list
- resetConversationState()
- loadMoreConversations(workspaceId)
- },
- onError: ({ error }) => {
- if (error.serverError) {
- toast.error(error.serverError)
- }
- },
- },
- )
-
- const { execute: unarchiveFn, isExecuting: isUnarchiving } = useAction(
- unarchiveConversationAction.bind(null, workspaceId),
- {
- onSuccess: () => {
- // updateConversation(conversation.id, {
- // archivedAt: null,
- // })
-
- // Reload conversation list
- resetConversationState()
- loadMoreConversations(workspaceId)
- },
- onError: ({ error }) => {
- if (error.serverError) {
- toast.error(error.serverError)
- }
- },
- },
- )
+ // Arquivar/desarquivar saiu daqui. Agora é botão primário no header
+ // (CloseConversationButton em message-head.tsx), igual Respond.io.
const { execute: blockContactFn, isExecuting: isBlockingContact } = useAction(
blockContactAction.bind(null, workspaceId, conversation.contactId),
@@ -179,8 +144,8 @@ export function ConversationAction({ conversation }: ConversationActionProps) {
return (
-
-
+
+
@@ -189,7 +154,7 @@ export function ConversationAction({ conversation }: ConversationActionProps) {
disabled={isRemovingFollowUp}
onSelect={() => removeFollowUpFn()}
>
-
+
{t("actions.removeFromFollowUp")}
) : (
@@ -197,7 +162,11 @@ export function ConversationAction({ conversation }: ConversationActionProps) {
disabled={isFollowingUp}
onSelect={() => followUpFn()}
>
-
+
{t("actions.markAsFollowUp")}
)}
@@ -205,29 +174,12 @@ export function ConversationAction({ conversation }: ConversationActionProps) {
disabled={isMarkingUnread}
onSelect={() => unreadFn()}
>
-
+
{t("actions.markAsUnread")}
- {conversation.archivedAt ? (
- unarchiveFn({ ids: [conversation.id] })}
- >
-
- {t("actions.unarchive")}
-
- ) : (
- archiveFn({ ids: [conversation.id] })}
- >
-
- {t("actions.archive")}
-
- )}
{conversation.contact?.blockedAt ? (
unblockContactFn()}>
-
+
{t("actions.unblockContact")}
) : (
@@ -235,7 +187,7 @@ export function ConversationAction({ conversation }: ConversationActionProps) {
disabled={isBlockingContact}
onSelect={() => blockContactFn()}
>
-
+
{t("actions.blockContact")}
)}
@@ -247,10 +199,22 @@ export function ConversationAction({ conversation }: ConversationActionProps) {
}}
trigger={
e.preventDefault()}>
-
+
{t("actions.deleteContact")}
}
+ usageCounts={{
+ // contactResource (no Inbox) não carrega tags — fora do escopo
+ // deste delete dialog. Confirmação numérica considera só a
+ // conversa ativa (sempre 1 aqui). Pra contagem real de tags,
+ // teria que ampliar contactResource ou fazer fetch on-open.
+ tags: 0,
+ conversations: 1,
+ }}
/>
diff --git a/apps/builder/src/features/conversations/conversation-display-filter.tsx b/apps/builder/src/features/conversations/conversation-display-filter.tsx
new file mode 100644
index 000000000..b262967d6
--- /dev/null
+++ b/apps/builder/src/features/conversations/conversation-display-filter.tsx
@@ -0,0 +1,157 @@
+"use client"
+
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@chatbotx.io/ui/components/ui/popover"
+import { Separator } from "@chatbotx.io/ui/components/ui/separator"
+import { cn } from "@chatbotx.io/ui/lib/utils"
+import { ChevronDownIcon } from "lucide-react"
+import { useTranslations } from "next-intl"
+import { useState } from "react"
+import { useChatStore } from "@/features/chat/store/chat-store-provider"
+
+type ArchiveFilter = "all" | "open" | "closed"
+type SortOrder = "desc" | "asc"
+
+type ConversationDisplayFilterProps = {
+ workspaceId: string
+ onOpenAdvancedFilter?: () => void
+}
+
+export function ConversationDisplayFilter({
+ workspaceId,
+ onOpenAdvancedFilter,
+}: ConversationDisplayFilterProps) {
+ const t = useTranslations()
+ const [open, setOpen] = useState(false)
+ const filters = useChatStore((state) => state.filters)
+ const setFilters = useChatStore((state) => state.setFilters)
+ const resetState = useChatStore((state) => state.resetState)
+ const loadMoreConversations = useChatStore(
+ (state) => state.loadMoreConversations,
+ )
+
+ const archiveFilter: ArchiveFilter = filters.archiveFilter ?? "open"
+ const sortOrder: SortOrder = filters.sortOrder ?? "desc"
+
+ const applyChange = (next: Partial) => {
+ setFilters({ ...filters, ...next })
+ resetState()
+ loadMoreConversations(workspaceId)
+ }
+
+ const archiveLabel: Record = {
+ all: t("inboxDisplayFilter.show.all"),
+ open: t("inboxDisplayFilter.show.open"),
+ closed: t("inboxDisplayFilter.show.closed"),
+ }
+
+ const sortLabel: Record = {
+ desc: t("inboxDisplayFilter.sort.recent"),
+ asc: t("inboxDisplayFilter.sort.oldest"),
+ }
+
+ const archiveOptions: { value: ArchiveFilter; label: string }[] = [
+ { value: "all", label: archiveLabel.all },
+ { value: "open", label: archiveLabel.open },
+ { value: "closed", label: archiveLabel.closed },
+ ]
+
+ const sortOptions: { value: SortOrder; label: string }[] = [
+ { value: "desc", label: sortLabel.desc },
+ { value: "asc", label: sortLabel.asc },
+ ]
+
+ return (
+
+
+
+
+ {archiveLabel[archiveFilter]}, {sortLabel[sortOrder]}
+
+
+
+
+
+
+
+
+ {t("inboxDisplayFilter.showLabel")}
+
+ {archiveOptions.map((option) => (
+ applyChange({ archiveFilter: option.value })}
+ />
+ ))}
+
+
+
+
+
+
+ {t("inboxDisplayFilter.sortLabel")}
+
+ {sortOptions.map((option) => (
+ applyChange({ sortOrder: option.value })}
+ />
+ ))}
+
+
+ {onOpenAdvancedFilter && (
+ <>
+
+
{
+ setOpen(false)
+ onOpenAdvancedFilter()
+ }}
+ type="button"
+ >
+ {t("inboxDisplayFilter.advanced")}
+
+ >
+ )}
+
+
+
+ )
+}
+
+function RadioRow({
+ checked,
+ label,
+ onClick,
+}: {
+ checked: boolean
+ label: string
+ onClick: () => void
+}) {
+ return (
+
+
+ {checked && }
+
+ {label}
+
+ )
+}
diff --git a/apps/builder/src/features/conversations/conversation-item.tsx b/apps/builder/src/features/conversations/conversation-item.tsx
index bac96f20f..8110af44a 100644
--- a/apps/builder/src/features/conversations/conversation-item.tsx
+++ b/apps/builder/src/features/conversations/conversation-item.tsx
@@ -1,6 +1,6 @@
"use client"
-import type { ChannelType } from "@chatbotx.io/database/partials"
+import type { LifecycleStageModel } from "@chatbotx.io/database/types"
import {
Avatar,
AvatarFallback,
@@ -13,57 +13,150 @@ import {
TooltipTrigger,
} from "@chatbotx.io/ui/components/ui/tooltip"
import { cn } from "@chatbotx.io/ui/lib/utils"
-import { formatDistanceToNowStrict, isAfter } from "date-fns"
-import { StarIcon, UsersRoundIcon } from "lucide-react"
+import { format, isToday, isYesterday } from "date-fns"
+import {
+ ArrowDownLeftIcon,
+ ArrowUpRightIcon,
+ UserIcon,
+ UsersRoundIcon,
+} from "lucide-react"
+import { useTranslations } from "next-intl"
import { useAction } from "next-safe-action/hooks"
import { useEffect, useMemo } from "react"
import { toast } from "sonner"
+import { RespondIcon } from "@/components/respond-icon"
import { useChatStore } from "../chat/store/chat-store-provider"
-import { useAvatarUrl } from "../contacts/utils"
-import { InboxIcon } from "../inboxes/components/inbox-icon"
+import {
+ getAvatarInitials,
+ getRespondAvatarUrl,
+ useAvatarUrl,
+} from "../contacts/utils"
+import { LifecycleStagePill } from "../lifecycle-stages/lifecycle-stage-pill"
import { readConversationAction } from "./actions/read-conversation.action"
import type { ListConversationItemResource } from "./schema/resource"
type ConversationItemProps = {
conversation: ListConversationItemResource
+ lifecycleStages?: LifecycleStageModel[]
onSelect: () => void
}
-const assignedIcon = (conversation: ListConversationItemResource) => {
- if (conversation.assignedUserId) {
+// Mini-avatar 16 px do atendente atribuído (canto direito da linha 3 do
+// card). Pixel-perfect Respond.io (doc inbox/assigning-and-closing-a-conversation.md
+// + imagem ae86d99a8a8cedde.jpg):
+//
+// - Atribuído a USER → avatar com foto (user.image) OU fallback colorido
+// com iniciais (cor consistente via getRespondAvatarUrl-hash do nome)
+// - Atribuído a TEAM → círculo neutro com ícone UsersRound
+// - NÃO atribuído → círculo VERMELHO/BORDÔ com silhueta humana branca
+// (Pedro pediu explicitamente 2026-05-25 — fica visível em todos os
+// cards sem agente, sinal claro de "ninguém pegou ainda")
+function AssignedAvatar({
+ conversation,
+ unassignedLabel,
+}: {
+ conversation: ListConversationItemResource
+ unassignedLabel: string
+}) {
+ // 1. Atribuído a um agente
+ if (conversation.assignedUserId && conversation.assignedUser) {
+ const user = conversation.assignedUser
+ const displayName = user.name || user.email || ""
+ // Iter 41: seed = user.id (consistente em todos os lugares).
+ const avatarSpec = getRespondAvatarUrl(user.id ?? displayName)
+ const initials = getAvatarInitials(displayName) || "?"
return (
-
-
-
-
- {conversation.assignedUser?.name?.slice(0, 2) ?? " "}
+
+ {/* Iter 42 (Pedro): src de fallback = respond-avatar gerado
+ pelo id. Antes ficava "" quando user.image=null → caía
+ pra iniciais. Agora mesmo sem user.image mostra avatar
+ consistente com o resto do app. */}
+
+
+ {initials}
- {conversation.assignedUser?.name ||
- conversation.assignedUser?.email ||
- "User"}
+ {displayName || "User"}
)
}
+
+ // 2. Atribuído a uma equipe
if (conversation.assignedInboxTeamId) {
+ const teamName = conversation.assignedInboxTeam?.name || "Team"
return (
-
-
-
+
+
+
+
+
+
+
+ {teamName}
+
+
)
}
- return
+
+ // 3. Não atribuído → placeholder vermelho/bordô
+ return (
+
+
+
+
+
+
+
+ {unassignedLabel}
+
+
+ )
+}
+
+// Formato pixel-perfect Respond.io: "7:19 PM" se hoje (12h com AM/PM —
+// validado no Respond ao vivo 2026-05-24, Pedro pediu igual), "Ontem" se
+// ontem, dia da semana se semana, "dd/MM/yy" antigo. Cor #97A0AA (12/16).
+function formatConversationTimestamp(
+ date: Date | string,
+ _todayLabel: string,
+ yesterdayLabel: string,
+): string {
+ const d = typeof date === "string" ? new Date(date) : date
+ if (isToday(d)) {
+ return format(d, "h:mm a")
+ }
+ if (isYesterday(d)) {
+ return yesterdayLabel
+ }
+ const sevenDaysAgo = new Date()
+ sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7)
+ if (d > sevenDaysAgo) {
+ return format(d, "EEE")
+ }
+ return format(d, "dd/MM/yy")
}
export default function ConversationItem({
conversation,
+ lifecycleStages = [],
onSelect,
}: ConversationItemProps) {
+ const t = useTranslations()
const lastMessage = conversation.messages?.[0]
const { activeConversationId, readConversation } = useChatStore(
(state) => state,
@@ -71,23 +164,50 @@ export default function ConversationItem({
const isActive = conversation.id === activeConversationId
const avatarUrl = useAvatarUrl(conversation.contact)
+ // Lookup local do stage do contato (lifecycleStages vem do layout via
+ // ChatLayout → ConversationList → ConversationItem). Evita N+1 fetch.
+ // Pixel-perfect Respond.io: pill aparece na linha 3 do card.
+ const lifecycleStage = useMemo(() => {
+ const contactStageId = conversation.contact?.lifecycleStageId
+ if (!contactStageId) {
+ return null
+ }
+ return lifecycleStages.find((s) => s.id === contactStageId) ?? null
+ }, [conversation.contact?.lifecycleStageId, lifecycleStages])
+
+ // Avatar 32x32 pixel-perfect Respond.io. Se contato tem avatar próprio,
+ // usa; senão cai no avatar real do Respond (pasta /public/respond-avatars).
+ // Cor + expressão determinada por hash do nome — mesmo contato sempre o
+ // mesmo avatar. Logo do canal WhatsApp REMOVIDO (Pedro pediu 2026-05-24).
+ const fullName = conversation.contact?.fullName ?? ""
+ // Iter 41 (Pedro): seed = ID do contato (snowflake imutável).
+ // Antes era `fullName || id` — risco de mudar cor se renomear contato.
+ // Garante consistência em TODOS os lugares que renderizam esse avatar.
+ const respondAvatar = getRespondAvatarUrl(
+ conversation.contact?.id ?? fullName,
+ )
+ const finalAvatarUrl = avatarUrl || respondAvatar.url
+ const initials = getAvatarInitials(fullName)
const contactAvatar = useMemo(
() => (
-
+
-
- {conversation.contact?.fullName?.slice(0, 2)}
+
+ {initials}
),
- [conversation.contact, avatarUrl],
+ [fullName, finalAvatarUrl, respondAvatar.color, initials],
)
- const { execute } = useAction(
+ const { execute: read } = useAction(
readConversationAction.bind(
null,
conversation.workspaceId,
@@ -108,74 +228,162 @@ export default function ConversationItem({
// biome-ignore lint/correctness/useExhaustiveDependencies: execute is not a dependency
useEffect(() => {
if (isActive) {
- execute()
+ read()
}
}, [isActive])
+ // -------- Indicadores visuais --------
+
+ // Não-lida: tem mensagem incoming nova após última leitura do agente.
+ // unreadCount vem do backend (subquery em listConversations). hasUnread
+ // = unreadCount > 0 — assim quando agente clica e marca como lida
+ // (agentLastReadAt = NOW), unreadCount vai pra 0 e pill some imediato.
+ // Não usar contactLastReadAt aqui — esse pode estar setado mas agente
+ // já leu, daria pill vazio com "0".
+ const hasUnread = conversation.unreadCount > 0
+
+ // Seta direção: outgoing (agente/bot) = ↗ azul, incoming (contato) = ↙
+ // laranja. Mostra origem da última mensagem (igual Respond.io).
+ const lastIsOutgoing = lastMessage?.messageType === "outgoing"
+ const lastIsIncoming = lastMessage?.messageType === "incoming"
+
+ const isClosed = Boolean(conversation.archivedAt)
+ const isBlocked = Boolean(conversation.contact?.blockedAt)
+
+ const timestamp = formatConversationTimestamp(
+ lastMessage?.createdAt
+ ? lastMessage.createdAt
+ : (conversation.lastActivityAt ?? new Date()),
+ t("conversations.todayLabel"),
+ t("conversations.yesterdayLabel"),
+ )
+
return (
-
+ // border-bottom sutil entre cards (rgba(255,255,255,.06) = white/[0.06]
+ // do Tailwind). Igual Respond.io ao vivo — separa cada card sem ficar
+ // pesado visualmente. Decisão Pedro 2026-05-24.
+
onSelect()}
type="button"
- variant={isActive ? "secondary" : "ghost"}
+ variant="ghost"
>
-
+
{contactAvatar}
-
- {assignedIcon(conversation)}
-
-
- {conversation.contactInboxes?.map((contactInbox) => (
-
- ))}
-
+ {/* Mini-avatar do atendente migrou pra LINHA 3 inline (pixel-perfect
+ Respond.io § 5 do _visual-respond-mapping.md). Antes ficava
+ overlay no avatar do contato. */}
+ {/* Logo do canal (WhatsApp etc) REMOVIDO — Pedro pediu 2026-05-24 */}
{conversation.followed && (
-
-
-
- {conversation.contact?.fullName}
+
+ {/* Linha 1: nome (14/22 weight 600) + timestamp HH:mm à direita */}
+
+
+ {conversation.contact?.fullName}
+
+
+ {timestamp}
+
-
+ {/* Setas Lucide (eram OK antes). Pedro pediu pra NÃO trocar
+ quando trocou tudo pra iconfont 2026-05-24. */}
+ {lastIsOutgoing && (
+
+ )}
+ {lastIsIncoming && (
+
+ )}
+
+ {lastMessage?.text ?? " "}
+
+ {conversation.unreadCount > 0 && (
+ // Pill azul com NÚMERO de mensagens incoming não lidas.
+ // SÓ renderiza quando count > 0 — quando agente clica e
+ // marca como lida, pill some por completo (não fica "0"
+ // vazio dentro).
+
+ {conversation.unreadCount > 99
+ ? "99+"
+ : conversation.unreadCount}
+
)}
- >
- {conversation.messages?.[0]?.text ?? " "}
-
-
- {formatDistanceToNowStrict(
- lastMessage?.createdAt ? lastMessage.createdAt : new Date(),
+
+ {/* Linha 3: StagePill (opcional) + mini-avatar atendente (UNIVERSAL).
+ Pixel-perfect Respond.io § 5 do _visual-respond-mapping.md.
+ Mini-avatar SEMPRE aparece — quando não atribuído, mostra
+ placeholder vermelho com silhueta humana (sinal visual claro
+ de "ninguém pegou ainda"). Pedro pediu 2026-05-25. */}
+
+
+ {/* Linha 4 (opcional): rótulo de status — Fechada ou Bloqueada */}
+ {(isClosed || isBlocked) && (
+
+ {isClosed && (
+
+ {t("states.closed")}
+
)}
-
-
+ {isBlocked && (
+
+ {t("states.blocked")}
+
+ )}
+
+ )}
+ {/* Hover actions REMOVIDOS — Pedro pediu 2026-05-24. Fechar/reabrir
+ fica só pelo botão primário do header da conversa. */}
)
}
diff --git a/apps/builder/src/features/conversations/conversation-list.tsx b/apps/builder/src/features/conversations/conversation-list.tsx
index 214612fcc..6566be105 100644
--- a/apps/builder/src/features/conversations/conversation-list.tsx
+++ b/apps/builder/src/features/conversations/conversation-list.tsx
@@ -1,33 +1,28 @@
"use client"
-import {
- assignerFilterTypes,
- channelTypes,
- conversationBotCategories,
-} from "@chatbotx.io/database/partials"
-import { InputField } from "@chatbotx.io/ui/components/form/input-field"
-import { SelectField } from "@chatbotx.io/ui/components/form/select-field"
+import type { LifecycleStageModel } from "@chatbotx.io/database/types"
import { Button } from "@chatbotx.io/ui/components/ui/button"
-import { Form } from "@chatbotx.io/ui/components/ui/form"
+import { Input } from "@chatbotx.io/ui/components/ui/input"
import { Skeleton } from "@chatbotx.io/ui/components/ui/skeleton"
+import { Switch } from "@chatbotx.io/ui/components/ui/switch"
import { SearchIcon, UserPlusIcon } from "lucide-react"
import { useRouter, useSearchParams } from "next/navigation"
import { useTranslations } from "next-intl"
import { useEffect, useState } from "react"
-import { useForm } from "react-hook-form"
import { type GridComponents, Virtuoso } from "react-virtuoso"
import { useDebouncedCallback } from "use-debounce"
import { TagStoreProvider } from "@/features/tags/provider/tag-store-context"
-import type { ConversationFilters } from "../chat/store/chat-store"
import { useChatStore } from "../chat/store/chat-store-provider"
import { CreateContactDialog } from "../contacts/create-contact-dialog"
-import { ConversationFilter } from "./conversation-filter"
+import { ConversationDisplayFilter } from "./conversation-display-filter"
import ConversationItem from "./conversation-item"
export default function ConversationList({
workspaceId,
+ lifecycleStages = [],
}: {
workspaceId: string
+ lifecycleStages?: LifecycleStageModel[]
}) {
const t = useTranslations()
const router = useRouter()
@@ -44,6 +39,7 @@ export default function ConversationList({
} = useChatStore((state) => state)
const [showSearchInput, setShowSearchInput] = useState(false)
+ const [searchValue, setSearchValue] = useState(filters.keyword ?? "")
// Check if there are more pages to load
const hasNextPage =
@@ -62,87 +58,94 @@ export default function ConversationList({
}
}
- const handleChange = useDebouncedCallback(() => {
+ const triggerReload = useDebouncedCallback(() => {
resetState()
loadMoreConversations(workspaceId)
}, 300)
- const form = useForm
({
- defaultValues: {
- keyword: "",
- botCategory: conversationBotCategories.enum.all,
- channel: channelTypes.enum.omnichannel,
- assignedId: assignerFilterTypes.enum.all,
- tags: [],
- contactFilter: {
- operator: "and",
- conditions: [],
- },
- },
- })
+ const updateFilters = (next: Partial) => {
+ setFilters({ ...filters, ...next })
+ triggerReload()
+ }
- useEffect(() => {
- const subscription = form.watch((values) => {
- setFilters(values as ConversationFilters)
- handleChange()
- })
- return () => subscription.unsubscribe()
- }, [form, handleChange, setFilters])
+ const debouncedSearch = useDebouncedCallback((value: string) => {
+ setFilters({ ...filters, keyword: value })
+ resetState()
+ loadMoreConversations(workspaceId)
+ }, 400)
+
+ const handleSearchChange = (value: string) => {
+ setSearchValue(value)
+ debouncedSearch(value)
+ }
+
+ const noReplyEnabled = filters.tags?.includes("noAdminReply") ?? false
+ const toggleNoReply = (next: boolean) => {
+ const current = filters.tags ?? []
+ const cleaned = current.filter((tag) => tag !== "noAdminReply")
+ const tagsNext = next ? [...cleaned, "noAdminReply" as const] : cleaned
+ updateFilters({ tags: tagsNext })
+ }
return (
-
-
-
-
+
+
+
+
-
- {
- setShowSearchInput(!showSearchInput)
- }}
- size="sm"
- type="button"
- variant="outline"
+
-
-
-
+ {t("inboxDisplayFilter.noReply")}
+
+
+
-
+
+
}
workspaceId={workspaceId}
/>
-
-
-
-
+ setShowSearchInput((prev) => !prev)}
+ size="sm"
+ variant="ghost"
+ >
+
+
+
-
+
)
}
@@ -190,7 +194,9 @@ const ConversationListList: GridComponents["List"] = ({
children,
...props
}) => (
-
+ // Sem gap entre cards — separação visual vem do border-bottom de cada
+ // ConversationItem (igual Respond.io ao vivo).
+
{children}
)
diff --git a/apps/builder/src/features/conversations/queries/list-conversations.query.ts b/apps/builder/src/features/conversations/queries/list-conversations.query.ts
index 39ac80d0e..a6916c503 100644
--- a/apps/builder/src/features/conversations/queries/list-conversations.query.ts
+++ b/apps/builder/src/features/conversations/queries/list-conversations.query.ts
@@ -1,5 +1,6 @@
"use server"
+import { conversationService } from "@chatbotx.io/business"
import { notFoundException } from "@chatbotx.io/business/errors"
import { db, sql } from "@chatbotx.io/database/client"
import {
@@ -27,12 +28,15 @@ export const listConversations = async (
const where: Record
= {
workspaceId,
- archivedAt: { isNull: true },
+ ...filterByArchiveStatus(data),
...filterByConversation(data),
contact: filterByContact(data),
contactInboxes: filterByContactInbox(data),
}
+ // Constrói condições OR adicionais (cursor) num AND combinado
+ const andConditions: Record[] = []
+
// Handle cursor pagination
const decodedCursor = cursor
? decodeCursor(
@@ -43,23 +47,39 @@ export const listConversations = async (
}),
)
: null
+ const useAsc = data.sortOrder === "asc"
if (decodedCursor) {
- where.OR = [
- {
- lastActivityAt: { lt: decodedCursor.lastActivityAt },
- },
- {
- lastActivityAt: { eq: decodedCursor.lastActivityAt },
- id: { gt: decodedCursor.id },
- },
- ]
+ andConditions.push({
+ OR: [
+ {
+ lastActivityAt: useAsc
+ ? { gt: decodedCursor.lastActivityAt }
+ : { lt: decodedCursor.lastActivityAt },
+ },
+ {
+ lastActivityAt: { eq: decodedCursor.lastActivityAt },
+ id: useAsc ? { lt: decodedCursor.id } : { gt: decodedCursor.id },
+ },
+ ],
+ })
+ }
+
+ if (andConditions.length > 0) {
+ where.AND = andConditions
}
const limit = pagination.limit + 1 // +1 to check if there is a next page
+ const direction: "asc" | "desc" = data.sortOrder ?? "desc"
const conversations = await db.query.conversationModel.findMany({
with: {
contact: true,
- contactInboxes: true,
+ contactInboxes: {
+ with: {
+ inbox: {
+ columns: { id: true, name: true, channel: true },
+ },
+ },
+ },
assignedUser: true,
assignedInboxTeam: true,
messages: {
@@ -71,8 +91,8 @@ export const listConversations = async (
offset: pagination.offset,
limit,
orderBy: {
- lastActivityAt: "desc",
- id: "asc",
+ lastActivityAt: direction,
+ id: direction === "desc" ? "asc" : "desc",
},
})
@@ -81,6 +101,18 @@ export const listConversations = async (
? conversations.slice(0, pagination.limit)
: conversations
+ // Conta mensagens incoming NÃO LIDAS pelo agente, agrupadas por
+ // conversa. Usado pro badge azul do card (igual Respond.io "8" no pill).
+ // Lógica mora no conversationService pra evitar quirk Next 16 standalone
+ // que reclama de imports de Drizzle models em "use server" files.
+ const unreadCountMap = await conversationService.countUnreadByConversationIds(
+ { conversationIds: items.map((c) => c.id) },
+ )
+ const itemsWithUnread = items.map((c) => ({
+ ...c,
+ unreadCount: unreadCountMap.get(c.id) ?? 0,
+ }))
+
const nextCursor = hasNext
? encodeCursor({
lastActivityAt: conversations[limit - 2].lastActivityAt.toISOString(),
@@ -89,7 +121,7 @@ export const listConversations = async (
: null
return {
- data: items,
+ data: itemsWithUnread,
nextCursor,
prevCursor: null,
}
@@ -114,7 +146,13 @@ export const findConversation = async (
tags: true,
},
},
- contactInboxes: true,
+ contactInboxes: {
+ with: {
+ inbox: {
+ columns: { id: true, name: true, channel: true },
+ },
+ },
+ },
messages: true,
assignedUser: true,
assignedInboxTeam: true,
@@ -122,7 +160,7 @@ export const findConversation = async (
where: input,
})
if (!conversation) {
- throw notFoundException("Conversation not found")
+ throw notFoundException("Conversa não encontrada")
}
const lastMessage = await db.query.messageModel.findFirst({
@@ -139,10 +177,32 @@ export const findConversation = async (
data: {
...conversation,
messages: lastMessage ? [lastMessage] : [],
+ // findConversation é chamado quando agente abre a conversa — por
+ // definição "marcando como lida". Zero é coerente. O badge azul do
+ // card só usa unreadCount na lista (listConversations).
+ unreadCount: 0,
},
}
}
+const filterByArchiveStatus = (
+ input: ListConversationsRequest,
+): Record => {
+ // Compatibilidade: se tag "archived" foi passada (UI antiga), trata como "closed"
+ const effectiveFilter: "open" | "closed" | "all" | undefined =
+ input.archiveFilter ??
+ (input.tags?.includes("archived") ? "closed" : undefined)
+
+ if (effectiveFilter === "all") {
+ return {}
+ }
+ if (effectiveFilter === "closed") {
+ return { archivedAt: { isNotNull: true } }
+ }
+ // default: open
+ return { archivedAt: { isNull: true } }
+}
+
const filterByConversation = (
input: ListConversationsRequest,
): Record => {
@@ -175,9 +235,7 @@ const filterByConversation = (
if (input.tags.includes("followUp")) {
where.followed = true
}
- if (input.tags.includes("archived")) {
- where.archivedAt = { isNotNull: true }
- }
+ // tag "archived" agora é tratado em filterByArchiveStatus pra compatibilidade
}
return where
@@ -201,6 +259,10 @@ const filterByContact = (
where.blockedAt = { isNotNull: true }
}
+ if (input.lifecycleStageId) {
+ where.lifecycleStageId = input.lifecycleStageId
+ }
+
if (input.contactFilter) {
Object.assign(where, applyContactFilter(input.contactFilter))
}
diff --git a/apps/builder/src/features/conversations/schema/close-with-note.ts b/apps/builder/src/features/conversations/schema/close-with-note.ts
new file mode 100644
index 000000000..b3f7adba7
--- /dev/null
+++ b/apps/builder/src/features/conversations/schema/close-with-note.ts
@@ -0,0 +1,23 @@
+import { zodBigintAsString } from "@chatbotx.io/utils"
+import { z } from "zod"
+
+// Schema permissivo do form do CloseConversationDialog: deixa categoryId e
+// summary opcionais. A obrigatoriedade contextual por modo é imposta
+// SERVER-SIDE (closeConversationWithNoteAction → ChatbotXException) e
+// reforçada visualmente via `disabled` no botão de confirmar.
+//
+// Por que opcional aqui? useHookFormAction tipa defaultValues por inferência
+// do Zod; deixar campos required-with-undefined gera incompatibilidade
+// com defaultValues que omitem a chave.
+//
+// Note: arquivo separado por causa do Next 16 standalone "use server" quirk
+// (schemas Zod não podem ficar em files marcados "use server").
+export const closeConversationWithNoteFormSchema = z.object({
+ conversationId: zodBigintAsString(),
+ categoryId: zodBigintAsString().optional(),
+ summary: z.string().trim().max(2000).optional(),
+})
+
+export type CloseConversationWithNoteFormValues = z.infer<
+ typeof closeConversationWithNoteFormSchema
+>
diff --git a/apps/builder/src/features/conversations/schema/mark-filter-read.ts b/apps/builder/src/features/conversations/schema/mark-filter-read.ts
new file mode 100644
index 000000000..5321ff06f
--- /dev/null
+++ b/apps/builder/src/features/conversations/schema/mark-filter-read.ts
@@ -0,0 +1,14 @@
+import { z } from "zod"
+
+// Schema do markFilterReadAction. Mora em arquivo separado pra evitar
+// quirk do Next 16 standalone que reclama de schema z.object() inline em
+// "use server" files (`A "use server" file can only export async
+// functions, found object`).
+export const markFilterReadRequest = z.object({
+ scope: z.enum(["all", "mine", "unassigned", "team", "lifecycle"]),
+ userId: z.string().optional(),
+ teamId: z.string().optional(),
+ stageId: z.string().optional(),
+})
+
+export type MarkFilterReadRequest = z.infer
diff --git a/apps/builder/src/features/conversations/schema/query.ts b/apps/builder/src/features/conversations/schema/query.ts
index ab1de5e02..85d0d9eba 100644
--- a/apps/builder/src/features/conversations/schema/query.ts
+++ b/apps/builder/src/features/conversations/schema/query.ts
@@ -8,6 +8,12 @@ import { z } from "zod"
import { contactFilterRequest } from "@/features/contacts/schemas/contact-filter"
import { cursorPaginationRequest } from "@/lib/pagination"
+export const archiveFilters = z.enum(["open", "closed", "all"])
+export type ArchiveFilter = z.infer
+
+export const sortOrders = z.enum(["asc", "desc"])
+export type SortOrder = z.infer
+
export const listConversationsRequest = z.object({
workspaceId: zodBigintAsString(),
botCategory: conversationBotCategories.optional(),
@@ -21,6 +27,9 @@ export const listConversationsRequest = z.object({
z.enum(["noAdminReply", "unread", "followUp", "archived", "blocked"]),
)
.optional(),
+ archiveFilter: archiveFilters.optional(),
+ sortOrder: sortOrders.optional(),
+ lifecycleStageId: z.string().nullable().optional(),
contactFilter: contactFilterRequest.shape.contactFilter.optional(),
...cursorPaginationRequest.shape,
})
diff --git a/apps/builder/src/features/conversations/schema/resource.ts b/apps/builder/src/features/conversations/schema/resource.ts
index 1fdfb3178..a84213d87 100644
--- a/apps/builder/src/features/conversations/schema/resource.ts
+++ b/apps/builder/src/features/conversations/schema/resource.ts
@@ -24,6 +24,11 @@ export const listConversationsItemResource = conversationResource.and(
contact: contactResource.nullable(),
assignedUser: userResource.nullable(),
assignedInboxTeam: inboxTeamResource.nullable(),
+ // Contagem de mensagens incoming não lidas pelo agente (createdAt >
+ // agentLastReadAt OR agentLastReadAt IS NULL). Calculada via subquery
+ // separada no listConversations. Usado no badge azul do card (igual
+ // Respond.io "8" no pill azul).
+ unreadCount: z.number().default(0),
}),
)
export type ListConversationItemResource = z.infer<
diff --git a/apps/builder/src/features/conversations/schema/send-whatsapp-template.ts b/apps/builder/src/features/conversations/schema/send-whatsapp-template.ts
new file mode 100644
index 000000000..01604de9f
--- /dev/null
+++ b/apps/builder/src/features/conversations/schema/send-whatsapp-template.ts
@@ -0,0 +1,14 @@
+import { z } from "zod"
+
+// Schema separado por quirk Next 16 standalone — não inline em "use server".
+// Variáveis `{{1}}`, `{{2}}` etc do template HSM passam por aqui já preenchidas
+// pelo agente; backend roda `replaceWhatsappTemplateVariables` (worker) caso
+// queira interpolar com dados do contact, mas pra envio manual do composer
+// o agente preenche manualmente.
+export const sendWhatsappTemplateRequest = z.object({
+ templateId: z.string(),
+ variables: z.array(z.string()).optional(),
+})
+export type SendWhatsappTemplateRequest = z.infer<
+ typeof sendWhatsappTemplateRequest
+>
diff --git a/apps/builder/src/features/custom-fields/actions/create-custom-field.action.ts b/apps/builder/src/features/custom-fields/actions/create-custom-field.action.ts
index 3f608c18c..ba7894705 100644
--- a/apps/builder/src/features/custom-fields/actions/create-custom-field.action.ts
+++ b/apps/builder/src/features/custom-fields/actions/create-custom-field.action.ts
@@ -1,8 +1,10 @@
"use server"
+import { auditLogActions, logAudit } from "@chatbotx.io/business"
import { ChatbotXException } from "@chatbotx.io/business/errors"
-import { db, isDatabaseError } from "@chatbotx.io/database/client"
+import { db, eq, isDatabaseError } from "@chatbotx.io/database/client"
import { customFieldModel } from "@chatbotx.io/database/schema"
+import { slugifyUnique } from "@chatbotx.io/utils"
import { returnValidationErrors } from "next-safe-action"
import {
type WorkspaceIdRequestParams,
@@ -24,11 +26,19 @@ export const createCustomFieldAction = workspaceActionClient
async ({
bindArgsParsedInputs: [workspaceId],
parsedInput,
+ ctx: { user },
}: {
bindArgsParsedInputs: WorkspaceIdRequestParams
parsedInput: CreateCustomFieldRequest
+ ctx: { user: { id: string } }
}) => {
await createCustomField(workspaceId, parsedInput)
+ await logAudit({
+ workspaceId,
+ userId: user.id,
+ action: auditLogActions.CUSTOM_FIELD_CREATED,
+ detail: `Campo personalizado "${parsedInput.name}" (${parsedInput.type}) criado`,
+ })
},
)
@@ -40,12 +50,21 @@ export const createCustomField = async (
await ensureFolderIsExists(parsedInput.folderId, workspaceId, "customField")
}
+ // Gera fieldId slug user-facing único por workspace (paridade Respond.io).
+ // Consulta slugs existentes pra evitar colisão; sufixa _2, _3 se preciso.
+ const existing = await db
+ .select({ fieldId: customFieldModel.fieldId })
+ .from(customFieldModel)
+ .where(eq(customFieldModel.workspaceId, workspaceId))
+ const existingSlugs = new Set(existing.map((r) => r.fieldId))
+ const fieldId = slugifyUnique(parsedInput.name, existingSlugs)
+
try {
const newField = await db
.insert(customFieldModel)
.values({
workspaceId,
- showInInbox: true,
+ fieldId,
...parsedInput,
})
.returning()
@@ -57,11 +76,11 @@ export const createCustomField = async (
} catch (error) {
if (isDatabaseError(error) && error.cause.code === "23505") {
return returnValidationErrors(createCustomFieldRequest, {
- _errors: ["Validation Exception"],
+ _errors: ["Exceção de Validação"],
name: { _errors: ["Name is already taken"] },
})
}
- throw new ChatbotXException("Failed to create custom field")
+ throw new ChatbotXException("Falha ao criar campo personalizado")
}
}
diff --git a/apps/builder/src/features/custom-fields/actions/delete-custom-field.action.ts b/apps/builder/src/features/custom-fields/actions/delete-custom-field.action.ts
index 3773686a4..d1f6ae396 100644
--- a/apps/builder/src/features/custom-fields/actions/delete-custom-field.action.ts
+++ b/apps/builder/src/features/custom-fields/actions/delete-custom-field.action.ts
@@ -1,5 +1,6 @@
"use server"
+import { auditLogActions, logAudit } from "@chatbotx.io/business"
import { and, db, eq, inArray } from "@chatbotx.io/database/client"
import { customFieldModel } from "@chatbotx.io/database/schema"
import {
@@ -18,11 +19,25 @@ export const deleteFieldsAction = workspaceActionClient
async ({
bindArgsParsedInputs: [workspaceId],
parsedInput,
+ ctx: { user },
}: {
bindArgsParsedInputs: WorkspaceIdRequestParams
parsedInput: BulkUpdateIdsRequest
+ ctx: { user: { id: string } }
}) => {
+ const names = await db.query.customFieldModel.findMany({
+ columns: { name: true },
+ where: { workspaceId, id: { in: parsedInput.ids } },
+ })
await deleteCustomFields({ workspaceId, ids: parsedInput.ids })
+ if (names.length > 0) {
+ await logAudit({
+ workspaceId,
+ userId: user.id,
+ action: auditLogActions.CUSTOM_FIELD_DELETED,
+ detail: `Campo(s) personalizado(s) excluído(s): ${names.map((f) => `"${f.name}"`).join(", ")}`,
+ })
+ }
},
)
diff --git a/apps/builder/src/features/custom-fields/actions/update-contact-fields-visibility.action.ts b/apps/builder/src/features/custom-fields/actions/update-contact-fields-visibility.action.ts
new file mode 100644
index 000000000..e60fca266
--- /dev/null
+++ b/apps/builder/src/features/custom-fields/actions/update-contact-fields-visibility.action.ts
@@ -0,0 +1,35 @@
+"use server"
+
+import { workspaceContactFieldVisibilityService } from "@chatbotx.io/business"
+import {
+ type WorkspaceIdRequestParams,
+ workspaceIdrequestParams,
+} from "@/features/common/schemas"
+import { revalidateCacheTags } from "@/lib/cache-helper"
+import { workspaceActionClient } from "@/lib/safe-action"
+import {
+ type UpdateContactFieldsVisibilityRequest,
+ updateContactFieldsVisibilityRequest,
+} from "../schemas/visibility"
+
+export const updateContactFieldsVisibilityAction = workspaceActionClient
+ .bindArgsSchemas(workspaceIdrequestParams)
+ .inputSchema(updateContactFieldsVisibilityRequest)
+ .action(
+ async ({
+ bindArgsParsedInputs: [workspaceId],
+ parsedInput,
+ }: {
+ bindArgsParsedInputs: WorkspaceIdRequestParams
+ parsedInput: UpdateContactFieldsVisibilityRequest
+ }) => {
+ await workspaceContactFieldVisibilityService.setFieldsVisibility({
+ workspaceId,
+ items: parsedInput.items,
+ })
+
+ revalidateCacheTags([
+ `workspaces:${workspaceId}#contact-field-visibility`,
+ ])
+ },
+ )
diff --git a/apps/builder/src/features/custom-fields/actions/update-custom-field.action.ts b/apps/builder/src/features/custom-fields/actions/update-custom-field.action.ts
index a0ce8c386..89b8d78d6 100644
--- a/apps/builder/src/features/custom-fields/actions/update-custom-field.action.ts
+++ b/apps/builder/src/features/custom-fields/actions/update-custom-field.action.ts
@@ -1,5 +1,6 @@
"use server"
+import { auditLogActions, logAudit } from "@chatbotx.io/business"
import { db, eq, findOrFail } from "@chatbotx.io/database/client"
import { customFieldModel } from "@chatbotx.io/database/schema"
import { zodBigintAsString } from "@chatbotx.io/utils"
@@ -18,9 +19,16 @@ export const updateCustomFieldAction = workspaceActionClient
const {
bindArgsParsedInputs: [workspaceId, id],
parsedInput,
+ ctx: { user },
} = props
await updateCustomField({ workspaceId, id }, parsedInput)
+ await logAudit({
+ workspaceId,
+ userId: user.id,
+ action: auditLogActions.CUSTOM_FIELD_UPDATED,
+ detail: `Campo personalizado "${parsedInput.name}" atualizado`,
+ })
})
export const updateCustomField = async (
diff --git a/apps/builder/src/features/custom-fields/api/private.ts b/apps/builder/src/features/custom-fields/api/private.ts
index da3f916ef..c91f3fc64 100644
--- a/apps/builder/src/features/custom-fields/api/private.ts
+++ b/apps/builder/src/features/custom-fields/api/private.ts
@@ -22,8 +22,8 @@ export const privateCustomFieldsAPI = {
.route({
method: "GET",
path: "/workspaces/{workspaceId}/custom-fields",
- summary: "List custom fields",
- tags: ["Custom Fields"],
+ summary: "Listar campos personalizados",
+ tags: ["Campos Personalizados"],
})
.input(listCustomFieldsRequest.and(withWorkspaceIdSchema))
.use(workspaceAuthorizedMidddleware, (input) => input.workspaceId)
@@ -37,8 +37,8 @@ export const privateCustomFieldsAPI = {
.route({
method: "POST",
path: "/workspaces/{workspaceId}/custom-fields",
- summary: "Create custom field",
- tags: ["Custom Fields"],
+ summary: "Criar campo personalizado",
+ tags: ["Campos Personalizados"],
})
.input(createCustomFieldRequest.and(withWorkspaceIdSchema))
.output(createCustomFieldResponse)
@@ -53,8 +53,8 @@ export const privateCustomFieldsAPI = {
.route({
method: "PUT",
path: "/workspaces/{workspaceId}/custom-fields/{id}",
- summary: "Update custom field",
- tags: ["Custom Fields"],
+ summary: "Atualizar campo personalizado",
+ tags: ["Campos Personalizados"],
})
.input(
updateCustomFieldRequest
@@ -77,8 +77,8 @@ export const privateCustomFieldsAPI = {
.route({
method: "DELETE",
path: "/workspaces/{workspaceId}/custom-fields/{customFieldId}",
- summary: "Delete custom field",
- tags: ["Custom Fields"],
+ summary: "Excluir campo personalizado",
+ tags: ["Campos Personalizados"],
})
.input(
z.object({
diff --git a/apps/builder/src/features/custom-fields/api/workspace-token.ts b/apps/builder/src/features/custom-fields/api/workspace-token.ts
index bb0816153..8d7795968 100644
--- a/apps/builder/src/features/custom-fields/api/workspace-token.ts
+++ b/apps/builder/src/features/custom-fields/api/workspace-token.ts
@@ -14,8 +14,8 @@ const customFieldsWorkspaceTokenAPI = {
.route({
method: "GET",
path: "/v1/custom-fields",
- summary: "Get all custom fields",
- tags: ["Custom Fields"],
+ summary: "Obter todos os campos personalizados",
+ tags: ["Campos Personalizados"],
})
.input(z.object({}))
.output(z.object({ data: z.array(publicCustomFieldResource) }))
@@ -31,8 +31,8 @@ const customFieldsWorkspaceTokenAPI = {
.route({
method: "POST",
path: "/v1/custom-fields",
- summary: "Create a custom field",
- tags: ["Custom Fields"],
+ summary: "Criar campo personalizado",
+ tags: ["Campos Personalizados"],
})
.input(createCustomFieldRequest.pick({ name: true, type: true }))
.output(publicCustomFieldResource)
@@ -45,8 +45,8 @@ const customFieldsWorkspaceTokenAPI = {
.route({
method: "GET",
path: "/v1/custom-fields/{id}",
- summary: "Get custom field by id",
- tags: ["Custom Fields"],
+ summary: "Obter campo personalizado por ID",
+ tags: ["Campos Personalizados"],
})
.input(z.object({ id: zodBigintAsString() }))
.output(publicCustomFieldResource)
@@ -65,8 +65,8 @@ const customFieldsWorkspaceTokenAPI = {
.route({
method: "GET",
path: "/v1/custom-fields/name/{name}",
- summary: "Get custom field by name",
- tags: ["Custom Fields"],
+ summary: "Obter campo personalizado por nome",
+ tags: ["Campos Personalizados"],
})
.input(z.object({ name: z.string() }))
.output(publicCustomFieldResource)
diff --git a/apps/builder/src/features/custom-fields/components/custom-field-label.tsx b/apps/builder/src/features/custom-fields/components/custom-field-label.tsx
index 980ce484a..cfd46ab36 100644
--- a/apps/builder/src/features/custom-fields/components/custom-field-label.tsx
+++ b/apps/builder/src/features/custom-fields/components/custom-field-label.tsx
@@ -4,23 +4,23 @@ import type { CustomFieldType } from "@chatbotx.io/database/partials"
import { useTranslations } from "next-intl"
import { useMemo } from "react"
-const getTranslationKey = (type: CustomFieldType): string => {
- switch (type) {
- case "number":
- return "fields.number.label"
- case "date":
- return "fields.date.label"
- case "datetime":
- return "fields.datetime.label"
- case "boolean":
- return "fields.boolean.label"
- case "longText":
- return "fields.longText.label"
- default:
- return "fields.shortText.label"
- }
+const TYPE_LABEL_KEY: Record = {
+ shortText: "fields.shortText.label",
+ longText: "fields.longText.label",
+ number: "fields.number.label",
+ date: "fields.date.label",
+ datetime: "fields.datetime.label",
+ time: "fields.time.label",
+ url: "fields.url.label",
+ list: "fields.list.label",
+ boolean: "fields.boolean.label",
+ email: "fields.email.label",
+ phoneNumber: "fields.phoneNumber.label",
}
+const getTranslationKey = (type: CustomFieldType): string =>
+ TYPE_LABEL_KEY[type] ?? "fields.shortText.label"
+
export default function CustomFieldTypeLabel({
type,
}: {
diff --git a/apps/builder/src/features/custom-fields/components/customize-contact-fields-dialog.tsx b/apps/builder/src/features/custom-fields/components/customize-contact-fields-dialog.tsx
new file mode 100644
index 000000000..dd3501efb
--- /dev/null
+++ b/apps/builder/src/features/custom-fields/components/customize-contact-fields-dialog.tsx
@@ -0,0 +1,215 @@
+"use client"
+
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@chatbotx.io/ui/components/ui/dialog"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@chatbotx.io/ui/components/ui/select"
+import { SlidersHorizontalIcon } from "lucide-react"
+import { useTranslations } from "next-intl"
+import { useAction } from "next-safe-action/hooks"
+import { useEffect, useMemo, useState } from "react"
+import { toast } from "sonner"
+import { updateContactFieldsVisibilityAction } from "../actions/update-contact-fields-visibility.action"
+import type { CustomFieldResource } from "../schemas/resource"
+import type { ContactFieldVisibilityValue } from "../schemas/visibility"
+
+/**
+ * Dialog "Personalizar visualização" — Pedro 2026-05-25 iteração 33.
+ *
+ * Replica o modal do Respond.io em Settings → Campos de contatos. Lista
+ * todos os fields (sistema + custom) e deixa user marcar como
+ * "Exibir sempre" / "Sempre ocultar". Persiste workspace-level (afeta
+ * todos os usuários).
+ *
+ * As keys de sistema seguem o nome no schema `Contact` do Drizzle:
+ * `phoneNumber`, `firstName`, `lastName`, `email`, `language`,
+ * `country`, `profilePicUrl`. As custom usam o id (string) do model
+ * `CustomField`.
+ */
+
+const SYSTEM_FIELDS: { key: string; labelKey: string }[] = [
+ { key: "phoneNumber", labelKey: "fields.phoneNumber.label" },
+ { key: "firstName", labelKey: "fields.firstName.label" },
+ { key: "lastName", labelKey: "fields.lastName.label" },
+ { key: "email", labelKey: "fields.email.label" },
+ { key: "language", labelKey: "fields.language.label" },
+ { key: "country", labelKey: "fields.country.label" },
+]
+
+type Props = {
+ workspaceId: string
+ /**
+ * Lista atual de pares hidden { fieldKey, visibility } (vindo do
+ * server). Usada pra inicializar o estado do dialog.
+ */
+ initialHiddenKeys: string[]
+ /**
+ * Custom fields do workspace passados como prop pra não depender de
+ * `` (que só existe na inbox).
+ */
+ customFields: CustomFieldResource[]
+}
+
+export function CustomizeContactFieldsDialog({
+ workspaceId,
+ initialHiddenKeys,
+ customFields,
+}: Props) {
+ const t = useTranslations()
+ const [open, setOpen] = useState(false)
+
+ const customFieldsList = customFields
+
+ // Map local: fieldKey -> visibility
+ const [visibility, setVisibility] = useState<
+ Record
+ >({})
+
+ // Inicializa quando abre + quando lista de hidden muda externamente
+ useEffect(() => {
+ if (!open) {
+ return
+ }
+ const next: Record = {}
+ for (const f of SYSTEM_FIELDS) {
+ next[f.key] = initialHiddenKeys.includes(f.key)
+ ? "alwaysHide"
+ : "showAlways"
+ }
+ for (const cf of customFieldsList) {
+ const k = cf.id.toString()
+ next[k] = initialHiddenKeys.includes(k) ? "alwaysHide" : "showAlways"
+ }
+ setVisibility(next)
+ }, [open, initialHiddenKeys, customFieldsList])
+
+ const rows = useMemo(
+ () => [
+ ...SYSTEM_FIELDS.map((f) => ({
+ key: f.key,
+ label: t(f.labelKey as never) || f.key,
+ })),
+ ...customFieldsList.map((cf) => ({
+ key: cf.id.toString(),
+ label: cf.name,
+ })),
+ ],
+ [customFieldsList, t],
+ )
+
+ const { execute, isPending } = useAction(
+ updateContactFieldsVisibilityAction.bind(null, workspaceId),
+ {
+ onSuccess: () => {
+ toast.success(
+ t("contacts.visibility.saved") ?? "Visualização atualizada",
+ )
+ setOpen(false)
+ },
+ onError: ({ error }) => {
+ if (error.serverError) {
+ toast.error(error.serverError)
+ }
+ },
+ },
+ )
+
+ const handleSave = () => {
+ const items = rows.map((row) => ({
+ fieldKey: row.key,
+ visibility: visibility[row.key] ?? "showAlways",
+ }))
+ execute({ items })
+ }
+
+ return (
+
+
+
+
+ {t("contacts.visibility.customize") ?? "Personalizar visualização"}
+
+
+
+
+
+ {t("contacts.visibility.title") ?? "Personalizar visualização"}
+
+
+ {t("contacts.visibility.description") ??
+ "Escolha quais campos de contato aparecem no painel direito do Inbox. A alteração será aplicada a todos os usuários do workspace."}
+
+
+
+
+
+
+ {t("contacts.visibility.fieldHeader") ?? "Campo de contato"}
+
+
+ {t("contacts.visibility.visibilityHeader") ?? "Visibilidade"}
+
+
+
+ {rows.map((row) => (
+
+ {row.label}
+
+ setVisibility((prev) => ({
+ ...prev,
+ [row.key]: v as ContactFieldVisibilityValue,
+ }))
+ }
+ value={visibility[row.key] ?? "showAlways"}
+ >
+
+
+
+
+
+ {t("contacts.visibility.showAlways") ?? "Exibir sempre"}
+
+
+ {t("contacts.visibility.alwaysHide") ?? "Sempre ocultar"}
+
+
+
+
+ ))}
+
+
+
+ setOpen(false)}
+ type="button"
+ variant="ghost"
+ >
+ {t("actions.cancel") ?? "Cancelar"}
+
+
+ {t("actions.save") ?? "Guardar"}
+
+
+
+
+ )
+}
diff --git a/apps/builder/src/features/custom-fields/components/default-fields-list.tsx b/apps/builder/src/features/custom-fields/components/default-fields-list.tsx
new file mode 100644
index 000000000..97c9acfa1
--- /dev/null
+++ b/apps/builder/src/features/custom-fields/components/default-fields-list.tsx
@@ -0,0 +1,148 @@
+"use client"
+
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@chatbotx.io/ui/components/ui/tooltip"
+import { cn } from "@chatbotx.io/ui/lib/utils"
+import {
+ AtSignIcon,
+ GlobeIcon,
+ ImageIcon,
+ LanguagesIcon,
+ LockIcon,
+ type LucideIcon,
+ PhoneIcon,
+ TextIcon,
+} from "lucide-react"
+import { useTranslations } from "next-intl"
+
+type DefaultField = {
+ fieldId: string
+ labelKey: string
+ type: string
+ icon: LucideIcon
+}
+
+// 7 campos padrão do contato — não podem ser editados/deletados (vêm do
+// schema do contato, sao imutáveis). Paridade Respond.io (gap #11 —
+// 2026-05-27): renderizar fixos no topo da tabela /settings/contact-fields
+// pra usuário saber que existem e qual o slug (pra usar em APIs/templates).
+const DEFAULT_FIELDS: DefaultField[] = [
+ {
+ fieldId: "firstName",
+ labelKey: "fields.firstName.label",
+ type: "shortText",
+ icon: TextIcon,
+ },
+ {
+ fieldId: "lastName",
+ labelKey: "fields.lastName.label",
+ type: "shortText",
+ icon: TextIcon,
+ },
+ {
+ fieldId: "phoneNumber",
+ labelKey: "fields.phoneNumber.label",
+ type: "phoneNumber",
+ icon: PhoneIcon,
+ },
+ {
+ fieldId: "email",
+ labelKey: "fields.email.label",
+ type: "email",
+ icon: AtSignIcon,
+ },
+ {
+ fieldId: "country",
+ labelKey: "fields.country.label",
+ type: "shortText",
+ icon: GlobeIcon,
+ },
+ {
+ fieldId: "locale",
+ labelKey: "fields.locale.label",
+ type: "shortText",
+ icon: LanguagesIcon,
+ },
+ {
+ fieldId: "avatar",
+ labelKey: "fields.avatar.label",
+ type: "url",
+ icon: ImageIcon,
+ },
+]
+
+const TYPE_LABEL: Record = {
+ shortText: "fields.shortText.label",
+ phoneNumber: "fields.phoneNumber.label",
+ email: "fields.email.label",
+ url: "fields.url.label",
+}
+
+export function DefaultFieldsList() {
+ const t = useTranslations()
+
+ return (
+
+
+
+ {t("customFields.defaultsTitle")}
+
+
+
+
+
+
+
+ {t("customFields.defaultsTooltip")}
+
+
+
+
+
+ {DEFAULT_FIELDS.map((field, idx) => {
+ const Icon = field.icon
+ return (
+
+
+
+ {t(field.labelKey)}
+
+
+ {field.fieldId}
+
+
+ {t(TYPE_LABEL[field.type] ?? "fields.shortText.label")}
+
+
+
+
+
+
+
+ {t("customFields.defaultsLockTooltip")}
+
+
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/apps/builder/src/features/custom-fields/components/values-editor.tsx b/apps/builder/src/features/custom-fields/components/values-editor.tsx
new file mode 100644
index 000000000..8648d68cf
--- /dev/null
+++ b/apps/builder/src/features/custom-fields/components/values-editor.tsx
@@ -0,0 +1,98 @@
+"use client"
+
+import { Badge } from "@chatbotx.io/ui/components/ui/badge"
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import { Input } from "@chatbotx.io/ui/components/ui/input"
+import { Label } from "@chatbotx.io/ui/components/ui/label"
+import { PlusIcon, XIcon } from "lucide-react"
+import { useTranslations } from "next-intl"
+import { type KeyboardEvent, useState } from "react"
+
+type ValuesEditorProps = {
+ value: string[]
+ onChange: (next: string[]) => void
+ label?: string
+ placeholder?: string
+}
+
+/**
+ * Editor de opções pré-definidas pro custom field tipo "list".
+ * Input + chips removíveis. Enter ou clique no + adiciona.
+ */
+export function ValuesEditor({
+ value,
+ onChange,
+ label,
+ placeholder,
+}: ValuesEditorProps) {
+ const t = useTranslations()
+ const [draft, setDraft] = useState("")
+
+ const handleAdd = () => {
+ const trimmed = draft.trim()
+ if (!trimmed) {
+ return
+ }
+ if (value.includes(trimmed)) {
+ setDraft("")
+ return
+ }
+ onChange([...value, trimmed])
+ setDraft("")
+ }
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Enter") {
+ e.preventDefault()
+ handleAdd()
+ }
+ }
+
+ const handleRemove = (item: string) => {
+ onChange(value.filter((v) => v !== item))
+ }
+
+ return (
+
+ {label &&
{label} }
+
+
setDraft(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder={placeholder ?? t("customFields.valuesPlaceholder")}
+ value={draft}
+ />
+
+
+
+
+ {value.length > 0 && (
+
+ {value.map((item) => (
+
+ {item}
+ handleRemove(item)}
+ type="button"
+ >
+
+
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/apps/builder/src/features/custom-fields/contact-custom-field-manage.tsx b/apps/builder/src/features/custom-fields/contact-custom-field-manage.tsx
index 60a95c6a8..9ce2e10da 100644
--- a/apps/builder/src/features/custom-fields/contact-custom-field-manage.tsx
+++ b/apps/builder/src/features/custom-fields/contact-custom-field-manage.tsx
@@ -78,9 +78,9 @@ export function ContactCustomFieldManage({
-
+
- No record found.
+ Nenhum registro encontrado.
{options.map((option) => (
[
- {
- value: "shortText",
- label: t("fields.shortText.label"),
- },
- {
- value: "number",
- label: t("fields.number.label"),
- },
- {
- value: "date",
- label: t("fields.date.label"),
- },
- {
- value: "datetime",
- label: t("fields.datetime.label"),
- },
- {
- value: "boolean",
- label: t("fields.boolean.label"),
- },
- {
- value: "longText",
- label: t("fields.longText.label"),
- },
+ { value: "shortText", label: t("fields.shortText.label") },
+ { value: "longText", label: t("fields.longText.label") },
+ { value: "list", label: t("fields.list.label") },
+ { value: "boolean", label: t("fields.boolean.label") },
+ { value: "email", label: t("fields.email.label") },
+ { value: "phoneNumber", label: t("fields.phoneNumber.label") },
+ { value: "number", label: t("fields.number.label") },
+ { value: "url", label: t("fields.url.label") },
+ { value: "date", label: t("fields.date.label") },
+ { value: "datetime", label: t("fields.datetime.label") },
+ { value: "time", label: t("fields.time.label") },
+ ],
+ [t],
+ )
+
+ const visibilityOptions = useMemo(
+ () => [
+ { value: "alwaysShow", label: t("fields.visibility.alwaysShow") },
+ { value: "alwaysHide", label: t("fields.visibility.alwaysHide") },
+ { value: "hideWhenEmpty", label: t("fields.visibility.hideWhenEmpty") },
],
[t],
)
@@ -155,13 +154,16 @@ function CreateCustomFieldForm({
type: "shortText",
description: "",
folderId: null,
+ visibility: "alwaysShow",
+ values: [],
},
},
errorMapProps: {},
},
)
- const { setValue } = form
+ const { setValue, control } = form
+ const watchedType = useWatch({ control, name: "type" })
useEffect(() => {
if (folderId && folderId !== rootFolderId) {
@@ -186,12 +188,32 @@ function CreateCustomFieldForm({
required
/>
+ {watchedType === "list" && (
+ (
+
+ )}
+ />
+ )}
+
+
+
onClose?.()}
diff --git a/apps/builder/src/features/custom-fields/custom-field-table-toolbar-actions.tsx b/apps/builder/src/features/custom-fields/custom-field-table-toolbar-actions.tsx
index b1af1b01a..b5e3144c8 100644
--- a/apps/builder/src/features/custom-fields/custom-field-table-toolbar-actions.tsx
+++ b/apps/builder/src/features/custom-fields/custom-field-table-toolbar-actions.tsx
@@ -1,11 +1,6 @@
"use client"
-import { Button } from "@chatbotx.io/ui/components/ui/button"
import type { Table } from "@tanstack/react-table"
-import { FolderUpIcon } from "lucide-react"
-import { useTranslations } from "next-intl"
-import { useState } from "react"
-import { ChangeFolderDialog } from "../folders/change-folder"
import { DeleteFieldsDialog } from "./delete-fields-dialog"
import type { CustomFieldResource } from "./schemas/resource"
@@ -14,42 +9,25 @@ type CustomFieldsTableToolbarActionsProps = {
workspaceId: string
}
+/**
+ * Toolbar quando há linhas selecionadas — só Excluir.
+ * Pedro removeu o conceito de pasta de /custom-fields em 2026-05-23, então
+ * o botão "Mover" + ChangeFolderDialog foram removidos junto.
+ */
export function CustomFieldsTableToolbarActions({
table,
workspaceId,
- // setRowAction,
}: CustomFieldsTableToolbarActionsProps) {
- const t = useTranslations()
- const [openChangeFolder, setOpenChangeFolder] = useState(false)
-
return (
{table.getFilteredSelectedRowModel().rows.length > 0 ? (
- <>
- table.toggleAllRowsSelected(false)}
- records={table
- .getFilteredSelectedRowModel()
- .rows.map((row) => row.original)}
- workspaceId={workspaceId}
- />
- row.original.id)}
- onOpenChange={setOpenChangeFolder}
- open={openChangeFolder}
- trigger={
-
-
- {t("actions.move")}
-
- }
- workspaceId={workspaceId}
- />
- >
+ table.toggleAllRowsSelected(false)}
+ records={table
+ .getFilteredSelectedRowModel()
+ .rows.map((row) => row.original)}
+ workspaceId={workspaceId}
+ />
) : null}
)
diff --git a/apps/builder/src/features/custom-fields/custom-field-table.tsx b/apps/builder/src/features/custom-fields/custom-field-table.tsx
index b06163793..6da3d0a80 100644
--- a/apps/builder/src/features/custom-fields/custom-field-table.tsx
+++ b/apps/builder/src/features/custom-fields/custom-field-table.tsx
@@ -5,12 +5,6 @@ import { DataTable } from "@chatbotx.io/ui/components/data-table/data-table"
import { DataTableColumnHeader } from "@chatbotx.io/ui/components/data-table/data-table-column-header"
import { DataTableToolbar } from "@chatbotx.io/ui/components/data-table/data-table-toolbar"
import { Button } from "@chatbotx.io/ui/components/ui/button"
-import {
- Card,
- CardContent,
- CardHeader,
- CardTitle,
-} from "@chatbotx.io/ui/components/ui/card"
import { Checkbox } from "@chatbotx.io/ui/components/ui/checkbox"
import {
DropdownMenu,
@@ -18,7 +12,6 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@chatbotx.io/ui/components/ui/dropdown-menu"
-import { Switch } from "@chatbotx.io/ui/components/ui/switch"
import {
Tooltip,
TooltipContent,
@@ -27,16 +20,12 @@ import {
import { useDataTable } from "@chatbotx.io/ui/hooks/use-data-table"
import type { DataTableRowAction } from "@chatbotx.io/ui/types/data-table"
import type { ColumnDef } from "@tanstack/react-table"
-import {
- FolderUpIcon,
- MoreHorizontalIcon,
- PencilIcon,
- Trash2Icon,
-} from "lucide-react"
-import { useTranslations } from "next-intl"
+import { MoreHorizontalIcon, PencilIcon, Trash2Icon } from "lucide-react"
+import { useLocale, useTranslations } from "next-intl"
import { use, useMemo, useState } from "react"
-import { ChangeFolderDialog } from "../folders/change-folder"
import CustomFieldTypeLabel from "./components/custom-field-label"
+import { CustomizeContactFieldsDialog } from "./components/customize-contact-fields-dialog"
+import { DefaultFieldsList } from "./components/default-fields-list"
import { CreateCustomFieldDialog } from "./create-custom-field"
import { CustomFieldsTableToolbarActions } from "./custom-field-table-toolbar-actions"
import { DeleteFieldsDialog } from "./delete-fields-dialog"
@@ -44,32 +33,55 @@ import type { listCustomFieldsRSC } from "./queries"
import type { CustomFieldResource } from "./schemas/resource"
import { UpdateCustomFieldDialog } from "./update-custom-field-dialog"
+// Map visibility 3-state → i18n key. Top-level pra evitar nested ternary
+// e não invalidar useMemo das columns a cada render.
+const VISIBILITY_LABEL_KEY: Record = {
+ alwaysShow: "fields.visibility.alwaysShow",
+ alwaysHide: "fields.visibility.alwaysHide",
+ hideWhenEmpty: "fields.visibility.hideWhenEmpty",
+}
+
+// Formata datas no padrão Respond.io ("abr 21, 2026"). Função pura no
+// top-level pra não invalidar o useMemo das columns a cada render.
+function formatRespondDate(
+ value: Date | string | null,
+ locale: string,
+): string {
+ if (!value) {
+ return "—"
+ }
+ const d = typeof value === "string" ? new Date(value) : value
+ if (Number.isNaN(d.getTime())) {
+ return "—"
+ }
+ return new Intl.DateTimeFormat(locale, {
+ month: "short",
+ day: "2-digit",
+ year: "numeric",
+ }).format(d)
+}
+
type FieldsTableProps = {
promises: Promise<[Awaited>]>
workspaceId: string
folderId: string | null
+ initialHiddenKeys: string[]
+ /** Lista completa (não paginada) pra alimentar o dialog Personalizar visualização. */
+ allCustomFields: CustomFieldResource[]
}
export function CustomFieldsTable({
promises,
workspaceId,
folderId,
+ initialHiddenKeys,
+ allCustomFields,
}: FieldsTableProps) {
const t = useTranslations()
+ const locale = useLocale()
const [{ data, pageCount }] = use(promises)
const [rowAction, setRowAction] =
useState | null>(null)
- // const [_, copyFieldId] = useCopyToClipboard()
-
- // const handleCopy = (id: string) => {
- // copyFieldId(id)
- // .then(() => {
- // toast.success("Copied to clipboard!")
- // })
- // .catch(() => {
- // toast.error("Failed to copy!")
- // })
- // }
const columns = useMemo[]>(
() => [
@@ -77,7 +89,7 @@ export function CustomFieldsTable({
id: "select",
header: ({ table: innerTable }) => (
(
row.toggleSelected(Boolean(value))}
/>
@@ -111,7 +123,7 @@ export function CustomFieldsTable({
-
+
{row.original.name}
@@ -130,6 +142,34 @@ export function CustomFieldsTable({
enableSorting: true,
enableHiding: false,
},
+ {
+ // "ID do campo" — slug user-facing imutável (gap #11 — 2026-05-27).
+ // Usado em APIs/integrações/variáveis de template; muito mais
+ // legível que o Snowflake `id` interno.
+ id: "fieldId",
+ accessorKey: "fieldId",
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => (
+
+
+
+ {row.original.fieldId}
+
+
+
+ {row.original.fieldId}
+
+
+ ),
+ size: 140,
+ enableSorting: false,
+ enableHiding: false,
+ },
{
id: "description",
accessorKey: "description",
@@ -176,30 +216,59 @@ export function CustomFieldsTable({
enableHiding: false,
},
{
- id: "Inbox",
- accessorKey: "showInInbox",
+ id: "visibility",
+ accessorKey: "visibility",
header: ({ column }) => (
),
- cell: ({ row }) =>
,
+ // Visibilidade 3-state Respond.io: alwaysShow / alwaysHide /
+ // hideWhenEmpty. Schema migrou de boolean showInInbox em 2026-05-27.
+ cell: ({ row }) => (
+
+ {t(
+ VISIBILITY_LABEL_KEY[row.original.visibility] ??
+ "fields.visibility.alwaysShow",
+ )}
+
+ ),
enableSorting: false,
enableHiding: false,
meta: {
- label: t("fields.inbox.label"),
+ label: t("fields.visibility.label"),
+ },
+ },
+ {
+ id: "addedAt",
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => (
+
+ {formatRespondDate(row.original.createdAt, locale)}
+
+ ),
+ size: 140,
+ enableSorting: true,
+ meta: {
+ label: t("fields.addedAt.label"),
},
},
{
id: "actions",
- header: "Actions",
+ header: "Ações",
cell: ({ row }) => (
- Open menu
+ Abrir menu
@@ -209,12 +278,7 @@ export function CustomFieldsTable({
{t("actions.edit")}
- setRowAction({ row, variant: "move" })}
- >
-
- {t("actions.move")}
-
+ {/* Item "Mover" (folder) removido — Pedro tirou pastas de /custom-fields */}
setRowAction({ row, variant: "delete" })}
variant="destructive"
@@ -230,7 +294,7 @@ export function CustomFieldsTable({
enableHiding: false,
},
],
- [t],
+ [t, locale],
)
const { table } = useDataTable({
@@ -246,55 +310,53 @@ export function CustomFieldsTable({
clearOnDefault: true,
})
+ // Header de página pixel-perfect Respond.io (Camada 2 — Dados Mestres).
+ // UI flat (sem Card wrapper) — heading só identifica a seção em /settings/.
return (
-
-
-
+
+
+
+
+
+
+
+
+
+
-
setRowAction(null)}
- open={rowAction?.variant === "update"}
- workspaceId={workspaceId}
- />
+ setRowAction(null)}
+ onSuccess={() => rowAction?.row.toggleSelected(false)}
+ open={rowAction?.variant === "delete"}
+ records={rowAction?.row.original ? [rowAction?.row.original] : []}
+ showTrigger={false}
+ workspaceId={workspaceId}
+ />
- setRowAction(null)}
- open={rowAction?.variant === "move"}
- workspaceId={workspaceId}
- />
-
-
+ setRowAction(null)}
+ open={rowAction?.variant === "update"}
+ workspaceId={workspaceId}
+ />
+ {/* ChangeFolderDialog removido — Pedro tirou pastas de /custom-fields 2026-05-23 */}
+
)
}
diff --git a/apps/builder/src/features/custom-fields/delete-fields-dialog.tsx b/apps/builder/src/features/custom-fields/delete-fields-dialog.tsx
index b402930b7..64437fc5c 100644
--- a/apps/builder/src/features/custom-fields/delete-fields-dialog.tsx
+++ b/apps/builder/src/features/custom-fields/delete-fields-dialog.tsx
@@ -91,7 +91,7 @@ export function DeleteFieldsDialog({
execute({ ids: records.map((r) => r.id) })}
size="sm"
diff --git a/apps/builder/src/features/custom-fields/provider/custom-field-hook.ts b/apps/builder/src/features/custom-fields/provider/custom-field-hook.ts
index 27e8ba923..aaaebdaca 100644
--- a/apps/builder/src/features/custom-fields/provider/custom-field-hook.ts
+++ b/apps/builder/src/features/custom-fields/provider/custom-field-hook.ts
@@ -8,7 +8,10 @@ import {
CalendarClockIcon,
CalendarDaysIcon,
CheckIcon,
+ ClockIcon,
HashIcon,
+ LinkIcon,
+ ListIcon,
type LucideIcon,
MailIcon,
PhoneIcon,
@@ -24,6 +27,9 @@ export const customFieldIconsMap: Record = {
number: HashIcon,
date: CalendarDaysIcon,
datetime: CalendarClockIcon,
+ time: ClockIcon,
+ url: LinkIcon,
+ list: ListIcon,
boolean: CheckIcon,
email: MailIcon,
phoneNumber: PhoneIcon,
@@ -114,6 +120,11 @@ const reservedCustomFieldIds: {
type: "shortText",
labelKey: "fields.currentTime.label",
},
+ {
+ id: systemFieldTypes.enum.lifecycle_stage,
+ type: "shortText",
+ labelKey: "lifecycle.title",
+ },
]
export const useCustomFieldSelectOptions = (
diff --git a/apps/builder/src/features/custom-fields/provider/custom-field-store.ts b/apps/builder/src/features/custom-fields/provider/custom-field-store.ts
index 89e471400..f36086f8a 100644
--- a/apps/builder/src/features/custom-fields/provider/custom-field-store.ts
+++ b/apps/builder/src/features/custom-fields/provider/custom-field-store.ts
@@ -44,7 +44,7 @@ export const createCustomFieldStore = (props: Partial) =>
error:
error instanceof HTTPError
? error.message
- : "Failed to fetch custom fields",
+ : "Falha ao buscar campos personalizados",
})
} finally {
set({ initialized: true })
@@ -79,7 +79,7 @@ export const createCustomFieldStore = (props: Partial) =>
error:
error instanceof HTTPError
? error.message
- : "Failed to fetch custom fields",
+ : "Falha ao buscar campos personalizados",
})
} finally {
set({ loading: false })
diff --git a/apps/builder/src/features/custom-fields/queries/visibility.ts b/apps/builder/src/features/custom-fields/queries/visibility.ts
new file mode 100644
index 000000000..8ad364f69
--- /dev/null
+++ b/apps/builder/src/features/custom-fields/queries/visibility.ts
@@ -0,0 +1,21 @@
+import { workspaceContactFieldVisibilityService } from "@chatbotx.io/business"
+
+/**
+ * Lista pares (fieldKey, visibility) gravados pro workspace.
+ * Items ausentes = default `showAlways`.
+ */
+export async function listContactFieldVisibility(workspaceId: string) {
+ return await workspaceContactFieldVisibilityService.listByWorkspaceId({
+ workspaceId,
+ })
+}
+
+/**
+ * Atalho: só as `fieldKey`s marcadas como `alwaysHide`.
+ * Usado pelo drawer pra separar visíveis x ocultos.
+ */
+export async function listHiddenContactFieldKeys(workspaceId: string) {
+ return await workspaceContactFieldVisibilityService.listHiddenFieldKeys({
+ workspaceId,
+ })
+}
diff --git a/apps/builder/src/features/custom-fields/schemas/action.ts b/apps/builder/src/features/custom-fields/schemas/action.ts
index 8986ecfde..769c5da88 100644
--- a/apps/builder/src/features/custom-fields/schemas/action.ts
+++ b/apps/builder/src/features/custom-fields/schemas/action.ts
@@ -1,4 +1,7 @@
-import { customFieldTypes } from "@chatbotx.io/database/partials"
+import {
+ customFieldTypes,
+ customFieldVisibilities,
+} from "@chatbotx.io/database/partials"
import { zodBigintAsString } from "@chatbotx.io/utils"
import { z } from "zod"
@@ -7,6 +10,9 @@ export const createCustomFieldRequest = z.object({
type: customFieldTypes,
folderId: zodBigintAsString().nullish(),
description: z.string().nullish(),
+ visibility: customFieldVisibilities.optional(),
+ // Pra type="list" — opções pré-definidas (multi-select). Outros tipos ignoram.
+ values: z.array(z.string().trim().min(1)).max(100).optional(),
})
export type CreateCustomFieldRequest = z.infer
@@ -21,5 +27,7 @@ export const updateCustomFieldRequest = z.object({
name: z.string().trim().min(1).max(255),
description: z.string().optional(),
folderId: zodBigintAsString().nullish(),
+ visibility: customFieldVisibilities.optional(),
+ values: z.array(z.string().trim().min(1)).max(100).optional(),
})
export type UpdateCustomFieldRequest = z.infer
diff --git a/apps/builder/src/features/custom-fields/schemas/visibility.ts b/apps/builder/src/features/custom-fields/schemas/visibility.ts
new file mode 100644
index 000000000..cba63da56
--- /dev/null
+++ b/apps/builder/src/features/custom-fields/schemas/visibility.ts
@@ -0,0 +1,26 @@
+import z from "zod"
+
+/**
+ * Schema da action de update de visibilidade dos campos de contato.
+ * Mora em arquivo SEPARADO pra evitar o quirk do Next 16 standalone
+ * (z.object inline em "use server" file corrompe a chunk).
+ */
+export const contactFieldVisibilityEnum = z.enum(["showAlways", "alwaysHide"])
+export type ContactFieldVisibilityValue = z.infer<
+ typeof contactFieldVisibilityEnum
+>
+
+export const contactFieldVisibilityItem = z.object({
+ fieldKey: z.string().min(1).max(128),
+ visibility: contactFieldVisibilityEnum,
+})
+export type ContactFieldVisibilityItem = z.infer<
+ typeof contactFieldVisibilityItem
+>
+
+export const updateContactFieldsVisibilityRequest = z.object({
+ items: z.array(contactFieldVisibilityItem).max(200),
+})
+export type UpdateContactFieldsVisibilityRequest = z.infer<
+ typeof updateContactFieldsVisibilityRequest
+>
diff --git a/apps/builder/src/features/custom-fields/update-custom-field-dialog.tsx b/apps/builder/src/features/custom-fields/update-custom-field-dialog.tsx
index eb12c744a..ada7bd04c 100644
--- a/apps/builder/src/features/custom-fields/update-custom-field-dialog.tsx
+++ b/apps/builder/src/features/custom-fields/update-custom-field-dialog.tsx
@@ -1,6 +1,7 @@
"use client"
import { InputField } from "@chatbotx.io/ui/components/form/input-field"
+import { SelectField } from "@chatbotx.io/ui/components/form/select-field"
import { TextareaField } from "@chatbotx.io/ui/components/form/textarea-field"
import { Button } from "@chatbotx.io/ui/components/ui/button"
import {
@@ -16,9 +17,11 @@ import { useHookFormAction } from "@next-safe-action/adapter-react-hook-form/hoo
import { Loader2Icon } from "lucide-react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
-import { useEffect } from "react"
+import { useEffect, useMemo } from "react"
+import { Controller } from "react-hook-form"
import { toast } from "sonner"
import { updateCustomFieldAction } from "./actions/update-custom-field.action"
+import { ValuesEditor } from "./components/values-editor"
import { updateCustomFieldRequest } from "./schemas/action"
import type { CustomFieldResource } from "./schemas/resource"
@@ -70,13 +73,38 @@ export function UpdateCustomFieldDialog({
},
)
+ const visibilityOptions = useMemo(
+ () => [
+ { value: "alwaysShow", label: t("fields.visibility.alwaysShow") },
+ { value: "alwaysHide", label: t("fields.visibility.alwaysHide") },
+ { value: "hideWhenEmpty", label: t("fields.visibility.hideWhenEmpty") },
+ ],
+ [t],
+ )
+
useEffect(() => {
if (customField) {
setValue("name", customField.name)
setValue("description", customField.description ?? "")
+ setValue(
+ "visibility",
+ (customField as unknown as { visibility?: string }).visibility as
+ | "alwaysShow"
+ | "alwaysHide"
+ | "hideWhenEmpty"
+ | undefined,
+ )
+ setValue(
+ "values",
+ ((customField as unknown as { values?: string[] }).values ??
+ []) as string[],
+ )
}
}, [customField, setValue])
+ const isList =
+ (customField as unknown as { type?: string } | null)?.type === "list"
+
return (
@@ -101,12 +129,32 @@ export function UpdateCustomFieldDialog({
required
/>
+ {isList && (
+ (
+
+ )}
+ />
+ )}
+
+
+
onOpenChange(false)}
diff --git a/apps/builder/src/features/error-logs/api/workspace-token.ts b/apps/builder/src/features/error-logs/api/workspace-token.ts
index 16afd7c13..7f1515e6a 100644
--- a/apps/builder/src/features/error-logs/api/workspace-token.ts
+++ b/apps/builder/src/features/error-logs/api/workspace-token.ts
@@ -10,8 +10,8 @@ export const errorLogsWorkspaceTokenAPIs = {
.route({
method: "GET",
path: "/v1/error-logs",
- summary: "List error logs",
- tags: ["Error Logs"],
+ summary: "Listar logs de erro",
+ tags: ["Logs de Erro"],
})
.input(listErrorLogsRequest.omit({ workspaceId: true }))
.output(publicListErrorLogsResponse)
diff --git a/apps/builder/src/features/error-logs/delete-error-logs.tsx b/apps/builder/src/features/error-logs/delete-error-logs.tsx
index 32a441a05..46cbdc842 100644
--- a/apps/builder/src/features/error-logs/delete-error-logs.tsx
+++ b/apps/builder/src/features/error-logs/delete-error-logs.tsx
@@ -86,7 +86,7 @@ export function DeleteErrorLogsDialog({
execute({
diff --git a/apps/builder/src/features/error-logs/error-logs-table-columns.tsx b/apps/builder/src/features/error-logs/error-logs-table-columns.tsx
index 9a606a3ce..eb3e8c748 100644
--- a/apps/builder/src/features/error-logs/error-logs-table-columns.tsx
+++ b/apps/builder/src/features/error-logs/error-logs-table-columns.tsx
@@ -39,7 +39,7 @@ export function getColumns({
id: "select",
header: ({ table }) => (
(
row.toggleSelected(Boolean(value))}
@@ -66,7 +66,7 @@ export function getColumns({
id: "action",
accessorKey: "type",
header: ({ column }) => (
-
+
),
cell: ({ row }) => {row.original.action}
,
meta: {
@@ -81,7 +81,7 @@ export function getColumns({
{
accessorKey: "detail",
header: ({ column }) => (
-
+
),
cell: ({ row }) => (
@@ -103,7 +103,7 @@ export function getColumns({
id: "contact",
accessorKey: "contact",
header: ({ column }) => (
-
+
),
cell: ({ row }) => (
{row.original.contactId ? : null}
@@ -118,7 +118,7 @@ export function getColumns({
{
accessorKey: "createdAt",
header: ({ column }) => (
-
+
),
cell: ({ row }) => format(row.original.createdAt, "yyyy/MM/dd HH:mm"),
meta: {
@@ -129,12 +129,12 @@ export function getColumns({
},
{
id: "actions",
- header: "Actions",
+ header: "Ações",
cell: ({ row }) => (
diff --git a/apps/builder/src/features/flows/actions/create-flow-action.ts b/apps/builder/src/features/flows/actions/create-flow-action.ts
index 62bcdc8f1..38bfae762 100644
--- a/apps/builder/src/features/flows/actions/create-flow-action.ts
+++ b/apps/builder/src/features/flows/actions/create-flow-action.ts
@@ -6,7 +6,7 @@ import {
flowModel,
flowVersionModel,
} from "@chatbotx.io/database/schema"
-import { sendMessageNodeDefaultFn } from "@chatbotx.io/flow-config"
+import { triggerNodeDefaultFn } from "@chatbotx.io/flow-config"
import { createId } from "@chatbotx.io/utils"
import {
type WorkspaceIdRequestParams,
@@ -32,9 +32,11 @@ export const createFlowAction = workspaceActionClient
await ensureFolderIsExists(parsedInput.folderId, workspaceId, "flow")
}
- const defaultNode = sendMessageNodeDefaultFn({
+ // Novo fluxo SEMPRE começa com TriggerNode (replica Respond.io).
+ // Tipo default: conversationOpened (mais comum). User customiza no editor.
+ const defaultNode = triggerNodeDefaultFn({
dataProps: {
- name: "Send Message #1",
+ name: "Gatilho",
isStartNode: true,
},
})
diff --git a/apps/builder/src/features/flows/actions/duplicate-flow.action.ts b/apps/builder/src/features/flows/actions/duplicate-flow.action.ts
index 954cffa01..00ab300a1 100644
--- a/apps/builder/src/features/flows/actions/duplicate-flow.action.ts
+++ b/apps/builder/src/features/flows/actions/duplicate-flow.action.ts
@@ -30,7 +30,7 @@ export const duplicateFlow = async (ctx: {
id: ctx.id,
workspaceId: ctx.workspaceId,
},
- message: "Flow not found",
+ message: "Fluxo não encontrado",
})
const draftVersion = await findOrFail({
@@ -39,7 +39,7 @@ export const duplicateFlow = async (ctx: {
flowId: flow.id,
isDraft: true,
},
- message: "Draft version not found",
+ message: "Versão rascunho não encontrada",
})
await db.transaction(async (tx) => {
diff --git a/apps/builder/src/features/flows/actions/publish-flow-action.ts b/apps/builder/src/features/flows/actions/publish-flow-action.ts
index 31721838e..5d7fa32d0 100644
--- a/apps/builder/src/features/flows/actions/publish-flow-action.ts
+++ b/apps/builder/src/features/flows/actions/publish-flow-action.ts
@@ -1,8 +1,12 @@
"use server"
-import { notFoundException } from "@chatbotx.io/business/errors"
+import {
+ badRequestException,
+ notFoundException,
+} from "@chatbotx.io/business/errors"
import { and, db, eq } from "@chatbotx.io/database/client"
import { flowModel, flowVersionModel } from "@chatbotx.io/database/schema"
+import { nodeTypeSchema } from "@chatbotx.io/flow-config"
import { createId, zodBigintAsString } from "@chatbotx.io/utils"
import { revalidateCacheTags } from "@/lib/cache-helper"
import { workspaceActionClient } from "@/lib/safe-action"
@@ -34,7 +38,7 @@ export const publishFlow = async (ctx: { workspaceId: string; id: string }) => {
})
if (!flow || flow.flowVersions.length === 0) {
- throw notFoundException("Flow not found")
+ throw notFoundException("Fluxo não encontrado")
}
const draftVersion = flow.flowVersions[0]
@@ -43,6 +47,32 @@ export const publishFlow = async (ctx: { workspaceId: string; id: string }) => {
edges: draftVersion?.edges,
})
+ // Validação estilo Respond.io: todo flow precisa ter EXATAMENTE 1 TriggerNode
+ // como entry point, com pelo menos 1 edge saindo. Sem isso, o flow não
+ // dispara automaticamente — fica órfão.
+ const triggerNodes = validated.nodes.filter(
+ (n) => n.type === nodeTypeSchema.enum.trigger,
+ )
+ if (triggerNodes.length === 0) {
+ throw badRequestException(
+ "O fluxo precisa ter um Gatilho como primeiro bloco. Configure o tipo de evento que dispara o fluxo.",
+ )
+ }
+ if (triggerNodes.length > 1) {
+ throw badRequestException(
+ "O fluxo só pode ter um Gatilho. Remova os Gatilhos extras antes de publicar.",
+ )
+ }
+ const triggerNode = triggerNodes[0]
+ const hasOutEdge = validated.edges.some(
+ (e) => e.source === triggerNode.id || e.sourceHandle === triggerNode.id,
+ )
+ if (!hasOutEdge) {
+ throw badRequestException(
+ "O Gatilho precisa estar conectado a pelo menos um próximo bloco.",
+ )
+ }
+
await db.transaction(async (tx) => {
// Remove all other latest versions
await tx
diff --git a/apps/builder/src/features/flows/actions/trigger-shortcut-action.ts b/apps/builder/src/features/flows/actions/trigger-shortcut-action.ts
new file mode 100644
index 000000000..652e65d53
--- /dev/null
+++ b/apps/builder/src/features/flows/actions/trigger-shortcut-action.ts
@@ -0,0 +1,81 @@
+"use server"
+
+import {
+ badRequestException,
+ notFoundException,
+} from "@chatbotx.io/business/errors"
+import { db } from "@chatbotx.io/database/client"
+import { emitShortcut } from "@chatbotx.io/events"
+import { zodBigintAsString } from "@chatbotx.io/utils"
+import { z } from "zod"
+import { logger } from "@/lib/log"
+import { workspaceActionClient } from "@/lib/safe-action"
+
+const triggerShortcutSchema = z.object({
+ conversationId: zodBigintAsString(),
+ flowId: zodBigintAsString(),
+})
+export type TriggerShortcutInput = z.infer
+
+/**
+ * Server action que o agente clica no header do Inbox pra disparar um flow
+ * com TriggerNode tipo "shortcut" pra um contato específico.
+ *
+ * Valida:
+ * - Conversa existe no workspace
+ * - Flow está ativo + tem currentVersionId
+ * - Flow tem TriggerNode tipo "shortcut" (não dispara flows "regulares")
+ *
+ * Emite `emitShortcut` — o `FlowEventDispatcher` no worker matcha o flow
+ * exato (`metadata.flowId === flow.id` no filtro de shortcut) e roda.
+ */
+export const triggerShortcutAction = workspaceActionClient
+ .bindArgsSchemas([zodBigintAsString()])
+ .inputSchema(triggerShortcutSchema)
+ .action(async ({ bindArgsParsedInputs, parsedInput, ctx }) => {
+ const [workspaceId] = bindArgsParsedInputs
+ const { conversationId, flowId } = parsedInput
+
+ const conversation = await db.query.conversationModel.findFirst({
+ where: { id: conversationId, workspaceId },
+ })
+ if (!conversation) {
+ throw notFoundException("Conversa não encontrada")
+ }
+
+ const flow = await db.query.flowModel.findFirst({
+ where: { id: flowId, workspaceId, active: true },
+ with: { flowVersion: true },
+ })
+ if (!(flow?.currentVersionId && flow.flowVersion)) {
+ throw notFoundException("Fluxo não encontrado ou inativo")
+ }
+
+ const nodes = (flow.flowVersion.nodes ?? []) as Array<{
+ type?: string
+ data?: { details?: { triggerType?: string } }
+ }>
+ const triggerNode = nodes.find((n) => n?.type === "trigger")
+ const triggerType = triggerNode?.data?.details?.triggerType
+ if (triggerType !== "shortcut") {
+ throw badRequestException(
+ "Este fluxo não é um atalho. Configure o Gatilho do tipo Atalho para usar aqui.",
+ )
+ }
+
+ try {
+ await emitShortcut(
+ workspaceId,
+ conversation.contactId,
+ conversation.id,
+ flow.id,
+ ctx.user.id,
+ )
+ } catch (error) {
+ logger.error(
+ { err: error },
+ "[triggerShortcutAction] Falha ao emitir evento shortcut",
+ )
+ throw badRequestException("Falha ao disparar o atalho")
+ }
+ })
diff --git a/apps/builder/src/features/flows/actions/update-draft-flow-version-action.ts b/apps/builder/src/features/flows/actions/update-draft-flow-version-action.ts
index 2e4407b3a..575249055 100644
--- a/apps/builder/src/features/flows/actions/update-draft-flow-version-action.ts
+++ b/apps/builder/src/features/flows/actions/update-draft-flow-version-action.ts
@@ -35,7 +35,7 @@ export const updateDraftFlowVersion = async (
workspaceId: ctx.workspaceId,
isDraft: true,
},
- message: "Draft flow version not found",
+ message: "Versão rascunho do fluxo não encontrada",
})
await db
diff --git a/apps/builder/src/features/flows/actions/update-flow-action.ts b/apps/builder/src/features/flows/actions/update-flow-action.ts
index a7880979b..805266823 100644
--- a/apps/builder/src/features/flows/actions/update-flow-action.ts
+++ b/apps/builder/src/features/flows/actions/update-flow-action.ts
@@ -32,7 +32,7 @@ const updateFlow = async (
id: ctx.id,
workspaceId: ctx.workspaceId,
},
- message: "Flow not found",
+ message: "Fluxo não encontrado",
})
await db.update(flowModel).set(parsedInput).where(eq(flowModel.id, flow.id))
diff --git a/apps/builder/src/features/flows/api/private.ts b/apps/builder/src/features/flows/api/private.ts
index a66c97130..c37706d62 100644
--- a/apps/builder/src/features/flows/api/private.ts
+++ b/apps/builder/src/features/flows/api/private.ts
@@ -16,8 +16,8 @@ export const privateFlowsAPI = {
.route({
method: "GET",
path: "/workspaces/{workspaceId}/flows",
- summary: "List flows",
- tags: ["Flows"],
+ summary: "Listar fluxos",
+ tags: ["Fluxos"],
})
.input(listFlowsRequest.and(withWorkspaceIdSchema))
.use(workspaceAuthorizedMidddleware, (input) => input.workspaceId)
@@ -32,8 +32,8 @@ export const privateFlowsAPI = {
.route({
method: "GET",
path: "/workspaces/{workspaceId}/flows/{flowId}/stats",
- summary: "Get flow stats",
- tags: ["Flows"],
+ summary: "Obter estatísticas do fluxo",
+ tags: ["Fluxos"],
})
.input(flowStatsRequest)
.output(flowNodeStatsResponse)
@@ -50,8 +50,8 @@ export const privateFlowsAPI = {
.route({
method: "GET",
path: "/workspaces/{workspaceId}/flows/{flowId}/contacts",
- summary: "Get flow event contacts",
- tags: ["Flows"],
+ summary: "Obter contatos do evento do fluxo",
+ tags: ["Fluxos"],
})
.input(flowContactStatsRequest)
.output(listFlowNodeContactsResponse)
diff --git a/apps/builder/src/features/flows/api/workspace-token.ts b/apps/builder/src/features/flows/api/workspace-token.ts
index 2ce0012f7..12beb0b70 100644
--- a/apps/builder/src/features/flows/api/workspace-token.ts
+++ b/apps/builder/src/features/flows/api/workspace-token.ts
@@ -8,8 +8,8 @@ const flowWorkspaceTokenAPIs = {
.route({
method: "GET",
path: "/v1/flows",
- summary: "Get all flows",
- tags: ["Flows"],
+ summary: "Obter todos os fluxos",
+ tags: ["Fluxos"],
})
.input(z.object({}))
.output(
diff --git a/apps/builder/src/features/flows/delete-flow-dialog.tsx b/apps/builder/src/features/flows/delete-flow-dialog.tsx
index 559f7aead..a0b2770b6 100644
--- a/apps/builder/src/features/flows/delete-flow-dialog.tsx
+++ b/apps/builder/src/features/flows/delete-flow-dialog.tsx
@@ -91,7 +91,7 @@ export function DeleteFlowsDialog({
execute({ ids: flows.map((f) => f.id) })}
size="sm"
diff --git a/apps/builder/src/features/flows/flows-table-columns.tsx b/apps/builder/src/features/flows/flows-table-columns.tsx
index d9ca52897..ae65be54c 100644
--- a/apps/builder/src/features/flows/flows-table-columns.tsx
+++ b/apps/builder/src/features/flows/flows-table-columns.tsx
@@ -47,7 +47,7 @@ export function getFlowColumns({
id: "select",
header: ({ table }) => (
(
row.toggleSelected(Boolean(value))}
@@ -198,12 +198,12 @@ export function getFlowColumns({
},
{
id: "actions",
- header: "Actions",
+ header: "Ações",
cell: ({ row }) => (
diff --git a/apps/builder/src/features/flows/flows-table.tsx b/apps/builder/src/features/flows/flows-table.tsx
index 0d1d163e3..33b436f63 100644
--- a/apps/builder/src/features/flows/flows-table.tsx
+++ b/apps/builder/src/features/flows/flows-table.tsx
@@ -2,12 +2,6 @@
import { DataTable } from "@chatbotx.io/ui/components/data-table/data-table"
import { DataTableToolbar } from "@chatbotx.io/ui/components/data-table/data-table-toolbar"
-import {
- Card,
- CardContent,
- CardHeader,
- CardTitle,
-} from "@chatbotx.io/ui/components/ui/card"
import { useDataTable } from "@chatbotx.io/ui/hooks/use-data-table"
import type { DataTableRowAction } from "@chatbotx.io/ui/types/data-table"
import { useRouter } from "next/navigation"
@@ -55,52 +49,47 @@ export function FlowsTable({
clearOnDefault: true,
})
+ // Sem Card wrapper — Pedro pediu UI flat estilo Respond.io em todas as
+ // páginas (2026-05-24). ChangeFolderDialog mantido pra ação no row dropdown.
return (
-
-
- {t("flows.title")}
-
-
-
-
-
-
-
-
+ <>
+
+
+
+
+
+
- setRowAction(null)}
- onSuccess={() => {
- rowAction?.row.toggleSelected(false)
- router.refresh()
- }}
- open={rowAction?.variant === "delete"}
- showTrigger={false}
- workspaceId={workspaceId}
- />
+ setRowAction(null)}
+ onSuccess={() => {
+ rowAction?.row.toggleSelected(false)
+ router.refresh()
+ }}
+ open={rowAction?.variant === "delete"}
+ showTrigger={false}
+ workspaceId={workspaceId}
+ />
- setRowAction(null)}
- open={rowAction?.variant === "rename"}
- />
+ setRowAction(null)}
+ open={rowAction?.variant === "rename"}
+ />
- setRowAction(null)}
- open={rowAction?.variant === "move"}
- workspaceId={workspaceId}
- />
-
-
+ setRowAction(null)}
+ open={rowAction?.variant === "move"}
+ workspaceId={workspaceId}
+ />
+ >
)
}
diff --git a/apps/builder/src/features/flows/provider/flow-store.ts b/apps/builder/src/features/flows/provider/flow-store.ts
index cab5cdc09..d444f2eb8 100644
--- a/apps/builder/src/features/flows/provider/flow-store.ts
+++ b/apps/builder/src/features/flows/provider/flow-store.ts
@@ -49,7 +49,7 @@ export const createFlowStore = (props: Partial) =>
error:
error instanceof HTTPError
? error.message
- : "Failed to fetch flows",
+ : "Falha ao buscar fluxos",
})
} finally {
set({ initialized: true })
@@ -92,7 +92,7 @@ export const createFlowStore = (props: Partial) =>
error:
error instanceof HTTPError
? error.message
- : "Failed to fetch flows",
+ : "Falha ao buscar fluxos",
})
} finally {
set({ loading: false })
diff --git a/apps/builder/src/features/flows/queries/list-shortcut-flows.ts b/apps/builder/src/features/flows/queries/list-shortcut-flows.ts
new file mode 100644
index 000000000..a980664e3
--- /dev/null
+++ b/apps/builder/src/features/flows/queries/list-shortcut-flows.ts
@@ -0,0 +1,65 @@
+"use server"
+
+import { db } from "@chatbotx.io/database/client"
+
+/**
+ * Lista flows ativos do workspace cujo TriggerNode é do tipo "shortcut".
+ * Usado pelo botão "Atalhos" no header da conversa (estilo Respond.io).
+ *
+ * Como o triggerType vive dentro do JSON `nodes` da flowVersion, filtramos em
+ * memória após o fetch — o volume de flows por workspace é baixo (geralmente
+ * <30) então o custo é desprezível.
+ */
+export type ShortcutFlow = {
+ flowId: string
+ label: string
+ icon: string
+}
+
+type FlowNodeShape = {
+ type?: string
+ data?: {
+ details?: {
+ triggerType?: string
+ icon?: string
+ label?: string
+ }
+ }
+}
+
+export async function listShortcutFlows(
+ workspaceId: string,
+): Promise {
+ const flows = await db.query.flowModel.findMany({
+ where: {
+ workspaceId,
+ active: true,
+ },
+ with: {
+ flowVersion: true,
+ },
+ })
+
+ const result: ShortcutFlow[] = []
+ for (const flow of flows) {
+ if (!(flow.currentVersionId && flow.flowVersion)) {
+ continue
+ }
+ const nodes = (flow.flowVersion.nodes ?? []) as FlowNodeShape[]
+ const trigger = nodes.find((n) => n?.type === "trigger")
+ if (!trigger) {
+ continue
+ }
+ const details = trigger.data?.details
+ if (!details || details.triggerType !== "shortcut") {
+ continue
+ }
+
+ result.push({
+ flowId: flow.id,
+ label: details.label?.trim() || flow.name,
+ icon: details.icon?.trim() || "⚡",
+ })
+ }
+ return result
+}
diff --git a/apps/builder/src/features/flows/react-flow/edges/button-edge.tsx b/apps/builder/src/features/flows/react-flow/edges/button-edge.tsx
index 653ce913b..48df3a6c9 100644
--- a/apps/builder/src/features/flows/react-flow/edges/button-edge.tsx
+++ b/apps/builder/src/features/flows/react-flow/edges/button-edge.tsx
@@ -55,7 +55,7 @@ export default memo((props: EdgeProps) => {
y={centerY}
>
(false)
+ // "rename" foi removido daqui — agora o botão de renomear vive no FrameHeader
+ // ao lado do nome do flow (estilo Respond.io).
const [action, setAction] = useState<
| "publish"
- | "rename"
| "duplicate"
| "getDraftLink"
| "getPublishedLink"
@@ -67,7 +66,7 @@ export function FlowEditToolbar({
publishFlowAction.bind(null, workspaceId, flow.id),
{
onSuccess: () => {
- toast.success("A new version has been published")
+ toast.success("Uma nova versão foi publicada")
},
},
)
@@ -87,7 +86,7 @@ export function FlowEditToolbar({
if (success) {
executePublish()
} else {
- toast.error("Some configurations are incomplete")
+ toast.error("Algumas configurações estão incompletas")
}
setIsValidating(false)
}
@@ -120,10 +119,6 @@ export function FlowEditToolbar({
- setAction("rename")}>
-
- {t("actions.rename")}
-
setAction("duplicate")}>
{t("actions.duplicate")}
@@ -162,12 +157,6 @@ export function FlowEditToolbar({
- setAction(null)}
- open={action === "rename"}
- />
-
setAction(null)}
diff --git a/apps/builder/src/features/flows/react-flow/frame-header.tsx b/apps/builder/src/features/flows/react-flow/frame-header.tsx
index e9f1802e6..c57ee0545 100644
--- a/apps/builder/src/features/flows/react-flow/frame-header.tsx
+++ b/apps/builder/src/features/flows/react-flow/frame-header.tsx
@@ -1,29 +1,63 @@
"use client"
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@chatbotx.io/ui/components/ui/tooltip"
+import { Edit2 } from "iconsax-reactjs"
import { useTranslations } from "next-intl"
-import { AppBreadcrumb } from "@/components/app-breadcrumb"
+import { useState } from "react"
+import { type IconsaxComponent, wrapIconsax } from "@/components/iconsax-icon"
+import { PageHeader } from "@/components/page-header"
import type { FlowResource } from "../schemas/resource"
+import { RenameFlowDialog } from "./components/rename-flow"
import { FlowEditToolbar } from "./flow-edit-toolbar"
+const PencilIcon = wrapIconsax(Edit2 as IconsaxComponent)
+
+/**
+ * Header do flow editor — usa o componente `PageHeader` padronizado
+ * (altura `h-[52px]`, mesmo padrão do Respond.io).
+ *
+ * Layout:
+ * [< voltar] [nome do flow] [✏️ pencil] [toolbar Publicar/...]
+ */
export function FrameHeader({ flow }: { flow: FlowResource }) {
const t = useTranslations()
+ const [renameOpen, setRenameOpen] = useState(false)
return (
-
-
- {/* */}
+ <>
+ }
+ backHref={`/space/${flow.workspaceId}/flows`}
+ backLabel={t("actions.back")}
+ title={flow.name}
+ titleAdornment={
+
+
+ setRenameOpen(true)}
+ size="icon"
+ variant="ghost"
+ >
+
+
+
+ {t("actions.rename")}
+
+ }
+ />
-
-
+
+ >
)
}
diff --git a/apps/builder/src/features/flows/react-flow/frame.tsx b/apps/builder/src/features/flows/react-flow/frame.tsx
index 6e92dada9..736031807 100644
--- a/apps/builder/src/features/flows/react-flow/frame.tsx
+++ b/apps/builder/src/features/flows/react-flow/frame.tsx
@@ -5,7 +5,7 @@ import type { FlowVersionResource } from "@/features/flow-versions/schema/resour
import type { FlowResource } from "../schemas/resource"
import { ButtonEditorDialog } from "./button-editor-dialog"
import { FrameHeader } from "./frame-header"
-import { NodeDetailSheet } from "./nodes/node-detail-sheet"
+import { NodeDetailPanel } from "./nodes/node-detail-panel"
import { ReactFlowWrapper } from "./react-flow-wrapper"
import { useStepStore } from "./stores/step-store-provider"
@@ -24,15 +24,23 @@ export function ReactFlowFrame({ flow, flowVersion }: ReactFlowFrameProps) {
<>
-
+ {/*
+ Wrapper relative pra ancorar o painel não-modal (NodeDetailPanel) com
+ `absolute right-0` dentro da área do canvas. `overflow-hidden` impede
+ que o `translate-x-full` do painel (quando fechado) crie scroll
+ horizontal no body. `min-w-0` impede que o canvas force largura > pai.
+ */}
+
+
-
+
+
>
diff --git a/apps/builder/src/features/flows/react-flow/nodes/editor.tsx b/apps/builder/src/features/flows/react-flow/nodes/editor.tsx
index 81236d43a..80f746d37 100644
--- a/apps/builder/src/features/flows/react-flow/nodes/editor.tsx
+++ b/apps/builder/src/features/flows/react-flow/nodes/editor.tsx
@@ -1,9 +1,15 @@
-import type { FlowNode, NodeType, StepType } from "@chatbotx.io/flow-config"
+import type {
+ FlowNode,
+ NodeType,
+ StepType,
+ TriggerNodeConfig,
+} from "@chatbotx.io/flow-config"
import {
buttonStepDefaultFn,
disabledCopyActionTypes,
hiddenActionsStepTypes,
MAX_QUICK_REPLIES,
+ nodeTypeSchema,
stepTypes,
} from "@chatbotx.io/flow-config"
import { TriggerFormInitially } from "@chatbotx.io/ui/components/form/form-trigger-initially"
@@ -42,6 +48,7 @@ import { ErrorAlert } from "../steps/error-alert"
import { useFlowTemplate } from "../stores/flow-template-store-provider"
import { useStepStore } from "../stores/step-store-provider"
import { allNodesConfig } from "./node-config"
+import { TriggerNodeEditor } from "./trigger/editor"
import type { MenuItem } from "./types"
const collectErrorMessages = (node: unknown): string[] => {
@@ -174,10 +181,29 @@ const NodeEditorMenu = memo(
},
)
+// Wrapper externo que despacha por nodeType — TriggerNode tem editor próprio
+// (discriminated union, sem `steps[]`). Refatorado 2026-05-26 pra cumprir regra
+// biome `useHookAtTopLevel`: o early return anterior deixava 9 hooks após ele,
+// invalidando a ordem fixa de hooks do React. Agora todos os hooks vivem em
+// `NodeStepsEditor` que só monta quando o tipo de fato precisa de steps.
export const NodeEditor = memo((props: NodeEditorProps) => {
const { nodeId, nodeType, nodeDetails } = props
+ if (nodeType === nodeTypeSchema.enum.trigger) {
+ return (
+
+ )
+ }
+ return
+})
+
+const NodeStepsEditor = memo((props: NodeEditorProps) => {
+ const { nodeId, nodeType, nodeDetails } = props
const t = useTranslations()
+
const nodeConfig = nodeType ? allNodesConfig[nodeType]?.(t) : null
const validator = nodeConfig?.validator.shape.data.shape.details
diff --git a/apps/builder/src/features/flows/react-flow/nodes/node-config.tsx b/apps/builder/src/features/flows/react-flow/nodes/node-config.tsx
index 21cee5305..7e68b5c83 100644
--- a/apps/builder/src/features/flows/react-flow/nodes/node-config.tsx
+++ b/apps/builder/src/features/flows/react-flow/nodes/node-config.tsx
@@ -4,6 +4,7 @@ import sendMailNodeConfig from "./send-mail"
import sendMessageNodeConfig from "./send-message"
import splitTrafficNodeConfig from "./split-traffic"
import startFlowNodeConfig from "./start-flow"
+import triggerNodeConfig from "./trigger"
import waitNodeConfig from "./wait"
export const allNodesConfig = {
@@ -16,4 +17,5 @@ export const allNodesConfig = {
[nodeTypeSchema.enum.wait]: waitNodeConfig,
[nodeTypeSchema.enum.landingPage]: undefined,
[nodeTypeSchema.enum.addNotes]: undefined,
+ [nodeTypeSchema.enum.trigger]: triggerNodeConfig,
}
diff --git a/apps/builder/src/features/flows/react-flow/nodes/node-detail-panel.tsx b/apps/builder/src/features/flows/react-flow/nodes/node-detail-panel.tsx
new file mode 100644
index 000000000..ee49235f9
--- /dev/null
+++ b/apps/builder/src/features/flows/react-flow/nodes/node-detail-panel.tsx
@@ -0,0 +1,114 @@
+"use client"
+
+import type { FlowNode, NodeType } from "@chatbotx.io/flow-config"
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import { cn } from "@chatbotx.io/ui/lib/utils"
+import { type ReactFlowState, useStore } from "@xyflow/react"
+import { CloseCircle } from "iconsax-reactjs"
+import { type IconsaxComponent, wrapIconsax } from "@/components/iconsax-icon"
+
+const XIcon = wrapIconsax(CloseCircle as IconsaxComponent)
+
+import { memo } from "react"
+import { NodeEditor } from "./editor"
+import { NodeNameEditor } from "./node-name-editor"
+
+type NodeDetailPanelProps = {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+// Select only the selected node from the store
+const selectSelectedNode = (state: ReactFlowState): FlowNode | null =>
+ (state.nodes.find((node) => node.selected) as FlowNode) || null
+
+// Custom equality function that compares node ID and data reference
+// This prevents re-renders from position/dragging changes but allows data updates
+const equalityFn = (a: FlowNode | null, b: FlowNode | null): boolean => {
+ if (a === b) {
+ return true
+ }
+ if (!a) {
+ return false
+ }
+ if (!b) {
+ return false
+ }
+ return a.id === b.id && a.data === b.data
+}
+
+/**
+ * Painel lateral NÃO-MODAL pra editar o node selecionado.
+ *
+ * Diferenças versus o `` do shadcn:
+ * - Sem overlay escuro
+ * - Sem focus trap (canvas continua interativo)
+ * - Sem locking do scroll/body
+ * - Clicar em outro node troca o conteúdo (re-render via React key)
+ * - Slide-in suave da direita via CSS transition
+ *
+ * Comportamento: Pedro pode arrastar, dar zoom, adicionar nodes enquanto o
+ * painel está aberto. Clicar em outro node abre o editor desse novo node
+ * (substitui o conteúdo). Botão X fecha.
+ */
+export function NodeDetailPanel({ open, onOpenChange }: NodeDetailPanelProps) {
+ const activeNode = useStore(selectSelectedNode, equalityFn)
+
+ return (
+
+ {activeNode ? (
+ onOpenChange(false)}
+ />
+ ) : null}
+
+ )
+}
+
+const NodeDetailPanelContent = memo(
+ ({ activeNode, onClose }: { activeNode: FlowNode; onClose: () => void }) => (
+ <>
+
+
+ {/*
+ `key` força re-mount quando muda o node selecionado — evita estado
+ stale do editor anterior (ex.: useState locais, controllers do RHF).
+ */}
+
+
+ >
+ ),
+)
+
+NodeDetailPanelContent.displayName = "NodeDetailPanelContent"
diff --git a/apps/builder/src/features/flows/react-flow/nodes/node-detail-sheet.tsx b/apps/builder/src/features/flows/react-flow/nodes/node-detail-sheet.tsx
index 6710c6584..119077fbb 100644
--- a/apps/builder/src/features/flows/react-flow/nodes/node-detail-sheet.tsx
+++ b/apps/builder/src/features/flows/react-flow/nodes/node-detail-sheet.tsx
@@ -62,7 +62,12 @@ export const NodeDetailSheetContent = memo(
onOpenChange: (open: boolean) => void
}) => (
-
+ {/*
+ Painel lateral de edição do node fica na DIREITA (estilo Respond.io).
+ Antes ficava à esquerda; Pedro pediu na direita pra manter o canvas
+ livre e os controles no canto superior esquerdo do canvas.
+ */}
+
diff --git a/apps/builder/src/features/flows/react-flow/nodes/node-name-editor.tsx b/apps/builder/src/features/flows/react-flow/nodes/node-name-editor.tsx
index 41c666f4a..d62ad55ec 100644
--- a/apps/builder/src/features/flows/react-flow/nodes/node-name-editor.tsx
+++ b/apps/builder/src/features/flows/react-flow/nodes/node-name-editor.tsx
@@ -58,7 +58,7 @@ export function NodeNameEditor({ activeNode }: { activeNode: FlowNode }) {
className="flex w-full flex-col gap-6"
onSubmit={form.handleSubmit(onSubmit)}
>
-
+
[
icon: SaveOffIcon,
stepType: stepTypes.enum.clearCustomField,
},
+ {
+ label: t("flows.actions.updateContactLifecycleStage"),
+ icon: RefreshCwIcon,
+ stepType: stepTypes.enum.updateContactLifecycleStage,
+ },
{
label: t("flows.actions.blockContact"),
icon: UserRoundXIcon,
diff --git a/apps/builder/src/features/flows/react-flow/nodes/trigger/editor.tsx b/apps/builder/src/features/flows/react-flow/nodes/trigger/editor.tsx
new file mode 100644
index 000000000..b680dc84b
--- /dev/null
+++ b/apps/builder/src/features/flows/react-flow/nodes/trigger/editor.tsx
@@ -0,0 +1,591 @@
+"use client"
+
+import type { FlowTriggerNodeType } from "@chatbotx.io/database/partials"
+import type { LifecycleStageModel } from "@chatbotx.io/database/types"
+import type { TriggerNodeConfig } from "@chatbotx.io/flow-config"
+import { Checkbox } from "@chatbotx.io/ui/components/ui/checkbox"
+import { Input } from "@chatbotx.io/ui/components/ui/input"
+import { Label } from "@chatbotx.io/ui/components/ui/label"
+import {
+ RadioGroup,
+ RadioGroupItem,
+} from "@chatbotx.io/ui/components/ui/radio-group"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@chatbotx.io/ui/components/ui/select"
+import { cn } from "@chatbotx.io/ui/lib/utils"
+import { useReactFlow } from "@xyflow/react"
+import {
+ MessageCircleIcon,
+ MessageCircleOffIcon,
+ RefreshCwIcon,
+ TagIcon,
+ TextCursorInputIcon,
+ ZapIcon,
+} from "lucide-react"
+import { useTranslations } from "next-intl"
+import { useEffect, useMemo, useState } from "react"
+import { useCustomFieldSelectOptions } from "@/features/custom-fields/provider/custom-field-hook"
+import { listLifecycleStages } from "@/features/lifecycle-stages/queries"
+import { useTagSelectOptions } from "@/features/tags/provider/tag-hook"
+import { useWorkspaceId } from "@/hooks/routing"
+
+type TriggerNodeEditorProps = {
+ nodeId: string
+ nodeDetails: TriggerNodeConfig
+}
+
+type TriggerTypeOption = {
+ value: FlowTriggerNodeType
+ labelKey: string
+ hintKey: string
+ icon: React.ComponentType<{ className?: string }>
+ /** true = totalmente funcional (worker dispatch implementado).
+ * false = UI funcional mas worker dispatch pendente (próxima iteração). */
+ ready: boolean
+}
+
+const TRIGGER_OPTIONS: TriggerTypeOption[] = [
+ {
+ value: "conversationOpened",
+ labelKey: "trigger.flowNode.conversationOpened.label",
+ hintKey: "trigger.flowNode.conversationOpened.hint",
+ icon: MessageCircleIcon,
+ ready: true,
+ },
+ {
+ value: "conversationClosed",
+ labelKey: "trigger.flowNode.conversationClosed.label",
+ hintKey: "trigger.flowNode.conversationClosed.hint",
+ icon: MessageCircleOffIcon,
+ ready: true,
+ },
+ {
+ value: "contactFieldUpdated",
+ labelKey: "trigger.flowNode.contactFieldUpdated.label",
+ hintKey: "trigger.flowNode.contactFieldUpdated.hint",
+ icon: TextCursorInputIcon,
+ ready: true,
+ },
+ {
+ value: "contactTagUpdated",
+ labelKey: "trigger.flowNode.contactTagUpdated.label",
+ hintKey: "trigger.flowNode.contactTagUpdated.hint",
+ icon: TagIcon,
+ ready: true,
+ },
+ {
+ value: "shortcut",
+ labelKey: "trigger.flowNode.shortcut.label",
+ hintKey: "trigger.flowNode.shortcut.hint",
+ icon: ZapIcon,
+ ready: true,
+ },
+ {
+ value: "lifecycleStageChanged",
+ labelKey: "trigger.flowNode.lifecycleStageChanged.label",
+ hintKey: "trigger.flowNode.lifecycleStageChanged.hint",
+ icon: RefreshCwIcon,
+ ready: true,
+ },
+]
+
+export const TriggerNodeEditor = ({
+ nodeId,
+ nodeDetails,
+}: TriggerNodeEditorProps) => {
+ const t = useTranslations()
+ const { getNodes, updateNodeData } = useReactFlow()
+
+ const currentType: FlowTriggerNodeType = nodeDetails.triggerType
+
+ const currentOption = useMemo(
+ () => TRIGGER_OPTIONS.find((o) => o.value === currentType),
+ [currentType],
+ )
+
+ /** Patch parcial do `details` mantendo o triggerType atual. */
+ const patchDetails = (next: Partial) => {
+ const currentNode = getNodes().find((n) => n.id === nodeId)
+ if (!currentNode) {
+ return
+ }
+ const merged = {
+ ...(currentNode.data?.details ?? {}),
+ ...next,
+ } as TriggerNodeConfig
+ updateNodeData(nodeId, {
+ ...currentNode.data,
+ details: merged,
+ })
+ }
+
+ const handleChangeType = (next: string) => {
+ const nextType = next as FlowTriggerNodeType
+ const currentNode = getNodes().find((n) => n.id === nodeId)
+ if (!currentNode) {
+ return
+ }
+
+ // Discriminated union: reset details pro mínimo do novo tipo (perde
+ // filtros antigos — esperado quando muda subtipo).
+ let nextDetails: TriggerNodeConfig
+ switch (nextType) {
+ case "conversationOpened":
+ nextDetails = { triggerType: "conversationOpened" }
+ break
+ case "conversationClosed":
+ nextDetails = { triggerType: "conversationClosed" }
+ break
+ case "contactFieldUpdated":
+ nextDetails = { triggerType: "contactFieldUpdated" }
+ break
+ case "contactTagUpdated":
+ nextDetails = { triggerType: "contactTagUpdated", action: "any" }
+ break
+ case "shortcut":
+ nextDetails = { triggerType: "shortcut" }
+ break
+ case "lifecycleStageChanged":
+ nextDetails = {
+ triggerType: "lifecycleStageChanged",
+ triggerOnCleared: false,
+ }
+ break
+ default:
+ return
+ }
+
+ updateNodeData(nodeId, {
+ ...currentNode.data,
+ details: nextDetails,
+ })
+ }
+
+ return (
+
+
+
+ {t("trigger.flowNode.helperText")}
+
+
+
+
+
{t("trigger.flowNode.typeLabel")}
+
+
+
+
+
+ {TRIGGER_OPTIONS.map((opt) => {
+ const Icon = opt.icon
+ return (
+
+
+
+ {t(opt.labelKey)}
+
+
+ )
+ })}
+
+
+ {currentOption && (
+
+ {t(currentOption.hintKey)}
+
+ )}
+
+
+ {/* Filtros específicos do subtipo */}
+
+
+ )
+}
+
+// ─── Filters por subtipo ────────────────────────────────────────────────────
+
+type FiltersProps = {
+ details: TriggerNodeConfig
+ onChange: (next: Partial) => void
+}
+
+const TriggerFilters = ({ details, onChange }: FiltersProps) => {
+ switch (details.triggerType) {
+ case "contactTagUpdated":
+ return
+ case "contactFieldUpdated":
+ return
+ case "lifecycleStageChanged":
+ return
+ case "shortcut":
+ return
+ case "conversationOpened":
+ case "conversationClosed":
+ return
+ default:
+ return null
+ }
+}
+
+const NoFiltersHint = () => {
+ const t = useTranslations()
+ return (
+
+
+ {t("trigger.flowNode.noFilters")}
+
+
+ )
+}
+
+// ─── 1. Contact Tag Updated ─────────────────────────────────────────────────
+
+const ContactTagFilters = ({
+ details,
+ onChange,
+}: {
+ details: TriggerNodeConfig & { triggerType: "contactTagUpdated" }
+ onChange: FiltersProps["onChange"]
+}) => {
+ const t = useTranslations()
+ const tagOptions = useTagSelectOptions()
+ const selectedIds = details.tagIds ?? []
+
+ const toggleTag = (tagId: string) => {
+ const next = selectedIds.includes(tagId)
+ ? selectedIds.filter((id) => id !== tagId)
+ : [...selectedIds, tagId]
+ onChange({
+ triggerType: "contactTagUpdated",
+ action: details.action,
+ tagIds: next,
+ })
+ }
+
+ return (
+
+
+
+ {t("trigger.flowNode.contactTagUpdated.actionLabel")}
+
+
+ onChange({
+ triggerType: "contactTagUpdated",
+ action: v as "added" | "removed" | "any",
+ tagIds: details.tagIds,
+ })
+ }
+ value={details.action ?? "any"}
+ >
+
+
+
+
+
+
+
({ id: o.value, name: o.label }))}
+ selectedIds={selectedIds}
+ />
+
+ )
+}
+
+// ─── 2. Contact Field Updated ───────────────────────────────────────────────
+
+const ContactFieldFilters = ({
+ details,
+ onChange,
+}: {
+ details: TriggerNodeConfig & { triggerType: "contactFieldUpdated" }
+ onChange: FiltersProps["onChange"]
+}) => {
+ const t = useTranslations()
+ const fieldOptions = useCustomFieldSelectOptions()
+ const selectedIds = details.fieldIds ?? []
+
+ const toggleField = (fieldId: string) => {
+ const next = selectedIds.includes(fieldId)
+ ? selectedIds.filter((id) => id !== fieldId)
+ : [...selectedIds, fieldId]
+ onChange({
+ triggerType: "contactFieldUpdated",
+ fieldIds: next,
+ })
+ }
+
+ return (
+ ({
+ id: String(o.value),
+ name: o.label,
+ }))}
+ selectedIds={selectedIds}
+ />
+ )
+}
+
+// ─── 3. Lifecycle Stage Changed ─────────────────────────────────────────────
+
+const LifecycleFilters = ({
+ details,
+ onChange,
+}: {
+ details: TriggerNodeConfig & { triggerType: "lifecycleStageChanged" }
+ onChange: FiltersProps["onChange"]
+}) => {
+ const t = useTranslations()
+ const workspaceId = useWorkspaceId()
+ const [stages, setStages] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ let cancelled = false
+ listLifecycleStages(workspaceId)
+ .then((data) => {
+ if (!cancelled) {
+ setStages(data)
+ setLoading(false)
+ }
+ })
+ .catch(() => {
+ if (!cancelled) {
+ setLoading(false)
+ }
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [workspaceId])
+
+ const toggleId = (field: "toStageIds" | "fromStageIds", stageId: string) => {
+ const current = details[field] ?? []
+ const next = current.includes(stageId)
+ ? current.filter((id) => id !== stageId)
+ : [...current, stageId]
+ onChange({
+ triggerType: "lifecycleStageChanged",
+ triggerOnCleared: details.triggerOnCleared,
+ toStageIds: field === "toStageIds" ? next : details.toStageIds,
+ fromStageIds: field === "fromStageIds" ? next : details.fromStageIds,
+ })
+ }
+
+ const stageOptions = stages.map((s) => ({
+ id: s.id,
+ name: `${s.icon ?? "•"} ${s.name}`,
+ }))
+
+ return (
+
+ toggleId("toStageIds", id)}
+ options={stageOptions}
+ selectedIds={details.toStageIds ?? []}
+ />
+
+ toggleId("fromStageIds", id)}
+ options={stageOptions}
+ selectedIds={details.fromStageIds ?? []}
+ />
+
+ {/* biome-ignore lint/a11y/noLabelWithoutControl: Checkbox shadcn renderiza input filho — label envolve corretamente */}
+
+
+ onChange({
+ triggerType: "lifecycleStageChanged",
+ triggerOnCleared: !!v,
+ toStageIds: details.toStageIds,
+ fromStageIds: details.fromStageIds,
+ })
+ }
+ />
+
+ {t("trigger.flowNode.lifecycleStageChanged.triggerOnCleared")}
+
+
+
+ )
+}
+
+// ─── 4. Shortcut ────────────────────────────────────────────────────────────
+
+const ShortcutFilters = ({
+ details,
+ onChange,
+}: {
+ details: TriggerNodeConfig & { triggerType: "shortcut" }
+ onChange: FiltersProps["onChange"]
+}) => {
+ const t = useTranslations()
+ return (
+
+
+
+ {t("trigger.flowNode.shortcut.iconLabel")}
+
+
+ onChange({
+ triggerType: "shortcut",
+ icon: e.target.value,
+ label: details.label,
+ })
+ }
+ placeholder="⚡"
+ value={details.icon ?? ""}
+ />
+
+ {t("trigger.flowNode.shortcut.iconHint")}
+
+
+
+
+
+ {t("trigger.flowNode.shortcut.labelLabel")}
+
+
+ onChange({
+ triggerType: "shortcut",
+ icon: details.icon,
+ label: e.target.value,
+ })
+ }
+ placeholder={t("trigger.flowNode.shortcut.labelPlaceholder")}
+ value={details.label ?? ""}
+ />
+
+
+
+
+ {t("trigger.flowNode.shortcut.note")}
+
+
+
+ )
+}
+
+// ─── Helpers ────────────────────────────────────────────────────────────────
+
+const RadioOption = ({ label, value }: { label: string; value: string }) => (
+
+
+
+ {label}
+
+
+)
+
+/**
+ * Multi-select com lista de checkboxes. Simples e suficiente pra
+ * volumes baixos (tags/fields/stages raramente passam de 50 items por workspace).
+ * Quando o `selectedIds` está vazio, renderiza "Qualquer" como hint pro usuário.
+ */
+const MultiCheckSelect = ({
+ label,
+ options,
+ selectedIds,
+ onToggle,
+ emptyHint,
+ anyLabel,
+}: {
+ label: string
+ options: { id: string; name: string }[]
+ selectedIds: string[]
+ onToggle: (id: string) => void
+ emptyHint: string
+ anyLabel: string
+}) => {
+ if (options.length === 0) {
+ return (
+
+ )
+ }
+ return (
+
+
+ {label}
+ {selectedIds.length === 0 ? (
+ {anyLabel}
+ ) : (
+ {selectedIds.length}
+ )}
+
+
+ {options.map((opt) => {
+ const checked = selectedIds.includes(opt.id)
+ return (
+ // biome-ignore lint/a11y/noLabelWithoutControl: Checkbox shadcn renderiza input filho — label envolve corretamente
+
+ onToggle(opt.id)}
+ />
+ {opt.name}
+
+ )
+ })}
+
+
+ )
+}
diff --git a/apps/builder/src/features/flows/react-flow/nodes/trigger/index.ts b/apps/builder/src/features/flows/react-flow/nodes/trigger/index.ts
new file mode 100644
index 000000000..a4377556a
--- /dev/null
+++ b/apps/builder/src/features/flows/react-flow/nodes/trigger/index.ts
@@ -0,0 +1,19 @@
+import {
+ nodeTypeSchema,
+ triggerNodeDefaultFn,
+ triggerNodeSchema,
+} from "@chatbotx.io/flow-config"
+import { ZapIcon } from "lucide-react"
+import type { TranslationFn } from "../types"
+import { triggerMenus } from "./menu"
+
+const triggerNodeConfig = (t: TranslationFn) => ({
+ defaultFn: triggerNodeDefaultFn,
+ icon: ZapIcon,
+ label: t("actions.trigger"),
+ menus: triggerMenus,
+ type: nodeTypeSchema.enum.trigger,
+ validator: triggerNodeSchema,
+})
+
+export default triggerNodeConfig
diff --git a/apps/builder/src/features/flows/react-flow/nodes/trigger/menu.tsx b/apps/builder/src/features/flows/react-flow/nodes/trigger/menu.tsx
new file mode 100644
index 000000000..cb48746c7
--- /dev/null
+++ b/apps/builder/src/features/flows/react-flow/nodes/trigger/menu.tsx
@@ -0,0 +1,4 @@
+// TriggerNode é configurado via gaveta lateral (não via menu de "adicionar item").
+// O menu interno fica vazio — o usuário NÃO adiciona items ao trigger, ele
+// apenas escolhe o tipo de evento + filtros opcionais no editor lateral.
+export const triggerMenus = () => []
diff --git a/apps/builder/src/features/flows/react-flow/nodes/trigger/viewer.tsx b/apps/builder/src/features/flows/react-flow/nodes/trigger/viewer.tsx
new file mode 100644
index 000000000..d017cf7bc
--- /dev/null
+++ b/apps/builder/src/features/flows/react-flow/nodes/trigger/viewer.tsx
@@ -0,0 +1,143 @@
+import type { FlowNode, TriggerNodeConfig } from "@chatbotx.io/flow-config"
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@chatbotx.io/ui/components/ui/card"
+import { cn } from "@chatbotx.io/ui/lib/utils"
+import { NodeToolbar, Position } from "@xyflow/react"
+import {
+ MessageCircleIcon,
+ MessageCircleOffIcon,
+ PlayCircleIcon,
+ RefreshCwIcon,
+ TagIcon,
+ TextCursorInputIcon,
+ ZapIcon,
+} from "lucide-react"
+import { useTranslations } from "next-intl"
+import { memo } from "react"
+import { ArrowHandle } from "@/components/arrow-handle"
+import { FlowNodeToolbar } from "../../toolbar/node-toolbar"
+
+type TriggerViewerProps = {
+ id: string
+ data: FlowNode["data"] & { forceToolbarVisible?: boolean }
+}
+
+/**
+ * Visual do TriggerNode no canvas — estilo Respond.io adaptado ao tema do
+ * ChatbotX (cores `primary` do design system).
+ *
+ * Estrutura:
+ * - Badge "INÍCIO" no topo (sinaliza entry point)
+ * - Card destacado com border primary
+ * - Header: ⚡ ícone + título "Gatilho"
+ * - Body: ícone específico do subtipo + label do subtipo
+ * - Source handle no bottom (sem target — gatilho é entry point)
+ * - Toolbar restrito (FlowNodeToolbar esconde delete/duplicate quando
+ * isStartNode = true)
+ */
+export const TriggerNodeViewer = memo(({ id, data }: TriggerViewerProps) => {
+ const t = useTranslations()
+ const details = data.details as TriggerNodeConfig | undefined
+ const subtypeInfo = details ? getSubtypeInfo(details.triggerType) : null
+ const SubtypeIcon = subtypeInfo?.icon ?? ZapIcon
+
+ return (
+ <>
+
+
+
+ {t("flowEditor.startBadge")}
+
+
+
+
+
+
+
+ {/*
+ TAMANHO PADRÃO igual aos outros nodes (w-72 + min-h-[110px]). Ícone
+ rosa só pra identificar tipo gatilho — border e handle uniformes
+ com o resto (minimalista). Card mais escuro que o canvas pra
+ contraste.
+ */}
+
+
+
+
+ {data.name || t("actions.trigger")}
+
+
+
+
+
+
+ {subtypeInfo
+ ? t(subtypeInfo.labelKey)
+ : t("trigger.flowNode.typePlaceholder")}
+
+
+
+
+
+ >
+ )
+})
+
+TriggerNodeViewer.displayName = "TriggerNodeViewer"
+
+// ─── Mapping subtipo → ícone + label key ────────────────────────────────────
+
+type SubtypeInfo = {
+ icon: React.ComponentType<{ className?: string }>
+ labelKey: string
+}
+
+function getSubtypeInfo(
+ triggerType: TriggerNodeConfig["triggerType"],
+): SubtypeInfo {
+ switch (triggerType) {
+ case "conversationOpened":
+ return {
+ icon: MessageCircleIcon,
+ labelKey: "trigger.flowNode.conversationOpened.label",
+ }
+ case "conversationClosed":
+ return {
+ icon: MessageCircleOffIcon,
+ labelKey: "trigger.flowNode.conversationClosed.label",
+ }
+ case "contactFieldUpdated":
+ return {
+ icon: TextCursorInputIcon,
+ labelKey: "trigger.flowNode.contactFieldUpdated.label",
+ }
+ case "contactTagUpdated":
+ return {
+ icon: TagIcon,
+ labelKey: "trigger.flowNode.contactTagUpdated.label",
+ }
+ case "shortcut":
+ return {
+ icon: ZapIcon,
+ labelKey: "trigger.flowNode.shortcut.label",
+ }
+ case "lifecycleStageChanged":
+ return {
+ icon: RefreshCwIcon,
+ labelKey: "trigger.flowNode.lifecycleStageChanged.label",
+ }
+ default:
+ return {
+ icon: ZapIcon,
+ labelKey: "trigger.flowNode.typePlaceholder",
+ }
+ }
+}
diff --git a/apps/builder/src/features/flows/react-flow/nodes/viewer.tsx b/apps/builder/src/features/flows/react-flow/nodes/viewer.tsx
index 4c84d3208..9790e65b3 100644
--- a/apps/builder/src/features/flows/react-flow/nodes/viewer.tsx
+++ b/apps/builder/src/features/flows/react-flow/nodes/viewer.tsx
@@ -2,6 +2,7 @@ import {
disabledContinueNodeTypes,
type FlowNode,
type NodeType,
+ nodeTypeSchema,
} from "@chatbotx.io/flow-config"
import {
Card,
@@ -9,11 +10,12 @@ import {
CardHeader,
CardTitle,
} from "@chatbotx.io/ui/components/ui/card"
+import { cn } from "@chatbotx.io/ui/lib/utils"
import { NodeToolbar, Position } from "@xyflow/react"
import { PlayCircleIcon } from "lucide-react"
import { useTranslations } from "next-intl"
import { memo } from "react"
-import { BaseHandle } from "@/components/base-handle"
+import { ArrowHandle } from "@/components/arrow-handle"
import { DynamicStepViewer } from "../steps"
import { ButtonStepViewer } from "../steps/button/viewer"
import { FlowNodeToolbar } from "../toolbar/node-toolbar"
@@ -25,11 +27,43 @@ type NodeViewerProps = {
data: FlowNode["data"] & { forceToolbarVisible?: boolean }
}
+/**
+ * Paleta minimalista — cor APENAS no ícone do header pra distinguir o tipo
+ * do node. Borda e handles ficam neutros (padrão do tema). Pedro pediu
+ * minimalista: "as cores está muito forte, está destacando muito, quero
+ * algo minimalista".
+ *
+ * Comparar com BotConversa: ícone verde/amarelo/etc, mas border do card e
+ * conectores são uniformes (cinza/azul).
+ */
+const NODE_ICON_COLORS: Partial> = {
+ [nodeTypeSchema.enum.sendMessage]: "text-emerald-500",
+ [nodeTypeSchema.enum.performAction]: "text-amber-500",
+ [nodeTypeSchema.enum.splitTraffic]: "text-blue-500",
+ [nodeTypeSchema.enum.wait]: "text-violet-500",
+ [nodeTypeSchema.enum.sendMail]: "text-cyan-500",
+ [nodeTypeSchema.enum.startFlow]: "text-indigo-500",
+ [nodeTypeSchema.enum.addNotes]: "text-zinc-500",
+ [nodeTypeSchema.enum.landingPage]: "text-pink-500",
+}
+
export const NodeViewer = memo((props: NodeViewerProps) => {
const { id, type, data } = props
const t = useTranslations()
const nodeConfig = allNodesConfig[type]?.(t)
+ const iconColor = NODE_ICON_COLORS[type] ?? "text-muted-foreground"
+ const hasContinue = !disabledContinueNodeTypes.includes(type)
+
+ const hasSteps =
+ "steps" in data.details &&
+ data.details.steps &&
+ data.details.steps.length > 0
+ const hasQuickReplies =
+ "quickReplies" in data.details &&
+ data.details.quickReplies &&
+ data.details.quickReplies.length > 0
+ const isEmpty = !(hasSteps || hasQuickReplies)
return data.details && nodeConfig ? (
<>
@@ -37,7 +71,7 @@ export const NodeViewer = memo((props: NodeViewerProps) => {
{data.isStartNode && (
- Start
+ {t("flowEditor.startBadge")}
)}
@@ -46,25 +80,34 @@ export const NodeViewer = memo((props: NodeViewerProps) => {
-
-
-
-
- {nodeConfig?.icon ? : " "}
- {data.name}
+ {/*
+ TAMANHO PADRÃO: w-72 (288px) + min-h-[110px]. Garante que todos os
+ nodes tenham mesma largura E altura mínima — mesmo quando não têm
+ steps configurados (Pedro reclamou que ficavam "finos demais" comparado
+ aos que têm conteúdo). Crescer só em altura quando adicionar steps.
+
+ CORES: Card mais escuro que o canvas pra ter contraste claro
+ (Pedro: "nodes e canvas com a mesma cor, não quero assim — só o
+ fundo do canvas fica cinza, nodes ficam escuros igual sidebar").
+ - Light mode: white sobre canvas zinc-100
+ - Dark mode: zinc-950 sobre canvas zinc-900 (mais escuro que canvas)
+ */}
+
+
+
+ {nodeConfig?.icon ? (
+
+ ) : (
+ " "
+ )}
+ {data.name}
-
- {"steps" in data.details &&
- data.details.steps &&
- data.details.steps.length > 0 &&
- data.details.steps.map((stepItem) => (
+
+ {hasSteps &&
+ "steps" in data.details &&
+ data.details.steps?.map((stepItem) => (
{
/>
))}
- {"quickReplies" in data.details &&
- data.details.quickReplies &&
- data.details.quickReplies.length > 0 &&
- data.details.quickReplies.map((quickReplyItem) => (
+ {hasQuickReplies &&
+ "quickReplies" in data.details &&
+ data.details.quickReplies?.map((quickReplyItem) => (
))}
- {!disabledContinueNodeTypes.includes(type) && (
-
- {t("actions.continue")}
-
-
+ {/*
+ Placeholder quando o node não tem conteúdo configurado — evita
+ visual "vazio" e instrui o usuário a configurar. Estilo BotConversa
+ (Bloco Inicial tem texto descritivo).
+ */}
+ {isEmpty && (
+
+ {t("actions.continue")} — clique para configurar
+
)}
+
+ {/*
+ Handles na borda externa do node, sem texto "Continuar". Cor
+ uniforme (azul) pra todos — Pedro pediu minimalista.
+ */}
+
+ {hasContinue && (
+
+ )}
>
) : (
- Node not found
+ Nó não encontrado
)
})
diff --git a/apps/builder/src/features/flows/react-flow/panel-buttons/flow-controls.tsx b/apps/builder/src/features/flows/react-flow/panel-buttons/flow-controls.tsx
new file mode 100644
index 000000000..be4afdd8c
--- /dev/null
+++ b/apps/builder/src/features/flows/react-flow/panel-buttons/flow-controls.tsx
@@ -0,0 +1,150 @@
+"use client"
+
+import type { NodeType } from "@chatbotx.io/flow-config"
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@chatbotx.io/ui/components/ui/popover"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@chatbotx.io/ui/components/ui/tooltip"
+import { cn } from "@chatbotx.io/ui/lib/utils"
+import { useReactFlow } from "@xyflow/react"
+import { Add, Maximize, SearchZoomIn, SearchZoomOut } from "iconsax-reactjs"
+import { useTranslations } from "next-intl"
+import { useState } from "react"
+import { type IconsaxComponent, wrapIconsax } from "@/components/iconsax-icon"
+import { allNodesConfig } from "../nodes/node-config"
+import { addNewNode } from "./add-node-button"
+
+// Ícones Iconsax (mesma família visual do resto do app)
+const PlusIcon = wrapIconsax(Add as IconsaxComponent, "Bold")
+const ZoomInIcon = wrapIconsax(SearchZoomIn as IconsaxComponent)
+const ZoomOutIcon = wrapIconsax(SearchZoomOut as IconsaxComponent)
+const MaximizeIcon = wrapIconsax(Maximize as IconsaxComponent)
+
+/**
+ * Card de controles flutuante no canto superior esquerdo do canvas — estilo
+ * premium (Respond.io / Figma). Substitui os botões padrão do react-flow
+ * que tinham aparência desatualizada.
+ *
+ * Layout:
+ * - Botão primário "+" Adicionar node (cor primary, destacado)
+ * - Separador visual
+ * - Botões secundários: Zoom In, Zoom Out, Focus (cor neutra)
+ *
+ * Todos com tooltip e ícones consistentes. Tamanho fixo 36×36.
+ */
+export function FlowControls() {
+ const t = useTranslations()
+ const reactFlow = useReactFlow()
+ const [addOpen, setAddOpen] = useState(false)
+
+ const onAdd = (nodeType: NodeType) => {
+ addNewNode(reactFlow, nodeType, t)
+ setAddOpen(false)
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {t("flowEditor.addNode")}
+
+
+
+
+ {Object.values(allNodesConfig).map((it, idx) => {
+ const item = it?.(t)
+ if (!item) {
+ // biome-ignore lint/suspicious/noArrayIndexKey: empty placeholder
+ return
+ }
+ // TriggerNode não é adicionável pelo botão "+" — ele já vem por
+ // default e é único por flow. Esconder da lista.
+ if (item.type === "trigger") {
+ return null
+ }
+ return (
+
onAdd(item.type)}
+ variant="ghost"
+ >
+
+ {item.label}
+
+ )
+ })}
+
+
+
+
+ {/* Separador visual entre ação primária e secundárias */}
+
+
+
reactFlow.zoomIn()}
+ />
+ reactFlow.zoomOut()}
+ />
+ reactFlow.fitView({ padding: 0.2, duration: 300 })}
+ />
+
+ )
+}
+
+const ControlIconButton = ({
+ Icon,
+ label,
+ onClick,
+}: {
+ Icon: React.ComponentType<{ className?: string }>
+ label: string
+ onClick: () => void
+}) => (
+
+
+
+
+
+
+ {label}
+
+)
diff --git a/apps/builder/src/features/flows/react-flow/react-flow-wrapper.css b/apps/builder/src/features/flows/react-flow/react-flow-wrapper.css
index d72907f55..16e781e59 100644
--- a/apps/builder/src/features/flows/react-flow/react-flow-wrapper.css
+++ b/apps/builder/src/features/flows/react-flow/react-flow-wrapper.css
@@ -5,6 +5,43 @@
}
}
+/*
+ * Canvas do flow builder — cor NEUTRA (zinc, não slate) pra ficar igual o
+ * Respond.io / BotConversa. Pedro recusou o azulado (slate) explicitamente:
+ * "ficou azul, não quero azul, quero um cinza claro igual ao Respond.io".
+ *
+ * Light mode: zinc-100 (#f4f4f5) — cinza muito claro neutro
+ * Dark mode: zinc-900 (#18181b) — cinza escuro neutro, sem tom azulado
+ * (anteriormente usava slate-800 que é azulado e ficou ruim)
+ *
+ * Header e sidebar continuam com bg-background do tema (que no dark é
+ * zinc-950 ou similar — mais escuro que o canvas, dando contraste).
+ */
+/* Theme overrides em CSS cascade exigem dark selector com mais specificity
+ * DEPOIS do default — refactor pra mesma specificity quebraria tema dark. */
+/* biome-ignore lint/style/noDescendingSpecificity: theme override pattern */
+.react-flow {
+ background-color: #f4f4f5; /* light: zinc-100 */
+}
+
+[data-theme="dark"] .react-flow,
+.dark .react-flow {
+ background-color: #18181b; /* dark: zinc-900 — neutro, sem azul */
+}
+
+/* Pontinhos do background pattern sutis em ambos modos */
+.react-flow .react-flow__background {
+ --xy-background-color: transparent;
+}
+.react-flow .react-flow__background-pattern {
+ color: rgba(161, 161, 170, 0.4); /* zinc-400 com alpha */
+}
+
+.dark .react-flow .react-flow__background-pattern,
+[data-theme="dark"] .react-flow .react-flow__background-pattern {
+ color: rgba(82, 82, 91, 0.5); /* zinc-600 com alpha */
+}
+
.react-flow__handle {
width: 12px;
height: 12px;
diff --git a/apps/builder/src/features/flows/react-flow/react-flow-wrapper.tsx b/apps/builder/src/features/flows/react-flow/react-flow-wrapper.tsx
index b49d08ee8..fb5a912ef 100644
--- a/apps/builder/src/features/flows/react-flow/react-flow-wrapper.tsx
+++ b/apps/builder/src/features/flows/react-flow/react-flow-wrapper.tsx
@@ -13,7 +13,6 @@ import {
addEdge,
Background,
type Connection,
- Controls,
type Edge,
type FinalConnectionState,
MarkerType,
@@ -31,11 +30,9 @@ import {
useEffect,
} from "react"
import { updateDraftFlowVersionAction } from "../actions/update-draft-flow-version-action"
+import { TriggerNodeViewer } from "./nodes/trigger/viewer"
import { NodeViewer } from "./nodes/viewer"
-import AddNodeButton from "./panel-buttons/add-node-button"
-import FocusButton from "./panel-buttons/focus-button"
-import ZoomInButton from "./panel-buttons/zoom-in-button"
-import ZoomOutButton from "./panel-buttons/zoom-out-button"
+import { FlowControls } from "./panel-buttons/flow-controls"
import "./react-flow-wrapper.css"
import { createId } from "@chatbotx.io/utils"
import type { ButtonProps } from "react-day-picker"
@@ -51,6 +48,10 @@ const viewerNodeTypes = {
[nodeTypeSchema.enum.wait]: NodeViewer,
[nodeTypeSchema.enum.startFlow]: NodeViewer,
[nodeTypeSchema.enum.splitTraffic]: NodeViewer,
+ // TriggerNode tem visual próprio (estilo Respond.io) — entry point com
+ // source handle só no bottom, sem target. NodeViewer genérico não serve
+ // porque assume steps[]/quickReplies[] no details.
+ [nodeTypeSchema.enum.trigger]: TriggerNodeViewer,
}
const edgeTypes = {
@@ -261,7 +262,7 @@ export function ReactFlowWrapper({
position,
},
dataProps: {
- name: `Send Message #${messageNodesLength + 1}`,
+ name: `Enviar Mensagem #${messageNodesLength + 1}`,
},
})
addNodes([newNode])
@@ -457,19 +458,13 @@ export function ReactFlowWrapper({
>
{/* */}
-
-
-
-
-
-
-
+ {/*
+ FlowControls = card único com Add + Zoom In/Out + Fit View no canto
+ SUPERIOR DIREITO. Inverteu de esquerda → direita pra harmonizar com o
+ painel de edição que agora abre na esquerda (estilo BotConversa).
+ */}
+
+
)
diff --git a/apps/builder/src/features/flows/react-flow/steps/get-user-data/editor.tsx b/apps/builder/src/features/flows/react-flow/steps/get-user-data/editor.tsx
index 50b741219..032640819 100644
--- a/apps/builder/src/features/flows/react-flow/steps/get-user-data/editor.tsx
+++ b/apps/builder/src/features/flows/react-flow/steps/get-user-data/editor.tsx
@@ -173,7 +173,7 @@ const GetUserDataStepEditor = ({ parentName }: { parentName: string }) => {
diff --git a/apps/builder/src/features/flows/react-flow/steps/index.tsx b/apps/builder/src/features/flows/react-flow/steps/index.tsx
index d144c470b..08ae9fc74 100644
--- a/apps/builder/src/features/flows/react-flow/steps/index.tsx
+++ b/apps/builder/src/features/flows/react-flow/steps/index.tsx
@@ -52,6 +52,7 @@ import { unassignConversationStep } from "./unassign-conversation"
import { unfollowConversationStep } from "./unfollow-conversation"
import { unsubscribeBroadcastStep } from "./unsubscribe-broadcast"
import { unsubscribeSequenceStep } from "./unsubscribe-sequence"
+import { updateContactLifecycleStageStep } from "./update-contact-lifecycle-stage"
import { waitStep } from "./wait"
import whatsappOptionListStep from "./whatsapp-option-list"
@@ -107,6 +108,7 @@ export const allSteps: Record
| undefined> = {
[stepTypes.enum.unsubscribeBroadcast]: unsubscribeBroadcastStep,
[stepTypes.enum.subscribeSequence]: subscribeSequenceStep,
[stepTypes.enum.unsubscribeSequence]: unsubscribeSequenceStep,
+ [stepTypes.enum.updateContactLifecycleStage]: updateContactLifecycleStageStep,
[stepTypes.enum.chooseChannel]: chooseChannelStep,
[stepTypes.enum.filterContact]: undefined,
[stepTypes.enum.addNotes]: addNotesStep,
diff --git a/apps/builder/src/features/flows/react-flow/steps/send-carousel/editor.tsx b/apps/builder/src/features/flows/react-flow/steps/send-carousel/editor.tsx
index 9f129515f..d12963955 100644
--- a/apps/builder/src/features/flows/react-flow/steps/send-carousel/editor.tsx
+++ b/apps/builder/src/features/flows/react-flow/steps/send-carousel/editor.tsx
@@ -116,7 +116,7 @@ const SendCarouselStepEditor = (props: SendCarouselStepEditorProps) => {
type="button"
>
- Add slide
+ Adicionar slide
{
variant="destructive"
>
- Remove slide
+ Remover slide
diff --git a/apps/builder/src/features/flows/react-flow/steps/send-wa-template-message/viewer.tsx b/apps/builder/src/features/flows/react-flow/steps/send-wa-template-message/viewer.tsx
index e1068ae78..be63f0a2d 100644
--- a/apps/builder/src/features/flows/react-flow/steps/send-wa-template-message/viewer.tsx
+++ b/apps/builder/src/features/flows/react-flow/steps/send-wa-template-message/viewer.tsx
@@ -23,7 +23,7 @@ export const SendWaTemplateMessageStepViewer = (
- {data.template.name || "Template Message"}
+ {data.template.name || "Mensagem Template"}
{data.template.language && (
diff --git a/apps/builder/src/features/flows/react-flow/steps/spreadsheet/components/spreadsheet-column-filter.tsx b/apps/builder/src/features/flows/react-flow/steps/spreadsheet/components/spreadsheet-column-filter.tsx
index 8e03787bb..d8177afba 100644
--- a/apps/builder/src/features/flows/react-flow/steps/spreadsheet/components/spreadsheet-column-filter.tsx
+++ b/apps/builder/src/features/flows/react-flow/steps/spreadsheet/components/spreadsheet-column-filter.tsx
@@ -67,7 +67,7 @@ export const SpreadsheetColumnFilter = ({
/>
remove(idx)} type="button" variant="ghost">
diff --git a/apps/builder/src/features/flows/react-flow/steps/spreadsheet/spreadsheet-operator-select.tsx b/apps/builder/src/features/flows/react-flow/steps/spreadsheet/spreadsheet-operator-select.tsx
index a968dd800..cfa76ab6c 100644
--- a/apps/builder/src/features/flows/react-flow/steps/spreadsheet/spreadsheet-operator-select.tsx
+++ b/apps/builder/src/features/flows/react-flow/steps/spreadsheet/spreadsheet-operator-select.tsx
@@ -15,16 +15,16 @@ export const SpreadsheetOperatorSelect = ({
}: ISpreadsheetOperatorSelectProps) => {
const operators = useMemo(
() => [
- { label: "Is", value: Operator.IS },
- { label: "Is Not", value: Operator.IS_NOT },
- { label: "Greater than or Equal to", value: Operator.GTE },
- { label: "Less than or Equal to", value: Operator.LTE },
- { label: "Greater than", value: Operator.GT },
- { label: "Less than", value: Operator.LT },
- { label: "Contains", value: Operator.CONTAINS },
- { label: "Not Contains", value: Operator.NOT_CONTAINS },
- { label: "Starts With", value: Operator.STARTS_WITH },
- { label: "Ends With", value: Operator.ENDS_WITH },
+ { label: "É", value: Operator.IS },
+ { label: "Não é", value: Operator.IS_NOT },
+ { label: "Maior ou igual a", value: Operator.GTE },
+ { label: "Menor ou igual a", value: Operator.LTE },
+ { label: "Maior que", value: Operator.GT },
+ { label: "Menor que", value: Operator.LT },
+ { label: "Contém", value: Operator.CONTAINS },
+ { label: "Não contém", value: Operator.NOT_CONTAINS },
+ { label: "Começa com", value: Operator.STARTS_WITH },
+ { label: "Termina com", value: Operator.ENDS_WITH },
],
[],
)
@@ -34,7 +34,7 @@ export const SpreadsheetOperatorSelect = ({
label={label}
name={name}
options={operators}
- placeholder="Please select"
+ placeholder="Por favor selecione"
/>
)
}
diff --git a/apps/builder/src/features/flows/react-flow/steps/spreadsheet/worksheet-column-select.tsx b/apps/builder/src/features/flows/react-flow/steps/spreadsheet/worksheet-column-select.tsx
index c7adfd3b0..da909a03b 100644
--- a/apps/builder/src/features/flows/react-flow/steps/spreadsheet/worksheet-column-select.tsx
+++ b/apps/builder/src/features/flows/react-flow/steps/spreadsheet/worksheet-column-select.tsx
@@ -46,7 +46,7 @@ export const WorksheetColumnSelect = ({
label={label}
name={getFieldName(name)}
options={headers}
- placeholder="Please select"
+ placeholder="Por favor selecione"
/>
)
}
diff --git a/apps/builder/src/features/flows/react-flow/steps/spreadsheet/worksheet-select.tsx b/apps/builder/src/features/flows/react-flow/steps/spreadsheet/worksheet-select.tsx
index 209675288..a7de7f633 100644
--- a/apps/builder/src/features/flows/react-flow/steps/spreadsheet/worksheet-select.tsx
+++ b/apps/builder/src/features/flows/react-flow/steps/spreadsheet/worksheet-select.tsx
@@ -14,7 +14,7 @@ type WorksheetSelectProps = {
export const WorksheetSelect = ({
name,
spreadsheetId,
- label = "Worksheet",
+ label = "Aba",
required = true,
}: WorksheetSelectProps) => {
const workspaceId = useWorkspaceId()
@@ -39,7 +39,7 @@ export const WorksheetSelect = ({
label={label}
name={name}
options={worksheetOptions}
- placeholder="Please select"
+ placeholder="Por favor selecione"
required={required}
/>
)
diff --git a/apps/builder/src/features/flows/react-flow/steps/update-contact-lifecycle-stage/editor.tsx b/apps/builder/src/features/flows/react-flow/steps/update-contact-lifecycle-stage/editor.tsx
new file mode 100644
index 000000000..8990f9795
--- /dev/null
+++ b/apps/builder/src/features/flows/react-flow/steps/update-contact-lifecycle-stage/editor.tsx
@@ -0,0 +1,212 @@
+"use client"
+
+import type { LifecycleStageModel } from "@chatbotx.io/database/types"
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectTrigger,
+ SelectValue,
+} from "@chatbotx.io/ui/components/ui/select"
+import { cn } from "@chatbotx.io/ui/lib/utils"
+import { RefreshCwIcon } from "lucide-react"
+import { useTranslations } from "next-intl"
+import { useEffect, useMemo, useState } from "react"
+import { Controller, useFormContext } from "react-hook-form"
+import { listLifecycleStages } from "@/features/lifecycle-stages/queries"
+import { useWorkspaceId } from "@/hooks/routing"
+import { BaseStepEditor } from "../base/editor"
+
+type UpdateContactLifecycleStageStepEditorProps = {
+ parentName: string
+}
+
+const UpdateContactLifecycleStageStepEditor = ({
+ parentName,
+}: UpdateContactLifecycleStageStepEditorProps) => {
+ const t = useTranslations()
+ const workspaceId = useWorkspaceId()
+ const { control, watch, setValue } = useFormContext()
+ const [stages, setStages] = useState([])
+
+ const action: "update" | "remove" = watch(`${parentName}.action`) ?? "update"
+
+ useEffect(() => {
+ let cancelled = false
+ listLifecycleStages(workspaceId)
+ .then((data) => {
+ if (!cancelled) {
+ setStages(data)
+ }
+ })
+ .catch(() => {
+ if (!cancelled) {
+ setStages([])
+ }
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [workspaceId])
+
+ const { activeStages, lostStages } = useMemo(
+ () => ({
+ activeStages: stages.filter((s) => !s.isLost),
+ lostStages: stages.filter((s) => s.isLost),
+ }),
+ [stages],
+ )
+
+ const setAction = (next: "update" | "remove") => {
+ setValue(`${parentName}.action`, next, { shouldDirty: true })
+ if (next === "remove") {
+ // Limpa a stage selecionada pra não validar
+ setValue(`${parentName}.lifecycleStageId`, "", { shouldDirty: true })
+ }
+ }
+
+ return (
+
+
+
+ {t("lifecycle.stepHint")}
+
+
+ {/* Modo: Atualizar etapa */}
+
setAction("update")}
+ type="button"
+ >
+
+
+
+
+ {t("lifecycle.stepUpdateAction")}
+
+
+ {t("lifecycle.stepUpdateActionHint")}
+
+
+
+
+
+ {/* Modo: Remover etapa */}
+
setAction("remove")}
+ type="button"
+ >
+
+
+
+
+ {t("lifecycle.stepRemoveAction")}
+
+
+ {t("lifecycle.stepRemoveActionHint")}
+
+
+
+
+
+ {/* Select de stage quando modo "update" */}
+ {action === "update" && (
+
+
+ {t("lifecycle.stepStageLabel")}
+
+ (
+ <>
+
+
+
+
+
+ {activeStages.length > 0 && (
+
+
+ {t("lifecycle.activeTitle")}
+
+ {activeStages.map((s) => (
+
+ {s.icon ?? "•"}
+ {s.name}
+
+ ))}
+
+ )}
+ {lostStages.length > 0 && (
+
+ {t("lifecycle.lostTitle")}
+ {lostStages.map((s) => (
+
+ {s.icon ?? "•"}
+ {s.name}
+
+ ))}
+
+ )}
+
+
+ {fieldState.error?.message && (
+
+ {fieldState.error.message}
+
+ )}
+ >
+ )}
+ />
+
+ )}
+
+
+ )
+}
+
+export default UpdateContactLifecycleStageStepEditor
diff --git a/apps/builder/src/features/flows/react-flow/steps/update-contact-lifecycle-stage/index.tsx b/apps/builder/src/features/flows/react-flow/steps/update-contact-lifecycle-stage/index.tsx
new file mode 100644
index 000000000..0e5816a9c
--- /dev/null
+++ b/apps/builder/src/features/flows/react-flow/steps/update-contact-lifecycle-stage/index.tsx
@@ -0,0 +1,16 @@
+import {
+ type UpdateContactLifecycleStageStepSchema,
+ updateContactLifecycleStageStepDefaultFn,
+ updateContactLifecycleStageStepSchema,
+} from "@chatbotx.io/flow-config"
+import type { StepDefinition } from "../definition"
+import UpdateContactLifecycleStageStepEditor from "./editor"
+import UpdateContactLifecycleStageStepViewer from "./viewer"
+
+export const updateContactLifecycleStageStep: StepDefinition =
+ {
+ editor: UpdateContactLifecycleStageStepEditor,
+ viewer: UpdateContactLifecycleStageStepViewer,
+ validator: updateContactLifecycleStageStepSchema,
+ defaultFn: updateContactLifecycleStageStepDefaultFn,
+ }
diff --git a/apps/builder/src/features/flows/react-flow/steps/update-contact-lifecycle-stage/viewer.tsx b/apps/builder/src/features/flows/react-flow/steps/update-contact-lifecycle-stage/viewer.tsx
new file mode 100644
index 000000000..04bcf6652
--- /dev/null
+++ b/apps/builder/src/features/flows/react-flow/steps/update-contact-lifecycle-stage/viewer.tsx
@@ -0,0 +1,18 @@
+"use client"
+
+import { RefreshCwIcon } from "lucide-react"
+import { useTranslations } from "next-intl"
+import { BaseStepViewer } from "../base/viewer"
+
+const UpdateContactLifecycleStageStepViewer = () => {
+ const t = useTranslations()
+
+ return (
+
+ )
+}
+
+export default UpdateContactLifecycleStageStepViewer
diff --git a/apps/builder/src/features/flows/react-flow/steps/wait/components/time-select.tsx b/apps/builder/src/features/flows/react-flow/steps/wait/components/time-select.tsx
index bdc3b3f69..97940c5de 100644
--- a/apps/builder/src/features/flows/react-flow/steps/wait/components/time-select.tsx
+++ b/apps/builder/src/features/flows/react-flow/steps/wait/components/time-select.tsx
@@ -16,7 +16,13 @@ const TimeSelect = (props: TimeSelectProps) => {
})
}
- return
+ return (
+
+ )
}
export default TimeSelect
diff --git a/apps/builder/src/features/flows/react-flow/stores/flow-template-store.ts b/apps/builder/src/features/flows/react-flow/stores/flow-template-store.ts
index e05046c2e..73b13d6c9 100644
--- a/apps/builder/src/features/flows/react-flow/stores/flow-template-store.ts
+++ b/apps/builder/src/features/flows/react-flow/stores/flow-template-store.ts
@@ -46,7 +46,7 @@ export const createFlowTemplateStore = (props: Partial) =>
error:
error instanceof HTTPError
? error.message
- : "Failed to fetch WA templates",
+ : "Falha ao buscar templates do WhatsApp",
})
} finally {
set({ initialized: true })
@@ -81,7 +81,7 @@ export const createFlowTemplateStore = (props: Partial) =>
error:
error instanceof HTTPError
? error.message
- : "Failed to fetch WA templates",
+ : "Falha ao buscar templates do WhatsApp",
})
} finally {
set({ loadingWhatsappTemplates: false })
diff --git a/apps/builder/src/features/flows/react-flow/toolbar/duplicate-node.tsx b/apps/builder/src/features/flows/react-flow/toolbar/duplicate-node.tsx
index 3bc01da9c..c25e28060 100644
--- a/apps/builder/src/features/flows/react-flow/toolbar/duplicate-node.tsx
+++ b/apps/builder/src/features/flows/react-flow/toolbar/duplicate-node.tsx
@@ -19,7 +19,7 @@ export function DuplicateNode() {
const duplicateNode = useCallback(
(node: FlowNode) => {
const newNodeData = {
- name: `${node.data.name} Copy`,
+ name: `${node.data.name} ${t("flowEditor.copySuffix")}`,
details: clone(node.data.details),
}
if ("beforeStep" in newNodeData.details) {
@@ -53,7 +53,7 @@ export function DuplicateNode() {
},
])
},
- [addNodes],
+ [addNodes, t],
)
const onClick = (e: MouseEvent) => {
diff --git a/apps/builder/src/features/flows/react-flow/toolbar/get-node-id.tsx b/apps/builder/src/features/flows/react-flow/toolbar/get-node-id.tsx
index 92e7f715d..a15cb3573 100644
--- a/apps/builder/src/features/flows/react-flow/toolbar/get-node-id.tsx
+++ b/apps/builder/src/features/flows/react-flow/toolbar/get-node-id.tsx
@@ -24,7 +24,7 @@ export function GetNodeId() {
const activeNode = allNodes.find((n) => n.data.forceToolbarVisible)
if (activeNode) {
copy(activeNode.id).then(() => {
- toast.success("Copied Node ID")
+ toast.success("ID do Nó Copiado")
})
}
}
diff --git a/apps/builder/src/features/flows/react-flow/toolbar/node-toolbar.tsx b/apps/builder/src/features/flows/react-flow/toolbar/node-toolbar.tsx
index 299db5efd..ba0c2b57c 100644
--- a/apps/builder/src/features/flows/react-flow/toolbar/node-toolbar.tsx
+++ b/apps/builder/src/features/flows/react-flow/toolbar/node-toolbar.tsx
@@ -6,9 +6,13 @@ import { SetStartNode } from "./set-start-node"
export function FlowNodeToolbar({ isStartNode }: { isStartNode: boolean }) {
return (
-
+ {/*
+ StartNode (= TriggerNode) é único, não-duplicável e não-deletável.
+ Mantém apenas GetNodeId pra inspeção/debug.
+ */}
+ {!isStartNode && }
-
+ {!isStartNode && }
{!isStartNode && }
)
diff --git a/apps/builder/src/features/flows/shortcut-menu.tsx b/apps/builder/src/features/flows/shortcut-menu.tsx
new file mode 100644
index 000000000..60af7c1af
--- /dev/null
+++ b/apps/builder/src/features/flows/shortcut-menu.tsx
@@ -0,0 +1,163 @@
+"use client"
+
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@chatbotx.io/ui/components/ui/dropdown-menu"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@chatbotx.io/ui/components/ui/tooltip"
+import { Flash } from "iconsax-reactjs"
+import { type IconsaxComponent, wrapIconsax } from "@/components/iconsax-icon"
+
+const ZapIcon = wrapIconsax(Flash as IconsaxComponent, "Bold")
+
+import { useTranslations } from "next-intl"
+import { useAction } from "next-safe-action/hooks"
+import { useEffect, useState } from "react"
+import { toast } from "sonner"
+import { useWorkspaceId } from "@/hooks/routing"
+import { triggerShortcutAction } from "./actions/trigger-shortcut-action"
+import {
+ listShortcutFlows,
+ type ShortcutFlow,
+} from "./queries/list-shortcut-flows"
+
+type ShortcutMenuProps = {
+ conversationId: string
+}
+
+/**
+ * Botão "⚡ Atalhos" no header da conversa do Inbox.
+ * Lista flows ativos com TriggerNode tipo "shortcut" e dispara o que o
+ * atendente clicar (estilo Respond.io).
+ *
+ * Esconde o botão inteiro se não houver shortcut configurado no workspace.
+ */
+export function ShortcutMenu({ conversationId }: ShortcutMenuProps) {
+ const t = useTranslations()
+ const workspaceId = useWorkspaceId()
+ const [shortcuts, setShortcuts] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [open, setOpen] = useState(false)
+
+ // Refetch sempre que o menu abrir — atalhos podem ter sido adicionados no
+ // flow builder em outra aba.
+ useEffect(() => {
+ if (!open) {
+ return
+ }
+ let cancelled = false
+ setLoading(true)
+ listShortcutFlows(workspaceId)
+ .then((data) => {
+ if (!cancelled) {
+ setShortcuts(data)
+ setLoading(false)
+ }
+ })
+ .catch(() => {
+ if (!cancelled) {
+ setLoading(false)
+ }
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [open, workspaceId])
+
+ // 1ª carga (pra decidir se mostra o botão ou esconde).
+ useEffect(() => {
+ let cancelled = false
+ listShortcutFlows(workspaceId)
+ .then((data) => {
+ if (!cancelled) {
+ setShortcuts(data)
+ setLoading(false)
+ }
+ })
+ .catch(() => {
+ if (!cancelled) {
+ setLoading(false)
+ }
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [workspaceId])
+
+ const { execute, isExecuting } = useAction(
+ triggerShortcutAction.bind(null, workspaceId),
+ {
+ onSuccess: () => {
+ toast.success(t("trigger.shortcutMenu.fired"))
+ setOpen(false)
+ },
+ onError: ({ error }) => {
+ toast.error(error.serverError ?? t("trigger.shortcutMenu.fireError"))
+ },
+ },
+ )
+
+ // Pedro pediu 2026-05-25: botão SEMPRE visível pra o usuário saber que
+ // existe a feature de atalhos (Workflows com gatilho "shortcut"). Quando
+ // não há atalho configurado, mostra mensagem indicando como criar.
+
+ return (
+
+
+
+
+
+
+
+
+
+ {t("trigger.shortcutMenu.button")}
+
+
+ {t("trigger.shortcutMenu.title")}
+
+ {(() => {
+ if (loading) {
+ return (
+
+ {t("trigger.shortcutMenu.loading")}
+
+ )
+ }
+ if (shortcuts.length === 0) {
+ return (
+
+ {t("trigger.shortcutMenu.empty")}
+
+ )
+ }
+ return shortcuts.map((s) => (
+ execute({ conversationId, flowId: s.flowId })}
+ >
+ {s.icon}
+ {s.label}
+
+ ))
+ })()}
+
+
+ )
+}
diff --git a/apps/builder/src/features/folders/api/authenticated.ts b/apps/builder/src/features/folders/api/authenticated.ts
index a4bb0336a..4cb8871c6 100644
--- a/apps/builder/src/features/folders/api/authenticated.ts
+++ b/apps/builder/src/features/folders/api/authenticated.ts
@@ -8,8 +8,8 @@ export const foldersAuthenticatedAPI = {
.route({
method: "GET",
path: "/workspaces/{workspaceId}/folders",
- summary: "List folders",
- tags: ["Folders"],
+ summary: "Listar pastas",
+ tags: ["Pastas"],
})
.input(listFoldersRequest)
.use(workspaceAuthorizedMidddleware, (input) => input.workspaceId)
diff --git a/apps/builder/src/features/folders/provider/folder-store.ts b/apps/builder/src/features/folders/provider/folder-store.ts
index cd2d36974..64a792f54 100644
--- a/apps/builder/src/features/folders/provider/folder-store.ts
+++ b/apps/builder/src/features/folders/provider/folder-store.ts
@@ -48,7 +48,7 @@ export const createFolderStore = (props: Partial) =>
error:
error instanceof HTTPError
? error.message
- : "Failed to fetch folders",
+ : "Falha ao buscar pastas",
})
} finally {
set({ initialized: true })
@@ -80,7 +80,7 @@ export const createFolderStore = (props: Partial) =>
error:
error instanceof HTTPError
? error.message
- : "Failed to fetch folders",
+ : "Falha ao buscar pastas",
})
} finally {
set({ loading: false })
diff --git a/apps/builder/src/features/inboxes/api/authenticated.ts b/apps/builder/src/features/inboxes/api/authenticated.ts
index 9fe1a513c..f0f9b709b 100644
--- a/apps/builder/src/features/inboxes/api/authenticated.ts
+++ b/apps/builder/src/features/inboxes/api/authenticated.ts
@@ -8,8 +8,8 @@ export const inboxesAuthenticatedAPI = {
.route({
method: "GET",
path: "/workspaces/{workspaceId}/inboxes",
- summary: "List inboxes",
- tags: ["Inboxes"],
+ summary: "Listar caixas de entrada",
+ tags: ["Caixas de Entrada"],
})
.input(listInboxesRequest)
.use(workspaceAuthorizedMidddleware, (input) => input.workspaceId)
diff --git a/apps/builder/src/features/inboxes/api/workspace-token.ts b/apps/builder/src/features/inboxes/api/workspace-token.ts
index ad1ad8f73..b682b4b00 100644
--- a/apps/builder/src/features/inboxes/api/workspace-token.ts
+++ b/apps/builder/src/features/inboxes/api/workspace-token.ts
@@ -10,8 +10,8 @@ export const inboxesWorkspaceTokenAPIs = {
.route({
method: "GET",
path: "/v1/channels",
- summary: "List channels",
- tags: ["Channels"],
+ summary: "Listar canais",
+ tags: ["Canais"],
})
.input(publishInboxesRequest)
.output(publicListInboxesResponse)
diff --git a/apps/builder/src/features/inboxes/components/get-inbox-url.tsx b/apps/builder/src/features/inboxes/components/get-inbox-url.tsx
index cff9095ba..cd7504bdd 100644
--- a/apps/builder/src/features/inboxes/components/get-inbox-url.tsx
+++ b/apps/builder/src/features/inboxes/components/get-inbox-url.tsx
@@ -36,7 +36,7 @@ export function GetInboxUrlDialog({
- Get Link
+ Obter Link
diff --git a/apps/builder/src/features/inboxes/components/inbox-select-card.tsx b/apps/builder/src/features/inboxes/components/inbox-select-card.tsx
index d103c6b51..a351d91e3 100644
--- a/apps/builder/src/features/inboxes/components/inbox-select-card.tsx
+++ b/apps/builder/src/features/inboxes/components/inbox-select-card.tsx
@@ -113,7 +113,10 @@ function InboxSelectCard({
-
+
{inboxOptions.map((inbox) => (
diff --git a/apps/builder/src/features/inboxes/provider/inbox-store.ts b/apps/builder/src/features/inboxes/provider/inbox-store.ts
index b7bf03332..f1969f5be 100644
--- a/apps/builder/src/features/inboxes/provider/inbox-store.ts
+++ b/apps/builder/src/features/inboxes/provider/inbox-store.ts
@@ -47,7 +47,7 @@ export const createInboxStore = (props: Partial
) =>
error:
error instanceof HTTPError
? error.message
- : "Failed to fetch inboxes",
+ : "Falha ao buscar caixas de entrada",
})
} finally {
set({ initialized: true })
@@ -82,7 +82,7 @@ export const createInboxStore = (props: Partial) =>
error:
error instanceof HTTPError
? error.message
- : "Failed to fetch inboxes",
+ : "Falha ao buscar caixas de entrada",
})
} finally {
set({ loadingInboxes: false })
diff --git a/apps/builder/src/features/integration-google-sheets/actions/disconnect.action.ts b/apps/builder/src/features/integration-google-sheets/actions/disconnect.action.ts
index f16fada42..23962ff0f 100644
--- a/apps/builder/src/features/integration-google-sheets/actions/disconnect.action.ts
+++ b/apps/builder/src/features/integration-google-sheets/actions/disconnect.action.ts
@@ -29,7 +29,7 @@ export const disconnectGoogleSheets = authActionClient
where: {
workspaceId,
},
- message: "Integration Google Sheets not found",
+ message: "Integração Google Sheets não encontrada",
})
try {
await integrationGoogleSheets.disconnect?.(
diff --git a/apps/builder/src/features/integration-instagram/components/instagram-manage.tsx b/apps/builder/src/features/integration-instagram/components/instagram-manage.tsx
index bbabb05cf..8cd172227 100644
--- a/apps/builder/src/features/integration-instagram/components/instagram-manage.tsx
+++ b/apps/builder/src/features/integration-instagram/components/instagram-manage.tsx
@@ -91,7 +91,7 @@ export function InstagramManage({
))}
{integrationInstagrams.length === 0 && (
- No data
+ Sem dados
)}
diff --git a/apps/builder/src/features/integration-messenger/actions/select-page.action.ts b/apps/builder/src/features/integration-messenger/actions/select-page.action.ts
index 472ca35ce..672fcb20d 100644
--- a/apps/builder/src/features/integration-messenger/actions/select-page.action.ts
+++ b/apps/builder/src/features/integration-messenger/actions/select-page.action.ts
@@ -69,7 +69,7 @@ export const selectPageAction = authActionClient
},
})
if (existedPage) {
- throw new ChatbotXException("Page is already connected")
+ throw new ChatbotXException("Página já conectada")
}
await db.transaction(async (tx) => {
diff --git a/apps/builder/src/features/integration-messenger/messenger-manage.tsx b/apps/builder/src/features/integration-messenger/messenger-manage.tsx
index 6778c59b9..82ef3ca34 100644
--- a/apps/builder/src/features/integration-messenger/messenger-manage.tsx
+++ b/apps/builder/src/features/integration-messenger/messenger-manage.tsx
@@ -90,7 +90,7 @@ export function MessengerManage({
))}
{integrationMessengers.length === 0 && (
- No data
+ Sem dados
)}
diff --git a/apps/builder/src/features/integration-messenger/update-messenger-form.tsx b/apps/builder/src/features/integration-messenger/update-messenger-form.tsx
index e86e83fea..a4ad2b23b 100644
--- a/apps/builder/src/features/integration-messenger/update-messenger-form.tsx
+++ b/apps/builder/src/features/integration-messenger/update-messenger-form.tsx
@@ -260,7 +260,7 @@ export function UpdateMessengerForm({
diff --git a/apps/builder/src/features/integration-smtp/api/authenticated.ts b/apps/builder/src/features/integration-smtp/api/authenticated.ts
index ce83cb51f..d3b010622 100644
--- a/apps/builder/src/features/integration-smtp/api/authenticated.ts
+++ b/apps/builder/src/features/integration-smtp/api/authenticated.ts
@@ -13,8 +13,8 @@ export const integrationSmtpAuthenticatedAPI = {
.route({
method: "GET",
path: "/workspaces/{workspaceId}/integrations/smtp",
- summary: "List SMTP integrations",
- tags: ["Integrations"],
+ summary: "Listar integrações SMTP",
+ tags: ["Integrações"],
})
.input(listIntegrationSmtpsRequest)
.use(workspaceAuthorizedMidddleware, (input) => input.workspaceId)
diff --git a/apps/builder/src/features/integration-smtp/smtp-manage.tsx b/apps/builder/src/features/integration-smtp/smtp-manage.tsx
index 23b395d06..60f65310e 100644
--- a/apps/builder/src/features/integration-smtp/smtp-manage.tsx
+++ b/apps/builder/src/features/integration-smtp/smtp-manage.tsx
@@ -65,7 +65,7 @@ export const SmtpManage = ({ workspaceId, promises }: SmtpManageProps) => {
))}
{integrationSmtps.length === 0 && (
- No data
+ Sem dados
)}
diff --git a/apps/builder/src/features/integration-telegram/actions/connect.action.ts b/apps/builder/src/features/integration-telegram/actions/connect.action.ts
index 4a6dc61e2..9b2787b7f 100644
--- a/apps/builder/src/features/integration-telegram/actions/connect.action.ts
+++ b/apps/builder/src/features/integration-telegram/actions/connect.action.ts
@@ -51,7 +51,7 @@ export const connectTelegramAction = authActionClient
where: {
botId: botData.id,
},
- message: "Bot is already connected",
+ message: "Bot já conectado",
})
return await db.transaction(async (tx) => {
@@ -97,7 +97,7 @@ export const connectTelegramAction = authActionClient
.then((result) => result[0])
if (!inbox) {
- throw new Error("Failed to create inbox")
+ throw new Error("Falha ao criar caixa de entrada")
}
await tx.insert(integrationTelegramModel).values({
diff --git a/apps/builder/src/features/integration-telegram/telegram-manage.tsx b/apps/builder/src/features/integration-telegram/telegram-manage.tsx
index 8ab78d867..006823552 100644
--- a/apps/builder/src/features/integration-telegram/telegram-manage.tsx
+++ b/apps/builder/src/features/integration-telegram/telegram-manage.tsx
@@ -57,7 +57,7 @@ export function TelegramManage({ workspaceId, promises }: TelegramManageProps) {
))}
{integrationTelegrams.length === 0 && (
- No data
+ Sem dados
)}
diff --git a/apps/builder/src/features/integration-webchat/actions/update-webchat.action.ts b/apps/builder/src/features/integration-webchat/actions/update-webchat.action.ts
index 9c767ade4..11f9202b5 100644
--- a/apps/builder/src/features/integration-webchat/actions/update-webchat.action.ts
+++ b/apps/builder/src/features/integration-webchat/actions/update-webchat.action.ts
@@ -23,7 +23,7 @@ export const updateWebchatAction = workspaceActionClient
id,
workspaceId,
},
- message: "Webchat integration not found",
+ message: "Integração Webchat não encontrada",
})
await db.transaction(async (tx) => {
diff --git a/apps/builder/src/features/integration-webchat/api/authenticated.ts b/apps/builder/src/features/integration-webchat/api/authenticated.ts
index cbe1e53a3..30b0069b9 100644
--- a/apps/builder/src/features/integration-webchat/api/authenticated.ts
+++ b/apps/builder/src/features/integration-webchat/api/authenticated.ts
@@ -12,8 +12,8 @@ export const integrationWebchatAuthenticatedAPI = {
.route({
method: "GET",
path: "/workspaces/{workspaceId}/integration-webchats",
- summary: "List Integration Webchats",
- tags: ["Integration Webchats"],
+ summary: "Listar integrações Webchat",
+ tags: ["Integrações Webchat"],
})
.input(listIntegrationWebchatsRequest.and(withWorkspaceIdSchema))
.use(workspaceAuthorizedMidddleware, (input) => input.workspaceId)
diff --git a/apps/builder/src/features/integration-webchat/components/create-webchat-form.tsx b/apps/builder/src/features/integration-webchat/components/create-webchat-form.tsx
index 6f41f54ea..01a2dcb57 100644
--- a/apps/builder/src/features/integration-webchat/components/create-webchat-form.tsx
+++ b/apps/builder/src/features/integration-webchat/components/create-webchat-form.tsx
@@ -82,7 +82,7 @@ export function CreateWebchatForm({ workspaceId }: { workspaceId: string }) {
router.push(`/space/${workspaceId}/settings/channels`)
},
onError: ({ error }) => {
- toast.error(error.serverError || "Failed to create webchat")
+ toast.error(error.serverError || "Falha ao criar webchat")
},
},
formProps: {
@@ -121,7 +121,7 @@ export function CreateWebchatForm({ workspaceId }: { workspaceId: string }) {
return (
-
+
{
- toast.error(error.serverError || "Failed to update webchat")
+ toast.error(error.serverError || "Falha ao atualizar webchat")
},
},
formProps: {
@@ -141,7 +141,7 @@ export function UpdateWebchatForm({
return (
-
+
execute({
diff --git a/apps/builder/src/features/integration-webchat/providers/store/guest-sesssion-store.ts b/apps/builder/src/features/integration-webchat/providers/store/guest-sesssion-store.ts
index 3ad4204f8..199f24e76 100644
--- a/apps/builder/src/features/integration-webchat/providers/store/guest-sesssion-store.ts
+++ b/apps/builder/src/features/integration-webchat/providers/store/guest-sesssion-store.ts
@@ -111,7 +111,7 @@ export const createGuestSessionStore = (props: IntegrationWebchatModel) => {
})
} catch (error) {
set({ isLoadMoreMessage: false })
- console.error("Failed to load more messages:", error)
+ console.error("Falha ao carregar mais mensagens:", error)
throw error
}
},
@@ -171,7 +171,7 @@ export const createGuestSessionStore = (props: IntegrationWebchatModel) => {
})
}
} catch (error) {
- console.error("Failed to send postback:", error)
+ console.error("Falha ao enviar postback:", error)
throw error
}
},
@@ -193,6 +193,11 @@ export const createGuestSessionStore = (props: IntegrationWebchatModel) => {
senderId: "",
clientId: createId(),
contactInboxId: "",
+ isInternal: false,
+ deliveredAt: null,
+ readAt: null,
+ failedAt: null,
+ failureReason: null,
...message,
}
diff --git a/apps/builder/src/features/integration-webchat/queries/index.ts b/apps/builder/src/features/integration-webchat/queries/index.ts
index cc8736ed6..5a69d48e2 100644
--- a/apps/builder/src/features/integration-webchat/queries/index.ts
+++ b/apps/builder/src/features/integration-webchat/queries/index.ts
@@ -49,6 +49,6 @@ export async function findIntegrationWebchat(
return await findOrFail({
table: integrationWebchatModel,
where,
- message: "Integration webchat not found",
+ message: "Integração webchat não encontrada",
})
}
diff --git a/apps/builder/src/features/integration-webchat/schema/mutation.ts b/apps/builder/src/features/integration-webchat/schema/mutation.ts
index 14a1c6b12..2ae047c15 100644
--- a/apps/builder/src/features/integration-webchat/schema/mutation.ts
+++ b/apps/builder/src/features/integration-webchat/schema/mutation.ts
@@ -16,7 +16,7 @@ export const createWebchatRequest = z.object({
),
conversationStarters: z.array(webchatConversationStarter),
persistentMenus: z.array(webchatPersistentMenu),
- brandColor: z.string().regex(/^#[0-9A-Fa-f]{6}$/, "Invalid color format"),
+ brandColor: z.string().regex(/^#[0-9A-Fa-f]{6}$/, "Formato de cor inválido"),
hideHeader: z.boolean().default(true),
showLogo: z.boolean().default(false),
hideMessageInput: z.boolean().default(true),
diff --git a/apps/builder/src/features/integration-webchat/simple-create-webchat.tsx b/apps/builder/src/features/integration-webchat/simple-create-webchat.tsx
index 56eeee8bc..c371f81e5 100644
--- a/apps/builder/src/features/integration-webchat/simple-create-webchat.tsx
+++ b/apps/builder/src/features/integration-webchat/simple-create-webchat.tsx
@@ -43,7 +43,7 @@ export function SimpleCreateWebchat({ workspaceId }: SimpleCreateWebchatProps) {
return router.push("/")
},
onError: ({ error }) => {
- toast.error(error.serverError || "Failed to create webchat")
+ toast.error(error.serverError || "Falha ao criar webchat")
},
},
formProps: {
@@ -75,7 +75,7 @@ export function SimpleCreateWebchat({ workspaceId }: SimpleCreateWebchatProps) {
-
+
input.workspaceId)
diff --git a/apps/builder/src/features/integration-whatsapp/automation/queries/index.ts b/apps/builder/src/features/integration-whatsapp/automation/queries/index.ts
index 69583a85d..fcb0c10ab 100644
--- a/apps/builder/src/features/integration-whatsapp/automation/queries/index.ts
+++ b/apps/builder/src/features/integration-whatsapp/automation/queries/index.ts
@@ -19,7 +19,7 @@ export const findWhatsappAutomation = async (
workspaceId: input.workspaceId,
id: input.id,
},
- message: "Whatsapp integration not found",
+ message: "Integração WhatsApp não encontrada",
})
return await findConversationalAutomation(
diff --git a/apps/builder/src/features/integration-whatsapp/components/whatsapp-create.tsx b/apps/builder/src/features/integration-whatsapp/components/whatsapp-create.tsx
index 87e7b8339..a0f12e10d 100644
--- a/apps/builder/src/features/integration-whatsapp/components/whatsapp-create.tsx
+++ b/apps/builder/src/features/integration-whatsapp/components/whatsapp-create.tsx
@@ -434,7 +434,7 @@ function ManualConnectSection({
// Event handlers
const handleListPhoneNumbers = useCallback(() => {
if (!(getValues().wabaId && getValues().accessToken)) {
- toast.error("Please fill in all required fields")
+ toast.error("Por favor preencha todos os campos obrigatórios")
return
}
diff --git a/apps/builder/src/features/integration-whatsapp/components/whatsapp-ecommerce.tsx b/apps/builder/src/features/integration-whatsapp/components/whatsapp-ecommerce.tsx
index 3bfd6dfa3..51179842c 100644
--- a/apps/builder/src/features/integration-whatsapp/components/whatsapp-ecommerce.tsx
+++ b/apps/builder/src/features/integration-whatsapp/components/whatsapp-ecommerce.tsx
@@ -8,7 +8,7 @@ export default function WhatsappEcommerce({
}) {
return (
-
Sell your products on WhatsApp
+
Venda seus produtos no WhatsApp
Manage your products
diff --git a/apps/builder/src/features/integration-whatsapp/flows/queries/index.ts b/apps/builder/src/features/integration-whatsapp/flows/queries/index.ts
index 27c889b88..d389a49bc 100644
--- a/apps/builder/src/features/integration-whatsapp/flows/queries/index.ts
+++ b/apps/builder/src/features/integration-whatsapp/flows/queries/index.ts
@@ -19,7 +19,7 @@ export async function listWhatsappFlows(
workspaceId: input.workspaceId,
id: input.id,
},
- message: "Whatsapp integration not found",
+ message: "Integração WhatsApp não encontrada",
})
return await listFlows({
diff --git a/apps/builder/src/features/integration-whatsapp/message-templates/actions/sync-message-templates.ts b/apps/builder/src/features/integration-whatsapp/message-templates/actions/sync-message-templates.ts
index c3b928f5e..3eddec7c2 100644
--- a/apps/builder/src/features/integration-whatsapp/message-templates/actions/sync-message-templates.ts
+++ b/apps/builder/src/features/integration-whatsapp/message-templates/actions/sync-message-templates.ts
@@ -25,7 +25,7 @@ export const syncMessageTemplateAction = workspaceActionClient
workspaceId,
id,
},
- message: "Whatsapp integration not found",
+ message: "Integração WhatsApp não encontrada",
})
const ctx = await buildContext({
diff --git a/apps/builder/src/features/integration-whatsapp/message-templates/api/private.ts b/apps/builder/src/features/integration-whatsapp/message-templates/api/private.ts
index 2a8bb49f8..bfde2c95b 100644
--- a/apps/builder/src/features/integration-whatsapp/message-templates/api/private.ts
+++ b/apps/builder/src/features/integration-whatsapp/message-templates/api/private.ts
@@ -11,8 +11,8 @@ export const whatsappMessageTemplateInternalAPIs = {
.route({
method: "GET",
path: "/workspaces/{workspaceId}/whatsapp-message-templates",
- summary: "List whatsapp message templates",
- tags: ["Integrations"],
+ summary: "Listar templates de mensagem do WhatsApp",
+ tags: ["Integrações"],
})
.input(listWhatsappMessageTemplatesRequest)
.use(workspaceAuthorizedMidddleware, (input) => input.workspaceId)
diff --git a/apps/builder/src/features/integration-whatsapp/message-templates/api/workspace-token.ts b/apps/builder/src/features/integration-whatsapp/message-templates/api/workspace-token.ts
index d2d625450..8393f62f9 100644
--- a/apps/builder/src/features/integration-whatsapp/message-templates/api/workspace-token.ts
+++ b/apps/builder/src/features/integration-whatsapp/message-templates/api/workspace-token.ts
@@ -10,8 +10,8 @@ export const whatsappMessageTemplateWorkspaceTokenAPIs = {
.route({
method: "GET",
path: "/v1/whatsapp-message-templates",
- summary: "List WhatsApp message templates",
- tags: ["Template Messages"],
+ summary: "Listar templates de mensagem do WhatsApp",
+ tags: ["Mensagens Template"],
})
.input(
listWhatsappMessageTemplatesRequest.omit({
diff --git a/apps/builder/src/features/integration-whatsapp/message-templates/components/category-select.tsx b/apps/builder/src/features/integration-whatsapp/message-templates/components/category-select.tsx
index f4594edf4..953b06d99 100644
--- a/apps/builder/src/features/integration-whatsapp/message-templates/components/category-select.tsx
+++ b/apps/builder/src/features/integration-whatsapp/message-templates/components/category-select.tsx
@@ -33,6 +33,7 @@ export function WhatsappMessageTemplateCategorySelect({
return [
whatsappTemplateCategories.enum.MARKETING,
whatsappTemplateCategories.enum.UTILITY,
+ whatsappTemplateCategories.enum.AUTHENTICATION,
]
}, [templateType])
@@ -40,15 +41,19 @@ export function WhatsappMessageTemplateCategorySelect({
() =>
[
{
- label: "Marketing",
+ label: t("whatsapp.category.marketing.label"),
value: whatsappTemplateCategories.enum.MARKETING,
},
{
- label: "Utility",
+ label: t("whatsapp.category.utility.label"),
value: whatsappTemplateCategories.enum.UTILITY,
},
+ {
+ label: t("whatsapp.category.authentication.label"),
+ value: whatsappTemplateCategories.enum.AUTHENTICATION,
+ },
].filter((option) => allowOptions.includes(option.value)),
- [allowOptions],
+ [allowOptions, t],
)
return (
@@ -57,7 +62,7 @@ export function WhatsappMessageTemplateCategorySelect({
label={label}
name={name}
options={options}
- placeholder="Please select"
+ placeholder="Por favor selecione"
required={required}
/>
{category === whatsappTemplateCategories.enum.MARKETING && (
@@ -80,6 +85,15 @@ export function WhatsappMessageTemplateCategorySelect({
{t("whatsapp.category.utility.description")}
)}
+ {category === whatsappTemplateCategories.enum.AUTHENTICATION && (
+
+
+
+ {t("whatsapp.category.authentication.label")}
+
+ {t("whatsapp.category.authentication.description")}
+
+ )}
>
)
}
diff --git a/apps/builder/src/features/integration-whatsapp/message-templates/components/language-select.tsx b/apps/builder/src/features/integration-whatsapp/message-templates/components/language-select.tsx
index 1c2e47ebb..f794ecde6 100644
--- a/apps/builder/src/features/integration-whatsapp/message-templates/components/language-select.tsx
+++ b/apps/builder/src/features/integration-whatsapp/message-templates/components/language-select.tsx
@@ -17,7 +17,7 @@ export function WhatsappMessageTemplateLanguageSelect({
label={label}
name={name}
options={languageOptions}
- placeholder="Please select"
+ placeholder="Por favor selecione"
required={required}
/>
)
diff --git a/apps/builder/src/features/integration-whatsapp/message-templates/components/template-preview.tsx b/apps/builder/src/features/integration-whatsapp/message-templates/components/template-preview.tsx
index 675f59888..1f223d63e 100644
--- a/apps/builder/src/features/integration-whatsapp/message-templates/components/template-preview.tsx
+++ b/apps/builder/src/features/integration-whatsapp/message-templates/components/template-preview.tsx
@@ -56,7 +56,7 @@ export function TemplatePreview({
{imageUrl ? (
) =>
error:
error instanceof HTTPError
? error.message
- : "Failed to fetch templates",
+ : "Falha ao buscar templates",
})
} finally {
set({ initialized: true })
@@ -80,7 +80,7 @@ export const createTemplateStore = (props: Partial) =>
error:
error instanceof HTTPError
? error.message
- : "Failed to fetch templates",
+ : "Falha ao buscar templates",
templates: [],
})
} finally {
diff --git a/apps/builder/src/features/integration-whatsapp/message-templates/templates/carousel-image/partial.tsx b/apps/builder/src/features/integration-whatsapp/message-templates/templates/carousel-image/partial.tsx
index fa5ffe183..8497f22dd 100644
--- a/apps/builder/src/features/integration-whatsapp/message-templates/templates/carousel-image/partial.tsx
+++ b/apps/builder/src/features/integration-whatsapp/message-templates/templates/carousel-image/partial.tsx
@@ -10,7 +10,7 @@ const VariableInput = memo(
({
parentName,
index,
- placeholder = "Type a message",
+ placeholder = "Digite uma mensagem",
}: {
parentName: string
index: number
diff --git a/apps/builder/src/features/integration-whatsapp/message-templates/templates/carousel-image/preview.tsx b/apps/builder/src/features/integration-whatsapp/message-templates/templates/carousel-image/preview.tsx
index d85e53dc6..edfe9d04a 100644
--- a/apps/builder/src/features/integration-whatsapp/message-templates/templates/carousel-image/preview.tsx
+++ b/apps/builder/src/features/integration-whatsapp/message-templates/templates/carousel-image/preview.tsx
@@ -102,7 +102,7 @@ const TemplateCarouselImagePreviewComponent = (
- Delete
+ Excluir
@@ -119,7 +119,7 @@ const TemplateCarouselImagePreviewComponent = (
- Move Left
+ Mover Esquerda
@@ -137,7 +137,7 @@ const TemplateCarouselImagePreviewComponent = (
- Move Right
+ Mover Direita
@@ -149,7 +149,7 @@ const TemplateCarouselImagePreviewComponent = (
- Add
+ Adicionar
@@ -175,7 +175,7 @@ const TemplateCarouselImagePreviewComponent = (
- Next
+ Próximo
@@ -194,7 +194,7 @@ const TemplateCarouselImagePreviewComponent = (
- Prev
+ Anterior
diff --git a/apps/builder/src/features/integration-whatsapp/message-templates/templates/carousel-video/partial.tsx b/apps/builder/src/features/integration-whatsapp/message-templates/templates/carousel-video/partial.tsx
index b4898125b..ff5f69cbe 100644
--- a/apps/builder/src/features/integration-whatsapp/message-templates/templates/carousel-video/partial.tsx
+++ b/apps/builder/src/features/integration-whatsapp/message-templates/templates/carousel-video/partial.tsx
@@ -10,7 +10,7 @@ const VariableInput = memo(
({
parentName,
index,
- placeholder = "Type a message",
+ placeholder = "Digite uma mensagem",
}: {
parentName: string
index: number
diff --git a/apps/builder/src/features/integration-whatsapp/message-templates/templates/carousel-video/preview.tsx b/apps/builder/src/features/integration-whatsapp/message-templates/templates/carousel-video/preview.tsx
index 3eff5fc81..20af415c3 100644
--- a/apps/builder/src/features/integration-whatsapp/message-templates/templates/carousel-video/preview.tsx
+++ b/apps/builder/src/features/integration-whatsapp/message-templates/templates/carousel-video/preview.tsx
@@ -103,7 +103,7 @@ const TemplateCarouselVideoPreviewComponent = (
- Delete
+ Excluir
@@ -120,7 +120,7 @@ const TemplateCarouselVideoPreviewComponent = (
- Move Left
+ Mover Esquerda
@@ -138,7 +138,7 @@ const TemplateCarouselVideoPreviewComponent = (
- Move Right
+ Mover Direita
@@ -150,7 +150,7 @@ const TemplateCarouselVideoPreviewComponent = (
- Add
+ Adicionar
@@ -176,7 +176,7 @@ const TemplateCarouselVideoPreviewComponent = (
- Next
+ Próximo
@@ -195,7 +195,7 @@ const TemplateCarouselVideoPreviewComponent = (
- Prev
+ Anterior
diff --git a/apps/builder/src/features/integration-whatsapp/message-templates/templates/catalog/partial.tsx b/apps/builder/src/features/integration-whatsapp/message-templates/templates/catalog/partial.tsx
index 08b6545e7..351f06a31 100644
--- a/apps/builder/src/features/integration-whatsapp/message-templates/templates/catalog/partial.tsx
+++ b/apps/builder/src/features/integration-whatsapp/message-templates/templates/catalog/partial.tsx
@@ -14,7 +14,7 @@ const VariableInput = memo(
diff --git a/apps/builder/src/features/integration-whatsapp/message-templates/templates/catalog/schema.ts b/apps/builder/src/features/integration-whatsapp/message-templates/templates/catalog/schema.ts
index 9a337d45b..341e4fbe6 100644
--- a/apps/builder/src/features/integration-whatsapp/message-templates/templates/catalog/schema.ts
+++ b/apps/builder/src/features/integration-whatsapp/message-templates/templates/catalog/schema.ts
@@ -16,7 +16,7 @@ export const templateCatalogSchema = z
if (data.showFooter && !data.footer?.length) {
ctx.addIssue({
path: ["footer"],
- message: "Footer text is required",
+ message: "Texto do rodapé é obrigatório",
code: z.ZodIssueCode.custom,
})
}
diff --git a/apps/builder/src/features/integration-whatsapp/message-templates/templates/components/body.tsx b/apps/builder/src/features/integration-whatsapp/message-templates/templates/components/body.tsx
index 30a851db5..de58c5f34 100644
--- a/apps/builder/src/features/integration-whatsapp/message-templates/templates/components/body.tsx
+++ b/apps/builder/src/features/integration-whatsapp/message-templates/templates/components/body.tsx
@@ -83,7 +83,7 @@ const TemplateBodyComponent = ({ parentName }: { parentName: string }) => {
autoFocus
maxLength={1024}
onChange={(e) => onChangeValue(e.target.value)}
- placeholder="Enter text"
+ placeholder="Digite o texto"
value={localBody}
/>
diff --git a/apps/builder/src/features/integration-whatsapp/message-templates/templates/components/footer.tsx b/apps/builder/src/features/integration-whatsapp/message-templates/templates/components/footer.tsx
index 5b0fbe456..411392f04 100644
--- a/apps/builder/src/features/integration-whatsapp/message-templates/templates/components/footer.tsx
+++ b/apps/builder/src/features/integration-whatsapp/message-templates/templates/components/footer.tsx
@@ -50,7 +50,7 @@ const TemplateFooterComponent = ({ parentName }: { parentName: string }) => {
autoFocus
maxLength={60}
onChange={handleTextChange}
- placeholder="Enter text"
+ placeholder="Digite o texto"
value={localFooter}
/>
diff --git a/apps/builder/src/features/integration-whatsapp/message-templates/templates/components/header.tsx b/apps/builder/src/features/integration-whatsapp/message-templates/templates/components/header.tsx
index 5c46b080a..967ecbf54 100644
--- a/apps/builder/src/features/integration-whatsapp/message-templates/templates/components/header.tsx
+++ b/apps/builder/src/features/integration-whatsapp/message-templates/templates/components/header.tsx
@@ -75,7 +75,7 @@ const TemplateHeaderComponent = ({ parentName }: { parentName: string }) => {
autoFocus
maxLength={1024}
onChange={(e) => onChangeValue(e.target.value)}
- placeholder="Enter text"
+ placeholder="Digite o texto"
value={localHeader}
/>
@@ -48,7 +48,7 @@ const TemplateDocumentPartialComponent = (props: { parentName?: string }) => {
name={`${parentName}.showFooter`}
options={[
{
- label: "Show footer",
+ label: "Mostrar rodapé",
value: "showFooter",
},
]}
diff --git a/apps/builder/src/features/integration-whatsapp/message-templates/templates/document/schema.ts b/apps/builder/src/features/integration-whatsapp/message-templates/templates/document/schema.ts
index c97246c10..3817a57f9 100644
--- a/apps/builder/src/features/integration-whatsapp/message-templates/templates/document/schema.ts
+++ b/apps/builder/src/features/integration-whatsapp/message-templates/templates/document/schema.ts
@@ -12,7 +12,7 @@ export const templateDocumentSchema = z
(file) =>
file && file instanceof File && file.type === "application/pdf",
{
- message: "File must be a PDF document",
+ message: "O arquivo deve ser um documento PDF",
},
)
.refine((file) => file && file.size <= 5 * 1024 * 1024, {
@@ -30,7 +30,7 @@ export const templateDocumentSchema = z
if (data.showFooter && !data.footer?.length) {
ctx.addIssue({
path: ["footer"],
- message: "Footer text is required",
+ message: "Texto do rodapé é obrigatório",
code: z.ZodIssueCode.custom,
})
}
diff --git a/apps/builder/src/features/integration-whatsapp/message-templates/templates/image/partial.tsx b/apps/builder/src/features/integration-whatsapp/message-templates/templates/image/partial.tsx
index 9824fd72d..297785d1b 100644
--- a/apps/builder/src/features/integration-whatsapp/message-templates/templates/image/partial.tsx
+++ b/apps/builder/src/features/integration-whatsapp/message-templates/templates/image/partial.tsx
@@ -14,7 +14,7 @@ const VariableInput = memo(
@@ -49,7 +49,7 @@ const TemplateImagePartialComponent = (props: { parentName?: string }) => {
name={`${parentName}.showFooter`}
options={[
{
- label: "Show footer",
+ label: "Mostrar rodapé",
value: "showFooter",
},
]}
diff --git a/apps/builder/src/features/integration-whatsapp/message-templates/templates/image/schema.ts b/apps/builder/src/features/integration-whatsapp/message-templates/templates/image/schema.ts
index a47493a9c..8b8a04863 100644
--- a/apps/builder/src/features/integration-whatsapp/message-templates/templates/image/schema.ts
+++ b/apps/builder/src/features/integration-whatsapp/message-templates/templates/image/schema.ts
@@ -14,7 +14,7 @@ export const templateImageSchema = z
file instanceof File &&
["image/png", "image/jpg", "image/jpeg"].includes(file.type),
{
- message: "File must be a Image png, jpg, jpeg",
+ message: "O arquivo deve ser uma imagem png, jpg ou jpeg",
},
)
.refine((file) => file && file.size <= 2 * 1024 * 1024, {
@@ -32,7 +32,7 @@ export const templateImageSchema = z
if (data.showFooter && !data.footer?.length) {
ctx.addIssue({
path: ["footer"],
- message: "Footer text is required",
+ message: "Texto do rodapé é obrigatório",
code: z.ZodIssueCode.custom,
})
}
diff --git a/apps/builder/src/features/integration-whatsapp/message-templates/templates/product/partial.tsx b/apps/builder/src/features/integration-whatsapp/message-templates/templates/product/partial.tsx
index 833db8c36..2e46c8348 100644
--- a/apps/builder/src/features/integration-whatsapp/message-templates/templates/product/partial.tsx
+++ b/apps/builder/src/features/integration-whatsapp/message-templates/templates/product/partial.tsx
@@ -23,7 +23,7 @@ const VariableInput = memo(
@@ -62,7 +62,7 @@ const TemplateProductPartialComponent = (props: { parentName?: string }) => {
name={`${parentName}.showFooter`}
options={[
{
- label: "Show footer",
+ label: "Mostrar rodapé",
value: "showFooter",
},
]}
diff --git a/apps/builder/src/features/integration-whatsapp/message-templates/templates/product/schema.ts b/apps/builder/src/features/integration-whatsapp/message-templates/templates/product/schema.ts
index 89ffa9423..e8f8a81a0 100644
--- a/apps/builder/src/features/integration-whatsapp/message-templates/templates/product/schema.ts
+++ b/apps/builder/src/features/integration-whatsapp/message-templates/templates/product/schema.ts
@@ -20,7 +20,7 @@ export const templateProductSchema = z
if (data.showFooter && !data.footer?.length) {
ctx.addIssue({
path: ["footer"],
- message: "Footer text is required",
+ message: "Texto do rodapé é obrigatório",
code: z.ZodIssueCode.custom,
})
}
diff --git a/apps/builder/src/features/integration-whatsapp/message-templates/templates/text/partial.tsx b/apps/builder/src/features/integration-whatsapp/message-templates/templates/text/partial.tsx
index e1c25de34..25b1a4e14 100644
--- a/apps/builder/src/features/integration-whatsapp/message-templates/templates/text/partial.tsx
+++ b/apps/builder/src/features/integration-whatsapp/message-templates/templates/text/partial.tsx
@@ -22,7 +22,7 @@ const VariableInput = memo(
@@ -84,7 +84,7 @@ const TemplateTextPartialComponent = (
name={`${parentName}.hideHeader`}
options={[
{
- label: "Show header",
+ label: "Mostrar cabeçalho",
value: "hideHeader",
},
]}
@@ -95,7 +95,7 @@ const TemplateTextPartialComponent = (
name={`${parentName}.showFooter`}
options={[
{
- label: "Show footer",
+ label: "Mostrar rodapé",
value: "showFooter",
},
]}
diff --git a/apps/builder/src/features/integration-whatsapp/message-templates/templates/text/schema.ts b/apps/builder/src/features/integration-whatsapp/message-templates/templates/text/schema.ts
index c63f60bc4..b026abdea 100644
--- a/apps/builder/src/features/integration-whatsapp/message-templates/templates/text/schema.ts
+++ b/apps/builder/src/features/integration-whatsapp/message-templates/templates/text/schema.ts
@@ -20,7 +20,7 @@ export const templateTextSchema = z
if (data.hideHeader && !data.header.text?.length) {
ctx.addIssue({
path: ["header", "text"],
- message: "Header text is required",
+ message: "Texto do cabeçalho é obrigatório",
code: z.ZodIssueCode.custom,
})
}
@@ -29,7 +29,7 @@ export const templateTextSchema = z
if (data.showFooter && !data.footer?.length) {
ctx.addIssue({
path: ["footer"],
- message: "Footer text is required",
+ message: "Texto do rodapé é obrigatório",
code: z.ZodIssueCode.custom,
})
}
diff --git a/apps/builder/src/features/integration-whatsapp/message-templates/templates/video/partial.tsx b/apps/builder/src/features/integration-whatsapp/message-templates/templates/video/partial.tsx
index cd8187d79..9a51ebda6 100644
--- a/apps/builder/src/features/integration-whatsapp/message-templates/templates/video/partial.tsx
+++ b/apps/builder/src/features/integration-whatsapp/message-templates/templates/video/partial.tsx
@@ -14,7 +14,7 @@ const VariableInput = memo(
@@ -49,7 +49,7 @@ const TemplateVideoPartialComponent = (props: { parentName?: string }) => {
name={`${parentName}.showFooter`}
options={[
{
- label: "Show footer",
+ label: "Mostrar rodapé",
value: "showFooter",
},
]}
diff --git a/apps/builder/src/features/integration-whatsapp/message-templates/templates/video/schema.ts b/apps/builder/src/features/integration-whatsapp/message-templates/templates/video/schema.ts
index 71c995740..9f63b9014 100644
--- a/apps/builder/src/features/integration-whatsapp/message-templates/templates/video/schema.ts
+++ b/apps/builder/src/features/integration-whatsapp/message-templates/templates/video/schema.ts
@@ -11,7 +11,7 @@ export const templateVideoSchema = z
.refine(
(file) => file && file instanceof File && file.type === "video/mp4",
{
- message: "File must be a Video",
+ message: "O arquivo deve ser um vídeo",
},
)
.refine((file) => file && file.size <= 20 * 1024 * 1024, {
@@ -29,7 +29,7 @@ export const templateVideoSchema = z
if (data.showFooter && !data.footer?.length) {
ctx.addIssue({
path: ["footer"],
- message: "Footer text is required",
+ message: "Texto do rodapé é obrigatório",
code: z.ZodIssueCode.custom,
})
}
diff --git a/apps/builder/src/features/integration-whatsapp/message-templates/type.ts b/apps/builder/src/features/integration-whatsapp/message-templates/type.ts
index 9bcecd28f..2164a5b25 100644
--- a/apps/builder/src/features/integration-whatsapp/message-templates/type.ts
+++ b/apps/builder/src/features/integration-whatsapp/message-templates/type.ts
@@ -14,98 +14,98 @@ export const templateTypes = z.enum([
export type TemplateType = z.infer
export const languageOptions = [
- { label: "Afrikaans", value: "af" },
- { label: "Albanian", value: "sq" },
- { label: "Arabic", value: "ar" },
- { label: "Arabic (EGY)", value: "ar_EG" },
- { label: "Arabic (UAE)", value: "ar_AE" },
- { label: "Arabic (LBN)", value: "ar_LB" },
- { label: "Arabic (MAR)", value: "ar_MA" },
- { label: "Arabic (QAT)", value: "ar_QA" },
- { label: "Azerbaijani", value: "az" },
- { label: "Belarusian", value: "be_BY" },
+ { label: "Africâner", value: "af" },
+ { label: "Albanês", value: "sq" },
+ { label: "Árabe", value: "ar" },
+ { label: "Árabe (EGY)", value: "ar_EG" },
+ { label: "Árabe (UAE)", value: "ar_AE" },
+ { label: "Árabe (LBN)", value: "ar_LB" },
+ { label: "Árabe (MAR)", value: "ar_MA" },
+ { label: "Árabe (QAT)", value: "ar_QA" },
+ { label: "Azerbaijano", value: "az" },
+ { label: "Bielorrusso", value: "be_BY" },
{ label: "Bengali", value: "bn" },
{ label: "Bengali (IND)", value: "bn_IN" },
- { label: "Bulgarian", value: "bg" },
- { label: "Catalan", value: "ca" },
- { label: "Chinese (CHN)", value: "zh_CN" },
- { label: "Chinese (HKG)", value: "zh_HK" },
- { label: "Chinese (TAI)", value: "zh_TW" },
- { label: "Croatian", value: "hr" },
- { label: "Czech", value: "cs" },
- { label: "Danish", value: "da" },
+ { label: "Búlgaro", value: "bg" },
+ { label: "Catalão", value: "ca" },
+ { label: "Chinês (CHN)", value: "zh_CN" },
+ { label: "Chinês (HKG)", value: "zh_HK" },
+ { label: "Chinês (TAI)", value: "zh_TW" },
+ { label: "Croata", value: "hr" },
+ { label: "Tcheco", value: "cs" },
+ { label: "Dinamarquês", value: "da" },
{ label: "Dari", value: "prs_AF" },
- { label: "Dutch", value: "nl" },
- { label: "Dutch (BEL)", value: "nl_BE" },
- { label: "English", value: "en" },
- { label: "English (UK)", value: "en_GB" },
- { label: "English (US)", value: "en_US" },
- { label: "English (UAE)", value: "en_AE" },
- { label: "English (AUS)", value: "en_AU" },
- { label: "English (CAN)", value: "en_CA" },
- { label: "English (GHA)", value: "en_GH" },
- { label: "English (IRL)", value: "en_IE" },
- { label: "English (IND)", value: "en_IN" },
- { label: "English (JAM)", value: "en_JM" },
- { label: "English (MYS)", value: "en_MY" },
- { label: "English (NZL)", value: "en_NZ" },
- { label: "English (QAT)", value: "en_QA" },
- { label: "English (SGP)", value: "en_SG" },
- { label: "English (UGA)", value: "en_UG" },
- { label: "English (ZAF)", value: "en_ZA" },
- { label: "Estonian", value: "et" },
+ { label: "Holandês", value: "nl" },
+ { label: "Holandês (BEL)", value: "nl_BE" },
+ { label: "Inglês", value: "en" },
+ { label: "Inglês (UK)", value: "en_GB" },
+ { label: "Inglês (US)", value: "en_US" },
+ { label: "Inglês (UAE)", value: "en_AE" },
+ { label: "Inglês (AUS)", value: "en_AU" },
+ { label: "Inglês (CAN)", value: "en_CA" },
+ { label: "Inglês (GHA)", value: "en_GH" },
+ { label: "Inglês (IRL)", value: "en_IE" },
+ { label: "Inglês (IND)", value: "en_IN" },
+ { label: "Inglês (JAM)", value: "en_JM" },
+ { label: "Inglês (MYS)", value: "en_MY" },
+ { label: "Inglês (NZL)", value: "en_NZ" },
+ { label: "Inglês (QAT)", value: "en_QA" },
+ { label: "Inglês (SGP)", value: "en_SG" },
+ { label: "Inglês (UGA)", value: "en_UG" },
+ { label: "Inglês (ZAF)", value: "en_ZA" },
+ { label: "Estoniano", value: "et" },
{ label: "Filipino", value: "fil" },
- { label: "Finnish", value: "fi" },
- { label: "French", value: "fr" },
- { label: "French (BEL)", value: "fr_BE" },
- { label: "French (CAN)", value: "fr_CA" },
- { label: "French (CHE)", value: "fr_CH" },
- { label: "French (CIV)", value: "fr_CI" },
- { label: "French (MAR)", value: "fr_MA" },
- { label: "Georgian", value: "ka" },
- { label: "German", value: "de" },
- { label: "German (AUT)", value: "de_AT" },
- { label: "German (CHE)", value: "de_CH" },
- { label: "Greek", value: "el" },
+ { label: "Finlandês", value: "fi" },
+ { label: "Francês", value: "fr" },
+ { label: "Francês (BEL)", value: "fr_BE" },
+ { label: "Francês (CAN)", value: "fr_CA" },
+ { label: "Francês (CHE)", value: "fr_CH" },
+ { label: "Francês (CIV)", value: "fr_CI" },
+ { label: "Francês (MAR)", value: "fr_MA" },
+ { label: "Georgiano", value: "ka" },
+ { label: "Alemão", value: "de" },
+ { label: "Alemão (AUT)", value: "de_AT" },
+ { label: "Alemão (CHE)", value: "de_CH" },
+ { label: "Grego", value: "el" },
{ label: "Gujarati", value: "gu" },
- { label: "Hausa", value: "ha" },
- { label: "Hebrew", value: "he" },
+ { label: "Hauçá", value: "ha" },
+ { label: "Hebraico", value: "he" },
{ label: "Hindi", value: "hi" },
- { label: "Hungarian", value: "hu" },
- { label: "Indonesian", value: "id" },
- { label: "Irish", value: "ga" },
- { label: "Italian", value: "it" },
- { label: "Japanese", value: "ja" },
- { label: "Kannada", value: "kn" },
- { label: "Kazakh", value: "kk" },
- { label: "Kinyarwanda", value: "rw_RW" },
- { label: "Korean", value: "ko" },
- { label: "Kyrgyz (Kyrgyzstan)", value: "ky_KG" },
- { label: "Lao", value: "lo" },
- { label: "Latvian", value: "lv" },
- { label: "Lithuanian", value: "lt" },
- { label: "Macedonian", value: "mk" },
- { label: "Malay", value: "ms" },
- { label: "Malayalam", value: "ml" },
- { label: "Marathi", value: "mr" },
- { label: "Norwegian", value: "nb" },
- { label: "Pashto", value: "ps_AF" },
- { label: "Persian", value: "fa" },
- { label: "Polish", value: "pl" },
- { label: "Portuguese (BR)", value: "pt_BR" },
- { label: "Portuguese (POR)", value: "pt_PT" },
- { label: "Punjabi", value: "pa" },
- { label: "Romanian", value: "ro" },
- { label: "Russian", value: "ru" },
- { label: "Serbian", value: "sr" },
- { label: "Sinhala", value: "si_LK" },
- { label: "Slovak", value: "sk" },
- { label: "Slovenian", value: "sl" },
- { label: "Spanish", value: "es" },
- { label: "Spanish (ARG)", value: "es_AR" },
- { label: "Spanish (CHL)", value: "es_CL" },
- { label: "Spanish (COL)", value: "es_CO" },
- { label: "Spanish (CRI)", value: "es_CR" },
- { label: "Spanish (DOM)", value: "es_DO" },
- { label: "Spanish (ECU)", value: "es_EC" },
+ { label: "Húngaro", value: "hu" },
+ { label: "Indonésio", value: "id" },
+ { label: "Irlandês", value: "ga" },
+ { label: "Italiano", value: "it" },
+ { label: "Japonês", value: "ja" },
+ { label: "Canarês", value: "kn" },
+ { label: "Cazaque", value: "kk" },
+ { label: "Quiniaruanda", value: "rw_RW" },
+ { label: "Coreano", value: "ko" },
+ { label: "Quirguiz (Quirguistão)", value: "ky_KG" },
+ { label: "Laosiano", value: "lo" },
+ { label: "Letão", value: "lv" },
+ { label: "Lituano", value: "lt" },
+ { label: "Macedônio", value: "mk" },
+ { label: "Malaio", value: "ms" },
+ { label: "Malaiala", value: "ml" },
+ { label: "Marata", value: "mr" },
+ { label: "Norueguês", value: "nb" },
+ { label: "Pachto", value: "ps_AF" },
+ { label: "Persa", value: "fa" },
+ { label: "Polonês", value: "pl" },
+ { label: "Português (BR)", value: "pt_BR" },
+ { label: "Português (POR)", value: "pt_PT" },
+ { label: "Panjabi", value: "pa" },
+ { label: "Romeno", value: "ro" },
+ { label: "Russo", value: "ru" },
+ { label: "Sérvio", value: "sr" },
+ { label: "Cingalês", value: "si_LK" },
+ { label: "Eslovaco", value: "sk" },
+ { label: "Esloveno", value: "sl" },
+ { label: "Espanhol", value: "es" },
+ { label: "Espanhol (ARG)", value: "es_AR" },
+ { label: "Espanhol (CHL)", value: "es_CL" },
+ { label: "Espanhol (COL)", value: "es_CO" },
+ { label: "Espanhol (CRI)", value: "es_CR" },
+ { label: "Espanhol (DOM)", value: "es_DO" },
+ { label: "Espanhol (ECU)", value: "es_EC" },
]
diff --git a/apps/builder/src/features/integration-whatsapp/profile/update-whatsapp-profile.tsx b/apps/builder/src/features/integration-whatsapp/profile/update-whatsapp-profile.tsx
index c5449cc42..f55909c34 100644
--- a/apps/builder/src/features/integration-whatsapp/profile/update-whatsapp-profile.tsx
+++ b/apps/builder/src/features/integration-whatsapp/profile/update-whatsapp-profile.tsx
@@ -29,11 +29,11 @@ export function UpdateWhatsappProfile({
return (
-
-
-
-
-
+
+
+
+
+
{t("actions.confirm")}
diff --git a/apps/builder/src/features/integration-whatsapp/provider/integration-store.ts b/apps/builder/src/features/integration-whatsapp/provider/integration-store.ts
index 3854ab298..77c7e5b85 100644
--- a/apps/builder/src/features/integration-whatsapp/provider/integration-store.ts
+++ b/apps/builder/src/features/integration-whatsapp/provider/integration-store.ts
@@ -47,7 +47,7 @@ export const createIntegrationStore = (props: Partial
) =>
error:
error instanceof Error
? error.message
- : "Failed to fetch integrations",
+ : "Falha ao buscar integrações",
})
} finally {
set({ initialized: true })
@@ -74,7 +74,7 @@ export const createIntegrationStore = (props: Partial) =>
error:
error instanceof Error
? error.message
- : "Failed to fetch integrations",
+ : "Falha ao buscar integrações",
integrations: [],
})
} finally {
diff --git a/apps/builder/src/features/integration-whatsapp/queries/index.ts b/apps/builder/src/features/integration-whatsapp/queries/index.ts
index cbbafab96..0191beac7 100644
--- a/apps/builder/src/features/integration-whatsapp/queries/index.ts
+++ b/apps/builder/src/features/integration-whatsapp/queries/index.ts
@@ -32,7 +32,7 @@ export const findIntegrationWhatsapp = async (
await findOrFail({
table: integrationWhatsappModel,
where: props,
- message: "Whatsapp integration not found",
+ message: "Integração WhatsApp não encontrada",
})
export const findIntegrationWhatsappById = async (
diff --git a/apps/builder/src/features/integration-whatsapp/schemas/index.ts b/apps/builder/src/features/integration-whatsapp/schemas/index.ts
index e50b0511f..45559a1ec 100644
--- a/apps/builder/src/features/integration-whatsapp/schemas/index.ts
+++ b/apps/builder/src/features/integration-whatsapp/schemas/index.ts
@@ -36,7 +36,7 @@ export const connectWhatsappSchema = z
if (!(data.manualConnect || data.businessId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
- message: "Required business id",
+ message: "ID do negócio obrigatório",
path: ["businessId"],
})
}
@@ -44,7 +44,7 @@ export const connectWhatsappSchema = z
if (!(data.accessToken || data.code)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
- message: "Required access token or code",
+ message: "Token de acesso ou código obrigatório",
})
}
})
diff --git a/apps/builder/src/features/integration-whatsapp/whatsapp-manage.tsx b/apps/builder/src/features/integration-whatsapp/whatsapp-manage.tsx
index 4eed84870..7b3952e43 100644
--- a/apps/builder/src/features/integration-whatsapp/whatsapp-manage.tsx
+++ b/apps/builder/src/features/integration-whatsapp/whatsapp-manage.tsx
@@ -1,14 +1,8 @@
"use client"
+import { Badge } from "@chatbotx.io/ui/components/ui/badge"
import { Button } from "@chatbotx.io/ui/components/ui/button"
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@chatbotx.io/ui/components/ui/table"
+import { SiWhatsapp, SiWhatsappHex } from "@icons-pack/react-simple-icons"
import { PlusCircleIcon } from "lucide-react"
import Link from "next/link"
import { useTranslations } from "next-intl"
@@ -22,6 +16,71 @@ type WhatsappManageProps = {
promises: Promise<[Awaited>]>
}
+// Cor da quality chip baseada no Meta WhatsApp Business API:
+// GREEN = "Alta qualidade"
+// YELLOW = "Média qualidade — atenção"
+// RED = "Baixa qualidade — risco de bloqueio"
+// UNKNOWN = sem score ainda (number novo / poucas mensagens)
+const QUALITY_CONFIG: Record<
+ "GREEN" | "YELLOW" | "RED" | "UNKNOWN",
+ { label: string; className: string }
+> = {
+ GREEN: {
+ label: "Alta",
+ className: "bg-emerald-500/10 text-emerald-400 border-emerald-500/30",
+ },
+ YELLOW: {
+ label: "Média",
+ className: "bg-amber-500/10 text-amber-400 border-amber-500/30",
+ },
+ RED: {
+ label: "Baixa",
+ className: "bg-red-500/10 text-red-400 border-red-500/30",
+ },
+ UNKNOWN: {
+ label: "—",
+ className: "bg-white/[0.06] text-text-secondary border-white/[0.12]",
+ },
+}
+
+// Formata número internacional pra "+1 555 649 0175" estilo Respond.io.
+// displayPhoneNumber vem do Meta em vários formatos:
+// "+15556490175" / "15556490175" / "+1 555-649-0175"
+// Padronizo pra "+1 555 649 0175" (espaços, sem traço).
+function formatDisplayPhone(raw: string | null | undefined): string {
+ if (!raw) {
+ return "—"
+ }
+ const digits = raw.replace(/\D/g, "")
+ if (digits.length < 8) {
+ return raw
+ }
+ if (digits.startsWith("1") && digits.length === 11) {
+ return `+1 ${digits.slice(1, 4)} ${digits.slice(4, 7)} ${digits.slice(7)}`
+ }
+ if (digits.startsWith("55") && digits.length >= 12) {
+ return `+55 ${digits.slice(2, 4)} ${digits.slice(4, digits.length - 4)} ${digits.slice(-4)}`
+ }
+ return `+${digits}`
+}
+
+type WhatsappPhoneMeta = {
+ verified_name?: string
+ display_phone_number?: string
+ quality_rating?: "GREEN" | "YELLOW" | "RED" | "UNKNOWN"
+ code_verification_status?: string
+ platform_type?: string
+ throughput?: { level?: string }
+}
+
+type WhatsappAuthShape = {
+ metadata?: {
+ isManual?: boolean
+ webhookVerifiedAt?: string
+ phoneNumber?: WhatsappPhoneMeta
+ }
+}
+
export function WhatsappManage({
isEnabled,
workspaceId,
@@ -41,8 +100,8 @@ export function WhatsappManage({
}
return (
-
-
+
+
-
-
-
-
- Name
-
-
-
-
- {integrationWhatsapps.map((integrationWhatsapp) => (
-
- {integrationWhatsapp.inbox?.name}
-
-
+ {integrationWhatsapps.length === 0 ? (
+
+
+
{t("whatsapp.list.empty")}
+
+ ) : (
+
+ {integrationWhatsapps.map((integration) => {
+ const auth = integration.auth as WhatsappAuthShape | null
+ const phoneMeta = auth?.metadata?.phoneNumber
+ const isManual = Boolean(auth?.metadata?.isManual)
+ const isWebhookVerified = Boolean(auth?.metadata?.webhookVerifiedAt)
+ const quality = (phoneMeta?.quality_rating ?? "UNKNOWN") as
+ | "GREEN"
+ | "YELLOW"
+ | "RED"
+ | "UNKNOWN"
+ const qualityCfg = QUALITY_CONFIG[quality]
+ const verifiedName =
+ phoneMeta?.verified_name ||
+ integration.inbox?.name ||
+ integration.name ||
+ "—"
+ const displayPhone = formatDisplayPhone(
+ phoneMeta?.display_phone_number || integration.displayPhoneNumber,
+ )
+
+ return (
+
+ {/* Header card: avatar WhatsApp + nome + quality chip */}
+
+
+
+
+
+
+ {verifiedName}
+
+
+ {displayPhone}
+
+
+
+ {qualityCfg.label}
+
+
+
+ {/* Status row */}
+
+ {isManual ? (
+
+ {t("whatsapp.connectionType.manual")}
+
+ ) : (
+
+ {t("whatsapp.connectionType.embedded")}
+
+ )}
+
+ {isWebhookVerified
+ ? t("whatsapp.webhook.verified")
+ : t("whatsapp.webhook.pending")}
+
+
+
+ {/* Footer card: ações */}
+
+
{t("actions.manage")}
-
-
- ))}
- {integrationWhatsapps.length === 0 && (
-
- No data
-
- )}
-
-
-
+
+
+ )
+ })}
+
+ )}
)
}
diff --git a/apps/builder/src/features/invitations/actions/accept-invitation.ts b/apps/builder/src/features/invitations/actions/accept-invitation.ts
index ff483ebfa..c725edb4f 100644
--- a/apps/builder/src/features/invitations/actions/accept-invitation.ts
+++ b/apps/builder/src/features/invitations/actions/accept-invitation.ts
@@ -8,15 +8,11 @@ import {
workspaceMemberModel,
} from "@chatbotx.io/database/schema"
import { createId } from "@chatbotx.io/utils"
-import { z } from "zod"
import { authActionClient } from "@/lib/safe-action"
+import { acceptInvitationRequest } from "../schema/accept-invitation"
export const acceptInvitationAction = authActionClient
- .inputSchema(
- z.object({
- code: z.string(),
- }),
- )
+ .inputSchema(acceptInvitationRequest)
.action(async ({ ctx, parsedInput }) => {
const { code } = parsedInput
@@ -32,43 +28,55 @@ export const acceptInvitationAction = authActionClient
throw new ChatbotXException("Invitation expired")
}
- if (invitation.workspaceId) {
- const existingMember = await db.query.workspaceMemberModel.findFirst({
- where: {
- workspaceId: invitation.workspaceId,
+ await db.transaction(async (tx) => {
+ const existingOrgMember =
+ await tx.query.organizationMemberModel.findFirst({
+ where: {
+ organizationId: invitation.organizationId,
+ userId: ctx.user.id,
+ },
+ })
+ if (!existingOrgMember) {
+ await tx.insert(organizationMemberModel).values({
+ id: createId(),
+ organizationId: invitation.organizationId,
userId: ctx.user.id,
- },
- })
- if (existingMember) {
- throw new ChatbotXException(
- "You are already a member of this workspace",
- )
+ role: "agent",
+ })
}
- await db.insert(workspaceMemberModel).values({
- id: createId(),
- workspaceId: invitation.workspaceId,
- userId: ctx.user.id,
- role: "agent",
- permissions: invitation.permissions,
- notificationTypes: {
- notifyAdmin: true,
- newMessageToHuman: true,
- newOrder: true,
- },
- notificationChannels: {
- messenger: true,
- email: true,
- telegram: true,
- browser: true,
- },
- })
- } else {
- await db.insert(organizationMemberModel).values({
- id: createId(),
- organizationId: invitation.organizationId,
- userId: ctx.user.id,
- role: "member",
- })
- }
+ if (invitation.workspaceId) {
+ const existingWorkspaceMember =
+ await tx.query.workspaceMemberModel.findFirst({
+ where: {
+ workspaceId: invitation.workspaceId,
+ userId: ctx.user.id,
+ },
+ })
+ if (existingWorkspaceMember) {
+ throw new ChatbotXException(
+ "You are already a member of this workspace",
+ )
+ }
+
+ await tx.insert(workspaceMemberModel).values({
+ id: createId(),
+ workspaceId: invitation.workspaceId,
+ userId: ctx.user.id,
+ role: invitation.role,
+ permissions: invitation.permissions,
+ notificationTypes: {
+ notifyAdmin: true,
+ newMessageToHuman: true,
+ newOrder: true,
+ },
+ notificationChannels: {
+ messenger: true,
+ email: true,
+ telegram: true,
+ browser: true,
+ },
+ })
+ }
+ })
})
diff --git a/apps/builder/src/features/invitations/schema/accept-invitation.ts b/apps/builder/src/features/invitations/schema/accept-invitation.ts
new file mode 100644
index 000000000..62b321035
--- /dev/null
+++ b/apps/builder/src/features/invitations/schema/accept-invitation.ts
@@ -0,0 +1,7 @@
+import { z } from "zod"
+
+export const acceptInvitationRequest = z.object({
+ code: z.string(),
+})
+
+export type AcceptInvitationRequest = z.infer
diff --git a/apps/builder/src/features/lifecycle-stages/actions/bulk-update-contact-lifecycle-action.ts b/apps/builder/src/features/lifecycle-stages/actions/bulk-update-contact-lifecycle-action.ts
new file mode 100644
index 000000000..b7233fa85
--- /dev/null
+++ b/apps/builder/src/features/lifecycle-stages/actions/bulk-update-contact-lifecycle-action.ts
@@ -0,0 +1,54 @@
+"use server"
+
+import { contactService } from "@chatbotx.io/business"
+import { emitLifecycleStageChanged } from "@chatbotx.io/events"
+import { revalidatePath } from "next/cache"
+import { z } from "zod"
+import {
+ type WorkspaceIdRequestParams,
+ workspaceIdrequestParams,
+} from "@/features/common/schemas"
+import { workspaceActionClient } from "@/lib/safe-action"
+
+export const bulkUpdateContactLifecycleSchema = z.object({
+ contactIds: z.array(z.string()).min(1).max(1000),
+ lifecycleStageId: z.string().nullable(),
+})
+
+export const bulkUpdateContactLifecycleAction = workspaceActionClient
+ .bindArgsSchemas(workspaceIdrequestParams)
+ .inputSchema(bulkUpdateContactLifecycleSchema)
+ .action(
+ async ({
+ bindArgsParsedInputs: [workspaceId],
+ parsedInput,
+ }: {
+ bindArgsParsedInputs: WorkspaceIdRequestParams
+ parsedInput: { contactIds: string[]; lifecycleStageId: string | null }
+ }) => {
+ const { count, transitions } =
+ await contactService.bulkUpdateLifecycleStage({
+ contactIds: parsedInput.contactIds,
+ workspaceId,
+ lifecycleStageId: parsedInput.lifecycleStageId,
+ })
+
+ // Fan-out one event per contact whose stage actually changed.
+ // Done in parallel so 1000 transitions don't serialize a slow loop.
+ await Promise.all(
+ transitions.map((t) =>
+ emitLifecycleStageChanged(
+ workspaceId,
+ t.contactId,
+ t.toStageId,
+ t.fromStageId,
+ t.toStageName,
+ t.fromStageName,
+ ),
+ ),
+ )
+
+ revalidatePath("/space", "layout")
+ return { updated: count }
+ },
+ )
diff --git a/apps/builder/src/features/lifecycle-stages/actions/delete-lifecycle-stage-action.ts b/apps/builder/src/features/lifecycle-stages/actions/delete-lifecycle-stage-action.ts
new file mode 100644
index 000000000..ed4f72350
--- /dev/null
+++ b/apps/builder/src/features/lifecycle-stages/actions/delete-lifecycle-stage-action.ts
@@ -0,0 +1,88 @@
+"use server"
+
+import { contactService, lifecycleStageService } from "@chatbotx.io/business"
+import { emitLifecycleStageChanged } from "@chatbotx.io/events"
+import { revalidatePath } from "next/cache"
+import { z } from "zod"
+import {
+ type WorkspaceIdRequestParams,
+ workspaceIdrequestParams,
+} from "@/features/common/schemas"
+import { workspaceActionClient } from "@/lib/safe-action"
+
+export const deleteLifecycleStageSchema = z.object({
+ stageId: z.string(),
+ reassignToStageId: z.string().nullable(),
+})
+
+/**
+ * Apaga uma etapa migrando todos os contatos pra outra etapa (ou null = sem etapa).
+ */
+export const deleteLifecycleStageWithReassignAction = workspaceActionClient
+ .bindArgsSchemas(workspaceIdrequestParams)
+ .inputSchema(deleteLifecycleStageSchema)
+ .action(
+ async ({
+ bindArgsParsedInputs: [workspaceId],
+ parsedInput,
+ }: {
+ bindArgsParsedInputs: WorkspaceIdRequestParams
+ parsedInput: { stageId: string; reassignToStageId: string | null }
+ }) => {
+ const { count, transitions } =
+ await contactService.reassignLifecycleStage({
+ workspaceId,
+ fromStageId: parsedInput.stageId,
+ toStageId: parsedInput.reassignToStageId,
+ })
+
+ await lifecycleStageService.delete({
+ id: parsedInput.stageId,
+ workspaceId,
+ })
+
+ // Emit events for each reassigned contact so user-defined triggers can
+ // run (e.g. "when lifecycle changes to 'Cliente', send welcome flow").
+ await Promise.all(
+ transitions.map((t) =>
+ emitLifecycleStageChanged(
+ workspaceId,
+ t.contactId,
+ t.toStageId,
+ t.fromStageId,
+ t.toStageName,
+ t.fromStageName,
+ ),
+ ),
+ )
+
+ revalidatePath("/space", "layout")
+ return { moved: count }
+ },
+ )
+
+export const countContactsByStageSchema = z.object({
+ stageId: z.string(),
+})
+
+/**
+ * Conta contatos numa etapa específica (usado pra mostrar no modal).
+ */
+export const countContactsByStageAction = workspaceActionClient
+ .bindArgsSchemas(workspaceIdrequestParams)
+ .inputSchema(countContactsByStageSchema)
+ .action(
+ async ({
+ bindArgsParsedInputs: [workspaceId],
+ parsedInput,
+ }: {
+ bindArgsParsedInputs: WorkspaceIdRequestParams
+ parsedInput: { stageId: string }
+ }) => {
+ const count = await contactService.countByLifecycleStage({
+ workspaceId,
+ lifecycleStageId: parsedInput.stageId,
+ })
+ return { count }
+ },
+ )
diff --git a/apps/builder/src/features/lifecycle-stages/actions/get-lifecycle-counts-action.ts b/apps/builder/src/features/lifecycle-stages/actions/get-lifecycle-counts-action.ts
new file mode 100644
index 000000000..de8c6d9be
--- /dev/null
+++ b/apps/builder/src/features/lifecycle-stages/actions/get-lifecycle-counts-action.ts
@@ -0,0 +1,51 @@
+"use server"
+
+import { lifecycleStageService } from "@chatbotx.io/business"
+import {
+ type WorkspaceIdRequestParams,
+ workspaceIdrequestParams,
+} from "@/features/common/schemas"
+import { getCurrentUserId } from "@/lib/auth/utils"
+import { workspaceActionClient } from "@/lib/safe-action"
+import {
+ type GetLifecycleCountsRequest,
+ getLifecycleCountsRequest,
+} from "../schema/counts"
+
+// Atualiza contagens do sidebar do Inbox: badge de cada lifecycle stage
+// (conversas abertas no stage) + badge dos filtros top (Todos/Minhas/
+// Não atribuídas/teams). Polling de 30s pra ficar fresco quando uma
+// conversa muda de stage / é arquivada / é atribuída.
+//
+// Retorna { byStage, byAssignment } numa única chamada (mais eficiente
+// que 2 actions separadas).
+export const getLifecycleCountsAction = workspaceActionClient
+ .bindArgsSchemas(workspaceIdrequestParams)
+ .inputSchema(getLifecycleCountsRequest)
+ .action(
+ async ({
+ bindArgsParsedInputs: [workspaceId],
+ parsedInput,
+ }: {
+ bindArgsParsedInputs: WorkspaceIdRequestParams
+ parsedInput: GetLifecycleCountsRequest
+ }) => {
+ const currentUserId = (await getCurrentUserId()) ?? ""
+ const [byStage, byAssignment, unreadByStage, unreadByAssignment] =
+ await Promise.all([
+ lifecycleStageService.countOpenConversationsByStage({ workspaceId }),
+ lifecycleStageService.countOpenConversationsByAssignment({
+ workspaceId,
+ currentUserId,
+ teamIds: parsedInput.teamIds ?? [],
+ }),
+ lifecycleStageService.countUnreadByStage({ workspaceId }),
+ lifecycleStageService.countUnreadByAssignment({
+ workspaceId,
+ currentUserId,
+ teamIds: parsedInput.teamIds ?? [],
+ }),
+ ])
+ return { byStage, byAssignment, unreadByStage, unreadByAssignment }
+ },
+ )
diff --git a/apps/builder/src/features/lifecycle-stages/actions/save-lifecycle-stages-action.ts b/apps/builder/src/features/lifecycle-stages/actions/save-lifecycle-stages-action.ts
new file mode 100644
index 000000000..b351f13ca
--- /dev/null
+++ b/apps/builder/src/features/lifecycle-stages/actions/save-lifecycle-stages-action.ts
@@ -0,0 +1,85 @@
+"use server"
+
+import { lifecycleStageService } from "@chatbotx.io/business"
+import { revalidatePath } from "next/cache"
+import {
+ type WorkspaceIdRequestParams,
+ workspaceIdrequestParams,
+} from "@/features/common/schemas"
+import { workspaceActionClient } from "@/lib/safe-action"
+import { saveLifecycleStagesSchema } from "../schema"
+
+/**
+ * Salva todas as etapas em batch (4 fases): delete, reset defaults, insert, update.
+ * Estado vem do front com flags _new/_dirty/_deleted.
+ */
+export const saveLifecycleStagesAction = workspaceActionClient
+ .bindArgsSchemas(workspaceIdrequestParams)
+ .inputSchema(saveLifecycleStagesSchema)
+ .action(
+ async ({
+ bindArgsParsedInputs: [workspaceId],
+ parsedInput,
+ }: {
+ bindArgsParsedInputs: WorkspaceIdRequestParams
+ parsedInput: {
+ stages: Array<{
+ id: string
+ key: string
+ name: string
+ icon: string | null
+ color: string | null
+ position: number
+ isDefault: boolean
+ isLost: boolean
+ _new?: boolean
+ _dirty?: boolean
+ _deleted?: boolean
+ }>
+ }
+ }) => {
+ const stages = parsedInput.stages
+
+ // FASE 1: Deletar (existentes marcados _deleted)
+ const toDelete = stages.filter((s) => s._deleted && !s._new)
+ for (const s of toDelete) {
+ await lifecycleStageService.delete({ id: s.id, workspaceId })
+ }
+
+ // FASE 2: Insert novos
+ const toInsert = stages.filter((s) => s._new && !s._deleted)
+ for (const s of toInsert) {
+ await lifecycleStageService.create({
+ workspaceId,
+ data: {
+ key: s.key,
+ name: s.name,
+ icon: s.icon,
+ color: s.color,
+ position: s.position,
+ isDefault: s.isDefault,
+ isLost: s.isLost,
+ },
+ })
+ }
+
+ // FASE 3: Update existentes (_dirty mas não _new nem _deleted)
+ const toUpdate = stages.filter((s) => s._dirty && !s._new && !s._deleted)
+ for (const s of toUpdate) {
+ await lifecycleStageService.update({
+ id: s.id,
+ workspaceId,
+ data: {
+ name: s.name,
+ icon: s.icon,
+ color: s.color,
+ position: s.position,
+ isDefault: s.isDefault,
+ isLost: s.isLost,
+ },
+ })
+ }
+
+ revalidatePath("/space", "layout")
+ },
+ )
diff --git a/apps/builder/src/features/lifecycle-stages/actions/update-contact-lifecycle-action.ts b/apps/builder/src/features/lifecycle-stages/actions/update-contact-lifecycle-action.ts
new file mode 100644
index 000000000..4935c3187
--- /dev/null
+++ b/apps/builder/src/features/lifecycle-stages/actions/update-contact-lifecycle-action.ts
@@ -0,0 +1,44 @@
+"use server"
+
+import { contactService } from "@chatbotx.io/business"
+import { emitLifecycleStageChanged } from "@chatbotx.io/events"
+import { revalidatePath } from "next/cache"
+import {
+ type WorkspaceIdRequestParams,
+ workspaceIdrequestParams,
+} from "@/features/common/schemas"
+import { workspaceActionClient } from "@/lib/safe-action"
+import { updateContactLifecycleSchema } from "../schema/update-contact-lifecycle"
+
+export const updateContactLifecycleAction = workspaceActionClient
+ .bindArgsSchemas(workspaceIdrequestParams)
+ .inputSchema(updateContactLifecycleSchema)
+ .action(
+ async ({
+ bindArgsParsedInputs: [workspaceId],
+ parsedInput,
+ }: {
+ bindArgsParsedInputs: WorkspaceIdRequestParams
+ parsedInput: { contactId: string; lifecycleStageId: string | null }
+ }) => {
+ const transition = await contactService.updateLifecycleStage({
+ contactId: parsedInput.contactId,
+ workspaceId,
+ lifecycleStageId: parsedInput.lifecycleStageId,
+ })
+
+ // Fire the trigger/webhook event only when the stage actually changed.
+ if (transition) {
+ await emitLifecycleStageChanged(
+ workspaceId,
+ transition.contactId,
+ transition.toStageId,
+ transition.fromStageId,
+ transition.toStageName,
+ transition.fromStageName,
+ )
+ }
+
+ revalidatePath("/space", "layout")
+ },
+ )
diff --git a/apps/builder/src/features/lifecycle-stages/bulk-move-lifecycle-dialog.tsx b/apps/builder/src/features/lifecycle-stages/bulk-move-lifecycle-dialog.tsx
new file mode 100644
index 000000000..2b6b4953a
--- /dev/null
+++ b/apps/builder/src/features/lifecycle-stages/bulk-move-lifecycle-dialog.tsx
@@ -0,0 +1,130 @@
+"use client"
+
+import type { LifecycleStageModel } from "@chatbotx.io/database/types"
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@chatbotx.io/ui/components/ui/dialog"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@chatbotx.io/ui/components/ui/select"
+import { Loader2Icon } from "lucide-react"
+import { useRouter } from "next/navigation"
+import { useTranslations } from "next-intl"
+import { useAction } from "next-safe-action/hooks"
+import { useState } from "react"
+import { toast } from "sonner"
+import { useWorkspaceId } from "@/hooks/routing"
+import { bulkUpdateContactLifecycleAction } from "./actions/bulk-update-contact-lifecycle-action"
+
+type BulkMoveLifecycleDialogProps = {
+ contactIds: string[]
+ stages: LifecycleStageModel[]
+ trigger: React.ReactNode
+}
+
+export function BulkMoveLifecycleDialog({
+ contactIds,
+ stages,
+ trigger,
+}: BulkMoveLifecycleDialogProps) {
+ const t = useTranslations()
+ const router = useRouter()
+ const workspaceId = useWorkspaceId()
+ const [open, setOpen] = useState(false)
+ const [targetStageId, setTargetStageId] = useState("__none__")
+
+ const activeStages = stages.filter((s) => !s.isLost)
+ const lostStages = stages.filter((s) => s.isLost)
+
+ const { execute, isExecuting } = useAction(
+ bulkUpdateContactLifecycleAction.bind(null, workspaceId),
+ {
+ onSuccess: ({ data }) => {
+ toast.success(
+ t("lifecycle.bulkMoved", {
+ count: data?.updated ?? contactIds.length,
+ }),
+ )
+ setOpen(false)
+ router.refresh()
+ },
+ onError: ({ error }) => {
+ toast.error(error.serverError ?? t("lifecycle.contactUpdateError"))
+ },
+ },
+ )
+
+ const handleConfirm = () => {
+ const stageId = targetStageId === "__none__" ? null : targetStageId
+ execute({ contactIds, lifecycleStageId: stageId })
+ }
+
+ return (
+
+ {trigger}
+
+
+ {t("lifecycle.bulkMove")}
+
+ {t("lifecycle.bulkMoved", { count: contactIds.length })}
+
+
+
+
+
+
+
+
+
+
+
+ {t("lifecycle.noStage")}
+
+
+ {activeStages.map((stage) => (
+
+ {stage.icon ?? "•"}
+ {stage.name}
+
+ ))}
+ {lostStages.map((stage) => (
+
+ {stage.icon ?? "•"}
+ {stage.name}
+
+ ))}
+
+
+
+
+
+ setOpen(false)}
+ variant="outline"
+ >
+ {t("actions.cancel")}
+
+
+ {isExecuting && }
+ {t("actions.confirm")}
+
+
+
+
+ )
+}
diff --git a/apps/builder/src/features/lifecycle-stages/contact-lifecycle-select.tsx b/apps/builder/src/features/lifecycle-stages/contact-lifecycle-select.tsx
new file mode 100644
index 000000000..cdda702f7
--- /dev/null
+++ b/apps/builder/src/features/lifecycle-stages/contact-lifecycle-select.tsx
@@ -0,0 +1,95 @@
+"use client"
+
+import type { LifecycleStageModel } from "@chatbotx.io/database/types"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@chatbotx.io/ui/components/ui/select"
+import { useTranslations } from "next-intl"
+import { useAction } from "next-safe-action/hooks"
+import { useState } from "react"
+import { toast } from "sonner"
+import { useWorkspaceId } from "@/hooks/routing"
+import { updateContactLifecycleAction } from "./actions/update-contact-lifecycle-action"
+
+type ContactLifecycleSelectProps = {
+ contactId: string
+ currentStageId: string | null
+ stages: LifecycleStageModel[]
+ disabled?: boolean
+ onChange?: (stageId: string | null) => void
+}
+
+export function ContactLifecycleSelect({
+ contactId,
+ currentStageId,
+ stages,
+ disabled = false,
+ onChange,
+}: ContactLifecycleSelectProps) {
+ const t = useTranslations()
+ const workspaceId = useWorkspaceId()
+ const [optimisticStageId, setOptimisticStageId] = useState(
+ currentStageId,
+ )
+
+ const activeStages = stages.filter((s) => !s.isLost)
+ const lostStages = stages.filter((s) => s.isLost)
+
+ const { execute, isExecuting } = useAction(
+ updateContactLifecycleAction.bind(null, workspaceId),
+ {
+ onSuccess: () => {
+ toast.success(t("lifecycle.contactUpdated"))
+ },
+ onError: ({ error }) => {
+ // rollback
+ setOptimisticStageId(currentStageId)
+ toast.error(error.serverError ?? t("lifecycle.contactUpdateError"))
+ },
+ },
+ )
+
+ function handleChange(value: string) {
+ const next = value === "__none__" ? null : value
+ setOptimisticStageId(next)
+ onChange?.(next)
+ execute({ contactId, lifecycleStageId: next })
+ }
+
+ return (
+
+
+
+
+
+
+
+ {t("lifecycle.noStage")}
+
+
+ {activeStages.length > 0 &&
+ activeStages.map((stage) => (
+
+ {stage.icon ?? "•"}
+ {stage.name}
+
+ ))}
+ {lostStages.length > 0 &&
+ lostStages.map((stage) => (
+
+ {stage.icon ?? "•"}
+ {stage.name}
+
+ ))}
+
+
+ )
+}
diff --git a/apps/builder/src/features/lifecycle-stages/delete-stage-dialog.tsx b/apps/builder/src/features/lifecycle-stages/delete-stage-dialog.tsx
new file mode 100644
index 000000000..2c9eee0b9
--- /dev/null
+++ b/apps/builder/src/features/lifecycle-stages/delete-stage-dialog.tsx
@@ -0,0 +1,177 @@
+"use client"
+
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@chatbotx.io/ui/components/ui/dialog"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@chatbotx.io/ui/components/ui/select"
+import { Loader2Icon } from "lucide-react"
+import { useTranslations } from "next-intl"
+import { useAction } from "next-safe-action/hooks"
+import { useEffect, useState } from "react"
+import { toast } from "sonner"
+import { useWorkspaceId } from "@/hooks/routing"
+import {
+ countContactsByStageAction,
+ deleteLifecycleStageWithReassignAction,
+} from "./actions/delete-lifecycle-stage-action"
+
+type Stage = {
+ id: string
+ name: string
+ icon: string | null
+ isLost: boolean
+}
+
+type DeleteStageDialogProps = {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ stage: Stage | null
+ allStages: Stage[]
+ onDeleted: () => void
+}
+
+export function DeleteStageDialog({
+ open,
+ onOpenChange,
+ stage,
+ allStages,
+ onDeleted,
+}: DeleteStageDialogProps) {
+ const t = useTranslations()
+ const workspaceId = useWorkspaceId()
+ const [count, setCount] = useState(null)
+ const [reassignTo, setReassignTo] = useState("__none__")
+
+ const candidates = allStages.filter(
+ (s) => s.id !== stage?.id && s.isLost === stage?.isLost,
+ )
+
+ const { execute: fetchCount } = useAction(
+ countContactsByStageAction.bind(null, workspaceId),
+ {
+ onSuccess: ({ data }) => {
+ setCount(data?.count ?? 0)
+ },
+ },
+ )
+
+ const { execute: doDelete, isExecuting: deleting } = useAction(
+ deleteLifecycleStageWithReassignAction.bind(null, workspaceId),
+ {
+ onSuccess: ({ data }) => {
+ toast.success(t("lifecycle.bulkMoved", { count: data?.moved ?? 0 }))
+ onOpenChange(false)
+ onDeleted()
+ },
+ onError: ({ error }) => {
+ toast.error(error.serverError ?? t("lifecycle.saveError"))
+ },
+ },
+ )
+
+ useEffect(() => {
+ if (open && stage) {
+ setCount(null)
+ setReassignTo("__none__")
+ fetchCount({ stageId: stage.id })
+ }
+ }, [open, stage, fetchCount])
+
+ if (!stage) {
+ return null
+ }
+
+ const hasContacts = count !== null && count > 0
+
+ const handleConfirm = () => {
+ const reassignToStageId = reassignTo === "__none__" ? null : reassignTo
+ doDelete({ stageId: stage.id, reassignToStageId })
+ }
+
+ return (
+
+
+
+
+ {t("lifecycle.deleteModalTitle", { name: stage.name })}
+
+
+ {(() => {
+ if (count === null) {
+ return (
+
+
+ ...
+
+ )
+ }
+ if (hasContacts) {
+ return t("lifecycle.deleteModalDescription", { count })
+ }
+ return t("lifecycle.deleteModalNoContacts")
+ })()}
+
+
+
+ {hasContacts && (
+
+
+ {t("lifecycle.deleteModalReassignTo")}
+
+
+
+
+
+
+
+
+ {t("lifecycle.deleteModalNoTarget")}
+
+
+ {candidates.map((s) => (
+
+ {s.icon ?? "•"}
+ {s.name}
+
+ ))}
+
+
+
+ )}
+
+
+ onOpenChange(false)}
+ variant="outline"
+ >
+ {t("lifecycle.deleteModalCancel")}
+
+
+ {deleting && }
+ {t("lifecycle.deleteModalConfirm")}
+
+
+
+
+ )
+}
diff --git a/apps/builder/src/features/lifecycle-stages/lifecycle-badge-select.tsx b/apps/builder/src/features/lifecycle-stages/lifecycle-badge-select.tsx
new file mode 100644
index 000000000..34cf33470
--- /dev/null
+++ b/apps/builder/src/features/lifecycle-stages/lifecycle-badge-select.tsx
@@ -0,0 +1,169 @@
+"use client"
+
+import type { LifecycleStageModel } from "@chatbotx.io/database/types"
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectSeparator,
+ SelectTrigger,
+} from "@chatbotx.io/ui/components/ui/select"
+import { cn } from "@chatbotx.io/ui/lib/utils"
+import { useTranslations } from "next-intl"
+import { useAction } from "next-safe-action/hooks"
+import { useEffect, useState } from "react"
+import { toast } from "sonner"
+import { useChatStore } from "@/features/chat/store/chat-store-provider"
+import { useWorkspaceId } from "@/hooks/routing"
+import { updateContactLifecycleAction } from "./actions/update-contact-lifecycle-action"
+
+type LifecycleBadgeSelectProps = {
+ contactId: string
+ currentStageId: string | null
+ stages: LifecycleStageModel[]
+ onChange?: (stageId: string | null) => void
+}
+
+// Versão "badge" compacta do ContactLifecycleSelect — pra usar no header da
+// conversa, igual Respond.io: lifecycle aparece como pill clicável ao lado do
+// nome, click abre dropdown com os stages. Quando não tem stage definido,
+// mostra "Sem estágio" em outline.
+// 2026-05-24 — Sprint Inbox 1.1.
+export function LifecycleBadgeSelect({
+ contactId,
+ currentStageId,
+ stages,
+ onChange,
+}: LifecycleBadgeSelectProps) {
+ const t = useTranslations()
+ const workspaceId = useWorkspaceId()
+ const updateContactInConversations = useChatStore(
+ (state) => state.updateContactInConversations,
+ )
+ const [optimisticStageId, setOptimisticStageId] = useState(
+ currentStageId,
+ )
+
+ // Sincroniza estado local com prop quando ela muda externamente (ex.:
+ // user clicou no botão de próxima etapa, navegou pra outra conversa,
+ // ou outro componente atualizou o lifecycle). Pedro pediu 2026-05-25 —
+ // "tudo instantâneo, trocou em um canto vai trocar em todos os outros".
+ useEffect(() => {
+ setOptimisticStageId(currentStageId)
+ }, [currentStageId])
+
+ const activeStages = stages.filter((s) => !s.isLost)
+ const lostStages = stages.filter((s) => s.isLost)
+ const current = optimisticStageId
+ ? stages.find((s) => s.id === optimisticStageId)
+ : null
+
+ const { execute, isExecuting } = useAction(
+ updateContactLifecycleAction.bind(null, workspaceId),
+ {
+ onSuccess: () => {
+ toast.success(t("lifecycle.contactUpdated"))
+ },
+ onError: ({ error }) => {
+ setOptimisticStageId(currentStageId)
+ // Reverte também no store global
+ updateContactInConversations(contactId, {
+ lifecycleStageId: currentStageId,
+ })
+ toast.error(error.serverError ?? t("lifecycle.contactUpdateError"))
+ },
+ },
+ )
+
+ function handleChange(value: string) {
+ const next = value === "__none__" ? null : value
+ setOptimisticStageId(next)
+ // Propaga MUDANÇA pra TODAS as conversations do contato no store
+ // global → card da lista, header da conversa, drawer direito todos
+ // re-renderizam instantaneamente sem precisar refresh.
+ updateContactInConversations(contactId, { lifecycleStageId: next })
+ onChange?.(next)
+ execute({ contactId, lifecycleStageId: next })
+ }
+
+ // Sem stages cadastrados no workspace: não renderiza (igual quando lifecycle
+ // tá desabilitado).
+ if (stages.length === 0) {
+ return null
+ }
+
+ return (
+
+ {/* Pixel-perfect Respond.io 2026-05-25:
+ bg transparente · color #CFD3D8 · font 14/400 · padding 0 14 0 8
+ radius só lado esquerdo (forma "pílula" com LifecycleNextStageButton à direita)
+ height 28px (h-7) · sem chevron interno · sem uppercase forçado */}
+ {/* Pixel-perfect Respond.io 2026-05-25 — forçar bg transparente em
+ dark mode (SelectTrigger tem `dark:bg-input/30` default) e forçar
+ h-7 sobrescrevendo o `data-[size=default]:h-9` padrão. Sem radius
+ nem border próprios — o WRAPPER pai (MessageHead) tem o border e
+ radius da pílula inteira. */}
+ svg]:hidden",
+ current
+ ? "text-text-secondary"
+ : "text-muted-foreground hover:text-text-secondary",
+ )}
+ >
+
+ {current ? (
+ <>
+ {current.icon ? `${current.icon} ` : ""}
+ {current.name}
+ >
+ ) : (
+ t("lifecycle.noStage")
+ )}
+
+
+
+
+
+ {t("lifecycle.noStage")}
+
+
+ {activeStages.length > 0 && (
+
+ {t("lifecycle.activeStages")}
+ {activeStages.map((stage) => (
+
+ {stage.icon ?? "•"}
+ {stage.name}
+
+ ))}
+
+ )}
+ {lostStages.length > 0 && (
+ <>
+
+
+ {t("lifecycle.lostStages")}
+ {lostStages.map((stage) => (
+
+ {stage.icon ?? "•"}
+ {stage.name}
+
+ ))}
+
+ >
+ )}
+
+
+ )
+}
diff --git a/apps/builder/src/features/lifecycle-stages/lifecycle-next-stage-button.tsx b/apps/builder/src/features/lifecycle-stages/lifecycle-next-stage-button.tsx
new file mode 100644
index 000000000..e4521ed05
--- /dev/null
+++ b/apps/builder/src/features/lifecycle-stages/lifecycle-next-stage-button.tsx
@@ -0,0 +1,142 @@
+"use client"
+
+import type { LifecycleStageModel } from "@chatbotx.io/database/types"
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@chatbotx.io/ui/components/ui/tooltip"
+import { ChevronRightIcon } from "lucide-react"
+import { useTranslations } from "next-intl"
+import { useAction } from "next-safe-action/hooks"
+import { useMemo } from "react"
+import { toast } from "sonner"
+import { useChatStore } from "@/features/chat/store/chat-store-provider"
+import { useWorkspaceId } from "@/hooks/routing"
+import { updateContactLifecycleAction } from "./actions/update-contact-lifecycle-action"
+
+type LifecycleNextStageButtonProps = {
+ contactId: string
+ currentStageId: string | null
+ stages: LifecycleStageModel[]
+ onChange?: (stageId: string | null) => void
+}
+
+// Botão chevron-right ao lado do badge de lifecycle no topbar da conversa.
+// Pedro pediu pixel-perfect Respond.io 2026-05-25: clicar avança o contato
+// PRA PRÓXIMA ETAPA do funil (próxima stage ATIVA por `position`). Etapas
+// "perdidas" (isLost) ficam fora do fluxo automático.
+//
+// - Sem stage atual → vai pra primeira stage ativa
+// - Na última stage ativa → desabilitado
+// - Tooltip mostra "Próxima etapa: NOME"
+export function LifecycleNextStageButton({
+ contactId,
+ currentStageId,
+ stages,
+ onChange,
+}: LifecycleNextStageButtonProps) {
+ const t = useTranslations()
+ const workspaceId = useWorkspaceId()
+ const updateContactInConversations = useChatStore(
+ (state) => state.updateContactInConversations,
+ )
+
+ // Stages ativas (isLost = false) ordenadas por position. Lost ficam de
+ // fora — não fazem parte do funil principal.
+ const activeStages = useMemo(
+ () =>
+ stages
+ .filter((s) => !s.isLost && s.isActive)
+ .sort((a, b) => a.position - b.position),
+ [stages],
+ )
+
+ const nextStage = useMemo(() => {
+ if (activeStages.length === 0) {
+ return null
+ }
+ if (!currentStageId) {
+ return activeStages[0]
+ }
+ const currentIdx = activeStages.findIndex((s) => s.id === currentStageId)
+ // Stage atual não está nas ativas (ex.: é uma lost) → próxima = primeira
+ if (currentIdx === -1) {
+ return activeStages[0]
+ }
+ // Já é a última — não há próxima (botão fica DISABLED, não escondido)
+ if (currentIdx === activeStages.length - 1) {
+ return null
+ }
+ return activeStages[currentIdx + 1]
+ }, [activeStages, currentStageId])
+
+ const { execute, isExecuting } = useAction(
+ updateContactLifecycleAction.bind(null, workspaceId),
+ {
+ onSuccess: () => {
+ toast.success(t("lifecycle.contactUpdated"))
+ },
+ onError: ({ error }) => {
+ toast.error(error.serverError ?? t("lifecycle.contactUpdateError"))
+ },
+ },
+ )
+
+ // Sem stages cadastrados no workspace → some por completo (não há funil).
+ if (stages.length === 0) {
+ return null
+ }
+
+ // Botão SEMPRE renderiza quando há funil. Quando contato já está na
+ // ÚLTIMA etapa (`!nextStage`), fica DISABLED com tooltip indicando que
+ // não há próxima — Pedro pediu 2026-05-25, igual Respond.io.
+ const isAtLastStage = !nextStage
+ const tooltipLabel = isAtLastStage
+ ? t("lifecycle.lastStageTooltip")
+ : t("lifecycle.nextStageTooltip", {
+ name: `${nextStage.icon ?? ""} ${nextStage.name}`.trim(),
+ })
+
+ return (
+
+
+ {/* Pixel-perfect Respond.io 2026-05-25:
+ w 35 · h 28 · padding 0 8 · radius right-only
+ color #CFD3D8 (text-secondary) · bg transparent
+ Forma "pílula" colada ao LifecycleBadgeSelect (gap-0 entre eles). */}
+ {/* Sem radius/border próprios — wrapper pai tem a borda da pílula
+ inteira. Forçar h-7 com !important (Button shadcn tem default h-9).
+ Disabled quando já está na ÚLTIMA etapa (não esconde — mantém
+ visível pra UX consistente, igual Respond.io). */}
+ {
+ if (!nextStage) {
+ return
+ }
+ // Propaga mudança INSTANTÂNEA pra todas as conversations do
+ // contato no store global → card + header + drawer atualizam
+ // sem refresh. Pedro pediu 2026-05-25.
+ updateContactInConversations(contactId, {
+ lifecycleStageId: nextStage.id,
+ })
+ onChange?.(nextStage.id)
+ execute({ contactId, lifecycleStageId: nextStage.id })
+ }}
+ size="icon"
+ type="button"
+ variant="ghost"
+ >
+
+
+
+
+ {tooltipLabel}
+
+
+ )
+}
diff --git a/apps/builder/src/features/lifecycle-stages/lifecycle-stage-pill.tsx b/apps/builder/src/features/lifecycle-stages/lifecycle-stage-pill.tsx
new file mode 100644
index 000000000..58c425c31
--- /dev/null
+++ b/apps/builder/src/features/lifecycle-stages/lifecycle-stage-pill.tsx
@@ -0,0 +1,37 @@
+import type { LifecycleStageModel } from "@chatbotx.io/database/types"
+import { cn } from "@chatbotx.io/ui/lib/utils"
+
+// Pill read-only do lifecycle. Pra usar na linha 3 do ConversationItem
+// (pixel-perfect Respond.io § 5 do _visual-respond-mapping.md):
+// inline-flex h 20 max-w 132 padding 2 4 radius 4
+// bg rgba(206,206,208,.16) color text-secondary 12/600 line-h 16
+// Sem dropdown, sem click handler. Pra edição usar LifecycleBadgeSelect.
+type LifecycleStagePillProps = {
+ stage: Pick | null | undefined
+ className?: string
+}
+
+export function LifecycleStagePill({
+ stage,
+ className,
+}: LifecycleStagePillProps) {
+ if (!stage) {
+ return null
+ }
+ return (
+
+ {stage.icon && (
+
+ {stage.icon}
+
+ )}
+ {stage.name}
+
+ )
+}
diff --git a/apps/builder/src/features/lifecycle-stages/lifecycle-stages-editor.tsx b/apps/builder/src/features/lifecycle-stages/lifecycle-stages-editor.tsx
new file mode 100644
index 000000000..7a5de2616
--- /dev/null
+++ b/apps/builder/src/features/lifecycle-stages/lifecycle-stages-editor.tsx
@@ -0,0 +1,708 @@
+"use client"
+
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import { Input } from "@chatbotx.io/ui/components/ui/input"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@chatbotx.io/ui/components/ui/popover"
+import { Skeleton } from "@chatbotx.io/ui/components/ui/skeleton"
+import { Switch } from "@chatbotx.io/ui/components/ui/switch"
+import { cn } from "@chatbotx.io/ui/lib/utils"
+import {
+ BookOpenIcon,
+ GripVerticalIcon,
+ Loader2Icon,
+ MoreHorizontalIcon,
+ PlusIcon,
+} from "lucide-react"
+import dynamic from "next/dynamic"
+import { useTranslations } from "next-intl"
+import { useAction } from "next-safe-action/hooks"
+import { useEffect, useMemo, useState } from "react"
+import { toast } from "sonner"
+import { useWorkspaceId } from "@/hooks/routing"
+import { saveLifecycleStagesAction } from "./actions/save-lifecycle-stages-action"
+import { DeleteStageDialog } from "./delete-stage-dialog"
+import { MAX_LIFECYCLE_STAGES } from "./schema"
+
+const LIFECYCLE_VISIBILITY_KEY = "chatbotx.lifecycle.visible"
+
+const EmojiPickerReact = dynamic(() => import("emoji-picker-react"), {
+ ssr: false,
+ loading: () => ,
+})
+
+export type LifecycleStageData = {
+ id: string
+ key: string
+ name: string
+ icon: string | null
+ color: string | null
+ position: number
+ isDefault: boolean
+ isLost: boolean
+}
+
+type EditableStage = LifecycleStageData & {
+ _new?: boolean
+ _dirty?: boolean
+ _deleted?: boolean
+}
+
+const slugify = (s: string) =>
+ s
+ .toLowerCase()
+ .normalize("NFD")
+ .replace(/[̀-ͯ]/g, "")
+ .replace(/[^a-z0-9]+/g, "_")
+ .replace(/^_|_$/g, "") || "etapa"
+
+function generateLocalId(): string {
+ return `__new_${Math.random().toString(36).slice(2, 11)}`
+}
+
+export function LifecycleStagesEditor({
+ initialStages,
+}: {
+ initialStages: LifecycleStageData[]
+}) {
+ const t = useTranslations()
+ const workspaceId = useWorkspaceId()
+ const [serverStages, setServerStages] =
+ useState(initialStages)
+ const [editedStages, setEditedStages] = useState(
+ initialStages.map((s) => ({ ...s })),
+ )
+ const [menuOpenFor, setMenuOpenFor] = useState(null)
+ const [emojiPickerFor, setEmojiPickerFor] = useState(null)
+ const [draggingId, setDraggingId] = useState(null)
+ const [dragOverId, setDragOverId] = useState(null)
+ const [deleteStageId, setDeleteStageId] = useState(null)
+
+ // Toggle global persiste em localStorage
+ const [showLifecycle, setShowLifecycle] = useState(true)
+ useEffect(() => {
+ const stored = localStorage.getItem(LIFECYCLE_VISIBILITY_KEY)
+ if (stored !== null) {
+ setShowLifecycle(stored === "true")
+ }
+ }, [])
+ const onToggleVisibility = (next: boolean) => {
+ setShowLifecycle(next)
+ localStorage.setItem(LIFECYCLE_VISIBILITY_KEY, next ? "true" : "false")
+ }
+
+ const hasChanges = useMemo(() => {
+ if (editedStages.length !== serverStages.length) {
+ return true
+ }
+ for (const e of editedStages) {
+ const s = serverStages.find((x) => x.id === e.id)
+ if (e._new || e._dirty || e._deleted) {
+ return true
+ }
+ if (!s) {
+ return true
+ }
+ if (
+ e.name !== s.name ||
+ e.icon !== s.icon ||
+ e.position !== s.position ||
+ e.isDefault !== s.isDefault ||
+ e.isLost !== s.isLost
+ ) {
+ return true
+ }
+ }
+ return false
+ }, [editedStages, serverStages])
+
+ const active = editedStages.filter((s) => !(s.isLost || s._deleted))
+ const lost = editedStages.filter((s) => s.isLost && !s._deleted)
+
+ const { execute: save, isExecuting: saving } = useAction(
+ saveLifecycleStagesAction.bind(null, workspaceId),
+ {
+ onSuccess: () => {
+ toast.success(t("lifecycle.saved"))
+ // Limpa as flags _new/_dirty/_deleted e atualiza serverStages
+ const _cleanStages = editedStages
+ .filter((s) => !s._deleted)
+ .map((s) => {
+ const clean: LifecycleStageData = {
+ id: s._new ? s.id : s.id, // backend gerou ID novo pra _new — mas como ainda não retornamos, vamos forçar reload
+ key: s.key,
+ name: s.name,
+ icon: s.icon,
+ color: s.color,
+ position: s.position,
+ isDefault: s.isDefault,
+ isLost: s.isLost,
+ }
+ return clean
+ })
+ // Reload pra pegar IDs corretos dos novos
+ window.location.reload()
+ },
+ onError: ({ error }) => {
+ if (error.serverError) {
+ toast.error(error.serverError)
+ } else {
+ toast.error(t("lifecycle.saveError"))
+ }
+ },
+ },
+ )
+
+ // --- Operações locais ---
+ function updateStage(id: string, patch: Partial) {
+ setEditedStages((prev) =>
+ prev.map((s) => (s.id === id ? { ...s, ...patch, _dirty: true } : s)),
+ )
+ }
+
+ function deleteStage(id: string) {
+ const target = editedStages.find((s) => s.id === id)
+ if (!target) {
+ return
+ }
+ setMenuOpenFor(null)
+
+ // Se a etapa é nova (ainda não salvou no banco), apaga local sem modal
+ if (target._new) {
+ setEditedStages((prev) => prev.filter((s) => s.id !== id))
+ return
+ }
+
+ // Abre modal de reassignment pra etapa persistida
+ setDeleteStageId(id)
+ }
+
+ function handleStageDeleted(stageId: string) {
+ // Remove da lista local após delete bem-sucedido no servidor
+ setEditedStages((prev) => prev.filter((s) => s.id !== stageId))
+ setServerStages((prev) => prev.filter((s) => s.id !== stageId))
+ }
+
+ function setAsDefault(id: string) {
+ setEditedStages((prev) =>
+ prev.map((s) => {
+ if (s.isLost) {
+ return s
+ }
+ const shouldBeDefault = s.id === id
+ if (s.isDefault === shouldBeDefault) {
+ return s
+ }
+ return { ...s, isDefault: shouldBeDefault, _dirty: true }
+ }),
+ )
+ setMenuOpenFor(null)
+ }
+
+ // Paridade Respond.io: até 20 fases no total (active + lost combinados).
+ const totalActiveStages = editedStages.filter((s) => !s._deleted).length
+ const hasReachedLimit = totalActiveStages >= MAX_LIFECYCLE_STAGES
+
+ function addStage(type: "active" | "lost") {
+ if (hasReachedLimit) {
+ toast.error(
+ t("lifecycle.maxStagesReached", { max: MAX_LIFECYCLE_STAGES }),
+ )
+ return
+ }
+ const isLost = type === "lost"
+ const sameTypeStages = editedStages.filter(
+ (s) => s.isLost === isLost && !s._deleted,
+ )
+ const newPosition =
+ (sameTypeStages.length > 0
+ ? Math.max(...sameTypeStages.map((s) => s.position))
+ : 0) + 1
+ const id = generateLocalId()
+ const newStage: EditableStage = {
+ id,
+ key: slugify(`nova_etapa_${id.slice(-6)}`),
+ name: t("lifecycle.newStage"),
+ icon: isLost ? "❌" : "📥",
+ color: isLost ? "#ef4444" : "#3b82f6",
+ position: newPosition,
+ isDefault: false,
+ isLost,
+ _new: true,
+ _dirty: true,
+ }
+ setEditedStages((prev) => [...prev, newStage])
+ }
+
+ function handleCancel() {
+ setEditedStages(serverStages.map((s) => ({ ...s })))
+ setMenuOpenFor(null)
+ }
+
+ function handleSave() {
+ // Validar nomes vazios
+ const invalid = editedStages.find((s) => !(s._deleted || s.name.trim()))
+ if (invalid) {
+ toast.error(t("lifecycle.emptyNameError"))
+ return
+ }
+
+ // Regenera key dos novos baseado no nome
+ const payload = editedStages.map((s) => ({
+ ...s,
+ key: s._new ? slugify(s.name) : s.key,
+ }))
+
+ save({ stages: payload })
+ }
+
+ // --- Drag & Drop ---
+ function onDragStart(e: React.DragEvent, id: string) {
+ setDraggingId(id)
+ e.dataTransfer.effectAllowed = "move"
+ e.dataTransfer.setData("text/plain", id)
+ }
+
+ function onDragOver(e: React.DragEvent, id: string) {
+ e.preventDefault()
+ if (id !== draggingId) {
+ setDragOverId(id)
+ }
+ e.dataTransfer.dropEffect = "move"
+ }
+
+ function onDragLeave() {
+ setDragOverId(null)
+ }
+
+ function onDrop(e: React.DragEvent, targetId: string) {
+ e.preventDefault()
+ const sourceId = draggingId || e.dataTransfer.getData("text/plain")
+ setDraggingId(null)
+ setDragOverId(null)
+ if (!sourceId || sourceId === targetId) {
+ return
+ }
+
+ const source = editedStages.find((s) => s.id === sourceId)
+ const target = editedStages.find((s) => s.id === targetId)
+ if (!(source && target)) {
+ return
+ }
+
+ // Cross-column drag bloqueado
+ if (source.isLost !== target.isLost) {
+ return
+ }
+
+ setEditedStages((prev) => {
+ const sameColumn = prev.filter(
+ (s) => s.isLost === source.isLost && !s._deleted,
+ )
+ const others = prev.filter(
+ (s) => s.isLost !== source.isLost || s._deleted,
+ )
+ const sIdx = sameColumn.findIndex((s) => s.id === sourceId)
+ const tIdx = sameColumn.findIndex((s) => s.id === targetId)
+ const reordered = [...sameColumn]
+ const [moved] = reordered.splice(sIdx, 1)
+ reordered.splice(tIdx, 0, moved)
+
+ const renumbered = reordered.map((s, i) => ({
+ ...s,
+ position: i + 1,
+ _dirty: true,
+ }))
+ return [...others, ...renumbered]
+ })
+ }
+
+ function onDragEnd() {
+ setDraggingId(null)
+ setDragOverId(null)
+ }
+
+ // ---
+ return (
+
+ {/* Header */}
+
+
+ {/* Toggle global Mostrar/Ocultar */}
+
+
+
+ {t("lifecycle.toggleTitle")}
+
+
+ {t("lifecycle.toggleSubtitle")}
+
+
+
+
+
+ {/* Sub-header com Cancelar/Guardar */}
+
+
+ {t("lifecycle.configureTitle")}
+
+ {hasChanges && (
+
+
+ {t("actions.cancel")}
+
+
+ {saving && }
+ {saving ? t("lifecycle.saving") : t("actions.save")}
+
+
+ )}
+
+
+ {/* 2 colunas */}
+
+ addStage("active")}
+ onDelete={deleteStage}
+ onDragEnd={onDragEnd}
+ onDragLeave={onDragLeave}
+ onDragOver={onDragOver}
+ onDragStart={onDragStart}
+ onDrop={onDrop}
+ onSetDefault={setAsDefault}
+ onUpdate={updateStage}
+ setEmojiPickerFor={setEmojiPickerFor}
+ setMenuOpenFor={setMenuOpenFor}
+ stages={active}
+ subtitle={t("lifecycle.activeSubtitle")}
+ title={t("lifecycle.activeTitle")}
+ />
+ addStage("lost")}
+ onDelete={deleteStage}
+ onDragEnd={onDragEnd}
+ onDragLeave={onDragLeave}
+ onDragOver={onDragOver}
+ onDragStart={onDragStart}
+ onDrop={onDrop}
+ onSetDefault={setAsDefault}
+ onUpdate={updateStage}
+ setEmojiPickerFor={setEmojiPickerFor}
+ setMenuOpenFor={setMenuOpenFor}
+ stages={lost}
+ subtitle={t("lifecycle.lostSubtitle")}
+ title={t("lifecycle.lostTitle")}
+ />
+
+
+
({
+ id: s.id,
+ name: s.name,
+ icon: s.icon,
+ isLost: s.isLost,
+ }))}
+ onDeleted={() => {
+ if (deleteStageId) {
+ handleStageDeleted(deleteStageId)
+ }
+ setDeleteStageId(null)
+ }}
+ onOpenChange={(open) => {
+ if (!open) {
+ setDeleteStageId(null)
+ }
+ }}
+ open={deleteStageId !== null}
+ stage={
+ deleteStageId
+ ? (() => {
+ const s = editedStages.find((e) => e.id === deleteStageId)
+ return s
+ ? {
+ id: s.id,
+ name: s.name,
+ icon: s.icon,
+ isLost: s.isLost,
+ }
+ : null
+ })()
+ : null
+ }
+ />
+
+ )
+}
+
+type ColumnProps = {
+ title: string
+ subtitle: string
+ icon: string
+ isLost: boolean
+ stages: EditableStage[]
+ draggingId: string | null
+ dragOverId: string | null
+ menuOpenFor: string | null
+ setMenuOpenFor: (id: string | null) => void
+ emojiPickerFor: string | null
+ setEmojiPickerFor: (id: string | null) => void
+ onAdd: () => void
+ disableAdd?: boolean
+ onUpdate: (id: string, patch: Partial) => void
+ onDelete: (id: string) => void
+ onSetDefault: (id: string) => void
+ onDragStart: (e: React.DragEvent, id: string) => void
+ onDragOver: (e: React.DragEvent, id: string) => void
+ onDragLeave: () => void
+ onDrop: (e: React.DragEvent, id: string) => void
+ onDragEnd: () => void
+}
+
+function Column({
+ title,
+ subtitle,
+ icon,
+ // biome-ignore lint/correctness/noUnusedFunctionParameters: parte do contrato do componente — caller passa pra distinguir coluna lost/active
+ isLost,
+ stages,
+ draggingId,
+ dragOverId,
+ menuOpenFor,
+ setMenuOpenFor,
+ emojiPickerFor,
+ setEmojiPickerFor,
+ onAdd,
+ disableAdd = false,
+ onUpdate,
+ onDelete,
+ onSetDefault,
+ onDragStart,
+ onDragOver,
+ onDragLeave,
+ onDrop,
+ onDragEnd,
+}: ColumnProps) {
+ const t = useTranslations()
+
+ return (
+
+
+
+ {icon}
+ {title}
+
+
{subtitle}
+
+
+
+ {stages.map((stage, idx) => (
+
+
+ {t("lifecycle.stageNumber", { n: idx + 1 })}
+
+ {/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: drag-and-drop pattern HTML5 nativo via draggable + onDragStart/Over/End */}
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: idem */}
+
onDragOver(e, stage.id)}
+ onDragStart={(e) => onDragStart(e, stage.id)}
+ onDrop={(e) => onDrop(e, stage.id)}
+ >
+
+
+ setEmojiPickerFor(open ? stage.id : null)
+ }
+ open={emojiPickerFor === stage.id}
+ >
+
+
+ {stage.icon ?? "📌"}
+
+
+
+ {
+ onUpdate(stage.id, { icon: emojiData.emoji })
+ setEmojiPickerFor(null)
+ }}
+ previewConfig={{ showPreview: false }}
+ searchPlaceHolder={t("lifecycle.emojiSearch")}
+ theme={"auto" as never}
+ width={320}
+ />
+
+
+
onUpdate(stage.id, { name: e.target.value })}
+ value={stage.name}
+ />
+ {stage.isDefault && !stage.isLost && (
+
+ {t("lifecycle.defaultBadge")}
+
+ )}
+
+
+ setMenuOpenFor(menuOpenFor === stage.id ? null : stage.id)
+ }
+ size="sm"
+ variant="ghost"
+ >
+
+
+ {menuOpenFor === stage.id && (
+ <>
+ {/* biome-ignore lint/a11y/useSemanticElements: backdrop overlay click-outside;
teria estilo inválido pro fullscreen invisible div */}
+ setMenuOpenFor(null)}
+ onKeyDown={(e) => {
+ if (e.key === "Escape") {
+ setMenuOpenFor(null)
+ }
+ }}
+ role="button"
+ tabIndex={-1}
+ />
+
+ {!(stage.isLost || stage.isDefault) && (
+ onSetDefault(stage.id)}
+ type="button"
+ >
+ {t("lifecycle.menu.setAsDefault")}
+
+ )}
+ onDelete(stage.id)}
+ type="button"
+ >
+ {t("lifecycle.menu.delete")}
+
+
+ >
+ )}
+
+
+
+ ))}
+
+
+
+ {t("lifecycle.addStage")}
+
+
+
+ )
+}
diff --git a/apps/builder/src/features/lifecycle-stages/queries.ts b/apps/builder/src/features/lifecycle-stages/queries.ts
new file mode 100644
index 000000000..6f3753da3
--- /dev/null
+++ b/apps/builder/src/features/lifecycle-stages/queries.ts
@@ -0,0 +1,79 @@
+"use server"
+
+import { lifecycleStageService } from "@chatbotx.io/business"
+import type { LifecycleStageModel } from "@chatbotx.io/database/types"
+
+export async function listLifecycleStages(
+ workspaceId: string,
+): Promise
{
+ return await lifecycleStageService.listByWorkspaceId({ workspaceId })
+}
+
+// Conta CONTATOS por estágio (usado em /contacts pra mostrar quantos
+// contatos têm cada lifecycle). NÃO é o badge do Inbox.
+export async function countContactsByLifecycle(
+ workspaceId: string,
+): Promise> {
+ return await lifecycleStageService.countContactsByStage({ workspaceId })
+}
+
+// Conta CONVERSAS ABERTAS por estágio do contato. Usado no sidebar do Inbox
+// (badge ao lado de "Novo Lead", "Lead Quente" etc) — alinhado com docs
+// Respond.io: "Contagem de conversas abertas indica o número de conversas
+// abertas em uma Caixa de entrada".
+export async function countOpenConversationsByLifecycle(
+ workspaceId: string,
+): Promise> {
+ return await lifecycleStageService.countOpenConversationsByStage({
+ workspaceId,
+ })
+}
+
+// Contagens pros filtros top do sidebar (Todos / Minhas / Não atribuídas +
+// teams). Inclui só conversas ABERTAS.
+export async function countOpenConversationsByAssignment(
+ workspaceId: string,
+ currentUserId: string,
+ teamIds: string[] = [],
+): Promise<{
+ all: number
+ mine: number
+ unassigned: number
+ teams: Record
+}> {
+ return await lifecycleStageService.countOpenConversationsByAssignment({
+ workspaceId,
+ currentUserId,
+ teamIds,
+ })
+}
+
+// Recortes (Todos/Minhas/Não atribuídas/teams) que TÊM conversa NÃO LIDA
+// pelo agente. Usado pra indicador "nova mensagem" (ponto azul) na sidebar.
+// Doc: markdown-raw/inbox/getting-started-with-inbox.md → "O indicador de
+// nova mensagem é um ponto azul que aparece quando uma conversa tem uma
+// nova mensagem recebida".
+export async function countUnreadConversationsByAssignment(
+ workspaceId: string,
+ currentUserId: string,
+ teamIds: string[] = [],
+): Promise<{
+ all: number
+ mine: number
+ unassigned: number
+ teams: Record
+}> {
+ return await lifecycleStageService.countUnreadByAssignment({
+ workspaceId,
+ currentUserId,
+ teamIds,
+ })
+}
+
+// Mesma coisa mas por lifecycle stage (pra bolinha "nova mensagem" ao
+// lado de cada Ciclo de Vida).
+export async function countUnreadConversationsByLifecycle(
+ workspaceId: string,
+): Promise> {
+ return await lifecycleStageService.countUnreadByStage({ workspaceId })
+}
diff --git a/apps/builder/src/features/lifecycle-stages/schema/counts.ts b/apps/builder/src/features/lifecycle-stages/schema/counts.ts
new file mode 100644
index 000000000..3ee00bbea
--- /dev/null
+++ b/apps/builder/src/features/lifecycle-stages/schema/counts.ts
@@ -0,0 +1,14 @@
+import { z } from "zod"
+
+// Schema do getLifecycleCountsAction. Mora em arquivo separado por causa
+// do quirk Next 16 standalone — z.object inline em .inputSchema() em
+// "use server" file dispara `"use server" file can only export async
+// functions`. Documentado em
+// memory/reference_next16_standalone_use_server_quirk.md.
+export const getLifecycleCountsRequest = z.object({
+ teamIds: z.array(z.string()).optional(),
+})
+
+export type GetLifecycleCountsRequest = z.infer<
+ typeof getLifecycleCountsRequest
+>
diff --git a/apps/builder/src/features/lifecycle-stages/schema/index.ts b/apps/builder/src/features/lifecycle-stages/schema/index.ts
new file mode 100644
index 000000000..5c82b93af
--- /dev/null
+++ b/apps/builder/src/features/lifecycle-stages/schema/index.ts
@@ -0,0 +1,34 @@
+import { z } from "zod"
+
+// Paridade Respond.io: até 20 fases no total (Lifecycle + Lost combinados).
+// Fonte: workspace-settings.md — "pode criar, renomear e gerir até 20 fases".
+export const MAX_LIFECYCLE_STAGES = 20
+
+export const lifecycleStageInputSchema = z.object({
+ id: z.string(),
+ key: z.string().min(1).max(64),
+ name: z.string().min(1).max(64),
+ icon: z.string().nullable(),
+ color: z.string().nullable(),
+ position: z.number().int().min(0),
+ isDefault: z.boolean(),
+ isLost: z.boolean(),
+ _new: z.boolean().optional(),
+ _dirty: z.boolean().optional(),
+ _deleted: z.boolean().optional(),
+})
+
+export const saveLifecycleStagesSchema = z.object({
+ stages: z
+ .array(lifecycleStageInputSchema)
+ .refine(
+ (stages) =>
+ stages.filter((s) => !s._deleted).length <= MAX_LIFECYCLE_STAGES,
+ {
+ message: `Máximo de ${MAX_LIFECYCLE_STAGES} etapas permitidas (ativas + perdidas).`,
+ },
+ ),
+})
+
+export type LifecycleStageInput = z.infer
+export type SaveLifecycleStagesInput = z.infer
diff --git a/apps/builder/src/features/lifecycle-stages/schema/update-contact-lifecycle.ts b/apps/builder/src/features/lifecycle-stages/schema/update-contact-lifecycle.ts
new file mode 100644
index 000000000..f4d8df8e1
--- /dev/null
+++ b/apps/builder/src/features/lifecycle-stages/schema/update-contact-lifecycle.ts
@@ -0,0 +1,14 @@
+import { z } from "zod"
+
+// Schema separado pra evitar quirk Next 16 standalone — z.object inline
+// (mesmo só usado, não exportado) em "use server" file dispara
+// `"use server" file can only export async functions`. Memory:
+// reference_next16_standalone_use_server_quirk.md.
+export const updateContactLifecycleSchema = z.object({
+ contactId: z.string(),
+ lifecycleStageId: z.string().nullable(),
+})
+
+export type UpdateContactLifecycleInput = z.infer<
+ typeof updateContactLifecycleSchema
+>
diff --git a/apps/builder/src/features/magic-links/actions/create-magic-link.action.ts b/apps/builder/src/features/magic-links/actions/create-magic-link.action.ts
index 73b164b19..0d7d5f0a0 100644
--- a/apps/builder/src/features/magic-links/actions/create-magic-link.action.ts
+++ b/apps/builder/src/features/magic-links/actions/create-magic-link.action.ts
@@ -37,7 +37,7 @@ export const createMagicLinkAction = workspaceActionClient
} catch (error) {
if (isUniqueViolationError(error)) {
return returnValidationErrors(createMagicLinkRequest, {
- _errors: ["Validation Exception"],
+ _errors: ["Exceção de Validação"],
name: { _errors: ["Name is already taken"] },
})
}
diff --git a/apps/builder/src/features/magic-links/actions/update-magic-link.action.ts b/apps/builder/src/features/magic-links/actions/update-magic-link.action.ts
index 0f1aed0fe..535766378 100644
--- a/apps/builder/src/features/magic-links/actions/update-magic-link.action.ts
+++ b/apps/builder/src/features/magic-links/actions/update-magic-link.action.ts
@@ -60,7 +60,7 @@ export const updateMagicLink = async (
} catch (error) {
if (isUniqueViolationError(error)) {
return returnValidationErrors(updateMagicLinkRequest, {
- _errors: ["Validation Exception"],
+ _errors: ["Exceção de Validação"],
name: { _errors: ["Name is already taken"] },
})
}
diff --git a/apps/builder/src/features/magic-links/delete-magic-links.tsx b/apps/builder/src/features/magic-links/delete-magic-links.tsx
index d29c6eddf..e937930e9 100644
--- a/apps/builder/src/features/magic-links/delete-magic-links.tsx
+++ b/apps/builder/src/features/magic-links/delete-magic-links.tsx
@@ -97,7 +97,7 @@ export const DeleteMagicLinksDialog = ({
execute({ ids: magicLinks.map((f) => f.id) })}
size="sm"
diff --git a/apps/builder/src/features/messages/actions/create-message.action.ts b/apps/builder/src/features/messages/actions/create-message.action.ts
index 3d43fe03c..709ecdc3b 100644
--- a/apps/builder/src/features/messages/actions/create-message.action.ts
+++ b/apps/builder/src/features/messages/actions/create-message.action.ts
@@ -24,7 +24,7 @@ export const createMessageAction = workspaceActionClient
id: conversationId,
workspaceId,
},
- message: "Conversation not found",
+ message: "Conversa não encontrada",
})
// Find target contact inbox, or fallback to latest interactive contactInbox
diff --git a/apps/builder/src/features/messages/actions/create-message.service.ts b/apps/builder/src/features/messages/actions/create-message.service.ts
index 3c6c175ff..9751e0dc5 100644
--- a/apps/builder/src/features/messages/actions/create-message.service.ts
+++ b/apps/builder/src/features/messages/actions/create-message.service.ts
@@ -75,6 +75,11 @@ export const createMessage = async (props: {
senderId: user?.id,
contactInboxId: contactInbox.id,
contentType: "text",
+ // Comentário interno fica só na database — não vai pro canal.
+ // 2026-05-24 — Sprint Inbox 4.
+ isInternal: Boolean(
+ (parsedInput as { isInternal?: boolean }).isInternal,
+ ),
})
.returning()
.then((result) => result[0])
@@ -115,6 +120,10 @@ export const createMessage = async (props: {
return newMessage
})
+ // Comentário interno: só broadcast pra realtime (equipe vê), NÃO envia
+ // pro canal externo. 2026-05-24 — Sprint Inbox 4 (padrão Respond.io).
+ const isInternalMsg = Boolean(message.isInternal)
+
const promises: Promise[] = [
chatQueue.add(ChatJobAction.broadcastEvent, {
type: ChatJobAction.broadcastEvent,
@@ -129,18 +138,22 @@ export const createMessage = async (props: {
},
},
}),
- chatQueue.add(ChatJobAction.sendChannelMessage, {
- type: ChatJobAction.sendChannelMessage,
- data: {
- conversation,
- contactInbox,
- message: {
- ...message,
- clientId: parsedInput.clientId,
- },
- },
- }),
]
+ if (!isInternalMsg) {
+ promises.push(
+ chatQueue.add(ChatJobAction.sendChannelMessage, {
+ type: ChatJobAction.sendChannelMessage,
+ data: {
+ conversation,
+ contactInbox,
+ message: {
+ ...message,
+ clientId: parsedInput.clientId,
+ },
+ },
+ }),
+ )
+ }
if (user) {
emit("analytics:dashboard", {
diff --git a/apps/builder/src/features/messages/actions/create-webchat-message.action.ts b/apps/builder/src/features/messages/actions/create-webchat-message.action.ts
index 5ab4e28dc..a413e7d1c 100644
--- a/apps/builder/src/features/messages/actions/create-webchat-message.action.ts
+++ b/apps/builder/src/features/messages/actions/create-webchat-message.action.ts
@@ -318,7 +318,7 @@ async function getConversationFromInput(
.returning()
.then((result) => result[0])
if (!contact) {
- throw new ChatbotXException("Contact not found")
+ throw new ChatbotXException("Contato não encontrado")
}
isNewContact = true
@@ -354,13 +354,13 @@ async function getConversationFromInput(
}
if (!conversation) {
- throw new ChatbotXException("Conversation not found")
+ throw new ChatbotXException("Conversa não encontrada")
}
if (!contactInbox) {
throw new ChatbotXException("Contact inbox not found")
}
if (!contact) {
- throw new ChatbotXException("Contact not found")
+ throw new ChatbotXException("Contato não encontrado")
}
return {
diff --git a/apps/builder/src/features/messages/actions/search-messages-in-conversation.action.ts b/apps/builder/src/features/messages/actions/search-messages-in-conversation.action.ts
new file mode 100644
index 000000000..191021918
--- /dev/null
+++ b/apps/builder/src/features/messages/actions/search-messages-in-conversation.action.ts
@@ -0,0 +1,32 @@
+"use server"
+
+import { zodBigintAsString } from "@chatbotx.io/utils"
+import { z } from "zod"
+import { workspaceActionClient } from "@/lib/safe-action"
+import { searchMessagesInConversation } from "../queries"
+
+// Server action chamada pela lupa do header da conversa (Sprint Inbox 1.2).
+// Busca por keyword dentro de UMA conversa específica (não global).
+// Pedro 2026-05-24: "100% igual Respond.io" — lupa do header é busca local.
+export const searchMessagesInConversationAction = workspaceActionClient
+ .bindArgsSchemas([zodBigintAsString()])
+ .inputSchema(
+ z.object({
+ conversationId: zodBigintAsString(),
+ keyword: z.string().min(1).max(200),
+ }),
+ )
+ .action(async (props) => {
+ const {
+ bindArgsParsedInputs: [workspaceId],
+ parsedInput,
+ } = props
+
+ const results = await searchMessagesInConversation({
+ workspaceId,
+ conversationId: parsedInput.conversationId,
+ keyword: parsedInput.keyword,
+ })
+
+ return { results }
+ })
diff --git a/apps/builder/src/features/messages/api/authenticated.ts b/apps/builder/src/features/messages/api/authenticated.ts
index e4942413b..828165164 100644
--- a/apps/builder/src/features/messages/api/authenticated.ts
+++ b/apps/builder/src/features/messages/api/authenticated.ts
@@ -13,8 +13,8 @@ export const messagesAuthenticatedAPI = {
.route({
method: "GET",
path: "/workspaces/{workspaceId}/messages",
- summary: "List messages",
- tags: ["Messages"],
+ summary: "Listar mensagens",
+ tags: ["Mensagens"],
})
.input(listMessagesRequest)
.use(workspaceAuthorizedMidddleware, (input) => input.workspaceId)
@@ -25,8 +25,8 @@ export const messagesAuthenticatedAPI = {
.route({
method: "GET",
path: "/workspaces/{workspaceId}/messages/{id}",
- summary: "Find message by message id",
- tags: ["Messages"],
+ summary: "Buscar mensagem pelo ID",
+ tags: ["Mensagens"],
})
.input(findMessageRequest)
.use(workspaceAuthorizedMidddleware, (input) => input.workspaceId)
diff --git a/apps/builder/src/features/messages/components/date-separator.tsx b/apps/builder/src/features/messages/components/date-separator.tsx
new file mode 100644
index 000000000..02d8525a8
--- /dev/null
+++ b/apps/builder/src/features/messages/components/date-separator.tsx
@@ -0,0 +1,40 @@
+"use client"
+
+import { format, isToday, isYesterday } from "date-fns"
+import { ptBR } from "date-fns/locale"
+import { useTranslations } from "next-intl"
+
+type DateSeparatorProps = {
+ date: Date | string
+}
+
+// Pixel-perfect Respond.io 2026-05-25 (FASE B + iteração 11):
+// Pill cinza centralizado mostrando "Hoje" / "Ontem" / "DD/MM/YYYY"
+// no topo do bloco de mensagens daquele dia. Aparece dentro do
+// scroll da timeline (não fixed) e some quando rola pra cima do dia
+// anterior.
+// REGRA Pedro 2026-05-25 (feedback_pedro_radius_pills.md): NUNCA usar
+// rounded-full em pills com texto — Respond.io usa radius=4px exato
+// (v-chip --size-small confirmado via Chrome MCP getComputedStyle:
+// borderRadius "4px", bg #2F2F32, font 12/600, padding 2 4 2 4).
+export function DateSeparator({ date }: DateSeparatorProps) {
+ const t = useTranslations()
+ const d = typeof date === "string" ? new Date(date) : date
+
+ let label: string
+ if (isToday(d)) {
+ label = t("conversations.todayLabel")
+ } else if (isYesterday(d)) {
+ label = t("conversations.yesterdayLabel")
+ } else {
+ label = format(d, "dd 'de' MMMM 'de' yyyy", { locale: ptBR })
+ }
+
+ return (
+
+
+ {label}
+
+
+ )
+}
diff --git a/apps/builder/src/features/messages/components/emoji-picker.tsx b/apps/builder/src/features/messages/components/emoji-picker.tsx
index 8ce7d2f64..d4f6deb18 100644
--- a/apps/builder/src/features/messages/components/emoji-picker.tsx
+++ b/apps/builder/src/features/messages/components/emoji-picker.tsx
@@ -5,47 +5,82 @@ import {
PopoverTrigger,
} from "@chatbotx.io/ui/components/ui/popover"
import { Skeleton } from "@chatbotx.io/ui/components/ui/skeleton"
-import { SmileIcon } from "lucide-react"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@chatbotx.io/ui/components/ui/tooltip"
import dynamic from "next/dynamic"
+import { useTranslations } from "next-intl"
+import { RespondIcon } from "@/components/respond-icon"
import "./emoji.picker.css"
const BaseEmojiPicker = dynamic(() => import("emoji-picker-react"), {
ssr: false,
- loading: () => ,
+ loading: () => (
+
+ ),
})
+// EmojiPicker do composer — pixel-perfect Respond.io 2026-05-25
+// iteração 16 (Pedro: "deixar exatamente igual do responde io, com a
+// mesma quantidade de emojis cabendo na tela, os icones em cima").
+//
+// Mapeamento Chrome MCP no emoji-mart do Respond.io ao vivo:
+// - Picker 345×420 px
+// - bg #222225 (app-surface) + radius 5 px
+// - 10 categoria anchors no topo (33×42 cada)
+// - Inativo branco, ativo #AE65C5 (roxo) + underline 3 px
+// - Search input "Pesquisa" 32 px
+// - Apple-style emojis 22 px
+//
+// Skin tones picker + preview bottom — REMOVIDOS (Respond.io não tem).
+// Search ATIVO (Respond.io tem "Pesquisa").
const EmojiPicker = (props: {
label?: string
- size?: number
disabled?: boolean
onSelectEmoji: (v: string) => void
}) => {
- const { label, size = 300, disabled = false, onSelectEmoji } = props
+ const { label, disabled = false, onSelectEmoji } = props
+ const t = useTranslations()
return (
-
-
-
-
-
- {label}
-
-
-
+
+
+
+
+
+ {label}
+
+
+
+
+ {t("messages.composer.emoji")}
+
+
+
onSelectEmoji(v.emoji)}
- searchDisabled
- width={size}
+ previewConfig={{ showPreview: false }}
+ searchPlaceholder={t("messages.composer.emojiSearch")}
+ skinTonesDisabled
+ theme={"dark" as never}
+ width={345}
/>
diff --git a/apps/builder/src/features/messages/components/emoji.picker.css b/apps/builder/src/features/messages/components/emoji.picker.css
index 4995886db..a657bf152 100644
--- a/apps/builder/src/features/messages/components/emoji.picker.css
+++ b/apps/builder/src/features/messages/components/emoji.picker.css
@@ -1,11 +1,150 @@
-.epr-main {
- background-color: var(--background) !important;
+/* Pixel-perfect Respond.io 2026-05-25 (iteração 16) — Pedro pediu:
+ "deixar exatamente igual do responde io, com a mesma quantidade de
+ emojis cabendo na tela, os icones em cima, exatamente igual".
+
+ Mapeado via Chrome MCP no emoji-mart do Respond.io ao vivo:
+ - Picker 345×420 px
+ - bg #222225 (app-surface) + radius 5px
+ - Header anchors 42px com 33×42 cada
+ - Inativo: branco. Ativo: #AE65C5 (roxo) + underline 3px
+ - Search "Pesquisa" 32px
+ - Apple-style emojis
+*/
+
+aside.EmojiPickerReact,
+.EmojiPickerReact {
+ /* TAMANHOS — match exato Respond.io: 9 emojis por linha.
+ Cada CÉLULA (button) = 36 px (24px emoji + 6px padding cada lado).
+ 9 × 36 = 324 px de emojis. Picker 345 px - container padding = ok. */
+ --epr-emoji-size: 24px;
+ --epr-emoji-padding: 6px;
+ --epr-emoji-fullsize: 36px;
+ --epr-category-navigation-button-size: 24px;
+ --epr-header-padding: 8px 12px;
+ --epr-category-padding: 0 6px;
+ --epr-horizontal-padding: 6px;
+ --epr-search-input-padding: 0 12px;
+ --epr-search-input-height: 32px;
+ --epr-picker-border-radius: 5px;
+ --epr-category-label-height: 28px;
+
+ /* CORES — dark theme Respond.io */
+ --epr-bg-color: #222225;
+ --epr-category-label-bg-color: #222225;
+ --epr-text-color: #ffffff;
+ --epr-search-input-bg-color: rgba(255, 255, 255, 0.04);
+ --epr-search-input-bg-color-active: rgba(255, 255, 255, 0.08);
+ --epr-search-input-text-color: #ffffff;
+ --epr-search-input-placeholder-color: #97a0aa;
+ --epr-search-border-color: transparent;
+ --epr-picker-border-color: transparent;
+ --epr-hover-bg-color: rgba(255, 255, 255, 0.08);
+ --epr-focus-bg-color: rgba(255, 255, 255, 0.12);
+
+ /* ROXO ativo (categoria + highlight) — #AE65C5 confirmado Chrome MCP */
+ --epr-category-icon-active-color: #ae65c5;
+ --epr-highlight-color: #ae65c5;
+ --epr-skin-tone-picker-menu-color: rgba(34, 34, 37, 0.95);
+}
+
+/* Estrutura geral */
+.EmojiPickerReact {
+ border: none !important;
+}
+
+/* Search bar — Respond.io tem placeholder "Pesquisa" + bg sutil.
+ Iteração 17 (Pedro): emoji-picker-react renderiza ícone de lupa
+ sobre o input, criando sobreposição visual ("Resquisa" no print).
+ Esconder o ícone interno e dar padding-left normal. */
+.EmojiPickerReact .epr-search-container input.epr-search {
+ padding-right: 32px !important;
+ padding-left: 12px !important;
+ font-size: 13px !important;
+ color: var(--epr-search-input-text-color) !important;
+ background: var(--epr-search-input-bg-color) !important;
+ border: 1px solid transparent !important;
+ border-radius: 4px !important;
+}
+.EmojiPickerReact .epr-search-container input.epr-search:focus {
+ outline: none !important;
+ background: var(--epr-search-input-bg-color-active) !important;
+ border-color: rgba(255, 255, 255, 0.12) !important;
+}
+/* Esconder ícone de lupa interno (lib renderiza svg dentro do input) */
+.EmojiPickerReact .epr-search-container .epr-icn-search {
+ display: none !important;
+}
+/* Mover botão "limpar" (X) — quando user digita algo. Mantém pequeno
+ e na direita. */
+.EmojiPickerReact .epr-search-container .epr-btn-clear-search {
+ right: 8px !important;
+}
+
+/* Category label "Usados Frequentemente", "Emoticon & Sorrisos" — 12px medium muted */
+.EmojiPickerReact .epr-emoji-category-label {
+ padding: 6px 8px !important;
+ font-size: 12px !important;
+ font-weight: 600 !important;
+ color: #ffffff !important;
+ background: var(--epr-category-label-bg-color) !important;
+}
+
+/* Anchors top — 10 ícones de categoria em linha */
+.EmojiPickerReact .epr-category-nav {
+ padding: 4px 8px !important;
+ border-bottom: 1px solid transparent !important;
+}
+.EmojiPickerReact .epr-category-nav > button.epr-cat-btn {
+ color: #ffffff !important;
+ opacity: 0.6;
+ transition:
+ opacity 0.15s,
+ color 0.15s;
+}
+.EmojiPickerReact .epr-category-nav > button.epr-cat-btn:hover {
+ opacity: 1;
+}
+.EmojiPickerReact .epr-category-nav > button.epr-cat-btn.epr-active {
+ position: relative;
+ color: var(--epr-category-icon-active-color) !important;
+ opacity: 1;
+}
+/* Underline roxo do ativo — barra 3px embaixo */
+.EmojiPickerReact .epr-category-nav > button.epr-cat-btn.epr-active::after {
+ position: absolute;
+ right: 4px;
+ bottom: -4px;
+ left: 4px;
+ height: 3px;
+ content: "";
+ background: var(--epr-category-icon-active-color);
+ border-radius: 2px 2px 0 0;
+}
+
+/* Emoji hover */
+.EmojiPickerReact .epr-emoji-img,
+.EmojiPickerReact .epr-emoji {
+ border-radius: 4px !important;
+}
+.EmojiPickerReact button.epr-emoji:hover > .epr-emoji-img,
+.EmojiPickerReact button.epr-emoji:hover {
+ background: var(--epr-hover-bg-color) !important;
}
-.epr-main h2 {
- background-color: var(--muted) !important;
+/* Esconder elementos que Respond.io NÃO TEM */
+.EmojiPickerReact .epr-preview,
+.EmojiPickerReact .epr-skin-tones {
+ display: none !important;
}
-.epr-skin-tones {
- border: 0 !important;
+/* Scrollbar sutil */
+.EmojiPickerReact .epr-body::-webkit-scrollbar {
+ width: 6px;
+}
+.EmojiPickerReact .epr-body::-webkit-scrollbar-thumb {
+ background: rgba(255, 255, 255, 0.12);
+ border-radius: 3px;
+}
+.EmojiPickerReact .epr-body::-webkit-scrollbar-track {
+ background: transparent;
}
diff --git a/apps/builder/src/features/messages/components/file-upload.tsx b/apps/builder/src/features/messages/components/file-upload.tsx
index 328759898..5202b1530 100644
--- a/apps/builder/src/features/messages/components/file-upload.tsx
+++ b/apps/builder/src/features/messages/components/file-upload.tsx
@@ -6,9 +6,10 @@ import { useImperativeHandle } from "react"
import { useDropzone } from "react-dropzone"
import { Controller, useFormContext } from "react-hook-form"
+// Pixel-perfect Respond.io 2026-05-24: 50 arquivos por mensagem, 20MB cada.
export const FileUploadPreview = ({
ref,
- maxFiles = 1,
+ maxFiles = 50,
}: {
maxFiles?: number
} & {
@@ -19,7 +20,7 @@ export const FileUploadPreview = ({
const { getRootProps, getInputProps, open } = useDropzone({
noClick: true,
maxFiles,
- maxSize: 5 * 1024 * 1024,
+ maxSize: 20 * 1024 * 1024, // 20MB (era 5MB)
multiple: maxFiles > 1,
noKeyboard: true,
onDrop: (acceptedFiles) => {
diff --git a/apps/builder/src/features/messages/components/input-menu.tsx b/apps/builder/src/features/messages/components/input-menu.tsx
index 636fb065f..b166493c1 100644
--- a/apps/builder/src/features/messages/components/input-menu.tsx
+++ b/apps/builder/src/features/messages/components/input-menu.tsx
@@ -1,7 +1,3 @@
-import { Button } from "@chatbotx.io/ui/components/ui/button"
-import { WorkflowIcon } from "lucide-react"
-import { useTranslations } from "next-intl"
-import { SelectFlowDialog } from "@/features/flows/components/select-flow-dialog"
import SavedReplyManage from "@/features/saved-replies/saved-reply-manage"
import EmojiPicker from "./emoji-picker"
@@ -9,21 +5,13 @@ type InputMenuProps = {
setContent: (text: string, insert?: boolean) => void
}
-export const InputMenu = ({ setContent }: InputMenuProps) => {
- const t = useTranslations()
-
- return (
- <>
-
-
-
-
-
- setContent(emoji, true)} />
-
- >
- )
-}
+// Toolbar do composer. Antes tinha botão de SelectFlowDialog (workflow) mas
+// foi removido porque era redundante com o ShortcutMenu (raio ⚡) no header
+// da conversa — Respond.io também só tem o atalho em um lugar (header).
+// 2026-05-24 — Sprint Inbox 1.1.
+export const InputMenu = ({ setContent }: InputMenuProps) => (
+ <>
+ setContent(emoji, true)} />
+
+ >
+)
diff --git a/apps/builder/src/features/messages/components/mention-popover.tsx b/apps/builder/src/features/messages/components/mention-popover.tsx
new file mode 100644
index 000000000..d6255b469
--- /dev/null
+++ b/apps/builder/src/features/messages/components/mention-popover.tsx
@@ -0,0 +1,205 @@
+"use client"
+
+import {
+ Avatar,
+ AvatarFallback,
+ AvatarImage,
+} from "@chatbotx.io/ui/components/ui/avatar"
+import { cn } from "@chatbotx.io/ui/lib/utils"
+import { useEffect, useMemo, useRef, useState } from "react"
+import {
+ getAvatarInitials,
+ getRespondAvatarUrl,
+} from "@/features/contacts/utils"
+import { useUserStore } from "@/features/users/provider/user-store-context"
+
+// Pixel-perfect Respond.io 2026-05-25 (iteração 23 — Pedro):
+// Popover de @mention pra comentários internos. Quando user digita "@"
+// no comment textarea, abre lista flutuante de workspace members
+// filtrada pela query (texto após @). Setas/Enter pra navegar, Esc
+// fecha. Click insere "@nome " no textarea.
+//
+// Layout Respond.io (print Pedro):
+// - Popover dark ~360 px de largura
+// - Lista vertical scrollável
+// - Cada item: avatar 28 px + nome 14 px
+// - Item destacado (highlight): bg sutil + cor primary
+// - Aparece ANCORADO ACIMA do textarea (acima do composer)
+
+export type MentionItem = {
+ id: string
+ name: string
+ image?: string | null
+}
+
+type MentionPopoverProps = {
+ /** Estado controlado: posição inicial do "@" no texto (índice). null = fechado. */
+ triggerIndex: number | null
+ /** Query extraída do texto após "@" (já filtrada). */
+ query: string
+ /** Elemento âncora — geralmente o textarea ou wrapper. */
+ anchorEl: HTMLElement | null
+ /** Click em um item — host insere @name no texto. */
+ onSelect: (item: MentionItem) => void
+ /** Fechar o popover (Esc, click fora, ou após select). */
+ onClose: () => void
+}
+
+export function MentionPopover({
+ triggerIndex,
+ query,
+ anchorEl,
+ onSelect,
+ onClose,
+}: MentionPopoverProps) {
+ const { workspaceMembers } = useUserStore((state) => state)
+ const [activeIndex, setActiveIndex] = useState(0)
+ const popoverRef = useRef(null)
+ const open = triggerIndex !== null
+
+ // Lista filtrada por query (case-insensitive).
+ const items: MentionItem[] = useMemo(() => {
+ const q = query.toLowerCase().trim()
+ const out: MentionItem[] = []
+ for (const m of workspaceMembers) {
+ const user = m.user
+ if (!user) {
+ continue
+ }
+ const name = user.name || "?"
+ if (q && !name.toLowerCase().includes(q)) {
+ continue
+ }
+ out.push({
+ id: String(user.id),
+ name,
+ image: user.image ?? null,
+ })
+ if (out.length >= 12) {
+ break
+ }
+ }
+ return out
+ }, [workspaceMembers, query])
+
+ // Reseta active index quando query muda.
+ // biome-ignore lint/correctness/useExhaustiveDependencies: only on query change
+ useEffect(() => {
+ setActiveIndex(0)
+ }, [query])
+
+ // Navegação teclado: setas / Enter / Esc.
+ useEffect(() => {
+ if (!open) {
+ return
+ }
+ const handler = (e: KeyboardEvent) => {
+ if (e.key === "ArrowDown") {
+ e.preventDefault()
+ setActiveIndex((i) => Math.min(i + 1, items.length - 1))
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault()
+ setActiveIndex((i) => Math.max(i - 1, 0))
+ } else if (e.key === "Enter") {
+ e.preventDefault()
+ const item = items[activeIndex]
+ if (item) {
+ onSelect(item)
+ }
+ } else if (e.key === "Escape") {
+ e.preventDefault()
+ onClose()
+ } else if (e.key === "Tab") {
+ e.preventDefault()
+ const item = items[activeIndex]
+ if (item) {
+ onSelect(item)
+ }
+ }
+ }
+ document.addEventListener("keydown", handler, true)
+ return () => document.removeEventListener("keydown", handler, true)
+ }, [open, items, activeIndex, onSelect, onClose])
+
+ // Click fora fecha.
+ useEffect(() => {
+ if (!open) {
+ return
+ }
+ const handler = (e: MouseEvent) => {
+ const target = e.target as Node
+ if (
+ popoverRef.current &&
+ !popoverRef.current.contains(target) &&
+ anchorEl &&
+ !anchorEl.contains(target)
+ ) {
+ onClose()
+ }
+ }
+ document.addEventListener("mousedown", handler)
+ return () => document.removeEventListener("mousedown", handler)
+ }, [open, anchorEl, onClose])
+
+ if (!(open && anchorEl) || items.length === 0) {
+ return null
+ }
+
+ // Posicionar popover ACIMA do anchor (textarea do comment).
+ // O comment box vive na parte BAIXA da tela, então abre pra cima.
+ const rect = anchorEl.getBoundingClientRect()
+ const popoverStyle: React.CSSProperties = {
+ position: "fixed",
+ left: rect.left,
+ bottom: window.innerHeight - rect.top + 8,
+ width: Math.max(rect.width * 0.7, 280),
+ maxHeight: 320,
+ zIndex: 50,
+ }
+
+ return (
+
+ {items.map((item, idx) => {
+ const isActive = idx === activeIndex
+ const avatarSpec = getRespondAvatarUrl(item.id ?? item.name)
+ const initials = getAvatarInitials(item.name) || "?"
+ return (
+
onSelect(item)}
+ onMouseEnter={() => setActiveIndex(idx)}
+ role="option"
+ type="button"
+ >
+
+
+
+ {initials}
+
+
+ {item.name}
+
+ )
+ })}
+
+ )
+}
diff --git a/apps/builder/src/features/messages/components/message-bubble.tsx b/apps/builder/src/features/messages/components/message-bubble.tsx
index e1f9154dc..769776555 100644
--- a/apps/builder/src/features/messages/components/message-bubble.tsx
+++ b/apps/builder/src/features/messages/components/message-bubble.tsx
@@ -3,7 +3,14 @@ import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import type { HTMLAttributes } from "react"
-const messageBubbleVariant = cva("flex gap-2", {
+// items-end (Pedro 2026-05-25 iteração 9): garante que avatar e bubble
+// fiquem alinhados pelo BOTTOM em qualquer situação. Sem isso, o flex
+// default (align-items: stretch) deixava o avatar `self-end` ir pro
+// fundo do container — e como tinha min-h-11 (44px) no wrapper, o
+// avatar ficava ABAIXO da bubble quando ela era menor que 44px (1 linha).
+// Confirmado no Respond.io ao vivo: `dls-items-end` no parent + sem
+// min-height na bubble = avatar sempre encosta no bottom da bubble real.
+const messageBubbleVariant = cva("flex items-end gap-2", {
variants: {
variant: {
left: "flex self-start",
diff --git a/apps/builder/src/features/messages/components/message-input.tsx b/apps/builder/src/features/messages/components/message-input.tsx
index 6b9c9d548..711791a10 100644
--- a/apps/builder/src/features/messages/components/message-input.tsx
+++ b/apps/builder/src/features/messages/components/message-input.tsx
@@ -1,42 +1,94 @@
"use client"
-import type { ChannelType } from "@chatbotx.io/database/partials"
+import {
+ Avatar,
+ AvatarFallback,
+ AvatarImage,
+} from "@chatbotx.io/ui/components/ui/avatar"
import { Button } from "@chatbotx.io/ui/components/ui/button"
import { Form } from "@chatbotx.io/ui/components/ui/form"
import { Textarea } from "@chatbotx.io/ui/components/ui/textarea"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@chatbotx.io/ui/components/ui/tooltip"
+import { cn } from "@chatbotx.io/ui/lib/utils"
import { createId } from "@chatbotx.io/utils"
import { zodResolver } from "@hookform/resolvers/zod"
import { useHookFormAction } from "@next-safe-action/adapter-react-hook-form/hooks"
-import { PaperclipIcon, SendHorizonalIcon } from "lucide-react"
import { useTranslations } from "next-intl"
-import { type KeyboardEvent, useCallback, useMemo, useRef } from "react"
+import {
+ type KeyboardEvent,
+ useCallback,
+ useMemo,
+ useRef,
+ useState,
+} from "react"
import { Controller, useWatch } from "react-hook-form"
-import { InboxIcon } from "@/features/inboxes/components/inbox-icon"
+import { RespondIcon } from "@/components/respond-icon"
+import {
+ getAvatarInitials,
+ getRespondAvatarUrl,
+} from "@/features/contacts/utils"
+import { TemplatePickerSheet } from "@/features/conversations/components/template-picker-sheet"
import { QuickRepliesPopover } from "@/features/saved-replies/quick-replies-popover"
+import SavedReplyManage from "@/features/saved-replies/saved-reply-manage"
import { authClient } from "@/lib/auth/auth-client"
import { useChatStore } from "../../chat/store/chat-store-provider"
import { createMessageAction } from "../actions/create-message.action"
import { createMessageRequest } from "../schema/mutation"
+import EmojiPicker from "./emoji-picker"
import { FileUploadPreview } from "./file-upload"
-import { InputMenu } from "./input-menu"
+import { type MentionItem, MentionPopover } from "./mention-popover"
+// Pixel-perfect Respond.io 2026-05-25 (SEÇÃO #4 iteração 13 — Pedro):
+// Composer principal SEMPRE visível. "Adicionar comentário" abre uma
+// CAIXA SUSPENSA AMBER SEPARADA por baixo (não substitui o composer
+// principal). Quando comment aberto, dá pra enviar tanto mensagem
+// (composer principal acima) quanto comentário interno (caixa amber).
+//
+// Cores AMBER (Chrome MCP no Respond.io ao vivo):
+// - bg: rgb(63, 51, 34) = #3F3322
+// - border: rgb(255, 160, 36) = #FFA024 (1.33 px)
+// - radius: 8 px
+// - padding: 24 px (dls-p-6)
+//
+// Comportamento medido:
+// - Main composer y=1019, h=112 (continua visível)
+// - Comment box y=1140, h=91 (aparece abaixo, gap 9 px)
+// - X close no canto direito da caixa fecha o painel
+//
+// Ícones do toolbar (esquerda → direita): emoji, attach, snippets.
+// Microphone, AI prompts, AI Assist: mapeados no iconfont mas NÃO
+// renderizados (sem feature real, e Pedro proibiu placeholders fake).
export const MessageInput = () => {
const t = useTranslations()
const session = authClient.useSession()
const textareaRef = useRef(null)
+ const commentTextareaRef = useRef(null)
const fileUploadRef = useRef(null)
const { appendMessage, activeConversationId, conversations } = useChatStore(
(state) => state,
)
- // Memoize active conversation to prevent unnecessary re-renders
const conversation = useMemo(
() => conversations.find((c) => c.id === activeConversationId) ?? null,
[conversations, activeConversationId],
)
+ const [showCommentBox, setShowCommentBox] = useState(false)
+ const [commentText, setCommentText] = useState("")
+ const [templatePickerOpen, setTemplatePickerOpen] = useState(false)
+
+ // Detecta se a conversa é WhatsApp (afeta UI: banner 24h + Template
+ // picker). Pedro 2026-05-26 Sprint WhatsApp 3.2.
+ const isWhatsapp = conversation?.contactInboxes?.some(
+ (ci) => ci.inbox?.channel === "whatsapp",
+ )
+
const { form, handleSubmitWithAction, resetFormAndAction } =
useHookFormAction(
createMessageAction.bind(
@@ -48,14 +100,17 @@ export const MessageInput = () => {
{
actionProps: {
onExecute: ({ input }: { input: unknown }) => {
- // try to push raw message to store
if (
typeof input === "object" &&
input !== null &&
"text" in input &&
input.text
) {
- const typedInput = input as { text: string; clientId: string }
+ const typedInput = input as {
+ text: string
+ clientId: string
+ isInternal?: boolean
+ }
appendMessage({
text: typedInput.text,
id: createId(),
@@ -71,6 +126,11 @@ export const MessageInput = () => {
senderType: "user",
senderId: session?.data?.user.id ?? null,
clientId: typedInput.clientId,
+ isInternal: typedInput.isInternal ?? false,
+ deliveredAt: null,
+ readAt: null,
+ failedAt: null,
+ failureReason: null,
})
}
@@ -88,13 +148,13 @@ export const MessageInput = () => {
text: "",
files: [],
clientId: createId(),
+ isInternal: false,
},
},
errorMapProps: {},
},
)
- // Memoize emoji selection handler
const setContent = useCallback(
(value: string, insert = false) => {
const element = textareaRef.current
@@ -120,15 +180,106 @@ export const MessageInput = () => {
[form],
)
- // Memoize attachment click handler
+ const setCommentContent = useCallback((value: string, insert = false) => {
+ const el = commentTextareaRef.current
+ if (!el) {
+ return
+ }
+ if (!insert) {
+ setCommentText(value)
+ return
+ }
+ const text = el.value
+ const before = text.slice(0, el.selectionStart)
+ const after = text.slice(el.selectionStart)
+ setCommentText(`${before}${value}${after}`)
+ }, [])
+
+ // ─── @ MENTION (Pedro 2026-05-25 iteração 23) ────────────────────
+ // Detecta quando user digita "@" no comment textarea e abre popover
+ // com lista de workspace members. Algoritmo:
+ // - A cada mudança do texto, olha caractere antes do cursor
+ // - Procura último "@" sem espaço após ele (entre @ e cursor)
+ // - Se achar, abre popover com query = texto entre @ e cursor
+ // - Click em member: substitui "@query" por "@nome "
+ const [mentionTriggerIndex, setMentionTriggerIndex] = useState(
+ null,
+ )
+ const [mentionQuery, setMentionQuery] = useState("")
+
+ const detectMention = useCallback((text: string, caret: number) => {
+ // Procurar último @ antes do cursor
+ let i = caret - 1
+ while (i >= 0) {
+ const ch = text[i]
+ if (ch === "@") {
+ // Mention só vale se @ é início OU vem após espaço/quebra
+ const prev = i > 0 ? text[i - 1] : " "
+ if (prev === " " || prev === "\n" || i === 0) {
+ const q = text.slice(i + 1, caret)
+ // Query inválida se tem espaço dentro
+ if (!(q.includes(" ") || q.includes("\n"))) {
+ setMentionTriggerIndex(i)
+ setMentionQuery(q)
+ return
+ }
+ }
+ break
+ }
+ if (ch === " " || ch === "\n") {
+ break
+ }
+ i--
+ }
+ setMentionTriggerIndex(null)
+ setMentionQuery("")
+ }, [])
+
+ const handleCommentTextChange = useCallback(
+ (e: React.ChangeEvent) => {
+ const value = e.target.value
+ setCommentText(value)
+ detectMention(value, e.target.selectionStart)
+ },
+ [detectMention],
+ )
+
+ const handleMentionSelect = useCallback(
+ (item: MentionItem) => {
+ const el = commentTextareaRef.current
+ if (!el || mentionTriggerIndex === null) {
+ return
+ }
+ const caret = el.selectionStart
+ const before = commentText.slice(0, mentionTriggerIndex)
+ const after = commentText.slice(caret)
+ const inserted = `@${item.name} `
+ const newText = `${before}${inserted}${after}`
+ setCommentText(newText)
+ setMentionTriggerIndex(null)
+ setMentionQuery("")
+ // Reposicionar cursor após @nome
+ setTimeout(() => {
+ const newCaret = before.length + inserted.length
+ el.setSelectionRange(newCaret, newCaret)
+ el.focus()
+ }, 0)
+ },
+ [commentText, mentionTriggerIndex],
+ )
+
+ const closeMention = useCallback(() => {
+ setMentionTriggerIndex(null)
+ setMentionQuery("")
+ }, [])
+
const onClickAttachment = useCallback(() => {
if (fileUploadRef.current) {
// biome-ignore lint/suspicious/noExplicitAny: wip
- ;(fileUploadRef.current as any).openFileDialog() // Trigger the file dialog
+ ;(fileUploadRef.current as any).openFileDialog()
}
}, [])
- // Memoize keyboard handler
const onKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.nativeEvent.isComposing || e.key === "Process") {
@@ -142,112 +293,377 @@ export const MessageInput = () => {
[handleSubmitWithAction],
)
- // Check if conversation is over 7 days since last contact reply
- // const isOver7Days = useMemo(() => {
- // if (!conversation?.messages.length) {
- // return false
- // }
-
- // const sevenDaysAgo = new Date()
- // sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7)
- // return conversation.contactRepliedAt
- // ? new Date(conversation.contactRepliedAt) < sevenDaysAgo
- // : false
- // }, [conversation])
+ const submitComment = useCallback(() => {
+ if (!(commentText.trim() && conversation)) {
+ return
+ }
+ // Reuse mesma action — só com isInternal=true
+ appendMessage({
+ text: commentText,
+ id: createId(),
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ workspaceId: conversation.workspaceId ?? "",
+ sourceId: null,
+ contactInboxId: "",
+ conversationId: conversation.id ?? "",
+ contentAttributes: null,
+ messageType: "outgoing",
+ contentType: "text",
+ senderType: "user",
+ senderId: session?.data?.user.id ?? null,
+ clientId: createId(),
+ isInternal: true,
+ deliveredAt: null,
+ readAt: null,
+ failedAt: null,
+ failureReason: null,
+ })
+ // Fire-and-forget pra backend
+ createMessageAction
+ .bind(
+ null,
+ conversation.workspaceId ?? "",
+ conversation.id ?? "",
+ )({
+ text: commentText,
+ files: [],
+ clientId: createId(),
+ isInternal: true,
+ } as never)
+ .catch(() => {
+ /* erros visíveis via toast em outro fluxo — manter simples aqui */
+ })
+ setCommentText("")
+ commentTextareaRef.current?.focus()
+ }, [commentText, conversation, appendMessage, session])
+
+ const onCommentKeyDown = useCallback(
+ (e: KeyboardEvent) => {
+ if (e.nativeEvent.isComposing || e.key === "Process") {
+ return
+ }
+ if (e.key === "Enter" && e.shiftKey === false) {
+ e.preventDefault()
+ submitComment()
+ }
+ },
+ [submitComment],
+ )
+
const isDisabled = false
- // const isDisabled = useMemo(() => {
- // return isOver7Days && currentInboxType === "messenger"
- // }, [isOver7Days])
- const placeholder = isDisabled ? t("messages.userInactive") : "Message..."
+ // Placeholder minimalista (Pedro 2026-05-26): "Digite uma mensagem" em
+ // cor #4A535E — substitui texto antigo "Use '/' para snippets..." que era
+ // verboso e poluía o composer. Hint de atalhos pode voltar como tooltip
+ // ou popup contextual no futuro.
+ const placeholder = t("conversations.composerPlaceholder")
+
+ // Avatar do user logado pra mostrar no canto esquerdo da caixa de
+ // comentário interno (Pedro iteração 15 — Respond.io mostra ali).
+ // Snowflake bigint pra string + helper visual padronizado.
+ const sUser = session?.data?.user
+ const currentUserName = sUser?.name ?? sUser?.email ?? ""
+ const currentUserImage = sUser?.image ?? null
+ // Iter 41: seed = user.id (consistente com message-item outgoing).
+ const currentUserSeed = getRespondAvatarUrl(
+ sUser?.id ?? currentUserName ?? "u",
+ )
+ const currentUserInitials = getAvatarInitials(currentUserName) || "?"
- // Check if files are attached
const files = useWatch({
control: form.control,
name: "files",
})
const hasFiles = Array.isArray(files) && files.length > 0
- // Early return if no active conversation
if (!activeConversationId) {
return null
}
+ const activeChannel = conversation?.contactInboxes?.[0]?.channel ?? "webchat"
+ const _channelLabel =
+ activeChannel === "whatsapp"
+ ? "WhatsApp Business"
+ : activeChannel.charAt(0).toUpperCase() + activeChannel.slice(1)
+ const _contactNumber = conversation?.contact?.phoneNumber ?? ""
+
return (
-
-
-
+ {/* COMPOSER PRINCIPAL — sempre visível. Caixa destacada com bg-card,
+ border + radius 8 px que dá sensação de "flutuante" sobre o
+ canvas (#101113) do MessageList.
+ Pedro 2026-05-26: channel pill (WhatsApp Business · para: …) foi
+ REMOVIDO — informação redundante (avatar+nome do contato já estão
+ no header da conversa). */}
+
+ {/* TEXTAREA + TOOLBAR — COLAPSAM com animação suave quando comment
+ box abre (Pedro 2026-05-25 iteração 19). Antes era `hidden`
+ (corte seco); agora usa grid-rows trick pra animar max-height
+ + opacity em ~300 ms. O `overflow-hidden` no wrapper interno
+ esconde conteúdo durante a animação sem deformar o textarea. */}
+
-
-
(
-
-
+
+
+
+ (
+
+
+
+ )}
/>
-
- )}
- />
-
-
-
+
+
+ {hasFiles && (
+
+
+
+ )}
+ {!hasFiles && }
+
+
+
+ setContent(e, true)} />
+
+
+
+
+
+
+
+ {t("messages.composer.attach")}
+
+
+
+
+
+
+
+
+
+
+
+
+ {t("messages.composer.send")}
+
+
+
+
+
-
-
-
+
+
+ {/* CAIXA AMBER SUSPENSA — comentário interno (suspensa abaixo do composer
+ principal, separada por gap). Pixel-perfect Respond.io 2026-05-25:
+ - bg: #3F3322 (rgb 63 51 34)
+ - border: #FFA024 (rgb 255 160 36) 1.33 px ≈ border-[1px]
+ - radius: 8 px
+ - padding: 24 px (dls-p-6)
+ Composer principal continua visível em cima — pode enviar mensagem
+ OU comentário, é escolha do agente.
+ O wrapper externo já tem px-3 pb-3, então uso só mt-2 aqui. */}
+ {showCommentBox ? (
+
+ {/* X CLOSE no canto SUPERIOR DIREITO — pixel-perfect Respond.io
+ iteração 15: posicionado absoluto, top-right, border outline.
+ Coords no Respond.io: x=1369, y=15 dentro da caixa amber. */}
+
+
+ {
+ setShowCommentBox(false)
+ setCommentText("")
+ }}
+ size="icon"
+ type="button"
+ variant="ghost"
+ >
+
+
+
+
+ {t("messages.composer.exitInternal")}
+
+
+
+ {/* LINHA SUPERIOR: avatar 24px do user + textarea */}
+
+
+ {currentUserInitials}
+
+
+ {
+ // Quando mention popover aberto, deixa o popover tratar
+ // setas/Enter (ele já tem listener global). Esc também.
+ if (
+ mentionTriggerIndex !== null &&
+ ["ArrowUp", "ArrowDown", "Enter", "Escape", "Tab"].includes(
+ e.key,
+ )
+ ) {
+ return
+ }
+ onCommentKeyDown(e)
+ }}
+ placeholder={t("messages.composer.internalPlaceholder")}
+ ref={commentTextareaRef}
+ value={commentText}
+ />
+
+
- {!isDisabled && (
-
- {!hasFiles &&
}
+ {/* TOOLBAR BOTTOM: ícones esquerda + Send direita.
+ Ordem pixel-perfect Respond.io: emoji, attach.
+ (Skip magicpen-bold / comments-ai / at — sem feature real,
+ Pedro proibiu placeholder fake.) */}
+
+
+ setCommentContent(e, true)} />
+
+
+
+
+
+
+
+ {t("messages.composer.attach")}
+
+
+
+
+
-
+
-
-
-
-
- )}
+
+
+ {t("messages.composer.send")}
+
+
-
-
+
+ ) : (
+ // BOTÃO "Adicionar comentário" — abre a caixa suspensa amber.
+ // Pixel-perfect Respond.io (Pedro iteração 14): linha discreta
+ // que vive ABAIXO da caixa do composer, fora dela. Mantém o
+ // wrapper externo px-3 pb-3 do composer principal — fica sem
+ // bg/border próprio pra parecer "ancorado" no canvas.
+
{
+ setShowCommentBox(true)
+ setTimeout(() => commentTextareaRef.current?.focus(), 60)
+ }}
+ type="button"
+ >
+
+
+ {t("messages.composer.addComment")}
+
+
+ )}
+
+ {isWhatsapp && conversation && (
+
+ )}
)
}
diff --git a/apps/builder/src/features/messages/components/message-item.tsx b/apps/builder/src/features/messages/components/message-item.tsx
index b5b189a1b..9a0ff023c 100644
--- a/apps/builder/src/features/messages/components/message-item.tsx
+++ b/apps/builder/src/features/messages/components/message-item.tsx
@@ -4,6 +4,11 @@ import type {
MessageButtonTemplate,
MessageTemplateEntity,
} from "@chatbotx.io/sdk"
+import {
+ Avatar,
+ AvatarFallback,
+ AvatarImage,
+} from "@chatbotx.io/ui/components/ui/avatar"
import { Button } from "@chatbotx.io/ui/components/ui/button"
import { Card, CardContent } from "@chatbotx.io/ui/components/ui/card"
import {
@@ -13,28 +18,82 @@ import {
CarouselNext,
CarouselPrevious,
} from "@chatbotx.io/ui/components/ui/carousel"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@chatbotx.io/ui/components/ui/tooltip"
import { cn } from "@chatbotx.io/ui/lib/utils"
-import { format } from "date-fns"
-import { ExternalLinkIcon, PaperclipIcon } from "lucide-react"
+import { format, isSameDay } from "date-fns"
+import { ptBR } from "date-fns/locale"
+import { ExternalLinkIcon } from "lucide-react"
import Image from "next/image"
import Link from "next/link"
+import { useTranslations } from "next-intl"
+import { useRef, useState } from "react"
+import { toast } from "sonner"
+import { RespondIcon } from "@/components/respond-icon"
import type { AttachmentResource } from "@/features/attachments/schema/resource"
import { useAttachmentUrl } from "@/features/attachments/utils"
+import { useChatStore } from "@/features/chat/store/chat-store-provider"
+import {
+ getAvatarInitials,
+ getRespondAvatarUrl,
+} from "@/features/contacts/utils"
+import { useUserStore } from "@/features/users/provider/user-store-context"
+import { authClient } from "@/lib/auth/auth-client"
import type { MessageResourceWithRelations } from "../schema/resource"
+import { DateSeparator } from "./date-separator"
import { MessageBubble } from "./message-bubble"
type MessageItemProps = {
message: MessageResourceWithRelations
+ prevMessage?: MessageResourceWithRelations
+ nextMessage?: MessageResourceWithRelations
guestDisplay?: boolean
onPostback?: (button: MessageButtonTemplate) => void
}
+// Ícone do ✓✓ por status real (substitui ternário aninhado).
+// "sent" usa `tick` em tamanho MAIOR (`xl` = 20px) pra compensar visualmente
+// que ✓ sozinho ocupa menos área que ✓✓ (`message-delivered` em `lg` = 18px).
+// Sem círculo ao redor (Pedro 2026-05-26).
+const DELIVERY_ICON_BY_STATUS = {
+ failed: "close-circle",
+ read: "message-delivered",
+ delivered: "message-delivered",
+ sent: "tick",
+} as const
+
+const DELIVERY_ICON_SIZE_BY_STATUS = {
+ failed: "lg",
+ read: "lg",
+ delivered: "lg",
+ sent: "2xl",
+} as const
+
export const MessageItem = (props: MessageItemProps) => {
- const { message, guestDisplay = false } = props
+ const { message, prevMessage, nextMessage, guestDisplay = false } = props
+ const t = useTranslations()
+
+ // Comentário interno: bubble amarela só pra equipe. Pedro pediu pra usar
+ // o padrão Respond.io (substitui Notas separadas).
+ const isInternal = Boolean((message as { isInternal?: boolean }).isInternal)
+ // Pixel-perfect Respond.io 2026-05-25 (FASE A):
+ // - incoming (esquerda): bg #2F2F32, radius 8px, padding 4 (mais compacto)
+ // - outgoing (direita): bg #2C3A58, radius `8 4 4 8` (TL=8 TR=4 BR=4 BL=8)
+ // - internal (comentário): bg amber-900 + border-l-amber-500
+ // - text: 14px / 20px line-height
+ // Cor do bubble interno (comentário) — pixel-perfect Respond.io
+ // 2026-05-25 iteração 20: bg #3F3322 (mesmo do input amber), texto
+ // amber-50, SEM border esquerdo. Antes era amber-950/80 + border
+ // esquerdo amarelo que não bate com o print do Respond.io ao vivo.
const variants: Record<"left" | "right" | "full", string> = {
- left: "px-4 py-3 rounded-xl bg-secondary",
- right: "px-4 py-3 rounded-xl bg-primary text-primary-foreground",
+ left: "px-3 py-1.5 rounded-[8px] bg-bubble-incoming text-foreground",
+ right: isInternal
+ ? "px-3 py-1.5 rounded-tl-[8px] rounded-tr-[4px] rounded-bl-[8px] rounded-br-[4px] bg-[#3F3322] text-amber-50"
+ : "px-3 py-1.5 rounded-tl-[8px] rounded-tr-[4px] rounded-bl-[8px] rounded-br-[4px] bg-bubble-outgoing text-foreground",
full: "text-center w-full text-muted-foreground",
}
@@ -51,44 +110,497 @@ export const MessageItem = (props: MessageItemProps) => {
break
}
+ // Footer com HH:mm + ✓✓ baseado em delivery status real (Pedro 2026-05-26
+ // Sprint WhatsApp 2.1): substitui mock por colunas Message.deliveredAt /
+ // readAt / failedAt populadas pelo event-bus listener message-status-persist.
+ // - failedAt → ⚠️ vermelho (msg falhou)
+ // - readAt → ✓✓ azul (lida pelo contato)
+ // - deliveredAt → ✓✓ cinza (entregue, não lida)
+ // - default → ✓ cinza claro (enviado, sem confirmação delivery ainda)
+ const _showFooter = variant !== "full"
+ const isOutgoing = message.messageType === "outgoing"
+ const deliveryAt = (message as { deliveredAt?: Date | string | null })
+ .deliveredAt
+ const readAt = (message as { readAt?: Date | string | null }).readAt
+ const failedAt = (message as { failedAt?: Date | string | null }).failedAt
+ const failureReason = (message as { failureReason?: string | null })
+ .failureReason
+ let deliveryStatus: "failed" | "read" | "delivered" | "sent" = "sent"
+ if (failedAt) {
+ deliveryStatus = "failed"
+ } else if (readAt) {
+ deliveryStatus = "read"
+ } else if (deliveryAt) {
+ deliveryStatus = "delivered"
+ }
+
+ // Avatar lateral (pixel-perfect Respond.io 2026-05-25 iteração 8):
+ // aparece SÓ na ÚLTIMA mensagem da sequência (msgs consecutivas do
+ // mesmo lado compartilham 1 avatar visual). Confirmado via Chrome MCP
+ // no Respond.io ao vivo — uma msg-item com altura 338 px tem o avatar
+ // em y=494 (33 px do bottom = alinhado com a última bubble). Pedro
+ // pegou: minha versão anterior usava isFirstInSequence (avatar na 1ª)
+ // que fazia o avatar parecer "soltinho" embaixo das outras. Quando não
+ // é a última, deixa um spacer de 32 px pra preservar alinhamento.
+ const isLastInSequence =
+ !nextMessage || nextMessage.messageType !== message.messageType
+
+ // FASE B (2026-05-25): mostra separador "Hoje" / "Ontem" / data quando
+ // a mensagem cai num dia diferente da anterior. Também na PRIMEIRA
+ // mensagem do chat (sem prevMessage).
+ const showDateSeparator = !(
+ prevMessage &&
+ isSameDay(new Date(prevMessage.createdAt), new Date(message.createdAt))
+ )
+
+ // Pedro 2026-05-25 iter 41: avatar consistente em TODOS os lugares.
+ // Bug anterior: `message.contact` vinha sem fullName populated, então
+ // seed caía em "?" → todo avatar de bubble ficava deep-orange (cor do
+ // hash de "?"). Agora puxamos contact do chatStore (sempre tem nome)
+ // e user do userStore.workspaceMembers (sempre tem nome).
+ //
+ // Seed do hash: SEMPRE `id` (snowflake imutável). Antes era
+ // `name || id` → se name mudasse, avatar mudava de cor. Agora trava
+ // no id pra ser determinístico forever.
+ const activeContact = useChatStore(
+ (state) =>
+ state.conversations.find((c) => c.id === message.conversationId)
+ ?.contact ?? null,
+ )
+ const workspaceMembers = useUserStore((state) => state.workspaceMembers)
+ const session = authClient.useSession()
+ const currentSessionUser = session?.data?.user ?? null
+
+ const sideAvatarPayload = (() => {
+ if (variant === "full") {
+ return null
+ }
+ if (variant === "left") {
+ // incoming → contato (resolve do chatStore, NÃO do message.contact)
+ const contact = activeContact ?? message.contact
+ const name = contact?.fullName ?? ""
+ const seed = String(contact?.id ?? name)
+ const avatarSpec = getRespondAvatarUrl(seed)
+ return {
+ image:
+ (contact as { avatar?: string | null } | undefined)?.avatar ?? null,
+ name,
+ initials: getAvatarInitials(name) || "?",
+ color: avatarSpec.color,
+ seedUrl: avatarSpec.url,
+ }
+ }
+ // outgoing → agente. Pedro iter 41: fallback em 3 níveis pra avatar
+ // consistente mesmo quando o backend não popula senderId/user:
+ // 1. senderId (FK no DB) → resolve via workspaceMembers
+ // 2. message.user (relation populada)
+ // 3. session.user (currentUser logado) — pra msgs antigas com
+ // senderId NULL no banco (seed data).
+ const senderId = (message as { senderId?: string | null }).senderId ?? null
+ const memberFromStore = senderId
+ ? workspaceMembers.find((m) => String(m.user.id) === String(senderId))
+ ?.user
+ : null
+ const messageUser = message.user
+ const user = memberFromStore ?? messageUser ?? currentSessionUser
+ const name =
+ user?.name ?? messageUser?.email ?? currentSessionUser?.email ?? ""
+ // Seed estável = senderId (FK imutável) com fallbacks pelo user resolvido.
+ const seed = String(senderId ?? user?.id ?? currentSessionUser?.id ?? name)
+ const avatarSpec = getRespondAvatarUrl(seed)
+ return {
+ image: user?.image ?? null,
+ name,
+ initials: getAvatarInitials(name) || "?",
+ color: avatarSpec.color,
+ seedUrl: avatarSpec.url,
+ }
+ })()
+
+ // Hover actions (Inbox 2.2) — menu "..." em cima da bubble com:
+ // Copy texto | Copy link mensagem | Reply (placeholder)
+ // Aparece só no hover via grupos do Tailwind.
+ const handleCopy = () => {
+ if (message.text) {
+ navigator.clipboard.writeText(message.text)
+ toast.success(t("messages.actions.copied"))
+ }
+ }
+ const handleCopyLink = () => {
+ const url = `${window.location.origin}${window.location.pathname}?conversationId=${message.conversationId}#msg-${message.id}`
+ navigator.clipboard.writeText(url)
+ toast.success(t("messages.actions.linkCopied"))
+ }
+
return (
-
-
- {message.text && message.text.length > 0 && (
-
-
- {message.text}
-
+
+ {showDateSeparator &&
}
+
+ {/* Avatar lateral (32 px) — só na PRIMEIRA da sequência. Quando não
+ é a primeira, mantém um spacer da mesma largura pra preservar
+ alinhamento vertical (igual Respond.io). System messages (full)
+ não têm avatar. */}
+ {/* Avatar wrapper SEM pb-1 (Pedro 2026-05-25 iteração 7):
+ Respond.io alinha avatar no bottom da bubble via items-end no
+ parent, sem padding extra. pb-1 estava criando desalinhamento. */}
+ {variant !== "full" && sideAvatarPayload && (
+
+ {isLastInSequence ? (
+
+
+
+ {sideAvatarPayload.initials}
+
+
+ ) : (
+
+ )}
)}
- {message.attachments && message.attachments.length > 0 && (
-
- )}
- {RenderContentAttributes(props)}
-
-
+ {/* Bubble wrapper SEM mx-2 (Pedro 2026-05-25 iteração 7) +
+ SEM min-h-11 (Pedro 2026-05-25 iteração 9):
+ - min-h-11 (44 px) forçava altura mínima maior que 1 linha de
+ texto (~30 px), criando espaço vazio embaixo do bubble. O
+ avatar `self-end` ia pro fundo desse espaço, ficando ABAIXO
+ da bubble visualmente. Sem min-h, a bubble se ajusta ao
+ conteúdo e o avatar encosta no bottom REAL dela.
+ - mx-2 estava dobrando o gap-2 pra 16 px. */}
+
+ {message.text && message.text.length > 0 && (
+
+ {/* FASE A/B revisão (Pedro 2026-05-25):
+ - Hora REMOVIDA de dentro/embaixo da bubble — agora aparece
+ só em TOOLTIP no hover (com Enviado/Entregue/Lido detalhado).
+ - ✓✓ status entrega vai FORA da bubble, à ESQUERDA dela
+ (apenas pra outgoing). Confirmado via Chrome MCP no
+ Respond.io ao vivo + doc managing-conversations.md.
+ - Cor ✓✓ lida = #6BA4FF (primary azul Respond.io). */}
+
+
+
+ {/* ✓✓ status entrega — só pra outgoing NÃO-INTERNA.
+ Pedro 2026-05-25 iteração 20: comentários internos
+ são mensagens só pra equipe (não vão pro contato),
+ então NÃO mostram delivery status. Igual Respond.io.
+ 2026-05-26 (Sprint WhatsApp 2.1): cor baseada em
+ Message.deliveredAt/readAt/failedAt reais. */}
+ {isOutgoing && variant === "right" && !isInternal && (
+
+ )}
+
+
+
+
+
+
+
+ {variant !== "full" && (
+
+ {/* 3 ÍCONES INLINE (Pedro 2026-05-25 iteração 10):
+ copy, link-1, reply do iconsax — mesmos que Respond.io
+ usa. Substituiu o dropdown ⋯ por 3 botões discretos
+ aparecendo no hover, lado oposto ao avatar. */}
+
+
+
+
+
+
+
+ {t("messages.actions.copy")}
+
+
+
+
+
+
+
+
+
+ {t("messages.actions.copyLink")}
+
+
+
+
+
+
+
+
+
+ {t("messages.actions.reply") ?? "Responder"}
+
+
+
+ )}
+
+ )}
+ {message.attachments && message.attachments.length > 0 && (
+
+ )}
+
+ {RenderContentAttributes(props)}
+
+ {/* Footer COMPLETAMENTE REMOVIDO 2026-05-25 (Pedro feedback):
+ hora não aparece mais visível na bubble — só em Tooltip on
+ hover. ✓✓ já está renderizado FORA da bubble (lado esquerdo
+ da outgoing). Attachments/locations sem texto também usam
+ esse mesmo padrão. */}
+
+
+
+ )
+}
+
+// Player de áudio compacto pixel-perfect Respond.io (Pedro 2026-05-26):
+// botão play/pause circular pequeno + barra de progresso horizontal +
+// duração + download. SEM waveform, SEM speed. Encaixa visualmente
+// dentro da bubble (bg transparente, herda cor do bubble outgoing/incoming).
+function formatAudioTime(seconds: number): string {
+ if (!Number.isFinite(seconds) || seconds < 0) {
+ return "0:00"
+ }
+ const m = Math.floor(seconds / 60)
+ const s = Math.floor(seconds % 60)
+ return `${m}:${String(s).padStart(2, "0")}`
+}
+
+function AudioBubblePlayer({
+ url,
+ mimeType,
+ label,
+ variant = "left",
+}: {
+ url: string
+ mimeType?: string | null
+ label: string
+ variant?: "left" | "right"
+}) {
+ const audioRef = useRef
(null)
+ const [isPlaying, setIsPlaying] = useState(false)
+ const [currentTime, setCurrentTime] = useState(0)
+ const [duration, setDuration] = useState(0)
+
+ const togglePlay = () => {
+ const el = audioRef.current
+ if (!el) {
+ return
+ }
+ if (el.paused) {
+ el.play()
+ } else {
+ el.pause()
+ }
+ }
+
+ const seek = (e: React.ChangeEvent) => {
+ const el = audioRef.current
+ if (!(el && Number.isFinite(duration))) {
+ return
+ }
+ const next = Number.parseFloat(e.target.value)
+ el.currentTime = next
+ setCurrentTime(next)
+ }
+
+ // Bubble wrapper (Pedro 2026-05-26): mesmo bg/radius da bubble de texto
+ // pra player não ficar flutuando sem container. Outgoing usa
+ // bubble-outgoing (#2C3A58), incoming usa bubble-incoming (#2F2F32).
+ // Corners assimétricos seguem o mesmo padrão das bubbles texto.
+ const bubbleClass =
+ variant === "right"
+ ? "rounded-tl-[8px] rounded-tr-[4px] rounded-bl-[8px] rounded-br-[4px] bg-bubble-outgoing"
+ : "rounded-[8px] bg-bubble-incoming"
+
+ return (
+
+
setIsPlaying(false)}
+ onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
+ onPause={() => setIsPlaying(false)}
+ onPlay={() => setIsPlaying(true)}
+ onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime)}
+ preload="metadata"
+ ref={audioRef}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ {formatAudioTime(duration > 0 ? duration - currentTime : 0)}
+
+
+
+
+
+
+ )
+}
+
+// Reaction (emoji) renderiza embaixo da bubble como pill flutuante.
+// Padrão WhatsApp / Respond.io: emoji pequeno num círculo cinza-escuro,
+// negativo positionado sobre o canto inferior da bubble. Aparece pra
+// outgoing (contato reagiu na nossa msg) e incoming (agente reagiu).
+// 2026-05-26 — Sprint WhatsApp reactions.
+const RenderReaction = ({
+ message,
+}: {
+ message: MessageResourceWithRelations
+}) => {
+ const attrs = message.contentAttributes as
+ | { reaction?: { emoji?: string } | null }
+ | null
+ | undefined
+ const emoji = attrs?.reaction?.emoji
+ if (!emoji) {
+ return null
+ }
+ return (
+
+
+ {emoji}
+
+
)
}
const RenderAttachments = (props: {
message: MessageResourceWithRelations
+ variant: "left" | "right" | "full"
}) => {
- const { message } = props
+ const { message, variant } = props
return (
{(message.attachments ?? []).map((attachment) => (
-
+
))}
)
}
-const RenderAttachmentItem = (props: { attachment: AttachmentResource }) => {
- const { attachment } = props
+// Regex top-level (regra biome useTopLevelRegex).
+const FIRST_WORD_CHAR = /^(\w)/
+
+// Formata bytes pra "1.2 MB" / "543 KB" etc. (Sprint Inbox 2.3)
+function formatBytes(bytes: number): string {
+ if (bytes === 0) {
+ return "0 B"
+ }
+ const units = ["B", "KB", "MB", "GB"]
+ const i = Math.min(
+ Math.floor(Math.log(bytes) / Math.log(1024)),
+ units.length - 1,
+ )
+ const value = bytes / 1024 ** i
+ return `${value.toFixed(value >= 100 || i === 0 ? 0 : 1)} ${units[i]}`
+}
+
+const RenderAttachmentItem = (props: {
+ attachment: AttachmentResource
+ variant: "left" | "right" | "full"
+}) => {
+ const { attachment, variant } = props
const attachmentUrl = useAttachmentUrl(attachment)
const attachmentLabel =
attachment.name || attachment.originPath || "Attachment"
@@ -96,14 +608,32 @@ const RenderAttachmentItem = (props: { attachment: AttachmentResource }) => {
if (!attachmentUrl) {
return (
)
}
+ // Detectar sticker pelo mimeType WhatsApp ou extensão (.webp animado).
+ // Sticker renderiza grande sem fundo (Inbox 2.3).
+ const isSticker =
+ attachment.mimeType === "image/webp" &&
+ (attachment.name?.toLowerCase().includes("sticker") ||
+ (attachment.width === 512 && attachment.height === 512))
+
switch (attachment.fileType) {
case "image":
+ if (isSticker) {
+ return (
+
+ )
+ }
return (
{
)
case "audio":
return (
-
-
-
-
+
)
default:
+ // Documento card rico (Sprint Inbox 2.3): ícone + nome + tamanho + download.
return (
-
-
-
- {attachmentUrl}
-
-
+
+
+
+
+
+
{attachmentLabel}
+
+ {attachment.mimeType && (
+
+ {attachment.mimeType.split("/")[1]?.slice(0, 6)}
+
+ )}
+ {attachment.size > 0 && (
+ {formatBytes(attachment.size)}
+ )}
+
+
+
+
)
}
}
+// Renderiza tipo "location" — card com mini-mapa estático (OpenStreetMap)
+// + link pro Google Maps. Lat/lng vêm em message.contentAttributes.
+// Sprint Inbox 2.3.
+const RenderLocation = ({
+ message,
+}: {
+ message: MessageResourceWithRelations
+}) => {
+ const attrs = message.contentAttributes as
+ | {
+ type?: string
+ latitude?: number
+ longitude?: number
+ name?: string
+ address?: string
+ }
+ | undefined
+ if (
+ !attrs ||
+ attrs.type !== "location" ||
+ typeof attrs.latitude !== "number" ||
+ typeof attrs.longitude !== "number"
+ ) {
+ return null
+ }
+ const { latitude, longitude, name, address } = attrs
+ const bbox = `${longitude - 0.01},${latitude - 0.01},${longitude + 0.01},${latitude + 0.01}`
+ const mapUrl = `https://www.openstreetmap.org/export/embed.html?bbox=${bbox}&layer=mapnik&marker=${latitude},${longitude}`
+ const gmapsUrl = `https://www.google.com/maps?q=${latitude},${longitude}`
+ return (
+
+
+
+
+
+ {name || "Localização"}
+
+ {address && (
+
+ {address}
+
+ )}
+
+
+ )
+}
+
const RenderContentAttributes = (props: MessageItemProps) => {
const { message, onPostback } = props
const contentAttributes = message.contentAttributes as
@@ -152,14 +762,24 @@ const RenderContentAttributes = (props: MessageItemProps) => {
switch (contentAttributes.type) {
case "template":
return (
-
+ // FASE D (2026-05-25) — quick replies pixel-perfect Respond.io:
+ // pílulas inline 24 px, bg white/8%, text muted-foreground, 12 px /
+ // 400, padding 4 8, radius 4 px. flex-wrap pra quebrar linha
+ // quando tem muitos botões. Substitui o min-w-60 outline antigo.
+
{contentAttributes.payload.templateType === "button" &&
contentAttributes.payload.buttons.map((button) => {
if (button.buttonType === "url") {
return (
-
+
-
+
{button.label}
@@ -167,14 +787,14 @@ const RenderContentAttributes = (props: MessageItemProps) => {
}
return (
{
onPostback?.(button)
}}
size="sm"
- variant="outline"
+ variant="ghost"
>
{button.label}
@@ -239,3 +859,145 @@ const RenderContentAttributes = (props: MessageItemProps) => {
return null
}
}
+
+// Conteúdo do Tooltip de hover — Pedro 2026-05-25 iteração 10:
+// pixel-perfect Respond.io (BRANCO, texto preto, 2 colunas alinhadas).
+// Label "Enviado:" / "Entregue:" / "Lido:" coluna esquerda, valor
+// "Hoje, HH:mm:ss" / "Ontem, HH:mm:ss" / "Segunda, HH:mm:ss" direita.
+// Bg do TooltipContent já vem branco via className no caller.
+// 2026-05-26 (Sprint WhatsApp 2.1): timestamps reais via Message.deliveredAt/
+// readAt/failedAt (populados pelo event-bus listener message-status-persist).
+// Quando ainda não chegou status do canal, só renderiza "Enviado".
+function DeliveryTooltipContent({
+ createdAt,
+ deliveredAt,
+ readAt,
+ failedAt,
+ failureReason,
+ isOutgoing,
+ isInternal = false,
+}: {
+ createdAt: Date | string
+ deliveredAt?: Date | string | null
+ readAt?: Date | string | null
+ failedAt?: Date | string | null
+ failureReason?: string | null
+ isOutgoing: boolean
+ isInternal?: boolean
+}) {
+ const t = useTranslations()
+ const base = new Date(createdAt)
+
+ // Formato Respond.io: "Hoje, 5:32:22 PM" / "Ontem, ..." / "Segunda, ..."
+ const fmtRelative = (d: Date) => {
+ const now = new Date()
+ const sameDay =
+ d.getDate() === now.getDate() &&
+ d.getMonth() === now.getMonth() &&
+ d.getFullYear() === now.getFullYear()
+ const yesterday = new Date(now)
+ yesterday.setDate(now.getDate() - 1)
+ const wasYesterday =
+ d.getDate() === yesterday.getDate() &&
+ d.getMonth() === yesterday.getMonth() &&
+ d.getFullYear() === yesterday.getFullYear()
+ const timePart = format(d, "h:mm:ss a", { locale: ptBR })
+ if (sameDay) {
+ return `Hoje, ${timePart}`
+ }
+ if (wasYesterday) {
+ return `Ontem, ${timePart}`
+ }
+ const dayName = format(d, "EEEE", { locale: ptBR }).replace(
+ FIRST_WORD_CHAR,
+ (s) => s.toUpperCase(),
+ )
+ return `${dayName}, ${timePart}`
+ }
+
+ const Row = ({ label, value }: { label: string; value: string }) => (
+
+ {label}:
+ {value}
+
+ )
+
+ // Comentários internos: só "Enviado" (não vão pro contato, então
+ // sem entregue/lido). Pedro 2026-05-25 iteração 20.
+ if (isInternal) {
+ return (
+
+
+
+ )
+ }
+
+ // Comentários internos: só "Enviado" (não vão pro contato, então
+ // sem entregue/lido). Pedro 2026-05-25 iteração 20.
+ if (isInternal) {
+ return (
+
+
+
+ )
+ }
+
+ if (!isOutgoing) {
+ return (
+
+
+
+ )
+ }
+
+ // Falha — substitui linha de entrega por motivo do erro.
+ if (failedAt) {
+ return (
+
+
|
+
|
+ {failureReason && (
+
+ {failureReason}
+
+ )}
+
+ )
+ }
+
+ return (
+
+
+ {deliveredAt && (
+
+ )}
+ {readAt && (
+
+ )}
+
+ )
+}
diff --git a/apps/builder/src/features/messages/message-head.tsx b/apps/builder/src/features/messages/message-head.tsx
index 03d89808f..21a556fb8 100644
--- a/apps/builder/src/features/messages/message-head.tsx
+++ b/apps/builder/src/features/messages/message-head.tsx
@@ -1,88 +1,183 @@
"use client"
+import type { ClosingNotesMode } from "@chatbotx.io/database/partials"
+import type { LifecycleStageModel } from "@chatbotx.io/database/types"
+import {
+ Avatar,
+ AvatarFallback,
+ AvatarImage,
+} from "@chatbotx.io/ui/components/ui/avatar"
import { Button } from "@chatbotx.io/ui/components/ui/button"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@chatbotx.io/ui/components/ui/tooltip"
-import { BotIcon } from "lucide-react"
import { useTranslations } from "next-intl"
-import { useAction } from "next-safe-action/hooks"
-import { toast } from "sonner"
-import { useWorkspaceId } from "@/hooks/routing"
+import { useState } from "react"
+import { RespondIcon } from "@/components/respond-icon"
+import type { ClosingNoteCategoryOption } from "@/features/closing-notes/queries/get-config"
import { useChatStore } from "../chat/store/chat-store-provider"
-import { enableBotAction } from "../conversations/actions/enable-bot.action"
+import {
+ getAvatarInitials,
+ getRespondAvatarUrl,
+ useAvatarUrl,
+} from "../contacts/utils"
+import { CloseConversationButton } from "../conversations/components/close-conversation-button"
+import { ConversationSearchBar } from "../conversations/components/conversation-search-bar"
import { UpdateConversationAssignee } from "../conversations/components/update-conversation-assignee"
import { ConversationAction } from "../conversations/conversation-action"
-import { isConversationActive } from "../conversations/utils/bot-state"
+import { ShortcutMenu } from "../flows/shortcut-menu"
+import { LifecycleBadgeSelect } from "../lifecycle-stages/lifecycle-badge-select"
+import { LifecycleNextStageButton } from "../lifecycle-stages/lifecycle-next-stage-button"
-export default function MessageHead() {
+// Pixel-perfect Respond.io 2026-05-24 — ordem do header da conversa:
+// [esquerda: Nome + Lifecycle Badge + chevron →]
+// [direita: Atribuição "Fernanda ↓" Lupa Atalhos⚡ FECHAR(verde) ...]
+// Atribuição saiu de SUB-LINHA do nome (era "Atribuir conversa ↓") e virou
+// botão inline no topo direito, exatamente como Respond.io.
+export default function MessageHead({
+ lifecycleStages = [],
+ closingNotesMode = "disabled",
+ closingNoteCategories = [],
+}: {
+ lifecycleStages?: LifecycleStageModel[]
+ closingNotesMode?: ClosingNotesMode
+ closingNoteCategories?: ClosingNoteCategoryOption[]
+}) {
const t = useTranslations()
- const workspaceId = useWorkspaceId()
- const {
- conversations,
- activeConversationId,
- setAssignee,
- updateConversation,
- } = useChatStore((state) => state)
+ const { conversations, activeConversationId, setAssignee } = useChatStore(
+ (state) => state,
+ )
const activeConversation = conversations.find(
(c) => c.id === activeConversationId,
)
- const { execute: enableBot, isExecuting: isEnablingBot } = useAction(
- enableBotAction.bind(null, workspaceId),
- {
- onSuccess: () => {
- if (activeConversation) {
- updateConversation(activeConversation.id, {
- botEnabled: true,
- botResumeAt: null,
- })
- }
- },
- onError: ({ error }) => {
- if (error.serverError) {
- toast.error(error.serverError)
- }
- },
- },
- )
+ const [searchOpen, setSearchOpen] = useState(false)
+
+ // IMPORTANTE: hook chamado ANTES do early return pra não violar React rules.
+ const avatarUrl = useAvatarUrl(activeConversation?.contact)
+
+ if (!activeConversation) {
+ return null
+ }
+
+ const contact = activeConversation.contact as
+ | {
+ id?: string
+ lifecycleStageId?: string | null
+ fullName?: string | null
+ avatar?: string | null
+ }
+ | null
+ | undefined
+ const contactLifecycleId = contact?.lifecycleStageId ?? null
+ const contactId = contact?.id ?? null
+ const fullName =
+ contact?.fullName ?? activeConversation.contact?.fullName ?? ""
+
+ // Avatar pequeno do contato no topbar.
+ // Iter 41: seed = ID do contato (consistente com conv-list + drawer + bubble).
+ const respondAvatar = getRespondAvatarUrl(contactId ?? fullName)
+ const initials = getAvatarInitials(fullName)
return (
- activeConversation && (
-
-
-
- {activeConversation?.contact?.fullName}
+
+ {/* Header pixel-perfect Respond.io 2026-05-25:
+ - bg #222225 · padding y 6px x 12px · height 45px
+ - border-bottom rgba(182,188,195,0.16)
+ - layout: [avatar 32 + nome 16/400 + LIFECYCLE-GROUP (esquerda flex-1)] [botões direita gap-1] */}
+
+ {/* ESQUERDA: Avatar 32 + Nome 16px/normal + grupo lifecycle (badge+chevron colados) */}
+
+
+
+
+ {initials}
+
+
+ {/* Nome do contato: weight 700 (bold) + 14px — re-medido Respond.io
+ iter 39 via Chrome MCP. Antes era 600/16px. Confirmado:
+ `Juracy Rodrigues Barbosa` size=14px weight=700 color=#CFD3D8. */}
+
+ {fullName}
+ {contactId && (
+ // Grupo "pílula" — badge + chevron colados (gap-0) com BORDER
+ // único em volta dos dois e radius no wrapper (children sem
+ // radius próprio). Pixel-perfect Respond.io 2026-05-25 (Pedro
+ // pediu contorno + altura uniforme h-7).
+
+
+
+
+ )}
+
+
+ {/* DIREITA: Atribuição + Lupa + Raio + Workflows + (Telefone) + FECHAR + Menu */}
+
-
- {!isConversationActive(activeConversation) && (
+
+ {/* Botão "Transferir Conversa para o Bot" REMOVIDO 2026-05-25
+ (Pedro pediu — Bot continua funcionando mas sem UI no header). */}
+
+ {/* Lupa "buscar nesta conversa" */}
{
- enableBot({ ids: [activeConversation.id] })
- }}
- variant="ghost"
+ aria-label={t("conversations.searchInConversation")}
+ className="size-7 p-1 text-text-secondary hover:bg-white/[0.06] hover:text-foreground"
+ onClick={() => setSearchOpen((open) => !open)}
+ size="icon"
+ variant={searchOpen ? "secondary" : "ghost"}
>
-
+
- {t("actions.transferConversationToBot")}
+ {t("conversations.searchInConversation")}
- )}
-
+
+ {/* Atalhos / Shortcut workflows (raio ⚡) — sempre visível
+ (Pedro pediu 2026-05-25). Lista fluxos com TriggerNode
+ "shortcut"; quando vazio mostra mensagem indicando como criar. */}
+
+
+ {/* Botão "Workflows em andamento" (ramificação) REMOVIDO 2026-05-25
+ — era placeholder fake sem funcionalidade. Pedro pediu "nenhuma
+ ponta solta". */}
+
+ {/* FECHAR/ABRIR (discreto, outline cinza) */}
+
+
+ {/* Menu ... (Follow, Não-lido, Bloquear, Excluir) */}
+
+
- )
+ {searchOpen && (
+
+ )}
+
)
}
diff --git a/apps/builder/src/features/messages/message-list.tsx b/apps/builder/src/features/messages/message-list.tsx
index 18546e5ef..de8e37abc 100644
--- a/apps/builder/src/features/messages/message-list.tsx
+++ b/apps/builder/src/features/messages/message-list.tsx
@@ -40,9 +40,15 @@ export function MessageList() {
}
return (
+ // FASE A/B revisão 2026-05-25 (Pedro feedback):
+ // - alignToBottom REMOVIDO → mensagens começam DO TOPO do canvas
+ // (antes ficavam grudadas no rodapé deixando espaço em branco
+ // acima quando havia poucas msgs).
+ // - followOutput mantido pra rolar pro fim quando chega msg nova.
+ // - initialTopMostItemIndex LAST → ao ENTRAR na conversa, scroll já
+ // posiciona na última mensagem (igual WhatsApp/Respond.io).
(
-
+ itemContent={(index, message) => (
+ 0 ? messages[index - 1] : undefined}
+ />
)}
rangeChanged={({ startIndex }) => {
if (startIndex <= 5 && page !== 1) {
@@ -73,13 +86,19 @@ const MessageComponentHeader: GridComponents["Header"] = () => {
) : null
}
+// Padding lateral horizontal pra que avatar+bubble não fiquem colados
+// nas bordas do canvas. Pedro pegou 2026-05-25 iteração 7: tinha px-4
+// (16px) mas Respond.io ao vivo usa exatamente 12px (`dls-px-3` no
+// container .dls-flex-row.dls-items-end da linha de mensagem). Confirmado
+// via Chrome MCP getComputedStyle: padding "0px 12px 6px 12px".
+// gap-1.5 entre mensagens.
const MessageComponentList: GridComponents["List"] = ({
children,
...props
}) => (
{children}
diff --git a/apps/builder/src/features/messages/queries/index.ts b/apps/builder/src/features/messages/queries/index.ts
index 3efbd6870..09dd24e13 100644
--- a/apps/builder/src/features/messages/queries/index.ts
+++ b/apps/builder/src/features/messages/queries/index.ts
@@ -91,6 +91,47 @@ export const listMessages = async (
return { data: items, nextCursor, prevCursor }
}
+/**
+ * Busca mensagens dentro de uma conversa por keyword. Usado pela "lupa"
+ * do header da conversa (Sprint Inbox 1.2). Filtra por content ILIKE
+ * `%keyword%` + conversationId + workspaceId. Limita a 100 resultados pra
+ * não sobrecarregar. Ordena por createdAt desc (mais recentes primeiro).
+ */
+export const searchMessagesInConversation = async (input: {
+ workspaceId: string
+ conversationId: string
+ keyword: string
+}): Promise => {
+ const keyword = input.keyword.trim()
+ if (!keyword) {
+ return []
+ }
+
+ const messages = await db.query.messageModel.findMany({
+ where: {
+ workspaceId: input.workspaceId,
+ conversationId: input.conversationId,
+ text: { ilike: `%${keyword}%` },
+ },
+ with: {
+ attachments: true,
+ },
+ orderBy: {
+ createdAt: "desc",
+ id: "desc",
+ },
+ limit: 100,
+ })
+
+ return messages.map((message) => ({
+ ...message,
+ attachments: message.attachments.map((attachment) => ({
+ ...attachment,
+ url: getPublicUrl(attachment.originPath),
+ })),
+ }))
+}
+
export const findMessage = async (
input: FindMessageRequest,
): Promise => {
diff --git a/apps/builder/src/features/messages/schema/mutation.ts b/apps/builder/src/features/messages/schema/mutation.ts
index 3449438cf..2cc0867fa 100644
--- a/apps/builder/src/features/messages/schema/mutation.ts
+++ b/apps/builder/src/features/messages/schema/mutation.ts
@@ -2,7 +2,9 @@ import { channelTypes } from "@chatbotx.io/database/partials"
import { zodBigintAsString } from "@chatbotx.io/utils"
import { z } from "zod"
-const MAX_FILE_SIZE = 5 * 1000 * 1000
+// Pixel-perfect Respond.io 2026-05-24: 50 arquivos por mensagem, 20MB cada.
+const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20 MB
+const MAX_FILES_PER_MESSAGE = 50
export const createMessageRequest = z
.union([
@@ -13,10 +15,11 @@ export const createMessageRequest = z
files: z
.array(
z.instanceof(File).refine((file) => file.size <= MAX_FILE_SIZE, {
- message: "Max image size is 5MB.",
+ message: "Tamanho máximo por arquivo: 20MB.",
}),
)
- .min(1),
+ .min(1)
+ .max(MAX_FILES_PER_MESSAGE),
}),
// z.object({
// fileUrl: z.url(),
@@ -33,6 +36,9 @@ export const createMessageRequest = z
"ID of the channel to send the message on. null to send message on the last interacted channel (if any).",
}),
clientId: zodBigintAsString().optional(),
+ // Quando true, mensagem é comentário interno (não vai pro contato).
+ // 2026-05-24 — Sprint Inbox 4 (Respond.io padrão).
+ isInternal: z.boolean().optional(),
}),
)
export type CreateMessageRequest = z.infer
diff --git a/apps/builder/src/features/notifications/actions/update-notifications-action.ts b/apps/builder/src/features/notifications/actions/update-notifications-action.ts
new file mode 100644
index 000000000..bf68bfec4
--- /dev/null
+++ b/apps/builder/src/features/notifications/actions/update-notifications-action.ts
@@ -0,0 +1,32 @@
+"use server"
+
+import { db, eq, findOrFail } from "@chatbotx.io/database/client"
+import { workspaceMemberModel } from "@chatbotx.io/database/schema"
+import { revalidatePath } from "next/cache"
+import { workspaceIdrequestParams } from "@/features/common/schemas"
+import { workspaceActionClient } from "@/lib/safe-action"
+import { updateNotificationsSchema } from "../schema/update-notifications-schema"
+
+export const updateMyNotificationsAction = workspaceActionClient
+ .bindArgsSchemas(workspaceIdrequestParams)
+ .inputSchema(updateNotificationsSchema)
+ .action(async ({ parsedInput, ctx }) => {
+ const workspaceMember = await findOrFail({
+ table: workspaceMemberModel,
+ where: {
+ workspaceId: ctx.workspaceId,
+ userId: ctx.user.id,
+ },
+ message: "Membro do workspace não encontrado",
+ })
+
+ await db
+ .update(workspaceMemberModel)
+ .set({
+ notificationTypes: parsedInput.notificationTypes,
+ notificationChannels: parsedInput.notificationChannels,
+ })
+ .where(eq(workspaceMemberModel.id, workspaceMember.id))
+
+ revalidatePath("/space", "layout")
+ })
diff --git a/apps/builder/src/features/notifications/queries/index.ts b/apps/builder/src/features/notifications/queries/index.ts
new file mode 100644
index 000000000..c88d7a4c9
--- /dev/null
+++ b/apps/builder/src/features/notifications/queries/index.ts
@@ -0,0 +1,16 @@
+"use server"
+
+import { db } from "@chatbotx.io/database/client"
+
+// biome-ignore lint/suspicious/useAwait: "use server" requires async functions (Next.js)
+export async function getMyWorkspaceMember(
+ workspaceId: string,
+ userId: string,
+) {
+ return db.query.workspaceMemberModel.findFirst({
+ where: {
+ workspaceId,
+ userId,
+ },
+ })
+}
diff --git a/apps/builder/src/features/notifications/schema/update-notifications-schema.ts b/apps/builder/src/features/notifications/schema/update-notifications-schema.ts
new file mode 100644
index 000000000..28b46fe50
--- /dev/null
+++ b/apps/builder/src/features/notifications/schema/update-notifications-schema.ts
@@ -0,0 +1,19 @@
+import { z } from "zod"
+
+export const updateNotificationsSchema = z.object({
+ notificationTypes: z.object({
+ notifyAdmin: z.boolean(),
+ newMessageToHuman: z.boolean(),
+ newOrder: z.boolean(),
+ }),
+ notificationChannels: z.object({
+ messenger: z.boolean(),
+ email: z.boolean(),
+ telegram: z.boolean(),
+ browser: z.boolean(),
+ }),
+})
+
+export type UpdateNotificationsRequest = z.infer<
+ typeof updateNotificationsSchema
+>
diff --git a/apps/builder/src/features/notifications/update-notifications-form.tsx b/apps/builder/src/features/notifications/update-notifications-form.tsx
new file mode 100644
index 000000000..1b1f238fe
--- /dev/null
+++ b/apps/builder/src/features/notifications/update-notifications-form.tsx
@@ -0,0 +1,157 @@
+"use client"
+
+import { SwitchField } from "@chatbotx.io/ui/components/form/switch-field"
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import { Card, CardContent } from "@chatbotx.io/ui/components/ui/card"
+import { Form } from "@chatbotx.io/ui/components/ui/form"
+import { Label } from "@chatbotx.io/ui/components/ui/label"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useHookFormAction } from "@next-safe-action/adapter-react-hook-form/hooks"
+import { Loader2Icon } from "lucide-react"
+import { useTranslations } from "next-intl"
+import { toast } from "sonner"
+import { updateMyNotificationsAction } from "./actions/update-notifications-action"
+import { updateNotificationsSchema } from "./schema/update-notifications-schema"
+
+type NotificationTypes = {
+ notifyAdmin?: boolean
+ newMessageToHuman?: boolean
+ newOrder?: boolean
+}
+
+type NotificationChannels = {
+ messenger?: boolean
+ email?: boolean
+ telegram?: boolean
+ browser?: boolean
+}
+
+export function UpdateNotificationsForm({
+ workspaceId,
+ notificationTypes,
+ notificationChannels,
+}: {
+ workspaceId: string
+ notificationTypes: NotificationTypes
+ notificationChannels: NotificationChannels
+}) {
+ const t = useTranslations()
+
+ const { form, handleSubmitWithAction } = useHookFormAction(
+ updateMyNotificationsAction.bind(null, workspaceId),
+ zodResolver(updateNotificationsSchema),
+ {
+ actionProps: {
+ onSuccess: () => {
+ toast.success(
+ t("messages.updatedSuccess", {
+ feature: t("fields.notificationType.label"),
+ }),
+ )
+ },
+ onError: ({ error }) => {
+ if (error.serverError) {
+ toast.error(error.serverError)
+ }
+ },
+ },
+ formProps: {
+ mode: "onChange",
+ defaultValues: {
+ notificationTypes: {
+ notifyAdmin: notificationTypes?.notifyAdmin ?? false,
+ newMessageToHuman: notificationTypes?.newMessageToHuman ?? false,
+ newOrder: notificationTypes?.newOrder ?? false,
+ },
+ notificationChannels: {
+ messenger: notificationChannels?.messenger ?? false,
+ email: notificationChannels?.email ?? false,
+ telegram: notificationChannels?.telegram ?? false,
+ browser: notificationChannels?.browser ?? false,
+ },
+ },
+ },
+ errorMapProps: {},
+ },
+ )
+
+ return (
+
+
+
+
+
+
{t("fields.notificationType.label")}
+
+
+
+
+
+
+
+
+
{t("fields.notificationChannel.label")}
+
+
+
+
+
+
+
+
+
+
+ {form.formState.isSubmitting && (
+
+ )}
+ {t("actions.confirm")}
+
+
+
+
+
+
+ )
+}
diff --git a/apps/builder/src/features/organization-members/services/index.ts b/apps/builder/src/features/organization-members/services/index.ts
index 68aa200d0..9127d3ef6 100644
--- a/apps/builder/src/features/organization-members/services/index.ts
+++ b/apps/builder/src/features/organization-members/services/index.ts
@@ -27,6 +27,13 @@ export class OrganizationMemberService extends BaseService {
},
)
}
+
+ invalidateForOrganization(organizationId: string) {
+ return this.invalidateCacheTags([
+ `organizations:${organizationId}`,
+ `organizations:${organizationId}:members`,
+ ])
+ }
}
export const organizationMemberService = new OrganizationMemberService()
diff --git a/apps/builder/src/features/organization-settings/giphy/update-giphy-settings.action.ts b/apps/builder/src/features/organization-settings/giphy/update-giphy-settings.action.ts
index 85be86059..a1f39ccfd 100644
--- a/apps/builder/src/features/organization-settings/giphy/update-giphy-settings.action.ts
+++ b/apps/builder/src/features/organization-settings/giphy/update-giphy-settings.action.ts
@@ -23,7 +23,7 @@ const isValidGiphyApiKey = async (apiKey: string) => {
})
return true
} catch (error) {
- logger.error(error, "Invalid GIPHY API key")
+ logger.error(error, "API Key do GIPHY inválida")
return false
}
}
diff --git a/apps/builder/src/features/organization-settings/google/google-settings.tsx b/apps/builder/src/features/organization-settings/google/google-settings.tsx
index 2801b6cde..fca9750d8 100644
--- a/apps/builder/src/features/organization-settings/google/google-settings.tsx
+++ b/apps/builder/src/features/organization-settings/google/google-settings.tsx
@@ -52,10 +52,10 @@ export function GoogleSettings({
const handleCopy = (text: string) => () => {
copy(text)
.then(() => {
- toast.success("Copied to clipboard")
+ toast.success("Copiado para a área de transferência")
})
.catch((error) => {
- console.error("Failed to copy!", error)
+ console.error("Falha ao copiar!", error)
})
}
diff --git a/apps/builder/src/features/organization-settings/instagram/instagram-settings.tsx b/apps/builder/src/features/organization-settings/instagram/instagram-settings.tsx
index 84ef0b46d..cd213886e 100644
--- a/apps/builder/src/features/organization-settings/instagram/instagram-settings.tsx
+++ b/apps/builder/src/features/organization-settings/instagram/instagram-settings.tsx
@@ -59,10 +59,10 @@ export function InstagramSettings({
const handleCopy = (text: string) => () => {
copy(text)
.then(() => {
- toast.success("Copied to clipboard")
+ toast.success("Copiado para a área de transferência")
})
.catch((error) => {
- console.error("Failed to copy!", error)
+ console.error("Falha ao copiar!", error)
})
}
diff --git a/apps/builder/src/features/organization-settings/settings/organization-settings.tsx b/apps/builder/src/features/organization-settings/settings/organization-settings.tsx
index c0a31aa62..07c45bd8e 100644
--- a/apps/builder/src/features/organization-settings/settings/organization-settings.tsx
+++ b/apps/builder/src/features/organization-settings/settings/organization-settings.tsx
@@ -3,7 +3,6 @@
import type { OrganizationModel } from "@chatbotx.io/database/types"
import { fileTypes } from "@chatbotx.io/sdk"
import { InputField } from "@chatbotx.io/ui/components/form/input-field"
-import { SelectField } from "@chatbotx.io/ui/components/form/select-field"
import { Button } from "@chatbotx.io/ui/components/ui/button"
import {
Card,
@@ -19,16 +18,10 @@ import { Loader2Icon } from "lucide-react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
-import { CodeEditorField } from "@/components/code-editor-field"
import { DirectUploadOrInsertLink } from "@/components/direct-upload"
-import { themeOptions, updateOrganizationSchema } from "./schema"
+import { updateOrganizationSchema } from "./schema"
import { updateOrganizationAction } from "./update-organization.action"
-const THEME_SELECT_OPTIONS = themeOptions.map((theme) => ({
- value: theme,
- label: theme,
-}))
-
type OrganizationSettingsProps = {
organization: OrganizationModel
}
@@ -65,13 +58,6 @@ export function OrganizationSettings({
url: organization.logo ?? "",
mode: "file" as const,
},
- theme:
- themeOptions.find(
- (opt) =>
- opt.toLowerCase() === (organization.theme ?? "").toLowerCase(),
- ) ?? null,
- customJS: organization.customJS ?? "",
- customCSS: organization.customCSS ?? "",
},
},
},
@@ -108,26 +94,6 @@ export function OrganizationSettings({
-
-
-
-
-
-
diff --git a/apps/builder/src/features/organization-settings/settings/update-organization.action.ts b/apps/builder/src/features/organization-settings/settings/update-organization.action.ts
index f697015b6..eea62338b 100644
--- a/apps/builder/src/features/organization-settings/settings/update-organization.action.ts
+++ b/apps/builder/src/features/organization-settings/settings/update-organization.action.ts
@@ -1,7 +1,12 @@
"use server"
-import { organizationService } from "@chatbotx.io/business"
-import type { OrganizationModel } from "@chatbotx.io/database/types"
+import {
+ auditLogActions,
+ logAudit,
+ organizationService,
+} from "@chatbotx.io/business"
+import { db } from "@chatbotx.io/database/client"
+import type { OrganizationModel, UserModel } from "@chatbotx.io/database/types"
import { orgAdminActionClient } from "@/lib/safe-action"
import {
type UpdateOrganizationSchema,
@@ -15,13 +20,22 @@ export const updateOrganizationAction = orgAdminActionClient
ctx,
parsedInput,
}: {
- ctx: { organization: OrganizationModel }
+ ctx: { organization: OrganizationModel; user: UserModel }
parsedInput: UpdateOrganizationSchema
}) => {
const {
logo: { url },
...rest
} = parsedInput
+
+ const changes: string[] = []
+ if (rest.name !== ctx.organization.name) {
+ changes.push(`name: "${ctx.organization.name}" → "${rest.name}"`)
+ }
+ if (url !== (ctx.organization.logo ?? "")) {
+ changes.push("logo updated")
+ }
+
await organizationService.update({
id: ctx.organization.id,
data: {
@@ -29,5 +43,22 @@ export const updateOrganizationAction = orgAdminActionClient
logo: url,
},
})
+
+ if (changes.length > 0) {
+ const fallbackWorkspaceId = (
+ await db.query.workspaceModel.findFirst({
+ where: { organizationId: ctx.organization.id },
+ columns: { id: true },
+ })
+ )?.id
+ if (fallbackWorkspaceId) {
+ await logAudit({
+ workspaceId: fallbackWorkspaceId,
+ userId: ctx.user.id,
+ action: auditLogActions.WORKSPACE_UPDATED,
+ detail: `Organização atualizada: ${changes.join(", ")}`,
+ })
+ }
+ }
},
)
diff --git a/apps/builder/src/features/organization-settings/stripe/stripe-settings.tsx b/apps/builder/src/features/organization-settings/stripe/stripe-settings.tsx
index d0f89a43c..d39fe09f6 100644
--- a/apps/builder/src/features/organization-settings/stripe/stripe-settings.tsx
+++ b/apps/builder/src/features/organization-settings/stripe/stripe-settings.tsx
@@ -51,10 +51,10 @@ export function StripeSettings({
const handleCopy = (text: string) => () => {
copy(text)
.then(() => {
- toast.success("Copied to clipboard")
+ toast.success("Copiado para a área de transferência")
})
.catch((error) => {
- console.error("Failed to copy!", error)
+ console.error("Falha ao copiar!", error)
})
}
diff --git a/apps/builder/src/features/reflinks/actions/create-reflink.action.ts b/apps/builder/src/features/reflinks/actions/create-reflink.action.ts
index f705f7b1b..cc956366e 100644
--- a/apps/builder/src/features/reflinks/actions/create-reflink.action.ts
+++ b/apps/builder/src/features/reflinks/actions/create-reflink.action.ts
@@ -37,7 +37,7 @@ export const createReflinkAction = workspaceActionClient
} catch (error) {
if (isUniqueViolationError(error)) {
return returnValidationErrors(createReflinkRequest, {
- _errors: ["Validation Exception"],
+ _errors: ["Exceção de Validação"],
name: { _errors: ["Name is already taken"] },
})
}
diff --git a/apps/builder/src/features/reflinks/actions/update-reflink.action.ts b/apps/builder/src/features/reflinks/actions/update-reflink.action.ts
index 77f2228c0..921d712c7 100644
--- a/apps/builder/src/features/reflinks/actions/update-reflink.action.ts
+++ b/apps/builder/src/features/reflinks/actions/update-reflink.action.ts
@@ -60,7 +60,7 @@ export const updateReflink = async (
} catch (error) {
if (isUniqueViolationError(error)) {
return returnValidationErrors(updateReflinkRequest, {
- _errors: ["Validation Exception"],
+ _errors: ["Exceção de Validação"],
name: { _errors: ["Name is already taken"] },
})
}
diff --git a/apps/builder/src/features/reflinks/delete-reflinks.tsx b/apps/builder/src/features/reflinks/delete-reflinks.tsx
index c4d2085ed..9622f9003 100644
--- a/apps/builder/src/features/reflinks/delete-reflinks.tsx
+++ b/apps/builder/src/features/reflinks/delete-reflinks.tsx
@@ -96,7 +96,7 @@ export function DeleteReflinksDialog({
execute({ ids: reflinks.map((f) => f.id) })}
size="sm"
diff --git a/apps/builder/src/features/reflinks/reflinks-table.tsx b/apps/builder/src/features/reflinks/reflinks-table.tsx
index 60f008220..7fd142e11 100644
--- a/apps/builder/src/features/reflinks/reflinks-table.tsx
+++ b/apps/builder/src/features/reflinks/reflinks-table.tsx
@@ -4,12 +4,6 @@ import { DataTable } from "@chatbotx.io/ui/components/data-table/data-table"
import { DataTableColumnHeader } from "@chatbotx.io/ui/components/data-table/data-table-column-header"
import { DataTableToolbar } from "@chatbotx.io/ui/components/data-table/data-table-toolbar"
import { Button } from "@chatbotx.io/ui/components/ui/button"
-import {
- Card,
- CardContent,
- CardHeader,
- CardTitle,
-} from "@chatbotx.io/ui/components/ui/card"
import { Checkbox } from "@chatbotx.io/ui/components/ui/checkbox"
import {
DropdownMenu,
@@ -59,7 +53,7 @@ export function ReflinksTable({ workspaceId, promises }: ReflinksTableProps) {
id: "select",
header: ({ table: tableData }) => (
(
row.toggleSelected(Boolean(value))}
@@ -145,7 +139,7 @@ export function ReflinksTable({ workspaceId, promises }: ReflinksTableProps) {
- Open menu
+ Abrir menu
@@ -193,50 +187,44 @@ export function ReflinksTable({ workspaceId, promises }: ReflinksTableProps) {
clearOnDefault: true,
})
+ // Sem Card wrapper — Pedro pediu UI flat estilo Respond.io (2026-05-24).
return (
-
-
-
- {t("reflinks.title")}
-
-
-
-
-
-
-
-
+ <>
+
+
+
+
+
- setRowAction(null)}
- open={rowAction?.variant === "copyUrl"}
- refConfig={{
- type: "reflink",
- name: rowAction?.row.original?.name ?? "",
- }}
- />
+ setRowAction(null)}
+ open={rowAction?.variant === "copyUrl"}
+ refConfig={{
+ type: "reflink",
+ name: rowAction?.row.original?.name ?? "",
+ }}
+ />
- setRowAction(null)}
- open={rowAction?.variant === "update"}
- reflink={rowAction?.row.original ?? null}
- workspaceId={workspaceId}
- />
+ setRowAction(null)}
+ open={rowAction?.variant === "update"}
+ reflink={rowAction?.row.original ?? null}
+ workspaceId={workspaceId}
+ />
- setRowAction(null)}
- onSuccess={() => {
- router.refresh()
- }}
- open={rowAction?.variant === "delete"}
- reflinks={rowAction?.row.original ? [rowAction?.row.original] : []}
- showTrigger={false}
- workspaceId={workspaceId}
- />
-
-
+ setRowAction(null)}
+ onSuccess={() => {
+ router.refresh()
+ }}
+ open={rowAction?.variant === "delete"}
+ reflinks={rowAction?.row.original ? [rowAction?.row.original] : []}
+ showTrigger={false}
+ workspaceId={workspaceId}
+ />
+ >
)
}
diff --git a/apps/builder/src/features/saved-replies/actions/create-saved-reply.action.ts b/apps/builder/src/features/saved-replies/actions/create-saved-reply.action.ts
index 1afc0fd52..b86147073 100644
--- a/apps/builder/src/features/saved-replies/actions/create-saved-reply.action.ts
+++ b/apps/builder/src/features/saved-replies/actions/create-saved-reply.action.ts
@@ -1,12 +1,17 @@
"use server"
-import { db } from "@chatbotx.io/database/client"
+import { auditLogActions, logAudit } from "@chatbotx.io/business"
+import { ChatbotXException } from "@chatbotx.io/business/errors"
+import { count, db, eq } from "@chatbotx.io/database/client"
import { savedReplyModel } from "@chatbotx.io/database/schema"
import { createId } from "@chatbotx.io/utils"
import { workspaceIdrequestParams } from "@/features/common/schemas"
import { revalidateCacheTags } from "@/lib/cache-helper"
import { workspaceActionClient } from "@/lib/safe-action"
-import { createSavedReplyRequest } from "../schema/mutation"
+import {
+ createSavedReplyRequest,
+ MAX_SNIPPETS_PER_WORKSPACE,
+} from "../schema/mutation"
export const createSavedReplyAction = workspaceActionClient
.bindArgsSchemas(workspaceIdrequestParams)
@@ -15,19 +20,42 @@ export const createSavedReplyAction = workspaceActionClient
const {
bindArgsParsedInputs: [workspaceId],
parsedInput,
+ ctx: { user },
} = props
+
+ // Paridade Respond.io (gap #12 — 2026-05-27): limite 5000/workspace.
+ const [{ value: total }] = await db
+ .select({ value: count() })
+ .from(savedReplyModel)
+ .where(eq(savedReplyModel.workspaceId, workspaceId))
+ if (total >= MAX_SNIPPETS_PER_WORKSPACE) {
+ throw new ChatbotXException(
+ `Limite de ${MAX_SNIPPETS_PER_WORKSPACE} snippets por workspace atingido.`,
+ )
+ }
+
const savedReply = await db
.insert(savedReplyModel)
.values({
id: createId(),
workspaceId,
+ name: parsedInput.name ?? null,
shortcut: parsedInput.shortcut,
text: parsedInput.text,
+ topics: parsedInput.topics ?? [],
+ files: parsedInput.files ?? [],
})
.returning()
.then((result) => result[0])
revalidateCacheTags(`workspaces:${workspaceId}#savedReplies`)
+ await logAudit({
+ workspaceId,
+ userId: user.id,
+ action: auditLogActions.SNIPPET_CREATED,
+ detail: `Trecho "${parsedInput.name ?? parsedInput.shortcut}" criado`,
+ })
+
return savedReply
})
diff --git a/apps/builder/src/features/saved-replies/actions/delete-saved-reply.action.ts b/apps/builder/src/features/saved-replies/actions/delete-saved-reply.action.ts
index a1486b411..9c39b254d 100644
--- a/apps/builder/src/features/saved-replies/actions/delete-saved-reply.action.ts
+++ b/apps/builder/src/features/saved-replies/actions/delete-saved-reply.action.ts
@@ -1,5 +1,6 @@
"use server"
+import { auditLogActions, logAudit } from "@chatbotx.io/business"
import { db, eq, findOrFail } from "@chatbotx.io/database/client"
import { savedReplyModel } from "@chatbotx.io/database/schema"
import { workspaceIdAndIdRequestParams } from "@/features/common/schemas"
@@ -11,6 +12,7 @@ export const deleteSavedReplyAction = workspaceActionClient
.action(async (props) => {
const {
bindArgsParsedInputs: [workspaceId, id],
+ ctx: { user },
} = props
const savedReply = await findOrFail({
@@ -19,7 +21,7 @@ export const deleteSavedReplyAction = workspaceActionClient
id,
workspaceId,
},
- message: "Saved reply not found",
+ message: "Resposta salva não encontrada",
})
await db
@@ -27,4 +29,11 @@ export const deleteSavedReplyAction = workspaceActionClient
.where(eq(savedReplyModel.id, savedReply.id))
revalidateCacheTags(`workspaces:${workspaceId}#savedReplies`)
+
+ await logAudit({
+ workspaceId,
+ userId: user.id,
+ action: auditLogActions.SNIPPET_DELETED,
+ detail: `Trecho "${savedReply.shortcut}" excluído`,
+ })
})
diff --git a/apps/builder/src/features/saved-replies/actions/edit-saved-reply.action.ts b/apps/builder/src/features/saved-replies/actions/edit-saved-reply.action.ts
index 8d105d231..b903e2074 100644
--- a/apps/builder/src/features/saved-replies/actions/edit-saved-reply.action.ts
+++ b/apps/builder/src/features/saved-replies/actions/edit-saved-reply.action.ts
@@ -1,5 +1,6 @@
"use server"
+import { auditLogActions, logAudit } from "@chatbotx.io/business"
import { db, eq, findOrFail } from "@chatbotx.io/database/client"
import { savedReplyModel } from "@chatbotx.io/database/schema"
import { zodBigintAsString } from "@chatbotx.io/utils"
@@ -14,6 +15,7 @@ export const editSavedReplyAction = workspaceActionClient
const {
bindArgsParsedInputs: [workspaceId, id],
parsedInput,
+ ctx: { user },
} = props
const savedReply = await findOrFail({
@@ -22,18 +24,28 @@ export const editSavedReplyAction = workspaceActionClient
id,
workspaceId,
},
- message: "Saved reply not found",
+ message: "Resposta salva não encontrada",
})
const [updatedSavedReply] = await db
.update(savedReplyModel)
.set({
+ name: parsedInput.name ?? null,
shortcut: parsedInput.shortcut,
text: parsedInput.text,
+ topics: parsedInput.topics ?? [],
+ files: parsedInput.files ?? [],
})
.where(eq(savedReplyModel.id, savedReply.id))
.returning()
revalidateCacheTags(`workspaces:${workspaceId}#savedReplies`)
+ await logAudit({
+ workspaceId,
+ userId: user.id,
+ action: auditLogActions.SNIPPET_UPDATED,
+ detail: `Trecho "${parsedInput.name ?? parsedInput.shortcut}" atualizado`,
+ })
+
return updatedSavedReply
})
diff --git a/apps/builder/src/features/saved-replies/api/authorized.ts b/apps/builder/src/features/saved-replies/api/authorized.ts
index 89f36cea6..e5fd2de7a 100644
--- a/apps/builder/src/features/saved-replies/api/authorized.ts
+++ b/apps/builder/src/features/saved-replies/api/authorized.ts
@@ -11,8 +11,8 @@ export const savedRepliesAuthorizedAPI = {
.route({
method: "GET",
path: "/workspaces/{workspaceId}/saved-replies",
- summary: "List saved replies",
- tags: ["Saved Replies"],
+ summary: "Listar respostas salvas",
+ tags: ["Respostas Salvas"],
})
.input(listSavedRepliesRequest)
.use(workspaceAuthorizedMidddleware, (input) => input.workspaceId)
diff --git a/apps/builder/src/features/saved-replies/api/workspace-token.ts b/apps/builder/src/features/saved-replies/api/workspace-token.ts
index ed33c7569..cc50c5e0a 100644
--- a/apps/builder/src/features/saved-replies/api/workspace-token.ts
+++ b/apps/builder/src/features/saved-replies/api/workspace-token.ts
@@ -8,8 +8,8 @@ export const savedReplyWorkspaceTokenAPIs = {
.route({
method: "GET",
path: "/v1/saved-replies",
- summary: "List saved replies",
- tags: ["Saved Replies"],
+ summary: "Listar respostas salvas",
+ tags: ["Respostas Salvas"],
})
.input(z.object({}))
.output(listSavedReplyResponse)
diff --git a/apps/builder/src/features/saved-replies/delete-snippet-dialog.tsx b/apps/builder/src/features/saved-replies/delete-snippet-dialog.tsx
new file mode 100644
index 000000000..65513f85b
--- /dev/null
+++ b/apps/builder/src/features/saved-replies/delete-snippet-dialog.tsx
@@ -0,0 +1,86 @@
+"use client"
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@chatbotx.io/ui/components/ui/alert-dialog"
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import { useRouter } from "next/navigation"
+import { useTranslations } from "next-intl"
+import { useAction } from "next-safe-action/hooks"
+import { toast } from "sonner"
+import { deleteSavedReplyAction } from "./actions/delete-saved-reply.action"
+import type { SavedReplyResource } from "./schema/resource"
+
+type DeleteSnippetDialogProps = {
+ workspaceId: string
+ snippet: SavedReplyResource | null
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onSuccess?: () => void
+}
+
+export function DeleteSnippetDialog({
+ workspaceId,
+ snippet,
+ open,
+ onOpenChange,
+ onSuccess,
+}: DeleteSnippetDialogProps) {
+ const t = useTranslations()
+ const router = useRouter()
+
+ const { execute, isExecuting } = useAction(
+ deleteSavedReplyAction.bind(null, workspaceId, snippet?.id ?? ""),
+ {
+ onSuccess: () => {
+ toast.success(
+ t("messages.deletedSuccess", { feature: t("snippets.feature") }),
+ )
+ onOpenChange(false)
+ onSuccess?.()
+ router.refresh()
+ },
+ onError: ({ error }) => {
+ if (error.serverError) {
+ toast.error(error.serverError)
+ }
+ },
+ },
+ )
+
+ return (
+
+
+
+ {t("snippets.deleteTitle")}
+
+ {t("snippets.deleteDescription", {
+ name: snippet?.shortcut ?? "",
+ })}
+
+
+
+
+ {t("actions.cancel")}
+
+
+ execute()}
+ variant="destructive"
+ >
+ {t("actions.delete")}
+
+
+
+
+
+ )
+}
diff --git a/apps/builder/src/features/saved-replies/provider/saved-reply-store.ts b/apps/builder/src/features/saved-replies/provider/saved-reply-store.ts
index f546bbaad..4f3e7b4c6 100644
--- a/apps/builder/src/features/saved-replies/provider/saved-reply-store.ts
+++ b/apps/builder/src/features/saved-replies/provider/saved-reply-store.ts
@@ -45,7 +45,7 @@ export const createSavedReplyStore = (props: Partial) =>
error:
error instanceof HTTPError
? error.message
- : "Failed to fetch saved replies",
+ : "Falha ao buscar respostas salvas",
})
} finally {
set({ initialized: true })
@@ -82,7 +82,7 @@ export const createSavedReplyStore = (props: Partial) =>
error:
error instanceof HTTPError
? error.message
- : "Failed to fetch saved replies",
+ : "Falha ao buscar respostas salvas",
})
} finally {
set({ isLoading: false })
diff --git a/apps/builder/src/features/saved-replies/queries/list-snippets-rsc.ts b/apps/builder/src/features/saved-replies/queries/list-snippets-rsc.ts
new file mode 100644
index 000000000..9583f882b
--- /dev/null
+++ b/apps/builder/src/features/saved-replies/queries/list-snippets-rsc.ts
@@ -0,0 +1,22 @@
+import { db } from "@chatbotx.io/database/client"
+import { assertCurrentUserCanAccessChatbot } from "@/lib/auth/utils"
+import type { SavedReplyResource } from "../schema/resource"
+
+/**
+ * Versão RSC (Server Component) — busca snippets do workspace pra page de
+ * Settings. Não paginada (Respond.io limita 5000 por workspace, valor que
+ * ainda cabe em memória). Quando passar de ~1000 snippets reais, adicionar
+ * pagination + search server-side.
+ */
+export async function listSnippetsRSC(input: {
+ workspaceId: string
+}): Promise<{ data: SavedReplyResource[]; pageCount: number }> {
+ await assertCurrentUserCanAccessChatbot(input.workspaceId)
+
+ const data = await db.query.savedReplyModel.findMany({
+ where: { workspaceId: input.workspaceId },
+ orderBy: { createdAt: "desc" },
+ })
+
+ return { data: data as SavedReplyResource[], pageCount: 1 }
+}
diff --git a/apps/builder/src/features/saved-replies/saved-reply-manage.tsx b/apps/builder/src/features/saved-replies/saved-reply-manage.tsx
index 8ff048428..6fd7673ca 100644
--- a/apps/builder/src/features/saved-replies/saved-reply-manage.tsx
+++ b/apps/builder/src/features/saved-replies/saved-reply-manage.tsx
@@ -6,9 +6,15 @@ import {
PopoverContent,
PopoverTrigger,
} from "@chatbotx.io/ui/components/ui/popover"
-import { Loader2Icon, MessageSquareMoreIcon, PlusIcon } from "lucide-react"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@chatbotx.io/ui/components/ui/tooltip"
+import { Loader2Icon, PlusIcon } from "lucide-react"
import { useTranslations } from "next-intl"
import { useEffect, useMemo, useState } from "react"
+import { RespondIcon } from "@/components/respond-icon"
import { useWorkspaceId } from "@/hooks/routing"
import { useSavedReplyStore } from "./provider/saved-reply-store-context"
import { SavedReplyCreateForm } from "./saved-reply-create-form"
@@ -62,11 +68,26 @@ const SavedReplyManage = (props: { onSelect: (text: string) => void }) => {
return (
-
-
-
-
-
+
+
+
+
+ {/* Ícone `receipt` — pixel-perfect Respond.io 2026-05-25
+ (Pedro pediu mesma ordem de ícones do composer Respond.io).
+ Substituiu Lucide MessageSquareMoreIcon. */}
+
+
+
+
+
+ {t("messages.composer.snippets")}
+
+
{view.type === "create" ? (
+
export const listSavedRepliesRequest = z.object({
workspaceId: zodBigintAsString(),
})
export type ListSavedRepliesRequest = z.infer
export const createSavedReplyRequest = z.object({
+ name: z.string().trim().max(100).optional(),
shortcut: z.string().trim().min(1).max(100),
text: z.string().trim().min(1).max(2000),
+ topics: z
+ .array(z.string().trim().min(1).max(50))
+ .max(MAX_TOPICS_PER_SNIPPET)
+ .optional(),
+ files: z.array(snippetFileSchema).max(MAX_FILES_PER_SNIPPET).optional(),
})
export type CreateSavedReplyRequest = z.infer
diff --git a/apps/builder/src/features/saved-replies/snippet-form-dialog.tsx b/apps/builder/src/features/saved-replies/snippet-form-dialog.tsx
new file mode 100644
index 000000000..f86cbeee8
--- /dev/null
+++ b/apps/builder/src/features/saved-replies/snippet-form-dialog.tsx
@@ -0,0 +1,488 @@
+"use client"
+
+import { Badge } from "@chatbotx.io/ui/components/ui/badge"
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@chatbotx.io/ui/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@chatbotx.io/ui/components/ui/form"
+import { Input } from "@chatbotx.io/ui/components/ui/input"
+import { Textarea } from "@chatbotx.io/ui/components/ui/textarea"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useHookFormAction } from "@next-safe-action/adapter-react-hook-form/hooks"
+import { Loader2Icon, PlusIcon, XIcon } from "lucide-react"
+import { useRouter } from "next/navigation"
+import { useTranslations } from "next-intl"
+import {
+ type KeyboardEvent,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from "react"
+import { toast } from "sonner"
+import { createSavedReplyAction } from "./actions/create-saved-reply.action"
+import { editSavedReplyAction } from "./actions/edit-saved-reply.action"
+import {
+ createSavedReplyRequest,
+ MAX_FILES_PER_SNIPPET,
+ MAX_TOPICS_PER_SNIPPET,
+ type SnippetFile,
+} from "./schema/mutation"
+import type { SavedReplyResource } from "./schema/resource"
+
+type SnippetFormDialogProps = {
+ workspaceId: string
+ // Quando `snippet` está presente o dialog opera em modo edit; senão create.
+ snippet?: SavedReplyResource | null
+ open?: boolean
+ onOpenChange?: (val: boolean) => void
+}
+
+export function SnippetFormDialog({
+ workspaceId,
+ snippet,
+ open: controlledOpen,
+ onOpenChange,
+}: SnippetFormDialogProps) {
+ const t = useTranslations()
+ const router = useRouter()
+ const isEdit = !!snippet
+ const [internalOpen, setInternalOpen] = useState(false)
+ const open = controlledOpen ?? internalOpen
+ const setOpen = onOpenChange ?? setInternalOpen
+ const isControlled = controlledOpen !== undefined
+ const showTrigger = !(isControlled || isEdit)
+
+ const createForm = useHookFormAction(
+ createSavedReplyAction.bind(null, workspaceId),
+ zodResolver(createSavedReplyRequest),
+ {
+ actionProps: {
+ onSuccess: () => {
+ toast.success(
+ t("messages.createdSuccess", { feature: t("snippets.feature") }),
+ )
+ setOpen(false)
+ createForm.resetFormAndAction()
+ router.refresh()
+ },
+ onError: ({ error }: { error: { serverError?: string } }) => {
+ if (error.serverError) {
+ toast.error(error.serverError)
+ }
+ },
+ },
+ formProps: {
+ mode: "onChange",
+ defaultValues: {
+ name: "",
+ shortcut: "",
+ text: "",
+ topics: [],
+ files: [],
+ },
+ },
+ errorMapProps: {},
+ },
+ )
+
+ const editForm = useHookFormAction(
+ editSavedReplyAction.bind(null, workspaceId, snippet?.id ?? ""),
+ zodResolver(createSavedReplyRequest),
+ {
+ actionProps: {
+ onSuccess: () => {
+ toast.success(
+ t("messages.updatedSuccess", { feature: t("snippets.feature") }),
+ )
+ setOpen(false)
+ editForm.resetFormAndAction()
+ router.refresh()
+ },
+ onError: ({ error }: { error: { serverError?: string } }) => {
+ if (error.serverError) {
+ toast.error(error.serverError)
+ }
+ },
+ },
+ formProps: { mode: "onChange" },
+ errorMapProps: {},
+ },
+ )
+
+ const { form, handleSubmitWithAction, resetFormAndAction } = isEdit
+ ? editForm
+ : createForm
+
+ useEffect(() => {
+ if (isEdit && snippet) {
+ const s = snippet as unknown as {
+ name?: string | null
+ shortcut: string
+ text: string
+ topics?: string[]
+ files?: SnippetFile[]
+ }
+ editForm.form.reset({
+ name: s.name ?? "",
+ shortcut: s.shortcut,
+ text: s.text,
+ topics: s.topics ?? [],
+ files: s.files ?? [],
+ })
+ }
+ }, [isEdit, snippet, editForm.form])
+
+ const handleOpenChange = useCallback(
+ (next: boolean) => {
+ setOpen(next)
+ if (!next) {
+ resetFormAndAction()
+ }
+ },
+ [setOpen, resetFormAndAction],
+ )
+
+ const title = isEdit
+ ? t("messages.editFeature", { feature: t("snippets.feature") })
+ : t("snippets.createTitle")
+
+ return (
+
+ {showTrigger && (
+
+
+
+ {t("snippets.addButton")}
+
+
+ )}
+
+
+ {title}
+ {t("snippets.formDescription")}
+
+
+
+
+ {/* Nome descritivo (paridade Respond.io) — opcional. Diferente do
+ atalho, fica visível na tabela pra identificar snippet. */}
+ (
+
+ {t("snippets.nameLabel")}
+
+
+
+
+
+ )}
+ />
+
+ (
+
+ {t("snippets.shortcutLabel")}
+
+
+
+
+
+ )}
+ />
+
+ (
+
+
+ {t("snippets.topicsLabel")}{" "}
+
+ ({(field.value ?? []).length}/{MAX_TOPICS_PER_SNIPPET})
+
+
+
+
+
+
+
+ )}
+ />
+
+ (
+
+ {t("snippets.messageLabel")}
+
+
+
+
+
+ )}
+ />
+
+ (
+
+
+ {t("snippets.filesLabel")}{" "}
+
+ ({(field.value ?? []).length}/{MAX_FILES_PER_SNIPPET})
+
+
+
+
+
+
+
+ )}
+ />
+
+
+ setOpen(false)}
+ size="sm"
+ type="button"
+ variant="ghost"
+ >
+ {t("actions.cancel")}
+
+
+ {form.formState.isSubmitting && (
+
+ )}
+ {isEdit ? t("actions.confirm") : t("snippets.createConfirm")}
+
+
+
+
+
+
+ )
+}
+
+// ─── Editor de tópicos inline ─────────────────────────────────────────
+// Input + chips com X. Enter adiciona. Dedup automático. Respeita max.
+function TopicsInput({
+ value,
+ onChange,
+ max,
+}: {
+ value: string[]
+ onChange: (next: string[]) => void
+ max: number
+}) {
+ const t = useTranslations()
+ const [draft, setDraft] = useState("")
+ const reachedLimit = value.length >= max
+
+ const handleAdd = () => {
+ const trimmed = draft.trim().toLowerCase()
+ if (!trimmed || value.includes(trimmed) || reachedLimit) {
+ setDraft("")
+ return
+ }
+ onChange([...value, trimmed])
+ setDraft("")
+ }
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Enter") {
+ e.preventDefault()
+ handleAdd()
+ }
+ }
+
+ return (
+
+
+
setDraft(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder={
+ reachedLimit
+ ? t("snippets.topicsLimitReached")
+ : t("snippets.topicsPlaceholder")
+ }
+ value={draft}
+ />
+
+
+
+
+ {value.length > 0 && (
+
+ {value.map((topic) => (
+
+ {topic}
+ onChange(value.filter((v) => v !== topic))}
+ type="button"
+ >
+
+
+
+ ))}
+
+ )}
+
+ )
+}
+
+// ─── Lista de arquivos anexados ──────────────────────────────────────
+// Hoje usa Input file simples local (gera blob URL) — placeholder até
+// integrar com presigned upload S3 (próximo: usar DirectUploadButton).
+function FilesList({
+ value,
+ onChange,
+ max,
+}: {
+ value: SnippetFile[]
+ onChange: (next: SnippetFile[]) => void
+ max: number
+}) {
+ const t = useTranslations()
+ const inputRef = useRef(null)
+ const reachedLimit = value.length >= max
+
+ const handlePick = (files: FileList | null) => {
+ if (!files) {
+ return
+ }
+ const remaining = max - value.length
+ const toAdd: SnippetFile[] = Array.from(files)
+ .slice(0, remaining)
+ .map((f) => ({
+ name: f.name,
+ url: URL.createObjectURL(f),
+ size: f.size,
+ mimeType: f.type || "application/octet-stream",
+ }))
+ onChange([...value, ...toAdd])
+ if (inputRef.current) {
+ inputRef.current.value = ""
+ }
+ }
+
+ return (
+
+
handlePick(e.target.files)}
+ ref={inputRef}
+ type="file"
+ />
+
inputRef.current?.click()}
+ size="sm"
+ type="button"
+ variant="outline"
+ >
+
+ {reachedLimit ? t("snippets.filesLimitReached") : t("snippets.addFile")}
+
+ {value.length > 0 && (
+
+ {value.map((file) => (
+
+
+ {file.name}
+
+
+ {formatBytes(file.size)}
+
+
+ onChange(value.filter((f) => f.url !== file.url))
+ }
+ type="button"
+ >
+
+
+
+ ))}
+
+ )}
+
+ )
+}
+
+function formatBytes(bytes: number): string {
+ if (bytes < 1024) {
+ return `${bytes} B`
+ }
+ if (bytes < 1024 * 1024) {
+ return `${(bytes / 1024).toFixed(1)} KB`
+ }
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
+}
diff --git a/apps/builder/src/features/saved-replies/snippets-table-columns.tsx b/apps/builder/src/features/saved-replies/snippets-table-columns.tsx
new file mode 100644
index 000000000..ae86b280a
--- /dev/null
+++ b/apps/builder/src/features/saved-replies/snippets-table-columns.tsx
@@ -0,0 +1,305 @@
+"use client"
+
+import { DataTableColumnHeader } from "@chatbotx.io/ui/components/data-table/data-table-column-header"
+import { Badge } from "@chatbotx.io/ui/components/ui/badge"
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import { Checkbox } from "@chatbotx.io/ui/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@chatbotx.io/ui/components/ui/dropdown-menu"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@chatbotx.io/ui/components/ui/tooltip"
+import type { DataTableRowAction } from "@chatbotx.io/ui/types/data-table"
+import type { ColumnDef } from "@tanstack/react-table"
+import {
+ EllipsisVerticalIcon,
+ FingerprintIcon,
+ PaperclipIcon,
+ PencilIcon,
+ Trash2Icon,
+} from "lucide-react"
+import type { useTranslations } from "next-intl"
+import type { Dispatch, SetStateAction } from "react"
+import type { SavedReplyResource } from "./schema/resource"
+
+type GetColumnsProps = {
+ setRowAction: Dispatch<
+ SetStateAction | null>
+ >
+ handleCopy: (id: string) => void
+ t: ReturnType
+ locale: string
+}
+
+function formatRespondDate(
+ value: Date | string | null,
+ locale: string,
+): string {
+ if (!value) {
+ return "—"
+ }
+ const d = typeof value === "string" ? new Date(value) : value
+ if (Number.isNaN(d.getTime())) {
+ return "—"
+ }
+ return new Intl.DateTimeFormat(locale, {
+ month: "short",
+ day: "2-digit",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: true,
+ }).format(d)
+}
+
+export function getSnippetColumns({
+ setRowAction,
+ handleCopy,
+ t,
+ locale,
+}: GetColumnsProps): ColumnDef[] {
+ return [
+ {
+ id: "select",
+ header: ({ table }) => (
+
+ table.toggleAllPageRowsSelected(Boolean(value))
+ }
+ />
+ ),
+ cell: ({ row }) => (
+ row.toggleSelected(Boolean(value))}
+ />
+ ),
+ size: 50,
+ enableSorting: false,
+ enableHiding: false,
+ },
+ {
+ // Coluna Nome: campo descritivo separado do atalho (paridade Respond.io
+ // — gap #12 2026-05-27). Fallback pro shortcut quando nome ainda
+ // não foi preenchido (snippets antigos).
+ id: "name",
+ accessorKey: "name",
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => {
+ const displayName = row.original.name?.trim() || row.original.shortcut
+ return (
+
+
+
+ {displayName}
+
+
+
+ {displayName}
+
+
+ )
+ },
+ meta: {
+ label: t("snippets.nameLabel"),
+ placeholder: t("snippets.searchPlaceholder"),
+ variant: "text",
+ },
+ enableColumnFilter: true,
+ enableSorting: true,
+ enableHiding: false,
+ },
+ {
+ id: "shortcut",
+ accessorKey: "shortcut",
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => (
+
+ /{row.original.shortcut}
+
+ ),
+ size: 140,
+ enableSorting: true,
+ },
+ {
+ id: "topics",
+ accessorKey: "topics",
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => {
+ const topics = (row.original.topics ?? []) as string[]
+ if (topics.length === 0) {
+ return —
+ }
+ const visible = topics.slice(0, 3)
+ const extra = topics.length - visible.length
+ return (
+
+ {visible.map((topic) => (
+
+ {topic}
+
+ ))}
+ {extra > 0 && (
+ +{extra}
+ )}
+
+ )
+ },
+ size: 200,
+ enableSorting: false,
+ },
+ {
+ id: "files",
+ accessorKey: "files",
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => {
+ const files = (row.original.files ?? []) as unknown[]
+ if (files.length === 0) {
+ return —
+ }
+ return (
+
+ )
+ },
+ size: 80,
+ enableSorting: false,
+ },
+ {
+ id: "text",
+ accessorKey: "text",
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => (
+
+
+
+ {row.original.text}
+
+
+
+ {row.original.text}
+
+
+ ),
+ enableSorting: false,
+ },
+ {
+ id: "createdAt",
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => (
+
+ {formatRespondDate(row.original.createdAt, locale)}
+
+ ),
+ size: 180,
+ enableSorting: true,
+ },
+ {
+ id: "updatedAt",
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => (
+
+ {formatRespondDate(row.original.updatedAt, locale)}
+
+ ),
+ size: 180,
+ enableSorting: true,
+ },
+ {
+ id: "actions",
+ header: t("actions.actions"),
+ cell: ({ row }) => (
+
+
+
+
+
+
+
+ setRowAction({ row, variant: "update" })}
+ >
+
+ {t("actions.edit")}
+
+ handleCopy(`${row.original.id}`)}>
+
+ {t("actions.getID")}
+
+ setRowAction({ row, variant: "delete" })}
+ >
+
+ {t("actions.delete")}
+
+
+
+ ),
+ size: 50,
+ enableSorting: false,
+ enableHiding: false,
+ },
+ ]
+}
diff --git a/apps/builder/src/features/saved-replies/snippets-table.tsx b/apps/builder/src/features/saved-replies/snippets-table.tsx
new file mode 100644
index 000000000..6571aea5a
--- /dev/null
+++ b/apps/builder/src/features/saved-replies/snippets-table.tsx
@@ -0,0 +1,91 @@
+"use client"
+
+import { DataTable } from "@chatbotx.io/ui/components/data-table/data-table"
+import { DataTableToolbar } from "@chatbotx.io/ui/components/data-table/data-table-toolbar"
+import { useDataTable } from "@chatbotx.io/ui/hooks/use-data-table"
+import type { DataTableRowAction } from "@chatbotx.io/ui/types/data-table"
+import { useLocale, useTranslations } from "next-intl"
+import React, { useMemo } from "react"
+import { toast } from "sonner"
+import { useCopyToClipboard } from "usehooks-ts"
+import { DeleteSnippetDialog } from "./delete-snippet-dialog"
+import type { listSnippetsRSC } from "./queries/list-snippets-rsc"
+import type { SavedReplyResource } from "./schema/resource"
+import { SnippetFormDialog } from "./snippet-form-dialog"
+import { getSnippetColumns } from "./snippets-table-columns"
+
+type SnippetsTableProps = {
+ promises: Promise<[Awaited>]>
+ workspaceId: string
+}
+
+export function SnippetsTable({ promises, workspaceId }: SnippetsTableProps) {
+ const [{ data, pageCount }] = React.use(promises)
+ const [rowAction, setRowAction] =
+ React.useState | null>(null)
+ const [_, copy] = useCopyToClipboard()
+ const t = useTranslations()
+ const locale = useLocale()
+
+ const handleCopy = (id: string) => {
+ copy(id)
+ .then(() => toast.success("Copiado para a área de transferência!"))
+ .catch(() => toast.error("Falha ao copiar!"))
+ }
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: memo
+ const columns = useMemo(
+ () => getSnippetColumns({ setRowAction, handleCopy, t, locale }),
+ [locale],
+ )
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => originalRow.id,
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+
+
+
+
+
+
+
+
+
setRowAction(null)}
+ open={rowAction?.variant === "update"}
+ snippet={rowAction?.row.original ?? null}
+ workspaceId={workspaceId}
+ />
+
+ {
+ if (!open) {
+ setRowAction(null)
+ }
+ }}
+ onSuccess={() => rowAction?.row.toggleSelected(false)}
+ open={rowAction?.variant === "delete"}
+ snippet={rowAction?.row.original ?? null}
+ workspaceId={workspaceId}
+ />
+
+ )
+}
diff --git a/apps/builder/src/features/sequences/actions/create-sequence.action.ts b/apps/builder/src/features/sequences/actions/create-sequence.action.ts
index 465ad76c6..c644b052e 100644
--- a/apps/builder/src/features/sequences/actions/create-sequence.action.ts
+++ b/apps/builder/src/features/sequences/actions/create-sequence.action.ts
@@ -52,7 +52,7 @@ export const createSequenceAction = workspaceActionClient
})
}
- throw new Error("Failed to create sequence")
+ throw new Error("Falha ao criar sequência")
}
},
)
diff --git a/apps/builder/src/features/sequences/actions/delete-sequence-step.action.ts b/apps/builder/src/features/sequences/actions/delete-sequence-step.action.ts
index 4feb8b2b7..251388087 100644
--- a/apps/builder/src/features/sequences/actions/delete-sequence-step.action.ts
+++ b/apps/builder/src/features/sequences/actions/delete-sequence-step.action.ts
@@ -29,7 +29,7 @@ async function validateSequenceOwnership(
id: sequenceId,
workspaceId,
},
- message: "Sequence not found",
+ message: "Sequência não encontrada",
})
}
@@ -44,11 +44,11 @@ async function deleteStep(stepId: string, workspaceId: string) {
})
if (!step) {
- throw new Error("Step not found")
+ throw new Error("Passo não encontrado")
}
if (step.sequence.workspaceId !== workspaceId) {
- throw new Error("Unauthorized: Step does not belong to this workspace")
+ throw new Error("Não autorizado: passo não pertence a este workspace")
}
await db.delete(sequenceStepModel).where(eq(sequenceStepModel.id, stepId))
diff --git a/apps/builder/src/features/sequences/actions/delete-sequence.action.ts b/apps/builder/src/features/sequences/actions/delete-sequence.action.ts
index 2cde832ae..0b07debd3 100644
--- a/apps/builder/src/features/sequences/actions/delete-sequence.action.ts
+++ b/apps/builder/src/features/sequences/actions/delete-sequence.action.ts
@@ -26,7 +26,7 @@ export const deleteSequence = async (ctx: {
id: ctx.id,
workspaceId: ctx.workspaceId,
},
- message: "Sequence not found",
+ message: "Sequência não encontrada",
})
await db.delete(sequenceModel).where(and(eq(sequenceModel.id, ctx.id)))
diff --git a/apps/builder/src/features/sequences/actions/update-sequence.action.ts b/apps/builder/src/features/sequences/actions/update-sequence.action.ts
index 1a9924184..2f74fb67e 100644
--- a/apps/builder/src/features/sequences/actions/update-sequence.action.ts
+++ b/apps/builder/src/features/sequences/actions/update-sequence.action.ts
@@ -51,7 +51,7 @@ export const updateSequence = async (
id: ctx.id,
workspaceId: ctx.workspaceId,
},
- message: "Sequence not found",
+ message: "Sequência não encontrada",
})
try {
@@ -69,7 +69,7 @@ export const updateSequence = async (
})
}
- throw new Error("Failed to update sequence")
+ throw new Error("Falha ao atualizar sequência")
}
revalidateCacheTags([`workspaces:${ctx.workspaceId}#sequences`])
diff --git a/apps/builder/src/features/sequences/actions/upsert-sequence-step.action.ts b/apps/builder/src/features/sequences/actions/upsert-sequence-step.action.ts
index d185fbddc..bcb2a8830 100644
--- a/apps/builder/src/features/sequences/actions/upsert-sequence-step.action.ts
+++ b/apps/builder/src/features/sequences/actions/upsert-sequence-step.action.ts
@@ -28,7 +28,7 @@ async function validateSequenceOwnership(
id: sequenceId,
workspaceId,
},
- message: "Sequence not found",
+ message: "Sequência não encontrada",
})
}
@@ -276,11 +276,11 @@ async function updateSequenceStep(
})
if (!step) {
- throw new Error("Step not found")
+ throw new Error("Passo não encontrado")
}
if (step.sequence.workspaceId !== workspaceId) {
- throw new Error("Unauthorized: Step does not belong to this workspace")
+ throw new Error("Não autorizado: passo não pertence a este workspace")
}
const [updated] = await db
diff --git a/apps/builder/src/features/sequences/api/authorized.ts b/apps/builder/src/features/sequences/api/authorized.ts
index 1724c4330..6f4fb7697 100644
--- a/apps/builder/src/features/sequences/api/authorized.ts
+++ b/apps/builder/src/features/sequences/api/authorized.ts
@@ -8,8 +8,8 @@ export const sequencesWorkspaceAuthAPI = {
.route({
method: "GET",
path: "/workspaces/{workspaceId}/sequences",
- summary: "List sequences",
- tags: ["Sequences"],
+ summary: "Listar sequências",
+ tags: ["Sequências"],
})
.input(listSequencesRequest)
.use(workspaceAuthorizedMidddleware, (input) => input.workspaceId)
diff --git a/apps/builder/src/features/sequences/api/private.ts b/apps/builder/src/features/sequences/api/private.ts
index e608f0c95..7d527e8c6 100644
--- a/apps/builder/src/features/sequences/api/private.ts
+++ b/apps/builder/src/features/sequences/api/private.ts
@@ -15,8 +15,8 @@ export const sequencesPrivateAPI = {
.route({
method: "GET",
path: "/workspaces/{workspaceId}/sequences/{sequenceId}/steps/{stepId}/stats",
- summary: "Get sequence step stats",
- tags: ["Sequences"],
+ summary: "Obter estatísticas da etapa da sequência",
+ tags: ["Sequências"],
})
.input(getSequenceStepStatsRequest)
.output(getSequenceStepStatsResponse)
@@ -34,8 +34,8 @@ export const sequencesPrivateAPI = {
.route({
method: "GET",
path: "/workspaces/{workspaceId}/sequences/{sequenceId}/steps/{stepId}/contacts",
- summary: "List sequence step contacts by event type",
- tags: ["Sequences"],
+ summary: "Listar contatos da etapa da sequência por tipo de evento",
+ tags: ["Sequências"],
})
.input(listSequenceStepContactsRequest)
.output(listSequenceStepContactsResponse)
diff --git a/apps/builder/src/features/sequences/api/workspace-token.ts b/apps/builder/src/features/sequences/api/workspace-token.ts
index cfcaf4298..2874f7266 100644
--- a/apps/builder/src/features/sequences/api/workspace-token.ts
+++ b/apps/builder/src/features/sequences/api/workspace-token.ts
@@ -10,8 +10,8 @@ export const sequencesWorkspaceTokenAPIs = {
.route({
method: "GET",
path: "/sequences",
- summary: "List sequences",
- tags: ["Sequences"],
+ summary: "Listar sequências",
+ tags: ["Sequências"],
})
.input(basePaginationRequest)
.output(listSequencesResponse)
@@ -27,8 +27,8 @@ export const sequencesWorkspaceTokenAPIs = {
.route({
method: "GET",
path: "/sequences/{id}",
- summary: "Get sequence details",
- tags: ["Sequences"],
+ summary: "Obter detalhes da sequência",
+ tags: ["Sequências"],
})
.input(z.object({ id: z.string() }))
.output(sequenceResource)
diff --git a/apps/builder/src/features/sequences/bulk-delete-sequence-dialog.tsx b/apps/builder/src/features/sequences/bulk-delete-sequence-dialog.tsx
index d02970966..676b72582 100644
--- a/apps/builder/src/features/sequences/bulk-delete-sequence-dialog.tsx
+++ b/apps/builder/src/features/sequences/bulk-delete-sequence-dialog.tsx
@@ -88,7 +88,7 @@ export function BulkDeleteSequenceDialog({
= {}) =>
})
} else {
set({
- error: "Failed to fetch sequences",
+ error: "Falha ao buscar sequências",
loading: false,
})
}
diff --git a/apps/builder/src/features/sequences/queries/index.ts b/apps/builder/src/features/sequences/queries/index.ts
index ed7df5553..b1d653491 100644
--- a/apps/builder/src/features/sequences/queries/index.ts
+++ b/apps/builder/src/features/sequences/queries/index.ts
@@ -90,7 +90,7 @@ export async function getSequence(workspaceId: string, sequenceId: string) {
})
if (!sequence) {
- throw new Error("Sequence not found")
+ throw new Error("Sequência não encontrada")
}
return {
diff --git a/apps/builder/src/features/sequences/sequence-editor.tsx b/apps/builder/src/features/sequences/sequence-editor.tsx
index d2bc0832d..a62332ed2 100644
--- a/apps/builder/src/features/sequences/sequence-editor.tsx
+++ b/apps/builder/src/features/sequences/sequence-editor.tsx
@@ -109,11 +109,11 @@ export function SequenceEditor({ sequence, workspaceId }: SequenceEditorProps) {
- Sent
- Delivered
- Seen
- Clicked
- Failed
+ Enviado
+ Entregue
+ Visto
+ Clicado
+ Falhou
diff --git a/apps/builder/src/features/sequences/sequences-table.tsx b/apps/builder/src/features/sequences/sequences-table.tsx
index c456208e1..9efdc2070 100644
--- a/apps/builder/src/features/sequences/sequences-table.tsx
+++ b/apps/builder/src/features/sequences/sequences-table.tsx
@@ -75,7 +75,7 @@ export function SequencesTable({ workspaceId, promises }: SequencesTableProps) {
id: "select",
header: ({ table: dataTable }) => (
(
row.toggleSelected(Boolean(value))}
@@ -166,9 +166,9 @@ export function SequencesTable({ workspaceId, promises }: SequencesTableProps) {
cell: ({ row }) => (
{row.original.active ? (
- Active
+ Ativo
) : (
- Inactive
+ Inativo
)}
),
@@ -184,7 +184,7 @@ export function SequencesTable({ workspaceId, promises }: SequencesTableProps) {
- Open menu
+ Abrir menu
diff --git a/apps/builder/src/features/spreadsheets/api/authenticated.ts b/apps/builder/src/features/spreadsheets/api/authenticated.ts
index bc9321eb7..0f8e0946f 100644
--- a/apps/builder/src/features/spreadsheets/api/authenticated.ts
+++ b/apps/builder/src/features/spreadsheets/api/authenticated.ts
@@ -19,8 +19,8 @@ export const spreadsheetsAuthenticatedAPI = {
.route({
method: "GET",
path: "/workspaces/{workspaceId}/spreadsheets",
- summary: "List spreadsheets",
- tags: ["Spreadsheets"],
+ summary: "Listar planilhas",
+ tags: ["Planilhas"],
})
.input(listSpreadsheetsRequest)
.use(workspaceAuthorizedMidddleware, (input) => input.workspaceId)
@@ -30,8 +30,8 @@ export const spreadsheetsAuthenticatedAPI = {
.route({
method: "GET",
path: "/workspaces/{workspaceId}/worksheets",
- summary: "List worksheets",
- tags: ["Worksheets"],
+ summary: "Listar abas da planilha",
+ tags: ["Abas"],
})
.input(listWorksheetsRequest)
.use(workspaceAuthorizedMidddleware, (input) => input.workspaceId)
@@ -41,8 +41,8 @@ export const spreadsheetsAuthenticatedAPI = {
.route({
method: "GET",
path: "/workspaces/{workspaceId}/worksheets/{spreadsheetId}/headers",
- summary: "List worksheet headers",
- tags: ["Worksheet Headers"],
+ summary: "Listar cabeçalhos da aba",
+ tags: ["Cabeçalhos da Aba"],
})
.input(listWorksheetHeadersRequest)
.use(workspaceAuthorizedMidddleware, (input) => input.workspaceId)
diff --git a/apps/builder/src/features/spreadsheets/delete-spreadsheet-dialog.tsx b/apps/builder/src/features/spreadsheets/delete-spreadsheet-dialog.tsx
index dce906c7f..ad66b1001 100644
--- a/apps/builder/src/features/spreadsheets/delete-spreadsheet-dialog.tsx
+++ b/apps/builder/src/features/spreadsheets/delete-spreadsheet-dialog.tsx
@@ -91,7 +91,7 @@ export function DeleteSpreadsheetsDialog({
execute({ ids: spreadsheets.map((f) => f.id) })}
size="sm"
diff --git a/apps/builder/src/features/spreadsheets/spreadsheets-table-columns.tsx b/apps/builder/src/features/spreadsheets/spreadsheets-table-columns.tsx
index dea071bbe..a0d7d0113 100644
--- a/apps/builder/src/features/spreadsheets/spreadsheets-table-columns.tsx
+++ b/apps/builder/src/features/spreadsheets/spreadsheets-table-columns.tsx
@@ -86,7 +86,7 @@ export function getSpreadsheetColumns({
diff --git a/apps/builder/src/features/tags/actions/create-tag-action.ts b/apps/builder/src/features/tags/actions/create-tag-action.ts
index e8531ccd1..d7afceadf 100644
--- a/apps/builder/src/features/tags/actions/create-tag-action.ts
+++ b/apps/builder/src/features/tags/actions/create-tag-action.ts
@@ -1,5 +1,6 @@
"use server"
+import { auditLogActions, logAudit } from "@chatbotx.io/business"
import { db } from "@chatbotx.io/database/client"
import { tagModel } from "@chatbotx.io/database/schema"
import { createId } from "@chatbotx.io/utils"
@@ -18,11 +19,24 @@ export const createTagAction = workspaceActionClient
.action(
async ({
parsedInput,
+ ctx: { user },
bindArgsParsedInputs: [workspaceId],
}: {
parsedInput: CreateTagRequest
+ ctx: { user: { id: string } }
bindArgsParsedInputs: WorkspaceIdRequestParams
- }) => await createTag({ workspaceId, ...parsedInput }),
+ }) => {
+ const result = await createTag({ workspaceId, ...parsedInput })
+ if ("data" in result && result.data) {
+ await logAudit({
+ workspaceId,
+ userId: user.id,
+ action: auditLogActions.TAG_CREATED,
+ detail: `Etiqueta "${result.data.name}" criada`,
+ })
+ }
+ return result
+ },
)
export const createTag = async (
diff --git a/apps/builder/src/features/tags/actions/delete-tag-action.ts b/apps/builder/src/features/tags/actions/delete-tag-action.ts
index b8e161096..10bf813da 100644
--- a/apps/builder/src/features/tags/actions/delete-tag-action.ts
+++ b/apps/builder/src/features/tags/actions/delete-tag-action.ts
@@ -1,5 +1,6 @@
"use server"
+import { auditLogActions, logAudit } from "@chatbotx.io/business"
import { and, db, eq, findOrFail, inArray } from "@chatbotx.io/database/client"
import { tagModel } from "@chatbotx.io/database/schema"
import {
@@ -18,11 +19,27 @@ export const deleteTagAction = workspaceActionClient
async ({
bindArgsParsedInputs: [workspaceId],
parsedInput,
+ ctx: { user },
}: {
bindArgsParsedInputs: WorkspaceIdRequestParams
parsedInput: BulkUpdateIdsRequest
+ ctx: { user: { id: string } }
}) => {
+ const names = await db.query.tagModel.findMany({
+ columns: { name: true },
+ where: { workspaceId, id: { in: parsedInput.ids } },
+ })
+
await deleteTags({ workspaceId, ids: parsedInput.ids })
+
+ if (names.length > 0) {
+ await logAudit({
+ workspaceId,
+ userId: user.id,
+ action: auditLogActions.TAG_DELETED,
+ detail: `Etiqueta(s) excluída(s): ${names.map((t) => `"${t.name}"`).join(", ")}`,
+ })
+ }
},
)
diff --git a/apps/builder/src/features/tags/actions/update-tag-action.ts b/apps/builder/src/features/tags/actions/update-tag-action.ts
index 09c81465e..423647cc5 100644
--- a/apps/builder/src/features/tags/actions/update-tag-action.ts
+++ b/apps/builder/src/features/tags/actions/update-tag-action.ts
@@ -1,5 +1,6 @@
"use server"
+import { auditLogActions, logAudit } from "@chatbotx.io/business"
import { db, eq } from "@chatbotx.io/database/client"
import { tagModel } from "@chatbotx.io/database/schema"
import { returnValidationErrors } from "next-safe-action"
@@ -17,12 +18,22 @@ export const updateTagAction = workspaceActionClient
.action(
async ({
parsedInput,
+ ctx: { user },
bindArgsParsedInputs: [workspaceId, id],
}: {
parsedInput: UpdateTagSchema
+ ctx: { user: { id: string } }
bindArgsParsedInputs: WorkspaceIdAndIdRequestParams
}) => {
- await updateTag({ workspaceId, id, parsedInput })
+ const updated = await updateTag({ workspaceId, id, parsedInput })
+ if (updated && !("validationErrors" in updated)) {
+ await logAudit({
+ workspaceId,
+ userId: user.id,
+ action: auditLogActions.TAG_UPDATED,
+ detail: `Etiqueta "${parsedInput.name}" atualizada`,
+ })
+ }
},
)
@@ -59,6 +70,13 @@ export const updateTag = async ({
.update(tagModel)
.set({
name: parsedInput.name,
+ // Cor sempre definida — se o usuário não enviou, mantém a default do schema.
+ // Pra emoji/description, undefined = não tocar, null = limpar campo.
+ ...(parsedInput.color !== undefined && { color: parsedInput.color }),
+ ...(parsedInput.emoji !== undefined && { emoji: parsedInput.emoji }),
+ ...(parsedInput.description !== undefined && {
+ description: parsedInput.description,
+ }),
})
.where(eq(tagModel.id, id))
.returning()
diff --git a/apps/builder/src/features/tags/api/private.ts b/apps/builder/src/features/tags/api/private.ts
index e963144c1..67a8ad3e2 100644
--- a/apps/builder/src/features/tags/api/private.ts
+++ b/apps/builder/src/features/tags/api/private.ts
@@ -14,7 +14,7 @@ const privateListWorkspaceTagsAPI = authorizedAPI
.route({
method: "GET",
path: "/workspaces/{workspaceId}/tags",
- summary: "List tags",
+ summary: "Listar tags",
tags: ["Tags"],
})
.input(listTagsRequest.and(withWorkspaceIdSchema))
@@ -26,7 +26,7 @@ const privateCreateWorkspaceTagAPI = authorizedAPI
.route({
method: "POST",
path: "/workspaces/{workspaceId}/tags",
- summary: "Create a tag",
+ summary: "Criar tag",
tags: ["Tags"],
})
.input(createTagRequest.and(withWorkspaceIdSchema))
@@ -41,7 +41,7 @@ const privateUpdateTagAPI = authorizedAPI
.route({
method: "PUT",
path: "/workspaces/{workspaceId}/tags/{id}",
- summary: "Update tag",
+ summary: "Atualizar tag",
tags: ["Tags"],
})
.input(
@@ -65,7 +65,7 @@ const privateDeleteTagsAPI = authorizedAPI
.route({
method: "DELETE",
path: "/workspaces/{workspaceId}/tags/{id}",
- summary: "Delete tag",
+ summary: "Excluir tag",
tags: ["Tags"],
})
.input(
diff --git a/apps/builder/src/features/tags/api/token-auth.ts b/apps/builder/src/features/tags/api/token-auth.ts
index 0f003ced2..cb2204daa 100644
--- a/apps/builder/src/features/tags/api/token-auth.ts
+++ b/apps/builder/src/features/tags/api/token-auth.ts
@@ -20,7 +20,7 @@ const listTagsWorkspaceTokenAPI = workspaceTokenAuthAPI
.route({
method: "GET",
path: "/v1/tags",
- summary: "Get all tags",
+ summary: "Obter todas as tags",
tags: ["Tags"],
})
.input(z.object({}))
@@ -40,7 +40,7 @@ const createTagWorkspaceTokenAPI = workspaceTokenAuthAPI
.route({
method: "POST",
path: "/v1/tags",
- summary: "Create a new tag",
+ summary: "Criar nova tag",
successStatus: 201,
tags: ["Tags"],
})
@@ -60,7 +60,7 @@ const findTagWorkspaceTokenAPI = workspaceTokenAuthAPI
.route({
method: "GET",
path: "/v1/tags/{id}",
- summary: "Get tag by id",
+ summary: "Obter tag pelo ID",
tags: ["Tags"],
})
.input(z.object({ id: zodBigintAsString() }))
@@ -83,7 +83,7 @@ const findTagByNameWorkspaceTokenAPI = workspaceTokenAuthAPI
.route({
method: "GET",
path: "/v1/tags/name/{name}",
- summary: "Get tag by name",
+ summary: "Obter tag pelo nome",
tags: ["Tags"],
})
.input(z.object({ name: z.string() }))
@@ -105,7 +105,7 @@ const updateTagWorkspaceTokenAPI = workspaceTokenAuthAPI
.route({
method: "PUT",
path: "/v1/tags/{id}",
- summary: "Update tag",
+ summary: "Atualizar tag",
tags: ["Tags"],
})
.input(
@@ -128,7 +128,7 @@ const deleteTagsWorkspaceTokenAPI = workspaceTokenAuthAPI
.route({
method: "DELETE",
path: "/v1/tags/{id}",
- summary: "Delete tag",
+ summary: "Excluir tag",
successStatus: 204,
tags: ["Tags"],
})
diff --git a/apps/builder/src/features/tags/delete-tag-dialog.tsx b/apps/builder/src/features/tags/delete-tag-dialog.tsx
index 734862e81..076992d65 100644
--- a/apps/builder/src/features/tags/delete-tag-dialog.tsx
+++ b/apps/builder/src/features/tags/delete-tag-dialog.tsx
@@ -12,18 +12,21 @@ import {
DialogTitle,
DialogTrigger,
} from "@chatbotx.io/ui/components/ui/dialog"
-import type { Row } from "@tanstack/react-table"
+import { Input } from "@chatbotx.io/ui/components/ui/input"
+import { Label } from "@chatbotx.io/ui/components/ui/label"
import { Loader, Trash } from "lucide-react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { useAction } from "next-safe-action/hooks"
-import type { ComponentPropsWithoutRef } from "react"
+import { type ComponentPropsWithoutRef, useEffect, useState } from "react"
import { toast } from "sonner"
import { deleteTagAction } from "./actions/delete-tag-action"
+type TagWithCount = TagModel & { contactsCount?: number | null }
+
type DeleteTagsDialogProps = ComponentPropsWithoutRef & {
workspaceId: string
- tags: Row["original"][]
+ tags: TagWithCount[]
showTrigger?: boolean
onSuccess?: () => void
onOpenChange?: (val: boolean) => void
@@ -40,6 +43,25 @@ export function DeleteTagsDialog({
const t = useTranslations()
const router = useRouter()
+ // Total de contatos atribuídos somando todas as tags selecionadas. Se 0,
+ // não exige confirmação numérica (Respond.io só pede pra tags com uso).
+ const totalContacts = tags.reduce(
+ (sum, tag) => sum + (tag.contactsCount ?? 0),
+ 0,
+ )
+ const requiresConfirm = totalContacts > 0
+ const expectedConfirm = String(totalContacts)
+
+ const [confirmInput, setConfirmInput] = useState("")
+ const isMatch = !requiresConfirm || confirmInput.trim() === expectedConfirm
+
+ // Limpa o input quando dialog fecha/abre.
+ useEffect(() => {
+ if (!props.open) {
+ setConfirmInput("")
+ }
+ }, [props.open])
+
const { execute, isPending } = useAction(
deleteTagAction.bind(null, workspaceId),
{
@@ -49,6 +71,7 @@ export function DeleteTagsDialog({
feature: t("fields.tag.label"),
}),
)
+ setConfirmInput("")
onSuccess?.()
onOpenChange?.(false)
router.refresh()
@@ -79,12 +102,37 @@ export function DeleteTagsDialog({
})}
- {t("messages.deleteConfirmation", {
- feature: t("fields.tag.label"),
- })}
+ {requiresConfirm
+ ? t("tags.delete.usageDescription", {
+ tagCount: tags.length,
+ contactCount: totalContacts,
+ })
+ : t("messages.deleteConfirmation", {
+ feature: t("fields.tag.label"),
+ })}
+ {requiresConfirm && (
+
+
+ {t("tags.delete.confirmInputLabel", {
+ count: totalContacts,
+ })}
+
+
+ setConfirmInput(e.target.value.replace(/[^0-9]/g, ""))
+ }
+ placeholder={expectedConfirm}
+ value={confirmInput}
+ />
+
+ )}
+
@@ -92,8 +140,8 @@ export function DeleteTagsDialog({
execute({
ids: (tags ?? []).map((tag) => tag.id),
diff --git a/apps/builder/src/features/tags/provider/tag-store.ts b/apps/builder/src/features/tags/provider/tag-store.ts
index aeb104b2e..dc05e6ba8 100644
--- a/apps/builder/src/features/tags/provider/tag-store.ts
+++ b/apps/builder/src/features/tags/provider/tag-store.ts
@@ -63,7 +63,7 @@ export const createTagStore = (props: Partial) =>
} catch (error: unknown) {
set({
error:
- error instanceof HTTPError ? error.message : "Failed to fetch tags",
+ error instanceof HTTPError ? error.message : "Falha ao buscar tags",
})
} finally {
set({ loading: false })
diff --git a/apps/builder/src/features/tags/schema/action.ts b/apps/builder/src/features/tags/schema/action.ts
index 22a1fd877..5e8dc3147 100644
--- a/apps/builder/src/features/tags/schema/action.ts
+++ b/apps/builder/src/features/tags/schema/action.ts
@@ -2,8 +2,25 @@ import { zodBigintAsString } from "@chatbotx.io/utils"
import { z } from "zod"
import { tagResource } from "./resource"
+// Aceita hex de 3 ou 6 chars (#abc, #aabbcc). Normalizamos no server pra 7.
+const hexColorSchema = z
+ .string()
+ .regex(
+ /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/,
+ "Cor inválida (use formato #RRGGBB)",
+ )
+
+// Emoji opcional — máximo 8 chars pra acomodar emojis ZWJ.
+const emojiSchema = z.string().trim().max(8).nullish()
+
+// Descrição opcional, até 280 chars (cabe num tooltip ou linha de tabela).
+const descriptionSchema = z.string().trim().max(280).nullish()
+
export const createTagRequest = z.object({
name: z.string().trim().min(1).max(255),
+ color: hexColorSchema.optional(),
+ emoji: emojiSchema,
+ description: descriptionSchema,
folderId: zodBigintAsString().nullish(),
syncToMessenger: z.boolean().nullish(),
})
@@ -16,5 +33,8 @@ export type CreateTagResponse = z.infer
export const updateTagSchema = z.object({
name: z.string().trim().min(1).max(255),
+ color: hexColorSchema.optional(),
+ emoji: emojiSchema,
+ description: descriptionSchema,
})
export type UpdateTagSchema = z.input
diff --git a/apps/builder/src/features/tags/schema/resource.ts b/apps/builder/src/features/tags/schema/resource.ts
index 900c10022..bf28abb18 100644
--- a/apps/builder/src/features/tags/schema/resource.ts
+++ b/apps/builder/src/features/tags/schema/resource.ts
@@ -11,5 +11,7 @@ export type TagResource = z.infer
export const publicTagResource = tagResource.pick({
id: true,
name: true,
+ color: true,
+ emoji: true,
})
export type PublicTagResource = z.infer
diff --git a/apps/builder/src/features/tags/tag-colors.ts b/apps/builder/src/features/tags/tag-colors.ts
new file mode 100644
index 000000000..1ac9ec2d0
--- /dev/null
+++ b/apps/builder/src/features/tags/tag-colors.ts
@@ -0,0 +1,134 @@
+/**
+ * Paleta + helper de estilo do chip de Etiqueta — pixel-perfect Respond.io.
+ *
+ * Pedro 2026-05-26 (após inspeção ao vivo via Chrome MCP):
+ * - Chip = trio (bg escuro saturado + text MUITO claro pastel + outline médio)
+ * - O "border fininho" que Pedro viu é na verdade `outline 1px sólido`,
+ * não `border` (border-width é 0px no Respond.io)
+ * - Mapping medido: amber bg `rgb(74,54,0)` text `rgb(255,235,209)` outline
+ * `rgb(118,87,0)`. Mesmo hue, S~mantido, L varia: bg=14-20%, outline=23-44%,
+ * text=91-96%
+ *
+ * Pra suportar qualquer cor user-input do swatch, derivamos a tripleta via
+ * HSL: mantém hue, satura mínimo 40%, e fixa L em 3 níveis.
+ */
+
+// 8 cores base — tons saturados médios pra ficarem bonitos no swatch da paleta.
+// O renderer converte cada uma pro trio bg/text/outline via HSL.
+export const TAG_PRESET_COLORS = [
+ "#64748B", // slate-500 (cinza neutro)
+ "#DC2626", // red-600
+ "#EA580C", // orange-600
+ "#D97706", // amber-600
+ "#16A34A", // green-600
+ "#2563EB", // blue-600
+ "#9333EA", // purple-600
+ "#DB2777", // pink-600
+] as const
+
+// Default = cinza neutro (1ª da paleta).
+export const DEFAULT_TAG_COLOR = TAG_PRESET_COLORS[0]
+
+// --- conversões HSL ---
+
+// Regex top-level (regra biome useTopLevelRegex).
+const HEX_6 = /^#([\da-fA-F]{6})$/
+
+function hexToRgb(hex: string): { r: number; g: number; b: number } {
+ const m = HEX_6.exec(hex)
+ if (!m) {
+ return { r: 100, g: 116, b: 139 } // fallback slate-500
+ }
+ // Decompõe via divisão/módulo em vez de bitwise (regra noBitwiseOperators).
+ const v = Number.parseInt(m[1], 16)
+ return {
+ r: Math.floor(v / 65_536) % 256,
+ g: Math.floor(v / 256) % 256,
+ b: v % 256,
+ }
+}
+
+function rgbToHsl(
+ r: number,
+ g: number,
+ b: number,
+): { h: number; s: number; l: number } {
+ const rn = r / 255
+ const gn = g / 255
+ const bn = b / 255
+ const max = Math.max(rn, gn, bn)
+ const min = Math.min(rn, gn, bn)
+ const l = (max + min) / 2
+ let h = 0
+ let s = 0
+ if (max !== min) {
+ const d = max - min
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
+ switch (max) {
+ case rn:
+ h = (gn - bn) / d + (gn < bn ? 6 : 0)
+ break
+ case gn:
+ h = (bn - rn) / d + 2
+ break
+ default:
+ h = (rn - gn) / d + 4
+ }
+ h *= 60
+ }
+ return { h, s, l }
+}
+
+function hslToCss(h: number, s: number, l: number): string {
+ // saturação mínima 40% pra evitar chip "lavado" quando user escolhe cinza.
+ // Pro cinza puro (s≈0) deixamos s=0 mesmo (hue irrelevante).
+ const sPct = Math.round(Math.max(s * 100, s > 0.02 ? 35 : 0))
+ const lPct = Math.round(Math.max(0, Math.min(100, l * 100)))
+ return `hsl(${Math.round(h)} ${sPct}% ${lPct}%)`
+}
+
+/**
+ * Estilo do chip RENDERIZADO (tabela + preview). Derivado da cor base do user.
+ * - backgroundColor: hue mantido, L=18% (escuro saturado)
+ * - color: hue mantido, L=93% (pastel claro)
+ * - outlineColor: hue mantido, L=24% (sutil — quase mesma luminosidade do bg
+ * pra borda ficar bem fina visualmente, conforme Pedro 2026-05-26 "borda
+ * muito grossa ainda")
+ *
+ * `outline` (não `border`) pra não deslocar layout. `outline-offset: -1px`
+ * desenha pra dentro do chip pra não vazar do container.
+ */
+export function getTagChipStyle(hex: string | null | undefined): {
+ backgroundColor: string
+ color: string
+ outline: string
+ outlineOffset: string
+} {
+ const safe = hex && HEX_6.test(hex) ? hex : DEFAULT_TAG_COLOR
+ const { r, g, b } = hexToRgb(safe)
+ const { h, s } = rgbToHsl(r, g, b)
+ const bg = hslToCss(h, s, 0.18)
+ const text = hslToCss(h, s, 0.93)
+ // L=24% é só 6 pontos acima do bg (L=18%) — borda sutil/fina visualmente.
+ const outlineColor = hslToCss(h, s, 0.24)
+ return {
+ backgroundColor: bg,
+ color: text,
+ outline: `1px solid ${outlineColor}`,
+ outlineOffset: "-1px",
+ }
+}
+
+/**
+ * Estilo do SWATCH da paleta (bolinha no dialog). Mantém o look pastel
+ * pra fácil distinção visual entre cores disponíveis.
+ */
+export function getSwatchStyle(hex: string): {
+ backgroundColor: string
+ borderColor: string
+} {
+ return {
+ backgroundColor: `${hex}40`, // ~25% alpha
+ borderColor: `${hex}80`, // ~50% alpha
+ }
+}
diff --git a/apps/builder/src/features/tags/tag-form-dialog.tsx b/apps/builder/src/features/tags/tag-form-dialog.tsx
new file mode 100644
index 000000000..8bf7231c4
--- /dev/null
+++ b/apps/builder/src/features/tags/tag-form-dialog.tsx
@@ -0,0 +1,439 @@
+"use client"
+
+import { rootFolderId } from "@chatbotx.io/database/partials"
+import type { TagModel } from "@chatbotx.io/database/types"
+import { Button } from "@chatbotx.io/ui/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@chatbotx.io/ui/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@chatbotx.io/ui/components/ui/form"
+import { Input } from "@chatbotx.io/ui/components/ui/input"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@chatbotx.io/ui/components/ui/popover"
+import { Skeleton } from "@chatbotx.io/ui/components/ui/skeleton"
+import { Textarea } from "@chatbotx.io/ui/components/ui/textarea"
+import { cn } from "@chatbotx.io/ui/lib/utils"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useHookFormAction } from "@next-safe-action/adapter-react-hook-form/hooks"
+import { CheckIcon, Loader2Icon, PlusIcon } from "lucide-react"
+import dynamic from "next/dynamic"
+import { useRouter } from "next/navigation"
+import { useTranslations } from "next-intl"
+import { useCallback, useEffect, useMemo, useState } from "react"
+import { useWatch } from "react-hook-form"
+import { toast } from "sonner"
+import { useCurrentTheme } from "@/hooks/use-current-theme"
+import { createTagAction } from "./actions/create-tag-action"
+import { updateTagAction } from "./actions/update-tag-action"
+import {
+ createTagRequest,
+ type UpdateTagSchema,
+ updateTagSchema,
+} from "./schema/action"
+import {
+ DEFAULT_TAG_COLOR,
+ getSwatchStyle,
+ getTagChipStyle,
+ TAG_PRESET_COLORS,
+} from "./tag-colors"
+
+// Carrega o picker dinamicamente — evita SSR (lib é pesada e só faz sentido client-side).
+const BaseEmojiPicker = dynamic(() => import("emoji-picker-react"), {
+ ssr: false,
+ loading: () => ,
+})
+
+// Valores do enum Theme da lib — copiados aqui pra evitar puxar o enum
+// (que carrega a lib inteira). Type-cast no uso é seguro porque a lib
+// aceita exatamente essas strings em runtime.
+const PICKER_THEME = { DARK: "dark", LIGHT: "light", AUTO: "auto" } as const
+
+type TagFormDialogProps = {
+ workspaceId: string
+ // Quando `tag` está presente o dialog opera em modo edit; senão é create.
+ tag?: TagModel | null
+ // No modo create o componente é controlado pelo botão "Criar Etiqueta" interno.
+ // No modo edit quem renderiza controla via open/onOpenChange.
+ open?: boolean
+ onOpenChange?: (val: boolean) => void
+ folderId?: string | null
+}
+
+export function TagFormDialog({
+ workspaceId,
+ tag,
+ open: controlledOpen,
+ onOpenChange,
+ folderId,
+}: TagFormDialogProps) {
+ const t = useTranslations()
+ const router = useRouter()
+ // Tema atual pra repassar pro emoji-picker-react (sem isso ele renderiza
+ // sempre em light, virou um quadrado branco horrível no app em dark mode).
+ // Cast pra `Theme` da lib — runtime aceita exatamente essas strings.
+ const currentTheme = useCurrentTheme()
+ const pickerTheme = (
+ currentTheme === "dark" ? PICKER_THEME.DARK : PICKER_THEME.LIGHT
+ ) as never
+ const isEdit = !!tag
+ const [internalOpen, setInternalOpen] = useState(false)
+ const open = controlledOpen ?? internalOpen
+ const setOpen = onOpenChange ?? setInternalOpen
+ // Quando o componente é controlado externamente (recebe `open`/`onOpenChange`),
+ // NÃO renderizamos o DialogTrigger interno — quem usa controla a abertura.
+ // Senão renderizamos só no modo create (botão "Criar Etiqueta" no toolbar).
+ // Sem essa checagem, o TagFormDialog em modo "update controlado" aparecia
+ // como uma barra azul gigante no meio da tela (bug visto pelo Pedro).
+ const isControlled = controlledOpen !== undefined
+ const showTrigger = !(isControlled || isEdit)
+
+ const createForm = useHookFormAction(
+ createTagAction.bind(null, workspaceId),
+ zodResolver(createTagRequest),
+ {
+ actionProps: {
+ onSuccess: () => {
+ toast.success(
+ t("messages.createdSuccess", { feature: t("fields.tag.label") }),
+ )
+ setOpen(false)
+ createForm.resetFormAndAction()
+ router.refresh()
+ },
+ onError: ({ error }: { error: { serverError?: string } }) => {
+ if (error.serverError) {
+ toast.error(error.serverError)
+ }
+ },
+ },
+ formProps: {
+ mode: "onChange",
+ defaultValues: {
+ name: "",
+ color: DEFAULT_TAG_COLOR,
+ emoji: "",
+ description: "",
+ folderId: null,
+ syncToMessenger: false,
+ },
+ },
+ errorMapProps: {},
+ },
+ )
+
+ const updateForm = useHookFormAction(
+ updateTagAction.bind(null, workspaceId, tag?.id ?? ""),
+ zodResolver(updateTagSchema),
+ {
+ actionProps: {
+ onSuccess: () => {
+ toast.success(
+ t("messages.updatedSuccess", { feature: t("fields.tag.label") }),
+ )
+ setOpen(false)
+ updateForm.resetFormAndAction()
+ router.refresh()
+ },
+ onError: ({ error }: { error: { serverError?: string } }) => {
+ if (error.serverError) {
+ toast.error(error.serverError)
+ }
+ },
+ },
+ formProps: {
+ mode: "onChange",
+ },
+ errorMapProps: {},
+ },
+ )
+
+ const { form, handleSubmitWithAction, resetFormAndAction } = isEdit
+ ? updateForm
+ : createForm
+
+ // Quando o usuário abre o create dentro de uma pasta específica, herda.
+ useEffect(() => {
+ if (!isEdit && folderId && folderId !== rootFolderId) {
+ createForm.form.setValue("folderId", folderId)
+ }
+ }, [folderId, isEdit, createForm.form])
+
+ // Pré-popula o form de edit ao trocar de tag.
+ useEffect(() => {
+ if (isEdit && tag) {
+ updateForm.form.reset({
+ name: tag.name,
+ color: tag.color ?? DEFAULT_TAG_COLOR,
+ emoji: tag.emoji ?? "",
+ description: tag.description ?? "",
+ } satisfies UpdateTagSchema)
+ }
+ }, [isEdit, tag, updateForm.form])
+
+ const handleOpenChange = useCallback(
+ (next: boolean) => {
+ setOpen(next)
+ if (!next) {
+ resetFormAndAction()
+ }
+ },
+ [setOpen, resetFormAndAction],
+ )
+
+ // Watch pra atualizar a preview chip ao vivo conforme o usuário muda.
+ // Usamos useWatch (vs form.watch) pra evitar problema de inferência de tipo
+ // — `form` aqui é união de 2 schemas (create vs update) e o overload de
+ // form.watch fica ambíguo. useWatch com `control` resolve cleanly.
+ const watched = useWatch({ control: form.control }) as {
+ color?: string
+ emoji?: string | null
+ name?: string
+ }
+ const watchedColor = watched.color ?? DEFAULT_TAG_COLOR
+ const watchedEmoji = watched.emoji ?? ""
+ const watchedName = watched.name ?? ""
+
+ const previewLabel = useMemo(
+ () => watchedName.trim() || t("fields.tag.label"),
+ [watchedName, t],
+ )
+
+ const title = isEdit
+ ? t("messages.editFeature", { feature: t("fields.tag.label") })
+ : t("messages.createFeature", { feature: t("fields.tag.label") })
+
+ return (
+
+ {showTrigger && (
+
+
+
+ {t("messages.createFeature", { feature: t("fields.tag.label") })}
+
+
+ )}
+
+
+ {title}
+ {t("tags.formDescription")}
+
+
+
+
+ {/* Linha 1 — Emoji (popover) + Nome (input) */}
+
+
+ {/* Linha 2 — Paleta de cores estilo Respond.io (8 swatches) */}
+ (
+
+ {t("tags.colorLabel")}
+
+
+ {TAG_PRESET_COLORS.map((c) => {
+ const isSelected = field.value === c
+ const swatch = getSwatchStyle(c)
+ return (
+ field.onChange(c)}
+ // Swatch fica em estilo pastel pra distinguir cores;
+ // quando selecionado, borda vira a cor cheia + check
+ // tb na cor cheia (visível sobre o pastel).
+ style={{
+ backgroundColor: swatch.backgroundColor,
+ borderColor: isSelected ? c : swatch.borderColor,
+ }}
+ type="button"
+ >
+ {isSelected ? (
+
+ ) : null}
+
+ )
+ })}
+
+
+
+
+ )}
+ />
+
+ {/* Linha 3 — Descrição opcional */}
+ (
+
+ {t("tags.descriptionLabel")}
+
+
+
+
+
+ )}
+ />
+
+ {/* Preview ao vivo do chip — ajuda usuário visualizar antes de salvar.
+ Estilo translúcido: bg com alpha + texto na cor saturada (igual Respond.io). */}
+
+
+ {t("tags.preview")}
+
+
+ {watchedEmoji ? {watchedEmoji} : null}
+ {previewLabel}
+
+
+
+
+ setOpen(false)}
+ size="sm"
+ type="button"
+ variant="ghost"
+ >
+ {t("actions.cancel")}
+
+
+ {form.formState.isSubmitting && (
+
+ )}
+ {t("actions.confirm")}
+
+
+
+
+
+
+ )
+}
diff --git a/apps/builder/src/features/tags/tags-table-columns.tsx b/apps/builder/src/features/tags/tags-table-columns.tsx
index f9e702f9a..2dc29acd5 100644
--- a/apps/builder/src/features/tags/tags-table-columns.tsx
+++ b/apps/builder/src/features/tags/tags-table-columns.tsx
@@ -21,13 +21,14 @@ import type { ColumnDef } from "@tanstack/react-table"
import {
EllipsisVerticalIcon,
FingerprintIcon,
- FolderUpIcon,
PencilIcon,
Trash2Icon,
} from "lucide-react"
import type { useTranslations } from "next-intl"
import type { Dispatch, SetStateAction } from "react"
+import { getTagChipStyle } from "./tag-colors"
+
type TagWithContacts = TagModel & {
contactsCount?: number
}
@@ -36,19 +37,44 @@ type GetColumnsProps = {
setRowAction: Dispatch | null>>
handleCopy: (id: string) => void
t: ReturnType
+ locale: string
+}
+
+// Formata datas no padrão Respond.io pt-BR: "abr 21, 2026 12:19 PM".
+// Locale dinâmico — passa "en"/"vi" e respeita.
+function formatRespondDate(
+ value: Date | string | null,
+ locale: string,
+): string {
+ if (!value) {
+ return "—"
+ }
+ const d = typeof value === "string" ? new Date(value) : value
+ if (Number.isNaN(d.getTime())) {
+ return "—"
+ }
+ return new Intl.DateTimeFormat(locale, {
+ month: "short",
+ day: "2-digit",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: true,
+ }).format(d)
}
export function getTagColumns({
setRowAction,
handleCopy,
t,
+ locale,
}: GetColumnsProps): ColumnDef[] {
return [
{
id: "select",
header: ({ table }) => (
(
row.toggleSelected(Boolean(value))}
@@ -77,16 +103,29 @@ export function getTagColumns({
header: ({ column }) => (
),
- cell: ({ row }) => (
-
-
- {row.original.name}
-
-
- {row.original.name}
-
-
- ),
+ cell: ({ row }) => {
+ const { name, color, emoji } = row.original
+ return (
+
+
+
+ {emoji ? {emoji} : null}
+ {name}
+
+
+
+ {name}
+
+
+ )
+ },
size: 300,
meta: {
label: t("fields.name.label"),
@@ -97,29 +136,83 @@ export function getTagColumns({
enableSorting: true,
},
{
- id: "contacts",
- accessorKey: "contacts",
+ id: "description",
+ accessorKey: "description",
header: ({ column }) => (
),
- cell: ({ row }) => {row.original.contactsCount ?? 0}
,
- size: 50,
+ cell: ({ row }) => {
+ const description = row.original.description
+ if (!description) {
+ return —
+ }
+ return (
+
+
+
+ {description}
+
+
+
+ {description}
+
+
+ )
+ },
+ size: 280,
+ enableSorting: false,
+ },
+ {
+ id: "createdAt",
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => (
+
+ {formatRespondDate(row.original.createdAt, locale)}
+
+ ),
+ size: 180,
+ enableSorting: true,
meta: {
- label: t("fields.contacts.label"),
+ label: t("fields.addedAt.label"),
+ },
+ },
+ {
+ id: "updatedAt",
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => (
+
+ {formatRespondDate(row.original.updatedAt, locale)}
+
+ ),
+ size: 180,
+ enableSorting: true,
+ meta: {
+ label: t("fields.lastEditedAt.label"),
},
- enableHiding: false,
},
{
id: "actions",
- header: "Actions",
+ header: t("actions.actions"),
cell: ({ row }) => (
@@ -134,12 +227,6 @@ export function getTagColumns({
{t("actions.edit")}
⌘⌫
- setRowAction({ row, variant: "move" })}
- >
-
- {t("actions.move")}
-
handleCopy(`${row.original.id}`)}>
{t("actions.getID")}
diff --git a/apps/builder/src/features/tags/tags-table-toolbar-actions.tsx b/apps/builder/src/features/tags/tags-table-toolbar-actions.tsx
index 2665c14f5..fb63d7ce5 100644
--- a/apps/builder/src/features/tags/tags-table-toolbar-actions.tsx
+++ b/apps/builder/src/features/tags/tags-table-toolbar-actions.tsx
@@ -1,54 +1,36 @@
"use client"
import type { TagModel } from "@chatbotx.io/database/types"
-import { Button } from "@chatbotx.io/ui/components/ui/button"
import type { Table } from "@tanstack/react-table"
-import { FolderUpIcon } from "lucide-react"
-import { useTranslations } from "next-intl"
-import { useState } from "react"
-import { ChangeFolderDialog } from "../folders/change-folder"
import { DeleteTagsDialog } from "./delete-tag-dialog"
+type TagWithCount = TagModel & { contactsCount?: number | null }
+
type TagsTableToolbarActionsProps = {
- table: Table
+ table: Table
workspaceId: string
}
+/**
+ * Toolbar quando há linhas selecionadas — só Excluir.
+ * Pedro removeu o conceito de pasta de /tags em 2026-05-23, então o botão
+ * "Mover" + ChangeFolderDialog foram removidos junto (não faz sentido mover
+ * pra pasta se a UI de pasta não existe mais).
+ */
export function TagsTableToolbarActions({
table,
workspaceId,
}: TagsTableToolbarActionsProps) {
- const t = useTranslations()
- const [openChangeFolder, setOpenChangeFolder] = useState(false)
-
return (
{table.getFilteredSelectedRowModel().rows.length > 0 ? (
- <>
-