Compare commits
11 Commits
codex/ruby
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc60ffc312 | ||
|
|
ab0182a9eb | ||
|
|
d79c052624 | ||
|
|
5f21903a7b | ||
|
|
f9cbacaa9e | ||
|
|
225b2af051 | ||
|
|
ccbda8b37f | ||
|
|
a296aa9191 | ||
|
|
52a6adedf8 | ||
|
|
f1c9e4831b | ||
|
|
af1e939467 |
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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
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!
|
||||
|
||||
|
||||
@@ -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`,
|
||||
|
||||
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;
|
||||
|
||||
@@ -23,6 +23,7 @@ const props = defineProps({
|
||||
const emit = defineEmits(['loadMore']);
|
||||
|
||||
const NOTE_CLAMP_LENGTH = 240;
|
||||
const SYSTEM_AUTHOR_ICON = 'i-lucide-settings';
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
@@ -59,7 +60,7 @@ const noteHtml = note => {
|
||||
const noteAuthor = note =>
|
||||
note.user?.id === currentUser.value.id
|
||||
? t('CONTACTS_LAYOUT.SIDEBAR.NOTES.YOU')
|
||||
: note.user?.name || t('COMPANIES.DETAIL.ACTIVITY.UNKNOWN_AUTHOR');
|
||||
: note.user?.name || t('COMPANIES.DETAIL.ACTIVITY.SYSTEM_AUTHOR');
|
||||
|
||||
const noteContactName = note =>
|
||||
note.contact?.name || t('COMPANIES.DETAIL.CONTACTS.UNNAMED_CONTACT');
|
||||
@@ -96,6 +97,7 @@ const groups = computed(() => groupByDay(props.notes, 'createdAt', t));
|
||||
<Avatar
|
||||
:name="noteAuthor(note)"
|
||||
:src="note.user?.thumbnail"
|
||||
:icon-name="note.user ? null : SYSTEM_AUTHOR_ICON"
|
||||
:size="40"
|
||||
hide-offline-status
|
||||
class="shrink-0"
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
defineProps({
|
||||
// 'installed' or 'update', from the catalog page's installed toolsets
|
||||
status: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: value => ['installed', 'update'].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs rounded-md shrink-0"
|
||||
:class="
|
||||
status === 'update'
|
||||
? 'bg-n-amber-3 text-n-amber-11'
|
||||
: 'bg-n-teal-3 text-n-teal-11'
|
||||
"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
status === 'update' ? 'i-lucide-arrow-up-circle' : 'i-lucide-check'
|
||||
"
|
||||
class="size-3"
|
||||
/>
|
||||
{{
|
||||
status === 'update'
|
||||
? t('CAPTAIN.CUSTOM_TOOLS.CATALOG.UPDATE_AVAILABLE')
|
||||
: t('CAPTAIN.CUSTOM_TOOLS.CATALOG.INSTALLED')
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,109 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import InstallManifestDialog from './InstallManifestDialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
source: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
// The assistant whose page is open, preselected in the picker
|
||||
assistantId: {
|
||||
type: [String, Number],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['installed', 'done']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const assistants = useMapGetter('captainAssistants/getRecords');
|
||||
|
||||
const pickerRef = ref(null);
|
||||
const installDialogRef = ref(null);
|
||||
const pickedAssistantId = ref(Number(props.assistantId));
|
||||
// Set once an assistant is chosen, which mounts the install dialog for it
|
||||
const installAssistantId = ref(null);
|
||||
|
||||
const assistantOptions = computed(() =>
|
||||
assistants.value.map(assistant => ({
|
||||
value: assistant.id,
|
||||
label: assistant.name,
|
||||
}))
|
||||
);
|
||||
|
||||
const install = async assistantId => {
|
||||
installAssistantId.value = assistantId;
|
||||
await nextTick();
|
||||
installDialogRef.value.open(props.source);
|
||||
};
|
||||
|
||||
const confirmAssistant = () => {
|
||||
install(pickedAssistantId.value);
|
||||
pickerRef.value.close();
|
||||
};
|
||||
|
||||
// Cancelling keeps the assistant that was open, not an unconfirmed pick
|
||||
const onPickerClose = () => {
|
||||
if (!installAssistantId.value) emit('done', Number(props.assistantId));
|
||||
};
|
||||
|
||||
// Leaving the tools page while assistants load must not open dialogs that are already gone
|
||||
let isUnmounted = false;
|
||||
onBeforeUnmount(() => {
|
||||
isUnmounted = true;
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await store.dispatch('captainAssistants/get');
|
||||
} catch {
|
||||
if (isUnmounted) return;
|
||||
// Finishing clears the install query, so the tools page doesn't keep a flow that never started
|
||||
useAlert(t('CAPTAIN.CUSTOM_TOOLS.INSTALL_LINK.LOAD_ERROR'));
|
||||
emit('done', Number(props.assistantId));
|
||||
return;
|
||||
}
|
||||
if (isUnmounted) return;
|
||||
|
||||
if (assistants.value.length > 1) {
|
||||
pickerRef.value.open();
|
||||
} else {
|
||||
install(Number(props.assistantId));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="pickerRef"
|
||||
:title="t('CAPTAIN.CUSTOM_TOOLS.INSTALL_LINK.TITLE')"
|
||||
:description="t('CAPTAIN.CUSTOM_TOOLS.INSTALL_LINK.DESCRIPTION')"
|
||||
:confirm-button-label="t('CAPTAIN.CUSTOM_TOOLS.INSTALL_MANIFEST.CONTINUE')"
|
||||
:disable-confirm-button="!pickedAssistantId"
|
||||
@confirm="confirmAssistant"
|
||||
@close="onPickerClose"
|
||||
>
|
||||
<ComboBox
|
||||
v-model="pickedAssistantId"
|
||||
:options="assistantOptions"
|
||||
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.INSTALL_LINK.PLACEHOLDER')"
|
||||
class="[&>div>button]:bg-n-alpha-black2"
|
||||
/>
|
||||
</Dialog>
|
||||
|
||||
<InstallManifestDialog
|
||||
v-if="installAssistantId"
|
||||
ref="installDialogRef"
|
||||
:assistant-id="installAssistantId"
|
||||
@installed="emit('installed', installAssistantId)"
|
||||
@close="emit('done', installAssistantId)"
|
||||
/>
|
||||
</template>
|
||||
@@ -568,7 +568,7 @@ const menuItems = computed(() => {
|
||||
{
|
||||
name: 'Tools',
|
||||
label: t('SIDEBAR.CAPTAIN_TOOLS'),
|
||||
activeOn: ['captain_tools_index'],
|
||||
activeOn: ['captain_tools_index', 'captain_tools_explore'],
|
||||
to: accountScopedRoute('captain_assistants_index', {
|
||||
navigationPath: 'captain_tools_index',
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import CopilotEditor from './CopilotEditor.vue';
|
||||
|
||||
let view = null;
|
||||
|
||||
// The component keeps its EditorView in a module local, so subclass it to
|
||||
// reach the DOM event handlers it registers.
|
||||
vi.mock('@chatwoot/prosemirror-schema', async importOriginal => {
|
||||
const actual = await importOriginal();
|
||||
class TrackedEditorView extends actual.EditorView {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
view = this;
|
||||
}
|
||||
}
|
||||
return { ...actual, EditorView: TrackedEditorView };
|
||||
});
|
||||
|
||||
// jsdom has no layout, and ProseMirror measures the caret through Range rects.
|
||||
const zeroRect = { top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0 };
|
||||
Range.prototype.getClientRects = () => [zeroRect];
|
||||
Range.prototype.getBoundingClientRect = () => zeroRect;
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
|
||||
let wrapper = null;
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount();
|
||||
wrapper = null;
|
||||
});
|
||||
|
||||
const mountEditor = () => {
|
||||
wrapper = mount(CopilotEditor, {
|
||||
props: { modelValue: 'make it shorter', autofocus: false },
|
||||
attachTo: document.body,
|
||||
});
|
||||
};
|
||||
|
||||
const editorKeydown = init => {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
cancelable: true,
|
||||
...init,
|
||||
});
|
||||
view.someProp('handleDOMEvents', handlers => handlers.keydown?.(view, event));
|
||||
return event;
|
||||
};
|
||||
|
||||
describe('CopilotEditor', () => {
|
||||
it('sends on a normal Enter', () => {
|
||||
mountEditor();
|
||||
editorKeydown({});
|
||||
expect(wrapper.emitted('send')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not send while an IME composition is active', () => {
|
||||
mountEditor();
|
||||
editorKeydown({ isComposing: true });
|
||||
expect(wrapper.emitted('send')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not send when WebKit reports the composition only through keyCode 229', () => {
|
||||
mountEditor();
|
||||
editorKeydown({ keyCode: 229 });
|
||||
expect(wrapper.emitted('send')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import { isComposing } from 'shared/helpers/KeyboardHelpers';
|
||||
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
@@ -132,7 +133,7 @@ function onKeydown(view, event) {
|
||||
|
||||
// Handle Enter key to send message (Shift+Enter for new line)
|
||||
// Skip if IME composition is active (CJK character confirmation)
|
||||
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
|
||||
if (event.key === 'Enter' && !event.shiftKey && !isComposing(event)) {
|
||||
event.preventDefault();
|
||||
handleSubmit();
|
||||
return true; // Prevent ProseMirror's default Enter handling
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createStore } from 'vuex';
|
||||
import Editor from './Editor.vue';
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ params: { accountId: 1 } }),
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}));
|
||||
|
||||
let view = null;
|
||||
|
||||
// The component keeps its EditorView in a module local, so subclass it to
|
||||
// reach the DOM event handlers it registers.
|
||||
vi.mock('@chatwoot/prosemirror-schema', async importOriginal => {
|
||||
const actual = await importOriginal();
|
||||
class TrackedEditorView extends actual.EditorView {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
view = this;
|
||||
}
|
||||
}
|
||||
return { ...actual, EditorView: TrackedEditorView };
|
||||
});
|
||||
|
||||
// jsdom has no layout, and ProseMirror measures the caret through Range rects.
|
||||
const zeroRect = { top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0 };
|
||||
Range.prototype.getClientRects = () => [zeroRect];
|
||||
Range.prototype.getBoundingClientRect = () => zeroRect;
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
|
||||
const store = createStore({
|
||||
getters: {
|
||||
getUISettings: () => ({ editor_message_key: 'enter' }),
|
||||
getSelectedChat: () => ({}),
|
||||
'globalConfig/get': () => ({}),
|
||||
'globalConfig/isOnChatwootCloud': () => false,
|
||||
'accounts/getAccount': () => () => ({}),
|
||||
'accounts/getUIFlags': () => ({}),
|
||||
'accounts/isFeatureEnabledonAccount': () => () => false,
|
||||
'draftMessages/getReplyEditorMode': () => 'reply',
|
||||
},
|
||||
});
|
||||
|
||||
let wrapper = null;
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount();
|
||||
wrapper = null;
|
||||
});
|
||||
|
||||
const mountEditor = () => {
|
||||
wrapper = mount(Editor, {
|
||||
props: { modelValue: 'thanks for reaching ou' },
|
||||
global: { plugins: [store] },
|
||||
attachTo: document.body,
|
||||
});
|
||||
};
|
||||
|
||||
// Runs the editor's own keydown handler, without ProseMirror's keymap, so the
|
||||
// assertion is about what this component does with the event.
|
||||
const editorKeydown = init => {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
code: 'Enter',
|
||||
cancelable: true,
|
||||
...init,
|
||||
});
|
||||
view.someProp('handleDOMEvents', handlers => handlers.keydown?.(view, event));
|
||||
return event;
|
||||
};
|
||||
|
||||
describe('Editor', () => {
|
||||
describe('Enter with enter-to-send enabled', () => {
|
||||
it('prevents the line break on a normal Enter', () => {
|
||||
mountEditor();
|
||||
expect(editorKeydown({}).defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves Enter alone while an IME composition is active', () => {
|
||||
mountEditor();
|
||||
expect(editorKeydown({ isComposing: true }).defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves Enter alone when WebKit reports the composition only through keyCode 229', () => {
|
||||
mountEditor();
|
||||
expect(editorKeydown({ keyCode: 229 }).defaultPrevented).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
import {
|
||||
hasPressedEnterAndNotCmdOrShift,
|
||||
hasPressedCommandAndEnter,
|
||||
isComposing,
|
||||
isEscape,
|
||||
} from 'shared/helpers/KeyboardHelpers';
|
||||
import { createTypingIndicator } from '@chatwoot/utils';
|
||||
@@ -800,6 +801,7 @@ function handleLineBreakWhenCmdAndEnterToSendEnabled(event) {
|
||||
}
|
||||
|
||||
function onKeydown(event) {
|
||||
if (isComposing(event)) return false;
|
||||
if (isEscape(event)) {
|
||||
collapseSelection(editorView);
|
||||
return true;
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
} from '@vueuse/core';
|
||||
// composable
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { useLabelSuggestions } from 'dashboard/composables/useLabelSuggestions';
|
||||
import { useCampaignHistory } from 'dashboard/composables/useCampaignHistory';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||
@@ -25,7 +24,6 @@ import { CONTACT_CONVERSATION_NAVIGATION } from 'dashboard/composables/useContac
|
||||
// components
|
||||
import ReplyBox from './ReplyBox.vue';
|
||||
import MessageList from 'next/message/MessageList.vue';
|
||||
import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue';
|
||||
import ContactConversationLink from './ContactConversationLink.vue';
|
||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
@@ -49,7 +47,6 @@ import {
|
||||
getUnreadScrollTop,
|
||||
} from './helpers/campaignScrollAnchor';
|
||||
import { calculateScrollTop } from './helpers/scrollTopCalculationHelper';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import {
|
||||
filterDuplicateSourceMessages,
|
||||
getReadMessages,
|
||||
@@ -60,10 +57,7 @@ import {
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
|
||||
import { REPLY_POLICY } from 'shared/constants/links';
|
||||
import wootConstants, {
|
||||
META_RESTRICTION_STATUS_URL,
|
||||
} from 'dashboard/constants/globals';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
|
||||
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
|
||||
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
@@ -84,7 +78,6 @@ export default {
|
||||
MessageList,
|
||||
ReplyBox,
|
||||
Banner,
|
||||
ConversationLabelSuggestion,
|
||||
ContactConversationLink,
|
||||
Spinner,
|
||||
NextButton,
|
||||
@@ -102,12 +95,6 @@ export default {
|
||||
const { height: containerHeight } = useElementSize(messagesViewRef);
|
||||
const { height: topBannerHeight } = useElementSize(topBannerRef);
|
||||
|
||||
const {
|
||||
captainTasksEnabled,
|
||||
isLabelSuggestionFeatureEnabled,
|
||||
getLabelSuggestions,
|
||||
} = useLabelSuggestions();
|
||||
|
||||
const {
|
||||
olderConversation,
|
||||
newerConversation,
|
||||
@@ -179,9 +166,6 @@ export default {
|
||||
|
||||
return {
|
||||
...useCampaignHistory(),
|
||||
captainTasksEnabled,
|
||||
getLabelSuggestions,
|
||||
isLabelSuggestionFeatureEnabled,
|
||||
olderConversation,
|
||||
newerConversation,
|
||||
openConversation,
|
||||
@@ -211,8 +195,6 @@ export default {
|
||||
conversationPanel: null,
|
||||
hasUserScrolled: false,
|
||||
isProgrammaticScroll: false,
|
||||
messageSentSinceOpened: false,
|
||||
labelSuggestions: [],
|
||||
};
|
||||
},
|
||||
|
||||
@@ -221,20 +203,8 @@ export default {
|
||||
currentChat: 'getSelectedChat',
|
||||
currentUserId: 'getCurrentUserID',
|
||||
listLoadingStatus: 'getAllMessagesLoaded',
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
isMetaMessageSendingDisabled: 'globalConfig/isMetaMessageSendingDisabled',
|
||||
}),
|
||||
isOpen() {
|
||||
return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN;
|
||||
},
|
||||
shouldShowLabelSuggestions() {
|
||||
return (
|
||||
this.isOpen &&
|
||||
this.captainTasksEnabled &&
|
||||
this.isLabelSuggestionFeatureEnabled &&
|
||||
!this.messageSentSinceOpened
|
||||
);
|
||||
},
|
||||
inboxId() {
|
||||
return this.currentChat.inbox_id;
|
||||
},
|
||||
@@ -414,8 +384,6 @@ export default {
|
||||
return;
|
||||
}
|
||||
this.fetchAllAttachmentsFromCurrentChat();
|
||||
this.fetchSuggestions();
|
||||
this.messageSentSinceOpened = false;
|
||||
this.resetReplyEditorHeight();
|
||||
},
|
||||
// The link is appended once the neighbours arrive, below an already scrolled list.
|
||||
@@ -431,11 +399,6 @@ export default {
|
||||
|
||||
created() {
|
||||
emitter.on(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
|
||||
// when a message is sent we set the flag to true this hides the label suggestions,
|
||||
// until the chat is changed and the flag is reset in the watch for currentChat
|
||||
emitter.on(BUS_EVENTS.MESSAGE_SENT, () => {
|
||||
this.messageSentSinceOpened = true;
|
||||
});
|
||||
// Anything that wants to write must bring the folded editor back first.
|
||||
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.showReplyBox);
|
||||
emitter.on(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, this.showReplyBox);
|
||||
@@ -445,7 +408,6 @@ export default {
|
||||
mounted() {
|
||||
this.addScrollListener();
|
||||
this.fetchAllAttachmentsFromCurrentChat();
|
||||
this.fetchSuggestions();
|
||||
},
|
||||
|
||||
unmounted() {
|
||||
@@ -454,47 +416,6 @@ export default {
|
||||
},
|
||||
|
||||
methods: {
|
||||
async fetchSuggestions() {
|
||||
// start empty, this ensures that the label suggestions are not shown
|
||||
this.labelSuggestions = [];
|
||||
|
||||
if (this.isLabelSuggestionDismissed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Early exit if conversation already has labels - no need to suggest more
|
||||
const existingLabels = this.currentChat?.labels || [];
|
||||
if (existingLabels.length > 0) return;
|
||||
|
||||
if (!this.captainTasksEnabled || !this.isLabelSuggestionFeatureEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.labelSuggestions = await this.getLabelSuggestions();
|
||||
|
||||
// once the labels are fetched, we need to scroll to bottom
|
||||
// but we need to wait for the DOM to be updated
|
||||
// so we use the nextTick method
|
||||
this.$nextTick(() => {
|
||||
// this param is added to route, telling the UI to navigate to the message
|
||||
// it is triggered by the SCROLL_TO_MESSAGE method
|
||||
// see setActiveChat on ConversationView.vue for more info
|
||||
const { messageId } = this.$route.query;
|
||||
|
||||
// only trigger the scroll to bottom if the user has not scrolled
|
||||
// and there's no active messageId that is selected in view
|
||||
if (!messageId && !this.hasUserScrolled) {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
});
|
||||
},
|
||||
isLabelSuggestionDismissed() {
|
||||
return LocalStorage.getFlag(
|
||||
LOCAL_STORAGE_KEYS.DISMISSED_LABEL_SUGGESTIONS,
|
||||
this.currentAccountId,
|
||||
this.currentChat.id
|
||||
);
|
||||
},
|
||||
fetchAllAttachmentsFromCurrentChat() {
|
||||
this.$store.dispatch('fetchAllAttachments', this.currentChat.id);
|
||||
},
|
||||
@@ -529,12 +450,6 @@ export default {
|
||||
},
|
||||
scrollToBottom() {
|
||||
this.isProgrammaticScroll = true;
|
||||
let relevantMessages = [];
|
||||
|
||||
// label suggestions are not part of the messages list
|
||||
// so we need to handle them separately
|
||||
let labelSuggestions =
|
||||
this.conversationPanel.querySelector('.label-suggestion');
|
||||
|
||||
// if there are unread messages, scroll to the first unread message
|
||||
if (this.unreadMessageCount > 0) {
|
||||
@@ -547,21 +462,12 @@ export default {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (labelSuggestions) {
|
||||
// when scrolling to the bottom, the label suggestions is below the last message
|
||||
// so we scroll there if there are no unread messages
|
||||
// Unread messages always take the highest priority
|
||||
relevantMessages = [labelSuggestions];
|
||||
} else {
|
||||
// if there are no unread messages or label suggestion, scroll to the last message
|
||||
// capturing last message from the messages list
|
||||
relevantMessages = getTimelineEntries(this.conversationPanel).slice(-1);
|
||||
}
|
||||
|
||||
// otherwise scroll to the last message in the timeline
|
||||
this.conversationPanel.scrollTop = calculateScrollTop(
|
||||
this.conversationPanel.scrollHeight,
|
||||
this.$el.scrollHeight,
|
||||
relevantMessages
|
||||
getTimelineEntries(this.conversationPanel).slice(-1)
|
||||
);
|
||||
},
|
||||
setScrollParams() {
|
||||
@@ -715,12 +621,6 @@ export default {
|
||||
</li>
|
||||
</template>
|
||||
<template #after>
|
||||
<ConversationLabelSuggestion
|
||||
v-if="shouldShowLabelSuggestions"
|
||||
:suggested-labels="labelSuggestions"
|
||||
:chat-labels="currentChat.labels"
|
||||
:conversation-id="currentChat.id"
|
||||
/>
|
||||
<ContactConversationLink
|
||||
v-if="newerConversation"
|
||||
direction="newer"
|
||||
|
||||
@@ -27,6 +27,7 @@ import { getUndefinedVariablesInMessage } from '@chatwoot/utils';
|
||||
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
|
||||
import ContentTemplates from './ContentTemplates/ContentTemplatesModal.vue';
|
||||
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
|
||||
import { isComposing } from 'shared/helpers/KeyboardHelpers';
|
||||
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
|
||||
import { trimContent, debounce, getRecipients } from '@chatwoot/utils';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
@@ -113,6 +114,7 @@ export default {
|
||||
},
|
||||
Enter: {
|
||||
action: e => {
|
||||
if (isComposing(e)) return;
|
||||
if (proxy.isAValidEvent('enter')) {
|
||||
proxy.onSendReply();
|
||||
e.preventDefault();
|
||||
@@ -121,7 +123,8 @@ export default {
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
'$mod+Enter': {
|
||||
action: () => {
|
||||
action: e => {
|
||||
if (isComposing(e)) return;
|
||||
if (copilot.isActive.value && proxy.isFocused) {
|
||||
proxy.onSubmitCopilotReply();
|
||||
} else if (proxy.isAValidEvent('cmd_enter')) {
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { dateFormat } from 'shared/helpers/timeHelper';
|
||||
import { useAbortableRequest } from 'dashboard/composables/useAbortableRequest';
|
||||
import {
|
||||
formatStripeAmount,
|
||||
STRIPE_STATUS_COLORS,
|
||||
} from 'dashboard/helper/stripeHelper';
|
||||
import StripeAPI from 'dashboard/api/integrations/stripe';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Label from 'dashboard/components-next/label/Label.vue';
|
||||
import StripeSubscription from './StripeSubscription.vue';
|
||||
|
||||
const props = defineProps({
|
||||
conversationId: { type: [Number, String], required: true },
|
||||
contactEmail: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const { run, isPending } = useAbortableRequest();
|
||||
const summary = ref(null);
|
||||
const customerId = ref('');
|
||||
const error = ref(false);
|
||||
|
||||
const load = async () => {
|
||||
error.value = false;
|
||||
summary.value = null;
|
||||
try {
|
||||
const response = await run(signal =>
|
||||
StripeAPI.customer(
|
||||
props.conversationId,
|
||||
customerId.value || undefined,
|
||||
signal
|
||||
)
|
||||
);
|
||||
if (response) summary.value = response.data;
|
||||
} catch {
|
||||
error.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
[() => props.conversationId, () => props.contactEmail],
|
||||
() => {
|
||||
customerId.value = '';
|
||||
load();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 px-4 py-3 text-sm text-n-slate-12">
|
||||
<Spinner v-if="isPending" class="mx-auto" />
|
||||
<p v-else-if="error" role="alert" class="m-0 text-n-ruby-11">
|
||||
{{ $t('STRIPE_INTEGRATION.ERROR') }}
|
||||
</p>
|
||||
<template v-else-if="summary">
|
||||
<p v-if="summary.missing_email" class="m-0 text-n-slate-11">
|
||||
{{ $t('STRIPE_INTEGRATION.NO_EMAIL') }}
|
||||
</p>
|
||||
<p v-else-if="!summary.customers.length" class="m-0 text-n-slate-11">
|
||||
{{ $t('STRIPE_INTEGRATION.EMPTY') }}
|
||||
</p>
|
||||
<template v-else>
|
||||
<label v-if="summary.customers.length > 1" class="flex flex-col gap-2">
|
||||
{{ $t('STRIPE_INTEGRATION.SELECT') }}
|
||||
<select v-model="customerId" class="!mb-0" @change="load">
|
||||
<option value="" disabled>
|
||||
{{ $t('STRIPE_INTEGRATION.SELECT') }}
|
||||
</option>
|
||||
<option
|
||||
v-for="customer in summary.customers"
|
||||
:key="customer.id"
|
||||
:value="customer.id"
|
||||
>
|
||||
{{
|
||||
$t('STRIPE_INTEGRATION.CUSTOMER_OPTION', {
|
||||
name: customer.name || customer.email,
|
||||
id: customer.id,
|
||||
})
|
||||
}}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<p
|
||||
v-if="summary.has_more_customers"
|
||||
class="m-0 text-xs text-n-slate-11"
|
||||
>
|
||||
{{ $t('STRIPE_INTEGRATION.MORE') }}
|
||||
</p>
|
||||
<template v-if="summary.customer">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<span
|
||||
class="flex items-center justify-center size-9 shrink-0 rounded-lg bg-n-alpha-2 text-n-slate-11"
|
||||
>
|
||||
<span class="i-lucide-user-round size-4" aria-hidden="true" />
|
||||
</span>
|
||||
<div
|
||||
class="flex flex-col gap-0.5 min-w-0 text-xs text-n-slate-11"
|
||||
:title="summary.customer.id"
|
||||
>
|
||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
||||
{{ summary.customer.name }}
|
||||
</span>
|
||||
<span class="truncate">{{ summary.customer.email }}</span>
|
||||
<span v-if="summary.customer.phone">
|
||||
{{ summary.customer.phone }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<section class="flex flex-col gap-2 pt-3 border-t border-n-weak">
|
||||
<h4 class="m-0 text-xs font-medium text-n-slate-11">
|
||||
{{ $t('STRIPE_INTEGRATION.SUBSCRIPTIONS') }}
|
||||
</h4>
|
||||
<p
|
||||
v-if="!summary.subscriptions.length"
|
||||
class="m-0 text-xs text-n-slate-11"
|
||||
>
|
||||
{{ $t('STRIPE_INTEGRATION.NO_SUBSCRIPTIONS') }}
|
||||
</p>
|
||||
<StripeSubscription
|
||||
v-for="subscription in summary.subscriptions"
|
||||
:key="subscription.id"
|
||||
:subscription="subscription"
|
||||
/>
|
||||
</section>
|
||||
<section class="flex flex-col pt-3 border-t border-n-weak">
|
||||
<h4 class="m-0 text-xs font-medium text-n-slate-11">
|
||||
{{ $t('STRIPE_INTEGRATION.INVOICES') }}
|
||||
</h4>
|
||||
<p
|
||||
v-if="!summary.invoices.length"
|
||||
class="mt-2 mb-0 text-xs text-n-slate-11"
|
||||
>
|
||||
{{ $t('STRIPE_INTEGRATION.NO_INVOICES') }}
|
||||
</p>
|
||||
<div
|
||||
v-for="invoice in summary.invoices"
|
||||
:key="invoice.id"
|
||||
class="flex items-center justify-between gap-3 py-2.5 border-b border-n-weak last:border-b-0 last:pb-0"
|
||||
>
|
||||
<div class="flex flex-col gap-1 min-w-0">
|
||||
<span
|
||||
class="text-sm font-medium truncate"
|
||||
:title="invoice.number || invoice.id"
|
||||
>
|
||||
{{ invoice.number || invoice.id }}
|
||||
</span>
|
||||
<span class="text-xs text-n-slate-11">
|
||||
{{ dateFormat(invoice.created) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-1 shrink-0">
|
||||
<span class="text-sm font-medium tabular-nums">
|
||||
{{ formatStripeAmount(invoice.total, invoice.currency) }}
|
||||
</span>
|
||||
<Label
|
||||
:label="invoice.status"
|
||||
:color="STRIPE_STATUS_COLORS[invoice.status] || 'slate'"
|
||||
compact
|
||||
class="capitalize"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,217 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
import { dateFormat } from 'shared/helpers/timeHelper';
|
||||
import {
|
||||
formatStripeAmount,
|
||||
STRIPE_STATUS_COLORS,
|
||||
} from 'dashboard/helper/stripeHelper';
|
||||
import SidePanel from 'dashboard/components-next/side-panel/SidePanel.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Label from 'dashboard/components-next/label/Label.vue';
|
||||
|
||||
const props = defineProps({ subscription: { type: Object, required: true } });
|
||||
|
||||
const { t } = useI18n();
|
||||
const { copy, copied } = useClipboard();
|
||||
const panel = ref(null);
|
||||
|
||||
const title = computed(
|
||||
() =>
|
||||
props.subscription.items
|
||||
?.map(item => item.name)
|
||||
.filter(Boolean)
|
||||
.join(', ') || t('STRIPE_INTEGRATION.SUBSCRIPTION')
|
||||
);
|
||||
|
||||
const statusLabel = computed(() =>
|
||||
props.subscription.status.replaceAll('_', ' ')
|
||||
);
|
||||
|
||||
const statusColor = computed(
|
||||
() => STRIPE_STATUS_COLORS[props.subscription.status] || 'slate'
|
||||
);
|
||||
|
||||
const isTrialing = computed(() => props.subscription.status === 'trialing');
|
||||
|
||||
const isRenewing = computed(
|
||||
() =>
|
||||
['active', 'trialing'].includes(props.subscription.status) &&
|
||||
!props.subscription.cancel_at_period_end &&
|
||||
!props.subscription.cancel_at
|
||||
);
|
||||
|
||||
const trialMessage = computed(() =>
|
||||
isTrialing.value && props.subscription.trial_end
|
||||
? t('STRIPE_INTEGRATION.TRIAL_ENDS', {
|
||||
date: dateFormat(props.subscription.trial_end),
|
||||
})
|
||||
: ''
|
||||
);
|
||||
|
||||
const cancellationMessage = computed(() => {
|
||||
if (props.subscription.status === 'canceled') return '';
|
||||
if (props.subscription.cancel_at)
|
||||
return t('STRIPE_INTEGRATION.CANCELS', {
|
||||
date: dateFormat(props.subscription.cancel_at),
|
||||
});
|
||||
return props.subscription.cancel_at_period_end
|
||||
? t('STRIPE_INTEGRATION.CANCELS_AT_PERIOD_END')
|
||||
: '';
|
||||
});
|
||||
|
||||
const periodEndLabel = timestamp =>
|
||||
isRenewing.value
|
||||
? t('STRIPE_INTEGRATION.RENEWS', { date: dateFormat(timestamp) })
|
||||
: t('STRIPE_INTEGRATION.PERIOD_END', { date: dateFormat(timestamp) });
|
||||
|
||||
// The card only has room for one period, so it is shown for single item subscriptions
|
||||
const cardPeriodEnd = computed(() => {
|
||||
const [item, ...otherItems] = props.subscription.items || [];
|
||||
if (isTrialing.value || otherItems.length || !item?.current_period_end)
|
||||
return '';
|
||||
return periodEndLabel(item.current_period_end);
|
||||
});
|
||||
|
||||
const priceLabel = item => {
|
||||
if (
|
||||
item.unit_amount == null ||
|
||||
item.billing_scheme === 'tiered' ||
|
||||
item.recurring?.usage_type === 'metered'
|
||||
)
|
||||
return t('STRIPE_INTEGRATION.VARIABLE_PRICE');
|
||||
const amount = formatStripeAmount(item.unit_amount, item.currency, {
|
||||
preservePrecision: true,
|
||||
});
|
||||
if (!item.recurring) return amount;
|
||||
const count = item.recurring.interval_count;
|
||||
return t('STRIPE_INTEGRATION.PRICE_INTERVAL', {
|
||||
amount,
|
||||
interval: {
|
||||
day: t('STRIPE_INTEGRATION.INTERVAL.day', count),
|
||||
week: t('STRIPE_INTEGRATION.INTERVAL.week', count),
|
||||
month: t('STRIPE_INTEGRATION.INTERVAL.month', count),
|
||||
year: t('STRIPE_INTEGRATION.INTERVAL.year', count),
|
||||
}[item.recurring.interval],
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 w-full rounded-lg bg-n-alpha-1 p-3 text-start hover:bg-n-alpha-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-n-brand"
|
||||
:aria-label="$t('STRIPE_INTEGRATION.VIEW_SUBSCRIPTION', { name: title })"
|
||||
@click="panel.open()"
|
||||
>
|
||||
<span class="flex flex-col flex-1 gap-1 min-w-0 text-xs text-n-slate-11">
|
||||
<span class="flex items-start justify-between gap-2">
|
||||
<span class="text-sm font-medium break-words text-n-slate-12">
|
||||
{{ title }}
|
||||
</span>
|
||||
<Label
|
||||
:label="statusLabel"
|
||||
:color="statusColor"
|
||||
compact
|
||||
class="capitalize"
|
||||
/>
|
||||
</span>
|
||||
<span v-for="item in subscription.items" :key="item.id">
|
||||
{{ priceLabel(item) }}
|
||||
<span
|
||||
v-if="item.quantity != null"
|
||||
class="ms-2 ps-2 border-s border-n-strong"
|
||||
>
|
||||
{{ $t('STRIPE_INTEGRATION.QUANTITY', { count: item.quantity }) }}
|
||||
</span>
|
||||
</span>
|
||||
<span v-if="trialMessage">{{ trialMessage }}</span>
|
||||
<span v-if="cancellationMessage" class="text-n-amber-11">
|
||||
{{ cancellationMessage }}
|
||||
</span>
|
||||
<span v-else-if="cardPeriodEnd">{{ cardPeriodEnd }}</span>
|
||||
</span>
|
||||
<span
|
||||
class="i-lucide-chevron-right size-4 shrink-0 text-n-slate-10"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<SidePanel ref="panel" :title="title" width="md">
|
||||
<div class="flex flex-col gap-5 text-sm text-n-slate-12">
|
||||
<div class="flex items-center gap-2">
|
||||
<Label
|
||||
:label="statusLabel"
|
||||
:color="statusColor"
|
||||
compact
|
||||
class="capitalize"
|
||||
/>
|
||||
<span class="flex-1 min-w-0 font-mono text-xs truncate text-n-slate-11">
|
||||
{{ subscription.id }}
|
||||
</span>
|
||||
<Button
|
||||
:label="
|
||||
copied
|
||||
? $t('STRIPE_INTEGRATION.COPIED')
|
||||
: $t('STRIPE_INTEGRATION.COPY_ID')
|
||||
"
|
||||
:icon="copied ? 'i-lucide-check' : 'i-lucide-copy'"
|
||||
xs
|
||||
ghost
|
||||
slate
|
||||
@click="copy(subscription.id)"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="trialMessage" class="m-0">{{ trialMessage }}</p>
|
||||
<p
|
||||
v-if="cancellationMessage"
|
||||
class="flex items-center gap-2 px-3 py-2 m-0 rounded-lg bg-n-amber-2 text-n-amber-11"
|
||||
>
|
||||
<span class="i-lucide-calendar-x size-4 shrink-0" aria-hidden="true" />
|
||||
{{ cancellationMessage }}
|
||||
</p>
|
||||
<p
|
||||
v-if="subscription.status === 'canceled' && subscription.canceled_at"
|
||||
class="m-0"
|
||||
>
|
||||
{{
|
||||
$t('STRIPE_INTEGRATION.CANCELED_ON', {
|
||||
date: dateFormat(subscription.canceled_at),
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<section
|
||||
v-for="item in subscription.items"
|
||||
:key="item.id"
|
||||
class="flex flex-col gap-1.5 pt-4 border-t border-n-weak text-n-slate-11"
|
||||
>
|
||||
<h4
|
||||
v-if="subscription.items.length > 1"
|
||||
class="m-0 text-sm font-medium text-n-slate-12"
|
||||
>
|
||||
{{ item.name || $t('STRIPE_INTEGRATION.SUBSCRIPTION') }}
|
||||
</h4>
|
||||
<p class="m-0 text-n-slate-12">{{ priceLabel(item) }}</p>
|
||||
<p v-if="item.quantity != null" class="m-0">
|
||||
{{ $t('STRIPE_INTEGRATION.QUANTITY', { count: item.quantity }) }}
|
||||
</p>
|
||||
<p v-if="item.current_period_start" class="m-0">
|
||||
{{
|
||||
$t('STRIPE_INTEGRATION.PERIOD_START', {
|
||||
date: dateFormat(item.current_period_start),
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<p v-if="item.current_period_end" class="m-0">
|
||||
{{ periodEndLabel(item.current_period_end) }}
|
||||
</p>
|
||||
</section>
|
||||
<p v-if="subscription.has_more_items" class="m-0 text-n-slate-11">
|
||||
{{ $t('STRIPE_INTEGRATION.MORE_ITEMS') }}
|
||||
</p>
|
||||
<p class="pt-4 m-0 text-xs border-t border-n-weak text-n-slate-11">
|
||||
{{ $t('STRIPE_INTEGRATION.PRICE_NOTE') }}
|
||||
</p>
|
||||
</div>
|
||||
</SidePanel>
|
||||
</template>
|
||||
@@ -1,277 +0,0 @@
|
||||
<script>
|
||||
// components
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
|
||||
// composables
|
||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
|
||||
// store & api
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
// utils & constants
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { CAPTAIN_EVENTS } from '../../../../helper/AnalyticsHelper/events';
|
||||
|
||||
export default {
|
||||
name: 'LabelSuggestion',
|
||||
components: {
|
||||
Avatar,
|
||||
NextButton,
|
||||
},
|
||||
props: {
|
||||
suggestedLabels: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
chatLabels: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { captainTasksEnabled } = useCaptain();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
return { captainTasksEnabled, replaceInstallationName };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isDismissed: false,
|
||||
isHovered: false,
|
||||
selectedLabels: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
allLabels: 'labels/getLabels',
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
currentChat: 'getSelectedChat',
|
||||
}),
|
||||
conversationId() {
|
||||
return this.currentChat?.id;
|
||||
},
|
||||
labelTooltip() {
|
||||
if (this.preparedLabels.length > 1) {
|
||||
return this.$t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.MULTIPLE_SUGGESTION');
|
||||
}
|
||||
|
||||
return this.$t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.SINGLE_SUGGESTION');
|
||||
},
|
||||
addButtonText() {
|
||||
if (this.selectedLabels.length === 1) {
|
||||
return this.$t('LABEL_MGMT.SUGGESTIONS.ADD_SELECTED_LABEL');
|
||||
}
|
||||
|
||||
if (this.selectedLabels.length > 1) {
|
||||
return this.$t('LABEL_MGMT.SUGGESTIONS.ADD_SELECTED_LABELS');
|
||||
}
|
||||
|
||||
return this.$t('LABEL_MGMT.SUGGESTIONS.ADD_ALL_LABELS');
|
||||
},
|
||||
preparedLabels() {
|
||||
return this.allLabels.filter(label =>
|
||||
this.suggestedLabels.includes(label.title)
|
||||
);
|
||||
},
|
||||
shouldShowSuggestions() {
|
||||
if (this.isDismissed) return false;
|
||||
if (!this.captainTasksEnabled) return false;
|
||||
|
||||
return this.preparedLabels.length && this.chatLabels.length === 0;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
conversationId: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
this.selectedLabels = [];
|
||||
this.isDismissed = this.isConversationDismissed();
|
||||
},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
pushOrAddLabel(label) {
|
||||
if (this.preparedLabels.length === 1) {
|
||||
this.addAllLabels();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.selectedLabels.includes(label)) {
|
||||
this.selectedLabels.push(label);
|
||||
} else {
|
||||
this.selectedLabels = this.selectedLabels.filter(l => l !== label);
|
||||
}
|
||||
},
|
||||
dismissSuggestions() {
|
||||
LocalStorage.setFlag(
|
||||
LOCAL_STORAGE_KEYS.DISMISSED_LABEL_SUGGESTIONS,
|
||||
this.currentAccountId,
|
||||
this.conversationId
|
||||
);
|
||||
|
||||
// dismiss this once the values are set
|
||||
this.isDismissed = true;
|
||||
this.trackLabelEvent(CAPTAIN_EVENTS.LABEL_SUGGESTION_DISMISSED);
|
||||
},
|
||||
isConversationDismissed() {
|
||||
return LocalStorage.getFlag(
|
||||
LOCAL_STORAGE_KEYS.DISMISSED_LABEL_SUGGESTIONS,
|
||||
this.currentAccountId,
|
||||
this.conversationId
|
||||
);
|
||||
},
|
||||
addAllLabels() {
|
||||
let labelsToAdd = this.selectedLabels;
|
||||
if (!labelsToAdd.length) {
|
||||
labelsToAdd = this.preparedLabels.map(label => label.title);
|
||||
}
|
||||
this.$store.dispatch('conversationLabels/update', {
|
||||
conversationId: this.conversationId,
|
||||
labels: labelsToAdd,
|
||||
});
|
||||
this.trackLabelEvent(CAPTAIN_EVENTS.LABEL_SUGGESTION_APPLIED);
|
||||
},
|
||||
trackLabelEvent(event) {
|
||||
const payload = {
|
||||
conversationId: this.conversationId,
|
||||
account: this.currentAccountId,
|
||||
suggestions: this.suggestedLabels,
|
||||
labelsApplied: this.selectedLabels.length
|
||||
? this.selectedLabels
|
||||
: this.suggestedLabels,
|
||||
};
|
||||
|
||||
useTrack(event, payload);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<li
|
||||
v-if="shouldShowSuggestions"
|
||||
class="label-suggestion right list-none"
|
||||
@mouseover="isHovered = true"
|
||||
@mouseleave="isHovered = false"
|
||||
>
|
||||
<div class="wrap">
|
||||
<div class="label-suggestion--container">
|
||||
<h6 class="label-suggestion--title">
|
||||
{{ $t('LABEL_MGMT.SUGGESTIONS.SUGGESTED_LABELS') }}
|
||||
</h6>
|
||||
<div class="label-suggestion--options">
|
||||
<button
|
||||
v-for="label in preparedLabels"
|
||||
:key="label.title"
|
||||
v-tooltip.top="{
|
||||
content: selectedLabels.includes(label.title)
|
||||
? $t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.DESELECT')
|
||||
: labelTooltip,
|
||||
delay: { show: 600, hide: 0 },
|
||||
hideOnClick: true,
|
||||
}"
|
||||
class="label-suggestion--option !px-0"
|
||||
@click="pushOrAddLabel(label.title)"
|
||||
>
|
||||
<woot-label
|
||||
variant="dashed"
|
||||
v-bind="label"
|
||||
:bg-color="selectedLabels.includes(label.title) ? '#2781F6' : ''"
|
||||
/>
|
||||
</button>
|
||||
<NextButton
|
||||
v-if="preparedLabels.length === 1"
|
||||
v-tooltip.top="{
|
||||
content: $t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.DISMISS'),
|
||||
delay: { show: 600, hide: 0 },
|
||||
hideOnClick: true,
|
||||
}"
|
||||
faded
|
||||
xs
|
||||
icon="i-lucide-x"
|
||||
class="flex-shrink-0"
|
||||
:color="isHovered ? 'ruby' : 'blue'"
|
||||
@click="dismissSuggestions"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="preparedLabels.length > 1"
|
||||
class="inline-flex items-center gap-1"
|
||||
>
|
||||
<NextButton
|
||||
xs
|
||||
icon="i-lucide-plus"
|
||||
class="flex-shrink-0"
|
||||
:variant="selectedLabels.length === 0 ? 'faded' : 'solid'"
|
||||
:label="addButtonText"
|
||||
@click="addAllLabels"
|
||||
/>
|
||||
<NextButton
|
||||
v-tooltip.top="{
|
||||
content: $t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.DISMISS'),
|
||||
delay: { show: 600, hide: 0 },
|
||||
hideOnClick: true,
|
||||
}"
|
||||
faded
|
||||
xs
|
||||
icon="i-lucide-x"
|
||||
class="flex-shrink-0"
|
||||
:color="isHovered ? 'ruby' : 'blue'"
|
||||
@click="dismissSuggestions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sender--info has-tooltip" data-original-title="null">
|
||||
<Avatar
|
||||
v-tooltip.top="{
|
||||
content: replaceInstallationName(
|
||||
$t('LABEL_MGMT.SUGGESTIONS.POWERED_BY')
|
||||
),
|
||||
delay: { show: 600, hide: 0 },
|
||||
hideOnClick: true,
|
||||
}"
|
||||
:size="16"
|
||||
name="chatwoot-ai"
|
||||
icon-name="i-lucide-sparkles"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.wrap {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.label-suggestion {
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1rem;
|
||||
|
||||
.label-suggestion--container {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.label-suggestion--options {
|
||||
@apply gap-0.5 text-end flex items-center;
|
||||
|
||||
button.label-suggestion--option {
|
||||
.label {
|
||||
cursor: pointer;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.label-suggestion--title {
|
||||
@apply text-n-slate-11 mt-0.5 text-xxs;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,4 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils';
|
||||
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
|
||||
import { nextTick } from 'vue';
|
||||
import { createStore } from 'vuex';
|
||||
@@ -40,6 +40,7 @@ const buildStore = ({
|
||||
drafts = {},
|
||||
inboxes,
|
||||
isMetaMessageSendingDisabled = false,
|
||||
uiSettings = {},
|
||||
}) =>
|
||||
createStore({
|
||||
state: {
|
||||
@@ -68,7 +69,7 @@ const buildStore = ({
|
||||
getCurrentUser: () => ({ id: 7, name: 'Agent', accounts: [] }),
|
||||
getCurrentAccountId: () => 1,
|
||||
getMessageSignature: () => '',
|
||||
getUISettings: () => ({}),
|
||||
getUISettings: () => uiSettings,
|
||||
getLastEmailInSelectedChat: () => null,
|
||||
'globalConfig/get': () => ({}),
|
||||
'globalConfig/isMetaMessageSendingDisabled': () =>
|
||||
@@ -95,6 +96,7 @@ const mountWith = ({
|
||||
drafts,
|
||||
inboxes,
|
||||
isMetaMessageSendingDisabled,
|
||||
uiSettings,
|
||||
}) => {
|
||||
const store = buildStore({
|
||||
inbox,
|
||||
@@ -103,6 +105,7 @@ const mountWith = ({
|
||||
drafts,
|
||||
inboxes,
|
||||
isMetaMessageSendingDisabled,
|
||||
uiSettings,
|
||||
});
|
||||
const wrapper = shallowMount(ReplyBox, {
|
||||
global: {
|
||||
@@ -447,4 +450,31 @@ describe('ReplyBox', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('send shortcut', () => {
|
||||
const pressEnter = init =>
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', ...init })
|
||||
);
|
||||
|
||||
it('ignores Enter while an IME composition is active', async () => {
|
||||
const { wrapper } = mountWith({
|
||||
inbox: { channel_type: 'Channel::WebWidget' },
|
||||
uiSettings: { editor_message_key: 'enter' },
|
||||
});
|
||||
await flushPromises();
|
||||
wrapper.vm.isFocused = true;
|
||||
const onSendReply = vi
|
||||
.spyOn(wrapper.vm, 'onSendReply')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
pressEnter({ key: 'Process', isComposing: true });
|
||||
expect(onSendReply).not.toHaveBeenCalled();
|
||||
|
||||
pressEnter({});
|
||||
expect(onSendReply).toHaveBeenCalledTimes(1);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import StripeCustomer from '../StripeCustomer.vue';
|
||||
import StripeAPI from 'dashboard/api/integrations/stripe';
|
||||
|
||||
vi.mock('dashboard/components-next/TeleportWithDirection.vue', () => ({
|
||||
default: {
|
||||
props: ['to'],
|
||||
template: '<Teleport :to="to"><slot /></Teleport>',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/api/integrations/stripe', () => ({
|
||||
default: { customer: vi.fn() },
|
||||
}));
|
||||
|
||||
describe('StripeCustomer', () => {
|
||||
const customer = {
|
||||
id: 'cus_alfred',
|
||||
name: 'Alfred',
|
||||
email: 'alfred@example.com',
|
||||
};
|
||||
const data = {
|
||||
customers: [customer],
|
||||
customer,
|
||||
subscriptions: [
|
||||
{
|
||||
id: 'sub_test',
|
||||
status: 'active',
|
||||
items: [
|
||||
{
|
||||
id: 'si_test',
|
||||
name: 'Support Pro',
|
||||
quantity: 3,
|
||||
unit_amount: 4900,
|
||||
currency: 'usd',
|
||||
recurring: { interval: 'month', interval_count: 1 },
|
||||
current_period_start: 1788998400,
|
||||
current_period_end: 1791590400,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
invoices: [
|
||||
{
|
||||
id: 'in_test',
|
||||
number: 'INV-001',
|
||||
total: 1000,
|
||||
currency: 'usd',
|
||||
status: 'paid',
|
||||
created: 1788998400,
|
||||
},
|
||||
],
|
||||
};
|
||||
let wrapper;
|
||||
|
||||
it.each(['active', 'canceled', 'trialing'])(
|
||||
'shows trial-end copy only for trialing subscriptions (%s)',
|
||||
async status => {
|
||||
StripeAPI.customer.mockResolvedValue({
|
||||
data: {
|
||||
...data,
|
||||
subscriptions: [
|
||||
{ ...data.subscriptions[0], status, trial_end: 1788998400 },
|
||||
],
|
||||
},
|
||||
});
|
||||
wrapper = mount(StripeCustomer, { props: { conversationId: 1 } });
|
||||
await flushPromises();
|
||||
await wrapper.get('button').trigger('click');
|
||||
await flushPromises();
|
||||
expect(
|
||||
document.body.textContent.includes('STRIPE_INTEGRATION.TRIAL_ENDS')
|
||||
).toBe(status === 'trialing');
|
||||
}
|
||||
);
|
||||
|
||||
afterEach(() => wrapper?.unmount());
|
||||
|
||||
it('renders customer billing details without a refresh button', async () => {
|
||||
StripeAPI.customer.mockResolvedValue({ data });
|
||||
wrapper = mount(StripeCustomer, { props: { conversationId: 1 } });
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain('Alfred');
|
||||
expect(wrapper.text()).toContain('INV-001');
|
||||
expect(wrapper.text()).toContain('$10.00');
|
||||
expect(wrapper.text()).toContain('paid');
|
||||
expect(wrapper.get('button').attributes('aria-label')).toBe(
|
||||
'STRIPE_INTEGRATION.VIEW_SUBSCRIPTION'
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the no-email state', async () => {
|
||||
StripeAPI.customer.mockResolvedValue({
|
||||
data: { customers: [], missing_email: true },
|
||||
});
|
||||
wrapper = mount(StripeCustomer, { props: { conversationId: 1 } });
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain('STRIPE_INTEGRATION.NO_EMAIL');
|
||||
});
|
||||
|
||||
it('shows scheduled cancellation instead of a renewal', async () => {
|
||||
StripeAPI.customer.mockResolvedValue({
|
||||
data: {
|
||||
...data,
|
||||
subscriptions: [
|
||||
{ ...data.subscriptions[0], cancel_at_period_end: true },
|
||||
],
|
||||
},
|
||||
});
|
||||
wrapper = mount(StripeCustomer, { props: { conversationId: 1 } });
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain(
|
||||
'STRIPE_INTEGRATION.CANCELS_AT_PERIOD_END'
|
||||
);
|
||||
expect(wrapper.text()).not.toContain('STRIPE_INTEGRATION.RENEWS');
|
||||
});
|
||||
|
||||
it('does not display metered pricing as a fixed charge', async () => {
|
||||
const subscription = data.subscriptions[0];
|
||||
const item = subscription.items[0];
|
||||
StripeAPI.customer.mockResolvedValue({
|
||||
data: {
|
||||
...data,
|
||||
subscriptions: [
|
||||
{
|
||||
...subscription,
|
||||
items: [
|
||||
{
|
||||
...item,
|
||||
recurring: { ...item.recurring, usage_type: 'metered' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
wrapper = mount(StripeCustomer, { props: { conversationId: 1 } });
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain('STRIPE_INTEGRATION.VARIABLE_PRICE');
|
||||
expect(wrapper.text()).not.toContain('STRIPE_INTEGRATION.PRICE_INTERVAL');
|
||||
});
|
||||
|
||||
it('opens subscription details and removes them when the conversation changes', async () => {
|
||||
StripeAPI.customer.mockResolvedValueOnce({ data });
|
||||
wrapper = mount(StripeCustomer, { props: { conversationId: 1 } });
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain('Support Pro');
|
||||
await wrapper.get('button').trigger('click');
|
||||
expect(document.querySelector('[role="dialog"]').textContent).toContain(
|
||||
'sub_test'
|
||||
);
|
||||
expect(document.querySelector('[role="dialog"]').textContent).toContain(
|
||||
'STRIPE_INTEGRATION.PERIOD_START'
|
||||
);
|
||||
StripeAPI.customer.mockResolvedValueOnce({ data: { customers: [] } });
|
||||
await wrapper.setProps({ conversationId: 2 });
|
||||
await flushPromises();
|
||||
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('lets the agent select between customers sharing the same email', async () => {
|
||||
StripeAPI.customer.mockResolvedValueOnce({
|
||||
data: { customers: [customer, { ...customer, id: 'cus_second' }] },
|
||||
});
|
||||
wrapper = mount(StripeCustomer, { props: { conversationId: 1 } });
|
||||
await flushPromises();
|
||||
StripeAPI.customer.mockResolvedValueOnce({ data });
|
||||
await wrapper.get('select').setValue('cus_alfred');
|
||||
await flushPromises();
|
||||
expect(StripeAPI.customer).toHaveBeenLastCalledWith(
|
||||
1,
|
||||
'cus_alfred',
|
||||
expect.any(AbortSignal)
|
||||
);
|
||||
expect(wrapper.text()).toContain('INV-001');
|
||||
});
|
||||
|
||||
it('clears the previous customer when switching to an unmatched conversation', async () => {
|
||||
StripeAPI.customer.mockResolvedValueOnce({ data });
|
||||
wrapper = mount(StripeCustomer, { props: { conversationId: 1 } });
|
||||
await flushPromises();
|
||||
StripeAPI.customer.mockResolvedValueOnce({ data: { customers: [] } });
|
||||
await wrapper.setProps({ conversationId: 2 });
|
||||
await flushPromises();
|
||||
expect(StripeAPI.customer).toHaveBeenLastCalledWith(
|
||||
2,
|
||||
undefined,
|
||||
expect.any(AbortSignal)
|
||||
);
|
||||
expect(wrapper.text()).not.toContain('Alfred');
|
||||
expect(wrapper.text()).toContain('STRIPE_INTEGRATION.EMPTY');
|
||||
});
|
||||
|
||||
it('shows an error without exposing the previous customer', async () => {
|
||||
StripeAPI.customer.mockResolvedValueOnce({ data });
|
||||
wrapper = mount(StripeCustomer, { props: { conversationId: 1 } });
|
||||
await flushPromises();
|
||||
StripeAPI.customer.mockRejectedValueOnce(
|
||||
new Error('private provider error')
|
||||
);
|
||||
await wrapper.setProps({ conversationId: 2 });
|
||||
await flushPromises();
|
||||
expect(wrapper.get('[role="alert"]').text()).toContain(
|
||||
'STRIPE_INTEGRATION.ERROR'
|
||||
);
|
||||
expect(wrapper.text()).not.toContain('Alfred');
|
||||
expect(wrapper.text()).not.toContain('private provider error');
|
||||
});
|
||||
|
||||
it('reloads and clears the selected customer after the contact email changes', async () => {
|
||||
StripeAPI.customer.mockResolvedValueOnce({
|
||||
data: { customers: [customer, { ...customer, id: 'cus_second' }] },
|
||||
});
|
||||
wrapper = mount(StripeCustomer, {
|
||||
props: { conversationId: 1, contactEmail: customer.email },
|
||||
});
|
||||
await flushPromises();
|
||||
StripeAPI.customer.mockResolvedValueOnce({ data });
|
||||
await wrapper.get('select').setValue('cus_alfred');
|
||||
await flushPromises();
|
||||
StripeAPI.customer.mockResolvedValueOnce({
|
||||
data: { customers: [], missing_email: true },
|
||||
});
|
||||
await wrapper.setProps({ contactEmail: '' });
|
||||
await flushPromises();
|
||||
expect(StripeAPI.customer).toHaveBeenLastCalledWith(
|
||||
1,
|
||||
undefined,
|
||||
expect.any(AbortSignal)
|
||||
);
|
||||
expect(wrapper.text()).not.toContain('Alfred');
|
||||
expect(wrapper.text()).toContain('STRIPE_INTEGRATION.NO_EMAIL');
|
||||
StripeAPI.customer.mockResolvedValueOnce({ data });
|
||||
await wrapper.setProps({ contactEmail: customer.email });
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain('INV-001');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['jpy', 1000, '1,000'],
|
||||
['isk', 1000, '10'],
|
||||
])('formats %s using Stripe minor units', async (currency, total, amount) => {
|
||||
StripeAPI.customer.mockResolvedValue({
|
||||
data: { ...data, invoices: [{ ...data.invoices[0], currency, total }] },
|
||||
});
|
||||
wrapper = mount(StripeCustomer, { props: { conversationId: 1 } });
|
||||
await flushPromises();
|
||||
expect(wrapper.get('.tabular-nums').text()).toContain(amount);
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import TasksAPI from 'dashboard/api/captain/tasks';
|
||||
|
||||
/**
|
||||
* Cleans and normalizes a list of labels.
|
||||
* @param {string} labels - A comma-separated string of labels.
|
||||
* @returns {string[]} An array of cleaned and unique labels.
|
||||
*/
|
||||
const cleanLabels = labels => {
|
||||
return labels
|
||||
.toLowerCase()
|
||||
.split(',')
|
||||
.filter(label => label.trim())
|
||||
.map(label => label.trim())
|
||||
.filter((label, index, self) => self.indexOf(label) === index);
|
||||
};
|
||||
|
||||
export function useLabelSuggestions() {
|
||||
const store = useStore();
|
||||
const { isCloudFeatureEnabled } = useAccount();
|
||||
const appIntegrations = useMapGetter('integrations/getAppIntegrations');
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const conversationId = computed(() => currentChat.value?.id);
|
||||
|
||||
const captainTasksEnabled = computed(() => {
|
||||
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_TASKS);
|
||||
});
|
||||
|
||||
const aiIntegration = computed(
|
||||
() =>
|
||||
appIntegrations.value.find(
|
||||
integration => integration.id === 'openai' && !!integration.hooks.length
|
||||
)?.hooks[0]
|
||||
);
|
||||
|
||||
const isLabelSuggestionFeatureEnabled = computed(() => {
|
||||
if (aiIntegration.value) {
|
||||
const { settings = {} } = aiIntegration.value || {};
|
||||
return !!settings.label_suggestion;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const fetchIntegrationsIfRequired = async () => {
|
||||
if (!appIntegrations.value.length) {
|
||||
await store.dispatch('integrations/get');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets label suggestions for the current conversation.
|
||||
* @returns {Promise<string[]>} An array of suggested labels.
|
||||
*/
|
||||
const getLabelSuggestions = async () => {
|
||||
if (!conversationId.value) return [];
|
||||
|
||||
try {
|
||||
const result = await TasksAPI.labelSuggestion(conversationId.value);
|
||||
const {
|
||||
data: { message: labels },
|
||||
} = result;
|
||||
return cleanLabels(labels);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchIntegrationsIfRequired();
|
||||
});
|
||||
|
||||
return {
|
||||
captainTasksEnabled,
|
||||
isLabelSuggestionFeatureEnabled,
|
||||
getLabelSuggestions,
|
||||
};
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([
|
||||
{ name: 'previous_conversation' },
|
||||
{ name: 'conversation_participants' },
|
||||
{ name: 'linear_issues' },
|
||||
{ name: 'stripe_customer' },
|
||||
{ name: 'shopify_orders' },
|
||||
]);
|
||||
|
||||
|
||||
@@ -4,11 +4,9 @@ export const LOCAL_STORAGE_KEYS = {
|
||||
WIDGET_BUILDER: 'widgetBubble_',
|
||||
DRAFT_MESSAGES: 'draftMessages',
|
||||
COLOR_SCHEME: 'color_scheme',
|
||||
DISMISSED_LABEL_SUGGESTIONS: 'labelSuggestionsDismissed',
|
||||
MESSAGE_REPLY_TO: 'messageReplyTo',
|
||||
RECENT_SEARCHES: 'recentSearches',
|
||||
SIDEBAR_MINIMIZED_SECTIONS: 'sidebarMinimizedSections',
|
||||
CAPTAIN_OVERVIEW_V2: 'captain_overview_v2',
|
||||
CAPTAIN_ASSISTANT_TOOLS_BANNER_DISMISSED:
|
||||
'captain_assistant_tools_banner_dismissed',
|
||||
};
|
||||
|
||||
@@ -39,6 +39,7 @@ export const FEATURE_FLAGS = {
|
||||
IP_LOOKUP: 'ip_lookup',
|
||||
LINEAR: 'linear_integration',
|
||||
SHOPIFY: 'shopify_integration',
|
||||
STRIPE: 'stripe_integration',
|
||||
CAPTAIN: 'captain_integration',
|
||||
CUSTOM_ROLES: 'custom_roles',
|
||||
CHATWOOT_V4: 'chatwoot_v4',
|
||||
|
||||
@@ -110,10 +110,6 @@ export const CAPTAIN_EVENTS = Object.freeze({
|
||||
// Follow-up events
|
||||
FOLLOW_UP_SENT: 'Captain: Follow-up sent',
|
||||
|
||||
// Label suggestions
|
||||
LABEL_SUGGESTION_APPLIED: 'Captain: Label suggestion applied',
|
||||
LABEL_SUGGESTION_DISMISSED: 'Captain: Label suggestion dismissed',
|
||||
|
||||
// Conversation sidebar suggestions
|
||||
LABEL_SUGGESTIONS_REQUESTED: 'Captain: Label suggestions requested',
|
||||
PRIORITY_SUGGESTION_REQUESTED: 'Captain: Priority suggestion requested',
|
||||
|
||||
@@ -1,31 +1,5 @@
|
||||
const FEATURE_HELP_URLS = {
|
||||
agent_bots: 'https://chwt.app/hc/agent-bots',
|
||||
agents: 'https://chwt.app/hc/agents',
|
||||
audit_logs: 'https://chwt.app/hc/audit-logs',
|
||||
campaigns: 'https://chwt.app/hc/campaigns',
|
||||
canned_responses: 'https://chwt.app/hc/canned',
|
||||
channel_email: 'https://chwt.app/hc/email',
|
||||
channel_facebook: 'https://chwt.app/hc/fb',
|
||||
custom_attributes: 'https://chwt.app/hc/custom-attributes',
|
||||
dashboard_apps: 'https://chwt.app/hc/dashboard-apps',
|
||||
help_center: 'https://chwt.app/hc/help-center',
|
||||
inboxes: 'https://chwt.app/hc/inboxes',
|
||||
integrations: 'https://chwt.app/hc/integrations',
|
||||
labels: 'https://chwt.app/hc/labels',
|
||||
macros: 'https://chwt.app/hc/macros',
|
||||
reports: 'https://chwt.app/hc/reports',
|
||||
sla: 'https://chwt.app/hc/sla',
|
||||
team_management: 'https://chwt.app/hc/teams',
|
||||
webhook: 'https://chwt.app/hc/webhooks',
|
||||
whatsapp_templates:
|
||||
'https://www.chatwoot.com/hc/user-guide/articles/1754940076-whatsapp-templates',
|
||||
billing: 'https://chwt.app/pricing',
|
||||
saml: 'https://chwt.app/hc/saml',
|
||||
captain: 'https://chwt.app/captain-docs',
|
||||
captain_billing: 'https://chwt.app/hc/captain_billing',
|
||||
shopify: 'https://chwt.app/hc/shopify',
|
||||
};
|
||||
import helpUrls from '../../../../config/feature_help_urls.yml';
|
||||
|
||||
export function getHelpUrlForFeature(featureName) {
|
||||
return FEATURE_HELP_URLS[featureName];
|
||||
return helpUrls[featureName];
|
||||
}
|
||||
|
||||
30
app/javascript/dashboard/helper/specs/stripeHelper.spec.js
Normal file
30
app/javascript/dashboard/helper/specs/stripeHelper.spec.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { formatStripeAmount } from '../stripeHelper';
|
||||
|
||||
describe('formatStripeAmount', () => {
|
||||
it('preserves fractional minor units for subscription prices', () => {
|
||||
expect(formatStripeAmount('0.05', 'USD', { preservePrecision: true })).toBe(
|
||||
'$0.0005'
|
||||
);
|
||||
expect(
|
||||
formatStripeAmount('0.000000000001', 'USD', { preservePrecision: true })
|
||||
).toBe('$0.00000000000001');
|
||||
});
|
||||
|
||||
it('keeps standard invoice precision and trims insignificant price zeros', () => {
|
||||
expect(formatStripeAmount(1000, 'USD')).toBe('$10.00');
|
||||
expect(
|
||||
formatStripeAmount('1000.00', 'USD', { preservePrecision: true })
|
||||
).toBe('$10.00');
|
||||
});
|
||||
|
||||
it('uses two-decimal API units for UGX', () => {
|
||||
expect(formatStripeAmount(5000, 'UGX')).toBe(
|
||||
new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency: 'UGX',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(50)
|
||||
);
|
||||
});
|
||||
});
|
||||
49
app/javascript/dashboard/helper/stripeHelper.js
Normal file
49
app/javascript/dashboard/helper/stripeHelper.js
Normal file
@@ -0,0 +1,49 @@
|
||||
// Stripe API units are independent of the locale's display precision.
|
||||
const ZERO_DECIMAL_CURRENCIES = [
|
||||
'BIF',
|
||||
'CLP',
|
||||
'DJF',
|
||||
'GNF',
|
||||
'JPY',
|
||||
'KMF',
|
||||
'KRW',
|
||||
'MGA',
|
||||
'PYG',
|
||||
'RWF',
|
||||
'VND',
|
||||
'VUV',
|
||||
'XAF',
|
||||
'XOF',
|
||||
'XPF',
|
||||
];
|
||||
const THREE_DECIMAL_CURRENCIES = ['BHD', 'JOD', 'KWD', 'OMR', 'TND'];
|
||||
|
||||
export const STRIPE_STATUS_COLORS = {
|
||||
active: 'teal',
|
||||
paid: 'teal',
|
||||
trialing: 'blue',
|
||||
open: 'amber',
|
||||
past_due: 'ruby',
|
||||
unpaid: 'ruby',
|
||||
uncollectible: 'ruby',
|
||||
};
|
||||
|
||||
export const formatStripeAmount = (
|
||||
amount,
|
||||
currency,
|
||||
{ preservePrecision = false } = {}
|
||||
) => {
|
||||
const code = currency.toUpperCase();
|
||||
let digits = 2;
|
||||
if (ZERO_DECIMAL_CURRENCIES.includes(code)) digits = 0;
|
||||
if (THREE_DECIMAL_CURRENCIES.includes(code)) digits = 3;
|
||||
const fractionalDigits = preservePrecision
|
||||
? (String(amount).split('.')[1] || '').replace(/0+$/, '').length
|
||||
: 0;
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency: code,
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits + fractionalDigits,
|
||||
}).format(Number(amount) / 10 ** digits);
|
||||
};
|
||||
@@ -75,7 +75,7 @@
|
||||
"ACTIVITY": {
|
||||
"TODAY": "Today",
|
||||
"YESTERDAY": "Yesterday",
|
||||
"UNKNOWN_AUTHOR": "Someone",
|
||||
"SYSTEM_AUTHOR": "System",
|
||||
"EMPTY_CONVERSATIONS": "No conversations yet. They'll show up here when this company's people get in touch.",
|
||||
"EMPTY_NOTES": "No notes yet. Notes added to this company's people show up here.",
|
||||
"LOAD_MORE": "Load more",
|
||||
|
||||
@@ -1,4 +1,54 @@
|
||||
{
|
||||
"STRIPE_INTEGRATION": {
|
||||
"UNAVAILABLE": "Stripe is unavailable for this account. Contact your administrator to check the integration configuration.",
|
||||
"TITLE": "Stripe",
|
||||
"DESCRIPTION": "View customer details, subscriptions, and invoices alongside your conversations.",
|
||||
"CONNECT": "Connect",
|
||||
"DISCONNECT": "Disconnect from Chatwoot",
|
||||
"DISCONNECT_NOTE": "Disconnecting removes the saved credentials from Chatwoot. Uninstall the app in Stripe to revoke its access there.",
|
||||
"CONNECTED": "Connected",
|
||||
"RECONNECT": "Reconnect Stripe",
|
||||
"REAUTHORIZATION_REQUIRED": "Reconnect required",
|
||||
"ACCOUNT": "Connected account",
|
||||
"ACCOUNT_ID": "Account ID",
|
||||
"ENVIRONMENT": "Environment",
|
||||
"SANDBOX": "Sandbox",
|
||||
"LIVE": "Live",
|
||||
"CONNECTED_ON": "Connected on",
|
||||
"OPEN_STRIPE": "Open Stripe dashboard",
|
||||
"ERROR": "Unable to load Stripe. Please try again or reconnect the integration.",
|
||||
"EMPTY": "No Stripe customer matches this contact's email.",
|
||||
"NO_EMAIL": "Add an email to this contact to look up Stripe customers.",
|
||||
"SELECT": "Select a matching customer",
|
||||
"CUSTOMER_OPTION": "{name} — {id}",
|
||||
"MORE": "Showing the first 100 matching customers. Refine the contact email if the customer is not listed.",
|
||||
"SUBSCRIPTIONS": "Latest five subscriptions",
|
||||
"SUBSCRIPTION": "Subscription",
|
||||
"VIEW_SUBSCRIPTION": "View {name} subscription details",
|
||||
"COPY_ID": "Copy ID",
|
||||
"COPIED": "Copied",
|
||||
"QUANTITY": "Quantity: {count}",
|
||||
"PRICE_INTERVAL": "{amount} / {interval}",
|
||||
"INTERVAL": {
|
||||
"day": "day | {count} days",
|
||||
"week": "week | {count} weeks",
|
||||
"month": "month | {count} months",
|
||||
"year": "year | {count} years"
|
||||
},
|
||||
"VARIABLE_PRICE": "Usage-based or tiered pricing",
|
||||
"RENEWS": "Renews {date}",
|
||||
"PERIOD_START": "Period starts {date}",
|
||||
"PERIOD_END": "Period ends {date}",
|
||||
"TRIAL_ENDS": "Trial ends {date}",
|
||||
"CANCELS": "Cancels {date}",
|
||||
"CANCELS_AT_PERIOD_END": "Cancels at the end of the billing period",
|
||||
"CANCELED_ON": "Canceled {date}",
|
||||
"MORE_ITEMS": "Additional subscription items are not shown.",
|
||||
"PRICE_NOTE": "Prices are per unit, before discounts and taxes. They are not an estimate of the next invoice.",
|
||||
"INVOICES": "Recent invoices",
|
||||
"NO_SUBSCRIPTIONS": "No subscriptions found.",
|
||||
"NO_INVOICES": "No invoices found."
|
||||
},
|
||||
"INTEGRATION_SETTINGS": {
|
||||
"SHOPIFY": {
|
||||
"HEADER": "Shopify",
|
||||
@@ -1281,18 +1331,38 @@
|
||||
"CUSTOM_TOOLS": {
|
||||
"HEADER": "Tools",
|
||||
"ADD_NEW": "Create a new tool",
|
||||
"CATALOG": {
|
||||
"BUTTON": "Tools Catalog",
|
||||
"HEADER": "Tools Catalog",
|
||||
"DESCRIPTION": "Browse toolsets from the Captain tools catalog and install them on this assistant.",
|
||||
"ALL": "All",
|
||||
"EMPTY": "No toolsets in this category yet.",
|
||||
"LOAD_ERROR": "Could not load the tools catalog. Check the catalog URL in the Captain settings.",
|
||||
"TOOL_COUNT": "{n} tool | {n} tools",
|
||||
"VERSION": "v{version}",
|
||||
"UPDATED": "Updated {time}",
|
||||
"REPOSITORY": "Repository",
|
||||
"MANIFEST": "Manifest",
|
||||
"CONFIGURATION": "Configuration",
|
||||
"REQUIRED": "Required",
|
||||
"DETAILS_ERROR": "Could not load the toolset details.",
|
||||
"INSTALLED": "Installed",
|
||||
"UPDATE_AVAILABLE": "Update available"
|
||||
},
|
||||
"INSTALL_LINK": {
|
||||
"TITLE": "Choose an assistant",
|
||||
"DESCRIPTION": "Pick the assistant to install this toolset on.",
|
||||
"PLACEHOLDER": "Select an assistant",
|
||||
"LOAD_ERROR": "Could not load your assistants. Reload the page to try again."
|
||||
},
|
||||
"INSTALL_MANIFEST": {
|
||||
"TITLE": "Install a toolset",
|
||||
"DESCRIPTION": "Install a toolset from the Captain tools catalog.",
|
||||
"SOURCE_LABEL": "Toolset",
|
||||
"SOURCE_PLACEHOLDER": "chatwoot/tools/folder",
|
||||
"SOURCE_HELP": "Installs the latest version from the catalog.",
|
||||
"VERSION_LABEL": "v{version} · {revision}",
|
||||
"UPDATE_NOTE": "A different version is installed. Updating replaces its tools and keeps whether each one is enabled. Enter the secrets again.",
|
||||
"TOOLS_LABEL": "Tools",
|
||||
"CONFIGURATION_LABEL": "Configuration",
|
||||
"CONTINUE": "Continue",
|
||||
"BACK": "Back",
|
||||
"INSTALL": "Install",
|
||||
"UPDATE": "Update",
|
||||
"REINSTALL": "Reinstall",
|
||||
|
||||
@@ -43,20 +43,6 @@
|
||||
"DELETE": "Delete",
|
||||
"CANCEL": "Cancel"
|
||||
},
|
||||
"SUGGESTIONS": {
|
||||
"TOOLTIP": {
|
||||
"SINGLE_SUGGESTION": "Add label to conversation",
|
||||
"MULTIPLE_SUGGESTION": "Select this label",
|
||||
"DESELECT": "Deselect label",
|
||||
"DISMISS": "Dismiss suggestion"
|
||||
},
|
||||
"POWERED_BY": "Chatwoot AI",
|
||||
"DISMISS": "Dismiss",
|
||||
"ADD_SELECTED_LABELS": "Add selected labels",
|
||||
"ADD_SELECTED_LABEL": "Add selected label",
|
||||
"ADD_ALL_LABELS": "Add all labels",
|
||||
"SUGGESTED_LABELS": "Suggested labels"
|
||||
},
|
||||
"ADD": {
|
||||
"TITLE": "Add label",
|
||||
"DESC": "Labels let you group the conversations together.",
|
||||
|
||||
@@ -569,12 +569,6 @@
|
||||
"HELP_CENTER_SEARCH": {
|
||||
"TITLE": "Help Center Search Indexing",
|
||||
"DESCRIPTION": "Use AI for context aware search inside your help center articles."
|
||||
},
|
||||
"LABEL_SUGGESTION": {
|
||||
"TITLE": "Label Suggestion",
|
||||
"DESCRIPTION": "Automatically suggest relevant labels and tags for conversations based on content analysis and context.",
|
||||
"MODEL_TITLE": "Label Suggestion Model",
|
||||
"MODEL_DESCRIPTION": "Select the AI model to use for analyzing conversations and suggesting appropriate labels"
|
||||
}
|
||||
},
|
||||
"API": {
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
import CaptainAssistant from 'dashboard/api/captain/assistant';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import OverviewV2 from 'dashboard/components-next/captain/pageComponents/overview/v2/OverviewV2.vue';
|
||||
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
|
||||
import RangeSelector from 'dashboard/components-next/captain/pageComponents/overview/RangeSelector.vue';
|
||||
import WelcomeCard from 'dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue';
|
||||
import MetricCard from 'dashboard/components-next/captain/pageComponents/overview/MetricCard.vue';
|
||||
import AssistantDrilldownDrawer from 'dashboard/components-next/captain/pageComponents/overview/AssistantDrilldownDrawer.vue';
|
||||
import KnowledgeCard from 'dashboard/components-next/captain/pageComponents/overview/KnowledgeCard.vue';
|
||||
import QuickLinks from 'dashboard/components-next/captain/pageComponents/overview/QuickLinks.vue';
|
||||
import InboxBanner from 'dashboard/components-next/captain/pageComponents/overview/InboxBanner.vue';
|
||||
import CoverageBanner from 'dashboard/components-next/captain/pageComponents/overview/CoverageBanner.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const isOverviewV2Enabled =
|
||||
LocalStorage.get(LOCAL_STORAGE_KEYS.CAPTAIN_OVERVIEW_V2) === true;
|
||||
// Drilldown is admin-only; the backend policy enforces the same restriction.
|
||||
const { checkPermissions } = usePolicy();
|
||||
const canDrilldown = computed(() => checkPermissions(['administrator']));
|
||||
|
||||
const selectedRange = ref('7');
|
||||
|
||||
const assistantId = computed(() => route.params.assistantId);
|
||||
const metricStats = ref(null);
|
||||
const faqStats = ref(null);
|
||||
const isFetchingMetrics = ref(false);
|
||||
|
||||
// Increments on every fetch so a response (or retry) from a superseded
|
||||
// range/assistant can't clobber the latest request's state.
|
||||
let metricsFetchToken = 0;
|
||||
let faqStatsFetchToken = 0;
|
||||
let metricsAbortController = null;
|
||||
let faqStatsAbortController = null;
|
||||
|
||||
const fetchMetrics = async () => {
|
||||
metricsFetchToken += 1;
|
||||
const token = metricsFetchToken;
|
||||
metricsAbortController?.abort();
|
||||
metricsAbortController = new AbortController();
|
||||
const { signal } = metricsAbortController;
|
||||
metricStats.value = null;
|
||||
isFetchingMetrics.value = true;
|
||||
|
||||
const requestMetrics = () =>
|
||||
CaptainAssistant.getMetrics({
|
||||
assistantId: assistantId.value,
|
||||
range: selectedRange.value,
|
||||
signal,
|
||||
});
|
||||
|
||||
let data = null;
|
||||
try {
|
||||
({ data } = await requestMetrics());
|
||||
} catch {
|
||||
// One silent retry before giving up, unless the request was aborted.
|
||||
try {
|
||||
if (token === metricsFetchToken && !signal.aborted)
|
||||
({ data } = await requestMetrics());
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (token !== metricsFetchToken || signal.aborted) return;
|
||||
metricStats.value = data;
|
||||
isFetchingMetrics.value = false;
|
||||
};
|
||||
|
||||
const fetchFaqStats = async () => {
|
||||
faqStatsFetchToken += 1;
|
||||
const token = faqStatsFetchToken;
|
||||
faqStatsAbortController?.abort();
|
||||
faqStatsAbortController = new AbortController();
|
||||
const { signal } = faqStatsAbortController;
|
||||
faqStats.value = null;
|
||||
|
||||
try {
|
||||
const { data } = await CaptainAssistant.getFaqStats({
|
||||
assistantId: assistantId.value,
|
||||
signal,
|
||||
});
|
||||
if (token === faqStatsFetchToken && !signal.aborted) faqStats.value = data;
|
||||
} catch {
|
||||
if (token === faqStatsFetchToken && !signal.aborted) faqStats.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const summaryStats = computed(() => {
|
||||
if (!metricStats.value || !faqStats.value) return null;
|
||||
|
||||
return { ...metricStats.value, knowledge: faqStats.value };
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
metricsAbortController?.abort();
|
||||
faqStatsAbortController?.abort();
|
||||
});
|
||||
|
||||
if (!isOverviewV2Enabled) {
|
||||
watch([selectedRange, assistantId], fetchMetrics, { immediate: true });
|
||||
watch(assistantId, fetchFaqStats, { immediate: true });
|
||||
}
|
||||
|
||||
// `direction` says whether a rising trend is good ('up'), bad ('down'), or
|
||||
// neutral, so we can colour the delta independently of its sign.
|
||||
const resolveTrendGood = (trendValue, direction) => {
|
||||
if (direction === 'neutral' || trendValue === 0) return null;
|
||||
return direction === 'up' ? trendValue > 0 : trendValue < 0;
|
||||
};
|
||||
|
||||
// Trend units mirror the backend pack mode: a relative percent change ('%') for
|
||||
// :percent metrics, a percentage-point delta (' pts') for rate metrics packed as
|
||||
// :point, and a plain number for :absolute counts like conversation depth.
|
||||
const TREND_SUFFIX = { percent: '%', point: ' pts', absolute: '' };
|
||||
|
||||
// Hours-saved is reported in hours, but large values read better as days. Past
|
||||
// 100h we switch the unit so the card stays legible.
|
||||
const formatDuration = hours =>
|
||||
hours >= 100 ? `${Math.round(hours / 24)}d` : `${hours}h`;
|
||||
|
||||
const metricFor = (statKey, formatValue, direction, trendKind = 'percent') => {
|
||||
const data = metricStats.value?.[statKey];
|
||||
if (!data) return { value: '—', trend: '', trendGood: null };
|
||||
|
||||
const sign = data.trend > 0 ? '+' : '';
|
||||
return {
|
||||
value: formatValue(data.current),
|
||||
trend: `${sign}${data.trend}${TREND_SUFFIX[trendKind]}`,
|
||||
trendGood: resolveTrendGood(data.trend, direction),
|
||||
};
|
||||
};
|
||||
|
||||
const metrics = computed(() => [
|
||||
{
|
||||
key: 'handled',
|
||||
metric: 'conversations_handled',
|
||||
label: t('CAPTAIN.OVERVIEW.METRICS.HANDLED.LABEL'),
|
||||
hint: t('CAPTAIN.OVERVIEW.METRICS.HANDLED.HINT'),
|
||||
...metricFor('conversations_handled', v => v.toLocaleString(), 'up'),
|
||||
},
|
||||
{
|
||||
key: 'autoResolution',
|
||||
metric: 'auto_resolution_rate',
|
||||
label: t('CAPTAIN.OVERVIEW.METRICS.AUTO_RESOLUTION.LABEL'),
|
||||
hint: t('CAPTAIN.OVERVIEW.METRICS.AUTO_RESOLUTION.HINT'),
|
||||
...metricFor('auto_resolution_rate', v => `${v}%`, 'up', 'point'),
|
||||
},
|
||||
{
|
||||
key: 'handoff',
|
||||
metric: 'handoff_rate',
|
||||
label: t('CAPTAIN.OVERVIEW.METRICS.HANDOFF.LABEL'),
|
||||
hint: t('CAPTAIN.OVERVIEW.METRICS.HANDOFF.HINT'),
|
||||
...metricFor('handoff_rate', v => `${v}%`, 'down', 'point'),
|
||||
},
|
||||
{
|
||||
key: 'reopen',
|
||||
metric: 'reopen_rate',
|
||||
label: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.LABEL'),
|
||||
hint: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.HINT'),
|
||||
...metricFor('reopen_rate', v => `${v}%`, 'down', 'point'),
|
||||
},
|
||||
{
|
||||
key: 'hoursSaved',
|
||||
label: t('CAPTAIN.OVERVIEW.METRICS.HOURS_SAVED.LABEL'),
|
||||
hint: t('CAPTAIN.OVERVIEW.METRICS.HOURS_SAVED.HINT'),
|
||||
...metricFor('hours_saved', formatDuration, 'up'),
|
||||
},
|
||||
{
|
||||
key: 'depth',
|
||||
label: t('CAPTAIN.OVERVIEW.METRICS.DEPTH.LABEL'),
|
||||
hint: t('CAPTAIN.OVERVIEW.METRICS.DEPTH.HINT'),
|
||||
...metricFor(
|
||||
'conversation_depth',
|
||||
v => v.toFixed(1),
|
||||
'neutral',
|
||||
'absolute'
|
||||
),
|
||||
},
|
||||
]);
|
||||
|
||||
const drilldown = ref({ metric: '', label: '', value: '' });
|
||||
const isDrilldownOpen = ref(false);
|
||||
|
||||
const openDrilldown = metric => {
|
||||
drilldown.value = {
|
||||
metric: metric.metric,
|
||||
label: metric.label,
|
||||
value: metric.value,
|
||||
};
|
||||
isDrilldownOpen.value = true;
|
||||
};
|
||||
|
||||
const closeDrilldown = () => {
|
||||
isDrilldownOpen.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OverviewV2 v-if="isOverviewV2Enabled" />
|
||||
<PageLayout
|
||||
v-else
|
||||
:header-title="$t('CAPTAIN.OVERVIEW.HEADER')"
|
||||
:is-empty="false"
|
||||
:show-pagination-footer="false"
|
||||
:show-know-more="false"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||
>
|
||||
<template #headerActions>
|
||||
<RangeSelector v-model="selectedRange" />
|
||||
</template>
|
||||
<template #paywall>
|
||||
<CaptainPaywall />
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-6 pb-8">
|
||||
<InboxBanner />
|
||||
|
||||
<CoverageBanner :knowledge="faqStats ?? undefined" />
|
||||
|
||||
<WelcomeCard :range="selectedRange" :stats="summaryStats" />
|
||||
|
||||
<div
|
||||
class="grid grid-cols-1 gap-px overflow-hidden border rounded-xl sm:grid-cols-2 lg:grid-cols-3 bg-n-weak border-n-weak"
|
||||
>
|
||||
<MetricCard
|
||||
v-for="metric in metrics"
|
||||
:key="metric.key"
|
||||
:label="metric.label"
|
||||
:value="metric.value"
|
||||
:trend="metric.trend"
|
||||
:hint="metric.hint"
|
||||
:trend-good="metric.trendGood"
|
||||
:loading="isFetchingMetrics"
|
||||
:clickable="
|
||||
canDrilldown && Boolean(metric.metric) && !isFetchingMetrics
|
||||
"
|
||||
@click="openDrilldown(metric)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<KnowledgeCard :knowledge="faqStats ?? undefined" />
|
||||
|
||||
<QuickLinks />
|
||||
</div>
|
||||
|
||||
<AssistantDrilldownDrawer
|
||||
v-if="canDrilldown"
|
||||
:open="isDrilldownOpen"
|
||||
:assistant-id="assistantId"
|
||||
:metric="drilldown.metric"
|
||||
:metric-name="drilldown.label"
|
||||
:metric-value="drilldown.value"
|
||||
:range="selectedRange"
|
||||
@close="closeDrilldown"
|
||||
/>
|
||||
</template>
|
||||
</PageLayout>
|
||||
</template>
|
||||
@@ -10,7 +10,7 @@ import CaptainPageRouteView from './pages/CaptainPageRouteView.vue';
|
||||
import AssistantsIndexPage from './pages/AssistantsIndexPage.vue';
|
||||
import AssistantEmptyStateIndex from './assistants/Index.vue';
|
||||
|
||||
import AssistantOverviewIndex from './assistants/overview/Index.vue';
|
||||
import AssistantOverviewIndex from 'dashboard/components-next/captain/pageComponents/overview/v2/OverviewV2.vue';
|
||||
import AssistantSettingsIndex from './assistants/settings/Index.vue';
|
||||
import AssistantSystemSettingsIndex from './assistants/settings/System.vue';
|
||||
import AssistantAudienceSettingsIndex from './assistants/settings/Audience.vue';
|
||||
@@ -24,6 +24,8 @@ import DocumentsIndex from './documents/Index.vue';
|
||||
import ResponsesIndex from './responses/Index.vue';
|
||||
import FaqSuggestionsIndex from './responses/FaqSuggestions.vue';
|
||||
import CustomToolsIndex from './tools/Index.vue';
|
||||
import CustomToolsExplore from './tools/Explore.vue';
|
||||
import CustomToolsInstall from './tools/Install.vue';
|
||||
|
||||
const meta = {
|
||||
permissions: ['administrator', 'agent'],
|
||||
@@ -73,6 +75,18 @@ const assistantRoutes = [
|
||||
name: 'captain_tools_index',
|
||||
meta: metaCustomTools,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/tools/explore'),
|
||||
component: CustomToolsExplore,
|
||||
name: 'captain_tools_explore',
|
||||
meta: metaCustomTools,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/toolsets/install'),
|
||||
component: CustomToolsInstall,
|
||||
name: 'captain_toolset_install',
|
||||
meta: { ...metaCustomTools, permissions: ['administrator'] },
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/scenarios'),
|
||||
component: AssistantScenariosIndex,
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import semver from 'semver';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAbortableRequest } from 'dashboard/composables/useAbortableRequest';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
import ToolsManifestAPI from 'dashboard/api/captain/toolsManifest';
|
||||
import {
|
||||
fetchToolsCatalog,
|
||||
isChatwootAuthor,
|
||||
} from 'dashboard/api/captain/toolsCatalog';
|
||||
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
|
||||
import InstallManifestDialog from 'dashboard/components-next/captain/pageComponents/customTool/InstallManifestDialog.vue';
|
||||
import ToolsetCatalogCard from 'dashboard/components-next/captain/pageComponents/customTool/ToolsetCatalogCard.vue';
|
||||
import ToolsetDetailsPanel from 'dashboard/components-next/captain/pageComponents/customTool/ToolsetDetailsPanel.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
const { shouldShowPaywall } = usePolicy();
|
||||
const { run: runCatalogRequest, isPending: isLoading } = useAbortableRequest();
|
||||
const { run: runInstalledRequest } = useAbortableRequest();
|
||||
|
||||
const assistantId = computed(() => route.params.assistantId);
|
||||
const installManifestDialogRef = ref(null);
|
||||
const detailsPanelRef = ref(null);
|
||||
const toolsets = ref([]);
|
||||
const hasError = ref(false);
|
||||
// null shows every category
|
||||
const selectedCategory = ref(null);
|
||||
// Installed version by owner/repository/folder; installs lowercase the owner and repository but keep the folder's case
|
||||
const installedVersions = ref(new Map());
|
||||
|
||||
const toolsRoute = computed(() => ({
|
||||
name: 'captain_tools_index',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
assistantId: assistantId.value,
|
||||
},
|
||||
}));
|
||||
|
||||
const categoryPills = computed(() => [
|
||||
{ value: null, label: t('CAPTAIN.CUSTOM_TOOLS.CATALOG.ALL') },
|
||||
...[...new Set(toolsets.value.map(toolset => toolset.category))].map(
|
||||
category => ({ value: category, label: category })
|
||||
),
|
||||
]);
|
||||
|
||||
const visibleToolsets = computed(() =>
|
||||
selectedCategory.value
|
||||
? toolsets.value.filter(
|
||||
toolset => toolset.category === selectedCategory.value
|
||||
)
|
||||
: toolsets.value
|
||||
);
|
||||
|
||||
const loadCatalog = async () => {
|
||||
hasError.value = false;
|
||||
try {
|
||||
const catalog = await runCatalogRequest(signal =>
|
||||
fetchToolsCatalog(globalConfig.value.captainToolsCatalogURL, { signal })
|
||||
);
|
||||
// Chatwoot's own toolsets lead; the stable sort keeps the catalog order otherwise
|
||||
if (catalog)
|
||||
toolsets.value = catalog.toolsets.toSorted(
|
||||
(a, b) => isChatwootAuthor(b.author) - isChatwootAuthor(a.author)
|
||||
);
|
||||
} catch {
|
||||
hasError.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const loadInstalled = async () => {
|
||||
const response = await runInstalledRequest(signal =>
|
||||
ToolsManifestAPI.installed({ assistantId: assistantId.value }, { signal })
|
||||
);
|
||||
if (!response) return;
|
||||
|
||||
installedVersions.value = new Map(
|
||||
response.data.map(({ repository, path, version }) => [
|
||||
`${repository}/${path}`,
|
||||
version,
|
||||
])
|
||||
);
|
||||
};
|
||||
|
||||
const installStatus = toolset => {
|
||||
const [owner, repository, ...folder] = toolset.source.split('/');
|
||||
const key = `${owner}/${repository}`.toLowerCase() + `/${folder.join('/')}`;
|
||||
if (!installedVersions.value.has(key)) return null;
|
||||
// The catalog can lag behind GitHub, so a newer install than the catalog lists is not an update
|
||||
return semver.gt(toolset.version, installedVersions.value.get(key))
|
||||
? 'update'
|
||||
: 'installed';
|
||||
};
|
||||
|
||||
const openToolset = toolset =>
|
||||
detailsPanelRef.value.open(toolset, installStatus(toolset));
|
||||
|
||||
const installToolset = toolset =>
|
||||
installManifestDialogRef.value.open(toolset.source);
|
||||
|
||||
const goToTools = () => router.push(toolsRoute.value);
|
||||
|
||||
const showPaywall = computed(() =>
|
||||
shouldShowPaywall(FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS)
|
||||
);
|
||||
|
||||
// On a full reload account features load after mount, so the catalog loads once access resolves
|
||||
watch(
|
||||
showPaywall,
|
||||
isPaywalled => {
|
||||
if (!isPaywalled) loadCatalog();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
[showPaywall, assistantId],
|
||||
() => {
|
||||
// Drop the previous assistant's badges so they never show while, or if, the new list fails to load
|
||||
installedVersions.value = new Map();
|
||||
if (!showPaywall.value) loadInstalled();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageLayout
|
||||
:header-title="t('CAPTAIN.CUSTOM_TOOLS.CATALOG.HEADER')"
|
||||
:back-url="toolsRoute"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS"
|
||||
:show-know-more="false"
|
||||
:show-pagination-footer="false"
|
||||
>
|
||||
<template #paywall>
|
||||
<CaptainPaywall feature-prefix="CAPTAIN.CUSTOM_TOOLS" />
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-6">
|
||||
<p class="mb-0 text-sm text-n-slate-11">
|
||||
{{ t('CAPTAIN.CUSTOM_TOOLS.CATALOG.DESCRIPTION') }}
|
||||
</p>
|
||||
|
||||
<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.LOAD_ERROR') }}
|
||||
</p>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
v-for="pill in categoryPills"
|
||||
:key="pill.label"
|
||||
:label="pill.label"
|
||||
size="sm"
|
||||
:variant="selectedCategory === pill.value ? 'solid' : 'faded'"
|
||||
:color="selectedCategory === pill.value ? 'blue' : 'slate'"
|
||||
class="!rounded-full"
|
||||
@click="selectedCategory = pill.value"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="!visibleToolsets.length"
|
||||
class="mb-0 text-sm text-n-slate-11"
|
||||
>
|
||||
{{ t('CAPTAIN.CUSTOM_TOOLS.CATALOG.EMPTY') }}
|
||||
</p>
|
||||
<div
|
||||
v-else
|
||||
class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
<ToolsetCatalogCard
|
||||
v-for="toolset in visibleToolsets"
|
||||
:key="toolset.source"
|
||||
:toolset="toolset"
|
||||
:install-status="installStatus(toolset)"
|
||||
@click="openToolset(toolset)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</PageLayout>
|
||||
|
||||
<ToolsetDetailsPanel ref="detailsPanelRef" @install="installToolset" />
|
||||
|
||||
<InstallManifestDialog
|
||||
ref="installManifestDialogRef"
|
||||
:assistant-id="assistantId"
|
||||
@installed="goToTools"
|
||||
/>
|
||||
</template>
|
||||
@@ -47,6 +47,10 @@ vi.mock('dashboard/composables/store', async () => {
|
||||
|
||||
vi.mock('dashboard/composables', () => ({ useAlert: mocks.alerts }));
|
||||
|
||||
vi.mock('dashboard/composables/useAdmin', () => ({
|
||||
useAdmin: () => ({ isAdmin: { value: true } }),
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables/usePolicy', async () => {
|
||||
const { ref } = await import('vue');
|
||||
// Reactive, so a spec can simulate account features loading after the page mounts
|
||||
@@ -61,7 +65,7 @@ vi.mock('dashboard/composables/usePolicy', async () => {
|
||||
|
||||
vi.mock('vue-router', async importOriginal => ({
|
||||
...(await importOriginal()),
|
||||
useRoute: () => ({ params: { assistantId: '1' } }),
|
||||
useRoute: () => ({ params: { assistantId: '1' }, query: {} }),
|
||||
}));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup>
|
||||
import { computed, watch, ref, nextTick } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useAbortableRequest } from 'dashboard/composables/useAbortableRequest';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
|
||||
@@ -15,15 +16,21 @@ import CreateCustomToolDialog from 'dashboard/components-next/captain/pageCompon
|
||||
import CustomToolCard from 'dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue';
|
||||
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import ToolsetInstallFlow from 'dashboard/components-next/captain/pageComponents/customTool/ToolsetInstallFlow.vue';
|
||||
import AssistantToolsBanner from 'dashboard/components-next/captain/pageComponents/customTool/AssistantToolsBanner.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const assistantId = computed(() => route.params.assistantId);
|
||||
const { t } = useI18n();
|
||||
const { shouldShowPaywall } = usePolicy();
|
||||
const { isAdmin } = useAdmin();
|
||||
|
||||
const uiFlags = useMapGetter('captainCustomTools/getUIFlags');
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
const { run, isPending: isFetchingTools } = useAbortableRequest();
|
||||
const records = useMapGetter('captainCustomTools/getRecords');
|
||||
const customTools = computed(() =>
|
||||
@@ -81,6 +88,15 @@ const fetchCustomTools = (page = 1) =>
|
||||
|
||||
const onPageChange = page => fetchCustomTools(page);
|
||||
|
||||
const openCatalog = () =>
|
||||
router.push({
|
||||
name: 'captain_tools_explore',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
assistantId: assistantId.value,
|
||||
},
|
||||
});
|
||||
|
||||
const openCreateDialog = () => {
|
||||
dialogType.value = 'create';
|
||||
selectedTool.value = null;
|
||||
@@ -183,6 +199,20 @@ const handleDialogClose = () => {
|
||||
|
||||
const handleToolCreated = () => fetchCustomTools();
|
||||
|
||||
// Install links land here with ?install=<source>; another assistant's tools load when the route changes
|
||||
const onToolsetInstalled = installedAssistantId => {
|
||||
if (installedAssistantId === Number(assistantId.value)) fetchCustomTools();
|
||||
};
|
||||
|
||||
const finishToolsetInstall = installedAssistantId =>
|
||||
router.replace({
|
||||
name: 'captain_tools_index',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
assistantId: installedAssistantId,
|
||||
},
|
||||
});
|
||||
|
||||
const onDeleteSuccess = () => {
|
||||
selectedTool.value = null;
|
||||
if (customTools.value.length === 0 && customToolsMeta.value.page > 1) {
|
||||
@@ -223,6 +253,25 @@ watch(
|
||||
@update:current-page="onPageChange"
|
||||
@click="openCreateDialog"
|
||||
>
|
||||
<template #headerActions>
|
||||
<Policy
|
||||
v-if="
|
||||
globalConfig.captainToolsManifestEnabled &&
|
||||
!shouldShowPaywall(FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS)
|
||||
"
|
||||
:permissions="['administrator']"
|
||||
>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.CUSTOM_TOOLS.CATALOG.BUTTON')"
|
||||
icon="i-lucide-blocks"
|
||||
size="sm"
|
||||
faded
|
||||
slate
|
||||
@click="openCatalog"
|
||||
/>
|
||||
</Policy>
|
||||
</template>
|
||||
|
||||
<template #paywall>
|
||||
<CaptainPaywall feature-prefix="CAPTAIN.CUSTOM_TOOLS" />
|
||||
</template>
|
||||
@@ -259,6 +308,14 @@ watch(
|
||||
</template>
|
||||
</PageLayout>
|
||||
|
||||
<ToolsetInstallFlow
|
||||
v-if="isAdmin && !showPaywall && route.query.install"
|
||||
:source="route.query.install"
|
||||
:assistant-id="assistantId"
|
||||
@installed="onToolsetInstalled"
|
||||
@done="finishToolsetInstall"
|
||||
/>
|
||||
|
||||
<CreateCustomToolDialog
|
||||
v-if="dialogType"
|
||||
ref="createDialogRef"
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const { uiSettings } = useUISettings();
|
||||
const assistants = useMapGetter('captainAssistants/getRecords');
|
||||
const hasError = ref(false);
|
||||
|
||||
// Leaving while assistants load must not pull the user back here once they arrive
|
||||
let isUnmounted = false;
|
||||
onBeforeUnmount(() => {
|
||||
isUnmounted = true;
|
||||
});
|
||||
|
||||
// Opens the tools page of the last active assistant, which runs the install flow from the query
|
||||
onMounted(async () => {
|
||||
const { accountId } = route.params;
|
||||
const { source } = route.query;
|
||||
try {
|
||||
await store.dispatch('captainAssistants/get');
|
||||
} catch {
|
||||
hasError.value = true;
|
||||
return;
|
||||
}
|
||||
if (isUnmounted) return;
|
||||
|
||||
if (!assistants.value.length) {
|
||||
router.replace({
|
||||
name: 'captain_assistants_create_index',
|
||||
params: { accountId },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const lastActiveId = Number(uiSettings.value?.last_active_assistant_id);
|
||||
const assistantId = assistants.value.some(a => a.id === lastActiveId)
|
||||
? lastActiveId
|
||||
: assistants.value[0].id;
|
||||
|
||||
router.replace({
|
||||
name: 'captain_tools_index',
|
||||
params: { accountId, assistantId },
|
||||
query: { install: source },
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-center w-full bg-n-surface-1 text-n-slate-11"
|
||||
>
|
||||
<p v-if="hasError" class="mb-0 text-sm text-n-ruby-11">
|
||||
{{ t('CAPTAIN.CUSTOM_TOOLS.INSTALL_LINK.LOAD_ERROR') }}
|
||||
</p>
|
||||
<Spinner v-else />
|
||||
</div>
|
||||
</template>
|
||||
@@ -24,6 +24,7 @@ import ShopifyOrdersList from 'dashboard/components/widgets/conversation/Shopify
|
||||
import SidebarActionsHeader from 'dashboard/components-next/SidebarActionsHeader.vue';
|
||||
import LinearIssuesList from 'dashboard/components/widgets/conversation/linear/IssuesList.vue';
|
||||
import LinearSetupCTA from 'dashboard/components/widgets/conversation/linear/LinearSetupCTA.vue';
|
||||
import StripeCustomer from 'dashboard/components/widgets/conversation/StripeCustomer.vue';
|
||||
|
||||
const props = defineProps({
|
||||
conversationId: {
|
||||
@@ -76,6 +77,10 @@ const isLinearConnected = computed(
|
||||
);
|
||||
|
||||
const store = useStore();
|
||||
const stripeIntegration = useFunctionGetter(
|
||||
'integrations/getIntegration',
|
||||
'stripe'
|
||||
);
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const conversationId = computed(() => props.conversationId);
|
||||
const conversationMetadataGetter = useMapGetter(
|
||||
@@ -135,8 +140,7 @@ onMounted(() => {
|
||||
conversationSidebarItems.value = conversationSidebarItemsOrder.value;
|
||||
getContactDetails();
|
||||
store.dispatch('attributes/get', 0);
|
||||
// Load integrations to ensure linear integration state is available
|
||||
store.dispatch('integrations/get', 'linear');
|
||||
store.dispatch('integrations/get');
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -299,6 +303,28 @@ onMounted(() => {
|
||||
<ShopifyOrdersList :contact-id="contactId" />
|
||||
</AccordionItem>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
element.name === 'stripe_customer' &&
|
||||
isCloudFeatureEnabled(FEATURE_FLAGS.STRIPE) &&
|
||||
stripeIntegration.enabled
|
||||
"
|
||||
>
|
||||
<AccordionItem
|
||||
:title="$t('STRIPE_INTEGRATION.TITLE')"
|
||||
:is-open="isContactSidebarItemOpen('is_stripe_customer_open')"
|
||||
compact
|
||||
@toggle="
|
||||
value => toggleSidebarUIState('is_stripe_customer_open', value)
|
||||
"
|
||||
>
|
||||
<StripeCustomer
|
||||
v-if="isContactSidebarItemOpen('is_stripe_customer_open')"
|
||||
:conversation-id="conversationId"
|
||||
:contact-email="contact.email"
|
||||
/>
|
||||
</AccordionItem>
|
||||
</div>
|
||||
<div v-else-if="element.name === 'contact_notes'">
|
||||
<AccordionItem
|
||||
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONTACT_NOTES')"
|
||||
|
||||
@@ -68,5 +68,11 @@ export default {
|
||||
name: 'no_accounts',
|
||||
component: NoAccounts,
|
||||
},
|
||||
{
|
||||
// Install links from the tools catalog can't know the account; the router guard moves them into the current one
|
||||
path: frontendURL('captain/toolsets/install'),
|
||||
name: 'captain_toolset_install_link',
|
||||
component: { render: () => null },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -46,9 +46,6 @@ const modelFeatures = computed(() => [
|
||||
]);
|
||||
|
||||
const featureToggles = computed(() => [
|
||||
{
|
||||
key: 'label_suggestion',
|
||||
},
|
||||
{
|
||||
key: 'help_center_search',
|
||||
enterprise: true,
|
||||
|
||||
@@ -46,9 +46,6 @@ const title = computed(() => {
|
||||
if (props.featureKey.toUpperCase() === 'HELP_CENTER_SEARCH') {
|
||||
return t('CAPTAIN_SETTINGS.FEATURES.HELP_CENTER_SEARCH.TITLE');
|
||||
}
|
||||
if (props.featureKey.toUpperCase() === 'LABEL_SUGGESTION') {
|
||||
return t('CAPTAIN_SETTINGS.FEATURES.LABEL_SUGGESTION.TITLE');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
@@ -59,9 +56,6 @@ const description = computed(() => {
|
||||
if (props.featureKey.toUpperCase() === 'HELP_CENTER_SEARCH') {
|
||||
return t('CAPTAIN_SETTINGS.FEATURES.HELP_CENTER_SEARCH.DESCRIPTION');
|
||||
}
|
||||
if (props.featureKey.toUpperCase() === 'LABEL_SUGGESTION') {
|
||||
return t('CAPTAIN_SETTINGS.FEATURES.LABEL_SUGGESTION.DESCRIPTION');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
@@ -72,9 +66,6 @@ const modelTitle = computed(() => {
|
||||
if (props.featureKey.toUpperCase() === 'HELP_CENTER_SEARCH') {
|
||||
return t('CAPTAIN_SETTINGS.FEATURES.HELP_CENTER_SEARCH.MODEL_TITLE');
|
||||
}
|
||||
if (props.featureKey.toUpperCase() === 'LABEL_SUGGESTION') {
|
||||
return t('CAPTAIN_SETTINGS.FEATURES.LABEL_SUGGESTION.MODEL_TITLE');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
@@ -85,9 +76,6 @@ const modelDescription = computed(() => {
|
||||
if (props.featureKey.toUpperCase() === 'HELP_CENTER_SEARCH') {
|
||||
return t('CAPTAIN_SETTINGS.FEATURES.HELP_CENTER_SEARCH.MODEL_DESCRIPTION');
|
||||
}
|
||||
if (props.featureKey.toUpperCase() === 'LABEL_SUGGESTION') {
|
||||
return t('CAPTAIN_SETTINGS.FEATURES.LABEL_SUGGESTION.MODEL_DESCRIPTION');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
|
||||
@@ -43,6 +43,9 @@ const integrationStatusColor = computed(() =>
|
||||
props.enabled ? 'teal' : 'slate'
|
||||
);
|
||||
|
||||
const isSvgLogo = computed(() => props.id === 'stripe');
|
||||
const logoExtension = computed(() => (isSvgLogo.value ? 'svg' : 'png'));
|
||||
|
||||
const actionURL = computed(() =>
|
||||
frontendURL(`accounts/${accountId.value}/settings/integrations/${props.id}`)
|
||||
);
|
||||
@@ -55,12 +58,20 @@ const actionURL = computed(() =>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex h-12 w-12 mb-2">
|
||||
<img
|
||||
:src="`/dashboard/images/integrations/${id}.png`"
|
||||
class="max-w-full rounded-md border border-n-weak shadow-sm block dark:hidden bg-n-alpha-3 dark:bg-n-alpha-2"
|
||||
:src="`/dashboard/images/integrations/${id}.${logoExtension}`"
|
||||
:alt="name"
|
||||
class="rounded-md border border-n-weak shadow-sm block dark:hidden"
|
||||
:class="
|
||||
isSvgLogo ? 'size-12 p-1.5 bg-white' : 'max-w-full bg-n-alpha-3'
|
||||
"
|
||||
/>
|
||||
<img
|
||||
:src="`/dashboard/images/integrations/${id}-dark.png`"
|
||||
class="max-w-full rounded-md border border-n-weak shadow-sm hidden dark:block bg-n-alpha-3 dark:bg-n-alpha-2"
|
||||
:src="`/dashboard/images/integrations/${id}-dark.${logoExtension}`"
|
||||
:alt="name"
|
||||
class="rounded-md border border-n-weak shadow-sm hidden dark:block"
|
||||
:class="
|
||||
isSvgLogo ? 'size-12 p-1.5 bg-n-solid-3' : 'max-w-full bg-n-alpha-2'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<Label
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useStore, useFunctionGetter } from 'dashboard/composables/store';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
import StripeAPI from 'dashboard/api/integrations/stripe';
|
||||
import IntegrationsAPI from 'dashboard/api/integrations';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Label from 'dashboard/components-next/label/Label.vue';
|
||||
import SettingsLayout from '../SettingsLayout.vue';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
|
||||
const store = useStore();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
const route = useRoute();
|
||||
const integration = useFunctionGetter('integrations/getIntegration', 'stripe');
|
||||
const loading = ref(true);
|
||||
const loaded = ref(false);
|
||||
const available = ref(false);
|
||||
const busy = ref(false);
|
||||
const error = ref(!!route.query.error);
|
||||
const account = ref(null);
|
||||
const STRIPE_LOGO = '/dashboard/images/integrations/stripe.svg';
|
||||
const STRIPE_LOGO_DARK = '/dashboard/images/integrations/stripe-dark.svg';
|
||||
|
||||
const dashboardURL = computed(() => {
|
||||
const { account_id: accountId, mode } = account.value;
|
||||
return `https://dashboard.stripe.com/${accountId}/${mode === 'live' ? '' : 'test/'}dashboard`;
|
||||
});
|
||||
|
||||
const loadIntegration = async () => {
|
||||
const { data } = await IntegrationsAPI.get();
|
||||
store.commit('integrations/SET_INTEGRATIONS', data.payload);
|
||||
const stripe = data.payload.find(item => item.id === 'stripe');
|
||||
available.value = !!stripe && !stripe.cleanup_only;
|
||||
account.value =
|
||||
stripe && (integration.value.enabled || stripe.cleanup_only)
|
||||
? (await StripeAPI.get()).data
|
||||
: null;
|
||||
loaded.value = true;
|
||||
};
|
||||
|
||||
const connect = async () => {
|
||||
busy.value = true;
|
||||
error.value = false;
|
||||
try {
|
||||
const { data } = await StripeAPI.connect();
|
||||
window.location.assign(data.url);
|
||||
} catch {
|
||||
error.value = true;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const disconnect = async () => {
|
||||
busy.value = true;
|
||||
error.value = false;
|
||||
try {
|
||||
await StripeAPI.disconnect();
|
||||
account.value = null;
|
||||
store.commit('integrations/DELETE_INTEGRATION', {
|
||||
id: 'stripe',
|
||||
enabled: false,
|
||||
hooks: [],
|
||||
});
|
||||
await loadIntegration();
|
||||
} catch {
|
||||
error.value = true;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await loadIntegration();
|
||||
} catch {
|
||||
error.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsLayout :is-loading="loading">
|
||||
<template #header>
|
||||
<BaseSettingsHeader
|
||||
:title="$t('STRIPE_INTEGRATION.TITLE')"
|
||||
:description="
|
||||
integration.enabled ? $t('STRIPE_INTEGRATION.DESCRIPTION') : ''
|
||||
"
|
||||
:back-button-label="$t('INTEGRATION_SETTINGS.HEADER')"
|
||||
/>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-6 w-full text-n-slate-12">
|
||||
<p v-if="error" role="alert" class="text-n-ruby-11">
|
||||
{{ $t('STRIPE_INTEGRATION.ERROR') }}
|
||||
</p>
|
||||
<p v-if="loaded && !available" role="status" class="text-n-slate-11">
|
||||
{{ $t('STRIPE_INTEGRATION.UNAVAILABLE') }}
|
||||
</p>
|
||||
<template v-if="loaded && account">
|
||||
<Button
|
||||
v-if="available && account.reauthorization_required"
|
||||
:label="$t('STRIPE_INTEGRATION.RECONNECT')"
|
||||
:disabled="busy"
|
||||
@click="connect"
|
||||
/>
|
||||
<div class="rounded-xl border border-n-weak bg-n-solid-1">
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-4 p-6 border-b border-n-weak"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<img :src="STRIPE_LOGO" alt="Stripe" class="w-16 dark:hidden" />
|
||||
<img
|
||||
:src="STRIPE_LOGO_DARK"
|
||||
alt="Stripe"
|
||||
class="w-16 hidden dark:block"
|
||||
/>
|
||||
<span class="text-sm font-medium">{{
|
||||
$t('STRIPE_INTEGRATION.ACCOUNT')
|
||||
}}</span>
|
||||
</div>
|
||||
<Label
|
||||
v-if="account.reauthorization_required"
|
||||
:label="$t('STRIPE_INTEGRATION.REAUTHORIZATION_REQUIRED')"
|
||||
color="amber"
|
||||
compact
|
||||
/>
|
||||
<Label
|
||||
v-else
|
||||
:label="$t('STRIPE_INTEGRATION.CONNECTED')"
|
||||
color="teal"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<dl
|
||||
class="grid grid-cols-1 sm:grid-cols-[10rem_1fr] gap-x-8 gap-y-3 p-6 text-sm m-0"
|
||||
>
|
||||
<dt class="text-n-slate-11">
|
||||
{{ $t('STRIPE_INTEGRATION.ACCOUNT_ID') }}
|
||||
</dt>
|
||||
<dd class="m-0 break-all font-mono text-xs self-center">
|
||||
{{ account.account_id }}
|
||||
</dd>
|
||||
<dt class="text-n-slate-11">
|
||||
{{ $t('STRIPE_INTEGRATION.ENVIRONMENT') }}
|
||||
</dt>
|
||||
<dd class="m-0">
|
||||
{{
|
||||
account.mode === 'live'
|
||||
? $t('STRIPE_INTEGRATION.LIVE')
|
||||
: $t('STRIPE_INTEGRATION.SANDBOX')
|
||||
}}
|
||||
</dd>
|
||||
<dt class="text-n-slate-11">
|
||||
{{ $t('STRIPE_INTEGRATION.CONNECTED_ON') }}
|
||||
</dt>
|
||||
<dd class="m-0">
|
||||
{{ new Date(account.connected_at).toLocaleDateString() }}
|
||||
</dd>
|
||||
</dl>
|
||||
<div class="px-6 pb-6">
|
||||
<a
|
||||
:href="dashboardURL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm text-n-brand hover:underline"
|
||||
>
|
||||
{{ $t('STRIPE_INTEGRATION.OPEN_STRIPE') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 rounded-xl border border-n-weak p-6"
|
||||
>
|
||||
<p class="m-0 text-sm text-n-slate-11 max-w-xl">
|
||||
{{
|
||||
replaceInstallationName(
|
||||
$t('STRIPE_INTEGRATION.DISCONNECT_NOTE')
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<Button
|
||||
:label="
|
||||
replaceInstallationName($t('STRIPE_INTEGRATION.DISCONNECT'))
|
||||
"
|
||||
:disabled="busy"
|
||||
variant="outline"
|
||||
color="ruby"
|
||||
@click="disconnect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-else-if="loaded && available"
|
||||
class="flex flex-col items-start justify-between lg:flex-row lg:items-center p-6 outline outline-n-container outline-1 bg-n-card rounded-xl gap-6"
|
||||
>
|
||||
<div
|
||||
class="flex items-start lg:items-center justify-start flex-1 m-0 gap-6 flex-col lg:flex-row"
|
||||
>
|
||||
<div
|
||||
class="flex h-16 w-16 items-center justify-center flex-shrink-0 rounded-md border border-n-weak shadow-sm bg-n-alpha-3 dark:bg-n-alpha-2"
|
||||
>
|
||||
<img :src="STRIPE_LOGO" alt="Stripe" class="w-12 dark:hidden" />
|
||||
<img
|
||||
:src="STRIPE_LOGO_DARK"
|
||||
alt="Stripe"
|
||||
class="w-12 hidden dark:block"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="mb-1 text-heading-1 text-n-slate-12">
|
||||
{{ $t('STRIPE_INTEGRATION.TITLE') }}
|
||||
</h3>
|
||||
<p class="text-n-slate-11 text-body-main">
|
||||
{{ $t('STRIPE_INTEGRATION.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
:label="$t('STRIPE_INTEGRATION.CONNECT')"
|
||||
:disabled="busy"
|
||||
faded
|
||||
blue
|
||||
@click="connect"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsLayout>
|
||||
</template>
|
||||
@@ -8,6 +8,7 @@ import Webhook from './Webhooks/Index.vue';
|
||||
import DashboardApps from './DashboardApps/Index.vue';
|
||||
import Slack from './Slack.vue';
|
||||
import Linear from './Linear.vue';
|
||||
import Stripe from './Stripe.vue';
|
||||
import Notion from './Notion.vue';
|
||||
import Shopify from './Shopify.vue';
|
||||
|
||||
@@ -92,6 +93,14 @@ export default {
|
||||
},
|
||||
props: route => ({ code: route.query.code }),
|
||||
},
|
||||
{
|
||||
path: 'stripe',
|
||||
name: 'settings_integrations_stripe',
|
||||
component: Stripe,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'notion',
|
||||
name: 'settings_integrations_notion',
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import Stripe from '../Stripe.vue';
|
||||
import StripeAPI from 'dashboard/api/integrations/stripe';
|
||||
import IntegrationsAPI from 'dashboard/api/integrations';
|
||||
import messages from 'dashboard/i18n/locale/en/integrations.json';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
commit: vi.fn(),
|
||||
enabled: true,
|
||||
query: {},
|
||||
installationName: undefined,
|
||||
}));
|
||||
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ query: mocks.query }) }));
|
||||
vi.mock(
|
||||
'dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue',
|
||||
() => ({
|
||||
default: { template: '<header />' },
|
||||
})
|
||||
);
|
||||
vi.mock('dashboard/composables/store', async () => {
|
||||
const { computed, reactive } = await import('vue');
|
||||
return {
|
||||
useStore: () => ({ commit: mocks.commit }),
|
||||
useFunctionGetter: () => {
|
||||
const integration = reactive({ enabled: mocks.enabled });
|
||||
mocks.commit.mockImplementation((type, data) => {
|
||||
if (type === 'integrations/DELETE_INTEGRATION') {
|
||||
Object.assign(integration, data);
|
||||
}
|
||||
});
|
||||
return computed(() => integration);
|
||||
},
|
||||
useMapGetter: () =>
|
||||
computed(() => ({ installationName: mocks.installationName })),
|
||||
};
|
||||
});
|
||||
vi.mock('dashboard/api/integrations/stripe', () => ({
|
||||
default: { get: vi.fn(), connect: vi.fn(), disconnect: vi.fn() },
|
||||
}));
|
||||
vi.mock('dashboard/api/integrations', () => ({
|
||||
default: { get: vi.fn() },
|
||||
}));
|
||||
|
||||
describe('Stripe integration settings', () => {
|
||||
let wrapper;
|
||||
const global = {
|
||||
stubs: {
|
||||
SettingsLayout: {
|
||||
template: '<div><slot name="header"/><slot name="body"/></div>',
|
||||
},
|
||||
BaseSettingsHeader: true,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.enabled = true;
|
||||
mocks.query = {};
|
||||
mocks.installationName = undefined;
|
||||
IntegrationsAPI.get.mockResolvedValue({
|
||||
data: { payload: [{ id: 'stripe', enabled: true }] },
|
||||
});
|
||||
StripeAPI.get.mockResolvedValue({
|
||||
data: {
|
||||
account_id: 'acct_test',
|
||||
connected_at: '2026-09-10T00:00:00Z',
|
||||
mode: 'sandbox',
|
||||
},
|
||||
});
|
||||
});
|
||||
afterEach(() => wrapper?.unmount());
|
||||
|
||||
it.each(['Acme Support', undefined])(
|
||||
'uses installation branding in disconnect copy: %s',
|
||||
async installationName => {
|
||||
mocks.installationName = installationName;
|
||||
wrapper = mount(Stripe, {
|
||||
global: {
|
||||
...global,
|
||||
mocks: {
|
||||
$t: key => messages.STRIPE_INTEGRATION[key.split('.')[1]] || key,
|
||||
},
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
const brand = installationName || 'Chatwoot';
|
||||
expect(wrapper.get('button').text()).toBe(`Disconnect from ${brand}`);
|
||||
expect(wrapper.text()).toContain(
|
||||
`Disconnecting removes the saved credentials from ${brand}.`
|
||||
);
|
||||
if (installationName) expect(wrapper.text()).not.toContain('Chatwoot');
|
||||
}
|
||||
);
|
||||
|
||||
it('shows connected account details and a sandbox dashboard link', async () => {
|
||||
wrapper = mount(Stripe, { global });
|
||||
await flushPromises();
|
||||
expect(mocks.commit).toHaveBeenCalledWith('integrations/SET_INTEGRATIONS', [
|
||||
{ id: 'stripe', enabled: true },
|
||||
]);
|
||||
expect(wrapper.text()).toContain('acct_test');
|
||||
expect(wrapper.text()).toContain('STRIPE_INTEGRATION.CONNECTED');
|
||||
expect(wrapper.get('a').attributes('href')).toBe(
|
||||
'https://dashboard.stripe.com/acct_test/test/dashboard'
|
||||
);
|
||||
expect(wrapper.get('a').attributes('rel')).toBe('noopener noreferrer');
|
||||
});
|
||||
|
||||
it('does not request account details when disconnected', async () => {
|
||||
mocks.enabled = false;
|
||||
wrapper = mount(Stripe, { global });
|
||||
await flushPromises();
|
||||
expect(StripeAPI.get).not.toHaveBeenCalled();
|
||||
expect(wrapper.get('button').text()).toContain(
|
||||
'STRIPE_INTEGRATION.CONNECT'
|
||||
);
|
||||
expect(wrapper.find('a').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('allows disconnecting an unavailable integration without showing Connect', async () => {
|
||||
mocks.enabled = false;
|
||||
IntegrationsAPI.get.mockResolvedValueOnce({
|
||||
data: { payload: [{ id: 'stripe', enabled: false, cleanup_only: true }] },
|
||||
});
|
||||
wrapper = mount(Stripe, { global });
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain('STRIPE_INTEGRATION.UNAVAILABLE');
|
||||
expect(wrapper.get('button').text()).toContain(
|
||||
'STRIPE_INTEGRATION.DISCONNECT'
|
||||
);
|
||||
IntegrationsAPI.get.mockResolvedValueOnce({ data: { payload: [] } });
|
||||
await wrapper.get('button').trigger('click');
|
||||
await flushPromises();
|
||||
expect(StripeAPI.disconnect).toHaveBeenCalled();
|
||||
expect(wrapper.find('button').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('shows live mode and links to the live dashboard', async () => {
|
||||
StripeAPI.get.mockResolvedValue({
|
||||
data: {
|
||||
account_id: 'acct_live',
|
||||
connected_at: '2026-09-10T00:00:00Z',
|
||||
mode: 'live',
|
||||
},
|
||||
});
|
||||
wrapper = mount(Stripe, { global });
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain('STRIPE_INTEGRATION.LIVE');
|
||||
expect(wrapper.text()).not.toContain('STRIPE_INTEGRATION.SANDBOX');
|
||||
expect(wrapper.get('a').attributes('href')).toBe(
|
||||
'https://dashboard.stripe.com/acct_live/dashboard'
|
||||
);
|
||||
});
|
||||
|
||||
it('shows a safe error if account details cannot load', async () => {
|
||||
StripeAPI.get.mockRejectedValue(new Error('private provider details'));
|
||||
wrapper = mount(Stripe, { global });
|
||||
await flushPromises();
|
||||
expect(wrapper.get('[role="alert"]').text()).toContain(
|
||||
'STRIPE_INTEGRATION.ERROR'
|
||||
);
|
||||
expect(wrapper.text()).not.toContain('private provider details');
|
||||
});
|
||||
|
||||
it('shows a failed OAuth callback error', async () => {
|
||||
mocks.query = { error: 'authorization_failed' };
|
||||
wrapper = mount(Stripe, { global });
|
||||
await flushPromises();
|
||||
expect(wrapper.get('[role="alert"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not show Connect when the integrations request fails', async () => {
|
||||
mocks.enabled = false;
|
||||
IntegrationsAPI.get.mockRejectedValue(new Error('request failed'));
|
||||
wrapper = mount(Stripe, { global });
|
||||
await flushPromises();
|
||||
expect(wrapper.get('[role="alert"]').text()).toContain(
|
||||
'STRIPE_INTEGRATION.ERROR'
|
||||
);
|
||||
expect(wrapper.find('button').exists()).toBe(false);
|
||||
expect(StripeAPI.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stays disconnected when the refresh after deletion fails', async () => {
|
||||
StripeAPI.disconnect.mockResolvedValue({});
|
||||
wrapper = mount(Stripe, { global });
|
||||
await flushPromises();
|
||||
IntegrationsAPI.get.mockRejectedValue(new Error('request failed'));
|
||||
|
||||
await wrapper.get('button').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.commit).toHaveBeenCalledWith(
|
||||
'integrations/DELETE_INTEGRATION',
|
||||
{
|
||||
id: 'stripe',
|
||||
enabled: false,
|
||||
hooks: [],
|
||||
}
|
||||
);
|
||||
expect(wrapper.text()).not.toContain('acct_test');
|
||||
expect(wrapper.text()).not.toContain('STRIPE_INTEGRATION.CONNECTED');
|
||||
expect(wrapper.get('button').text()).toBe('STRIPE_INTEGRATION.CONNECT');
|
||||
expect(wrapper.find('a').exists()).toBe(false);
|
||||
expect(wrapper.get('[role="alert"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the connected account visible when disconnect fails', async () => {
|
||||
StripeAPI.disconnect.mockRejectedValue(new Error('request failed'));
|
||||
wrapper = mount(Stripe, { global });
|
||||
await flushPromises();
|
||||
await wrapper.get('button').trigger('click');
|
||||
await flushPromises();
|
||||
expect(StripeAPI.disconnect).toHaveBeenCalledOnce();
|
||||
expect(mocks.commit).not.toHaveBeenCalledWith(
|
||||
'integrations/DELETE_INTEGRATION',
|
||||
expect.anything()
|
||||
);
|
||||
expect(wrapper.text()).toContain('acct_test');
|
||||
expect(wrapper.get('[role="alert"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user