Compare commits
18 Commits
codex/bulk
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc60ffc312 | ||
|
|
ab0182a9eb | ||
|
|
d79c052624 | ||
|
|
5f21903a7b | ||
|
|
f9cbacaa9e | ||
|
|
225b2af051 | ||
|
|
ccbda8b37f | ||
|
|
a296aa9191 | ||
|
|
52a6adedf8 | ||
|
|
f1c9e4831b | ||
|
|
af1e939467 | ||
|
|
ed7b122d27 | ||
|
|
b0559b0234 | ||
|
|
1202b5a2da | ||
|
|
12ce7b73cc | ||
|
|
351c96a213 | ||
|
|
843385ff3a | ||
|
|
80086e5afa |
@@ -29,3 +29,9 @@ 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
|
||||
|
||||
@@ -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
|
||||
# CONVERSATION_MONITORS_DAILY_TOKEN_LIMIT=20000000
|
||||
# Each account can make 500,000 monitor calls per UTC day and 10,000,000 per UTC month.
|
||||
# To use a dedicated URL for help center pages
|
||||
# HELPCENTER_URL=http://0.0.0.0:3000
|
||||
|
||||
|
||||
41
.github/workflows/migration_version_check.yml
vendored
Normal file
41
.github/workflows/migration_version_check.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
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
|
||||
@@ -44,6 +44,7 @@
|
||||
## 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.
|
||||
|
||||
@@ -2,4 +2,3 @@
|
||||
//= link administrate/application.css
|
||||
//= link administrate/application.js
|
||||
//= link administrate-field-active_storage/application.css
|
||||
//= link secretField.js
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,32 +1,5 @@
|
||||
@charset 'utf-8';
|
||||
|
||||
@import 'reset/normalize';
|
||||
|
||||
@import 'utilities/variables';
|
||||
@import 'utilities/text-color';
|
||||
|
||||
// The super admin is styled by app/javascript/entrypoints/superadmin.scss.
|
||||
// Sprockets only serves the selectize base styles its pickers need.
|
||||
@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';
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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%;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
.app-container {
|
||||
align-items: stretch;
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 100rem;
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
.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%;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
$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%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
.form-actions {
|
||||
margin-left: calc(15% + 1.25rem);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
@mixin administrate-clearfix {
|
||||
&::after {
|
||||
clear: both;
|
||||
content: '';
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
@mixin data-label {
|
||||
color: $hint-grey;
|
||||
font-size: 0.8em;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.0357em;
|
||||
position: relative;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
// 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;
|
||||
@@ -1,448 +0,0 @@
|
||||
/*! 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;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
.text-color-red {
|
||||
color: $alert-color;
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
// 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;
|
||||
@@ -70,12 +70,17 @@ 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.save!
|
||||
@user
|
||||
end
|
||||
end
|
||||
|
||||
@@ -31,15 +31,6 @@ 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,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Api::V1::Accounts::Companies::ContactsController < Api::V1::Accounts::Companies::BaseController
|
||||
RESULTS_PER_PAGE = 15
|
||||
RESULTS_PER_PAGE = 25
|
||||
CONTACT_SEARCH_QUERY = [
|
||||
'contacts.name ILIKE :search',
|
||||
'contacts.email ILIKE :search',
|
||||
@@ -13,7 +13,9 @@ class Api::V1::Accounts::Companies::ContactsController < Api::V1::Accounts::Comp
|
||||
before_action :fetch_contact, only: [:destroy]
|
||||
|
||||
def index
|
||||
@contacts = fetch_contacts(@company.contacts.order(:name, :id))
|
||||
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_count = @contacts.total_count
|
||||
end
|
||||
|
||||
|
||||
@@ -1,15 +1,38 @@
|
||||
class Api::V1::Accounts::Companies::ConversationsController < Api::V1::Accounts::Companies::BaseController
|
||||
before_action :authorize_company_read!
|
||||
before_action :set_counts
|
||||
|
||||
def index
|
||||
conversations = Current.account.conversations.includes(
|
||||
:assignee, :contact, :inbox, :taggings
|
||||
).preload(ai_assignee: { avatar_attachment: [:blob] }).where(contact_id: @company.contacts.select(:id))
|
||||
@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 = Conversations::PermissionFilterService.new(
|
||||
conversations,
|
||||
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)),
|
||||
Current.user,
|
||||
Current.account
|
||||
).perform.order(last_activity_at: :desc).limit(20)
|
||||
).perform
|
||||
end
|
||||
|
||||
def set_counts
|
||||
@all_count = company_conversations.count
|
||||
@open_count = company_conversations.open.count
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
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))
|
||||
.latest
|
||||
.includes(:contact, :user)
|
||||
.limit(20)
|
||||
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)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,12 +6,13 @@ 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 = 25
|
||||
RESULTS_PER_PAGE = 50
|
||||
|
||||
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)
|
||||
@@ -83,6 +84,14 @@ 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,
|
||||
|
||||
@@ -3,9 +3,10 @@ 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 :inbox, :contact, :contact_inbox, only: [:create]
|
||||
before_action :validate_assignment_targets, :inbox, :contact, :contact_inbox, only: [:create]
|
||||
|
||||
ATTACHMENT_RESULTS_PER_PAGE = 100
|
||||
|
||||
|
||||
@@ -10,14 +10,17 @@ class Api::V1::Accounts::Integrations::AppsController < Api::V1::Accounts::BaseC
|
||||
private
|
||||
|
||||
def fetch_apps
|
||||
@apps = Integrations::App.all.select { |app| app.active?(Current.account) }
|
||||
@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
|
||||
end
|
||||
|
||||
def fetch_app
|
||||
@app = Integrations::App.find(id: params[:id])
|
||||
return unless @app&.id == 'shopify'
|
||||
return unless %w[shopify stripe].include?(@app&.id)
|
||||
|
||||
# Keep direct Shopify lookups subject to the same availability checks as the integrations list.
|
||||
# Keep direct lookups subject to the same availability checks as the integrations list.
|
||||
raise ActiveRecord::RecordNotFound unless @app.active?(Current.account)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,6 +2,7 @@ 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)
|
||||
@@ -32,6 +33,14 @@ 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
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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
|
||||
@@ -18,6 +18,7 @@ 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
|
||||
@@ -25,14 +26,8 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
end
|
||||
|
||||
def create
|
||||
@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
|
||||
builder_class = account_params[:shopify_pending_install_token].present? ? Shopify::SignupService : AccountBuilder
|
||||
@user, @account = builder_class.new(**account_builder_params).perform
|
||||
enqueue_branding_enrichment
|
||||
if @user
|
||||
# Authenticated users (dashboard "add account") and api_only signups
|
||||
@@ -77,6 +72,20 @@ 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?
|
||||
@@ -111,7 +120,22 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
end
|
||||
|
||||
def account_params
|
||||
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :user_full_name)
|
||||
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
|
||||
end
|
||||
|
||||
def custom_attributes_params
|
||||
@@ -129,7 +153,14 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
end
|
||||
|
||||
def check_signup_enabled
|
||||
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_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)
|
||||
end
|
||||
|
||||
def api_only_signup?
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
class Api::V1::Integrations::WebhooksController < ApplicationController
|
||||
SIGNATURE_TOLERANCE = 5.minutes.to_i
|
||||
|
||||
prepend_before_action :verify_slack_signature!, only: [:create]
|
||||
|
||||
def create
|
||||
@@ -13,31 +11,12 @@ class Api::V1::Integrations::WebhooksController < ApplicationController
|
||||
|
||||
# Skip (rather than reject) when no secret is configured, so existing installs keep working.
|
||||
def verify_slack_signature!
|
||||
secret = slack_signing_secret
|
||||
if secret.blank?
|
||||
if Integrations::Slack::SignatureVerifier.signing_secret.blank?
|
||||
Rails.logger.warn('[SLACK] SLACK_SIGNING_SECRET not configured; skipping webhook signature verification')
|
||||
return
|
||||
end
|
||||
|
||||
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)
|
||||
head :unauthorized unless Integrations::Slack::SignatureVerifier.valid?(request)
|
||||
end
|
||||
|
||||
# TODO: This is a temporary solution to permit all params for slack unfurling job.
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
# 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?
|
||||
|
||||
@@ -12,7 +19,36 @@ class Auth::ResendConfirmationsController < ActionController::API
|
||||
return head(:ok) unless email.is_a?(String)
|
||||
|
||||
user = User.from_email(email.strip.downcase)
|
||||
user&.send_confirmation_instructions unless user&.confirmed?
|
||||
send_confirmation_instructions(user) if user && !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
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
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
|
||||
@@ -28,6 +28,7 @@ class DashboardController < ActionController::Base
|
||||
DEPLOYMENT_ENV
|
||||
INSTALLATION_PRICING_PLAN
|
||||
CAPTAIN_TOOLS_MANIFEST_ENABLED
|
||||
CAPTAIN_TOOLS_CATALOG_URL
|
||||
].freeze
|
||||
|
||||
before_action :set_application_pack
|
||||
@@ -84,6 +85,7 @@ 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,
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
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 : sign_up_user
|
||||
@resource.present? ? sign_in_user(redirect_url: oauth_redirect_url, sso_account_id: oauth_context['sso_account_id']) : sign_up_user
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sign_in_user
|
||||
def sign_in_user(redirect_url: nil, sso_account_id: nil)
|
||||
# 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.
|
||||
@@ -21,7 +23,12 @@ 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_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
|
||||
)
|
||||
end
|
||||
|
||||
def sign_in_user_on_mobile
|
||||
@@ -42,18 +49,25 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
||||
|
||||
def sign_up_user
|
||||
return redirect_to login_page_url(error: 'no-account-found') unless account_signup_allowed?
|
||||
return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain?
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
def login_page_url(error: nil, email: nil, sso_auth_token: nil, redirect_url: nil, sso_account_id: nil)
|
||||
frontend_url = omniauth_frontend_url
|
||||
params = { email: email, sso_auth_token: sso_auth_token }.compact
|
||||
params = { email: email, sso_auth_token: sso_auth_token, redirect_url: redirect_url, sso_account_id: sso_account_id }.compact
|
||||
params[:error] = error if error.present?
|
||||
|
||||
"#{frontend_url}/app/login?#{params.to_query}"
|
||||
@@ -63,8 +77,45 @@ 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?
|
||||
GlobalConfigService.account_signup_enabled?
|
||||
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
|
||||
end
|
||||
|
||||
def resource_class(_mapping = nil)
|
||||
@@ -84,13 +135,18 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
||||
end
|
||||
|
||||
def create_account_for_user
|
||||
@resource, @account = AccountBuilder.new(
|
||||
attributes = {
|
||||
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']
|
||||
).perform
|
||||
}
|
||||
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
|
||||
Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image'])
|
||||
end
|
||||
|
||||
|
||||
@@ -6,8 +6,12 @@ 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
|
||||
@user&.send_reset_password_instructions(redirect_url: params[:redirect_url], sso_account_id: params.permit(:sso_account_id)[:sso_account_id])
|
||||
build_response(I18n.t('messages.reset_password'), 200)
|
||||
end
|
||||
|
||||
|
||||
@@ -5,7 +5,13 @@ class Shopify::CallbacksController < ApplicationController
|
||||
@account_id = verified_account_id
|
||||
raise StandardError, 'Shopify authorization was denied' if params[:error].present?
|
||||
|
||||
handle_chatwoot_initiated_flow
|
||||
if chatwoot_initiated?
|
||||
handle_chatwoot_initiated_flow
|
||||
elsif params[:code].blank?
|
||||
handle_shopify_initiated_without_code
|
||||
else
|
||||
handle_shopify_initiated_flow
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Shopify callback error: #{e.message}")
|
||||
redirect_to error_redirect_url, allow_other_host: true
|
||||
@@ -13,6 +19,8 @@ class Shopify::CallbacksController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def chatwoot_initiated? = verified_account_id.present?
|
||||
|
||||
def handle_chatwoot_initiated_flow
|
||||
raise StandardError, 'Invalid state parameter' if account.blank?
|
||||
|
||||
@@ -28,11 +36,13 @@ class Shopify::CallbacksController < ApplicationController
|
||||
def exchange_and_create_hook
|
||||
shop_generation = Shopify::PendingInstallation.generation(shop: params[:shop])
|
||||
exchange_access_token
|
||||
with_current_shop_generation(shop_generation) { create_hook }
|
||||
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)
|
||||
Shopify::InstallationGeneration.with_shop_lock(params[:shop]) do
|
||||
ActiveRecord::Base.transaction do
|
||||
unless expected_generation.to_i == Shopify::PendingInstallation.generation(shop: params[:shop])
|
||||
raise StandardError, 'Shopify installation changed during authorization'
|
||||
end
|
||||
@@ -41,33 +51,97 @@ class Shopify::CallbacksController < ApplicationController
|
||||
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
|
||||
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 create_hook
|
||||
Shopify::InstallationGeneration.with_current!(account, @shopify_installation_generation) do
|
||||
hook = account.hooks.find_or_initialize_by(app_id: 'shopify', reference_id: Shopify::ShopDomain.normalize(params[:shop]))
|
||||
hook.update!(shopify_hook_attributes)
|
||||
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 shopify_hook_attributes
|
||||
{
|
||||
app_id: 'shopify',
|
||||
access_token: parsed_body['access_token'],
|
||||
status: 'enabled',
|
||||
reference_id: params[:shop],
|
||||
settings: shopify_hook_settings
|
||||
}
|
||||
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'
|
||||
|
||||
def shopify_hook_settings
|
||||
{
|
||||
scope: parsed_body['scope'],
|
||||
connected_at: Time.current.utc.iso8601(6),
|
||||
installation_id: SecureRandom.uuid
|
||||
}
|
||||
shopify_integration_url
|
||||
end
|
||||
|
||||
def parsed_body
|
||||
@@ -93,6 +167,8 @@ 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
|
||||
@@ -109,6 +185,8 @@ class Shopify::CallbacksController < ApplicationController
|
||||
|
||||
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
|
||||
@@ -123,6 +201,10 @@ class Shopify::CallbacksController < ApplicationController
|
||||
|
||||
def frontend_url = ENV.fetch('FRONTEND_URL', '')
|
||||
|
||||
def oauth_state_key(state)
|
||||
"shopify_oauth_state:#{state}"
|
||||
end
|
||||
|
||||
def valid_shop_domain?
|
||||
return false if params[:shop].blank?
|
||||
|
||||
|
||||
71
app/controllers/stripe/callbacks_controller.rb
Normal file
71
app/controllers/stripe/callbacks_controller.rb
Normal file
@@ -0,0 +1,71 @@
|
||||
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
|
||||
@@ -29,7 +29,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
end
|
||||
|
||||
def create
|
||||
errors = shopify_partner_config_errors
|
||||
errors = shopify_partner_config_errors + stripe_config_errors
|
||||
params['app_config'].each do |key, value|
|
||||
break if errors.any?
|
||||
next unless @allowed_configs.include?(key)
|
||||
@@ -39,15 +39,19 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
errors.concat(i.errors.full_messages) unless i.save
|
||||
end
|
||||
|
||||
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
|
||||
redirect_to super_admin_app_config_path(config: @config), flash: errors.any? ? { alert: errors.join(', ') } : success_flash
|
||||
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
|
||||
@@ -61,6 +65,7 @@ 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],
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
class SuperAdmin::ApplicationController < Administrate::ApplicationController
|
||||
include ActionView::Helpers::TagHelper
|
||||
include ActionView::Context
|
||||
include SuperAdmin::NavigationHelper
|
||||
|
||||
helper_method :render_vue_component, :settings_open?, :settings_pages
|
||||
helper SuperAdmin::NavigationHelper, SuperAdmin::CommandBarHelper, SuperAdmin::UiHelper
|
||||
helper_method :render_vue_component
|
||||
# authenticiation done via devise : SuperAdmin Model
|
||||
before_action :authenticate_super_admin!
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ class Webhooks::ShopifyController < ActionController::API
|
||||
|
||||
before_action :ensure_shopify_enabled, unless: :mandatory_cleanup_event?
|
||||
before_action :verify_hmac!
|
||||
around_action :lock_shop_installation, only: :events
|
||||
around_action :with_installation_transaction, only: :events
|
||||
|
||||
def events
|
||||
case request.headers['X-Shopify-Topic']
|
||||
@@ -20,8 +20,8 @@ class Webhooks::ShopifyController < ActionController::API
|
||||
|
||||
private
|
||||
|
||||
def lock_shop_installation(&)
|
||||
Shopify::InstallationGeneration.with_shop_lock(params[:shop_domain] || params[:myshopify_domain], &)
|
||||
def with_installation_transaction(&)
|
||||
ActiveRecord::Base.transaction(&)
|
||||
end
|
||||
|
||||
def ensure_shopify_enabled
|
||||
|
||||
@@ -16,6 +16,13 @@ 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
|
||||
|
||||
@@ -36,8 +36,7 @@ class UserDashboard < Administrate::BaseDashboard
|
||||
updated_at: Field::DateTime,
|
||||
pubsub_token: Field::String,
|
||||
type: Field::Select.with_options(collection: [nil, 'SuperAdmin']),
|
||||
accounts: CountField,
|
||||
access_token: Field::HasOne
|
||||
accounts: CountField
|
||||
}.freeze
|
||||
|
||||
# COLLECTION_ATTRIBUTES
|
||||
@@ -68,7 +67,6 @@ class UserDashboard < Administrate::BaseDashboard
|
||||
updated_at
|
||||
confirmed_at
|
||||
account_users
|
||||
access_token
|
||||
].freeze
|
||||
|
||||
# FORM_ATTRIBUTES
|
||||
|
||||
70
app/helpers/super_admin/account_feature_icons.yml
Normal file
70
app/helpers/super_admin/account_feature_icons.yml
Normal file
@@ -0,0 +1,70 @@
|
||||
# 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'
|
||||
@@ -7,6 +7,17 @@ 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|
|
||||
@@ -37,16 +48,12 @@ module SuperAdmin::AccountFeaturesHelper
|
||||
filtered = filter_deprecated_features(filtered)
|
||||
display_names = feature_display_names
|
||||
|
||||
regular, premium = filtered.partition { |key, _value| account_premium_features.exclude?(key) }
|
||||
premium_features = account_premium_features
|
||||
regular, premium = filtered.partition { |key, _value| 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
|
||||
|
||||
70
app/helpers/super_admin/command_bar_helper.rb
Normal file
70
app/helpers/super_admin/command_bar_helper.rb
Normal file
@@ -0,0 +1,70 @@
|
||||
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
|
||||
@@ -6,39 +6,39 @@ saml:
|
||||
name: 'SAML SSO'
|
||||
description: 'Configuration for controlling SAML Single Sign-On availability'
|
||||
enabled: <%= ChatwootApp.enterprise? %>
|
||||
icon: 'icon-lock-line'
|
||||
icon: 'i-lucide-key-round'
|
||||
config_key: 'saml'
|
||||
enterprise: true
|
||||
custom_branding:
|
||||
name: 'Custom Branding'
|
||||
description: 'Apply your own branding to this installation.'
|
||||
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
|
||||
icon: 'icon-paint-brush-line'
|
||||
icon: 'i-lucide-palette'
|
||||
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: 'icon-hourglass-line'
|
||||
icon: 'i-lucide-gauge'
|
||||
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: 'icon-menu-search-line'
|
||||
icon: 'i-lucide-scroll-text'
|
||||
enterprise: true
|
||||
disable_branding:
|
||||
name: 'Disable Branding'
|
||||
description: 'Disable branding on live-chat widget and external emails.'
|
||||
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
|
||||
icon: 'icon-sailbot-fill'
|
||||
icon: 'i-lucide-eye-off'
|
||||
enterprise: true
|
||||
voice_calls:
|
||||
name: 'Voice Calls'
|
||||
description: 'Enable voice calling capabilities for your agents and customers.'
|
||||
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
|
||||
icon: 'icon-voice-line'
|
||||
icon: 'i-lucide-phone-call'
|
||||
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: 'icon-book-2-line'
|
||||
icon: 'i-lucide-book-open'
|
||||
captain:
|
||||
name: 'Captain'
|
||||
description: 'Enable AI-powered conversations with your customers.'
|
||||
enabled: true
|
||||
icon: 'icon-captain'
|
||||
icon: 'i-woot-captain text-n-violet-9'
|
||||
config_key: 'captain'
|
||||
|
||||
# ------- Communication Channels ------- #
|
||||
@@ -59,94 +59,100 @@ live_chat:
|
||||
name: 'Live Chat'
|
||||
description: 'Improve your customer experience using a live chat on your website.'
|
||||
enabled: true
|
||||
icon: 'icon-chat-smile-3-line'
|
||||
icon: 'i-lucide-message-circle'
|
||||
email:
|
||||
name: 'Email'
|
||||
description: 'Manage your email customer interactions from Chatwoot.'
|
||||
enabled: true
|
||||
icon: 'icon-mail-send-fill'
|
||||
icon: 'i-lucide-mail'
|
||||
config_key: 'email'
|
||||
sms:
|
||||
name: 'SMS'
|
||||
description: 'Manage your SMS customer interactions from Chatwoot.'
|
||||
enabled: true
|
||||
icon: 'icon-message-line'
|
||||
icon: 'i-lucide-message-square-text'
|
||||
messenger:
|
||||
name: 'Messenger'
|
||||
description: 'Stay connected with your customers on Facebook & Instagram.'
|
||||
enabled: true
|
||||
icon: 'icon-messenger-line'
|
||||
icon: 'i-logos-messenger'
|
||||
config_key: 'facebook'
|
||||
instagram:
|
||||
name: 'Instagram'
|
||||
description: 'Stay connected with your customers on Instagram'
|
||||
enabled: true
|
||||
icon: 'icon-instagram'
|
||||
icon: 'i-logos-instagram-icon dark:invert'
|
||||
config_key: 'instagram'
|
||||
tiktok:
|
||||
name: 'TikTok'
|
||||
description: 'Stay connected with your customers on TikTok'
|
||||
enabled: true
|
||||
icon: 'icon-tiktok'
|
||||
icon: 'i-logos-tiktok-icon dark:invert'
|
||||
config_key: 'tiktok'
|
||||
whatsapp:
|
||||
name: 'WhatsApp'
|
||||
description: 'Manage your WhatsApp business interactions from Chatwoot.'
|
||||
enabled: true
|
||||
icon: 'icon-whatsapp-line'
|
||||
icon: 'i-logos-whatsapp-icon'
|
||||
telegram:
|
||||
name: 'Telegram'
|
||||
description: 'Manage your Telegram customer interactions from Chatwoot.'
|
||||
enabled: true
|
||||
icon: 'icon-telegram-line'
|
||||
icon: 'i-logos-telegram'
|
||||
line:
|
||||
name: 'Line'
|
||||
description: 'Manage your Line customer interactions from Chatwoot.'
|
||||
enabled: true
|
||||
icon: 'icon-line-line'
|
||||
icon: 'i-ri-line-fill text-[#06C755]'
|
||||
|
||||
# ------- OAuth & Authentication ------- #
|
||||
google:
|
||||
name: 'Google'
|
||||
description: 'Configuration for setting up Google OAuth Integration'
|
||||
enabled: true
|
||||
icon: 'icon-google'
|
||||
icon: 'i-logos-google-icon'
|
||||
config_key: 'google'
|
||||
microsoft:
|
||||
name: 'Microsoft'
|
||||
description: 'Configuration for setting up Microsoft Email'
|
||||
enabled: true
|
||||
icon: 'icon-microsoft'
|
||||
icon: 'i-logos-microsoft-icon'
|
||||
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: 'icon-linear'
|
||||
icon: 'i-logos-linear-icon dark:invert'
|
||||
config_key: 'linear'
|
||||
notion:
|
||||
name: 'Notion'
|
||||
description: 'Configuration for setting up Notion Integration'
|
||||
enabled: true
|
||||
icon: 'icon-notion'
|
||||
icon: 'i-logos-notion-icon dark:invert'
|
||||
config_key: 'notion'
|
||||
slack:
|
||||
name: 'Slack'
|
||||
description: 'Configuration for setting up Slack Integration'
|
||||
enabled: true
|
||||
icon: 'icon-slack'
|
||||
icon: 'i-logos-slack-icon'
|
||||
config_key: 'slack'
|
||||
whatsapp_embedded:
|
||||
name: 'WhatsApp Embedded'
|
||||
description: 'Configuration for setting up WhatsApp Embedded Integration'
|
||||
enabled: true
|
||||
icon: 'icon-whatsapp-line'
|
||||
icon: 'i-logos-whatsapp-icon'
|
||||
config_key: 'whatsapp_embedded'
|
||||
shopify:
|
||||
name: 'Shopify'
|
||||
description: 'Configuration for setting up Shopify Integration'
|
||||
enabled: true
|
||||
icon: 'icon-shopify'
|
||||
icon: 'i-logos-shopify'
|
||||
config_key: 'shopify'
|
||||
|
||||
@@ -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}</span> plan with <span class='font-semibold'>#{quantity} agents</span>."
|
||||
"You are currently on the <span class='font-semibold'>#{plan.titleize}</span> plan with <span class='font-semibold'>#{quantity} agents</span>."
|
||||
else
|
||||
"You are currently on the <span class='font-semibold'>#{plan}</span> edition plan."
|
||||
"You are currently on the <span class='font-semibold'>#{plan.titleize}</span> edition plan."
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,16 +1,121 @@
|
||||
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
|
||||
features = SuperAdmin::FeaturesHelper.available_features.select do |_feature, attrs|
|
||||
attrs['config_key'].present? && attrs['enabled']
|
||||
@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
|
||||
end
|
||||
end
|
||||
|
||||
# Add general at the beginning
|
||||
general_feature = [['general', { 'config_key' => 'general', 'name' => 'General' }]]
|
||||
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
|
||||
|
||||
general_feature + features.to_a
|
||||
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
|
||||
end
|
||||
end
|
||||
|
||||
19
app/helpers/super_admin/ui_helper.rb
Normal file
19
app/helpers/super_admin/ui_helper.rb
Normal file
@@ -0,0 +1,19 @@
|
||||
# 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
|
||||
@@ -66,22 +66,6 @@ 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.
|
||||
|
||||
21
app/javascript/dashboard/api/captain/toolsCatalog.js
Normal file
21
app/javascript/dashboard/api/captain/toolsCatalog.js
Normal file
@@ -0,0 +1,21 @@
|
||||
// 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';
|
||||
@@ -6,6 +6,13 @@ 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`,
|
||||
|
||||
@@ -24,16 +24,25 @@ class CompanyAPI extends ApiClient {
|
||||
return axios.get(requestURL);
|
||||
}
|
||||
|
||||
listContacts(id, page = 1) {
|
||||
return axios.get(`${this.url}/${id}/contacts?${buildParams({ page })}`);
|
||||
listContacts(id, page = 1, q = undefined) {
|
||||
return axios.get(`${this.url}/${id}/contacts?${buildParams({ page, q })}`);
|
||||
}
|
||||
|
||||
listNotes(id) {
|
||||
return axios.get(`${this.url}/${id}/notes`);
|
||||
listNotes(id, page = 1, q = undefined) {
|
||||
return axios.get(`${this.url}/${id}/notes?${buildParams({ page, q })}`);
|
||||
}
|
||||
|
||||
listConversations(id) {
|
||||
return axios.get(`${this.url}/${id}/conversations`);
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
searchContacts(id, query = '', page = 1) {
|
||||
@@ -55,6 +64,10 @@ class CompanyAPI extends ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
enrich(id) {
|
||||
return axios.post(`${this.url}/${id}/enrichment`);
|
||||
}
|
||||
|
||||
destroyAvatar(id) {
|
||||
return axios.delete(`${this.url}/${id}/avatar`);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,12 @@ class ShopifyAPI extends ApiClient {
|
||||
params: { contact_id: contactId },
|
||||
});
|
||||
}
|
||||
|
||||
completeInstall(pendingInstallToken) {
|
||||
return axios.post(`${this.url}/complete_install`, {
|
||||
pending_install_token: pendingInstallToken,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new ShopifyAPI();
|
||||
|
||||
25
app/javascript/dashboard/api/integrations/stripe.js
Normal file
25
app/javascript/dashboard/api/integrations/stripe.js
Normal file
@@ -0,0 +1,25 @@
|
||||
/* 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();
|
||||
79
app/javascript/dashboard/assets/scss/_typography.scss
Normal file
79
app/javascript/dashboard/assets/scss/_typography.scss
Normal file
@@ -0,0 +1,79 @@
|
||||
@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%
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@
|
||||
@import 'shared/assets/fonts/InterDisplay/inter-display';
|
||||
@import 'shared/assets/fonts/inter';
|
||||
|
||||
// Typography Utilities
|
||||
@import 'typography';
|
||||
|
||||
// Next Colors
|
||||
@import 'next-colors';
|
||||
|
||||
@@ -65,84 +68,4 @@ body {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
/**
|
||||
* ============================================================================
|
||||
* TYPOGRAPHY UTILITIES
|
||||
* ============================================================================
|
||||
*
|
||||
* | Class | Use Case |
|
||||
* |--------------------|----------------------------------------------------|
|
||||
* | .text-body-main | <p>, <span>, general body text |
|
||||
* | .text-body-para | <p> for paragraphs, larger text blocks |
|
||||
* | .text-heading-1 | <h1>, page titles, panel headers |
|
||||
* | .text-heading-2 | <h2>, section headings, card titles |
|
||||
* | .text-heading-3 | <h3>, card headings, breadcrumbs, subsections |
|
||||
* | .text-label | <label>, form labels, field names |
|
||||
* | .text-label-small | <small>, footnotes, tags, badges, captions |
|
||||
* | .text-button | <button>, standard button text |
|
||||
* | .text-button-small | <button>, small/compact button text |
|
||||
*/
|
||||
|
||||
/* body-text-main: Main text style for general body text */
|
||||
.text-body-main {
|
||||
@apply font-inter text-sm font-420;
|
||||
line-height: 21px; /* 150% */
|
||||
letter-spacing: -0.28px;
|
||||
}
|
||||
|
||||
/* body-text-paragraph: For paragraphs or larger blocks of text */
|
||||
.text-body-para {
|
||||
@apply font-inter text-sm font-420;
|
||||
line-height: 21px; /* 150% */
|
||||
letter-spacing: -0.21px;
|
||||
}
|
||||
|
||||
/* heading-1: Large heading for pages and panels */
|
||||
.text-heading-1 {
|
||||
@apply font-inter text-lg font-520;
|
||||
line-height: 24px; /* 133.333% */
|
||||
letter-spacing: -0.27px;
|
||||
}
|
||||
|
||||
/* heading-2: Secondary heading for sections */
|
||||
.text-heading-2 {
|
||||
@apply font-inter text-base font-medium;
|
||||
line-height: 24px; /* 133.333% */
|
||||
letter-spacing: -0.27px;
|
||||
}
|
||||
|
||||
/* heading-3: For card headings, breadcrumbs, subsections */
|
||||
.text-heading-3 {
|
||||
@apply font-inter text-sm font-medium;
|
||||
line-height: 21px; /* 150% */
|
||||
letter-spacing: -0.27px;
|
||||
}
|
||||
|
||||
/* label: Standard label text for form fields */
|
||||
.text-label {
|
||||
@apply font-inter text-sm font-medium;
|
||||
line-height: 21px; /* 150% */
|
||||
}
|
||||
|
||||
/* label-small: Smallest font for labels, footnotes, tags */
|
||||
.text-label-small {
|
||||
@apply font-inter text-xs font-440;
|
||||
line-height: 16px; /* 133.333% */
|
||||
letter-spacing: -0.24px;
|
||||
}
|
||||
|
||||
/* button-text: Text for standard size buttons */
|
||||
.text-button {
|
||||
@apply font-inter text-sm font-460;
|
||||
line-height: 21px; /* 150% */
|
||||
letter-spacing: -0.28px;
|
||||
}
|
||||
|
||||
/* button-text-small: Text for smaller buttons */
|
||||
.text-button-small {
|
||||
@apply font-inter text-xs font-440;
|
||||
line-height: 18px; /* 150% */
|
||||
letter-spacing: -0.24px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -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,6 +24,19 @@ body {
|
||||
--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;
|
||||
@@ -63,6 +76,19 @@ body {
|
||||
--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;
|
||||
@@ -112,6 +138,9 @@ body {
|
||||
}
|
||||
|
||||
.dark {
|
||||
// scss-lint:disable PropertySpelling
|
||||
color-scheme: dark;
|
||||
|
||||
--slate-1: 17 17 19;
|
||||
--slate-2: 24 25 27;
|
||||
--slate-3: 33 34 37;
|
||||
@@ -125,6 +154,19 @@ body {
|
||||
--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;
|
||||
@@ -164,6 +206,19 @@ body {
|
||||
--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;
|
||||
|
||||
@@ -4,8 +4,10 @@ import { useI18n } from 'vue-i18n';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import { useExactTimestamp } from 'shared/composables/useExactTimestamp';
|
||||
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import { getCompanyProfile } from 'dashboard/components-next/Companies/helpers/companyHelper';
|
||||
|
||||
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({
|
||||
@@ -15,6 +17,8 @@ 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']);
|
||||
@@ -27,13 +31,36 @@ const onClickViewDetails = () => emit('showCompany', props.id);
|
||||
|
||||
const displayName = computed(() => props.name || t('COMPANIES.UNNAMED'));
|
||||
|
||||
const avatarSource = computed(() => props.avatarUrl || null);
|
||||
const details = computed(() => {
|
||||
const { industry, employees, location, countryCode } = getCompanyProfile(
|
||||
props.additionalAttributes
|
||||
);
|
||||
const contactsCount = Number(props.contactsCount || 0);
|
||||
|
||||
const hasContacts = computed(() => Number(props.contactsCount || 0) > 0);
|
||||
|
||||
const contactsCountLabel = computed(() =>
|
||||
t('COMPANIES.CONTACTS_COUNT', { n: Number(props.contactsCount || 0) })
|
||||
);
|
||||
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 formattedLastActivityAt = computed(() => {
|
||||
if (!props.lastActivityAt) return '';
|
||||
@@ -42,52 +69,66 @@ const formattedLastActivityAt = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout layout="row" @click="onClickViewDetails">
|
||||
<div class="flex items-center justify-start flex-1 gap-4 cursor-pointer">
|
||||
<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">
|
||||
<Avatar
|
||||
:username="displayName"
|
||||
:src="avatarSource"
|
||||
class="shrink-0"
|
||||
:name="name"
|
||||
:size="42"
|
||||
:name="displayName"
|
||||
:src="avatarUrl"
|
||||
class="shrink-0 max-sm:row-span-3"
|
||||
:size="36"
|
||||
hide-offline-status
|
||||
/>
|
||||
<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">
|
||||
<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)]">
|
||||
<span
|
||||
v-if="domain"
|
||||
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate cursor-text"
|
||||
@click.stop
|
||||
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"
|
||||
>
|
||||
<Icon icon="i-lucide-globe" size="size-3.5 text-n-slate-11" />
|
||||
<span class="truncate">{{ domain }}</span>
|
||||
<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>
|
||||
</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>
|
||||
</CardLayout>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, useSlots } from 'vue';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
breadcrumbItems: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['back']);
|
||||
|
||||
const slots = useSlots();
|
||||
const isSidebarOpen = ref(false);
|
||||
|
||||
const toggleSidebar = () => {
|
||||
isSidebarOpen.value = !isSidebarOpen.value;
|
||||
};
|
||||
|
||||
const closeMobileSidebar = () => {
|
||||
if (!isSidebarOpen.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSidebarOpen.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="flex w-full h-full overflow-hidden justify-evenly bg-n-surface-1"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col w-full h-full transition-all duration-300 ltr:2xl:ml-56 rtl:2xl:mr-56"
|
||||
>
|
||||
<header class="sticky top-0 z-10 px-6 3xl:px-0">
|
||||
<div class="w-full mx-auto max-w-[40.625rem]">
|
||||
<div
|
||||
class="flex flex-col xs:flex-row items-start xs:items-center justify-between w-full py-7 gap-2"
|
||||
>
|
||||
<Breadcrumb :items="breadcrumbItems" @click="emit('back')" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 px-6 overflow-y-auto 3xl:px-px">
|
||||
<div class="w-full py-4 mx-auto max-w-[40.625rem]">
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="slots.sidebar"
|
||||
class="hidden lg:flex flex-col min-w-52 w-full max-w-md border-l border-n-weak bg-n-solid-2"
|
||||
>
|
||||
<div class="shrink-0">
|
||||
<slot name="sidebarHeader" />
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto pb-6 pt-3">
|
||||
<slot name="sidebar" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="slots.sidebar"
|
||||
class="lg:hidden fixed top-0 ltr:right-0 rtl:left-0 h-full z-50 flex justify-end transition-all duration-200 ease-in-out"
|
||||
:class="isSidebarOpen ? 'w-full' : 'w-16'"
|
||||
>
|
||||
<div
|
||||
v-on-click-outside="[
|
||||
closeMobileSidebar,
|
||||
{ ignore: ['#details-sidebar-content'] },
|
||||
]"
|
||||
class="flex items-start p-1 w-fit h-fit relative order-1 xs:top-24 top-28 transition-all bg-n-solid-2 border border-n-weak duration-500 ease-in-out"
|
||||
:class="[
|
||||
isSidebarOpen
|
||||
? 'justify-end ltr:rounded-l-full rtl:rounded-r-full ltr:rounded-r-none rtl:rounded-l-none'
|
||||
: 'justify-center rounded-full ltr:mr-6 rtl:ml-6',
|
||||
]"
|
||||
>
|
||||
<Button
|
||||
ghost
|
||||
slate
|
||||
sm
|
||||
class="!rounded-full rtl:rotate-180"
|
||||
:class="{ 'bg-n-alpha-2': isSidebarOpen }"
|
||||
:icon="
|
||||
isSidebarOpen
|
||||
? 'i-lucide-panel-right-close'
|
||||
: 'i-lucide-panel-right-open'
|
||||
"
|
||||
data-details-sidebar-toggle
|
||||
@click="toggleSidebar"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-transform duration-200 ease-in-out"
|
||||
leave-active-class="transition-transform duration-200 ease-in-out"
|
||||
enter-from-class="ltr:translate-x-full rtl:-translate-x-full"
|
||||
enter-to-class="ltr:translate-x-0 rtl:-translate-x-0"
|
||||
leave-from-class="ltr:translate-x-0 rtl:-translate-x-0"
|
||||
leave-to-class="ltr:translate-x-full rtl:-translate-x-full"
|
||||
>
|
||||
<div
|
||||
v-if="isSidebarOpen"
|
||||
id="details-sidebar-content"
|
||||
class="order-2 w-[85%] sm:w-[50%] flex flex-col bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak shadow-lg"
|
||||
>
|
||||
<div class="shrink-0">
|
||||
<slot name="sidebarHeader" />
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto pb-6 pt-3">
|
||||
<slot name="sidebar" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup>
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import CompanySearchInput from 'dashboard/components-next/Companies/CompanySearchInput.vue';
|
||||
import CompanySortMenu from './components/CompanySortMenu.vue';
|
||||
import CompanyMoreActions from './components/CompanyMoreActions.vue';
|
||||
|
||||
@@ -18,31 +17,19 @@ const emit = defineEmits(['search', 'update:sort', 'create']);
|
||||
<template>
|
||||
<header class="sticky top-0 z-10 px-6">
|
||||
<div
|
||||
class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl"
|
||||
class="flex items-center justify-between w-full py-6 gap-4 mx-auto max-w-5xl"
|
||||
>
|
||||
<span class="text-xl font-medium truncate text-n-slate-12">
|
||||
<span class="text-xl font-medium shrink-0 text-n-slate-12">
|
||||
{{ headerTitle }}
|
||||
</span>
|
||||
<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 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-shrink-0 gap-2">
|
||||
<CompanySortMenu
|
||||
:active-sort="activeSort"
|
||||
|
||||
@@ -38,7 +38,7 @@ const handleAction = ({ action }) => {
|
||||
<DropdownMenu
|
||||
v-if="showActionsDropdown"
|
||||
:menu-items="menuItems"
|
||||
class="ltr:right-0 rtl:left-0 mt-1 w-52 top-full"
|
||||
class="end-0 mt-1 w-52 top-full"
|
||||
@action="handleAction($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -95,7 +95,7 @@ const handleOrderChange = value => {
|
||||
<div
|
||||
v-if="isMenuOpen"
|
||||
v-on-clickaway="() => (isMenuOpen = false)"
|
||||
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
|
||||
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"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm text-n-slate-12">
|
||||
|
||||
@@ -48,7 +48,7 @@ const updateCurrentPage = page => {
|
||||
current-page-info="COMPANIES_LAYOUT.PAGINATION_FOOTER.SHOWING"
|
||||
:current-page="currentPage"
|
||||
:total-items="totalItems"
|
||||
:items-per-page="25"
|
||||
:items-per-page="50"
|
||||
class="max-w-[67rem]"
|
||||
@update:current-page="updateCurrentPage"
|
||||
/>
|
||||
|
||||
@@ -1,31 +1,17 @@
|
||||
<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,
|
||||
@@ -47,39 +33,15 @@ 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(' • ');
|
||||
|
||||
@@ -88,11 +50,7 @@ const contactName = contact =>
|
||||
|
||||
const contactOptions = computed(() =>
|
||||
props.searchResults
|
||||
.filter(
|
||||
contact =>
|
||||
!contact.linkedToCurrentCompany &&
|
||||
!linkedContactIds.value.has(Number(contact.id))
|
||||
)
|
||||
.filter(contact => !contact.linkedToCurrentCompany)
|
||||
.map(contact => ({
|
||||
value: contact.id,
|
||||
label: [contactName(contact), contact.email, contact.phoneNumber]
|
||||
@@ -169,16 +127,11 @@ const handleContactSelect = contactId => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 px-6 pb-8">
|
||||
<div class="flex flex-col gap-6">
|
||||
<div v-if="!selectedContact" class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-base text-n-slate-12">
|
||||
{{ t('COMPANIES.DETAIL.CONTACTS.ACTIONS.ADD') }}
|
||||
</label>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.DESCRIPTION') }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-body-main text-n-slate-11">
|
||||
{{ t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.DESCRIPTION') }}
|
||||
</span>
|
||||
<ComboBox
|
||||
use-api-results
|
||||
:model-value="selectedContactId"
|
||||
@@ -230,7 +183,6 @@ 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">
|
||||
@@ -268,87 +220,5 @@ 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>
|
||||
@@ -0,0 +1,125 @@
|
||||
<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>
|
||||
@@ -0,0 +1,109 @@
|
||||
<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>
|
||||
@@ -80,7 +80,7 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="hasCompanyAttributes" class="flex flex-col gap-6 px-6 py-6">
|
||||
<div v-if="hasCompanyAttributes" class="flex flex-col gap-6">
|
||||
<div v-if="!hasNoUsedAttributes" class="flex flex-col gap-2">
|
||||
<CompanyCustomAttributeItem
|
||||
v-for="attribute in usedAttributes"
|
||||
@@ -105,14 +105,12 @@ 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 ltr:left-3 rtl:right-3"
|
||||
/>
|
||||
<span class="absolute i-lucide-search size-3.5 top-2 start-3" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:placeholder="t('COMPANIES.DETAIL.ATTRIBUTES.SEARCH_PLACEHOLDER')"
|
||||
class="w-full h-8 py-2 pl-10 pr-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -136,7 +134,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-else class="px-6 py-10 text-sm leading-6 text-center text-n-slate-11">
|
||||
<p v-else class="text-sm text-n-slate-11">
|
||||
{{ t('COMPANIES.DETAIL.ATTRIBUTES.EMPTY_STATE') }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
<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>
|
||||
@@ -0,0 +1,277 @@
|
||||
<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>
|
||||
@@ -0,0 +1,119 @@
|
||||
<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>
|
||||
@@ -1,67 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import ConversationCard from 'dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
defineProps({
|
||||
conversations: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const contactsById = useMapGetter('contacts/getContactById');
|
||||
const stateInbox = useMapGetter('inboxes/getInboxById');
|
||||
const accountLabels = useMapGetter('labels/getLabels');
|
||||
|
||||
const accountLabelsValue = computed(() => accountLabels.value);
|
||||
const conversationContact = conversation => {
|
||||
const sender = conversation.meta?.sender || {};
|
||||
const contact = contactsById.value(sender.id);
|
||||
return contact.id ? contact : sender;
|
||||
};
|
||||
const conversationInbox = conversation =>
|
||||
stateInbox.value(conversation.inboxId) || {
|
||||
name: '',
|
||||
channelType: conversation.meta?.channel,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="conversations.length > 0"
|
||||
class="px-6 divide-y divide-n-strong [&>*:hover]:!border-y-transparent [&>*:hover+*]:!border-t-transparent"
|
||||
>
|
||||
<ConversationCard
|
||||
v-for="conversation in conversations"
|
||||
:key="conversation.id"
|
||||
:conversation="conversation"
|
||||
:contact="conversationContact(conversation)"
|
||||
:state-inbox="conversationInbox(conversation)"
|
||||
:account-labels="accountLabelsValue"
|
||||
class="rounded-none hover:rounded-xl hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-else
|
||||
class="py-8 px-4 mx-6 text-sm text-center rounded-xl border border-dashed border-n-strong text-n-slate-11"
|
||||
>
|
||||
{{ t('COMPANIES.DETAIL.HISTORY.EMPTY') }}
|
||||
</p>
|
||||
</template>
|
||||
@@ -0,0 +1,164 @@
|
||||
<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>
|
||||
@@ -1,123 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import { 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>
|
||||
@@ -0,0 +1,181 @@
|
||||
<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>
|
||||
@@ -1,221 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import { 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"> • </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>
|
||||
@@ -0,0 +1,30 @@
|
||||
<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>
|
||||
@@ -0,0 +1,43 @@
|
||||
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;
|
||||
}, []);
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,8 @@ 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([
|
||||
@@ -31,6 +33,30 @@ 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);
|
||||
@@ -78,23 +104,46 @@ 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">
|
||||
<div class="flex items-center justify-center flex-shrink-0" @click.stop>
|
||||
<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
|
||||
>
|
||||
<Checkbox v-model="selectedModel" />
|
||||
</div>
|
||||
|
||||
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
|
||||
<div v-if="selectable" class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
|
||||
|
||||
<div class="w-4 flex items-center justify-center flex-shrink-0">
|
||||
<div
|
||||
class="w-4 flex items-center justify-center flex-shrink-0"
|
||||
:class="orderClass('priority')"
|
||||
>
|
||||
<CardPriorityIcon :priority="chat.priority" show-empty />
|
||||
</div>
|
||||
|
||||
<div class="w-4 flex items-center justify-center flex-shrink-0">
|
||||
<div
|
||||
class="w-4 flex items-center justify-center flex-shrink-0"
|
||||
:class="orderClass('assignee')"
|
||||
>
|
||||
<Avatar
|
||||
v-if="showAssignee && assignee.name"
|
||||
v-tooltip.top="{
|
||||
@@ -114,18 +163,28 @@ const selectedModel = computed({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-4 flex items-center justify-center flex-shrink-0">
|
||||
<div
|
||||
class="w-4 flex items-center justify-center flex-shrink-0"
|
||||
:class="orderClass('status')"
|
||||
>
|
||||
<CardStatusIcon :status="chat.status" show-empty />
|
||||
</div>
|
||||
|
||||
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
|
||||
<div
|
||||
v-if="!conversationFirst"
|
||||
class="w-px h-3 bg-n-slate-6 flex-shrink-0"
|
||||
/>
|
||||
|
||||
<div v-if="!isInboxView && showInboxName" class="w-20 flex-shrink-0">
|
||||
<div
|
||||
v-if="!isInboxView && showInboxName"
|
||||
class="w-20 flex-shrink-0"
|
||||
:class="orderClass('inbox')"
|
||||
>
|
||||
<InboxName v-if="showInboxName" :inbox="inbox" class="min-w-0" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isInboxView && showInboxName"
|
||||
v-if="!isInboxView && showInboxName && !conversationFirst"
|
||||
class="w-px h-3 bg-n-slate-6 flex-shrink-0"
|
||||
/>
|
||||
|
||||
@@ -135,6 +194,7 @@ 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"
|
||||
@@ -146,6 +206,7 @@ const selectedModel = computed({
|
||||
</div>
|
||||
|
||||
<CardAvatar
|
||||
:class="orderClass('avatar')"
|
||||
:contact="currentContact"
|
||||
:selected="false"
|
||||
:enable-selection="false"
|
||||
@@ -154,11 +215,13 @@ 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"
|
||||
@@ -168,8 +231,15 @@ const selectedModel = computed({
|
||||
</div>
|
||||
|
||||
<!-- RIGHT SECTION -->
|
||||
<div class="flex items-center justify-end gap-1.5 flex-shrink-0">
|
||||
<div v-if="showLabelsSection" class="min-w-0 w-full">
|
||||
<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')"
|
||||
>
|
||||
<CardLabels
|
||||
:labels="chat.labels"
|
||||
disable-toggle
|
||||
@@ -177,11 +247,18 @@ const selectedModel = computed({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="hasSlaPolicyId" class="flex-shrink-0">
|
||||
<div
|
||||
v-if="hasSlaPolicyId"
|
||||
class="flex-shrink-0"
|
||||
:class="orderClass('sla')"
|
||||
>
|
||||
<SLACardLabel ref="slaCardLabel" :chat="chat" />
|
||||
</div>
|
||||
|
||||
<div class="flex-shrink-0 w-[4.375rem] text-end">
|
||||
<div
|
||||
class="flex-shrink-0 w-[4.375rem] text-end"
|
||||
:class="orderClass('time')"
|
||||
>
|
||||
<TimeAgo
|
||||
:conversation-id="chat.id"
|
||||
:last-activity-timestamp="chat.timestamp"
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup>
|
||||
import { useTemplateRef } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
|
||||
defineProps({
|
||||
backLabel: { type: String, required: true },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
tabs: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['back']);
|
||||
|
||||
const isRTL = useMapGetter('accounts/isRTL');
|
||||
|
||||
const activeTab = defineModel('activeTab', { type: String, default: '' });
|
||||
|
||||
const tabsRef = useTemplateRef('tabsRef');
|
||||
|
||||
const scrollToTabs = () =>
|
||||
tabsRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
|
||||
defineExpose({ scrollToTabs });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="w-full h-full px-6 overflow-y-auto bg-n-surface-1">
|
||||
<div class="flex flex-col w-full max-w-5xl gap-8 pt-6 pb-24 mx-auto">
|
||||
<Button
|
||||
:label="backLabel"
|
||||
:icon="isRTL ? 'i-lucide-arrow-right' : 'i-lucide-arrow-left'"
|
||||
variant="link"
|
||||
color="slate"
|
||||
size="sm"
|
||||
class="self-start -mb-4"
|
||||
@click="emit('back')"
|
||||
/>
|
||||
|
||||
<div v-if="isLoading" class="flex justify-center py-24 text-n-slate-11">
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
<slot v-else-if="$slots.state" name="state" />
|
||||
|
||||
<template v-else>
|
||||
<section
|
||||
class="flex flex-col gap-5 p-6 border rounded-xl border-n-weak bg-n-solid-1"
|
||||
>
|
||||
<slot name="header" />
|
||||
</section>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div
|
||||
ref="tabsRef"
|
||||
class="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-3 py-3 border-b bg-n-surface-1 border-n-weak"
|
||||
>
|
||||
<TabBar
|
||||
:tabs="tabs"
|
||||
class="min-w-0 max-w-full"
|
||||
:initial-active-tab="
|
||||
tabs.findIndex(tab => tab.value === activeTab)
|
||||
"
|
||||
@tab-changed="tab => (activeTab = tab.value)"
|
||||
/>
|
||||
<div class="flex items-center w-full gap-2 sm:w-auto">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -15,7 +15,7 @@ vi.mock('dashboard/composables', () => ({ useAlert: mocks.alert }));
|
||||
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: key => key }) }));
|
||||
|
||||
const DialogStub = {
|
||||
emits: ['confirm'],
|
||||
emits: ['confirm', 'close'],
|
||||
methods: {
|
||||
open() {},
|
||||
close() {
|
||||
@@ -63,8 +63,7 @@ const mountDialog = () =>
|
||||
|
||||
const loadPreview = async (wrapper, data) => {
|
||||
mocks.preview.mockResolvedValue({ data });
|
||||
await wrapper.find('input').setValue(' Chatwoot/Support-Tools/Shopify ');
|
||||
await wrapper.findAll('button').at(-1).trigger('click');
|
||||
wrapper.vm.open('Chatwoot/Tools/Shopify');
|
||||
await flushPromises();
|
||||
};
|
||||
|
||||
@@ -85,8 +84,8 @@ describe('InstallManifestDialog', () => {
|
||||
|
||||
expect(mocks.install).toHaveBeenCalledWith({
|
||||
assistantId: 7,
|
||||
// Sent as entered: the preview's identity is lowercase, but GitHub folder names are case-sensitive
|
||||
source: 'Chatwoot/Support-Tools/Shopify',
|
||||
// Sent as opened: the preview's identity is lowercase, but GitHub folder names are case-sensitive
|
||||
source: 'Chatwoot/Tools/Shopify',
|
||||
revision: previewData.revision,
|
||||
configuration: { inputs: {}, secrets: { access_token: 'shpat_secret' } },
|
||||
});
|
||||
@@ -100,8 +99,7 @@ describe('InstallManifestDialog', () => {
|
||||
return new Promise(() => {});
|
||||
});
|
||||
const wrapper = mountDialog();
|
||||
await wrapper.find('input').setValue('chatwoot/tools/shopify');
|
||||
await wrapper.findAll('button').at(-1).trigger('click');
|
||||
wrapper.vm.open('chatwoot/tools/shopify');
|
||||
|
||||
wrapper.findComponent(DialogStub).vm.$emit('close');
|
||||
|
||||
@@ -124,15 +122,35 @@ describe('InstallManifestDialog', () => {
|
||||
wrapper.findAll('button').at(0).attributes('disabled')
|
||||
).toBeDefined();
|
||||
|
||||
wrapper.vm.open();
|
||||
wrapper.vm.open('chatwoot/tools/shopify');
|
||||
finishInstall({ data: { payload: [] } });
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.emitted('installed')).toHaveLength(1);
|
||||
expect(wrapper.emitted('installed')).toBeUndefined();
|
||||
expect(mocks.dialogClose).not.toHaveBeenCalled();
|
||||
expect(mocks.alert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores an install that finishes after the dialog was dismissed', async () => {
|
||||
let finishInstall;
|
||||
mocks.install.mockReturnValue(
|
||||
new Promise(resolve => {
|
||||
finishInstall = resolve;
|
||||
})
|
||||
);
|
||||
const wrapper = mountDialog();
|
||||
await loadPreview(wrapper, previewData);
|
||||
await wrapper.find('input[type="password"]').setValue('shpat_secret');
|
||||
await wrapper.findAll('button').at(-1).trigger('click');
|
||||
|
||||
wrapper.findComponent(DialogStub).vm.$emit('close');
|
||||
finishInstall({ data: { payload: [] } });
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.emitted('installed')).toBeUndefined();
|
||||
expect(mocks.alert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not treat built-in object property names as filled', async () => {
|
||||
const wrapper = mountDialog();
|
||||
await loadPreview(wrapper, {
|
||||
|
||||
@@ -11,6 +11,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import Switch from 'dashboard/components-next/switch/Switch.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const props = defineProps({
|
||||
assistantId: {
|
||||
@@ -19,7 +20,7 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['installed']);
|
||||
const emit = defineEmits(['installed', 'close']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -27,20 +28,15 @@ const SHORT_REVISION_LENGTH = 7;
|
||||
const INPUT_TYPES = { password: 'password', number: 'number' };
|
||||
|
||||
const dialogRef = ref(null);
|
||||
// Sent to install exactly as opened; preview returns a lowercase identity, but GitHub folder names are case-sensitive
|
||||
const source = ref('');
|
||||
const preview = ref(null);
|
||||
// The source exactly as previewed; preview returns a lowercase identity, but GitHub folder names are case-sensitive
|
||||
const previewedSource = ref('');
|
||||
// Prototype-free, so field names like "constructor" don't read inherited values and look already filled
|
||||
const emptyValues = () => Object.create(null);
|
||||
const values = reactive({ inputs: emptyValues(), secrets: emptyValues() });
|
||||
const isInstalling = ref(false);
|
||||
// A slow preview must not land in a dialog that was closed or reopened for another source
|
||||
const {
|
||||
run: runPreview,
|
||||
abort: abortPreview,
|
||||
isPending: isLoadingPreview,
|
||||
} = useAbortableRequest();
|
||||
const { run: runPreview, abort: abortPreview } = useAbortableRequest();
|
||||
|
||||
const isInstalled = computed(() => !!preview.value?.up_to_date);
|
||||
// Installed at this commit but some tools were deleted; reinstalling adds them back
|
||||
@@ -93,23 +89,14 @@ const selectOptions = field =>
|
||||
(field.options || []).map(option => ({ value: option, label: option }));
|
||||
|
||||
const reset = () => {
|
||||
source.value = '';
|
||||
preview.value = null;
|
||||
previewedSource.value = '';
|
||||
values.inputs = emptyValues();
|
||||
values.secrets = emptyValues();
|
||||
};
|
||||
|
||||
// Bumped on every open, so an install finishing after the dialog was reopened doesn't act on the new session
|
||||
// Bumped on every open and close, so an install finishing after the dialog was dismissed or reopened is ignored
|
||||
let session = 0;
|
||||
|
||||
const open = () => {
|
||||
session += 1;
|
||||
abortPreview();
|
||||
reset();
|
||||
dialogRef.value.open();
|
||||
};
|
||||
|
||||
const close = () => dialogRef.value.close();
|
||||
|
||||
// The tools page is reused across assistants, so a preview must never be installed into the next one
|
||||
@@ -123,20 +110,15 @@ watch(
|
||||
);
|
||||
|
||||
const loadPreview = async () => {
|
||||
const requestedSource = source.value.trim();
|
||||
if (!requestedSource) return;
|
||||
|
||||
try {
|
||||
const response = await runPreview(signal =>
|
||||
ToolsManifestAPI.preview(
|
||||
{ assistantId: props.assistantId, source: requestedSource },
|
||||
{ assistantId: props.assistantId, source: source.value },
|
||||
{ signal }
|
||||
)
|
||||
);
|
||||
if (!response) return;
|
||||
|
||||
previewedSource.value = requestedSource;
|
||||
|
||||
const { data } = response;
|
||||
values.inputs = emptyValues();
|
||||
values.secrets = emptyValues();
|
||||
@@ -151,23 +133,37 @@ const loadPreview = async () => {
|
||||
parseAPIErrorResponse(error) ||
|
||||
t('CAPTAIN.CUSTOM_TOOLS.INSTALL_MANIFEST.PREVIEW_ERROR')
|
||||
);
|
||||
// There is nothing to show without a preview
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
// The catalog and install links open the dialog with a source, which goes straight to its preview
|
||||
const open = toolsetSource => {
|
||||
session += 1;
|
||||
abortPreview();
|
||||
reset();
|
||||
// A request from before a reopen must not keep the new dialog locked
|
||||
isInstalling.value = false;
|
||||
source.value = toolsetSource;
|
||||
dialogRef.value.open();
|
||||
loadPreview();
|
||||
};
|
||||
|
||||
const install = async () => {
|
||||
const installSession = session;
|
||||
isInstalling.value = true;
|
||||
try {
|
||||
await ToolsManifestAPI.install({
|
||||
assistantId: props.assistantId,
|
||||
source: previewedSource.value,
|
||||
source: source.value,
|
||||
// Pin to the previewed commit so a newer push can't install tools that were never reviewed
|
||||
revision: preview.value.revision,
|
||||
configuration: values,
|
||||
});
|
||||
emit('installed');
|
||||
if (installSession !== session) return;
|
||||
|
||||
emit('installed');
|
||||
useAlert(t('CAPTAIN.CUSTOM_TOOLS.INSTALL_MANIFEST.SUCCESS_MESSAGE'));
|
||||
close();
|
||||
} catch (error) {
|
||||
@@ -178,21 +174,14 @@ const install = async () => {
|
||||
t('CAPTAIN.CUSTOM_TOOLS.INSTALL_MANIFEST.ERROR_MESSAGE')
|
||||
);
|
||||
} finally {
|
||||
isInstalling.value = false;
|
||||
if (installSession === session) isInstalling.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
if (preview.value) {
|
||||
preview.value = null;
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
// The dialog is a form, so Enter in the source field submits it
|
||||
const onEnter = () => {
|
||||
if (!preview.value) loadPreview();
|
||||
const onClose = () => {
|
||||
session += 1;
|
||||
abortPreview();
|
||||
emit('close');
|
||||
};
|
||||
|
||||
defineExpose({ open });
|
||||
@@ -206,20 +195,10 @@ defineExpose({ open });
|
||||
:description="t('CAPTAIN.CUSTOM_TOOLS.INSTALL_MANIFEST.DESCRIPTION')"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
@confirm="onEnter"
|
||||
@close="abortPreview"
|
||||
@close="onClose"
|
||||
>
|
||||
<div v-if="!preview" class="flex flex-col gap-2">
|
||||
<Input
|
||||
v-model="source"
|
||||
:label="t('CAPTAIN.CUSTOM_TOOLS.INSTALL_MANIFEST.SOURCE_LABEL')"
|
||||
:placeholder="
|
||||
t('CAPTAIN.CUSTOM_TOOLS.INSTALL_MANIFEST.SOURCE_PLACEHOLDER')
|
||||
"
|
||||
:message="t('CAPTAIN.CUSTOM_TOOLS.INSTALL_MANIFEST.SOURCE_HELP')"
|
||||
class="[&_input]:font-mono"
|
||||
autofocus
|
||||
/>
|
||||
<div v-if="!preview" class="flex justify-center py-8">
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -260,11 +239,7 @@ defineExpose({ open });
|
||||
:key="tool.id"
|
||||
class="flex items-center gap-2 text-sm text-n-slate-12"
|
||||
>
|
||||
<span
|
||||
class="px-1.5 py-0.5 text-xs font-mono rounded bg-n-alpha-2 text-n-slate-11 w-16 text-center shrink-0"
|
||||
>
|
||||
{{ tool.http_method }}
|
||||
</span>
|
||||
<i class="i-lucide-wrench size-3.5 shrink-0 text-n-slate-10" />
|
||||
<span class="truncate">{{ tool.title }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -316,31 +291,19 @@ defineExpose({ open });
|
||||
type="button"
|
||||
faded
|
||||
slate
|
||||
:label="
|
||||
preview
|
||||
? t('CAPTAIN.CUSTOM_TOOLS.INSTALL_MANIFEST.BACK')
|
||||
: t('CAPTAIN.FORM.CANCEL')
|
||||
"
|
||||
:label="t('CAPTAIN.FORM.CANCEL')"
|
||||
class="w-full"
|
||||
:disabled="isInstalling"
|
||||
@click="goBack"
|
||||
@click="close"
|
||||
/>
|
||||
<Button
|
||||
v-if="!preview"
|
||||
type="button"
|
||||
:label="t('CAPTAIN.CUSTOM_TOOLS.INSTALL_MANIFEST.CONTINUE')"
|
||||
class="w-full"
|
||||
:is-loading="isLoadingPreview"
|
||||
:disabled="!source.trim() || isLoadingPreview"
|
||||
@click="loadPreview"
|
||||
/>
|
||||
<Button
|
||||
v-else
|
||||
type="button"
|
||||
:label="installLabel"
|
||||
class="w-full"
|
||||
:is-loading="isInstalling"
|
||||
:disabled="isInstalled || hasMissingRequiredValues || isInstalling"
|
||||
:disabled="
|
||||
!preview || isInstalled || hasMissingRequiredValues || isInstalling
|
||||
"
|
||||
@click="install"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import ToolsetIdentity from './ToolsetIdentity.vue';
|
||||
import ToolsetInstallBadge from './ToolsetInstallBadge.vue';
|
||||
|
||||
defineProps({
|
||||
toolset: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
installStatus: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col w-full h-full gap-3 p-4 text-start rounded-xl outline outline-1 outline-n-weak bg-n-solid-1 hover:bg-n-alpha-1"
|
||||
@click="emit('click')"
|
||||
>
|
||||
<div class="flex items-start w-full gap-3">
|
||||
<ToolsetIdentity :toolset="toolset" class="flex-1" />
|
||||
<ToolsetInstallBadge v-if="installStatus" :status="installStatus" />
|
||||
</div>
|
||||
<p class="mb-0 text-sm text-n-slate-11 line-clamp-2">
|
||||
{{ toolset.description }}
|
||||
</p>
|
||||
<!-- Only the category shrinks, so a long one truncates instead of wrapping the counts -->
|
||||
<div
|
||||
class="flex items-center w-full min-w-0 gap-2 mt-auto text-xs text-n-slate-11"
|
||||
>
|
||||
<span class="truncate" :title="toolset.category">
|
||||
{{ toolset.category }}
|
||||
</span>
|
||||
<span class="rounded-full size-1 shrink-0 bg-n-slate-8" />
|
||||
<span class="shrink-0 whitespace-nowrap">
|
||||
{{
|
||||
t('CAPTAIN.CUSTOM_TOOLS.CATALOG.TOOL_COUNT', {
|
||||
n: toolset.tool_count,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span class="rounded-full size-1 shrink-0 bg-n-slate-8" />
|
||||
<span class="shrink-0 whitespace-nowrap">
|
||||
{{
|
||||
t('CAPTAIN.CUSTOM_TOOLS.CATALOG.VERSION', {
|
||||
version: toolset.version,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,246 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAbortableRequest } from 'dashboard/composables/useAbortableRequest';
|
||||
import MessageFormatter from 'shared/helpers/MessageFormatter';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import { fetchToolsetDetails } from 'dashboard/api/captain/toolsCatalog';
|
||||
|
||||
import SidePanel from 'dashboard/components-next/side-panel/SidePanel.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import ToolsetIdentity from './ToolsetIdentity.vue';
|
||||
import ToolsetInstallBadge from './ToolsetInstallBadge.vue';
|
||||
|
||||
const emit = defineEmits(['install']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
run: runDetailsRequest,
|
||||
abort: abortDetails,
|
||||
isPending: isLoading,
|
||||
} = useAbortableRequest();
|
||||
|
||||
const panelRef = ref(null);
|
||||
// The list entry renders the header right away while the details load
|
||||
const toolset = ref(null);
|
||||
const details = ref(null);
|
||||
const hasError = ref(false);
|
||||
const installStatus = ref(null);
|
||||
|
||||
// An installed toolset can be reinstalled to restore deleted tools, or updated when the catalog has a newer version
|
||||
const installAction = computed(() => {
|
||||
if (installStatus.value === 'installed') {
|
||||
return {
|
||||
label: t('CAPTAIN.CUSTOM_TOOLS.INSTALL_MANIFEST.REINSTALL'),
|
||||
icon: 'i-lucide-refresh-cw',
|
||||
};
|
||||
}
|
||||
if (installStatus.value === 'update') {
|
||||
return {
|
||||
label: t('CAPTAIN.CUSTOM_TOOLS.INSTALL_MANIFEST.UPDATE'),
|
||||
icon: 'i-lucide-arrow-up-circle',
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: t('CAPTAIN.CUSTOM_TOOLS.INSTALL_MANIFEST.INSTALL'),
|
||||
icon: 'i-lucide-download',
|
||||
};
|
||||
});
|
||||
|
||||
// Images are dropped: the panel has no use for them, and relative image paths break the shared formatter
|
||||
const readmeHtml = computed(() => {
|
||||
const formatter = new MessageFormatter(details.value.readme);
|
||||
formatter.disableImageRendering();
|
||||
return formatter.formattedMessage;
|
||||
});
|
||||
|
||||
const configurationFields = computed(() => [
|
||||
...(details.value?.inputs || []),
|
||||
...(details.value?.secrets || []).map(field => ({ ...field, secret: true })),
|
||||
]);
|
||||
|
||||
const updatedAt = computed(() =>
|
||||
dynamicTime(Date.parse(toolset.value.updated_at) / 1000)
|
||||
);
|
||||
|
||||
const loadDetails = async () => {
|
||||
hasError.value = false;
|
||||
try {
|
||||
const response = await runDetailsRequest(signal =>
|
||||
fetchToolsetDetails(toolset.value.detail_url, { signal })
|
||||
);
|
||||
if (response) details.value = response;
|
||||
} catch {
|
||||
hasError.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const open = (selectedToolset, selectedInstallStatus = null) => {
|
||||
abortDetails();
|
||||
toolset.value = selectedToolset;
|
||||
installStatus.value = selectedInstallStatus;
|
||||
details.value = null;
|
||||
panelRef.value.open();
|
||||
loadDetails();
|
||||
};
|
||||
|
||||
const close = () => panelRef.value.close();
|
||||
|
||||
const install = () => {
|
||||
close();
|
||||
emit('install', toolset.value);
|
||||
};
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidePanel ref="panelRef" width="2xl" @close="abortDetails">
|
||||
<template v-if="toolset" #header>
|
||||
<ToolsetIdentity :toolset="toolset">
|
||||
<ToolsetInstallBadge v-if="installStatus" :status="installStatus" />
|
||||
</ToolsetIdentity>
|
||||
</template>
|
||||
|
||||
<div v-if="toolset" class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<p class="mb-0 text-sm text-n-slate-12">{{ toolset.description }}</p>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-n-slate-11"
|
||||
>
|
||||
<span>{{ toolset.category }}</span>
|
||||
<span class="rounded-full size-1 bg-n-slate-8" />
|
||||
<span>
|
||||
{{
|
||||
t('CAPTAIN.CUSTOM_TOOLS.CATALOG.VERSION', {
|
||||
version: toolset.version,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span class="rounded-full size-1 bg-n-slate-8" />
|
||||
<span>
|
||||
{{ t('CAPTAIN.CUSTOM_TOOLS.CATALOG.UPDATED', { time: updatedAt }) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="details" class="flex flex-wrap gap-4 text-xs">
|
||||
<a
|
||||
:href="details.repository_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1 text-n-blue-11 hover:underline"
|
||||
>
|
||||
{{ t('CAPTAIN.CUSTOM_TOOLS.CATALOG.REPOSITORY') }}
|
||||
<i class="i-lucide-external-link size-3" />
|
||||
</a>
|
||||
<a
|
||||
:href="details.manifest_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1 text-n-blue-11 hover:underline"
|
||||
>
|
||||
{{ t('CAPTAIN.CUSTOM_TOOLS.CATALOG.MANIFEST') }}
|
||||
<i class="i-lucide-external-link size-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="flex justify-center py-10">
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
<p v-else-if="hasError" class="mb-0 text-sm text-n-ruby-11">
|
||||
{{ t('CAPTAIN.CUSTOM_TOOLS.CATALOG.DETAILS_ERROR') }}
|
||||
</p>
|
||||
|
||||
<template v-else-if="details">
|
||||
<section class="flex flex-col gap-2">
|
||||
<h4 class="text-sm font-medium text-n-slate-12">
|
||||
{{
|
||||
t('CAPTAIN.CUSTOM_TOOLS.CATALOG.TOOL_COUNT', {
|
||||
n: details.tools.length,
|
||||
})
|
||||
}}
|
||||
</h4>
|
||||
<ul
|
||||
class="m-0 list-none divide-y rounded-xl outline outline-1 outline-n-weak divide-n-weak"
|
||||
>
|
||||
<li
|
||||
v-for="tool in details.tools"
|
||||
:key="tool.id"
|
||||
class="flex items-start gap-3 px-4 py-3"
|
||||
>
|
||||
<span
|
||||
class="flex items-center justify-center rounded-lg size-7 shrink-0 bg-n-alpha-2 text-n-slate-11"
|
||||
>
|
||||
<i class="i-lucide-wrench size-3.5" />
|
||||
</span>
|
||||
<div class="flex flex-col min-w-0 gap-0.5">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ tool.title }}
|
||||
</span>
|
||||
<span class="text-xs text-n-slate-11">
|
||||
{{ tool.description }}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section v-if="configurationFields.length" class="flex flex-col gap-2">
|
||||
<h4 class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAPTAIN.CUSTOM_TOOLS.CATALOG.CONFIGURATION') }}
|
||||
</h4>
|
||||
<ul class="flex flex-col gap-1.5 m-0 list-none">
|
||||
<li
|
||||
v-for="field in configurationFields"
|
||||
:key="`${field.secret}-${field.id}`"
|
||||
class="flex items-center gap-2 text-sm text-n-slate-12"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
field.secret
|
||||
? 'i-lucide-key-round'
|
||||
: 'i-lucide-sliders-horizontal'
|
||||
"
|
||||
class="size-3.5 shrink-0 text-n-slate-11"
|
||||
/>
|
||||
<span class="truncate">{{ field.label }}</span>
|
||||
<span v-if="field.required" class="text-xs text-n-slate-10">
|
||||
{{ t('CAPTAIN.CUSTOM_TOOLS.CATALOG.REQUIRED') }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section v-if="details.readme" class="pt-6 border-t border-n-weak">
|
||||
<div
|
||||
v-dompurify-html="readmeHtml"
|
||||
class="prose prose-sm max-w-none break-words text-n-slate-12 prose-headings:text-n-slate-12 prose-strong:text-n-slate-12 prose-code:text-n-slate-12 prose-a:text-n-blue-11 prose-p:my-2 prose-headings:mb-2 prose-headings:mt-4 prose-ul:my-2 prose-ol:my-2 prose-li:my-1"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
faded
|
||||
slate
|
||||
:label="t('GENERAL.CLOSE')"
|
||||
@click="close"
|
||||
/>
|
||||
<Policy :permissions="['administrator']">
|
||||
<Button
|
||||
type="button"
|
||||
:icon="installAction.icon"
|
||||
:label="installAction.label"
|
||||
@click="install"
|
||||
/>
|
||||
</Policy>
|
||||
</div>
|
||||
</template>
|
||||
</SidePanel>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
toolset: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center w-full min-w-0 gap-3">
|
||||
<div
|
||||
class="flex items-center justify-center overflow-hidden rounded-lg size-10 shrink-0 bg-n-alpha-2"
|
||||
>
|
||||
<template v-if="toolset.logo_url">
|
||||
<img
|
||||
:src="toolset.logo_url"
|
||||
alt=""
|
||||
class="object-contain size-full dark:hidden"
|
||||
/>
|
||||
<img
|
||||
:src="toolset.logo_dark_url || toolset.logo_url"
|
||||
alt=""
|
||||
class="hidden object-contain size-full dark:block"
|
||||
/>
|
||||
</template>
|
||||
<i v-else class="i-lucide-blocks size-5 text-n-slate-11" />
|
||||
</div>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<!-- The slot sits on the name line, for tags like the install status -->
|
||||
<div class="flex items-center min-w-0 gap-2">
|
||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
||||
{{ toolset.name }}
|
||||
</span>
|
||||
<slot />
|
||||
</div>
|
||||
<span class="text-xs truncate text-n-slate-11">
|
||||
{{ toolset.author }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user