Merge branch 'develop' into feature/10945-search-results-page

This commit is contained in:
Alex
2025-03-19 12:25:14 +01:00
committed by GitHub
194 changed files with 7025 additions and 2723 deletions
@@ -9,7 +9,7 @@ input[type='submit']:not(.reset-base),
border-radius: $base-border-radius;
color: $white;
cursor: pointer;
display: inline-block;
display: inline-flex;
font-size: $font-size-small;
-webkit-font-smoothing: antialiased;
font-weight: $font-weight-medium;
@@ -2,6 +2,10 @@ class Api::V1::Widget::CampaignsController < Api::V1::Widget::BaseController
skip_before_action :set_contact
def index
@campaigns = @web_widget.inbox.campaigns.where(enabled: true)
@campaigns = @web_widget
.inbox
.campaigns
.where(enabled: true, account_id: @web_widget.inbox.account_id)
.includes(:sender)
end
end
@@ -2,6 +2,6 @@ class Api::V1::Widget::InboxMembersController < Api::V1::Widget::BaseController
skip_before_action :set_contact
def index
@inbox_members = @web_widget.inbox.inbox_members.includes(:user)
@inbox_members = @web_widget.inbox.inbox_members.includes(user: { avatar_attachment: :blob })
end
end
@@ -6,17 +6,25 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
layout 'portal'
def index
@articles = @portal.articles.published
@articles = @portal.articles.published.includes(:category, :author)
@articles_count = @articles.count
search_articles
order_by_sort_param
@articles = @articles.page(list_params[:page]) if list_params[:page].present?
limit_results
end
def show; end
private
def limit_results
return if list_params[:per_page].blank?
per_page = [list_params[:per_page].to_i, 100].min
per_page = 25 if per_page < 1
@articles = @articles.page(list_params[:page]).per(per_page)
end
def search_articles
@articles = @articles.search(list_params) if list_params.present?
end
@@ -45,7 +53,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
end
def list_params
params.permit(:query, :locale, :sort, :status, :page)
params.permit(:query, :locale, :sort, :status, :page, :per_page)
end
def permitted_params
@@ -1,6 +1,6 @@
class Twilio::CallbackController < ApplicationController
def create
::Twilio::IncomingMessageService.new(params: permitted_params).perform
Webhooks::TwilioEventsJob.perform_later(permitted_params.to_unsafe_hash)
head :no_content
end
@@ -1,6 +1,6 @@
class Twilio::DeliveryStatusController < ApplicationController
def create
::Twilio::DeliveryStatusService.new(params: permitted_params).perform
Webhooks::TwilioDeliveryStatusJob.perform_later(permitted_params.to_unsafe_hash)
head :no_content
end
@@ -10,7 +10,7 @@
&.no-margin {
.mx-input {
@apply mb-0;
margin-bottom: 0 !important;
}
}
@@ -0,0 +1,208 @@
// scss-lint:disable PropertySortOrder
@layer base {
// NEXT COLORS START
:root {
// slate
--slate-1: 252 252 253;
--slate-2: 249 249 251;
--slate-3: 240 240 243;
--slate-4: 232 232 236;
--slate-5: 224 225 230;
--slate-6: 217 217 224;
--slate-7: 205 206 214;
--slate-8: 185 187 198;
--slate-9: 139 141 152;
--slate-10: 128 131 141;
--slate-11: 96 100 108;
--slate-12: 28 32 36;
--iris-1: 253 253 255;
--iris-2: 248 248 255;
--iris-3: 240 241 254;
--iris-4: 230 231 255;
--iris-5: 218 220 255;
--iris-6: 203 205 255;
--iris-7: 184 186 248;
--iris-8: 155 158 240;
--iris-9: 91 91 214;
--iris-10: 81 81 205;
--iris-11: 87 83 198;
--iris-12: 39 41 98;
--ruby-1: 255 252 253;
--ruby-2: 255 247 248;
--ruby-3: 254 234 237;
--ruby-4: 255 220 225;
--ruby-5: 255 206 214;
--ruby-6: 248 191 200;
--ruby-7: 239 172 184;
--ruby-8: 229 146 163;
--ruby-9: 229 70 102;
--ruby-10: 220 59 93;
--ruby-11: 202 36 77;
--ruby-12: 100 23 43;
--amber-1: 254 253 251;
--amber-2: 254 251 233;
--amber-3: 255 247 194;
--amber-4: 255 238 156;
--amber-5: 251 229 119;
--amber-6: 243 214 115;
--amber-7: 233 193 98;
--amber-8: 226 163 54;
--amber-9: 255 197 61;
--amber-10: 255 186 24;
--amber-11: 171 100 0;
--amber-12: 79 52 34;
--teal-1: 250 254 253;
--teal-2: 243 251 249;
--teal-3: 224 248 243;
--teal-4: 204 243 234;
--teal-5: 184 234 224;
--teal-6: 161 222 210;
--teal-7: 131 205 193;
--teal-8: 83 185 171;
--teal-9: 18 165 148;
--teal-10: 13 155 138;
--teal-11: 0 133 115;
--teal-12: 13 61 56;
--gray-1: 252 252 252;
--gray-2: 249 249 249;
--gray-3: 240 240 240;
--gray-4: 232 232 232;
--gray-5: 224 224 224;
--gray-6: 217 217 217;
--gray-7: 206 206 206;
--gray-8: 187 187 187;
--gray-9: 141 141 141;
--gray-10: 131 131 131;
--gray-11: 100 100 100;
--gray-12: 32 32 32;
--background-color: 253 253 253;
--text-blue: 8 109 224;
--border-container: 236 236 236;
--border-strong: 235 235 235;
--border-weak: 234 234 234;
--solid-1: 255 255 255;
--solid-2: 255 255 255;
--solid-3: 255 255 255;
--solid-active: 255 255 255;
--solid-amber: 252 232 193;
--solid-blue: 218 236 255;
--solid-iris: 230 231 255;
--alpha-1: 67, 67, 67, 0.06;
--alpha-2: 201, 202, 207, 0.15;
--alpha-3: 255, 255, 255, 0.96;
--black-alpha-1: 0, 0, 0, 0.12;
--black-alpha-2: 0, 0, 0, 0.04;
--border-blue: 39, 129, 246, 0.5;
--white-alpha: 255, 255, 255, 0.8;
}
.dark {
// slate
--slate-1: 17 17 19;
--slate-2: 24 25 27;
--slate-3: 33 34 37;
--slate-4: 39 42 45;
--slate-5: 46 49 53;
--slate-6: 54 58 63;
--slate-7: 67 72 78;
--slate-8: 90 97 105;
--slate-9: 105 110 119;
--slate-10: 119 123 132;
--slate-11: 176 180 186;
--slate-12: 237 238 240;
--iris-1: 19 19 30;
--iris-2: 23 22 37;
--iris-3: 32 34 72;
--iris-4: 38 42 101;
--iris-5: 48 51 116;
--iris-6: 61 62 130;
--iris-7: 74 74 149;
--iris-8: 89 88 177;
--iris-9: 91 91 214;
--iris-10: 84 114 228;
--iris-11: 158 177 255;
--iris-12: 224 223 254;
--ruby-1: 25 17 19;
--ruby-2: 30 21 23;
--ruby-3: 58 20 30;
--ruby-4: 78 19 37;
--ruby-5: 94 26 46;
--ruby-6: 111 37 57;
--ruby-7: 136 52 71;
--ruby-8: 179 68 90;
--ruby-9: 229 70 102;
--ruby-10: 236 90 114;
--ruby-11: 255 148 157;
--ruby-12: 254 210 225;
--amber-1: 22 18 12;
--amber-2: 29 24 15;
--amber-3: 48 32 8;
--amber-4: 63 39 0;
--amber-5: 77 48 0;
--amber-6: 92 61 5;
--amber-7: 113 79 25;
--amber-8: 143 100 36;
--amber-9: 255 197 61;
--amber-10: 255 214 10;
--amber-11: 255 202 22;
--amber-12: 255 231 179;
--teal-1: 13 21 20;
--teal-2: 17 28 27;
--teal-3: 13 45 42;
--teal-4: 2 59 55;
--teal-5: 8 72 67;
--teal-6: 20 87 80;
--teal-7: 28 105 97;
--teal-8: 32 126 115;
--teal-9: 18 165 148;
--teal-10: 14 179 158;
--teal-11: 11 216 182;
--teal-12: 173 240 221;
--gray-1: 17 17 17;
--gray-2: 25 25 25;
--gray-3: 34 34 34;
--gray-4: 42 42 42;
--gray-5: 49 49 49;
--gray-6: 58 58 58;
--gray-7: 72 72 72;
--gray-8: 96 96 96;
--gray-9: 110 110 110;
--gray-10: 123 123 123;
--gray-11: 180 180 180;
--gray-12: 238 238 238;
--background-color: 18 18 19;
--border-strong: 52 52 52;
--border-weak: 38 38 42;
--solid-1: 23 23 26;
--solid-2: 29 30 36;
--solid-3: 44 45 54;
--solid-active: 53 57 66;
--solid-amber: 42 37 30;
--solid-blue: 16 49 91;
--solid-iris: 38 42 101;
--text-blue: 126 182 255;
--alpha-1: 36, 36, 36, 0.8;
--alpha-2: 139, 147, 182, 0.15;
--alpha-3: 36, 38, 45, 0.9;
--black-alpha-1: 0, 0, 0, 0.3;
--black-alpha-2: 0, 0, 0, 0.2;
--border-blue: 39, 129, 246, 0.5;
--border-container: 236, 236, 236, 0;
--white-alpha: 255, 255, 255, 0.1;
}
}
// NEXT COLORS END
+14 -206
View File
@@ -5,6 +5,9 @@
@import 'shared/assets/fonts/InterDisplay/inter-display';
@import 'shared/assets/fonts/inter';
// Next Colors
@import 'next-colors';
@import 'shared/assets/stylesheets/animations';
@import 'shared/assets/stylesheets/colors';
@import 'shared/assets/stylesheets/spacing';
@@ -54,214 +57,19 @@
@apply text-n-blue-text;
}
.custom-dashed-border {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='none' rx='16' ry='16' stroke='%23E2E3E7' stroke-width='2' stroke-dasharray='6, 8' stroke-dashoffset='0' stroke-linecap='round'/%3E%3C/svg%3E");
background-position: center;
background-repeat: no-repeat;
background-size: 100% 100%;
}
.dark .custom-dashed-border {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='none' rx='16' ry='16' stroke='%23343434' stroke-width='2' stroke-dasharray='6, 8' stroke-dashoffset='0' stroke-linecap='round'/%3E%3C/svg%3E");
}
// scss-lint:disable PropertySortOrder
@layer base {
/* NEXT COLORS START */
:root {
/* slate */
--slate-1: 252 252 253;
--slate-2: 249 249 251;
--slate-3: 240 240 243;
--slate-4: 232 232 236;
--slate-5: 224 225 230;
--slate-6: 217 217 224;
--slate-7: 205 206 214;
--slate-8: 185 187 198;
--slate-9: 139 141 152;
--slate-10: 128 131 141;
--slate-11: 96 100 108;
--slate-12: 28 32 36;
--iris-1: 253 253 255;
--iris-2: 248 248 255;
--iris-3: 240 241 254;
--iris-4: 230 231 255;
--iris-5: 218 220 255;
--iris-6: 203 205 255;
--iris-7: 184 186 248;
--iris-8: 155 158 240;
--iris-9: 91 91 214;
--iris-10: 81 81 205;
--iris-11: 87 83 198;
--iris-12: 39 41 98;
--ruby-1: 255 252 253;
--ruby-2: 255 247 248;
--ruby-3: 254 234 237;
--ruby-4: 255 220 225;
--ruby-5: 255 206 214;
--ruby-6: 248 191 200;
--ruby-7: 239 172 184;
--ruby-8: 229 146 163;
--ruby-9: 229 70 102;
--ruby-10: 220 59 93;
--ruby-11: 202 36 77;
--ruby-12: 100 23 43;
--amber-1: 254 253 251;
--amber-2: 254 251 233;
--amber-3: 255 247 194;
--amber-4: 255 238 156;
--amber-5: 251 229 119;
--amber-6: 243 214 115;
--amber-7: 233 193 98;
--amber-8: 226 163 54;
--amber-9: 255 197 61;
--amber-10: 255 186 24;
--amber-11: 171 100 0;
--amber-12: 79 52 34;
--teal-1: 250 254 253;
--teal-2: 243 251 249;
--teal-3: 224 248 243;
--teal-4: 204 243 234;
--teal-5: 184 234 224;
--teal-6: 161 222 210;
--teal-7: 131 205 193;
--teal-8: 83 185 171;
--teal-9: 18 165 148;
--teal-10: 13 155 138;
--teal-11: 0 133 115;
--teal-12: 13 61 56;
--gray-1: 252 252 252;
--gray-2: 249 249 249;
--gray-3: 240 240 240;
--gray-4: 232 232 232;
--gray-5: 224 224 224;
--gray-6: 217 217 217;
--gray-7: 206 206 206;
--gray-8: 187 187 187;
--gray-9: 141 141 141;
--gray-10: 131 131 131;
--gray-11: 100 100 100;
--gray-12: 32 32 32;
--background-color: 253 253 253;
--text-blue: 8 109 224;
--border-container: 236 236 236;
--border-strong: 235 235 235;
--border-weak: 234 234 234;
--solid-1: 255 255 255;
--solid-2: 255 255 255;
--solid-3: 255 255 255;
--solid-active: 255 255 255;
--solid-amber: 252 232 193;
--solid-blue: 218 236 255;
--solid-iris: 230 231 255;
--alpha-1: 67, 67, 67, 0.06;
--alpha-2: 201, 202, 207, 0.15;
--alpha-3: 255, 255, 255, 0.96;
--black-alpha-1: 0, 0, 0, 0.12;
--black-alpha-2: 0, 0, 0, 0.04;
--border-blue: 39, 129, 246, 0.5;
--white-alpha: 255, 255, 255, 0.8;
}
.dark {
/* slate */
--slate-1: 17 17 19;
--slate-2: 24 25 27;
--slate-3: 33 34 37;
--slate-4: 39 42 45;
--slate-5: 46 49 53;
--slate-6: 54 58 63;
--slate-7: 67 72 78;
--slate-8: 90 97 105;
--slate-9: 105 110 119;
--slate-10: 119 123 132;
--slate-11: 176 180 186;
--slate-12: 237 238 240;
--iris-1: 19 19 30;
--iris-2: 23 22 37;
--iris-3: 32 34 72;
--iris-4: 38 42 101;
--iris-5: 48 51 116;
--iris-6: 61 62 130;
--iris-7: 74 74 149;
--iris-8: 89 88 177;
--iris-9: 91 91 214;
--iris-10: 84 114 228;
--iris-11: 158 177 255;
--iris-12: 224 223 254;
--ruby-1: 25 17 19;
--ruby-2: 30 21 23;
--ruby-3: 58 20 30;
--ruby-4: 78 19 37;
--ruby-5: 94 26 46;
--ruby-6: 111 37 57;
--ruby-7: 136 52 71;
--ruby-8: 179 68 90;
--ruby-9: 229 70 102;
--ruby-10: 236 90 114;
--ruby-11: 255 148 157;
--ruby-12: 254 210 225;
--amber-1: 22 18 12;
--amber-2: 29 24 15;
--amber-3: 48 32 8;
--amber-4: 63 39 0;
--amber-5: 77 48 0;
--amber-6: 92 61 5;
--amber-7: 113 79 25;
--amber-8: 143 100 36;
--amber-9: 255 197 61;
--amber-10: 255 214 10;
--amber-11: 255 202 22;
--amber-12: 255 231 179;
--teal-1: 13 21 20;
--teal-2: 17 28 27;
--teal-3: 13 45 42;
--teal-4: 2 59 55;
--teal-5: 8 72 67;
--teal-6: 20 87 80;
--teal-7: 28 105 97;
--teal-8: 32 126 115;
--teal-9: 18 165 148;
--teal-10: 14 179 158;
--teal-11: 11 216 182;
--teal-12: 173 240 221;
--gray-1: 17 17 17;
--gray-2: 25 25 25;
--gray-3: 34 34 34;
--gray-4: 42 42 42;
--gray-5: 49 49 49;
--gray-6: 58 58 58;
--gray-7: 72 72 72;
--gray-8: 96 96 96;
--gray-9: 110 110 110;
--gray-10: 123 123 123;
--gray-11: 180 180 180;
--gray-12: 238 238 238;
--background-color: 18 18 19;
--border-strong: 52 52 52;
--border-weak: 38 38 42;
--solid-1: 23 23 26;
--solid-2: 29 30 36;
--solid-3: 44 45 54;
--solid-active: 53 57 66;
--solid-amber: 42 37 30;
--solid-blue: 16 49 91;
--solid-iris: 38 42 101;
--text-blue: 126 182 255;
--alpha-1: 36, 36, 36, 0.8;
--alpha-2: 139, 147, 182, 0.15;
--alpha-3: 36, 38, 45, 0.9;
--black-alpha-1: 0, 0, 0, 0.3;
--black-alpha-2: 0, 0, 0, 0.2;
--border-blue: 39, 129, 246, 0.5;
--border-container: 236, 236, 236, 0;
--white-alpha: 255, 255, 255, 0.1;
}
/* NEXT COLORS END */
:root {
--color-amber-25: 254 253 251;
--color-amber-50: 255 249 237;
@@ -1,13 +1,13 @@
@mixin label-multiselect-hover {
&::after {
@apply text-woot-600 dark:text-woot-600;
@apply text-n-brand;
}
&:hover {
@apply bg-slate-50 dark:bg-slate-700;
@apply bg-n-slate-3;
&::after {
@apply text-woot-500 dark:text-woot-500;
@apply text-n-blue-text;
}
}
}
@@ -18,20 +18,16 @@
}
&.multiselect--disabled {
@apply opacity-50 border border-slate-200 dark:border-slate-600 rounded-md cursor-not-allowed;
@apply opacity-50 rounded-lg cursor-not-allowed pointer-events-auto;
.multiselect__select {
@apply cursor-not-allowed bg-white dark:bg-slate-900 rounded-md;
}
.multiselect__tags {
@apply border-0;
@apply cursor-not-allowed bg-transparent rounded-lg;
}
}
.multiselect--active {
> .multiselect__tags {
@apply border-woot-500 dark:border-woot-500;
@apply outline-n-blue-border;
}
}
@@ -44,7 +40,7 @@
}
.multiselect__content-wrapper {
@apply bg-white dark:bg-slate-900 border border-solid border-slate-200 dark:border-slate-600 text-slate-800 dark:text-slate-100;
@apply bg-n-alpha-black2 text-n-slate-12 backdrop-blur-[100px] border-0 border-none outline outline-1 outline-n-weak rounded-b-lg;
}
.multiselect__content {
@@ -61,15 +57,19 @@
@apply mb-0;
}
&::after {
@apply bottom-0 flex items-center justify-center text-center;
}
&.multiselect__option--highlight {
@apply bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-100;
@apply bg-n-alpha-black2 text-n-slate-12;
}
&.multiselect__option--highlight:hover {
@apply bg-woot-50 dark:bg-woot-600 text-slate-800 dark:text-slate-100;
@apply bg-n-brand/10 text-n-slate-12;
&::after {
@apply bg-woot-50 dark:bg-woot-600 text-slate-600 dark:text-slate-200;
@apply bg-transparent text-center text-n-slate-12;
}
}
@@ -78,17 +78,17 @@
}
&.multiselect__option--selected {
@apply bg-woot-50 dark:bg-woot-600 text-slate-800 dark:text-slate-100;
@apply bg-n-brand/20 text-n-slate-12;
&::after {
@apply bg-transparent;
}
&.multiselect__option--highlight:hover {
@apply bg-woot-75 dark:bg-woot-600;
&::after {
@apply bg-transparent;
}
@apply bg-transparent;
&::after:hover {
@apply text-slate-800 dark:text-slate-100;
@apply text-n-slate-12 bg-transparent;
}
}
}
@@ -96,23 +96,27 @@
}
.multiselect__tags {
@apply bg-white dark:bg-slate-900 border border-solid border-slate-200 dark:border-slate-600 m-0 min-h-[2.875rem] pt-0;
@apply bg-n-alpha-black2 border-0 grid items-center w-full border-none outline-1 outline outline-n-weak hover:outline-n-slate-6 m-0 min-h-[2.875rem] rounded-lg pt-0;
input {
@apply border-0 border-none;
@apply border-0 border-none bg-transparent dark:bg-transparent text-n-slate-12 placeholder:text-n-slate-10;
}
}
.multiselect__spinner {
background-color: transparent;
}
.multiselect__tags-wrap {
@apply inline-block leading-none mt-1;
}
.multiselect__placeholder {
@apply text-slate-400 dark:text-slate-400 font-normal pt-3;
@apply text-n-slate-10 font-normal pt-3;
}
.multiselect__tag {
@apply bg-slate-50 dark:bg-slate-800 mt-1 text-slate-800 dark:text-slate-100 pr-6 pl-2.5 py-1.5;
@apply bg-n-alpha-white mt-1 text-n-slate-12 pr-6 pl-2.5 py-1.5;
}
.multiselect__tag-icon {
@@ -125,7 +129,7 @@
}
.multiselect__single {
@apply bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 inline-block mb-0 py-3 px-2.5 overflow-hidden whitespace-nowrap text-ellipsis;
@apply bg-transparent text-n-slate-12 inline-block mb-0 py-3 px-2.5 overflow-hidden whitespace-nowrap text-ellipsis;
}
}
@@ -143,11 +147,11 @@
}
> .multiselect__tags {
@apply border-transparent;
@apply outline-transparent;
}
&.multiselect--active > .multiselect__tags {
@apply border-woot-500 dark:border-woot-500;
@apply outline-n-blue-border;
}
}
}
@@ -179,7 +183,7 @@
.multiselect__tags,
.multiselect__input,
.multiselect {
@apply bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 rounded-[5px] text-sm min-h-[2.5rem];
@apply text-n-slate-12 rounded-lg text-sm min-h-[2.5rem];
}
.multiselect__input {
@@ -187,7 +191,7 @@
}
.multiselect__single {
@apply items-center flex m-0 text-sm max-h-[2.375rem] text-slate-800 dark:text-slate-100 bg-white dark:bg-slate-900 py-3 px-0.5;
@apply items-center flex m-0 text-sm max-h-[2.375rem] bg-transparent text-n-slate-12 py-3 px-0.5;
}
.multiselect__placeholder {
@@ -208,6 +212,24 @@
}
}
.multiselect--disabled {
background-color: rgba(var(--black-alpha-2)) !important;
.multiselect__tags {
@apply hover:outline-n-weak;
}
}
.multiselect--active {
.multiselect__select::before {
@apply top-[62%];
}
}
.multiselect__select::before {
top: 60% !important;
}
.multiselect-wrap--medium {
.multiselect__tags,
.multiselect__input {
@@ -217,16 +239,16 @@
.multiselect__tags,
.multiselect__input,
.multiselect {
@apply bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 text-sm h-12 min-h-[3rem];
@apply bg-n-alpha-black2 text-n-slate-12 text-sm h-12 min-h-[3rem];
}
.multiselect__input {
@apply h-[2.875rem] min-h-[2.875rem];
margin-bottom: 0px !important;
margin-bottom: 0 !important;
}
.multiselect__single {
@apply items-center flex m-0 text-sm py-1 px-0.5 text-slate-800 dark:text-slate-100 bg-white dark:bg-slate-900;
@apply items-center flex m-0 text-sm py-1 px-0.5 bg-transparent text-n-slate-12;
}
.multiselect__placeholder {
@@ -1,22 +1,22 @@
// scss-lint:disable QualifyingElement
// Base typography
// -------------------------
h1,
h2,
h3,
h4,
h5,
h6 {
@apply font-medium text-slate-800 dark:text-slate-50;
@apply font-medium text-n-slate-12;
}
p {
text-rendering: optimizeLegibility;
@apply mb-2 leading-[1.65] text-sm;
a {
@apply text-woot-500 dark:text-woot-500 cursor-pointer;
@apply text-n-brand dark:text-n-brand cursor-pointer;
}
}
@@ -41,64 +41,82 @@ dl:not(.reset-base) {
}
// Form elements
// -------------------------
label {
@apply text-slate-800 dark:text-slate-200 block m-0 leading-7 text-sm font-medium;
&.error {
input {
@apply mb-1;
}
}
@apply text-n-slate-12 block m-0 leading-7 text-sm font-medium;
}
.input-wrap,
.help-text {
@apply text-slate-800 dark:text-slate-100 text-sm font-medium;
.help-text {
@apply font-normal text-slate-600 dark:text-slate-400;
}
@apply text-n-slate-11 text-sm font-medium;
}
// Focus outline removal
.button,
textarea,
input:focus {
textarea {
outline: none;
}
// Inputs
input[type='text']:not(.reset-base),
input[type='number']:not(.reset-base),
input[type='password']:not(.reset-base),
input[type='date']:not(.reset-base),
input[type='email']:not(.reset-base),
input[type='url']:not(.reset-base) {
@apply block box-border w-full transition-colors focus:border-woot-500 dark:focus:border-woot-600 duration-[0.25s] ease-[ease-in-out] h-10 appearance-none mx-0 mt-0 mb-4 p-2 rounded-md text-base font-normal bg-white dark:bg-slate-900 focus:bg-white focus:dark:bg-slate-900 text-slate-900 dark:text-slate-100 border border-solid border-slate-200 dark:border-slate-600;
// Field base styles (Input, TextArea, Select)
@layer components {
.field-base {
@apply block box-border w-full transition-colors duration-[0.25s] ease-[ease-in-out] focus:outline-n-brand dark:focus:outline-n-brand appearance-none mx-0 mt-0 mb-4 py-2 px-3 rounded-lg text-base font-normal bg-n-alpha-black2 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 border-none outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6;
}
&[disabled] {
@apply bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-400 border-slate-200 dark:border-slate-600 cursor-not-allowed;
.field-disabled {
@apply opacity-50 outline-n-weak dark:outline-n-weak cursor-not-allowed;
}
.field-error {
@apply outline outline-1 outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 disabled:outline-n-ruby-8 dark:disabled:outline-n-ruby-8;
}
}
input[type='file'] {
@apply bg-n-background leading-[1.15] mb-4;
$form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not([type='radio']):not([type='range']):not([type='button']):not([type='submit']):not([type='reset']):not([type='color']):not([type='image']):not([type='hidden']):not(.reset-base):not(.multiselect__input):not(.no-margin)";
#{$form-input-selector} {
@apply field-base h-10;
&[disabled] {
@apply field-disabled;
}
&.error {
@apply field-error mb-1;
}
}
input[type='file']:not(.multiselect__input) {
@apply leading-[1.15] mb-4 border-0 bg-transparent text-sm;
}
// Select
select {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' width='32' height='24' viewBox='0 0 32 24'><polygon points='0,0 32,0 16,24' style='fill: rgb%28110, 111, 115%29'></polygon></svg>");
background-position: right -1rem center;
background-size: 9px 6px;
@apply h-10 mx-0 mt-0 mb-4 bg-origin-content focus-visible:outline-none bg-no-repeat py-2 pr-6 pl-2 rounded-md w-full text-base font-normal appearance-none transition-colors focus:border-woot-500 dark:focus:border-woot-600 duration-[0.25s] ease-[ease-in-out] bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 border border-solid border-slate-200 dark:border-slate-600;
@apply field-base h-10 bg-origin-content focus-visible:outline-none bg-no-repeat py-2 ltr:bg-[right_-1rem_center] rtl:bg-[left_-1rem_center] ltr:pr-6 rtl:pl-6 rtl:pr-3 ltr:pl-3;
&[disabled] {
@apply field-disabled;
}
}
// Textarea
textarea {
@apply block box-border w-full transition-colors focus:border-woot-500 dark:focus:border-woot-600 duration-[0.25s] ease-[ease-in-out] h-16 appearance-none mx-0 mt-0 mb-4 p-2 rounded-md text-base font-normal bg-white dark:bg-slate-900 focus:bg-white focus:dark:bg-slate-900 text-slate-900 dark:text-slate-100 border border-solid border-slate-200 dark:border-slate-600;
@apply field-base h-16;
&[disabled] {
@apply bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-400 border-slate-200 dark:border-slate-600 cursor-not-allowed;
@apply field-disabled;
}
}
// Add mb-1 when .help-text exists within the same label container
label:has(.help-text) {
input,
textarea,
select {
margin-bottom: 0.25rem !important;
}
}
@@ -109,18 +127,41 @@ textarea {
}
}
// FormKit support
.formkit-outer[data-invalid='true'] {
#{$form-input-selector},
textarea,
select {
@apply field-error;
}
.formkit-message {
@apply text-n-ruby-9 dark:text-n-ruby-9 block text-sm mb-2.5 w-full;
}
}
.error {
input,
#{$form-input-selector},
input:not([type]),
textarea,
select,
.multiselect > .multiselect__tags,
.multiselect:not(.no-margin) {
@apply border border-solid border-red-400 dark:border-red-400 mb-1;
@apply field-error;
}
// Only add mb-1 when .message exists within the same .error container
// And exclude no-margin from the margin-bottom
&:has(.message) {
input:not(.no-margin),
textarea,
select {
margin-bottom: 0.25rem !important;
}
}
.message {
@apply text-red-400 dark:text-red-400 block text-sm mb-2.5 w-full;
@apply text-n-ruby-9 dark:text-n-ruby-9 block text-sm mb-2.5 w-full;
}
}
@@ -130,7 +171,7 @@ textarea {
}
.error {
@apply border-red-400 dark:border-red-400;
@apply text-n-ruby-9 dark:text-n-ruby-9;
}
}
@@ -141,11 +182,11 @@ code {
@apply text-xs border-0;
&.hljs {
@apply bg-n-slate-3 dark:bg-n-solid-3 text-slate-800 dark:text-slate-50 rounded-lg p-5;
@apply bg-n-slate-3 dark:bg-n-solid-3 text-n-slate-12 rounded-lg p-5;
.hljs-number,
.hljs-string {
@apply text-red-800 dark:text-red-400;
@apply text-n-ruby-9 dark:text-n-ruby-9;
}
.hljs-name,
@@ -42,7 +42,7 @@ button {
}
}
&:hover:not(.secondary):not(.success):not(.alert):not(.warning):not(
&:hover:not(:disabled):not(.success):not(.alert):not(.warning):not(
.clear
):not(.smooth):not(.hollow) {
@apply bg-n-brand/80 dark:bg-n-brand/80;
@@ -117,63 +117,63 @@ button {
}
&.hollow {
@apply border border-n-brand/40 bg-transparent text-n-blue-text hover:bg-n-brand/20;
@apply border border-n-brand/40 bg-transparent text-n-blue-text hover:enabled:bg-n-brand/20;
&.secondary {
@apply text-n-slate-12 border-n-slate-5 hover:bg-n-slate-5;
@apply text-n-slate-12 border-n-slate-5 hover:enabled:bg-n-slate-5;
}
&.success {
@apply text-n-teal-9 border-n-teal-8 hover:bg-n-teal-5;
@apply text-n-teal-9 border-n-teal-8 hover:enabled:bg-n-teal-5;
}
&.alert {
@apply text-n-ruby-9 border-n-ruby-8 hover:bg-n-ruby-5;
@apply text-n-ruby-9 border-n-ruby-8 hover:enabled:bg-n-ruby-5;
}
&.warning {
@apply text-n-amber-9 border-n-amber-8 hover:bg-n-amber-5;
@apply text-n-amber-9 border-n-amber-8 hover:enabled:bg-n-amber-5;
}
}
// Smooth style
&.smooth {
@apply bg-n-brand/10 dark:bg-n-brand/30 text-n-blue-text hover:bg-n-brand/20 dark:hover:bg-n-brand/40;
@apply bg-n-brand/10 dark:bg-n-brand/30 text-n-blue-text hover:enabled:bg-n-brand/20 dark:hover:enabled:bg-n-brand/40;
&.secondary {
@apply bg-n-slate-4 text-n-slate-11 hover:text-n-slate-11 hover:bg-n-slate-5;
@apply bg-n-slate-4 text-n-slate-11 hover:enabled:text-n-slate-11 hover:enabled:bg-n-slate-5;
}
&.success {
@apply bg-n-teal-4 text-n-teal-11 hover:text-n-teal-11 hover:bg-n-teal-5;
@apply bg-n-teal-4 text-n-teal-11 hover:enabled:text-n-teal-11 hover:enabled:bg-n-teal-5;
}
&.alert {
@apply bg-n-ruby-4 text-n-ruby-11 hover:text-n-ruby-11 hover:bg-n-ruby-5;
@apply bg-n-ruby-4 text-n-ruby-11 hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-5;
}
&.warning {
@apply bg-n-amber-4 text-n-amber-11 hover:text-n-amber-11 hover:bg-n-amber-5;
@apply bg-n-amber-4 text-n-amber-11 hover:enabled:text-n-amber-11 hover:enabled:bg-n-amber-5;
}
}
&.clear {
@apply text-n-blue-text hover:bg-n-brand/10 dark:hover:bg-n-brand/30;
@apply text-n-blue-text hover:enabled:bg-n-brand/10 dark:hover:enabled:bg-n-brand/30;
&.secondary {
@apply text-n-slate-12 hover:bg-n-slate-4;
@apply text-n-slate-12 hover:enabled:bg-n-slate-4;
}
&.success {
@apply text-n-teal-10 hover:bg-n-teal-4;
@apply text-n-teal-10 hover:enabled:bg-n-teal-4;
}
&.alert {
@apply text-n-ruby-11 hover:bg-n-ruby-4;
@apply text-n-ruby-11 hover:enabled:bg-n-ruby-4;
}
&.warning {
@apply text-n-amber-11 hover:bg-n-amber-4;
@apply text-n-amber-11 hover:enabled:bg-n-amber-4;
}
&:active {
@@ -247,10 +247,9 @@ defineExpose({
:placeholder="item.placeholder"
class="[&>div>button]:h-8"
:class="{
'[&>div>button]:bg-n-alpha-black2 [&>div>button]:!outline-transparent':
'[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:!outline-transparent':
!isDetailsView,
'[&>div>button]:!outline-n-weak [&>div>button]:hover:!outline-n-strong [&>div>button]:!bg-n-alpha-black2':
isDetailsView,
'[&>div>button]:!bg-n-alpha-black2': isDetailsView,
}"
@update:model-value="handleCountrySelection"
/>
@@ -266,7 +265,9 @@ defineExpose({
:placeholder="item.placeholder"
:message-type="getMessageType(item.key)"
:custom-input-class="`h-8 !pt-1 !pb-1 ${
!isDetailsView ? '[&:not(.error,.focus)]:!border-transparent' : ''
!isDetailsView
? '[&:not(.error,.focus)]:!outline-transparent'
: ''
}`"
class="w-full"
@input="
@@ -303,7 +304,7 @@ defineExpose({
v-model="
state.additionalAttributes.socialProfiles[item.key.toLowerCase()]
"
class="w-auto min-w-[100px] text-sm bg-transparent reset-base text-n-slate-12 dark:text-n-slate-12 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10"
class="w-auto min-w-[100px] text-sm bg-transparent outline-none reset-base text-n-slate-12 dark:text-n-slate-12 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10"
:placeholder="item.placeholder"
:size="item.placeholder.length"
@input="emit('update', state)"
@@ -135,7 +135,7 @@ const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
:placeholder="
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.SEARCH_PLACEHOLDER')
"
class="w-full h-8 py-2 pl-10 pr-2 text-sm border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
class="w-full h-8 py-2 pl-10 pr-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
<div
@@ -75,7 +75,7 @@ onMounted(() => {
<div>
<div class="flex justify-between w-full gap-4 py-2">
<label
class="text-sm font-medium whitespace-nowrap min-w-[6.25rem] text-slate-900 dark:text-slate-50"
class="text-sm font-medium whitespace-nowrap min-w-[6.25rem] text-n-slate-12"
>
{{
t(
@@ -113,7 +113,7 @@ onMounted(() => {
</div>
<div class="flex justify-between w-full gap-3 py-2">
<label
class="text-sm font-medium whitespace-nowrap min-w-[7.5rem] text-slate-900 dark:text-slate-50"
class="text-sm font-medium whitespace-nowrap min-w-[7.5rem] text-n-slate-12"
>
{{
t('HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TAGS')
@@ -95,7 +95,7 @@ defineExpose({ dialogRef });
:placeholder="
t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.COMBOBOX.PLACEHOLDER')
"
class="[&>div>button]:!border-n-slate-5 [&>div>button]:dark:!border-n-slate-5"
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
/>
</div>
</Dialog>
@@ -303,7 +303,7 @@ const handleAvatarDelete = () => {
:message="
t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.HELP_TEXT')
"
class="[&>div>button]:!outline-n-weak"
class="[&>div>button:not(.focused)]:!outline-n-weak"
/>
</div>
<div class="flex items-start justify-between w-full gap-2">
@@ -27,8 +27,14 @@ const props = defineProps({
type: String,
default: null,
},
isModal: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['close']);
const store = useStore();
const { t } = useI18n();
@@ -61,6 +67,8 @@ const directUploadsEnabled = computed(
const activeContact = computed(() => contactById.value(props.contactId));
const composePopoverClass = computed(() => {
if (props.isModal) return '';
return props.alignPosition === 'right'
? 'absolute ltr:left-0 ltr:right-[unset] rtl:right-0 rtl:left-[unset]'
: 'absolute rtl:left-0 rtl:right-[unset] ltr:right-0 ltr:left-[unset]';
@@ -131,9 +139,14 @@ const clearSelectedContact = () => {
const closeCompose = () => {
showComposeNewConversation.value = false;
selectedContact.value = null;
if (!props.contactId) {
// If contactId is passed as prop
// Then don't allow to remove the selected contact
selectedContact.value = null;
}
targetInbox.value = null;
resetContacts();
emit('close');
};
const createConversation = async ({ payload, isFromWhatsApp }) => {
@@ -182,7 +195,15 @@ watch(
);
const handleClickOutside = () => {
if (!showComposeNewConversation.value) return;
showComposeNewConversation.value = false;
emit('close');
};
const onModalBackdropClick = () => {
if (!props.isModal) return;
handleClickOutside();
};
onMounted(() => resetContacts());
@@ -205,7 +226,7 @@ useKeyboardEvents(keyboardEvents);
v-on-click-outside="[
handleClickOutside,
// Fixed and edge case https://github.com/chatwoot/chatwoot/issues/10785
// This will prevent closing the compose conversation modal when the editor Create link popup is open.
// This will prevent closing the compose conversation modal when the editor Create link popup is open
{ ignore: ['div.ProseMirror-prompt'] },
]"
class="relative"
@@ -218,29 +239,37 @@ useKeyboardEvents(keyboardEvents);
:is-open="showComposeNewConversation"
:toggle="toggle"
/>
<ComposeNewConversationForm
<div
v-if="showComposeNewConversation"
:contacts="contacts"
:contact-id="contactId"
:is-loading="isSearching"
:current-user="currentUser"
:selected-contact="selectedContact"
:target-inbox="targetInbox"
:is-creating-contact="isCreatingContact"
:is-fetching-inboxes="isFetchingInboxes"
:is-direct-uploads-enabled="directUploadsEnabled"
:contact-conversations-ui-flags="uiFlags"
:contacts-ui-flags="contactsUiFlags"
:class="composePopoverClass"
:message-signature="messageSignature"
:send-with-signature="sendWithSignature"
@search-contacts="onContactSearch"
@reset-contact-search="resetContacts"
@update-selected-contact="handleSelectedContact"
@update-target-inbox="handleTargetInbox"
@clear-selected-contact="clearSelectedContact"
@create-conversation="createConversation"
@discard="closeCompose"
/>
:class="{
'fixed z-50 bg-n-alpha-black1 backdrop-blur-[4px] flex items-start pt-[clamp(3rem,15vh,12rem)] justify-center inset-0':
isModal,
}"
@click.self="onModalBackdropClick"
>
<ComposeNewConversationForm
:class="[{ 'mt-2': !isModal }, composePopoverClass]"
:contacts="contacts"
:contact-id="contactId"
:is-loading="isSearching"
:current-user="currentUser"
:selected-contact="selectedContact"
:target-inbox="targetInbox"
:is-creating-contact="isCreatingContact"
:is-fetching-inboxes="isFetchingInboxes"
:is-direct-uploads-enabled="directUploadsEnabled"
:contact-conversations-ui-flags="uiFlags"
:contacts-ui-flags="contactsUiFlags"
:message-signature="messageSignature"
:send-with-signature="sendWithSignature"
@search-contacts="onContactSearch"
@reset-contact-search="resetContacts"
@update-selected-contact="handleSelectedContact"
@update-target-inbox="handleTargetInbox"
@clear-selected-contact="clearSelectedContact"
@create-conversation="createConversation"
@discard="closeCompose"
/>
</div>
</div>
</template>
@@ -232,4 +232,20 @@ useKeyboardEvents(keyboardEvents);
.emoji-dialog::before {
@apply hidden;
}
// The <label> tag inside the file-upload component overlaps the button due to its position.
// This causes the button's hover state to not work, as it's positioned below the label (z-index).
// Increasing the button's z-index would break the file upload functionality.
// This style ensures the label remains clickable while preserving the button's hover effect.
:deep() {
.file-uploads.file-uploads-html5 {
label {
@apply hover:cursor-pointer;
}
&:hover button {
@apply dark:bg-n-solid-2 bg-n-alpha-2;
}
}
}
</style>
@@ -265,7 +265,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
<template>
<div
class="w-[42rem] mt-2 divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl"
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl"
>
<ContactSelector
:contacts="contacts"
@@ -113,7 +113,8 @@ const handleInput = value => {
</div>
<div
v-else-if="selectedContact"
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 px-3 min-h-7 min-w-0"
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 min-h-7 min-w-0"
:class="!contactId ? 'ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1' : 'px-3'"
>
<span class="text-sm truncate text-n-slate-12">
{{
@@ -123,6 +124,7 @@ const handleInput = value => {
}}
</span>
<Button
v-if="!contactId"
variant="ghost"
icon="i-lucide-x"
color="slate"
@@ -52,7 +52,7 @@ const targetInboxLabel = computed(() => {
</label>
<div
v-if="targetInbox"
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 truncate px-3 h-7 min-w-0"
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 truncate ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 h-7 min-w-0"
>
<span class="text-sm truncate text-n-slate-12">
{{ targetInboxLabel }}
@@ -84,7 +84,7 @@ const handleSendMessage = template => {
/>
<div
v-if="showTemplatesMenu"
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto left-0 flex flex-col gap-2 p-4 items-center w-[350px] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto left-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<div class="relative w-full">
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
@@ -96,7 +96,7 @@ const handleSendMessage = template => {
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER'
)
"
class="w-full h-8 py-2 pl-10 pr-2 text-sm border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
class="w-full h-8 py-2 pl-10 pr-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
<div
@@ -106,7 +106,7 @@ onMounted(() => {
<template>
<div
class="absolute top-full mt-1.5 max-h-[500px] overflow-y-auto left-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[460px] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto left-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<span class="text-sm text-n-slate-12">
{{
@@ -30,11 +30,11 @@ const bannerClass = computed(() => {
const buttonClass = computed(() => {
const classMap = {
slate: 'bg-n-slate-4 text-n-slate-11',
amber: 'bg-n-amber-4 text-n-amber-11',
teal: 'bg-n-teal-4 text-n-teal-11',
ruby: 'bg-n-ruby-4 text-n-ruby-11',
blue: 'bg-n-blue-4 text-n-blue-11',
slate: 'bg-n-slate-4 hover:bg-n-slate-5 text-n-slate-11',
amber: 'bg-n-amber-4 hover:bg-n-amber-5 text-n-amber-11',
teal: 'bg-n-teal-4 hover:bg-n-teal-5 text-n-teal-11',
ruby: 'bg-n-ruby-4 hover:bg-n-ruby-5 text-n-ruby-11',
blue: 'bg-n-blue-4 hover:bg-n-blue-5 text-n-blue-11',
};
return classMap[props.color];
@@ -100,49 +100,57 @@ const STYLE_CONFIG = {
colors: {
blue: {
solid:
'bg-n-brand text-white hover:enabled:brightness-110 outline-transparent',
'bg-n-brand text-white hover:enabled:brightness-110 focus-visible:brightness-110 outline-transparent',
faded:
'bg-n-brand/10 text-n-blue-text hover:enabled:bg-n-brand/20 outline-transparent',
outline: 'text-n-blue-text outline-n-blue-border',
ghost: 'text-n-blue-text hover:enabled:bg-n-alpha-2 outline-transparent',
link: 'text-n-blue-text hover:enabled:underline outline-transparent',
'bg-n-brand/10 text-n-blue-text hover:enabled:bg-n-brand/20 focus-visible:bg-n-brand/20 outline-transparent',
outline: 'text-n-blue-text outline-n-brand',
ghost:
'text-n-blue-text hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
link: 'text-n-blue-text hover:enabled:underline focus-visible:underline outline-transparent',
},
ruby: {
solid:
'bg-n-ruby-9 text-white hover:enabled:bg-n-ruby-10 outline-transparent',
'bg-n-ruby-9 text-white hover:enabled:bg-n-ruby-10 focus-visible:bg-n-ruby-10 outline-transparent',
faded:
'bg-n-ruby-9/10 text-n-ruby-11 hover:enabled:bg-n-ruby-9/20 outline-transparent',
outline: 'text-n-ruby-11 hover:enabled:bg-n-ruby-9/10 outline-n-ruby-8',
ghost: 'text-n-ruby-11 hover:enabled:bg-n-alpha-2 outline-transparent',
link: 'text-n-ruby-9 hover:enabled:underline outline-transparent',
'bg-n-ruby-9/10 text-n-ruby-11 hover:enabled:bg-n-ruby-9/20 focus-visible:bg-n-ruby-9/20 outline-transparent',
outline:
'text-n-ruby-11 hover:enabled:bg-n-ruby-9/10 focus-visible:bg-n-ruby-9/10 outline-n-ruby-8',
ghost:
'text-n-ruby-11 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
link: 'text-n-ruby-9 hover:enabled:underline focus-visible:underline outline-transparent',
},
amber: {
solid:
'bg-n-amber-9 text-white hover:enabled:bg-n-amber-10 outline-transparent',
'bg-n-amber-9 text-white hover:enabled:bg-n-amber-10 focus-visible:bg-n-amber-10 outline-transparent',
faded:
'bg-n-amber-9/10 text-n-slate-12 hover:enabled:bg-n-amber-9/20 outline-transparent',
'bg-n-amber-9/10 text-n-slate-12 hover:enabled:bg-n-amber-9/20 focus-visible:bg-n-amber-9/20 outline-transparent',
outline:
'text-n-amber-11 hover:enabled:bg-n-amber-9/10 outline-n-amber-9',
link: 'text-n-amber-9 hover:enabled:underline outline-transparent',
ghost: 'text-n-amber-9 hover:enabled:bg-n-alpha-2 outline-transparent',
'text-n-amber-11 hover:enabled:bg-n-amber-9/10 focus-visible:bg-n-amber-9/10 outline-n-amber-9',
link: 'text-n-amber-9 hover:enabled:underline focus-visible:underline outline-transparent',
ghost:
'text-n-amber-9 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
},
slate: {
solid:
'bg-n-solid-3 dark:hover:enabled:bg-n-solid-2 hover:enabled:bg-n-alpha-2 text-n-slate-12 outline-n-container',
'bg-n-solid-3 dark:hover:enabled:bg-n-solid-2 dark:focus-visible:bg-n-solid-2 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 text-n-slate-12 outline-n-container',
faded:
'bg-n-slate-9/10 text-n-slate-12 hover:enabled:bg-n-slate-9/20 outline-transparent',
outline: 'text-n-slate-11 outline-n-strong hover:enabled:bg-n-slate-9/10',
link: 'text-n-slate-11 hover:enabled:text-n-slate-12 hover:enabled:underline outline-transparent',
ghost: 'text-n-slate-12 hover:enabled:bg-n-alpha-2 outline-transparent',
'bg-n-slate-9/10 text-n-slate-12 hover:enabled:bg-n-slate-9/20 focus-visible:bg-n-slate-9/20 outline-transparent',
outline:
'text-n-slate-11 outline-n-strong hover:enabled:bg-n-slate-9/10 focus-visible:bg-n-slate-9/10',
link: 'text-n-slate-11 hover:enabled:text-n-slate-12 focus-visible:text-n-slate-12 hover:enabled:underline focus-visible:underline outline-transparent',
ghost:
'text-n-slate-12 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
},
teal: {
solid:
'bg-n-teal-9 text-white hover:enabled:bg-n-teal-10 outline-transparent',
'bg-n-teal-9 text-white hover:enabled:bg-n-teal-10 focus-visible:bg-n-teal-10 outline-transparent',
faded:
'bg-n-teal-9/10 text-n-slate-12 hover:enabled:bg-n-teal-9/20 outline-transparent',
outline: 'text-n-teal-11 hover:enabled:bg-n-teal-9/10 outline-n-teal-9',
link: 'text-n-teal-9 hover:enabled:underline outline-transparent',
ghost: 'text-n-teal-9 hover:enabled:bg-n-alpha-2 outline-transparent',
'bg-n-teal-9/10 text-n-slate-12 hover:enabled:bg-n-teal-9/20 focus-visible:bg-n-teal-9/20 outline-transparent',
outline:
'text-n-teal-11 hover:enabled:bg-n-teal-9/10 focus-visible:bg-n-teal-9/10 outline-n-teal-9',
link: 'text-n-teal-9 hover:enabled:underline focus-visible:underline outline-transparent',
ghost:
'text-n-teal-9 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
},
},
sizes: {
@@ -72,10 +72,18 @@ const handlePageChange = event => {
<div
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
>
<span class="text-xl font-medium text-n-slate-12">
{{ headerTitle }}
<slot name="headerTitle" />
</span>
<div class="flex gap-4 items-center">
<slot name="headerTitle">
<span class="text-xl font-medium text-n-slate-12">
{{ headerTitle }}
</span>
</slot>
<div v-if="!isEmpty" class="flex items-center gap-2">
<div class="w-0.5 h-4 rounded-2xl bg-n-weak" />
<slot name="knowMore" />
</div>
</div>
<div
v-if="!showPaywall"
v-on-clickaway="() => emit('close')"
@@ -2,6 +2,7 @@
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import AssistantCard from 'dashboard/components-next/captain/assistant/AssistantCard.vue';
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
import { assistantsList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
const emit = defineEmits(['click']);
@@ -12,6 +13,14 @@ const onClick = () => {
</script>
<template>
<FeatureSpotlight
:title="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
:note="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
fallback-thumbnail="/assets/images/dashboard/captain/assistant-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/assistant-dark.svg"
learn-more-url="https://chwt.app/captain-assistant"
class="mb-8"
/>
<EmptyStateLayout
:title="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.TITLE')"
:subtitle="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.SUBTITLE')"
@@ -2,6 +2,7 @@
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
import { documentsList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
const emit = defineEmits(['click']);
@@ -12,6 +13,14 @@ const onClick = () => {
</script>
<template>
<FeatureSpotlight
:title="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
:note="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
fallback-thumbnail="/assets/images/dashboard/captain/document-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/document-dark.svg"
learn-more-url="https://chwt.app/captain-document"
class="mb-8"
/>
<EmptyStateLayout
:title="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.TITLE')"
:subtitle="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.SUBTITLE')"
@@ -2,6 +2,7 @@
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
import { responsesList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
const emit = defineEmits(['click']);
@@ -12,6 +13,14 @@ const onClick = () => {
</script>
<template>
<FeatureSpotlight
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
:note="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
fallback-thumbnail="/assets/images/dashboard/captain/faqs-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/faqs-dark.svg"
learn-more-url="https://chwt.app/captain-faq"
class="mb-8"
/>
<EmptyStateLayout
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')"
:subtitle="$t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE')"
@@ -96,7 +96,7 @@ watch(
:label="selectedLabel"
trailing-icon
:disabled="disabled"
class="justify-between w-full !px-3 !py-2.5 text-n-slate-12 font-normal group-hover/combobox:border-n-slate-6"
class="justify-between w-full !px-3 !py-2.5 text-n-slate-12 font-normal group-hover/combobox:border-n-slate-6 [&:not(.focused)]:hover:enabled:outline-n-slate-6 [&:not(.focused)]:dark:hover:enabled:outline-n-slate-6 [&:not(.focused)]:dark:outline-n-weak focus:outline-n-brand"
:class="{ focused: open }"
:icon="open ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
@click="toggleDropdown"
@@ -69,7 +69,7 @@ defineExpose({
:value="searchValue"
type="search"
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
class="w-full py-2 pl-10 pr-2 text-sm border-none rounded-t-md bg-n-solid-1 text-slate-900 dark:text-slate-50"
class="reset-base w-full py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-t-md bg-n-solid-1 text-slate-900 dark:text-slate-50"
@input="onInputSearch"
/>
</div>
@@ -75,7 +75,7 @@ onMounted(() => {
:placeholder="
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
"
class="w-full h-8 py-2 pl-10 pr-2 text-sm border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
<button
@@ -0,0 +1,38 @@
<script setup>
import FeatureSpotlight from './FeatureSpotlight.vue';
</script>
<template>
<Story
title="Components/FeatureSpotlight/Default"
:layout="{ type: 'grid', width: '1000px' }"
>
<Variant title="Default with learn more URL">
<div class="p-6 bg-n-background">
<FeatureSpotlight
title="Captain Assistant"
note="Captain Assistant engages directly with customers, learns from your help docs and past conversations, and delivers instant, accurate responses. It handles the initial queries, providing quick resolutions before transferring to an agent when needed."
video-url=""
thumbnail=""
fallback-thumbnail="/assets/images/dashboard/captain/assistant-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/assistant-dark.svg"
learn-more-url="https://www.chatwoot.com/hc/user-guide/articles/1738101547-creating-an-assistant-with-captain"
/>
</div>
</Variant>
<Variant title="With Video URL and Thumbnail">
<div class="p-6 bg-n-background">
<FeatureSpotlight
title="Captain Assistant"
note="Captain Assistant engages directly with customers, learns from your help docs and past conversations, and delivers instant, accurate responses. It handles the initial queries, providing quick resolutions before transferring to an agent when needed."
video-url="https://www.youtube.com/watch?v=E4xUHyAAktY"
thumbnail="https://i.ytimg.com/an_webp/E4xUHyAAktY/mqdefault_6s.webp?du=3000&sqp=CJaKmL4G&rs=AOn4CLCmfy1TMOcW4UsjQTgyKRp4TSGZgg"
fallback-thumbnail="/assets/images/dashboard/captain/assistant-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/assistant-dark.svg"
learn-more-url="https://www.chatwoot.com/hc/user-guide/articles/1738101547-creating-an-assistant-with-captain"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,95 @@
<script setup>
import { ref } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
title: { type: String, default: '' },
note: { type: String, default: '' },
videoUrl: { type: String, default: '' },
thumbnail: { type: String, default: '' },
fallbackThumbnail: { type: String, default: '' },
fallbackThumbnailDark: { type: String, default: '' },
learnMoreUrl: { type: String, default: '' },
});
const imageError = ref(false);
const handleImageError = () => {
imageError.value = true;
};
const openLink = link => {
if (link) {
window.open(link, '_blank');
}
};
</script>
<template>
<section class="custom-dashed-border rounded-2xl py-5 px-6">
<div class="flex flex-col md:flex-row items-start md:items-center gap-6">
<div
class="flex-shrink-0 bg-gray-800 w-[7.5rem] h-[6.5rem] rounded-lg flex items-center justify-center overflow-hidden"
>
<img
v-if="!imageError && thumbnail"
:src="thumbnail"
:alt="title"
draggable="false"
class="w-full h-full object-cover rounded-lg"
loading="lazy"
@error="handleImageError"
/>
<template v-else>
<img
v-if="fallbackThumbnailDark"
:src="fallbackThumbnailDark"
:alt="title"
draggable="false"
class="w-full h-full object-cover hidden dark:block rounded-lg"
loading="lazy"
/>
<img
v-if="fallbackThumbnail"
:src="fallbackThumbnail"
:alt="title"
draggable="false"
class="w-full h-full object-cover block dark:hidden rounded-lg"
loading="lazy"
/>
</template>
</div>
<div class="flex flex-col flex-1 gap-3 ltr:pr-8 rtl:pl-8">
<p v-if="note" class="text-n-slate-12 text-sm mb-0">{{ note }}</p>
<div class="flex gap-3">
<slot name="actions">
<Button
v-if="videoUrl"
:label="$t('FEATURE_SPOTLIGHT.WATCH_VIDEO')"
sm
faded
slate
icon="i-lucide-circle-play"
@click="openLink(videoUrl)"
/>
<Button
v-if="learnMoreUrl"
:label="$t('FEATURE_SPOTLIGHT.LEARN_MORE')"
sm
faded
slate
trailing-icon
icon="i-lucide-arrow-up-right"
@click="openLink(learnMoreUrl)"
/>
</slot>
</div>
</div>
</div>
</section>
</template>
@@ -0,0 +1,44 @@
<script setup>
import FeatureSpotlightPopover from './FeatureSpotlightPopover.vue';
</script>
<template>
<Story
title="Components/FeatureSpotlight/Popup"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Default with learn more URL">
<div class="p-6 h-[450px] bg-n-background">
<div class="flex gap-8">
<FeatureSpotlightPopover
button-label="Learn about Assistant"
title="Captain Assistant"
note="Captain Assistant engages directly with customers, learns from your help docs and past conversations, and delivers instant, accurate responses. It handles the initial queries, providing quick resolutions before transferring to an agent when needed."
video-url=""
thumbnail=""
fallback-thumbnail="/assets/images/dashboard/captain/assistant-popover-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/assistant-popover-dark.svg"
learn-more-url="https://www.chatwoot.com/hc/user-guide/articles/1738101547-creating-an-assistant-with-captain"
/>
</div>
</div>
</Variant>
<Variant title="With Video Thumbnail and URL">
<div class="p-6 h-[450px] bg-n-background">
<div class="flex gap-8">
<FeatureSpotlightPopover
button-label="Learn about Assistant"
title="Captain Assistant"
note="Captain Assistant engages directly with customers, learns from your help docs and past conversations, and delivers instant, accurate responses. It handles the initial queries, providing quick resolutions before transferring to an agent when needed."
video-url="https://www.youtube.com/watch?v=E4xUHyAAktY"
thumbnail="https://i.ytimg.com/an_webp/E4xUHyAAktY/mqdefault_6s.webp?du=3000&sqp=CJaKmL4G&rs=AOn4CLCmfy1TMOcW4UsjQTgyKRp4TSGZgg"
fallback-thumbnail="/assets/images/dashboard/captain/assistant-popover-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/assistant-popover-dark.svg"
learn-more-url="https://www.chatwoot.com/hc/user-guide/articles/1738101547-creating-an-assistant-with-captain"
/>
</div>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,125 @@
<script setup>
import { ref } from 'vue';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
buttonLabel: { type: String, default: '' },
title: { type: String, default: '' },
note: { type: String, default: '' },
videoUrl: { type: String, default: '' },
thumbnail: { type: String, default: '' },
fallbackThumbnail: { type: String, default: '' },
fallbackThumbnailDark: { type: String, default: '' },
learnMoreUrl: { type: String, default: '' },
});
const imageError = ref(false);
const [isPopupVisible, togglePopup] = useToggle();
const handleImageError = () => {
imageError.value = true;
};
const openLink = link => {
if (link) {
window.open(link, '_blank');
}
};
</script>
<template>
<div class="relative">
<Button
id="togglePopup"
:label="buttonLabel"
slate
ghost
sm
:class="{ 'bg-n-alpha-2': isPopupVisible }"
@click="togglePopup(!isPopupVisible)"
/>
<div
v-if="isPopupVisible"
v-on-click-outside="[
() => isPopupVisible && (isPopupVisible = false),
{ ignore: ['#togglePopup'] },
]"
>
<section
class="absolute top-full mt-6 ltr:left-0 rtl:right-0 outline outline-1 outline-n-weak bg-n-alpha-3 backdrop-blur-[100px] rounded-xl p-4 w-80"
>
<div
class="absolute -top-[0.77rem] ltr:left-12 rtl:right-12 w-6 h-6 ltr:rotate-45 rtl:-rotate-45 rtl:rounded-tr ltr:rounded-tl rtl:border-r ltr:border-l border-t border-n-weak bg-n-alpha-3 z-10"
/>
<div class="relative flex flex-col items-start gap-4 z-20">
<div class="flex-shrink-0 bg-gray-800 w-full h-[7.5rem] rounded-lg">
<img
v-if="!imageError && thumbnail"
:src="thumbnail"
:alt="title"
draggable="false"
loading="lazy"
class="w-full h-full object-cover rounded-lg"
@error="handleImageError"
/>
<template v-else>
<img
v-if="fallbackThumbnailDark"
:src="fallbackThumbnailDark"
:alt="title"
draggable="false"
loading="lazy"
class="w-full h-full object-cover hidden dark:block"
/>
<img
v-if="fallbackThumbnail"
:src="fallbackThumbnail"
:alt="title"
draggable="false"
loading="lazy"
class="w-full h-full object-cover block dark:hidden"
/>
</template>
</div>
<p v-if="note" class="text-n-slate-12 text-start text-sm mb-0">
{{ note }}
</p>
<div class="flex gap-3 justify-between w-full">
<slot name="actions">
<Button
v-if="videoUrl"
:label="$t('FEATURE_SPOTLIGHT.WATCH_VIDEO')"
sm
faded
slate
icon="i-lucide-circle-play"
class="w-full"
@click="openLink(videoUrl)"
/>
<Button
v-if="learnMoreUrl"
:label="$t('FEATURE_SPOTLIGHT.LEARN_MORE')"
sm
faded
slate
trailing-icon
class="w-full"
icon="i-lucide-arrow-up-right"
@click="openLink(learnMoreUrl)"
/>
</slot>
</div>
</div>
</section>
</div>
</div>
</template>
@@ -83,7 +83,7 @@ defineExpose({
v-if="label"
:for="id"
:class="customLabelClass"
class="mb-0.5 text-sm font-medium text-n-slate-11"
class="mb-0.5 text-sm font-medium text-n-slate-12"
>
{{ label }}
</label>
@@ -97,7 +97,7 @@ defineExpose({
:placeholder="placeholder"
:disabled="disabled"
:class="customInputClass"
class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-none bg-transparent dark:bg-transparent placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 dark:text-n-slate-12 transition-all duration-500 ease-in-out"
class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-none outline-none outline-0 bg-transparent dark:bg-transparent placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 dark:text-n-slate-12 transition-all duration-500 ease-in-out"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@@ -70,12 +70,12 @@ const messageClass = computed(() => {
}
});
const inputBorderClass = computed(() => {
const inputOutlineClass = computed(() => {
switch (props.messageType) {
case 'error':
return 'border-n-ruby-8 dark:border-n-ruby-8 hover:border-n-ruby-9 dark:hover:border-n-ruby-9 disabled:border-n-ruby-8 dark:disabled:border-n-ruby-8';
return 'outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 disabled:outline-n-ruby-8 dark:disabled:outline-n-ruby-8';
default:
return 'border-n-weak dark:border-n-weak hover:border-n-slate-6 dark:hover:border-n-slate-6 disabled:border-n-weak dark:disabled:border-n-weak focus:border-n-brand dark:focus:border-n-brand';
return 'outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 disabled:outline-n-weak dark:disabled:outline-n-weak focus:outline-n-brand dark:focus:outline-n-brand';
}
});
@@ -124,7 +124,7 @@ onMounted(() => {
:value="modelValue"
:class="[
customInputClass,
inputBorderClass,
inputOutlineClass,
{
error: messageType === 'error',
focus: isFocused,
@@ -134,7 +134,7 @@ onMounted(() => {
:placeholder="placeholder"
:disabled="disabled"
:min="['date', 'datetime-local', 'time'].includes(type) ? min : undefined"
class="block w-full reset-base text-sm h-10 !px-3 !py-2.5 !mb-0 border rounded-lg bg-n-alpha-black2 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 transition-all duration-500 ease-in-out"
class="block w-full reset-base text-sm h-10 !px-3 !py-2.5 !mb-0 outline outline-1 border-none border-0 outline-offset-[-1px] rounded-lg bg-n-alpha-black2 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 transition-all duration-500 ease-in-out"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@@ -90,19 +90,19 @@ const activeCountry = computed(() =>
const inputBorderClass = computed(() => {
const errorClass =
'border-n-ruby-8 dark:border-n-ruby-8 hover:border-n-ruby-9 dark:hover:border-n-ruby-9 disabled:border-n-ruby-8 dark:disabled:border-n-ruby-8';
'outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 disabled:outline-n-ruby-8 dark:disabled:outline-n-ruby-8';
const focusClass =
'has-[:focus]:border-n-brand dark:has-[:focus]:border-n-brand';
'has-[:focus]:outline-n-brand dark:has-[:focus]:outline-n-brand';
if (!props.showBorder) {
if (hasError.value) return errorClass;
return `border-transparent ${focusClass}`;
return `outline-transparent ${focusClass}`;
}
if (hasError.value) {
return errorClass;
}
return `${focusClass} border-n-weak dark:border-n-weak hover:border-n-slate-6 dark:hover:border-n-slate-6 disabled:border-n-weak dark:disabled:border-n-weak`;
return `${focusClass} outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 disabled:outline-n-weak dark:disabled:outline-n-weak`;
});
const phoneNumberError = computed(() => {
@@ -163,7 +163,7 @@ watch(
<div>
<div
v-on-clickaway="() => closeCountryDropdown()"
class="relative flex items-center h-8 transition-all duration-500 ease-in-out border rounded-lg bg-n-alpha-black2"
class="relative flex items-center h-8 transition-all duration-500 ease-in-out outline outline-1 outline-offset-[-1px] rounded-lg bg-n-alpha-black2"
:class="[inputBorderClass, { 'cursor-not-allowed opacity-50': disabled }]"
>
<Input
@@ -171,7 +171,7 @@ watch(
type="tel"
:placeholder="placeholder"
:disabled="disabled"
custom-input-class="!border-0 h-8 !py-0.5 !bg-transparent ltr:!pl-1 rtl:!pr-1"
custom-input-class="!border-0 !outline-none h-8 !py-0.5 !bg-transparent ltr:!pl-1 rtl:!pr-1"
class="w-full !flex-row"
>
<template #prefix>
@@ -185,7 +185,7 @@ watch(
"
trailing-icon
:disabled="disabled"
class="!h-[1.875rem] top-1 !px-2 outline-0 !outline-none !rounded-lg border-0 ltr:!rounded-r-none rtl:!rounded-l-none"
class="!h-[1.875rem] top-1 ltr:ml-px rtl:mr-px !px-2 outline-0 !outline-none !rounded-lg border-0 ltr:!rounded-r-none rtl:!rounded-l-none"
@click="toggleCountryDropdown"
>
<span
@@ -179,7 +179,7 @@ onMounted(() => {
}"
:disabled="disabled"
rows="1"
class="flex w-full reset-base text-sm p-0 !rounded-none !bg-transparent dark:!bg-transparent !border-0 !mb-0 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 dark:text-n-slate-12 disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-slate-25 dark:disabled:bg-slate-900"
class="flex w-full reset-base text-sm p-0 !rounded-none !bg-transparent dark:!bg-transparent !border-0 !outline-0 !mb-0 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 dark:text-n-slate-12 disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-slate-25 dark:disabled:bg-slate-900"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@@ -28,8 +28,8 @@ export default {
'border-b border-solid border-n-weak/60 dark:border-n-weak': showBorder,
}"
>
<div class="grid grid-cols-1 lg:grid-cols-6 gap-6">
<div class="col-span-2 xl:col-span-1">
<div class="grid grid-cols-1 lg:grid-cols-8 gap-6">
<div class="col-span-2">
<p
v-if="title"
class="text-base text-woot-500 dark:text-woot-500 mb-0 font-medium"
@@ -48,7 +48,7 @@ export default {
{{ note }}
</p>
</div>
<div class="col-span-4 lg:col-span-4 2xl:col-span-3">
<div class="col-span-6 xl:col-span-5">
<slot />
</div>
</div>
@@ -1,12 +1,47 @@
<script setup>
import { computed } from 'vue';
import { computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import FilterSelect from 'dashboard/components-next/filter/inputs/FilterSelect.vue';
const props = defineProps({
table: {
type: Object,
required: true,
},
defaultPageSize: {
type: Number,
default: 10,
},
showPageSizeSelector: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['pageSizeChange']);
const { t } = useI18n();
const pageSizeOptions = [
{
label: `${t('REPORT.PAGINATION.PER_PAGE_TEMPLATE', { size: 10 })}`,
value: 10,
},
{
label: `${t('REPORT.PAGINATION.PER_PAGE_TEMPLATE', { size: 20 })}`,
value: 20,
},
{
label: `${t('REPORT.PAGINATION.PER_PAGE_TEMPLATE', { size: 50 })}`,
value: 50,
},
{
label: `${t('REPORT.PAGINATION.PER_PAGE_TEMPLATE', { size: 100 })}`,
value: 100,
},
];
const getFormattedPages = (start, end) => {
const formatter = new Intl.NumberFormat(navigator.language);
return Array.from({ length: end - start + 1 }, (_, i) =>
@@ -48,70 +83,98 @@ const end = computed(() => {
total.value
);
});
const currentPageSize = computed({
get() {
return props.table.getState().pagination.pageSize;
},
set(newValue) {
props.table.setPageSize(Number(newValue));
emit('pageSizeChange', Number(newValue));
},
});
onMounted(() => {
if (
props.showPageSizeSelector &&
props.defaultPageSize &&
props.defaultPageSize !== 10
) {
props.table.setPageSize(props.defaultPageSize);
}
});
</script>
<template>
<div class="flex items-center justify-between">
<div class="flex flex-1 items-center justify-between">
<div>
<p class="text-sm text-n-slate-11 mb-0">
{{ $t('REPORT.PAGINATION.RESULTS', { start, end, total }) }}
</p>
</div>
<nav class="isolate inline-flex gap-1">
<woot-button
:disabled="!table.getCanPreviousPage()"
variant="clear"
class="h-8 border-0 flex items-center"
color-scheme="secondary"
@click="table.setPageIndex(0)"
>
<span class="i-lucide-chevrons-left size-3" aria-hidden="true" />
</woot-button>
<woot-button
variant="clear"
class="h-8 border-0 flex items-center"
color-scheme="secondary"
:disabled="!table.getCanPreviousPage()"
@click="table.previousPage()"
>
<span class="i-lucide-chevron-left size-3" aria-hidden="true" />
</woot-button>
<woot-button
v-for="page in visiblePages"
:key="page"
variant="clear"
class="h-8 flex items-center justify-center text-xs leading-none text-center"
:class="page == currentPage ? 'border-n-brand' : 'border-slate-50'"
color-scheme="secondary"
@click="table.setPageIndex(page - 1)"
>
<span
class="text-center"
:class="{ 'text-n-brand': page == currentPage }"
<div class="flex flex-1 items-center gap-2 justify-between">
<p class="text-sm truncate text-n-slate-11 mb-0">
{{ $t('REPORT.PAGINATION.RESULTS', { start, end, total }) }}
</p>
<div class="flex items-center gap-2">
<FilterSelect
v-if="showPageSizeSelector"
v-model="currentPageSize"
variant="outline"
hide-icon
class="[&>button]:text-n-slate-11 [&>button]:hover:text-n-slate-12 [&>button]:h-6"
:options="pageSizeOptions"
/>
<nav class="isolate inline-flex items-center gap-1.5">
<Button
icon="i-lucide-chevrons-left"
ghost
slate
sm
class="!size-6"
:disabled="!table.getCanPreviousPage()"
@click="table.setPageIndex(0)"
/>
<Button
icon="i-lucide-chevron-left"
ghost
slate
sm
class="!size-6"
:disabled="!table.getCanPreviousPage()"
@click="table.previousPage()"
/>
<Button
v-for="page in visiblePages"
:key="page"
xs
outline
:color="page == currentPage ? 'blue' : 'slate'"
class="!h-6 min-w-6"
@click="table.setPageIndex(page - 1)"
>
{{ page }}
</span>
</woot-button>
<woot-button
:disabled="!table.getCanNextPage()"
variant="clear"
class="h-8 border-0 flex items-center"
color-scheme="secondary"
@click="table.nextPage()"
>
<span class="i-lucide-chevron-right size-3" aria-hidden="true" />
</woot-button>
<woot-button
:disabled="!table.getCanNextPage()"
variant="clear"
class="h-8 border-0 flex items-center"
color-scheme="secondary"
@click="table.setPageIndex(table.getPageCount() - 1)"
>
<span class="i-lucide-chevrons-right size-3" aria-hidden="true" />
</woot-button>
</nav>
<span
class="text-center"
:class="{ 'text-n-brand': page == currentPage }"
>
{{ page }}
</span>
</Button>
<Button
icon="i-lucide-chevron-right"
ghost
slate
sm
class="!size-6"
:disabled="!table.getCanNextPage()"
@click="table.nextPage()"
/>
<Button
icon="i-lucide-chevrons-right"
ghost
slate
sm
class="!size-6"
:disabled="!table.getCanNextPage()"
@click="table.setPageIndex(table.getPageCount() - 1)"
/>
</nav>
</div>
</div>
</div>
</template>
@@ -68,7 +68,7 @@ export default {
<style lang="scss" scoped>
.wizard-box {
.item {
@apply cursor-pointer after:bg-slate-75 before:bg-slate-75 dark:after:bg-slate-600 dark:before:bg-slate-600 py-4 pr-4 pl-6 relative before:h-4 before:top-0 last:before:h-0 first:before:h-0 last:after:h-0 before:content-[''] before:absolute before:w-0.5 after:content-[''] after:h-full after:absolute after:top-5 after:w-0.5;
@apply cursor-pointer after:bg-slate-75 before:bg-slate-75 dark:after:bg-slate-600 dark:before:bg-slate-600 py-4 ltr:pr-4 rtl:pl-4 ltr:pl-6 rtl:pr-6 relative before:h-4 before:top-0 last:before:h-0 first:before:h-0 last:after:h-0 before:content-[''] before:absolute before:w-0.5 after:content-[''] after:h-full after:absolute after:top-5 after:w-0.5 rtl:after:left-6 rtl:before:left-6;
&.active {
h3 {
@@ -177,6 +177,7 @@ export default {
icon="dismiss"
variant="clear"
color-scheme="secondary"
class="flex-shrink-0"
@click="removeAction"
/>
</div>
@@ -201,10 +202,10 @@ export default {
<style lang="scss" scoped>
.filter {
@apply bg-n-slate-3 dark:bg-n-solid-3 p-2 border border-solid border-n-strong dark:border-n-strong rounded-md mb-2;
@apply bg-n-background p-2 border border-solid border-n-strong dark:border-n-strong rounded-lg mb-2;
&.is-a-macro {
@apply mb-0 bg-n-slate-2 dark:bg-n-solid-3 p-0 border-0 rounded-none;
@apply mb-0 bg-n-background dark:bg-n-solid-1 p-0 border-0 rounded-none;
}
}
@@ -213,15 +214,19 @@ export default {
}
.filter.has-error {
@apply bg-red-50 dark:bg-red-800/50 border-red-100 dark:border-red-700/50;
@apply bg-n-ruby-8/20 border-n-ruby-5 dark:border-n-ruby-5;
&.is-a-macro {
@apply bg-transparent;
}
}
.filter-inputs {
@apply flex;
@apply flex gap-1;
}
.filter-error {
@apply text-red-500 dark:text-red-200 block my-1 mx-0;
@apply text-n-ruby-9 dark:text-n-ruby-9 block my-1 mx-0;
}
.action__question,
@@ -29,7 +29,7 @@ export default {
<template>
<div>
<div class="multiselect-wrap--small">
<div class="multiselect-wrap--small flex flex-col gap-1 mt-1">
<multiselect
v-model="selectedTeams"
track-by="id"
@@ -11,13 +11,13 @@ export default {
<div class="empty-state py-16 px-1 ml-0 mr-0">
<h3
v-if="title"
class="text-slate-700 dark:text-slate-200 block text-center w-full text-xl font-thin"
class="text-n-slate-12 block text-center w-full text-xl font-medium"
>
{{ title }}
</h3>
<p
v-if="message"
class="block text-center text-slate-500 dark:text-slate-400 my-4 mx-auto w-[90%]"
class="block text-center text-n-slate-11 dark:text-slate-400 my-4 mx-auto w-[90%]"
>
{{ message }}
</p>
@@ -127,8 +127,8 @@ export default {
},
getInputErrorClass(errorMessage) {
return errorMessage
? 'bg-red-50 dark:bg-red-800/50 border-red-100 dark:border-red-700/50'
: 'bg-n-slate-3 dark:bg-n-solid-3 border-n-strong dark:border-n-strong';
? 'bg-n-ruby-8/20 border-n-ruby-5 dark:border-n-ruby-5'
: 'bg-n-background border-n-weak dark:border-n-weak';
},
},
};
@@ -138,14 +138,14 @@ export default {
<template>
<div>
<div
class="p-2 border border-solid rounded-md"
class="p-2 border border-solid rounded-lg"
:class="getInputErrorClass(errorMessage)"
>
<div class="flex">
<div class="flex gap-1">
<select
v-if="groupedFilters"
v-model="attributeKey"
class="bg-white max-w-[30%] dark:bg-slate-900 mb-0 mr-1 text-slate-800 dark:text-slate-100 border-slate-75 dark:border-slate-600"
class="max-w-[30%] mb-0 mr-1"
@change="resetFilter()"
>
<optgroup
@@ -166,7 +166,7 @@ export default {
<select
v-else
v-model="attributeKey"
class="bg-white max-w-[30%] dark:bg-slate-900 mb-0 mr-1 text-slate-800 dark:text-slate-100 border-slate-75 dark:border-slate-600"
class="max-w-[30%] mb-0 mr-1"
@change="resetFilter()"
>
<option
@@ -179,10 +179,7 @@ export default {
</option>
</select>
<select
v-model="filterOperator"
class="bg-white dark:bg-slate-900 max-w-[20%] mb-0 mr-1 text-slate-800 dark:text-slate-100 border-slate-75 dark:border-slate-600"
>
<select v-model="filterOperator" class="max-w-[20%] mb-0 mr-1">
<option
v-for="(operator, o) in operators"
:key="o"
@@ -234,14 +231,14 @@ export default {
v-model="values"
type="date"
:editable="false"
class="mb-0 datepicker"
class="!mb-0 datepicker"
/>
</div>
<input
v-else
v-model="values"
type="text"
class="mb-0"
class="!mb-0"
:placeholder="$t('FILTER.INPUT_PLACEHOLDER')"
/>
</div>
@@ -287,7 +284,7 @@ export default {
}
.filter-error {
@apply text-red-500 dark:text-red-200 block my-1 mx-0;
@apply text-n-ruby-9 dark:text-n-ruby-9 block my-1 mx-0;
}
.multiselect {
@@ -1,76 +0,0 @@
<script>
import {
getInboxClassByType,
getReadableInboxByType,
} from 'dashboard/helper/inbox';
export default {
components: {},
props: {
name: {
type: String,
default: '',
},
inboxIdentifier: {
type: String,
default: '',
},
channelType: {
type: String,
default: '',
},
},
computed: {
computedInboxIcon() {
if (!this.channelType) return 'chat';
const classByType = getInboxClassByType(
this.channelType,
this.inboxIdentifier
);
return classByType;
},
computedInboxType() {
if (!this.channelType) return 'chat';
const classByType = getReadableInboxByType(
this.channelType,
this.inboxIdentifier
);
return classByType;
},
},
};
</script>
<template>
<div class="flex items-center h-[2.375rem] min-w-0 py-0 px-1">
<span
class="inline-flex rounded mr-1 rtl:ml-1 rtl:mr-0 bg-slate-25 dark:bg-slate-700 p-0.5 items-center flex-shrink-0 justify-center w-6 h-6"
>
<fluent-icon
:icon="computedInboxIcon"
size="14"
class="text-slate-800 dark:text-slate-200"
/>
</span>
<div class="flex flex-col w-full min-w-0 ml-1 mr-1">
<h5 class="option__title">
{{ name }}
</h5>
<p
class="option__body overflow-hidden whitespace-nowrap text-ellipsis"
:title="inboxIdentifier"
>
{{ inboxIdentifier || computedInboxType }}
</p>
</div>
</div>
</template>
<style lang="scss" scoped>
.option__body {
@apply inline-block text-slate-600 dark:text-slate-200 leading-[1.3] min-w-0 m-0;
}
.option__title {
@apply leading-[1.1] text-xs m-0 text-slate-800 dark:text-slate-100;
}
</style>
@@ -1,90 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import AddLabel from 'shared/components/ui/dropdown/AddLabel.vue';
import LabelDropdown from 'shared/components/ui/label/LabelDropdown.vue';
const props = defineProps({
allLabels: {
type: Array,
default: () => [],
},
savedLabels: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['add', 'remove']);
const { isAdmin } = useAdmin();
const showSearchDropdownLabel = ref(false);
const selectedLabels = computed(() => {
return props.savedLabels.map(label => label.title);
});
const addItem = label => {
emit('add', label);
};
const removeItem = label => {
emit('remove', label);
};
const toggleLabels = () => {
showSearchDropdownLabel.value = !showSearchDropdownLabel.value;
};
const closeDropdownLabel = () => {
showSearchDropdownLabel.value = false;
};
const keyboardEvents = {
KeyL: {
action: e => {
toggleLabels();
e.preventDefault();
},
},
Escape: {
action: () => closeDropdownLabel(),
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
</script>
<template>
<div v-on-clickaway="closeDropdownLabel" class="relative leading-6">
<AddLabel @add="toggleLabels" />
<woot-label
v-for="label in savedLabels"
:key="label.id"
:title="label.title"
:description="label.description"
show-close
:color="label.color"
variant="smooth"
@remove="removeItem"
/>
<div class="absolute w-full top-7">
<div
:class="{ 'dropdown-pane--open': showSearchDropdownLabel }"
class="!box-border !w-full dropdown-pane"
>
<LabelDropdown
v-if="showSearchDropdownLabel"
:account-labels="allLabels"
:selected-labels="selectedLabels"
:allow-creation="isAdmin"
@add="addItem"
@remove="removeItem"
/>
</div>
</div>
</div>
</template>
@@ -14,16 +14,18 @@ export default {
</script>
<template>
<div class="pt-4 pb-0 px-8 border-b border-solid border-n-weak/60">
<h2 class="text-2xl text-slate-800 dark:text-slate-100 mb-1 font-medium">
{{ headerTitle }}
</h2>
<p
v-if="headerContent"
class="w-full text-slate-600 dark:text-slate-300 text-sm mb-2"
>
{{ headerContent }}
</p>
<slot />
<div class="border-b border-solid border-n-weak/60">
<div class="max-w-6xl w-full mx-auto pt-4 pb-0 px-8">
<h2 class="text-2xl text-slate-800 dark:text-slate-100 mb-1 font-medium">
{{ headerTitle }}
</h2>
<p
v-if="headerContent"
class="w-full text-slate-600 dark:text-slate-300 text-sm mb-2"
>
{{ headerContent }}
</p>
<slot />
</div>
</div>
</template>
@@ -783,15 +783,15 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
.is-private {
.prosemirror-mention-node {
@apply font-medium bg-n-amber-2/80 dark:bg-n-amber-2/80 text-slate-900 dark:text-slate-25 py-0 px-1;
@apply font-medium bg-n-amber-2/80 dark:bg-n-amber-2/80 text-n-slate-12 py-0 px-1;
}
.ProseMirror-menubar-wrapper {
> .ProseMirror {
@apply text-slate-800 dark:text-slate-25;
@apply text-n-slate-12;
p {
@apply text-slate-800 dark:text-slate-25;
@apply text-n-slate-12;
}
}
}
@@ -802,11 +802,11 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
}
.message-editor {
@apply border border-solid border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-900 rounded-md py-0 px-1 mb-0;
@apply rounded-lg outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 bg-n-alpha-black2 py-0 px-1 mb-0;
}
.editor_warning {
@apply border border-solid border-red-400 dark:border-red-400;
@apply outline outline-1 outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9;
}
.editor-warning__message {
@@ -1253,7 +1253,7 @@ export default {
@apply relative py-0 px-4 -mt-px;
textarea {
@apply shadow-none border-transparent bg-transparent m-0 max-h-60 min-h-[3rem] pt-4 pb-0 px-0 resize-none;
@apply shadow-none outline-none border-transparent bg-transparent m-0 max-h-60 min-h-[3rem] pt-4 pb-0 px-0 resize-none;
}
}
@@ -97,7 +97,7 @@ export default {
<woot-input
v-model="v$.toEmailsVal.$model"
type="text"
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none [&>input]:!bg-transparent dark:[&>input]:!bg-transparent"
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:!outline-none [&>input]:h-8 [&>input]:!text-sm [&>input]:!border-0 [&>input]:border-none [&>input]:!bg-transparent dark:[&>input]:!bg-transparent"
:class="{ error: v$.toEmailsVal.$error }"
:placeholder="$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.CC.PLACEHOLDER')"
@blur="onBlur"
@@ -113,7 +113,7 @@ export default {
<div class="flex-1 min-w-0 m-0 rounded-none whitespace-nowrap">
<woot-input
v-model="v$.ccEmailsVal.$model"
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none [&>input]:!bg-transparent dark:[&>input]:!bg-transparent"
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:!outline-none [&>input]:h-8 [&>input]:!text-sm [&>input]:!border-0 [&>input]:border-none [&>input]:!bg-transparent dark:[&>input]:!bg-transparent"
type="text"
:class="{ error: v$.ccEmailsVal.$error }"
:placeholder="$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.CC.PLACEHOLDER')"
@@ -142,7 +142,7 @@ export default {
<woot-input
v-model="v$.bccEmailsVal.$model"
type="text"
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none [&>input]:!bg-transparent dark:[&>input]:!bg-transparent"
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:!outline-none [&>input]:h-8 [&>input]:!text-sm [&>input]:!border-0 [&>input]:border-none [&>input]:!bg-transparent dark:[&>input]:!bg-transparent"
:class="{ error: v$.bccEmailsVal.$error }"
:placeholder="
$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.BCC.PLACEHOLDER')
@@ -1,10 +1,11 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted, useTemplateRef } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useStoreGetters } from 'dashboard/composables/store';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useImageZoom } from 'dashboard/composables/useImageZoom';
import { messageTimestamp } from 'shared/helpers/timeHelper';
import { downloadFile } from '@chatwoot/utils';
@@ -26,7 +27,6 @@ const emit = defineEmits(['close']);
const show = defineModel('show', { type: Boolean, default: false });
const { t } = useI18n();
const getters = useStoreGetters();
const ALLOWED_FILE_TYPES = {
@@ -36,11 +36,7 @@ const ALLOWED_FILE_TYPES = {
AUDIO: 'audio',
};
const MAX_ZOOM_LEVEL = 2;
const MIN_ZOOM_LEVEL = 1;
const isDownloading = ref(false);
const zoomScale = ref(1);
const activeAttachment = ref({});
const activeFileType = ref('');
const activeImageIndex = ref(
@@ -48,10 +44,23 @@ const activeImageIndex = ref(
attachment => attachment.message_id === props.attachment.message_id
) || 0
);
const activeImageRotation = ref(0);
const imageRef = useTemplateRef('imageRef');
const {
imageWrapperStyle,
imageStyle,
onRotate,
activeImageRotation,
onZoom,
onDoubleClickZoomImage,
onWheelImageZoom,
onMouseMove,
onMouseLeave,
resetZoomAndRotation,
} = useImageZoom(imageRef);
const currentUser = computed(() => getters.getCurrentUser.value);
const hasMoreThanOneAttachment = computed(
() => props.allAttachments.length > 1
);
@@ -65,10 +74,10 @@ const readableTime = computed(() => {
const isImage = computed(
() => activeFileType.value === ALLOWED_FILE_TYPES.IMAGE
);
const isVideo = computed(
() =>
activeFileType.value === ALLOWED_FILE_TYPES.VIDEO ||
activeFileType.value === ALLOWED_FILE_TYPES.IG_REEL
const isVideo = computed(() =>
[ALLOWED_FILE_TYPES.VIDEO, ALLOWED_FILE_TYPES.IG_REEL].includes(
activeFileType.value
)
);
const isAudio = computed(
() => activeFileType.value === ALLOWED_FILE_TYPES.AUDIO
@@ -82,9 +91,9 @@ const senderDetails = computed(() => {
thumbnail,
id,
} = activeAttachment.value?.sender || props.attachment?.sender || {};
const currentUserID = currentUser.value?.id;
return {
name: currentUserID === id ? 'You' : name || availableName || '',
name: currentUser.value?.id === id ? 'You' : name || availableName || '',
avatar: thumbnail || avatar_url || '',
};
});
@@ -92,43 +101,32 @@ const senderDetails = computed(() => {
const fileNameFromDataUrl = computed(() => {
const { data_url: dataUrl } = activeAttachment.value;
if (!dataUrl) return '';
const fileName = dataUrl?.split('/').pop();
return decodeURIComponent(fileName || '');
const fileName = dataUrl.split('/').pop();
return fileName ? decodeURIComponent(fileName) : '';
});
const imageRotationStyle = computed(() => ({
transform: `rotate(${activeImageRotation.value}deg) scale(${zoomScale.value})`,
cursor: zoomScale.value < MAX_ZOOM_LEVEL ? 'zoom-in' : 'zoom-out',
}));
const onClose = () => {
emit('close');
};
const onClose = () => emit('close');
const setImageAndVideoSrc = attachment => {
const { file_type: type } = attachment;
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
return;
}
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) return;
activeAttachment.value = attachment;
activeFileType.value = type;
};
const onClickChangeAttachment = (attachment, index) => {
if (!attachment) {
return;
}
if (!attachment) return;
activeImageIndex.value = index;
setImageAndVideoSrc(attachment);
activeImageRotation.value = 0;
zoomScale.value = 1;
resetZoomAndRotation();
};
const onClickDownload = async () => {
const { file_type: type, data_url: url, extension } = activeAttachment.value;
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
return;
}
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) return;
try {
isDownloading.value = true;
@@ -140,66 +138,8 @@ const onClickDownload = async () => {
}
};
const onRotate = type => {
if (!isImage.value) {
return;
}
const rotation = type === 'clockwise' ? 90 : -90;
// Reset rotation if it is 360
if (Math.abs(activeImageRotation.value) === 360) {
activeImageRotation.value = rotation;
} else {
activeImageRotation.value += rotation;
}
};
const onZoom = scale => {
if (!isImage.value) {
return;
}
const newZoomScale = zoomScale.value + scale;
// Check if the new zoom scale is within the allowed range
if (newZoomScale > MAX_ZOOM_LEVEL) {
// Set zoom to max but do not reset to default
zoomScale.value = MAX_ZOOM_LEVEL;
return;
}
if (newZoomScale < MIN_ZOOM_LEVEL) {
// Set zoom to min but do not reset to default
zoomScale.value = MIN_ZOOM_LEVEL;
return;
}
// If within bounds, update the zoom scale
zoomScale.value = newZoomScale;
};
const onClickZoomImage = () => {
// If already at max zoom, clicking should zoom out to minimum
if (zoomScale.value >= MAX_ZOOM_LEVEL) {
zoomScale.value = MIN_ZOOM_LEVEL;
return;
}
// Otherwise zoom in
onZoom(0.1);
};
const onWheelImageZoom = e => {
if (!isImage.value) {
return;
}
const scale = e.deltaY > 0 ? -0.1 : 0.1;
onZoom(scale);
};
const keyboardEvents = {
Escape: {
action: () => {
onClose();
},
},
Escape: { action: onClose },
ArrowLeft: {
action: () => {
onClickChangeAttachment(
@@ -217,6 +157,7 @@ const keyboardEvents = {
},
},
};
useKeyboardEvents(keyboardEvents);
onMounted(() => {
@@ -232,50 +173,46 @@ onMounted(() => {
:on-close="onClose"
>
<div
class="bg-white dark:bg-slate-900 flex flex-col h-[inherit] w-[inherit] overflow-hidden"
class="bg-n-background flex flex-col h-[inherit] w-[inherit] overflow-hidden select-none"
@click="onClose"
>
<div
class="z-10 flex items-center justify-between w-full h-16 px-6 py-2 bg-white dark:bg-slate-900"
<header
class="z-10 flex items-center justify-between w-full h-16 px-6 py-2 bg-n-background border-b border-n-weak"
@click.stop
>
<div
v-if="senderDetails"
class="items-center flex justify-start min-w-[15rem]"
class="flex items-center min-w-[15rem] shrink-0"
>
<Thumbnail
v-if="senderDetails.avatar"
:username="senderDetails.name"
:src="senderDetails.avatar"
class="flex-shrink-0"
/>
<div
class="flex flex-col items-start justify-center ml-2 rtl:ml-0 rtl:mr-2"
>
<h3 class="text-base inline-block leading-[1.4] m-0 p-0 capitalize">
<div class="flex flex-col ml-2 rtl:ml-0 rtl:mr-2 overflow-hidden">
<h3 class="text-base leading-5 m-0 font-medium">
<span
class="overflow-hidden text-slate-800 dark:text-slate-100 whitespace-nowrap text-ellipsis"
class="overflow-hidden text-n-slate-12 whitespace-nowrap text-ellipsis"
>
{{ senderDetails.name }}
</span>
</h3>
<span
class="p-0 m-0 overflow-hidden text-xs text-slate-400 dark:text-slate-200 whitespace-nowrap text-ellipsis"
class="text-xs text-n-slate-11 whitespace-nowrap text-ellipsis"
>
{{ readableTime }}
</span>
</div>
</div>
<div
class="flex items-center justify-start w-auto min-w-0 p-1 text-sm font-semibold text-slate-700 dark:text-slate-100"
class="flex-1 mx-2 px-2 truncate text-sm font-medium text-center text-n-slate-12"
>
<span
v-dompurify-html="fileNameFromDataUrl"
class="overflow-hidden text-slate-700 dark:text-slate-100 whitespace-nowrap text-ellipsis"
/>
<span v-dompurify-html="fileNameFromDataUrl" class="truncate" />
</div>
<div
class="items-center flex gap-2 justify-end min-w-[8rem] sm:min-w-[15rem]"
>
<div class="flex items-center gap-2 ml-2 shrink-0">
<NextButton
v-if="isImage"
icon="i-lucide-zoom-in"
@@ -314,13 +251,14 @@ onMounted(() => {
/>
<NextButton icon="i-lucide-x" slate ghost @click="onClose" />
</div>
</div>
<div class="flex items-center justify-center w-full h-full">
<div class="flex justify-center min-w-[6.25rem] w-[6.25rem]">
</header>
<main class="flex items-stretch flex-1 h-full overflow-hidden">
<div class="flex items-center justify-center w-16 shrink-0">
<NextButton
v-if="hasMoreThanOneAttachment"
icon="i-lucide-chevron-left"
class="z-10 disabled:pointer-events-auto"
class="z-10"
blue
faded
lg
@@ -333,42 +271,60 @@ onMounted(() => {
"
/>
</div>
<div class="flex flex-col items-center justify-center w-full h-full">
<div>
<div class="flex-1 flex items-center justify-center overflow-hidden">
<div
v-if="isImage"
:style="imageWrapperStyle"
class="flex items-center justify-center origin-center"
:class="{
// Adjust dimensions when rotated 90/270 degrees to maintain visibility
// and prevent image from overflowing container in different aspect ratios
'w-[calc(100dvh-8rem)] h-[calc(100dvw-7rem)]':
activeImageRotation % 180 !== 0,
'size-full': activeImageRotation % 180 === 0,
}"
>
<img
v-if="isImage"
ref="imageRef"
:key="activeAttachment.message_id"
:src="activeAttachment.data_url"
class="mx-auto my-0 duration-150 ease-in-out transform modal-image skip-context-menu"
:style="imageRotationStyle"
@click.stop="onClickZoomImage"
@wheel.stop="onWheelImageZoom"
/>
<video
v-if="isVideo"
:key="activeAttachment.message_id"
:src="activeAttachment.data_url"
controls
playsInline
class="mx-auto my-0 modal-video skip-context-menu"
:style="imageStyle"
class="max-h-full max-w-full object-contain duration-100 ease-in-out transform select-none"
@click.stop
@dblclick.stop="onDoubleClickZoomImage"
@wheel.prevent.stop="onWheelImageZoom"
@mousemove="onMouseMove"
@mouseleave="onMouseLeave"
/>
<audio
v-if="isAudio"
:key="activeAttachment.message_id"
controls
class="skip-context-menu"
@click.stop
>
<source :src="`${activeAttachment.data_url}?t=${Date.now()}`" />
</audio>
</div>
<video
v-if="isVideo"
:key="activeAttachment.message_id"
:src="activeAttachment.data_url"
controls
playsInline
class="max-h-full max-w-full object-contain"
@click.stop
/>
<audio
v-if="isAudio"
:key="activeAttachment.message_id"
controls
class="w-full max-w-md"
@click.stop
>
<source :src="`${activeAttachment.data_url}?t=${Date.now()}`" />
</audio>
</div>
<div class="flex justify-center min-w-[6.25rem] w-[6.25rem]">
<div class="flex items-center justify-center w-16 shrink-0">
<NextButton
v-if="hasMoreThanOneAttachment"
icon="i-lucide-chevron-right"
class="z-10 disabled:pointer-events-auto"
class="z-10"
blue
faded
lg
@@ -381,16 +337,17 @@ onMounted(() => {
"
/>
</div>
</div>
<div class="z-10 flex items-center justify-center w-full h-16 px-6 py-2">
</main>
<footer
class="z-10 flex items-center justify-center h-12 border-t border-n-weak"
>
<div
class="items-center rounded-sm flex font-semibold justify-center min-w-[5rem] p-1 bg-slate-25 dark:bg-slate-800 text-slate-600 dark:text-slate-200 text-sm"
class="rounded-md flex items-center justify-center px-3 py-1 bg-n-slate-3 text-n-slate-12 text-sm font-medium"
>
<span class="count">
{{ `${activeImageIndex + 1} / ${allAttachments.length}` }}
</span>
{{ `${activeImageIndex + 1} / ${allAttachments.length}` }}
</div>
</div>
</footer>
</div>
</woot-modal>
</template>
@@ -63,7 +63,6 @@ export default {
<label class="input-container">
<span v-if="label">{{ label }}</span>
<input
class="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 border-slate-200 dark:border-slate-600"
:value="modelValue"
:type="type"
:placeholder="placeholder"
@@ -82,7 +81,7 @@ export default {
<style scoped lang="scss">
.help-text {
@apply mt-0.5 text-xs not-italic text-slate-600 dark:text-slate-400;
@apply mt-0.5 text-xs not-italic text-n-slate-11;
}
.message {
@@ -175,15 +175,15 @@ export default {
<template>
<div class="relative phone-input--wrap">
<div
class="flex items-center justify-start border border-solid rounded-md dark:bg-slate-900"
class="flex items-center justify-start border-none outline outline-1 rounded-lg bg-n-alpha-black2"
:class="
error
? 'border border-solid border-red-400 dark:border-red-400 mb-1'
: 'mb-4 border-slate-200 dark:border-slate-600'
? 'outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 mb-1'
: 'mb-4 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6'
"
>
<div
class="cursor-pointer py-2 pr-1.5 pl-2 rounded-tl-md rounded-bl-md flex items-center justify-center gap-1.5 bg-slate-25 dark:bg-slate-700 h-10 w-14"
class="cursor-pointer py-2 pr-1.5 pl-2 rounded-tl-lg rounded-bl-lg flex items-center justify-center gap-1.5 bg-n-solid-3 h-10 w-14"
@click.prevent="toggleCountryDropdown"
>
<h5 v-if="activeCountry" class="mb-0">
@@ -194,7 +194,7 @@ export default {
</div>
<span
v-if="activeDialCode"
class="flex py-2 pl-2 pr-0 text-base font-normal leading-normal bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100"
class="flex py-2 pl-2 pr-0 text-base font-normal leading-normal text-n-slate-12"
>
{{ activeDialCode }}
</span>
@@ -202,7 +202,7 @@ export default {
ref="phoneNumberInput"
:value="phoneNumber"
type="tel"
class="!mb-0 !rounded-tl-none !rounded-bl-none !border-0 font-normal !w-full dark:!bg-slate-900 text-base !px-1.5 placeholder:font-normal"
class="no-margin !rounded-tl-none !rounded-bl-none !outline-none !border-0 font-normal !w-full !bg-transparent text-base !px-1.5 placeholder:font-normal"
:placeholder="placeholder"
:readonly="readonly"
:style="styles"
@@ -215,20 +215,22 @@ export default {
ref="dropdown"
v-on-clickaway="onOutsideClick"
tabindex="0"
class="z-10 absolute h-60 w-[12.5rem] shadow-md overflow-y-auto top-10 rounded px-0 pt-0 pb-1 bg-white dark:bg-slate-900"
class="z-10 absolute h-60 w-[12.5rem] shadow-md overflow-y-auto top-10 rounded-lg px-0 pt-0 pb-1 bg-n-alpha-3 backdrop-blur-[100px]"
@keydown.prevent.up="moveUp"
@keydown.prevent.down="moveDown"
@keydown.prevent.enter="
onSelectCountry(filteredCountriesBySearch[selectedIndex])
"
>
<div class="sticky top-0 p-1 bg-white dark:bg-slate-900">
<div
class="sticky top-0 p-1 bg-white dark:bg-transparent backdrop-blur-[100px]"
>
<input
ref="searchbar"
v-model="searchCountry"
type="text"
:placeholder="$t('GENERAL.PHONE_INPUT.PLACEHOLDER')"
class="!h-8 !mb-0 !text-sm !border !border-solid !border-slate-2000 dark:!border-slate-6000"
class="!h-8 !mb-0 !text-sm !outline-n-brand dark:!outline-n-brand"
@input="onSearchCountry"
/>
</div>
@@ -236,10 +238,10 @@ export default {
v-for="(country, index) in filteredCountriesBySearch"
ref="dropdownItem"
:key="index"
class="flex items-center px-1 py-0 cursor-pointer h-7 hover:bg-slate-50 dark:hover:bg-slate-700"
class="flex items-center px-1 py-0 cursor-pointer h-7 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2"
:class="{
'bg-slate-50 dark:bg-slate-700': country.id === activeCountryCode,
'bg-slate-25 dark:bg-slate-800': index === selectedIndex,
'bg-n-alpha-1 dark:bg-n-alpha-2': country.id === activeCountryCode,
'bg-n-alpha-1 dark:bg-n-alpha-2': index === selectedIndex,
}"
@click="onSelectCountry(country)"
>
@@ -250,13 +252,13 @@ export default {
>
{{ country.name }}
</span>
<span class="ml-1 text-xs text-slate-300 dark:text-slate-300">{{
country.dial_code
}}</span>
<span class="ml-1 text-xs text-n-slate-11">
{{ country.dial_code }}
</span>
</div>
<div v-if="filteredCountriesBySearch.length === 0">
<span
class="flex items-center justify-center mt-4 text-sm text-slate-500 dark:text-slate-300"
class="flex items-center justify-center mt-4 text-sm text-n-slate-10"
>
{{ $t('GENERAL.PHONE_INPUT.EMPTY_STATE') }}
</span>
@@ -0,0 +1,141 @@
import { ref } from 'vue';
import { useImageZoom } from 'dashboard/composables/useImageZoom';
describe('useImageZoom', () => {
let imageRef;
beforeEach(() => {
// Mock imageRef element with getBoundingClientRect method
imageRef = ref({
getBoundingClientRect: () => ({
left: 100,
top: 100,
width: 200,
height: 200,
}),
});
});
it('should initialize with default values', () => {
const { zoomScale, imgTransformOriginPoint, activeImageRotation } =
useImageZoom(imageRef);
expect(zoomScale.value).toBe(1);
expect(imgTransformOriginPoint.value).toBe('center center');
expect(activeImageRotation.value).toBe(0);
});
it('should update zoom scale when onZoom is called', () => {
const { zoomScale, onZoom } = useImageZoom(imageRef);
onZoom(0.5);
expect(zoomScale.value).toBe(1.5);
// Should respect max zoom level
onZoom(10);
expect(zoomScale.value).toBe(3);
// Should respect min zoom level
onZoom(-10);
expect(zoomScale.value).toBe(1);
});
it('should update rotation when onRotate is called', () => {
const { activeImageRotation, onRotate } = useImageZoom(imageRef);
onRotate('clockwise');
expect(activeImageRotation.value).toBe(90);
onRotate('counter-clockwise');
expect(activeImageRotation.value).toBe(0);
// Test full rotation reset
onRotate('clockwise');
onRotate('clockwise');
onRotate('clockwise');
onRotate('clockwise');
onRotate('clockwise');
// After 360 degrees, it should reset and add the new rotation
expect(activeImageRotation.value).toBe(90);
});
it('should reset zoom and rotation', () => {
const {
zoomScale,
activeImageRotation,
onZoom,
onRotate,
resetZoomAndRotation,
} = useImageZoom(imageRef);
onZoom(0.5);
onRotate('clockwise');
expect(zoomScale.value).toBe(1); // Rotation resets zoom
expect(activeImageRotation.value).toBe(90);
onZoom(0.5);
expect(zoomScale.value).toBe(1.5);
resetZoomAndRotation();
expect(zoomScale.value).toBe(1);
expect(activeImageRotation.value).toBe(0);
});
it('should handle double click zoom', () => {
const { zoomScale, onDoubleClickZoomImage } = useImageZoom(imageRef);
// Mock event
const event = {
clientX: 150,
clientY: 150,
preventDefault: vi.fn(),
};
onDoubleClickZoomImage(event);
expect(zoomScale.value).toBe(3); // Max zoom
expect(event.preventDefault).toHaveBeenCalled();
onDoubleClickZoomImage(event);
expect(zoomScale.value).toBe(1); // Min zoom
});
it('should handle wheel zoom', () => {
const { zoomScale, onWheelImageZoom } = useImageZoom(imageRef);
// Mock event
const event = {
clientX: 150,
clientY: 150,
deltaY: -10, // Zoom in
preventDefault: vi.fn(),
};
onWheelImageZoom(event);
expect(zoomScale.value).toBe(1.2);
expect(event.preventDefault).toHaveBeenCalled();
// Zoom out
event.deltaY = 10;
onWheelImageZoom(event);
expect(zoomScale.value).toBe(1);
});
it('should correctly compute zoom origin', () => {
const { getZoomOrigin } = useImageZoom(imageRef);
// Test center point
const centerOrigin = getZoomOrigin(200, 200);
expect(centerOrigin.x).toBeCloseTo(50);
expect(centerOrigin.y).toBeCloseTo(50);
// Test top-left corner
const topLeftOrigin = getZoomOrigin(100, 100);
expect(topLeftOrigin.x).toBeCloseTo(0);
expect(topLeftOrigin.y).toBeCloseTo(0);
// Test bottom-right corner
const bottomRightOrigin = getZoomOrigin(300, 300);
expect(bottomRightOrigin.x).toBeCloseTo(100);
expect(bottomRightOrigin.y).toBeCloseTo(100);
});
});
@@ -0,0 +1,186 @@
import { ref, computed } from 'vue';
import {
debounce,
calculateCenterOffset,
applyRotationTransform,
normalizeToPercentage,
} from '@chatwoot/utils';
// Composable for images in gallery view
export const useImageZoom = imageRef => {
const MAX_ZOOM_LEVEL = 3;
const MIN_ZOOM_LEVEL = 1;
const ZOOM_INCREMENT = 0.2;
const MOUSE_MOVE_DEBOUNCE_MS = 100;
const MOUSE_LEAVE_DEBOUNCE_MS = 110;
const DEFAULT_IMG_TRANSFORM_ORIGIN = 'center center';
const zoomScale = ref(1);
const imgTransformOriginPoint = ref(DEFAULT_IMG_TRANSFORM_ORIGIN);
const activeImageRotation = ref(0);
const imageWrapperStyle = computed(() => ({
transform: `rotate(${activeImageRotation.value}deg)`,
}));
const imageStyle = computed(() => ({
transform: `scale(${zoomScale.value})`,
cursor: zoomScale.value < MAX_ZOOM_LEVEL ? 'zoom-in' : 'zoom-out',
transformOrigin: `${imgTransformOriginPoint.value}`,
}));
// Resets the transform origin to center
const resetTransformOrigin = () => {
if (imageRef.value) {
imgTransformOriginPoint.value = DEFAULT_IMG_TRANSFORM_ORIGIN;
}
};
// Rotates the current image
const onRotate = type => {
if (!imageRef.value) return;
resetTransformOrigin();
const rotation = type === 'clockwise' ? 90 : -90;
// ensure that the value of the rotation is within the range of -360 to 360 degrees
activeImageRotation.value = (activeImageRotation.value + rotation) % 360;
// Reset zoom when rotating
zoomScale.value = 1;
resetTransformOrigin();
};
/**
* Calculates the appropriate transform origin point based on mouse position and image rotation
* Used to create a natural zoom behavior where the image zooms toward/from the cursor position
*
* @param {number} x - The client X coordinate of the mouse pointer
* @param {number} y - The client Y coordinate of the mouse pointer
* @returns {{x: number, y: number}} Object containing the transform origin coordinates as percentages
*/
const getZoomOrigin = (x, y) => {
// Default to center
if (!imageRef.value) return { x: 50, y: 50 };
const rect = imageRef.value.getBoundingClientRect();
// Step 1: Calculate offset from center
const { relativeX, relativeY } = calculateCenterOffset(x, y, rect);
// Step 2: Apply rotation transformation
const { rotatedX, rotatedY } = applyRotationTransform(
relativeX,
relativeY,
activeImageRotation.value
);
// Step 3: Convert to percentage coordinates
return normalizeToPercentage(rotatedX, rotatedY, rect.width, rect.height);
};
// Handles zooming the image
const onZoom = (scale, x, y) => {
if (!imageRef.value) return;
// Calculate new scale within bounds
const newScale = Math.max(
MIN_ZOOM_LEVEL,
Math.min(MAX_ZOOM_LEVEL, zoomScale.value + scale)
);
// Skip if no change
if (newScale === zoomScale.value) return;
// Update transform origin based on mouse position and zoom scale is minimum
if (x != null && y != null && zoomScale.value === MIN_ZOOM_LEVEL) {
const { x: originX, y: originY } = getZoomOrigin(x, y);
imgTransformOriginPoint.value = `${originX}% ${originY}%`;
}
// Apply the new scale
zoomScale.value = newScale;
};
// Handles double-click zoom toggling
const onDoubleClickZoomImage = e => {
if (!imageRef.value) return;
e.preventDefault();
// Toggle between max zoom and min zoom
const newScale =
zoomScale.value >= MAX_ZOOM_LEVEL ? MIN_ZOOM_LEVEL : MAX_ZOOM_LEVEL;
// Update transform origin based on mouse position
const { x: originX, y: originY } = getZoomOrigin(e.clientX, e.clientY);
imgTransformOriginPoint.value = `${originX}% ${originY}%`;
// Apply the new scale
zoomScale.value = newScale;
};
// Handles mouse wheel zooming for images
const onWheelImageZoom = e => {
if (!imageRef.value) return;
e.preventDefault();
const scale = e.deltaY > 0 ? -ZOOM_INCREMENT : ZOOM_INCREMENT;
onZoom(scale, e.clientX, e.clientY);
};
/**
* Sets transform origin to mouse position during hover.
* Enables precise scroll/double-click zoom targeting by updating the
* transform origin to cursor position. Only active at minimum zoom level.
* Debounced (100ms) to improve performance during rapid mouse movement.
*/
const onMouseMove = debounce(
e => {
if (!imageRef.value) return;
if (zoomScale.value !== MIN_ZOOM_LEVEL) return;
const { x: originX, y: originY } = getZoomOrigin(e.clientX, e.clientY);
imgTransformOriginPoint.value = `${originX}% ${originY}%`;
},
MOUSE_MOVE_DEBOUNCE_MS,
false
);
/**
* Resets transform origin to center when mouse leaves image.
* Ensures button-based zooming works predictably after hover ends.
* Uses slightly longer debounce (110ms) to avoid conflicts with onMouseMove.
*/
const onMouseLeave = debounce(
() => {
if (!imageRef.value) return;
if (zoomScale.value !== MIN_ZOOM_LEVEL) return;
imgTransformOriginPoint.value = DEFAULT_IMG_TRANSFORM_ORIGIN;
},
MOUSE_LEAVE_DEBOUNCE_MS,
false
);
const resetZoomAndRotation = () => {
activeImageRotation.value = 0;
zoomScale.value = 1;
resetTransformOrigin();
};
return {
zoomScale,
imgTransformOriginPoint,
activeImageRotation,
imageWrapperStyle,
imageStyle,
getZoomOrigin,
resetTransformOrigin,
onRotate,
onZoom,
onDoubleClickZoomImage,
onWheelImageZoom,
onMouseMove,
onMouseLeave,
resetZoomAndRotation,
};
};
@@ -39,5 +39,9 @@
},
"LABEL": {
"TAG_BUTTON": "tag"
},
"FEATURE_SPOTLIGHT": {
"LEARN_MORE": "Learn more",
"WATCH_VIDEO": "Watch video"
}
}
@@ -308,6 +308,7 @@
},
"CAPTAIN": {
"NAME": "Captain",
"HEADER_KNOW_MORE": "Know more",
"COPILOT": {
"SEND_MESSAGE": "Send message...",
"LOADER": "Captain is thinking",
@@ -387,7 +388,11 @@
},
"EMPTY_STATE": {
"TITLE": "No assistants available",
"SUBTITLE": "Create an assistant to provide quick and accurate responses to your users. It can learn from your help articles and past conversations."
"SUBTITLE": "Create an assistant to provide quick and accurate responses to your users. It can learn from your help articles and past conversations.",
"FEATURE_SPOTLIGHT": {
"TITLE": "Captain Assistant",
"NOTE": "Captain Assistant engages directly with customers, learns from your help docs and past conversations, and delivers instant, accurate responses. It handles the initial queries, providing quick resolutions before transferring to an agent when needed."
}
}
},
"DOCUMENTS": {
@@ -429,7 +434,11 @@
},
"EMPTY_STATE": {
"TITLE": "No documents available",
"SUBTITLE": "Documents are used by your assistant to generate FAQs. You can import documents to provide context for your assistant."
"SUBTITLE": "Documents are used by your assistant to generate FAQs. You can import documents to provide context for your assistant.",
"FEATURE_SPOTLIGHT": {
"TITLE": "Captain Document",
"NOTE": "A document in Captain serves as a knowledge resource for the assistant. By connecting your help center or guides, Captain can analyze the content and provide accurate responses for customer inquiries."
}
}
},
"RESPONSES": {
@@ -507,7 +516,11 @@
},
"EMPTY_STATE": {
"TITLE": "No FAQs Found",
"SUBTITLE": "FAQs help your assistant provide quick and accurate answers to questions from your customers. They can be generated automatically from your content or can be added manually."
"SUBTITLE": "FAQs help your assistant provide quick and accurate answers to questions from your customers. They can be generated automatically from your content or can be added manually.",
"FEATURE_SPOTLIGHT": {
"TITLE": "Captain FAQ",
"NOTE": "Captain FAQs detects common customer questions—whether missing from your knowledge base or frequently asked—and generates relevant FAQs to improve support. You can review each suggestion and decide whether to approve or reject it."
}
}
},
"INBOXES": {
@@ -119,7 +119,8 @@
"EMPTY_LIST": "No results found"
},
"PAGINATION": {
"RESULTS": "Showing {start} to {end} of {total} results"
"RESULTS": "Showing {start} to {end} of {total} results",
"PER_PAGE_TEMPLATE": "{size} / page"
}
},
"AGENT_REPORTS": {
@@ -70,7 +70,7 @@ onUnmounted(() => {
<input
ref="searchInput"
type="search"
class="w-full m-0 bg-transparent border-transparent shadow-none text-n-slate-12 dark:text-n-slate-12 active:border-transparent active:shadow-none hover:border-transparent hover:shadow-none focus:border-transparent focus:shadow-none"
class="reset-base outline-none w-full m-0 bg-transparent border-transparent shadow-none text-n-slate-12 dark:text-n-slate-12 active:border-transparent active:shadow-none hover:border-transparent hover:shadow-none focus:border-transparent focus:shadow-none"
:placeholder="$t('SEARCH.INPUT_PLACEHOLDER')"
:value="searchQuery"
@focus="onFocus"
@@ -9,6 +9,7 @@ import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import CreateAssistantDialog from 'dashboard/components-next/captain/pageComponents/assistant/CreateAssistantDialog.vue';
import AssistantPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/AssistantPageEmptyState.vue';
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
import LimitBanner from 'dashboard/components-next/captain/pageComponents/response/LimitBanner.vue';
import { useRouter } from 'vue-router';
@@ -82,6 +83,16 @@ onMounted(() => store.dispatch('captainAssistants/get'));
:feature-flag="FEATURE_FLAGS.CAPTAIN"
@click="handleCreate"
>
<template #knowMore>
<FeatureSpotlightPopover
:button-label="$t('CAPTAIN.HEADER_KNOW_MORE')"
:title="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
:note="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
fallback-thumbnail="/assets/images/dashboard/captain/assistant-popover-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/assistant-popover-dark.svg"
learn-more-url="https://chwt.app/captain-assistant"
/>
</template>
<template #emptyState>
<AssistantPageEmptyState @click="handleCreate" />
</template>
@@ -79,7 +79,9 @@ onMounted(() =>
<template v-if="!isFetchingAssistant" #headerTitle>
<div class="flex flex-row items-center gap-4">
<BackButton compact />
<span class="flex items-center gap-1 text-lg">
<span
class="flex items-center gap-1 text-lg font-medium text-n-slate-12"
>
{{ assistant.name }}
<span class="i-lucide-chevron-right text-xl text-n-slate-10" />
{{ $t('CAPTAIN.INBOXES.HEADER') }}
@@ -11,6 +11,7 @@ import RelatedResponses from 'dashboard/components-next/captain/pageComponents/d
import CreateDocumentDialog from 'dashboard/components-next/captain/pageComponents/document/CreateDocumentDialog.vue';
import AssistantSelector from 'dashboard/components-next/captain/pageComponents/AssistantSelector.vue';
import DocumentPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/DocumentPageEmptyState.vue';
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
import LimitBanner from 'dashboard/components-next/captain/pageComponents/document/LimitBanner.vue';
const store = useStore();
@@ -115,6 +116,17 @@ onMounted(() => {
@update:current-page="onPageChange"
@click="handleCreateDocument"
>
<template #knowMore>
<FeatureSpotlightPopover
:button-label="$t('CAPTAIN.HEADER_KNOW_MORE')"
:title="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
:note="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
fallback-thumbnail="/assets/images/dashboard/captain/document-popover-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/document-popover-dark.svg"
learn-more-url="https://chwt.app/captain-document"
/>
</template>
<template #emptyState>
<DocumentPageEmptyState @click="handleCreateDocument" />
</template>
@@ -18,6 +18,7 @@ import AssistantSelector from 'dashboard/components-next/captain/pageComponents/
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
import CreateResponseDialog from 'dashboard/components-next/captain/pageComponents/response/CreateResponseDialog.vue';
import ResponsePageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/ResponsePageEmptyState.vue';
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
import LimitBanner from 'dashboard/components-next/captain/pageComponents/response/LimitBanner.vue';
const router = useRouter();
@@ -247,6 +248,17 @@ onMounted(() => {
@update:current-page="onPageChange"
@click="handleCreate"
>
<template #knowMore>
<FeatureSpotlightPopover
:button-label="$t('CAPTAIN.HEADER_KNOW_MORE')"
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
:note="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
fallback-thumbnail="/assets/images/dashboard/captain/faqs-popover-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/faqs-popover-dark.svg"
learn-more-url="https://chwt.app/captain-faq"
/>
</template>
<template #emptyState>
<ResponsePageEmptyState @click="handleCreate" />
</template>
@@ -7,8 +7,8 @@ import ContactInfoRow from './ContactInfoRow.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import SocialIcons from './SocialIcons.vue';
import EditContact from './EditContact.vue';
import NewConversation from './NewConversation.vue';
import ContactMergeModal from 'dashboard/modules/contact/ContactMergeModal.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -25,8 +25,8 @@ export default {
ContactInfoRow,
EditContact,
Thumbnail,
ComposeConversation,
SocialIcons,
NewConversation,
ContactMergeModal,
},
props: {
@@ -49,7 +49,6 @@ export default {
data() {
return {
showEditModal: false,
showConversationModal: false,
showMergeModal: false,
showDeleteModal: false,
};
@@ -92,17 +91,29 @@ export default {
return ` ${this.contact.name}?`;
},
},
watch: {
'contact.id': {
handler(id) {
this.$store.dispatch('contacts/fetchContactableInbox', id);
},
immediate: true,
},
},
methods: {
dynamicTime,
toggleEditModal() {
this.showEditModal = !this.showEditModal;
},
toggleConversationModal() {
this.showConversationModal = !this.showConversationModal;
emitter.emit(
BUS_EVENTS.NEW_CONVERSATION_MODAL,
this.showConversationModal
);
openComposeConversationModal(toggleFn) {
toggleFn();
// Flag to prevent triggering drag n drop,
// When compose modal is active
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true);
},
closeComposeConversationModal() {
// Flag to enable drag n drop,
// When compose modal is closed
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
},
toggleDeleteModal() {
this.showDeleteModal = !this.showDeleteModal;
@@ -113,7 +124,6 @@ export default {
},
closeDelete() {
this.showDeleteModal = false;
this.showConversationModal = false;
this.showEditModal = false;
},
findCountryFlag(countryCode, cityAndCountry) {
@@ -250,14 +260,22 @@ export default {
</div>
</div>
<div class="flex items-center w-full mt-0.5 gap-2">
<NextButton
v-tooltip.top-end="$t('CONTACT_PANEL.NEW_MESSAGE')"
icon="i-ph-chat-circle-dots"
slate
faded
sm
@click="toggleConversationModal"
/>
<ComposeConversation
:contact-id="String(contact.id)"
is-modal
@close="closeComposeConversationModal"
>
<template #trigger="{ toggle }">
<NextButton
v-tooltip.top-end="$t('CONTACT_PANEL.NEW_MESSAGE')"
icon="i-ph-chat-circle-dots"
slate
faded
sm
@click="openComposeConversationModal(toggle)"
/>
</template>
</ComposeConversation>
<NextButton
v-tooltip.top-end="$t('EDIT_CONTACT.BUTTON_LABEL')"
icon="i-ph-pencil-simple"
@@ -293,12 +311,6 @@ export default {
:contact="contact"
@cancel="toggleEditModal"
/>
<NewConversation
v-if="contact.id"
:show="showConversationModal"
:contact="contact"
@cancel="toggleConversationModal"
/>
<ContactMergeModal
v-if="showMergeModal"
:primary-contact="contact"
@@ -1,638 +0,0 @@
<script>
import { ref } from 'vue';
// constants & helpers
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
import { getInboxSource, INBOX_TYPES } from 'dashboard/helper/inbox';
// store
import { mapGetters } from 'vuex';
// composables
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAlert } from 'dashboard/composables';
import { required, requiredIf } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
// mixins
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
import inboxMixin from 'shared/mixins/inboxMixin';
// components
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue';
import CannedResponse from 'dashboard/components/widgets/conversation/CannedResponse.vue';
import InboxDropdownItem from 'dashboard/components/widgets/InboxDropdownItem.vue';
import MessageSignatureMissingAlert from 'dashboard/components/widgets/conversation/MessageSignatureMissingAlert.vue';
import ReplyEmailHead from 'dashboard/components/widgets/conversation/ReplyEmailHead.vue';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import FileUpload from 'vue-upload-component';
import WhatsappTemplates from './WhatsappTemplates.vue';
import {
appendSignature,
removeSignature,
} from 'dashboard/helper/editorHelper';
export default {
components: {
Thumbnail,
WootMessageEditor,
ReplyEmailHead,
CannedResponse,
WhatsappTemplates,
InboxDropdownItem,
FileUpload,
AttachmentPreview,
MessageSignatureMissingAlert,
},
mixins: [inboxMixin, fileUploadMixin],
props: {
contact: {
type: Object,
default: () => ({}),
},
onSubmit: {
type: Function,
default: () => {},
},
},
emits: ['cancel', 'success'],
setup() {
const { fetchSignatureFlagFromUISettings, setSignatureFlagForInbox } =
useUISettings();
const v$ = useVuelidate();
const uploadAttachment = ref(false);
return {
fetchSignatureFlagFromUISettings,
setSignatureFlagForInbox,
v$,
uploadAttachment,
};
},
data() {
return {
name: '',
subject: '',
message: '',
showCannedResponseMenu: false,
cannedResponseSearchKey: '',
bccEmails: '',
ccEmails: '',
targetInbox: {},
whatsappTemplateSelected: false,
attachedFiles: [],
};
},
validations() {
return {
subject: {
required: requiredIf(this.isAnEmailInbox),
},
message: {
required,
},
targetInbox: {
required,
},
};
},
computed: {
...mapGetters({
uiFlags: 'contacts/getUIFlags',
conversationsUiFlags: 'contactConversations/getUIFlags',
currentUser: 'getCurrentUser',
globalConfig: 'globalConfig/get',
messageSignature: 'getMessageSignature',
inboxesList: 'inboxes/getInboxes',
}),
sendWithSignature() {
return this.fetchSignatureFlagFromUISettings(this.channelType);
},
signatureToApply() {
return this.messageSignature;
},
newMessagePayload() {
const payload = {
inboxId: this.targetInbox.id,
sourceId: this.targetInbox.sourceId,
contactId: this.contact.id,
message: { content: this.message },
mailSubject: this.subject,
assigneeId: this.currentUser.id,
};
if (this.attachedFiles && this.attachedFiles.length) {
payload.files = [];
this.setAttachmentPayload(payload);
}
if (this.ccEmails) {
payload.message.cc_emails = this.ccEmails;
}
if (this.bccEmails) {
payload.message.bcc_emails = this.bccEmails;
}
return payload;
},
selectedInbox: {
get() {
const inboxList = this.contact.contact_inboxes || [];
const selectedContactInbox = inboxList.find(
inbox => inbox.inbox?.id && inbox.inbox?.id === this.targetInbox?.id
);
if (!selectedContactInbox) {
return { inbox: {} };
}
// Find the matching inbox from the inboxesList
const matchingInbox =
this.inboxesList.find(
item => item.id === selectedContactInbox.inbox?.id
) || {};
// The entire inbox payload is not available in this object, so we need to patch it from the store
return {
...selectedContactInbox,
inbox: {
...matchingInbox,
...selectedContactInbox.inbox,
sourceId: selectedContactInbox.source_id || matchingInbox.sourceId,
},
};
},
set(value) {
this.targetInbox = value.inbox;
},
},
showNoInboxAlert() {
if (!this.contact.contact_inboxes) {
return false;
}
return this.inboxes.length === 0 && !this.uiFlags.isFetchingInboxes;
},
isSignatureEnabledForInbox() {
return this.isAnEmailInbox && this.sendWithSignature;
},
signatureToggleTooltip() {
return this.sendWithSignature
? this.$t('CONVERSATION.FOOTER.DISABLE_SIGN_TOOLTIP')
: this.$t('CONVERSATION.FOOTER.ENABLE_SIGN_TOOLTIP');
},
inboxes() {
const inboxList = this.contact.contact_inboxes || [];
if (!inboxList.length) return [];
return inboxList.map(inbox => {
const matchingInbox =
this.inboxesList.find(item => item.id === inbox.inbox?.id) || {};
// Create merged object with a clear property order
return {
...matchingInbox,
...inbox.inbox,
sourceId: inbox.source_id,
};
});
},
isAnEmailInbox() {
return (
this.selectedInbox &&
this.selectedInbox.inbox.channel_type === INBOX_TYPES.EMAIL
);
},
isAnWebWidgetInbox() {
return (
this.selectedInbox &&
this.selectedInbox.inbox.channel_type === INBOX_TYPES.WEB
);
},
isEmailOrWebWidgetInbox() {
return this.isAnEmailInbox || this.isAnWebWidgetInbox;
},
hasWhatsappTemplates() {
return !!this.selectedInbox.inbox?.message_templates;
},
hasAttachments() {
return this.attachedFiles.length;
},
inbox() {
return this.targetInbox;
},
allowedFileTypes() {
return ALLOWED_FILE_TYPES;
},
},
watch: {
message(value) {
this.hasSlashCommand = value[0] === '/' && !this.isEmailOrWebWidgetInbox;
const hasNextWord = value.includes(' ');
const isShortCodeActive = this.hasSlashCommand && !hasNextWord;
if (isShortCodeActive) {
this.cannedResponseSearchKey = value.substring(1);
this.showCannedResponseMenu = true;
} else {
this.cannedResponseSearchKey = '';
this.showCannedResponseMenu = false;
}
},
targetInbox() {
this.setSignature();
},
},
mounted() {
this.setSignature();
},
methods: {
setSignature() {
if (this.messageSignature) {
if (this.isSignatureEnabledForInbox) {
this.message = appendSignature(this.message, this.signatureToApply);
} else {
this.message = removeSignature(this.message, this.signatureToApply);
}
}
},
setAttachmentPayload(payload) {
this.attachedFiles.forEach(attachment => {
if (this.globalConfig.directUploadsEnabled) {
payload.files.push(attachment.blobSignedId);
} else {
payload.files.push(attachment.resource.file);
}
});
},
attachFile({ blob, file }) {
const reader = new FileReader();
reader.readAsDataURL(file.file);
reader.onloadend = () => {
this.attachedFiles.push({
currentChatId: this.contact.id,
resource: blob || file,
isPrivate: this.isPrivate,
thumb: reader.result,
blobSignedId: blob ? blob.signed_id : undefined,
});
};
},
removeAttachment(attachments) {
this.attachedFiles = attachments;
},
onCancel() {
this.$emit('cancel');
},
onSuccess() {
this.$emit('success');
},
replaceTextWithCannedResponse(message) {
this.message = message;
},
toggleCannedMenu(value) {
this.showCannedMenu = value;
},
prepareWhatsAppMessagePayload({ message: content, templateParams }) {
const payload = {
inboxId: this.targetInbox.id,
sourceId: this.targetInbox.sourceId,
contactId: this.contact.id,
message: { content, template_params: templateParams },
assigneeId: this.currentUser.id,
};
return payload;
},
onFormSubmit() {
const isFromWhatsApp = false;
this.v$.$touch();
if (this.v$.$invalid) {
return;
}
this.createConversation({
payload: this.newMessagePayload,
isFromWhatsApp,
});
},
async createConversation({ payload, isFromWhatsApp }) {
try {
const data = await this.onSubmit(payload, isFromWhatsApp);
const action = {
type: 'link',
to: `/app/accounts/${data.account_id}/conversations/${data.id}`,
message: this.$t('NEW_CONVERSATION.FORM.GO_TO_CONVERSATION'),
};
this.onSuccess();
useAlert(this.$t('NEW_CONVERSATION.FORM.SUCCESS_MESSAGE'), action);
} catch (error) {
if (error instanceof ExceptionWithMessage) {
useAlert(error.data);
} else {
useAlert(this.$t('NEW_CONVERSATION.FORM.ERROR_MESSAGE'));
}
}
},
toggleWaTemplate(val) {
this.whatsappTemplateSelected = val;
},
async onSendWhatsAppReply(messagePayload) {
const isFromWhatsApp = true;
const payload = this.prepareWhatsAppMessagePayload(messagePayload);
await this.createConversation({ payload, isFromWhatsApp });
},
inboxReadableIdentifier(inbox) {
return `${inbox.name} (${inbox.channel_type})`;
},
computedInboxSource(inbox) {
if (!inbox.channel_type) return '';
const classByType = getInboxSource(
inbox.channel_type,
inbox.phone_number,
inbox
);
return classByType;
},
toggleMessageSignature() {
this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature);
this.setSignature();
},
},
};
</script>
<!-- eslint-disable vue/prefer-true-attribute-shorthand -->
<template>
<form class="w-full conversation--form" @submit.prevent="onFormSubmit">
<div
v-if="showNoInboxAlert"
class="relative mx-0 mt-0 mb-2.5 p-2 rounded-none text-sm border border-solid border-yellow-500 dark:border-yellow-700 bg-yellow-200/60 dark:bg-yellow-200/20 text-slate-700 dark:text-yellow-400"
>
<p class="mb-0">
{{ $t('NEW_CONVERSATION.NO_INBOX') }}
</p>
</div>
<div v-else>
<div class="flex flex-row gap-2">
<div class="w-[50%]">
<label>
{{ $t('NEW_CONVERSATION.FORM.INBOX.LABEL') }}
</label>
<div
class="multiselect-wrap--small"
:class="{ 'has-multi-select-error': v$.targetInbox.$error }"
>
<multiselect
v-model="targetInbox"
track-by="id"
label="name"
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
selected-label=""
select-label=""
class="reset-base"
deselect-label=""
:max-height="160"
close-on-select
:options="[...inboxes]"
>
<template #singleLabel="{ option }">
<InboxDropdownItem
v-if="option.name"
:name="option.name"
:inbox-identifier="computedInboxSource(option)"
:channel-type="option.channel_type"
/>
<span v-else>
{{ $t('NEW_CONVERSATION.FORM.INBOX.PLACEHOLDER') }}
</span>
</template>
<template #option="{ option }">
<InboxDropdownItem
:name="option.name"
:inbox-identifier="computedInboxSource(option)"
:channel-type="option.channel_type"
/>
</template>
</multiselect>
</div>
<label :class="{ error: v$.targetInbox.$error }">
<span v-if="v$.targetInbox.$error" class="message">
{{ $t('NEW_CONVERSATION.FORM.INBOX.ERROR') }}
</span>
</label>
</div>
<div class="w-[50%]">
<label>
{{ $t('NEW_CONVERSATION.FORM.TO.LABEL') }}
<div
class="flex items-center h-[2.4735rem] rounded-sm py-1 px-2 bg-slate-25 dark:bg-slate-900 border border-solid border-slate-75 dark:border-slate-600"
>
<Thumbnail
:src="contact.thumbnail"
size="24px"
:username="contact.name"
:status="contact.availability_status"
/>
<h4
class="m-0 ml-2 mr-2 text-sm text-slate-700 dark:text-slate-100"
>
{{ contact.name }}
</h4>
</div>
</label>
</div>
</div>
<div v-if="isAnEmailInbox" class="w-full">
<div class="w-full">
<label :class="{ error: v$.subject.$error }">
{{ $t('NEW_CONVERSATION.FORM.SUBJECT.LABEL') }}
<input
v-model="subject"
type="text"
:placeholder="$t('NEW_CONVERSATION.FORM.SUBJECT.PLACEHOLDER')"
@input="v$.subject.$touch"
/>
<span v-if="v$.subject.$error" class="message">
{{ $t('NEW_CONVERSATION.FORM.SUBJECT.ERROR') }}
</span>
</label>
</div>
</div>
<div class="w-full">
<div class="w-full">
<div class="relative">
<CannedResponse
v-if="showCannedResponseMenu && hasSlashCommand"
:search-key="cannedResponseSearchKey"
@replace="replaceTextWithCannedResponse"
/>
</div>
<div v-if="isEmailOrWebWidgetInbox">
<label>
{{ $t('NEW_CONVERSATION.FORM.MESSAGE.LABEL') }}
</label>
<ReplyEmailHead
v-if="isAnEmailInbox"
v-model:cc-emails="ccEmails"
v-model:bcc-emails="bccEmails"
/>
<div class="editor-wrap">
<WootMessageEditor
v-model="message"
class="message-editor"
:class="{ editor_warning: v$.message.$error }"
enable-variables
:signature="signatureToApply"
allow-signature
:placeholder="$t('NEW_CONVERSATION.FORM.MESSAGE.PLACEHOLDER')"
@toggle-canned-menu="toggleCannedMenu"
@blur="v$.message.$touch"
>
<template #footer>
<MessageSignatureMissingAlert
v-if="isSignatureEnabledForInbox && !messageSignature"
class="!mx-0 mb-1"
/>
<div v-if="isAnEmailInbox" class="mt-px mb-3">
<woot-button
v-tooltip.top-end="signatureToggleTooltip"
icon="signature"
color-scheme="secondary"
variant="smooth"
size="small"
:title="signatureToggleTooltip"
@click.prevent="toggleMessageSignature"
/>
</div>
</template>
</WootMessageEditor>
<span v-if="v$.message.$error" class="editor-warning__message">
{{ $t('NEW_CONVERSATION.FORM.MESSAGE.ERROR') }}
</span>
</div>
</div>
<WhatsappTemplates
v-else-if="hasWhatsappTemplates"
:inbox-id="selectedInbox.inbox.id"
@on-select-template="toggleWaTemplate"
@on-send="onSendWhatsAppReply"
/>
<label v-else :class="{ error: v$.message.$error }">
{{ $t('NEW_CONVERSATION.FORM.MESSAGE.LABEL') }}
<textarea
v-model="message"
class="min-h-[5rem]"
type="text"
:placeholder="$t('NEW_CONVERSATION.FORM.MESSAGE.PLACEHOLDER')"
@input="v$.message.$touch"
/>
<span v-if="v$.message.$error" class="message">
{{ $t('NEW_CONVERSATION.FORM.MESSAGE.ERROR') }}
</span>
</label>
<div v-if="isEmailOrWebWidgetInbox" class="flex flex-col">
<FileUpload
ref="uploadAttachment"
input-id="newConversationAttachment"
:size="4096 * 4096"
:accept="allowedFileTypes"
multiple
:drop="true"
:drop-directory="false"
:data="{
direct_upload_url: '/rails/active_storage/direct_uploads',
direct_upload: true,
}"
@input-file="onFileUpload"
>
<woot-button
class-names="button--upload"
icon="attach"
emoji="📎"
color-scheme="secondary"
variant="smooth"
size="small"
>
{{ $t('NEW_CONVERSATION.FORM.ATTACHMENTS.SELECT') }}
</woot-button>
<span
class="text-xs font-medium text-slate-500 ltr:ml-1 rtl:mr-1 dark:text-slate-400"
>
{{ $t('NEW_CONVERSATION.FORM.ATTACHMENTS.HELP_TEXT') }}
</span>
</FileUpload>
<div
v-if="hasAttachments"
class="max-h-20 overflow-y-auto mb-4 mt-1.5"
>
<AttachmentPreview
class="[&>.preview-item]:dark:bg-slate-700 flex-row flex-wrap gap-x-3 gap-y-1"
:attachments="attachedFiles"
@remove-attachment="removeAttachment"
/>
</div>
</div>
</div>
</div>
</div>
<div
v-if="!hasWhatsappTemplates"
class="flex flex-row justify-end w-full gap-2 px-0 py-2"
>
<button class="button clear" @click.prevent="onCancel">
{{ $t('NEW_CONVERSATION.FORM.CANCEL') }}
</button>
<woot-button type="submit" :is-loading="conversationsUiFlags.isCreating">
{{ $t('NEW_CONVERSATION.FORM.SUBMIT') }}
</woot-button>
</div>
<transition v-if="isEmailOrWebWidgetInbox" name="modal-fade">
<div
v-show="uploadAttachment && uploadAttachment.dropActive"
class="absolute top-0 bottom-0 left-0 right-0 z-30 flex flex-col items-center justify-center w-full h-full gap-2 bg-white/80 dark:bg-slate-700/80"
>
<fluent-icon icon="cloud-backup" size="40" />
<h4 class="text-2xl break-words text-slate-600 dark:text-slate-200">
{{ $t('CONVERSATION.REPLYBOX.DRAG_DROP') }}
</h4>
</div>
</transition>
</form>
</template>
<style scoped lang="scss">
.conversation--form {
@apply pt-4 px-8 pb-8;
}
.message-editor {
@apply px-3;
::v-deep {
.ProseMirror-menubar {
@apply rounded-tl-[4px];
}
}
}
.file-uploads {
@apply text-start;
}
.multiselect-wrap--small.has-multi-select-error {
::v-deep {
.multiselect__tags {
@apply border-red-500;
}
}
}
::v-deep {
.mention--box {
@apply left-0 m-auto right-0 top-auto h-fit;
}
}
</style>
@@ -1,71 +0,0 @@
<script>
import ConversationForm from './ConversationForm.vue';
export default {
components: {
ConversationForm,
},
props: {
show: {
type: Boolean,
default: false,
},
contact: {
type: Object,
default: () => ({}),
},
},
emits: ['cancel', 'update:show'],
computed: {
localShow: {
get() {
return this.show;
},
set(value) {
this.$emit('update:show', value);
},
},
},
watch: {
'contact.id'(id) {
this.$store.dispatch('contacts/fetchContactableInbox', id);
},
},
mounted() {
const { id } = this.contact;
this.$store.dispatch('contacts/fetchContactableInbox', id);
},
methods: {
onCancel() {
this.$emit('cancel');
},
onSuccess() {
this.$emit('cancel');
},
async onSubmit(params, isFromWhatsApp) {
const data = await this.$store.dispatch('contactConversations/create', {
params,
isFromWhatsApp,
});
return data;
},
},
};
</script>
<template>
<woot-modal v-model:show="localShow" :on-close="onCancel">
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header
:header-title="$t('NEW_CONVERSATION.TITLE')"
:header-content="$t('NEW_CONVERSATION.DESC')"
/>
<ConversationForm
:contact="contact"
:on-submit="onSubmit"
@success="onSuccess"
@cancel="onCancel"
/>
</div>
</woot-modal>
</template>
@@ -1,54 +0,0 @@
<script>
import TemplatesPicker from 'dashboard/components/widgets/conversation/WhatsappTemplates/TemplatesPicker.vue';
import TemplateParser from 'dashboard/components/widgets/conversation/WhatsappTemplates/TemplateParser.vue';
export default {
components: {
TemplatesPicker,
TemplateParser,
},
props: {
inboxId: {
type: Number,
default: undefined,
},
},
emits: ['pickTemplate', 'onSend', 'cancel'],
data() {
return {
selectedWaTemplate: null,
};
},
methods: {
pickTemplate(template) {
this.$emit('pickTemplate', true);
this.selectedWaTemplate = template;
},
onResetTemplate() {
this.$emit('pickTemplate', false);
this.selectedWaTemplate = null;
},
onSendMessage(message) {
this.$emit('onSend', message);
},
onClose() {
this.$emit('cancel');
},
},
};
</script>
<template>
<div class="flex flex-wrap mx-0">
<TemplatesPicker
v-if="!selectedWaTemplate"
:inbox-id="inboxId"
@on-select="pickTemplate"
/>
<TemplateParser
v-else
:template="selectedWaTemplate"
@reset-template="onResetTemplate"
@send-message="onSendMessage"
/>
</div>
</template>
@@ -271,7 +271,7 @@ const evenClass = [
ghost-class="ghost"
handle=".drag-handle"
item-key="key"
class="last:rounded-b-lg overflow-hidden"
class="last:rounded-b-lg"
:class="evenClass"
@start="dragging = true"
@end="onDragEnd"
@@ -8,7 +8,7 @@ export default {
</script>
<template>
<div class="flex flex-col w-full items-start">
<div class="flex flex-col w-full items-start mb-4">
<h2
class="text-xl font-medium mb-1 text-slate-800 dark:text-slate-100 break-words"
>
@@ -12,6 +12,7 @@ const props = defineProps({
showBackButton: { type: Boolean, default: false },
backUrl: { type: [String, Object], default: '' },
showSidemenuIcon: { type: Boolean, default: true },
fullWidth: { type: Boolean, default: false },
});
const { t } = useI18n();
@@ -31,7 +32,10 @@ const showSettingsHeader = computed(
<template>
<div class="flex flex-1 flex-col m-0 bg-n-background overflow-auto">
<div class="max-w-6xl mx-auto w-full flex flex-col flex-1">
<div
class="mx-auto w-full flex flex-col flex-1"
:class="{ 'max-w-6xl': !fullWidth }"
>
<SettingsHeader
v-if="showSettingsHeader"
button-route="new"
@@ -43,6 +47,7 @@ const showSettingsHeader = computed(
:show-new-button="showNewButton"
:show-sidemenu-icon="showSidemenuIcon"
class="sticky top-0 z-20"
:class="{ 'max-w-6xl w-full mx-auto': fullWidth }"
/>
<router-view v-slot="{ Component }" class="px-5 flex-1 overflow-hidden">
@@ -100,17 +100,17 @@ const tableHeaders = computed(() => {
<th
v-for="thHeader in tableHeaders"
:key="thHeader"
class="py-4 pr-4 text-left font-semibold text-n-slate-11"
class="py-4 ltr:pr-4 rtl:pl-4 text-left font-semibold text-n-slate-11"
>
{{ thHeader }}
</th>
</thead>
<tbody class="divide-y divide-n-weak text-n-slate-11">
<tr v-for="auditLogItem in records" :key="auditLogItem.id">
<td class="py-4 pr-4 break-all whitespace-nowrap">
<td class="py-4 ltr:pr-4 rtl:pl-4 break-all whitespace-nowrap">
{{ generateLogText(auditLogItem) }}
</td>
<td class="py-4 pr-4 break-all whitespace-nowrap">
<td class="py-4 ltr:pr-4 rtl:pl-4 break-all whitespace-nowrap">
{{
messageTimestamp(
auditLogItem.created_at,
@@ -2,6 +2,7 @@
import { mapGetters } from 'vuex';
import FilterInputBox from 'dashboard/components/widgets/FilterInput/Index.vue';
import AutomationActionInput from 'dashboard/components/widgets/AutomationActionInput.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { useAutomation } from 'dashboard/composables/useAutomation';
import { validateAutomation } from 'dashboard/helper/validations';
import {
@@ -39,6 +40,7 @@ export default {
components: {
FilterInputBox,
AutomationActionInput,
NextButton,
},
props: {
onClose: {
@@ -184,7 +186,7 @@ export default {
</label>
<p
v-if="hasAutomationMutated"
class="text-xs text-right text-green-500 dark:text-green-500"
class="text-xs text-right text-green-500 pt-1 dark:text-green-500"
>
{{ $t('AUTOMATION.FORM.RESET_MESSAGE') }}
</p>
@@ -243,15 +245,15 @@ export default {
@remove-filter="removeFilter(i)"
/>
<div class="mt-4">
<woot-button
icon="add"
color-scheme="success"
variant="smooth"
size="small"
<NextButton
icon="i-lucide-plus"
blue
faded
sm
@click="appendNewCondition"
>
{{ $t('AUTOMATION.ADD.CONDITION_BUTTON_LABEL') }}
</woot-button>
</NextButton>
</div>
</div>
</section>
@@ -287,27 +289,27 @@ export default {
@remove-action="removeAction(i)"
/>
<div class="mt-4">
<woot-button
icon="add"
color-scheme="success"
variant="smooth"
size="small"
<NextButton
icon="i-lucide-plus"
blue
faded
sm
@click="appendNewAction"
>
{{ $t('AUTOMATION.ADD.ACTION_BUTTON_LABEL') }}
</woot-button>
</NextButton>
</div>
</div>
</section>
<!-- // Actions End -->
<div class="w-full">
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<woot-button class="button clear" @click.prevent="onClose">
<NextButton ghost blue @click.prevent="onClose">
{{ $t('AUTOMATION.ADD.CANCEL_BUTTON_TEXT') }}
</woot-button>
<woot-button @click="emitSaveAutomation">
</NextButton>
<NextButton solid blue @click="emitSaveAutomation">
{{ $t('AUTOMATION.ADD.SUBMIT') }}
</woot-button>
</NextButton>
</div>
</div>
</div>
@@ -201,7 +201,7 @@ const tableHeaders = computed(() => {
<th
v-for="thHeader in tableHeaders"
:key="thHeader"
class="py-4 pr-4 text-left font-semibold text-n-slate-11"
class="py-4 ltr:pr-4 rtl:pl-4 rtl:text-right ltr:text-left font-semibold text-n-slate-11"
>
{{ thHeader }}
</th>
@@ -153,7 +153,7 @@ const tableHeaders = computed(() => {
<th
v-for="thHeader in tableHeaders"
:key="thHeader"
class="py-4 pr-4 text-left font-semibold text-n-slate-11 last:text-right"
class="py-4 ltr:pr-4 rtl:pl-4 text-left font-semibold text-n-slate-11 last:text-right"
>
<span v-if="thHeader !== tableHeaders[0]">
{{ thHeader }}
@@ -179,12 +179,12 @@ const tableHeaders = computed(() => {
:key="cannedItem.short_code"
>
<td
class="py-4 pr-4 truncate max-w-xs font-medium"
class="py-4 ltr:pr-4 rtl:pl-4 truncate max-w-xs font-medium"
:title="cannedItem.short_code"
>
{{ cannedItem.short_code }}
</td>
<td class="py-4 pr-4 md:break-all whitespace-normal">
<td class="py-4 ltr:pr-4 rtl:pl-4 md:break-all whitespace-normal">
{{ cannedItem.content }}
</td>
<td class="py-4 flex justify-end gap-1">
@@ -13,7 +13,7 @@ defineProps({
<template>
<div
class="flex relative flex-col sm:flex-row p-4 gap-4 sm:p-6 justify-between group outline outline-n-container outline-1 bg-n-alpha-3 rounded-2xl shadow w-full"
class="flex relative flex-col sm:flex-row p-4 gap-4 sm:p-6 justify-between group outline outline-n-container outline-1 bg-n-solid-2 rounded-2xl shadow w-full"
>
<slot name="leftSection">
<div class="flex flex-col min-w-0 items-start gap-3 max-w-[480px] w-full">
@@ -156,7 +156,7 @@ const confirmDeletion = () => {
<th
v-for="thHeader in tableHeaders"
:key="thHeader"
class="py-4 pr-4 font-semibold text-left text-slate-700 dark:text-slate-300"
class="py-4 ltr:pr-4 rtl:pl-4 font-semibold text-left text-slate-700 dark:text-slate-300"
>
<span class="mb-0">
{{ thHeader }}
@@ -160,13 +160,13 @@ const isSubmitDisabled = computed(
/>
<form class="flex flex-col w-full" @submit.prevent="handleCustomRole">
<div class="w-full">
<label :class="{ 'text-red-500': v$.name.$error }">
<label>
{{ $t('CUSTOM_ROLE.FORM.NAME.LABEL') }}
<input
ref="nameInput"
v-model.trim="name"
type="text"
:class="{ '!border-red-500': v$.name.$error }"
:class="{ error: v$.name.$error }"
:placeholder="$t('CUSTOM_ROLE.FORM.NAME.PLACEHOLDER')"
@blur="v$.name.$touch"
/>
@@ -174,7 +174,7 @@ const isSubmitDisabled = computed(
</div>
<div class="w-full">
<label :class="{ 'text-red-500': v$.description.$error }">
<label>
{{ $t('CUSTOM_ROLE.FORM.DESCRIPTION.LABEL') }}
<textarea
@@ -79,7 +79,7 @@ const tableHeaders = computed(() => {
<th
v-for="thHeader in tableHeaders"
:key="thHeader"
class="py-4 pr-4 font-semibold text-left text-slate-700 dark:text-slate-300"
class="py-4 ltr:pr-4 rtl:pl-4 font-semibold text-left text-slate-700 dark:text-slate-300"
>
<span class="mb-0">
{{ thHeader }}
@@ -28,15 +28,19 @@ const getFormattedPermissions = role => {
<tbody class="divide-y divide-n-weak text-n-slate-11">
<tr v-for="(customRole, index) in roles" :key="index">
<td
class="max-w-xs py-4 pr-4 font-medium truncate align-baseline"
class="max-w-xs py-4 ltr:pr-4 rtl:pl-4 font-medium truncate align-baseline"
:title="customRole.name"
>
{{ customRole.name }}
</td>
<td class="py-4 pr-4 whitespace-normal align-baseline md:break-words">
<td
class="py-4 ltr:pr-4 rtl:pl-4 whitespace-normal align-baseline md:break-words"
>
{{ customRole.description }}
</td>
<td class="py-4 pr-4 whitespace-normal align-baseline md:break-words">
<td
class="py-4 ltr:pr-4 rtl:pl-4 whitespace-normal align-baseline md:break-words"
>
{{ getFormattedPermissions(customRole) }}
</td>
<td class="flex justify-end gap-1 py-4">
@@ -4,6 +4,7 @@ import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import InboxMembersAPI from '../../../../api/inboxMembers';
import NextButton from 'dashboard/components-next/button/Button.vue';
import router from '../../../index';
import PageHeader from '../SettingsSubPageHeader.vue';
import { useVuelidate } from '@vuelidate/core';
@@ -11,6 +12,7 @@ import { useVuelidate } from '@vuelidate/core';
export default {
components: {
PageHeader,
NextButton,
},
validations: {
selectedAgents: {
@@ -62,16 +64,16 @@ export default {
<template>
<div
class="border border-slate-25 dark:border-slate-800/60 bg-white dark:bg-slate-900 h-full p-6 w-full max-w-full md:w-3/4 md:max-w-[75%] flex-shrink-0 flex-grow-0"
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<form class="flex flex-wrap mx-0" @submit.prevent="addAgents()">
<form class="flex flex-wrap flex-col mx-0" @submit.prevent="addAgents()">
<div class="w-full">
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.AGENTS.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.AGENTS.DESC')"
/>
</div>
<div class="w-3/5">
<div>
<div class="w-full">
<label :class="{ error: v$.selectedAgents.$error }">
{{ $t('INBOX_MGMT.ADD.AGENTS.TITLE') }}
@@ -96,9 +98,12 @@ export default {
</label>
</div>
<div class="w-full">
<woot-submit-button
:button-text="$t('INBOX_MGMT.AGENTS.BUTTON_TEXT')"
:loading="isCreating"
<NextButton
type="submit"
:is-loading="isCreating"
solid
blue
:label="$t('INBOX_MGMT.AGENTS.BUTTON_TEXT')"
/>
</div>
</div>
@@ -62,7 +62,7 @@ export default {
<template>
<div
class="border border-slate-25 dark:border-slate-800/60 bg-white dark:bg-slate-900 h-full p-6 w-full max-w-full md:w-3/4 md:max-w-[75%] flex-shrink-0 flex-grow-0"
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<PageHeader
class="max-w-4xl"
@@ -1,9 +1,11 @@
<script>
import EmptyState from '../../../../components/widgets/EmptyState.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
EmptyState,
NextButton,
},
computed: {
currentInbox() {
@@ -70,7 +72,7 @@ export default {
<template>
<div
class="border border-slate-25 dark:border-slate-800/60 bg-white dark:bg-slate-900 h-full p-6 w-full max-w-full md:w-3/4 md:max-w-[75%] flex-shrink-0 flex-grow-0"
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<EmptyState
:title="$t('INBOX_MGMT.FINISH.TITLE')"
@@ -130,22 +132,28 @@ export default {
</div>
<div class="flex justify-center gap-2 mt-4">
<router-link
class="rounded button hollow primary"
:to="{
name: 'settings_inbox_show',
params: { inboxId: $route.params.inbox_id },
}"
>
{{ $t('INBOX_MGMT.FINISH.MORE_SETTINGS') }}
<NextButton
outline
slate
:label="$t('INBOX_MGMT.FINISH.MORE_SETTINGS')"
/>
</router-link>
<router-link
class="rounded button success"
:to="{
name: 'inbox_dashboard',
params: { inboxId: $route.params.inbox_id },
}"
>
{{ $t('INBOX_MGMT.FINISH.BUTTON_TEXT') }}
<NextButton
solid
teal
:label="$t('INBOX_MGMT.FINISH.BUTTON_TEXT')"
/>
</router-link>
</div>
</div>
@@ -4,10 +4,12 @@ import { useAlert } from 'dashboard/composables';
import SettingsSection from 'dashboard/components/SettingsSection.vue';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
SettingsSection,
NextButton,
},
props: {
inbox: {
@@ -155,9 +157,10 @@ export default {
{{ $t('INBOX_MGMT.IMAP.ENABLE_SSL') }}
</label>
</div>
<woot-submit-button
:button-text="$t('INBOX_MGMT.IMAP.UPDATE')"
:loading="uiFlags.isUpdatingIMAP"
<NextButton
type="submit"
:label="$t('INBOX_MGMT.IMAP.UPDATE')"
:is-loading="uiFlags.isUpdatingIMAP"
:disabled="(v$.$invalid && isIMAPEnabled) || uiFlags.isUpdatingIMAP"
/>
</form>
@@ -40,12 +40,14 @@ export default {
</script>
<template>
<div class="flex flex-row overflow-auto h-full">
<div class="grid grid-cols-1 md:grid-cols-8 overflow-auto h-full">
<woot-wizard
class="hidden md:block w-1/4"
class="hidden md:block col-span-2"
:global-config="globalConfig"
:items="items"
/>
<router-view />
<div class="col-span-6">
<router-view />
</div>
</div>
</template>
@@ -8,6 +8,7 @@ import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import ChannelName from './components/ChannelName.vue';
import ChannelIcon from 'next/icon/ChannelIcon.vue';
const getters = useStoreGetters();
const store = useStore();
@@ -87,43 +88,25 @@ const openDelete = inbox => {
</BaseSettingsHeader>
</template>
<template #body>
<table
class="min-w-full overflow-x-auto divide-y divide-slate-75 dark:divide-slate-700"
>
<table class="min-w-full overflow-x-auto">
<tbody
class="divide-y divide-slate-25 dark:divide-slate-800 flex-1 text-slate-700 dark:text-slate-100"
class="divide-y divide-n-weak flex-1 text-slate-700 dark:text-slate-100"
>
<tr v-for="inbox in inboxesList" :key="inbox.id">
<td class="py-4 ltr:pr-4 rtl:pl-4">
<div class="flex items-center flex-row gap-4">
<Thumbnail
v-if="inbox.avatar_url"
class="bg-black-50 dark:bg-black-800 rounded-full p-2 ring ring-opacity-20 dark:ring-opacity-80 ring-black-100 dark:ring-black-900 border border-slate-100 dark:border-slate-700/50 shadow-sm"
class="bg-n-alpha-3 rounded-full p-2 ring ring-n-solid-1 border border-n-strong shadow-sm"
:src="inbox.avatar_url"
:username="inbox.name"
size="48px"
/>
<div
v-else
class="w-[48px] h-[48px] flex justify-center items-center bg-black-50 dark:bg-black-800 rounded-full p-2 ring ring-opacity-20 dark:ring-opacity-80 ring-black-100 dark:ring-black-900 border border-slate-100 dark:border-slate-700/50 shadow-sm"
class="w-[48px] h-[48px] flex justify-center items-center bg-n-alpha-3 rounded-full p-2 ring ring-n-solid-1 border border-n-strong shadow-sm"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
class="opacity-80 p-1 flex-shrink-0"
>
<path
fill="currentColor"
d="M1 12c0-5.185 0-7.778 1.61-9.39C4.223 1 6.816 1 12 1s7.778 0 9.39 1.61C23 4.223 23 6.816 23 12s0 7.778-1.61 9.39C19.777 23 17.184 23 12 23s-7.778 0-9.39-1.61C1 19.777 1 17.184 1 12"
opacity=".35"
/>
<path
fill="currentColor"
d="M2.61 21.389c1.612 1.61 4.205 1.61 9.39 1.61s7.778 0 9.39-1.61c1.492-1.493 1.601-3.829 1.61-8.29h-3.476c-.996 0-1.494 0-1.931.202c-.438.201-.762.58-1.41 1.335l-.666.777c-.648.756-.972 1.134-1.41 1.335s-.935.202-1.93.202h-.353c-.996 0-1.494 0-1.931-.202c-.438-.2-.762-.579-1.41-1.335l-.666-.777c-.648-.756-.972-1.134-1.41-1.335s-.935-.201-1.93-.201H1c.008 4.46.118 6.796 1.61 8.289"
/>
</svg>
<ChannelIcon class="size-5" :inbox="inbox" />
</div>
<div>
<span class="block font-medium capitalize">
@@ -4,10 +4,13 @@ import { useAlert } from 'dashboard/composables';
import PreChatFields from './PreChatFields.vue';
import { getPreChatFields, standardFieldKeys } from 'dashboard/helper/preChat';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
PreChatFields,
WootMessageEditor,
NextButton,
},
props: {
inbox: {
@@ -110,7 +113,7 @@ export default {
</select>
</label>
<div v-if="preChatFormEnabled">
<div class="w-[70%]">
<div>
<label>
{{ $t('INBOX_MGMT.PRE_CHAT_FORM.PRE_CHAT_MESSAGE.LABEL') }}
</label>
@@ -122,7 +125,7 @@ export default {
"
/>
</div>
<div class="w-[70%] mt-4">
<div class="mt-4">
<label>{{ $t('INBOX_MGMT.PRE_CHAT_FORM.SET_FIELDS') }}</label>
<table class="table w-full table-striped woot-table">
<thead class="thead-dark">
@@ -161,11 +164,10 @@ export default {
</div>
</div>
<div class="w-auto my-4">
<woot-submit-button
:button-text="
$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE_PRE_CHAT_FORM_SETTINGS')
"
:loading="uiFlags.isUpdating"
<NextButton
type="submit"
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE_PRE_CHAT_FORM_SETTINGS')"
:is-loading="uiFlags.isUpdating"
/>
</div>
</form>
@@ -18,6 +18,7 @@ import WidgetBuilder from './WidgetBuilder.vue';
import BotConfiguration from './components/BotConfiguration.vue';
import { FEATURE_FLAGS } from '../../../../featureFlags';
import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
@@ -34,6 +35,7 @@ export default {
SenderNameExamplePreview,
MicrosoftReauthorize,
GoogleReauthorize,
NextButton,
},
mixins: [inboxMixin],
setup() {
@@ -381,406 +383,417 @@ export default {
/>
</woot-tabs>
</SettingIntroBanner>
<MicrosoftReauthorize v-if="microsoftUnauthorized" :inbox="inbox" />
<FacebookReauthorize v-if="facebookUnauthorized" :inbox="inbox" />
<GoogleReauthorize v-if="googleUnauthorized" :inbox="inbox" />
<div v-if="selectedTabKey === 'inbox_settings'" class="mx-8">
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_UPDATE_TITLE')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_UPDATE_SUB_TEXT')"
:show-border="false"
>
<woot-avatar-uploader
:label="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_AVATAR.LABEL')"
:src="avatarUrl"
class="pb-4"
delete-avatar
@on-avatar-select="handleImageUpload"
@on-avatar-delete="handleAvatarDelete"
/>
<woot-input
v-model="selectedInboxName"
class="w-3/4 pb-4"
:class="{ error: v$.selectedInboxName.$error }"
:label="inboxNameLabel"
:placeholder="inboxNamePlaceHolder"
:error="
v$.selectedInboxName.$error
? $t('INBOX_MGMT.ADD.CHANNEL_NAME.ERROR')
: ''
"
@blur="v$.selectedInboxName.$touch"
/>
<woot-input
v-if="isAPIInbox"
v-model="webhookUrl"
class="w-3/4 pb-4"
:class="{ error: v$.webhookUrl.$error }"
:label="
$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WEBHOOK_URL.LABEL')
"
:placeholder="
$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WEBHOOK_URL.PLACEHOLDER')
"
:error="
v$.webhookUrl.$error
? $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WEBHOOK_URL.ERROR')
: ''
"
@blur="v$.webhookUrl.$touch"
/>
<woot-input
v-if="isAWebWidgetInbox"
v-model="channelWebsiteUrl"
class="w-3/4 pb-4"
:label="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_DOMAIN.LABEL')"
:placeholder="
$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_DOMAIN.PLACEHOLDER')
"
/>
<woot-input
v-if="isAWebWidgetInbox"
v-model="channelWelcomeTitle"
class="w-3/4 pb-4"
:label="
$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TITLE.LABEL')
"
:placeholder="
$t(
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TITLE.PLACEHOLDER'
)
"
/>
<woot-input
v-if="isAWebWidgetInbox"
v-model="channelWelcomeTagline"
class="w-3/4 pb-4"
:label="
$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.LABEL')
"
:placeholder="
$t(
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.PLACEHOLDER'
)
"
/>
<label v-if="isAWebWidgetInbox" class="w-3/4 pb-4">
{{ $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.WIDGET_COLOR.LABEL') }}
<woot-color-picker v-model="inbox.widget_color" />
</label>
<label v-if="isAWhatsAppChannel" class="w-3/4 pb-4">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.LABEL') }}
<input v-model="whatsAppAPIProviderName" type="text" disabled />
</label>
<label class="w-3/4 pb-4">
{{
$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_GREETING_TOGGLE.LABEL')
}}
<select v-model="greetingEnabled">
<option :value="true">
{{
$t(
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_GREETING_TOGGLE.ENABLED'
)
}}
</option>
<option :value="false">
{{
$t(
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_GREETING_TOGGLE.DISABLED'
)
}}
</option>
</select>
<p class="pb-1 text-sm not-italic text-slate-600 dark:text-slate-400">
{{
$t(
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_GREETING_TOGGLE.HELP_TEXT'
)
}}
</p>
</label>
<div v-if="greetingEnabled" class="pb-4">
<GreetingsEditor
v-model="greetingMessage"
<section class="max-w-6xl mx-auto w-full">
<MicrosoftReauthorize v-if="microsoftUnauthorized" :inbox="inbox" />
<FacebookReauthorize v-if="facebookUnauthorized" :inbox="inbox" />
<GoogleReauthorize v-if="googleUnauthorized" :inbox="inbox" />
<div v-if="selectedTabKey === 'inbox_settings'" class="mx-8">
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_UPDATE_TITLE')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_UPDATE_SUB_TEXT')"
:show-border="false"
>
<woot-avatar-uploader
:label="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_AVATAR.LABEL')"
:src="avatarUrl"
class="pb-4"
delete-avatar
@on-avatar-select="handleImageUpload"
@on-avatar-delete="handleAvatarDelete"
/>
<woot-input
v-model="selectedInboxName"
class="pb-4"
:class="{ error: v$.selectedInboxName.$error }"
:label="inboxNameLabel"
:placeholder="inboxNamePlaceHolder"
:error="
v$.selectedInboxName.$error
? $t('INBOX_MGMT.ADD.CHANNEL_NAME.ERROR')
: ''
"
@blur="v$.selectedInboxName.$touch"
/>
<woot-input
v-if="isAPIInbox"
v-model="webhookUrl"
class="pb-4"
:class="{ error: v$.webhookUrl.$error }"
:label="
$t(
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_GREETING_MESSAGE.LABEL'
)
$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WEBHOOK_URL.LABEL')
"
:placeholder="
$t(
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_GREETING_MESSAGE.PLACEHOLDER'
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WEBHOOK_URL.PLACEHOLDER'
)
"
:richtext="!textAreaChannels"
:error="
v$.webhookUrl.$error
? $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WEBHOOK_URL.ERROR')
: ''
"
@blur="v$.webhookUrl.$touch"
/>
</div>
<label v-if="isAWebWidgetInbox" class="w-3/4 pb-4">
{{ $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.REPLY_TIME.TITLE') }}
<select v-model="replyTime">
<option key="in_a_few_minutes" value="in_a_few_minutes">
{{
$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.REPLY_TIME.IN_A_FEW_MINUTES')
}}
</option>
<option key="in_a_few_hours" value="in_a_few_hours">
{{
$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.REPLY_TIME.IN_A_FEW_HOURS')
}}
</option>
<option key="in_a_day" value="in_a_day">
{{ $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.REPLY_TIME.IN_A_DAY') }}
</option>
</select>
<p class="pb-1 text-sm not-italic text-slate-600 dark:text-slate-400">
{{ $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.REPLY_TIME.HELP_TEXT') }}
</p>
</label>
<label v-if="isAWebWidgetInbox" class="w-3/4 pb-4">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.ENABLE_EMAIL_COLLECT_BOX') }}
<select v-model="emailCollectEnabled">
<option :value="true">
{{ $t('INBOX_MGMT.EDIT.EMAIL_COLLECT_BOX.ENABLED') }}
</option>
<option :value="false">
{{ $t('INBOX_MGMT.EDIT.EMAIL_COLLECT_BOX.DISABLED') }}
</option>
</select>
<p class="pb-1 text-sm not-italic text-slate-600 dark:text-slate-400">
{{
$t('INBOX_MGMT.SETTINGS_POPUP.ENABLE_EMAIL_COLLECT_BOX_SUB_TEXT')
}}
</p>
</label>
<label class="w-3/4 pb-4">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.ENABLE_CSAT') }}
<select v-model="csatSurveyEnabled">
<option :value="true">
{{ $t('INBOX_MGMT.EDIT.ENABLE_CSAT.ENABLED') }}
</option>
<option :value="false">
{{ $t('INBOX_MGMT.EDIT.ENABLE_CSAT.DISABLED') }}
</option>
</select>
<p class="pb-1 text-sm not-italic text-slate-600 dark:text-slate-400">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.ENABLE_CSAT_SUB_TEXT') }}
</p>
</label>
<label v-if="isAWebWidgetInbox" class="w-3/4 pb-4">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.ALLOW_MESSAGES_AFTER_RESOLVED') }}
<select v-model="allowMessagesAfterResolved">
<option :value="true">
{{ $t('INBOX_MGMT.EDIT.ALLOW_MESSAGES_AFTER_RESOLVED.ENABLED') }}
</option>
<option :value="false">
{{ $t('INBOX_MGMT.EDIT.ALLOW_MESSAGES_AFTER_RESOLVED.DISABLED') }}
</option>
</select>
<p class="pb-1 text-sm not-italic text-slate-600 dark:text-slate-400">
{{
<woot-input
v-if="isAWebWidgetInbox"
v-model="channelWebsiteUrl"
class="pb-4"
:label="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_DOMAIN.LABEL')"
:placeholder="
$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_DOMAIN.PLACEHOLDER')
"
/>
<woot-input
v-if="isAWebWidgetInbox"
v-model="channelWelcomeTitle"
class="pb-4"
:label="
$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TITLE.LABEL')
"
:placeholder="
$t(
'INBOX_MGMT.SETTINGS_POPUP.ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT'
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TITLE.PLACEHOLDER'
)
}}
</p>
</label>
"
/>
<label v-if="isAWebWidgetInbox" class="w-3/4 pb-4">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.ENABLE_CONTINUITY_VIA_EMAIL') }}
<select v-model="continuityViaEmail">
<option :value="true">
{{ $t('INBOX_MGMT.EDIT.ENABLE_CONTINUITY_VIA_EMAIL.ENABLED') }}
</option>
<option :value="false">
{{ $t('INBOX_MGMT.EDIT.ENABLE_CONTINUITY_VIA_EMAIL.DISABLED') }}
</option>
</select>
<p class="pb-1 text-sm not-italic text-slate-600 dark:text-slate-400">
{{
<woot-input
v-if="isAWebWidgetInbox"
v-model="channelWelcomeTagline"
class="pb-4"
:label="
$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.LABEL')
"
:placeholder="
$t(
'INBOX_MGMT.SETTINGS_POPUP.ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT'
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.PLACEHOLDER'
)
}}
</p>
</label>
<div class="w-3/4 pb-4">
<label>
{{ $t('INBOX_MGMT.HELP_CENTER.LABEL') }}
</label>
<select v-model="selectedPortalSlug" class="filter__question">
<option value="">
{{ $t('INBOX_MGMT.HELP_CENTER.PLACEHOLDER') }}
</option>
<option v-for="p in portals" :key="p.slug" :value="p.slug">
{{ p.name }}
</option>
</select>
<p class="pb-1 text-sm not-italic text-slate-600 dark:text-slate-400">
{{ $t('INBOX_MGMT.HELP_CENTER.SUB_TEXT') }}
</p>
</div>
<label v-if="canLocktoSingleConversation" class="w-3/4 pb-4">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.LOCK_TO_SINGLE_CONVERSATION') }}
<select v-model="locktoSingleConversation">
<option :value="true">
{{ $t('INBOX_MGMT.EDIT.LOCK_TO_SINGLE_CONVERSATION.ENABLED') }}
</option>
<option :value="false">
{{ $t('INBOX_MGMT.EDIT.LOCK_TO_SINGLE_CONVERSATION.DISABLED') }}
</option>
</select>
<p class="pb-1 text-sm not-italic text-slate-600 dark:text-slate-400">
{{
$t(
'INBOX_MGMT.SETTINGS_POPUP.LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT'
)
}}
</p>
</label>
"
/>
<label v-if="isAWebWidgetInbox">
{{ $t('INBOX_MGMT.FEATURES.LABEL') }}
</label>
<div v-if="isAWebWidgetInbox" class="flex gap-2 pt-2 pb-4">
<input
v-model="selectedFeatureFlags"
type="checkbox"
value="attachments"
@input="handleFeatureFlag"
/>
<label for="attachments">
{{ $t('INBOX_MGMT.FEATURES.DISPLAY_FILE_PICKER') }}
<label v-if="isAWebWidgetInbox" class="pb-4">
{{ $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.WIDGET_COLOR.LABEL') }}
<woot-color-picker v-model="inbox.widget_color" />
</label>
</div>
<div v-if="isAWebWidgetInbox" class="flex gap-2 pb-4">
<input
v-model="selectedFeatureFlags"
type="checkbox"
value="emoji_picker"
@input="handleFeatureFlag"
/>
<label for="emoji_picker">
{{ $t('INBOX_MGMT.FEATURES.DISPLAY_EMOJI_PICKER') }}
<label v-if="isAWhatsAppChannel" class="pb-4">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.LABEL') }}
<input v-model="whatsAppAPIProviderName" type="text" disabled />
</label>
</div>
<div v-if="isAWebWidgetInbox" class="flex gap-2 pb-4">
<input
v-model="selectedFeatureFlags"
type="checkbox"
value="end_conversation"
@input="handleFeatureFlag"
/>
<label for="end_conversation">
{{ $t('INBOX_MGMT.FEATURES.ALLOW_END_CONVERSATION') }}
</label>
</div>
<div v-if="isAWebWidgetInbox" class="flex gap-2 pb-4">
<input
v-model="selectedFeatureFlags"
type="checkbox"
value="use_inbox_avatar_for_bot"
@input="handleFeatureFlag"
/>
<label for="use_inbox_avatar_for_bot">
{{ $t('INBOX_MGMT.FEATURES.USE_INBOX_AVATAR_FOR_BOT') }}
</label>
</div>
</SettingsSection>
<SettingsSection
v-if="isAWebWidgetInbox || isAnEmailChannel"
:title="$t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.TITLE')"
:sub-title="$t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.SUB_TEXT')"
:show-border="false"
>
<div class="w-3/4 pb-4">
<SenderNameExamplePreview
:sender-name-type="senderNameType"
:business-name="businessName"
@update="toggleSenderNameType"
/>
<div class="flex flex-col items-start gap-2 mt-2">
<woot-button
variant="clear"
color-scheme="primary"
@click="onClickShowBusinessNameInput"
>
{{
$t(
'INBOX_MGMT.EDIT.SENDER_NAME_SECTION.BUSINESS_NAME.BUTTON_TEXT'
)
}}
</woot-button>
<div v-if="showBusinessNameInput" class="flex gap-2 w-[80%]">
<input
ref="businessNameInput"
v-model="businessName"
:placeholder="
$t(
'INBOX_MGMT.EDIT.SENDER_NAME_SECTION.BUSINESS_NAME.PLACEHOLDER'
)
"
class="mb-0"
type="text"
/>
<woot-button color-scheme="primary" @click="updateInbox">
<label class="pb-4">
{{
$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_GREETING_TOGGLE.LABEL')
}}
<select v-model="greetingEnabled">
<option :value="true">
{{
$t(
'INBOX_MGMT.EDIT.SENDER_NAME_SECTION.BUSINESS_NAME.SAVE_BUTTON_TEXT'
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_GREETING_TOGGLE.ENABLED'
)
}}
</option>
<option :value="false">
{{
$t(
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_GREETING_TOGGLE.DISABLED'
)
}}
</option>
</select>
<p class="pb-1 text-sm not-italic text-n-slate-11">
{{
$t(
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_GREETING_TOGGLE.HELP_TEXT'
)
}}
</p>
</label>
<div v-if="greetingEnabled" class="pb-4">
<GreetingsEditor
v-model="greetingMessage"
:label="
$t(
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_GREETING_MESSAGE.LABEL'
)
"
:placeholder="
$t(
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_GREETING_MESSAGE.PLACEHOLDER'
)
"
:richtext="!textAreaChannels"
/>
</div>
<label v-if="isAWebWidgetInbox" class="pb-4">
{{ $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.REPLY_TIME.TITLE') }}
<select v-model="replyTime">
<option key="in_a_few_minutes" value="in_a_few_minutes">
{{
$t(
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.REPLY_TIME.IN_A_FEW_MINUTES'
)
}}
</option>
<option key="in_a_few_hours" value="in_a_few_hours">
{{
$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.REPLY_TIME.IN_A_FEW_HOURS')
}}
</option>
<option key="in_a_day" value="in_a_day">
{{ $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.REPLY_TIME.IN_A_DAY') }}
</option>
</select>
<p class="pb-1 text-sm not-italic text-n-slate-11">
{{ $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.REPLY_TIME.HELP_TEXT') }}
</p>
</label>
<label v-if="isAWebWidgetInbox" class="pb-4">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.ENABLE_EMAIL_COLLECT_BOX') }}
<select v-model="emailCollectEnabled">
<option :value="true">
{{ $t('INBOX_MGMT.EDIT.EMAIL_COLLECT_BOX.ENABLED') }}
</option>
<option :value="false">
{{ $t('INBOX_MGMT.EDIT.EMAIL_COLLECT_BOX.DISABLED') }}
</option>
</select>
<p class="pb-1 text-sm not-italic text-n-slate-11">
{{
$t(
'INBOX_MGMT.SETTINGS_POPUP.ENABLE_EMAIL_COLLECT_BOX_SUB_TEXT'
)
}}
</p>
</label>
<label class="pb-4">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.ENABLE_CSAT') }}
<select v-model="csatSurveyEnabled">
<option :value="true">
{{ $t('INBOX_MGMT.EDIT.ENABLE_CSAT.ENABLED') }}
</option>
<option :value="false">
{{ $t('INBOX_MGMT.EDIT.ENABLE_CSAT.DISABLED') }}
</option>
</select>
<p class="pb-1 text-sm not-italic text-n-slate-11">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.ENABLE_CSAT_SUB_TEXT') }}
</p>
</label>
<label v-if="isAWebWidgetInbox" class="pb-4">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.ALLOW_MESSAGES_AFTER_RESOLVED') }}
<select v-model="allowMessagesAfterResolved">
<option :value="true">
{{
$t('INBOX_MGMT.EDIT.ALLOW_MESSAGES_AFTER_RESOLVED.ENABLED')
}}
</option>
<option :value="false">
{{
$t('INBOX_MGMT.EDIT.ALLOW_MESSAGES_AFTER_RESOLVED.DISABLED')
}}
</option>
</select>
<p class="pb-1 text-sm not-italic text-n-slate-11">
{{
$t(
'INBOX_MGMT.SETTINGS_POPUP.ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT'
)
}}
</p>
</label>
<label v-if="isAWebWidgetInbox" class="pb-4">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.ENABLE_CONTINUITY_VIA_EMAIL') }}
<select v-model="continuityViaEmail">
<option :value="true">
{{ $t('INBOX_MGMT.EDIT.ENABLE_CONTINUITY_VIA_EMAIL.ENABLED') }}
</option>
<option :value="false">
{{ $t('INBOX_MGMT.EDIT.ENABLE_CONTINUITY_VIA_EMAIL.DISABLED') }}
</option>
</select>
<p class="pb-1 text-sm not-italic text-n-slate-11">
{{
$t(
'INBOX_MGMT.SETTINGS_POPUP.ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT'
)
}}
</p>
</label>
<div class="pb-4">
<label>
{{ $t('INBOX_MGMT.HELP_CENTER.LABEL') }}
</label>
<select v-model="selectedPortalSlug" class="filter__question">
<option value="">
{{ $t('INBOX_MGMT.HELP_CENTER.PLACEHOLDER') }}
</option>
<option v-for="p in portals" :key="p.slug" :value="p.slug">
{{ p.name }}
</option>
</select>
<p class="pb-1 text-sm not-italic text-n-slate-11">
{{ $t('INBOX_MGMT.HELP_CENTER.SUB_TEXT') }}
</p>
</div>
<label v-if="canLocktoSingleConversation" class="pb-4">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.LOCK_TO_SINGLE_CONVERSATION') }}
<select v-model="locktoSingleConversation">
<option :value="true">
{{ $t('INBOX_MGMT.EDIT.LOCK_TO_SINGLE_CONVERSATION.ENABLED') }}
</option>
<option :value="false">
{{ $t('INBOX_MGMT.EDIT.LOCK_TO_SINGLE_CONVERSATION.DISABLED') }}
</option>
</select>
<p class="pb-1 text-sm not-italic text-n-slate-11">
{{
$t(
'INBOX_MGMT.SETTINGS_POPUP.LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT'
)
}}
</p>
</label>
<label v-if="isAWebWidgetInbox">
{{ $t('INBOX_MGMT.FEATURES.LABEL') }}
</label>
<div v-if="isAWebWidgetInbox" class="flex gap-2 pt-2 pb-4">
<input
v-model="selectedFeatureFlags"
type="checkbox"
value="attachments"
@input="handleFeatureFlag"
/>
<label for="attachments">
{{ $t('INBOX_MGMT.FEATURES.DISPLAY_FILE_PICKER') }}
</label>
</div>
<div v-if="isAWebWidgetInbox" class="flex gap-2 pb-4">
<input
v-model="selectedFeatureFlags"
type="checkbox"
value="emoji_picker"
@input="handleFeatureFlag"
/>
<label for="emoji_picker">
{{ $t('INBOX_MGMT.FEATURES.DISPLAY_EMOJI_PICKER') }}
</label>
</div>
<div v-if="isAWebWidgetInbox" class="flex gap-2 pb-4">
<input
v-model="selectedFeatureFlags"
type="checkbox"
value="end_conversation"
@input="handleFeatureFlag"
/>
<label for="end_conversation">
{{ $t('INBOX_MGMT.FEATURES.ALLOW_END_CONVERSATION') }}
</label>
</div>
<div v-if="isAWebWidgetInbox" class="flex gap-2 pb-4">
<input
v-model="selectedFeatureFlags"
type="checkbox"
value="use_inbox_avatar_for_bot"
@input="handleFeatureFlag"
/>
<label for="use_inbox_avatar_for_bot">
{{ $t('INBOX_MGMT.FEATURES.USE_INBOX_AVATAR_FOR_BOT') }}
</label>
</div>
</SettingsSection>
<SettingsSection
v-if="isAWebWidgetInbox || isAnEmailChannel"
:title="$t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.TITLE')"
:sub-title="$t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.SUB_TEXT')"
:show-border="false"
>
<div class="pb-4">
<SenderNameExamplePreview
:sender-name-type="senderNameType"
:business-name="businessName"
@update="toggleSenderNameType"
/>
<div class="flex flex-col items-start gap-2 mt-2">
<woot-button
variant="clear"
color-scheme="primary"
@click="onClickShowBusinessNameInput"
>
{{
$t(
'INBOX_MGMT.EDIT.SENDER_NAME_SECTION.BUSINESS_NAME.BUTTON_TEXT'
)
}}
</woot-button>
<div v-if="showBusinessNameInput" class="flex gap-2 w-[80%]">
<input
ref="businessNameInput"
v-model="businessName"
:placeholder="
$t(
'INBOX_MGMT.EDIT.SENDER_NAME_SECTION.BUSINESS_NAME.PLACEHOLDER'
)
"
class="mb-0"
type="text"
/>
<woot-button color-scheme="primary" @click="updateInbox">
{{
$t(
'INBOX_MGMT.EDIT.SENDER_NAME_SECTION.BUSINESS_NAME.SAVE_BUTTON_TEXT'
)
}}
</woot-button>
</div>
</div>
</div>
</div>
</SettingsSection>
<SettingsSection :show-border="false">
<woot-submit-button
v-if="isAPIInbox"
type="submit"
:disabled="v$.webhookUrl.$invalid"
:button-text="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
:loading="uiFlags.isUpdating"
@click="updateInbox"
/>
<woot-submit-button
v-else
type="submit"
:disabled="v$.$invalid"
:button-text="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
:loading="uiFlags.isUpdating"
@click="updateInbox"
/>
</SettingsSection>
</div>
</SettingsSection>
<SettingsSection :show-border="false">
<NextButton
v-if="isAPIInbox"
type="submit"
:disabled="v$.webhookUrl.$invalid"
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
:is-loading="uiFlags.isUpdating"
@click="updateInbox"
/>
<NextButton
v-else
type="submit"
:disabled="v$.$invalid"
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
:is-loading="uiFlags.isUpdating"
@click="updateInbox"
/>
</SettingsSection>
</div>
<div v-if="selectedTabKey === 'collaborators'" class="mx-8">
<CollaboratorsPage :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'configuration'">
<ConfigurationPage :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'preChatForm'">
<PreChatFormSettings :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'businesshours'">
<WeeklyAvailability :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'widgetBuilder'">
<WidgetBuilder :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'botConfiguration'">
<BotConfiguration :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'collaborators'" class="mx-8">
<CollaboratorsPage :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'configuration'">
<ConfigurationPage :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'preChatForm'">
<PreChatFormSettings :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'businesshours'">
<WeeklyAvailability :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'widgetBuilder'">
<WidgetBuilder :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'botConfiguration'">
<BotConfiguration :inbox="inbox" />
</div>
</section>
</div>
</template>
@@ -6,12 +6,14 @@ import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import InputRadioGroup from './components/InputRadioGroup.vue';
import SingleSelectDropdown from './components/SingleSelectDropdown.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
SettingsSection,
InputRadioGroup,
SingleSelectDropdown,
NextButton,
},
props: {
inbox: {
@@ -231,9 +233,10 @@ export default {
:action="handleAuthMechanismChange"
/>
</div>
<woot-submit-button
:button-text="$t('INBOX_MGMT.SMTP.UPDATE')"
:loading="uiFlags.isUpdatingSMTP"
<NextButton
type="submit"
:label="$t('INBOX_MGMT.SMTP.UPDATE')"
:is-loading="uiFlags.isUpdatingSMTP"
:disabled="(v$.$invalid && isSMTPEnabled) || uiFlags.isUpdatingSMTP"
/>
</form>
@@ -7,11 +7,13 @@ import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
Widget,
InputRadioGroup,
NextButton,
},
props: {
inbox: {
@@ -268,7 +270,7 @@ export default {
<div class="mx-8">
<div class="flex p-2.5">
<div class="w-100 lg:w-[40%]">
<div class="min-h-full py-4 overflow-y-scroll">
<div class="min-h-full py-4 overflow-y-scroll px-px">
<form @submit.prevent="updateWidget">
<woot-avatar-uploader
:label="
@@ -376,14 +378,15 @@ export default {
)
"
/>
<woot-submit-button
<NextButton
type="submit"
class="mt-4"
:button-text="
:label="
$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.UPDATE.BUTTON_TEXT'
)
"
:loading="uiFlags.isUpdating"
:is-loading="uiFlags.isUpdating"
:disabled="v$.$invalid || uiFlags.isUpdating"
/>
</form>
@@ -66,8 +66,8 @@ export default {
</script>
<template>
<form class="flex flex-wrap mx-0" @submit.prevent="createChannel()">
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<form class="flex flex-wrap flex-col mx-0" @submit.prevent="createChannel()">
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.inboxName.$error }">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.LABEL') }}
<input
@@ -82,7 +82,7 @@ export default {
</label>
</div>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.phoneNumber.$error }">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PHONE_NUMBER.LABEL') }}
<input
@@ -97,7 +97,7 @@ export default {
</label>
</div>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.apiKey.$error }">
<span>
{{ $t('INBOX_MGMT.ADD.WHATSAPP.API_KEY.LABEL') }}
@@ -5,6 +5,7 @@ import { useAlert } from 'dashboard/composables';
import { required } from '@vuelidate/validators';
import router from '../../../../index';
import PageHeader from '../../SettingsSubPageHeader.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const shouldBeWebhookUrl = (value = '') =>
value ? value.startsWith('http') : true;
@@ -12,6 +13,7 @@ const shouldBeWebhookUrl = (value = '') =>
export default {
components: {
PageHeader,
NextButton,
},
setup() {
return { v$: useVuelidate() };
@@ -64,14 +66,17 @@ export default {
<template>
<div
class="border border-slate-25 dark:border-slate-800/60 bg-white dark:bg-slate-900 h-full p-6 w-full max-w-full md:w-3/4 md:max-w-[75%] flex-shrink-0 flex-grow-0"
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.API_CHANNEL.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.API_CHANNEL.DESC')"
/>
<form class="flex flex-wrap mx-0" @submit.prevent="createChannel()">
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<form
class="flex flex-wrap flex-col mx-0"
@submit.prevent="createChannel()"
>
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.channelName.$error }">
{{ $t('INBOX_MGMT.ADD.API_CHANNEL.CHANNEL_NAME.LABEL') }}
<input
@@ -88,7 +93,7 @@ export default {
</label>
</div>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.webhookUrl.$error }">
{{ $t('INBOX_MGMT.ADD.API_CHANNEL.WEBHOOK_URL.LABEL') }}
<input
@@ -105,10 +110,13 @@ export default {
</p>
</div>
<div class="w-full">
<woot-submit-button
:loading="uiFlags.isCreating"
:button-text="$t('INBOX_MGMT.ADD.API_CHANNEL.SUBMIT_BUTTON')"
<div class="w-full mt-4">
<NextButton
:is-loading="uiFlags.isCreating"
type="submit"
solid
blue
:label="$t('INBOX_MGMT.ADD.API_CHANNEL.SUBMIT_BUTTON')"
/>
</div>
</form>
@@ -5,9 +5,14 @@ import { useAlert } from 'dashboard/composables';
import { required } from '@vuelidate/validators';
import router from '../../../../index';
import NextButton from 'dashboard/components-next/button/Button.vue';
const shouldStartWithPlusSign = (value = '') => value.startsWith('+');
export default {
components: {
NextButton,
},
setup() {
return { v$: useVuelidate() };
},
@@ -72,8 +77,8 @@ export default {
</script>
<template>
<form class="flex flex-wrap mx-0" @submit.prevent="createChannel()">
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<form class="flex flex-wrap flex-col mx-0" @submit.prevent="createChannel()">
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.inboxName.$error }">
{{ $t('INBOX_MGMT.ADD.SMS.BANDWIDTH.INBOX_NAME.LABEL') }}
<input
@@ -90,7 +95,7 @@ export default {
</label>
</div>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.phoneNumber.$error }">
{{ $t('INBOX_MGMT.ADD.SMS.BANDWIDTH.PHONE_NUMBER.LABEL') }}
<input
@@ -107,7 +112,7 @@ export default {
</label>
</div>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.accountId.$error }">
{{ $t('INBOX_MGMT.ADD.SMS.BANDWIDTH.ACCOUNT_ID.LABEL') }}
<input
@@ -124,7 +129,7 @@ export default {
</label>
</div>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.applicationId.$error }">
{{ $t('INBOX_MGMT.ADD.SMS.BANDWIDTH.APPLICATION_ID.LABEL') }}
<input
@@ -141,7 +146,7 @@ export default {
</label>
</div>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.apiKey.$error }">
{{ $t('INBOX_MGMT.ADD.SMS.BANDWIDTH.API_KEY.LABEL') }}
<input
@@ -156,7 +161,7 @@ export default {
</label>
</div>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.apiSecret.$error }">
{{ $t('INBOX_MGMT.ADD.SMS.BANDWIDTH.API_SECRET.LABEL') }}
<input
@@ -173,10 +178,13 @@ export default {
</label>
</div>
<div class="w-full">
<woot-submit-button
:loading="uiFlags.isCreating"
:button-text="$t('INBOX_MGMT.ADD.SMS.BANDWIDTH.SUBMIT_BUTTON')"
<div class="w-full mt-4">
<NextButton
:is-loading="uiFlags.isCreating"
type="submit"
solid
blue
:label="$t('INBOX_MGMT.ADD.SMS.BANDWIDTH.SUBMIT_BUTTON')"
/>
</div>
</form>
@@ -6,7 +6,12 @@ import { required } from '@vuelidate/validators';
import router from '../../../../index';
import { isPhoneE164OrEmpty, isNumber } from 'shared/helpers/Validators';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
setup() {
return { v$: useVuelidate() };
},
@@ -72,8 +77,8 @@ export default {
</script>
<template>
<form class="flex flex-wrap mx-0" @submit.prevent="createChannel()">
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<form class="flex flex-wrap flex-col mx-0" @submit.prevent="createChannel()">
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.inboxName.$error }">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.INBOX_NAME.LABEL') }}
<input
@@ -88,7 +93,7 @@ export default {
</label>
</div>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.phoneNumber.$error }">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PHONE_NUMBER.LABEL') }}
<input
@@ -103,7 +108,7 @@ export default {
</label>
</div>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.phoneNumberId.$error }">
<span>
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PHONE_NUMBER_ID.LABEL') }}
@@ -122,7 +127,7 @@ export default {
</label>
</div>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.businessAccountId.$error }">
<span>
{{ $t('INBOX_MGMT.ADD.WHATSAPP.BUSINESS_ACCOUNT_ID.LABEL') }}
@@ -141,7 +146,7 @@ export default {
</label>
</div>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.apiKey.$error }">
<span>
{{ $t('INBOX_MGMT.ADD.WHATSAPP.API_KEY.LABEL') }}
@@ -158,10 +163,13 @@ export default {
</label>
</div>
<div class="w-full">
<woot-submit-button
:loading="uiFlags.isCreating"
:button-text="$t('INBOX_MGMT.ADD.WHATSAPP.SUBMIT_BUTTON')"
<div class="w-full mt-4">
<NextButton
:is-loading="uiFlags.isCreating"
type="submit"
solid
blue
:label="$t('INBOX_MGMT.ADD.WHATSAPP.SUBMIT_BUTTON')"
/>
</div>
</form>
@@ -55,7 +55,7 @@ function onClick(emailProvider) {
<template>
<div
v-if="!provider"
class="border border-slate-25 dark:border-slate-800/60 bg-white dark:bg-slate-900 h-full p-6 w-full md:w-full max-w-full md:max-w-[75%] flex-shrink-0 flex-grow-0"
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<PageHeader
class="max-w-4xl"
@@ -207,7 +207,7 @@ export default {
<template>
<div
class="border border-slate-25 dark:border-slate-800/60 bg-white dark:bg-slate-900 h-full p-6 w-full max-w-full md:w-3/4 md:max-w-[75%] flex-shrink-0 flex-grow-0"
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<div
v-if="!hasLoginStarted"
@@ -240,7 +240,7 @@ export default {
<LoadingState v-else-if="showLoader" :message="emptyStateMessage" />
<form
v-else
class="flex flex-wrap mx-0"
class="flex flex-wrap flex-col mx-0"
@submit.prevent="createChannel()"
>
<div class="w-full">
@@ -5,10 +5,12 @@ import { useAlert } from 'dashboard/composables';
import { required } from '@vuelidate/validators';
import router from '../../../../index';
import PageHeader from '../../SettingsSubPageHeader.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
PageHeader,
NextButton,
},
setup() {
return { v$: useVuelidate() };
@@ -70,14 +72,17 @@ export default {
<template>
<div
class="border border-slate-25 dark:border-slate-800/60 bg-white dark:bg-slate-900 h-full p-6 w-full max-w-full md:w-3/4 md:max-w-[75%] flex-shrink-0 flex-grow-0"
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
>
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.LINE_CHANNEL.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.LINE_CHANNEL.DESC')"
/>
<form class="flex flex-wrap mx-0" @submit.prevent="createChannel()">
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<form
class="flex flex-wrap flex-col mx-0"
@submit.prevent="createChannel()"
>
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.channelName.$error }">
{{ $t('INBOX_MGMT.ADD.LINE_CHANNEL.CHANNEL_NAME.LABEL') }}
<input
@@ -94,7 +99,7 @@ export default {
</label>
</div>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.lineChannelId.$error }">
{{ $t('INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_ID.LABEL') }}
<input
@@ -108,7 +113,7 @@ export default {
</label>
</div>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.lineChannelSecret.$error }">
{{ $t('INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_SECRET.LABEL') }}
<input
@@ -122,7 +127,7 @@ export default {
</label>
</div>
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
<div class="flex-shrink-0 flex-grow-0">
<label :class="{ error: v$.lineChannelToken.$error }">
{{ $t('INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_TOKEN.LABEL') }}
<input
@@ -136,10 +141,13 @@ export default {
</label>
</div>
<div class="w-full">
<woot-submit-button
:loading="uiFlags.isCreating"
:button-text="$t('INBOX_MGMT.ADD.LINE_CHANNEL.SUBMIT_BUTTON')"
<div class="w-full mt-4">
<NextButton
:is-loading="uiFlags.isCreating"
type="submit"
solid
blue
:label="$t('INBOX_MGMT.ADD.LINE_CHANNEL.SUBMIT_BUTTON')"
/>
</div>
</form>

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