Compare commits

..
Author SHA1 Message Date
Sojan daacd6eda5 refactor: split connection timeout handling into modular concerns
- Extract query inspection logic to Database::QueryInspection
- Extract query logging to Database::QueryLogging
- Move connection diagnostics to Database::ConnectionDiagnostics
- Fix all rubocop issues by improving code organization
2025-03-19 18:31:38 -07:00
Sojan a4617b2dcb refactor: improve code organization for connection timeout handler 2025-03-19 18:23:13 -07:00
Sojan JoseandGitHub 63041d743d Merge branch 'develop' into debug/connection-timeout 2025-03-19 17:30:26 -07:00
Sojan 687f7fd33a style: fix whitespace and formatting issues 2025-03-19 16:58:27 -07:00
Sojan 8b7c9173e2 feat: add detailed logging for ActiveRecord::ConnectionTimeoutError
- Add comprehensive connection pool and active query diagnostics
- Track database locks and blocking queries
- Include thread/process information for troubleshooting
- Add detailed PostgreSQL query wait event tracking
- Update exception tracker to support additional context
2025-03-19 16:56:27 -07:00
445 changed files with 4413 additions and 7620 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 966 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 934 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

-1
View File
@@ -173,7 +173,6 @@ gem 'pgvector'
# Convert Website HTML to Markdown # Convert Website HTML to Markdown
gem 'reverse_markdown' gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai' gem 'ruby-openai'
gem 'shopify_api' gem 'shopify_api'
+4 -7
View File
@@ -378,8 +378,6 @@ GEM
io-console (0.6.0) io-console (0.6.0)
irb (1.7.2) irb (1.7.2)
reline (>= 0.3.6) reline (>= 0.3.6)
iso-639 (0.3.8)
csv
jbuilder (2.11.5) jbuilder (2.11.5)
actionview (>= 5.0.0) actionview (>= 5.0.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
@@ -501,14 +499,14 @@ GEM
newrelic_rpm (9.6.0) newrelic_rpm (9.6.0)
base64 base64
nio4r (2.7.3) nio4r (2.7.3)
nokogiri (1.18.4) nokogiri (1.18.3)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.4-arm64-darwin) nokogiri (1.18.3-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.4-x86_64-darwin) nokogiri (1.18.3-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.4-x86_64-linux-gnu) nokogiri (1.18.3-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
oauth (1.1.0) oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1) oauth-tty (~> 1.0, >= 1.0.1)
@@ -915,7 +913,6 @@ DEPENDENCIES
hashie hashie
html2text html2text
image_processing image_processing
iso-639
jbuilder jbuilder
json_refs json_refs
json_schemer json_schemer
+46 -63
View File
@@ -1,11 +1,22 @@
<img src="./.github/screenshots/header.png#gh-light-mode-only" width="100%" alt="Header light mode"/> ## 🚨 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="./.github/screenshots/header-dark.png#gh-dark-mode-only" width="100%" alt="Header dark mode"/>
<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"/>
___ ___
# Chatwoot # Chatwoot
The modern customer support platform, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc. 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>
<p> <p>
<a href="https://codeclimate.com/github/chatwoot/chatwoot/maintainability"><img src="https://api.codeclimate.com/v1/badges/e6e3f66332c91e5a4c0c/maintainability" alt="Maintainability"></a> <a href="https://codeclimate.com/github/chatwoot/chatwoot/maintainability"><img src="https://api.codeclimate.com/v1/badges/e6e3f66332c91e5a4c0c/maintainability" alt="Maintainability"></a>
@@ -20,71 +31,41 @@ The modern customer support platform, an open-source alternative to Intercom, Ze
<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> <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> </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"/>
<p> 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.
<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>
<img src="./.github/screenshots/dashboard.png#gh-light-mode-only" width="100%" alt="Chat dashboard dark mode"/> ## Features
<img src="./.github/screenshots/dashboard-dark.png#gh-dark-mode-only" width="100%" alt="Chat dashboard"/>
--- Chatwoot supports the following conversation channels:
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. - **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.
### ✨ Captain AI Agent for Support And more.
Supercharge your support with Captain, Chatwoots 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). Other features include:
### 💬 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 Chatwoots webhooks and APIs.
- **Integrations**: Chatwoot natively integrates with Slack right now. Manage your conversations in Slack without logging into the dashboard.
## Documentation ## Documentation
@@ -126,11 +107,13 @@ For other supported options, checkout our [deployment page](https://chatwoot.com
Looking to report a vulnerability? Please refer our [SECURITY.md](./SECURITY.md) file. Looking to report a vulnerability? Please refer our [SECURITY.md](./SECURITY.md) file.
## Community
## Community? Questions? Support ?
If you need help or just want to hang out, come, say hi on our [Discord](https://discord.gg/cJXdrwS) server. 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): Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contributors):
-35
View File
@@ -64,40 +64,5 @@ class ContactInboxBuilder
inbox_id: @inbox.id, inbox_id: @inbox.id,
source_id: @source_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 theres a contact inbox with the
# same source ID but linked to a different contact. This can happen
# if the agent updates the contacts 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
end end
@@ -0,0 +1,51 @@
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
@@ -0,0 +1,91 @@
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
@@ -0,0 +1,33 @@
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
@@ -1,8 +1,10 @@
module RequestExceptionHandler module RequestExceptionHandler
extend ActiveSupport::Concern extend ActiveSupport::Concern
include Database::ConnectionDiagnostics
included do included do
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
rescue_from ActiveRecord::ConnectionTimeoutError, with: :handle_connection_timeout
end end
private private
@@ -18,11 +20,36 @@ module RequestExceptionHandler
rescue ActionController::ParameterMissing => e rescue ActionController::ParameterMissing => e
log_handled_error(e) log_handled_error(e)
render_could_not_create_error(e.message) render_could_not_create_error(e.message)
rescue ActiveRecord::ConnectionTimeoutError => e
handle_connection_timeout(e)
ensure ensure
# to address the thread variable leak issues in Puma/Thin webserver # to address the thread variable leak issues in Puma/Thin webserver
Current.reset Current.reset
end 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) def render_unauthorized(message)
render json: { error: message }, status: :unauthorized render json: { error: message }, status: :unauthorized
end end
@@ -43,6 +70,10 @@ module RequestExceptionHandler
render json: { error: message }, status: :internal_server_error render json: { error: message }, status: :internal_server_error
end end
def render_service_unavailable(message)
render json: { error: message }, status: :service_unavailable
end
def render_record_invalid(exception) def render_record_invalid(exception)
log_handled_error(exception) log_handled_error(exception)
render json: { render json: {
@@ -4,6 +4,7 @@
@apply inline-block h-6 py-0 px-6 relative align-middle w-6; @apply inline-block h-6 py-0 px-6 relative align-middle w-6;
&.message { &.message {
@include normal-shadow;
@apply bg-white dark:bg-slate-800 rounded-full left-0 my-3 mx-auto p-4 top-0; @apply bg-white dark:bg-slate-800 rounded-full left-0 my-3 mx-auto p-4 top-0;
&::before { &::before {
@@ -1,7 +1,79 @@
@import 'dashboard/assets/scss/variables'; @import 'dashboard/assets/scss/variables';
@import 'widget/assets/scss/mixins';
$spinner-before-border-color: rgba(255, 255, 255, 0.7); $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 // input form
@mixin ghost-input() { @mixin ghost-input() {
box-shadow: none; box-shadow: none;
@@ -15,6 +87,65 @@ $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() { @mixin color-spinner() {
@keyframes spinner { @keyframes spinner {
to { to {
@@ -99,3 +230,17 @@ $spinner-before-border-color: rgba(255, 255, 255, 0.7);
border-left: $size solid transparent; 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);
}
@@ -55,13 +55,13 @@ defineExpose({ dialogRef, contactsFormRef, onSuccess });
@click="closeDialog" @click="closeDialog"
/> />
<Button <Button
type="submit"
:label=" :label="
t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.SAVE_CONTACT') t('CONTACTS_LAYOUT.HEADER.ACTIONS.CONTACT_CREATION.SAVE_CONTACT')
" "
color="blue" color="blue"
:disabled="contactsFormRef?.isFormInvalid" :disabled="contactsFormRef?.isFormInvalid"
:is-loading="isCreatingContact" :is-loading="isCreatingContact"
@click="handleDialogConfirm"
/> />
</div> </div>
</template> </template>
@@ -190,7 +190,7 @@ const STYLE_CONFIG = {
const variantClasses = computed(() => { const variantClasses = computed(() => {
const variantMap = { const variantMap = {
ghost: `${STYLE_CONFIG.colors[computedColor.value].ghost}`, ghost: `${STYLE_CONFIG.colors[computedColor.value].ghost}`,
link: `${STYLE_CONFIG.colors[computedColor.value].link} p-0 font-medium underline-offset-2`, link: `${STYLE_CONFIG.colors[computedColor.value].link} p-0 font-medium underline-offset-4`,
outline: STYLE_CONFIG.colors[computedColor.value].outline, outline: STYLE_CONFIG.colors[computedColor.value].outline,
faded: STYLE_CONFIG.colors[computedColor.value].faded, faded: STYLE_CONFIG.colors[computedColor.value].faded,
solid: STYLE_CONFIG.colors[computedColor.value].solid, solid: STYLE_CONFIG.colors[computedColor.value].solid,
@@ -13,9 +13,6 @@ const attachment = computed(() => {
<template> <template>
<BaseBubble class="bg-transparent" data-bubble-name="audio"> <BaseBubble class="bg-transparent" data-bubble-name="audio">
<AudioChip <AudioChip :attachment="attachment" class="p-2 text-n-slate-12" />
:attachment="attachment"
class="p-2 text-n-slate-12 skip-context-menu"
/>
</BaseBubble> </BaseBubble>
</template> </template>
@@ -56,7 +56,6 @@ const downloadAttachment = async () => {
</div> </div>
<div v-else class="relative group rounded-lg overflow-hidden"> <div v-else class="relative group rounded-lg overflow-hidden">
<img <img
class="skip-context-menu"
:src="attachment.dataUrl" :src="attachment.dataUrl"
:width="attachment.width" :width="attachment.width"
:height="attachment.height" :height="attachment.height"
@@ -64,9 +63,8 @@ const downloadAttachment = async () => {
@error="handleError" @error="handleError"
/> />
<div <div
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" 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"
/> >
<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 solid slate icon="i-lucide-expand" class="opacity-60" />
<Button <Button
xs xs
@@ -41,13 +41,13 @@ const onVideoLoadError = () => {
<div v-if="content" v-dompurify-html="formattedContent" class="mb-2" /> <div v-if="content" v-dompurify-html="formattedContent" class="mb-2" />
<img <img
v-if="!hasImgStoryError" v-if="!hasImgStoryError"
class="rounded-lg max-w-80 skip-context-menu" class="rounded-lg max-w-80"
:src="attachment.dataUrl" :src="attachment.dataUrl"
@error="onImageLoadError" @error="onImageLoadError"
/> />
<video <video
v-else-if="!hasVideoStoryError" v-else-if="!hasVideoStoryError"
class="rounded-lg max-w-80 skip-context-menu" class="rounded-lg max-w-80"
controls controls
:src="attachment.dataUrl" :src="attachment.dataUrl"
@error="onVideoLoadError" @error="onVideoLoadError"
@@ -35,13 +35,13 @@ const isReel = computed(() => {
<div class="relative group rounded-lg overflow-hidden"> <div class="relative group rounded-lg overflow-hidden">
<div <div
v-if="isReel" v-if="isReel"
class="absolute p-2 flex items-start justify-end right-0 pointer-events-none" class="absolute p-2 flex items-start justify-end right-0"
> >
<Icon icon="i-lucide-instagram" class="text-white shadow-lg" /> <Icon icon="i-lucide-instagram" class="text-white shadow-lg" />
</div> </div>
<video <video
controls controls
class="rounded-lg skip-context-menu" class="rounded-lg"
:src="attachment.dataUrl" :src="attachment.dataUrl"
:class="{ :class="{
'max-w-48': isReel, 'max-w-48': isReel,
@@ -36,7 +36,7 @@ const handleError = () => {
</div> </div>
<img <img
v-else v-else
class="object-cover w-full h-full skip-context-menu" class="object-cover w-full h-full"
:src="attachment.dataUrl" :src="attachment.dataUrl"
@error="handleError" @error="handleError"
/> />
@@ -61,7 +61,6 @@ import {
getUserPermissions, getUserPermissions,
filterItemsByPermission, filterItemsByPermission,
} from 'dashboard/helper/permissionsHelper.js'; } from 'dashboard/helper/permissionsHelper.js';
import { matchesFilters } from '../store/modules/conversations/helpers/filterHelpers';
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events'; import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
import { ASSIGNEE_TYPE_TAB_PERMISSIONS } from 'dashboard/constants/permissions.js'; import { ASSIGNEE_TYPE_TAB_PERMISSIONS } from 'dashboard/constants/permissions.js';
@@ -106,7 +105,7 @@ const advancedFilterTypes = ref(
); );
const currentUser = useMapGetter('getCurrentUser'); const currentUser = useMapGetter('getCurrentUser');
const chatLists = useMapGetter('getFilteredConversations'); const chatLists = useMapGetter('getAllConversations');
const mineChatsList = useMapGetter('getMineChats'); const mineChatsList = useMapGetter('getMineChats');
const allChatList = useMapGetter('getAllStatusChats'); const allChatList = useMapGetter('getAllStatusChats');
const unAssignedChatsList = useMapGetter('getUnAssignedChats'); const unAssignedChatsList = useMapGetter('getUnAssignedChats');
@@ -325,14 +324,6 @@ const conversationList = computed(() => {
} else { } else {
localConversationList = [...chatLists.value]; localConversationList = [...chatLists.value];
} }
if (activeFolder.value) {
const { payload } = activeFolder.value.query;
localConversationList = localConversationList.filter(conversation => {
return matchesFilters(conversation, payload);
});
}
return localConversationList; return localConversationList;
}); });
@@ -469,12 +460,6 @@ function setParamsForEditFolderModal() {
campaigns: campaigns.value, campaigns: campaigns.value,
languages: languages, languages: languages,
countries: countries, 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, filterTypes: advancedFilterTypes.value,
allCustomAttributes: conversationCustomAttributes.value, allCustomAttributes: conversationCustomAttributes.value,
}; };
@@ -9,15 +9,12 @@ import { getRegexp } from 'shared/helpers/Validators';
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
import NextButton from 'dashboard/components-next/button/Button.vue';
const DATE_FORMAT = 'yyyy-MM-dd'; const DATE_FORMAT = 'yyyy-MM-dd';
export default { export default {
components: { components: {
MultiselectDropdown, MultiselectDropdown,
HelperTextPopup, HelperTextPopup,
NextButton,
}, },
props: { props: {
label: { type: String, required: true }, label: { type: String, required: true },
@@ -222,13 +219,14 @@ export default {
class="mt-0.5" class="mt-0.5"
/> />
</span> </span>
<NextButton <woot-button
v-if="showActions && value" v-if="showActions && value"
v-tooltip.left="$t('CUSTOM_ATTRIBUTES.ACTIONS.DELETE')" v-tooltip.left="$t('CUSTOM_ATTRIBUTES.ACTIONS.DELETE')"
slate variant="link"
sm size="medium"
link color-scheme="secondary"
icon="i-lucide-trash-2" icon="delete"
class-names="flex justify-end !w-fit"
@click="onDelete" @click="onDelete"
/> />
</div> </div>
@@ -248,10 +246,10 @@ export default {
@keyup.enter="onUpdate" @keyup.enter="onUpdate"
/> />
<div> <div>
<NextButton <woot-button
sm size="small"
icon="i-lucide-check" icon="checkmark"
class="ltr:rounded-l-none rtl:rounded-r-none h-[34px]" class="ltr:rounded-l-none rtl:rounded-r-none"
@click="onUpdate" @click="onUpdate"
/> />
</div> </div>
@@ -283,27 +281,25 @@ export default {
> >
{{ displayValue || '---' }} {{ displayValue || '---' }}
</p> </p>
<div <div class="flex max-w-[2rem] gap-1 ml-1 rtl:mr-1 rtl:ml-0">
class="flex items-center max-w-[2rem] gap-1 ml-1 rtl:mr-1 rtl:ml-0" <woot-button
>
<NextButton
v-if="showActions && value" v-if="showActions && value"
v-tooltip="$t('CUSTOM_ATTRIBUTES.ACTIONS.COPY')" v-tooltip="$t('CUSTOM_ATTRIBUTES.ACTIONS.COPY')"
xs variant="link"
slate size="small"
ghost color-scheme="secondary"
icon="i-lucide-clipboard" icon="clipboard"
class="hidden group-hover:flex flex-shrink-0" class-names="hidden group-hover:flex !w-6 flex-shrink-0"
@click="onCopy" @click="onCopy"
/> />
<NextButton <woot-button
v-if="showActions" v-if="showActions"
v-tooltip.right="$t('CUSTOM_ATTRIBUTES.ACTIONS.EDIT')" v-tooltip.right="$t('CUSTOM_ATTRIBUTES.ACTIONS.EDIT')"
xs variant="link"
slate size="small"
ghost color-scheme="secondary"
icon="i-lucide-pen" icon="edit"
class="hidden group-hover:flex flex-shrink-0" class-names="hidden group-hover:flex !w-6 flex-shrink-0"
@click="onEdit" @click="onEdit"
/> />
</div> </div>
@@ -1,11 +1,9 @@
<script> <script>
import DatePicker from 'vue-datepicker-next'; import DatePicker from 'vue-datepicker-next';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default { export default {
components: { components: {
DatePicker, DatePicker,
NextButton,
}, },
emits: ['close', 'chooseTime'], emits: ['close', 'chooseTime'],
@@ -54,23 +52,18 @@ export default {
v-model:value="snoozeTime" v-model:value="snoozeTime"
type="datetime" type="datetime"
inline inline
input-class="mx-input " input-class="mx-input reset-base"
:lang="lang" :lang="lang"
:disabled-date="disabledDate" :disabled-date="disabledDate"
:disabled-time="disabledTime" :disabled-time="disabledTime"
/> />
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2"> <div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<NextButton <woot-button variant="clear" @click.prevent="onClose">
faded {{ $t('CONVERSATION.CUSTOM_SNOOZE.CANCEL') }}
slate </woot-button>
type="reset" <woot-button>
:label="$t('CONVERSATION.CUSTOM_SNOOZE.CANCEL')" {{ $t('CONVERSATION.CUSTOM_SNOOZE.APPLY') }}
@click.prevent="onClose" </woot-button>
/>
<NextButton
type="submit"
:label="$t('CONVERSATION.CUSTOM_SNOOZE.APPLY')"
/>
</div> </div>
</form> </form>
</div> </div>
@@ -2,7 +2,6 @@
// [TODO] Use Teleport to move the modal to the end of the body // [TODO] Use Teleport to move the modal to the end of the body
import { ref, computed, defineEmits, onMounted } from 'vue'; import { ref, computed, defineEmits, onMounted } from 'vue';
import { useEventListener } from '@vueuse/core'; import { useEventListener } from '@vueuse/core';
import Button from 'dashboard/components-next/button/Button.vue';
const { modalType, closeOnBackdropClick, onClose } = defineProps({ const { modalType, closeOnBackdropClick, onClose } = defineProps({
closeOnBackdropClick: { type: Boolean, default: true }, closeOnBackdropClick: { type: Boolean, default: true },
@@ -86,11 +85,11 @@ onMounted(() => {
@mouse.stop @mouse.stop
@mousedown="event => event.stopPropagation()" @mousedown="event => event.stopPropagation()"
> >
<Button <woot-button
v-if="showCloseButton" v-if="showCloseButton"
ghost color-scheme="secondary"
slate icon="dismiss"
icon="i-lucide-x" variant="clear"
class="absolute z-10 ltr:right-2 rtl:left-2 top-2" class="absolute z-10 ltr:right-2 rtl:left-2 top-2"
@click="close" @click="close"
/> />
@@ -11,8 +11,6 @@ import {
} from 'dashboard/helper/routeHelpers'; } from 'dashboard/helper/routeHelpers';
import { useEventListener } from '@vueuse/core'; import { useEventListener } from '@vueuse/core';
import Button from 'dashboard/components-next/button/Button.vue';
const { t } = useI18n(); const { t } = useI18n();
const route = useRoute(); const route = useRoute();
@@ -109,31 +107,34 @@ onBeforeUnmount(() => {
<template> <template>
<transition name="network-notification-fade" tag="div"> <transition name="network-notification-fade" tag="div">
<div v-show="showNotification" class="fixed z-50 top-2 left-2 group"> <div v-show="showNotification" class="fixed z-50 top-4 left-2 group">
<div <div
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" class="relative flex items-center justify-between w-full px-2 py-1 bg-yellow-200 rounded-lg shadow-lg dark:bg-yellow-700"
> >
<fluent-icon :icon="iconName" class="text-n-amber-12" size="18" /> <fluent-icon
<span class="px-2 text-xs font-medium tracking-wide text-n-amber-12"> :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"
>
{{ bannerText }} {{ bannerText }}
</span> </span>
<Button <woot-button
v-if="canRefresh" v-if="canRefresh"
ghost
sm
amber
icon="i-lucide-refresh-ccw"
:title="$t('NETWORK.BUTTON.REFRESH')" :title="$t('NETWORK.BUTTON.REFRESH')"
class="!text-n-amber-12 dark:!text-n-amber-9" variant="clear"
size="small"
color-scheme="warning"
icon="arrow-clockwise"
@click="refreshPage" @click="refreshPage"
/> />
<woot-button
<Button variant="clear"
ghost size="small"
sm color-scheme="warning"
amber icon="dismiss"
icon="i-lucide-x"
class="!text-n-amber-12 dark:!text-n-amber-9"
@click="closeNotification" @click="closeNotification"
/> />
</div> </div>
@@ -0,0 +1,90 @@
<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>
@@ -2,12 +2,10 @@
import { useMessageFormatter } from 'shared/composables/useMessageFormatter'; import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { useAI } from 'dashboard/composables/useAI'; import { useAI } from 'dashboard/composables/useAI';
import AILoader from './AILoader.vue'; import AILoader from './AILoader.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default { export default {
components: { components: {
AILoader, AILoader,
NextButton,
}, },
props: { props: {
aiOption: { aiOption: {
@@ -86,22 +84,16 @@ export default {
</div> </div>
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2"> <div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<NextButton <woot-button variant="clear" @click.prevent="onClose">
faded {{
slate
type="reset"
:label="
$t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.BUTTONS.CANCEL') $t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.BUTTONS.CANCEL')
" }}
@click.prevent="onClose" </woot-button>
/> <woot-button :disabled="!generatedContent">
<NextButton {{
type="submit"
:disabled="!generatedContent"
:label="
$t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.BUTTONS.APPLY') $t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.BUTTONS.APPLY')
" }}
/> </woot-button>
</div> </div>
</form> </form>
</div> </div>
@@ -6,12 +6,7 @@ import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAI } from 'dashboard/composables/useAI'; import { useAI } from 'dashboard/composables/useAI';
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default { export default {
components: {
NextButton,
},
emits: ['close'], emits: ['close'],
setup() { setup() {
@@ -99,30 +94,16 @@ export default {
/> />
</div> </div>
<div class="flex flex-row justify-between w-full gap-2 px-0 py-2"> <div class="flex flex-row justify-between w-full gap-2 px-0 py-2">
<NextButton <woot-button variant="link" @click.prevent="openOpenAIDoc">
ghost {{ $t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.BUTTONS.NEED_HELP') }}
type="button" </woot-button>
class="!px-3"
:label="
$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.BUTTONS.NEED_HELP')
"
@click.prevent="openOpenAIDoc"
/>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<NextButton <woot-button variant="clear" @click.prevent="onDismiss">
faded {{ $t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.BUTTONS.DISMISS') }}
slate </woot-button>
type="reset" <woot-button :is-disabled="v$.value.$invalid">
:label=" {{ $t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.BUTTONS.FINISH') }}
$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.BUTTONS.DISMISS') </woot-button>
"
@click.prevent="onDismiss"
/>
<NextButton
type="submit"
:disabled="v$.value.$invalid"
:label="$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.BUTTONS.FINISH')"
/>
</div> </div>
</div> </div>
</form> </form>
@@ -2,8 +2,6 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { formatBytes } from 'shared/helpers/FileHelper'; import { formatBytes } from 'shared/helpers/FileHelper';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({ const props = defineProps({
attachments: { attachments: {
type: Array, type: Array,
@@ -21,45 +19,13 @@ const recordedAudioAttachments = computed(() =>
props.attachments.filter(attachment => attachment.isRecordedAudio) props.attachments.filter(attachment => attachment.isRecordedAudio)
); );
// Create unified list of attachments with metadata for rendering const onRemoveAttachment = itemIndex => {
const allAttachments = computed(() => { emit(
const audioAttachments = recordedAudioAttachments.value.map( 'removeAttachment',
(attachment, index) => ({ nonRecordedAudioAttachments.value
attachment, .filter((_, index) => index !== itemIndex)
isRecordedAudio: true, .concat(recordedAudioAttachments.value)
index,
key: `audio-${index}`,
})
); );
const otherAttachments = nonRecordedAudioAttachments.value.map(
(attachment, index) => ({
attachment,
isRecordedAudio: false,
index,
key: attachment.id,
})
);
return [...audioAttachments, ...otherAttachments];
});
const removeAttachment = (isRecordedAudio, itemIndex) => {
if (isRecordedAudio) {
emit(
'removeAttachment',
nonRecordedAudioAttachments.value.concat(
recordedAudioAttachments.value.filter((_, index) => index !== itemIndex)
)
);
} else {
emit(
'removeAttachment',
nonRecordedAudioAttachments.value
.filter((_, index) => index !== itemIndex)
.concat(recordedAudioAttachments.value)
);
}
}; };
const formatFileSize = file => { const formatFileSize = file => {
@@ -78,41 +44,39 @@ const fileName = file => {
</script> </script>
<template> <template>
<div class="flex flex-col overflow-auto max-h-[12.5rem]"> <div class="flex overflow-auto max-h-[12.5rem]">
<div <div
v-for="item in allAttachments" v-for="(attachment, index) in nonRecordedAudioAttachments"
:key="item.key" :key="attachment.id"
class="preview-item flex items-center p-1 bg-n-slate-9/10 gap-1 rounded-md w-[15rem] mb-1" class="preview-item flex items-center p-1 bg-slate-50 dark:bg-slate-800 gap-1 rounded-md w-[15rem] mb-1"
> >
<div class="max-w-[4rem] flex-shrink-0 w-6 flex items-center"> <div class="max-w-[4rem] flex-shrink-0 w-6 flex items-center">
<img <img
v-if="!item.isRecordedAudio && isTypeImage(item.attachment.resource)" v-if="isTypeImage(attachment.resource)"
class="object-cover w-6 h-6 rounded-sm" class="object-cover w-6 h-6 rounded-sm"
:src="item.attachment.thumb" :src="attachment.thumb"
/> />
<span v-else class="relative w-6 h-6 text-lg text-left -top-px"> <span v-else class="relative w-6 h-6 text-lg text-left -top-px">
{{ item.isRecordedAudio ? '🎤' : '📄' }} 📄
</span> </span>
</div> </div>
<div class="max-w-3/5 min-w-[50%] overflow-hidden text-ellipsis"> <div class="max-w-3/5 min-w-[50%] overflow-hidden text-ellipsis">
<span <span
class="h-4 overflow-hidden text-sm font-medium text-ellipsis whitespace-nowrap" class="h-4 overflow-hidden text-sm font-medium text-ellipsis whitespace-nowrap"
> >
{{ fileName(item.attachment.resource) }} {{ fileName(attachment.resource) }}
</span> </span>
</div> </div>
<div class="w-[30%] justify-center"> <div class="w-[30%] justify-center">
<span class="overflow-hidden text-xs text-ellipsis whitespace-nowrap"> <span class="overflow-hidden text-xs text-ellipsis whitespace-nowrap">
{{ formatFileSize(item.attachment.resource) }} {{ formatFileSize(attachment.resource) }}
</span> </span>
</div> </div>
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<Button <woot-button
ghost class="!w-6 !h-6 text-sm rounded-md hover:bg-slate-50 dark:hover:bg-slate-800 clear secondary"
slate icon="dismiss"
xs @click="onRemoveAttachment(index)"
icon="i-lucide-x"
@click="removeAttachment(item.isRecordedAudio, item.index)"
/> />
</div> </div>
</div> </div>
@@ -2,14 +2,11 @@
import AutomationActionTeamMessageInput from './AutomationActionTeamMessageInput.vue'; import AutomationActionTeamMessageInput from './AutomationActionTeamMessageInput.vue';
import AutomationActionFileInput from './AutomationFileInput.vue'; import AutomationActionFileInput from './AutomationFileInput.vue';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue'; import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default { export default {
components: { components: {
AutomationActionTeamMessageInput, AutomationActionTeamMessageInput,
AutomationActionFileInput, AutomationActionFileInput,
WootMessageEditor, WootMessageEditor,
NextButton,
}, },
props: { props: {
modelValue: { modelValue: {
@@ -175,11 +172,11 @@ export default {
/> />
</div> </div>
</div> </div>
<NextButton <woot-button
v-if="!isMacro" v-if="!isMacro"
icon="i-lucide-x" icon="dismiss"
slate variant="clear"
ghost color-scheme="secondary"
class="flex-shrink-0" class="flex-shrink-0"
@click="removeAction" @click="removeAction"
/> />
@@ -32,7 +32,7 @@ const buttonStyleClass = props.compact ? 'text-sm' : 'text-base';
:class="buttonStyleClass" :class="buttonStyleClass"
@click.capture="goBack" @click.capture="goBack"
> >
<i class="i-lucide-chevron-left -ml-1 text-lg" /> <fluent-icon icon="chevron-left" class="-ml-1" />
{{ buttonLabel || $t('GENERAL_SETTINGS.BACK') }} {{ buttonLabel || $t('GENERAL_SETTINGS.BACK') }}
</button> </button>
</template> </template>
@@ -1,11 +1,6 @@
<script> <script>
import NextButton from 'dashboard/components-next/button/Button.vue';
export default { export default {
name: 'FilterInput', name: 'FilterInput',
components: {
NextButton,
},
props: { props: {
modelValue: { modelValue: {
type: Object, type: Object,
@@ -247,11 +242,10 @@ export default {
:placeholder="$t('FILTER.INPUT_PLACEHOLDER')" :placeholder="$t('FILTER.INPUT_PLACEHOLDER')"
/> />
</div> </div>
<NextButton <woot-button
icon="i-lucide-x" icon="dismiss"
slate variant="clear"
ghost color-scheme="secondary"
class="flex-shrink-0"
@click="removeFilter" @click="removeFilter"
/> />
</div> </div>
@@ -164,6 +164,11 @@ export default {
'is-note-mode': this.isNote, 'is-note-mode': this.isNote,
}; };
}, },
buttonClass() {
return {
warning: this.isNote,
};
},
showAttachButton() { showAttachButton() {
return this.showFileUpload || this.isNote; return this.showFileUpload || this.isNote;
}, },
@@ -362,15 +367,14 @@ export default {
/> />
</div> </div>
<div class="right-wrap"> <div class="right-wrap">
<NextButton <woot-button
:label="sendButtonText" size="small"
type="submit" :class-names="buttonClass"
sm :is-disabled="isSendDisabled"
:color="isNote ? 'amber' : 'blue'"
:disabled="isSendDisabled"
class="flex-shrink-0"
@click="onSend" @click="onSend"
/> >
{{ sendButtonText }}
</woot-button>
</div> </div>
</div> </div>
</template> </template>
@@ -13,8 +13,6 @@ import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Linear from './linear/index.vue'; import Linear from './linear/index.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default { export default {
components: { components: {
BackButton, BackButton,
@@ -23,7 +21,6 @@ export default {
Thumbnail, Thumbnail,
SLACardLabel, SLACardLabel,
Linear, Linear,
NextButton,
}, },
mixins: [inboxMixin], mixins: [inboxMixin],
props: { props: {
@@ -157,13 +154,16 @@ export default {
<div <div
class="flex flex-row items-center max-w-full gap-1 p-0 m-0 w-fit" class="flex flex-row items-center max-w-full gap-1 p-0 m-0 w-fit"
> >
<NextButton link slate @click.prevent="$emit('contactPanelToggle')"> <woot-button
<span variant="link"
class="text-base font-medium truncate leading-tight text-n-slate-12" color-scheme="secondary"
> class="[&>span]:overflow-hidden [&>span]:whitespace-nowrap [&>span]:text-ellipsis min-w-0"
@click.prevent="$emit('contactPanelToggle')"
>
<span class="text-base font-medium leading-tight text-n-slate-12">
{{ currentContact.name }} {{ currentContact.name }}
</span> </span>
</NextButton> </woot-button>
<fluent-icon <fluent-icon
v-if="!isHMACVerified" v-if="!isHMACVerified"
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')" v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
@@ -180,13 +180,14 @@ export default {
<span v-if="isSnoozed" class="font-medium text-n-amber-10"> <span v-if="isSnoozed" class="font-medium text-n-amber-10">
{{ snoozedDisplayText }} {{ snoozedDisplayText }}
</span> </span>
<NextButton <woot-button
link class="p-0"
xs size="small"
blue variant="link"
:label="contactPanelToggleText"
@click="$emit('contactPanelToggle')" @click="$emit('contactPanelToggle')"
/> >
{{ contactPanelToggleText }}
</woot-button>
</div> </div>
</div> </div>
</div> </div>
@@ -25,8 +25,6 @@ import * as Sentry from '@sentry/vue';
import { useTrack } from 'dashboard/composables'; import { useTrack } from 'dashboard/composables';
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default { export default {
components: { components: {
BubbleActions, BubbleActions,
@@ -42,7 +40,6 @@ export default {
InstagramStory, InstagramStory,
InstagramStoryReply, InstagramStoryReply,
Spinner, Spinner,
NextButton,
}, },
props: { props: {
data: { data: {
@@ -455,12 +452,12 @@ export default {
v-if="isFailed && !hasOneDayPassed && !isAnEmailInbox" v-if="isFailed && !hasOneDayPassed && !isAnEmailInbox"
class="message-failed--alert" class="message-failed--alert"
> >
<NextButton <woot-button
v-tooltip.top-end="$t('CONVERSATION.TRY_AGAIN')" v-tooltip.top-end="$t('CONVERSATION.TRY_AGAIN')"
ghost size="tiny"
xs color-scheme="alert"
ruby variant="clear"
icon="i-lucide-refresh-ccw" icon="arrow-clockwise"
@click="retrySendMessage" @click="retrySendMessage"
/> />
</div> </div>
@@ -663,10 +660,10 @@ export default {
} }
&.is-failed { &.is-failed {
@apply bg-n-ruby-4 dark:bg-n-ruby-4 text-n-slate-12; @apply bg-red-200 dark:bg-red-200;
.message-text--metadata .time { .message-text--metadata .time {
@apply text-n-ruby-12 dark:text-n-ruby-12; @apply text-red-50 dark:text-red-50;
} }
} }
} }
@@ -727,7 +724,7 @@ li.right {
} }
.wrap.is-failed { .wrap.is-failed {
@apply flex items-end ltr:ml-auto rtl:mr-auto; @apply flex items-end ml-auto;
} }
} }
@@ -1,11 +1,10 @@
<script setup> <script>
import { useRouter } from 'vue-router'; export default {
import Button from 'dashboard/components-next/button/Button.vue'; methods: {
openProfileSettings() {
const router = useRouter(); return this.$router.push({ name: 'profile_settings_index' });
},
const openProfileSettings = () => { },
return router.push({ name: 'profile_settings_index' });
}; };
</script> </script>
@@ -15,12 +14,13 @@ const openProfileSettings = () => {
> >
<p class="w-fit !m-0"> <p class="w-fit !m-0">
{{ $t('CONVERSATION.FOOTER.MESSAGE_SIGNATURE_NOT_CONFIGURED') }} {{ $t('CONVERSATION.FOOTER.MESSAGE_SIGNATURE_NOT_CONFIGURED') }}
<woot-button
<Button color-scheme="primary"
link variant="link"
:label="$t('CONVERSATION.FOOTER.CLICK_HERE')"
@click="openProfileSettings" @click="openProfileSettings"
/> >
{{ $t('CONVERSATION.FOOTER.CLICK_HERE') }}
</woot-button>
</p> </p>
</div> </div>
</template> </template>
@@ -37,8 +37,6 @@ import wootConstants from 'dashboard/constants/globals';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { FEATURE_FLAGS } from '../../../featureFlags'; import { FEATURE_FLAGS } from '../../../featureFlags';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default { export default {
components: { components: {
Message, Message,
@@ -46,7 +44,6 @@ export default {
ReplyBox, ReplyBox,
Banner, Banner,
ConversationLabelSuggestion, ConversationLabelSuggestion,
NextButton,
}, },
mixins: [inboxMixin], mixins: [inboxMixin],
props: { props: {
@@ -491,15 +488,13 @@ export default {
:href-link-text="replyWindowLinkText" :href-link-text="replyWindowLinkText"
/> />
<div class="flex justify-end"> <div class="flex justify-end">
<NextButton <woot-button
faded variant="smooth"
xs size="tiny"
slate color-scheme="secondary"
class="!rounded-r-none rtl:rotate-180 !rounded-2xl !fixed z-10" class="box-border fixed z-10 bg-white border border-r-0 border-solid rounded-bl-calc rtl:rotate-180 rounded-tl-calc border-n-weak"
:icon="
isContactPanelOpen ? 'i-ph-caret-right-fill' : 'i-ph-caret-left-fill'
"
:class="isInboxView ? 'top-52 md:top-40' : 'top-32'" :class="isInboxView ? 'top-52 md:top-40' : 'top-32'"
:icon="isRightOrLeftIcon"
@click="onToggleContactPanel" @click="onToggleContactPanel"
/> />
</div> </div>
@@ -619,6 +614,20 @@ export default {
</div> </div>
</template> </template>
<style scoped>
@tailwind components;
@layer components {
.rounded-bl-calc {
border-bottom-left-radius: calc(1.5rem + 1px);
}
.rounded-tl-calc {
border-top-left-radius: calc(1.5rem + 1px);
}
}
</style>
<style scoped lang="scss"> <style scoped lang="scss">
.modal-mask { .modal-mask {
@apply absolute; @apply absolute;
@@ -40,11 +40,7 @@ import {
replaceSignature, replaceSignature,
extractTextFromMarkdown, extractTextFromMarkdown,
} from 'dashboard/helper/editorHelper'; } from 'dashboard/helper/editorHelper';
import {
saveAudioToDraft,
getAudioFromDraft,
removeAudioDraft,
} from 'dashboard/helpers/audioDraftHelper';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage'; import { LocalStorage } from 'shared/helpers/localStorage';
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
@@ -219,11 +215,7 @@ export default {
}, },
isReplyButtonDisabled() { isReplyButtonDisabled() {
if (this.isATwitterInbox) return true; if (this.isATwitterInbox) return true;
if (this.hasAttachments || this.hasRecordedAudio) return false;
const hasActualAttachments =
this.attachedFiles && this.attachedFiles.length > 0;
if (hasActualAttachments) return false;
return ( return (
this.isMessageEmpty || this.isMessageEmpty ||
@@ -426,9 +418,7 @@ export default {
}, },
conversationIdByRoute(conversationId, oldConversationId) { conversationIdByRoute(conversationId, oldConversationId) {
if (conversationId !== oldConversationId) { if (conversationId !== oldConversationId) {
this.saveAudioDraft(oldConversationId);
this.setToDraft(oldConversationId, this.replyType); this.setToDraft(oldConversationId, this.replyType);
this.resetAudioRecorderUIState();
this.getFromDraft(); this.getFromDraft();
} }
}, },
@@ -539,55 +529,6 @@ export default {
); );
} }
}, },
resetAudioRecorderUIState() {
this.recordingAudioDurationText = '00:00';
this.isRecordingAudio = false;
this.recordingAudioState = '';
},
async getAudioRecordingFromDraft(
conversationId,
replyType = this.replyType
) {
try {
// First remove any existing audio recordings to prevent duplicates
this.attachedFiles = this.attachedFiles.filter(
file => !file.isRecordedAudio
);
const audioFile = await getAudioFromDraft(conversationId, replyType);
if (audioFile) {
// Add to attachedFiles
this.attachedFiles.push({
currentChatId: conversationId,
...audioFile,
});
// Update UI state
this.hasRecordedAudio = true;
this.recordingAudioState = 'stopped';
}
} catch (error) {
// If there's an error, cleanup to prevent UI issues
this.resetAudioRecorderInput();
}
},
saveAudioDraft(oldConversationId) {
// Save audio draft for the old conversation if any
// When switching between conversations, save the audio to draft for the old conversation
if (this.hasRecordedAudio && this.attachedFiles.length) {
const audioFile = this.attachedFiles.find(file => file.isRecordedAudio);
if (audioFile) {
// Check if the audio file size exceeds the 5MB limit for localStorage
// If it does, show an error message and do not save
if (audioFile.resource.file.size > 5 * 1024 * 1024) {
useAlert(
this.$t('CONVERSATION.REPLYBOX.DRAFT_AUDIO_SIZE_LIMIT_ERROR')
);
return;
}
saveAudioToDraft(oldConversationId, this.replyType, audioFile);
}
}
},
saveDraft(conversationId, replyType) { saveDraft(conversationId, replyType) {
if (this.message || this.message === '') { if (this.message || this.message === '') {
const key = `draft-${conversationId}-${replyType}`; const key = `draft-${conversationId}-${replyType}`;
@@ -611,8 +552,6 @@ export default {
// ensure that the message has signature set based on the ui setting // ensure that the message has signature set based on the ui setting
this.message = this.toggleSignatureForDraft(messageFromStore); this.message = this.toggleSignatureForDraft(messageFromStore);
// get audio recording from draft
this.getAudioRecordingFromDraft(this.conversationIdByRoute);
} }
}, },
toggleSignatureForDraft(message) { toggleSignatureForDraft(message) {
@@ -877,12 +816,6 @@ export default {
// if signature is enabled, append it to the message // if signature is enabled, append it to the message
this.message = appendSignature(this.message, this.signatureToApply); this.message = appendSignature(this.message, this.signatureToApply);
} }
// Remove audio draft on clear
if (this.hasRecordedAudio && this.conversationIdByRoute) {
removeAudioDraft(this.conversationIdByRoute, this.replyType);
}
this.attachedFiles = []; this.attachedFiles = [];
this.isRecordingAudio = false; this.isRecordingAudio = false;
this.resetReplyToMessage(); this.resetReplyToMessage();
@@ -940,15 +873,6 @@ export default {
this.recordingAudioDurationText = duration; this.recordingAudioDurationText = duration;
}, },
onFinishRecorder(file) { onFinishRecorder(file) {
// Remove audio from draft on new recording
if (file && this.conversationIdByRoute) {
removeAudioDraft(this.conversationIdByRoute, this.replyType);
// remove from attached files and isRecordedAudio
this.attachedFiles = this.attachedFiles.filter(
f => f.id !== file.id && !f.isRecordedAudio
);
}
this.recordingAudioState = 'stopped'; this.recordingAudioState = 'stopped';
this.hasRecordedAudio = true; this.hasRecordedAudio = true;
// Added a new key isRecordedAudio to the file to find it's and recorded audio // Added a new key isRecordedAudio to the file to find it's and recorded audio
@@ -989,16 +913,6 @@ export default {
}, },
removeAttachment(attachments) { removeAttachment(attachments) {
this.attachedFiles = attachments; this.attachedFiles = attachments;
// If there are no attachments or if there are recorded audio attachments
// then remove the audio draft
if (
attachments?.some(attachment => attachment.isRecordedAudio) ||
!attachments.length
) {
if (this.conversationIdByRoute) {
removeAudioDraft(this.conversationIdByRoute, this.replyType);
}
}
}, },
setReplyToInPayload(payload) { setReplyToInPayload(payload) {
if (this.inReplyTo?.id) { if (this.inReplyTo?.id) {
@@ -1,20 +1,21 @@
<script setup> <script>
import MessagePreview from 'dashboard/components/widgets/conversation/MessagePreview.vue'; import MessagePreview from 'dashboard/components/widgets/conversation/MessagePreview.vue';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({ export default {
message: { components: { MessagePreview },
type: Object, props: {
required: true, message: {
type: Object,
required: true,
},
}, },
}); emits: ['dismiss'],
};
const emit = defineEmits(['dismiss']);
</script> </script>
<template> <template>
<div <div
class="reply-editor bg-n-slate-9/10 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5 -mx-2" class="reply-editor bg-slate-50 dark:bg-slate-800 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5 -mx-2"
> >
<fluent-icon class="flex-shrink-0 icon" icon="arrow-reply" size="14" /> <fluent-icon class="flex-shrink-0 icon" icon="arrow-reply" size="14" />
<div class="flex-grow gap-1 mt-px text-xs truncate"> <div class="flex-grow gap-1 mt-px text-xs truncate">
@@ -26,13 +27,14 @@ const emit = defineEmits(['dismiss']);
class="inline" class="inline"
/> />
</div> </div>
<Button <woot-button
v-tooltip="$t('CONVERSATION.REPLYBOX.DISMISS_REPLY')" v-tooltip="$t('CONVERSATION.REPLYBOX.DISMISS_REPLY')"
ghost color-scheme="secondary"
xs icon="dismiss"
slate variant="clear"
icon="i-lucide-x" size="tiny"
@click.stop="emit('dismiss')" class="flex-shrink-0"
@click.stop="$emit('dismiss')"
/> />
</div> </div>
</template> </template>
@@ -12,12 +12,7 @@ import { ref, computed, onMounted } from 'vue';
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators'; import { requiredIf } from '@vuelidate/validators';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default { export default {
components: {
NextButton,
},
props: { props: {
template: { template: {
type: Object, type: Object,
@@ -119,47 +114,78 @@ export default {
readonly readonly
class="template-input" class="template-input"
/> />
<div v-if="variables" class="p-2.5"> <div v-if="variables" class="template__variables-container">
<p class="text-sm font-semibold mb-2.5"> <p class="variables-label">
{{ $t('WHATSAPP_TEMPLATES.PARSER.VARIABLES_LABEL') }} {{ $t('WHATSAPP_TEMPLATES.PARSER.VARIABLES_LABEL') }}
</p> </p>
<div <div
v-for="(variable, key) in processedParams" v-for="(variable, key) in processedParams"
:key="key" :key="key"
class="items-center flex mb-2.5" class="template__variable-item"
> >
<span <span class="variable-label">
class="bg-n-alpha-black2 text-n-slate-12 inline-block rounded-md text-xs py-2.5 px-6"
>
{{ key }} {{ key }}
</span> </span>
<woot-input <woot-input
v-model="processedParams[key]" v-model="processedParams[key]"
type="text" type="text"
class="flex-1 text-sm ml-2.5" class="variable-input"
:styles="{ marginBottom: 0 }" :styles="{ marginBottom: 0 }"
/> />
</div> </div>
<p <p v-if="v$.$dirty && v$.$invalid" class="error">
v-if="v$.$dirty && v$.$invalid"
class="bg-n-ruby-9/20 rounded-md text-n-ruby-9 p-2.5 text-center"
>
{{ $t('WHATSAPP_TEMPLATES.PARSER.FORM_ERROR_MESSAGE') }} {{ $t('WHATSAPP_TEMPLATES.PARSER.FORM_ERROR_MESSAGE') }}
</p> </p>
</div> </div>
<footer class="flex justify-end gap-2"> <footer>
<NextButton <woot-button variant="smooth" @click="resetTemplate">
faded {{ $t('WHATSAPP_TEMPLATES.PARSER.GO_BACK_LABEL') }}
slate </woot-button>
type="reset" <woot-button type="button" @click="sendMessage">
:label="$t('WHATSAPP_TEMPLATES.PARSER.GO_BACK_LABEL')" {{ $t('WHATSAPP_TEMPLATES.PARSER.SEND_MESSAGE_LABEL') }}
@click="resetTemplate" </woot-button>
/>
<NextButton
type="button"
:label="$t('WHATSAPP_TEMPLATES.PARSER.SEND_MESSAGE_LABEL')"
@click="sendMessage"
/>
</footer> </footer>
</div> </div>
</template> </template>
<style scoped lang="scss">
.template__variables-container {
@apply p-2.5;
}
.variables-label {
@apply text-sm font-semibold mb-2.5;
}
.template__variable-item {
@apply items-center flex mb-2.5;
.label {
@apply text-xs;
}
.variable-input {
@apply flex-1 text-sm ml-2.5;
}
.variable-label {
@apply bg-slate-75 dark:bg-slate-700 text-slate-700 dark:text-slate-100 inline-block rounded-md text-xs py-2.5 px-6;
}
}
footer {
@apply flex justify-end;
button {
@apply ml-2.5;
}
}
.error {
@apply bg-red-100 dark:bg-red-100 rounded-md text-red-800 dark:text-red-800 p-2.5 text-center;
}
.template-input {
@apply bg-slate-25 dark:bg-slate-900 text-slate-700 dark:text-slate-100;
}
</style>
@@ -43,56 +43,48 @@ export default {
<template> <template>
<div class="w-full"> <div class="w-full">
<div <div class="gap-1 templates__list-search">
class="gap-1 bg-n-alpha-black2 items-center flex mb-2.5 py-0 px-2.5 rounded-lg outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 focus-within:outline-n-brand dark:focus-within:outline-n-brand" <fluent-icon icon="search" class="search-icon" size="16" />
>
<fluent-icon icon="search" class="text-n-slate-12" size="16" />
<input <input
v-model="query" v-model="query"
type="search" type="search"
:placeholder="$t('WHATSAPP_TEMPLATES.PICKER.SEARCH_PLACEHOLDER')" :placeholder="$t('WHATSAPP_TEMPLATES.PICKER.SEARCH_PLACEHOLDER')"
class="reset-base w-full h-9 bg-transparent text-n-slate-12 !text-sm !outline-0" class="templates__search-input"
/> />
</div> </div>
<div <div class="template__list-container">
class="bg-n-background outline-n-container outline outline-1 rounded-lg max-h-[18.75rem] overflow-y-auto p-2.5"
>
<div v-for="(template, i) in filteredTemplateMessages" :key="template.id"> <div v-for="(template, i) in filteredTemplateMessages" :key="template.id">
<button <button
class="rounded-lg cursor-pointer block p-2.5 text-left w-full hover:bg-n-alpha-2 dark:hover:bg-n-solid-2" class="template__list-item"
@click="$emit('onSelect', template)" @click="$emit('onSelect', template)"
> >
<div> <div>
<div class="flex items-center justify-between mb-2.5"> <div class="flex items-center justify-between mb-2.5">
<p class="text-sm"> <p class="label-title">
{{ template.name }} {{ template.name }}
</p> </p>
<span <span
class="inline-block px-2 py-1 text-xs leading-none bg-n-slate-3 rounded-lg cursor-default text-n-slate-12" class="inline-block px-2 py-1 text-xs leading-none bg-white rounded-sm cursor-default dark:bg-slate-700 text-slate-800 dark:text-slate-100"
> >
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.LANGUAGE') }} : {{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.LANGUAGE') }} :
{{ template.language }} {{ template.language }}
</span> </span>
</div> </div>
<div> <div>
<p class="font-medium"> <p class="strong">
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.TEMPLATE_BODY') }} {{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.TEMPLATE_BODY') }}
</p> </p>
<p class="label-body">{{ getTemplatebody(template) }}</p> <p class="label-body">{{ getTemplatebody(template) }}</p>
</div> </div>
<div class="mt-5"> <div class="label-category">
<p class="font-medium"> <p class="strong">
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.CATEGORY') }} {{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.CATEGORY') }}
</p> </p>
<p>{{ template.category }}</p> <p>{{ template.category }}</p>
</div> </div>
</div> </div>
</button> </button>
<hr <hr v-if="i != filteredTemplateMessages.length - 1" :key="`hr-${i}`" />
v-if="i != filteredTemplateMessages.length - 1"
:key="`hr-${i}`"
class="border-b border-solid border-n-weak my-2.5 mx-auto max-w-[95%]"
/>
</div> </div>
<div v-if="!filteredTemplateMessages.length"> <div v-if="!filteredTemplateMessages.length">
<p> <p>
@@ -105,7 +97,46 @@ export default {
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.label-body { .templates__list-search {
font-family: monospace; @apply items-center flex bg-slate-25 dark:bg-slate-900 mb-2.5 py-0 px-2.5 rounded-md border border-solid border-slate-100 dark:border-slate-700;
.search-icon {
@apply text-slate-400 dark:text-slate-300;
}
.templates__search-input {
@apply bg-transparent border-0 text-xs h-9 m-0;
}
}
.template__list-container {
@apply bg-slate-25 dark:bg-slate-900 rounded-md max-h-[18.75rem] overflow-y-auto p-2.5;
.template__list-item {
@apply rounded-lg cursor-pointer block p-2.5 text-left w-full hover:bg-woot-50 dark:hover:bg-slate-800;
.label-title {
@apply text-sm;
}
.label-category {
@apply mt-5;
span {
@apply text-sm font-semibold;
}
}
.label-body {
font-family: monospace;
}
}
}
.strong {
@apply text-xs font-semibold;
}
hr {
@apply border-b border-solid border-slate-100 dark:border-slate-700 my-2.5 mx-auto max-w-[95%];
} }
</style> </style>
@@ -22,14 +22,6 @@ const filterTypes = [
filterOperators: OPERATOR_TYPES_2, filterOperators: OPERATOR_TYPES_2,
attributeModel: 'standard', attributeModel: 'standard',
}, },
{
attributeKey: 'priority',
attributeI18nKey: 'PRIORITY',
inputType: 'multi_select',
dataType: 'text',
filterOperators: OPERATOR_TYPES_1,
attributeModel: 'standard',
},
{ {
attributeKey: 'inbox_id', attributeKey: 'inbox_id',
attributeI18nKey: 'INBOX_NAME', attributeI18nKey: 'INBOX_NAME',
@@ -166,190 +166,188 @@ onMounted(() => {
</script> </script>
<template> <template>
<Teleport to="body"> <woot-modal
<woot-modal v-model:show="show"
v-model:show="show" full-width
full-width :show-close-button="false"
:show-close-button="false" :on-close="onClose"
:on-close="onClose" >
<div
class="bg-n-background flex flex-col h-[inherit] w-[inherit] overflow-hidden select-none"
@click="onClose"
> >
<div <header
class="bg-n-background flex flex-col h-[inherit] w-[inherit] overflow-hidden select-none" class="z-10 flex items-center justify-between w-full h-16 px-6 py-2 bg-n-background border-b border-n-weak"
@click="onClose" @click.stop
> >
<header <div
class="z-10 flex items-center justify-between w-full h-16 px-6 py-2 bg-n-background border-b border-n-weak" v-if="senderDetails"
@click.stop class="flex items-center min-w-[15rem] shrink-0"
> >
<div <Thumbnail
v-if="senderDetails" v-if="senderDetails.avatar"
class="flex items-center min-w-[15rem] shrink-0" :username="senderDetails.name"
> :src="senderDetails.avatar"
<Thumbnail class="flex-shrink-0"
v-if="senderDetails.avatar" />
:username="senderDetails.name" <div class="flex flex-col ml-2 rtl:ml-0 rtl:mr-2 overflow-hidden">
:src="senderDetails.avatar" <h3 class="text-base leading-5 m-0 font-medium">
class="flex-shrink-0"
/>
<div class="flex flex-col ml-2 rtl:ml-0 rtl:mr-2 overflow-hidden">
<h3 class="text-base leading-5 m-0 font-medium">
<span
class="overflow-hidden text-n-slate-12 whitespace-nowrap text-ellipsis"
>
{{ senderDetails.name }}
</span>
</h3>
<span <span
class="text-xs text-n-slate-11 whitespace-nowrap text-ellipsis" class="overflow-hidden text-n-slate-12 whitespace-nowrap text-ellipsis"
> >
{{ readableTime }} {{ senderDetails.name }}
</span> </span>
</div> </h3>
</div> <span
class="text-xs text-n-slate-11 whitespace-nowrap text-ellipsis"
<div
class="flex-1 mx-2 px-2 truncate text-sm font-medium text-center text-n-slate-12"
>
<span v-dompurify-html="fileNameFromDataUrl" class="truncate" />
</div>
<div class="flex items-center gap-2 ml-2 shrink-0">
<NextButton
v-if="isImage"
icon="i-lucide-zoom-in"
slate
ghost
@click="onZoom(0.1)"
/>
<NextButton
v-if="isImage"
icon="i-lucide-zoom-out"
slate
ghost
@click="onZoom(-0.1)"
/>
<NextButton
v-if="isImage"
icon="i-lucide-rotate-ccw"
slate
ghost
@click="onRotate('counter-clockwise')"
/>
<NextButton
v-if="isImage"
icon="i-lucide-rotate-cw"
slate
ghost
@click="onRotate('clockwise')"
/>
<NextButton
icon="i-lucide-download"
slate
ghost
:is-loading="isDownloading"
:disabled="isDownloading"
@click="onClickDownload"
/>
<NextButton icon="i-lucide-x" slate ghost @click="onClose" />
</div>
</header>
<main class="flex items-stretch flex-1 h-full overflow-hidden">
<div class="flex items-center justify-center w-16 shrink-0">
<NextButton
v-if="hasMoreThanOneAttachment"
icon="i-lucide-chevron-left"
class="z-10"
blue
faded
lg
:disabled="activeImageIndex === 0"
@click.stop="
onClickChangeAttachment(
allAttachments[activeImageIndex - 1],
activeImageIndex - 1
)
"
/>
</div>
<div class="flex-1 flex items-center justify-center overflow-hidden">
<div
v-if="isImage"
:style="imageWrapperStyle"
class="flex items-center justify-center origin-center"
:class="{
// Adjust dimensions when rotated 90/270 degrees to maintain visibility
// and prevent image from overflowing container in different aspect ratios
'w-[calc(100dvh-8rem)] h-[calc(100dvw-7rem)]':
activeImageRotation % 180 !== 0,
'size-full': activeImageRotation % 180 === 0,
}"
> >
<img {{ readableTime }}
ref="imageRef" </span>
:key="activeAttachment.message_id" </div>
:src="activeAttachment.data_url" </div>
:style="imageStyle"
class="max-h-full max-w-full object-contain duration-100 ease-in-out transform select-none"
@click.stop
@dblclick.stop="onDoubleClickZoomImage"
@wheel.prevent.stop="onWheelImageZoom"
@mousemove="onMouseMove"
@mouseleave="onMouseLeave"
/>
</div>
<video <div
v-if="isVideo" class="flex-1 mx-2 px-2 truncate text-sm font-medium text-center text-n-slate-12"
>
<span v-dompurify-html="fileNameFromDataUrl" class="truncate" />
</div>
<div class="flex items-center gap-2 ml-2 shrink-0">
<NextButton
v-if="isImage"
icon="i-lucide-zoom-in"
slate
ghost
@click="onZoom(0.1)"
/>
<NextButton
v-if="isImage"
icon="i-lucide-zoom-out"
slate
ghost
@click="onZoom(-0.1)"
/>
<NextButton
v-if="isImage"
icon="i-lucide-rotate-ccw"
slate
ghost
@click="onRotate('counter-clockwise')"
/>
<NextButton
v-if="isImage"
icon="i-lucide-rotate-cw"
slate
ghost
@click="onRotate('clockwise')"
/>
<NextButton
icon="i-lucide-download"
slate
ghost
:is-loading="isDownloading"
:disabled="isDownloading"
@click="onClickDownload"
/>
<NextButton icon="i-lucide-x" slate ghost @click="onClose" />
</div>
</header>
<main class="flex items-stretch flex-1 h-full overflow-hidden">
<div class="flex items-center justify-center w-16 shrink-0">
<NextButton
v-if="hasMoreThanOneAttachment"
icon="i-lucide-chevron-left"
class="z-10"
blue
faded
lg
:disabled="activeImageIndex === 0"
@click.stop="
onClickChangeAttachment(
allAttachments[activeImageIndex - 1],
activeImageIndex - 1
)
"
/>
</div>
<div class="flex-1 flex items-center justify-center overflow-hidden">
<div
v-if="isImage"
:style="imageWrapperStyle"
class="flex items-center justify-center origin-center"
:class="{
// Adjust dimensions when rotated 90/270 degrees to maintain visibility
// and prevent image from overflowing container in different aspect ratios
'w-[calc(100dvh-8rem)] h-[calc(100dvw-7rem)]':
activeImageRotation % 180 !== 0,
'size-full': activeImageRotation % 180 === 0,
}"
>
<img
ref="imageRef"
:key="activeAttachment.message_id" :key="activeAttachment.message_id"
:src="activeAttachment.data_url" :src="activeAttachment.data_url"
controls :style="imageStyle"
playsInline class="max-h-full max-w-full object-contain duration-100 ease-in-out transform select-none"
class="max-h-full max-w-full object-contain"
@click.stop @click.stop
/> @dblclick.stop="onDoubleClickZoomImage"
@wheel.prevent.stop="onWheelImageZoom"
<audio @mousemove="onMouseMove"
v-if="isAudio" @mouseleave="onMouseLeave"
:key="activeAttachment.message_id"
controls
class="w-full max-w-md"
@click.stop
>
<source :src="`${activeAttachment.data_url}?t=${Date.now()}`" />
</audio>
</div>
<div class="flex items-center justify-center w-16 shrink-0">
<NextButton
v-if="hasMoreThanOneAttachment"
icon="i-lucide-chevron-right"
class="z-10"
blue
faded
lg
:disabled="activeImageIndex === allAttachments.length - 1"
@click.stop="
onClickChangeAttachment(
allAttachments[activeImageIndex + 1],
activeImageIndex + 1
)
"
/> />
</div> </div>
</main>
<footer <video
class="z-10 flex items-center justify-center h-12 border-t border-n-weak" v-if="isVideo"
> :key="activeAttachment.message_id"
<div :src="activeAttachment.data_url"
class="rounded-md flex items-center justify-center px-3 py-1 bg-n-slate-3 text-n-slate-12 text-sm font-medium" controls
playsInline
class="max-h-full max-w-full object-contain"
@click.stop
/>
<audio
v-if="isAudio"
:key="activeAttachment.message_id"
controls
class="w-full max-w-md"
@click.stop
> >
{{ `${activeImageIndex + 1} / ${allAttachments.length}` }} <source :src="`${activeAttachment.data_url}?t=${Date.now()}`" />
</div> </audio>
</footer> </div>
</div>
</woot-modal> <div class="flex items-center justify-center w-16 shrink-0">
</Teleport> <NextButton
v-if="hasMoreThanOneAttachment"
icon="i-lucide-chevron-right"
class="z-10"
blue
faded
lg
:disabled="activeImageIndex === allAttachments.length - 1"
@click.stop="
onClickChangeAttachment(
allAttachments[activeImageIndex + 1],
activeImageIndex + 1
)
"
/>
</div>
</main>
<footer
class="z-10 flex items-center justify-center h-12 border-t border-n-weak"
>
<div
class="rounded-md flex items-center justify-center px-3 py-1 bg-n-slate-3 text-n-slate-12 text-sm font-medium"
>
{{ `${activeImageIndex + 1} / ${allAttachments.length}` }}
</div>
</footer>
</div>
</woot-modal>
</template> </template>
@@ -3,7 +3,6 @@ import { ref, computed } from 'vue';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import SLAEventItem from './SLAEventItem.vue'; import SLAEventItem from './SLAEventItem.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({ const props = defineProps({
slaMissedEvents: { slaMissedEvents: {
@@ -61,19 +60,20 @@ const toggleShowAllNRT = () => {
v-if="shouldShowMoreNRTButton" v-if="shouldShowMoreNRTButton"
class="flex flex-col items-end w-full" class="flex flex-col items-end w-full"
> >
<Button <woot-button
link size="small"
xs :icon="!shouldShowAllNrts ? 'plus-sign' : ''"
slate variant="link"
class="hover:!no-underline" color-scheme="secondary"
:icon="!shouldShowAllNrts ? 'i-lucide-plus' : ''" class="hover:!no-underline !gap-1 hover:!bg-transparent dark:hover:!bg-transparent"
:label=" @click="toggleShowAllNRT"
>
{{
shouldShowAllNrts shouldShowAllNrts
? $t('SLA.EVENTS.HIDE', { count: nrtMisses.length }) ? $t('SLA.EVENTS.HIDE', { count: nrtMisses.length })
: $t('SLA.EVENTS.SHOW_MORE', { count: nrtMisses.length }) : $t('SLA.EVENTS.SHOW_MORE', { count: nrtMisses.length })
" }}
@click="toggleShowAllNRT" </woot-button>
/>
</div> </div>
</template> </template>
</SLAEventItem> </SLAEventItem>
@@ -2,13 +2,11 @@
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue'; import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Spinner from 'shared/components/Spinner.vue'; import Spinner from 'shared/components/Spinner.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default { export default {
components: { components: {
Thumbnail, Thumbnail,
Spinner, Spinner,
NextButton,
}, },
props: { props: {
selectedInboxes: { selectedInboxes: {
@@ -96,7 +94,13 @@ export default {
</div> </div>
<div class="flex items-center justify-between header"> <div class="flex items-center justify-between header">
<span>{{ $t('BULK_ACTION.AGENT_SELECT_LABEL') }}</span> <span>{{ $t('BULK_ACTION.AGENT_SELECT_LABEL') }}</span>
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" /> <woot-button
size="tiny"
variant="clear"
color-scheme="secondary"
icon="dismiss"
@click="onClose"
/>
</div> </div>
<div class="container"> <div class="container">
<div <div
@@ -117,7 +121,7 @@ export default {
v-model="query" v-model="query"
type="search" type="search"
:placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')" :placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
class="reset-base !outline-0 !text-sm agent--search_input" class="agent--search_input"
/> />
</div> </div>
</li> </li>
@@ -157,21 +161,21 @@ export default {
}} }}
</p> </p>
<div class="agent-confirmation-actions"> <div class="agent-confirmation-actions">
<NextButton <woot-button
faded color-scheme="primary"
sm variant="smooth"
slate
type="reset"
:label="$t('BULK_ACTION.GO_BACK_LABEL')"
@click="goBack" @click="goBack"
/> >
<NextButton {{ $t('BULK_ACTION.GO_BACK_LABEL') }}
sm </woot-button>
type="submit" <woot-button
:label="$t('BULK_ACTION.YES')" color-scheme="primary"
variant="flat"
:is-loading="uiFlags.isUpdating" :is-loading="uiFlags.isUpdating"
@click="submit" @click="submit"
/> >
{{ $t('BULK_ACTION.YES') }}
</woot-button>
</div> </div>
</div> </div>
</div> </div>
@@ -1,11 +1,7 @@
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default { export default {
components: {
NextButton,
},
emits: ['update', 'close', 'assign'], emits: ['update', 'close', 'assign'],
data() { data() {
return { return {
@@ -44,7 +40,13 @@ export default {
</div> </div>
<div class="flex items-center justify-between header"> <div class="flex items-center justify-between header">
<span>{{ $t('BULK_ACTION.LABELS.ASSIGN_LABELS') }}</span> <span>{{ $t('BULK_ACTION.LABELS.ASSIGN_LABELS') }}</span>
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" /> <woot-button
size="tiny"
variant="clear"
color-scheme="secondary"
icon="dismiss"
@click="onClose"
/>
</div> </div>
<div class="labels-list"> <div class="labels-list">
<header class="labels-list__header"> <header class="labels-list__header">
@@ -56,7 +58,7 @@ export default {
v-model="query" v-model="query"
type="search" type="search"
:placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')" :placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
class="reset-base !outline-0 !text-sm label--search_input" class="label--search_input"
/> />
</div> </div>
</header> </header>
@@ -89,13 +91,15 @@ export default {
</li> </li>
</ul> </ul>
<footer class="labels-list__footer"> <footer class="labels-list__footer">
<NextButton <woot-button
sm size="small"
type="submit" is-expanded
:label="$t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')" color-scheme="primary"
:disabled="!selectedLabels.length" :disabled="!selectedLabels.length"
@click="$emit('assign', selectedLabels)" @click="$emit('assign', selectedLabels)"
/> >
<span>{{ $t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS') }}</span>
</woot-button>
</footer> </footer>
</div> </div>
</div> </div>
@@ -1,11 +1,6 @@
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default { export default {
components: {
NextButton,
},
emits: ['assignTeam', 'close'], emits: ['assignTeam', 'close'],
data() { data() {
@@ -45,7 +40,13 @@ export default {
</div> </div>
<div class="flex items-center justify-between header"> <div class="flex items-center justify-between header">
<span>{{ $t('BULK_ACTION.TEAMS.TEAM_SELECT_LABEL') }}</span> <span>{{ $t('BULK_ACTION.TEAMS.TEAM_SELECT_LABEL') }}</span>
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" /> <woot-button
size="tiny"
variant="clear"
color-scheme="secondary"
icon="dismiss"
@click="onClose"
/>
</div> </div>
<div class="container"> <div class="container">
<div class="team__list-container"> <div class="team__list-container">
@@ -59,7 +60,7 @@ export default {
v-model="query" v-model="query"
type="search" type="search"
:placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')" :placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
class="reset-base !outline-0 !text-sm agent--search_input" class="agent--search_input"
/> />
</div> </div>
</li> </li>
@@ -4,7 +4,6 @@ import { ref } from 'vue';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue'; import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue'; import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({ const props = defineProps({
showResolve: { showResolve: {
@@ -26,9 +25,9 @@ const emit = defineEmits(['update', 'close']);
const { t } = useI18n(); const { t } = useI18n();
const actions = ref([ const actions = ref([
{ icon: 'i-lucide-check', key: 'resolved' }, { icon: 'checkmark', key: 'resolved' },
{ icon: 'i-lucide-redo', key: 'open' }, { icon: 'arrow-redo', key: 'open' },
{ icon: 'i-lucide-alarm-clock', key: 'snoozed' }, { icon: 'send-clock', key: 'snoozed' },
]); ]);
const updateConversations = key => { const updateConversations = key => {
@@ -86,21 +85,28 @@ const actionLabel = key => {
<span class="text-sm font-medium text-slate-600 dark:text-slate-100"> <span class="text-sm font-medium text-slate-600 dark:text-slate-100">
{{ $t('BULK_ACTION.UPDATE.CHANGE_STATUS') }} {{ $t('BULK_ACTION.UPDATE.CHANGE_STATUS') }}
</span> </span>
<Button ghost xs slate icon="i-lucide-x" @click="onClose" /> <woot-button
size="tiny"
variant="clear"
color-scheme="secondary"
icon="dismiss"
@click="onClose"
/>
</div> </div>
<div class="px-2.5 pt-0 pb-2.5"> <div class="px-2.5 pt-0 pb-2.5">
<WootDropdownMenu class="m-0 list-none"> <WootDropdownMenu class="m-0 list-none">
<template v-for="action in actions"> <template v-for="action in actions">
<WootDropdownItem v-if="showAction(action.key)" :key="action.key"> <WootDropdownItem v-if="showAction(action.key)" :key="action.key">
<Button <woot-button
ghost variant="clear"
sm color-scheme="secondary"
slate size="small"
class="!w-full ltr:!justify-start rtl:!justify-end"
:icon="action.icon" :icon="action.icon"
:label="actionLabel(action.key)" class="hover:!bg-n-slate-3 dark:hover:!bg-n-solid-3"
@click="updateConversations(action.key)" @click="updateConversations(action.key)"
/> >
{{ actionLabel(action.key) }}
</woot-button>
</WootDropdownItem> </WootDropdownItem>
</template> </template>
</WootDropdownMenu> </WootDropdownMenu>
@@ -9,7 +9,6 @@ import validations from './validations';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api'; import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
import SearchableDropdown from './SearchableDropdown.vue'; import SearchableDropdown from './SearchableDropdown.vue';
import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({ const props = defineProps({
conversationId: { conversationId: {
@@ -249,20 +248,20 @@ onMounted(getTeams);
/> />
</div> </div>
<div class="flex items-center justify-end w-full gap-2 mt-8"> <div class="flex items-center justify-end w-full gap-2 mt-8">
<Button <woot-button
faded class="px-4 rounded-xl button clear outline-woot-200/50 outline"
slate
type="reset"
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CANCEL')"
@click.prevent="onClose" @click.prevent="onClose"
/> >
<Button {{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CANCEL') }}
type="submit" </woot-button>
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE')" <woot-button
:disabled="isSubmitDisabled" :is-disabled="isSubmitDisabled"
class="px-4 rounded-xl"
:is-loading="isCreating" :is-loading="isCreating"
@click.prevent="createIssue" @click.prevent="createIssue"
/> >
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE') }}
</woot-button>
</div> </div>
</div> </div>
</template> </template>
@@ -1,7 +1,5 @@
<script setup> <script setup>
import { inject } from 'vue'; import { inject } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({ const props = defineProps({
identifier: { identifier: {
type: String, type: String,
@@ -39,24 +37,30 @@ const openIssue = () => {
/> />
<span class="text-xs font-medium text-ash-900">{{ identifier }}</span> <span class="text-xs font-medium text-ash-900">{{ identifier }}</span>
</div> </div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-0.5">
<Button <woot-button
ghost variant="clear"
xs color-scheme="secondary"
slate class="h-[24px]"
icon="i-lucide-unlink"
class="!transition-none"
:is-loading="isUnlinking" :is-loading="isUnlinking"
@click="unlinkIssue" @click="unlinkIssue"
/> >
<Button <fluent-icon
ghost v-if="!isUnlinking"
xs icon="unlink"
slate size="12"
class="!transition-none" type="outline"
icon="i-lucide-arrow-up-right" icon-lib="lucide"
/>
</woot-button>
<woot-button
variant="clear"
class="h-[24px]"
color-scheme="secondary"
@click="openIssue" @click="openIssue"
/> >
<fluent-icon icon="arrow-up-right" size="14" />
</woot-button>
</div> </div>
</div> </div>
</template> </template>
@@ -8,7 +8,6 @@ import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
import FilterListDropdown from 'dashboard/components/ui/Dropdown/DropdownList.vue'; import FilterListDropdown from 'dashboard/components/ui/Dropdown/DropdownList.vue';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api'; import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({ const props = defineProps({
conversationId: { conversationId: {
@@ -130,20 +129,20 @@ const linkIssue = async () => {
</template> </template>
</FilterButton> </FilterButton>
<div class="flex items-center justify-end w-full gap-2 mt-2"> <div class="flex items-center justify-end w-full gap-2 mt-2">
<Button <woot-button
faded class="px-4 rounded-xl button clear outline-woot-200/50 outline"
slate
type="reset"
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CANCEL')"
@click.prevent="onClose" @click.prevent="onClose"
/> >
<Button {{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CANCEL') }}
type="submit" </woot-button>
:label="$t('INTEGRATION_SETTINGS.LINEAR.LINK.TITLE')" <woot-button
:disabled="isSubmitDisabled" :is-disabled="isSubmitDisabled"
class="px-4 rounded-xl"
:is-loading="isLinking" :is-loading="isLinking"
@click.prevent="linkIssue" @click.prevent="linkIssue"
/> >
{{ $t('INTEGRATION_SETTINGS.LINEAR.LINK.TITLE') }}
</woot-button>
</div> </div>
</div> </div>
</template> </template>
@@ -9,7 +9,6 @@ import Issue from './Issue.vue';
import { useTrack } from 'dashboard/composables'; import { useTrack } from 'dashboard/composables';
import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api'; import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({ const props = defineProps({
conversationId: { conversationId: {
@@ -101,25 +100,23 @@ onMounted(() => {
<template> <template>
<div class="relative" :class="{ group: linkedIssue }"> <div class="relative" :class="{ group: linkedIssue }">
<Button <woot-button
v-on-clickaway="closeIssue" v-on-clickaway="closeIssue"
v-tooltip="tooltipText" v-tooltip="tooltipText"
sm variant="clear"
ghost color-scheme="secondary"
slate
class="!gap-1"
@click="openIssue" @click="openIssue"
> >
<fluent-icon <fluent-icon
icon="linear" icon="linear"
size="19" size="19"
class="text-[#5E6AD2] flex-shrink-0" class="text-[#5E6AD2]"
view-box="0 0 19 19" view-box="0 0 19 19"
/> />
<span v-if="linkedIssue" class="text-xs font-medium text-n-slate-11"> <span v-if="linkedIssue" class="text-xs font-medium text-ash-800">
{{ linkedIssue.issue.identifier }} {{ linkedIssue.issue.identifier }}
</span> </span>
</Button> </woot-button>
<Issue <Issue
v-if="linkedIssue" v-if="linkedIssue"
:issue="linkedIssue.issue" :issue="linkedIssue.issue"
@@ -3,12 +3,9 @@ import { required } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
import Modal from '../../Modal.vue'; import Modal from '../../Modal.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default { export default {
components: { components: {
Modal, Modal,
NextButton,
}, },
props: { props: {
show: { type: Boolean, default: false }, show: { type: Boolean, default: false },
@@ -69,20 +66,13 @@ export default {
:placeholder="confirmPlaceHolderText" :placeholder="confirmPlaceHolderText"
@blur="v$.value.$touch" @blur="v$.value.$touch"
/> />
<div class="flex items-center justify-end gap-2"> <div class="button-wrapper">
<NextButton <woot-button color-scheme="alert" :is-disabled="v$.value.$invalid">
faded {{ confirmText }}
slate </woot-button>
type="reset" <woot-button class="clear" @click.prevent="closeModal">
:label="rejectText" {{ rejectText }}
@click.prevent="closeModal" </woot-button>
/>
<NextButton
ruby
type="submit"
:label="confirmText"
:disabled="v$.value.$invalid"
/>
</div> </div>
</form> </form>
</Modal> </Modal>
@@ -1,11 +1,9 @@
<script> <script>
import Modal from '../../Modal.vue'; import Modal from '../../Modal.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default { export default {
components: { components: {
Modal, Modal,
NextButton,
}, },
props: { props: {
title: { title: {
@@ -57,8 +55,12 @@ export default {
<div class="h-auto overflow-auto flex flex-col"> <div class="h-auto overflow-auto flex flex-col">
<woot-modal-header :header-title="title" :header-content="description" /> <woot-modal-header :header-title="title" :header-content="description" />
<div class="flex flex-row justify-end gap-2 py-4 px-6 w-full"> <div class="flex flex-row justify-end gap-2 py-4 px-6 w-full">
<NextButton faded type="reset" :label="cancelLabel" @click="cancel" /> <woot-button variant="clear" @click="cancel">
<NextButton type="submit" :label="confirmLabel" @click="confirm" /> {{ cancelLabel }}
</woot-button>
<woot-button @click="confirm">
{{ confirmLabel }}
</woot-button>
</div> </div>
</div> </div>
</Modal> </Modal>
@@ -1,6 +1,5 @@
<script setup> <script setup>
import Modal from '../../Modal.vue'; import Modal from '../../Modal.vue';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({ defineProps({
onClose: { type: Function, default: () => {} }, onClose: { type: Function, default: () => {} },
@@ -23,8 +22,23 @@ const show = defineModel('show', { type: Boolean, default: false });
:header-content-value="messageValue" :header-content-value="messageValue"
/> />
<div class="flex items-center justify-end gap-2 p-8"> <div class="flex items-center justify-end gap-2 p-8">
<Button faded slate type="reset" :label="rejectText" @click="onClose" /> <woot-button variant="clear" class="action-button" @click="onClose">
<Button ruby type="submit" :label="confirmText" @click="onConfirm" /> {{ rejectText }}
</woot-button>
<woot-button
color-scheme="alert"
class="action-button"
variant="smooth"
@click="onConfirm"
>
{{ confirmText }}
</woot-button>
</div> </div>
</Modal> </Modal>
</template> </template>
<style lang="scss" scoped>
.action-button {
max-width: var(--space-giga);
}
</style>
@@ -21,7 +21,6 @@ export const getAttributeInputType = (key, allCustomAttributes) => {
const customAttribute = allCustomAttributes.find( const customAttribute = allCustomAttributes.find(
attr => attr.attribute_key === key attr => attr.attribute_key === key
); );
const { attribute_display_type } = customAttribute; const { attribute_display_type } = customAttribute;
const filterInputTypes = generateCustomAttributesInputType( const filterInputTypes = generateCustomAttributesInputType(
attribute_display_type attribute_display_type
@@ -69,22 +68,10 @@ const getValuesForCountries = (values, countries) => {
})); }));
}; };
const getValuesForPriority = (values, priority) => {
return priority.filter(option => values.includes(option.id));
};
export const getValuesForFilter = (filter, params) => { export const getValuesForFilter = (filter, params) => {
const { attribute_key, values } = filter; const { attribute_key, values } = filter;
const { const { languages, countries, agents, inboxes, teams, campaigns, labels } =
languages, params;
countries,
agents,
inboxes,
teams,
campaigns,
labels,
priority,
} = params;
switch (attribute_key) { switch (attribute_key) {
case 'status': case 'status':
return getValuesForStatus(values); return getValuesForStatus(values);
@@ -96,14 +83,15 @@ export const getValuesForFilter = (filter, params) => {
return getValuesName(values, teams, 'id', 'name'); return getValuesName(values, teams, 'id', 'name');
case 'campaign_id': case 'campaign_id':
return getValuesName(values, campaigns, 'id', 'title'); return getValuesName(values, campaigns, 'id', 'title');
case 'labels': case 'labels': {
return getValuesForLabels(values, labels); return getValuesForLabels(values, labels);
case 'priority': }
return getValuesForPriority(values, priority); case 'browser_language': {
case 'browser_language':
return getValuesForLanguages(values, languages); return getValuesForLanguages(values, languages);
case 'country_code': }
case 'country_code': {
return getValuesForCountries(values, countries); return getValuesForCountries(values, countries);
}
default: default:
return { id: values[0], name: values[0] }; return { id: values[0], name: values[0] };
} }
@@ -112,9 +100,9 @@ export const getValuesForFilter = (filter, params) => {
export const generateValuesForEditCustomViews = (filter, params) => { export const generateValuesForEditCustomViews = (filter, params) => {
const { attribute_key, filter_operator, values } = filter; const { attribute_key, filter_operator, values } = filter;
const { filterTypes, allCustomAttributes } = params; const { filterTypes, allCustomAttributes } = params;
const inputType = getInputType(attribute_key, filter_operator, filterTypes); const inboxType = getInputType(attribute_key, filter_operator, filterTypes);
if (inputType === undefined) { if (inboxType === undefined) {
const filterInputTypes = getAttributeInputType( const filterInputTypes = getAttributeInputType(
attribute_key, attribute_key,
allCustomAttributes allCustomAttributes
@@ -124,7 +112,7 @@ export const generateValuesForEditCustomViews = (filter, params) => {
: { id: values[0], name: values[0] }; : { id: values[0], name: values[0] };
} }
return inputType === 'multi_select' || inputType === 'search_select' return inboxType === 'multi_select' || inboxType === 'search_select'
? getValuesForFilter(filter, params) ? getValuesForFilter(filter, params)
: values[0].toString(); : values[0].toString();
}; };
@@ -1,149 +0,0 @@
import { LocalStorage } from 'shared/helpers/localStorage';
const AUDIO_STORAGE_KEY = 'draftRecordedAudio';
/**
* Converts a Blob to a Base64 string
* @param {Blob} blob - The blob to convert
* @returns {Promise<string>} - A promise that resolves with the Base64 string
*/
export const blobToBase64 = blob => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
};
/**
* Converts a Base64 string back to a Blob
* @param {string} base64String - The Base64 string to convert
* @returns {Blob} - The resulting Blob
*/
export const base64ToBlob = base64String => {
const [header, base64] = base64String.split(',');
const matches = header.match(/^data:([A-Za-z-+/]+);base64$/);
if (!matches || matches.length !== 2) {
throw new Error('Invalid base64 string format');
}
const contentType = matches[1];
const binaryString = atob(base64);
const bytes = new Uint8Array(
[...binaryString].map(char => char.charCodeAt(0))
);
return new Blob([bytes], { type: contentType });
};
/**
* Generates a storage key for a conversation's audio draft
* @param {string} conversationId - The conversation ID
* @param {string} replyType - The reply type (REPLY or NOTE)
* @returns {string} - The storage key
*/
export const getAudioStorageKey = (conversationId, replyType) =>
`audio-${conversationId}-${replyType}`;
/**
* Saves the recorded audio to the draft storage
* @param {string} conversationId - The conversation ID
* @param {string} replyType - The reply type (REPLY or NOTE)
* @param {Object} audioFile - The audio file object to save
* @returns {Promise<void>}
*/
export const saveAudioToDraft = async (
conversationId,
replyType,
audioFile
) => {
try {
if (!audioFile?.resource?.file) {
return;
}
// Check if the audio file size exceeds the 5MB limit for localStorage
// If it does, show an error message and do not save
if (audioFile.resource.file.size > 5 * 1024 * 1024) {
return;
}
const key = getAudioStorageKey(conversationId, replyType);
// Convert audio blob to Base64
const base64Audio = await blobToBase64(audioFile.resource.file);
// Save audio metadata
const audioData = {
base64: base64Audio,
thumb: audioFile.thumb,
name: audioFile.resource.name,
type: audioFile.resource.type,
size: audioFile.resource.size,
isPrivate: audioFile.isPrivate,
timestamp: Date.now(),
};
LocalStorage.updateJsonStore(AUDIO_STORAGE_KEY, key, audioData);
} catch (error) {
// Error saving audio draft
}
};
/**
* Retrieves audio from draft storage
* @param {string} conversationId - The conversation ID
* @param {string} replyType - The reply type (REPLY or NOTE)
* @returns {Promise<Object|null>} - The audio file object or null if not found
*/
export const getAudioFromDraft = (conversationId, replyType) => {
try {
const key = getAudioStorageKey(conversationId, replyType);
const audioData = LocalStorage.getFromJsonStore(AUDIO_STORAGE_KEY, key);
if (!audioData) {
return null;
}
// Convert Base64 back to Blob
const blob = base64ToBlob(audioData.base64);
// Create a file from the blob
const file = new File([blob], audioData.name, {
type: audioData.type,
lastModified: audioData.timestamp,
});
// Return the complete audio file object
return {
resource: {
file,
name: audioData.name,
type: audioData.type,
size: audioData.size,
},
isPrivate: audioData.isPrivate,
thumb: audioData.thumb,
isRecordedAudio: true,
};
} catch (error) {
// Error retrieving audio draft
return null;
}
};
/**
* Removes audio draft from storage
* @param {string} conversationId - The conversation ID
* @param {string} replyType - The reply type (REPLY or NOTE)
*/
export const removeAudioDraft = (conversationId, replyType) => {
try {
const key = getAudioStorageKey(conversationId, replyType);
LocalStorage.deleteFromJsonStore(AUDIO_STORAGE_KEY, key);
} catch (error) {
// Error removing audio draft
}
};
@@ -295,27 +295,7 @@
"CONVERSATION_INFO": "Conversation Information", "CONVERSATION_INFO": "Conversation Information",
"CONTACT_ATTRIBUTES": "Contact Attributes", "CONTACT_ATTRIBUTES": "Contact Attributes",
"PREVIOUS_CONVERSATION": "Previous Conversations", "PREVIOUS_CONVERSATION": "Previous Conversations",
"MACROS": "Macros", "MACROS": "Macros"
"SHOPIFY_ORDERS": "Shopify Orders"
},
"SHOPIFY": {
"ORDER_ID": "Order #{id}",
"ERROR": "Error loading orders",
"NO_SHOPIFY_ORDERS": "No orders found",
"FINANCIAL_STATUS": {
"PENDING": "Pending",
"AUTHORIZED": "Authorized",
"PARTIALLY_PAID": "Partially Paid",
"PAID": "Paid",
"PARTIALLY_REFUNDED": "Partially Refunded",
"REFUNDED": "Refunded",
"VOIDED": "Voided"
},
"FULFILLMENT_STATUS": {
"FULFILLED": "Fulfilled",
"PARTIALLY_FULFILLED": "Partially Fulfilled",
"UNFULFILLED": "Unfulfilled"
}
} }
}, },
"CONVERSATION_CUSTOM_ATTRIBUTES": { "CONVERSATION_CUSTOM_ATTRIBUTES": {
@@ -1,20 +1,5 @@
{ {
"INTEGRATION_SETTINGS": { "INTEGRATION_SETTINGS": {
"SHOPIFY": {
"DELETE": {
"TITLE": "Delete Shopify Integration",
"MESSAGE": "Are you sure you want to delete the Shopify integration?"
},
"STORE_URL": {
"TITLE": "Connect Shopify Store",
"LABEL": "Store URL",
"PLACEHOLDER": "your-store.myshopify.com",
"HELP": "Enter your Shopify store's myshopify.com URL",
"CANCEL": "Cancel",
"SUBMIT": "Connect Store"
},
"ERROR": "There was an error connecting to Shopify. Please try again or contact support if the issue persists."
},
"HEADER": "Integrations", "HEADER": "Integrations",
"DESCRIPTION": "Chatwoot integrates with multiple tools and services to improve your team's efficiency. Explore the list below to configure your favorite apps.", "DESCRIPTION": "Chatwoot integrates with multiple tools and services to improve your team's efficiency. Explore the list below to configure your favorite apps.",
"LEARN_MORE": "Learn more about integrations", "LEARN_MORE": "Learn more about integrations",
@@ -119,8 +119,7 @@
"EMPTY_LIST": "No results found" "EMPTY_LIST": "No results found"
}, },
"PAGINATION": { "PAGINATION": {
"RESULTS": "Showing {start} to {end} of {total} results", "RESULTS": "Showing {start} to {end} of {total} results"
"PER_PAGE_TEMPLATE": "{size} / page"
} }
}, },
"AGENT_REPORTS": { "AGENT_REPORTS": {
@@ -295,27 +295,7 @@
"CONVERSATION_INFO": "معلومات المحادثة", "CONVERSATION_INFO": "معلومات المحادثة",
"CONTACT_ATTRIBUTES": "سمات جهة الاتصال", "CONTACT_ATTRIBUTES": "سمات جهة الاتصال",
"PREVIOUS_CONVERSATION": "المحادثات السابقة", "PREVIOUS_CONVERSATION": "المحادثات السابقة",
"MACROS": "ماكروس", "MACROS": "ماكروس"
"SHOPIFY_ORDERS": "Shopify Orders"
},
"SHOPIFY": {
"ORDER_ID": "Order #{id}",
"ERROR": "Error loading orders",
"NO_SHOPIFY_ORDERS": "No orders found",
"FINANCIAL_STATUS": {
"PENDING": "معلق",
"AUTHORIZED": "Authorized",
"PARTIALLY_PAID": "Partially Paid",
"PAID": "Paid",
"PARTIALLY_REFUNDED": "Partially Refunded",
"REFUNDED": "Refunded",
"VOIDED": "Voided"
},
"FULFILLMENT_STATUS": {
"FULFILLED": "Fulfilled",
"PARTIALLY_FULFILLED": "Partially Fulfilled",
"UNFULFILLED": "Unfulfilled"
}
} }
}, },
"CONVERSATION_CUSTOM_ATTRIBUTES": { "CONVERSATION_CUSTOM_ATTRIBUTES": {
@@ -1,20 +1,5 @@
{ {
"INTEGRATION_SETTINGS": { "INTEGRATION_SETTINGS": {
"SHOPIFY": {
"DELETE": {
"TITLE": "Delete Shopify Integration",
"MESSAGE": "Are you sure you want to delete the Shopify integration?"
},
"STORE_URL": {
"TITLE": "Connect Shopify Store",
"LABEL": "Store URL",
"PLACEHOLDER": "your-store.myshopify.com",
"HELP": "Enter your Shopify store's myshopify.com URL",
"CANCEL": "إلغاء",
"SUBMIT": "Connect Store"
},
"ERROR": "There was an error connecting to Shopify. Please try again or contact support if the issue persists."
},
"HEADER": "خيارات الربط", "HEADER": "خيارات الربط",
"DESCRIPTION": "Chatwoot تتكامل مع أدوات وخدمات متعددة لتحسين كفاءة فريقك. استكشف القائمة أدناه لتكوين تطبيقاتك المفضلة.", "DESCRIPTION": "Chatwoot تتكامل مع أدوات وخدمات متعددة لتحسين كفاءة فريقك. استكشف القائمة أدناه لتكوين تطبيقاتك المفضلة.",
"LEARN_MORE": "معرفة المزيد عن التكاملات", "LEARN_MORE": "معرفة المزيد عن التكاملات",
@@ -119,8 +119,7 @@
"EMPTY_LIST": "لم يتم العثور على النتائج" "EMPTY_LIST": "لم يتم العثور على النتائج"
}, },
"PAGINATION": { "PAGINATION": {
"RESULTS": "Showing {start} to {end} of {total} results", "RESULTS": "Showing {start} to {end} of {total} results"
"PER_PAGE_TEMPLATE": "{size} / page"
} }
}, },
"AGENT_REPORTS": { "AGENT_REPORTS": {
@@ -295,27 +295,7 @@
"CONVERSATION_INFO": "Conversation Information", "CONVERSATION_INFO": "Conversation Information",
"CONTACT_ATTRIBUTES": "Contact Attributes", "CONTACT_ATTRIBUTES": "Contact Attributes",
"PREVIOUS_CONVERSATION": "Previous Conversations", "PREVIOUS_CONVERSATION": "Previous Conversations",
"MACROS": "Macros", "MACROS": "Macros"
"SHOPIFY_ORDERS": "Shopify Orders"
},
"SHOPIFY": {
"ORDER_ID": "Order #{id}",
"ERROR": "Error loading orders",
"NO_SHOPIFY_ORDERS": "No orders found",
"FINANCIAL_STATUS": {
"PENDING": "Pending",
"AUTHORIZED": "Authorized",
"PARTIALLY_PAID": "Partially Paid",
"PAID": "Paid",
"PARTIALLY_REFUNDED": "Partially Refunded",
"REFUNDED": "Refunded",
"VOIDED": "Voided"
},
"FULFILLMENT_STATUS": {
"FULFILLED": "Fulfilled",
"PARTIALLY_FULFILLED": "Partially Fulfilled",
"UNFULFILLED": "Unfulfilled"
}
} }
}, },
"CONVERSATION_CUSTOM_ATTRIBUTES": { "CONVERSATION_CUSTOM_ATTRIBUTES": {
@@ -1,20 +1,5 @@
{ {
"INTEGRATION_SETTINGS": { "INTEGRATION_SETTINGS": {
"SHOPIFY": {
"DELETE": {
"TITLE": "Delete Shopify Integration",
"MESSAGE": "Are you sure you want to delete the Shopify integration?"
},
"STORE_URL": {
"TITLE": "Connect Shopify Store",
"LABEL": "Store URL",
"PLACEHOLDER": "your-store.myshopify.com",
"HELP": "Enter your Shopify store's myshopify.com URL",
"CANCEL": "Cancel",
"SUBMIT": "Connect Store"
},
"ERROR": "There was an error connecting to Shopify. Please try again or contact support if the issue persists."
},
"HEADER": "Integrations", "HEADER": "Integrations",
"DESCRIPTION": "Chatwoot integrates with multiple tools and services to improve your team's efficiency. Explore the list below to configure your favorite apps.", "DESCRIPTION": "Chatwoot integrates with multiple tools and services to improve your team's efficiency. Explore the list below to configure your favorite apps.",
"LEARN_MORE": "Learn more about integrations", "LEARN_MORE": "Learn more about integrations",
@@ -119,8 +119,7 @@
"EMPTY_LIST": "No results found" "EMPTY_LIST": "No results found"
}, },
"PAGINATION": { "PAGINATION": {
"RESULTS": "Showing {start} to {end} of {total} results", "RESULTS": "Showing {start} to {end} of {total} results"
"PER_PAGE_TEMPLATE": "{size} / page"
} }
}, },
"AGENT_REPORTS": { "AGENT_REPORTS": {
@@ -295,27 +295,7 @@
"CONVERSATION_INFO": "Conversation Information", "CONVERSATION_INFO": "Conversation Information",
"CONTACT_ATTRIBUTES": "Contact Attributes", "CONTACT_ATTRIBUTES": "Contact Attributes",
"PREVIOUS_CONVERSATION": "Предишни разговори", "PREVIOUS_CONVERSATION": "Предишни разговори",
"MACROS": "Macros", "MACROS": "Macros"
"SHOPIFY_ORDERS": "Shopify Orders"
},
"SHOPIFY": {
"ORDER_ID": "Order #{id}",
"ERROR": "Error loading orders",
"NO_SHOPIFY_ORDERS": "No orders found",
"FINANCIAL_STATUS": {
"PENDING": "Предстоящ",
"AUTHORIZED": "Authorized",
"PARTIALLY_PAID": "Partially Paid",
"PAID": "Paid",
"PARTIALLY_REFUNDED": "Partially Refunded",
"REFUNDED": "Refunded",
"VOIDED": "Voided"
},
"FULFILLMENT_STATUS": {
"FULFILLED": "Fulfilled",
"PARTIALLY_FULFILLED": "Partially Fulfilled",
"UNFULFILLED": "Unfulfilled"
}
} }
}, },
"CONVERSATION_CUSTOM_ATTRIBUTES": { "CONVERSATION_CUSTOM_ATTRIBUTES": {
@@ -1,20 +1,5 @@
{ {
"INTEGRATION_SETTINGS": { "INTEGRATION_SETTINGS": {
"SHOPIFY": {
"DELETE": {
"TITLE": "Delete Shopify Integration",
"MESSAGE": "Are you sure you want to delete the Shopify integration?"
},
"STORE_URL": {
"TITLE": "Connect Shopify Store",
"LABEL": "Store URL",
"PLACEHOLDER": "your-store.myshopify.com",
"HELP": "Enter your Shopify store's myshopify.com URL",
"CANCEL": "Отмени",
"SUBMIT": "Connect Store"
},
"ERROR": "There was an error connecting to Shopify. Please try again or contact support if the issue persists."
},
"HEADER": "Integrations", "HEADER": "Integrations",
"DESCRIPTION": "Chatwoot integrates with multiple tools and services to improve your team's efficiency. Explore the list below to configure your favorite apps.", "DESCRIPTION": "Chatwoot integrates with multiple tools and services to improve your team's efficiency. Explore the list below to configure your favorite apps.",
"LEARN_MORE": "Learn more about integrations", "LEARN_MORE": "Learn more about integrations",
@@ -119,8 +119,7 @@
"EMPTY_LIST": "Няма намерени резултати" "EMPTY_LIST": "Няма намерени резултати"
}, },
"PAGINATION": { "PAGINATION": {
"RESULTS": "Showing {start} to {end} of {total} results", "RESULTS": "Showing {start} to {end} of {total} results"
"PER_PAGE_TEMPLATE": "{size} / page"
} }
}, },
"AGENT_REPORTS": { "AGENT_REPORTS": {
@@ -295,27 +295,7 @@
"CONVERSATION_INFO": "Informació de la conversa", "CONVERSATION_INFO": "Informació de la conversa",
"CONTACT_ATTRIBUTES": "Atributs de contacte", "CONTACT_ATTRIBUTES": "Atributs de contacte",
"PREVIOUS_CONVERSATION": "Converses prèvies", "PREVIOUS_CONVERSATION": "Converses prèvies",
"MACROS": "Macros", "MACROS": "Macros"
"SHOPIFY_ORDERS": "Shopify Orders"
},
"SHOPIFY": {
"ORDER_ID": "Order #{id}",
"ERROR": "Error loading orders",
"NO_SHOPIFY_ORDERS": "No orders found",
"FINANCIAL_STATUS": {
"PENDING": "Pendent",
"AUTHORIZED": "Authorized",
"PARTIALLY_PAID": "Partially Paid",
"PAID": "Paid",
"PARTIALLY_REFUNDED": "Partially Refunded",
"REFUNDED": "Refunded",
"VOIDED": "Voided"
},
"FULFILLMENT_STATUS": {
"FULFILLED": "Fulfilled",
"PARTIALLY_FULFILLED": "Partially Fulfilled",
"UNFULFILLED": "Unfulfilled"
}
} }
}, },
"CONVERSATION_CUSTOM_ATTRIBUTES": { "CONVERSATION_CUSTOM_ATTRIBUTES": {
@@ -1,20 +1,5 @@
{ {
"INTEGRATION_SETTINGS": { "INTEGRATION_SETTINGS": {
"SHOPIFY": {
"DELETE": {
"TITLE": "Delete Shopify Integration",
"MESSAGE": "Are you sure you want to delete the Shopify integration?"
},
"STORE_URL": {
"TITLE": "Connect Shopify Store",
"LABEL": "Store URL",
"PLACEHOLDER": "your-store.myshopify.com",
"HELP": "Enter your Shopify store's myshopify.com URL",
"CANCEL": "Cancel·la",
"SUBMIT": "Connect Store"
},
"ERROR": "There was an error connecting to Shopify. Please try again or contact support if the issue persists."
},
"HEADER": "Integracions", "HEADER": "Integracions",
"DESCRIPTION": "Chatwoot integrates with multiple tools and services to improve your team's efficiency. Explore the list below to configure your favorite apps.", "DESCRIPTION": "Chatwoot integrates with multiple tools and services to improve your team's efficiency. Explore the list below to configure your favorite apps.",
"LEARN_MORE": "Learn more about integrations", "LEARN_MORE": "Learn more about integrations",
@@ -119,8 +119,7 @@
"EMPTY_LIST": "No s'ha trobat agents" "EMPTY_LIST": "No s'ha trobat agents"
}, },
"PAGINATION": { "PAGINATION": {
"RESULTS": "Showing {start} to {end} of {total} results", "RESULTS": "Showing {start} to {end} of {total} results"
"PER_PAGE_TEMPLATE": "{size} / page"
} }
}, },
"AGENT_REPORTS": { "AGENT_REPORTS": {
@@ -295,27 +295,7 @@
"CONVERSATION_INFO": "Informace o konverzaci", "CONVERSATION_INFO": "Informace o konverzaci",
"CONTACT_ATTRIBUTES": "Atributy kontaktu", "CONTACT_ATTRIBUTES": "Atributy kontaktu",
"PREVIOUS_CONVERSATION": "Předchozí konverzace", "PREVIOUS_CONVERSATION": "Předchozí konverzace",
"MACROS": "Macros", "MACROS": "Macros"
"SHOPIFY_ORDERS": "Shopify Orders"
},
"SHOPIFY": {
"ORDER_ID": "Order #{id}",
"ERROR": "Error loading orders",
"NO_SHOPIFY_ORDERS": "No orders found",
"FINANCIAL_STATUS": {
"PENDING": "Čekající",
"AUTHORIZED": "Authorized",
"PARTIALLY_PAID": "Partially Paid",
"PAID": "Paid",
"PARTIALLY_REFUNDED": "Partially Refunded",
"REFUNDED": "Refunded",
"VOIDED": "Voided"
},
"FULFILLMENT_STATUS": {
"FULFILLED": "Fulfilled",
"PARTIALLY_FULFILLED": "Partially Fulfilled",
"UNFULFILLED": "Unfulfilled"
}
} }
}, },
"CONVERSATION_CUSTOM_ATTRIBUTES": { "CONVERSATION_CUSTOM_ATTRIBUTES": {
@@ -1,20 +1,5 @@
{ {
"INTEGRATION_SETTINGS": { "INTEGRATION_SETTINGS": {
"SHOPIFY": {
"DELETE": {
"TITLE": "Delete Shopify Integration",
"MESSAGE": "Are you sure you want to delete the Shopify integration?"
},
"STORE_URL": {
"TITLE": "Connect Shopify Store",
"LABEL": "Store URL",
"PLACEHOLDER": "your-store.myshopify.com",
"HELP": "Enter your Shopify store's myshopify.com URL",
"CANCEL": "Zrušit",
"SUBMIT": "Connect Store"
},
"ERROR": "There was an error connecting to Shopify. Please try again or contact support if the issue persists."
},
"HEADER": "Integrace", "HEADER": "Integrace",
"DESCRIPTION": "Chatwoot integrates with multiple tools and services to improve your team's efficiency. Explore the list below to configure your favorite apps.", "DESCRIPTION": "Chatwoot integrates with multiple tools and services to improve your team's efficiency. Explore the list below to configure your favorite apps.",
"LEARN_MORE": "Learn more about integrations", "LEARN_MORE": "Learn more about integrations",
@@ -119,8 +119,7 @@
"EMPTY_LIST": "Žádné výsledky" "EMPTY_LIST": "Žádné výsledky"
}, },
"PAGINATION": { "PAGINATION": {
"RESULTS": "Showing {start} to {end} of {total} results", "RESULTS": "Showing {start} to {end} of {total} results"
"PER_PAGE_TEMPLATE": "{size} / page"
} }
}, },
"AGENT_REPORTS": { "AGENT_REPORTS": {
@@ -295,27 +295,7 @@
"CONVERSATION_INFO": "Samtale Information", "CONVERSATION_INFO": "Samtale Information",
"CONTACT_ATTRIBUTES": "Kontakt Attributter", "CONTACT_ATTRIBUTES": "Kontakt Attributter",
"PREVIOUS_CONVERSATION": "Tidligere Samtaler", "PREVIOUS_CONVERSATION": "Tidligere Samtaler",
"MACROS": "Macros", "MACROS": "Macros"
"SHOPIFY_ORDERS": "Shopify Orders"
},
"SHOPIFY": {
"ORDER_ID": "Order #{id}",
"ERROR": "Error loading orders",
"NO_SHOPIFY_ORDERS": "No orders found",
"FINANCIAL_STATUS": {
"PENDING": "Afventer",
"AUTHORIZED": "Authorized",
"PARTIALLY_PAID": "Partially Paid",
"PAID": "Paid",
"PARTIALLY_REFUNDED": "Partially Refunded",
"REFUNDED": "Refunded",
"VOIDED": "Voided"
},
"FULFILLMENT_STATUS": {
"FULFILLED": "Fulfilled",
"PARTIALLY_FULFILLED": "Partially Fulfilled",
"UNFULFILLED": "Unfulfilled"
}
} }
}, },
"CONVERSATION_CUSTOM_ATTRIBUTES": { "CONVERSATION_CUSTOM_ATTRIBUTES": {
@@ -1,20 +1,5 @@
{ {
"INTEGRATION_SETTINGS": { "INTEGRATION_SETTINGS": {
"SHOPIFY": {
"DELETE": {
"TITLE": "Delete Shopify Integration",
"MESSAGE": "Are you sure you want to delete the Shopify integration?"
},
"STORE_URL": {
"TITLE": "Connect Shopify Store",
"LABEL": "Store URL",
"PLACEHOLDER": "your-store.myshopify.com",
"HELP": "Enter your Shopify store's myshopify.com URL",
"CANCEL": "Annuller",
"SUBMIT": "Connect Store"
},
"ERROR": "There was an error connecting to Shopify. Please try again or contact support if the issue persists."
},
"HEADER": "Integrationer", "HEADER": "Integrationer",
"DESCRIPTION": "Chatwoot integrates with multiple tools and services to improve your team's efficiency. Explore the list below to configure your favorite apps.", "DESCRIPTION": "Chatwoot integrates with multiple tools and services to improve your team's efficiency. Explore the list below to configure your favorite apps.",
"LEARN_MORE": "Learn more about integrations", "LEARN_MORE": "Learn more about integrations",
@@ -119,8 +119,7 @@
"EMPTY_LIST": "Ingen resultater fundet" "EMPTY_LIST": "Ingen resultater fundet"
}, },
"PAGINATION": { "PAGINATION": {
"RESULTS": "Showing {start} to {end} of {total} results", "RESULTS": "Showing {start} to {end} of {total} results"
"PER_PAGE_TEMPLATE": "{size} / page"
} }
}, },
"AGENT_REPORTS": { "AGENT_REPORTS": {
@@ -295,27 +295,7 @@
"CONVERSATION_INFO": "Konversationsinformationen", "CONVERSATION_INFO": "Konversationsinformationen",
"CONTACT_ATTRIBUTES": "Kontakt-Attribute", "CONTACT_ATTRIBUTES": "Kontakt-Attribute",
"PREVIOUS_CONVERSATION": "Vorherige Konversationen", "PREVIOUS_CONVERSATION": "Vorherige Konversationen",
"MACROS": "Makros", "MACROS": "Makros"
"SHOPIFY_ORDERS": "Shopify Orders"
},
"SHOPIFY": {
"ORDER_ID": "Order #{id}",
"ERROR": "Error loading orders",
"NO_SHOPIFY_ORDERS": "No orders found",
"FINANCIAL_STATUS": {
"PENDING": "Ausstehend",
"AUTHORIZED": "Authorized",
"PARTIALLY_PAID": "Partially Paid",
"PAID": "Paid",
"PARTIALLY_REFUNDED": "Partially Refunded",
"REFUNDED": "Refunded",
"VOIDED": "Voided"
},
"FULFILLMENT_STATUS": {
"FULFILLED": "Fulfilled",
"PARTIALLY_FULFILLED": "Partially Fulfilled",
"UNFULFILLED": "Unfulfilled"
}
} }
}, },
"CONVERSATION_CUSTOM_ATTRIBUTES": { "CONVERSATION_CUSTOM_ATTRIBUTES": {
@@ -1,20 +1,5 @@
{ {
"INTEGRATION_SETTINGS": { "INTEGRATION_SETTINGS": {
"SHOPIFY": {
"DELETE": {
"TITLE": "Delete Shopify Integration",
"MESSAGE": "Are you sure you want to delete the Shopify integration?"
},
"STORE_URL": {
"TITLE": "Connect Shopify Store",
"LABEL": "Store URL",
"PLACEHOLDER": "your-store.myshopify.com",
"HELP": "Enter your Shopify store's myshopify.com URL",
"CANCEL": "Stornieren",
"SUBMIT": "Connect Store"
},
"ERROR": "There was an error connecting to Shopify. Please try again or contact support if the issue persists."
},
"HEADER": "Integrationen", "HEADER": "Integrationen",
"DESCRIPTION": "Chatwoot integriert sich mit mehreren Tools und Diensten, um die Effizienz Ihres Teams zu verbessern. Erkunden Sie die folgende Liste, um Ihre Lieblingsapps zu konfigurieren.", "DESCRIPTION": "Chatwoot integriert sich mit mehreren Tools und Diensten, um die Effizienz Ihres Teams zu verbessern. Erkunden Sie die folgende Liste, um Ihre Lieblingsapps zu konfigurieren.",
"LEARN_MORE": "Mehr über Integrationen erfahren", "LEARN_MORE": "Mehr über Integrationen erfahren",
@@ -119,8 +119,7 @@
"EMPTY_LIST": "Keine Ergebnisse gefunden" "EMPTY_LIST": "Keine Ergebnisse gefunden"
}, },
"PAGINATION": { "PAGINATION": {
"RESULTS": "Showing {start} to {end} of {total} results", "RESULTS": "Showing {start} to {end} of {total} results"
"PER_PAGE_TEMPLATE": "{size} / page"
} }
}, },
"AGENT_REPORTS": { "AGENT_REPORTS": {
@@ -295,27 +295,7 @@
"CONVERSATION_INFO": "Πληροφορίες Συνομιλίας", "CONVERSATION_INFO": "Πληροφορίες Συνομιλίας",
"CONTACT_ATTRIBUTES": "Ιδιότητες Επαφής", "CONTACT_ATTRIBUTES": "Ιδιότητες Επαφής",
"PREVIOUS_CONVERSATION": "Προηγούμενες συνομιλίες", "PREVIOUS_CONVERSATION": "Προηγούμενες συνομιλίες",
"MACROS": "Μακροεντολές", "MACROS": "Μακροεντολές"
"SHOPIFY_ORDERS": "Shopify Orders"
},
"SHOPIFY": {
"ORDER_ID": "Order #{id}",
"ERROR": "Error loading orders",
"NO_SHOPIFY_ORDERS": "No orders found",
"FINANCIAL_STATUS": {
"PENDING": "Εκκρεμεί",
"AUTHORIZED": "Authorized",
"PARTIALLY_PAID": "Partially Paid",
"PAID": "Paid",
"PARTIALLY_REFUNDED": "Partially Refunded",
"REFUNDED": "Refunded",
"VOIDED": "Voided"
},
"FULFILLMENT_STATUS": {
"FULFILLED": "Fulfilled",
"PARTIALLY_FULFILLED": "Partially Fulfilled",
"UNFULFILLED": "Unfulfilled"
}
} }
}, },
"CONVERSATION_CUSTOM_ATTRIBUTES": { "CONVERSATION_CUSTOM_ATTRIBUTES": {
@@ -1,20 +1,5 @@
{ {
"INTEGRATION_SETTINGS": { "INTEGRATION_SETTINGS": {
"SHOPIFY": {
"DELETE": {
"TITLE": "Delete Shopify Integration",
"MESSAGE": "Are you sure you want to delete the Shopify integration?"
},
"STORE_URL": {
"TITLE": "Connect Shopify Store",
"LABEL": "Store URL",
"PLACEHOLDER": "your-store.myshopify.com",
"HELP": "Enter your Shopify store's myshopify.com URL",
"CANCEL": "Άκυρο",
"SUBMIT": "Connect Store"
},
"ERROR": "There was an error connecting to Shopify. Please try again or contact support if the issue persists."
},
"HEADER": "Ενοποιήσεις", "HEADER": "Ενοποιήσεις",
"DESCRIPTION": "Chatwoot integrates with multiple tools and services to improve your team's efficiency. Explore the list below to configure your favorite apps.", "DESCRIPTION": "Chatwoot integrates with multiple tools and services to improve your team's efficiency. Explore the list below to configure your favorite apps.",
"LEARN_MORE": "Learn more about integrations", "LEARN_MORE": "Learn more about integrations",
@@ -119,8 +119,7 @@
"EMPTY_LIST": "Δεν βρέθηκαν αποτελέσματα" "EMPTY_LIST": "Δεν βρέθηκαν αποτελέσματα"
}, },
"PAGINATION": { "PAGINATION": {
"RESULTS": "Showing {start} to {end} of {total} results", "RESULTS": "Showing {start} to {end} of {total} results"
"PER_PAGE_TEMPLATE": "{size} / page"
} }
}, },
"AGENT_REPORTS": { "AGENT_REPORTS": {
@@ -195,8 +195,7 @@
"YES": "Send", "YES": "Send",
"CANCEL": "Cancel" "CANCEL": "Cancel"
} }
}, }
"DRAFT_AUDIO_SIZE_LIMIT_ERROR": "Audio recording exceeds the 5MB size limit and cannot be saved as a draft"
}, },
"VISIBLE_TO_AGENTS": "Private Note: Only visible to you and your team", "VISIBLE_TO_AGENTS": "Private Note: Only visible to you and your team",
"CHANGE_STATUS": "Conversation status changed", "CHANGE_STATUS": "Conversation status changed",
@@ -295,27 +295,7 @@
"CONVERSATION_INFO": "Información de la conversación", "CONVERSATION_INFO": "Información de la conversación",
"CONTACT_ATTRIBUTES": "Atributos de contacto", "CONTACT_ATTRIBUTES": "Atributos de contacto",
"PREVIOUS_CONVERSATION": "Conversaciones anteriores", "PREVIOUS_CONVERSATION": "Conversaciones anteriores",
"MACROS": "Macros", "MACROS": "Macros"
"SHOPIFY_ORDERS": "Shopify Orders"
},
"SHOPIFY": {
"ORDER_ID": "Order #{id}",
"ERROR": "Error loading orders",
"NO_SHOPIFY_ORDERS": "No orders found",
"FINANCIAL_STATUS": {
"PENDING": "Pendientes",
"AUTHORIZED": "Authorized",
"PARTIALLY_PAID": "Partially Paid",
"PAID": "Paid",
"PARTIALLY_REFUNDED": "Partially Refunded",
"REFUNDED": "Refunded",
"VOIDED": "Voided"
},
"FULFILLMENT_STATUS": {
"FULFILLED": "Fulfilled",
"PARTIALLY_FULFILLED": "Partially Fulfilled",
"UNFULFILLED": "Unfulfilled"
}
} }
}, },
"CONVERSATION_CUSTOM_ATTRIBUTES": { "CONVERSATION_CUSTOM_ATTRIBUTES": {
@@ -1,20 +1,5 @@
{ {
"INTEGRATION_SETTINGS": { "INTEGRATION_SETTINGS": {
"SHOPIFY": {
"DELETE": {
"TITLE": "Delete Shopify Integration",
"MESSAGE": "Are you sure you want to delete the Shopify integration?"
},
"STORE_URL": {
"TITLE": "Connect Shopify Store",
"LABEL": "Store URL",
"PLACEHOLDER": "your-store.myshopify.com",
"HELP": "Enter your Shopify store's myshopify.com URL",
"CANCEL": "Cancelar",
"SUBMIT": "Connect Store"
},
"ERROR": "There was an error connecting to Shopify. Please try again or contact support if the issue persists."
},
"HEADER": "Integraciones", "HEADER": "Integraciones",
"DESCRIPTION": "Chatwoot se integra con múltiples herramientas y servicios para mejorar la eficiencia de tu equipo. Explora la lista de abajo para configurar tus aplicaciones favoritas.", "DESCRIPTION": "Chatwoot se integra con múltiples herramientas y servicios para mejorar la eficiencia de tu equipo. Explora la lista de abajo para configurar tus aplicaciones favoritas.",
"LEARN_MORE": "Más información acerca de integraciones", "LEARN_MORE": "Más información acerca de integraciones",
@@ -119,8 +119,7 @@
"EMPTY_LIST": "No se encontraron resultados" "EMPTY_LIST": "No se encontraron resultados"
}, },
"PAGINATION": { "PAGINATION": {
"RESULTS": "Mostrando {start} a {end} de {total} resultados", "RESULTS": "Mostrando {start} a {end} de {total} resultados"
"PER_PAGE_TEMPLATE": "{size} / page"
} }
}, },
"AGENT_REPORTS": { "AGENT_REPORTS": {
@@ -295,27 +295,7 @@
"CONVERSATION_INFO": "اطلاعات مکالمه", "CONVERSATION_INFO": "اطلاعات مکالمه",
"CONTACT_ATTRIBUTES": "ویژگی‌های تماس", "CONTACT_ATTRIBUTES": "ویژگی‌های تماس",
"PREVIOUS_CONVERSATION": "گفتگوهای قبلی", "PREVIOUS_CONVERSATION": "گفتگوهای قبلی",
"MACROS": "ماکروها", "MACROS": "ماکروها"
"SHOPIFY_ORDERS": "Shopify Orders"
},
"SHOPIFY": {
"ORDER_ID": "Order #{id}",
"ERROR": "Error loading orders",
"NO_SHOPIFY_ORDERS": "No orders found",
"FINANCIAL_STATUS": {
"PENDING": "در انتظار",
"AUTHORIZED": "Authorized",
"PARTIALLY_PAID": "Partially Paid",
"PAID": "Paid",
"PARTIALLY_REFUNDED": "Partially Refunded",
"REFUNDED": "Refunded",
"VOIDED": "Voided"
},
"FULFILLMENT_STATUS": {
"FULFILLED": "Fulfilled",
"PARTIALLY_FULFILLED": "Partially Fulfilled",
"UNFULFILLED": "Unfulfilled"
}
} }
}, },
"CONVERSATION_CUSTOM_ATTRIBUTES": { "CONVERSATION_CUSTOM_ATTRIBUTES": {
@@ -1,20 +1,5 @@
{ {
"INTEGRATION_SETTINGS": { "INTEGRATION_SETTINGS": {
"SHOPIFY": {
"DELETE": {
"TITLE": "Delete Shopify Integration",
"MESSAGE": "Are you sure you want to delete the Shopify integration?"
},
"STORE_URL": {
"TITLE": "Connect Shopify Store",
"LABEL": "Store URL",
"PLACEHOLDER": "your-store.myshopify.com",
"HELP": "Enter your Shopify store's myshopify.com URL",
"CANCEL": "انصراف",
"SUBMIT": "Connect Store"
},
"ERROR": "There was an error connecting to Shopify. Please try again or contact support if the issue persists."
},
"HEADER": "برنامه‌های تلفیق شده", "HEADER": "برنامه‌های تلفیق شده",
"DESCRIPTION": "Chatwoot integrates with multiple tools and services to improve your team's efficiency. Explore the list below to configure your favorite apps.", "DESCRIPTION": "Chatwoot integrates with multiple tools and services to improve your team's efficiency. Explore the list below to configure your favorite apps.",
"LEARN_MORE": "Learn more about integrations", "LEARN_MORE": "Learn more about integrations",
@@ -119,8 +119,7 @@
"EMPTY_LIST": "نتیجه‌ای یافت نشد" "EMPTY_LIST": "نتیجه‌ای یافت نشد"
}, },
"PAGINATION": { "PAGINATION": {
"RESULTS": "Showing {start} to {end} of {total} results", "RESULTS": "Showing {start} to {end} of {total} results"
"PER_PAGE_TEMPLATE": "{size} / page"
} }
}, },
"AGENT_REPORTS": { "AGENT_REPORTS": {
@@ -295,27 +295,7 @@
"CONVERSATION_INFO": "Keskustelun Tiedot", "CONVERSATION_INFO": "Keskustelun Tiedot",
"CONTACT_ATTRIBUTES": "Yhteystiedon määritteet", "CONTACT_ATTRIBUTES": "Yhteystiedon määritteet",
"PREVIOUS_CONVERSATION": "Edelliset keskustelut", "PREVIOUS_CONVERSATION": "Edelliset keskustelut",
"MACROS": "Macros", "MACROS": "Macros"
"SHOPIFY_ORDERS": "Shopify Orders"
},
"SHOPIFY": {
"ORDER_ID": "Order #{id}",
"ERROR": "Error loading orders",
"NO_SHOPIFY_ORDERS": "No orders found",
"FINANCIAL_STATUS": {
"PENDING": "Odottava",
"AUTHORIZED": "Authorized",
"PARTIALLY_PAID": "Partially Paid",
"PAID": "Paid",
"PARTIALLY_REFUNDED": "Partially Refunded",
"REFUNDED": "Refunded",
"VOIDED": "Voided"
},
"FULFILLMENT_STATUS": {
"FULFILLED": "Fulfilled",
"PARTIALLY_FULFILLED": "Partially Fulfilled",
"UNFULFILLED": "Unfulfilled"
}
} }
}, },
"CONVERSATION_CUSTOM_ATTRIBUTES": { "CONVERSATION_CUSTOM_ATTRIBUTES": {
@@ -1,20 +1,5 @@
{ {
"INTEGRATION_SETTINGS": { "INTEGRATION_SETTINGS": {
"SHOPIFY": {
"DELETE": {
"TITLE": "Delete Shopify Integration",
"MESSAGE": "Are you sure you want to delete the Shopify integration?"
},
"STORE_URL": {
"TITLE": "Connect Shopify Store",
"LABEL": "Store URL",
"PLACEHOLDER": "your-store.myshopify.com",
"HELP": "Enter your Shopify store's myshopify.com URL",
"CANCEL": "Peruuta",
"SUBMIT": "Connect Store"
},
"ERROR": "There was an error connecting to Shopify. Please try again or contact support if the issue persists."
},
"HEADER": "Integraatiot", "HEADER": "Integraatiot",
"DESCRIPTION": "Chatwoot integrates with multiple tools and services to improve your team's efficiency. Explore the list below to configure your favorite apps.", "DESCRIPTION": "Chatwoot integrates with multiple tools and services to improve your team's efficiency. Explore the list below to configure your favorite apps.",
"LEARN_MORE": "Learn more about integrations", "LEARN_MORE": "Learn more about integrations",
@@ -119,8 +119,7 @@
"EMPTY_LIST": "Tuloksia ei löytynyt" "EMPTY_LIST": "Tuloksia ei löytynyt"
}, },
"PAGINATION": { "PAGINATION": {
"RESULTS": "Showing {start} to {end} of {total} results", "RESULTS": "Showing {start} to {end} of {total} results"
"PER_PAGE_TEMPLATE": "{size} / page"
} }
}, },
"AGENT_REPORTS": { "AGENT_REPORTS": {
@@ -295,27 +295,7 @@
"CONVERSATION_INFO": "Informations de la conversation", "CONVERSATION_INFO": "Informations de la conversation",
"CONTACT_ATTRIBUTES": "Attributs du contact", "CONTACT_ATTRIBUTES": "Attributs du contact",
"PREVIOUS_CONVERSATION": "Conversations précédentes", "PREVIOUS_CONVERSATION": "Conversations précédentes",
"MACROS": "Macros", "MACROS": "Macros"
"SHOPIFY_ORDERS": "Shopify Orders"
},
"SHOPIFY": {
"ORDER_ID": "Order #{id}",
"ERROR": "Error loading orders",
"NO_SHOPIFY_ORDERS": "No orders found",
"FINANCIAL_STATUS": {
"PENDING": "En attente",
"AUTHORIZED": "Authorized",
"PARTIALLY_PAID": "Partially Paid",
"PAID": "Paid",
"PARTIALLY_REFUNDED": "Partially Refunded",
"REFUNDED": "Refunded",
"VOIDED": "Voided"
},
"FULFILLMENT_STATUS": {
"FULFILLED": "Fulfilled",
"PARTIALLY_FULFILLED": "Partially Fulfilled",
"UNFULFILLED": "Unfulfilled"
}
} }
}, },
"CONVERSATION_CUSTOM_ATTRIBUTES": { "CONVERSATION_CUSTOM_ATTRIBUTES": {
@@ -1,20 +1,5 @@
{ {
"INTEGRATION_SETTINGS": { "INTEGRATION_SETTINGS": {
"SHOPIFY": {
"DELETE": {
"TITLE": "Delete Shopify Integration",
"MESSAGE": "Are you sure you want to delete the Shopify integration?"
},
"STORE_URL": {
"TITLE": "Connect Shopify Store",
"LABEL": "Store URL",
"PLACEHOLDER": "your-store.myshopify.com",
"HELP": "Enter your Shopify store's myshopify.com URL",
"CANCEL": "Annuler",
"SUBMIT": "Connect Store"
},
"ERROR": "There was an error connecting to Shopify. Please try again or contact support if the issue persists."
},
"HEADER": "Intégrations", "HEADER": "Intégrations",
"DESCRIPTION": "Chatwoot integrates with multiple tools and services to improve your team's efficiency. Explore the list below to configure your favorite apps.", "DESCRIPTION": "Chatwoot integrates with multiple tools and services to improve your team's efficiency. Explore the list below to configure your favorite apps.",
"LEARN_MORE": "Learn more about integrations", "LEARN_MORE": "Learn more about integrations",
@@ -119,8 +119,7 @@
"EMPTY_LIST": "Aucun résultat trouvé" "EMPTY_LIST": "Aucun résultat trouvé"
}, },
"PAGINATION": { "PAGINATION": {
"RESULTS": "Showing {start} to {end} of {total} results", "RESULTS": "Showing {start} to {end} of {total} results"
"PER_PAGE_TEMPLATE": "{size} / page"
} }
}, },
"AGENT_REPORTS": { "AGENT_REPORTS": {
@@ -295,27 +295,7 @@
"CONVERSATION_INFO": "מידע על שיחה", "CONVERSATION_INFO": "מידע על שיחה",
"CONTACT_ATTRIBUTES": "תכונות יצירת קשר", "CONTACT_ATTRIBUTES": "תכונות יצירת קשר",
"PREVIOUS_CONVERSATION": "שיחות קודמות", "PREVIOUS_CONVERSATION": "שיחות קודמות",
"MACROS": "מאקרו", "MACROS": "מאקרו"
"SHOPIFY_ORDERS": "Shopify Orders"
},
"SHOPIFY": {
"ORDER_ID": "Order #{id}",
"ERROR": "Error loading orders",
"NO_SHOPIFY_ORDERS": "No orders found",
"FINANCIAL_STATUS": {
"PENDING": "ממתין ל",
"AUTHORIZED": "Authorized",
"PARTIALLY_PAID": "Partially Paid",
"PAID": "Paid",
"PARTIALLY_REFUNDED": "Partially Refunded",
"REFUNDED": "Refunded",
"VOIDED": "Voided"
},
"FULFILLMENT_STATUS": {
"FULFILLED": "Fulfilled",
"PARTIALLY_FULFILLED": "Partially Fulfilled",
"UNFULFILLED": "Unfulfilled"
}
} }
}, },
"CONVERSATION_CUSTOM_ATTRIBUTES": { "CONVERSATION_CUSTOM_ATTRIBUTES": {

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