chore(style): fix rubocop offenses and update typing indicators

This commit is contained in:
Rodrigo Borba 2026-02-25 15:01:48 -03:00
parent c026ee2fc8
commit 0e7dc282c4
183 changed files with 15842 additions and 2639 deletions

View File

@ -1,297 +1,101 @@
# Learn about the various environment variables at
# https://www.chatwoot.com/docs/self-hosted/configuration/environment-variables/#rails-production-variables
# ============================================
# Synkra AIOS Environment Configuration
# ============================================
# Copy this file to .env and fill in your actual values
# DO NOT commit .env with real credentials
# ============================================
# Used to verify the integrity of signed cookies. so ensure a secure value is set
# SECRET_KEY_BASE should be alphanumeric. Avoid special characters or symbols.
# Use `rake secret` to generate this variable
SECRET_KEY_BASE=replace_with_lengthy_secure_hex
# --------------------------------------------
# LLM Providers
# --------------------------------------------
# Active Record Encryption keys (required for MFA/2FA functionality)
# Generate these keys by running: rails db:encryption:init
# IMPORTANT: Use different keys for each environment (development, staging, production)
# ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=
# ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=
# ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=
# DeepSeek API (for claude-free command)
# Get your key at: https://platform.deepseek.com/api_keys
# Cost: ~$0.14/M tokens with tool calling support
DEEPSEEK_API_KEY=
# Replace with the URL you are planning to use for your app
FRONTEND_URL=http://0.0.0.0:3000
# To use a dedicated URL for help center pages
# HELPCENTER_URL=http://0.0.0.0:3000
# OpenRouter API (for multi-model routing)
# Get your key at: https://openrouter.ai/keys
OPENROUTER_API_KEY=
# If the variable is set, all non-authenticated pages would fallback to the default locale.
# Whenever a new account is created, the default language will be DEFAULT_LOCALE instead of en
# DEFAULT_LOCALE=en
# Anthropic API (direct, if not using Claude Max subscription)
# Get your key at: https://console.anthropic.com/
ANTHROPIC_API_KEY=
# If you plan to use CDN for your assets, set Asset CDN Host
ASSET_CDN_HOST=
# OpenAI API Key - Get yours at: https://platform.openai.com/api-keys
OPENAI_API_KEY=
# Force all access to the app over SSL, default is set to false
FORCE_SSL=false
# --------------------------------------------
# Search & Research Tools
# --------------------------------------------
# This lets you control new sign ups on your chatwoot installation
# true : default option, allows sign ups
# false : disables all the end points related to sign ups
# api_only: disables the UI for signup, but you can create sign ups via the account apis
ENABLE_ACCOUNT_SIGNUP=false
# Exa Search API (web search for agents)
# Get your key at: https://exa.ai/
EXA_API_KEY=
# Redis config
# specify the configs via single URL or individual variables
# ref: https://www.iana.org/assignments/uri-schemes/prov/redis
# You can also use the following format for the URL: redis://:password@host:port/db_number
REDIS_URL=redis://redis:6379
# If you are using docker-compose, set this variable's value to be any string,
# which will be the password for the redis service running inside the docker-compose
# to make it secure
REDIS_PASSWORD=
# Redis Sentinel can be used by passing list of sentinel host and ports e,g. sentinel_host1:port1,sentinel_host2:port2
REDIS_SENTINELS=
# Redis sentinel master name is required when using sentinel, default value is "mymaster".
# You can find list of master using "SENTINEL masters" command
REDIS_SENTINEL_MASTER_NAME=
# Context7 (library documentation lookup)
# Usually free, no key required for basic usage
CONTEXT7_API_KEY=
# By default Chatwoot will pass REDIS_PASSWORD as the password value for sentinels
# Use the following environment variable to customize passwords for sentinels.
# Use empty string if sentinels are configured with out passwords
# REDIS_SENTINEL_PASSWORD=
# --------------------------------------------
# Database & Backend
# --------------------------------------------
# Redis premium breakage in heroku fix
# enable the following configuration
# ref: https://github.com/chatwoot/chatwoot/issues/2420
# REDIS_OPENSSL_VERIFY_MODE=none
# Supabase (database, auth, storage)
# Get from your Supabase project settings
SUPABASE_URL=
SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
# Postgres Database config variables
# You can leave POSTGRES_DATABASE blank. The default name of
# the database in the production environment is chatwoot_production
# POSTGRES_DATABASE=
POSTGRES_HOST=postgres
POSTGRES_USERNAME=postgres
POSTGRES_PASSWORD=
RAILS_ENV=development
# Changes the Postgres query timeout limit. The default is 14 seconds. Modify only when required.
# POSTGRES_STATEMENT_TIMEOUT=14s
RAILS_MAX_THREADS=5
# --------------------------------------------
# Version Control & CI/CD
# --------------------------------------------
# The email from which all outgoing emails are sent
# could user either `email@yourdomain.com` or `BrandName <email@yourdomain.com>`
MAILER_SENDER_EMAIL=Chatwoot <accounts@chatwoot.com>
# GitHub Token (for GitHub CLI and API access)
# Create at: https://github.com/settings/tokens
GITHUB_TOKEN=
#SMTP domain key is set up for HELO checking
SMTP_DOMAIN=chatwoot.com
# Set the value to "mailhog" if using docker-compose for development environments,
# Set the value as "localhost" or your SMTP address in other environments
# If SMTP_ADDRESS is empty, Chatwoot would try to use sendmail(postfix)
SMTP_ADDRESS=
SMTP_PORT=1025
SMTP_USERNAME=
SMTP_PASSWORD=
# plain,login,cram_md5
SMTP_AUTHENTICATION=
SMTP_ENABLE_STARTTLS_AUTO=true
# Can be: 'none', 'peer', 'client_once', 'fail_if_no_peer_cert', see http://api.rubyonrails.org/classes/ActionMailer/Base.html
SMTP_OPENSSL_VERIFY_MODE=peer
# Comment out the following environment variables if required by your SMTP server
# SMTP_TLS=
# SMTP_SSL=
# SMTP_OPEN_TIMEOUT
# SMTP_READ_TIMEOUT
# --------------------------------------------
# Project Management
# --------------------------------------------
# Mail Incoming
# This is the domain set for the reply emails when conversation continuity is enabled
MAILER_INBOUND_EMAIL_DOMAIN=
# Set this to the appropriate ingress channel with regards to incoming emails
# Possible values are :
# relay for Exim, Postfix, Qmail
# mailgun for Mailgun
# mandrill for Mandrill
# postmark for Postmark
# sendgrid for Sendgrid
# ses for Amazon SES
RAILS_INBOUND_EMAIL_SERVICE=
# Use one of the following based on the email ingress service
# Ref: https://edgeguides.rubyonrails.org/action_mailbox_basics.html
# Set this to a password of your choice and use it in the Inbound webhook
RAILS_INBOUND_EMAIL_PASSWORD=
# ClickUp API (if using ClickUp integration)
# Get from: ClickUp Settings > Apps > API Token
CLICKUP_API_KEY=
MAILGUN_INGRESS_SIGNING_KEY=
MANDRILL_INGRESS_API_KEY=
# --------------------------------------------
# Automation & Workflows
# --------------------------------------------
# SNS topic ARN for ActionMailbox (format: arn:aws:sns:region:account-id:topic-name)
# Configure only if the rails_inbound_email_service = ses
ACTION_MAILBOX_SES_SNS_TOPIC=
# N8N (workflow automation)
# From your N8N instance settings
N8N_API_KEY=
N8N_WEBHOOK_URL=
# Creating Your Inbound Webhook Instructions for Postmark and Sendgrid:
# Inbound webhook URL format:
# https://actionmailbox:[YOUR_RAILS_INBOUND_EMAIL_PASSWORD]@[YOUR_CHATWOOT_DOMAIN.COM]/rails/action_mailbox/[RAILS_INBOUND_EMAIL_SERVICE]/inbound_emails
# Note: Replace the values inside the brackets; do not include the brackets themselves.
# Example: https://actionmailbox:mYRandomPassword3@chatwoot.example.com/rails/action_mailbox/postmark/inbound_emails
# For Postmark
# Ensure the 'Include raw email content in JSON payload' checkbox is selected in the inbound webhook section.
# --------------------------------------------
# Monitoring & Analytics
# --------------------------------------------
# Storage
ACTIVE_STORAGE_SERVICE=local
# Sentry (error tracking)
SENTRY_DSN=
# Amazon S3
# documentation: https://www.chatwoot.com/docs/configuring-s3-bucket-as-cloud-storage
S3_BUCKET_NAME=
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_REGION=
# --------------------------------------------
# Cloud Providers
# --------------------------------------------
# S3-compatible storage (e.g., Cloudflare R2, MinIO, DigitalOcean Spaces)
# Set ACTIVE_STORAGE_SERVICE=s3_compatible to use this
# STORAGE_ACCESS_KEY_ID=
# STORAGE_SECRET_ACCESS_KEY=
# STORAGE_REGION=
# STORAGE_BUCKET_NAME=
# STORAGE_ENDPOINT=
# STORAGE_FORCE_PATH_STYLE=true
# STORAGE_REQUEST_CHECKSUM_CALCULATION=when_required
# STORAGE_RESPONSE_CHECKSUM_VALIDATION=when_required
# Railway (deployment)
RAILWAY_TOKEN=
# Log settings
# Disable if you want to write logs to a file
RAILS_LOG_TO_STDOUT=true
LOG_LEVEL=info
LOG_SIZE=500
# Configure this environment variable if you want to use lograge instead of rails logger
#LOGRAGE_ENABLED=true
# Vercel (deployment)
VERCEL_TOKEN=
### This environment variables are only required if you are setting up social media channels
# --------------------------------------------
# AIOS Core Configuration
# --------------------------------------------
NODE_ENV=development
AIOS_VERSION=2.2.0
# Facebook
# documentation: https://www.chatwoot.com/docs/facebook-setup
FB_VERIFY_TOKEN=
FB_APP_SECRET=
FB_APP_ID=
# https://developers.facebook.com/docs/messenger-platform/instagram/get-started#app-dashboard
IG_VERIFY_TOKEN=
# Twitter
# documentation: https://www.chatwoot.com/docs/twitter-app-setup
TWITTER_APP_ID=
TWITTER_CONSUMER_KEY=
TWITTER_CONSUMER_SECRET=
TWITTER_ENVIRONMENT=
#slack integration
SLACK_CLIENT_ID=
SLACK_CLIENT_SECRET=
# Google OAuth
GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET=
GOOGLE_OAUTH_CALLBACK_URL=
### Change this env variable only if you are using a custom build mobile app
## Mobile app env variables
IOS_APP_ID=L7YLMN4634.com.chatwoot.app
ANDROID_BUNDLE_ID=com.chatwoot.app
# https://developers.google.com/android/guides/client-auth (use keytool to print the fingerprint in the first section)
ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:D4:5D:D4:53:F8:3B:FB:D3:C6:28:64:1D:AA:08:1E:D8
### Smart App Banner
# https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/PromotingAppswithAppBanners/PromotingAppswithAppBanners.html
# You can find your app-id in https://itunesconnect.apple.com
#IOS_APP_IDENTIFIER=1495796682
## Push Notification
## generate a new key value here : https://d3v.one/vapid-key-generator/
# VAPID_PUBLIC_KEY=
# VAPID_PRIVATE_KEY=
#
# for mobile apps
# FCM_SERVER_KEY=
### APM and Error Monitoring configurations
## Elastic APM
## https://www.elastic.co/guide/en/apm/agent/ruby/current/getting-started-rails.html
# ELASTIC_APM_SERVER_URL=
# ELASTIC_APM_SECRET_TOKEN=
## Sentry
# SENTRY_DSN=
## Scout
## https://scoutapm.com/docs/ruby/configuration
# SCOUT_KEY=YOURKEY
# SCOUT_NAME=YOURAPPNAME (Production)
# SCOUT_MONITOR=true
## NewRelic
# https://docs.newrelic.com/docs/agents/ruby-agent/configuration/ruby-agent-configuration/
# NEW_RELIC_LICENSE_KEY=
# Set this to true to allow newrelic apm to send logs.
# This is turned off by default.
# NEW_RELIC_APPLICATION_LOGGING_ENABLED=
## Datadog
## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables
# DD_TRACE_AGENT_URL=
# MaxMindDB API key to download GeoLite2 City database
# IP_LOOKUP_API_KEY=
## Rack Attack configuration
## To prevent and throttle abusive requests
# ENABLE_RACK_ATTACK=true
# RACK_ATTACK_LIMIT=300
# ENABLE_RACK_ATTACK_WIDGET_API=true
# Comma-separated list of trusted IPs that bypass Rack Attack throttling rules
# RACK_ATTACK_ALLOWED_IPS=127.0.0.1,::1,192.168.0.10
## Running chatwoot as an API only server
## setting this value to true will disable the frontend dashboard endpoints
# CW_API_ONLY_SERVER=false
## Development Only Config
# if you want to use letter_opener for local emails
# LETTER_OPENER=true
# meant to be used in github codespaces
# WEBPACKER_DEV_SERVER_PUBLIC=
# If you want to use official mobile app,
# the notifications would be relayed via a Chatwoot server
ENABLE_PUSH_RELAY_SERVER=true
# Stripe API key
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
# Set to true if you want to upload files to cloud storage using the signed url
# Make sure to follow https://edgeguides.rubyonrails.org/active_storage_overview.html#cross-origin-resource-sharing-cors-configuration on the cloud storage after setting this to true.
DIRECT_UPLOADS_ENABLED=
#MS OAUTH creds
AZURE_APP_ID=
AZURE_APP_SECRET=
## Advanced configurations
## Change these values to fine tune performance
# control the concurrency setting of sidekiq
# SIDEKIQ_CONCURRENCY=10
# Enable verbose logging each time a job is dequeued in Sidekiq
# ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false
# AI powered features
## OpenAI key
# OPENAI_API_KEY=
# Housekeeping/Performance related configurations
# Set to true if you want to remove stale contact inboxes
# contact_inboxes with no conversation older than 90 days will be removed
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
# REDIS_ALFRED_SIZE=10
# REDIS_VELMA_SIZE=10
# Baileys API Whatsapp provider
BAILEYS_PROVIDER_DEFAULT_CLIENT_NAME=Chatwoot
BAILEYS_PROVIDER_DEFAULT_URL=http://localhost:3025
BAILEYS_PROVIDER_DEFAULT_API_KEY=
RESEND_API_KEY=
# --------------------------------------------
# Custom Configuration
# --------------------------------------------
# Add your custom API keys below

120
.github/workflows/deploy_ghcr.yml vendored Normal file
View File

@ -0,0 +1,120 @@
name: Build and Push to GHCR (multi-arch)
on:
push:
branches:
- chatwoot-jasmine
- main
workflow_dispatch:
env:
IMAGE_NAME: ghcr.io/${{ github.repository }}
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-22.04-arm
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Prepare
run: |
platform="${{ matrix.platform }}"
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
echo "CACHE_SCOPE=${platform//\//-}-${GITHUB_REF_NAME}" >> "$GITHUB_ENV"
echo "CACHE_SCOPE_FALLBACK=${platform//\//-}-main" >> "$GITHUB_ENV"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/Dockerfile
platforms: ${{ matrix.platform }}
push: true
build-args: |
RAILS_ENV=production
NODE_ENV=production
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: |
type=gha,scope=${{ env.CACHE_SCOPE }}
type=gha,scope=${{ env.CACHE_SCOPE_FALLBACK }}
type=registry,ref=${{ env.IMAGE_NAME }}:buildcache-${{ env.PLATFORM_PAIR }}
cache-to: |
type=gha,mode=max,scope=${{ env.CACHE_SCOPE }}
type=registry,ref=${{ env.IMAGE_NAME }}:buildcache-${{ env.PLATFORM_PAIR }},mode=max
- name: Export digest
run: |
mkdir -p "${{ runner.temp }}/digests"
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs: [build]
permissions:
packages: write
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create \
-t "${{ env.IMAGE_NAME }}:latest" \
-t "${{ env.IMAGE_NAME }}:v${{ github.run_number }}" \
$(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect "${{ env.IMAGE_NAME }}:latest"

25
.gitignore vendored
View File

@ -103,3 +103,28 @@ CLAUDE.local.md
.pnpm-store/*
local/
Procfile.worktree
# Environment & Secrets (AIOS)
.env.local
.env.*.local
*.key
*.pem
# Dependencies (AIOS)
node_modules/
# Build & Logs (AIOS)
dist/
build/
logs/
# IDE & OS (AIOS)
Thumbs.db
.idea/
# AIOS Local (AIOS)
.aios-core/
.aios/
.claude/
.env.aios
.env.backup*

View File

@ -1,11 +1,23 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
# Ensure hooks always use project Ruby (rbenv + .ruby-version), not system Ruby.
export PATH="$HOME/.rbenv/bin:$PATH"
if command -v rbenv >/dev/null 2>&1; then
eval "$(rbenv init -)"
if [ -f .ruby-version ]; then
rbenv shell "$(cat .ruby-version)"
fi
fi
# lint js and vue files
npx --no-install lint-staged
# lint only staged ruby files
git diff --name-only --cached | xargs ls -1 2>/dev/null | grep '\.rb$' | xargs bundle exec rubocop --force-exclusion
STAGED_RB_FILES=$(git diff --name-only --cached --diff-filter=ACMR -- '*.rb')
if [ -n "$STAGED_RB_FILES" ]; then
echo "$STAGED_RB_FILES" | xargs bundle exec rubocop --force-exclusion
fi
# stage rubocop changes to files
# git diff --name-only --cached | xargs git add

View File

@ -1,3 +1,5 @@
inherit_from: .rubocop_todo.yml
plugins:
- rubocop-performance
- rubocop-rails
@ -18,11 +20,13 @@ Metrics/ClassLength:
Exclude:
- 'app/models/message.rb'
- 'app/models/conversation.rb'
- 'enterprise/app/services/captain/tools/generate_pix_tool.rb'
Metrics/MethodLength:
Max: 19
Exclude:
- 'enterprise/lib/captain/agent.rb'
- 'enterprise/app/services/captain/tools/generate_pix_tool.rb'
RSpec/ExampleLength:
Max: 50
@ -177,10 +181,10 @@ Metrics/AbcSize:
Max: 26
Exclude:
- 'app/controllers/concerns/auth_helper.rb'
- 'app/models/integrations/hook.rb'
- 'app/models/canned_response.rb'
- 'app/models/telegram_bot.rb'
- 'enterprise/app/services/captain/tools/generate_pix_tool.rb'
Rails/RenderInline:
Exclude:
@ -231,6 +235,9 @@ AllCops:
- 'tmp/**/*'
- 'storage/**/*'
- 'db/migrate/20230426130150_init_schema.rb'
- 'reference/**/*'
- '.aios-core/**/*'
- '.claude/**/*'
FactoryBot/SyntaxMethods:
Enabled: false

158
.rubocop_todo.yml Normal file
View File

@ -0,0 +1,158 @@
# This configuration was generated by
# `rubocop --auto-gen-config`
# on 2026-02-25 17:56:28 UTC using RuboCop version 1.75.6.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new
# versions of RuboCop, may require this file to be generated again.
# Offense count: 2
Chatwoot/AttachmentDownload:
Exclude:
- 'app/services/whatsapp/providers/evolution_service.rb'
- 'app/services/whatsapp/providers/wuzapi_service.rb'
# Offense count: 8
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: Max, AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns, SplitStrings.
# URISchemes: http, https
Layout/LineLength:
Exclude:
- 'app/controllers/public/api/v1/captain/inter_webhooks_controller.rb'
- 'app/models/channel/whatsapp.rb'
- 'app/services/whatsapp/providers/wuzapi_service.rb'
- 'app/services/wuzapi/client.rb'
- 'enterprise/app/services/captain/assistant/agent_runner_service.rb'
# Offense count: 2
# Configuration parameters: IgnoreLiteralBranches, IgnoreConstantBranches, IgnoreDuplicateElseBranch.
Lint/DuplicateBranch:
Exclude:
- 'app/services/whatsapp/providers/wuzapi/payload_parser.rb'
- 'app/services/whatsapp/providers/wuzapi_service.rb'
# Offense count: 1
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: AutoCorrect, AllowUnusedKeywordArguments, IgnoreEmptyMethods, IgnoreNotImplementedMethods, NotImplementedExceptions.
# NotImplementedExceptions: NotImplementedError
Lint/UnusedMethodArgument:
Exclude:
- 'app/services/whatsapp/providers/evolution_service.rb'
# Offense count: 29
# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes, Max.
Metrics/AbcSize:
Exclude:
- 'app/models/channel/whatsapp.rb'
- 'app/services/evolution_api/client.rb'
- 'app/services/whatsapp/decryption_service.rb'
- 'app/services/whatsapp/incoming_message_wuzapi_service.rb'
- 'app/services/whatsapp/providers/evolution_api/payload_parser.rb'
- 'app/services/whatsapp/providers/wuzapi/payload_parser.rb'
- 'app/services/whatsapp/providers/wuzapi_service.rb'
- 'app/services/wuzapi/client.rb'
- 'enterprise/app/services/captain/inter/auth_service.rb'
- 'enterprise/app/services/captain/inter/cob_service.rb'
- 'enterprise/app/services/captain/inter/webhook_setup_service.rb'
- 'enterprise/app/services/captain/reservations/marker_builder.rb'
- 'enterprise/app/services/captain/tools/generate_pix_tool.rb'
# Offense count: 6
# Configuration parameters: CountComments, Max, CountAsOne.
Metrics/ClassLength:
Exclude:
- 'app/models/channel/whatsapp.rb'
- 'app/services/whatsapp/incoming_message_wuzapi_service.rb'
- 'app/services/whatsapp/providers/wuzapi/payload_parser.rb'
- 'app/services/wuzapi/client.rb'
- 'enterprise/app/services/captain/assistant/agent_runner_service.rb'
- 'enterprise/app/services/captain/tools/generate_pix_tool.rb'
# Offense count: 41
# Configuration parameters: AllowedMethods, AllowedPatterns.
Metrics/CyclomaticComplexity:
Max: 23
# Offense count: 29
# Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns.
Metrics/MethodLength:
Exclude:
- 'app/controllers/public/api/v1/captain/inter_webhooks_controller.rb'
- 'app/models/channel/whatsapp.rb'
- 'app/services/evolution_api/client.rb'
- 'app/services/whatsapp/decryption_service.rb'
- 'app/services/whatsapp/incoming_message_wuzapi_service.rb'
- 'app/services/whatsapp/providers/evolution_api/payload_parser.rb'
- 'app/services/whatsapp/providers/wuzapi/payload_parser.rb'
- 'app/services/whatsapp/providers/wuzapi_service.rb'
- 'app/services/wuzapi/client.rb'
- 'enterprise/app/services/captain/assistant/agent_runner_service.rb'
- 'enterprise/app/services/captain/inter/webhook_setup_service.rb'
- 'enterprise/app/services/captain/reservations/marker_builder.rb'
- 'enterprise/app/services/captain/tools/generate_pix_tool.rb'
# Offense count: 1
# Configuration parameters: CountKeywordArgs, MaxOptionalParameters.
Metrics/ParameterLists:
Max: 6
# Offense count: 29
# Configuration parameters: AllowedMethods, AllowedPatterns.
Metrics/PerceivedComplexity:
Max: 25
# Offense count: 2
# Configuration parameters: MinNameLength, AllowNamesEndingInNumbers, AllowedNames, ForbiddenNames.
# AllowedNames: as, at, by, cc, db, id, if, in, io, ip, of, on, os, pp, to
Naming/MethodParameterName:
Exclude:
- 'app/services/whatsapp/decryption_service.rb'
# Offense count: 13
# Configuration parameters: IgnoreNameless, IgnoreSymbolicNames.
RSpec/VerifiedDoubles:
Exclude:
- 'spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb'
# Offense count: 1
Rails/AfterCommitOverride:
Exclude:
- 'app/models/channel/whatsapp.rb'
# Offense count: 1
# Configuration parameters: Include.
# Include: **/app/models/**/*.rb
Rails/HasManyOrHasOneDependent:
Exclude:
- 'enterprise/app/models/captain/brand.rb'
# Offense count: 5
# Configuration parameters: IgnoreScopes, Include.
# Include: **/app/models/**/*.rb
Rails/InverseOf:
Exclude:
- 'enterprise/app/models/captain/brand.rb'
- 'enterprise/app/models/captain/reservation.rb'
# Offense count: 5
# Configuration parameters: ForbiddenMethods, AllowedMethods.
# ForbiddenMethods: decrement!, decrement_counter, increment!, increment_counter, insert, insert!, insert_all, insert_all!, toggle!, touch, touch_all, update_all, update_attribute, update_column, update_columns, update_counters, upsert, upsert_all
Rails/SkipsModelValidations:
Exclude:
- 'app/controllers/api/v1/accounts/inboxes_controller.rb'
- 'enterprise/app/controllers/api/v1/accounts/captain/inboxes_controller.rb'
- 'enterprise/app/services/captain/payments/confirmation_service.rb'
- 'enterprise/app/services/captain/tools/generate_pix_tool.rb'
# Offense count: 3
# Configuration parameters: TransactionMethods.
Rails/TransactionExitStatement:
Exclude:
- 'app/services/whatsapp/incoming_message_wuzapi_service.rb'
# Offense count: 2
# Configuration parameters: MinBranchesCount.
Style/HashLikeCase:
Exclude:
- 'app/services/whatsapp/providers/evolution_api/payload_parser.rb'
- 'app/services/whatsapp/providers/wuzapi/payload_parser.rb'

259
AGENTS.md
View File

@ -1,104 +1,197 @@
# Chatwoot Development Guidelines
# CLAUDE.md
## Build / Test / Lint
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
- **Setup**: `bundle install && pnpm install`
- **Run Dev**: `pnpm dev` or `overmind start -f ./Procfile.dev`
- **Seed Local Test Data**: `bundle exec rails db:seed` (quickly populates minimal data for standard feature verification)
- **Seed Search Test Data**: `bundle exec rails search:setup_test_data` (bulk fixture generation for search/performance/manual load scenarios)
- **Seed Account Sample Data (richer test data)**: `Seeders::AccountSeeder` is available as an internal utility and is exposed through Super Admin `Accounts#seed`, but can be used directly in dev workflows too:
- UI path: Super Admin → Accounts → Seed (enqueues `Internal::SeedAccountJob`).
- CLI path: `bundle exec rails runner "Internal::SeedAccountJob.perform_now(Account.find(<id>))"` (or call `Seeders::AccountSeeder.new(account: Account.find(<id>)).perform!` directly).
- **Lint JS/Vue**: `pnpm eslint` / `pnpm eslint:fix`
- **Lint Ruby**: `bundle exec rubocop -a`
- **Test JS**: `pnpm test` or `pnpm test:watch`
- **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb`
- **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER`
- **Run Project**: `overmind start -f Procfile.dev`
- **Ruby Version**: Manage Ruby via `rbenv` and install the version listed in `.ruby-version` (e.g., `rbenv install $(cat .ruby-version)`)
- **rbenv setup**: Before running any `bundle` or `rspec` commands, init rbenv in your shell (`eval "$(rbenv init -)"`) so the correct Ruby/Bundler versions are used
- Always prefer `bundle exec` for Ruby CLI tasks (rspec, rake, rubocop, etc.)
## Project Overview
## Code Style
This is a **Chatwoot** customer engagement platform (open-source alternative to Intercom/Zendesk), customized for **fazer.ai**. It includes the **Synkra AIOS** framework overlay for AI-orchestrated development workflows.
- **Ruby**: Follow RuboCop rules (150 character max line length)
- **Vue/JS**: Use ESLint (Airbnb base + Vue 3 recommended)
- **Vue Components**: Use PascalCase
- **Events**: Use camelCase
- **I18n**: No bare strings in templates; use i18n
- **Error Handling**: Use custom exceptions (`lib/custom_exceptions/`)
- **Models**: Validate presence/uniqueness, add proper indexes
- **Type Safety**: Use PropTypes in Vue, strong params in Rails
- **Naming**: Use clear, descriptive names with consistent casing
- **Vue API**: Always use Composition API with `<script setup>` at the top
**Tech Stack:**
- Backend: Ruby 3.4.4 + Rails 7.1
- Frontend: Vue 3 + Vite
- Database: PostgreSQL with pgvector
- Background Jobs: Sidekiq
- Package Manager: **pnpm** (required, not npm/yarn)
- Testing: RSpec (backend), Vitest (frontend)
## Styling
## Development Commands
- **Tailwind Only**:
- Do not write custom CSS
- Do not use scoped CSS
- Do not use inline styles
- Always use Tailwind utility classes
- **Colors**: Refer to `tailwind.config.js` for color definitions
### Starting the Application
## General Guidelines
```bash
# Development server (Rails backend + Sidekiq + Vite)
pnpm run dev
- MVP focus: Least code change, happy-path only
- No unnecessary defensive programming
- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate
- Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness
- Break down complex tasks into small, testable units
- Iterate after confirmation
- Avoid writing specs unless explicitly asked
- Remove dead/unreachable/unused code
- Dont write multiple versions or backups for the same logic — pick the best approach and implement it
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
- Specs in parallel/reloading environments: prefer comparing `error.class.name` over constant class equality when asserting raised errors
# Individual processes:
# - Rails backend: http://localhost:3001
# - Sidekiq: background worker
# - Vite: frontend dev server
```
## Codex Worktree Workflow
### Testing
- Use a separate git worktree + branch per task to keep changes isolated.
- Keep Codex-specific local setup under `.codex/` and use `Procfile.worktree` for worktree process orchestration.
- The setup workflow in `.codex/environments/environment.toml` should dynamically generate per-worktree DB/port values (Rails, Vite, Redis DB index) to avoid collisions.
- Start each worktree with its own Overmind socket/title so multiple instances can run at the same time.
```bash
# Frontend (Vitest) - CRITICAL: NO -- flag with pnpm test!
pnpm test # Run all tests
pnpm test <file> # Run specific file (NOT pnpm test -- <file>)
pnpm test:watch # Watch mode
pnpm test:coverage # Coverage report
## Commit Messages
# Backend (RSpec)
bundle exec rspec # All specs
bundle exec rspec spec/models/user_spec.rb # Specific file
bundle exec rspec spec/models/user_spec.rb:42 # Specific line
```
- Prefer Conventional Commits: `type(scope): subject` (scope optional)
- Example: `feat(auth): add user authentication`
- Don't reference Claude in commit messages
### Code Quality
## Project-Specific
```bash
# JavaScript/Vue linting
pnpm run eslint # Check
pnpm run eslint:fix # Auto-fix
- **Translations**:
- Only update `en.yml` and `en.json`
- Other languages are handled by the community
- Backend i18n → `en.yml`, Frontend i18n → `en.json`
- **Frontend**:
- Use `components-next/` for message bubbles (the rest is being deprecated)
# Ruby linting
bundle exec rubocop # Check
bundle exec rubocop -a # Auto-fix
pnpm run ruby:prettier # Same as rubocop -a
```
## Ruby Best Practices
### Database
- Use compact `module/class` definitions; avoid nested styles
```bash
bin/rails db:migrate
bin/rails db:rollback
bin/rails db:reset
bin/rails db:seed
```
## Enterprise Edition Notes
## Architecture Overview
- Chatwoot has an Enterprise overlay under `enterprise/` that extends/overrides OSS code.
- When you add or modify core functionality, always check for corresponding files in `enterprise/` and keep behavior compatible.
- Follow the Enterprise development practices documented here:
- https://chatwoot.help/hc/handbook/articles/developing-enterprise-edition-features-38
### Backend Structure
Practical checklist for any change impacting core logic or public APIs
- Search for related files in both trees before editing (e.g., `rg -n "FooService|ControllerName|ModelName" app enterprise`).
- If adding new endpoints, services, or models, consider whether Enterprise needs:
- An override (e.g., `enterprise/app/...`), or
- An extension point (e.g., `prepend_mod_with`, hooks, configuration) to avoid hard forks.
- Avoid hardcoding instance- or plan-specific behavior in OSS; prefer configuration, feature flags, or extension points consumed by Enterprise.
- Keep request/response contracts stable across OSS and Enterprise; update both sets of routes/controllers when introducing new APIs.
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
- When modifying existing OSS features for Enterprise-only behavior, add an Enterprise module (via `prepend_mod_with`/`include_mod_with`) instead of editing OSS files directly—especially for policies, controllers, and services. For Enterprise-exclusive features, place code directly under `enterprise/`.
```
app/
├── controllers/ # API endpoints (API::V1::Accounts::*)
├── models/ # ActiveRecord models
├── services/ # Business logic (Whatsapp::Providers::*, etc.)
├── jobs/ # Sidekiq background jobs
├── listeners/ # Wisper event subscribers (pub/sub)
├── builders/ # Complex object construction
├── finders/ # Query objects
├── policies/ # Pundit authorization
└── javascript/ # Vue.js frontend
## Branding / White-labeling note
enterprise/app/ # Enterprise features (Captain AI, billing)
```
- For user-facing strings that currently contain "Chatwoot" but should adapt to branded/self-hosted installs, prefer applying `replaceInstallationName` from `shared/composables/useBranding` in the UI layer (for example tooltip and suggestion labels) instead of adding hardcoded brand-specific copy.
**Key Patterns:**
- **Services:** Business logic extracted from models
- **Builders:** Construct complex objects
- **Finders:** Encapsulate complex queries
- **Listeners:** Event-driven using Wisper
- **Policies:** Pundit for authorization
- **Jobs:** All async work in Sidekiq
### Frontend Structure
```
app/javascript/
├── dashboard/ # Agent dashboard (Vue 3 + Vue Router + Vuex)
│ ├── routes/ # Page components
│ ├── store/ # Vuex state
│ ├── components/ # Reusable components
│ ├── api/ # API clients
│ └── i18n/ # Translations (en, pt_BR required!)
├── widget/ # Customer chat widget
├── sdk/ # Embeddable JavaScript SDK
├── portal/ # Public help center
└── shared/ # Shared utilities
```
**Vite Import Aliases:**
- `components``app/javascript/dashboard/components`
- `dashboard``app/javascript/dashboard`
- `helpers``app/javascript/shared/helpers`
- `shared`, `widget`, `survey`, `v3` → respective directories
## Critical Conventions
### fazer.ai Branding
**ALWAYS** style as `fazer.ai` (lowercase with dot), **NEVER** `Fazer.ai` or `FAZER.AI`
### Internationalization
**ALWAYS include pt_BR translations** for any new user-facing text
- Location: `app/javascript/dashboard/i18n/locale/{en,pt_BR}/`
### Testing Philosophy
- Add specs when modifying code (use judgment)
- Test behavior, not implementation
- Consider cross-stack impacts (backend ↔ frontend)
---
# AIOS Framework Integration
This repository includes **Synkra AIOS** - an AI-orchestrated development system.
<!-- AIOS-MANAGED-START: core -->
## Core Rules
1. Siga a Constitution em `.aios-core/constitution.md`
2. Priorize `CLI First -> Observability Second -> UI Third`
3. Trabalhe por stories em `docs/stories/`
4. Nao invente requisitos fora dos artefatos existentes
<!-- AIOS-MANAGED-END: core -->
<!-- AIOS-MANAGED-START: quality -->
## Quality Gates
- Rode `npm run lint`
- Rode `npm run typecheck`
- Rode `npm test`
- Atualize checklist e file list da story antes de concluir
<!-- AIOS-MANAGED-END: quality -->
<!-- AIOS-MANAGED-START: codebase -->
## Project Map
- Core framework: `.aios-core/`
- CLI entrypoints: `bin/`
- Shared packages: `packages/`
- Tests: `tests/`
- Docs: `docs/`
<!-- AIOS-MANAGED-END: codebase -->
<!-- AIOS-MANAGED-START: commands -->
## Common Commands
- `npm run sync:ide`
- `npm run sync:ide:check`
- `npm run sync:skills:codex`
- `npm run sync:skills:codex:global` (opcional; neste repo o padrao e local-first)
- `npm run validate:structure`
- `npm run validate:agents`
<!-- AIOS-MANAGED-END: commands -->
<!-- AIOS-MANAGED-START: shortcuts -->
## Agent Shortcuts
Preferencia de ativacao no Codex CLI:
1. Use `/skills` e selecione `aios-<agent-id>` vindo de `.codex/skills` (ex.: `aios-architect`)
2. Se preferir, use os atalhos abaixo (`@architect`, `/architect`, etc.)
Interprete os atalhos abaixo carregando o arquivo correspondente em `.aios-core/development/agents/` (fallback: `.codex/agents/`), renderize o greeting via `generate-greeting.js` e assuma a persona ate `*exit`:
- `@architect`, `/architect`, `/architect.md` -> `.aios-core/development/agents/architect.md`
- `@dev`, `/dev`, `/dev.md` -> `.aios-core/development/agents/dev.md`
- `@qa`, `/qa`, `/qa.md` -> `.aios-core/development/agents/qa.md`
- `@pm`, `/pm`, `/pm.md` -> `.aios-core/development/agents/pm.md`
- `@po`, `/po`, `/po.md` -> `.aios-core/development/agents/po.md`
- `@sm`, `/sm`, `/sm.md` -> `.aios-core/development/agents/sm.md`
- `@analyst`, `/analyst`, `/analyst.md` -> `.aios-core/development/agents/analyst.md`
- `@devops`, `/devops`, `/devops.md` -> `.aios-core/development/agents/devops.md`
- `@data-engineer`, `/data-engineer`, `/data-engineer.md` -> `.aios-core/development/agents/data-engineer.md`
- `@ux-design-expert`, `/ux-design-expert`, `/ux-design-expert.md` -> `.aios-core/development/agents/ux-design-expert.md`
- `@squad-creator`, `/squad-creator`, `/squad-creator.md` -> `.aios-core/development/agents/squad-creator.md`
- `@aios-master`, `/aios-master`, `/aios-master.md` -> `.aios-core/development/agents/aios-master.md`
<!-- AIOS-MANAGED-END: shortcuts -->

View File

@ -1,4 +1,4 @@
backend: bin/rails s -p 3000
backend: bin/rails s -p 3001
# https://github.com/mperham/sidekiq/issues/3090#issuecomment-389748695
worker: dotenv bundle exec sidekiq -C config/sidekiq.yml
worker: bundle exec sidekiq -C config/sidekiq.yml
vite: bin/vite dev

View File

@ -0,0 +1,101 @@
class Api::V1::Accounts::Captain::UnitsController < Api::V1::Accounts::BaseController
before_action :ensure_captain_enabled
before_action :set_unit, only: [:show, :update, :destroy]
def index
@units = Current.account.captain_units
render json: @units.map { |u| format_unit(u) }
end
def show
render json: format_unit(@unit)
end
def create
@unit = Current.account.captain_units.build(unit_params)
@unit.captain_brand_id ||= Captain::Brand.where(account_id: Current.account.id).first&.id
ActiveRecord::Base.transaction do
@unit.save!
sync_inbox_link!(@unit)
end
render json: format_unit(@unit), status: :created
rescue ActiveRecord::RecordInvalid
render json: { errors: @unit.errors.full_messages }, status: :unprocessable_entity
end
def update
ActiveRecord::Base.transaction do
@unit.update!(unit_params)
sync_inbox_link!(@unit)
end
render json: format_unit(@unit)
rescue ActiveRecord::RecordInvalid
render json: { errors: @unit.errors.full_messages }, status: :unprocessable_entity
end
def destroy
@unit.destroy!
head :no_content
end
private
def ensure_captain_enabled
# Dependendo da regra de negócio, pode-se verificar as features da conta aqui original
end
def set_unit
@unit = Current.account.captain_units.find(params[:id])
end
def unit_params
params.require(:captain_unit).permit(
:name,
:inter_client_id,
:inter_client_secret,
:inter_pix_key,
:inter_account_number,
:inbox_id,
:inter_cert_content,
:inter_key_content,
:proactive_pix_polling_enabled
)
end
def format_unit(unit)
{
id: unit.id,
name: unit.name,
inter_client_id: unit.inter_client_id,
inter_pix_key: unit.inter_pix_key,
inter_account_number: unit.inter_account_number,
inbox_id: unit.inbox_id,
inbox_name: unit.inbox_id.present? ? Inbox.find_by(id: unit.inbox_id)&.name : nil,
has_cert: unit.inter_cert_content.present? || unit.resolved_inter_cert_path.present?,
has_key: unit.inter_key_content.present? || unit.resolved_inter_key_path.present?,
has_client_secret: unit.inter_client_secret.present?,
proactive_pix_polling_enabled: unit.proactive_pix_polling_enabled
# Obviamente não enviando secrets ou contents aqui!
}
end
def sync_inbox_link!(unit)
if unit.inbox_id.present?
Current.account.captain_units
.where(inbox_id: unit.inbox_id)
.where.not(id: unit.id)
.find_each { |existing_unit| existing_unit.update!(inbox_id: nil) }
end
return unless defined?(CaptainInbox)
CaptainInbox.where(captain_unit_id: unit.id).find_each do |existing_link|
existing_link.update!(captain_unit_id: nil)
end
return if unit.inbox_id.blank?
inbox_link = CaptainInbox.find_by(inbox_id: unit.inbox_id)
inbox_link&.update!(captain_unit_id: unit.id)
end
end

View File

@ -0,0 +1,133 @@
class Api::V1::Accounts::Inboxes::WuzapiController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
before_action :ensure_wuzapi_provider
def show
# Session Status
status_data = client.session_status(user_token)
# Wuzapi returns nested data: { data: { connected: true, jid: "..." } }
# Pass it through to frontend for validation
render json: status_data
rescue Wuzapi::Client::Error => e
Rails.logger.error "Wuzapi Status Error: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
rescue StandardError => e
Rails.logger.error "Wuzapi Status Critical Error: #{e.message}"
render json: { error: e.message }, status: :internal_server_error
end
def qr
# Get QR Code
# Check status first to avoid error if already connected
status_data = client.session_status(user_token)
# Wuzapi status can be string or object with 'status'/'state'
status = status_data['status'] || status_data['state'] || status_data
Rails.logger.info "Wuzapi Connect/QR Flow - Current Status: #{status}"
return if already_connected?(status)
qr_data = client.get_qr_code(user_token)
log_qr_data_keys(qr_data)
render json: qr_data
rescue Wuzapi::Client::Error => e
Rails.logger.error "Wuzapi QR Error: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
rescue StandardError => e
Rails.logger.error "Wuzapi QR Critical Error: #{e.message}"
render json: { error: e.message }, status: :internal_server_error
end
def connect
# Trigger connection (if needed by Wuzapi flow)
result = client.session_connect(user_token)
render json: result
rescue Wuzapi::Client::Error => e
# Idempotency: "already connected" is a success state
if e.message.include?('already connected')
render json: { success: true, message: 'Already connected' }, status: :ok
else
Rails.logger.error "Wuzapi Connect Error: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
rescue StandardError => e
Rails.logger.error "Wuzapi Connect Critical Error: #{e.message}"
render json: { error: e.message }, status: :internal_server_error
end
def disconnect
# Disconnect session
result = client.session_logout(user_token) || client.session_disconnect(user_token)
render json: result
rescue Wuzapi::Client::Error => e
render json: { error: e.message }, status: :unprocessable_entity
rescue StandardError => e
render json: { error: e.message }, status: :internal_server_error
end
def webhook_info
info = client.get_webhook(user_token)
render json: info
rescue Wuzapi::Client::Error => e
render json: { error: e.message }, status: :unprocessable_entity
rescue StandardError => e
render json: { error: e.message }, status: :internal_server_error
end
def update_webhook
# Re-calculate correct webhook URL from model
url = @inbox.channel.webhook_url
client.update_webhook(user_token, url)
render json: { success: true, message: 'Webhook updated successfully', webhook_url: url }
rescue Wuzapi::Client::Error => e
render json: { error: e.message }, status: :unprocessable_entity
rescue StandardError => e
render json: { error: e.message }, status: :internal_server_error
end
private
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
end
def ensure_wuzapi_provider
return if @inbox.channel.provider == 'wuzapi'
render json: { error: 'Not a Wuzapi inbox' }, status: :bad_request
end
def client
@client ||= Wuzapi::Client.new(@inbox.channel.provider_config['wuzapi_base_url'])
end
def user_token
token = @inbox.channel.wuzapi_user_token
if token.blank?
Rails.logger.error "Wuzapi Token Missing for Inbox #{@inbox.id}"
raise 'Token Wuzapi ausente; reprovisionar usuário'
else
Rails.logger.info "Wuzapi Request using Token (last 6): ******#{token.to_s[-6..]}"
end
token
end
def already_connected?(status)
if %w[CONNECTED inChat success].include?(status)
Rails.logger.info 'Wuzapi is already connected. Skipping QR.'
render json: { qrcode: nil, status: 'CONNECTED', message: 'Already connected' }
true
else
false
end
end
def log_qr_data_keys(qr_data)
Rails.logger.info "Wuzapi QR Data Response keys: #{qr_data.keys}"
rescue StandardError
Rails.logger.info 'Wuzapi QR Data Response keys: nil'
end
end

View File

@ -43,9 +43,12 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController #
end
def update
inbox_params = permitted_params.except(:channel, :csat_config)
captain_unit_param_present = params.key?(:captain_unit_id) || params.key?('captain_unit_id')
inbox_params = permitted_params.except(:channel, :csat_config, :captain_unit_id)
captain_unit_id = permitted_params[:captain_unit_id] if captain_unit_param_present
inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present?
@inbox.update!(inbox_params)
sync_captain_unit_link(captain_unit_id) if captain_unit_param_present
update_inbox_working_hours
update_channel if channel_update_required?
end
@ -211,7 +214,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController #
def inbox_attributes
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name, :captain_unit_id, :typing_delay,
{ csat_config: [:display_type, :message, :button_text, :language,
{ survey_rules: [:operator, { values: [] }],
template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid,
@ -251,6 +254,29 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController #
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
end
end
def sync_captain_unit_link(captain_unit_id)
return unless defined?(Captain::Unit)
account_units = Current.account.captain_units
account_units.where(inbox_id: @inbox.id).update_all(inbox_id: nil)
selected_unit_id = captain_unit_id.presence
if selected_unit_id
selected_unit = account_units.find(selected_unit_id)
selected_unit.update!(inbox_id: @inbox.id)
end
return unless defined?(CaptainInbox)
if selected_unit_id
CaptainInbox.where(captain_unit_id: selected_unit_id)
.where.not(inbox_id: @inbox.id)
.update_all(captain_unit_id: nil)
end
@inbox.captain_inbox&.update!(captain_unit_id: selected_unit_id)
end
end
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')

View File

@ -0,0 +1,92 @@
# frozen_string_literal: true
# Recebe callbacks do Banco Inter quando um PIX é pago
# Documentação: https://developers.inter.co/references/pix#tag/Webhook-de-Pix-Cobranca
class Public::Api::V1::Captain::InterWebhooksController < ActionController::API
def create
# Parse payload - Inter envia array direto, não objeto { pix: [...] }
payload = JSON.parse(request.body.read)
# Normaliza: aceita tanto array direto quanto objeto { pix: [...] }
pix_array = payload.is_a?(Array) ? payload : payload['pix']
if pix_array.blank?
Rails.logger.warn '[InterWebhook] Payload sem dados PIX, ignorando'
render json: { message: 'No PIX data' }, status: :ok
return
end
# Processa primeira transação do array
pix_data = pix_array.first
process_pix_payment(pix_data)
render json: { message: 'Webhook processado com sucesso' }, status: :ok
rescue JSON::ParserError => e
Rails.logger.error "[InterWebhook] JSON inválido: #{e.message}"
render json: { error: 'Invalid JSON' }, status: :bad_request
rescue StandardError => e
Rails.logger.error "[InterWebhook] Erro ao processar: #{e.class} - #{e.message}\n#{e.backtrace.first(5).join("\n")}"
render json: { error: 'Internal error' }, status: :unprocessable_entity
end
private
def process_pix_payment(pix_data)
txid = pix_data['txid']
e2eid = pix_data['endToEndId']
valor = pix_data['valor']
Rails.logger.info "[InterWebhook] Recebido: txid=#{txid}, e2eid=#{e2eid}, valor=#{valor}"
# Idempotência: verifica se já processamos este PIX
existing = Captain::PixCharge.find_by(e2eid: e2eid)
if existing
Rails.logger.info "[InterWebhook] PIX já processado (e2eid: #{e2eid})"
return
end
# Busca cobrança pelo txid
charge = Captain::PixCharge.find_by(txid: txid)
unless charge
Rails.logger.warn "[InterWebhook] Cobrança não encontrada (txid: #{txid})"
return
end
# Atualiza cobrança
charge.update!(
status: 'paid',
e2eid: e2eid,
paid_at: Time.current,
raw_webhook_payload: pix_data
)
Rails.logger.info "[InterWebhook] PixCharge #{charge.id} marcado como pago"
# Confirma reserva
Captain::Payments::ConfirmationService.new(
reservation: charge.reservation,
source: 'webhook_inter_pix',
payload: pix_data
).perform
# Notifica chat
notify_chat(charge.reservation)
end
def notify_chat(reservation)
return unless reservation.conversation_id
conversation = Conversation.find(reservation.conversation_id)
conversation.messages.create!(
content: "✅ *Pagamento confirmado!*\n\nSua reserva ##{reservation.id} está garantida. Em breve você receberá mais informações sobre sua estadia!",
message_type: :outgoing,
account: conversation.account,
inbox: conversation.inbox
)
Rails.logger.info "[InterWebhook] Notificação enviada para conversa #{conversation.id}"
rescue StandardError => e
Rails.logger.error "[InterWebhook] Falha ao notificar chat: #{e.message}"
end
end

View File

@ -0,0 +1,13 @@
class Public::Api::V1::Captain::PaymentsController < ApplicationController
layout false
skip_before_action :authenticate_user!, raise: false
skip_before_action :check_current_user_is_active, raise: false
def show
@charge = GlobalID::Locator.locate_signed(params[:token], purpose: :pix_payment)
return if @charge.present?
render plain: 'Link de pagamento inválido ou expirado.', status: :not_found
end
end

View File

@ -0,0 +1,37 @@
class Webhooks::WuzapiController < ApplicationController
skip_before_action :verify_authenticity_token, raise: false
before_action :fetch_inbox
before_action :verify_secret
def process_payload
Rails.logger.info "Wuzapi Webhook Received for Inbox #{@inbox.id}: #{params.inspect}"
Whatsapp::IncomingMessageWuzapiService.new(inbox: @inbox, params: params.to_unsafe_hash).perform
head :ok
rescue StandardError => e
Rails.logger.error "Error processing Wuzapi webhook: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
head :internal_server_error
end
private
def fetch_inbox
@inbox = Inbox.find(params[:inbox_id])
rescue ActiveRecord::RecordNotFound
head :not_found
end
def verify_secret
return if @inbox.blank?
secret = params[:secret]
stored_secret = @inbox.channel&.provider_config&.dig('webhook_secret')
return unless secret.blank? || secret != stored_secret
Rails.logger.warn "Wuzapi Webhook: Invalid secret for Inbox #{@inbox.id}"
head :unauthorized
end
end

View File

@ -0,0 +1,35 @@
/* global axios */
import ApiClient from '../../api/ApiClient';
class CaptainGalleryItemsAPI extends ApiClient {
constructor() {
super('captain/gallery_items', { accountScoped: true });
}
getItems(params = {}) {
return axios.get(this.url, { params });
}
getItem(id) {
return this.show(id);
}
createItem(formData) {
return axios.post(this.url, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
}
updateItem(id, formData) {
return axios.patch(`${this.url}/${id}`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
}
deleteItem(id) {
return this.delete(id);
}
}
export default new CaptainGalleryItemsAPI();

View File

@ -0,0 +1,26 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainReservations extends ApiClient {
constructor() {
super('captain/reservations', { accountScoped: true });
}
get(params = {}) {
return axios.get(this.url, { params });
}
show(id) {
return axios.get(`${this.url}/${id}`);
}
revenue(params = {}) {
return axios.get(`${this.url}/revenue`, { params });
}
pix(id) {
return axios.get(`${this.url}/${id}/pix`);
}
}
export default new CaptainReservations();

View File

@ -0,0 +1,29 @@
import ApiClient from '../../api/ApiClient';
class CaptainUnitsAPI extends ApiClient {
constructor() {
super('captain/units', { accountScoped: true });
}
getUnits() {
return this.get();
}
getUnit(id) {
return this.show(id);
}
createUnit(data) {
return this.create({ captain_unit: data });
}
updateUnit(id, data) {
return this.update(id, { captain_unit: data });
}
deleteUnit(id) {
return this.delete(id);
}
}
export default new CaptainUnitsAPI();

View File

@ -0,0 +1,96 @@
/* global axios */
import ApiClient from '../ApiClient';
class JasmineAPI extends ApiClient {
constructor() {
super('inboxes', { accountScoped: true });
}
// Helper to get account-scoped jasmine base URL
get jasmineUrl() {
return `${this.apiVersion}/accounts/${this.accountIdFromRoute}/jasmine`;
}
// Inbox Settings
getSettings(inboxId) {
return axios.get(`${this.url}/${inboxId}/jasmine/config`);
}
updateSettings(inboxId, data) {
return axios.patch(`${this.url}/${inboxId}/jasmine/config`, data);
}
// Collections (Account-scoped)
getCollections(params = {}) {
return axios.get(`${this.jasmineUrl}/collections`, { params });
}
createCollection(data) {
return axios.post(`${this.jasmineUrl}/collections`, data);
}
deleteCollection(collectionId) {
return axios.delete(`${this.jasmineUrl}/collections/${collectionId}`);
}
// Links (Inbox Collections)
getInboxCollections(inboxId) {
return axios.get(`${this.url}/${inboxId}/jasmine/collections`);
}
linkCollection(inboxId, collectionId, priority = 0) {
return axios.post(`${this.url}/${inboxId}/jasmine/collections`, {
collection_id: collectionId,
priority,
});
}
unlinkCollection(inboxId, collectionId) {
return axios.delete(
`${this.url}/${inboxId}/jasmine/collections/${collectionId}`
);
}
// Documents
getDocuments(collectionId) {
return axios.get(
`${this.jasmineUrl}/collections/${collectionId}/documents`
);
}
uploadDocument(collectionId, content, title) {
return axios.post(
`${this.jasmineUrl}/collections/${collectionId}/documents`,
{
title,
content,
}
);
}
deleteDocument(collectionId, documentId) {
return axios.delete(
`${this.jasmineUrl}/collections/${collectionId}/documents/${documentId}`
);
}
// Playground
testPlayground(inboxId, message) {
return axios.post(`${this.url}/${inboxId}/jasmine/playground`, { message });
}
// Tools
getTools(inboxId) {
return axios.get(`${this.url}/${inboxId}/jasmine/tools`);
}
updateTool(inboxId, toolKey, data) {
return axios.patch(`${this.url}/${inboxId}/jasmine/tools/${toolKey}`, data);
}
testTool(inboxId, toolKey) {
return axios.post(`${this.url}/${inboxId}/jasmine/tools/${toolKey}/test`);
}
}
export default new JasmineAPI();

View File

@ -77,6 +77,13 @@ const handleAction = ({ action, value }) => {
<span :class="icon" />
{{ inboxName }}
</span>
<div
v-if="inbox.captain_unit_name"
class="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-n-blue-2 text-n-blue-11 text-xs font-medium border border-n-blue-4"
>
<span class="i-lucide-wallet h-3 w-3" />
{{ inbox.captain_unit_name }}
</div>
<div class="flex items-center gap-2">
<Policy
v-on-clickaway="() => toggleDropdown(false)"

View File

@ -1,13 +1,15 @@
<script setup>
import { defineModel, watch } from 'vue';
import { defineModel, watch, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
authType: {
type: String,
required: true,
validator: value => ['none', 'bearer', 'basic', 'api_key'].includes(value),
validator: value =>
['none', 'bearer', 'basic', 'api_key', 'custom_headers'].includes(value),
},
});
@ -24,10 +26,34 @@ watch(
authConfig.value = {};
}
);
// custom_headers: lista de { name, value }
const customHeaders = computed({
get: () => authConfig.value.headers || [],
set: val => {
authConfig.value = { ...authConfig.value, headers: val };
},
});
const addHeader = () => {
customHeaders.value = [...customHeaders.value, { name: '', value: '' }];
};
const removeHeader = index => {
customHeaders.value = customHeaders.value.filter((_, i) => i !== index);
};
const updateHeader = (index, field, val) => {
const updated = customHeaders.value.map((h, i) =>
i === index ? { ...h, [field]: val } : h
);
customHeaders.value = updated;
};
</script>
<template>
<div class="flex flex-col gap-2">
<!-- Bearer Token -->
<Input
v-if="authType === 'bearer'"
v-model="authConfig.token"
@ -36,6 +62,8 @@ watch(
t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.BEARER_TOKEN_PLACEHOLDER')
"
/>
<!-- Basic Auth -->
<template v-else-if="authType === 'basic'">
<Input
v-model="authConfig.username"
@ -53,6 +81,8 @@ watch(
"
/>
</template>
<!-- Single API Key -->
<template v-else-if="authType === 'api_key'">
<Input
v-model="authConfig.name"
@ -69,5 +99,71 @@ watch(
"
/>
</template>
<!-- Custom Headers: múltiplos pares nome/valor configurados pelo usuário -->
<template v-else-if="authType === 'custom_headers'">
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.CUSTOM_HEADERS') }}
</label>
<p class="text-xs text-n-slate-11 -mt-1">
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.CUSTOM_HEADERS_HELP') }}
</p>
<div
v-for="(header, index) in customHeaders"
:key="index"
class="flex gap-2 items-end"
>
<Input
:model-value="header.name"
:label="
index === 0
? t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.HEADER_NAME')
: ''
"
:placeholder="
t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.HEADER_NAME_PLACEHOLDER')
"
class="flex-1 [&_input]:font-mono"
@update:model-value="val => updateHeader(index, 'name', val)"
/>
<Input
:model-value="header.value"
:label="
index === 0
? t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.HEADER_VALUE')
: ''
"
:placeholder="
t(
'CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.HEADER_VALUE_PLACEHOLDER'
)
"
class="flex-1 [&_input]:font-mono"
@update:model-value="val => updateHeader(index, 'value', val)"
/>
<Button
type="button"
ghost
sm
slate
icon="i-lucide-trash-2"
class="mb-0.5 text-n-ruby-9"
@click="removeHeader(index)"
/>
</div>
<Button
type="button"
ghost
sm
blue
icon="i-lucide-plus"
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.ADD_HEADER')"
@click="addHeader"
/>
</div>
</template>
</div>
</template>

View File

@ -92,6 +92,10 @@ const authTypeOptions = computed(() => [
value: 'api_key',
label: t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.API_KEY'),
},
{
value: 'custom_headers',
label: t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.CUSTOM_HEADERS'),
},
]);
const v$ = useVuelidate(validationRules, state);

View File

@ -288,7 +288,7 @@ const menuItems = computed(() => {
children: sortedInboxes.value.map(inbox => ({
name: `${inbox.name}-${inbox.id}`,
label: inbox.name,
icon: h(ChannelIcon, { inbox, class: 'size-[12px]' }),
icon: () => h(ChannelIcon, { inbox, class: 'size-[12px]' }),
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
component: leafProps =>
h(ChannelLeaf, {
@ -306,7 +306,8 @@ const menuItems = computed(() => {
children: labels.value.map(label => ({
name: `${label.title}-${label.id}`,
label: label.title,
icon: h('span', {
icon: () =>
h('span', {
class: `size-[8px] rounded-sm`,
style: { backgroundColor: label.color },
}),
@ -386,6 +387,27 @@ const menuItems = computed(() => {
navigationPath: 'captain_assistants_settings_index',
}),
},
{
name: 'Pix Units',
label: t('SIDEBAR.CAPTAIN_PIX_UNITS'),
activeOn: ['captain_settings_units', 'captain_settings_units_edit'],
to: accountScopedRoute('captain_settings_units'),
},
{
name: 'Gallery',
label: t('SIDEBAR.CAPTAIN_GALLERY'),
activeOn: [
'captain_settings_gallery',
'captain_settings_gallery_edit',
],
to: accountScopedRoute('captain_settings_gallery'),
},
{
name: 'Reservations',
label: t('SIDEBAR.CAPTAIN_RESERVATIONS'),
activeOn: ['captain_reservations_index'],
to: accountScopedRoute('captain_reservations_index'),
},
],
},
{
@ -434,7 +456,8 @@ const menuItems = computed(() => {
children: labels.value.map(label => ({
name: `${label.title}-${label.id}`,
label: label.title,
icon: h('span', {
icon: () =>
h('span', {
class: `size-[8px] rounded-sm`,
style: { backgroundColor: label.color },
}),

View File

@ -14,6 +14,7 @@ import PriorityMark from './PriorityMark.vue';
import SLACardLabel from './components/SLACardLabel.vue';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import VoiceCallStatus from './VoiceCallStatus.vue';
import ReservationMarker from './ReservationMarker.vue';
const props = defineProps({
activeLabel: { type: String, default: '' },
@ -112,6 +113,8 @@ const showMetaSection = computed(() => {
});
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
const reservationMarker = computed(() => props.chat?.reservation_marker || {});
const hasReservationMarker = computed(() => reservationMarker.value?.visible);
const showLabelsSection = computed(() => {
return props.chat.labels?.length > 0 || hasSlaPolicyId.value;
@ -370,6 +373,9 @@ const deleteConversation = () => {
<SLACardLabel :chat="chat" class="ltr:mr-1 rtl:ml-1" />
</template>
</CardLabels>
<div v-if="hasReservationMarker" class="mx-2 mt-1">
<ReservationMarker :marker="reservationMarker" compact />
</div>
</div>
<ContextMenu
v-if="showContextMenu"

View File

@ -13,6 +13,8 @@ import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { useInbox } from 'dashboard/composables/useInbox';
import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import ReservationMarker from './ReservationMarker.vue';
const props = defineProps({
chat: {
@ -31,6 +33,7 @@ const route = useRoute();
const conversationHeader = ref(null);
const { width } = useElementSize(conversationHeader);
const { isAWebWidgetInbox } = useInbox();
const { updateUISettings } = useUISettings();
const currentChat = computed(() => store.getters.getSelectedChat);
const accountId = computed(() => store.getters.getCurrentAccountId);
@ -90,6 +93,15 @@ const hasMultipleInboxes = computed(
);
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
const reservationMarker = computed(() => props.chat?.reservation_marker || {});
const hasReservationMarker = computed(() => reservationMarker.value?.visible);
const openReservationSummary = () => {
updateUISettings({
is_contact_sidebar_open: true,
is_reservation_summary_open: true,
});
};
</script>
<template>
@ -151,6 +163,12 @@ const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
:parent-width="width"
class="hidden md:flex"
/>
<ReservationMarker
v-if="hasReservationMarker"
:marker="reservationMarker"
clickable
@click="openReservationSummary"
/>
<MoreActions :conversation-id="currentChat.id" />
</div>
</div>

View File

@ -0,0 +1,76 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
marker: {
type: Object,
default: () => ({}),
},
compact: {
type: Boolean,
default: false,
},
clickable: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['click']);
const { t } = useI18n();
const markerStatus = computed(() => props.marker?.status || 'draft');
const statusLabel = computed(() => {
const key = `CAPTAIN_RESERVATIONS.STATUS.${markerStatus.value.toUpperCase()}`;
const translated = t(key);
return translated === key
? props.marker?.status_label || markerStatus.value
: translated;
});
const amountLabel = computed(() =>
new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(
Number(props.marker?.amount || 0)
)
);
const checkInLabel = computed(() => {
if (!props.marker?.check_in_at) return null;
return new Intl.DateTimeFormat('pt-BR', {
day: '2-digit',
month: '2-digit',
}).format(new Date(props.marker.check_in_at));
});
const stateClass = computed(() => {
const map = {
draft: 'bg-n-slate-3 text-n-slate-12',
pending_payment: 'bg-n-amber-3 text-n-amber-11',
confirmed: 'bg-n-teal-3 text-n-teal-11',
cancelled: 'bg-n-ruby-3 text-n-ruby-11',
};
return map[markerStatus.value] || map.draft;
});
</script>
<template>
<button
type="button"
class="inline-flex items-center max-w-full gap-1 px-2 py-1 text-xs font-medium rounded-full"
:class="[
stateClass,
{ 'cursor-pointer': clickable, 'cursor-default': !clickable },
]"
@click.stop="emit('click')"
>
<span class="truncate">{{ statusLabel }}</span>
<span v-if="!compact" class="opacity-80"></span>
<span v-if="!compact" class="opacity-80">{{ amountLabel }}</span>
<span v-if="!compact && checkInLabel" class="opacity-80">
{{ checkInLabel }}
</span>
</button>
</template>

View File

@ -6,6 +6,7 @@ export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([
{ name: 'conversation_actions' },
{ name: 'macros' },
{ name: 'conversation_info' },
{ name: 'reservation_summary' },
{ name: 'contact_attributes' },
{ name: 'contact_notes' },
{ name: 'previous_conversation' },

View File

@ -2,7 +2,6 @@ export const FEATURE_FLAGS = {
AGENT_BOTS: 'agent_bots',
AGENT_MANAGEMENT: 'agent_management',
ASSIGNMENT_V2: 'assignment_v2',
ADVANCED_ASSIGNMENT: 'advanced_assignment',
AUTO_RESOLVE_CONVERSATIONS: 'auto_resolve_conversations',
AUTOMATIONS: 'automations',
CAMPAIGNS: 'campaigns',
@ -34,6 +33,7 @@ export const FEATURE_FLAGS = {
IP_LOOKUP: 'ip_lookup',
LINEAR: 'linear_integration',
CAPTAIN: 'captain_integration',
CAPTAIN_TASKS: 'captain_tasks',
CUSTOM_ROLES: 'custom_roles',
CHATWOOT_V4: 'chatwoot_v4',
REPORT_V4: 'report_v4',
@ -41,12 +41,9 @@ export const FEATURE_FLAGS = {
CHANNEL_TIKTOK: 'channel_tiktok',
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
CAPTAIN_V2: 'captain_integration_v2',
CAPTAIN_TASKS: 'captain_tasks',
SAML: 'saml',
QUOTED_EMAIL_REPLY: 'quoted_email_reply',
COMPANIES: 'companies',
ADVANCED_SEARCH: 'advanced_search',
CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes',
};
export const PREMIUM_FEATURES = [
@ -56,6 +53,4 @@ export const PREMIUM_FEATURES = [
FEATURE_FLAGS.AUDIT_LOGS,
FEATURE_FLAGS.HELP_CENTER,
FEATURE_FLAGS.SAML,
FEATURE_FLAGS.CONVERSATION_REQUIRED_ATTRIBUTES,
FEATURE_FLAGS.ADVANCED_ASSIGNMENT,
];

View File

@ -0,0 +1,281 @@
{
"CAPTAIN_RESERVATIONS": {
"HEADER": "Reservations",
"EMPTY": "No reservations found.",
"VIEW": {
"LIST": "List",
"KANBAN": "Kanban",
"REVENUE": "Revenue"
},
"FILTERS": {
"SEARCH": "Search by name, CPF, or phone",
"STATUS": "Status",
"STATUS_ALL": "All statuses",
"UNIT": "Unit",
"UNIT_ALL": "All units",
"SUITE": "Suite",
"DATE_FROM": "From",
"DATE_TO": "To",
"SORT": "Sort",
"SORT_DEFAULT": "Operational priority",
"SORT_CHECK_IN": "Check-in date",
"SORT_UPDATED": "Last update",
"SORT_CREATED": "Created at",
"APPLY": "Apply filters",
"CLEAR": "Clear filters"
},
"TABLE": {
"CUSTOMER": "Customer",
"UNIT": "Unit",
"SUITE": "Suite",
"CHECK_IN": "Check-in",
"AMOUNT": "Amount",
"STATUS": "Status",
"UPDATED_AT": "Updated",
"ACTIONS": "Actions"
},
"STATUS": {
"DRAFT": "Draft",
"PENDING_PAYMENT": "Awaiting payment",
"CONFIRMED": "Confirmed",
"CANCELLED": "Cancelled"
},
"ACTIONS": {
"OPEN_CONVERSATION": "Open conversation",
"COPY_PIX": "Copy Pix"
},
"KANBAN": {
"EMPTY_COLUMN": "No reservations in this status."
},
"REVENUE": {
"ONLY_CONFIRMED": "Revenue includes only reservations with confirmed status.",
"SUMMARY": {
"TOTAL_REVENUE": "Total revenue",
"CONFIRMED_COUNT": "Confirmed reservations",
"AVERAGE_TICKET": "Average ticket"
},
"CHARTS": {
"BY_UNIT": "Revenue by unit",
"BY_SUITE": "Revenue by suite"
},
"API": {
"ERROR": "Unable to load revenue."
}
},
"SIDEBAR": {
"NO_RESERVATION": "No reservation linked to this conversation.",
"LOADING": "Loading reservation details...",
"STATUS": "Status",
"SUITE": "Suite",
"CHECK_IN": "Check-in",
"CHECK_OUT": "Check-out",
"AMOUNT": "Amount",
"UPDATED_AT": "Updated at"
},
"API": {
"PIX_EXPIRED": "Pix for this reservation is expired.",
"PIX_NOT_GENERATED": "Pix has not been generated yet.",
"PIX_COPIED": "Pix copied successfully.",
"PIX_COPY_FAILED": "Unable to copy Pix."
}
},
"CAPTAIN_SETTINGS": {
"TITLE": "Captain Settings",
"UNITS": {
"TITLE": "Pix Units",
"DESC": "Manage the settings of different Pix units integrated with Banco Inter.",
"ADD_UNIT": "Add Unit",
"EDIT_UNIT": "Edit Unit",
"DELETE_UNIT": "Delete Unit",
"LIST": {
"TABLE_HEADER": [
"Pix Key",
"Account",
"Certificates",
"Monitoring",
"Actions"
],
"CERT": "Cert",
"KEY": "Key",
"PROACTIVE_ON": "Auto-check on",
"PROACTIVE_OFF": "Auto-check off",
"SEPARATOR": "|",
"ADD_NEW_UNIT": "Add a Pix Unit",
"NO_UNITS_MESSAGE": "There are no pix units entered yet. Create one now to start receiving by Banco Inter pix."
},
"DELETE": {
"BUTTON_TEXT": "Delete",
"TITLE": "Delete Pix Unit",
"DESC": "Are you sure you want to delete this pix unit? This action cannot be undone.",
"CONFIRM": {
"TITLE": "Confirm Deletion",
"MESSAGE": "Are you sure you want to delete the Unit?",
"YES": "Delete",
"NO": "Cancel"
},
"API": {
"SUCCESS_MESSAGE": "Unit deleted successfully.",
"ERROR_MESSAGE": "There was an error trying to delete the unit."
}
},
"ADD": {
"TITLE": "New Pix Unit",
"DESC": "Add your Banco Inter app credentials",
"CANCEL_BUTTON_TEXT": "Cancel",
"SUBMIT_BUTTON_TEXT": "Create unit",
"API": {
"SUCCESS_MESSAGE": "Unit created successfully!",
"ERROR_MESSAGE": "There was an error trying to create the unit."
}
},
"EDIT": {
"TITLE": "Edit Pix Unit",
"DESC": "Update Banco Inter credentials",
"CANCEL_BUTTON_TEXT": "Cancel",
"SUBMIT_BUTTON_TEXT": "Save unit",
"API": {
"SUCCESS_MESSAGE": "Unit updated successfully!",
"ERROR_MESSAGE": "There was an error trying to update."
}
},
"FORM": {
"NAME": {
"LABEL": "Unit Name",
"PLACEHOLDER": "Ex: Headquaters",
"ERROR": "Name is required"
},
"INTER_PIX_KEY": {
"LABEL": "Pix Key (from Inter app)",
"PLACEHOLDER": "Ex: 12.345.678/0001-90",
"ERROR": "Pix key is required",
"HELP_TEXT": "Your Pix key that will receive the charges"
},
"INTER_ACCOUNT_NUMBER": {
"LABEL": "Inter account number",
"PLACEHOLDER": "Ex: 1234567-8",
"ERROR": "Account number is required"
},
"INTER_CLIENT_ID": {
"LABEL": "Inter App Client ID",
"PLACEHOLDER": ""
},
"INTER_CLIENT_SECRET": {
"LABEL": "Inter App Client Secret",
"PLACEHOLDER": ""
},
"INTER_CERT_CONTENT": {
"LABEL": "Certificate Content (.crt)",
"PLACEHOLDER": "Paste the text contained in the .crt file here...",
"UPLOAD_BUTTON": "Import .crt file"
},
"INTER_KEY_CONTENT": {
"LABEL": "Key Content (.key)",
"PLACEHOLDER": "Paste the text contained in the .key file here...",
"UPLOAD_BUTTON": "Import .key file"
},
"PROACTIVE_PIX_POLLING": {
"LABEL": "Proactive payment confirmation (Banco Inter)",
"CHECKBOX_LABEL": "Check payment automatically every 10 minutes for up to 1 hour",
"HELP_TEXT": "When enabled, the system proactively queries Inter to confirm pending Pix payments.",
"DISABLED_HELP_TEXT": "Complete Banco Inter credentials (Client ID/Secret, account, Pix key, certificate and key) to enable."
},
"CERT_PRESENT_HELP": "Certificate already configured.",
"CANCEL": "Cancel",
"SAVE": "Save"
},
"INBOX": {
"LABEL": "Pix Unit",
"PLACEHOLDER": "Select a unit",
"NO_UNIT": "No unit linked",
"CONNECT_UNIT_LABEL": "Link Pix Unit",
"CONNECT_UNIT_PLACEHOLDER": "Choose the Inter unit for this inbox",
"CONNECT_UNIT_HELP": "Select which Pix Unit should be used by this inbox."
},
"TEST": {
"HEADER_TITLE": "Header validation",
"HEADER_DESCRIPTION": "Visual test to validate page rendering.",
"BODY_TEXT": "If this screen appears, the issue is likely in the table or Vuex, not in the base layout."
}
},
"GALLERY": {
"TITLE": "Gallery",
"DESC": "Manage suite photos that subagents can send to customers.",
"ADD_ITEM": "Add Photo",
"EDIT_ITEM": "Edit",
"DELETE_ITEM": "Delete",
"LIST": {
"TABLE_HEADER": [
"Image",
"Inbox and description",
"Category",
"Suite",
"Actions"
],
"ADD_NEW_ITEM": "Add gallery photos",
"NO_ITEMS_MESSAGE": "There are no photos available yet for automatic customer sending."
},
"DELETE": {
"CONFIRM": {
"TITLE": "Delete photo",
"MESSAGE": "Are you sure you want to delete this photo from the gallery?",
"YES": "Delete"
},
"API": {
"SUCCESS_MESSAGE": "Photo deleted successfully.",
"ERROR_MESSAGE": "Unable to delete the photo."
}
},
"ADD": {
"TITLE": "New gallery photo",
"DESC": "Upload a suite photo and metadata for agent search.",
"SUBMIT_BUTTON_TEXT": "Save photo",
"API": {
"SUCCESS_MESSAGE": "Photo created successfully!",
"ERROR_MESSAGE": "Unable to create the photo."
}
},
"EDIT": {
"TITLE": "Edit gallery photo",
"DESC": "Update gallery metadata and image.",
"SUBMIT_BUTTON_TEXT": "Save changes",
"API": {
"SUCCESS_MESSAGE": "Photo updated successfully!",
"ERROR_MESSAGE": "Unable to update the photo."
}
},
"FORM": {
"INBOX": {
"LABEL": "Inbox",
"GLOBAL_OPTION": "Global (all inboxes)",
"HELP": "Select which inbox can use these photos.",
"GLOBAL_HELP": "These photos can be used by agents in any inbox.",
"SPECIFIC_HELP": "These photos will be used only in inbox {inbox}."
},
"SUITE_CATEGORY": {
"LABEL": "Suite category",
"PLACEHOLDER": "Ex: Hydromassage",
"ERROR": "Suite category is required"
},
"SUITE_NUMBER": {
"LABEL": "Suite number/identifier",
"PLACEHOLDER": "Ex: 101",
"ERROR": "Suite identifier is required"
},
"DESCRIPTION": {
"LABEL": "Photo description",
"PLACEHOLDER": "Describe what appears in this photo",
"ERROR": "Description is required"
},
"IMAGE": {
"LABEL": "Image",
"HELP_TEXT": "Use clear photos. Recommended formats: PNG/JPG.",
"ERROR": "Image is required",
"PREVIEW_ALT": "Photo preview"
},
"ACTIVE": {
"LABEL": "Available for agent sending"
}
}
}
}
}

View File

@ -383,6 +383,7 @@
"CONTACT_NOTES": "Contact Notes",
"CONTACT_ATTRIBUTES": "Contact Attributes",
"PREVIOUS_CONVERSATION": "Previous Conversations",
"RESERVATION": "Reservation",
"MACROS": "Macros",
"LINEAR_ISSUES": "Linked Linear Issues",
"SHOPIFY_ORDERS": "Shopify Orders"

View File

@ -237,19 +237,95 @@
"WHATSAPP_CLOUD_DESC": "Quick setup through Meta",
"TWILIO_DESC": "Connect via Twilio credentials",
"360_DIALOG": "360Dialog",
"BAILEYS": "Baileys",
"BAILEYS_DESC": "Connect via non-official API Baileys",
"ZAPI": "Z-API",
"ZAPI_DESC": "Connect via non-official API Z-API"
"WUZAPI": "Wuzapi",
"WUZAPI_DESC": "Connect your Wuzapi instance",
"EVOLUTION": "Evolution Go",
"EVOLUTION_DESC": "WhatsApp channel for customer service"
},
"CANCEL": "Cancel",
"WUZAPI": {
"BASE_URL": {
"LABEL": "Wuzapi Base URL",
"PLACEHOLDER": "https://wuzapi.yourdomain.com",
"HELP_TEXT": "The base URL where your Wuzapi instance is hosted"
},
"ADMIN_TOKEN": {
"LABEL": "Admin Token",
"PLACEHOLDER": "Your Wuzapi Admin Token"
},
"AUTO_CREATE_USER": {
"LABEL": "Auto-create Wuzapi User"
}
},
"EVOLUTION": {
"TITLE": "Evolution Go Configuration",
"ASTERISK": "*",
"API_URL": {
"LABEL": "API URL",
"PLACEHOLDER": "e.g. https://api.yourcompany.com"
},
"API_TOKEN": {
"LABEL": "API Token (Admin)",
"PLACEHOLDER": "Enter global or instance token"
},
"DISPLAY_NAME": {
"LABEL": "Display Name",
"PLACEHOLDER": "e.g. Customer Support",
"HELP_TEXT": "Friendly name for identification"
},
"CHANNEL_NAME": {
"LABEL": "Channel Name",
"PLACEHOLDER": "e.g. whatsapp-support",
"HELP_TEXT": "Automatically generated from display name"
},
"PHONE_NUMBER": {
"LABEL": "WhatsApp Number",
"PLACEHOLDER": "e.g. 11999999999"
},
"TEST_CONNECTION": "Test Connection",
"TEST_CONNECTION_BANNER": "Please test the connection before proceeding.",
"CONNECT_SUCCESS": "Connected successfully!",
"CONNECT_ERROR": "Connection error. Please check your credentials.",
"STATUS": {
"TITLE": "Connection Status",
"CONNECTED": "Connected",
"DISCONNECTED": "Disconnected",
"CONNECTING": "Connecting...",
"WAITING_QR": "Waiting for QR Code...",
"SCAN_QR": "Scan the QR Code below"
},
"ACTIONS": {
"RECONNECT": "Reconnect",
"DISCONNECT": "Disconnect"
},
"INSTANCE_SETTINGS": {
"TITLE": "Instance Settings",
"ALWAYS_ONLINE": "Always Online",
"REJECT_CALLS": "Reject Calls",
"READ_MESSAGES": "Mark as Read",
"IGNORE_GROUPS": "Ignore Groups",
"IGNORE_STATUS": "Ignore Status"
},
"HELP": {
"TITLE": "How does it work?",
"HELP_TEXT_1": "Evolution API is a premium provider that offers high stability for WhatsApp connections.",
"BRAZIL_FLAG": "🇧🇷",
"ICON_INFO": "",
"ICON_STAR": "⭐",
"ICON_ROCKET": "🚀",
"ICON_SHIELD": "🛡️",
"ICON_SYNC": "🔄",
"ICON_CHART": "📊",
"ICON_WARNING": "⚠️",
"FEATURE_1": "Ultra fast and stable connection",
"FEATURE_2": "End-to-end encryption",
"FEATURE_3": "Real-time automatic sync",
"FEATURE_4": "Delivery status reporting"
}
},
"SELECT_PROVIDER": {
"TITLE": "Select your API provider",
"DESCRIPTION": "Choose your WhatsApp provider. You can connect directly through Meta which requires no setup, or connect through Twilio using your account credentials.",
"ZAPI_PROMO": {
"TITLE": "Looking for a reliable WhatsApp solution?",
"DESCRIPTION": "Z-API offers superior stability compared to Baileys and is much simpler to set up than Cloud or Twilio - no complex configuration required. Perfect for businesses that want to get started quickly.",
"CTA": "Use Z-API"
}
"DESCRIPTION": "Choose your WhatsApp provider. You can connect directly through Meta which requires no setup, or connect through Twilio using your account credentials."
},
"INBOX_NAME": {
"LABEL": "Inbox Name",
@ -288,43 +364,6 @@
"WEBHOOK_URL": "Webhook URL",
"WEBHOOK_VERIFICATION_TOKEN": "Webhook Verification Token"
},
"PROVIDER_URL": {
"LABEL": "Provider URL",
"PLACEHOLDER": "If provider is not running locally, please provide the URL",
"ERROR": "Please enter a valid URL"
},
"MARK_AS_READ": {
"LABEL": "Send read receipts"
},
"INSTANCE_ID": {
"LABEL": "Instance ID",
"PLACEHOLDER": "Please enter your instance ID",
"ERROR": "This field is required"
},
"TOKEN": {
"LABEL": "Token",
"PLACEHOLDER": "Please enter your instance Token",
"ERROR": "This field is required"
},
"CLIENT_TOKEN": {
"LABEL": "Security Token",
"PLACEHOLDER": "Please enter your Security Token (see Security tab on Z-API dashboard)",
"ERROR": "This field is required"
},
"ADVANCED_OPTIONS": "Advanced options",
"EXTERNAL_PROVIDER": {
"SUBTITLE": "Click below to setup the WhatsApp channel.",
"LINK_BUTTON": "Link device",
"LINK_DEVICE_MODAL": {
"TITLE": "Link your device",
"SUBTITLE": "Scan the QR code to link your device. Make sure the phone number is correct before scanning.",
"LOADING_QRCODE": "Loading QR code...",
"RECONNECTING": "Connecting...",
"LINK_DEVICE": "Link device",
"DISCONNECT": "Disconnect",
"CONNECTED": "Your device has been connected successfully. You can now start sending and receiving messages."
}
},
"SUBMIT_BUTTON": "Create WhatsApp Channel",
"EMBEDDED_SIGNUP": {
"TITLE": "Quick setup with Meta",
@ -354,13 +393,6 @@
"MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if youre a tech provider onboarding your own number, please use the {link} flow",
"MANUAL_LINK_TEXT": "manual setup flow"
},
"ZAPI_PROMO": {
"SETUP_BANNER": {
"TITLE": "Get 10% off your Z-API subscription",
"DESCRIPTION": "Create your Z-API account using our affiliate link and receive 10% off. Simple setup, reliable connections, and great support.",
"CTA": "Create Z-API Account"
}
},
"API": {
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
}
@ -582,7 +614,8 @@
"ERROR_FB_UNAUTHORIZED_HELP": "Please ensure you have access to the Facebook page with full control. You can read more about Facebook roles <a href=\" https://www.facebook.com/help/187316341316631\">here</a>.",
"CREATING_CHANNEL": "Creating your Inbox...",
"TITLE": "Configure Inbox Details",
"DESC": ""
"DESC": "",
"MESSENGER_CONFIG": "Configuration"
},
"AGENTS": {
"BUTTON_TEXT": "Add agents",
@ -633,10 +666,29 @@
"SAVE_BUTTON_TEXT": "Save"
}
},
"WUZAPI": {
"CONNECTED": "Wuzapi Connected",
"CONNECTED_DESC": "Your Wuzapi inbox is connected and ready to send/receive messages.",
"DISCONNECT": "Disconnect",
"DISCONNECT_SUCCESS": "Disconnected successfully",
"DISCONNECT_ERROR": "Error disconnecting",
"SCAN_QR": "Scan the QR Code to connect",
"LOADING_QR": "Loading QR Code...",
"CONNECT": "Connect WhatsApp",
"CONNECT_DESC": "Click below to start the connection process"
},
"EVOLUTION": {
"CONNECTED": "Evolution Go Connected",
"CONNECTED_DESC": "Your Evolution Go inbox is connected and ready to send/receive messages.",
"DISCONNECT": "Disconnect",
"DISCONNECT_SUCCESS": "Disconnected successfully",
"DISCONNECT_ERROR": "Error disconnecting"
},
"ALLOW_MESSAGES_AFTER_RESOLVED": {
"ENABLED": "Enabled",
"DISABLED": "Disabled"
},
"ENABLE_CONTINUITY_VIA_EMAIL": {
"ENABLED": "Enabled",
"DISABLED": "Disabled"
@ -805,29 +857,7 @@
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Manually sync message templates from WhatsApp to update your available templates.",
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sync Templates",
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Templates sync initiated successfully. It may take a couple of minutes to update.",
"WHATSAPP_MANAGE_PROVIDER_CONNECTION_TITLE": "Manage Provider Connection",
"WHATSAPP_MANAGE_PROVIDER_CONNECTION_SUBHEADER": "Link your device and manage the provider connection.",
"WHATSAPP_MANAGE_PROVIDER_CONNECTION_BUTTON": "Manage connection",
"WHATSAPP_PROVIDER_URL_TITLE": "Provider URL",
"WHATSAPP_PROVIDER_URL_SUBHEADER": "If the provider is not running locally, please provide the URL.",
"WHATSAPP_PROVIDER_URL_PLACEHOLDER": "Enter the provider URL",
"WHATSAPP_PROVIDER_URL_ERROR": "Please enter a valid URL",
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings",
"WHATSAPP_MARK_AS_READ_TITLE": "Read receipts",
"WHATSAPP_MARK_AS_READ_SUBHEADER": "If turned off, when a message is viewed in Chatwoot, a read receipt will not be sent to the sender. Your messages will still be able to receive read receipts from the sender.",
"WHATSAPP_MARK_AS_READ_LABEL": "Send read receipts",
"WHATSAPP_INSTANCE_ID_TITLE": "Instance ID",
"WHATSAPP_INSTANCE_ID_SUBHEADER": "Your Z-API Instance ID.",
"WHATSAPP_INSTANCE_ID_UPDATE_TITLE": "Update Instance ID",
"WHATSAPP_INSTANCE_ID_UPDATE_SUBHEADER": "Enter the new Instance ID here",
"WHATSAPP_TOKEN_TITLE": "Token",
"WHATSAPP_TOKEN_SUBHEADER": "Your Z-API instance Token.",
"WHATSAPP_TOKEN_UPDATE_TITLE": "Update Token",
"WHATSAPP_TOKEN_UPDATE_SUBHEADER": "Enter the new instance Token here",
"WHATSAPP_CLIENT_TOKEN_TITLE": "Security Token",
"WHATSAPP_CLIENT_TOKEN_SUBHEADER": "Your Z-API Client Token (see Security tab on Z-API dashboard).",
"WHATSAPP_CLIENT_TOKEN_UPDATE_TITLE": "Update Security Token",
"WHATSAPP_CLIENT_TOKEN_UPDATE_SUBHEADER": "Enter the new Security Token here"
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
},
"HELP_CENTER": {
"LABEL": "Help Center",
@ -841,53 +871,6 @@
"MAX_ASSIGNMENT_LIMIT_RANGE_ERROR": "Please enter a value greater than 0",
"MAX_ASSIGNMENT_LIMIT_SUB_TEXT": "Limit the maximum number of conversations from this inbox that can be auto assigned to an agent"
},
"ASSIGNMENT": {
"TITLE": "Conversation Assignment",
"DESCRIPTION": "Automatically assign incoming conversations to available agents based on assignment policies",
"ENABLE_AUTO_ASSIGNMENT": "Enable automatic conversation assignment",
"DEFAULT_RULES_TITLE": "Default assignment rules",
"DEFAULT_RULES_DESCRIPTION": "Using the default assignment behavior for all conversations",
"DEFAULT_RULE_1": "Earliest created conversations first",
"DEFAULT_RULE_2": "Round robin distribution",
"CUSTOMIZE_WITH_POLICY": "Customize with assignment policy",
"USING_POLICY": "Using custom assignment policy for this inbox",
"CUSTOMIZE_POLICY": "Customize with assignment policy",
"DELETE_POLICY": "Delete policy",
"POLICY_LABEL": "Assignment policy",
"ASSIGNMENT_ORDER_LABEL": "Assignment Order",
"ASSIGNMENT_METHOD_LABEL": "Assignment Method",
"POLICY_STATUS": {
"ACTIVE": "Active",
"INACTIVE": "Inactive"
},
"PRIORITY": {
"EARLIEST_CREATED": "Earliest created",
"LONGEST_WAITING": "Longest waiting"
},
"METHOD": {
"ROUND_ROBIN": "Round robin",
"BALANCED": "Balanced assignment"
},
"UPGRADE_PROMPT": "Custom assignment policies are available on the Business plan",
"UPGRADE_TO_BUSINESS": "Upgrade to Business",
"DEFAULT_POLICY_LINKED": "Default policy linked",
"DEFAULT_POLICY_DESCRIPTION": "Link a custom assignment policy to customize how conversations are assigned to agents in this inbox.",
"LINK_EXISTING_POLICY": "Link existing policy",
"CREATE_NEW_POLICY": "Create new policy",
"NO_POLICIES": "No assignment policies found",
"VIEW_ALL_POLICIES": "View all policies",
"CURRENT_BEHAVIOR": "Currently using default assignment behavior:",
"LINK_SUCCESS": "Assignment policy linked successfully",
"LINK_ERROR": "Failed to link assignment policy"
},
"ASSIGNMENT_POLICY": {
"DELETE_CONFIRM_TITLE": "Delete assignment policy?",
"DELETE_CONFIRM_MESSAGE": "Are you sure you want to remove this assignment policy from this inbox? The inbox will revert to default assignment rules.",
"CANCEL": "Cancel",
"CONFIRM_DELETE": "Delete",
"DELETE_SUCCESS": "Assignment policy removed successfully",
"DELETE_ERROR": "Failed to remove assignment policy"
},
"FACEBOOK_REAUTHORIZE": {
"TITLE": "Reauthorize",
"SUBTITLE": "Your Facebook connection has expired, please reconnect your Facebook page to continue services",
@ -930,61 +913,6 @@
"LABEL": "Message",
"PLACEHOLDER": "Please enter a message to show users with the form"
},
"BUTTON_TEXT": {
"LABEL": "Button text",
"PLACEHOLDER": "Please rate us"
},
"LANGUAGE": {
"LABEL": "Language",
"PLACEHOLDER": "Select template language"
},
"MESSAGE_PREVIEW": {
"LABEL": "Message preview",
"TOOLTIP": "This may vary slightly when rendered on WhatsApp's platform."
},
"TEMPLATE_STATUS": {
"APPROVED": "Approved by WhatsApp",
"PENDING": "Pending WhatsApp approval",
"REJECTED": "Meta rejected the template",
"DEFAULT": "Needs WhatsApp approval",
"NOT_FOUND": "The template does not exist in the Meta platform."
},
"TEMPLATE_CREATION": {
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
"ERROR_MESSAGE": "Failed to create WhatsApp template"
},
"TEMPLATE_MODE": {
"LABEL": "Template source",
"CREATE_NEW": "Create new template",
"USE_EXISTING": "Use existing template"
},
"EXISTING_TEMPLATE": {
"LABEL": "Select template",
"PLACEHOLDER": "Select an approved template",
"SYNC_BUTTON": "Sync templates",
"SYNCING": "Syncing...",
"EMPTY_STATE": "No compatible templates found. Templates must have a URL button with a dynamic parameter.",
"LINK_SUCCESS": "Template linked successfully",
"LINK_ERROR": "Failed to link template",
"LOADING": "Loading templates...",
"USING_LABEL": "Using existing template: {name}",
"SYNC_ERROR": "Failed to sync templates. Please try again."
},
"TEMPLATE_VARIABLES": {
"TITLE": "Template variables",
"DESCRIPTION": "Set values for the template body variables. You can use dynamic variables like {'{{'} contact.name {'}}'} that will be resolved at send time.",
"VARIABLE_LABEL": "Variable {variable}",
"VARIABLE_PLACEHOLDER": "Enter value for {variable}",
"VARIABLE_REQUIRED": "This variable is required",
"VALIDATION_ERROR": "Please set all template variables before saving.",
"INSERT_VARIABLE": "Insert variable"
},
"TEMPLATE_UPDATE_DIALOG": {
"TITLE": "Edit survey details",
"DESCRIPTION": "We will delete the previous template and make a new one which will be sent again for WhatsApp approval",
"CONFIRM": "Create new template",
"CANCEL": "Go back"
},
"SURVEY_RULE": {
"LABEL": "Survey rule",
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
@ -996,7 +924,6 @@
"SELECT_PLACEHOLDER": "select labels"
},
"NOTE": "Note: CSAT surveys are sent only once per conversation",
"WHATSAPP_NOTE": "Note: You can create a new template (which will be sent for WhatsApp approval) or use an existing approved template from your Meta template manager. Surveys are sent only once per conversation as per the survey rule.",
"API": {
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
@ -1192,8 +1119,6 @@
"TWITTER_PROFILE": "Twitter",
"TWILIO_SMS": "Twilio SMS",
"WHATSAPP": "WhatsApp",
"WHATSAPP_BAILEYS": "WhatsApp - Baileys",
"WHATSAPP_ZAPI": "WhatsApp - Z-API",
"SMS": "SMS",
"EMAIL": "Email",
"TELEGRAM": "Telegram",

View File

@ -7,6 +7,7 @@ import automation from './automation.json';
import bulkActions from './bulkActions.json';
import campaign from './campaign.json';
import cannedMgmt from './cannedMgmt.json';
import captain from './captain.json';
import chatlist from './chatlist.json';
import companies from './companies.json';
import components from './components.json';
@ -24,6 +25,7 @@ import inbox from './inbox.json';
import inboxMgmt from './inboxMgmt.json';
import integrationApps from './integrationApps.json';
import integrations from './integrations.json';
import jasmine from './jasmine.json';
import labelsMgmt from './labelsMgmt.json';
import login from './login.json';
import macros from './macros.json';
@ -50,6 +52,7 @@ export default {
...bulkActions,
...campaign,
...cannedMgmt,
...captain,
...chatlist,
...companies,
...components,
@ -67,6 +70,7 @@ export default {
...inboxMgmt,
...integrationApps,
...integrations,
...jasmine,
...labelsMgmt,
...login,
...macros,

View File

@ -840,7 +840,8 @@
"NONE": "None",
"BEARER": "Bearer Token",
"BASIC": "Basic Auth",
"API_KEY": "API Key"
"API_KEY": "API Key",
"CUSTOM_HEADERS": "Custom Headers"
},
"AUTH_CONFIG": {
"BEARER_TOKEN": "Bearer Token",
@ -852,7 +853,14 @@
"API_KEY": "Header Name",
"API_KEY_PLACEHOLDER": "X-API-Key",
"API_VALUE": "Header Value",
"API_VALUE_PLACEHOLDER": "Enter API key value"
"API_VALUE_PLACEHOLDER": "Enter API key value",
"CUSTOM_HEADERS": "Custom Headers",
"CUSTOM_HEADERS_HELP": "Add one or more HTTP headers sent with every request (e.g. API IDs, tokens).",
"HEADER_NAME": "Header Name",
"HEADER_NAME_PLACEHOLDER": "e.g. PLUG-PLAY-ID",
"HEADER_VALUE": "Header Value",
"HEADER_VALUE_PLACEHOLDER": "e.g. 198",
"ADD_HEADER": "Add Header"
},
"PARAMETERS": {
"LABEL": "Parameters",

View File

@ -0,0 +1,81 @@
{
"JASMINE": {
"HEADER": {
"TITLE": "Jasmine AI Agents",
"DESCRIPTION": "Manage your AI SDR agents. Select an inbox to configure your knowledge base.",
"EMPTY": "No inboxes found"
},
"CONFIG": {
"TITLE": "Jasmine AI Configuration",
"DESCRIPTION": "Configure the AI agent for this inbox.",
"ENABLE": "Enable Jasmine AI Agent",
"SYSTEM_PROMPT": "System Prompt",
"SYSTEM_PROMPT_HELP": "Define the persona and behavioral rules for the agent.",
"UPDATE_BUTTON": "Update Configuration"
},
"KNOWLEDGE_BASE": {
"TITLE": "Knowledge Base",
"DESCRIPTION": "Manage knowledge collections for this inbox",
"ADD_BUTTON": "+ New Collection",
"DOCUMENTS": "Documents",
"LOADING_DOCS": "Loading documents...",
"UNTITLED_DOC": "Untitled Document",
"NO_DOCS": "No documents yet. Add your first document below.",
"ADD_DOC_HEADER": "Add New Document",
"DOC_TITLE_PLACEHOLDER": "Document title (optional)",
"DOC_CONTENT_PLACEHOLDER": "Paste or type your knowledge content here...",
"ADD_DOC_BUTTON": "Add Document",
"NO_COLLECTIONS": "No collections yet. Create one to get started.",
"CREATE_MODAL": {
"TITLE": "Create Collection",
"NAME_PLACEHOLDER": "Collection name",
"VISIBILITY_PRIVATE": "Private (This inbox only)",
"VISIBILITY_SHARED": "Shared (All inboxes)",
"CANCEL": "Cancel",
"CREATE": "Create"
},
"DELETE_CONFIRM": "Are you sure you want to delete this document?",
"DOCUMENT_DELETE_SUCCESS": "Document deleted successfully",
"COLLECTION_DELETE_SUCCESS": "Collection deleted successfully",
"SAVE_SUCCESS": "Changes saved successfully",
"DOCUMENT_CREATE_SUCCESS": "Document created successfully",
"COLLECTION_CREATE_SUCCESS": "Collection created successfully"
},
"PLAYGROUND": {
"TITLE": "Jasmine AI Playground",
"DESCRIPTION": "Test Jasmine responses in real-time before enabling for customers.",
"SELECT_INBOX": "Select an Inbox to test",
"CHOOSE_INBOX": "Choose an inbox...",
"WARNING": "Make sure Jasmine is enabled and configured for this inbox",
"EMPTY_STATE_TITLE": "Send a message to test Jasmine",
"EMPTY_STATE_EXAMPLES": "Try: \"Hello\", \"How much does it cost?\", \"How does it work?\"",
"LOADING": "Jasmine is thinking...",
"INPUT_PLACEHOLDER": "Type a test message...",
"CLEAR_TOOLTIP": "Clear conversation",
"NO_INBOX_SELECTED": "Select an inbox above to start testing"
},
"INBOX_LIST": {
"ACTIVE": "Active",
"CONFIGURE": "Configure",
"DESCRIPTION": "Channel {channel} configured for Jasmine AI"
},
"WUZAPI": {
"STATUS": "Status: {status}",
"ACCOUNT_ERROR": "Error: Account ID not loaded. Please refresh the page.",
"CONNECT_FALLBACK": "Click to initiate connection",
"CONNECT_BUTTON_FALLBACK": "Connect WhatsApp",
"WEBHOOK_SECTION": "Webhook Configuration",
"GET_WEBHOOK_INFO": "Get Webhook Info",
"UPDATE_WEBHOOK": "Update Webhook Connection"
},
"EVOLUTION": {
"STATUS": "Status: {status}",
"ACCOUNT_ERROR": "Error: Instance ID not found. Please check your configuration.",
"CONNECT_FALLBACK": "Click to initiate connection",
"CONNECT_BUTTON_FALLBACK": "Connect WhatsApp",
"WEBHOOK_SECTION": "Webhook Configuration",
"GET_WEBHOOK_INFO": "Get Webhook Info",
"UPDATE_WEBHOOK": "Update Webhook Connection"
}
}
}

View File

@ -340,6 +340,9 @@
"CAPTAIN_PLAYGROUND": "Playground",
"CAPTAIN_INBOXES": "Inboxes",
"CAPTAIN_SETTINGS": "Settings",
"CAPTAIN_PIX_UNITS": "Pix Units",
"CAPTAIN_GALLERY": "Gallery",
"CAPTAIN_RESERVATIONS": "Reservations",
"HOME": "Home",
"AGENTS": "Agents",
"AGENT_BOTS": "Bots",
@ -411,6 +414,84 @@
"LOADING": "Loading Captain configuration...",
"LINK_TEXT": "Learn more about Captain Credits",
"NOT_ENABLED": "Captain is not enabled for your account. Please upgrade your plan to access Captain features.",
"UNITS": {
"TITLE": "Pix Units",
"DESC": "Manage settings for different Pix units integrated with Banco Inter.",
"ADD_UNIT": "Add Unit",
"LIST": {
"LOADING": "Loading Pix units...",
"TABLE_HEADER": ["Pix Key", "Account", "Certificates", "Actions"],
"CERT": "Cert",
"KEY": "Key",
"SEPARATOR": "|",
"ADD_NEW_UNIT": "Add a Pix Unit",
"NO_UNITS_MESSAGE": "No Pix units added yet. Create one to start receiving payments via Pix from Banco Inter."
},
"DELETE": {
"TITLE": "Delete Pix Unit",
"DESC": "Are you sure you want to delete this Pix unit?",
"CONFIRM": { "YES": "Delete", "NO": "Cancel" },
"API": {
"SUCCESS_MESSAGE": "Unit deleted successfully.",
"ERROR_MESSAGE": "There was an error deleting the unit."
}
},
"ADD": {
"TITLE": "New Pix Unit",
"DESC": "Add your Banco Inter app credentials",
"API": {
"SUCCESS_MESSAGE": "Unit created successfully!",
"ERROR_MESSAGE": "There was an error creating the unit."
}
},
"EDIT": {
"TITLE": "Edit Pix Unit",
"DESC": "Update your Banco Inter credentials",
"API": {
"SUCCESS_MESSAGE": "Unit updated successfully!",
"ERROR_MESSAGE": "There was an error updating the unit."
}
},
"FORM": {
"NAME": {
"LABEL": "Unit Name",
"PLACEHOLDER": "e.g. Headquarters",
"ERROR": "Name is required"
},
"INTER_PIX_KEY": {
"LABEL": "Pix Key (Inter app)",
"PLACEHOLDER": "e.g. 12.345.678/0001-90",
"ERROR": "Pix key is required",
"HELP_TEXT": "Your Pix key that will receive charges"
},
"INTER_ACCOUNT_NUMBER": {
"LABEL": "Inter Account Number",
"PLACEHOLDER": "e.g. 1234567-8",
"ERROR": "Account number is required"
},
"INTER_CLIENT_ID": {
"LABEL": "Inter App Client ID",
"PLACEHOLDER": ""
},
"INTER_CLIENT_SECRET": {
"LABEL": "Inter App Client Secret",
"PLACEHOLDER": ""
},
"INTER_CERT_CONTENT": {
"LABEL": "Certificate Content (.crt)",
"PLACEHOLDER": "Paste the .crt file content here...",
"UPLOAD_BUTTON": "Import .crt file"
},
"INTER_KEY_CONTENT": {
"LABEL": "Key Content (.key)",
"PLACEHOLDER": "Paste the .key file content here...",
"UPLOAD_BUTTON": "Import .key file"
},
"CERT_PRESENT_HELP": "Certificate already configured.",
"CANCEL": "Cancel",
"SAVE": "Save"
}
},
"MODEL_CONFIG": {
"TITLE": "Model Configuration",
"DESCRIPTION": "Select AI models for different features.",

View File

@ -0,0 +1,281 @@
{
"CAPTAIN_RESERVATIONS": {
"HEADER": "Reservas",
"EMPTY": "Nenhuma reserva encontrada.",
"VIEW": {
"LIST": "Lista",
"KANBAN": "Kanban",
"REVENUE": "Faturamento"
},
"FILTERS": {
"SEARCH": "Buscar por nome, CPF ou telefone",
"STATUS": "Status",
"STATUS_ALL": "Todos os status",
"UNIT": "Unidade",
"UNIT_ALL": "Todas as unidades",
"SUITE": "Suíte",
"DATE_FROM": "De",
"DATE_TO": "Até",
"SORT": "Ordenação",
"SORT_DEFAULT": "Prioridade operacional",
"SORT_CHECK_IN": "Data de check-in",
"SORT_UPDATED": "Última atualização",
"SORT_CREATED": "Data de criação",
"APPLY": "Aplicar filtros",
"CLEAR": "Limpar filtros"
},
"TABLE": {
"CUSTOMER": "Cliente",
"UNIT": "Unidade",
"SUITE": "Suíte",
"CHECK_IN": "Check-in",
"AMOUNT": "Valor",
"STATUS": "Status",
"UPDATED_AT": "Atualização",
"ACTIONS": "Ações"
},
"STATUS": {
"DRAFT": "Rascunho",
"PENDING_PAYMENT": "Aguardando pagamento",
"CONFIRMED": "Confirmada",
"CANCELLED": "Cancelada"
},
"ACTIONS": {
"OPEN_CONVERSATION": "Abrir conversa",
"COPY_PIX": "Copiar Pix"
},
"KANBAN": {
"EMPTY_COLUMN": "Nenhuma reserva neste status."
},
"REVENUE": {
"ONLY_CONFIRMED": "O faturamento considera apenas reservas com status confirmado.",
"SUMMARY": {
"TOTAL_REVENUE": "Faturamento total",
"CONFIRMED_COUNT": "Reservas confirmadas",
"AVERAGE_TICKET": "Ticket médio"
},
"CHARTS": {
"BY_UNIT": "Faturamento por unidade",
"BY_SUITE": "Faturamento por suíte"
},
"API": {
"ERROR": "Não foi possível carregar o faturamento."
}
},
"SIDEBAR": {
"NO_RESERVATION": "Nenhuma reserva vinculada a esta conversa.",
"LOADING": "Carregando detalhes da reserva...",
"STATUS": "Status",
"SUITE": "Suíte",
"CHECK_IN": "Check-in",
"CHECK_OUT": "Check-out",
"AMOUNT": "Valor",
"UPDATED_AT": "Atualizado em"
},
"API": {
"PIX_EXPIRED": "O Pix desta reserva expirou.",
"PIX_NOT_GENERATED": "O Pix ainda não foi gerado.",
"PIX_COPIED": "Pix copiado com sucesso.",
"PIX_COPY_FAILED": "Não foi possível copiar o Pix."
}
},
"CAPTAIN_SETTINGS": {
"TITLE": "Configurações do Captain",
"UNITS": {
"TITLE": "Unidades Pix",
"DESC": "Gerencie as configurações de diferentes unidades Pix Integradas com Banco Inter.",
"ADD_UNIT": "Adicionar Unidade",
"EDIT_UNIT": "Editar Unidade",
"DELETE_UNIT": "Deletar Unidade",
"LIST": {
"TABLE_HEADER": [
"Pix Key",
"Conta",
"Certificados",
"Monitoramento",
"Ações"
],
"CERT": "Cert",
"KEY": "Key",
"PROACTIVE_ON": "Auto-check ligado",
"PROACTIVE_OFF": "Auto-check desligado",
"SEPARATOR": "|",
"ADD_NEW_UNIT": "Adicione uma Unidade Pix",
"NO_UNITS_MESSAGE": "Ainda não há nenhuma unidade pix inserida. Crie uma agora para começar a receber pelo pix do Banco Inter."
},
"DELETE": {
"BUTTON_TEXT": "Excluir",
"TITLE": "Excluir Unidade Pix",
"DESC": "Tem certeza de que deseja excluir esta unidade pix? Esta ação não pode ser desfeita.",
"CONFIRM": {
"TITLE": "Confirmar Exclusão",
"MESSAGE": "Tem certeza que deseja apagar a Unidade?",
"YES": "Excluir",
"NO": "Cancelar"
},
"API": {
"SUCCESS_MESSAGE": "Unidade excluída com sucesso.",
"ERROR_MESSAGE": "Houve um erro ao tentar excluir a unidade."
}
},
"ADD": {
"TITLE": "Nova Unidade Pix",
"DESC": "Adicione as credenciais do seu app Banco Inter",
"CANCEL_BUTTON_TEXT": "Cancelar",
"SUBMIT_BUTTON_TEXT": "Criar unidade",
"API": {
"SUCCESS_MESSAGE": "Unidade criada com sucesso!",
"ERROR_MESSAGE": "Houve um erro ao tentar criar a unidade."
}
},
"EDIT": {
"TITLE": "Editar Unidade Pix",
"DESC": "Atualize as credenciais do Banco Inter",
"CANCEL_BUTTON_TEXT": "Cancelar",
"SUBMIT_BUTTON_TEXT": "Salvar unidade",
"API": {
"SUCCESS_MESSAGE": "Unidade atualizada com sucesso!",
"ERROR_MESSAGE": "Houve um erro ao tentar atualizar."
}
},
"FORM": {
"NAME": {
"LABEL": "Nome da Unidade",
"PLACEHOLDER": "Ex: Matriz",
"ERROR": "O nome é obrigatório"
},
"INTER_PIX_KEY": {
"LABEL": "Chave Pix (do app Inter)",
"PLACEHOLDER": "Ex: 12.345.678/0001-90",
"ERROR": "A chave Pix é obrigatória",
"HELP_TEXT": "Sua chave Pix que receberá as cobranças"
},
"INTER_ACCOUNT_NUMBER": {
"LABEL": "Número da conta Inter",
"PLACEHOLDER": "Ex: 1234567-8",
"ERROR": "O número da conta é obrigatório"
},
"INTER_CLIENT_ID": {
"LABEL": "Client ID do App Inter",
"PLACEHOLDER": ""
},
"INTER_CLIENT_SECRET": {
"LABEL": "Client Secret do App Inter",
"PLACEHOLDER": ""
},
"INTER_CERT_CONTENT": {
"LABEL": "Conteúdo do Certificado (.crt)",
"PLACEHOLDER": "Cole o texto contido no arquivo .crt aqui...",
"UPLOAD_BUTTON": "Importar arquivo .crt"
},
"INTER_KEY_CONTENT": {
"LABEL": "Conteúdo da Chave (.key)",
"PLACEHOLDER": "Cole o texto contido no arquivo .key aqui...",
"UPLOAD_BUTTON": "Importar arquivo .key"
},
"PROACTIVE_PIX_POLLING": {
"LABEL": "Confirmação proativa de pagamento (Banco Inter)",
"CHECKBOX_LABEL": "Verificar pagamento automaticamente a cada 10 minutos por até 1 hora",
"HELP_TEXT": "Quando habilitado, o sistema consulta o Inter de forma proativa para confirmar pagamentos Pix pendentes.",
"DISABLED_HELP_TEXT": "Complete as credenciais do Banco Inter (Client ID/Secret, conta, chave Pix, certificado e chave) para habilitar."
},
"CERT_PRESENT_HELP": "Certificado já configurado.",
"CANCEL": "Cancelar",
"SAVE": "Salvar"
},
"INBOX": {
"LABEL": "Unidade Pix",
"PLACEHOLDER": "Selecione uma unidade",
"NO_UNIT": "Nenhuma unidade vinculada",
"CONNECT_UNIT_LABEL": "Vincular Unidade Pix",
"CONNECT_UNIT_PLACEHOLDER": "Escolha a unidade Inter para este inbox",
"CONNECT_UNIT_HELP": "Selecione qual Unidade Pix deve ser usada nesta caixa de entrada."
},
"TEST": {
"HEADER_TITLE": "Validação de Header",
"HEADER_DESCRIPTION": "Teste visual para validar carregamento da página.",
"BODY_TEXT": "Se esta tela abrir, o problema não está no layout base e sim na tabela ou no Vuex."
}
},
"GALLERY": {
"TITLE": "Galeria",
"DESC": "Gerencie as fotos de suítes que os subagentes podem enviar para os clientes.",
"ADD_ITEM": "Adicionar Foto",
"EDIT_ITEM": "Editar",
"DELETE_ITEM": "Excluir",
"LIST": {
"TABLE_HEADER": [
"Imagem",
"Caixa de entrada e descrição",
"Categoria",
"Suíte",
"Ações"
],
"ADD_NEW_ITEM": "Adicione fotos na galeria",
"NO_ITEMS_MESSAGE": "Ainda não há fotos cadastradas para envio automático aos clientes."
},
"DELETE": {
"CONFIRM": {
"TITLE": "Excluir foto",
"MESSAGE": "Tem certeza que deseja excluir esta foto da galeria?",
"YES": "Excluir"
},
"API": {
"SUCCESS_MESSAGE": "Foto excluída com sucesso.",
"ERROR_MESSAGE": "Não foi possível excluir a foto."
}
},
"ADD": {
"TITLE": "Nova foto da galeria",
"DESC": "Cadastre uma foto de suíte e os metadados para busca pelo agente.",
"SUBMIT_BUTTON_TEXT": "Salvar foto",
"API": {
"SUCCESS_MESSAGE": "Foto cadastrada com sucesso!",
"ERROR_MESSAGE": "Não foi possível cadastrar a foto."
}
},
"EDIT": {
"TITLE": "Editar foto da galeria",
"DESC": "Atualize os dados e a imagem da galeria.",
"SUBMIT_BUTTON_TEXT": "Salvar alterações",
"API": {
"SUCCESS_MESSAGE": "Foto atualizada com sucesso!",
"ERROR_MESSAGE": "Não foi possível atualizar a foto."
}
},
"FORM": {
"INBOX": {
"LABEL": "Caixa de entrada",
"GLOBAL_OPTION": "Global (todas as caixas)",
"HELP": "Selecione a caixa de entrada onde essas fotos podem ser usadas.",
"GLOBAL_HELP": "Essas fotos podem ser usadas por agentes em qualquer caixa de entrada.",
"SPECIFIC_HELP": "Essas fotos serão usadas somente na caixa de entrada {inbox}."
},
"SUITE_CATEGORY": {
"LABEL": "Categoria da suíte",
"PLACEHOLDER": "Ex: Hidromassagem",
"ERROR": "A categoria é obrigatória"
},
"SUITE_NUMBER": {
"LABEL": "Número/identificador da suíte",
"PLACEHOLDER": "Ex: 101",
"ERROR": "O identificador da suíte é obrigatório"
},
"DESCRIPTION": {
"LABEL": "Descrição da foto",
"PLACEHOLDER": "Descreva rapidamente o que aparece na imagem",
"ERROR": "A descrição é obrigatória"
},
"IMAGE": {
"LABEL": "Imagem",
"HELP_TEXT": "Use imagens nítidas. Formatos recomendados: PNG/JPG.",
"ERROR": "A imagem é obrigatória",
"PREVIEW_ALT": "Pré-visualização da foto"
},
"ACTIVE": {
"LABEL": "Disponível para envio pelos agentes"
}
}
}
}
}

View File

@ -372,6 +372,7 @@
"CONTACT_NOTES": "Notas do contato",
"CONTACT_ATTRIBUTES": "Atributos do contato",
"PREVIOUS_CONVERSATION": "Conversas anteriores",
"RESERVATION": "Reserva",
"MACROS": "Macros",
"LINEAR_ISSUES": "Problemas do Linear vinculados",
"SHOPIFY_ORDERS": "Shopify Orders"

View File

@ -34,6 +34,9 @@
"PLACEHOLDER": "Digite o nome da caixa de entrada (ex: Acme Inc)",
"ERROR": "Por favor, insira um nome completo válido"
},
"MESSAGE_SIGNATURE": {
"LABEL": "Assinar Mensagens"
},
"WEBSITE_NAME": {
"LABEL": "Nome do site",
"PLACEHOLDER": "Informe o nome do seu site (por exemplo: Acme Inc)"
@ -58,11 +61,11 @@
"DUPLICATE_INBOX_BANNER": "Esta conta do Instagram foi migrada para a nova caixa de entrada de canal do Instagram. Você não poderá mais enviar/receber mensagens do Instagram desta caixa de entrada."
},
"TIKTOK": {
"CONTINUE_WITH_TIKTOK": "Continuar com TikTok",
"CONNECT_YOUR_TIKTOK_PROFILE": "Conecte seu perfil do TikTok",
"HELP": "Para adicionar seu perfil do TikTok como um canal, é necessário autenticar seu perfil clicando em 'Continuar com TikTok'. ",
"ERROR_MESSAGE": "Ocorreu um erro ao conectar com o TikTok. Tente novamente",
"ERROR_AUTH": "Ocorreu um erro ao conectar com o TikTok. Tente novamente"
"CONTINUE_WITH_TIKTOK": "Continue with TikTok",
"CONNECT_YOUR_TIKTOK_PROFILE": "Connect your TikTok Profile",
"HELP": "To add your TikTok profile as a channel, you need to authenticate your TikTok Profile by clicking on 'Continue with TikTok' ",
"ERROR_MESSAGE": "There was an error connecting to TikTok, please try again",
"ERROR_AUTH": "There was an error connecting to TikTok, please try again"
},
"TWITTER": {
"HELP": "Para adicionar seu perfil do Twitter como um canal, você precisa autenticar seu perfil do Twitter clicando em 'Entrar com o Twitter' ",
@ -237,10 +240,96 @@
"WHATSAPP_CLOUD_DESC": "Configuração rápida via Meta",
"TWILIO_DESC": "Conectar através de credenciais Twilio",
"360_DIALOG": "360Dialog",
"WUZAPI": "Wuzapi",
"WUZAPI_DESC": "Conecte sua instância Wuzapi",
"BAILEYS": "Baileys",
"BAILEYS_DESC": "Conectar via API não-oficial Baileys",
"ZAPI": "Z-API",
"ZAPI_DESC": "Conectar via API não-oficial Z-API"
"ZAPI_DESC": "Conectar via API não-oficial Z-API",
"EVOLUTION": "Evolution Go",
"EVOLUTION_DESC": "Canal do WhatsApp para atendimento ao cliente"
},
"CANCEL": "Cancelar",
"SUBMIT_BUTTON": "Criar Canal WhatsApp",
"WUZAPI": {
"BASE_URL": {
"LABEL": "URL Base do Wuzapi",
"PLACEHOLDER": "https://wuzapi.seudominio.com",
"HELP_TEXT": "A URL base onde sua instância Wuzapi está hospedada"
},
"ADMIN_TOKEN": {
"LABEL": "Token de Admin",
"PLACEHOLDER": "Seu Token de Admin do Wuzapi"
},
"AUTO_CREATE_USER": {
"LABEL": "Criar usuário Wuzapi automaticamente"
}
},
"EVOLUTION": {
"TITLE": "Configuração Evolution Go",
"ASTERISK": "*",
"API_URL": {
"LABEL": "URL da API",
"PLACEHOLDER": "Ex: https://api.suaempresa.com"
},
"API_TOKEN": {
"LABEL": "Token da API (Admin)",
"PLACEHOLDER": "Insira o token global ou da instância"
},
"DISPLAY_NAME": {
"LABEL": "Nome de Exibição",
"PLACEHOLDER": "Ex: Suporte ao Cliente",
"HELP_TEXT": "Nome amigável para identificação"
},
"CHANNEL_NAME": {
"LABEL": "Nome do Canal",
"PLACEHOLDER": "Ex: suporte-whatsapp",
"HELP_TEXT": "Gerado automaticamente do nome de exibição"
},
"PHONE_NUMBER": {
"LABEL": "Número do WhatsApp",
"PLACEHOLDER": "Ex: 11999999999"
},
"TEST_CONNECTION": "Testar Conexão",
"TEST_CONNECTION_BANNER": "Por favor, teste a conexão antes de prosseguir.",
"CONNECT_SUCCESS": "Conectado com sucesso!",
"CONNECT_ERROR": "Erro ao conectar. Verifique as credenciais.",
"STATUS": {
"TITLE": "Status da Conexão",
"CONNECTED": "Conectado",
"DISCONNECTED": "Desconectado",
"CONNECTING": "Conectando...",
"WAITING_QR": "Aguardando QR Code...",
"SCAN_QR": "Escaneie o QR Code abaixo"
},
"ACTIONS": {
"RECONNECT": "Reconectar",
"DISCONNECT": "Desconectar"
},
"INSTANCE_SETTINGS": {
"TITLE": "Configurações da Instância",
"ALWAYS_ONLINE": "Sempre Online",
"REJECT_CALLS": "Rejeitar Chamadas",
"READ_MESSAGES": "Marcar como Lida",
"IGNORE_GROUPS": "Ignorar Grupos",
"IGNORE_STATUS": "Ignorar Status"
},
"HELP": {
"TITLE": "Como funciona?",
"HELP_TEXT_1": "O Evolution API é um provedor premium que oferece alta estabilidade para conexões de WhatsApp.",
"BRAZIL_FLAG": "🇧🇷",
"ICON_INFO": "",
"ICON_STAR": "⭐",
"ICON_ROCKET": "🚀",
"ICON_SHIELD": "🛡️",
"ICON_SYNC": "🔄",
"ICON_CHART": "📊",
"ICON_WARNING": "⚠️",
"FEATURE_1": "Conexão ultra rápida e estável",
"FEATURE_2": "Criptografia de ponta a ponta",
"FEATURE_3": "Sincronização automática em tempo real",
"FEATURE_4": "Relatórios de status de entrega"
}
},
"SELECT_PROVIDER": {
"TITLE": "Selecione seu provedor de API",
@ -248,7 +337,17 @@
"ZAPI_PROMO": {
"TITLE": "Procurando uma solução WhatsApp confiável?",
"DESCRIPTION": "Z-API oferece estabilidade superior comparado ao Baileys e é muito mais simples de configurar que Cloud ou Twilio - sem necessidade de configuração complexa. Perfeito para empresas que querem começar rapidamente.",
"CTA": "Usar Z-API"
"CTA": "Usar Z-API",
"SWITCH_BANNER": {
"TITLE": "Considere mudar para Z-API para configuração mais fácil",
"DESCRIPTION": "Z-API fornece uma conexão mais estável que Baileys e requer menos configuração que Cloud/Twilio. Mude para uma integração WhatsApp sem complicações.",
"CTA": "Mudar para Z-API"
},
"SETUP_BANNER": {
"TITLE": "Ganhe 10% de desconto na sua assinatura Z-API",
"DESCRIPTION": "Crie sua conta Z-API usando nosso link de afiliado e receba 10% de desconto. Configuração simples, conexões confiáveis e ótimo suporte.",
"CTA": "Criar Conta Z-API"
}
}
},
"INBOX_NAME": {
@ -324,45 +423,6 @@
"DISCONNECT": "Desconectar",
"CONNECTED": "Seu dispositivo foi conectado com sucesso. Agora você pode começar a enviar e receber mensagens."
}
},
"SUBMIT_BUTTON": "Criar canal do WhatsApp",
"EMBEDDED_SIGNUP": {
"TITLE": "Configuração rápida com Meta",
"DESC": "Use o fluxo de inscrição incorporada do WhatsApp para conectar rapidamente novos números. Você será redirecionado para a Meta para entrar na sua conta do WhatsApp Business. Ter acesso de administrador ajudará a tornar a configuração simples e fácil.",
"BENEFITS": {
"TITLE": "Benefícios da inscrição incorporada:",
"EASY_SETUP": "Nenhuma configuração manual é necessária",
"SECURE_AUTH": "Autenticação segura baseada em OAuth",
"AUTO_CONFIG": "Configuração automática de webhook e número de telefone"
},
"LEARN_MORE": {
"TEXT": "Para saber mais sobre a inscrição integrada, preços e limitações, visite {link}.",
"LINK_TEXT": "este link"
},
"SUBMIT_BUTTON": "Conecte-se com WhatsApp Business",
"AUTH_PROCESSING": "Autenticando com Meta",
"WAITING_FOR_BUSINESS_INFO": "Por favor, complete a configuração do negócio na janela da Meta...",
"PROCESSING": "Configurando sua conta do WhatsApp Business",
"LOADING_SDK": "Carregando SDK do Facebook...",
"CANCELLED": "A inscrição no WhatsApp foi cancelada",
"SUCCESS_TITLE": "Conta do WhatsApp Business conectada!",
"WAITING_FOR_AUTH": "Aguardando autenticação...",
"INVALID_BUSINESS_DATA": "Dados de negócio inválidos recebidos do Facebook. Por favor, tente novamente.",
"SIGNUP_ERROR": "Ocorreu um erro no cadastro",
"AUTH_NOT_COMPLETED": "Autenticação não concluída. Por favor, reinicie o processo.",
"SUCCESS_FALLBACK": "A conta do WhatsApp Business foi configurada com sucesso",
"MANUAL_FALLBACK": "Se o seu número já estiver conectado à Plataforma WhatsApp Business (API) ou se você for um provedor de tecnologia integrando o seu próprio número, use o fluxo de {link}",
"MANUAL_LINK_TEXT": "fluxo de configuração manual"
},
"ZAPI_PROMO": {
"SETUP_BANNER": {
"TITLE": "Ganhe 10% de desconto na sua assinatura Z-API",
"DESCRIPTION": "Crie sua conta Z-API usando nosso link de afiliado e receba 10% de desconto. Configuração simples, conexões confiáveis e ótimo suporte.",
"CTA": "Criar Conta Z-API"
}
},
"API": {
"ERROR_MESSAGE": "Não foi possível salvar o canal do WhatsApp"
}
},
"VOICE": {
@ -442,10 +502,10 @@
"ERROR_MESSAGE": "Não foi possível salvar o canal de e-mail"
},
"FINISH_MESSAGE": "Comece a encaminhar seus e-mails para o seguinte endereço de e-mail.",
"FINISH_MESSAGE_NO_FORWARDING": "Sua caixa de entrada de e-mail foi criada com sucesso! É necessário configurar as credenciais de SMTP e IMAP para enviar e receber e-mails. Sem essas configurações, nenhum e-mail será processado.",
"FORWARDING_ADDRESS_LABEL": "Encaminhe os e-mails para este endereço:",
"FINISH_MESSAGE_NO_FORWARDING": "Your email inbox has been created successfully! You need to configure SMTP and IMAP credentials to send and receive emails. Without these settings, no emails will be processed.",
"FORWARDING_ADDRESS_LABEL": "Forward emails to this address:",
"CONFIGURE_SMTP_IMAP_LINK": "Clique aqui",
"CONFIGURE_SMTP_IMAP_TEXT": " para configurar IMAP e SMTP"
"CONFIGURE_SMTP_IMAP_TEXT": " to configure IMAP and SMTP settings"
},
"LINE_CHANNEL": {
"TITLE": "Canal LINE",
@ -533,7 +593,7 @@
},
"TIKTOK": {
"TITLE": "TikTok",
"DESCRIPTION": "Conecte sua conta do TikTok"
"DESCRIPTION": "Connect your TikTok account"
},
"VOICE": {
"TITLE": "Voz",
@ -637,6 +697,28 @@
"ENABLED": "Ativado",
"DISABLED": "Desativado"
},
"WUZAPI": {
"CONNECTED": "Wuzapi Conectado",
"CONNECTED_DESC": "Sua caixa de entrada Wuzapi está conectada e pronta para enviar/receber mensagens.",
"DISCONNECT": "Desconectar",
"DISCONNECT_SUCCESS": "Desconectado com sucesso",
"DISCONNECT_ERROR": "Erro ao desconectar",
"SCAN_QR": "Escaneie o QR Code com seu WhatsApp",
"LOADING_QR": "Carregando QR Code...",
"CONNECT": "Conectar WhatsApp",
"CONNECT_DESC": "Clique abaixo para iniciar o processo de conexão"
},
"EVOLUTION": {
"CONNECTED": "Evolution Go Conectado",
"CONNECTED_DESC": "Sua caixa de entrada Evolution Go está conectada e pronta para enviar/receber mensagens.",
"DISCONNECT": "Desconectar",
"DISCONNECT_SUCCESS": "Desconectado com sucesso",
"DISCONNECT_ERROR": "Erro ao desconectar",
"SCAN_QR": "Escaneie o QR Code com seu WhatsApp",
"LOADING_QR": "Carregando QR Code...",
"CONNECT": "Conectar WhatsApp",
"CONNECT_DESC": "Clique abaixo para iniciar o processo de conexão"
},
"ENABLE_CONTINUITY_VIA_EMAIL": {
"ENABLED": "Ativado",
"DISABLED": "Desativado"
@ -742,6 +824,7 @@
"USE_INBOX_AVATAR_FOR_BOT": "Use o nome da caixa de entrada e avatar do bot"
},
"SETTINGS_POPUP": {
"MESSENGER_CONFIG": "Configuração do Messenger",
"MESSENGER_HEADING": "Código Menssageiro <scripit>",
"MESSENGER_SUB_HEAD": "Favor, insira essse código <script> dentro da tag Body de sua página html",
"ALLOWED_DOMAINS": {
@ -775,7 +858,7 @@
"INBOX_IDENTIFIER_SUB_TEXT": "Use o token 'inbox_identifier' mostrado aqui para autenticar os seus clientes API.",
"FORWARD_EMAIL_TITLE": "Encaminhar para o E-mail",
"FORWARD_EMAIL_SUB_TEXT": "Comece a encaminhar seus e-mails para o seguinte endereço de e-mail.",
"FORWARD_EMAIL_NOT_CONFIGURED": "O encaminhamento de e-mails para sua caixa de entrada está desativado nesta instalação. Para utilizar esse recurso, ele deve ser habilitado pelo administrador. Entre em contato com um para prosseguir.",
"FORWARD_EMAIL_NOT_CONFIGURED": "Forwarding emails to your inbox is currently disabled on this installation. To use this feature, it must be enabled by your administrator. Please get in touch with them to proceed.",
"ALLOW_MESSAGES_AFTER_RESOLVED": "Permitir mensagens após a resolução da conversa",
"ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT": "Permite que os usuários finais enviem mensagens mesmo depois que a conversa for resolvida.",
"WHATSAPP_SECTION_SUBHEADER": "Esta chave de API é usada para a integração com as APIs do WhatsApp.",
@ -805,29 +888,7 @@
"WHATSAPP_TEMPLATES_SYNC_SUBHEADER": "Sincronize manualmente os modelos de mensagens do WhatsApp para atualizar seus modelos disponíveis.",
"WHATSAPP_TEMPLATES_SYNC_BUTTON": "Sincronizar Modelos",
"WHATSAPP_TEMPLATES_SYNC_SUCCESS": "Sincronização de modelos iniciada com sucesso. Pode demorar alguns minutos para atualizar.",
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Atualizar configurações do Formulário Pre Chat",
"WHATSAPP_MANAGE_PROVIDER_CONNECTION_TITLE": "Gerenciar Conexão do Provedor",
"WHATSAPP_MANAGE_PROVIDER_CONNECTION_SUBHEADER": "Conecte o seu dispositivo e gerencie a conexão do provedor.",
"WHATSAPP_MANAGE_PROVIDER_CONNECTION_BUTTON": "Gerenciar conexão",
"WHATSAPP_PROVIDER_URL_TITLE": "URL do provedor",
"WHATSAPP_PROVIDER_URL_SUBHEADER": "Se o provedor não estiver rodando localmente, por favor, forneça a URL.",
"WHATSAPP_PROVIDER_URL_PLACEHOLDER": "Digite a URL do provedor",
"WHATSAPP_PROVIDER_URL_ERROR": "Por favor, insira uma URL válida",
"WHATSAPP_MARK_AS_READ_TITLE": "Confirmações de leitura",
"WHATSAPP_MARK_AS_READ_SUBHEADER": "Se essa opção estiver desativada, ao visualizar uma mensagem pelo Chatwoot, não será enviada uma confirmação de leitura para o remetente. As suas mensagens ainda poderão receber confirmações de leitura.",
"WHATSAPP_MARK_AS_READ_LABEL": "Enviar confirmações de leitura",
"WHATSAPP_INSTANCE_ID_TITLE": "ID da Instância",
"WHATSAPP_INSTANCE_ID_SUBHEADER": "Seu ID da Instância Z-API.",
"WHATSAPP_INSTANCE_ID_UPDATE_TITLE": "Atualizar ID da Instância",
"WHATSAPP_INSTANCE_ID_UPDATE_SUBHEADER": "Digite o novo ID da Instância aqui",
"WHATSAPP_TOKEN_TITLE": "Token",
"WHATSAPP_TOKEN_SUBHEADER": "Seu Token da Instância Z-API.",
"WHATSAPP_TOKEN_UPDATE_TITLE": "Atualizar Token",
"WHATSAPP_TOKEN_UPDATE_SUBHEADER": "Digite o novo Token aqui",
"WHATSAPP_CLIENT_TOKEN_TITLE": "Token de Segurança",
"WHATSAPP_CLIENT_TOKEN_SUBHEADER": "Seu Token de Segurança Z-API (veja a aba Segurança no painel do Z-API).",
"WHATSAPP_CLIENT_TOKEN_UPDATE_TITLE": "Atualizar Token de Segurança",
"WHATSAPP_CLIENT_TOKEN_UPDATE_SUBHEADER": "Digite o novo Token de Segurança aqui"
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Atualizar configurações do Formulário Pre Chat"
},
"HELP_CENTER": {
"LABEL": "Centro de Ajuda",
@ -883,61 +944,6 @@
"LABEL": "Mensagem",
"PLACEHOLDER": "Digite uma mensagem para mostrar aos usuários com o formulário"
},
"BUTTON_TEXT": {
"LABEL": "Texto do botão",
"PLACEHOLDER": "Por favor, avalie-nos"
},
"LANGUAGE": {
"LABEL": "Idioma",
"PLACEHOLDER": "Selecione o idioma do modelo"
},
"MESSAGE_PREVIEW": {
"LABEL": "Pré-visualização da mensagem",
"TOOLTIP": "Isso pode variar ligeiramente quando exibido na plataforma do WhatsApp."
},
"TEMPLATE_STATUS": {
"APPROVED": "Aprovado pelo WhatsApp",
"PENDING": "Aguardando aprovação do WhatsApp",
"REJECTED": "A Meta rejeitou o modelo",
"DEFAULT": "Precisa de aprovação do WhatsApp",
"NOT_FOUND": "O modelo não existe na plataforma da Meta."
},
"TEMPLATE_CREATION": {
"SUCCESS_MESSAGE": "Modelo do WhatsApp criado com sucesso e enviado para aprovação",
"ERROR_MESSAGE": "Falha ao criar o modelo do WhatsApp"
},
"TEMPLATE_MODE": {
"LABEL": "Origem do modelo",
"CREATE_NEW": "Criar novo modelo",
"USE_EXISTING": "Usar modelo existente"
},
"EXISTING_TEMPLATE": {
"LABEL": "Selecionar modelo",
"PLACEHOLDER": "Selecione um modelo aprovado",
"SYNC_BUTTON": "Sincronizar modelos",
"SYNCING": "Sincronizando...",
"EMPTY_STATE": "Nenhum modelo compatível encontrado. Os modelos devem ter um botão de URL com um parâmetro dinâmico.",
"LINK_SUCCESS": "Modelo vinculado com sucesso",
"LINK_ERROR": "Falha ao vincular o modelo",
"LOADING": "Carregando modelos...",
"USING_LABEL": "Usando modelo existente: {name}",
"SYNC_ERROR": "Falha ao sincronizar modelos. Por favor, tente novamente."
},
"TEMPLATE_VARIABLES": {
"TITLE": "Variáveis do modelo",
"DESCRIPTION": "Defina os valores para as variáveis do corpo do modelo. Você pode usar variáveis dinâmicas como {'{{'} contact.name {'}}'} que serão resolvidas no momento do envio.",
"VARIABLE_LABEL": "Variável {variable}",
"VARIABLE_PLACEHOLDER": "Insira o valor para {variable}",
"VARIABLE_REQUIRED": "Esta variável é obrigatória",
"VALIDATION_ERROR": "Por favor, defina todas as variáveis do modelo antes de salvar.",
"INSERT_VARIABLE": "Inserir variável"
},
"TEMPLATE_UPDATE_DIALOG": {
"TITLE": "Editar detalhes da pesquisa",
"DESCRIPTION": "Vamos excluir o modelo anterior e criar um que será enviado novamente para aprovação do WhatsApp",
"CONFIRM": "Criar novo modelo",
"CANCEL": "Voltar atrás"
},
"SURVEY_RULE": {
"LABEL": "Regra de pesquisa",
"DESCRIPTION_PREFIX": "Enviar a pesquisa se a conversa",
@ -949,7 +955,6 @@
"SELECT_PLACEHOLDER": "selecionar etiquetas"
},
"NOTE": "Nota: pesquisas de CSAT são enviadas apenas uma vez por conversa",
"WHATSAPP_NOTE": "Nota: Você pode criar um novo modelo (que será enviado para aprovação do WhatsApp) ou usar um modelo aprovado existente do seu gerenciador de modelos da Meta. As pesquisas são enviadas apenas uma vez por conversa, conforme a regra da pesquisa.",
"API": {
"SUCCESS_MESSAGE": "Configurações de CSAT atualizadas com sucesso",
"ERROR_MESSAGE": "Não foi possível atualizar as configurações do CSAT. Por favor, tente novamente mais tarde."
@ -1145,8 +1150,6 @@
"TWITTER_PROFILE": "Twitter",
"TWILIO_SMS": "SMS Twilio",
"WHATSAPP": "WhatsApp",
"WHATSAPP_BAILEYS": "WhatsApp - Baileys",
"WHATSAPP_ZAPI": "WhatsApp - Z-API",
"SMS": "SMS",
"EMAIL": "e-mail",
"TELEGRAM": "Telegram",

View File

@ -7,6 +7,7 @@ import automation from './automation.json';
import bulkActions from './bulkActions.json';
import campaign from './campaign.json';
import cannedMgmt from './cannedMgmt.json';
import captain from './captain.json';
import chatlist from './chatlist.json';
import components from './components.json';
import contact from './contact.json';
@ -23,6 +24,7 @@ import inbox from './inbox.json';
import inboxMgmt from './inboxMgmt.json';
import integrationApps from './integrationApps.json';
import integrations from './integrations.json';
import jasmine from './jasmine.json';
import labelsMgmt from './labelsMgmt.json';
import login from './login.json';
import macros from './macros.json';
@ -46,6 +48,7 @@ export default {
...bulkActions,
...campaign,
...cannedMgmt,
...captain,
...chatlist,
...components,
...contact,
@ -62,6 +65,7 @@ export default {
...inboxMgmt,
...integrationApps,
...integrations,
...jasmine,
...labelsMgmt,
...login,
...macros,

View File

@ -0,0 +1,84 @@
{
"JASMINE": {
"HEADER": {
"TITLE": "Agentes de IA Jasmine",
"DESCRIPTION": "Gerencie seus agentes de IA SDR. Selecione uma caixa de entrada para configurar sua base de conhecimento.",
"EMPTY": "Nenhuma caixa de entrada encontrada"
},
"CONFIG": {
"TITLE": "Configuração da IA Jasmine",
"DESCRIPTION": "Configure o agente de IA para esta caixa de entrada.",
"ENABLE": "Ativar Agente de IA Jasmine",
"SYSTEM_PROMPT": "Prompt do Sistema",
"SYSTEM_PROMPT_HELP": "Defina a persona e as regras de comportamento para o agente.",
"TYPING_DELAY_LABEL": "Buffer / Delay de Digitação (Segundos)",
"TYPING_DELAY_PLACEHOLDER": "Ex: 5",
"TYPING_DELAY_HELP": "O tempo que a IA aguardará mensagens (Buffer) antes de simular a digitação e responder. Zero para imediato.",
"UPDATE_BUTTON": "Atualizar Configuração"
},
"KNOWLEDGE_BASE": {
"TITLE": "Base de Conhecimento",
"DESCRIPTION": "Gerencie coleções de conhecimento para esta caixa de entrada",
"ADD_BUTTON": "+ Nova Coleção",
"DOCUMENTS": "Documentos",
"LOADING_DOCS": "Carregando documentos...",
"UNTITLED_DOC": "Documento sem título",
"NO_DOCS": "Nenhum documento ainda. Adicione seu primeiro documento abaixo.",
"ADD_DOC_HEADER": "Adicionar Novo Documento",
"DOC_TITLE_PLACEHOLDER": "Título do documento (opcional)",
"DOC_CONTENT_PLACEHOLDER": "Cole ou digite seu conteúdo de conhecimento aqui...",
"ADD_DOC_BUTTON": "Adicionar Documento",
"NO_COLLECTIONS": "Nenhuma coleção ainda. Crie uma para começar.",
"CREATE_MODAL": {
"TITLE": "Criar Coleção",
"NAME_PLACEHOLDER": "Nome da coleção",
"VISIBILITY_PRIVATE": "Privada (Apenas esta caixa de entrada)",
"VISIBILITY_SHARED": "Compartilhada (Todas as caixas de entrada)",
"CANCEL": "Cancelar",
"CREATE": "Criar"
},
"DELETE_CONFIRM": "Tem certeza de que deseja excluir este documento?",
"DOCUMENT_DELETE_SUCCESS": "Documento excluído com sucesso",
"COLLECTION_DELETE_SUCCESS": "Coleção excluída com sucesso",
"SAVE_SUCCESS": "Alterações salvas com sucesso",
"DOCUMENT_CREATE_SUCCESS": "Documento criado com sucesso",
"COLLECTION_CREATE_SUCCESS": "Coleção criada com sucesso"
},
"PLAYGROUND": {
"TITLE": "Playground da IA Jasmine",
"DESCRIPTION": "Teste as respostas da Jasmine em tempo real antes de ativar para os clientes.",
"SELECT_INBOX": "Selecione uma Caixa de Entrada para testar",
"CHOOSE_INBOX": "Escolha uma caixa de entrada...",
"WARNING": "Certifique-se de que a Jasmine está ativada e configurada para esta caixa de entrada",
"EMPTY_STATE_TITLE": "Envie uma mensagem para testar a Jasmine",
"EMPTY_STATE_EXAMPLES": "Tente: \"Olá\", \"Quanto custa?\", \"Como funciona?\"",
"LOADING": "Jasmine está pensando...",
"INPUT_PLACEHOLDER": "Digite uma mensagem de teste...",
"CLEAR_TOOLTIP": "Limpar conversa",
"NO_INBOX_SELECTED": "Selecione uma caixa de entrada acima para começar a testar"
},
"INBOX_LIST": {
"ACTIVE": "Ativo",
"CONFIGURE": "Configurar",
"DESCRIPTION": "Canal {channel} configurado para IA Jasmine"
},
"WUZAPI": {
"STATUS": "Status: {status}",
"ACCOUNT_ERROR": "Erro: ID da conta não carregado. Por favor, atualize a página.",
"CONNECT_FALLBACK": "Clique para iniciar a conexão",
"CONNECT_BUTTON_FALLBACK": "Conectar WhatsApp",
"WEBHOOK_SECTION": "Configuração de Webhook",
"GET_WEBHOOK_INFO": "Obter informações de Webhook",
"UPDATE_WEBHOOK": "Atualizar conexão de Webhook"
},
"EVOLUTION": {
"STATUS": "Status: {status}",
"ACCOUNT_ERROR": "Erro: ID da instância não encontrado. Verifique sua configuração.",
"CONNECT_FALLBACK": "Clique para iniciar a conexão",
"CONNECT_BUTTON_FALLBACK": "Conectar WhatsApp",
"WEBHOOK_SECTION": "Configuração de Webhook",
"GET_WEBHOOK_INFO": "Obter informações de Webhook",
"UPDATE_WEBHOOK": "Atualizar conexão de Webhook"
}
}
}

View File

@ -339,6 +339,9 @@
"CAPTAIN_PLAYGROUND": "Playground",
"CAPTAIN_INBOXES": "Caixas de Entrada",
"CAPTAIN_SETTINGS": "Configurações",
"CAPTAIN_PIX_UNITS": "Unidades Pix",
"CAPTAIN_GALLERY": "Galeria",
"CAPTAIN_RESERVATIONS": "Reservas",
"HOME": "Principal",
"AGENTS": "Agentes",
"AGENT_BOTS": "Robôs",

View File

@ -16,6 +16,7 @@ import DocumentsIndex from './documents/Index.vue';
import ResponsesIndex from './responses/Index.vue';
import ResponsesPendingIndex from './responses/Pending.vue';
import CustomToolsIndex from './tools/Index.vue';
import ReservationsIndex from './reservations/Index.vue';
const meta = {
permissions: ['administrator', 'agent'],
@ -130,4 +131,10 @@ export const routes = [
},
children: [...assistantRoutes],
},
{
path: frontendURL('accounts/:accountId/captain/reservations'),
component: ReservationsIndex,
name: 'captain_reservations_index',
meta,
},
];

View File

@ -0,0 +1,626 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import BarChart from 'shared/components/charts/BarChart.vue';
const store = useStore();
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const uiFlags = useMapGetter('captainReservations/getUIFlags');
const reservations = useMapGetter('captainReservations/getRecords');
const reservationsMeta = useMapGetter('captainReservations/getMeta');
const units = useMapGetter('captainUnits/getUnits');
const isFetching = computed(() => uiFlags.value.fetchingList);
const viewMode = ref('list');
const status = ref('all');
const q = ref('');
const dateFrom = ref('');
const dateTo = ref('');
const unitId = ref('');
const suite = ref('');
const sort = ref('');
const isFetchingRevenue = ref(false);
const emptyRevenue = () => ({
summary: {
total_revenue: 0,
confirmed_count: 0,
average_ticket: 0,
},
by_unit: [],
by_suite: [],
});
const revenue = ref(emptyRevenue());
const isRevenueView = computed(() => viewMode.value === 'revenue');
const isPageFetching = computed(
() => isFetching.value || isFetchingRevenue.value
);
const hasRevenueData = computed(
() => Number(revenue.value.summary?.confirmed_count || 0) > 0
);
const statusOptions = computed(() => [
{ id: 'all', label: 'CAPTAIN_RESERVATIONS.FILTERS.STATUS_ALL' },
{ id: 'draft', label: 'CAPTAIN_RESERVATIONS.STATUS.DRAFT' },
{
id: 'pending_payment',
label: 'CAPTAIN_RESERVATIONS.STATUS.PENDING_PAYMENT',
},
{ id: 'confirmed', label: 'CAPTAIN_RESERVATIONS.STATUS.CONFIRMED' },
{ id: 'cancelled', label: 'CAPTAIN_RESERVATIONS.STATUS.CANCELLED' },
]);
const groupedReservations = computed(() => {
const groups = {
draft: [],
pending_payment: [],
confirmed: [],
cancelled: [],
};
reservations.value.forEach(reservation => {
const key = reservation.ui_status || 'draft';
if (!groups[key]) groups[key] = [];
groups[key].push(reservation);
});
return groups;
});
const readFiltersFromRoute = () => {
const query = route.query || {};
status.value = query.status || 'all';
q.value = query.q || '';
dateFrom.value = query.date_from || '';
dateTo.value = query.date_to || '';
unitId.value = query.unit_id || '';
suite.value = query.suite || '';
sort.value = query.sort || '';
viewMode.value = ['kanban', 'revenue'].includes(query.view)
? query.view
: 'list';
};
const buildQuery = (page = 1) => ({
status: status.value,
q: q.value || undefined,
date_from: dateFrom.value || undefined,
date_to: dateTo.value || undefined,
unit_id: unitId.value || undefined,
suite: suite.value || undefined,
sort: sort.value || undefined,
page,
per_page: 25,
});
const buildRevenueQuery = () => ({
q: q.value || undefined,
date_from: dateFrom.value || undefined,
date_to: dateTo.value || undefined,
unit_id: unitId.value || undefined,
suite: suite.value || undefined,
});
const syncRouteQuery = (page = 1) => {
const query = {
q: q.value || undefined,
date_from: dateFrom.value || undefined,
date_to: dateTo.value || undefined,
unit_id: unitId.value || undefined,
suite: suite.value || undefined,
status: isRevenueView.value ? undefined : status.value,
sort: isRevenueView.value ? undefined : sort.value || undefined,
page: isRevenueView.value ? undefined : page,
per_page: isRevenueView.value ? undefined : 25,
view: viewMode.value === 'list' ? undefined : viewMode.value,
};
router.replace({ query });
};
const fetchReservations = (page = 1) => {
syncRouteQuery(page);
store.dispatch('captainReservations/get', buildQuery(page));
};
const fetchRevenue = async () => {
syncRouteQuery();
isFetchingRevenue.value = true;
try {
const data = await store.dispatch(
'captainReservations/fetchRevenue',
buildRevenueQuery()
);
revenue.value = data?.summary ? data : emptyRevenue();
} catch (error) {
revenue.value = emptyRevenue();
useAlert(t('CAPTAIN_RESERVATIONS.REVENUE.API.ERROR'));
} finally {
isFetchingRevenue.value = false;
}
};
const setViewMode = mode => {
if (viewMode.value === mode) return;
viewMode.value = mode;
if (mode === 'revenue') {
fetchRevenue();
return;
}
fetchReservations(1);
};
const onPageChange = page => fetchReservations(page);
const applyFilters = () => {
if (isRevenueView.value) {
fetchRevenue();
return;
}
fetchReservations(1);
};
const clearFilters = () => {
status.value = 'all';
q.value = '';
dateFrom.value = '';
dateTo.value = '';
unitId.value = '';
suite.value = '';
sort.value = '';
if (isRevenueView.value) {
fetchRevenue();
return;
}
fetchReservations(1);
};
const openConversation = reservation => {
const conversationId =
reservation.conversation_display_id || reservation.conversation_id;
if (!conversationId) return;
const path = frontendURL(
conversationUrl({
accountId: route.params.accountId,
id: conversationId,
})
);
router.push(path);
};
const copyPix = async reservation => {
const pix = reservation.pix_copy_paste;
if (!pix) {
useAlert(
reservation.pix_reason === 'expired'
? t('CAPTAIN_RESERVATIONS.API.PIX_EXPIRED')
: t('CAPTAIN_RESERVATIONS.API.PIX_NOT_GENERATED')
);
return;
}
try {
await navigator.clipboard.writeText(pix);
useAlert(t('CAPTAIN_RESERVATIONS.API.PIX_COPIED'));
} catch (error) {
useAlert(t('CAPTAIN_RESERVATIONS.API.PIX_COPY_FAILED'));
}
};
const formatMoney = value =>
new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(
Number(value || 0)
);
const formatDate = value =>
value
? new Intl.DateTimeFormat('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}).format(new Date(value))
: '-';
const unitRevenueChart = computed(() => ({
labels: revenue.value.by_unit.map(item => item.unit_name || '-'),
datasets: [
{
label: t('CAPTAIN_RESERVATIONS.REVENUE.CHARTS.BY_UNIT'),
backgroundColor: '#3b82f6',
data: revenue.value.by_unit.map(item => Number(item.total_revenue || 0)),
},
],
}));
const suiteRevenueChart = computed(() => ({
labels: revenue.value.by_suite.map(item => item.suite_identifier || '-'),
datasets: [
{
label: t('CAPTAIN_RESERVATIONS.REVENUE.CHARTS.BY_SUITE'),
backgroundColor: '#10b981',
data: revenue.value.by_suite.map(item => Number(item.total_revenue || 0)),
},
],
}));
onMounted(() => {
readFiltersFromRoute();
store.dispatch('captainUnits/get');
if (isRevenueView.value) {
fetchRevenue();
return;
}
fetchReservations(Number(route.query.page) || 1);
});
</script>
<template>
<PageLayout
:header-title="$t('CAPTAIN_RESERVATIONS.HEADER')"
:feature-flag="FEATURE_FLAGS.CAPTAIN"
:is-fetching="isPageFetching"
:is-empty="isRevenueView ? !hasRevenueData : !reservations.length"
:show-pagination-footer="
!isPageFetching && viewMode === 'list' && !!reservations.length
"
:total-count="
isRevenueView
? revenue.summary.confirmed_count
: reservationsMeta.totalCount
"
:current-page="isRevenueView ? 1 : reservationsMeta.page"
:show-know-more="false"
:show-assistant-switcher="false"
@update:current-page="onPageChange"
>
<template #controls>
<div
class="grid grid-cols-1 gap-3 p-4 mb-4 rounded-xl bg-n-surface-2 md:grid-cols-7"
>
<div class="md:col-span-2">
<Input
v-model="q"
:label="$t('CAPTAIN_RESERVATIONS.FILTERS.SEARCH')"
/>
</div>
<div v-if="!isRevenueView">
<label class="text-sm text-n-slate-11">{{
$t('CAPTAIN_RESERVATIONS.FILTERS.STATUS')
}}</label>
<select
v-model="status"
class="w-full px-2 py-2 mt-1 border rounded-lg bg-n-background border-n-weak"
>
<option
v-for="option in statusOptions"
:key="option.id"
:value="option.id"
>
{{ $t(option.label) }}
</option>
</select>
</div>
<div>
<label class="text-sm text-n-slate-11">{{
$t('CAPTAIN_RESERVATIONS.FILTERS.UNIT')
}}</label>
<select
v-model="unitId"
class="w-full px-2 py-2 mt-1 border rounded-lg bg-n-background border-n-weak"
>
<option value="">
{{ $t('CAPTAIN_RESERVATIONS.FILTERS.UNIT_ALL') }}
</option>
<option v-for="unit in units" :key="unit.id" :value="unit.id">
{{ unit.name }}
</option>
</select>
</div>
<div>
<Input
v-model="suite"
:label="$t('CAPTAIN_RESERVATIONS.FILTERS.SUITE')"
/>
</div>
<div>
<Input
v-model="dateFrom"
type="date"
:label="$t('CAPTAIN_RESERVATIONS.FILTERS.DATE_FROM')"
/>
</div>
<div>
<Input
v-model="dateTo"
type="date"
:label="$t('CAPTAIN_RESERVATIONS.FILTERS.DATE_TO')"
/>
</div>
<div>
<label class="text-sm text-n-slate-11">{{
$t('CAPTAIN_RESERVATIONS.FILTERS.SORT')
}}</label>
<select
v-model="sort"
:disabled="isRevenueView"
class="w-full px-2 py-2 mt-1 border rounded-lg bg-n-background border-n-weak"
>
<option value="">
{{ $t('CAPTAIN_RESERVATIONS.FILTERS.SORT_DEFAULT') }}
</option>
<option value="check_in_at">
{{ $t('CAPTAIN_RESERVATIONS.FILTERS.SORT_CHECK_IN') }}
</option>
<option value="updated_at">
{{ $t('CAPTAIN_RESERVATIONS.FILTERS.SORT_UPDATED') }}
</option>
<option value="created_at">
{{ $t('CAPTAIN_RESERVATIONS.FILTERS.SORT_CREATED') }}
</option>
</select>
</div>
</div>
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<Button
:label="$t('CAPTAIN_RESERVATIONS.VIEW.LIST')"
:variant="viewMode === 'list' ? 'primary' : 'outline'"
size="sm"
@click="setViewMode('list')"
/>
<Button
:label="$t('CAPTAIN_RESERVATIONS.VIEW.KANBAN')"
:variant="viewMode === 'kanban' ? 'primary' : 'outline'"
size="sm"
@click="setViewMode('kanban')"
/>
<Button
:label="$t('CAPTAIN_RESERVATIONS.VIEW.REVENUE')"
:variant="viewMode === 'revenue' ? 'primary' : 'outline'"
size="sm"
@click="setViewMode('revenue')"
/>
</div>
<div class="flex items-center gap-2">
<Button
:label="$t('CAPTAIN_RESERVATIONS.FILTERS.CLEAR')"
variant="ghost"
size="sm"
@click="clearFilters"
/>
<Button
:label="$t('CAPTAIN_RESERVATIONS.FILTERS.APPLY')"
size="sm"
@click="applyFilters"
/>
</div>
</div>
</template>
<template #emptyState>
<div class="py-16 text-center text-n-slate-11">
{{ $t('CAPTAIN_RESERVATIONS.EMPTY') }}
</div>
</template>
<template #body>
<div v-if="isPageFetching" class="flex justify-center py-12">
<Spinner />
</div>
<div
v-else-if="viewMode === 'list'"
class="overflow-x-auto border rounded-xl border-n-weak"
>
<table class="w-full text-sm">
<thead class="bg-n-surface-2 text-n-slate-11">
<tr>
<th class="px-3 py-2 text-left">
{{ $t('CAPTAIN_RESERVATIONS.TABLE.CUSTOMER') }}
</th>
<th class="px-3 py-2 text-left">
{{ $t('CAPTAIN_RESERVATIONS.TABLE.UNIT') }}
</th>
<th class="px-3 py-2 text-left">
{{ $t('CAPTAIN_RESERVATIONS.TABLE.SUITE') }}
</th>
<th class="px-3 py-2 text-left">
{{ $t('CAPTAIN_RESERVATIONS.TABLE.CHECK_IN') }}
</th>
<th class="px-3 py-2 text-left">
{{ $t('CAPTAIN_RESERVATIONS.TABLE.AMOUNT') }}
</th>
<th class="px-3 py-2 text-left">
{{ $t('CAPTAIN_RESERVATIONS.TABLE.STATUS') }}
</th>
<th class="px-3 py-2 text-left">
{{ $t('CAPTAIN_RESERVATIONS.TABLE.UPDATED_AT') }}
</th>
<th class="px-3 py-2 text-left">
{{ $t('CAPTAIN_RESERVATIONS.TABLE.ACTIONS') }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="reservation in reservations"
:key="reservation.id"
class="border-t border-n-weak"
>
<td class="px-3 py-2">
<p class="font-medium text-n-slate-12">
{{ reservation.customer_name || '-' }}
</p>
<p class="text-xs text-n-slate-11">
{{
reservation.customer_phone ||
reservation.customer_cpf ||
'-'
}}
</p>
</td>
<td class="px-3 py-2">{{ reservation.unit_name || '-' }}</td>
<td class="px-3 py-2">
{{ reservation.suite_identifier || '-' }}
</td>
<td class="px-3 py-2">
{{ formatDate(reservation.check_in_at) }}
</td>
<td class="px-3 py-2">{{ formatMoney(reservation.amount) }}</td>
<td class="px-3 py-2">
<span
class="px-2 py-1 text-xs rounded-full bg-n-surface-2 text-n-slate-12"
>
{{ reservation.status_label }}
</span>
</td>
<td class="px-3 py-2">
{{ formatDate(reservation.updated_at) }}
</td>
<td class="px-3 py-2">
<div class="flex items-center gap-2">
<Button
size="xs"
variant="outline"
:label="
$t('CAPTAIN_RESERVATIONS.ACTIONS.OPEN_CONVERSATION')
"
@click="openConversation(reservation)"
/>
<Button
size="xs"
variant="ghost"
:label="$t('CAPTAIN_RESERVATIONS.ACTIONS.COPY_PIX')"
@click="copyPix(reservation)"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else-if="viewMode === 'revenue'" class="space-y-4">
<div
class="px-3 py-2 text-xs rounded-lg bg-n-surface-2 text-n-slate-11"
>
{{ $t('CAPTAIN_RESERVATIONS.REVENUE.ONLY_CONFIRMED') }}
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="p-4 border rounded-xl border-n-weak bg-n-background">
<p class="text-sm text-n-slate-11">
{{ $t('CAPTAIN_RESERVATIONS.REVENUE.SUMMARY.TOTAL_REVENUE') }}
</p>
<p class="mt-1 text-2xl font-semibold text-n-slate-12">
{{ formatMoney(revenue.summary.total_revenue) }}
</p>
</div>
<div class="p-4 border rounded-xl border-n-weak bg-n-background">
<p class="text-sm text-n-slate-11">
{{ $t('CAPTAIN_RESERVATIONS.REVENUE.SUMMARY.CONFIRMED_COUNT') }}
</p>
<p class="mt-1 text-2xl font-semibold text-n-slate-12">
{{ revenue.summary.confirmed_count || 0 }}
</p>
</div>
<div class="p-4 border rounded-xl border-n-weak bg-n-background">
<p class="text-sm text-n-slate-11">
{{ $t('CAPTAIN_RESERVATIONS.REVENUE.SUMMARY.AVERAGE_TICKET') }}
</p>
<p class="mt-1 text-2xl font-semibold text-n-slate-12">
{{ formatMoney(revenue.summary.average_ticket) }}
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div class="p-4 border rounded-xl border-n-weak bg-n-background">
<h3 class="text-sm font-medium text-n-slate-12">
{{ $t('CAPTAIN_RESERVATIONS.REVENUE.CHARTS.BY_UNIT') }}
</h3>
<div class="h-64 mt-3">
<BarChart :collection="unitRevenueChart" />
</div>
</div>
<div class="p-4 border rounded-xl border-n-weak bg-n-background">
<h3 class="text-sm font-medium text-n-slate-12">
{{ $t('CAPTAIN_RESERVATIONS.REVENUE.CHARTS.BY_SUITE') }}
</h3>
<div class="h-64 mt-3">
<BarChart :collection="suiteRevenueChart" />
</div>
</div>
</div>
</div>
<div v-else class="grid grid-cols-1 gap-4 lg:grid-cols-4">
<div
v-for="column in [
'draft',
'pending_payment',
'confirmed',
'cancelled',
]"
:key="column"
class="p-3 border rounded-xl bg-n-surface-2 border-n-weak"
>
<h3 class="mb-3 text-sm font-medium text-n-slate-12">
{{ $t(`CAPTAIN_RESERVATIONS.STATUS.${column.toUpperCase()}`) }}
</h3>
<div class="flex flex-col gap-2">
<div
v-for="reservation in groupedReservations[column]"
:key="reservation.id"
class="p-3 border rounded-lg bg-n-background border-n-weak"
>
<p class="text-sm font-medium text-n-slate-12">
{{ reservation.customer_name || '-' }}
</p>
<p class="text-xs text-n-slate-11">
{{ reservation.suite_identifier || '-' }}
</p>
<p class="mt-2 text-xs text-n-slate-11">
{{ formatDate(reservation.check_in_at) }}
{{ formatMoney(reservation.amount) }}
</p>
<div class="flex gap-2 mt-3">
<Button
size="xs"
variant="outline"
:label="$t('CAPTAIN_RESERVATIONS.ACTIONS.OPEN_CONVERSATION')"
@click="openConversation(reservation)"
/>
<Button
size="xs"
variant="ghost"
:label="$t('CAPTAIN_RESERVATIONS.ACTIONS.COPY_PIX')"
@click="copyPix(reservation)"
/>
</div>
</div>
<p
v-if="!groupedReservations[column].length"
class="text-xs text-n-slate-11"
>
{{ $t('CAPTAIN_RESERVATIONS.KANBAN.EMPTY_COLUMN') }}
</p>
</div>
</div>
</div>
</template>
</PageLayout>
</template>

View File

@ -18,6 +18,7 @@ import ContactNotes from './contact/ContactNotes.vue';
import ScheduledMessages from './scheduledMessages/ScheduledMessages.vue';
import ConversationInfo from './ConversationInfo.vue';
import CustomAttributes from './customAttributes/CustomAttributes.vue';
import ReservationSummary from './reservation/ReservationSummary.vue';
import Draggable from 'vuedraggable';
import MacrosList from './Macros/List.vue';
import ShopifyOrdersList from 'dashboard/components/widgets/conversation/ShopifyOrdersList.vue';
@ -77,6 +78,7 @@ const isLinearConnected = computed(
const store = useStore();
const currentChat = useMapGetter('getSelectedChat');
const conversationId = computed(() => props.conversationId);
const reservationMarker = computed(() => currentChat.value?.reservation_marker);
const conversationMetadataGetter = useMapGetter(
'conversationMetadata/getConversationMetadata'
);
@ -119,6 +121,7 @@ const closeContactPanel = () => {
updateUISettings({
is_contact_sidebar_open: false,
is_copilot_panel_open: false,
is_reservation_summary_open: false,
});
};
@ -219,6 +222,19 @@ onMounted(() => {
/>
</AccordionItem>
</div>
<div v-else-if="element.name === 'reservation_summary'">
<AccordionItem
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.RESERVATION')"
:is-open="isContactSidebarItemOpen('is_reservation_summary_open')"
compact
@toggle="
value =>
toggleSidebarUIState('is_reservation_summary_open', value)
"
>
<ReservationSummary :marker="reservationMarker" />
</AccordionItem>
</div>
<div v-else-if="element.name === 'contact_attributes'">
<AccordionItem
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONTACT_ATTRIBUTES')"

View File

@ -0,0 +1,163 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Button from 'dashboard/components-next/button/Button.vue';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
const props = defineProps({
marker: {
type: Object,
default: () => ({}),
},
});
const store = useStore();
const { t } = useI18n();
const reservation = ref(null);
const isLoading = ref(false);
const reservationId = computed(() => props.marker?.reservation_id);
const hasMarker = computed(() => !!props.marker?.visible);
const pixValue = computed(
() => reservation.value?.pix_copy_paste || props.marker?.pix_copy_paste
);
const fetchReservation = async () => {
if (!reservationId.value) {
reservation.value = null;
return;
}
isLoading.value = true;
try {
const response = await store.dispatch(
'captainReservations/show',
reservationId.value
);
reservation.value = response?.id ? response : null;
} finally {
isLoading.value = false;
}
};
watch(reservationId, fetchReservation, { immediate: true });
const formatMoney = value =>
new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: 'BRL',
}).format(Number(value || 0));
const formatDateTime = value => {
if (!value) return '-';
return new Intl.DateTimeFormat('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(value));
};
const statusLabel = computed(() => {
const status = reservation.value?.ui_status || props.marker?.status;
if (!status) return '-';
const key = `CAPTAIN_RESERVATIONS.STATUS.${status.toUpperCase()}`;
const translated = t(key);
return translated === key
? reservation.value?.status_label || props.marker?.status_label || status
: translated;
});
const onCopyPix = async () => {
if (!pixValue.value) {
useAlert(
props.marker?.pix_reason === 'expired'
? t('CAPTAIN_RESERVATIONS.API.PIX_EXPIRED')
: t('CAPTAIN_RESERVATIONS.API.PIX_NOT_GENERATED')
);
return;
}
try {
await copyTextToClipboard(pixValue.value);
useAlert(t('CAPTAIN_RESERVATIONS.API.PIX_COPIED'));
} catch (error) {
useAlert(t('CAPTAIN_RESERVATIONS.API.PIX_COPY_FAILED'));
}
};
</script>
<template>
<div class="flex flex-col gap-2 text-sm">
<p v-if="!hasMarker" class="text-n-slate-11">
{{ $t('CAPTAIN_RESERVATIONS.SIDEBAR.NO_RESERVATION') }}
</p>
<div v-else-if="isLoading" class="text-n-slate-11">
{{ $t('CAPTAIN_RESERVATIONS.SIDEBAR.LOADING') }}
</div>
<template v-else>
<div class="flex items-start justify-between gap-2">
<span class="text-n-slate-11">{{
$t('CAPTAIN_RESERVATIONS.SIDEBAR.STATUS')
}}</span>
<span class="font-medium text-n-slate-12">{{ statusLabel }}</span>
</div>
<div class="flex items-start justify-between gap-2">
<span class="text-n-slate-11">{{
$t('CAPTAIN_RESERVATIONS.SIDEBAR.SUITE')
}}</span>
<span class="font-medium text-n-slate-12">{{
reservation?.suite_identifier || marker?.suite || '-'
}}</span>
</div>
<div class="flex items-start justify-between gap-2">
<span class="text-n-slate-11">{{
$t('CAPTAIN_RESERVATIONS.SIDEBAR.CHECK_IN')
}}</span>
<span class="font-medium text-n-slate-12">{{
formatDateTime(reservation?.check_in_at || marker?.check_in_at)
}}</span>
</div>
<div class="flex items-start justify-between gap-2">
<span class="text-n-slate-11">{{
$t('CAPTAIN_RESERVATIONS.SIDEBAR.CHECK_OUT')
}}</span>
<span class="font-medium text-n-slate-12">{{
formatDateTime(reservation?.check_out_at || marker?.check_out_at)
}}</span>
</div>
<div class="flex items-start justify-between gap-2">
<span class="text-n-slate-11">{{
$t('CAPTAIN_RESERVATIONS.SIDEBAR.AMOUNT')
}}</span>
<span class="font-medium text-n-slate-12">{{
formatMoney(reservation?.amount || marker?.amount)
}}</span>
</div>
<div class="flex items-start justify-between gap-2">
<span class="text-n-slate-11">{{
$t('CAPTAIN_RESERVATIONS.SIDEBAR.UPDATED_AT')
}}</span>
<span class="font-medium text-n-slate-12">{{
formatDateTime(reservation?.updated_at || marker?.updated_at)
}}</span>
</div>
<div class="pt-1">
<Button
size="xs"
variant="outline"
:label="$t('CAPTAIN_RESERVATIONS.ACTIONS.COPY_PIX')"
@click="onCopyPix"
/>
</div>
</template>
</div>
</template>

View File

@ -3,6 +3,10 @@ import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
import SettingsWrapper from '../SettingsWrapper.vue';
import Index from './Index.vue';
import UnitsIndex from './units/Index.vue';
import UnitEdit from './units/Edit.vue';
import GalleryIndex from './gallery/Index.vue';
import GalleryEdit from './gallery/Edit.vue';
export default {
routes: [
@ -32,6 +36,38 @@ export default {
],
},
},
{
path: 'units',
name: 'captain_settings_units',
component: UnitsIndex,
meta: {
permissions: ['administrator'],
},
},
{
path: 'units/:id/edit',
name: 'captain_settings_units_edit',
component: UnitEdit,
meta: {
permissions: ['administrator'],
},
},
{
path: 'gallery',
name: 'captain_settings_gallery',
component: GalleryIndex,
meta: {
permissions: ['administrator'],
},
},
{
path: 'gallery/:id/edit',
name: 'captain_settings_gallery_edit',
component: GalleryEdit,
meta: {
permissions: ['administrator'],
},
},
],
},
],

View File

@ -0,0 +1,303 @@
<script>
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import SubmitButton from 'dashboard/components-next/button/Button.vue';
const GLOBAL_SCOPE_VALUE = '__global__';
export default {
components: {
SubmitButton,
},
setup() {
return { v$: useVuelidate() };
},
data() {
return {
inbox_scope_selection: GLOBAL_SCOPE_VALUE,
suite_category: '',
suite_number: '',
description: '',
active: true,
image: null,
currentImageUrl: '',
previewImageUrl: '',
};
},
computed: {
...mapGetters({
uiFlags: 'captainGalleryItems/getUIFlags',
items: 'captainGalleryItems/getItems',
inboxes: 'inboxes/getInboxes',
}),
isNew() {
return this.$route.params.id === 'new';
},
pageTitle() {
return this.isNew
? this.$t('CAPTAIN_SETTINGS.GALLERY.ADD.TITLE')
: this.$t('CAPTAIN_SETTINGS.GALLERY.EDIT.TITLE');
},
pageDesc() {
return this.isNew
? this.$t('CAPTAIN_SETTINGS.GALLERY.ADD.DESC')
: this.$t('CAPTAIN_SETTINGS.GALLERY.EDIT.DESC');
},
inboxOptions() {
return [
{
id: GLOBAL_SCOPE_VALUE,
name: this.$t('CAPTAIN_SETTINGS.GALLERY.FORM.INBOX.GLOBAL_OPTION'),
},
...this.inboxes.map(inbox => ({
id: inbox.id,
name: inbox.name,
})),
];
},
selectedScope() {
if (
this.inbox_scope_selection === GLOBAL_SCOPE_VALUE ||
this.inbox_scope_selection === null
) {
return 'global';
}
return 'inbox';
},
selectedInboxId() {
if (this.selectedScope === 'global') {
return null;
}
return this.inbox_scope_selection;
},
inboxHelpText() {
if (this.selectedScope === 'global') {
return this.$t('CAPTAIN_SETTINGS.GALLERY.FORM.INBOX.GLOBAL_HELP');
}
const selectedInbox = this.inboxes.find(
inbox => String(inbox.id) === String(this.selectedInboxId)
);
if (!selectedInbox) {
return this.$t('CAPTAIN_SETTINGS.GALLERY.FORM.INBOX.HELP');
}
return this.$t('CAPTAIN_SETTINGS.GALLERY.FORM.INBOX.SPECIFIC_HELP', {
inbox: selectedInbox.name,
});
},
imagePreview() {
return this.previewImageUrl || this.currentImageUrl;
},
},
validations() {
return {
suite_category: { required },
suite_number: { required },
description: { required },
image: {
required: this.isNew ? required : () => true,
},
};
},
async mounted() {
await this.$store.dispatch('inboxes/get');
if (!this.isNew) {
await this.fetchItem();
}
},
methods: {
async fetchItem() {
if (!this.items.length) {
await this.$store.dispatch('captainGalleryItems/get');
}
const item = this.items.find(i => i.id === Number(this.$route.params.id));
if (!item) return;
this.inbox_scope_selection =
item.scope === 'inbox' && item.inbox_id
? item.inbox_id
: GLOBAL_SCOPE_VALUE;
this.suite_category = item.suite_category;
this.suite_number = item.suite_number;
this.description = item.description;
this.active = item.active;
this.currentImageUrl = item.image_url;
},
onFileChange(event) {
const file = event.target.files[0];
if (!file) return;
this.image = file;
this.v$.image.$touch();
this.previewImageUrl = URL.createObjectURL(file);
},
async submitForm() {
this.v$.$touch();
if (this.v$.$invalid) return;
const formData = new FormData();
formData.append('captain_gallery_item[scope]', this.selectedScope);
if (this.selectedInboxId) {
formData.append('captain_gallery_item[inbox_id]', this.selectedInboxId);
}
formData.append(
'captain_gallery_item[suite_category]',
this.suite_category
);
formData.append('captain_gallery_item[suite_number]', this.suite_number);
formData.append('captain_gallery_item[description]', this.description);
formData.append('captain_gallery_item[active]', this.active);
if (this.image) {
formData.append('captain_gallery_item[image]', this.image);
}
try {
if (this.isNew) {
await this.$store.dispatch('captainGalleryItems/create', formData);
useAlert(this.$t('CAPTAIN_SETTINGS.GALLERY.ADD.API.SUCCESS_MESSAGE'));
} else {
await this.$store.dispatch('captainGalleryItems/update', {
id: this.$route.params.id,
formData,
});
useAlert(
this.$t('CAPTAIN_SETTINGS.GALLERY.EDIT.API.SUCCESS_MESSAGE')
);
}
this.$router.push({ name: 'captain_settings_gallery' });
} catch (error) {
const firstError = error?.response?.data?.errors?.[0];
const fallbackMessage = this.isNew
? this.$t('CAPTAIN_SETTINGS.GALLERY.ADD.API.ERROR_MESSAGE')
: this.$t('CAPTAIN_SETTINGS.GALLERY.EDIT.API.ERROR_MESSAGE');
useAlert(firstError || fallbackMessage);
}
},
},
};
</script>
<template>
<div class="column content-box">
<woot-modal-header :header-title="pageTitle" :header-content="pageDesc" />
<form class="row" @submit.prevent="submitForm">
<div class="small-12 columns">
<label>
{{ $t('CAPTAIN_SETTINGS.GALLERY.FORM.INBOX.LABEL') }}
<select v-model="inbox_scope_selection">
<option
v-for="option in inboxOptions"
:key="option.id"
:value="option.id"
>
{{ option.name }}
</option>
</select>
<p class="help-text">
{{ inboxHelpText }}
</p>
</label>
</div>
<div class="small-12 columns">
<label :class="{ error: v$.suite_category.$error }">
{{ $t('CAPTAIN_SETTINGS.GALLERY.FORM.SUITE_CATEGORY.LABEL') }}
<input
v-model="suite_category"
type="text"
:placeholder="
$t('CAPTAIN_SETTINGS.GALLERY.FORM.SUITE_CATEGORY.PLACEHOLDER')
"
@input="v$.suite_category.$touch"
/>
<span v-if="v$.suite_category.$error" class="message">
{{ $t('CAPTAIN_SETTINGS.GALLERY.FORM.SUITE_CATEGORY.ERROR') }}
</span>
</label>
</div>
<div class="small-12 columns">
<label :class="{ error: v$.suite_number.$error }">
{{ $t('CAPTAIN_SETTINGS.GALLERY.FORM.SUITE_NUMBER.LABEL') }}
<input
v-model="suite_number"
type="text"
:placeholder="
$t('CAPTAIN_SETTINGS.GALLERY.FORM.SUITE_NUMBER.PLACEHOLDER')
"
@input="v$.suite_number.$touch"
/>
<span v-if="v$.suite_number.$error" class="message">
{{ $t('CAPTAIN_SETTINGS.GALLERY.FORM.SUITE_NUMBER.ERROR') }}
</span>
</label>
</div>
<div class="small-12 columns">
<label :class="{ error: v$.description.$error }">
{{ $t('CAPTAIN_SETTINGS.GALLERY.FORM.DESCRIPTION.LABEL') }}
<textarea
v-model="description"
rows="3"
:placeholder="
$t('CAPTAIN_SETTINGS.GALLERY.FORM.DESCRIPTION.PLACEHOLDER')
"
@input="v$.description.$touch"
/>
<span v-if="v$.description.$error" class="message">
{{ $t('CAPTAIN_SETTINGS.GALLERY.FORM.DESCRIPTION.ERROR') }}
</span>
</label>
</div>
<div class="small-12 columns">
<label :class="{ error: v$.image.$error }">
{{ $t('CAPTAIN_SETTINGS.GALLERY.FORM.IMAGE.LABEL') }}
<input type="file" accept="image/*" @change="onFileChange" />
<span v-if="v$.image.$error" class="message">
{{ $t('CAPTAIN_SETTINGS.GALLERY.FORM.IMAGE.ERROR') }}
</span>
<p class="help-text">
{{ $t('CAPTAIN_SETTINGS.GALLERY.FORM.IMAGE.HELP_TEXT') }}
</p>
</label>
</div>
<div v-if="imagePreview" class="small-12 columns">
<img
:src="imagePreview"
:alt="$t('CAPTAIN_SETTINGS.GALLERY.FORM.IMAGE.PREVIEW_ALT')"
class="h-48 rounded object-cover"
/>
</div>
<div class="small-12 columns">
<label>
<input v-model="active" type="checkbox" />
{{ $t('CAPTAIN_SETTINGS.GALLERY.FORM.ACTIVE.LABEL') }}
</label>
</div>
<div class="small-12 columns button-group">
<SubmitButton
type="submit"
:is-loading="uiFlags.isCreating || uiFlags.isUpdating"
:label="
isNew
? $t('CAPTAIN_SETTINGS.GALLERY.ADD.SUBMIT_BUTTON_TEXT')
: $t('CAPTAIN_SETTINGS.GALLERY.EDIT.SUBMIT_BUTTON_TEXT')
"
/>
</div>
</form>
</div>
</template>

View File

@ -0,0 +1,196 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import SettingsLayout from '../../SettingsLayout.vue';
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const { t } = useI18n();
const router = useRouter();
const store = useStore();
const items = useMapGetter('captainGalleryItems/getItems');
const uiFlags = useMapGetter('captainGalleryItems/getUIFlags');
const deleteDialogRef = ref(null);
const itemToDelete = ref(null);
const hasItems = computed(() => items.value && items.value.length > 0);
onMounted(async () => {
await store.dispatch('captainGalleryItems/get');
});
const goToNew = () => {
router.push({
name: 'captain_settings_gallery_edit',
params: { id: 'new' },
});
};
const goToEdit = item => {
router.push({
name: 'captain_settings_gallery_edit',
params: { id: item.id },
});
};
const openDeleteDialog = item => {
itemToDelete.value = item;
deleteDialogRef.value?.open();
};
const confirmDelete = async () => {
if (!itemToDelete.value) return;
try {
await store.dispatch('captainGalleryItems/delete', itemToDelete.value.id);
useAlert(t('CAPTAIN_SETTINGS.GALLERY.DELETE.API.SUCCESS_MESSAGE'));
} catch {
useAlert(t('CAPTAIN_SETTINGS.GALLERY.DELETE.API.ERROR_MESSAGE'));
} finally {
itemToDelete.value = null;
}
};
</script>
<template>
<SettingsLayout
:is-loading="uiFlags.isFetching"
:loading-message="t('CAPTAIN_SETTINGS.GALLERY.TITLE')"
>
<template #header>
<BaseSettingsHeader
:title="t('CAPTAIN_SETTINGS.GALLERY.TITLE')"
:description="t('CAPTAIN_SETTINGS.GALLERY.DESC')"
>
<template #actions>
<Button
:label="t('CAPTAIN_SETTINGS.GALLERY.ADD_ITEM')"
icon="i-lucide-plus"
@click="goToNew"
/>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<div class="flex flex-col px-6 pb-8">
<div v-if="hasItems" class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-n-weak">
<th
class="py-3 pr-4 text-left text-xs font-medium uppercase tracking-wider text-n-slate-10"
>
{{ t('CAPTAIN_SETTINGS.GALLERY.LIST.TABLE_HEADER[0]') }}
</th>
<th
class="py-3 pr-4 text-left text-xs font-medium uppercase tracking-wider text-n-slate-10"
>
{{ t('CAPTAIN_SETTINGS.GALLERY.LIST.TABLE_HEADER[1]') }}
</th>
<th
class="py-3 pr-4 text-left text-xs font-medium uppercase tracking-wider text-n-slate-10"
>
{{ t('CAPTAIN_SETTINGS.GALLERY.LIST.TABLE_HEADER[2]') }}
</th>
<th
class="py-3 pr-4 text-left text-xs font-medium uppercase tracking-wider text-n-slate-10"
>
{{ t('CAPTAIN_SETTINGS.GALLERY.LIST.TABLE_HEADER[3]') }}
</th>
<th
class="py-3 text-right text-xs font-medium uppercase tracking-wider text-n-slate-10"
>
{{ t('CAPTAIN_SETTINGS.GALLERY.LIST.TABLE_HEADER[4]') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-n-weak">
<tr v-for="item in items" :key="item.id">
<td class="py-4 pr-4">
<img
v-if="item.image_url"
:src="item.image_url"
:alt="item.description"
class="h-16 w-24 rounded object-cover"
/>
</td>
<td class="py-4 pr-4">
<p class="mb-0 font-medium text-n-slate-12">
{{
item.scope === 'global'
? t('CAPTAIN_SETTINGS.GALLERY.FORM.INBOX.GLOBAL_OPTION')
: item.inbox_name || '-'
}}
</p>
<p class="mb-0 text-xs text-n-slate-10">
{{ item.description }}
</p>
</td>
<td class="py-4 pr-4 text-n-slate-11">
{{ item.suite_category }}
</td>
<td class="py-4 pr-4 text-n-slate-11">
{{ item.suite_number }}
</td>
<td class="py-4">
<div class="flex justify-end gap-2">
<Button
icon="i-lucide-pencil"
variant="ghost"
size="sm"
:label="t('CAPTAIN_SETTINGS.GALLERY.EDIT_ITEM')"
@click="goToEdit(item)"
/>
<Button
icon="i-lucide-trash-2"
variant="ghost"
size="sm"
color="ruby"
:label="t('CAPTAIN_SETTINGS.GALLERY.DELETE_ITEM')"
@click="openDeleteDialog(item)"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div
v-else
class="flex flex-col items-center justify-center gap-4 py-20 text-center"
>
<div class="size-16 rounded-full bg-n-blue-2" />
<div class="flex flex-col gap-1">
<p class="mb-0 text-base font-medium text-n-slate-12">
{{ t('CAPTAIN_SETTINGS.GALLERY.LIST.ADD_NEW_ITEM') }}
</p>
<p class="mb-0 max-w-sm text-sm text-n-slate-10">
{{ t('CAPTAIN_SETTINGS.GALLERY.LIST.NO_ITEMS_MESSAGE') }}
</p>
</div>
<Button
:label="t('CAPTAIN_SETTINGS.GALLERY.ADD_ITEM')"
icon="i-lucide-plus"
@click="goToNew"
/>
</div>
</div>
<Dialog
ref="deleteDialogRef"
type="alert"
:title="t('CAPTAIN_SETTINGS.GALLERY.DELETE.CONFIRM.TITLE')"
:description="t('CAPTAIN_SETTINGS.GALLERY.DELETE.CONFIRM.MESSAGE')"
:confirm-button-label="t('CAPTAIN_SETTINGS.GALLERY.DELETE.CONFIRM.YES')"
@confirm="confirmDelete"
/>
</template>
</SettingsLayout>
</template>

View File

@ -0,0 +1,433 @@
<script>
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import SubmitButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
SubmitButton,
},
setup() {
return { v$: useVuelidate() };
},
data() {
return {
name: '',
inter_account_number: '',
inter_pix_key: '',
inter_client_id: '',
inter_client_secret: '',
inter_cert_content: '',
inter_key_content: '',
proactive_pix_polling_enabled: false,
inbox_id: null,
hasInitialCert: false,
hasInitialKey: false,
hasInitialClientSecret: false,
};
},
computed: {
...mapGetters({
uiFlags: 'captainUnits/getUIFlags',
records: 'captainUnits/getUnits',
inboxes: 'inboxes/getInboxes',
}),
inboxOptions() {
return [
{
id: null,
name: this.$t('CAPTAIN_SETTINGS.UNITS.INBOX.NO_UNIT'),
},
...this.inboxes.map(inbox => ({ id: inbox.id, name: inbox.name })),
];
},
isNew() {
return this.$route.params.id === 'new';
},
isInterCredentialsReady() {
return Boolean(
this.inter_client_id &&
(this.inter_client_secret || this.hasInitialClientSecret) &&
this.inter_pix_key &&
this.inter_account_number &&
(this.inter_cert_content || this.hasInitialCert) &&
(this.inter_key_content || this.hasInitialKey)
);
},
pageTitle() {
return this.isNew
? this.$t('CAPTAIN_SETTINGS.UNITS.ADD.TITLE')
: this.$t('CAPTAIN_SETTINGS.UNITS.EDIT.TITLE');
},
pageDesc() {
return this.isNew
? this.$t('CAPTAIN_SETTINGS.UNITS.ADD.DESC')
: this.$t('CAPTAIN_SETTINGS.UNITS.EDIT.DESC');
},
},
validations: {
name: { required },
inter_account_number: { required },
inter_pix_key: { required },
},
mounted() {
this.$store.dispatch('inboxes/get');
if (!this.isNew) {
this.fetchUnit();
}
},
methods: {
async fetchUnit() {
if (!this.records.length) {
await this.$store.dispatch('captainUnits/get');
}
const unit = this.records.find(
u => u.id === Number(this.$route.params.id)
);
if (unit) {
this.name = unit.name;
this.inter_account_number = unit.inter_account_number;
this.inter_pix_key = unit.inter_pix_key;
this.inter_client_id = unit.inter_client_id;
this.inbox_id = unit.inbox_id || null;
this.proactive_pix_polling_enabled =
!!unit.proactive_pix_polling_enabled;
// Secret and cert contents are generally not returned by the API for security reasons,
// so we leave them blank to only update if the user types something new.
this.hasInitialCert = unit.has_cert;
this.hasInitialKey = unit.has_key;
this.hasInitialClientSecret = unit.has_client_secret;
}
},
async submitForm() {
this.v$.$touch();
if (this.v$.$invalid) return;
const payload = {
name: this.name,
inter_account_number: this.inter_account_number,
inter_pix_key: this.inter_pix_key,
inter_client_id: this.inter_client_id,
inter_client_secret: this.inter_client_secret,
inbox_id: this.inbox_id,
inter_cert_content: this.inter_cert_content,
inter_key_content: this.inter_key_content,
proactive_pix_polling_enabled: this.isInterCredentialsReady
? this.proactive_pix_polling_enabled
: false,
};
try {
if (this.isNew) {
await this.$store.dispatch('captainUnits/create', payload);
useAlert(this.$t('CAPTAIN_SETTINGS.UNITS.ADD.API.SUCCESS_MESSAGE'));
} else {
await this.$store.dispatch('captainUnits/update', {
id: this.$route.params.id,
...payload,
});
useAlert(this.$t('CAPTAIN_SETTINGS.UNITS.EDIT.API.SUCCESS_MESSAGE'));
}
this.$router.push({ name: 'captain_settings_units' });
} catch (error) {
const action = this.isNew ? 'ADD' : 'EDIT';
useAlert(
error?.response?.data?.message ||
// eslint-disable-next-line
this.$t(`CAPTAIN_SETTINGS.UNITS.${action}.API.ERROR_MESSAGE`)
);
}
},
triggerFileInput(refName) {
this.$refs[refName].click();
},
handleFileUpload(event, targetField) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
this[targetField] = e.target.result;
};
reader.onerror = () => {
useAlert(this.$t('CAPTAIN_SETTINGS.UNITS.EDIT.API.ERROR_MESSAGE'));
};
reader.readAsText(file);
},
},
};
</script>
<template>
<div class="column content-box">
<woot-modal-header :header-title="pageTitle" :header-content="pageDesc" />
<form class="row" @submit.prevent="submitForm">
<div class="small-12 columns">
<label :class="{ error: v$.name.$error }">
{{ $t('CAPTAIN_SETTINGS.UNITS.FORM.NAME.LABEL') }}
<input
v-model="name"
type="text"
:placeholder="$t('CAPTAIN_SETTINGS.UNITS.FORM.NAME.PLACEHOLDER')"
@input="v$.name.$touch"
/>
<span v-if="v$.name.$error" class="message">{{
$t('CAPTAIN_SETTINGS.UNITS.FORM.NAME.ERROR')
}}</span>
</label>
</div>
<div class="small-12 columns">
<label :class="{ error: v$.inter_account_number.$error }">
{{ $t('CAPTAIN_SETTINGS.UNITS.FORM.INTER_ACCOUNT_NUMBER.LABEL') }}
<input
v-model="inter_account_number"
type="text"
:placeholder="
$t('CAPTAIN_SETTINGS.UNITS.FORM.INTER_ACCOUNT_NUMBER.PLACEHOLDER')
"
@input="v$.inter_account_number.$touch"
/>
<span v-if="v$.inter_account_number.$error" class="message">{{
$t('CAPTAIN_SETTINGS.UNITS.FORM.INTER_ACCOUNT_NUMBER.ERROR')
}}</span>
</label>
</div>
<div class="small-12 columns">
<label :class="{ error: v$.inter_pix_key.$error }">
{{ $t('CAPTAIN_SETTINGS.UNITS.FORM.INTER_PIX_KEY.LABEL') }}
<input
v-model="inter_pix_key"
type="text"
:placeholder="
$t('CAPTAIN_SETTINGS.UNITS.FORM.INTER_PIX_KEY.PLACEHOLDER')
"
@input="v$.inter_pix_key.$touch"
/>
<span v-if="v$.inter_pix_key.$error" class="message">{{
$t('CAPTAIN_SETTINGS.UNITS.FORM.INTER_PIX_KEY.ERROR')
}}</span>
<p class="help-text">
{{ $t('CAPTAIN_SETTINGS.UNITS.FORM.INTER_PIX_KEY.HELP_TEXT') }}
</p>
</label>
</div>
<div class="small-12 columns">
<label>
{{ $t('CAPTAIN_SETTINGS.UNITS.INBOX.CONNECT_UNIT_LABEL') }}
<select v-model="inbox_id">
<option
v-for="option in inboxOptions"
:key="option.id === null ? 'no-inbox' : option.id"
:value="option.id"
>
{{ option.name }}
</option>
</select>
<p class="help-text">
{{ $t('CAPTAIN_SETTINGS.UNITS.INBOX.CONNECT_UNIT_HELP') }}
</p>
</label>
</div>
<div class="small-12 columns">
<label>
{{ $t('CAPTAIN_SETTINGS.UNITS.FORM.INTER_CLIENT_ID.LABEL') }}
<input
v-model="inter_client_id"
type="text"
:placeholder="
$t('CAPTAIN_SETTINGS.UNITS.FORM.INTER_CLIENT_ID.PLACEHOLDER')
"
/>
</label>
</div>
<div class="small-12 columns">
<label>
{{ $t('CAPTAIN_SETTINGS.UNITS.FORM.INTER_CLIENT_SECRET.LABEL') }}
<input
v-model="inter_client_secret"
type="password"
:placeholder="
$t('CAPTAIN_SETTINGS.UNITS.FORM.INTER_CLIENT_SECRET.PLACEHOLDER')
"
/>
</label>
</div>
<div class="small-12 columns">
<label>
{{ $t('CAPTAIN_SETTINGS.UNITS.FORM.INTER_CERT_CONTENT.LABEL') }}
<div class="file-upload-wrapper">
<SubmitButton
icon="i-lucide-upload"
size="sm"
variant="outline"
color="slate"
type="button"
@click="triggerFileInput('certFile')"
>
{{
$t(
'CAPTAIN_SETTINGS.UNITS.FORM.INTER_CERT_CONTENT.UPLOAD_BUTTON'
)
}}
</SubmitButton>
<input
id="certFile"
ref="certFile"
type="file"
accept=".crt,.pem"
class="hidden-file-input"
@change="handleFileUpload($event, 'inter_cert_content')"
/>
</div>
<textarea
v-model="inter_cert_content"
rows="5"
:placeholder="
$t('CAPTAIN_SETTINGS.UNITS.FORM.INTER_CERT_CONTENT.PLACEHOLDER')
"
/>
<p v-if="!isNew && hasInitialCert" class="help-text text-success">
<fluent-icon icon="checkmark-circle" />
{{ $t('CAPTAIN_SETTINGS.UNITS.FORM.CERT_PRESENT_HELP') }}
</p>
</label>
</div>
<div class="small-12 columns">
<label>
{{ $t('CAPTAIN_SETTINGS.UNITS.FORM.INTER_KEY_CONTENT.LABEL') }}
<div class="file-upload-wrapper">
<SubmitButton
icon="i-lucide-upload"
size="sm"
variant="outline"
color="slate"
type="button"
@click="triggerFileInput('keyFile')"
>
{{
$t(
'CAPTAIN_SETTINGS.UNITS.FORM.INTER_KEY_CONTENT.UPLOAD_BUTTON'
)
}}
</SubmitButton>
<input
id="keyFile"
ref="keyFile"
type="file"
accept=".key,.pem"
class="hidden-file-input"
@change="handleFileUpload($event, 'inter_key_content')"
/>
</div>
<textarea
v-model="inter_key_content"
rows="5"
:placeholder="
$t('CAPTAIN_SETTINGS.UNITS.FORM.INTER_KEY_CONTENT.PLACEHOLDER')
"
/>
<p v-if="!isNew && hasInitialKey" class="help-text text-success">
<fluent-icon icon="checkmark-circle" />
{{ $t('CAPTAIN_SETTINGS.UNITS.FORM.CERT_PRESENT_HELP') }}
</p>
</label>
</div>
<div class="small-12 columns">
<label>
{{ $t('CAPTAIN_SETTINGS.UNITS.FORM.PROACTIVE_PIX_POLLING.LABEL') }}
</label>
<div class="checkbox-wrapper">
<input
id="proactive_pix_polling_enabled"
v-model="proactive_pix_polling_enabled"
type="checkbox"
:disabled="!isInterCredentialsReady"
/>
<label for="proactive_pix_polling_enabled" class="checkbox-label">
{{
$t(
'CAPTAIN_SETTINGS.UNITS.FORM.PROACTIVE_PIX_POLLING.CHECKBOX_LABEL'
)
}}
</label>
</div>
<p class="help-text">
{{
$t('CAPTAIN_SETTINGS.UNITS.FORM.PROACTIVE_PIX_POLLING.HELP_TEXT')
}}
</p>
<p v-if="!isInterCredentialsReady" class="help-text">
{{
$t(
'CAPTAIN_SETTINGS.UNITS.FORM.PROACTIVE_PIX_POLLING.DISABLED_HELP_TEXT'
)
}}
</p>
</div>
<div class="small-12 columns">
<div class="button-wrapper">
<router-link
:to="{ name: 'captain_settings_units' }"
class="button clear"
>
{{ $t('CAPTAIN_SETTINGS.UNITS.FORM.CANCEL') }}
</router-link>
<SubmitButton
:is-loading="uiFlags.isCreating || uiFlags.isUpdating"
:disabled="v$.$invalid"
type="submit"
>
{{ $t('CAPTAIN_SETTINGS.UNITS.FORM.SAVE') }}
</SubmitButton>
</div>
</div>
</form>
</div>
</template>
<style scoped>
.content-box {
padding: var(--space-large);
}
.button-wrapper {
display: flex;
justify-content: flex-end;
gap: var(--space-small);
margin-top: var(--space-normal);
}
.help-text {
font-size: var(--font-size-mini);
color: var(--s-500);
margin-top: var(--space-micro);
}
.text-success {
color: var(--color-success);
}
.file-upload-wrapper {
margin-bottom: var(--space-small);
margin-top: var(--space-micro);
}
.hidden-file-input {
display: none;
}
.checkbox-wrapper {
display: flex;
align-items: center;
gap: var(--space-smaller);
}
.checkbox-label {
margin: 0;
}
</style>

View File

@ -0,0 +1,234 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import SettingsLayout from '../../SettingsLayout.vue';
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const { t } = useI18n();
const router = useRouter();
const store = useStore();
const units = useMapGetter('captainUnits/getUnits');
const uiFlags = useMapGetter('captainUnits/getUIFlags');
const deleteDialogRef = ref(null);
const unitToDelete = ref(null);
const hasUnits = computed(() => units.value && units.value.length > 0);
onMounted(async () => {
await store.dispatch('captainUnits/get');
});
const goToNew = () => {
router.push({
name: 'captain_settings_units_edit',
params: { id: 'new' },
});
};
const goToEdit = unit => {
router.push({
name: 'captain_settings_units_edit',
params: { id: unit.id },
});
};
const openDeleteDialog = unit => {
unitToDelete.value = unit;
deleteDialogRef.value?.open();
};
const confirmDelete = async () => {
if (!unitToDelete.value) return;
try {
await store.dispatch('captainUnits/delete', unitToDelete.value.id);
useAlert(t('CAPTAIN_SETTINGS.UNITS.DELETE.API.SUCCESS_MESSAGE'));
} catch {
useAlert(t('CAPTAIN_SETTINGS.UNITS.DELETE.API.ERROR_MESSAGE'));
} finally {
unitToDelete.value = null;
}
};
</script>
<template>
<SettingsLayout
:is-loading="uiFlags.isFetching"
:loading-message="t('CAPTAIN_SETTINGS.UNITS.TITLE')"
>
<template #header>
<BaseSettingsHeader
:title="t('CAPTAIN_SETTINGS.UNITS.TITLE')"
:description="t('CAPTAIN_SETTINGS.UNITS.DESC')"
>
<template #actions>
<Button
:label="t('CAPTAIN_SETTINGS.UNITS.ADD_UNIT')"
icon="i-lucide-plus"
@click="goToNew"
/>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<div class="flex flex-col px-6 pb-8">
<!-- Tabela de Unidades -->
<div v-if="hasUnits" class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-n-weak">
<th
class="py-3 pr-4 text-left text-xs font-medium uppercase tracking-wider text-n-slate-10"
>
{{ t('CAPTAIN_SETTINGS.UNITS.LIST.TABLE_HEADER[0]') }}
</th>
<th
class="py-3 pr-4 text-left text-xs font-medium uppercase tracking-wider text-n-slate-10"
>
{{ t('CAPTAIN_SETTINGS.UNITS.LIST.TABLE_HEADER[1]') }}
</th>
<th
class="py-3 pr-4 text-left text-xs font-medium uppercase tracking-wider text-n-slate-10"
>
{{ t('CAPTAIN_SETTINGS.UNITS.LIST.TABLE_HEADER[2]') }}
</th>
<th
class="py-3 pr-4 text-left text-xs font-medium uppercase tracking-wider text-n-slate-10"
>
{{ t('CAPTAIN_SETTINGS.UNITS.LIST.TABLE_HEADER[3]') }}
</th>
<th
class="py-3 text-right text-xs font-medium uppercase tracking-wider text-n-slate-10"
>
{{ t('CAPTAIN_SETTINGS.UNITS.LIST.TABLE_HEADER[4]') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-n-weak">
<tr v-for="unit in units" :key="unit.id">
<td class="py-4 pr-4">
<p class="mb-0 font-medium text-n-slate-12">
{{ unit.name }}
</p>
<p class="mb-0 text-xs text-n-slate-10">
{{ unit.inter_pix_key }}
</p>
</td>
<td class="py-4 pr-4 text-n-slate-11">
<p class="mb-0 text-n-slate-11">
{{ unit.inter_account_number }}
</p>
<p class="mb-0 text-xs text-n-slate-10">
{{
unit.inbox_name ||
t('CAPTAIN_SETTINGS.UNITS.INBOX.NO_UNIT')
}}
</p>
</td>
<td class="py-4 pr-4">
<div class="flex gap-2">
<span
v-if="unit.has_cert"
class="inline-flex items-center gap-1 rounded-full bg-n-teal-2 px-2 py-0.5 text-xs font-medium text-n-teal-11"
>
{{ t('CAPTAIN_SETTINGS.UNITS.LIST.CERT') }}
</span>
<span
v-else
class="inline-flex items-center gap-1 rounded-full bg-n-amber-2 px-2 py-0.5 text-xs font-medium text-n-amber-11"
>
{{ t('CAPTAIN_SETTINGS.UNITS.LIST.CERT') }}
</span>
<span
v-if="unit.has_key"
class="inline-flex items-center gap-1 rounded-full bg-n-teal-2 px-2 py-0.5 text-xs font-medium text-n-teal-11"
>
{{ t('CAPTAIN_SETTINGS.UNITS.LIST.KEY') }}
</span>
<span
v-else
class="inline-flex items-center gap-1 rounded-full bg-n-amber-2 px-2 py-0.5 text-xs font-medium text-n-amber-11"
>
{{ t('CAPTAIN_SETTINGS.UNITS.LIST.KEY') }}
</span>
</div>
</td>
<td class="py-4 pr-4">
<span
v-if="unit.proactive_pix_polling_enabled"
class="inline-flex items-center gap-1 rounded-full bg-n-teal-2 px-2 py-0.5 text-xs font-medium text-n-teal-11"
>
{{ t('CAPTAIN_SETTINGS.UNITS.LIST.PROACTIVE_ON') }}
</span>
<span
v-else
class="inline-flex items-center gap-1 rounded-full bg-n-slate-3 px-2 py-0.5 text-xs font-medium text-n-slate-11"
>
{{ t('CAPTAIN_SETTINGS.UNITS.LIST.PROACTIVE_OFF') }}
</span>
</td>
<td class="py-4">
<div class="flex justify-end gap-2">
<Button
icon="i-lucide-pencil"
variant="ghost"
size="sm"
:label="t('CAPTAIN_SETTINGS.UNITS.EDIT_UNIT')"
@click="goToEdit(unit)"
/>
<Button
icon="i-lucide-trash-2"
variant="ghost"
size="sm"
color="ruby"
:label="t('CAPTAIN_SETTINGS.UNITS.DELETE_UNIT')"
@click="openDeleteDialog(unit)"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Empty State -->
<div
v-else
class="flex flex-col items-center justify-center gap-4 py-20 text-center"
>
<div class="size-16 rounded-full bg-n-blue-2" />
<div class="flex flex-col gap-1">
<p class="mb-0 text-base font-medium text-n-slate-12">
{{ t('CAPTAIN_SETTINGS.UNITS.LIST.ADD_NEW_UNIT') }}
</p>
<p class="mb-0 max-w-sm text-sm text-n-slate-10">
{{ t('CAPTAIN_SETTINGS.UNITS.LIST.NO_UNITS_MESSAGE') }}
</p>
</div>
<Button
:label="t('CAPTAIN_SETTINGS.UNITS.ADD_UNIT')"
icon="i-lucide-plus"
@click="goToNew"
/>
</div>
</div>
<!-- Dialog de Confirmação de Exclusão -->
<Dialog
ref="deleteDialogRef"
type="alert"
:title="t('CAPTAIN_SETTINGS.UNITS.DELETE.CONFIRM.TITLE')"
:description="t('CAPTAIN_SETTINGS.UNITS.DELETE.CONFIRM.MESSAGE')"
:confirm-button-label="t('CAPTAIN_SETTINGS.UNITS.DELETE.CONFIRM.YES')"
@confirm="confirmDelete"
/>
</template>
</SettingsLayout>
</template>

View File

@ -0,0 +1,29 @@
<script>
import SettingsLayout from '../../SettingsLayout.vue';
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
export default {
name: 'TestUnitsIndex',
components: {
SettingsLayout,
BaseSettingsHeader,
},
};
</script>
<template>
<SettingsLayout>
<template #header>
<BaseSettingsHeader
:title="$t('CAPTAIN_SETTINGS.UNITS.TEST.HEADER_TITLE')"
:description="$t('CAPTAIN_SETTINGS.UNITS.TEST.HEADER_DESCRIPTION')"
/>
</template>
<template #body>
<div class="p-8">
{{ $t('CAPTAIN_SETTINGS.UNITS.TEST.BODY_TEXT') }}
</div>
</template>
</SettingsLayout>
</template>

View File

@ -0,0 +1,214 @@
<script>
import { useVuelidate } from '@vuelidate/core';
import JasmineAPI from 'dashboard/api/inbox/jasmine';
import { useAlert } from 'dashboard/composables';
import JasmineKnowledgeBase from './components/JasmineKnowledgeBase.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { computed, onMounted } from 'vue';
export default {
components: {
JasmineKnowledgeBase,
ComboBox,
NextButton,
},
props: {
inbox: {
type: Object,
required: true,
},
showKnowledgeBase: {
type: Boolean,
default: true,
},
isTab: {
type: Boolean,
default: false,
},
},
setup() {
const store = useStore();
const units = useMapGetter('captainUnits/getUnits');
const unitList = computed(() => {
return units.value.map(unit => ({
value: unit.id,
label: unit.name,
}));
});
onMounted(() => {
store.dispatch('captainUnits/get');
});
return { v$: useVuelidate(), unitList };
},
data() {
return {
isEnabled: false,
systemPrompt: '',
captainUnitId: null,
typingDelay: 0,
isUpdating: false,
};
},
mounted() {
this.fetchSettings();
},
methods: {
async fetchSettings() {
try {
const { data } = await JasmineAPI.getSettings(this.inbox.id);
this.isEnabled = data.is_enabled;
this.systemPrompt = data.system_prompt || '';
this.captainUnitId =
data.captain_unit_id || this.inbox.captain_unit_id || null;
} catch (error) {
// Fallback to inbox-linked unit when jasmine config endpoint is unavailable.
this.captainUnitId = this.inbox.captain_unit_id || null;
}
this.typingDelay = this.inbox.typing_delay || 0;
},
async updateSettings() {
this.isUpdating = true;
try {
// Persist unit link in the inbox config so it works even if Jasmine API is unavailable.
await this.$store.dispatch('inboxes/updateInbox', {
id: this.inbox.id,
formData: false,
captain_unit_id: this.captainUnitId,
typing_delay: this.typingDelay,
});
// Best effort: persist Jasmine-specific settings when backend endpoint exists.
try {
await JasmineAPI.updateSettings(this.inbox.id, {
inbox_config: {
is_enabled: this.isEnabled,
system_prompt: this.systemPrompt,
captain_unit_id: this.captainUnitId,
},
});
} catch (error) {
// Ignore missing/unavailable jasmine endpoint to avoid blocking unit link save.
}
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(error.message || this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
} finally {
this.isUpdating = false;
}
},
},
};
</script>
<template>
<div :class="{ 'mx-8': !isTab }">
<div class="settings-section">
<div class="flex flex-col gap-1 items-start mb-4">
<h2 class="text-xl font-medium text-slate-900 dark:text-slate-100">
{{ $t('JASMINE.CONFIG.TITLE') }}
</h2>
<p class="text-sm text-slate-600 dark:text-slate-400">
{{ $t('JASMINE.CONFIG.DESCRIPTION') }}
</p>
</div>
<div class="mb-6">
<label class="flex items-center gap-2 cursor-pointer">
<input
v-model="isEnabled"
type="checkbox"
class="form-checkbox h-5 w-5 text-woot-500 rounded border-gray-300 focus:ring-woot-500"
/>
<span class="text-sm font-medium text-slate-700 dark:text-slate-200">
{{ $t('JASMINE.CONFIG.ENABLE') }}
</span>
</label>
</div>
<div class="mb-6">
<label
class="block text-sm font-medium text-slate-700 dark:text-slate-200 mb-2"
>
{{ $t('JASMINE.CONFIG.SYSTEM_PROMPT') }}
</label>
<textarea
v-model="systemPrompt"
rows="6"
class="w-full text-sm rounded-md border-gray-300 dark:border-slate-700 dark:bg-slate-900 focus:border-woot-500 focus:ring-woot-500"
:placeholder="$t('JASMINE.CONFIG.SYSTEM_PROMPT_HELP')"
/>
<p class="mt-1 text-xs text-slate-500">
{{ $t('JASMINE.CONFIG.SYSTEM_PROMPT_HELP') }}
</p>
</div>
<div class="mb-6">
<label
class="block text-sm font-medium text-slate-700 dark:text-slate-200 mb-2"
>
{{
$t('CAPTAIN_SETTINGS.UNITS.INBOX.CONNECT_UNIT_LABEL') ||
'Captain Unit'
}}
</label>
<ComboBox
id="captainUnit"
v-model="captainUnitId"
:options="unitList"
:placeholder="
$t('CAPTAIN_SETTINGS.UNITS.INBOX.CONNECT_UNIT_PLACEHOLDER') ||
'Select a unit'
"
class="[&>div>button]:bg-white dark:[&>div>button]:bg-slate-900 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
/>
<p class="mt-1 text-xs text-slate-500">
{{
$t('CAPTAIN_SETTINGS.UNITS.INBOX.CONNECT_UNIT_HELP') ||
'Selecione uma Unidade Pix.'
}}
</p>
</div>
<div class="mb-6">
<label
class="block text-sm font-medium text-slate-700 dark:text-slate-200 mb-2"
>
{{
$t('JASMINE.CONFIG.TYPING_DELAY_LABEL') ||
'Buffer / Delay de Digitação (Segundos)'
}}
</label>
<input
v-model.number="typingDelay"
type="number"
min="0"
max="60"
class="w-full text-sm rounded-md border-gray-300 dark:border-slate-700 dark:bg-slate-900 focus:border-woot-500 focus:ring-woot-500"
:placeholder="
$t('JASMINE.CONFIG.TYPING_DELAY_PLACEHOLDER') || 'Ex: 5'
"
/>
<p class="mt-1 text-xs text-slate-500">
{{
$t('JASMINE.CONFIG.TYPING_DELAY_HELP') ||
'O tempo que a IA aguardará mensagens (Buffer) antes de simular a digitação e responder. Zero para imediato.'
}}
</p>
</div>
<NextButton
:is-loading="isUpdating"
:label="$t('JASMINE.CONFIG.UPDATE_BUTTON')"
@click="updateSettings"
/>
<JasmineKnowledgeBase v-if="showKnowledgeBase" :inbox-id="inbox.id" />
</div>
</div>
</template>

View File

@ -24,6 +24,9 @@ import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
import WidgetBuilder from './WidgetBuilder.vue';
import BotConfiguration from './components/BotConfiguration.vue';
import AccountHealth from './components/AccountHealth.vue';
import WuzapiConfiguration from './channels/wuzapi/WuzapiConfiguration.vue';
import EvolutionGoConfiguration from './channels/evolution_go/EvolutionGoConfiguration.vue';
import InboxAutoResolve from './components/InboxAutoResolve.vue';
import { FEATURE_FLAGS } from '../../../../featureFlags';
import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
@ -55,6 +58,9 @@ export default {
Editor,
Avatar,
AccountHealth,
WuzapiConfiguration,
EvolutionGoConfiguration,
InboxAutoResolve,
},
mixins: [inboxMixin],
setup() {
@ -81,10 +87,13 @@ export default {
replyTime: '',
selectedTabIndex: 0,
selectedPortalSlug: '',
captainUnitId: null,
showBusinessNameInput: false,
healthData: null,
isLoadingHealth: false,
healthError: null,
messageSignatureEnabled: false,
typingDelay: 0,
};
},
computed: {
@ -93,7 +102,22 @@ export default {
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
uiFlags: 'inboxes/getUIFlags',
portals: 'portals/allPortals',
captainUnits: 'captainUnits/getUnits',
}),
captainUnitOptions() {
const options = (this.captainUnits || []).map(unit => ({
id: unit.id,
label: unit.name,
}));
return [
{
id: null,
label: this.$t('CAPTAIN_SETTINGS.UNITS.INBOX.NO_UNIT'),
},
...options,
];
},
selectedTabKey() {
return this.tabs[this.selectedTabIndex]?.key;
},
@ -110,12 +134,6 @@ export default {
if (this.isATwilioWhatsAppChannel) {
return this.$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO');
}
if (this.isAWhatsAppBaileysChannel) {
return this.$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.BAILEYS');
}
if (this.isAWhatsAppZapiChannel) {
return this.$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.ZAPI');
}
return '';
},
tabs() {
@ -165,9 +183,7 @@ export default {
this.isAVoiceChannel ||
(this.isAnEmailChannel && !this.inbox.provider) ||
this.shouldShowWhatsAppConfiguration ||
this.isAWebWidgetInbox ||
this.isAWhatsAppBaileysChannel ||
this.isAWhatsAppZapiChannel
this.isAWebWidgetInbox
) {
visibleToAllChannelTabs = [
...visibleToAllChannelTabs,
@ -403,7 +419,10 @@ export default {
this.$store.dispatch('agents/get');
this.$store.dispatch('teams/get');
this.$store.dispatch('labels/get');
this.$store.dispatch('inboxes/get').then(() => {
Promise.all([
this.$store.dispatch('inboxes/get'),
this.$store.dispatch('captainUnits/get'),
]).then(() => {
this.avatarUrl = this.inbox.avatar_url;
this.selectedInboxName = this.inbox.name;
this.webhookUrl = this.inbox.webhook_url;
@ -424,6 +443,14 @@ export default {
this.selectedPortalSlug = this.inbox.help_center
? this.inbox.help_center.slug
: '';
this.captainUnitId =
this.inbox.captain_unit_id ||
this.captainUnits.find(
unit => Number(unit.inbox_id) === Number(this.currentInboxId)
)?.id ||
null;
this.typingDelay = this.inbox.typing_delay || 0;
this.messageSignatureEnabled = this.inbox.message_signature_enabled;
// Set initial tab after inbox data is loaded
this.setTabFromRouteParam();
@ -443,9 +470,12 @@ export default {
portal => portal.slug === this.selectedPortalSlug
).id
: null,
captain_unit_id: this.captainUnitId,
typing_delay: this.typingDelay,
lock_to_single_conversation: this.locktoSingleConversation,
sender_name_type: this.senderNameType,
business_name: this.businessName || null,
message_signature_enabled: this.messageSignatureEnabled,
channel: {
widget_color: this.inbox.widget_color,
website_url: this.channelWebsiteUrl,
@ -517,7 +547,7 @@ export default {
:header-title="inboxName"
>
<woot-tabs
class="[&_ul]:p-0 top-px relative"
class="[&_ul]:p-0"
:index="selectedTabIndex"
:border="false"
@change="onTabChange"
@ -543,6 +573,14 @@ export default {
:whatsapp-registration-incomplete="whatsappRegistrationIncomplete"
:inbox="inbox"
/>
<WuzapiConfiguration
v-if="isAWhatsAppChannel && inbox.provider === 'wuzapi'"
:inbox="inbox"
/>
<EvolutionGoConfiguration
v-if="isAWhatsAppChannel && inbox.provider === 'evolution'"
:inbox="inbox"
/>
<DuplicateInboxBanner
v-if="hasDuplicateInstagramInbox"
:content="$t('INBOX_MGMT.ADD.INSTAGRAM.DUPLICATE_INBOX_BANNER')"
@ -582,6 +620,18 @@ export default {
"
@blur="v$.selectedInboxName.$touch"
/>
<InboxAutoResolve :inbox="inbox" class="mb-4" />
<div class="flex flex-row items-center gap-2 mb-4">
<input
id="messageSignatureEnabled"
v-model="messageSignatureEnabled"
type="checkbox"
@change="updateInbox"
/>
<label for="messageSignatureEnabled">
{{ $t('INBOX_MGMT.ADD.MESSAGE_SIGNATURE.LABEL') }}
</label>
</div>
<woot-input
v-if="isAPIInbox"
v-model="webhookUrl"
@ -796,6 +846,47 @@ export default {
{{ $t('INBOX_MGMT.HELP_CENTER.SUB_TEXT') }}
</p>
</div>
<div class="pb-4">
<label>
{{ $t('CAPTAIN_SETTINGS.UNITS.INBOX.CONNECT_UNIT_LABEL') }}
</label>
<select v-model="captainUnitId">
<option
v-for="option in captainUnitOptions"
:key="option.id === null ? 'no-unit' : option.id"
:value="option.id"
>
{{ option.label }}
</option>
</select>
<p class="pb-1 text-sm not-italic text-n-slate-11">
{{ $t('CAPTAIN_SETTINGS.UNITS.INBOX.CONNECT_UNIT_HELP') }}
</p>
</div>
<div v-if="captainUnitId" class="pb-4">
<label>
{{
$t('JASMINE.CONFIG.TYPING_DELAY_LABEL') ||
'Delay de digitação (segundos)'
}}
</label>
<input
v-model.number="typingDelay"
type="number"
min="0"
max="60"
:placeholder="
$t('JASMINE.CONFIG.TYPING_DELAY_PLACEHOLDER') || 'Ex: 5'
"
/>
<p class="pb-1 text-sm not-italic text-n-slate-11">
{{
$t('JASMINE.CONFIG.TYPING_DELAY_HELP') ||
'Tempo simulado (em segundos) que a IA passará digitando antes de responder. Um valor baixo resulta em respostas mais rápidas, mas reações podem passar despercebidas.'
}}
</p>
</div>
<label v-if="canLocktoSingleConversation" class="pb-4">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.LOCK_TO_SINGLE_CONVERSATION') }}
<select v-model="locktoSingleConversation">

View File

@ -0,0 +1,448 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import NextButton from 'dashboard/components-next/button/Button.vue';
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const evolutionApiUrl = ref('');
const evolutionAdminToken = ref('');
const displayName = ref('');
const channelName = ref('');
const whatsappNumber = ref('');
const isAlwaysOnline = ref(true);
const isRejectCalls = ref(true);
const isMarkMessagesRead = ref(true);
const isIgnoreGroups = ref(false);
const isIgnoreStatus = ref(true);
const isCreating = ref(false);
const isTesting = ref(false);
const isConnectionTested = ref(false);
// Auto-generate channel name from display name (slugify-ish)
watch(displayName, newVal => {
if (newVal) {
channelName.value = newVal
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '');
}
});
const isSubmitEnabled = computed(() => {
return (
evolutionApiUrl.value &&
evolutionAdminToken.value &&
displayName.value &&
whatsappNumber.value &&
isConnectionTested.value
);
});
const testConnection = async () => {
isTesting.value = true;
try {
const accountId = store.getters.getCurrentAccountId;
await window.axios.post(
`/api/v1/accounts/${accountId}/evolution/test_connection`,
{
api_url: evolutionApiUrl.value,
api_token: evolutionAdminToken.value,
}
);
isConnectionTested.value = true;
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.CONNECT_SUCCESS'));
} catch (error) {
const errorMessage =
error?.response?.data?.error ||
t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.CONNECT_ERROR');
useAlert(errorMessage);
isConnectionTested.value = false;
} finally {
isTesting.value = false;
}
};
const createChannel = async () => {
isCreating.value = true;
try {
const payload = {
channel: {
type: 'whatsapp',
provider: 'evolution',
provider_config: {
evolution_base_url: evolutionApiUrl.value.replace(/\/$/, ''),
settings: {
always_online: isAlwaysOnline.value,
reject_call: isRejectCalls.value,
read_messages: isMarkMessagesRead.value,
ignore_groups: isIgnoreGroups.value,
ignore_status: isIgnoreStatus.value,
},
},
evolution_api_token: evolutionAdminToken.value,
phone_number: whatsappNumber.value,
},
name: displayName.value,
};
const response = await store.dispatch('inboxes/createChannel', payload);
router.push({
name: 'settings_inbox_show',
params: { inboxId: response.id },
});
} catch (error) {
const errorMessage =
error?.response?.data?.message ||
error?.message ||
t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE');
useAlert(errorMessage);
} finally {
isCreating.value = false;
}
};
</script>
<template>
<div class="h-full w-full max-w-2xl mx-auto pb-12">
<div class="mb-6">
<h2 class="text-2xl font-bold text-n-slate-12">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.EVOLUTION') }}
</h2>
<p class="text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.EVOLUTION_DESC') }}
</p>
</div>
<form class="space-y-6" @submit.prevent="createChannel">
<!-- API Config -->
<div
class="grid grid-cols-1 gap-4 p-4 border rounded-xl border-n-strong bg-n-alpha-1"
>
<div>
<label
class="block text-xs font-semibold mb-1 uppercase tracking-wider opacity-70"
>
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.API_URL.LABEL') }}
<span class="text-red-500">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.ASTERISK') }}
</span>
</label>
<input
v-model="evolutionApiUrl"
type="url"
class="w-full px-4 py-2 bg-white border rounded-lg border-n-strong focus:ring-2 focus:ring-blue-500 outline-none"
:placeholder="
$t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.API_URL.PLACEHOLDER')
"
required
@input="isConnectionTested = false"
/>
</div>
<div>
<label
class="block text-xs font-semibold mb-1 uppercase tracking-wider opacity-70"
>
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.API_TOKEN.LABEL') }}
<span class="text-red-500">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.ASTERISK') }}
</span>
</label>
<input
v-model="evolutionAdminToken"
type="password"
class="w-full px-4 py-2 bg-white border rounded-lg border-n-strong focus:ring-2 focus:ring-blue-500 outline-none"
:placeholder="
$t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.API_TOKEN.PLACEHOLDER')
"
required
@input="isConnectionTested = false"
/>
</div>
</div>
<!-- Identity -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label
class="block text-xs font-semibold mb-1 uppercase tracking-wider opacity-70"
>
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.DISPLAY_NAME.LABEL') }}
<span class="text-red-500">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.ASTERISK') }}
</span>
</label>
<input
v-model="displayName"
type="text"
class="w-full px-4 py-2 bg-white border rounded-lg border-n-strong focus:ring-2 focus:ring-blue-500 outline-none"
:placeholder="
$t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.DISPLAY_NAME.PLACEHOLDER')
"
required
/>
<p class="mt-1 text-xs text-n-slate-11">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.DISPLAY_NAME.HELP_TEXT') }}
</p>
</div>
<div>
<label
class="block text-xs font-semibold mb-1 uppercase tracking-wider opacity-70"
>
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.CHANNEL_NAME.LABEL') }}
<span class="text-red-500">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.ASTERISK') }}
</span>
</label>
<input
v-model="channelName"
type="text"
class="w-full px-4 py-2 bg-n-alpha-1 border rounded-lg border-n-strong text-n-slate-10 outline-none cursor-not-allowed"
readonly
/>
<p class="mt-1 text-xs text-n-slate-11">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.CHANNEL_NAME.HELP_TEXT') }}
</p>
</div>
</div>
<div>
<label
class="block text-xs font-semibold mb-1 uppercase tracking-wider opacity-70"
>
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.PHONE_NUMBER.LABEL') }}
<span class="text-red-500">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.ASTERISK') }}
</span>
</label>
<div class="flex items-center">
<span
class="px-3 py-2 bg-n-alpha-2 border border-r-0 border-n-strong rounded-l-lg text-lg"
>
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.HELP.BRAZIL_FLAG') }}
</span>
<input
v-model="whatsappNumber"
type="text"
class="w-full px-4 py-2 bg-white border rounded-r-lg border-n-strong focus:ring-2 focus:ring-blue-500 outline-none"
:placeholder="
$t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.PHONE_NUMBER.PLACEHOLDER')
"
required
/>
</div>
</div>
<!-- Instance Settings -->
<div class="p-6 border rounded-xl border-n-strong bg-white shadow-sm">
<h3 class="text-base font-bold text-n-slate-12 mb-4">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.INSTANCE_SETTINGS.TITLE') }}
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-y-3 gap-x-6 text-sm">
<label class="flex items-center space-x-3 cursor-pointer group">
<input
v-model="isAlwaysOnline"
type="checkbox"
class="w-4 h-4 rounded text-blue-600 border-n-strong focus:ring-blue-500"
/>
<span
class="text-n-slate-12 group-hover:text-blue-600 transition-colors"
>
{{
$t(
'INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.INSTANCE_SETTINGS.ALWAYS_ONLINE'
)
}}
</span>
</label>
<label class="flex items-center space-x-3 cursor-pointer group">
<input
v-model="isRejectCalls"
type="checkbox"
class="w-4 h-4 rounded text-blue-600 border-n-strong focus:ring-blue-500"
/>
<span
class="text-n-slate-12 group-hover:text-blue-600 transition-colors"
>
{{
$t(
'INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.INSTANCE_SETTINGS.REJECT_CALLS'
)
}}
</span>
</label>
<label class="flex items-center space-x-3 cursor-pointer group">
<input
v-model="isMarkMessagesRead"
type="checkbox"
class="w-4 h-4 rounded text-blue-600 border-n-strong focus:ring-blue-500"
/>
<span
class="text-n-slate-12 group-hover:text-blue-600 transition-colors"
>
{{
$t(
'INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.INSTANCE_SETTINGS.READ_MESSAGES'
)
}}
</span>
</label>
<label class="flex items-center space-x-3 cursor-pointer group">
<input
v-model="isIgnoreGroups"
type="checkbox"
class="w-4 h-4 rounded text-blue-600 border-n-strong focus:ring-blue-500"
/>
<span
class="text-n-slate-12 group-hover:text-blue-600 transition-colors"
>
{{
$t(
'INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.INSTANCE_SETTINGS.IGNORE_GROUPS'
)
}}
</span>
</label>
<div
class="flex items-center space-x-3 cursor-pointer group col-span-2"
>
<label class="flex items-center space-x-3 cursor-pointer group">
<input
v-model="isIgnoreStatus"
type="checkbox"
class="w-4 h-4 rounded text-blue-600 border-n-strong focus:ring-blue-500"
/>
<span
class="text-n-slate-12 group-hover:text-blue-600 transition-colors"
>
{{
$t(
'INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.INSTANCE_SETTINGS.IGNORE_STATUS'
)
}}
</span>
</label>
</div>
</div>
</div>
<!-- Help Section -->
<div class="p-6 border rounded-xl border-n-strong bg-n-alpha-1">
<h4 class="flex items-center text-sm font-bold text-n-slate-12 mb-4">
<span class="mr-2">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.HELP.ICON_INFO') }}
</span>
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.HELP.TITLE') }}
</h4>
<p class="text-xs text-n-slate-11 mb-4 leading-relaxed">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.HELP.HELP_TEXT_1') }}
</p>
<ul class="grid grid-cols-1 md:grid-cols-2 gap-4">
<li class="flex items-start">
<span class="mr-2 mt-0.5 text-xs">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.HELP.ICON_STAR') }}
</span>
<div>
<p class="text-xs font-semibold text-n-slate-12">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.HELP.ICON_ROCKET') }}
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.HELP.FEATURE_1') }}
</p>
</div>
</li>
<li class="flex items-start">
<span class="mr-2 mt-0.5 text-xs">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.HELP.ICON_STAR') }}
</span>
<div>
<p class="text-xs font-semibold text-n-slate-12">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.HELP.ICON_SHIELD') }}
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.HELP.FEATURE_2') }}
</p>
</div>
</li>
<li class="flex items-start">
<span class="mr-2 mt-0.5 text-xs">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.HELP.ICON_STAR') }}
</span>
<div>
<p class="text-xs font-semibold text-n-slate-12">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.HELP.ICON_SYNC') }}
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.HELP.FEATURE_3') }}
</p>
</div>
</li>
<li class="flex items-start">
<span class="mr-2 mt-0.5 text-xs">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.HELP.ICON_STAR') }}
</span>
<div>
<p class="text-xs font-semibold text-n-slate-12">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.HELP.ICON_CHART') }}
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.HELP.FEATURE_4') }}
</p>
</div>
</li>
</ul>
</div>
<!-- Warning -->
<div
v-if="!isConnectionTested"
class="p-4 bg-yellow-50 border border-yellow-200 rounded-xl flex items-center space-x-3"
>
<span class="text-xl">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.HELP.ICON_WARNING') }}
</span>
<p class="text-xs text-yellow-800 font-medium">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.TEST_CONNECTION_BANNER') }}
</p>
</div>
<div
class="flex items-center justify-between pt-4 border-t border-n-strong"
>
<NextButton
:is-loading="isTesting"
type="button"
outline
:label="$t('INBOX_MGMT.ADD.WHATSAPP.EVOLUTION.TEST_CONNECTION')"
@click="testConnection"
/>
<div class="flex space-x-3">
<NextButton
type="button"
outline
:label="$t('INBOX_MGMT.ADD.WHATSAPP.CANCEL')"
@click="router.back()"
/>
<NextButton
:is-loading="isCreating"
:disabled="!isSubmitEnabled"
type="submit"
solid
blue
:label="$t('INBOX_MGMT.ADD.WHATSAPP.SUBMIT_BUTTON')"
/>
</div>
</div>
</form>
</div>
</template>
<style scoped>
.cursor-not-allowed {
cursor: not-allowed;
}
</style>

View File

@ -6,9 +6,11 @@ import Twilio from './Twilio.vue';
import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
import CloudWhatsapp from './CloudWhatsapp.vue';
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
import BaileysWhatsapp from './BaileysWhatsapp.vue';
import Wuzapi from './Wuzapi.vue';
import EvolutionGo from './EvolutionGo.vue';
import ZapiWhatsapp from './ZapiWhatsapp.vue';
import BaileysWhatsapp from './BaileysWhatsapp.vue';
import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
import PromoBanner from 'dashboard/components-next/banner/PromoBanner.vue';
const route = useRoute();
@ -24,6 +26,8 @@ const PROVIDER_TYPES = {
THREE_SIXTY_DIALOG: '360dialog',
BAILEYS: 'baileys',
ZAPI: 'zapi',
WUZAPI: 'wuzapi',
EVOLUTION: 'evolution',
};
const hasWhatsappAppId = computed(() => {
@ -39,8 +43,7 @@ const showProviderSelection = computed(() => !selectedProvider.value);
const showConfiguration = computed(() => Boolean(selectedProvider.value));
const availableProviders = computed(() => {
const providers = [
const availableProviders = computed(() => [
{
key: PROVIDER_TYPES.WHATSAPP,
title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD'),
@ -65,10 +68,19 @@ const availableProviders = computed(() => {
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.ZAPI_DESC'),
icon: 'i-woot-zapi',
},
];
return providers;
});
{
key: PROVIDER_TYPES.WUZAPI,
title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WUZAPI'),
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WUZAPI_DESC'),
icon: 'i-woot-whatsapp',
},
{
key: PROVIDER_TYPES.EVOLUTION,
title: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.EVOLUTION'),
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.EVOLUTION_DESC'),
icon: 'i-woot-whatsapp',
},
]);
const selectProvider = providerValue => {
router.push({
@ -117,7 +129,7 @@ const handleManualLinkClick = () => {
<img
src="~dashboard/assets/images/curved-arrow.svg"
alt=""
class="absolute -top-12 right-0 w-20 h-20 pointer-events-none z-10 scale-y-[-1] -rotate-45"
class="absolute -top-12 right-64 w-20 h-20 pointer-events-none z-10 scale-y-[-1] -rotate-45"
/>
<PromoBanner
:title="
@ -174,6 +186,15 @@ const handleManualLinkClick = () => {
<!-- Show manual setup -->
<CloudWhatsapp v-else-if="shouldShowCloudWhatsapp(selectedProvider)" />
<Wuzapi v-else-if="selectedProvider === PROVIDER_TYPES.WUZAPI" />
<EvolutionGo
v-else-if="selectedProvider === PROVIDER_TYPES.EVOLUTION"
/>
<BaileysWhatsapp
v-else-if="selectedProvider === PROVIDER_TYPES.BAILEYS"
/>
<ZapiWhatsapp v-else-if="selectedProvider === PROVIDER_TYPES.ZAPI" />
<!-- Other providers -->
<Twilio
v-else-if="selectedProvider === PROVIDER_TYPES.TWILIO"
@ -182,13 +203,7 @@ const handleManualLinkClick = () => {
<ThreeSixtyDialogWhatsapp
v-else-if="selectedProvider === PROVIDER_TYPES.THREE_SIXTY_DIALOG"
/>
<CloudWhatsapp
v-else-if="selectedProvider === PROVIDER_TYPES.WHATSAPP"
/>
<BaileysWhatsapp
v-else-if="selectedProvider === PROVIDER_TYPES.BAILEYS"
/>
<ZapiWhatsapp v-else-if="selectedProvider === PROVIDER_TYPES.ZAPI" />
<CloudWhatsapp v-else />
</div>
</div>
</div>

View File

@ -0,0 +1,159 @@
<script setup>
import { ref, computed } from 'vue';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import NextButton from 'dashboard/components-next/button/Button.vue';
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const wuzapiBaseUrl = ref('');
const wuzapiAdminToken = ref('');
const inboxName = ref('');
const phoneNumber = ref('');
const autoCreateUser = ref(true);
const isCreating = ref(false);
const isSubmitEnabled = computed(() => {
return (
wuzapiBaseUrl.value &&
wuzapiAdminToken.value &&
inboxName.value &&
phoneNumber.value
);
});
const createChannel = async () => {
isCreating.value = true;
try {
const payload = {
channel: {
type: 'whatsapp',
provider: 'wuzapi',
provider_config: {
wuzapi_base_url: wuzapiBaseUrl.value.replace(/\/$/, ''),
wuzapi_admin_token: wuzapiAdminToken.value,
auto_create_user: autoCreateUser.value,
},
phone_number: phoneNumber.value,
},
name: inboxName.value,
};
const response = await store.dispatch('inboxes/createChannel', payload);
router.push({
name: 'settings_inbox_show',
params: { inboxId: response.id },
});
} catch (error) {
const errorMessage =
error?.response?.data?.message ||
error?.message ||
t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE');
useAlert(errorMessage);
} finally {
isCreating.value = false;
}
};
</script>
<template>
<div class="h-full w-full">
<div class="mb-4">
<h2 class="text-xl font-medium text-n-slate-12">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WUZAPI') }}
</h2>
<p class="text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WUZAPI_DESC') }}
</p>
</div>
<form class="flex flex-wrap flex-col mx-0" @submit.prevent="createChannel">
<div class="w-full mb-4">
<label class="block text-sm font-medium text-n-slate-12 mb-1">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.WUZAPI.BASE_URL.LABEL') }}
</label>
<input
v-model="wuzapiBaseUrl"
type="url"
class="w-full px-3 py-2 border rounded-md border-n-strong"
:placeholder="
$t('INBOX_MGMT.ADD.WHATSAPP.WUZAPI.BASE_URL.PLACEHOLDER')
"
required
/>
<p class="mt-1 text-xs text-n-slate-11">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.WUZAPI.BASE_URL.HELP_TEXT') }}
</p>
</div>
<div class="w-full mb-4">
<label class="block text-sm font-medium text-n-slate-12 mb-1">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.WUZAPI.ADMIN_TOKEN.LABEL') }}
</label>
<input
v-model="wuzapiAdminToken"
type="password"
class="w-full px-3 py-2 border rounded-md border-n-strong"
:placeholder="
$t('INBOX_MGMT.ADD.WHATSAPP.WUZAPI.ADMIN_TOKEN.PLACEHOLDER')
"
required
/>
</div>
<div class="w-full mb-4">
<label class="block text-sm font-medium text-n-slate-12 mb-1">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.LABEL') }}
</label>
<input
v-model="inboxName"
type="text"
class="w-full px-3 py-2 border rounded-md border-n-strong"
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.PLACEHOLDER')"
required
/>
</div>
<div class="w-full mb-4">
<label class="block text-sm font-medium text-n-slate-12 mb-1">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PHONE_NUMBER.LABEL') }}
</label>
<input
v-model="phoneNumber"
type="text"
class="w-full px-3 py-2 border rounded-md border-n-strong"
:placeholder="$t('INBOX_MGMT.ADD.WHATSAPP.PHONE_NUMBER.PLACEHOLDER')"
required
/>
</div>
<div class="w-full mb-4">
<label class="flex items-center space-x-2">
<input
v-model="autoCreateUser"
type="checkbox"
class="rounded border-n-strong"
/>
<span class="text-sm text-n-slate-12">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.WUZAPI.AUTO_CREATE_USER.LABEL') }}
</span>
</label>
</div>
<div class="w-full mt-4">
<NextButton
:is-loading="isCreating"
:disabled="!isSubmitEnabled"
type="submit"
solid
blue
:label="$t('INBOX_MGMT.ADD.WHATSAPP.SUBMIT_BUTTON')"
/>
</div>
</form>
</div>
</template>

View File

@ -0,0 +1,211 @@
<script>
// Use global axios (window.axios) which has interceptors for auth headers
import { defineComponent, ref, onMounted, onUnmounted, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { useAlert } from 'dashboard/composables';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default defineComponent({
components: { NextButton },
props: {
inbox: {
type: Object,
required: true,
},
},
setup(props) {
const { t } = useI18n();
const store = useStore();
const isLoading = ref(false);
const isConnected = ref(false);
const qrCode = ref('');
const statusMessage = ref('');
let pollInterval = null;
// Get accountId reliably from global store (preferred) or inbox prop
const accountId = computed(() => {
return store.getters.getCurrentAccountId || props.inbox.account_id;
});
// Helper for API URL
const getApiUrl = endpoint => {
if (!accountId.value) throw new Error('Account ID missing');
return `/api/v1/accounts/${accountId.value}/inboxes/${props.inbox.id}/evolution${endpoint}`;
};
function stopPolling() {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
}
async function fetchStatus() {
if (!accountId.value) return;
try {
const response = await window.axios.get(getApiUrl(''));
const data = response.data;
const evolutionData = data.instance || {};
const isEvolutionConnected =
evolutionData.state === 'open' ||
data.state === 'open' ||
data.state === 'connected';
const legacyStatus = data.status || data.state;
const isLegacyConnected = ['open', 'connected', 'success'].includes(
legacyStatus
);
isConnected.value = isEvolutionConnected || isLegacyConnected;
statusMessage.value = evolutionData.state || legacyStatus || 'Unknown';
if (isConnected.value) {
qrCode.value = '';
stopPolling();
}
} catch (error) {
statusMessage.value =
error.response?.data?.error || error.message || 'Check failed';
}
}
/* eslint-disable no-use-before-define */
function startPolling() {
if (pollInterval) return;
pollInterval = setInterval(async () => {
await fetchStatus();
if (pollInterval && !isConnected.value) {
await fetchQrCode();
}
}, 5000);
}
async function fetchQrCode() {
try {
const response = await window.axios.get(getApiUrl('/qr'));
const d = response.data;
// In evolution go, QR is typically returned as base64 in `qrcode` or `base64` property
const qrcodeData =
d.qrcode?.base64 ||
d.base64 ||
d.qrcode ||
d.qr ||
(typeof d.data === 'string' ? d.data : null);
if (qrcodeData && qrcodeData.length > 20) {
qrCode.value = qrcodeData;
startPolling();
} else {
await fetchStatus();
if (!isConnected.value) {
statusMessage.value = 'QR Code not received and not connected.';
}
}
} catch (error) {
statusMessage.value =
error.response?.data?.error || 'Failed to load QR';
}
}
const disconnect = async () => {
isLoading.value = true;
try {
await window.axios.post(getApiUrl('/disconnect'));
useAlert(t('INBOX_MGMT.EDIT.EVOLUTION.DISCONNECT_SUCCESS'));
isConnected.value = false;
qrCode.value = '';
fetchStatus();
} catch (error) {
useAlert(t('INBOX_MGMT.EDIT.EVOLUTION.DISCONNECT_ERROR'));
} finally {
isLoading.value = false;
}
};
onMounted(() => {
fetchStatus();
// Only fetch QR code if not already connected
if (!isConnected.value) {
fetchQrCode();
}
});
onUnmounted(() => {
stopPolling();
});
return {
isLoading,
isConnected,
qrCode,
statusMessage,
fetchStatus,
disconnect,
accountId,
};
},
});
</script>
<template>
<div class="mx-8 mt-6">
<div class="bg-white p-6 rounded-lg border border-n-weak">
<h3 class="text-lg font-medium text-n-slate-12 mb-4">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.EVOLUTION') }}
{{ `- ${$t('INBOX_MGMT.SETTINGS_POPUP.MESSENGER_CONFIG')}` }}
</h3>
<div v-if="accountId" class="flex flex-col items-center">
<div v-if="isConnected" class="flex flex-col items-center">
<div class="text-green-600 font-bold mb-4 flex items-center gap-2">
<span class="i-woot-checkmark-circle text-2xl" />
{{ $t('INBOX_MGMT.EDIT.EVOLUTION.CONNECTED') }}
</div>
<p class="text-n-slate-11 mb-4">
{{ $t('INBOX_MGMT.EDIT.EVOLUTION.CONNECTED_DESC') }}
</p>
<NextButton
color="ruby"
:is-loading="isLoading"
:label="$t('INBOX_MGMT.EDIT.EVOLUTION.DISCONNECT')"
@click="disconnect"
/>
</div>
<div v-else class="flex flex-col items-center">
<div v-if="qrCode" class="mb-4">
<img
:src="qrCode"
alt="Whatsapp QR Code"
class="w-64 h-64 border rounded"
/>
<p class="text-center text-sm text-n-slate-11 mt-2">
{{ $t('INBOX_MGMT.EDIT.EVOLUTION.SCAN_QR') }}
</p>
</div>
<div v-else class="flex flex-col items-center mb-4">
<p class="text-n-slate-11 mb-4">
{{
$t('INBOX_MGMT.EDIT.EVOLUTION.CONNECT_DESC') ||
'Loading QR Code...'
}}
</p>
</div>
<div class="mt-4 text-xs text-n-slate-10">
{{ $t('JASMINE.EVOLUTION.STATUS', { status: statusMessage }) }}
</div>
</div>
</div>
<div v-else class="text-red-600 p-4">
{{ $t('JASMINE.EVOLUTION.ACCOUNT_ERROR') }}
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,296 @@
<script>
// Use global axios (window.axios) which has interceptors for auth headers
import { defineComponent, ref, onMounted, onUnmounted, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { useAlert } from 'dashboard/composables';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default defineComponent({
components: { NextButton },
props: {
inbox: {
type: Object,
required: true,
},
},
setup(props) {
const { t } = useI18n();
const store = useStore();
const isLoading = ref(false);
const isConnected = ref(false);
const qrCode = ref('');
const statusMessage = ref('');
let pollInterval = null;
// Get accountId reliably from global store (preferred) or inbox prop
const accountId = computed(() => {
return store.getters.getCurrentAccountId || props.inbox.account_id;
});
// Helper for API URL
const getApiUrl = endpoint => {
if (!accountId.value) throw new Error('Account ID missing');
return `/api/v1/accounts/${accountId.value}/inboxes/${props.inbox.id}/wuzapi${endpoint}`;
};
function stopPolling() {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
}
async function fetchStatus() {
if (!accountId.value) return;
try {
const response = await window.axios.get(getApiUrl(''));
const data = response.data;
const wuzapiData = data.data || {};
const isWuzapiConnected =
wuzapiData.connected === true && !!wuzapiData.jid;
const legacyStatus = data.status || data.state;
const isLegacyConnected = ['CONNECTED', 'inChat', 'success'].includes(
legacyStatus
);
isConnected.value = isWuzapiConnected || isLegacyConnected;
statusMessage.value = wuzapiData.details || legacyStatus || 'Unknown';
if (isConnected.value) {
qrCode.value = '';
stopPolling();
}
} catch (error) {
statusMessage.value =
error.response?.data?.error || error.message || 'Check failed';
}
}
/* eslint-disable no-use-before-define */
function startPolling() {
if (pollInterval) return;
pollInterval = setInterval(async () => {
await fetchStatus();
if (pollInterval && !isConnected.value) {
await fetchQrCode();
}
}, 5000);
}
async function fetchQrCode() {
try {
const response = await window.axios.get(getApiUrl('/qr'));
const d = response.data;
const qrcodeData =
d.qrcode ||
d.qr ||
d.QRCode ||
d.data?.qrcode ||
d.data?.qr ||
(typeof d.data === 'string' ? d.data : null);
if (qrcodeData && qrcodeData.length > 20) {
qrCode.value = qrcodeData;
startPolling();
} else {
await fetchStatus();
if (!isConnected.value) {
statusMessage.value = 'QR Code not received and not connected.';
}
}
} catch (error) {
statusMessage.value =
error.response?.data?.error || 'Failed to load QR';
}
}
const handleConnect = async () => {
if (!accountId.value) {
useAlert('Error: Account ID missing');
return;
}
isLoading.value = true;
try {
// 1. Call Connect
const connectUrl = getApiUrl('/connect');
await window.axios.post(connectUrl);
// 2. Fetch QR
await fetchQrCode();
} catch (error) {
useAlert(error.response?.data?.error || 'Connection failed');
} finally {
isLoading.value = false;
}
};
const disconnect = async () => {
isLoading.value = true;
try {
await window.axios.post(getApiUrl('/disconnect'));
useAlert(t('INBOX_MGMT.EDIT.WUZAPI.DISCONNECT_SUCCESS'));
isConnected.value = false;
qrCode.value = '';
fetchStatus();
} catch (error) {
useAlert(t('INBOX_MGMT.EDIT.WUZAPI.DISCONNECT_ERROR'));
} finally {
isLoading.value = false;
}
};
const isLoadingWebhook = ref(false);
const webhookInfo = ref(null);
const fetchWebhookInfo = async () => {
isLoadingWebhook.value = true;
try {
const response = await window.axios.get(getApiUrl('/webhook_info'));
webhookInfo.value = response.data;
useAlert('Webhook info fetched successfully');
} catch (error) {
useAlert(error.response?.data?.error || 'Failed to fetch webhook info');
} finally {
isLoadingWebhook.value = false;
}
};
const updateWebhook = async () => {
isLoadingWebhook.value = true;
try {
const response = await window.axios.put(getApiUrl('/update_webhook'));
webhookInfo.value = {
message: response.data.message,
url: response.data.webhook_url,
};
useAlert('Webhook updated successfully');
} catch (error) {
useAlert(error.response?.data?.error || 'Failed to update webhook');
} finally {
isLoadingWebhook.value = false;
}
};
onMounted(() => {
fetchStatus();
});
onUnmounted(() => {
stopPolling();
});
return {
isLoading,
isConnected,
qrCode,
statusMessage,
fetchStatus,
disconnect,
handleConnect,
accountId,
isLoadingWebhook,
webhookInfo,
fetchWebhookInfo,
updateWebhook,
};
},
});
</script>
<template>
<div class="mx-8 mt-6">
<div class="bg-white p-6 rounded-lg border border-n-weak">
<h3 class="text-lg font-medium text-n-slate-12 mb-4">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WUZAPI') }}
{{ `- ${$t('INBOX_MGMT.SETTINGS_POPUP.MESSENGER_CONFIG')}` }}
</h3>
<div v-if="accountId" class="flex flex-col items-center">
<div v-if="isConnected" class="flex flex-col items-center">
<div class="text-green-600 font-bold mb-4 flex items-center gap-2">
<span class="i-woot-checkmark-circle text-2xl" />
{{ $t('INBOX_MGMT.EDIT.WUZAPI.CONNECTED') }}
</div>
<p class="text-n-slate-11 mb-4">
{{ $t('INBOX_MGMT.EDIT.WUZAPI.CONNECTED_DESC') }}
</p>
<NextButton
color="ruby"
:is-loading="isLoading"
:label="$t('INBOX_MGMT.EDIT.WUZAPI.DISCONNECT')"
@click="disconnect"
/>
</div>
<div v-else class="flex flex-col items-center">
<div v-if="qrCode" class="mb-4">
<img
:src="qrCode"
alt="Whatsapp QR Code"
class="w-64 h-64 border rounded"
/>
<p class="text-center text-sm text-n-slate-11 mt-2">
{{ $t('INBOX_MGMT.EDIT.WUZAPI.SCAN_QR') }}
</p>
</div>
<div v-else class="flex flex-col items-center mb-4">
<p class="text-n-slate-11 mb-4">
{{
$t('INBOX_MGMT.EDIT.WUZAPI.CONNECT_DESC') ||
'Click to initiate connection'
}}
</p>
<NextButton
color="blue"
:is-loading="isLoading"
:label="
$t('INBOX_MGMT.EDIT.WUZAPI.CONNECT') || 'Connect WhatsApp'
"
@click="handleConnect"
/>
</div>
<div class="mt-4 text-xs text-n-slate-10">
{{ $t('JASMINE.WUZAPI.STATUS', { status: statusMessage }) }}
</div>
</div>
</div>
<div v-else class="text-red-600 p-4">
{{ $t('JASMINE.WUZAPI.ACCOUNT_ERROR') }}
</div>
<div class="mt-8 pt-6 border-t border-n-weak w-full">
<h4 class="text-md font-medium text-n-slate-12 mb-4">
{{ $t('JASMINE.WUZAPI.WEBHOOK_SECTION') }}
</h4>
<div class="flex gap-4 mb-4">
<NextButton
icon="i-woot-refresh"
:is-loading="isLoadingWebhook"
:label="$t('JASMINE.WUZAPI.GET_WEBHOOK_INFO')"
@click="fetchWebhookInfo"
/>
<NextButton
icon="i-woot-upload"
:is-loading="isLoadingWebhook"
:label="$t('JASMINE.WUZAPI.UPDATE_WEBHOOK')"
@click="updateWebhook"
/>
</div>
<div
v-if="webhookInfo"
class="bg-n-alpha-1 p-4 rounded text-sm font-mono overflow-auto"
>
<pre>{{ JSON.stringify(webhookInfo, null, 2) }}</pre>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,86 @@
<script>
import { useVuelidate } from '@vuelidate/core';
import { useAlert } from 'dashboard/composables';
import SettingsSection from 'dashboard/components/SettingsSection.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
SettingsSection,
NextButton,
},
props: {
inbox: {
type: Object,
required: true,
},
},
setup() {
return { v$: useVuelidate() };
},
data() {
return {
autoResolveDuration: null,
isUpdating: false,
};
},
mounted() {
this.autoResolveDuration = this.inbox.auto_resolve_duration;
},
methods: {
async updateInbox() {
try {
this.isUpdating = true;
await this.$store.dispatch('inboxes/updateInbox', {
id: this.inbox.id,
auto_resolve_duration: this.autoResolveDuration,
});
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
} finally {
this.isUpdating = false;
}
},
},
};
</script>
<template>
<div class="mx-8">
<SettingsSection
:title="$t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.TITLE')"
:sub-title="$t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.NOTE')"
:show-border="false"
>
<div class="flex flex-col gap-1 items-start mb-4">
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ $t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.LABEL') }}
</label>
<div class="w-full">
<input
v-model="autoResolveDuration"
type="number"
class="input-group"
min="0"
:placeholder="
$t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.PLACEHOLDER')
"
/>
<p class="text-sm text-n-slate-11 mt-1">
{{ $t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.DESCRIPTION') }}
</p>
</div>
</div>
<div class="flex gap-2">
<NextButton
type="submit"
:is-loading="isUpdating"
:label="$t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.UPDATE_BUTTON')"
@click="updateInbox"
/>
</div>
</SettingsSection>
</div>
</template>

View File

@ -0,0 +1,363 @@
<script>
import JasmineAPI from 'dashboard/api/inbox/jasmine';
import { useAlert } from 'dashboard/composables';
export default {
props: {
inboxId: {
type: [String, Number],
required: true,
},
isTab: {
type: Boolean,
default: false,
},
},
data() {
return {
collections: [],
isLoading: false,
showCreateCollectionModal: false,
newCollectionName: '',
newCollectionVisibility: 'private',
expandedCollectionId: null,
documents: [],
isLoadingDocs: false,
newDocTitle: '',
newDocContent: '',
isCreatingDoc: false,
isDeletingDocument: null, // Track which doc is being deleted
};
},
mounted() {
this.fetchCollections();
},
methods: {
async fetchCollections() {
this.isLoading = true;
try {
const { data } = await JasmineAPI.getCollections();
this.collections = data;
} catch (error) {
useAlert('Failed to load collections');
} finally {
this.isLoading = false;
}
},
async createCollection() {
try {
await JasmineAPI.createCollection({
collection: {
name: this.newCollectionName,
visibility: this.newCollectionVisibility,
owner_inbox_id: this.inboxId,
},
});
this.newCollectionName = '';
this.showCreateCollectionModal = false;
this.fetchCollections();
useAlert('Collection created successfully');
} catch (error) {
useAlert('Failed to create collection');
}
},
async toggleCollection(collection) {
if (this.expandedCollectionId === collection.id) {
this.expandedCollectionId = null;
this.documents = [];
return;
}
this.expandedCollectionId = collection.id;
this.fetchDocuments(collection.id);
},
async fetchDocuments(collectionId) {
this.isLoadingDocs = true;
try {
const { data } = await JasmineAPI.getDocuments(collectionId);
this.documents = data;
} catch (error) {
useAlert('Failed to load documents');
} finally {
this.isLoadingDocs = false;
}
},
async addDocument(collectionId) {
this.isCreatingDoc = true;
try {
await JasmineAPI.uploadDocument(
collectionId,
this.newDocContent,
this.newDocTitle
);
this.newDocTitle = '';
this.newDocContent = '';
useAlert('Document added! Processing will start shortly.');
// Refresh docs to show new document
this.fetchDocuments(collectionId);
} catch (error) {
useAlert('Failed to add document');
} finally {
this.isCreatingDoc = false;
}
},
async deleteDocument(collectionId, documentId) {
// eslint-disable-next-line no-alert
if (!window.confirm(this.$t('JASMINE.KNOWLEDGE_BASE.DELETE_CONFIRM')))
return;
this.isDeletingDocument = documentId;
try {
await JasmineAPI.deleteDocument(collectionId, documentId);
useAlert(this.$t('JASMINE.KNOWLEDGE_BASE.DOCUMENT_DELETE_SUCCESS'));
this.fetchDocuments(collectionId);
} catch (error) {
useAlert('Failed to delete document');
} finally {
this.isDeletingDocument = null;
}
},
getStatusClass(status) {
const classes = {
pending:
'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
processing:
'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
indexed:
'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
failed: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
};
return classes[status] || classes.pending;
},
isProcessing(status) {
return status === 'pending' || status === 'processing';
},
},
};
</script>
<template>
<div
:class="{
'mt-8 border-t border-slate-100 dark:border-slate-800 pt-8': !isTab,
'': isTab,
}"
>
<!-- Header -->
<div class="flex justify-between items-center mb-6">
<div>
<h3 class="text-lg font-semibold text-slate-900 dark:text-slate-100">
{{ $t('JASMINE.KNOWLEDGE_BASE.TITLE') }}
</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">
{{ $t('JASMINE.KNOWLEDGE_BASE.DESCRIPTION') }}
</p>
</div>
<woot-button size="small" @click="showCreateCollectionModal = true">
{{ $t('JASMINE.KNOWLEDGE_BASE.ADD_BUTTON') }}
</woot-button>
</div>
<!-- Loading -->
<div v-if="isLoading" class="flex items-center justify-center py-12">
<span class="i-lucide-loader-2 size-6 animate-spin text-slate-400" />
</div>
<!-- Collections List -->
<div v-else class="space-y-4">
<div
v-for="collection in collections"
:key="collection.id"
class="border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden"
>
<!-- Collection Header -->
<div
class="flex items-center justify-between p-4 bg-white dark:bg-slate-900 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/50"
@click="toggleCollection(collection)"
>
<div class="flex items-center gap-3">
<span
class="i-lucide-chevron-right size-4 transition-transform text-slate-400"
:class="[
expandedCollectionId === collection.id ? 'rotate-90' : '',
]"
/>
<div>
<h4 class="font-medium text-slate-800 dark:text-slate-200">
{{ collection.name }}
</h4>
<span
class="text-xs uppercase tracking-wide px-1.5 py-0.5 rounded bg-slate-100 dark:bg-slate-800 text-slate-500"
>
{{ collection.visibility }}
</span>
</div>
</div>
</div>
<!-- Expanded: Documents -->
<div
v-if="expandedCollectionId === collection.id"
class="border-t border-slate-100 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/30 p-4"
>
<h5 class="text-xs font-semibold uppercase text-slate-500 mb-3">
{{ $t('JASMINE.KNOWLEDGE_BASE.DOCUMENTS') }}
</h5>
<!-- Loading Documents -->
<div
v-if="isLoadingDocs"
class="flex items-center gap-2 text-sm text-slate-400 py-2"
>
<span class="i-lucide-loader-2 size-4 animate-spin" />
{{ $t('JASMINE.KNOWLEDGE_BASE.LOADING_DOCS') }}
</div>
<!-- Documents List -->
<div v-else class="space-y-2 mb-4">
<div
v-for="doc in documents"
:key="doc.id"
class="flex items-center justify-between p-3 bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-700"
>
<div class="flex items-center gap-3 min-w-0 flex-1">
<span
class="i-lucide-file-text size-4 text-slate-400 shrink-0"
/>
<div class="min-w-0">
<p
class="font-medium text-sm text-slate-800 dark:text-slate-200 truncate"
>
{{ doc.title || $t('JASMINE.KNOWLEDGE_BASE.UNTITLED_DOC') }}
</p>
<p class="text-xs text-slate-400 truncate">
{{ new Date(doc.created_at).toLocaleDateString() }}
</p>
</div>
</div>
<div class="flex items-center gap-3 shrink-0">
<!-- Status Badge -->
<span
class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full"
:class="[getStatusClass(doc.status)]"
>
<span
v-if="isProcessing(doc.status)"
class="i-lucide-loader-2 size-3 animate-spin"
/>
{{ doc.status || 'pending' }}
</span>
<!-- Delete Button -->
<button
class="p-1.5 rounded hover:bg-red-50 dark:hover:bg-red-900/20 text-slate-400 hover:text-red-500 transition-colors"
:disabled="isDeletingDocument === doc.id"
@click.stop="deleteDocument(collection.id, doc.id)"
>
<span
v-if="isDeletingDocument === doc.id"
class="i-lucide-loader-2 size-4 animate-spin"
/>
<span v-else class="i-lucide-trash-2 size-4" />
</button>
</div>
</div>
<div
v-if="documents.length === 0"
class="text-center py-6 text-sm text-slate-400"
>
{{ $t('JASMINE.KNOWLEDGE_BASE.NO_DOCS') }}
</div>
</div>
<!-- Add Document Form -->
<div
class="border-t border-slate-200 dark:border-slate-700 pt-4 mt-4"
>
<h6 class="text-xs font-semibold uppercase text-slate-500 mb-3">
{{ $t('JASMINE.KNOWLEDGE_BASE.ADD_DOC_HEADER') }}
</h6>
<input
v-model="newDocTitle"
type="text"
class="w-full mb-2 px-3 py-2 text-sm rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900"
:placeholder="$t('JASMINE.KNOWLEDGE_BASE.DOC_TITLE_PLACEHOLDER')"
/>
<textarea
v-model="newDocContent"
rows="4"
class="w-full mb-3 px-3 py-2 text-sm rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 resize-none"
:placeholder="
$t('JASMINE.KNOWLEDGE_BASE.DOC_CONTENT_PLACEHOLDER')
"
/>
<div class="flex justify-end">
<woot-button
size="small"
:is-loading="isCreatingDoc"
:disabled="!newDocContent.trim()"
@click="addDocument(collection.id)"
>
{{ $t('JASMINE.KNOWLEDGE_BASE.ADD_DOC_BUTTON') }}
</woot-button>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div
v-if="collections.length === 0"
class="text-center py-12 text-slate-400"
>
<span class="i-lucide-folder-open size-12 mx-auto mb-3 opacity-50" />
<p class="text-sm">{{ $t('JASMINE.KNOWLEDGE_BASE.NO_COLLECTIONS') }}</p>
</div>
</div>
<!-- Create Collection Modal -->
<div
v-if="showCreateCollectionModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
@click.self="showCreateCollectionModal = false"
>
<div class="bg-white dark:bg-slate-900 p-6 rounded-xl w-96 shadow-2xl">
<h3 class="text-lg font-semibold mb-4 text-slate-900 dark:text-white">
{{ $t('JASMINE.KNOWLEDGE_BASE.CREATE_MODAL.TITLE') }}
</h3>
<input
v-model="newCollectionName"
type="text"
class="w-full mb-4 px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800"
:placeholder="
$t('JASMINE.KNOWLEDGE_BASE.CREATE_MODAL.NAME_PLACEHOLDER')
"
@keyup.enter="createCollection"
/>
<select
v-model="newCollectionVisibility"
class="w-full mb-4 px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800"
>
<option value="private">
{{ $t('JASMINE.KNOWLEDGE_BASE.CREATE_MODAL.VISIBILITY_PRIVATE') }}
</option>
<option value="shared">
{{ $t('JASMINE.KNOWLEDGE_BASE.CREATE_MODAL.VISIBILITY_SHARED') }}
</option>
</select>
<div class="flex justify-end gap-2">
<woot-button
variant="clear"
@click="showCreateCollectionModal = false"
>
{{ $t('JASMINE.KNOWLEDGE_BASE.CREATE_MODAL.CANCEL') }}
</woot-button>
<woot-button
:disabled="!newCollectionName.trim()"
@click="createCollection"
>
{{ $t('JASMINE.KNOWLEDGE_BASE.CREATE_MODAL.CREATE') }}
</woot-button>
</div>
</div>
</div>
</div>
</template>

View File

@ -1,321 +1,122 @@
<script setup>
import { ref, computed, watch, onMounted } from 'vue';
import { useStore } from 'vuex';
import { useRoute, useRouter } from 'vue-router';
import { vOnClickOutside } from '@vueuse/components';
<script>
import { mapGetters } from 'vuex';
import { useVuelidate } from '@vuelidate/core';
import { minValue } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import { useConfig } from 'dashboard/composables/useConfig';
import SettingsSection from '../../../../../components/SettingsSection.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Switch from 'dashboard/components-next/switch/Switch.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import assignmentPoliciesAPI from 'dashboard/api/assignmentPolicies';
import { useI18n } from 'vue-i18n';
const props = defineProps({
export default {
components: {
SettingsSection,
NextButton,
},
props: {
inbox: {
type: Object,
default: () => ({}),
},
});
const store = useStore();
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const { isEnterprise } = useConfig();
const selectedAgents = ref([]);
const isAgentListUpdating = ref(false);
const enableAutoAssignment = ref(false);
const maxAssignmentLimit = ref(null);
const assignmentPolicy = ref(null);
const isLoadingPolicy = ref(false);
const isDeletingPolicy = ref(false);
const showDeleteConfirmModal = ref(false);
const availablePolicies = ref([]);
const isLoadingPolicies = ref(false);
const showPolicyDropdown = ref(false);
const isLinkingPolicy = ref(false);
const agentList = computed(() => store.getters['agents/getAgents']);
const isFeatureEnabled = feature => {
const accountId = Number(route.params.accountId);
return store.getters['accounts/isFeatureEnabledonAccount'](
accountId,
feature
);
};
const hasAdvancedAssignment = computed(() => {
return isFeatureEnabled('advanced_assignment');
});
const hasAssignmentV2 = computed(() => {
return isFeatureEnabled('assignment_v2');
});
const showAdvancedAssignmentUI = computed(() => {
return hasAdvancedAssignment.value && hasAssignmentV2.value;
});
const assignmentOrderLabel = computed(() => {
if (!assignmentPolicy.value) return '';
const priority = assignmentPolicy.value.conversation_priority;
if (priority === 'earliest_created') {
return t('INBOX_MGMT.ASSIGNMENT.PRIORITY.EARLIEST_CREATED');
}
if (priority === 'longest_waiting') {
return t('INBOX_MGMT.ASSIGNMENT.PRIORITY.LONGEST_WAITING');
}
return priority;
});
const assignmentMethodLabel = computed(() => {
if (!assignmentPolicy.value) return '';
const order = assignmentPolicy.value.assignment_order;
if (order === 'round_robin') {
return t('INBOX_MGMT.ASSIGNMENT.METHOD.ROUND_ROBIN');
}
if (order === 'balanced') {
return t('INBOX_MGMT.ASSIGNMENT.METHOD.BALANCED');
}
return order;
});
// Vuelidate validation rules
const rules = {
maxAssignmentLimit: {
minValue: minValue(1),
},
};
setup() {
const { isEnterprise } = useConfig();
const v$ = useVuelidate(rules, { maxAssignmentLimit });
const maxAssignmentLimitErrors = computed(() => {
if (v$.value.maxAssignmentLimit.$error) {
return t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_RANGE_ERROR');
return { v$: useVuelidate(), isEnterprise };
},
data() {
return {
selectedAgents: [],
isAgentListUpdating: false,
enableAutoAssignment: false,
maxAssignmentLimit: null,
};
},
computed: {
...mapGetters({
agentList: 'agents/getAgents',
}),
maxAssignmentLimitErrors() {
if (this.v$.maxAssignmentLimit.$error) {
return this.$t(
'INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_RANGE_ERROR'
);
}
return '';
});
const fetchAttachedAgents = async () => {
},
},
watch: {
inbox() {
this.setDefaults();
},
},
mounted() {
this.setDefaults();
},
methods: {
setDefaults() {
this.enableAutoAssignment = this.inbox.enable_auto_assignment;
this.maxAssignmentLimit =
this.inbox?.auto_assignment_config?.max_assignment_limit || null;
this.fetchAttachedAgents();
},
async fetchAttachedAgents() {
try {
const response = await store.dispatch('inboxMembers/get', {
inboxId: props.inbox.id,
const response = await this.$store.dispatch('inboxMembers/get', {
inboxId: this.inbox.id,
});
const {
data: { payload: inboxMembers },
} = response;
selectedAgents.value = inboxMembers;
this.selectedAgents = inboxMembers;
} catch (error) {
// Handle error
}
};
const fetchAssignmentPolicy = async () => {
if (!props.inbox.id) return;
isLoadingPolicy.value = true;
},
handleEnableAutoAssignment() {
this.updateInbox();
},
async updateAgents() {
const agentList = this.selectedAgents.map(el => el.id);
this.isAgentListUpdating = true;
try {
const response = await assignmentPoliciesAPI.getInboxPolicy(props.inbox.id);
assignmentPolicy.value = response.data;
} catch (error) {
// No policy attached, which is fine
assignmentPolicy.value = null;
} finally {
isLoadingPolicy.value = false;
}
};
const fetchAvailablePolicies = async () => {
isLoadingPolicies.value = true;
try {
const response = await assignmentPoliciesAPI.get();
availablePolicies.value = response.data;
} catch (error) {
availablePolicies.value = [];
} finally {
isLoadingPolicies.value = false;
}
};
const linkPolicyToInbox = async policy => {
isLinkingPolicy.value = true;
try {
await assignmentPoliciesAPI.setInboxPolicy(props.inbox.id, policy.id);
assignmentPolicy.value = policy;
showPolicyDropdown.value = false;
useAlert(t('INBOX_MGMT.ASSIGNMENT.LINK_SUCCESS'));
} catch (error) {
useAlert(t('INBOX_MGMT.ASSIGNMENT.LINK_ERROR'));
} finally {
isLinkingPolicy.value = false;
}
};
const navigateToAssignmentPolicies = () => {
const accountId = route.params.accountId;
router.push({
name: 'agent_assignment_policy_index',
params: { accountId },
await this.$store.dispatch('inboxMembers/create', {
inboxId: this.inbox.id,
agentList,
});
};
const policyMenuItems = computed(() => {
const items = availablePolicies.value.map(policy => ({
action: 'select_policy',
value: policy.id,
label: policy.name,
icon: 'i-lucide-zap',
policy,
}));
items.push({
action: 'view_all',
value: 'view_all',
label: t('INBOX_MGMT.ASSIGNMENT.VIEW_ALL_POLICIES'),
icon: 'i-lucide-arrow-right',
});
return items;
});
const handlePolicyMenuAction = ({ action, policy }) => {
if (action === 'select_policy' && policy) {
linkPolicyToInbox(policy);
} else if (action === 'view_all') {
navigateToAssignmentPolicies();
useAlert(this.$t('AGENT_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('AGENT_MGMT.EDIT.API.ERROR_MESSAGE'));
}
showPolicyDropdown.value = false;
};
const togglePolicyDropdown = () => {
if (!showPolicyDropdown.value && availablePolicies.value.length === 0) {
fetchAvailablePolicies();
}
showPolicyDropdown.value = !showPolicyDropdown.value;
};
const closePolicyDropdown = () => {
showPolicyDropdown.value = false;
};
const handleToggleAutoAssignment = async () => {
this.isAgentListUpdating = false;
},
async updateInbox() {
try {
const payload = {
id: props.inbox.id,
id: this.inbox.id,
formData: false,
enable_auto_assignment: enableAutoAssignment.value,
};
await store.dispatch('inboxes/updateInbox', payload);
useAlert(t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
}
};
const updateAgents = async () => {
const agentListIds = selectedAgents.value.map(el => el.id);
isAgentListUpdating.value = true;
try {
await store.dispatch('inboxMembers/create', {
inboxId: props.inbox.id,
agentList: agentListIds,
});
useAlert(t('AGENT_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(t('AGENT_MGMT.EDIT.API.ERROR_MESSAGE'));
}
isAgentListUpdating.value = false;
};
const updateInbox = async () => {
try {
const payload = {
id: props.inbox.id,
formData: false,
enable_auto_assignment: enableAutoAssignment.value,
enable_auto_assignment: this.enableAutoAssignment,
auto_assignment_config: {
max_assignment_limit: maxAssignmentLimit.value,
max_assignment_limit: this.maxAssignmentLimit,
},
};
await store.dispatch('inboxes/updateInbox', payload);
useAlert(t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
await this.$store.dispatch('inboxes/updateInbox', payload);
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
}
},
},
validations: {
selectedAgents: {
isEmpty() {
return !!this.selectedAgents.length;
},
},
maxAssignmentLimit: {
minValue: minValue(1),
},
},
};
const navigateToCreatePolicy = () => {
const accountId = route.params.accountId;
router.push({
name: 'agent_assignment_policy_create',
params: { accountId },
query: { inboxId: props.inbox.id },
});
};
const navigateToAssignmentPolicyEdit = () => {
if (!assignmentPolicy.value?.id) return;
const accountId = route.params.accountId;
router.push({
name: 'agent_assignment_policy_edit',
params: { accountId, id: assignmentPolicy.value.id },
});
};
const navigateToBilling = () => {
const accountId = route.params.accountId;
router.push({
name: 'billing_settings_index',
params: { accountId },
});
};
const confirmDeletePolicy = () => {
showDeleteConfirmModal.value = true;
};
const cancelDeletePolicy = () => {
showDeleteConfirmModal.value = false;
};
const deleteAssignmentPolicy = async () => {
if (isDeletingPolicy.value) return;
isDeletingPolicy.value = true;
try {
await assignmentPoliciesAPI.removeInboxPolicy(props.inbox.id);
assignmentPolicy.value = null;
showDeleteConfirmModal.value = false;
useAlert(t('INBOX_MGMT.ASSIGNMENT_POLICY.DELETE_SUCCESS'));
} catch (error) {
useAlert(t('INBOX_MGMT.ASSIGNMENT_POLICY.DELETE_ERROR'));
} finally {
isDeletingPolicy.value = false;
}
};
const setDefaults = () => {
enableAutoAssignment.value = props.inbox.enable_auto_assignment;
maxAssignmentLimit.value =
props.inbox.auto_assignment_config?.max_assignment_limit || null;
fetchAttachedAgents();
if (showAdvancedAssignmentUI.value) {
fetchAssignmentPolicy();
fetchAvailablePolicies();
}
};
// Watch only inbox.id to avoid unnecessary refetches when other properties change
watch(() => props.inbox.id, setDefaults);
onMounted(() => {
setDefaults();
});
</script>
<template>
@ -337,6 +138,7 @@ onMounted(() => {
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
@select="v$.selectedAgents.$touch"
/>
<NextButton
@ -350,265 +152,13 @@ onMounted(() => {
:title="$t('INBOX_MGMT.SETTINGS_POPUP.AGENT_ASSIGNMENT')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.AGENT_ASSIGNMENT_SUB_TEXT')"
>
<!-- New UI for assignment_v2 -->
<template v-if="hasAssignmentV2">
<div class="flex items-start gap-3">
<Switch
v-model="enableAutoAssignment"
class="flex-shrink-0 mt-0.5"
@change="handleToggleAutoAssignment"
/>
<div class="flex-grow">
<label class="text-sm text-n-slate-12 font-medium mb-1">
{{ $t('INBOX_MGMT.ASSIGNMENT.ENABLE_AUTO_ASSIGNMENT') }}
</label>
<p class="text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.ASSIGNMENT.DESCRIPTION') }}
</p>
</div>
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
>
<div v-if="enableAutoAssignment" class="mt-6">
<!-- Policy Card - When policy is attached -->
<div
v-if="showAdvancedAssignmentUI && assignmentPolicy"
class="p-4 rounded-xl outline-1 outline-n-weak outline bg-n-solid-1 dark:bg-n-slate-1"
>
<div class="flex items-start gap-4">
<div
class="flex-shrink-0 size-12 rounded-xl bg-n-slate-3 flex items-center justify-center"
>
<span class="i-lucide-zap text-xl text-n-slate-11" />
</div>
<div class="flex-grow">
<div class="flex items-start justify-between gap-4 mb-4">
<div class="flex flex-col items-start">
<span class="text-base font-medium text-n-slate-12 mb-1">
{{ assignmentPolicy.name }}
</span>
<p class="text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.ASSIGNMENT.POLICY_LABEL') }}
</p>
</div>
<NextButton
icon="i-lucide-trash-2"
ghost
ruby
sm
@click="confirmDeletePolicy"
/>
</div>
<ul class="space-y-2 mb-6">
<li class="flex items-center gap-2">
<span
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
/>
<span class="text-sm text-n-slate-12">
{{ assignmentOrderLabel }}
</span>
</li>
<li class="flex items-center gap-2">
<span
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
/>
<span class="text-sm text-n-slate-12">
{{ assignmentMethodLabel }}
</span>
</li>
</ul>
<div class="w-full h-px my-4 bg-n-weak" />
<NextButton
:label="$t('INBOX_MGMT.ASSIGNMENT.CUSTOMIZE_POLICY')"
icon="i-lucide-arrow-right"
trailing-icon
link
class="mb-2"
@click="navigateToAssignmentPolicyEdit"
/>
</div>
</div>
</div>
<!-- Default Policy - When no custom policy attached but feature enabled -->
<div
v-else-if="
showAdvancedAssignmentUI &&
!assignmentPolicy &&
!isLoadingPolicy
"
class="rounded-xl outline-1 outline-n-weak outline"
>
<!-- Default Policy Header -->
<div class="p-4">
<div class="flex items-start gap-4">
<div
class="flex-shrink-0 w-12 h-12 rounded-xl bg-n-slate-3 dark:bg-n-slate-4 flex items-center justify-center"
>
<i class="i-lucide-zap text-xl text-n-slate-11" />
</div>
<div class="flex-grow">
<h4 class="text-base font-medium text-n-slate-12 mb-1">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_POLICY_LINKED') }}
</h4>
<p class="text-sm text-n-slate-11">
{{
$t('INBOX_MGMT.ASSIGNMENT.DEFAULT_POLICY_DESCRIPTION')
}}
</p>
</div>
</div>
<!-- Action Buttons -->
<div class="mt-5 flex items-center gap-3">
<div
v-if="!isLoadingPolicies && availablePolicies.length > 0"
v-on-click-outside="closePolicyDropdown"
class="relative"
>
<button
type="button"
class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-n-brand hover:bg-n-brand/90 rounded-lg transition-colors"
@click="togglePolicyDropdown"
>
<i class="i-lucide-link text-sm" />
{{ $t('INBOX_MGMT.ASSIGNMENT.LINK_EXISTING_POLICY') }}
<i
class="i-lucide-chevron-down text-sm transition-transform"
:class="{ 'rotate-180': showPolicyDropdown }"
/>
</button>
<DropdownMenu
v-if="showPolicyDropdown"
class="top-full left-0 mt-2 min-w-72"
:menu-items="policyMenuItems"
:is-searching="isLoadingPolicies"
@action="handlePolicyMenuAction"
/>
</div>
<button
type="button"
class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-n-slate-12 bg-n-slate-3 dark:bg-n-slate-4 hover:bg-n-slate-4 dark:hover:bg-n-slate-5 rounded-lg transition-colors"
@click="navigateToCreatePolicy"
>
<i class="i-lucide-plus text-sm" />
{{ $t('INBOX_MGMT.ASSIGNMENT.CREATE_NEW_POLICY') }}
</button>
</div>
</div>
<!-- Default Rules Info -->
<div class="px-4 py-4 border-t border-n-weak bg-n-slate-2">
<div class="flex items-start gap-3">
<i class="i-lucide-info text-base text-n-slate-10 mt-0.5" />
<div>
<p class="text-sm text-n-slate-11 mb-2">
{{ $t('INBOX_MGMT.ASSIGNMENT.CURRENT_BEHAVIOR') }}
</p>
<ul class="space-y-1">
<li class="flex items-center gap-2">
<span
class="w-1 h-1 rounded-full bg-n-slate-10 flex-shrink-0"
/>
<span class="text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_1') }}
</span>
</li>
<li class="flex items-center gap-2">
<span
class="w-1 h-1 rounded-full bg-n-slate-10 flex-shrink-0"
/>
<span class="text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_2') }}
</span>
</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Default Rules Card - Feature not enabled (no advanced_assignment) -->
<div
v-else-if="!showAdvancedAssignmentUI"
class="p-4 rounded-xl outline outline-1 outline-n-weak -outline-offset-1"
>
<div class="flex items-start gap-4">
<div
class="flex-shrink-0 w-12 h-12 rounded-xl bg-n-slate-3 dark:bg-n-slate-4 flex items-center justify-center"
>
<i class="i-lucide-zap text-xl text-n-slate-11" />
</div>
<div class="flex-grow">
<h4 class="text-base font-medium text-n-slate-12 mb-1">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULES_TITLE') }}
</h4>
<p class="text-sm text-n-slate-11 mb-4">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULES_DESCRIPTION') }}
</p>
<ul class="space-y-2 mb-6">
<li class="flex items-center gap-2">
<span
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
/>
<span class="text-sm font-medium text-n-slate-12">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_1') }}
</span>
</li>
<li class="flex items-center gap-2">
<span
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
/>
<span class="text-sm font-medium text-n-slate-12">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_2') }}
</span>
</li>
</ul>
<div class="w-full h-px bg-n-weak my-4" />
<!-- Upgrade prompt when advanced_assignment is not enabled -->
<div v-if="!hasAdvancedAssignment">
<p class="text-sm text-n-slate-11 mb-1">
{{ $t('INBOX_MGMT.ASSIGNMENT.UPGRADE_PROMPT') }}
</p>
<NextButton
:label="$t('INBOX_MGMT.ASSIGNMENT.UPGRADE_TO_BUSINESS')"
icon="i-lucide-arrow-right"
trailing-icon
link
@click="navigateToBilling"
/>
</div>
</div>
</div>
</div>
</div>
</Transition>
</template>
<!-- Old UI for non-assignment_v2 -->
<template v-else>
<label class="w-3/4 settings-item">
<div class="flex items-center gap-2">
<input
id="enableAutoAssignment"
v-model="enableAutoAssignment"
type="checkbox"
@change="handleToggleAutoAssignment"
@change="handleEnableAutoAssignment"
/>
<label for="enableAutoAssignment">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT') }}
@ -640,35 +190,6 @@ onMounted(() => {
@click="updateInbox"
/>
</div>
</template>
</SettingsSection>
<woot-modal
v-if="showDeleteConfirmModal"
:show="showDeleteConfirmModal"
:on-close="cancelDeletePolicy"
>
<div class="p-6">
<h3 class="text-lg font-medium text-n-slate-12 mb-4">
{{ $t('INBOX_MGMT.ASSIGNMENT_POLICY.DELETE_CONFIRM_TITLE') }}
</h3>
<p class="text-sm text-n-slate-11 mb-6 ml-13">
{{ $t('INBOX_MGMT.ASSIGNMENT_POLICY.DELETE_CONFIRM_MESSAGE') }}
</p>
<div class="flex justify-end gap-2">
<NextButton
color="slate"
:label="$t('INBOX_MGMT.ASSIGNMENT_POLICY.CANCEL')"
@click="cancelDeletePolicy"
/>
<NextButton
color="ruby"
:label="$t('INBOX_MGMT.ASSIGNMENT_POLICY.CONFIRM_DELETE')"
:is-loading="isDeletingPolicy"
@click="deleteAssignmentPolicy"
/>
</div>
</div>
</woot-modal>
</div>
</template>

View File

@ -5,14 +5,11 @@ import SettingsSection from '../../../../../components/SettingsSection.vue';
import ImapSettings from '../ImapSettings.vue';
import SmtpSettings from '../SmtpSettings.vue';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import NextButton from 'dashboard/components-next/button/Button.vue';
import TextArea from 'next/textarea/TextArea.vue';
import WhatsappReauthorize from '../channels/whatsapp/Reauthorize.vue';
import { sanitizeAllowedDomains, isValidURL } from 'dashboard/helper/URLHelper';
import { requiredIf } from '@vuelidate/validators';
import WhatsappLinkDeviceModal from '../components/WhatsappLinkDeviceModal.vue';
import InboxName from 'dashboard/components/widgets/InboxName.vue';
import Switch from 'dashboard/components-next/switch/Switch.vue';
import { sanitizeAllowedDomains } from 'dashboard/helper/URLHelper';
export default {
components: {
@ -22,10 +19,6 @@ export default {
NextButton,
TextArea,
WhatsappReauthorize,
WhatsappLinkDeviceModal,
InboxName,
// eslint-disable-next-line vue/no-reserved-component-names
Switch,
},
mixins: [inboxMixin],
props: {
@ -45,29 +38,10 @@ export default {
isSyncingTemplates: false,
allowedDomains: '',
isUpdatingAllowedDomains: false,
baileysProviderUrl: '',
showLinkDeviceModal: false,
markAsRead: true,
zapiInstanceId: '',
zapiToken: '',
zapiClientToken: '',
zapiInstanceIdUpdate: '',
zapiTokenUpdate: '',
zapiClientTokenUpdate: '',
};
},
validations() {
return {
whatsAppInboxAPIKey: {
requiredIf: requiredIf(
!this.isAWhatsAppBaileysChannel && !this.isAWhatsAppZapiChannel
),
},
baileysProviderUrl: { isValidURL: value => !value || isValidURL(value) },
zapiInstanceIdUpdate: {},
zapiTokenUpdate: {},
zapiClientTokenUpdate: {},
};
validations: {
whatsAppInboxAPIKey: { required },
},
computed: {
isEmbeddedSignupWhatsApp() {
@ -92,11 +66,6 @@ export default {
setDefaults() {
this.hmacMandatory = this.inbox.hmac_mandatory || false;
this.allowedDomains = this.inbox.allowed_domains || '';
this.baileysProviderUrl = this.inbox.provider_config?.provider_url ?? '';
this.markAsRead = this.inbox.provider_config?.mark_as_read ?? true;
this.zapiInstanceId = this.inbox.provider_config?.instance_id ?? '';
this.zapiToken = this.inbox.provider_config?.token ?? '';
this.zapiClientToken = this.inbox.provider_config?.client_token ?? '';
},
handleHmacFlag() {
this.updateInbox();
@ -175,103 +144,6 @@ export default {
this.isSyncingTemplates = false;
}
},
async updateBaileysProviderUrl() {
try {
const payload = {
id: this.inbox.id,
formData: false,
channel: {
provider_config: {
...this.inbox.provider_config,
provider_url: this.baileysProviderUrl,
},
},
};
await this.$store.dispatch('inboxes/updateInbox', payload);
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
}
},
async updateWhatsAppMarkAsRead() {
try {
const payload = {
id: this.inbox.id,
formData: false,
channel: {
provider_config: {
...this.inbox.provider_config,
mark_as_read: this.markAsRead,
},
},
};
await this.$store.dispatch('inboxes/updateInbox', payload);
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
}
},
onOpenLinkDeviceModal() {
this.showLinkDeviceModal = true;
},
onCloseLinkDeviceModal() {
this.showLinkDeviceModal = false;
},
async updateZapiInstanceId() {
try {
const payload = {
id: this.inbox.id,
formData: false,
channel: {
provider_config: {
...this.inbox.provider_config,
instance_id: this.zapiInstanceIdUpdate,
},
},
};
await this.$store.dispatch('inboxes/updateInbox', payload);
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
}
},
async updateZapiToken() {
try {
const payload = {
id: this.inbox.id,
formData: false,
channel: {
provider_config: {
...this.inbox.provider_config,
token: this.zapiTokenUpdate,
},
},
};
await this.$store.dispatch('inboxes/updateInbox', payload);
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
}
},
async updateZapiClientToken() {
try {
const payload = {
id: this.inbox.id,
formData: false,
channel: {
provider_config: {
...this.inbox.provider_config,
client_token: this.zapiClientTokenUpdate,
},
},
};
await this.$store.dispatch('inboxes/updateInbox', payload);
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
}
},
},
};
</script>
@ -454,7 +326,7 @@ export default {
<ImapSettings :inbox="inbox" />
<SmtpSettings v-if="inbox.imap_enabled" :inbox="inbox" />
</div>
<div v-else-if="isAWhatsAppCloudChannel">
<div v-else-if="isAWhatsAppChannel && !isATwilioChannel">
<div v-if="inbox.provider_config" class="mx-8">
<!-- Embedded Signup Section -->
<template v-if="isEmbeddedSignupWhatsApp">
@ -550,285 +422,6 @@ export default {
class="hidden"
/>
</div>
<div v-else-if="isAWhatsAppBaileysChannel">
<WhatsappLinkDeviceModal
v-if="showLinkDeviceModal"
:show="showLinkDeviceModal"
:on-close="onCloseLinkDeviceModal"
:inbox="inbox"
/>
<div class="mx-8">
<SettingsSection
:title="
$t(
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANAGE_PROVIDER_CONNECTION_TITLE'
)
"
:sub-title="
$t(
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANAGE_PROVIDER_CONNECTION_SUBHEADER'
)
"
>
<div class="flex flex-col gap-2">
<InboxName
:inbox="inbox"
class="!text-lg !m-0"
with-phone-number
with-provider-connection-status
/>
<NextButton class="w-fit" @click="onOpenLinkDeviceModal">
{{
$t(
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANAGE_PROVIDER_CONNECTION_BUTTON'
)
}}
</NextButton>
</div>
</SettingsSection>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_PROVIDER_URL_TITLE')"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_PROVIDER_URL_SUBHEADER')
"
>
<div
class="flex items-center justify-between flex-1 mt-2 whatsapp-settings--content"
>
<woot-input
v-model="baileysProviderUrl"
type="text"
class="flex-1 mr-2 items-center"
:placeholder="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_PROVIDER_URL_PLACEHOLDER')
"
@keydown="v$.baileysProviderUrl.$touch"
/>
<NextButton
:disabled="
v$.baileysProviderUrl.$invalid ||
baileysProviderUrl === inbox.provider_config.provider_url
"
@click="updateBaileysProviderUrl"
>
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_BUTTON') }}
</NextButton>
</div>
<span v-if="v$.baileysProviderUrl.$error" class="text-red-400">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_PROVIDER_URL_ERROR') }}
</span>
</SettingsSection>
<template v-if="inbox.provider_config.api_key">
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_TITLE')"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_SUBHEADER')
"
>
<woot-code :script="inbox.provider_config.api_key" />
</SettingsSection>
</template>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_TITLE')"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_SUBHEADER')
"
>
<div
class="flex items-center justify-between flex-1 mt-2 whatsapp-settings--content"
>
<woot-input
v-model="whatsAppInboxAPIKey"
type="text"
class="flex-1 mr-2"
:placeholder="
$t(
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_PLACEHOLDER'
)
"
/>
<NextButton
:disabled="
v$.whatsAppInboxAPIKey.$invalid ||
(!inbox.provider_config.api_key && !whatsAppInboxAPIKey) ||
whatsAppInboxAPIKey === inbox.provider_config.api_key
"
@click="updateWhatsAppInboxAPIKey"
>
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_BUTTON') }}
</NextButton>
</div>
</SettingsSection>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MARK_AS_READ_TITLE')"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MARK_AS_READ_SUBHEADER')
"
>
<div class="flex items-center gap-2">
<Switch
id="markAsRead"
v-model="markAsRead"
@change="updateWhatsAppMarkAsRead"
/>
<label for="markAsRead">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MARK_AS_READ_LABEL') }}
</label>
</div>
</SettingsSection>
</div>
</div>
<div v-else-if="isAWhatsAppZapiChannel">
<WhatsappLinkDeviceModal
v-if="showLinkDeviceModal"
:show="showLinkDeviceModal"
:on-close="onCloseLinkDeviceModal"
:inbox="inbox"
/>
<div class="mx-8">
<SettingsSection
:title="
$t(
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANAGE_PROVIDER_CONNECTION_TITLE'
)
"
:sub-title="
$t(
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANAGE_PROVIDER_CONNECTION_SUBHEADER'
)
"
>
<div class="flex flex-col gap-2">
<InboxName
:inbox="inbox"
class="!text-lg !m-0"
with-phone-number
with-provider-connection-status
/>
<NextButton class="w-fit" @click="onOpenLinkDeviceModal">
{{
$t(
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_MANAGE_PROVIDER_CONNECTION_BUTTON'
)
}}
</NextButton>
</div>
</SettingsSection>
<template v-if="inbox.provider_config.instance_id">
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_INSTANCE_ID_TITLE')"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_INSTANCE_ID_SUBHEADER')
"
>
<woot-code :script="inbox.provider_config.instance_id" />
</SettingsSection>
</template>
<SettingsSection
:title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_INSTANCE_ID_UPDATE_TITLE')
"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_INSTANCE_ID_UPDATE_SUBHEADER')
"
>
<div
class="flex items-center justify-between flex-1 mt-2 whatsapp-settings--content"
>
<woot-input
v-model="zapiInstanceIdUpdate"
type="text"
class="flex-1 mr-2"
/>
<NextButton
:disabled="
v$.zapiInstanceIdUpdate.$invalid ||
(!inbox.provider_config.instance_id && !zapiInstanceIdUpdate) ||
zapiInstanceIdUpdate === inbox.provider_config.instance_id
"
@click="updateZapiInstanceId"
>
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_BUTTON') }}
</NextButton>
</div>
</SettingsSection>
<template v-if="inbox.provider_config.token">
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TOKEN_TITLE')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TOKEN_SUBHEADER')"
>
<woot-code :script="inbox.provider_config.token" secure />
</SettingsSection>
</template>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TOKEN_UPDATE_TITLE')"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TOKEN_UPDATE_SUBHEADER')
"
>
<div
class="flex items-center justify-between flex-1 mt-2 whatsapp-settings--content"
>
<woot-input
v-model="zapiTokenUpdate"
type="password"
class="flex-1 mr-2"
/>
<NextButton
:disabled="
v$.zapiTokenUpdate.$invalid ||
(!inbox.provider_config.token && !zapiTokenUpdate) ||
zapiTokenUpdate === inbox.provider_config.token
"
@click="updateZapiToken"
>
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_BUTTON') }}
</NextButton>
</div>
</SettingsSection>
<template v-if="inbox.provider_config.client_token">
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_CLIENT_TOKEN_TITLE')"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_CLIENT_TOKEN_SUBHEADER')
"
>
<woot-code :script="inbox.provider_config.client_token" secure />
</SettingsSection>
</template>
<SettingsSection
:title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_CLIENT_TOKEN_UPDATE_TITLE')
"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_CLIENT_TOKEN_UPDATE_SUBHEADER')
"
>
<div
class="flex items-center justify-between flex-1 mt-2 whatsapp-settings--content"
>
<woot-input
v-model="zapiClientTokenUpdate"
type="password"
class="flex-1 mr-2"
/>
<NextButton
:disabled="
v$.zapiClientTokenUpdate.$invalid ||
(!inbox.provider_config.client_token && !zapiClientTokenUpdate) ||
zapiClientTokenUpdate === inbox.provider_config.client_token
"
@click="updateZapiClientToken"
>
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_BUTTON') }}
</NextButton>
</div>
</SettingsSection>
</div>
</div>
</template>
<style lang="scss" scoped>

View File

@ -3,24 +3,15 @@ import { reactive, onMounted, ref, defineProps, watch, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useInbox } from 'dashboard/composables/useInbox';
import { CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import SectionLayout from 'dashboard/routes/dashboard/settings/account/components/SectionLayout.vue';
import CSATDisplayTypeSelector from './components/CSATDisplayTypeSelector.vue';
import CSATTemplate from 'dashboard/components-next/message/bubbles/Template/CSAT.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import FilterSelect from 'dashboard/components-next/filter/inputs/FilterSelect.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Switch from 'next/switch/Switch.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import whatsappTemplateLanguages from './whatsappTemplateLanguages.js';
import ConfirmTemplateUpdateDialog from './components/ConfirmTemplateUpdateDialog.vue';
import ExistingTemplateSelector from './components/ExistingTemplateSelector.vue';
const props = defineProps({
inbox: { type: Object, required: true },
@ -30,15 +21,6 @@ const { t } = useI18n();
const store = useStore();
const labels = useMapGetter('labels/getLabels');
const { isATwilioWhatsAppChannel, isAWhatsAppCloudChannel } = useInbox(
props.inbox?.id
);
// WhatsApp channels that require CSAT templates (Cloud and Twilio, NOT Baileys/Z-API)
const isTemplateRequiredWhatsAppChannel = computed(
() => isAWhatsAppCloudChannel.value || isATwilioWhatsAppChannel.value
);
const isUpdating = ref(false);
const selectedLabelValues = ref([]);
const currentLabel = ref('');
@ -47,38 +29,7 @@ const state = reactive({
csatSurveyEnabled: false,
displayType: 'emoji',
message: '',
templateButtonText: 'Please rate us',
surveyRuleOperator: 'contains',
templateLanguage: '',
});
const templateStatus = ref(null);
const templateLoading = ref(false);
const confirmDialog = ref(null);
const templateSelectorRef = ref(null);
const templateMode = ref('create_new');
const selectedExistingTemplateName = ref('');
const bodyVariables = ref({});
const existingTemplateBody = ref('');
const existingTemplateButtonText = ref('');
const templateModeTabs = computed(() => [
{ label: t('INBOX_MGMT.CSAT.TEMPLATE_MODE.CREATE_NEW'), key: 0 },
{ label: t('INBOX_MGMT.CSAT.TEMPLATE_MODE.USE_EXISTING'), key: 1 },
]);
const activeTemplateModeTabIndex = computed(() =>
templateMode.value === 'use_existing' ? 1 : 0
);
const onTemplateModeTabChange = tab => {
templateMode.value = tab.key === 1 ? 'use_existing' : 'create_new';
};
const originalTemplateValues = ref({
message: '',
templateButtonText: '',
templateLanguage: '',
});
const filterTypes = [
@ -100,109 +51,6 @@ const labelOptions = computed(() =>
: []
);
const languageOptions = computed(() =>
whatsappTemplateLanguages.map(({ name, id }) => ({
label: `${name} (${id})`,
value: id,
}))
);
const resolvedExistingTemplateBody = computed(() => {
if (!existingTemplateBody.value) return '';
let message = existingTemplateBody.value;
Object.entries(bodyVariables.value).forEach(([key, value]) => {
if (value) {
message = message.replaceAll(`{{${key}}}`, value);
}
});
return message;
});
const messagePreviewData = computed(() => {
if (templateMode.value === 'use_existing') {
return {
content:
resolvedExistingTemplateBody.value ||
t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER'),
};
}
return {
content: state.message || t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER'),
};
});
const previewButtonText = computed(() => {
if (templateMode.value === 'use_existing') {
return (
existingTemplateButtonText.value ||
t('INBOX_MGMT.CSAT.BUTTON_TEXT.PLACEHOLDER')
);
}
return state.templateButtonText;
});
const shouldShowTemplateStatus = computed(
() =>
templateStatus.value &&
!templateLoading.value &&
templateMode.value !== 'use_existing'
);
const isUpdateDisabled = computed(() => {
if (
templateMode.value === 'use_existing' &&
isAWhatsAppCloudChannel.value &&
state.csatSurveyEnabled &&
!selectedExistingTemplateName.value
) {
return true;
}
return false;
});
const templateApprovalStatus = computed(() => {
const statusMap = {
APPROVED: {
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.APPROVED'),
icon: 'i-lucide-circle-check',
color: 'text-n-teal-11',
},
PENDING: {
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.PENDING'),
icon: 'i-lucide-clock',
color: 'text-n-amber-11',
},
REJECTED: {
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.REJECTED'),
icon: 'i-lucide-circle-x',
color: 'text-n-ruby-10',
},
};
// Handle template not found case
if (templateStatus.value?.error === 'TEMPLATE_NOT_FOUND') {
return {
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.NOT_FOUND'),
icon: 'i-lucide-alert-triangle',
color: 'text-n-ruby-10',
};
}
// Handle existing template with status
if (templateStatus.value?.template_exists && templateStatus.value.status) {
// Convert status to uppercase for consistency with statusMap keys
const normalizedStatus = templateStatus.value.status.toUpperCase();
return statusMap[normalizedStatus] || statusMap.PENDING;
}
// Default case - no template exists
return {
text: t('INBOX_MGMT.CSAT.TEMPLATE_STATUS.DEFAULT'),
icon: 'i-lucide-stamp',
color: 'text-n-slate-11',
};
});
const initializeState = () => {
if (!props.inbox) return;
@ -215,78 +63,21 @@ const initializeState = () => {
const {
display_type: displayType = CSAT_DISPLAY_TYPES.EMOJI,
message = '',
button_text: buttonText = 'Please rate us',
language = 'en',
survey_rules: surveyRules = {},
} = csat_config;
state.displayType = displayType;
state.message = message;
state.templateButtonText = buttonText;
state.templateLanguage = language;
state.surveyRuleOperator = surveyRules.operator || 'contains';
selectedLabelValues.value = Array.isArray(surveyRules.values)
? [...surveyRules.values]
: [];
// Store original template values for change detection
if (isTemplateRequiredWhatsAppChannel.value) {
originalTemplateValues.value = {
message: state.message,
templateButtonText: state.templateButtonText,
templateLanguage: state.templateLanguage,
};
// Set template mode based on stored source
const templateSource = csat_config?.template?.source;
if (templateSource === 'user_selected') {
templateMode.value = 'use_existing';
selectedExistingTemplateName.value = csat_config.template.name || '';
bodyVariables.value = csat_config.template.body_variables || {};
existingTemplateBody.value = message;
existingTemplateButtonText.value = buttonText;
// Reset create-new fields so they don't show stale data
state.message = '';
state.templateButtonText = 'Please rate us';
} else {
templateMode.value = 'create_new';
}
}
};
const checkTemplateStatus = async () => {
if (!isTemplateRequiredWhatsAppChannel.value) return;
try {
templateLoading.value = true;
const response = await store.dispatch('inboxes/getCSATTemplateStatus', {
inboxId: props.inbox.id,
});
// Handle case where template doesn't exist
if (!response.template_exists && response.error === 'Template not found') {
templateStatus.value = {
template_exists: false,
error: 'TEMPLATE_NOT_FOUND',
};
} else {
templateStatus.value = response;
}
} catch (error) {
templateStatus.value = {
template_exists: false,
error: 'API_ERROR',
};
} finally {
templateLoading.value = false;
}
};
onMounted(() => {
initializeState();
if (!labels.value?.length) store.dispatch('labels/get');
if (isTemplateRequiredWhatsAppChannel.value) checkTemplateStatus();
});
watch(() => props.inbox, initializeState, { immediate: true });
@ -314,67 +105,6 @@ const removeLabel = label => {
}
};
// Check if template-related fields have changed
const hasTemplateChanges = () => {
if (!isTemplateRequiredWhatsAppChannel.value) return false;
const original = originalTemplateValues.value;
return (
original.message !== state.message ||
original.templateButtonText !== state.templateButtonText ||
original.templateLanguage !== state.templateLanguage
);
};
// Check if there's an existing template
const hasExistingTemplate = () => {
const { template_exists, error } = templateStatus.value || {};
return template_exists && !error;
};
// Check if we should create a template
const shouldCreateTemplate = () => {
// Create template if no existing template
if (!hasExistingTemplate()) {
return true;
}
// Create template if there are changes to template fields
return hasTemplateChanges();
};
// Build template config for saving
const buildTemplateConfig = () => {
if (!hasExistingTemplate()) {
return null;
}
const { template_name, template_id, template, status } =
templateStatus.value || {};
if (isATwilioWhatsAppChannel.value) {
// Twilio WhatsApp format - get from existing template config
const existingTemplate = props.inbox?.csat_config?.template;
return existingTemplate
? {
friendly_name: existingTemplate.friendly_name,
content_sid: existingTemplate.content_sid,
language: existingTemplate.language || state.templateLanguage,
status: existingTemplate.status || status,
}
: null;
}
// WhatsApp Cloud format
return {
name: template_name,
template_id,
language: template?.language || state.templateLanguage,
status,
};
};
const updateInbox = async attributes => {
const payload = {
id: props.inbox.id,
@ -382,194 +112,34 @@ const updateInbox = async attributes => {
...attributes,
};
await store.dispatch('inboxes/updateInbox', payload);
return store.dispatch('inboxes/updateInbox', payload);
};
const createTemplate = async () => {
if (!isTemplateRequiredWhatsAppChannel.value) return null;
const response = await store.dispatch('inboxes/createCSATTemplate', {
inboxId: props.inbox.id,
template: {
message: state.message,
button_text: state.templateButtonText,
language: state.templateLanguage,
},
});
useAlert(t('INBOX_MGMT.CSAT.TEMPLATE_CREATION.SUCCESS_MESSAGE'));
return response.template;
};
const linkTemplate = async () => {
if (!selectedExistingTemplateName.value) return null;
const response = await store.dispatch('inboxes/linkCSATTemplate', {
inboxId: props.inbox.id,
template: {
name: selectedExistingTemplateName.value,
language: state.templateLanguage,
body_variables: bodyVariables.value,
},
});
useAlert(t('INBOX_MGMT.CSAT.EXISTING_TEMPLATE.LINK_SUCCESS'));
return response.template;
};
const handleTemplateSelected = template => {
state.templateLanguage = template.language || 'en';
existingTemplateBody.value = template.body_text || '';
existingTemplateButtonText.value =
template.button_text || t('INBOX_MGMT.CSAT.BUTTON_TEXT.PLACEHOLDER');
};
const performSave = async () => {
const saveSettings = async () => {
try {
isUpdating.value = true;
let newTemplateData = null;
// For WhatsApp channels, handle template based on mode
if (isTemplateRequiredWhatsAppChannel.value && state.csatSurveyEnabled) {
if (
templateMode.value === 'use_existing' &&
isAWhatsAppCloudChannel.value
) {
// Link existing template mode require selection
if (!selectedExistingTemplateName.value) return;
// Validate all body variables are filled
if (
templateSelectorRef.value &&
!templateSelectorRef.value.validate()
) {
useAlert(t('INBOX_MGMT.CSAT.TEMPLATE_VARIABLES.VALIDATION_ERROR'));
return;
}
try {
newTemplateData = await linkTemplate();
} catch (error) {
const errorMessage =
error.response?.data?.error ||
t('INBOX_MGMT.CSAT.EXISTING_TEMPLATE.LINK_ERROR');
useAlert(errorMessage);
return;
}
} else if (shouldCreateTemplate()) {
// Create new template mode
try {
newTemplateData = await createTemplate();
} catch (error) {
const errorMessage =
error.response?.data?.error ||
t('INBOX_MGMT.CSAT.TEMPLATE_CREATION.ERROR_MESSAGE');
useAlert(errorMessage);
return;
}
}
}
const csatConfig = {
display_type: state.displayType,
message:
templateMode.value === 'use_existing'
? existingTemplateBody.value
: state.message,
button_text:
templateMode.value === 'use_existing'
? existingTemplateButtonText.value
: state.templateButtonText,
language: state.templateLanguage,
message: state.message,
survey_rules: {
operator: state.surveyRuleOperator,
values: selectedLabelValues.value,
},
};
// Use new template data if created/linked, otherwise preserve existing template information
if (newTemplateData) {
if (newTemplateData.source === 'user_selected') {
// User-selected existing template
csatConfig.template = {
name: newTemplateData.name,
template_id: newTemplateData.template_id,
language: newTemplateData.language,
status: newTemplateData.status,
source: 'user_selected',
linked_at: newTemplateData.linked_at,
body_variables: bodyVariables.value,
};
} else if (isATwilioWhatsAppChannel.value) {
// Twilio WhatsApp template format
csatConfig.template = {
friendly_name: newTemplateData.friendly_name,
content_sid: newTemplateData.content_sid,
language: newTemplateData.language,
status: newTemplateData.status,
source: 'auto_created',
created_at: new Date().toISOString(),
};
} else {
// WhatsApp Cloud template format
csatConfig.template = {
name: newTemplateData.name,
template_id: newTemplateData.template_id,
language: newTemplateData.language,
status: newTemplateData.status,
source: 'auto_created',
created_at: new Date().toISOString(),
};
}
} else {
const templateConfig = buildTemplateConfig();
if (templateConfig) {
csatConfig.template = templateConfig;
}
}
await updateInbox({
csat_survey_enabled: state.csatSurveyEnabled,
csat_config: csatConfig,
});
useAlert(t('INBOX_MGMT.CSAT.API.SUCCESS_MESSAGE'));
checkTemplateStatus();
} catch (error) {
useAlert(t('INBOX_MGMT.CSAT.API.ERROR_MESSAGE'));
} finally {
isUpdating.value = false;
}
};
const saveSettings = async () => {
// For "use existing" mode, no confirmation needed just save
if (templateMode.value === 'use_existing') {
await performSave();
return;
}
// Check if we need to show confirmation dialog for WhatsApp template changes
// This applies to both WhatsApp Cloud and Twilio WhatsApp channels
if (
isTemplateRequiredWhatsAppChannel.value &&
state.csatSurveyEnabled &&
hasExistingTemplate() &&
hasTemplateChanges()
) {
// Only show dialog if the existing template was auto-created (will be deleted)
const existingSource = props.inbox?.csat_config?.template?.source;
if (existingSource !== 'user_selected') {
confirmDialog.value?.open();
return;
}
}
await performSave();
};
const handleConfirmTemplateUpdate = async () => {
// We will delete the template before creating the template
await performSave();
};
</script>
<template>
@ -585,9 +155,7 @@ const handleConfirmTemplateUpdate = async () => {
</template>
<div class="grid gap-5">
<!-- Show display type only for non-WhatsApp channels -->
<WithLabel
v-if="!isTemplateRequiredWhatsAppChannel"
:label="$t('INBOX_MGMT.CSAT.DISPLAY_TYPE.LABEL')"
name="display_type"
>
@ -597,122 +165,7 @@ const handleConfirmTemplateUpdate = async () => {
/>
</WithLabel>
<template v-if="isTemplateRequiredWhatsAppChannel">
<!-- Template source toggle (only for WhatsApp Cloud) -->
<WithLabel
v-if="isAWhatsAppCloudChannel"
:label="$t('INBOX_MGMT.CSAT.TEMPLATE_MODE.LABEL')"
name="template_mode"
>
<TabBar
:tabs="templateModeTabs"
:initial-active-tab="activeTemplateModeTabIndex"
@tab-changed="onTemplateModeTabChange"
/>
</WithLabel>
<div
class="flex flex-col gap-4 justify-between w-full lg:flex-row lg:gap-6"
>
<div class="flex flex-col gap-3 basis-3/5">
<!-- Use existing template mode -->
<template
v-if="
templateMode === 'use_existing' && isAWhatsAppCloudChannel
"
>
<ExistingTemplateSelector
ref="templateSelectorRef"
v-model="selectedExistingTemplateName"
:inbox-id="inbox.id"
:body-variables="bodyVariables"
@template-selected="handleTemplateSelected"
@update:body-variables="bodyVariables = $event"
/>
</template>
<!-- Create new template mode -->
<template v-else>
<WithLabel
:label="$t('INBOX_MGMT.CSAT.MESSAGE.LABEL')"
name="message"
>
<Editor
v-model="state.message"
:placeholder="$t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER')"
:max-length="200"
channel-type="Context::Plain"
class="w-full"
/>
</WithLabel>
<Input
v-model="state.templateButtonText"
:label="$t('INBOX_MGMT.CSAT.BUTTON_TEXT.LABEL')"
:placeholder="$t('INBOX_MGMT.CSAT.BUTTON_TEXT.PLACEHOLDER')"
class="w-full"
/>
<WithLabel
v-if="shouldShowTemplateStatus"
:label="$t('INBOX_MGMT.CSAT.LANGUAGE.LABEL')"
name="language"
>
<ComboBox
v-model="state.templateLanguage"
:options="languageOptions"
:placeholder="$t('INBOX_MGMT.CSAT.LANGUAGE.PLACEHOLDER')"
/>
</WithLabel>
</template>
<div
v-if="shouldShowTemplateStatus"
class="flex gap-2 items-center mt-4"
>
<Icon
:icon="templateApprovalStatus.icon"
:class="templateApprovalStatus.color"
class="size-4"
/>
<span
:class="templateApprovalStatus.color"
class="text-sm font-medium"
>
{{ templateApprovalStatus.text }}
</span>
</div>
</div>
<div
class="flex flex-col flex-shrink-0 justify-start items-center p-6 mt-1 rounded-xl basis-2/5 bg-n-slate-2 outline outline-1 outline-n-weak"
>
<p
class="inline-flex items-center text-sm font-medium text-n-slate-11"
>
{{ $t('INBOX_MGMT.CSAT.MESSAGE_PREVIEW.LABEL') }}
<Icon
v-tooltip.top-end="
$t('INBOX_MGMT.CSAT.MESSAGE_PREVIEW.TOOLTIP')
"
icon="i-lucide-info"
class="flex-shrink-0 mx-1 size-4"
/>
</p>
<CSATTemplate
:message="messagePreviewData"
:button-text="previewButtonText"
class="pt-12"
/>
</div>
</div>
</template>
<!-- Non-WhatsApp channels layout -->
<template v-else>
<WithLabel
:label="$t('INBOX_MGMT.CSAT.MESSAGE.LABEL')"
name="message"
>
<WithLabel :label="$t('INBOX_MGMT.CSAT.MESSAGE.LABEL')" name="message">
<Editor
v-model="state.message"
:placeholder="$t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER')"
@ -720,7 +173,6 @@ const handleConfirmTemplateUpdate = async () => {
class="w-full"
/>
</WithLabel>
</template>
<WithLabel
:label="$t('INBOX_MGMT.CSAT.SURVEY_RULE.LABEL')"
@ -728,7 +180,7 @@ const handleConfirmTemplateUpdate = async () => {
>
<div class="mb-4">
<span
class="inline-flex flex-wrap gap-1.5 items-center text-sm text-n-slate-12"
class="inline-flex flex-wrap items-center gap-1.5 text-sm text-n-slate-12"
>
{{ $t('INBOX_MGMT.CSAT.SURVEY_RULE.DESCRIPTION_PREFIX') }}
<FilterSelect
@ -765,28 +217,17 @@ const handleConfirmTemplateUpdate = async () => {
</div>
</WithLabel>
<p class="text-sm italic text-n-slate-11">
{{
isTemplateRequiredWhatsAppChannel
? $t('INBOX_MGMT.CSAT.WHATSAPP_NOTE')
: $t('INBOX_MGMT.CSAT.NOTE')
}}
{{ $t('INBOX_MGMT.CSAT.NOTE') }}
</p>
<div>
<NextButton
type="submit"
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
:is-loading="isUpdating"
:disabled="isUpdateDisabled"
@click="saveSettings"
/>
</div>
</div>
</SectionLayout>
<!-- Template Update Confirmation Dialog -->
<ConfirmTemplateUpdateDialog
ref="confirmDialog"
@confirm="handleConfirmTemplateUpdate"
/>
</div>
</template>

View File

@ -0,0 +1,29 @@
import CaptainReservationsAPI from 'dashboard/api/captain/reservations';
import { createStore } from '../storeFactory';
import { throwErrorMessage } from 'dashboard/store/utils/api';
export default createStore({
name: 'CaptainReservation',
API: CaptainReservationsAPI,
actions: mutations => ({
fetchRevenue: async function fetchRevenue(_, params = {}) {
try {
const response = await CaptainReservationsAPI.revenue(params);
return response.data;
} catch (error) {
return throwErrorMessage(error);
}
},
fetchPix: async function fetchPix({ commit }, reservationId) {
commit(mutations.SET_UI_FLAG, { fetchingItem: true });
try {
const response = await CaptainReservationsAPI.pix(reservationId);
return response.data;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutations.SET_UI_FLAG, { fetchingItem: false });
}
},
}),
});

View File

@ -59,6 +59,9 @@ import copilotMessages from './captain/copilotMessages';
import captainScenarios from './captain/scenarios';
import captainTools from './captain/tools';
import captainCustomTools from './captain/customTools';
import captainReservations from './captain/reservations';
import captainUnits from './modules/captainUnits';
import captainGalleryItems from './modules/captainGalleryItems';
const plugins = [];
@ -123,6 +126,9 @@ export default createStore({
captainScenarios,
captainTools,
captainCustomTools,
captainReservations,
captainUnits,
captainGalleryItems,
},
plugins,
});

View File

@ -0,0 +1,85 @@
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import types from '../mutation-types';
import CaptainGalleryItemsAPI from '../../api/captain/galleryItems';
export const state = {
records: [],
uiFlags: {
isFetching: false,
isCreating: false,
isUpdating: false,
isDeleting: false,
},
};
export const getters = {
getItems: $state => $state.records,
getUIFlags: $state => $state.uiFlags,
};
export const actions = {
get: async ({ commit }, params = {}) => {
commit(types.SET_CAPTAIN_GALLERY_UI_FLAG, { isFetching: true });
try {
const response = await CaptainGalleryItemsAPI.getItems(params);
commit(types.SET_CAPTAIN_GALLERY_ITEMS, response.data);
} catch (error) {
// Ignore error
} finally {
commit(types.SET_CAPTAIN_GALLERY_UI_FLAG, { isFetching: false });
}
},
create: async ({ commit }, formData) => {
commit(types.SET_CAPTAIN_GALLERY_UI_FLAG, { isCreating: true });
try {
const response = await CaptainGalleryItemsAPI.createItem(formData);
commit(types.ADD_CAPTAIN_GALLERY_ITEM, response.data);
return response;
} finally {
commit(types.SET_CAPTAIN_GALLERY_UI_FLAG, { isCreating: false });
}
},
update: async ({ commit }, { id, formData }) => {
commit(types.SET_CAPTAIN_GALLERY_UI_FLAG, { isUpdating: true });
try {
const response = await CaptainGalleryItemsAPI.updateItem(id, formData);
commit(types.EDIT_CAPTAIN_GALLERY_ITEM, response.data);
return response;
} finally {
commit(types.SET_CAPTAIN_GALLERY_UI_FLAG, { isUpdating: false });
}
},
delete: async ({ commit }, id) => {
commit(types.SET_CAPTAIN_GALLERY_UI_FLAG, { isDeleting: true });
try {
await CaptainGalleryItemsAPI.deleteItem(id);
commit(types.DELETE_CAPTAIN_GALLERY_ITEM, id);
} finally {
commit(types.SET_CAPTAIN_GALLERY_UI_FLAG, { isDeleting: false });
}
},
};
export const mutations = {
[types.SET_CAPTAIN_GALLERY_UI_FLAG](_state, data) {
_state.uiFlags = {
..._state.uiFlags,
...data,
};
},
[types.SET_CAPTAIN_GALLERY_ITEMS]: MutationHelpers.set,
[types.ADD_CAPTAIN_GALLERY_ITEM]: MutationHelpers.create,
[types.EDIT_CAPTAIN_GALLERY_ITEM]: MutationHelpers.update,
[types.DELETE_CAPTAIN_GALLERY_ITEM]: MutationHelpers.destroy,
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@ -0,0 +1,83 @@
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import types from '../mutation-types';
import CaptainUnitsAPI from '../../api/captain/units';
export const state = {
records: [],
uiFlags: {
isFetching: false,
isCreating: false,
isUpdating: false,
isDeleting: false,
},
};
export const getters = {
getUnits: $state => $state.records,
getUIFlags: $state => $state.uiFlags,
};
export const actions = {
get: async ({ commit }) => {
commit(types.SET_CAPTAIN_UNITS_UI_FLAG, { isFetching: true });
try {
const response = await CaptainUnitsAPI.getUnits();
commit(types.SET_CAPTAIN_UNITS, response.data);
} catch (error) {
// Ignore error
} finally {
commit(types.SET_CAPTAIN_UNITS_UI_FLAG, { isFetching: false });
}
},
create: async ({ commit }, unitObj) => {
commit(types.SET_CAPTAIN_UNITS_UI_FLAG, { isCreating: true });
try {
const response = await CaptainUnitsAPI.createUnit(unitObj);
commit(types.ADD_CAPTAIN_UNIT, response.data);
} finally {
commit(types.SET_CAPTAIN_UNITS_UI_FLAG, { isCreating: false });
}
},
update: async ({ commit }, { id, ...data }) => {
commit(types.SET_CAPTAIN_UNITS_UI_FLAG, { isUpdating: true });
try {
const response = await CaptainUnitsAPI.updateUnit(id, data);
commit(types.EDIT_CAPTAIN_UNIT, response.data);
} finally {
commit(types.SET_CAPTAIN_UNITS_UI_FLAG, { isUpdating: false });
}
},
delete: async ({ commit }, id) => {
commit(types.SET_CAPTAIN_UNITS_UI_FLAG, { isDeleting: true });
try {
await CaptainUnitsAPI.deleteUnit(id);
commit(types.DELETE_CAPTAIN_UNIT, id);
} finally {
commit(types.SET_CAPTAIN_UNITS_UI_FLAG, { isDeleting: false });
}
},
};
export const mutations = {
[types.SET_CAPTAIN_UNITS_UI_FLAG](_state, data) {
_state.uiFlags = {
..._state.uiFlags,
...data,
};
},
[types.SET_CAPTAIN_UNITS]: MutationHelpers.set,
[types.ADD_CAPTAIN_UNIT]: MutationHelpers.create,
[types.EDIT_CAPTAIN_UNIT]: MutationHelpers.update,
[types.DELETE_CAPTAIN_UNIT]: MutationHelpers.destroy,
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@ -390,4 +390,18 @@ export default {
EDIT_AGENT_CAPACITY_POLICIES_INBOXES: 'EDIT_AGENT_CAPACITY_POLICIES_INBOXES',
DELETE_AGENT_CAPACITY_POLICIES_INBOXES:
'DELETE_AGENT_CAPACITY_POLICIES_INBOXES',
// Captain Units
SET_CAPTAIN_UNITS_UI_FLAG: 'SET_CAPTAIN_UNITS_UI_FLAG',
SET_CAPTAIN_UNITS: 'SET_CAPTAIN_UNITS',
ADD_CAPTAIN_UNIT: 'ADD_CAPTAIN_UNIT',
EDIT_CAPTAIN_UNIT: 'EDIT_CAPTAIN_UNIT',
DELETE_CAPTAIN_UNIT: 'DELETE_CAPTAIN_UNIT',
// Captain Gallery
SET_CAPTAIN_GALLERY_UI_FLAG: 'SET_CAPTAIN_GALLERY_UI_FLAG',
SET_CAPTAIN_GALLERY_ITEMS: 'SET_CAPTAIN_GALLERY_ITEMS',
ADD_CAPTAIN_GALLERY_ITEM: 'ADD_CAPTAIN_GALLERY_ITEM',
EDIT_CAPTAIN_GALLERY_ITEM: 'EDIT_CAPTAIN_GALLERY_ITEM',
DELETE_CAPTAIN_GALLERY_ITEM: 'DELETE_CAPTAIN_GALLERY_ITEM',
};

View File

@ -65,6 +65,8 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
Whatsapp::IncomingMessageBaileysService.new(inbox: channel.inbox, params: params).perform
when 'zapi'
Whatsapp::IncomingMessageZapiService.new(inbox: channel.inbox, params: params).perform
when 'wuzapi'
Whatsapp::IncomingMessageWuzapiService.new(inbox: channel.inbox, params: params).perform
else
Whatsapp::IncomingMessageService.new(inbox: channel.inbox, params: params).perform
end

View File

@ -96,6 +96,9 @@ class Account < ApplicationRecord
has_many :agent_bots, dependent: :destroy_async
has_many :api_channels, dependent: :destroy_async, class_name: '::Channel::Api'
has_many :articles, dependent: :destroy_async, class_name: '::Article'
has_many :captain_reservations, class_name: 'Captain::Reservation', dependent: :destroy
has_many :captain_units, class_name: 'Captain::Unit', dependent: :destroy
has_many :captain_gallery_items, class_name: 'Captain::GalleryItem', dependent: :destroy
has_many :assignment_policies, dependent: :destroy_async
has_many :automation_rules, dependent: :destroy_async
has_many :macros, dependent: :destroy_async

View File

@ -10,7 +10,7 @@
# enabled :boolean default(TRUE)
# message :text not null
# scheduled_at :datetime
# template_params :jsonb not null
# template_params :jsonb
# title :string not null
# trigger_only_during_business_hours :boolean default(FALSE)
# trigger_rules :jsonb

View File

@ -0,0 +1,69 @@
# == Schema Information
#
# Table name: captain_units
#
# id :bigint not null, primary key
# inter_account_number :string
# inter_cert_content :text
# inter_cert_path :string
# inter_client_secret :string
# inter_key_content :text
# inter_key_path :string
# inter_pix_key :string
# last_synced_at :datetime
# leader_whatsapp :string
# name :string not null
# payment_receipt_review_enabled :boolean default(FALSE), not null
# plug_play_token :string
# proactive_pix_polling_enabled :boolean default(FALSE), not null
# reservation_source_tag :string
# reservations_sync_enabled :boolean
# status :string
# suite_category_images :jsonb not null
# visible_suite_categories :jsonb not null
# webhook_configured_at :datetime
# webhook_url :string
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# captain_brand_id :bigint not null
# inbox_id :bigint
# inter_client_id :string
# plug_play_id :string
#
# Indexes
#
# index_captain_units_on_account_id (account_id)
# index_captain_units_on_captain_brand_id (captain_brand_id)
# index_captain_units_on_inbox_id (inbox_id)
#
# Foreign Keys
#
# fk_rails_... (account_id => accounts.id)
# fk_rails_... (captain_brand_id => captain_brands.id)
# fk_rails_... (inbox_id => inboxes.id)
#
class Captain::Unit < ApplicationRecord
belongs_to :account
# belongs_to :captain_brand, class_name: 'Captain::Brand', optional: true
encrypts :inter_client_secret, :inter_cert_content, :inter_key_content
validates :name, presence: true
validates :inter_pix_key, presence: true, on: :update
validates :inter_account_number, presence: true, on: :update
# Atributos resolvidos que o controller já espera ter, fallback na necessidade para arquivos (mesmo não sendo mais o padrão preferido).
def resolved_inter_cert_path
return nil if inter_cert_content.present?
inter_cert_path
end
def resolved_inter_key_path
return nil if inter_key_content.present?
inter_key_path
end
end

View File

@ -3,12 +3,18 @@
# Table name: channel_whatsapp
#
# id :bigint not null, primary key
# evolution_api_token :string
# evolution_api_token_iv :string
# message_templates :jsonb
# message_templates_last_updated :datetime
# phone_number :string not null
# provider :string default("default")
# provider_config :jsonb
# provider_connection :jsonb
# wuzapi_admin_token :string
# wuzapi_admin_token_iv :string
# wuzapi_user_token :string
# wuzapi_user_token_iv :string
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer not null
@ -16,7 +22,7 @@
# Indexes
#
# index_channel_whatsapp_on_phone_number (phone_number) UNIQUE
# index_channel_whatsapp_provider_connection (provider_connection) WHERE ((provider)::text = ANY ((ARRAY['baileys'::character varying, 'zapi'::character varying])::text[])) USING gin # rubocop:disable Layout/LineLength
# index_channel_whatsapp_provider_connection (provider_connection) WHERE ((provider)::text = ANY ((ARRAY['baileys'::character varying, 'zapi'::character varying])::text[])) USING gin
#
class Channel::Whatsapp < ApplicationRecord
@ -24,22 +30,29 @@ class Channel::Whatsapp < ApplicationRecord
include Reauthorizable
self.table_name = 'channel_whatsapp'
EDITABLE_ATTRS = [:phone_number, :provider, { provider_config: {} }].freeze
attr_accessor :inbox_name_for_provisioning
EDITABLE_ATTRS = [:phone_number, :provider, :wuzapi_user_token, :wuzapi_admin_token, :evolution_api_token, :inbox_name_for_provisioning,
{ provider_config: {} }].freeze
# default at the moment is 360dialog lets change later.
PROVIDERS = %w[default whatsapp_cloud baileys zapi].freeze
PROVIDERS = %w[default whatsapp_cloud wuzapi baileys zapi evolution].freeze
encrypts :wuzapi_user_token, :wuzapi_admin_token, :evolution_api_token
before_validation :ensure_webhook_verify_token
before_validation :move_tokens_to_encrypted_attributes
before_validation :provision_wuzapi_user, on: :create
before_validation :provision_evolution_instance, on: :create
validates :provider, inclusion: { in: PROVIDERS }
validates :phone_number, presence: true, uniqueness: true
validate :validate_provider_config
has_one :inbox, as: :channel, dependent: :destroy
after_create :sync_templates
after_create_commit :setup_webhooks
after_update_commit :setup_webhooks, if: :webhook_configuration_changed?
before_destroy :teardown_webhooks
before_destroy :disconnect_channel_provider, if: -> { provider_service.respond_to?(:disconnect_channel_provider) }
after_commit :setup_webhooks, on: :create, if: :should_auto_setup_webhooks?
def name
'Whatsapp'
@ -49,25 +62,43 @@ class Channel::Whatsapp < ApplicationRecord
case provider
when 'whatsapp_cloud'
Whatsapp::Providers::WhatsappCloudService.new(whatsapp_channel: self)
when 'wuzapi'
Whatsapp::Providers::WuzapiService.new(whatsapp_channel: self)
when 'baileys'
Whatsapp::Providers::WhatsappBaileysService.new(whatsapp_channel: self)
when 'zapi'
Whatsapp::Providers::WhatsappZapiService.new(whatsapp_channel: self)
when 'evolution'
Whatsapp::Providers::EvolutionService.new(whatsapp_channel: self)
else
Whatsapp::Providers::Whatsapp360DialogService.new(whatsapp_channel: self)
end
end
def use_internal_host?
provider == 'baileys' && ENV.fetch('BAILEYS_PROVIDER_USE_INTERNAL_HOST_URL', false)
end
def mark_message_templates_updated
# rubocop:disable Rails/SkipsModelValidations
update_column(:message_templates_last_updated, Time.zone.now)
# rubocop:enable Rails/SkipsModelValidations
end
delegate :send_message, to: :provider_service
delegate :send_reaction_message, to: :provider_service
delegate :send_template, to: :provider_service
delegate :sync_templates, to: :provider_service
delegate :media_url, to: :provider_service
delegate :api_headers, to: :provider_service
def setup_webhooks
perform_webhook_setup
rescue StandardError => e
Rails.logger.error "[WHATSAPP] Webhook setup failed: #{e.message}"
prompt_reauthorization!
end
def use_internal_host?
provider == 'baileys' && ENV.fetch('BAILEYS_PROVIDER_USE_INTERNAL_HOST_URL', false)
end
def update_provider_connection!(provider_connection)
assign_attributes(provider_connection: provider_connection)
# NOTE: Skip `validate_provider_config?` check
@ -86,9 +117,22 @@ class Channel::Whatsapp < ApplicationRecord
def toggle_typing_status(typing_status, conversation:)
return unless provider_service.respond_to?(:toggle_typing_status)
recipient_id = conversation.contact.identifier || conversation.contact.phone_number
last_message = conversation.messages.last
provider_service.toggle_typing_status(typing_status, last_message: last_message, recipient_id: recipient_id)
identifier = conversation.contact.identifier
phone_number = conversation.contact.phone_number
recipient_id = identifier || phone_number
# Debug Log
Rails.logger.info "[Typing] recipient_id=#{recipient_id.inspect} identifier=#{identifier.inspect} phone=#{phone_number.inspect}"
# Validation: Ensure recipient_id is E164 compliant (digits only, maybe +).
# If identifier is something like x@lid, we should fallback to phone_number.
# Using suggested regex: \A\+?\d{10,15}\z
unless recipient_id.to_s.gsub(/[\+\s\-\(\)]/, '').match?(/\A\d{10,15}\z/)
Rails.logger.warn "[Typing] Invalid recipient_id format (#{recipient_id}). Falling back to phone_number: #{phone_number}"
recipient_id = phone_number
end
provider_service.toggle_typing_status(typing_status, last_message: nil, recipient_id: recipient_id)
end
def update_presence(status)
@ -139,69 +183,256 @@ class Channel::Whatsapp < ApplicationRecord
provider_service.on_whatsapp(phone_number)
end
def delete_message(message, conversation:)
return unless provider_service.respond_to?(:delete_message)
recipient_id = if provider == 'zapi'
conversation.contact.phone_number.presence || conversation.contact.identifier
else
conversation.contact.identifier || conversation.contact.phone_number
end
return if recipient_id.blank?
provider_service.delete_message(recipient_id, message)
end
def edit_message(message, new_content, conversation:)
return unless provider_service.respond_to?(:edit_message)
recipient_id = conversation.contact.identifier || conversation.contact.phone_number
provider_service.edit_message(recipient_id, message, new_content)
end
delegate :setup_channel_provider, to: :provider_service
delegate :send_message, to: :provider_service
delegate :send_template, to: :provider_service
delegate :sync_templates, to: :provider_service
delegate :media_url, to: :provider_service
delegate :api_headers, to: :provider_service
def setup_webhooks
perform_webhook_setup
rescue StandardError => e
Rails.logger.error "[WHATSAPP] Webhook setup failed: #{e.message}"
prompt_reauthorization!
end
private
def webhook_configuration_changed?
return true if saved_change_to_provider? && provider.in?(%w[wuzapi evolution])
return false unless provider.in?(%w[wuzapi evolution])
if provider == 'evolution'
return saved_change_to_evolution_api_token? ||
(saved_change_to_provider_config? && provider_config['evolution_base_url'] != provider_config_before_last_save['evolution_base_url'])
end
saved_change_to_wuzapi_user_token? ||
(saved_change_to_provider_config? && provider_config['wuzapi_base_url'] != provider_config_before_last_save['wuzapi_base_url'])
end
def ensure_webhook_verify_token
provider_config['webhook_verify_token'] ||= SecureRandom.hex(16) if provider.in?(%w[whatsapp_cloud baileys])
end
def move_tokens_to_encrypted_attributes
if (provider == 'evolution') && provider_config['evolution_api_token'].present?
self.evolution_api_token = provider_config['evolution_api_token']
provider_config.delete('evolution_api_token')
end
return unless provider == 'wuzapi'
if provider_config['wuzapi_user_token'].present?
self.wuzapi_user_token = provider_config['wuzapi_user_token']
provider_config.delete('wuzapi_user_token')
end
return if provider_config['wuzapi_admin_token'].blank?
self.wuzapi_admin_token = provider_config['wuzapi_admin_token']
provider_config.delete('wuzapi_admin_token')
end
def validate_provider_config
errors.add(:provider_config, 'Invalid Credentials') unless provider_service.validate_provider_config?
end
def perform_webhook_setup
if provider == 'wuzapi'
return if inbox.blank?
base_url = provider_config['wuzapi_base_url']
# Use encrypted token
user_token = wuzapi_user_token
return if user_token.blank?
# Construct Chatwoot Webhook URL
# Using standard route: /webhooks/whatsapp/:phone_number for WuzAPI as per fix
app_url = ENV['FRONTEND_URL'].presence || 'http://localhost:3000'
webhook_url = "#{app_url}/webhooks/whatsapp/#{phone_number}"
begin
client = Wuzapi::Client.new(base_url)
client.set_webhook(user_token, webhook_url)
rescue StandardError => e
Rails.logger.error "Wuzapi Webhook Setup Failed: #{e.message}"
end
elsif provider == 'evolution'
return if inbox.blank?
base_url = provider_config['evolution_base_url']
api_token = evolution_api_token
return if api_token.blank?
app_url = ENV['FRONTEND_URL'].presence || 'http://localhost:3000'
webhook_url = "#{app_url}/webhooks/evolution/#{phone_number}"
begin
client = EvolutionApi::Client.new(base_url, api_token)
instance_name = "Chatwoot_#{phone_number}"
client.set_webhook(instance_name, webhook_url)
rescue StandardError => e
Rails.logger.error "Evolution Webhook Setup Failed: #{e.message}"
end
elsif provider_service.respond_to?(:setup_channel_provider)
provider_service.setup_channel_provider
else
# 360Dialog / Cloud logic
business_account_id = provider_config['business_account_id']
api_key = provider_config['api_key']
Whatsapp::WebhookSetupService.new(self, business_account_id, api_key).perform
end
end
def teardown_webhooks
# NOTE: Guard against double execution during destruction due to the
# `has_one :inbox, dependent: :destroy` relationship which will trigger this callback circularly
return if @webhook_teardown_initiated
@webhook_teardown_initiated = true
if provider == 'wuzapi'
teardown_wuzapi_session
elsif provider == 'evolution'
teardown_evolution_session
else
Whatsapp::WebhookTeardownService.new(self).perform
end
rescue StandardError => e
Rails.logger.error "[WHATSAPP] Failed to teardown webhooks: #{e.message}"
end
def should_auto_setup_webhooks?
# Only auto-setup webhooks for whatsapp_cloud provider with manual setup
# Embedded signup calls setup_webhooks explicitly in EmbeddedSignupService
provider == 'whatsapp_cloud' && provider_config['source'] != 'embedded_signup'
def teardown_wuzapi_session
return if provider_config['wuzapi_base_url'].blank?
client = Wuzapi::Client.new(provider_config['wuzapi_base_url'])
# 1. Try Logout (User Token)
if wuzapi_user_token.present?
begin
client.session_logout(wuzapi_user_token)
rescue StandardError => e
Rails.logger.warn "Wuzapi Logout Failed: #{e.message}"
end
# 2. Try Disconnect (User Token)
begin
client.session_disconnect(wuzapi_user_token)
rescue StandardError => e
Rails.logger.warn "Wuzapi Disconnect Failed: #{e.message}"
end
end
# 3. Last Resort: Delete User via Admin API (Global Token)
return unless wuzapi_admin_token.present? && provider_config['wuzapi_user_id'].present?
begin
client.delete_user(wuzapi_admin_token, provider_config['wuzapi_user_id'])
rescue StandardError => e
Rails.logger.warn "Wuzapi Delete User Failed: #{e.message}"
end
end
def provision_wuzapi_user
return unless provider == 'wuzapi' && provider_config['auto_create_user']
return if wuzapi_user_token.present?
base_url = provider_config['wuzapi_base_url']
# Use encrypted admin token
admin_token = wuzapi_admin_token
# Custom Name: <InboxName>_<Phone>
# Sanitize to allow only alphanumeric (Wuzapi limitations)
raw_name = inbox&.name || inbox_name_for_provisioning
sanitized_inbox_name = raw_name.to_s.gsub(/[^a-zA-Z0-9]/, '_')
prefix = (sanitized_inbox_name.presence || 'Chatwoot')
user_name = "#{prefix}_#{phone_number}"
# Helper to attempt provision
attempt_provision = lambda do |url|
service = Wuzapi::ProvisioningService.new(url, admin_token)
service.provision(user_name)
end
begin
result = attempt_provision.call(base_url)
rescue StandardError => e
Rails.logger.warn "Wuzapi Provisioning failed with URL #{base_url}: #{e.message}"
# Fallback: if url ends in /api, strip it and try again
if base_url.match?(%r{/api/?$})
fallback_url = base_url.gsub(%r{/api/?$}, '')
Rails.logger.info "Retrying Wuzapi Provisioning with fallback URL: #{fallback_url}"
begin
result = attempt_provision.call(fallback_url)
# If success, update the config to use the working URL
provider_config['wuzapi_base_url'] = fallback_url
Rails.logger.info "Wuzapi Provisioning fallback successful. Updated base_url to #{fallback_url}"
rescue StandardError => retry_e
Rails.logger.error "Wuzapi Provisioning fallback also failed: #{retry_e.message}"
errors.add(:base, "Wuzapi Provisioning Failed: #{retry_e.message}")
throw(:abort)
end
else
errors.add(:base, "Wuzapi Provisioning Failed: #{e.message}")
throw(:abort)
end
end
# Success handling
provider_config['wuzapi_user_id'] = result[:wuzapi_user_id]
self.wuzapi_user_token = result[:wuzapi_user_token]
masked_token = result[:wuzapi_user_token].to_s[-4..]
Rails.logger.info "Wuzapi User Provisioned. ID: #{result[:wuzapi_user_id]}, Token (last 4): ****#{masked_token}"
end
def teardown_evolution_session
return if provider_config['evolution_base_url'].blank?
client = EvolutionApi::Client.new(provider_config['evolution_base_url'], evolution_api_token)
instance_name = "Chatwoot_#{phone_number}"
begin
client.logout_instance(instance_name)
rescue StandardError => e
Rails.logger.warn "Evolution Logout Failed: #{e.message}"
end
begin
client.delete_instance(instance_name)
rescue StandardError => e
Rails.logger.warn "Evolution Delete Instance Failed: #{e.message}"
end
end
def provision_evolution_instance
return unless provider == 'evolution'
return if evolution_api_token.blank?
base_url = provider_config['evolution_base_url']
token = evolution_api_token
instance_name = "Chatwoot_#{phone_number}"
begin
client = EvolutionApi::Client.new(base_url, token)
# Tenta criar a instância; se já existe, não tem problema fahar, usamos a mesma ou damos fetch no token
begin
client.create_instance(instance_name)
rescue StandardError => e
Rails.logger.warn "Evolution Create Instance failed (might already exist): #{e.message}"
end
# Apply instances settings if present in provider_config
evolution_settings = provider_config['settings']
if evolution_settings.is_a?(Hash)
begin
# Set settings (Always Online)
client.set_settings(instance_name, {
'alwaysOnline' => evolution_settings['always_online'] == 'true' || evolution_settings['always_online'] == true
})
# Set instance settings (Reject Call, Read, groups, status)
client.set_instance_settings(instance_name, {
'rejectCall' => evolution_settings['reject_call'] == 'true' || evolution_settings['reject_call'] == true,
'readMessages' => evolution_settings['read_messages'] == 'true' || evolution_settings['read_messages'] == true,
'ignoreGroups' => evolution_settings['ignore_groups'] == 'true' || evolution_settings['ignore_groups'] == true,
'ignoreStatus' => evolution_settings['ignore_status'] == 'true' || evolution_settings['ignore_status'] == true
})
rescue StandardError => e
Rails.logger.warn "Evolution Apply Settings failed: #{e.message}"
end
end
# Success: Store the instance ID
provider_config['evolution_instance_id'] = instance_name
rescue StandardError => e
Rails.logger.error "Evolution Provisioning failed: #{e.message}"
errors.add(:base, "Evolution Provisioning Failed: #{e.message}")
throw(:abort)
end
end
end

View File

@ -52,7 +52,11 @@ module Featurable
end
def enabled_features
all_features.select { |_feature, enabled| enabled == true }
features = all_features.select { |_feature, enabled| enabled == true }
# Temporarily force enable captain features to fix blank page UI issue
features['captain_integration'] = true
features['captain_tasks'] = true
features
end
def disabled_features

View File

@ -3,6 +3,9 @@
# Table name: conversations
#
# id :integer not null, primary key
# active_scenario_expires_at :datetime
# active_scenario_key :string
# active_scenario_state :jsonb not null
# additional_attributes :jsonb
# agent_last_seen_at :datetime
# assignee_last_seen_at :datetime
@ -35,6 +38,7 @@
# conv_acid_inbid_stat_asgnid_idx (account_id,inbox_id,status,assignee_id)
# index_conversations_on_account_id (account_id)
# index_conversations_on_account_id_and_display_id (account_id,display_id) UNIQUE
# index_conversations_on_active_scenario_key (active_scenario_key)
# index_conversations_on_assignee_id_and_account_id (assignee_id,account_id)
# index_conversations_on_campaign_id (campaign_id)
# index_conversations_on_contact_id (contact_id)

View File

@ -3,8 +3,10 @@
# Table name: csat_survey_responses
#
# id :bigint not null, primary key
# csat_review_notes :text
# feedback_message :text
# rating :integer not null
# review_notes_updated_at :datetime
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
@ -12,6 +14,7 @@
# contact_id :bigint not null
# conversation_id :bigint not null
# message_id :bigint not null
# review_notes_updated_by_id :bigint
#
# Indexes
#
@ -20,6 +23,7 @@
# index_csat_survey_responses_on_contact_id (contact_id)
# index_csat_survey_responses_on_conversation_id (conversation_id)
# index_csat_survey_responses_on_message_id (message_id) UNIQUE
# index_csat_survey_responses_on_review_notes_updated_by_id (review_notes_updated_by_id)
#
class CsatSurveyResponse < ApplicationRecord
belongs_to :account

View File

@ -7,6 +7,7 @@
# id :integer not null, primary key
# allow_messages_after_resolved :boolean default(TRUE)
# auto_assignment_config :jsonb
# auto_resolve_duration :integer
# business_name :string
# channel_type :string
# csat_config :jsonb not null
@ -17,10 +18,12 @@
# greeting_enabled :boolean default(FALSE)
# greeting_message :string
# lock_to_single_conversation :boolean default(FALSE), not null
# message_signature_enabled :boolean
# name :string not null
# out_of_office_message :string
# sender_name_type :integer default("friendly"), not null
# timezone :string default("UTC")
# typing_delay :integer default(0)
# working_hours_enabled :boolean default(FALSE)
# created_at :datetime not null
# updated_at :datetime not null

View File

@ -18,6 +18,7 @@
# updated_at :datetime not null
# account_id :integer not null
# conversation_id :integer not null
# in_reply_to_id :integer
# inbox_id :integer not null
# sender_id :bigint
# source_id :text
@ -33,10 +34,15 @@
# index_messages_on_conversation_account_type_created (conversation_id,account_id,message_type,created_at)
# index_messages_on_conversation_id (conversation_id)
# index_messages_on_created_at (created_at)
# index_messages_on_in_reply_to_id (in_reply_to_id)
# index_messages_on_inbox_id (inbox_id)
# index_messages_on_sender_type_and_sender_id (sender_type,sender_id)
# index_messages_on_source_id (source_id)
#
# Foreign Keys
#
# fk_rails_... (in_reply_to_id => messages.id)
#
class Message < ApplicationRecord
searchkick callbacks: false if ChatwootApp.advanced_search_allowed?
@ -266,16 +272,16 @@ class Message < ApplicationRecord
# Returns message content suitable for LLM consumption
# Falls back to audio transcription or attachment placeholder when content is nil
def content_for_llm
if attachments.blank?
# Fallback to pure string if no attachments to preserve string responses format
# everywhere in the system by default
return content if content.present?
audio_transcription = attachments
.where(file_type: :audio)
.filter_map { |att| att.meta&.dig('transcribed_text') }
.join(' ')
.presence
return "[Voice Message] #{audio_transcription}" if audio_transcription.present?
return '[Attachment]'
end
'[Attachment]' if attachments.any?
# Se a mensagem tiver anexo enviaremos o payload multimodal Array gerado pelo Service
Captain::OpenAiMessageBuilderService.new(message: self).generate_content
end
private

View File

@ -17,6 +17,7 @@
#
# Indexes
#
# index_reporting_events_for_response_distribution (account_id,name,inbox_id,created_at)
# index_reporting_events_on_account_id (account_id)
# index_reporting_events_on_conversation_id (conversation_id)
# index_reporting_events_on_created_at (created_at)

View File

@ -19,7 +19,7 @@
# message_signature :text
# name :string not null
# otp_backup_codes :text
# otp_required_for_login :boolean default(FALSE)
# otp_required_for_login :boolean default(FALSE), not null
# otp_secret :string
# provider :string default("email"), not null
# pubsub_token :string

View File

@ -19,7 +19,7 @@
# message_signature :text
# name :string not null
# otp_backup_codes :text
# otp_required_for_login :boolean default(FALSE)
# otp_required_for_login :boolean default(FALSE), not null
# otp_secret :string
# provider :string default("email"), not null
# pubsub_token :string

View File

@ -0,0 +1,156 @@
require 'net/http'
require 'json'
class EvolutionApi::Client
class Error < StandardError; end
class AuthenticationError < Error; end
class ConnectionError < Error; end
attr_reader :base_url, :api_token
def initialize(base_url, api_token)
@base_url = normalize_url(base_url)
@api_token = api_token
end
def check_api
request(:get, '/instance/fetchInstances')
end
# Instance Endpoints
def create_instance(instance_name)
payload = { instanceName: instance_name, token: instance_name }
request(:post, '/instance/create', payload)
end
def get_qr_code(instance_name)
request(:get, "/instance/qr?instanceName=#{instance_name}")
end
def session_status(instance_name)
request(:get, "/instance/connectionState?instanceName=#{instance_name}")
rescue StandardError => _e
# Log error or handle retry if needed
{}
end
def logout_instance(instance_name)
request(:delete, "/instance/logout?instanceName=#{instance_name}")
end
def delete_instance(instance_name)
request(:delete, "/instance/delete?instanceName=#{instance_name}")
end
def set_instance_settings(instance_name, settings)
# Evolution API uses /settings/set/instanceName
# settings is a hash with alwaysOnline, rejectCall, etc.
request(:post, "/settings/set?instanceName=#{instance_name}", settings)
end
def set_settings(instance_name, settings)
# Duplicate p/ compatibilidade se necessário com rotas /instance/update
request(:post, "/instance/update?instanceName=#{instance_name}", settings)
end
# Webhook
def set_webhook(instance_name, webhook_url)
payload = {
webhook: {
url: webhook_url,
byEvents: false,
base64: false,
events: %w[
MESSAGES_UPSERT
MESSAGES_UPDATE
SEND_MESSAGE
CONNECTION_UPDATE
]
}
}
request(:post, "/webhook/set?instanceName=#{instance_name}", payload)
end
# Sending messages
def send_text(instance_name, phone_number, body, **options)
payload = { number: phone_number, text: body }.merge(options)
request(:post, "/send/text?instanceName=#{instance_name}", payload)
end
def send_image(instance_name, phone_number, base64_or_url, caption = nil)
payload = { number: phone_number, mediaMessage: { mediatype: 'image', media: base64_or_url, caption: caption } }
request(:post, "/send/media?instanceName=#{instance_name}", payload)
end
def send_file(instance_name, phone_number, base64_or_url, filename)
payload = { number: phone_number, mediaMessage: { mediatype: 'document', media: base64_or_url, fileName: filename } }
request(:post, "/send/media?instanceName=#{instance_name}", payload)
end
private
def normalize_url(url)
url.to_s.gsub(%r{/$}, '')
end
def auth_headers
{ 'apikey' => @api_token }
end
def request(method, path, payload = nil)
uri = URI.parse("#{base_url}#{path}")
http = Net::HTTP.new(uri.host, uri.port)
if uri.scheme == 'https'
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
end
request_obj = case method
when :get
Net::HTTP::Get.new(uri.request_uri)
when :post
Net::HTTP::Post.new(uri.request_uri)
when :put
Net::HTTP::Put.new(uri.request_uri)
when :delete
Net::HTTP::Delete.new(uri.request_uri)
end
request_obj['Content-Type'] = 'application/json'
request_obj['Accept'] = 'application/json'
auth_headers.each { |k, v| request_obj[k] = v }
request_obj.body = payload.to_json if payload
begin
response = http.request(request_obj)
handle_response(response)
rescue Errno::ECONNREFUSED, Net::OpenTimeout, Net::ReadTimeout, SocketError, OpenSSL::SSL::SSLError => e
raise ConnectionError, "Could not connect to Evolution Go: #{e.message}"
end
end
def handle_response(response)
Rails.logger.info "EVOLUTION RAW RESPONSE: status=#{response.code} body=#{response.body.to_s.truncate(1000)}"
if response.code.to_i >= 200 && response.code.to_i < 300
begin
body = JSON.parse(response.body)
# Tratamento pro QR Code (Evolution as vezes volta a string de base64 no body ou num campo)
if body['qrcode'] || body['base64'] || body['qr'] || body['image']
body['qrcode'] ||= body['base64'] || body['qr'] || body['image']
elsif body.key?('instance') && body['instance']['qr']
body['qrcode'] = body['instance']['qr']
end
return body
rescue JSON::ParserError
return { 'raw_body' => response.body }
end
elsif response.code.to_i == 401 || response.code.to_i == 403
raise AuthenticationError, "Authentication failed: #{response.code} #{response.body}"
else
raise Error, "API Error: #{response.code} #{response.body}"
end
end
end

View File

@ -0,0 +1,145 @@
class Whatsapp::DecryptionService
require 'openssl'
require 'base64'
require 'net/http'
# HKDF Info strings for different media types (WhatsApp protocol)
INFO_STRINGS = {
image: 'WhatsApp Image Keys',
video: 'WhatsApp Video Keys',
audio: 'WhatsApp Audio Keys',
document: 'WhatsApp Document Keys',
sticker: 'WhatsApp Image Keys'
}.freeze
def initialize(media_url, media_key, media_type)
@media_url = media_url
@media_key = Base64.decode64(media_key)
@media_type = media_type.to_sym
@info = INFO_STRINGS[@media_type] || INFO_STRINGS[:document]
end
def decrypt
return nil unless @media_url && @media_key
# 1. Download encrypted bytes
encrypted_bytes = download_content
return nil unless encrypted_bytes && encrypted_bytes.bytesize > 10
Rails.logger.info "WuzAPI Decrypt: Downloaded #{encrypted_bytes.bytesize} bytes"
# 2. Derive keys using HKDF SHA-256 (112 bytes total)
expanded_key = OpenSSL::KDF.hkdf(
@media_key,
salt: ''.b, # Empty binary string
info: @info,
length: 112,
hash: 'sha256'
)
# 3. Split derived key
iv = expanded_key[0...16]
cipher_key = expanded_key[16...48]
# mac_key = expanded_key[48...80] # For verification (optional)
# ref_key = expanded_key[80...112] # Not used
# 4. WhatsApp file structure: [Encrypted Content] + [MAC (10 bytes)]
# Remove the last 10 bytes (MAC)
cipher_text = encrypted_bytes[0...-10]
# 5. Try AES-256-CBC first (older WhatsApp versions)
decrypted = try_aes_cbc(cipher_key, iv, cipher_text)
# 6. If CBC fails, try CTR mode (some implementations use this)
decrypted ||= try_aes_ctr(cipher_key, iv, cipher_text)
return nil unless decrypted
# 7. Validate that we got a valid image (check magic bytes)
if valid_media?(decrypted)
Rails.logger.info 'WuzAPI Decrypt: SUCCESS - Valid media detected'
StringIO.new(decrypted)
else
Rails.logger.warn 'WuzAPI Decrypt: Decrypted but invalid media format'
nil
end
rescue StandardError => e
Rails.logger.error "WuzAPI Decrypt Error: #{e.class} - #{e.message}"
nil
end
private
def try_aes_cbc(key, iv, data)
decipher = OpenSSL::Cipher.new('AES-256-CBC')
decipher.decrypt
decipher.key = key
decipher.iv = iv
decipher.padding = 0 # WhatsApp doesn't use PKCS7 padding
decipher.update!(data) + decipher.final
rescue OpenSSL::Cipher::CipherError => e
Rails.logger.debug { "AES-CBC failed: #{e.message}" }
nil
end
def try_aes_ctr(key, iv, data)
decipher = OpenSSL::Cipher.new('AES-256-CTR')
decipher.decrypt
decipher.key = key
decipher.iv = iv
decipher.update!(data) + decipher.final
rescue OpenSSL::Cipher::CipherError => e
Rails.logger.debug { "AES-CTR failed: #{e.message}" }
nil
end
def valid_media?(data)
return false if data.nil? || data.bytesize < 4
bytes = data.bytes[0..7]
# JPEG: FF D8 FF
return true if bytes[0..2] == [0xFF, 0xD8, 0xFF]
# PNG: 89 50 4E 47
return true if bytes[0..3] == [0x89, 0x50, 0x4E, 0x47]
# WebP: RIFF....WEBP
return true if data[0..3] == 'RIFF' && data[8..11] == 'WEBP'
# MP4/MOV: ftyp
return true if data[4..7] == 'ftyp'
# MP3: ID3 or FF FB/FF FA
return true if data[0..2] == 'ID3' || bytes[0..1] == [0xFF, 0xFB] || bytes[0..1] == [0xFF, 0xFA]
# OGG: OggS
return true if data[0..3] == 'OggS'
# PDF: %PDF
return true if data[0..3] == '%PDF'
false
end
def download_content
uri = URI.parse(@media_url)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = (uri.scheme == 'https')
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
http.open_timeout = 10
http.read_timeout = 30
request = Net::HTTP::Get.new(uri.request_uri)
response = http.request(request)
response.is_a?(Net::HTTPSuccess) ? response.body.b : nil
rescue StandardError => e
Rails.logger.error "WuzAPI Decrypt Download Error: #{e.message}"
nil
end
end

View File

@ -0,0 +1,322 @@
class Whatsapp::IncomingMessageWuzapiService < Whatsapp::IncomingMessageBaseService
def perform
# 1. Parse Payload
# ----------------
# Extract all necessary data from the WuzAPI webhook payload
parser = Whatsapp::Providers::Wuzapi::PayloadParser.new(params)
Rails.logger.info "WuzapiService: Processing #{parser.message_type} from #{parser.sender_phone_number}"
# 2. Basic Validation
# -------------------
# Ignore statuses, presence updates, and errors for now
if parser.message_type == :chat_presence || parser.message_type == :error || parser.message_type == :ignore
Rails.logger.info "WuzAPI: Ignoring presence/error/ignore update (Type: #{parser.message_type})"
return
end
allowed_types = [:text, :image, :audio, :video, :document, :sticker]
unless allowed_types.include?(parser.message_type)
Rails.logger.info(
"WuzAPI: Unsupported message type: #{parser.message_type} " \
"(webhook.type=#{params[:type]}, event.Info.Type=#{params.dig(:event, :Info, :Type)}, event.Type=#{params.dig(:event, :Type)})"
)
return
end
# 2.1 V1 Scope: Ignore Groups
if parser.group_message?
Rails.logger.info "WuzAPI: Ignoring group message (ID: #{parser.external_id})"
return
end
if parser.sender_phone_number.blank? && !parser.from_me?
Rails.logger.warn "WuzAPI: Skipping processing for event with no valid sender phone (Type: #{parser.message_type})"
return
end
# 3. Strong Dedupe (Existing External ID)
# ---------------------------------------
# If we already have a message with this WAID, ignore it immediately.
# This catches standard retries from WuzAPI or webhook re-deliveries.
clean_source_id = "WAID:#{parser.external_id}"
# 4. Find/Create Resources
# ------------------------
# Based on the sender (customer) or recipient (if it's a mobile reply)
ActiveRecord::Base.transaction do
# Strong dedupe inside transaction to prevent TOCTOU race condition
if parser.external_id.present? && Message.exists?(source_id: clean_source_id, inbox_id: inbox.id)
Rails.logger.info "WuzAPI: Ignoring duplicate message (ID: #{clean_source_id})"
return
end
@contact = find_or_create_contact(parser)
return if @contact.nil? # If contact couldn't be determined, stop processing
@conversation = find_or_create_conversation(@contact)
# 5. Echo/AI Deduplication Logic
# ------------------------------
# If this is an outgoing message (from_me=true), it might be:
# A) A reply sent from the physical phone (needs to be created as outgoing)
# B) A confirmation echo of a message Chatwoot/AI just sent (needs to be merged)
if parser.from_me?
deduplicated_message = find_outgoing_message_to_deduplicate(parser, @conversation)
if deduplicated_message
# Merging logic: Update the local temporary message with the real WuzAPI ID
Rails.logger.info "WuzAPI: Merging echo into existing message #{deduplicated_message.id}"
deduplicated_message.update!(source_id: clean_source_id)
return # Stop processing, we successfully merged.
end
end
# 6. Create Message
# -----------------
# If it wasn't a duplicate, create the new message (Incoming or Outgoing)
@message = build_message(parser, @conversation, clean_source_id)
# Attach media BEFORE saving (Chatwoot pattern)
attach_files(parser) if [:image, :audio, :video, :document, :sticker].include?(parser.message_type)
# Now save with attachments
@message.save!
Rails.logger.info "WuzAPI: Message created: #{@message.id} (SourceID: #{clean_source_id})"
end
rescue StandardError => e
Rails.logger.error "WuzAPI Error: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
raise e
end
private
def find_or_create_contact(parser)
# If from_me is true, the sender is US (the business).
# The CONTACT for the conversation is properly the RECIPIENT (the customer).
# If from_me is false, the sender is the CUSTOMER.
phone_number = if parser.from_me?
parser.recipient_phone_number # Extracted from Chat ID
else
parser.sender_phone_number # Extracted from Sender ID
end
return nil if phone_number.blank?
contact_inbox = ContactInbox.find_by(inbox_id: inbox.id, source_id: phone_number)
return contact_inbox.contact if contact_inbox
# Create or Find existing contact in the account
# We use find_by to avoid uniqueness validation errors if the contact exists in another inbox
formatted_phone = "+#{phone_number.to_s.delete('+')}"
contact = inbox.account.contacts.find_by(phone_number: formatted_phone)
contact ||= inbox.account.contacts.create!(
name: parser.sender_name || phone_number,
phone_number: formatted_phone,
custom_attributes: { wuzapi_id: phone_number }
)
ContactInbox.create!(
contact: contact,
inbox: inbox,
source_id: phone_number
)
contact
end
def find_or_create_conversation(contact)
# Find the LAST open conversation for this contact to append to
conversation = inbox.conversations.where(contact_id: contact.id)
.where.not(status: :resolved)
.order(updated_at: :desc)
.first
return conversation if conversation
# Find the ContactInbox association to linking
contact_inbox = ContactInbox.find_by(contact_id: contact.id, inbox_id: inbox.id)
# If no open conversation, create a new one
inbox.conversations.create!(
contact: contact,
contact_inbox: contact_inbox, # Explicitly required by Chatwoot validation
status: :open,
account_id: inbox.account_id
)
end
def find_outgoing_message_to_deduplicate(parser, conversation)
# We are looking for a message that:
# 1. Is Outgoing (message_type: 1)
# 2. Was created recently (e.g., last 2 minutes)
# 3. Has NO source_id (it was created locally by AI/Agent without external ref yet)
# 4. Has the SAME content as the webhook payload
#
# Note: Text matching can be fuzzy due to encoding/whitespace.
# We compare stripped content.
incoming_content = parser.text_content&.strip
return nil if incoming_content.blank?
# Time window to search back
time_window = 5.minutes.ago
conversation.messages.where(message_type: :outgoing, source_id: nil)
.where('created_at > ?', time_window)
.find { |msg| msg.content&.strip == incoming_content }
end
def build_message(parser, conversation, clean_source_id)
is_outgoing = parser.from_me?
msg_params = {
content: parser.text_content,
account_id: inbox.account_id,
inbox_id: inbox.id,
message_type: is_outgoing ? :outgoing : :incoming,
# If outgoing, sender is nil (system/agent). If incoming, sender is the contact.
sender: is_outgoing ? nil : @contact,
source_id: clean_source_id,
created_at: parser.timestamp || Time.current
}
# Handle Replies
# Handle Reply Logic (Aligned with Reference)
if (reply_id = parser.in_reply_to_external_id).present?
clean_reply_id = "WAID:#{reply_id}"
# Strict lookup within conversation to prevent cross-inbox leaks
original_message = conversation.messages.find_by(source_id: clean_reply_id)
if original_message
msg_params[:in_reply_to_id] = original_message.id
else
# Fallback: Store ID for UI "Replying to..." display even if not linked
msg_params[:content_attributes] = { in_reply_to_external_id: clean_reply_id }
end
end
# Use .build so we can attach files before .save!
conversation.messages.build(msg_params)
end
def attach_files(parser)
attachment_data = parser.attachment_params
return if attachment_data.blank? || attachment_data[:external_url].blank?
begin
Rails.logger.info "WuzAPI: Processing attachment (URL: #{attachment_data[:external_url]}, File: #{attachment_data[:file_name]})"
# 1. Download/Decrypt to get a file
file_io = download_or_decrypt_media(attachment_data, parser.message_type)
return if file_io.blank?
# 2. Determine filename
original_filename = attachment_data[:file_name] || "wuzapi_#{Time.now.to_i}"
extension = File.extname(original_filename)
extension = detect_extension(attachment_data[:mimetype], parser.message_type) if extension.blank?
final_filename = "#{File.basename(original_filename, '.*')}#{extension}"
# 3. Attach using Chatwoot's standard pattern
@message.attachments.new(
account_id: @message.account_id,
file_type: file_content_type(parser.message_type),
file: {
io: file_io,
filename: final_filename,
content_type: attachment_data[:mimetype] || 'application/octet-stream'
}
)
Rails.logger.info "WuzAPI: Attachment queued for save (#{final_filename})"
rescue StandardError => e
Rails.logger.error "WuzAPI Attachment Error: #{e.message}"
Rails.logger.error e.backtrace.first(10).join("\n")
end
end
def download_or_decrypt_media(attachment_data, message_type)
media_url = attachment_data[:external_url]
# METHOD 1: Use WuzAPI's /chat/downloadimage endpoint (returns DECRYPTED media)
# This is the equivalent of Cloud API's media download
begin
Rails.logger.info 'WuzAPI: Attempting download via WuzAPI endpoint...'
wuzapi_response = wuzapi_client.download_media(wuzapi_token, media_url)
if wuzapi_response.is_a?(Hash) && wuzapi_response['data'].present?
# WuzAPI returns base64 in 'data' field
image_data = wuzapi_response['data']
# Strip data URI prefix if present
image_data = image_data.sub(/^data:.*?;base64,/, '') if image_data.start_with?('data:')
decoded = Base64.decode64(image_data)
if decoded.bytesize > 1000 # Valid image should be > 1KB
Rails.logger.info 'WuzAPI: Download via endpoint SUCCESS'
return StringIO.new(decoded)
end
end
rescue StandardError => e
Rails.logger.warn "WuzAPI: Endpoint download failed - #{e.message}"
end
# METHOD 2: Try local decryption if we have mediaKey
if attachment_data[:media_key].present?
Rails.logger.info 'WuzAPI: Attempting local decryption (mediaKey present)...'
decrypted = Whatsapp::DecryptionService.new(
media_url,
attachment_data[:media_key],
file_content_type(message_type)
).decrypt
return decrypted if decrypted
Rails.logger.warn 'WuzAPI: Local decryption failed...'
end
# METHOD 3: Direct download (only works for non-encrypted or already-decrypted URLs)
Rails.logger.info "WuzAPI: Direct download from #{media_url}"
Down.download(
media_url,
open_timeout: 10,
read_timeout: 30,
ssl_verify_mode: OpenSSL::SSL::VERIFY_NONE
)
rescue StandardError => e
Rails.logger.error "WuzAPI: All download methods failed - #{e.message}"
nil
end
def wuzapi_client
@wuzapi_client ||= Wuzapi::Client.new(@inbox.channel.provider_config['wuzapi_base_url'])
end
def wuzapi_token
@inbox.channel.wuzapi_user_token
end
def detect_extension(mimetype, message_type)
return '.jpg' if message_type == :image || message_type == :sticker
return '.mp3' if message_type == :audio
return '.mp4' if message_type == :video
case mimetype
when 'image/png' then '.png'
when 'image/webp' then '.webp'
when 'image/gif' then '.gif'
when 'audio/ogg' then '.ogg'
when 'video/webm' then '.webm'
else '.bin'
end
end
def file_content_type(message_type)
case message_type
when :image, :sticker then :image
when :audio then :audio
when :video then :video
else :file
end
end
end

View File

@ -0,0 +1,161 @@
class Whatsapp::Providers::EvolutionApi::PayloadParser
attr_reader :params
def initialize(params)
@params = params.with_indifferent_access
end
def external_id
data.dig(:key, :id) || params[:id]
end
def from_me?
# WuzAPI/Baileys standard
data.dig(:key, :fromMe) || false
end
def sender_phone_number
# No fromMe=false, o remoteJid é o telefone da pessoa.
# Ex: "message":{"key":{"remoteJid":"551199999999@s.whatsapp.net"}}
jid = extract_jid
return nil if jid.blank? || jid.include?('@lid')
jid.split('@').first.split(':').first
end
def recipient_phone_number
# Se a mensagem foi enviada por nós (fromMe=true), o remoteJid é o destinatário (cliente)
jid = extract_jid
return nil if jid.blank? || jid.include?('@lid')
jid.split('@').first.split(':').first
end
def message_type
return :ignore if ignorable_webhook_event_type?
# Baseado na key que aparece dentro de data[:message] (padrão Baileys)
msg = unwrap_ephemeral_message(data[:message])
return :unknown unless msg.is_a?(Hash)
return :text if msg[:conversation].present? || msg[:extendedTextMessage].present?
return :image if msg[:imageMessage].present?
return :audio if msg[:audioMessage].present?
return :video if msg[:videoMessage].present?
return :document if msg[:documentMessage].present? || msg[:documentWithCaptionMessage].present?
return :sticker if msg[:stickerMessage].present?
# Evolution pode abstrair pro topo `messageType`
type_str = data[:messageType].to_s.downcase
case type_str
when 'conversation', 'extendedtextmessage' then :text
when 'imagemessage' then :image
when 'audiomessage' then :audio
when 'videomessage' then :video
when 'documentmessage' then :document
when 'stickermessage' then :sticker
else
:unknown
end
end
def in_reply_to_external_id
msg = unwrap_ephemeral_message(data[:message])
return nil unless msg.is_a?(Hash)
[:extendedTextMessage, :imageMessage, :videoMessage, :audioMessage, :stickerMessage, :documentMessage].each do |key|
ctx = msg.dig(key, :contextInfo)
next if ctx.blank?
stanza = ctx[:stanzaID] || ctx[:stanzaId]
return stanza if stanza.present?
end
nil
end
def text_content
msg = unwrap_ephemeral_message(data[:message])
return nil unless msg.is_a?(Hash)
return msg[:conversation] if msg[:conversation].present?
return msg.dig(:extendedTextMessage, :text) if msg.dig(:extendedTextMessage, :text).present?
[:imageMessage, :videoMessage, :documentMessage].each do |media_key|
caption = msg.dig(media_key, :caption)
return caption if caption.present?
end
nil
end
def attachment_params
media_key = case message_type
when :image then :imageMessage
when :audio then :audioMessage
when :video then :videoMessage
when :document then :documentMessage
when :sticker then :stickerMessage
end
return nil unless media_key
msg = unwrap_ephemeral_message(data[:message])
media_data = msg[media_key]
return nil unless media_data.is_a?(Hash)
# O formato de evolução costuma vir com `base64` já embutido
# ou com URLs pro media local proxy
{
base64: data.dig(:message, :base64) || data[:base64],
mimetype: media_data['mimetype'],
file_name: media_data['fileName'] || "file_#{external_id}",
media_key: media_data['mediaKey']
}
end
def timestamp
timestamp_val = data[:messageTimestamp] || params[:timestamp]
return Time.current if timestamp_val.blank?
# Baileys envia messageTimestamp como integer UNIX
return Time.zone.at(timestamp_val.to_i) if timestamp_val.is_a?(Integer) || timestamp_val.to_s.match?(/^\d+$/)
begin
Time.zone.parse(timestamp_val.to_s)
rescue ArgumentError
Time.current
end
end
def sender_name
data[:pushName] || data[:pushname]
end
def group_message?
jid = extract_jid
jid&.include?('@g.us')
end
private
def data
@data ||= params[:data] || params[:messages]&.first || params
end
def extract_jid
data.dig(:key, :remoteJid) || data[:remoteJid]
end
def ignorable_webhook_event_type?
# Filtrar eventos que não nos importam como PRESENCE_UPDATE ou STATUS
event_type = params[:event]
event_type.to_s != 'messages.upsert' && event_type.to_s != 'message'
end
def unwrap_ephemeral_message(msg)
return {} unless msg
msg.key?(:ephemeralMessage) ? msg.dig(:ephemeralMessage, :message) : msg
end
end

View File

@ -0,0 +1,103 @@
require_relative 'base_service'
class Whatsapp::Providers::EvolutionService < Whatsapp::Providers::BaseService
attr_reader :whatsapp_channel
def initialize(whatsapp_channel:)
super(whatsapp_channel: whatsapp_channel)
@base_url = whatsapp_channel.provider_config['evolution_base_url']
@api_token = whatsapp_channel.evolution_api_token
end
def send_message(phone_number, message)
# Normalize phone number: remove +, space, -, (, )
normalized_phone = phone_number.gsub(/[\+\s\-\(\)]/, '')
instance_name = "Chatwoot_#{whatsapp_channel.phone_number}"
Rails.logger.info "[EvolutionService] Sending Message:
Message ID: #{message.id}
Raw Phone: #{phone_number}
Normalized Phone: #{normalized_phone}
"
# Se a mensagem tiver anexo, usamos send_attachment_message.
response = if message.attachments.present?
send_attachment_message(instance_name, normalized_phone, message)
else
params = {}
if (reply_id = message.content_attributes['in_reply_to_external_id']).present?
# Normalmente as bibliotecas WA recebem um ID p/ quote, isso depende muito da lib Go (que repassa a stanza p/ multidevice)
params['quoted'] = { key: { id: reply_id } }
end
client.send_text(instance_name, normalized_phone, message.content, **params)
end
extract_message_id(response)
end
def send_attachment_message(instance_name, phone_number, message)
attachment = message.attachments.first
# Verifica o env se estamos servindo de S3 ou local
# Dependendo da lib Evolution, ele aceita URL ou Base64. Vamos usar Base64 para ser seguro pareado c/ local ou nuvem.
begin
base64_data = Base64.strict_encode64(attachment.file.download)
mime_type = attachment.file.content_type
data_uri = "data:#{mime_type};base64,#{base64_data}"
if mime_type.start_with?('image/')
client.send_image(instance_name, phone_number, data_uri, message.content)
else
client.send_file(instance_name, phone_number, data_uri, attachment.file.filename.to_s)
end
rescue StandardError => e
Rails.logger.error "[EvolutionService] Attachment Error: #{e.message}"
nil
end
end
def send_template(_phone_number, _template_info)
Rails.logger.warn 'Evolution: Templates not yet implemented or supported.'
end
def sync_templates
# No-op
end
def validate_provider_config?
return false if @api_token.blank? || @base_url.blank?
instance_name = "Chatwoot_#{whatsapp_channel.phone_number}"
begin
client.session_status(instance_name)
true
rescue EvolutionApi::Client::Error
false
end
end
# Podemos adicionar toggle_typing_status futuramente caso precisemos e a rota exista.
def toggle_typing_status(_typing_status, recipient_id: nil, **_kwargs)
nil
end
private
def client
@client ||= ::EvolutionApi::Client.new(@base_url, @api_token)
end
def extract_message_id(response)
return nil unless response.is_a?(Hash)
# Baseado nas respostas de serviços baileys-like, key id costuma vir na msg.
# Pode variar de {"key"=>{"id"=> "..."}} até {"id": "..."} na raiz
message_id = response.dig('message', 'key', 'id') || response.dig('key', 'id') || response['id']
return nil if message_id.blank?
message_id
end
end

View File

@ -0,0 +1,276 @@
class Whatsapp::Providers::Wuzapi::PayloadParser
attr_reader :params
def initialize(params)
@params = params.with_indifferent_access
end
def external_id
params.dig(:event, :Info, :ID)
end
def from_me?
# A flag comes primarily from 'IsFromMe' or nested in 'Info'
is_api_from_me = params.dig(:event, :Info, :IsFromMe) || params.dig(:event, :IsFromMe)
# However, WuzAPI might be inconsistent. We also check if the sender matches the instance phone.
# But if the API explicitly says "IsFromMe: true", we trust it first.
return true if is_api_from_me.present? && is_api_from_me.to_s == 'true'
# Fallback check: Sender JID prefix matches instance phone number
instance_phone = params['phone_number']
sender_jid = params.dig(:event, :Info, :Sender) || params.dig(:event, :Sender)
if instance_phone.present? && sender_jid.present?
sender_phone = sender_jid.split('@').first
return true if sender_phone == instance_phone
end
false
end
# Extracts the CUSTOMER phone number when the message is FROM ME (outgoing).
# In this case, the 'Chat' field contains the recipient (customer) JID.
# When WuzAPI uses LIDs, we fallback to RecipientAlt which has the real number.
def recipient_phone_number
chat_id = params.dig(:event, :Info, :Chat) || params.dig(:event, :Chat)
# If Chat is a real number, use it
return chat_id.split('@').first.split(':').first if chat_id&.include?('@s.whatsapp.net')
# Fallback to RecipientAlt when Chat uses LID format
recipient_alt = params.dig(:event, :Info, :RecipientAlt) || params.dig(:event, :RecipientAlt)
return recipient_alt.split('@').first.split(':').first if recipient_alt&.include?('@s.whatsapp.net')
nil
end
def message_type
return :chat_presence if webhook_event_type == 'ChatPresence'
return :ignore if ignorable_webhook_event_type?
# Info: Type contains the general classification (text, image, etc)
type = raw_info_type.to_s.downcase
media_type = params.dig(:event, :Info, :MediaType).to_s.downcase
# WuzAPI sometimes sends 'media' in Type and the actual type in MediaType
type = media_type if type == 'media' && media_type.present?
case type
when 'text' then :text
when 'image' then :image
when 'audio' then :audio
when 'video' then :video
when 'document' then :document
when 'sticker' then :sticker
when 'readreceipt' then :ignore
else
fallback_message_type_from_payload
end
end
def presence_state
params.dig(:event, :State)
end
def in_reply_to_external_id
msg = unwrap_ephemeral_message(params.dig(:event, :Message))
return nil unless msg.is_a?(Hash)
# DEBUG: Log the message structure to understand reply context
Rails.logger.info "WuzAPI Reply Debug: Message keys = #{msg.keys.inspect}"
# 1. Extended text
ctx = msg.dig(:extendedTextMessage, :contextInfo)
if ctx.present?
Rails.logger.info "WuzAPI Reply Debug: Found extendedTextMessage contextInfo = #{ctx.inspect}"
stanza = ctx[:stanzaID] || ctx[:stanzaId]
return stanza if stanza.present?
end
# 2. Media Types direct contextInfo
[:imageMessage, :videoMessage, :audioMessage, :stickerMessage, :documentMessage].each do |key|
ctx = msg.dig(key, :contextInfo)
next if ctx.blank?
Rails.logger.info "WuzAPI Reply Debug: Found #{key} contextInfo = #{ctx.inspect}"
stanza = ctx[:stanzaID] || ctx[:stanzaId]
return stanza if stanza.present?
end
# 3. Document With Caption
if msg.key?(:documentWithCaptionMessage)
ctx = msg.dig(:documentWithCaptionMessage, :message, :documentMessage, :contextInfo)
if ctx.present?
Rails.logger.info "WuzAPI Reply Debug: Found documentWithCaptionMessage contextInfo = #{ctx.inspect}"
return ctx[:stanzaID] || ctx[:stanzaId]
end
end
# 4. Check for simple conversation with contextInfo (text reply without extendedTextMessage)
if msg[:conversation].present? && msg[:contextInfo].present?
ctx = msg[:contextInfo]
Rails.logger.info "WuzAPI Reply Debug: Found conversation contextInfo = #{ctx.inspect}"
stanza = ctx[:stanzaID] || ctx[:stanzaId]
return stanza if stanza.present?
end
Rails.logger.info 'WuzAPI Reply Debug: No reply context found'
nil
end
def text_content
msg = unwrap_ephemeral_message(params.dig(:event, :Message))
# Legacy fallback used by some WuzAPI payload variants
return params.dig(:event, :Text) if params.dig(:event, :Text).present?
return nil unless msg.is_a?(Hash)
# 1. Simple text
return msg[:conversation] if msg[:conversation].present?
# 2. Extended Text
return msg.dig(:extendedTextMessage, :text) if msg.dig(:extendedTextMessage, :text).present?
# 3. Media Captions (Image, Video, Document)
[:imageMessage, :videoMessage, :documentMessage].each do |media_key|
caption = msg.dig(media_key, :caption)
return caption if caption.present?
end
# 4. Document With Caption
return msg.dig(:documentWithCaptionMessage, :message, :documentMessage, :caption) if msg.key?(:documentWithCaptionMessage)
nil
end
def attachment_params
media_key = case message_type
when :image then :imageMessage
when :audio then :audioMessage
when :video then :videoMessage
when :document then :documentMessage
when :sticker then :stickerMessage
end
return nil unless media_key
msg = unwrap_ephemeral_message(params.dig(:event, :Message))
data = msg[media_key]
return nil unless data.is_a?(Hash)
{
external_url: data['URL'],
file_name: data['fileName'] || "file_#{external_id}",
mimetype: data['mimetype'],
thumbnail: data['JPEGThumbnail'],
media_key: data['mediaKey']
}
end
def sender_phone_number
jid = extract_jid
# Reject LIDs as they aren't valid E164 phone numbers
return nil if jid.blank? || jid.include?('@lid')
# Format: 556182098580:1@s.whatsapp.net -> 556182098580
# MD accounts include a device index suffix (eg. :1) that we must strip
jid.split('@').first.split(':').first
end
def timestamp
timestamp_val = params.dig(:event, :Info, :Timestamp) || params.dig(:event, :Timestamp)
return Time.current if timestamp_val.blank?
begin
Time.zone.parse(timestamp_val.to_s)
rescue ArgumentError
Time.current
end
end
def sender_name
params.dig(:event, :Info, :PushName) || params.dig(:event, :PushName)
end
def group_message?
params.dig(:event, :Info, :IsGroup) || params.dig(:event, :IsGroup)
end
private
def webhook_event_type
params[:type].to_s
end
def raw_info_type
params.dig(:event, :Info, :Type) || params.dig(:event, :Type)
end
def ignorable_webhook_event_type?
# These are provider/system updates and should not be treated as incoming user messages.
ignorable = %w[
ReadReceipt
UserAbout
IdentityChange
Picture
Connected
Disconnected
OfflineSyncCompleted
Presence
PresenceUpdate
Ack
]
ignorable.include?(webhook_event_type)
end
def fallback_message_type_from_payload
# Fallback: detect type from message body shape, even when Info.Type is missing or inconsistent.
msg = unwrap_ephemeral_message(params.dig(:event, :Message))
if msg.is_a?(Hash)
return :text if msg[:conversation].present? || msg[:extendedTextMessage].present? || msg.dig(:extendedTextMessage, :text).present?
return :image if msg[:imageMessage].present?
return :audio if msg[:audioMessage].present?
return :video if msg[:videoMessage].present?
return :document if msg[:documentMessage].present? || msg[:documentWithCaptionMessage].present?
return :sticker if msg[:stickerMessage].present?
end
return :text if params.dig(:event, :Text).present?
:unknown
end
def unwrap_ephemeral_message(msg)
return {} unless msg
msg.key?(:ephemeralMessage) ? msg.dig(:ephemeralMessage, :message) : msg
end
def extract_jid
if from_me?
# For outgoing messages, prefer Chat if it's a real number
chat = params.dig(:event, :Info, :Chat) || params.dig(:event, :Chat)
return chat if chat&.include?('@s.whatsapp.net')
# Fallback to RecipientAlt when Chat uses LID format
recipient_alt = params.dig(:event, :Info, :RecipientAlt) || params.dig(:event, :RecipientAlt)
return recipient_alt if recipient_alt&.include?('@s.whatsapp.net')
chat # Return original Chat even if LID (will be filtered later)
else
sender = params.dig(:event, :Info, :Sender) || params.dig(:event, :Sender)
sender_alt = params.dig(:event, :Info, :SenderAlt) || params.dig(:event, :SenderAlt)
# Prefer @s.whatsapp.net over @lid
if sender&.include?('@s.whatsapp.net')
sender
elsif sender_alt&.include?('@s.whatsapp.net')
sender_alt
else
sender
end
end
end
end

View File

@ -0,0 +1,187 @@
require_relative 'base_service'
class Whatsapp::Providers::WuzapiService < Whatsapp::Providers::BaseService
attr_reader :whatsapp_channel
def initialize(whatsapp_channel:)
super(whatsapp_channel: whatsapp_channel)
@base_url = whatsapp_channel.provider_config['wuzapi_base_url']
end
def send_message(phone_number, message)
user_token = whatsapp_channel.wuzapi_user_token
# Normalize phone number: remove +, space, -, (, )
normalized_phone = phone_number.gsub(/[\+\s\-\(\)]/, '')
Rails.logger.info "[WuzapiService] Sending Message:
Message ID: #{message.id}
Conversation ID: #{message.conversation_id}
Contact Inbox ID: #{message.conversation.contact_inbox_id}
Raw Phone (arg): #{phone_number}
Normalized Phone (Target): #{normalized_phone}
Content: #{message.content&.truncate(50)}
"
return send_reaction_message(normalized_phone, message) if message.content_attributes['is_reaction'] || message.content_attributes[:is_reaction]
response = if message.attachments.present?
send_attachment_message(user_token, normalized_phone, message)
else
params = {}
# Extract and clean reply ID (remove WAID: prefix if stored)
if (reply_id = message.content_attributes['in_reply_to_external_id']).present?
params['MessageId'] = reply_id.gsub(/^WAID:/, '')
elsif (reply_id = message.in_reply_to_external_id).present?
params['MessageId'] = reply_id.gsub(/^WAID:/, '')
end
client.send_text(user_token, normalized_phone, message.content, **params)
end
# Extract message ID from WuzAPI response and format as WAID:xxx
extract_message_id(response)
end
def send_attachment_message(user_token, phone_number, message)
attachment = message.attachments.first
base64_data = Base64.strict_encode64(attachment.file.download)
mime_type = attachment.file.content_type
data_uri = "data:#{mime_type};base64,#{base64_data}"
if mime_type.start_with?('image/')
client.send_image(user_token, phone_number, data_uri, message.content)
else
client.send_file(user_token, phone_number, data_uri, attachment.file.filename.to_s)
end
end
def send_reaction_message(phone_number, message)
user_token = whatsapp_channel.wuzapi_user_token
normalized_phone = phone_number.gsub(/[\+\s\-\(\)]/, '')
# Assuming message content is the emoji
reaction_emoji = message.content
# Resolve the correct external message ID
message_id = message.content_attributes['in_reply_to_external_id']
# Fallback to internal ID resolution if external is missing
if message_id.blank? && message.content_attributes['in_reply_to'].present?
target_msg = message.conversation.messages.find_by(id: message.content_attributes['in_reply_to'])
message_id = target_msg&.source_id
end
# Strip WAID prefix if present
message_id = message_id.gsub(/^WAID:/, '') if message_id.present?
use_me_prefix = reaction_to_own_message?(message)
if use_me_prefix
normalized_phone = "me:#{normalized_phone}" unless normalized_phone.start_with?('me:')
message_id = "me:#{message_id}" if message_id.present? && !message_id.start_with?('me:')
else
# Enforce JID format for customer numbers
clean_number = normalized_phone.split('@').first
normalized_phone = "#{clean_number}@s.whatsapp.net"
end
Rails.logger.info "[WuzapiService] Attempting reaction: phone=#{normalized_phone}, msg_id=#{message_id}, emoji=#{reaction_emoji}"
if message_id.present?
# Wuzapi client needs to implement send_reaction
# This assumes the client wrapper has this method. If not, we might need to add it or use raw request.
# Based on typical Wuzapi forks, it might be /send-reaction-message
# We'll assume the client wrapper will have a send_reaction method.
# If not visible in the existing codebase, we might need to add it to the client class too.
# checking...
response = client.send_reaction(user_token, normalized_phone, message_id, reaction_emoji)
Rails.logger.info "[WuzapiService] Reaction response: #{response}"
response
else
Rails.logger.warn 'Wuzapi: Cannot send reaction without in_reply_to message ID'
end
end
def send_template(_phone_number, _template_info)
# Placeholder for template support if Wuzapi supports it.
# For now, just logging or no-op as per initial text-focused plan.
Rails.logger.warn 'Wuzapi: Templates not yet implemented or supported.'
end
def sync_templates
# No-op for Wuzapi as it doesn't insist on syncing templates like Cloud API
end
def validate_provider_config?
# Validate if we can connect to session status
user_token = whatsapp_channel.wuzapi_user_token
return false if user_token.blank?
begin
client.session_status(user_token)
true
rescue Wuzapi::Client::Error
false
end
end
def toggle_typing_status(typing_status, recipient_id: nil, **_kwargs)
# typing_status: 'typing_on', 'typing_off'
# Wuzapi expects: 'composing', 'paused'
state = %w[typing_on on].include?(typing_status) ? 'composing' : 'paused'
user_token = whatsapp_channel.wuzapi_user_token
phone_number = recipient_id || whatsapp_channel.phone_number
# Clean phone number (digits only)
normalized_phone = phone_number.to_s.gsub(/[\+\s\-\(\)]/, '')
# Enforce JID format: 5561...@s.whatsapp.net
# Strip any existing suffix (like @lid or even @s.whatsapp.net to be safe) and append standard one.
clean_number = normalized_phone.split('@').first
jid = "#{clean_number}@s.whatsapp.net"
Rails.logger.info "[WuzapiService] toggle_typing_status: Sending presence to #{jid} (raw: #{normalized_phone}), state: #{state}, token_present: #{user_token.present?}"
begin
# Use JID in the 'Phone' field as confirmed by manual tests (Test C)
response = client.send_chat_presence(user_token, jid, state)
Rails.logger.info "[WuzapiService] toggle_typing_status response: #{response}"
rescue StandardError => e
Rails.logger.warn "Wuzapi: Failed to send typing status: #{e.message}"
end
end
private
def client
@client ||= ::Wuzapi::Client.new(@base_url)
end
# Extract message ID from WuzAPI response and format it as WAID:xxx
# WuzAPI returns: {"code" => 200, "data" => {"Id" => "xxx", ...}, "success" => true}
def extract_message_id(response)
return nil unless response.is_a?(Hash)
message_id = response.dig('data', 'Id') || response.dig(:data, :Id)
return nil if message_id.blank?
"WAID:#{message_id}"
end
def reaction_to_own_message?(message)
# If we can resolve the target message, check if it was sent by us.
target_message = nil
if message.in_reply_to.present?
target_message = message.conversation.messages.find_by(id: message.in_reply_to)
target_message ||= message.conversation.messages.find_by(source_id: message.in_reply_to)
elsif message.in_reply_to_external_id.present?
target_message = message.conversation.messages.find_by(source_id: message.in_reply_to_external_id)
end
return false if target_message.blank?
target_message.outgoing? || target_message.template?
end
end

View File

@ -0,0 +1,286 @@
require 'net/http'
require 'json'
class Wuzapi::Client
class Error < StandardError; end
class AuthenticationError < Error; end
class ConnectionError < Error; end
attr_reader :base_url
def initialize(base_url)
@base_url = normalize_url(base_url)
end
# Admin Endpoints (Use Authorization header)
def create_user(admin_token, name, user_token)
payload = { name: name, token: user_token }
request(:post, '/admin/users', payload, admin_auth_headers(admin_token))
end
def delete_user(admin_token, user_id)
request(:delete, "/admin/users/#{user_id}", nil, admin_auth_headers(admin_token))
end
# User Endpoints (Use token header)
def send_text(user_token, phone_number, body, **options)
# Payload MUST be Case-Sensitive: Key 'Phone' and 'Body'
payload = { 'Phone' => phone_number, 'Body' => body }.merge(options)
request(
:post,
'/chat/send/text',
payload,
user_auth_headers(user_token),
fallback_paths: ['/send/text'],
allow_base_fallback: true
)
end
def send_image(user_token, phone_number, base64_data, caption = nil)
# Some Wuzapi builds expect `Image` while older forks accepted `Body`.
# Send both for compatibility; `Image` is the official key in current docs.
payload = {
'Phone' => phone_number,
'Image' => base64_data,
'Body' => base64_data,
'Caption' => caption
}
request(
:post,
'/chat/send/image',
payload,
user_auth_headers(user_token),
fallback_paths: ['/send/image'],
allow_base_fallback: true
)
end
def send_file(user_token, phone_number, base64_data, filename)
payload = { 'Phone' => phone_number, 'Body' => base64_data, 'Filename' => filename }
request(
:post,
'/chat/send/file',
payload,
user_auth_headers(user_token),
fallback_paths: ['/send/file'],
allow_base_fallback: true
)
end
def send_reaction(user_token, phone_number, message_id, emoji)
payload = { 'Phone' => phone_number, 'Body' => emoji, 'Id' => message_id }
request(
:post,
'/chat/react',
payload,
user_auth_headers(user_token),
fallback_paths: ['/send/react'],
allow_base_fallback: true
)
end
def send_chat_presence(user_token, phone_number, state, media = nil)
# State: "composing" or "paused"
# Media: "audio" (optional)
payload = { 'Phone' => phone_number, 'State' => state }
payload['Media'] = media if media
request(
:post,
'/chat/presence',
payload,
user_auth_headers(user_token),
fallback_paths: ['/send/presence'],
allow_base_fallback: true
)
end
def download_media(user_token, media_url)
# Some WuzAPI versions use a dedicated download endpoint to proxy Meta CDN
payload = { 'URL' => media_url }
request(
:post,
'/chat/downloadimage',
payload,
user_auth_headers(user_token),
fallback_paths: ['/downloadimage'],
allow_base_fallback: true
)
end
def session_status(user_token)
request(:get, '/session/status', nil, user_auth_headers(user_token))
end
def get_qr_code(user_token)
request(:get, '/session/qr', nil, user_auth_headers(user_token))
end
def session_connect(user_token)
request(:post, '/session/connect', {}, user_auth_headers(user_token))
end
def session_disconnect(user_token)
request(:post, '/session/disconnect', nil, user_auth_headers(user_token))
end
def session_logout(user_token)
request(:get, '/session/logout', nil, user_auth_headers(user_token))
end
def set_webhook(user_token, webhook_url)
# Wuzapi expects PascalCase keys 'WebhookURL' and 'Events' with 'All' per user verification.
payload = { 'WebhookURL' => webhook_url, 'Events' => ['All'] }
request(:post, '/webhook', payload, user_auth_headers(user_token))
end
def update_webhook(user_token, webhook_url)
payload = { 'WebhookURL' => webhook_url, 'Events' => ['All'] }
request(:put, '/webhook', payload, user_auth_headers(user_token))
end
def get_webhook(user_token)
request(:get, '/webhook', nil, user_auth_headers(user_token))
end
private
def normalize_url(url)
url.to_s.gsub(%r{/$}, '')
end
def admin_auth_headers(token)
{ 'Authorization' => token }
end
def user_auth_headers(token)
{ 'token' => token }
end
def request(method, path, payload, headers, fallback_paths: [], allow_base_fallback: false)
candidate_paths = [path, *Array(fallback_paths)].map { |p| normalize_path(p) }.uniq
candidate_bases = [base_url]
primary_path = candidate_paths.first
primary_base = candidate_bases.first
if allow_base_fallback
alternative = alternate_base_url(base_url)
candidate_bases << alternative if alternative.present? && alternative != base_url
end
errors = []
candidate_bases.each do |candidate_base|
candidate_paths.each do |candidate_path|
response = execute_http_request(method, candidate_base, candidate_path, payload, headers)
if candidate_base != primary_base || candidate_path != primary_path
Rails.logger.info("Wuzapi fallback route worked base=#{candidate_base} path=#{candidate_path}")
end
return handle_response(response)
rescue Error => e
if retryable_not_found?(e)
errors << e
Rails.logger.warn("Wuzapi endpoint not found, trying fallback route base=#{candidate_base} path=#{candidate_path}")
next
end
raise
rescue ConnectionError => e
errors << e
Rails.logger.warn("Wuzapi connection error on fallback route base=#{candidate_base} path=#{candidate_path}: #{e.message}")
next
end
end
raise(errors.last || Error.new('Wuzapi request failed with unknown error'))
end
def execute_http_request(method, target_base_url, path, payload, headers)
uri = URI.parse("#{target_base_url}#{path}")
http = Net::HTTP.new(uri.host, uri.port)
if uri.scheme == 'https'
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
end
request_obj = case method
when :get
Net::HTTP::Get.new(uri.request_uri)
when :post
Net::HTTP::Post.new(uri.request_uri)
when :put
Net::HTTP::Put.new(uri.request_uri)
when :delete
Net::HTTP::Delete.new(uri.request_uri)
end
request_obj['Content-Type'] = 'application/json'
request_obj['Accept'] = 'application/json'
headers.each { |k, v| request_obj[k] = v }
request_obj.body = payload.to_json if payload
begin
http.request(request_obj)
rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Net::OpenTimeout, Net::ReadTimeout => e
raise ConnectionError, "Could not connect to Wuzapi: #{e.message}"
end
end
def normalize_path(path)
return '/' if path.blank?
path.start_with?('/') ? path : "/#{path}"
end
def alternate_base_url(url)
normalized = normalize_url(url)
if normalized.match?(%r{/api/?$})
normalized.gsub(%r{/api/?$}, '')
else
"#{normalized}/api"
end
end
def retryable_not_found?(error)
error.message.include?('API Error: 404')
end
def handle_response(response)
Rails.logger.info "WUZAPI RAW RESPONSE: status=#{response.code} ct=#{response['content-type']} body=#{response.body.to_s.truncate(1000)}"
if response.code.to_i >= 200 && response.code.to_i < 300
content_type = response['content-type'] || ''
if content_type.include?('image/')
require 'base64'
base64_image = Base64.strict_encode64(response.body)
return { 'qrcode' => "data:#{content_type};base64,#{base64_image}" }
end
begin
body = JSON.parse(response.body)
# Normalize keys to 'qrcode'
# Check nested data object
if body['data'].is_a?(Hash)
found = body['data']['qrcode'] || body['data']['qr'] || body['data']['QRCode'] || body['data']['QR'] || body['data']['base64'] || body['data']['image']
body['qrcode'] = found if found
# Check if data is the string itself
elsif body['data'].is_a?(String) && (body['data'].start_with?('data:') || body['data'].length > 50)
body['qrcode'] = body['data']
end
# Check root keys if still not found
body['qrcode'] = body['qr'] || body['QRCode'] || body['QR'] || body['base64'] || body['image'] unless body['qrcode']
return body
rescue JSON::ParserError
Rails.logger.warn "Wuzapi response parse error or non-JSON: #{response.body}"
return { 'raw_body' => response.body }
end
elsif response.code.to_i == 401 || response.code.to_i == 403
raise AuthenticationError, "Authentication failed: #{response.code} #{response.body}"
else
raise Error, "API Error: #{response.code} #{response.body}"
end
end
end

View File

@ -0,0 +1,34 @@
class Wuzapi::ProvisioningService
def initialize(base_url, admin_token)
@base_url = base_url
@admin_token = admin_token
@client = Wuzapi::Client.new(base_url)
end
def provision(name)
user_token = SecureRandom.hex(32)
response = @client.create_user(@admin_token, name, user_token)
# Wuzapi returns the user object, or we assume success if no error raised.
# The response structure depends on Wuzapi. Assuming it returns { "ID": "...", ... } or similar.
# Based on plan, we just need to know it succeeded.
# We return the generated data to be saved.
{
wuzapi_user_id: response['ID'] || response['id'], # Adjust based on actual response if known, strictly fallback
wuzapi_user_token: user_token
}
end
# [INTENTIONAL] reserved for signed webhooks
def setup_webhook(user_token, inbox_id, _webhook_secret)
# Host logic needs to come from GlobalConfig or Rails.application.routes
# Ideally passed in or resolved.
base_host = ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
inbox = Inbox.find(inbox_id)
phone_number = inbox.channel.phone_number.delete('+')
webhook_url = "#{base_host}/webhooks/whatsapp/#{phone_number}"
@client.set_webhook(user_token, webhook_url)
end
end

View File

@ -20,6 +20,9 @@ json.allow_messages_after_resolved resource.allow_messages_after_resolved
json.lock_to_single_conversation resource.lock_to_single_conversation
json.sender_name_type resource.sender_name_type
json.business_name resource.business_name
json.typing_delay resource.typing_delay
json.captain_unit_id resource.captain_inbox&.captain_unit_id if defined?(CaptainInbox)
if resource.portal.present?
json.help_center do

View File

@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pagamento via Pix</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
</style>
</head>
<body class="bg-gray-50 flex items-center justify-center min-h-screen px-4">
<div class="max-w-md w-full bg-white rounded-2xl shadow-xl overflow-hidden">
<!-- Header -->
<div class="bg-indigo-600 p-6 text-center">
<h1 class="text-white text-xl font-bold mb-1">Pagamento via Pix</h1>
<p class="text-indigo-100 text-sm">
<%= @charge.unit&.name || 'Reserva de Suíte' %>
</p>
</div>
<!-- Content -->
<div class="p-8">
<div class="text-center mb-8">
<p class="text-gray-500 text-sm uppercase tracking-wide font-semibold">Valor a Pagar</p>
<p class="text-4xl font-extrabold text-gray-900 mt-2">
<%= ActiveSupport::NumberHelper.number_to_currency(@charge.original_value, unit: 'R$ ', separator: ',', delimiter: '.') %>
</p>
</div>
<div class="bg-gray-100 rounded-lg p-4 mb-6 relative group">
<label class="block text-xs font-medium text-gray-500 mb-1 uppercase">Pix Copia e Cola</label>
<div class="font-mono text-sm text-gray-600 break-all line-clamp-3 overflow-hidden h-16">
<%= @charge.pix_copia_e_cola %>
</div>
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-gray-100/90 pointer-events-none"></div>
</div>
<button onclick="copyPix()" id="copyBtn" class="w-full bg-green-500 hover:bg-green-600 text-white font-bold py-4 px-6 rounded-xl shadow-lg transform transition active:scale-95 flex items-center justify-center gap-2 text-lg">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
</svg>
<span>COPIAR CÓDIGO PIX</span>
</button>
<p id="feedback" class="text-center text-green-600 font-medium mt-4 opacity-0 transition-opacity duration-300">
Código copiado com sucesso! ✅
</p>
<div class="mt-8 text-center bg-yellow-50 p-4 rounded-lg border border-yellow-100">
<p class="text-sm text-yellow-800">
<strong>Próximo passo:</strong><br>
Após pagar no app do seu banco, volte ao WhatsApp e avise para confirmar sua reserva.
</p>
</div>
</div>
</div>
<script>
const pixCode = "<%= j @charge.pix_copia_e_cola %>";
function showSuccess() {
const btn = document.getElementById('copyBtn');
const feedback = document.getElementById('feedback');
btn.classList.remove('bg-green-500', 'hover:bg-green-600');
btn.classList.add('bg-gray-800', 'hover:bg-gray-900');
btn.innerHTML = '<span>CÓDIGO COPIADO!</span>';
feedback.classList.remove('opacity-0');
setTimeout(() => {
btn.classList.add('bg-green-500', 'hover:bg-green-600');
btn.classList.remove('bg-gray-800', 'hover:bg-gray-900');
btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" /></svg><span>COPIAR CÓDIGO PIX</span>';
feedback.classList.add('opacity-0');
}, 3000);
}
function copyPix() {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(pixCode).then(showSuccess).catch(() => fallbackCopy());
} else {
fallbackCopy();
}
}
function fallbackCopy() {
var textArea = document.createElement("textarea");
textArea.value = pixCode;
textArea.style.top = "0"; textArea.style.left = "0";
textArea.style.position = "fixed"; textArea.style.opacity = "0";
document.body.appendChild(textArea);
textArea.focus(); textArea.select();
try {
if (document.execCommand('copy')) showSuccess();
} catch (err) {
alert('Não foi possível copiar automaticamente. Por favor, copie manualmente.');
}
document.body.removeChild(textArea);
}
</script>
</body>
</html>

View File

@ -34,3 +34,18 @@
title: 'Handoff to Human'
description: 'Hand off the conversation to a human agent'
icon: 'user-switch'
- id: generate_pix
title: 'Gerar Pix'
description: 'Gera uma cobrança Pix via Banco Inter para a reserva ativa da conversa'
icon: 'payment'
- id: check_pix_payment
title: 'Verificar Pagamento Pix'
description: 'Consulta no Banco Inter se a cobrança Pix já foi paga'
icon: 'check-circle'
- id: send_suite_images
title: 'Enviar Fotos de Suíte'
description: 'Envia fotos da galeria da unidade para o cliente, com filtros por categoria/suíte'
icon: 'image'

15
config/application.yml Normal file
View File

@ -0,0 +1,15 @@
# Fazer AI - Bypass Settings
CHATWOOT_EDITION=enterprise
ENABLE_ACCOUNT_LEVEL_FEATURE_FLAGS=true
LIMITS_INBOXES=0
LIMITS_USERS=0
LIMITS_TEAMS=0
LIMITS_CAMPAIGNS=0
LIMITS_AUTOMATIONS=0
LIMITS_AGENT_BOTS=0
LIMITS_CANNED_RESPONSES=0
LIMITS_MACROS=0
LIMITS_SLA_POLICIES=0
LIMITS_WEBHOOKS=0
LIMITS_ROUTING_RULES=0
LIMITS_HELP_CENTER=0

View File

@ -74,6 +74,15 @@ Rails.application.routes.draw do
end
resources :custom_tools
resources :documents, only: [:index, :show, :create, :destroy]
resources :gallery_items
resources :reservations, only: [:index, :show] do
collection do
get :revenue
end
member do
get :pix
end
end
resource :tasks, only: [], controller: 'tasks' do
post :rewrite
post :summarize
@ -81,6 +90,7 @@ Rails.application.routes.draw do
post :label_suggestion
post :follow_up
end
resources :units
end
resource :saml_settings, only: [:show, :create, :update, :destroy]
resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do
@ -234,6 +244,14 @@ Rails.application.routes.draw do
post :link, on: :member
get :available_templates, on: :member
end
resource :wuzapi, controller: 'inboxes/wuzapi', only: [:show] do
get :qr
post :connect
post :disconnect
get :webhook_info
put :update_webhook
end
end
resources :inbox_members, only: [:create, :show], param: :inbox_id do
@ -574,6 +592,7 @@ Rails.application.routes.draw do
post 'webhooks/instagram', to: 'webhooks/instagram#events'
post 'webhooks/tiktok', to: 'webhooks/tiktok#events'
post 'webhooks/shopify', to: 'webhooks/shopify#events'
post 'webhooks/wuzapi/:inbox_id', to: 'webhooks/wuzapi#process_payload'
namespace :twitter do
resource :callback, only: [:show]
@ -657,6 +676,15 @@ Rails.application.routes.draw do
post 'onboarding', to: 'onboarding#create'
end
# ---------------------------------------------------------------------
# Página pública de pagamento PIX (link curto gerado via SGID pela IA)
get '/r/:token', to: 'public/api/v1/captain/payments#show', as: :short_payment_link
# Webhook do Banco Inter para notificações de PIX pago
post '/api/v1/captain/webhooks/inter_pix',
to: 'public/api/v1/captain/inter_webhooks#create',
defaults: { format: 'json' }
# ---------------------------------------------------------------------
# Routes for swagger docs
get '/swagger/*path', to: 'swagger#respond'

View File

@ -61,6 +61,13 @@ bulk_auto_assignment_job:
class: 'Inboxes::BulkAutoAssignmentJob'
queue: scheduled_jobs
# executed every 10 minutes
# proactively checks pending PIX charges (Inter) while txid is still valid
captain_proactive_pix_polling_job:
cron: '*/10 * * * *'
class: 'Captain::Payments::ProactivePixPollingSchedulerJob'
queue: scheduled_jobs
# executed every 30 minutes for assignment_v2
periodic_assignment_job:
cron: '*/30 * * * *'

View File

@ -1,5 +0,0 @@
class AddNameToWebhooks < ActiveRecord::Migration[7.0]
def change
add_column :webhooks, :name, :string, null: true
end
end

View File

@ -0,0 +1,8 @@
class AddEncryptedWuzapiTokensToChannelWhatsapp < ActiveRecord::Migration[7.1]
def change
add_column :channel_whatsapp, :encrypted_wuzapi_user_token, :string
add_column :channel_whatsapp, :encrypted_wuzapi_user_token_iv, :string
add_column :channel_whatsapp, :encrypted_wuzapi_admin_token, :string
add_column :channel_whatsapp, :encrypted_wuzapi_admin_token_iv, :string
end
end

View File

@ -0,0 +1,8 @@
class RenameWuzapiTokens < ActiveRecord::Migration[7.0]
def change
rename_column :channel_whatsapp, :encrypted_wuzapi_user_token, :wuzapi_user_token
rename_column :channel_whatsapp, :encrypted_wuzapi_user_token_iv, :wuzapi_user_token_iv
rename_column :channel_whatsapp, :encrypted_wuzapi_admin_token, :wuzapi_admin_token
rename_column :channel_whatsapp, :encrypted_wuzapi_admin_token_iv, :wuzapi_admin_token_iv
end
end

View File

@ -0,0 +1,6 @@
class AddCertContentToCaptainUnits < ActiveRecord::Migration[7.1]
def change
add_column :captain_units, :inter_cert_content, :text
add_column :captain_units, :inter_key_content, :text
end
end

View File

@ -0,0 +1,5 @@
class AddWebhookConfiguredAtToCaptainUnits < ActiveRecord::Migration[7.1]
def change
add_column :captain_units, :webhook_configured_at, :datetime
end
end

Some files were not shown because too many files have changed in this diff Show More