chore(style): fix rubocop offenses and update typing indicators
This commit is contained in:
parent
c026ee2fc8
commit
0e7dc282c4
352
.env.example
352
.env.example
@ -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
120
.github/workflows/deploy_ghcr.yml
vendored
Normal 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
25
.gitignore
vendored
@ -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*
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
158
.rubocop_todo.yml
Normal 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
259
AGENTS.md
@ -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
|
||||
- Don’t 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 -->
|
||||
|
||||
@ -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
|
||||
|
||||
101
app/controllers/api/v1/accounts/captain/units_controller.rb
Normal file
101
app/controllers/api/v1/accounts/captain/units_controller.rb
Normal 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
|
||||
133
app/controllers/api/v1/accounts/inboxes/wuzapi_controller.rb
Normal file
133
app/controllers/api/v1/accounts/inboxes/wuzapi_controller.rb
Normal 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
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
13
app/controllers/public/api/v1/captain/payments_controller.rb
Normal file
13
app/controllers/public/api/v1/captain/payments_controller.rb
Normal 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
|
||||
37
app/controllers/webhooks/wuzapi_controller.rb
Normal file
37
app/controllers/webhooks/wuzapi_controller.rb
Normal 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
|
||||
35
app/javascript/dashboard/api/captain/galleryItems.js
Normal file
35
app/javascript/dashboard/api/captain/galleryItems.js
Normal 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();
|
||||
26
app/javascript/dashboard/api/captain/reservations.js
Normal file
26
app/javascript/dashboard/api/captain/reservations.js
Normal 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();
|
||||
29
app/javascript/dashboard/api/captain/units.js
Normal file
29
app/javascript/dashboard/api/captain/units.js
Normal 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();
|
||||
96
app/javascript/dashboard/api/inbox/jasmine.js
Normal file
96
app/javascript/dashboard/api/inbox/jasmine.js
Normal 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();
|
||||
@ -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)"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 },
|
||||
}),
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
@ -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' },
|
||||
|
||||
@ -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,
|
||||
];
|
||||
|
||||
281
app/javascript/dashboard/i18n/locale/en/captain.json
Normal file
281
app/javascript/dashboard/i18n/locale/en/captain.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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 you’re 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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
81
app/javascript/dashboard/i18n/locale/en/jasmine.json
Normal file
81
app/javascript/dashboard/i18n/locale/en/jasmine.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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.",
|
||||
|
||||
281
app/javascript/dashboard/i18n/locale/pt_BR/captain.json
Normal file
281
app/javascript/dashboard/i18n/locale/pt_BR/captain.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
84
app/javascript/dashboard/i18n/locale/pt_BR/jasmine.json
Normal file
84
app/javascript/dashboard/i18n/locale/pt_BR/jasmine.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@ -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>
|
||||
@ -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')"
|
||||
|
||||
@ -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>
|
||||
@ -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'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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();
|
||||
},
|
||||
setup() {
|
||||
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),
|
||||
return { v$: useVuelidate(), isEnterprise };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedAgents: [],
|
||||
isAgentListUpdating: false,
|
||||
enableAutoAssignment: false,
|
||||
maxAssignmentLimit: null,
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(rules, { maxAssignmentLimit });
|
||||
|
||||
const maxAssignmentLimitErrors = computed(() => {
|
||||
if (v$.value.maxAssignmentLimit.$error) {
|
||||
return t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_RANGE_ERROR');
|
||||
},
|
||||
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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
29
app/javascript/dashboard/store/captain/reservations.js
Normal file
29
app/javascript/dashboard/store/captain/reservations.js
Normal 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 });
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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,
|
||||
};
|
||||
83
app/javascript/dashboard/store/modules/captainUnits.js
Normal file
83
app/javascript/dashboard/store/modules/captainUnits.js
Normal 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,
|
||||
};
|
||||
@ -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',
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
69
app/models/captain/unit.rb
Normal file
69
app/models/captain/unit.rb
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
156
app/services/evolution_api/client.rb
Normal file
156
app/services/evolution_api/client.rb
Normal 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
|
||||
145
app/services/whatsapp/decryption_service.rb
Normal file
145
app/services/whatsapp/decryption_service.rb
Normal 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
|
||||
322
app/services/whatsapp/incoming_message_wuzapi_service.rb
Normal file
322
app/services/whatsapp/incoming_message_wuzapi_service.rb
Normal 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
|
||||
161
app/services/whatsapp/providers/evolution_api/payload_parser.rb
Normal file
161
app/services/whatsapp/providers/evolution_api/payload_parser.rb
Normal 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
|
||||
103
app/services/whatsapp/providers/evolution_service.rb
Normal file
103
app/services/whatsapp/providers/evolution_service.rb
Normal 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
|
||||
276
app/services/whatsapp/providers/wuzapi/payload_parser.rb
Normal file
276
app/services/whatsapp/providers/wuzapi/payload_parser.rb
Normal 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
|
||||
187
app/services/whatsapp/providers/wuzapi_service.rb
Normal file
187
app/services/whatsapp/providers/wuzapi_service.rb
Normal 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
|
||||
286
app/services/wuzapi/client.rb
Normal file
286
app/services/wuzapi/client.rb
Normal 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
|
||||
34
app/services/wuzapi/provisioning_service.rb
Normal file
34
app/services/wuzapi/provisioning_service.rb
Normal 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
|
||||
@ -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
|
||||
|
||||
104
app/views/public/api/v1/captain/payments/show.html.erb
Normal file
104
app/views/public/api/v1/captain/payments/show.html.erb
Normal 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>
|
||||
@ -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
15
config/application.yml
Normal 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
|
||||
@ -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'
|
||||
|
||||
@ -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 * * * *'
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
class AddNameToWebhooks < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :webhooks, :name, :string, null: true
|
||||
end
|
||||
end
|
||||
@ -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
|
||||
8
db/migrate/20251227230000_rename_wuzapi_tokens.rb
Normal file
8
db/migrate/20251227230000_rename_wuzapi_tokens.rb
Normal 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
|
||||
@ -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
|
||||
@ -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
Loading…
Reference in New Issue
Block a user