Compare commits

...
Author SHA1 Message Date
Muhsin KelothandGitHub 9b1e822d21 Merge branch 'develop' into fix/scroll-reply-to 2023-12-05 09:48:50 +05:30
Muhsin KelothandGitHub 449503bb94 feat: Snooze notification API (#8439) 2023-12-04 12:32:35 +05:30
Muhsin KelothandGitHub aad18e1ca4 feat: Add notification_deleted action cable event (#8431) 2023-12-01 23:06:22 +05:30
Shivam Mishra fa97ac0691 refactor: defaults 2023-12-01 09:21:03 +05:30
Shivam Mishra 1a1f8c52b2 chore: revert class formatting 2023-12-01 09:19:59 +05:30
Muhsin KelothandGitHub fdc1123b18 feat: Add support for attachments(image and video) in LINE channel (#8425) 2023-12-01 07:23:35 +05:30
Anthony MeirlaenandGitHub 0974b1e705 fix: Install node 20.x in non-production docker (#8441) (#8442) 2023-11-30 16:22:34 -08:00
Shivam Mishra 97301c3080 fix: message URL navigation won't work 2023-11-30 20:46:37 +05:30
Shivam Mishra 8a7a5ca8d7 chore: remove behaviour 2023-11-30 20:26:43 +05:30
Shivam Mishra 04160c04cc fix: setting active chat when messageId is missing 2023-11-30 13:45:43 +05:30
Shivam Mishra 29292fd99d refactor: don't call setActiveChat 2023-11-30 13:08:49 +05:30
Shivam Mishra baf1323ebd refactor: move fetching logic to onScrollToMessage 2023-11-30 13:08:49 +05:30
Shivam Mishra 9cc6275c82 Revert "feat: remove click action"
This reverts commit 39e748bd739d8849a01e51b78e5db3c440d7c803.
2023-11-30 13:08:49 +05:30
Shivam Mishra 20dac9d77b Revert "chore: remove force option"
This reverts commit c65361ba82d7f0fd225d0c7aa51c9c5055e7fff6.
2023-11-30 13:08:30 +05:30
Shivam Mishra a6c28c94d4 Revert "chore: remove behavior option"
This reverts commit b09c946af35f8984aa852d8658eb4745cc5c5d00.
2023-11-30 13:08:30 +05:30
Muhsin KelothandGitHub bc6e3e598f feat: Delete notification by id API (#8430) 2023-11-29 22:45:11 +05:30
683f9d9a28 fix: article meta does not show up on reload (#8415)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2023-11-29 16:15:31 +05:30
Martín GómezandGitHub f9bf0883c6 feat: Allow more image formats in avatar uploads form (#8419) 2023-11-28 11:18:16 +05:30
ad8ba299c9 feat: Author sections in category block and category list (#8414)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2023-11-27 15:13:38 +05:30
Muhsin KelothandGitHub 3ae64822e1 chore: Enforce HTTP Basic Authentication for Twilio medias. (#8396) 2023-11-27 13:24:53 +05:30
Sivin VargheseandGitHub a49aaee9cf fix: Email messages in dark mode (#8300) 2023-11-24 12:14:02 +05:30
Sivin VargheseandGitHub 2d1f70eb79 feat: Update public portal colors with new design (#8230) 2023-11-23 08:16:52 +05:30
Sojan JoseandGitHub 4fc5f765de chore: Make super admin auth error readable (#8406)
- Remove the cryptic error for super admin auth and make it more readable
2023-11-22 17:26:08 -08:00
Nithin David ThomasandGitHub 7efc2726d9 fix: Updates the text alignment on articles table (#8405) 2023-11-23 02:58:00 +05:30
Pranav Raj SandGitHub 29a778697c fix: Remove validation for message signature (#8404) 2023-11-22 13:09:17 -08:00
Pranav Raj SandGitHub 90649e72bb fix: Add a .present? check for sentiment file path to avoid false positives (#8401) 2023-11-22 10:35:51 -08:00
Pranav Raj SandGitHub 1904ec7df4 fix: Update campaign routes to fix the rendering issue (#8400) 2023-11-22 09:39:41 -08:00
Nithin David ThomasandGitHub 96add30331 chore: Add analytics event for insert article feature (#8393) 2023-11-22 08:18:58 -08:00
9c7148e2ad feat: Split dashboard to chunks for build performance (#8394)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-11-21 14:51:05 -08:00
31c709be5c fix: Opens foreign links from article page in new tab [cw-2725] (#8304)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2023-11-22 01:57:58 +05:30
e750ee6d28 chore: [Snyk] Security upgrade administrate-field-active_storage from 0.4.2 to 1.0.0 (#8382)
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2023-11-20 19:42:54 -08:00
Vishnu NarayananandGitHub 5b3f9ac1cd chore: skip node upgrade if latest on cwctl upgrade (#8336) 2023-11-20 12:48:39 +05:30
Muhsin KelothandGitHub 6c8dacfa0d feat: Facebook delivery reports (#8136) 2023-11-20 12:22:45 +05:30
Sojan feead30b0b Merge branch 'release/3.3.1' into develop 2023-11-18 10:24:35 -08:00
94 changed files with 1592 additions and 757 deletions
+1 -1
View File
@@ -75,7 +75,7 @@ gem 'jwt'
gem 'pundit'
# super admin
gem 'administrate', '>= 0.19.0'
gem 'administrate-field-active_storage'
gem 'administrate-field-active_storage', '>= 1.0.0'
gem 'administrate-field-belongs_to_search'
##--- gems for pubsub service ---##
+16 -16
View File
@@ -113,7 +113,7 @@ GEM
kaminari (>= 1.0)
sassc-rails (~> 2.1)
selectize-rails (~> 0.6)
administrate-field-active_storage (0.4.2)
administrate-field-active_storage (1.0.0)
administrate (>= 0.2.2)
rails (>= 7.0)
administrate-field-belongs_to_search (0.8.0)
@@ -183,7 +183,7 @@ GEM
activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1)
date (3.3.3)
date (3.3.4)
ddtrace (1.11.1)
debase-ruby_core_source (>= 0.10.16, <= 3.2.0)
libdatadog (~> 2.0.0.1.0)
@@ -256,7 +256,7 @@ GEM
fcm (1.0.8)
faraday (>= 1.0.0, < 3.0)
googleauth (~> 1)
ffi (1.15.5)
ffi (1.16.3)
ffi-compiler (1.0.1)
ffi (>= 1.0.0)
rake
@@ -440,7 +440,7 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.21.4)
loofah (2.22.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
@@ -472,14 +472,14 @@ GEM
activerecord (>= 5.2)
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.3.7)
net-imap (0.4.5)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.2.1)
net-protocol (0.2.2)
timeout
net-smtp (0.3.3)
net-smtp (0.4.0)
net-protocol
netrc (0.11.0)
newrelic-sidekiq-metrics (1.6.2)
@@ -487,15 +487,15 @@ GEM
sidekiq
newrelic_rpm (9.6.0)
base64
nio4r (2.5.9)
nokogiri (1.15.4)
nio4r (2.6.0)
nokogiri (1.15.5)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.15.4-arm64-darwin)
nokogiri (1.15.5-arm64-darwin)
racc (~> 1.4)
nokogiri (1.15.4-x86_64-darwin)
nokogiri (1.15.5-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.15.4-x86_64-linux)
nokogiri (1.15.5-x86_64-linux)
racc (~> 1.4)
numo-narray (0.9.2.1)
oauth (1.1.0)
@@ -752,7 +752,7 @@ GEM
spring-watcher-listen (2.1.0)
listen (>= 2.7, < 4.0)
spring (>= 4)
sprockets (4.2.0)
sprockets (4.2.1)
concurrent-ruby (~> 1.0)
rack (>= 2.2.4, < 4)
sprockets-rails (3.4.2)
@@ -766,11 +766,11 @@ GEM
telephone_number (1.4.20)
test-prof (1.2.1)
thor (1.3.0)
tilt (2.2.0)
tilt (2.3.0)
time_diff (0.3.0)
activesupport
i18n
timeout (0.4.0)
timeout (0.4.1)
trailblazer-option (0.1.2)
twilio-ruby (5.77.0)
faraday (>= 0.9, < 3.0)
@@ -841,7 +841,7 @@ DEPENDENCIES
activerecord-import
acts-as-taggable-on
administrate (>= 0.19.0)
administrate-field-active_storage
administrate-field-active_storage (>= 1.0.0)
administrate-field-belongs_to_search
annotate
attr_extras
@@ -1,7 +1,8 @@
class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseController
RESULTS_PER_PAGE = 15
include DateRangeHelper
before_action :fetch_notification, only: [:update]
before_action :fetch_notification, only: [:update, :destroy, :snooze]
before_action :set_primary_actor, only: [:read_all]
before_action :set_current_page, only: [:index]
@@ -28,11 +29,21 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
render json: @notification
end
def destroy
@notification.destroy
head :ok
end
def unread_count
@unread_count = current_user.notifications.where(account_id: current_account.id, read_at: nil).count
render json: @unread_count
end
def snooze
@notification.update(snoozed_until: parse_date_time(params[:snoozed_until].to_s)) if params[:snoozed_until]
render json: @notification
end
private
def set_primary_actor
@@ -11,11 +11,7 @@ class Public::Api::V1::Portals::BaseController < PublicController
end
def set_color_scheme
@theme = if %w[dark light].include?(params[:theme])
params[:theme]
else
'system'
end
@theme_from_params = params[:theme] if %w[dark light].include?(params[:theme])
end
def portal
@@ -27,7 +27,8 @@ class SuperAdmin::Devise::SessionsController < Devise::SessionsController
true
rescue StandardError => e
@error_message = e.message
Rails.logger.error e.message
@error_message = 'Invalid credentials. Please try again.'
false
end
end
+40 -19
View File
@@ -6,7 +6,17 @@ module PortalHelper
def generate_portal_bg(portal_color, theme)
bg_image = theme == 'dark' ? 'hexagon-dark.svg' : 'hexagon-light.svg'
"background: url(/assets/images/hc/#{bg_image}) #{generate_portal_bg_color(portal_color, theme)}"
"url(/assets/images/hc/#{bg_image}) #{generate_portal_bg_color(portal_color, theme)}"
end
def generate_gradient_to_bottom(theme)
base_color = theme == 'dark' ? '#151718' : 'white'
"linear-gradient(to bottom, transparent, #{base_color})"
end
def generate_portal_hover_color(portal_color, theme)
base_color = theme == 'dark' ? '#1B1B1B' : '#F9F9F9'
"color-mix(in srgb, #{portal_color} 5%, #{base_color})"
end
def language_name(locale)
@@ -14,35 +24,46 @@ module PortalHelper
language_map[locale] || locale
end
def get_theme_names(theme)
if theme == 'light'
I18n.t('public_portal.header.appearance.light')
elsif theme == 'dark'
I18n.t('public_portal.header.appearance.dark')
def theme_query_string(theme)
theme.present? && theme != 'system' ? "?theme=#{theme}" : ''
end
def generate_home_link(portal_slug, portal_locale, theme, is_plain_layout_enabled)
if is_plain_layout_enabled
"/hc/#{portal_slug}/#{portal_locale}#{theme_query_string(theme)}"
else
I18n.t('public_portal.header.appearance.system')
"/hc/#{portal_slug}/#{portal_locale}"
end
end
def get_theme_icon(theme)
if theme == 'light'
'icons/sun'
elsif theme == 'dark'
'icons/moon'
def generate_category_link(params)
portal_slug = params[:portal_slug]
category_locale = params[:category_locale]
category_slug = params[:category_slug]
theme = params[:theme]
is_plain_layout_enabled = params[:is_plain_layout_enabled]
if is_plain_layout_enabled
"/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}#{theme_query_string(theme)}"
else
'icons/monitor'
"/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}"
end
end
def generate_gradient_to_bottom(theme)
"background-image: linear-gradient(to bottom, transparent, #{theme == 'dark' ? '#151718' : 'white'})"
end
def generate_article_link(portal_slug, article_slug, theme)
"/hc/#{portal_slug}/articles/#{article_slug}#{theme.present? && theme != 'system' ? "?theme=#{theme}" : ''}"
def generate_article_link(portal_slug, article_slug, theme, is_plain_layout_enabled)
if is_plain_layout_enabled
"/hc/#{portal_slug}/articles/#{article_slug}#{theme_query_string(theme)}"
else
"/hc/#{portal_slug}/articles/#{article_slug}"
end
end
def render_category_content(content)
ChatwootMarkdownRenderer.new(content).render_markdown_to_plain_text
end
def thumbnail_bg_color(username)
colors = ['#6D95BA', '#A4C3C3', '#E19191']
colors[username.length % colors.size]
end
end
@@ -144,6 +144,7 @@ import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import {
ALLOWED_FILE_TYPES,
ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP,
ALLOWED_FILE_TYPES_FOR_LINE,
} from 'shared/constants/messages';
import VideoCallButton from '../VideoCallButton.vue';
import AIAssistanceButton from '../AIAssistanceButton.vue';
@@ -270,6 +271,9 @@ export default {
return this.showFileUpload || this.isNote;
},
showAudioRecorderButton() {
if (this.isALineChannel) {
return false;
}
// Disable audio recorder for safari browser as recording is not supported
const isSafari = /^((?!chrome|android|crios|fxios).)*safari/i.test(
navigator.userAgent
@@ -291,6 +295,9 @@ export default {
if (this.isATwilioWhatsAppChannel) {
return ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP;
}
if (this.isALineChannel) {
return ALLOWED_FILE_TYPES_FOR_LINE;
}
return ALLOWED_FILE_TYPES;
},
enableDragAndDrop() {
@@ -28,6 +28,7 @@
:message="inReplyTo"
:message-type="data.message_type"
:parent-has-attachments="hasAttachments"
@click="navigateToMessage"
/>
<bubble-text
v-if="data.content"
@@ -519,6 +520,13 @@ export default {
this.showBackgroundHighlight = false;
}, HIGHLIGHT_TIMER);
},
async navigateToMessage() {
this.$nextTick(() => {
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE, {
messageId: this.inReplyToMessageId,
});
});
},
},
};
</script>
@@ -377,12 +377,20 @@ export default {
removeBusListeners() {
bus.$off(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
},
onScrollToMessage({ messageId = '' } = {}) {
async onScrollToMessage({ messageId = '' } = {}) {
if (messageId) {
await this.$store.dispatch('setActiveChat', {
data: this.currentChat,
after: messageId,
force: true,
});
}
this.$nextTick(() => {
const messageElement = document.getElementById('message' + messageId);
if (messageElement) {
this.isProgrammaticScroll = true;
messageElement.scrollIntoView({ behavior: 'smooth' });
messageElement.scrollIntoView();
this.fetchPreviousMessages();
} else {
this.scrollToBottom();
@@ -29,6 +29,7 @@
v-if="shouldShowReplyToMessage"
:message="inReplyTo"
@dismiss="resetReplyToMessage"
@navigate-to-message="navigateToMessage"
/>
<canned-response
v-if="showMentions && hasSlashCommand"
@@ -392,7 +393,8 @@ export default {
this.isAPIInbox ||
this.isAnEmailChannel ||
this.isASmsInbox ||
this.isATelegramChannel
this.isATelegramChannel ||
this.isALineChannel
);
},
replyButtonLabel() {
@@ -624,6 +626,8 @@ export default {
`${this.$t('CONVERSATION.REPLYBOX.INSERT_READ_MORE')} ${url}`
);
}
this.$track(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK);
},
toggleRichContentEditor() {
this.updateUISettings({
@@ -1156,6 +1160,11 @@ export default {
LocalStorage.deleteFromJsonStore(replyStorageKey, this.conversationId);
bus.$emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE);
},
navigateToMessage(messageId) {
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE, {
messageId,
});
},
onNewConversationModalActive(isActive) {
// Issue is if the new conversation modal is open and we drag and drop the file
// then the file is not getting attached to the new conversation modal
@@ -1,6 +1,7 @@
<template>
<div
class="reply-editor bg-slate-50 dark:bg-slate-800 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5 -mx-2"
class="reply-editor bg-slate-50 dark:bg-slate-800 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5 -mx-2 cursor-pointer"
@click="$emit('navigate-to-message', message.id)"
>
<fluent-icon class="flex-shrink-0 icon" icon="arrow-reply" size="14" />
<div class="flex-grow gap-1 mt-px text-xs truncate">
@@ -1,12 +1,13 @@
<template>
<div
class="px-2 py-1.5 rounded-sm min-w-[10rem] mb-2"
class="px-2 py-1.5 -mx-2 rounded-sm min-w-[15rem] mb-2 cursor-pointer"
:class="{
'bg-slate-50 dark:bg-slate-600 dark:text-slate-50':
messageType === MESSAGE_TYPE.INCOMING,
'bg-woot-600 text-woot-50': messageType === MESSAGE_TYPE.OUTGOING,
'-mx-2': !parentHasAttachments,
}"
@click="$emit('click')"
>
<message-preview
:message="message"
@@ -7,7 +7,11 @@
}"
>
<div v-if="!isEmail" v-dompurify-html="message" class="text-content" />
<letter v-else class="text-content" :html="message" />
<letter
v-else
class="text-content bg-white dark:bg-white text-slate-900 dark:text-slate-900 p-2 rounded-[4px]"
:html="message"
/>
<button
v-if="showQuoteToggle"
class="text-slate-300 dark:text-slate-300 cursor-pointer text-xs py-1"
@@ -25,7 +25,7 @@
id="file"
ref="file"
type="file"
accept="image/png, image/jpeg, image/gif"
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
@change="handleImageUpload"
/>
<slot />
@@ -9,6 +9,7 @@ export const CONVERSATION_EVENTS = Object.freeze({
SEARCH_CONVERSATION: 'Searched conversations',
APPLY_FILTER: 'Applied filters in the conversation list',
CHANGE_PRIORITY: 'Assigned priority to a conversation',
INSERT_ARTICLE_LINK: 'Inserted article into reply via article search',
});
export const ACCOUNT_EVENTS = Object.freeze({
@@ -1,7 +1,8 @@
/* eslint-disable storybook/default-exports */
import SearchView from './components/SearchView.vue';
import { frontendURL } from '../../helper/URLHelper';
const SearchView = () => import('./components/SearchView.vue');
export const routes = [
{
path: frontendURL('accounts/:accountId/search'),
@@ -1,7 +1,7 @@
/* eslint arrow-body-style: 0 */
import ContactsView from './components/ContactsView';
import ContactManageView from './pages/ContactManageView';
import { frontendURL } from '../../../helper/URLHelper';
const ContactsView = () => import('./components/ContactsView.vue');
const ContactManageView = () => import('./pages/ContactManageView.vue');
export const routes = [
{
@@ -1,6 +1,6 @@
/* eslint arrow-body-style: 0 */
import ConversationView from './ConversationView';
import { frontendURL } from '../../../helper/URLHelper';
const ConversationView = () => import('./ConversationView');
export default {
routes: [
@@ -1,4 +1,3 @@
import AppContainer from './Dashboard';
import settings from './settings/settings.routes';
import conversation from './conversation/conversation.routes';
import { routes as searchRoutes } from '../../modules/search/search.routes';
@@ -7,7 +6,8 @@ import { routes as notificationRoutes } from './notifications/routes';
import { frontendURL } from '../../helper/URLHelper';
import helpcenterRoutes from './helpcenter/helpcenter.routes';
const Suspended = () => import('./suspended/Index');
const AppContainer = () => import('./Dashboard.vue');
const Suspended = () => import('./suspended/Index.vue');
export default {
routes: [
@@ -161,7 +161,7 @@ export default {
}
span.article-column {
@apply text-slate-700 dark:text-slate-100 text-sm font-semibold py-2 px-0 text-right capitalize;
@apply text-slate-700 dark:text-slate-100 text-sm font-semibold py-2 px-0 text-left capitalize last:text-right;
&.article-title {
@apply items-start flex gap-2 col-span-4 text-left;
@@ -10,17 +10,17 @@
{{ $t('HELP_CENTER.TABLE.HEADERS.TITLE') }}
</div>
<div
class="font-semibold capitalize text-sm py-2 px-0 text-slate-700 dark:text-slate-100 text-right"
class="font-semibold capitalize text-sm py-2 px-0 text-slate-700 dark:text-slate-100 text-left"
>
{{ $t('HELP_CENTER.TABLE.HEADERS.CATEGORY') }}
</div>
<div
class="font-semibold capitalize text-sm py-2 px-0 text-slate-700 dark:text-slate-100 text-right hidden lg:block"
class="font-semibold capitalize text-sm py-2 px-0 text-slate-700 dark:text-slate-100 text-left hidden lg:block"
>
{{ $t('HELP_CENTER.TABLE.HEADERS.READ_COUNT') }}
</div>
<div
class="font-semibold capitalize text-sm py-2 px-0 text-slate-700 dark:text-slate-100 text-right"
class="font-semibold capitalize text-sm py-2 px-0 text-slate-700 dark:text-slate-100 text-left"
>
{{ $t('HELP_CENTER.TABLE.HEADERS.STATUS') }}
</div>
@@ -157,16 +157,23 @@ export default {
return this.metaTags.map(item => item.name);
},
},
watch: {
article: {
handler() {
if (!isEmptyObject(this.article.meta || {})) {
const {
meta: { title = '', description = '', tags = [] },
} = this.article;
this.metaTitle = title;
this.metaDescription = description;
this.metaTags = this.formattedTags({ tags });
}
},
deep: true,
immediate: true,
},
},
mounted() {
if (!isEmptyObject(this.article.meta || {})) {
const {
meta: { title = '', description = '', tags = [] },
} = this.article;
this.metaTitle = title;
this.metaDescription = description;
this.metaTags = this.formattedTags({ tags });
}
this.saveArticle = debounce(
() => {
this.$emit('save-article', {
@@ -1,7 +1,7 @@
/* eslint arrow-body-style: 0 */
import NotificationsView from './components/NotificationsView.vue';
import { frontendURL } from '../../../helper/URLHelper';
import SettingsWrapper from '../settings/Wrapper';
const SettingsWrapper = () => import('../settings/Wrapper.vue');
const NotificationsView = () => import('./components/NotificationsView.vue');
export const routes = [
{
@@ -1,6 +1,6 @@
import SettingsContent from '../Wrapper';
import Index from './Index.vue';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Index = () => import('./Index.vue');
export default {
routes: [
@@ -1,8 +1,8 @@
import SettingsContent from '../Wrapper';
const Bot = () => import('./Index.vue');
const CsmlEditBot = () => import('./csml/Edit.vue');
const CsmlNewBot = () => import('./csml/New.vue');
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
export default {
routes: [
@@ -1,6 +1,6 @@
import SettingsContent from '../Wrapper';
import AgentHome from './Index';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const AgentHome = () => import('./Index.vue');
export default {
routes: [
@@ -1,6 +1,6 @@
import SettingsContent from '../Wrapper';
import AttributesHome from './Index';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const AttributesHome = () => import('./Index.vue');
export default {
routes: [
@@ -1,7 +1,8 @@
import SettingsContent from '../Wrapper';
import AuditLogsHome from './Index';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const AuditLogsHome = () => import('./Index.vue');
export default {
routes: [
{
@@ -1,6 +1,6 @@
import SettingsContent from '../Wrapper';
import Automation from './Index';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Automation = () => import('./Index.vue');
export default {
routes: [
@@ -1,6 +1,6 @@
import SettingsContent from '../Wrapper';
import Index from './Index.vue';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Index = () => import('./Index.vue');
export default {
routes: [
@@ -1,6 +1,6 @@
import Index from './Index';
import SettingsContent from '../Wrapper';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Index = () => import('./Index.vue');
export default {
routes: [
@@ -20,7 +20,7 @@ export default {
path: 'ongoing',
name: 'settings_account_campaigns',
roles: ['administrator'],
component: { ...Index },
component: Index,
},
],
},
@@ -36,7 +36,7 @@ export default {
path: 'one_off',
name: 'one_off',
roles: ['administrator'],
component: { ...Index },
component: Index,
},
],
},
@@ -1,7 +1,8 @@
import SettingsContent from '../Wrapper';
import CannedHome from './Index';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const CannedHome = () => import('./Index.vue');
export default {
routes: [
{
@@ -1,13 +1,14 @@
/* eslint arrow-body-style: 0 */
import SettingsContent from '../Wrapper';
import Settings from './Settings';
import InboxHome from './Index';
import InboxChannel from './InboxChannels';
import ChannelList from './ChannelList';
import channelFactory from './channel-factory';
import AddAgents from './AddAgents';
import FinishSetup from './FinishSetup';
import { frontendURL } from '../../../../helper/URLHelper';
import channelFactory from './channel-factory';
const SettingsContent = () => import('../Wrapper.vue');
const InboxHome = () => import('./Index.vue');
const Settings = () => import('./Settings.vue');
const InboxChannel = () => import('./InboxChannels.vue');
const ChannelList = () => import('./ChannelList.vue');
const AddAgents = () => import('./AddAgents.vue');
const FinishSetup = () => import('./FinishSetup.vue');
export default {
routes: [
@@ -1,7 +1,7 @@
import Index from './Index';
import SettingsContent from '../Wrapper';
import IntegrationHooks from './IntegrationHooks';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const IntegrationHooks = () => import('./IntegrationHooks.vue');
const Index = () => import('./Index.vue');
export default {
routes: [
@@ -1,11 +1,12 @@
import Index from './Index';
import SettingsContent from '../Wrapper';
import Webhook from './Webhooks/Index';
import DashboardApps from './DashboardApps/Index';
import ShowIntegration from './ShowIntegration';
import Slack from './Slack';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Webhook = () => import('./Webhooks/Index.vue');
const DashboardApps = () => import('./DashboardApps/Index.vue');
const ShowIntegration = () => import('./ShowIntegration.vue');
const Slack = () => import('./Slack.vue');
const Index = () => import('./Index.vue');
export default {
routes: [
{
@@ -1,7 +1,8 @@
import SettingsContent from '../Wrapper';
import Index from './Index';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Index = () => import('./Index.vue');
export default {
routes: [
{
@@ -1,6 +1,8 @@
<template>
<div class="flex flex-row h-full">
<div class="w-[60%] macros-canvas">
<div class="flex flex-col md:flex-row h-auto md:h-full w-full">
<div
class="flex-1 w-full md:w-auto macro-gradient-radial dark:macro-dark-gradient-radial macro-gradient-radial-size h-full max-h-full py-4 px-12 overflow-y-auto"
>
<macro-nodes
v-model="macro.actions"
:files="files"
@@ -9,7 +11,7 @@
@resetAction="resetNode"
/>
</div>
<div class="w-[34%]">
<div class="w-full md:w-1/3">
<macro-properties
:macro-name="macro.name"
:macro-visibility="macro.visibility"
@@ -138,7 +140,4 @@ export default {
background-size: 1rem 1rem;
}
}
.macros-canvas {
@apply macro-gradient-radial dark:macro-dark-gradient-radial macro-gradient-radial-size h-full max-h-full py-4 px-12 overflow-y-auto;
}
</style>
@@ -18,7 +18,7 @@
>
{{ $t('MACROS.EDITOR.VISIBILITY.LABEL') }}
</p>
<div class="grid grid-cols-2 gap-3">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<button
class="p-2 relative rounded-md border border-solid text-left cursor-default"
:class="isActive('global')"
@@ -1,8 +1,9 @@
import SettingsContent from '../Wrapper';
import Macros from './Index';
const MacroEditor = () => import('./MacroEditor');
import { frontendURL } from 'dashboard/helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Macros = () => import('./Index.vue');
const MacroEditor = () => import('./MacroEditor.vue');
export default {
routes: [
{
@@ -22,14 +22,12 @@
:enabled-menu-options="customEditorMenuList"
:enable-suggestions="false"
:show-image-resize-toolbar="true"
@blur="$v.messageSignature.$touch"
/>
</div>
<woot-button
:is-loading="isUpdating"
type="button"
:is-disabled="$v.messageSignature.$invalid"
@click.prevent="updateSignature()"
@click.prevent="updateSignature"
>
{{ $t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.BTN_TEXT') }}
</woot-button>
@@ -38,7 +36,6 @@
</template>
<script>
import { required } from 'vuelidate/lib/validators';
import { mapGetters } from 'vuex';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
@@ -59,11 +56,6 @@ export default {
customEditorMenuList: MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS,
};
},
validations: {
messageSignature: {
required,
},
},
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
@@ -79,15 +71,9 @@ export default {
this.messageSignature = messageSignature || '';
},
async updateSignature() {
this.$v.$touch();
if (this.$v.$invalid) {
this.showAlert(this.$t('PROFILE_SETTINGS.FORM.ERROR'));
return;
}
try {
await this.$store.dispatch('updateProfile', {
message_signature: this.messageSignature,
message_signature: this.messageSignature || '',
});
this.errorMessage = this.$t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.API_SUCCESS'
@@ -1,7 +1,8 @@
import SettingsContent from '../Wrapper';
import Index from './Index.vue';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Index = () => import('./Index.vue');
export default {
routes: [
{
@@ -1,13 +1,14 @@
import Index from './Index';
import AgentReports from './AgentReports';
import LabelReports from './LabelReports';
import InboxReports from './InboxReports';
import TeamReports from './TeamReports';
import CsatResponses from './CsatResponses';
import LiveReports from './LiveReports';
import SettingsContent from '../Wrapper';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Index = () => import('./Index.vue');
const AgentReports = () => import('./AgentReports.vue');
const LabelReports = () => import('./LabelReports.vue');
const InboxReports = () => import('./InboxReports.vue');
const TeamReports = () => import('./TeamReports.vue');
const CsatResponses = () => import('./CsatResponses.vue');
const LiveReports = () => import('./LiveReports.vue');
export default {
routes: [
{
@@ -1,15 +1,16 @@
/* eslint arrow-body-style: 0 */
import SettingsContent from '../Wrapper';
import TeamsHome from './Index';
import CreateStepWrap from './Create/Index';
import EditStepWrap from './Edit/Index';
import CreateTeam from './Create/CreateTeam';
import EditTeam from './Edit/EditTeam';
import AddAgents from './Create/AddAgents';
import EditAgents from './Edit/EditAgents';
import FinishSetup from './FinishSetup';
import { frontendURL } from '../../../../helper/URLHelper';
const CreateStepWrap = () => import('./Create/Index.vue');
const EditStepWrap = () => import('./Edit/Index.vue');
const CreateTeam = () => import('./Create/CreateTeam.vue');
const EditTeam = () => import('./Edit/EditTeam.vue');
const AddAgents = () => import('./Create/AddAgents.vue');
const EditAgents = () => import('./Edit/EditAgents.vue');
const FinishSetup = () => import('./FinishSetup.vue');
const SettingsContent = () => import('../Wrapper.vue');
const TeamsHome = () => import('./Index.vue');
export default {
routes: [
{
@@ -160,10 +160,10 @@ const actions = {
});
},
async setActiveChat({ commit, dispatch }, { data, after }) {
async setActiveChat({ commit, dispatch }, { data, after, force = false }) {
commit(types.SET_CURRENT_CHAT_WINDOW, data);
commit(types.CLEAR_ALL_MESSAGES_LOADED);
if (data.dataFetched === undefined) {
if (data.dataFetched === undefined || force) {
try {
await dispatch('fetchPreviousMessages', {
after,
+33 -107
View File
@@ -3,6 +3,7 @@ import Vue from 'vue';
import PublicArticleSearch from './components/PublicArticleSearch.vue';
import TableOfContents from './components/TableOfContents.vue';
import { initializeTheme } from './portalThemeHelper.js';
export const getHeadingsfromTheArticle = () => {
const rows = [];
@@ -21,82 +22,42 @@ export const getHeadingsfromTheArticle = () => {
return rows;
};
export const generatePortalBgColor = (portalColor, theme) => {
const baseColor = theme === 'dark' ? 'black' : 'white';
return `color-mix(in srgb, ${portalColor} 20%, ${baseColor})`;
};
export const openExternalLinksInNewTab = () => {
const { customDomain, hostURL } = window.portalConfig;
const isSameHost =
window.location.href.includes(customDomain) ||
window.location.href.includes(hostURL);
export const generatePortalBg = (portalColor, theme) => {
const bgImage = theme === 'dark' ? 'hexagon-dark.svg' : 'hexagon-light.svg';
return `background: url(/assets/images/hc/${bgImage}) ${generatePortalBgColor(
portalColor,
theme
)}`;
};
// Modify external links only on articles page
const isOnArticlePage =
isSameHost && document.querySelector('#cw-article-content') !== null;
export const generateGradientToBottom = theme => {
return `background-image: linear-gradient(to bottom, transparent, ${
theme === 'dark' ? '#151718' : 'white'
})`;
};
document.addEventListener('click', function (event) {
if (!isOnArticlePage) return;
export const setPortalStyles = theme => {
const portalColor = window.portalConfig.portalColor;
const portalBgDiv = document.querySelector('#portal-bg');
const portalBgGradientDiv = document.querySelector('#portal-bg-gradient');
// Some of the links come wrapped in strong tag through prosemirror
if (portalBgDiv) {
// Set background for #portal-bg
portalBgDiv.setAttribute('style', generatePortalBg(portalColor, theme));
}
const isTagAnchor = event.target.tagName === 'A';
const isParentTagAnchor =
event.target.tagName === 'STRONG' &&
event.target.parentNode.tagName === 'A';
if (portalBgGradientDiv) {
// Set gradient background for #portal-bg-gradient
portalBgGradientDiv.setAttribute('style', generateGradientToBottom(theme));
}
};
if (isTagAnchor || isParentTagAnchor) {
const link = isTagAnchor ? event.target : event.target.parentNode;
export const setPortalClass = theme => {
const portalDiv = document.querySelector('#portal');
portalDiv.classList.remove('light', 'dark');
if (!portalDiv) return;
portalDiv.classList.add(theme);
};
const isInternalLink =
link.hostname === window.location.hostname ||
link.href.includes(customDomain) ||
link.href.includes(hostURL);
export const updateThemeStyles = theme => {
setPortalStyles(theme);
setPortalClass(theme);
};
export const toggleAppearanceDropdown = () => {
const dropdown = document.getElementById('appearance-dropdown');
if (!dropdown) return;
dropdown.style.display =
dropdown.style.display === 'none' || !dropdown.style.display
? 'flex'
: 'none';
};
export const updateURLParameter = (param, paramVal) => {
const urlObj = new URL(window.location);
urlObj.searchParams.set(param, paramVal);
return urlObj.toString();
};
export const removeURLParameter = parameter => {
const urlObj = new URL(window.location);
urlObj.searchParams.delete(parameter);
return urlObj.toString();
};
export const switchTheme = theme => {
updateThemeStyles(theme);
const newUrl =
theme !== 'system'
? updateURLParameter('theme', theme)
: removeURLParameter('theme');
window.location.href = newUrl;
toggleAppearanceDropdown();
if (!isInternalLink) {
link.target = '_blank';
link.rel = 'noopener noreferrer'; // Security and performance benefits
// Prevent default if you want to stop the link from opening in the current tab
event.stopPropagation();
}
}
});
};
export const InitializationHelpers = {
@@ -146,52 +107,17 @@ export const InitializationHelpers = {
});
},
initializeTheme: () => {
const mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');
const getThemePreference = () =>
mediaQueryList.matches ? 'dark' : 'light';
const themeFromServer = window.portalConfig.theme;
if (themeFromServer === 'system') {
// Handle dynamic theme changes for system theme
mediaQueryList.addEventListener('change', event => {
const newTheme = event.matches ? 'dark' : 'light';
updateThemeStyles(newTheme);
});
const themePreference = getThemePreference();
updateThemeStyles(themePreference);
}
},
initializeToggleButton: () => {
const toggleButton = document.getElementById('toggle-appearance');
if (toggleButton) {
toggleButton.addEventListener('click', toggleAppearanceDropdown);
}
},
initializeThemeSwitchButtons: () => {
const appearanceDropdown = document.getElementById('appearance-dropdown');
if (!appearanceDropdown) return;
appearanceDropdown.addEventListener('click', event => {
const target = event.target.closest('button[data-theme]');
if (target) {
const theme = target.getAttribute('data-theme');
switchTheme(theme);
}
});
},
initializeThemesInPortal: initializeTheme,
initialize: () => {
openExternalLinksInNewTab();
if (window.portalConfig.isPlainLayoutEnabled === 'true') {
InitializationHelpers.appendPlainParamToURLs();
} else {
InitializationHelpers.initializeThemesInPortal();
InitializationHelpers.navigateToLocalePage();
InitializationHelpers.initializeSearch();
InitializationHelpers.initializeTableOfContents();
InitializationHelpers.initializeTheme();
InitializationHelpers.initializeToggleButton();
InitializationHelpers.initializeThemeSwitchButtons();
}
},
+127
View File
@@ -0,0 +1,127 @@
import { adjustColorForContrast } from '../shared/helpers/colorHelper.js';
export const setPortalHoverColor = theme => {
// This function is to set the hover color for the portal
if (theme === 'system') {
const prefersDarkMode = window.matchMedia(
'(prefers-color-scheme: dark)'
).matches;
theme = prefersDarkMode ? 'dark' : 'light';
}
const portalColor = window.portalConfig.portalColor;
const bgColor = theme === 'dark' ? '#151718' : 'white';
const hoverColor = adjustColorForContrast(portalColor, bgColor);
// Set hover color for border and text dynamically
document.documentElement.style.setProperty(
'--dynamic-hover-color',
hoverColor
);
};
export const removeQueryParamsFromUrl = (queryParam = 'theme') => {
// This function is to remove the theme query param from the URL
// This is done so that the theme is not persisted in the URL
// This is called when the theme is switched from the dropdown
const url = new URL(window.location.href);
const param = url.searchParams.get(queryParam);
if (param) {
url.searchParams.delete(queryParam);
window.history.replaceState({}, '', url.toString()); // Convert URL to string
}
};
export const updateThemeInHeader = theme => {
// This function is to update the theme selection in the header in real time
const themeToggleButton = document.getElementById('toggle-appearance');
if (!themeToggleButton) return;
const allElementInButton =
themeToggleButton.querySelectorAll('.theme-button');
if (!allElementInButton) return;
allElementInButton.forEach(button => {
button.classList.toggle('hidden', button.dataset.theme !== theme);
button.classList.toggle('flex', button.dataset.theme === theme);
});
};
export const switchTheme = theme => {
if (theme === 'system') {
localStorage.removeItem('theme');
const prefersDarkMode = window.matchMedia(
'(prefers-color-scheme: dark)'
).matches;
// remove this so that the system theme is used
document.documentElement.classList.remove('dark', 'light');
document.documentElement.classList.add(prefersDarkMode ? 'dark' : 'light');
} else {
localStorage.theme = theme;
document.documentElement.classList.remove('dark', 'light');
document.documentElement.classList.add(theme);
}
setPortalHoverColor(theme);
updateThemeInHeader(theme);
removeQueryParamsFromUrl();
};
export const initializeThemeSwitchButtons = () => {
const appearanceDropdown = document.getElementById('appearance-dropdown');
appearanceDropdown.dataset.currentTheme = localStorage.theme || 'system';
appearanceDropdown.addEventListener('click', event => {
const target = event.target.closest('button[data-theme]');
if (target) {
const { theme } = target.dataset;
// setting this data property will automatically toggle the checkmark using CSS
appearanceDropdown.dataset.currentTheme = theme;
switchTheme(theme);
// wait for a bit before hiding the dropdown
appearanceDropdown.style.display = 'none';
}
});
};
export const initializeToggleButton = () => {
const themeToggleButton = document.getElementById('toggle-appearance');
themeToggleButton?.addEventListener('click', () => {
const appearanceDropdown = document.getElementById('appearance-dropdown');
const isCurrentlyHidden = appearanceDropdown.style.display === 'none';
// Toggle the appearanceDropdown
appearanceDropdown.style.display = isCurrentlyHidden ? 'flex' : 'none';
});
};
export const initializeMediaQueryListener = () => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', () => {
if (['light', 'dark'].includes(localStorage.theme)) return;
switchTheme('system');
});
};
export const initializeTheme = () => {
if (window.portalConfig.isPlainLayoutEnabled === 'true') return;
// start with updating the theme in the header, this will set the current theme on the button
// and set the hover color at the start of init, this is set again when the theme is switched
setPortalHoverColor(localStorage.theme || 'system');
window.updateThemeInHeader = updateThemeInHeader;
updateThemeInHeader(localStorage.theme || 'system');
// add the event listeners for the dropdown toggle and theme buttons
initializeToggleButton();
initializeThemeSwitchButtons();
// add the media query listener to update the theme when the system theme changes
initializeMediaQueryListener();
};
+1 -312
View File
@@ -1,15 +1,4 @@
import {
InitializationHelpers,
generatePortalBgColor,
generatePortalBg,
generateGradientToBottom,
setPortalStyles,
setPortalClass,
updateThemeStyles,
toggleAppearanceDropdown,
updateURLParameter,
removeURLParameter,
} from '../portalHelpers';
import { InitializationHelpers } from '../portalHelpers';
describe('#navigateToLocalePage', () => {
it('returns correct cookie name', () => {
@@ -32,303 +21,3 @@ describe('#navigateToLocalePage', () => {
);
});
});
describe('Theme Functions', () => {
describe('#generatePortalBgColor', () => {
it('returns mixed color for dark theme', () => {
const result = generatePortalBgColor('#FF5733', 'dark');
expect(result).toBe('color-mix(in srgb, #FF5733 20%, black)');
});
it('returns mixed color for light theme', () => {
const result = generatePortalBgColor('#FF5733', 'light');
expect(result).toBe('color-mix(in srgb, #FF5733 20%, white)');
});
});
describe('#generatePortalBg', () => {
it('returns background for dark theme', () => {
const result = generatePortalBg('#FF5733', 'dark');
expect(result).toBe(
'background: url(/assets/images/hc/hexagon-dark.svg) color-mix(in srgb, #FF5733 20%, black)'
);
});
it('returns background for light theme', () => {
const result = generatePortalBg('#FF5733', 'light');
expect(result).toBe(
'background: url(/assets/images/hc/hexagon-light.svg) color-mix(in srgb, #FF5733 20%, white)'
);
});
});
describe('#generateGradientToBottom', () => {
it('returns gradient for dark theme', () => {
const result = generateGradientToBottom('dark');
expect(result).toBe(
'background-image: linear-gradient(to bottom, transparent, #151718)'
);
});
it('returns gradient for light theme', () => {
const result = generateGradientToBottom('light');
expect(result).toBe(
'background-image: linear-gradient(to bottom, transparent, white)'
);
});
});
describe('#setPortalStyles', () => {
let mockPortalBgDiv;
let mockPortalBgGradientDiv;
beforeEach(() => {
// Mocking portal background div
mockPortalBgDiv = document.createElement('div');
mockPortalBgDiv.id = 'portal-bg';
document.body.appendChild(mockPortalBgDiv);
// Mocking portal background gradient div
mockPortalBgGradientDiv = document.createElement('div');
mockPortalBgGradientDiv.id = 'portal-bg-gradient';
document.body.appendChild(mockPortalBgGradientDiv);
});
afterEach(() => {
document.body.innerHTML = '';
});
it('sets styles for portal background based on theme', () => {
window.portalConfig = { portalColor: '#FF5733' };
setPortalStyles('dark');
const expectedPortalBgStyle =
'background: url(/assets/images/hc/hexagon-dark.svg) color-mix(in srgb, #FF5733 20%, black)';
const expectedGradientStyle =
'background-image: linear-gradient(to bottom, transparent, #151718)';
expect(mockPortalBgDiv.getAttribute('style')).toBe(expectedPortalBgStyle);
expect(mockPortalBgGradientDiv.getAttribute('style')).toBe(
expectedGradientStyle
);
});
});
describe('#setPortalClass', () => {
let mockPortalDiv;
beforeEach(() => {
// Mocking portal div
mockPortalDiv = document.createElement('div');
mockPortalDiv.id = 'portal';
mockPortalDiv.classList.add('light');
document.body.appendChild(mockPortalDiv);
});
afterEach(() => {
document.body.innerHTML = '';
});
it('sets portal class to "dark" based on theme', () => {
setPortalClass('dark');
expect(mockPortalDiv.classList.contains('dark')).toBe(true);
expect(mockPortalDiv.classList.contains('light')).toBe(false);
});
it('sets portal class to "light" based on theme', () => {
setPortalClass('light');
expect(mockPortalDiv.classList.contains('light')).toBe(true);
expect(mockPortalDiv.classList.contains('dark')).toBe(false);
});
});
describe('toggleAppearanceDropdown', () => {
it('sets dropdown display to flex if initially none', () => {
document.body.innerHTML = `<div id="appearance-dropdown" style="display: none;"></div>`;
toggleAppearanceDropdown();
const dropdown = document.getElementById('appearance-dropdown');
expect(dropdown.style.display).toBe('flex');
});
it('sets dropdown display to none if initially flex', () => {
document.body.innerHTML = `<div id="appearance-dropdown" style="display: flex;"></div>`;
toggleAppearanceDropdown();
const dropdown = document.getElementById('appearance-dropdown');
expect(dropdown.style.display).toBe('none');
});
it('does nothing if dropdown element does not exist', () => {
document.body.innerHTML = ``;
expect(() => toggleAppearanceDropdown()).not.toThrow();
});
});
describe('updateURLParameter', () => {
it('updates a given parameter with a new value', () => {
const originalUrl = 'http://example.com?param=oldValue';
delete window.location;
window.location = new URL(originalUrl);
const updatedUrl = updateURLParameter('param', 'newValue');
expect(updatedUrl).toContain('param=newValue');
});
it('adds a new parameter if it does not exist', () => {
const originalUrl = 'http://example.com';
delete window.location;
window.location = new URL(originalUrl);
const updatedUrl = updateURLParameter('newParam', 'value');
expect(updatedUrl).toContain('newParam=value');
});
});
describe('removeURLParameter', () => {
it('removes an existing parameter', () => {
const originalUrl = 'http://example.com?param=value';
delete window.location;
window.location = new URL(originalUrl);
const updatedUrl = removeURLParameter('param');
expect(updatedUrl).not.toContain('param=value');
});
it('does nothing if the parameter does not exist', () => {
const originalUrl = 'http://example.com/';
delete window.location;
window.location = new URL(originalUrl);
const updatedUrl = removeURLParameter('param');
expect(updatedUrl).toBe(originalUrl);
});
});
describe('#updateThemeStyles', () => {
let mockPortalDiv;
let mockPortalBgDiv;
let mockPortalBgGradientDiv;
beforeEach(() => {
// Mocking portal div
mockPortalDiv = document.createElement('div');
mockPortalDiv.id = 'portal';
document.body.appendChild(mockPortalDiv);
// Mocking portal background div
mockPortalBgDiv = document.createElement('div');
mockPortalBgDiv.id = 'portal-bg';
document.body.appendChild(mockPortalBgDiv);
// Mocking portal background gradient div
mockPortalBgGradientDiv = document.createElement('div');
mockPortalBgGradientDiv.id = 'portal-bg-gradient';
document.body.appendChild(mockPortalBgGradientDiv);
});
afterEach(() => {
document.body.innerHTML = '';
});
it('updates theme styles based on theme', () => {
window.portalConfig = { portalColor: '#FF5733' };
updateThemeStyles('dark');
const expectedPortalBgStyle =
'background: url(/assets/images/hc/hexagon-dark.svg) color-mix(in srgb, #FF5733 20%, black)';
const expectedGradientStyle =
'background-image: linear-gradient(to bottom, transparent, #151718)';
expect(mockPortalDiv.classList.contains('dark')).toBe(true);
expect(mockPortalBgDiv.getAttribute('style')).toBe(expectedPortalBgStyle);
expect(mockPortalBgGradientDiv.getAttribute('style')).toBe(
expectedGradientStyle
);
});
});
describe('#initializeTheme', () => {
let mockPortalDiv;
beforeEach(() => {
mockPortalDiv = document.createElement('div');
mockPortalDiv.id = 'portal';
document.body.appendChild(mockPortalDiv);
});
afterEach(() => {
document.body.innerHTML = '';
});
it('updates theme based on system preferences', () => {
const mediaQueryList = {
matches: true,
addEventListener: jest.fn(),
};
window.matchMedia = jest.fn().mockReturnValue(mediaQueryList);
window.portalConfig = { theme: 'system' };
InitializationHelpers.initializeTheme();
expect(mediaQueryList.addEventListener).toBeCalledWith(
'change',
expect.any(Function)
);
expect(mockPortalDiv.classList.contains('dark')).toBe(true);
});
it('does not update theme if themeFromServer is not "system"', () => {
const mediaQueryList = {
matches: true,
addEventListener: jest.fn(),
};
window.matchMedia = jest.fn().mockReturnValue(mediaQueryList);
window.portalConfig = { theme: 'dark' };
InitializationHelpers.initializeTheme();
expect(mediaQueryList.addEventListener).not.toBeCalled();
expect(mockPortalDiv.classList.contains('dark')).toBe(false);
expect(mockPortalDiv.classList.contains('light')).toBe(false);
});
});
describe('initializeToggleButton', () => {
it('adds a click listener to the toggle button', () => {
document.body.innerHTML = `<button id="toggle-appearance"></button>`;
InitializationHelpers.initializeToggleButton();
const toggleButton = document.getElementById('toggle-appearance');
expect(toggleButton.onclick).toBeDefined();
});
it('does nothing if the toggle button is not present', () => {
document.body.innerHTML = ``;
expect(() =>
InitializationHelpers.initializeToggleButton()
).not.toThrow();
});
});
describe('initializeThemeSwitchButtons', () => {
it('adds click listeners to theme switch buttons', () => {
document.body.innerHTML = `<div id="appearance-dropdown"><button data-theme="dark"></button><button data-theme="light"></button></div>`;
InitializationHelpers.initializeThemeSwitchButtons();
const buttons = document.querySelectorAll('button[data-theme]');
buttons.forEach(button => expect(button.onclick).toBeDefined());
});
it('does nothing if theme switch buttons are not present', () => {
document.body.innerHTML = ``;
expect(() =>
InitializationHelpers.initializeThemeSwitchButtons()
).not.toThrow();
});
it('does nothing if appearance-dropdown is not present', () => {
document.body.innerHTML = ``;
expect(() =>
InitializationHelpers.initializeThemeSwitchButtons()
).not.toThrow();
});
});
});
@@ -0,0 +1,279 @@
import {
setPortalHoverColor,
removeQueryParamsFromUrl,
updateThemeInHeader,
switchTheme,
initializeThemeSwitchButtons,
initializeToggleButton,
initializeMediaQueryListener,
initializeTheme,
} from '../portalThemeHelper.js';
import { adjustColorForContrast } from '../../shared/helpers/colorHelper.js';
describe('portalThemeHelper', () => {
let themeToggleButton;
let appearanceDropdown;
beforeEach(() => {
themeToggleButton = document.createElement('div');
themeToggleButton.id = 'toggle-appearance';
document.body.appendChild(themeToggleButton);
appearanceDropdown = document.createElement('div');
appearanceDropdown.id = 'appearance-dropdown';
document.body.appendChild(appearanceDropdown);
window.matchMedia = jest.fn().mockImplementation(query => ({
matches: query === '(prefers-color-scheme: dark)',
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
}));
window.portalConfig = { portalColor: '#ff5733' };
document.documentElement.style.setProperty = jest.fn();
document.documentElement.classList.remove('dark', 'light');
jest.clearAllMocks();
});
afterEach(() => {
themeToggleButton.remove();
appearanceDropdown.remove();
delete window.portalConfig;
document.documentElement.style.setProperty.mockRestore();
document.documentElement.classList.remove('dark', 'light');
localStorage.clear();
});
describe('#setPortalHoverColor', () => {
it('should apply dark hover color in dark theme', () => {
const hoverColor = adjustColorForContrast('#ff5733', '#151718');
setPortalHoverColor('dark');
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
'--dynamic-hover-color',
hoverColor
);
});
it('should apply light hover color in light theme', () => {
const hoverColor = adjustColorForContrast('#ff5733', '#ffffff');
setPortalHoverColor('light');
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
'--dynamic-hover-color',
hoverColor
);
});
});
describe('#removeQueryParamsFromUrl', () => {
let originalLocation;
beforeEach(() => {
originalLocation = window.location;
delete window.location;
window.location = new URL('http://localhost:3000/');
window.history.replaceState = jest.fn();
});
afterEach(() => {
window.location = originalLocation;
});
it('should not remove query params if theme is not in the URL', () => {
removeQueryParamsFromUrl();
expect(window.history.replaceState).not.toHaveBeenCalled();
});
it('should remove theme query param from the URL', () => {
window.location = new URL(
'http://localhost:3000/?theme=light&show_plain_layout=true'
);
removeQueryParamsFromUrl('theme');
expect(window.history.replaceState).toHaveBeenCalledWith(
{},
'',
'http://localhost:3000/?show_plain_layout=true'
);
});
});
describe('#updateThemeInHeader', () => {
beforeEach(() => {
themeToggleButton.innerHTML = `
<div class="theme-button" data-theme="light"></div>
<div class="theme-button" data-theme="dark"></div>
<div class="theme-button" data-theme="system"></div>
`;
});
it('should not update header if theme toggle button is not found', () => {
themeToggleButton.remove();
updateThemeInHeader('light');
expect(document.querySelector('.theme-button')).toBeNull();
});
it('should show the theme button for the selected theme', () => {
updateThemeInHeader('light');
const lightButton = themeToggleButton.querySelector(
'.theme-button[data-theme="light"]'
);
expect(lightButton.classList).toContain('flex');
});
});
describe('#switchTheme', () => {
it('should set theme to system theme and update classes', () => {
window.matchMedia = jest.fn().mockReturnValue({ matches: true });
switchTheme('system');
expect(localStorage.theme).toBeUndefined();
expect(document.documentElement.classList).toContain('dark');
});
it('should set theme to light theme and update classes', () => {
switchTheme('light');
expect(localStorage.theme).toBe('light');
expect(document.documentElement.classList).toContain('light');
});
it('should set theme to dark theme and update classes', () => {
switchTheme('dark');
expect(localStorage.theme).toBe('dark');
expect(document.documentElement.classList).toContain('dark');
});
});
describe('#initializeThemeSwitchButtons', () => {
beforeEach(() => {
appearanceDropdown.innerHTML = `
<button data-theme="light"><span class="check-mark-icon light-theme"></span></button>
<button data-theme="dark"><span class="check-mark-icon dark-theme"></span></button>
<button data-theme="system"><span class="check-mark-icon system-theme"></span></button>
`;
});
it('does nothing if the appearance dropdown is not found', () => {
appearanceDropdown.remove();
expect(appearanceDropdown.dataset.currentTheme).toBeUndefined();
});
it('should set current theme to system if no theme in localStorage', () => {
localStorage.removeItem('theme');
initializeThemeSwitchButtons();
expect(appearanceDropdown.dataset.currentTheme).toBe('system');
});
it('sets the current theme to the light theme', () => {
localStorage.theme = 'light';
appearanceDropdown.dataset.currentTheme = 'light';
initializeThemeSwitchButtons();
expect(appearanceDropdown.dataset.currentTheme).toBe('light');
});
it('sets the current theme to the dark theme', () => {
localStorage.theme = 'dark';
appearanceDropdown.dataset.currentTheme = 'dark';
initializeThemeSwitchButtons();
expect(appearanceDropdown.dataset.currentTheme).toBe('dark');
});
});
describe('#initializeToggleButton', () => {
it('does nothing if the theme toggle button is not found', () => {
themeToggleButton.remove();
initializeToggleButton();
expect(appearanceDropdown.style.display).toBe('');
});
it('toggles the appearance dropdown show/hide', () => {
themeToggleButton.click();
appearanceDropdown.style.display = 'flex';
expect(appearanceDropdown.style.display).toBe('flex');
themeToggleButton.click();
appearanceDropdown.style.display = 'none';
expect(appearanceDropdown.style.display).toBe('none');
});
});
describe('#initializeMediaQueryListener', () => {
let mediaQuery;
beforeEach(() => {
mediaQuery = {
addEventListener: jest.fn(),
matches: false,
};
window.matchMedia = jest.fn().mockReturnValue(mediaQuery);
});
it('adds a listener to the media query', () => {
initializeMediaQueryListener();
expect(window.matchMedia).toHaveBeenCalledWith(
'(prefers-color-scheme: dark)'
);
expect(mediaQuery.addEventListener).toHaveBeenCalledWith(
'change',
expect.any(Function)
);
});
it('does not switch theme if local storage theme is light or dark', () => {
localStorage.theme = 'light';
initializeMediaQueryListener();
mediaQuery.matches = true;
mediaQuery.addEventListener.mock.calls[0][1]();
expect(localStorage.theme).toBe('light');
});
it('switches to dark theme if system preference changes to dark and no theme is set in local storage', () => {
localStorage.removeItem('theme');
initializeMediaQueryListener();
mediaQuery.matches = true;
mediaQuery.addEventListener.mock.calls[0][1]();
expect(document.documentElement.classList).toContain('dark');
});
it('switches to light theme if system preference changes to light and no theme is set in local storage', () => {
localStorage.removeItem('theme');
initializeMediaQueryListener();
mediaQuery.matches = false;
mediaQuery.addEventListener.mock.calls[0][1]();
expect(document.documentElement.classList).toContain('light');
});
});
describe('#initializeTheme', () => {
it('should not initialize theme if plain layout is enabled', () => {
window.portalConfig.isPlainLayoutEnabled = 'true';
initializeTheme();
expect(localStorage.theme).toBeUndefined();
expect(document.documentElement.classList).not.toContain('light');
expect(document.documentElement.classList).not.toContain('dark');
});
it('sets the theme to the system theme', () => {
initializeTheme();
expect(localStorage.theme).toBeUndefined();
const prefersDarkMode = window.matchMedia(
'(prefers-color-scheme: dark)'
).matches;
expect(document.documentElement.classList.contains('light')).toBe(
!prefersDarkMode
);
});
it('sets the theme to the light theme', () => {
localStorage.theme = 'light';
document.documentElement.classList.add('light');
initializeTheme();
expect(localStorage.theme).toBe('light');
expect(document.documentElement.classList.contains('light')).toBe(true);
});
it('sets the theme to the dark theme', () => {
localStorage.theme = 'dark';
document.documentElement.classList.add('dark');
initializeTheme();
expect(localStorage.theme).toBe('dark');
expect(document.documentElement.classList.contains('dark')).toBe(true);
});
});
});
@@ -55,6 +55,8 @@ export const ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP =
'audio/mpeg, audio/opus, audio/ogg, audio/amr,' +
'video/mp4,' +
'application/pdf,';
// https://developers.line.biz/en/reference/messaging-api/#image-message, https://developers.line.biz/en/reference/messaging-api/#video-message
export const ALLOWED_FILE_TYPES_FOR_LINE = 'image/png, image/jpeg,video/mp4';
export const CSAT_RATINGS = [
{
@@ -1,3 +1,5 @@
import { toHex, mix, getLuminance, getContrast } from 'color2k';
export const isWidgetColorLighter = color => {
const colorToCheck = color.replace('#', '');
const c_r = parseInt(colorToCheck.substr(0, 2), 16);
@@ -6,3 +8,21 @@ export const isWidgetColorLighter = color => {
const brightness = (c_r * 299 + c_g * 587 + c_b * 114) / 1000;
return brightness > 225;
};
export const adjustColorForContrast = (color, backgroundColor) => {
const targetRatio = 3.1;
const MAX_ITERATIONS = 20;
let adjustedColor = color;
for (let iteration = 0; iteration < MAX_ITERATIONS; iteration += 1) {
const currentRatio = getContrast(adjustedColor, backgroundColor);
if (currentRatio >= targetRatio) {
break;
}
const adjustmentDirection =
getLuminance(adjustedColor) < 0.5 ? '#fff' : '#151718';
adjustedColor = mix(adjustedColor, adjustmentDirection, 0.05);
}
return toHex(adjustedColor);
};
@@ -1,4 +1,8 @@
import { isWidgetColorLighter } from 'shared/helpers/colorHelper';
import { toHex, getContrast } from 'color2k';
import {
isWidgetColorLighter,
adjustColorForContrast,
} from 'shared/helpers/colorHelper';
describe('#isWidgetColorLighter', () => {
it('returns true if color is lighter', () => {
@@ -8,3 +12,56 @@ describe('#isWidgetColorLighter', () => {
expect(isWidgetColorLighter('#000000')).toEqual(false);
});
});
describe('#adjustColorForContrast', () => {
const targetRatio = 3.1;
const getContrastRatio = (color1, color2) => {
// getContrast from 'color2k'
return getContrast(color1, color2);
};
it('adjusts a color to meet the contrast ratio against a light background', () => {
const color = '#ff0000';
const backgroundColor = '#ffffff';
const adjustedColor = adjustColorForContrast(color, backgroundColor);
const ratio = getContrastRatio(adjustedColor, backgroundColor);
expect(ratio).toBeGreaterThanOrEqual(targetRatio);
});
it('adjusts a color to meet the contrast ratio against a dark background', () => {
const color = '#00ff00';
const backgroundColor = '#000000';
const adjustedColor = adjustColorForContrast(color, backgroundColor);
const ratio = getContrastRatio(adjustedColor, backgroundColor);
expect(ratio).toBeGreaterThanOrEqual(targetRatio);
});
it('returns a string representation of the color', () => {
const color = '#00ff00';
const backgroundColor = '#000000';
const adjustedColor = adjustColorForContrast(color, backgroundColor);
expect(typeof adjustedColor).toEqual('string');
});
it('handles cases where the color already meets the contrast ratio', () => {
const color = '#000000';
const backgroundColor = '#ffffff';
const adjustedColor = adjustColorForContrast(color, backgroundColor);
const ratio = getContrastRatio(adjustedColor, backgroundColor);
expect(ratio).toBeGreaterThanOrEqual(targetRatio);
expect(adjustedColor).toEqual(toHex(color));
});
it('does not modify a color that already exceeds the contrast ratio', () => {
const color = '#000000';
const backgroundColor = '#ffffff';
const adjustedColor = adjustColorForContrast(color, backgroundColor);
expect(adjustedColor).toEqual(toHex(color));
});
});
@@ -0,0 +1,8 @@
class Webhooks::FacebookDeliveryJob < ApplicationJob
queue_as :low
def perform(message)
response = ::Integrations::Facebook::MessageParser.new(message)
Integrations::Facebook::DeliveryStatus.new(params: response).perform
end
end
+6
View File
@@ -7,6 +7,12 @@ class ActionCableListener < BaseListener
broadcast(account, tokens, NOTIFICATION_CREATED, { notification: notification.push_event_data, unread_count: unread_count, count: count })
end
def notification_deleted(event)
notification, account, unread_count, count = extract_notification_and_account(event)
tokens = [event.data[:notification].user.pubsub_token]
broadcast(account, tokens, NOTIFICATION_DELETED, notification: notification, unread_count: unread_count, count: count)
end
def account_cache_invalidated(event)
account = event.data[:account]
tokens = user_tokens(account, account.agents)
+6
View File
@@ -7,6 +7,7 @@
# primary_actor_type :string not null
# read_at :datetime
# secondary_actor_type :string
# snoozed_until :datetime
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
@@ -41,6 +42,7 @@ class Notification < ApplicationRecord
enum notification_type: NOTIFICATION_TYPES
after_create_commit :process_notification_delivery, :dispatch_create_event
after_destroy_commit :dispatch_destroy_event
# TODO: Get rid of default scope
# https://stackoverflow.com/a/1834250/939299
@@ -135,4 +137,8 @@ class Notification < ApplicationRecord
def dispatch_create_event
Rails.configuration.dispatcher.dispatch(NOTIFICATION_CREATED, Time.zone.now, notification: self)
end
def dispatch_destroy_event
Rails.configuration.dispatcher.dispatch(NOTIFICATION_DELETED, Time.zone.now, notification: self)
end
end
+33 -1
View File
@@ -6,7 +6,8 @@ class Line::SendOnLineService < Base::SendOnChannelService
end
def perform_reply
response = channel.client.push_message(message.conversation.contact_inbox.source_id, [{ type: 'text', text: message.content }])
response = channel.client.push_message(message.conversation.contact_inbox.source_id, build_payload)
return if response.blank?
parsed_json = JSON.parse(response.body)
@@ -20,6 +21,37 @@ class Line::SendOnLineService < Base::SendOnChannelService
end
end
def build_payload
if message.content && message.attachments.any?
[text_message, *attachments]
elsif message.content.nil? && message.attachments.any?
attachments
else
text_message
end
end
def attachments
message.attachments.map do |attachment|
# Support only image and video for now, https://developers.line.biz/en/reference/messaging-api/#image-message
next unless attachment.file_type == 'image' || attachment.file_type == 'video'
{
type: attachment.file_type,
originalContentUrl: attachment.download_url,
previewImageUrl: attachment.download_url
}
end
end
# https://developers.line.biz/en/reference/messaging-api/#text-message
def text_message
{
type: 'text',
text: message.content
}
end
# https://developers.line.biz/en/reference/messaging-api/#error-responses
def external_error(error)
# Message containing information about the error. See https://developers.line.biz/en/reference/messaging-api/#error-messages
@@ -108,7 +108,9 @@ class Twilio::IncomingMessageService
return if params[:MediaUrl0].blank?
attachment_file = Down.download(
params[:MediaUrl0]
params[:MediaUrl0],
# https://support.twilio.com/hc/en-us/articles/223183748-Protect-Media-Access-with-HTTP-Basic-Authentication-for-Programmable-Messaging
http_basic_authentication: [twilio_channel.account_sid, twilio_channel.auth_token || twilio_channel.api_key_sid]
)
attachment = @message.attachments.new(
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 846 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12"><path fill="" d="M6 1a2 2 0 1 0 0 4a2 2 0 0 0 0-4Zm2.5 5h-5A1.5 1.5 0 0 0 2 7.5c0 1.116.459 2.01 1.212 2.615C3.953 10.71 4.947 11 6 11c1.053 0 2.047-.29 2.788-.885C9.54 9.51 10 8.616 10 7.5A1.5 1.5 0 0 0 8.5 6Z"/></svg>

After

Width:  |  Height:  |  Size: 303 B

+71 -5
View File
@@ -10,6 +10,7 @@ By default, it renders:
(if provided by a `content_for` block in a nested page)
- Flashes
- Links to stylesheets and JavaScripts
- The appearance dropdown styles are added to the top to prevent FOUC
%>
<!DOCTYPE html>
@@ -21,16 +22,43 @@ By default, it renders:
<meta name="viewport" content="width=device-width, initial-scale=1" />
<%= javascript_pack_tag 'portal' %>
<%= stylesheet_pack_tag 'portal' %>
<style>
#appearance-dropdown[data-current-theme="system"] .check-mark-icon.light-theme,
#appearance-dropdown[data-current-theme="system"] .check-mark-icon.dark-theme,
#appearance-dropdown[data-current-theme="dark"] .check-mark-icon.light-theme,
#appearance-dropdown[data-current-theme="dark"] .check-mark-icon.system-theme,
#appearance-dropdown[data-current-theme="light"] .check-mark-icon.dark-theme,
#appearance-dropdown[data-current-theme="light"] .check-mark-icon.system-theme {
display: none;
}
</style>
<%= csrf_meta_tags %>
<% if content_for?(:head) %>
<%= yield(:head) %>
<% else %>
<title><%= @portal.page_title%></title>
<% end %>
<% unless @theme_from_params.blank? %>
<%# this adds the theme from params, ensuring that there a localstorage value set %>
<%# this will further trigger the next script to ensure color mode is toggled without a FOUC %>
<script>localStorage.theme = '<%= @theme_from_params %>';</script>
<% end %>
<script>
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
// we can use document.body here but that would mean pushing this script inside the body
// since the body is not created yet. This is done to avoid FOUC, at a tiny cost of Time to Interactive
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
document.documentElement.classList.add('light')
}
</script>
</head>
<body>
<div id="portal" class="antialiased <%= @theme unless @theme == 'system' %>">
<main class="main-content min-h-screen flex flex-col bg-white dark:bg-slate-900" role="main">
<div id="portal" class="antialiased">
<main class="flex flex-col min-h-screen bg-white main-content dark:bg-slate-900" role="main">
<% if !@is_plain_layout_enabled %>
<%= render "public/api/v1/portals/header", portal: @portal %>
<% end %>
@@ -42,15 +70,53 @@ By default, it renders:
</div>
</body>
<style>
:root {
color-scheme: <% if @theme == 'system' %>light dark<% else %><%= @theme %><% end %>;
html.dark {
--dynamic-portal-bg: <%= generate_portal_bg(@portal.color, 'dark') %>;
--dynamic-portal-bg-gradient: <%= generate_gradient_to_bottom('dark') %>;
--dynamic-hover-bg-color: <%= generate_portal_hover_color(@portal.color , 'dark') %>;
}
html.light {
--dynamic-portal-bg: <%= generate_portal_bg(@portal.color, 'light') %>;
--dynamic-portal-bg-gradient: <%= generate_gradient_to_bottom('light') %>;
--dynamic-hover-bg-color: <%= generate_portal_hover_color(@portal.color , 'light') %>;
}
/* Portal background */
#portal-bg {
background: var(--dynamic-portal-bg);
}
/* Portal background gradient */
#portal-bg-gradient {
background-image: var(--dynamic-portal-bg-gradient);
}
/* Category block item hover color */
#category-item:hover {
background-color: var(--dynamic-hover-bg-color);
}
/* Header section */
#header-action-button:hover,
#toggle-appearance:hover,
#toggle-theme-button:hover {
color: var(--dynamic-hover-color);
stroke: var(--dynamic-hover-color);
}
#category-block:hover {
border-color: var(--dynamic-hover-color);
}
#category-block:hover #category-name {
color: var(--dynamic-hover-color);
}
</style>
<script>
window.portalConfig = {
portalSlug: '<%= @portal.slug %>',
portalColor: '<%= @portal.color %>',
theme: '<%= @theme %>',
theme: '<%= @theme_from_params %>',
customDomain: '<%= @portal.custom_domain %>',
hostURL: '<%= ENV.fetch('FRONTEND_URL', '') %>',
localeCode: '<%= @locale %>',
searchTranslations: {
searchPlaceholder: '<%= I18n.t('public_portal.search.search_placeholder') %>',
@@ -0,0 +1,24 @@
<% author_count = category.articles.published.order(position: :asc).map(&:author).uniq.size %>
<% if author_count > 0 %>
<div class="flex flex-row items-center gap-1">
<div class="flex flex-row items-center -space-x-2">
<% category.articles.published.order(position: :asc).map(&:author).uniq.take(3).each do |author| %>
<%= render "public/api/v1/portals/thumbnail", author: author, size: 5 %>
<% end %>
</div>
<% first_author = category.articles.published.order(position: :asc).map(&:author).uniq.first.name %>
<% author_text = author_count > 1 ? "#{author_count} #{I18n.t('public_portal.common.authors')}" : "#{author_count} #{I18n.t('public_portal.common.author')}" %>
<% other_authors_count = author_count - 1 %>
<% other_authors_text = other_authors_count > 1 ? I18n.t('public_portal.common.others') : I18n.t('public_portal.common.other') %>
<span class="<%= show_expanded ? 'text-base' : 'text-sm' %> font-medium text-slate-600 dark:text-slate-400">
<% if show_expanded %>
<%= "#{I18n.t('public_portal.common.by')} #{first_author}" %>
<%= other_authors_count.positive? ? " and #{other_authors_count} #{other_authors_text}" : '' %>
<% else %>
<%= author_text %>
<% end %>
</span>
</div>
<% end %>
@@ -1,13 +1,22 @@
<section class="lg:container w-full flex flex-col h-full">
<div class="flex flex-col gap-8 h-full group <%= !@is_plain_layout_enabled ? 'border border-solid border-slate-100 dark:border-slate-800 hover:border-woot-600 dark:hover:border-woot-600 py-5 px-3 rounded-lg' : '' %>">
<div class="flex justify-between items-center w-full">
<div class="flex items-start flex-col gap-1">
<% category_link_params = {
portal_slug: portal.slug,
category_locale: category.locale,
category_slug: category.slug,
theme: @theme_from_params,
is_plain_layout_enabled: @is_plain_layout_enabled
}
%>
<section class="flex flex-col w-full h-full lg:container">
<div id="<%= !@is_plain_layout_enabled ? 'category-block' : '' %>" class="flex flex-col gap-8 h-full <%= !@is_plain_layout_enabled ? 'border border-solid border-slate-100 dark:border-slate-800 py-5 px-3 rounded-lg' : '' %>">
<div class="flex items-center justify-between w-full">
<div class="flex flex-col items-start gap-1">
<div class="flex flex-row items-center gap-2 <%= !@is_plain_layout_enabled && 'px-1' %>">
<% if category.icon.present? %>
<span class="text-lg rounded-md cursor-pointer <%= !@is_plain_layout_enabled && 'pl-1' %>"><%= category.icon %></span>
<% end %>
<h3 class="text-xl text-slate-800 dark:text-slate-50 font-semibold leading-relaxed hover:cursor-pointer <%= @is_plain_layout_enabled ? 'hover:underline' : 'group-hover:text-woot-600 dark:group-hover:text-woot-600' %> <%= category.icon.blank? && !@is_plain_layout_enabled ? 'pl-1' : '' %>">
<a href="/hc/<%= portal.slug %>/<%= category.locale %>/categories/<%= category.slug %><%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>">
<h3 id="<%= !@is_plain_layout_enabled ? 'category-name' : '' %>" class="text-xl text-slate-800 dark:text-slate-50 font-semibold leading-relaxed hover:cursor-pointer <%= @is_plain_layout_enabled ? 'hover:underline' : '' %> <%= category.icon.blank? && !@is_plain_layout_enabled ? 'pl-1' : '' %>">
<a href="<%= generate_category_link(category_link_params) %>">
<%= category.name %>
</a>
</h3>
@@ -19,15 +28,15 @@
</div>
<div class="flex flex-col gap-2 flex-grow <%= category.description.blank? && '-mt-4' %>">
<% if category.articles.published.size==0 %>
<div class="h-full flex items-center justify-center bg-slate-50 dark:bg-slate-800 rounded-xl mb-4">
<div class="flex items-center justify-center h-full mb-4 bg-slate-50 dark:bg-slate-800 rounded-xl">
<p class="text-sm text-slate-500"><%= I18n.t('public_portal.common.no_articles') %></p>
</div>
<% else %>
<% category.articles.published.order(position: :asc).take(5).each do |article| %>
<a class="text-slate-700 dark:text-slate-100 leading-7" href="<%= generate_article_link(portal.slug, article.slug, @theme) %>">
<div class="flex justify-between hover:cursor-pointer items-center py-1 rounded-lg gap-3 <%= !@is_plain_layout_enabled ? 'px-2 hover:bg-slate-50 dark:hover:bg-slate-800' : 'hover:underline' %>">
<a class="leading-7 text-slate-700 dark:text-slate-100" href="<%= generate_article_link(portal.slug, article.slug, @theme_from_params, @is_plain_layout_enabled) %>">
<div id="<%= !@is_plain_layout_enabled ? 'category-item' : '' %>" class="flex justify-between hover:cursor-pointer items-start py-1 rounded-lg gap-6 <%= !@is_plain_layout_enabled ? 'px-2' : 'hover:underline' %>">
<%= article.title %>
<span class="flex items-center font-normal">
<span class="flex items-center font-normal mt-1.5">
<%= render partial: 'icons/chevron-right' %>
</span>
</div>
@@ -36,11 +45,13 @@
<% end %>
</div>
<div class="flex justify-between flex-row items-center <%= !@is_plain_layout_enabled && 'px-2' %>">
<div>
<div class="flex flex-row items-center gap-1">
<%= render "public/api/v1/portals/authors", category: category, show_expanded: false %>
<span class="text-slate-600 dark:text-slate-400"></span>
<span class="text-sm font-medium text-slate-600 dark:text-slate-400"><%= render 'public/api/v1/portals/article_count', article_count: category.articles.published.order(position: :asc).size %></span>
</div>
<div>
<a href="/hc/<%= portal.slug %>/<%= category.locale %>/categories/<%= category.slug %><%= @theme.present? ? '?theme='+@theme : '' %>" class="flex flex-row items-center text-sm font-medium text-slate-600 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-100">
<a href="<%= generate_category_link(category_link_params) %>" class="flex flex-row items-center text-sm font-medium text-slate-600 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-100">
<%= I18n.t('public_portal.common.view_all_articles') %>
</a>
</div>
@@ -1,20 +1,20 @@
<% featured_articles = articles.where(category_id: categories).search_by_status(:published).order_by_views.limit(6) %>
<% if featured_articles.count >= 6 %>
<section class="lg:container w-full flex flex-col h-full">
<div class="flex flex-col gap-5 border border-solid border-slate-100 dark:border-slate-800 rounded-lg py-5 px-3">
<div class="flex justify-between items-center w-full">
<div class="flex items-start flex-col gap-1">
<section class="flex flex-col w-full h-full lg:container">
<div class="flex flex-col gap-5 px-3 py-5 border border-solid rounded-lg border-slate-100 dark:border-slate-800">
<div class="flex items-center justify-between w-full">
<div class="flex flex-col items-start gap-1">
<div class="flex flex-row items-center gap-2 px-2">
<h3 class="text-xl text-slate-800 dark:text-slate-50 font-semibold leading-relaxed">
<h3 class="text-xl font-semibold leading-relaxed text-slate-800 dark:text-slate-50">
<%= I18n.t('public_portal.header.featured_articles') %>
</h3>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-2 gap-y-2 gap-2">
<div class="grid grid-cols-1 gap-2 md:grid-cols-2 gap-x-2 gap-y-2">
<% featured_articles.each do |article| %>
<a class="text-slate-700 dark:text-slate-100 leading-7" href="<%= generate_article_link(portal.slug, article.slug, @theme) %>">
<div class="flex justify-between items-start py-1 px-2 rounded-lg gap-3 hover:bg-slate-50 dark:hover:bg-slate-800">
<a class="leading-7 text-slate-700 dark:text-slate-100" href="<%= generate_article_link(portal.slug, article.slug, @theme_from_params, @is_plain_layout_enabled) %>">
<div id="category-item" class="flex items-start justify-between gap-6 px-2 py-1 rounded-lg">
<%= article.title %>
<span class="flex items-center font-normal mt-1.5">
<%= render partial: 'icons/chevron-right' %>
@@ -1,9 +1,9 @@
<header class="bg-white dark:bg-slate-900 w-full shadow-sm sticky top-0 z-50">
<nav class=" flex mx-auto max-w-5xl px-4 md:px-8" aria-label="Top">
<div class="w-full py-5 flex items-center overflow-hidden">
<a href="/hc/<%= @portal.slug %>/<%= @portal.config['default_locale'] || params[:locale] %>/<%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>" class="h-10 text-lg flex items-center text-slate-900 dark:text-white font-semibold">
<header class="sticky top-0 z-50 w-full bg-white shadow-sm dark:bg-slate-900">
<nav class="flex max-w-5xl px-4 mx-auto md:px-8" aria-label="Top">
<div class="flex items-center w-full py-5 overflow-hidden">
<a href="<%= generate_home_link(@portal.slug, @portal.config['default_locale'] || params[:locale], @theme_from_params, @is_plain_layout_enabled) %>" class="flex items-center h-10 text-lg font-semibold text-slate-900 dark:text-white">
<% if @portal.logo.present? %>
<img src="<%= url_for(@portal.logo) %>" class="h-10 w-auto mr-2" />
<img src="<%= url_for(@portal.logo) %>" class="w-auto h-10 mr-2" />
<% end %>
<%= @portal.name %>
</a>
@@ -12,9 +12,9 @@
<%# Go to homepage link section %>
<div class="flex items-center justify-between gap-2 sm:gap-5">
<% if @portal.homepage_link %>
<div class="ml-8 border-l-1 border-slate-50 dark:border-slate-800 hidden md:block cursor-pointer px-1 py-2">
<div class="hidden px-1 py-2 ml-8 cursor-pointer border-l-1 border-slate-50 dark:border-slate-800 md:block">
<div class="flex-grow flex-shrink-0">
<a target="_blank" rel="noopener noreferrer nofollow" href="<%= @portal.homepage_link %>" class="flex flex-row items-center gap-1 text-sm font-medium whitespace-nowrap text-slate-800 dark:text-slate-100 hover:text-woot-500 dark:hover:text-woot-500 stroke-slate-700 dark:stroke-slate-200 hover:stroke-woot-500 dark:hover:stroke-woot-500">
<a id="header-action-button" target="_blank" rel="noopener noreferrer nofollow" href="<%= @portal.homepage_link %>" class="flex flex-row items-center gap-1 text-sm font-medium whitespace-nowrap text-slate-800 dark:text-slate-100 stroke-slate-700 dark:stroke-slate-200">
<%= render partial: 'icons/redirect' %>
<%= I18n.t('public_portal.header.go_to_homepage') %>
</a>
@@ -23,54 +23,64 @@
<% end %>
<%# Appearance toggle section %>
<div class="flex-grow flex-shrink-0 relative cursor-pointer px-1 py-2">
<button id="toggle-appearance" class="toggle-appearance flex flex-row items-center stroke-slate-700 dark:stroke-slate-200 hover:stroke-woot-500 dark:hover:stroke-woot-500 text-slate-800 dark:text-slate-100 hover:text-woot-500 dark:hover:text-woot-500 gap-1" type="button">
<%= render partial: get_theme_icon(@theme) %>
<span class="text-sm font-medium"><%= get_theme_names(@theme) %></span>
<div class="pointer-events-none flex items-center px-1">
<div class="relative flex-grow flex-shrink-0 px-1 py-2 cursor-pointer">
<button id="toggle-appearance" class="flex justify-between min-w-[76px] flex-row items-center stroke-slate-700 dark:stroke-slate-200 text-slate-800 dark:text-slate-100 gap-1" type="button">
<div data-theme="system" class="flex-row items-center gap-1 theme-button <%= @theme_from_params == 'system' ? 'flex' : 'hidden' %>">
<%= render partial: 'icons/monitor' %>
<span class="text-sm font-medium"><%= I18n.t('public_portal.header.appearance.system') %></span>
</div>
<div data-theme="light" class="flex-row items-center gap-1 theme-button <%= @theme_from_params == 'light' ? 'flex' : 'hidden' %>">
<%= render partial: 'icons/sun' %>
<span class="text-sm font-medium"><%= I18n.t('public_portal.header.appearance.light') %></span>
</div>
<div data-theme="dark" class="flex-row items-center gap-1 theme-button <%= @theme_from_params == 'dark' ? 'flex' : 'hidden' %>">
<%= render partial: 'icons/moon' %>
<span class="text-sm font-medium"><%= I18n.t('public_portal.header.appearance.dark') %></span>
</div>
<div class="flex items-center px-1 pointer-events-none">
<%= render partial: 'icons/chevron-down' %>
</div>
</button>
<%# Appearance dropdown section %>
<div id="appearance-dropdown" class="hidden absolute flex-col h-auto w-32 bg-white dark:bg-slate-900 border border-solid rounded top-9 border-slate-100 dark:border-slate-800" aria-hidden="true" data-dropdown="appearance-dropdown">
<button data-theme="system" class="flex flex-row items-center justify-between px-2 py-2 border-b border-solid border-slate-100 dark:border-slate-800 gap-1 stroke-slate-700 dark:stroke-slate-200 hover:stroke-woot-500 dark:hover:stroke-woot-500 text-slate-800 dark:text-slate-100 hover:text-woot-500 dark:hover:text-woot-500">
<div id="appearance-dropdown" data-current-theme="<%= @theme_from_params %>" class="absolute flex-col w-32 h-auto bg-white border border-solid rounded dark:bg-slate-900 top-9 right-1 border-slate-100 dark:border-slate-800" aria-hidden="true" style="display: none;" data-dropdown="appearance-dropdown">
<button id="toggle-theme-button" data-theme="system" class="flex flex-row items-center justify-between gap-1 px-2 py-2 border-b border-solid border-slate-100 dark:border-slate-800 stroke-slate-700 dark:stroke-slate-200 text-slate-800 dark:text-slate-100">
<div class="flex flex-row items-center gap-1">
<%= render partial: 'icons/monitor' %>
<span class="text-xs font-medium"><%= I18n.t('public_portal.header.appearance.system') %></span>
</div>
<% if @theme.present? && @theme == 'system' %>
<span class="check-mark-icon system-theme">
<%= render partial: 'icons/check-mark' %>
<% end %>
</span>
</button>
<button data-theme="light" class="flex flex-row items-center justify-between px-2 py-2 border-b border-solid border-slate-100 dark:border-slate-800 gap-1 stroke-slate-700 dark:stroke-slate-200 hover:stroke-woot-500 dark:hover:stroke-woot-500 text-slate-800 dark:text-slate-100 hover:text-woot-500 dark:hover:text-woot-500">
<button id="toggle-theme-button" data-theme="light" class="flex flex-row items-center justify-between gap-1 px-2 py-2 border-b border-solid border-slate-100 dark:border-slate-800 stroke-slate-700 dark:stroke-slate-200 text-slate-800 dark:text-slate-100">
<div class="flex flex-row items-center gap-1">
<%= render partial: 'icons/sun' %>
<span class="text-xs font-medium"><%= I18n.t('public_portal.header.appearance.light') %></span>
</div>
<% if @theme.present? && @theme == 'light' %>
<span class="check-mark-icon light-theme">
<%= render partial: 'icons/check-mark' %>
<% end %>
</span>
</button>
<button data-theme="dark" class="flex flex-row items-center justify-between px-2 py-2 gap-1 stroke-slate-700 dark:stroke-slate-200 hover:stroke-woot-500 dark:hover:stroke-woot-500 text-slate-800 dark:text-slate-100 hover:text-woot-500 dark:hover:text-woot-500">
<button id="toggle-theme-button" data-theme="dark" class="flex flex-row items-center justify-between gap-1 px-2 py-2 stroke-slate-700 dark:stroke-slate-200 text-slate-800 dark:text-slate-100">
<div class="flex flex-row items-center gap-1">
<%= render partial: 'icons/moon' %>
<span class="text-xs font-medium"><%= I18n.t('public_portal.header.appearance.dark') %></span>
</div>
<% if @theme.present? && @theme == 'dark' %>
<span class="check-mark-icon dark-theme">
<%= render partial: 'icons/check-mark' %>
<% end %>
</span>
</button>
</div>
</div>
<%# Locale switcher section %>
<% if @portal.config["allowed_locales"].length > 1 %>
<div class="flex items-center stroke-slate-700 dark:stroke-slate-200 hover:stroke-woot-500 dark:hover:stroke-woot-500 text-slate-800 dark:text-slate-100 hover:text-woot-500 dark:hover:text-woot-500">
<div id="header-action-button" class="flex items-center stroke-slate-700 dark:stroke-slate-200 text-slate-800 dark:text-slate-100">
<div class="flex items-center gap-1 px-1 py-2 cursor-pointer">
<%= render partial: 'icons/globe' %>
<select
data-portal-slug="<%= @portal.slug %>"
class="bg-white dark:bg-slate-900 appearance-none w-24 overflow-hidden text-ellipsis whitespace-nowrap leading-tight font-medium focus:outline-none text-sm focus:shadow-outline locale-switcher cursor-pointer"
class="w-24 overflow-hidden text-sm font-medium leading-tight bg-white appearance-none cursor-pointer dark:bg-slate-900 text-ellipsis whitespace-nowrap focus:outline-none focus:shadow-outline locale-switcher"
>
<% @portal.config["allowed_locales"].each do |locale| %>
<option <%= locale == params[:locale] ? 'selected': '' %> value="<%= locale %>"><%= "#{language_name(locale)} (#{locale})" %></option>
@@ -1,6 +1,6 @@
<% if !@is_plain_layout_enabled %>
<section id="portal-bg" class="w-full bg-woot-50 dark:bg-woot-900 shadow-inner" style="<%= generate_portal_bg(@portal.color, @theme) %>">
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6" style="<%= generate_gradient_to_bottom(@theme) %>">
<section id="portal-bg" class="w-full bg-white dark:bg-slate-900 shadow-inner">
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6 min-h-[240px] md:min-h-[260px]">
<div class="mx-auto max-w-5xl px-4 md:px-8 flex flex-col items-center sm:items-start">
<h1 class="text-2xl md:text-4xl text-slate-900 dark:text-white font-semibold leading-normal">
<%= portal.header_text %>
@@ -0,0 +1,7 @@
<% if author&.avatar_url&.present? %>
<img src="<%= url_for(author.avatar_url) %>" alt="<%= author.name %>" class="w-<%= size %> h-<%= size %> rounded-full border border-solid border-white dark:border-slate-900">
<% else %>
<div class="w-<%= size %> h-<%= size %> rounded-full [&>svg]:opacity-70 border border-solid fill-white dark:fill-slate-900 border-white dark:border-slate-900 flex justify-center items-center" style="background-color: <%= thumbnail_bg_color(author&.available_name) %>;">
<%= render partial: 'icons/user' %>
</div>
<% end %>
@@ -1,17 +1,17 @@
<section class="lg:container w-full flex flex-col h-full">
<div class="flex flex-col gap-8 h-full group <%= !@is_plain_layout_enabled ? 'border border-solid border-slate-100 dark:border-slate-800 hover:border-woot-600 dark:hover:border-woot-600 py-5 px-3 rounded-lg' : '' %>">
<section class="flex flex-col w-full h-full lg:container">
<div id="<%= !@is_plain_layout_enabled ? 'category-block' : '' %>" class="flex flex-col gap-8 h-full <%= !@is_plain_layout_enabled ? 'border border-solid border-slate-100 dark:border-slate-800 py-5 px-3 rounded-lg' : '' %>">
<div class="flex justify-between items-center w-full <%= !@is_plain_layout_enabled ? 'px-1' : '' %>">
<h3 class="text-xl text-slate-800 dark:text-slate-50 font-semibold leading-relaxed hover:cursor-pointer <%= @is_plain_layout_enabled ? 'hover:underline' : 'pl-1 group-hover:text-woot-600 dark:group-hover:text-woot-600' %>">
<h3 id="<%= !@is_plain_layout_enabled ? 'category-name' : '' %>" class="text-xl text-slate-800 dark:text-slate-50 font-semibold leading-relaxed hover:cursor-pointer <%= @is_plain_layout_enabled ? 'hover:underline' : 'pl-1' %>">
<%= I18n.t('public_portal.header.uncategorized') %>
</h3>
</div>
<div class="-mt-4">
<% portal.articles.published.where(category_id: nil).order(position: :asc).take(5).each do |article| %>
<a
class="text-slate-700 dark:text-slate-100 leading-7"
href="<%= generate_article_link(portal.slug, article.slug, @theme) %>"
class="leading-7 text-slate-700 dark:text-slate-100"
href="<%= generate_article_link(portal.slug, article.slug, @theme_from_params, @is_plain_layout_enabled) %>"
>
<div class="flex justify-between hover:cursor-pointer items-center py-1 rounded-lg gap-3 <%= !@is_plain_layout_enabled ? 'px-2 hover:bg-slate-50 dark:hover:bg-slate-800' : 'hover:underline' %>">
<div id="<%= !@is_plain_layout_enabled ? 'category-item' : '' %>" class="flex justify-between hover:cursor-pointer items-center py-1 rounded-lg gap-3 <%= !@is_plain_layout_enabled ? 'px-2' : 'hover:underline' %>">
<%= article.title %>
<span class="flex items-center font-normal">
<%= render partial: 'icons/chevron-right' %>
@@ -1,31 +1,38 @@
<div class="flex items-center flex-row mb-6 gap-px">
<% category_link_params = {
portal_slug: @portal.slug,
category_locale: @article.category.locale,
category_slug: @article.category.slug,
theme: @theme_from_params,
is_plain_layout_enabled: @is_plain_layout_enabled
}
%>
<div class="flex flex-row items-center gap-px mb-6">
<a
class="text-slate-500 dark:text-slate-200 text-sm gap-1 hover:cursor-pointer <%= @is_plain_layout_enabled && 'hover:underline' %> leading-8 font-semibold"
href="/hc/<%= @portal.slug %>/<%= @article.category&.locale %><%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>"
href="<%= generate_home_link(@portal.slug, @article.category&.locale, @theme_from_params, @is_plain_layout_enabled) %>"
>
<%= I18n.t('public_portal.common.home') %>
</a>
<span class="w-4 h-4 [&>svg]:w-3 [&>svg]:h-3 flex items-center justify-center text-xs text-slate-500 dark:text-slate-300"><%= render partial: 'icons/chevron-right' %></span>
<% if @article.category %>
<a class="text-slate-500 dark:text-slate-200 text-sm gap-1 whitespace-nowrap hover:cursor-pointer <%= @is_plain_layout_enabled && 'hover:underline' %> leading-8 font-semibold" href="/hc/<%= @portal.slug %>/<%= @article.category.locale %>/categories/<%= @article.category.slug %><%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>">
<a class="text-slate-500 dark:text-slate-200 text-sm gap-1 whitespace-nowrap hover:cursor-pointer <%= @is_plain_layout_enabled && 'hover:underline' %> leading-8 font-semibold" href="<%= generate_category_link(category_link_params) %>">
<%= @article.category&.name %>
</a>
<span class="w-4 h-4 [&>svg]:w-3 [&>svg]:h-3 flex items-center justify-center text-xs text-slate-500 dark:text-slate-300"><%= render partial: 'icons/chevron-right' %></span>
<span class="text-sm text-slate-800 dark:text-slate-100 font-semibold overflow-hidden text-ellipsis min-w-0 whitespace-nowrap"><%= @article.title %></span>
<span class="min-w-0 overflow-hidden text-sm font-semibold text-slate-800 dark:text-slate-100 text-ellipsis whitespace-nowrap"><%= @article.title %></span>
<% else %>
<span class="text-slate-700 dark:text-slate-100 leading-8 text-sm font-semibold" ><%= I18n.t('public_portal.header.uncategorized') %></span>
<span class="text-sm font-semibold leading-8 text-slate-700 dark:text-slate-100" ><%= I18n.t('public_portal.header.uncategorized') %></span>
<% end %>
</div>
<h1 class="text-3xl font-semibold leading-normal md:tracking-normal md:text-4xl text-slate-900 dark:text-white">
<%= article.title %>
</h1>
<div class="flex flex-col items-start justify-between w-full md:flex-row md:items-center pt-6">
<div class="flex flex-col items-start justify-between w-full pt-6 md:flex-row md:items-center">
<div class="flex items-start space-x-1">
<% if article.author&.avatar_url&.present? %>
<div class="pr-px mt-0.5 min-w-[20px] min-h-[20px]">
<img src="<%= article.author.avatar_url %>" alt="<%= article.author.display_name %>" class="w-5 h-5 border rounded-full">
</div>
<% end %>
<span class="flex items-center text-base text-slate-600 dark:text-slate-400 font-medium">By <%= article.author.available_name %><%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %></span>
<div class="pr-px mt-0.5 min-w-[20px] min-h-[20px]">
<%= render "public/api/v1/portals/thumbnail", author: article.author, size: 5 %>
</div>
<span class="flex items-center text-base font-medium text-slate-600 dark:text-slate-400">By <%= article.author.available_name %><%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %></span>
</div>
</div>
@@ -1,10 +1,10 @@
<div class="bg-slate-50 dark:bg-slate-800">
<div class="max-w-4xl px-6 py-16 mx-auto space-y-12 w-full">
<div class="w-full max-w-4xl px-6 py-16 mx-auto space-y-12">
<div class="space-y-4">
<div>
<a
class="text-slate-800 hover:underline leading-8"
href="/hc/<%= @portal.slug %>/<%= @category.present? ? @category.slug : '' %><%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>"
class="leading-8 text-slate-800 hover:underline"
href="<%= generate_home_link(@portal.slug, @category.present? ? @category.slug : '', @theme_from_params, @is_plain_layout_enabled) %>"
>
<%= @portal.name %> <%= I18n.t('public_portal.common.home') %>
</a>
@@ -12,13 +12,13 @@
<span>/</span>
</div>
<% @articles.each do |article| %>
<h1 class="text-4xl font-semibold md:tracking-normal leading-snug md:text-5xl text-slate-900 dark:text-white">
<h1 class="text-4xl font-semibold leading-snug md:tracking-normal md:text-5xl text-slate-900 dark:text-white">
<%= article.title %></h1>
<div class="flex flex-col items-start justify-between w-full md:flex-row md:items-center pt-2">
<div class="flex flex-col items-start justify-between w-full pt-2 md:flex-row md:items-center">
<div class="flex items-center space-x-2">
<img src="<%= article.author.avatar_url %>" alt="" class="w-12 h-812 border rounded-full">
<img src="<%= article.author.avatar_url %>" alt="" class="w-12 border rounded-full h-812">
<div>
<h5 class="text-base font-medium text-slate-900 dark:text-white mb-2"><%= article.author.name %></h5>
<h5 class="mb-2 text-base font-medium text-slate-900 dark:text-white"><%= article.author.name %></h5>
<p class="text-sm font-normal text-slate-700 dark:text-slate-100">
<%= article.author.updated_at.strftime("%B %d %Y") %></p>
</div>
@@ -28,9 +28,9 @@
</div>
</div>
</div>
<div class="max-w-4xl flex-grow w-full px-8 py-16 mx-auto space-y-12">
<div class="flex-grow w-full max-w-4xl px-8 py-16 mx-auto space-y-12">
<article class="space-y-8">
<div class="text-slate-800 dark:text-slate-50 font-sans leading-8 text-lg max-w-3xl blog-content">
<div class="max-w-3xl font-sans text-lg leading-8 text-slate-800 dark:text-slate-50 blog-content">
</div>
</article>
</div>
@@ -12,8 +12,8 @@
<% end %>
<% if !@is_plain_layout_enabled %>
<div id="portal-bg" class="bg-woot-50 dark:bg-woot-900 shadow-inner" style="<%= generate_portal_bg(@portal.color, @theme) %>">
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6" style="<%= generate_gradient_to_bottom(@theme) %>">
<div id="portal-bg" class="bg-white dark:bg-slate-900 shadow-inner">
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6">
<div class="max-w-5xl px-4 md:px-8 mx-auto flex flex-col">
<%= render "public/api/v1/portals/articles/article_header", article: @article %>
</div>
@@ -1,8 +1,17 @@
<section class="lg:container w-full py-6 px-4 flex flex-col h-full">
<div class="flex justify-between items-center w-full">
<h3 class="text-xl text-slate-900 dark:text-white font-semibold leading-relaxed hover:underline">
<a href="/hc/<%= portal.slug %>/<%= category.locale %>/categories/<%= category.slug %><%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>">
<% category_link_params = {
portal_slug: portal.slug,
category_locale: category.locale,
category_slug: category.slug,
theme: @theme_from_params,
is_plain_layout_enabled: @is_plain_layout_enabled
}
%>
<section class="flex flex-col w-full h-full px-4 py-6 lg:container">
<div class="flex items-center justify-between w-full">
<h3 class="text-xl font-semibold leading-relaxed text-slate-900 dark:text-white hover:underline">
<a href="<%= generate_category_link(category_link_params) %>">
<%= category.name %>
</a>
</h3>
@@ -10,17 +19,17 @@
<%= render 'public/api/v1/portals/article_count', article_count: category.articles.published.order(position: :asc).size %>
</span>
</div>
<div class="py-4 w-full mt-2 flex-grow">
<div class="flex-grow w-full py-4 mt-2">
<% if category.articles.published.size == 0 %>
<div class="h-full flex items-center justify-center bg-slate-50 dark:bg-slate-800 rounded-xl mb-4">
<div class="flex items-center justify-center h-full mb-4 bg-slate-50 dark:bg-slate-800 rounded-xl">
<p class="text-sm text-slate-500"><%= I18n.t('public_portal.common.no_articles') %></p>
</div>
<% else %>
<% category.articles.published.order(position: :asc).take(5).each do |article| %>
<div class="flex justify-between content-center h-8 my-1">
<div class="flex content-center justify-between h-8 my-1">
<a
class="text-slate-800 dark:text-slate-50 hover:underline leading-8"
href="/hc/<%= portal.slug %>/articles/<%= article.slug %><%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>"
class="leading-8 text-slate-800 dark:text-slate-50 hover:underline"
href="<%= generate_article_link(portal.slug, article.slug, @theme_from_params, @is_plain_layout_enabled) %>"
>
<%= article.title %>
</a>
@@ -45,8 +54,8 @@
</div>
<div>
<a
href="/hc/<%= portal.slug %>/<%= category.locale %>/categories/<%= category.slug %><%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>"
class="flex flex-row items-center text-base font-medium text-woot-600 dark:text-woot-500 hover:text-slate-900 dark:hover:text-white hover:underline mt-4"
href="<%= generate_category_link(category_link_params) %>"
class="flex flex-row items-center mt-4 text-base font-medium text-woot-600 dark:text-woot-500 hover:text-slate-900 dark:hover:text-white hover:underline"
>
<%= I18n.t('public_portal.common.view_all_articles') %>
<span class="ml-2">
@@ -2,7 +2,7 @@
<div class="flex items-center flex-row">
<a
class="text-slate-500 dark:text-slate-200 text-sm gap-1 <%= @is_plain_layout_enabled && 'hover:underline' %> hover:cursor-pointer leading-8 font-semibold"
href="/hc/<%= portal.slug %>/<%= category.locale %><%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>"
href="<%= generate_home_link(portal.slug, category.locale, @theme_from_params, @is_plain_layout_enabled) %>"
>
<%= I18n.t('public_portal.common.home') %>
</a>
@@ -14,13 +14,17 @@
<% if category.icon.present? %>
<span class="text-4xl"><%= category.icon %></span>
<% end %>
<h1 class="text-3xl font-semibold leading-[52.5px] text-slate-900 dark:text-white">
<h1 class="text-3xl font-bold tracking-wide leading-[52.5px] text-slate-900 dark:text-white">
<%= category.name %>
</h1>
<% if category.description.present? %>
<span class="font-medium text-slate-800 dark:text-slate-75 text-base leading-5"><%= category.description %></span>
<span class="font-medium text-slate-700 dark:text-slate-200 text-base leading-5"><%= category.description %></span>
<% end %>
</div>
<span class="flex items-center text-base text-slate-600 dark:text-slate-400 font-medium"><%= render 'public/api/v1/portals/article_count', article_count: category.articles.published.size %></span>
<div class="flex flex-row items-center gap-1">
<%= render "public/api/v1/portals/authors", category: category, show_expanded: true %>
<span class="text-slate-600 dark:text-slate-400"></span>
<span class="flex items-center text-base text-slate-600 dark:text-slate-400 font-medium"><%= render 'public/api/v1/portals/article_count', article_count: category.articles.published.size %></span>
</div>
</div>
</div>
@@ -6,8 +6,8 @@
<% end %>
<% if !@is_plain_layout_enabled %>
<div id="portal-bg" class="bg-woot-50 dark:bg-woot-900" style="<%= generate_portal_bg(@portal.color, @theme) %>">
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6" style="<%= generate_gradient_to_bottom(@theme) %>">
<div id="portal-bg" class="bg-white dark:bg-slate-900">
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6">
<%= render 'public/api/v1/portals/categories/category-hero', category: @category, portal: @portal %>
</div>
</div>
@@ -22,17 +22,25 @@
</div>
<% else %>
<% @category.articles.published.order(:position).each do |article| %>
<div class="<%= !@is_plain_layout_enabled ? 'group border border-solid border-slate-100 dark:border-slate-800 hover:border-woot-600 dark:hover:border-woot-600 rounded-lg' : '' %>">
<div id="<%= !@is_plain_layout_enabled ? 'category-block' : '' %>" class="<%= !@is_plain_layout_enabled ? 'border border-solid border-slate-100 dark:border-slate-800 rounded-lg' : 'group' %>">
<a
class="<%= !@is_plain_layout_enabled ? 'p-4' : 'px-0 py-1' %> text-slate-800 dark:text-slate-50 flex justify-between content-center hover:cursor-pointer"
href="/hc/<%= @portal.slug %>/articles/<%= article.slug %><%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>"
href="<%= generate_article_link(@portal.slug, article.slug, @theme_from_params, @is_plain_layout_enabled) %>"
>
<div class="flex flex-col gap-5">
<div class="flex flex-col gap-1">
<h3 class="text-lg text-slate-900 dark:text-slate-50 font-semibold <%= @is_plain_layout_enabled ? 'hover:underline' : 'group-hover:text-woot-600 dark:group-hover:text-woot-600' %>"><%= article.title %></h3>
<p class="text-base font-normal text-slate-500 dark:text-slate-200 line-clamp-1 break-all"><%= render_category_content(article.content) %></p>
<h3 id="<%= !@is_plain_layout_enabled ? 'category-name' : '' %>" class="text-lg text-slate-900 tracking-[0.28px] dark:text-slate-50 font-semibold <%= @is_plain_layout_enabled ? 'group-hover:underline' : '' %>"><%= article.title %></h3>
<p class="text-base font-normal text-slate-600 dark:text-slate-200 line-clamp-1 break-all"><%= render_category_content(article.content) %></p>
</div>
<div class="flex flex-row items-center gap-1">
<div class="flex flex-row items-center gap-1">
<% author = article.author %>
<%= render "public/api/v1/portals/thumbnail", author: author, size: 5 %>
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium flex items-center"><%= "#{I18n.t('public_portal.common.by')} #{author.name}" %></span>
</div>
<span class="text-slate-600 dark:text-slate-400"></span>
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium flex items-center"><%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %></span>
</div>
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium flex items-center"><%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %></span>
</div>
</a>
</div>
@@ -5,7 +5,7 @@
<h1 class="text-6xl text-center font-semibold text-slate-800 dark:text-slate-100 leading-relaxed"><%= I18n.t('public_portal.404.title') %></h1>
<p class="text-center text-slate-700 dark:text-slate-300 my-1"><%= I18n.t('public_portal.404.description') %></p>
<div class="text-center my-8">
<a href="/hc/<%= @portal.slug %>/<%= @portal.config['default_locale'] || params[:locale] %>/<%= @theme.present? ? '?theme='+@theme : '' %>" class="text-woot-500 font-semibold underline">
<a href="<%= generate_home_link(@portal.slug, @portal.config['default_locale'] || params[:locale], @theme_from_params, @is_plain_layout_enabled) %>" class="text-woot-500 font-semibold underline">
<%= I18n.t('public_portal.404.back_to_home') %>
</a>
</div>
+7 -8
View File
@@ -29,14 +29,13 @@ Rails.application.reloader.to_prepare do
end
Facebook::Messenger::Bot.on :delivery do |delivery|
# delivery.ids # => 'mid.1457764197618:41d102a3e1ae206a38'
# delivery.sender # => { 'id' => '1008372609250235' }
# delivery.recipient # => { 'id' => '2015573629214912' }
# delivery.at # => 2016-04-22 21:30:36 +0200
# delivery.seq # => 37
updater = Integrations::Facebook::DeliveryStatus.new(delivery)
updater.perform
Rails.logger.info "Human was online at #{delivery.at}"
Rails.logger.info "Recieved delivery status #{delivery.to_json}"
Webhooks::FacebookDeliveryJob.perform_later(delivery.to_json)
end
Facebook::Messenger::Bot.on :read do |read|
Rails.logger.info "Recieved read status #{read.to_json}"
Webhooks::FacebookDeliveryJob.perform_later(read.to_json)
end
Facebook::Messenger::Bot.on :message_echo do |message|
+5
View File
@@ -215,6 +215,11 @@ en:
view_all_articles: View all
article: article
articles: articles
author: author
authors: authors
other: other
others: others
by: By
no_articles: There are no articles here
footer:
made_with: Made with
+4 -1
View File
@@ -166,11 +166,14 @@ Rails.application.routes.draw do
end
end
resources :notifications, only: [:index, :update] do
resources :notifications, only: [:index, :update, :destroy] do
collection do
post :read_all
get :unread_count
end
member do
post :snooze
end
end
resource :notification_settings, only: [:show, :update]
@@ -0,0 +1,5 @@
class AddSnoozedUntilToNotifications < ActiveRecord::Migration[7.0]
def change
add_column :notifications, :snoozed_until, :datetime
end
end
+2 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2023_11_14_111614) do
ActiveRecord::Schema[7.0].define(version: 2023_11_29_091149) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -729,6 +729,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_11_14_111614) do
t.datetime "read_at", precision: nil
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "snoozed_until"
t.index ["account_id"], name: "index_notifications_on_account_id"
t.index ["primary_actor_type", "primary_actor_id"], name: "uniq_primary_actor_per_account_notifications"
t.index ["secondary_actor_type", "secondary_actor_id"], name: "uniq_secondary_actor_per_account_notifications"
+24 -1
View File
@@ -754,8 +754,31 @@ function upgrade_redis() {
apt install libvips -y
}
##############################################################################
# Update nodejs to v20+
# Globals:
# None
# Arguments:
# None
# Outputs:
# None
##############################################################################
function upgrade_node() {
echo "Upgrading nodejs version to v20.x"
echo "Checking Node.js version..."
# Get current Node.js version
current_version=$(node --version | cut -c 2-)
# Parse major version number
major_version=$(echo "$current_version" | cut -d. -f1)
if [ "$major_version" -ge 20 ]; then
echo "Node.js is already version $current_version (>= 20.x). Skipping Node.js upgrade."
return
fi
echo "Upgrading Node.js version to v20.x"
curl -sL https://deb.nodesource.com/setup_20.x | sudo bash -
apt install -y nodejs
}
+1 -1
View File
@@ -104,7 +104,7 @@ RUN apk update && apk add --no-cache \
&& gem install bundler
RUN if [ "$RAILS_ENV" != "production" ]; then \
apk add --no-cache nodejs yarn; \
apk add --no-cache nodejs-current yarn; \
fi
COPY --from=pre-builder /gems/ /gems/
+1 -1
View File
@@ -1,5 +1,5 @@
module Enterprise::Message
def update_message_sentiments
::Enterprise::SentimentAnalysisJob.perform_later(self) if ENV.fetch('SENTIMENT_FILE_PATH', nil)
::Enterprise::SentimentAnalysisJob.perform_later(self) if ENV.fetch('SENTIMENT_FILE_PATH', nil).present?
end
end
+1
View File
@@ -48,6 +48,7 @@ module Events::Types
# notification events
NOTIFICATION_CREATED = 'notification.created'
NOTIFICATION_DELETED = 'notification.deleted'
# agent events
AGENT_ADDED = 'agent.added'
+17 -12
View File
@@ -1,32 +1,37 @@
# frozen_string_literal: true
class Integrations::Facebook::DeliveryStatus
def initialize(params)
@params = params
end
pattr_initialize [:params!]
def perform
update_message_status
return if facebook_channel.blank?
return unless conversation
process_delivery_status if params.delivery_watermark
process_read_status if params.read_watermark
end
private
def sender_id
@params.sender['id']
def process_delivery_status
timestamp = Time.zone.at(params.delivery_watermark.to_i).to_datetime.utc
::Conversations::UpdateMessageStatusJob.perform_later(conversation.id, timestamp, :delivered)
end
def process_read_status
timestamp = Time.zone.at(params.read_watermark.to_i).to_datetime.utc
::Conversations::UpdateMessageStatusJob.perform_later(conversation.id, timestamp, :read)
end
def contact
::ContactInbox.find_by(source_id: sender_id)&.contact
::ContactInbox.find_by(source_id: params.sender_id)&.contact
end
def conversation
@conversation ||= ::Conversation.find_by(contact_id: contact.id) if contact.present?
end
def update_message_status
return unless conversation
conversation.contact_last_seen_at = @params.at
conversation.save!
def facebook_channel
@facebook_channel ||= Channel::FacebookPage.find_by(page_id: params.recipient_id)
end
end
@@ -34,6 +34,22 @@ class Integrations::Facebook::MessageParser
@messaging.dig('message', 'mid')
end
def delivery
@messaging['delivery']
end
def read
@messaging['read']
end
def read_watermark
read&.dig('watermark')
end
def delivery_watermark
delivery&.dig('watermark')
end
def echo?
@messaging.dig('message', 'is_echo')
end
+1
View File
@@ -50,6 +50,7 @@
"babel-plugin-syntax-jsx": "^6.18.0",
"babel-plugin-transform-vue-jsx": "^3.7.0",
"chart.js": "~2.9.4",
"color2k": "^2.0.2",
"company-email-validator": "^1.0.8",
"core-js": "3.11.0",
"date-fns": "2.21.1",
@@ -127,4 +127,58 @@ RSpec.describe 'Notifications API', type: :request do
end
end
end
describe 'DELETE /api/v1/accounts/{account.id}/notifications/:id' do
let(:admin) { create(:user, account: account, role: :administrator) }
let!(:notification) { create(:notification, account: account, user: admin) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
delete "/api/v1/accounts/#{account.id}/notifications/#{notification.id}"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:admin) { create(:user, account: account, role: :administrator) }
it 'deletes the notification' do
delete "/api/v1/accounts/#{account.id}/notifications/#{notification.id}",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(Notification.count).to eq(0)
end
end
end
describe 'PATCH /api/v1/accounts/{account.id}/notifications/:id/snooze' do
let(:admin) { create(:user, account: account, role: :administrator) }
let!(:notification) { create(:notification, account: account, user: admin) }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/notifications/#{notification.id}/snooze",
params: { snoozed_until: DateTime.now.utc + 1.day }
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:admin) { create(:user, account: account, role: :administrator) }
it 'updates the notification snoozed until' do
post "/api/v1/accounts/#{account.id}/notifications/#{notification.id}/snooze",
headers: admin.create_new_auth_token,
params: { snoozed_until: DateTime.now.utc + 1.day },
as: :json
expect(response).to have_http_status(:success)
expect(notification.reload.snoozed_until).not_to eq('')
end
end
end
end
@@ -10,4 +10,24 @@ FactoryBot.define do
initialize_with { attributes }
end
factory :message_deliveries, class: Hash do
messaging do
{ sender: { id: '3383290475046708' },
recipient: { id: '117172741761305' },
delivery: { watermark: '1648581633369' } }
end
initialize_with { attributes }
end
factory :message_reads, class: Hash do
messaging do
{ sender: { id: '3383290475046708' },
recipient: { id: '117172741761305' },
read: { watermark: '1648581633369' } }
end
initialize_with { attributes }
end
end
+165 -41
View File
@@ -33,71 +33,175 @@ describe PortalHelper do
describe '#generate_portal_bg' do
context 'when theme is dark' do
it 'returns the correct background with dark grid image and color mix with black' do
expected_bg = 'background: url(/assets/images/hc/hexagon-dark.svg) color-mix(in srgb, #ff0000 20%, black)'
expected_bg = 'url(/assets/images/hc/hexagon-dark.svg) color-mix(in srgb, #ff0000 20%, black)'
expect(helper.generate_portal_bg('#ff0000', 'dark')).to eq(expected_bg)
end
end
context 'when theme is not dark' do
it 'returns the correct background with light grid image and color mix with white' do
expected_bg = 'background: url(/assets/images/hc/hexagon-light.svg) color-mix(in srgb, #ff0000 20%, white)'
expected_bg = 'url(/assets/images/hc/hexagon-light.svg) color-mix(in srgb, #ff0000 20%, white)'
expect(helper.generate_portal_bg('#ff0000', 'light')).to eq(expected_bg)
end
end
context 'when provided with various colors' do
it 'adjusts the background appropriately for dark theme' do
expected_bg = 'background: url(/assets/images/hc/hexagon-dark.svg) color-mix(in srgb, #00ff00 20%, black)'
expected_bg = 'url(/assets/images/hc/hexagon-dark.svg) color-mix(in srgb, #00ff00 20%, black)'
expect(helper.generate_portal_bg('#00ff00', 'dark')).to eq(expected_bg)
end
it 'adjusts the background appropriately for light theme' do
expected_bg = 'background: url(/assets/images/hc/hexagon-light.svg) color-mix(in srgb, #0000ff 20%, white)'
expected_bg = 'url(/assets/images/hc/hexagon-light.svg) color-mix(in srgb, #0000ff 20%, white)'
expect(helper.generate_portal_bg('#0000ff', 'light')).to eq(expected_bg)
end
end
end
describe '#get_theme_names' do
it 'returns the light theme name' do
expect(helper.get_theme_names('light')).to eq(I18n.t('public_portal.header.appearance.light'))
end
it 'returns the dark theme name' do
expect(helper.get_theme_names('dark')).to eq(I18n.t('public_portal.header.appearance.dark'))
end
it 'returns the system theme name for any other value' do
expect(helper.get_theme_names('any_other_value')).to eq(I18n.t('public_portal.header.appearance.system'))
end
end
describe '#get_theme_icon' do
it 'returns the light theme icon' do
expect(helper.get_theme_icon('light')).to eq('icons/sun')
end
it 'returns the dark theme icon' do
expect(helper.get_theme_icon('dark')).to eq('icons/moon')
end
it 'returns the system theme icon for any other value' do
expect(helper.get_theme_icon('any_other_value')).to eq('icons/monitor')
end
end
describe '#generate_gradient_to_bottom' do
context 'when theme is dark' do
it 'returns the correct background gradient' do
expected_gradient = 'background-image: linear-gradient(to bottom, transparent, #151718)'
expect(helper.generate_gradient_to_bottom('dark')).to eq(expected_gradient)
it 'returns the correct gradient' do
expect(helper.generate_gradient_to_bottom('dark')).to eq(
'linear-gradient(to bottom, transparent, #151718)'
)
end
end
context 'when theme is not dark' do
it 'returns the correct background gradient' do
expected_gradient = 'background-image: linear-gradient(to bottom, transparent, white)'
expect(helper.generate_gradient_to_bottom('light')).to eq(expected_gradient)
it 'returns the correct gradient' do
expect(helper.generate_gradient_to_bottom('light')).to eq(
'linear-gradient(to bottom, transparent, white)'
)
end
end
context 'when provided with various colors' do
it 'adjusts the gradient appropriately' do
expect(helper.generate_gradient_to_bottom('dark')).to eq(
'linear-gradient(to bottom, transparent, #151718)'
)
expect(helper.generate_gradient_to_bottom('light')).to eq(
'linear-gradient(to bottom, transparent, white)'
)
end
end
end
describe '#generate_portal_hover_color' do
context 'when theme is dark' do
it 'returns the correct color mix with #1B1B1B' do
expect(helper.generate_portal_hover_color('#ff0000', 'dark')).to eq(
'color-mix(in srgb, #ff0000 5%, #1B1B1B)'
)
end
end
context 'when theme is not dark' do
it 'returns the correct color mix with #F9F9F9' do
expect(helper.generate_portal_hover_color('#ff0000', 'light')).to eq(
'color-mix(in srgb, #ff0000 5%, #F9F9F9)'
)
end
end
context 'when provided with various colors' do
it 'adjusts the color mix appropriately' do
expect(helper.generate_portal_hover_color('#00ff00', 'dark')).to eq(
'color-mix(in srgb, #00ff00 5%, #1B1B1B)'
)
expect(helper.generate_portal_hover_color('#0000ff', 'light')).to eq(
'color-mix(in srgb, #0000ff 5%, #F9F9F9)'
)
end
end
end
describe '#theme_query_string' do
context 'when theme is present and not system' do
it 'returns the correct query string' do
expect(helper.theme_query_string('dark')).to eq('?theme=dark')
end
end
context 'when theme is not present' do
it 'returns the correct query string' do
expect(helper.theme_query_string(nil)).to eq('')
end
end
context 'when theme is system' do
it 'returns the correct query string' do
expect(helper.theme_query_string('system')).to eq('')
end
end
end
describe '#generate_home_link' do
context 'when theme is not present' do
it 'returns the correct link' do
expect(helper.generate_home_link('portal_slug', 'en', nil, true)).to eq(
'/hc/portal_slug/en'
)
end
end
context 'when theme is present and plain layout is enabled' do
it 'returns the correct link' do
expect(helper.generate_home_link('portal_slug', 'en', 'dark', true)).to eq(
'/hc/portal_slug/en?theme=dark'
)
end
end
context 'when plain layout is not enabled' do
it 'returns the correct link' do
expect(helper.generate_home_link('portal_slug', 'en', 'dark', false)).to eq(
'/hc/portal_slug/en'
)
end
end
end
describe '#generate_category_link' do
context 'when theme is not present' do
it 'returns the correct link' do
expect(helper.generate_category_link(
portal_slug: 'portal_slug',
category_locale: 'en',
category_slug: 'category_slug',
theme: nil,
is_plain_layout_enabled: true
)).to eq(
'/hc/portal_slug/en/categories/category_slug'
)
end
end
context 'when theme is present and plain layout is enabled' do
it 'returns the correct link' do
expect(helper.generate_category_link(
portal_slug: 'portal_slug',
category_locale: 'en',
category_slug: 'category_slug',
theme: 'dark',
is_plain_layout_enabled: true
)).to eq(
'/hc/portal_slug/en/categories/category_slug?theme=dark'
)
end
end
context 'when plain layout is not enabled' do
it 'returns the correct link' do
expect(helper.generate_category_link(
portal_slug: 'portal_slug',
category_locale: 'en',
category_slug: 'category_slug',
theme: 'dark',
is_plain_layout_enabled: false
)).to eq(
'/hc/portal_slug/en/categories/category_slug'
)
end
end
end
@@ -105,19 +209,27 @@ describe PortalHelper do
describe '#generate_article_link' do
context 'when theme is not present' do
it 'returns the correct link' do
expect(helper.generate_article_link('portal_slug', 'article_slug', nil)).to eq(
expect(helper.generate_article_link('portal_slug', 'article_slug', nil, true)).to eq(
'/hc/portal_slug/articles/article_slug'
)
end
end
context 'when theme is present' do
context 'when theme is present and plain layout is enabled' do
it 'returns the correct link' do
expect(helper.generate_article_link('portal_slug', 'article_slug', 'dark')).to eq(
expect(helper.generate_article_link('portal_slug', 'article_slug', 'dark', true)).to eq(
'/hc/portal_slug/articles/article_slug?theme=dark'
)
end
end
context 'when plain layout is not enabled' do
it 'returns the correct link' do
expect(helper.generate_article_link('portal_slug', 'article_slug', 'dark', false)).to eq(
'/hc/portal_slug/articles/article_slug'
)
end
end
end
describe '#render_category_content' do
@@ -134,4 +246,16 @@ describe PortalHelper do
expect(helper.render_category_content(markdown_content)).to eq(plain_text_content)
end
end
describe '#thumbnail_bg_color' do
it 'returns the correct color based on username length' do
expect(helper.thumbnail_bg_color('')).to eq('#6D95BA') # Length 0, so index is 0
expect(helper.thumbnail_bg_color('Joe')).to eq('#6D95BA') # Length 3, so index is 0
expect(helper.thumbnail_bg_color('John')).to eq('#A4C3C3') # Length 4, so index is 1
expect(helper.thumbnail_bg_color('Jane james')).to eq('#A4C3C3') # Length 10, so index is 1
expect(helper.thumbnail_bg_color('Jane_123')).to eq('#E19191') # Length 8, so index is 2
expect(helper.thumbnail_bg_color('AlexanderTheGreat')).to eq('#E19191') # Length 17, so index is 2
expect(helper.thumbnail_bg_color('Reginald John Sans')).to eq('#6D95BA') # Length 18, so index is 0
end
end
end
@@ -0,0 +1,44 @@
require 'rails_helper'
RSpec.describe Webhooks::FacebookDeliveryJob do
include ActiveJob::TestHelper
let(:message) { 'test_message' }
let(:parsed_message) { instance_double(Integrations::Facebook::MessageParser) }
let(:delivery_status) { instance_double(Integrations::Facebook::DeliveryStatus) }
before do
allow(Integrations::Facebook::MessageParser).to receive(:new).with(message).and_return(parsed_message)
allow(Integrations::Facebook::DeliveryStatus).to receive(:new).with(params: parsed_message).and_return(delivery_status)
allow(delivery_status).to receive(:perform)
end
after do
clear_enqueued_jobs
end
describe '#perform_later' do
it 'enqueues the job' do
expect do
described_class.perform_later(message)
end.to have_enqueued_job(described_class).with(message).on_queue('low')
end
end
describe '#perform' do
it 'calls the MessageParser with the correct argument' do
expect(Integrations::Facebook::MessageParser).to receive(:new).with(message)
described_class.perform_now(message)
end
it 'calls the DeliveryStatus with the correct argument' do
expect(Integrations::Facebook::DeliveryStatus).to receive(:new).with(params: parsed_message)
described_class.perform_now(message)
end
it 'executes perform on the DeliveryStatus instance' do
expect(delivery_status).to receive(:perform)
described_class.perform_now(message)
end
end
end
@@ -0,0 +1,83 @@
require 'rails_helper'
describe Integrations::Facebook::DeliveryStatus do
subject(:message_builder) { described_class.new(message_deliveries, facebook_channel.inbox).perform }
before do
stub_request(:post, /graph\.facebook\.com/)
end
let!(:account) { create(:account) }
let!(:facebook_channel) { create(:channel_facebook_page, page_id: '117172741761305') }
let!(:message_delivery_object) { build(:message_deliveries).to_json }
let!(:message_deliveries) { Integrations::Facebook::MessageParser.new(message_delivery_object) }
let!(:contact) { create(:contact, account: account) }
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: facebook_channel.inbox, source_id: '3383290475046708') }
let!(:conversation) { create(:conversation, inbox: facebook_channel.inbox, contact: contact, contact_inbox: contact_inbox) }
let!(:message_read_object) { build(:message_reads).to_json }
let!(:message_reads) { Integrations::Facebook::MessageParser.new(message_read_object) }
let!(:message1) do
create(:message, content: 'facebook message', message_type: 'outgoing', inbox: facebook_channel.inbox, conversation: conversation)
end
let!(:message2) do
create(:message, content: 'facebook message', message_type: 'incoming', inbox: facebook_channel.inbox, conversation: conversation)
end
describe '#perform' do
context 'when message_deliveries callback fires' do
before do
allow(Conversations::UpdateMessageStatusJob).to receive(:perform_later)
end
it 'updates all messages if the status is delivered' do
described_class.new(params: message_deliveries).perform
expect(Conversations::UpdateMessageStatusJob).to have_received(:perform_later).with(
message1.conversation.id,
Time.zone.at(message_deliveries.delivery['watermark'].to_i).to_datetime,
:delivered
)
end
it 'does not update the message status if the message is incoming' do
described_class.new(params: message_deliveries).perform
expect(message2.reload.status).to eq('sent')
end
it 'does not update the message status if the message was created after the watermark' do
message1.update(created_at: 1.day.from_now)
message_deliveries.delivery['watermark'] = 1.day.ago.to_i
described_class.new(params: message_deliveries).perform
expect(message1.reload.status).to eq('sent')
end
end
context 'when message_reads callback fires' do
before do
allow(Conversations::UpdateMessageStatusJob).to receive(:perform_later)
end
it 'updates all messages if the status is read' do
described_class.new(params: message_reads).perform
expect(Conversations::UpdateMessageStatusJob).to have_received(:perform_later).with(
message1.conversation.id,
Time.zone.at(message_reads.read['watermark'].to_i).to_datetime,
:read
)
end
it 'does not update the message status if the message is incoming' do
described_class.new(params: message_reads).perform
expect(message2.reload.status).to eq('sent')
end
it 'does not update the message status if the message was created after the watermark' do
message1.update(created_at: 1.day.from_now)
message_reads.read['watermark'] = 1.day.ago.to_i
described_class.new(params: message_reads).perform
expect(message1.reload.status).to eq('sent')
end
end
end
end
@@ -131,6 +131,27 @@ describe ActionCableListener do
end
end
describe '#notification_deleted' do
let(:event_name) { :'notification.deleted' }
let!(:notification) { create(:notification, account: account, user: agent) }
let!(:event) { Events::Base.new(event_name, Time.zone.now, notification: notification) }
it 'sends message to account admins, inbox agents' do
expect(ActionCableBroadcastJob).to receive(:perform_later).with(
[agent.pubsub_token],
'notification.deleted',
{
account_id: notification.account_id,
notification: notification,
unread_count: 1,
count: 1
}
)
listener.notification_deleted(event)
end
end
describe '#conversation_updated' do
let(:event_name) { :'conversation.updated' }
let!(:event) { Events::Base.new(event_name, Time.zone.now, conversation: conversation, user: agent, is_private: false) }
+1 -1
View File
@@ -134,7 +134,7 @@ Hey @John, @Alisha Peter can you check this ticket?"
end
end
context 'when primary actory is deleted' do
context 'when primary actor is deleted' do
let!(:conversation) { create(:conversation) }
it 'clears notifications' do
@@ -92,5 +92,57 @@ describe Line::SendOnLineService do
expect(message.status).to eq('delivered')
end
end
context 'with message attachments' do
it 'sends the message with text and attachments' do
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
expected_url_regex = %r{rails/active_storage/disk/[a-zA-Z0-9=_\-+]+/avatar\.png}
expect(line_client).to receive(:push_message).with(
message.conversation.contact_inbox.source_id,
[
{ type: 'text', text: message.content },
{
type: 'image',
originalContentUrl: match(expected_url_regex),
previewImageUrl: match(expected_url_regex)
}
]
)
described_class.new(message: message).perform
end
it 'sends the message with attachments only' do
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
message.update!(content: nil)
expected_url_regex = %r{rails/active_storage/disk/[a-zA-Z0-9=_\-+]+/avatar\.png}
expect(line_client).to receive(:push_message).with(
message.conversation.contact_inbox.source_id,
[
{
type: 'image',
originalContentUrl: match(expected_url_regex),
previewImageUrl: match(expected_url_regex)
}
]
)
described_class.new(message: message).perform
end
it 'sends the message with text only' do
message.attachments.destroy_all
expect(line_client).to receive(:push_message).with(
message.conversation.contact_inbox.source_id,
{ type: 'text', text: message.content }
)
described_class.new(message: message).perform
end
end
end
end
+5
View File
@@ -8926,6 +8926,11 @@ color-support@^1.1.2:
resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2"
integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==
color2k@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/color2k/-/color2k-2.0.2.tgz#ac2b4aea11c822a6bcb70c768b5a289f4fffcebb"
integrity sha512-kJhwH5nAwb34tmyuqq/lgjEKzlFXn1U99NlnB6Ws4qVaERcRUYeYP1cBw6BJ4vxaWStAUEef4WMr7WjOCnBt8w==
color@^3.0.0:
version "3.1.3"
resolved "https://registry.yarnpkg.com/color/-/color-3.1.3.tgz#ca67fb4e7b97d611dcde39eceed422067d91596e"