Compare commits

...
Author SHA1 Message Date
Shivam Mishra 2c505eef43 refactor: don't call setActiveChat 2023-11-30 12:56:38 +05:30
Shivam Mishra 4429e95f2b refactor: move fetching logic to onScrollToMessage 2023-11-30 12:56:38 +05:30
Shivam Mishra 61c07472e0 Revert "feat: remove click action"
This reverts commit 39e748bd739d8849a01e51b78e5db3c440d7c803.
2023-11-30 12:56:38 +05:30
Shivam Mishra 09969af10c Revert "chore: remove force option"
This reverts commit c65361ba82d7f0fd225d0c7aa51c9c5055e7fff6.
2023-11-30 12:55:25 +05:30
Shivam Mishra f830821e0a Revert "chore: remove behavior option"
This reverts commit b09c946af35f8984aa852d8658eb4745cc5c5d00.
2023-11-30 12:55:25 +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
83 changed files with 1417 additions and 760 deletions
+1 -1
View File
@@ -75,7 +75,7 @@ gem 'jwt'
gem 'pundit' gem 'pundit'
# super admin # super admin
gem 'administrate', '>= 0.19.0' gem 'administrate', '>= 0.19.0'
gem 'administrate-field-active_storage' gem 'administrate-field-active_storage', '>= 1.0.0'
gem 'administrate-field-belongs_to_search' gem 'administrate-field-belongs_to_search'
##--- gems for pubsub service ---## ##--- gems for pubsub service ---##
+16 -16
View File
@@ -113,7 +113,7 @@ GEM
kaminari (>= 1.0) kaminari (>= 1.0)
sassc-rails (~> 2.1) sassc-rails (~> 2.1)
selectize-rails (~> 0.6) selectize-rails (~> 0.6)
administrate-field-active_storage (0.4.2) administrate-field-active_storage (1.0.0)
administrate (>= 0.2.2) administrate (>= 0.2.2)
rails (>= 7.0) rails (>= 7.0)
administrate-field-belongs_to_search (0.8.0) administrate-field-belongs_to_search (0.8.0)
@@ -183,7 +183,7 @@ GEM
activerecord (>= 5.a) activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0) database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1) database_cleaner-core (2.0.1)
date (3.3.3) date (3.3.4)
ddtrace (1.11.1) ddtrace (1.11.1)
debase-ruby_core_source (>= 0.10.16, <= 3.2.0) debase-ruby_core_source (>= 0.10.16, <= 3.2.0)
libdatadog (~> 2.0.0.1.0) libdatadog (~> 2.0.0.1.0)
@@ -256,7 +256,7 @@ GEM
fcm (1.0.8) fcm (1.0.8)
faraday (>= 1.0.0, < 3.0) faraday (>= 1.0.0, < 3.0)
googleauth (~> 1) googleauth (~> 1)
ffi (1.15.5) ffi (1.16.3)
ffi-compiler (1.0.1) ffi-compiler (1.0.1)
ffi (>= 1.0.0) ffi (>= 1.0.0)
rake rake
@@ -440,7 +440,7 @@ GEM
activesupport (>= 4) activesupport (>= 4)
railties (>= 4) railties (>= 4)
request_store (~> 1.0) request_store (~> 1.0)
loofah (2.21.4) loofah (2.22.0)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.8.1) mail (2.8.1)
@@ -472,14 +472,14 @@ GEM
activerecord (>= 5.2) activerecord (>= 5.2)
net-http-persistent (4.0.2) net-http-persistent (4.0.2)
connection_pool (~> 2.2) connection_pool (~> 2.2)
net-imap (0.3.7) net-imap (0.4.5)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
net-protocol net-protocol
net-protocol (0.2.1) net-protocol (0.2.2)
timeout timeout
net-smtp (0.3.3) net-smtp (0.4.0)
net-protocol net-protocol
netrc (0.11.0) netrc (0.11.0)
newrelic-sidekiq-metrics (1.6.2) newrelic-sidekiq-metrics (1.6.2)
@@ -487,15 +487,15 @@ GEM
sidekiq sidekiq
newrelic_rpm (9.6.0) newrelic_rpm (9.6.0)
base64 base64
nio4r (2.5.9) nio4r (2.6.0)
nokogiri (1.15.4) nokogiri (1.15.5)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.15.4-arm64-darwin) nokogiri (1.15.5-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.15.4-x86_64-darwin) nokogiri (1.15.5-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.15.4-x86_64-linux) nokogiri (1.15.5-x86_64-linux)
racc (~> 1.4) racc (~> 1.4)
numo-narray (0.9.2.1) numo-narray (0.9.2.1)
oauth (1.1.0) oauth (1.1.0)
@@ -752,7 +752,7 @@ GEM
spring-watcher-listen (2.1.0) spring-watcher-listen (2.1.0)
listen (>= 2.7, < 4.0) listen (>= 2.7, < 4.0)
spring (>= 4) spring (>= 4)
sprockets (4.2.0) sprockets (4.2.1)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
rack (>= 2.2.4, < 4) rack (>= 2.2.4, < 4)
sprockets-rails (3.4.2) sprockets-rails (3.4.2)
@@ -766,11 +766,11 @@ GEM
telephone_number (1.4.20) telephone_number (1.4.20)
test-prof (1.2.1) test-prof (1.2.1)
thor (1.3.0) thor (1.3.0)
tilt (2.2.0) tilt (2.3.0)
time_diff (0.3.0) time_diff (0.3.0)
activesupport activesupport
i18n i18n
timeout (0.4.0) timeout (0.4.1)
trailblazer-option (0.1.2) trailblazer-option (0.1.2)
twilio-ruby (5.77.0) twilio-ruby (5.77.0)
faraday (>= 0.9, < 3.0) faraday (>= 0.9, < 3.0)
@@ -841,7 +841,7 @@ DEPENDENCIES
activerecord-import activerecord-import
acts-as-taggable-on acts-as-taggable-on
administrate (>= 0.19.0) administrate (>= 0.19.0)
administrate-field-active_storage administrate-field-active_storage (>= 1.0.0)
administrate-field-belongs_to_search administrate-field-belongs_to_search
annotate annotate
attr_extras attr_extras
@@ -1,7 +1,7 @@
class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseController
RESULTS_PER_PAGE = 15 RESULTS_PER_PAGE = 15
before_action :fetch_notification, only: [:update] before_action :fetch_notification, only: [:update, :destroy]
before_action :set_primary_actor, only: [:read_all] before_action :set_primary_actor, only: [:read_all]
before_action :set_current_page, only: [:index] before_action :set_current_page, only: [:index]
@@ -28,6 +28,11 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
render json: @notification render json: @notification
end end
def destroy
@notification.destroy
head :ok
end
def unread_count def unread_count
@unread_count = current_user.notifications.where(account_id: current_account.id, read_at: nil).count @unread_count = current_user.notifications.where(account_id: current_account.id, read_at: nil).count
render json: @unread_count render json: @unread_count
@@ -11,11 +11,7 @@ class Public::Api::V1::Portals::BaseController < PublicController
end end
def set_color_scheme def set_color_scheme
@theme = if %w[dark light].include?(params[:theme]) @theme_from_params = params[:theme] if %w[dark light].include?(params[:theme])
params[:theme]
else
'system'
end
end end
def portal def portal
@@ -27,7 +27,8 @@ class SuperAdmin::Devise::SessionsController < Devise::SessionsController
true true
rescue StandardError => e rescue StandardError => e
@error_message = e.message Rails.logger.error e.message
@error_message = 'Invalid credentials. Please try again.'
false false
end end
end end
+40 -19
View File
@@ -6,7 +6,17 @@ module PortalHelper
def generate_portal_bg(portal_color, theme) def generate_portal_bg(portal_color, theme)
bg_image = theme == 'dark' ? 'hexagon-dark.svg' : 'hexagon-light.svg' 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 end
def language_name(locale) def language_name(locale)
@@ -14,35 +24,46 @@ module PortalHelper
language_map[locale] || locale language_map[locale] || locale
end end
def get_theme_names(theme) def theme_query_string(theme)
if theme == 'light' theme.present? && theme != 'system' ? "?theme=#{theme}" : ''
I18n.t('public_portal.header.appearance.light') end
elsif theme == 'dark'
I18n.t('public_portal.header.appearance.dark') 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 else
I18n.t('public_portal.header.appearance.system') "/hc/#{portal_slug}/#{portal_locale}"
end end
end end
def get_theme_icon(theme) def generate_category_link(params)
if theme == 'light' portal_slug = params[:portal_slug]
'icons/sun' category_locale = params[:category_locale]
elsif theme == 'dark' category_slug = params[:category_slug]
'icons/moon' 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 else
'icons/monitor' "/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}"
end end
end end
def generate_gradient_to_bottom(theme) def generate_article_link(portal_slug, article_slug, theme, is_plain_layout_enabled)
"background-image: linear-gradient(to bottom, transparent, #{theme == 'dark' ? '#151718' : 'white'})" if is_plain_layout_enabled
end "/hc/#{portal_slug}/articles/#{article_slug}#{theme_query_string(theme)}"
else
def generate_article_link(portal_slug, article_slug, theme) "/hc/#{portal_slug}/articles/#{article_slug}"
"/hc/#{portal_slug}/articles/#{article_slug}#{theme.present? && theme != 'system' ? "?theme=#{theme}" : ''}" end
end end
def render_category_content(content) def render_category_content(content)
ChatwootMarkdownRenderer.new(content).render_markdown_to_plain_text ChatwootMarkdownRenderer.new(content).render_markdown_to_plain_text
end end
def thumbnail_bg_color(username)
colors = ['#6D95BA', '#A4C3C3', '#E19191']
colors[username.length % colors.size]
end
end end
@@ -28,6 +28,7 @@
:message="inReplyTo" :message="inReplyTo"
:message-type="data.message_type" :message-type="data.message_type"
:parent-has-attachments="hasAttachments" :parent-has-attachments="hasAttachments"
@click="navigateToMessage"
/> />
<bubble-text <bubble-text
v-if="data.content" v-if="data.content"
@@ -519,6 +520,13 @@ export default {
this.showBackgroundHighlight = false; this.showBackgroundHighlight = false;
}, HIGHLIGHT_TIMER); }, HIGHLIGHT_TIMER);
}, },
async navigateToMessage() {
this.$nextTick(() => {
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE, {
messageId: this.inReplyToMessageId,
});
});
},
}, },
}; };
</script> </script>
@@ -377,12 +377,19 @@ export default {
removeBusListeners() { removeBusListeners() {
bus.$off(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage); bus.$off(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
}, },
onScrollToMessage({ messageId = '' } = {}) { async onScrollToMessage({ messageId }) {
// TODO: check if the message is already loaded
await this.$store.dispatch('setActiveChat', {
data: this.currentChat,
after: messageId,
force: true,
});
this.$nextTick(() => { this.$nextTick(() => {
const messageElement = document.getElementById('message' + messageId); const messageElement = document.getElementById('message' + messageId);
if (messageElement) { if (messageElement) {
this.isProgrammaticScroll = true; this.isProgrammaticScroll = true;
messageElement.scrollIntoView({ behavior: 'smooth' }); messageElement.scrollIntoView();
this.fetchPreviousMessages(); this.fetchPreviousMessages();
} else { } else {
this.scrollToBottom(); this.scrollToBottom();
@@ -29,6 +29,7 @@
v-if="shouldShowReplyToMessage" v-if="shouldShowReplyToMessage"
:message="inReplyTo" :message="inReplyTo"
@dismiss="resetReplyToMessage" @dismiss="resetReplyToMessage"
@navigate-to-message="navigateToMessage"
/> />
<canned-response <canned-response
v-if="showMentions && hasSlashCommand" v-if="showMentions && hasSlashCommand"
@@ -624,6 +625,8 @@ export default {
`${this.$t('CONVERSATION.REPLYBOX.INSERT_READ_MORE')} ${url}` `${this.$t('CONVERSATION.REPLYBOX.INSERT_READ_MORE')} ${url}`
); );
} }
this.$track(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK);
}, },
toggleRichContentEditor() { toggleRichContentEditor() {
this.updateUISettings({ this.updateUISettings({
@@ -1156,6 +1159,12 @@ export default {
LocalStorage.deleteFromJsonStore(replyStorageKey, this.conversationId); LocalStorage.deleteFromJsonStore(replyStorageKey, this.conversationId);
bus.$emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE); bus.$emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE);
}, },
navigateToMessage(messageId) {
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE, {
messageId,
behavior: 'instant',
});
},
onNewConversationModalActive(isActive) { onNewConversationModalActive(isActive) {
// Issue is if the new conversation modal is open and we drag and drop the file // 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 // then the file is not getting attached to the new conversation modal
@@ -1,6 +1,7 @@
<template> <template>
<div <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" /> <fluent-icon class="flex-shrink-0 icon" icon="arrow-reply" size="14" />
<div class="flex-grow gap-1 mt-px text-xs truncate"> <div class="flex-grow gap-1 mt-px text-xs truncate">
@@ -1,12 +1,13 @@
<template> <template>
<div <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="{ :class="{
'bg-slate-50 dark:bg-slate-600 dark:text-slate-50': 'bg-slate-50 dark:bg-slate-600 dark:text-slate-50':
messageType === MESSAGE_TYPE.INCOMING, messageType === MESSAGE_TYPE.INCOMING,
'bg-woot-600 text-woot-50': messageType === MESSAGE_TYPE.OUTGOING, 'bg-woot-600 text-woot-50': messageType === MESSAGE_TYPE.OUTGOING,
'-mx-2': !parentHasAttachments, '-mx-2': !parentHasAttachments,
}" }"
@click="$emit('click')"
> >
<message-preview <message-preview
:message="message" :message="message"
@@ -7,7 +7,11 @@
}" }"
> >
<div v-if="!isEmail" v-dompurify-html="message" class="text-content" /> <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 <button
v-if="showQuoteToggle" v-if="showQuoteToggle"
class="text-slate-300 dark:text-slate-300 cursor-pointer text-xs py-1" class="text-slate-300 dark:text-slate-300 cursor-pointer text-xs py-1"
@@ -25,7 +25,7 @@
id="file" id="file"
ref="file" ref="file"
type="file" type="file"
accept="image/png, image/jpeg, image/gif" accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
@change="handleImageUpload" @change="handleImageUpload"
/> />
<slot /> <slot />
@@ -9,6 +9,7 @@ export const CONVERSATION_EVENTS = Object.freeze({
SEARCH_CONVERSATION: 'Searched conversations', SEARCH_CONVERSATION: 'Searched conversations',
APPLY_FILTER: 'Applied filters in the conversation list', APPLY_FILTER: 'Applied filters in the conversation list',
CHANGE_PRIORITY: 'Assigned priority to a conversation', CHANGE_PRIORITY: 'Assigned priority to a conversation',
INSERT_ARTICLE_LINK: 'Inserted article into reply via article search',
}); });
export const ACCOUNT_EVENTS = Object.freeze({ export const ACCOUNT_EVENTS = Object.freeze({
@@ -1,7 +1,8 @@
/* eslint-disable storybook/default-exports */ /* eslint-disable storybook/default-exports */
import SearchView from './components/SearchView.vue';
import { frontendURL } from '../../helper/URLHelper'; import { frontendURL } from '../../helper/URLHelper';
const SearchView = () => import('./components/SearchView.vue');
export const routes = [ export const routes = [
{ {
path: frontendURL('accounts/:accountId/search'), path: frontendURL('accounts/:accountId/search'),
@@ -1,7 +1,7 @@
/* eslint arrow-body-style: 0 */ /* eslint arrow-body-style: 0 */
import ContactsView from './components/ContactsView';
import ContactManageView from './pages/ContactManageView';
import { frontendURL } from '../../../helper/URLHelper'; import { frontendURL } from '../../../helper/URLHelper';
const ContactsView = () => import('./components/ContactsView.vue');
const ContactManageView = () => import('./pages/ContactManageView.vue');
export const routes = [ export const routes = [
{ {
@@ -163,14 +163,7 @@ export default {
return; return;
} }
const { messageId } = this.$route.query; const { messageId } = this.$route.query;
this.$store bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE, { messageId });
.dispatch('setActiveChat', {
data: selectedConversation,
after: messageId,
})
.then(() => {
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE, { messageId });
});
} else { } else {
this.$store.dispatch('clearSelectedState'); this.$store.dispatch('clearSelectedState');
} }
@@ -1,6 +1,6 @@
/* eslint arrow-body-style: 0 */ /* eslint arrow-body-style: 0 */
import ConversationView from './ConversationView';
import { frontendURL } from '../../../helper/URLHelper'; import { frontendURL } from '../../../helper/URLHelper';
const ConversationView = () => import('./ConversationView');
export default { export default {
routes: [ routes: [
@@ -1,4 +1,3 @@
import AppContainer from './Dashboard';
import settings from './settings/settings.routes'; import settings from './settings/settings.routes';
import conversation from './conversation/conversation.routes'; import conversation from './conversation/conversation.routes';
import { routes as searchRoutes } from '../../modules/search/search.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 { frontendURL } from '../../helper/URLHelper';
import helpcenterRoutes from './helpcenter/helpcenter.routes'; import helpcenterRoutes from './helpcenter/helpcenter.routes';
const Suspended = () => import('./suspended/Index'); const AppContainer = () => import('./Dashboard.vue');
const Suspended = () => import('./suspended/Index.vue');
export default { export default {
routes: [ routes: [
@@ -161,7 +161,7 @@ export default {
} }
span.article-column { 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 { &.article-title {
@apply items-start flex gap-2 col-span-4 text-left; @apply items-start flex gap-2 col-span-4 text-left;
@@ -10,17 +10,17 @@
{{ $t('HELP_CENTER.TABLE.HEADERS.TITLE') }} {{ $t('HELP_CENTER.TABLE.HEADERS.TITLE') }}
</div> </div>
<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') }} {{ $t('HELP_CENTER.TABLE.HEADERS.CATEGORY') }}
</div> </div>
<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') }} {{ $t('HELP_CENTER.TABLE.HEADERS.READ_COUNT') }}
</div> </div>
<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') }} {{ $t('HELP_CENTER.TABLE.HEADERS.STATUS') }}
</div> </div>
@@ -157,16 +157,23 @@ export default {
return this.metaTags.map(item => item.name); 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() { 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.saveArticle = debounce(
() => { () => {
this.$emit('save-article', { this.$emit('save-article', {
@@ -1,7 +1,7 @@
/* eslint arrow-body-style: 0 */ /* eslint arrow-body-style: 0 */
import NotificationsView from './components/NotificationsView.vue';
import { frontendURL } from '../../../helper/URLHelper'; 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 = [ export const routes = [
{ {
@@ -1,6 +1,6 @@
import SettingsContent from '../Wrapper';
import Index from './Index.vue';
import { frontendURL } from '../../../../helper/URLHelper'; import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Index = () => import('./Index.vue');
export default { export default {
routes: [ routes: [
@@ -1,8 +1,8 @@
import SettingsContent from '../Wrapper';
const Bot = () => import('./Index.vue'); const Bot = () => import('./Index.vue');
const CsmlEditBot = () => import('./csml/Edit.vue'); const CsmlEditBot = () => import('./csml/Edit.vue');
const CsmlNewBot = () => import('./csml/New.vue'); const CsmlNewBot = () => import('./csml/New.vue');
import { frontendURL } from '../../../../helper/URLHelper'; import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
export default { export default {
routes: [ routes: [
@@ -1,6 +1,6 @@
import SettingsContent from '../Wrapper';
import AgentHome from './Index';
import { frontendURL } from '../../../../helper/URLHelper'; import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const AgentHome = () => import('./Index.vue');
export default { export default {
routes: [ routes: [
@@ -1,6 +1,6 @@
import SettingsContent from '../Wrapper';
import AttributesHome from './Index';
import { frontendURL } from '../../../../helper/URLHelper'; import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const AttributesHome = () => import('./Index.vue');
export default { export default {
routes: [ routes: [
@@ -1,7 +1,8 @@
import SettingsContent from '../Wrapper';
import AuditLogsHome from './Index';
import { frontendURL } from '../../../../helper/URLHelper'; import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const AuditLogsHome = () => import('./Index.vue');
export default { export default {
routes: [ routes: [
{ {
@@ -1,6 +1,6 @@
import SettingsContent from '../Wrapper';
import Automation from './Index';
import { frontendURL } from '../../../../helper/URLHelper'; import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Automation = () => import('./Index.vue');
export default { export default {
routes: [ routes: [
@@ -1,6 +1,6 @@
import SettingsContent from '../Wrapper';
import Index from './Index.vue';
import { frontendURL } from '../../../../helper/URLHelper'; import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Index = () => import('./Index.vue');
export default { export default {
routes: [ routes: [
@@ -1,6 +1,6 @@
import Index from './Index';
import SettingsContent from '../Wrapper';
import { frontendURL } from '../../../../helper/URLHelper'; import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Index = () => import('./Index.vue');
export default { export default {
routes: [ routes: [
@@ -20,7 +20,7 @@ export default {
path: 'ongoing', path: 'ongoing',
name: 'settings_account_campaigns', name: 'settings_account_campaigns',
roles: ['administrator'], roles: ['administrator'],
component: { ...Index }, component: Index,
}, },
], ],
}, },
@@ -36,7 +36,7 @@ export default {
path: 'one_off', path: 'one_off',
name: 'one_off', name: 'one_off',
roles: ['administrator'], roles: ['administrator'],
component: { ...Index }, component: Index,
}, },
], ],
}, },
@@ -1,7 +1,8 @@
import SettingsContent from '../Wrapper';
import CannedHome from './Index';
import { frontendURL } from '../../../../helper/URLHelper'; import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const CannedHome = () => import('./Index.vue');
export default { export default {
routes: [ routes: [
{ {
@@ -1,13 +1,14 @@
/* eslint arrow-body-style: 0 */ /* 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 { 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 { export default {
routes: [ routes: [
@@ -1,7 +1,7 @@
import Index from './Index';
import SettingsContent from '../Wrapper';
import IntegrationHooks from './IntegrationHooks';
import { frontendURL } from '../../../../helper/URLHelper'; import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const IntegrationHooks = () => import('./IntegrationHooks.vue');
const Index = () => import('./Index.vue');
export default { export default {
routes: [ 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'; 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 { export default {
routes: [ routes: [
{ {
@@ -1,7 +1,8 @@
import SettingsContent from '../Wrapper';
import Index from './Index';
import { frontendURL } from '../../../../helper/URLHelper'; import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Index = () => import('./Index.vue');
export default { export default {
routes: [ routes: [
{ {
@@ -1,6 +1,8 @@
<template> <template>
<div class="flex flex-row h-full"> <div class="flex flex-col md:flex-row h-auto md:h-full w-full">
<div class="w-[60%] macros-canvas"> <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 <macro-nodes
v-model="macro.actions" v-model="macro.actions"
:files="files" :files="files"
@@ -9,7 +11,7 @@
@resetAction="resetNode" @resetAction="resetNode"
/> />
</div> </div>
<div class="w-[34%]"> <div class="w-full md:w-1/3">
<macro-properties <macro-properties
:macro-name="macro.name" :macro-name="macro.name"
:macro-visibility="macro.visibility" :macro-visibility="macro.visibility"
@@ -138,7 +140,4 @@ export default {
background-size: 1rem 1rem; 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> </style>
@@ -18,7 +18,7 @@
> >
{{ $t('MACROS.EDITOR.VISIBILITY.LABEL') }} {{ $t('MACROS.EDITOR.VISIBILITY.LABEL') }}
</p> </p>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<button <button
class="p-2 relative rounded-md border border-solid text-left cursor-default" class="p-2 relative rounded-md border border-solid text-left cursor-default"
:class="isActive('global')" :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'; import { frontendURL } from 'dashboard/helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Macros = () => import('./Index.vue');
const MacroEditor = () => import('./MacroEditor.vue');
export default { export default {
routes: [ routes: [
{ {
@@ -22,14 +22,12 @@
:enabled-menu-options="customEditorMenuList" :enabled-menu-options="customEditorMenuList"
:enable-suggestions="false" :enable-suggestions="false"
:show-image-resize-toolbar="true" :show-image-resize-toolbar="true"
@blur="$v.messageSignature.$touch"
/> />
</div> </div>
<woot-button <woot-button
:is-loading="isUpdating" :is-loading="isUpdating"
type="button" type="button"
:is-disabled="$v.messageSignature.$invalid" @click.prevent="updateSignature"
@click.prevent="updateSignature()"
> >
{{ $t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.BTN_TEXT') }} {{ $t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.BTN_TEXT') }}
</woot-button> </woot-button>
@@ -38,7 +36,6 @@
</template> </template>
<script> <script>
import { required } from 'vuelidate/lib/validators';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue'; import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
@@ -59,11 +56,6 @@ export default {
customEditorMenuList: MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS, customEditorMenuList: MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS,
}; };
}, },
validations: {
messageSignature: {
required,
},
},
computed: { computed: {
...mapGetters({ ...mapGetters({
currentUser: 'getCurrentUser', currentUser: 'getCurrentUser',
@@ -79,15 +71,9 @@ export default {
this.messageSignature = messageSignature || ''; this.messageSignature = messageSignature || '';
}, },
async updateSignature() { async updateSignature() {
this.$v.$touch();
if (this.$v.$invalid) {
this.showAlert(this.$t('PROFILE_SETTINGS.FORM.ERROR'));
return;
}
try { try {
await this.$store.dispatch('updateProfile', { await this.$store.dispatch('updateProfile', {
message_signature: this.messageSignature, message_signature: this.messageSignature || '',
}); });
this.errorMessage = this.$t( this.errorMessage = this.$t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.API_SUCCESS' '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'; import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Index = () => import('./Index.vue');
export default { export default {
routes: [ 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'; 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 { export default {
routes: [ routes: [
{ {
@@ -1,15 +1,16 @@
/* eslint arrow-body-style: 0 */ /* 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'; 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 { export default {
routes: [ 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.SET_CURRENT_CHAT_WINDOW, data);
commit(types.CLEAR_ALL_MESSAGES_LOADED); commit(types.CLEAR_ALL_MESSAGES_LOADED);
if (data.dataFetched === undefined) { if (data.dataFetched === undefined || force) {
try { try {
await dispatch('fetchPreviousMessages', { await dispatch('fetchPreviousMessages', {
after, after,
+33 -107
View File
@@ -3,6 +3,7 @@ import Vue from 'vue';
import PublicArticleSearch from './components/PublicArticleSearch.vue'; import PublicArticleSearch from './components/PublicArticleSearch.vue';
import TableOfContents from './components/TableOfContents.vue'; import TableOfContents from './components/TableOfContents.vue';
import { initializeTheme } from './portalThemeHelper.js';
export const getHeadingsfromTheArticle = () => { export const getHeadingsfromTheArticle = () => {
const rows = []; const rows = [];
@@ -21,82 +22,42 @@ export const getHeadingsfromTheArticle = () => {
return rows; return rows;
}; };
export const generatePortalBgColor = (portalColor, theme) => { export const openExternalLinksInNewTab = () => {
const baseColor = theme === 'dark' ? 'black' : 'white'; const { customDomain, hostURL } = window.portalConfig;
return `color-mix(in srgb, ${portalColor} 20%, ${baseColor})`; const isSameHost =
}; window.location.href.includes(customDomain) ||
window.location.href.includes(hostURL);
export const generatePortalBg = (portalColor, theme) => { // Modify external links only on articles page
const bgImage = theme === 'dark' ? 'hexagon-dark.svg' : 'hexagon-light.svg'; const isOnArticlePage =
return `background: url(/assets/images/hc/${bgImage}) ${generatePortalBgColor( isSameHost && document.querySelector('#cw-article-content') !== null;
portalColor,
theme
)}`;
};
export const generateGradientToBottom = theme => { document.addEventListener('click', function (event) {
return `background-image: linear-gradient(to bottom, transparent, ${ if (!isOnArticlePage) return;
theme === 'dark' ? '#151718' : 'white'
})`;
};
export const setPortalStyles = theme => { // Some of the links come wrapped in strong tag through prosemirror
const portalColor = window.portalConfig.portalColor;
const portalBgDiv = document.querySelector('#portal-bg');
const portalBgGradientDiv = document.querySelector('#portal-bg-gradient');
if (portalBgDiv) { const isTagAnchor = event.target.tagName === 'A';
// Set background for #portal-bg const isParentTagAnchor =
portalBgDiv.setAttribute('style', generatePortalBg(portalColor, theme)); event.target.tagName === 'STRONG' &&
} event.target.parentNode.tagName === 'A';
if (portalBgGradientDiv) { if (isTagAnchor || isParentTagAnchor) {
// Set gradient background for #portal-bg-gradient const link = isTagAnchor ? event.target : event.target.parentNode;
portalBgGradientDiv.setAttribute('style', generateGradientToBottom(theme));
}
};
export const setPortalClass = theme => { const isInternalLink =
const portalDiv = document.querySelector('#portal'); link.hostname === window.location.hostname ||
portalDiv.classList.remove('light', 'dark'); link.href.includes(customDomain) ||
if (!portalDiv) return; link.href.includes(hostURL);
portalDiv.classList.add(theme);
};
export const updateThemeStyles = theme => { if (!isInternalLink) {
setPortalStyles(theme); link.target = '_blank';
setPortalClass(theme); 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 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();
}; };
export const InitializationHelpers = { export const InitializationHelpers = {
@@ -146,52 +107,17 @@ export const InitializationHelpers = {
}); });
}, },
initializeTheme: () => { initializeThemesInPortal: 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);
}
});
},
initialize: () => { initialize: () => {
openExternalLinksInNewTab();
if (window.portalConfig.isPlainLayoutEnabled === 'true') { if (window.portalConfig.isPlainLayoutEnabled === 'true') {
InitializationHelpers.appendPlainParamToURLs(); InitializationHelpers.appendPlainParamToURLs();
} else { } else {
InitializationHelpers.initializeThemesInPortal();
InitializationHelpers.navigateToLocalePage(); InitializationHelpers.navigateToLocalePage();
InitializationHelpers.initializeSearch(); InitializationHelpers.initializeSearch();
InitializationHelpers.initializeTableOfContents(); 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 { import { InitializationHelpers } from '../portalHelpers';
InitializationHelpers,
generatePortalBgColor,
generatePortalBg,
generateGradientToBottom,
setPortalStyles,
setPortalClass,
updateThemeStyles,
toggleAppearanceDropdown,
updateURLParameter,
removeURLParameter,
} from '../portalHelpers';
describe('#navigateToLocalePage', () => { describe('#navigateToLocalePage', () => {
it('returns correct cookie name', () => { 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);
});
});
});
@@ -1,3 +1,5 @@
import { toHex, mix, getLuminance, getContrast } from 'color2k';
export const isWidgetColorLighter = color => { export const isWidgetColorLighter = color => {
const colorToCheck = color.replace('#', ''); const colorToCheck = color.replace('#', '');
const c_r = parseInt(colorToCheck.substr(0, 2), 16); 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; const brightness = (c_r * 299 + c_g * 587 + c_b * 114) / 1000;
return brightness > 225; 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', () => { describe('#isWidgetColorLighter', () => {
it('returns true if color is lighter', () => { it('returns true if color is lighter', () => {
@@ -8,3 +12,56 @@ describe('#isWidgetColorLighter', () => {
expect(isWidgetColorLighter('#000000')).toEqual(false); 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
@@ -108,7 +108,9 @@ class Twilio::IncomingMessageService
return if params[:MediaUrl0].blank? return if params[:MediaUrl0].blank?
attachment_file = Down.download( 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( 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) (if provided by a `content_for` block in a nested page)
- Flashes - Flashes
- Links to stylesheets and JavaScripts - Links to stylesheets and JavaScripts
- The appearance dropdown styles are added to the top to prevent FOUC
%> %>
<!DOCTYPE html> <!DOCTYPE html>
@@ -21,16 +22,43 @@ By default, it renders:
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<%= javascript_pack_tag 'portal' %> <%= javascript_pack_tag 'portal' %>
<%= stylesheet_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 %> <%= csrf_meta_tags %>
<% if content_for?(:head) %> <% if content_for?(:head) %>
<%= yield(:head) %> <%= yield(:head) %>
<% else %> <% else %>
<title><%= @portal.page_title%></title> <title><%= @portal.page_title%></title>
<% end %> <% 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> </head>
<body> <body>
<div id="portal" class="antialiased <%= @theme unless @theme == 'system' %>"> <div id="portal" class="antialiased">
<main class="main-content min-h-screen flex flex-col bg-white dark:bg-slate-900" role="main"> <main class="flex flex-col min-h-screen bg-white main-content dark:bg-slate-900" role="main">
<% if !@is_plain_layout_enabled %> <% if !@is_plain_layout_enabled %>
<%= render "public/api/v1/portals/header", portal: @portal %> <%= render "public/api/v1/portals/header", portal: @portal %>
<% end %> <% end %>
@@ -42,15 +70,53 @@ By default, it renders:
</div> </div>
</body> </body>
<style> <style>
:root { html.dark {
color-scheme: <% if @theme == 'system' %>light dark<% else %><%= @theme %><% end %>; --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> </style>
<script> <script>
window.portalConfig = { window.portalConfig = {
portalSlug: '<%= @portal.slug %>', portalSlug: '<%= @portal.slug %>',
portalColor: '<%= @portal.color %>', portalColor: '<%= @portal.color %>',
theme: '<%= @theme %>', theme: '<%= @theme_from_params %>',
customDomain: '<%= @portal.custom_domain %>',
hostURL: '<%= ENV.fetch('FRONTEND_URL', '') %>',
localeCode: '<%= @locale %>', localeCode: '<%= @locale %>',
searchTranslations: { searchTranslations: {
searchPlaceholder: '<%= I18n.t('public_portal.search.search_placeholder') %>', 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"> <% category_link_params = {
<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' : '' %>"> portal_slug: portal.slug,
<div class="flex justify-between items-center w-full"> category_locale: category.locale,
<div class="flex items-start flex-col gap-1"> 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' %>"> <div class="flex flex-row items-center gap-2 <%= !@is_plain_layout_enabled && 'px-1' %>">
<% if category.icon.present? %> <% if category.icon.present? %>
<span class="text-lg rounded-md cursor-pointer <%= !@is_plain_layout_enabled && 'pl-1' %>"><%= category.icon %></span> <span class="text-lg rounded-md cursor-pointer <%= !@is_plain_layout_enabled && 'pl-1' %>"><%= category.icon %></span>
<% end %> <% 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' : '' %>"> <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="/hc/<%= portal.slug %>/<%= category.locale %>/categories/<%= category.slug %><%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>"> <a href="<%= generate_category_link(category_link_params) %>">
<%= category.name %> <%= category.name %>
</a> </a>
</h3> </h3>
@@ -19,15 +28,15 @@
</div> </div>
<div class="flex flex-col gap-2 flex-grow <%= category.description.blank? && '-mt-4' %>"> <div class="flex flex-col gap-2 flex-grow <%= category.description.blank? && '-mt-4' %>">
<% if category.articles.published.size==0 %> <% 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> <p class="text-sm text-slate-500"><%= I18n.t('public_portal.common.no_articles') %></p>
</div> </div>
<% else %> <% else %>
<% category.articles.published.order(position: :asc).take(5).each do |article| %> <% 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) %>"> <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 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-start py-1 rounded-lg gap-6 <%= !@is_plain_layout_enabled ? 'px-2' : 'hover:underline' %>">
<%= article.title %> <%= article.title %>
<span class="flex items-center font-normal"> <span class="flex items-center font-normal mt-1.5">
<%= render partial: 'icons/chevron-right' %> <%= render partial: 'icons/chevron-right' %>
</span> </span>
</div> </div>
@@ -36,11 +45,13 @@
<% end %> <% end %>
</div> </div>
<div class="flex justify-between flex-row items-center <%= !@is_plain_layout_enabled && 'px-2' %>"> <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> <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>
<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') %> <%= I18n.t('public_portal.common.view_all_articles') %>
</a> </a>
</div> </div>
@@ -1,20 +1,20 @@
<% featured_articles = articles.where(category_id: categories).search_by_status(:published).order_by_views.limit(6) %> <% featured_articles = articles.where(category_id: categories).search_by_status(:published).order_by_views.limit(6) %>
<% if featured_articles.count >= 6 %> <% if featured_articles.count >= 6 %>
<section class="lg:container w-full flex flex-col h-full"> <section class="flex flex-col w-full h-full lg:container">
<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 flex-col gap-5 px-3 py-5 border border-solid rounded-lg border-slate-100 dark:border-slate-800">
<div class="flex justify-between items-center w-full"> <div class="flex items-center justify-between w-full">
<div class="flex items-start flex-col gap-1"> <div class="flex flex-col items-start gap-1">
<div class="flex flex-row items-center gap-2 px-2"> <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') %> <%= I18n.t('public_portal.header.featured_articles') %>
</h3> </h3>
</div> </div>
</div> </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| %> <% 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) %>"> <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 class="flex justify-between items-start py-1 px-2 rounded-lg gap-3 hover:bg-slate-50 dark:hover:bg-slate-800"> <div id="category-item" class="flex items-start justify-between gap-6 px-2 py-1 rounded-lg">
<%= article.title %> <%= article.title %>
<span class="flex items-center font-normal mt-1.5"> <span class="flex items-center font-normal mt-1.5">
<%= render partial: 'icons/chevron-right' %> <%= 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"> <header class="sticky top-0 z-50 w-full bg-white shadow-sm dark:bg-slate-900">
<nav class=" flex mx-auto max-w-5xl px-4 md:px-8" aria-label="Top"> <nav class="flex max-w-5xl px-4 mx-auto md:px-8" aria-label="Top">
<div class="w-full py-5 flex items-center overflow-hidden"> <div class="flex items-center w-full py-5 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"> <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? %> <% 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 %> <% end %>
<%= @portal.name %> <%= @portal.name %>
</a> </a>
@@ -12,9 +12,9 @@
<%# Go to homepage link section %> <%# Go to homepage link section %>
<div class="flex items-center justify-between gap-2 sm:gap-5"> <div class="flex items-center justify-between gap-2 sm:gap-5">
<% if @portal.homepage_link %> <% 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"> <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' %> <%= render partial: 'icons/redirect' %>
<%= I18n.t('public_portal.header.go_to_homepage') %> <%= I18n.t('public_portal.header.go_to_homepage') %>
</a> </a>
@@ -23,54 +23,64 @@
<% end %> <% end %>
<%# Appearance toggle section %> <%# Appearance toggle section %>
<div class="flex-grow flex-shrink-0 relative cursor-pointer px-1 py-2"> <div class="relative flex-grow flex-shrink-0 px-1 py-2 cursor-pointer">
<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"> <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">
<%= render partial: get_theme_icon(@theme) %> <div data-theme="system" class="flex-row items-center gap-1 theme-button <%= @theme_from_params == 'system' ? 'flex' : 'hidden' %>">
<span class="text-sm font-medium"><%= get_theme_names(@theme) %></span> <%= render partial: 'icons/monitor' %>
<div class="pointer-events-none flex items-center px-1"> <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' %> <%= render partial: 'icons/chevron-down' %>
</div> </div>
</button> </button>
<%# Appearance dropdown section %> <%# 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"> <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 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"> <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"> <div class="flex flex-row items-center gap-1">
<%= render partial: 'icons/monitor' %> <%= render partial: 'icons/monitor' %>
<span class="text-xs font-medium"><%= I18n.t('public_portal.header.appearance.system') %></span> <span class="text-xs font-medium"><%= I18n.t('public_portal.header.appearance.system') %></span>
</div> </div>
<% if @theme.present? && @theme == 'system' %> <span class="check-mark-icon system-theme">
<%= render partial: 'icons/check-mark' %> <%= render partial: 'icons/check-mark' %>
<% end %> </span>
</button> </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"> <div class="flex flex-row items-center gap-1">
<%= render partial: 'icons/sun' %> <%= render partial: 'icons/sun' %>
<span class="text-xs font-medium"><%= I18n.t('public_portal.header.appearance.light') %></span> <span class="text-xs font-medium"><%= I18n.t('public_portal.header.appearance.light') %></span>
</div> </div>
<% if @theme.present? && @theme == 'light' %> <span class="check-mark-icon light-theme">
<%= render partial: 'icons/check-mark' %> <%= render partial: 'icons/check-mark' %>
<% end %> </span>
</button> </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"> <div class="flex flex-row items-center gap-1">
<%= render partial: 'icons/moon' %> <%= render partial: 'icons/moon' %>
<span class="text-xs font-medium"><%= I18n.t('public_portal.header.appearance.dark') %></span> <span class="text-xs font-medium"><%= I18n.t('public_portal.header.appearance.dark') %></span>
</div> </div>
<% if @theme.present? && @theme == 'dark' %> <span class="check-mark-icon dark-theme">
<%= render partial: 'icons/check-mark' %> <%= render partial: 'icons/check-mark' %>
<% end %> </span>
</button> </button>
</div> </div>
</div> </div>
<%# Locale switcher section %> <%# Locale switcher section %>
<% if @portal.config["allowed_locales"].length > 1 %> <% 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"> <div class="flex items-center gap-1 px-1 py-2 cursor-pointer">
<%= render partial: 'icons/globe' %> <%= render partial: 'icons/globe' %>
<select <select
data-portal-slug="<%= @portal.slug %>" 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| %> <% @portal.config["allowed_locales"].each do |locale| %>
<option <%= locale == params[:locale] ? 'selected': '' %> value="<%= locale %>"><%= "#{language_name(locale)} (#{locale})" %></option> <option <%= locale == params[:locale] ? 'selected': '' %> value="<%= locale %>"><%= "#{language_name(locale)} (#{locale})" %></option>
@@ -1,6 +1,6 @@
<% if !@is_plain_layout_enabled %> <% 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) %>"> <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" style="<%= generate_gradient_to_bottom(@theme) %>"> <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"> <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"> <h1 class="text-2xl md:text-4xl text-slate-900 dark:text-white font-semibold leading-normal">
<%= portal.header_text %> <%= 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"> <section class="flex flex-col w-full h-full lg:container">
<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 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' : '' %>"> <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') %> <%= I18n.t('public_portal.header.uncategorized') %>
</h3> </h3>
</div> </div>
<div class="-mt-4"> <div class="-mt-4">
<% portal.articles.published.where(category_id: nil).order(position: :asc).take(5).each do |article| %> <% portal.articles.published.where(category_id: nil).order(position: :asc).take(5).each do |article| %>
<a <a
class="text-slate-700 dark:text-slate-100 leading-7" class="leading-7 text-slate-700 dark:text-slate-100"
href="<%= generate_article_link(portal.slug, article.slug, @theme) %>" 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 %> <%= article.title %>
<span class="flex items-center font-normal"> <span class="flex items-center font-normal">
<%= render partial: 'icons/chevron-right' %> <%= 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 <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" 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') %> <%= I18n.t('public_portal.common.home') %>
</a> </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="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 %> <% 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 %> <%= @article.category&.name %>
</a> </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="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 %> <% 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 %> <% end %>
</div> </div>
<h1 class="text-3xl font-semibold leading-normal md:tracking-normal md:text-4xl text-slate-900 dark:text-white"> <h1 class="text-3xl font-semibold leading-normal md:tracking-normal md:text-4xl text-slate-900 dark:text-white">
<%= article.title %> <%= article.title %>
</h1> </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"> <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]">
<div class="pr-px mt-0.5 min-w-[20px] min-h-[20px]"> <%= render "public/api/v1/portals/thumbnail", author: article.author, size: 5 %>
<img src="<%= article.author.avatar_url %>" alt="<%= article.author.display_name %>" class="w-5 h-5 border rounded-full"> </div>
</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>
<% 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> </div>
</div> </div>
@@ -1,10 +1,10 @@
<div class="bg-slate-50 dark:bg-slate-800"> <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 class="space-y-4">
<div> <div>
<a <a
class="text-slate-800 hover:underline leading-8" class="leading-8 text-slate-800 hover:underline"
href="/hc/<%= @portal.slug %>/<%= @category.present? ? @category.slug : '' %><%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>" 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') %> <%= @portal.name %> <%= I18n.t('public_portal.common.home') %>
</a> </a>
@@ -12,13 +12,13 @@
<span>/</span> <span>/</span>
</div> </div>
<% @articles.each do |article| %> <% @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> <%= 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"> <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> <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"> <p class="text-sm font-normal text-slate-700 dark:text-slate-100">
<%= article.author.updated_at.strftime("%B %d %Y") %></p> <%= article.author.updated_at.strftime("%B %d %Y") %></p>
</div> </div>
@@ -28,9 +28,9 @@
</div> </div>
</div> </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"> <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> </div>
</article> </article>
</div> </div>
@@ -12,8 +12,8 @@
<% end %> <% end %>
<% if !@is_plain_layout_enabled %> <% 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" 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" style="<%= generate_gradient_to_bottom(@theme) %>"> <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"> <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 %> <%= render "public/api/v1/portals/articles/article_header", article: @article %>
</div> </div>
@@ -1,8 +1,17 @@
<section class="lg:container w-full py-6 px-4 flex flex-col h-full"> <% category_link_params = {
<div class="flex justify-between items-center w-full"> portal_slug: portal.slug,
<h3 class="text-xl text-slate-900 dark:text-white font-semibold leading-relaxed hover:underline"> category_locale: category.locale,
<a href="/hc/<%= portal.slug %>/<%= category.locale %>/categories/<%= category.slug %><%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>"> 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 %> <%= category.name %>
</a> </a>
</h3> </h3>
@@ -10,17 +19,17 @@
<%= render 'public/api/v1/portals/article_count', article_count: category.articles.published.order(position: :asc).size %> <%= render 'public/api/v1/portals/article_count', article_count: category.articles.published.order(position: :asc).size %>
</span> </span>
</div> </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 %> <% 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> <p class="text-sm text-slate-500"><%= I18n.t('public_portal.common.no_articles') %></p>
</div> </div>
<% else %> <% else %>
<% category.articles.published.order(position: :asc).take(5).each do |article| %> <% 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 <a
class="text-slate-800 dark:text-slate-50 hover:underline leading-8" class="leading-8 text-slate-800 dark:text-slate-50 hover:underline"
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) %>"
> >
<%= article.title %> <%= article.title %>
</a> </a>
@@ -45,8 +54,8 @@
</div> </div>
<div> <div>
<a <a
href="/hc/<%= portal.slug %>/<%= category.locale %>/categories/<%= category.slug %><%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>" href="<%= generate_category_link(category_link_params) %>"
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" 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') %> <%= I18n.t('public_portal.common.view_all_articles') %>
<span class="ml-2"> <span class="ml-2">
@@ -2,7 +2,7 @@
<div class="flex items-center flex-row"> <div class="flex items-center flex-row">
<a <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" 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') %> <%= I18n.t('public_portal.common.home') %>
</a> </a>
@@ -14,13 +14,17 @@
<% if category.icon.present? %> <% if category.icon.present? %>
<span class="text-4xl"><%= category.icon %></span> <span class="text-4xl"><%= category.icon %></span>
<% end %> <% 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 %> <%= category.name %>
</h1> </h1>
<% if category.description.present? %> <% 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 %> <% end %>
</div> </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>
</div> </div>
@@ -6,8 +6,8 @@
<% end %> <% end %>
<% if !@is_plain_layout_enabled %> <% 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" class="bg-white dark:bg-slate-900">
<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-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6">
<%= render 'public/api/v1/portals/categories/category-hero', category: @category, portal: @portal %> <%= render 'public/api/v1/portals/categories/category-hero', category: @category, portal: @portal %>
</div> </div>
</div> </div>
@@ -22,17 +22,25 @@
</div> </div>
<% else %> <% else %>
<% @category.articles.published.order(:position).each do |article| %> <% @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 <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" 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-5">
<div class="flex flex-col gap-1"> <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> <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-500 dark:text-slate-200 line-clamp-1 break-all"><%= render_category_content(article.content) %></p> <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> </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> </div>
</a> </a>
</div> </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> <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> <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"> <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') %> <%= I18n.t('public_portal.404.back_to_home') %>
</a> </a>
</div> </div>
+7 -8
View File
@@ -29,14 +29,13 @@ Rails.application.reloader.to_prepare do
end end
Facebook::Messenger::Bot.on :delivery do |delivery| Facebook::Messenger::Bot.on :delivery do |delivery|
# delivery.ids # => 'mid.1457764197618:41d102a3e1ae206a38' Rails.logger.info "Recieved delivery status #{delivery.to_json}"
# delivery.sender # => { 'id' => '1008372609250235' } Webhooks::FacebookDeliveryJob.perform_later(delivery.to_json)
# delivery.recipient # => { 'id' => '2015573629214912' } end
# delivery.at # => 2016-04-22 21:30:36 +0200
# delivery.seq # => 37 Facebook::Messenger::Bot.on :read do |read|
updater = Integrations::Facebook::DeliveryStatus.new(delivery) Rails.logger.info "Recieved read status #{read.to_json}"
updater.perform Webhooks::FacebookDeliveryJob.perform_later(read.to_json)
Rails.logger.info "Human was online at #{delivery.at}"
end end
Facebook::Messenger::Bot.on :message_echo do |message| Facebook::Messenger::Bot.on :message_echo do |message|
+5
View File
@@ -215,6 +215,11 @@ en:
view_all_articles: View all view_all_articles: View all
article: article article: article
articles: articles articles: articles
author: author
authors: authors
other: other
others: others
by: By
no_articles: There are no articles here no_articles: There are no articles here
footer: footer:
made_with: Made with made_with: Made with
+1 -1
View File
@@ -166,7 +166,7 @@ Rails.application.routes.draw do
end end
end end
resources :notifications, only: [:index, :update] do resources :notifications, only: [:index, :update, :destroy] do
collection do collection do
post :read_all post :read_all
get :unread_count get :unread_count
+24 -1
View File
@@ -754,8 +754,31 @@ function upgrade_redis() {
apt install libvips -y apt install libvips -y
} }
##############################################################################
# Update nodejs to v20+
# Globals:
# None
# Arguments:
# None
# Outputs:
# None
##############################################################################
function upgrade_node() { 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 - curl -sL https://deb.nodesource.com/setup_20.x | sudo bash -
apt install -y nodejs apt install -y nodejs
} }
+1 -1
View File
@@ -1,5 +1,5 @@
module Enterprise::Message module Enterprise::Message
def update_message_sentiments 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
end end
+17 -12
View File
@@ -1,32 +1,37 @@
# frozen_string_literal: true # frozen_string_literal: true
class Integrations::Facebook::DeliveryStatus class Integrations::Facebook::DeliveryStatus
def initialize(params) pattr_initialize [:params!]
@params = params
end
def perform 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 end
private private
def sender_id def process_delivery_status
@params.sender['id'] 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 end
def contact def contact
::ContactInbox.find_by(source_id: sender_id)&.contact ::ContactInbox.find_by(source_id: params.sender_id)&.contact
end end
def conversation def conversation
@conversation ||= ::Conversation.find_by(contact_id: contact.id) if contact.present? @conversation ||= ::Conversation.find_by(contact_id: contact.id) if contact.present?
end end
def update_message_status def facebook_channel
return unless conversation @facebook_channel ||= Channel::FacebookPage.find_by(page_id: params.recipient_id)
conversation.contact_last_seen_at = @params.at
conversation.save!
end end
end end
@@ -34,6 +34,22 @@ class Integrations::Facebook::MessageParser
@messaging.dig('message', 'mid') @messaging.dig('message', 'mid')
end 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? def echo?
@messaging.dig('message', 'is_echo') @messaging.dig('message', 'is_echo')
end end
+1
View File
@@ -50,6 +50,7 @@
"babel-plugin-syntax-jsx": "^6.18.0", "babel-plugin-syntax-jsx": "^6.18.0",
"babel-plugin-transform-vue-jsx": "^3.7.0", "babel-plugin-transform-vue-jsx": "^3.7.0",
"chart.js": "~2.9.4", "chart.js": "~2.9.4",
"color2k": "^2.0.2",
"company-email-validator": "^1.0.8", "company-email-validator": "^1.0.8",
"core-js": "3.11.0", "core-js": "3.11.0",
"date-fns": "2.21.1", "date-fns": "2.21.1",
@@ -127,4 +127,30 @@ RSpec.describe 'Notifications API', type: :request do
end end
end 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
end end
@@ -10,4 +10,24 @@ FactoryBot.define do
initialize_with { attributes } initialize_with { attributes }
end 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 end
+165 -41
View File
@@ -33,71 +33,175 @@ describe PortalHelper do
describe '#generate_portal_bg' do describe '#generate_portal_bg' do
context 'when theme is dark' do context 'when theme is dark' do
it 'returns the correct background with dark grid image and color mix with black' 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) expect(helper.generate_portal_bg('#ff0000', 'dark')).to eq(expected_bg)
end end
end end
context 'when theme is not dark' do context 'when theme is not dark' do
it 'returns the correct background with light grid image and color mix with white' 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) expect(helper.generate_portal_bg('#ff0000', 'light')).to eq(expected_bg)
end end
end end
context 'when provided with various colors' do context 'when provided with various colors' do
it 'adjusts the background appropriately for dark theme' 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) expect(helper.generate_portal_bg('#00ff00', 'dark')).to eq(expected_bg)
end end
it 'adjusts the background appropriately for light theme' do 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) expect(helper.generate_portal_bg('#0000ff', 'light')).to eq(expected_bg)
end end
end 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 describe '#generate_gradient_to_bottom' do
context 'when theme is dark' do context 'when theme is dark' do
it 'returns the correct background gradient' do it 'returns the correct gradient' do
expected_gradient = 'background-image: linear-gradient(to bottom, transparent, #151718)' expect(helper.generate_gradient_to_bottom('dark')).to eq(
expect(helper.generate_gradient_to_bottom('dark')).to eq(expected_gradient) 'linear-gradient(to bottom, transparent, #151718)'
)
end end
end end
context 'when theme is not dark' do context 'when theme is not dark' do
it 'returns the correct background gradient' do it 'returns the correct gradient' do
expected_gradient = 'background-image: linear-gradient(to bottom, transparent, white)' expect(helper.generate_gradient_to_bottom('light')).to eq(
expect(helper.generate_gradient_to_bottom('light')).to eq(expected_gradient) '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 end
end end
@@ -105,19 +209,27 @@ describe PortalHelper do
describe '#generate_article_link' do describe '#generate_article_link' do
context 'when theme is not present' do context 'when theme is not present' do
it 'returns the correct link' 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' '/hc/portal_slug/articles/article_slug'
) )
end end
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 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' '/hc/portal_slug/articles/article_slug?theme=dark'
) )
end end
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 end
describe '#render_category_content' do describe '#render_category_content' do
@@ -134,4 +246,16 @@ describe PortalHelper do
expect(helper.render_category_content(markdown_content)).to eq(plain_text_content) expect(helper.render_category_content(markdown_content)).to eq(plain_text_content)
end end
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 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
+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" resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2"
integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== 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: color@^3.0.0:
version "3.1.3" version "3.1.3"
resolved "https://registry.yarnpkg.com/color/-/color-3.1.3.tgz#ca67fb4e7b97d611dcde39eceed422067d91596e" resolved "https://registry.yarnpkg.com/color/-/color-3.1.3.tgz#ca67fb4e7b97d611dcde39eceed422067d91596e"