Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b1e822d21 | ||
|
|
449503bb94 | ||
|
|
aad18e1ca4 | ||
|
|
fa97ac0691 | ||
|
|
1a1f8c52b2 | ||
|
|
fdc1123b18 | ||
|
|
0974b1e705 | ||
|
|
97301c3080 | ||
|
|
8a7a5ca8d7 | ||
|
|
04160c04cc | ||
|
|
29292fd99d | ||
|
|
baf1323ebd | ||
|
|
9cc6275c82 | ||
|
|
20dac9d77b | ||
|
|
a6c28c94d4 | ||
|
|
bc6e3e598f | ||
|
|
683f9d9a28 | ||
|
|
f9bf0883c6 | ||
|
|
ad8ba299c9 | ||
|
|
3ae64822e1 | ||
|
|
a49aaee9cf | ||
|
|
2d1f70eb79 | ||
|
|
4fc5f765de | ||
|
|
7efc2726d9 | ||
|
|
29a778697c | ||
|
|
90649e72bb | ||
|
|
1904ec7df4 | ||
|
|
96add30331 | ||
|
|
9c7148e2ad | ||
|
|
31c709be5c | ||
|
|
e750ee6d28 | ||
|
|
5b3f9ac1cd | ||
|
|
6c8dacfa0d | ||
|
|
feead30b0b | ||
|
|
4244945e7b | ||
|
|
17725e4dd0 | ||
|
|
d55bb63aa0 | ||
|
|
6e22695b85 | ||
|
|
146e46d79f | ||
|
|
0af27a2387 | ||
|
|
7380f0e7ce | ||
|
|
2c5a0abcce | ||
|
|
f23e33c1a9 | ||
|
|
5588d6e344 | ||
|
|
59ace66c6f | ||
|
|
decef1ad52 | ||
|
|
0e19a4196b | ||
|
|
04dd65687b | ||
|
|
5f503b1a57 | ||
|
|
5f6e974531 | ||
|
|
a023eabf59 | ||
|
|
cb1e25a487 | ||
|
|
bcf7c6c45a | ||
|
|
b411b6ca21 |
@@ -75,7 +75,7 @@ gem 'jwt'
|
||||
gem 'pundit'
|
||||
# super admin
|
||||
gem 'administrate', '>= 0.19.0'
|
||||
gem 'administrate-field-active_storage'
|
||||
gem 'administrate-field-active_storage', '>= 1.0.0'
|
||||
gem 'administrate-field-belongs_to_search'
|
||||
|
||||
##--- gems for pubsub service ---##
|
||||
@@ -109,9 +109,9 @@ gem 'elastic-apm', require: false
|
||||
gem 'newrelic_rpm', require: false
|
||||
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
|
||||
gem 'scout_apm', require: false
|
||||
gem 'sentry-rails', '>= 5.12.0', require: false
|
||||
gem 'sentry-rails', '>= 5.13.0', require: false
|
||||
gem 'sentry-ruby', require: false
|
||||
gem 'sentry-sidekiq', '>= 5.12.0', require: false
|
||||
gem 'sentry-sidekiq', '>= 5.13.0', require: false
|
||||
|
||||
##-- background job processing --##
|
||||
gem 'sidekiq', '>= 7.1.3'
|
||||
@@ -200,6 +200,8 @@ group :development do
|
||||
# profiling
|
||||
gem 'rack-mini-profiler', '>= 3.1.1', require: false
|
||||
gem 'stackprof'
|
||||
# Should install the associated chrome extension to view query logs
|
||||
gem 'meta_request'
|
||||
end
|
||||
|
||||
group :test do
|
||||
|
||||
+33
-27
@@ -113,7 +113,7 @@ GEM
|
||||
kaminari (>= 1.0)
|
||||
sassc-rails (~> 2.1)
|
||||
selectize-rails (~> 0.6)
|
||||
administrate-field-active_storage (0.4.2)
|
||||
administrate-field-active_storage (1.0.0)
|
||||
administrate (>= 0.2.2)
|
||||
rails (>= 7.0)
|
||||
administrate-field-belongs_to_search (0.8.0)
|
||||
@@ -183,7 +183,7 @@ GEM
|
||||
activerecord (>= 5.a)
|
||||
database_cleaner-core (~> 2.0.0)
|
||||
database_cleaner-core (2.0.1)
|
||||
date (3.3.3)
|
||||
date (3.3.4)
|
||||
ddtrace (1.11.1)
|
||||
debase-ruby_core_source (>= 0.10.16, <= 3.2.0)
|
||||
libdatadog (~> 2.0.0.1.0)
|
||||
@@ -256,7 +256,7 @@ GEM
|
||||
fcm (1.0.8)
|
||||
faraday (>= 1.0.0, < 3.0)
|
||||
googleauth (~> 1)
|
||||
ffi (1.15.5)
|
||||
ffi (1.16.3)
|
||||
ffi-compiler (1.0.1)
|
||||
ffi (>= 1.0.0)
|
||||
rake
|
||||
@@ -440,7 +440,7 @@ GEM
|
||||
activesupport (>= 4)
|
||||
railties (>= 4)
|
||||
request_store (~> 1.0)
|
||||
loofah (2.21.4)
|
||||
loofah (2.22.0)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.8.1)
|
||||
@@ -451,13 +451,16 @@ GEM
|
||||
marcel (1.0.2)
|
||||
maxminddb (0.1.22)
|
||||
memoist (0.16.2)
|
||||
meta_request (0.7.4)
|
||||
rack-contrib (>= 1.1, < 3)
|
||||
railties (>= 3.0.0, < 7.1)
|
||||
method_source (1.0.0)
|
||||
mime-types (3.4.1)
|
||||
mime-types-data (~> 3.2015)
|
||||
mime-types-data (3.2023.0218.1)
|
||||
mini_magick (4.12.0)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.4)
|
||||
mini_portile2 (2.8.5)
|
||||
minitest (5.20.0)
|
||||
mock_redis (0.36.0)
|
||||
ruby2_keywords
|
||||
@@ -469,14 +472,14 @@ GEM
|
||||
activerecord (>= 5.2)
|
||||
net-http-persistent (4.0.2)
|
||||
connection_pool (~> 2.2)
|
||||
net-imap (0.3.7)
|
||||
net-imap (0.4.5)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
net-protocol
|
||||
net-protocol (0.2.1)
|
||||
net-protocol (0.2.2)
|
||||
timeout
|
||||
net-smtp (0.3.3)
|
||||
net-smtp (0.4.0)
|
||||
net-protocol
|
||||
netrc (0.11.0)
|
||||
newrelic-sidekiq-metrics (1.6.2)
|
||||
@@ -484,15 +487,15 @@ GEM
|
||||
sidekiq
|
||||
newrelic_rpm (9.6.0)
|
||||
base64
|
||||
nio4r (2.5.9)
|
||||
nokogiri (1.15.4)
|
||||
nio4r (2.6.0)
|
||||
nokogiri (1.15.5)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.15.4-arm64-darwin)
|
||||
nokogiri (1.15.5-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.15.4-x86_64-darwin)
|
||||
nokogiri (1.15.5-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.15.4-x86_64-linux)
|
||||
nokogiri (1.15.5-x86_64-linux)
|
||||
racc (~> 1.4)
|
||||
numo-narray (0.9.2.1)
|
||||
oauth (1.1.0)
|
||||
@@ -555,10 +558,12 @@ GEM
|
||||
pundit (2.3.0)
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.7.1)
|
||||
racc (1.7.3)
|
||||
rack (2.2.8)
|
||||
rack-attack (6.7.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-contrib (2.4.0)
|
||||
rack (< 4)
|
||||
rack-cors (2.0.1)
|
||||
rack (>= 2.0.0)
|
||||
rack-mini-profiler (3.1.1)
|
||||
@@ -599,7 +604,7 @@ GEM
|
||||
thor (~> 1.0)
|
||||
zeitwerk (~> 2.5)
|
||||
rainbow (3.1.1)
|
||||
rake (13.0.6)
|
||||
rake (13.1.0)
|
||||
rb-fsevent (0.11.2)
|
||||
rb-inotify (0.10.1)
|
||||
ffi (~> 1.0)
|
||||
@@ -704,13 +709,13 @@ GEM
|
||||
activesupport (>= 4)
|
||||
selectize-rails (0.12.6)
|
||||
semantic_range (3.0.0)
|
||||
sentry-rails (5.12.0)
|
||||
sentry-rails (5.13.0)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.12.0)
|
||||
sentry-ruby (5.12.0)
|
||||
sentry-ruby (~> 5.13.0)
|
||||
sentry-ruby (5.13.0)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
sentry-sidekiq (5.12.0)
|
||||
sentry-ruby (~> 5.12.0)
|
||||
sentry-sidekiq (5.13.0)
|
||||
sentry-ruby (~> 5.13.0)
|
||||
sidekiq (>= 3.0)
|
||||
sexp_processor (4.17.0)
|
||||
shoulda-matchers (5.3.0)
|
||||
@@ -747,7 +752,7 @@ GEM
|
||||
spring-watcher-listen (2.1.0)
|
||||
listen (>= 2.7, < 4.0)
|
||||
spring (>= 4)
|
||||
sprockets (4.2.0)
|
||||
sprockets (4.2.1)
|
||||
concurrent-ruby (~> 1.0)
|
||||
rack (>= 2.2.4, < 4)
|
||||
sprockets-rails (3.4.2)
|
||||
@@ -760,12 +765,12 @@ GEM
|
||||
stripe (8.5.0)
|
||||
telephone_number (1.4.20)
|
||||
test-prof (1.2.1)
|
||||
thor (1.2.2)
|
||||
tilt (2.2.0)
|
||||
thor (1.3.0)
|
||||
tilt (2.3.0)
|
||||
time_diff (0.3.0)
|
||||
activesupport
|
||||
i18n
|
||||
timeout (0.4.0)
|
||||
timeout (0.4.1)
|
||||
trailblazer-option (0.1.2)
|
||||
twilio-ruby (5.77.0)
|
||||
faraday (>= 0.9, < 3.0)
|
||||
@@ -836,7 +841,7 @@ DEPENDENCIES
|
||||
activerecord-import
|
||||
acts-as-taggable-on
|
||||
administrate (>= 0.19.0)
|
||||
administrate-field-active_storage
|
||||
administrate-field-active_storage (>= 1.0.0)
|
||||
administrate-field-belongs_to_search
|
||||
annotate
|
||||
attr_extras
|
||||
@@ -895,6 +900,7 @@ DEPENDENCIES
|
||||
listen
|
||||
lograge (~> 0.14.0)
|
||||
maxminddb
|
||||
meta_request
|
||||
mock_redis
|
||||
neighbor
|
||||
newrelic-sidekiq-metrics (>= 1.6.2)
|
||||
@@ -929,9 +935,9 @@ DEPENDENCIES
|
||||
scout_apm
|
||||
scss_lint
|
||||
seed_dump
|
||||
sentry-rails (>= 5.12.0)
|
||||
sentry-rails (>= 5.13.0)
|
||||
sentry-ruby
|
||||
sentry-sidekiq (>= 5.12.0)
|
||||
sentry-sidekiq (>= 5.13.0)
|
||||
shoulda-matchers
|
||||
sidekiq (>= 7.1.3)
|
||||
sidekiq-cron (>= 1.11.0)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseController
|
||||
RESULTS_PER_PAGE = 15
|
||||
include DateRangeHelper
|
||||
|
||||
before_action :fetch_notification, only: [:update]
|
||||
before_action :fetch_notification, only: [:update, :destroy, :snooze]
|
||||
before_action :set_primary_actor, only: [:read_all]
|
||||
before_action :set_current_page, only: [:index]
|
||||
|
||||
@@ -28,11 +29,21 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
|
||||
render json: @notification
|
||||
end
|
||||
|
||||
def destroy
|
||||
@notification.destroy
|
||||
head :ok
|
||||
end
|
||||
|
||||
def unread_count
|
||||
@unread_count = current_user.notifications.where(account_id: current_account.id, read_at: nil).count
|
||||
render json: @unread_count
|
||||
end
|
||||
|
||||
def snooze
|
||||
@notification.update(snoozed_until: parse_date_time(params[:snoozed_until].to_s)) if params[:snoozed_until]
|
||||
render json: @notification
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_primary_actor
|
||||
|
||||
@@ -47,6 +47,11 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
head :ok
|
||||
end
|
||||
|
||||
def logo
|
||||
@portal.logo.purge if @portal.logo.attached?
|
||||
head :ok
|
||||
end
|
||||
|
||||
def process_attached_logo
|
||||
blob_id = params[:blob_id]
|
||||
blob = ActiveStorage::Blob.find_by(id: blob_id)
|
||||
|
||||
@@ -11,11 +11,7 @@ class Public::Api::V1::Portals::BaseController < PublicController
|
||||
end
|
||||
|
||||
def set_color_scheme
|
||||
@theme = if %w[dark light].include?(params[:theme])
|
||||
params[:theme]
|
||||
else
|
||||
'system'
|
||||
end
|
||||
@theme_from_params = params[:theme] if %w[dark light].include?(params[:theme])
|
||||
end
|
||||
|
||||
def portal
|
||||
|
||||
@@ -27,7 +27,8 @@ class SuperAdmin::Devise::SessionsController < Devise::SessionsController
|
||||
|
||||
true
|
||||
rescue StandardError => e
|
||||
@error_message = e.message
|
||||
Rails.logger.error e.message
|
||||
@error_message = 'Invalid credentials. Please try again.'
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,7 +6,17 @@ module PortalHelper
|
||||
|
||||
def generate_portal_bg(portal_color, theme)
|
||||
bg_image = theme == 'dark' ? 'hexagon-dark.svg' : 'hexagon-light.svg'
|
||||
"background: url(/assets/images/hc/#{bg_image}) #{generate_portal_bg_color(portal_color, theme)}"
|
||||
"url(/assets/images/hc/#{bg_image}) #{generate_portal_bg_color(portal_color, theme)}"
|
||||
end
|
||||
|
||||
def generate_gradient_to_bottom(theme)
|
||||
base_color = theme == 'dark' ? '#151718' : 'white'
|
||||
"linear-gradient(to bottom, transparent, #{base_color})"
|
||||
end
|
||||
|
||||
def generate_portal_hover_color(portal_color, theme)
|
||||
base_color = theme == 'dark' ? '#1B1B1B' : '#F9F9F9'
|
||||
"color-mix(in srgb, #{portal_color} 5%, #{base_color})"
|
||||
end
|
||||
|
||||
def language_name(locale)
|
||||
@@ -14,35 +24,46 @@ module PortalHelper
|
||||
language_map[locale] || locale
|
||||
end
|
||||
|
||||
def get_theme_names(theme)
|
||||
if theme == 'light'
|
||||
I18n.t('public_portal.header.appearance.light')
|
||||
elsif theme == 'dark'
|
||||
I18n.t('public_portal.header.appearance.dark')
|
||||
def theme_query_string(theme)
|
||||
theme.present? && theme != 'system' ? "?theme=#{theme}" : ''
|
||||
end
|
||||
|
||||
def generate_home_link(portal_slug, portal_locale, theme, is_plain_layout_enabled)
|
||||
if is_plain_layout_enabled
|
||||
"/hc/#{portal_slug}/#{portal_locale}#{theme_query_string(theme)}"
|
||||
else
|
||||
I18n.t('public_portal.header.appearance.system')
|
||||
"/hc/#{portal_slug}/#{portal_locale}"
|
||||
end
|
||||
end
|
||||
|
||||
def get_theme_icon(theme)
|
||||
if theme == 'light'
|
||||
'icons/sun'
|
||||
elsif theme == 'dark'
|
||||
'icons/moon'
|
||||
def generate_category_link(params)
|
||||
portal_slug = params[:portal_slug]
|
||||
category_locale = params[:category_locale]
|
||||
category_slug = params[:category_slug]
|
||||
theme = params[:theme]
|
||||
is_plain_layout_enabled = params[:is_plain_layout_enabled]
|
||||
|
||||
if is_plain_layout_enabled
|
||||
"/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}#{theme_query_string(theme)}"
|
||||
else
|
||||
'icons/monitor'
|
||||
"/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}"
|
||||
end
|
||||
end
|
||||
|
||||
def generate_gradient_to_bottom(theme)
|
||||
"background-image: linear-gradient(to bottom, transparent, #{theme == 'dark' ? '#151718' : 'white'})"
|
||||
end
|
||||
|
||||
def generate_article_link(portal_slug, article_slug, theme)
|
||||
"/hc/#{portal_slug}/articles/#{article_slug}#{theme.present? && theme != 'system' ? "?theme=#{theme}" : ''}"
|
||||
def generate_article_link(portal_slug, article_slug, theme, is_plain_layout_enabled)
|
||||
if is_plain_layout_enabled
|
||||
"/hc/#{portal_slug}/articles/#{article_slug}#{theme_query_string(theme)}"
|
||||
else
|
||||
"/hc/#{portal_slug}/articles/#{article_slug}"
|
||||
end
|
||||
end
|
||||
|
||||
def render_category_content(content)
|
||||
ChatwootMarkdownRenderer.new(content).render_markdown_to_plain_text
|
||||
end
|
||||
|
||||
def thumbnail_bg_color(username)
|
||||
colors = ['#6D95BA', '#A4C3C3', '#E19191']
|
||||
colors[username.length % colors.size]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -17,6 +17,10 @@ class PortalsAPI extends ApiClient {
|
||||
deletePortal(portalSlug) {
|
||||
return axios.delete(`${this.url}/${portalSlug}`);
|
||||
}
|
||||
|
||||
deleteLogo(portalSlug) {
|
||||
return axios.delete(`${this.url}/${portalSlug}/logo`);
|
||||
}
|
||||
}
|
||||
|
||||
export default PortalsAPI;
|
||||
|
||||
@@ -16,3 +16,23 @@
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.integration-hooks {
|
||||
.formulate-input[data-type='checkbox'] {
|
||||
.formulate-input-wrapper {
|
||||
@apply flex;
|
||||
|
||||
.formulate-input-element {
|
||||
@apply pr-2;
|
||||
|
||||
input {
|
||||
@apply mb-0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.formulate-input-element-decorator {
|
||||
@apply hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ export default {
|
||||
computed: {
|
||||
...mapGetters({
|
||||
currentChat: 'getSelectedChat',
|
||||
isAChatwootInstance: 'globalConfig/isAChatwootInstance',
|
||||
}),
|
||||
isAICTAModalDismissed() {
|
||||
return this.uiSettings.is_open_ai_cta_modal_dismissed;
|
||||
@@ -70,7 +71,8 @@ export default {
|
||||
return (
|
||||
this.isAdmin &&
|
||||
!this.isAIIntegrationEnabled &&
|
||||
!this.isAICTAModalDismissed
|
||||
!this.isAICTAModalDismissed &&
|
||||
this.isAChatwootInstance
|
||||
);
|
||||
},
|
||||
// Display a AI CTA button for agents and other admins who have not yet opened the AI assistance modal.
|
||||
|
||||
@@ -144,6 +144,7 @@ import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import {
|
||||
ALLOWED_FILE_TYPES,
|
||||
ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP,
|
||||
ALLOWED_FILE_TYPES_FOR_LINE,
|
||||
} from 'shared/constants/messages';
|
||||
import VideoCallButton from '../VideoCallButton.vue';
|
||||
import AIAssistanceButton from '../AIAssistanceButton.vue';
|
||||
@@ -270,6 +271,9 @@ export default {
|
||||
return this.showFileUpload || this.isNote;
|
||||
},
|
||||
showAudioRecorderButton() {
|
||||
if (this.isALineChannel) {
|
||||
return false;
|
||||
}
|
||||
// Disable audio recorder for safari browser as recording is not supported
|
||||
const isSafari = /^((?!chrome|android|crios|fxios).)*safari/i.test(
|
||||
navigator.userAgent
|
||||
@@ -291,6 +295,9 @@ export default {
|
||||
if (this.isATwilioWhatsAppChannel) {
|
||||
return ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP;
|
||||
}
|
||||
if (this.isALineChannel) {
|
||||
return ALLOWED_FILE_TYPES_FOR_LINE;
|
||||
}
|
||||
return ALLOWED_FILE_TYPES;
|
||||
},
|
||||
enableDragAndDrop() {
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
:message="inReplyTo"
|
||||
:message-type="data.message_type"
|
||||
:parent-has-attachments="hasAttachments"
|
||||
@click="navigateToMessage"
|
||||
/>
|
||||
<bubble-text
|
||||
v-if="data.content"
|
||||
@@ -519,6 +520,13 @@ export default {
|
||||
this.showBackgroundHighlight = false;
|
||||
}, HIGHLIGHT_TIMER);
|
||||
},
|
||||
async navigateToMessage() {
|
||||
this.$nextTick(() => {
|
||||
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE, {
|
||||
messageId: this.inReplyToMessageId,
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -114,7 +114,6 @@ import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
|
||||
// constants
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { REPLY_POLICY } from 'shared/constants/links';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
@@ -285,14 +284,6 @@ export default {
|
||||
return this.currentChat.unread_count || 0;
|
||||
},
|
||||
inboxSupportsReplyTo() {
|
||||
if (
|
||||
!this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.MESSAGE_REPLY_TO
|
||||
)
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
incoming: this.inboxHasFeature(INBOX_FEATURES.REPLY_TO),
|
||||
outgoing: this.inboxHasFeature(INBOX_FEATURES.REPLY_TO_OUTGOING),
|
||||
@@ -349,7 +340,7 @@ export default {
|
||||
// method available in mixin, need to ensure that integrations are present
|
||||
await this.fetchIntegrationsIfRequired();
|
||||
|
||||
if (!this.isAIIntegrationEnabled) {
|
||||
if (!this.isLabelSuggestionFeatureEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -386,12 +377,20 @@ export default {
|
||||
removeBusListeners() {
|
||||
bus.$off(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
|
||||
},
|
||||
onScrollToMessage({ messageId = '' } = {}) {
|
||||
async onScrollToMessage({ messageId = '' } = {}) {
|
||||
if (messageId) {
|
||||
await this.$store.dispatch('setActiveChat', {
|
||||
data: this.currentChat,
|
||||
after: messageId,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
this.$nextTick(() => {
|
||||
const messageElement = document.getElementById('message' + messageId);
|
||||
if (messageElement) {
|
||||
this.isProgrammaticScroll = true;
|
||||
messageElement.scrollIntoView({ behavior: 'smooth' });
|
||||
messageElement.scrollIntoView();
|
||||
this.fetchPreviousMessages();
|
||||
} else {
|
||||
this.scrollToBottom();
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
v-if="shouldShowReplyToMessage"
|
||||
:message="inReplyTo"
|
||||
@dismiss="resetReplyToMessage"
|
||||
@navigate-to-message="navigateToMessage"
|
||||
/>
|
||||
<canned-response
|
||||
v-if="showMentions && hasSlashCommand"
|
||||
@@ -194,7 +195,6 @@ import {
|
||||
replaceSignature,
|
||||
extractTextFromMarkdown,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
@@ -276,11 +276,7 @@ export default {
|
||||
return (
|
||||
this.inReplyTo?.id &&
|
||||
!this.isPrivate &&
|
||||
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO) &&
|
||||
this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.MESSAGE_REPLY_TO
|
||||
)
|
||||
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO)
|
||||
);
|
||||
},
|
||||
showRichContentEditor() {
|
||||
@@ -397,7 +393,8 @@ export default {
|
||||
this.isAPIInbox ||
|
||||
this.isAnEmailChannel ||
|
||||
this.isASmsInbox ||
|
||||
this.isATelegramChannel
|
||||
this.isATelegramChannel ||
|
||||
this.isALineChannel
|
||||
);
|
||||
},
|
||||
replyButtonLabel() {
|
||||
@@ -629,6 +626,8 @@ export default {
|
||||
`${this.$t('CONVERSATION.REPLYBOX.INSERT_READ_MORE')} ${url}`
|
||||
);
|
||||
}
|
||||
|
||||
this.$track(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK);
|
||||
},
|
||||
toggleRichContentEditor() {
|
||||
this.updateUISettings({
|
||||
@@ -1161,6 +1160,11 @@ export default {
|
||||
LocalStorage.deleteFromJsonStore(replyStorageKey, this.conversationId);
|
||||
bus.$emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE);
|
||||
},
|
||||
navigateToMessage(messageId) {
|
||||
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE, {
|
||||
messageId,
|
||||
});
|
||||
},
|
||||
onNewConversationModalActive(isActive) {
|
||||
// Issue is if the new conversation modal is open and we drag and drop the file
|
||||
// then the file is not getting attached to the new conversation modal
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
class="reply-editor bg-slate-50 dark:bg-slate-800 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5 -mx-2"
|
||||
class="reply-editor bg-slate-50 dark:bg-slate-800 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5 -mx-2 cursor-pointer"
|
||||
@click="$emit('navigate-to-message', message.id)"
|
||||
>
|
||||
<fluent-icon class="flex-shrink-0 icon" icon="arrow-reply" size="14" />
|
||||
<div class="flex-grow gap-1 mt-px text-xs truncate">
|
||||
|
||||
@@ -192,7 +192,7 @@ export default {
|
||||
if (!this.showStatusIndicators) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Messages will be marked as sent for the Email channel if they have a source ID.
|
||||
if (this.isAnEmailChannel) {
|
||||
return !!this.sourceId;
|
||||
}
|
||||
@@ -201,11 +201,12 @@ export default {
|
||||
this.isAWhatsAppChannel ||
|
||||
this.isATwilioChannel ||
|
||||
this.isAFacebookInbox ||
|
||||
this.isASmsInbox
|
||||
this.isASmsInbox ||
|
||||
this.isATelegramChannel
|
||||
) {
|
||||
return this.sourceId && this.isSent;
|
||||
}
|
||||
// There is no source id for the line channel
|
||||
// All messages will be mark as sent for the Line channel, as there is no source ID.
|
||||
if (this.isALineChannel) {
|
||||
return true;
|
||||
}
|
||||
@@ -216,15 +217,15 @@ export default {
|
||||
if (!this.showStatusIndicators) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
this.isAWhatsAppChannel ||
|
||||
this.isATwilioChannel ||
|
||||
this.isASmsInbox
|
||||
this.isASmsInbox ||
|
||||
this.isAFacebookInbox
|
||||
) {
|
||||
return this.sourceId && this.isDelivered;
|
||||
}
|
||||
// We will consider messages as delivered for web widget inbox and API inbox if they are sent
|
||||
// All messages marked as delivered for the web widget inbox and API inbox once they are sent.
|
||||
if (this.isAWebWidgetInbox || this.isAPIInbox) {
|
||||
return this.isSent;
|
||||
}
|
||||
@@ -238,12 +239,6 @@ export default {
|
||||
if (!this.showStatusIndicators) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.isAWebWidgetInbox || this.isAPIInbox) {
|
||||
const { contact_last_seen_at: contactLastSeenAt } = this.currentChat;
|
||||
return contactLastSeenAt >= this.createdAt;
|
||||
}
|
||||
|
||||
if (
|
||||
this.isAWhatsAppChannel ||
|
||||
this.isATwilioChannel ||
|
||||
@@ -252,6 +247,10 @@ export default {
|
||||
return this.sourceId && this.isRead;
|
||||
}
|
||||
|
||||
if (this.isAWebWidgetInbox || this.isAPIInbox) {
|
||||
return this.isRead;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<template>
|
||||
<div
|
||||
class="px-2 py-1.5 rounded-sm min-w-[10rem] mb-2"
|
||||
class="px-2 py-1.5 -mx-2 rounded-sm min-w-[15rem] mb-2 cursor-pointer"
|
||||
:class="{
|
||||
'bg-slate-50 dark:bg-slate-600 dark:text-slate-50':
|
||||
messageType === MESSAGE_TYPE.INCOMING,
|
||||
'bg-woot-600 text-woot-50': messageType === MESSAGE_TYPE.OUTGOING,
|
||||
'-mx-2': !parentHasAttachments,
|
||||
}"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<message-preview
|
||||
:message="message"
|
||||
|
||||
@@ -7,7 +7,11 @@
|
||||
}"
|
||||
>
|
||||
<div v-if="!isEmail" v-dompurify-html="message" class="text-content" />
|
||||
<letter v-else class="text-content" :html="message" />
|
||||
<letter
|
||||
v-else
|
||||
class="text-content bg-white dark:bg-white text-slate-900 dark:text-slate-900 p-2 rounded-[4px]"
|
||||
:html="message"
|
||||
/>
|
||||
<button
|
||||
v-if="showQuoteToggle"
|
||||
class="text-slate-300 dark:text-slate-300 cursor-pointer text-xs py-1"
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
id="file"
|
||||
ref="file"
|
||||
type="file"
|
||||
accept="image/png, image/jpeg, image/gif"
|
||||
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
|
||||
@change="handleImageUpload"
|
||||
/>
|
||||
<slot />
|
||||
|
||||
@@ -16,6 +16,5 @@ export const FEATURE_FLAGS = {
|
||||
TEAM_MANAGEMENT: 'team_management',
|
||||
VOICE_RECORDER: 'voice_recorder',
|
||||
AUDIT_LOGS: 'audit_logs',
|
||||
MESSAGE_REPLY_TO: 'message_reply_to',
|
||||
INSERT_ARTICLE_IN_REPLY: 'insert_article_in_reply',
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ export const CONVERSATION_EVENTS = Object.freeze({
|
||||
SEARCH_CONVERSATION: 'Searched conversations',
|
||||
APPLY_FILTER: 'Applied filters in the conversation list',
|
||||
CHANGE_PRIORITY: 'Assigned priority to a conversation',
|
||||
INSERT_ARTICLE_LINK: 'Inserted article into reply via article search',
|
||||
});
|
||||
|
||||
export const ACCOUNT_EVENTS = Object.freeze({
|
||||
|
||||
@@ -96,3 +96,15 @@ export const getArticleSearchURL = ({
|
||||
|
||||
return `${host}/${portalSlug}/articles?${queryParams.toString()}`;
|
||||
};
|
||||
|
||||
export const hasValidAvatarUrl = avatarUrl => {
|
||||
try {
|
||||
const { host: avatarUrlHost } = new URL(avatarUrl);
|
||||
const isFromGravatar = ['www.gravatar.com', 'gravatar'].includes(
|
||||
avatarUrlHost
|
||||
);
|
||||
return avatarUrl && !isFromGravatar;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
isValidURL,
|
||||
conversationListPageURL,
|
||||
getArticleSearchURL,
|
||||
hasValidAvatarUrl,
|
||||
} from '../URLHelper';
|
||||
|
||||
describe('#URL Helpers', () => {
|
||||
@@ -164,4 +165,29 @@ describe('#URL Helpers', () => {
|
||||
expect(url).toBe('myurl.com/news/articles?page=1&locale=en');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasValidAvatarUrl', () => {
|
||||
test('should return true for valid non-Gravatar URL', () => {
|
||||
expect(hasValidAvatarUrl('https://chatwoot.com/avatar.jpg')).toBe(true);
|
||||
});
|
||||
|
||||
test('should return false for a Gravatar URL (www.gravatar.com)', () => {
|
||||
expect(hasValidAvatarUrl('https://www.gravatar.com/avatar.jpg')).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test('should return false for a Gravatar URL (gravatar)', () => {
|
||||
expect(hasValidAvatarUrl('https://gravatar/avatar.jpg')).toBe(false);
|
||||
});
|
||||
|
||||
test('should handle invalid URL', () => {
|
||||
expect(hasValidAvatarUrl('invalid-url')).toBe(false); // or expect an error, depending on function design
|
||||
});
|
||||
|
||||
test('should return false for empty or undefined URL', () => {
|
||||
expect(hasValidAvatarUrl('')).toBe(false);
|
||||
expect(hasValidAvatarUrl()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -223,7 +223,10 @@
|
||||
"LOGO": {
|
||||
"LABEL": "Logo",
|
||||
"UPLOAD_BUTTON": "Upload logo",
|
||||
"HELP_TEXT": "This logo will be displayed on the portal header."
|
||||
"HELP_TEXT": "This logo will be displayed on the portal header.",
|
||||
"IMAGE_UPLOAD_SUCCESS": "Logo uploaded successfully",
|
||||
"IMAGE_UPLOAD_ERROR": "Logo deleted successfully",
|
||||
"IMAGE_DELETE_ERROR": "Error while deleting logo"
|
||||
},
|
||||
"NAME": {
|
||||
"LABEL": "Name",
|
||||
|
||||
@@ -15,18 +15,26 @@ export default {
|
||||
currentChat: 'getSelectedChat',
|
||||
replyMode: 'draftMessages/getReplyEditorMode',
|
||||
}),
|
||||
isAIIntegrationEnabled() {
|
||||
return !!this.appIntegrations.find(
|
||||
aiIntegration() {
|
||||
return this.appIntegrations.find(
|
||||
integration => integration.id === 'openai' && !!integration.hooks.length
|
||||
);
|
||||
)?.hooks[0];
|
||||
},
|
||||
isAIIntegrationEnabled() {
|
||||
return !!this.aiIntegration;
|
||||
},
|
||||
isLabelSuggestionFeatureEnabled() {
|
||||
if (this.aiIntegration) {
|
||||
const { settings = {} } = this.aiIntegration || {};
|
||||
return settings.label_suggestion;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
isFetchingAppIntegrations() {
|
||||
return this.uiFlags.isFetching;
|
||||
},
|
||||
hookId() {
|
||||
return this.appIntegrations.find(
|
||||
integration => integration.id === 'openai' && !!integration.hooks.length
|
||||
).hooks[0].id;
|
||||
return this.aiIntegration.id;
|
||||
},
|
||||
draftMessage() {
|
||||
return this.$store.getters['draftMessages/get'](this.draftKey);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* eslint-disable storybook/default-exports */
|
||||
import SearchView from './components/SearchView.vue';
|
||||
import { frontendURL } from '../../helper/URLHelper';
|
||||
|
||||
const SearchView = () => import('./components/SearchView.vue');
|
||||
|
||||
export const routes = [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/search'),
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<div v-else class="chat-message-input is-focused">
|
||||
<resizable-text-area
|
||||
id="chat-input"
|
||||
:rows="1"
|
||||
:placeholder="
|
||||
$t('INBOX_MGMT.WIDGET_BUILDER.FOOTER.CHAT_INPUT_PLACEHOLDER')
|
||||
"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint arrow-body-style: 0 */
|
||||
import ContactsView from './components/ContactsView';
|
||||
import ContactManageView from './pages/ContactManageView';
|
||||
import { frontendURL } from '../../../helper/URLHelper';
|
||||
const ContactsView = () => import('./components/ContactsView.vue');
|
||||
const ContactManageView = () => import('./pages/ContactManageView.vue');
|
||||
|
||||
export const routes = [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint arrow-body-style: 0 */
|
||||
import ConversationView from './ConversationView';
|
||||
import { frontendURL } from '../../../helper/URLHelper';
|
||||
const ConversationView = () => import('./ConversationView');
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import AppContainer from './Dashboard';
|
||||
import settings from './settings/settings.routes';
|
||||
import conversation from './conversation/conversation.routes';
|
||||
import { routes as searchRoutes } from '../../modules/search/search.routes';
|
||||
@@ -7,7 +6,8 @@ import { routes as notificationRoutes } from './notifications/routes';
|
||||
import { frontendURL } from '../../helper/URLHelper';
|
||||
import helpcenterRoutes from './helpcenter/helpcenter.routes';
|
||||
|
||||
const Suspended = () => import('./suspended/Index');
|
||||
const AppContainer = () => import('./Dashboard.vue');
|
||||
const Suspended = () => import('./suspended/Index.vue');
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<resizable-text-area
|
||||
v-model="articleTitle"
|
||||
type="text"
|
||||
rows="1"
|
||||
:rows="1"
|
||||
class="article-heading"
|
||||
:placeholder="$t('HELP_CENTER.EDIT_ARTICLE.TITLE_PLACEHOLDER')"
|
||||
@focus="onFocus"
|
||||
|
||||
@@ -161,7 +161,7 @@ export default {
|
||||
}
|
||||
|
||||
span.article-column {
|
||||
@apply text-slate-700 dark:text-slate-100 text-sm font-semibold py-2 px-0 text-right capitalize;
|
||||
@apply text-slate-700 dark:text-slate-100 text-sm font-semibold py-2 px-0 text-left capitalize last:text-right;
|
||||
|
||||
&.article-title {
|
||||
@apply items-start flex gap-2 col-span-4 text-left;
|
||||
|
||||
@@ -10,17 +10,17 @@
|
||||
{{ $t('HELP_CENTER.TABLE.HEADERS.TITLE') }}
|
||||
</div>
|
||||
<div
|
||||
class="font-semibold capitalize text-sm py-2 px-0 text-slate-700 dark:text-slate-100 text-right"
|
||||
class="font-semibold capitalize text-sm py-2 px-0 text-slate-700 dark:text-slate-100 text-left"
|
||||
>
|
||||
{{ $t('HELP_CENTER.TABLE.HEADERS.CATEGORY') }}
|
||||
</div>
|
||||
<div
|
||||
class="font-semibold capitalize text-sm py-2 px-0 text-slate-700 dark:text-slate-100 text-right hidden lg:block"
|
||||
class="font-semibold capitalize text-sm py-2 px-0 text-slate-700 dark:text-slate-100 text-left hidden lg:block"
|
||||
>
|
||||
{{ $t('HELP_CENTER.TABLE.HEADERS.READ_COUNT') }}
|
||||
</div>
|
||||
<div
|
||||
class="font-semibold capitalize text-sm py-2 px-0 text-slate-700 dark:text-slate-100 text-right"
|
||||
class="font-semibold capitalize text-sm py-2 px-0 text-slate-700 dark:text-slate-100 text-left"
|
||||
>
|
||||
{{ $t('HELP_CENTER.TABLE.HEADERS.STATUS') }}
|
||||
</div>
|
||||
|
||||
+73
-18
@@ -14,21 +14,24 @@
|
||||
>
|
||||
<div class="w-[65%] flex-shrink-0 flex-grow-0 max-w-[65%]">
|
||||
<div class="mb-4">
|
||||
<label>
|
||||
{{ $t('HELP_CENTER.PORTAL.ADD.LOGO.LABEL') }}
|
||||
</label>
|
||||
<div class="flex items-center flex-row">
|
||||
<thumbnail :username="name" size="56px" variant="square" />
|
||||
<woot-button
|
||||
v-if="false"
|
||||
class="ml-3"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
icon="upload"
|
||||
size="small"
|
||||
>
|
||||
{{ $t('HELP_CENTER.PORTAL.ADD.LOGO.UPLOAD_BUTTON') }}
|
||||
</woot-button>
|
||||
<woot-avatar-uploader
|
||||
ref="imageUpload"
|
||||
:label="$t('HELP_CENTER.PORTAL.ADD.LOGO.LABEL')"
|
||||
:src="logoUrl"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<div v-if="showDeleteButton" class="avatar-delete-btn">
|
||||
<woot-button
|
||||
type="button"
|
||||
color-scheme="alert"
|
||||
variant="hollow"
|
||||
size="small"
|
||||
@click="deleteAvatar"
|
||||
>
|
||||
{{ $t('PROFILE_SETTINGS.DELETE_AVATAR') }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
class="mt-1 mb-0 text-xs text-slate-600 dark:text-slate-400 not-italic"
|
||||
@@ -87,17 +90,20 @@
|
||||
<script>
|
||||
import { required, minLength } from 'vuelidate/lib/validators';
|
||||
import { isDomain } from 'shared/helpers/Validators';
|
||||
import thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import { convertToCategorySlug } from 'dashboard/helper/commons.js';
|
||||
import { buildPortalURL } from 'dashboard/helper/portalHelper';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { hasValidAvatarUrl } from 'dashboard/helper/URLHelper';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { uploadFile } from 'dashboard/helper/uploadHelper';
|
||||
|
||||
const { EXAMPLE_URL } = wootConstants;
|
||||
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
||||
|
||||
export default {
|
||||
components: {
|
||||
thumbnail,
|
||||
},
|
||||
mixins: [alertMixin],
|
||||
props: {
|
||||
portal: {
|
||||
type: Object,
|
||||
@@ -118,6 +124,10 @@ export default {
|
||||
slug: '',
|
||||
domain: '',
|
||||
alertMessage: '',
|
||||
|
||||
// Logouploader keys
|
||||
avatarBlobId: '',
|
||||
logoUrl: '',
|
||||
};
|
||||
},
|
||||
validations: {
|
||||
@@ -159,6 +169,9 @@ export default {
|
||||
exampleURL: EXAMPLE_URL,
|
||||
});
|
||||
},
|
||||
showDeleteButton() {
|
||||
return hasValidAvatarUrl(this.logoUrl);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
const portal = this.portal || {};
|
||||
@@ -166,6 +179,13 @@ export default {
|
||||
this.slug = portal.slug || '';
|
||||
this.domain = portal.custom_domain || '';
|
||||
this.alertMessage = '';
|
||||
if (portal.logo) {
|
||||
const {
|
||||
logo: { file_url: logoURL, blob_id: blobId },
|
||||
} = portal;
|
||||
this.logoUrl = logoURL;
|
||||
this.avatarBlobId = blobId;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onNameChange() {
|
||||
@@ -181,9 +201,44 @@ export default {
|
||||
name: this.name,
|
||||
slug: this.slug,
|
||||
custom_domain: this.domain,
|
||||
blob_id: this.avatarBlobId || null,
|
||||
};
|
||||
this.$emit('submit', portal);
|
||||
},
|
||||
async deleteAvatar() {
|
||||
this.logoUrl = '';
|
||||
this.avatarBlobId = '';
|
||||
this.$emit('delete-logo');
|
||||
},
|
||||
onFileChange({ file }) {
|
||||
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
|
||||
this.uploadLogoToStorage(file);
|
||||
} else {
|
||||
this.showAlert(
|
||||
this.$t(
|
||||
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SIZE_ERROR',
|
||||
{
|
||||
size: MAXIMUM_FILE_UPLOAD_SIZE,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
this.$refs.imageUpload.value = '';
|
||||
},
|
||||
async uploadLogoToStorage(file) {
|
||||
try {
|
||||
const { fileUrl, blobId } = await uploadFile(file);
|
||||
if (fileUrl) {
|
||||
this.logoUrl = fileUrl;
|
||||
this.avatarBlobId = blobId;
|
||||
}
|
||||
} catch (error) {
|
||||
this.showAlert(
|
||||
this.$t('HELP_CENTER.PORTAL.ADD.LOGO.IMAGE_DELETE_ERROR')
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
+16
-9
@@ -157,16 +157,23 @@ export default {
|
||||
return this.metaTags.map(item => item.name);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
article: {
|
||||
handler() {
|
||||
if (!isEmptyObject(this.article.meta || {})) {
|
||||
const {
|
||||
meta: { title = '', description = '', tags = [] },
|
||||
} = this.article;
|
||||
this.metaTitle = title;
|
||||
this.metaDescription = description;
|
||||
this.metaTags = this.formattedTags({ tags });
|
||||
}
|
||||
},
|
||||
deep: true,
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (!isEmptyObject(this.article.meta || {})) {
|
||||
const {
|
||||
meta: { title = '', description = '', tags = [] },
|
||||
} = this.article;
|
||||
this.metaTitle = title;
|
||||
this.metaDescription = description;
|
||||
this.metaTags = this.formattedTags({ tags });
|
||||
}
|
||||
|
||||
this.saveArticle = debounce(
|
||||
() => {
|
||||
this.$emit('save-article', {
|
||||
|
||||
+14
@@ -7,6 +7,7 @@
|
||||
$t('HELP_CENTER.PORTAL.EDIT.EDIT_BASIC_INFO.BUTTON_TEXT')
|
||||
"
|
||||
@submit="updatePortalSettings"
|
||||
@delete-logo="deleteLogo"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -70,6 +71,19 @@ export default {
|
||||
this.showAlert(this.alertMessage);
|
||||
}
|
||||
},
|
||||
async deleteLogo() {
|
||||
try {
|
||||
const portalSlug = this.lastPortalSlug;
|
||||
await this.$store.dispatch('portals/deleteLogo', {
|
||||
portalSlug,
|
||||
});
|
||||
} catch (error) {
|
||||
this.alertMessage =
|
||||
error?.message ||
|
||||
this.$t('HELP_CENTER.PORTAL.ADD.LOGO.IMAGE_DELETE_ERROR');
|
||||
this.showAlert(this.alertMessage);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -40,9 +40,12 @@ export default {
|
||||
methods: {
|
||||
async createPortal(portal) {
|
||||
try {
|
||||
const { blob_id: blobId } = portal;
|
||||
await this.$store.dispatch('portals/create', {
|
||||
portal,
|
||||
blob_id: blobId,
|
||||
});
|
||||
|
||||
this.alertMessage = this.$t(
|
||||
'HELP_CENTER.PORTAL.ADD.API.SUCCESS_MESSAGE_FOR_BASIC'
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint arrow-body-style: 0 */
|
||||
import NotificationsView from './components/NotificationsView.vue';
|
||||
import { frontendURL } from '../../../helper/URLHelper';
|
||||
import SettingsWrapper from '../settings/Wrapper';
|
||||
const SettingsWrapper = () => import('../settings/Wrapper.vue');
|
||||
const NotificationsView = () => import('./components/NotificationsView.vue');
|
||||
|
||||
export const routes = [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import SettingsContent from '../Wrapper';
|
||||
import Index from './Index.vue';
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
const SettingsContent = () => import('../Wrapper.vue');
|
||||
const Index = () => import('./Index.vue');
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import SettingsContent from '../Wrapper';
|
||||
const Bot = () => import('./Index.vue');
|
||||
const CsmlEditBot = () => import('./csml/Edit.vue');
|
||||
const CsmlNewBot = () => import('./csml/New.vue');
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
const SettingsContent = () => import('../Wrapper.vue');
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import SettingsContent from '../Wrapper';
|
||||
import AgentHome from './Index';
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
const SettingsContent = () => import('../Wrapper.vue');
|
||||
const AgentHome = () => import('./Index.vue');
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import SettingsContent from '../Wrapper';
|
||||
import AttributesHome from './Index';
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
const SettingsContent = () => import('../Wrapper.vue');
|
||||
const AttributesHome = () => import('./Index.vue');
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import SettingsContent from '../Wrapper';
|
||||
import AuditLogsHome from './Index';
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
|
||||
const SettingsContent = () => import('../Wrapper.vue');
|
||||
const AuditLogsHome = () => import('./Index.vue');
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import SettingsContent from '../Wrapper';
|
||||
import Automation from './Index';
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
const SettingsContent = () => import('../Wrapper.vue');
|
||||
const Automation = () => import('./Index.vue');
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import SettingsContent from '../Wrapper';
|
||||
import Index from './Index.vue';
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
const SettingsContent = () => import('../Wrapper.vue');
|
||||
const Index = () => import('./Index.vue');
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Index from './Index';
|
||||
import SettingsContent from '../Wrapper';
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
const SettingsContent = () => import('../Wrapper.vue');
|
||||
const Index = () => import('./Index.vue');
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
@@ -20,7 +20,7 @@ export default {
|
||||
path: 'ongoing',
|
||||
name: 'settings_account_campaigns',
|
||||
roles: ['administrator'],
|
||||
component: { ...Index },
|
||||
component: Index,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -36,7 +36,7 @@ export default {
|
||||
path: 'one_off',
|
||||
name: 'one_off',
|
||||
roles: ['administrator'],
|
||||
component: { ...Index },
|
||||
component: Index,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import SettingsContent from '../Wrapper';
|
||||
import CannedHome from './Index';
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
|
||||
const SettingsContent = () => import('../Wrapper.vue');
|
||||
const CannedHome = () => import('./Index.vue');
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
{
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/* eslint arrow-body-style: 0 */
|
||||
import SettingsContent from '../Wrapper';
|
||||
import Settings from './Settings';
|
||||
import InboxHome from './Index';
|
||||
import InboxChannel from './InboxChannels';
|
||||
import ChannelList from './ChannelList';
|
||||
import channelFactory from './channel-factory';
|
||||
import AddAgents from './AddAgents';
|
||||
import FinishSetup from './FinishSetup';
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
import channelFactory from './channel-factory';
|
||||
|
||||
const SettingsContent = () => import('../Wrapper.vue');
|
||||
const InboxHome = () => import('./Index.vue');
|
||||
const Settings = () => import('./Settings.vue');
|
||||
const InboxChannel = () => import('./InboxChannels.vue');
|
||||
const ChannelList = () => import('./ChannelList.vue');
|
||||
const AddAgents = () => import('./AddAgents.vue');
|
||||
const FinishSetup = () => import('./FinishSetup.vue');
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<!-- eslint-disable vue/v-slot-style -->
|
||||
<template>
|
||||
<div class="flex flex-col h-auto overflow-auto">
|
||||
<div class="flex flex-col h-auto overflow-auto integration-hooks">
|
||||
<woot-modal-header
|
||||
:header-title="integration.name"
|
||||
:header-content="integration.description"
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import Index from './Index';
|
||||
import SettingsContent from '../Wrapper';
|
||||
import IntegrationHooks from './IntegrationHooks';
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
const SettingsContent = () => import('../Wrapper.vue');
|
||||
const IntegrationHooks = () => import('./IntegrationHooks.vue');
|
||||
const Index = () => import('./Index.vue');
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
|
||||
+7
-6
@@ -1,11 +1,12 @@
|
||||
import Index from './Index';
|
||||
import SettingsContent from '../Wrapper';
|
||||
import Webhook from './Webhooks/Index';
|
||||
import DashboardApps from './DashboardApps/Index';
|
||||
import ShowIntegration from './ShowIntegration';
|
||||
import Slack from './Slack';
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
|
||||
const SettingsContent = () => import('../Wrapper.vue');
|
||||
const Webhook = () => import('./Webhooks/Index.vue');
|
||||
const DashboardApps = () => import('./DashboardApps/Index.vue');
|
||||
const ShowIntegration = () => import('./ShowIntegration.vue');
|
||||
const Slack = () => import('./Slack.vue');
|
||||
const Index = () => import('./Index.vue');
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
{
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import SettingsContent from '../Wrapper';
|
||||
import Index from './Index';
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
|
||||
const SettingsContent = () => import('../Wrapper.vue');
|
||||
const Index = () => import('./Index.vue');
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<template>
|
||||
<div class="flex flex-row h-full">
|
||||
<div class="w-[60%] macros-canvas">
|
||||
<div class="flex flex-col md:flex-row h-auto md:h-full w-full">
|
||||
<div
|
||||
class="flex-1 w-full md:w-auto macro-gradient-radial dark:macro-dark-gradient-radial macro-gradient-radial-size h-full max-h-full py-4 px-12 overflow-y-auto"
|
||||
>
|
||||
<macro-nodes
|
||||
v-model="macro.actions"
|
||||
:files="files"
|
||||
@@ -9,7 +11,7 @@
|
||||
@resetAction="resetNode"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-[34%]">
|
||||
<div class="w-full md:w-1/3">
|
||||
<macro-properties
|
||||
:macro-name="macro.name"
|
||||
:macro-visibility="macro.visibility"
|
||||
@@ -138,7 +140,4 @@ export default {
|
||||
background-size: 1rem 1rem;
|
||||
}
|
||||
}
|
||||
.macros-canvas {
|
||||
@apply macro-gradient-radial dark:macro-dark-gradient-radial macro-gradient-radial-size h-full max-h-full py-4 px-12 overflow-y-auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
>
|
||||
{{ $t('MACROS.EDITOR.VISIBILITY.LABEL') }}
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
<button
|
||||
class="p-2 relative rounded-md border border-solid text-left cursor-default"
|
||||
:class="isActive('global')"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import SettingsContent from '../Wrapper';
|
||||
import Macros from './Index';
|
||||
const MacroEditor = () => import('./MacroEditor');
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
|
||||
const SettingsContent = () => import('../Wrapper.vue');
|
||||
const Macros = () => import('./Index.vue');
|
||||
const MacroEditor = () => import('./MacroEditor.vue');
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
{
|
||||
|
||||
@@ -128,6 +128,7 @@
|
||||
import { required, minLength, email } from 'vuelidate/lib/validators';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { clearCookiesOnLogout } from '../../../../store/utils/api';
|
||||
import { hasValidAvatarUrl } from 'dashboard/helper/URLHelper';
|
||||
import NotificationSettings from './NotificationSettings.vue';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import ChangePassword from './ChangePassword.vue';
|
||||
@@ -198,6 +199,9 @@ export default {
|
||||
currentUserId: 'getCurrentUserID',
|
||||
globalConfig: 'globalConfig/get',
|
||||
}),
|
||||
showDeleteButton() {
|
||||
return hasValidAvatarUrl(this.avatarUrl);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
currentUserId(newCurrentUserId, prevCurrentUserId) {
|
||||
@@ -265,9 +269,6 @@ export default {
|
||||
this.showAlert(this.$t('PROFILE_SETTINGS.AVATAR_DELETE_FAILED'));
|
||||
}
|
||||
},
|
||||
showDeleteButton() {
|
||||
return this.avatarUrl && !this.avatarUrl.includes('www.gravatar.com');
|
||||
},
|
||||
toggleEditorMessageKey(key) {
|
||||
this.updateUISettings({ editor_message_key: key });
|
||||
this.showAlert(
|
||||
|
||||
@@ -22,14 +22,12 @@
|
||||
:enabled-menu-options="customEditorMenuList"
|
||||
:enable-suggestions="false"
|
||||
:show-image-resize-toolbar="true"
|
||||
@blur="$v.messageSignature.$touch"
|
||||
/>
|
||||
</div>
|
||||
<woot-button
|
||||
:is-loading="isUpdating"
|
||||
type="button"
|
||||
:is-disabled="$v.messageSignature.$invalid"
|
||||
@click.prevent="updateSignature()"
|
||||
@click.prevent="updateSignature"
|
||||
>
|
||||
{{ $t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.BTN_TEXT') }}
|
||||
</woot-button>
|
||||
@@ -38,7 +36,6 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { required } from 'vuelidate/lib/validators';
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||
@@ -59,11 +56,6 @@ export default {
|
||||
customEditorMenuList: MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS,
|
||||
};
|
||||
},
|
||||
validations: {
|
||||
messageSignature: {
|
||||
required,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
currentUser: 'getCurrentUser',
|
||||
@@ -79,15 +71,9 @@ export default {
|
||||
this.messageSignature = messageSignature || '';
|
||||
},
|
||||
async updateSignature() {
|
||||
this.$v.$touch();
|
||||
if (this.$v.$invalid) {
|
||||
this.showAlert(this.$t('PROFILE_SETTINGS.FORM.ERROR'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.$store.dispatch('updateProfile', {
|
||||
message_signature: this.messageSignature,
|
||||
message_signature: this.messageSignature || '',
|
||||
});
|
||||
this.errorMessage = this.$t(
|
||||
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.API_SUCCESS'
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import SettingsContent from '../Wrapper';
|
||||
import Index from './Index.vue';
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
|
||||
const SettingsContent = () => import('../Wrapper.vue');
|
||||
const Index = () => import('./Index.vue');
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
{
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import Index from './Index';
|
||||
import AgentReports from './AgentReports';
|
||||
import LabelReports from './LabelReports';
|
||||
import InboxReports from './InboxReports';
|
||||
import TeamReports from './TeamReports';
|
||||
import CsatResponses from './CsatResponses';
|
||||
import LiveReports from './LiveReports';
|
||||
import SettingsContent from '../Wrapper';
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
|
||||
const SettingsContent = () => import('../Wrapper.vue');
|
||||
const Index = () => import('./Index.vue');
|
||||
const AgentReports = () => import('./AgentReports.vue');
|
||||
const LabelReports = () => import('./LabelReports.vue');
|
||||
const InboxReports = () => import('./InboxReports.vue');
|
||||
const TeamReports = () => import('./TeamReports.vue');
|
||||
const CsatResponses = () => import('./CsatResponses.vue');
|
||||
const LiveReports = () => import('./LiveReports.vue');
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
{
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
/* eslint arrow-body-style: 0 */
|
||||
import SettingsContent from '../Wrapper';
|
||||
import TeamsHome from './Index';
|
||||
import CreateStepWrap from './Create/Index';
|
||||
import EditStepWrap from './Edit/Index';
|
||||
import CreateTeam from './Create/CreateTeam';
|
||||
import EditTeam from './Edit/EditTeam';
|
||||
import AddAgents from './Create/AddAgents';
|
||||
import EditAgents from './Edit/EditAgents';
|
||||
import FinishSetup from './FinishSetup';
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
|
||||
const CreateStepWrap = () => import('./Create/Index.vue');
|
||||
const EditStepWrap = () => import('./Edit/Index.vue');
|
||||
const CreateTeam = () => import('./Create/CreateTeam.vue');
|
||||
const EditTeam = () => import('./Edit/EditTeam.vue');
|
||||
const AddAgents = () => import('./Create/AddAgents.vue');
|
||||
const EditAgents = () => import('./Edit/EditAgents.vue');
|
||||
const FinishSetup = () => import('./FinishSetup.vue');
|
||||
const SettingsContent = () => import('../Wrapper.vue');
|
||||
const TeamsHome = () => import('./Index.vue');
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
{
|
||||
|
||||
@@ -160,10 +160,10 @@ const actions = {
|
||||
});
|
||||
},
|
||||
|
||||
async setActiveChat({ commit, dispatch }, { data, after }) {
|
||||
async setActiveChat({ commit, dispatch }, { data, after, force = false }) {
|
||||
commit(types.SET_CURRENT_CHAT_WINDOW, data);
|
||||
commit(types.CLEAR_ALL_MESSAGES_LOADED);
|
||||
if (data.dataFetched === undefined) {
|
||||
if (data.dataFetched === undefined || force) {
|
||||
try {
|
||||
await dispatch('fetchPreviousMessages', {
|
||||
after,
|
||||
|
||||
@@ -2,6 +2,7 @@ import PortalAPI from 'dashboard/api/helpCenter/portals';
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
import { types } from './mutations';
|
||||
const portalAPIs = new PortalAPI();
|
||||
|
||||
export const actions = {
|
||||
index: async ({ commit }) => {
|
||||
try {
|
||||
@@ -89,6 +90,23 @@ export const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
deleteLogo: async ({ commit }, { portalSlug }) => {
|
||||
commit(types.SET_HELP_PORTAL_UI_FLAG, {
|
||||
uiFlags: { isUpdating: true },
|
||||
portalSlug,
|
||||
});
|
||||
try {
|
||||
await portalAPIs.deleteLogo(portalSlug);
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(types.SET_HELP_PORTAL_UI_FLAG, {
|
||||
uiFlags: { isUpdating: false },
|
||||
portalSlug,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
updatePortal: async ({ commit }, portal) => {
|
||||
commit(types.UPDATE_PORTAL_ENTRY, portal);
|
||||
},
|
||||
|
||||
@@ -2,27 +2,21 @@
|
||||
<div class="hidden lg:block flex-1 py-6 scroll-mt-24 pl-4">
|
||||
<div v-if="rows.length > 0" class="sticky top-24 py-2 overflow-auto">
|
||||
<nav class="max-w-2xl">
|
||||
<h2
|
||||
id="on-this-page-title"
|
||||
class="text-slate-800 pl-6 dark:text-slate-50 font-semibold tracking-wide py-3 leading-7 border-l-2 border-solid border-slate-100 dark:border-slate-800"
|
||||
:class="{
|
||||
'!border-slate-400 dark:!border-slate-100': !currentSlug,
|
||||
}"
|
||||
<ol
|
||||
role="list"
|
||||
class="flex flex-col gap-2 text-base border-l-2 border-solid border-slate-100 dark:border-slate-800"
|
||||
>
|
||||
{{ tocHeader }}
|
||||
</h2>
|
||||
<ol role="list" class="text-base">
|
||||
<li
|
||||
v-for="element in rows"
|
||||
:key="element.slug"
|
||||
class="leading-6 py-2 pl-6 border-l-2 border-solid"
|
||||
class="leading-6 border-l-2 relative -left-0.5 border-solid"
|
||||
:class="elementBorderStyles(element)"
|
||||
>
|
||||
<p :class="getClassName(element)">
|
||||
<p class="py-2 px-4" :class="getClassName(element)">
|
||||
<a
|
||||
:href="`#${element.slug}`"
|
||||
data-turbolinks="false"
|
||||
class="text-base cursor-pointer"
|
||||
class="font-medium text-sm tracking-[0.28px] cursor-pointer"
|
||||
:class="elementTextStyles(element)"
|
||||
>
|
||||
{{ element.title }}
|
||||
@@ -45,12 +39,10 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
currentSlug: window.location?.hash?.substring(1) || '',
|
||||
intersectionObserver: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
tocHeader() {
|
||||
return window.portalConfig.tocHeader;
|
||||
},
|
||||
h1Count() {
|
||||
return this.rows.filter(el => el.tag === 'h1').length;
|
||||
},
|
||||
@@ -59,10 +51,14 @@ export default {
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.initializeIntersectionObserver();
|
||||
window.addEventListener('hashchange', this.onURLHashChange);
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('hashchange', this.onURLHashChange);
|
||||
if (this.intersectionObserver) {
|
||||
this.intersectionObserver.disconnect();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getClassName(el) {
|
||||
@@ -85,18 +81,32 @@ export default {
|
||||
|
||||
return '';
|
||||
},
|
||||
initializeIntersectionObserver() {
|
||||
this.intersectionObserver = new IntersectionObserver(
|
||||
entries => {
|
||||
const currentSection = entries.find(entry => entry.isIntersecting);
|
||||
if (currentSection) {
|
||||
this.currentSlug = currentSection.target.id;
|
||||
}
|
||||
},
|
||||
{
|
||||
threshold: 0.25,
|
||||
rootMargin: '0px 0px -20% 0px',
|
||||
}
|
||||
);
|
||||
|
||||
this.rows.forEach(el => {
|
||||
const sectionElement = document.getElementById(el.slug);
|
||||
if (!sectionElement) return;
|
||||
this.intersectionObserver.observe(sectionElement);
|
||||
});
|
||||
},
|
||||
onURLHashChange() {
|
||||
this.currentSlug = window.location?.hash?.substring(1) || '';
|
||||
},
|
||||
isElementActive(el) {
|
||||
return this.currentSlug === el.slug;
|
||||
},
|
||||
tocHeaderBorderStyles() {
|
||||
if (this.currentSlug === '') {
|
||||
return 'border-slate-400 dark:border-slate-100';
|
||||
}
|
||||
return 'border-slate-100 dark:border-slate-800';
|
||||
},
|
||||
elementBorderStyles(el) {
|
||||
if (this.isElementActive(el)) {
|
||||
return 'border-slate-400 dark:border-slate-50 transition-colors duration-200';
|
||||
@@ -105,7 +115,7 @@ export default {
|
||||
},
|
||||
elementTextStyles(el) {
|
||||
if (this.isElementActive(el)) {
|
||||
return 'font-semibold text-slate-900 dark:text-slate-25';
|
||||
return 'text-slate-900 dark:text-slate-25 transition-colors duration-200';
|
||||
}
|
||||
return 'text-slate-700 dark:text-slate-100';
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import Vue from 'vue';
|
||||
|
||||
import PublicArticleSearch from './components/PublicArticleSearch.vue';
|
||||
import TableOfContents from './components/TableOfContents.vue';
|
||||
import { initializeTheme } from './portalThemeHelper.js';
|
||||
|
||||
export const getHeadingsfromTheArticle = () => {
|
||||
const rows = [];
|
||||
@@ -21,82 +22,42 @@ export const getHeadingsfromTheArticle = () => {
|
||||
return rows;
|
||||
};
|
||||
|
||||
export const generatePortalBgColor = (portalColor, theme) => {
|
||||
const baseColor = theme === 'dark' ? 'black' : 'white';
|
||||
return `color-mix(in srgb, ${portalColor} 20%, ${baseColor})`;
|
||||
};
|
||||
export const openExternalLinksInNewTab = () => {
|
||||
const { customDomain, hostURL } = window.portalConfig;
|
||||
const isSameHost =
|
||||
window.location.href.includes(customDomain) ||
|
||||
window.location.href.includes(hostURL);
|
||||
|
||||
export const generatePortalBg = (portalColor, theme) => {
|
||||
const bgImage = theme === 'dark' ? 'hexagon-dark.svg' : 'hexagon-light.svg';
|
||||
return `background: url(/assets/images/hc/${bgImage}) ${generatePortalBgColor(
|
||||
portalColor,
|
||||
theme
|
||||
)}`;
|
||||
};
|
||||
// Modify external links only on articles page
|
||||
const isOnArticlePage =
|
||||
isSameHost && document.querySelector('#cw-article-content') !== null;
|
||||
|
||||
export const generateGradientToBottom = theme => {
|
||||
return `background-image: linear-gradient(to bottom, transparent, ${
|
||||
theme === 'dark' ? '#151718' : 'white'
|
||||
})`;
|
||||
};
|
||||
document.addEventListener('click', function (event) {
|
||||
if (!isOnArticlePage) return;
|
||||
|
||||
export const setPortalStyles = theme => {
|
||||
const portalColor = window.portalConfig.portalColor;
|
||||
const portalBgDiv = document.querySelector('#portal-bg');
|
||||
const portalBgGradientDiv = document.querySelector('#portal-bg-gradient');
|
||||
// Some of the links come wrapped in strong tag through prosemirror
|
||||
|
||||
if (portalBgDiv) {
|
||||
// Set background for #portal-bg
|
||||
portalBgDiv.setAttribute('style', generatePortalBg(portalColor, theme));
|
||||
}
|
||||
const isTagAnchor = event.target.tagName === 'A';
|
||||
const isParentTagAnchor =
|
||||
event.target.tagName === 'STRONG' &&
|
||||
event.target.parentNode.tagName === 'A';
|
||||
|
||||
if (portalBgGradientDiv) {
|
||||
// Set gradient background for #portal-bg-gradient
|
||||
portalBgGradientDiv.setAttribute('style', generateGradientToBottom(theme));
|
||||
}
|
||||
};
|
||||
if (isTagAnchor || isParentTagAnchor) {
|
||||
const link = isTagAnchor ? event.target : event.target.parentNode;
|
||||
|
||||
export const setPortalClass = theme => {
|
||||
const portalDiv = document.querySelector('#portal');
|
||||
portalDiv.classList.remove('light', 'dark');
|
||||
if (!portalDiv) return;
|
||||
portalDiv.classList.add(theme);
|
||||
};
|
||||
const isInternalLink =
|
||||
link.hostname === window.location.hostname ||
|
||||
link.href.includes(customDomain) ||
|
||||
link.href.includes(hostURL);
|
||||
|
||||
export const updateThemeStyles = theme => {
|
||||
setPortalStyles(theme);
|
||||
setPortalClass(theme);
|
||||
};
|
||||
|
||||
export const toggleAppearanceDropdown = () => {
|
||||
const dropdown = document.getElementById('appearance-dropdown');
|
||||
if (!dropdown) return;
|
||||
dropdown.style.display =
|
||||
dropdown.style.display === 'none' || !dropdown.style.display
|
||||
? 'flex'
|
||||
: 'none';
|
||||
};
|
||||
|
||||
export const updateURLParameter = (param, paramVal) => {
|
||||
const urlObj = new URL(window.location);
|
||||
urlObj.searchParams.set(param, paramVal);
|
||||
return urlObj.toString();
|
||||
};
|
||||
|
||||
export const removeURLParameter = parameter => {
|
||||
const urlObj = new URL(window.location);
|
||||
urlObj.searchParams.delete(parameter);
|
||||
return urlObj.toString();
|
||||
};
|
||||
|
||||
export const switchTheme = theme => {
|
||||
updateThemeStyles(theme);
|
||||
const newUrl =
|
||||
theme !== 'system'
|
||||
? updateURLParameter('theme', theme)
|
||||
: removeURLParameter('theme');
|
||||
window.location.href = newUrl;
|
||||
toggleAppearanceDropdown();
|
||||
if (!isInternalLink) {
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer'; // Security and performance benefits
|
||||
// Prevent default if you want to stop the link from opening in the current tab
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const InitializationHelpers = {
|
||||
@@ -146,52 +107,17 @@ export const InitializationHelpers = {
|
||||
});
|
||||
},
|
||||
|
||||
initializeTheme: () => {
|
||||
const mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const getThemePreference = () =>
|
||||
mediaQueryList.matches ? 'dark' : 'light';
|
||||
const themeFromServer = window.portalConfig.theme;
|
||||
if (themeFromServer === 'system') {
|
||||
// Handle dynamic theme changes for system theme
|
||||
mediaQueryList.addEventListener('change', event => {
|
||||
const newTheme = event.matches ? 'dark' : 'light';
|
||||
updateThemeStyles(newTheme);
|
||||
});
|
||||
const themePreference = getThemePreference();
|
||||
updateThemeStyles(themePreference);
|
||||
}
|
||||
},
|
||||
|
||||
initializeToggleButton: () => {
|
||||
const toggleButton = document.getElementById('toggle-appearance');
|
||||
if (toggleButton) {
|
||||
toggleButton.addEventListener('click', toggleAppearanceDropdown);
|
||||
}
|
||||
},
|
||||
|
||||
initializeThemeSwitchButtons: () => {
|
||||
const appearanceDropdown = document.getElementById('appearance-dropdown');
|
||||
if (!appearanceDropdown) return;
|
||||
appearanceDropdown.addEventListener('click', event => {
|
||||
const target = event.target.closest('button[data-theme]');
|
||||
|
||||
if (target) {
|
||||
const theme = target.getAttribute('data-theme');
|
||||
switchTheme(theme);
|
||||
}
|
||||
});
|
||||
},
|
||||
initializeThemesInPortal: initializeTheme,
|
||||
|
||||
initialize: () => {
|
||||
openExternalLinksInNewTab();
|
||||
if (window.portalConfig.isPlainLayoutEnabled === 'true') {
|
||||
InitializationHelpers.appendPlainParamToURLs();
|
||||
} else {
|
||||
InitializationHelpers.initializeThemesInPortal();
|
||||
InitializationHelpers.navigateToLocalePage();
|
||||
InitializationHelpers.initializeSearch();
|
||||
InitializationHelpers.initializeTableOfContents();
|
||||
InitializationHelpers.initializeTheme();
|
||||
InitializationHelpers.initializeToggleButton();
|
||||
InitializationHelpers.initializeThemeSwitchButtons();
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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,15 +1,4 @@
|
||||
import {
|
||||
InitializationHelpers,
|
||||
generatePortalBgColor,
|
||||
generatePortalBg,
|
||||
generateGradientToBottom,
|
||||
setPortalStyles,
|
||||
setPortalClass,
|
||||
updateThemeStyles,
|
||||
toggleAppearanceDropdown,
|
||||
updateURLParameter,
|
||||
removeURLParameter,
|
||||
} from '../portalHelpers';
|
||||
import { InitializationHelpers } from '../portalHelpers';
|
||||
|
||||
describe('#navigateToLocalePage', () => {
|
||||
it('returns correct cookie name', () => {
|
||||
@@ -32,303 +21,3 @@ describe('#navigateToLocalePage', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Theme Functions', () => {
|
||||
describe('#generatePortalBgColor', () => {
|
||||
it('returns mixed color for dark theme', () => {
|
||||
const result = generatePortalBgColor('#FF5733', 'dark');
|
||||
expect(result).toBe('color-mix(in srgb, #FF5733 20%, black)');
|
||||
});
|
||||
|
||||
it('returns mixed color for light theme', () => {
|
||||
const result = generatePortalBgColor('#FF5733', 'light');
|
||||
expect(result).toBe('color-mix(in srgb, #FF5733 20%, white)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#generatePortalBg', () => {
|
||||
it('returns background for dark theme', () => {
|
||||
const result = generatePortalBg('#FF5733', 'dark');
|
||||
expect(result).toBe(
|
||||
'background: url(/assets/images/hc/hexagon-dark.svg) color-mix(in srgb, #FF5733 20%, black)'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns background for light theme', () => {
|
||||
const result = generatePortalBg('#FF5733', 'light');
|
||||
expect(result).toBe(
|
||||
'background: url(/assets/images/hc/hexagon-light.svg) color-mix(in srgb, #FF5733 20%, white)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#generateGradientToBottom', () => {
|
||||
it('returns gradient for dark theme', () => {
|
||||
const result = generateGradientToBottom('dark');
|
||||
expect(result).toBe(
|
||||
'background-image: linear-gradient(to bottom, transparent, #151718)'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns gradient for light theme', () => {
|
||||
const result = generateGradientToBottom('light');
|
||||
expect(result).toBe(
|
||||
'background-image: linear-gradient(to bottom, transparent, white)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#setPortalStyles', () => {
|
||||
let mockPortalBgDiv;
|
||||
let mockPortalBgGradientDiv;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mocking portal background div
|
||||
mockPortalBgDiv = document.createElement('div');
|
||||
mockPortalBgDiv.id = 'portal-bg';
|
||||
document.body.appendChild(mockPortalBgDiv);
|
||||
|
||||
// Mocking portal background gradient div
|
||||
mockPortalBgGradientDiv = document.createElement('div');
|
||||
mockPortalBgGradientDiv.id = 'portal-bg-gradient';
|
||||
document.body.appendChild(mockPortalBgGradientDiv);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('sets styles for portal background based on theme', () => {
|
||||
window.portalConfig = { portalColor: '#FF5733' };
|
||||
|
||||
setPortalStyles('dark');
|
||||
const expectedPortalBgStyle =
|
||||
'background: url(/assets/images/hc/hexagon-dark.svg) color-mix(in srgb, #FF5733 20%, black)';
|
||||
const expectedGradientStyle =
|
||||
'background-image: linear-gradient(to bottom, transparent, #151718)';
|
||||
|
||||
expect(mockPortalBgDiv.getAttribute('style')).toBe(expectedPortalBgStyle);
|
||||
expect(mockPortalBgGradientDiv.getAttribute('style')).toBe(
|
||||
expectedGradientStyle
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#setPortalClass', () => {
|
||||
let mockPortalDiv;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mocking portal div
|
||||
mockPortalDiv = document.createElement('div');
|
||||
mockPortalDiv.id = 'portal';
|
||||
mockPortalDiv.classList.add('light');
|
||||
document.body.appendChild(mockPortalDiv);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('sets portal class to "dark" based on theme', () => {
|
||||
setPortalClass('dark');
|
||||
expect(mockPortalDiv.classList.contains('dark')).toBe(true);
|
||||
expect(mockPortalDiv.classList.contains('light')).toBe(false);
|
||||
});
|
||||
|
||||
it('sets portal class to "light" based on theme', () => {
|
||||
setPortalClass('light');
|
||||
expect(mockPortalDiv.classList.contains('light')).toBe(true);
|
||||
expect(mockPortalDiv.classList.contains('dark')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleAppearanceDropdown', () => {
|
||||
it('sets dropdown display to flex if initially none', () => {
|
||||
document.body.innerHTML = `<div id="appearance-dropdown" style="display: none;"></div>`;
|
||||
toggleAppearanceDropdown();
|
||||
const dropdown = document.getElementById('appearance-dropdown');
|
||||
expect(dropdown.style.display).toBe('flex');
|
||||
});
|
||||
|
||||
it('sets dropdown display to none if initially flex', () => {
|
||||
document.body.innerHTML = `<div id="appearance-dropdown" style="display: flex;"></div>`;
|
||||
toggleAppearanceDropdown();
|
||||
const dropdown = document.getElementById('appearance-dropdown');
|
||||
expect(dropdown.style.display).toBe('none');
|
||||
});
|
||||
|
||||
it('does nothing if dropdown element does not exist', () => {
|
||||
document.body.innerHTML = ``;
|
||||
expect(() => toggleAppearanceDropdown()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateURLParameter', () => {
|
||||
it('updates a given parameter with a new value', () => {
|
||||
const originalUrl = 'http://example.com?param=oldValue';
|
||||
delete window.location;
|
||||
window.location = new URL(originalUrl);
|
||||
|
||||
const updatedUrl = updateURLParameter('param', 'newValue');
|
||||
expect(updatedUrl).toContain('param=newValue');
|
||||
});
|
||||
|
||||
it('adds a new parameter if it does not exist', () => {
|
||||
const originalUrl = 'http://example.com';
|
||||
delete window.location;
|
||||
window.location = new URL(originalUrl);
|
||||
|
||||
const updatedUrl = updateURLParameter('newParam', 'value');
|
||||
expect(updatedUrl).toContain('newParam=value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeURLParameter', () => {
|
||||
it('removes an existing parameter', () => {
|
||||
const originalUrl = 'http://example.com?param=value';
|
||||
delete window.location;
|
||||
window.location = new URL(originalUrl);
|
||||
|
||||
const updatedUrl = removeURLParameter('param');
|
||||
expect(updatedUrl).not.toContain('param=value');
|
||||
});
|
||||
|
||||
it('does nothing if the parameter does not exist', () => {
|
||||
const originalUrl = 'http://example.com/';
|
||||
delete window.location;
|
||||
window.location = new URL(originalUrl);
|
||||
|
||||
const updatedUrl = removeURLParameter('param');
|
||||
expect(updatedUrl).toBe(originalUrl);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#updateThemeStyles', () => {
|
||||
let mockPortalDiv;
|
||||
let mockPortalBgDiv;
|
||||
let mockPortalBgGradientDiv;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mocking portal div
|
||||
mockPortalDiv = document.createElement('div');
|
||||
mockPortalDiv.id = 'portal';
|
||||
document.body.appendChild(mockPortalDiv);
|
||||
|
||||
// Mocking portal background div
|
||||
mockPortalBgDiv = document.createElement('div');
|
||||
mockPortalBgDiv.id = 'portal-bg';
|
||||
document.body.appendChild(mockPortalBgDiv);
|
||||
|
||||
// Mocking portal background gradient div
|
||||
mockPortalBgGradientDiv = document.createElement('div');
|
||||
mockPortalBgGradientDiv.id = 'portal-bg-gradient';
|
||||
document.body.appendChild(mockPortalBgGradientDiv);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('updates theme styles based on theme', () => {
|
||||
window.portalConfig = { portalColor: '#FF5733' };
|
||||
|
||||
updateThemeStyles('dark');
|
||||
|
||||
const expectedPortalBgStyle =
|
||||
'background: url(/assets/images/hc/hexagon-dark.svg) color-mix(in srgb, #FF5733 20%, black)';
|
||||
const expectedGradientStyle =
|
||||
'background-image: linear-gradient(to bottom, transparent, #151718)';
|
||||
|
||||
expect(mockPortalDiv.classList.contains('dark')).toBe(true);
|
||||
expect(mockPortalBgDiv.getAttribute('style')).toBe(expectedPortalBgStyle);
|
||||
expect(mockPortalBgGradientDiv.getAttribute('style')).toBe(
|
||||
expectedGradientStyle
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#initializeTheme', () => {
|
||||
let mockPortalDiv;
|
||||
|
||||
beforeEach(() => {
|
||||
mockPortalDiv = document.createElement('div');
|
||||
mockPortalDiv.id = 'portal';
|
||||
document.body.appendChild(mockPortalDiv);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('updates theme based on system preferences', () => {
|
||||
const mediaQueryList = {
|
||||
matches: true,
|
||||
addEventListener: jest.fn(),
|
||||
};
|
||||
window.matchMedia = jest.fn().mockReturnValue(mediaQueryList);
|
||||
window.portalConfig = { theme: 'system' };
|
||||
|
||||
InitializationHelpers.initializeTheme();
|
||||
|
||||
expect(mediaQueryList.addEventListener).toBeCalledWith(
|
||||
'change',
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(mockPortalDiv.classList.contains('dark')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not update theme if themeFromServer is not "system"', () => {
|
||||
const mediaQueryList = {
|
||||
matches: true,
|
||||
addEventListener: jest.fn(),
|
||||
};
|
||||
window.matchMedia = jest.fn().mockReturnValue(mediaQueryList);
|
||||
window.portalConfig = { theme: 'dark' };
|
||||
|
||||
InitializationHelpers.initializeTheme();
|
||||
|
||||
expect(mediaQueryList.addEventListener).not.toBeCalled();
|
||||
expect(mockPortalDiv.classList.contains('dark')).toBe(false);
|
||||
expect(mockPortalDiv.classList.contains('light')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initializeToggleButton', () => {
|
||||
it('adds a click listener to the toggle button', () => {
|
||||
document.body.innerHTML = `<button id="toggle-appearance"></button>`;
|
||||
InitializationHelpers.initializeToggleButton();
|
||||
const toggleButton = document.getElementById('toggle-appearance');
|
||||
expect(toggleButton.onclick).toBeDefined();
|
||||
});
|
||||
|
||||
it('does nothing if the toggle button is not present', () => {
|
||||
document.body.innerHTML = ``;
|
||||
expect(() =>
|
||||
InitializationHelpers.initializeToggleButton()
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('initializeThemeSwitchButtons', () => {
|
||||
it('adds click listeners to theme switch buttons', () => {
|
||||
document.body.innerHTML = `<div id="appearance-dropdown"><button data-theme="dark"></button><button data-theme="light"></button></div>`;
|
||||
InitializationHelpers.initializeThemeSwitchButtons();
|
||||
const buttons = document.querySelectorAll('button[data-theme]');
|
||||
buttons.forEach(button => expect(button.onclick).toBeDefined());
|
||||
});
|
||||
|
||||
it('does nothing if theme switch buttons are not present', () => {
|
||||
document.body.innerHTML = ``;
|
||||
expect(() =>
|
||||
InitializationHelpers.initializeThemeSwitchButtons()
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('does nothing if appearance-dropdown is not present', () => {
|
||||
document.body.innerHTML = ``;
|
||||
expect(() =>
|
||||
InitializationHelpers.initializeThemeSwitchButtons()
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import {
|
||||
setPortalHoverColor,
|
||||
removeQueryParamsFromUrl,
|
||||
updateThemeInHeader,
|
||||
switchTheme,
|
||||
initializeThemeSwitchButtons,
|
||||
initializeToggleButton,
|
||||
initializeMediaQueryListener,
|
||||
initializeTheme,
|
||||
} from '../portalThemeHelper.js';
|
||||
import { adjustColorForContrast } from '../../shared/helpers/colorHelper.js';
|
||||
|
||||
describe('portalThemeHelper', () => {
|
||||
let themeToggleButton;
|
||||
let appearanceDropdown;
|
||||
|
||||
beforeEach(() => {
|
||||
themeToggleButton = document.createElement('div');
|
||||
themeToggleButton.id = 'toggle-appearance';
|
||||
document.body.appendChild(themeToggleButton);
|
||||
|
||||
appearanceDropdown = document.createElement('div');
|
||||
appearanceDropdown.id = 'appearance-dropdown';
|
||||
document.body.appendChild(appearanceDropdown);
|
||||
|
||||
window.matchMedia = jest.fn().mockImplementation(query => ({
|
||||
matches: query === '(prefers-color-scheme: dark)',
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
}));
|
||||
|
||||
window.portalConfig = { portalColor: '#ff5733' };
|
||||
document.documentElement.style.setProperty = jest.fn();
|
||||
document.documentElement.classList.remove('dark', 'light');
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
themeToggleButton.remove();
|
||||
appearanceDropdown.remove();
|
||||
delete window.portalConfig;
|
||||
document.documentElement.style.setProperty.mockRestore();
|
||||
document.documentElement.classList.remove('dark', 'light');
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('#setPortalHoverColor', () => {
|
||||
it('should apply dark hover color in dark theme', () => {
|
||||
const hoverColor = adjustColorForContrast('#ff5733', '#151718');
|
||||
setPortalHoverColor('dark');
|
||||
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
|
||||
'--dynamic-hover-color',
|
||||
hoverColor
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply light hover color in light theme', () => {
|
||||
const hoverColor = adjustColorForContrast('#ff5733', '#ffffff');
|
||||
setPortalHoverColor('light');
|
||||
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
|
||||
'--dynamic-hover-color',
|
||||
hoverColor
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#removeQueryParamsFromUrl', () => {
|
||||
let originalLocation;
|
||||
|
||||
beforeEach(() => {
|
||||
originalLocation = window.location;
|
||||
delete window.location;
|
||||
window.location = new URL('http://localhost:3000/');
|
||||
window.history.replaceState = jest.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.location = originalLocation;
|
||||
});
|
||||
|
||||
it('should not remove query params if theme is not in the URL', () => {
|
||||
removeQueryParamsFromUrl();
|
||||
expect(window.history.replaceState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should remove theme query param from the URL', () => {
|
||||
window.location = new URL(
|
||||
'http://localhost:3000/?theme=light&show_plain_layout=true'
|
||||
);
|
||||
removeQueryParamsFromUrl('theme');
|
||||
expect(window.history.replaceState).toHaveBeenCalledWith(
|
||||
{},
|
||||
'',
|
||||
'http://localhost:3000/?show_plain_layout=true'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#updateThemeInHeader', () => {
|
||||
beforeEach(() => {
|
||||
themeToggleButton.innerHTML = `
|
||||
<div class="theme-button" data-theme="light"></div>
|
||||
<div class="theme-button" data-theme="dark"></div>
|
||||
<div class="theme-button" data-theme="system"></div>
|
||||
`;
|
||||
});
|
||||
|
||||
it('should not update header if theme toggle button is not found', () => {
|
||||
themeToggleButton.remove();
|
||||
updateThemeInHeader('light');
|
||||
expect(document.querySelector('.theme-button')).toBeNull();
|
||||
});
|
||||
|
||||
it('should show the theme button for the selected theme', () => {
|
||||
updateThemeInHeader('light');
|
||||
const lightButton = themeToggleButton.querySelector(
|
||||
'.theme-button[data-theme="light"]'
|
||||
);
|
||||
expect(lightButton.classList).toContain('flex');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#switchTheme', () => {
|
||||
it('should set theme to system theme and update classes', () => {
|
||||
window.matchMedia = jest.fn().mockReturnValue({ matches: true });
|
||||
switchTheme('system');
|
||||
expect(localStorage.theme).toBeUndefined();
|
||||
expect(document.documentElement.classList).toContain('dark');
|
||||
});
|
||||
|
||||
it('should set theme to light theme and update classes', () => {
|
||||
switchTheme('light');
|
||||
expect(localStorage.theme).toBe('light');
|
||||
expect(document.documentElement.classList).toContain('light');
|
||||
});
|
||||
|
||||
it('should set theme to dark theme and update classes', () => {
|
||||
switchTheme('dark');
|
||||
expect(localStorage.theme).toBe('dark');
|
||||
expect(document.documentElement.classList).toContain('dark');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#initializeThemeSwitchButtons', () => {
|
||||
beforeEach(() => {
|
||||
appearanceDropdown.innerHTML = `
|
||||
<button data-theme="light"><span class="check-mark-icon light-theme"></span></button>
|
||||
<button data-theme="dark"><span class="check-mark-icon dark-theme"></span></button>
|
||||
<button data-theme="system"><span class="check-mark-icon system-theme"></span></button>
|
||||
`;
|
||||
});
|
||||
|
||||
it('does nothing if the appearance dropdown is not found', () => {
|
||||
appearanceDropdown.remove();
|
||||
expect(appearanceDropdown.dataset.currentTheme).toBeUndefined();
|
||||
});
|
||||
it('should set current theme to system if no theme in localStorage', () => {
|
||||
localStorage.removeItem('theme');
|
||||
initializeThemeSwitchButtons();
|
||||
expect(appearanceDropdown.dataset.currentTheme).toBe('system');
|
||||
});
|
||||
|
||||
it('sets the current theme to the light theme', () => {
|
||||
localStorage.theme = 'light';
|
||||
appearanceDropdown.dataset.currentTheme = 'light';
|
||||
initializeThemeSwitchButtons();
|
||||
expect(appearanceDropdown.dataset.currentTheme).toBe('light');
|
||||
});
|
||||
|
||||
it('sets the current theme to the dark theme', () => {
|
||||
localStorage.theme = 'dark';
|
||||
appearanceDropdown.dataset.currentTheme = 'dark';
|
||||
initializeThemeSwitchButtons();
|
||||
expect(appearanceDropdown.dataset.currentTheme).toBe('dark');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#initializeToggleButton', () => {
|
||||
it('does nothing if the theme toggle button is not found', () => {
|
||||
themeToggleButton.remove();
|
||||
initializeToggleButton();
|
||||
expect(appearanceDropdown.style.display).toBe('');
|
||||
});
|
||||
|
||||
it('toggles the appearance dropdown show/hide', () => {
|
||||
themeToggleButton.click();
|
||||
appearanceDropdown.style.display = 'flex';
|
||||
expect(appearanceDropdown.style.display).toBe('flex');
|
||||
themeToggleButton.click();
|
||||
appearanceDropdown.style.display = 'none';
|
||||
expect(appearanceDropdown.style.display).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#initializeMediaQueryListener', () => {
|
||||
let mediaQuery;
|
||||
|
||||
beforeEach(() => {
|
||||
mediaQuery = {
|
||||
addEventListener: jest.fn(),
|
||||
matches: false,
|
||||
};
|
||||
window.matchMedia = jest.fn().mockReturnValue(mediaQuery);
|
||||
});
|
||||
|
||||
it('adds a listener to the media query', () => {
|
||||
initializeMediaQueryListener();
|
||||
expect(window.matchMedia).toHaveBeenCalledWith(
|
||||
'(prefers-color-scheme: dark)'
|
||||
);
|
||||
expect(mediaQuery.addEventListener).toHaveBeenCalledWith(
|
||||
'change',
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('does not switch theme if local storage theme is light or dark', () => {
|
||||
localStorage.theme = 'light';
|
||||
initializeMediaQueryListener();
|
||||
mediaQuery.matches = true;
|
||||
mediaQuery.addEventListener.mock.calls[0][1]();
|
||||
expect(localStorage.theme).toBe('light');
|
||||
});
|
||||
|
||||
it('switches to dark theme if system preference changes to dark and no theme is set in local storage', () => {
|
||||
localStorage.removeItem('theme');
|
||||
initializeMediaQueryListener();
|
||||
mediaQuery.matches = true;
|
||||
mediaQuery.addEventListener.mock.calls[0][1]();
|
||||
expect(document.documentElement.classList).toContain('dark');
|
||||
});
|
||||
|
||||
it('switches to light theme if system preference changes to light and no theme is set in local storage', () => {
|
||||
localStorage.removeItem('theme');
|
||||
initializeMediaQueryListener();
|
||||
mediaQuery.matches = false;
|
||||
mediaQuery.addEventListener.mock.calls[0][1]();
|
||||
expect(document.documentElement.classList).toContain('light');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#initializeTheme', () => {
|
||||
it('should not initialize theme if plain layout is enabled', () => {
|
||||
window.portalConfig.isPlainLayoutEnabled = 'true';
|
||||
initializeTheme();
|
||||
expect(localStorage.theme).toBeUndefined();
|
||||
expect(document.documentElement.classList).not.toContain('light');
|
||||
expect(document.documentElement.classList).not.toContain('dark');
|
||||
});
|
||||
|
||||
it('sets the theme to the system theme', () => {
|
||||
initializeTheme();
|
||||
expect(localStorage.theme).toBeUndefined();
|
||||
const prefersDarkMode = window.matchMedia(
|
||||
'(prefers-color-scheme: dark)'
|
||||
).matches;
|
||||
expect(document.documentElement.classList.contains('light')).toBe(
|
||||
!prefersDarkMode
|
||||
);
|
||||
});
|
||||
|
||||
it('sets the theme to the light theme', () => {
|
||||
localStorage.theme = 'light';
|
||||
document.documentElement.classList.add('light');
|
||||
initializeTheme();
|
||||
expect(localStorage.theme).toBe('light');
|
||||
expect(document.documentElement.classList.contains('light')).toBe(true);
|
||||
});
|
||||
|
||||
it('sets the theme to the dark theme', () => {
|
||||
localStorage.theme = 'dark';
|
||||
document.documentElement.classList.add('dark');
|
||||
initializeTheme();
|
||||
expect(localStorage.theme).toBe('dark');
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@
|
||||
<resizable-text-area
|
||||
v-else
|
||||
v-model="greetingsMessage"
|
||||
rows="4"
|
||||
:rows="4"
|
||||
type="text"
|
||||
class="greetings--textarea"
|
||||
:label="label"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<textarea
|
||||
ref="textarea"
|
||||
:placeholder="placeholder"
|
||||
:rows="rows"
|
||||
:value="value"
|
||||
@input="onInput"
|
||||
@focus="onFocus"
|
||||
@@ -36,6 +37,10 @@ export default {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
rows: {
|
||||
type: Number,
|
||||
default: 2,
|
||||
},
|
||||
// add this as a prop, so that we won't have to include uiSettingsMixin
|
||||
sendWithSignature: {
|
||||
type: Boolean,
|
||||
|
||||
@@ -55,6 +55,8 @@ export const ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP =
|
||||
'audio/mpeg, audio/opus, audio/ogg, audio/amr,' +
|
||||
'video/mp4,' +
|
||||
'application/pdf,';
|
||||
// https://developers.line.biz/en/reference/messaging-api/#image-message, https://developers.line.biz/en/reference/messaging-api/#video-message
|
||||
export const ALLOWED_FILE_TYPES_FOR_LINE = 'image/png, image/jpeg,video/mp4';
|
||||
|
||||
export const CSAT_RATINGS = [
|
||||
{
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { toHex, mix, getLuminance, getContrast } from 'color2k';
|
||||
|
||||
export const isWidgetColorLighter = color => {
|
||||
const colorToCheck = color.replace('#', '');
|
||||
const c_r = parseInt(colorToCheck.substr(0, 2), 16);
|
||||
@@ -6,3 +8,21 @@ export const isWidgetColorLighter = color => {
|
||||
const brightness = (c_r * 299 + c_g * 587 + c_b * 114) / 1000;
|
||||
return brightness > 225;
|
||||
};
|
||||
|
||||
export const adjustColorForContrast = (color, backgroundColor) => {
|
||||
const targetRatio = 3.1;
|
||||
const MAX_ITERATIONS = 20;
|
||||
let adjustedColor = color;
|
||||
|
||||
for (let iteration = 0; iteration < MAX_ITERATIONS; iteration += 1) {
|
||||
const currentRatio = getContrast(adjustedColor, backgroundColor);
|
||||
if (currentRatio >= targetRatio) {
|
||||
break;
|
||||
}
|
||||
const adjustmentDirection =
|
||||
getLuminance(adjustedColor) < 0.5 ? '#fff' : '#151718';
|
||||
adjustedColor = mix(adjustedColor, adjustmentDirection, 0.05);
|
||||
}
|
||||
|
||||
return toHex(adjustedColor);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { isWidgetColorLighter } from 'shared/helpers/colorHelper';
|
||||
import { toHex, getContrast } from 'color2k';
|
||||
import {
|
||||
isWidgetColorLighter,
|
||||
adjustColorForContrast,
|
||||
} from 'shared/helpers/colorHelper';
|
||||
|
||||
describe('#isWidgetColorLighter', () => {
|
||||
it('returns true if color is lighter', () => {
|
||||
@@ -8,3 +12,56 @@ describe('#isWidgetColorLighter', () => {
|
||||
expect(isWidgetColorLighter('#000000')).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#adjustColorForContrast', () => {
|
||||
const targetRatio = 3.1;
|
||||
|
||||
const getContrastRatio = (color1, color2) => {
|
||||
// getContrast from 'color2k'
|
||||
return getContrast(color1, color2);
|
||||
};
|
||||
|
||||
it('adjusts a color to meet the contrast ratio against a light background', () => {
|
||||
const color = '#ff0000';
|
||||
const backgroundColor = '#ffffff';
|
||||
const adjustedColor = adjustColorForContrast(color, backgroundColor);
|
||||
const ratio = getContrastRatio(adjustedColor, backgroundColor);
|
||||
|
||||
expect(ratio).toBeGreaterThanOrEqual(targetRatio);
|
||||
});
|
||||
|
||||
it('adjusts a color to meet the contrast ratio against a dark background', () => {
|
||||
const color = '#00ff00';
|
||||
const backgroundColor = '#000000';
|
||||
const adjustedColor = adjustColorForContrast(color, backgroundColor);
|
||||
const ratio = getContrastRatio(adjustedColor, backgroundColor);
|
||||
|
||||
expect(ratio).toBeGreaterThanOrEqual(targetRatio);
|
||||
});
|
||||
|
||||
it('returns a string representation of the color', () => {
|
||||
const color = '#00ff00';
|
||||
const backgroundColor = '#000000';
|
||||
const adjustedColor = adjustColorForContrast(color, backgroundColor);
|
||||
|
||||
expect(typeof adjustedColor).toEqual('string');
|
||||
});
|
||||
|
||||
it('handles cases where the color already meets the contrast ratio', () => {
|
||||
const color = '#000000';
|
||||
const backgroundColor = '#ffffff';
|
||||
const adjustedColor = adjustColorForContrast(color, backgroundColor);
|
||||
const ratio = getContrastRatio(adjustedColor, backgroundColor);
|
||||
|
||||
expect(ratio).toBeGreaterThanOrEqual(targetRatio);
|
||||
expect(adjustedColor).toEqual(toHex(color));
|
||||
});
|
||||
|
||||
it('does not modify a color that already exceeds the contrast ratio', () => {
|
||||
const color = '#000000';
|
||||
const backgroundColor = '#ffffff';
|
||||
const adjustedColor = adjustColorForContrast(color, backgroundColor);
|
||||
|
||||
expect(adjustedColor).toEqual(toHex(color));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,6 +50,7 @@ export const getters = {
|
||||
get: $state => $state,
|
||||
isOnChatwootCloud: $state => $state.deploymentEnv === 'cloud',
|
||||
isACustomBrandedInstance: $state => $state.installationName !== 'Chatwoot',
|
||||
isAChatwootInstance: $state => $state.installationName === 'Chatwoot',
|
||||
};
|
||||
|
||||
export const actions = {};
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
<drag-wrapper
|
||||
class="space-y-2"
|
||||
direction="right"
|
||||
:disabled="!allowReplyTo"
|
||||
@dragged="toggleReply"
|
||||
>
|
||||
<AgentMessageBubble
|
||||
@@ -58,7 +57,6 @@
|
||||
</drag-wrapper>
|
||||
<div class="flex flex-col justify-end">
|
||||
<message-reply-button
|
||||
v-if="allowReplyTo"
|
||||
class="transition-opacity delay-75 opacity-0 group-hover:opacity-100 sm:opacity-0"
|
||||
@click="toggleReply"
|
||||
/>
|
||||
@@ -127,7 +125,6 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
hasImageError: false,
|
||||
allowReplyTo: window.chatwootWebChannel.allowReplyTo || false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -217,7 +214,6 @@ export default {
|
||||
};
|
||||
},
|
||||
hasReplyTo() {
|
||||
if (!this.allowReplyTo) return false;
|
||||
return this.replyTo && (this.replyTo.content || this.replyTo.attachments);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -68,7 +68,6 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
inReplyTo: null,
|
||||
allowReplyTo: window.chatwootWebChannel.allowReplyTo || false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -91,8 +90,6 @@ export default {
|
||||
return this.currentUser && this.currentUser.email;
|
||||
},
|
||||
hasReplyTo() {
|
||||
if (!this.allowReplyTo) return false;
|
||||
|
||||
return (
|
||||
this.inReplyTo && (this.inReplyTo.content || this.inReplyTo.attachments)
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
id="chat-input"
|
||||
ref="chatInput"
|
||||
v-model="userInput"
|
||||
:rows="1"
|
||||
:aria-label="$t('CHAT_PLACEHOLDER')"
|
||||
:placeholder="$t('CHAT_PLACEHOLDER')"
|
||||
class="form-input user-message-input is-focused"
|
||||
|
||||
@@ -11,16 +11,12 @@
|
||||
<div class="flex justify-end gap-1">
|
||||
<div class="flex flex-col justify-end">
|
||||
<message-reply-button
|
||||
v-if="!isInProgress && !isFailed && allowReplyTo"
|
||||
v-if="!isInProgress && !isFailed"
|
||||
class="transition-opacity delay-75 opacity-0 group-hover:opacity-100 sm:opacity-0"
|
||||
@click="toggleReply"
|
||||
/>
|
||||
</div>
|
||||
<drag-wrapper
|
||||
direction="left"
|
||||
:disabled="!allowReplyTo"
|
||||
@dragged="toggleReply"
|
||||
>
|
||||
<drag-wrapper direction="left" @dragged="toggleReply">
|
||||
<user-message-bubble
|
||||
v-if="showTextBubble"
|
||||
:message="message.content"
|
||||
@@ -111,7 +107,6 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
hasImageError: false,
|
||||
allowReplyTo: window.chatwootWebChannel.allowReplyTo || false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -142,7 +137,6 @@ export default {
|
||||
: this.$t('COMPONENTS.MESSAGE_BUBBLE.ERROR_MESSAGE');
|
||||
},
|
||||
hasReplyTo() {
|
||||
if (!this.allowReplyTo) return false;
|
||||
return this.replyTo && (this.replyTo.content || this.replyTo.attachments);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
@@ -7,6 +7,12 @@ class ActionCableListener < BaseListener
|
||||
broadcast(account, tokens, NOTIFICATION_CREATED, { notification: notification.push_event_data, unread_count: unread_count, count: count })
|
||||
end
|
||||
|
||||
def notification_deleted(event)
|
||||
notification, account, unread_count, count = extract_notification_and_account(event)
|
||||
tokens = [event.data[:notification].user.pubsub_token]
|
||||
broadcast(account, tokens, NOTIFICATION_DELETED, notification: notification, unread_count: unread_count, count: count)
|
||||
end
|
||||
|
||||
def account_cache_invalidated(event)
|
||||
account = event.data[:account]
|
||||
tokens = user_tokens(account, account.agents)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
# auto_resolve_duration :integer
|
||||
# custom_attributes :jsonb
|
||||
# domain :string(100)
|
||||
# feature_flags :integer default(0), not null
|
||||
# feature_flags :bigint default(0), not null
|
||||
# limits :jsonb
|
||||
# locale :integer default("en")
|
||||
# name :string not null
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
# primary_actor_type :string not null
|
||||
# read_at :datetime
|
||||
# secondary_actor_type :string
|
||||
# snoozed_until :datetime
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
@@ -41,6 +42,7 @@ class Notification < ApplicationRecord
|
||||
enum notification_type: NOTIFICATION_TYPES
|
||||
|
||||
after_create_commit :process_notification_delivery, :dispatch_create_event
|
||||
after_destroy_commit :dispatch_destroy_event
|
||||
|
||||
# TODO: Get rid of default scope
|
||||
# https://stackoverflow.com/a/1834250/939299
|
||||
@@ -135,4 +137,8 @@ class Notification < ApplicationRecord
|
||||
def dispatch_create_event
|
||||
Rails.configuration.dispatcher.dispatch(NOTIFICATION_CREATED, Time.zone.now, notification: self)
|
||||
end
|
||||
|
||||
def dispatch_destroy_event
|
||||
Rails.configuration.dispatcher.dispatch(NOTIFICATION_DELETED, Time.zone.now, notification: self)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -27,6 +27,10 @@ class PortalPolicy < ApplicationPolicy
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def logo?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def portal_member?
|
||||
|
||||
@@ -17,7 +17,10 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
|
||||
def send_message_to_facebook(delivery_params)
|
||||
result = Facebook::Messenger::Bot.deliver(delivery_params, page_id: channel.page_id)
|
||||
parsed_result = JSON.parse(result)
|
||||
message.update!(status: :failed, external_error: external_error(parsed_result)) if parsed_result['error'].present?
|
||||
if parsed_result['error'].present?
|
||||
message.update!(status: :failed, external_error: external_error(parsed_result))
|
||||
Rails.logger.info "Facebook::SendOnFacebookService: Error sending message to Facebook : Page - #{channel.page_id} : #{result}"
|
||||
end
|
||||
message.update!(source_id: parsed_result['message_id']) if parsed_result['message_id'].present?
|
||||
end
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ class Line::SendOnLineService < Base::SendOnChannelService
|
||||
end
|
||||
|
||||
def perform_reply
|
||||
response = channel.client.push_message(message.conversation.contact_inbox.source_id, [{ type: 'text', text: message.content }])
|
||||
response = channel.client.push_message(message.conversation.contact_inbox.source_id, build_payload)
|
||||
|
||||
return if response.blank?
|
||||
|
||||
parsed_json = JSON.parse(response.body)
|
||||
@@ -20,6 +21,37 @@ class Line::SendOnLineService < Base::SendOnChannelService
|
||||
end
|
||||
end
|
||||
|
||||
def build_payload
|
||||
if message.content && message.attachments.any?
|
||||
[text_message, *attachments]
|
||||
elsif message.content.nil? && message.attachments.any?
|
||||
attachments
|
||||
else
|
||||
text_message
|
||||
end
|
||||
end
|
||||
|
||||
def attachments
|
||||
message.attachments.map do |attachment|
|
||||
# Support only image and video for now, https://developers.line.biz/en/reference/messaging-api/#image-message
|
||||
next unless attachment.file_type == 'image' || attachment.file_type == 'video'
|
||||
|
||||
{
|
||||
type: attachment.file_type,
|
||||
originalContentUrl: attachment.download_url,
|
||||
previewImageUrl: attachment.download_url
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
# https://developers.line.biz/en/reference/messaging-api/#text-message
|
||||
def text_message
|
||||
{
|
||||
type: 'text',
|
||||
text: message.content
|
||||
}
|
||||
end
|
||||
|
||||
# https://developers.line.biz/en/reference/messaging-api/#error-responses
|
||||
def external_error(error)
|
||||
# Message containing information about the error. See https://developers.line.biz/en/reference/messaging-api/#error-messages
|
||||
|
||||
@@ -108,7 +108,9 @@ class Twilio::IncomingMessageService
|
||||
return if params[:MediaUrl0].blank?
|
||||
|
||||
attachment_file = Down.download(
|
||||
params[:MediaUrl0]
|
||||
params[:MediaUrl0],
|
||||
# https://support.twilio.com/hc/en-us/articles/223183748-Protect-Media-Access-with-HTTP-Basic-Authentication-for-Programmable-Messaging
|
||||
http_basic_authentication: [twilio_channel.account_sid, twilio_channel.auth_token || twilio_channel.api_key_sid]
|
||||
)
|
||||
|
||||
attachment = @message.attachments.new(
|
||||
|
||||
@@ -7,7 +7,6 @@ json.phone_number resource.phone_number
|
||||
json.identifier resource.identifier
|
||||
json.thumbnail resource.avatar_url
|
||||
json.custom_attributes resource.custom_attributes
|
||||
json.conversations_count resource.conversations_count if resource[:conversations_count].present?
|
||||
json.last_activity_at resource.last_activity_at.to_i if resource[:last_activity_at].present?
|
||||
json.created_at resource.created_at.to_i if resource[:created_at].present?
|
||||
# we only want to output contact inbox when its /contacts endpoints
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
json.website_channel_config do
|
||||
json.allow_messages_after_resolved @web_widget.inbox.allow_messages_after_resolved
|
||||
json.allow_reply_to @web_widget.inbox.account.feature_enabled?('message_reply_to')
|
||||
json.api_host ENV.fetch('FRONTEND_URL', nil)
|
||||
json.auth_token @token
|
||||
json.avatar_url @web_widget.inbox.avatar_url
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 846 B |
@@ -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 |
@@ -10,6 +10,7 @@ By default, it renders:
|
||||
(if provided by a `content_for` block in a nested page)
|
||||
- Flashes
|
||||
- Links to stylesheets and JavaScripts
|
||||
- The appearance dropdown styles are added to the top to prevent FOUC
|
||||
%>
|
||||
|
||||
<!DOCTYPE html>
|
||||
@@ -21,16 +22,43 @@ By default, it renders:
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<%= javascript_pack_tag 'portal' %>
|
||||
<%= stylesheet_pack_tag 'portal' %>
|
||||
<style>
|
||||
#appearance-dropdown[data-current-theme="system"] .check-mark-icon.light-theme,
|
||||
#appearance-dropdown[data-current-theme="system"] .check-mark-icon.dark-theme,
|
||||
#appearance-dropdown[data-current-theme="dark"] .check-mark-icon.light-theme,
|
||||
#appearance-dropdown[data-current-theme="dark"] .check-mark-icon.system-theme,
|
||||
#appearance-dropdown[data-current-theme="light"] .check-mark-icon.dark-theme,
|
||||
#appearance-dropdown[data-current-theme="light"] .check-mark-icon.system-theme {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
<%= csrf_meta_tags %>
|
||||
<% if content_for?(:head) %>
|
||||
<%= yield(:head) %>
|
||||
<% else %>
|
||||
<title><%= @portal.page_title%></title>
|
||||
<% end %>
|
||||
|
||||
<% unless @theme_from_params.blank? %>
|
||||
<%# this adds the theme from params, ensuring that there a localstorage value set %>
|
||||
<%# this will further trigger the next script to ensure color mode is toggled without a FOUC %>
|
||||
<script>localStorage.theme = '<%= @theme_from_params %>';</script>
|
||||
<% end %>
|
||||
|
||||
<script>
|
||||
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
// we can use document.body here but that would mean pushing this script inside the body
|
||||
// since the body is not created yet. This is done to avoid FOUC, at a tiny cost of Time to Interactive
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
document.documentElement.classList.add('light')
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="portal" class="antialiased <%= @theme unless @theme == 'system' %>">
|
||||
<main class="main-content min-h-screen flex flex-col bg-white dark:bg-slate-900" role="main">
|
||||
<div id="portal" class="antialiased">
|
||||
<main class="flex flex-col min-h-screen bg-white main-content dark:bg-slate-900" role="main">
|
||||
<% if !@is_plain_layout_enabled %>
|
||||
<%= render "public/api/v1/portals/header", portal: @portal %>
|
||||
<% end %>
|
||||
@@ -42,15 +70,53 @@ By default, it renders:
|
||||
</div>
|
||||
</body>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: <% if @theme == 'system' %>light dark<% else %><%= @theme %><% end %>;
|
||||
html.dark {
|
||||
--dynamic-portal-bg: <%= generate_portal_bg(@portal.color, 'dark') %>;
|
||||
--dynamic-portal-bg-gradient: <%= generate_gradient_to_bottom('dark') %>;
|
||||
--dynamic-hover-bg-color: <%= generate_portal_hover_color(@portal.color , 'dark') %>;
|
||||
}
|
||||
|
||||
html.light {
|
||||
--dynamic-portal-bg: <%= generate_portal_bg(@portal.color, 'light') %>;
|
||||
--dynamic-portal-bg-gradient: <%= generate_gradient_to_bottom('light') %>;
|
||||
--dynamic-hover-bg-color: <%= generate_portal_hover_color(@portal.color , 'light') %>;
|
||||
}
|
||||
|
||||
/* Portal background */
|
||||
#portal-bg {
|
||||
background: var(--dynamic-portal-bg);
|
||||
}
|
||||
/* Portal background gradient */
|
||||
#portal-bg-gradient {
|
||||
background-image: var(--dynamic-portal-bg-gradient);
|
||||
}
|
||||
/* Category block item hover color */
|
||||
#category-item:hover {
|
||||
background-color: var(--dynamic-hover-bg-color);
|
||||
}
|
||||
|
||||
/* Header section */
|
||||
#header-action-button:hover,
|
||||
#toggle-appearance:hover,
|
||||
#toggle-theme-button:hover {
|
||||
color: var(--dynamic-hover-color);
|
||||
stroke: var(--dynamic-hover-color);
|
||||
}
|
||||
#category-block:hover {
|
||||
border-color: var(--dynamic-hover-color);
|
||||
}
|
||||
#category-block:hover #category-name {
|
||||
color: var(--dynamic-hover-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
window.portalConfig = {
|
||||
portalSlug: '<%= @portal.slug %>',
|
||||
portalColor: '<%= @portal.color %>',
|
||||
theme: '<%= @theme %>',
|
||||
theme: '<%= @theme_from_params %>',
|
||||
customDomain: '<%= @portal.custom_domain %>',
|
||||
hostURL: '<%= ENV.fetch('FRONTEND_URL', '') %>',
|
||||
localeCode: '<%= @locale %>',
|
||||
searchTranslations: {
|
||||
searchPlaceholder: '<%= I18n.t('public_portal.search.search_placeholder') %>',
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<% author_count = category.articles.published.order(position: :asc).map(&:author).uniq.size %>
|
||||
<% if author_count > 0 %>
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<div class="flex flex-row items-center -space-x-2">
|
||||
<% category.articles.published.order(position: :asc).map(&:author).uniq.take(3).each do |author| %>
|
||||
<%= render "public/api/v1/portals/thumbnail", author: author, size: 5 %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% first_author = category.articles.published.order(position: :asc).map(&:author).uniq.first.name %>
|
||||
<% author_text = author_count > 1 ? "#{author_count} #{I18n.t('public_portal.common.authors')}" : "#{author_count} #{I18n.t('public_portal.common.author')}" %>
|
||||
<% other_authors_count = author_count - 1 %>
|
||||
<% other_authors_text = other_authors_count > 1 ? I18n.t('public_portal.common.others') : I18n.t('public_portal.common.other') %>
|
||||
|
||||
<span class="<%= show_expanded ? 'text-base' : 'text-sm' %> font-medium text-slate-600 dark:text-slate-400">
|
||||
<% if show_expanded %>
|
||||
<%= "#{I18n.t('public_portal.common.by')} #{first_author}" %>
|
||||
<%= other_authors_count.positive? ? " and #{other_authors_count} #{other_authors_text}" : '' %>
|
||||
<% else %>
|
||||
<%= author_text %>
|
||||
<% end %>
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -1,13 +1,22 @@
|
||||
<section class="lg:container w-full flex flex-col h-full">
|
||||
<div class="flex flex-col gap-8 h-full group <%= !@is_plain_layout_enabled ? 'border border-solid border-slate-100 dark:border-slate-800 hover:border-woot-600 dark:hover:border-woot-600 py-5 px-3 rounded-lg' : '' %>">
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<div class="flex items-start flex-col gap-1">
|
||||
<% category_link_params = {
|
||||
portal_slug: portal.slug,
|
||||
category_locale: category.locale,
|
||||
category_slug: category.slug,
|
||||
theme: @theme_from_params,
|
||||
is_plain_layout_enabled: @is_plain_layout_enabled
|
||||
}
|
||||
%>
|
||||
|
||||
<section class="flex flex-col w-full h-full lg:container">
|
||||
<div id="<%= !@is_plain_layout_enabled ? 'category-block' : '' %>" class="flex flex-col gap-8 h-full <%= !@is_plain_layout_enabled ? 'border border-solid border-slate-100 dark:border-slate-800 py-5 px-3 rounded-lg' : '' %>">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<div class="flex flex-row items-center gap-2 <%= !@is_plain_layout_enabled && 'px-1' %>">
|
||||
<% if category.icon.present? %>
|
||||
<span class="text-lg rounded-md cursor-pointer <%= !@is_plain_layout_enabled && 'pl-1' %>"><%= category.icon %></span>
|
||||
<% end %>
|
||||
<h3 class="text-xl text-slate-800 dark:text-slate-50 font-semibold leading-relaxed hover:cursor-pointer <%= @is_plain_layout_enabled ? 'hover:underline' : 'group-hover:text-woot-600 dark:group-hover:text-woot-600' %> <%= category.icon.blank? && !@is_plain_layout_enabled ? 'pl-1' : '' %>">
|
||||
<a href="/hc/<%= portal.slug %>/<%= category.locale %>/categories/<%= category.slug %><%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>">
|
||||
<h3 id="<%= !@is_plain_layout_enabled ? 'category-name' : '' %>" class="text-xl text-slate-800 dark:text-slate-50 font-semibold leading-relaxed hover:cursor-pointer <%= @is_plain_layout_enabled ? 'hover:underline' : '' %> <%= category.icon.blank? && !@is_plain_layout_enabled ? 'pl-1' : '' %>">
|
||||
<a href="<%= generate_category_link(category_link_params) %>">
|
||||
<%= category.name %>
|
||||
</a>
|
||||
</h3>
|
||||
@@ -19,15 +28,15 @@
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 flex-grow <%= category.description.blank? && '-mt-4' %>">
|
||||
<% if category.articles.published.size==0 %>
|
||||
<div class="h-full flex items-center justify-center bg-slate-50 dark:bg-slate-800 rounded-xl mb-4">
|
||||
<div class="flex items-center justify-center h-full mb-4 bg-slate-50 dark:bg-slate-800 rounded-xl">
|
||||
<p class="text-sm text-slate-500"><%= I18n.t('public_portal.common.no_articles') %></p>
|
||||
</div>
|
||||
<% else %>
|
||||
<% category.articles.published.order(position: :asc).take(5).each do |article| %>
|
||||
<a class="text-slate-700 dark:text-slate-100 leading-7" href="<%= generate_article_link(portal.slug, article.slug, @theme) %>">
|
||||
<div class="flex justify-between hover:cursor-pointer items-center py-1 rounded-lg gap-3 <%= !@is_plain_layout_enabled ? 'px-2 hover:bg-slate-50 dark:hover:bg-slate-800' : 'hover:underline' %>">
|
||||
<a class="leading-7 text-slate-700 dark:text-slate-100" href="<%= generate_article_link(portal.slug, article.slug, @theme_from_params, @is_plain_layout_enabled) %>">
|
||||
<div id="<%= !@is_plain_layout_enabled ? 'category-item' : '' %>" class="flex justify-between hover:cursor-pointer items-start py-1 rounded-lg gap-6 <%= !@is_plain_layout_enabled ? 'px-2' : 'hover:underline' %>">
|
||||
<%= article.title %>
|
||||
<span class="flex items-center font-normal">
|
||||
<span class="flex items-center font-normal mt-1.5">
|
||||
<%= render partial: 'icons/chevron-right' %>
|
||||
</span>
|
||||
</div>
|
||||
@@ -36,11 +45,13 @@
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="flex justify-between flex-row items-center <%= !@is_plain_layout_enabled && 'px-2' %>">
|
||||
<div>
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<%= render "public/api/v1/portals/authors", category: category, show_expanded: false %>
|
||||
<span class="text-slate-600 dark:text-slate-400">•</span>
|
||||
<span class="text-sm font-medium text-slate-600 dark:text-slate-400"><%= render 'public/api/v1/portals/article_count', article_count: category.articles.published.order(position: :asc).size %></span>
|
||||
</div>
|
||||
<div>
|
||||
<a href="/hc/<%= portal.slug %>/<%= category.locale %>/categories/<%= category.slug %><%= @theme.present? ? '?theme='+@theme : '' %>" class="flex flex-row items-center text-sm font-medium text-slate-600 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-100">
|
||||
<a href="<%= generate_category_link(category_link_params) %>" class="flex flex-row items-center text-sm font-medium text-slate-600 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-100">
|
||||
<%= I18n.t('public_portal.common.view_all_articles') %>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
<% featured_articles = articles.where(category_id: categories).search_by_status(:published).order_by_views.limit(6) %>
|
||||
<% if featured_articles.count >= 6 %>
|
||||
<section class="lg:container w-full flex flex-col h-full">
|
||||
<div class="flex flex-col gap-5 border border-solid border-slate-100 dark:border-slate-800 rounded-lg py-5 px-3">
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<div class="flex items-start flex-col gap-1">
|
||||
<section class="flex flex-col w-full h-full lg:container">
|
||||
<div class="flex flex-col gap-5 px-3 py-5 border border-solid rounded-lg border-slate-100 dark:border-slate-800">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<div class="flex flex-row items-center gap-2 px-2">
|
||||
<h3 class="text-xl text-slate-800 dark:text-slate-50 font-semibold leading-relaxed">
|
||||
<h3 class="text-xl font-semibold leading-relaxed text-slate-800 dark:text-slate-50">
|
||||
<%= I18n.t('public_portal.header.featured_articles') %>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-2 gap-y-2 gap-2">
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-2 gap-x-2 gap-y-2">
|
||||
<% featured_articles.each do |article| %>
|
||||
<a class="text-slate-700 dark:text-slate-100 leading-7" href="<%= generate_article_link(portal.slug, article.slug, @theme) %>">
|
||||
<div class="flex justify-between items-center py-1 px-2 rounded-lg gap-3 hover:bg-slate-50 dark:hover:bg-slate-800">
|
||||
<a class="leading-7 text-slate-700 dark:text-slate-100" href="<%= generate_article_link(portal.slug, article.slug, @theme_from_params, @is_plain_layout_enabled) %>">
|
||||
<div id="category-item" class="flex items-start justify-between gap-6 px-2 py-1 rounded-lg">
|
||||
<%= article.title %>
|
||||
<span class="flex items-center font-normal">
|
||||
<span class="flex items-center font-normal mt-1.5">
|
||||
<%= render partial: 'icons/chevron-right' %>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<header class="bg-white dark:bg-slate-900 w-full shadow-sm sticky top-0 z-50">
|
||||
<nav class=" flex mx-auto max-w-5xl px-4 md:px-8" aria-label="Top">
|
||||
<div class="w-full py-5 flex items-center overflow-hidden">
|
||||
<a href="/hc/<%= @portal.slug %>/<%= @portal.config['default_locale'] || params[:locale] %>/<%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>" class="h-10 text-lg flex items-center text-slate-900 dark:text-white font-semibold">
|
||||
<header class="sticky top-0 z-50 w-full bg-white shadow-sm dark:bg-slate-900">
|
||||
<nav class="flex max-w-5xl px-4 mx-auto md:px-8" aria-label="Top">
|
||||
<div class="flex items-center w-full py-5 overflow-hidden">
|
||||
<a href="<%= generate_home_link(@portal.slug, @portal.config['default_locale'] || params[:locale], @theme_from_params, @is_plain_layout_enabled) %>" class="flex items-center h-10 text-lg font-semibold text-slate-900 dark:text-white">
|
||||
<% if @portal.logo.present? %>
|
||||
<img src="<%= url_for(@portal.logo) %>" class="h-10 w-auto mr-2" />
|
||||
<img src="<%= url_for(@portal.logo) %>" class="w-auto h-10 mr-2" />
|
||||
<% end %>
|
||||
<%= @portal.name %>
|
||||
</a>
|
||||
@@ -12,9 +12,9 @@
|
||||
<%# Go to homepage link section %>
|
||||
<div class="flex items-center justify-between gap-2 sm:gap-5">
|
||||
<% if @portal.homepage_link %>
|
||||
<div class="ml-8 border-l-1 border-slate-50 dark:border-slate-800 hidden md:block cursor-pointer px-1 py-2">
|
||||
<div class="hidden px-1 py-2 ml-8 cursor-pointer border-l-1 border-slate-50 dark:border-slate-800 md:block">
|
||||
<div class="flex-grow flex-shrink-0">
|
||||
<a target="_blank" rel="noopener noreferrer nofollow" href="<%= @portal.homepage_link %>" class="flex flex-row items-center gap-1 text-sm font-medium whitespace-nowrap text-slate-800 dark:text-slate-100 hover:text-woot-500 dark:hover:text-woot-500 stroke-slate-700 dark:stroke-slate-200 hover:stroke-woot-500 dark:hover:stroke-woot-500">
|
||||
<a id="header-action-button" target="_blank" rel="noopener noreferrer nofollow" href="<%= @portal.homepage_link %>" class="flex flex-row items-center gap-1 text-sm font-medium whitespace-nowrap text-slate-800 dark:text-slate-100 stroke-slate-700 dark:stroke-slate-200">
|
||||
<%= render partial: 'icons/redirect' %>
|
||||
<%= I18n.t('public_portal.header.go_to_homepage') %>
|
||||
</a>
|
||||
@@ -23,54 +23,64 @@
|
||||
<% end %>
|
||||
|
||||
<%# Appearance toggle section %>
|
||||
<div class="flex-grow flex-shrink-0 relative cursor-pointer px-1 py-2">
|
||||
<button id="toggle-appearance" class="toggle-appearance flex flex-row items-center stroke-slate-700 dark:stroke-slate-200 hover:stroke-woot-500 dark:hover:stroke-woot-500 text-slate-800 dark:text-slate-100 hover:text-woot-500 dark:hover:text-woot-500 gap-1" type="button">
|
||||
<%= render partial: get_theme_icon(@theme) %>
|
||||
<span class="text-sm font-medium"><%= get_theme_names(@theme) %></span>
|
||||
<div class="pointer-events-none flex items-center px-1">
|
||||
<div class="relative flex-grow flex-shrink-0 px-1 py-2 cursor-pointer">
|
||||
<button id="toggle-appearance" class="flex justify-between min-w-[76px] flex-row items-center stroke-slate-700 dark:stroke-slate-200 text-slate-800 dark:text-slate-100 gap-1" type="button">
|
||||
<div data-theme="system" class="flex-row items-center gap-1 theme-button <%= @theme_from_params == 'system' ? 'flex' : 'hidden' %>">
|
||||
<%= render partial: 'icons/monitor' %>
|
||||
<span class="text-sm font-medium"><%= I18n.t('public_portal.header.appearance.system') %></span>
|
||||
</div>
|
||||
<div data-theme="light" class="flex-row items-center gap-1 theme-button <%= @theme_from_params == 'light' ? 'flex' : 'hidden' %>">
|
||||
<%= render partial: 'icons/sun' %>
|
||||
<span class="text-sm font-medium"><%= I18n.t('public_portal.header.appearance.light') %></span>
|
||||
</div>
|
||||
<div data-theme="dark" class="flex-row items-center gap-1 theme-button <%= @theme_from_params == 'dark' ? 'flex' : 'hidden' %>">
|
||||
<%= render partial: 'icons/moon' %>
|
||||
<span class="text-sm font-medium"><%= I18n.t('public_portal.header.appearance.dark') %></span>
|
||||
</div>
|
||||
<div class="flex items-center px-1 pointer-events-none">
|
||||
<%= render partial: 'icons/chevron-down' %>
|
||||
</div>
|
||||
</button>
|
||||
<%# Appearance dropdown section %>
|
||||
<div id="appearance-dropdown" class="hidden absolute flex-col h-auto w-32 bg-white dark:bg-slate-900 border border-solid rounded top-9 border-slate-100 dark:border-slate-800" aria-hidden="true" data-dropdown="appearance-dropdown">
|
||||
<button data-theme="system" class="flex flex-row items-center justify-between px-2 py-2 border-b border-solid border-slate-100 dark:border-slate-800 gap-1 stroke-slate-700 dark:stroke-slate-200 hover:stroke-woot-500 dark:hover:stroke-woot-500 text-slate-800 dark:text-slate-100 hover:text-woot-500 dark:hover:text-woot-500">
|
||||
<div id="appearance-dropdown" data-current-theme="<%= @theme_from_params %>" class="absolute flex-col w-32 h-auto bg-white border border-solid rounded dark:bg-slate-900 top-9 right-1 border-slate-100 dark:border-slate-800" aria-hidden="true" style="display: none;" data-dropdown="appearance-dropdown">
|
||||
<button id="toggle-theme-button" data-theme="system" class="flex flex-row items-center justify-between gap-1 px-2 py-2 border-b border-solid border-slate-100 dark:border-slate-800 stroke-slate-700 dark:stroke-slate-200 text-slate-800 dark:text-slate-100">
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<%= render partial: 'icons/monitor' %>
|
||||
<span class="text-xs font-medium"><%= I18n.t('public_portal.header.appearance.system') %></span>
|
||||
</div>
|
||||
<% if @theme.present? && @theme == 'system' %>
|
||||
<span class="check-mark-icon system-theme">
|
||||
<%= render partial: 'icons/check-mark' %>
|
||||
<% end %>
|
||||
</span>
|
||||
</button>
|
||||
<button data-theme="light" class="flex flex-row items-center justify-between px-2 py-2 border-b border-solid border-slate-100 dark:border-slate-800 gap-1 stroke-slate-700 dark:stroke-slate-200 hover:stroke-woot-500 dark:hover:stroke-woot-500 text-slate-800 dark:text-slate-100 hover:text-woot-500 dark:hover:text-woot-500">
|
||||
<button id="toggle-theme-button" data-theme="light" class="flex flex-row items-center justify-between gap-1 px-2 py-2 border-b border-solid border-slate-100 dark:border-slate-800 stroke-slate-700 dark:stroke-slate-200 text-slate-800 dark:text-slate-100">
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<%= render partial: 'icons/sun' %>
|
||||
<span class="text-xs font-medium"><%= I18n.t('public_portal.header.appearance.light') %></span>
|
||||
</div>
|
||||
<% if @theme.present? && @theme == 'light' %>
|
||||
<span class="check-mark-icon light-theme">
|
||||
<%= render partial: 'icons/check-mark' %>
|
||||
<% end %>
|
||||
</span>
|
||||
</button>
|
||||
<button data-theme="dark" class="flex flex-row items-center justify-between px-2 py-2 gap-1 stroke-slate-700 dark:stroke-slate-200 hover:stroke-woot-500 dark:hover:stroke-woot-500 text-slate-800 dark:text-slate-100 hover:text-woot-500 dark:hover:text-woot-500">
|
||||
<button id="toggle-theme-button" data-theme="dark" class="flex flex-row items-center justify-between gap-1 px-2 py-2 stroke-slate-700 dark:stroke-slate-200 text-slate-800 dark:text-slate-100">
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<%= render partial: 'icons/moon' %>
|
||||
<span class="text-xs font-medium"><%= I18n.t('public_portal.header.appearance.dark') %></span>
|
||||
</div>
|
||||
<% if @theme.present? && @theme == 'dark' %>
|
||||
<span class="check-mark-icon dark-theme">
|
||||
<%= render partial: 'icons/check-mark' %>
|
||||
<% end %>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# Locale switcher section %>
|
||||
<% if @portal.config["allowed_locales"].length > 1 %>
|
||||
<div class="flex items-center stroke-slate-700 dark:stroke-slate-200 hover:stroke-woot-500 dark:hover:stroke-woot-500 text-slate-800 dark:text-slate-100 hover:text-woot-500 dark:hover:text-woot-500">
|
||||
<div id="header-action-button" class="flex items-center stroke-slate-700 dark:stroke-slate-200 text-slate-800 dark:text-slate-100">
|
||||
<div class="flex items-center gap-1 px-1 py-2 cursor-pointer">
|
||||
<%= render partial: 'icons/globe' %>
|
||||
<select
|
||||
data-portal-slug="<%= @portal.slug %>"
|
||||
class="bg-white dark:bg-slate-900 appearance-none w-24 overflow-hidden text-ellipsis whitespace-nowrap leading-tight font-medium focus:outline-none text-sm focus:shadow-outline locale-switcher cursor-pointer"
|
||||
class="w-24 overflow-hidden text-sm font-medium leading-tight bg-white appearance-none cursor-pointer dark:bg-slate-900 text-ellipsis whitespace-nowrap focus:outline-none focus:shadow-outline locale-switcher"
|
||||
>
|
||||
<% @portal.config["allowed_locales"].each do |locale| %>
|
||||
<option <%= locale == params[:locale] ? 'selected': '' %> value="<%= locale %>"><%= "#{language_name(locale)} (#{locale})" %></option>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<% if !@is_plain_layout_enabled %>
|
||||
<section id="portal-bg" class="w-full bg-woot-50 dark:bg-woot-900 shadow-inner" style="<%= generate_portal_bg(@portal.color, @theme) %>">
|
||||
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6" style="<%= generate_gradient_to_bottom(@theme) %>">
|
||||
<section id="portal-bg" class="w-full bg-white dark:bg-slate-900 shadow-inner">
|
||||
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6 min-h-[240px] md:min-h-[260px]">
|
||||
<div class="mx-auto max-w-5xl px-4 md:px-8 flex flex-col items-center sm:items-start">
|
||||
<h1 class="text-2xl md:text-4xl text-slate-900 dark:text-white font-semibold leading-normal">
|
||||
<%= portal.header_text %>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<% if author&.avatar_url&.present? %>
|
||||
<img src="<%= url_for(author.avatar_url) %>" alt="<%= author.name %>" class="w-<%= size %> h-<%= size %> rounded-full border border-solid border-white dark:border-slate-900">
|
||||
<% else %>
|
||||
<div class="w-<%= size %> h-<%= size %> rounded-full [&>svg]:opacity-70 border border-solid fill-white dark:fill-slate-900 border-white dark:border-slate-900 flex justify-center items-center" style="background-color: <%= thumbnail_bg_color(author&.available_name) %>;">
|
||||
<%= render partial: 'icons/user' %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -1,17 +1,17 @@
|
||||
<section class="lg:container w-full flex flex-col h-full">
|
||||
<div class="flex flex-col gap-8 h-full group <%= !@is_plain_layout_enabled ? 'border border-solid border-slate-100 dark:border-slate-800 hover:border-woot-600 dark:hover:border-woot-600 py-5 px-3 rounded-lg' : '' %>">
|
||||
<section class="flex flex-col w-full h-full lg:container">
|
||||
<div id="<%= !@is_plain_layout_enabled ? 'category-block' : '' %>" class="flex flex-col gap-8 h-full <%= !@is_plain_layout_enabled ? 'border border-solid border-slate-100 dark:border-slate-800 py-5 px-3 rounded-lg' : '' %>">
|
||||
<div class="flex justify-between items-center w-full <%= !@is_plain_layout_enabled ? 'px-1' : '' %>">
|
||||
<h3 class="text-xl text-slate-800 dark:text-slate-50 font-semibold leading-relaxed hover:cursor-pointer <%= @is_plain_layout_enabled ? 'hover:underline' : 'pl-1 group-hover:text-woot-600 dark:group-hover:text-woot-600' %>">
|
||||
<h3 id="<%= !@is_plain_layout_enabled ? 'category-name' : '' %>" class="text-xl text-slate-800 dark:text-slate-50 font-semibold leading-relaxed hover:cursor-pointer <%= @is_plain_layout_enabled ? 'hover:underline' : 'pl-1' %>">
|
||||
<%= I18n.t('public_portal.header.uncategorized') %>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="-mt-4">
|
||||
<% portal.articles.published.where(category_id: nil).order(position: :asc).take(5).each do |article| %>
|
||||
<a
|
||||
class="text-slate-700 dark:text-slate-100 leading-7"
|
||||
href="<%= generate_article_link(portal.slug, article.slug, @theme) %>"
|
||||
class="leading-7 text-slate-700 dark:text-slate-100"
|
||||
href="<%= generate_article_link(portal.slug, article.slug, @theme_from_params, @is_plain_layout_enabled) %>"
|
||||
>
|
||||
<div class="flex justify-between hover:cursor-pointer items-center py-1 rounded-lg gap-3 <%= !@is_plain_layout_enabled ? 'px-2 hover:bg-slate-50 dark:hover:bg-slate-800' : 'hover:underline' %>">
|
||||
<div id="<%= !@is_plain_layout_enabled ? 'category-item' : '' %>" class="flex justify-between hover:cursor-pointer items-center py-1 rounded-lg gap-3 <%= !@is_plain_layout_enabled ? 'px-2' : 'hover:underline' %>">
|
||||
<%= article.title %>
|
||||
<span class="flex items-center font-normal">
|
||||
<%= render partial: 'icons/chevron-right' %>
|
||||
|
||||
@@ -1,31 +1,38 @@
|
||||
<div class="flex items-center flex-row mb-6 gap-px">
|
||||
<% category_link_params = {
|
||||
portal_slug: @portal.slug,
|
||||
category_locale: @article.category.locale,
|
||||
category_slug: @article.category.slug,
|
||||
theme: @theme_from_params,
|
||||
is_plain_layout_enabled: @is_plain_layout_enabled
|
||||
}
|
||||
%>
|
||||
|
||||
<div class="flex flex-row items-center gap-px mb-6">
|
||||
<a
|
||||
class="text-slate-500 dark:text-slate-200 text-sm gap-1 hover:cursor-pointer <%= @is_plain_layout_enabled && 'hover:underline' %> leading-8 font-semibold"
|
||||
href="/hc/<%= @portal.slug %>/<%= @article.category&.locale %><%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>"
|
||||
href="<%= generate_home_link(@portal.slug, @article.category&.locale, @theme_from_params, @is_plain_layout_enabled) %>"
|
||||
>
|
||||
<%= I18n.t('public_portal.common.home') %>
|
||||
</a>
|
||||
<span class="w-4 h-4 [&>svg]:w-3 [&>svg]:h-3 flex items-center justify-center text-xs text-slate-500 dark:text-slate-300"><%= render partial: 'icons/chevron-right' %></span>
|
||||
<% if @article.category %>
|
||||
<a class="text-slate-500 dark:text-slate-200 text-sm gap-1 whitespace-nowrap hover:cursor-pointer <%= @is_plain_layout_enabled && 'hover:underline' %> leading-8 font-semibold" href="/hc/<%= @portal.slug %>/<%= @article.category.locale %>/categories/<%= @article.category.slug %><%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>">
|
||||
<a class="text-slate-500 dark:text-slate-200 text-sm gap-1 whitespace-nowrap hover:cursor-pointer <%= @is_plain_layout_enabled && 'hover:underline' %> leading-8 font-semibold" href="<%= generate_category_link(category_link_params) %>">
|
||||
<%= @article.category&.name %>
|
||||
</a>
|
||||
<span class="w-4 h-4 [&>svg]:w-3 [&>svg]:h-3 flex items-center justify-center text-xs text-slate-500 dark:text-slate-300"><%= render partial: 'icons/chevron-right' %></span>
|
||||
<span class="text-sm text-slate-800 dark:text-slate-100 font-semibold overflow-hidden text-ellipsis min-w-0 whitespace-nowrap"><%= @article.title %></span>
|
||||
<span class="min-w-0 overflow-hidden text-sm font-semibold text-slate-800 dark:text-slate-100 text-ellipsis whitespace-nowrap"><%= @article.title %></span>
|
||||
<% else %>
|
||||
<span class="text-slate-700 dark:text-slate-100 leading-8 text-sm font-semibold" ><%= I18n.t('public_portal.header.uncategorized') %></span>
|
||||
<span class="text-sm font-semibold leading-8 text-slate-700 dark:text-slate-100" ><%= I18n.t('public_portal.header.uncategorized') %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
<h1 class="text-3xl font-semibold leading-normal md:tracking-normal md:text-4xl text-slate-900 dark:text-white">
|
||||
<%= article.title %>
|
||||
</h1>
|
||||
<div class="flex flex-col items-start justify-between w-full md:flex-row md:items-center pt-6">
|
||||
<div class="flex flex-col items-start justify-between w-full pt-6 md:flex-row md:items-center">
|
||||
<div class="flex items-start space-x-1">
|
||||
<% if article.author&.avatar_url&.present? %>
|
||||
<div class="pr-px mt-0.5 min-w-[20px] min-h-[20px]">
|
||||
<img src="<%= article.author.avatar_url %>" alt="<%= article.author.display_name %>" class="w-5 h-5 border rounded-full">
|
||||
</div>
|
||||
<% end %>
|
||||
<span class="flex items-center text-base text-slate-600 dark:text-slate-400 font-medium">By <%= article.author.available_name %> • <%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %></span>
|
||||
<div class="pr-px mt-0.5 min-w-[20px] min-h-[20px]">
|
||||
<%= render "public/api/v1/portals/thumbnail", author: article.author, size: 5 %>
|
||||
</div>
|
||||
<span class="flex items-center text-base font-medium text-slate-600 dark:text-slate-400">By <%= article.author.available_name %> • <%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<div class="bg-slate-50 dark:bg-slate-800">
|
||||
<div class="max-w-4xl px-6 py-16 mx-auto space-y-12 w-full">
|
||||
<div class="w-full max-w-4xl px-6 py-16 mx-auto space-y-12">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<a
|
||||
class="text-slate-800 hover:underline leading-8"
|
||||
href="/hc/<%= @portal.slug %>/<%= @category.present? ? @category.slug : '' %><%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>"
|
||||
class="leading-8 text-slate-800 hover:underline"
|
||||
href="<%= generate_home_link(@portal.slug, @category.present? ? @category.slug : '', @theme_from_params, @is_plain_layout_enabled) %>"
|
||||
>
|
||||
<%= @portal.name %> <%= I18n.t('public_portal.common.home') %>
|
||||
</a>
|
||||
@@ -12,13 +12,13 @@
|
||||
<span>/</span>
|
||||
</div>
|
||||
<% @articles.each do |article| %>
|
||||
<h1 class="text-4xl font-semibold md:tracking-normal leading-snug md:text-5xl text-slate-900 dark:text-white">
|
||||
<h1 class="text-4xl font-semibold leading-snug md:tracking-normal md:text-5xl text-slate-900 dark:text-white">
|
||||
<%= article.title %></h1>
|
||||
<div class="flex flex-col items-start justify-between w-full md:flex-row md:items-center pt-2">
|
||||
<div class="flex flex-col items-start justify-between w-full pt-2 md:flex-row md:items-center">
|
||||
<div class="flex items-center space-x-2">
|
||||
<img src="<%= article.author.avatar_url %>" alt="" class="w-12 h-812 border rounded-full">
|
||||
<img src="<%= article.author.avatar_url %>" alt="" class="w-12 border rounded-full h-812">
|
||||
<div>
|
||||
<h5 class="text-base font-medium text-slate-900 dark:text-white mb-2"><%= article.author.name %></h5>
|
||||
<h5 class="mb-2 text-base font-medium text-slate-900 dark:text-white"><%= article.author.name %></h5>
|
||||
<p class="text-sm font-normal text-slate-700 dark:text-slate-100">
|
||||
<%= article.author.updated_at.strftime("%B %d %Y") %></p>
|
||||
</div>
|
||||
@@ -28,9 +28,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="max-w-4xl flex-grow w-full px-8 py-16 mx-auto space-y-12">
|
||||
<div class="flex-grow w-full max-w-4xl px-8 py-16 mx-auto space-y-12">
|
||||
<article class="space-y-8">
|
||||
<div class="text-slate-800 dark:text-slate-50 font-sans leading-8 text-lg max-w-3xl blog-content">
|
||||
<div class="max-w-3xl font-sans text-lg leading-8 text-slate-800 dark:text-slate-50 blog-content">
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
<% end %>
|
||||
|
||||
<% if !@is_plain_layout_enabled %>
|
||||
<div id="portal-bg" class="bg-woot-50 dark:bg-woot-900 shadow-inner" style="<%= generate_portal_bg(@portal.color, @theme) %>">
|
||||
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6" style="<%= generate_gradient_to_bottom(@theme) %>">
|
||||
<div id="portal-bg" class="bg-white dark:bg-slate-900 shadow-inner">
|
||||
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6">
|
||||
<div class="max-w-5xl px-4 md:px-8 mx-auto flex flex-col">
|
||||
<%= render "public/api/v1/portals/articles/article_header", article: @article %>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user