Compare commits

..

1 Commits

Author SHA1 Message Date
Sony Mathew
2fdf3a23a8 feat(captain): share Jev quotas across classification calls 2026-09-30 17:50:36 -07:00
462 changed files with 6857 additions and 14614 deletions

View File

@@ -29,9 +29,3 @@ ignore:
# Patched 2.0 releases need an ai-agents migration (currently constrained to RubyLLM 1.x).
# Remove when a supported patched RubyLLM/ai-agents combination is adopted.
- CVE-2026-67991
# Think-tag parsing and Mistral matching ReDoS are reported on Ruby 3.1.
# We pin Ruby 3.4.4, where the affected regexes are linear-time.
# Remove when a supported patched RubyLLM/ai-agents combination is adopted;
# reassess if the supported Ruby runtime changes.
- CVE-2026-67987
- CVE-2026-67989

View File

@@ -23,7 +23,7 @@ FRONTEND_URL=http://0.0.0.0:3000
# CONVERSATION_MONITORS_LIMIT=20
# Allowlisted contact/conversation custom attribute keys to send as customer context
# CONVERSATION_MONITORS_ATTRIBUTES=deployment_type
# Each account can make 500,000 monitor calls per UTC day and 10,000,000 per UTC month.
# CONVERSATION_MONITORS_DAILY_TOKEN_LIMIT=20000000
# To use a dedicated URL for help center pages
# HELPCENTER_URL=http://0.0.0.0:3000

View File

@@ -1,41 +0,0 @@
name: Migration Version Check
on:
pull_request:
paths:
- 'db/migrate/**'
concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
migration_version_check:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Reject hand-written migration versions
run: |
rounded=$(git diff --name-only --diff-filter=AR HEAD^1 HEAD -- db/migrate | grep -E '^db/migrate/[0-9]{10}0000_' || true)
if [ -n "$rounded" ]; then
echo "::error::Rounded migration versions collide across branches. Regenerate with 'bundle exec rails g migration':"
echo "$rounded"
exit 1
fi
- name: Reject duplicate migration versions
run: |
duplicates=$(ls db/migrate | cut -d_ -f1 | sort | uniq -d)
if [ -n "$duplicates" ]; then
echo "::error::Duplicate migration versions break db:migrate:"
echo "$duplicates"
exit 1
fi

View File

@@ -44,7 +44,6 @@
## General Guidelines
- **Database foreign keys**: Prefer Rails associations and callbacks over database foreign-key constraints. Add a database foreign key only when there is a clear need and after checking deletion behavior and production migration/locking risks. Existing foreign keys alone are not a reason to add new ones.
- **Migrations**: Create migrations with `bundle exec rails g migration <Name>` so the version is a real timestamp. Never hand-write or round the version (e.g. `20260925000000`); rounded versions collide across branches and break `db:migrate`.
- Prefer the smallest production-ready change that solves the current problem.
- Build for the expected production path first. Do not add speculative guards, fallbacks, retries, or edge-case handling unless the caller can actually hit that case or production has proven it necessary.
- Enforce eligibility and exclusivity rules at the earliest shared entry point. Do not repeat backup guards across downstream jobs, callbacks, services, or writes unless a proven independent path bypasses that point.

View File

@@ -2,3 +2,4 @@
//= link administrate/application.css
//= link administrate/application.js
//= link administrate-field-active_storage/application.css
//= link secretField.js

View File

@@ -0,0 +1,45 @@
// eslint-disable-next-line
function toggleSecretField(e) {
e.preventDefault();
e.stopPropagation();
const toggler = e.currentTarget;
const secretField = toggler.parentElement;
const textElement = secretField.querySelector('[data-secret-masked]');
if (!textElement) return;
if (textElement.dataset.secretMasked === 'false') {
const maskedLength = secretField.dataset.secretText?.length || 10;
textElement.textContent = '•'.repeat(maskedLength);
textElement.dataset.secretMasked = 'true';
toggler.querySelector('svg use').setAttribute('xlink:href', '#eye-show');
return;
}
textElement.textContent = secretField.dataset.secretText;
textElement.dataset.secretMasked = 'false';
toggler.querySelector('svg use').setAttribute('xlink:href', '#eye-hide');
}
// eslint-disable-next-line
function copySecretField(e) {
e.preventDefault();
e.stopPropagation();
const toggler = e.currentTarget;
const secretField = toggler.parentElement;
navigator.clipboard.writeText(secretField.dataset.secretText);
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.cell-data__secret-field').forEach(field => {
const span = field.querySelector('[data-secret-masked]');
if (span && span.dataset.secretMasked === 'true') {
const len = field.dataset.secretText?.length || 10;
span.textContent = '•'.repeat(len);
}
});
});

View File

@@ -1,5 +1,32 @@
@charset 'utf-8';
// The super admin is styled by app/javascript/entrypoints/superadmin.scss.
// Sprockets only serves the selectize base styles its pickers need.
@import 'reset/normalize';
@import 'utilities/variables';
@import 'utilities/text-color';
@import 'selectize';
@import 'library/clearfix';
@import 'library/data-label';
@import 'library/variables';
@import 'base/forms';
@import 'base/layout';
@import 'base/lists';
@import 'base/tables';
@import 'base/typography';
@import 'components/app-container';
@import 'components/attributes';
@import 'components/buttons';
@import 'components/cells';
@import 'components/field-unit';
@import 'components/flashes';
@import 'components/form-actions';
@import 'components/main-content';
@import 'components/pagination';
@import 'components/search';
@import 'components/reports';
@import 'custom_styles';

View File

@@ -0,0 +1,96 @@
fieldset {
background-color: transparent;
border: 0;
margin: 0;
padding: 0;
}
legend {
font-weight: $font-weight-medium;
margin: 0;
padding: 0;
}
label {
display: block;
font-weight: $font-weight-medium;
margin: 0;
}
input,
select {
display: block;
font-family: $base-font-family;
font-size: $base-font-size;
}
[type="color"],
[type="date"],
[type="datetime-local"],
[type="email"],
[type="month"],
[type="number"],
[type="password"],
[type="search"],
[type="tel"],
[type="text"],
[type="time"],
[type="url"],
[type="week"],
input:not([type]),
textarea {
appearance: none;
background-color: $white;
border: $base-border;
border-radius: $base-border-radius;
font-family: $base-font-family;
padding: 0.5em;
transition: border-color $base-duration $base-timing;
width: 100%;
&:hover {
border-color: mix($black, $base-border-color, 20%);
}
&:focus {
border-color: $action-color;
outline: none;
}
&:disabled {
background-color: mix($black, $white, 5%);
cursor: not-allowed;
&:hover {
border: $base-border;
}
}
}
textarea {
resize: vertical;
}
[type="checkbox"],
[type="radio"] {
display: inline;
margin-right: $small-spacing / 2;
}
[type="file"] {
width: 100%;
}
select {
width: 100%;
}
[type="checkbox"],
[type="radio"],
[type="file"],
select {
&:focus {
outline: $focus-outline;
outline-offset: $focus-outline-offset;
}
}

View File

@@ -0,0 +1,22 @@
html {
background-color: $color-white;
box-sizing: border-box;
font-size: 16px;
-webkit-font-smoothing: antialiased;
}
*,
*::before,
*::after {
box-sizing: inherit;
}
figure {
margin: 0;
}
img,
picture {
margin: 0;
max-width: 100%;
}

View File

@@ -0,0 +1,19 @@
ul,
ol {
list-style-type: none;
margin: 0;
padding: 0;
}
dl {
margin-bottom: $small-spacing;
dt {
font-weight: $font-weight-medium;
margin-top: $small-spacing;
}
dd {
margin: 0;
}
}

View File

@@ -0,0 +1,71 @@
table {
border-collapse: collapse;
font-size: $font-size-default;
text-align: left;
width: 100%;
a {
color: inherit;
text-decoration: none;
}
}
tr {
border-bottom: $base-border;
th {
font-weight: $font-weight-medium;
&.cell-label--avatar-field {
a {
display: none;
}
}
}
}
tbody tr {
&:hover {
background-color: $base-background-color;
cursor: pointer;
}
&:focus {
outline: $focus-outline;
outline-offset: -($focus-outline-width);
}
td {
&.cell-data--avatar-field {
line-height: 1;
text-align: center;
img {
border-radius: 50%;
height: $space-large;
max-height: $space-large;
width: $space-large;
}
}
}
}
td,
th {
padding: $space-slab;
vertical-align: middle;
}
td:first-child,
th:first-child {
padding-left: 0;
}
td:last-child,
th:last-child {
padding-right: 0;
}
td img {
max-height: 2rem;
}

View File

@@ -0,0 +1,44 @@
body {
color: $base-font-color;
font-family: $base-font-family;
font-size: $base-font-size;
line-height: $base-line-height;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: $heading-font-family;
font-size: $base-font-size;
line-height: $heading-line-height;
margin: 0;
}
p {
margin: 0 0 $small-spacing;
}
a {
color: $action-color;
transition: color $base-duration $base-timing;
&:hover {
color: mix($black, $action-color, 25%);
}
&:focus {
outline: $focus-outline;
outline-offset: $focus-outline-offset;
}
}
hr {
border-bottom: $base-border;
border-left: 0;
border-right: 0;
border-top: 0;
margin: $base-spacing 0;
}

View File

@@ -0,0 +1,8 @@
.app-container {
align-items: stretch;
display: flex;
margin-left: auto;
margin-right: auto;
max-width: 100rem;
min-height: 100vh;
}

View File

@@ -0,0 +1,26 @@
.attribute-label {
@include data-label;
clear: left;
float: left;
margin-bottom: $base-spacing;
margin-top: 0.25em;
text-align: left;
width: calc(16% - 1rem);
}
.preserve-whitespace {
white-space: pre-wrap;
word-wrap: break-word;
}
.attribute-data {
float: left;
margin-bottom: $base-spacing;
margin-left: 1.25rem;
width: calc(84% - 0.625rem);
}
.attribute--nested {
border: $base-border;
padding: $small-spacing;
}

View File

@@ -0,0 +1,50 @@
button:not(.reset-base),
input[type='button']:not(.reset-base),
input[type='reset']:not(.reset-base),
input[type='submit']:not(.reset-base),
.button:not(.reset-base) {
appearance: none;
background-color: $color-woot;
border: 0;
border-radius: $base-border-radius;
color: $white;
cursor: pointer;
display: inline-flex;
font-size: $font-size-small;
-webkit-font-smoothing: antialiased;
font-weight: $font-weight-medium;
line-height: 1;
padding: $space-one $space-two;
text-decoration: none;
transition: background-color $base-duration $base-timing;
user-select: none;
vertical-align: middle;
white-space: nowrap;
&:hover {
background-color: mix($black, $color-woot, 20%);
color: $white;
}
&:focus {
outline: $focus-outline;
outline-offset: $focus-outline-offset;
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
&:hover {
background-color: $color-woot;
}
}
}
.button--alt {
background-color: transparent;
border: $base-border;
border-color: $blue;
color: $blue;
margin-bottom: $base-spacing;
}

View File

@@ -0,0 +1,70 @@
.cell-label {
&:hover {
a {
color: $action-color;
}
svg {
fill: $action-color;
transform: rotate(180deg);
}
}
a {
color: inherit;
display: inline-block;
transition: color $base-duration $base-timing;
width: 100%;
}
}
.cell-label--asc,
.cell-label--desc {
font-weight: $font-weight-medium;
}
.cell-label__sort-indicator {
float: right;
margin-left: 5px;
svg {
fill: $hint-grey;
height: 13px;
transition: transform $base-duration $base-timing;
width: 13px;
}
}
.cell-label__sort-indicator--desc {
transform: rotate(180deg);
}
.cell-data--number,
.cell-label--number {
text-align: right;
}
.cell-data__secret-field {
align-items: center;
color: $hint-grey;
display: flex;
span {
flex: 0 0 auto;
}
[data-secret-toggler],
[data-secret-copier] {
background: transparent;
border: 0;
color: inherit;
margin-left: 0.5rem;
padding: 0;
svg {
fill: currentColor;
height: 1.25rem;
width: 1.25rem;
}
}
}

View File

@@ -0,0 +1,54 @@
.field-unit {
@include administrate-clearfix;
align-items: center;
display: flex;
margin-bottom: $base-spacing;
position: relative;
width: 100%;
}
.field-unit__label {
float: left;
margin-left: 0.625rem;
text-align: right;
width: calc(15% - 0.625rem);
}
.field-unit__field {
float: left;
margin-left: 1.25rem;
max-width: 31.15rem;
width: 100%;
}
.field-unit--nested {
border: $base-border;
margin-left: 7.5%;
max-width: 37.5rem;
padding: $small-spacing;
width: 100%;
.field-unit__field {
width: 100%;
}
.field-unit__label {
width: 10rem;
}
}
.field-unit--required {
label::after {
color: $red;
content: ' *';
}
}
.attribute-data--avatar-field {
height: $space-larger;
width: $space-larger;
img {
border-radius: 50%;
}
}

View File

@@ -0,0 +1,28 @@
$base-spacing: 1.5em !default;
$flashes: (
"alert": #fff6bf,
"error": #fbe3e4,
"notice": #e5edf8,
"success": #e6efc2,
) !default;
@each $flash-type, $color in $flashes {
.flash-#{$flash-type} {
background-color: $color;
color: mix($black, $color, 60%);
display: block;
margin-bottom: $base-spacing / 2;
padding: $base-spacing / 2;
text-align: center;
a {
color: mix($black, $color, 70%);
text-decoration: underline;
&:focus,
&:hover {
color: mix($black, $color, 90%);
}
}
}
}

View File

@@ -0,0 +1,3 @@
.form-actions {
margin-left: calc(15% + 1.25rem);
}

View File

@@ -0,0 +1,35 @@
.main-content {
font-size: $font-size-default;
left: 21rem;
position: absolute;
right: 0;
top: 0;
}
.main-content__body {
font-size: $font-size-small;
padding: $space-two;
table {
font-size: $font-size-small;
}
form {
margin-top: $space-two;
}
}
.main-content__header {
align-items: center;
background-color: $color-white;
border-bottom: 1px solid $color-border;
display: flex;
min-height: 3.5rem;
padding: $space-small $space-normal;
}
.main-content__page-title {
font-size: $font-size-medium;
font-weight: $font-weight-medium;
margin-right: auto;
}

View File

@@ -0,0 +1,19 @@
.pagination {
font-size: $font-size-default;
margin-top: $base-spacing;
padding-left: $base-spacing;
padding-right: $base-spacing;
text-align: center;
.first,
.prev,
.page,
.next,
.last {
margin: $small-spacing;
}
.current {
font-weight: $font-weight-medium;
}
}

View File

@@ -0,0 +1,15 @@
.report--list {
display: flex;
padding: 0 $space-two $space-larger;
}
.report-card {
flex: 1;
font-size: $font-size-small;
text-align: center;
.metric {
font-size: $font-size-bigger;
font-weight: 200;
}
}

View File

@@ -0,0 +1,44 @@
.search {
margin-left: auto;
margin-right: 1.25rem;
max-width: 27.5rem;
position: relative;
width: 100%;
}
.search__input {
background: $grey-1;
padding-left: $space-normal * 2.5;
padding-right: $space-normal * 2.5;
}
.search__eyeglass-icon {
fill: $grey-7;
height: $space-normal;
left: $space-normal;
position: absolute;
top: 50%;
transform: translateY(-50%);
width: $space-normal;
}
.search__clear-link {
height: $space-normal;
position: absolute;
right: $space-normal * 0.75;
top: 50%;
transform: translateY(-50%);
width: $space-normal;
}
.search__clear-icon {
fill: $grey-5;
height: $space-normal;
position: absolute;
transition: fill $base-duration $base-timing;
width: $space-normal;
&:hover {
fill: $action-color;
}
}

View File

@@ -0,0 +1,23 @@
// custom styles for the dashboard
.feature-cell {
background: $color-extra-light-blue;
border-radius: 10px;
float: left;
margin-left: 8px;
margin-top: 6px;
padding: 4px 12px;
.icon-container {
margin-right: 2px;
}
.value-container {
margin-left: 6px;
}
}
.feature-container {
max-width: 100rem;
}

View File

@@ -0,0 +1,7 @@
@mixin administrate-clearfix {
&::after {
clear: both;
content: '';
display: block;
}
}

View File

@@ -0,0 +1,8 @@
@mixin data-label {
color: $hint-grey;
font-size: 0.8em;
font-weight: 400;
letter-spacing: 0.0357em;
position: relative;
text-transform: uppercase;
}

View File

@@ -0,0 +1,61 @@
// Typography
$base-font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif !default;
$heading-font-family: $base-font-family !default;
$base-font-size: 16px !default;
$base-line-height: 1.5 !default;
$heading-line-height: 1.2 !default;
// Other Sizes
$base-border-radius: 4px !default;
$base-spacing: $base-line-height * 1em !default;
$small-spacing: $base-spacing / 2 !default;
// Colors
$white: #fff !default;
$black: #000 !default;
$blue: #1f93ff !default;
$red: #ff382d !default;
$light-yellow: #ffc532 !default;
$light-green: #44ce4b !default;
$grey-0: #f6f7f7 !default;
$grey-1: #f0f4f5 !default;
$grey-2: #cfd8dc !default;
$grey-5: #adb5bd !default;
$grey-7: #293f54 !default;
$hint-grey: #7b808c !default;
// Font Colors
$base-font-color: $grey-7 !default;
$action-color: $blue !default;
// Background Colors
$base-background-color: $grey-0 !default;
// Focus
$focus-outline-color: transparentize($action-color, 0.4);
$focus-outline-width: 3px;
$focus-outline: $focus-outline-width solid $focus-outline-color;
$focus-outline-offset: 1px;
// Flash Colors
$flash-colors: (
alert: $light-yellow,
error: $red,
notice: mix($white, $blue, 50%),
success: $light-green
);
// Border
$base-border-color: $grey-1 !default;
$base-border: 1px solid $base-border-color !default;
// Transitions
$base-duration: 250ms !default;
$base-timing: ease-in-out !default;

View File

@@ -0,0 +1,448 @@
/*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */
/* Document
========================================================================== */
/**
* 1. Correct the line height in all browsers.
* 2. Prevent adjustments of font size after orientation changes in
* IE on Windows Phone and in iOS.
*/
html {
line-height: 1.15; /* 1 */
-ms-text-size-adjust: 100%; /* 2 */
-webkit-text-size-adjust: 100%; /* 2 */
-moz-osx-font-smoothing: grayscale;
}
/* Sections
========================================================================== */
/**
* Remove the margin in all browsers (opinionated).
*/
body {
margin: 0;
}
/**
* Add the correct display in IE 9-.
*/
article,
aside,
footer,
header,
nav,
section {
display: block;
}
/**
* Correct the font size and margin on `h1` elements within `section` and
* `article` contexts in Chrome, Firefox, and Safari.
*/
h1 {
font-size: 2em;
margin: 0.67em 0;
}
/* Grouping content
========================================================================== */
/**
* Add the correct display in IE 9-.
* 1. Add the correct display in IE.
*/
figcaption,
figure,
main { /* 1 */
display: block;
}
/**
* Add the correct margin in IE 8.
*/
figure {
margin: 1em 40px;
}
/**
* 1. Add the correct box sizing in Firefox.
* 2. Show the overflow in Edge and IE.
*/
hr {
box-sizing: content-box; /* 1 */
height: 0; /* 1 */
overflow: visible; /* 2 */
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
pre {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/* Text-level semantics
========================================================================== */
/**
* 1. Remove the gray background on active links in IE 10.
* 2. Remove gaps in links underline in iOS 8+ and Safari 8+.
*/
a {
background-color: transparent; /* 1 */
-webkit-text-decoration-skip: objects; /* 2 */
}
/**
* 1. Remove the bottom border in Chrome 57- and Firefox 39-.
* 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
*/
abbr[title] {
border-bottom: none; /* 1 */
text-decoration: underline; /* 2 */
text-decoration: underline dotted; /* 2 */
}
/**
* Prevent the duplicate application of `bolder` by the next rule in Safari 6.
*/
b,
strong {
font-weight: inherit;
}
/**
* Add the correct font weight in Chrome, Edge, and Safari.
*/
b,
strong {
font-weight: bolder;
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/**
* Add the correct font style in Android 4.3-.
*/
dfn {
font-style: italic;
}
/**
* Add the correct background and color in IE 9-.
*/
mark {
background-color: #ff0;
color: #000;
}
/**
* Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/**
* Prevent `sub` and `sup` elements from affecting the line height in
* all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/* Embedded content
========================================================================== */
/**
* Add the correct display in IE 9-.
*/
audio,
video {
display: inline-block;
}
/**
* Add the correct display in iOS 4-7.
*/
audio:not([controls]) {
display: none;
height: 0;
}
/**
* Remove the border on images inside links in IE 10-.
*/
img {
border-style: none;
}
/**
* Hide the overflow in IE.
*/
svg:not(:root) {
overflow: hidden;
}
/* Forms
========================================================================== */
/**
* 1. Change the font styles in all browsers (opinionated).
* 2. Remove the margin in Firefox and Safari.
*/
button,
input,
optgroup,
select,
textarea {
font-family: sans-serif; /* 1 */
font-size: 100%; /* 1 */
line-height: 1.15; /* 1 */
margin: 0; /* 2 */
}
/**
* Show the overflow in IE.
* 1. Show the overflow in Edge.
*/
button,
input { /* 1 */
overflow: visible;
}
/**
* Remove the inheritance of text transform in Edge, Firefox, and IE.
* 1. Remove the inheritance of text transform in Firefox.
*/
button,
select { /* 1 */
text-transform: none;
}
/**
* 1. Prevent a WebKit bug where (2) destroys native `audio` and `video`
* controls in Android 4.
* 2. Correct the inability to style clickable types in iOS and Safari.
*/
button,
html [type="button"], /* 1 */
[type="reset"],
[type="submit"] {
-webkit-appearance: button; /* 2 */
}
/**
* Remove the inner border and padding in Firefox.
*/
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
border-style: none;
padding: 0;
}
/**
* Restore the focus styles unset by the previous rule.
*/
button:-moz-focusring,
[type="button"]:-moz-focusring,
[type="reset"]:-moz-focusring,
[type="submit"]:-moz-focusring {
outline: 1px dotted ButtonText;
}
/**
* Correct the padding in Firefox.
*/
fieldset {
padding: 0.35em 0.75em 0.625em;
}
/**
* 1. Correct the text wrapping in Edge and IE.
* 2. Correct the color inheritance from `fieldset` elements in IE.
* 3. Remove the padding so developers are not caught out when they zero out
* `fieldset` elements in all browsers.
*/
legend {
box-sizing: border-box; /* 1 */
color: inherit; /* 2 */
display: table; /* 1 */
max-width: 100%; /* 1 */
padding: 0; /* 3 */
white-space: normal; /* 1 */
}
/**
* 1. Add the correct display in IE 9-.
* 2. Add the correct vertical alignment in Chrome, Firefox, and Opera.
*/
progress {
display: inline-block; /* 1 */
vertical-align: baseline; /* 2 */
}
/**
* Remove the default vertical scrollbar in IE.
*/
textarea {
overflow: auto;
}
/**
* 1. Add the correct box sizing in IE 10-.
* 2. Remove the padding in IE 10-.
*/
[type="checkbox"],
[type="radio"] {
box-sizing: border-box; /* 1 */
padding: 0; /* 2 */
}
/**
* Correct the cursor style of increment and decrement buttons in Chrome.
*/
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
height: auto;
}
/**
* 1. Correct the odd appearance in Chrome and Safari.
* 2. Correct the outline style in Safari.
*/
[type="search"] {
-webkit-appearance: textfield; /* 1 */
outline-offset: -2px; /* 2 */
}
/**
* Remove the inner padding and cancel buttons in Chrome and Safari on macOS.
*/
[type="search"]::-webkit-search-cancel-button,
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
* 1. Correct the inability to style clickable types in iOS and Safari.
* 2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button; /* 1 */
font: inherit; /* 2 */
}
/* Interactive
========================================================================== */
/*
* Add the correct display in IE 9-.
* 1. Add the correct display in Edge, IE, and Firefox.
*/
details, /* 1 */
menu {
display: block;
}
/*
* Add the correct display in all browsers.
*/
summary {
display: list-item;
}
/* Scripting
========================================================================== */
/**
* Add the correct display in IE 9-.
*/
canvas {
display: inline-block;
}
/**
* Add the correct display in IE.
*/
template {
display: none;
}
/* Hidden
========================================================================== */
/**
* Add the correct display in IE 10-.
*/
[hidden] {
display: none;
}

View File

@@ -0,0 +1,3 @@
.text-color-red {
color: $alert-color;
}

View File

@@ -0,0 +1,90 @@
// Font sizes
$font-size-nano: 0.5rem;
$font-size-micro: 0.675rem;
$font-size-mini: 0.75rem;
$font-size-small: 0.875rem;
$font-size-default: 1rem;
$font-size-medium: 1.125rem;
$font-size-large: 1.375rem;
$font-size-big: 1.5rem;
$font-size-bigger: 1.75rem;
$font-size-mega: 2.125rem;
$font-size-giga: 2.5rem;
// spaces
$zero: 0;
$space-micro: 0.125rem;
$space-smaller: 0.25rem;
$space-small: 0.5rem;
$space-one: 0.675rem;
$space-slab: 0.75rem;
$space-normal: 1rem;
$space-two: 1.25rem;
$space-medium: 1.5rem;
$space-large: 2rem;
$space-larger: 3rem;
$space-jumbo: 4rem;
$space-mega: 6.25rem;
// font-weight
$font-weight-feather: 100;
$font-weight-light: 300;
$font-weight-normal: 400;
$font-weight-medium: 500;
$font-weight-bold: 600;
$font-weight-black: 700;
//Navbar
$nav-bar-width: 23rem;
$header-height: 5.6rem;
$woot-logo-padding: $space-large $space-two;
// Colors
$color-woot: #1f93ff;
$color-gray: #6e6f73;
$color-light-gray: #747677;
$color-border: #e0e6ed;
$color-border-light: #f0f4f5;
$color-background: #f4f6fb;
$color-border-dark: #cad0d4;
$color-background-light: #f9fafc;
$color-white: #fff;
$color-body: #3c4858;
$color-heading: #1f2d3d;
$color-extra-light-blue: #f5f7f9;
$primary-color: $color-woot;
$secondary-color: #5d7592;
$success-color: #44ce4b;
$warning-color: #ffc532;
$alert-color: #ff382d;
$masked-bg: rgba(0, 0, 0, .4);
// Color-palettes
$color-primary-light: #c7e3ff;
$color-primary-dark: darken($color-woot, 20%);
// Thumbnail
$thumbnail-radius: 4rem;
// chat-header
$conv-header-height: 4rem;
// Inbox List
$inbox-thumb-size: 4.8rem;
// Snackbar default
$woot-snackbar-bg: #323232;
$woot-snackbar-button: #ffeb3b;
$swift-ease-out-duration: .4s !default;
$swift-ease-out-timing-function: cubic-bezier(.25, .8, .25, 1) !default;
$swift-ease-out: all $swift-ease-out-duration $swift-ease-out-timing-function !default;
// Transitions
$transition-ease-in: all 0.250s ease-in;

View File

@@ -70,17 +70,12 @@ class AccountBuilder
end
def create_user
@user = build_user
@user.save!
end
def build_user
@user = User.new(email: @email,
password: user_password,
password_confirmation: user_password,
name: user_full_name)
@user.type = 'SuperAdmin' if @super_admin
@user.confirm if @confirmed
@user
@user.save!
end
end

View File

@@ -1,6 +1,4 @@
class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseController
before_action :validate_assignment_targets, only: :create, if: -> { normalized_type == 'Conversation' }
def create
case normalized_type
when 'Conversation'
@@ -21,19 +19,6 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll
params[:type].to_s.camelize
end
def validate_assignment_targets
fields = conversation_params[:fields] || {}
assignee_id = fields[:assignee_id]
return render_could_not_create_error('Invalid assignee_id') if !assignee_id.nil? && !@current_account.users.exists?(id: assignee_id)
team_id = fields[:team_id]
# The dashboard sends 0 to remove the team assignment; accept '0' from form-encoded requests too.
return if [nil, 0, '0'].include?(team_id) || @current_account.teams.exists?(id: team_id)
render_could_not_create_error('Invalid team_id')
end
def enqueue_conversation_job
::BulkActionsJob.perform_later(
account: @current_account,

View File

@@ -31,6 +31,15 @@ class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseContr
render_result(result)
end
def label_suggestion
result = Captain::LabelSuggestionService.new(
account: Current.account,
conversation_display_id: params[:conversation_display_id]
).perform
render_result(result)
end
def follow_up
result = Captain::FollowUpService.new(
account: Current.account,

View File

@@ -1,5 +1,5 @@
class Api::V1::Accounts::Companies::ContactsController < Api::V1::Accounts::Companies::BaseController
RESULTS_PER_PAGE = 25
RESULTS_PER_PAGE = 15
CONTACT_SEARCH_QUERY = [
'contacts.name ILIKE :search',
'contacts.email ILIKE :search',
@@ -13,9 +13,7 @@ class Api::V1::Accounts::Companies::ContactsController < Api::V1::Accounts::Comp
before_action :fetch_contact, only: [:destroy]
def index
contacts = @company.contacts.order(:name, :id)
contacts = contacts.where(CONTACT_SEARCH_QUERY, search: "%#{params[:q].strip}%") if params[:q].present?
@contacts = fetch_contacts(contacts)
@contacts = fetch_contacts(@company.contacts.order(:name, :id))
@contacts_count = @contacts.total_count
end

View File

@@ -1,38 +1,15 @@
class Api::V1::Accounts::Companies::ConversationsController < Api::V1::Accounts::Companies::BaseController
before_action :authorize_company_read!
before_action :set_counts
def index
@conversations = Conversations::SortService.apply(company_conversations, params[:sort_by])
.includes(:assignee, :contact, :inbox, :taggings)
.preload(ai_assignee: { avatar_attachment: [:blob] })
.page(params[:page] || 1)
end
conversations = Current.account.conversations.includes(
:assignee, :contact, :inbox, :taggings
).preload(ai_assignee: { avatar_attachment: [:blob] }).where(contact_id: @company.contacts.select(:id))
def filter
@conversations = Companies::ConversationFilterService.new(
params.permit!, Current.user, Current.account, company: @company
).perform[:conversations]
render :index
rescue CustomExceptions::CustomFilter::InvalidAttribute,
CustomExceptions::CustomFilter::InvalidOperator,
CustomExceptions::CustomFilter::InvalidQueryOperator,
CustomExceptions::CustomFilter::InvalidValue => e
render_could_not_create_error(e.message)
end
private
def company_conversations
@company_conversations ||= Conversations::PermissionFilterService.new(
Current.account.conversations.where(contact_id: @company.contacts.select(:id)),
@conversations = Conversations::PermissionFilterService.new(
conversations,
Current.user,
Current.account
).perform
end
def set_counts
@all_count = company_conversations.count
@open_count = company_conversations.open.count
).perform.order(last_activity_at: :desc).limit(20)
end
end

View File

@@ -1,14 +1,11 @@
class Api::V1::Accounts::Companies::NotesController < Api::V1::Accounts::Companies::BaseController
RESULTS_PER_PAGE = 20
before_action :authorize_company_read!
def index
notes = Current.account.notes.where(contact_id: @company.contacts.select(:id))
notes = notes.where('notes.content ILIKE ?', "%#{Note.sanitize_sql_like(params[:q].strip)}%") if params[:q].present?
@notes = notes.latest
.includes(:contact, :user)
.page(params[:page] || 1)
.per(RESULTS_PER_PAGE)
@notes = Current.account.notes
.where(contact_id: @company.contacts.select(:id))
.latest
.includes(:contact, :user)
.limit(20)
end
end

View File

@@ -6,13 +6,12 @@ class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::BaseController
sort_on :last_activity_at, internal_name: :order_on_last_activity_at, type: :scope, scope_params: [:direction]
sort_on :contacts_count, internal_name: :order_on_contacts_count, type: :scope, scope_params: [:direction]
RESULTS_PER_PAGE = 50
RESULTS_PER_PAGE = 25
before_action :ensure_companies_enabled!
before_action :check_authorization
before_action :set_current_page, only: [:index, :search]
before_action :fetch_company, only: [:show, :update, :destroy, :avatar, :destroy_custom_attributes]
before_action :validate_social_profiles, only: [:create, :update]
def index
@companies = fetch_companies(resolved_companies)
@@ -84,14 +83,6 @@ class Api::V1::Accounts::CompaniesController < Api::V1::Accounts::BaseController
@company = Current.account.companies.find(params[:id])
end
def validate_social_profiles
social_profiles = params.dig(:company, :additional_attributes, :social_profiles)
return unless social_profiles.is_a?(ActionController::Parameters)
return if social_profiles.values.all? { |url| url.to_s.match?(%r{\Ahttps?://}i) }
render json: { error: I18n.t('errors.companies.social_profiles.invalid') }, status: :unprocessable_entity
end
def company_params
params.require(:company).permit(
:name,

View File

@@ -3,6 +3,11 @@ class Api::V1::Accounts::Conversations::SuggestionsController < Api::V1::Account
before_action :ensure_feature_enabled
rescue_from CustomExceptions::JevQuotaError do |error|
response.set_header('Retry-After', error.retry_after.to_s)
render json: { error: error.code, retry_after: error.retry_after }, status: :too_many_requests
end
def labels
render json: { labels: classifier.labels }
end

View File

@@ -3,10 +3,9 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
include DateRangeHelper
include HmacConcern
include ConversationCustomAttributesConcern
include ConversationAssignmentTargetsConcern
before_action :conversation, except: [:index, :meta, :search, :create, :filter]
before_action :validate_assignment_targets, :inbox, :contact, :contact_inbox, only: [:create]
before_action :inbox, :contact, :contact_inbox, only: [:create]
ATTACHMENT_RESULTS_PER_PAGE = 100

View File

@@ -10,17 +10,14 @@ class Api::V1::Accounts::Integrations::AppsController < Api::V1::Accounts::BaseC
private
def fetch_apps
@apps = Integrations::App.all.select do |app|
app.active?(Current.account) ||
(app.id == 'stripe' && Current.account_user&.administrator? && Current.account.hooks.exists?(app_id: 'stripe'))
end
@apps = Integrations::App.all.select { |app| app.active?(Current.account) }
end
def fetch_app
@app = Integrations::App.find(id: params[:id])
return unless %w[shopify stripe].include?(@app&.id)
return unless @app&.id == 'shopify'
# Keep direct lookups subject to the same availability checks as the integrations list.
# Keep direct Shopify lookups subject to the same availability checks as the integrations list.
raise ActiveRecord::RecordNotFound unless @app.active?(Current.account)
end
end

View File

@@ -2,7 +2,6 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Inte
before_action :fetch_hook, except: [:create]
before_action :check_authorization
before_action :ensure_shopify_enabled, if: :shopify_hook?
before_action :reject_stripe_hook
def create
@hook = Current.account.hooks.create!(permitted_params)
@@ -33,14 +32,6 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Inte
private
def reject_stripe_hook
app_id = action_name == 'create' ? permitted_params[:app_id] : @hook.app_id
return unless app_id == 'stripe'
# Stripe credentials and connection changes are managed by its OAuth and disconnect endpoints.
head :not_found
end
def fetch_hook
@hook = Current.account.hooks.find(params[:id])
end

View File

@@ -1,27 +1,25 @@
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Integrations::BaseController
include Shopify::IntegrationHelper
before_action :ensure_shopify_enabled
before_action -> { Shopify::ApiContext.setup! }, only: [:orders]
before_action :fetch_hook, except: [:auth, :complete_install]
before_action :check_authorization, only: [:auth, :complete_install, :destroy]
before_action :setup_shopify_context, only: [:orders]
before_action :fetch_hook, except: [:auth]
before_action :check_authorization, only: [:destroy]
before_action :validate_contact, only: [:orders]
def auth
shop_domain = params[:shop_domain]
unless shop_domain.is_a?(String) && Shopify::ShopDomain.valid?(shop_domain)
return render json: { error: 'Invalid Shopify shop domain' }, status: :unprocessable_entity
end
return render json: { error: 'Shop domain is required' }, status: :unprocessable_entity if shop_domain.blank?
state = generate_shopify_token(Current.account.id)
raise 'Shopify OAuth is not configured' if client_id.blank? || state.blank?
oauth_client = OAuth2::Client.new(client_id, client_secret,
site: "https://#{Shopify::ShopDomain.normalize(shop_domain)}",
authorize_url: '/admin/oauth/authorize')
render json: { redirect_url: oauth_client.auth_code.authorize_url(
redirect_uri: "#{ENV.fetch('FRONTEND_URL', '')}/shopify/callback",
scope: REQUIRED_SCOPES.join(','), state: state
) }
auth_url = "https://#{shop_domain}/admin/oauth/authorize?"
auth_url += URI.encode_www_form(
client_id: client_id,
scope: REQUIRED_SCOPES.join(','),
redirect_uri: redirect_uri,
state: state
)
render json: { redirect_url: auth_url }
end
def orders
@@ -34,25 +32,6 @@ class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::In
render json: { error: e.message }, status: :unprocessable_entity
end
def complete_install
pending_installation = Shopify::PendingInstallation.claim(
token: params[:pending_install_token]
)
Shopify::InstallationService.new(account: Current.account, pending_installation: pending_installation).perform
head :ok
rescue Shopify::PendingInstallation::AlreadyClaimed => e
pending_installation&.release!
render json: { error: e.message }, status: :conflict
rescue Shopify::PendingInstallation::CommitOutcomeUnknown
raise
rescue Shopify::PendingInstallation::Error => e
pending_installation&.release!
render json: { error: e.message }, status: :unprocessable_entity
rescue StandardError
pending_installation&.release!
raise
end
def destroy
@hook.destroy!
head :ok
@@ -62,8 +41,8 @@ class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::In
private
def ensure_shopify_enabled
head :not_found unless Shopify::FeatureGate.enabled?(account: Current.account)
def redirect_uri
"#{ENV.fetch('FRONTEND_URL', '')}/shopify/callback"
end
def contact
@@ -71,9 +50,7 @@ class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::In
end
def fetch_hook
hooks = Integrations::Hook.where(account: Current.account, app_id: 'shopify')
hooks = hooks.enabled if action_name == 'orders'
@hook = hooks.first!
@hook = Integrations::Hook.find_by!(account: Current.account, app_id: 'shopify')
end
def fetch_customers
@@ -105,15 +82,25 @@ class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::In
end
end
def setup_shopify_context
return if client_id.blank? || client_secret.blank?
ShopifyAPI::Context.setup(
api_key: client_id,
api_secret_key: client_secret,
api_version: '2025-01'.freeze,
scope: REQUIRED_SCOPES.join(','),
is_embedded: true,
is_private: false
)
end
def shopify_session
ShopifyAPI::Auth::Session.new(shop: @hook.reference_id, access_token: @hook.access_token)
end
def shopify_client
@shopify_client ||= ShopifyAPI::Clients::Rest::Admin.new(
session: shopify_session,
api_version: Shopify::ApiContext::API_VERSION
)
@shopify_client ||= ShopifyAPI::Clients::Rest::Admin.new(session: shopify_session)
end
def validate_contact

View File

@@ -1,58 +0,0 @@
class Api::V1::Accounts::Integrations::StripeController < Api::V1::Accounts::Integrations::BaseController
before_action :ensure_configured, only: [:auth, :customer]
before_action :check_admin_authorization?, only: [:show, :auth, :destroy]
def show
hook = Current.account.hooks.find_by!(app_id: 'stripe')
mode = Integrations::Stripe::Connection.new(hook).livemode? ? 'live' : 'sandbox'
render json: { account_id: hook.reference_id, connected_at: hook.settings.fetch('connected_at', hook.created_at), mode: mode,
reauthorization_required: hook.reauthorization_required? }
end
def auth
url = Integrations::Stripe::Oauth.authorize_url(account: Current.account, user: Current.user)
state = URI.decode_www_form(URI(url).query).to_h.fetch('state')
cookies.signed[:stripe_oauth_state] = { value: state, httponly: true, secure: request.ssl?, same_site: :lax,
expires: Integrations::Stripe::Oauth::STATE_TTL.from_now }
render json: { url: url }
end
def destroy
Current.account.hooks.find_by!(app_id: 'stripe').destroy!
head :no_content
end
def customer
conversation_id = params.require(:conversation_id)
unless conversation_id.is_a?(String) && conversation_id.match?(/\A[1-9]\d*\z/)
return render_could_not_create_error('conversation_id must be a positive integer')
end
conversation = Current.account.conversations.find_by!(display_id: conversation_id)
authorize conversation, :show?
hook = Current.account.hooks.find_by!(app_id: 'stripe', status: :enabled)
render json: Integrations::Stripe::CustomerSummary.new(connection: Integrations::Stripe::Connection.new(hook),
contact: conversation.contact).perform(customer_id: params[:customer_id])
rescue Integrations::Stripe::Connection::ReauthorizationRequired, ::Stripe::AuthenticationError
hook.prompt_reauthorization!
render json: { error: 'stripe_reauthorization_required' }, status: :unprocessable_entity
rescue OAuth2::Error => e
hook.prompt_reauthorization! if e.code == 'invalid_grant'
render json: { error: 'stripe_unavailable' }, status: :unprocessable_entity
rescue ::Stripe::StripeError, Faraday::TimeoutError, Faraday::ConnectionFailed => e
render_customer_failure(e)
end
private
def render_customer_failure(error)
details = { error: error.class.name }
details.merge!(code: error.code, status: error.http_status, request_id: error.request_id) if error.is_a?(::Stripe::StripeError)
Rails.logger.warn(details.compact.to_json)
render json: { error: 'stripe_unavailable' }, status: :unprocessable_entity
end
def ensure_configured
head :not_found unless Integrations::App.find(id: 'stripe').active?(Current.account)
end
end

View File

@@ -18,7 +18,6 @@ class Api::V1::AccountsController < Api::BaseController
CustomExceptions::Account::UserExists,
CustomExceptions::Account::UserErrors,
with: :render_error_response
rescue_from Shopify::PendingInstallation::Error, with: :render_shopify_signup_error
def show
@latest_chatwoot_version = latest_chatwoot_version
@@ -26,8 +25,14 @@ class Api::V1::AccountsController < Api::BaseController
end
def create
builder_class = account_params[:shopify_pending_install_token].present? ? Shopify::SignupService : AccountBuilder
@user, @account = builder_class.new(**account_builder_params).perform
@user, @account = AccountBuilder.new(
account_name: account_params[:account_name],
user_full_name: account_params[:user_full_name],
email: account_params[:email],
user_password: account_params[:password],
locale: account_params[:locale],
user: current_user
).perform
enqueue_branding_enrichment
if @user
# Authenticated users (dashboard "add account") and api_only signups
@@ -72,20 +77,6 @@ class Api::V1::AccountsController < Api::BaseController
Redis::Alfred.get(Redis::Alfred::LATEST_CHATWOOT_VERSION)
end
def account_builder_params
attributes = {
account_name: account_params[:account_name],
user_full_name: account_params[:user_full_name],
email: account_params[:email],
user_password: account_params[:password],
locale: account_params[:locale],
user: current_user
}
pending_install_token = account_params[:shopify_pending_install_token]
attributes[:shopify_pending_install_token] = pending_install_token if pending_install_token.present?
attributes
end
def enqueue_branding_enrichment
email = account_params[:email].presence || @user&.email
return if email.blank?
@@ -120,22 +111,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def account_params
params.permit(
:account_name,
:email,
:name,
:password,
:locale,
:domain,
:support_email,
:user_full_name,
:shopify_pending_install_token
)
end
def render_shopify_signup_error(exception)
log_handled_error(exception)
render json: { message: exception.message }, status: :unprocessable_entity
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :user_full_name)
end
def custom_attributes_params
@@ -153,14 +129,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def check_signup_enabled
return if GlobalConfigService.account_signup_enabled? || pending_shopify_signup?
raise ActionController::RoutingError, 'Not Found'
end
def pending_shopify_signup?
token = account_params[:shopify_pending_install_token]
Shopify::FeatureGate.enabled? && Shopify::PendingInstallation.pending?(token: token)
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
end
def api_only_signup?

View File

@@ -1,4 +1,6 @@
class Api::V1::Integrations::WebhooksController < ApplicationController
SIGNATURE_TOLERANCE = 5.minutes.to_i
prepend_before_action :verify_slack_signature!, only: [:create]
def create
@@ -11,12 +13,31 @@ class Api::V1::Integrations::WebhooksController < ApplicationController
# Skip (rather than reject) when no secret is configured, so existing installs keep working.
def verify_slack_signature!
if Integrations::Slack::SignatureVerifier.signing_secret.blank?
secret = slack_signing_secret
if secret.blank?
Rails.logger.warn('[SLACK] SLACK_SIGNING_SECRET not configured; skipping webhook signature verification')
return
end
head :unauthorized unless Integrations::Slack::SignatureVerifier.valid?(request)
head :unauthorized unless valid_slack_signature?(secret)
end
# Config reconciliation creates a blank row for this key on upgrade, and that blank row
# makes GlobalConfigService skip its ENV fallback, so read ENV directly as a last resort.
def slack_signing_secret
GlobalConfigService.load('SLACK_SIGNING_SECRET', nil).presence || ENV.fetch('SLACK_SIGNING_SECRET', nil)
end
def valid_slack_signature?(secret)
timestamp = request.headers['X-Slack-Request-Timestamp']
signature = request.headers['X-Slack-Signature']
return false if timestamp.blank? || signature.blank?
return false if (Time.current.to_i - timestamp.to_i).abs > SIGNATURE_TOLERANCE
# Build over raw bytes so a payload with invalid UTF-8 can't raise on interpolation.
basestring = "v0:#{timestamp}:".b << request.raw_post
expected = "v0=#{OpenSSL::HMAC.hexdigest('SHA256', secret, basestring)}"
ActiveSupport::SecurityUtils.secure_compare(expected, signature)
end
# TODO: This is a temporary solution to permit all params for slack unfurling job.

View File

@@ -5,13 +5,6 @@
# Inherits from ActionController::API to avoid both issues entirely.
# Rate-limited by Rack::Attack (IP + email) and gated by hCaptcha.
class Auth::ResendConfirmationsController < ActionController::API
SHOPIFY_INSTALL_REDIRECT_PATTERN = %r{\Asettings/integrations/shopify\?shopify_pending_install=[0-9a-f]{32}\z}
SHOPIFY_BILLING_PARAMETERS = {
'shop' => Shopify::ShopDomain::FORMAT,
'plan_handle' => /\A[a-zA-Z0-9_-]+\z/
}.freeze
def create
return head(:ok) unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid?
@@ -19,36 +12,7 @@ class Auth::ResendConfirmationsController < ActionController::API
return head(:ok) unless email.is_a?(String)
user = User.from_email(email.strip.downcase)
send_confirmation_instructions(user) if user && !user.confirmed?
user&.send_confirmation_instructions unless user&.confirmed?
head :ok
end
private
def confirmation_redirect_url
redirect_url = params[:redirect_url].to_s
redirect_url if redirect_url.match?(SHOPIFY_INSTALL_REDIRECT_PATTERN) || shopify_billing_redirect?(redirect_url)
end
def shopify_billing_redirect?(redirect_url)
path, query = redirect_url.split('?', 2)
return false unless path == 'settings/billing' && query.present?
valid_shopify_billing_parameters?(URI.decode_www_form(query))
rescue ArgumentError
false
end
def valid_shopify_billing_parameters?(pairs)
return false if pairs.empty?
return false unless pairs.map(&:first).uniq.size == pairs.size
pairs.all? { |key, value| SHOPIFY_BILLING_PARAMETERS[key]&.match?(value) }
end
def send_confirmation_instructions(user)
return user.send_confirmation_instructions unless confirmation_redirect_url
user.send_confirmation_instructions_with_redirect(redirect_url: confirmation_redirect_url)
end
end

View File

@@ -1,13 +0,0 @@
module ConversationAssignmentTargetsConcern
private
def validate_assignment_targets
assignee_id = params[:assignee_id]
return render_could_not_create_error('Invalid assignee_id') if assignee_id.present? && !Current.account.users.exists?(id: assignee_id)
team_id = params[:team_id]
return if team_id.blank? || Current.account.teams.exists?(id: team_id)
render_could_not_create_error('Invalid team_id')
end
end

View File

@@ -28,7 +28,6 @@ class DashboardController < ActionController::Base
DEPLOYMENT_ENV
INSTALLATION_PRICING_PLAN
CAPTAIN_TOOLS_MANIFEST_ENABLED
CAPTAIN_TOOLS_CATALOG_URL
].freeze
before_action :set_application_pack
@@ -85,7 +84,6 @@ class DashboardController < ActionController::Base
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
IS_ENTERPRISE: ChatwootApp.enterprise?,
IS_COMPANY_ENRICHMENT_ENABLED: ChatwootApp.enterprise? && GlobalConfigService.load('CONTEXT_DEV_API_KEY', nil).present?,
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
GIT_SHA: GIT_HASH,
ALLOWED_LOGIN_METHODS: allowed_login_methods,

View File

@@ -1,17 +1,15 @@
class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCallbacksController
include EmailHelper
SHOPIFY_INSTALL_REDIRECT_PATTERN = %r{\Asettings/integrations/shopify\?shopify_pending_install=([0-9a-f]{32})\z}
def omniauth_success
get_resource_from_auth_hash
@resource.present? ? sign_in_user(redirect_url: oauth_redirect_url, sso_account_id: oauth_context['sso_account_id']) : sign_up_user
@resource.present? ? sign_in_user : sign_up_user
end
private
def sign_in_user(redirect_url: nil, sso_account_id: nil)
def sign_in_user
# Capture before skip_confirmation! sets confirmed_at, which would
# make oauth_user_needs_password_reset? return false and skip the
# password reset for persisted unconfirmed users.
@@ -23,12 +21,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
# we can just send them to the login page again with the SSO params
# that will log them in
encoded_email = ERB::Util.url_encode(@resource.email)
redirect_to login_page_url(
email: encoded_email,
sso_auth_token: @resource.generate_sso_auth_token,
redirect_url: redirect_url,
sso_account_id: sso_account_id
)
redirect_to login_page_url(email: encoded_email, sso_auth_token: @resource.generate_sso_auth_token)
end
def sign_in_user_on_mobile
@@ -49,25 +42,18 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
def sign_up_user
return redirect_to login_page_url(error: 'no-account-found') unless account_signup_allowed?
unless validate_signup_email_is_business_domain?
return redirect_to login_page_url(
error: 'business-account-only', redirect_url: oauth_redirect_url, sso_account_id: oauth_context['sso_account_id']
)
end
return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain?
create_account_for_user
set_random_password_if_oauth_user
token = @resource.send(:set_reset_password_token)
frontend_url = ENV.fetch('FRONTEND_URL', nil)
redirect_to "#{frontend_url}/app/auth/password/edit?config=default&reset_password_token=#{token}"
rescue Shopify::PendingInstallation::Error
redirect_to login_page_url(error: 'shopify-installation-failed')
end
def login_page_url(error: nil, email: nil, sso_auth_token: nil, redirect_url: nil, sso_account_id: nil)
def login_page_url(error: nil, email: nil, sso_auth_token: nil)
frontend_url = omniauth_frontend_url
params = { email: email, sso_auth_token: sso_auth_token, redirect_url: redirect_url, sso_account_id: sso_account_id }.compact
params = { email: email, sso_auth_token: sso_auth_token }.compact
params[:error] = error if error.present?
"#{frontend_url}/app/login?#{params.to_query}"
@@ -77,45 +63,8 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
ENV.fetch('FRONTEND_URL', nil)
end
def oauth_redirect_url
oauth_context['redirect_url']
end
def oauth_context
@oauth_context ||= parse_google_oauth_context(params[:state].to_s)
end
def get_redirect_route(devise_mapping)
route = super
return route unless params[:provider] == 'google_oauth2' && oauth_context.present?
# Keep the continuation on this callback's redirect instead of sharing it across tabs in the session.
"#{route}?#{URI.encode_www_form(state: oauth_context.to_json)}"
end
def parse_google_oauth_context(state)
context = state.start_with?('{') ? JSON.parse(state) : { 'redirect_url' => state }
redirect_url = context['redirect_url']
context.delete('redirect_url') unless redirect_url.is_a?(String) && allowed_google_oauth_redirect?(redirect_url)
account_id = context['sso_account_id']
context.delete('sso_account_id') unless account_id.is_a?(String) && account_id.match?(/\A[1-9]\d*\z/)
context.slice('redirect_url', 'sso_account_id')
rescue JSON::ParserError
{}
end
def allowed_google_oauth_redirect?(redirect_url)
redirect_url == 'settings/integrations/shopify' || redirect_url.match?(SHOPIFY_INSTALL_REDIRECT_PATTERN)
end
def account_signup_allowed?
return true if GlobalConfigService.account_signup_enabled?
Shopify::FeatureGate.enabled? && Shopify::PendingInstallation.pending?(token: shopify_pending_install_token)
end
def shopify_pending_install_token
@shopify_pending_install_token ||= oauth_redirect_url&.match(SHOPIFY_INSTALL_REDIRECT_PATTERN)&.captures&.first
GlobalConfigService.account_signup_enabled?
end
def resource_class(_mapping = nil)
@@ -135,18 +84,13 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
end
def create_account_for_user
attributes = {
@resource, @account = AccountBuilder.new(
account_name: extract_domain_without_tld(auth_hash['info']['email']),
user_full_name: auth_hash['info']['name'],
email: auth_hash['info']['email'],
locale: I18n.locale,
confirmed: auth_hash['info']['email_verified']
}
pending_install_token = shopify_pending_install_token
attributes[:shopify_pending_install_token] = pending_install_token if pending_install_token
builder_class = pending_install_token.present? ? Shopify::SignupService : AccountBuilder
@resource, @account = builder_class.new(**attributes).perform
).perform
Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image'])
end

View File

@@ -6,12 +6,8 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
skip_before_action :authenticate_user!, raise: false
def create
unless params[:redirect_url].nil? || params[:redirect_url].is_a?(String)
return render json: { error: 'Invalid redirect_url' }, status: :unprocessable_entity
end
@user = User.from_email(params[:email])
@user&.send_reset_password_instructions(redirect_url: params[:redirect_url], sso_account_id: params.permit(:sso_account_id)[:sso_account_id])
@user&.send_reset_password_instructions
build_response(I18n.t('messages.reset_password'), 200)
end

View File

@@ -2,157 +2,45 @@ class Shopify::CallbacksController < ApplicationController
include Shopify::IntegrationHelper
def show
@account_id = verified_account_id
raise StandardError, 'Shopify authorization was denied' if params[:error].present?
verify_account!
if chatwoot_initiated?
handle_chatwoot_initiated_flow
elsif params[:code].blank?
handle_shopify_initiated_without_code
else
handle_shopify_initiated_flow
end
@response = oauth_client.auth_code.get_token(
params[:code],
redirect_uri: '/shopify/callback'
)
handle_response
rescue StandardError => e
Rails.logger.error("Shopify callback error: #{e.message}")
redirect_to error_redirect_url, allow_other_host: true
redirect_to "#{redirect_uri}?error=true"
end
private
def chatwoot_initiated? = verified_account_id.present?
def handle_chatwoot_initiated_flow
def verify_account!
@account_id = verify_shopify_token(params[:state])
raise StandardError, 'Invalid state parameter' if account.blank?
ensure_shopify_enabled!(account: account)
raise StandardError, 'Invalid HMAC signature' unless valid_hmac?
raise StandardError, 'Invalid shop domain' unless valid_shop_domain?
@shopify_installation_generation = Shopify::InstallationGeneration.current(account)
exchange_and_create_hook
redirect_to shopify_integration_url, allow_other_host: true
end
def exchange_and_create_hook
shop_generation = Shopify::PendingInstallation.generation(shop: params[:shop])
exchange_access_token
with_current_shop_generation(shop_generation) do
callback_service.connect(account: account, generation: @shopify_installation_generation, credentials: parsed_body)
end
end
def with_current_shop_generation(expected_generation)
ActiveRecord::Base.transaction do
unless expected_generation.to_i == Shopify::PendingInstallation.generation(shop: params[:shop])
raise StandardError, 'Shopify installation changed during authorization'
end
yield
end
end
def handle_shopify_initiated_flow
prepare_shopify_initiated_flow
verify_shopify_oauth_state!
load_existing_shopify_account
# Security: HMAC validation ensures params (including shop) haven't been tampered with.
# Additionally, the OAuth code is cryptographically bound to the shop that issued it.
# Shopify will reject any attempt to exchange a code at a different shop's endpoint.
exchange_access_token
if @account
with_current_shop_generation(@pending_installation_generation) do
callback_service.reconnect(account: account, generation: @shopify_installation_generation, credentials: parsed_body)
end
return redirect_to existing_account_redirect_url, allow_other_host: true
def handle_response
account.with_lock do
hook = account.hooks.find_or_initialize_by(app_id: 'shopify', reference_id: Shopify::ShopDomain.normalize(params[:shop]))
hook.update!(
access_token: parsed_body['access_token'],
status: 'enabled',
settings: {
scope: parsed_body['scope'],
connected_at: Time.current.utc.iso8601(6),
installation_id: SecureRandom.uuid
}
)
end
token_key = Shopify::PendingInstallation.create(
access_token: parsed_body['access_token'],
shop: params[:shop],
scope: parsed_body['scope'],
shop_generation: @pending_installation_generation
)
redirect_to "#{frontend_url}/app/auth/signup?shopify_pending_install=#{CGI.escape(token_key)}", allow_other_host: true
end
def handle_shopify_initiated_without_code
prepare_shopify_initiated_flow
load_existing_shopify_account
hook = @account&.hooks&.find_by(app_id: 'shopify')
return redirect_to existing_account_redirect_url, allow_other_host: true if callback_service.reusable_hook?(hook)
state = SecureRandom.hex(16)
Redis::SecureStorage.set(
oauth_state_key(state),
{
shop: params[:shop],
pending_installation_generation: Shopify::PendingInstallation.generation(shop: params[:shop])
},
10.minutes
)
authorization_url = oauth_client.auth_code.authorize_url(
redirect_uri: redirect_callback_uri,
scope: REQUIRED_SCOPES.join(','),
state: state
)
redirect_to authorization_url, allow_other_host: true
end
def prepare_shopify_initiated_flow
ensure_shopify_enabled!
raise StandardError, 'Invalid HMAC signature' unless valid_hmac?
raise StandardError, 'Invalid shop domain' unless valid_shop_domain?
end
def verify_shopify_oauth_state!
state = params[:state].to_s
raise StandardError, 'Invalid state parameter' unless state.match?(/\A[0-9a-f]{32}\z/)
stored_state = JSON.parse(Redis::SecureStorage.get(oauth_state_key(state)).to_s)
raise StandardError, 'Invalid state parameter' unless stored_state['shop'] == params[:shop]
@pending_installation_generation = stored_state['pending_installation_generation']
Redis::SecureStorage.delete(oauth_state_key(state))
return if @pending_installation_generation.to_i == Shopify::PendingInstallation.generation(shop: params[:shop])
raise StandardError, 'Shopify installation changed during authorization'
rescue JSON::ParserError
raise StandardError, 'Invalid state parameter'
end
def exchange_access_token
@response = oauth_client.auth_code.get_token(params[:code], redirect_uri: redirect_callback_uri)
end
def load_existing_shopify_account
@account = callback_service.existing_account
return unless @account
@account_id = account.id
ensure_shopify_enabled!(account: account)
@shopify_installation_generation = Shopify::InstallationGeneration.current(account)
end
def existing_account_redirect_url
billing_identity = account.internal_attributes.stringify_keys
return shopify_billing_url if billing_identity['billing_provider'] == 'shopify' && billing_identity['signup_source'] == 'shopify'
shopify_integration_url
redirect_to shopify_integration_url
end
def parsed_body
@parsed_body ||= begin
parsed = @response.response.parsed
# Handle both SnakyHash (production) and regular Hash (tests)
{
'access_token' => parsed.respond_to?(:access_token) ? parsed.access_token : parsed['access_token'],
'scope' => parsed.respond_to?(:scope) ? parsed.scope : parsed['scope']
}
end
@parsed_body ||= @response.response.parsed
end
def oauth_client
@@ -167,63 +55,21 @@ class Shopify::CallbacksController < ApplicationController
)
end
def callback_service = (@callback_service ||= Shopify::CallbackService.new(shop: params[:shop]))
def account = (@account ||= Account.find(@account_id))
def verified_account_id
return unless params[:state].to_s.count('.') == 2
@verified_account_id ||= verify_shopify_token(params[:state])
def account
@account ||= Account.find(@account_id)
end
def ensure_shopify_enabled!(account: nil)
raise StandardError, 'Shopify integration is disabled' unless Shopify::FeatureGate.enabled?(account: account)
def account_id
@account_id ||= params[:state].split('_').first
end
def redirect_callback_uri = "#{frontend_url}/shopify/callback"
def shopify_integration_url = "#{frontend_url}/app/accounts/#{account.id}/settings/integrations/shopify"
def shopify_billing_url = "#{frontend_url}/app/accounts/#{account.id}/settings/billing?shop=#{CGI.escape(params[:shop])}"
def error_redirect_url
if @account_id
begin
"#{shopify_integration_url}?error=true"
rescue ActiveRecord::RecordNotFound
"#{frontend_url}?error=true"
end
else
"#{frontend_url}?error=true"
end
def shopify_integration_url
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/shopify"
end
def frontend_url = ENV.fetch('FRONTEND_URL', '')
def redirect_uri
return shopify_integration_url if account
def oauth_state_key(state)
"shopify_oauth_state:#{state}"
end
def valid_shop_domain?
return false if params[:shop].blank?
# Shopify shop domains must match: *.myshopify.com or *.myshopify.io (for dev shops)
params[:shop].match?(/\A[a-zA-Z0-9][a-zA-Z0-9\-]*\.myshopify\.(com|io)\z/)
end
def valid_hmac?
hmac = params[:hmac].to_s
return false unless hmac.match?(/\A[0-9a-f]{64}\z/)
# Shopify HMAC validation
# Reference: https://shopify.dev/docs/apps/build/authentication-authorization/get-access-tokens
query_params = request.query_parameters.except('hmac')
query_string = URI.encode_www_form(query_params.sort)
# Compute HMAC-SHA256
computed_hmac = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('SHA256'), client_secret, query_string)
ActiveSupport::SecurityUtils.secure_compare(computed_hmac, hmac)
ENV.fetch('FRONTEND_URL', nil)
end
end

View File

@@ -1,71 +0,0 @@
class Stripe::CallbacksController < ApplicationController
OAUTH_ERROR_CODES = %w[invalid_request invalid_client invalid_grant unauthorized_client unsupported_grant_type
invalid_scope access_denied server_error temporarily_unavailable].freeze
before_action :verify_browser_state
before_action :load_installation
rescue_from Faraday::TimeoutError, Faraday::ConnectionFailed, with: -> { authorization_failed('token_exchange_unavailable') }
def show
return authorization_failed('provider_error') if params[:error].present?
return authorization_failed('missing_code') if params[:code].blank?
token = Integrations::Stripe::Oauth.client.auth_code.get_token(params[:code], redirect_uri: Integrations::Stripe::Oauth.callback_url)
return authorization_failed('token_mode_mismatch') unless token.params.fetch('livemode') == @livemode
save_connection(token)
redirect_to destination
rescue OAuth2::Error => e
authorization_failed('token_exchange_failed', oauth_error: safe_oauth_error_code(e.code), http_status: e.response.status)
end
private
def safe_oauth_error_code(code)
# Provider messages and response bodies can contain credentials. Only log known error codes.
OAUTH_ERROR_CODES.include?(code) ? code : 'unknown'
end
def authorization_failed(reason, **details)
Rails.logger.warn({ event: 'stripe_oauth_authorization_failed', reason: reason, account_id: @account.id,
request_id: request.request_id, expected_livemode: @livemode }.merge(details).to_json)
redirect_to "#{destination}?error=authorization_failed"
end
def save_connection(token)
@account.with_lock do
hook = @account.hooks.find_or_initialize_by(app_id: 'stripe')
hook.reference_id = token.params.fetch('stripe_user_id')
hook.status = :enabled
hook.settings = hook.settings.merge('livemode' => @livemode, 'connected_at' => Time.current.iso8601)
Integrations::Stripe::Connection.new(hook).store_token!(token)
hook.reauthorized!
end
end
def verify_browser_state
browser_state = cookies.signed[:stripe_oauth_state]
return head :bad_request unless browser_state.present? && browser_state == params[:state]
cookies.delete(:stripe_oauth_state)
end
def load_installation
return head :not_found unless Integrations::Stripe::Oauth.configured?
state = Integrations::Stripe::Oauth.consume_state(params[:state])
return head :bad_request unless state
@account = Account.find(state.fetch('account_id'))
return head :not_found unless @account.feature_enabled?('stripe_integration')
@livemode = state.fetch('livemode')
return head :bad_request unless @livemode == Integrations::Stripe::Oauth.livemode?
head :forbidden unless @account.account_users.exists?(user_id: state.fetch('user_id'), role: :administrator)
end
def destination
"#{ENV.fetch('FRONTEND_URL')}/app/accounts/#{@account.id}/settings/integrations/stripe"
end
end

View File

@@ -29,7 +29,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
end
def create
errors = shopify_partner_config_errors + stripe_config_errors
errors = shopify_partner_config_errors
params['app_config'].each do |key, value|
break if errors.any?
next unless @allowed_configs.include?(key)
@@ -39,19 +39,15 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
errors.concat(i.errors.full_messages) unless i.save
end
redirect_to super_admin_app_config_path(config: @config), flash: errors.any? ? { alert: errors.join(', ') } : success_flash
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
end
end
private
def stripe_config_errors
return [] unless @config == 'stripe'
values = InstallationConfig.where(name: @allowed_configs).to_h { |config| [config.name, config.value] }
values.merge!(params.fetch('app_config', {}).permit(*@allowed_configs).to_h)
Integrations::Stripe::Oauth.configuration_errors(url: values['STRIPE_APP_AUTHORIZE_URL'], key: values['STRIPE_APP_SECRET_KEY'])
end
def set_config
@config = params[:config] || 'general'
end
@@ -65,7 +61,6 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET],
'email' => %w[MAILER_INBOUND_EMAIL_DOMAIN ACCOUNT_EMAILS_LIMIT ACCOUNT_EMAILS_PLAN_LIMITS],
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'stripe' => %w[STRIPE_APP_AUTHORIZE_URL STRIPE_APP_SECRET_KEY],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET SLACK_SIGNING_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET TIKTOK_API_VERSION],

View File

@@ -7,9 +7,9 @@
class SuperAdmin::ApplicationController < Administrate::ApplicationController
include ActionView::Helpers::TagHelper
include ActionView::Context
include SuperAdmin::NavigationHelper
helper SuperAdmin::NavigationHelper, SuperAdmin::CommandBarHelper, SuperAdmin::UiHelper
helper_method :render_vue_component
helper_method :render_vue_component, :settings_open?, :settings_pages
# authenticiation done via devise : SuperAdmin Model
before_action :authenticate_super_admin!

View File

@@ -5,7 +5,6 @@ class Webhooks::ShopifyController < ActionController::API
before_action :ensure_shopify_enabled, unless: :mandatory_cleanup_event?
before_action :verify_hmac!
around_action :with_installation_transaction, only: :events
def events
case request.headers['X-Shopify-Topic']
@@ -20,10 +19,6 @@ class Webhooks::ShopifyController < ActionController::API
private
def with_installation_transaction(&)
ActiveRecord::Base.transaction(&)
end
def ensure_shopify_enabled
head :not_found unless Shopify::FeatureGate.enabled?
end

View File

@@ -16,13 +16,6 @@ class PlatformBannerDashboard < Administrate::BaseDashboard
SHOW_PAGE_ATTRIBUTES = %i[id banner_type title banner_message video_url active created_at updated_at].freeze
FORM_ATTRIBUTES = %i[banner_type title banner_message video_url active].freeze
COLLECTION_FILTERS = {
info: ->(resources) { resources.where(banner_type: :info) },
warning: ->(resources) { resources.where(banner_type: :warning) },
error: ->(resources) { resources.where(banner_type: :error) },
feature_announcement: ->(resources) { resources.where(banner_type: :feature_announcement) }
}.freeze
def display_resource(platform_banner)
"Banner ##{platform_banner.id} (#{platform_banner.banner_type})"
end

View File

@@ -36,7 +36,8 @@ class UserDashboard < Administrate::BaseDashboard
updated_at: Field::DateTime,
pubsub_token: Field::String,
type: Field::Select.with_options(collection: [nil, 'SuperAdmin']),
accounts: CountField
accounts: CountField,
access_token: Field::HasOne
}.freeze
# COLLECTION_ATTRIBUTES
@@ -67,6 +68,7 @@ class UserDashboard < Administrate::BaseDashboard
updated_at
confirmed_at
account_users
access_token
].freeze
# FORM_ATTRIBUTES

View File

@@ -17,8 +17,7 @@ module Shopify::IntegrationHelper
def token_payload(account_id)
{
sub: account_id,
iat: Time.current.to_i,
exp: 10.minutes.from_now.to_i
iat: Time.current.to_i
}
end

View File

@@ -1,70 +0,0 @@
# Icon for each account feature flag in config/features.yml, shown on the account's Features tab.
# Tailwind scans this file: every class must be a literal here.
advanced_assignment: 'i-lucide-gauge'
advanced_search: 'i-lucide-scan-search'
advanced_search_indexing: 'i-lucide-database-zap'
agent_bots: 'i-lucide-bot'
agent_management: 'i-lucide-square-user'
api_and_webhooks: 'i-lucide-webhook'
assignment_v2: 'i-lucide-user-cog'
audit_log_ip_address: 'i-lucide-map-pinned'
audit_logs: 'i-lucide-scroll-text'
auto_resolve_conversations: 'i-lucide-timer-reset'
automations: 'i-lucide-repeat'
branded_email_templates: 'i-lucide-palette'
campaign_analytics: 'i-lucide-chart-no-axes-combined'
campaigns: 'i-lucide-megaphone'
canned_responses: 'i-lucide-message-square-quote'
captain_classifier: 'i-lucide-brain-circuit'
captain_document_auto_sync: 'i-lucide-refresh-cw'
captain_integration: 'i-woot-captain'
captain_tasks: 'i-lucide-list-checks'
channel_email: 'i-lucide-mail'
channel_facebook: 'i-logos-messenger'
channel_instagram: 'i-logos-instagram-icon dark:invert'
channel_tiktok: 'i-logos-tiktok-icon dark:invert'
channel_voice: 'i-lucide-phone-call'
channel_website: 'i-lucide-message-circle'
chatwoot_v4: 'i-lucide-rocket'
companies: 'i-lucide-building-2'
company_enrichment: 'i-lucide-sparkles'
contact_chatwoot_support_team: 'i-lucide-life-buoy'
conversation_monitors: 'i-lucide-activity'
conversation_required_attributes: 'i-lucide-clipboard-list'
conversation_unread_counts: 'i-lucide-bell-dot'
crm: 'i-lucide-contact'
crm_integration: 'i-lucide-link'
crm_v2: 'i-lucide-book-user'
csat_review_notes: 'i-lucide-star'
custom_attributes: 'i-lucide-code'
custom_reply_domain: 'i-lucide-globe'
custom_reply_email: 'i-lucide-at-sign'
custom_roles: 'i-lucide-shield-plus'
custom_tools: 'i-lucide-wrench'
data_import: 'i-lucide-upload'
delayed_automations: 'i-lucide-hourglass'
disable_branding: 'i-lucide-eye-off'
email_continuity_on_api_channel: 'i-lucide-reply'
help_center: 'i-lucide-library-big'
help_center_embedding_search: 'i-lucide-text-search'
inbound_emails: 'i-lucide-mail-open'
inbox_management: 'i-lucide-inbox'
inbox_view: 'i-lucide-layout-list'
integrations: 'i-lucide-blocks'
ip_lookup: 'i-lucide-map-pin'
labels: 'i-lucide-tags'
linear_integration: 'i-logos-linear-icon dark:invert'
macros: 'i-lucide-toy-brick'
notion_integration: 'i-logos-notion-icon dark:invert'
reply_mailer_migration: 'i-lucide-send'
report_rollup: 'i-lucide-chart-column-stacked'
reports: 'i-lucide-chart-spline'
saml: 'i-lucide-key-round'
search_with_gin: 'i-lucide-search'
shopify_integration: 'i-logos-shopify'
sla: 'i-lucide-clock-alert'
team_management: 'i-lucide-users'
unread_count_for_filters: 'i-lucide-list-filter'
voice_recorder: 'i-lucide-mic'
whatsapp_embedded_signup_inbox_creation: 'i-logos-whatsapp-icon'
whatsapp_manual_transfer: 'i-logos-whatsapp-icon'

View File

@@ -7,17 +7,6 @@ module SuperAdmin::AccountFeaturesHelper
account_features.filter { |feature| feature['premium'] }.pluck('name')
end
# The list the dashboard shows its "Learn more" links from (dashboard/helper/featureHelper.js).
def self.feature_help_urls
YAML.safe_load(Rails.root.join('config/feature_help_urls.yml').read).freeze
end
def self.feature_icons
icons = YAML.safe_load(Rails.root.join('app/helpers/super_admin/account_feature_icons.yml').read)
icons.default = 'i-lucide-toggle-right'
icons.freeze
end
# Returns a hash mapping feature names to their display names
def self.feature_display_names
account_features.each_with_object({}) do |feature, hash|
@@ -48,12 +37,16 @@ module SuperAdmin::AccountFeaturesHelper
filtered = filter_deprecated_features(filtered)
display_names = feature_display_names
premium_features = account_premium_features
regular, premium = filtered.partition { |key, _value| premium_features.exclude?(key) }
regular, premium = filtered.partition { |key, _value| account_premium_features.exclude?(key) }
[
sort_and_transform_features(regular, display_names),
sort_and_transform_features(premium, display_names)
]
end
def self.filtered_features(features)
regular, premium = partition_features(features)
regular.merge(premium)
end
end

View File

@@ -1,70 +0,0 @@
module SuperAdmin::CommandBarHelper
def command_groups
[
{ label: t('super_admin.command_bar.go_to'), commands: primary_nav_items },
{ label: t('super_admin.command_bar.create'), commands: create_commands },
{ label: t('super_admin.navigation.settings'), commands: settings_nav_items },
{ label: t('super_admin.navigation.monitor'), commands: monitor_nav_items },
{ label: t('super_admin.navigation.theme.label'), commands: theme_commands },
{ label: t('super_admin.navigation.account'), commands: session_commands }
]
end
def record_commands(record)
commands = [
{ action: :show, label: t('super_admin.command_bar.open'), url: [namespace, record], icon: 'i-lucide-arrow-right' },
{ action: :edit, label: t('administrate.actions.edit'), url: [:edit, namespace, record], icon: 'i-lucide-pencil' }
].select { |command| accessible_action?(record, command[:action]) && !current_page?(command[:url]) }
commands += user_commands(record) if record.is_a?(User)
commands += account_commands(record) if record.is_a?(Account)
return commands unless accessible_action?(record, :destroy)
commands << { label: t('administrate.actions.destroy'), url: [namespace, record], method: :delete, icon: 'i-lucide-trash-2', danger: true,
form: { data: { confirm: t('administrate.actions.confirm') } } }
end
private
def create_commands
nav_resources.select { |resource| existing_action?(resource, :new) }.map do |resource|
{ label: t('administrate.actions.new_resource', name: resource.singularize.titleize.downcase),
url: [:new, namespace, resource.singularize.to_sym], icon: 'i-lucide-plus' }
end
end
def theme_commands
[
{ label: t('super_admin.navigation.theme.system'), icon: 'i-lucide-monitor', data: { theme_value: 'system' } },
{ label: t('super_admin.navigation.theme.light'), icon: 'i-lucide-sun', data: { theme_value: 'light' } },
{ label: t('super_admin.navigation.theme.dark'), icon: 'i-lucide-moon', data: { theme_value: 'dark' } }
]
end
def session_commands
[
{ label: t('super_admin.navigation.profile'), url: super_admin_user_path(current_super_admin), icon: 'i-lucide-user' },
{ label: t('super_admin.navigation.agent_dashboard'), url: '/', icon: 'i-lucide-message-circle' },
{ label: t('super_admin.navigation.logout'), url: super_admin_logout_path, icon: 'i-lucide-log-out' }
]
end
def user_commands(user)
commands = []
if current_page?([namespace, user])
commands << { label: t('super_admin.users.impersonate'), icon: 'i-lucide-venetian-mask', data: { dialog_open: 'impersonate-dialog' } }
end
return commands if user.confirmed?
commands << { label: t('super_admin.users.resend_confirmation.button'), url: resend_confirmation_super_admin_user_path(user), method: :post,
icon: 'i-lucide-mail-check' }
end
def account_commands(account)
commands = [{ label: t('super_admin.accounts.reset_cache'), url: reset_cache_super_admin_account_path(account), method: :post,
icon: 'i-lucide-refresh-cw' }]
return commands unless Seeders::AccountSeeder.allowed?
commands << { label: t('super_admin.accounts.seed_data'), url: seed_super_admin_account_path(account), method: :post, icon: 'i-lucide-sprout',
danger: true, form: { data: { confirm: t('super_admin.accounts.seed_data_warning') } } }
end
end

View File

@@ -6,39 +6,39 @@ saml:
name: 'SAML SSO'
description: 'Configuration for controlling SAML Single Sign-On availability'
enabled: <%= ChatwootApp.enterprise? %>
icon: 'i-lucide-key-round'
icon: 'icon-lock-line'
config_key: 'saml'
enterprise: true
custom_branding:
name: 'Custom Branding'
description: 'Apply your own branding to this installation.'
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'i-lucide-palette'
icon: 'icon-paint-brush-line'
config_key: 'custom_branding'
enterprise: true
agent_capacity:
name: 'Agent Capacity'
description: 'Set limits to auto-assigning conversations to your agents.'
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'i-lucide-gauge'
icon: 'icon-hourglass-line'
enterprise: true
audit_logs:
name: 'Audit Logs'
description: 'Track and trace account activities with ease with detailed audit logs.'
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'i-lucide-scroll-text'
icon: 'icon-menu-search-line'
enterprise: true
disable_branding:
name: 'Disable Branding'
description: 'Disable branding on live-chat widget and external emails.'
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'i-lucide-eye-off'
icon: 'icon-sailbot-fill'
enterprise: true
voice_calls:
name: 'Voice Calls'
description: 'Enable voice calling capabilities for your agents and customers.'
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'i-lucide-phone-call'
icon: 'icon-voice-line'
enterprise: true
# ------- Product Features ------- #
@@ -46,12 +46,12 @@ help_center:
name: 'Help Center'
description: 'Allow agents to create help center articles and publish them in a portal.'
enabled: true
icon: 'i-lucide-book-open'
icon: 'icon-book-2-line'
captain:
name: 'Captain'
description: 'Enable AI-powered conversations with your customers.'
enabled: true
icon: 'i-woot-captain text-n-violet-9'
icon: 'icon-captain'
config_key: 'captain'
# ------- Communication Channels ------- #
@@ -59,100 +59,94 @@ live_chat:
name: 'Live Chat'
description: 'Improve your customer experience using a live chat on your website.'
enabled: true
icon: 'i-lucide-message-circle'
icon: 'icon-chat-smile-3-line'
email:
name: 'Email'
description: 'Manage your email customer interactions from Chatwoot.'
enabled: true
icon: 'i-lucide-mail'
icon: 'icon-mail-send-fill'
config_key: 'email'
sms:
name: 'SMS'
description: 'Manage your SMS customer interactions from Chatwoot.'
enabled: true
icon: 'i-lucide-message-square-text'
icon: 'icon-message-line'
messenger:
name: 'Messenger'
description: 'Stay connected with your customers on Facebook & Instagram.'
enabled: true
icon: 'i-logos-messenger'
icon: 'icon-messenger-line'
config_key: 'facebook'
instagram:
name: 'Instagram'
description: 'Stay connected with your customers on Instagram'
enabled: true
icon: 'i-logos-instagram-icon dark:invert'
icon: 'icon-instagram'
config_key: 'instagram'
tiktok:
name: 'TikTok'
description: 'Stay connected with your customers on TikTok'
enabled: true
icon: 'i-logos-tiktok-icon dark:invert'
icon: 'icon-tiktok'
config_key: 'tiktok'
whatsapp:
name: 'WhatsApp'
description: 'Manage your WhatsApp business interactions from Chatwoot.'
enabled: true
icon: 'i-logos-whatsapp-icon'
icon: 'icon-whatsapp-line'
telegram:
name: 'Telegram'
description: 'Manage your Telegram customer interactions from Chatwoot.'
enabled: true
icon: 'i-logos-telegram'
icon: 'icon-telegram-line'
line:
name: 'Line'
description: 'Manage your Line customer interactions from Chatwoot.'
enabled: true
icon: 'i-ri-line-fill text-[#06C755]'
icon: 'icon-line-line'
# ------- OAuth & Authentication ------- #
google:
name: 'Google'
description: 'Configuration for setting up Google OAuth Integration'
enabled: true
icon: 'i-logos-google-icon'
icon: 'icon-google'
config_key: 'google'
microsoft:
name: 'Microsoft'
description: 'Configuration for setting up Microsoft Email'
enabled: true
icon: 'i-logos-microsoft-icon'
icon: 'icon-microsoft'
config_key: 'microsoft'
# ------- Third-party Integrations ------- #
stripe:
name: 'Stripe'
description: 'Configure the Stripe customer billing integration'
enabled: true
icon: 'icon-settings-2-line'
config_key: 'stripe'
linear:
name: 'Linear'
description: 'Configuration for setting up Linear Integration'
enabled: true
icon: 'i-logos-linear-icon dark:invert'
icon: 'icon-linear'
config_key: 'linear'
notion:
name: 'Notion'
description: 'Configuration for setting up Notion Integration'
enabled: true
icon: 'i-logos-notion-icon dark:invert'
icon: 'icon-notion'
config_key: 'notion'
slack:
name: 'Slack'
description: 'Configuration for setting up Slack Integration'
enabled: true
icon: 'i-logos-slack-icon'
icon: 'icon-slack'
config_key: 'slack'
whatsapp_embedded:
name: 'WhatsApp Embedded'
description: 'Configuration for setting up WhatsApp Embedded Integration'
enabled: true
icon: 'i-logos-whatsapp-icon'
icon: 'icon-whatsapp-line'
config_key: 'whatsapp_embedded'
shopify:
name: 'Shopify'
description: 'Configuration for setting up Shopify Integration'
enabled: true
icon: 'i-logos-shopify'
icon: 'icon-shopify'
config_key: 'shopify'

View File

@@ -8,9 +8,9 @@ module SuperAdmin::FeaturesHelper
quantity = ChatwootHub.pricing_plan_quantity
if plan == 'premium'
"You are currently on the <span class='font-semibold'>#{plan.titleize}</span> plan with <span class='font-semibold'>#{quantity} agents</span>."
"You are currently on the <span class='font-semibold'>#{plan}</span> plan with <span class='font-semibold'>#{quantity} agents</span>."
else
"You are currently on the <span class='font-semibold'>#{plan.titleize}</span> edition plan."
"You are currently on the <span class='font-semibold'>#{plan}</span> edition plan."
end
end
end

View File

@@ -1,121 +1,16 @@
module SuperAdmin::NavigationHelper
NAV_ICONS = {
'accounts' => 'i-lucide-building-2',
'users' => 'i-lucide-users',
'agent_bots' => 'i-lucide-bot',
'platform_apps' => 'i-lucide-blocks',
'platform_banners' => 'i-lucide-megaphone'
}.freeze
# Routed resources that are reached from other pages instead of the navigation.
HIDDEN_RESOURCES = %w[dashboard access_tokens installation_configs].freeze
# Always in the tab bar on small screens; on phones the others sit behind "More".
PINNED_RESOURCES = %w[accounts users].freeze
# Names short enough to sit under an icon in that tab bar.
TAB_LABELS = {
'agent_bots' => 'super_admin.navigation.tabs.agent_bots',
'platform_apps' => 'super_admin.navigation.tabs.platform_apps',
'platform_banners' => 'super_admin.navigation.tabs.platform_banners'
}.freeze
# Heading each feature's page sits under in the settings list. Pages not listed stay at the top.
SETTINGS_GROUPS = {
'super_admin.navigation.settings_groups.product' => %w[saml custom_branding captain],
'super_admin.navigation.settings_groups.channels' => %w[email messenger instagram tiktok],
'super_admin.navigation.settings_groups.authentication' => %w[google microsoft],
'super_admin.navigation.settings_groups.integrations' => %w[linear notion slack whatsapp_embedded shopify]
}.freeze
def settings_open?
params[:controller].in? %w[super_admin/settings super_admin/app_configs]
end
def monitor_open?
controller_name.in? %w[instance_statuses push_diagnostics]
end
def settings_pages
@settings_pages ||= begin
features = SuperAdmin::FeaturesHelper.available_features.select do |_feature, attrs|
attrs['config_key'].present? && attrs['enabled']
end
pages = [['general', { 'config_key' => 'general', 'name' => t('super_admin.settings.pages.general'), 'icon' => 'i-lucide-sliders-horizontal' }]]
if ChatwootApp.chatwoot_cloud?
pages << ['internal', { 'config_key' => 'internal', 'name' => t('super_admin.settings.pages.internal'),
'description' => t('super_admin.settings.pages.internal_description'), 'icon' => 'i-lucide-lock-keyhole' }]
end
pages + features.to_a
features = SuperAdmin::FeaturesHelper.available_features.select do |_feature, attrs|
attrs['config_key'].present? && attrs['enabled']
end
end
def primary_nav_items
instance = t('super_admin.navigation.groups.instance')
[
{ label: t('super_admin.navigation.dashboard'), url: super_admin_root_path, icon: 'i-lucide-layout-dashboard',
active: current_page?(super_admin_root_path), pinned: true },
*resource_nav_items,
{ label: t('super_admin.navigation.settings'), url: super_admin_settings_path, icon: 'i-lucide-settings', active: settings_open?,
pinned: true, group: instance },
{ label: t('super_admin.navigation.monitor'), url: super_admin_instance_status_path, icon: 'i-lucide-activity', active: monitor_open?,
group: instance }
]
end
# Add general at the beginning
general_feature = [['general', { 'config_key' => 'general', 'name' => 'General' }]]
def settings_nav_items(current_config = nil)
overview = { label: t('super_admin.navigation.overview'), url: super_admin_settings_path, icon: 'i-lucide-layout-grid',
active: controller_name == 'settings' }
[overview] + settings_pages.map do |feature_key, attrs|
group_key = SETTINGS_GROUPS.keys.find { |key| SETTINGS_GROUPS[key].include?(feature_key) }
{ label: attrs['name'], url: super_admin_app_config_path(config: attrs['config_key']), group: (t(group_key) if group_key),
icon: attrs['icon'], active: attrs['config_key'] == current_config }
end
end
# Settings and Monitor swap the sidebar for their own links; nil on every other page.
def section_nav
if settings_open?
current_config = params[:config] || 'general' if controller_name == 'app_configs'
{ label: t('super_admin.navigation.settings'), items: settings_nav_items(current_config) }
elsif monitor_open?
{ label: t('super_admin.navigation.monitor'), items: monitor_nav_items }
end
end
def monitor_nav_items
[
{ label: t('super_admin.navigation.instance_health'), url: super_admin_instance_status_path, icon: 'i-lucide-heart-pulse',
active: controller_name == 'instance_statuses' },
{ label: t('super_admin.navigation.push_diagnostics'), url: super_admin_push_diagnostics_path, icon: 'i-lucide-bell-ring',
active: controller_name == 'push_diagnostics' },
{ label: t('super_admin.navigation.sidekiq_dashboard'), url: sidekiq_web_path, icon: 'i-lucide-layers', external: true }
]
end
def nav_resources
Administrate::Namespace.new(namespace).resources_with_index_route.reject do |resource|
HIDDEN_RESOURCES.include?(resource) || (resource == 'platform_banners' && !ChatwootApp.chatwoot_cloud?)
end
end
# superadmin.js keeps the tab last opened on a page in this cookie, so the page renders with that tab open.
def active_tab(tabs)
path, key = cookies[:super_admin_tab].to_s.split('#', 2)
path == request.path && tabs.any? { |tab| tab[:key] == key } ? key : tabs.first[:key]
end
private
def resource_nav_items
nav_resources.map do |resource|
tab_label_key = TAB_LABELS[resource]
{ label: display_resource_name(resource), tab_label: (t(tab_label_key) if tab_label_key), url: resource_index_route(resource),
icon: NAV_ICONS[resource], active: nav_link_state(resource) == :active,
pinned: PINNED_RESOURCES.include?(resource), group: t('super_admin.navigation.groups.manage') }
end
general_feature + features.to_a
end
end

View File

@@ -1,19 +0,0 @@
# Tailwind classes for form controls. Rails builds those tags (`f.text_field`, `select_tag`, `check_box_tag` and so on),
# so the classes are passed to them; everything else is a partial. Tailwind scans this file: every class must be a literal here.
module SuperAdmin::UiHelper
INPUT = %w[
block min-h-9 w-full rounded-lg border border-n-slate-6 bg-n-solid-1 px-3 py-1.5 text-body-main text-n-slate-12 outline-none
transition-colors placeholder:text-n-slate-10 hover:border-n-slate-8 focus:border-n-brand focus:ring-2 focus:ring-n-brand/25
disabled:cursor-not-allowed disabled:bg-n-slate-3 disabled:text-n-slate-10 touch:min-h-11 touch:text-base
].join(' ').freeze
CLASSES = {
input: INPUT,
select: "#{INPUT} appearance-none bg-chevron pe-9",
checkbox: 'size-4 shrink-0 cursor-pointer accent-n-brand disabled:cursor-not-allowed touch:size-5'
}.freeze
def ui(name, extra = nil)
class_names(CLASSES.fetch(name), extra)
end
end

View File

@@ -66,6 +66,22 @@ class TasksAPI extends ApiClient {
);
}
/**
* Gets label suggestions for a conversation.
* @param {string} conversationId - The conversation ID.
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
* @returns {Promise} A promise that resolves with label suggestions.
*/
labelSuggestion(conversationId, signal) {
return axios.post(
`${this.url}/label_suggestion`,
{
conversation_display_id: conversationId,
},
{ signal }
);
}
/**
* Sends a follow-up message to continue refining a previous task result.
* @param {Object} options - The follow-up options.

View File

@@ -1,21 +0,0 @@
// Uses fetch instead of the axios client so the user's auth headers are never sent to the catalog host
const fetchCatalogJson = async (url, { signal } = {}) => {
const response = await fetch(url, { signal, credentials: 'omit' });
if (!response.ok) {
throw new Error(`Could not load the tools catalog: ${response.status}`);
}
return response.json();
};
export const fetchToolsCatalog = (baseUrl, options) => {
if (!baseUrl) throw new Error('The tools catalog URL is not configured');
return fetchCatalogJson(`${baseUrl.replace(/\/+$/, '')}.json`, options);
};
// The catalog list links each toolset to its details, which carry the readme and tools
export const fetchToolsetDetails = (detailUrl, options) =>
fetchCatalogJson(detailUrl, options);
// Every catalog toolset lives in chatwoot/tools; the author is who wrote it. GitHub logins are case-insensitive
export const isChatwootAuthor = author => author?.toLowerCase() === 'chatwoot';

View File

@@ -6,13 +6,6 @@ class CaptainToolsManifest extends ApiClient {
super('captain/tools_manifest', { accountScoped: true });
}
installed({ assistantId }, { signal } = {}) {
return axios.get(`${this.url}/installed`, {
params: { assistant_id: assistantId },
signal,
});
}
preview({ assistantId, source }, { signal } = {}) {
return axios.post(
`${this.url}/preview`,

View File

@@ -24,25 +24,16 @@ class CompanyAPI extends ApiClient {
return axios.get(requestURL);
}
listContacts(id, page = 1, q = undefined) {
return axios.get(`${this.url}/${id}/contacts?${buildParams({ page, q })}`);
listContacts(id, page = 1) {
return axios.get(`${this.url}/${id}/contacts?${buildParams({ page })}`);
}
listNotes(id, page = 1, q = undefined) {
return axios.get(`${this.url}/${id}/notes?${buildParams({ page, q })}`);
listNotes(id) {
return axios.get(`${this.url}/${id}/notes`);
}
listConversations(id, page = 1) {
return axios.get(
`${this.url}/${id}/conversations?${buildParams({ page })}`
);
}
filterConversations(id, payload, page = 1) {
return axios.post(`${this.url}/${id}/conversations/filter`, {
payload,
page,
});
listConversations(id) {
return axios.get(`${this.url}/${id}/conversations`);
}
searchContacts(id, query = '', page = 1) {
@@ -64,10 +55,6 @@ class CompanyAPI extends ApiClient {
});
}
enrich(id) {
return axios.post(`${this.url}/${id}/enrichment`);
}
destroyAvatar(id) {
return axios.delete(`${this.url}/${id}/avatar`);
}

View File

@@ -10,12 +10,6 @@ class EnterpriseAccountAPI extends ApiClient {
return axios.post(`${this.url}checkout`);
}
reconnectShopify(pendingInstallToken) {
return axios.post(`${this.url}reconnect_shopify`, {
pending_install_token: pendingInstallToken,
});
}
subscription() {
return axios.post(`${this.url}subscription`);
}

View File

@@ -33,6 +33,12 @@ class IntegrationsAPI extends ApiClient {
deleteHook(hookId) {
return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`);
}
connectShopify({ shopDomain }) {
return axios.post(`${this.baseUrl()}/integrations/shopify/auth`, {
shop_domain: shopDomain,
});
}
}
export default new IntegrationsAPI();

View File

@@ -7,21 +7,11 @@ class ShopifyAPI extends ApiClient {
super('integrations/shopify', { accountScoped: true });
}
connect(shopDomain) {
return axios.post(`${this.url}/auth`, { shop_domain: shopDomain });
}
getOrders(contactId) {
return axios.get(`${this.url}/orders`, {
params: { contact_id: contactId },
});
}
completeInstall(pendingInstallToken) {
return axios.post(`${this.url}/complete_install`, {
pending_install_token: pendingInstallToken,
});
}
}
export default new ShopifyAPI();

View File

@@ -1,25 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class StripeAPI extends ApiClient {
constructor() {
super('integrations/stripe', { accountScoped: true });
}
connect() {
return axios.post(`${this.url}/auth`);
}
disconnect() {
return axios.delete(this.url);
}
customer(conversationId, customerId, signal) {
return axios.get(`${this.url}/customer`, {
params: { conversation_id: conversationId, customer_id: customerId },
signal,
});
}
}
export default new StripeAPI();

View File

@@ -1,79 +0,0 @@
@layer utilities {
// ============================================================================
// TYPOGRAPHY UTILITIES
// ============================================================================
//
// | Class | Use Case |
// |--------------------|----------------------------------------------------|
// | .text-body-main | <p>, <span>, general body text |
// | .text-body-para | <p> for paragraphs, larger text blocks |
// | .text-heading-1 | <h1>, page titles, panel headers |
// | .text-heading-2 | <h2>, section headings, card titles |
// | .text-heading-3 | <h3>, card headings, breadcrumbs, subsections |
// | .text-label | <label>, form labels, field names |
// | .text-label-small | <small>, footnotes, tags, badges, captions |
// | .text-button | <button>, standard button text |
// | .text-button-small | <button>, small/compact button text |
// body-text-main: Main text style for general body text
.text-body-main {
@apply font-inter text-sm font-420;
letter-spacing: -0.28px;
line-height: 21px; // 150%
}
// body-text-paragraph: For paragraphs or larger blocks of text
.text-body-para {
@apply font-inter text-sm font-420;
letter-spacing: -0.21px;
line-height: 21px; // 150%
}
// heading-1: Large heading for pages and panels
.text-heading-1 {
@apply font-inter text-lg font-520;
letter-spacing: -0.27px;
line-height: 24px; // 133.333%
}
// heading-2: Secondary heading for sections
.text-heading-2 {
@apply font-inter text-base font-medium;
letter-spacing: -0.27px;
line-height: 24px; // 133.333%
}
// heading-3: For card headings, breadcrumbs, subsections
.text-heading-3 {
@apply font-inter text-sm font-medium;
letter-spacing: -0.27px;
line-height: 21px; // 150%
}
// label: Standard label text for form fields
.text-label {
@apply font-inter text-sm font-medium;
line-height: 21px; // 150%
}
// label-small: Smallest font for labels, footnotes, tags
.text-label-small {
@apply font-inter text-xs font-440;
letter-spacing: -0.24px;
line-height: 16px; // 133.333%
}
// button-text: Text for standard size buttons
.text-button {
@apply font-inter text-sm font-460;
letter-spacing: -0.28px;
line-height: 21px; // 150%
}
// button-text-small: Text for smaller buttons
.text-button-small {
@apply font-inter text-xs font-440;
letter-spacing: -0.24px;
line-height: 18px; // 150%
}
}

View File

@@ -6,9 +6,6 @@
@import 'shared/assets/fonts/InterDisplay/inter-display';
@import 'shared/assets/fonts/inter';
// Typography Utilities
@import 'typography';
// Next Colors
@import 'next-colors';
@@ -68,4 +65,84 @@ body {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
/**
* ============================================================================
* TYPOGRAPHY UTILITIES
* ============================================================================
*
* | Class | Use Case |
* |--------------------|----------------------------------------------------|
* | .text-body-main | <p>, <span>, general body text |
* | .text-body-para | <p> for paragraphs, larger text blocks |
* | .text-heading-1 | <h1>, page titles, panel headers |
* | .text-heading-2 | <h2>, section headings, card titles |
* | .text-heading-3 | <h3>, card headings, breadcrumbs, subsections |
* | .text-label | <label>, form labels, field names |
* | .text-label-small | <small>, footnotes, tags, badges, captions |
* | .text-button | <button>, standard button text |
* | .text-button-small | <button>, small/compact button text |
*/
/* body-text-main: Main text style for general body text */
.text-body-main {
@apply font-inter text-sm font-420;
line-height: 21px; /* 150% */
letter-spacing: -0.28px;
}
/* body-text-paragraph: For paragraphs or larger blocks of text */
.text-body-para {
@apply font-inter text-sm font-420;
line-height: 21px; /* 150% */
letter-spacing: -0.21px;
}
/* heading-1: Large heading for pages and panels */
.text-heading-1 {
@apply font-inter text-lg font-520;
line-height: 24px; /* 133.333% */
letter-spacing: -0.27px;
}
/* heading-2: Secondary heading for sections */
.text-heading-2 {
@apply font-inter text-base font-medium;
line-height: 24px; /* 133.333% */
letter-spacing: -0.27px;
}
/* heading-3: For card headings, breadcrumbs, subsections */
.text-heading-3 {
@apply font-inter text-sm font-medium;
line-height: 21px; /* 150% */
letter-spacing: -0.27px;
}
/* label: Standard label text for form fields */
.text-label {
@apply font-inter text-sm font-medium;
line-height: 21px; /* 150% */
}
/* label-small: Smallest font for labels, footnotes, tags */
.text-label-small {
@apply font-inter text-xs font-440;
line-height: 16px; /* 133.333% */
letter-spacing: -0.24px;
}
/* button-text: Text for standard size buttons */
.text-button {
@apply font-inter text-sm font-460;
line-height: 21px; /* 150% */
letter-spacing: -0.28px;
}
/* button-text-small: Text for smaller buttons */
.text-button-small {
@apply font-inter text-xs font-440;
line-height: 18px; /* 150% */
letter-spacing: -0.24px;
}
}

View File

@@ -1,36 +0,0 @@
// Selectize is the one widget whose markup we do not render, so it cannot take
// utilities in the templates. These selectors mirror its own specificity.
.selectize-control .selectize-input,
.selectize-control.single .selectize-input.input-active {
@apply flex min-h-9 items-center rounded-lg border border-n-slate-6 bg-n-solid-1 px-3 py-1.5 text-body-main text-n-slate-12 shadow-none hover:border-n-slate-8 touch:min-h-11 touch:text-base;
background-image: none;
&.focus {
@apply border-n-brand ring-2 ring-n-brand/25;
}
&.dropdown-active::before {
@apply hidden;
}
> input {
@apply text-body-main text-n-slate-12 touch:text-base;
}
}
.selectize-control.single .selectize-input::after {
@apply end-3 border-x-4 border-b-0 border-t-4 border-x-transparent border-t-n-slate-10;
}
.selectize-dropdown {
@apply mt-1 overflow-hidden rounded-lg border border-n-weak bg-n-solid-2 text-body-main text-n-slate-12 shadow-lg;
.option {
@apply px-3 py-2;
}
.active {
@apply bg-n-alpha-2 text-n-slate-12;
}
}

View File

@@ -1,27 +0,0 @@
// Utilities only the super admin uses, next to Tailwind's own and the shared typography ones.
@layer utilities {
// The square mosaic from chatwoot.com, drawn with gradients so it needs no image
// and follows the theme tokens.
.bg-pixel-band {
background-image: linear-gradient(to right, rgb(var(--solid-1)) 0.0625rem, transparent 0.0625rem),
linear-gradient(to bottom, rgb(var(--solid-1)) 0.0625rem, transparent 0.0625rem),
radial-gradient(circle at 88% 10%, rgb(var(--blue-9) / 0.6), transparent 38%),
radial-gradient(circle at 62% 110%, rgb(var(--blue-8) / 0.55), transparent 42%),
radial-gradient(circle at 30% -20%, rgb(var(--blue-7) / 0.4), transparent 45%),
linear-gradient(to right, rgb(var(--blue-2)), rgb(var(--blue-4)));
background-size: 0.5rem 0.5rem,
0.5rem 0.5rem,
100% 100%,
100% 100%,
100% 100%,
100% 100%;
}
// Arrow for native selects, which lose theirs with `appearance-none`.
.bg-chevron {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2380838d' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-position: right 0.625rem center;
background-repeat: no-repeat;
background-size: 1rem;
}
}

View File

@@ -1,12 +1,12 @@
@import 'shared/assets/fonts/inter';
@import 'shared/assets/fonts/InterDisplay/inter-display';
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import '../typography';
@import 'utilities';
@import 'selectize';
@import 'tailwindcss/utilities';
body {
font-family: Inter, -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important;
}
@layer base {
// FIXME: Use a common color file for all packs
// scss-lint:disable PropertySortOrder
@@ -24,19 +24,6 @@
--slate-11: 96 100 108;
--slate-12: 28 32 36;
--blue-1: 251 253 255;
--blue-2: 245 249 255;
--blue-3: 233 243 255;
--blue-4: 218 236 255;
--blue-5: 201 226 255;
--blue-6: 181 213 255;
--blue-7: 155 195 252;
--blue-8: 117 171 247;
--blue-9: 39 129 246;
--blue-10: 16 115 233;
--blue-11: 8 109 224;
--blue-12: 11 50 101;
--iris-1: 253 253 255;
--iris-2: 248 248 255;
--iris-3: 240 241 254;
@@ -76,19 +63,6 @@
--amber-11: 171 100 0;
--amber-12: 79 52 34;
--violet-1: 253 252 254;
--violet-2: 250 248 255;
--violet-3: 244 240 254;
--violet-4: 235 228 255;
--violet-5: 225 217 255;
--violet-6: 212 202 254;
--violet-7: 194 178 248;
--violet-8: 169 153 236;
--violet-9: 110 86 207;
--violet-10: 100 84 196;
--violet-11: 101 85 183;
--violet-12: 47 38 95;
--teal-1: 250 254 253;
--teal-2: 243 251 249;
--teal-3: 224 248 243;
@@ -138,9 +112,6 @@
}
.dark {
// scss-lint:disable PropertySpelling
color-scheme: dark;
--slate-1: 17 17 19;
--slate-2: 24 25 27;
--slate-3: 33 34 37;
@@ -154,19 +125,6 @@
--slate-11: 176 180 186;
--slate-12: 237 238 240;
--blue-1: 10 17 28;
--blue-2: 15 24 38;
--blue-3: 15 39 72;
--blue-4: 10 49 99;
--blue-5: 18 61 117;
--blue-6: 29 84 134;
--blue-7: 40 89 156;
--blue-8: 48 106 186;
--blue-9: 39 129 246;
--blue-10: 21 116 231;
--blue-11: 126 182 255;
--blue-12: 205 227 255;
--iris-1: 19 19 30;
--iris-2: 23 22 37;
--iris-3: 32 34 72;
@@ -206,19 +164,6 @@
--amber-11: 255 202 22;
--amber-12: 255 231 179;
--violet-1: 20 17 31;
--violet-2: 27 21 37;
--violet-3: 41 31 67;
--violet-4: 50 37 85;
--violet-5: 60 46 105;
--violet-6: 71 56 135;
--violet-7: 86 70 151;
--violet-8: 110 86 171;
--violet-9: 110 86 207;
--violet-10: 125 109 217;
--violet-11: 169 153 236;
--violet-12: 226 221 254;
--teal-1: 13 21 20;
--teal-2: 17 28 27;
--teal-3: 13 45 42;

View File

@@ -4,10 +4,8 @@ import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { useExactTimestamp } from 'shared/composables/useExactTimestamp';
import { getCompanyProfile } from 'dashboard/components-next/Companies/helpers/companyHelper';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Flag from 'dashboard/components-next/flag/Flag.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
@@ -17,8 +15,6 @@ const props = defineProps({
contactsCount: { type: Number, default: 0 },
avatarUrl: { type: String, default: '' },
lastActivityAt: { type: [String, Number], default: null },
description: { type: String, default: '' },
additionalAttributes: { type: Object, default: () => ({}) },
});
const emit = defineEmits(['showCompany']);
@@ -31,36 +27,13 @@ const onClickViewDetails = () => emit('showCompany', props.id);
const displayName = computed(() => props.name || t('COMPANIES.UNNAMED'));
const details = computed(() => {
const { industry, employees, location, countryCode } = getCompanyProfile(
props.additionalAttributes
);
const contactsCount = Number(props.contactsCount || 0);
const avatarSource = computed(() => props.avatarUrl || null);
return [
props.domain && {
key: 'domain',
icon: 'i-lucide-globe',
label: props.domain,
},
industry && {
key: 'industry',
icon: 'i-lucide-building-2',
label: industry,
},
employees && {
key: 'employees',
icon: 'i-lucide-users',
label: t('COMPANIES.CARD.EMPLOYEES', { count: employees }),
},
location && { key: 'location', countryCode, label: location },
contactsCount > 0 && {
key: 'contacts',
icon: 'i-lucide-contact',
label: t('COMPANIES.CONTACTS_COUNT', { n: contactsCount }),
},
].filter(Boolean);
});
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 '';
@@ -69,66 +42,52 @@ const formattedLastActivityAt = computed(() => {
</script>
<template>
<div
class="flex items-start justify-between gap-4 py-4 cursor-pointer group max-sm:grid max-sm:grid-cols-[auto_minmax(0,1fr)_auto] max-sm:gap-x-3 max-sm:gap-y-1"
@click="onClickViewDetails"
>
<div class="flex items-start flex-1 min-w-0 gap-3 max-sm:contents">
<CardLayout layout="row" @click="onClickViewDetails">
<div class="flex items-center justify-start flex-1 gap-4 cursor-pointer">
<Avatar
:name="displayName"
:src="avatarUrl"
class="shrink-0 max-sm:row-span-3"
:size="36"
:username="displayName"
:src="avatarSource"
class="shrink-0"
:name="name"
:size="42"
hide-offline-status
/>
<div class="flex flex-col flex-1 min-w-0 gap-1 max-sm:contents">
<span
class="block truncate text-heading-3 text-n-slate-12 group-hover:text-n-blue-11 max-sm:col-start-2"
>
{{ displayName }}
</span>
<p
v-if="description"
class="mb-0 text-n-slate-11 text-body-main line-clamp-2 sm:line-clamp-1 max-sm:col-start-2 max-sm:col-span-2"
>
{{ description }}
</p>
<div
v-if="details.length"
class="overflow-hidden max-sm:col-start-2 max-sm:col-span-2"
>
<div class="flex flex-wrap gap-y-1 -ms-[calc(0.75rem+1px)]">
<div class="flex flex-col gap-0.5 flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 min-w-0">
<span class="text-base font-medium truncate text-n-slate-12">
{{ displayName }}
</span>
<span
v-if="hasContacts"
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 }}
</span>
</div>
<div class="flex items-center justify-between gap-3">
<div class="flex items-center min-w-0">
<span
v-for="detail in details"
:key="detail.key"
class="inline-flex items-center min-w-0 gap-1.5 text-body-main text-n-slate-11 max-w-full me-3 before:content-[''] before:w-px before:h-3 before:shrink-0 before:bg-n-slate-6 before:me-1.5"
:title="detail.label"
v-if="domain"
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate cursor-text"
@click.stop
>
<Flag
v-if="detail.countryCode"
:country="detail.countryCode"
class="size-3.5 shrink-0"
/>
<Icon
v-else-if="detail.icon"
:icon="detail.icon"
class="size-3.5 shrink-0 text-n-slate-10"
/>
<span class="truncate sm:max-w-60">{{ detail.label }}</span>
<Icon icon="i-lucide-globe" size="size-3.5 text-n-slate-11" />
<span class="truncate">{{ domain }}</span>
</span>
</div>
<span
v-if="lastActivityAt"
v-tooltip.top="{
content: exactTimestamp(lastActivityAt),
delay: { show: 500, hide: 0 },
}"
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 flex-shrink-0"
>
{{ formattedLastActivityAt }}
</span>
</div>
</div>
</div>
<span
v-if="lastActivityAt"
v-tooltip.top="{
content: exactTimestamp(lastActivityAt),
delay: { show: 500, hide: 0 },
}"
class="flex-shrink-0 text-sm text-n-slate-11 leading-[1.3125rem] max-sm:col-start-3 max-sm:row-start-1"
>
{{ formattedLastActivityAt }}
</span>
</div>
</CardLayout>
</template>

View File

@@ -0,0 +1,125 @@
<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>

View File

@@ -1,5 +1,6 @@
<script setup>
import CompanySearchInput from 'dashboard/components-next/Companies/CompanySearchInput.vue';
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';
@@ -17,19 +18,31 @@ const emit = defineEmits(['search', 'update:sort', 'create']);
<template>
<header class="sticky top-0 z-10 px-6">
<div
class="flex items-center justify-between w-full py-6 gap-4 mx-auto max-w-5xl"
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 shrink-0 text-n-slate-12">
<span class="text-xl font-medium truncate text-n-slate-12">
{{ headerTitle }}
</span>
<div class="flex items-center min-w-0 gap-2 sm:gap-4">
<CompanySearchInput
v-if="showSearch"
:model-value="searchValue"
:placeholder="$t('COMPANIES.SEARCH_PLACEHOLDER')"
class="flex-1 min-w-0"
@update:model-value="emit('search', $event)"
/>
<div class="flex items-center flex-col sm:flex-row flex-shrink-0 gap-4">
<div v-if="showSearch" class="flex items-center gap-2 w-full">
<Input
:model-value="searchValue"
type="search"
:placeholder="$t('COMPANIES.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',
]"
class="w-full"
@input="emit('search', $event.target.value)"
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute -translate-y-1/2 text-n-slate-11 size-4 top-1/2 ltr:left-2 rtl:right-2"
/>
</template>
</Input>
</div>
<div class="flex items-center flex-shrink-0 gap-2">
<CompanySortMenu
:active-sort="activeSort"

View File

@@ -38,7 +38,7 @@ const handleAction = ({ action }) => {
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="menuItems"
class="end-0 mt-1 w-52 top-full"
class="ltr:right-0 rtl:left-0 mt-1 w-52 top-full"
@action="handleAction($event)"
/>
</div>

View File

@@ -95,7 +95,7 @@ const handleOrderChange = value => {
<div
v-if="isMenuOpen"
v-on-clickaway="() => (isMenuOpen = false)"
class="absolute top-full mt-1 end-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-n-slate-12">

View File

@@ -48,7 +48,7 @@ const updateCurrentPage = page => {
current-page-info="COMPANIES_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage"
:total-items="totalItems"
:items-per-page="50"
:items-per-page="25"
class="max-w-[67rem]"
@update:current-page="updateCurrentPage"
/>

View File

@@ -1,17 +1,31 @@
<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,
@@ -33,15 +47,39 @@ const props = defineProps({
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(' • ');
@@ -50,7 +88,11 @@ const contactName = contact =>
const contactOptions = computed(() =>
props.searchResults
.filter(contact => !contact.linkedToCurrentCompany)
.filter(
contact =>
!contact.linkedToCurrentCompany &&
!linkedContactIds.value.has(Number(contact.id))
)
.map(contact => ({
value: contact.id,
label: [contactName(contact), contact.email, contact.phoneNumber]
@@ -127,11 +169,16 @@ const handleContactSelect = contactId => {
</script>
<template>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-6 px-6 pb-8">
<div v-if="!selectedContact" class="flex flex-col gap-4">
<span class="text-body-main text-n-slate-11">
{{ t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.DESCRIPTION') }}
</span>
<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"
@@ -183,6 +230,7 @@ const handleContactSelect = contactId => {
: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">
@@ -220,5 +268,87 @@ const handleContactSelect = contactId => {
/>
</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>

View File

@@ -1,125 +0,0 @@
<script setup>
import { computed, onMounted, useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { useStore } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import SingleSelect from 'dashboard/components-next/filter/inputs/SingleSelect.vue';
import { useConversationFilterContext } from 'dashboard/components-next/filter/provider';
const filters = defineModel({ type: Array, default: () => [] });
const SIMPLE_FILTER_KEYS = [
'status',
'assignee_id',
'inbox_id',
'team_id',
'priority',
'labels',
];
const { t } = useI18n();
const store = useStore();
const { attributeFilterTypes } = useConversationFilterContext();
const [showFilters, toggleFilters] = useToggle();
const toggleButton = useTemplateRef('toggleButton');
const closeOnClickOutside = [
() => toggleFilters(false),
{ ignore: [toggleButton] },
];
// Assignee options need agents, which the dashboard doesn't load globally.
onMounted(() => store.dispatch('agents/get'));
const simpleFilters = computed(() =>
SIMPLE_FILTER_KEYS.map(key =>
attributeFilterTypes.value.find(type => type.attributeKey === key)
)
.filter(Boolean)
.map(type => ({
key: type.attributeKey,
label: type.label,
options: type.options.filter(option => option.id !== 'all'),
}))
);
const activeCount = computed(() => filters.value.length);
const selectedOption = key =>
filters.value.find(filter => filter.attributeKey === key)?.values?.[0];
const setFilter = (key, option) => {
const others = filters.value.filter(filter => filter.attributeKey !== key);
filters.value = option
? [
...others,
{
attributeKey: key,
filterOperator: 'equal_to',
values: [option],
queryOperator: 'and',
},
]
: others;
};
const clearFilters = () => {
filters.value = [];
};
</script>
<template>
<div class="relative">
<Button
ref="toggleButton"
:label="
activeCount
? t('COMPANIES.DETAIL.CONVERSATION_FILTERS.BUTTON_WITH_COUNT', {
count: activeCount,
})
: t('COMPANIES.DETAIL.CONVERSATION_FILTERS.BUTTON')
"
icon="i-lucide-list-filter"
slate
:faded="!activeCount"
sm
@click="toggleFilters()"
/>
<div
v-if="showFilters"
v-on-click-outside="closeOnClickOutside"
class="absolute z-40 flex flex-col gap-4 p-4 mt-1 border bg-n-alpha-3 backdrop-blur-[100px] border-n-weak w-80 rounded-xl top-full end-0"
>
<div
v-for="filter in simpleFilters"
:key="filter.key"
class="flex items-center justify-between gap-3"
>
<span class="text-sm truncate text-n-slate-12">
{{ filter.label }}
</span>
<SingleSelect
:model-value="selectedOption(filter.key)"
:options="filter.options"
:placeholder="t('COMPANIES.DETAIL.CONVERSATION_FILTERS.ANY')"
placeholder-icon="i-lucide-chevron-down"
placeholder-trailing-icon
dropdown-max-height="max-h-64"
@update:model-value="option => setFilter(filter.key, option)"
/>
</div>
<Button
v-if="activeCount"
:label="t('FILTER.CLEAR_BUTTON_LABEL')"
variant="link"
color="slate"
size="xs"
class="self-end"
@click="clearFilters"
/>
</div>
</div>
</template>

View File

@@ -1,109 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { conversationUrl, frontendURL } from 'dashboard/helper/URLHelper';
import { groupByDay } from 'dashboard/components-next/Companies/helpers/companyHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import ConversationCardExpanded from 'dashboard/components-next/Conversation/ConversationCard/ConversationCardExpanded.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
conversations: { type: Array, default: () => [] },
isLoading: { type: Boolean, default: false },
hasMore: { type: Boolean, default: false },
filtered: { type: Boolean, default: false },
});
const emit = defineEmits(['loadMore']);
const { t } = useI18n();
const router = useRouter();
const { accountId } = useAccount();
const contactById = useMapGetter('contacts/getContact');
const inboxById = useMapGetter('inboxes/getInbox');
const groups = computed(() => groupByDay(props.conversations, 'timestamp', t));
const conversationContact = conversation => {
const sender = conversation.meta?.sender || {};
const contact = sender.id ? contactById.value(sender.id) : {};
return contact?.id ? contact : sender;
};
const conversationInbox = conversation =>
inboxById.value(conversation.inbox_id) || {};
const openConversation = (conversation, event) => {
const path = frontendURL(
conversationUrl({ accountId: accountId.value, id: conversation.id })
);
if (event.metaKey || event.ctrlKey) {
window.open(
`${window.chatwootConfig.hostURL}${path}`,
'_blank',
'noopener,noreferrer'
);
return;
}
router.push({ path });
};
</script>
<template>
<section class="flex flex-col gap-4">
<div
v-if="isLoading && !conversations.length"
class="flex justify-center py-12"
>
<Spinner />
</div>
<p
v-else-if="!conversations.length"
class="px-6 py-10 text-sm text-center border border-dashed rounded-xl border-n-weak text-n-slate-11"
>
{{
filtered
? t('COMPANIES.DETAIL.ACTIVITY.NO_MATCHING_CONVERSATIONS')
: t('COMPANIES.DETAIL.ACTIVITY.EMPTY_CONVERSATIONS')
}}
</p>
<div v-else class="flex flex-col gap-6">
<div v-for="group in groups" :key="group.label" class="flex flex-col">
<h3 class="pb-1 text-label-small text-n-slate-10">
{{ group.label }}
</h3>
<ConversationCardExpanded
v-for="conversation in group.items"
:key="conversation.id"
:chat="conversation"
:current-contact="conversationContact(conversation)"
:assignee="conversation.meta?.assignee || {}"
:inbox="conversationInbox(conversation)"
:selectable="false"
conversation-first
show-assignee
show-inbox-name
class="!px-0 hover:!bg-transparent hover:!border-n-slate-3 hover:before:!content-none [&:hover_h4]:!text-n-blue-11"
@click="openConversation(conversation, $event)"
/>
</div>
</div>
<Button
v-if="hasMore && conversations.length"
:label="t('COMPANIES.DETAIL.ACTIVITY.LOAD_MORE')"
variant="faded"
color="slate"
size="sm"
class="self-center"
:is-loading="isLoading"
@click="emit('loadMore')"
/>
</section>
</template>

View File

@@ -80,7 +80,7 @@ onMounted(() => {
</script>
<template>
<div v-if="hasCompanyAttributes" class="flex flex-col gap-6">
<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"
@@ -105,12 +105,14 @@ onMounted(() => {
<div class="flex flex-col gap-3">
<div v-if="!hasNoUnusedAttributes" class="relative">
<span class="absolute i-lucide-search size-3.5 top-2 start-3" />
<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 ps-10 pe-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
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>
@@ -134,7 +136,7 @@ onMounted(() => {
</div>
</div>
<p v-else class="text-sm text-n-slate-11">
<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>

View File

@@ -1,301 +0,0 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useAlert } from 'dashboard/composables';
import { useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { usePolicy } from 'dashboard/composables/usePolicy';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
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 Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import SocialProfileLinks from 'dashboard/components-next/social-profiles/SocialProfileLinks.vue';
import { useCompaniesStore } from 'dashboard/stores/companies';
const props = defineProps({
company: { type: Object, required: true },
openConversationsCount: { type: Number, default: 0 },
});
const emit = defineEmits(['edit', 'delete', 'showOpenConversations']);
const DESCRIPTION_CLAMP_LENGTH = 200;
const { t } = useI18n();
const companiesStore = useCompaniesStore();
const { isAdmin } = useAdmin();
const router = useRouter();
const { accountId } = useAccount();
const { shouldShowPaywall } = usePolicy();
const globalConfig = useMapGetter('globalConfig/get');
const upgradeDialogRef = ref(null);
const avatarPreviewUrl = ref('');
const isUploadingAvatar = ref(false);
const isDescriptionExpanded = ref(false);
const showActionsMenu = ref(false);
const uiFlags = computed(() => companiesStore.getUIFlags);
const showRefreshButton = computed(
() =>
isAdmin.value &&
globalConfig.value.isCompanyEnrichmentEnabled &&
Boolean(props.company.domain)
);
const displayName = computed(
() => props.company.name || t('COMPANIES.UNNAMED')
);
const isAvatarBusy = computed(
() =>
isUploadingAvatar.value ||
uiFlags.value.deletingAvatar ||
uiFlags.value.updatingItem
);
const isDescriptionLong = computed(
() => (props.company.description || '').length > DESCRIPTION_CLAMP_LENGTH
);
const socialProfiles = computed(
() => props.company.additionalAttributes?.social_profiles || {}
);
const phone = computed(() => props.company.additionalAttributes?.phone);
const enrichedAt = computed(() => {
const value = props.company.additionalAttributes?.enriched_at;
return value && dynamicTime(new Date(value).getTime() / 1000);
});
const goToBilling = () => {
upgradeDialogRef.value?.close();
router.push({
name: 'billing_settings_index',
params: { accountId: accountId.value },
});
};
const handleRefresh = async () => {
if (shouldShowPaywall(FEATURE_FLAGS.COMPANY_ENRICHMENT)) {
upgradeDialogRef.value?.open();
return;
}
try {
await companiesStore.enrich(props.company.id);
useAlert(t('COMPANIES.DETAIL.PROFILE.MESSAGES.REFRESH_SUCCESS'));
} catch (error) {
// The account's flags can be stale; the server decides on the plan.
if (error.response?.status === 403) {
upgradeDialogRef.value?.open();
return;
}
useAlert(
error.response?.data?.error ||
t('COMPANIES.DETAIL.PROFILE.MESSAGES.REFRESH_ERROR')
);
}
};
const menuItems = computed(() =>
isAdmin.value
? [
{
label: t('COMPANIES.DETAIL.DELETE.BUTTON'),
action: 'delete',
value: 'delete',
icon: 'i-lucide-trash-2',
},
]
: []
);
watch(
() => [props.company.id, props.company.avatarUrl],
() => {
avatarPreviewUrl.value = '';
isDescriptionExpanded.value = false;
}
);
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 handleDelete = () => {
showActionsMenu.value = false;
emit('delete');
};
</script>
<template>
<header class="flex flex-col gap-4">
<div class="flex items-start gap-4">
<Avatar
:name="displayName"
:src="avatarPreviewUrl || company.avatarUrl || ''"
:size="48"
:allow-upload="!isAvatarBusy"
class="shrink-0"
hide-offline-status
@upload="handleAvatarUpload"
@delete="handleAvatarDelete"
/>
<div class="flex flex-col flex-1 min-w-0 gap-1.5">
<div class="flex flex-wrap items-center gap-x-2.5 gap-y-1">
<h1 class="break-words text-heading-1 text-n-slate-12">
{{ displayName }}
</h1>
<button
v-if="openConversationsCount"
type="button"
class="inline-flex items-center gap-1.5 px-1.5 py-px rounded-md text-label-small bg-n-amber-3 text-n-amber-11 hover:bg-n-amber-4"
@click="emit('showOpenConversations')"
>
<span class="rounded-full size-1.5 bg-n-amber-9" />
{{
t(
'COMPANIES.DETAIL.HEADER.OPEN_COUNT',
{ count: openConversationsCount },
openConversationsCount
)
}}
</button>
</div>
<div class="flex flex-wrap items-center text-sm gap-x-3 gap-y-1">
<a
v-if="company.domain"
:href="`https://${company.domain}`"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-n-slate-11 hover:text-n-slate-12"
>
{{ company.domain }}
<span class="i-lucide-arrow-up-right size-3.5" />
</a>
<a
v-if="phone"
:href="`tel:${phone}`"
class="inline-flex items-center gap-1 text-n-slate-11 hover:text-n-slate-12"
>
<span class="i-lucide-phone size-3.5" />
<span dir="ltr">{{ phone }}</span>
</a>
<span
v-if="
Object.keys(socialProfiles).length && (company.domain || phone)
"
class="w-px h-3.5 bg-n-weak max-sm:hidden"
/>
<SocialProfileLinks :profiles="socialProfiles" />
</div>
</div>
<div class="flex items-center gap-1 shrink-0">
<Button
v-if="showRefreshButton"
v-tooltip.top="
enrichedAt
? t('COMPANIES.DETAIL.HEADER.REFRESH_WITH_TIME', {
time: enrichedAt,
})
: t('COMPANIES.DETAIL.PROFILE.ACTIONS.REFRESH')
"
icon="i-lucide-refresh-cw"
variant="ghost"
color="slate"
size="sm"
:is-loading="uiFlags.enrichingItem"
@click="handleRefresh"
/>
<Button
v-tooltip.top="t('COMPANIES.DETAIL.HEADER.EDIT')"
icon="i-lucide-pencil"
variant="ghost"
color="slate"
size="sm"
@click="emit('edit')"
/>
<div
v-if="menuItems.length"
v-on-clickaway="() => (showActionsMenu = false)"
class="relative"
>
<Button
icon="i-lucide-ellipsis"
variant="ghost"
color="slate"
size="sm"
@click="showActionsMenu = !showActionsMenu"
/>
<DropdownMenu
v-if="showActionsMenu"
:menu-items="menuItems"
class="mt-1 end-0 w-48 top-full"
@action="handleDelete"
/>
</div>
</div>
</div>
<div
v-if="company.description"
class="flex flex-col items-start max-w-4xl gap-1"
>
<p
class="mb-0 text-body-main text-n-slate-11"
:class="{ 'line-clamp-2': !isDescriptionExpanded }"
>
{{ company.description }}
</p>
<button
v-if="isDescriptionLong"
type="button"
class="p-0 text-label-small text-n-slate-11 hover:text-n-slate-12"
@click="isDescriptionExpanded = !isDescriptionExpanded"
>
{{
isDescriptionExpanded
? t('COMPANIES.DETAIL.HEADER.LESS')
: t('COMPANIES.DETAIL.HEADER.MORE')
}}
</button>
</div>
<p
v-if="isUploadingAvatar || uiFlags.deletingAvatar"
class="text-sm text-n-slate-11"
>
{{ t('COMPANIES.DETAIL.AVATAR.UPDATING') }}
</p>
<Dialog
ref="upgradeDialogRef"
:title="t('COMPANIES.DETAIL.ENRICHMENT_UPGRADE.TITLE')"
:description="t('COMPANIES.DETAIL.ENRICHMENT_UPGRADE.DESCRIPTION')"
:confirm-button-label="t('COMPANIES.DETAIL.ENRICHMENT_UPGRADE.UPGRADE')"
@confirm="goToBilling"
/>
</header>
</template>

View File

@@ -1,277 +0,0 @@
<script setup>
import { computed, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Button from 'dashboard/components-next/button/Button.vue';
import CompanyCustomAttributes from 'dashboard/components-next/Companies/CompanyDetail/CompanyCustomAttributes.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import SidePanel from 'dashboard/components-next/side-panel/SidePanel.vue';
import SocialProfileInput from 'dashboard/components-next/social-profiles/SocialProfileInput.vue';
import {
socialProfileHandle,
socialProfileUrl,
} from 'dashboard/components-next/social-profiles/socialProfiles';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import { useCompaniesStore } from 'dashboard/stores/companies';
const props = defineProps({
company: { type: Object, required: true },
});
const DESCRIPTION_MAX_LENGTH = 1000;
const SOCIAL_NETWORKS = [
'linkedin',
'x',
'facebook',
'instagram',
'github',
'youtube',
];
const { t } = useI18n();
const companiesStore = useCompaniesStore();
const panelRef = ref(null);
const form = reactive({
name: '',
domain: '',
description: '',
industry: '',
employees: '',
city: '',
country: '',
phone: '',
socialProfiles: {},
});
const initialDetails = ref({});
const isUpdating = computed(() => companiesStore.getUIFlags.updatingItem);
const isFormInvalid = computed(() => !form.name.trim());
const socialLabels = computed(() => ({
linkedin: t('COMPANIES.DETAIL.EDIT.SOCIAL.LINKEDIN'),
x: t('COMPANIES.DETAIL.EDIT.SOCIAL.X'),
facebook: t('COMPANIES.DETAIL.EDIT.SOCIAL.FACEBOOK'),
instagram: t('COMPANIES.DETAIL.EDIT.SOCIAL.INSTAGRAM'),
github: t('COMPANIES.DETAIL.EDIT.SOCIAL.GITHUB'),
youtube: t('COMPANIES.DETAIL.EDIT.SOCIAL.YOUTUBE'),
}));
// Editing industry or employees replaces the enriched value shown and clears its counterpart.
const readDetails = attributes => ({
industry: attributes.sub_industry || attributes.industry || '',
employees: String(
attributes.employee_count || attributes.employee_count_range || ''
),
city: attributes.city || '',
country: attributes.country || '',
phone: attributes.phone || '',
});
const open = () => {
const attributes = props.company.additionalAttributes || {};
const socialProfiles = attributes.social_profiles || {};
initialDetails.value = readDetails(attributes);
Object.assign(form, {
name: props.company.name || '',
domain: props.company.domain || '',
description: props.company.description || '',
...initialDetails.value,
socialProfiles: {
...Object.fromEntries(
SOCIAL_NETWORKS.map(network => [
network,
socialProfileHandle(network, socialProfiles[network]),
])
),
x: socialProfileHandle('x', socialProfiles.x || socialProfiles.twitter),
},
});
panelRef.value?.open();
};
const close = () => panelRef.value?.close();
const changed = field =>
form[field].trim() !== initialDetails.value[field].trim();
const buildAdditionalAttributes = () => {
const attributes = { ...(props.company.additionalAttributes || {}) };
const assign = (key, value) => {
if (value) attributes[key] = value;
else delete attributes[key];
};
if (changed('industry')) {
assign('industry', form.industry.trim());
delete attributes.sub_industry;
}
if (changed('employees')) {
assign('employee_count_range', form.employees.trim());
delete attributes.employee_count;
}
if (changed('country')) delete attributes.country_code;
assign('city', form.city.trim());
assign('country', form.country.trim());
assign('phone', form.phone.trim());
const socialProfiles = { ...attributes.social_profiles };
delete socialProfiles.twitter;
Object.entries(form.socialProfiles).forEach(([network, handle]) => {
const url = socialProfileUrl(network, handle);
if (url) socialProfiles[network] = url;
else delete socialProfiles[network];
});
delete attributes.social_profiles;
if (Object.keys(socialProfiles).length) {
attributes.social_profiles = socialProfiles;
}
return attributes;
};
const handleConfirm = async () => {
if (isFormInvalid.value) return;
try {
await companiesStore.update({
id: props.company.id,
name: form.name.trim(),
domain: form.domain.trim() || null,
description: form.description.trim() || null,
additionalAttributes: buildAdditionalAttributes(),
});
useAlert(t('COMPANIES.DETAIL.PROFILE.MESSAGES.UPDATE_SUCCESS'));
close();
} catch (error) {
useAlert(
error.message || t('COMPANIES.DETAIL.PROFILE.MESSAGES.UPDATE_ERROR')
);
}
};
defineExpose({ open });
</script>
<template>
<SidePanel
ref="panelRef"
width="2xl"
:title="t('COMPANIES.DETAIL.EDIT.TITLE')"
>
<div class="flex flex-col gap-8">
<section class="flex flex-col gap-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Input
v-model="form.name"
:label="t('COMPANIES.DETAIL.PROFILE.FIELDS.NAME')"
:disabled="isUpdating"
autofocus
/>
<Input
v-model="form.domain"
:label="t('COMPANIES.DETAIL.PROFILE.FIELDS.DOMAIN')"
:placeholder="t('COMPANIES.DETAIL.EDIT.PLACEHOLDERS.DOMAIN')"
:disabled="isUpdating"
/>
</div>
<div class="flex flex-col gap-1">
<label class="text-label text-n-slate-12">
{{ t('COMPANIES.DETAIL.EDIT.DESCRIPTION') }}
</label>
<TextArea
v-model="form.description"
:placeholder="t('COMPANIES.DETAIL.PROFILE.DESCRIPTION_PLACEHOLDER')"
:disabled="isUpdating"
:max-length="DESCRIPTION_MAX_LENGTH"
class="w-full"
show-character-count
auto-height
/>
</div>
</section>
<section class="flex flex-col gap-4">
<h4 class="mb-0 text-heading-2 text-n-slate-12">
{{ t('COMPANIES.DETAIL.EDIT.DETAILS') }}
</h4>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Input
v-model="form.industry"
:label="t('COMPANIES.DETAIL.FACTS.INDUSTRY')"
:placeholder="t('COMPANIES.DETAIL.EDIT.PLACEHOLDERS.INDUSTRY')"
:disabled="isUpdating"
/>
<Input
v-model="form.employees"
:label="t('COMPANIES.DETAIL.FACTS.SIZE')"
:placeholder="t('COMPANIES.DETAIL.EDIT.PLACEHOLDERS.EMPLOYEES')"
:disabled="isUpdating"
/>
<Input
v-model="form.city"
:label="t('COMPANIES.DETAIL.EDIT.CITY')"
:disabled="isUpdating"
/>
<Input
v-model="form.country"
:label="t('COMPANIES.DETAIL.EDIT.COUNTRY')"
:disabled="isUpdating"
/>
<Input
v-model="form.phone"
:label="t('COMPANIES.DETAIL.EDIT.PHONE')"
type="tel"
:disabled="isUpdating"
/>
</div>
</section>
<section class="flex flex-col gap-4">
<h4 class="mb-0 text-heading-2 text-n-slate-12">
{{ t('COMPANIES.DETAIL.EDIT.SOCIAL.TITLE') }}
</h4>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<SocialProfileInput
v-for="network in SOCIAL_NETWORKS"
:key="network"
v-model="form.socialProfiles[network]"
:network="network"
:label="socialLabels[network]"
:placeholder="t('COMPANIES.DETAIL.EDIT.PLACEHOLDERS.USERNAME')"
:disabled="isUpdating"
/>
</div>
</section>
<section class="flex flex-col gap-4">
<h4 class="mb-0 text-heading-2 text-n-slate-12">
{{ t('COMPANIES.DETAIL.ATTRIBUTES.TITLE') }}
</h4>
<CompanyCustomAttributes :company="company" />
</section>
</div>
<template #footer>
<div class="flex items-center justify-end w-full gap-3">
<Button
:label="t('DIALOG.BUTTONS.CANCEL')"
variant="faded"
color="slate"
type="button"
@click="close"
/>
<Button
:label="t('COMPANIES.DETAIL.EDIT.SAVE')"
color="blue"
type="button"
:disabled="isFormInvalid || isUpdating"
:is-loading="isUpdating"
@click="handleConfirm"
/>
</div>
</template>
</SidePanel>
</template>

View File

@@ -1,119 +0,0 @@
<script setup>
import { computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { dateFormat } from 'shared/helpers/timeHelper';
import { getCompanyProfile } from 'dashboard/components-next/Companies/helpers/companyHelper';
import Flag from 'dashboard/components-next/flag/Flag.vue';
const props = defineProps({
company: { type: Object, required: true },
});
// Padded to full rows so the gap-based hairlines never show an empty grey cell.
const COLUMNS = 4;
const { t } = useI18n();
const store = useStore();
const companyAttributes = useMapGetter('attributes/getCompanyAttributes');
onMounted(() => store.dispatch('attributes/get'));
const formatCustomValue = (attribute, value) => {
if (attribute.attributeDisplayType === 'checkbox') {
return value
? t('COMPANIES.DETAIL.FACTS.YES')
: t('COMPANIES.DETAIL.FACTS.NO');
}
if (attribute.attributeDisplayType === 'date')
return dateFormat(new Date(value).getTime() / 1000);
return String(value);
};
const customFacts = computed(() => {
const values = props.company.customAttributes || {};
return (companyAttributes.value || [])
.filter(
({ attributeKey }) =>
![undefined, null, ''].includes(values[attributeKey])
)
.map(attribute => ({
key: `custom-${attribute.attributeKey}`,
icon: 'i-lucide-tag',
label: attribute.attributeDisplayName,
value: formatCustomValue(attribute, values[attribute.attributeKey]),
}));
});
const facts = computed(() => {
const { industry, employees, location, fullLocation, countryCode } =
getCompanyProfile(props.company.additionalAttributes);
return [
{
key: 'industry',
icon: 'i-lucide-building-2',
label: t('COMPANIES.DETAIL.FACTS.INDUSTRY'),
value: industry,
},
{
key: 'size',
icon: 'i-lucide-users',
label: t('COMPANIES.DETAIL.FACTS.SIZE'),
value: employees,
},
{
key: 'location',
icon: 'i-lucide-map-pin',
label: t('COMPANIES.DETAIL.FACTS.LOCATION'),
value: location,
title: fullLocation,
countryCode,
},
{
key: 'added',
icon: 'i-lucide-calendar',
label: t('COMPANIES.DETAIL.FACTS.ADDED'),
value: props.company.createdAt && dateFormat(props.company.createdAt),
},
...customFacts.value,
];
});
const emptyCells = computed(
() => (COLUMNS - (facts.value.length % COLUMNS)) % COLUMNS
);
</script>
<template>
<div class="overflow-hidden border rounded-xl border-n-weak bg-n-weak">
<dl class="grid grid-cols-2 gap-px m-0 md:grid-cols-4">
<div
v-for="fact in facts"
:key="fact.key"
class="flex flex-col min-w-0 gap-1 px-4 py-3 bg-n-solid-2"
>
<dt class="flex items-center gap-1.5 text-label-small text-n-slate-10">
<span :class="fact.icon" class="size-3.5 shrink-0" />
{{ fact.label }}
</dt>
<dd
class="flex items-start gap-1.5 text-heading-3"
:class="fact.value ? 'text-n-slate-12' : 'text-n-slate-9'"
>
<Flag
v-if="fact.value && fact.countryCode"
:country="fact.countryCode"
class="mt-0.5 shrink-0"
/>
<span class="line-clamp-2" :title="fact.title">
{{ fact.value || t('COMPANIES.DETAIL.FACTS.UNKNOWN') }}
</span>
</dd>
</div>
<div v-for="n in emptyCells" :key="`empty-${n}`" class="bg-n-solid-2" />
</dl>
</div>
</template>

View File

@@ -0,0 +1,67 @@
<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>

View File

@@ -1,164 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { dateFormat } from 'shared/helpers/timeHelper';
import { groupByDay } from 'dashboard/components-next/Companies/helpers/companyHelper';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
notes: { type: Array, default: () => [] },
isLoading: { type: Boolean, default: false },
emptyMessage: { type: String, required: true },
hasMore: { type: Boolean, default: false },
highlight: { type: String, default: '' },
});
const emit = defineEmits(['loadMore']);
const NOTE_CLAMP_LENGTH = 240;
const SYSTEM_AUTHOR_ICON = 'i-lucide-settings';
const { t } = useI18n();
const router = useRouter();
const { accountId } = useAccount();
const { formatMessage } = useMessageFormatter();
const currentUser = useMapGetter('getCurrentUser');
const expandedNotes = ref(new Set());
const toggleNote = noteId => {
const next = new Set(expandedNotes.value);
if (next.has(noteId)) next.delete(noteId);
else next.add(noteId);
expandedNotes.value = next;
};
const escapeRegExp = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Wrap search matches in <mark>, touching only text between tags so markup stays intact.
const noteHtml = note => {
const html = formatMessage(note.content || '');
const term = props.highlight.trim();
if (!term) return html;
const pattern = new RegExp(`(${escapeRegExp(term)})`, 'gi');
return html
.split(/(<[^>]+>)/)
.map(part =>
part.startsWith('<') ? part : part.replace(pattern, '<mark>$1</mark>')
)
.join('');
};
const noteAuthor = note =>
note.user?.id === currentUser.value.id
? t('CONTACTS_LAYOUT.SIDEBAR.NOTES.YOU')
: note.user?.name || t('COMPANIES.DETAIL.ACTIVITY.SYSTEM_AUTHOR');
const noteContactName = note =>
note.contact?.name || t('COMPANIES.DETAIL.CONTACTS.UNNAMED_CONTACT');
const openContact = contactId => {
router.push({
name: 'contacts_edit',
params: { accountId: accountId.value, contactId },
});
};
const groups = computed(() => groupByDay(props.notes, 'createdAt', t));
</script>
<template>
<section class="flex flex-col gap-5">
<div v-if="isLoading && !notes.length" class="flex justify-center py-12">
<Spinner />
</div>
<p
v-else-if="!notes.length"
class="px-6 py-10 text-sm text-center border border-dashed rounded-xl border-n-weak text-n-slate-11"
>
{{ emptyMessage }}
</p>
<div v-else class="flex flex-col gap-6">
<div v-for="group in groups" :key="group.label" class="flex flex-col">
<h3 class="text-label-small text-n-slate-10">{{ group.label }}</h3>
<ol class="m-0 list-none divide-y ps-0 divide-n-weak">
<li v-for="note in group.items" :key="note.id">
<div class="flex items-start gap-4 py-4">
<Avatar
:name="noteAuthor(note)"
:src="note.user?.thumbnail"
:icon-name="note.user ? null : SYSTEM_AUTHOR_ICON"
:size="40"
hide-offline-status
class="shrink-0"
/>
<span class="flex flex-col flex-1 min-w-0 gap-1">
<span class="truncate text-heading-3 text-n-slate-12">
{{ noteAuthor(note) }}
</span>
<span
v-dompurify-html="noteHtml(note)"
class="text-n-slate-11 text-body-main [&_p]:m-0 [&_p+p]:mt-2 [&_mark]:bg-n-amber-4 [&_mark]:text-n-slate-12 [&_mark]:rounded-sm [&_mark]:px-0.5"
:class="{
'line-clamp-3': !highlight && !expandedNotes.has(note.id),
}"
/>
<button
v-if="
!highlight &&
(note.content || '').length > NOTE_CLAMP_LENGTH
"
type="button"
class="self-start p-0 text-label-small text-n-slate-11 hover:text-n-slate-12"
@click="toggleNote(note.id)"
>
{{
expandedNotes.has(note.id)
? t('COMPANIES.DETAIL.HEADER.LESS')
: t('COMPANIES.DETAIL.HEADER.MORE')
}}
</button>
<span class="flex items-center gap-1.5 text-xs text-n-slate-10">
<span class="leading-4 tabular-nums">
{{ dateFormat(note.createdAt, 'h:mm a') }}
</span>
<template v-if="note.contact">
<span class="rounded-full size-1 bg-n-slate-8" />
<button
type="button"
class="p-0 text-xs leading-4 truncate text-n-slate-10 hover:text-n-slate-12 hover:underline"
@click="openContact(note.contact.id)"
>
{{ noteContactName(note) }}
</button>
</template>
</span>
</span>
</div>
</li>
</ol>
</div>
</div>
<Button
v-if="hasMore && notes.length"
:label="t('COMPANIES.DETAIL.ACTIVITY.LOAD_MORE')"
variant="faded"
color="slate"
size="sm"
class="self-center"
:is-loading="isLoading"
@click="emit('loadMore')"
/>
</section>
</template>

View File

@@ -0,0 +1,123 @@
<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 { useExactTimestamp } from 'shared/composables/useExactTimestamp';
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 exactTimestamp = useExactTimestamp();
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
v-tooltip.top="{
content: exactTimestamp(note.createdAt),
delay: { show: 500, hide: 0 },
}"
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>

View File

@@ -1,181 +0,0 @@
<script setup>
import { computed, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useDebounceFn } from '@vueuse/core';
import { useAlert } from 'dashboard/composables';
import { useAccount } from 'dashboard/composables/useAccount';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Flag from 'dashboard/components-next/flag/Flag.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import { useCompaniesStore } from 'dashboard/stores/companies';
const props = defineProps({
company: { type: Object, required: true },
query: { type: String, default: '' },
});
const SEARCH_DEBOUNCE_MS = 300;
// Matches Api::V1::Accounts::Companies::ContactsController::RESULTS_PER_PAGE
const PAGE_SIZE = 25;
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const { accountId } = useAccount();
const companiesStore = useCompaniesStore();
const contacts = computed(() => companiesStore.companyContacts);
const meta = computed(() => companiesStore.companyContactsMeta);
const isLoading = computed(() => companiesStore.getUIFlags.fetchingContacts);
const isRemoving = computed(() => companiesStore.getUIFlags.removingContact);
const page = computed(() => Number(meta.value.page || 1));
const matchCount = computed(() => Number(meta.value.totalCount || 0));
const fetchPage = targetPage => {
const search = props.query.trim() || undefined;
router.replace({
query: {
...route.query,
page: targetPage > 1 ? String(targetPage) : undefined,
q: search,
},
});
return companiesStore.getCompanyContacts(
props.company.id,
targetPage,
search
);
};
onMounted(() => fetchPage(Number(route.query.page) || 1));
const debouncedSearch = useDebounceFn(() => fetchPage(1), SEARCH_DEBOUNCE_MS);
watch(() => props.query, debouncedSearch);
const contactName = contact =>
contact.name ||
contact.email ||
t('COMPANIES.DETAIL.CONTACTS.UNNAMED_CONTACT');
const contactMeta = contact => {
const { city, country, countryCode } = contact.additionalAttributes || {};
const location = [city, country].filter(Boolean).join(', ');
return [
contact.email && { key: 'email', label: contact.email },
contact.phoneNumber && { key: 'phone', label: contact.phoneNumber },
location && { key: 'location', label: location, countryCode },
].filter(Boolean);
};
const openContact = contactId => {
router.push({
name: 'contacts_edit',
params: { accountId: accountId.value, contactId },
});
};
const removeContact = async contact => {
const targetPage =
page.value > 1 && contacts.value.length === 1 ? page.value - 1 : page.value;
try {
await companiesStore.removeContactFromCompany(
props.company.id,
contact.id,
targetPage
);
if (props.query.trim()) await fetchPage(targetPage);
useAlert(t('COMPANIES.DETAIL.CONTACTS.MESSAGES.REMOVE_SUCCESS'));
} catch {
useAlert(t('COMPANIES.DETAIL.CONTACTS.MESSAGES.REMOVE_ERROR'));
}
};
</script>
<template>
<section class="flex flex-col gap-4">
<div
v-if="isLoading && !contacts.length"
class="flex justify-center py-12 text-n-slate-11"
>
<Spinner />
</div>
<div v-else-if="contacts.length" class="divide-y divide-n-weak">
<div
v-for="contact in contacts"
:key="contact.id"
class="flex items-start justify-between gap-4 py-4"
>
<div class="flex items-start flex-1 min-w-0 gap-4">
<Avatar
:name="contactName(contact)"
:src="contact.thumbnail"
:size="40"
hide-offline-status
/>
<div class="flex flex-col flex-1 min-w-0 gap-1">
<button
type="button"
class="block p-0 truncate text-heading-3 text-start text-n-slate-12 hover:text-n-blue-11"
@click="openContact(contact.id)"
>
{{ contactName(contact) }}
</button>
<div v-if="contactMeta(contact).length" class="overflow-hidden">
<div class="flex flex-wrap gap-y-1 -ms-[calc(0.75rem+1px)]">
<span
v-for="item in contactMeta(contact)"
:key="item.key"
class="inline-flex items-center min-w-0 gap-1.5 text-body-main text-n-slate-11 max-w-full me-3 before:content-[''] before:w-px before:h-3 before:shrink-0 before:bg-n-slate-6 before:me-1.5"
:title="item.label"
>
<Flag
v-if="item.countryCode"
:country="item.countryCode"
class="size-3.5 shrink-0"
/>
<span class="truncate">{{ item.label }}</span>
</span>
</div>
</div>
</div>
</div>
<Button
v-tooltip.top="t('COMPANIES.DETAIL.CONTACTS.ACTIONS.REMOVE')"
icon="i-lucide-unlink"
slate
sm
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
:disabled="isRemoving"
@click="removeContact(contact)"
/>
</div>
</div>
<p
v-else
class="px-4 py-10 text-sm text-center border border-dashed rounded-xl border-n-weak text-n-slate-11"
>
{{
query.trim()
? t('COMPANIES.DETAIL.PEOPLE.NO_MATCHES')
: t('COMPANIES.DETAIL.PEOPLE.EMPTY')
}}
</p>
<PaginationFooter
v-if="matchCount > PAGE_SIZE"
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="page"
:total-items="matchCount"
:items-per-page="PAGE_SIZE"
class="!px-0 before:hidden bg-transparent"
@update:current-page="fetchPage"
/>
</section>
</template>

View File

@@ -0,0 +1,221 @@
<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 { useExactTimestamp } from 'shared/composables/useExactTimestamp';
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 exactTimestamp = useExactTimestamp();
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 && {
key: 'createdAt',
label: t('COMPANIES.DETAIL.PROFILE.CREATED_AT', {
date: dynamicTime(createdAt),
}),
exactTime: exactTimestamp(createdAt),
},
lastActivityAt && {
key: 'lastActivityAt',
label: t('COMPANIES.DETAIL.PROFILE.LAST_ACTIVE', {
date: dynamicTime(lastActivityAt),
}),
exactTime: exactTimestamp(lastActivityAt),
},
].filter(Boolean);
});
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"
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">
<template v-for="(item, index) in summary" :key="item.key">
<span v-if="index">&nbsp;•&nbsp;</span>
<span
v-tooltip.top="{
content: item.exactTime,
delay: { show: 500, hide: 0 },
}"
>
{{ item.label }}
</span>
</template>
</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>

View File

@@ -1,30 +0,0 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Input from 'dashboard/components-next/input/Input.vue';
defineProps({
placeholder: { type: String, required: true },
});
const query = defineModel({ type: String, default: '' });
</script>
<template>
<Input
v-model="query"
type="search"
:placeholder="placeholder"
size="sm"
:custom-input-class="[
'[&:not(.focus)]:!border-transparent bg-n-alpha-2 dark:bg-n-solid-1 !ps-8',
]"
class="w-full sm:w-56"
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute -translate-y-1/2 text-n-slate-11 size-4 top-1/2 start-2"
/>
</template>
</Input>
</template>

View File

@@ -1,43 +0,0 @@
import { fromUnixTime, isToday, isYesterday } from 'date-fns';
import { dateFormat } from 'shared/helpers/timeHelper';
export const getCompanyProfile = additionalAttributes => {
const {
industry,
sub_industry: subIndustry,
employee_count_range: employeeCountRange,
employee_count: employeeCount,
city,
country,
country_code: countryCode,
} = additionalAttributes || {};
return {
industry: subIndustry || industry,
employees: employeeCount
? employeeCount.toLocaleString()
: employeeCountRange,
location: [city, city ? countryCode || country : country]
.filter(Boolean)
.join(', '),
fullLocation: [city, country].filter(Boolean).join(', '),
countryCode,
};
};
export const groupByDay = (items, timeKey, t) => {
const dayLabel = time => {
const date = fromUnixTime(time);
if (isToday(date)) return t('COMPANIES.DETAIL.ACTIVITY.TODAY');
if (isYesterday(date)) return t('COMPANIES.DETAIL.ACTIVITY.YESTERDAY');
return dateFormat(time, 'EEEE, MMM d');
};
return items.reduce((groups, item) => {
const label = dayLabel(item[timeKey]);
const group = groups.at(-1);
if (group?.label === label) group.items.push(item);
else groups.push({ label, items: [item] });
return groups;
}, []);
};

View File

@@ -1,98 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { getCompanyProfile, groupByDay } from '../companyHelper';
describe('companyHelper', () => {
describe('getCompanyProfile', () => {
it('prefers the most specific industry and the exact employee count', () => {
expect(
getCompanyProfile({
industry: 'Financials',
sub_industry: 'Payment Processing',
employee_count_range: '5001-10000',
employee_count: 8500,
city: 'South San Francisco',
country: 'United States',
country_code: 'US',
})
).toEqual({
industry: 'Payment Processing',
employees: (8500).toLocaleString(),
location: 'South San Francisco, US',
fullLocation: 'South San Francisco, United States',
countryCode: 'US',
});
});
it('falls back to the industry and the employee range', () => {
const profile = getCompanyProfile({
industry: 'Financials',
employee_count_range: '51-200',
});
expect(profile.industry).toBe('Financials');
expect(profile.employees).toBe('51-200');
});
it('shows the country name when there is no city', () => {
const profile = getCompanyProfile({
country: 'India',
country_code: 'IN',
});
expect(profile.location).toBe('India');
expect(profile.fullLocation).toBe('India');
});
it('returns empty values when attributes are missing', () => {
expect(getCompanyProfile(undefined)).toEqual({
industry: undefined,
employees: undefined,
location: '',
fullLocation: '',
countryCode: undefined,
});
});
});
describe('groupByDay', () => {
const t = key => key;
const toUnix = value => Math.floor(new Date(value).getTime() / 1000);
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-10-03T12:00:00Z'));
});
afterEach(() => {
vi.useRealTimers();
});
it('groups consecutive items by day with today and yesterday labels', () => {
const items = [
{ id: 1, timestamp: toUnix('2026-10-03T10:00:00Z') },
{ id: 2, timestamp: toUnix('2026-10-03T08:00:00Z') },
{ id: 3, timestamp: toUnix('2026-10-02T09:00:00Z') },
{ id: 4, timestamp: toUnix('2026-09-27T09:00:00Z') },
];
expect(groupByDay(items, 'timestamp', t)).toEqual([
{
label: 'COMPANIES.DETAIL.ACTIVITY.TODAY',
items: [items[0], items[1]],
},
{ label: 'COMPANIES.DETAIL.ACTIVITY.YESTERDAY', items: [items[2]] },
{ label: 'Sunday, Sep 27', items: [items[3]] },
]);
});
it('reads the time from the given key', () => {
const notes = [{ id: 1, createdAt: toUnix('2026-10-03T10:00:00Z') }];
expect(groupByDay(notes, 'createdAt', t)[0].items).toEqual(notes);
});
it('returns no groups for an empty list', () => {
expect(groupByDay([], 'timestamp', t)).toEqual([]);
});
});
});

View File

@@ -23,8 +23,6 @@ const props = defineProps({
showAssignee: { type: Boolean, default: false },
showInboxName: { type: Boolean, default: false },
isInboxView: { type: Boolean, default: false },
selectable: { type: Boolean, default: true },
conversationFirst: { type: Boolean, default: false },
});
const emit = defineEmits([
@@ -33,30 +31,6 @@ const emit = defineEmits([
'click',
'contextmenu',
]);
const CONVERSATION_FIRST_ORDER = {
id: 'order-1 max-lg:w-auto',
avatar: 'order-2',
name: 'order-3 max-lg:basis-12 max-lg:grow max-lg:max-w-32',
dividerAfterName: 'order-4',
priority: 'order-5',
assignee: 'order-6',
status: 'order-7',
// Below lg this divider is the line break that moves the message to a second line.
time: 'max-lg:order-7 max-lg:ms-auto',
dividerAfterStatus: 'order-8 max-lg:basis-full max-lg:h-0',
content: 'order-9 max-lg:flex-1 max-lg:min-w-0',
labels: 'max-lg:order-9 max-lg:w-24',
sla: 'max-lg:order-9',
dividerBeforeInbox: 'order-10 ms-auto max-lg:hidden',
inbox: 'order-11 max-lg:hidden',
};
const CONVERSATION_FIRST_DIVIDERS = [
'dividerAfterName',
'dividerAfterStatus',
'dividerBeforeInbox',
];
const orderClass = key =>
props.conversationFirst ? CONVERSATION_FIRST_ORDER[key] : undefined;
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const showLabelsSection = computed(() => props.chat.labels?.length > 0);
@@ -104,46 +78,23 @@ const selectedModel = computed({
'hover:bg-n-alpha-1': !isActiveChat && !selected,
'grid-cols-[minmax(0,2fr)_minmax(0,1fr)]': showLabelsSection,
'grid-cols-[minmax(0,2fr)_max-content]': !showLabelsSection,
'max-lg:flex max-lg:flex-wrap max-lg:gap-x-2 max-lg:gap-y-1 max-lg:h-auto max-lg:py-2.5':
conversationFirst,
}"
@click="$emit('click', $event)"
@contextmenu="$emit('contextmenu', $event)"
>
<!-- LEFT SECTION -->
<div
class="flex items-center gap-2 min-w-0 flex-1"
:class="{ 'max-lg:contents': conversationFirst }"
>
<template v-if="conversationFirst">
<div
v-for="divider in CONVERSATION_FIRST_DIVIDERS"
:key="divider"
class="w-px h-3 bg-n-slate-6 flex-shrink-0"
:class="orderClass(divider)"
/>
</template>
<div
v-if="selectable"
class="flex items-center justify-center flex-shrink-0"
@click.stop
>
<div class="flex items-center gap-2 min-w-0 flex-1">
<div class="flex items-center justify-center flex-shrink-0" @click.stop>
<Checkbox v-model="selectedModel" />
</div>
<div v-if="selectable" class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
<div
class="w-4 flex items-center justify-center flex-shrink-0"
:class="orderClass('priority')"
>
<div class="w-4 flex items-center justify-center flex-shrink-0">
<CardPriorityIcon :priority="chat.priority" show-empty />
</div>
<div
class="w-4 flex items-center justify-center flex-shrink-0"
:class="orderClass('assignee')"
>
<div class="w-4 flex items-center justify-center flex-shrink-0">
<Avatar
v-if="showAssignee && assignee.name"
v-tooltip.top="{
@@ -163,28 +114,18 @@ const selectedModel = computed({
/>
</div>
<div
class="w-4 flex items-center justify-center flex-shrink-0"
:class="orderClass('status')"
>
<div class="w-4 flex items-center justify-center flex-shrink-0">
<CardStatusIcon :status="chat.status" show-empty />
</div>
<div
v-if="!conversationFirst"
class="w-px h-3 bg-n-slate-6 flex-shrink-0"
/>
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
<div
v-if="!isInboxView && showInboxName"
class="w-20 flex-shrink-0"
:class="orderClass('inbox')"
>
<div v-if="!isInboxView && showInboxName" class="w-20 flex-shrink-0">
<InboxName v-if="showInboxName" :inbox="inbox" class="min-w-0" />
</div>
<div
v-if="!isInboxView && showInboxName && !conversationFirst"
v-if="!isInboxView && showInboxName"
class="w-px h-3 bg-n-slate-6 flex-shrink-0"
/>
@@ -194,7 +135,6 @@ const selectedModel = computed({
delay: { show: 500, hide: 0 },
}"
class="h-6 flex items-center gap-1 max-w-20 w-full min-w-0 flex-shrink-0"
:class="orderClass('id')"
>
<Icon
icon="i-woot-hash"
@@ -206,7 +146,6 @@ const selectedModel = computed({
</div>
<CardAvatar
:class="orderClass('avatar')"
:contact="currentContact"
:selected="false"
:enable-selection="false"
@@ -215,13 +154,11 @@ const selectedModel = computed({
<h4
class="text-heading-3 my-0 capitalize truncate text-n-slate-12 font-medium w-32 flex-shrink-0"
:class="orderClass('name')"
>
{{ currentContact.name }}
</h4>
<CardContent
:class="orderClass('content')"
:last-message="lastMessageInChat"
:voice-call-status="voiceCallData.status"
:voice-call-direction="voiceCallData.direction"
@@ -231,15 +168,8 @@ const selectedModel = computed({
</div>
<!-- RIGHT SECTION -->
<div
class="flex items-center justify-end gap-1.5 flex-shrink-0"
:class="{ 'max-lg:contents': conversationFirst }"
>
<div
v-if="showLabelsSection"
class="min-w-0 w-full"
:class="orderClass('labels')"
>
<div class="flex items-center justify-end gap-1.5 flex-shrink-0">
<div v-if="showLabelsSection" class="min-w-0 w-full">
<CardLabels
:labels="chat.labels"
disable-toggle
@@ -247,18 +177,11 @@ const selectedModel = computed({
/>
</div>
<div
v-if="hasSlaPolicyId"
class="flex-shrink-0"
:class="orderClass('sla')"
>
<div v-if="hasSlaPolicyId" class="flex-shrink-0">
<SLACardLabel ref="slaCardLabel" :chat="chat" />
</div>
<div
class="flex-shrink-0 w-[4.375rem] text-end"
:class="orderClass('time')"
>
<div class="flex-shrink-0 w-[4.375rem] text-end">
<TimeAgo
:conversation-id="chat.id"
:last-activity-timestamp="chat.timestamp"

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