Compare commits
101
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a954e1eaca | ||
|
|
59e0499616 | ||
|
|
de8fdb1377 | ||
|
|
fe20b61bc4 | ||
|
|
49715ebf2b | ||
|
|
0b156f6d4e | ||
|
|
630826baed | ||
|
|
72509f9e38 | ||
|
|
c6d4bc5632 | ||
|
|
5c00880857 | ||
|
|
4da92bbc12 | ||
|
|
ad17887898 | ||
|
|
458bf1803e | ||
|
|
ebef021e94 | ||
|
|
3da26ee025 | ||
|
|
1140ca7a78 | ||
|
|
79fc5bb555 | ||
|
|
78a40114ef | ||
|
|
a1f61f0e21 | ||
|
|
befdfb0ae6 | ||
|
|
e0097ab102 | ||
|
|
bdcb080e40 | ||
|
|
50344a282b | ||
|
|
65cd0717e6 | ||
|
|
73dcf539ed | ||
|
|
35d9dc925f | ||
|
|
2d14f27d5b | ||
|
|
70a65e2c34 | ||
|
|
7fe888fa51 | ||
|
|
91cd42583e | ||
|
|
4304e06748 | ||
|
|
e2d88dc1b8 | ||
|
|
d7590d0548 | ||
|
|
d827e66453 | ||
|
|
ae0b68147e | ||
|
|
1a78a9243f | ||
|
|
196bdf15af | ||
|
|
246deab684 | ||
|
|
7a24672b66 | ||
|
|
0dc2af3c78 | ||
|
|
8bf2081aff | ||
|
|
c35edc9c49 | ||
|
|
d7de73ce5f | ||
|
|
ca83a27e95 | ||
|
|
f20a18b03f | ||
|
|
cc4d54becf | ||
|
|
9fb3053007 | ||
|
|
4e58a2a91d | ||
|
|
0175714d65 | ||
|
|
001b25c92a | ||
|
|
91fa68bbb5 | ||
|
|
21fbed32eb | ||
|
|
2fd54b8d9d | ||
|
|
9761214860 | ||
|
|
5951c4b985 | ||
|
|
4b4d9f8f7c | ||
|
|
49ee147fe3 | ||
|
|
d9450fde4a | ||
|
|
157ec00994 | ||
|
|
d797fe34fb | ||
|
|
8826a7066c | ||
|
|
50efd28d16 | ||
|
|
41d6f9a200 | ||
|
|
07d19362d2 | ||
|
|
abf69c5134 | ||
|
|
bc5f1722e1 | ||
|
|
c2946415c8 | ||
|
|
3c85b79d2a | ||
|
|
6757b025f4 | ||
|
|
d355801555 | ||
|
|
950d9f50a5 | ||
|
|
3a693947b5 | ||
|
|
e4ea078e52 | ||
|
|
280bc58963 | ||
|
|
5745ffbcb0 | ||
|
|
b5deac468e | ||
|
|
4bce0a5fae | ||
|
|
359b992683 | ||
|
|
41be634db0 | ||
|
|
9cf2bd284b | ||
|
|
91b7785dfe | ||
|
|
220014c290 | ||
|
|
48dd487db3 | ||
|
|
0cbcb8b80c | ||
|
|
16e2c33db5 | ||
|
|
3cf3aca760 | ||
|
|
2310d92805 | ||
|
|
3eee499093 | ||
|
|
46289a6491 | ||
|
|
b2debdc912 | ||
|
|
46297ce85e | ||
|
|
f82dfee0a5 | ||
|
|
57a571ea87 | ||
|
|
3a4249da11 | ||
|
|
8f39e62570 | ||
|
|
7520ca7a99 | ||
|
|
35f4e63605 | ||
|
|
5773089865 | ||
|
|
d01c7d3fa7 | ||
|
|
959d2c0d8c | ||
|
|
622f29a0b7 |
Binary file not shown.
|
Before Width: | Height: | Size: 285 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 966 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 934 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
@@ -10,7 +10,7 @@ on:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# #
|
||||
# # Linux nightly installer action
|
||||
# # This action will try to install and setup
|
||||
# # chatwoot on an Ubuntu 20.04 machine using
|
||||
# # chatwoot on an Ubuntu 22.04 machine using
|
||||
# # the linux installer script.
|
||||
# #
|
||||
# # This is set to run daily at midnight.
|
||||
|
||||
@@ -9,7 +9,7 @@ on:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-22.04
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg15
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -91,3 +91,6 @@ yarn-debug.log*
|
||||
# Vite uses dotenv and suggests to ignore local-only env files. See
|
||||
# https://vitejs.dev/guide/env-and-mode.html#env-files
|
||||
*.local
|
||||
|
||||
# Claude.ai config file
|
||||
CLAUDE.md
|
||||
|
||||
@@ -173,6 +173,7 @@ gem 'pgvector'
|
||||
# Convert Website HTML to Markdown
|
||||
gem 'reverse_markdown'
|
||||
|
||||
gem 'iso-639'
|
||||
gem 'ruby-openai'
|
||||
|
||||
gem 'shopify_api'
|
||||
|
||||
+7
-4
@@ -378,6 +378,8 @@ GEM
|
||||
io-console (0.6.0)
|
||||
irb (1.7.2)
|
||||
reline (>= 0.3.6)
|
||||
iso-639 (0.3.8)
|
||||
csv
|
||||
jbuilder (2.11.5)
|
||||
actionview (>= 5.0.0)
|
||||
activesupport (>= 5.0.0)
|
||||
@@ -499,14 +501,14 @@ GEM
|
||||
newrelic_rpm (9.6.0)
|
||||
base64
|
||||
nio4r (2.7.3)
|
||||
nokogiri (1.18.3)
|
||||
nokogiri (1.18.4)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.3-arm64-darwin)
|
||||
nokogiri (1.18.4-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.3-x86_64-darwin)
|
||||
nokogiri (1.18.4-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.3-x86_64-linux-gnu)
|
||||
nokogiri (1.18.4-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
oauth (1.1.0)
|
||||
oauth-tty (~> 1.0, >= 1.0.1)
|
||||
@@ -913,6 +915,7 @@ DEPENDENCIES
|
||||
hashie
|
||||
html2text
|
||||
image_processing
|
||||
iso-639
|
||||
jbuilder
|
||||
json_refs
|
||||
json_schemer
|
||||
|
||||
@@ -1,22 +1,11 @@
|
||||
## 🚨 Note: This branch is unstable. For the stable branch's source code, please use the branch [3.x](https://github.com/chatwoot/chatwoot/tree/3.x)
|
||||
|
||||
|
||||
<img src="https://user-images.githubusercontent.com/2246121/282256557-1570674b-d142-4198-9740-69404cc6a339.png#gh-light-mode-only" width="100%" alt="Chat dashboard dark mode"/>
|
||||
<img src="https://user-images.githubusercontent.com/2246121/282256632-87f6a01b-6467-4e0e-8a93-7bbf66d03a17.png#gh-dark-mode-only" width="100%" alt="Chat dashboard"/>
|
||||
<img src="./.github/screenshots/header.png#gh-light-mode-only" width="100%" alt="Header light mode"/>
|
||||
<img src="./.github/screenshots/header-dark.png#gh-dark-mode-only" width="100%" alt="Header dark mode"/>
|
||||
|
||||
___
|
||||
|
||||
# Chatwoot
|
||||
|
||||
Customer engagement suite, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
|
||||
<p>
|
||||
<a href="https://heroku.com/deploy?template=https://github.com/chatwoot/chatwoot/tree/master" alt="Deploy to Heroku">
|
||||
<img width="150" alt="Deploy" src="https://www.herokucdn.com/deploy/button.svg"/>
|
||||
</a>
|
||||
<a href="https://marketplace.digitalocean.com/apps/chatwoot?refcode=f2238426a2a8" alt="Deploy to DigitalOcean">
|
||||
<img width="200" alt="Deploy to DO" src="https://www.deploytodo.com/do-btn-blue.svg"/>
|
||||
</a>
|
||||
</p>
|
||||
The modern customer support platform, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
|
||||
|
||||
<p>
|
||||
<a href="https://codeclimate.com/github/chatwoot/chatwoot/maintainability"><img src="https://api.codeclimate.com/v1/badges/e6e3f66332c91e5a4c0c/maintainability" alt="Maintainability"></a>
|
||||
@@ -31,41 +20,71 @@ Customer engagement suite, an open-source alternative to Intercom, Zendesk, Sale
|
||||
<a href="https://artifacthub.io/packages/helm/chatwoot/chatwoot"><img src="https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/artifact-hub" alt="Artifact HUB"></a>
|
||||
</p>
|
||||
|
||||
<img src="https://user-images.githubusercontent.com/2246121/282255783-ee8a50c9-f42d-4752-8201-2d59965a663d.png#gh-light-mode-only" width="100%" alt="Chat dashboard dark mode"/>
|
||||
<img src="https://user-images.githubusercontent.com/2246121/282255784-3d1994ec-d895-4ff5-ac68-d819987e1869.png#gh-dark-mode-only" width="100%" alt="Chat dashboard"/>
|
||||
|
||||
Chatwoot is an open-source, self-hosted customer engagement suite. Chatwoot lets you view and manage your customer data, communicate with them irrespective of which medium they use, and re-engage them based on their profile.
|
||||
<p>
|
||||
<a href="https://heroku.com/deploy?template=https://github.com/chatwoot/chatwoot/tree/master" alt="Deploy to Heroku">
|
||||
<img width="150" alt="Deploy" src="https://www.herokucdn.com/deploy/button.svg"/>
|
||||
</a>
|
||||
<a href="https://marketplace.digitalocean.com/apps/chatwoot?refcode=f2238426a2a8" alt="Deploy to DigitalOcean">
|
||||
<img width="200" alt="Deploy to DO" src="https://www.deploytodo.com/do-btn-blue.svg"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## Features
|
||||
<img src="./.github/screenshots/dashboard.png#gh-light-mode-only" width="100%" alt="Chat dashboard dark mode"/>
|
||||
<img src="./.github/screenshots/dashboard-dark.png#gh-dark-mode-only" width="100%" alt="Chat dashboard"/>
|
||||
|
||||
Chatwoot supports the following conversation channels:
|
||||
---
|
||||
|
||||
- **Website**: Talk to your customers using our live chat widget and make use of our SDK to identify a user and provide contextual support.
|
||||
- **Facebook**: Connect your Facebook pages and start replying to the direct messages to your page.
|
||||
- **Instagram**: Connect your Instagram profile and start replying to the direct messages.
|
||||
- **Twitter**: Connect your Twitter profiles and reply to direct messages or the tweets where you are mentioned.
|
||||
- **Telegram**: Connect your Telegram bot and reply to your customers right from a single dashboard.
|
||||
- **WhatsApp**: Connect your WhatsApp business account and manage the conversation in Chatwoot.
|
||||
- **Line**: Connect your Line account and manage the conversations in Chatwoot.
|
||||
- **SMS**: Connect your Twilio SMS account and reply to the SMS queries in Chatwoot.
|
||||
- **API Channel**: Build custom communication channels using our API channel.
|
||||
- **Email**: Forward all your email queries to Chatwoot and view it in our integrated dashboard.
|
||||
Chatwoot is the modern, open-source, and self-hosted customer support platform designed to help businesses deliver exceptional customer support experience. Built for scale and flexibility, Chatwoot gives you full control over your customer data while providing powerful tools to manage conversations across channels.
|
||||
|
||||
And more.
|
||||
### ✨ Captain – AI Agent for Support
|
||||
|
||||
Other features include:
|
||||
Supercharge your support with Captain, Chatwoot’s AI agent. Captain helps automate responses, handle common queries, and reduce agent workload—ensuring customers get instant, accurate answers. With Captain, your team can focus on complex conversations while routine questions are resolved automatically. Read more about Captain [here](https://chwt.app/captain-docs).
|
||||
|
||||
### 💬 Omnichannel Support Desk
|
||||
|
||||
Chatwoot centralizes all customer conversations into one powerful inbox, no matter where your customers reach out from. It supports live chat on your website, email, Facebook, Instagram, Twitter, WhatsApp, Telegram, Line, SMS etc.
|
||||
|
||||
### 📚 Help center portal
|
||||
|
||||
Publish help articles, FAQs, and guides through the built-in Help Center Portal. Enable customers to find answers on their own, reduce repetitive queries, and keep your support team focused on more complex issues.
|
||||
|
||||
### 🗂️ Other features
|
||||
|
||||
#### Collaboration & Productivity
|
||||
|
||||
- Private Notes and @mentions for internal team discussions.
|
||||
- Labels to organize and categorize conversations.
|
||||
- Keyboard Shortcuts and a Command Bar for quick navigation.
|
||||
- Canned Responses to reply faster to frequently asked questions.
|
||||
- Auto-Assignment to route conversations based on agent availability.
|
||||
- Multi-lingual Support to serve customers in multiple languages.
|
||||
- Custom Views and Filters for better inbox organization.
|
||||
- Business Hours and Auto-Responders to manage response expectations.
|
||||
- Teams and Automation tools for scaling support workflows.
|
||||
- Agent Capacity Management to balance workload across the team.
|
||||
|
||||
#### Customer Data & Segmentation
|
||||
- Contact Management with profiles and interaction history.
|
||||
- Contact Segments and Notes for targeted communication.
|
||||
- Campaigns to proactively engage customers.
|
||||
- Custom Attributes for storing additional customer data.
|
||||
- Pre-Chat Forms to collect user information before starting conversations.
|
||||
|
||||
#### Integrations
|
||||
- Slack Integration to manage conversations directly from Slack.
|
||||
- Dialogflow Integration for chatbot automation.
|
||||
- Dashboard Apps to embed internal tools within Chatwoot.
|
||||
- Shopify Integration to view and manage customer orders right within Chatwoot.
|
||||
- Use Google Translate to translate messages from your customers in realtime.
|
||||
- Create and manage Linear tickets within Chatwoot.
|
||||
|
||||
#### Reports & Insights
|
||||
- Live View of ongoing conversations for real-time monitoring.
|
||||
- Conversation, Agent, Inbox, Label, and Team Reports for operational visibility.
|
||||
- CSAT Reports to measure customer satisfaction.
|
||||
- Downloadable Reports for offline analysis and reporting.
|
||||
|
||||
- **CRM**: Save all your customer information right inside Chatwoot, use contact notes to log emails, phone calls, or meeting notes.
|
||||
- **Custom Attributes**: Define custom attribute attributes to store information about a contact or a conversation and extend the product to match your workflow.
|
||||
- **Shared multi-brand inboxes**: Manage multiple brands or pages using a shared inbox.
|
||||
- **Private notes**: Use @mentions and private notes to communicate internally about a conversation.
|
||||
- **Canned responses (Saved replies)**: Improve the response rate by adding saved replies for frequently asked questions.
|
||||
- **Conversation Labels**: Use conversation labels to create custom workflows.
|
||||
- **Auto assignment**: Chatwoot intelligently assigns a ticket to the agents who have access to the inbox depending on their availability and load.
|
||||
- **Conversation continuity**: If the user has provided an email address through the chat widget, Chatwoot will send an email to the customer under the agent name so that the user can continue the conversation over the email.
|
||||
- **Multi-lingual support**: Chatwoot supports 10+ languages.
|
||||
- **Powerful API & Webhooks**: Extend the capability of the software using Chatwoot’s webhooks and APIs.
|
||||
- **Integrations**: Chatwoot natively integrates with Slack right now. Manage your conversations in Slack without logging into the dashboard.
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -107,13 +126,11 @@ For other supported options, checkout our [deployment page](https://chatwoot.com
|
||||
|
||||
Looking to report a vulnerability? Please refer our [SECURITY.md](./SECURITY.md) file.
|
||||
|
||||
|
||||
## Community? Questions? Support ?
|
||||
## Community
|
||||
|
||||
If you need help or just want to hang out, come, say hi on our [Discord](https://discord.gg/cJXdrwS) server.
|
||||
|
||||
|
||||
## Contributors ✨
|
||||
## Contributors
|
||||
|
||||
Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contributors):
|
||||
|
||||
|
||||
@@ -64,5 +64,40 @@ class ContactInboxBuilder
|
||||
inbox_id: @inbox.id,
|
||||
source_id: @source_id
|
||||
)
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
Rails.logger.info("[ContactInboxBuilder] RecordNotUnique #{@source_id} #{@contact.id} #{@inbox.id}")
|
||||
update_old_contact_inbox
|
||||
retry
|
||||
end
|
||||
|
||||
def update_old_contact_inbox
|
||||
# The race condition occurs when there’s a contact inbox with the
|
||||
# same source ID but linked to a different contact. This can happen
|
||||
# if the agent updates the contact’s email or phone number, or
|
||||
# if the contact is merged with another.
|
||||
#
|
||||
# We update the old contact inbox source_id to a random value to
|
||||
# avoid disrupting the current flow. However, the root cause of
|
||||
# this issue is a flaw in the contact inbox model design.
|
||||
# Contact inbox is essentially tracking a session and is not
|
||||
# needed for non-live chat channels.
|
||||
raise ActiveRecord::RecordNotUnique unless allowed_channels?
|
||||
|
||||
contact_inbox = ::ContactInbox.find_by(inbox_id: @inbox.id, source_id: @source_id)
|
||||
return if contact_inbox.blank?
|
||||
|
||||
contact_inbox.update!(source_id: new_source_id)
|
||||
end
|
||||
|
||||
def new_source_id
|
||||
if @inbox.whatsapp? || @inbox.sms? || @inbox.twilio?
|
||||
"whatsapp:#{@source_id}#{rand(100)}"
|
||||
else
|
||||
"#{rand(10)}#{@source_id}"
|
||||
end
|
||||
end
|
||||
|
||||
def allowed_channels?
|
||||
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -63,9 +63,33 @@ class ContactInboxWithContactBuilder
|
||||
contact = find_contact_by_identifier(contact_attributes[:identifier])
|
||||
contact ||= find_contact_by_email(contact_attributes[:email])
|
||||
contact ||= find_contact_by_phone_number(contact_attributes[:phone_number])
|
||||
contact ||= find_contact_by_instagram_source_id(source_id) if instagram_channel?
|
||||
|
||||
contact
|
||||
end
|
||||
|
||||
def instagram_channel?
|
||||
inbox.channel_type == 'Channel::Instagram'
|
||||
end
|
||||
|
||||
# There might be existing contact_inboxes created through Channel::FacebookPage
|
||||
# with the same Instagram source_id. New Instagram interactions should create fresh contact_inboxes
|
||||
# while still reusing contacts if found in Facebook channels so that we can create
|
||||
# new conversations with the same contact.
|
||||
def find_contact_by_instagram_source_id(instagram_id)
|
||||
return if instagram_id.blank?
|
||||
|
||||
existing_contact_inbox = ContactInbox.joins(:inbox)
|
||||
.where(source_id: instagram_id)
|
||||
.where(
|
||||
'inboxes.channel_type = ? AND inboxes.account_id = ?',
|
||||
'Channel::FacebookPage',
|
||||
account.id
|
||||
).first
|
||||
|
||||
existing_contact_inbox&.contact
|
||||
end
|
||||
|
||||
def find_contact_by_identifier(identifier)
|
||||
return if identifier.blank?
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuilder
|
||||
attr_reader :messaging
|
||||
|
||||
def initialize(messaging, inbox, outgoing_echo: false)
|
||||
super()
|
||||
@messaging = messaging
|
||||
@inbox = inbox
|
||||
@outgoing_echo = outgoing_echo
|
||||
end
|
||||
|
||||
def perform
|
||||
return if @inbox.channel.reauthorization_required?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
build_message
|
||||
end
|
||||
rescue StandardError => e
|
||||
handle_error(e)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def attachments
|
||||
@messaging[:message][:attachments] || {}
|
||||
end
|
||||
|
||||
def message_type
|
||||
@outgoing_echo ? :outgoing : :incoming
|
||||
end
|
||||
|
||||
def message_identifier
|
||||
message[:mid]
|
||||
end
|
||||
|
||||
def message_source_id
|
||||
@outgoing_echo ? recipient_id : sender_id
|
||||
end
|
||||
|
||||
def message_is_unsupported?
|
||||
message[:is_unsupported].present? && @messaging[:message][:is_unsupported] == true
|
||||
end
|
||||
|
||||
def sender_id
|
||||
@messaging[:sender][:id]
|
||||
end
|
||||
|
||||
def recipient_id
|
||||
@messaging[:recipient][:id]
|
||||
end
|
||||
|
||||
def message
|
||||
@messaging[:message]
|
||||
end
|
||||
|
||||
def contact
|
||||
@contact ||= @inbox.contact_inboxes.find_by(source_id: message_source_id)&.contact
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= set_conversation_based_on_inbox_config
|
||||
end
|
||||
|
||||
def set_conversation_based_on_inbox_config
|
||||
if @inbox.lock_to_single_conversation
|
||||
find_conversation_scope.order(created_at: :desc).first || build_conversation
|
||||
else
|
||||
find_or_build_for_multiple_conversations
|
||||
end
|
||||
end
|
||||
|
||||
def find_conversation_scope
|
||||
Conversation.where(conversation_params)
|
||||
end
|
||||
|
||||
def find_or_build_for_multiple_conversations
|
||||
last_conversation = find_conversation_scope.where.not(status: :resolved).order(created_at: :desc).first
|
||||
return build_conversation if last_conversation.nil?
|
||||
|
||||
last_conversation
|
||||
end
|
||||
|
||||
def message_content
|
||||
@messaging[:message][:text]
|
||||
end
|
||||
|
||||
def story_reply_attributes
|
||||
message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present?
|
||||
end
|
||||
|
||||
def message_reply_attributes
|
||||
message[:reply_to][:mid] if message[:reply_to].present? && message[:reply_to][:mid].present?
|
||||
end
|
||||
|
||||
def build_message
|
||||
# Duplicate webhook events may be sent for the same message
|
||||
# when a user is connected to the Instagram account through both Messenger and Instagram login.
|
||||
# There is chance for echo events to be sent for the same message.
|
||||
# Therefore, we need to check if the message already exists before creating it.
|
||||
return if message_already_exists?
|
||||
|
||||
return if message_content.blank? && all_unsupported_files?
|
||||
|
||||
@message = conversation.messages.create!(message_params)
|
||||
save_story_id
|
||||
|
||||
attachments.each do |attachment|
|
||||
process_attachment(attachment)
|
||||
end
|
||||
end
|
||||
|
||||
def save_story_id
|
||||
return if story_reply_attributes.blank?
|
||||
|
||||
@message.save_story_info(story_reply_attributes)
|
||||
end
|
||||
|
||||
def build_conversation
|
||||
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
|
||||
Conversation.create!(conversation_params.merge(
|
||||
contact_inbox_id: @contact_inbox.id,
|
||||
additional_attributes: additional_conversation_attributes
|
||||
))
|
||||
end
|
||||
|
||||
def additional_conversation_attributes
|
||||
{}
|
||||
end
|
||||
|
||||
def conversation_params
|
||||
{
|
||||
account_id: @inbox.account_id,
|
||||
inbox_id: @inbox.id,
|
||||
contact_id: contact.id
|
||||
}
|
||||
end
|
||||
|
||||
def message_params
|
||||
params = {
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
message_type: message_type,
|
||||
source_id: message_identifier,
|
||||
content: message_content,
|
||||
sender: @outgoing_echo ? nil : contact,
|
||||
content_attributes: {
|
||||
in_reply_to_external_id: message_reply_attributes
|
||||
}
|
||||
}
|
||||
|
||||
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
|
||||
params
|
||||
end
|
||||
|
||||
def message_already_exists?
|
||||
cw_message = conversation.messages.where(
|
||||
source_id: @messaging[:message][:mid]
|
||||
).first
|
||||
|
||||
cw_message.present?
|
||||
end
|
||||
|
||||
def all_unsupported_files?
|
||||
return if attachments.empty?
|
||||
|
||||
attachments_type = attachments.pluck(:type).uniq.first
|
||||
unsupported_file_type?(attachments_type)
|
||||
end
|
||||
|
||||
def handle_error(error)
|
||||
ChatwootExceptionTracker.new(error, account: @inbox.account).capture_exception
|
||||
true
|
||||
end
|
||||
|
||||
# Abstract methods to be implemented by subclasses
|
||||
def get_story_object_from_source_id(source_id)
|
||||
raise NotImplementedError
|
||||
end
|
||||
end
|
||||
@@ -1,200 +1,42 @@
|
||||
# This class creates both outgoing messages from chatwoot and echo outgoing messages based on the flag `outgoing_echo`
|
||||
# Assumptions
|
||||
# 1. Incase of an outgoing message which is echo, source_id will NOT be nil,
|
||||
# based on this we are showing "not sent from chatwoot" message in frontend
|
||||
# Hence there is no need to set user_id in message for outgoing echo messages.
|
||||
|
||||
class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
attr_reader :messaging
|
||||
|
||||
class Messages::Instagram::MessageBuilder < Messages::Instagram::BaseMessageBuilder
|
||||
def initialize(messaging, inbox, outgoing_echo: false)
|
||||
super()
|
||||
@messaging = messaging
|
||||
@inbox = inbox
|
||||
@outgoing_echo = outgoing_echo
|
||||
end
|
||||
|
||||
def perform
|
||||
return if @inbox.channel.reauthorization_required?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
build_message
|
||||
end
|
||||
rescue Koala::Facebook::AuthenticationError => e
|
||||
Rails.logger.warn("Instagram authentication error for inbox: #{@inbox.id} with error: #{e.message}")
|
||||
Rails.logger.error e
|
||||
@inbox.channel.authorization_error!
|
||||
raise
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
|
||||
true
|
||||
super(messaging, inbox, outgoing_echo: outgoing_echo)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def attachments
|
||||
@messaging[:message][:attachments] || {}
|
||||
def get_story_object_from_source_id(source_id)
|
||||
url = "#{base_uri}/#{source_id}?fields=story,from&access_token=#{@inbox.channel.access_token}"
|
||||
|
||||
response = HTTParty.get(url)
|
||||
|
||||
return JSON.parse(response.body).with_indifferent_access if response.success?
|
||||
|
||||
# Create message first if it doesn't exist
|
||||
@message ||= conversation.messages.create!(message_params)
|
||||
handle_error_response(response)
|
||||
nil
|
||||
end
|
||||
|
||||
def message_type
|
||||
@outgoing_echo ? :outgoing : :incoming
|
||||
end
|
||||
def handle_error_response(response)
|
||||
parsed_response = JSON.parse(response.body)
|
||||
error_code = parsed_response.dig('error', 'code')
|
||||
|
||||
def message_identifier
|
||||
message[:mid]
|
||||
end
|
||||
# https://developers.facebook.com/docs/messenger-platform/error-codes
|
||||
# Access token has expired or become invalid.
|
||||
channel.authorization_error! if error_code == 190
|
||||
|
||||
def message_source_id
|
||||
@outgoing_echo ? recipient_id : sender_id
|
||||
end
|
||||
|
||||
def message_is_unsupported?
|
||||
message[:is_unsupported].present? && @messaging[:message][:is_unsupported] == true
|
||||
end
|
||||
|
||||
def sender_id
|
||||
@messaging[:sender][:id]
|
||||
end
|
||||
|
||||
def recipient_id
|
||||
@messaging[:recipient][:id]
|
||||
end
|
||||
|
||||
def message
|
||||
@messaging[:message]
|
||||
end
|
||||
|
||||
def contact
|
||||
@contact ||= @inbox.contact_inboxes.find_by(source_id: message_source_id)&.contact
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= set_conversation_based_on_inbox_config
|
||||
end
|
||||
|
||||
def instagram_direct_message_conversation
|
||||
Conversation.where(conversation_params)
|
||||
.where("additional_attributes ->> 'type' = 'instagram_direct_message'")
|
||||
end
|
||||
|
||||
def set_conversation_based_on_inbox_config
|
||||
if @inbox.lock_to_single_conversation
|
||||
instagram_direct_message_conversation.order(created_at: :desc).first || build_conversation
|
||||
else
|
||||
find_or_build_for_multiple_conversations
|
||||
# There was a problem scraping data from the provided link.
|
||||
# https://developers.facebook.com/docs/graph-api/guides/error-handling/ search for error code 1609005
|
||||
if error_code == 1_609_005
|
||||
@message.attachments.destroy_all
|
||||
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
|
||||
end
|
||||
|
||||
Rails.logger.error("[InstagramStoryFetchError]: #{parsed_response.dig('error', 'message')} #{error_code}")
|
||||
end
|
||||
|
||||
def find_or_build_for_multiple_conversations
|
||||
last_conversation = instagram_direct_message_conversation.where.not(status: :resolved).order(created_at: :desc).first
|
||||
|
||||
return build_conversation if last_conversation.nil?
|
||||
|
||||
last_conversation
|
||||
def base_uri
|
||||
"https://graph.instagram.com/#{GlobalConfigService.load('INSTAGRAM_API_VERSION', 'v22.0')}"
|
||||
end
|
||||
|
||||
def message_content
|
||||
@messaging[:message][:text]
|
||||
end
|
||||
|
||||
def story_reply_attributes
|
||||
message[:reply_to][:story] if message[:reply_to].present? && message[:reply_to][:story].present?
|
||||
end
|
||||
|
||||
def message_reply_attributes
|
||||
message[:reply_to][:mid] if message[:reply_to].present? && message[:reply_to][:mid].present?
|
||||
end
|
||||
|
||||
def build_message
|
||||
return if @outgoing_echo && already_sent_from_chatwoot?
|
||||
return if message_content.blank? && all_unsupported_files?
|
||||
|
||||
@message = conversation.messages.create!(message_params)
|
||||
save_story_id
|
||||
|
||||
attachments.each do |attachment|
|
||||
process_attachment(attachment)
|
||||
end
|
||||
end
|
||||
|
||||
def save_story_id
|
||||
return if story_reply_attributes.blank?
|
||||
|
||||
@message.save_story_info(story_reply_attributes)
|
||||
end
|
||||
|
||||
def build_conversation
|
||||
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
|
||||
|
||||
Conversation.create!(conversation_params.merge(
|
||||
contact_inbox_id: @contact_inbox.id,
|
||||
additional_attributes: { type: 'instagram_direct_message' }
|
||||
))
|
||||
end
|
||||
|
||||
def conversation_params
|
||||
{
|
||||
account_id: @inbox.account_id,
|
||||
inbox_id: @inbox.id,
|
||||
contact_id: contact.id
|
||||
}
|
||||
end
|
||||
|
||||
def message_params
|
||||
params = {
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
message_type: message_type,
|
||||
source_id: message_identifier,
|
||||
content: message_content,
|
||||
sender: @outgoing_echo ? nil : contact,
|
||||
content_attributes: {
|
||||
in_reply_to_external_id: message_reply_attributes
|
||||
}
|
||||
}
|
||||
|
||||
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
|
||||
params
|
||||
end
|
||||
|
||||
def already_sent_from_chatwoot?
|
||||
cw_message = conversation.messages.where(
|
||||
source_id: @messaging[:message][:mid]
|
||||
).first
|
||||
|
||||
cw_message.present?
|
||||
end
|
||||
|
||||
def all_unsupported_files?
|
||||
return if attachments.empty?
|
||||
|
||||
attachments_type = attachments.pluck(:type).uniq.first
|
||||
unsupported_file_type?(attachments_type)
|
||||
end
|
||||
|
||||
### Sample response
|
||||
# {
|
||||
# "object": "instagram",
|
||||
# "entry": [
|
||||
# {
|
||||
# "id": "<IGID>",// ig id of the business
|
||||
# "time": 1569262486134,
|
||||
# "messaging": [
|
||||
# {
|
||||
# "sender": {
|
||||
# "id": "<IGSID>"
|
||||
# },
|
||||
# "recipient": {
|
||||
# "id": "<IGID>"
|
||||
# },
|
||||
# "timestamp": 1569262485349,
|
||||
# "message": {
|
||||
# "mid": "<MESSAGE_ID>",
|
||||
# "text": "<MESSAGE_CONTENT>"
|
||||
# }
|
||||
# }
|
||||
# ]
|
||||
# }
|
||||
# ],
|
||||
# }
|
||||
end
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
class Messages::Instagram::Messenger::MessageBuilder < Messages::Instagram::BaseMessageBuilder
|
||||
def initialize(messaging, inbox, outgoing_echo: false)
|
||||
super(messaging, inbox, outgoing_echo: outgoing_echo)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def get_story_object_from_source_id(source_id)
|
||||
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
|
||||
k.get_object(source_id, fields: %w[story from]) || {}
|
||||
rescue Koala::Facebook::AuthenticationError
|
||||
@inbox.channel.authorization_error!
|
||||
raise
|
||||
rescue Koala::Facebook::ClientError => e
|
||||
# The exception occurs when we are trying fetch the deleted story or blocked story.
|
||||
@message.attachments.destroy_all
|
||||
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
|
||||
Rails.logger.error e
|
||||
{}
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
|
||||
{}
|
||||
end
|
||||
|
||||
def find_conversation_scope
|
||||
Conversation.where(conversation_params)
|
||||
.where("additional_attributes ->> 'type' = 'instagram_direct_message'")
|
||||
end
|
||||
|
||||
def additional_conversation_attributes
|
||||
{ type: 'instagram_direct_message' }
|
||||
end
|
||||
end
|
||||
@@ -68,20 +68,8 @@ class Messages::Messenger::MessageBuilder
|
||||
message.save!
|
||||
end
|
||||
|
||||
def get_story_object_from_source_id(source_id)
|
||||
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
|
||||
k.get_object(source_id, fields: %w[story from]) || {}
|
||||
rescue Koala::Facebook::AuthenticationError
|
||||
@inbox.channel.authorization_error!
|
||||
raise
|
||||
rescue Koala::Facebook::ClientError => e
|
||||
# The exception occurs when we are trying fetch the deleted story or blocked story.
|
||||
@message.attachments.destroy_all
|
||||
@message.update(content: I18n.t('conversations.messages.instagram_deleted_story_content'))
|
||||
Rails.logger.error e
|
||||
{}
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
|
||||
# This is a placeholder method to be overridden by child classes
|
||||
def get_story_object_from_source_id(_source_id)
|
||||
{}
|
||||
end
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:name, :description, :outgoing_url, :avatar, :avatar_url, :bot_type, bot_config: [:csml_content])
|
||||
params.permit(:name, :description, :outgoing_url, :avatar, :avatar_url, :bot_type, bot_config: {})
|
||||
end
|
||||
|
||||
def process_avatar_from_url
|
||||
|
||||
@@ -72,7 +72,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def allowed_agent_params
|
||||
[:name, :email, :name, :role, :availability, :auto_offline]
|
||||
[:name, :email, :role, :availability, :auto_offline]
|
||||
end
|
||||
|
||||
def agent_params
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::Contacts::BaseController
|
||||
def index
|
||||
@conversations = Current.account.conversations.includes(
|
||||
# Start with all conversations for this contact
|
||||
conversations = Current.account.conversations.includes(
|
||||
:assignee, :contact, :inbox, :taggings
|
||||
).where(inbox_id: inbox_ids, contact_id: @contact.id).order(last_activity_at: :desc).limit(20)
|
||||
end
|
||||
).where(contact_id: @contact.id)
|
||||
|
||||
private
|
||||
# Apply permission-based filtering using the existing service
|
||||
conversations = Conversations::PermissionFilterService.new(
|
||||
conversations,
|
||||
Current.user,
|
||||
Current.account
|
||||
).perform
|
||||
|
||||
def inbox_ids
|
||||
if Current.user.administrator? || Current.user.agent?
|
||||
Current.user.assigned_inboxes.pluck(:id)
|
||||
else
|
||||
[]
|
||||
end
|
||||
# Only allow conversations from inboxes the user has access to
|
||||
inbox_ids = Current.user.assigned_inboxes.pluck(:id)
|
||||
conversations = conversations.where(inbox_id: inbox_ids)
|
||||
|
||||
@conversations = conversations.order(last_activity_at: :desc).limit(20)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -48,7 +48,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
end
|
||||
|
||||
def filter
|
||||
result = ::Conversations::FilterService.new(params.permit!, current_user).perform
|
||||
result = ::Conversations::FilterService.new(params.permit!, current_user, current_account).perform
|
||||
@conversations = result[:conversations]
|
||||
@conversations_count = result[:count]
|
||||
rescue CustomExceptions::CustomFilter::InvalidAttribute,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::BaseController
|
||||
include InstagramConcern
|
||||
include Instagram::IntegrationHelper
|
||||
before_action :check_authorization
|
||||
|
||||
def create
|
||||
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#step-1--get-authorization
|
||||
redirect_url = instagram_client.auth_code.authorize_url(
|
||||
{
|
||||
redirect_uri: "#{base_url}/instagram/callback",
|
||||
scope: REQUIRED_SCOPES.join(','),
|
||||
enable_fb_login: '0',
|
||||
force_authentication: '1',
|
||||
response_type: 'code',
|
||||
state: generate_instagram_token(Current.account.id)
|
||||
}
|
||||
)
|
||||
if redirect_url
|
||||
render json: { success: true, url: redirect_url }
|
||||
else
|
||||
render json: { success: false }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_authorization
|
||||
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
|
||||
end
|
||||
end
|
||||
@@ -9,11 +9,6 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
@portals = Current.account.portals
|
||||
end
|
||||
|
||||
def add_members
|
||||
agents = Current.account.agents.where(id: portal_member_params[:member_ids])
|
||||
@portal.members << agents
|
||||
end
|
||||
|
||||
def show
|
||||
@all_articles = @portal.articles
|
||||
@articles = @all_articles.search(locale: params[:locale])
|
||||
@@ -85,10 +80,6 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
{ channel_web_widget_id: inbox.channel.id }
|
||||
end
|
||||
|
||||
def portal_member_params
|
||||
params.require(:portal).permit(:account_id, member_ids: [])
|
||||
end
|
||||
|
||||
def set_current_page
|
||||
@current_page = params[:page] || 1
|
||||
end
|
||||
|
||||
@@ -66,9 +66,7 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def check_authorization
|
||||
return if Current.account_user.administrator?
|
||||
|
||||
raise Pundit::NotAuthorizedError
|
||||
authorize :report, :view?
|
||||
end
|
||||
|
||||
def common_params
|
||||
@@ -137,5 +135,3 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
|
||||
end
|
||||
end
|
||||
|
||||
Api::V2::Accounts::ReportsController.prepend_mod_with('Api::V2::Accounts::ReportsController')
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
module Database::ConnectionDiagnostics
|
||||
extend ActiveSupport::Concern
|
||||
include Database::QueryInspection
|
||||
include Database::QueryLogging
|
||||
|
||||
private
|
||||
|
||||
def log_connection_pool_stats(connection_pool)
|
||||
{
|
||||
pool_size: connection_pool.size,
|
||||
active_connections: connection_pool.connections.count(&:in_use?),
|
||||
total_connections: connection_pool.connections.count,
|
||||
waiting_threads: connection_pool.num_waiting_in_queue,
|
||||
checkout_timeout: connection_pool.checkout_timeout
|
||||
}
|
||||
end
|
||||
|
||||
def connection_status_sql
|
||||
<<~SQL.squish
|
||||
SELECT count(*) as connection_count, state#{' '}
|
||||
FROM pg_stat_activity#{' '}
|
||||
GROUP BY state;
|
||||
SQL
|
||||
end
|
||||
|
||||
def fetch_connection_diagnostics
|
||||
{
|
||||
active_queries: fetch_active_queries,
|
||||
locked_queries: fetch_locked_queries,
|
||||
connection_status: ActiveRecord::Base.connection.execute(connection_status_sql).to_a
|
||||
}
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error fetching active query data: #{e.message}"
|
||||
{ active_queries: [], locked_queries: [] }
|
||||
end
|
||||
|
||||
def caller_info(exception)
|
||||
{
|
||||
process_id: Process.pid,
|
||||
thread_id: Thread.current.object_id,
|
||||
backtrace: exception.backtrace&.first(15) || []
|
||||
}
|
||||
end
|
||||
|
||||
def log_timeout_error(exception, connection_info)
|
||||
Rails.logger.error "ActiveRecord::ConnectionTimeoutError: #{exception.message}"
|
||||
Rails.logger.error "Connection Pool Stats: #{connection_info.except(:active_queries, :locked_queries).inspect}"
|
||||
log_active_queries(connection_info[:active_queries])
|
||||
log_locked_queries(connection_info[:locked_queries])
|
||||
end
|
||||
end
|
||||
@@ -1,91 +0,0 @@
|
||||
module Database::QueryInspection
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def active_queries_sql
|
||||
<<~SQL.squish
|
||||
SELECT pid,#{' '}
|
||||
now() - pg_stat_activity.query_start AS duration,
|
||||
query,
|
||||
state,
|
||||
wait_event_type,
|
||||
wait_event,
|
||||
backend_type,
|
||||
application_name,
|
||||
client_addr,
|
||||
usename
|
||||
FROM pg_stat_activity#{' '}
|
||||
WHERE state <> 'idle'
|
||||
AND query NOT ILIKE '%pg_stat_activity%'
|
||||
ORDER BY duration DESC;
|
||||
SQL
|
||||
end
|
||||
|
||||
def process_active_query_row(row)
|
||||
{
|
||||
pid: row['pid'],
|
||||
duration: row['duration'].to_s,
|
||||
state: row['state'],
|
||||
query: row['query'],
|
||||
wait_event_type: row['wait_event_type'],
|
||||
wait_event: row['wait_event'],
|
||||
backend_type: row['backend_type'],
|
||||
application_name: row['application_name'],
|
||||
client_addr: row['client_addr'],
|
||||
username: row['usename']
|
||||
}
|
||||
end
|
||||
|
||||
def fetch_active_queries
|
||||
query_data = ActiveRecord::Base.connection.execute(active_queries_sql)
|
||||
query_data.map { |row| process_active_query_row(row) }
|
||||
end
|
||||
|
||||
def locked_queries_sql
|
||||
<<~SQL.squish
|
||||
SELECT blocked_locks.pid AS blocked_pid,
|
||||
blocked_activity.usename AS blocked_user,
|
||||
blocking_locks.pid AS blocking_pid,
|
||||
blocking_activity.usename AS blocking_user,
|
||||
blocked_activity.query AS blocked_statement,
|
||||
blocking_activity.query AS blocking_statement,
|
||||
now() - blocking_activity.query_start AS blocking_duration
|
||||
FROM pg_catalog.pg_locks blocked_locks
|
||||
JOIN pg_catalog.pg_stat_activity blocked_activity ON blocked_activity.pid = blocked_locks.pid
|
||||
JOIN pg_catalog.pg_locks blocking_locks#{' '}
|
||||
ON blocking_locks.locktype = blocked_locks.locktype
|
||||
AND blocking_locks.DATABASE IS NOT DISTINCT FROM blocked_locks.DATABASE
|
||||
AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
|
||||
AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page
|
||||
AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple
|
||||
AND blocking_locks.virtualxid IS NOT DISTINCT FROM blocked_locks.virtualxid
|
||||
AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid
|
||||
AND blocking_locks.classid IS NOT DISTINCT FROM blocked_locks.classid
|
||||
AND blocking_locks.objid IS NOT DISTINCT FROM blocked_locks.objid
|
||||
AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid
|
||||
AND blocking_locks.pid != blocked_locks.pid
|
||||
JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pid
|
||||
WHERE NOT blocked_locks.granted;
|
||||
SQL
|
||||
end
|
||||
|
||||
def process_lock_row(row)
|
||||
{
|
||||
blocked_pid: row['blocked_pid'],
|
||||
blocked_user: row['blocked_user'],
|
||||
blocking_pid: row['blocking_pid'],
|
||||
blocking_user: row['blocking_user'],
|
||||
blocked_statement: row['blocked_statement'],
|
||||
blocking_statement: row['blocking_statement'],
|
||||
blocking_duration: row['blocking_duration'].to_s
|
||||
}
|
||||
end
|
||||
|
||||
def fetch_locked_queries
|
||||
lock_data = ActiveRecord::Base.connection.execute(locked_queries_sql)
|
||||
return [] if lock_data.count.zero?
|
||||
|
||||
lock_data.map { |row| process_lock_row(row) }
|
||||
end
|
||||
end
|
||||
@@ -1,33 +0,0 @@
|
||||
module Database::QueryLogging
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def log_query_details(query_info, index)
|
||||
Rails.logger.error "Query ##{index + 1} [PID: #{query_info[:pid]}] [Duration: #{query_info[:duration]}] [State: #{query_info[:state]}]:"
|
||||
Rails.logger.error "App: #{query_info[:application_name]} User: #{query_info[:username]} Client: #{query_info[:client_addr]}"
|
||||
Rails.logger.error "Waiting: #{query_info[:wait_event_type]} / #{query_info[:wait_event]}" if query_info[:wait_event_type].present?
|
||||
Rails.logger.error query_info[:query]
|
||||
end
|
||||
|
||||
def log_active_queries(active_queries)
|
||||
if active_queries.any?
|
||||
Rails.logger.error "Active Database Queries (#{active_queries.count}):"
|
||||
active_queries.each_with_index { |query_info, index| log_query_details(query_info, index) }
|
||||
else
|
||||
Rails.logger.error 'No active queries found or unable to retrieve query information'
|
||||
end
|
||||
end
|
||||
|
||||
def log_locked_queries(locked_queries)
|
||||
return if locked_queries.blank?
|
||||
|
||||
Rails.logger.error "Locked Queries (#{locked_queries.count}):"
|
||||
locked_queries.each_with_index do |lock_info, index|
|
||||
Rails.logger.error "Lock ##{index + 1}: PID #{lock_info[:blocked_pid]} blocked by PID #{lock_info[:blocking_pid]}"
|
||||
Rails.logger.error "Duration: #{lock_info[:blocking_duration]}"
|
||||
Rails.logger.error "Blocked query: #{lock_info[:blocked_statement]}"
|
||||
Rails.logger.error "Blocking query: #{lock_info[:blocking_statement]}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,74 @@
|
||||
module InstagramConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def instagram_client
|
||||
::OAuth2::Client.new(
|
||||
client_id,
|
||||
client_secret,
|
||||
{
|
||||
site: 'https://api.instagram.com',
|
||||
authorize_url: 'https://api.instagram.com/oauth/authorize',
|
||||
token_url: 'https://api.instagram.com/oauth/access_token',
|
||||
auth_scheme: :request_body,
|
||||
token_method: :post
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def client_id
|
||||
GlobalConfigService.load('INSTAGRAM_APP_ID', nil)
|
||||
end
|
||||
|
||||
def client_secret
|
||||
GlobalConfigService.load('INSTAGRAM_APP_SECRET', nil)
|
||||
end
|
||||
|
||||
def exchange_for_long_lived_token(short_lived_token)
|
||||
endpoint = 'https://graph.instagram.com/access_token'
|
||||
params = {
|
||||
grant_type: 'ig_exchange_token',
|
||||
client_secret: client_secret,
|
||||
access_token: short_lived_token,
|
||||
client_id: client_id
|
||||
}
|
||||
|
||||
make_api_request(endpoint, params, 'Failed to exchange token')
|
||||
end
|
||||
|
||||
def fetch_instagram_user_details(access_token)
|
||||
endpoint = 'https://graph.instagram.com/v22.0/me'
|
||||
params = {
|
||||
fields: 'id,username,user_id,name,profile_picture_url,account_type',
|
||||
access_token: access_token
|
||||
}
|
||||
|
||||
make_api_request(endpoint, params, 'Failed to fetch Instagram user details')
|
||||
end
|
||||
|
||||
def make_api_request(endpoint, params, error_prefix)
|
||||
response = HTTParty.get(
|
||||
endpoint,
|
||||
query: params,
|
||||
headers: { 'Accept' => 'application/json' }
|
||||
)
|
||||
|
||||
unless response.success?
|
||||
Rails.logger.error "#{error_prefix}. Status: #{response.code}, Body: #{response.body}"
|
||||
raise "#{error_prefix}: #{response.body}"
|
||||
end
|
||||
|
||||
begin
|
||||
JSON.parse(response.body)
|
||||
rescue JSON::ParserError => e
|
||||
ChatwootExceptionTracker.new(e).capture_exception
|
||||
Rails.logger.error "Invalid JSON response: #{response.body}"
|
||||
raise e
|
||||
end
|
||||
end
|
||||
|
||||
def base_url
|
||||
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
|
||||
end
|
||||
end
|
||||
@@ -1,10 +1,8 @@
|
||||
module RequestExceptionHandler
|
||||
extend ActiveSupport::Concern
|
||||
include Database::ConnectionDiagnostics
|
||||
|
||||
included do
|
||||
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
|
||||
rescue_from ActiveRecord::ConnectionTimeoutError, with: :handle_connection_timeout
|
||||
end
|
||||
|
||||
private
|
||||
@@ -20,36 +18,11 @@ module RequestExceptionHandler
|
||||
rescue ActionController::ParameterMissing => e
|
||||
log_handled_error(e)
|
||||
render_could_not_create_error(e.message)
|
||||
rescue ActiveRecord::ConnectionTimeoutError => e
|
||||
handle_connection_timeout(e)
|
||||
ensure
|
||||
# to address the thread variable leak issues in Puma/Thin webserver
|
||||
Current.reset
|
||||
end
|
||||
|
||||
def handle_connection_timeout(exception)
|
||||
connection_pool = ActiveRecord::Base.connection_pool
|
||||
connection_info = log_connection_pool_stats(connection_pool)
|
||||
|
||||
# Gather diagnostic info
|
||||
diagnostics = fetch_connection_diagnostics
|
||||
connection_info.merge!(diagnostics)
|
||||
connection_info[:caller_info] = caller_info(exception)
|
||||
|
||||
# Log error details
|
||||
log_timeout_error(exception, connection_info)
|
||||
|
||||
# Report to exception tracker
|
||||
ChatwootExceptionTracker.new(
|
||||
exception,
|
||||
user: Current.user,
|
||||
account: Current.account,
|
||||
additional_context: { connection_info: connection_info }
|
||||
).capture_exception
|
||||
|
||||
render_service_unavailable('Database connection timeout. Please try again later.')
|
||||
end
|
||||
|
||||
def render_unauthorized(message)
|
||||
render json: { error: message }, status: :unauthorized
|
||||
end
|
||||
@@ -70,10 +43,6 @@ module RequestExceptionHandler
|
||||
render json: { error: message }, status: :internal_server_error
|
||||
end
|
||||
|
||||
def render_service_unavailable(message)
|
||||
render json: { error: message }, status: :service_unavailable
|
||||
end
|
||||
|
||||
def render_record_invalid(exception)
|
||||
log_handled_error(exception)
|
||||
render json: {
|
||||
|
||||
@@ -36,7 +36,7 @@ class DashboardController < ActionController::Base
|
||||
'LOGOUT_REDIRECT_LINK',
|
||||
'DISABLE_USER_PROFILE_UPDATE',
|
||||
'DEPLOYMENT_ENV',
|
||||
'CSML_EDITOR_HOST', 'INSTALLATION_PRICING_PLAN'
|
||||
'INSTALLATION_PRICING_PLAN'
|
||||
).merge(app_config)
|
||||
end
|
||||
|
||||
@@ -65,6 +65,7 @@ class DashboardController < ActionController::Base
|
||||
VAPID_PUBLIC_KEY: VapidService.public_key,
|
||||
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
|
||||
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
|
||||
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
|
||||
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
|
||||
IS_ENTERPRISE: ChatwootApp.enterprise?,
|
||||
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
|
||||
|
||||
@@ -55,7 +55,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
||||
|
||||
def validate_business_account?
|
||||
# return true if the user is a business account, false if it is a gmail account
|
||||
auth_hash['info']['email'].exclude?('@gmail.com')
|
||||
auth_hash['info']['email'].downcase.exclude?('@gmail.com')
|
||||
end
|
||||
|
||||
def create_account_for_user
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
class Instagram::CallbacksController < ApplicationController
|
||||
include InstagramConcern
|
||||
include Instagram::IntegrationHelper
|
||||
|
||||
def show
|
||||
# Check if Instagram redirected with an error (user canceled authorization)
|
||||
# See: https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#canceled-authorization
|
||||
if params[:error].present?
|
||||
handle_authorization_error
|
||||
return
|
||||
end
|
||||
|
||||
process_successful_authorization
|
||||
rescue StandardError => e
|
||||
handle_error(e)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Process the authorization code and create inbox
|
||||
def process_successful_authorization
|
||||
@response = instagram_client.auth_code.get_token(
|
||||
oauth_code,
|
||||
redirect_uri: "#{base_url}/#{provider_name}/callback",
|
||||
grant_type: 'authorization_code'
|
||||
)
|
||||
|
||||
@long_lived_token_response = exchange_for_long_lived_token(@response.token)
|
||||
inbox, already_exists = find_or_create_inbox
|
||||
|
||||
if already_exists
|
||||
redirect_to app_instagram_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
|
||||
else
|
||||
redirect_to app_instagram_inbox_agents_url(account_id: account_id, inbox_id: inbox.id)
|
||||
end
|
||||
end
|
||||
|
||||
# Handle all errors that might occur during authorization
|
||||
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#sample-rejected-response
|
||||
def handle_error(error)
|
||||
Rails.logger.error("Instagram Channel creation Error: #{error.message}")
|
||||
ChatwootExceptionTracker.new(error).capture_exception
|
||||
|
||||
error_info = extract_error_info(error)
|
||||
redirect_to_error_page(error_info)
|
||||
end
|
||||
|
||||
# Extract error details from the exception
|
||||
def extract_error_info(error)
|
||||
if error.is_a?(OAuth2::Error)
|
||||
begin
|
||||
# Instagram returns JSON error response which we parse to extract error details
|
||||
JSON.parse(error.message)
|
||||
rescue JSON::ParseError
|
||||
# Fall back to a generic OAuth error if JSON parsing fails
|
||||
{ 'error_type' => 'OAuthException', 'code' => 400, 'error_message' => error.message }
|
||||
end
|
||||
else
|
||||
# For other unexpected errors
|
||||
{ 'error_type' => error.class.name, 'code' => 500, 'error_message' => error.message }
|
||||
end
|
||||
end
|
||||
|
||||
# Handles the case when a user denies permissions or cancels the authorization flow
|
||||
# Error parameters are documented at:
|
||||
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#canceled-authorization
|
||||
def handle_authorization_error
|
||||
error_info = {
|
||||
'error_type' => params[:error] || 'authorization_error',
|
||||
'code' => 400,
|
||||
'error_message' => params[:error_description] || 'Authorization was denied'
|
||||
}
|
||||
|
||||
Rails.logger.error("Instagram Authorization Error: #{error_info['error_message']}")
|
||||
redirect_to_error_page(error_info)
|
||||
end
|
||||
|
||||
# Centralized method to redirect to error page with appropriate parameters
|
||||
# This ensures consistent error handling across different error scenarios
|
||||
# Frontend will handle the error page based on the error_type
|
||||
def redirect_to_error_page(error_info)
|
||||
redirect_to app_new_instagram_inbox_url(
|
||||
account_id: account_id,
|
||||
error_type: error_info['error_type'],
|
||||
code: error_info['code'],
|
||||
error_message: error_info['error_message']
|
||||
)
|
||||
end
|
||||
|
||||
def find_or_create_inbox
|
||||
user_details = fetch_instagram_user_details(@long_lived_token_response['access_token'])
|
||||
channel_instagram = find_channel_by_instagram_id(user_details['user_id'].to_s)
|
||||
channel_exists = channel_instagram.present?
|
||||
|
||||
if channel_instagram
|
||||
update_channel(channel_instagram, user_details)
|
||||
else
|
||||
channel_instagram = create_channel_with_inbox(user_details)
|
||||
end
|
||||
|
||||
# reauthorize channel, this code path only triggers when instagram auth is successful
|
||||
# reauthorized will also update cache keys for the associated inbox
|
||||
channel_instagram.reauthorized!
|
||||
|
||||
[channel_instagram.inbox, channel_exists]
|
||||
end
|
||||
|
||||
def find_channel_by_instagram_id(instagram_id)
|
||||
Channel::Instagram.find_by(instagram_id: instagram_id, account: account)
|
||||
end
|
||||
|
||||
def update_channel(channel_instagram, user_details)
|
||||
expires_at = Time.current + @long_lived_token_response['expires_in'].seconds
|
||||
|
||||
channel_instagram.update!(
|
||||
access_token: @long_lived_token_response['access_token'],
|
||||
expires_at: expires_at
|
||||
)
|
||||
|
||||
# Update inbox name if username changed
|
||||
channel_instagram.inbox.update!(name: user_details['username'])
|
||||
channel_instagram
|
||||
end
|
||||
|
||||
def create_channel_with_inbox(user_details)
|
||||
ActiveRecord::Base.transaction do
|
||||
expires_at = Time.current + @long_lived_token_response['expires_in'].seconds
|
||||
|
||||
channel_instagram = Channel::Instagram.create!(
|
||||
access_token: @long_lived_token_response['access_token'],
|
||||
instagram_id: user_details['user_id'].to_s,
|
||||
account: account,
|
||||
expires_at: expires_at
|
||||
)
|
||||
|
||||
account.inboxes.create!(
|
||||
account: account,
|
||||
channel: channel_instagram,
|
||||
name: user_details['username']
|
||||
)
|
||||
|
||||
channel_instagram
|
||||
end
|
||||
end
|
||||
|
||||
def account_id
|
||||
return unless params[:state]
|
||||
|
||||
verify_instagram_token(params[:state])
|
||||
end
|
||||
|
||||
def oauth_code
|
||||
params[:code]
|
||||
end
|
||||
|
||||
def account
|
||||
@account ||= Account.find(account_id)
|
||||
end
|
||||
|
||||
def provider_name
|
||||
'instagram'
|
||||
end
|
||||
end
|
||||
@@ -43,6 +43,8 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
['MAILER_INBOUND_EMAIL_DOMAIN']
|
||||
when 'linear'
|
||||
%w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET]
|
||||
when 'instagram'
|
||||
%w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
|
||||
else
|
||||
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]
|
||||
end
|
||||
|
||||
@@ -15,6 +15,9 @@ class Webhooks::InstagramController < ActionController::API
|
||||
private
|
||||
|
||||
def valid_token?(token)
|
||||
token == GlobalConfigService.load('IG_VERIFY_TOKEN', '')
|
||||
# Validates against both IG_VERIFY_TOKEN (Instagram channel via Facebook page) and
|
||||
# INSTAGRAM_VERIFY_TOKEN (Instagram channel via direct Instagram login)
|
||||
token == GlobalConfigService.load('IG_VERIFY_TOKEN', '') ||
|
||||
token == GlobalConfigService.load('INSTAGRAM_VERIFY_TOKEN', '')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -81,7 +81,8 @@ class AccountDashboard < Administrate::BaseDashboard
|
||||
COLLECTION_FILTERS = {
|
||||
active: ->(resources) { resources.where(status: :active) },
|
||||
suspended: ->(resources) { resources.where(status: :suspended) },
|
||||
recent: ->(resources) { resources.where('created_at > ?', 30.days.ago) }
|
||||
recent: ->(resources) { resources.where('created_at > ?', 30.days.ago) },
|
||||
marked_for_deletion: ->(resources) { resources.where("custom_attributes->>'marked_for_deletion_at' IS NOT NULL") }
|
||||
}.freeze
|
||||
|
||||
# Overwrite this method to customize how accounts are displayed
|
||||
|
||||
@@ -32,6 +32,7 @@ class ConversationFinder
|
||||
def initialize(current_user, params)
|
||||
@current_user = current_user
|
||||
@current_account = current_user.account
|
||||
@is_admin = current_account.account_users.find_by(user_id: current_user.id)&.administrator?
|
||||
@params = params
|
||||
end
|
||||
|
||||
@@ -85,8 +86,19 @@ class ConversationFinder
|
||||
@team = current_account.teams.find(params[:team_id]) if params[:team_id]
|
||||
end
|
||||
|
||||
def find_conversation_by_inbox
|
||||
@conversations = current_account.conversations
|
||||
@conversations = @conversations.where(inbox_id: @inbox_ids) unless params[:inbox_id].blank? && @is_admin
|
||||
end
|
||||
|
||||
def find_all_conversations
|
||||
@conversations = current_account.conversations.where(inbox_id: @inbox_ids)
|
||||
find_conversation_by_inbox
|
||||
# Apply permission-based filtering
|
||||
@conversations = Conversations::PermissionFilterService.new(
|
||||
@conversations,
|
||||
current_user,
|
||||
current_account
|
||||
).perform
|
||||
filter_by_conversation_type if params[:conversation_type]
|
||||
@conversations
|
||||
end
|
||||
|
||||
@@ -18,4 +18,8 @@ module BillingHelper
|
||||
def non_web_inboxes(account)
|
||||
account.inboxes.where.not(channel_type: Channel::WebWidget.to_s).count
|
||||
end
|
||||
|
||||
def agents(account)
|
||||
account.users.count
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
module Instagram::IntegrationHelper
|
||||
REQUIRED_SCOPES = %w[instagram_business_basic instagram_business_manage_messages].freeze
|
||||
|
||||
# Generates a signed JWT token for Instagram integration
|
||||
#
|
||||
# @param account_id [Integer] The account ID to encode in the token
|
||||
# @return [String, nil] The encoded JWT token or nil if client secret is missing
|
||||
def generate_instagram_token(account_id)
|
||||
return if client_secret.blank?
|
||||
|
||||
JWT.encode(token_payload(account_id), client_secret, 'HS256')
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Failed to generate Instagram token: #{e.message}")
|
||||
nil
|
||||
end
|
||||
|
||||
def token_payload(account_id)
|
||||
{
|
||||
sub: account_id,
|
||||
iat: Time.current.to_i
|
||||
}
|
||||
end
|
||||
|
||||
# Verifies and decodes a Instagram JWT token
|
||||
#
|
||||
# @param token [String] The JWT token to verify
|
||||
# @return [Integer, nil] The account ID from the token or nil if invalid
|
||||
def verify_instagram_token(token)
|
||||
return if token.blank? || client_secret.blank?
|
||||
|
||||
decode_token(token, client_secret)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def client_secret
|
||||
@client_secret ||= GlobalConfigService.load('INSTAGRAM_APP_SECRET', nil)
|
||||
end
|
||||
|
||||
def decode_token(token, secret)
|
||||
JWT.decode(token, secret, true, {
|
||||
algorithm: 'HS256',
|
||||
verify_expiration: true
|
||||
}).first['sub']
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Unexpected error verifying Instagram token: #{e.message}")
|
||||
nil
|
||||
end
|
||||
end
|
||||
@@ -4,7 +4,6 @@ import AddAccountModal from '../dashboard/components/layout/sidebarComponents/Ad
|
||||
import LoadingState from './components/widgets/LoadingState.vue';
|
||||
import NetworkNotification from './components/NetworkNotification.vue';
|
||||
import UpdateBanner from './components/app/UpdateBanner.vue';
|
||||
import UpgradeBanner from './components/app/UpgradeBanner.vue';
|
||||
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
|
||||
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
|
||||
import vueActionCable from './helper/actionCable';
|
||||
@@ -31,7 +30,6 @@ export default {
|
||||
UpdateBanner,
|
||||
PaymentPendingBanner,
|
||||
WootSnackbarBox,
|
||||
UpgradeBanner,
|
||||
PendingEmailVerificationBanner,
|
||||
},
|
||||
setup() {
|
||||
@@ -146,7 +144,6 @@ export default {
|
||||
<template v-if="currentAccountId">
|
||||
<PendingEmailVerificationBanner v-if="hideOnOnboardingView" />
|
||||
<PaymentPendingBanner v-if="hideOnOnboardingView" />
|
||||
<UpgradeBanner />
|
||||
</template>
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class AgentBotsAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('agent_bots', { accountScoped: true });
|
||||
}
|
||||
|
||||
create(data) {
|
||||
return axios.post(this.url, data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
}
|
||||
|
||||
update(id, data) {
|
||||
return axios.patch(`${this.url}/${id}`, data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
}
|
||||
|
||||
deleteAgentBotAvatar(botId) {
|
||||
return axios.delete(`${this.url}/${botId}/avatar`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new AgentBotsAPI();
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class InstagramChannel extends ApiClient {
|
||||
constructor() {
|
||||
super('instagram', { accountScoped: true });
|
||||
}
|
||||
|
||||
generateAuthorization(payload) {
|
||||
return axios.post(`${this.url}/authorization`, payload);
|
||||
}
|
||||
}
|
||||
|
||||
export default new InstagramChannel();
|
||||
@@ -17,6 +17,12 @@ class EnterpriseAccountAPI extends ApiClient {
|
||||
getLimits() {
|
||||
return axios.get(`${this.url}limits`);
|
||||
}
|
||||
|
||||
toggleDeletion(action) {
|
||||
return axios.post(`${this.url}toggle_deletion`, {
|
||||
action_type: action,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new EnterpriseAccountAPI();
|
||||
|
||||
@@ -10,6 +10,7 @@ describe('#enterpriseAccountAPI', () => {
|
||||
expect(accountAPI).toHaveProperty('update');
|
||||
expect(accountAPI).toHaveProperty('delete');
|
||||
expect(accountAPI).toHaveProperty('checkout');
|
||||
expect(accountAPI).toHaveProperty('toggleDeletion');
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
@@ -42,5 +43,21 @@ describe('#enterpriseAccountAPI', () => {
|
||||
'/enterprise/api/v1/subscription'
|
||||
);
|
||||
});
|
||||
|
||||
it('#toggleDeletion with delete action', () => {
|
||||
accountAPI.toggleDeletion('delete');
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
'/enterprise/api/v1/toggle_deletion',
|
||||
{ action_type: 'delete' }
|
||||
);
|
||||
});
|
||||
|
||||
it('#toggleDeletion with undelete action', () => {
|
||||
accountAPI.toggleDeletion('undelete');
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
'/enterprise/api/v1/toggle_deletion',
|
||||
{ action_type: 'undelete' }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
@apply inline-block h-6 py-0 px-6 relative align-middle w-6;
|
||||
|
||||
&.message {
|
||||
@include normal-shadow;
|
||||
@apply bg-white dark:bg-slate-800 rounded-full left-0 my-3 mx-auto p-4 top-0;
|
||||
|
||||
&::before {
|
||||
|
||||
@@ -1,79 +1,7 @@
|
||||
@import 'dashboard/assets/scss/variables';
|
||||
@import 'widget/assets/scss/mixins';
|
||||
|
||||
$spinner-before-border-color: rgba(255, 255, 255, 0.7);
|
||||
|
||||
//borders
|
||||
@mixin border-nil() {
|
||||
border-color: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@mixin thin-border($color) {
|
||||
border: 1px solid $color;
|
||||
}
|
||||
|
||||
@mixin custom-border-bottom($size, $color) {
|
||||
border-bottom: $size solid $color;
|
||||
}
|
||||
|
||||
@mixin custom-border-top($size, $color) {
|
||||
border-top: $size solid $color;
|
||||
}
|
||||
|
||||
@mixin border-normal() {
|
||||
@apply border border-slate-50 dark:border-slate-700;
|
||||
}
|
||||
|
||||
@mixin border-normal-left() {
|
||||
@apply border-l border-slate-50 dark:border-slate-700;
|
||||
}
|
||||
|
||||
@mixin border-normal-top() {
|
||||
@apply border-t border-slate-50 dark:border-slate-700;
|
||||
}
|
||||
|
||||
@mixin border-normal-right() {
|
||||
@apply border-r border-slate-50 dark:border-slate-700;
|
||||
}
|
||||
|
||||
@mixin border-normal-bottom() {
|
||||
@apply border-b border-slate-50 dark:border-slate-700;
|
||||
}
|
||||
|
||||
@mixin border-light() {
|
||||
@apply border border-slate-25 dark:border-slate-700;
|
||||
}
|
||||
|
||||
@mixin border-light-left() {
|
||||
@apply border-l border-slate-25 dark:border-slate-700;
|
||||
}
|
||||
|
||||
@mixin border-light-top() {
|
||||
@apply border-t border-slate-25 dark:border-slate-700;
|
||||
}
|
||||
|
||||
@mixin border-light-right() {
|
||||
@apply border-r border-slate-25 dark:border-slate-700;
|
||||
}
|
||||
|
||||
@mixin border-light-bottom() {
|
||||
@apply border-b border-slate-25 dark:border-slate-700;
|
||||
}
|
||||
|
||||
// background
|
||||
@mixin background-gray() {
|
||||
background: $color-background;
|
||||
}
|
||||
|
||||
@mixin background-light() {
|
||||
@apply bg-slate-50 dark:bg-slate-800;
|
||||
}
|
||||
|
||||
@mixin background-white() {
|
||||
@apply bg-white dark:bg-slate-900;
|
||||
}
|
||||
|
||||
// input form
|
||||
@mixin ghost-input() {
|
||||
box-shadow: none;
|
||||
@@ -87,65 +15,6 @@ $spinner-before-border-color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
// flex-layout
|
||||
@mixin space-between() {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
@mixin space-between-column() {
|
||||
@include space-between;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@mixin space-between-row() {
|
||||
@include space-between;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
@mixin flex-shrink() {
|
||||
flex: 0 0 auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@mixin flex-weight($value) {
|
||||
// Grab flex-grow for older browsers.
|
||||
$flex-grow: nth($value, 1);
|
||||
|
||||
// 2009
|
||||
@include prefixer(box-flex, $flex-grow, webkit moz spec);
|
||||
|
||||
// 2011 (IE 10), 2012
|
||||
@include prefixer(flex, $value, webkit moz ms spec);
|
||||
}
|
||||
|
||||
// full height
|
||||
@mixin full-height() {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@mixin round-corner() {
|
||||
border-radius: 1000px;
|
||||
}
|
||||
|
||||
@mixin scroll-on-hover() {
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@mixin horizontal-scroll() {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@mixin elegant-card() {
|
||||
@include normal-shadow;
|
||||
border-radius: $space-small;
|
||||
}
|
||||
|
||||
@mixin color-spinner() {
|
||||
@keyframes spinner {
|
||||
to {
|
||||
@@ -230,17 +99,3 @@ $spinner-before-border-color: rgba(255, 255, 255, 0.7);
|
||||
border-left: $size solid transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin text-ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@mixin three-column-grid($column-one-width: 16rem,
|
||||
$column-three-width: 16rem) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax($column-one-width, 6fr) 10fr minmax($column-three-width, 6fr);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,19 @@
|
||||
--iris-11: 87 83 198;
|
||||
--iris-12: 39 41 98;
|
||||
|
||||
--blue-1: 251 253 255;
|
||||
--blue-2: 245 249 255;
|
||||
--blue-3: 233 243 255;
|
||||
--blue-4: 218 236 255;
|
||||
--blue-5: 201 226 255;
|
||||
--blue-6: 181 213 255;
|
||||
--blue-7: 155 195 252;
|
||||
--blue-8: 117 171 247;
|
||||
--blue-9: 39 129 246;
|
||||
--blue-10: 16 115 233;
|
||||
--blue-11: 8 109 224;
|
||||
--blue-12: 11 50 101;
|
||||
|
||||
--ruby-1: 255 252 253;
|
||||
--ruby-2: 255 247 248;
|
||||
--ruby-3: 254 234 237;
|
||||
@@ -131,6 +144,19 @@
|
||||
--iris-11: 158 177 255;
|
||||
--iris-12: 224 223 254;
|
||||
|
||||
--blue-1: 10 17 28;
|
||||
--blue-2: 15 24 38;
|
||||
--blue-3: 15 39 72;
|
||||
--blue-4: 10 49 99;
|
||||
--blue-5: 18 61 117;
|
||||
--blue-6: 29 84 134;
|
||||
--blue-7: 40 89 156;
|
||||
--blue-8: 48 106 186;
|
||||
--blue-9: 39 129 246;
|
||||
--blue-10: 21 116 231;
|
||||
--blue-11: 126 182 255;
|
||||
--blue-12: 205 227 255;
|
||||
|
||||
--ruby-1: 25 17 19;
|
||||
--ruby-2: 30 21 23;
|
||||
--ruby-3: 58 20 30;
|
||||
|
||||
@@ -81,28 +81,6 @@
|
||||
margin-left: var(--space-small);
|
||||
}
|
||||
}
|
||||
|
||||
// Conversation sidebar close button
|
||||
.close-button--rtl {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
// Resolve actions button
|
||||
.resolve-actions {
|
||||
.button-group .button:first-child {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: var(--border-radius-normal);
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: var(--border-radius-normal);
|
||||
}
|
||||
|
||||
.button-group .button:last-child {
|
||||
border-bottom-left-radius: var(--border-radius-normal);
|
||||
border-bottom-right-radius: 0;
|
||||
border-top-left-radius: var(--border-radius-normal);
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Conversation list
|
||||
@@ -177,71 +155,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Help center
|
||||
.article-container .row--article-block {
|
||||
td:last-child {
|
||||
direction: initial;
|
||||
}
|
||||
}
|
||||
|
||||
.portal-popover__container .portal {
|
||||
.actions-container {
|
||||
margin-left: unset;
|
||||
margin-right: var(--space-one);
|
||||
}
|
||||
}
|
||||
|
||||
.edit-article--container {
|
||||
.header-right--wrap {
|
||||
.button-group .button:first-child {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: var(--border-radius-normal);
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: var(--border-radius-normal);
|
||||
}
|
||||
|
||||
.button-group .button:last-child {
|
||||
border-bottom-left-radius: var(--border-radius-normal);
|
||||
border-bottom-right-radius: 0;
|
||||
border-top-left-radius: var(--border-radius-normal);
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.header-left--wrap {
|
||||
.back-button {
|
||||
direction: initial;
|
||||
}
|
||||
}
|
||||
|
||||
.article--buttons {
|
||||
.dropdown-pane {
|
||||
left: 0;
|
||||
position: absolute;
|
||||
right: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-button {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.article-settings--container {
|
||||
border-left: 0;
|
||||
border-right: 1px solid var(--color-border-light);
|
||||
flex-direction: row-reverse;
|
||||
margin-left: 0;
|
||||
margin-right: var(--space-normal);
|
||||
padding-left: 0;
|
||||
padding-right: var(--space-normal);
|
||||
}
|
||||
|
||||
.category-list--container .header-left--wrap {
|
||||
direction: initial;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
// Toggle switch
|
||||
.toggle-button {
|
||||
&.small {
|
||||
@@ -264,11 +177,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Widget builder
|
||||
.widget-builder-container .widget-preview {
|
||||
direction: initial;
|
||||
}
|
||||
|
||||
// Modal
|
||||
.modal-container {
|
||||
text-align: right;
|
||||
@@ -282,7 +190,6 @@
|
||||
}
|
||||
|
||||
// Other changes
|
||||
|
||||
.colorpicker--chrome {
|
||||
direction: initial;
|
||||
}
|
||||
@@ -291,14 +198,6 @@
|
||||
direction: initial;
|
||||
}
|
||||
|
||||
.contact--details .contact--bio {
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.merge-contacts .child-contact-wrap {
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.contact--form .input-group {
|
||||
direction: initial;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
@import 'rtl';
|
||||
|
||||
@import 'widgets/base';
|
||||
@import 'widgets/buttons';
|
||||
@import 'widgets/conversation-view';
|
||||
@import 'widgets/tabs';
|
||||
@import 'widgets/woot-tables';
|
||||
|
||||
@@ -40,6 +40,12 @@ dl:not(.reset-base) {
|
||||
@apply mb-0;
|
||||
}
|
||||
|
||||
// Button base
|
||||
button {
|
||||
font-family: inherit;
|
||||
@apply inline-block text-center align-middle cursor-pointer text-sm m-0 py-1 px-2.5 transition-all duration-200 ease-in-out border-0 border-none rounded-lg disabled:opacity-50;
|
||||
}
|
||||
|
||||
// Form elements
|
||||
// -------------------------
|
||||
label {
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
// scss-lint:disable SpaceAfterPropertyColon
|
||||
// scss-lint:disable MergeableSelector
|
||||
button {
|
||||
font-family: inherit;
|
||||
transition:
|
||||
background-color 0.25s ease-out,
|
||||
color 0.25s ease-out;
|
||||
@apply inline-block items-center mb-0 text-center align-middle cursor-pointer text-sm mt-0 mx-0 py-1 px-2.5 border border-solid border-transparent dark:border-transparent rounded-[0.3125rem];
|
||||
|
||||
&:disabled,
|
||||
&.disabled {
|
||||
@apply opacity-40 cursor-not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.button-group {
|
||||
@apply mb-0 flex flex-nowrap items-stretch;
|
||||
|
||||
.button {
|
||||
flex: 0 0 auto;
|
||||
@apply m-0 text-sm rounded-none first:rounded-tl-[0.3125rem] first:rounded-bl-[0.3125rem] last:rounded-tr-[0.3125rem] last:rounded-br-[0.3125rem] rtl:space-x-reverse;
|
||||
}
|
||||
|
||||
.button--only-icon {
|
||||
@apply w-10 justify-center pl-0 pr-0;
|
||||
}
|
||||
}
|
||||
|
||||
.back-button {
|
||||
@apply m-0;
|
||||
}
|
||||
|
||||
.button {
|
||||
@apply items-center bg-n-brand px-2.5 text-white dark:text-white inline-flex h-10 mb-0 gap-2 font-medium;
|
||||
|
||||
.button__content {
|
||||
@apply w-full whitespace-nowrap overflow-hidden text-ellipsis;
|
||||
|
||||
img,
|
||||
svg {
|
||||
@apply inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover:not(:disabled):not(.success):not(.alert):not(.warning):not(
|
||||
.clear
|
||||
):not(.smooth):not(.hollow) {
|
||||
@apply bg-n-brand/80 dark:bg-n-brand/80;
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&.disabled {
|
||||
@apply opacity-40 cursor-not-allowed;
|
||||
}
|
||||
|
||||
&.success {
|
||||
@apply bg-n-teal-9 text-white dark:text-white;
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
@apply bg-n-solid-3 text-white dark:text-white;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
@apply bg-n-brand text-white dark:text-white;
|
||||
}
|
||||
|
||||
&.clear {
|
||||
@apply text-n-blue-text dark:text-n-blue-text bg-transparent dark:bg-transparent;
|
||||
}
|
||||
|
||||
&.alert {
|
||||
@apply bg-n-ruby-9 text-white dark:text-white;
|
||||
|
||||
&.clear {
|
||||
@apply bg-transparent dark:bg-transparent;
|
||||
}
|
||||
}
|
||||
|
||||
&.warning {
|
||||
@apply bg-n-amber-9 text-white dark:text-white;
|
||||
|
||||
&.clear {
|
||||
@apply bg-transparent dark:bg-transparent;
|
||||
}
|
||||
}
|
||||
|
||||
&.tiny {
|
||||
@apply h-6 text-[10px];
|
||||
}
|
||||
|
||||
&.small {
|
||||
@apply h-8 text-xs;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
@apply px-2 py-0;
|
||||
}
|
||||
|
||||
// @TODDO - Remove after moving all buttons to woot-button
|
||||
.icon + .button__content {
|
||||
@apply w-auto;
|
||||
}
|
||||
|
||||
&.expanded {
|
||||
@apply flex justify-center text-center;
|
||||
}
|
||||
|
||||
&.round {
|
||||
@apply rounded-full;
|
||||
}
|
||||
|
||||
// @TODO Use with link
|
||||
|
||||
&.compact {
|
||||
@apply pb-0 pt-0;
|
||||
}
|
||||
|
||||
&.hollow {
|
||||
@apply border border-n-brand/40 bg-transparent text-n-blue-text hover:enabled:bg-n-brand/20;
|
||||
|
||||
&.secondary {
|
||||
@apply text-n-slate-12 border-n-slate-5 hover:enabled:bg-n-slate-5;
|
||||
}
|
||||
|
||||
&.success {
|
||||
@apply text-n-teal-9 border-n-teal-8 hover:enabled:bg-n-teal-5;
|
||||
}
|
||||
|
||||
&.alert {
|
||||
@apply text-n-ruby-9 border-n-ruby-8 hover:enabled:bg-n-ruby-5;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
@apply text-n-amber-9 border-n-amber-8 hover:enabled:bg-n-amber-5;
|
||||
}
|
||||
}
|
||||
|
||||
// Smooth style
|
||||
&.smooth {
|
||||
@apply bg-n-brand/10 dark:bg-n-brand/30 text-n-blue-text hover:enabled:bg-n-brand/20 dark:hover:enabled:bg-n-brand/40;
|
||||
|
||||
&.secondary {
|
||||
@apply bg-n-slate-4 text-n-slate-11 hover:enabled:text-n-slate-11 hover:enabled:bg-n-slate-5;
|
||||
}
|
||||
|
||||
&.success {
|
||||
@apply bg-n-teal-4 text-n-teal-11 hover:enabled:text-n-teal-11 hover:enabled:bg-n-teal-5;
|
||||
}
|
||||
|
||||
&.alert {
|
||||
@apply bg-n-ruby-4 text-n-ruby-11 hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-5;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
@apply bg-n-amber-4 text-n-amber-11 hover:enabled:text-n-amber-11 hover:enabled:bg-n-amber-5;
|
||||
}
|
||||
}
|
||||
|
||||
&.clear {
|
||||
@apply text-n-blue-text hover:enabled:bg-n-brand/10 dark:hover:enabled:bg-n-brand/30;
|
||||
|
||||
&.secondary {
|
||||
@apply text-n-slate-12 hover:enabled:bg-n-slate-4;
|
||||
}
|
||||
|
||||
&.success {
|
||||
@apply text-n-teal-10 hover:enabled:bg-n-teal-4;
|
||||
}
|
||||
|
||||
&.alert {
|
||||
@apply text-n-ruby-11 hover:enabled:bg-n-ruby-4;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
@apply text-n-amber-11 hover:enabled:bg-n-amber-4;
|
||||
}
|
||||
|
||||
&:active {
|
||||
&.secondary {
|
||||
@apply active:bg-n-slate-3 dark:active:bg-n-slate-7;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
&.secondary {
|
||||
@apply focus:bg-n-slate-4 dark:focus:bg-n-slate-6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sizes
|
||||
&.tiny {
|
||||
@apply h-6;
|
||||
}
|
||||
|
||||
&.small {
|
||||
@apply h-8 pb-1 pt-1;
|
||||
}
|
||||
|
||||
&.large {
|
||||
@apply h-12;
|
||||
}
|
||||
|
||||
&.button--only-icon {
|
||||
@apply justify-center pl-0 pr-0 w-10;
|
||||
|
||||
&.tiny {
|
||||
@apply w-6;
|
||||
}
|
||||
|
||||
&.small {
|
||||
@apply w-8;
|
||||
}
|
||||
|
||||
&.large {
|
||||
@apply w-12;
|
||||
}
|
||||
}
|
||||
|
||||
&.link {
|
||||
@apply h-auto m-0 p-0;
|
||||
|
||||
&:hover {
|
||||
@apply underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -51,17 +51,18 @@ defineExpose({ dialogRef, contactsFormRef, onSuccess });
|
||||
<Button
|
||||
:label="t('DIALOG.BUTTONS.CANCEL')"
|
||||
variant="link"
|
||||
type="reset"
|
||||
class="h-10 hover:!no-underline hover:text-n-brand"
|
||||
@click="closeDialog"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
:label="
|
||||
t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.SAVE_CONTACT')
|
||||
"
|
||||
color="blue"
|
||||
:disabled="contactsFormRef?.isFormInvalid"
|
||||
:is-loading="isCreatingContact"
|
||||
@click="handleDialogConfirm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
-4
@@ -31,10 +31,6 @@ const sortMenus = [
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.EMAIL'),
|
||||
value: 'email',
|
||||
},
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.PHONE_NUMBER'),
|
||||
value: 'phone_number',
|
||||
},
|
||||
{
|
||||
label: t('CONTACTS_LAYOUT.HEADER.ACTIONS.SORT_BY.OPTIONS.COMPANY'),
|
||||
value: 'company_name',
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<!-- DEPRECIATED -->
|
||||
<!-- TODO: Replace this banner component with NextBanner "app/javascript/dashboard/components-next/banner/Banner.vue" -->
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
|
||||
@@ -190,7 +190,7 @@ const STYLE_CONFIG = {
|
||||
const variantClasses = computed(() => {
|
||||
const variantMap = {
|
||||
ghost: `${STYLE_CONFIG.colors[computedColor.value].ghost}`,
|
||||
link: `${STYLE_CONFIG.colors[computedColor.value].link} p-0 font-medium underline-offset-4`,
|
||||
link: `${STYLE_CONFIG.colors[computedColor.value].link} p-0 font-medium underline-offset-2`,
|
||||
outline: STYLE_CONFIG.colors[computedColor.value].outline,
|
||||
faded: STYLE_CONFIG.colors[computedColor.value].faded,
|
||||
solid: STYLE_CONFIG.colors[computedColor.value].solid,
|
||||
|
||||
@@ -33,6 +33,8 @@ const insertIntoRichEditor = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const hasEmptyMessageContent = computed(() => !props.message?.content);
|
||||
|
||||
const useCopilotResponse = () => {
|
||||
if (insertIntoRichEditor.value) {
|
||||
emitter.emit(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, props.message?.content);
|
||||
@@ -53,9 +55,17 @@ const useCopilotResponse = () => {
|
||||
/>
|
||||
<div class="flex flex-col gap-1 text-n-slate-12">
|
||||
<div class="font-medium">{{ $t('CAPTAIN.NAME') }}</div>
|
||||
<div v-dompurify-html="messageContent" class="prose-sm break-words" />
|
||||
<span v-if="hasEmptyMessageContent" class="text-n-ruby-11">
|
||||
{{ $t('CAPTAIN.COPILOT.EMPTY_MESSAGE') }}
|
||||
</span>
|
||||
<div
|
||||
v-else
|
||||
v-dompurify-html="messageContent"
|
||||
class="prose-sm break-words"
|
||||
/>
|
||||
<div class="flex flex-row mt-1">
|
||||
<Button
|
||||
v-if="!hasEmptyMessageContent"
|
||||
:label="$t('CAPTAIN.COPILOT.USE')"
|
||||
faded
|
||||
sm
|
||||
|
||||
@@ -125,7 +125,10 @@ defineExpose({ open, close });
|
||||
<slot />
|
||||
<!-- Dialog content will be injected here -->
|
||||
<slot name="footer">
|
||||
<div class="flex items-center justify-between w-full gap-3">
|
||||
<div
|
||||
v-if="showCancelButton || showConfirmButton"
|
||||
class="flex items-center justify-between w-full gap-3"
|
||||
>
|
||||
<Button
|
||||
v-if="showCancelButton"
|
||||
variant="faded"
|
||||
|
||||
@@ -103,7 +103,7 @@ export default {
|
||||
{{ $t('FILTER.CUSTOM_VIEWS.ADD.TITLE') }}
|
||||
</h3>
|
||||
<form class="w-full grid gap-6" @submit.prevent="saveCustomViews">
|
||||
<div>
|
||||
<label :class="{ error: v$.name.$error }">
|
||||
<input
|
||||
v-model="name"
|
||||
class="py-1.5 px-3 text-n-slate-12 bg-n-alpha-1 text-sm rounded-lg reset-base w-full"
|
||||
@@ -116,14 +116,14 @@ export default {
|
||||
>
|
||||
{{ $t('FILTER.CUSTOM_VIEWS.ADD.ERROR_MESSAGE') }}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
<div class="flex flex-row justify-end w-full gap-2">
|
||||
<NextButton sm solid blue :disabled="isButtonDisabled">
|
||||
{{ $t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON') }}
|
||||
</NextButton>
|
||||
<NextButton faded slate sm @click.prevent="onClose">
|
||||
{{ $t('FILTER.CUSTOM_VIEWS.ADD.CANCEL_BUTTON') }}
|
||||
</NextButton>
|
||||
<NextButton solid blue sm :disabled="isButtonDisabled">
|
||||
{{ $t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON') }}
|
||||
</NextButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ export function useChannelIcon(inbox) {
|
||||
'Channel::TwitterProfile': 'i-ri-twitter-x-fill',
|
||||
'Channel::WebWidget': 'i-ri-global-fill',
|
||||
'Channel::Whatsapp': 'i-ri-whatsapp-fill',
|
||||
'Channel::Instagram': 'i-ri-instagram-fill',
|
||||
};
|
||||
|
||||
const providerIconMap = {
|
||||
|
||||
@@ -19,10 +19,17 @@ const {
|
||||
isAWebWidgetInbox,
|
||||
isAWhatsAppChannel,
|
||||
isAnEmailChannel,
|
||||
isAInstagramChannel,
|
||||
} = useInbox();
|
||||
|
||||
const { status, isPrivate, createdAt, sourceId, messageType } =
|
||||
useMessageContext();
|
||||
const {
|
||||
status,
|
||||
isPrivate,
|
||||
createdAt,
|
||||
sourceId,
|
||||
messageType,
|
||||
contentAttributes,
|
||||
} = useMessageContext();
|
||||
|
||||
const readableTime = computed(() =>
|
||||
messageTimestamp(createdAt.value, 'LLL d, h:mm a')
|
||||
@@ -30,6 +37,11 @@ const readableTime = computed(() =>
|
||||
|
||||
const showStatusIndicator = computed(() => {
|
||||
if (isPrivate.value) return false;
|
||||
// Don't show status for failed messages, we already show error message
|
||||
if (status.value === MESSAGE_STATUS.FAILED) return false;
|
||||
// Don't show status for deleted messages
|
||||
if (contentAttributes.value?.deleted) return false;
|
||||
|
||||
if (messageType.value === MESSAGE_TYPES.OUTGOING) return true;
|
||||
if (messageType.value === MESSAGE_TYPES.TEMPLATE) return true;
|
||||
|
||||
@@ -47,7 +59,8 @@ const isSent = computed(() => {
|
||||
isATwilioChannel.value ||
|
||||
isAFacebookInbox.value ||
|
||||
isASmsInbox.value ||
|
||||
isATelegramChannel.value
|
||||
isATelegramChannel.value ||
|
||||
isAInstagramChannel.value
|
||||
) {
|
||||
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
|
||||
}
|
||||
@@ -86,7 +99,8 @@ const isRead = computed(() => {
|
||||
if (
|
||||
isAWhatsAppChannel.value ||
|
||||
isATwilioChannel.value ||
|
||||
isAFacebookInbox.value
|
||||
isAFacebookInbox.value ||
|
||||
isAInstagramChannel.value
|
||||
) {
|
||||
return sourceId.value && status.value === MESSAGE_STATUS.READ;
|
||||
}
|
||||
@@ -102,7 +116,6 @@ const statusToShow = computed(() => {
|
||||
if (isRead.value) return MESSAGE_STATUS.READ;
|
||||
if (isDelivered.value) return MESSAGE_STATUS.DELIVERED;
|
||||
if (isSent.value) return MESSAGE_STATUS.SENT;
|
||||
if (status.value === MESSAGE_STATUS.FAILED) return MESSAGE_STATUS.FAILED;
|
||||
|
||||
return MESSAGE_STATUS.PROGRESS;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup>
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
|
||||
defineProps({
|
||||
showingOriginal: Boolean,
|
||||
});
|
||||
|
||||
defineEmits(['toggle']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span>
|
||||
<span
|
||||
class="text-xs text-n-slate-11 cursor-pointer hover:underline select-none"
|
||||
@click="$emit('toggle')"
|
||||
>
|
||||
{{
|
||||
showingOriginal
|
||||
? $t('CONVERSATION.VIEW_TRANSLATED')
|
||||
: $t('CONVERSATION.VIEW_ORIGINAL')
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -13,6 +13,9 @@ const attachment = computed(() => {
|
||||
|
||||
<template>
|
||||
<BaseBubble class="bg-transparent" data-bubble-name="audio">
|
||||
<AudioChip :attachment="attachment" class="p-2 text-n-slate-12" />
|
||||
<AudioChip
|
||||
:attachment="attachment"
|
||||
class="p-2 text-n-slate-12 skip-context-menu"
|
||||
/>
|
||||
</BaseBubble>
|
||||
</template>
|
||||
|
||||
@@ -9,9 +9,11 @@ import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
import FormattedContent from 'next/message/bubbles/Text/FormattedContent.vue';
|
||||
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
|
||||
import EmailMeta from './EmailMeta.vue';
|
||||
import TranslationToggle from 'dashboard/components-next/message/TranslationToggle.vue';
|
||||
|
||||
import { useMessageContext } from '../../provider.js';
|
||||
import { MESSAGE_TYPES } from 'next/message/constants.js';
|
||||
import { useTranslations } from 'dashboard/composables/useTranslations';
|
||||
|
||||
const { content, contentAttributes, attachments, messageType } =
|
||||
useMessageContext();
|
||||
@@ -19,35 +21,77 @@ const { content, contentAttributes, attachments, messageType } =
|
||||
const isExpandable = ref(false);
|
||||
const isExpanded = ref(false);
|
||||
const showQuotedMessage = ref(false);
|
||||
const renderOriginal = ref(false);
|
||||
const contentContainer = useTemplateRef('contentContainer');
|
||||
|
||||
onMounted(() => {
|
||||
isExpandable.value = contentContainer.value?.scrollHeight > 400;
|
||||
});
|
||||
|
||||
const isOutgoing = computed(() => {
|
||||
return messageType.value === MESSAGE_TYPES.OUTGOING;
|
||||
});
|
||||
const isOutgoing = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
|
||||
const isIncoming = computed(() => !isOutgoing.value);
|
||||
|
||||
const textToShow = computed(() => {
|
||||
const { hasTranslations, translationContent } =
|
||||
useTranslations(contentAttributes);
|
||||
|
||||
const originalEmailText = computed(() => {
|
||||
const text =
|
||||
contentAttributes?.value?.email?.textContent?.full ?? content.value;
|
||||
return text?.replace(/\n/g, '<br>');
|
||||
});
|
||||
|
||||
// Use TextContent as the default to fullHTML
|
||||
const originalEmailHtml = computed(
|
||||
() =>
|
||||
contentAttributes?.value?.email?.htmlContent?.full ??
|
||||
originalEmailText.value
|
||||
);
|
||||
|
||||
const messageContent = computed(() => {
|
||||
// If translations exist and we're showing translations (not original)
|
||||
if (hasTranslations.value && !renderOriginal.value) {
|
||||
return translationContent.value;
|
||||
}
|
||||
// Otherwise show original content
|
||||
return content.value;
|
||||
});
|
||||
|
||||
const textToShow = computed(() => {
|
||||
// If translations exist and we're showing translations (not original)
|
||||
if (hasTranslations.value && !renderOriginal.value) {
|
||||
return translationContent.value;
|
||||
}
|
||||
// Otherwise show original text
|
||||
return originalEmailText.value;
|
||||
});
|
||||
|
||||
const fullHTML = computed(() => {
|
||||
return contentAttributes?.value?.email?.htmlContent?.full ?? textToShow.value;
|
||||
// If translations exist and we're showing translations (not original)
|
||||
if (hasTranslations.value && !renderOriginal.value) {
|
||||
return translationContent.value;
|
||||
}
|
||||
// Otherwise show original HTML
|
||||
return originalEmailHtml.value;
|
||||
});
|
||||
|
||||
const unquotedHTML = computed(() => {
|
||||
return EmailQuoteExtractor.extractQuotes(fullHTML.value);
|
||||
const unquotedHTML = computed(() =>
|
||||
EmailQuoteExtractor.extractQuotes(fullHTML.value)
|
||||
);
|
||||
|
||||
const hasQuotedMessage = computed(() =>
|
||||
EmailQuoteExtractor.hasQuotes(fullHTML.value)
|
||||
);
|
||||
|
||||
// Ensure unique keys for <Letter> when toggling between original and translated views.
|
||||
// This forces Vue to re-render the component and update content correctly.
|
||||
const translationKeySuffix = computed(() => {
|
||||
if (renderOriginal.value) return 'original';
|
||||
if (hasTranslations.value) return 'translated';
|
||||
return 'original';
|
||||
});
|
||||
|
||||
const hasQuotedMessage = computed(() => {
|
||||
return EmailQuoteExtractor.hasQuotes(fullHTML.value);
|
||||
});
|
||||
const handleSeeOriginal = () => {
|
||||
renderOriginal.value = !renderOriginal.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -75,7 +119,7 @@ const hasQuotedMessage = computed(() => {
|
||||
>
|
||||
<div
|
||||
v-if="isExpandable && !isExpanded"
|
||||
class="absolute left-0 right-0 bottom-0 h-40 px-8 flex items-end bg-gradient-to-t from-n-gray-3 via-n-gray-3 via-20% to-transparent"
|
||||
class="absolute left-0 right-0 bottom-0 h-40 px-8 flex items-end bg-gradient-to-t from-n-slate-4 via-n-slate-4 via-20% to-transparent"
|
||||
>
|
||||
<button
|
||||
class="text-n-slate-12 py-2 px-8 mx-auto text-center flex items-center gap-2"
|
||||
@@ -88,11 +132,12 @@ const hasQuotedMessage = computed(() => {
|
||||
<FormattedContent
|
||||
v-if="isOutgoing && content"
|
||||
class="text-n-slate-12"
|
||||
:content="content"
|
||||
:content="messageContent"
|
||||
/>
|
||||
<template v-else>
|
||||
<Letter
|
||||
v-if="showQuotedMessage"
|
||||
:key="`letter-quoted-${translationKeySuffix}`"
|
||||
class-name="prose prose-bubble !max-w-none letter-render"
|
||||
:allowed-css-properties="[
|
||||
...allowedCssProperties,
|
||||
@@ -104,6 +149,7 @@ const hasQuotedMessage = computed(() => {
|
||||
/>
|
||||
<Letter
|
||||
v-else
|
||||
:key="`letter-unquoted-${translationKeySuffix}`"
|
||||
class-name="prose prose-bubble !max-w-none letter-render"
|
||||
:html="unquotedHTML"
|
||||
:allowed-css-properties="[
|
||||
@@ -135,6 +181,12 @@ const hasQuotedMessage = computed(() => {
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<TranslationToggle
|
||||
v-if="hasTranslations"
|
||||
class="py-2 px-3"
|
||||
:showing-original="renderOriginal"
|
||||
@toggle="handleSeeOriginal"
|
||||
/>
|
||||
<section
|
||||
v-if="Array.isArray(attachments) && attachments.length"
|
||||
class="px-4 pb-4 space-y-2"
|
||||
|
||||
@@ -56,6 +56,7 @@ const downloadAttachment = async () => {
|
||||
</div>
|
||||
<div v-else class="relative group rounded-lg overflow-hidden">
|
||||
<img
|
||||
class="skip-context-menu"
|
||||
:src="attachment.dataUrl"
|
||||
:width="attachment.width"
|
||||
:height="attachment.height"
|
||||
@@ -63,8 +64,9 @@ const downloadAttachment = async () => {
|
||||
@error="handleError"
|
||||
/>
|
||||
<div
|
||||
class="inset-0 p-2 absolute bg-gradient-to-tl from-n-slate-12/30 dark:from-n-slate-1/50 via-transparent to-transparent hidden group-hover:flex items-end justify-end gap-1.5"
|
||||
>
|
||||
class="inset-0 p-2 pointer-events-none absolute bg-gradient-to-tl from-n-slate-12/30 dark:from-n-slate-1/50 via-transparent to-transparent hidden group-hover:flex"
|
||||
/>
|
||||
<div class="absolute right-2 bottom-2 hidden group-hover:flex gap-2">
|
||||
<Button xs solid slate icon="i-lucide-expand" class="opacity-60" />
|
||||
<Button
|
||||
xs
|
||||
|
||||
@@ -41,13 +41,13 @@ const onVideoLoadError = () => {
|
||||
<div v-if="content" v-dompurify-html="formattedContent" class="mb-2" />
|
||||
<img
|
||||
v-if="!hasImgStoryError"
|
||||
class="rounded-lg max-w-80"
|
||||
class="rounded-lg max-w-80 skip-context-menu"
|
||||
:src="attachment.dataUrl"
|
||||
@error="onImageLoadError"
|
||||
/>
|
||||
<video
|
||||
v-else-if="!hasVideoStoryError"
|
||||
class="rounded-lg max-w-80"
|
||||
class="rounded-lg max-w-80 skip-context-menu"
|
||||
controls
|
||||
:src="attachment.dataUrl"
|
||||
@error="onVideoLoadError"
|
||||
|
||||
@@ -3,16 +3,16 @@ import { computed, ref } from 'vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
import FormattedContent from './FormattedContent.vue';
|
||||
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
|
||||
import TranslationToggle from 'dashboard/components-next/message/TranslationToggle.vue';
|
||||
import { MESSAGE_TYPES } from '../../constants';
|
||||
import { useMessageContext } from '../../provider.js';
|
||||
import { useTranslations } from 'dashboard/composables/useTranslations';
|
||||
|
||||
const { content, attachments, contentAttributes, messageType } =
|
||||
useMessageContext();
|
||||
|
||||
const hasTranslations = computed(() => {
|
||||
const { translations = {} } = contentAttributes.value;
|
||||
return Object.keys(translations || {}).length > 0;
|
||||
});
|
||||
const { hasTranslations, translationContent } =
|
||||
useTranslations(contentAttributes);
|
||||
|
||||
const renderOriginal = ref(false);
|
||||
|
||||
@@ -22,8 +22,7 @@ const renderContent = computed(() => {
|
||||
}
|
||||
|
||||
if (hasTranslations.value) {
|
||||
const translations = contentAttributes.value.translations;
|
||||
return translations[Object.keys(translations)[0]];
|
||||
return translationContent.value;
|
||||
}
|
||||
|
||||
return content.value;
|
||||
@@ -37,12 +36,6 @@ const isEmpty = computed(() => {
|
||||
return !content.value && !attachments.value?.length;
|
||||
});
|
||||
|
||||
const viewToggleKey = computed(() => {
|
||||
return renderOriginal.value
|
||||
? 'CONVERSATION.VIEW_TRANSLATED'
|
||||
: 'CONVERSATION.VIEW_ORIGINAL';
|
||||
});
|
||||
|
||||
const handleSeeOriginal = () => {
|
||||
renderOriginal.value = !renderOriginal.value;
|
||||
};
|
||||
@@ -55,15 +48,12 @@ const handleSeeOriginal = () => {
|
||||
{{ $t('CONVERSATION.NO_CONTENT') }}
|
||||
</span>
|
||||
<FormattedContent v-if="renderContent" :content="renderContent" />
|
||||
<span class="-mt-3">
|
||||
<span
|
||||
v-if="hasTranslations"
|
||||
class="text-xs text-n-slate-11 cursor-pointer hover:underline"
|
||||
@click="handleSeeOriginal"
|
||||
>
|
||||
{{ $t(viewToggleKey) }}
|
||||
</span>
|
||||
</span>
|
||||
<TranslationToggle
|
||||
v-if="hasTranslations"
|
||||
class="-mt-3"
|
||||
:showing-original="renderOriginal"
|
||||
@toggle="handleSeeOriginal"
|
||||
/>
|
||||
<AttachmentChips :attachments="attachments" class="gap-2" />
|
||||
<template v-if="isTemplate">
|
||||
<div
|
||||
|
||||
@@ -35,13 +35,13 @@ const isReel = computed(() => {
|
||||
<div class="relative group rounded-lg overflow-hidden">
|
||||
<div
|
||||
v-if="isReel"
|
||||
class="absolute p-2 flex items-start justify-end right-0"
|
||||
class="absolute p-2 flex items-start justify-end right-0 pointer-events-none"
|
||||
>
|
||||
<Icon icon="i-lucide-instagram" class="text-white shadow-lg" />
|
||||
</div>
|
||||
<video
|
||||
controls
|
||||
class="rounded-lg"
|
||||
class="rounded-lg skip-context-menu"
|
||||
:src="attachment.dataUrl"
|
||||
:class="{
|
||||
'max-w-48': isReel,
|
||||
|
||||
@@ -39,7 +39,7 @@ const textColorClass = computed(() => {
|
||||
docx: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-12
|
||||
json: 'text-n-slate-12',
|
||||
odt: 'dark:text-[#D6E1FF] text-[#1F2D5C]', // indigo-12
|
||||
pdf: 'text-n-ruby-12',
|
||||
pdf: 'text-n-slate-12',
|
||||
ppt: 'dark:text-[#FFE0C2] text-[#582D1D]',
|
||||
pptx: 'dark:text-[#FFE0C2] text-[#582D1D]',
|
||||
rar: 'dark:text-[#EDEEF0] text-[#2F265F]',
|
||||
|
||||
@@ -36,7 +36,7 @@ const handleError = () => {
|
||||
</div>
|
||||
<img
|
||||
v-else
|
||||
class="object-cover w-full h-full"
|
||||
class="object-cover w-full h-full skip-context-menu"
|
||||
:src="attachment.dataUrl"
|
||||
@error="handleError"
|
||||
/>
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import SidebarGroup from './SidebarGroup.vue';
|
||||
@@ -37,18 +36,6 @@ const toggleShortcutModalFn = show => {
|
||||
}
|
||||
};
|
||||
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
const showV4Routes = computed(() => {
|
||||
return isFeatureEnabledonAccount.value(
|
||||
currentAccountId.value,
|
||||
FEATURE_FLAGS.REPORT_V4
|
||||
);
|
||||
});
|
||||
|
||||
useSidebarKeyboardShortcuts(toggleShortcutModalFn);
|
||||
|
||||
// We're using localStorage to store the expanded item in the sidebar
|
||||
@@ -116,32 +103,7 @@ const newReportRoutes = () => [
|
||||
},
|
||||
];
|
||||
|
||||
const oldReportRoutes = () => [
|
||||
{
|
||||
name: 'Reports Agent',
|
||||
label: t('SIDEBAR.REPORTS_AGENT'),
|
||||
to: accountScopedRoute('agent_reports'),
|
||||
},
|
||||
{
|
||||
name: 'Reports Label',
|
||||
label: t('SIDEBAR.REPORTS_LABEL'),
|
||||
to: accountScopedRoute('label_reports'),
|
||||
},
|
||||
{
|
||||
name: 'Reports Inbox',
|
||||
label: t('SIDEBAR.REPORTS_INBOX'),
|
||||
to: accountScopedRoute('inbox_reports'),
|
||||
},
|
||||
{
|
||||
name: 'Reports Team',
|
||||
label: t('SIDEBAR.REPORTS_TEAM'),
|
||||
to: accountScopedRoute('team_reports'),
|
||||
},
|
||||
];
|
||||
|
||||
const reportRoutes = computed(() =>
|
||||
showV4Routes.value ? newReportRoutes() : oldReportRoutes()
|
||||
);
|
||||
const reportRoutes = computed(() => newReportRoutes());
|
||||
|
||||
const menuItems = computed(() => {
|
||||
return [
|
||||
|
||||
@@ -26,6 +26,12 @@ const showAccountSwitcher = computed(
|
||||
() => userAccounts.value.length > 1 && currentAccount.value.name
|
||||
);
|
||||
|
||||
const sortedCurrentUserAccounts = computed(() => {
|
||||
return [...(currentUser.value.accounts || [])].sort((a, b) =>
|
||||
a.name.localeCompare(b.name)
|
||||
);
|
||||
});
|
||||
|
||||
const onChangeAccount = newId => {
|
||||
const accountUrl = `/app/accounts/${newId}/dashboard`;
|
||||
window.location.href = accountUrl;
|
||||
@@ -70,7 +76,7 @@ const emitNewAccount = () => {
|
||||
<DropdownBody v-if="showAccountSwitcher" class="min-w-80 z-50">
|
||||
<DropdownSection :title="t('SIDEBAR_ITEMS.SWITCH_ACCOUNT')">
|
||||
<DropdownItem
|
||||
v-for="account in currentUser.accounts"
|
||||
v-for="account in sortedCurrentUserAccounts"
|
||||
:id="`account-${account.id}`"
|
||||
:key="account.id"
|
||||
class="cursor-pointer"
|
||||
|
||||
@@ -35,7 +35,7 @@ const onToggle = () => {
|
||||
<template>
|
||||
<div class="text-sm">
|
||||
<button
|
||||
class="flex items-center select-none w-full rounded-lg bg-n-slate-2 border border-n-weak m-0 cursor-grab justify-between py-2 px-4 drag-handle"
|
||||
class="flex items-center select-none w-full rounded-lg bg-n-slate-2 outline outline-1 outline-n-weak m-0 cursor-grab justify-between py-2 px-4 drag-handle"
|
||||
:class="{ 'rounded-bl-none rounded-br-none': isOpen }"
|
||||
@click.stop="onToggle"
|
||||
>
|
||||
@@ -55,7 +55,7 @@ const onToggle = () => {
|
||||
</button>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="bg-n-background border border-n-weak dark:border-n-slate-2 border-t-0 rounded-br-lg rounded-bl-lg"
|
||||
class="bg-n-background outline outline-1 outline-n-weak -mt-[-1px] border-t-0 rounded-br-lg rounded-bl-lg"
|
||||
:class="compact ? 'p-0' : 'px-2 py-4'"
|
||||
>
|
||||
<slot />
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
getUserPermissions,
|
||||
filterItemsByPermission,
|
||||
} from 'dashboard/helper/permissionsHelper.js';
|
||||
import { matchesFilters } from '../store/modules/conversations/helpers/filterHelpers';
|
||||
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
|
||||
import { ASSIGNEE_TYPE_TAB_PERMISSIONS } from 'dashboard/constants/permissions.js';
|
||||
|
||||
@@ -105,7 +106,7 @@ const advancedFilterTypes = ref(
|
||||
);
|
||||
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const chatLists = useMapGetter('getAllConversations');
|
||||
const chatLists = useMapGetter('getFilteredConversations');
|
||||
const mineChatsList = useMapGetter('getMineChats');
|
||||
const allChatList = useMapGetter('getAllStatusChats');
|
||||
const unAssignedChatsList = useMapGetter('getUnAssignedChats');
|
||||
@@ -324,6 +325,14 @@ const conversationList = computed(() => {
|
||||
} else {
|
||||
localConversationList = [...chatLists.value];
|
||||
}
|
||||
|
||||
if (activeFolder.value) {
|
||||
const { payload } = activeFolder.value.query;
|
||||
localConversationList = localConversationList.filter(conversation => {
|
||||
return matchesFilters(conversation, payload);
|
||||
});
|
||||
}
|
||||
|
||||
return localConversationList;
|
||||
});
|
||||
|
||||
@@ -460,6 +469,12 @@ function setParamsForEditFolderModal() {
|
||||
campaigns: campaigns.value,
|
||||
languages: languages,
|
||||
countries: countries,
|
||||
priority: [
|
||||
{ id: 'low', name: t('CONVERSATION.PRIORITY.OPTIONS.LOW') },
|
||||
{ id: 'medium', name: t('CONVERSATION.PRIORITY.OPTIONS.MEDIUM') },
|
||||
{ id: 'high', name: t('CONVERSATION.PRIORITY.OPTIONS.HIGH') },
|
||||
{ id: 'urgent', name: t('CONVERSATION.PRIORITY.OPTIONS.URGENT') },
|
||||
],
|
||||
filterTypes: advancedFilterTypes.value,
|
||||
allCustomAttributes: conversationCustomAttributes.value,
|
||||
};
|
||||
|
||||
@@ -9,12 +9,15 @@ import { getRegexp } from 'shared/helpers/Validators';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const DATE_FORMAT = 'yyyy-MM-dd';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
MultiselectDropdown,
|
||||
HelperTextPopup,
|
||||
NextButton,
|
||||
},
|
||||
props: {
|
||||
label: { type: String, required: true },
|
||||
@@ -219,14 +222,13 @@ export default {
|
||||
class="mt-0.5"
|
||||
/>
|
||||
</span>
|
||||
<woot-button
|
||||
<NextButton
|
||||
v-if="showActions && value"
|
||||
v-tooltip.left="$t('CUSTOM_ATTRIBUTES.ACTIONS.DELETE')"
|
||||
variant="link"
|
||||
size="medium"
|
||||
color-scheme="secondary"
|
||||
icon="delete"
|
||||
class-names="flex justify-end !w-fit"
|
||||
slate
|
||||
sm
|
||||
link
|
||||
icon="i-lucide-trash-2"
|
||||
@click="onDelete"
|
||||
/>
|
||||
</div>
|
||||
@@ -246,10 +248,10 @@ export default {
|
||||
@keyup.enter="onUpdate"
|
||||
/>
|
||||
<div>
|
||||
<woot-button
|
||||
size="small"
|
||||
icon="checkmark"
|
||||
class="ltr:rounded-l-none rtl:rounded-r-none"
|
||||
<NextButton
|
||||
sm
|
||||
icon="i-lucide-check"
|
||||
class="ltr:rounded-l-none rtl:rounded-r-none h-[34px]"
|
||||
@click="onUpdate"
|
||||
/>
|
||||
</div>
|
||||
@@ -281,25 +283,27 @@ export default {
|
||||
>
|
||||
{{ displayValue || '---' }}
|
||||
</p>
|
||||
<div class="flex max-w-[2rem] gap-1 ml-1 rtl:mr-1 rtl:ml-0">
|
||||
<woot-button
|
||||
<div
|
||||
class="flex items-center max-w-[2rem] gap-1 ml-1 rtl:mr-1 rtl:ml-0"
|
||||
>
|
||||
<NextButton
|
||||
v-if="showActions && value"
|
||||
v-tooltip="$t('CUSTOM_ATTRIBUTES.ACTIONS.COPY')"
|
||||
variant="link"
|
||||
size="small"
|
||||
color-scheme="secondary"
|
||||
icon="clipboard"
|
||||
class-names="hidden group-hover:flex !w-6 flex-shrink-0"
|
||||
xs
|
||||
slate
|
||||
ghost
|
||||
icon="i-lucide-clipboard"
|
||||
class="hidden group-hover:flex flex-shrink-0"
|
||||
@click="onCopy"
|
||||
/>
|
||||
<woot-button
|
||||
<NextButton
|
||||
v-if="showActions"
|
||||
v-tooltip.right="$t('CUSTOM_ATTRIBUTES.ACTIONS.EDIT')"
|
||||
variant="link"
|
||||
size="small"
|
||||
color-scheme="secondary"
|
||||
icon="edit"
|
||||
class-names="hidden group-hover:flex !w-6 flex-shrink-0"
|
||||
xs
|
||||
slate
|
||||
ghost
|
||||
icon="i-lucide-pen"
|
||||
class="hidden group-hover:flex flex-shrink-0"
|
||||
@click="onEdit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script>
|
||||
import DatePicker from 'vue-datepicker-next';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
DatePicker,
|
||||
NextButton,
|
||||
},
|
||||
emits: ['close', 'chooseTime'],
|
||||
|
||||
@@ -52,18 +54,23 @@ export default {
|
||||
v-model:value="snoozeTime"
|
||||
type="datetime"
|
||||
inline
|
||||
input-class="mx-input reset-base"
|
||||
input-class="mx-input "
|
||||
:lang="lang"
|
||||
:disabled-date="disabledDate"
|
||||
:disabled-time="disabledTime"
|
||||
/>
|
||||
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
|
||||
<woot-button variant="clear" @click.prevent="onClose">
|
||||
{{ $t('CONVERSATION.CUSTOM_SNOOZE.CANCEL') }}
|
||||
</woot-button>
|
||||
<woot-button>
|
||||
{{ $t('CONVERSATION.CUSTOM_SNOOZE.APPLY') }}
|
||||
</woot-button>
|
||||
<NextButton
|
||||
faded
|
||||
slate
|
||||
type="reset"
|
||||
:label="$t('CONVERSATION.CUSTOM_SNOOZE.CANCEL')"
|
||||
@click.prevent="onClose"
|
||||
/>
|
||||
<NextButton
|
||||
type="submit"
|
||||
:label="$t('CONVERSATION.CUSTOM_SNOOZE.APPLY')"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
<script>
|
||||
import 'highlight.js/styles/default.css';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
masked: true,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
async onCopy(e) {
|
||||
e.preventDefault();
|
||||
await copyTextToClipboard(this.value);
|
||||
useAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
|
||||
},
|
||||
toggleMasked() {
|
||||
this.masked = !this.masked;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text--container">
|
||||
<woot-button size="small" class="button--text" @click="onCopy">
|
||||
{{ $t('COMPONENTS.CODE.BUTTON_TEXT') }}
|
||||
</woot-button>
|
||||
<woot-button
|
||||
variant="clear"
|
||||
size="small"
|
||||
class="button--visibility"
|
||||
color-scheme="secondary"
|
||||
:icon="masked ? 'eye-show' : 'eye-hide'"
|
||||
@click.prevent="toggleMasked"
|
||||
/>
|
||||
<highlightjs v-if="value" :code="masked ? '•'.repeat(10) : value" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.text--container {
|
||||
position: relative;
|
||||
text-align: left;
|
||||
|
||||
.button--text,
|
||||
.button--visibility {
|
||||
margin-top: 0;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.button--visibility {
|
||||
right: 60px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,7 @@
|
||||
// [TODO] Use Teleport to move the modal to the end of the body
|
||||
import { ref, computed, defineEmits, onMounted } from 'vue';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const { modalType, closeOnBackdropClick, onClose } = defineProps({
|
||||
closeOnBackdropClick: { type: Boolean, default: true },
|
||||
@@ -85,11 +86,11 @@ onMounted(() => {
|
||||
@mouse.stop
|
||||
@mousedown="event => event.stopPropagation()"
|
||||
>
|
||||
<woot-button
|
||||
<Button
|
||||
v-if="showCloseButton"
|
||||
color-scheme="secondary"
|
||||
icon="dismiss"
|
||||
variant="clear"
|
||||
ghost
|
||||
slate
|
||||
icon="i-lucide-x"
|
||||
class="absolute z-10 ltr:right-2 rtl:left-2 top-2"
|
||||
@click="close"
|
||||
/>
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
} from 'dashboard/helper/routeHelpers';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
@@ -107,34 +109,31 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<transition name="network-notification-fade" tag="div">
|
||||
<div v-show="showNotification" class="fixed z-50 top-4 left-2 group">
|
||||
<div v-show="showNotification" class="fixed z-50 top-2 left-2 group">
|
||||
<div
|
||||
class="relative flex items-center justify-between w-full px-2 py-1 bg-yellow-200 rounded-lg shadow-lg dark:bg-yellow-700"
|
||||
class="relative flex items-center justify-between w-full px-2 py-1 bg-n-amber-4 dark:bg-n-amber-8 rounded-lg shadow-lg"
|
||||
>
|
||||
<fluent-icon
|
||||
:icon="iconName"
|
||||
class="text-yellow-700/50 dark:text-yellow-50"
|
||||
size="18"
|
||||
/>
|
||||
<span
|
||||
class="px-2 text-xs font-medium tracking-wide text-yellow-700/70 dark:text-yellow-50"
|
||||
>
|
||||
<fluent-icon :icon="iconName" class="text-n-amber-12" size="18" />
|
||||
<span class="px-2 text-xs font-medium tracking-wide text-n-amber-12">
|
||||
{{ bannerText }}
|
||||
</span>
|
||||
<woot-button
|
||||
<Button
|
||||
v-if="canRefresh"
|
||||
ghost
|
||||
sm
|
||||
amber
|
||||
icon="i-lucide-refresh-ccw"
|
||||
:title="$t('NETWORK.BUTTON.REFRESH')"
|
||||
variant="clear"
|
||||
size="small"
|
||||
color-scheme="warning"
|
||||
icon="arrow-clockwise"
|
||||
class="!text-n-amber-12 dark:!text-n-amber-9"
|
||||
@click="refreshPage"
|
||||
/>
|
||||
<woot-button
|
||||
variant="clear"
|
||||
size="small"
|
||||
color-scheme="warning"
|
||||
icon="dismiss"
|
||||
|
||||
<Button
|
||||
ghost
|
||||
sm
|
||||
amber
|
||||
icon="i-lucide-x"
|
||||
class="!text-n-amber-12 dark:!text-n-amber-9"
|
||||
@click="closeNotification"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,12 +3,16 @@ import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
},
|
||||
props: {
|
||||
size: {
|
||||
type: String,
|
||||
default: 'small',
|
||||
default: 'sm',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
@@ -33,13 +37,13 @@ export default {
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<woot-button
|
||||
<NextButton
|
||||
v-if="!hasNextSidebar"
|
||||
ghost
|
||||
slate
|
||||
:size="size"
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
class="-ml-3 text-black-900 dark:text-slate-300"
|
||||
icon="list"
|
||||
icon="i-lucide-menu"
|
||||
class="-ml-3"
|
||||
@click="onMenuItemClick"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -35,7 +35,7 @@ export default {
|
||||
color-scheme="alert"
|
||||
:banner-message="bannerMessage"
|
||||
:action-button-label="actionButtonMessage"
|
||||
action-button-icon="mail"
|
||||
action-button-icon="i-lucide-mail"
|
||||
has-action-button
|
||||
@primary-action="resendVerificationEmail"
|
||||
/>
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
<script>
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Spinner,
|
||||
},
|
||||
props: {
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
buttonIconClass: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'button',
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'primary',
|
||||
},
|
||||
},
|
||||
created() {
|
||||
if (import.meta.env.DEV) {
|
||||
// eslint-disable-next-line
|
||||
console.warn(
|
||||
'[DEPRECATED] This component has been deprecated and will be removed soon. Please use v3/components/Form/Button.vue instead'
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button :type="type" class="button nice" :class="variant">
|
||||
<fluent-icon
|
||||
v-if="!isLoading && icon"
|
||||
class="icon"
|
||||
:class="buttonIconClass"
|
||||
:icon="icon"
|
||||
/>
|
||||
<Spinner v-if="isLoading" />
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
@@ -1,66 +0,0 @@
|
||||
<script>
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Spinner,
|
||||
},
|
||||
props: {
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
buttonText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
buttonClass: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
iconClass: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
spinnerClass: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'submit',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
computedClass() {
|
||||
return `button nice gap-2 ${this.buttonClass || ' '}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:type="type"
|
||||
data-testid="submit_button"
|
||||
:disabled="disabled"
|
||||
:class="computedClass"
|
||||
>
|
||||
<fluent-icon v-if="!!iconClass" :icon="iconClass" class="icon" />
|
||||
<span>{{ buttonText }}</span>
|
||||
<Spinner v-if="loading" class="ml-2" :color-scheme="spinnerClass" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
button:disabled {
|
||||
@apply bg-woot-100 dark:bg-woot-500/25 dark:text-slate-500 opacity-100;
|
||||
&:hover {
|
||||
@apply bg-woot-100 dark:bg-woot-500/25;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -134,7 +134,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
|
||||
<template>
|
||||
<div class="relative flex items-center justify-end resolve-actions">
|
||||
<div
|
||||
class="rounded-lg shadow button-group outline-1 outline"
|
||||
class="rounded-lg shadow outline-1 outline"
|
||||
:class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'"
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// [NOTE][DEPRECATED] This method is to be deprecated, please do not add new components to this file.
|
||||
/* eslint no-plusplus: 0 */
|
||||
import AvatarUploader from './widgets/forms/AvatarUploader.vue';
|
||||
import Button from './ui/WootButton.vue';
|
||||
import Code from './Code.vue';
|
||||
import ColorPicker from './widgets/ColorPicker.vue';
|
||||
import ConfirmDeleteModal from './widgets/modal/ConfirmDeleteModal.vue';
|
||||
@@ -18,7 +17,6 @@ import ModalHeader from './ModalHeader.vue';
|
||||
import Modal from './Modal.vue';
|
||||
import SidemenuIcon from './SidemenuIcon.vue';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import SubmitButton from './buttons/FormSubmitButton.vue';
|
||||
import Tabs from './ui/Tabs/Tabs.vue';
|
||||
import TabsItem from './ui/Tabs/TabsItem.vue';
|
||||
import Thumbnail from './widgets/Thumbnail.vue';
|
||||
@@ -26,7 +24,6 @@ import DatePicker from './ui/DatePicker/DatePicker.vue';
|
||||
|
||||
const WootUIKit = {
|
||||
AvatarUploader,
|
||||
Button,
|
||||
Code,
|
||||
ColorPicker,
|
||||
ConfirmDeleteModal,
|
||||
@@ -43,7 +40,6 @@ const WootUIKit = {
|
||||
ModalHeader,
|
||||
SidemenuIcon,
|
||||
Spinner,
|
||||
SubmitButton,
|
||||
Tabs,
|
||||
TabsItem,
|
||||
Thumbnail,
|
||||
|
||||
@@ -7,6 +7,7 @@ import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader.vue
|
||||
import WootDropdownDivider from 'shared/components/ui/dropdown/DropdownDivider.vue';
|
||||
import AvailabilityStatusBadge from '../widgets/conversation/AvailabilityStatusBadge.vue';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const { AVAILABILITY_STATUS_KEYS } = wootConstants;
|
||||
|
||||
@@ -17,6 +18,7 @@ export default {
|
||||
WootDropdownMenu,
|
||||
WootDropdownItem,
|
||||
AvailabilityStatusBadge,
|
||||
NextButton,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -101,19 +103,21 @@ export default {
|
||||
:key="status.value"
|
||||
class="flex items-baseline"
|
||||
>
|
||||
<woot-button
|
||||
size="small"
|
||||
:color-scheme="status.disabled ? '' : 'secondary'"
|
||||
:variant="status.disabled ? 'smooth' : 'clear'"
|
||||
class="status-change--dropdown-button"
|
||||
<NextButton
|
||||
sm
|
||||
:color="status.disabled ? 'blue' : 'slate'"
|
||||
:variant="status.disabled ? 'faded' : 'ghost'"
|
||||
class="status-change--dropdown-button !w-full !justify-start"
|
||||
@click="changeAvailabilityStatus(status.value)"
|
||||
>
|
||||
<AvailabilityStatusBadge :status="status.value" />
|
||||
{{ status.label }}
|
||||
</woot-button>
|
||||
<span class="min-w-0 truncate font-medium text-xs">
|
||||
{{ status.label }}
|
||||
</span>
|
||||
</NextButton>
|
||||
</WootDropdownItem>
|
||||
<WootDropdownDivider />
|
||||
<WootDropdownItem class="flex items-center justify-between p-2 m-0">
|
||||
<WootDropdownItem class="flex items-center justify-between px-3 py-2 m-0">
|
||||
<div class="flex items-center">
|
||||
<fluent-icon
|
||||
v-tooltip.right-start="$t('SIDEBAR.SET_AUTO_OFFLINE.INFO_TEXT')"
|
||||
@@ -123,7 +127,7 @@ export default {
|
||||
/>
|
||||
|
||||
<span
|
||||
class="mx-1 my-0 text-xs font-medium text-slate-600 dark:text-slate-100"
|
||||
class="mx-2 my-0 text-xs font-medium text-slate-600 dark:text-slate-100"
|
||||
>
|
||||
{{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}
|
||||
</span>
|
||||
|
||||
@@ -127,7 +127,6 @@ const settings = accountId => ({
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
globalConfigFlag: 'csmlEditorHost',
|
||||
toState: frontendURL(`accounts/${accountId}/settings/agent-bots`),
|
||||
toStateName: 'agent_bots',
|
||||
featureFlag: FEATURE_FLAGS.AGENT_BOTS,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
},
|
||||
emits: ['toggleAccounts'],
|
||||
data() {
|
||||
return { showSwitchButton: false };
|
||||
@@ -46,14 +50,13 @@ export default {
|
||||
class="absolute top-0 right-0 flex items-center justify-end w-full h-full rounded-md ltr:overlay-shadow ltr:dark:overlay-shadow-dark rtl:rtl-overlay-shadow rtl:dark:rtl-overlay-shadow-dark"
|
||||
>
|
||||
<div class="mx-2 my-0">
|
||||
<woot-button
|
||||
variant="clear"
|
||||
size="tiny"
|
||||
icon="arrow-swap"
|
||||
<NextButton
|
||||
ghost
|
||||
xs
|
||||
icon="i-lucide-arrow-right-left"
|
||||
:label="$t('SIDEBAR.SWITCH')"
|
||||
@click="$emit('toggleAccounts')"
|
||||
>
|
||||
{{ $t('SIDEBAR.SWITCH') }}
|
||||
</woot-button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
@@ -3,8 +3,12 @@ import { required, minLength } from '@vuelidate/validators';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
},
|
||||
props: {
|
||||
show: {
|
||||
type: Boolean,
|
||||
@@ -86,19 +90,24 @@ export default {
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div class="w-full">
|
||||
<woot-submit-button
|
||||
:disabled="
|
||||
v$.accountName.$invalid ||
|
||||
v$.accountName.$invalid ||
|
||||
uiFlags.isCreating
|
||||
"
|
||||
:button-text="$t('CREATE_ACCOUNT.FORM.SUBMIT')"
|
||||
:loading="uiFlags.isCreating"
|
||||
button-class="large expanded"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full flex justify-end gap-2 items-center">
|
||||
<NextButton
|
||||
faded
|
||||
slate
|
||||
type="reset"
|
||||
:label="$t('CREATE_ACCOUNT.FORM.CANCEL')"
|
||||
@click.prevent="() => $emit('closeAccountCreateModal')"
|
||||
/>
|
||||
<NextButton
|
||||
type="submit"
|
||||
:label="$t('CREATE_ACCOUNT.FORM.SUBMIT')"
|
||||
:is-loading="uiFlags.isCreating"
|
||||
:disabled="
|
||||
v$.accountName.$invalid ||
|
||||
v$.accountName.$invalid ||
|
||||
uiFlags.isCreating
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import Thumbnail from '../../widgets/Thumbnail.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Thumbnail,
|
||||
NextButton,
|
||||
},
|
||||
emits: ['toggleMenu'],
|
||||
computed: {
|
||||
@@ -25,10 +27,10 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<woot-button
|
||||
<NextButton
|
||||
v-tooltip.right="$t(`SIDEBAR.PROFILE_SETTINGS`)"
|
||||
variant="link"
|
||||
class="flex items-center rounded-full"
|
||||
link
|
||||
class="rounded-full"
|
||||
@click="handleClick"
|
||||
>
|
||||
<Thumbnail
|
||||
@@ -37,6 +39,7 @@ export default {
|
||||
:status="statusOfAgent"
|
||||
should-show-status-always
|
||||
size="32px"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
</woot-button>
|
||||
</NextButton>
|
||||
</template>
|
||||
|
||||
@@ -5,12 +5,14 @@ import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
|
||||
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
|
||||
import AvailabilityStatus from 'dashboard/components/layout/AvailabilityStatus.vue';
|
||||
import { FEATURE_FLAGS } from '../../../featureFlags';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
WootDropdownMenu,
|
||||
WootDropdownItem,
|
||||
AvailabilityStatus,
|
||||
NextButton,
|
||||
},
|
||||
props: {
|
||||
show: {
|
||||
@@ -82,37 +84,46 @@ export default {
|
||||
<AvailabilityStatus />
|
||||
<WootDropdownMenu>
|
||||
<WootDropdownItem v-if="showChangeAccountOption">
|
||||
<woot-button
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
size="small"
|
||||
icon="arrow-swap"
|
||||
<NextButton
|
||||
ghost
|
||||
sm
|
||||
slate
|
||||
icon="i-lucide-arrow-right-left"
|
||||
class="!w-full !justify-start"
|
||||
@click="$emit('toggleAccounts')"
|
||||
>
|
||||
{{ $t('SIDEBAR_ITEMS.CHANGE_ACCOUNTS') }}
|
||||
</woot-button>
|
||||
<span class="min-w-0 truncate font-medium text-xs">
|
||||
{{ $t('SIDEBAR_ITEMS.CHANGE_ACCOUNTS') }}
|
||||
</span>
|
||||
</NextButton>
|
||||
</WootDropdownItem>
|
||||
<WootDropdownItem v-if="showChatSupport">
|
||||
<woot-button
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
size="small"
|
||||
icon="chat-help"
|
||||
<NextButton
|
||||
ghost
|
||||
sm
|
||||
slate
|
||||
icon="i-lucide-message-circle-question"
|
||||
class="!w-full !justify-start"
|
||||
@click="$emit('showSupportChatWindow')"
|
||||
>
|
||||
{{ $t('SIDEBAR_ITEMS.CONTACT_SUPPORT') }}
|
||||
</woot-button>
|
||||
<span class="min-w-0 truncate font-medium text-xs">
|
||||
{{ $t('SIDEBAR_ITEMS.CONTACT_SUPPORT') }}
|
||||
</span>
|
||||
</NextButton>
|
||||
</WootDropdownItem>
|
||||
<WootDropdownItem>
|
||||
<woot-button
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
size="small"
|
||||
icon="keyboard"
|
||||
<NextButton
|
||||
ghost
|
||||
sm
|
||||
slate
|
||||
icon="i-lucide-keyboard"
|
||||
class="!w-full !justify-start"
|
||||
@click="handleKeyboardHelpClick"
|
||||
>
|
||||
{{ $t('SIDEBAR_ITEMS.KEYBOARD_SHORTCUTS') }}
|
||||
</woot-button>
|
||||
<span class="min-w-0 truncate font-medium text-xs">
|
||||
{{ $t('SIDEBAR_ITEMS.KEYBOARD_SHORTCUTS') }}
|
||||
</span>
|
||||
</NextButton>
|
||||
</WootDropdownItem>
|
||||
<WootDropdownItem>
|
||||
<router-link
|
||||
@@ -122,56 +133,70 @@ export default {
|
||||
>
|
||||
<a
|
||||
:href="href"
|
||||
class="h-8 bg-white button small clear secondary dark:bg-slate-800"
|
||||
:class="{ 'is-active': isActive }"
|
||||
@click="e => handleProfileSettingClick(e, navigate)"
|
||||
>
|
||||
<fluent-icon icon="person" size="14" class="icon icon--font" />
|
||||
<span class="button__content">
|
||||
{{ $t('SIDEBAR_ITEMS.PROFILE_SETTINGS') }}
|
||||
</span>
|
||||
<NextButton
|
||||
ghost
|
||||
sm
|
||||
slate
|
||||
icon="i-lucide-circle-user"
|
||||
class="!w-full !justify-start"
|
||||
>
|
||||
<span class="min-w-0 truncate font-medium text-xs">
|
||||
{{ $t('SIDEBAR_ITEMS.PROFILE_SETTINGS') }}
|
||||
</span>
|
||||
</NextButton>
|
||||
</a>
|
||||
</router-link>
|
||||
</WootDropdownItem>
|
||||
<WootDropdownItem>
|
||||
<woot-button
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
size="small"
|
||||
icon="appearance"
|
||||
<NextButton
|
||||
ghost
|
||||
sm
|
||||
slate
|
||||
icon="i-lucide-sun-moon"
|
||||
class="!w-full !justify-start"
|
||||
@click="openAppearanceOptions"
|
||||
>
|
||||
{{ $t('SIDEBAR_ITEMS.APPEARANCE') }}
|
||||
</woot-button>
|
||||
<span class="min-w-0 truncate font-medium text-xs">
|
||||
{{ $t('SIDEBAR_ITEMS.APPEARANCE') }}
|
||||
</span>
|
||||
</NextButton>
|
||||
</WootDropdownItem>
|
||||
<WootDropdownItem v-if="currentUser.type === 'SuperAdmin'">
|
||||
<a
|
||||
href="/super_admin"
|
||||
class="h-8 bg-white button small clear secondary dark:bg-slate-800"
|
||||
target="_blank"
|
||||
rel="noopener nofollow noreferrer"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="content-settings"
|
||||
size="14"
|
||||
class="icon icon--font"
|
||||
/>
|
||||
<span class="button__content">
|
||||
{{ $t('SIDEBAR_ITEMS.SUPER_ADMIN_CONSOLE') }}
|
||||
</span>
|
||||
<NextButton
|
||||
ghost
|
||||
sm
|
||||
slate
|
||||
icon="i-lucide-layout-dashboard"
|
||||
class="!w-full !justify-start"
|
||||
>
|
||||
<span class="min-w-0 truncate font-medium text-xs">
|
||||
{{ $t('SIDEBAR_ITEMS.SUPER_ADMIN_CONSOLE') }}
|
||||
</span>
|
||||
</NextButton>
|
||||
</a>
|
||||
</WootDropdownItem>
|
||||
<WootDropdownItem>
|
||||
<woot-button
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
size="small"
|
||||
icon="power"
|
||||
<NextButton
|
||||
ghost
|
||||
sm
|
||||
slate
|
||||
icon="i-lucide-circle-power"
|
||||
class="!w-full !justify-start"
|
||||
@click="logout"
|
||||
>
|
||||
{{ $t('SIDEBAR_ITEMS.LOGOUT') }}
|
||||
</woot-button>
|
||||
<span class="min-w-0 truncate font-medium text-xs">
|
||||
{{ $t('SIDEBAR_ITEMS.LOGOUT') }}
|
||||
</span>
|
||||
</NextButton>
|
||||
</WootDropdownItem>
|
||||
</WootDropdownMenu>
|
||||
</div>
|
||||
|
||||
@@ -13,9 +13,10 @@ import {
|
||||
isOnUnattendedView,
|
||||
} from '../../../store/modules/conversations/helpers/actionHelpers';
|
||||
import Policy from '../../policy.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: { SecondaryChildNavItem, Policy },
|
||||
components: { SecondaryChildNavItem, Policy, NextButton },
|
||||
props: {
|
||||
menuItem: {
|
||||
type: Object,
|
||||
@@ -48,13 +49,6 @@ export default {
|
||||
return !!this.menuItem.children;
|
||||
},
|
||||
isMenuItemVisible() {
|
||||
if (this.menuItem.globalConfigFlag) {
|
||||
// this checks for the `csmlEditorHost` flag in the global config
|
||||
// if this is present, we toggle the CSML editor menu item
|
||||
// TODO: This is very specific, and can be handled better, fix it
|
||||
return !!this.globalConfig[this.menuItem.globalConfigFlag];
|
||||
}
|
||||
|
||||
let isFeatureEnabled = true;
|
||||
if (this.menuItem.featureFlag) {
|
||||
isFeatureEnabled = this.isFeatureEnabledonAccount(
|
||||
@@ -205,14 +199,7 @@ export default {
|
||||
{{ $t(`SIDEBAR.${menuItem.label}`) }}
|
||||
</span>
|
||||
<div v-if="menuItem.showNewButton" class="flex items-center">
|
||||
<woot-button
|
||||
size="tiny"
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
icon="add"
|
||||
class="p-0 ml-2"
|
||||
@click="onClickOpen"
|
||||
/>
|
||||
<NextButton ghost xs slate icon="i-lucide-plus" @click="onClickOpen" />
|
||||
</div>
|
||||
</div>
|
||||
<router-link
|
||||
@@ -272,16 +259,15 @@ export default {
|
||||
>
|
||||
<li class="pl-1">
|
||||
<a :href="href">
|
||||
<woot-button
|
||||
size="tiny"
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
icon="add"
|
||||
<NextButton
|
||||
ghost
|
||||
xs
|
||||
slate
|
||||
icon="i-lucide-plus"
|
||||
:label="$t(`SIDEBAR.${menuItem.newLinkTag}`)"
|
||||
:data-testid="menuItem.dataTestid"
|
||||
@click="e => newLinkClick(e, navigate)"
|
||||
>
|
||||
{{ $t(`SIDEBAR.${menuItem.newLinkTag}`) }}
|
||||
</woot-button>
|
||||
/>
|
||||
</a>
|
||||
</li>
|
||||
</router-link>
|
||||
|
||||
-1
@@ -41,7 +41,6 @@ describe('AccountSelector', () => {
|
||||
'fluent-icon': FluentIcon,
|
||||
},
|
||||
stubs: {
|
||||
WootButton: { template: '<button />' },
|
||||
// override global stub
|
||||
WootModalHeader: false,
|
||||
},
|
||||
|
||||
+3
-3
@@ -2,7 +2,7 @@ import { shallowMount } from '@vue/test-utils';
|
||||
import { createStore } from 'vuex';
|
||||
import AgentDetails from '../AgentDetails.vue';
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
import WootButton from 'dashboard/components/ui/WootButton.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
describe('AgentDetails', () => {
|
||||
const currentUser = {
|
||||
@@ -40,12 +40,12 @@ describe('AgentDetails', () => {
|
||||
plugins: [store],
|
||||
components: {
|
||||
Thumbnail,
|
||||
WootButton,
|
||||
NextButton,
|
||||
},
|
||||
directives: {
|
||||
tooltip: mockTooltipDirective, // Mocking the tooltip directive
|
||||
},
|
||||
stubs: { WootButton: { template: '<button><slot /></button>' } },
|
||||
stubs: { NextButton: { template: '<button><slot /></button>' } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createStore } from 'vuex';
|
||||
import AvailabilityStatus from '../AvailabilityStatus.vue';
|
||||
import WootButton from 'dashboard/components/ui/WootButton.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
|
||||
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
|
||||
import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader.vue';
|
||||
@@ -40,7 +40,7 @@ describe('AvailabilityStatus', () => {
|
||||
global: {
|
||||
plugins: [store],
|
||||
components: {
|
||||
WootButton,
|
||||
NextButton,
|
||||
WootDropdownItem,
|
||||
WootDropdownMenu,
|
||||
WootDropdownHeader,
|
||||
|
||||
@@ -22,7 +22,7 @@ const store = createStore({
|
||||
describe('SidemenuIcon', () => {
|
||||
test('matches snapshot', () => {
|
||||
const wrapper = shallowMount(SidemenuIcon, {
|
||||
stubs: { WootButton: { template: '<button><slot /></button>' } },
|
||||
stubs: { NextButton: { template: '<button><slot /></button>' } },
|
||||
global: { plugins: [store] },
|
||||
});
|
||||
expect(wrapper.vm).toBeTruthy();
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
exports[`SidemenuIcon > matches snapshot 1`] = `
|
||||
<button
|
||||
class="-ml-3 text-black-900 dark:text-slate-300"
|
||||
color-scheme="secondary"
|
||||
icon="list"
|
||||
size="small"
|
||||
variant="clear"
|
||||
class="-ml-3"
|
||||
ghost=""
|
||||
icon="i-lucide-menu"
|
||||
size="sm"
|
||||
slate=""
|
||||
>
|
||||
|
||||
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
popupMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
routeText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
hasCloseButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
closeButtonText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
emits: ['open', 'close'],
|
||||
methods: {
|
||||
onClickOpenPath() {
|
||||
this.$emit('open');
|
||||
},
|
||||
onClickClose(e) {
|
||||
this.$emit('close', e);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="announcement-popup">
|
||||
<span v-if="popupMessage" class="popup-content">
|
||||
{{ popupMessage }}
|
||||
<span v-if="routeText" class="route-url" @click="onClickOpenPath">
|
||||
{{ routeText }}
|
||||
</span>
|
||||
</span>
|
||||
<div v-if="hasCloseButton" class="popup-close">
|
||||
<woot-button
|
||||
v-if="hasCloseButton"
|
||||
color-scheme="primary"
|
||||
variant="link"
|
||||
size="small"
|
||||
@click="onClickClose"
|
||||
>
|
||||
{{ closeButtonText }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.announcement-popup {
|
||||
max-width: 15rem;
|
||||
min-width: 10rem;
|
||||
display: flex;
|
||||
position: absolute;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
height: fit-content;
|
||||
background: var(--white);
|
||||
padding: 0 var(--space-normal);
|
||||
z-index: var(--z-index-much-higher);
|
||||
box-shadow: var(--b-200) 4px 4px 16px 4px;
|
||||
border-radius: var(--border-radius-normal);
|
||||
|
||||
.popup-content {
|
||||
font-size: var(--font-size-mini);
|
||||
color: var(--s-700);
|
||||
padding: var(--space-one) 0;
|
||||
.route-url {
|
||||
font-size: var(--font-size-mini);
|
||||
color: var(--s-600);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.popup-close {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--space-one) 0;
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,10 @@
|
||||
<script>
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
},
|
||||
props: {
|
||||
bannerMessage: {
|
||||
type: String,
|
||||
@@ -19,7 +24,7 @@ export default {
|
||||
},
|
||||
actionButtonVariant: {
|
||||
type: String,
|
||||
default: '',
|
||||
default: 'faded',
|
||||
},
|
||||
actionButtonLabel: {
|
||||
type: String,
|
||||
@@ -27,7 +32,7 @@ export default {
|
||||
},
|
||||
actionButtonIcon: {
|
||||
type: String,
|
||||
default: 'arrow-right',
|
||||
default: 'i-lucide-arrow-right',
|
||||
},
|
||||
colorScheme: {
|
||||
type: String,
|
||||
@@ -48,6 +53,18 @@ export default {
|
||||
}
|
||||
return classList;
|
||||
},
|
||||
// TODO - Remove this method when we standardize
|
||||
// the button color and variant names
|
||||
getButtonColor() {
|
||||
const colorMap = {
|
||||
primary: 'blue',
|
||||
secondary: 'blue',
|
||||
alert: 'ruby',
|
||||
warning: 'amber',
|
||||
};
|
||||
|
||||
return colorMap[this.colorScheme] || 'blue';
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onClick(e) {
|
||||
@@ -77,27 +94,23 @@ export default {
|
||||
</a>
|
||||
</span>
|
||||
<div class="actions">
|
||||
<woot-button
|
||||
<NextButton
|
||||
v-if="hasActionButton"
|
||||
size="tiny"
|
||||
xs
|
||||
:icon="actionButtonIcon"
|
||||
:variant="actionButtonVariant"
|
||||
color-scheme="primary"
|
||||
class-names="banner-action__button"
|
||||
:color="getButtonColor"
|
||||
:label="actionButtonLabel"
|
||||
@click="onClick"
|
||||
>
|
||||
{{ actionButtonLabel }}
|
||||
</woot-button>
|
||||
<woot-button
|
||||
/>
|
||||
<NextButton
|
||||
v-if="hasCloseButton"
|
||||
size="tiny"
|
||||
:color-scheme="colorScheme"
|
||||
icon="dismiss-circle"
|
||||
class-names="banner-action__button"
|
||||
xs
|
||||
icon="i-lucide-circle-x"
|
||||
:color="getButtonColor"
|
||||
:label="$t('GENERAL_SETTINGS.DISMISS')"
|
||||
@click="onClickClose"
|
||||
>
|
||||
{{ $t('GENERAL_SETTINGS.DISMISS') }}
|
||||
</woot-button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -106,13 +119,6 @@ export default {
|
||||
.banner {
|
||||
&.primary {
|
||||
@apply bg-woot-500 dark:bg-woot-500;
|
||||
.banner-action__button {
|
||||
@apply bg-woot-600 dark:bg-woot-600 border-none text-white;
|
||||
|
||||
&:hover {
|
||||
@apply bg-woot-700 dark:bg-woot-700;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
@@ -124,13 +130,6 @@ export default {
|
||||
|
||||
&.alert {
|
||||
@apply bg-n-ruby-3 text-n-ruby-12;
|
||||
.banner-action__button {
|
||||
@apply border-none text-n-ruby-12 bg-n-ruby-5;
|
||||
|
||||
&:hover {
|
||||
@apply bg-n-ruby-4;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-n-ruby-12;
|
||||
@@ -146,21 +145,12 @@ export default {
|
||||
|
||||
&.gray {
|
||||
@apply text-black-500 dark:text-black-500;
|
||||
.banner-action__button {
|
||||
@apply text-white dark:text-white;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
@apply ml-1 underline text-white dark:text-white text-xs;
|
||||
}
|
||||
|
||||
.banner-action__button {
|
||||
::v-deep .button__content {
|
||||
@apply whitespace-nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.banner-message {
|
||||
@apply flex items-center;
|
||||
}
|
||||
|
||||
@@ -218,14 +218,14 @@ const emitDateRange = () => {
|
||||
/>
|
||||
<div
|
||||
v-if="showDatePicker"
|
||||
class="flex absolute top-9 ltr:left-0 rtl:right-0 z-30 shadow-md select-none w-[880px] h-[490px] rounded-2xl border border-slate-50 dark:border-slate-800 bg-white dark:bg-slate-800"
|
||||
class="flex absolute top-9 ltr:left-0 rtl:right-0 z-30 shadow-md select-none w-[880px] h-[490px] rounded-2xl bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container"
|
||||
>
|
||||
<CalendarDateRange
|
||||
:selected-range="selectedRange"
|
||||
@set-range="setDateRange"
|
||||
/>
|
||||
<div
|
||||
class="flex flex-col w-[680px] ltr:border-l rtl:border-r border-slate-50 dark:border-slate-700/50"
|
||||
class="flex flex-col w-[680px] ltr:border-l rtl:border-r border-n-strong"
|
||||
>
|
||||
<div class="flex justify-around h-fit">
|
||||
<!-- Calendars for Start and End Dates -->
|
||||
@@ -251,12 +251,12 @@ const emitDateRange = () => {
|
||||
@validate="updateManualInput($event, calendar)"
|
||||
@error="handleManualInputError($event)"
|
||||
/>
|
||||
<div class="py-5 border-b border-slate-50 dark:border-slate-700/50">
|
||||
<div class="py-5 border-b border-n-strong">
|
||||
<div
|
||||
class="flex flex-col items-center gap-2 px-5 min-w-[340px] max-h-[352px]"
|
||||
:class="
|
||||
calendar === START_CALENDAR &&
|
||||
'ltr:border-r rtl:border-l border-slate-50 dark:border-slate-700/50'
|
||||
'ltr:border-r rtl:border-l border-n-strong'
|
||||
"
|
||||
>
|
||||
<CalendarYear
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user