Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
320e7ea246 | ||
|
|
c1f6d9f76f | ||
|
|
ed562832a6 |
+1
-1
@@ -799,7 +799,7 @@ GEM
|
||||
unf_ext (0.0.8.2)
|
||||
unicode-display_width (2.4.2)
|
||||
uniform_notifier (1.16.0)
|
||||
uri (0.13.0)
|
||||
uri (1.0.3)
|
||||
uri_template (0.7.0)
|
||||
valid_email2 (5.2.6)
|
||||
activemodel (>= 3.2)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
button,
|
||||
input[type="button"],
|
||||
input[type="reset"],
|
||||
input[type="submit"],
|
||||
.button {
|
||||
button:not(.reset-base),
|
||||
input[type='button']:not(.reset-base),
|
||||
input[type='reset']:not(.reset-base),
|
||||
input[type='submit']:not(.reset-base),
|
||||
.button:not(.reset-base) {
|
||||
appearance: none;
|
||||
background-color: $color-woot;
|
||||
border: 0;
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
|
||||
.icon-container {
|
||||
margin-right: 2px;
|
||||
|
||||
}
|
||||
|
||||
.value-container {
|
||||
|
||||
@@ -78,7 +78,11 @@ class AccountDashboard < Administrate::BaseDashboard
|
||||
# COLLECTION_FILTERS = {
|
||||
# open: ->(resources) { resources.where(open: true) }
|
||||
# }.freeze
|
||||
COLLECTION_FILTERS = {}.freeze
|
||||
COLLECTION_FILTERS = {
|
||||
active: ->(resources) { resources.where(status: :active) },
|
||||
suspended: ->(resources) { resources.where(status: :suspended) },
|
||||
recent: ->(resources) { resources.where('created_at > ?', 30.days.ago) }
|
||||
}.freeze
|
||||
|
||||
# Overwrite this method to customize how accounts are displayed
|
||||
# across all pages of the admin dashboard.
|
||||
|
||||
@@ -94,7 +94,12 @@ class UserDashboard < Administrate::BaseDashboard
|
||||
# COLLECTION_FILTERS = {
|
||||
# open: ->(resources) { resources.where(open: true) }
|
||||
# }.freeze
|
||||
COLLECTION_FILTERS = {}.freeze
|
||||
COLLECTION_FILTERS = {
|
||||
super_admin: ->(resources) { resources.where(type: 'SuperAdmin') },
|
||||
confirmed: ->(resources) { resources.where.not(confirmed_at: nil) },
|
||||
unconfirmed: ->(resources) { resources.where(confirmed_at: nil) },
|
||||
recent: ->(resources) { resources.where('created_at > ?', 30.days.ago) }
|
||||
}.freeze
|
||||
|
||||
# Overwrite this method to customize how users are displayed
|
||||
# across all pages of the admin dashboard.
|
||||
|
||||
@@ -4,7 +4,6 @@ import AddAccountModal from '../dashboard/components/layout/sidebarComponents/Ad
|
||||
import LoadingState from './components/widgets/LoadingState.vue';
|
||||
import NetworkNotification from './components/NetworkNotification.vue';
|
||||
import UpdateBanner from './components/app/UpdateBanner.vue';
|
||||
import UpgradeBanner from './components/app/UpgradeBanner.vue';
|
||||
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
|
||||
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
|
||||
import vueActionCable from './helper/actionCable';
|
||||
@@ -31,7 +30,6 @@ export default {
|
||||
UpdateBanner,
|
||||
PaymentPendingBanner,
|
||||
WootSnackbarBox,
|
||||
UpgradeBanner,
|
||||
PendingEmailVerificationBanner,
|
||||
},
|
||||
setup() {
|
||||
@@ -146,7 +144,6 @@ export default {
|
||||
<template v-if="currentAccountId">
|
||||
<PendingEmailVerificationBanner v-if="hideOnOnboardingView" />
|
||||
<PaymentPendingBanner v-if="hideOnOnboardingView" />
|
||||
<UpgradeBanner />
|
||||
</template>
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
const ALWAYS_ON_ROUTES = [
|
||||
'agent_list',
|
||||
'settings_inbox_list',
|
||||
'billing_settings_index',
|
||||
];
|
||||
|
||||
const { accountId } = useAccount();
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
const routeName = computed(() => {
|
||||
return route.name || '';
|
||||
});
|
||||
const isOnChatwootCloud = computed(
|
||||
() => store.getters['globalConfig/isOnChatwootCloud']
|
||||
);
|
||||
const account = computed(() =>
|
||||
store.getters['accounts/getAccount'](accountId.value)
|
||||
);
|
||||
|
||||
const isTrialAccount = computed(() => {
|
||||
if (!account.value) return false;
|
||||
|
||||
const createdAt = new Date(account.value.created_at);
|
||||
const diffDays = differenceInDays(new Date(), createdAt);
|
||||
|
||||
return diffDays <= 15;
|
||||
});
|
||||
|
||||
const testLimit = ({ allowed, consumed }) => {
|
||||
return consumed > allowed;
|
||||
};
|
||||
|
||||
const isLimitExceeded = computed(() => {
|
||||
if (ALWAYS_ON_ROUTES.includes(routeName.value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!account.value) return false;
|
||||
|
||||
const { limits } = account.value;
|
||||
if (!limits) return false;
|
||||
|
||||
const { conversation, non_web_inboxes: nonWebInboxes } = limits;
|
||||
return testLimit(conversation) || testLimit(nonWebInboxes);
|
||||
});
|
||||
|
||||
const shouldShowBanner = computed(() => {
|
||||
if (!isOnChatwootCloud.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isTrialAccount.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isLimitExceeded.value;
|
||||
});
|
||||
|
||||
const fetchLimits = () => store.dispatch('accounts/limits');
|
||||
|
||||
onMounted(() => {
|
||||
if (isOnChatwootCloud.value) {
|
||||
fetchLimits();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="shouldShowBanner">Limits exceeded</div>
|
||||
|
||||
<slot v-else />
|
||||
</template>
|
||||
@@ -9,7 +9,7 @@ import AddAccountModal from 'dashboard/components/layout/sidebarComponents/AddAc
|
||||
import AccountSelector from 'dashboard/components/layout/sidebarComponents/AccountSelector.vue';
|
||||
import AddLabelModal from 'dashboard/routes/dashboard/settings/labels/AddLabel.vue';
|
||||
import NotificationPanel from 'dashboard/routes/dashboard/notifications/components/NotificationPanel.vue';
|
||||
|
||||
import PaymentPaywall from 'dashboard/components/app/PaymentPaywall.vue';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
|
||||
@@ -29,6 +29,7 @@ export default {
|
||||
CommandBar,
|
||||
WootKeyShortcutModal,
|
||||
AddAccountModal,
|
||||
PaymentPaywall,
|
||||
AccountSelector,
|
||||
AddLabelModal,
|
||||
NotificationPanel,
|
||||
@@ -194,7 +195,9 @@ export default {
|
||||
@show-add-label-popup="showAddLabelPopup"
|
||||
/>
|
||||
<main class="flex flex-1 h-full min-h-0 px-0 overflow-hidden">
|
||||
<router-view />
|
||||
<PaymentPaywall>
|
||||
<router-view />
|
||||
</PaymentPaywall>
|
||||
<CommandBar />
|
||||
<AccountSelector
|
||||
:show-account-modal="showAccountModal"
|
||||
|
||||
@@ -23,6 +23,7 @@ const state = {
|
||||
|
||||
export const getters = {
|
||||
getAccount: $state => id => {
|
||||
console.log('account', id);
|
||||
return findRecordById($state, id);
|
||||
},
|
||||
getUIFlags($state) {
|
||||
|
||||
@@ -10,7 +10,6 @@ import router from '../widget/router';
|
||||
import { directive as onClickaway } from 'vue3-click-away';
|
||||
import { domPurifyConfig } from '../shared/helpers/HTMLSanitizer';
|
||||
import { plugin, defaultConfig } from '@formkit/vue';
|
||||
import { createPinia } from 'pinia';
|
||||
|
||||
import {
|
||||
startsWithPlus,
|
||||
@@ -22,11 +21,9 @@ const i18n = createI18n({
|
||||
locale: 'en',
|
||||
messages: i18nMessages,
|
||||
});
|
||||
const pinia = createPinia();
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(i18n);
|
||||
app.use(pinia);
|
||||
app.use(store);
|
||||
app.use(router);
|
||||
app.use(VueDOMPurifyHTML, domPurifyConfig);
|
||||
|
||||
@@ -1,19 +1,3 @@
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 100;
|
||||
font-display: swap;
|
||||
src: url('shared/assets/fonts/Inter/Inter-Thin.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url('shared/assets/fonts/Inter/Inter-Light.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
@@ -22,14 +6,6 @@
|
||||
src: url('shared/assets/fonts/Inter/Inter-Regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('shared/assets/fonts/Inter/Inter-Italic.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
@@ -37,19 +13,3 @@
|
||||
font-display: swap;
|
||||
src: url('shared/assets/fonts/Inter/Inter-Medium.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('shared/assets/fonts/Inter/Inter-SemiBold.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('shared/assets/fonts/Inter/Inter-Bold.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@@ -335,14 +335,14 @@ export default {
|
||||
<template>
|
||||
<div
|
||||
v-if="!conversationSize && isFetchingList"
|
||||
class="flex items-center justify-center flex-1 h-full max-w-96 mx-auto"
|
||||
class="flex items-center justify-center flex-1 h-full bg-black-25"
|
||||
:class="{ dark: prefersDarkMode }"
|
||||
>
|
||||
<Spinner size="" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col justify-end h-full max-w-[600px] mx-auto"
|
||||
class="flex flex-col justify-end h-full"
|
||||
:class="{
|
||||
'is-mobile': isMobile,
|
||||
'is-widget-right': isRightAligned,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import endPoints from 'widget/api/endPoints';
|
||||
import { API } from 'widget/helpers/axios';
|
||||
|
||||
export const getMostReadArticles = async (slug, locale) => {
|
||||
const urlData = endPoints.getMostReadArticles(slug, locale);
|
||||
return API.get(urlData.url, { params: urlData.params });
|
||||
};
|
||||
@@ -97,6 +97,15 @@ const triggerCampaign = ({ websiteToken, campaignId, customAttributes }) => ({
|
||||
},
|
||||
});
|
||||
|
||||
const getMostReadArticles = (slug, locale) => ({
|
||||
url: `/hc/${slug}/${locale}/articles.json`,
|
||||
params: {
|
||||
page: 1,
|
||||
sort: 'views',
|
||||
status: 1,
|
||||
},
|
||||
});
|
||||
|
||||
export default {
|
||||
createConversation,
|
||||
sendMessage,
|
||||
@@ -106,4 +115,5 @@ export default {
|
||||
getAvailableAgents,
|
||||
getCampaigns,
|
||||
triggerCampaign,
|
||||
getMostReadArticles,
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ $input-height: $space-two * 2;
|
||||
box-sizing: border-box;
|
||||
color: $color-body;
|
||||
display: block;
|
||||
font-family: $font-family;
|
||||
font-size: $font-size-medium;
|
||||
height: $input-height;
|
||||
line-height: 1.5;
|
||||
@@ -68,6 +69,7 @@ $input-height: $space-two * 2;
|
||||
|
||||
// Form element: Textarea
|
||||
textarea.form-input {
|
||||
font-family: $font-family;
|
||||
|
||||
@include placeholder {
|
||||
color: $color-light-gray;
|
||||
|
||||
@@ -72,5 +72,16 @@ $line-height: 1;
|
||||
$footer-height: 11.2rem;
|
||||
$header-expanded-height: $space-medium * 10;
|
||||
|
||||
$font-family: 'Inter',
|
||||
-apple-system,
|
||||
system-ui,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
'Helvetica Neue',
|
||||
Tahoma,
|
||||
Arial,
|
||||
sans-serif;
|
||||
|
||||
// Break points
|
||||
$break-point-medium: 667px;
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
|
||||
html,
|
||||
body {
|
||||
@apply antialiased h-full font-inter bg-n-background;
|
||||
font-family: $font-family;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.is-mobile {
|
||||
@@ -40,6 +43,10 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
ul {
|
||||
list-style: disc;
|
||||
@@ -75,212 +82,3 @@ body {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@layer base {
|
||||
|
||||
/* NEXT COLORS START */
|
||||
:root {
|
||||
/* slate */
|
||||
--slate-1: 252 252 253;
|
||||
--slate-2: 249 249 251;
|
||||
--slate-3: 240 240 243;
|
||||
--slate-4: 232 232 236;
|
||||
--slate-5: 224 225 230;
|
||||
--slate-6: 217 217 224;
|
||||
--slate-7: 205 206 214;
|
||||
--slate-8: 185 187 198;
|
||||
--slate-9: 139 141 152;
|
||||
--slate-10: 128 131 141;
|
||||
--slate-11: 96 100 108;
|
||||
--slate-12: 28 32 36;
|
||||
|
||||
--iris-1: 253 253 255;
|
||||
--iris-2: 248 248 255;
|
||||
--iris-3: 240 241 254;
|
||||
--iris-4: 230 231 255;
|
||||
--iris-5: 218 220 255;
|
||||
--iris-6: 203 205 255;
|
||||
--iris-7: 184 186 248;
|
||||
--iris-8: 155 158 240;
|
||||
--iris-9: 91 91 214;
|
||||
--iris-10: 81 81 205;
|
||||
--iris-11: 87 83 198;
|
||||
--iris-12: 39 41 98;
|
||||
|
||||
--ruby-1: 255 252 253;
|
||||
--ruby-2: 255 247 248;
|
||||
--ruby-3: 254 234 237;
|
||||
--ruby-4: 255 220 225;
|
||||
--ruby-5: 255 206 214;
|
||||
--ruby-6: 248 191 200;
|
||||
--ruby-7: 239 172 184;
|
||||
--ruby-8: 229 146 163;
|
||||
--ruby-9: 229 70 102;
|
||||
--ruby-10: 220 59 93;
|
||||
--ruby-11: 202 36 77;
|
||||
--ruby-12: 100 23 43;
|
||||
|
||||
--amber-1: 254 253 251;
|
||||
--amber-2: 254 251 233;
|
||||
--amber-3: 255 247 194;
|
||||
--amber-4: 255 238 156;
|
||||
--amber-5: 251 229 119;
|
||||
--amber-6: 243 214 115;
|
||||
--amber-7: 233 193 98;
|
||||
--amber-8: 226 163 54;
|
||||
--amber-9: 255 197 61;
|
||||
--amber-10: 255 186 24;
|
||||
--amber-11: 171 100 0;
|
||||
--amber-12: 79 52 34;
|
||||
|
||||
--teal-1: 250 254 253;
|
||||
--teal-2: 243 251 249;
|
||||
--teal-3: 224 248 243;
|
||||
--teal-4: 204 243 234;
|
||||
--teal-5: 184 234 224;
|
||||
--teal-6: 161 222 210;
|
||||
--teal-7: 131 205 193;
|
||||
--teal-8: 83 185 171;
|
||||
--teal-9: 18 165 148;
|
||||
--teal-10: 13 155 138;
|
||||
--teal-11: 0 133 115;
|
||||
--teal-12: 13 61 56;
|
||||
|
||||
--gray-1: 252 252 252;
|
||||
--gray-2: 249 249 249;
|
||||
--gray-3: 240 240 240;
|
||||
--gray-4: 232 232 232;
|
||||
--gray-5: 224 224 224;
|
||||
--gray-6: 217 217 217;
|
||||
--gray-7: 206 206 206;
|
||||
--gray-8: 187 187 187;
|
||||
--gray-9: 141 141 141;
|
||||
--gray-10: 131 131 131;
|
||||
--gray-11: 100 100 100;
|
||||
--gray-12: 32 32 32;
|
||||
|
||||
--background-color: 253 253 253;
|
||||
--text-blue: 8 109 224;
|
||||
--border-container: 236 236 236;
|
||||
--border-strong: 235 235 235;
|
||||
--border-weak: 234 234 234;
|
||||
--solid-1: 255 255 255;
|
||||
--solid-2: 255 255 255;
|
||||
--solid-3: 255 255 255;
|
||||
--solid-active: 255 255 255;
|
||||
--solid-amber: 252 232 193;
|
||||
--solid-blue: 218 236 255;
|
||||
--solid-iris: 230 231 255;
|
||||
|
||||
--alpha-1: 67, 67, 67, 0.06;
|
||||
--alpha-2: 201, 202, 207, 0.15;
|
||||
--alpha-3: 255, 255, 255, 0.96;
|
||||
--black-alpha-1: 0, 0, 0, 0.12;
|
||||
--black-alpha-2: 0, 0, 0, 0.04;
|
||||
--border-blue: 39, 129, 246, 0.5;
|
||||
--white-alpha: 255, 255, 255, 0.8;
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* slate */
|
||||
--slate-1: 17 17 19;
|
||||
--slate-2: 24 25 27;
|
||||
--slate-3: 33 34 37;
|
||||
--slate-4: 39 42 45;
|
||||
--slate-5: 46 49 53;
|
||||
--slate-6: 54 58 63;
|
||||
--slate-7: 67 72 78;
|
||||
--slate-8: 90 97 105;
|
||||
--slate-9: 105 110 119;
|
||||
--slate-10: 119 123 132;
|
||||
--slate-11: 176 180 186;
|
||||
--slate-12: 237 238 240;
|
||||
|
||||
--iris-1: 19 19 30;
|
||||
--iris-2: 23 22 37;
|
||||
--iris-3: 32 34 72;
|
||||
--iris-4: 38 42 101;
|
||||
--iris-5: 48 51 116;
|
||||
--iris-6: 61 62 130;
|
||||
--iris-7: 74 74 149;
|
||||
--iris-8: 89 88 177;
|
||||
--iris-9: 91 91 214;
|
||||
--iris-10: 84 114 228;
|
||||
--iris-11: 158 177 255;
|
||||
--iris-12: 224 223 254;
|
||||
|
||||
--ruby-1: 25 17 19;
|
||||
--ruby-2: 30 21 23;
|
||||
--ruby-3: 58 20 30;
|
||||
--ruby-4: 78 19 37;
|
||||
--ruby-5: 94 26 46;
|
||||
--ruby-6: 111 37 57;
|
||||
--ruby-7: 136 52 71;
|
||||
--ruby-8: 179 68 90;
|
||||
--ruby-9: 229 70 102;
|
||||
--ruby-10: 236 90 114;
|
||||
--ruby-11: 255 148 157;
|
||||
--ruby-12: 254 210 225;
|
||||
|
||||
--amber-1: 22 18 12;
|
||||
--amber-2: 29 24 15;
|
||||
--amber-3: 48 32 8;
|
||||
--amber-4: 63 39 0;
|
||||
--amber-5: 77 48 0;
|
||||
--amber-6: 92 61 5;
|
||||
--amber-7: 113 79 25;
|
||||
--amber-8: 143 100 36;
|
||||
--amber-9: 255 197 61;
|
||||
--amber-10: 255 214 10;
|
||||
--amber-11: 255 202 22;
|
||||
--amber-12: 255 231 179;
|
||||
|
||||
--teal-1: 13 21 20;
|
||||
--teal-2: 17 28 27;
|
||||
--teal-3: 13 45 42;
|
||||
--teal-4: 2 59 55;
|
||||
--teal-5: 8 72 67;
|
||||
--teal-6: 20 87 80;
|
||||
--teal-7: 28 105 97;
|
||||
--teal-8: 32 126 115;
|
||||
--teal-9: 18 165 148;
|
||||
--teal-10: 14 179 158;
|
||||
--teal-11: 11 216 182;
|
||||
--teal-12: 173 240 221;
|
||||
|
||||
--gray-1: 17 17 17;
|
||||
--gray-2: 25 25 25;
|
||||
--gray-3: 34 34 34;
|
||||
--gray-4: 42 42 42;
|
||||
--gray-5: 49 49 49;
|
||||
--gray-6: 58 58 58;
|
||||
--gray-7: 72 72 72;
|
||||
--gray-8: 96 96 96;
|
||||
--gray-9: 110 110 110;
|
||||
--gray-10: 123 123 123;
|
||||
--gray-11: 180 180 180;
|
||||
--gray-12: 238 238 238;
|
||||
|
||||
--background-color: 18 18 19;
|
||||
--border-strong: 52 52 52;
|
||||
--border-weak: 38 38 42;
|
||||
--solid-1: 23 23 26;
|
||||
--solid-2: 29 30 36;
|
||||
--solid-3: 44 45 54;
|
||||
--solid-active: 53 57 66;
|
||||
--solid-amber: 42 37 30;
|
||||
--solid-blue: 16 49 91;
|
||||
--solid-iris: 38 42 101;
|
||||
--text-blue: 126 182 255;
|
||||
|
||||
--alpha-1: 36, 36, 36, 0.8;
|
||||
--alpha-2: 139, 147, 182, 0.15;
|
||||
--alpha-3: 36, 38, 45, 0.9;
|
||||
--black-alpha-1: 0, 0, 0, 0.3;
|
||||
--black-alpha-2: 0, 0, 0, 0.2;
|
||||
--border-blue: 39, 129, 246, 0.5;
|
||||
--border-container: 236, 236, 236, 0;
|
||||
--white-alpha: 255, 255, 255, 0.1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div class="py-4 space-y-4 bg-white dark:bg-slate-700">
|
||||
<div class="space-y-2 animate-pulse">
|
||||
<div class="h-6 bg-slate-100 dark:bg-slate-500 rounded w-2/5" />
|
||||
</div>
|
||||
<div class="space-y-2 animate-pulse">
|
||||
<div class="h-4 bg-slate-100 dark:bg-slate-500 rounded" />
|
||||
<div class="h-4 bg-slate-100 dark:bg-slate-500 rounded" />
|
||||
<div class="h-4 bg-slate-100 dark:bg-slate-500 rounded" />
|
||||
</div>
|
||||
<div class="space-y-2 animate-pulse">
|
||||
<div class="h-4 bg-slate-100 dark:bg-slate-500 rounded w-1/5" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import ArticleList from './ArticleList.vue';
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
|
||||
export default {
|
||||
components: { FluentIcon, ArticleList },
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
articles: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
emits: ['view', 'viewAll'],
|
||||
computed: {
|
||||
...mapGetters({ widgetColor: 'appConfig/getWidgetColor' }),
|
||||
},
|
||||
methods: {
|
||||
onArticleClick(link) {
|
||||
this.$emit('view', link);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="mb-0 text-sm font-medium text-slate-800 dark:text-slate-50">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<ArticleList :articles="articles" @select-article="onArticleClick" />
|
||||
<button
|
||||
class="inline-flex items-center justify-between px-2 py-1 -ml-2 text-sm font-medium leading-6 rounded-md text-slate-800 dark:text-slate-50 hover:bg-slate-25 dark:hover:bg-slate-800 see-articles"
|
||||
:style="{ color: widgetColor }"
|
||||
@click="$emit('viewAll')"
|
||||
>
|
||||
<span class="pr-2 text-sm">{{ $t('PORTAL.VIEW_ALL_ARTICLES') }}</span>
|
||||
<FluentIcon icon="arrow-right" size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.see-articles {
|
||||
color: var(--brand-textButtonClear);
|
||||
svg {
|
||||
color: var(--brand-textButtonClear);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script>
|
||||
import CategoryCard from './ArticleCategoryCard.vue';
|
||||
export default {
|
||||
components: { CategoryCard },
|
||||
props: {
|
||||
articles: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
emits: ['view', 'viewAll'],
|
||||
methods: {
|
||||
onArticleClick(link) {
|
||||
this.$emit('view', link);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CategoryCard
|
||||
:title="$t('PORTAL.POPULAR_ARTICLES')"
|
||||
:articles="articles.slice(0, 6)"
|
||||
@view-all="$emit('viewAll')"
|
||||
@view="onArticleClick"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script>
|
||||
import ArticleListItem from './ArticleListItem.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ArticleListItem,
|
||||
},
|
||||
props: {
|
||||
articles: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
emits: ['selectArticle'],
|
||||
data() {
|
||||
return {};
|
||||
},
|
||||
methods: {
|
||||
onClick(link) {
|
||||
this.$emit('selectArticle', link);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul role="list" class="py-2">
|
||||
<ArticleListItem
|
||||
v-for="article in articles"
|
||||
:key="article.slug"
|
||||
:link="article.link"
|
||||
:title="article.title"
|
||||
@select-article="onClick"
|
||||
/>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script>
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
|
||||
export default {
|
||||
components: { FluentIcon },
|
||||
props: {
|
||||
link: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
emits: ['selectArticle'],
|
||||
data() {
|
||||
return {};
|
||||
},
|
||||
methods: {
|
||||
onClick() {
|
||||
this.$emit('selectArticle', this.link);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li
|
||||
class="py-1 flex items-center justify-between -mx-1 px-1 hover:bg-slate-25 dark:hover:bg-slate-600 rounded cursor-pointer text-slate-700 dark:text-slate-50 dark:hover:text-slate-25 hover:text-slate-900"
|
||||
role="button"
|
||||
@click="onClick"
|
||||
>
|
||||
<button class="underline-offset-2 text-sm leading-6 text-left">
|
||||
{{ title }}
|
||||
</button>
|
||||
<span class="pl-1 arrow">
|
||||
<FluentIcon icon="arrow-right" size="14" />
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script>
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
export default {
|
||||
props: {
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
emits: ['search'],
|
||||
data() {
|
||||
return {
|
||||
searchQuery: '',
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
handleInput: debounce(
|
||||
() => {
|
||||
this.$emit('search', this.searchQuery);
|
||||
},
|
||||
500,
|
||||
true
|
||||
),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex items-center">
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 flex items-center px-2 py-2 text-slate-500"
|
||||
>
|
||||
<fluent-icon icon="search" size="14" />
|
||||
</div>
|
||||
<input
|
||||
id="search"
|
||||
v-model="searchQuery"
|
||||
:placeholder="placeholder"
|
||||
type="text"
|
||||
name="search"
|
||||
class="block w-full h-8 px-2 pl-6 pr-1 text-sm border rounded-md focus-visible:outline-none text-slate-800 border-slate-100 bg-slate-75 placeholder:text-slate-400 focus:ring focus:border-woot-500 focus:ring-woot-200 hover:border-woot-200"
|
||||
@input="handleInput"
|
||||
/>
|
||||
<div class="absolute inset-y-0 right-0 flex py-1.5 pr-1.5">
|
||||
<kbd
|
||||
class="inline-flex items-center px-1 font-sans border rounded border-slate-200 text-xxs text-slate-400"
|
||||
>
|
||||
{{ '⌘K' }}
|
||||
</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script>
|
||||
import GroupedAvatars from 'widget/components/GroupedAvatars.vue';
|
||||
|
||||
export default {
|
||||
name: 'AvailableAgents',
|
||||
components: { GroupedAvatars },
|
||||
props: {
|
||||
agents: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
users() {
|
||||
return this.agents.slice(0, 4).map(agent => ({
|
||||
id: agent.id,
|
||||
avatar: agent.avatar_url,
|
||||
name: agent.name,
|
||||
}));
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<GroupedAvatars :users="users" />
|
||||
</template>
|
||||
@@ -1,36 +1,45 @@
|
||||
<script setup>
|
||||
<script>
|
||||
import HeaderActions from './HeaderActions.vue';
|
||||
import { computed } from 'vue';
|
||||
import { useDarkMode } from 'widget/composables/useDarkMode';
|
||||
|
||||
const props = defineProps({
|
||||
avatarUrl: {
|
||||
type: String,
|
||||
default: '',
|
||||
export default {
|
||||
name: 'ChatHeaderExpanded',
|
||||
components: {
|
||||
HeaderActions,
|
||||
},
|
||||
introHeading: {
|
||||
type: String,
|
||||
default: '',
|
||||
props: {
|
||||
avatarUrl: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
introHeading: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
introBody: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showPopoutButton: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
introBody: {
|
||||
type: String,
|
||||
default: '',
|
||||
setup() {
|
||||
const { getThemeClass } = useDarkMode();
|
||||
return { getThemeClass };
|
||||
},
|
||||
showPopoutButton: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const containerClasses = computed(() => [
|
||||
props.avatarUrl ? 'justify-between' : 'justify-end',
|
||||
]);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
class="header-expanded pt-6 pb-4 px-5 relative box-border w-full bg-transparent"
|
||||
>
|
||||
<div class="flex items-start" :class="containerClasses">
|
||||
<div
|
||||
class="flex items-start"
|
||||
:class="[avatarUrl ? 'justify-between' : 'justify-end']"
|
||||
>
|
||||
<img
|
||||
v-if="avatarUrl"
|
||||
class="h-12 rounded-full"
|
||||
@@ -44,11 +53,13 @@ const containerClasses = computed(() => [
|
||||
</div>
|
||||
<h2
|
||||
v-dompurify-html="introHeading"
|
||||
class="mt-4 text-2xl mb-1.5 font-medium text-n-slate-12"
|
||||
class="mt-4 text-2xl mb-1.5 font-medium"
|
||||
:class="getThemeClass('text-slate-900', 'dark:text-slate-50')"
|
||||
/>
|
||||
<p
|
||||
v-dompurify-html="introBody"
|
||||
class="text-lg leading-normal text-n-slate-11"
|
||||
class="text-base leading-normal"
|
||||
:class="getThemeClass('text-slate-700', 'dark:text-slate-200')"
|
||||
/>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
@@ -1,28 +1,26 @@
|
||||
<script setup>
|
||||
<script>
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
import { defineProps, computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
users: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
export default {
|
||||
name: 'GroupedAvatars',
|
||||
components: { Thumbnail },
|
||||
props: {
|
||||
users: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
limit: {
|
||||
type: Number,
|
||||
default: 4,
|
||||
},
|
||||
});
|
||||
|
||||
const usersToDisplay = computed(() => props.users.slice(0, props.limit));
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex">
|
||||
<span
|
||||
v-for="(user, index) in usersToDisplay"
|
||||
v-for="(user, index) in users"
|
||||
:key="user.id"
|
||||
:class="index ? '-ml-4' : ''"
|
||||
class="inline-block rounded-full text-white shadow-solid"
|
||||
:class="`${
|
||||
index ? '-ml-4' : ''
|
||||
} inline-block rounded-full text-white shadow-solid`"
|
||||
>
|
||||
<Thumbnail
|
||||
size="36px"
|
||||
|
||||
@@ -2,22 +2,24 @@
|
||||
import { mapGetters } from 'vuex';
|
||||
import { getContrastingTextColor } from '@chatwoot/utils';
|
||||
import nextAvailabilityTime from 'widget/mixins/nextAvailabilityTime';
|
||||
import AvailableAgents from 'widget/components/AvailableAgents.vue';
|
||||
import configMixin from 'widget/mixins/configMixin';
|
||||
import availabilityMixin from 'widget/mixins/availability';
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
import { IFrameHelper } from 'widget/helpers/utils';
|
||||
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
|
||||
import GroupedAvatars from 'widget/components/GroupedAvatars.vue';
|
||||
|
||||
export default {
|
||||
name: 'TeamAvailability',
|
||||
components: {
|
||||
GroupedAvatars,
|
||||
AvailableAgents,
|
||||
FluentIcon,
|
||||
},
|
||||
mixins: [configMixin, nextAvailabilityTime, availabilityMixin],
|
||||
props: {
|
||||
availableAgents: {
|
||||
type: Array,
|
||||
default: () => { },
|
||||
default: () => {},
|
||||
},
|
||||
hasConversation: {
|
||||
type: Boolean,
|
||||
@@ -33,13 +35,6 @@ export default {
|
||||
textColor() {
|
||||
return getContrastingTextColor(this.widgetColor);
|
||||
},
|
||||
agentAvatars() {
|
||||
return this.availableAgents.map(agent => ({
|
||||
name: agent.name,
|
||||
avatar: agent.avatar_url,
|
||||
id: agent.id,
|
||||
}));
|
||||
},
|
||||
isOnline() {
|
||||
const { workingHoursEnabled } = this.channelConfig;
|
||||
const anyAgentOnline = this.availableAgents.length > 0;
|
||||
@@ -66,37 +61,35 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-3 w-full shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-5 py-4"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="font-medium text-n-slate-12">
|
||||
<div class="p-4 bg-white rounded-md shadow-sm dark:bg-slate-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="">
|
||||
<div class="text-sm font-medium text-slate-700 dark:text-slate-50">
|
||||
{{
|
||||
isOnline
|
||||
? $t('TEAM_AVAILABILITY.ONLINE')
|
||||
: $t('TEAM_AVAILABILITY.OFFLINE')
|
||||
}}
|
||||
</div>
|
||||
<div class="text-n-slate-11">
|
||||
<div class="mt-1 text-sm text-slate-500 dark:text-slate-100">
|
||||
{{ replyWaitMessage }}
|
||||
</div>
|
||||
</div>
|
||||
<GroupedAvatars v-if="isOnline" :users="agentAvatars" />
|
||||
<AvailableAgents v-if="isOnline" :agents="availableAgents" />
|
||||
</div>
|
||||
<button
|
||||
class="inline-flex items-center font-medium text-n-slate-12"
|
||||
class="inline-flex items-center justify-between px-2 py-1 mt-2 -ml-2 text-sm font-medium leading-6 rounded-md text-slate-800 dark:text-slate-50 hover:bg-slate-25 dark:hover:bg-slate-800"
|
||||
:style="{ color: widgetColor }"
|
||||
@click="startConversation"
|
||||
>
|
||||
<span class="pr-1">
|
||||
<span class="pr-2 text-sm">
|
||||
{{
|
||||
hasConversation
|
||||
? $t('CONTINUE_CONVERSATION')
|
||||
: $t('START_CONVERSATION')
|
||||
}}
|
||||
</span>
|
||||
<i class="i-lucide-chevron-right text-lg" />
|
||||
<FluentIcon icon="arrow-right" size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -103,13 +103,13 @@ export default {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-full h-full"
|
||||
class="w-full h-full bg-slate-25 dark:bg-slate-800"
|
||||
:class="{ 'overflow-auto': isOnHomeView }"
|
||||
@keydown.esc="closeWindow"
|
||||
>
|
||||
<div class="relative flex flex-col h-full">
|
||||
<div
|
||||
class=""
|
||||
class="sticky top-0 z-40 transition-all header-wrap"
|
||||
:class="{
|
||||
expanded: !isHeaderCollapsed,
|
||||
collapsed: isHeaderCollapsed,
|
||||
@@ -148,4 +148,21 @@ export default {
|
||||
.custom-header-shadow {
|
||||
@include shadow-large;
|
||||
}
|
||||
|
||||
.header-wrap {
|
||||
flex-shrink: 0;
|
||||
transition: max-height 100ms;
|
||||
|
||||
&.expanded {
|
||||
max-height: 16rem;
|
||||
}
|
||||
|
||||
&.collapsed {
|
||||
max-height: 4.5rem;
|
||||
}
|
||||
|
||||
@media only screen and (min-device-width: 320px) and (max-device-width: 667px) {
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
<script setup>
|
||||
import { defineProps, defineEmits, computed } from 'vue';
|
||||
import ArticleListItem from './ArticleListItem.vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
const props = defineProps({
|
||||
articles: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['view', 'viewAll']);
|
||||
const widgetColor = useMapGetter('appConfig/getWidgetColor');
|
||||
const articlesToDisplay = computed(() => props.articles.slice(0, 6));
|
||||
|
||||
const onArticleClick = link => {
|
||||
emit('view', link);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 pb-4">
|
||||
<h3 class="font-medium text-n-slate-12">
|
||||
{{ $t('PORTAL.POPULAR_ARTICLES') }}
|
||||
</h3>
|
||||
<div class="flex flex-col gap-4">
|
||||
<ArticleListItem
|
||||
v-for="article in articlesToDisplay"
|
||||
:key="article.slug"
|
||||
:link="article.link"
|
||||
:title="article.title"
|
||||
@select-article="onArticleClick"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="font-medium tracking-wide inline-flex"
|
||||
:style="{ color: widgetColor }"
|
||||
@click="$emit('viewAll')"
|
||||
>
|
||||
<span>{{ $t('PORTAL.VIEW_ALL_ARTICLES') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,74 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue';
|
||||
import ArticleBlock from 'widget/components/pageComponents/Home/Article/ArticleBlock.vue';
|
||||
import ArticleCardSkeletonLoader from 'widget/components/pageComponents/Home/Article/SkeletonLoader.vue';
|
||||
import { useArticleStore } from 'widget/stores/articleStore';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useDarkMode } from 'widget/composables/useDarkMode';
|
||||
|
||||
const store = useArticleStore();
|
||||
const router = useRouter();
|
||||
const { prefersDarkMode } = useDarkMode();
|
||||
|
||||
const portal = computed(() => window.chatwootWebChannel.portal);
|
||||
|
||||
const locale = computed(() => {
|
||||
const { locale: selectedLocale } = useI18n();
|
||||
|
||||
const {
|
||||
allowed_locales: allowedLocales,
|
||||
default_locale: defaultLocale = 'en',
|
||||
} = portal.value.config;
|
||||
|
||||
// IMPORTANT: Variation strict locale matching, Follow iso_639_1_code
|
||||
// If the exact match of a locale is available in the list of portal locales, return it
|
||||
// Else return the default locale. Eg: `es` will not work if `es_ES` is available in the list
|
||||
if (allowedLocales.includes(selectedLocale)) {
|
||||
return locale;
|
||||
}
|
||||
return defaultLocale;
|
||||
});
|
||||
|
||||
const fetchArticles = () => {
|
||||
if (portal.value && !store.getRecords.length) {
|
||||
store.index({ slug: portal.value.slug, locale: locale.value });
|
||||
}
|
||||
};
|
||||
|
||||
const openArticleInArticleViewer = link => {
|
||||
let linkToOpen = `${link}?show_plain_layout=true`;
|
||||
if (prefersDarkMode) {
|
||||
linkToOpen = `${linkToOpen}&theme=dark`;
|
||||
}
|
||||
router.push({ name: 'article-viewer', query: { link: linkToOpen } });
|
||||
};
|
||||
const viewAllArticles = () => {
|
||||
const {
|
||||
portal: { slug },
|
||||
} = window.chatwootWebChannel;
|
||||
openArticleInArticleViewer(`/hc/${slug}/${locale.value}`);
|
||||
};
|
||||
|
||||
const hasArticles = computed(
|
||||
() => !store.getUIFlags.isFetching && !!store.getRecords.length
|
||||
);
|
||||
|
||||
onMounted(() => fetchArticles());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="portal"
|
||||
class="w-full shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-5 py-4"
|
||||
>
|
||||
<ArticleBlock
|
||||
v-if="hasArticles"
|
||||
:articles="store.getRecords"
|
||||
@view="openArticleInArticleViewer"
|
||||
@view-all="viewAllArticles"
|
||||
/>
|
||||
<ArticleCardSkeletonLoader v-else />
|
||||
</div>
|
||||
<div v-else />
|
||||
</template>
|
||||
@@ -1,33 +0,0 @@
|
||||
<script setup>
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
link: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['selectArticle']);
|
||||
|
||||
const onClick = () => {
|
||||
emit('selectArticle', props.link);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-between rounded cursor-pointer text-n-slate-11 hover:text-n-slate-12 gap-2"
|
||||
role="button"
|
||||
@click="onClick"
|
||||
>
|
||||
<button class="underline-offset-2 leading-6 text-left">
|
||||
{{ title }}
|
||||
</button>
|
||||
<span class="i-lucide-chevron-right text-base shrink-0" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,15 +0,0 @@
|
||||
<template>
|
||||
<div class="py-4 space-y-4">
|
||||
<div class="space-y-2 animate-pulse">
|
||||
<div class="h-6 bg-n-slate-7 rounded w-2/5" />
|
||||
</div>
|
||||
<div class="space-y-2 animate-pulse">
|
||||
<div class="h-4 bg-n-slate-7 rounded" />
|
||||
<div class="h-4 bg-n-slate-7 rounded" />
|
||||
<div class="h-4 bg-n-slate-7 rounded" />
|
||||
</div>
|
||||
<div class="space-y-2 animate-pulse">
|
||||
<div class="h-4 bg-n-slate-7 rounded w-1/5" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,94 +0,0 @@
|
||||
const organizeByDay = workingHoursData => {
|
||||
return workingHoursData.reduce((acc, schedule) => {
|
||||
acc[schedule.dayOfWeek] = schedule;
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const toMinutes = (hours, minutes = 0) => {
|
||||
return hours * 60 + (minutes || 0);
|
||||
};
|
||||
|
||||
const calculateMinutesUntilOpen = (
|
||||
schedule,
|
||||
currentTimeInMinutes,
|
||||
dayOffset
|
||||
) => {
|
||||
if (!schedule || schedule.closedAllDay) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let openHour = schedule.openHour;
|
||||
let openMinutes = schedule.openMinutes;
|
||||
|
||||
if (schedule.openAllDay) {
|
||||
openHour = 0;
|
||||
openMinutes = 0;
|
||||
}
|
||||
|
||||
const openTimeInMinutes = toMinutes(openHour, openMinutes);
|
||||
|
||||
if (dayOffset === 0) {
|
||||
if (currentTimeInMinutes < openTimeInMinutes) {
|
||||
return openTimeInMinutes - currentTimeInMinutes;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
toMinutes(24, 0) * dayOffset + openTimeInMinutes - currentTimeInMinutes
|
||||
);
|
||||
};
|
||||
|
||||
class WorkingHours {
|
||||
constructor(workingHoursData) {
|
||||
this.workingHoursByDay = organizeByDay(workingHoursData);
|
||||
}
|
||||
|
||||
isOpen(date) {
|
||||
const schedule = this.workingHoursByDay[date.getDay()];
|
||||
|
||||
if (!schedule || schedule.closedAllDay) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (schedule.openAllDay) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const currentMinutes = toMinutes(date.getHours(), date.getMinutes());
|
||||
const openMinutes = toMinutes(schedule.openHour, schedule.openMinutes);
|
||||
const closeMinutes = toMinutes(schedule.closeHour, schedule.closeMinutes);
|
||||
|
||||
return currentMinutes >= openMinutes && currentMinutes <= closeMinutes;
|
||||
}
|
||||
|
||||
openInMinutes(date) {
|
||||
if (this.isOpen(date)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const currentMinutes = toMinutes(date.getHours(), date.getMinutes());
|
||||
|
||||
for (let i = 0; i < 7; i += 1) {
|
||||
const checkDate = new Date(date);
|
||||
checkDate.setDate(date.getDate() + i);
|
||||
|
||||
const schedule = this.workingHoursByDay[checkDate.getDay()];
|
||||
const minutesUntilOpen = calculateMinutesUntilOpen(
|
||||
schedule,
|
||||
currentMinutes,
|
||||
i
|
||||
);
|
||||
|
||||
if (minutesUntilOpen !== null) {
|
||||
return minutesUntilOpen;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
export default WorkingHours;
|
||||
@@ -1,57 +0,0 @@
|
||||
export default [
|
||||
{
|
||||
dayOfWeek: 0,
|
||||
openHour: 10,
|
||||
openMinutes: 0,
|
||||
closeHour: 16,
|
||||
closeMinutes: 0,
|
||||
closedAllDay: false,
|
||||
openAllDay: false,
|
||||
}, // Sunday
|
||||
{
|
||||
dayOfWeek: 1,
|
||||
openHour: 9,
|
||||
openMinutes: 0,
|
||||
closeHour: 17,
|
||||
closeMinutes: 0,
|
||||
closedAllDay: false,
|
||||
openAllDay: false,
|
||||
}, // Monday
|
||||
{
|
||||
dayOfWeek: 2,
|
||||
openHour: 9,
|
||||
openMinutes: 0,
|
||||
closeHour: 17,
|
||||
closeMinutes: 0,
|
||||
closedAllDay: false,
|
||||
openAllDay: false,
|
||||
}, // Tuesday
|
||||
{
|
||||
dayOfWeek: 3,
|
||||
openHour: 9,
|
||||
openMinutes: 0,
|
||||
closeHour: 17,
|
||||
closeMinutes: 0,
|
||||
closedAllDay: false,
|
||||
openAllDay: false,
|
||||
}, // Wednesday
|
||||
{
|
||||
dayOfWeek: 4,
|
||||
openHour: 9,
|
||||
openMinutes: 0,
|
||||
closeHour: 17,
|
||||
closeMinutes: 0,
|
||||
closedAllDay: false,
|
||||
openAllDay: false,
|
||||
}, // Thursday
|
||||
{
|
||||
dayOfWeek: 5,
|
||||
openHour: 9,
|
||||
openMinutes: 0,
|
||||
closeHour: 17,
|
||||
closeMinutes: 0,
|
||||
closedAllDay: false,
|
||||
openAllDay: false,
|
||||
}, // Friday
|
||||
{ dayOfWeek: 6, closedAllDay: true, openAllDay: false }, // Saturday
|
||||
];
|
||||
@@ -1,95 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import WorkingHours from '../WorkingHoursHelper';
|
||||
import workingHoursData from './WorkingHourFixtures';
|
||||
|
||||
describe('WorkingHours', () => {
|
||||
describe('isOpen', () => {
|
||||
it('returns true during business hours', () => {
|
||||
const workingHours = new WorkingHours(workingHoursData);
|
||||
const date = new Date(2025, 0, 6); // Monday
|
||||
date.setHours(12, 0, 0); // 12:00
|
||||
expect(workingHours.isOpen(date)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false before opening hours', () => {
|
||||
const workingHours = new WorkingHours(workingHoursData);
|
||||
const date = new Date(2025, 0, 6); // Monday
|
||||
date.setHours(8, 0, 0); // 8:00
|
||||
expect(workingHours.isOpen(date)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false after closing hours', () => {
|
||||
const workingHours = new WorkingHours(workingHoursData);
|
||||
const date = new Date(2025, 0, 6); // Monday
|
||||
date.setHours(18, 0, 0); // 18:00
|
||||
expect(workingHours.isOpen(date)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false on closed days', () => {
|
||||
const workingHours = new WorkingHours(workingHoursData);
|
||||
const date = new Date(2025, 0, 4); // Saturday
|
||||
date.setHours(12, 0, 0); // 12:00
|
||||
expect(workingHours.isOpen(date)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for openAllDay', () => {
|
||||
const customData = [...workingHoursData];
|
||||
customData[1] = { ...customData[1], openAllDay: true };
|
||||
const workingHours = new WorkingHours(customData);
|
||||
const date = new Date(2025, 0, 6); // Monday
|
||||
date.setHours(22, 0, 0);
|
||||
expect(workingHours.isOpen(date)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openInMinutes', () => {
|
||||
it('returns 0 when currently open', () => {
|
||||
const workingHours = new WorkingHours(workingHoursData);
|
||||
const date = new Date(2025, 0, 6); // Monday
|
||||
date.setHours(12, 0, 0); // 12:00
|
||||
expect(workingHours.openInMinutes(date)).toBe(0);
|
||||
});
|
||||
|
||||
it('calculates minutes until opening same day', () => {
|
||||
const workingHours = new WorkingHours(workingHoursData);
|
||||
const date = new Date(2025, 0, 6); // Monday
|
||||
date.setHours(8, 0, 0); // 8:00
|
||||
expect(workingHours.openInMinutes(date)).toBe(60); // 60 minutes until 9:00
|
||||
});
|
||||
|
||||
it('calculates minutes until opening next day', () => {
|
||||
const workingHours = new WorkingHours(workingHoursData);
|
||||
const date = new Date(2025, 0, 6); // Monday
|
||||
date.setHours(23, 0, 0); // 23:00
|
||||
// Tuesday opens at 9:00, so it's 10 hours (600 minutes) away
|
||||
expect(workingHours.openInMinutes(date)).toBe(600);
|
||||
});
|
||||
|
||||
it('handles closed days correctly', () => {
|
||||
const workingHours = new WorkingHours(workingHoursData);
|
||||
const date = new Date(2025, 0, 4); // Saturday
|
||||
date.setHours(12, 0, 0); // 12:00
|
||||
// Should find Sunday's opening at 10:00
|
||||
const expectedMinutes = (24 - 12) * 60 + 10 * 60; // Remaining hours today + hours until opening tomorrow
|
||||
expect(workingHours.openInMinutes(date)).toBe(expectedMinutes);
|
||||
});
|
||||
|
||||
it('returns -1 if no opening time found within a week', () => {
|
||||
const closedWeek = workingHoursData.map(day => ({
|
||||
...day,
|
||||
closedAllDay: true,
|
||||
}));
|
||||
const workingHours = new WorkingHours(closedWeek);
|
||||
const date = new Date(2025, 0, 6); // Monday
|
||||
date.setHours(12, 0, 0); // 12:00
|
||||
expect(workingHours.openInMinutes(date)).toBe(-1);
|
||||
});
|
||||
|
||||
it('returns -1 if no working hours are provided', () => {
|
||||
const workingHours = new WorkingHours([]);
|
||||
const date = new Date(2025, 0, 6); // Monday
|
||||
date.setHours(12, 0, 0); // 12:00
|
||||
expect(workingHours.openInMinutes(date)).toBe(-1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,5 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router';
|
||||
import ViewWithHeader from './components/layouts/ViewWithHeader.vue';
|
||||
import UnreadMessages from './views/UnreadMessages.vue';
|
||||
import Campaigns from './views/Campaigns.vue';
|
||||
import Home from './views/Home.vue';
|
||||
import PreChatForm from './views/PreChatForm.vue';
|
||||
import Messages from './views/Messages.vue';
|
||||
import ArticleViewer from './views/ArticleViewer.vue';
|
||||
import store from './store';
|
||||
|
||||
const router = createRouter({
|
||||
@@ -14,12 +8,12 @@ const router = createRouter({
|
||||
{
|
||||
path: '/unread-messages',
|
||||
name: 'unread-messages',
|
||||
component: UnreadMessages,
|
||||
component: () => import('./views/UnreadMessages.vue'),
|
||||
},
|
||||
{
|
||||
path: '/campaigns',
|
||||
name: 'campaigns',
|
||||
component: Campaigns,
|
||||
component: () => import('./views/Campaigns.vue'),
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
@@ -28,22 +22,22 @@ const router = createRouter({
|
||||
{
|
||||
path: '',
|
||||
name: 'home',
|
||||
component: Home,
|
||||
component: () => import('./views/Home.vue'),
|
||||
},
|
||||
{
|
||||
path: '/prechat-form',
|
||||
name: 'prechat-form',
|
||||
component: PreChatForm,
|
||||
component: () => import('./views/PreChatForm.vue'),
|
||||
},
|
||||
{
|
||||
path: '/messages',
|
||||
name: 'messages',
|
||||
component: Messages,
|
||||
component: () => import('./views/Messages.vue'),
|
||||
},
|
||||
{
|
||||
path: '/article',
|
||||
name: 'article-viewer',
|
||||
component: ArticleViewer,
|
||||
component: () => import('./views/ArticleViewer.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ import events from 'widget/store/modules/events';
|
||||
import globalConfig from 'shared/store/globalConfig';
|
||||
import message from 'widget/store/modules/message';
|
||||
import campaign from 'widget/store/modules/campaign';
|
||||
import article from 'widget/store/modules/articles';
|
||||
|
||||
export default createStore({
|
||||
modules: {
|
||||
@@ -23,5 +24,6 @@ export default createStore({
|
||||
globalConfig,
|
||||
message,
|
||||
campaign,
|
||||
article,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { getMostReadArticles } from 'widget/api/article';
|
||||
|
||||
const state = {
|
||||
records: [],
|
||||
uiFlags: {
|
||||
isError: false,
|
||||
hasFetched: false,
|
||||
isFetching: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const getters = {
|
||||
uiFlags: $state => $state.uiFlags,
|
||||
popularArticles: $state => $state.records,
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
fetch: async ({ commit }, { slug, locale }) => {
|
||||
commit('setIsFetching', true);
|
||||
commit('setError', false);
|
||||
|
||||
try {
|
||||
const { data } = await getMostReadArticles(slug, locale);
|
||||
const { payload = [] } = data;
|
||||
|
||||
if (payload.length) {
|
||||
commit('setArticles', payload);
|
||||
}
|
||||
} catch (error) {
|
||||
commit('setError', true);
|
||||
} finally {
|
||||
commit('setIsFetching', false);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
setArticles($state, data) {
|
||||
$state.records = data;
|
||||
},
|
||||
setError($state, value) {
|
||||
$state.uiFlags.isError = value;
|
||||
},
|
||||
setIsFetching($state, value) {
|
||||
$state.uiFlags.isFetching = value;
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state,
|
||||
getters,
|
||||
actions,
|
||||
mutations,
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
import { API } from 'widget/helpers/axios';
|
||||
|
||||
export default {
|
||||
index: params => {
|
||||
const url = `/hc/${params.slug}/${params.locale}/articles.json`;
|
||||
const urlParams = {
|
||||
page: 1,
|
||||
sort: 'views',
|
||||
status: 1,
|
||||
};
|
||||
return API.get(url, { params: urlParams });
|
||||
},
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
import articleAPI from './api/articleAPI';
|
||||
import { createResourceStore } from './piniaStoreFactory';
|
||||
|
||||
export const useArticleStore = createResourceStore('articles', {
|
||||
api: articleAPI,
|
||||
});
|
||||
@@ -1,105 +0,0 @@
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
export const createResourceStore = (
|
||||
storeName,
|
||||
{ api, customGetters = {}, customActions = {} }
|
||||
) => {
|
||||
return defineStore(storeName, {
|
||||
state: () => ({
|
||||
records: [],
|
||||
uiFlags: {
|
||||
isFetching: false,
|
||||
isCreating: false,
|
||||
isUpdating: false,
|
||||
isDeleting: false,
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
getRecords: state => state.records,
|
||||
getUIFlags: state => state.uiFlags,
|
||||
getError: state => state.error,
|
||||
...customGetters,
|
||||
},
|
||||
|
||||
actions: {
|
||||
async index(params = {}) {
|
||||
this.uiFlags.isFetching = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const {
|
||||
data: { payload = [] },
|
||||
} = await api.index(params);
|
||||
this.records = [...payload];
|
||||
} catch (error) {
|
||||
this.error = error;
|
||||
throw error;
|
||||
} finally {
|
||||
this.uiFlags.isFetching = false;
|
||||
}
|
||||
},
|
||||
|
||||
async create(payload) {
|
||||
this.uiFlags.isCreating = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const { data } = await api.post(payload);
|
||||
this.records.push(data.payload);
|
||||
return data.payload;
|
||||
} catch (error) {
|
||||
this.error = error;
|
||||
throw error;
|
||||
} finally {
|
||||
this.uiFlags.isCreating = false;
|
||||
}
|
||||
},
|
||||
|
||||
async update(id, payload) {
|
||||
this.uiFlags.isUpdating = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const { data } = await api.put(id, payload);
|
||||
const index = this.records.findIndex(record => record.id === id);
|
||||
if (index !== -1) {
|
||||
this.records[index] = data.payload;
|
||||
}
|
||||
return data.payload;
|
||||
} catch (error) {
|
||||
this.error = error;
|
||||
throw error;
|
||||
} finally {
|
||||
this.uiFlags.isUpdating = false;
|
||||
}
|
||||
},
|
||||
|
||||
async delete(id) {
|
||||
this.uiFlags.isDeleting = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
await api.delete(id);
|
||||
this.records = this.records.filter(record => record.id !== id);
|
||||
} catch (error) {
|
||||
this.error = error;
|
||||
throw error;
|
||||
} finally {
|
||||
this.uiFlags.isDeleting = false;
|
||||
}
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.records = [];
|
||||
this.error = null;
|
||||
Object.keys(this.uiFlags).forEach(key => {
|
||||
this.uiFlags[key] = false;
|
||||
});
|
||||
},
|
||||
|
||||
...customActions,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,26 +1,69 @@
|
||||
<script>
|
||||
import TeamAvailability from 'widget/components/TeamAvailability.vue';
|
||||
import ArticleHero from 'widget/components/ArticleHero.vue';
|
||||
import ArticleCardSkeletonLoader from 'widget/components/ArticleCardSkeletonLoader.vue';
|
||||
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useDarkMode } from 'widget/composables/useDarkMode';
|
||||
import routerMixin from 'widget/mixins/routerMixin';
|
||||
import configMixin from 'widget/mixins/configMixin';
|
||||
import ArticleContainer from '../components/pageComponents/Home/Article/ArticleContainer.vue';
|
||||
|
||||
export default {
|
||||
name: 'Home',
|
||||
components: {
|
||||
ArticleContainer,
|
||||
ArticleHero,
|
||||
TeamAvailability,
|
||||
ArticleCardSkeletonLoader,
|
||||
},
|
||||
mixins: [configMixin, routerMixin],
|
||||
setup() {
|
||||
const { prefersDarkMode } = useDarkMode();
|
||||
return { prefersDarkMode };
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
availableAgents: 'agent/availableAgents',
|
||||
conversationSize: 'conversation/getConversationSize',
|
||||
unreadMessageCount: 'conversation/getUnreadMessageCount',
|
||||
popularArticles: 'article/popularArticles',
|
||||
articleUiFlags: 'article/uiFlags',
|
||||
}),
|
||||
},
|
||||
widgetLocale() {
|
||||
return this.$i18n.locale || 'en';
|
||||
},
|
||||
portal() {
|
||||
return window.chatwootWebChannel.portal;
|
||||
},
|
||||
showArticles() {
|
||||
return (
|
||||
this.portal &&
|
||||
!this.articleUiFlags.isFetching &&
|
||||
this.popularArticles.length
|
||||
);
|
||||
},
|
||||
defaultLocale() {
|
||||
const widgetLocale = this.widgetLocale;
|
||||
const { allowed_locales: allowedLocales, default_locale: defaultLocale } =
|
||||
this.portal.config;
|
||||
|
||||
// IMPORTANT: Variation strict locale matching, Follow iso_639_1_code
|
||||
// If the exact match of a locale is available in the list of portal locales, return it
|
||||
// Else return the default locale. Eg: `es` will not work if `es_ES` is available in the list
|
||||
if (allowedLocales.includes(widgetLocale)) {
|
||||
return widgetLocale;
|
||||
}
|
||||
return defaultLocale;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (this.portal && this.popularArticles.length === 0) {
|
||||
const locale = this.defaultLocale;
|
||||
this.$store.dispatch('article/fetch', {
|
||||
slug: this.portal.slug,
|
||||
locale,
|
||||
});
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
startConversation() {
|
||||
if (this.preChatFormEnabled && !this.conversationSize) {
|
||||
@@ -28,19 +71,59 @@ export default {
|
||||
}
|
||||
return this.replaceRoute('messages');
|
||||
},
|
||||
openArticleInArticleViewer(link) {
|
||||
let linkToOpen = `${link}?show_plain_layout=true`;
|
||||
const isDark = this.prefersDarkMode;
|
||||
if (isDark) {
|
||||
linkToOpen = `${linkToOpen}&theme=dark`;
|
||||
}
|
||||
this.$router.push({
|
||||
name: 'article-viewer',
|
||||
query: { link: linkToOpen },
|
||||
});
|
||||
},
|
||||
viewAllArticles() {
|
||||
const locale = this.defaultLocale;
|
||||
const {
|
||||
portal: { slug },
|
||||
} = window.chatwootWebChannel;
|
||||
this.openArticleInArticleViewer(`/hc/${slug}/${locale}`);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="z-50 flex flex-col flex-1 w-full p-4 gap-4">
|
||||
<TeamAvailability
|
||||
:available-agents="availableAgents"
|
||||
:has-conversation="!!conversationSize"
|
||||
:unread-count="unreadMessageCount"
|
||||
@start-conversation="startConversation"
|
||||
/>
|
||||
|
||||
<ArticleContainer />
|
||||
<div
|
||||
class="z-50 flex flex-col flex-1 w-full rounded-md"
|
||||
:class="{ 'pb-2': showArticles, 'justify-end': !showArticles }"
|
||||
>
|
||||
<div class="w-full px-4 pt-4">
|
||||
<TeamAvailability
|
||||
:available-agents="availableAgents"
|
||||
:has-conversation="!!conversationSize"
|
||||
:unread-count="unreadMessageCount"
|
||||
@start-conversation="startConversation"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="showArticles" class="w-full px-4 py-2">
|
||||
<div class="w-full p-4 bg-white rounded-md shadow-sm dark:bg-slate-700">
|
||||
<ArticleHero
|
||||
v-if="
|
||||
!articleUiFlags.isFetching &&
|
||||
!articleUiFlags.isError &&
|
||||
popularArticles.length
|
||||
"
|
||||
:articles="popularArticles"
|
||||
@view="openArticleInArticleViewer"
|
||||
@view-all="viewAllArticles"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="articleUiFlags.isFetching" class="w-full px-4 py-2">
|
||||
<div class="w-full p-4 bg-white rounded-md shadow-sm dark:bg-slate-700">
|
||||
<ArticleCardSkeletonLoader />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -11,13 +11,7 @@ json.slug article.slug
|
||||
|
||||
if article.portal.present?
|
||||
json.portal do
|
||||
json.custom_domain article.portal.custom_domain
|
||||
json.header_text article.portal.header_text
|
||||
json.homepage_link article.portal.homepage_link
|
||||
json.name article.portal.name
|
||||
json.page_title article.portal.page_title
|
||||
json.slug article.portal.slug
|
||||
json.logo article.portal.file_base_data if article.portal.logo.present?
|
||||
json.partial! 'public/api/v1/models/hc/portal', formats: [:json], portal: article.portal
|
||||
end
|
||||
end
|
||||
|
||||
@@ -37,4 +31,12 @@ if article.author.present?
|
||||
end
|
||||
end
|
||||
|
||||
json.associated_articles do
|
||||
if article.associated_articles.any?
|
||||
json.array! article.associated_articles.each do |associated_article|
|
||||
json.partial! 'public/api/v1/models/hc/associated_article', formats: [:json], article: associated_article
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
json.link "hc/#{article.portal.slug}/articles/#{article.slug}"
|
||||
|
||||
@@ -4,5 +4,5 @@ json.payload do
|
||||
end
|
||||
|
||||
json.meta do
|
||||
json.articles_count @articles_count
|
||||
json.articles_count @articles.published.size
|
||||
end
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
<% content_for(:title) do %>
|
||||
Configure Settings - <%= @config.titleize %>
|
||||
<% end %>
|
||||
|
||||
<header class="main-content__header" role="banner">
|
||||
<h1 class="main-content__page-title" id="page-title">
|
||||
<%= content_for(:title) %>
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.eye-icon.eye-hide path {
|
||||
d: path('M3 3l18 18M10.5 10.677a2 2 0 002.823 2.823M7.362 7.561C5.68 8.74 4.279 10.42 3 12c1.889 2.991 5.282 6 9 6 1.55 0 3.043-.523 4.395-1.35M12 6c4.008 0 6.701 3.009 9 6a15.66 15.66 0 01-1.078 1.5');
|
||||
}
|
||||
</style>
|
||||
|
||||
<section class="main-content__body">
|
||||
<%= form_with url: super_admin_app_config_url(config: @config) , method: :post do |form| %>
|
||||
<% @allowed_configs.each do |key| %>
|
||||
@@ -15,18 +23,36 @@
|
||||
</div>
|
||||
<div class="-mt-2 field-unit__field ">
|
||||
<% if @installation_configs[key]&.dig('type') == 'boolean' %>
|
||||
<%= form.select "app_config[#{key}]",
|
||||
[["True", true], ["False", false]],
|
||||
<%= form.select "app_config[#{key}]",
|
||||
[["True", true], ["False", false]],
|
||||
{ selected: ActiveModel::Type::Boolean.new.cast(@app_config[key]) },
|
||||
class: "mt-2 border border-slate-100 p-1 rounded-md"
|
||||
class: "mt-2 border border-slate-100 p-1 rounded-md"
|
||||
%>
|
||||
<% elsif @installation_configs[key]&.dig('type') == 'code' %>
|
||||
<%= form.text_area "app_config[#{key}]",
|
||||
value: @app_config[key],
|
||||
<%= form.text_area "app_config[#{key}]",
|
||||
value: @app_config[key],
|
||||
rows: 12,
|
||||
wrap: 'off',
|
||||
class: "mt-2 border font-mono text-xs border-slate-100 p-1 rounded-md overflow-scroll"
|
||||
%>
|
||||
<% elsif @installation_configs[key]&.dig('type') == 'secret' %>
|
||||
<div class="relative">
|
||||
<%= form.password_field "app_config[#{key}]",
|
||||
id: "app_config_#{key}",
|
||||
value: @app_config[key],
|
||||
class: "mt-2 border border-slate-100 p-1.5 pr-8 rounded-md w-full"
|
||||
%>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute reset-base !bg-white top-1/2 !outline-0 !text-n-slate-11 -translate-y-1/2 right-2 p-1 hover:!bg-n-slate-5 rounded-sm toggle-password"
|
||||
data-target="app_config_<%= key %>"
|
||||
>
|
||||
<svg class="eye-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 5C5.63636 5 2 12 2 12C2 12 5.63636 19 12 19C18.3636 19 22 12 22 12C22 12 18.3636 5 12 5Z"/>
|
||||
<path d="M12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<% else %>
|
||||
<%= form.text_field "app_config[#{key}]", value: @app_config[key] %>
|
||||
<% end %>
|
||||
@@ -43,3 +69,26 @@
|
||||
</div>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
<% content_for :javascript do %>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('.toggle-password').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const targetId = button.dataset.target;
|
||||
const input = document.getElementById(targetId);
|
||||
const type = input.type === 'password' ? 'text' : 'password';
|
||||
input.type = type;
|
||||
|
||||
// Toggle icon
|
||||
const svg = button.querySelector('.eye-icon');
|
||||
if (type === 'password') {
|
||||
svg.classList.remove('eye-hide')
|
||||
} else {
|
||||
svg.classList.add('eye-hide')
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<% end %>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<%#
|
||||
# Filters
|
||||
|
||||
This partial is used on the `index` page to display available filters
|
||||
for a collection of resources.
|
||||
|
||||
## Local variables:
|
||||
|
||||
- `page`:
|
||||
An instance of [Administrate::Page::Collection][1].
|
||||
Contains helper methods to help display a table,
|
||||
and knows which attributes should be displayed in the resource's table.
|
||||
|
||||
[1]: http://www.rubydoc.info/gems/administrate/Administrate/Page/Collection
|
||||
%>
|
||||
|
||||
<%
|
||||
# Get the dashboard class name from the resource name
|
||||
resource_name = page.resource_name.classify
|
||||
dashboard_class_name = "#{resource_name}Dashboard"
|
||||
dashboard_class = dashboard_class_name.constantize
|
||||
|
||||
# Get the current filter if any
|
||||
current_filter = nil
|
||||
if params[:search] && params[:search].include?(':')
|
||||
current_filter = params[:search].split(':').first
|
||||
end
|
||||
%>
|
||||
|
||||
<% if dashboard_class.const_defined?(:COLLECTION_FILTERS) && !dashboard_class::COLLECTION_FILTERS.empty? %>
|
||||
<div class="flex items-center bg-gray-100 border-0 rounded-md shadow-none relative w-[260px]">
|
||||
<div class="flex items-center h-10 px-2 w-full">
|
||||
<div class="flex items-center justify-center flex-shrink-0 mr-2 text-gray-500" title="Filter by">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 h-full min-w-0 relative">
|
||||
<select id="filter-select" class="appearance-none bg-gray-100 border-0 text-gray-700 cursor-pointer text-sm h-full overflow-hidden truncate whitespace-nowrap w-full pr-7 pl-0 py-2 focus:outline-none bg-[url('data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2712%27 height=%2712%27 viewBox=%270 0 12 12%27%3E%3Cpath fill=%27%23293f54%27 d=%27M6 9L1 4h10z%27/%3E%3C/svg%3E')] bg-[right_0.25rem_center] bg-no-repeat bg-[length:0.75rem]" onchange="applyFilter(this.value)">
|
||||
<option value="">All records</option>
|
||||
<% dashboard_class::COLLECTION_FILTERS.each do |filter_name, _| %>
|
||||
<option value="<%= filter_name %>" <%= 'selected' if filter_name.to_s == current_filter %>>
|
||||
<%= filter_name.to_s.titleize %>
|
||||
</option>
|
||||
<% end %>
|
||||
</select>
|
||||
<% if current_filter %>
|
||||
<a href="?" class="flex items-center justify-center rounded-full text-gray-500 text-xl font-bold h-[18px] w-[18px] leading-none absolute right-5 top-1/2 -translate-y-1/2 no-underline z-2 hover:text-gray-900" title="Clear filter">×</a>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function applyFilter(filterName) {
|
||||
if (filterName) {
|
||||
window.location.href = "?search=" + encodeURIComponent(filterName) + "%3A";
|
||||
} else {
|
||||
window.location.href = "?";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<% end %>
|
||||
@@ -0,0 +1,24 @@
|
||||
<form class="search" role="search">
|
||||
<label class="search__label" for="search">
|
||||
<svg class="search__eyeglass-icon" role="img">
|
||||
<title>
|
||||
<%= t("administrate.search.label", resource: resource_name) %>
|
||||
</title>
|
||||
<use xlink:href="#icon-eyeglass" />
|
||||
</svg>
|
||||
</label>
|
||||
|
||||
<input class="search__input"
|
||||
id="search"
|
||||
type="search"
|
||||
name="search"
|
||||
placeholder="<%= t("administrate.search.label", resource: resource_name) %>"
|
||||
value="<%= search_term %>">
|
||||
|
||||
<%= link_to clear_search_params, class: "search__clear-link" do %>
|
||||
<svg class="search__clear-icon" role="img">
|
||||
<title><%= t("administrate.search.clear") %></title>
|
||||
<use xlink:href="#icon-cancel" />
|
||||
</svg>
|
||||
<% end %>
|
||||
</form>
|
||||
@@ -28,27 +28,36 @@ It renders the `_table` partial to display details about the resources.
|
||||
<% end %>
|
||||
|
||||
<header class="main-content__header" role="banner">
|
||||
<h1 class="main-content__page-title" id="page-title">
|
||||
<%= content_for(:title) %>
|
||||
</h1>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<h1 class="main-content__page-title m-0 mr-6" id="page-title">
|
||||
<%= content_for(:title) %>
|
||||
</h1>
|
||||
|
||||
<% if show_search_bar %>
|
||||
<%= render(
|
||||
"search",
|
||||
search_term: search_term,
|
||||
resource_name: display_resource_name(page.resource_name)
|
||||
) %>
|
||||
<% end %>
|
||||
<div class="flex items-center">
|
||||
<% if show_search_bar %>
|
||||
<div class="flex items-center">
|
||||
<%= render("filters", page: page) %>
|
||||
<div class="ml-3">
|
||||
<%= render(
|
||||
"search",
|
||||
search_term: search_term,
|
||||
resource_name: display_resource_name(page.resource_name)
|
||||
) %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div>
|
||||
<%= link_to(
|
||||
t(
|
||||
"administrate.actions.new_resource",
|
||||
name: page.resource_name.titleize.downcase
|
||||
),
|
||||
[:new, namespace, page.resource_path.to_sym],
|
||||
class: "button",
|
||||
) if accessible_action?(new_resource, :new) %>
|
||||
<div class="whitespace-nowrap ml-4">
|
||||
<%= link_to(
|
||||
t(
|
||||
"administrate.actions.new_resource",
|
||||
name: page.resource_name.titleize.downcase
|
||||
),
|
||||
[:new, namespace, page.resource_path.to_sym],
|
||||
class: "button",
|
||||
) if accessible_action?(new_resource, :new) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
if Rails.env.development? && ENV.fetch('ENABLE_MINI_PROFILER', 'false') == 'true'
|
||||
if Rails.env.development?
|
||||
require 'rack-mini-profiler'
|
||||
|
||||
# initialization is skipped so trigger it
|
||||
|
||||
@@ -103,13 +103,16 @@
|
||||
display_title: 'Facebook Verify Token'
|
||||
description: 'The verify token used for Facebook Messenger Webhook'
|
||||
locked: false
|
||||
type: secret
|
||||
- name: FB_APP_SECRET
|
||||
display_title: 'Facebook App Secret'
|
||||
locked: false
|
||||
type: secret
|
||||
- name: IG_VERIFY_TOKEN
|
||||
display_title: 'Instagram Verify Token'
|
||||
description: 'The verify token used for Instagram Webhook'
|
||||
locked: false
|
||||
type: secret
|
||||
- name: FACEBOOK_API_VERSION
|
||||
display_title: 'Facebook API Version'
|
||||
description: 'Configure this if you want to use a different Facebook API version. Make sure its prefixed with `v`'
|
||||
@@ -131,6 +134,7 @@
|
||||
- name: AZURE_APP_SECRET
|
||||
display_title: 'Azure App Secret'
|
||||
locked: false
|
||||
type: secret
|
||||
# End of Microsoft Email Channel Config
|
||||
|
||||
# MARK: Captain Config
|
||||
@@ -138,6 +142,7 @@
|
||||
display_title: 'OpenAI API Key'
|
||||
description: 'The API key used to authenticate requests to OpenAI services for Captain AI.'
|
||||
locked: false
|
||||
type: secret
|
||||
- name: CAPTAIN_OPEN_AI_MODEL
|
||||
display_title: 'OpenAI Model'
|
||||
description: 'The OpenAI model configured for use in Captain AI. Default: gpt-4o-mini'
|
||||
@@ -146,6 +151,7 @@
|
||||
display_title: 'FireCrawl API Key (optional)'
|
||||
description: 'The FireCrawl API key for the Captain AI service'
|
||||
locked: false
|
||||
type: secret
|
||||
- name: CAPTAIN_CLOUD_PLAN_LIMITS
|
||||
display_title: 'Captain Cloud Plan Limits'
|
||||
description: 'The limits for the Captain AI service for different plans'
|
||||
@@ -160,11 +166,13 @@
|
||||
display_title: 'Inbox Token'
|
||||
description: 'The Chatwoot Inbox Token for Contact Support in Cloud'
|
||||
locked: false
|
||||
type: secret
|
||||
- name: CHATWOOT_INBOX_HMAC_KEY
|
||||
value:
|
||||
display_title: 'Inbox HMAC Key'
|
||||
description: 'The Chatwoot Inbox HMAC Key for Contact Support in Cloud'
|
||||
locked: false
|
||||
type: secret
|
||||
- name: CHATWOOT_CLOUD_PLANS
|
||||
display_title: 'Cloud Plans'
|
||||
value:
|
||||
@@ -180,10 +188,12 @@
|
||||
value:
|
||||
display_title: 'Analytics Token'
|
||||
description: 'The June.so analytics token for Chatwoot cloud'
|
||||
type: secret
|
||||
- name: CLEARBIT_API_KEY
|
||||
value:
|
||||
display_title: 'Clearbit API Key'
|
||||
description: 'This API key is used for onboarding the users, to pre-fill account data.'
|
||||
type: secret
|
||||
- name: DASHBOARD_SCRIPTS
|
||||
value:
|
||||
display_title: 'Dashboard Scripts'
|
||||
@@ -206,12 +216,14 @@
|
||||
- name: CHATWOOT_SUPPORT_WEBSITE_TOKEN
|
||||
value:
|
||||
description: 'The Chatwoot website token, used to identify the Chatwoot inbox and display the "Contact Support" option on the billing page'
|
||||
type: secret
|
||||
- name: CHATWOOT_SUPPORT_SCRIPT_URL
|
||||
value:
|
||||
description: 'The Chatwoot script base URL, to display the "Contact Support" option on the billing page'
|
||||
- name: CHATWOOT_SUPPORT_IDENTIFIER_HASH
|
||||
value:
|
||||
description: 'The Chatwoot identifier hash, to validate the contact in the live chat window.'
|
||||
type: secret
|
||||
- name: ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL
|
||||
display_title: Webhook URL to post security analysis
|
||||
value:
|
||||
@@ -245,6 +257,7 @@
|
||||
display_title: 'Firebase Credentials'
|
||||
value:
|
||||
locked: false
|
||||
type: secret
|
||||
description: 'Contents on your firebase credentials json file'
|
||||
## ------ End of Configs added for FCM v1 notifications ------ ##
|
||||
|
||||
@@ -259,4 +272,5 @@
|
||||
value:
|
||||
locked: false
|
||||
description: 'Linear client secret'
|
||||
type: secret
|
||||
## ------ End of Configs added for Linear ------ ##
|
||||
|
||||
@@ -79,7 +79,6 @@
|
||||
"md5": "^2.3.0",
|
||||
"mitt": "^3.0.1",
|
||||
"opus-recorder": "^8.0.5",
|
||||
"pinia": "^2.3.0",
|
||||
"semver": "7.6.3",
|
||||
"snakecase-keys": "^8.0.1",
|
||||
"timezone-phone-codes": "^0.0.2",
|
||||
|
||||
Generated
-37
@@ -157,9 +157,6 @@ importers:
|
||||
opus-recorder:
|
||||
specifier: ^8.0.5
|
||||
version: 8.0.5
|
||||
pinia:
|
||||
specifier: ^2.3.0
|
||||
version: 2.3.1(typescript@5.6.2)(vue@3.5.12(typescript@5.6.2))
|
||||
semver:
|
||||
specifier: 7.6.3
|
||||
version: 7.6.3
|
||||
@@ -3897,15 +3894,6 @@ packages:
|
||||
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
pinia@2.3.1:
|
||||
resolution: {integrity: sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==}
|
||||
peerDependencies:
|
||||
typescript: '>=4.4.4'
|
||||
vue: ^2.7.0 || ^3.5.11
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
pirates@4.0.6:
|
||||
resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -4897,17 +4885,6 @@ packages:
|
||||
'@vue/composition-api':
|
||||
optional: true
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
engines: {node: '>=12'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@vue/composition-api': ^1.0.0-rc.1
|
||||
vue: ^3.0.0-0 || ^2.6.0
|
||||
peerDependenciesMeta:
|
||||
'@vue/composition-api':
|
||||
optional: true
|
||||
|
||||
vue-dompurify-html@5.1.0:
|
||||
resolution: {integrity: sha512-616o2/PBdOLM2bwlRWLdzeEC9NerLkwiudqNgaIJ5vBQWXec+u7Kuzh+45DtQQrids67s4pHnTnJZLVfyPMxbA==}
|
||||
peerDependencies:
|
||||
@@ -9219,16 +9196,6 @@ snapshots:
|
||||
|
||||
pify@2.3.0: {}
|
||||
|
||||
pinia@2.3.1(typescript@5.6.2)(vue@3.5.12(typescript@5.6.2)):
|
||||
dependencies:
|
||||
'@vue/devtools-api': 6.6.4
|
||||
vue: 3.5.12(typescript@5.6.2)
|
||||
vue-demi: 0.14.10(vue@3.5.12(typescript@5.6.2))
|
||||
optionalDependencies:
|
||||
typescript: 5.6.2
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
|
||||
pirates@4.0.6: {}
|
||||
|
||||
pkcs7@1.0.4:
|
||||
@@ -10366,10 +10333,6 @@ snapshots:
|
||||
dependencies:
|
||||
vue: 3.5.12(typescript@5.6.2)
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.12(typescript@5.6.2)):
|
||||
dependencies:
|
||||
vue: 3.5.12(typescript@5.6.2)
|
||||
|
||||
vue-dompurify-html@5.1.0(vue@3.5.12(typescript@5.6.2)):
|
||||
dependencies:
|
||||
dompurify: 3.2.4
|
||||
|
||||
Reference in New Issue
Block a user