Compare commits

..
Author SHA1 Message Date
Tanmay Deep Sharma b7674062b4 Merge branch 'feat/whatsapp-call-ui' into feat/whatsapp-call-ui-temp 2026-05-06 13:49:17 +07:00
Tanmay Deep Sharma 5e4fc5264d Merge branch 'feat/whatsapp-call-meta-bridge' into feat/whatsapp-call-ui 2026-05-06 13:46:58 +07:00
Tanmay Deep SharmaandClaude Opus 4.7 4093a8a48d feat(voice): unify call button flow and redesign call widget
- Contact-panel call button now continues in the open conversation when
  the conversation's inbox is voice-capable, mirroring the header button.
  For non-voice channels it falls back to the inbox picker.
- Twilio and WhatsApp paths share the same upstream decision; both pass
  a conversationId hint to the provider so the agent stays in the same
  thread.
- Floating call widget redesigned: avatar + name + phone with duration
  on top, a clickable "View chat history" pill linking to the
  conversation, and labeled End / Join action buttons. Mute is preserved
  for active WhatsApp calls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:45:10 +07:00
Tanmay Deep SharmaandClaude Opus 4.7 2866b29f3a feat(voice): reuse open conversation for Twilio outbound calls
Accept an optional conversation_id in POST /contacts/:id/call. When the
hint matches the picked voice inbox, OutboundCallBuilder reuses that
conversation instead of creating a new one — mirroring the WhatsApp
calling flow so the agent stays in the same thread.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:44:28 +07:00
Tanmay Deep Sharma 0b3284ac62 Merge branch 'feat/whatsapp-call-meta-bridge' into feat/whatsapp-call-ui 2026-05-05 18:14:40 +07:00
Tanmay Deep Sharma 0b1caa7286 Merge remote-tracking branch 'origin/feat/whatsapp-call-incoming-pipeline' into feat/whatsapp-call-meta-bridge 2026-05-05 18:14:30 +07:00
Tanmay Deep Sharma 8df58d0a93 Merge branch 'feat/whatsapp-call-meta-bridge' into feat/whatsapp-call-ui 2026-05-05 18:11:11 +07:00
Tanmay Deep Sharma 23ccec3ebc test(whatsapp): update facebook_api_client spec for calls subscription
Mirrors the subscribed_fields change in 567631b — the webmock body matcher
needs `calls` so the override-failure stub actually fires.
2026-05-05 18:11:01 +07:00
Tanmay Deep Sharma 9e012e923b Merge branch 'develop' into feat/whatsapp-call-meta-bridge
Conflicts resolved:
- config/locales/en.yml — kept the new
  conversations.messages.voice_call.{twilio,whatsapp} keys this branch added.
- enterprise/app/services/enterprise/whatsapp/providers/whatsapp_cloud_service.rb
  — kept this branch's calls_phone_id_path helper that pins to
  WHATSAPP_API_VERSION (v22) for the Calls API endpoints. Develop's version
  from PR #14312 used the OSS phone_id_path (v13) which doesn't support the
  Calls API.
2026-05-05 18:09:33 +07:00
Tanmay Deep Sharma c0f1e9237f feat(voice): hide WhatsApp Calling settings tab when channel_voice flag is off
Matches the existing voice-configuration tab gating so admins on accounts
without the premium channel_voice entitlement don't see the WhatsApp Calling
configuration tab.
2026-05-05 18:01:33 +07:00
Tanmay Deep Sharma 9aa338da0a Merge branch 'feat/whatsapp-call-meta-bridge' into feat/whatsapp-call-ui 2026-05-05 18:01:25 +07:00
Tanmay Deep Sharma 567631b866 feat(voice): subscribe to calls webhook + gate WhatsApp calling on channel_voice flag
- Add 'calls' to the WhatsApp WABA override_callback subscribed_fields so a
  fresh embedded-signup connection (or a re-register) automatically receives
  call webhooks from Meta. Previously required a manual App Dashboard step.
- Gate Channel::Whatsapp#voice_enabled? on the account-level 'channel_voice'
  feature flag, matching the entitlement Twilio voice channel creation
  already requires. Cascades through the controller, webhook services, and
  inbox jbuilder voice_enabled serialization (FE button hides automatically).
2026-05-05 18:01:14 +07:00
Tanmay Deep Sharma f712796df3 Merge branch 'develop' into feat/whatsapp-call-incoming-pipeline
Resolves conflict in config/locales/en.yml — kept the new
conversations.messages.voice_call.{twilio,whatsapp} keys added in
this branch.
2026-05-05 17:45:52 +07:00
Tanmay Deep Sharma c1e3e61671 fix(voice): handle 422 permission-blocked response in WhatsApp call composable
The BE now returns 422 (instead of 200) when the contact hasn't opted in
yet — see companion fix in WhatsappCallsController#render_permission_request.
Detect that shape in the catch block and surface it as a normal response so
the existing banner branch in ConversationHeader.vue keeps working without
falling into the error toast path.
2026-05-05 17:30:31 +07:00
Tanmay Deep Sharma 17ff977b07 Merge branch 'feat/whatsapp-call-meta-bridge' into feat/whatsapp-call-ui 2026-05-05 17:30:15 +07:00
Tanmay Deep Sharma cb420d83f6 fix(voice): address PR #14356 review feedback
- Return 422 (not 200) from `WhatsappCallsController#initiate` permission
  branch so clients can't mistake the opt-in template path for a
  successful dial. Body shape unchanged (`{ status: ... }`).
- Drop the `file.attached?` guard from `enqueue_audio_transcription`.
  The social-media ingest path (Messages::Messenger::MessageBuilder)
  saves the Attachment before attaching the blob, and the guard was
  silently dropping transcription for those audio messages.
  AudioTranscriptionJob already has retry_on FileNotFoundError to ride
  out the create-then-attach race.
2026-05-05 17:30:03 +07:00
Tanmay Deep Sharma 97e7c106e3 Merge feat/whatsapp-call-meta-bridge: out-of-scope cleanups 2026-05-05 16:28:19 +07:00
Tanmay Deep Sharma 61a8511f7a chore: revert audio_transcription_service temperature change (out of scope)
Whisper temperature tuning belongs with the transcription stack work,
not with WhatsApp call wiring. Restoring develop's value to keep the
PR focused.
2026-05-05 16:28:07 +07:00
Tanmay Deep Sharma 14a9b48d49 chore: drop Channel::Voice stub (out of scope for this PR)
The legacy-row stub for Channel::Voice belongs with the upstream
DropChannelVoice migration cleanup, not with WhatsApp call wiring.
Removing it keeps the PR's surface focused on the call pipeline.
2026-05-05 16:27:33 +07:00
Tanmay Deep Sharma e6a35193f3 fix(voice): align outbound WhatsApp call lifecycle with real pickup
A pile of related fixes around the dashboard's WhatsApp call flow:

- ConversationHeader / Contacts/VoiceCallButton: drop the immediate
  setCallActive at initiate time. The call sits in incomingCalls
  (callDirection: outbound) until the backend signals real pickup, so
  the duration timer never starts pre-pickup. Phone button is disabled
  whenever there is an active or incoming call.

- FloatingCallWidget:
  * Loop a ringtone (bell.mp3) for inbound ringing only.
  * Hide the green Join button for outbound — the agent has nothing to
    "join", and clicking it routed through acceptIncomingCall →
    prepareInboundAnswer → cleanup() and tore down the live outbound
    session before the API 409 ("already accepted by another agent").
  * Auto-join watcher skips whatsapp outbound (Twilio's joinConference
    flow only).

- useCallSession:
  * joinCall short-circuits for outbound calls — defense-in-depth so
    no future surface can re-trigger the destroyed-session bug.
  * endCall + outbound rejectIncomingCall pass call.callId to
    endActiveCall, so terminate fires even if module state was wiped.

- useWhatsappCallSession:
  * New recorderArmed flag, reset by cleanup. ontrack only calls
    setupRecorder when armed.
  * Inbound's acceptIncomingCall arms the recorder before the API
    round-trip (agent click = pickup).
  * armOutboundRecorder exported for the cable handler when ACCEPTED
    arrives.
  * endActiveCall accepts a callIdOverride to fall back when the
    module's activeCallId was nulled by an earlier cleanup.

- actionCable:
  * Split the cable contract: outbound_connected only applies the SDP
    answer (tunnel-up signal); outbound_accepted (new) is the real
    pickup signal — flips active and arms the recorder.
2026-05-05 14:15:40 +07:00
Tanmay Deep Sharma 4a79abb367 fix(voice): direction-aware copy for voice call bubble
Outbound IN_PROGRESS no longer claims "They answered" — the backend can
flip in_progress before the contact actually picks up (Twilio agent-join
flow), so the label "Call in progress" stands on its own. Outbound
no_answer now reads "Contact didn't pick up" instead of the inbound
"Missed call / No answer" framing, and outbound ringing shows
"Calling…" instead of "Not answered yet".
2026-05-05 14:14:52 +07:00
Tanmay Deep Sharma 5dda138ee7 feat(voice): inbox-level customizable WhatsApp call permission body
Surface inbox.provider_config['call_permission_request_body'] in the
WhatsappCallingPage settings as an editable textarea — saving submits the
new key alongside calling_enabled. Blank trims to null so the i18n default
keeps applying. Backend reads the override before the i18n fallback.
2026-05-05 14:14:39 +07:00
Tanmay Deep Sharma 040c11f0dc Merge feat/whatsapp-call-meta-bridge into feat/whatsapp-call-ui 2026-05-05 14:13:42 +07:00
Tanmay Deep Sharma 5086edefb3 fix(voice): use status=ACCEPTED as the real pickup signal for outbound calls
Meta delivers two payload shapes under field=calls: value.calls[] (event:
connect/terminate) and value.statuses[] (status: RINGING/ACCEPTED). The
dispatcher only forwarded the calls[] array, so status webhooks were
silently dropped.

Empirically `connect` for BUSINESS_INITIATED outbound fires when the
WebRTC tunnel is up, not on pickup — sometimes ~20s before the contact
actually answers. The terminate webhook's start_time aligns with the
ACCEPTED status timestamp, confirming ACCEPTED is the true pickup.

- whatsapp_events_job: route value.statuses[] (type=call) to
  IncomingCallService under the same per-call_id mutex.
- incoming_call_service: handle_status routes ACCEPTED to
  mark_outbound_accepted (flip in_progress, set started_at, broadcast
  voice_call.outbound_accepted). Strip the in_progress flip from the
  connect handler — connect now only stores the SDP answer + broadcasts
  voice_call.outbound_connected so the browser can complete the DTLS
  handshake during ringing.
2026-05-05 14:13:26 +07:00
Tanmay Deep Sharma 66d9ddb9c4 feat(voice): add activity messages + customizable body for call permission
Emit a conversation activity message when the call permission template is
sent (in the controller's render_permission_request) and another when the
contact accepts the prompt (in CallPermissionReplyService). Read an inbox
provider_config['call_permission_request_body'] override before falling
back to the i18n default — surfaced in the inbox UI in a separate change.
2026-05-05 14:13:10 +07:00
Tanmay Deep Sharma 41a7c88aef Merge branch 'feat/whatsapp-call-meta-bridge' into feat/whatsapp-call-ui
# Conflicts:
#	app/services/base/send_on_channel_service.rb
#	enterprise/app/controllers/api/v1/accounts/whatsapp_calls_controller.rb
#	enterprise/app/models/enterprise/concerns/attachment.rb
2026-05-04 20:51:43 +07:00
Tanmay Deep Sharma 19b5496c56 Merge remote-tracking branch 'origin/develop' into feat/whatsapp-call-ui
# Conflicts:
#	app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue
#	app/javascript/dashboard/composables/useCallSession.js
#	app/javascript/dashboard/helper/voice.js
#	config/locales/en.yml
#	enterprise/app/services/enterprise/whatsapp/providers/whatsapp_cloud_service.rb
2026-05-04 20:48:25 +07:00
Tanmay Deep Sharma d693d468d8 feat(voice): bridge WhatsApp Cloud Calling to Chatwoot voice pipeline
Wires Meta's WhatsApp Cloud Calling APIs into the voice subsystem so
agents can answer / place calls on WhatsApp Cloud inboxes from the
existing Voice flow. Browser ↔ Meta WebRTC is direct (no media-server
hop); Chatwoot owns the signalling, recording upload, and lifecycle
state on the Call/Message records.

Backend
- API surface: new Api::V1::Accounts::WhatsappCallsController with
  show / accept / reject / terminate / initiate / upload_recording.
  Enforces conversation-level Pundit visibility, blocks initiate when
  the channel isn't embedded-signup voice-enabled, and surfaces the
  Meta 138006 (no-call-permission) flow as a throttled opt-in template
  send under a conversation lock so concurrent retries can't double-send.
- Whatsapp::CallService — accept/reject/terminate state machine with
  call-level locking; wraps Meta API failures (transport or business)
  as Voice::CallErrors::CallFailed so the controller renders 422.
- Whatsapp::IncomingCallService — handles Meta connect/terminate
  webhooks; pins setup:active on outbound answers, materialises a
  missed-call record when terminate arrives before connect, and
  broadcasts voice_call.* events to assignee or account streams.
- Whatsapp::Providers::WhatsappCloudService — adds pre_accept/accept/
  reject/terminate/initiate/permission-request endpoints. Uses
  configurable WHATSAPP_API_VERSION (default v22) since the Calls API
  needs v17+ and OSS phone_id_path is locked at v13.
- Audio recordings: Attachment after_create_commit enqueues
  transcription and rebroadcasts the message so the FE bubble updates
  immediately. Active Storage initializer allows audio MIME types to
  serve inline. Audio transcription drops Whisper temperature to 0 to
  suppress hallucinations on silence.
- Channel::Voice stub backs legacy inbox rows whose channel_type
  survived the DropChannelVoice migration so jbuilder .try chains
  short-circuit instead of raising.

Routes / config
- /api/v1/accounts/:id/whatsapp_calls/* (enterprise-only)
- en.yml error strings under errors.whatsapp.calls.*

The FE consumer of this API ships separately on feat/whatsapp-call-ui.
2026-05-04 15:38:28 +07:00
Tanmay Deep Sharma 0b1ad1a854 chore(voice): trim dead exports, fix duplicate-OR bug, drop redundant comments
Branch review pass:
- useWhatsappCallSession: drop unused `error` ref and unused exports
  (hasActiveWhatsappCall, isWhatsappCallMuted), fold sendWhatsappCallBeacon
  into a private beaconTerminate helper since only sendWhatsappTerminateBeacon
  consumed it externally. Trim WHAT-comments; keep WHY-comments
  (browser-quirk explanations, race-condition notes, auth-cookie rationale).
- VoiceCall.vue bubble: fix `data || data` short-circuit that did nothing —
  upstream key transform was the same on both branches; collapse to one read.
- calls/useCallSession/actionCable/FloatingCallWidget/VoiceCallButton:
  drop comments that just describe what the next line already says.

Net diff: -63 lines across 7 files. No behavior change.
2026-05-04 15:16:04 +07:00
Tanmay Deep Sharma 6179adac22 fix(voice): keep ringing WhatsApp calls alive across page refresh
Inverted the previous decision: only the active (accepted) call gets
terminated on pagehide, since its WebRTC session dies with the page and
genuinely can't be rejoined. Ringing calls have no WebRTC state yet —
they're just a row on the backend and a state on Meta — so killing them
on refresh would needlessly hang up on the customer when the agent
intended to recover their UI, not reject the call.

After this, the flow on refresh during a ringing inbound is:
  - beforeunload prompt fires (warning the agent)
  - pagehide does nothing for ringing calls; active calls still terminate
  - new page loads, conversation messages fetch
  - seedCallsFromHydratedMessages picks up status='ringing' voice_call
    messages and populates the calls store
  - FloatingCallWidget reappears with Accept; agent clicks; acceptIncomingCall
    fetches the SDP via /whatsapp_calls/:id and the call connects
2026-05-03 16:49:00 +07:00
Tanmay Deep Sharma 3c959f0604 fix(voice): authenticate page-close terminate beacon
The previous beacon used navigator.sendBeacon, which can't set custom
request headers — and Chatwoot's API requires devise-token-auth headers
(access-token / client / uid) on every call. So the beacon hit the
terminate endpoint, got 401, and Meta kept the call alive until the
carrier-side timeout (~60s) — exactly the bug reported: refresh during
an in-progress call leaves it ringing on Meta's side.

Switch to fetch with keepalive:true so the request survives page unload
AND lets us rehydrate the auth headers from the cw_d_session_info cookie
the dashboard sets at login. credentials:'same-origin' keeps any session
cookies along for the ride. Empty body is well under the 64KB keepalive
quota so it actually flushes before the tab dies.
2026-05-03 16:11:43 +07:00
Tanmay Deep Sharma 772734840d fix(voice): terminate ringing WhatsApp calls on page close
Browser-direct WebRTC has no rejoin path, so any call that outlives the
agent's tab becomes permanently orphaned on Meta's side. The existing
pagehide beacon only covered the active (accepted) call — extend it to
every ringing inbound too, so a hard refresh during ringing releases the
call instead of leaving Meta to time it out.

- useWhatsappCallSession: factor sendWhatsappCallBeacon(callId) out of
  the active-call wrapper so any caller can post terminate for any callId
  without going through the activeCallId / intentionallyClosing guard.
- useCallSession.handlePageHide: after the active-call beacon, iterate
  the calls store for any ringing WhatsApp call and beacon /terminate
  for each. terminate is the right endpoint because the backend records
  it as 'no_answer' when the call was still ringing — accurate UX shape.
2026-05-03 16:07:55 +07:00
Tanmay Deep Sharma 77c57acffa feat(voice): move WhatsApp calling toggle to its own Calls tab
Mirror the Twilio voice configuration flow: split the WhatsApp Cloud
calling-enabled toggle out of the generic Configuration tab into a
dedicated Calls tab so the surface for telephony settings is consistent
across providers.

- Added WhatsappCallingPage.vue with the toggle, business phone display,
  and a how-it-works blurb (no extra credentials needed since the
  embedded-signup token already grants the call scopes).
- Settings.vue: register the page and surface a Calls tab on
  WhatsApp Cloud + embedded-signup inboxes.
- ConfigurationPage.vue: drop the inline calling-enabled section, the
  watcher that auto-saved on every toggle flip, and the now-orphan
  updateWhatsAppCallingEnabled method.
- en/inboxMgmt.json: TABS.CALLS label + WHATSAPP_CALLING.* strings.
2026-05-03 15:52:43 +07:00
Tanmay Deep Sharma 499ac053ee chore(voice): drop debug toasts; appease rubocop on inbox jbuilder
- Remove the [debug] useAlert breadcrumbs that surfaced the accept-call
  silent-fail (the !conversation guard). The actual fix from
  6ed9500792 stays.
- Rubocop Style/IfUnlessModifier on the WhatsApp voice_enabled jbuilder
  block — convert to modifier form.
2026-05-03 15:40:30 +07:00
Tanmay Deep Sharma 5aa1df81a8 debug(voice): surface accept-call diagnostics via toast alerts
Console logs aren't visible to the agent on staging — switch to
useAlert toasts so the debug breadcrumbs appear directly in the UI.
Will revert once root cause is identified.
2026-05-03 14:12:22 +07:00
Tanmay Deep Sharma 81464a9087 debug(voice): add temporary console logs around accept-call path
Trace every checkpoint between the green Accept button click and the
backend POST so we can see exactly where the silent failure happens
(0 /accept requests landing on backend, no console errors, no network
requests reported by the user). Logs prefixed [CW Voice] for grep.
Will revert once the root cause is identified.
2026-05-03 13:49:33 +07:00
Tanmay Deep Sharma 6ed9500792 fix(voice): accept calls without conversation hydration; warn on refresh during ringing
- FloatingCallWidget: drop !conversation early-return in handleJoinCall —
  on a fresh account or after a hard refresh the inbound call's conversation
  may not be in the Vuex store yet, which silently no-op'd Accept while
  Reject worked (Reject doesn't read the conversation).
- useCallSession: seed the calls store from already-loaded voice_call
  messages with status='ringing' on mount and whenever the conversation
  list changes. Cable events (voice_call.incoming, message.created) are
  one-shot and not replayed on reconnect, so without seeding a refresh
  during a ringing call leaves the FloatingCallWidget empty.
- useCallSession: extend the beforeunload warning to fire while a call is
  ringing too — losing a ringing inbound to refresh is the same UX hit as
  losing an active one.
2026-05-02 17:35:53 +07:00
Tanmay Deep Sharma 6835f9c85b feat(voice): WhatsApp Cloud Calling — UI 2026-05-02 16:06:48 +07:00
Tanmay Deep Sharma 442631102c fix(voice): stub Channel::Voice for legacy inbox rows after DropChannelVoice migration 2026-05-02 16:06:36 +07:00
Tanmay Deep Sharma 78c615fe42 chore(voice): align whatsapp_spec to incoming-pipeline (specs go to specs PR) 2026-05-02 14:49:21 +07:00
Tanmay Deep Sharma 907011e280 chore(voice): split spec changes out to feat/whatsapp-call-specs (sibling PR) 2026-05-02 14:48:41 +07:00
Tanmay Deep Sharma a766ebf642 chore(voice): align FE files to incoming-pipeline base (UI changes consolidated to PR #14346) 2026-05-02 14:40:06 +07:00
Tanmay Deep Sharma 0e0e0868d7 chore(voice): split FE changes out to feat/whatsapp-call-ui (PR #14346) 2026-05-02 14:31:13 +07:00
Tanmay Deep Sharma d9077c64d3 fix(voice): robust accept-with-fetch + remote-end recording race + single-duration bubble + audio playback signals 2026-05-01 18:29:54 +07:00
Tanmay Deep Sharma 1abb51992b fix(voice): fall back to content_attributes.data for duration when call payload lacks it 2026-05-01 17:50:02 +07:00
Tanmay Deep Sharma 0282156821 fix(audio): allow audio attachments to serve inline so <audio> can play call recordings 2026-05-01 17:47:04 +07:00
Tanmay Deep Sharma 257a9129d7 fix(voice): create missed-call record when terminate webhook arrives before connect 2026-05-01 17:33:05 +07:00
Tanmay Deep Sharma 1ce316efc7 ui(voice): use ButtonV4 for header WhatsApp call button to match action toolbar styling 2026-05-01 17:27:57 +07:00
Tanmay Deep Sharma ba94d8c2b0 fix(transcription): drop Whisper temperature to 0 to suppress hallucinations 2026-05-01 16:31:48 +07:00
Tanmay Deep Sharma 6e11bebeae fix(audio): resolve real duration for MediaRecorder WebM blobs via end-seek trick 2026-05-01 11:16:55 +07:00
Tanmay Deep Sharma 069f25f88a fix(voice): await remote-end upload before store removeCall to prevent sync cleanup race 2026-05-01 11:15:09 +07:00
Tanmay Deep Sharma 63c193358c fix(voice): render audio + transcript + duration on call bubble; upload recording on remote-end too 2026-05-01 11:06:53 +07:00
Tanmay Deep Sharma 7663e2ac30 fix(voice): merge call store entries so cable + message.created races don't drop provider/sdp/caller 2026-05-01 10:54:47 +07:00
Tanmay Deep Sharma 0567655840 fix(voice): outbound STUN, mute toggle, skip voice_call bubble in send pipeline 2026-04-30 20:43:35 +07:00
Tanmay Deep Sharma c25ed094fa fix(voice): pipe Meta's remote audio to a hidden audio element so the agent hears the contact 2026-04-30 20:17:19 +07:00
Tanmay Deep Sharma 112a8f9e9e feat(voice): branch contact-panel call button on inbox provider for WhatsApp 2026-04-30 20:14:21 +07:00
Tanmay Deep Sharma 723d416ce3 fix(voice): bump WhatsApp Cloud Calling endpoints to v22 (OSS phone_id_path is locked at v13) 2026-04-30 19:54:31 +07:00
Tanmay Deep Sharma f3df486ce6 feat(voice): add WhatsApp calling enable toggle to inbox configuration 2026-04-30 19:39:19 +07:00
Tanmay Deep Sharma c7b6a87e77 feat(voice): wire WhatsApp call UI on top of existing Twilio session flow 2026-04-30 19:30:43 +07:00
Tanmay Deep Sharma 19d91e47f1 fix(voice): rescue transport errors during permission_request send 2026-04-30 19:11:14 +07:00
Tanmay Deep Sharma 1c945899c7 fix(whatsapp): restrict voice_enabled? to embedded-signup whatsapp_cloud channels 2026-04-30 19:10:06 +07:00
Tanmay Deep Sharma 2c025755e2 fix(voice): wrap Meta transport errors during accept as CallFailed 2026-04-30 18:52:51 +07:00
Tanmay Deep Sharma 0618e6e229 fix(voice): close lock-window gaps in accept, throttle, and call_attributes_changed? 2026-04-30 18:46:09 +07:00
Tanmay Deep Sharma cbef2d4e66 refactor(voice): memoize provider_service accessor in whatsapp calls controller 2026-04-30 18:26:43 +07:00
Tanmay Deep Sharma cb93aba55c refactor(voice): split call validations into single-purpose before_actions 2026-04-30 18:14:29 +07:00
Tanmay Deep Sharma beef43f2bf refactor(voice): hoist validations to before_action and centralize call-error rendering 2026-04-30 18:04:25 +07:00
Tanmay Deep Sharma cbd3f759b5 fix(voice): order accept guards correctly and validate contact phone upstream 2026-04-30 17:46:24 +07:00
Tanmay Deep Sharma a7fdc45977 fix(voice): bail on Meta failures and restrict initiate to WhatsApp inboxes 2026-04-30 17:26:01 +07:00
Tanmay Deep Sharma 41e3ea0d87 fix(voice): record permission_request wamid for context-based reply matching 2026-04-30 17:11:55 +07:00
Tanmay Deep Sharma fd22017292 Merge feat/whatsapp-call-incoming-pipeline into feature/pla-150 2026-04-30 17:10:45 +07:00
Tanmay Deep Sharma 555894793e fix(voice): match permission reply to originating conversation via context.id 2026-04-30 17:10:34 +07:00
Tanmay Deep Sharma 7c87d3d150 fix(voice): lock call state transitions, branch terminate on connection state, atomic recording upload, rescue CallFailed in initiate 2026-04-30 16:54:47 +07:00
Tanmay Deep Sharma c37d1b1d6b Merge feat/whatsapp-call-incoming-pipeline into feature/pla-150 2026-04-30 16:51:42 +07:00
Tanmay Deep Sharma e5a03d08ea fix(voice): resolve contact via source_id and i18n call message bubble 2026-04-30 16:51:29 +07:00
Tanmay Deep Sharma bc80985f55 refactor(voice): align WhatsappCallsController with Chatwoot jbuilder + concern conventions 2026-04-30 16:38:08 +07:00
Tanmay Deep Sharma 83cd568ca2 feat(voice): add WhatsApp Cloud Calling agent service, controller and routes 2026-04-30 16:24:39 +07:00
Tanmay Deep Sharma f32a7b3f77 refactor(voice): drop redundant CallMessageBuilder.update_status! class delegate 2026-04-30 16:13:53 +07:00
Tanmay Deep Sharma ba0fcbfc7f refactor(voice): tighten WhatsApp call pipeline and broadcast hyphenated terminate status 2026-04-30 16:04:39 +07:00
Tanmay Deep Sharma 546193de2a refactor(voice): tighten predicate and trim STUN comment 2026-04-30 15:35:58 +07:00
Tanmay Deep Sharma 67b522c433 refactor(voice): expose voice_enabled? on Channel::Whatsapp for duck-typed checks 2026-04-30 15:34:08 +07:00
Tanmay Deep Sharma f5eca9d1d0 refactor(voice): inline single-use helpers in WhatsApp call services 2026-04-30 15:28:41 +07:00
Tanmay Deep Sharma 5c4db3a6bc refactor(voice): extract finalize_status! helper to DRY incoming call lifecycle 2026-04-30 15:19:21 +07:00
Tanmay Deep Sharma 792dc359d4 fix(voice): tighten WhatsApp call lifecycle handling and per-call locking 2026-04-30 15:07:51 +07:00
Tanmay Deep Sharma 2b0aaa237f feat(voice): add WhatsApp inbound call webhook pipeline 2026-04-30 14:59:46 +07:00
Tanmay Deep Sharma 48022833ea refactor(voice): address review feedback on WhatsApp Cloud Calling provider methods 2026-04-30 14:43:44 +07:00
Tanmay Deep Sharma e2b3965a08 refactor(voice): raise typed Voice::CallErrors::CallFailed instead of StandardError 2026-04-30 14:43:44 +07:00
Tanmay Deep Sharma 17e25fa3c4 test(voice): cover WhatsApp Cloud Calling provider methods 2026-04-30 14:43:44 +07:00
Tanmay Deep Sharma 37963cc654 fix(voice): coerce non-Hash parsed_response in initiate_call error path 2026-04-30 14:43:44 +07:00
Tanmay Deep Sharma c7f5277277 feat(voice): add WhatsApp Cloud Calling provider methods 2026-04-30 14:43:44 +07:00
1203 changed files with 6990 additions and 69091 deletions
+4 -15
View File
@@ -2,19 +2,8 @@
ignore:
- CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated)
- GHSA-57hq-95w6-v4fc # Devise confirmable race condition — patched locally in User model (remove once on Devise 5+)
# Rails 7.1 has no patched release for the Active Storage proxy range
# advisories. Chatwoot limits proxy range requests locally.
# Chatwoot defaults to Active Storage redirect-style URLs, and its recommended
# storage setup uses local/cloud storage with optional direct uploads to the
# storage provider rather than Rails proxy mode. Revisit if we enable
# rails_storage_proxy or other app-served Active Storage proxy routes.
- CVE-2026-33658
# Rails 7.1 has no patched release for this Active Storage direct-upload
# advisory. Chatwoot filters internal metadata keys locally.
- CVE-2026-33173
- CVE-2026-33174
# Rails 7.1 has no patched release for these Rails advisories. These are not
# reachable through Chatwoot's current usage patterns and should be removed
# once we upgrade to Rails 7.2.3.1+.
- CVE-2026-33168
- CVE-2026-33169
- CVE-2026-33170
- CVE-2026-33176
- CVE-2026-33195
- CVE-2026-33202
+1 -1
View File
@@ -144,7 +144,7 @@ jobs:
# Backend tests with parallelization
backend-tests:
<<: *defaults
parallelism: 20
parallelism: 18
steps:
- checkout
- node/install:
-195
View File
@@ -1,195 +0,0 @@
#!/usr/bin/env python3
"""Sync triage GitHub security advisories to Linear issues."""
from __future__ import annotations
import os
import sys
from typing import Any
import requests
GITHUB_API = "https://api.github.com"
LINEAR_API = "https://api.linear.app/graphql"
SEVERITY_PRIORITY = {"critical": 1, "high": 2, "medium": 3, "low": 4}
SEVERITY_COLOR = {
"critical": 15548997,
"high": 15105570,
"medium": 15844367,
"low": 3066993,
}
DEFAULT_COLOR = 9807270
def required_env(name: str) -> str:
value = os.environ.get(name)
if not value:
sys.exit(f"Missing required env var: {name}")
return value
def fetch_triage_advisories(repo: str, token: str) -> list[dict[str, Any]]:
url: str | None = f"{GITHUB_API}/repos/{repo}/security-advisories"
params: dict[str, Any] | None = {"state": "triage", "per_page": 100}
headers = {
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {token}",
"X-GitHub-Api-Version": "2022-11-28",
}
advisories: list[dict[str, Any]] = []
while url:
r = requests.get(url, headers=headers, params=params, timeout=30)
r.raise_for_status()
advisories.extend(r.json())
next_link = r.links.get("next")
url = next_link["url"] if next_link else None
params = None
return advisories
def linear_call(query: str, variables: dict[str, Any], api_key: str) -> dict[str, Any]:
r = requests.post(
LINEAR_API,
headers={"Authorization": api_key},
json={"query": query, "variables": variables},
timeout=30,
)
r.raise_for_status()
return r.json()
def linear_issue_exists(ghsa_id: str, api_key: str) -> bool:
query = (
"query($q: String!) { issues(filter: {title: {contains: $q}}, first: 1) "
"{ nodes { id } } }"
)
resp = linear_call(query, {"q": ghsa_id}, api_key)
return len(resp.get("data", {}).get("issues", {}).get("nodes", [])) > 0
def linear_create_issue(input_data: dict[str, Any], api_key: str) -> dict[str, str] | None:
query = (
"mutation($input: IssueCreateInput!) { issueCreate(input: $input) "
"{ success issue { identifier url } } }"
)
resp = linear_call(query, {"input": input_data}, api_key)
create = resp.get("data", {}).get("issueCreate") or {}
if not create.get("success"):
return None
return create.get("issue")
def reporter_login(advisory: dict[str, Any]) -> str:
for credit in advisory.get("credits") or []:
user = (credit or {}).get("user") or {}
if user.get("login"):
return user["login"]
return "unknown"
def cvss_score(advisory: dict[str, Any]) -> str:
score = (advisory.get("cvss") or {}).get("score")
return str(score) if score is not None else "n/a"
def build_description(adv: dict[str, Any]) -> str:
return (
f"**GHSA:** {adv['ghsa_id']}\n"
f"**CVE:** {adv.get('cve_id') or 'n/a'}\n"
f"**Severity:** {adv.get('severity') or 'unknown'} (CVSS {cvss_score(adv)})\n"
f"**Reporter:** {reporter_login(adv)}\n"
f"**Reported:** {(adv.get('created_at') or '').split('T')[0]}\n"
f"**Advisory:** {adv['html_url']}\n\n"
f"---\n\n"
f"{adv.get('description') or 'No description provided.'}"
)
def post_discord(adv: dict[str, Any], issue: dict[str, str], webhook_url: str) -> None:
severity = adv.get("severity") or "unknown"
title = f"[{adv['ghsa_id']}] {adv['summary']}"[:250]
payload = {
"username": "GHSA Sync",
"embeds": [
{
"title": title,
"url": issue["url"],
"color": SEVERITY_COLOR.get(severity, DEFAULT_COLOR),
"fields": [
{"name": "Linear", "value": issue["identifier"], "inline": True},
{
"name": "Severity",
"value": f"{severity} (CVSS {cvss_score(adv)})",
"inline": True,
},
{
"name": "Advisory",
"value": f"[GitHub]({adv['html_url']})",
"inline": True,
},
],
}
],
}
try:
requests.post(webhook_url, json=payload, timeout=10)
except requests.RequestException:
pass
def main() -> int:
repo = required_env("GITHUB_REPOSITORY")
gh_token = required_env("GHSA_READ_TOKEN")
linear_api_key = required_env("LINEAR_API_KEY")
team_id = required_env("LINEAR_TEAM_ID")
project_id = required_env("LINEAR_PROJECT_ID")
label_id = required_env("LINEAR_LABEL_ID")
discord_webhook = os.environ.get("DISCORD_WEBHOOK_URL") or None
advisories = fetch_triage_advisories(repo, gh_token)
print(f"Fetched {len(advisories)} triage advisories")
created = skipped = failed = 0
for adv in advisories:
ghsa_id = adv.get("ghsa_id")
if not ghsa_id:
failed += 1
continue
try:
if linear_issue_exists(ghsa_id, linear_api_key):
skipped += 1
continue
severity = adv.get("severity") or "unknown"
issue = linear_create_issue(
{
"title": f"[{ghsa_id}] {adv.get('summary', '')}",
"description": build_description(adv),
"teamId": team_id,
"projectId": project_id,
"labelIds": [label_id],
"priority": SEVERITY_PRIORITY.get(severity, 3),
},
linear_api_key,
)
except requests.RequestException:
failed += 1
continue
if not issue:
failed += 1
continue
created += 1
if discord_webhook:
post_discord(adv, issue, discord_webhook)
print(f"Created {created}, skipped {skipped}, failed {failed}")
return 1 if failed > 0 else 0
if __name__ == "__main__":
sys.exit(main())
+81 -13
View File
@@ -5,25 +5,93 @@ on:
- cron: '0 4 * * *' # daily at 09:30 IST
workflow_dispatch: {}
permissions:
contents: read
jobs:
sync:
runs-on: ubuntu-latest
permissions:
security-events: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: pip install requests==2.32.3
- name: Sync advisories
- name: Fetch triage advisories
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh api --paginate \
-H "Accept: application/vnd.github+json" \
"/repos/${{ github.repository }}/security-advisories?state=triage&per_page=100" \
| jq -cs 'add | [.[] | {
ghsa_id, cve_id, summary, severity, state, html_url,
description, created_at,
cvss_score: .cvss.score,
reporter: ([.credits[]?.user.login] | first // "unknown")
}]' > advisories.json
echo "Fetched $(jq 'length' advisories.json) triage advisories"
- name: Create Linear issues for new advisories
env:
GHSA_READ_TOKEN: ${{ secrets.GHSA_READ_TOKEN }}
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }}
LINEAR_PROJECT_ID: ${{ secrets.LINEAR_PROJECT_ID }}
LINEAR_LABEL_ID: ${{ secrets.LINEAR_LABEL_ID }}
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
run: python3 .github/scripts/ghsa_linear_sync.py
run: |
created_count=0
skipped_count=0
failed_count=0
while read -r advisory; do
ghsa_id=$(printf '%s' "$advisory" | jq -r '.ghsa_id')
summary=$(printf '%s' "$advisory" | jq -r '.summary')
severity=$(printf '%s' "$advisory" | jq -r '.severity // "unknown"')
cve_id=$(printf '%s' "$advisory" | jq -r '.cve_id // "n/a"')
cvss=$(printf '%s' "$advisory" | jq -r '.cvss_score // "n/a"')
reporter=$(printf '%s' "$advisory" | jq -r '.reporter')
html_url=$(printf '%s' "$advisory" | jq -r '.html_url')
created_date=$(printf '%s' "$advisory" | jq -r '.created_at' | cut -dT -f1)
description=$(printf '%s' "$advisory" | jq -r '.description // "No description provided."')
existing=$(curl -s -X POST https://api.linear.app/graphql \
-H "Content-Type: application/json" \
-H "Authorization: $LINEAR_API_KEY" \
-d "$(jq -n --arg q "$ghsa_id" '{query: "query($q: String!) { issues(filter: {title: {contains: $q}}, first: 1) { nodes { id } } }", variables: {q: $q}}')" \
| jq '.data.issues.nodes | length')
if [ "${existing:-0}" -gt 0 ] 2>/dev/null; then
skipped_count=$((skipped_count+1))
continue
fi
priority=3
case "$severity" in
critical) priority=1 ;;
high) priority=2 ;;
medium) priority=3 ;;
low) priority=4 ;;
esac
title="[$ghsa_id] $summary"
body=$(printf '**GHSA:** %s\n**CVE:** %s\n**Severity:** %s (CVSS %s)\n**Reporter:** %s\n**Reported:** %s\n**Advisory:** %s\n\n---\n\n%s' \
"$ghsa_id" "$cve_id" "$severity" "$cvss" "$reporter" "$created_date" "$html_url" "$description")
success=$(curl -s -X POST https://api.linear.app/graphql \
-H "Content-Type: application/json" \
-H "Authorization: $LINEAR_API_KEY" \
-d "$(jq -n \
--arg title "$title" \
--arg body "$body" \
--arg teamId "$LINEAR_TEAM_ID" \
--arg projectId "$LINEAR_PROJECT_ID" \
--arg labelId "$LINEAR_LABEL_ID" \
--argjson priority "$priority" \
'{
query: "mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success } }",
variables: {input: {title: $title, description: $body, teamId: $teamId, projectId: $projectId, labelIds: [$labelId], priority: $priority}}
}')" | jq -r '.data.issueCreate.success // false')
if [ "$success" = "true" ]; then
created_count=$((created_count+1))
else
failed_count=$((failed_count+1))
fi
done < <(jq -c '.[]' advisories.json)
echo "Created $created_count, skipped $skipped_count, failed $failed_count"
if [ "$failed_count" -gt 0 ]; then
exit 1
fi
+2 -2
View File
@@ -195,10 +195,10 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
gem 'ai-agents', '>= 0.10.0'
gem 'ai-agents', '>= 0.9.1'
# TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.14.1'
gem 'ruby_llm', '>= 1.8.2'
gem 'ruby_llm-schema'
gem 'cld3', '~> 3.7'
+21 -21
View File
@@ -126,8 +126,8 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
ai-agents (0.10.0)
ruby_llm (~> 1.14)
ai-agents (0.9.1)
ruby_llm (~> 1.9.1)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
activesupport (>= 6.0.0)
@@ -212,7 +212,7 @@ GEM
logger
msgpack
datadog-ruby_core_source (3.4.1)
date (3.5.1)
date (3.4.1)
debug (1.8.0)
irb (>= 1.5.0)
reline (>= 0.3.1)
@@ -307,8 +307,8 @@ GEM
faraday-mashify (1.0.0)
faraday (~> 2.0)
hashie
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (3.4.2)
net-http (~> 0.5)
faraday-net_http_persistent (2.1.0)
@@ -466,7 +466,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.19.5)
json (2.19.2)
json_refs (0.1.8)
hana
json_schemer (0.2.24)
@@ -574,7 +574,7 @@ GEM
uri (>= 0.11.1)
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.24)
net-imap (0.4.20)
date
net-protocol
net-pop (0.1.2)
@@ -590,14 +590,14 @@ GEM
newrelic_rpm (9.6.0)
base64
nio4r (2.7.3)
nokogiri (1.19.3)
nokogiri (1.19.1)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.19.3-arm64-darwin)
nokogiri (1.19.1-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.3-x86_64-darwin)
nokogiri (1.19.1-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.19.3-x86_64-linux-gnu)
nokogiri (1.19.1-x86_64-linux-gnu)
racc (~> 1.4)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
@@ -684,7 +684,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.2.6)
rack (3.2.5)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.5.0)
@@ -699,7 +699,7 @@ GEM
rack (>= 3.0.0, < 4)
rack-proxy (0.7.7)
rack
rack-session (2.1.2)
rack-session (2.1.1)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.1.0)
@@ -835,17 +835,17 @@ GEM
ruby2ruby (2.5.0)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
ruby_llm (1.15.0)
ruby_llm (1.9.2)
base64
event_stream_parser (~> 1)
faraday (>= 1.10.0)
faraday-multipart (>= 1)
faraday-net_http (>= 1)
faraday-retry (>= 1)
marcel (~> 1)
ruby_llm-schema (~> 0)
marcel (~> 1.0)
ruby_llm-schema (~> 0.2.1)
zeitwerk (~> 2)
ruby_llm-schema (0.3.0)
ruby_llm-schema (0.2.5)
ruby_parser (3.20.0)
sexp_processor (~> 4.16)
sass (3.7.4)
@@ -961,7 +961,7 @@ GEM
time_diff (0.3.0)
activesupport
i18n
timeout (0.6.1)
timeout (0.4.3)
trailblazer-option (0.1.2)
twilio-ruby (7.6.0)
faraday (>= 0.9, < 3.0)
@@ -1019,7 +1019,7 @@ GEM
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
zeitwerk (2.7.5)
zeitwerk (2.7.4)
PLATFORMS
arm64-darwin-20
@@ -1039,7 +1039,7 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
ai-agents (>= 0.10.0)
ai-agents (>= 0.9.1)
annotaterb
attr_extras
audited (~> 5.4, >= 5.4.1)
@@ -1144,7 +1144,7 @@ DEPENDENCIES
rubocop-rails
rubocop-rspec
ruby-openai
ruby_llm (>= 1.14.1)
ruby_llm (>= 1.8.2)
ruby_llm-schema
scout_apm
scss_lint
+1 -1
View File
@@ -1 +1 @@
4.14.0
4.13.0
@@ -28,10 +28,6 @@ class Messages::Messenger::MessageBuilder
filename: attachment_file.original_filename,
content_type: attachment_file.content_type
)
# The Attachment row is saved before the blob is attached, so the
# after_create_commit broadcast bails on `file.attached?`. Re-fire here
# for audio so the bubble updates without waiting on transcription.
attachment.message&.reload&.send_update_event if attachment.file_type.to_sym == :audio
end
def attachment_params(attachment)
-15
View File
@@ -27,8 +27,6 @@ class NotificationBuilder
return if notification_type == 'conversation_creation' && !user_subscribed_to_notification?
# skip notifications for blocked conversations except for user mentions
return if primary_actor.contact.blocked? && notification_type != 'conversation_mention'
# respect conversation access (inbox/team membership and custom-role permissions)
return unless user_can_access_conversation?
user.notifications.create!(
notification_type: notification_type,
@@ -38,17 +36,4 @@ class NotificationBuilder
secondary_actor: secondary_actor || current_user
)
end
def user_can_access_conversation?
conversation = primary_actor.is_a?(Conversation) ? primary_actor : primary_actor.try(:conversation)
return true if conversation.blank?
account_user = AccountUser.find_by(account_id: account.id, user_id: user.id)
return false if account_user.blank?
ConversationPolicy.new(
{ user: user, account: account, account_user: account_user },
conversation
).show?
end
end
+4 -15
View File
@@ -31,24 +31,13 @@ class V2::Reports::BotMetricsBuilder
end
def bot_resolutions_count
# Exclude conversations that also had a handoff in the same range — handoff wins
account.reporting_events.joins(:conversation).select(:conversation_id)
.where(account_id: account.id, name: :conversation_bot_resolved, created_at: range)
.where.not(conversation_id: bot_handoff_conversation_ids_subquery)
.distinct.count
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved,
created_at: range).distinct.count
end
def bot_handoffs_count
account.reporting_events.joins(:conversation).select(:conversation_id)
.where(account_id: account.id, name: :conversation_bot_handoff, created_at: range)
.distinct.count
end
def bot_handoff_conversation_ids_subquery
account.reporting_events
.where(name: :conversation_bot_handoff, created_at: range)
.where.not(conversation_id: nil)
.select(:conversation_id)
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_handoff,
created_at: range).distinct.count
end
def bot_resolution_rate
@@ -1,18 +0,0 @@
class Api::V1::Accounts::Contacts::AttachmentsController < Api::V1::Accounts::Contacts::BaseController
RESULTS_PER_PAGE = 100
def index
conversations = Conversations::PermissionFilterService.new(
Current.account.conversations.where(contact_id: @contact.id),
Current.user,
Current.account
).perform
@attachments = Attachment.where(message_id: Message.where(conversation_id: conversations).select(:id))
.includes({ file_attachment: :blob }, message: [:conversation, :inbox, { sender: { avatar_attachment: :blob } }])
.order(created_at: :desc)
.page(params[:page])
.per(RESULTS_PER_PAGE)
@attachments_count = @attachments.total_count
end
end
@@ -28,7 +28,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def attachments
@attachments_count = @conversation.attachments.count
@attachments = @conversation.attachments
.includes({ file_attachment: :blob }, message: [:inbox, { sender: { avatar_attachment: :blob } }])
.includes(:message)
.order(created_at: :desc)
.page(attachment_params[:page])
.per(ATTACHMENT_RESULTS_PER_PAGE)
@@ -1,7 +1,6 @@
class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Accounts::BaseController
before_action :fetch_custom_attributes_definitions, except: [:create]
before_action :fetch_custom_attribute_definition, only: [:show, :update, :destroy]
before_action :check_authorization
DEFAULT_ATTRIBUTE_MODEL = 'conversation_attribute'.freeze
def index; end
@@ -9,9 +9,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
def index
@inboxes = policy_scope(Current.account.inboxes)
.includes(:channel, :portal, :working_hours, { avatar_attachment: :blob })
.order_by_name
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
end
def show; end
@@ -87,7 +85,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def fetch_agent_bot
@agent_bot = AgentBot.accessible_to(Current.account).find(params[:agent_bot]) if params[:agent_bot]
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
end
def create_channel
@@ -18,16 +18,7 @@ class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
end
def destroy
label_title = @label.title
account_id = Current.account.id
label_deleted_at = Time.current
@label.destroy!
Labels::RemoveAssociationsJob.perform_later(
label_title: label_title,
account_id: account_id,
label_deleted_at: label_deleted_at
)
head :ok
end
@@ -41,9 +41,9 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
def destroy_all
if params[:type] == 'read'
::Notification::DeleteNotificationJob.perform_later(Current.user, Current.account, type: :read)
::Notification::DeleteNotificationJob.perform_later(Current.user, type: :read)
else
::Notification::DeleteNotificationJob.perform_later(Current.user, Current.account, type: :all)
::Notification::DeleteNotificationJob.perform_later(Current.user, type: :all)
end
head :ok
end
@@ -69,7 +69,7 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
end
def fetch_notification
@notification = current_user.notifications.where(account_id: Current.account.id).find(params[:id])
@notification = current_user.notifications.find(params[:id])
end
def set_current_page
@@ -18,7 +18,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
@portal = Current.account.portals.build(portal_params.merge(live_chat_widget_params))
@portal.custom_domain = parsed_custom_domain
@portal.save!
process_attached_logo if params[:blob_id].present?
process_attached_logo
end
def update
@@ -87,7 +87,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
return {} unless permitted_params.key?(:inbox_id)
return { channel_web_widget_id: nil } if permitted_params[:inbox_id].blank?
inbox = Current.account.inboxes.find(permitted_params[:inbox_id])
inbox = Inbox.find(permitted_params[:inbox_id])
return {} unless inbox.web_widget?
{ channel_web_widget_id: inbox.channel.id }
@@ -98,8 +98,6 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
end
def parsed_custom_domain
return @portal.custom_domain if @portal.custom_domain.blank?
domain = URI.parse(@portal.custom_domain)
domain.is_a?(URI::HTTP) ? domain.host : @portal.custom_domain
end
@@ -111,7 +111,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def custom_attributes_params
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website)
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role)
end
def settings_params
@@ -8,8 +8,7 @@ class Api::V1::NotificationSubscriptionsController < Api::BaseController
end
def destroy
notification_subscription = current_user.notification_subscriptions
.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
notification_subscription = NotificationSubscription.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
notification_subscription.destroy! if notification_subscription.present?
head :ok
end
@@ -83,10 +83,6 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
end
def set_message
# `conversation.messages.find` would be simpler, but `conversation` is `conversations.last`,
# which means a visitor with more than one open thread could not edit a message in any
# but their most recent one. Scoping across all of the visitor's conversations keeps the
# happy path correct for that future multi-conversation widget flow.
@message = Message.where(conversation_id: conversations.select(:id)).find(permitted_params[:id])
@message = @web_widget.inbox.messages.find(permitted_params[:id])
end
end
@@ -25,14 +25,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
private
def render_create_error_not_confirmed
render_error(
:unauthorized,
I18n.t('devise_token_auth.sessions.not_confirmed', email: @resource.email),
error_code: 'user_not_confirmed'
)
end
def find_user_for_authentication
return nil unless params[:email].present? && params[:password].present?
@@ -24,10 +24,6 @@ class Public::Api::V1::InboxesController < PublicController
def set_conversation
return if params[:conversation_id].blank?
@conversation = if @contact_inbox.hmac_verified?
@contact_inbox.contact.conversations.find_by!(display_id: params[:conversation_id])
else
@contact_inbox.conversations.find_by!(display_id: params[:conversation_id])
end
@conversation = @contact_inbox.contact.conversations.find_by!(display_id: params[:conversation_id])
end
end
@@ -27,7 +27,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
if errors.any?
redirect_to super_admin_app_config_path(config: @config), alert: errors.join(', ')
else
redirect_to super_admin_settings_path, flash: success_flash
redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully"
end
end
@@ -58,21 +58,6 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE WIDGET_TOKEN_EXPIRY]
)
end
def success_notice
message = "#{@config.titleize} settings updated successfully"
return message unless restart_required_config_saved?
"#{message.delete_suffix('.')}. Restart Chatwoot web and worker processes to apply this change everywhere."
end
def success_flash
restart_required_config_saved? ? { success: success_notice } : { notice: success_notice }
end
def restart_required_config_saved?
params.fetch('app_config', {}).keys.intersect?(InstallationConfig::RESTART_REQUIRED_CONFIG_KEYS)
end
end
SuperAdmin::AppConfigsController.prepend_mod_with('SuperAdmin::AppConfigsController')
@@ -25,29 +25,6 @@ class SuperAdmin::InstallationConfigsController < SuperAdmin::ApplicationControl
resource_class.editable
end
def create
resource = new_resource(resource_params)
authorize_resource(resource)
if resource.save
redirect_to after_resource_created_path(resource), flash: success_flash(resource)
else
render :new, locals: {
page: Administrate::Page::Form.new(dashboard, resource)
}, status: :unprocessable_entity
end
end
def update
if requested_resource.update(resource_params)
redirect_to after_resource_updated_path(requested_resource), flash: success_flash(requested_resource)
else
render :edit, locals: {
page: Administrate::Page::Form.new(dashboard, requested_resource)
}, status: :unprocessable_entity
end
end
# Override `resource_params` if you want to transform the submitted
# data before it's persisted. For example, the following would turn all
# empty values into nil values. It uses other APIs such as `resource_class`
@@ -65,20 +42,6 @@ class SuperAdmin::InstallationConfigsController < SuperAdmin::ApplicationControl
.transform_values { |value| value == '' ? nil : value }.merge(locked: false)
end
private
def success_flash(resource)
message = translate_with_resource('update.success')
message = translate_with_resource('create.success') if action_name == 'create'
return { notice: message } unless restart_required_config?(resource)
{ success: "#{message.delete_suffix('.')}. Restart Chatwoot web and worker processes to apply this change everywhere." }
end
def restart_required_config?(resource)
resource.name.in?(InstallationConfig::RESTART_REQUIRED_CONFIG_KEYS)
end
# See https://administrate-prototype.herokuapp.com/customizing_controller_actions
# for more information
end
+3 -8
View File
@@ -1,12 +1,7 @@
class SwaggerController < ApplicationController
def respond
if Rails.env.development? || Rails.env.test?
swagger_root = Rails.root.join('swagger')
file_path = swagger_root.join(derived_path).cleanpath
return head :not_found unless file_path.to_s.start_with?("#{swagger_root}/") && file_path.file?
render inline: file_path.read
render inline: Rails.root.join('swagger', derived_path).read
else
head :not_found
end
@@ -16,8 +11,8 @@ class SwaggerController < ApplicationController
def derived_path
params[:path] ||= 'index.html'
path = Rack::Utils.clean_path_info(params[:path]).delete_prefix('/')
path << ".#{Rack::Utils.clean_path_info(params[:format]).delete_prefix('/')}" unless path.ends_with?(params[:format].to_s)
path = Rack::Utils.clean_path_info(params[:path])
path << ".#{Rack::Utils.clean_path_info(params[:format])}" unless path.ends_with?(params[:format].to_s)
path
end
end
+10 -18
View File
@@ -17,12 +17,15 @@ module Api::V1::InboxesHelper
def validate_imap(channel_data)
return unless channel_data.key?('imap_enabled') && channel_data[:imap_enabled]
# Validate the user-selected auth mechanism before opening the connection.
authentication = Imap::Authentication.validate_user_configurable!(channel_data[:imap_authentication])
Mail.defaults do
retriever_method :imap, { address: channel_data[:imap_address],
port: channel_data[:imap_port],
user_name: channel_data[:imap_login],
password: channel_data[:imap_password],
enable_ssl: channel_data[:imap_enable_ssl] }
end
# Use the same auth adapter as the fetch service so LOGIN uses the IMAP LOGIN command,
# not SASL AUTH=LOGIN.
check_imap_connection(channel_data, authentication)
check_imap_connection(channel_data)
end
def validate_smtp(channel_data)
@@ -34,8 +37,8 @@ module Api::V1::InboxesHelper
check_smtp_connection(channel_data, smtp)
end
def check_imap_connection(channel_data, authentication)
imap = open_imap_connection(channel_data, authentication)
def check_imap_connection(channel_data)
Mail.connection {} # rubocop:disable:block
rescue SocketError => e
raise StandardError, I18n.t('errors.inboxes.imap.socket_error')
rescue Net::IMAP::NoResponseError => e
@@ -50,20 +53,9 @@ module Api::V1::InboxesHelper
rescue StandardError => e
raise StandardError, e.message
ensure
imap.disconnect if imap.present? && !imap.disconnected?
Rails.logger.error "[Api::V1::InboxesHelper] check_imap_connection failed with #{e.message}" if e.present?
end
def open_imap_connection(channel_data, authentication)
imap = build_imap_connection(channel_data)
Imap::Authentication.authenticate!(imap, authentication, channel_data[:imap_login], channel_data[:imap_password])
imap
end
def build_imap_connection(channel_data)
Net::IMAP.new(channel_data[:imap_address], port: channel_data[:imap_port], ssl: channel_data[:imap_enable_ssl])
end
def check_smtp_connection(channel_data, smtp)
smtp.open_timeout = 10
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password],
+4 -7
View File
@@ -53,12 +53,13 @@ module ReportHelper
end
def resolutions
scope.reporting_events.where(account_id: account.id, name: :conversation_resolved, created_at: range)
scope.reporting_events.where(account_id: account.id, name: :conversation_resolved,
created_at: range)
end
def bot_resolutions
scope.reporting_events.where(account_id: account.id, name: :conversation_bot_resolved, created_at: range)
.where.not(conversation_id: bot_handoff_conversation_ids_subquery)
scope.reporting_events.where(account_id: account.id, name: :conversation_bot_resolved,
created_at: range)
end
def bot_handoffs
@@ -66,10 +67,6 @@ module ReportHelper
created_at: range).distinct
end
def bot_handoff_conversation_ids_subquery
bot_handoffs
end
def avg_first_response_time
grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'first_response', account_id: account.id))
return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours]
@@ -6,22 +6,15 @@ class CaptainDocument extends ApiClient {
super('captain/documents', { accountScoped: true });
}
get({ page = 1, searchKey, assistantId, filter, source, sort } = {}) {
get({ page = 1, searchKey, assistantId } = {}) {
return axios.get(this.url, {
params: {
page,
search_key: searchKey,
searchKey,
assistant_id: assistantId,
filter,
source,
sort,
},
});
}
sync(id) {
return axios.post(`${this.url}/${id}/sync`);
}
}
export default new CaptainDocument();
@@ -0,0 +1,45 @@
/* global axios */
import ApiClient from '../../ApiClient';
class WhatsappCallsAPI extends ApiClient {
constructor() {
super('whatsapp_calls', { accountScoped: true });
}
show(callId) {
return axios.get(`${this.url}/${callId}`).then(r => r.data);
}
initiate(conversationId, sdpOffer) {
return axios
.post(`${this.url}/initiate`, {
conversation_id: conversationId,
sdp_offer: sdpOffer,
})
.then(r => r.data);
}
accept(callId, sdpAnswer) {
return axios
.post(`${this.url}/${callId}/accept`, { sdp_answer: sdpAnswer })
.then(r => r.data);
}
reject(callId) {
return axios.post(`${this.url}/${callId}/reject`).then(r => r.data);
}
terminate(callId) {
return axios.post(`${this.url}/${callId}/terminate`).then(r => r.data);
}
uploadRecording(callId, blob, filename = 'call-recording.webm') {
const formData = new FormData();
formData.append('recording', blob, filename);
return axios
.post(`${this.url}/${callId}/upload_recording`, formData)
.then(r => r.data);
}
}
export default new WhatsappCallsAPI();
+17 -43
View File
@@ -1,12 +1,21 @@
/* global axios */
import ApiClient from './ApiClient';
const buildParams = params =>
new URLSearchParams(
Object.entries(params).filter(
([key, value]) => value !== undefined && (value !== '' || key === 'q')
)
).toString();
export const buildCompanyParams = (page, sort) => {
let params = `page=${page}`;
if (sort) {
params = `${params}&sort=${sort}`;
}
return params;
};
export const buildSearchParams = (query, page, sort) => {
let params = `q=${encodeURIComponent(query)}&page=${page}`;
if (sort) {
params = `${params}&sort=${sort}`;
}
return params;
};
class CompanyAPI extends ApiClient {
constructor() {
@@ -15,49 +24,14 @@ class CompanyAPI extends ApiClient {
get(params = {}) {
const { page = 1, sort = 'name' } = params;
const requestURL = `${this.url}?${buildParams({ page, sort })}`;
const requestURL = `${this.url}?${buildCompanyParams(page, sort)}`;
return axios.get(requestURL);
}
search(query = '', page = 1, sort = 'name') {
const requestURL = `${this.url}/search?${buildParams({ q: query, page, sort })}`;
const requestURL = `${this.url}/search?${buildSearchParams(query, page, sort)}`;
return axios.get(requestURL);
}
listContacts(id, page = 1) {
return axios.get(`${this.url}/${id}/contacts?${buildParams({ page })}`);
}
listNotes(id) {
return axios.get(`${this.url}/${id}/notes`);
}
listConversations(id) {
return axios.get(`${this.url}/${id}/conversations`);
}
searchContacts(id, query = '', page = 1) {
const requestURL = `${this.url}/${id}/contacts/search?${buildParams({ q: query, page })}`;
return axios.get(requestURL);
}
createContact(id, payload) {
return axios.post(`${this.url}/${id}/contacts`, payload);
}
removeContact(id, contactId) {
return axios.delete(`${this.url}/${id}/contacts/${contactId}`);
}
destroyCustomAttributes(id, customAttributes) {
return axios.post(`${this.url}/${id}/destroy_custom_attributes`, {
custom_attributes: customAttributes,
});
}
destroyAvatar(id) {
return axios.delete(`${this.url}/${id}/avatar`);
}
}
export default new CompanyAPI();
+2 -1
View File
@@ -47,9 +47,10 @@ class ContactAPI extends ApiClient {
return axios.get(`${this.url}/${contactId}/labels`);
}
initiateCall(contactId, inboxId) {
initiateCall(contactId, inboxId, conversationId = null) {
return axios.post(`${this.url}/${contactId}/call`, {
inbox_id: inboxId,
conversation_id: conversationId,
});
}
@@ -1,4 +1,7 @@
import companyAPI from '../companies';
import companyAPI, {
buildCompanyParams,
buildSearchParams,
} from '../companies';
import ApiClient from '../ApiClient';
describe('#CompanyAPI', () => {
@@ -6,6 +9,7 @@ describe('#CompanyAPI', () => {
expect(companyAPI).toBeInstanceOf(ApiClient);
expect(companyAPI).toHaveProperty('get');
expect(companyAPI).toHaveProperty('show');
expect(companyAPI).toHaveProperty('create');
expect(companyAPI).toHaveProperty('update');
expect(companyAPI).toHaveProperty('delete');
expect(companyAPI).toHaveProperty('search');
@@ -28,69 +32,111 @@ describe('#CompanyAPI', () => {
window.axios = originalAxios;
});
it('#get includes pagination and sorting params', () => {
it('#get with default params', () => {
companyAPI.get({});
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies?page=1&sort=name'
);
});
it('#search encodes query params', () => {
companyAPI.search('acme & co', 2, 'domain');
it('#get with page and sort params', () => {
companyAPI.get({ page: 2, sort: 'domain' });
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/search?q=acme+%26+co&page=2&sort=domain'
'/api/v1/companies?page=2&sort=domain'
);
});
it('#search keeps empty query param for backend validation', () => {
it('#get with descending sort', () => {
companyAPI.get({ page: 1, sort: '-created_at' });
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies?page=1&sort=-created_at'
);
});
it('#search with query', () => {
companyAPI.search('acme', 1, 'name');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/search?q=acme&page=1&sort=name'
);
});
it('#search with special characters in query', () => {
companyAPI.search('acme & co', 2, 'domain');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/search?q=acme%20%26%20co&page=2&sort=domain'
);
});
it('#search with descending sort', () => {
companyAPI.search('test', 1, '-created_at');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/search?q=test&page=1&sort=-created_at'
);
});
it('#search with empty query', () => {
companyAPI.search('', 1, 'name');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/search?q=&page=1&sort=name'
);
});
});
});
it('#destroyAvatar deletes the company avatar endpoint', () => {
companyAPI.destroyAvatar(1);
expect(axiosMock.delete).toHaveBeenCalledWith(
'/api/v1/companies/1/avatar'
);
});
it('#listContacts fetches company contacts', () => {
companyAPI.listContacts(1, 2);
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/1/contacts?page=2'
);
});
it('#searchContacts encodes contact search params', () => {
companyAPI.searchContacts(1, 'jane & co', 3);
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/1/contacts/search?q=jane+%26+co&page=3'
);
});
it('#createContact links a contact to the company', () => {
companyAPI.createContact(1, { contact_id: 2 });
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/companies/1/contacts',
{ contact_id: 2 }
);
});
it('#removeContact unlinks a contact from the company', () => {
companyAPI.removeContact(1, 2);
expect(axiosMock.delete).toHaveBeenCalledWith(
'/api/v1/companies/1/contacts/2'
);
});
it('#destroyCustomAttributes removes company custom attributes', () => {
companyAPI.destroyCustomAttributes(1, ['plan']);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/companies/1/destroy_custom_attributes',
{ custom_attributes: ['plan'] }
);
});
describe('#buildCompanyParams', () => {
it('returns correct string with page only', () => {
expect(buildCompanyParams(1)).toBe('page=1');
});
it('returns correct string with page and sort', () => {
expect(buildCompanyParams(1, 'name')).toBe('page=1&sort=name');
});
it('returns correct string with different page', () => {
expect(buildCompanyParams(3, 'domain')).toBe('page=3&sort=domain');
});
it('returns correct string with descending sort', () => {
expect(buildCompanyParams(1, '-created_at')).toBe(
'page=1&sort=-created_at'
);
});
it('returns correct string without sort parameter', () => {
expect(buildCompanyParams(2, '')).toBe('page=2');
});
});
describe('#buildSearchParams', () => {
it('returns correct string with all parameters', () => {
expect(buildSearchParams('acme', 1, 'name')).toBe(
'q=acme&page=1&sort=name'
);
});
it('returns correct string with special characters', () => {
expect(buildSearchParams('acme & co', 2, 'domain')).toBe(
'q=acme%20%26%20co&page=2&sort=domain'
);
});
it('returns correct string with empty query', () => {
expect(buildSearchParams('', 1, 'name')).toBe('q=&page=1&sort=name');
});
it('returns correct string without sort parameter', () => {
expect(buildSearchParams('test', 1, '')).toBe('q=test&page=1');
});
it('returns correct string with descending sort', () => {
expect(buildSearchParams('company', 3, '-created_at')).toBe(
'q=company&page=3&sort=-created_at'
);
});
it('encodes special characters correctly', () => {
expect(buildSearchParams('test@example.com', 1, 'name')).toBe(
'q=test%40example.com&page=1&sort=name'
);
});
});
@@ -1,7 +1,7 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { formatDistanceToNow } from 'date-fns';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
@@ -12,8 +12,9 @@ const props = defineProps({
name: { type: String, default: '' },
domain: { type: String, default: '' },
contactsCount: { type: Number, default: 0 },
description: { type: String, default: '' },
avatarUrl: { type: String, default: '' },
lastActivityAt: { type: [String, Number], default: null },
updatedAt: { type: [String, Number], default: null },
});
const emit = defineEmits(['showCompany']);
@@ -26,21 +27,15 @@ const displayName = computed(() => props.name || t('COMPANIES.UNNAMED'));
const avatarSource = computed(() => props.avatarUrl || null);
const hasContacts = computed(() => Number(props.contactsCount || 0) > 0);
const contactsCountLabel = computed(() =>
t('COMPANIES.CONTACTS_COUNT', { n: Number(props.contactsCount || 0) })
);
const formattedLastActivityAt = computed(() => {
if (!props.lastActivityAt) return '';
return dynamicTime(props.lastActivityAt);
const formattedUpdatedAt = computed(() => {
if (!props.updatedAt) return '';
return formatDistanceToNow(new Date(props.updatedAt), { addSuffix: true });
});
</script>
<template>
<CardLayout layout="row" @click="onClickViewDetails">
<div class="flex items-center justify-start flex-1 gap-4 cursor-pointer">
<div class="flex items-center justify-start flex-1 gap-4">
<Avatar
:username="displayName"
:src="avatarSource"
@@ -56,29 +51,42 @@ const formattedLastActivityAt = computed(() => {
{{ displayName }}
</span>
<span
v-if="hasContacts"
v-if="domain && description"
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
>
<Icon icon="i-lucide-contact" size="size-3.5 text-n-slate-11" />
{{ contactsCountLabel }}
<Icon icon="i-lucide-globe" size="size-3.5 text-n-slate-11" />
<span class="truncate">{{ domain }}</span>
</span>
</div>
<div class="flex items-center justify-between gap-3">
<div class="flex items-center min-w-0">
<div class="flex items-center justify-between">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 min-w-0">
<span
v-if="domain"
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate cursor-text"
@click.stop
v-if="domain && !description"
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
>
<Icon icon="i-lucide-globe" size="size-3.5 text-n-slate-11" />
<span class="truncate">{{ domain }}</span>
</span>
<span v-if="description" class="text-sm text-n-slate-11 truncate">
{{ description }}
</span>
<div
v-if="(description || domain) && contactsCount"
class="w-px h-3 bg-n-slate-6"
/>
<span
v-if="contactsCount"
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
>
<Icon icon="i-lucide-contact" size="size-3.5 text-n-slate-11" />
{{ t('COMPANIES.CONTACTS_COUNT', { n: contactsCount }) }}
</span>
</div>
<span
v-if="lastActivityAt"
v-if="updatedAt"
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 flex-shrink-0"
>
{{ formattedLastActivityAt }}
{{ formattedUpdatedAt }}
</span>
</div>
</div>
@@ -1,125 +0,0 @@
<script setup>
import { ref, useSlots } from 'vue';
import { vOnClickOutside } from '@vueuse/components';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
breadcrumbItems: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['back']);
const slots = useSlots();
const isSidebarOpen = ref(false);
const toggleSidebar = () => {
isSidebarOpen.value = !isSidebarOpen.value;
};
const closeMobileSidebar = () => {
if (!isSidebarOpen.value) {
return;
}
isSidebarOpen.value = false;
};
</script>
<template>
<section
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: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-[40.625rem]">
<div
class="flex flex-col xs:flex-row items-start xs:items-center justify-between w-full py-7 gap-2"
>
<Breadcrumb :items="breadcrumbItems" @click="emit('back')" />
</div>
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto 3xl:px-px">
<div class="w-full py-4 mx-auto max-w-[40.625rem]">
<slot />
</div>
</main>
</div>
<div
v-if="slots.sidebar"
class="hidden lg:flex flex-col min-w-52 w-full max-w-md border-l border-n-weak bg-n-solid-2"
>
<div class="shrink-0">
<slot name="sidebarHeader" />
</div>
<div class="flex-1 overflow-y-auto pb-6 pt-3">
<slot name="sidebar" />
</div>
</div>
<div
v-if="slots.sidebar"
class="lg:hidden fixed top-0 ltr:right-0 rtl:left-0 h-full z-50 flex justify-end transition-all duration-200 ease-in-out"
:class="isSidebarOpen ? 'w-full' : 'w-16'"
>
<div
v-on-click-outside="[
closeMobileSidebar,
{ ignore: ['#details-sidebar-content'] },
]"
class="flex items-start p-1 w-fit h-fit relative order-1 xs:top-24 top-28 transition-all bg-n-solid-2 border border-n-weak duration-500 ease-in-out"
:class="[
isSidebarOpen
? 'justify-end ltr:rounded-l-full rtl:rounded-r-full ltr:rounded-r-none rtl:rounded-l-none'
: 'justify-center rounded-full ltr:mr-6 rtl:ml-6',
]"
>
<Button
ghost
slate
sm
class="!rounded-full rtl:rotate-180"
:class="{ 'bg-n-alpha-2': isSidebarOpen }"
:icon="
isSidebarOpen
? 'i-lucide-panel-right-close'
: 'i-lucide-panel-right-open'
"
data-details-sidebar-toggle
@click="toggleSidebar"
/>
</div>
<Transition
enter-active-class="transition-transform duration-200 ease-in-out"
leave-active-class="transition-transform duration-200 ease-in-out"
enter-from-class="ltr:translate-x-full rtl:-translate-x-full"
enter-to-class="ltr:translate-x-0 rtl:-translate-x-0"
leave-from-class="ltr:translate-x-0 rtl:-translate-x-0"
leave-to-class="ltr:translate-x-full rtl:-translate-x-full"
>
<div
v-if="isSidebarOpen"
id="details-sidebar-content"
class="order-2 w-[85%] sm:w-[50%] flex flex-col bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak shadow-lg"
>
<div class="shrink-0">
<slot name="sidebarHeader" />
</div>
<div class="flex-1 overflow-y-auto pb-6 pt-3">
<slot name="sidebar" />
</div>
</div>
</Transition>
</div>
</section>
</template>
@@ -2,7 +2,6 @@
import Input from 'dashboard/components-next/input/Input.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import CompanySortMenu from './components/CompanySortMenu.vue';
import CompanyMoreActions from './components/CompanyMoreActions.vue';
defineProps({
showSearch: { type: Boolean, default: true },
@@ -12,7 +11,7 @@ defineProps({
activeOrdering: { type: String, default: '' },
});
const emit = defineEmits(['search', 'update:sort', 'create']);
const emit = defineEmits(['search', 'update:sort']);
</script>
<template>
@@ -20,15 +19,22 @@ const emit = defineEmits(['search', 'update:sort', 'create']);
<div
class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl"
>
<span class="text-xl font-medium truncate text-n-slate-12">
<span class="text-heading-1 truncate text-n-slate-12">
{{ headerTitle }}
</span>
<div class="flex items-center flex-col sm:flex-row flex-shrink-0 gap-4">
<div class="flex items-center flex-row flex-shrink-0 gap-2">
<div class="flex items-center">
<CompanySortMenu
:active-sort="activeSort"
:active-ordering="activeOrdering"
@update:sort="emit('update:sort', $event)"
/>
</div>
<div v-if="showSearch" class="flex items-center gap-2 w-full">
<Input
:model-value="searchValue"
type="search"
:placeholder="$t('COMPANIES.SEARCH_PLACEHOLDER')"
:placeholder="$t('CONTACTS_LAYOUT.HEADER.SEARCH_PLACEHOLDER')"
:custom-input-class="[
'h-8 [&:not(.focus)]:!border-transparent bg-n-alpha-2 dark:bg-n-solid-1 ltr:!pl-8 !py-1 rtl:!pr-8',
]"
@@ -43,14 +49,6 @@ const emit = defineEmits(['search', 'update:sort', 'create']);
</template>
</Input>
</div>
<div class="flex items-center flex-shrink-0 gap-2">
<CompanySortMenu
:active-sort="activeSort"
:active-ordering="activeOrdering"
@update:sort="emit('update:sort', $event)"
/>
<CompanyMoreActions @create="emit('create')" />
</div>
</div>
</div>
</header>
@@ -1,45 +0,0 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const emit = defineEmits(['create']);
const { t } = useI18n();
const showActionsDropdown = ref(false);
const menuItems = [
{
label: t('COMPANIES.ACTIONS.CREATE'),
action: 'create',
value: 'create',
icon: 'i-lucide-plus',
},
];
const handleAction = ({ action }) => {
if (action === 'create') emit('create');
showActionsDropdown.value = false;
};
</script>
<template>
<div v-on-clickaway="() => (showActionsDropdown = false)" class="relative">
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
variant="ghost"
size="sm"
:class="showActionsDropdown ? 'bg-n-alpha-2' : ''"
@click="showActionsDropdown = !showActionsDropdown"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="menuItems"
class="ltr:right-0 rtl:left-0 mt-1 w-52 top-full"
@action="handleAction($event)"
/>
</div>
</template>
@@ -35,10 +35,6 @@ const sortMenus = [
label: t('COMPANIES.SORT_BY.OPTIONS.CREATED_AT'),
value: 'created_at',
},
{
label: t('COMPANIES.SORT_BY.OPTIONS.LAST_ACTIVITY_AT'),
value: 'last_activity_at',
},
{
label: t('COMPANIES.SORT_BY.OPTIONS.CONTACTS_COUNT'),
value: 'contacts_count',
@@ -105,7 +101,6 @@ const handleOrderChange = value => {
:model-value="activeSort"
:options="sortMenus"
:label="activeSortLabel"
sub-menu-position="left"
@update:model-value="handleSortChange"
/>
</div>
@@ -117,7 +112,6 @@ const handleOrderChange = value => {
:model-value="activeOrdering"
:options="orderingMenus"
:label="activeOrderingLabel"
sub-menu-position="left"
@update:model-value="handleOrderChange"
/>
</div>
@@ -12,12 +12,7 @@ defineProps({
showPaginationFooter: { type: Boolean, default: true },
});
const emit = defineEmits([
'update:currentPage',
'update:sort',
'search',
'create',
]);
const emit = defineEmits(['update:currentPage', 'update:sort', 'search']);
const updateCurrentPage = page => {
emit('update:currentPage', page);
@@ -36,7 +31,6 @@ const updateCurrentPage = page => {
:active-ordering="activeOrdering"
@search="emit('search', $event)"
@update:sort="emit('update:sort', $event)"
@create="emit('create')"
/>
<main class="flex-1 px-6 overflow-y-auto">
<div class="w-full mx-auto max-w-5xl py-4">
@@ -1,110 +0,0 @@
<script setup>
import { computed, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
defineProps({
isLoading: { type: Boolean, default: false },
});
const emit = defineEmits(['create']);
const { t } = useI18n();
const dialogRef = ref(null);
const form = reactive({ name: '', domain: '', description: '' });
const isFormInvalid = computed(() => !form.name.trim());
const resetForm = () => {
form.name = '';
form.domain = '';
form.description = '';
};
const handleConfirm = () => {
if (isFormInvalid.value) return;
emit('create', {
name: form.name.trim(),
domain: form.domain.trim() || null,
description: form.description.trim() || null,
});
};
const closeDialog = () => {
dialogRef.value?.close();
};
const onSuccess = () => {
resetForm();
closeDialog();
};
defineExpose({ dialogRef, onSuccess });
</script>
<template>
<Dialog
ref="dialogRef"
width="3xl"
overflow-y-auto
@confirm="handleConfirm"
@close="resetForm"
>
<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('COMPANIES.CREATE.TITLE') }}
</span>
<div class="grid w-full grid-cols-1 gap-4 sm:grid-cols-2">
<Input
v-model="form.name"
:placeholder="t('COMPANIES.DETAIL.PROFILE.FIELDS.NAME')"
:disabled="isLoading"
custom-input-class="h-8 !pt-1 !pb-1 [&:not(.error,.focus)]:!outline-transparent"
autofocus
/>
<Input
v-model="form.domain"
:placeholder="t('COMPANIES.DETAIL.PROFILE.FIELDS.DOMAIN')"
:disabled="isLoading"
custom-input-class="h-8 !pt-1 !pb-1 [&:not(.error,.focus)]:!outline-transparent"
/>
</div>
</div>
<TextArea
v-model="form.description"
:placeholder="t('COMPANIES.DETAIL.PROFILE.DESCRIPTION_PLACEHOLDER')"
:disabled="isLoading"
:max-length="280"
class="w-full"
show-character-count
auto-height
/>
</div>
<template #footer>
<div class="flex items-center justify-between w-full gap-3">
<Button
:label="t('DIALOG.BUTTONS.CANCEL')"
variant="link"
type="reset"
class="h-10 hover:!no-underline hover:text-n-brand"
@click="closeDialog"
/>
<Button
:label="t('COMPANIES.CREATE.ACTIONS.SAVE')"
color="blue"
type="submit"
:disabled="isFormInvalid || isLoading"
:is-loading="isLoading"
/>
</div>
</template>
</Dialog>
</template>
@@ -1,354 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { debounce } from '@chatwoot/utils';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
const props = defineProps({
company: {
type: Object,
default: () => ({}),
},
contacts: {
type: Array,
default: () => [],
},
meta: {
type: Object,
default: () => ({}),
},
isLoading: {
type: Boolean,
default: false,
},
isBusy: {
type: Boolean,
default: false,
},
searchResults: {
type: Array,
default: () => [],
},
isSearching: {
type: Boolean,
default: false,
},
selectedContact: {
type: Object,
default: null,
},
});
const emit = defineEmits([
'cancelContactSelection',
'confirmContactSelection',
'removeContact',
'search',
'selectContact',
'update:currentPage',
]);
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const selectedContactId = ref(null);
const searchQuery = ref('');
const hasContacts = computed(() => props.contacts.length > 0);
const currentPage = computed(() => Number(props.meta?.page || 1));
const totalContacts = computed(() => Number(props.meta?.totalCount || 0));
const linkedContactIds = computed(
() => new Set(props.contacts.map(contact => Number(contact.id)))
);
const showPaginationFooter = computed(
() => hasContacts.value && totalContacts.value > props.contacts.length
);
const openContact = contactId => {
router.push({
name: 'contacts_edit',
params: {
accountId: route.params.accountId,
contactId,
},
});
};
const contactMeta = contact =>
[contact.email, contact.phoneNumber].filter(Boolean).join(' • ');
const contactName = contact =>
contact.name || t('COMPANIES.DETAIL.CONTACTS.UNNAMED_CONTACT');
const contactOptions = computed(() =>
props.searchResults
.filter(
contact =>
!contact.linkedToCurrentCompany &&
!linkedContactIds.value.has(Number(contact.id))
)
.map(contact => ({
value: contact.id,
label: [contactName(contact), contact.email, contact.phoneNumber]
.filter(Boolean)
.join(' · '),
}))
);
const emptyState = computed(() => {
if (props.isSearching) {
return t('COMPANIES.DETAIL.CONTACTS.LOADING');
}
return searchQuery.value.trim()
? t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.EMPTY')
: t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.INITIAL');
});
const selectedContactName = computed(() =>
props.selectedContact ? contactName(props.selectedContact) : ''
);
const selectedContactMeta = computed(() =>
props.selectedContact ? contactMeta(props.selectedContact) : ''
);
const selectedContactCompanyName = computed(
() => props.selectedContact?.company?.name || ''
);
const summaryRows = computed(() => [
{
key: 'company',
label: t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.COMPANY_LABEL'),
avatarName: props.company.name || t('COMPANIES.UNNAMED'),
avatarSrc: props.company.avatarUrl,
primary: props.company.name || t('COMPANIES.UNNAMED'),
secondary: props.company.domain,
},
{
key: 'contact',
label: t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.CONTACT_LABEL'),
badge: selectedContactCompanyName.value
? t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.CURRENT_COMPANY', {
companyName: selectedContactCompanyName.value,
})
: '',
avatarName: selectedContactName.value,
avatarSrc: props.selectedContact?.thumbnail,
primary: selectedContactName.value,
secondary: selectedContactMeta.value,
},
]);
const debouncedSearch = debounce(query => {
emit('search', query);
}, 300);
const handleSearch = query => {
searchQuery.value = query;
debouncedSearch(query.trim());
};
const handleContactSelect = contactId => {
const selectedContact = props.searchResults.find(
contact => contact.id === Number(contactId)
);
selectedContactId.value = null;
if (selectedContact) {
emit('selectContact', selectedContact);
}
};
</script>
<template>
<div class="flex flex-col gap-6 px-6 pb-8">
<div v-if="!selectedContact" class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<label class="text-base text-n-slate-12">
{{ t('COMPANIES.DETAIL.CONTACTS.ACTIONS.ADD') }}
</label>
<span class="text-sm text-n-slate-11">
{{ t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.DESCRIPTION') }}
</span>
</div>
<ComboBox
use-api-results
:model-value="selectedContactId"
:options="contactOptions"
:disabled="isBusy"
:empty-state="emptyState"
:search-placeholder="
t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.SEARCH_PLACEHOLDER')
"
:placeholder="t('COMPANIES.DETAIL.CONTACTS.ACTIONS.ADD')"
class="[&>div>button]:bg-n-alpha-black2"
@search="handleSearch"
@update:model-value="handleContactSelect"
/>
</div>
<div v-else class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<label class="text-base text-n-slate-12">
{{ t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.CONFIRM_TITLE') }}
</label>
<span class="text-sm text-n-slate-11">
{{ t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.CONFIRM_DESCRIPTION') }}
</span>
</div>
<div class="flex flex-col gap-4">
<div
v-for="row in summaryRows"
:key="row.key"
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">
{{ row.label }}
</label>
<span
v-if="row.badge"
class="px-2 py-0.5 text-xs rounded-md text-n-amber-11 bg-n-alpha-2"
>
{{ row.badge }}
</span>
</div>
<div
class="border border-n-strong h-[60px] gap-2 flex items-center rounded-xl p-3"
>
<Avatar
:name="row.avatarName"
:src="row.avatarSrc"
:size="32"
rounded-full
hide-offline-status
/>
<div class="flex flex-col w-full min-w-0 gap-1">
<span
class="text-sm leading-4 font-medium truncate text-n-slate-12"
>
{{ row.primary }}
</span>
<span
v-if="row.secondary"
class="text-sm leading-4 truncate text-n-slate-11"
>
{{ row.secondary }}
</span>
</div>
</div>
</div>
</div>
<div class="flex items-center justify-between gap-3 mt-2">
<Button
variant="faded"
color="slate"
:label="t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
:disabled="isBusy"
@click="emit('cancelContactSelection')"
/>
<Button
:label="t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.ADD')"
class="w-full"
:is-loading="isBusy"
:disabled="isBusy"
@click="emit('confirmContactSelection')"
/>
</div>
</div>
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<h4 class="text-sm font-medium text-n-slate-12">
{{ t('COMPANIES.DETAIL.SIDEBAR.TABS.CONTACTS') }}
</h4>
<span v-if="hasContacts" class="text-xs tabular-nums text-n-slate-11">
{{ t('COMPANIES.CONTACTS_COUNT', { n: totalContacts }) }}
</span>
</div>
<div
v-if="isLoading && !hasContacts"
class="py-8 text-sm text-center rounded-xl border border-dashed border-n-weak text-n-slate-11"
>
{{ t('COMPANIES.DETAIL.CONTACTS.LOADING') }}
</div>
<div
v-else-if="!hasContacts"
class="py-8 px-4 text-sm text-center rounded-xl border border-dashed border-n-strong text-n-slate-11"
>
{{ t('COMPANIES.DETAIL.CONTACTS.EMPTY') }}
</div>
<div v-else class="flex flex-col divide-y divide-n-weak">
<div
v-for="contact in contacts"
:key="contact.id"
class="flex items-center gap-2 py-3 group/contact"
>
<button
type="button"
class="flex items-center flex-1 min-w-0 !p-0 gap-3 text-start rounded-lg transition-colors text-n-slate-12 hover:text-n-blue-11 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-n-brand focus-visible:ring-offset-2 focus-visible:ring-offset-n-background"
@click="openContact(contact.id)"
>
<Avatar
:name="contactName(contact)"
:src="contact.thumbnail"
:size="32"
rounded-full
hide-offline-status
/>
<div class="min-w-0 space-y-0.5">
<span
class="text-sm font-medium leading-5 truncate text-n-slate-12"
>
{{ contactName(contact) }}
</span>
<p
v-if="contactMeta(contact)"
class="text-sm leading-5 truncate text-n-slate-11"
>
{{ contactMeta(contact) }}
</p>
</div>
</button>
<Button
icon="i-lucide-unlink"
color="slate"
variant="ghost"
size="xs"
class="shrink-0 opacity-70 transition-opacity sm:opacity-0 sm:group-hover/contact:opacity-100 sm:focus-visible:opacity-100"
:disabled="isBusy"
:title="t('COMPANIES.DETAIL.CONTACTS.ACTIONS.REMOVE')"
:aria-label="t('COMPANIES.DETAIL.CONTACTS.ACTIONS.REMOVE')"
@click.stop="emit('removeContact', contact.id)"
/>
</div>
</div>
<PaginationFooter
v-if="showPaginationFooter"
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage"
:total-items="totalContacts"
:items-per-page="15"
class="!px-0 before:hidden bg-transparent"
@update:current-page="emit('update:currentPage', $event)"
/>
</div>
</div>
</template>
@@ -1,94 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useCompaniesStore } from 'dashboard/stores/companies';
import ListAttribute from 'dashboard/components-next/CustomAttributes/ListAttribute.vue';
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';
const props = defineProps({
companyId: {
type: Number,
required: true,
},
attribute: {
type: Object,
required: true,
},
isEditingView: {
type: Boolean,
default: false,
},
});
const companiesStore = useCompaniesStore();
const { t } = useI18n();
const handleDelete = async () => {
try {
await companiesStore.deleteCustomAttributes({
id: props.companyId,
customAttributes: [props.attribute.attributeKey],
});
useAlert(t('COMPANIES.DETAIL.ATTRIBUTES.MESSAGES.DELETE_SUCCESS'));
} catch (error) {
useAlert(
error?.response?.message ||
t('COMPANIES.DETAIL.ATTRIBUTES.MESSAGES.DELETE_ERROR')
);
}
};
const handleUpdate = async value => {
try {
await companiesStore.update({
id: props.companyId,
customAttributes: {
[props.attribute.attributeKey]: value,
},
});
useAlert(t('COMPANIES.DETAIL.ATTRIBUTES.MESSAGES.UPDATE_SUCCESS'));
} catch (error) {
useAlert(
error?.response?.message ||
t('COMPANIES.DETAIL.ATTRIBUTES.MESSAGES.UPDATE_ERROR')
);
}
};
const componentMap = {
list: ListAttribute,
checkbox: CheckboxAttribute,
date: DateAttribute,
default: OtherAttribute,
};
const CurrentAttributeComponent = computed(
() =>
componentMap[props.attribute.attributeDisplayType] || componentMap.default
);
</script>
<template>
<div
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 justify-between truncate">
<span class="text-sm font-medium truncate text-n-slate-12">
{{ attribute.attributeDisplayName }}
</span>
</div>
<component
:is="CurrentAttributeComponent"
:attribute="attribute"
:is-editing-view="isEditingView"
@update="handleUpdate"
@delete="handleDelete"
/>
</div>
</template>
@@ -1,142 +0,0 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import CompanyCustomAttributeItem from 'dashboard/components-next/Companies/CompanyDetail/CompanyCustomAttributeItem.vue';
const props = defineProps({
company: {
type: Object,
default: () => ({}),
},
});
const { t } = useI18n();
const store = useStore();
const searchQuery = ref('');
const companyAttributes = useMapGetter('attributes/getCompanyAttributes');
const customAttributes = computed(() => props.company?.customAttributes || {});
const hasCompanyAttributes = computed(
() => companyAttributes.value?.length > 0
);
const processCompanyAttributes = (
attributes,
attributeValues,
filterCondition
) => {
if (!attributes.length) {
return [];
}
return attributes.reduce((result, attribute) => {
const { attributeKey } = attribute;
if (filterCondition(attributeKey, attributeValues)) {
result.push({
...attribute,
value: attributeValues[attributeKey] ?? '',
});
}
return result;
}, []);
};
const usedAttributes = computed(() =>
processCompanyAttributes(
companyAttributes.value,
customAttributes.value,
(key, values) => key in values
)
);
const unusedAttributes = computed(() =>
processCompanyAttributes(
companyAttributes.value,
customAttributes.value,
(key, values) => !(key in values)
)
);
const filteredUnusedAttributes = computed(() =>
unusedAttributes.value.filter(attribute =>
attribute.attributeDisplayName
.toLowerCase()
.includes(searchQuery.value.toLowerCase())
)
);
const unusedAttributesCount = computed(() => unusedAttributes.value.length);
const hasNoUnusedAttributes = computed(() => unusedAttributesCount.value === 0);
const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
onMounted(() => {
store.dispatch('attributes/get');
});
</script>
<template>
<div v-if="hasCompanyAttributes" class="flex flex-col gap-6 px-6 py-6">
<div v-if="!hasNoUsedAttributes" class="flex flex-col gap-2">
<CompanyCustomAttributeItem
v-for="attribute in usedAttributes"
:key="`${company.id}-${attribute.id}`"
is-editing-view
:company-id="company.id"
:attribute="attribute"
/>
</div>
<div v-if="!hasNoUnusedAttributes" class="flex items-center gap-3">
<div class="flex-1 h-px bg-n-slate-5" />
<span class="text-sm font-medium text-n-slate-10">
{{
t('COMPANIES.DETAIL.ATTRIBUTES.UNUSED_ATTRIBUTES', {
count: unusedAttributesCount,
})
}}
</span>
<div class="flex-1 h-px bg-n-slate-5" />
</div>
<div class="flex flex-col gap-3">
<div v-if="!hasNoUnusedAttributes" class="relative">
<span
class="absolute i-lucide-search size-3.5 top-2 ltr:left-3 rtl:right-3"
/>
<input
v-model="searchQuery"
type="search"
:placeholder="t('COMPANIES.DETAIL.ATTRIBUTES.SEARCH_PLACEHOLDER')"
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
v-if="filteredUnusedAttributes.length === 0 && !hasNoUnusedAttributes"
class="flex items-center justify-start h-11"
>
<p class="text-sm text-n-slate-11">
{{ t('COMPANIES.DETAIL.ATTRIBUTES.NO_ATTRIBUTES') }}
</p>
</div>
<div v-if="!hasNoUnusedAttributes" class="flex flex-col gap-2">
<CompanyCustomAttributeItem
v-for="attribute in filteredUnusedAttributes"
:key="`${company.id}-${attribute.id}`"
:company-id="company.id"
:attribute="attribute"
/>
</div>
</div>
</div>
<p v-else class="px-6 py-10 text-sm leading-6 text-center text-n-slate-11">
{{ t('COMPANIES.DETAIL.ATTRIBUTES.EMPTY_STATE') }}
</p>
</template>
@@ -1,67 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import ConversationCard from 'dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
defineProps({
conversations: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
const contactsById = useMapGetter('contacts/getContactById');
const stateInbox = useMapGetter('inboxes/getInboxById');
const accountLabels = useMapGetter('labels/getLabels');
const accountLabelsValue = computed(() => accountLabels.value);
const conversationContact = conversation => {
const sender = conversation.meta?.sender || {};
const contact = contactsById.value(sender.id);
return contact.id ? contact : sender;
};
const conversationInbox = conversation =>
stateInbox.value(conversation.inboxId) || {
name: '',
channelType: conversation.meta?.channel,
};
</script>
<template>
<div
v-if="isLoading"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div
v-else-if="conversations.length > 0"
class="px-6 divide-y divide-n-strong [&>*:hover]:!border-y-transparent [&>*:hover+*]:!border-t-transparent"
>
<ConversationCard
v-for="conversation in conversations"
:key="conversation.id"
:conversation="conversation"
:contact="conversationContact(conversation)"
:state-inbox="conversationInbox(conversation)"
: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="py-8 px-4 mx-6 text-sm text-center rounded-xl border border-dashed border-n-strong text-n-slate-11"
>
{{ t('COMPANIES.DETAIL.HISTORY.EMPTY') }}
</p>
</template>
@@ -1,114 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
notes: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
});
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const { formatMessage } = useMessageFormatter();
const currentUser = useMapGetter('getCurrentUser');
const hasNotes = computed(() => props.notes.length > 0);
const contactName = contact =>
contact?.name || t('COMPANIES.DETAIL.CONTACTS.UNNAMED_CONTACT');
const getWrittenBy = note => {
const isCurrentUser = note?.user?.id === currentUser.value.id;
return isCurrentUser
? t('CONTACTS_LAYOUT.SIDEBAR.NOTES.YOU')
: note?.user?.name || 'Bot';
};
const openContact = contactId => {
router.push({
name: 'contacts_edit',
params: {
accountId: route.params.accountId,
contactId,
},
});
};
</script>
<template>
<div v-if="hasNotes" class="flex flex-col px-6">
<div class="flex flex-col divide-y divide-n-strong">
<div
v-for="note in notes"
:key="note.id"
class="flex flex-col gap-2 py-4 group/note"
>
<div class="flex items-center gap-1.5 min-w-0">
<Avatar
:name="contactName(note.contact)"
:src="note.contact?.thumbnail"
:size="16"
rounded-full
hide-offline-status
/>
<div
class="flex items-center justify-between min-w-0 gap-1 w-full text-sm text-n-slate-11"
>
<button
type="button"
class="min-w-0 font-medium truncate text-start text-n-slate-12 hover:text-n-blue-11 p-0"
@click="openContact(note.contact.id)"
>
{{ contactName(note.contact) }}
</button>
<div class="min-w-0 truncate">
<span
class="inline-flex items-center gap-1 text-sm text-n-slate-10"
>
<span class="font-medium text-n-slate-11">
{{ getWrittenBy(note) }}
</span>
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.WROTE') }}
<span class="font-medium text-n-slate-11">
{{ dynamicTime(note.createdAt) }}
</span>
</span>
</div>
</div>
</div>
<p
v-dompurify-html="formatMessage(note.content || '')"
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"
/>
</div>
</div>
</div>
<div
v-else-if="isLoading"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<p
v-else
class="py-8 mx-6 px-4 text-sm text-center rounded-xl border border-dashed border-n-strong text-n-slate-11"
>
{{ t('COMPANIES.DETAIL.NOTES.EMPTY') }}
</p>
</template>
@@ -1,203 +0,0 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { dynamicTime } from 'shared/helpers/timeHelper';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import { useCompaniesStore } from 'dashboard/stores/companies';
const props = defineProps({
company: { type: Object, default: () => ({}) },
isLoading: { type: Boolean, default: false },
});
const { t } = useI18n();
const companiesStore = useCompaniesStore();
const form = reactive({ name: '', domain: '', description: '' });
const avatarPreviewUrl = ref('');
const isUploadingAvatar = ref(false);
const uiFlags = computed(() => companiesStore.getUIFlags);
const isUpdating = computed(() => uiFlags.value.updatingItem);
const isAvatarBusy = computed(
() =>
isUploadingAvatar.value || uiFlags.value.deletingAvatar || isUpdating.value
);
const displayName = computed(
() => props.company?.name || t('COMPANIES.UNNAMED')
);
const avatarSource = computed(
() => avatarPreviewUrl.value || props.company?.avatarUrl || ''
);
const isFormInvalid = computed(() => !form.name.trim());
const hasChanges = computed(
() =>
form.name.trim() !== (props.company?.name || '').trim() ||
form.domain.trim() !== (props.company?.domain || '').trim() ||
form.description.trim() !== (props.company?.description || '').trim()
);
const summary = computed(() => {
const { createdAt, lastActivityAt } = props.company || {};
return [
createdAt &&
t('COMPANIES.DETAIL.PROFILE.CREATED_AT', {
date: dynamicTime(createdAt),
}),
lastActivityAt &&
t('COMPANIES.DETAIL.PROFILE.LAST_ACTIVE', {
date: dynamicTime(lastActivityAt),
}),
]
.filter(Boolean)
.join(' • ');
});
const syncForm = company => {
form.name = company?.name || '';
form.domain = company?.domain || '';
form.description = company?.description || '';
};
const isCurrentCompany = companyId => Number(props.company?.id) === companyId;
watch(
() => [
props.company?.id,
props.company?.name,
props.company?.domain,
props.company?.description,
props.company?.avatarUrl,
],
() => {
avatarPreviewUrl.value = '';
syncForm(props.company);
},
{ immediate: true }
);
const handleAvatarUpload = async ({ file, url }) => {
avatarPreviewUrl.value = url;
isUploadingAvatar.value = true;
try {
await companiesStore.update({ id: props.company.id, avatar: file });
useAlert(t('COMPANIES.DETAIL.AVATAR.UPLOAD_SUCCESS'));
} catch {
avatarPreviewUrl.value = '';
useAlert(t('COMPANIES.DETAIL.AVATAR.UPLOAD_ERROR'));
} finally {
isUploadingAvatar.value = false;
}
};
const handleAvatarDelete = async () => {
try {
await companiesStore.deleteCompanyAvatar(props.company.id);
avatarPreviewUrl.value = '';
useAlert(t('COMPANIES.DETAIL.AVATAR.DELETE_SUCCESS'));
} catch {
useAlert(t('COMPANIES.DETAIL.AVATAR.DELETE_ERROR'));
}
};
const handleUpdateCompany = async () => {
const companyId = Number(props.company.id);
try {
const updated = await companiesStore.update({
id: companyId,
name: form.name.trim(),
domain: form.domain.trim() || null,
description: form.description.trim() || null,
});
if (!isCurrentCompany(companyId)) return;
syncForm(updated);
useAlert(t('COMPANIES.DETAIL.PROFILE.MESSAGES.UPDATE_SUCCESS'));
} catch {
if (!isCurrentCompany(companyId)) return;
syncForm(props.company);
useAlert(t('COMPANIES.DETAIL.PROFILE.MESSAGES.UPDATE_ERROR'));
}
};
</script>
<template>
<div v-if="isLoading && !company?.id" class="text-sm text-n-slate-11">
{{ t('COMPANIES.DETAIL.LOADING') }}
</div>
<div v-else-if="company?.id" class="flex flex-col items-start gap-8 pb-6">
<div class="flex flex-col items-start gap-3">
<Avatar
:name="displayName"
:src="avatarSource"
:size="72"
:allow-upload="!isAvatarBusy"
rounded-full
hide-offline-status
@upload="handleAvatarUpload"
@delete="handleAvatarDelete"
/>
<div class="flex flex-col gap-1">
<h3 class="text-base font-medium text-n-slate-12">
{{ displayName }}
</h3>
<span class="text-sm leading-6 text-n-slate-11">{{ summary }}</span>
<p
v-if="isUploadingAvatar || uiFlags.deletingAvatar"
class="text-sm text-n-slate-11"
>
{{ t('COMPANIES.DETAIL.AVATAR.UPDATING') }}
</p>
</div>
</div>
<div class="flex flex-col items-start w-full gap-6">
<span class="py-1 text-sm font-medium text-n-slate-12">
{{ t('COMPANIES.DETAIL.PROFILE.TITLE') }}
</span>
<div class="grid w-full gap-4 sm:grid-cols-2">
<Input
v-model="form.name"
:placeholder="t('COMPANIES.DETAIL.PROFILE.FIELDS.NAME')"
:disabled="isUpdating"
custom-input-class="h-8 !pt-1 !pb-1"
/>
<Input
v-model="form.domain"
:placeholder="t('COMPANIES.DETAIL.PROFILE.FIELDS.DOMAIN')"
:disabled="isUpdating"
custom-input-class="h-8 !pt-1 !pb-1"
/>
</div>
<TextArea
v-model="form.description"
:placeholder="t('COMPANIES.DETAIL.PROFILE.DESCRIPTION_PLACEHOLDER')"
:disabled="isUpdating"
:max-length="280"
class="w-full"
show-character-count
auto-height
/>
<Button
:label="t('COMPANIES.DETAIL.PROFILE.ACTIONS.SAVE')"
size="sm"
:is-loading="isUpdating"
:disabled="isUpdating || isFormInvalid || !hasChanges"
@click="handleUpdateCompany"
/>
</div>
</div>
</template>
@@ -1,45 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
company: {
type: Object,
default: () => ({}),
},
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['confirm']);
const { t } = useI18n();
const dialogRef = ref(null);
const description = computed(() =>
props.company?.name
? t('COMPANIES.DETAIL.DELETE.DESCRIPTION_WITH_NAME', {
companyName: props.company.name,
})
: t('COMPANIES.DETAIL.DELETE.DESCRIPTION')
);
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="alert"
:title="t('COMPANIES.DETAIL.DELETE.TITLE')"
:description="description"
:confirm-button-label="t('COMPANIES.DETAIL.DELETE.CONFIRM')"
:is-loading="isLoading"
@confirm="emit('confirm')"
/>
</template>
@@ -32,7 +32,7 @@ const emit = defineEmits([
</script>
<template>
<header class="sticky top-0 z-20 px-6">
<header class="sticky top-0 z-10 px-6">
<div
class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl"
>
@@ -105,7 +105,7 @@ const handleOrderChange = value => {
<div
v-if="isMenuOpen"
v-on-clickaway="() => (isMenuOpen = false)"
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 z-50"
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">
@@ -3,10 +3,16 @@ import { computed, ref, useAttrs } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
import {
isVoiceCallEnabled,
getVoiceCallProvider,
VOICE_CALL_PROVIDERS,
} from 'dashboard/helper/inbox';
import { useAlert } from 'dashboard/composables';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { useCallsStore } from 'dashboard/stores/calls';
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
import ContactAPI from 'dashboard/api/contacts';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
@@ -14,6 +20,9 @@ import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
phone: { type: String, default: '' },
contactId: { type: [String, Number], required: true },
// When set, the WhatsApp call continues in this conversation (matching the
// header button) instead of looking up the contact's most recent one.
conversationId: { type: [String, Number], default: null },
label: { type: String, default: '' },
icon: { type: [String, Object, Function], default: '' },
size: { type: String, default: 'sm' },
@@ -38,7 +47,6 @@ const voiceInboxes = computed(() =>
);
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
// Unified behavior: hide when no phone
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
const isInitiatingCall = computed(() => {
@@ -58,17 +66,76 @@ const navigateToConversation = conversationId => {
}
};
const startCall = async inboxId => {
const whatsappCallSession = useWhatsappCallSession();
// Find the most recent open conversation for this contact in the picked inbox.
// WhatsApp /initiate is conversation-scoped (unlike Twilio's contact-scoped path).
const findWhatsappConversationId = async inboxId => {
const { data } = await ContactAPI.getConversations(props.contactId);
const conversations = data?.payload || [];
const match = conversations
.filter(c => c.inbox_id === inboxId)
.sort((a, b) => (b.last_activity_at || 0) - (a.last_activity_at || 0))[0];
return match?.id || null;
};
const startWhatsappCall = async (inboxId, conversationIdHint) => {
// WhatsApp /initiate is conversation-scoped, so we must hand it a
// conversation. Use the caller's hint when given (in-conversation flow);
// otherwise pick the most recent one in the inbox.
const conversationId =
conversationIdHint || (await findWhatsappConversationId(inboxId));
if (!conversationId) {
useAlert(t('CONTACT_PANEL.CALL_FAILED'));
return;
}
const response =
await whatsappCallSession.initiateOutboundCall(conversationId);
if (!response?.id) {
// Permission flow returns no id — banner already handled server-side; surface to user.
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
navigateToConversation(conversationId);
return;
}
const callsStore = useCallsStore();
// Stay non-active until the connect cable event arrives — flipping to active
// here would start the duration timer before the contact picks up.
callsStore.addCall({
callSid: response.call_id,
callId: response.id,
conversationId,
inboxId,
callDirection: 'outbound',
provider: 'whatsapp',
});
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
navigateToConversation(conversationId);
};
const startCall = async (inboxId, conversationIdHint = null) => {
if (isInitiatingCall.value) return;
const inbox = (inboxesList.value || []).find(i => i.id === inboxId);
if (getVoiceCallProvider(inbox) === VOICE_CALL_PROVIDERS.WHATSAPP) {
try {
await startWhatsappCall(inboxId, conversationIdHint);
} catch (error) {
useAlert(error?.message || t('CONTACT_PANEL.CALL_FAILED'));
}
return;
}
try {
const response = await store.dispatch('contacts/initiateCall', {
contactId: props.contactId,
inboxId,
conversationId: conversationIdHint,
});
const { call_sid: callSid, conversation_id: conversationId } = response;
// Add call to store immediately so widget shows
const callsStore = useCallsStore();
callsStore.addCall({
callSid,
@@ -86,6 +153,22 @@ const startCall = async inboxId => {
};
const onClick = async () => {
// In conversation context, only stay in this conversation if its inbox is
// itself voice-capable (works the same for Twilio and WhatsApp). For
// non-voice channels (email, web, …) fall back to the picker so the call
// goes out via a voice inbox.
if (props.conversationId) {
const conversation = store.getters.getConversationById(
props.conversationId
);
const conversationInbox = (inboxesList.value || []).find(
i => i.id === conversation?.inbox_id
);
if (conversationInbox && isVoiceCallEnabled(conversationInbox)) {
await startCall(conversationInbox.id, props.conversationId);
return;
}
}
if (voiceInboxes.value.length > 1) {
dialogRef.value?.open();
return;
@@ -134,7 +134,7 @@ const handleInputUpdate = async () => {
:message-type="hasError ? 'error' : 'info'"
autofocus
custom-input-class="h-8 ltr:rounded-r-none rtl:rounded-l-none"
@enter="handleInputUpdate"
@keyup.enter="handleInputUpdate"
/>
<Button
icon="i-lucide-check"
@@ -191,7 +191,7 @@ const handleInputUpdate = async () => {
:message="attributeErrorMessage"
:message-type="hasError ? 'error' : 'info'"
custom-input-class="h-8 ltr:rounded-r-none rtl:rounded-l-none"
@enter="handleInputUpdate"
@keyup.enter="handleInputUpdate"
/>
<Button
icon="i-lucide-check"
@@ -84,20 +84,28 @@ const findCategoryFromSlug = slug => {
return categories.value?.find(category => category.slug === slug);
};
const assignCategoryFromSlug = slug => {
const categoryFromSlug = findCategoryFromSlug(slug);
if (categoryFromSlug) {
selectedCategoryId.value = categoryFromSlug.id;
return categoryFromSlug;
}
return null;
};
const selectedCategory = computed(() => {
if (isNewArticle.value) {
if (selectedCategoryId.value) {
return (
categories.value?.find(c => c.id === selectedCategoryId.value) || null
);
}
if (categorySlugFromRoute.value) {
const categoryFromSlug = findCategoryFromSlug(
const categoryFromSlug = assignCategoryFromSlug(
categorySlugFromRoute.value
);
if (categoryFromSlug) return categoryFromSlug;
}
return categories.value?.[0] || null;
return selectedCategoryId.value
? categories.value.find(
category => category.id === selectedCategoryId.value
)
: categories.value[0] || null;
}
return categories.value.find(
category => category.id === props.article?.category?.id
@@ -193,7 +201,7 @@ onMounted(() => {
v-if="openAgentsList && hasAgentList"
:menu-items="agentList"
show-search
class="z-[100] w-48 mt-2 ltr:left-0 rtl:right-0 top-full max-h-60"
class="z-[100] w-48 mt-2 overflow-y-auto ltr:left-0 rtl:right-0 top-full max-h-60"
@action="handleArticleAction"
/>
</OnClickOutside>
@@ -225,7 +233,7 @@ onMounted(() => {
v-if="openCategoryList && hasCategoryMenuItems"
:menu-items="categoryList"
show-search
class="w-48 mt-2 z-[100] left-0 top-full max-h-60"
class="w-48 mt-2 z-[100] overflow-y-auto left-0 top-full max-h-60"
@action="handleArticleAction"
/>
</OnClickOutside>
@@ -159,7 +159,7 @@ const handleTabChange = value => {
v-if="isLocaleMenuOpen"
:menu-items="localeMenuItems"
show-search
class="left-0 w-40 max-w-[300px] mt-2 xl:right-0 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>
@@ -180,7 +180,7 @@ const handleTabChange = value => {
v-if="isCategoryMenuOpen"
:menu-items="categoryMenuItems"
show-search
class="left-0 w-48 mt-2 xl:right-0 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>
@@ -168,9 +168,7 @@ const handlePageChange = page => emit('pageChange', page);
const navigateToNewArticlePage = () => {
const { categorySlug, locale } = route.params;
router.push({
name: props.isCategoryArticles
? 'portals_categories_articles_new'
: 'portals_articles_new',
name: 'portals_articles_new',
params: { categorySlug, locale },
});
};
@@ -276,7 +274,6 @@ watch(
:categories="categories"
:allowed-locales="allowedLocales"
:has-selected-category="isCategoryArticles"
@new-article="navigateToNewArticlePage"
/>
</div>
</template>
@@ -299,7 +296,7 @@ watch(
:selected-count-label="selectedCountLabel"
class="py-2 ltr:!pr-3 rtl:!pl-3 justify-between"
>
<template #secondaryActions>
<template #secondary-actions>
<Button
sm
ghost
@@ -25,7 +25,7 @@ const props = defineProps({
},
});
const emit = defineEmits(['localeChange', 'newArticle']);
const emit = defineEmits(['localeChange']);
const route = useRoute();
const router = useRouter();
@@ -142,7 +142,7 @@ const handleBreadcrumbClick = () => {
v-if="isLocaleMenuOpen"
:menu-items="localeMenuItems"
show-search
class="left-0 w-40 mt-2 xl:right-0 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>
@@ -179,7 +179,7 @@ const handleBreadcrumbClick = () => {
/>
</OnClickOutside>
</div>
<div v-else class="relative flex items-center gap-2">
<div v-else class="relative">
<OnClickOutside @trigger="isEditCategoryDialogOpen = false">
<Button
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.EDIT_CATEGORY')"
@@ -196,12 +196,6 @@ const handleBreadcrumbClick = () => {
@close="isEditCategoryDialogOpen = false"
/>
</OnClickOutside>
<Button
:label="t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.NEW_ARTICLE')"
icon="i-lucide-plus"
size="sm"
@click="emit('newArticle')"
/>
</div>
</div>
</template>
@@ -19,7 +19,6 @@ const DEFAULT_ROUTE = 'portals_articles_index';
const CATEGORY_ROUTE = 'portals_categories_index';
const CATEGORY_SUB_ROUTES = [
'portals_categories_articles_index',
'portals_categories_articles_new',
'portals_categories_articles_edit',
];
@@ -90,7 +90,7 @@ const targetInboxLabel = computed(() => {
<DropdownMenu
v-if="contactableInboxesList?.length > 0 && showInboxesDropdown"
:menu-items="contactableInboxesList"
class="ltr:left-0 rtl:right-0 z-[100] top-8 max-h-56 w-fit max-w-sm dark:!outline-n-slate-5"
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-56 w-fit max-w-sm dark:!outline-n-slate-5"
@action="emit('handleInboxAction', $event)"
/>
</div>
@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { computed, useSlots } from 'vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -47,6 +47,9 @@ const allSelected = computed(
selectedVisibleCount.value === visibleItemCount.value
);
const slots = useSlots();
const hasSecondaryActions = computed(() => Boolean(slots['secondary-actions']));
const bulkCheckboxState = computed({
get: () => allSelected.value,
set: shouldSelectAll => {
@@ -92,12 +95,10 @@ const bulkCheckboxState = computed({
<span class="text-sm text-n-slate-10 truncate tabular-nums">
{{ selectedCountLabel }}
</span>
<div v-if="$slots.primaryActions" class="h-4 w-px bg-n-strong" />
<slot v-if="$slots.primaryActions" name="primaryActions" />
</div>
<div class="flex items-center gap-3">
<slot v-if="$slots.secondaryActions" name="secondaryActions" />
<div v-if="$slots.secondaryActions" class="h-4 w-px bg-n-strong" />
<slot v-if="hasSecondaryActions" name="secondary-actions" />
<div v-if="hasSecondaryActions" class="h-4 w-px bg-n-strong" />
<div class="flex items-center gap-3">
<slot name="actions" :selected-count="selectedCount">
<Button
@@ -1,113 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
selectedIds: { type: Set, default: () => new Set() },
documents: { type: Array, default: () => [] },
});
const emit = defineEmits([
'update:selectedIds',
'bulkSyncQueued',
'bulkDeleteSucceeded',
]);
const { t } = useI18n();
const store = useStore();
const bulkDeleteDialog = ref(null);
const isSyncableDocument = doc =>
!doc.pdf_document && doc.status === 'available' && !doc.sync_in_progress;
const syncableSelectedIds = computed(() => {
if (!props.selectedIds.size) return [];
return props.documents
.filter(doc => props.selectedIds.has(doc.id) && isSyncableDocument(doc))
.map(doc => doc.id);
});
const hasSyncableSelection = computed(
() => syncableSelectedIds.value.length > 0
);
const selectAllLabel = computed(() => {
const count = props.documents.length;
const isAllSelected = props.selectedIds.size === count && count > 0;
return isAllSelected
? t('CAPTAIN.DOCUMENTS.UNSELECT_ALL', { count })
: t('CAPTAIN.DOCUMENTS.SELECT_ALL', { count });
});
const selectedCountLabel = computed(() =>
t('CAPTAIN.DOCUMENTS.SELECTED', { count: props.selectedIds.size })
);
const handleBulkSync = async () => {
const ids = syncableSelectedIds.value;
if (!ids.length) return;
try {
const response = await store.dispatch('captainBulkActions/handleBulkSync', {
ids,
});
const queuedCount = response?.count ?? response?.ids?.length ?? 0;
let message = t('CAPTAIN.DOCUMENTS.BULK_SYNC.ZERO_MESSAGE');
if (queuedCount === 1) {
message = t('CAPTAIN.DOCUMENTS.BULK_SYNC.SUCCESS_MESSAGE_ONE');
} else if (queuedCount > 1) {
message = t('CAPTAIN.DOCUMENTS.BULK_SYNC.SUCCESS_MESSAGE', {
count: queuedCount,
});
}
useAlert(message);
emit('update:selectedIds', new Set());
if (queuedCount > 0) emit('bulkSyncQueued');
} catch (error) {
useAlert(t('CAPTAIN.DOCUMENTS.BULK_SYNC.ERROR_MESSAGE'));
}
};
</script>
<template>
<div>
<BulkSelectBar
:model-value="selectedIds"
:all-items="documents"
:select-all-label="selectAllLabel"
:selected-count-label="selectedCountLabel"
:delete-label="$t('CAPTAIN.DOCUMENTS.BULK_DELETE_BUTTON')"
class="w-fit"
:class="{ 'mb-2': selectedIds.size > 0 }"
@update:model-value="emit('update:selectedIds', $event)"
@bulk-delete="bulkDeleteDialog.dialogRef.open()"
>
<template v-if="hasSyncableSelection" #secondaryActions>
<Button
:label="$t('CAPTAIN.DOCUMENTS.BULK_SYNC_BUTTON')"
sm
slate
ghost
icon="i-lucide-refresh-cw"
class="!px-1.5"
@click="handleBulkSync"
/>
</template>
</BulkSelectBar>
<BulkDeleteDialog
ref="bulkDeleteDialog"
:bulk-ids="selectedIds"
type="AssistantDocument"
@delete-success="emit('bulkDeleteSucceeded')"
/>
</div>
</template>
@@ -5,17 +5,14 @@ import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { usePolicy } from 'dashboard/composables/usePolicy';
import {
isSafeHttpLink,
isPdfDocument,
formatDocumentLink,
getDocumentDisplayPath,
} from 'shared/helpers/documentHelper';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import DocumentSyncStatus from 'dashboard/components-next/captain/assistant/DocumentSyncStatus.vue';
const props = defineProps({
id: {
@@ -34,38 +31,10 @@ const props = defineProps({
type: String,
required: true,
},
pdfDocument: {
type: Boolean,
default: false,
},
createdAt: {
type: Number,
required: true,
},
status: {
type: String,
default: null,
},
syncStatus: {
type: String,
default: null,
},
lastSyncedAt: {
type: Number,
default: null,
},
lastSyncErrorCode: {
type: String,
default: null,
},
syncInProgress: {
type: Boolean,
default: false,
},
syncStaleAfterHours: {
type: Number,
default: null,
},
isSelected: {
type: Boolean,
default: false,
@@ -95,20 +64,6 @@ const modelValue = computed({
set: () => emit('select', props.id),
});
const isPdf = computed(() => props.pdfDocument);
const hasSafeLink = computed(() => isSafeHttpLink(props.externalLink));
const canManage = computed(() => checkPermissions(['administrator']));
const isAvailable = computed(() => props.status === 'available');
const canSync = computed(
() => canManage.value && !isPdf.value && isAvailable.value
);
const isSyncing = computed(() => props.syncStatus === 'syncing');
const isFailed = computed(() => props.syncStatus === 'failed');
const isRetryableSync = computed(
() => isFailed.value || (isSyncing.value && !props.syncInProgress)
);
const showSyncStatus = computed(() => !isPdf.value);
const menuItems = computed(() => {
const allOptions = [
{
@@ -119,19 +74,7 @@ const menuItems = computed(() => {
},
];
if (canSync.value) {
allOptions.push({
label: isRetryableSync.value
? t('CAPTAIN.DOCUMENTS.OPTIONS.RETRY_SYNC')
: t('CAPTAIN.DOCUMENTS.OPTIONS.SYNC_NOW'),
value: 'sync',
action: 'sync',
icon: 'i-lucide-refresh-cw',
disabled: props.syncInProgress,
});
}
if (canManage.value) {
if (checkPermissions(['administrator'])) {
allOptions.push({
label: t('CAPTAIN.DOCUMENTS.OPTIONS.DELETE_DOCUMENT'),
value: 'delete',
@@ -143,25 +86,17 @@ const menuItems = computed(() => {
return allOptions;
});
const createdAtLabel = computed(() => dynamicTime(props.createdAt));
const createdAt = computed(() => dynamicTime(props.createdAt));
const displayLink = computed(() =>
isPdf.value
? formatDocumentLink(props.externalLink)
: getDocumentDisplayPath(props.externalLink)
);
const displayLink = computed(() => formatDocumentLink(props.externalLink));
const linkIcon = computed(() =>
isPdf.value ? 'i-ph-file-pdf' : 'i-ph-link-simple'
isPdfDocument(props.externalLink) ? 'i-ph-file-pdf' : 'i-ph-link-simple'
);
const handleAction = ({ action, value }) => {
toggleDropdown(false);
emit('action', { action, value, id: props.id });
};
const handleRetry = () => {
emit('action', { action: 'sync', id: props.id });
};
</script>
<template>
@@ -206,41 +141,17 @@ const handleRetry = () => {
<span
class="flex gap-1 items-center text-sm truncate shrink-0 text-n-slate-11"
>
<Icon icon="i-woot-captain" />
<i class="i-woot-captain" />
{{ assistant?.name || '' }}
</span>
<a
v-if="!isPdf && hasSafeLink"
:href="externalLink"
:title="externalLink"
target="_blank"
rel="noopener noreferrer"
class="flex flex-1 gap-1 justify-start items-center text-sm truncate text-n-slate-11 hover:text-n-slate-12 hover:underline"
@click.stop
>
<Icon :icon="linkIcon" class="shrink-0" />
<span class="truncate">{{ displayLink }}</span>
<Icon icon="i-lucide-external-link size-3 shrink-0 opacity-70" />
</a>
<span
v-else
class="flex flex-1 gap-1 justify-start items-center text-sm truncate text-n-slate-11"
>
<Icon :icon="linkIcon" class="shrink-0" />
<i :class="linkIcon" class="shrink-0" />
<span class="truncate">{{ displayLink }}</span>
</span>
<DocumentSyncStatus
v-if="showSyncStatus"
:status="syncStatus"
:last-synced-at="lastSyncedAt"
:error-code="lastSyncErrorCode"
:sync-in-progress="syncInProgress"
:stale-after-hours="syncStaleAfterHours"
:show-retry="canSync && isRetryableSync"
@retry="handleRetry"
/>
<div v-else class="text-sm shrink-0 text-n-slate-11 line-clamp-1">
{{ createdAtLabel }}
<div class="text-sm shrink-0 text-n-slate-11 line-clamp-1">
{{ createdAt }}
</div>
</div>
</CardLayout>
@@ -1,61 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import DocumentFiltersBar from 'dashboard/components-next/captain/assistant/DocumentFiltersBar.vue';
const emit = defineEmits(['change']);
const source = ref('all');
const status = ref(null);
const sort = ref('recently_updated');
const hasActiveFilters = computed(
() => source.value !== 'all' || Boolean(status.value)
);
const buildParams = (page = 1) => {
const params = { page };
if (source.value !== 'all') params.source = source.value;
if (status.value) params.filter = status.value;
if (sort.value) params.sort = sort.value;
return params;
};
const emitChange = () => emit('change');
const handleSourceSelect = sourceKey => {
source.value = sourceKey;
if (sourceKey !== 'web') status.value = null;
emitChange();
};
const handleStatusSelect = statusKey => {
status.value = statusKey;
if (statusKey) source.value = 'web';
emitChange();
};
const handleSortSelect = sortKey => {
sort.value = sortKey;
emitChange();
};
const reset = () => {
source.value = 'all';
status.value = null;
sort.value = 'recently_updated';
};
defineExpose({ buildParams, reset, hasActiveFilters });
</script>
<template>
<DocumentFiltersBar
:active-source-filter="source"
:active-status-filter="status"
:active-sort="sort"
@select-source="handleSourceSelect"
@select-status="handleStatusSelect"
@select-sort="handleSortSelect"
/>
</template>
@@ -1,141 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
activeSourceFilter: { type: String, default: 'all' },
activeStatusFilter: { type: String, default: null },
activeSort: { type: String, default: 'recently_updated' },
});
const emit = defineEmits(['selectSource', 'selectStatus', 'selectSort']);
const { t } = useI18n();
const openMenu = ref(null);
const MENU_CONFIG = [
{
key: 'source',
activeKey: 'activeSourceFilter',
dropdownClass: 'min-w-48',
options: [
{ labelKey: 'SOURCE.ALL', value: 'all', icon: 'i-lucide-files' },
{ labelKey: 'SOURCE.WEB', value: 'web', icon: 'i-lucide-link' },
{ labelKey: 'SOURCE.PDF', value: 'pdf', icon: 'i-lucide-file-text' },
],
},
{
key: 'status',
activeKey: 'activeStatusFilter',
dropdownClass: 'min-w-52',
options: [
{ labelKey: 'STATUS.ANY', value: null, icon: 'i-lucide-circle-dashed' },
{
labelKey: 'STATUS.UPDATED',
value: 'synced',
icon: 'i-lucide-check-circle',
},
{
labelKey: 'STATUS.NEEDS_UPDATE',
value: 'stale',
icon: 'i-lucide-clock',
},
{
labelKey: 'STATUS.UPDATING',
value: 'syncing',
icon: 'i-lucide-refresh-cw',
},
{
labelKey: 'STATUS.FAILED',
value: 'failed',
icon: 'i-lucide-circle-x',
},
],
},
{
key: 'sort',
activeKey: 'activeSort',
dropdownClass: 'min-w-56',
options: [
{
labelKey: 'SORT.RECENTLY_UPDATED',
value: 'recently_updated',
icon: 'i-lucide-arrow-down-up',
},
{
labelKey: 'SORT.RECENTLY_CREATED',
value: 'recently_created',
icon: 'i-lucide-clock',
},
],
},
];
const filterMenus = computed(() =>
MENU_CONFIG.filter(
menu => !(menu.key === 'status' && props.activeSourceFilter === 'pdf')
).map(menu => {
const active = props[menu.activeKey];
const items = menu.options.map(opt => ({
label: t(`CAPTAIN.DOCUMENTS.FILTERS.${opt.labelKey}`),
value: opt.value,
icon: opt.icon,
action: menu.key,
isSelected: opt.value === active,
}));
return {
...menu,
items,
selected: items.find(item => item.isSelected) || items[0],
};
})
);
const closeMenu = () => {
openMenu.value = null;
};
const toggleMenu = menu => {
openMenu.value = openMenu.value === menu ? null : menu;
};
const handleMenuAction = ({ action, value }) => {
closeMenu();
if (action === 'source') emit('selectSource', value);
else if (action === 'status') emit('selectStatus', value);
else if (action === 'sort') emit('selectSort', value);
};
</script>
<template>
<div
v-on-click-outside="closeMenu"
class="inline-flex flex-wrap items-center gap-2 pt-2 w-fit"
>
<div v-for="menu in filterMenus" :key="menu.key" class="relative">
<Button
:icon="menu.selected.icon"
slate
size="sm"
:class="{ 'bg-n-slate-9/10': openMenu === menu.key }"
@click="toggleMenu(menu.key)"
>
<span class="min-w-0 truncate">{{ menu.selected.label }}</span>
<Icon icon="i-lucide-chevron-down" class="shrink-0 size-4" />
</Button>
<DropdownMenu
v-if="openMenu === menu.key"
:menu-items="menu.items"
:class="menu.dropdownClass"
class="top-full mt-2 ltr:left-0 rtl:right-0"
@action="handleMenuAction"
/>
</div>
</div>
</template>
@@ -1,153 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
status: {
type: String,
default: null,
},
lastSyncedAt: {
type: Number,
default: null,
},
errorCode: {
type: String,
default: null,
},
syncInProgress: {
type: Boolean,
default: false,
},
staleAfterHours: {
type: Number,
default: null,
},
showRetry: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['retry']);
const { t } = useI18n();
const SECONDS_PER_HOUR = 3600;
const SYNCING = 'syncing';
const FAILED = 'failed';
const ERROR_CODE_LABELS = {
not_found: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.NOT_FOUND',
access_denied: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.ACCESS_DENIED',
timeout: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.TIMEOUT',
content_empty: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.CONTENT_EMPTY',
fetch_failed: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.FETCH_FAILED',
sync_error: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.SYNC_ERROR',
};
const DEFAULT_ERROR_LABEL = 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.DEFAULT';
const hasSyncingStatus = computed(() => props.status === SYNCING);
const isSyncing = computed(
() => hasSyncingStatus.value && props.syncInProgress
);
const isStaleSync = computed(
() => hasSyncingStatus.value && !props.syncInProgress
);
const isFailed = computed(() => props.status === FAILED);
const canRetry = computed(() => isFailed.value || isStaleSync.value);
const hasBeenSynced = computed(() => Boolean(props.lastSyncedAt));
const ageInHours = computed(() => {
if (!props.lastSyncedAt) return null;
const nowSeconds = Date.now() / 1000;
return (nowSeconds - props.lastSyncedAt) / SECONDS_PER_HOUR;
});
const staleAfterHours = computed(() => Number(props.staleAfterHours));
const hasStaleThreshold = computed(
() => Number.isFinite(staleAfterHours.value) && staleAfterHours.value > 0
);
const isStale = computed(
() =>
hasStaleThreshold.value &&
ageInHours.value !== null &&
ageInHours.value >= staleAfterHours.value
);
const errorLabel = computed(() =>
t(ERROR_CODE_LABELS[props.errorCode] || DEFAULT_ERROR_LABEL)
);
const label = computed(() => {
if (isSyncing.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.SYNCING');
if (isStaleSync.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.STALE_SYNC');
if (isFailed.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.FAILED');
if (hasBeenSynced.value)
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.SYNCED', {
time: dynamicTime(props.lastSyncedAt),
});
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.NEVER_SYNCED');
});
const fullLabel = computed(() => {
if (isSyncing.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.SYNCING');
if (isStaleSync.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.STALE_SYNC');
if (isFailed.value)
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.FAILED', {
error: errorLabel.value,
});
if (hasBeenSynced.value)
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.SYNCED', {
time: dynamicTime(props.lastSyncedAt),
});
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.NEVER_SYNCED');
});
const tone = computed(() => {
if (isSyncing.value) return 'amber';
if (isStaleSync.value) return 'amber';
if (isFailed.value) return 'ruby';
if (isStale.value) return 'amber';
return 'slate';
});
const textClass = computed(() => {
if (tone.value === 'amber') return 'text-n-amber-11';
if (tone.value === 'ruby') return 'text-n-ruby-11';
return 'text-n-slate-11';
});
const statusIcon = computed(() => {
if (isFailed.value || isStale.value || isStaleSync.value) {
return 'i-lucide-circle-alert';
}
return 'i-lucide-refresh-cw';
});
</script>
<template>
<span
class="flex gap-1.5 items-center text-sm truncate shrink-0 tabular-nums"
:class="textClass"
:title="fullLabel"
>
<Spinner v-if="isSyncing" class="text-n-amber-11 size-3" />
<Icon v-else :icon="statusIcon" class="shrink-0 size-3.5" />
<span class="truncate">{{ label }}</span>
<Button
v-if="showRetry && canRetry"
:label="t('CAPTAIN.DOCUMENTS.OPTIONS.RETRY_SYNC')"
xs
link
ruby
icon="i-lucide-refresh-cw"
class="hover:!no-underline !gap-1 ms-1"
@click.stop="emit('retry')"
/>
</span>
</template>
@@ -15,7 +15,7 @@ defineProps({
},
});
const emit = defineEmits(['close', 'createSuccess']);
const emit = defineEmits(['close']);
const { t } = useI18n();
const store = useStore();
@@ -26,7 +26,6 @@ const i18nKey = 'CAPTAIN.DOCUMENTS.CREATE';
const handleSubmit = async newDocument => {
try {
await store.dispatch('captainDocuments/create', newDocument);
emit('createSuccess');
useAlert(t(`${i18nKey}.SUCCESS_MESSAGE`));
dialogRef.value.close();
} catch (error) {
@@ -10,6 +10,9 @@ const props = defineProps({
menuItems: {
type: Array,
default: () => [],
validator: value => {
return value.every(item => item.action && item.value && item.label);
},
},
menuSections: {
type: Array,
@@ -19,10 +22,6 @@ const props = defineProps({
type: Number,
default: 20,
},
roundedThumbnail: {
type: Boolean,
default: true,
},
showSearch: {
type: Boolean,
default: false,
@@ -43,17 +42,9 @@ const props = defineProps({
type: Boolean,
default: false,
},
isLoading: {
type: Boolean,
default: false,
},
emptyStateMessage: {
type: String,
default: 'DROPDOWN_MENU.EMPTY_STATE',
},
});
const emit = defineEmits(['action', 'search', 'empty']);
const emit = defineEmits(['action', 'search']);
const { t } = useI18n();
@@ -105,13 +96,9 @@ const filteredMenuSections = computed(() => {
});
const handleSearchInput = event => {
emit('search', event.target.value);
const isEmpty = hasSections.value
? filteredMenuSections.value.length === 0
: filteredMenuItems.value.length === 0;
if (isEmpty) emit('empty');
if (props.disableLocalFiltering) {
emit('search', event.target.value);
}
};
const handleAction = item => {
@@ -136,104 +123,57 @@ onMounted(() => {
<template>
<div
class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 flex flex-col min-w-[136px] shadow-lg pt-2 overflow-hidden"
class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 gap-2 flex flex-col min-w-[136px] shadow-lg pb-2 px-2"
:class="{
'pt-2': !showSearch,
}"
>
<div v-if="showSearch" class="relative shrink-0 px-2 mb-2">
<span
class="absolute i-lucide-search size-3.5 top-2 ltr:left-5 rtl:right-5"
/>
<input
ref="searchInput"
v-model="searchQuery"
type="search"
:placeholder="
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
"
class="reset-base w-full h-8 py-2 ltr:pl-10 ltr:pr-2 rtl:pl-2 rtl:pr-10 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
@input="handleSearchInput"
/>
<div
v-if="showSearch"
class="sticky top-0 bg-n-alpha-3 backdrop-blur-sm pt-2 z-20"
>
<div class="relative">
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
<input
ref="searchInput"
v-model="searchQuery"
type="search"
:placeholder="
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
"
class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
@input="handleSearchInput"
/>
</div>
</div>
<div class="flex flex-col gap-2 overflow-y-auto min-h-0 px-2 pb-2">
<template v-if="hasSections">
<template v-if="hasSections">
<div
v-for="(section, sectionIndex) in filteredMenuSections"
:key="section.title || sectionIndex"
class="flex flex-col gap-1"
>
<p
v-if="section.title"
class="px-2 py-2 text-xs mb-0 font-medium text-n-slate-11 uppercase tracking-wide sticky z-10 bg-n-alpha-3 backdrop-blur-sm"
:class="showSearch ? 'top-10' : 'top-0'"
>
{{ section.title }}
</p>
<div
v-for="(section, sectionIndex) in filteredMenuSections"
:key="section.title || sectionIndex"
class="flex flex-col gap-1"
v-if="section.isLoading"
class="flex items-center justify-center py-2"
>
<p
v-if="section.title"
class="px-2 py-2 text-xs mb-0 font-medium text-n-slate-11 uppercase tracking-wide sticky top-0 z-10 bg-n-alpha-3 backdrop-blur-sm"
>
{{ section.title }}
</p>
<div
v-if="section.isLoading"
class="flex items-center justify-center py-2"
>
<Spinner :size="24" />
</div>
<div
v-else-if="!section.items.length && section.emptyState"
class="text-sm text-n-slate-11 px-2 py-1.5"
>
{{ section.emptyState }}
</div>
<button
v-for="(item, itemIndex) in section.items"
:key="item.value || itemIndex"
type="button"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
:class="{
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
'text-n-ruby-11': item.action === 'delete',
'text-n-slate-12': item.action !== 'delete',
}"
:disabled="item.disabled"
@click="handleAction(item)"
>
<slot name="thumbnail" :item="item">
<Avatar
v-if="item.thumbnail"
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
:rounded-full="roundedThumbnail"
/>
</slot>
<slot name="icon" :item="item">
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
</slot>
<span v-if="item.emoji" class="flex-shrink-0">{{
item.emoji
}}</span>
<slot name="label" :item="item">
<span
v-if="item.label"
class="min-w-0 text-sm font-420 truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</slot>
<slot name="trailing-icon" :item="item" />
</button>
<div
v-if="sectionIndex < filteredMenuSections.length - 1"
class="h-px bg-n-alpha-2 mx-2 my-1"
/>
</div>
</template>
<template v-else>
<div v-if="isLoading" class="flex items-center justify-center py-2">
<Spinner :size="24" />
</div>
<div
v-else-if="!section.items.length && section.emptyState"
class="text-sm text-n-slate-11 px-2 py-1.5"
>
{{ section.emptyState }}
</div>
<button
v-for="(item, index) in filteredMenuItems"
:key="index"
v-for="(item, itemIndex) in section.items"
:key="item.value || itemIndex"
type="button"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
:class="{
@@ -250,44 +190,77 @@ onMounted(() => {
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
:rounded-full="roundedThumbnail"
/>
</slot>
<slot name="icon" :item="item">
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
rounded-full
/>
</slot>
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<slot name="label" :item="item">
<span
v-if="item.label"
class="min-w-0 text-sm font-420 truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</slot>
<slot name="trailing-icon" :item="item" />
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</button>
</template>
<div
v-if="shouldShowEmptyState"
class="text-sm text-n-slate-11 px-2 py-1.5"
>
{{
isSearching
? t('DROPDOWN_MENU.SEARCHING')
: searchQuery
? t('DROPDOWN_MENU.EMPTY_STATE')
: t(emptyStateMessage)
}}
<div
v-if="sectionIndex < filteredMenuSections.length - 1"
class="h-px bg-n-alpha-2 mx-2 my-1"
/>
</div>
</template>
<template v-else>
<button
v-for="(item, index) in filteredMenuItems"
:key="index"
type="button"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
:class="{
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
'text-n-ruby-11': item.action === 'delete',
'text-n-slate-12': item.action !== 'delete',
}"
:disabled="item.disabled"
@click="handleAction(item)"
>
<slot name="thumbnail" :item="item">
<Avatar
v-if="item.thumbnail"
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
rounded-full
/>
</slot>
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</button>
</template>
<div
v-if="shouldShowEmptyState"
class="text-sm text-n-slate-11 px-2 py-1.5"
>
{{
isSearching
? t('DROPDOWN_MENU.SEARCHING')
: t('DROPDOWN_MENU.EMPTY_STATE')
}}
</div>
<div v-if="$slots.footer" class="shrink-0">
<slot name="footer" />
</div>
<slot name="footer" />
</div>
</template>
@@ -35,7 +35,7 @@ const showDropdown = ref(false);
v-on-clickaway="() => (showDropdown = false)"
:menu-items="labelMenuItems"
show-search
class="z-[100] w-48 mt-2 ltr:left-0 rtl:right-0 top-full max-h-52"
class="z-[100] w-48 mt-2 overflow-y-auto ltr:left-0 rtl:right-0 top-full max-h-52"
@action="emit('updateLabel', $event)"
>
<template #thumbnail="{ item }">
@@ -32,7 +32,14 @@ const BG_COLOR_MAP = {
const { t } = useI18n();
const store = useStore();
const { call, conversationId, currentUserId, inboxId } = useMessageContext();
const {
call,
attachments,
contentAttributes,
conversationId,
currentUserId,
inboxId,
} = useMessageContext();
const { joinCall, endCall, activeCall, hasActiveCall, isJoining } =
useCallSession();
@@ -66,6 +73,23 @@ const displayAgentName = computed(() => {
return conversationAssignee.value?.name || null;
});
const audioAttachment = computed(() =>
(attachments?.value || []).find(a => a.fileType === 'audio')
);
// Duration may arrive on call.duration_seconds (push_event_data) or
// content_attributes.data.duration_seconds — and either may be camelCased
// upstream, so check every variant.
const durationSeconds = computed(() => {
const fromCall = call.value?.durationSeconds || call.value?.duration_seconds;
if (fromCall != null) return fromCall;
const data = contentAttributes?.value?.data;
return data?.durationSeconds || data?.duration_seconds;
});
const formattedDuration = computed(() => formatDuration(durationSeconds.value));
const labelKey = computed(() => {
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
if (status.value === VOICE_CALL_STATUS.RINGING) {
@@ -73,24 +97,30 @@ const labelKey = computed(() => {
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
}
return isFailed.value
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
if (isFailed.value) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_LABEL'
: 'CONVERSATION.VOICE_CALL.MISSED_CALL';
}
return 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
});
const formattedDuration = computed(() =>
formatDuration(call.value?.durationSeconds)
);
const subtext = computed(() => {
if (status.value === VOICE_CALL_STATUS.RINGING) {
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
return isOutbound.value
? t('CONVERSATION.VOICE_CALL.CALLING')
: t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
}
if (status.value === VOICE_CALL_STATUS.COMPLETED) {
return formattedDuration.value;
}
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
if (isOutbound.value) return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
// Outbound calls can flip to in-progress before the contact actually
// picks up (e.g., Twilio marks in-progress when the agent joins the
// conference); claiming "they answered" here would be misleading. The
// 'Call in progress' label alone is accurate; the floating widget shows
// the live duration.
if (isOutbound.value) return null;
if (didCurrentUserAnswer.value) {
return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
}
@@ -99,11 +129,14 @@ const subtext = computed(() => {
agentName: displayAgentName.value,
});
}
return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
return null;
}
return isFailed.value
? t('CONVERSATION.VOICE_CALL.NO_ANSWER')
: t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
if (isFailed.value) {
return isOutbound.value
? t('CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_SUBTEXT')
: t('CONVERSATION.VOICE_CALL.MISSED_CALL_INBOUND_SUBTEXT');
}
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
});
const iconName = computed(() => {
@@ -135,6 +168,7 @@ const canJoinCall = computed(() => {
});
const recordingAttachment = computed(() => {
if (audioAttachment.value) return audioAttachment.value;
const url = call.value?.recordingUrl;
if (!url) return null;
return {
@@ -201,8 +235,8 @@ const handleJoinCall = async () => {
</div>
</div>
<div v-if="recordingAttachment" class="px-3 pb-3">
<AudioChip :attachment="recordingAttachment" />
<div v-if="recordingAttachment" class="px-3 pb-3 w-full">
<AudioChip :attachment="recordingAttachment" class="text-n-slate-12" />
</div>
</div>
</BaseBubble>
@@ -41,8 +41,33 @@ const playbackSpeed = ref(1);
const { uid } = getCurrentInstance();
// MediaRecorder-produced WebM/Opus blobs lack a Duration header → <audio>.duration
// resolves to Infinity until we seek past the end, which forces the engine to
// scan the file and compute the real length. Safe no-op for files with a real
// duration already (mp3/m4a/etc).
const resolveStreamingDuration = () => {
const el = audioPlayer.value;
if (!el) return;
const onTimeUpdate = () => {
el.removeEventListener('timeupdate', onTimeUpdate);
el.currentTime = 0;
duration.value = el.duration;
};
el.addEventListener('timeupdate', onTimeUpdate);
try {
el.currentTime = Number.MAX_SAFE_INTEGER;
} catch {
el.removeEventListener('timeupdate', onTimeUpdate);
}
};
const onLoadedMetadata = () => {
duration.value = audioPlayer.value?.duration;
const d = audioPlayer.value?.duration;
if (!Number.isFinite(d)) {
resolveStreamingDuration();
return;
}
duration.value = d;
};
const playbackSpeedLabel = computed(() => {
@@ -53,7 +78,8 @@ const playbackSpeedLabel = computed(() => {
// When the onLoadMetadata is called, so we need to set the duration
// value when the component is mounted
onMounted(() => {
duration.value = audioPlayer.value?.duration;
const d = audioPlayer.value?.duration;
if (Number.isFinite(d)) duration.value = d;
audioPlayer.value.playbackRate = playbackSpeed.value;
});
@@ -209,7 +209,7 @@ watch(
v-if="showDropdown"
:menu-items="filteredCountries"
show-search
class="z-[100] w-48 mt-2 ltr:left-0 rtl:right-0 top-full max-h-52"
class="z-[100] w-48 mt-2 overflow-y-auto ltr:left-0 rtl:right-0 top-full max-h-52"
@action="onSelectCountry"
/>
</div>
@@ -457,7 +457,7 @@ const menuItems = computed(() => {
{},
{ page: 1, search: undefined }
),
activeOn: ['companies_dashboard_index', 'companies_dashboard_show'],
activeOn: ['companies_dashboard_index'],
},
],
},
@@ -247,7 +247,7 @@ const handleBlur = e => emit('blur', e);
v-if="showDropdownMenu"
:menu-items="filteredMenuItems"
:is-searching="isLoading"
class="ltr:left-0 rtl:right-0 z-[100] top-8 max-h-56 w-[inherit] max-w-md dark:!outline-n-slate-5"
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-56 w-[inherit] max-w-md dark:!outline-n-slate-5"
@action="handleDropdownAction"
/>
</div>
@@ -1,7 +1,5 @@
<script setup>
import { useI18n } from 'vue-i18n';
import Icon from 'next/icon/Icon.vue';
import Label from 'dashboard/components-next/label/Label.vue';
defineProps({
title: {
@@ -20,13 +18,7 @@ defineProps({
type: Boolean,
default: false,
},
isBeta: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
</script>
<template>
@@ -45,18 +37,9 @@ const { t } = useI18n();
</div>
<div class="flex flex-col items-start gap-1.5">
<div class="flex items-center gap-2">
<h3 class="text-n-slate-12 text-sm text-start font-medium capitalize">
{{ title }}
</h3>
<Label
v-if="isBeta && !isComingSoon"
v-tooltip.top="t('GENERAL.BETA_DESCRIPTION')"
:label="t('GENERAL.BETA')"
color="blue"
compact
/>
</div>
<h3 class="text-n-slate-12 text-sm text-start font-medium capitalize">
{{ title }}
</h3>
<p class="text-n-slate-11 text-start text-sm">
{{ description }}
</p>
@@ -67,7 +50,7 @@ const { t } = useI18n();
class="absolute inset-0 flex items-center justify-center backdrop-blur-[2px] rounded-2xl bg-gradient-to-br from-n-surface-1/90 via-n-surface-1/70 to-n-surface-1/95 cursor-not-allowed"
>
<span class="text-n-slate-12 font-medium text-sm">
{{ t('CHANNEL_SELECTOR.COMING_SOON') }} 🚀
{{ $t('CHANNEL_SELECTOR.COMING_SOON') }} 🚀
</span>
</div>
</button>
@@ -122,6 +122,8 @@ const {
onAssignAgent,
onAssignLabels,
onRemoveLabels,
onAssignTeamsForBulk,
onUpdateConversations,
} = useBulkActions();
const {
@@ -864,7 +866,7 @@ watch(conversationFilters, (newVal, oldVal) => {
<template>
<div
class="flex flex-col flex-shrink-0 conversations-list-wrap bg-n-surface-1 relative"
class="flex flex-col flex-shrink-0 conversations-list-wrap bg-n-surface-1"
:class="[
{ hidden: !showConversationList },
isOnExpandedLayout ? 'basis-full' : 'w-[340px] 2xl:w-[412px]',
@@ -922,14 +924,18 @@ watch(conversationFilters, (newVal, oldVal) => {
{{ $t('CHAT_LIST.LIST.404') }}
</p>
<ConversationBulkActions
v-if="selectedConversations.length"
:conversations="selectedConversations"
:all-conversations-selected="allConversationsSelected"
:selected-inboxes="uniqueInboxes"
:show-open-action="allSelectedConversationsStatus('open')"
:show-resolved-action="allSelectedConversationsStatus('resolved')"
:show-snoozed-action="allSelectedConversationsStatus('snoozed')"
:class="isOnExpandedLayout && 'sm:!w-[24rem] !w-full'"
@select-all-conversations="toggleSelectAll"
@assign-agent="onAssignAgent"
@update-conversations="onUpdateConversations"
@assign-labels="onAssignLabels"
@assign-team="onAssignTeamsForBulk"
/>
<ConversationList
:conversation-list="conversationList"
@@ -77,10 +77,6 @@ const isComingSoon = computed(() => {
return ['voice'].includes(key) && !isActive.value;
});
const isBeta = computed(() => {
return ['tiktok', 'voice'].includes(props.channel.key);
});
const onItemClick = () => {
if (isActive.value) {
emit('channelItemClick', props.channel.key);
@@ -94,7 +90,6 @@ const onItemClick = () => {
:description="channel.description"
:icon="channel.icon"
:is-coming-soon="isComingSoon"
:is-beta="isBeta"
:disabled="!isActive"
@click="onItemClick"
/>
@@ -1,11 +1,16 @@
<script setup>
import { watch } from 'vue';
import { useRouter } from 'vue-router';
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { useCallSession } from 'dashboard/composables/useCallSession';
import { setWhatsappCallMuted } from 'dashboard/composables/useWhatsappCallSession';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilityHelper';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const RINGTONE_URL = '/audio/dashboard/bell.mp3';
const route = useRoute();
const router = useRouter();
const store = useStore();
@@ -21,19 +26,53 @@ const {
formattedCallDuration,
} = useCallSession();
// Mute is currently WhatsApp-only — Twilio calls are mediated server-side and
// don't expose a mic track on the browser side.
const isMuted = ref(false);
const isWhatsappActive = computed(
() => activeCall.value?.provider === 'whatsapp'
);
const toggleMute = () => {
isMuted.value = !isMuted.value;
setWhatsappCallMuted(isMuted.value);
};
watch(hasActiveCall, active => {
if (!active) isMuted.value = false;
});
const getCallInfo = call => {
const conversation = store.getters.getConversationById(call?.conversationId);
const inbox = store.getters['inboxes/getInbox'](conversation?.inbox_id);
const sender = conversation?.meta?.sender;
// Inbound WhatsApp calls stash caller info on the call record (from the cable
// payload) so the widget has something to show before the conversation lands.
const caller = call?.caller;
return {
conversation,
inbox,
contactName: sender?.name || sender?.phone_number || 'Unknown caller',
contactName:
sender?.name ||
sender?.phone_number ||
caller?.name ||
caller?.phone ||
'Unknown caller',
phoneNumber: sender?.phone_number || caller?.phone || '',
inboxName: inbox?.name || 'Customer support',
avatar: sender?.avatar || sender?.thumbnail,
avatar: sender?.avatar || sender?.thumbnail || caller?.avatar,
};
};
const goToConversation = call => {
const conversationId = call?.conversationId;
const accountId = route.params.accountId;
if (!conversationId || !accountId) return;
router.push({
path: frontendURL(conversationUrl({ accountId, id: conversationId })),
});
};
const handleEndCall = async () => {
const call = activeCall.value;
if (!call) return;
@@ -49,21 +88,22 @@ const handleEndCall = async () => {
};
const handleJoinCall = async call => {
if (!call || isJoining.value) return;
const { conversation } = getCallInfo(call);
if (!call || !conversation || isJoining.value) return;
// End current active call before joining new one
if (hasActiveCall.value) {
await handleEndCall();
}
// The conversation may not be hydrated yet (post-refresh seeding path);
// call.inboxId already carries what joinCall needs.
const result = await joinCall({
conversationId: call.conversationId,
inboxId: conversation.inbox_id,
inboxId: call.inboxId || conversation?.inbox_id,
callSid: call.callSid,
});
if (result) {
if (result && conversation) {
router.push({
name: 'inbox_conversation',
params: { conversation_id: call.conversationId },
@@ -71,12 +111,15 @@ const handleJoinCall = async call => {
}
};
// Auto-join outbound calls when window is visible
// Auto-join outbound calls when window is visible. WhatsApp outbound has no
// separate join step (the offer was sent at initiate time and the answer is
// applied directly by the cable handler), so this only covers Twilio.
watch(
() => incomingCalls.value[0],
call => {
if (
call?.callDirection === 'outbound' &&
call?.provider !== 'whatsapp' &&
!hasActiveCall.value &&
WindowVisibilityHelper.isWindowVisible()
) {
@@ -85,47 +128,103 @@ watch(
},
{ immediate: true }
);
// Loop the ringtone while an inbound call is unanswered. Stop the moment any
// call is active (we joined), every inbound call cleared, or the widget tears
// down. Browser autoplay may reject the first play() if the tab has no prior
// user gesture; that's fine — the visual widget still surfaces the call.
const ringtone = new Audio(RINGTONE_URL);
ringtone.loop = true;
ringtone.volume = 1;
const stopRingtone = () => {
ringtone.pause();
ringtone.currentTime = 0;
};
const ringingInbound = computed(() =>
incomingCalls.value.some(call => call.callDirection !== 'outbound')
);
watch(
() => ringingInbound.value && !hasActiveCall.value,
shouldRing => {
if (shouldRing) {
ringtone.play().catch(() => {});
} else {
stopRingtone();
}
},
{ immediate: true }
);
onBeforeUnmount(stopRingtone);
</script>
<template>
<div
v-if="incomingCalls.length || hasActiveCall"
class="fixed ltr:right-4 rtl:left-4 bottom-4 z-50 flex flex-col gap-2 w-72"
class="fixed ltr:right-4 rtl:left-4 bottom-4 z-50 flex flex-col gap-2 w-80"
>
<!-- Incoming Calls (shown above active call) -->
<div
v-for="call in hasActiveCall ? incomingCalls : []"
:key="call.callSid"
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
class="flex flex-col gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
>
<div class="animate-pulse ring-2 ring-n-teal-9 rounded-full inline-flex">
<Avatar
:src="getCallInfo(call).avatar"
:name="getCallInfo(call).contactName"
:size="40"
rounded-full
/>
<div class="flex items-center gap-3">
<div
class="animate-pulse ring-2 ring-n-teal-9 rounded-full inline-flex"
>
<Avatar
:src="getCallInfo(call).avatar"
:name="getCallInfo(call).contactName"
:size="40"
rounded-full
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
{{ getCallInfo(call).contactName }}
</p>
<p
v-if="getCallInfo(call).phoneNumber"
class="text-xs text-n-slate-11 truncate mb-0"
>
{{ getCallInfo(call).phoneNumber }}
</p>
</div>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
{{ getCallInfo(call).contactName }}
</p>
<p class="text-xs text-n-slate-11 truncate">
{{ getCallInfo(call).inboxName }}
</p>
</div>
<div class="flex shrink-0 gap-2">
<button
v-if="call.conversationId"
class="flex items-center gap-2 px-3 py-2 bg-n-alpha-2 hover:bg-n-alpha-3 rounded-lg transition-colors"
@click="goToConversation(call)"
>
<i class="text-base text-n-slate-11 i-ph-chat-circle-text-bold" />
<span class="text-xs text-n-slate-12">
{{ $t('CONVERSATION.VOICE_WIDGET.VIEW_CHAT_HISTORY') }}
</span>
<span class="ml-auto text-xs text-n-slate-11">
#{{ call.conversationId }}
</span>
<i class="text-xs text-n-slate-11 i-ph-caret-right-bold" />
</button>
<div class="flex items-center justify-between gap-2">
<button
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
v-tooltip.top="$t('CONVERSATION.VOICE_WIDGET.REJECT_CALL')"
class="flex justify-center items-center w-10 h-10 bg-n-slate-3 hover:bg-n-slate-4 rounded-full transition-colors"
@click="dismissCall(call.callSid)"
>
<i class="text-lg text-white i-ph-phone-x-bold" />
<i class="text-lg text-n-slate-12 i-ph-phone-x-bold" />
</button>
<button
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
class="flex justify-center items-center gap-2 px-4 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
@click="handleJoinCall(call)"
>
<i class="text-lg text-white i-ph-phone-bold" />
<i class="text-base text-white i-ph-phone-bold" />
<span class="text-sm font-medium text-white">
{{ $t('CONVERSATION.VOICE_WIDGET.JOIN_CALL') }}
</span>
</button>
</div>
</div>
@@ -133,27 +232,38 @@ watch(
<!-- Main Call Widget -->
<div
v-if="hasActiveCall || incomingCalls.length"
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
class="flex flex-col gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
>
<div
class="ring-2 ring-n-teal-9 rounded-full inline-flex"
:class="{ 'animate-pulse': !hasActiveCall }"
>
<Avatar
:src="getCallInfo(activeCall || incomingCalls[0]).avatar"
:name="getCallInfo(activeCall || incomingCalls[0]).contactName"
:size="40"
rounded-full
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
{{ getCallInfo(activeCall || incomingCalls[0]).contactName }}
</p>
<p v-if="hasActiveCall" class="font-mono text-sm text-n-teal-9">
<div class="flex items-center gap-3">
<div
class="ring-2 ring-n-teal-9 rounded-full inline-flex"
:class="{ 'animate-pulse': !hasActiveCall }"
>
<Avatar
:src="getCallInfo(activeCall || incomingCalls[0]).avatar"
:name="getCallInfo(activeCall || incomingCalls[0]).contactName"
:size="40"
rounded-full
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
{{ getCallInfo(activeCall || incomingCalls[0]).contactName }}
</p>
<p
v-if="getCallInfo(activeCall || incomingCalls[0]).phoneNumber"
class="text-xs text-n-slate-11 truncate mb-0"
>
{{ getCallInfo(activeCall || incomingCalls[0]).phoneNumber }}
</p>
</div>
<p
v-if="hasActiveCall"
class="font-mono text-sm text-n-slate-12 shrink-0"
>
{{ formattedCallDuration }}
</p>
<p v-else class="text-xs text-n-slate-11">
<p v-else class="text-xs text-n-slate-11 shrink-0">
{{
incomingCalls[0]?.callDirection === 'outbound'
? $t('CONVERSATION.VOICE_WIDGET.OUTGOING_CALL')
@@ -161,24 +271,76 @@ watch(
}}
</p>
</div>
<div class="flex shrink-0 gap-2">
<button
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
@click="
hasActiveCall
? handleEndCall()
: rejectIncomingCall(incomingCalls[0]?.callSid)
"
>
<i class="text-lg text-white i-ph-phone-x-bold" />
</button>
<button
v-if="!hasActiveCall"
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
@click="handleJoinCall(incomingCalls[0])"
>
<i class="text-lg text-white i-ph-phone-bold" />
</button>
<button
v-if="(activeCall || incomingCalls[0])?.conversationId"
class="flex items-center gap-2 px-3 py-2 bg-n-alpha-2 hover:bg-n-alpha-3 rounded-lg transition-colors"
@click="goToConversation(activeCall || incomingCalls[0])"
>
<i class="text-base text-n-slate-11 i-ph-chat-circle-text-bold" />
<span class="text-xs text-n-slate-12">
{{ $t('CONVERSATION.VOICE_WIDGET.VIEW_CHAT_HISTORY') }}
</span>
<span class="ml-auto text-xs text-n-slate-11">
#{{ (activeCall || incomingCalls[0])?.conversationId }}
</span>
<i class="text-xs text-n-slate-11 i-ph-caret-right-bold" />
</button>
<div class="flex items-center justify-between gap-2">
<div class="flex gap-2">
<button
v-if="hasActiveCall && isWhatsappActive"
v-tooltip.top="
isMuted
? $t('CONVERSATION.VOICE_WIDGET.UNMUTE')
: $t('CONVERSATION.VOICE_WIDGET.MUTE')
"
class="flex justify-center items-center w-10 h-10 rounded-full transition-colors"
:class="
isMuted
? 'bg-n-amber-9 hover:bg-n-amber-10'
: 'bg-n-slate-3 hover:bg-n-slate-4'
"
@click="toggleMute"
>
<i
class="text-lg"
:class="
isMuted
? 'text-white i-ph-microphone-slash-bold'
: 'text-n-slate-12 i-ph-microphone-bold'
"
/>
</button>
</div>
<div class="flex gap-2">
<button
v-if="
!hasActiveCall && incomingCalls[0]?.callDirection !== 'outbound'
"
class="flex justify-center items-center gap-2 px-4 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
@click="handleJoinCall(incomingCalls[0])"
>
<i class="text-base text-white i-ph-phone-bold" />
<span class="text-sm font-medium text-white">
{{ $t('CONVERSATION.VOICE_WIDGET.JOIN_CALL') }}
</span>
</button>
<button
class="flex justify-center items-center gap-2 px-4 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
@click="
hasActiveCall
? handleEndCall()
: rejectIncomingCall(incomingCalls[0]?.callSid)
"
>
<i class="text-base text-white i-ph-phone-x-bold" />
<span class="text-sm font-medium text-white">
{{ $t('CONVERSATION.VOICE_WIDGET.END_CALL') }}
</span>
</button>
</div>
</div>
</div>
</div>
@@ -803,11 +803,6 @@ watch(
}
);
watch(
computed(() => props.disabled),
() => editorView?.setProps({})
);
watch(
computed(() => props.updateSelectionWith),
(newValue, oldValue) => {
@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
import { useElementSize } from '@vueuse/core';
import BackButton from '../BackButton.vue';
import ButtonV4 from 'dashboard/components-next/button/Button.vue';
import InboxName from '../InboxName.vue';
import MoreActions from './MoreActions.vue';
import Avatar from 'next/avatar/Avatar.vue';
@@ -12,9 +13,14 @@ import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { useInbox } from 'dashboard/composables/useInbox';
import { useI18n } from 'vue-i18n';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import {
getVoiceCallProvider,
VOICE_CALL_PROVIDERS,
} from 'dashboard/helper/inbox';
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
import { useCallsStore } from 'dashboard/stores/calls';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
const props = defineProps({
chat: {
@@ -94,12 +100,50 @@ const hasMultipleInboxes = computed(
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
const copyConversationId = async () => {
const callsStore = useCallsStore();
const whatsappCallSession = useWhatsappCallSession();
const isWhatsappVoiceInbox = computed(
() => getVoiceCallProvider(inbox.value) === VOICE_CALL_PROVIDERS.WHATSAPP
);
const isWhatsappCallButtonDisabled = computed(
() =>
whatsappCallSession.isInitiating.value ||
callsStore.hasActiveCall ||
callsStore.hasIncomingCall
);
const startWhatsappCall = async () => {
if (whatsappCallSession.isInitiating.value) return;
try {
await copyTextToClipboard(String(props.chat.id));
useAlert(t('CONVERSATION.HEADER.COPY_ID_SUCCESS'));
const response = await whatsappCallSession.initiateOutboundCall(
currentChat.value.id
);
// Permission template path returns no call id — show banner, no widget yet.
if (!response?.id) {
const status = response?.status;
const messageKey =
status === 'permission_pending'
? 'CONVERSATION.HEADER.WHATSAPP_CALL_PERMISSION_PENDING'
: 'CONVERSATION.HEADER.WHATSAPP_CALL_PERMISSION_REQUESTED';
useAlert(t(messageKey));
return;
}
// Stay non-active until Meta delivers the connect webhook (sdp_answer);
// flipping to active here would start the duration timer before pickup.
callsStore.addCall({
callSid: response.call_id,
callId: response.id,
conversationId: currentChat.value.id,
inboxId: inbox.value?.id,
callDirection: 'outbound',
provider: 'whatsapp',
});
} catch (error) {
// error
useAlert(error?.message || t('CONVERSATION.HEADER.WHATSAPP_CALL_FAILED'));
}
};
</script>
@@ -144,18 +188,9 @@ const copyConversationId = async () => {
</div>
<div
class="flex items-center gap-1 overflow-hidden text-xs conversation--header--actions text-n-slate-11 text-ellipsis whitespace-nowrap"
class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap"
>
<button
type="button"
class="truncate text-label-small text-n-slate-11 hover:text-n-slate-12 !p-0 cucursor-pointer"
@click="copyConversationId"
>
{{ `#${chat.id}` }}
</button>
<span v-if="hasMultipleInboxes">•</span>
<InboxName v-if="hasMultipleInboxes" :inbox="inbox" class="!mx-0" />
<span v-if="isSnoozed">•</span>
<span v-if="isSnoozed" class="font-medium text-n-amber-10">
{{ snoozedDisplayText }}
</span>
@@ -172,6 +207,18 @@ const copyConversationId = async () => {
:parent-width="width"
class="hidden md:flex"
/>
<ButtonV4
v-if="isWhatsappVoiceInbox"
v-tooltip.bottom="$t('CONVERSATION.HEADER.WHATSAPP_CALL')"
size="sm"
variant="ghost"
color="slate"
icon="i-lucide-phone"
:is-loading="whatsappCallSession.isInitiating.value"
:disabled="isWhatsappCallButtonDisabled"
class="rounded-md hover:bg-n-alpha-2"
@click="startWhatsappCall"
/>
<MoreActions :conversation-id="currentChat.id" />
</div>
</div>
@@ -5,6 +5,7 @@ import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useTrack } from 'dashboard/composables';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import ReplyToMessage from './ReplyToMessage.vue';
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue';
@@ -143,6 +144,8 @@ export default {
currentUser: 'getCurrentUser',
lastEmail: 'getLastEmailInSelectedChat',
globalConfig: 'globalConfig/get',
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
currentContact() {
const senderId = this.currentChat?.meta?.sender?.id;
@@ -270,10 +273,6 @@ export default {
return MESSAGE_MAX_LENGTH.GENERAL;
},
showFileUpload() {
const { image_send: imageSend } =
this.currentChat?.additional_attributes?.tiktok_capabilities ?? {};
const tiktokAttachmentSupported = imageSend ?? true;
return (
this.isAWebWidgetInbox ||
this.isAFacebookInbox ||
@@ -284,7 +283,7 @@ export default {
this.isATelegramChannel ||
this.isALineChannel ||
this.isAnInstagramChannel ||
(this.isATiktokChannel && tiktokAttachmentSupported)
this.isATiktokChannel
);
},
replyButtonLabel() {
@@ -381,8 +380,14 @@ export default {
const { slug = '' } = portal;
return slug;
},
isQuotedEmailReplyEnabled() {
return this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.QUOTED_EMAIL_REPLY
);
},
quotedReplyPreference() {
if (!this.isAnEmailChannel) {
if (!this.isAnEmailChannel || !this.isQuotedEmailReplyEnabled) {
return false;
}
@@ -407,7 +412,11 @@ export default {
return truncatePreviewText(this.quotedEmailText, 80);
},
shouldShowQuotedReplyToggle() {
return this.isAnEmailChannel && !this.isOnPrivateNote;
return (
this.isAnEmailChannel &&
!this.isOnPrivateNote &&
this.isQuotedEmailReplyEnabled
);
},
shouldShowQuotedPreview() {
return (
@@ -564,6 +573,7 @@ export default {
},
shouldIncludeQuotedEmail() {
return (
this.isQuotedEmailReplyEnabled &&
this.quotedReplyPreference &&
this.shouldShowQuotedReplyToggle &&
!!this.quotedEmailText
@@ -696,7 +706,6 @@ export default {
// Don't handle paste if editor is disabled
if (this.isEditorDisabled) return;
if (!this.showFileUpload && !this.isOnPrivateNote) return;
// Filter valid files (non-zero size)
Array.from(e.clipboardData.files)
@@ -1016,8 +1025,6 @@ export default {
});
},
attachFile({ blob, file }) {
if (!this.showFileUpload && !this.isOnPrivateNote) return;
const reader = new FileReader();
reader.readAsDataURL(file.file);
reader.onloadend = () => {
@@ -22,10 +22,6 @@ const props = defineProps({
type: Array,
required: true,
},
autoPlay: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['close']);
@@ -313,7 +309,6 @@ onMounted(() => {
:src="activeAttachment.data_url"
controls
playsInline
:autoplay="autoPlay"
class="max-h-full max-w-full object-contain"
@click.stop
/>
@@ -322,7 +317,6 @@ onMounted(() => {
v-if="isAudio"
:key="activeAttachment.message_id"
controls
:autoplay="autoPlay"
class="w-full max-w-md"
@click.stop
>
@@ -0,0 +1,254 @@
<script>
import { mapGetters } from 'vuex';
import Avatar from 'next/avatar/Avatar.vue';
import Spinner from 'shared/components/Spinner.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
Avatar,
Spinner,
NextButton,
},
props: {
selectedInboxes: {
type: Array,
default: () => [],
},
conversationCount: {
type: Number,
default: 0,
},
},
emits: ['select', 'close'],
data() {
return {
query: '',
selectedAgent: null,
goBackToAgentList: false,
};
},
computed: {
...mapGetters({
uiFlags: 'bulkActions/getUIFlags',
assignableAgentsUiFlags: 'inboxAssignableAgents/getUIFlags',
}),
filteredAgents() {
if (this.query) {
return this.assignableAgents.filter(agent =>
agent.name.toLowerCase().includes(this.query.toLowerCase())
);
}
return [
{
confirmed: true,
name: 'None',
id: null,
role: 'agent',
account_id: 0,
email: 'None',
},
...this.assignableAgents,
];
},
assignableAgents() {
return this.$store.getters['inboxAssignableAgents/getAssignableAgents'](
this.selectedInboxes.join(',')
);
},
conversationLabel() {
return this.conversationCount > 1 ? 'conversations' : 'conversation';
},
},
mounted() {
this.$store.dispatch('inboxAssignableAgents/fetch', this.selectedInboxes);
},
methods: {
submit() {
this.$emit('select', this.selectedAgent);
},
goBack() {
this.goBackToAgentList = true;
this.selectedAgent = null;
},
assignAgent(agent) {
this.selectedAgent = agent;
},
onClose() {
this.$emit('close');
},
onCloseAgentList() {
if (this.selectedAgent === null && !this.goBackToAgentList) {
this.onClose();
}
this.goBackToAgentList = false;
},
},
};
</script>
<template>
<div v-on-clickaway="onCloseAgentList" class="bulk-action__agents">
<div class="triangle">
<svg height="12" viewBox="0 0 24 12" width="24">
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
</svg>
</div>
<div class="flex items-center justify-between header">
<span>{{ $t('BULK_ACTION.AGENT_SELECT_LABEL') }}</span>
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
</div>
<div class="container">
<div
v-if="assignableAgentsUiFlags.isFetching"
class="agent__list-loading"
>
<Spinner />
<p>{{ $t('BULK_ACTION.AGENT_LIST_LOADING') }}</p>
</div>
<div v-else class="agent__list-container">
<ul v-if="!selectedAgent">
<li class="search-container">
<div
class="flex items-center justify-between h-8 gap-2 agent-list-search"
>
<fluent-icon icon="search" class="search-icon" size="16" />
<input
v-model="query"
type="search"
:placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
class="reset-base !outline-0 !text-sm agent--search_input"
/>
</div>
</li>
<li v-for="agent in filteredAgents" :key="agent.id">
<div class="agent-list-item" @click="assignAgent(agent)">
<Avatar
:name="agent.name"
:src="agent.thumbnail"
:status="agent.availability_status"
:size="22"
hide-offline-status
rounded-full
/>
<span class="my-0 text-n-slate-12">
{{ agent.name }}
</span>
</div>
</li>
</ul>
<div v-else class="agent-confirmation-container">
<p v-if="selectedAgent.id">
{{
$t('BULK_ACTION.ASSIGN_CONFIRMATION_LABEL', {
conversationCount,
conversationLabel,
})
}}
<strong>
{{ selectedAgent.name }}
</strong>
<span>?</span>
</p>
<p v-else>
{{
$t('BULK_ACTION.UNASSIGN_CONFIRMATION_LABEL', {
conversationCount,
conversationLabel,
})
}}
</p>
<div class="agent-confirmation-actions">
<NextButton
faded
sm
slate
type="reset"
:label="$t('BULK_ACTION.GO_BACK_LABEL')"
@click="goBack"
/>
<NextButton
sm
type="submit"
:label="$t('BULK_ACTION.YES')"
:is-loading="uiFlags.isUpdating"
@click="submit"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.bulk-action__agents {
@apply max-w-[75%] absolute ltr:right-2 rtl:left-2 top-12 origin-top-right w-auto z-20 min-w-[15rem] bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md;
.header {
@apply p-2.5;
span {
@apply text-sm font-medium;
}
}
.container {
@apply overflow-y-auto max-h-[15rem];
.agent__list-container {
@apply h-full;
}
.agent-list-search {
@apply py-0 px-2.5 bg-n-alpha-black2 border border-solid border-n-strong rounded-md;
.search-icon {
@apply text-n-slate-10;
}
.agent--search_input {
@apply border-0 text-xs m-0 dark:bg-transparent bg-transparent h-[unset] w-full;
}
}
}
.triangle {
@apply block z-10 absolute -top-3 text-left ltr:right-[--triangle-position] rtl:left-[--triangle-position];
svg path {
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
}
}
}
ul {
@apply m-0 list-none;
li {
&:last-child {
.agent-list-item {
@apply last:rounded-b-lg;
}
}
}
}
.agent-list-item {
@apply flex items-center p-2.5 gap-2 cursor-pointer hover:bg-n-slate-3 dark:hover:bg-n-solid-3;
span {
@apply text-sm;
}
}
.agent-confirmation-container {
@apply flex flex-col h-full p-2.5;
p {
@apply flex-grow;
}
.agent-confirmation-actions {
@apply w-full grid grid-cols-2 gap-2.5;
}
}
.search-container {
@apply py-0 px-2.5 sticky top-0 z-20 bg-n-alpha-3 backdrop-blur-[100px];
}
.agent__list-loading {
@apply m-2.5 rounded-md dark:bg-n-solid-3 bg-n-slate-2 flex items-center justify-center flex-col p-5 h-[calc(95%-6.25rem)];
}
</style>
@@ -1,202 +0,0 @@
<script setup>
import { useTemplateRef, computed, ref } from 'vue';
import { useI18n, I18nT } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { useStore } from 'vuex';
import { useMapGetter } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
selectedInboxes: {
type: Array,
default: () => [],
},
conversationCount: {
type: Number,
default: 0,
},
});
const emit = defineEmits(['select']);
const { t } = useI18n();
const store = useStore();
const containerRef = useTemplateRef('containerRef');
const [showDropdown, toggleDropdown] = useToggle(false);
const selectedAgent = ref(null);
const assignableAgentsUiFlags = useMapGetter(
'inboxAssignableAgents/getUIFlags'
);
const bulkActionsUiFlags = useMapGetter('bulkActions/getUIFlags');
const isLoading = computed(() => assignableAgentsUiFlags.value.isFetching);
const isUpdating = computed(() => bulkActionsUiFlags.value.isUpdating);
const assignableAgentsList = useMapGetter(
'inboxAssignableAgents/getAssignableAgents'
);
const assignableAgents = computed(() =>
assignableAgentsList.value(props.selectedInboxes.join(','))
);
const agentMenuItems = computed(() => {
const items = [
{
action: 'select',
value: 'none',
label: t('BULK_ACTION.NONE'),
thumbnail: {
name: t('BULK_ACTION.NONE'),
src: '',
},
isSelected: selectedAgent.value?.id === null,
},
];
assignableAgents.value.forEach(agent => {
items.push({
action: 'select',
value: agent.id,
label: agent.name,
thumbnail: {
name: agent.name,
src: agent.thumbnail,
},
isSelected: selectedAgent.value?.id === agent.id,
});
});
return items;
});
const handleSelectAgent = item => {
if (item.value === 'none') {
selectedAgent.value = { id: null, name: t('BULK_ACTION.NONE') };
} else {
const agent = assignableAgents.value.find(a => a.id === item.value);
selectedAgent.value = agent || { id: null, name: t('BULK_ACTION.NONE') };
}
};
const handleAssign = () => {
if (isUpdating.value) return;
emit('select', selectedAgent.value);
selectedAgent.value = null;
toggleDropdown(false);
};
const handleCancel = () => {
selectedAgent.value = null;
};
const handleDismiss = () => {
selectedAgent.value = null;
toggleDropdown(false);
};
const handleToggleDropdown = () => {
const willOpen = !showDropdown.value;
toggleDropdown();
// Fetch agents only when opening the dropdown
if (willOpen && props.selectedInboxes.length > 0) {
store.dispatch('inboxAssignableAgents/fetch', props.selectedInboxes);
}
};
</script>
<template>
<div ref="containerRef" class="relative">
<Button
v-tooltip="$t('BULK_ACTION.ASSIGN_AGENT_TOOLTIP')"
icon="i-lucide-user-round-check"
slate
xs
ghost
:class="{ 'bg-n-alpha-2': showDropdown }"
@click="handleToggleDropdown"
/>
<Transition
enter-active-class="transition-all duration-150 ease-out origin-bottom"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition-all duration-100 ease-in origin-bottom"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<DropdownMenu
v-if="showDropdown"
v-on-click-outside="[handleDismiss, { ignore: [containerRef] }]"
:menu-items="agentMenuItems"
:is-loading="isLoading"
show-search
:search-placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
class="ltr:-right-10 rtl:-left-10 ltr:2xl:right-0 rtl:2xl:left-0 bottom-8 w-60 max-h-80"
@action="handleSelectAgent"
>
<template v-if="selectedAgent" #footer>
<div
class="pt-2 pb-2 px-2 border-t border-n-weak sticky bottom-0 rounded-b-md z-20 bg-n-alpha-3 backdrop-blur-[4px]"
>
<div class="flex flex-col gap-2">
<I18nT
v-if="selectedAgent.id"
keypath="BULK_ACTION.ASSIGN_AGENT_CONFIRMATION_LABEL"
tag="p"
class="text-xs text-n-slate-11 px-1 mb-0"
:plural="props.conversationCount"
>
<template #n>
<strong class="text-n-slate-12">
{{ props.conversationCount }}
</strong>
</template>
<template #agentName>
<strong class="text-n-slate-12">
{{ selectedAgent.name }}
</strong>
</template>
</I18nT>
<I18nT
v-else
keypath="BULK_ACTION.UNASSIGN_AGENT_CONFIRMATION_LABEL"
tag="p"
class="text-xs text-n-slate-11 px-1 mb-0"
:plural="props.conversationCount"
>
<template #n>
<strong class="text-n-slate-12">
{{ props.conversationCount }}
</strong>
</template>
</I18nT>
<div class="flex gap-2">
<Button
sm
faded
slate
class="flex-1"
:label="t('BULK_ACTION.CANCEL')"
@click="handleCancel"
/>
<Button
sm
class="flex-1"
:label="t('BULK_ACTION.YES')"
:disabled="isUpdating"
:is-loading="isUpdating"
@click="handleAssign"
/>
</div>
</div>
</div>
</template>
</DropdownMenu>
</Transition>
</div>
</template>
@@ -1,160 +0,0 @@
<script setup>
import { ref, useTemplateRef, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import NextButton 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({
type: {
type: String,
default: 'conversation',
},
isLoading: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['assign']);
const { t } = useI18n();
const labels = useMapGetter('labels/getLabels');
const containerRef = useTemplateRef('containerRef');
const [showDropdown, toggleDropdown] = useToggle(false);
const selectedLabels = ref([]);
const isTypeContact = computed(() => props.type === 'contact');
const buttonLabel = computed(() =>
props.type === 'contact' ? t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS') : ''
);
const isLabelSelected = labelTitle => {
return selectedLabels.value.includes(labelTitle);
};
const labelMenuItems = computed(() => {
return labels.value.map(label => ({
action: 'select',
value: label.title,
label: label.title,
color: label.color,
id: label.id,
isSelected: isLabelSelected(label.title),
}));
});
const toggleLabelSelection = labelTitle => {
const index = selectedLabels.value.indexOf(labelTitle);
if (index > -1) {
selectedLabels.value.splice(index, 1);
} else {
selectedLabels.value.push(labelTitle);
}
};
const handleAssign = () => {
if (selectedLabels.value.length > 0) {
emit('assign', selectedLabels.value);
toggleDropdown(false);
selectedLabels.value = [];
}
};
const handleDismiss = () => {
selectedLabels.value = [];
toggleDropdown(false);
};
</script>
<template>
<div ref="containerRef" class="relative">
<NextButton
v-tooltip="isTypeContact ? '' : $t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
:label="buttonLabel"
icon="i-lucide-tag"
slate
:size="isTypeContact ? 'sm' : 'xs'"
ghost
:class="{
'bg-n-alpha-2': showDropdown,
'[&>span:nth-child(2)]:hidden md:[&>span:nth-child(2)]:inline w-fit !text-n-blue-11 [&>span]:!text-n-blue-11 !px-2':
isTypeContact,
}"
:disabled="disabled || isLoading"
:is-loading="isLoading"
@click="toggleDropdown()"
/>
<Transition
:enter-active-class="
!isTypeContact
? 'transition-all duration-150 ease-out origin-bottom'
: 'transition-all duration-150 ease-out origin-top'
"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
:leave-active-class="
!isTypeContact
? 'transition-all duration-100 ease-in origin-bottom'
: 'transition-all duration-100 ease-in origin-top'
"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<DropdownMenu
v-if="showDropdown"
v-on-click-outside="[handleDismiss, { ignore: [containerRef] }]"
:menu-items="labelMenuItems"
show-search
:search-placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
class="w-60 max-h-80"
:class="{
'ltr:-right-[6.5rem] rtl:-left-[6.5rem] ltr:2xl:right-0 rtl:2xl:left-0 bottom-8':
!isTypeContact,
'ltr:right-0 rtl:left-0 mb-1 top-10': isTypeContact,
}"
@action="item => toggleLabelSelection(item.value)"
>
<template #thumbnail="{ item }">
<span
class="rounded-md h-3 w-3 flex-shrink-0 border border-solid border-n-weak"
:style="{ backgroundColor: item.color }"
/>
</template>
<template #trailing-icon="{ item }">
<Icon
v-if="isLabelSelected(item.value)"
icon="i-lucide-check"
class="size-4 text-n-blue-11 flex-shrink-0"
/>
</template>
<template #footer>
<div
class="sticky bottom-0 rounded-b-md px-2 py-2 z-20 bg-n-alpha-3 backdrop-blur-[4px]"
>
<NextButton
sm
class="w-full [&>span:nth-child(2)]:hidden md:[&>span:nth-child(2)]:inline-flex"
:label="t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
:disabled="!selectedLabels.length"
@click="handleAssign"
/>
</div>
</template>
</DropdownMenu>
</Transition>
</div>
</template>
@@ -1,168 +0,0 @@
<script setup>
import { useTemplateRef, computed, ref } from 'vue';
import { useI18n, I18nT } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { useMapGetter } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
conversationCount: {
type: Number,
required: true,
},
});
const emit = defineEmits(['select']);
const { t } = useI18n();
const containerRef = useTemplateRef('containerRef');
const [showDropdown, toggleDropdown] = useToggle(false);
const selectedTeam = ref(null);
const teams = useMapGetter('teams/getTeams');
const bulkActionsUiFlags = useMapGetter('bulkActions/getUIFlags');
const isUpdating = computed(() => bulkActionsUiFlags.value.isUpdating);
const teamMenuItems = computed(() => {
const items = [
{
action: 'select',
value: 'none',
label: t('BULK_ACTION.TEAMS.NONE'),
isSelected: selectedTeam.value?.id === 0,
},
];
teams.value.forEach(team => {
items.push({
action: 'select',
value: team.id,
label: team.name,
isSelected: selectedTeam.value?.id === team.id,
});
});
return items;
});
const handleSelectTeam = item => {
if (item.value === 'none') {
selectedTeam.value = { id: 0, name: t('BULK_ACTION.TEAMS.NONE') };
} else {
const foundTeam = teams.value.find(team => team.id === item.value);
selectedTeam.value = foundTeam || {
id: 0,
name: t('BULK_ACTION.TEAMS.NONE'),
};
}
};
const handleAssign = () => {
if (isUpdating.value) return;
emit('select', selectedTeam.value);
selectedTeam.value = null;
toggleDropdown(false);
};
const handleCancel = () => {
selectedTeam.value = null;
};
const handleDismiss = () => {
selectedTeam.value = null;
toggleDropdown(false);
};
</script>
<template>
<div ref="containerRef" class="relative">
<Button
v-tooltip="$t('BULK_ACTION.ASSIGN_TEAM_TOOLTIP')"
icon="i-lucide-users-round"
slate
xs
ghost
:class="{ 'bg-n-alpha-2': showDropdown }"
@click="toggleDropdown()"
/>
<Transition
enter-active-class="transition-all duration-150 ease-out origin-bottom"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition-all duration-100 ease-in origin-bottom"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<DropdownMenu
v-if="showDropdown"
v-on-click-outside="[handleDismiss, { ignore: [containerRef] }]"
:menu-items="teamMenuItems"
show-search
:search-placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
class="ltr:-right-2 rtl:-left-2 bottom-8 w-60 max-h-80"
@action="handleSelectTeam"
>
<template v-if="selectedTeam" #footer>
<div
class="pt-2 pb-2 px-2 border-t border-n-weak sticky bottom-0 rounded-b-md z-20 bg-n-alpha-3 backdrop-blur-[4px]"
>
<div class="flex flex-col gap-2">
<I18nT
v-if="selectedTeam.id"
keypath="BULK_ACTION.TEAMS.ASSIGN_TEAM_CONFIRMATION_LABEL"
tag="p"
class="text-xs text-n-slate-11 px-1 mb-0"
:plural="props.conversationCount"
>
<template #n>
<strong class="text-n-slate-12">
{{ props.conversationCount }}
</strong>
</template>
<template #teamName>
<strong class="text-n-slate-12">
{{ selectedTeam.name }}
</strong>
</template>
</I18nT>
<I18nT
v-else
keypath="BULK_ACTION.TEAMS.UNASSIGN_TEAM_CONFIRMATION_LABEL"
tag="p"
class="text-xs text-n-slate-11 px-1 mb-0"
:plural="props.conversationCount"
>
<template #n>
<strong class="text-n-slate-12">
{{ props.conversationCount }}
</strong>
</template>
</I18nT>
<div class="flex gap-2">
<Button
sm
faded
slate
class="flex-1"
:label="t('BULK_ACTION.CANCEL')"
@click="handleCancel"
/>
<Button
sm
class="flex-1"
:label="t('BULK_ACTION.YES')"
:disabled="isUpdating"
:is-loading="isUpdating"
@click="handleAssign"
/>
</div>
</div>
</div>
</template>
</DropdownMenu>
</Transition>
</div>
</template>
@@ -1,109 +0,0 @@
<script setup>
import { useTemplateRef, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
showResolve: {
type: Boolean,
default: true,
},
showReopen: {
type: Boolean,
default: true,
},
showSnooze: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['update']);
const { t } = useI18n();
const containerRef = useTemplateRef('containerRef');
const [showDropdown, toggleDropdown] = useToggle(false);
const updateMenuItems = computed(() => {
const items = [];
if (props.showResolve) {
items.push({
action: 'update',
value: 'resolved',
label: t('CONVERSATION.HEADER.RESOLVE_ACTION'),
icon: 'i-lucide-check',
});
}
if (props.showReopen) {
items.push({
action: 'update',
value: 'open',
label: t('CONVERSATION.HEADER.REOPEN_ACTION'),
icon: 'i-lucide-redo',
});
}
if (props.showSnooze) {
items.push({
action: 'update',
value: 'snoozed',
label: t('BULK_ACTION.UPDATE.SNOOZE_UNTIL'),
icon: 'i-lucide-alarm-clock',
});
}
return items;
});
const handleUpdate = item => {
if (item.value === 'snoozed') {
// If the user clicks on the snooze option from the bulk action change status dropdown.
// Open the snooze option for bulk action in the cmd bar.
const ninja = document.querySelector('ninja-keys');
ninja?.open({ parent: 'bulk_action_snooze_conversation' });
} else {
emit('update', item.value);
}
toggleDropdown(false);
};
</script>
<template>
<div ref="containerRef" class="relative">
<Button
v-tooltip="$t('BULK_ACTION.UPDATE.CHANGE_STATUS')"
icon="i-lucide-circle-fading-arrow-up"
slate
xs
ghost
:class="{ 'bg-n-alpha-2': showDropdown }"
@click="toggleDropdown()"
/>
<Transition
enter-active-class="transition-all duration-150 ease-out origin-bottom"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition-all duration-100 ease-in origin-bottom"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<DropdownMenu
v-if="showDropdown"
v-on-click-outside="[
() => toggleDropdown(false),
{ ignore: [containerRef] },
]"
:menu-items="updateMenuItems"
class="ltr:-right-[4.5rem] rtl:-left-[4.5rem] ltr:2xl:right-0 rtl:2xl:left-0 bottom-8 w-36"
@action="handleUpdate"
/>
</Transition>
</div>
</template>
@@ -1,9 +1,7 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, useAttrs } from 'vue';
<script>
import { getUnixTime } from 'date-fns';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import { emitter } from 'shared/helpers/mitt';
import { useBulkActions } from 'dashboard/composables/chatlist/useBulkActions.js';
import wootConstants from 'dashboard/constants/globals';
import {
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
@@ -12,181 +10,315 @@ import {
} from 'dashboard/helper/commandbar/events';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import BulkAgentActions from './BulkAgentActions.vue';
import BulkUpdateActions from './BulkUpdateActions.vue';
import BulkLabelActions from './BulkLabelActions.vue';
import BulkTeamActions from './BulkTeamActions.vue';
import AgentSelector from './AgentSelector.vue';
import UpdateActions from './UpdateActions.vue';
import LabelActions from './LabelActions.vue';
import TeamActions from './TeamActions.vue';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
const props = defineProps({
conversations: {
type: Array,
default: () => [],
export default {
components: {
AgentSelector,
UpdateActions,
LabelActions,
TeamActions,
CustomSnoozeModal,
NextButton,
},
allConversationsSelected: {
type: Boolean,
default: false,
props: {
conversations: {
type: Array,
default: () => [],
},
allConversationsSelected: {
type: Boolean,
default: false,
},
selectedInboxes: {
type: Array,
default: () => [],
},
showOpenAction: {
type: Boolean,
default: false,
},
showResolvedAction: {
type: Boolean,
default: false,
},
showSnoozedAction: {
type: Boolean,
default: false,
},
},
selectedInboxes: {
type: Array,
default: () => [],
emits: [
'selectAllConversations',
'assignAgent',
'updateConversations',
'assignLabels',
'assignTeam',
'resolveConversations',
],
data() {
return {
showAgentsList: false,
showUpdateActions: false,
showLabelActions: false,
showTeamsList: false,
popoverPositions: {},
showCustomTimeSnoozeModal: false,
};
},
showOpenAction: {
type: Boolean,
default: false,
mounted() {
emitter.on(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
this.onCmdSnoozeConversation
);
emitter.on(
CMD_BULK_ACTION_REOPEN_CONVERSATION,
this.onCmdReopenConversation
);
emitter.on(
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
this.onCmdResolveConversation
);
},
showResolvedAction: {
type: Boolean,
default: false,
unmounted() {
emitter.off(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
this.onCmdSnoozeConversation
);
emitter.off(
CMD_BULK_ACTION_REOPEN_CONVERSATION,
this.onCmdReopenConversation
);
emitter.off(
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
this.onCmdResolveConversation
);
},
showSnoozedAction: {
type: Boolean,
default: false,
methods: {
onCmdSnoozeConversation(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomTimeSnoozeModal = true;
} else if (typeof snoozeType === 'number') {
this.updateConversations('snoozed', snoozeType);
} else {
this.updateConversations('snoozed', findSnoozeTime(snoozeType) || null);
}
},
onCmdReopenConversation() {
this.updateConversations('open', null);
},
onCmdResolveConversation() {
this.updateConversations('resolved', null);
},
customSnoozeTime(customSnoozedTime) {
this.showCustomTimeSnoozeModal = false;
if (customSnoozedTime) {
this.updateConversations('snoozed', getUnixTime(customSnoozedTime));
}
},
hideCustomSnoozeModal() {
this.showCustomTimeSnoozeModal = false;
},
selectAll(e) {
this.$emit('selectAllConversations', e.target.checked);
},
submit(agent) {
this.$emit('assignAgent', agent);
},
updateConversations(status, snoozedUntil) {
this.$emit('updateConversations', status, snoozedUntil);
},
assignLabels(labels) {
this.$emit('assignLabels', labels);
},
assignTeam(team) {
this.$emit('assignTeam', team);
},
resolveConversations() {
this.$emit('resolveConversations');
},
toggleUpdateActions() {
this.showUpdateActions = !this.showUpdateActions;
},
toggleLabelActions() {
this.showLabelActions = !this.showLabelActions;
},
toggleAgentList() {
this.showAgentsList = !this.showAgentsList;
},
toggleTeamsList() {
this.showTeamsList = !this.showTeamsList;
},
},
});
const emit = defineEmits(['selectAllConversations']);
defineOptions({
inheritAttrs: false,
});
const attrs = useAttrs();
const {
onAssignAgent,
onAssignLabels,
onAssignTeamsForBulk: onAssignTeam,
onUpdateConversations,
} = useBulkActions();
const showCustomTimeSnoozeModal = ref(false);
function onCmdSnoozeConversation(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
showCustomTimeSnoozeModal.value = true;
} else if (typeof snoozeType === 'number') {
onUpdateConversations('snoozed', snoozeType);
} else {
onUpdateConversations('snoozed', findSnoozeTime(snoozeType) || null);
}
}
function onCmdReopenConversation() {
onUpdateConversations('open', null);
}
function onCmdResolveConversation() {
onUpdateConversations('resolved', null);
}
function customSnoozeTime(customSnoozedTime) {
showCustomTimeSnoozeModal.value = false;
if (customSnoozedTime) {
onUpdateConversations('snoozed', getUnixTime(customSnoozedTime));
}
}
function hideCustomSnoozeModal() {
showCustomTimeSnoozeModal.value = false;
}
// Computed property with getter/setter to enable v-model usage
const allSelected = computed({
get: () => props.allConversationsSelected,
set: value => {
emit('selectAllConversations', value);
},
});
onMounted(() => {
emitter.on(CMD_BULK_ACTION_SNOOZE_CONVERSATION, onCmdSnoozeConversation);
emitter.on(CMD_BULK_ACTION_REOPEN_CONVERSATION, onCmdReopenConversation);
emitter.on(CMD_BULK_ACTION_RESOLVE_CONVERSATION, onCmdResolveConversation);
});
onUnmounted(() => {
emitter.off(CMD_BULK_ACTION_SNOOZE_CONVERSATION, onCmdSnoozeConversation);
emitter.off(CMD_BULK_ACTION_REOPEN_CONVERSATION, onCmdReopenConversation);
emitter.off(CMD_BULK_ACTION_RESOLVE_CONVERSATION, onCmdResolveConversation);
});
};
</script>
<template>
<Transition
enter-active-class="transition-all duration-200 ease-out origin-bottom"
enter-from-class="opacity-0 scale-95 translate-y-2"
enter-to-class="opacity-100 scale-100 translate-y-0"
leave-active-class="transition-all duration-150 ease-in origin-bottom"
leave-from-class="opacity-100 scale-100 translate-y-0"
leave-to-class="opacity-0 scale-95 translate-y-2"
>
<div
v-if="conversations.length > 0"
v-bind="attrs"
class="px-2 absolute bottom-20 sm:bottom-4 left-1/2 -translate-x-1/2 z-30 w-full origin-bottom"
>
<div
v-if="allConversationsSelected"
class="bg-n-amber-2 outline -outline-offset-1 outline-1 outline-n-amber-5 rounded-lg text-sm mb-2 py-1.5 px-2 text-n-amber-text"
>
{{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }}
</div>
<div
class="flex items-center justify-between p-2 bg-n-button-color outline outline-1 -outline-offset-1 rounded-[10px] outline-n-weak shadow-[0_0_12px_0_rgba(27,40,59,0.08)]"
>
<div class="ltr:ml-0.5 rtl:mr-0.5 flex items-center gap-1">
<label class="cursor-pointer flex items-center gap-1.5">
<Checkbox
v-model="allSelected"
:indeterminate="!allConversationsSelected"
/>
<span class="cursor-pointer">
{{
$t('BULK_ACTION.CONVERSATIONS_SELECTED', {
conversationCount: conversations.length,
})
}}
</span>
</label>
<div class="w-px h-3 bg-n-weak rounded-lg ltr:ml-1 rtl:mr-1" />
<NextButton
:label="$t('BULK_ACTION.CLEAR_SELECTION')"
ghost
class="!text-n-blue-11 !px-1 !h-6"
sm
@click="allSelected = false"
/>
</div>
<div class="flex items-center gap-2">
<BulkLabelActions @assign="onAssignLabels" />
<BulkUpdateActions
:show-resolve="!showResolvedAction"
:show-reopen="!showOpenAction"
:show-snooze="!showSnoozedAction"
@update="onUpdateConversations"
/>
<BulkAgentActions
:selected-inboxes="selectedInboxes"
:conversation-count="conversations.length"
@select="onAssignAgent"
/>
<BulkTeamActions
:conversation-count="conversations.length"
@select="onAssignTeam"
/>
</div>
<div class="bulk-action__container">
<div class="flex items-center justify-between">
<label class="flex items-center justify-between bulk-action__panel">
<input
type="checkbox"
class="checkbox"
:checked="allConversationsSelected"
:indeterminate.prop="!allConversationsSelected"
@change="selectAll($event)"
/>
<span>
{{
$t('BULK_ACTION.CONVERSATIONS_SELECTED', {
conversationCount: conversations.length,
})
}}
</span>
</label>
<div class="flex items-center gap-1 bulk-action__actions">
<NextButton
v-tooltip="$t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
icon="i-lucide-tags"
slate
xs
faded
@click="toggleLabelActions"
/>
<NextButton
v-tooltip="$t('BULK_ACTION.UPDATE.CHANGE_STATUS')"
icon="i-lucide-repeat"
slate
xs
faded
@click="toggleUpdateActions"
/>
<NextButton
v-tooltip="$t('BULK_ACTION.ASSIGN_AGENT_TOOLTIP')"
icon="i-lucide-user-round-plus"
slate
xs
faded
@click="toggleAgentList"
/>
<NextButton
v-tooltip="$t('BULK_ACTION.ASSIGN_TEAM_TOOLTIP')"
icon="i-lucide-users-round"
slate
xs
faded
@click="toggleTeamsList"
/>
</div>
<transition name="popover-animation">
<LabelActions
v-if="showLabelActions"
class="label-actions-box"
@assign="assignLabels"
@close="showLabelActions = false"
/>
</transition>
<transition name="popover-animation">
<UpdateActions
v-if="showUpdateActions"
class="update-actions-box"
:selected-inboxes="selectedInboxes"
:conversation-count="conversations.length"
:show-resolve="!showResolvedAction"
:show-reopen="!showOpenAction"
:show-snooze="!showSnoozedAction"
@update="updateConversations"
@close="showUpdateActions = false"
/>
</transition>
<transition name="popover-animation">
<AgentSelector
v-if="showAgentsList"
class="agent-actions-box"
:selected-inboxes="selectedInboxes"
:conversation-count="conversations.length"
@select="submit"
@close="showAgentsList = false"
/>
</transition>
<transition name="popover-animation">
<TeamActions
v-if="showTeamsList"
class="team-actions-box"
@assign-team="assignTeam"
@close="showTeamsList = false"
/>
</transition>
</div>
</Transition>
<woot-modal
v-model:show="showCustomTimeSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<CustomSnoozeModal
@close="hideCustomSnoozeModal"
@choose-time="customSnoozeTime"
/>
</woot-modal>
<div v-if="allConversationsSelected" class="bulk-action__alert">
{{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }}
</div>
<woot-modal
v-model:show="showCustomTimeSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<CustomSnoozeModal
@close="hideCustomSnoozeModal"
@choose-time="customSnoozeTime"
/>
</woot-modal>
</div>
</template>
<style scoped lang="scss">
.bulk-action__container {
@apply p-3 relative border-b border-solid border-n-strong dark:border-n-weak;
}
.bulk-action__panel {
@apply cursor-pointer;
span {
@apply text-xs my-0 mx-1;
}
input[type='checkbox'] {
@apply cursor-pointer m-0;
}
}
.bulk-action__alert {
@apply bg-n-amber-3 text-n-amber-12 rounded text-xs mt-2 py-1 px-2 border border-solid border-n-amber-5;
}
.popover-animation-enter-active,
.popover-animation-leave-active {
transition: transform ease-out 0.1s;
}
.popover-animation-enter {
transform: scale(0.95);
@apply opacity-0;
}
.popover-animation-enter-to {
transform: scale(1);
@apply opacity-100;
}
.popover-animation-leave {
transform: scale(1);
@apply opacity-100;
}
.popover-animation-leave-to {
transform: scale(0.95);
@apply opacity-0;
}
.label-actions-box {
--triangle-position: 5.3125rem;
}
.update-actions-box {
--triangle-position: 3.5rem;
}
.agent-actions-box {
--triangle-position: 1.75rem;
}
.team-actions-box {
--triangle-position: 0.125rem;
}
</style>
@@ -0,0 +1,141 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { vOnClickOutside } from '@vueuse/components';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
const emit = defineEmits(['close', 'assign']);
const { t } = useI18n();
const labels = useMapGetter('labels/getLabels');
const query = ref('');
const selectedLabels = ref([]);
const filteredLabels = computed(() => {
if (!query.value) return labels.value;
return labels.value.filter(label =>
label.title.toLowerCase().includes(query.value.toLowerCase())
);
});
const hasLabels = computed(() => labels.value.length > 0);
const hasFilteredLabels = computed(() => filteredLabels.value.length > 0);
const isLabelSelected = label => {
return selectedLabels.value.includes(label);
};
const onClose = () => {
emit('close');
};
const handleAssign = () => {
if (selectedLabels.value.length > 0) {
emit('assign', selectedLabels.value);
}
};
</script>
<template>
<div
v-on-click-outside="onClose"
class="absolute ltr:right-2 rtl:left-2 top-12 origin-top-right z-20 w-60 bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md"
role="dialog"
aria-labelledby="label-dialog-title"
>
<div class="triangle">
<svg height="12" viewBox="0 0 24 12" width="24">
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
</svg>
</div>
<div class="flex items-center justify-between p-2.5">
<span class="text-sm font-medium">{{
t('BULK_ACTION.LABELS.ASSIGN_LABELS')
}}</span>
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
</div>
<div class="flex flex-col max-h-60 min-h-0">
<header class="py-2 px-2.5">
<Input
v-model="query"
type="search"
:placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
icon-left="i-lucide-search"
size="sm"
class="w-full"
:aria-label="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
/>
</header>
<ul
v-if="hasLabels"
class="flex-1 overflow-y-auto m-0 list-none"
role="listbox"
:aria-label="t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
>
<li v-if="!hasFilteredLabels" class="p-2 text-center">
<span class="text-sm text-n-slate-11">{{
t('BULK_ACTION.LABELS.NO_LABELS_FOUND')
}}</span>
</li>
<li
v-for="label in filteredLabels"
:key="label.id"
class="my-1 mx-0 py-0 px-2.5"
role="option"
:aria-selected="isLabelSelected(label.title)"
>
<label
class="items-center rounded-md cursor-pointer flex py-1 px-2.5 hover:bg-n-slate-3 dark:hover:bg-n-solid-3 has-[:checked]:bg-n-slate-2"
>
<input
v-model="selectedLabels"
type="checkbox"
:value="label.title"
class="my-0 ltr:mr-2.5 rtl:ml-2.5"
:aria-label="label.title"
/>
<span
class="overflow-hidden flex-grow w-full text-sm whitespace-nowrap text-ellipsis"
>
{{ label.title }}
</span>
<span
class="rounded-md h-3 w-3 flex-shrink-0 border border-solid border-n-weak"
:style="{ backgroundColor: label.color }"
/>
</label>
</li>
</ul>
<div v-else class="p-2 text-center">
<span class="text-sm text-n-slate-11">{{
t('CONTACTS_BULK_ACTIONS.NO_LABELS_FOUND')
}}</span>
</div>
<footer class="p-2">
<NextButton
sm
type="submit"
class="w-full"
:label="t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
:disabled="!selectedLabels.length"
@click="handleAssign"
/>
</footer>
</div>
</div>
</template>
<style scoped lang="scss">
.triangle {
@apply block z-10 absolute text-left -top-3 ltr:right-[--triangle-position] rtl:left-[--triangle-position];
svg path {
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
}
}
</style>
@@ -0,0 +1,145 @@
<script>
import { mapGetters } from 'vuex';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
emits: ['assignTeam', 'close'],
data() {
return {
query: '',
selectedteams: [],
};
},
computed: {
...mapGetters({ teams: 'teams/getTeams' }),
filteredTeams() {
return [
{ name: 'None', id: 0 },
...this.teams.filter(team =>
team.name.toLowerCase().includes(this.query.toLowerCase())
),
];
},
},
methods: {
assignTeam(key) {
this.$emit('assignTeam', key);
},
onClose() {
this.$emit('close');
},
},
};
</script>
<template>
<div v-on-clickaway="onClose" class="bulk-action__teams">
<div class="triangle">
<svg height="12" viewBox="0 0 24 12" width="24">
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
</svg>
</div>
<div class="flex items-center justify-between header">
<span>{{ $t('BULK_ACTION.TEAMS.TEAM_SELECT_LABEL') }}</span>
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
</div>
<div class="container">
<div class="team__list-container">
<ul>
<li class="search-container">
<div
class="flex items-center justify-between h-8 gap-2 agent-list-search"
>
<fluent-icon icon="search" class="search-icon" size="16" />
<input
v-model="query"
type="search"
:placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
class="reset-base !outline-0 !text-sm agent--search_input"
/>
</div>
</li>
<template v-if="filteredTeams.length">
<li v-for="team in filteredTeams" :key="team.id">
<div class="team__list-item" @click="assignTeam(team)">
<span class="my-0 ltr:ml-2 rtl:mr-2 text-n-slate-12">
{{ team.name }}
</span>
</div>
</li>
</template>
<li v-else>
<div class="team__list-item">
<span class="my-0 ltr:ml-2 rtl:mr-2 text-n-slate-12">
{{ $t('BULK_ACTION.TEAMS.NO_TEAMS_AVAILABLE') }}
</span>
</div>
</li>
</ul>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.bulk-action__teams {
@apply max-w-[75%] absolute ltr:right-2 rtl:left-2 top-12 origin-top-right w-auto z-20 min-w-[15rem] bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md;
.header {
@apply p-2.5;
span {
@apply text-sm font-medium;
}
}
.container {
@apply overflow-y-auto max-h-[15rem];
.team__list-container {
@apply h-full;
}
.agent-list-search {
@apply py-0 px-2.5 bg-n-alpha-black2 border border-solid border-n-strong rounded-md;
.search-icon {
@apply text-n-slate-10;
}
.agent--search_input {
@apply border-0 text-xs m-0 dark:bg-transparent bg-transparent w-full h-[unset];
}
}
}
.triangle {
@apply block z-10 absolute text-left -top-3 ltr:right-[--triangle-position] rtl:left-[--triangle-position];
svg path {
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
}
}
}
ul {
@apply m-0 list-none;
li {
&:last-child {
.agent-list-item {
@apply last:rounded-b-lg;
}
}
}
}
.team__list-item {
@apply flex items-center p-2.5 cursor-pointer hover:bg-n-slate-3 dark:hover:bg-n-solid-3;
span {
@apply text-sm;
}
}
.search-container {
@apply py-0 px-2.5 sticky top-0 z-20 bg-n-alpha-3 backdrop-blur-[100px];
}
</style>
@@ -0,0 +1,109 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { ref } from 'vue';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
showResolve: {
type: Boolean,
default: true,
},
showReopen: {
type: Boolean,
default: true,
},
showSnooze: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['update', 'close']);
const { t } = useI18n();
const actions = ref([
{ icon: 'i-lucide-check', key: 'resolved' },
{ icon: 'i-lucide-redo', key: 'open' },
{ icon: 'i-lucide-alarm-clock', key: 'snoozed' },
]);
const updateConversations = key => {
if (key === 'snoozed') {
// If the user clicks on the snooze option from the bulk action change status dropdown.
// Open the snooze option for bulk action in the cmd bar.
const ninja = document.querySelector('ninja-keys');
ninja?.open({ parent: 'bulk_action_snooze_conversation' });
} else {
emit('update', key);
}
};
const onClose = () => {
emit('close');
};
const showAction = key => {
const actionsMap = {
resolved: props.showResolve,
open: props.showReopen,
snoozed: props.showSnooze,
};
return actionsMap[key] || false;
};
const actionLabel = key => {
const labelsMap = {
resolved: t('CONVERSATION.HEADER.RESOLVE_ACTION'),
open: t('CONVERSATION.HEADER.REOPEN_ACTION'),
snoozed: t('BULK_ACTION.UPDATE.SNOOZE_UNTIL'),
};
return labelsMap[key] || '';
};
</script>
<template>
<div
v-on-clickaway="onClose"
class="absolute z-20 w-auto origin-top-right border border-solid rounded-lg shadow-md ltr:right-2 rtl:left-2 top-12 bg-n-alpha-3 backdrop-blur-[100px] border-n-weak"
>
<div
class="right-[var(--triangle-position)] block z-10 absolute text-left -top-3"
>
<svg height="12" viewBox="0 0 24 12" width="24">
<path
d="M20 12l-8-8-12 12"
fill-rule="evenodd"
stroke-width="1px"
class="fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak"
/>
</svg>
</div>
<div class="p-2.5 flex gap-1 items-center justify-between">
<span class="text-sm font-medium text-n-slate-12">
{{ $t('BULK_ACTION.UPDATE.CHANGE_STATUS') }}
</span>
<Button ghost xs slate icon="i-lucide-x" @click="onClose" />
</div>
<div class="px-2.5 pt-0 pb-2.5">
<WootDropdownMenu class="m-0 list-none">
<template v-for="action in actions">
<WootDropdownItem v-if="showAction(action.key)" :key="action.key">
<Button
ghost
sm
slate
class="!w-full !justify-start"
:icon="action.icon"
:label="actionLabel(action.key)"
@click="updateConversations(action.key)"
/>
</WootDropdownItem>
</template>
</WootDropdownMenu>
</div>
</div>
</template>
@@ -23,15 +23,9 @@ export function useBulkActions() {
function deSelectConversation(conversationId, inboxId) {
store.dispatch('bulkActions/removeSelectedConversationIds', conversationId);
// Only remove one instance of the inboxId, not all
// This handles the case where multiple conversations from the same inbox are selected
const index = selectedInboxes.value.indexOf(inboxId);
if (index > -1) {
selectedInboxes.value = [
...selectedInboxes.value.slice(0, index),
...selectedInboxes.value.slice(index + 1),
];
}
selectedInboxes.value = selectedInboxes.value.filter(
item => item !== inboxId
);
}
function resetBulkActions() {
@@ -147,8 +141,6 @@ export function useBulkActions() {
}
async function onUpdateConversations(status, snoozedUntil) {
if (selectedConversations.value.length === 0) return;
let conversationIds = selectedConversations.value;
let skippedCount = 0;
@@ -1,13 +1,24 @@
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
import { useCallsStore } from 'dashboard/stores/calls';
import { useAlert } from 'dashboard/composables';
import {
useWhatsappCallSession,
sendWhatsappTerminateBeacon,
cleanupWhatsappSession,
} from 'dashboard/composables/useWhatsappCallSession';
import { handleVoiceCallCreated } from 'dashboard/helper/voice';
import Timer from 'dashboard/helper/Timer';
const isWhatsappCall = call => call?.provider === 'whatsapp';
export function useCallSession() {
const store = useStore();
const callsStore = useCallsStore();
const whatsappSession = useWhatsappCallSession();
const { t } = useI18n();
const isJoining = ref(false);
const callDuration = ref(0);
@@ -18,6 +29,7 @@ export function useCallSession() {
const activeCall = computed(() => callsStore.activeCall);
const incomingCalls = computed(() => callsStore.incomingCalls);
const hasActiveCall = computed(() => callsStore.hasActiveCall);
const hasIncomingCall = computed(() => callsStore.hasIncomingCall);
watch(
hasActiveCall,
@@ -32,20 +44,78 @@ export function useCallSession() {
{ immediate: true }
);
// Warn before a refresh/close drops a live or ringing call. Cable events
// aren't replayed on reconnect, so a confirmed refresh during ringing would
// leave the agent unable to accept; for active calls the WebRTC session
// dies outright (no rejoin path).
const handleBeforeUnload = event => {
if (!hasActiveCall.value && !hasIncomingCall.value) return;
event.preventDefault();
event.returnValue = '';
};
// Cable broadcasts (voice_call.incoming / message.created) are one-shot, so
// on a hard refresh they leave the calls store empty. Seed it from any
// ringing voice_call message in the conversation cache.
const seedCallsFromHydratedMessages = () => {
const conversations = store.getters.getAllConversations || [];
const currentUserId = store.getters.getCurrentUserID;
conversations.forEach(conv => {
(conv.messages || []).forEach(msg => {
if (msg.content_type !== 'voice_call') return;
if (msg.call?.status !== 'ringing') return;
handleVoiceCallCreated(msg, currentUserId);
});
});
};
// Terminate only the active call — ringing calls stay alive on Meta so the
// agent can pick them up after reload (seeded back via the watcher above).
const handlePageHide = () => {
sendWhatsappTerminateBeacon();
};
const handleTwilioDisconnected = () => callsStore.clearActiveCall();
onMounted(() => {
TwilioVoiceClient.addEventListener('call:disconnected', () =>
callsStore.clearActiveCall()
TwilioVoiceClient.addEventListener(
'call:disconnected',
handleTwilioDisconnected
);
window.addEventListener('beforeunload', handleBeforeUnload);
window.addEventListener('pagehide', handlePageHide);
seedCallsFromHydratedMessages();
});
// Re-seed when conversations stream in after mount; addCall merges by callSid.
watch(
() => store.getters.getAllConversations?.length,
() => seedCallsFromHydratedMessages()
);
onUnmounted(() => {
durationTimer.stop();
TwilioVoiceClient.removeEventListener('call:disconnected', () =>
callsStore.clearActiveCall()
TwilioVoiceClient.removeEventListener(
'call:disconnected',
handleTwilioDisconnected
);
window.removeEventListener('beforeunload', handleBeforeUnload);
window.removeEventListener('pagehide', handlePageHide);
});
const findCall = callSid => callsStore.calls.find(c => c.callSid === callSid);
const endCall = async ({ conversationId, inboxId, callSid }) => {
const call = findCall(callSid);
if (isWhatsappCall(call)) {
// Pass call.callId so a wiped module state (e.g. a prior accept attempt
// tore down the WebRTC session) doesn't stop us hitting /terminate.
await whatsappSession.endActiveCall(call.callId);
durationTimer.stop();
callsStore.clearActiveCall();
return;
}
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
TwilioVoiceClient.endClientCall();
durationTimer.stop();
@@ -55,8 +125,26 @@ export function useCallSession() {
const joinCall = async ({ conversationId, inboxId, callSid }) => {
if (isJoining.value) return null;
const call = findCall(callSid);
// Outbound calls were initiated by this agent — there is no inbound offer
// to accept and the WebRTC session is already mid-handshake. Routing
// through acceptIncomingCall would call prepareInboundAnswer → cleanup()
// and destroy the live outbound session, then 409 from the backend.
if (call?.callDirection === 'outbound') return null;
isJoining.value = true;
try {
if (isWhatsappCall(call)) {
await whatsappSession.acceptIncomingCall({
callId: call.callId,
sdpOffer: call.sdpOffer,
iceServers: call.iceServers,
});
callsStore.setCallActive(callSid);
durationTimer.start();
return { callId: call.callId };
}
const device = await TwilioVoiceClient.initializeDevice(inboxId);
if (!device) return null;
@@ -84,6 +172,8 @@ export function useCallSession() {
}
// eslint-disable-next-line no-console
console.error('Failed to join call:', error);
// Drop any half-built WebRTC state so the next click starts fresh.
cleanupWhatsappSession();
return null;
} finally {
isJoining.value = false;
@@ -91,7 +181,19 @@ export function useCallSession() {
};
const rejectIncomingCall = callSid => {
TwilioVoiceClient.endClientCall();
const call = findCall(callSid);
if (isWhatsappCall(call) && call?.callId) {
// Outbound calls that are still ringing must be terminated, not rejected
// (reject is the inbound-side verb on Meta's API). Pass call.callId so
// a wiped module state still hits /terminate.
if (call.callDirection === 'outbound') {
whatsappSession.endActiveCall(call.callId);
} else {
whatsappSession.rejectIncomingCall(call.callId);
}
} else {
TwilioVoiceClient.endClientCall();
}
callsStore.dismissCall(callSid);
};
@@ -7,7 +7,6 @@ export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([
{ name: 'conversation_info' },
{ name: 'contact_attributes' },
{ name: 'contact_notes' },
{ name: 'shared_files' },
{ name: 'previous_conversation' },
{ name: 'conversation_participants' },
{ name: 'linear_issues' },
@@ -0,0 +1,391 @@
import { ref } from 'vue';
import Cookies from 'js-cookie';
import WhatsappCallsAPI from 'dashboard/api/channel/whatsapp/whatsappCallsAPI';
// Module-level state lets the cable handlers and unload listeners reach the
// live PeerConnection without prop-drilling refs through every composable.
let pc = null;
let localStream = null;
let remoteStream = null;
let remoteAudioEl = null;
let mediaRecorder = null;
let recorderChunks = [];
let audioContext = null;
let activeCallId = null;
let intentionallyClosing = false;
// Inbound calls record from the moment the agent clicks accept (their click =
// pickup). Outbound calls must wait — Meta's `connect` webhook (which lands
// during ringing) negotiates remote tracks ~20s before the contact actually
// answers, and we don't want pre-pickup audio in the recording. This flag is
// flipped to true by armOutboundRecorder() when the ACCEPTED status arrives.
let recorderArmed = false;
const ensureRemoteAudioElement = () => {
if (remoteAudioEl) return remoteAudioEl;
remoteAudioEl = document.createElement('audio');
remoteAudioEl.id = 'whatsapp-call-remote-audio';
remoteAudioEl.autoplay = true;
remoteAudioEl.playsInline = true;
remoteAudioEl.style.display = 'none';
document.body.appendChild(remoteAudioEl);
return remoteAudioEl;
};
const playRemoteStream = stream => {
const el = ensureRemoteAudioElement();
el.srcObject = stream;
el.play().catch(err => {
// eslint-disable-next-line no-console
console.warn('[WhatsApp Call] remote audio play() failed:', err);
});
};
// 1s timeslice keeps a recent recording chunk in memory so a remote hangup
// that races cleanup still has data to upload.
const RECORDING_TIMESLICE_MS = 1000;
const ICE_GATHER_TIMEOUT_MS = 10000;
const RECORDER_MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/ogg;codecs=opus',
];
// Outbound calls have no backend-supplied ice_servers (the call doesn't exist
// at offer time). Without STUN the browser only sends host candidates and
// browser→Meta media silently drops through any non-trivial NAT.
const DEFAULT_OUTBOUND_ICE_SERVERS = [{ urls: 'stun:stun.l.google.com:19302' }];
const waitForIceGatheringComplete = peer =>
new Promise(resolve => {
if (peer.iceGatheringState === 'complete') {
resolve();
return;
}
const timer = setTimeout(resolve, ICE_GATHER_TIMEOUT_MS);
peer.addEventListener('icegatheringstatechange', () => {
if (peer.iceGatheringState === 'complete') {
clearTimeout(timer);
resolve();
}
});
});
const setupRecorder = () => {
if (!localStream || !remoteStream || mediaRecorder) return;
// createMediaStreamSource on a stream with no audio tracks wires up to
// nothing — the recorded mix would be silence. Wait until ontrack fires.
if (remoteStream.getAudioTracks().length === 0) return;
audioContext = new AudioContext({ sampleRate: 48000 });
// AudioContext starts suspended under most autoplay policies; without
// resume() the destination stream produces silence.
audioContext.resume().catch(() => {});
const destination = audioContext.createMediaStreamDestination();
audioContext.createMediaStreamSource(localStream).connect(destination);
audioContext.createMediaStreamSource(remoteStream).connect(destination);
const mimeType = RECORDER_MIME_CANDIDATES.find(t =>
MediaRecorder.isTypeSupported(t)
);
if (!mimeType) return;
recorderChunks = [];
mediaRecorder = new MediaRecorder(destination.stream, { mimeType });
mediaRecorder.ondataavailable = event => {
if (event.data && event.data.size > 0) recorderChunks.push(event.data);
};
mediaRecorder.start(RECORDING_TIMESLICE_MS);
};
const cleanup = () => {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
try {
mediaRecorder.stop();
} catch (_) {
/* noop */
}
}
if (audioContext && audioContext.state !== 'closed') {
audioContext.close().catch(() => {});
}
if (localStream) localStream.getTracks().forEach(t => t.stop());
if (remoteStream) remoteStream.getTracks().forEach(t => t.stop());
if (pc) pc.close();
if (remoteAudioEl) remoteAudioEl.srcObject = null;
pc = null;
localStream = null;
remoteStream = null;
mediaRecorder = null;
recorderChunks = [];
audioContext = null;
activeCallId = null;
intentionallyClosing = false;
recorderArmed = false;
};
const buildPeerConnection = iceServers => {
const config = iceServers && iceServers.length ? { iceServers } : {};
pc = new RTCPeerConnection(config);
remoteStream = new MediaStream();
pc.ontrack = event => {
// Reuse the same MediaStream object — the recorder's audioContext source
// taps it once, so reassigning would orphan the recorder.
const tracks =
event.streams && event.streams[0]
? event.streams[0].getTracks()
: [event.track];
tracks.forEach(track => {
if (!remoteStream.getTracks().includes(track))
remoteStream.addTrack(track);
});
playRemoteStream(remoteStream);
// Only arm the recorder when the call is actually accepted. For outbound
// this is the ACCEPTED status webhook; for inbound this is the agent's
// own click (acceptIncomingCall flips recorderArmed before returning).
if (recorderArmed) setupRecorder();
};
return pc;
};
const stopRecorderAndUpload = async callId => {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
await new Promise(resolve => {
mediaRecorder.addEventListener('stop', resolve, { once: true });
try {
mediaRecorder.stop();
} catch (_) {
resolve();
}
});
}
if (!recorderChunks.length || !callId) return;
const blob = new Blob(recorderChunks, { type: recorderChunks[0].type });
// Best-effort — the controller's idempotency guard handles a retry.
try {
await WhatsappCallsAPI.uploadRecording(callId, blob);
} catch (_) {
/* noop */
}
};
// devise-token-auth requires access-token / client / uid headers on every
// request — navigator.sendBeacon can't set custom headers, so we rehydrate
// the auth payload from the cw_d_session_info cookie that the dashboard sets
// at login. Used by the page-close terminate path below.
const getDeviseAuthHeaders = () => {
try {
const raw = Cookies.get('cw_d_session_info');
if (!raw) return null;
const session = JSON.parse(raw);
return {
'access-token': session['access-token'] || '',
client: session.client || '',
uid: session.uid || '',
expiry: session.expiry || '',
'token-type': session['token-type'] || 'Bearer',
};
} catch (_) {
return null;
}
};
const beaconTerminate = callId => {
if (!callId) return;
const accountId = window.location.pathname.split('/')[3];
if (!accountId) return;
const headers = getDeviseAuthHeaders();
if (!headers) return;
const url = `/api/v1/accounts/${accountId}/whatsapp_calls/${callId}/terminate`;
// fetch+keepalive (instead of navigator.sendBeacon) so we can attach auth
// headers — without them devise-token-auth 401s and the call stays open on
// Meta until its carrier-side timeout (~60s).
try {
fetch(url, {
method: 'POST',
keepalive: true,
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...headers },
body: '{}',
}).catch(() => {});
} catch (_) {
/* noop */
}
};
export function useWhatsappCallSession() {
const isInitiating = ref(false);
const prepareInboundAnswer = async (sdpOffer, iceServers) => {
cleanup();
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
buildPeerConnection(iceServers);
localStream.getTracks().forEach(t => pc.addTrack(t, localStream));
await pc.setRemoteDescription({ type: 'offer', sdp: sdpOffer });
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
await waitForIceGatheringComplete(pc);
return pc.localDescription.sdp;
};
const prepareOutboundOffer = async () => {
cleanup();
localStream = await navigator.mediaDevices.getUserMedia({ audio: true });
buildPeerConnection(DEFAULT_OUTBOUND_ICE_SERVERS);
localStream.getTracks().forEach(t => pc.addTrack(t, localStream));
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await waitForIceGatheringComplete(pc);
return pc.localDescription.sdp;
};
const acceptIncomingCall = async ({ callId, sdpOffer, iceServers }) => {
// The store may not have sdpOffer yet (the cable broadcast can race the
// click). Fall back to GET /whatsapp_calls/:id which exposes it.
let offer = sdpOffer;
let ice = iceServers;
if (!offer && callId) {
try {
const fresh = await WhatsappCallsAPI.show(callId);
offer = fresh?.sdp_offer || fresh?.sdpOffer;
ice = ice || fresh?.ice_servers || fresh?.iceServers;
} catch (e) {
// eslint-disable-next-line no-console
console.error(
'[WhatsApp Call] failed to fetch call data for accept:',
e
);
}
}
if (!offer) {
throw new Error('Missing sdp_offer for accept — call may have ended.');
}
const sdpAnswer = await prepareInboundAnswer(offer, ice);
activeCallId = callId;
// Inbound: agent's click is the pickup. Arm the recorder before the API
// round-trip so when ontrack fires (triggered by setRemoteDescription
// back in prepareInboundAnswer) the recorder is already authorized.
recorderArmed = true;
setupRecorder();
await WhatsappCallsAPI.accept(callId, sdpAnswer);
};
const rejectIncomingCall = async callId => {
intentionallyClosing = true;
try {
await WhatsappCallsAPI.reject(callId);
} finally {
cleanup();
}
};
const initiateOutboundCall = async conversationId => {
if (isInitiating.value) return null;
isInitiating.value = true;
try {
const sdpOffer = await prepareOutboundOffer();
const response = await WhatsappCallsAPI.initiate(
conversationId,
sdpOffer
);
// The permission-request branch returns no call id; let the caller render the banner.
activeCallId = response?.id || null;
return response;
} catch (e) {
cleanup();
// BE returns 422 when the contact hasn't opted in (permission template
// sent or already pending). Surface it to the caller as a normal
// response shape so it can render the banner instead of an error toast.
const data = e?.response?.data;
if (
data?.status === 'permission_requested' ||
data?.status === 'permission_pending'
) {
return { status: data.status };
}
throw e;
} finally {
isInitiating.value = false;
}
};
// callIdOverride is the call.id from the dashboard's calls store. Module
// `activeCallId` may be null after a prior accept attempt's cleanup() — but
// the call still exists on Meta and must still be terminated. Falling back
// to the override means hangup is robust to a wiped local session.
const endActiveCall = async (callIdOverride = null) => {
const callId = activeCallId || callIdOverride;
if (!callId) {
cleanup();
return;
}
intentionallyClosing = true;
try {
await stopRecorderAndUpload(callId);
await WhatsappCallsAPI.terminate(callId).catch(() => {});
} finally {
cleanup();
}
};
return {
isInitiating,
prepareInboundAnswer,
prepareOutboundOffer,
acceptIncomingCall,
rejectIncomingCall,
initiateOutboundCall,
endActiveCall,
};
}
// Cable handlers fire outside any composable instance, so the shared session
// surface is exposed as module-level functions for them.
export const applyOutboundAnswer = async (callId, sdpAnswer) => {
if (!pc) return;
activeCallId = callId;
await pc.setRemoteDescription({ type: 'answer', sdp: sdpAnswer });
};
// Called by the cable handler when Meta delivers status=ACCEPTED for the
// outbound call (real pickup). Flips the recorder gate and starts the
// MediaRecorder. Idempotent — safe if ontrack hasn't fired yet (setupRecorder
// bails until the remote stream has audio tracks; ontrack will retry).
export const armOutboundRecorder = () => {
recorderArmed = true;
setupRecorder();
};
export const cleanupWhatsappSession = () => cleanup();
export const handleWhatsappRemoteEnd = async callId => {
// Snapshot before cleanup nulls activeCallId.
const id = callId || activeCallId;
if (!id) {
cleanup();
return;
}
try {
await stopRecorderAndUpload(id);
} finally {
cleanup();
}
};
export const setWhatsappCallMuted = muted => {
if (!localStream) return false;
localStream.getAudioTracks().forEach(track => {
track.enabled = !muted;
});
return muted;
};
export const sendWhatsappTerminateBeacon = () => {
if (!activeCallId || intentionallyClosing) return;
beaconTerminate(activeCallId);
};
+1 -1
View File
@@ -41,8 +41,8 @@ export const FEATURE_FLAGS = {
CAPTAIN_CUSTOM_TOOLS: 'custom_tools',
CAPTAIN_V2: 'captain_integration_v2',
CAPTAIN_TASKS: 'captain_tasks',
CAPTAIN_DOCUMENT_AUTO_SYNC: 'captain_document_auto_sync',
SAML: 'saml',
QUOTED_EMAIL_REPLY: 'quoted_email_reply',
COMPANIES: 'companies',
ADVANCED_SEARCH: 'advanced_search',
CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes',
@@ -4,6 +4,12 @@ import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotifi
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
import { useImpersonation } from 'dashboard/composables/useImpersonation';
import { useCallsStore } from 'dashboard/stores/calls';
import {
applyOutboundAnswer,
armOutboundRecorder,
handleWhatsappRemoteEnd,
} from 'dashboard/composables/useWhatsappCallSession';
const { isImpersonating } = useImpersonation();
@@ -35,6 +41,10 @@ class ActionCableConnector extends BaseActionCableConnector {
'account.cache_invalidated': this.onCacheInvalidate,
'account.enrichment_completed': this.onEnrichmentCompleted,
'copilot.message.created': this.onCopilotMessageCreated,
'voice_call.incoming': this.onVoiceCallIncoming,
'voice_call.outbound_connected': this.onVoiceCallOutboundConnected,
'voice_call.outbound_accepted': this.onVoiceCallOutboundAccepted,
'voice_call.ended': this.onVoiceCallEnded,
};
}
@@ -205,6 +215,59 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
};
// eslint-disable-next-line class-methods-use-this
onVoiceCallIncoming = data => {
if (data?.provider !== 'whatsapp') return;
useCallsStore().addCall({
callSid: data.call_id,
callId: data.id,
conversationId: data.conversation_id,
inboxId: data.inbox_id,
callDirection: 'inbound',
provider: 'whatsapp',
sdpOffer: data.sdp_offer,
iceServers: data.ice_servers,
caller: data.caller,
});
};
// `connect` is the WebRTC tunnel-ready signal (fires ~20s before pickup
// for outbound). Apply the SDP answer so the handshake completes during
// ringing, but stay non-active until `outbound_accepted` arrives.
// eslint-disable-next-line class-methods-use-this
onVoiceCallOutboundConnected = async data => {
if (data?.provider !== 'whatsapp' || !data.sdp_answer) return;
try {
await applyOutboundAnswer(data.id, data.sdp_answer);
} catch (_) {
/* noop */
}
};
// Real pickup signal — Meta sends status=ACCEPTED on the call when the
// contact answers. Flip active (timer starts) and arm the recorder.
// eslint-disable-next-line class-methods-use-this
onVoiceCallOutboundAccepted = data => {
if (data?.provider !== 'whatsapp') return;
const store = useCallsStore();
if (!store.calls.some(c => c.callSid === data.call_id)) return;
store.setCallActive(data.call_id);
armOutboundRecorder();
};
// eslint-disable-next-line class-methods-use-this
onVoiceCallEnded = async data => {
if (data?.provider !== 'whatsapp') return;
// Await upload before removeCall — the store's sync teardown would otherwise
// wipe the recorder chunks before they reach the server.
try {
await handleWhatsappRemoteEnd(data.id);
} catch (_) {
/* noop */
}
useCallsStore().removeCall(data.call_id);
};
}
export default {
+6 -3
View File
@@ -16,6 +16,7 @@ export const INBOX_TYPES = {
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
export const VOICE_CALL_PROVIDERS = {
TWILIO: 'twilio',
WHATSAPP: 'whatsapp',
};
export const getVoiceCallProvider = inbox => {
@@ -25,9 +26,11 @@ export const getVoiceCallProvider = inbox => {
const channelType = inbox.channel_type || inbox.channelType;
const voiceEnabled = inbox.voice_enabled || inbox.voiceEnabled;
if (channelType === INBOX_TYPES.TWILIO && voiceEnabled) {
return VOICE_CALL_PROVIDERS.TWILIO;
}
if (!voiceEnabled) return null;
if (channelType === INBOX_TYPES.TWILIO) return VOICE_CALL_PROVIDERS.TWILIO;
if (channelType === INBOX_TYPES.WHATSAPP)
return VOICE_CALL_PROVIDERS.WHATSAPP;
return null;
};
+13 -2
View File
@@ -49,6 +49,8 @@ function extractCallData(message) {
const call = message?.call || {};
return {
callSid: call.provider_call_id,
callId: call.id,
provider: call.provider,
status: call.status,
callDirection: call.direction === 'outgoing' ? 'outbound' : 'inbound',
conversationId: message?.conversation_id,
@@ -60,8 +62,15 @@ function extractCallData(message) {
export function handleVoiceCallCreated(message, currentUserId) {
if (!isVoiceCallMessage(message)) return;
const { callSid, callDirection, conversationId, assigneeId, senderId } =
extractCallData(message);
const {
callSid,
callId,
provider,
callDirection,
conversationId,
assigneeId,
senderId,
} = extractCallData(message);
if (
!shouldShowCall({
@@ -77,6 +86,8 @@ export function handleVoiceCallCreated(message, currentUserId) {
const callsStore = useCallsStore();
callsStore.addCall({
callSid,
callId,
provider,
conversationId,
callDirection,
senderId,
-2
View File
@@ -7,7 +7,6 @@ import de from './locale/de';
import el from './locale/el';
import en from './locale/en';
import es from './locale/es';
import et from './locale/et';
import fa from './locale/fa';
import fi from './locale/fi';
import fr from './locale/fr';
@@ -50,7 +49,6 @@ export default {
el,
en,
es,
et,
fa,
fi,
fr,
@@ -10,8 +10,7 @@
"NO_RESULTS": "No attributes found matching your search",
"ATTRIBUTE_MODELS": {
"CONVERSATION": "Conversation",
"CONTACT": "Contact",
"COMPANY": "ኩባንያ"
"CONTACT": "Contact"
},
"ATTRIBUTE_TYPES": {
"TEXT": "Text",
@@ -109,8 +108,7 @@
"TABS": {
"HEADER": "Custom Attributes",
"CONVERSATION": "Conversation",
"CONTACT": "Contact",
"COMPANY": "ኩባንያ"
"CONTACT": "Contact"
},
"LIST": {
"TABLE_HEADER": {
@@ -130,7 +130,6 @@
"ATLEAST_ONE_ACTION_REQUIRED": "At least one action is required"
},
"NONE_OPTION": "None",
"LAST_RESPONDING_AGENT": "Last Responding Agent",
"EVENTS": {
"CONVERSATION_CREATED": "Conversation Created",
"CONVERSATION_UPDATED": "Conversation Updated",
@@ -182,7 +181,6 @@
"BROWSER_LANGUAGE": "Browser Language",
"MAIL_SUBJECT": "Email Subject",
"COUNTRY_NAME": "Country",
"COMPANY_NAME": "ኩባንያ",
"REFERER_LINK": "Referrer Link",
"ASSIGNEE_NAME": "Assignee",
"TEAM_NAME": "Team",
@@ -6,12 +6,7 @@
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign {conversationCount} {conversationLabel}?",
"GO_BACK_LABEL": "Go back",
"ASSIGN_LABEL": "Assign",
"NONE": "None",
"CLEAR_SELECTION": "Clear",
"ASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {agentName}? | Are you sure you want to assign {n} conversations to {agentName}?",
"UNASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?",
"YES": "Yes",
"CANCEL": "Cancel",
"SEARCH_INPUT_PLACEHOLDER": "Search",
"ASSIGN_AGENT_TOOLTIP": "Assign agent",
"ASSIGN_TEAM_TOOLTIP": "Assign team",
@@ -43,8 +38,6 @@
"NONE": "None",
"NO_TEAMS_AVAILABLE": "There are no teams added to this account yet.",
"ASSIGN_SELECTED_TEAMS": "Assign selected team.",
"ASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {teamName}? | Are you sure you want to assign {n} conversations to {teamName}?",
"UNASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?",
"ASSIGN_SUCCESFUL": "Teams assigned successfully.",
"ASSIGN_FAILED": "Failed to assign team. Please try again."
}

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