Compare commits

..
Author SHA1 Message Date
Shivam Mishra a9ee7819b3 feat: apply filter on click outside 2026-02-06 17:25:14 +05:30
Shivam Mishra 2456ef2729 fix: button 2026-02-06 17:19:13 +05:30
Shivam Mishra 59a44add5c chore: add todo 2026-02-06 17:15:29 +05:30
Shivam Mishra a37bcada72 fix: recalculate navigation offset after URL date restore
Arrow navigation was relative to current period instead of the
restored range. Now recomputes monthOffset from the difference
between restored dates and current period on URL restore.
2026-02-06 17:10:47 +05:30
Shivam Mishra 7a1a9fbd9b fix: use correct property names from getActiveDateRange
getActiveDateRange returns { start, end } not { startDate, endDate }.
Also pass currentDate to avoid undefined date calculations.
2026-02-06 16:45:18 +05:30
Shivam Mishra 122df392e0 fix: resolve date picker navigation bugs
- Reset monthOffset when selecting custom dates to prevent stale state
- Use date-based comparison for canNavigateNext instead of monthOffset
  so forward navigation works correctly after URL restore
- Use date-based comparison for week navigation label instead of
  monthOffset so "Week #N" label displays correctly after URL restore
2026-02-06 16:44:48 +05:30
Shivam Mishra 338b48d3c5 feat: add separator 2026-02-06 16:34:34 +05:30
Shivam Mishra b0be108ea0 feat: better year formatting 2026-02-06 16:34:27 +05:30
Shivam Mishra 402ffce895 feat: add this month and this week to date picker 2026-02-06 16:22:52 +05:30
Shivam Mishra 3fc9d78025 fix: height 2026-02-06 14:11:17 +05:30
iamsivin 3691f7b8b9 feat: Add URL persistence for SLA and CSAT report filters 2026-02-05 13:38:58 +05:30
iamsivin c52bf3571b feat: Add URL persistence for report filters 2026-02-05 01:55:43 +05:30
Sivin VargheseandGitHub 8ab0328b15 Merge branch 'develop' into feat/reports-filter 2026-02-04 20:47:57 +05:30
053b7774dd fix: Render all account limit fields (#13435)
## Bug Explanation
- The Super Admin limits form renders inputs by iterating the keys of
`account.limits`.
- When `account.limits` was present, `AccountLimitsField#to_s` returned
only that hash (no defaults).
- On save, `SuperAdmin::AccountsController` compacts the limits hash,
removing blank keys.
- Result: if only one key (e.g., `agents`) was saved, the other keys
were missing from the hash and their fields disappeared on the next
render.

## Fix
- Always start from a defaults hash of all expected limit keys and merge
in any saved overrides.
- This keeps the UI stable and ensures all limit inputs remain visible
even when the stored hash is partial.
- Upgraded meta_request to `0.8.5` to stop a dev‑only `SystemStackError`
caused by JSON‑encoding ActiveRecord::Transaction in Rails 7.2. No
production behavior changes.

## Reproduction Steps
1. In Super Admin, edit an account and set only `agents` in the limits;
leave other limit fields blank and save.
2. Re-open the same account in Super Admin.
3. Observe that only `agents` is rendered and other limit fields are
missing.

## Testing
- Tested on UI

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-02-04 20:21:07 +05:30
Muhsin KelothandGitHub 8eaea7c72e feat: Add standalone outgoing messages count API endpoint (#13419)
This PR adds a new standalone `GET
/api/v2/accounts/:id/reports/outgoing_messages_count` endpoint that
returns outgoing message counts grouped by agent, team, inbox, or label.
2026-02-04 19:36:50 +05:30
iamsivin 6cc9cce327 feat: Refactor reports filters 2026-02-04 16:14:23 +05:30
Tanmay Deep SharmaandGitHub 7ade9061a8 feat: display total FAQ count in Related FAQs dialog (#13433)
## Description

Display the total count of generated FAQs in the Related FAQs dialog
title to give users immediate visibility into how many FAQs were
generated from a document.

## Type of change

Please delete options that are not relevant.

- [ ] New feature (non-breaking change which adds functionality)

## Snapshots?

<img width="717" height="268" alt="Screenshot 2026-02-04 at 1 47 36 AM"
src="https://github.com/user-attachments/assets/c3e927ce-6d09-499d-8d02-8a44e0c353e2"
/>


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Small UI-only change using existing store metadata; risk is limited to
incorrect/blank counts if `meta.totalCount` is missing or stale.
> 
> **Overview**
> Updates the `RelatedResponses` dialog to display the total related
response count in the title by reading
`captainResponses/getMeta.totalCount` (defaulting to 0) and appending it
as `(<count>)`.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
7cd67c9991faceeff33d33c319e324b1c6cf73f4. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2026-02-04 11:27:51 +05:30
Sojan Jose 9eb3ee44a8 Revert "chore: Upgrade Rails to 7.2.2 and update Gemfile dependencies (#11037)"
This reverts commit ef6ba8aabd.
2026-02-03 21:09:42 -08:00
Sojan JoseandGitHub ef6ba8aabd chore: Upgrade Rails to 7.2.2 and update Gemfile dependencies (#11037)
Upgrade rails to 7.2.2 so that we can proceed with the rails 8 upgrade
afterwards
 
 # Changelog
- `.circleci/config.yml` — align CI DB setup with GitHub Actions
(`db:create` + `db:schema:load`) to avoid trigger-dependent prep steps.
- `.rubocop.yml` — add `rubocop-rspec_rails` and disable new cops that
don't match existing spec style.
- `AGENTS.md` — document that specs should run without `.env` (rename
temporarily when present).
- `Gemfile` — upgrade to Rails 7.2, switch Azure storage gem, pin
`commonmarker`, bump `sidekiq-cron`, add `rubocop-rspec_rails`, and
relax some gem pins.
- `Gemfile.lock` — dependency lockfile updates from the Rails 7.2 and
gem changes.
- `app/controllers/api/v1/accounts/integrations/linear_controller.rb` —
stringify params before passing to the Linear service to keep key types
stable.
- `app/controllers/super_admin/instance_statuses_controller.rb` — use
`MigrationContext` API for migration status in Rails 7.2.
- `app/models/installation_config.rb` — add commentary on YAML
serialization and future JSONB migration (no behavior change).
- `app/models/integrations/hook.rb` — ensure hook type is set on create
only and guard against missing app.
- `app/models/user.rb` — update enum syntax for Rails 7.2 deprecation,
serialize OTP backup codes with JSON, and use Ruby `alias`.
- `app/services/crm/leadsquared/setup_service.rb` — stringify hook
settings keys before merge to keep JSON shape consistent.
- `app/services/macros/execution_service.rb` — remove macro-specific
assignee activity workaround; rely on standard assignment handlers.
- `config/application.rb` — load Rails 7.2 defaults.
- `config/storage.yml` — update Azure Active Storage service name to
`AzureBlob`.
- `db/migrate/20230515051424_update_article_image_keys.rb` — use
credentials `secret_key_base` with fallback to legacy secrets.
- `docker/Dockerfile` — add `yaml-dev` and `pkgconf` packages for native
extensions (Ruby 3.4 / psych).
- `lib/seeders/reports/message_creator.rb` — add parentheses for clarity
in range calculation.
- `package.json` — pin Vite version and bump `vite-plugin-ruby`.
- `pnpm-lock.yaml` — lockfile changes from JS dependency updates.
- `spec/builders/v2/report_builder_spec.rb` — disable transactional
fixtures; truncate tables per example via Rails `truncate_tables` so
after_commit callbacks run with clean isolation; keep builder spec
metadata minimal.
- `spec/builders/v2/reports/label_summary_builder_spec.rb` — disable
transactional fixtures + truncate tables via Rails `truncate_tables`;
revert to real `resolved!`/`open!`/`resolved!` flow for multiple
resolution events; align date range to `Time.zone` to avoid offset gaps;
keep builder spec metadata minimal.
- `spec/controllers/api/v1/accounts/macros_controller_spec.rb` — assert
`assignee_id` instead of activity message to avoid transaction-timing
flakes.
- `spec/services/telegram/incoming_message_service_spec.rb` — reference
the contact tied to the created conversation instead of
`Contact.all.first` to avoid order-dependent failures when other specs
leave data behind.
-
`spec/mailers/administrator_notifications/shared/smtp_config_shared.rb`
— use `with_modified_env` instead of stubbing mailer internals.
- `spec/services/account/sign_up_email_validation_service_spec.rb` —
compare error `class.name` for parallel/reload-safe assertions.
2026-02-03 14:29:26 -08:00
Vishnu NarayananandGitHub c884cdefde feat: add per-account daily rate limit for outbound emails (#13411)
Introduce a daily cap on non-channel outbound emails to prevent abuse.

Fixes https://linear.app/chatwoot/issue/CW-6418/ses-incident-jan-28

## Type of change

- [x] New feature (non-breaking change which adds functionality)
- [x] Breaking change (fix or feature that would cause existing
functionality not to work as expected)

## Summary
- Adds a Redis-based daily counter to rate limit outbound emails per
account, preventing email abuse
- Covers continuity emails (WebWidget/API), conversation transcripts,
and agent notifications
  - Email channel replies are excluded (paid feature, not abusable)
- Adds account suspension check in `ConversationReplyMailer` to block
already-queued emails for suspended accounts

  ## Limit Resolution Hierarchy
1. Per-account override (`account.limits['emails']`) — SuperAdmin
configurable
2. Enterprise plan-based (`ACCOUNT_EMAILS_PLAN_LIMITS`
InstallationConfig)
3. Global default (`ACCOUNT_EMAILS_LIMIT` InstallationConfig, default:
100)
  4. Fallback (`ChatwootApp.max_limit` — effectively unlimited)

  ## Enforcement Points
  | Path | Where | Behavior |
  |------|-------|----------|
| WebWidget/API continuity |
`SendEmailNotificationService#should_send_email_notification?` |
Silently skipped |
| Widget transcript | `Widget::ConversationsController#transcript` |
Returns 429 |
| API transcript | `ConversationsController#transcript` | Returns 429 |
| Agent notifications | `Notification::EmailNotificationService#perform`
| Silently skipped |
  | Email channel replies | Not rate limited | Paid feature |
| Suspended accounts | `ConversationReplyMailer` | Blocked at mailer
level |
2026-02-03 02:06:51 +05:30
c77d935e38 fix: Subscribe app to WABA before overriding webhook callback URL (#13279)
#### Problem
Meta requires the app to be subscribed to the WABA before
`override_callback_uri` can be used. The current implementation tries to
use `override_callback_uri` directly, which fails with:

> Error 100: "Before override the current callback uri, your app must be
subscribed to receive messages for WhatsApp Business Account"

This causes embedded signup to fail silently, the inbox appears
connected but never receives messages.

  #### Solution

  Split `subscribe_waba_webhook` into two sequential API calls:

  ```ruby
  def subscribe_waba_webhook(waba_id, callback_url, verify_token)
    # Step 1: Subscribe app to WABA first (required before override)
    subscribe_app_to_waba(waba_id)

    # Step 2: Override callback URL for this specific WABA
    override_waba_callback(waba_id, callback_url, verify_token)
  end
```

#### References
  - Subscribe app to WABA's webhooks: https://www.postman.com/meta/whatsapp-business-platform/request/ju40fld/subscribe-app-to-waba-s-webhooks
  - Override Callback URL (Embedded Signup): https://www.postman.com/meta/whatsapp-business-platform/request/l6a09ow/override-callback-url

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-02-02 16:50:35 +05:30
Muhsin KelothandGitHub b686d14044 feat: Handle external echo messages from native apps (#13371)
When businesses use WhatsApp Business App (co-existence mode) or
Instagram App or TikTok alongside Chatwoot, messages sent from the
native apps were not synced properly back to Chatwoot. This left agents
with an incomplete conversation history and no visibility into responses
sent outside the dashboard. Additionally, if these echo messages did
arrive, they appeared as "Sent by: Bot" in the UI since they had no
sender, making it confusing for agents.

This PR subscribes to WhatsApp `smb_message_echoes` webhook events and
routes them through the existing service with an `outgoing_echo` flag,
mirroring how Instagram already handles echoes. On the Instagram side,
echo messages now also carry the `external_echo` content attribute and
`delivered` status.

On the frontend, messages with `externalEcho` are distinguished from bot
messages showing a "Native app" avatar and an advisory note encouraging
agents to reply from Chatwoot to maintain the service window.

<img width="1518" height="524" alt="CleanShot 2026-01-29 at 13 37 57@2x"
src="https://github.com/user-attachments/assets/5aa0b552-6382-441f-96aa-9a62ca716e4a"
/>


Fixes
https://linear.app/chatwoot/issue/CW-4204/display-messages-not-sent-from-chatwoot-in-case-of-outgoing-echo
Fixes
https://linear.app/chatwoot/issue/PLA-33/incoming-from-me-messages-from-whatsapp-business-app-are-not-falling
2026-02-02 15:52:53 +05:30
Shivam MishraandGitHub 133fb1bcf6 feat: add mark pending action to automation (#13378) 2026-02-02 11:59:51 +05:30
PranavandGitHub e9e6de5690 fix: Increase the parallelism config to fix flaky tests, revert bad commits (#13410)
The specs break only in Circle CI, we have to figure out the root cause
for the same. At the moment, I have increased the parallelism to fix
this.
2026-01-30 12:49:31 -08:00
PranavandGitHub 329b749702 Add API documentation for inbox, agent, and team summary report (#13409)
- Add API documentation for inbox, agent, and team summary report
endpoints
- These endpoints return conversation statistics grouped by
inbox/agent/team for a given date range

Endpoints documented:
GET /api/v2/accounts/{account_id}/summary_reports/inbox │ Conversation
stats grouped by inbox │
GET /api/v2/accounts/{account_id}/summary_reports/agent │ Conversation
stats grouped by agent │
GET /api/v2/accounts/{account_id}/summary_reports/team │ Conversation
stats grouped by team │

Query parameters (all endpoints):
- since - Start timestamp (Unix)
- until - End timestamp (Unix)
- business_hours - Calculate metrics using business hours only

Response fields:
- id - Inbox/Agent/Team ID
- conversations_count - Total conversations in date range
- resolved_conversations_count - Resolved conversations in date range
- avg_resolution_time - Average resolution time (seconds)
- avg_first_response_time - Average first response time (seconds)
- avg_reply_time - Average reply time (seconds)
2026-01-30 22:48:10 +04:00
PranavandGitHub d8c5dda36c chore: Update report documentation (#13408)
New API Documentation

GET
/api/v2/accounts/{account_id}/reports/first_response_time_distribution
  - Returns first response time distribution grouped by channel type
- Shows conversation counts in time buckets: 0-1h, 1-4h, 4-8h, 8-24h,
24h+
  - Parameters: since, until (Unix timestamps)

  GET /api/v2/accounts/{account_id}/reports/inbox_label_matrix
  - Returns a matrix of conversation counts for inbox-label combinations
  - Parameters: since, until, inbox_ids[], label_ids[]

  Fixes

  - Removed unused business_hours boolean parameter from
  /api/v2/accounts/{account_id}/summary_reports/channel
- Updated ReDoc script from unstable @next to stable @2.1.5 version to
fix empty swagger page
2026-01-30 22:33:03 +04:00
5ec77aca64 feat: Add first response time distribution report endpoint (#13400)
The index is already added in production.

Adds a new reporting API that returns conversation counts grouped by
channel type and first response time buckets (0-1h, 1-4h, 4-8h, 8-24h,
24h+).

- GET /api/v2/accounts/:id/reports/first_response_time_distribution
- Uses SQL aggregation to handle large datasets efficiently
- Adds composite index on reporting_events for query performance

Tested on production workload.
Request: GET
`/api/v2/accounts/1/reports/first_response_time_distribution?since=<since>&until=<until>`
Response payload:
```
{
    "Channel::WebWidget": {
      "0-1h": 120,
      "1-4h": 85,
      "4-8h": 32,
      "8-24h": 12,
      "24h+": 3
    },
    "Channel::Email": {
      "0-1h": 12,
      "1-4h": 28,
      "4-8h": 45,
      "8-24h": 35,
      "24h+": 10
    },
    "Channel::FacebookPage": {
      "0-1h": 50,
      "1-4h": 30,
      "4-8h": 15,
      "8-24h": 8,
      "24h+": 2
    }
  }
```

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-01-30 22:22:27 +04:00
Sivin VargheseandGitHub 85324c82fa fix: Formatting issue with reply preview content (#13399) 2026-01-30 16:35:32 +05:30
81307d5aea feat: search documentation tool for reply suggestions (#13340)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-01-30 16:18:33 +05:30
6f45af605c feat: Add inbox-label matrix report endpoint (#13394)
This PR added new API endpoint GET
/api/v2/accounts/:account_id/reports/inbox_label_matrix that returns
conversation counts grouped by inbox and label in a matrix format.
Supports optional filtering by date range, inbox_ids, and label_ids.

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
2026-01-29 13:32:59 -08:00
a32565d72b fix: velma connection limit (#13395)
## Description 
Make the $velma Redis connection pool size configurable via
`REDIS_VELMA_SIZE` environment variable (default: 5, matching current
behavior)
The $velma pool is used exclusively by Rack::Attack for rate limiting
and was the only Redis pool with a hardcoded size

## Fixes

Under high traffic, the hardcoded $velma pool (size: 5) causes
connection contention. Every HTTP request passes through Rack::Attack
middleware, which requires a $velma Redis connection. When
`WEB_CONCURRENCY=2` and `RAILS_MAX_THREADS=10` (20 concurrent threads),
the 4:1 thread-to-connection ratio causes threads to queue for up to 1
second (the pool timeout), resulting in intermittent request latency
spikes during traffic bursts.

The $alfred pool was already configurable via REDIS_ALFRED_SIZE — this
change brings $velma to parity.


## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules



<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Low risk: changes only Redis connection pool sizing for Rack::Attack;
misconfiguration could cause rate-limiting Redis contention or extra
connections but no data/auth logic changes.
> 
> **Overview**
> Makes the `velma` Redis connection pool (used by Rack::Attack)
configurable via a new `REDIS_VELMA_SIZE` env var, replacing the
previously hardcoded pool size.
> 
> Documents `REDIS_VELMA_SIZE` in `.env.example` alongside the existing
`REDIS_ALFRED_SIZE` setting.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
dcbc946f2e1d7356dc743178ca46cdf12cb25c78. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2026-01-29 20:53:41 +05:30
Muhsin KelothandGitHub 9238b4cc92 feat: Add the ability to configure WIDGET_TOKEN_EXPIRY (#13385)
This PR adds `WIDGET_TOKEN_EXPIRY` to the general config allowlist in the SuperAdmin app config page.
2026-01-29 09:57:01 +04:00
Muhsin KelothandGitHub acd0f1457e feat: Add TikTok social profile display support (#13384)
Fixes
https://linear.app/chatwoot/issue/PLA-77/store-tiktok-user-id-in-contact-attributes
Adds support for displaying TikTok profile links in the contact sidebar,
matching the behavior of other social channels (Instagram, Facebook,
Twitter, etc.).

<img width="400" height="600" alt="CleanShot 2026-01-28 at 15 48 14@2x"
src="https://github.com/user-attachments/assets/c861e6af-6a45-40ff-a4bf-7d46b8937691"
/>
2026-01-29 09:26:10 +04:00
77493c5d0f fix: captain assistant image comprehension (#13390)
# Pull Request Template

## Description

Fixes # (issue)

When we migrated to RubyLLM, images weren't being sent properly in
RubyLLM format to the model, so it did not understand images.

## Type of change

Please delete options that are not relevant.

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.

specs + local testing

Current behaviour on staging:
<img width="772" height="1012" alt="image"
src="https://github.com/user-attachments/assets/7b7d360f-dea4-48af-b20b-ee4c98a38a85"
/>

local testing with fix:
<img width="792" height="1216" alt="image"
src="https://github.com/user-attachments/assets/5ef82452-015e-4bda-a68f-884d00acb014"
/>


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-01-28 16:07:13 -08:00
PranavandGitHub 0d9c0b2ed2 fix: Force account_id in the query (#13388)
### What

Forces `account_id` to be applied consistently in queries and message creation paths.

### Why

Some queries were missing `account_id`, leading to cross-account scans and slow performance in large datasets.

### Changes

* Added `account_id` to the relevant query columns.
* Ensured messages are always created within the correct account scope.
* Updated `created_at` handling where required for consistency.

### Impact

* Prevents cross-account queries.
* Improves query performance.
* Reduces risk of incorrect data access across accounts.

### Notes

No functional behavior change for end users. This is a performance and safety fix.
2026-01-28 14:51:24 -08:00
2a69b37958 chore: Update theme colors and add new Inter variable fonts (#13347)
# Pull Request Template

## Description

This PR includes the following updates:

1. Updated the design system color tokens by introducing new tokens for
surfaces, overlays, buttons, labels, and cards, along with refinements
to existing shades.
2. Refreshed both light and dark themes with adjusted background,
border, and solid colors.
3. Replaced static Inter font files with the Inter variable font
(including italic), supporting weights from 100–900.
4. Added custom font weights (420, 440, 460, 520) along with custom
typography classes to enable more fine-grained and consistent typography
control.


## Type of change

- [x] New feature (non-breaking change which adds functionality)


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
2026-01-28 14:36:04 -08:00
Vishnu NarayananandGitHub 0ca98bc84f feat: add lightweight /health endpoint (#13386)
The existing /api health check endpoint creates a new Redis connection
on every request and checks both Redis and Postgres availability. During
peak traffic, this creates unnecessary load and can cause cascading
failures when either service is slow - instances get marked unhealthy,
traffic shifts to remaining instances, which then also fail health
checks.

The new /health endpoint:
  - Returns immediately with 200 {"status":"woot"}
  - Skips all middleware and authentication
  - No Redis or Postgres dependency
- Suitable for health checks that only need to verify the web server is
responding
2026-01-29 00:24:01 +05:30
Muhsin KelothandGitHub 6cd1b37981 feat: Tiktok API version configurable via Super Admin (#13381)
Extracted hardcoded TikTok API version (`v1.3`) into a configurable
`TIKTOK_API_VERSION` setting, consistent with how Instagram and WhatsApp
handle API versions.

Fixes
https://linear.app/chatwoot/issue/CW-6408/tiktok-api-version-configurable-via-super-admin
2026-01-28 19:46:48 +04:00
Muhsin KelothandGitHub 3b612e2b20 chore: Add unsupported message for Tiktok (#13380)
This PR adds the unsupported messages for tiktok.
Fixes
https://linear.app/chatwoot/issue/CW-6407/add-support-for-unsupported-message
2026-01-28 19:34:11 +04:00
d166ae73bc feat: add cron job to remove orphan conversations (#13335)
## Description

This PR includes cron job to delete the orphans

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Introduces a scheduled cleanup for conversations missing `contact` or
`inbox`.
> 
> - Adds `Internal::RemoveOrphanConversationsService` to batch-delete
orphan conversations (scoped by optional `account`, within a
configurable `days` window) with progress logging
> - New `Internal::RemoveOrphanConversationsJob` that invokes the
service; scheduled via `config/schedule.yml` to run every 12 hours on
`housekeeping` queue
> - Refactors rake task `chatwoot:ops:cleanup_orphan_conversations` to
use the service and report `total_deleted` after confirmation
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
59a24715cc59f048d08db3f588cde6fa036f3166. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-01-28 19:25:20 +05:30
Muhsin KelothandGitHub aaeea6c9bf feat: Display story replies with attachment and context label (#13356)
Fixes https://github.com/chatwoot/chatwoot/issues/13354
Fixes
https://linear.app/chatwoot/issue/CW-6394/story-responses-are-not-being-shown-in-the-ui
When someone replies to your Instagram story, agents in Chatwoot only
see the reply text with no story image and no indication that it was a
story reply. This makes it impossible to understand what the customer is
responding to the message looks like a random text with no context. For
example, if a customer replies "Love this!" to your story, the agent
just sees "Love this!" with no way to know which story triggered the
conversation. This PR fixes the issue by storing the story attachment
and adding a context label.

<img width="1408" height="2052" alt="CleanShot 2026-01-27 at 19 19
38@2x"
src="https://github.com/user-attachments/assets/341afea9-98e3-4e47-b2fa-ef77fe32851f"
/>
2026-01-28 16:47:04 +04:00
b870a48734 perf: limit the number of notifications per user to 300 (#13234)
## Linear issue


https://linear.app/chatwoot/issue/CW-6289/limit-the-number-of-notifications-per-user-to-300

## Description

Limits the number of notifications per user to 300 by introducing an
async trim job that runs after each notification creation. This prevents
unbounded notification growth that was causing DB CPU spikes.

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] This change requires a documentation update

## How Has This Been Tested?

- Added unit tests for TrimUserNotificationsJob

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Implements a dedicated purge job to control notification volume and
scheduling.
> 
> - Introduces `Notification::RemoveOldNotificationJob` (queue:
`purgable`) to delete notifications older than 1 month and trim each
user to the 300 most recent (deterministic by `created_at DESC, id
DESC`)
> - Adds daily cron (`remove_old_notification_job` at 22:30 UTC, queue
`purgable`) in `config/schedule.yml`
> - Removes ad-hoc triggering of the purge from
`TriggerScheduledItemsJob`
> - Adds/updates specs covering enqueue queue, old-notification
deletion, per-user trimming, and combined behavior
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
9ea2b48e36df96cd15d4119d1dd7dcf5250695de. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-01-28 17:35:13 +05:30
68f0da7351 fix: Attachments download authentication issue in Tiktok (#13151)
Fixes
https://linear.app/chatwoot/issue/CW-6357/ensure-authentication-when-fetching-attachments
Update the attachment download method to include the access token in the
request headers, ensuring proper authentication when fetching
attachments.

https://business-api.tiktok.com/portal/docs?id=1832184455450626

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-01-28 11:50:14 +04:00
40c622ed95 fix: Tiktok nil conversation handling in ReadStatusService (#13152)
Fixes
https://linear.app/chatwoot/issue/CW-6358/handling-of-nil-conversation-in-the-readstatusservice
Improve handling of nil conversation in the `ReadStatusService` to
prevent potential errors. Ensure that the conversation is checked before
performing updates to message status. This change fixes the below error.



```
NoMethodError: undefined method 'conversations' for nil (NoMethodError)

    channel.inbox.contact_inboxes.find_by(source_id: tt_conversation_id).conversations.first
                                                                        ^^^^^^^^^^^^^^
    from app/services/tiktok/messaging_helpers.rb:29:in 'Tiktok::MessagingHelpers#find_conversation'
    from app/services/tiktok/read_status_service.rb:13:in 'Tiktok::ReadStatusService#conversation'
    from app/services/tiktok/read_status_service.rb:9:in 'Tiktok::ReadStatusService#perform'
    from app/jobs/webhooks/tiktok_events_job.rb:67:in 'Webhooks::TiktokEventsJob#im_mark_read_msg'
    from app/jobs/webhooks/tiktok_events_job.rb:31:in 'Webhooks::TiktokEventsJob#process_event'
    from app/jobs/webhooks/tiktok_events_job.rb:15:in 'block in Webhooks::TiktokEventsJob#perform'
    from app/jobs/mutex_application_job.rb:23:in 'MutexApplicationJob#with_lock'
    from app/jobs/webhooks/tiktok_events_job.rb:14:in 'Webhooks::TiktokEventsJob#perform'
    from activejob (7.1.5.2) lib/active_job/execution.rb:68:in 'block in ActiveJob::Execution#_perform_job'
    from activesupport (7.1.5.2) lib/active_support/callbacks.rb:121:in 'block in ActiveSupport::Callbacks#run_callbacks'
    from i18n (1.14.7) lib/i18n.rb:353:in 'I18n::Base#with_locale'
    from activejob (7.1.5.2) lib/active_job/translation.rb:9:in 'block (2 levels) in <module:Translation>'
    from activesupport (7.1.5.2) lib/active_support/callbacks.rb:130:in 'BasicObject#instance_exec'
    from activesupport (7.1.5.2) lib/active_support/callbacks.rb:130:in 'block in ActiveSupport::Callbacks#run_callbacks'
    from activesupport (7.1.5.2) lib/active_support/core_ext/time/zones.rb:65:in 'Time.use_zone'
    from activejob (7.1.5.2) lib/active_job/timezones.rb:9:in 'block (2 levels) in <module:Timezones>'
    from activesupport (7.1.5.2) lib/active_support/callbacks.rb:130:in 'BasicObject#instance_exec'
    from activesupport (7.1.5.2) lib/active_support/callbacks.rb:130:in 'block in ActiveSupport::Callbacks#run_callbacks'
    from activesupport (7.1.5.2) lib/active_support/callbacks.rb:141:in 'ActiveSupport::Callbacks#run_callbacks'
    from activejob (7.1.5.2) lib/active_job/execution.rb:67:in 'ActiveJob::Execution#_perform_job
```

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-01-28 10:41:19 +04:00
PranavandGitHub 7cddba2b08 feat: Add infinite scroll to contacts search page (#13376)
## Summary
- Add `has_more` to contacts search API response to enable infinite
scroll without expensive count queries
- Set `count` to the number of items in the current page instead of
total count
- Implement "Load more" button for contacts search results
- Keep existing contacts visible while loading additional pages

## Changes

### Backend
- Add `fetch_contacts_with_has_more` method that fetches N+1 records to
determine if more pages exist
- Return `has_more` in search endpoint meta response
- Set `count` to current page size instead of total count

### Frontend
- Add `APPEND_CONTACTS` mutation for appending contacts without clearing
existing ones
- Update search action to support `append` parameter
- Add `ContactsLoadMore` component with loading state
- Update `ContactsListLayout` to support infinite scroll mode
- Update `ContactsIndex` to use infinite scroll for search view
2026-01-27 18:55:19 -08:00
04b2901e1f feat: Conversation workflows(EE) (#13040)
We are expanding Chatwoot’s automation capabilities by
introducing **Conversation Workflows**, a dedicated section in settings
where teams can configure rules that govern how conversations are closed
and what information agents must fill before resolving. This feature
helps teams enforce data consistency, collect structured resolution
information, and ensure downstream reporting is accurate.

Instead of having auto‑resolution buried inside Account Settings, we
introduced a new sidebar item:
- Auto‑resolve conversations (existing behaviour)
- Required attributes on resolution (new)

This groups all conversation‑closing logic into a single place.

#### Required Attributes on Resolve

Admins can now pick which custom conversation attributes must be filled
before an agent can resolve a conversation.

**How it works**

- Admin selects one or more attributes from the list of existing
conversation level custom attributes.
- These selected attributes become mandatory during resolution.
- List all the attributes configured via Required Attributes (Text,
Number, Link, Date, List, Checkbox)
- When an agent clicks Resolve Conversation:
If attributes already have values → the conversation resolves normally.
If attributes are missing → a modal appears prompting the agent to fill
them.

<img width="1554" height="1282" alt="CleanShot 2025-12-10 at 11 42
23@2x"
src="https://github.com/user-attachments/assets/4cd5d6e1-abe8-4999-accd-d4a08913b373"
/>


#### Custom Attributes Integration

On the Custom Attributes page, we will surfaced indicators showing how
each attribute is being used.

Each attribute will show badges such as:

- Resolution → used in the required‑on‑resolve workflow

- Pre‑chat form → already existing

<img width="2390" height="1822" alt="CleanShot 2025-12-10 at 11 43
42@2x"
src="https://github.com/user-attachments/assets/b92a6eb7-7f6c-40e6-bf23-6a5310f2d9c5"
/>


#### Admin Flow

- Navigate to Settings → Conversation Workflows.
- Under Required attributes on resolve, click Add Required Attribute.
- Pick from the dropdown list of conversation attributes.
- Save changes.

Agents will now be prompted automatically whenever they resolve.

<img width="2434" height="872" alt="CleanShot 2025-12-10 at 11 44 42@2x"
src="https://github.com/user-attachments/assets/632fc0e5-767c-4a1c-8cf4-ffe3d058d319"
/>



#### NOTES
- The Required Attributes on Resolve modal should only appear when
values are missing.
- Required attributes must block the resolution action until satisfied.
- Bulk‑resolve actions should follow the same rules — any conversation
missing attributes cannot be bulk‑resolved, rest will be resolved, show
a notification that the resolution cannot be done.
- API resolution does not respect the attributes.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Pranav <pranav@chatwoot.com>
2026-01-27 11:36:20 +04:00
TheDanniCraftandGitHub 885b041a83 fix: Update help center sitemap XML structure (#13357)
# Pull Request Template

## Description
The Help Center sitemap endpoint (`/hc/:portal_slug/sitemap.xml`)
previously rendered a `<sitemapindex>` element while embedding article
URLs directly, which does not align with the sitemap specification.

This change fixes the structure by:
- Replacing `<sitemapindex>` with `<urlset>`
- Adding the required sitemap XML namespace
- Rendering each published article as a `<url>` entry with `<loc>` and
`<lastmod>`

This ensures the endpoint outputs a valid, self-contained sitemap
document.

Fixes #13334

## Type of change

Please delete options that are not relevant.

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?
- Updated the existing `portals_controller_spec.rb`
- Adjusted assertions to validate a `<urlset>` root element and the
sitemap XML namespace
- Verified that the sitemap returns only published article URLs
- Ran the updated RSpec controller specs locally


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
2026-01-26 18:08:20 -08:00
PranavandGitHub ad2329c237 perf(conversations): throttle agent_last_seen_at updates to reduce DB load (#13355)
High-traffic accounts generate excessive database writes due to agents
frequently switching between conversations. The update_last_seen
endpoint was being called every time an agent loaded a conversation,
resulting in unnecessary updates to agent_last_seen_at and
assignee_last_seen_at even when there were no new messages to mark as
read.

#### Solution
Implemented throttling for the update_last_seen endpoint:

**Unread messages present:**
- Updates immediately without throttling to maintain accurate
read/unread state
- Uses assignee_unread_messages for assignees, unread_messages for other
agents

**No unread messages:**
- Throttles updates to once per hour per conversation
- Checks if agent_last_seen_at is older than 1 hour before updating
- For assignees, checks both agent_last_seen_at AND
assignee_last_seen_at - updates if either timestamp is old
- Skips DB write if all relevant timestamps were updated within the last
hour

- Consolidated two separate update_column calls into a single
update_columns call to reduce DB queries
2026-01-23 22:23:41 -08:00
PranavandGitHub 747d451387 chore: Improve signup flow, reduce the number of inputs (#13350)
- Improved design for the Chatwoot sign up page
2026-01-22 18:47:42 -08:00
Shivam MishraandGitHub 8eb6fd1bff feat: track copilot events (#13342) 2026-01-22 18:38:04 +05:30
Tanmay Deep SharmaandGitHub 75f75ce786 fix: sanitize integer fields to prevent Elasticsearch mapping errors (#13276)
## Linear task:

https://linear.app/chatwoot/issue/CW-6318/searchkickimporterror-type-=-mapper-parsing-exception-reason-=-failed

## Description

Fixes Elasticsearch `mapper_parsing_exception` errors that occur when
`campaign_id` contain non-numeric string values

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

- Unit tests
- use a local OpenSearch 3.4.0 cluster to verify actual indexing
behavior.


## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules


<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Removes `campaign_id` from the message search index payload to
simplify `additional_attributes`, keeping only `automation_rule_id`.
> 
> - `Messages::SearchDataPresenter#additional_attributes_data` now
returns only `automation_rule_id`
> - Specs updated to stop asserting `campaign_id` and continue
validating `automation_rule_id` and email subject handling
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
5a9c8eb794a044e3f258b644f67a6731de9e904c. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2026-01-22 18:29:49 +05:30
964d2f8544 perf: use account.contacts directly in search to reduce DB load (#12956)
- Use resolved contacts instead of accounts.contacts for search

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Pranav <pranavrajs@gmail.com>
2026-01-22 17:59:38 +05:30
Muhsin KelothandGitHub 1f5fdd7199 fix: Add Portuguese (Brazil) to CSAT template language options (#13343)
Added Portuguese (Brazil) (`pt_BR`) to the CSAT template language
dropdown
2026-01-22 15:59:24 +04:00
587 changed files with 20221 additions and 14923 deletions
+1 -1
View File
@@ -144,7 +144,7 @@ jobs:
# Backend tests with parallelization
backend-tests:
<<: *defaults
parallelism: 16
parallelism: 20
steps:
- checkout
- node/install:
+1
View File
@@ -276,3 +276,4 @@ AZURE_APP_SECRET=
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
# REDIS_ALFRED_SIZE=10
# REDIS_VELMA_SIZE=10
+2 -2
View File
@@ -541,9 +541,9 @@ GEM
net-smtp
marcel (1.0.4)
maxminddb (0.1.22)
meta_request (0.8.3)
meta_request (0.8.5)
rack-contrib (>= 1.1, < 3)
railties (>= 3.0.0, < 8)
railties (>= 3.0.0, < 9)
method_source (1.1.0)
mime-types (3.4.1)
mime-types-data (~> 3.2015)
@@ -112,6 +112,25 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
return if story_reply_attributes.blank?
@message.save_story_info(story_reply_attributes)
create_story_reply_attachment(story_reply_attributes['url'])
end
def create_story_reply_attachment(story_url)
return if story_url.blank?
attachment = @message.attachments.new(
file_type: :ig_story,
account_id: @message.account_id,
external_url: story_url
)
attachment.save!
begin
attach_file(attachment, story_url)
rescue Down::Error, StandardError => e
Rails.logger.warn "Failed to download Instagram story attachment: #{e.message}"
end
@message.content_attributes[:image_type] = 'ig_story_reply'
@message.save!
end
def build_conversation
@@ -139,6 +158,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: message_type,
status: @outgoing_echo ? :delivered : :sent,
source_id: message_identifier,
content: message_content,
sender: @outgoing_echo ? nil : contact,
@@ -147,6 +167,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
}
}
params[:content_attributes][:external_echo] = true if @outgoing_echo
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
params
end
@@ -0,0 +1,68 @@
class V2::Reports::FirstResponseTimeDistributionBuilder
include DateRangeHelper
attr_reader :account, :params
def initialize(account:, params:)
@account = account
@params = params
end
def build
build_distribution
end
private
def build_distribution
results = fetch_aggregated_counts
map_to_channel_types(results)
end
def fetch_aggregated_counts
ReportingEvent
.where(account_id: account.id, name: 'first_response')
.where(range_condition)
.group(:inbox_id)
.select(
:inbox_id,
bucket_case_statements
)
end
def bucket_case_statements
<<~SQL.squish
COUNT(CASE WHEN value < 3600 THEN 1 END) AS bucket_0_1h,
COUNT(CASE WHEN value >= 3600 AND value < 14400 THEN 1 END) AS bucket_1_4h,
COUNT(CASE WHEN value >= 14400 AND value < 28800 THEN 1 END) AS bucket_4_8h,
COUNT(CASE WHEN value >= 28800 AND value < 86400 THEN 1 END) AS bucket_8_24h,
COUNT(CASE WHEN value >= 86400 THEN 1 END) AS bucket_24h_plus
SQL
end
def range_condition
range.present? ? { created_at: range } : {}
end
def inbox_channel_types
@inbox_channel_types ||= account.inboxes.pluck(:id, :channel_type).to_h
end
def map_to_channel_types(results)
results.each_with_object({}) do |row, hash|
channel_type = inbox_channel_types[row.inbox_id]
next unless channel_type
hash[channel_type] ||= empty_buckets
hash[channel_type]['0-1h'] += row.bucket_0_1h
hash[channel_type]['1-4h'] += row.bucket_1_4h
hash[channel_type]['4-8h'] += row.bucket_4_8h
hash[channel_type]['8-24h'] += row.bucket_8_24h
hash[channel_type]['24h+'] += row.bucket_24h_plus
end
end
def empty_buckets
{ '0-1h' => 0, '1-4h' => 0, '4-8h' => 0, '8-24h' => 0, '24h+' => 0 }
end
end
@@ -0,0 +1,65 @@
class V2::Reports::InboxLabelMatrixBuilder
include DateRangeHelper
attr_reader :account, :params
def initialize(account:, params:)
@account = account
@params = params
end
def build
{
inboxes: filtered_inboxes.map { |inbox| { id: inbox.id, name: inbox.name } },
labels: filtered_labels.map { |label| { id: label.id, title: label.title } },
matrix: build_matrix
}
end
private
def filtered_inboxes
@filtered_inboxes ||= begin
inboxes = account.inboxes
inboxes = inboxes.where(id: params[:inbox_ids]) if params[:inbox_ids].present?
inboxes.order(:name).to_a
end
end
def filtered_labels
@filtered_labels ||= begin
labels = account.labels
labels = labels.where(id: params[:label_ids]) if params[:label_ids].present?
labels.order(:title).to_a
end
end
def conversation_filter
filter = { account_id: account.id }
filter[:created_at] = range if range.present?
filter[:inbox_id] = params[:inbox_ids] if params[:inbox_ids].present?
filter
end
def fetch_grouped_counts
label_names = filtered_labels.map(&:title)
return {} if label_names.empty?
ActsAsTaggableOn::Tagging
.joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id')
.joins('INNER JOIN tags ON taggings.tag_id = tags.id')
.where(taggable_type: 'Conversation', context: 'labels', conversations: conversation_filter)
.where(tags: { name: label_names })
.group('conversations.inbox_id', 'tags.name')
.count
end
def build_matrix
counts = fetch_grouped_counts
filtered_inboxes.map do |inbox|
filtered_labels.map do |label|
counts[[inbox.id, label.title]] || 0
end
end
end
end
@@ -0,0 +1,79 @@
class V2::Reports::OutgoingMessagesCountBuilder
include DateRangeHelper
attr_reader :account, :params
def initialize(account, params)
@account = account
@params = params
end
def build
send("build_by_#{params[:group_by]}")
end
private
def base_messages
account.messages.outgoing.unscope(:order).where(created_at: range)
end
def build_by_agent
counts = base_messages
.where(sender_type: 'User')
.where.not(sender_id: nil)
.group(:sender_id)
.count
user_names = account.users.where(id: counts.keys).index_by(&:id)
counts.map do |user_id, count|
user = user_names[user_id]
{ id: user_id, name: user&.name, outgoing_messages_count: count }
end
end
def build_by_team
counts = base_messages
.joins('INNER JOIN conversations ON messages.conversation_id = conversations.id')
.where.not(conversations: { team_id: nil })
.group('conversations.team_id')
.count
team_names = account.teams.where(id: counts.keys).index_by(&:id)
counts.map do |team_id, count|
team = team_names[team_id]
{ id: team_id, name: team&.name, outgoing_messages_count: count }
end
end
def build_by_inbox
counts = base_messages
.group(:inbox_id)
.count
inbox_names = account.inboxes.where(id: counts.keys).index_by(&:id)
counts.map do |inbox_id, count|
inbox = inbox_names[inbox_id]
{ id: inbox_id, name: inbox&.name, outgoing_messages_count: count }
end
end
def build_by_label
counts = base_messages
.joins('INNER JOIN conversations ON messages.conversation_id = conversations.id')
.joins("INNER JOIN taggings ON taggings.taggable_id = conversations.id
AND taggings.taggable_type = 'Conversation' AND taggings.context = 'labels'")
.joins('INNER JOIN tags ON tags.id = taggings.tag_id')
.group('tags.name')
.count
label_ids = account.labels.where(title: counts.keys).index_by(&:title)
counts.map do |label_name, count|
label = label_ids[label_name]
{ id: label&.id, name: label_name, outgoing_messages_count: count }
end
end
end
@@ -24,13 +24,11 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def search
render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return
contacts = resolved_contacts.where(
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search
OR contacts.additional_attributes->>\'company_name\' ILIKE :search',
contacts = Current.account.contacts.where(
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search',
search: "%#{params[:q].strip}%"
)
@contacts = fetch_contacts(contacts)
@contacts_count = @contacts.total_count
@contacts = fetch_contacts_with_has_more(contacts)
end
def import
@@ -143,6 +141,24 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
.per(RESULTS_PER_PAGE)
end
def fetch_contacts_with_has_more(contacts)
includes_hash = { avatar_attachment: [:blob] }
includes_hash[:contact_inboxes] = { inbox: :channel } if @include_contact_inboxes
# Calculate offset manually to fetch one extra record for has_more check
offset = (@current_page.to_i - 1) * RESULTS_PER_PAGE
results = filtrate(contacts)
.includes(includes_hash)
.offset(offset)
.limit(RESULTS_PER_PAGE + 1)
.to_a
@has_more = results.size > RESULTS_PER_PAGE
results = results.first(RESULTS_PER_PAGE) if @has_more
@contacts_count = results.size
results
end
def build_contact_inbox
return if params[:inbox_id].blank?
@@ -70,8 +70,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def transcript
render json: { error: 'email param missing' }, status: :unprocessable_entity and return if params[:email].blank?
return head :too_many_requests unless @conversation.account.within_email_rate_limit?
ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, params[:email])&.deliver_later
@conversation.account.increment_email_sent_count
head :ok
end
@@ -110,6 +112,15 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def update_last_seen
# High-traffic accounts generate excessive DB writes when agents frequently switch between conversations.
# Throttle last_seen updates to once per hour when there are no unread messages to reduce DB load.
# Always update immediately if there are unread messages to maintain accurate read/unread state.
return update_last_seen_on_conversation(DateTime.now.utc, true) if assignee? && @conversation.assignee_unread_messages.any?
return update_last_seen_on_conversation(DateTime.now.utc, false) if !assignee? && @conversation.unread_messages.any?
# No unread messages - apply throttling to limit DB writes
return unless should_update_last_seen?
update_last_seen_on_conversation(DateTime.now.utc, assignee?)
end
@@ -142,12 +153,25 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def update_last_seen_on_conversation(last_seen_at, update_assignee)
updates = { agent_last_seen_at: last_seen_at }
updates[:assignee_last_seen_at] = last_seen_at if update_assignee.present?
# rubocop:disable Rails/SkipsModelValidations
@conversation.update_column(:agent_last_seen_at, last_seen_at)
@conversation.update_column(:assignee_last_seen_at, last_seen_at) if update_assignee.present?
@conversation.update_columns(updates)
# rubocop:enable Rails/SkipsModelValidations
end
def should_update_last_seen?
# Update if at least one relevant timestamp is older than 1 hour or not set
# This prevents redundant DB writes when agents repeatedly view the same conversation
agent_needs_update = @conversation.agent_last_seen_at.blank? || @conversation.agent_last_seen_at < 1.hour.ago
return agent_needs_update unless assignee?
# For assignees, check both timestamps - update if either is old
assignee_needs_update = @conversation.assignee_last_seen_at.blank? || @conversation.assignee_last_seen_at < 1.hour.ago
agent_needs_update || assignee_needs_update
end
def set_conversation_status
@conversation.status = params[:status]
@conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until]
@@ -92,8 +92,11 @@ class Api::V1::AccountsController < Api::BaseController
end
def settings_params
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label,
conversation_required_attributes: [])
params.permit(*permitted_settings_attributes)
end
def permitted_settings_attributes
[:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label]
end
def check_signup_enabled
@@ -112,3 +115,5 @@ class Api::V1::AccountsController < Api::BaseController
}
end
end
Api::V1::AccountsController.prepend_mod_with('Api::V1::AccountsSettings')
@@ -35,12 +35,9 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
end
def transcript
if conversation.present? && conversation.contact.present? && conversation.contact.email.present?
ConversationReplyMailer.with(account: conversation.account).conversation_transcript(
conversation,
conversation.contact.email
)&.deliver_later
end
return head :too_many_requests unless conversation.present? && conversation.account.within_email_rate_limit?
send_transcript_email
head :ok
end
@@ -77,6 +74,16 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
private
def send_transcript_email
return if conversation.contact&.email.blank?
ConversationReplyMailer.with(account: conversation.account).conversation_transcript(
conversation,
conversation.contact.email
)&.deliver_later
conversation.account.increment_email_sent_count
end
def trigger_typing_event(event)
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: conversation, user: @contact)
end
@@ -62,6 +62,31 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
render json: bot_metrics
end
def inbox_label_matrix
builder = V2::Reports::InboxLabelMatrixBuilder.new(
account: Current.account,
params: inbox_label_matrix_params
)
render json: builder.build
end
def first_response_time_distribution
builder = V2::Reports::FirstResponseTimeDistributionBuilder.new(
account: Current.account,
params: first_response_time_distribution_params
)
render json: builder.build
end
OUTGOING_MESSAGES_ALLOWED_GROUP_BY = %w[agent team inbox label].freeze
def outgoing_messages_count
return head :unprocessable_entity unless OUTGOING_MESSAGES_ALLOWED_GROUP_BY.include?(params[:group_by])
builder = V2::Reports::OutgoingMessagesCountBuilder.new(Current.account, outgoing_messages_count_params)
render json: builder.build
end
private
def generate_csv(filename, template)
@@ -139,4 +164,28 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
def conversation_metrics
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
end
def inbox_label_matrix_params
{
since: params[:since],
until: params[:until],
inbox_ids: params[:inbox_ids],
label_ids: params[:label_ids]
}
end
def first_response_time_distribution_params
{
since: params[:since],
until: params[:until]
}
end
def outgoing_messages_count_params
{
group_by: params[:group_by],
since: params[:since],
until: params[:until]
}
end
end
+7
View File
@@ -0,0 +1,7 @@
# Inherits from ActionController::Base to skip all middleware,
# authentication, and callbacks. Used for health checks
class HealthController < ActionController::Base # rubocop:disable Rails/ApplicationController
def show
render json: { status: 'woot' }
end
end
@@ -42,11 +42,11 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'facebook' => %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT],
'shopify' => %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET],
'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET],
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
'email' => %w[MAILER_INBOUND_EMAIL_DOMAIN ACCOUNT_EMAILS_LIMIT ACCOUNT_EMAILS_PLAN_LIMITS],
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET],
'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET TIKTOK_API_VERSION],
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN],
@@ -55,7 +55,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
@allowed_configs = mapping.fetch(
@config,
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE]
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE WIDGET_TOKEN_EXPIRY]
)
end
end
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

@@ -1,11 +1,34 @@
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M42.4834 68.6832C42.7774 69.425 42.8429 70.2378 42.6714 71.0172L40.5414 77.5971C40.4728 77.9308 40.4905 78.2765 40.5929 78.6014C40.6954 78.9263 40.8791 79.2197 41.1267 79.4536C41.3743 79.6876 41.6776 79.8545 42.0078 79.9384C42.3379 80.0223 42.6841 80.0205 43.0134 79.9331L49.8393 77.9371C50.5747 77.7912 51.3364 77.855 52.0373 78.1211C56.308 80.1155 61.146 80.5375 65.6975 79.3125C70.249 78.0876 74.2217 75.2944 76.9145 71.4259C79.6073 67.5573 80.8473 62.862 80.4157 58.1683C79.984 53.4747 77.9085 49.0843 74.5553 45.7717C71.202 42.4592 66.7866 40.4375 62.088 40.0632C57.3894 39.6889 52.7096 40.9861 48.8742 43.726C45.0388 46.4659 42.2944 50.4724 41.1252 55.0385C39.9559 59.6047 40.4369 64.4371 42.4834 68.6832Z" stroke="#5A6165" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M53.8503 25.6371C58.2429 24.787 62.7578 24.787 67.1503 25.6371" stroke="#35373C" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M67.1503 94.3623C62.7578 95.2124 58.2429 95.2124 53.8503 94.3623" stroke="#35373C" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M80.1311 31.02C83.8462 33.5373 87.0426 36.7456 89.5461 40.47" stroke="#35373C" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M26.1371 66.6506C25.287 62.258 25.287 57.7432 26.1371 53.3506" stroke="#35373C" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M89.4798 79.6348C86.9625 83.3499 83.7542 86.5463 80.0298 89.0498" stroke="#35373C" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M94.8621 53.3506C95.7121 57.7432 95.7121 62.258 94.8621 66.6506" stroke="#35373C" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M31.5249 40.3696C34.0422 36.6544 37.2504 33.4581 40.9749 30.9546" stroke="#35373C" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M40.0717 91.9083L29.9007 94.8833C29.3241 95.0365 28.718 95.0397 28.1398 94.8927C27.5616 94.7457 27.0306 94.4534 26.5972 94.0434C26.1638 93.6335 25.8423 93.1196 25.6634 92.5505C25.4844 91.9814 25.4539 91.376 25.5747 90.7918L28.9522 80.3618" stroke="#35373C" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<svg width="495" height="384" viewBox="0 0 495 384" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="No Chat History">
<path id="Fill 1" fill-rule="evenodd" clip-rule="evenodd" d="M266.903 0C221.687 0 180.128 15.7196 147.313 42.0198H94.2771C87.3866 42.0198 81.749 47.6925 81.749 54.6257C81.749 61.559 87.3866 67.2317 94.2771 67.2317H121.268H129.178C136.068 67.2317 141.706 72.9043 141.706 79.8376C141.706 83.3 140.294 86.4599 138.023 88.7374C135.759 91.0233 132.619 92.4435 129.178 92.4435H112.303H102.986H57.5281C50.6376 92.4435 45 98.1162 45 105.049C45 111.983 50.6376 117.655 57.5281 117.655H90.071C80.2473 140.886 74.8059 166.446 74.8059 193.291C74.8059 203.031 75.5242 212.603 76.9023 221.957C77.4786 225.865 78.1718 229.739 78.982 233.571C102.618 229.932 127.691 227.008 153.866 224.873L155.837 226.68L157.692 224.571C189.137 222.108 222.144 220.797 256.187 220.797C328.516 220.797 396.201 226.714 454.072 237C456.302 227.268 457.806 217.259 458.516 207.023C458.841 202.485 459 197.905 459 193.291C459 86.5356 372.999 0 266.903 0Z" fill="#43484C"/>
<path id="Fill 3" fill-rule="evenodd" clip-rule="evenodd" d="M20.2465 91.4282H12.6678C5.7005 91.4282 0 97.1882 0 104.228C0 111.268 5.7005 117.028 12.6678 117.028H20.2465C27.2137 117.028 32.9142 111.268 32.9142 104.228C32.9142 97.1882 27.2137 91.4282 20.2465 91.4282Z" fill="#43484C"/>
<g id="Group 7">
<mask id="mask0_201_141" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="78" y="219" width="417" height="165">
<path id="Clip 6" fill-rule="evenodd" clip-rule="evenodd" d="M495 219V384H286.5H78V219H495Z" fill="white"/>
</mask>
<g mask="url(#mask0_201_141)">
<path id="Fill 5" fill-rule="evenodd" clip-rule="evenodd" d="M495 279.775C495 286.675 489.336 292.321 482.413 292.321H402.016C398.559 292.321 395.405 293.734 393.131 296.009C390.849 298.276 389.43 301.421 389.43 304.867C389.43 311.768 395.094 317.413 402.016 317.413H416.954C420.41 317.413 423.566 318.827 425.84 321.102C428.122 323.369 429.54 326.514 429.54 329.96C429.54 336.86 423.876 342.506 416.954 342.506H382.608C350.344 366.662 310.251 380.981 266.802 380.981C174.004 380.981 96.5026 315.699 78 228.695C101.747 225.073 126.937 222.162 153.235 220.038L155.215 221.836L157.079 219.736C188.671 217.286 221.833 215.981 256.036 215.981C328.703 215.981 396.705 221.869 454.847 232.107C452.02 244.302 448.026 256.045 442.991 267.228H482.413C485.879 267.228 489.017 268.642 491.3 270.917C493.582 273.184 495 276.328 495 279.775Z" fill="#34393D"/>
</g>
</g>
<path id="Fill 8" fill-rule="evenodd" clip-rule="evenodd" d="M459 206.12C458.285 216.665 456.771 226.975 454.525 237C396.235 226.404 328.06 220.31 255.208 220.31C220.919 220.31 187.673 221.66 156 224.196L177.965 198.615L212.134 221.028L258.388 208.19L337.959 220.31L372.503 208.19L381.252 220.31L402.126 204.736L421.632 222.707L452.396 198L459 206.12Z" fill="#55575E"/>
<path id="Fill 10" fill-rule="evenodd" clip-rule="evenodd" d="M344.905 216.502C353.565 203.915 358.526 188.987 358.397 173.012C358.043 128.578 318.46 92.8734 269.986 93.2602C221.512 93.6478 182.505 129.983 182.86 174.416C183.214 218.849 222.798 254.555 271.271 254.168C288.59 254.03 304.7 249.3 318.238 241.253L354.454 249.78L344.905 216.502Z" fill="#686B73"/>
<path id="Fill 12" fill-rule="evenodd" clip-rule="evenodd" d="M245.029 171.813C245.069 176.862 241.008 180.988 235.959 181.028C230.909 181.069 226.783 177.008 226.743 171.959C226.703 166.909 230.763 162.784 235.813 162.743C240.861 162.703 244.987 166.763 245.029 171.813Z" fill="#BEC0D6"/>
<path id="Fill 14" fill-rule="evenodd" clip-rule="evenodd" d="M277.942 171.812C277.982 176.862 273.922 180.988 268.872 181.028C263.822 181.068 259.696 177.008 259.657 171.959C259.616 166.909 263.677 162.783 268.726 162.743C273.775 162.703 277.901 166.763 277.942 171.812Z" fill="#BEC0D6"/>
<path id="Fill 16" fill-rule="evenodd" clip-rule="evenodd" d="M310.856 171.812C310.897 176.861 306.836 180.987 301.786 181.028C296.737 181.068 292.611 177.008 292.571 171.958C292.53 166.908 296.591 162.783 301.641 162.743C306.689 162.702 310.815 166.763 310.856 171.812Z" fill="#BEC0D6"/>
<path id="Fill 20" fill-rule="evenodd" clip-rule="evenodd" d="M173.714 235.887C173.714 241.693 175.571 247.099 178.763 251.649L175.376 263.789L188.564 260.581C193.523 263.465 199.402 265.143 205.714 265.143C223.388 265.143 237.714 252.044 237.714 235.887C237.714 219.728 223.388 206.629 205.714 206.629C188.041 206.629 173.714 219.728 173.714 235.887Z" fill="url(#paint0_linear_201_141)"/>
<g id="Frame 148">
<path id="Fill 26" fill-rule="evenodd" clip-rule="evenodd" d="M189 234.658C189 236.677 190.637 238.314 192.657 238.314C194.676 238.314 196.314 236.677 196.314 234.658C196.314 232.638 194.676 231 192.657 231C190.637 231 189 232.638 189 234.658Z" fill="#0D0D0D"/>
<path id="Fill 24" fill-rule="evenodd" clip-rule="evenodd" d="M202.314 234.658C202.314 236.677 203.951 238.314 205.971 238.314C207.99 238.314 209.628 236.677 209.628 234.658C209.628 232.638 207.99 231 205.971 231C203.951 231 202.314 232.638 202.314 234.658Z" fill="#0D0D0D"/>
<path id="Fill 22" fill-rule="evenodd" clip-rule="evenodd" d="M215.629 234.658C215.629 236.677 217.265 238.314 219.285 238.314C221.305 238.314 222.943 236.677 222.943 234.658C222.943 232.638 221.305 231 219.285 231C217.265 231 215.629 232.638 215.629 234.658Z" fill="#0D0D0D"/>
</g>
<path id="Fill 28" fill-rule="evenodd" clip-rule="evenodd" d="M153.6 223.557C127.481 225.685 102.461 228.601 78.8755 232.228C78.0671 228.408 77.3754 224.546 76.8003 220.65L117.229 190.171L153.6 223.557Z" fill="#55575E"/>
<path id="Fill 30" fill-rule="evenodd" clip-rule="evenodd" d="M336.457 286.171C336.457 289.706 304.529 292.571 265.143 292.571C225.757 292.571 193.829 289.706 193.829 286.171C193.829 282.638 225.757 279.771 265.143 279.771C304.529 279.771 336.457 282.638 336.457 286.171Z" fill="#686B73"/>
<path id="Fill 32" fill-rule="evenodd" clip-rule="evenodd" d="M453.486 345.599C446.446 345.599 440.686 339.839 440.686 332.799C440.686 325.76 446.446 320 453.486 320C460.526 320 466.286 325.76 466.286 332.799C466.286 339.839 460.526 345.599 453.486 345.599Z" fill="#34393D"/>
</g>
<defs>
<linearGradient id="paint0_linear_201_141" x1="205.714" y1="177.372" x2="147.434" y2="241.115" gradientUnits="userSpaceOnUse">
<stop stop-color="#9DC9FB"/>
<stop offset="1" stop-color="#9DC9FB"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

@@ -1,11 +1,33 @@
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M42.4834 68.6832C42.7774 69.425 42.8429 70.2378 42.6714 71.0172L40.5414 77.5971C40.4728 77.9308 40.4905 78.2765 40.5929 78.6014C40.6954 78.9263 40.8791 79.2197 41.1267 79.4536C41.3743 79.6876 41.6776 79.8545 42.0078 79.9384C42.3379 80.0223 42.6841 80.0205 43.0134 79.9331L49.8393 77.9371C50.5747 77.7912 51.3364 77.855 52.0373 78.1211C56.308 80.1155 61.146 80.5375 65.6975 79.3125C70.249 78.0876 74.2217 75.2944 76.9145 71.4259C79.6073 67.5573 80.8473 62.862 80.4157 58.1683C79.984 53.4747 77.9085 49.0843 74.5553 45.7717C71.202 42.4592 66.7866 40.4375 62.088 40.0632C57.3894 39.6889 52.7096 40.9861 48.8742 43.726C45.0388 46.4659 42.2944 50.4724 41.1252 55.0385C39.9559 59.6047 40.4369 64.4371 42.4834 68.6832Z" stroke="#B9BBC6" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M53.8503 25.6371C58.2429 24.787 62.7578 24.787 67.1503 25.6371" stroke="#E4E4E9" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M67.1503 94.3621C62.7578 95.2121 58.2429 95.2121 53.8503 94.3621" stroke="#E4E4E9" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M80.1311 31.02C83.8462 33.5373 87.0426 36.7456 89.5461 40.47" stroke="#E4E4E9" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M26.1371 66.6503C25.287 62.2578 25.287 57.7429 26.1371 53.3503" stroke="#E4E4E9" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M89.4798 79.635C86.9625 83.3502 83.7542 86.5465 80.0298 89.05" stroke="#E4E4E9" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M94.8621 53.3503C95.7121 57.7429 95.7121 62.2578 94.8621 66.6503" stroke="#E4E4E9" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M31.5249 40.3696C34.0422 36.6544 37.2504 33.4581 40.9749 30.9546" stroke="#E4E4E9" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M40.0717 91.9083L29.9007 94.8833C29.3241 95.0365 28.718 95.0397 28.1398 94.8927C27.5616 94.7457 27.0306 94.4534 26.5972 94.0434C26.1638 93.6335 25.8423 93.1196 25.6634 92.5505C25.4844 91.9814 25.4539 91.376 25.5747 90.7918L28.9522 80.3618" stroke="#E4E4E9" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<svg width="495" height="387" viewBox="0 0 495 387" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="No Chat History">
<path id="Fill 1" fill-rule="evenodd" clip-rule="evenodd" d="M267.927 1.5C222.792 1.5 181.307 17.1457 148.551 43.3222H95.611C88.7329 43.3222 83.1054 48.9682 83.1054 55.8688C83.1054 62.7695 88.7329 68.4155 95.611 68.4155H122.554H130.449C137.327 68.4155 142.955 74.0615 142.955 80.9621C142.955 84.4083 141.546 87.5533 139.278 89.8201C137.019 92.0952 133.884 93.5088 130.449 93.5088H113.605H104.304H58.9279C52.0499 93.5088 46.4224 99.1548 46.4224 106.055C46.4224 112.956 52.0499 118.602 58.9279 118.602H91.4124C81.6064 141.723 76.1748 167.164 76.1748 193.882C76.1748 203.576 76.8918 213.103 78.2674 222.413C78.8426 226.303 79.5346 230.159 80.3433 233.973C103.937 230.351 128.965 227.44 155.093 225.316L157.061 227.114L158.912 225.014C190.301 222.564 223.249 221.259 257.23 221.259C329.429 221.259 396.993 227.147 454.76 237.385C456.986 227.699 458.487 217.737 459.196 207.549C459.521 203.033 459.679 198.474 459.679 193.882C459.679 87.6286 373.832 1.5 267.927 1.5Z" fill="#F1F2F7"/>
<path id="Fill 3" fill-rule="evenodd" clip-rule="evenodd" d="M20.954 92.9282H13.3753C6.40802 92.9282 0.70752 98.6882 0.70752 105.728C0.70752 112.768 6.40802 118.528 13.3753 118.528H20.954C27.9213 118.528 33.6218 112.768 33.6218 105.728C33.6218 98.6882 27.9213 92.9282 20.954 92.9282Z" fill="#F1F2F7"/>
<g id="Group 7">
<mask id="mask0_156_62" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="80" y="221" width="415" height="165">
<path id="Clip 6" fill-rule="evenodd" clip-rule="evenodd" d="M494.293 221.536V385.5H287.188H80.083V221.536H494.293Z" fill="white"/>
</mask>
<g mask="url(#mask0_156_62)">
<path id="Fill 5" fill-rule="evenodd" clip-rule="evenodd" d="M494.293 284.929C494.293 291.786 488.667 297.396 481.79 297.396H401.932C398.498 297.396 395.364 298.801 393.106 301.062C390.838 303.314 389.429 306.439 389.429 309.864C389.429 316.721 395.055 322.331 401.932 322.331H416.769C420.202 322.331 423.337 323.736 425.596 325.997C427.863 328.249 429.271 331.374 429.271 334.799C429.271 341.656 423.645 347.266 416.769 347.266H382.653C350.604 371.271 310.78 385.5 267.622 385.5C175.444 385.5 98.4618 320.627 80.083 234.169C103.671 230.57 128.693 227.678 154.815 225.567L156.781 227.354L158.632 225.268C190.013 222.832 222.954 221.536 256.928 221.536C329.108 221.536 396.656 227.387 454.409 237.561C451.601 249.679 447.633 261.349 442.632 272.461H481.79C485.233 272.461 488.35 273.866 490.617 276.127C492.884 278.379 494.293 281.504 494.293 284.929Z" fill="#E8EBF2"/>
</g>
</g>
<path id="Fill 8" fill-rule="evenodd" clip-rule="evenodd" d="M457.85 206.981C457.142 217.363 455.643 227.515 453.42 237.386C395.73 226.953 328.256 220.953 256.153 220.953C222.216 220.953 189.312 222.282 157.964 224.779L179.704 199.591L213.521 221.659L259.3 209.019L338.053 220.953L372.242 209.019L380.901 220.953L401.56 205.618L420.866 223.313L451.314 198.986L457.85 206.981Z" fill="#E0E2EE"/>
<path id="Fill 10" fill-rule="evenodd" clip-rule="evenodd" d="M345.614 218.002C354.273 205.415 359.234 190.487 359.106 174.512C358.752 130.078 319.168 94.3734 270.694 94.7602C222.22 95.1478 183.213 131.483 183.568 175.916C183.923 220.349 223.506 256.055 271.979 255.668C289.298 255.53 305.408 250.8 318.946 242.753L355.162 251.28L345.614 218.002Z" fill="#D8DBEA"/>
<path id="Fill 12" fill-rule="evenodd" clip-rule="evenodd" d="M245.736 173.313C245.776 178.362 241.716 182.488 236.667 182.528C231.616 182.569 227.49 178.508 227.451 173.459C227.411 168.409 231.471 164.284 236.52 164.243C241.569 164.203 245.695 168.263 245.736 173.313Z" fill="#BEC0D6"/>
<path id="Fill 14" fill-rule="evenodd" clip-rule="evenodd" d="M278.65 173.312C278.69 178.362 274.629 182.488 269.58 182.528C264.53 182.568 260.404 178.508 260.365 173.459C260.323 168.409 264.384 164.283 269.434 164.243C274.483 164.203 278.609 168.263 278.65 173.312Z" fill="#BEC0D6"/>
<path id="Fill 16" fill-rule="evenodd" clip-rule="evenodd" d="M311.565 173.312C311.605 178.361 307.544 182.487 302.495 182.528C297.445 182.568 293.319 178.508 293.28 173.458C293.239 168.408 297.299 164.283 302.349 164.243C307.398 164.202 311.524 168.263 311.565 173.312Z" fill="#BEC0D6"/>
<path id="Fill 18" fill-rule="evenodd" clip-rule="evenodd" d="M157.965 224.585L156.192 226.414L154.308 224.848C155.521 224.753 156.744 224.666 157.965 224.585Z" fill="#F1F2F7"/>
<path id="Fill 20" fill-rule="evenodd" clip-rule="evenodd" d="M174.422 237.387C174.422 243.193 176.279 248.599 179.47 253.149L176.083 265.289L189.272 262.081C194.23 264.965 200.11 266.643 206.422 266.643C224.095 266.643 238.422 253.544 238.422 237.387C238.422 221.228 224.095 208.129 206.422 208.129C188.748 208.129 174.422 221.228 174.422 237.387Z" fill="url(#paint0_linear_156_62)"/>
<path id="Fill 22" fill-rule="evenodd" clip-rule="evenodd" d="M214.651 237.386C214.651 239.405 216.287 241.043 218.308 241.043C220.327 241.043 221.965 239.405 221.965 237.386C221.965 235.366 220.327 233.728 218.308 233.728C216.287 233.728 214.651 235.366 214.651 237.386Z" fill="white"/>
<path id="Fill 24" fill-rule="evenodd" clip-rule="evenodd" d="M203.679 237.386C203.679 239.405 205.316 241.043 207.336 241.043C209.355 241.043 210.993 239.405 210.993 237.386C210.993 235.366 209.355 233.728 207.336 233.728C205.316 233.728 203.679 235.366 203.679 237.386Z" fill="white"/>
<path id="Fill 26" fill-rule="evenodd" clip-rule="evenodd" d="M190.879 237.386C190.879 239.405 192.516 241.043 194.537 241.043C196.556 241.043 198.194 239.405 198.194 237.386C198.194 235.366 196.556 233.728 194.537 233.728C192.516 233.728 190.879 235.366 190.879 237.386Z" fill="white"/>
<path id="Fill 28" fill-rule="evenodd" clip-rule="evenodd" d="M154.308 225.057C128.188 227.185 103.169 230.101 79.583 233.728C78.7746 229.908 78.0829 226.046 77.5078 222.15L117.936 191.671L154.308 225.057Z" fill="#E0E2EE"/>
<path id="Fill 30" fill-rule="evenodd" clip-rule="evenodd" d="M337.164 287.671C337.164 291.206 305.235 294.071 265.85 294.071C226.464 294.071 194.536 291.206 194.536 287.671C194.536 284.138 226.464 281.271 265.85 281.271C305.235 281.271 337.164 284.138 337.164 287.671Z" fill="#D8DBEA"/>
<path id="Fill 32" fill-rule="evenodd" clip-rule="evenodd" d="M454.193 347.099C447.153 347.099 441.393 341.339 441.393 334.299C441.393 327.26 447.153 321.5 454.193 321.5C461.233 321.5 466.993 327.26 466.993 334.299C466.993 341.339 461.233 347.099 454.193 347.099Z" fill="#E1E3EF"/>
</g>
<defs>
<linearGradient id="paint0_linear_156_62" x1="206.422" y1="178.872" x2="148.142" y2="242.615" gradientUnits="userSpaceOnUse">
<stop stop-color="#9DC9FB"/>
<stop offset="1" stop-color="#9DC9FB"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

@@ -46,14 +46,14 @@ body {
}
.custom-dashed-border {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect x='1' y='1' width='calc(100%25 - 2px)' height='calc(100%25 - 2px)' fill='none' rx='12' ry='12' stroke='%23E2E3E7' stroke-width='1.5' stroke-dasharray='6 4' stroke-linecap='butt'/%3E%3C/svg%3E");
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='none' rx='16' ry='16' stroke='%23E2E3E7' stroke-width='2' stroke-dasharray='6, 8' stroke-dashoffset='0' stroke-linecap='round'/%3E%3C/svg%3E");
background-position: center;
background-repeat: no-repeat;
background-size: 100% 100%;
}
.dark .custom-dashed-border {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect x='1' y='1' width='calc(100%25 - 2px)' height='calc(100%25 - 2px)' fill='none' rx='12' ry='12' stroke='%23343434' stroke-width='1.5' stroke-dasharray='6 4' stroke-linecap='butt'/%3E%3C/svg%3E");
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='none' rx='16' ry='16' stroke='%23343434' stroke-width='2' stroke-dasharray='6, 8' stroke-dashoffset='0' stroke-linecap='round'/%3E%3C/svg%3E");
}
@layer utilities {
@@ -66,84 +66,4 @@ body {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
/**
* ============================================================================
* TYPOGRAPHY UTILITIES
* ============================================================================
*
* | Class | Use Case |
* |--------------------|----------------------------------------------------|
* | .text-body-main | <p>, <span>, general body text |
* | .text-body-para | <p> for paragraphs, larger text blocks |
* | .text-heading-1 | <h1>, page titles, panel headers |
* | .text-heading-2 | <h2>, section headings, card titles |
* | .text-heading-3 | <h3>, card headings, breadcrumbs, subsections |
* | .text-label | <label>, form labels, field names |
* | .text-label-small | <small>, footnotes, tags, badges, captions |
* | .text-button | <button>, standard button text |
* | .text-button-small | <button>, small/compact button text |
*/
/* body-text-main: Main text style for general body text */
.text-body-main {
@apply font-inter text-sm font-420;
line-height: 21px; /* 150% */
letter-spacing: -0.28px;
}
/* body-text-paragraph: For paragraphs or larger blocks of text */
.text-body-para {
@apply font-inter text-sm font-420;
line-height: 21px; /* 150% */
letter-spacing: -0.21px;
}
/* heading-1: Large heading for pages and panels */
.text-heading-1 {
@apply font-inter text-lg font-520;
line-height: 24px; /* 133.333% */
letter-spacing: -0.27px;
}
/* heading-2: Secondary heading for sections */
.text-heading-2 {
@apply font-inter text-base font-medium;
line-height: 24px; /* 133.333% */
letter-spacing: -0.27px;
}
/* heading-3: For card headings, breadcrumbs, subsections */
.text-heading-3 {
@apply font-inter text-sm font-medium;
line-height: 21px; /* 150% */
letter-spacing: -0.27px;
}
/* label: Standard label text for form fields */
.text-label {
@apply font-inter text-sm font-medium;
line-height: 21px; /* 150% */
}
/* label-small: Smallest font for labels, footnotes, tags */
.text-label-small {
@apply font-inter text-xs font-440;
line-height: 16px; /* 133.333% */
letter-spacing: -0.24px;
}
/* button-text: Text for standard size buttons */
.text-button {
@apply font-inter text-sm font-460;
line-height: 21px; /* 150% */
letter-spacing: -0.28px;
}
/* button-text-small: Text for smaller buttons */
.text-button-small {
@apply font-inter text-xs font-440;
line-height: 18px; /* 150% */
letter-spacing: -0.24px;
}
}
@@ -7,7 +7,7 @@
@apply bg-n-slate-3;
&::after {
@apply text-n-blue-text;
@apply text-n-blue-11;
}
}
}
@@ -2,7 +2,7 @@
import { computed, ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
import LabelItem from 'dashboard/components-next/label/LabelItem.vue';
import LabelItem from 'dashboard/components-next/Label/LabelItem.vue';
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
@@ -103,7 +103,7 @@ onMounted(() => {
:key="tag.id"
:label="tag"
:is-hovered="hoveredLabel === tag.id"
class="h-8 ltr:!pr-1 rtl:!pl-1"
class="h-8"
@remove="onClickRemoveTag"
@hover="hoveredLabel = tag.id"
/>
@@ -119,7 +119,7 @@ onMounted(() => {
)
"
:items="filteredTags"
class="[&>button]:!text-n-blue-text [&>div]:min-w-64"
class="[&>button]:!text-n-blue-11 [&>div]:min-w-64"
@add="onClickAddTag"
/>
</div>
@@ -77,7 +77,7 @@ const inboxName = computed(() => props.inbox?.name || '');
const inboxIcon = computed(() => {
const { medium, channel_type: type } = props.inbox;
return getInboxIconByType(type, medium, 'line');
return getInboxIconByType(type, medium);
});
</script>
@@ -47,8 +47,8 @@ const senderThumbnailSrc = computed(() => props.sender?.thumbnail);
<span class="flex-shrink-0 text-sm text-n-slate-11 whitespace-nowrap">
{{ t('CAMPAIGN.LIVE_CHAT.CARD.CAMPAIGN_DETAILS.FROM') }}
</span>
<div class="flex items-center gap-1 flex-shrink-0">
<Icon :icon="inboxIcon" class="flex-shrink-0 text-n-slate-12 size-4" />
<div class="flex items-center gap-1.5 flex-shrink-0">
<Icon :icon="inboxIcon" class="flex-shrink-0 text-n-slate-12 size-3" />
<span class="text-sm font-medium text-n-slate-12">
{{ inboxName }}
</span>
@@ -21,12 +21,10 @@ const handleButtonClick = () => {
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
<header
class="sticky top-0 z-10 px-6 after:absolute after:inset-x-0 after:-bottom-4 after:bg-gradient-to-b after:from-n-surface-1 after:from-10% after:dark:from-0% after:to-transparent after:h-4 after:pointer-events-none"
>
<div class="w-full max-w-5xl mx-auto">
<header class="sticky top-0 z-10 px-6 lg:px-0">
<div class="w-full max-w-[60rem] mx-auto">
<div class="flex items-center justify-between w-full h-20 gap-2">
<span class="text-heading-1 text-n-slate-12">
<span class="text-xl font-medium text-n-slate-12">
{{ headerTitle }}
</span>
<div
@@ -45,8 +43,8 @@ const handleButtonClick = () => {
</div>
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto">
<div class="w-full mx-auto max-w-5xl py-4">
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[60rem] mx-auto py-4">
<slot name="default" />
</div>
</main>
@@ -306,7 +306,7 @@ defineExpose({ prepareCampaignDetails, isSubmitDisabled });
variant="faded"
color="slate"
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -174,7 +174,7 @@ const handleSubmit = async () => {
color="slate"
type="button"
:label="t('CAMPAIGN.SMS.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -251,7 +251,7 @@ watch(
color="slate"
type="button"
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -22,10 +22,10 @@ const handleClick = () => {
class="flex flex-col w-full outline-1 outline outline-n-container group/cardLayout rounded-xl bg-n-solid-2"
>
<div
class="flex w-full gap-2 py-4"
class="flex w-full gap-3 py-5"
:class="[
layout === 'col' ? 'flex-col' : 'flex-row justify-between items-center',
selectable ? 'px-10 py-6' : 'px-4',
selectable ? 'px-10 py-6' : 'px-6',
]"
@click="handleClick"
>
@@ -1,170 +0,0 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import { debounce } from '@chatwoot/utils';
import { useCompaniesStore } from 'dashboard/stores/companies';
import { useToggle } from '@vueuse/core';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
placeholder: {
type: String,
default: '',
},
selectedCompanyName: {
type: String,
default: '',
},
});
const emit = defineEmits(['change']);
const companyId = defineModel({
type: [Number, String],
default: null,
});
const { t } = useI18n();
const companiesStore = useCompaniesStore();
const [showCompanyDropdown, toggleCompanyDropdown] = useToggle();
const searchQuery = ref('');
const isSearching = ref(false);
const companies = computed(() => companiesStore.getCompaniesList || []);
const isLoading = computed(() => companiesStore.uiFlags.fetchingList);
const clearOption = computed(() => ({
label: t('DROPDOWN_MENU.CLEAR_SELECTION'),
value: 'clear',
action: 'clear',
}));
const companyOptions = computed(() =>
companies.value.map(company => ({
label: company.name,
value: company.id,
action: 'select',
isSelected: companyId.value === company.id,
}))
);
const menuItems = computed(() => {
const items = [];
// Show clear option if a company is selected (either by ID or by name prop)
if (companyId.value || props.selectedCompanyName) {
items.push(clearOption.value);
}
// Add company options after clear option
items.push(...companyOptions.value);
return items;
});
const selectedCompany = computed(() => {
return companies.value.find(company => company.id === companyId.value);
});
const buttonLabel = computed(() => {
if (selectedCompany.value) {
return selectedCompany.value.name;
}
if (props.selectedCompanyName) {
return props.selectedCompanyName;
}
return props.placeholder;
});
const fetchCompanies = async (search = '') => {
if (!search) return;
isSearching.value = true;
try {
await companiesStore.search({
search,
page: 1,
sort: 'name',
});
} finally {
isSearching.value = false;
}
};
const debouncedSearch = debounce(query => {
searchQuery.value = query;
fetchCompanies(query);
}, 300);
const handleAction = ({ value, action }) => {
if (action === 'clear') {
companyId.value = null;
emit('change', null);
toggleCompanyDropdown(false);
return;
}
if (companyId.value === value) {
companyId.value = null;
emit('change', null);
} else {
companyId.value = value;
const company = companies.value.find(c => c.id === value);
emit('change', company);
}
toggleCompanyDropdown(false);
};
const handleSearch = query => {
debouncedSearch(query);
};
const handleClickOutside = () => {
toggleCompanyDropdown(false);
};
watch(showCompanyDropdown, isOpen => {
if (!isOpen) {
searchQuery.value = '';
companiesStore.clearRecords();
}
});
</script>
<template>
<div v-on-click-outside="handleClickOutside" class="relative w-full min-w-0">
<Button
slate
sm
:label="buttonLabel"
no-animation
type="button"
:variant="showCompanyDropdown ? 'faded' : 'solid'"
icon="i-lucide-briefcase-business"
justify="start"
class="w-full -outline-offset-1"
@click="toggleCompanyDropdown()"
/>
<Transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="transform scale-95 opacity-0"
enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-95 opacity-0"
>
<DropdownMenu
v-if="showCompanyDropdown"
:menu-items="menuItems"
show-search
disable-local-filtering
:search-placeholder="t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')"
:empty-state-message="t('COMPANIES.DROPDOWN_MENU.EMPTY_STATE')"
:is-loading="isLoading"
:is-searching="isSearching"
class="max-h-60 overflow-y-auto absolute z-50 w-full mt-1 top-full"
@action="handleAction"
@search="handleSearch"
/>
</Transition>
</div>
</template>
@@ -15,13 +15,11 @@ const emit = defineEmits(['search', 'update:sort']);
</script>
<template>
<header
class="sticky top-0 z-10 after:absolute after:inset-x-0 after:-bottom-4 after:bg-gradient-to-b after:from-n-surface-1 after:from-10% after:dark:from-0% after:to-transparent after:h-4 after:pointer-events-none px-6"
>
<header class="sticky top-0 z-10">
<div
class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl"
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]"
>
<span class="text-heading-1 truncate text-n-slate-12">
<span class="text-xl font-medium truncate text-n-slate-12">
{{ headerTitle }}
</span>
<div class="flex items-center flex-row flex-shrink-0 gap-2">
@@ -91,7 +91,7 @@ const handleOrderChange = value => {
<div
v-if="isMenuOpen"
v-on-clickaway="() => (isMenuOpen = false)"
class="absolute z-20 top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-n-slate-12">
@@ -32,18 +32,17 @@ const updateCurrentPage = page => {
@search="emit('search', $event)"
@update:sort="emit('update:sort', $event)"
/>
<main class="flex-1 px-6 overflow-y-auto">
<div class="w-full mx-auto max-w-5xl py-4">
<main class="flex-1 overflow-y-auto">
<div class="w-full mx-auto max-w-[60rem]">
<slot name="default" />
</div>
</main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0">
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
<PaginationFooter
current-page-info="COMPANIES_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage"
:total-items="totalItems"
:items-per-page="25"
class="max-w-[67rem]"
@update:current-page="updateCurrentPage"
/>
</footer>
@@ -1,15 +1,12 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { computed, watch, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import LabelItem from 'dashboard/components-next/label/LabelItem.vue';
import AddLabel from 'dashboard/components-next/label/AddLabel.vue';
import LabelItem from 'dashboard/components-next/Label/LabelItem.vue';
import AddLabel from 'dashboard/components-next/Label/AddLabel.vue';
const props = defineProps({
wrapperRef: {
type: Object,
default: null,
},
contactId: {
type: [String, Number],
default: null,
@@ -17,6 +14,7 @@ const props = defineProps({
});
const store = useStore();
const route = useRoute();
const showDropdown = ref(false);
@@ -26,14 +24,12 @@ const showDropdown = ref(false);
const hoveredLabel = ref(null);
const allLabels = useMapGetter('labels/getLabels');
const getContactById = useMapGetter('contacts/getContactById');
const getContactLabels = useMapGetter('contactLabels/getContactLabels');
const contactLabels = useMapGetter('contactLabels/getContactLabels');
const savedLabels = computed(() => {
const contactLabelsList = getContactLabels.value(props.contactId) || [];
const availableContactLabels = contactLabels.value(props.contactId);
return allLabels.value.filter(({ title }) =>
contactLabelsList.includes(title)
availableContactLabels.includes(title)
);
});
@@ -51,6 +47,13 @@ const labelMenuItems = computed(() => {
.toSorted((a, b) => Number(a.isSelected) - Number(b.isSelected));
});
const fetchLabels = async contactId => {
if (!contactId) {
return;
}
store.dispatch('contactLabels/get', contactId);
};
const handleLabelAction = async ({ value }) => {
try {
// Get current label titles
@@ -77,11 +80,6 @@ const handleLabelAction = async ({ value }) => {
labels: updatedLabels,
});
store.dispatch('contacts/updateContactLabels', {
contactId: props.contactId,
labels: updatedLabels,
});
showDropdown.value = false;
} catch (error) {
// error
@@ -92,19 +90,19 @@ const handleRemoveLabel = label => {
return handleLabelAction({ value: label.id });
};
// Sync contact labels from contact object to contactLabels store when contact loads
watch(
() => getContactById.value(props.contactId),
contact => {
if (contact?.labels) {
store.dispatch('contactLabels/setContactLabel', {
id: props.contactId,
data: contact.labels,
});
() => props.contactId,
(newVal, oldVal) => {
if (newVal !== oldVal) {
fetchLabels(newVal);
}
},
{ immediate: true }
}
);
onMounted(() => {
if (route.params.contactId) {
fetchLabels(route.params.contactId);
}
});
const handleMouseLeave = () => {
// Reset hover state when mouse leaves the container
@@ -127,13 +125,11 @@ const handleLabelHover = labelId => {
:key="label.id"
:label="label"
:is-hovered="hoveredLabel === label.id"
class="ltr:!pr-1 rtl:!pl-1"
@remove="handleRemoveLabel"
@hover="handleLabelHover(label.id)"
/>
<AddLabel
:label-menu-items="labelMenuItems"
:wrapper-ref="wrapperRef"
@update-label="handleLabelAction"
/>
</div>
@@ -1,67 +0,0 @@
<script setup>
import { reactive } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
contactId: {
type: Number,
default: null,
},
});
const { t } = useI18n();
const store = useStore();
const state = reactive({
message: '',
});
const onAdd = async content => {
if (!content) return;
try {
await store.dispatch('contactNotes/create', {
content,
contactId: props.contactId,
});
state.message = '';
useAlert(t('CONTACTS_LAYOUT.CARD.ADD_NOTE.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(t('CONTACTS_LAYOUT.CARD.ADD_NOTE.API.ERROR_MESSAGE'));
}
};
const keyboardEvents = {
'$mod+Enter': {
action: () => onAdd(state.message),
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
</script>
<template>
<div class="flex flex-col items-start gap-4 py-3">
<span
class="py-1 text-heading-3 text-n-slate-12 z-10 h-6 bg-n-surface-1 inline-flex items-center gap-2"
>
<Icon
icon="i-lucide-file-plus-corner"
class="size-4 text-n-slate-11 hidden lg:block"
/>
{{ t('CONTACTS_LAYOUT.CARD.ADD_NOTE.TITLE') }}
</span>
<div class="flex flex-col gap-6 lg:px-6 max-w-lg w-full">
<Editor
v-model="state.message"
:placeholder="t('CONTACTS_LAYOUT.CARD.ADD_NOTE.PLACEHOLDER')"
class="[&>div]:!border-transparent [&>div]:!px-3 [&>div]:!pb-4 [&_.ProseMirror-woot-style]:min-h-6 [&_.ProseMirror-menubar]:!relative ltr:[&_.ProseMirror-menubar]:!-left-[3px] ltr:[&_.ProseMirror-menubar]:!right-[unset] rtl:[&_.ProseMirror-menubar]:!-right-[3px] rtl:[&_.ProseMirror-menubar]:!left-[unset] [&_.ProseMirror-menubar]:!w-[unset] [&_.ProseMirror-menubar]:!top-[unset] [&_.ProseMirror-menubar-spacer]:!hidden"
/>
</div>
</div>
</template>
@@ -1,78 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useUISettings } from 'dashboard/composables/useUISettings';
const props = defineProps({
customAttributes: { type: Object, required: true },
});
const { t } = useI18n();
const { uiSettings } = useUISettings();
const contactAttributes = useMapGetter('attributes/getContactAttributes');
const displayAttributes = computed(() => {
const selectedKeys = uiSettings.value.contact_list_display_properties || [];
if (!selectedKeys.length) return [];
return contactAttributes.value
.filter(
attr =>
selectedKeys.includes(attr.attributeKey) &&
attr.attributeKey in props.customAttributes
)
.map(attribute => ({
attribute,
value: props.customAttributes[attribute.attributeKey],
}));
});
const formatValue = (value, displayType) => {
if (value === null || value === undefined || value === '') return null;
switch (displayType) {
case 'checkbox':
return value
? t('CONTACTS_LAYOUT.DISPLAY_PROPERTIES.YES')
: t('CONTACTS_LAYOUT.DISPLAY_PROPERTIES.NO');
case 'list':
return Array.isArray(value) ? value.join(', ') : value;
case 'date':
return new Date(value).toLocaleDateString();
default:
return String(value);
}
};
</script>
<template>
<template
v-for="(item, index) in displayAttributes"
:key="item.attribute.attributeKey"
>
<div
v-if="index === 0"
class="w-px h-3 bg-n-strong rounded-lg flex-shrink-0"
/>
<div
v-tooltip.top="{
content: `${item.attribute.attributeDisplayName}: ${formatValue(item.value, item.attribute.attributeDisplayType)}`,
delay: { show: 500, hide: 0 },
}"
class="flex items-center gap-1 truncate"
>
<span class="text-body-main text-n-slate-12 truncate">
{{ item.attribute.attributeDisplayName }}{{ ':' }}
</span>
<span class="text-body-main text-n-slate-11 truncate">
{{ formatValue(item.value, item.attribute.attributeDisplayType) }}
</span>
</div>
<div
v-if="index < displayAttributes.length - 1"
class="w-px h-3 bg-n-strong rounded-lg flex-shrink-0"
/>
</template>
</template>
@@ -1,370 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Flag from 'dashboard/components-next/flag/Flag.vue';
import ContactLabels from 'dashboard/components-next/Conversation/ConversationCard/CardLabels.vue';
import countries from 'shared/constants/countries';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import CardContent from 'dashboard/components-next/Conversation/ConversationCard/CardContent.vue';
import InboxName from 'dashboard/components-next/Conversation/InboxName.vue';
import CardPriorityIcon from 'dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue';
import CardStatusIcon from 'dashboard/components-next/Conversation/ConversationCard/CardStatusIcon.vue';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
const props = defineProps({
contactData: {
type: Object,
default: null,
},
});
const { t } = useI18n();
const router = useRouter();
const contactNotes = useMapGetter('contactNotes/getAllNotesByContactId');
const contactConversations = useMapGetter(
'contactConversations/getAllConversationsByContactId'
);
const currentAccountId = useMapGetter('getCurrentAccountId');
const currentUser = useMapGetter('getCurrentUser');
const agentsList = useMapGetter('agents/getAgents');
const inboxGetter = useMapGetter('inboxes/getInbox');
const countriesMap = computed(() => {
return countries.reduce((acc, country) => {
acc[country.code] = country;
acc[country.id] = country;
return acc;
}, {});
});
const countryDetails = computed(() => {
const attributes = props.contactData?.additionalAttributes || {};
const { country, countryCode, city } = attributes;
if (!country && !countryCode) return null;
const activeCountry =
countriesMap.value[country] || countriesMap.value[countryCode];
if (!activeCountry) return null;
return {
countryCode: activeCountry.id,
city: city ? `${city},` : null,
name: activeCountry.name,
};
});
const formattedLocation = computed(() => {
if (!countryDetails.value) return '';
return [countryDetails.value.city, countryDetails.value.name]
.filter(Boolean)
.join(' ');
});
const companyName = computed(() => {
const attributes = props.contactData?.additionalAttributes || {};
return attributes.companyName || '';
});
const contactLabels = computed(() => {
return props.contactData?.labels || [];
});
const latestNote = computed(() => {
if (!props.contactData?.id) return null;
const notes = contactNotes.value(props.contactData.id);
return notes && notes.length > 0 ? notes[0] : null;
});
const latestConversation = computed(() => {
if (!props.contactData?.id) return null;
const conversations = contactConversations.value(props.contactData.id);
return conversations && conversations.length > 0 ? conversations[0] : null;
});
const lastMessageInConversation = computed(() => {
if (!latestConversation.value) return null;
return getLastMessage(latestConversation.value);
});
const inbox = computed(() => {
const inboxId = latestConversation.value?.inboxId;
return inboxId ? inboxGetter.value(inboxId) : {};
});
const assignee = computed(() => {
if (!latestConversation.value?.meta?.assignee?.id) return null;
return agentsList.value?.find(
agent => agent.id === latestConversation.value.meta.assignee.id
);
});
const getWrittenBy = ({ user } = {}) => {
const currentUserId = currentUser.value?.id;
return user?.id === currentUserId
? t('CONTACTS_LAYOUT.SIDEBAR.NOTES.YOU')
: user?.name || t('CONVERSATION.BOT');
};
const formatTimestamp = timestamp => {
if (!timestamp) return '';
return dynamicTime(timestamp);
};
const getConversationUrl = conversationId => {
if (!conversationId || !currentAccountId.value) return '#';
const url = `/app/accounts/${currentAccountId.value}/conversations/${conversationId}`;
return router.push(url);
};
</script>
<template>
<div
class="flex flex-col gap-2 ltr:pl-2 rtl:pr-2 lg:ltr:pl-9 lg:rtl:pr-9 lg:before:content-[''] lg:before:absolute lg:before:ltr:left-[2.688rem] lg:before:rtl:right-[2.688rem] lg:before:top-0 lg:before:w-px lg:before:h-4 lg:before:bg-n-weak"
>
<div class="flex items-center gap-2 pb-2">
<Icon icon="i-woot-overview" class="size-4 text-n-slate-11" />
<h3 class="text-n-slate-11 text-body-main">
{{ t('CONTACTS_LAYOUT.CARD.QUICK_OVERVIEW.TITLE') }}
</h3>
</div>
<!-- Section 1: Basic Information -->
<div
class="hidden lg:flex rounded-xl bg-n-card w-auto outline outline-1 outline-n-container -outline-offset-1 p-3"
>
<div class="flex flex-wrap items-center gap-2 w-full">
<div class="flex items-center gap-1">
<Icon icon="i-lucide-circle-user" class="size-4 text-n-slate-11" />
<span class="text-label-small text-n-slate-11 m-0">
{{ t('CONTACTS_LAYOUT.CARD.QUICK_OVERVIEW.BASIC_INFO') }}
</span>
</div>
<div
v-if="contactData?.email"
class="h-3 w-px bg-n-strong rounded-lg"
/>
<!-- Email -->
<div v-if="contactData?.email" class="flex items-center gap-1">
<Icon
icon="i-woot-mail"
class="size-4 text-n-slate-11 flex-shrink-0"
/>
<span class="text-body-main text-n-slate-12 truncate">
{{ contactData.email }}
</span>
</div>
<div
v-if="contactData?.phoneNumber"
class="h-3 w-px bg-n-strong rounded-lg"
/>
<!-- Phone Number -->
<div v-if="contactData?.phoneNumber" class="flex items-center gap-1">
<Icon
icon="i-lucide-phone"
class="size-3.5 text-n-slate-11 flex-shrink-0"
/>
<span class="text-body-main text-n-slate-12 truncate">
{{ contactData.phoneNumber }}
</span>
</div>
<div v-if="companyName" class="h-3 w-px bg-n-strong rounded-lg" />
<!-- Company Name -->
<div v-if="companyName" class="flex items-center gap-1">
<Icon
icon="i-lucide-briefcase-business"
class="size-4 text-n-slate-11 flex-shrink-0"
/>
<span class="text-body-main text-n-slate-12 truncate">
{{ companyName }}
</span>
</div>
<div v-if="countryDetails" class="h-3 w-px bg-n-strong rounded-lg" />
<!-- Location (Country & City) -->
<div v-if="countryDetails" class="flex items-center gap-1">
<Flag
:country="countryDetails.countryCode"
class="size-4 flex-shrink-0"
/>
<span class="text-body-main text-n-slate-12 truncate">
{{ formattedLocation }}
</span>
</div>
<div
v-if="contactLabels.length > 0"
class="h-3 w-px bg-n-strong rounded-lg"
/>
<!-- Labels -->
<ContactLabels
v-if="contactLabels.length > 0"
:labels="contactLabels"
disable-toggle
class="my-0 flex-1"
/>
</div>
</div>
<!-- Section 2: Latest Contact Note -->
<div
class="flex flex-col rounded-xl bg-n-card outline outline-1 outline-n-container -outline-offset-1 p-3 gap-1.6 w-full gap-1.5"
>
<div class="flex items-center gap-2 py-1">
<div class="flex items-center gap-1.5">
<Icon icon="i-lucide-notebook-pen" class="size-3.5 text-n-slate-11" />
<span class="text-label-small text-n-slate-11 m-0">
{{ t('CONTACTS_LAYOUT.CARD.QUICK_OVERVIEW.LATEST_NOTE') }}
</span>
</div>
<template v-if="latestNote">
<div class="h-3 w-px bg-n-strong rounded-lg" />
<span class="text-label-small text-n-slate-11">
{{ formatTimestamp(latestNote.createdAt) }}
</span>
</template>
</div>
<div v-if="latestNote" class="flex items-center gap-1.5">
<Avatar
v-tooltip.left="{
content: getWrittenBy(latestNote),
delay: { show: 500, hide: 0 },
}"
:name="latestNote?.user?.name || 'Bot'"
:src="
latestNote?.user?.name
? latestNote?.user?.thumbnail
: '/assets/images/chatwoot_bot.png'
"
:size="14"
rounded-full
/>
<p class="text-body-main text-n-slate-12 m-0 line-clamp-1">
{{ latestNote.content }}
</p>
</div>
<p v-else class="text-body-para text-n-slate-10 m-0 italic">
{{ t('CONTACTS_LAYOUT.CARD.QUICK_OVERVIEW.NO_NOTES') }}
</p>
</div>
<!-- Section 3: Latest Previous Conversation -->
<div
v-if="latestConversation"
class="flex flex-col rounded-xl bg-n-card outline outline-1 outline-n-container -outline-offset-1 p-3 gap-1.6 w-full gap-1.5 cursor-pointer"
@click="getConversationUrl(latestConversation.id)"
>
<div
class="flex justify-between gap-2 w-full min-w-0"
:class="
latestConversation?.labels?.length > 0
? 'sm:flex-row flex-col items-start sm:items-center '
: 'flex-row items-center'
"
>
<div class="flex items-center gap-2 py-1 min-w-0 flex-shrink">
<div class="flex items-center gap-1.5 min-w-0">
<Icon
icon="i-lucide-message-circle"
class="size-3.5 text-n-slate-11"
/>
<span class="text-label-small text-n-slate-11 m-0 truncate">
{{ t('CONTACTS_LAYOUT.CARD.QUICK_OVERVIEW.LATEST_CONVERSATION') }}
</span>
</div>
<div v-if="inbox?.id" class="h-3 w-px bg-n-strong rounded-lg" />
<div class="flex items-center gap-1 min-w-0">
<InboxName
v-if="inbox?.id"
:inbox="inbox"
class="gap-1 [&>span]:text-n-slate-11"
/>
<div
v-if="latestConversation?.id"
class="flex items-center gap-1 min-w-0"
>
<Icon
icon="i-woot-hash"
class="size-3.5 text-n-slate-10 flex-shrink-0"
/>
<span class="text-label-small truncate text-n-slate-11">
{{ latestConversation.id }}
</span>
</div>
</div>
<div
v-if="latestConversation?.createdAt"
class="h-3 w-px bg-n-strong rounded-lg"
/>
<span
v-if="latestConversation?.updatedAt"
class="text-label-small text-n-slate-11 truncate min-w-0"
>
{{ formatTimestamp(latestConversation.updatedAt) }}
</span>
</div>
<ContactLabels
v-if="latestConversation?.labels?.length > 0"
:labels="latestConversation.labels"
class="flex-1 min-w-0 sm:justify-end w-full sm:w-auto"
disable-toggle
>
<template #before>
<div data-before-slot class="flex items-center gap-1.5">
<CardPriorityIcon
v-if="latestConversation.priority"
:priority="latestConversation.priority"
class="flex-shrink-0"
/>
<Avatar
v-if="assignee?.name"
v-tooltip.top="assignee.name"
:name="assignee.name"
:src="assignee.thumbnail"
:size="14"
:status="assignee.availabilityStatus"
hide-offline-status
rounded-full
/>
<CardStatusIcon
v-if="latestConversation.status"
:status="latestConversation.status"
/>
<div class="h-3 w-px bg-n-weak rounded-lg mx-1" />
</div>
</template>
</ContactLabels>
<div v-else class="flex items-center justify-end gap-1.5 flex-1">
<CardPriorityIcon
v-if="latestConversation.priority"
:priority="latestConversation.priority"
class="flex-shrink-0"
/>
<Avatar
v-if="assignee?.name"
v-tooltip.top="assignee.name"
:name="assignee.name"
:src="assignee.thumbnail"
:size="14"
:status="assignee.availability_status"
hide-offline-status
rounded-full
/>
<CardStatusIcon
v-if="latestConversation.status"
:status="latestConversation.status"
/>
</div>
</div>
<CardContent
v-if="lastMessageInConversation"
:last-message="lastMessageInConversation"
:unread-count="lastMessageInConversation?.conversation?.unreadCount"
/>
</div>
</div>
</template>
@@ -0,0 +1,68 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import Button from 'dashboard/components-next/button/Button.vue';
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
import Policy from 'dashboard/components/policy.vue';
defineProps({
selectedContact: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const [showDeleteSection, toggleDeleteSection] = useToggle();
const confirmDeleteContactDialogRef = ref(null);
const openConfirmDeleteContactDialog = () => {
confirmDeleteContactDialogRef.value?.dialogRef.open();
};
</script>
<template>
<Policy :permissions="['administrator']">
<div class="flex flex-col items-start border-t border-n-strong px-6 py-5">
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
sm
link
slate
class="hover:!no-underline text-n-slate-12"
icon="i-lucide-chevron-down"
trailing-icon
@click="toggleDeleteSection()"
/>
<div
class="transition-all duration-300 ease-in-out grid w-full overflow-hidden"
:class="
showDeleteSection
? 'grid-rows-[1fr] opacity-100 mt-2'
: 'grid-rows-[0fr] opacity-0 mt-0'
"
>
<div class="overflow-hidden min-h-0">
<span class="inline-flex text-n-slate-11 text-sm items-center gap-1">
{{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }}
<Button
:label="t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.BUTTON')"
sm
ruby
link
@click="openConfirmDeleteContactDialog()"
/>
</span>
</div>
</div>
</div>
<ConfirmContactDeleteDialog
ref="confirmDeleteContactDialogRef"
:selected-contact="selectedContact"
/>
</Policy>
</template>
@@ -1,46 +1,41 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import ContactCardOverview from 'dashboard/components-next/Contacts/ContactsCard/ContactCardOverview.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Flag from 'dashboard/components-next/flag/Flag.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import ContactLabels from 'dashboard/components-next/Conversation/ConversationCard/CardLabels.vue';
import ContactDeleteSection from 'dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Policy from 'dashboard/components/policy.vue';
import ContactAttributeDisplay from './ContactAttributeDisplay.vue';
import countries from 'shared/constants/countries';
const props = defineProps({
id: { type: Number, required: true },
name: { type: String, default: '' },
email: { type: String, default: '' },
labels: { type: Array, default: () => [] },
additionalAttributes: { type: Object, default: () => ({}) },
customAttributes: { type: Object, default: () => ({}) },
phoneNumber: { type: String, default: '' },
thumbnail: { type: String, default: '' },
availabilityStatus: { type: String, default: null },
isExpanded: { type: Boolean, default: false },
isUpdating: { type: Boolean, default: false },
selectable: { type: Boolean, default: false },
isSelected: { type: Boolean, default: false },
});
const emit = defineEmits([
'toggle',
'updateContact',
'showContact',
'select',
'sendMessage',
'deleteContact',
'avatarHover',
]);
const { t } = useI18n();
const store = useStore();
const isPrefetching = ref(false);
const hasPrefetched = ref(false);
const contactsFormRef = ref(null);
const getInitialContactData = () => ({
id: props.id,
@@ -48,11 +43,12 @@ const getInitialContactData = () => ({
email: props.email,
phoneNumber: props.phoneNumber,
additionalAttributes: props.additionalAttributes,
labels: props.labels,
});
const contactData = ref(getInitialContactData());
const isFormInvalid = computed(() => contactsFormRef.value?.isFormInvalid);
const countriesMap = computed(() => {
return countries.reduce((acc, country) => {
acc[country.code] = country;
@@ -79,11 +75,6 @@ const countryDetails = computed(() => {
};
});
const companyName = computed(() => {
const attributes = props.additionalAttributes || {};
return attributes.companyName || '';
});
const formattedLocation = computed(() => {
if (!countryDetails.value) return '';
@@ -92,6 +83,14 @@ const formattedLocation = computed(() => {
.join(' ');
});
const handleFormUpdate = updatedData => {
Object.assign(contactData.value, updatedData);
};
const handleUpdateContact = () => {
emit('updateContact', contactData.value);
};
const onClickExpand = () => {
emit('toggle');
contactData.value = getInitialContactData();
@@ -99,275 +98,147 @@ const onClickExpand = () => {
const onClickViewDetails = () => emit('showContact', props.id);
const onClickOpenSendMessage = () => emit('sendMessage', props.id);
const onClickDeleteContact = () => {
emit('deleteContact', props.id);
};
const toggleSelect = checked => {
emit('select', checked);
};
const prefetchContactData = async () => {
if (hasPrefetched.value || isPrefetching.value || !props.id) return;
isPrefetching.value = true;
try {
await Promise.all([
store.dispatch('contactNotes/get', { contactId: props.id }),
store.dispatch('contactConversations/get', props.id),
]);
hasPrefetched.value = true;
} catch (error) {
// error
} finally {
isPrefetching.value = false;
}
};
const handleExpandHover = isHovered => {
if (isHovered && !props.isExpanded) {
prefetchContactData();
}
const handleAvatarHover = isHovered => {
emit('avatarHover', isHovered);
};
</script>
<template>
<div class="relative">
<div
class="flex flex-col gap-2 p-2 lg:grid lg:gap-4 lg:items-center lg:rounded-lg lg:transition-all lg:duration-200 lg:grid-cols-[minmax(30%,1fr)_minmax(50%,1.5fr)_1fr]"
:class="{ 'border-b border-n-weak lg:border-none': isExpanded }"
<CardLayout
:key="id"
layout="row"
:class="{
'outline-n-weak !bg-n-slate-3 dark:!bg-n-solid-3': isSelected,
}"
>
<div
class="flex items-center gap-3 lg:gap-2"
:class="{ 'lg:col-span-3': isExpanded }"
>
<div class="flex-shrink-0 size-5 flex items-center justify-center">
<Checkbox
:model-value="isSelected"
@change="event => toggleSelect(event.target.checked)"
/>
</div>
<div class="flex items-center justify-start flex-1 gap-4">
<div
class="relative hidden lg:block size-4 rounded-md hover:bg-n-alpha-2 flex-shrink-0"
@mouseenter.passive="handleExpandHover(true)"
@mouseleave.passive="handleExpandHover(false)"
class="relative"
@mouseenter="handleAvatarHover(true)"
@mouseleave="handleAvatarHover(false)"
>
<Button
icon="i-lucide-chevron-down"
link
slate
sm
no-animation
class="flex-shrink-0 !size-8 absolute -inset-2"
:class="{ 'rotate-180': isExpanded }"
@click="onClickExpand"
/>
</div>
<div class="flex-shrink-0 flex items-center">
<Avatar
:name="name"
:src="thumbnail"
:size="24"
:size="48"
:status="availabilityStatus"
hide-offline-status
/>
</div>
<h4
class="text-heading-3 my-0 capitalize hover:cursor-pointer truncate text-n-slate-12 lg:max-w-40"
@click="onClickViewDetails"
>
{{ name }}
</h4>
<div
v-if="isExpanded"
class="hidden lg:flex items-center gap-2 flex-shrink-0"
>
<div
class="w-px h-3 bg-n-strong rounded-md ltr:ml-1 rtl:mr-1 flex-shrink-0"
/>
<Button
:label="t('CONTACTS_LAYOUT.CARD.ACTIONS.SEND_MESSAGE')"
icon="i-lucide-message-circle"
link
sm
class="hover:!no-underline"
@click="onClickOpenSendMessage"
/>
<div
class="w-px h-3 bg-n-strong rounded-md ltr:ml-1 rtl:mr-1 flex-shrink-0"
/>
<Button
:label="t('CONTACTS_LAYOUT.CARD.ACTIONS.VIEW_DETAILS')"
icon="i-lucide-info"
link
sm
class="hover:!no-underline"
@click="onClickViewDetails"
/>
<Policy
:permissions="['administrator']"
class="flex items-center gap-2"
rounded-full
>
<div
class="w-px h-3 bg-n-strong rounded-md ltr:ml-1 rtl:mr-1 flex-shrink-0"
/>
<template v-if="selectable" #overlay="{ size }">
<label
class="flex items-center justify-center rounded-full cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px] border border-n-weak"
:style="{ width: `${size}px`, height: `${size}px` }"
@click.stop
>
<Checkbox
:model-value="isSelected"
@change="event => toggleSelect(event.target.checked)"
/>
</label>
</template>
</Avatar>
</div>
<div class="flex flex-col gap-0.5 flex-1">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
<span class="text-base font-medium truncate text-n-slate-12">
{{ name }}
</span>
<span class="inline-flex items-center gap-1">
<span
v-if="additionalAttributes?.companyName"
class="i-ph-building-light size-4 text-n-slate-10 mb-0.5"
/>
<span
v-if="additionalAttributes?.companyName"
class="text-sm truncate text-n-slate-11"
>
{{ additionalAttributes.companyName }}
</span>
</span>
</div>
<div
class="flex flex-wrap items-center justify-start gap-x-3 gap-y-1"
>
<div v-if="email" class="truncate max-w-72" :title="email">
<span class="text-sm text-n-slate-11">
{{ email }}
</span>
</div>
<div v-if="email" class="w-px h-3 truncate bg-n-slate-6" />
<span v-if="phoneNumber" class="text-sm truncate text-n-slate-11">
{{ phoneNumber }}
</span>
<div v-if="phoneNumber" class="w-px h-3 truncate bg-n-slate-6" />
<span
v-if="countryDetails"
class="inline-flex items-center gap-2 text-sm truncate text-n-slate-11"
>
<Flag :country="countryDetails.countryCode" class="size-3.5" />
{{ formattedLocation }}
</span>
<div v-if="countryDetails" class="w-px h-3 truncate bg-n-slate-6" />
<Button
:label="t('CONTACTS_LAYOUT.CARD.ACTIONS.DELETE_CONTACT')"
icon="i-lucide-trash-2"
link
sm
ruby
class="hover:!no-underline"
@click="onClickDeleteContact"
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
variant="link"
size="xs"
@click="onClickViewDetails"
/>
</Policy>
</div>
<div
v-if="companyName"
class="my-0 capitalize h-6 px-1 inline-flex items-center gap-1 rounded-md text-n-slate-12 max-w-40 min-w-0 bg-n-label-color outline outline-1 outline-n-label-border -outline-offset-1"
:class="{ 'lg:hidden': isExpanded }"
>
<Icon
icon="i-lucide-briefcase-business"
class="size-3.5 flex-shrink-0 text-n-slate-11"
/>
<span class="truncate text-label-small">{{ companyName }}</span>
</div>
<div
class="relative lg:hidden size-5 rounded-md hover:bg-n-alpha-2 ltr:ml-auto rtl:mr-auto flex-shrink-0"
@mouseenter.passive="handleExpandHover(true)"
@mouseleave.passive="handleExpandHover(false)"
>
<Button
icon="i-lucide-chevron-down"
link
slate
sm
no-animation
class="flex-shrink-0 !size-8 absolute -inset-1.5"
:class="{ 'rotate-180': isExpanded }"
@click="onClickExpand"
/>
</div>
</div>
</div>
<div
class="flex items-center gap-2 lg:gap-2 lg:min-w-0"
:class="{ 'lg:hidden': isExpanded }"
>
<Button
icon="i-lucide-chevron-down"
variant="ghost"
color="slate"
size="xs"
:class="{ 'rotate-180': isExpanded }"
@click="onClickExpand"
/>
<template #after>
<div
v-if="email"
v-tooltip.top="{
content: email,
delay: { show: 500, hide: 0 },
}"
class="flex items-center gap-1 truncate lg:max-w-72"
class="transition-all duration-500 ease-in-out grid overflow-hidden"
:class="
isExpanded
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0'
"
>
<Icon
icon="i-woot-mail"
class="size-4 flex-shrink-0 text-n-slate-11"
/>
<span class="text-body-main text-n-slate-12 truncate">
{{ email }}
</span>
<div class="overflow-hidden">
<div class="flex flex-col gap-6 p-6 border-t border-n-strong">
<ContactsForm
ref="contactsFormRef"
:contact-data="contactData"
@update="handleFormUpdate"
/>
<div>
<Button
:label="
t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')
"
size="sm"
:is-loading="isUpdating"
:disabled="isUpdating || isFormInvalid"
@click="handleUpdateContact"
/>
</div>
</div>
<ContactDeleteSection
:selected-contact="{
id: props.id,
name: props.name,
}"
/>
</div>
</div>
<div
v-if="email && phoneNumber"
class="w-px h-3 bg-n-strong rounded-lg flex-shrink-0"
/>
<div
v-if="phoneNumber"
v-tooltip.top="{
content: phoneNumber,
delay: { show: 500, hide: 0 },
}"
class="flex items-center gap-1 truncate lg:max-w-72"
>
<Icon
icon="i-lucide-phone"
class="size-3 flex-shrink-0 text-n-slate-11"
/>
<span class="text-body-main text-n-slate-12 truncate">
{{ phoneNumber }}
</span>
</div>
<div
v-if="countryDetails"
class="w-px h-3 bg-n-strong rounded-lg flex-shrink-0"
/>
<div
v-if="countryDetails"
v-tooltip.top="{
content: formattedLocation,
delay: { show: 500, hide: 0 },
}"
class="flex items-center gap-2 min-w-0 text-n-slate-11"
>
<Flag
:country="countryDetails.countryCode"
class="size-3.5 flex-shrink-0"
/>
<span class="text-body-main text-n-slate-12 truncate">
{{ formattedLocation }}
</span>
</div>
<ContactAttributeDisplay :custom-attributes="customAttributes" />
</div>
<div class="flex flex-col gap-2 lg:hidden">
<div v-if="labels.length > 0" class="min-h-[1rem]">
<ContactLabels :labels="labels" disable-toggle class="my-0" />
</div>
<Button
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
link
xs
class="w-fit hover:!no-underline"
slate
@click="onClickViewDetails"
/>
</div>
<div
class="hidden lg:flex items-center justify-end gap-3 flex-shrink-0"
:class="{ 'lg:hidden': isExpanded }"
>
<ContactLabels
:labels="labels"
disable-toggle
class="my-0 flex-1 justify-end"
/>
</div>
</div>
<div
class="grid [transition:grid-template-rows_300ms_ease-out]"
:class="[
isExpanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
isExpanded ? '' : 'overflow-hidden',
]"
>
<div class="min-h-0">
<div
class="relative flex flex-col pt-3 pb-4 lg:pt-[1.125rem] lg:pb-5 overflow-visible transition-opacity duration-[600ms] ease-out"
:class="isExpanded ? 'opacity-100 delay-200' : 'opacity-0'"
>
<ContactCardOverview :contact-data="contactData" />
</div>
</div>
</div>
</template>
</CardLayout>
</div>
</template>
@@ -76,12 +76,12 @@ const closeMobileSidebar = () => {
class="flex w-full h-full overflow-hidden justify-evenly bg-n-surface-1"
>
<div
class="flex flex-col w-full h-full transition-all duration-300 ltr:3xl:ml-56 rtl:3xl:mr-56"
class="flex flex-col w-full h-full transition-all duration-300 ltr:2xl:ml-56 rtl:2xl:mr-56"
>
<header class="sticky top-0 z-10 px-6 3xl:px-0">
<div class="w-full mx-auto max-w-4xl">
<div class="w-full mx-auto max-w-[40.625rem]">
<div
class="flex flex-col xs:flex-row items-start xs:items-center justify-between w-full py-4 gap-2"
class="flex flex-col xs:flex-row items-start xs:items-center justify-between w-full py-7 gap-2"
>
<Breadcrumb
:items="breadcrumbItems"
@@ -94,20 +94,23 @@ const closeMobileSidebar = () => {
? $t('CONTACTS_LAYOUT.HEADER.BLOCK_CONTACT')
: $t('CONTACTS_LAYOUT.HEADER.UNBLOCK_CONTACT')
"
size="sm"
slate
:is-loading="isUpdating"
:disabled="isUpdating"
@click="toggleBlock"
/>
<VoiceCallButton
:phone="selectedContact?.phoneNumber"
:contact-id="contactId"
size="md"
:label="$t('CONTACT_PANEL.CALL')"
size="sm"
/>
<ComposeConversation :contact-id="contactId">
<template #trigger="{ toggle }">
<Button
:label="$t('CONTACTS_LAYOUT.HEADER.SEND_MESSAGE')"
size="sm"
@click="toggle"
/>
</template>
@@ -117,7 +120,7 @@ const closeMobileSidebar = () => {
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto 3xl:px-px">
<div class="w-full pt-2.5 pb-4 mx-auto max-w-4xl">
<div class="w-full py-4 mx-auto max-w-[40.625rem]">
<slot name="default" />
</div>
</main>
@@ -126,7 +129,7 @@ const closeMobileSidebar = () => {
<!-- Desktop sidebar -->
<div
v-if="slots.sidebar"
class="hidden lg:block overflow-y-auto justify-end min-w-52 w-full py-5 max-w-md ltr:border-l rtl:border-r border-n-weak bg-n-surface-2"
class="hidden lg:block overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
>
<slot name="sidebar" />
</div>
@@ -73,7 +73,7 @@ defineExpose({ dialogRef });
target="_blank"
rel="noopener noreferrer"
download="import-contacts-sample.csv"
class="text-n-blue-text"
class="text-n-blue-11"
>
{{
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.DOWNLOAD_LABEL')
@@ -1,8 +1,7 @@
<script setup>
import { useI18n } from 'vue-i18n';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import { useI18n } from 'vue-i18n';
defineProps({
selectedContact: {
@@ -38,13 +37,13 @@ const { t } = useI18n();
<template>
<div class="flex flex-col">
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between h-6 gap-2">
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between h-5 gap-2">
<label class="text-sm text-n-slate-12">
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PRIMARY') }}
</label>
<span
class="flex items-center justify-center px-1.5 h-6 font-420 text-xs rounded-md text-n-teal-11 outline outline-1 outline-n-weak"
class="flex items-center justify-center w-24 h-5 text-xs rounded-md text-n-teal-11 bg-n-alpha-2"
>
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PRIMARY_HELP_LABEL') }}
</span>
@@ -70,7 +69,7 @@ const { t } = useI18n();
@search="query => emit('search', query)"
/>
</div>
<div class="relative flex justify-center gap-2 mt-4 mb-3">
<div class="relative flex justify-center gap-2 top-4">
<div v-for="i in 3" :key="i" class="relative w-4 h-8">
<div
class="absolute w-0 h-0 border-l-[4px] border-r-[4px] border-b-[6px] border-l-transparent border-r-transparent border-n-strong ltr:translate-x-[4px] rtl:-translate-x-[4px] -translate-y-[4px]"
@@ -80,13 +79,13 @@ const { t } = useI18n();
/>
</div>
</div>
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between h-6 gap-2">
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between h-5 gap-2">
<label class="text-sm text-n-slate-12">
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PARENT') }}
</label>
<span
class="flex items-center justify-center px-1.5 h-6 font-420 text-xs rounded-md text-n-ruby-11 outline outline-1 outline-n-weak"
class="flex items-center justify-center w-24 h-5 text-xs rounded-md text-n-ruby-11 bg-n-alpha-2"
>
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PARENT_HELP_LABEL') }}
</span>
@@ -4,11 +4,11 @@ import { useI18n } from 'vue-i18n';
import { required, email } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import { splitName } from '@chatwoot/utils';
import countries from 'shared/constants/countries.js';
import Input from 'dashboard/components-next/input/Input.vue';
import CountryDropdown from 'dashboard/components-next/Countries/CountryDropdown.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import PhoneNumberInput from 'dashboard/components-next/phonenumberinput/PhoneNumberInput.vue';
import CompaniesDropdown from 'dashboard/components-next/Companies/CompaniesDropdown.vue';
const props = defineProps({
contactData: {
@@ -124,6 +124,10 @@ const prepareStateBasedOnProps = () => {
});
};
const countryOptions = computed(() =>
countries.map(({ name, id }) => ({ label: name, value: id }))
);
const editDetailsForm = computed(() =>
Object.keys(FORM_CONFIG).map(key => ({
key,
@@ -202,14 +206,9 @@ const getMessageType = key => {
: 'info';
};
const handleCountryChange = country => {
state.additionalAttributes.countryCode = country?.id || '';
state.additionalAttributes.country = country?.name || '';
emit('update', state);
};
const handleCompanyChange = company => {
state.additionalAttributes.companyName = company?.name || '';
const handleCountrySelection = value => {
const selectedCountry = countries.find(option => option.id === value);
state.additionalAttributes.country = selectedCountry?.name || '';
emit('update', state);
};
@@ -239,49 +238,41 @@ defineExpose({
</script>
<template>
<div class="flex flex-col gap-6 w-full">
<div class="flex flex-col items-start gap-5 w-full">
<span class="text-sm font-medium text-n-slate-12">
<div class="flex flex-col gap-6">
<div class="flex flex-col items-start gap-2">
<span class="py-1 text-sm font-medium text-n-slate-12">
{{ t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.TITLE') }}
</span>
<div class="grid w-full grid-cols-1 gap-4 sm:grid-cols-2">
<template v-for="item in editDetailsForm" :key="item.key">
<CountryDropdown
<ComboBox
v-if="item.key === 'COUNTRY'"
v-model="state.additionalAttributes.countryCode"
:options="countryOptions"
:placeholder="item.placeholder"
class="[&>div>button]:h-8"
:class="{
'[&>div>button]:h-8': !isDetailsView,
'[&_button]:!h-10 [&_button]:!rounded-[10px]': isDetailsView,
'[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:!outline-transparent':
!isDetailsView,
'[&>div>button]:!bg-n-alpha-black2': isDetailsView,
}"
@change="handleCountryChange"
@update:model-value="handleCountrySelection"
/>
<PhoneNumberInput
v-else-if="item.key === 'PHONE_NUMBER'"
v-model="getFormBinding(item.key).value"
:placeholder="item.placeholder"
:show-border="isDetailsView"
:compact="!isDetailsView"
/>
<CompaniesDropdown
v-else-if="item.key === 'COMPANY_NAME'"
:placeholder="item.placeholder"
:selected-company-name="state.additionalAttributes.companyName"
:class="{
'[&>div>button]:h-8': !isDetailsView,
'[&_button]:!h-10 [&_button]:!rounded-[10px]': isDetailsView,
}"
@change="handleCompanyChange"
/>
<Input
v-else
v-model="getFormBinding(item.key).value"
:placeholder="item.placeholder"
:message-type="getMessageType(item.key)"
:custom-input-class="`!pt-1 !pb-1 ${
:custom-input-class="`h-8 !pt-1 !pb-1 ${
!isDetailsView
? '[&:not(.error,.focus)]:!outline-transparent h-8'
: 'h-10 !rounded-[10px]'
? '[&:not(.error,.focus)]:!outline-transparent'
: ''
}`"
class="w-full"
@input="
@@ -296,19 +287,18 @@ defineExpose({
</template>
</div>
</div>
<div class="flex flex-col items-start gap-5">
<span class="text-sm font-medium text-n-slate-12">
<div class="flex flex-col items-start gap-2">
<span class="py-1 text-sm font-medium text-n-slate-12">
{{ t('CONTACTS_LAYOUT.CARD.SOCIAL_MEDIA.TITLE') }}
</span>
<div class="flex flex-wrap gap-2">
<div
v-for="item in socialProfilesForm"
:key="item.key"
class="flex items-center h-8 gap-2 px-2 bg-n-alpha-2 dark:bg-n-solid-2"
class="flex items-center h-8 gap-2 px-2 rounded-lg"
:class="{
'rounded-[10px] outline-1 outline outline-n-weak -outline-offset-1 hover:outline-n-slate-6':
isDetailsView,
'rounded-lg': !isDetailsView,
'bg-n-alpha-2 dark:bg-n-solid-2': isDetailsView,
'bg-n-alpha-2 dark:bg-n-solid-3': !isDetailsView,
}"
>
<Icon
@@ -1,13 +1,10 @@
<script setup>
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import ContactSortMenu from './components/ContactSortMenu.vue';
import ContactMoreActions from './components/ContactMoreActions.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
import DisplayPropertiesMenu from './components/DisplayPropertiesMenu.vue';
defineProps({
showSearch: { type: Boolean, default: true },
@@ -32,25 +29,17 @@ const emit = defineEmits([
'createSegment',
'deleteSegment',
]);
const [showDisplayProperties, toggleDisplayProperties] = useToggle(false);
const closeDisplayProperties = () => {
if (showDisplayProperties.value) {
toggleDisplayProperties(false);
}
};
</script>
<template>
<header class="sticky top-0 z-10">
<div
class="flex items-start sm:items-center justify-between w-full py-4 px-6 gap-2 mx-auto after:absolute after:inset-x-0 after:-bottom-4 after:bg-gradient-to-b after:from-n-surface-1 after:from-10% after:dark:from-0% after:to-transparent after:h-4 after:pointer-events-none"
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]"
>
<span class="text-heading-1 truncate text-n-slate-12">
<span class="text-xl font-medium truncate text-n-slate-12">
{{ headerTitle }}
</span>
<div class="flex items-center flex-col sm:flex-row flex-shrink-0 gap-3">
<div class="flex items-center flex-col sm:flex-row flex-shrink-0 gap-4">
<div v-if="showSearch" class="flex items-center gap-2 w-full">
<Input
:model-value="searchValue"
@@ -70,23 +59,7 @@ const closeDisplayProperties = () => {
</template>
</Input>
</div>
<div class="flex items-center flex-shrink-0 gap-2">
<div class="w-px h-3 rounded-lg bg-n-strong" />
<div v-on-click-outside="closeDisplayProperties" class="relative">
<Button
icon="i-lucide-settings-2"
color="slate"
:label="$t('CONTACTS_LAYOUT.HEADER.DISPLAY_PROPERTIES')"
size="sm"
:variant="!showDisplayProperties ? 'ghost' : 'faded'"
@click="toggleDisplayProperties()"
/>
<DisplayPropertiesMenu
v-if="showDisplayProperties"
class="absolute top-full mt-1 max-md:ltr:left-0 max-md:rtl:right-0 md:ltr:right-0 md:rtl:left-0"
/>
</div>
<div class="w-px h-3 rounded-lg bg-n-strong mx-1" />
<div class="flex items-center flex-shrink-0 gap-4">
<div class="flex items-center gap-2">
<div v-if="!isLabelView && !isActiveView" class="relative">
<Button
@@ -139,9 +112,7 @@ const closeDisplayProperties = () => {
@export="emit('export')"
/>
</div>
<div
class="w-px h-3 rounded-lg bg-n-strong ltr:ml-1 rtl:mr-2 ltr:mr-1 rtl:ml-2"
/>
<div class="w-px h-4 bg-n-strong" />
<ComposeConversation>
<template #trigger="{ toggle }">
<Button :label="buttonLabel" size="sm" @click="toggle" />
@@ -294,7 +294,7 @@ defineExpose({
>
<template #filter>
<div
class="absolute z-20 mt-1 ltr:-right-52 rtl:-left-52 sm:ltr:right-0 sm:rtl:left-0 top-full"
class="absolute mt-1 ltr:-right-52 rtl:-left-52 sm:ltr:right-0 sm:rtl:left-0 top-full"
>
<ContactsFilter
v-if="showFiltersModal"
@@ -105,7 +105,7 @@ const handleOrderChange = value => {
<div
v-if="isMenuOpen"
v-on-clickaway="() => (isMenuOpen = false)"
class="absolute z-20 top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-n-slate-12">
@@ -62,7 +62,7 @@ const activeFilterQueryData = computed(() => {
t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.CLEAR_FILTERS')
"
:show-clear-button="!hasActiveSegments"
class="max-w-[105rem] px-6 relative z-20"
class="max-w-[60rem] px-6"
@open-filter="emit('openFilter')"
@clear-filters="emit('clearFilters')"
/>
@@ -1,162 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
const { t } = useI18n();
const { uiSettings, updateUISettings } = useUISettings();
const contactAttributes = useMapGetter('attributes/getContactAttributes');
const MAX_SELECTED = 3;
const selectedAttributeKeys = computed({
get: () => uiSettings.value?.contact_list_display_properties || [],
set: value => {
updateUISettings({
contact_list_display_properties: value,
});
},
});
const selectedAttributes = computed(() => {
return (
contactAttributes.value?.filter(attr =>
selectedAttributeKeys.value.includes(attr.attributeKey)
) || []
);
});
const otherAttributes = computed(() => {
return (
contactAttributes.value?.filter(
attr => !selectedAttributeKeys.value.includes(attr.attributeKey)
) || []
);
});
const canSelectMore = computed(() => {
return selectedAttributeKeys.value.length < MAX_SELECTED;
});
const toggleAttribute = attributeKey => {
const currentKeys = [...selectedAttributeKeys.value];
const index = currentKeys.indexOf(attributeKey);
if (index > -1) {
currentKeys.splice(index, 1);
} else if (currentKeys.length < MAX_SELECTED) {
currentKeys.push(attributeKey);
}
selectedAttributeKeys.value = currentKeys;
};
const removeAttribute = attributeKey => {
const currentKeys = [...selectedAttributeKeys.value];
const index = currentKeys.indexOf(attributeKey);
if (index > -1) {
currentKeys.splice(index, 1);
selectedAttributeKeys.value = currentKeys;
}
};
</script>
<template>
<div
class="flex flex-col gap-2 px-3 pt-3 pb-4 bg-n-alpha-3 rounded-xl outline outline-1 outline-n-weak -outline-offset-1 w-80 z-20 backdrop-blur-md"
>
<div class="flex items-start gap-1 flex-col">
<h3 class="text-heading-3 text-n-slate-12 m-0">
{{ t('CONTACTS_LAYOUT.DISPLAY_PROPERTIES.TITLE') }}
</h3>
<p class="text-body-main text-n-slate-11 m-0">
{{ t('CONTACTS_LAYOUT.DISPLAY_PROPERTIES.DESCRIPTION') }}
</p>
</div>
<div v-if="selectedAttributes.length > 0" class="flex flex-col gap-2">
<span class="pt-2 text-label-small text-n-slate-11">
{{ t('CONTACTS_LAYOUT.DISPLAY_PROPERTIES.SELECTED_ATTRIBUTES') }}
</span>
<div class="flex flex-wrap gap-2">
<div
v-for="attribute in selectedAttributes"
:key="attribute.attributeKey"
class="inline-flex items-center gap-1 ltr:pl-1 rtl:pr-1 ltr:pr-2 rtl:pl-2 rounded-lg bg-n-button-color shadow-[0_1px_2px_0_rgba(27,28,29,0.04)] outline outline-1 -outline-offset-1 outline-n-container h-8"
>
<div class="size-5 flex items-center justify-center">
<Checkbox
model-value
@change="removeAttribute(attribute.attributeKey)"
/>
</div>
<span class="text-body-main text-n-slate-12">
{{ attribute.attributeDisplayName }}
</span>
<Button
icon="i-lucide-x"
slate
xs
ghost
class="!size-4 !rounded-md"
@click="removeAttribute(attribute.attributeKey)"
/>
</div>
</div>
</div>
<div v-if="otherAttributes.length > 0" class="flex flex-col gap-2">
<span class="pt-2 text-label-small text-n-slate-11">
{{ t('CONTACTS_LAYOUT.DISPLAY_PROPERTIES.OTHER_ATTRIBUTES') }}
</span>
<div class="flex flex-wrap gap-2">
<div
v-for="attribute in otherAttributes"
:key="attribute.attributeKey"
class="inline-flex items-center gap-1 ltr:pl-1 rtl:pr-1 ltr:pr-2 rtl:pl-2 rounded-lg shadow-[0_1px_2px_0_rgba(27,28,29,0.04)] outline outline-1 -outline-offset-1 outline-n-container h-8 bg-n-button-color"
:class="[
canSelectMore ? 'cursor-pointer' : 'opacity-50 cursor-not-allowed',
]"
@click="canSelectMore && toggleAttribute(attribute.attributeKey)"
>
<div class="size-5 flex items-center justify-center">
<Checkbox
:model-value="false"
:disabled="!canSelectMore"
@change="toggleAttribute(attribute.attributeKey)"
/>
</div>
<span class="text-body-main text-n-slate-12">
{{ attribute.attributeDisplayName }}
</span>
<Button
v-if="attribute.attributeDescription"
v-tooltip.top="{
content: attribute.attributeDescription,
delay: { show: 500, hide: 0 },
}"
icon="i-lucide-info"
slate
xs
ghost
:disabled="!canSelectMore"
class="!size-4 !rounded-md"
/>
</div>
</div>
</div>
<div
v-if="contactAttributes.length === 0"
class="flex flex-col items-center justify-center gap-3 py-8"
>
<p class="text-body-main text-center text-n-slate-11 m-0">
{{ t('CONTACTS_LAYOUT.DISPLAY_PROPERTIES.NO_ATTRIBUTES') }}
</p>
</div>
</div>
</template>
@@ -5,6 +5,7 @@ import { useRoute } from 'vue-router';
import ContactListHeaderWrapper from 'dashboard/components-next/Contacts/ContactsHeader/ContactListHeaderWrapper.vue';
import ContactsActiveFiltersPreview from 'dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import ContactsLoadMore from 'dashboard/components-next/Contacts/ContactsLoadMore.vue';
const props = defineProps({
searchValue: { type: String, default: '' },
@@ -19,6 +20,9 @@ const props = defineProps({
segmentsId: { type: [String, Number], default: 0 },
hasAppliedFilters: { type: Boolean, default: false },
isFetchingList: { type: Boolean, default: false },
useInfiniteScroll: { type: Boolean, default: false },
hasMore: { type: Boolean, default: false },
isLoadingMore: { type: Boolean, default: false },
});
const emit = defineEmits([
@@ -27,6 +31,7 @@ const emit = defineEmits([
'search',
'applyFilter',
'clearFilters',
'loadMore',
]);
const route = useRoute();
@@ -61,11 +66,19 @@ const updateCurrentPage = page => {
const openFilter = () => {
contactListHeaderWrapper.value?.onToggleFilters();
};
const showLoadMore = computed(() => {
return props.useInfiniteScroll && props.hasMore;
});
const showPagination = computed(() => {
return !props.useInfiniteScroll && props.showPaginationFooter;
});
</script>
<template>
<section
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-surface"
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-surface-1"
>
<div class="flex flex-col w-full h-full transition-all duration-300">
<ContactListHeaderWrapper
@@ -86,7 +99,7 @@ const openFilter = () => {
@clear-filters="emit('clearFilters')"
/>
<main class="flex-1 overflow-y-auto">
<div class="w-full mx-auto">
<div class="w-full mx-auto max-w-[60rem]">
<ContactsActiveFiltersPreview
v-if="showActiveFiltersPreview"
:active-segment="activeSegment"
@@ -94,9 +107,14 @@ const openFilter = () => {
@open-filter="openFilter"
/>
<slot name="default" />
<ContactsLoadMore
v-if="showLoadMore"
:is-loading="isLoadingMore"
@load-more="emit('loadMore')"
/>
</div>
</main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0">
<footer v-if="showPagination" class="sticky bottom-0 z-0 px-4 pb-4">
<PaginationFooter
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage"
@@ -0,0 +1,28 @@
<script setup>
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['loadMore']);
const { t } = useI18n();
</script>
<template>
<div class="flex justify-center py-4">
<Button
:label="t('CONTACTS_LAYOUT.LOAD_MORE')"
:is-loading="isLoading"
variant="faded"
color="slate"
size="sm"
@click="emit('loadMore')"
/>
</div>
</template>
@@ -9,7 +9,6 @@ import ListAttribute from 'dashboard/components-next/CustomAttributes/ListAttrib
import CheckboxAttribute from 'dashboard/components-next/CustomAttributes/CheckboxAttribute.vue';
import DateAttribute from 'dashboard/components-next/CustomAttributes/DateAttribute.vue';
import OtherAttribute from 'dashboard/components-next/CustomAttributes/OtherAttribute.vue';
import HelperTextPopup from 'dashboard/components/ui/HelperTextPopup.vue';
const props = defineProps({
attribute: {
@@ -76,15 +75,11 @@ const CurrentAttributeComponent = computed(() => {
<template>
<div
class="grid grid-cols-[auto,auto] group/attribute items-center w-full gap-4"
class="grid grid-cols-[140px,1fr] group/attribute items-center w-full gap-2"
:class="isEditingView ? 'min-h-10' : 'min-h-11'"
>
<div class="flex items-center gap-1.5 min-w-0">
<HelperTextPopup
v-if="attribute.attributeDescription"
:message="attribute.attributeDescription"
/>
<span class="text-body-main truncate text-n-slate-12">
<div class="flex items-center justify-between truncate">
<span class="text-sm font-medium truncate text-n-slate-12">
{{ attribute.attributeDisplayName }}
</span>
</div>
@@ -108,7 +108,7 @@ const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
</script>
<template>
<div v-if="hasContactAttributes" class="flex flex-col gap-4 px-5 pt-4 pb-6">
<div v-if="hasContactAttributes" class="flex flex-col gap-6 px-6 py-6">
<div v-if="!hasNoUsedAttributes" class="flex flex-col gap-2">
<ContactCustomAttributeItem
v-for="attribute in usedAttributes"
@@ -117,16 +117,16 @@ const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
:attribute="attribute"
/>
</div>
<div v-if="!hasNoUnusedAttributes" class="flex items-center gap-2">
<div v-if="!hasNoUnusedAttributes" class="flex items-center gap-3">
<div class="flex-1 h-[1px] bg-n-slate-5" />
<span class="text-xs font-420 text-n-slate-10">{{
<span class="text-sm font-medium text-n-slate-10">{{
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.UNUSED_ATTRIBUTES', {
count: unusedAttributesCount,
})
}}</span>
<div class="flex-1 h-[1px] bg-n-slate-5" />
</div>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3">
<div v-if="!hasNoUnusedAttributes" class="relative">
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
<input
@@ -135,7 +135,7 @@ const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
:placeholder="
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.SEARCH_PLACEHOLDER')
"
class="w-full h-8 py-2 ltr:pl-10 ltr:pr-2 rtl:pr-10 rtl:pl-2 mb-0 reset-base outline-n-weak outline-1 outline hover:outline-n-slate-6 text-sm border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12 outline-offset-0"
class="w-full h-8 py-2 pl-10 pr-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
<div
@@ -3,7 +3,6 @@ import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ConversationCard from 'dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue';
@@ -14,6 +13,11 @@ const route = useRoute();
const conversations = useMapGetter(
'contactConversations/getAllConversationsByContactId'
);
const contactsById = useMapGetter('contacts/getContactById');
const stateInbox = useMapGetter('inboxes/getInboxById');
const accountLabels = useMapGetter('labels/getLabels');
const accountLabelsValue = computed(() => accountLabels.value);
const uiFlags = useMapGetter('contactConversations/getUIFlags');
const isFetching = computed(() => uiFlags.value.isFetching);
@@ -32,17 +36,16 @@ const contactConversations = computed(() =>
</div>
<div
v-else-if="contactConversations.length > 0"
class="px-6 pt-4 pb-6 divide-y divide-n-weak"
class="px-6 py-4 divide-y divide-n-strong [&>*:hover]:!border-y-transparent [&>*:hover+*]:!border-t-transparent"
>
<ConversationCard
v-for="conversation in contactConversations"
:key="conversation.id"
:chat="useSnakeCase(conversation, { deep: true })"
enable-context-menu
compact
show-assignee
:allowed-context-menu-options="['open-new-tab', 'copy-link']"
:enable-selection="false"
:conversation="conversation"
:contact="contactsById(conversation.meta.sender.id)"
:state-inbox="stateInbox(conversation.inboxId)"
:account-labels="accountLabelsValue"
class="rounded-none hover:rounded-xl hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
/>
</div>
<p v-else class="px-6 py-10 text-sm leading-6 text-center text-n-slate-11">
@@ -103,12 +103,12 @@ const onMergeContacts = async () => {
</script>
<template>
<div class="flex flex-col gap-6 px-5 pb-6 pt-4">
<div class="flex flex-col gap-2 mt-4">
<h4 class="text-base font-medium text-n-slate-12">
<div class="flex flex-col gap-8 px-6 py-6">
<div class="flex flex-col gap-2">
<h4 class="text-base text-n-slate-12">
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.TITLE') }}
</h4>
<p class="text-sm font-420 text-n-slate-11">
<p class="text-sm text-n-slate-11">
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.DESCRIPTION') }}
</p>
</div>
@@ -126,6 +126,13 @@ const onMergeContacts = async () => {
@search="onContactSearch"
/>
<div class="flex items-center justify-between gap-3">
<Button
variant="faded"
color="slate"
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="resetState"
/>
<Button
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CONFIRM')"
class="w-full"
@@ -133,12 +140,6 @@ const onMergeContacts = async () => {
:disabled="isMergingContact"
@click="onMergeContacts"
/>
<Button
slate
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CANCEL')"
class="w-full"
@click="resetState"
/>
</div>
</div>
</template>
@@ -55,12 +55,12 @@ useKeyboardEvents(keyboardEvents);
</script>
<template>
<div class="flex flex-col gap-4 pt-4 pb-6">
<div class="flex flex-col gap-6 py-6">
<Editor
v-model="state.message"
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.PLACEHOLDER')"
focus-on-mount
class="[&>div]:px-4 [&>div]:py-4 px-5"
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 px-6"
>
<template #actions>
<div class="flex items-center gap-3">
@@ -87,7 +87,7 @@ useKeyboardEvents(keyboardEvents);
<ContactNoteItem
v-for="note in notes"
:key="note.id"
class="mx-5 py-4"
class="mx-6 py-4"
:note="note"
:written-by="getWrittenBy(note)"
allow-delete
@@ -48,26 +48,10 @@ onMounted(() => {
</script>
<template>
<div class="flex flex-col gap-1.5 border-b border-n-strong group/note">
<div class="flex items-center justify-between w-full gap-1.5 min-w-0 h-9">
<div class="min-w-0 truncate text-n-slate-11 text-xs font-440">
{{ dynamicTime(note.createdAt) }}
</div>
<div class="flex items-center gap-1">
<Button
v-if="allowDelete"
ghost
ruby
size="xs"
icon="i-lucide-trash"
class="opacity-0 group-hover/note:opacity-100"
@click="handleDelete"
/>
<div class="flex flex-col gap-2 border-b border-n-strong group/note">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-1.5 min-w-0">
<Avatar
v-tooltip.left="{
content: writtenBy,
delay: { show: 500, hide: 0 },
}"
:name="note?.user?.name || 'Bot'"
:src="
note?.user?.name
@@ -77,12 +61,30 @@ onMounted(() => {
:size="16"
rounded-full
/>
<div class="min-w-0 truncate">
<span class="inline-flex items-center gap-1 text-sm text-n-slate-11">
<span class="font-medium text-n-slate-12">{{ writtenBy }}</span>
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.WROTE') }}
<span class="font-medium text-n-slate-12">
{{ dynamicTime(note.createdAt) }}
</span>
</span>
</div>
</div>
<Button
v-if="allowDelete"
variant="faded"
color="ruby"
size="xs"
icon="i-lucide-trash"
class="opacity-0 group-hover/note:opacity-100"
@click="handleDelete"
/>
</div>
<p
ref="noteContentRef"
v-dompurify-html="formatMessage(note.content || '')"
class="mb-0 text-body-main prose-sm prose-p:text-sm prose-p:leading-relaxed prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
class="mb-0 prose-sm prose-p:text-sm prose-p:leading-relaxed prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
:class="{
'line-clamp-4': collapsible && !isExpanded && needsCollapse,
}"
@@ -11,7 +11,6 @@ import ContactLabels from 'dashboard/components-next/Contacts/ContactLabels/Cont
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
import Policy from 'dashboard/components/policy.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
selectedContact: {
@@ -25,8 +24,6 @@ const emit = defineEmits(['goToContactsList']);
const { t } = useI18n();
const store = useStore();
const wrapperRef = ref(null);
const confirmDeleteContactDialogRef = ref(null);
const avatarFile = ref(null);
@@ -124,17 +121,17 @@ const handleAvatarDelete = async () => {
</script>
<template>
<div class="flex flex-col items-start gap-6 pb-6 w-full">
<div ref="wrapperRef" class="flex flex-col items-start gap-3 w-full">
<div class="flex flex-col items-start gap-8 pb-6">
<div class="flex flex-col items-start gap-3">
<Avatar
:src="avatarSrc || ''"
:name="selectedContact?.name || ''"
:size="64"
:size="72"
allow-upload
@upload="handleAvatarUpload"
@delete="handleAvatarDelete"
/>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-1">
<h3 class="text-base font-medium text-n-slate-12">
{{ selectedContact?.name }}
</h3>
@@ -143,44 +140,27 @@ const handleAvatarDelete = async () => {
v-if="selectedContact?.identifier"
class="inline-flex items-center gap-1 text-sm text-n-slate-11"
>
<Icon
icon="i-ph-user-gear"
class="text-n-slate-10 size-4 flex-shrink-0"
/>
<span class="i-ph-user-gear text-n-slate-10 size-4" />
{{ selectedContact?.identifier }}
</span>
<span class="inline-flex items-start gap-1 text-sm text-n-slate-11">
<Icon
<span class="inline-flex items-center gap-1 text-sm text-n-slate-11">
<span
v-if="selectedContact?.identifier"
icon="i-ph-activity"
class="text-n-slate-10 size-4 flex-shrink-0 mt-0.5"
class="i-ph-activity text-n-slate-10 size-4"
/>
<div
class="flex flex-col sm:flex-row sm:inline-flex items-start sm:items-center gap-1 sm:gap-3"
>
<span>
{{
$t('CONTACTS_LAYOUT.DETAILS.CREATED_AT', { date: createdAt })
}}
</span>
<div class="w-px h-3 rounded-xl bg-n-weak hidden sm:block" />
<span>
{{
$t('CONTACTS_LAYOUT.DETAILS.LAST_ACTIVITY', {
date: lastActivityAt,
})
}}
</span>
</div>
{{ $t('CONTACTS_LAYOUT.DETAILS.CREATED_AT', { date: createdAt }) }}
{{
$t('CONTACTS_LAYOUT.DETAILS.LAST_ACTIVITY', {
date: lastActivityAt,
})
}}
</span>
</div>
</div>
<ContactLabels
:wrapper-ref="wrapperRef"
:contact-id="selectedContact?.id"
/>
<ContactLabels :contact-id="selectedContact?.id" />
</div>
<div class="flex flex-col items-start gap-6 w-full">
<div class="flex flex-col items-start gap-6">
<ContactsForm
ref="contactsFormRef"
:contact-data="contactData"
@@ -195,23 +175,21 @@ const handleAvatarDelete = async () => {
@click="updateContact"
/>
</div>
<Policy :permissions="['administrator']" class="w-full">
<div class="w-full h-px bg-n-slate-3 mt-1 mb-7" />
<Policy :permissions="['administrator']">
<div
class="flex items-end justify-between w-full gap-4 outline outline-1 outline-n-weak rounded-2xl p-4"
class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong"
>
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }}
</h6>
<span class="text-sm font-420 text-n-slate-11">
<span class="text-sm text-n-slate-11">
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
</span>
</div>
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
ruby
class="flex-shrink-0"
color="ruby"
@click="openConfirmDeleteContactDialog"
/>
</div>
@@ -9,8 +9,6 @@ import {
ExceptionWithMessage,
} from 'shared/helpers/CustomErrors';
import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
const props = defineProps({
contacts: { type: Array, required: true },
@@ -30,10 +28,7 @@ const route = useRoute();
const uiFlags = useMapGetter('contacts/getUIFlags');
const isUpdating = computed(() => uiFlags.value.isUpdating);
const expandedCardId = ref(null);
const selectedContactId = ref(null);
const composeConversationRef = ref(null);
const confirmDeleteContactDialogRef = ref(null);
const contactToDelete = ref(null);
const hoveredAvatarId = ref(null);
const selectedIdsSet = computed(() => new Set(props.selectedContactIds || []));
@@ -71,42 +66,27 @@ const onClickViewDetails = async id => {
await router.push({ name, params, query: route.query });
};
const toggleExpanded = async id => {
const isExpanding = expandedCardId.value !== id;
const toggleExpanded = id => {
expandedCardId.value = expandedCardId.value === id ? null : id;
// Fetch contactable inboxes when expanding a card
if (isExpanding) {
try {
await store.dispatch('contacts/fetchContactableInbox', id);
} catch (error) {
// error
}
}
};
const isSelected = id => selectedIdsSet.value.has(id);
const shouldShowSelection = id => {
return hoveredAvatarId.value === id || isSelected(id);
};
const handleSelect = (id, value) => {
emit('toggleContact', { id, value });
};
const handleSendMessage = id => {
selectedContactId.value = String(id);
composeConversationRef.value?.toggle();
};
const handleDeleteContact = id => {
const contact = props.contacts.find(c => c.id === id);
if (contact) {
contactToDelete.value = { id: contact.id, name: contact.name };
confirmDeleteContactDialogRef.value?.dialogRef.open();
}
const handleAvatarHover = (id, isHovered) => {
hoveredAvatarId.value = isHovered ? id : null;
};
</script>
<template>
<div class="flex flex-col divide-y divide-n-slate-3">
<div class="flex flex-col gap-4">
<div v-for="contact in contacts" :key="contact.id" class="relative">
<ContactsCard
:id="contact.id"
@@ -115,37 +95,17 @@ const handleDeleteContact = id => {
:thumbnail="contact.thumbnail"
:phone-number="contact.phoneNumber"
:additional-attributes="contact.additionalAttributes"
:custom-attributes="contact.customAttributes"
:availability-status="contact.availabilityStatus"
:is-expanded="expandedCardId === contact.id"
:is-updating="isUpdating"
:selectable="shouldShowSelection(contact.id)"
:is-selected="isSelected(contact.id)"
:labels="contact.labels || []"
@toggle="toggleExpanded(contact.id)"
@update-contact="updateContact"
@show-contact="onClickViewDetails"
@select="value => handleSelect(contact.id, value)"
@send-message="handleSendMessage"
@delete-contact="handleDeleteContact"
@avatar-hover="value => handleAvatarHover(contact.id, value)"
/>
</div>
<ComposeConversation
ref="composeConversationRef"
:contact-id="selectedContactId"
is-modal
>
<template #trigger="{ toggle }">
<button
type="button"
class="hidden"
aria-hidden="true"
@click="toggle"
/>
</template>
</ComposeConversation>
<ConfirmContactDeleteDialog
ref="confirmDeleteContactDialogRef"
:selected-contact="contactToDelete"
/>
</div>
</template>
@@ -1,58 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import Avatar from 'next/avatar/Avatar.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
const props = defineProps({
contact: { type: Object, required: true },
selected: { type: Boolean, default: false },
enableSelection: { type: Boolean, default: true },
hideThumbnail: { type: Boolean, default: false },
});
const emit = defineEmits(['selectConversation']);
const hovered = ref(false);
const onThumbnailHover = () => {
hovered.value = !props.hideThumbnail;
};
const onThumbnailLeave = () => {
hovered.value = false;
};
const selectedModel = computed({
get: () => props.selected,
set: value => {
emit('selectConversation', value);
},
});
</script>
<template>
<div
class="relative flex items-center flex-shrink-0"
@mouseenter="onThumbnailHover"
@mouseleave="onThumbnailLeave"
>
<Avatar
v-if="!hideThumbnail"
:name="contact.name"
:src="contact.thumbnail"
:size="24"
:status="contact.availability_status"
hide-offline-status
>
<template v-if="enableSelection" #overlay>
<div
v-if="hovered || selected"
class="flex items-center justify-center rounded-md cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px] size-6"
@click.stop
>
<Checkbox v-model="selectedModel" />
</div>
</template>
</Avatar>
</div>
</template>
@@ -1,47 +0,0 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
import MessagePreview from './MessagePreview.vue';
import VoiceCallStatus from './VoiceCallStatus.vue';
import UnreadBadge from './UnreadBadge.vue';
defineProps({
lastMessage: { type: Object, default: null },
voiceCallStatus: { type: String, default: '' },
voiceCallDirection: { type: String, default: '' },
unreadCount: { type: Number, default: 0 },
showExpandedPreview: { type: Boolean, default: false },
});
</script>
<template>
<div
class="grid grid-cols-[1fr_auto] gap-1.5"
:class="showExpandedPreview ? 'items-end' : 'items-center'"
>
<VoiceCallStatus
v-if="voiceCallStatus"
key="voice-status-row"
:status="voiceCallStatus"
:direction="voiceCallDirection"
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
/>
<MessagePreview
v-else-if="lastMessage"
key="message-preview"
:message="lastMessage"
:multi-line="showExpandedPreview"
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
/>
<span
v-else
key="no-messages"
class="inline-grid grid-flow-col auto-cols-max items-center gap-1 text-body-main"
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
>
<Icon icon="i-lucide-info" class="size-3.5" />
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
</span>
<UnreadBadge :count="unreadCount" :align-bottom="showExpandedPreview" />
</div>
</template>
@@ -1,23 +0,0 @@
<script setup>
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
defineProps({
contactName: { type: String, required: true },
timestamp: { type: Number, required: true },
createdAt: { type: Number, required: true },
});
</script>
<template>
<div class="grid grid-cols-[1fr_auto] items-center gap-2 h-5">
<h4 class="text-sm my-0 capitalize truncate text-n-slate-12 font-medium">
{{ contactName }}
</h4>
<TimeAgo
:last-activity-timestamp="timestamp"
:created-at-timestamp="createdAt"
class="font-440 !text-xs shrink-0"
/>
</div>
</template>
@@ -1,191 +1,95 @@
<script setup>
import {
ref,
computed,
inject,
nextTick,
useSlots,
watch,
useAttrs,
} from 'vue';
import { useI18n } from 'vue-i18n';
import { useThrottleFn } from '@vueuse/core';
import { useMapGetter } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import Label from 'dashboard/components-next/label/Label.vue';
import { ref, computed } from 'vue';
const props = defineProps({
labels: {
conversationLabels: {
type: Array,
required: true,
},
disableToggle: {
type: Boolean,
default: false,
accountLabels: {
type: Array,
required: true,
},
});
defineOptions({ inheritAttrs: false });
const WIDTH_CONFIG = Object.freeze({
DEFAULT_WIDTH: 80,
CHAR_WIDTH: {
SHORT: 8, // For labels <= 5 chars
LONG: 6, // For labels > 5 chars
},
BASE_WIDTH: 12, // dot + gap
THRESHOLD: 5, // character length threshold
});
const attrs = useAttrs();
const slots = useSlots();
const { t } = useI18n();
const accountLabels = useMapGetter('labels/getLabels');
const containerRef = ref(null);
const maxLabels = ref(1);
const activeLabels = computed(() => {
return accountLabels.value.filter(({ title }) =>
props.labels.includes(title)
);
const labelSet = new Set(props.conversationLabels);
return props.accountLabels?.filter(({ title }) => labelSet.has(title));
});
const showAllLabels = ref(false);
const showExpandLabelButton = ref(false);
const labelPosition = ref(-1);
const labelContainer = ref(null);
const calculateLabelWidth = ({ title = '' }) => {
const charWidth =
title.length > WIDTH_CONFIG.THRESHOLD
? WIDTH_CONFIG.CHAR_WIDTH.LONG
: WIDTH_CONFIG.CHAR_WIDTH.SHORT;
// Show if there are labels OR if before slot exists
const showSection = computed(
() => activeLabels.value.length > 0 || !!slots.before
);
const computeVisibleLabelPosition = () => {
const container = labelContainer.value;
if (!container || activeLabels.value.length === 0) {
showExpandLabelButton.value = false;
return;
}
const labels = container.querySelectorAll('[data-label]');
if (labels.length === 0) {
showExpandLabelButton.value = false;
return;
}
// Early exit if all labels are visible
if (showAllLabels.value) return;
const beforeSlot = container.querySelector('[data-before-slot]');
const beforeSlotWidth = beforeSlot?.offsetWidth ?? 0;
const availableWidth = container.clientWidth - 46 - beforeSlotWidth;
let totalWidth = 0;
const labelsArray = Array.from(labels);
// Find last visible label index using some() - stops early on overflow
const overflowIndex = labelsArray.findIndex(label => {
totalWidth += label.offsetWidth + 6;
return totalWidth > availableWidth;
});
const visibleIndex =
overflowIndex === -1 ? labelsArray.length - 1 : overflowIndex - 1;
labelPosition.value = visibleIndex;
showExpandLabelButton.value = visibleIndex < labelsArray.length - 1;
return title.length * charWidth + WIDTH_CONFIG.BASE_WIDTH;
};
const throttledCalculate = useThrottleFn(computeVisibleLabelPosition, 16);
const getAverageWidth = labels => {
if (!labels.length) return WIDTH_CONFIG.DEFAULT_WIDTH;
watch(activeLabels, () => nextTick(throttledCalculate), { immediate: true });
const totalWidth = labels.reduce(
(sum, label) => sum + calculateLabelWidth(label),
0
);
// Recalculate when parent triggers (e.g., tab changes in ChatList)
// Needed because inbox conversation view shows metadata inside CardLabels, affecting available width
const recalculateKey = inject('recalculateLabelsKey', null);
return totalWidth / labels.length;
};
if (recalculateKey) {
watch(recalculateKey, () => nextTick(throttledCalculate));
}
const visibleLabels = computed(() =>
activeLabels.value?.slice(0, maxLabels.value)
);
const hiddenLabelsCount = computed(() => {
if (!showExpandLabelButton.value || showAllLabels.value) return 0;
return activeLabels.value.length - labelPosition.value - 1;
});
const updateVisibleLabels = () => {
if (!containerRef.value) return;
// Check if all labels are hidden (none visible)
const allLabelsHidden = computed(() => {
return labelPosition.value === -1 && activeLabels.value.length > 0;
});
const containerWidth = containerRef.value.offsetWidth;
const avgWidth = getAverageWidth(activeLabels.value);
// Label text for button when disableToggle is true and all labels are hidden
const labelsCountText = computed(() => {
if (props.disableToggle && allLabelsHidden.value) {
return t('CONVERSATION.CARD.LABELS_COUNT', {
count: activeLabels.value.length,
});
}
if (!showAllLabels.value && hiddenLabelsCount.value > 0) {
return hiddenLabelsCount.value;
}
return '';
});
const hiddenLabelsTooltip = computed(() => {
if (!props.disableToggle) return '';
// When all labels are hidden, show all label titles
if (allLabelsHidden.value) {
return activeLabels.value.map(label => label.title).join(', ');
}
if (!showExpandLabelButton.value) return '';
const hiddenLabels = activeLabels.value.slice(labelPosition.value + 1);
return hiddenLabels.map(label => label.title).join(', ');
});
const tooltipText = computed(() => {
if (props.disableToggle && hiddenLabelsTooltip.value) {
return hiddenLabelsTooltip.value;
}
return showAllLabels.value
? t('CONVERSATION.CARD.HIDE_LABELS')
: t('CONVERSATION.CARD.SHOW_LABELS');
});
const onShowLabels = e => {
e.stopPropagation();
if (props.disableToggle) return;
showAllLabels.value = !showAllLabels.value;
nextTick(() => computeVisibleLabelPosition());
maxLabels.value = Math.max(1, Math.floor(containerWidth / avgWidth));
};
</script>
<template>
<div
v-if="showSection"
ref="labelContainer"
v-bind="attrs"
v-resize="throttledCalculate"
data-labels-container
class="flex items-center flex-shrink min-w-0 min-h-6 gap-x-1.5 gap-y-1 [&:not(:has([data-label],[data-before-slot]))]:hidden"
:class="{ 'h-auto overflow-visible flex-row flex-wrap': showAllLabels }"
ref="containerRef"
v-resize="updateVisibleLabels"
class="flex items-center gap-2.5 w-full min-w-0 h-6 overflow-hidden"
>
<slot name="before" />
<Label
v-for="(label, index) in activeLabels"
:key="label ? label.id : index"
data-label
:label="label"
compact
:class="{
'invisible absolute': !showAllLabels && index > labelPosition,
}"
/>
<Button
v-if="showExpandLabelButton || (disableToggle && allLabelsHidden)"
v-tooltip.top="{
content: tooltipText,
delay: { show: 1000, hide: 0 },
}"
:label="labelsCountText"
xs
slate
:no-animation="disableToggle"
:icon="labelsCountText ? 'i-lucide-plus' : 'i-lucide-chevron-left'"
class="!py-0 !px-1.5 flex-shrink-0 !rounded-md !bg-n-button-color -outline-offset-1 !gap-0.5 [&>span:first-child]:!text-n-slate-10 [&>span:last-child]:!text-n-slate-11"
:class="{ 'cursor-default': disableToggle }"
@click="onShowLabels"
/>
<template v-for="(label, index) in visibleLabels" :key="label.id">
<div
class="flex items-center gap-1.5 min-w-0"
:class="[
index !== visibleLabels.length - 1
? 'flex-shrink-0 text-ellipsis'
: 'flex-shrink',
]"
>
<div
:style="{ backgroundColor: label.color }"
class="size-1.5 rounded-full flex-shrink-0"
/>
<span
class="text-sm text-n-slate-10 whitespace-nowrap"
:class="{ truncate: index === visibleLabels.length - 1 }"
>
{{ label.title }}
</span>
</div>
</template>
</div>
<template v-else />
</template>
@@ -0,0 +1,67 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const props = defineProps({
conversation: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const { getPlainText } = useMessageFormatter();
const lastNonActivityMessageContent = computed(() => {
const { lastNonActivityMessage = {}, customAttributes = {} } =
props.conversation;
const { email: { subject } = {} } = customAttributes;
return getPlainText(
subject || lastNonActivityMessage?.content || t('CHAT_LIST.NO_CONTENT')
);
});
const assignee = computed(() => {
const { meta: { assignee: agent = {} } = {} } = props.conversation;
return {
name: agent.name ?? agent.availableName,
thumbnail: agent.thumbnail,
status: agent.availabilityStatus,
};
});
const unreadMessagesCount = computed(() => {
const { unreadCount } = props.conversation;
return unreadCount;
});
</script>
<template>
<div class="flex items-end w-full gap-2 pb-1">
<p class="w-full mb-0 text-sm leading-7 text-n-slate-12 line-clamp-2">
{{ lastNonActivityMessageContent }}
</p>
<div class="flex items-center flex-shrink-0 gap-2 pb-2">
<Avatar
v-if="assignee.name"
:name="assignee.name"
:src="assignee.thumbnail"
:size="20"
:status="assignee.status"
rounded-full
/>
<div
v-if="unreadMessagesCount > 0"
class="inline-flex items-center justify-center rounded-full size-5 bg-n-brand"
>
<span class="text-xs font-semibold text-white">
{{ unreadMessagesCount }}
</span>
</div>
</div>
</div>
</template>
@@ -0,0 +1,108 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import CardLabels from 'dashboard/components-next/Conversation/ConversationCard/CardLabels.vue';
import SLACardLabel from 'dashboard/components-next/Conversation/ConversationCard/SLACardLabel.vue';
const props = defineProps({
conversation: {
type: Object,
required: true,
},
accountLabels: {
type: Array,
required: true,
},
});
const { t } = useI18n();
const slaCardLabelRef = ref(null);
const { getPlainText } = useMessageFormatter();
const lastNonActivityMessageContent = computed(() => {
const { lastNonActivityMessage = {}, customAttributes = {} } =
props.conversation;
const { email: { subject } = {} } = customAttributes;
return getPlainText(
subject || lastNonActivityMessage?.content || t('CHAT_LIST.NO_CONTENT')
);
});
const assignee = computed(() => {
const { meta: { assignee: agent = {} } = {} } = props.conversation;
return {
name: agent.name ?? agent.availableName,
thumbnail: agent.thumbnail,
status: agent.availabilityStatus,
};
});
const unreadMessagesCount = computed(() => {
const { unreadCount } = props.conversation;
return unreadCount;
});
const hasSlaThreshold = computed(() => {
return (
slaCardLabelRef.value?.hasSlaThreshold && props.conversation?.slaPolicyId
);
});
defineExpose({
hasSlaThreshold,
});
</script>
<template>
<div class="flex flex-col w-full gap-1">
<div class="flex items-center justify-between w-full gap-2 py-1 h-7">
<p class="mb-0 text-sm leading-7 text-n-slate-12 line-clamp-1">
{{ lastNonActivityMessageContent }}
</p>
<div
v-if="unreadMessagesCount > 0"
class="inline-flex items-center justify-center flex-shrink-0 rounded-full size-5 bg-n-brand"
>
<span class="text-xs font-semibold text-white">
{{ unreadMessagesCount }}
</span>
</div>
</div>
<div
class="grid items-center gap-2.5 h-7"
:class="
hasSlaThreshold
? 'grid-cols-[auto_auto_1fr_20px]'
: 'grid-cols-[1fr_20px]'
"
>
<SLACardLabel
v-show="hasSlaThreshold"
ref="slaCardLabelRef"
:conversation="conversation"
/>
<div v-if="hasSlaThreshold" class="w-px h-3 bg-n-slate-4" />
<div class="overflow-hidden">
<CardLabels
:conversation-labels="conversation.labels"
:account-labels="accountLabels"
/>
</div>
<Avatar
v-if="assignee.name"
:name="assignee.name"
:src="assignee.thumbnail"
:size="20"
:status="assignee.status"
rounded-full
/>
</div>
</div>
</template>
@@ -1,103 +0,0 @@
<script setup>
import { computed, useTemplateRef } from 'vue';
import Avatar from 'next/avatar/Avatar.vue';
import InboxName from 'dashboard/components-next/Conversation/InboxName.vue';
import CardPriorityIcon from './CardPriorityIcon.vue';
import CardStatusIcon from './CardStatusIcon.vue';
import SLACardLabel from 'dashboard/components-next/Conversation/Sla/SLACardLabel.vue';
const props = defineProps({
chat: { type: Object, required: true },
inbox: { type: Object, required: true },
showInboxName: { type: Boolean, default: false },
showAssignee: { type: Boolean, default: false },
assignee: { type: Object, default: () => ({}) },
inline: { type: Boolean, default: false },
isLabelsEmpty: { type: Boolean, default: false },
});
const slaCardLabel = useTemplateRef('slaCardLabel');
const hasSlaPolicyId = computed(
() => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold
);
const showSection = computed(() => {
if (props.inline) {
return (
hasSlaPolicyId.value ||
props.chat.priority ||
(props.showAssignee && props.assignee.name)
);
}
return (
props.showInboxName ||
(props.showAssignee && props.assignee.name) ||
props.chat.priority ||
props.chat.status ||
hasSlaPolicyId.value
);
});
</script>
<template>
<!-- Full meta section (when not inline) -->
<div
v-if="showSection && !inline"
class="flex items-center justify-between min-w-0 gap-8 h-6 mb-1"
>
<div class="flex-1 min-w-0 flex items-center gap-0.5">
<InboxName v-if="showInboxName" :inbox="inbox" class="min-w-0" />
</div>
<div class="flex items-center gap-1.5 flex-shrink-0">
<CardPriorityIcon
v-if="chat.priority"
:priority="chat.priority"
class="flex-shrink-0"
/>
<Avatar
v-if="showAssignee && assignee.name"
v-tooltip.top="{
content: assignee.name,
delay: { show: 500, hide: 0 },
}"
:name="assignee.name"
:src="assignee.thumbnail"
:size="14"
:status="assignee.availability_status"
hide-offline-status
rounded-full
/>
<CardStatusIcon v-if="chat.status" :status="chat.status" />
</div>
</div>
<!-- Inline meta section (compact mode for cases in inbox and label filter view) -->
<div
v-else-if="showSection && inline"
data-before-slot
class="flex items-center gap-1.5 flex-shrink-0"
>
<div class="flex items-center gap-1.5">
<CardPriorityIcon
v-if="chat.priority"
:priority="chat.priority"
class="flex-shrink-0"
/>
<Avatar
v-if="showAssignee && assignee.name"
v-tooltip.top="assignee.name"
:name="assignee.name"
:src="assignee.thumbnail"
:size="14"
:status="assignee.availability_status"
hide-offline-status
rounded-full
/>
<CardStatusIcon v-if="chat.status" :status="chat.status" />
</div>
<div v-if="hasSlaPolicyId" class="rounded-lg h-2 w-px bg-n-strong mx-0.5" />
<SLACardLabel v-if="hasSlaPolicyId" ref="slaCardLabel" :chat="chat" />
<div v-if="!isLabelsEmpty" class="rounded-lg h-2 w-px bg-n-strong mx-0.5" />
</div>
</template>
@@ -1,42 +1,207 @@
<script setup>
import { computed } from 'vue';
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
defineProps({
priority: {
type: String,
default: '',
},
showEmpty: {
type: Boolean,
default: false,
},
});
const icons = {
[CONVERSATION_PRIORITY.URGENT]: 'i-woot-priority-urgent',
[CONVERSATION_PRIORITY.HIGH]: 'i-woot-priority-high',
[CONVERSATION_PRIORITY.MEDIUM]: 'i-woot-priority-medium',
[CONVERSATION_PRIORITY.LOW]: 'i-woot-priority-low',
};
const iconName = computed(() => {
if (props.priority && icons[props.priority]) {
return icons[props.priority];
}
return props.showEmpty ? 'i-woot-priority-empty' : '';
});
</script>
<!-- eslint-disable vue/no-static-inline-styles -->
<template>
<Icon
v-tooltip.top="{
content: priority,
delay: { show: 500, hide: 0 },
}"
:icon="iconName"
class="size-4 text-n-slate-5"
/>
<div class="inline-flex items-center justify-center rounded-md">
<!-- Low Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.LOW"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_2030_12879"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2030_12879)">
<rect
x="3.33301"
y="10"
width="3.33333"
height="6.66667"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="8.33301"
y="6.6665"
width="3.33333"
height="10"
rx="1.66667"
class="fill-n-slate-6"
/>
<rect
x="13.333"
y="3.3335"
width="3.33333"
height="13.3333"
rx="1.66667"
class="fill-n-slate-6"
/>
</g>
</svg>
<!-- Medium Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.MEDIUM"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_2030_12879"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2030_12879)">
<rect
x="3.33301"
y="10"
width="3.33333"
height="6.66667"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="8.33301"
y="6.6665"
width="3.33333"
height="10"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="13.333"
y="3.3335"
width="3.33333"
height="13.3333"
rx="1.66667"
class="fill-n-slate-6"
/>
</g>
</svg>
<!-- High Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.HIGH"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_2030_12879"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2030_12879)">
<rect
x="3.33301"
y="10"
width="3.33333"
height="6.66667"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="8.33301"
y="6.6665"
width="3.33333"
height="10"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="13.333"
y="3.3335"
width="3.33333"
height="13.3333"
rx="1.66667"
class="fill-n-amber-9"
/>
</g>
</svg>
<!-- Urgent Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.URGENT"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_2030_12879"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2030_12879)">
<rect
x="3.33301"
y="10"
width="3.33333"
height="6.66667"
rx="1.66667"
class="fill-n-ruby-9"
/>
<rect
x="8.33301"
y="6.6665"
width="3.33333"
height="10"
rx="1.66667"
class="fill-n-ruby-9"
/>
<rect
x="13.333"
y="3.3335"
width="3.33333"
height="13.3333"
rx="1.66667"
class="fill-n-ruby-9"
/>
</g>
</svg>
</div>
</template>
@@ -1,42 +0,0 @@
<script setup>
import { computed } from 'vue';
import { CONVERSATION_STATUS } from 'shared/constants/messages';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
status: {
type: String,
default: '',
},
showEmpty: {
type: Boolean,
default: false,
},
});
const icons = {
[CONVERSATION_STATUS.OPEN]: 'i-woot-status-open',
[CONVERSATION_STATUS.RESOLVED]: 'i-woot-status-resolved',
[CONVERSATION_STATUS.PENDING]: 'i-woot-status-pending',
[CONVERSATION_STATUS.SNOOZED]: 'i-woot-status-snoozed',
};
const iconName = computed(() => {
if (props.status && icons[props.status]) {
return icons[props.status];
}
return props.showEmpty ? 'i-woot-status-empty' : '';
});
</script>
<template>
<Icon
v-tooltip.top="{
content: status,
delay: { show: 500, hide: 0 },
}"
:icon="iconName"
class="size-4 flex-shrink-0"
/>
</template>
@@ -0,0 +1,477 @@
<script setup>
import { computed } from 'vue';
import ConversationCard from './ConversationCard.vue';
// Base conversation object
const conversationWithoutMeta = {
meta: {
sender: {
additionalAttributes: {},
availabilityStatus: 'offline',
email: 'candice@chatwoot.com',
id: 29,
name: 'Candice Matherson',
phone_number: '+918585858585',
identifier: null,
thumbnail: '',
customAttributes: {
linkContact: 'https://apple.com',
listContact: 'Not spam',
textContact: 'hey',
checkboxContact: true,
},
last_activity_at: 1712127410,
created_at: 1712127389,
},
channel: 'Channel::Email',
assignee: {
id: 1,
accountId: 2,
availabilityStatus: 'online',
autoOffline: false,
confirmed: true,
email: 'sivin@chatwoot.com',
availableName: 'Sivin',
name: 'Sivin',
role: 'administrator',
thumbnail: '',
customRoleId: null,
},
hmacVerified: false,
},
id: 38,
messages: [
{
id: 3597,
content: 'Sivin set the priority to low',
accountId: 2,
inboxId: 7,
conversationId: 38,
messageType: 2,
createdAt: 1730885168,
updatedAt: '2024-11-06T09:26:08.565Z',
private: false,
status: 'sent',
source_id: null,
contentType: 'text',
contentAttributes: {},
senderType: null,
senderId: null,
externalSourceIds: {},
additionalAttributes: {},
processedMessageContent: 'Sivin set the priority to low',
sentiment: {},
conversation: {
assigneeId: 1,
unreadCount: 0,
lastActivityAt: 1730885168,
contactInbox: {
sourceId: 'candice@chatwoot.com',
},
},
},
],
accountId: 2,
uuid: '21bd8638-a711-4080-b4ac-7fda1bc71837',
additionalAttributes: {
mail_subject: 'Test email',
},
agentLastSeenAt: 0,
assigneeLastSeenAt: 0,
canReply: true,
contactLastSeenAt: 0,
customAttributes: {},
inboxId: 7,
labels: [],
status: 'open',
createdAt: 1730836533,
timestamp: 1730885168,
firstReplyCreatedAt: 1730836533,
unreadCount: 0,
lastNonActivityMessage: {
id: 3591,
content:
'Hello, I bought some paper but they did not come with the indices as we had assumed. Was there a change in the product line?',
account_id: 2,
inbox_id: 7,
conversation_id: 38,
message_type: 1,
created_at: 1730836533,
updated_at: '2024-11-05T19:55:37.158Z',
private: false,
status: 'sent',
source_id:
'conversation/21bd8638-a711-4080-b4ac-7fda1bc71837/messages/3591@paperlayer.test',
content_type: 'text',
content_attributes: {
cc_emails: ['test@gmail.com'],
bcc_emails: [],
to_emails: [],
},
sender_type: 'User',
sender_id: 1,
external_source_ids: {},
additional_attributes: {},
processed_message_content:
'Hello, I bought some paper but they did not come with the indices as we had assumed. Was there a change in the product line?',
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 0,
last_activity_at: 1730885168,
contact_inbox: {
source_id: 'candice@chatwoot.com',
},
},
sender: {
id: 1,
name: 'Sivin',
available_name: 'Sivin',
avatar_url: '',
type: 'user',
availability_status: 'online',
thumbnail: '',
},
},
lastActivityAt: 1730885168,
priority: 'low',
waitingSince: 0,
slaPolicyId: null,
slaEvents: [],
};
const conversationWithMeta = {
meta: {
sender: {
additionalAttributes: {},
availabilityStatus: 'offline',
email: 'willy@chatwoot.com',
id: 29,
name: 'Willy Castelot',
phoneNumber: '+918585858585',
identifier: null,
thumbnail: '',
customAttributes: {
linkContact: 'https://apple.com',
listContact: 'Not spam',
textContact: 'hey',
checkboxContact: true,
},
lastActivityAt: 1712127410,
createdAt: 1712127389,
},
channel: 'Channel::Email',
assignee: {
id: 1,
accountId: 2,
availabilityStatus: 'online',
autoOffline: false,
confirmed: true,
email: 'sivin@chatwoot.com',
availableName: 'Sivin',
name: 'Sivin',
role: 'administrator',
thumbnail: '',
customRoleId: null,
},
hmacVerified: false,
},
id: 37,
messages: [
{
id: 3599,
content:
'If you want to buy our premium supplies,we can offer you a 20% discount! They come with indices and lazer beams!',
accountId: 2,
inboxId: 7,
conversationId: 37,
messageType: 1,
createdAt: 1730885428,
updatedAt: '2024-11-06T09:30:30.619Z',
private: false,
status: 'sent',
sourceId:
'conversation/53df668d-329d-420e-8fe9-980cb0e4d63c/messages/3599@paperlayer.test',
contentType: 'text',
contentAttributes: {
ccEmails: [],
bccEmails: [],
toEmails: [],
},
sender_type: 'User',
senderId: 1,
externalSourceIds: {},
additionalAttributes: {},
processedMessageContent:
'If you want to buy our premium supplies,we can offer you a 20% discount! They come with indices and lazer beams!',
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 0,
last_activity_at: 1730885428,
contact_inbox: {
source_id: 'candice@chatwoot.com',
},
},
sender: {
id: 1,
name: 'Sivin',
availableName: 'Sivin',
avatarUrl: '',
type: 'user',
availabilityStatus: 'online',
thumbnail: '',
},
},
],
accountId: 2,
uuid: '53df668d-329d-420e-8fe9-980cb0e4d63c',
additionalAttributes: {
mail_subject: 'we',
},
agentLastSeenAt: 1730885428,
assigneeLastSeenAt: 1730885428,
canReply: true,
contactLastSeenAt: 0,
customAttributes: {},
inboxId: 7,
labels: [
'billing',
'delivery',
'lead',
'premium-customer',
'software',
'ops-handover',
],
muted: false,
snoozedUntil: null,
status: 'open',
createdAt: 1722487645,
timestamp: 1730885428,
firstReplyCreatedAt: 1722487645,
unreadCount: 0,
lastNonActivityMessage: {
id: 3599,
content:
'If you want to buy our premium supplies,we can offer you a 20% discount! They come with indices and lazer beams!',
account_id: 2,
inbox_id: 7,
conversation_id: 37,
message_type: 1,
created_at: 1730885428,
updated_at: '2024-11-06T09:30:30.619Z',
private: false,
status: 'sent',
source_id:
'conversation/53df668d-329d-420e-8fe9-980cb0e4d63c/messages/3599@paperlayer.test',
content_type: 'text',
content_attributes: {
cc_emails: [],
bcc_emails: [],
to_emails: [],
},
sender_type: 'User',
sender_id: 1,
external_source_ids: {},
additional_attributes: {},
processed_message_content:
'If you want to buy our premium supplies,we can offer you a 20% discount! They come with indices and lazer beams!',
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 2,
last_activity_at: 1730885428,
contact_inbox: {
source_id: 'willy@chatwoot.com',
},
},
sender: {
id: 1,
name: 'Sivin',
available_name: 'Sivin',
avatar_url: '',
type: 'user',
availability_status: 'online',
thumbnail: '',
},
},
lastActivityAt: 1730885428,
priority: 'urgent',
waitingSince: 1730885428,
slaPolicyId: 3,
appliedSla: {
id: 4,
sla_id: 3,
sla_status: 'active_with_misses',
created_at: 1712127410,
updated_at: 1712127545,
sla_description:
'Premium Service Level Agreements (SLAs) are contracts that define clear expectations ',
sla_name: 'Premium SLA',
sla_first_response_time_threshold: 120,
sla_next_response_time_threshold: 180,
sla_only_during_business_hours: false,
sla_resolution_time_threshold: 360,
},
slaEvents: [
{
id: 8,
event_type: 'frt',
meta: {},
updated_at: 1712127545,
created_at: 1712127545,
},
{
id: 9,
event_type: 'rt',
meta: {},
updated_at: 1712127790,
created_at: 1712127790,
},
],
};
const contactForConversationWithoutMeta = computed(() => ({
availabilityStatus: null,
email: 'candice@chatwoot.com',
id: 29,
name: 'Candice Matherson',
phoneNumber: '+918585858585',
identifier: null,
thumbnail: 'https://api.dicebear.com/9.x/dylan/svg?seed=George',
customAttributes: {},
last_activity_at: 1712127410,
createdAt: 1712127389,
contactInboxes: [],
}));
const contactForConversationWithMeta = computed(() => ({
availabilityStatus: null,
email: 'willy@chatwoot.com',
id: 29,
name: 'Willy Castelot',
phoneNumber: '+918585858585',
identifier: null,
thumbnail: 'https://api.dicebear.com/9.x/dylan/svg?seed=Liam',
customAttributes: {},
lastActivityAt: 1712127410,
createdAt: 1712127389,
contactInboxes: [],
}));
const webWidgetInbox = computed(() => ({
phone_number: '+918585858585',
channel_type: 'Channel::WebWidget',
}));
const accountLabels = computed(() => [
{
id: 1,
title: 'billing',
description: 'Label is used for tagging billing related conversations',
color: '#28AD21',
show_on_sidebar: true,
},
{
id: 3,
title: 'delivery',
description: null,
color: '#A2FDD5',
show_on_sidebar: true,
},
{
id: 6,
title: 'lead',
description: null,
color: '#F161C8',
show_on_sidebar: true,
},
{
id: 4,
title: 'ops-handover',
description: null,
color: '#A53326',
show_on_sidebar: true,
},
{
id: 5,
title: 'premium-customer',
description: null,
color: '#6FD4EF',
show_on_sidebar: true,
},
{
id: 2,
title: 'software',
description: null,
color: '#8F6EF2',
show_on_sidebar: true,
},
]);
</script>
<template>
<Story
title="Components/ConversationCard"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Conversation without meta">
<div class="flex flex-col">
<ConversationCard
:key="conversationWithoutMeta.id"
:conversation="conversationWithoutMeta"
:contact="contactForConversationWithoutMeta"
:state-inbox="webWidgetInbox"
:account-labels="accountLabels"
class="hover:bg-n-alpha-1"
/>
</div>
</Variant>
<Variant title="Conversation with meta (SLA, Labels)">
<div class="flex flex-col">
<ConversationCard
:key="conversationWithMeta.id"
:conversation="{
...conversationWithMeta,
priority: 'medium',
}"
:contact="contactForConversationWithMeta"
:state-inbox="webWidgetInbox"
:account-labels="accountLabels"
class="hover:bg-n-alpha-1"
/>
</div>
</Variant>
<Variant title="Conversation without meta (Unread count)">
<div class="flex flex-col">
<ConversationCard
:key="conversationWithoutMeta.id"
:conversation="{
...conversationWithoutMeta,
unreadCount: 2,
priority: 'high',
}"
:contact="contactForConversationWithoutMeta"
:state-inbox="webWidgetInbox"
:account-labels="accountLabels"
class="hover:bg-n-alpha-1"
/>
</div>
</Variant>
<Variant title="Conversation with meta (SLA, Labels, Unread count)">
<div class="flex flex-col">
<ConversationCard
:key="conversationWithMeta.id"
:conversation="{
...conversationWithMeta,
unreadCount: 2,
}"
:contact="contactForConversationWithMeta"
:state-inbox="webWidgetInbox"
:account-labels="accountLabels"
class="hover:bg-n-alpha-1"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,243 +1,133 @@
<script setup>
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useBreakpoints } from '@vueuse/core';
import { useMapGetter } from 'dashboard/composables/store';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import wootConstants from 'dashboard/constants/globals';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import ConversationContextMenu from 'dashboard/components/widgets/conversation/contextMenu/Index.vue';
import ConversationCardExpanded from './ConversationCardExpanded.vue';
import ConversationCardCompact from './ConversationCardCompact.vue';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import { useRouter, useRoute } from 'vue-router';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper.js';
import { dynamicTime, shortTimestamp } from 'shared/helpers/timeHelper';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import CardMessagePreview from './CardMessagePreview.vue';
import CardMessagePreviewWithMeta from './CardMessagePreviewWithMeta.vue';
import CardPriorityIcon from './CardPriorityIcon.vue';
const props = defineProps({
activeLabel: { type: String, default: '' },
chat: { type: Object, default: () => ({}) },
hideThumbnail: { type: Boolean, default: false },
teamId: { type: [String, Number], default: 0 },
foldersId: { type: [String, Number], default: 0 },
showAssignee: { type: Boolean, default: false },
conversationType: { type: String, default: '' },
selected: { type: Boolean, default: false },
compact: { type: Boolean, default: false },
enableContextMenu: { type: Boolean, default: false },
allowedContextMenuOptions: { type: Array, default: () => [] },
enableSelection: { type: Boolean, default: true },
isExpandedLayout: { type: Boolean, default: false },
isPreviousConversations: { type: Boolean, default: false },
conversation: {
type: Object,
required: true,
},
contact: {
type: Object,
required: true,
},
stateInbox: {
type: Object,
required: true,
},
accountLabels: {
type: Array,
required: true,
},
});
const emit = defineEmits([
'contextMenuToggle',
'assignAgent',
'assignLabel',
'assignTeam',
'markAsUnread',
'markAsRead',
'assignPriority',
'updateConversationStatus',
'deleteConversation',
'selectConversation',
'deSelectConversation',
]);
const router = useRouter();
const route = useRoute();
const breakpoints = useBreakpoints({
lg: wootConstants.LARGE_SCREEN_BREAKPOINT,
});
const isLargeScreen = breakpoints.greaterOrEqual('lg');
const cardMessagePreviewWithMetaRef = ref(null);
// Show expanded only when: isExpandedLayout=true AND screen >= 1024px
const showExpanded = computed(() => {
return props.isExpandedLayout && isLargeScreen.value;
});
const currentContact = computed(() => props.contact);
const showContextMenu = ref(false);
const contextMenu = ref({ x: null, y: null });
const currentChat = useMapGetter('getSelectedChat');
const inboxesList = useMapGetter('inboxes/getInboxes');
const activeInbox = useMapGetter('getSelectedInbox');
const accountId = useMapGetter('getCurrentAccountId');
const contactGetter = useMapGetter('contacts/getContact');
const inboxGetter = useMapGetter('inboxes/getInbox');
const chatMetadata = computed(() => props.chat.meta || {});
const assignee = computed(() => chatMetadata.value.assignee || {});
const senderId = computed(() => chatMetadata.value.sender?.id);
const currentContact = computed(() =>
senderId.value ? contactGetter.value(senderId.value) : {}
const currentContactName = computed(() => currentContact.value?.name);
const currentContactThumbnail = computed(() => currentContact.value?.thumbnail);
const currentContactStatus = computed(
() => currentContact.value?.availabilityStatus
);
const hasActiveInbox = computed(() => activeInbox.value);
const isActiveChat = computed(() => currentChat.value.id === props.chat.id);
const inbox = computed(() => {
const inboxId = props.chat.inbox_id;
return inboxId ? inboxGetter.value(inboxId) : {};
const inbox = computed(() => props.stateInbox);
const inboxName = computed(() => inbox.value?.name);
const inboxIcon = computed(() => {
const { channelType, medium } = inbox.value;
return getInboxIconByType(channelType, medium);
});
const showInboxName = computed(() => inboxesList.value.length > 1);
const lastActivityAt = computed(() => {
const timestamp = props.conversation?.timestamp;
return timestamp ? shortTimestamp(dynamicTime(timestamp)) : '';
});
const conversationPath = computed(() => {
return frontendURL(
conversationUrl({
accountId: accountId.value,
activeInbox: activeInbox.value,
id: props.chat.id,
label: props.activeLabel,
teamId: props.teamId,
conversationType: props.conversationType,
foldersId: props.foldersId,
})
const showMessagePreviewWithoutMeta = computed(() => {
const { labels = [] } = props.conversation;
return (
!cardMessagePreviewWithMetaRef.value?.hasSlaThreshold && labels.length === 0
);
});
const onCardClick = e => {
const path = conversationPath.value;
if (!path) return;
const path = frontendURL(
conversationUrl({
accountId: route.params.accountId,
id: props.conversation.id,
})
);
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
window.open(
`${window.chatwootConfig.hostURL}${path}`,
window.chatwootConfig.hostURL + path,
'_blank',
'noopener,noreferrer'
'noopener noreferrer nofollow'
);
return;
}
if (isActiveChat.value) return;
router.push({ path });
};
const onSelectConversation = checked => {
if (checked) {
emit('selectConversation', props.chat.id, inbox.value.id);
} else {
emit('deSelectConversation', props.chat.id, inbox.value.id);
}
};
const openContextMenu = e => {
if (!props.enableContextMenu) return;
e.preventDefault();
emit('contextMenuToggle', true);
contextMenu.value.x = e.pageX || e.clientX;
contextMenu.value.y = e.pageY || e.clientY;
showContextMenu.value = true;
};
const closeContextMenu = () => {
emit('contextMenuToggle', false);
showContextMenu.value = false;
contextMenu.value.x = null;
contextMenu.value.y = null;
};
const onUpdateConversation = (status, snoozedUntil) => {
closeContextMenu();
emit('updateConversationStatus', props.chat.id, status, snoozedUntil);
};
const onAssignAgent = agent => {
emit('assignAgent', agent, [props.chat.id]);
closeContextMenu();
};
const onAssignLabel = label => {
emit('assignLabel', [label.title], [props.chat.id]);
closeContextMenu();
};
const onAssignTeam = team => {
emit('assignTeam', team, props.chat.id);
closeContextMenu();
};
const markAsUnread = () => {
emit('markAsUnread', props.chat.id);
closeContextMenu();
};
const markAsRead = () => {
emit('markAsRead', props.chat.id);
closeContextMenu();
};
const assignPriority = priority => {
emit('assignPriority', priority, props.chat.id);
closeContextMenu();
};
const deleteConversation = () => {
emit('deleteConversation', props.chat.id);
closeContextMenu();
};
</script>
<template>
<!-- Expanded: Only when isExpandedLayout=true AND screen >= 1024px -->
<ConversationCardExpanded
v-if="showExpanded"
:chat="chat"
:current-contact="currentContact"
:assignee="assignee"
:inbox="inbox"
:selected="selected"
:is-active-chat="isActiveChat"
:show-assignee="showAssignee"
:show-inbox-name="showInboxName"
:is-inbox-view="hasActiveInbox && !isPreviousConversations"
@select-conversation="onSelectConversation"
@de-select-conversation="onSelectConversation"
<div
role="button"
class="flex w-full gap-3 px-3 py-4 transition-all duration-300 ease-in-out cursor-pointer"
@click="onCardClick"
@contextmenu="openContextMenu"
/>
<!-- Compact: All other cases (mobile OR isExpandedLayout=false) -->
<ConversationCardCompact
v-else
:chat="chat"
:current-contact="currentContact"
:assignee="assignee"
:inbox="inbox"
:selected="selected"
:is-active-chat="isActiveChat"
:compact="compact"
:show-assignee="showAssignee"
:show-inbox-name="showInboxName"
:hide-thumbnail="hideThumbnail"
:enable-selection="enableSelection"
:is-inbox-view="hasActiveInbox && !isPreviousConversations"
@select-conversation="onSelectConversation"
@click="onCardClick"
@contextmenu="openContextMenu"
/>
<ContextMenu
v-if="showContextMenu"
:x="contextMenu.x"
:y="contextMenu.y"
@close="closeContextMenu"
>
<ConversationContextMenu
:status="chat.status"
:inbox-id="inbox.id"
:priority="chat.priority"
:chat-id="chat.id"
:has-unread-messages="chat.unread_count > 0"
:conversation-url="conversationPath"
:allowed-options="allowedContextMenuOptions"
@update-conversation="onUpdateConversation"
@assign-agent="onAssignAgent"
@assign-label="onAssignLabel"
@assign-team="onAssignTeam"
@mark-as-unread="markAsUnread"
@mark-as-read="markAsRead"
@assign-priority="assignPriority"
@delete-conversation="deleteConversation"
@close="closeContextMenu"
<Avatar
:name="currentContactName"
:src="currentContactThumbnail"
:size="24"
:status="currentContactStatus"
rounded-full
/>
</ContextMenu>
<div class="flex flex-col w-full gap-1 min-w-0">
<div class="flex items-center justify-between h-6 gap-2">
<h4 class="text-base font-medium truncate text-n-slate-12">
{{ currentContactName }}
</h4>
<div class="flex items-center gap-2">
<CardPriorityIcon :priority="conversation.priority || null" />
<div
v-tooltip.left="inboxName"
class="flex items-center justify-center flex-shrink-0 rounded-full bg-n-alpha-2 size-5"
>
<Icon
:icon="inboxIcon"
class="flex-shrink-0 text-n-slate-11 size-3"
/>
</div>
<span class="text-sm text-n-slate-10">
{{ lastActivityAt }}
</span>
</div>
</div>
<CardMessagePreview
v-show="showMessagePreviewWithoutMeta"
:conversation="conversation"
/>
<CardMessagePreviewWithMeta
v-show="!showMessagePreviewWithoutMeta"
ref="cardMessagePreviewWithMetaRef"
:conversation="conversation"
:account-labels="accountLabels"
/>
</div>
</div>
</template>
@@ -1,135 +0,0 @@
<script setup>
import { computed, useTemplateRef } from 'vue';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import CardMetaSection from './CardMetaSection.vue';
import CardAvatar from './CardAvatar.vue';
import CardHeader from './CardHeader.vue';
import CardContent from './CardContent.vue';
import CardLabels from './CardLabels.vue';
import SLACardLabel from 'dashboard/components-next/Conversation/Sla/SLACardLabel.vue';
const props = defineProps({
chat: { type: Object, required: true },
currentContact: { type: Object, required: true },
assignee: { type: Object, default: () => ({}) },
inbox: { type: Object, default: () => ({}) },
selected: { type: Boolean, default: false },
isActiveChat: { type: Boolean, default: false },
compact: { type: Boolean, default: false },
showAssignee: { type: Boolean, default: false },
showInboxName: { type: Boolean, default: false },
hideThumbnail: { type: Boolean, default: false },
enableSelection: { type: Boolean, default: true },
isInboxView: { type: Boolean, default: false },
});
const emit = defineEmits(['selectConversation', 'click', 'contextmenu']);
const slaCardLabel = useTemplateRef('slaCardLabel');
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const showLabelsSection = computed(() => props.chat.labels?.length > 0);
const showExpandedPreview = computed(
() => props.compact && !showLabelsSection.value
);
const voiceCallData = computed(() => ({
status: props.chat.additional_attributes?.call_status,
direction: props.chat.additional_attributes?.call_direction,
}));
const unreadCount = computed(() => props.chat?.unread_count);
const hasSlaPolicyId = computed(
() => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold
);
const onSelectConversation = checked => {
emit('selectConversation', checked);
};
</script>
<template>
<div
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full cursor-pointer group transition-all duration-150"
:class="{
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3': isActiveChat,
'selected bg-n-slate-2 dark:bg-n-slate-3': selected,
'px-0 py-3': compact,
'px-2 pt-2.5 pb-3 hover:bg-n-alpha-1 rounded-lg': !compact,
}"
@click="$emit('click', $event)"
@contextmenu="$emit('contextmenu', $event)"
>
<div class="min-w-0 w-full">
<CardMetaSection
v-if="!isInboxView"
:chat="chat"
:inbox="inbox"
:show-inbox-name="showInboxName"
:show-assignee="showAssignee"
:assignee="assignee"
/>
<div
class="grid gap-2.5 items-start"
:class="{
'mt-0.5 grid-cols-1': compact,
'grid-cols-[auto,1fr]': !compact,
}"
>
<CardAvatar
v-if="!compact"
:contact="currentContact"
:selected="selected"
:enable-selection="enableSelection"
:hide-thumbnail="hideThumbnail"
class="mt-0.5"
@select-conversation="onSelectConversation"
/>
<div class="min-w-0 flex flex-col gap-1.5">
<div class="min-w-0 flex flex-col gap-px">
<CardHeader
v-if="!compact"
:contact-name="currentContact.name"
:timestamp="chat.timestamp"
:created-at="chat.created_at"
/>
<CardContent
:last-message="lastMessageInChat"
:voice-call-status="voiceCallData.status"
:voice-call-direction="voiceCallData.direction"
:unread-count="unreadCount"
:show-expanded-preview="showExpandedPreview"
/>
</div>
<CardLabels
v-if="showLabelsSection || isInboxView"
:labels="chat.labels"
>
<template #before>
<CardMetaSection
v-if="isInboxView"
:chat="chat"
:inbox="inbox"
:show-assignee="showAssignee"
:assignee="assignee"
:is-labels-empty="chat.labels.length === 0"
inline
/>
<SLACardLabel
v-else-if="hasSlaPolicyId"
ref="slaCardLabel"
data-before-slot
:chat="chat"
/>
</template>
</CardLabels>
</div>
</div>
</div>
</div>
</template>
@@ -1,182 +0,0 @@
<script setup>
import { computed, useTemplateRef } from 'vue';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import CardAvatar from './CardAvatar.vue';
import CardContent from './CardContent.vue';
import CardLabels from './CardLabels.vue';
import CardPriorityIcon from './CardPriorityIcon.vue';
import InboxName from 'dashboard/components-next/Conversation/InboxName.vue';
import Avatar from 'next/avatar/Avatar.vue';
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
import SLACardLabel from 'dashboard/components-next/Conversation/Sla/SLACardLabel.vue';
import CardStatusIcon from './CardStatusIcon.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
chat: { type: Object, required: true },
currentContact: { type: Object, required: true },
assignee: { type: Object, default: () => ({}) },
inbox: { type: Object, default: () => ({}) },
selected: { type: Boolean, default: false },
isActiveChat: { type: Boolean, default: false },
showAssignee: { type: Boolean, default: false },
showInboxName: { type: Boolean, default: false },
isInboxView: { type: Boolean, default: false },
});
const emit = defineEmits([
'selectConversation',
'deSelectConversation',
'click',
'contextmenu',
]);
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const showLabelsSection = computed(() => props.chat.labels?.length > 0);
const voiceCallData = computed(() => ({
status: props.chat.additional_attributes?.call_status,
direction: props.chat.additional_attributes?.call_direction,
}));
const unreadCount = computed(() => props.chat.unread_count);
const slaCardLabel = useTemplateRef('slaCardLabel');
const hasSlaPolicyId = computed(
() => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold
);
const selectedModel = computed({
get: () => props.selected,
set: value => {
if (value) {
emit('selectConversation', value);
} else {
emit('deSelectConversation', value);
}
},
});
</script>
<template>
<div
class="relative cursor-pointer group transition-all duration-150 grid gap-4 items-center px-2 h-12"
:class="{
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3': isActiveChat,
'selected bg-n-slate-2 dark:bg-n-slate-3': selected,
'hover:bg-n-alpha-1 rounded-lg': !isActiveChat && !selected,
'grid-cols-[minmax(0,2fr)_minmax(0,1fr)]': showLabelsSection,
'grid-cols-[minmax(0,2fr)_max-content]': !showLabelsSection,
}"
@click="$emit('click', $event)"
@contextmenu="$emit('contextmenu', $event)"
>
<!-- LEFT SECTION -->
<div class="flex items-center gap-2 min-w-0 flex-1">
<div class="flex items-center justify-center flex-shrink-0" @click.stop>
<Checkbox v-model="selectedModel" />
</div>
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
<div class="w-4 flex items-center justify-center flex-shrink-0">
<CardPriorityIcon :priority="chat.priority" show-empty />
</div>
<div class="w-4 flex items-center justify-center flex-shrink-0">
<Avatar
v-if="showAssignee && assignee.name"
v-tooltip.top="{
content: assignee.name,
delay: { show: 500, hide: 0 },
}"
:name="assignee.name"
:src="assignee.thumbnail"
:size="14"
:status="assignee.availability_status"
hide-offline-status
rounded-full
/>
<Icon
v-else
icon="i-woot-empty-assignee"
class="size-4 text-n-slate-7"
/>
</div>
<div class="w-4 flex items-center justify-center flex-shrink-0">
<CardStatusIcon :status="chat.status" show-empty />
</div>
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
<div v-if="!isInboxView" class="w-20 flex-shrink-0">
<InboxName v-if="showInboxName" :inbox="inbox" class="min-w-0" />
</div>
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
<div
v-tooltip.top="{
content: chat.id,
delay: { show: 500, hide: 0 },
}"
class="h-6 flex items-center gap-1 max-w-20 w-full min-w-0"
>
<Icon
icon="i-woot-hash"
class="size-3.5 text-n-slate-10 flex-shrink-0"
/>
<span class="text-label-small text-n-slate-11 truncate">
{{ chat.id }}
</span>
</div>
<CardAvatar
:contact="currentContact"
:selected="false"
:enable-selection="false"
:hide-thumbnail="false"
/>
<h4
class="text-heading-3 my-0 capitalize truncate text-n-slate-12 font-medium w-32 flex-shrink-0"
>
{{ currentContact.name }}
</h4>
<CardContent
:last-message="lastMessageInChat"
:voice-call-status="voiceCallData.status"
:voice-call-direction="voiceCallData.direction"
:unread-count="unreadCount"
:show-expanded-preview="false"
/>
</div>
<!-- RIGHT SECTION -->
<div class="flex items-center justify-end gap-1.5 flex-shrink-0">
<div v-if="showLabelsSection" class="min-w-0 w-full">
<CardLabels
:labels="chat.labels"
disable-toggle
class="my-0 [&>div]:justify-end justify-end"
/>
</div>
<div v-if="hasSlaPolicyId" class="flex-shrink-0">
<SLACardLabel ref="slaCardLabel" :chat="chat" />
</div>
<div class="flex-shrink-0 w-[4.375rem] text-end">
<TimeAgo
:last-activity-timestamp="chat.timestamp"
:created-at-timestamp="chat.created_at"
class="font-440 !text-xs text-n-slate-11"
/>
</div>
</div>
</div>
</template>
@@ -1,156 +0,0 @@
<script setup>
import { computed } from 'vue';
import { MESSAGE_TYPE } from 'widget/helpers/constants';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
message: {
type: Object,
required: true,
},
showMessageType: {
type: Boolean,
default: true,
},
defaultEmptyMessage: {
type: String,
default: '',
},
multiLine: {
type: Boolean,
default: false,
},
});
const { getPlainText } = useMessageFormatter();
const attachmentIcons = {
image: 'i-lucide-image',
audio: 'i-lucide-headphones',
video: 'i-lucide-video',
file: 'i-lucide-file',
location: 'i-lucide-map-pin',
fallback: 'i-lucide-link-2',
};
const messageByAgent = computed(() => {
const { message_type: messageType } = props.message;
return messageType === MESSAGE_TYPE.OUTGOING;
});
const isMessageAnActivity = computed(() => {
const { message_type: messageType } = props.message;
return messageType === MESSAGE_TYPE.ACTIVITY;
});
const isMessagePrivate = computed(() => {
const { private: isPrivate } = props.message;
return isPrivate;
});
const parsedLastMessage = computed(() => {
const { content_attributes: contentAttributes } = props.message;
const { email: { subject } = {} } = contentAttributes || {};
return getPlainText(subject || props.message.content);
});
const lastMessageFileType = computed(() => {
const [{ file_type: fileType } = {}] = props.message.attachments;
return fileType;
});
const attachmentIcon = computed(() => {
return attachmentIcons[lastMessageFileType.value];
});
const attachmentMessageContent = computed(() => {
return `CHAT_LIST.ATTACHMENTS.${lastMessageFileType.value}.CONTENT`;
});
const isMessageSticker = computed(() => {
return props.message && props.message.content_type === 'sticker';
});
</script>
<template>
<div
class="min-w-0 text-sm"
:class="
multiLine
? 'flex items-start gap-1'
: 'grid grid-cols-[auto_1fr] items-center gap-1'
"
>
<template v-if="showMessageType && !multiLine">
<Icon
v-if="isMessagePrivate"
icon="i-lucide-lock-keyhole"
class="size-3.5"
/>
<Icon
v-else-if="messageByAgent"
icon="i-lucide-undo-2"
class="size-3.5"
/>
<Icon
v-else-if="isMessageAnActivity"
icon="i-lucide-info"
class="size-3.5"
/>
</template>
<span
class="min-w-0 text-body-main"
:class="multiLine ? 'line-clamp-2' : 'truncate'"
>
<!-- Case for previous and conversation conversation card -->
<template v-if="showMessageType && multiLine">
<Icon
v-if="isMessagePrivate"
icon="i-lucide-lock-keyhole"
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
/>
<Icon
v-else-if="messageByAgent"
icon="i-lucide-undo-2"
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
/>
<Icon
v-else-if="isMessageAnActivity"
icon="i-lucide-info"
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
/>
</template>
<span
v-if="message.content && isMessageSticker"
class="inline-grid grid-flow-col auto-cols-max items-center gap-1"
>
<Icon icon="i-lucide-image" class="size-3.5" />
{{ $t('CHAT_LIST.ATTACHMENTS.image.CONTENT') }}
</span>
<template v-else-if="message.content">
{{ parsedLastMessage }}
</template>
<span
v-else-if="message.attachments"
class="inline-block align-middle truncate"
>
<Icon
v-if="attachmentIcon && showMessageType"
:icon="attachmentIcon"
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
/>
<span class="inline-block align-middle">
{{ $t(attachmentMessageContent) }}
</span>
</span>
<template v-else>
{{ defaultEmptyMessage || $t('CHAT_LIST.NO_CONTENT') }}
</template>
</span>
</div>
</template>
@@ -1,28 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
count: { type: Number, required: true },
alignBottom: { type: Boolean, default: false },
});
const { t } = useI18n();
const displayCount = computed(() =>
props.count > 9 ? t('CHAT_LIST.UNREAD_COUNT_OVERFLOW') : props.count
);
</script>
<template>
<span
v-if="count > 0"
class="bg-n-blue-9 rounded-full h-4 min-w-4 max-w-5 px-1 w-fit font-medium text-xxs leading-3 text-white inline-grid place-items-center flex-shrink-0"
:class="{
'mb-0.5': alignBottom,
}"
>
{{ displayCount }}
</span>
<span v-else />
</template>
@@ -0,0 +1,17 @@
<script setup>
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
</script>
<template>
<div class="flex justify-between items-center px-4 py-4 w-full">
<div
class="flex justify-center items-center py-6 w-full custom-dashed-border"
>
<span class="text-sm text-n-slate-11">
{{ t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.NO_ATTRIBUTES') }}
</span>
</div>
</div>
</template>
@@ -20,10 +20,10 @@ const showCopilotTab = computed(() =>
const { uiSettings } = useUISettings();
const isContactSidebarOpen = computed(
() => uiSettings.value.is_contact_sidebar_open || false
() => uiSettings.value.is_contact_sidebar_open
);
const isCopilotPanelOpen = computed(
() => uiSettings.value.is_copilot_panel_open || false
() => uiSettings.value.is_copilot_panel_open
);
const toggleConversationSidebarToggle = () => {
@@ -35,7 +35,7 @@ const toggleConversationSidebarToggle = () => {
const handleConversationSidebarToggle = () => {
updateUISettings({
is_contact_sidebar_open: !isContactSidebarOpen.value,
is_contact_sidebar_open: true,
is_copilot_panel_open: false,
});
};
@@ -43,7 +43,7 @@ const handleConversationSidebarToggle = () => {
const handleCopilotSidebarToggle = () => {
updateUISettings({
is_contact_sidebar_open: false,
is_copilot_panel_open: !isCopilotPanelOpen.value,
is_copilot_panel_open: true,
});
};
@@ -1,100 +0,0 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { evaluateSLAStatus } from '@chatwoot/utils';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import SLAPopoverCard from './SLAPopoverCard.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Label from 'dashboard/components-next/label/Label.vue';
const props = defineProps({
chat: {
type: Object,
default: () => ({}),
},
showExtendedInfo: {
type: Boolean,
default: false,
},
});
const REFRESH_INTERVAL = 60000;
const timer = ref(null);
const slaStatus = ref({
threshold: null,
isSlaMissed: false,
type: null,
icon: null,
});
defineOptions({
inheritAttrs: false,
});
const appliedSLA = computed(() => props.chat?.applied_sla);
const slaEvents = computed(() => props.chat?.sla_events);
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
const conversation = computed(() => useCamelCase(props.chat, { deep: true }));
const showSlaPopoverCard = computed(
() => props.showExtendedInfo && slaEvents.value?.length > 0
);
const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
appliedSla: appliedSLA.value || {},
chat: conversation.value,
});
};
const createTimer = () => {
timer.value = setTimeout(() => {
updateSlaStatus();
createTimer();
}, REFRESH_INTERVAL);
};
onMounted(() => {
updateSlaStatus();
createTimer();
});
onUnmounted(() => {
if (timer.value) {
clearTimeout(timer.value);
}
});
watch(() => props.chat, updateSlaStatus);
defineExpose({
hasSlaThreshold,
});
</script>
<template>
<div
v-if="hasSlaThreshold"
v-bind="$attrs"
class="relative flex items-center cursor-pointer min-w-fit group"
>
<Label
:label="slaStatus.threshold"
:color="isSlaMissed ? 'ruby' : 'amber'"
compact
>
<template #icon>
<Icon icon="i-lucide-flame" class="flex-shrink-0 size-3.5" />
</template>
</Label>
<SLAPopoverCard
v-if="showSlaPopoverCard"
:sla-missed-events="slaEvents"
class="start-0 md:start-auto md:end-0 top-7 hidden group-hover:flex"
/>
</div>
<template v-else />
</template>
@@ -1,10 +1,8 @@
<script setup>
import { computed } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Label from 'dashboard/components-next/label/Label.vue';
import AttributeBadge from 'dashboard/components-next/CustomAttributes/AttributeBadge.vue';
import { computed } from 'vue';
const props = defineProps({
attribute: {
@@ -20,7 +18,7 @@ const props = defineProps({
const emit = defineEmits(['edit', 'delete']);
const iconByType = {
text: 'i-lucide-menu',
text: 'i-lucide-align-justify',
checkbox: 'i-lucide-circle-check-big',
list: 'i-lucide-list',
date: 'i-lucide-calendar',
@@ -30,60 +28,61 @@ const iconByType = {
const attributeIcon = computed(() => {
const typeKey = props.attribute.type?.toLowerCase();
return iconByType[typeKey] || 'i-lucide-menu';
return iconByType[typeKey] || 'i-lucide-align-justify';
});
</script>
<template>
<div class="flex flex-col py-4 divide-y divide-n-weak">
<div class="flex justify-between flex-row items-center gap-4 min-w-0">
<div class="flex items-center gap-4 min-w-0">
<div
class="flex items-center flex-shrink-0 size-10 justify-center rounded-xl outline outline-1 outline-n-weak -outline-offset-1"
>
<Icon :icon="attributeIcon" class="size-4 text-n-slate-11" />
</div>
<div class="flex flex-col gap-1.5 items-start min-w-0">
<div class="flex items-center gap-2">
<h4 class="text-heading-3 truncate text-n-slate-12">
{{ attribute.label }}
</h4>
<div class="flex items-center gap-1.5">
<Label :label="attribute.type" compact />
<AttributeBadge
v-for="badge in badges"
:key="badge.type"
:type="badge.type"
/>
</div>
<div
class="flex flex-col gap-2 p-4 bg-n-solid-1 rounded-2xl outline outline-1 outline-n-container"
>
<div class="flex flex-wrap gap-2 justify-between items-center">
<div class="flex flex-wrap gap-2 items-center min-w-0">
<h4 class="text-sm font-medium truncate text-n-slate-12">
{{ attribute.label }}
</h4>
<div class="w-px h-3 bg-n-strong" />
<div class="flex gap-2 items-center text-sm text-n-slate-11">
<div class="flex items-center gap-1.5 text-n-slate-11">
<Icon :icon="attributeIcon" class="size-4" />
<span class="text-sm">{{ attribute.type }}</span>
</div>
<div class="grid grid-cols-[auto_1fr] items-center gap-1.5">
<Icon icon="i-lucide-key-round" class="size-3.5 text-n-slate-11" />
<div class="flex items-center gap-2 min-w-0">
<span class="text-body-main text-n-slate-11 truncate">
{{ attribute.value }}
</span>
<template
v-if="attribute.attribute_description || attribute.description"
>
<div class="w-px h-3 rounded-lg bg-n-weak flex-shrink-0" />
<span class="text-body-main text-n-slate-11 truncate">
{{ attribute.attribute_description || attribute.description }}
</span>
</template>
</div>
<div class="w-px h-3 bg-n-weak" />
<div class="flex items-center gap-1.5 text-n-slate-11">
<Icon icon="i-lucide-key-round" class="size-4" />
<span class="line-clamp-1 text-sm">{{ attribute.value }}</span>
</div>
</div>
</div>
<div class="flex gap-3 justify-end flex-shrink-0">
<div class="flex gap-2 items-center">
<AttributeBadge
v-for="badge in badges"
:key="badge.type"
:type="badge.type"
/>
<div
v-if="badges.length > 0"
class="w-px h-3 bg-n-strong ltr:ml-1.5 rtl:mr-1.5"
/>
<Button
icon="i-woot-edit-pen"
slate
sm
icon="i-lucide-pencil-line"
size="sm"
color="slate"
ghost
@click="emit('edit', attribute)"
/>
<Button icon="i-woot-bin" slate sm @click="emit('delete', attribute)" />
<div class="w-px h-3 bg-n-strong" />
<Button
icon="i-lucide-trash"
size="sm"
color="slate"
ghost
@click="emit('delete', attribute)"
/>
</div>
</div>
<p class="mb-0 text-sm text-n-slate-11">
{{ attribute.attribute_description || attribute.description || '' }}
</p>
</div>
</template>
@@ -0,0 +1,56 @@
<script setup>
import { computed } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { ATTRIBUTE_TYPES } from './constants';
const props = defineProps({
attribute: {
type: Object,
required: true,
},
});
const emit = defineEmits(['delete']);
const iconByType = {
[ATTRIBUTE_TYPES.TEXT]: 'i-lucide-align-justify',
[ATTRIBUTE_TYPES.CHECKBOX]: 'i-lucide-circle-check-big',
[ATTRIBUTE_TYPES.LIST]: 'i-lucide-list',
[ATTRIBUTE_TYPES.DATE]: 'i-lucide-calendar',
[ATTRIBUTE_TYPES.LINK]: 'i-lucide-link',
[ATTRIBUTE_TYPES.NUMBER]: 'i-lucide-hash',
};
const attributeIcon = computed(() => {
const typeKey = props.attribute.type?.toLowerCase();
return iconByType[typeKey] || 'i-lucide-align-justify';
});
const handleDelete = () => {
emit('delete', props.attribute);
};
</script>
<template>
<div class="flex justify-between items-center px-4 py-3 w-full">
<div class="flex gap-3 items-center">
<h5 class="text-sm font-medium text-n-slate-12 line-clamp-1">
{{ attribute.label }}
</h5>
<div class="w-px h-2.5 bg-n-slate-5" />
<div class="flex gap-1.5 items-center">
<Icon :icon="attributeIcon" class="size-4 text-n-slate-11" />
<span class="text-sm text-n-slate-11">{{ attribute.type }}</span>
</div>
<div class="w-px h-2.5 bg-n-slate-5" />
<div class="flex gap-1.5 items-center">
<Icon icon="i-lucide-key-round" class="size-4 text-n-slate-11" />
<span class="text-sm text-n-slate-11">{{ attribute.value }}</span>
</div>
</div>
<div class="flex gap-2 items-center">
<Button icon="i-lucide-trash" sm slate ghost @click.stop="handleDelete" />
</div>
</div>
</template>
@@ -0,0 +1,186 @@
<script setup>
import { computed } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useAlert } from 'dashboard/composables';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import ConversationRequiredAttributeItem from 'dashboard/components-next/ConversationWorkflow/ConversationRequiredAttributeItem.vue';
import ConversationRequiredEmpty from 'dashboard/components-next/Conversation/ConversationRequiredEmpty.vue';
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
const props = defineProps({
isEnabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['click']);
const router = useRouter();
const { t } = useI18n();
const { currentAccount, accountId, isOnChatwootCloud, updateAccount } =
useAccount();
const [showDropdown, toggleDropdown] = useToggle(false);
const [isSaving, toggleSaving] = useToggle(false);
const conversationAttributes = useMapGetter(
'attributes/getConversationAttributes'
);
const currentUser = useMapGetter('getCurrentUser');
const isSuperAdmin = computed(() => currentUser.value.type === 'SuperAdmin');
const showPaywall = computed(() => !props.isEnabled && isOnChatwootCloud.value);
const i18nKey = computed(() =>
isOnChatwootCloud.value ? 'PAYWALL' : 'ENTERPRISE_PAYWALL'
);
const goToBillingSettings = () => {
router.push({
name: 'billing_settings_index',
params: { accountId: accountId.value },
});
};
const handleClick = () => {
emit('click');
};
const selectedAttributeKeys = computed(
() => currentAccount.value?.settings?.conversation_required_attributes || []
);
const allAttributeOptions = computed(() =>
(conversationAttributes.value || []).map(attribute => ({
...attribute,
action: 'add',
value: attribute.attributeKey,
label: attribute.attributeDisplayName,
type: attribute.attributeDisplayType,
}))
);
const attributeOptions = computed(() => {
const selectedKeysSet = new Set(selectedAttributeKeys.value);
return allAttributeOptions.value.filter(
attribute => !selectedKeysSet.has(attribute.value)
);
});
const conversationRequiredAttributes = computed(() => {
const attributeMap = new Map(
allAttributeOptions.value.map(attr => [attr.value, attr])
);
return selectedAttributeKeys.value
.map(key => attributeMap.get(key))
.filter(Boolean);
});
const handleAddAttributesClick = event => {
event.stopPropagation();
toggleDropdown();
};
const saveRequiredAttributes = async keys => {
try {
toggleSaving(true);
await updateAccount(
{ conversation_required_attributes: keys },
{ silent: true }
);
useAlert(t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.SAVE.SUCCESS'));
} catch (error) {
useAlert(t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.SAVE.ERROR'));
} finally {
toggleSaving(false);
toggleDropdown(false);
}
};
const handleAttributeAction = ({ value }) => {
if (!value || isSaving.value) return;
const updatedKeys = Array.from(
new Set([...selectedAttributeKeys.value, value])
);
saveRequiredAttributes(updatedKeys);
};
const closeDropdown = () => {
toggleDropdown(false);
};
const handleDelete = attribute => {
if (isSaving.value) return;
const updatedKeys = selectedAttributeKeys.value.filter(
key => key !== attribute.value
);
saveRequiredAttributes(updatedKeys);
};
</script>
<template>
<div
v-if="isEnabled || showPaywall"
class="flex flex-col w-full outline-1 outline outline-n-container rounded-xl bg-n-solid-2 divide-y divide-n-weak"
@click="handleClick"
>
<div class="flex flex-col gap-2 items-start px-5 py-4">
<div class="flex justify-between items-center w-full">
<div class="flex flex-col gap-2">
<h3 class="text-base font-medium text-n-slate-12">
{{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.TITLE') }}
</h3>
<p class="mb-0 text-sm text-n-slate-11">
{{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.DESCRIPTION') }}
</p>
</div>
<div v-if="isEnabled" v-on-clickaway="closeDropdown" class="relative">
<Button
icon="i-lucide-circle-plus"
:label="$t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.ADD.TITLE')"
:is-loading="isSaving"
:disabled="isSaving || attributeOptions.length === 0"
@click="handleAddAttributesClick"
/>
<DropdownMenu
v-if="showDropdown"
:menu-items="attributeOptions"
show-search
:search-placeholder="
$t(
'CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.ADD.SEARCH_PLACEHOLDER'
)
"
class="top-full mt-1 w-52 ltr:right-0 rtl:left-0"
@action="handleAttributeAction"
/>
</div>
</div>
</div>
<template v-if="isEnabled">
<ConversationRequiredEmpty
v-if="conversationRequiredAttributes.length === 0"
/>
<ConversationRequiredAttributeItem
v-for="attribute in conversationRequiredAttributes"
:key="attribute.value"
:attribute="attribute"
@delete="handleDelete"
/>
</template>
<BasePaywallModal
v-else
class="mx-auto my-8"
feature-prefix="CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES"
:i18n-key="i18nKey"
:is-on-chatwoot-cloud="isOnChatwootCloud"
:is-super-admin="isSuperAdmin"
@upgrade="goToBillingSettings"
/>
</div>
</template>
@@ -0,0 +1,248 @@
<script setup>
import { ref, computed, reactive } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, url, helpers } from '@vuelidate/validators';
import { getRegexp } from 'shared/helpers/Validators';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import TextArea from 'next/textarea/TextArea.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import ChoiceToggle from 'dashboard/components-next/input/ChoiceToggle.vue';
import { ATTRIBUTE_TYPES } from './constants';
const emit = defineEmits(['submit']);
const { t } = useI18n();
const dialogRef = ref(null);
const visibleAttributes = ref([]);
const formValues = reactive({});
const conversationContext = ref(null);
const placeholders = computed(() => ({
text: t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.TEXT'),
number: t(
'CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.NUMBER'
),
link: t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.LINK'),
date: t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.DATE'),
list: t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.LIST'),
}));
const getPlaceholder = type => placeholders.value[type] || '';
const validationRules = computed(() => {
const rules = {};
visibleAttributes.value.forEach(attribute => {
if (attribute.type === ATTRIBUTE_TYPES.LINK) {
rules[attribute.value] = { required, url };
} else if (attribute.type === ATTRIBUTE_TYPES.CHECKBOX) {
// Checkbox doesn't need validation - any selection is valid
rules[attribute.value] = {};
} else {
rules[attribute.value] = { required };
if (attribute.regexPattern) {
rules[attribute.value].regexValidation = helpers.withParams(
{ regexCue: attribute.regexCue },
value => !value || getRegexp(attribute.regexPattern).test(value)
);
}
}
});
return rules;
});
const v$ = useVuelidate(validationRules, formValues);
const getErrorMessage = attributeKey => {
const field = v$.value[attributeKey];
if (!field || !field.$error) return '';
if (field.url && field.url.$invalid) {
return t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_URL');
}
if (field.regexValidation && field.regexValidation.$invalid) {
return (
field.regexValidation.$params?.regexCue ||
t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_INPUT')
);
}
if (field.required && field.required.$invalid) {
return t('CUSTOM_ATTRIBUTES.VALIDATIONS.REQUIRED');
}
return '';
};
const isFormComplete = computed(() =>
visibleAttributes.value.every(attribute => {
const value = formValues[attribute.value];
// For checkbox attributes, ensure the agent has explicitly selected a value
if (attribute.type === ATTRIBUTE_TYPES.CHECKBOX) {
return formValues[attribute.value] !== null;
}
// For other attribute types, check for valid non-empty values
return value !== undefined && value !== null && String(value).trim() !== '';
})
);
const comboBoxOptions = computed(() => {
const options = {};
visibleAttributes.value.forEach(attribute => {
if (attribute.type === ATTRIBUTE_TYPES.LIST) {
options[attribute.value] = (attribute.attributeValues || []).map(
option => ({
value: option,
label: option,
})
);
}
});
return options;
});
const close = () => {
dialogRef.value?.close();
conversationContext.value = null;
v$.value.$reset();
};
const open = (attributes = [], initialValues = {}, context = null) => {
visibleAttributes.value = attributes;
conversationContext.value = context;
// Clear existing formValues
Object.keys(formValues).forEach(key => {
delete formValues[key];
});
// Initialize form values
attributes.forEach(attribute => {
const presetValue = initialValues[attribute.value];
if (presetValue !== undefined && presetValue !== null) {
formValues[attribute.value] = presetValue;
} else {
// For checkbox attributes, initialize to null to avoid pre-selection
// For other attributes, initialize to empty string
formValues[attribute.value] =
attribute.type === ATTRIBUTE_TYPES.CHECKBOX ? null : '';
}
});
v$.value.$reset();
dialogRef.value?.open();
};
const handleConfirm = async () => {
v$.value.$touch();
if (v$.value.$invalid) {
return;
}
emit('submit', {
attributes: { ...formValues },
context: conversationContext.value,
});
close();
};
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
width="lg"
:title="t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.TITLE')"
:description="
t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.DESCRIPTION')
"
:confirm-button-label="
t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.ACTIONS.RESOLVE')
"
:cancel-button-label="
t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.ACTIONS.CANCEL')
"
:disable-confirm-button="!isFormComplete"
@confirm="handleConfirm"
>
<div class="flex flex-col gap-4">
<div
v-for="attribute in visibleAttributes"
:key="attribute.value"
class="flex flex-col gap-2"
>
<div class="flex justify-between items-center">
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ attribute.label }}
</label>
</div>
<template v-if="attribute.type === ATTRIBUTE_TYPES.TEXT">
<TextArea
v-model="formValues[attribute.value]"
class="w-full"
:placeholder="getPlaceholder(ATTRIBUTE_TYPES.TEXT)"
:message="getErrorMessage(attribute.value)"
:message-type="v$[attribute.value].$error ? 'error' : 'info'"
@blur="v$[attribute.value].$touch"
/>
</template>
<template v-else-if="attribute.type === ATTRIBUTE_TYPES.NUMBER">
<Input
v-model="formValues[attribute.value]"
type="number"
size="md"
:placeholder="getPlaceholder(ATTRIBUTE_TYPES.NUMBER)"
:message="getErrorMessage(attribute.value)"
:message-type="v$[attribute.value].$error ? 'error' : 'info'"
@blur="v$[attribute.value].$touch"
/>
</template>
<template v-else-if="attribute.type === ATTRIBUTE_TYPES.LINK">
<Input
v-model="formValues[attribute.value]"
type="url"
size="md"
:placeholder="getPlaceholder(ATTRIBUTE_TYPES.LINK)"
:message="getErrorMessage(attribute.value)"
:message-type="v$[attribute.value].$error ? 'error' : 'info'"
@blur="v$[attribute.value].$touch"
/>
</template>
<template v-else-if="attribute.type === ATTRIBUTE_TYPES.DATE">
<Input
v-model="formValues[attribute.value]"
type="date"
size="md"
:placeholder="getPlaceholder(ATTRIBUTE_TYPES.DATE)"
:message="getErrorMessage(attribute.value)"
:message-type="v$[attribute.value].$error ? 'error' : 'info'"
@blur="v$[attribute.value].$touch"
/>
</template>
<template v-else-if="attribute.type === ATTRIBUTE_TYPES.LIST">
<ComboBox
v-model="formValues[attribute.value]"
:options="comboBoxOptions[attribute.value]"
:placeholder="getPlaceholder(ATTRIBUTE_TYPES.LIST)"
:message="getErrorMessage(attribute.value)"
:message-type="v$[attribute.value].$error ? 'error' : 'info'"
:has-error="v$[attribute.value].$error"
class="w-full"
/>
</template>
<template v-else-if="attribute.type === ATTRIBUTE_TYPES.CHECKBOX">
<ChoiceToggle v-model="formValues[attribute.value]" />
</template>
</div>
</div>
</Dialog>
</template>
@@ -0,0 +1,8 @@
export const ATTRIBUTE_TYPES = {
TEXT: 'text',
NUMBER: 'number',
LINK: 'link',
DATE: 'date',
LIST: 'list',
CHECKBOX: 'checkbox',
};
@@ -1,149 +0,0 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import { useToggle } from '@vueuse/core';
import countries from 'shared/constants/countries.js';
import Button from 'dashboard/components-next/button/Button.vue';
import Flag from 'dashboard/components-next/flag/Flag.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
placeholder: {
type: String,
default: '',
},
});
const emit = defineEmits(['change']);
const countryCode = defineModel({
type: String,
default: '',
});
const { t } = useI18n();
const [showCountryDropdown, toggleCountryDropdown] = useToggle();
const searchQuery = ref('');
const clearOption = computed(() => ({
label: t('DROPDOWN_MENU.CLEAR_SELECTION'),
value: 'clear',
action: 'clear',
}));
const countryOptions = computed(() =>
countries.map(({ name, id }) => ({
label: name,
value: id,
action: 'select',
isSelected: countryCode.value === id,
}))
);
const filteredCountries = computed(() => {
if (!searchQuery.value) return countryOptions.value;
const query = searchQuery.value.toLowerCase();
return countryOptions.value.filter(country =>
country.label.toLowerCase().includes(query)
);
});
const menuItems = computed(() => {
const items = [...filteredCountries.value];
if (countryCode.value) {
items.unshift(clearOption.value);
}
return items;
});
const selectedCountry = computed(() => {
return countries.find(country => country.id === countryCode.value);
});
const buttonLabel = computed(() => {
if (selectedCountry.value) {
return selectedCountry.value.name;
}
return props.placeholder;
});
const handleAction = ({ value, action }) => {
if (action === 'clear') {
countryCode.value = '';
emit('change', null);
toggleCountryDropdown(false);
return;
}
if (countryCode.value === value) {
countryCode.value = '';
emit('change', null);
} else {
countryCode.value = value;
const country = countries.find(c => c.id === value);
emit('change', country);
}
toggleCountryDropdown(false);
};
const handleSearch = query => {
searchQuery.value = query;
};
const handleClickOutside = () => {
toggleCountryDropdown(false);
};
watch(showCountryDropdown, isOpen => {
if (isOpen) searchQuery.value = '';
});
</script>
<template>
<div v-on-click-outside="handleClickOutside" class="relative w-full min-w-0">
<Button
slate
sm
:label="buttonLabel"
:icon="!selectedCountry ? 'i-lucide-flag' : ''"
:variant="showCountryDropdown ? 'faded' : 'solid'"
type="button"
no-animation
justify="start"
class="w-full -outline-offset-1"
@click="toggleCountryDropdown()"
>
<template v-if="selectedCountry" #icon>
<Flag :country="selectedCountry.id" class="size-3" />
</template>
</Button>
<Transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="transform scale-95 opacity-0"
enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-95 opacity-0"
>
<DropdownMenu
v-if="showCountryDropdown"
:menu-items="menuItems"
show-search
:search-placeholder="t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')"
class="max-h-60 overflow-y-auto absolute z-50 w-full mt-1 top-full min-w-40"
@action="handleAction"
@search="handleSearch"
>
<template #icon="{ item }">
<Flag
v-if="item.value !== 'clear'"
:country="item.value"
class="size-3"
/>
</template>
</DropdownMenu>
</Transition>
</div>
</template>
@@ -2,7 +2,6 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Label from 'dashboard/components-next/label/Label.vue';
const props = defineProps({
type: {
@@ -19,13 +18,11 @@ const attributeConfig = {
colorClass: 'text-n-blue-11',
icon: 'i-lucide-message-circle',
labelKey: 'ATTRIBUTES_MGMT.BADGES.PRE_CHAT',
color: 'slate',
},
resolution: {
colorClass: 'text-n-teal-11',
icon: 'i-lucide-circle-check-big',
labelKey: 'ATTRIBUTES_MGMT.BADGES.RESOLUTION',
color: 'slate',
},
};
const config = computed(
@@ -34,9 +31,12 @@ const config = computed(
</script>
<template>
<Label :label="t(config.labelKey)" :color="config.color" compact>
<template #icon>
<Icon :icon="config.icon" class="size-3.5 text-n-slate-12" />
</template>
</Label>
<div
class="flex gap-1 justify-center items-center px-1.5 py-1 rounded-md shadow outline-1 outline outline-n-container bg-n-solid-2"
>
<Icon :icon="config.icon" class="size-4" :class="config.colorClass" />
<span class="text-xs" :class="config.colorClass">{{
t(config.labelKey)
}}</span>
</div>
</template>
@@ -1,5 +1,5 @@
<script setup>
import { ref, computed } from 'vue';
import { ref } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Switch from 'dashboard/components-next/switch/Switch.vue';
@@ -19,35 +19,28 @@ const emit = defineEmits(['update', 'delete']);
const attributeValue = ref(Boolean(props.attribute.value));
const isAttributePresent = computed(() => {
return (
props.attribute.value !== undefined &&
props.attribute.value !== null &&
props.attribute.value !== ''
);
});
const handleChange = value => {
emit('update', value);
};
const handleDelete = () => {
attributeValue.value = false;
emit('delete');
};
</script>
<template>
<div class="flex items-center w-full gap-2 justify-end">
<div
class="flex items-center w-full gap-2"
:class="{
'justify-start': isEditingView,
'justify-end': !isEditingView,
}"
>
<Switch v-model="attributeValue" @change="handleChange" />
<Button
v-if="isEditingView && isAttributePresent"
ghost
ruby
v-if="isEditingView"
variant="faded"
color="ruby"
icon="i-lucide-trash"
xs
class="flex-shrink-0 !size-5 !rounded"
@click="handleDelete"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="emit('delete')"
/>
</div>
</template>
@@ -75,15 +75,20 @@ const handleInputUpdate = async () => {
</script>
<template>
<div class="flex items-center w-full min-w-0 gap-1.5 justify-end">
<div
class="flex items-center w-full min-w-0 gap-2"
:class="{
'justify-start': isEditingView,
'justify-end': !isEditingView,
}"
>
<span
v-if="!isEditingValue"
class="min-w-0 text-body-main"
class="min-w-0 text-sm"
:class="{
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none !font-medium':
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
!isEditingView,
'text-n-slate-12 truncate': isEditingView && attribute.value,
'text-n-slate-10 truncate': isEditingView && !attribute.value,
'text-n-slate-12 truncate': isEditingView,
}"
@click="toggleEditValue(!isEditingView)"
>
@@ -95,20 +100,19 @@ const handleInputUpdate = async () => {
class="flex items-center gap-1"
>
<Button
ghost
slate
icon="i-lucide-pen-line"
xs
class="flex-shrink-0 !size-5 !rounded"
variant="faded"
color="slate"
icon="i-lucide-pencil"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="toggleEditValue(true)"
/>
<Button
v-if="attribute.value"
ghost
ruby
variant="faded"
color="ruby"
icon="i-lucide-trash"
xs
class="flex-shrink-0 !size-5 !rounded"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="emit('delete')"
/>
</div>
@@ -121,7 +125,7 @@ const handleInputUpdate = async () => {
<Input
v-model="defaultDateValue"
type="date"
class="w-full [&>p]:absolute [&>p]:mt-1 [&>p]:top-6 ltr:[&>p]:left-0 rtl:[&>p]:right-0"
class="w-full [&>p]:absolute [&>p]:mt-0.5 [&>p]:top-8 ltr:[&>p]:left-0 rtl:[&>p]:right-0"
:message="
hasError
? t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.VALIDATIONS.INVALID_DATE')
@@ -129,15 +133,14 @@ const handleInputUpdate = async () => {
"
:message-type="hasError ? 'error' : 'info'"
autofocus
custom-input-class="h-7 py-1 ltr:rounded-r-none rtl:rounded-l-none"
@enter="handleInputUpdate"
custom-input-class="h-8 ltr:rounded-r-none rtl:rounded-l-none"
@keyup.enter="handleInputUpdate"
/>
<Button
icon="i-lucide-check"
:color="hasError ? 'ruby' : 'blue'"
size="sm"
type="button"
class="flex-shrink-0 ltr:rounded-l-none rtl:rounded-r-none !h-7"
class="flex-shrink-0 ltr:rounded-l-none rtl:rounded-r-none"
@click="handleInputUpdate"
/>
</div>
@@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
attribute: {
@@ -41,56 +40,61 @@ const handleAttributeAction = async action => {
</script>
<template>
<div class="flex items-center w-full min-w-0 gap-1.5 justify-end">
<div
class="flex items-center w-full min-w-0 gap-2"
:class="{
'justify-start': isEditingView,
'justify-end': !isEditingView,
}"
>
<div
v-on-clickaway="() => toggleAttributeListDropdown(false)"
class="relative flex items-center min-w-0 flex-1 justify-end gap-2 cursor-pointer"
@click="toggleAttributeListDropdown()"
class="relative flex items-center"
>
<span
v-tooltip.top="{
content: attribute.value,
delay: { show: 1000, hide: 0 },
}"
class="min-w-0 text-body-main truncate"
class="min-w-0 text-sm"
:class="{
'text-n-slate-11 hover:text-n-slate-12 py-2 select-none !font-medium':
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
!isEditingView,
'text-n-slate-12': isEditingView && attribute.value,
'text-n-slate-10': isEditingView && !attribute.value,
'text-n-slate-12 truncate flex-1': isEditingView,
}"
@click="toggleAttributeListDropdown(!props.isEditingView)"
>
{{
attribute.value ||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.TRIGGER.SELECT')
}}
</span>
<Icon
:icon="
showAttributeListDropdown
? 'i-lucide-chevron-up'
: 'i-lucide-chevron-down'
"
class="text-n-slate-11 flex-shrink-0 size-4"
/>
<DropdownMenu
v-if="showAttributeListDropdown"
:menu-items="attributeListMenuItems"
show-search
class="w-48 mt-2 top-full ltr:right-0 rtl:left-0"
@click.stop
class="w-48 mt-2 top-full"
:class="{
'ltr:right-0 rtl:left-0': !isEditingView,
'ltr:left-0 rtl:right-0': isEditingView,
}"
@action="handleAttributeAction($event)"
/>
</div>
<Button
v-if="isEditingView && attribute.value"
ghost
ruby
icon="i-lucide-trash"
xs
class="flex-shrink-0 !size-5 !rounded"
@click="emit('delete')"
/>
<div v-if="isEditingView" class="flex items-center gap-1">
<Button
variant="faded"
color="slate"
icon="i-lucide-pencil"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="toggleAttributeListDropdown()"
/>
<Button
variant="faded"
color="ruby"
icon="i-lucide-trash"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="emit('delete')"
/>
</div>
</div>
</template>
@@ -114,24 +114,24 @@ const handleInputUpdate = async () => {
</script>
<template>
<div class="flex items-center w-full min-w-0 gap-1.5 justify-end">
<div
class="flex items-center w-full min-w-0 gap-2"
:class="{
'justify-start': isEditingView,
'justify-end': !isEditingView,
}"
>
<span
v-if="!isEditingValue"
v-tooltip.top="{
content: !isAttributeTypeLink ? attribute.value : '',
delay: { show: 1000, hide: 0 },
}"
class="min-w-0 cursor-pointer text-body-main"
class="min-w-0 text-sm"
:class="{
'text-n-slate-11 hover:text-n-slate-12 py-2 select-none !font-medium':
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
!isEditingView,
'text-n-slate-12 truncate':
isEditingView && !isAttributeTypeLink && attribute.value,
'text-n-slate-10 truncate': isEditingView && !attribute.value,
'truncate hover:text-n-brand text-n-blue-10':
isEditingView && isAttributeTypeLink && attribute.value,
'text-n-slate-12 truncate': isEditingView && !isAttributeTypeLink,
'truncate hover:text-n-brand text-n-blue-11':
isEditingView && isAttributeTypeLink,
}"
@click="toggleEditValue(true)"
@click="toggleEditValue(!isEditingView)"
>
<a
v-if="isAttributeTypeLink && attribute.value && isEditingView"
@@ -156,20 +156,19 @@ const handleInputUpdate = async () => {
class="flex items-center gap-1"
>
<Button
ghost
slate
icon="i-lucide-pen-line"
xs
class="flex-shrink-0 !size-5 !rounded"
variant="faded"
color="slate"
icon="i-lucide-pencil"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="toggleEditValue(true)"
/>
<Button
v-if="attribute.value"
ghost
ruby
variant="faded"
color="ruby"
icon="i-lucide-trash"
xs
class="flex-shrink-0 !size-5 !rounded"
size="xs"
class="flex-shrink-0 opacity-0 group-hover/attribute:opacity-100 hover:no-underline"
@click="emit('delete')"
/>
</div>
@@ -183,19 +182,18 @@ const handleInputUpdate = async () => {
v-model="editedValue"
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.TRIGGER.INPUT')"
:type="getInputType"
class="w-full [&>p]:absolute [&>p]:mt-1 [&>p]:top-6 ltr:[&>p]:left-0 rtl:[&>p]:right-0"
class="w-full [&>p]:absolute [&>p]:mt-0.5 [&>p]:top-8 ltr:[&>p]:left-0 rtl:[&>p]:right-0"
autofocus
:message="attributeErrorMessage"
:message-type="hasError ? 'error' : 'info'"
custom-input-class="h-7 py-1 ltr:rounded-r-none rtl:rounded-l-none"
@enter="handleInputUpdate"
custom-input-class="h-8 ltr:rounded-r-none rtl:rounded-l-none"
@keyup.enter="handleInputUpdate"
/>
<Button
icon="i-lucide-check"
:color="hasError ? 'ruby' : 'blue'"
size="sm"
type="button"
class="flex-shrink-0 ltr:rounded-l-none rtl:rounded-r-none !h-7"
class="flex-shrink-0 ltr:rounded-l-none rtl:rounded-r-none"
@click="handleInputUpdate"
/>
</div>
@@ -26,7 +26,7 @@ defineProps({
class="relative flex flex-col items-center justify-center w-full h-full overflow-hidden"
>
<div
class="relative w-full max-w-5xl mx-auto overflow-hidden h-full max-h-[28rem]"
class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[28rem]"
>
<div
v-if="showBackdrop"
@@ -37,7 +37,7 @@ defineProps({
<div
class="flex flex-col items-center justify-end w-full h-full pb-20"
:class="{
'absolute inset-x-0 bottom-0 bg-gradient-to-t from-n-surface-1 from-40% to-transparent':
'absolute inset-x-0 bottom-0 bg-gradient-to-t from-n-surface-1 from-25% to-transparent':
showBackdrop,
}"
>
@@ -48,14 +48,12 @@ defineProps({
}"
>
<div class="flex flex-col items-center justify-center gap-3">
<h2
class="text-3xl font-medium text-center text-n-slate-12 font-interDisplay"
>
<h2 class="text-3xl font-medium text-center text-n-slate-12">
{{ title }}
</h2>
<p
v-if="subtitle"
class="max-w-xl text-base text-center text-n-slate-11 font-interDisplay tracking-[0.3px]"
class="max-w-xl text-base text-center text-n-slate-11 tracking-[0.3px]"
>
{{ subtitle }}
</p>
@@ -93,15 +93,13 @@ const statusText = computed(() => {
const categoryName = computed(() => {
if (props.category?.slug) {
return props.category.name;
return `${props.category.icon} ${props.category.name}`;
}
return t(
'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.CATEGORY.UNCATEGORISED'
);
});
const categoryIcon = computed(() => props.category?.icon || '');
const authorName = computed(() => {
return props.author?.name || props.author?.availableName || '';
});
@@ -125,17 +123,17 @@ const handleClick = id => {
</script>
<template>
<CardLayout class="[&>div]:!pt-3 group/articleCard hover:bg-n-alpha-1">
<CardLayout>
<div class="flex justify-between w-full gap-1">
<span
class="cursor-pointer py-1.5 text-heading-3 group-hover/articleCard:text-n-blue-11 text-n-slate-12 line-clamp-1"
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12 line-clamp-1"
@click="handleClick(id)"
>
{{ title }}
</span>
<div class="flex items-center gap-2">
<span
class="text-xs font-440 outline outline-1 outline-n-container inline-flex items-center px-2 h-6 rounded-md bg-n-button-color"
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
:class="statusTextColor"
>
{{ statusText }}
@@ -146,52 +144,41 @@ const handleClick = id => {
>
<Button
icon="i-lucide-ellipsis-vertical"
slate
:variant="!showActionsDropdown ? 'ghost' : 'faded'"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-alpha-2 [&>span]:size-3.5"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="articleMenuItems"
class="mt-1 ltr:right-0 rtl:left-0 top-full"
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 top-full"
@action="handleArticleAction($event)"
/>
</div>
</div>
</div>
<div class="flex items-center justify-between w-full gap-4 min-w-0">
<div class="flex items-center gap-3 min-w-0 overflow-hidden">
<div class="flex items-center gap-1.5 min-w-0 shrink">
<div class="flex items-center justify-between w-full gap-4">
<div class="flex items-center gap-4">
<div class="flex items-center gap-1">
<Avatar
:name="authorName"
:src="authorThumbnailSrc"
:size="16"
rounded-full
class="shrink-0"
/>
<span class="text-body-main truncate text-n-slate-11">
<span class="text-sm truncate text-n-slate-11">
{{ authorName || '-' }}
</span>
</div>
<div class="w-px h-3 bg-n-weak rounded-lg shrink-0" />
<span class="block text-sm whitespace-nowrap text-n-slate-11">
{{ categoryName }}
</span>
<div
v-if="category"
class="flex items-center gap-1.5 text-n-slate-11 min-w-0 shrink"
class="inline-flex items-center gap-1 text-n-slate-11 whitespace-nowrap"
>
<span v-if="categoryIcon" class="text-sm shrink-0">
{{ categoryIcon }}
</span>
<Icon v-else icon="i-lucide-shapes" class="size-4 shrink-0" />
<span class="text-body-main truncate">
{{ categoryName }}
</span>
</div>
<div v-if="category" class="w-px h-3 bg-n-weak rounded-lg shrink-0" />
<div class="flex items-center gap-1.5 text-n-slate-11 shrink-0">
<Icon icon="i-lucide-trending-up" class="size-4" />
<span class="text-body-main whitespace-nowrap">
<Icon icon="i-lucide-eye" class="size-4" />
<span class="text-sm">
{{
t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.VIEWS', {
count: views,
@@ -200,7 +187,7 @@ const handleClick = id => {
</span>
</div>
</div>
<span class="text-sm text-n-slate-11 truncate">
<span class="text-sm text-n-slate-11 line-clamp-1">
{{ lastUpdatedAt }}
</span>
</div>
@@ -1,10 +1,11 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
id: {
@@ -37,6 +38,27 @@ const emit = defineEmits(['click', 'action']);
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const categoryMenuItems = [
{
label: 'Edit',
action: 'edit',
value: 'edit',
icon: 'i-lucide-pencil',
},
{
label: 'Delete',
action: 'delete',
value: 'delete',
icon: 'i-lucide-trash',
},
];
const categoryTitleWithIcon = computed(() => {
return `${props.icon} ${props.title}`;
});
const description = computed(() => {
return props.description ? props.description : 'No description added';
});
@@ -49,68 +71,59 @@ const handleClick = slug => {
emit('click', slug);
};
const handleAction = ({ action }) => {
emit('action', { action, id: props.id });
const handleAction = ({ action, value }) => {
emit('action', { action, value, id: props.id });
toggleDropdown(false);
};
</script>
<template>
<CardLayout>
<div class="flex items-start justify-between gap-3 group/categoryCard">
<div
class="size-10 rounded-[0.625rem] mt-1 outline outline-1 outline-n-weak flex items-center justify-center flex-shrink-0"
>
<span v-if="icon" class="text-body-para">
{{ icon }}
</span>
<Icon v-else icon="i-lucide-shapes" class="size-4 shrink-0" />
</div>
<div class="flex flex-col w-full gap-2">
<div class="flex justify-between w-full gap-2 h-6">
<div class="flex items-center justify-start min-w-0 gap-2">
<span
class="text-heading-3 truncate group-hover/categoryCard:text-n-blue-11 cursor-pointer text-n-slate-12"
@click="handleClick(slug)"
>
{{ title }}
</span>
<div class="w-px h-3 bg-n-weak rounded-lg shrink-0" />
<span class="text-body-main text-n-slate-11 shrink-0">
{{
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_CARD.ARTICLES_COUNT', {
count: articlesCount,
})
}}
</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<Button
icon="i-lucide-pen-line"
slate
xs
ghost
class="[&>span]:size-3.5"
@click="handleAction({ action: 'edit' })"
/>
<div class="w-px h-3 bg-n-weak rounded-lg" />
<Button
icon="i-lucide-trash"
slate
xs
ghost
class="[&>span]:size-3.5"
@click="handleAction({ action: 'delete' })"
/>
</div>
<div class="flex w-full gap-2">
<div class="flex justify-between w-full gap-2">
<div class="flex items-center justify-start w-full min-w-0 gap-2">
<span
class="text-base truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12"
@click="handleClick(slug)"
>
{{ categoryTitleWithIcon }}
</span>
<span
class="inline-flex items-center justify-center h-6 px-2 py-1 text-xs text-center border rounded-lg bg-n-slate-1 whitespace-nowrap shrink-0 text-n-slate-11 border-n-slate-4"
>
{{
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_CARD.ARTICLES_COUNT', {
count: articlesCount,
})
}}
</span>
</div>
<span
class="text-body-main line-clamp-3"
:class="hasDescription ? 'text-n-slate-11' : 'text-n-slate-9'"
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative group"
>
{{ description }}
</span>
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
size="xs"
variant="ghost"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="categoryMenuItems"
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 top-full z-60"
@action="handleAction"
/>
</div>
</div>
</div>
<span
class="text-sm line-clamp-3"
:class="hasDescription ? 'text-n-slate-11' : 'text-n-slate-9'"
>
{{ description }}
</span>
</CardLayout>
</template>
@@ -1,5 +1,5 @@
<script setup>
import { ref, computed, useTemplateRef } from 'vue';
import { ref, computed } from 'vue';
import { OnClickOutside } from '@vueuse/components';
import { useRoute } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store.js';
@@ -14,14 +14,6 @@ defineProps({
type: Number,
default: 1,
},
headerTitle: {
type: String,
default: '',
},
createButtonLabel: {
type: String,
default: '',
},
totalItems: {
type: Number,
default: 100,
@@ -40,12 +32,11 @@ defineProps({
},
});
const emit = defineEmits(['update:currentPage', 'create']);
const emit = defineEmits(['update:currentPage']);
const route = useRoute();
const createPortalDialogRef = ref(null);
const createButtonRef = useTemplateRef('createButtonRef');
const showPortalSwitcher = ref(false);
@@ -64,86 +55,56 @@ const updateCurrentPage = page => {
const togglePortalSwitcher = () => {
showPortalSwitcher.value = !showPortalSwitcher.value;
};
const onClickCreateButton = () => {
emit('create');
};
</script>
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
<header
class="sticky top-0 z-10 px-6 pb-3 after:absolute after:inset-x-0 after:-bottom-4 after:bg-gradient-to-b after:from-n-surface-1 after:from-10% after:dark:from-0% after:to-transparent after:h-4 after:pointer-events-none"
>
<div class="w-full max-w-5xl mx-auto">
<div class="flex items-center justify-between gap-3">
<div
v-if="showHeaderTitle"
class="flex items-center justify-start h-20 gap-1"
<header class="sticky top-0 z-10 px-6 pb-3 lg:px-0">
<div class="w-full max-w-[60rem] mx-auto lg:px-6">
<div
v-if="showHeaderTitle"
class="flex items-center justify-start h-20 gap-2"
>
<span
v-if="activePortalName"
class="text-xl font-medium text-n-slate-12"
>
<span
v-if="activePortalName"
class="text-heading-1 text-n-slate-12 ltr:mr-1 rtl:ml-1"
>
{{ activePortalName }}
</span>
<div v-if="activePortalName" class="relative group">
<OnClickOutside @trigger="showPortalSwitcher = false">
<Button
icon="i-lucide-chevron-down"
:variant="showPortalSwitcher ? 'faded' : 'ghost'"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-slate-3 hover:bg-n-slate-3 [&>span]:size-4"
@click="togglePortalSwitcher"
/>
<PortalSwitcher
v-if="showPortalSwitcher"
class="absolute ltr:left-0 rtl:right-0 top-9"
@close="showPortalSwitcher = false"
@create-portal="createPortalDialogRef.dialogRef.open()"
/>
</OnClickOutside>
<CreatePortalDialog ref="createPortalDialogRef" />
</div>
<div
v-if="activePortalName && headerTitle"
class="flex items-center gap-1"
>
<div
v-if="activePortalName"
class="w-px h-3 rounded-2xl bg-n-strong ltr:ml-2 ltr:mr-3 rtl:mr-2 rtl:ml-3"
{{ activePortalName }}
</span>
<div v-if="activePortalName" class="relative group">
<OnClickOutside @trigger="showPortalSwitcher = false">
<Button
icon="i-lucide-chevron-down"
variant="ghost"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-slate-3 hover:bg-n-slate-3"
@click="togglePortalSwitcher"
/>
<span v-if="headerTitle" class="text-body-main text-n-slate-12">
{{ headerTitle }}
</span>
</div>
</div>
<div ref="createButtonRef" class="relative z-20">
<Button
v-if="createButtonLabel"
:label="createButtonLabel"
size="sm"
@click="onClickCreateButton"
/>
<slot name="modal" :button-ref="createButtonRef" />
<PortalSwitcher
v-if="showPortalSwitcher"
class="absolute ltr:left-0 rtl:right-0 top-9"
@close="showPortalSwitcher = false"
@create-portal="createPortalDialogRef.dialogRef.open()"
/>
</OnClickOutside>
<CreatePortalDialog ref="createPortalDialogRef" />
</div>
</div>
<slot name="header-actions" />
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto">
<div class="w-full max-w-5xl mx-auto py-3">
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[60rem] mx-auto py-3 lg:px-6">
<slot name="content" />
</div>
</main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10">
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 px-4 pb-4">
<PaginationFooter
:current-page="currentPage"
:total-items="totalItems"
:items-per-page="itemsPerPage"
class="max-w-[67rem]"
@update:current-page="updateCurrentPage"
/>
</footer>
@@ -55,20 +55,19 @@ const handleAction = ({ action, value }) => {
<CardLayout>
<div class="flex justify-between gap-2">
<div class="flex items-center justify-start gap-2">
<span class="text-heading-3 text-n-slate-12 line-clamp-1">
<span class="text-sm font-medium text-n-slate-12 line-clamp-1">
{{ locale }} ({{ localeCode }})
</span>
<div v-if="isDefault" class="w-px h-3 bg-n-weak rounded-lg shrink-0" />
<span
v-if="isDefault"
class="text-button-small outline outline-1 outline-n-container inline-flex items-center px-2 h-6 rounded-md bg-n-button-color"
class="bg-n-alpha-2 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-n-blue-11 px-2 py-0.5"
>
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }}
</span>
</div>
<div class="flex items-center justify-end gap-4">
<div class="flex items-center gap-4">
<span class="text-body-main text-n-slate-11 whitespace-nowrap">
<span class="text-sm text-n-slate-11 whitespace-nowrap">
{{
$t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.ARTICLES_COUNT',
@@ -77,7 +76,7 @@ const handleAction = ({ action, value }) => {
}}
</span>
<div class="w-px h-3 bg-n-weak" />
<span class="text-body-main text-n-slate-11 whitespace-nowrap">
<span class="text-sm text-n-slate-11 whitespace-nowrap">
{{
$t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.CATEGORIES_COUNT',
@@ -93,16 +92,15 @@ const handleAction = ({ action, value }) => {
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
ghost
xs
class="rounded-md group-hover:bg-n-alpha-2 [&>span]:size-3.5"
size="xs"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showDropdownMenu"
:menu-items="localeMenuItems"
class="ltr:right-0 rtl:left-0 mt-1 top-full z-60 min-w-[150px]"
class="ltr:right-0 rtl:left-0 mt-1 xl:ltr:left-0 xl:rtl:right-0 top-full z-60 min-w-[150px]"
@action="handleAction"
/>
</div>
@@ -29,7 +29,12 @@ const props = defineProps({
},
});
const emit = defineEmits(['tabChange', 'localeChange', 'categoryChange']);
const emit = defineEmits([
'tabChange',
'localeChange',
'categoryChange',
'newArticle',
]);
const route = useRoute();
const { t } = useI18n();
@@ -121,21 +126,23 @@ const handleCategoryAction = ({ value }) => {
isCategoryMenuOpen.value = false;
};
const handleNewArticle = () => {
emit('newArticle');
};
const handleTabChange = value => {
emit('tabChange', value);
};
</script>
<template>
<div
class="flex flex-col items-start justify-between w-full gap-2 lg:flex-row"
>
<div class="flex flex-col items-start w-full gap-2 lg:flex-row">
<TabBar
:tabs="tabs"
:initial-active-tab="activeTabIndex"
@tab-changed="handleTabChange"
/>
<div class="flex items-start justify-between lg:justify-end w-full gap-2">
<div class="flex items-start justify-between w-full gap-2">
<div class="flex items-center gap-2">
<div class="relative group">
<OnClickOutside @trigger="isLocaleMenuOpen = false">
@@ -152,7 +159,7 @@ const handleTabChange = value => {
v-if="isLocaleMenuOpen"
:menu-items="localeMenuItems"
show-search
class="ltr:left-0 rtl:right-0 w-40 max-w-[300px] mt-2 overflow-y-auto top-full max-h-60"
class="left-0 w-40 max-w-[300px] mt-2 overflow-y-auto xl:right-0 top-full max-h-60"
@action="handleLocaleAction"
/>
</OnClickOutside>
@@ -173,12 +180,18 @@ const handleTabChange = value => {
v-if="isCategoryMenuOpen"
:menu-items="categoryMenuItems"
show-search
class="ltr:right-0 rtl:left-0 w-48 mt-2 overflow-y-auto top-full max-h-60"
class="left-0 w-48 mt-2 overflow-y-auto xl:right-0 top-full max-h-60"
@action="handleCategoryAction"
/>
</OnClickOutside>
</div>
</div>
<Button
:label="t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.NEW_ARTICLE')"
icon="i-lucide-plus"
size="sm"
@click="handleNewArticle"
/>
</div>
</div>
</template>
@@ -136,12 +136,7 @@ const navigateToNewArticlePage = () => {
:total-items="articlesCount"
:items-per-page="25"
:header="portalName"
:header-title="t('HELP_CENTER.ARTICLES_PAGE.HEADER')"
:create-button-label="
t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.NEW_ARTICLE')
"
:show-pagination-footer="shouldShowPaginationFooter"
@create="navigateToNewArticlePage"
@update:current-page="handlePageChange"
>
<template #header-actions>
@@ -154,6 +149,7 @@ const navigateToNewArticlePage = () => {
@tab-change="handleTabChange"
@locale-change="handleLocaleAction"
@category-change="handleCategoryAction"
@new-article="navigateToNewArticlePage"
/>
<CategoryHeaderControls
v-else-if="showCategoryHeaderControls"
@@ -1,7 +1,5 @@
<script setup>
import { ref, computed } from 'vue';
import { vOnClickOutside } from '@vueuse/components';
import { useStoreGetters } from 'dashboard/composables/store.js';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
@@ -14,7 +12,6 @@ import CategoryHeaderControls from 'dashboard/components-next/HelpCenter/Pages/C
import CategoryEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/Category/CategoryEmptyState.vue';
import EditCategoryDialog from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/EditCategoryDialog.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import CategoryDialog from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryDialog.vue';
const props = defineProps({
categories: {
@@ -40,36 +37,6 @@ const { t } = useI18n();
const editCategoryDialog = ref(null);
const selectedCategory = ref(null);
const isCreateCategoryDialogOpen = ref(false);
const getters = useStoreGetters();
const currentPortalSlug = computed(() => route.params.portalSlug);
const currentPortal = computed(() => {
const slug = currentPortalSlug.value;
if (slug) return getters['portals/portalBySlug'].value(slug);
return getters['portals/allPortals'].value[0];
});
const currentPortalName = computed(() => currentPortal.value?.name);
const activeLocale = computed(() => {
return props.allowedLocales.find(
locale => locale.code === route.params.locale
);
});
const activeLocaleName = computed(() => activeLocale.value?.name ?? '');
const activeLocaleCode = computed(() => activeLocale.value?.code ?? '');
const toggleCreateCategoryDialog = () => {
isCreateCategoryDialogOpen.value = !isCreateCategoryDialogOpen.value;
};
const closeCreateCategoryDialog = () => {
isCreateCategoryDialogOpen.value = false;
};
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
const isLoading = computed(() => props.isFetching || isSwitchingPortal.value);
@@ -134,28 +101,7 @@ const handleAction = ({ action, id, category: categoryData }) => {
</script>
<template>
<HelpCenterLayout
:header-title="t('HELP_CENTER.CATEGORY_PAGE.HEADER')"
:create-button-label="
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.NEW_CATEGORY')
"
:show-pagination-footer="false"
@create="toggleCreateCategoryDialog"
>
<template #modal="{ buttonRef }">
<CategoryDialog
v-if="isCreateCategoryDialogOpen"
v-on-click-outside="[
closeCreateCategoryDialog,
{ ignore: [buttonRef] },
]"
mode="create"
:portal-name="currentPortalName"
:active-locale-name="activeLocaleName"
:active-locale-code="activeLocaleCode"
@close="closeCreateCategoryDialog"
/>
</template>
<HelpCenterLayout :show-pagination-footer="false">
<template #header-actions>
<CategoryHeaderControls
:categories="categories"
@@ -246,7 +246,7 @@ defineExpose({ state, isSubmitDisabled });
variant="faded"
color="slate"
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -33,6 +33,7 @@ const getters = useStoreGetters();
const { t } = useI18n();
const isLocaleMenuOpen = ref(false);
const isCreateCategoryDialogOpen = ref(false);
const isEditCategoryDialogOpen = ref(false);
const currentPortalSlug = computed(() => {
@@ -126,19 +127,7 @@ const handleBreadcrumbClick = () => {
<template>
<div class="flex items-center justify-between w-full">
<div
v-if="!hasSelectedCategory"
class="flex items-center gap-4 w-full justify-between"
>
<span
class="min-w-0 text-sm font-medium truncate text-n-slate-11 ltr:ml-1 rtl:mr-1"
>
{{
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.CATEGORIES_COUNT', {
n: categoriesCount,
})
}}
</span>
<div v-if="!hasSelectedCategory" class="flex items-center gap-4">
<div class="relative group">
<OnClickOutside @trigger="isLocaleMenuOpen = false">
<Button
@@ -153,18 +142,44 @@ const handleBreadcrumbClick = () => {
v-if="isLocaleMenuOpen"
:menu-items="localeMenuItems"
show-search
class="ltr:right-0 rtl:left-0 w-40 mt-2 overflow-y-auto top-full max-h-60"
class="left-0 w-40 mt-2 overflow-y-auto xl:right-0 top-full max-h-60"
@action="handleLocaleAction"
/>
</OnClickOutside>
</div>
<div class="w-px h-3.5 rounded my-auto bg-n-weak" />
<span class="min-w-0 text-sm font-medium truncate text-n-slate-12">
{{
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.CATEGORIES_COUNT', {
n: categoriesCount,
})
}}
</span>
</div>
<Breadcrumb
v-else
:items="breadcrumbItems"
@click="handleBreadcrumbClick"
/>
<div v-if="hasSelectedCategory" class="relative">
<div v-if="!hasSelectedCategory" class="relative">
<OnClickOutside @trigger="isCreateCategoryDialogOpen = false">
<Button
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.NEW_CATEGORY')"
icon="i-lucide-plus"
size="sm"
@click="isCreateCategoryDialogOpen = !isCreateCategoryDialogOpen"
/>
<CategoryDialog
v-if="isCreateCategoryDialogOpen"
mode="create"
:portal-name="currentPortalName"
:active-locale-name="activeLocaleName"
:active-locale-code="activeLocaleCode"
@close="isCreateCategoryDialogOpen = false"
/>
</OnClickOutside>
</div>
<div v-else class="relative">
<OnClickOutside @trigger="isEditCategoryDialogOpen = false">
<Button
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.EDIT_CATEGORY')"
@@ -14,13 +14,13 @@ const handleClick = slug => {
emit('click', slug);
};
const handleAction = ({ action, id }, category) => {
emit('action', { action, id, category });
const handleAction = ({ action, value, id }, category) => {
emit('action', { action, value, id, category });
};
</script>
<template>
<ul role="list" class="flex w-full h-full flex-col gap-4">
<ul role="list" class="grid w-full h-full grid-cols-1 gap-4 md:grid-cols-2">
<CategoryCard
v-for="category in categories"
:id="category.id"
@@ -1,9 +1,9 @@
<script setup>
import { computed, ref } from 'vue';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useI18n } from 'vue-i18n';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import LocaleList from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue';
import AddLocaleDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/AddLocaleDialog.vue';
@@ -19,8 +19,6 @@ const props = defineProps({
},
});
const { t } = useI18n();
const addLocaleDialogRef = ref(null);
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
@@ -33,17 +31,20 @@ const localeCount = computed(() => props.locales?.length);
</script>
<template>
<HelpCenterLayout
:header-title="t('HELP_CENTER.LOCALES_PAGE.HEADER')"
:create-button-label="t('HELP_CENTER.LOCALES_PAGE.NEW_LOCALE_BUTTON_TEXT')"
:show-pagination-footer="false"
@create="openAddLocaleDialog"
>
<HelpCenterLayout :show-pagination-footer="false">
<template #header-actions>
<div class="flex items-start h-8 ltr:ml-1 rtl:mr-1">
<span class="text-sm font-medium text-n-slate-12">
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALES_COUNT', localeCount) }}
</span>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<span class="text-sm font-medium text-n-slate-12">
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALES_COUNT', localeCount) }}
</span>
</div>
<Button
:label="$t('HELP_CENTER.LOCALES_PAGE.NEW_LOCALE_BUTTON_TEXT')"
icon="i-lucide-plus"
size="sm"
@click="openAddLocaleDialog"
/>
</div>
</template>
<template #content>
@@ -85,7 +85,7 @@ const handleDeletePortal = () => {
</div>
<div
v-else-if="activePortal"
class="flex flex-col w-full gap-4 max-w-2xl pb-8"
class="flex flex-col w-full gap-4 max-w-[40rem] pb-8"
>
<PortalBaseSettings
:active-portal="activePortal"
@@ -12,7 +12,7 @@ import { NOTIFICATION_TYPES_MAPPING } from 'dashboard/routes/dashboard/inbox/hel
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import CardPriorityIcon from 'dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue';
import SLACardLabel from 'dashboard/components-next/Inbox/SLACardLabel.vue';
import SLACardLabel from 'dashboard/components-next/Conversation/ConversationCard/SLACardLabel.vue';
import InboxContextMenu from 'dashboard/routes/dashboard/inbox/components/InboxContextMenu.vue';
const props = defineProps({
@@ -100,7 +100,7 @@ const formattedMessage = computed(() => {
const notificationDetails = computed(() => {
const type = props.inboxItem?.notificationType?.toUpperCase() || '';
const [icon = '', color = 'text-n-blue-text'] =
const [icon = '', color = 'text-n-blue-11'] =
NOTIFICATION_TYPES_MAPPING[type] || [];
return { text: type ? t(`INBOX.TYPES_NEXT.${type}`) : '', icon, color };
});
@@ -181,11 +181,11 @@ onBeforeMount(contextMenuActions.close);
: 'i-lucide-alarm-clock-off'
"
class="flex-shrink-0 size-4"
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-text'"
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-11'"
/>
<span
class="text-xs font-medium truncate"
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-text'"
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-11'"
>
{{ snoozedText }}
</span>
@@ -221,6 +221,7 @@ onBeforeMount(contextMenuActions.close);
<CardPriorityIcon
v-if="primaryActor?.priority"
:priority="primaryActor?.priority"
class="[&>svg]:size-4"
/>
<div
v-if="inboxIcon"

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