Compare commits
46
Commits
fix/debug-sdk
...
v3.14.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
622f29a0b7 | ||
|
|
5133f4e579 | ||
|
|
c8657c55a8 | ||
|
|
604c592e89 | ||
|
|
fe2d91e0d3 | ||
|
|
6d40986a64 | ||
|
|
35b21c1cea | ||
|
|
306a6c6ce0 | ||
|
|
902a9aa7d7 | ||
|
|
6a383dc3ca | ||
|
|
e2db5b8cff | ||
|
|
544fe2b913 | ||
|
|
a04f8182a2 | ||
|
|
6d6dc0c153 | ||
|
|
32a9d5b0ce | ||
|
|
0082c6adb9 | ||
|
|
431d533635 | ||
|
|
5fd389e15d | ||
|
|
44be3c9eec | ||
|
|
7be1ecaf96 | ||
|
|
f13644245e | ||
|
|
62f4f127aa | ||
|
|
392e58b0be | ||
|
|
3a0fd9b777 | ||
|
|
dec637ab8a | ||
|
|
e0ef007047 | ||
|
|
593270d10b | ||
|
|
6986d34bd9 | ||
|
|
694302b930 | ||
|
|
1e9959bb65 | ||
|
|
ed9dc6d419 | ||
|
|
2d5afef7c2 | ||
|
|
1fc06f8959 | ||
|
|
16c6ef0e11 | ||
|
|
01cc46b318 | ||
|
|
4c7a539f9d | ||
|
|
a2f32f7232 | ||
|
|
220a947380 | ||
|
|
8505aa48c3 | ||
|
|
b49eaa5c45 | ||
|
|
f18ed01eb7 | ||
|
|
aa5fa0c758 | ||
|
|
42eca69763 | ||
|
|
3a78192e74 | ||
|
|
fd01a5056a | ||
|
|
d70ba8ff40 |
+14
-6
@@ -12,6 +12,20 @@ module.exports = {
|
||||
'vitest-globals/env': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.story.vue'],
|
||||
rules: {
|
||||
'vue/no-undef-components': [
|
||||
'error',
|
||||
{
|
||||
ignorePatterns: ['Variant', 'Story'],
|
||||
},
|
||||
],
|
||||
// Story files can have static strings, it doesn't need to handle i18n always.
|
||||
'vue/no-bare-strings-in-template': 'off',
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
plugins: ['html', 'prettier'],
|
||||
parserOptions: {
|
||||
@@ -223,11 +237,5 @@ module.exports = {
|
||||
globals: {
|
||||
bus: true,
|
||||
vi: true,
|
||||
// beforeEach: true,
|
||||
// afterEach: true,
|
||||
// test: true,
|
||||
// describe: true,
|
||||
// it: true,
|
||||
// expect: true,
|
||||
},
|
||||
};
|
||||
|
||||
+56
-56
@@ -33,70 +33,70 @@ GIT
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (7.0.8.4)
|
||||
actionpack (= 7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
actioncable (7.0.8.5)
|
||||
actionpack (= 7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
actionmailbox (7.0.8.4)
|
||||
actionpack (= 7.0.8.4)
|
||||
activejob (= 7.0.8.4)
|
||||
activerecord (= 7.0.8.4)
|
||||
activestorage (= 7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
actionmailbox (7.0.8.5)
|
||||
actionpack (= 7.0.8.5)
|
||||
activejob (= 7.0.8.5)
|
||||
activerecord (= 7.0.8.5)
|
||||
activestorage (= 7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
mail (>= 2.7.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
actionmailer (7.0.8.4)
|
||||
actionpack (= 7.0.8.4)
|
||||
actionview (= 7.0.8.4)
|
||||
activejob (= 7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
actionmailer (7.0.8.5)
|
||||
actionpack (= 7.0.8.5)
|
||||
actionview (= 7.0.8.5)
|
||||
activejob (= 7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
rails-dom-testing (~> 2.0)
|
||||
actionpack (7.0.8.4)
|
||||
actionview (= 7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
actionpack (7.0.8.5)
|
||||
actionview (= 7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
rack (~> 2.0, >= 2.2.4)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
||||
actiontext (7.0.8.4)
|
||||
actionpack (= 7.0.8.4)
|
||||
activerecord (= 7.0.8.4)
|
||||
activestorage (= 7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
actiontext (7.0.8.5)
|
||||
actionpack (= 7.0.8.5)
|
||||
activerecord (= 7.0.8.5)
|
||||
activestorage (= 7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
actionview (7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.1, >= 1.2.0)
|
||||
active_record_query_trace (1.8)
|
||||
activejob (7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
activejob (7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
activerecord (7.0.8.4)
|
||||
activemodel (= 7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
activemodel (7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
activerecord (7.0.8.5)
|
||||
activemodel (= 7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
activerecord-import (1.4.1)
|
||||
activerecord (>= 4.2)
|
||||
activestorage (7.0.8.4)
|
||||
actionpack (= 7.0.8.4)
|
||||
activejob (= 7.0.8.4)
|
||||
activerecord (= 7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
activestorage (7.0.8.5)
|
||||
actionpack (= 7.0.8.5)
|
||||
activejob (= 7.0.8.5)
|
||||
activerecord (= 7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
marcel (~> 1.0)
|
||||
mini_mime (>= 1.1.0)
|
||||
activesupport (7.0.8.4)
|
||||
activesupport (7.0.8.5)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
i18n (>= 1.6, < 2)
|
||||
minitest (>= 5.1)
|
||||
@@ -373,7 +373,7 @@ GEM
|
||||
mini_mime (>= 1.0.0)
|
||||
multi_xml (>= 0.5.2)
|
||||
httpclient (2.8.3)
|
||||
i18n (1.14.5)
|
||||
i18n (1.14.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
image_processing (1.12.2)
|
||||
mini_magick (>= 4.9.5, < 5)
|
||||
@@ -479,7 +479,7 @@ GEM
|
||||
uri
|
||||
net-http-persistent (4.0.2)
|
||||
connection_pool (~> 2.2)
|
||||
net-imap (0.4.14)
|
||||
net-imap (0.4.17)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -557,7 +557,7 @@ GEM
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (2.2.9)
|
||||
rack (2.2.10)
|
||||
rack-attack (6.7.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-contrib (2.5.0)
|
||||
@@ -574,20 +574,20 @@ GEM
|
||||
rack-test (2.1.0)
|
||||
rack (>= 1.3)
|
||||
rack-timeout (0.6.3)
|
||||
rails (7.0.8.4)
|
||||
actioncable (= 7.0.8.4)
|
||||
actionmailbox (= 7.0.8.4)
|
||||
actionmailer (= 7.0.8.4)
|
||||
actionpack (= 7.0.8.4)
|
||||
actiontext (= 7.0.8.4)
|
||||
actionview (= 7.0.8.4)
|
||||
activejob (= 7.0.8.4)
|
||||
activemodel (= 7.0.8.4)
|
||||
activerecord (= 7.0.8.4)
|
||||
activestorage (= 7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
rails (7.0.8.5)
|
||||
actioncable (= 7.0.8.5)
|
||||
actionmailbox (= 7.0.8.5)
|
||||
actionmailer (= 7.0.8.5)
|
||||
actionpack (= 7.0.8.5)
|
||||
actiontext (= 7.0.8.5)
|
||||
actionview (= 7.0.8.5)
|
||||
activejob (= 7.0.8.5)
|
||||
activemodel (= 7.0.8.5)
|
||||
activerecord (= 7.0.8.5)
|
||||
activestorage (= 7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.0.8.4)
|
||||
railties (= 7.0.8.5)
|
||||
rails-dom-testing (2.2.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
@@ -595,9 +595,9 @@ GEM
|
||||
rails-html-sanitizer (1.6.0)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (~> 1.14)
|
||||
railties (7.0.8.4)
|
||||
actionpack (= 7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
railties (7.0.8.5)
|
||||
actionpack (= 7.0.8.5)
|
||||
activesupport (= 7.0.8.5)
|
||||
method_source
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0)
|
||||
|
||||
@@ -6,7 +6,7 @@ RAILS_ENV ?= development
|
||||
setup:
|
||||
gem install bundler
|
||||
bundle install
|
||||
yarn install
|
||||
pnpm install
|
||||
|
||||
db_create:
|
||||
RAILS_ENV=$(RAILS_ENV) bundle exec rails db:create
|
||||
@@ -30,7 +30,7 @@ server:
|
||||
RAILS_ENV=$(RAILS_ENV) bundle exec rails server -b 0.0.0.0 -p 3000
|
||||
|
||||
burn:
|
||||
bundle && yarn
|
||||
bundle && pnpm install
|
||||
|
||||
run:
|
||||
@if [ -f ./.overmind.sock ]; then \
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
class Google::CallbacksController < OauthCallbackController
|
||||
include GoogleConcern
|
||||
|
||||
def find_channel_by_email
|
||||
# find by imap_login first, and then by email
|
||||
# this ensures the legacy users can migrate correctly even if inbox email address doesn't match
|
||||
imap_channel = Channel::Email.find_by(imap_login: users_data['email'], account: account)
|
||||
return imap_channel if imap_channel
|
||||
|
||||
Channel::Email.find_by(email: users_data['email'], account: account)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def provider_name
|
||||
|
||||
@@ -25,7 +25,7 @@ class OauthCallbackController < ApplicationController
|
||||
end
|
||||
|
||||
def find_or_create_inbox
|
||||
channel_email = Channel::Email.find_by(email: users_data['email'], account: account)
|
||||
channel_email = find_channel_by_email
|
||||
# we need this value to know where to redirect on sucessful processing of the callback
|
||||
channel_exists = channel_email.present?
|
||||
|
||||
@@ -39,6 +39,10 @@ class OauthCallbackController < ApplicationController
|
||||
[channel_email.inbox, channel_exists]
|
||||
end
|
||||
|
||||
def find_channel_by_email
|
||||
Channel::Email.find_by(email: users_data['email'], account: account)
|
||||
end
|
||||
|
||||
def update_channel(channel_email)
|
||||
channel_email.update!({
|
||||
imap_login: users_data['email'], imap_address: imap_address,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useStore } from 'dashboard/composables/store';
|
||||
import WootSnackbarBox from './components/SnackbarContainer.vue';
|
||||
import { setColorTheme } from './helper/themeHelper';
|
||||
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import {
|
||||
registerSubscription,
|
||||
verifyServiceWorkerExistence,
|
||||
@@ -35,8 +36,9 @@ export default {
|
||||
setup() {
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { accountId } = useAccount();
|
||||
|
||||
return { router, store };
|
||||
return { router, store, currentAccountId: accountId };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -52,7 +54,6 @@ export default {
|
||||
currentUser: 'getCurrentUser',
|
||||
authUIFlags: 'getAuthUIFlags',
|
||||
accountUIFlags: 'accounts/getUIFlags',
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
}),
|
||||
hasAccounts() {
|
||||
const { accounts = [] } = this.currentUser || {};
|
||||
@@ -69,10 +70,13 @@ export default {
|
||||
this.showAddAccountModal = true;
|
||||
}
|
||||
},
|
||||
currentAccountId() {
|
||||
if (this.currentAccountId) {
|
||||
this.initializeAccount();
|
||||
}
|
||||
currentAccountId: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
if (this.currentAccountId) {
|
||||
this.initializeAccount();
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -46,8 +46,158 @@
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
// scss-lint:disable PropertySortOrder
|
||||
@layer base {
|
||||
// scss-lint:disable PropertySortOrder
|
||||
/* 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;
|
||||
|
||||
--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;
|
||||
|
||||
--background-color: 248 248 248;
|
||||
--solid-1: 255 255 255;
|
||||
--solid-2: 252 252 252;
|
||||
--solid-3: 255 255 255;
|
||||
--solid-active: 250 251 251;
|
||||
--white-alpha: 255 255 255 0.1;
|
||||
--border-weak: 231 231 231;
|
||||
--border-strong: 235 235 235;
|
||||
--amber-solid: 252 232 193;
|
||||
--blue-solid: 218 236 255;
|
||||
--blue: 39 129 246;
|
||||
|
||||
/* alpha is added by default */
|
||||
--alpha-1: 36, 38, 48, 0.06;
|
||||
--alpha-2: 130, 134, 150, 0.12;
|
||||
--alpha-3: 255, 255, 255, 0.9;
|
||||
--black-alpha-1: 0, 0, 0, 0.12;
|
||||
--black-alpha-2: 0, 0, 0, 0.04;
|
||||
}
|
||||
|
||||
body.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;
|
||||
|
||||
--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;
|
||||
|
||||
--background-color: 18 18 19;
|
||||
--solid-1: 23 23 26;
|
||||
--solid-2: 29 30 36;
|
||||
--solid-3: 36 38 48;
|
||||
--solid-active: 51 53 64;
|
||||
--border-weak: 34 34 37;
|
||||
--border-strong: 46 47 49;
|
||||
--amber-solid: 42 37 30;
|
||||
--blue-solid: 16 49 91;
|
||||
--blue: 126 182 255;
|
||||
|
||||
/* alpha is added by default */
|
||||
--alpha-1: 35, 37, 45, 0.8;
|
||||
--alpha-2: 130, 134, 150, 0.15;
|
||||
--alpha-3: 32, 33, 37, 0.9;
|
||||
--black-alpha-1: 0, 0, 0, 0.3;
|
||||
--black-alpha-2: 0, 0, 0, 0.2;
|
||||
--white-alpha: 255, 255, 255, 0.1;
|
||||
}
|
||||
/* NEXT COLORS END */
|
||||
|
||||
:root {
|
||||
--color-amber-25: 254 253 251;
|
||||
--color-amber-50: 255 249 237;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup>
|
||||
const emit = defineEmits(['click']);
|
||||
const handleClick = () => {
|
||||
emit('click');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex flex-col w-full gap-3 px-6 py-5 group/cardLayout rounded-2xl bg-slate-25 dark:bg-slate-800/50"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot name="header" />
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="relative flex flex-col items-center justify-center w-full h-full min-h-screen p-4 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="relative w-full max-w-[940px] mx-auto overflow-hidden h-full max-h-[448px]"
|
||||
>
|
||||
<div
|
||||
class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none"
|
||||
>
|
||||
<slot name="empty-state-item" />
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 flex flex-col items-center justify-end w-full h-full pb-9 bg-gradient-to-t from-white dark:from-slate-900 to-transparent font-interDisplay"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center gap-6">
|
||||
<div class="flex flex-col items-center justify-center gap-2">
|
||||
<h2
|
||||
class="text-3xl font-medium text-center text-slate-900 dark:text-white"
|
||||
>
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p
|
||||
class="max-w-lg text-base text-center text-slate-600 dark:text-slate-300"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup>
|
||||
import ArticleCard from './ArticleCard.vue';
|
||||
|
||||
const articles = [
|
||||
{
|
||||
title: "How to get an SSL certificate for your Help Center's custom domain",
|
||||
status: 'draft',
|
||||
updatedAt: '2 days ago',
|
||||
author: 'Michael',
|
||||
category: '⚡️ Marketing',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: '1 week ago',
|
||||
author: 'John',
|
||||
category: '🛠️ Development',
|
||||
views: 1400,
|
||||
},
|
||||
{
|
||||
title: 'Best practices for organizing your Help Center content',
|
||||
status: 'archived',
|
||||
updatedAt: '3 days ago',
|
||||
author: 'Fernando',
|
||||
category: '💰 Finance',
|
||||
views: 4300,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||
<!-- eslint-disable vue/no-undef-components -->
|
||||
<template>
|
||||
<Story
|
||||
title="Components/HelpCenter/ArticleCard"
|
||||
:layout="{ type: 'grid', width: '700px' }"
|
||||
>
|
||||
<Variant title="Article Card">
|
||||
<div
|
||||
v-for="(article, index) in articles"
|
||||
:key="index"
|
||||
class="px-20 py-4 bg-white dark:bg-slate-900"
|
||||
>
|
||||
<ArticleCard
|
||||
:title="article.title"
|
||||
:status="article.status"
|
||||
:author="article.author"
|
||||
:category="article.category"
|
||||
:views="article.views"
|
||||
:updated-at="article.updatedAt"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,142 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
author: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
category: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
views: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
updatedAt: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const isOpen = ref(false);
|
||||
|
||||
const menuItems = computed(() => {
|
||||
const baseItems = [{ label: 'Delete', action: 'delete', icon: 'delete' }];
|
||||
const menuOptions = {
|
||||
archived: [
|
||||
{ label: 'Publish', action: 'publish', icon: 'checkmark' },
|
||||
{ label: 'Draft', action: 'draft', icon: 'draft' },
|
||||
],
|
||||
draft: [
|
||||
{ label: 'Publish', action: 'publish', icon: 'checkmark' },
|
||||
{ label: 'Archive', action: 'archive', icon: 'archive' },
|
||||
],
|
||||
'': [
|
||||
// Empty string represents published status
|
||||
{ label: 'Draft', action: 'draft', icon: 'draft' },
|
||||
{ label: 'Archive', action: 'archive', icon: 'archive' },
|
||||
],
|
||||
};
|
||||
return [...(menuOptions[props.status] || menuOptions['']), ...baseItems];
|
||||
});
|
||||
|
||||
const statusTextColor = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'archived':
|
||||
return '!text-slate-600 dark:!text-slate-200';
|
||||
case 'draft':
|
||||
return '!text-amber-700 dark:!text-amber-400';
|
||||
default:
|
||||
return '!text-teal-700 dark:!text-teal-400';
|
||||
}
|
||||
});
|
||||
|
||||
const statusText = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'archived':
|
||||
return 'Archived';
|
||||
case 'draft':
|
||||
return 'Draft';
|
||||
default:
|
||||
return 'Published';
|
||||
}
|
||||
});
|
||||
|
||||
const handleAction = () => {
|
||||
isOpen.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- TODO: Add i18n -->
|
||||
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||
<template>
|
||||
<CardLayout>
|
||||
<template #header>
|
||||
<div class="flex justify-between gap-1">
|
||||
<span class="text-base text-slate-900 dark:text-slate-50 line-clamp-1">
|
||||
{{ title }}
|
||||
</span>
|
||||
<div class="relative group">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-xs bg-slate-50 !font-normal group-hover:bg-slate-100/50 dark:group-hover:bg-slate-700/50 !h-6 dark:bg-slate-800 rounded-md border-0 !px-2 !py-0.5"
|
||||
:label="statusText"
|
||||
:class="statusTextColor"
|
||||
@click="isOpen = !isOpen"
|
||||
/>
|
||||
<OnClickOutside @trigger="isOpen = false">
|
||||
<DropdownMenu
|
||||
v-if="isOpen"
|
||||
:menu-items="menuItems"
|
||||
class="right-0 mt-2 xl:left-0 top-full"
|
||||
@action="handleAction"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="w-4 h-4 rounded-full bg-slate-100 dark:bg-slate-700" />
|
||||
<span class="text-sm text-slate-500 dark:text-slate-400">
|
||||
{{ author }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="block text-sm whitespace-nowrap text-slate-500 dark:text-slate-400"
|
||||
>
|
||||
{{ category }}
|
||||
</span>
|
||||
<div
|
||||
class="inline-flex items-center gap-1 text-slate-500 dark:text-slate-400 whitespace-nowrap"
|
||||
>
|
||||
<FluentIcon icon="eye-show" size="18" />
|
||||
<span class="text-sm"> {{ views }} views </span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm text-slate-600 dark:text-slate-400 line-clamp-1">
|
||||
{{ updatedAt }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</CardLayout>
|
||||
</template>
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
<script setup>
|
||||
import CategoryCard from './CategoryCard.vue';
|
||||
const categories = [
|
||||
{
|
||||
id: 'getting-started',
|
||||
title: '🚀 Getting started',
|
||||
description:
|
||||
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.',
|
||||
articlesCount: '5',
|
||||
},
|
||||
{
|
||||
id: 'marketing',
|
||||
title: '📈 Marketing',
|
||||
description: '',
|
||||
articlesCount: '4',
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||
<!-- eslint-disable vue/no-undef-components -->
|
||||
<template>
|
||||
<Story
|
||||
title="Components/HelpCenter/CategoryCard"
|
||||
:layout="{ type: 'grid', width: '800px' }"
|
||||
>
|
||||
<Variant title="Category Card">
|
||||
<div
|
||||
v-for="(category, index) in categories"
|
||||
:key="index"
|
||||
class="px-20 py-4 bg-white dark:bg-slate-900"
|
||||
>
|
||||
<CategoryCard
|
||||
:title="category.title"
|
||||
:description="category.description"
|
||||
:articles-count="category.articlesCount"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
articlesCount: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
|
||||
const isOpen = ref(false);
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
label: 'Edit',
|
||||
action: 'edit',
|
||||
icon: 'edit',
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
action: 'delete',
|
||||
icon: 'delete',
|
||||
},
|
||||
];
|
||||
|
||||
const description = computed(() => {
|
||||
return props.description ? props.description : 'No description added';
|
||||
});
|
||||
|
||||
const hasDescription = computed(() => {
|
||||
return props.description.length > 0;
|
||||
});
|
||||
|
||||
const handleClick = id => {
|
||||
emit('click', id);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const handleAction = action => {
|
||||
// TODO: Implement action
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- TODO: Add i18n -->
|
||||
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||
<template>
|
||||
<CardLayout @click="handleClick(id)">
|
||||
<template #header>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex justify-between w-full">
|
||||
<div class="flex items-center justify-start gap-2">
|
||||
<span
|
||||
class="text-base cursor-pointer group-hover/cardLayout:underline text-slate-900 dark:text-slate-50 line-clamp-1"
|
||||
>
|
||||
{{ title }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center justify-center h-6 px-2 py-1 text-xs text-center border rounded-lg text-slate-500 w-fit border-slate-200 dark:border-slate-800 dark:text-slate-400"
|
||||
>
|
||||
{{ articlesCount }} articles
|
||||
</span>
|
||||
</div>
|
||||
<div class="relative group" @click.stop>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
icon="more-vertical"
|
||||
class="w-8 z-60 group-hover:bg-slate-100 dark:group-hover:bg-slate-800"
|
||||
@click="isOpen = !isOpen"
|
||||
/>
|
||||
<OnClickOutside @trigger="isOpen = false">
|
||||
<DropdownMenu
|
||||
v-if="isOpen"
|
||||
:menu-items="menuItems"
|
||||
class="right-0 mt-1 xl:left-0 top-full z-60"
|
||||
@action="handleAction"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<span
|
||||
class="text-sm line-clamp-3"
|
||||
:class="
|
||||
hasDescription
|
||||
? 'text-slate-500 dark:text-slate-400'
|
||||
: 'text-slate-400 dark:text-slate-700'
|
||||
"
|
||||
>
|
||||
{{ description }}
|
||||
</span>
|
||||
</template>
|
||||
</CardLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
|
||||
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import PortalSwitcher from 'dashboard/components-next/HelpCenter/PortalSwitcher/PortalSwitcher.vue';
|
||||
|
||||
defineProps({
|
||||
header: {
|
||||
type: String,
|
||||
default: 'Chatwoot Help Center',
|
||||
},
|
||||
currentPage: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
totalItems: {
|
||||
type: Number,
|
||||
default: 100,
|
||||
},
|
||||
itemsPerPage: {
|
||||
type: Number,
|
||||
default: 25,
|
||||
},
|
||||
showHeaderTitle: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showPaginationFooter: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:currentPage']);
|
||||
|
||||
const showPortalSwitcher = ref(false);
|
||||
|
||||
const updateCurrentPage = page => {
|
||||
emit('update:currentPage', page);
|
||||
};
|
||||
const togglePortalSwitcher = () => {
|
||||
showPortalSwitcher.value = !showPortalSwitcher.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="flex flex-col w-full h-full overflow-hidden bg-white dark:bg-slate-900"
|
||||
>
|
||||
<header
|
||||
class="sticky top-0 z-10 px-6 pb-3 bg-white lg:px-0 dark:bg-slate-900"
|
||||
>
|
||||
<div class="w-full max-w-[900px] mx-auto">
|
||||
<div
|
||||
v-if="showHeaderTitle"
|
||||
class="flex items-center justify-start h-20 gap-2"
|
||||
>
|
||||
<span class="text-xl font-medium text-slate-900 dark:text-white">
|
||||
{{ header }}
|
||||
</span>
|
||||
<div class="relative group">
|
||||
<Button
|
||||
icon="more-vertical"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="group-hover:bg-slate-100 dark:group-hover:bg-slate-800"
|
||||
@click="togglePortalSwitcher"
|
||||
/>
|
||||
<OnClickOutside @trigger="showPortalSwitcher = false">
|
||||
<PortalSwitcher
|
||||
v-if="showPortalSwitcher"
|
||||
class="absolute left-0 top-9"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
</div>
|
||||
<slot name="header-actions" />
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
|
||||
<div class="w-full max-w-[900px] mx-auto py-3">
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</main>
|
||||
<footer
|
||||
v-if="showPaginationFooter"
|
||||
class="sticky bottom-0 z-10 px-4 pt-3 pb-4 bg-white dark:bg-slate-900"
|
||||
>
|
||||
<PaginationFooter
|
||||
:current-page="currentPage"
|
||||
:total-items="totalItems"
|
||||
:items-per-page="itemsPerPage"
|
||||
@update:current-page="updateCurrentPage"
|
||||
/>
|
||||
</footer>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup>
|
||||
import LocaleCard from './LocaleCard.vue';
|
||||
const locales = [
|
||||
{ name: 'English', isDefault: true, articleCount: 29, categoryCount: 5 },
|
||||
{ name: 'Spanish', isDefault: false, articleCount: 29, categoryCount: 5 },
|
||||
];
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||
<!-- eslint-disable vue/no-undef-components -->
|
||||
<template>
|
||||
<Story
|
||||
title="Components/HelpCenter/LocaleCard"
|
||||
:layout="{ type: 'grid', width: '800px' }"
|
||||
>
|
||||
<Variant title="Locale Card">
|
||||
<div class="px-10 py-4 bg-white dark:bg-slate-900">
|
||||
<div v-for="(locale, index) in locales" :key="index" class="px-20 py-2">
|
||||
<LocaleCard
|
||||
:locale="locale.name"
|
||||
:is-default="locale.isDefault"
|
||||
:article-count="locale.articleCount"
|
||||
:category-count="locale.categoryCount"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,103 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
defineProps({
|
||||
locale: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isDefault: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
articleCount: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
categoryCount: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const isOpen = ref(false);
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
label: 'Make default',
|
||||
action: 'default',
|
||||
icon: 'star-emphasis',
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
action: 'delete',
|
||||
icon: 'delete',
|
||||
},
|
||||
];
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const handleAction = action => {
|
||||
// TODO: Implement action
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- TODO: Add i18n -->
|
||||
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||
<template>
|
||||
<CardLayout class="ltr:pr-2 rtl:pl-2">
|
||||
<template #header>
|
||||
<div class="flex justify-between gap-2">
|
||||
<div class="flex items-center justify-start gap-2">
|
||||
<span
|
||||
class="text-sm font-medium text-slate-900 dark:text-slate-50 line-clamp-1"
|
||||
>
|
||||
{{ locale }}
|
||||
</span>
|
||||
<span
|
||||
v-if="isDefault"
|
||||
class="bg-slate-100 dark:bg-slate-800 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-woot-500 dark:text-woot-400 px-2 py-0.5"
|
||||
>
|
||||
Default
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<div class="flex items-center gap-4">
|
||||
<span
|
||||
class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap"
|
||||
>
|
||||
{{ articleCount }} articles
|
||||
</span>
|
||||
<div class="w-px h-3 bg-slate-75 dark:bg-slate-800" />
|
||||
<span
|
||||
class="text-sm text-slate-500 dark:text-slate-400 whitespace-nowrap"
|
||||
>
|
||||
{{ categoryCount }} categories
|
||||
</span>
|
||||
</div>
|
||||
<div class="relative group">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
icon="more-vertical"
|
||||
class="w-8 group-hover:bg-slate-100 dark:group-hover:bg-slate-800"
|
||||
@click="isOpen = !isOpen"
|
||||
/>
|
||||
<OnClickOutside @trigger="isOpen = false">
|
||||
<DropdownMenu
|
||||
v-if="isOpen"
|
||||
:menu-items="menuItems"
|
||||
class="right-0 mt-1 xl:left-0 top-full z-60 min-w-[147px]"
|
||||
@action="handleAction"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</CardLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue';
|
||||
|
||||
defineProps({
|
||||
articles: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul role="list" class="w-full h-full space-y-4">
|
||||
<ArticleCard
|
||||
v-for="article in articles"
|
||||
:key="article.title"
|
||||
:title="article.title"
|
||||
:status="article.status"
|
||||
:author="article.author"
|
||||
:category="article.category"
|
||||
:views="article.views"
|
||||
:updated-at="article.updatedAt"
|
||||
/>
|
||||
</ul>
|
||||
</template>
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
<script setup>
|
||||
import ArticlesPage from './ArticlesPage.vue';
|
||||
|
||||
const articles = [
|
||||
{
|
||||
title: "How to get an SSL certificate for your Help Center's custom domain",
|
||||
status: 'draft',
|
||||
updatedAt: '2 days ago',
|
||||
author: 'Michael',
|
||||
category: '⚡️ Marketing',
|
||||
views: 3400,
|
||||
},
|
||||
{
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: '1 week ago',
|
||||
author: 'John',
|
||||
category: '🛠️ Development',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
title: 'Best practices for organizing your Help Center content',
|
||||
status: 'archived',
|
||||
updatedAt: '3 days ago',
|
||||
author: 'Fernando',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
title: 'Customizing the appearance of your Help Center',
|
||||
status: '',
|
||||
updatedAt: '5 days ago',
|
||||
author: 'Jane',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
title: 'Best practices for organizing your Help Center content',
|
||||
status: 'archived',
|
||||
updatedAt: '3 days ago',
|
||||
author: 'Fernando',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
title: 'Customizing the appearance of your Help Center',
|
||||
status: '',
|
||||
updatedAt: '5 days ago',
|
||||
author: 'Jane',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
title: 'Best practices for organizing your Help Center content',
|
||||
status: 'archived',
|
||||
updatedAt: '3 days ago',
|
||||
author: 'Fernando',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Pages/HelpCenter/ArticlesPage" :layout="{ type: 'single' }">
|
||||
<Variant title="All Articles">
|
||||
<div class="w-full min-h-screen bg-white dark:bg-slate-900">
|
||||
<ArticlesPage :articles="articles" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
<script setup>
|
||||
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ArticleList from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue';
|
||||
|
||||
defineProps({
|
||||
articles: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const tabs = [
|
||||
{ label: 'All articles', count: 24 },
|
||||
{ label: 'Mine', count: 13 },
|
||||
{ label: 'Draft', count: 5 },
|
||||
{ label: 'Archived', count: 11 },
|
||||
];
|
||||
// TODO: remove comments
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const handleTabChange = tab => {
|
||||
// TODO: Implement tab change logic
|
||||
};
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const handlePageChange = page => {
|
||||
// TODO: Implement page change logic
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HelpCenterLayout
|
||||
:current-page="1"
|
||||
:total-items="100"
|
||||
:items-per-page="10"
|
||||
@update:current-page="handlePageChange"
|
||||
>
|
||||
<template #header-actions>
|
||||
<div class="flex items-end justify-between">
|
||||
<div class="flex flex-col items-start w-full gap-2 lg:flex-row">
|
||||
<TabBar
|
||||
:tabs="tabs"
|
||||
:initial-active-tab="1"
|
||||
@tab-changed="handleTabChange"
|
||||
/>
|
||||
<div class="flex items-start justify-between w-full gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
label="English"
|
||||
size="sm"
|
||||
icon-position="right"
|
||||
icon="chevron-lucide-down"
|
||||
icon-lib="lucide"
|
||||
variant="secondary"
|
||||
/>
|
||||
<Button
|
||||
label="All categories"
|
||||
size="sm"
|
||||
icon-position="right"
|
||||
icon="chevron-lucide-down"
|
||||
icon-lib="lucide"
|
||||
variant="secondary"
|
||||
/>
|
||||
</div>
|
||||
<Button label="New article" icon="add" size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<ArticleList :articles="articles" />
|
||||
</template>
|
||||
</HelpCenterLayout>
|
||||
</template>
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
<script setup>
|
||||
import CategoriesPage from './CategoriesPage.vue';
|
||||
|
||||
const categories = [
|
||||
{
|
||||
id: 'getting-started',
|
||||
title: '🚀 Getting started',
|
||||
description:
|
||||
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.',
|
||||
articlesCount: '2',
|
||||
articles: [
|
||||
{
|
||||
variant: 'Draft article',
|
||||
title:
|
||||
"How to get an SSL certificate for your Help Center's custom domain",
|
||||
status: 'draft',
|
||||
updatedAt: '2 days ago',
|
||||
author: 'Michael',
|
||||
category: '⚡️ Marketing',
|
||||
views: 3400,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: '1 week ago',
|
||||
author: 'John',
|
||||
category: '🛠️ Development',
|
||||
views: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'marketing',
|
||||
title: 'Marketing',
|
||||
description:
|
||||
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support.',
|
||||
articlesCount: '4',
|
||||
articles: [
|
||||
{
|
||||
variant: 'Draft article',
|
||||
title:
|
||||
"How to get an SSL certificate for your Help Center's custom domain",
|
||||
status: 'draft',
|
||||
updatedAt: '2 days ago',
|
||||
author: 'Michael',
|
||||
category: '⚡️ Marketing',
|
||||
views: 3400,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: '1 week ago',
|
||||
author: 'John',
|
||||
category: '🛠️ Development',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
variant: 'Archived article',
|
||||
title: 'Best practices for organizing your Help Center content',
|
||||
status: 'archived',
|
||||
updatedAt: '3 days ago',
|
||||
author: 'Fernando',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Customizing the appearance of your Help Center',
|
||||
status: '',
|
||||
updatedAt: '5 days ago',
|
||||
author: 'Jane',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'development',
|
||||
title: 'Development',
|
||||
description: '',
|
||||
articlesCount: '5',
|
||||
articles: [
|
||||
{
|
||||
variant: 'Draft article',
|
||||
title:
|
||||
"How to get an SSL certificate for your Help Center's custom domain",
|
||||
status: 'draft',
|
||||
updatedAt: '2 days ago',
|
||||
author: 'Michael',
|
||||
category: '⚡️ Marketing',
|
||||
views: 3400,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: '1 week ago',
|
||||
author: 'John',
|
||||
category: '🛠️ Development',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
variant: 'Archived article',
|
||||
title: 'Best practices for organizing your Help Center content',
|
||||
status: 'archived',
|
||||
updatedAt: '3 days ago',
|
||||
author: 'Fernando',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
variant: 'Archived article',
|
||||
title: 'Best practices for organizing your Help Center content',
|
||||
status: 'archived',
|
||||
updatedAt: '3 days ago',
|
||||
author: 'Fernando',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Customizing the appearance of your Help Center',
|
||||
status: '',
|
||||
updatedAt: '5 days ago',
|
||||
author: 'Jane',
|
||||
category: '💰 Finance',
|
||||
views: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'roadmap',
|
||||
title: '🛣️ Roadmap',
|
||||
description:
|
||||
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.',
|
||||
articlesCount: '3',
|
||||
articles: [
|
||||
{
|
||||
variant: 'Draft article',
|
||||
title:
|
||||
"How to get an SSL certificate for your Help Center's custom domain",
|
||||
status: 'draft',
|
||||
updatedAt: '2 days ago',
|
||||
author: 'Michael',
|
||||
category: '⚡️ Marketing',
|
||||
views: 3400,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: '1 week ago',
|
||||
author: 'John',
|
||||
category: '🛠️ Development',
|
||||
views: 400,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: '1 week ago',
|
||||
author: 'John',
|
||||
category: '🛠️ Development',
|
||||
views: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'finance',
|
||||
title: '💰 Finance',
|
||||
description:
|
||||
'Learn how to use Chatwoot effectively and make the most of its features to enhance customer support and engagement.',
|
||||
articlesCount: '2',
|
||||
articles: [
|
||||
{
|
||||
variant: 'Draft article',
|
||||
title:
|
||||
"How to get an SSL certificate for your Help Center's custom domain",
|
||||
status: 'draft',
|
||||
updatedAt: '2 days ago',
|
||||
author: 'Michael',
|
||||
category: '⚡️ Marketing',
|
||||
views: 3400,
|
||||
},
|
||||
{
|
||||
variant: 'Published article',
|
||||
title: 'Setting up your first Help Center portal',
|
||||
status: '',
|
||||
updatedAt: '1 week ago',
|
||||
author: 'John',
|
||||
category: '🛠️ Development',
|
||||
views: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Pages/HelpCenter/CategoryPage" :layout="{ type: 'single' }">
|
||||
<Variant title="All Categories">
|
||||
<div class="w-full min-h-screen bg-white dark:bg-slate-900">
|
||||
<CategoriesPage :categories="categories" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
// import { OnClickOutside } from '@vueuse/components';
|
||||
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
||||
import CategoryList from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryList.vue';
|
||||
import ArticleList from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue';
|
||||
// import EditCategory from 'dashboard/playground/HelpCenter/components/EditCategory.vue';
|
||||
|
||||
const props = defineProps({
|
||||
categories: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const selectedCategory = ref(null);
|
||||
// const showEditCategory = ref(false);
|
||||
|
||||
// const openEditCategory = () => {
|
||||
// showEditCategory.value = true;
|
||||
// };
|
||||
// const closeEditCategory = () => {
|
||||
// showEditCategory.value = false;
|
||||
// };
|
||||
|
||||
const breadcrumbItems = computed(() => {
|
||||
const items = [{ label: 'Categories (en-US)', link: '#' }];
|
||||
if (selectedCategory.value) {
|
||||
items.push({
|
||||
label: selectedCategory.value.title,
|
||||
count: selectedCategory.value.articles.length,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
});
|
||||
const openCategoryArticles = id => {
|
||||
selectedCategory.value = props.categories.find(
|
||||
category => category.id === id
|
||||
);
|
||||
};
|
||||
const resetCategory = () => {
|
||||
selectedCategory.value = null;
|
||||
};
|
||||
const displayedArticles = computed(() => {
|
||||
return selectedCategory.value ? selectedCategory.value.articles : [];
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||
<template>
|
||||
<HelpCenterLayout :show-pagination-footer="false">
|
||||
<template #header-actions>
|
||||
<div class="flex items-center justify-between">
|
||||
<div v-if="!selectedCategory" class="flex items-center gap-4">
|
||||
<Button
|
||||
label="English"
|
||||
size="sm"
|
||||
icon-position="right"
|
||||
icon="chevron-lucide-down"
|
||||
icon-lib="lucide"
|
||||
variant="secondary"
|
||||
/>
|
||||
<div
|
||||
class="w-px h-3.5 rounded my-auto bg-slate-75 dark:bg-slate-800"
|
||||
/>
|
||||
<span class="text-sm font-medium text-slate-800 dark:text-slate-100">
|
||||
{{ categories.length }} categories
|
||||
</span>
|
||||
</div>
|
||||
<Breadcrumb v-else :items="breadcrumbItems" @click="resetCategory" />
|
||||
<Button
|
||||
v-if="!selectedCategory"
|
||||
label="New category"
|
||||
icon="add"
|
||||
size="sm"
|
||||
/>
|
||||
<div v-else class="relative">
|
||||
<Button
|
||||
label="Edit category"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@click="openEditCategory"
|
||||
/>
|
||||
<!-- <OnClickOutside @trigger="closeEditCategory">
|
||||
<EditCategory v-if="showEditCategory" @close="closeEditCategory" />
|
||||
</OnClickOutside> -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<CategoryList
|
||||
v-if="!selectedCategory"
|
||||
:categories="categories"
|
||||
@click="openCategoryArticles"
|
||||
/>
|
||||
<ArticleList v-else :articles="displayedArticles" />
|
||||
</template>
|
||||
</HelpCenterLayout>
|
||||
</template>
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<script setup>
|
||||
import CategoryCard from 'dashboard/components-next/HelpCenter/CategoryCard/CategoryCard.vue';
|
||||
|
||||
defineProps({
|
||||
categories: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
|
||||
const handleClick = id => {
|
||||
emit('click', id);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul role="list" class="grid w-full h-full grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<CategoryCard
|
||||
v-for="category in categories"
|
||||
:id="category.id"
|
||||
:key="category.title"
|
||||
:title="category.title"
|
||||
:description="category.description"
|
||||
:articles-count="category.articlesCount"
|
||||
@click="handleClick(category.id)"
|
||||
/>
|
||||
</ul>
|
||||
</template>
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
|
||||
|
||||
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
import FullEditor from 'dashboard/components/widgets/WootWriter/FullEditor.vue';
|
||||
|
||||
const { article } = defineProps({
|
||||
article: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['saveArticle']);
|
||||
|
||||
const saveArticle = debounce(value => emit('saveArticle', value), 400, false);
|
||||
|
||||
const articleTitle = computed({
|
||||
get: () => article.title,
|
||||
set: title => {
|
||||
saveArticle({ title });
|
||||
},
|
||||
});
|
||||
|
||||
const articleContent = computed({
|
||||
get: () => article.content,
|
||||
set: content => {
|
||||
saveArticle({ content });
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||
<template>
|
||||
<HelpCenterLayout :show-header-title="false" :show-pagination-footer="false">
|
||||
<template #header-actions>
|
||||
<div class="flex items-center justify-between h-20">
|
||||
<Button
|
||||
label="Back to articles"
|
||||
icon="chevron-lucide-left"
|
||||
icon-lib="lucide"
|
||||
variant="link"
|
||||
text-variant="info"
|
||||
size="sm"
|
||||
/>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-xs font-medium text-slate-500 dark:text-slate-400">
|
||||
Saved
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button label="Preview" variant="secondary" size="sm" />
|
||||
<Button
|
||||
label="Publish"
|
||||
icon="chevron-lucide-down"
|
||||
icon-position="right"
|
||||
icon-lib="lucide"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="flex flex-col gap-3 pl-4 mb-3 rtl:pr-3 rtl:pl-0">
|
||||
<TextArea
|
||||
v-model="articleTitle"
|
||||
class="h-12"
|
||||
custom-text-area-class="border-0 !text-[32px] !bg-transparent !py-0 !px-0 !h-auto !leading-[48px] !font-medium !tracking-[0.2px]"
|
||||
placeholder="Title"
|
||||
/>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-5 h-5 rounded-full bg-slate-100 dark:bg-slate-700" />
|
||||
<span class="text-sm text-slate-500 dark:text-slate-400">
|
||||
John Doe
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-px h-3 bg-slate-50 dark:bg-slate-800" />
|
||||
<Button
|
||||
label="Uncategorized"
|
||||
icon="play-shape"
|
||||
variant="ghost"
|
||||
class="!px-2 font-normal"
|
||||
text-variant="info"
|
||||
/>
|
||||
<div class="w-px h-3 bg-slate-50 dark:bg-slate-800" />
|
||||
<Button
|
||||
label="More properties"
|
||||
icon="add"
|
||||
variant="ghost"
|
||||
class="!px-2 font-normal"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FullEditor
|
||||
v-model="articleContent"
|
||||
class="py-0 pb-10 pl-4 rtl:pr-4 rtl:pl-0 h-fit"
|
||||
placeholder="Write something"
|
||||
:enabled-menu-options="ARTICLE_EDITOR_MENU_OPTIONS"
|
||||
/>
|
||||
</template>
|
||||
</HelpCenterLayout>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
::v-deep {
|
||||
.ProseMirror .empty-node::before {
|
||||
@apply text-slate-200 dark:text-slate-500 text-base;
|
||||
}
|
||||
|
||||
.ProseMirror-menubar-wrapper {
|
||||
.ProseMirror-woot-style {
|
||||
@apply min-h-[15rem] max-h-full;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-menubar {
|
||||
display: none; // Hide by default
|
||||
}
|
||||
|
||||
.editor-root .has-selection {
|
||||
.ProseMirror-menubar {
|
||||
@apply h-8 rounded-lg !px-2 z-50 bg-slate-50 dark:bg-slate-800 items-center gap-4 ml-0 mb-0 shadow-md border border-slate-75 dark:border-slate-700/50;
|
||||
display: flex;
|
||||
top: var(--selection-top, auto) !important;
|
||||
left: var(--selection-left, 0) !important;
|
||||
width: fit-content !important;
|
||||
position: absolute !important;
|
||||
|
||||
.ProseMirror-menuitem {
|
||||
@apply mr-0;
|
||||
.ProseMirror-icon {
|
||||
@apply p-0 mt-1 !mr-0;
|
||||
svg {
|
||||
width: 20px !important;
|
||||
height: 20px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue';
|
||||
|
||||
defineProps({
|
||||
locales: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul role="list" class="w-full h-full space-y-4">
|
||||
<LocaleCard
|
||||
v-for="(locale, index) in locales"
|
||||
:key="index"
|
||||
:locale="locale.name"
|
||||
:is-default="locale.isDefault"
|
||||
:article-count="locale.articleCount"
|
||||
:category-count="locale.categoryCount"
|
||||
/>
|
||||
</ul>
|
||||
</template>
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
<script setup>
|
||||
import LocalesPage from './LocalesPage.vue';
|
||||
|
||||
const locales = [
|
||||
{
|
||||
name: 'English (en-US)',
|
||||
isDefault: true,
|
||||
articleCount: 5,
|
||||
categoryCount: 5,
|
||||
},
|
||||
{
|
||||
name: 'Spanish (es-ES)',
|
||||
isDefault: false,
|
||||
articleCount: 20,
|
||||
categoryCount: 10,
|
||||
},
|
||||
{
|
||||
name: 'English (en-UK)',
|
||||
isDefault: false,
|
||||
articleCount: 15,
|
||||
categoryCount: 7,
|
||||
},
|
||||
{
|
||||
name: 'Malay (ms-MY)',
|
||||
isDefault: false,
|
||||
articleCount: 15,
|
||||
categoryCount: 7,
|
||||
},
|
||||
{
|
||||
name: 'Malayalam (ml-IN)',
|
||||
isDefault: false,
|
||||
articleCount: 10,
|
||||
categoryCount: 5,
|
||||
},
|
||||
{
|
||||
name: 'Hindi (hi-IN)',
|
||||
isDefault: false,
|
||||
articleCount: 15,
|
||||
categoryCount: 7,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Pages/HelpCenter/LocalePage" :layout="{ type: 'single' }">
|
||||
<Variant title="All Locales">
|
||||
<div class="w-full min-h-screen bg-white dark:bg-slate-900">
|
||||
<LocalesPage :locales="locales" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import LocaleList from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue';
|
||||
|
||||
const props = defineProps({
|
||||
locales: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const localeCount = computed(() => props.locales?.length);
|
||||
|
||||
// TODO: remove comments
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const handleTabChange = tab => {
|
||||
// TODO: Implement tab change logic
|
||||
};
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const handlePageChange = page => {
|
||||
// TODO: Implement page change logic
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HelpCenterLayout :show-pagination-footer="false">
|
||||
<template #header-actions>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-sm font-medium text-slate-800 dark:text-slate-100">
|
||||
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALES_COUNT', localeCount) }}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
:label="$t('HELP_CENTER.LOCALES_PAGE.NEW_LOCALE_BUTTON_TEXT')"
|
||||
icon="add"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<LocaleList :locales="locales" />
|
||||
</template>
|
||||
</HelpCenterLayout>
|
||||
</template>
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<script setup>
|
||||
import PortalSettings from './PortalSettings.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Pages/HelpCenter/PortalSettings" :layout="{ type: 'single' }">
|
||||
<Variant title="Default">
|
||||
<div class="w-[1000px] min-h-screen bg-white dark:bg-slate-900">
|
||||
<PortalSettings />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
<script setup>
|
||||
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
const handleUploadAvatar = () => {};
|
||||
</script>
|
||||
|
||||
<!-- TODO: Add i18n -->
|
||||
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||
<template>
|
||||
<HelpCenterLayout :show-pagination-footer="false">
|
||||
<template #content>
|
||||
<div class="flex flex-col w-full gap-10 max-w-[640px] pt-2 pb-8">
|
||||
<div class="flex flex-col w-full gap-4">
|
||||
<div class="flex flex-col w-full gap-2">
|
||||
<label
|
||||
class="mb-0.5 text-sm font-medium text-gray-900 dark:text-gray-50"
|
||||
>
|
||||
Avatar
|
||||
</label>
|
||||
<Avatar
|
||||
label="Avatar"
|
||||
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Amaya"
|
||||
class="bg-ruby-300 dark:bg-ruby-400"
|
||||
@upload="handleUploadAvatar"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col w-full gap-2">
|
||||
<div class="flex justify-between w-full h-10 gap-2 py-1">
|
||||
<label
|
||||
class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 dark:text-slate-50"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<Input placeholder="Name" class="w-[432px]" />
|
||||
</div>
|
||||
<div class="flex justify-between w-full h-10 gap-2 py-1">
|
||||
<label
|
||||
class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 dark:text-slate-50"
|
||||
>
|
||||
Header text
|
||||
</label>
|
||||
<Input placeholder="Header text" class="w-[432px]" />
|
||||
</div>
|
||||
<div class="flex justify-between w-full h-10 gap-2 py-1">
|
||||
<label
|
||||
class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 dark:text-slate-50"
|
||||
>
|
||||
Page title
|
||||
</label>
|
||||
<Input placeholder="Page title" class="w-[432px]" />
|
||||
</div>
|
||||
<div class="flex justify-between w-full h-10 gap-2 py-1">
|
||||
<label
|
||||
class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 dark:text-slate-50"
|
||||
>
|
||||
Widget color
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex justify-end w-full gap-2 py-2">
|
||||
<Button label="Save changes" size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full h-px bg-slate-50 dark:bg-slate-800/50" />
|
||||
</div>
|
||||
<div class="flex flex-col w-full gap-6">
|
||||
<div class="flex flex-col w-full gap-6">
|
||||
<h6 class="text-base font-medium text-slate-900 dark:text-slate-50">
|
||||
Configuration
|
||||
</h6>
|
||||
<div class="flex flex-col w-full gap-4">
|
||||
<div class="flex justify-between w-full gap-2 py-1">
|
||||
<InlineInput
|
||||
placeholder="Slug"
|
||||
label="Slug:"
|
||||
custom-label-class="min-w-[100px]"
|
||||
custom-input-class="!w-[430px]"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-between w-full gap-2 py-1">
|
||||
<InlineInput
|
||||
placeholder="Custom domain"
|
||||
label="Custom domain:"
|
||||
custom-label-class="min-w-[100px]"
|
||||
custom-input-class="!w-[430px]"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-between w-full gap-2 py-1">
|
||||
<InlineInput
|
||||
placeholder="Home page link"
|
||||
label="Home page link:"
|
||||
custom-label-class="min-w-[100px]"
|
||||
custom-input-class="!w-[430px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end w-full gap-3 py-4">
|
||||
<Button label="Edit configuration" size="sm" variant="secondary" />
|
||||
<Button
|
||||
label="Delete Test-Help Center"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</HelpCenterLayout>
|
||||
</template>
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
<script setup>
|
||||
import PortalSwitcher from './PortalSwitcher.vue';
|
||||
|
||||
const portals = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Chatwoot Help Center',
|
||||
articles: 67,
|
||||
domain: 'chatwoot.help',
|
||||
slug: 'help-center',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Chatwoot Handbook',
|
||||
articles: 42,
|
||||
domain: 'chatwoot.help',
|
||||
slug: 'handbook',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Developer Documentation',
|
||||
articles: 89,
|
||||
domain: 'dev.chatwoot.com',
|
||||
slug: 'docs',
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/HelpCenter/PortalSwitcher"
|
||||
:layout="{ type: 'grid', width: '510px' }"
|
||||
>
|
||||
<Variant title="Portal Switcher">
|
||||
<div class="h-[500px] p-4 bg-slate-100 dark:bg-slate-900">
|
||||
<PortalSwitcher
|
||||
:portals="portals"
|
||||
header="Choose a Portal"
|
||||
description="Select from available help center portals"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
portals: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Chatwoot Help Center',
|
||||
articles: 67,
|
||||
domain: 'chatwoot.help',
|
||||
slug: 'help-center',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Chatwoot Handbook',
|
||||
articles: 42,
|
||||
domain: 'chatwoot.help',
|
||||
slug: 'handbook',
|
||||
},
|
||||
],
|
||||
},
|
||||
header: {
|
||||
type: String,
|
||||
default: 'Portals',
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: 'Create and manage multiple portals',
|
||||
},
|
||||
});
|
||||
|
||||
const selectedPortal = ref(1);
|
||||
|
||||
const handlePortalChange = id => {
|
||||
selectedPortal.value = id;
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- TODO: Add i18n -->
|
||||
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||
<template>
|
||||
<div
|
||||
class="pt-5 pb-3 bg-white z-50 dark:bg-slate-800 absolute w-[440px] rounded-xl shadow-md flex flex-col gap-4"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4 px-6 pb-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="text-base font-medium text-slate-900 dark:text-slate-50">
|
||||
{{ header }}
|
||||
</h2>
|
||||
<p class="text-sm text-slate-600 dark:text-slate-300">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<Button label="New portal" variant="secondary" icon="add" size="sm" />
|
||||
</div>
|
||||
<div v-if="portals.length > 0" class="flex flex-col gap-3">
|
||||
<template v-for="(portal, index) in portals" :key="portal.id">
|
||||
<div class="flex flex-col gap-2 px-6 py-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
:id="portal.id"
|
||||
v-model="selectedPortal"
|
||||
type="radio"
|
||||
:value="portal.id"
|
||||
class="mr-3"
|
||||
@change="handlePortalChange(portal.id)"
|
||||
/>
|
||||
<label
|
||||
:for="portal.id"
|
||||
class="text-sm font-medium text-slate-900 dark:text-slate-100"
|
||||
>
|
||||
{{ portal.name }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="w-4 h-4 rounded-full bg-slate-100 dark:bg-slate-700" />
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-2 py-1 text-sm">
|
||||
<span class="text-slate-600 dark:text-slate-400">
|
||||
articles:
|
||||
<span class="text-slate-800 dark:text-slate-200">
|
||||
{{ portal.articles }}
|
||||
</span>
|
||||
</span>
|
||||
<div class="w-px h-3 bg-slate-50 dark:bg-slate-700" />
|
||||
<span class="text-slate-600 dark:text-slate-400">
|
||||
domain:
|
||||
<span class="text-slate-800 dark:text-slate-200">
|
||||
{{ portal.domain }}
|
||||
</span>
|
||||
</span>
|
||||
<div class="w-px h-3 bg-slate-50 dark:bg-slate-700" />
|
||||
<span class="text-slate-600 dark:text-slate-400">
|
||||
slug:
|
||||
<span class="text-slate-800 dark:text-slate-200">
|
||||
{{ portal.slug }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="index < portals.length - 1 && portals.length > 1"
|
||||
class="w-full h-px bg-slate-50 dark:bg-slate-700/50"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup>
|
||||
import Avatar from './Avatar.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Components/Avatar" :layout="{ type: 'grid', width: '400' }">
|
||||
<Variant title="Default">
|
||||
<div class="p-4 bg-white dark:bg-slate-900">
|
||||
<Avatar
|
||||
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Amaya"
|
||||
class="bg-ruby-300 dark:bg-ruby-900"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Different Sizes">
|
||||
<div class="flex flex-wrap gap-4 p-4 bg-white dark:bg-slate-900">
|
||||
<Avatar
|
||||
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Felix"
|
||||
:size="48"
|
||||
class="bg-green-300 dark:bg-green-900"
|
||||
/>
|
||||
<Avatar
|
||||
:size="72"
|
||||
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Jade"
|
||||
class="bg-indigo-300 dark:bg-indigo-900"
|
||||
/>
|
||||
<Avatar
|
||||
src="https://api.dicebear.com/9.x/avataaars/svg?seed=Emery"
|
||||
:size="96"
|
||||
class="bg-woot-300 dark:bg-woot-900"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
default: 72,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['upload']);
|
||||
|
||||
const avatarSize = computed(() => `${props.size}px`);
|
||||
const iconSize = computed(() => `${props.size / 2}px`);
|
||||
|
||||
const handleUploadAvatar = () => {
|
||||
emit('upload');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex flex-col items-center gap-2 select-none rounded-xl group/avatar"
|
||||
:style="{
|
||||
width: avatarSize,
|
||||
height: avatarSize,
|
||||
}"
|
||||
>
|
||||
<img
|
||||
v-if="src"
|
||||
:src="props.src"
|
||||
alt="avatar"
|
||||
class="w-full h-full shadow-sm rounded-xl"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center invisible w-full h-full transition-all duration-500 ease-in-out opacity-0 rounded-xl dark:bg-slate-900/50 bg-slate-900/20 group-hover/avatar:visible group-hover/avatar:opacity-100"
|
||||
@click="handleUploadAvatar"
|
||||
>
|
||||
<FluentIcon
|
||||
icon="upload-lucide"
|
||||
icon-lib="lucide"
|
||||
:size="iconSize"
|
||||
class="text-white dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import Breadcrumb from './Breadcrumb.vue';
|
||||
|
||||
const singleItem = ref([{ label: 'Home', link: '#' }]);
|
||||
const twoItems = ref([
|
||||
{ label: 'Home', link: '#' },
|
||||
{ label: 'Categories', link: '#' },
|
||||
]);
|
||||
const threeItems = ref([
|
||||
{ label: 'Home', link: '#' },
|
||||
{ label: 'Categories', link: '#' },
|
||||
{ label: 'Marketing', count: 6 },
|
||||
]);
|
||||
const longBreadcrumb = ref([
|
||||
{ label: 'Home', link: '#' },
|
||||
{ label: 'Categories', link: '#' },
|
||||
{ label: 'Marketing', link: '#' },
|
||||
{ label: 'Digital', link: '#' },
|
||||
{ label: 'Social Media', count: 12 },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||
<!-- eslint-disable vue/no-undef-components -->
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Breadcrumb"
|
||||
:layout="{ type: 'grid', width: '800px' }"
|
||||
>
|
||||
<Variant title="Single Item">
|
||||
<div class="w-full p-4 bg-white dark:bg-slate-900">
|
||||
<Breadcrumb :items="singleItem" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Two Items">
|
||||
<div class="w-full p-4 bg-white dark:bg-slate-900">
|
||||
<Breadcrumb :items="twoItems" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Three Items with Count">
|
||||
<div class="w-full p-4 bg-white dark:bg-slate-900">
|
||||
<Breadcrumb :items="threeItems" count-label="articles" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Long Breadcrumb">
|
||||
<div class="w-full p-4 bg-white dark:bg-slate-900">
|
||||
<Breadcrumb :items="longBreadcrumb" count-label="articles" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="RTL Support">
|
||||
<div dir="rtl">
|
||||
<div class="w-full p-4 bg-white dark:bg-slate-900">
|
||||
<Breadcrumb :items="threeItems" count-label="articles" />
|
||||
</div>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup>
|
||||
import { defineProps } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
|
||||
|
||||
defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
validator: value => {
|
||||
return value.every(
|
||||
item =>
|
||||
typeof item.label === 'string' &&
|
||||
(item.link === undefined || typeof item.link === 'string') &&
|
||||
(item.count === undefined || typeof item.count === 'number')
|
||||
);
|
||||
},
|
||||
},
|
||||
countLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav :aria-label="t('BREADCRUMB.ARIA_LABEL')" class="flex items-center h-8">
|
||||
<ol class="flex items-center mb-0">
|
||||
<li
|
||||
v-for="(item, index) in items"
|
||||
:key="index"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<template v-if="index === items.length - 1">
|
||||
<span class="text-sm text-slate-900 dark:text-slate-50">
|
||||
{{
|
||||
`${item.label}${item.count ? ` (${item.count} ${countLabel})` : ''}`
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
<a
|
||||
v-else
|
||||
:href="item.link"
|
||||
class="text-sm transition-colors duration-200 text-slate-300 dark:text-slate-500 hover:text-slate-700 dark:hover:text-slate-100"
|
||||
>
|
||||
{{ item.label }}
|
||||
</a>
|
||||
<FluentIcon
|
||||
v-if="index < items.length - 1"
|
||||
icon="chevron-lucide-right"
|
||||
size="18"
|
||||
icon-lib="lucide"
|
||||
class="flex-shrink-0 text-slate-300 dark:text-slate-500 ltr:mr-3 rtl:mr-0 rtl:ml-3"
|
||||
/>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -0,0 +1,126 @@
|
||||
<script setup>
|
||||
import Button from './Button.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Components/Button" :layout="{ type: 'grid', width: '400' }">
|
||||
<Variant title="Default">
|
||||
<div class="p-4 bg-white dark:bg-slate-900">
|
||||
<Button label="Default Button" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Disabled">
|
||||
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
|
||||
<Button label="Disabled" disabled />
|
||||
<Button label="Disabled" variant="outline" disabled />
|
||||
<Button label="Disabled" disabled icon="delete" variant="outline" />
|
||||
<Button
|
||||
label="Disabled"
|
||||
disabled
|
||||
icon="delete"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
/>
|
||||
<Button
|
||||
label="Disabled"
|
||||
disabled
|
||||
icon="delete"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
/>
|
||||
<Button
|
||||
label="Disabled"
|
||||
disabled
|
||||
icon="delete"
|
||||
variant="link"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Disabled with icon">
|
||||
<div class="p-4 bg-white dark:bg-slate-900">
|
||||
<Button label="Disabled Button" icon="emoji-add" disabled />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Different variant">
|
||||
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
|
||||
<Button label="Default" variant="default" />
|
||||
<Button label="Destructive" variant="destructive" />
|
||||
<Button label="Outline" variant="outline" />
|
||||
<Button label="Secondary" variant="secondary" />
|
||||
<Button label="Ghost" variant="ghost" />
|
||||
<Button label="Link" variant="link" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Different variant with icon only">
|
||||
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
|
||||
<Button icon="emoji-add" variant="default" />
|
||||
<Button icon="emoji-add" variant="destructive" />
|
||||
<Button icon="emoji-add" variant="outline" />
|
||||
<Button icon="emoji-add" variant="secondary" />
|
||||
<Button icon="emoji-add" variant="ghost" />
|
||||
<Button icon="emoji-add" variant="link" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Different size">
|
||||
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
|
||||
<Button label="Default" />
|
||||
<Button label="Large" size="lg" />
|
||||
<Button label="Small" size="sm" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Different text variant">
|
||||
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
|
||||
<Button label="Default" text-variant="default" variant="outline" />
|
||||
<Button label="Success" text-variant="success" variant="outline" />
|
||||
<Button label="Warning" text-variant="warning" variant="outline" />
|
||||
<Button label="Danger" text-variant="danger" variant="outline" />
|
||||
<Button label="Info" text-variant="info" variant="outline" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Button with left icon with different sizes and icon only">
|
||||
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
|
||||
<Button label="Default" icon="emoji-add" icon-position="left" />
|
||||
<Button
|
||||
label="Default LG"
|
||||
icon="emoji-add"
|
||||
icon-position="left"
|
||||
size="lg"
|
||||
/>
|
||||
<Button
|
||||
label="Default SM"
|
||||
icon="emoji-add"
|
||||
icon-position="left"
|
||||
size="sm"
|
||||
/>
|
||||
<Button icon="emoji-add" size="icon" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Button with right icon with different sizes and icon only">
|
||||
<div class="flex flex-wrap gap-2 p-4 bg-white dark:bg-slate-900">
|
||||
<Button label="Default" icon="emoji-add" icon-position="right" />
|
||||
<Button
|
||||
label="Default LG"
|
||||
icon="emoji-add"
|
||||
icon-position="right"
|
||||
size="lg"
|
||||
/>
|
||||
<Button
|
||||
label="Default SM"
|
||||
icon="emoji-add"
|
||||
icon-position="right"
|
||||
size="sm"
|
||||
/>
|
||||
<Button icon="emoji-add" size="icon" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,130 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
validator: value =>
|
||||
[
|
||||
'default',
|
||||
'destructive',
|
||||
'outline',
|
||||
'secondary',
|
||||
'ghost',
|
||||
'link',
|
||||
].includes(value),
|
||||
},
|
||||
textVariant: {
|
||||
type: String,
|
||||
default: '',
|
||||
validator: value =>
|
||||
['', 'default', 'success', 'warning', 'danger', 'info'].includes(value),
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
validator: value => ['default', 'sm', 'lg', 'icon'].includes(value),
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
iconPosition: {
|
||||
type: String,
|
||||
default: 'left',
|
||||
validator: value => ['left', 'right'].includes(value),
|
||||
},
|
||||
iconLib: {
|
||||
type: String,
|
||||
default: 'fluent',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
|
||||
const buttonVariants = {
|
||||
variant: {
|
||||
default:
|
||||
'bg-woot-500 dark:bg-woot-500 text-white dark:text-white hover:bg-woot-600 dark:hover:bg-woot-600',
|
||||
destructive:
|
||||
'bg-ruby-700 dark:bg-ruby-700 text-white dark:text-white hover:bg-ruby-800 dark:hover:bg-ruby-800',
|
||||
outline:
|
||||
'border border-slate-200 dark:border-slate-700/50 hover:border-slate-300 dark:hover:border-slate-600',
|
||||
secondary:
|
||||
'bg-slate-50 text-slate-900 dark:bg-slate-700/50 dark:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-600',
|
||||
ghost:
|
||||
'text-slate-900 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800',
|
||||
link: 'text-woot-500 underline-offset-4 hover:underline dark:hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-8 px-3',
|
||||
lg: 'h-11 px-4',
|
||||
icon: 'h-auto w-auto px-2',
|
||||
},
|
||||
text: {
|
||||
default:
|
||||
'!text-woot-500 dark:!text-woot-500 hover:!text-woot-600 dark:hover:!text-woot-600',
|
||||
success:
|
||||
'!text-green-500 dark:!text-green-500 hover:!text-green-600 dark:hover:!text-green-600',
|
||||
warning:
|
||||
'!text-amber-600 dark:!text-amber-600 hover:!text-amber-600 dark:hover:!text-amber-600',
|
||||
danger:
|
||||
'!text-ruby-700 dark:!text-ruby-700 hover:!text-ruby-800 dark:hover:!text-ruby-800',
|
||||
info: '!text-slate-500 dark:!text-slate-400 hover:!text-slate-600 dark:hover:!text-slate-500',
|
||||
},
|
||||
};
|
||||
|
||||
const buttonClasses = computed(() => {
|
||||
const classes = [
|
||||
buttonVariants.variant[props.variant],
|
||||
buttonVariants.size[props.size],
|
||||
];
|
||||
|
||||
if (props.textVariant && buttonVariants.text[props.textVariant]) {
|
||||
classes.push(buttonVariants.text[props.textVariant]);
|
||||
}
|
||||
|
||||
return classes.join(' ');
|
||||
});
|
||||
|
||||
const iconSize = computed(() => {
|
||||
if (props.size === 'sm') return 16;
|
||||
if (props.size === 'lg') return 20;
|
||||
return 18;
|
||||
});
|
||||
|
||||
const handleClick = () => {
|
||||
emit('click');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:class="buttonClasses"
|
||||
class="inline-flex items-center justify-center h-10 min-w-0 gap-2 text-sm font-medium transition-all duration-200 ease-in-out rounded-lg disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
|
||||
@click="handleClick"
|
||||
>
|
||||
<FluentIcon
|
||||
v-if="icon && iconPosition === 'left'"
|
||||
:icon="icon"
|
||||
:size="iconSize"
|
||||
:icon-lib="iconLib"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<span v-if="label" class="min-w-0 truncate">{{ label }}</span>
|
||||
<FluentIcon
|
||||
v-if="icon && iconPosition === 'right'"
|
||||
:icon="icon"
|
||||
:size="iconSize"
|
||||
:icon-lib="iconLib"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import ComboBox from './ComboBox.vue';
|
||||
|
||||
const options = [
|
||||
{ value: 1, label: 'Option 1' },
|
||||
{ value: 2, label: 'Option 2' },
|
||||
{ value: 3, label: 'Option 3' },
|
||||
{ value: 4, label: 'Option 4' },
|
||||
{ value: 5, label: 'Option 5' },
|
||||
];
|
||||
const selectedValue = ref('');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Components/ComboBox" :layout="{ type: 'grid', width: '300px' }">
|
||||
<Variant title="Default">
|
||||
<div class="w-full p-4 bg-white h-80 dark:bg-slate-900">
|
||||
<ComboBox v-model="selectedValue" :options="options" />
|
||||
<p class="mt-2">Selected value: {{ selectedValue }}</p>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Disabled">
|
||||
<div class="w-full p-4 bg-white h-80 dark:bg-slate-900">
|
||||
<ComboBox :options="options" disabled />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,163 @@
|
||||
<script setup>
|
||||
import { nextTick, ref, computed, watch } from 'vue';
|
||||
import { onClickOutside } from '@vueuse/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
options: {
|
||||
type: Array,
|
||||
required: true,
|
||||
validator: value =>
|
||||
value.every(option => 'value' in option && 'label' in option),
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
searchPlaceholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
emptyState: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const selectedValue = ref(props.modelValue);
|
||||
const open = ref(false);
|
||||
const search = ref('');
|
||||
const searchInput = ref(null);
|
||||
const comboboxRef = ref(null);
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
const searchTerm = search.value.toLowerCase();
|
||||
return props.options.filter(option =>
|
||||
option.label.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
});
|
||||
const selectPlaceholder = computed(() => {
|
||||
return props.placeholder || t('COMBOBOX.PLACEHOLDER');
|
||||
});
|
||||
const selectedLabel = computed(() => {
|
||||
const selected = props.options.find(
|
||||
option => option.value === selectedValue.value
|
||||
);
|
||||
return selected?.label ?? selectPlaceholder.value;
|
||||
});
|
||||
|
||||
const selectOption = option => {
|
||||
selectedValue.value = option.value;
|
||||
emit('update:modelValue', option.value);
|
||||
open.value = false;
|
||||
search.value = '';
|
||||
};
|
||||
const toggleDropdown = () => {
|
||||
open.value = !open.value;
|
||||
if (open.value) {
|
||||
search.value = '';
|
||||
nextTick(() => searchInput.value.focus());
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
newValue => {
|
||||
selectedValue.value = newValue;
|
||||
}
|
||||
);
|
||||
|
||||
onClickOutside(comboboxRef, () => {
|
||||
open.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="comboboxRef"
|
||||
class="relative w-full"
|
||||
:class="{
|
||||
'cursor-not-allowed': disabled,
|
||||
'group/combobox': !disabled,
|
||||
}"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
:label="selectedLabel"
|
||||
icon-position="right"
|
||||
size="sm"
|
||||
:disabled="disabled"
|
||||
class="justify-between w-full text-slate-900 dark:text-slate-100 group-hover/combobox:border-slate-300 dark:group-hover/combobox:border-slate-600"
|
||||
:icon="open ? 'chevron-up' : 'chevron-down'"
|
||||
@click="toggleDropdown"
|
||||
/>
|
||||
<div
|
||||
v-show="open"
|
||||
class="absolute z-50 w-full mt-1 transition-opacity duration-200 bg-white border rounded-md shadow-lg border-slate-200 dark:bg-slate-900 dark:border-slate-700/50"
|
||||
>
|
||||
<div class="relative border-b border-slate-100 dark:border-slate-700/50">
|
||||
<FluentIcon
|
||||
icon="search"
|
||||
:size="14"
|
||||
class="absolute text-gray-400 dark:text-slate-500 top-3 left-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="search"
|
||||
type="search"
|
||||
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
|
||||
class="w-full py-2 pl-10 pr-2 text-sm bg-white border-none rounded-t-md dark:bg-slate-900 text-slate-900 dark:text-slate-50"
|
||||
/>
|
||||
</div>
|
||||
<ul
|
||||
class="py-1 overflow-auto max-h-60"
|
||||
role="listbox"
|
||||
:aria-activedescendant="selectedValue"
|
||||
>
|
||||
<li
|
||||
v-for="option in filteredOptions"
|
||||
:key="option.value"
|
||||
class="flex items-center justify-between w-full gap-2 px-3 py-2 text-sm transition-colors duration-150 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/50"
|
||||
:class="{
|
||||
'bg-slate-50 dark:bg-slate-800/50': option.value === selectedValue,
|
||||
}"
|
||||
role="option"
|
||||
:aria-selected="option.value === selectedValue"
|
||||
@click="selectOption(option)"
|
||||
>
|
||||
<span :class="{ 'font-medium': option.value === selectedValue }">
|
||||
{{ option.label }}
|
||||
</span>
|
||||
<FluentIcon
|
||||
v-if="option.value === selectedValue"
|
||||
icon="checkmark"
|
||||
:size="16"
|
||||
class="flex-shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</li>
|
||||
<li
|
||||
v-if="filteredOptions.length === 0"
|
||||
class="px-3 py-2 text-sm text-slate-600 dark:text-slate-300"
|
||||
>
|
||||
{{ emptyState || t('COMBOBOX.EMPTY_STATE') }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import Dialog from './Dialog.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
const alertDialog = ref(null);
|
||||
const editDialog = ref(null);
|
||||
const confirmDialog = ref(null);
|
||||
|
||||
const openAlertDialog = () => {
|
||||
alertDialog.value.open();
|
||||
};
|
||||
const openEditDialog = () => {
|
||||
editDialog.value.open();
|
||||
};
|
||||
const openConfirmDialog = () => {
|
||||
confirmDialog.value.open();
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const onConfirm = dialog => {};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Components/Dialog" :layout="{ type: 'grid', width: '100%' }">
|
||||
<Variant title="Alert Dialog">
|
||||
<Button label="Open Alert Dialog" @click="openAlertDialog" />
|
||||
<Dialog
|
||||
ref="alertDialog"
|
||||
type="alert"
|
||||
title="Alert"
|
||||
description="This is an alert message."
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Edit Dialog">
|
||||
<Button label="Open Edit Dialog" @click="openEditDialog" />
|
||||
<Dialog
|
||||
ref="editDialog"
|
||||
type="edit"
|
||||
description="You can create a new portal here, by providing a name and a slug."
|
||||
title="Create Portal"
|
||||
confirm-button-label="Save"
|
||||
@confirm="onConfirm()"
|
||||
>
|
||||
<template #form>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Input
|
||||
id="portal-name"
|
||||
type="text"
|
||||
placeholder="User Guide | Chatwoot"
|
||||
label="Name"
|
||||
message="This will be the name of your public facing portal"
|
||||
/>
|
||||
<Input
|
||||
id="portal-slug"
|
||||
type="text"
|
||||
placeholder="user-guide"
|
||||
label="Slug"
|
||||
message="app.chatwoot.com/hc/my-portal/en-US/categories/my-slug"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Confirm Dialog">
|
||||
<Button label="Open Confirm Dialog" @click="openConfirmDialog" />
|
||||
<Dialog
|
||||
ref="confirmDialog"
|
||||
type="confirm"
|
||||
title="Confirm Action"
|
||||
description="Are you sure you want to perform this action?"
|
||||
confirm-button-label="Yes, I'm sure"
|
||||
cancel-button-label="No, cancel"
|
||||
@confirm="onConfirm()"
|
||||
/>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,109 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { onClickOutside } from '@vueuse/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'edit',
|
||||
validator: value => ['alert', 'edit'].includes(value),
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
cancelButtonLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
confirmButtonLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['confirm']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const dialogContentRef = ref(null);
|
||||
|
||||
const open = () => {
|
||||
dialogRef.value?.showModal();
|
||||
};
|
||||
const close = () => {
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
const confirm = () => {
|
||||
emit('confirm');
|
||||
};
|
||||
|
||||
defineExpose({ open });
|
||||
|
||||
onClickOutside(dialogContentRef, event => {
|
||||
if (
|
||||
dialogRef.value &&
|
||||
dialogRef.value.open &&
|
||||
event.target === dialogRef.value
|
||||
) {
|
||||
close();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<dialog
|
||||
ref="dialogRef"
|
||||
class="w-full max-w-lg overflow-visible shadow-xl bg-modal-backdrop-light dark:bg-modal-backdrop-dark rounded-xl"
|
||||
@close="close"
|
||||
>
|
||||
<div
|
||||
ref="dialogContentRef"
|
||||
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-left align-middle transition-all duration-300 ease-in-out transform bg-white shadow-xl dark:bg-slate-800 rounded-xl"
|
||||
@click.stop
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3
|
||||
class="text-base font-medium leading-6 text-gray-900 dark:text-white"
|
||||
>
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p
|
||||
v-if="description"
|
||||
class="mb-0 text-sm text-slate-500 dark:text-slate-400"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<slot name="form">
|
||||
<!-- Form content will be injected here -->
|
||||
</slot>
|
||||
<div class="flex items-center justify-between w-full gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
:label="cancelButtonLabel || t('DIALOG.BUTTONS.CANCEL')"
|
||||
class="w-full"
|
||||
size="sm"
|
||||
@click="close"
|
||||
/>
|
||||
<Button
|
||||
v-if="type !== 'alert'"
|
||||
:variant="type === 'edit' ? 'default' : 'destructive'"
|
||||
:label="confirmButtonLabel || t('DIALOG.BUTTONS.CONFIRM')"
|
||||
class="w-full"
|
||||
size="sm"
|
||||
@click="confirm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import DropdownMenu from './DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
const menuItems = [
|
||||
{ label: 'Edit', action: 'edit', icon: 'edit' },
|
||||
{ label: 'Publish', action: 'publish', icon: 'checkmark' },
|
||||
{ label: 'Archive', action: 'archive', icon: 'archive' },
|
||||
{ label: 'Delete', action: 'delete', icon: 'delete' },
|
||||
];
|
||||
const isOpen = ref(false);
|
||||
const toggleDropdown = () => {
|
||||
isOpen.value = !isOpen.value;
|
||||
};
|
||||
const handleAction = () => {
|
||||
isOpen.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Components/DropdownMenu" :layout="{ type: 'grid', width: 300 }">
|
||||
<Variant title="Default">
|
||||
<div class="p-4 bg-white h-72 dark:bg-slate-900">
|
||||
<div class="relative">
|
||||
<Button label="Open Menu" size="sm" @click="toggleDropdown" />
|
||||
<DropdownMenu
|
||||
v-if="isOpen"
|
||||
:menu-items="menuItems"
|
||||
class="absolute left-0 top-10"
|
||||
@action="handleAction"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Always Open">
|
||||
<div class="p-4 bg-white h-72 dark:bg-slate-900">
|
||||
<DropdownMenu :menu-items="menuItems" @action="handleAction" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Custom Items">
|
||||
<div class="p-4 bg-white h-72 dark:bg-slate-900">
|
||||
<DropdownMenu
|
||||
:menu-items="[
|
||||
{ label: 'Custom 1', action: 'custom1', icon: 'file-upload' },
|
||||
{ label: 'Custom 2', action: 'custom2', icon: 'document' },
|
||||
{ label: 'Danger', action: 'delete', icon: 'delete' },
|
||||
]"
|
||||
@action="handleAction"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup>
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
menuItems: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action']);
|
||||
|
||||
const handleAction = action => {
|
||||
emit('action', action);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-white dark:bg-slate-800 absolute rounded-xl z-50 py-3 px-1 gap-2 flex flex-col min-w-[136px] shadow-lg"
|
||||
>
|
||||
<Button
|
||||
v-for="item in menuItems"
|
||||
:key="item.action"
|
||||
:label="item.label"
|
||||
:icon="item.icon"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="!justify-start w-full hover:bg-white dark:hover:bg-slate-800 z-60 font-normal"
|
||||
:text-variant="item.action === 'delete' ? 'danger' : ''"
|
||||
@click="handleAction(item.action)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup>
|
||||
import InlineInput from './InlineInput.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/InlineInput"
|
||||
:layout="{ type: 'grid', width: '400' }"
|
||||
>
|
||||
<Variant title="Default">
|
||||
<div class="p-4 bg-white dark:bg-slate-800">
|
||||
<InlineInput id="inline-input-1" placeholder="Default InlineInput" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="With Label">
|
||||
<div class="p-4 bg-white dark:bg-slate-800">
|
||||
<InlineInput
|
||||
id="inline-input-2"
|
||||
label="Username"
|
||||
placeholder="Enter your username"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Disabled">
|
||||
<div class="p-4 bg-white dark:bg-slate-800">
|
||||
<InlineInput
|
||||
id="inline-input-3"
|
||||
label="Disabled InlineInput"
|
||||
placeholder="Can't type here"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="With Custom Classes">
|
||||
<div class="flex flex-col gap-4 p-4 bg-white dark:bg-slate-800">
|
||||
<InlineInput
|
||||
id="inline-input-4"
|
||||
label="Custom Input Class"
|
||||
placeholder="Custom input style"
|
||||
custom-input-class="placeholder:text-green-200 dark:placeholder:text-green-800"
|
||||
/>
|
||||
<InlineInput
|
||||
id="inline-input-5"
|
||||
label="Custom Label Class"
|
||||
placeholder="Custom label style"
|
||||
custom-label-class="text-green-600 dark:text-green-400"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Different Types">
|
||||
<div class="flex flex-col gap-4 p-4 bg-white dark:bg-slate-800">
|
||||
<InlineInput
|
||||
id="inline-input-6"
|
||||
label="Text"
|
||||
placeholder="Text input"
|
||||
/>
|
||||
<InlineInput
|
||||
id="inline-input-7"
|
||||
label="Number"
|
||||
placeholder="Number input"
|
||||
type="number"
|
||||
/>
|
||||
<InlineInput
|
||||
id="inline-input-8"
|
||||
label="Password"
|
||||
placeholder="Password input"
|
||||
type="password"
|
||||
/>
|
||||
<InlineInput
|
||||
id="inline-input-9"
|
||||
label="Email"
|
||||
placeholder="Email input"
|
||||
type="email"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'text',
|
||||
},
|
||||
customInputClass: {
|
||||
type: [String, Object, Array],
|
||||
default: '',
|
||||
},
|
||||
customLabelClass: {
|
||||
type: [String, Object, Array],
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
id: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['update:modelValue']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex items-center justify-between w-full gap-2 whitespace-nowrap"
|
||||
>
|
||||
<label
|
||||
v-if="label"
|
||||
:for="id"
|
||||
:class="customLabelClass"
|
||||
class="mb-0.5 text-sm font-medium text-gray-900 dark:text-gray-50"
|
||||
>
|
||||
{{ label }}
|
||||
</label>
|
||||
<!-- Added prefix slot to allow adding custom labels to the input -->
|
||||
<slot name="prefix" />
|
||||
<input
|
||||
:id="id"
|
||||
:value="modelValue"
|
||||
:type="type"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:class="customInputClass"
|
||||
class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-lg bg-transparent dark:bg-transparent placeholder:text-slate-200 dark:placeholder:text-slate-500 disabled:cursor-not-allowed disabled:opacity-50 text-slate-900 dark:text-white transition-all duration-500 ease-in-out"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup>
|
||||
import Input from './Input.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Components/Input" :layout="{ type: 'grid', width: '400' }">
|
||||
<Variant title="Default">
|
||||
<div class="p-4 bg-white dark:bg-slate-800">
|
||||
<Input placeholder="Default Input" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="With Label">
|
||||
<div class="p-4 bg-white dark:bg-slate-800">
|
||||
<Input label="Username" placeholder="Enter your username" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Disabled">
|
||||
<div class="p-4 bg-white dark:bg-slate-800">
|
||||
<Input label="Disabled Input" placeholder="Can't type here" disabled />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="With Message">
|
||||
<div class="flex flex-col gap-4 p-4 bg-white dark:bg-slate-800">
|
||||
<Input
|
||||
label="Email"
|
||||
placeholder="Enter your email"
|
||||
message="We'll never share your email."
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
message="Password is incorrect"
|
||||
message-type="error"
|
||||
/>
|
||||
<Input
|
||||
label="Verification Code"
|
||||
placeholder="Enter the code"
|
||||
message="Code verified successfully"
|
||||
message-type="success"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Different Types">
|
||||
<div class="flex flex-col gap-4 p-4 bg-white dark:bg-slate-800">
|
||||
<Input label="Text" type="text" placeholder="Text input" />
|
||||
<Input label="Number" type="number" placeholder="Number input" />
|
||||
<Input label="Password" type="password" placeholder="Password input" />
|
||||
<Input label="Email" type="email" placeholder="Email input" />
|
||||
<Input label="Date" type="date" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Custom Input Class">
|
||||
<div class="p-4 bg-white dark:bg-slate-800">
|
||||
<Input
|
||||
label="Custom Style"
|
||||
placeholder="Custom input class"
|
||||
custom-input-class="border-yellow-500 dark:border-yellow-700"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,92 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'text',
|
||||
},
|
||||
customInputClass: {
|
||||
type: [String, Object, Array],
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
id: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
messageType: {
|
||||
type: String,
|
||||
default: 'info',
|
||||
validator: value => ['info', 'error', 'success'].includes(value),
|
||||
},
|
||||
});
|
||||
defineEmits(['update:modelValue']);
|
||||
const messageClass = computed(() => {
|
||||
switch (props.messageType) {
|
||||
case 'error':
|
||||
return 'text-red-500 dark:text-red-400';
|
||||
case 'success':
|
||||
return 'text-green-500 dark:text-green-400';
|
||||
default:
|
||||
return 'text-slate-500 dark:text-slate-400';
|
||||
}
|
||||
});
|
||||
const inputBorderClass = computed(() => {
|
||||
switch (props.messageType) {
|
||||
case 'error':
|
||||
return 'border-red-500 dark:border-red-400';
|
||||
default:
|
||||
return 'border-slate-100 dark:border-slate-700/50';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex flex-col gap-1">
|
||||
<label
|
||||
v-if="label"
|
||||
:for="id"
|
||||
class="mb-0.5 text-sm font-medium text-gray-900 dark:text-gray-50"
|
||||
>
|
||||
{{ label }}
|
||||
</label>
|
||||
<!-- Added prefix slot to allow adding icons to the input -->
|
||||
<slot name="prefix" />
|
||||
<input
|
||||
:id="id"
|
||||
:value="modelValue"
|
||||
:class="[customInputClass, inputBorderClass]"
|
||||
:type="type"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
class="flex w-full reset-base text-sm h-8 pl-3 pr-2 rtl:pr-3 rtl:pl-2 py-1.5 !mb-0 border rounded-lg focus:border-woot-500 dark:focus:border-woot-600 bg-white dark:bg-slate-900 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-200 dark:placeholder:text-slate-500 disabled:cursor-not-allowed disabled:opacity-50 text-slate-900 dark:text-white transition-all duration-500 ease-in-out"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
/>
|
||||
<p
|
||||
v-if="message"
|
||||
class="mt-1 mb-0 text-xs transition-all duration-500 ease-in-out"
|
||||
:class="messageClass"
|
||||
>
|
||||
{{ message }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import PaginationFooter from './PaginationFooter.vue';
|
||||
|
||||
const createPaginationState = (initialPage, totalItems, itemsPerPage) => {
|
||||
const currentPage = ref(initialPage);
|
||||
const handlePageChange = newPage => {
|
||||
currentPage.value = newPage;
|
||||
};
|
||||
return { currentPage, totalItems, itemsPerPage, handlePageChange };
|
||||
};
|
||||
|
||||
const defaultState = createPaginationState(1, 100, 16);
|
||||
const middlePageState = createPaginationState(3, 100, 16);
|
||||
const lastPageState = createPaginationState(7, 100, 16);
|
||||
const customItemsState = createPaginationState(2, 100, 10);
|
||||
const singlePageState = createPaginationState(1, 10, 16);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/PaginationFooter"
|
||||
:layout="{ type: 'grid', width: '957' }"
|
||||
>
|
||||
<Variant title="Default">
|
||||
<div class="p-4 bg-white dark:bg-slate-900">
|
||||
<PaginationFooter
|
||||
:current-page="defaultState.currentPage.value"
|
||||
:total-items="defaultState.totalItems"
|
||||
:items-per-page="defaultState.itemsPerPage"
|
||||
@update:current-page="defaultState.handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Middle Page">
|
||||
<div class="p-4 bg-white dark:bg-slate-900">
|
||||
<PaginationFooter
|
||||
:current-page="middlePageState.currentPage.value"
|
||||
:total-items="middlePageState.totalItems"
|
||||
:items-per-page="middlePageState.itemsPerPage"
|
||||
@update:current-page="middlePageState.handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Last Page">
|
||||
<div class="p-4 bg-white dark:bg-slate-900">
|
||||
<PaginationFooter
|
||||
:current-page="lastPageState.currentPage.value"
|
||||
:total-items="lastPageState.totalItems"
|
||||
:items-per-page="lastPageState.itemsPerPage"
|
||||
@update:current-page="lastPageState.handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Custom Items Per Page">
|
||||
<div class="p-4 bg-white dark:bg-slate-900">
|
||||
<PaginationFooter
|
||||
:current-page="customItemsState.currentPage.value"
|
||||
:total-items="customItemsState.totalItems"
|
||||
:items-per-page="customItemsState.itemsPerPage"
|
||||
@update:current-page="customItemsState.handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Single Page">
|
||||
<div class="p-4 bg-white dark:bg-slate-900">
|
||||
<PaginationFooter
|
||||
:current-page="singlePageState.currentPage.value"
|
||||
:total-items="singlePageState.totalItems"
|
||||
:items-per-page="singlePageState.itemsPerPage"
|
||||
@update:current-page="singlePageState.handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,113 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
currentPage: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
totalItems: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
itemsPerPage: {
|
||||
type: Number,
|
||||
default: 16,
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['update:currentPage']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const totalPages = computed(() =>
|
||||
Math.ceil(props.totalItems / props.itemsPerPage)
|
||||
);
|
||||
const startItem = computed(
|
||||
() => (props.currentPage - 1) * props.itemsPerPage + 1
|
||||
);
|
||||
const endItem = computed(() =>
|
||||
Math.min(startItem.value + props.itemsPerPage - 1, props.totalItems)
|
||||
);
|
||||
const isFirstPage = computed(() => props.currentPage === 1);
|
||||
const isLastPage = computed(() => props.currentPage === totalPages.value);
|
||||
const changePage = newPage => {
|
||||
if (newPage >= 1 && newPage <= totalPages.value) {
|
||||
emit('update:currentPage', newPage);
|
||||
}
|
||||
};
|
||||
|
||||
const currentPageInformation = computed(() => {
|
||||
return t('PAGINATION_FOOTER.SHOWING', {
|
||||
startItem: startItem.value,
|
||||
endItem: endItem.value,
|
||||
totalItems: props.totalItems,
|
||||
});
|
||||
});
|
||||
|
||||
const pageInfo = computed(() => {
|
||||
return t('PAGINATION_FOOTER.CURRENT_PAGE_INFO', {
|
||||
currentPage: '',
|
||||
totalPages: totalPages.value,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex justify-between h-12 w-full max-w-[957px] mx-auto bg-slate-25 dark:bg-slate-800/50 rounded-xl py-2 px-3 items-center"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="min-w-0 text-sm font-normal line-clamp-1 text-slate-600 dark:text-slate-300"
|
||||
>
|
||||
{{ currentPageInformation }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
icon="chevrons-lucide-left"
|
||||
icon-lib="lucide"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:disabled="isFirstPage"
|
||||
@click="changePage(1)"
|
||||
/>
|
||||
<Button
|
||||
icon="chevron-lucide-left"
|
||||
icon-lib="lucide"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:disabled="isFirstPage"
|
||||
@click="changePage(currentPage - 1)"
|
||||
/>
|
||||
<div
|
||||
class="inline-flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400"
|
||||
>
|
||||
<span
|
||||
class="px-3 tabular-nums py-0.5 bg-white dark:bg-slate-900 rounded-md"
|
||||
>
|
||||
{{ currentPage }}
|
||||
</span>
|
||||
<span>{{ pageInfo }}</span>
|
||||
</div>
|
||||
<Button
|
||||
icon="chevron-lucide-right"
|
||||
icon-lib="lucide"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:disabled="isLastPage"
|
||||
@click="changePage(currentPage + 1)"
|
||||
/>
|
||||
<Button
|
||||
icon="chevrons-lucide-right"
|
||||
icon-lib="lucide"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:disabled="isLastPage"
|
||||
@click="changePage(totalPages)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup>
|
||||
import Spinner from './Spinner.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Components/Spinner" :layout="{ type: 'grid', width: '400' }">
|
||||
<Variant title="Default">
|
||||
<Spinner size="24" class="text-n-slate-10" />
|
||||
<Spinner size="32" class="text-n-ruby-10" />
|
||||
<Spinner size="40" class="text-n-amber-10" />
|
||||
<Spinner size="48" class="text-n-teal-10" />
|
||||
<Spinner size="64" class="text-n-brand" />
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
size: {
|
||||
type: Number,
|
||||
default: 24,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
class="animate-spin"
|
||||
>
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup>
|
||||
import TabBar from './TabBar.vue';
|
||||
|
||||
const defaultTabs = [
|
||||
{ label: 'All articles', count: 24 },
|
||||
{ label: 'Mine', count: 13 },
|
||||
{ label: 'Draft', count: 5 },
|
||||
{ label: 'Archived', count: 11 },
|
||||
{ label: 'Unpublished', count: 23 },
|
||||
{ label: 'Published', count: 100 },
|
||||
{ label: 'Scheduled', count: 49 },
|
||||
];
|
||||
|
||||
const customTabs = [
|
||||
{ label: 'Active', count: 10 },
|
||||
{ label: 'Pending', count: 5 },
|
||||
{ label: 'Completed', count: 15 },
|
||||
];
|
||||
|
||||
const handleTabChanged = tab => {
|
||||
console.log('Tab changed:', tab);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Components/TabBar" :layout="{ type: 'grid', width: '920px' }">
|
||||
<Variant title="Default">
|
||||
<div class="p-4 bg-white dark:bg-slate-900">
|
||||
<TabBar :tabs="defaultTabs" @tab-changed="handleTabChanged" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Custom Tabs with Initial Tab">
|
||||
<div class="p-4 bg-white dark:bg-slate-900">
|
||||
<TabBar
|
||||
:tabs="customTabs"
|
||||
:initial-active-tab="1"
|
||||
@tab-changed="handleTabChanged"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="No Counts">
|
||||
<div class="p-4 bg-white dark:bg-slate-900">
|
||||
<TabBar
|
||||
:tabs="[{ label: 'Tab 1' }, { label: 'Tab 2' }, { label: 'Tab 3' }]"
|
||||
@tab-changed="handleTabChanged"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Single Tab">
|
||||
<div class="p-4 bg-white dark:bg-slate-900">
|
||||
<TabBar
|
||||
:tabs="[{ label: 'Single Tab', count: 42 }]"
|
||||
@tab-changed="handleTabChanged"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
const props = defineProps({
|
||||
initialActiveTab: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
tabs: {
|
||||
type: Array,
|
||||
required: true,
|
||||
validator: value => {
|
||||
return value.every(
|
||||
tab => typeof tab.label === 'string' && typeof tab.count === 'number'
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['tabChanged']);
|
||||
const activeTab = ref(props.initialActiveTab);
|
||||
const selectTab = index => {
|
||||
activeTab.value = index;
|
||||
emit('tabChanged', props.tabs[index]);
|
||||
};
|
||||
const showDivider = index => {
|
||||
return (
|
||||
// Show dividers after the active tab, but not after the last tab
|
||||
(index > activeTab.value && index < props.tabs.length - 1) ||
|
||||
// Show dividers before the active tab, but not immediately before it and not before the first tab
|
||||
(index < activeTab.value - 1 && index > -1)
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-8 rounded-lg bg-slate-25 dark:bg-slate-800/50 w-fit">
|
||||
<template v-for="(tab, index) in tabs" :key="index">
|
||||
<button
|
||||
class="relative px-4 truncate py-1.5 text-sm border-0 rounded-lg transition-colors duration-300 ease-in-out"
|
||||
:class="[
|
||||
activeTab === index
|
||||
? 'text-woot-500 bg-woot-500/10 dark:bg-woot-500/10'
|
||||
: 'text-slate-500 dark:text-slate-400 hover:text-woot-500 dark:hover:text-woot-400',
|
||||
]"
|
||||
@click="selectTab(index)"
|
||||
>
|
||||
{{ tab.label }} {{ tab.count ? `(${tab.count})` : '' }}
|
||||
</button>
|
||||
<div
|
||||
class="w-px h-3.5 rounded my-auto transition-colors duration-300 ease-in-out"
|
||||
:class="
|
||||
showDivider(index)
|
||||
? 'bg-slate-75 dark:bg-slate-800'
|
||||
: 'bg-transparent dark:bg-transparent'
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import TextArea from './TextArea.vue';
|
||||
|
||||
const bio = ref('');
|
||||
const description = ref('');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Components/TextArea" :layout="{ type: 'grid', width: '400' }">
|
||||
<Variant title="Default">
|
||||
<div class="p-4 bg-white dark:bg-slate-800">
|
||||
<TextArea placeholder="Default TextArea" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="With Label">
|
||||
<div class="p-4 bg-white dark:bg-slate-800">
|
||||
<TextArea label="Description" placeholder="Enter your description" />
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Disabled">
|
||||
<div class="p-4 bg-white dark:bg-slate-800">
|
||||
<TextArea
|
||||
label="Disabled TextArea"
|
||||
placeholder="Can't type here"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="With Character Count">
|
||||
<div class="p-4 bg-white dark:bg-slate-800">
|
||||
<TextArea
|
||||
v-model="bio"
|
||||
label="Bio"
|
||||
placeholder="Tell us about yourself"
|
||||
:max-length="100"
|
||||
show-character-count
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Custom Max Length">
|
||||
<div class="p-4 bg-white dark:bg-slate-800">
|
||||
<TextArea
|
||||
v-model="description"
|
||||
label="Long Description"
|
||||
placeholder="Enter a longer description"
|
||||
:max-length="500"
|
||||
show-character-count
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Custom TextArea Class">
|
||||
<div class="p-4 bg-white dark:bg-slate-800">
|
||||
<TextArea
|
||||
label="Custom Style"
|
||||
placeholder="Custom textarea class"
|
||||
custom-text-area-class="border-yellow-500 dark:border-yellow-700"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
maxLength: {
|
||||
type: Number,
|
||||
default: 200,
|
||||
},
|
||||
id: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
customTextAreaClass: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showCharacterCount: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
defineEmits(['update:modelValue']);
|
||||
const characterCount = computed(() => props.modelValue.length);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex flex-col gap-1">
|
||||
<label
|
||||
v-if="label"
|
||||
:for="id"
|
||||
class="mb-0.5 text-sm font-medium text-gray-900 dark:text-gray-50"
|
||||
>
|
||||
{{ label }}
|
||||
</label>
|
||||
<textarea
|
||||
:id="id"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:maxlength="maxLength"
|
||||
class="flex w-full reset-base text-sm h-24 px-3 pt-3 !mb-0 border rounded-lg focus:border-woot-500 dark:focus:border-woot-600 bg-white dark:bg-slate-900 placeholder:text-slate-200 dark:placeholder:text-slate-500 text-slate-900 dark:text-white transition-all duration-500 ease-in-out resize-none disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-slate-25 dark:disabled:bg-slate-900"
|
||||
:class="[customTextAreaClass, showCharacterCount ? 'pb-9' : 'pb-3']"
|
||||
:disabled="disabled"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
/>
|
||||
<div
|
||||
v-if="showCharacterCount"
|
||||
class="absolute flex items-center justify-between mt-1 bottom-3 ltr:right-3 rtl:left-3"
|
||||
>
|
||||
<span class="text-xs tabular-nums text-slate-300 dark:text-slate-600">
|
||||
{{ characterCount }} / {{ maxLength }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { mapGetters } from 'vuex';
|
||||
import { getSidebarItems } from './config/default-sidebar';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import PrimarySidebar from './sidebarComponents/Primary.vue';
|
||||
@@ -33,6 +34,7 @@ export default {
|
||||
setup(props, { emit }) {
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { accountId } = useAccount();
|
||||
|
||||
const toggleKeyShortcutModal = () => {
|
||||
emit('openKeyShortcutModal');
|
||||
@@ -72,6 +74,7 @@ export default {
|
||||
|
||||
return {
|
||||
toggleKeyShortcutModal,
|
||||
accountId,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
@@ -82,7 +85,6 @@ export default {
|
||||
|
||||
computed: {
|
||||
...mapGetters({
|
||||
accountId: 'getCurrentAccountId',
|
||||
currentUser: 'getCurrentUser',
|
||||
globalConfig: 'globalConfig/get',
|
||||
inboxes: 'inboxes/getInboxes',
|
||||
|
||||
@@ -90,13 +90,17 @@ export default {
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-end pb-6">
|
||||
<PrimaryNavItem
|
||||
<a
|
||||
v-if="!isACustomBrandedInstance"
|
||||
icon="book-open-globe"
|
||||
name="DOCS"
|
||||
open-in-new-page
|
||||
:to="helpDocsURL"
|
||||
/>
|
||||
v-tooltip.right="$t(`SIDEBAR.DOCS`)"
|
||||
:href="helpDocsURL"
|
||||
class="text-slate-700 dark:text-slate-100 w-10 h-10 my-2 flex items-center justify-center rounded-lg hover:bg-slate-25 dark:hover:bg-slate-700 dark:hover:text-slate-100 hover:text-slate-600 relative"
|
||||
rel="noopener noreferrer nofollow"
|
||||
target="_blank"
|
||||
>
|
||||
<fluent-icon icon="book-open-globe" />
|
||||
<span class="sr-only">{{ $t(`SIDEBAR.DOCS`) }}</span>
|
||||
</a>
|
||||
<NotificationBell @open-notification-panel="openNotificationPanel" />
|
||||
<AgentDetails @toggle-menu="toggleOptions" />
|
||||
<OptionsMenu
|
||||
|
||||
@@ -48,7 +48,7 @@ const debouncedEmit = debounce(value => {
|
||||
|
||||
const onSearch = value => {
|
||||
searchTerm.value = value;
|
||||
debouncedEmit();
|
||||
debouncedEmit(value);
|
||||
};
|
||||
|
||||
const filteredListItems = computed(() => {
|
||||
|
||||
@@ -59,6 +59,7 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
plugins: [imagePastePlugin(this.handleImageUpload)],
|
||||
isTextSelected: false, // Tracks text selection and prevents unnecessary re-renders on mouse selection
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
@@ -181,6 +182,7 @@ export default {
|
||||
if (tx.docChanged) {
|
||||
this.emitOnChange();
|
||||
}
|
||||
this.checkSelection(state);
|
||||
},
|
||||
handleDOMEvents: {
|
||||
keyup: this.onKeyup,
|
||||
@@ -226,6 +228,56 @@ export default {
|
||||
onFocus() {
|
||||
this.$emit('focus');
|
||||
},
|
||||
checkSelection(editorState) {
|
||||
const { from, to } = editorState.selection;
|
||||
// Check if there's a selection (from and to are different)
|
||||
const hasSelection = from !== to;
|
||||
// If the selection state is the same as the previous state, do nothing
|
||||
if (hasSelection === this.isTextSelected) return;
|
||||
// Update the selection state
|
||||
this.isTextSelected = hasSelection;
|
||||
|
||||
const { editor } = this.$refs;
|
||||
|
||||
// Toggle the 'has-selection' class based on whether there's a selection
|
||||
editor.classList.toggle('has-selection', hasSelection);
|
||||
// If there's a selection, update the menubar position
|
||||
if (hasSelection) this.setMenubarPosition(editorState);
|
||||
},
|
||||
setMenubarPosition(editorState) {
|
||||
if (!editorState.selection) return;
|
||||
|
||||
// Get the start and end positions of the selection
|
||||
const { from, to } = editorState.selection;
|
||||
const { editor } = this.$refs;
|
||||
// Get the editor's position relative to the viewport
|
||||
const { left: editorLeft, top: editorTop } =
|
||||
editor.getBoundingClientRect();
|
||||
|
||||
// Get the editor's width
|
||||
const editorWidth = editor.offsetWidth;
|
||||
const menubarWidth = 480; // Menubar width (adjust as needed (px))
|
||||
|
||||
// Get the end position of the selection
|
||||
const { bottom: endBottom, right: endRight } = editorView.coordsAtPos(to);
|
||||
// Get the start position of the selection
|
||||
const { left: startLeft } = editorView.coordsAtPos(from);
|
||||
|
||||
// Calculate the top position for the menubar (10px below the selection)
|
||||
const top = endBottom - editorTop + 10;
|
||||
// Calculate the left position for the menubar
|
||||
// This centers the menubar on the selection while keeping it within the editor's bounds
|
||||
const left = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
(startLeft + endRight) / 2 - editorLeft,
|
||||
editorWidth - menubarWidth
|
||||
)
|
||||
);
|
||||
// Set the CSS custom properties for positioning the menubar
|
||||
editor.style.setProperty('--selection-top', `${top}px`);
|
||||
editor.style.setProperty('--selection-left', `${left}px`);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -259,6 +311,7 @@ export default {
|
||||
}
|
||||
|
||||
.editor-root {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import Letter from 'vue-letter';
|
||||
import { Letter } from 'vue-letter';
|
||||
import GalleryView from '../components/GalleryView.vue';
|
||||
|
||||
export default {
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { ref } from 'vue';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useAccount } from '../useAccount';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
vi.mock('vue-router');
|
||||
|
||||
describe('useAccount', () => {
|
||||
beforeEach(() => {
|
||||
useStoreGetters.mockReturnValue({
|
||||
getCurrentAccountId: ref(123),
|
||||
useRoute.mockReturnValue({
|
||||
params: {
|
||||
accountId: 123,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { computed } from 'vue';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
/**
|
||||
* Composable for account-related operations.
|
||||
* @returns {Object} An object containing account-related properties and methods.
|
||||
*/
|
||||
export function useAccount() {
|
||||
const getters = useStoreGetters();
|
||||
|
||||
/**
|
||||
* Computed property for the current account ID.
|
||||
* @type {import('vue').ComputedRef<number>}
|
||||
*/
|
||||
const accountId = computed(() => getters.getCurrentAccountId.value);
|
||||
const route = useRoute();
|
||||
|
||||
const accountId = computed(() => {
|
||||
return Number(route.params.accountId);
|
||||
});
|
||||
|
||||
/**
|
||||
* Generates an account-scoped URL.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"TITLE": "تسجيل الدخول إلى Chatwoot",
|
||||
"EMAIL": {
|
||||
"LABEL": "البريد الإلكتروني",
|
||||
"PLACEHOLDER": "مثلاً: someone@example.com",
|
||||
"PLACEHOLDER": "مثلاً: someone{'@'}example.com",
|
||||
"ERROR": "الرجاء إدخال عنوان بريد إلكتروني صحيح"
|
||||
},
|
||||
"PASSWORD": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"TITLE": "Login to Chatwoot",
|
||||
"EMAIL": {
|
||||
"LABEL": "Email",
|
||||
"PLACEHOLDER": "Email eg: someone@example.com",
|
||||
"PLACEHOLDER": "Email eg: someone{'@'}example.com",
|
||||
"ERROR": "Please enter a valid email address"
|
||||
},
|
||||
"PASSWORD": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"TITLE": "Entra a Chatwoot",
|
||||
"EMAIL": {
|
||||
"LABEL": "Correu electrònic",
|
||||
"PLACEHOLDER": "exemple@nomdelacompanyia.com",
|
||||
"PLACEHOLDER": "exemple{'@'}nomdelacompanyia.com",
|
||||
"ERROR": "Introduïu una adreça de correu electrònic vàlida"
|
||||
},
|
||||
"PASSWORD": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"TITLE": "Přihlásit se do Chatwoot",
|
||||
"EMAIL": {
|
||||
"LABEL": "E-mailová adresa",
|
||||
"PLACEHOLDER": "E-mail např: někdo@example.com",
|
||||
"PLACEHOLDER": "E-mail např: někdo{'@'}example.com",
|
||||
"ERROR": "Zadejte prosím platnou e-mailovou adresu"
|
||||
},
|
||||
"PASSWORD": {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
"EMAIL": {
|
||||
"LABEL": "Pracovní e-mail",
|
||||
"PLACEHOLDER": "Zadejte svou pracovní e-mailovou adresu. např.: jan@novak.spolecnost",
|
||||
"PLACEHOLDER": "Zadejte svou pracovní e-mailovou adresu. např.: jan{'@'}novak.spolecnost",
|
||||
"ERROR": "Please enter a valid work email address"
|
||||
},
|
||||
"PASSWORD": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"TITLE": "Log ind på Chatwoot",
|
||||
"EMAIL": {
|
||||
"LABEL": "E-mail",
|
||||
"PLACEHOLDER": "E-mail, fx: navn@eksempel.dk",
|
||||
"PLACEHOLDER": "E-mail, fx: navn{'@'}eksempel.dk",
|
||||
"ERROR": "Indtast venligst en gyldig e-mailadresse"
|
||||
},
|
||||
"PASSWORD": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"TITLE": "Melden Sie sich bei Chatwoot an",
|
||||
"EMAIL": {
|
||||
"LABEL": "E-Mail",
|
||||
"PLACEHOLDER": "E-Mail zB: jemand@example.com",
|
||||
"PLACEHOLDER": "E-Mail zB: jemand{'@'}example.com",
|
||||
"ERROR": "Bitte geben Sie eine gültige E-Mail-Adresse ein"
|
||||
},
|
||||
"PASSWORD": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"TITLE": "Είσοδος στο Chatwoot",
|
||||
"EMAIL": {
|
||||
"LABEL": "Email",
|
||||
"PLACEHOLDER": "Email π.χ.: someone@example.com",
|
||||
"PLACEHOLDER": "Email π.χ.: someone{'@'}example.com",
|
||||
"ERROR": "Παρακαλώ εισάγετε μια έγκυρη διεύθυνση email"
|
||||
},
|
||||
"PASSWORD": {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
"EMAIL": {
|
||||
"LABEL": "email εργασίας",
|
||||
"PLACEHOLDER": "συμπληρώστε το email εργασίας πχ: papadopoulos@wyane.com",
|
||||
"PLACEHOLDER": "συμπληρώστε το email εργασίας πχ: papadopoulos{'@'}wyane.com",
|
||||
"ERROR": "Παρακαλώ εισάγετε μια έγκυρη διεύθυνση email"
|
||||
},
|
||||
"PASSWORD": {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"PAGINATION_FOOTER": {
|
||||
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} items",
|
||||
"CURRENT_PAGE_INFO": "{currentPage} of {totalPages} pages"
|
||||
},
|
||||
"COMBOBOX": {
|
||||
"PLACEHOLDER": "Select an option...",
|
||||
"EMPTY_STATE": "No results found.",
|
||||
"SEARCH_PLACEHOLDER": "Search..."
|
||||
},
|
||||
"DIALOG": {
|
||||
"BUTTONS": {
|
||||
"CANCEL": "Cancel",
|
||||
"CONFIRM": "Confirm"
|
||||
}
|
||||
},
|
||||
"BREADCRUMB": {
|
||||
"ARIA_LABEL": "Breadcrumb"
|
||||
}
|
||||
}
|
||||
@@ -477,6 +477,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"LOADING": "Loading..."
|
||||
"LOADING": "Loading...",
|
||||
"LOCALES_PAGE": {
|
||||
"LOCALES_COUNT": "No locales available | {n} locale | {n} locales",
|
||||
"NEW_LOCALE_BUTTON_TEXT": "New locale"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import components from './components.json';
|
||||
import advancedFilters from './advancedFilters.json';
|
||||
import agentBots from './agentBots.json';
|
||||
import agentMgmt from './agentMgmt.json';
|
||||
@@ -36,6 +37,7 @@ import teamsSettings from './teamsSettings.json';
|
||||
import whatsappTemplates from './whatsappTemplates.json';
|
||||
|
||||
export default {
|
||||
...components,
|
||||
...advancedFilters,
|
||||
...agentBots,
|
||||
...agentMgmt,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"TITLE": "Iniciar sesión en Chatwoot",
|
||||
"EMAIL": {
|
||||
"LABEL": "E-mail",
|
||||
"PLACEHOLDER": "Email por ejemplo: alguien@ejemplo.com",
|
||||
"PLACEHOLDER": "Email por ejemplo: alguien{'@'}ejemplo.com",
|
||||
"ERROR": "Por favor, introduzca una dirección de correo válida"
|
||||
},
|
||||
"PASSWORD": {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
"EMAIL": {
|
||||
"LABEL": "E-mail",
|
||||
"PLACEHOLDER": "bruce@wayne.empresas",
|
||||
"PLACEHOLDER": "bruce{'@'}wayne.empresas",
|
||||
"ERROR": "Por favor, introduzca una dirección de correo válida"
|
||||
},
|
||||
"PASSWORD": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"TITLE": "ورود به چت ووت",
|
||||
"EMAIL": {
|
||||
"LABEL": "ایمیل",
|
||||
"PLACEHOLDER": "ایمیل به عنوان مثال: someone@example.com",
|
||||
"PLACEHOLDER": "ایمیل به عنوان مثال: someone{'@'}example.com",
|
||||
"ERROR": "لطفا ایمیل خود را به شکل صحیح وارد کنید"
|
||||
},
|
||||
"PASSWORD": {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
"EMAIL": {
|
||||
"LABEL": "ایمیل کاری",
|
||||
"PLACEHOLDER": "ایمیل کاری خود را وارد کنید به عنوان مثال: jafari@wayne.enterprises",
|
||||
"PLACEHOLDER": "ایمیل کاری خود را وارد کنید به عنوان مثال: jafari{'@'}wayne.enterprises",
|
||||
"ERROR": "لطفا یک آدرس ایمیل کاری معتبر وارد کنید"
|
||||
},
|
||||
"PASSWORD": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"TITLE": "Kirjaudu sisään Chatwootiin",
|
||||
"EMAIL": {
|
||||
"LABEL": "Sähköposti",
|
||||
"PLACEHOLDER": "Sähköposti, esim: someone@example.fi",
|
||||
"PLACEHOLDER": "Sähköposti, esim: someone{'@'}example.fi",
|
||||
"ERROR": "Ole hyvä ja syötä validi sähköposti"
|
||||
},
|
||||
"PASSWORD": {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
"EMAIL": {
|
||||
"LABEL": "Työsähköposti",
|
||||
"PLACEHOLDER": "Anna työsähköpostiosoiteeesi, esim: ismo@hassisenkone.fi",
|
||||
"PLACEHOLDER": "Anna työsähköpostiosoiteeesi, esim: ismo{'@'}hassisenkone.fi",
|
||||
"ERROR": "Syötä voimassa oleva työsähköpostiosoite."
|
||||
},
|
||||
"PASSWORD": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"TITLE": "Se connecter à Chatwoot",
|
||||
"EMAIL": {
|
||||
"LABEL": "Courriel",
|
||||
"PLACEHOLDER": "exemple@nomentreprise.fr",
|
||||
"PLACEHOLDER": "exemple{'@'}nomentreprise.fr",
|
||||
"ERROR": "Veuillez saisir une adresse de courriel valide"
|
||||
},
|
||||
"PASSWORD": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"TITLE": "התחבר ל Woot",
|
||||
"EMAIL": {
|
||||
"LABEL": "אימייל",
|
||||
"PLACEHOLDER": "מייל לדוגמא: someone@example.com",
|
||||
"PLACEHOLDER": "מייל לדוגמא: someone{'@'}example.com",
|
||||
"ERROR": "נא הכנס כתובת דוא\"ל תקינה"
|
||||
},
|
||||
"PASSWORD": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"TITLE": "Login to Chatwoot",
|
||||
"EMAIL": {
|
||||
"LABEL": "Email",
|
||||
"PLACEHOLDER": "Email eg: someone@example.com",
|
||||
"PLACEHOLDER": "Email eg: someone{'@'}example.com",
|
||||
"ERROR": "Please enter a valid email address"
|
||||
},
|
||||
"PASSWORD": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"TITLE": "Login to Chatwoot",
|
||||
"EMAIL": {
|
||||
"LABEL": "Email",
|
||||
"PLACEHOLDER": "Email eg: someone@example.com",
|
||||
"PLACEHOLDER": "Email eg: someone{'@'}example.com",
|
||||
"ERROR": "Please enter a valid email address"
|
||||
},
|
||||
"PASSWORD": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"TITLE": "Chatwoot belépés",
|
||||
"EMAIL": {
|
||||
"LABEL": "E-mail",
|
||||
"PLACEHOLDER": "E-mail pl.: valaki@példa.hu",
|
||||
"PLACEHOLDER": "E-mail pl.: valaki{'@'}példa.hu",
|
||||
"ERROR": "Kérjük helyes e-mailcímet adj meg"
|
||||
},
|
||||
"PASSWORD": {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
"EMAIL": {
|
||||
"LABEL": "Munkahelyi e-mail",
|
||||
"PLACEHOLDER": "Add meg munkahelyi e-mailcímed. Pl. kovacs.janos@email.hu",
|
||||
"PLACEHOLDER": "Add meg munkahelyi e-mailcímed. Pl. kovacs.janos{'@'}email.hu",
|
||||
"ERROR": "Please enter a valid work email address"
|
||||
},
|
||||
"PASSWORD": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"TITLE": "Login to Chatwoot",
|
||||
"EMAIL": {
|
||||
"LABEL": "Email",
|
||||
"PLACEHOLDER": "Email eg: someone@example.com",
|
||||
"PLACEHOLDER": "Email eg: someone{'@'}example.com",
|
||||
"ERROR": "Please enter a valid email address"
|
||||
},
|
||||
"PASSWORD": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"TITLE": "Masuk ke Chatwoot",
|
||||
"EMAIL": {
|
||||
"LABEL": "Email",
|
||||
"PLACEHOLDER": "contoh@perusahan-mu.com",
|
||||
"PLACEHOLDER": "contoh{'@'}perusahan-mu.com",
|
||||
"ERROR": "Harap masukkan alamat email yang valid"
|
||||
},
|
||||
"PASSWORD": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"TITLE": "Login to Chatwoot",
|
||||
"EMAIL": {
|
||||
"LABEL": "Tölvupóstfang",
|
||||
"PLACEHOLDER": "Tölvupóstfang t.d. someone@example.com",
|
||||
"PLACEHOLDER": "Tölvupóstfang t.d. someone{'@'}example.com",
|
||||
"ERROR": "Vinsamlegast skrifaðu gilt netfang"
|
||||
},
|
||||
"PASSWORD": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"TITLE": "Accedi a Chatwoot",
|
||||
"EMAIL": {
|
||||
"LABEL": "Email",
|
||||
"PLACEHOLDER": "Email es.: qualcuno@esempio.com",
|
||||
"PLACEHOLDER": "Email es.: qualcuno{'@'}esempio.com",
|
||||
"ERROR": "Inserisci un indirizzo email valido"
|
||||
},
|
||||
"PASSWORD": {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
"EMAIL": {
|
||||
"LABEL": "회사 이메일",
|
||||
"PLACEHOLDER": "회사 이메일 주소를 입력하세요. 예: taeyeon@girls.generation",
|
||||
"PLACEHOLDER": "회사 이메일 주소를 입력하세요. 예: taeyeon{'@'}girls.generation",
|
||||
"ERROR": "Please enter a valid work email address"
|
||||
},
|
||||
"PASSWORD": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"TITLE": "Login to Chatwoot",
|
||||
"EMAIL": {
|
||||
"LABEL": "El. paštas",
|
||||
"PLACEHOLDER": "email pavyzdys: someone@example.com",
|
||||
"PLACEHOLDER": "email pavyzdys: someone{'@'}example.com",
|
||||
"ERROR": "Prašau įveskite teisingą el. pašto adresą"
|
||||
},
|
||||
"PASSWORD": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"TITLE": "Login to Chatwoot",
|
||||
"EMAIL": {
|
||||
"LABEL": "E-pasts",
|
||||
"PLACEHOLDER": "piemers@firmasnosaukums.com",
|
||||
"PLACEHOLDER": "piemers{'@'}firmasnosaukums.com",
|
||||
"ERROR": "Lūdzu, ievadiet derīgu e-pasta adresi"
|
||||
},
|
||||
"PASSWORD": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"TITLE": "Inloggen bij Chatwoot",
|
||||
"EMAIL": {
|
||||
"LABEL": "E-mailadres",
|
||||
"PLACEHOLDER": "voorbeeld@bedrijfsnaam.nl",
|
||||
"PLACEHOLDER": "voorbeeld{'@'}bedrijfsnaam.nl",
|
||||
"ERROR": "Voer een geldig e-mailadres in"
|
||||
},
|
||||
"PASSWORD": {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
"EMAIL": {
|
||||
"LABEL": "Bedriftse-postadresse",
|
||||
"PLACEHOLDER": "Skriv inn din profesjonelle e-postadresse. F.eks: ola@olasbedrift.no",
|
||||
"PLACEHOLDER": "Skriv inn din profesjonelle e-postadresse. F.eks: ola{'@'}olasbedrift.no",
|
||||
"ERROR": "Please enter a valid work email address"
|
||||
},
|
||||
"PASSWORD": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"TITLE": "Zaloguj się do Chatwoot",
|
||||
"EMAIL": {
|
||||
"LABEL": "E-mail",
|
||||
"PLACEHOLDER": "przyklad@nazwafirmy.com",
|
||||
"PLACEHOLDER": "przyklad{'@'}nazwafirmy.com",
|
||||
"ERROR": "Wprowadź poprawny adres e-mail"
|
||||
},
|
||||
"PASSWORD": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"TITLE": "Entrar no Chatwoot",
|
||||
"EMAIL": {
|
||||
"LABEL": "E-mail",
|
||||
"PLACEHOLDER": "exemplo@nomedaempresa.pt",
|
||||
"PLACEHOLDER": "exemplo{'@'}nomedaempresa.pt",
|
||||
"ERROR": "Por favor, insira um endereço de e-mail válido"
|
||||
},
|
||||
"PASSWORD": {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
"EMAIL": {
|
||||
"LABEL": "E-mail de trabalho",
|
||||
"PLACEHOLDER": "Digite o seu endereço de e-mail profissional. Por exemplo: geral@informatico.pt",
|
||||
"PLACEHOLDER": "Digite o seu endereço de e-mail profissional. Por exemplo: geral{'@'}informatico.pt",
|
||||
"ERROR": "Please enter a valid work email address"
|
||||
},
|
||||
"PASSWORD": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"TITLE": "Entrar no Chatwoot",
|
||||
"EMAIL": {
|
||||
"LABEL": "e-mail",
|
||||
"PLACEHOLDER": "exemplo@empresa.com",
|
||||
"PLACEHOLDER": "exemplo{'@'}empresa.com",
|
||||
"ERROR": "Digite um endereço de e-mail válido"
|
||||
},
|
||||
"PASSWORD": {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
"EMAIL": {
|
||||
"LABEL": "E-mail comercial",
|
||||
"PLACEHOLDER": "Digite seu e-mail de trabalho. Ex.: bruce@wayne.com.br",
|
||||
"PLACEHOLDER": "Digite seu e-mail de trabalho. Ex.: bruce{'@'}wayne.com.br",
|
||||
"ERROR": "Por favor, insira um endereço de e-mail de trabalho válido."
|
||||
},
|
||||
"PASSWORD": {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"TITLE": "Conectează-te la Chatwoot",
|
||||
"EMAIL": {
|
||||
"LABEL": "E-mail",
|
||||
"PLACEHOLDER": "exemplu@companyname.com",
|
||||
"PLACEHOLDER": "exemplu{'@'}companyname.com",
|
||||
"ERROR": "Vă rugăm să introduceți o adresă de e-mail validă"
|
||||
},
|
||||
"PASSWORD": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user