Compare commits
62
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cde6b75c1e | ||
|
|
9aeb5b8db6 | ||
|
|
2a66cd5eb7 | ||
|
|
80bd0e39b4 | ||
|
|
a1334c947d | ||
|
|
5ce8c1c3d7 | ||
|
|
b617f3e4b2 | ||
|
|
29cb6c18be | ||
|
|
49cfe240e3 | ||
|
|
218acfd50a | ||
|
|
2267d85b29 | ||
|
|
f1484f1625 | ||
|
|
a0ef39fb12 | ||
|
|
621ebf13bd | ||
|
|
693cc29339 | ||
|
|
cea740ceb7 | ||
|
|
8fe521d66d | ||
|
|
f728c8089d | ||
|
|
585ad7d9a4 | ||
|
|
b36a789518 | ||
|
|
68d36e314f | ||
|
|
1b17068948 | ||
|
|
2f5ba952f4 | ||
|
|
489f2b5bcc | ||
|
|
5468e8a625 | ||
|
|
476eff7f5d | ||
|
|
ffcb148626 | ||
|
|
f32aa4b517 | ||
|
|
a017c51c6b | ||
|
|
bdc9b63800 | ||
|
|
9275328a3a | ||
|
|
ff30c3ea61 | ||
|
|
086b131375 | ||
|
|
12b56e6a2f | ||
|
|
b0e6fc155d | ||
|
|
9c75c3b829 | ||
|
|
2d02261bbb | ||
|
|
f4f243bd3f | ||
|
|
944c6c4842 | ||
|
|
115690b88a | ||
|
|
4f7ed036b0 | ||
|
|
51b845e937 | ||
|
|
ae0eac9474 | ||
|
|
bc6e3e598f | ||
|
|
683f9d9a28 | ||
|
|
f9bf0883c6 | ||
|
|
ad8ba299c9 | ||
|
|
3ae64822e1 | ||
|
|
a49aaee9cf | ||
|
|
2d1f70eb79 | ||
|
|
4fc5f765de | ||
|
|
7efc2726d9 | ||
|
|
29a778697c | ||
|
|
90649e72bb | ||
|
|
1904ec7df4 | ||
|
|
96add30331 | ||
|
|
9c7148e2ad | ||
|
|
31c709be5c | ||
|
|
e750ee6d28 | ||
|
|
5b3f9ac1cd | ||
|
|
6c8dacfa0d | ||
|
|
feead30b0b |
@@ -75,7 +75,7 @@ gem 'jwt'
|
||||
gem 'pundit'
|
||||
# super admin
|
||||
gem 'administrate', '>= 0.19.0'
|
||||
gem 'administrate-field-active_storage'
|
||||
gem 'administrate-field-active_storage', '>= 1.0.0'
|
||||
gem 'administrate-field-belongs_to_search'
|
||||
|
||||
##--- gems for pubsub service ---##
|
||||
|
||||
+16
-16
@@ -113,7 +113,7 @@ GEM
|
||||
kaminari (>= 1.0)
|
||||
sassc-rails (~> 2.1)
|
||||
selectize-rails (~> 0.6)
|
||||
administrate-field-active_storage (0.4.2)
|
||||
administrate-field-active_storage (1.0.0)
|
||||
administrate (>= 0.2.2)
|
||||
rails (>= 7.0)
|
||||
administrate-field-belongs_to_search (0.8.0)
|
||||
@@ -183,7 +183,7 @@ GEM
|
||||
activerecord (>= 5.a)
|
||||
database_cleaner-core (~> 2.0.0)
|
||||
database_cleaner-core (2.0.1)
|
||||
date (3.3.3)
|
||||
date (3.3.4)
|
||||
ddtrace (1.11.1)
|
||||
debase-ruby_core_source (>= 0.10.16, <= 3.2.0)
|
||||
libdatadog (~> 2.0.0.1.0)
|
||||
@@ -256,7 +256,7 @@ GEM
|
||||
fcm (1.0.8)
|
||||
faraday (>= 1.0.0, < 3.0)
|
||||
googleauth (~> 1)
|
||||
ffi (1.15.5)
|
||||
ffi (1.16.3)
|
||||
ffi-compiler (1.0.1)
|
||||
ffi (>= 1.0.0)
|
||||
rake
|
||||
@@ -440,7 +440,7 @@ GEM
|
||||
activesupport (>= 4)
|
||||
railties (>= 4)
|
||||
request_store (~> 1.0)
|
||||
loofah (2.21.4)
|
||||
loofah (2.22.0)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.8.1)
|
||||
@@ -472,14 +472,14 @@ GEM
|
||||
activerecord (>= 5.2)
|
||||
net-http-persistent (4.0.2)
|
||||
connection_pool (~> 2.2)
|
||||
net-imap (0.3.7)
|
||||
net-imap (0.4.5)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
net-protocol
|
||||
net-protocol (0.2.1)
|
||||
net-protocol (0.2.2)
|
||||
timeout
|
||||
net-smtp (0.3.3)
|
||||
net-smtp (0.4.0)
|
||||
net-protocol
|
||||
netrc (0.11.0)
|
||||
newrelic-sidekiq-metrics (1.6.2)
|
||||
@@ -487,15 +487,15 @@ GEM
|
||||
sidekiq
|
||||
newrelic_rpm (9.6.0)
|
||||
base64
|
||||
nio4r (2.5.9)
|
||||
nokogiri (1.15.4)
|
||||
nio4r (2.6.0)
|
||||
nokogiri (1.15.5)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.15.4-arm64-darwin)
|
||||
nokogiri (1.15.5-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.15.4-x86_64-darwin)
|
||||
nokogiri (1.15.5-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.15.4-x86_64-linux)
|
||||
nokogiri (1.15.5-x86_64-linux)
|
||||
racc (~> 1.4)
|
||||
numo-narray (0.9.2.1)
|
||||
oauth (1.1.0)
|
||||
@@ -752,7 +752,7 @@ GEM
|
||||
spring-watcher-listen (2.1.0)
|
||||
listen (>= 2.7, < 4.0)
|
||||
spring (>= 4)
|
||||
sprockets (4.2.0)
|
||||
sprockets (4.2.1)
|
||||
concurrent-ruby (~> 1.0)
|
||||
rack (>= 2.2.4, < 4)
|
||||
sprockets-rails (3.4.2)
|
||||
@@ -766,11 +766,11 @@ GEM
|
||||
telephone_number (1.4.20)
|
||||
test-prof (1.2.1)
|
||||
thor (1.3.0)
|
||||
tilt (2.2.0)
|
||||
tilt (2.3.0)
|
||||
time_diff (0.3.0)
|
||||
activesupport
|
||||
i18n
|
||||
timeout (0.4.0)
|
||||
timeout (0.4.1)
|
||||
trailblazer-option (0.1.2)
|
||||
twilio-ruby (5.77.0)
|
||||
faraday (>= 0.9, < 3.0)
|
||||
@@ -841,7 +841,7 @@ DEPENDENCIES
|
||||
activerecord-import
|
||||
acts-as-taggable-on
|
||||
administrate (>= 0.19.0)
|
||||
administrate-field-active_storage
|
||||
administrate-field-active_storage (>= 1.0.0)
|
||||
administrate-field-belongs_to_search
|
||||
annotate
|
||||
attr_extras
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
class AccountBuilder
|
||||
include CustomExceptions::Account
|
||||
pattr_initialize [:account_name!, :email!, :confirmed, :user, :user_full_name, :user_password, :super_admin, :locale]
|
||||
pattr_initialize [:email!, :confirmed, :user, :user_password, :super_admin, :locale]
|
||||
|
||||
def perform
|
||||
if @user.nil?
|
||||
@@ -39,7 +39,7 @@ class AccountBuilder
|
||||
end
|
||||
|
||||
def create_account
|
||||
@account = Account.create!(name: @account_name, locale: I18n.locale)
|
||||
@account = Account.create!(name: '', locale: I18n.locale)
|
||||
Current.account = @account
|
||||
end
|
||||
|
||||
@@ -63,8 +63,8 @@ class AccountBuilder
|
||||
def create_user
|
||||
@user = User.new(email: @email,
|
||||
password: user_password,
|
||||
password_confirmation: user_password,
|
||||
name: @user_full_name)
|
||||
name: '',
|
||||
password_confirmation: user_password)
|
||||
@user.type = 'SuperAdmin' if @super_admin
|
||||
@user.confirm if @confirmed
|
||||
@user.save!
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseController
|
||||
RESULTS_PER_PAGE = 15
|
||||
|
||||
before_action :fetch_notification, only: [:update]
|
||||
before_action :fetch_notification, only: [:update, :destroy]
|
||||
before_action :set_primary_actor, only: [:read_all]
|
||||
before_action :set_current_page, only: [:index]
|
||||
|
||||
@@ -28,6 +28,11 @@ 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
|
||||
|
||||
@@ -21,8 +21,6 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
|
||||
def create
|
||||
@user, @account = AccountBuilder.new(
|
||||
account_name: account_params[:account_name],
|
||||
user_full_name: account_params[:user_full_name],
|
||||
email: account_params[:email],
|
||||
user_password: account_params[:password],
|
||||
locale: account_params[:locale],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<with-label
|
||||
:label="label"
|
||||
:name="name"
|
||||
:has-error="hasError"
|
||||
:error-message="errorMessage"
|
||||
>
|
||||
<template #label> {{ label }} <slot /> </template>
|
||||
|
||||
<input
|
||||
:id="name"
|
||||
:name="name"
|
||||
:type="type"
|
||||
autocomplete="off"
|
||||
:required="required"
|
||||
:placeholder="placeholder"
|
||||
:data-testid="dataTestid"
|
||||
:value="value"
|
||||
:class="{
|
||||
'focus:ring-red-600 ring-red-600': hasError,
|
||||
'dark:ring-slate-600 dark:focus:ring-woot-500 ring-slate-200':
|
||||
!hasError,
|
||||
'px-3 py-3': spacing === 'base',
|
||||
'px-3 py-2 mb-0': spacing === 'compact',
|
||||
}"
|
||||
class="block w-full border-none rounded-md shadow-sm appearance-none outline outline-1 outline-slate-200 dark:outline-slate-800 focus:outline-none focus:outline-0 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 focus:ring-2 focus:ring-woot-500 sm:text-sm sm:leading-6 dark:bg-slate-800"
|
||||
@input="onInput"
|
||||
@blur="$emit('blur')"
|
||||
/>
|
||||
</with-label>
|
||||
</template>
|
||||
<script>
|
||||
import WithLabel from './WithLabel.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
WithLabel,
|
||||
},
|
||||
props: {
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'text',
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
value: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
hasError: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
errorMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
dataTestid: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
spacing: {
|
||||
type: String,
|
||||
default: 'base',
|
||||
validator: value => ['base', 'compact'].includes(value),
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onInput(e) {
|
||||
this.$emit('input', e.target.value);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<with-label
|
||||
:label="label"
|
||||
:name="name"
|
||||
:has-error="hasError"
|
||||
:error-message="errorMessage"
|
||||
>
|
||||
<select
|
||||
:id="id"
|
||||
:selected="value"
|
||||
:name="name"
|
||||
:class="{
|
||||
'text-slate-400': !value,
|
||||
'text-slate-900 dark:text-slate-100': value,
|
||||
}"
|
||||
class="block w-full px-3 py-2 pr-6 border-0 rounded-md shadow-sm outline-none appearance-none select-caret ring-1 ring-inset placeholder:text-slate-400 focus:ring-2 focus:ring-inset focus:ring-woot-500 sm:text-sm sm:leading-6 dark:bg-slate-700 dark:ring-slate-600 dark:focus:ring-woot-500 ring-slate-200"
|
||||
@input="onInput"
|
||||
>
|
||||
<option value="" disabled selected class="hidden">
|
||||
{{ placeholder }}
|
||||
</option>
|
||||
<slot>
|
||||
<option v-for="opt in options" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</slot>
|
||||
</select>
|
||||
</with-label>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import WithLabel from './WithLabel.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
WithLabel,
|
||||
},
|
||||
props: {
|
||||
id: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
hasError: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
errorMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onInput(e) {
|
||||
this.$emit('input', e.target.value);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.select-caret {
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' width='32' height='24' viewBox='0 0 32 24'><polygon points='0,0 32,0 16,24' style='fill: rgb%28110, 111, 115%29'></polygon></svg>");
|
||||
background-origin: content-box;
|
||||
background-position: right -1rem center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 9px 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<with-label
|
||||
:label="label"
|
||||
:name="name"
|
||||
:has-error="hasError"
|
||||
:error-message="errorMessage"
|
||||
>
|
||||
<template #label> {{ label }} <slot /> </template>
|
||||
|
||||
<textarea
|
||||
:id="name"
|
||||
:name="name"
|
||||
autocomplete="off"
|
||||
:required="required"
|
||||
:placeholder="placeholder"
|
||||
:data-testid="dataTestid"
|
||||
:value="value"
|
||||
:rows="rows"
|
||||
:class="{
|
||||
'focus:ring-red-600 ring-red-600': hasError,
|
||||
'dark:ring-slate-600 dark:focus:ring-woot-500 ring-slate-200':
|
||||
!hasError,
|
||||
'px-3 py-3': spacing === 'base',
|
||||
'px-3 py-2': spacing === 'compact',
|
||||
'resize-none': !allowResize,
|
||||
}"
|
||||
class="block w-full border-none rounded-md shadow-sm appearance-none outline outline-1 outline-slate-200 dark:outline-slate-800 focus:outline-none focus:outline-0 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 focus:ring-2 focus:ring-woot-500 sm:text-sm sm:leading-6 dark:bg-slate-800"
|
||||
@input="onInput"
|
||||
@blur="$emit('blur')"
|
||||
/>
|
||||
</with-label>
|
||||
</template>
|
||||
<script>
|
||||
import WithLabel from './WithLabel.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
WithLabel,
|
||||
},
|
||||
props: {
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
value: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
rows: {
|
||||
type: Number,
|
||||
default: 3,
|
||||
},
|
||||
allowResize: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
hasError: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
errorMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
dataTestid: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
spacing: {
|
||||
type: String,
|
||||
default: 'base',
|
||||
validator: value => ['base', 'compact'].includes(value),
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onInput(e) {
|
||||
this.$emit('input', e.target.value);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
v-if="label"
|
||||
:for="name"
|
||||
class="flex justify-between text-sm font-medium leading-6 text-slate-900 dark:text-white"
|
||||
:class="{ 'text-red-500': hasError }"
|
||||
>
|
||||
<slot name="label">
|
||||
{{ label }}
|
||||
</slot>
|
||||
</label>
|
||||
<div>
|
||||
<slot />
|
||||
<span
|
||||
v-if="errorMessage && hasError"
|
||||
class="text-xs text-red-400 leading-2"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
hasError: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
errorMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -624,6 +624,8 @@ export default {
|
||||
`${this.$t('CONVERSATION.REPLYBOX.INSERT_READ_MORE')} ${url}`
|
||||
);
|
||||
}
|
||||
|
||||
this.$track(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK);
|
||||
},
|
||||
toggleRichContentEditor() {
|
||||
this.updateUISettings({
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -44,3 +44,4 @@ export default {
|
||||
EXAMPLE_WEBHOOK_URL: 'https://example/api/webhook',
|
||||
};
|
||||
export const DEFAULT_REDIRECT_URL = '/app/';
|
||||
export const ONBOARDING_START_URL = '/app/start/setup-profile';
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -40,5 +40,65 @@
|
||||
},
|
||||
"SUBMIT": "Create account",
|
||||
"HAVE_AN_ACCOUNT": "Already have an account?"
|
||||
},
|
||||
"ONBOARDING": {
|
||||
"PROFILE": {
|
||||
"TITLE": "Tell us about yourself",
|
||||
"BODY": "Here, we'll set up your details like profile picture, display name, contact info, etc., visible to end users, to make a positive first impression.",
|
||||
"SUBMIT": "Continue to company setup",
|
||||
"FULL_NAME": {
|
||||
"LABEL": "Full name",
|
||||
"PLACEHOLDER": "Enter your full name"
|
||||
},
|
||||
"DISPLAY_NAME": {
|
||||
"LABEL": "Display name",
|
||||
"PLACEHOLDER": "Enter the name you want to display your customers"
|
||||
},
|
||||
"PHONE_NUMBER": {
|
||||
"LABEL": "Phone number (Optional)",
|
||||
"PLACEHOLDER": "Enter your phone number"
|
||||
},
|
||||
"SIGNATURE": {
|
||||
"LABEL": "Signature",
|
||||
"PLACEHOLDER": "Add a signature for your messages. You can set this later in your settings."
|
||||
}
|
||||
},
|
||||
"COMPANY": {
|
||||
"TITLE": "Tell us about your company",
|
||||
"BODY": "In this section, we'll set up the language, timezone, and company size. This information allows us to provide contextually accurate information and suggest relevant features for you.",
|
||||
"SUBMIT": "Continue",
|
||||
"COMPANY_NAME": {
|
||||
"LABEL": "Company name",
|
||||
"PLACEHOLDER": "Enter your company name",
|
||||
"ERROR": "Company name is too short."
|
||||
},
|
||||
"INDUSTRY": {
|
||||
"LABEL": "Industry",
|
||||
"PLACEHOLDER": "Select your industry"
|
||||
},
|
||||
"TIMEZONE": {
|
||||
"LABEL": "Timezone",
|
||||
"PLACEHOLDER": "What timezone do you work from?"
|
||||
},
|
||||
"LOCALE": {
|
||||
"LABEL": "Language",
|
||||
"PLACEHOLDER": "Select the language you want to use"
|
||||
},
|
||||
"SIZE": {
|
||||
"LABEL": "Company size",
|
||||
"PLACEHOLDER": "How many people work at your company?"
|
||||
}
|
||||
},
|
||||
"INVITE_TEAM": {
|
||||
"TITLE": "Invite your team",
|
||||
"BODY": "Invite your team members to Chatwoot. You can add them later as well.",
|
||||
"SUBMIT": "Send invitations",
|
||||
"PLACEHOLDER": "robin@chatwoot.com",
|
||||
"SKIP": "Skip for now"
|
||||
},
|
||||
"FOUNDERS_NOTE": {
|
||||
"TITLE": "A note from the founder",
|
||||
"SUBMIT": "Go to dashboard"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
/* eslint-disable storybook/default-exports */
|
||||
import SearchView from './components/SearchView.vue';
|
||||
import { frontendURL } from '../../helper/URLHelper';
|
||||
|
||||
const SearchView = () => import('./components/SearchView.vue');
|
||||
|
||||
export const routes = [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/search'),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint arrow-body-style: 0 */
|
||||
import ContactsView from './components/ContactsView';
|
||||
import ContactManageView from './pages/ContactManageView';
|
||||
import { frontendURL } from '../../../helper/URLHelper';
|
||||
const ContactsView = () => import('./components/ContactsView.vue');
|
||||
const ContactManageView = () => import('./pages/ContactManageView.vue');
|
||||
|
||||
export const routes = [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint arrow-body-style: 0 */
|
||||
import ConversationView from './ConversationView';
|
||||
import { frontendURL } from '../../../helper/URLHelper';
|
||||
const ConversationView = () => import('./ConversationView');
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import AppContainer from './Dashboard';
|
||||
import settings from './settings/settings.routes';
|
||||
import conversation from './conversation/conversation.routes';
|
||||
import { routes as searchRoutes } from '../../modules/search/search.routes';
|
||||
@@ -7,7 +6,12 @@ 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');
|
||||
const SetupProfile = () => import('./start/SetupProfile.vue');
|
||||
const SetupCompany = () => import('./start/SetupCompany.vue');
|
||||
const FoundersNote = () => import('./start/FoundersNote.vue');
|
||||
const InviteTeam = () => import('./start/InviteTeam.vue');
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
@@ -29,5 +33,25 @@ export default {
|
||||
roles: ['administrator', 'agent'],
|
||||
component: Suspended,
|
||||
},
|
||||
{
|
||||
path: frontendURL('start/setup-profile'),
|
||||
name: 'onboarding_setup_profile',
|
||||
component: SetupProfile,
|
||||
},
|
||||
{
|
||||
path: frontendURL('start/setup-company'),
|
||||
name: 'onboarding_setup_company',
|
||||
component: SetupCompany,
|
||||
},
|
||||
{
|
||||
path: frontendURL('start/founders-note'),
|
||||
name: 'onboarding_founders_note',
|
||||
component: FoundersNote,
|
||||
},
|
||||
{
|
||||
path: frontendURL('start/invite-team'),
|
||||
name: 'onboarding_invite-team',
|
||||
component: InviteTeam,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
+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', {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
+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: [
|
||||
{
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<onboarding-layout
|
||||
:title="$t('ONBOARDING.FOUNDERS_NOTE.TITLE')"
|
||||
current-step="founders-note"
|
||||
>
|
||||
<template #body>
|
||||
<section class="mt-4 space-y-3 text-sm leading-relaxed">
|
||||
<p>👋 Hey {{ user.name }},</p>
|
||||
<p>
|
||||
I'm Pranav, one of the co-founders at Chatwoot. Thanks for checking
|
||||
out Chatwoot. We're eager to understand your needs and expectations.
|
||||
</p>
|
||||
<p>
|
||||
If you are facing trouble using the app or achieving your use case, we
|
||||
want to help you as quickly as possible.
|
||||
</p>
|
||||
<p>
|
||||
Feel free to schedule a call with our team. We are open to your
|
||||
feedback. Talk soon!
|
||||
</p>
|
||||
</section>
|
||||
<figure class="mt-5 text-sm leading-relaxed">
|
||||
<thumbnail
|
||||
src="https://www.chatwoot.com/images/team/pranav.jpg"
|
||||
username="Pranav"
|
||||
/>
|
||||
<p class="mt-2">Pranav, <br />Co-founder & CEO, Chatwoot</p>
|
||||
</figure>
|
||||
</template>
|
||||
<submit-button
|
||||
button-class="flex justify-center w-full text-sm text-center"
|
||||
:button-text="$t('ONBOARDING.FOUNDERS_NOTE.SUBMIT')"
|
||||
/>
|
||||
</onboarding-layout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import OnboardingLayout from './components/OnboardingLayout.vue';
|
||||
import Thumbnail from '../../../../dashboard/components/widgets/Thumbnail.vue';
|
||||
import SubmitButton from '../../../components/buttons/FormSubmitButton.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
OnboardingLayout,
|
||||
Thumbnail,
|
||||
SubmitButton,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
user: {
|
||||
name: 'Shivam',
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<onboarding-layout
|
||||
:title="$t('ONBOARDING.INVITE_TEAM.TITLE')"
|
||||
:body="$t('ONBOARDING.INVITE_TEAM.BODY')"
|
||||
current-step="invite"
|
||||
>
|
||||
<section class="space-y-5">
|
||||
<div class="flex gap-2">
|
||||
<form-input
|
||||
v-model="emailToAdd"
|
||||
name="email"
|
||||
spacing="compact"
|
||||
class="flex-grow"
|
||||
:placeholder="$t('ONBOARDING.INVITE_TEAM.PLACEHOLDER')"
|
||||
@keyup.enter="pushEmail"
|
||||
/>
|
||||
<woot-button
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
@click="pushEmail"
|
||||
>
|
||||
Add
|
||||
</woot-button>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-md min-h-[10rem] divide-y divide-slate-100"
|
||||
:class="{
|
||||
'border border-slate-200 dark:border-slate-700 border-dashed grid place-content-center':
|
||||
emailsToInvite.length === 0,
|
||||
}"
|
||||
>
|
||||
<div v-if="emailsToInvite.length === 0" class="px-5 py-5">
|
||||
<p class="text-sm text-slate-400">No members to invite</p>
|
||||
</div>
|
||||
<div
|
||||
v-for="email in emailsToInvite"
|
||||
:key="email"
|
||||
class="flex items-center justify-between gap-2 py-2 first:pt-0 last:pb-0"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<thumbnail :username="email" size="30px" />
|
||||
{{ email }}
|
||||
</div>
|
||||
<woot-button
|
||||
variant="clear"
|
||||
size="small"
|
||||
color-scheme="alert"
|
||||
icon="delete"
|
||||
@click="emailsToInvite.splice(emailsToInvite.indexOf(email), 1)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<submit-button
|
||||
button-class="flex justify-center w-full text-sm text-center"
|
||||
:button-text="$t('ONBOARDING.INVITE_TEAM.SUBMIT')"
|
||||
/>
|
||||
|
||||
<woot-button
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
size="expanded"
|
||||
is-expanded
|
||||
>
|
||||
{{ $t('ONBOARDING.INVITE_TEAM.SKIP') }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</section>
|
||||
</onboarding-layout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FormInput from '../../../components/form/Input.vue';
|
||||
import WootButton from '../../../components/ui/WootButton.vue';
|
||||
import Thumbnail from '../../../../dashboard/components/widgets/Thumbnail.vue';
|
||||
import OnboardingLayout from './components/OnboardingLayout.vue';
|
||||
import SubmitButton from '../../../components/buttons/FormSubmitButton.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
OnboardingLayout,
|
||||
WootButton,
|
||||
Thumbnail,
|
||||
SubmitButton,
|
||||
FormInput,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
emailsToInvite: [],
|
||||
emailToAdd: '',
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
pushEmail() {
|
||||
if (!this.emailToAdd) return;
|
||||
this.emailsToInvite.push(this.emailToAdd);
|
||||
this.emailsToInvite = [...new Set(this.emailsToInvite)];
|
||||
this.emailToAdd = '';
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<onboarding-layout
|
||||
:title="$t('ONBOARDING.COMPANY.TITLE')"
|
||||
:body="$t('ONBOARDING.COMPANY.BODY')"
|
||||
current-step="company"
|
||||
>
|
||||
<form class="space-y-8">
|
||||
<div class="space-y-3">
|
||||
<form-input
|
||||
v-model="companyName"
|
||||
name="companyName"
|
||||
spacing="compact"
|
||||
:label="$t('ONBOARDING.COMPANY.COMPANY_NAME.LABEL')"
|
||||
:placeholder="$t('ONBOARDING.COMPANY.COMPANY_NAME.PLACEHOLDER')"
|
||||
:error-message="$t('ONBOARDING.COMPANY.COMPANY_NAME.ERROR')"
|
||||
/>
|
||||
<form-input
|
||||
v-model="industry"
|
||||
name="industry"
|
||||
spacing="compact"
|
||||
:label="$t('ONBOARDING.COMPANY.INDUSTRY.LABEL')"
|
||||
:placeholder="$t('ONBOARDING.COMPANY.INDUSTRY.PLACEHOLDER')"
|
||||
/>
|
||||
<form-input
|
||||
v-model="timezone"
|
||||
name="timezone"
|
||||
spacing="compact"
|
||||
:label="$t('ONBOARDING.COMPANY.TIMEZONE.LABEL')"
|
||||
:placeholder="$t('ONBOARDING.COMPANY.TIMEZONE.PLACEHOLDER')"
|
||||
/>
|
||||
<form-select
|
||||
v-model="locale"
|
||||
name="locale"
|
||||
:label="$t('ONBOARDING.COMPANY.LOCALE.LABEL')"
|
||||
:placeholder="$t('ONBOARDING.COMPANY.LOCALE.PLACEHOLDER')"
|
||||
>
|
||||
<option
|
||||
v-for="lang in languagesSortedByCode"
|
||||
:key="lang.iso_639_1_code"
|
||||
:value="lang.iso_639_1_code"
|
||||
>
|
||||
{{ lang.name }}
|
||||
</option>
|
||||
</form-select>
|
||||
<form-select
|
||||
v-model="companySize"
|
||||
name="companySize"
|
||||
:label="$t('ONBOARDING.COMPANY.SIZE.LABEL')"
|
||||
:placeholder="$t('ONBOARDING.COMPANY.SIZE.PLACEHOLDER')"
|
||||
:options="companySizeOptions"
|
||||
/>
|
||||
</div>
|
||||
<submit-button
|
||||
button-class="flex justify-center w-full text-sm text-center"
|
||||
:button-text="$t('ONBOARDING.COMPANY.SUBMIT')"
|
||||
/>
|
||||
</form>
|
||||
</onboarding-layout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FormInput from '../../../components/form/Input.vue';
|
||||
import FormSelect from '../../../components/form/Select.vue';
|
||||
import SubmitButton from '../../../components/buttons/FormSubmitButton.vue';
|
||||
import OnboardingLayout from './components/OnboardingLayout.vue';
|
||||
import configMixin from 'shared/mixins/configMixin';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
FormInput,
|
||||
SubmitButton,
|
||||
OnboardingLayout,
|
||||
FormSelect,
|
||||
},
|
||||
mixins: [configMixin],
|
||||
data() {
|
||||
return {
|
||||
companyName: '',
|
||||
timezone: '',
|
||||
locale: '',
|
||||
companySize: '',
|
||||
industry: '',
|
||||
companySizeOptions: [
|
||||
{ value: '1-10', label: '1-10' },
|
||||
{ value: '11-50', label: '11-50' },
|
||||
{ value: '51-200', label: '51-200' },
|
||||
{ value: '201-500', label: '201-500' },
|
||||
{ value: '501-1000', label: '501-1000' },
|
||||
{ value: '1001-5000', label: '1001-5000' },
|
||||
{ value: '5001+', label: 'Over 5000' },
|
||||
],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
languagesSortedByCode() {
|
||||
const enabledLanguages = [...this.enabledLanguages];
|
||||
return enabledLanguages.sort((l1, l2) =>
|
||||
l1.iso_639_1_code.localeCompare(l2.iso_639_1_code)
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<onboarding-layout
|
||||
:title="$t('ONBOARDING.PROFILE.TITLE')"
|
||||
:body="$t('ONBOARDING.PROFILE.BODY')"
|
||||
current-step="profile"
|
||||
>
|
||||
<form class="mt-8 space-y-8">
|
||||
<div class="space-y-3">
|
||||
<form-input
|
||||
v-model="fullName"
|
||||
name="full_name"
|
||||
spacing="compact"
|
||||
:label="$t('ONBOARDING.PROFILE.FULL_NAME.LABEL')"
|
||||
:placeholder="$t('ONBOARDING.PROFILE.FULL_NAME.PLACEHOLDER')"
|
||||
:error-message="$t('ONBOARDING.PROFILE.FULL_NAME.ERROR')"
|
||||
/>
|
||||
<form-input
|
||||
v-model="displayName"
|
||||
name="display_name"
|
||||
spacing="compact"
|
||||
:label="$t('ONBOARDING.PROFILE.DISPLAY_NAME.LABEL')"
|
||||
:placeholder="$t('ONBOARDING.PROFILE.DISPLAY_NAME.PLACEHOLDER')"
|
||||
/>
|
||||
<form-input
|
||||
v-model="phoneNumber"
|
||||
name="phone_number"
|
||||
spacing="compact"
|
||||
:label="$t('ONBOARDING.PROFILE.PHONE_NUMBER.LABEL')"
|
||||
:placeholder="$t('ONBOARDING.PROFILE.PHONE_NUMBER.PLACEHOLDER')"
|
||||
/>
|
||||
<form-text-area
|
||||
v-model="signature"
|
||||
name="signature"
|
||||
spacing="compact"
|
||||
:allow-resize="false"
|
||||
:label="$t('ONBOARDING.PROFILE.SIGNATURE.LABEL')"
|
||||
:placeholder="$t('ONBOARDING.PROFILE.SIGNATURE.PLACEHOLDER')"
|
||||
/>
|
||||
</div>
|
||||
<submit-button
|
||||
button-class="flex justify-center w-full text-sm text-center"
|
||||
:button-text="$t('ONBOARDING.PROFILE.SUBMIT')"
|
||||
/>
|
||||
</form>
|
||||
</onboarding-layout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FormInput from '../../../components/form/Input.vue';
|
||||
import FormTextArea from '../../../components/form/TextArea.vue';
|
||||
import SubmitButton from '../../../components/buttons/FormSubmitButton.vue';
|
||||
import OnboardingLayout from './components/OnboardingLayout.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
FormInput,
|
||||
SubmitButton,
|
||||
OnboardingLayout,
|
||||
FormTextArea,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
fullName: '',
|
||||
displayName: '',
|
||||
phoneNumber: '',
|
||||
signature: '',
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<section
|
||||
class="relative h-screen px-8 py-12 dark:text-white bg-woot-25 dark:bg-slate-900"
|
||||
>
|
||||
<div class="absolute inset-0 w-full h-screen overflow-hidden">
|
||||
<img
|
||||
id="bg-grad"
|
||||
src="/onboarding/bg-gradient.png"
|
||||
class="w-full opacity-50 dark:mix-blend-color"
|
||||
/>
|
||||
</div>
|
||||
<div class="relative max-w-lg mx-auto">
|
||||
<div
|
||||
id="steps"
|
||||
class="rounded-md w-48 -left-52 dark:shadow-[#000] p-1 border shadow-sm border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 absolute grid gap-1"
|
||||
>
|
||||
<h6
|
||||
class="px-2 pt-1 font-bold tracking-widest uppercase text-xxs text-slate-500"
|
||||
>
|
||||
Get Started
|
||||
</h6>
|
||||
<onboarding-step
|
||||
v-for="(step, index) in steps"
|
||||
:key="step.name"
|
||||
v-bind="step"
|
||||
:step-number="index + 1"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="modal-body"
|
||||
class="dark:shadow-[#000] rounded-md mx-auto p-9 border shadow border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900"
|
||||
>
|
||||
<h2 class="text-lg font-bold tracking-tight dark:text-white">
|
||||
<slot name="title">
|
||||
{{ title }}
|
||||
</slot>
|
||||
</h2>
|
||||
<slot name="body">
|
||||
<p class="mt-2 text-sm dark:text-slate-200">
|
||||
{{ body }}
|
||||
</p>
|
||||
</slot>
|
||||
<div class="mt-8">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import OnboardingStep from './OnboardingStep.vue';
|
||||
|
||||
const UISteps = [
|
||||
{
|
||||
name: 'profile',
|
||||
title: 'Setup Profile',
|
||||
isActive: false,
|
||||
isComplete: false,
|
||||
},
|
||||
{
|
||||
name: 'company',
|
||||
title: 'Setup Company',
|
||||
isActive: false,
|
||||
isComplete: false,
|
||||
},
|
||||
{
|
||||
name: 'invite',
|
||||
title: 'Invite your team',
|
||||
isActive: false,
|
||||
isComplete: false,
|
||||
},
|
||||
{
|
||||
name: 'founders-note',
|
||||
title: "Founder's Note",
|
||||
hidden: true,
|
||||
isActive: false,
|
||||
isComplete: false,
|
||||
},
|
||||
];
|
||||
|
||||
export default {
|
||||
components: {
|
||||
OnboardingStep,
|
||||
},
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
body: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
currentStep: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
steps() {
|
||||
let foundCurrentStep = false;
|
||||
|
||||
return UISteps.map(step => {
|
||||
if (step.name === this.currentStep) {
|
||||
foundCurrentStep = true;
|
||||
}
|
||||
|
||||
return {
|
||||
...step,
|
||||
isActive: step.name === this.currentStep,
|
||||
isComplete: !foundCurrentStep,
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="!hidden"
|
||||
class="flex items-center gap-2 p-2 text-sm rounded-md"
|
||||
:class="{
|
||||
'text-slate-800 dark:text-slate-100 bg-slate-50 dark:bg-slate-800':
|
||||
isActive,
|
||||
'opacity-50': !isActive && !isComplete,
|
||||
hidden: hidden,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="grid w-4 h-4 font-bold rounded-full text-xxs place-content-center"
|
||||
:class="{
|
||||
'bg-slate-200 dark:bg-slate-600': !isComplete,
|
||||
'bg-green-500': isComplete,
|
||||
}"
|
||||
>
|
||||
<fluent-icon
|
||||
v-if="isComplete"
|
||||
size="13"
|
||||
class="text-white"
|
||||
icon="checkmark"
|
||||
/>
|
||||
<template v-else>
|
||||
{{ stepNumber }}
|
||||
</template>
|
||||
</div>
|
||||
<span
|
||||
:class="{
|
||||
'line-through': isComplete,
|
||||
}"
|
||||
>
|
||||
{{ title }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ModalStep',
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
stepNumber: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hidden: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isComplete: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -45,11 +45,19 @@ export const validateAuthenticateRoutePermission = (to, next, { getters }) => {
|
||||
return next(frontendURL(`accounts/${user.account_id}/dashboard`));
|
||||
}
|
||||
|
||||
// we can let them access onboarding routes
|
||||
// we will eventually need guards as onboarding is done, but for now
|
||||
// we can let them access onboarding routes
|
||||
if (to.name.startsWith('onboarding')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const nextRoute = validateLoggedInRoutes(
|
||||
to,
|
||||
getters.getCurrentUser,
|
||||
window.roleWiseRoutes
|
||||
);
|
||||
|
||||
return nextRoute ? next(frontendURL(nextRoute)) : next();
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,8 +28,6 @@ export const login = async ({
|
||||
export const register = async creds => {
|
||||
try {
|
||||
const response = await wootAPI.post('api/v1/accounts.json', {
|
||||
account_name: creds.accountName.trim(),
|
||||
user_full_name: creds.fullName.trim(),
|
||||
email: creds.email,
|
||||
password: creds.password,
|
||||
h_captcha_client_response: creds.hCaptchaClientResponse,
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<label
|
||||
v-if="label"
|
||||
:for="name"
|
||||
class="flex justify-between text-sm font-medium leading-6 text-slate-900 dark:text-white"
|
||||
:class="{ 'text-red-500': hasError }"
|
||||
>
|
||||
{{ label }}
|
||||
<slot />
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
<input
|
||||
:id="name"
|
||||
:name="name"
|
||||
:type="type"
|
||||
autocomplete="off"
|
||||
:required="required"
|
||||
:placeholder="placeholder"
|
||||
:data-testid="dataTestid"
|
||||
:value="value"
|
||||
:class="{
|
||||
'focus:ring-red-600 ring-red-600': hasError,
|
||||
'dark:ring-slate-600 dark:focus:ring-woot-500 ring-slate-200':
|
||||
!hasError,
|
||||
}"
|
||||
class="block w-full rounded-md border-0 px-3 py-3 appearance-none shadow-sm ring-1 ring-inset text-slate-900 dark:text-slate-100 placeholder:text-slate-400 focus:ring-2 focus:ring-inset focus:ring-woot-500 sm:text-sm sm:leading-6 outline-none dark:bg-slate-700"
|
||||
@input="onInput"
|
||||
@blur="$emit('blur')"
|
||||
/>
|
||||
<span
|
||||
v-if="errorMessage && hasError"
|
||||
class="text-xs leading-2 text-red-400"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'text',
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
value: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
hasError: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
errorMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
dataTestid: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onInput(e) {
|
||||
this.$emit('input', e.target.value);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -49,7 +49,7 @@
|
||||
|
||||
<script>
|
||||
import { required, minLength } from 'vuelidate/lib/validators';
|
||||
import FormInput from '../../../components/Form/Input.vue';
|
||||
import FormInput from 'dashboard/components/form/Input.vue';
|
||||
import SubmitButton from '../../../components/Button/SubmitButton.vue';
|
||||
import { DEFAULT_REDIRECT_URL } from 'dashboard/constants/globals';
|
||||
import { setNewPassword } from '../../../api/auth';
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
import { required, minLength, email } from 'vuelidate/lib/validators';
|
||||
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
|
||||
import { mapGetters } from 'vuex';
|
||||
import FormInput from '../../../../components/Form/Input.vue';
|
||||
import FormInput from 'dashboard/components/form/Input.vue';
|
||||
import { resetPassword } from '../../../../api/auth';
|
||||
import SubmitButton from '../../../../components/Button/SubmitButton.vue';
|
||||
|
||||
|
||||
@@ -1,52 +1,30 @@
|
||||
<template>
|
||||
<div class="flex-1 overflow-auto px-1">
|
||||
<form class="space-y-3" @submit.prevent="submit">
|
||||
<div class="flex">
|
||||
<div class="flex-1 px-1 overflow-auto">
|
||||
<form class="space-y-8" @submit.prevent="submit">
|
||||
<section class="space-y-3">
|
||||
<form-input
|
||||
v-model.trim="credentials.fullName"
|
||||
name="full_name"
|
||||
class="flex-1"
|
||||
:class="{ error: $v.credentials.fullName.$error }"
|
||||
:label="$t('REGISTER.FULL_NAME.LABEL')"
|
||||
:placeholder="$t('REGISTER.FULL_NAME.PLACEHOLDER')"
|
||||
:has-error="$v.credentials.fullName.$error"
|
||||
:error-message="$t('REGISTER.FULL_NAME.ERROR')"
|
||||
@blur="$v.credentials.fullName.$touch"
|
||||
v-model.trim="credentials.email"
|
||||
type="email"
|
||||
name="email_address"
|
||||
:class="{ error: $v.credentials.email.$error }"
|
||||
:label="$t('REGISTER.EMAIL.LABEL')"
|
||||
:placeholder="$t('REGISTER.EMAIL.PLACEHOLDER')"
|
||||
:has-error="$v.credentials.email.$error"
|
||||
:error-message="$t('REGISTER.EMAIL.ERROR')"
|
||||
@blur="$v.credentials.email.$touch"
|
||||
/>
|
||||
<form-input
|
||||
v-model.trim="credentials.accountName"
|
||||
name="account_name"
|
||||
class="flex-1 ml-2"
|
||||
:class="{ error: $v.credentials.accountName.$error }"
|
||||
:label="$t('REGISTER.COMPANY_NAME.LABEL')"
|
||||
:placeholder="$t('REGISTER.COMPANY_NAME.PLACEHOLDER')"
|
||||
:has-error="$v.credentials.accountName.$error"
|
||||
:error-message="$t('REGISTER.COMPANY_NAME.ERROR')"
|
||||
@blur="$v.credentials.accountName.$touch"
|
||||
v-model.trim="credentials.password"
|
||||
type="password"
|
||||
name="password"
|
||||
:class="{ error: $v.credentials.password.$error }"
|
||||
:label="$t('LOGIN.PASSWORD.LABEL')"
|
||||
:placeholder="$t('SET_NEW_PASSWORD.PASSWORD.PLACEHOLDER')"
|
||||
:has-error="$v.credentials.password.$error"
|
||||
:error-message="passwordErrorText"
|
||||
@blur="$v.credentials.password.$touch"
|
||||
/>
|
||||
</div>
|
||||
<form-input
|
||||
v-model.trim="credentials.email"
|
||||
type="email"
|
||||
name="email_address"
|
||||
:class="{ error: $v.credentials.email.$error }"
|
||||
:label="$t('REGISTER.EMAIL.LABEL')"
|
||||
:placeholder="$t('REGISTER.EMAIL.PLACEHOLDER')"
|
||||
:has-error="$v.credentials.email.$error"
|
||||
:error-message="$t('REGISTER.EMAIL.ERROR')"
|
||||
@blur="$v.credentials.email.$touch"
|
||||
/>
|
||||
<form-input
|
||||
v-model.trim="credentials.password"
|
||||
type="password"
|
||||
name="password"
|
||||
:class="{ error: $v.credentials.password.$error }"
|
||||
:label="$t('LOGIN.PASSWORD.LABEL')"
|
||||
:placeholder="$t('SET_NEW_PASSWORD.PASSWORD.PLACEHOLDER')"
|
||||
:has-error="$v.credentials.password.$error"
|
||||
:error-message="passwordErrorText"
|
||||
@blur="$v.credentials.password.$touch"
|
||||
/>
|
||||
</section>
|
||||
<div v-if="globalConfig.hCaptchaSiteKey" class="mb-3">
|
||||
<vue-hcaptcha
|
||||
ref="hCaptcha"
|
||||
@@ -83,9 +61,9 @@ import { required, minLength, email } from 'vuelidate/lib/validators';
|
||||
import { mapGetters } from 'vuex';
|
||||
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import { DEFAULT_REDIRECT_URL } from 'dashboard/constants/globals';
|
||||
import { ONBOARDING_START_URL } from 'dashboard/constants/globals';
|
||||
import VueHcaptcha from '@hcaptcha/vue-hcaptcha';
|
||||
import FormInput from '../../../../../components/Form/Input.vue';
|
||||
import FormInput from 'dashboard/components/form/Input.vue';
|
||||
import SubmitButton from '../../../../../components/Button/SubmitButton.vue';
|
||||
import { isValidPassword } from 'shared/helpers/Validators';
|
||||
import GoogleOAuthButton from '../../../../../components/GoogleOauth/Button.vue';
|
||||
@@ -103,10 +81,8 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
credentials: {
|
||||
accountName: '',
|
||||
fullName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
email: 'Testing123@shivam.dev',
|
||||
password: 'Testing123@shivam.dev',
|
||||
hCaptchaClientResponse: '',
|
||||
},
|
||||
didCaptchaReset: false,
|
||||
@@ -116,14 +92,6 @@ export default {
|
||||
},
|
||||
validations: {
|
||||
credentials: {
|
||||
accountName: {
|
||||
required,
|
||||
minLength: minLength(2),
|
||||
},
|
||||
fullName: {
|
||||
required,
|
||||
minLength: minLength(2),
|
||||
},
|
||||
email: {
|
||||
required,
|
||||
email,
|
||||
@@ -181,7 +149,7 @@ export default {
|
||||
this.isSignupInProgress = true;
|
||||
try {
|
||||
await register(this.credentials);
|
||||
window.location = DEFAULT_REDIRECT_URL;
|
||||
window.location = ONBOARDING_START_URL;
|
||||
} catch (error) {
|
||||
let errorMessage =
|
||||
error?.message || this.$t('REGISTER.API.ERROR_MESSAGE');
|
||||
|
||||
@@ -90,7 +90,7 @@ import SubmitButton from '../../components/Button/SubmitButton.vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { parseBoolean } from '@chatwoot/utils';
|
||||
import GoogleOAuthButton from '../../components/GoogleOauth/Button.vue';
|
||||
import FormInput from '../../components/Form/Input.vue';
|
||||
import FormInput from 'dashboard/components/form/Input.vue';
|
||||
import { login } from '../../api/auth';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
const ERROR_MESSAGES = {
|
||||
|
||||
@@ -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
|
||||
@@ -32,7 +32,6 @@ class Account < ApplicationRecord
|
||||
check_for_column: false
|
||||
}.freeze
|
||||
|
||||
validates :name, presence: true
|
||||
validates :auto_resolve_duration, numericality: { greater_than_or_equal_to: 1, less_than_or_equal_to: 999, allow_nil: true }
|
||||
validates :domain, length: { maximum: 100 }
|
||||
|
||||
|
||||
+1
-2
@@ -68,8 +68,7 @@ class User < ApplicationRecord
|
||||
# work because :validatable in devise overrides this.
|
||||
# validates_uniqueness_of :email, scope: :account_id
|
||||
|
||||
validates :email, :name, presence: true
|
||||
validates_length_of :name, minimum: 1, maximum: 255
|
||||
validates :email, presence: true
|
||||
|
||||
has_many :account_users, dependent: :destroy_async
|
||||
has_many :accounts, through: :account_users
|
||||
|
||||
@@ -108,7 +108,9 @@ class Twilio::IncomingMessageService
|
||||
return if params[:MediaUrl0].blank?
|
||||
|
||||
attachment_file = Down.download(
|
||||
params[:MediaUrl0]
|
||||
params[:MediaUrl0],
|
||||
# https://support.twilio.com/hc/en-us/articles/223183748-Protect-Media-Access-with-HTTP-Basic-Authentication-for-Programmable-Messaging
|
||||
http_basic_authentication: [twilio_channel.account_sid, twilio_channel.auth_token || twilio_channel.api_key_sid]
|
||||
)
|
||||
|
||||
attachment = @message.attachments.new(
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 846 B |
@@ -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,20 +1,20 @@
|
||||
<% featured_articles = articles.where(category_id: categories).search_by_status(:published).order_by_views.limit(6) %>
|
||||
<% if featured_articles.count >= 6 %>
|
||||
<section class="lg:container w-full flex flex-col h-full">
|
||||
<div class="flex flex-col gap-5 border border-solid border-slate-100 dark:border-slate-800 rounded-lg py-5 px-3">
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<div class="flex items-start flex-col gap-1">
|
||||
<section class="flex flex-col w-full h-full lg:container">
|
||||
<div class="flex flex-col gap-5 px-3 py-5 border border-solid rounded-lg border-slate-100 dark:border-slate-800">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<div class="flex flex-row items-center gap-2 px-2">
|
||||
<h3 class="text-xl text-slate-800 dark:text-slate-50 font-semibold leading-relaxed">
|
||||
<h3 class="text-xl font-semibold leading-relaxed text-slate-800 dark:text-slate-50">
|
||||
<%= I18n.t('public_portal.header.featured_articles') %>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-2 gap-y-2 gap-2">
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-2 gap-x-2 gap-y-2">
|
||||
<% featured_articles.each do |article| %>
|
||||
<a class="text-slate-700 dark:text-slate-100 leading-7" href="<%= generate_article_link(portal.slug, article.slug, @theme) %>">
|
||||
<div class="flex justify-between items-start py-1 px-2 rounded-lg gap-3 hover:bg-slate-50 dark:hover:bg-slate-800">
|
||||
<a class="leading-7 text-slate-700 dark:text-slate-100" href="<%= generate_article_link(portal.slug, article.slug, @theme_from_params, @is_plain_layout_enabled) %>">
|
||||
<div id="category-item" class="flex items-start justify-between gap-6 px-2 py-1 rounded-lg">
|
||||
<%= article.title %>
|
||||
<span class="flex items-center font-normal mt-1.5">
|
||||
<%= render partial: 'icons/chevron-right' %>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<header class="bg-white dark:bg-slate-900 w-full shadow-sm sticky top-0 z-50">
|
||||
<nav class=" flex mx-auto max-w-5xl px-4 md:px-8" aria-label="Top">
|
||||
<div class="w-full py-5 flex items-center overflow-hidden">
|
||||
<a href="/hc/<%= @portal.slug %>/<%= @portal.config['default_locale'] || params[:locale] %>/<%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>" class="h-10 text-lg flex items-center text-slate-900 dark:text-white font-semibold">
|
||||
<header class="sticky top-0 z-50 w-full bg-white shadow-sm dark:bg-slate-900">
|
||||
<nav class="flex max-w-5xl px-4 mx-auto md:px-8" aria-label="Top">
|
||||
<div class="flex items-center w-full py-5 overflow-hidden">
|
||||
<a href="<%= generate_home_link(@portal.slug, @portal.config['default_locale'] || params[:locale], @theme_from_params, @is_plain_layout_enabled) %>" class="flex items-center h-10 text-lg font-semibold text-slate-900 dark:text-white">
|
||||
<% if @portal.logo.present? %>
|
||||
<img src="<%= url_for(@portal.logo) %>" class="h-10 w-auto mr-2" />
|
||||
<img src="<%= url_for(@portal.logo) %>" class="w-auto h-10 mr-2" />
|
||||
<% end %>
|
||||
<%= @portal.name %>
|
||||
</a>
|
||||
@@ -12,9 +12,9 @@
|
||||
<%# Go to homepage link section %>
|
||||
<div class="flex items-center justify-between gap-2 sm:gap-5">
|
||||
<% if @portal.homepage_link %>
|
||||
<div class="ml-8 border-l-1 border-slate-50 dark:border-slate-800 hidden md:block cursor-pointer px-1 py-2">
|
||||
<div class="hidden px-1 py-2 ml-8 cursor-pointer border-l-1 border-slate-50 dark:border-slate-800 md:block">
|
||||
<div class="flex-grow flex-shrink-0">
|
||||
<a target="_blank" rel="noopener noreferrer nofollow" href="<%= @portal.homepage_link %>" class="flex flex-row items-center gap-1 text-sm font-medium whitespace-nowrap text-slate-800 dark:text-slate-100 hover:text-woot-500 dark:hover:text-woot-500 stroke-slate-700 dark:stroke-slate-200 hover:stroke-woot-500 dark:hover:stroke-woot-500">
|
||||
<a id="header-action-button" target="_blank" rel="noopener noreferrer nofollow" href="<%= @portal.homepage_link %>" class="flex flex-row items-center gap-1 text-sm font-medium whitespace-nowrap text-slate-800 dark:text-slate-100 stroke-slate-700 dark:stroke-slate-200">
|
||||
<%= render partial: 'icons/redirect' %>
|
||||
<%= I18n.t('public_portal.header.go_to_homepage') %>
|
||||
</a>
|
||||
@@ -23,54 +23,64 @@
|
||||
<% end %>
|
||||
|
||||
<%# Appearance toggle section %>
|
||||
<div class="flex-grow flex-shrink-0 relative cursor-pointer px-1 py-2">
|
||||
<button id="toggle-appearance" class="toggle-appearance flex flex-row items-center stroke-slate-700 dark:stroke-slate-200 hover:stroke-woot-500 dark:hover:stroke-woot-500 text-slate-800 dark:text-slate-100 hover:text-woot-500 dark:hover:text-woot-500 gap-1" type="button">
|
||||
<%= render partial: get_theme_icon(@theme) %>
|
||||
<span class="text-sm font-medium"><%= get_theme_names(@theme) %></span>
|
||||
<div class="pointer-events-none flex items-center px-1">
|
||||
<div class="relative flex-grow flex-shrink-0 px-1 py-2 cursor-pointer">
|
||||
<button id="toggle-appearance" class="flex justify-between min-w-[76px] flex-row items-center stroke-slate-700 dark:stroke-slate-200 text-slate-800 dark:text-slate-100 gap-1" type="button">
|
||||
<div data-theme="system" class="flex-row items-center gap-1 theme-button <%= @theme_from_params == 'system' ? 'flex' : 'hidden' %>">
|
||||
<%= render partial: 'icons/monitor' %>
|
||||
<span class="text-sm font-medium"><%= I18n.t('public_portal.header.appearance.system') %></span>
|
||||
</div>
|
||||
<div data-theme="light" class="flex-row items-center gap-1 theme-button <%= @theme_from_params == 'light' ? 'flex' : 'hidden' %>">
|
||||
<%= render partial: 'icons/sun' %>
|
||||
<span class="text-sm font-medium"><%= I18n.t('public_portal.header.appearance.light') %></span>
|
||||
</div>
|
||||
<div data-theme="dark" class="flex-row items-center gap-1 theme-button <%= @theme_from_params == 'dark' ? 'flex' : 'hidden' %>">
|
||||
<%= render partial: 'icons/moon' %>
|
||||
<span class="text-sm font-medium"><%= I18n.t('public_portal.header.appearance.dark') %></span>
|
||||
</div>
|
||||
<div class="flex items-center px-1 pointer-events-none">
|
||||
<%= render partial: 'icons/chevron-down' %>
|
||||
</div>
|
||||
</button>
|
||||
<%# Appearance dropdown section %>
|
||||
<div id="appearance-dropdown" class="hidden absolute flex-col h-auto w-32 bg-white dark:bg-slate-900 border border-solid rounded top-9 border-slate-100 dark:border-slate-800" aria-hidden="true" data-dropdown="appearance-dropdown">
|
||||
<button data-theme="system" class="flex flex-row items-center justify-between px-2 py-2 border-b border-solid border-slate-100 dark:border-slate-800 gap-1 stroke-slate-700 dark:stroke-slate-200 hover:stroke-woot-500 dark:hover:stroke-woot-500 text-slate-800 dark:text-slate-100 hover:text-woot-500 dark:hover:text-woot-500">
|
||||
<div id="appearance-dropdown" data-current-theme="<%= @theme_from_params %>" class="absolute flex-col w-32 h-auto bg-white border border-solid rounded dark:bg-slate-900 top-9 right-1 border-slate-100 dark:border-slate-800" aria-hidden="true" style="display: none;" data-dropdown="appearance-dropdown">
|
||||
<button id="toggle-theme-button" data-theme="system" class="flex flex-row items-center justify-between gap-1 px-2 py-2 border-b border-solid border-slate-100 dark:border-slate-800 stroke-slate-700 dark:stroke-slate-200 text-slate-800 dark:text-slate-100">
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<%= render partial: 'icons/monitor' %>
|
||||
<span class="text-xs font-medium"><%= I18n.t('public_portal.header.appearance.system') %></span>
|
||||
</div>
|
||||
<% if @theme.present? && @theme == 'system' %>
|
||||
<span class="check-mark-icon system-theme">
|
||||
<%= render partial: 'icons/check-mark' %>
|
||||
<% end %>
|
||||
</span>
|
||||
</button>
|
||||
<button data-theme="light" class="flex flex-row items-center justify-between px-2 py-2 border-b border-solid border-slate-100 dark:border-slate-800 gap-1 stroke-slate-700 dark:stroke-slate-200 hover:stroke-woot-500 dark:hover:stroke-woot-500 text-slate-800 dark:text-slate-100 hover:text-woot-500 dark:hover:text-woot-500">
|
||||
<button id="toggle-theme-button" data-theme="light" class="flex flex-row items-center justify-between gap-1 px-2 py-2 border-b border-solid border-slate-100 dark:border-slate-800 stroke-slate-700 dark:stroke-slate-200 text-slate-800 dark:text-slate-100">
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<%= render partial: 'icons/sun' %>
|
||||
<span class="text-xs font-medium"><%= I18n.t('public_portal.header.appearance.light') %></span>
|
||||
</div>
|
||||
<% if @theme.present? && @theme == 'light' %>
|
||||
<span class="check-mark-icon light-theme">
|
||||
<%= render partial: 'icons/check-mark' %>
|
||||
<% end %>
|
||||
</span>
|
||||
</button>
|
||||
<button data-theme="dark" class="flex flex-row items-center justify-between px-2 py-2 gap-1 stroke-slate-700 dark:stroke-slate-200 hover:stroke-woot-500 dark:hover:stroke-woot-500 text-slate-800 dark:text-slate-100 hover:text-woot-500 dark:hover:text-woot-500">
|
||||
<button id="toggle-theme-button" data-theme="dark" class="flex flex-row items-center justify-between gap-1 px-2 py-2 stroke-slate-700 dark:stroke-slate-200 text-slate-800 dark:text-slate-100">
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<%= render partial: 'icons/moon' %>
|
||||
<span class="text-xs font-medium"><%= I18n.t('public_portal.header.appearance.dark') %></span>
|
||||
</div>
|
||||
<% if @theme.present? && @theme == 'dark' %>
|
||||
<span class="check-mark-icon dark-theme">
|
||||
<%= render partial: 'icons/check-mark' %>
|
||||
<% end %>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# Locale switcher section %>
|
||||
<% if @portal.config["allowed_locales"].length > 1 %>
|
||||
<div class="flex items-center stroke-slate-700 dark:stroke-slate-200 hover:stroke-woot-500 dark:hover:stroke-woot-500 text-slate-800 dark:text-slate-100 hover:text-woot-500 dark:hover:text-woot-500">
|
||||
<div id="header-action-button" class="flex items-center stroke-slate-700 dark:stroke-slate-200 text-slate-800 dark:text-slate-100">
|
||||
<div class="flex items-center gap-1 px-1 py-2 cursor-pointer">
|
||||
<%= render partial: 'icons/globe' %>
|
||||
<select
|
||||
data-portal-slug="<%= @portal.slug %>"
|
||||
class="bg-white dark:bg-slate-900 appearance-none w-24 overflow-hidden text-ellipsis whitespace-nowrap leading-tight font-medium focus:outline-none text-sm focus:shadow-outline locale-switcher cursor-pointer"
|
||||
class="w-24 overflow-hidden text-sm font-medium leading-tight bg-white appearance-none cursor-pointer dark:bg-slate-900 text-ellipsis whitespace-nowrap focus:outline-none focus:shadow-outline locale-switcher"
|
||||
>
|
||||
<% @portal.config["allowed_locales"].each do |locale| %>
|
||||
<option <%= locale == params[:locale] ? 'selected': '' %> value="<%= locale %>"><%= "#{language_name(locale)} (#{locale})" %></option>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<% if !@is_plain_layout_enabled %>
|
||||
<section id="portal-bg" class="w-full bg-woot-50 dark:bg-woot-900 shadow-inner" style="<%= generate_portal_bg(@portal.color, @theme) %>">
|
||||
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6" style="<%= generate_gradient_to_bottom(@theme) %>">
|
||||
<section id="portal-bg" class="w-full bg-white dark:bg-slate-900 shadow-inner">
|
||||
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6 min-h-[240px] md:min-h-[260px]">
|
||||
<div class="mx-auto max-w-5xl px-4 md:px-8 flex flex-col items-center sm:items-start">
|
||||
<h1 class="text-2xl md:text-4xl text-slate-900 dark:text-white font-semibold leading-normal">
|
||||
<%= portal.header_text %>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<% if author&.avatar_url&.present? %>
|
||||
<img src="<%= url_for(author.avatar_url) %>" alt="<%= author.name %>" class="w-<%= size %> h-<%= size %> rounded-full border border-solid border-white dark:border-slate-900">
|
||||
<% else %>
|
||||
<div class="w-<%= size %> h-<%= size %> rounded-full [&>svg]:opacity-70 border border-solid fill-white dark:fill-slate-900 border-white dark:border-slate-900 flex justify-center items-center" style="background-color: <%= thumbnail_bg_color(author&.available_name) %>;">
|
||||
<%= render partial: 'icons/user' %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -1,17 +1,17 @@
|
||||
<section class="lg:container w-full flex flex-col h-full">
|
||||
<div class="flex flex-col gap-8 h-full group <%= !@is_plain_layout_enabled ? 'border border-solid border-slate-100 dark:border-slate-800 hover:border-woot-600 dark:hover:border-woot-600 py-5 px-3 rounded-lg' : '' %>">
|
||||
<section class="flex flex-col w-full h-full lg:container">
|
||||
<div id="<%= !@is_plain_layout_enabled ? 'category-block' : '' %>" class="flex flex-col gap-8 h-full <%= !@is_plain_layout_enabled ? 'border border-solid border-slate-100 dark:border-slate-800 py-5 px-3 rounded-lg' : '' %>">
|
||||
<div class="flex justify-between items-center w-full <%= !@is_plain_layout_enabled ? 'px-1' : '' %>">
|
||||
<h3 class="text-xl text-slate-800 dark:text-slate-50 font-semibold leading-relaxed hover:cursor-pointer <%= @is_plain_layout_enabled ? 'hover:underline' : 'pl-1 group-hover:text-woot-600 dark:group-hover:text-woot-600' %>">
|
||||
<h3 id="<%= !@is_plain_layout_enabled ? 'category-name' : '' %>" class="text-xl text-slate-800 dark:text-slate-50 font-semibold leading-relaxed hover:cursor-pointer <%= @is_plain_layout_enabled ? 'hover:underline' : 'pl-1' %>">
|
||||
<%= I18n.t('public_portal.header.uncategorized') %>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="-mt-4">
|
||||
<% portal.articles.published.where(category_id: nil).order(position: :asc).take(5).each do |article| %>
|
||||
<a
|
||||
class="text-slate-700 dark:text-slate-100 leading-7"
|
||||
href="<%= generate_article_link(portal.slug, article.slug, @theme) %>"
|
||||
class="leading-7 text-slate-700 dark:text-slate-100"
|
||||
href="<%= generate_article_link(portal.slug, article.slug, @theme_from_params, @is_plain_layout_enabled) %>"
|
||||
>
|
||||
<div class="flex justify-between hover:cursor-pointer items-center py-1 rounded-lg gap-3 <%= !@is_plain_layout_enabled ? 'px-2 hover:bg-slate-50 dark:hover:bg-slate-800' : 'hover:underline' %>">
|
||||
<div id="<%= !@is_plain_layout_enabled ? 'category-item' : '' %>" class="flex justify-between hover:cursor-pointer items-center py-1 rounded-lg gap-3 <%= !@is_plain_layout_enabled ? 'px-2' : 'hover:underline' %>">
|
||||
<%= article.title %>
|
||||
<span class="flex items-center font-normal">
|
||||
<%= render partial: 'icons/chevron-right' %>
|
||||
|
||||
@@ -1,31 +1,38 @@
|
||||
<div class="flex items-center flex-row mb-6 gap-px">
|
||||
<% category_link_params = {
|
||||
portal_slug: @portal.slug,
|
||||
category_locale: @article.category.locale,
|
||||
category_slug: @article.category.slug,
|
||||
theme: @theme_from_params,
|
||||
is_plain_layout_enabled: @is_plain_layout_enabled
|
||||
}
|
||||
%>
|
||||
|
||||
<div class="flex flex-row items-center gap-px mb-6">
|
||||
<a
|
||||
class="text-slate-500 dark:text-slate-200 text-sm gap-1 hover:cursor-pointer <%= @is_plain_layout_enabled && 'hover:underline' %> leading-8 font-semibold"
|
||||
href="/hc/<%= @portal.slug %>/<%= @article.category&.locale %><%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>"
|
||||
href="<%= generate_home_link(@portal.slug, @article.category&.locale, @theme_from_params, @is_plain_layout_enabled) %>"
|
||||
>
|
||||
<%= I18n.t('public_portal.common.home') %>
|
||||
</a>
|
||||
<span class="w-4 h-4 [&>svg]:w-3 [&>svg]:h-3 flex items-center justify-center text-xs text-slate-500 dark:text-slate-300"><%= render partial: 'icons/chevron-right' %></span>
|
||||
<% if @article.category %>
|
||||
<a class="text-slate-500 dark:text-slate-200 text-sm gap-1 whitespace-nowrap hover:cursor-pointer <%= @is_plain_layout_enabled && 'hover:underline' %> leading-8 font-semibold" href="/hc/<%= @portal.slug %>/<%= @article.category.locale %>/categories/<%= @article.category.slug %><%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>">
|
||||
<a class="text-slate-500 dark:text-slate-200 text-sm gap-1 whitespace-nowrap hover:cursor-pointer <%= @is_plain_layout_enabled && 'hover:underline' %> leading-8 font-semibold" href="<%= generate_category_link(category_link_params) %>">
|
||||
<%= @article.category&.name %>
|
||||
</a>
|
||||
<span class="w-4 h-4 [&>svg]:w-3 [&>svg]:h-3 flex items-center justify-center text-xs text-slate-500 dark:text-slate-300"><%= render partial: 'icons/chevron-right' %></span>
|
||||
<span class="text-sm text-slate-800 dark:text-slate-100 font-semibold overflow-hidden text-ellipsis min-w-0 whitespace-nowrap"><%= @article.title %></span>
|
||||
<span class="min-w-0 overflow-hidden text-sm font-semibold text-slate-800 dark:text-slate-100 text-ellipsis whitespace-nowrap"><%= @article.title %></span>
|
||||
<% else %>
|
||||
<span class="text-slate-700 dark:text-slate-100 leading-8 text-sm font-semibold" ><%= I18n.t('public_portal.header.uncategorized') %></span>
|
||||
<span class="text-sm font-semibold leading-8 text-slate-700 dark:text-slate-100" ><%= I18n.t('public_portal.header.uncategorized') %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
<h1 class="text-3xl font-semibold leading-normal md:tracking-normal md:text-4xl text-slate-900 dark:text-white">
|
||||
<%= article.title %>
|
||||
</h1>
|
||||
<div class="flex flex-col items-start justify-between w-full md:flex-row md:items-center pt-6">
|
||||
<div class="flex flex-col items-start justify-between w-full pt-6 md:flex-row md:items-center">
|
||||
<div class="flex items-start space-x-1">
|
||||
<% if article.author&.avatar_url&.present? %>
|
||||
<div class="pr-px mt-0.5 min-w-[20px] min-h-[20px]">
|
||||
<img src="<%= article.author.avatar_url %>" alt="<%= article.author.display_name %>" class="w-5 h-5 border rounded-full">
|
||||
</div>
|
||||
<% end %>
|
||||
<span class="flex items-center text-base text-slate-600 dark:text-slate-400 font-medium">By <%= article.author.available_name %> • <%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %></span>
|
||||
<div class="pr-px mt-0.5 min-w-[20px] min-h-[20px]">
|
||||
<%= render "public/api/v1/portals/thumbnail", author: article.author, size: 5 %>
|
||||
</div>
|
||||
<span class="flex items-center text-base font-medium text-slate-600 dark:text-slate-400">By <%= article.author.available_name %> • <%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<div class="bg-slate-50 dark:bg-slate-800">
|
||||
<div class="max-w-4xl px-6 py-16 mx-auto space-y-12 w-full">
|
||||
<div class="w-full max-w-4xl px-6 py-16 mx-auto space-y-12">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<a
|
||||
class="text-slate-800 hover:underline leading-8"
|
||||
href="/hc/<%= @portal.slug %>/<%= @category.present? ? @category.slug : '' %><%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>"
|
||||
class="leading-8 text-slate-800 hover:underline"
|
||||
href="<%= generate_home_link(@portal.slug, @category.present? ? @category.slug : '', @theme_from_params, @is_plain_layout_enabled) %>"
|
||||
>
|
||||
<%= @portal.name %> <%= I18n.t('public_portal.common.home') %>
|
||||
</a>
|
||||
@@ -12,13 +12,13 @@
|
||||
<span>/</span>
|
||||
</div>
|
||||
<% @articles.each do |article| %>
|
||||
<h1 class="text-4xl font-semibold md:tracking-normal leading-snug md:text-5xl text-slate-900 dark:text-white">
|
||||
<h1 class="text-4xl font-semibold leading-snug md:tracking-normal md:text-5xl text-slate-900 dark:text-white">
|
||||
<%= article.title %></h1>
|
||||
<div class="flex flex-col items-start justify-between w-full md:flex-row md:items-center pt-2">
|
||||
<div class="flex flex-col items-start justify-between w-full pt-2 md:flex-row md:items-center">
|
||||
<div class="flex items-center space-x-2">
|
||||
<img src="<%= article.author.avatar_url %>" alt="" class="w-12 h-812 border rounded-full">
|
||||
<img src="<%= article.author.avatar_url %>" alt="" class="w-12 border rounded-full h-812">
|
||||
<div>
|
||||
<h5 class="text-base font-medium text-slate-900 dark:text-white mb-2"><%= article.author.name %></h5>
|
||||
<h5 class="mb-2 text-base font-medium text-slate-900 dark:text-white"><%= article.author.name %></h5>
|
||||
<p class="text-sm font-normal text-slate-700 dark:text-slate-100">
|
||||
<%= article.author.updated_at.strftime("%B %d %Y") %></p>
|
||||
</div>
|
||||
@@ -28,9 +28,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="max-w-4xl flex-grow w-full px-8 py-16 mx-auto space-y-12">
|
||||
<div class="flex-grow w-full max-w-4xl px-8 py-16 mx-auto space-y-12">
|
||||
<article class="space-y-8">
|
||||
<div class="text-slate-800 dark:text-slate-50 font-sans leading-8 text-lg max-w-3xl blog-content">
|
||||
<div class="max-w-3xl font-sans text-lg leading-8 text-slate-800 dark:text-slate-50 blog-content">
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
<% end %>
|
||||
|
||||
<% if !@is_plain_layout_enabled %>
|
||||
<div id="portal-bg" class="bg-woot-50 dark:bg-woot-900 shadow-inner" style="<%= generate_portal_bg(@portal.color, @theme) %>">
|
||||
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6" style="<%= generate_gradient_to_bottom(@theme) %>">
|
||||
<div id="portal-bg" class="bg-white dark:bg-slate-900 shadow-inner">
|
||||
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6">
|
||||
<div class="max-w-5xl px-4 md:px-8 mx-auto flex flex-col">
|
||||
<%= render "public/api/v1/portals/articles/article_header", article: @article %>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
|
||||
<section class="lg:container w-full py-6 px-4 flex flex-col h-full">
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<h3 class="text-xl text-slate-900 dark:text-white font-semibold leading-relaxed hover:underline">
|
||||
<a href="/hc/<%= portal.slug %>/<%= category.locale %>/categories/<%= category.slug %><%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>">
|
||||
<% category_link_params = {
|
||||
portal_slug: portal.slug,
|
||||
category_locale: category.locale,
|
||||
category_slug: category.slug,
|
||||
theme: @theme_from_params,
|
||||
is_plain_layout_enabled: @is_plain_layout_enabled
|
||||
}
|
||||
%>
|
||||
|
||||
<section class="flex flex-col w-full h-full px-4 py-6 lg:container">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<h3 class="text-xl font-semibold leading-relaxed text-slate-900 dark:text-white hover:underline">
|
||||
<a href="<%= generate_category_link(category_link_params) %>">
|
||||
<%= category.name %>
|
||||
</a>
|
||||
</h3>
|
||||
@@ -10,17 +19,17 @@
|
||||
<%= render 'public/api/v1/portals/article_count', article_count: category.articles.published.order(position: :asc).size %>
|
||||
</span>
|
||||
</div>
|
||||
<div class="py-4 w-full mt-2 flex-grow">
|
||||
<div class="flex-grow w-full py-4 mt-2">
|
||||
<% if category.articles.published.size == 0 %>
|
||||
<div class="h-full flex items-center justify-center bg-slate-50 dark:bg-slate-800 rounded-xl mb-4">
|
||||
<div class="flex items-center justify-center h-full mb-4 bg-slate-50 dark:bg-slate-800 rounded-xl">
|
||||
<p class="text-sm text-slate-500"><%= I18n.t('public_portal.common.no_articles') %></p>
|
||||
</div>
|
||||
<% else %>
|
||||
<% category.articles.published.order(position: :asc).take(5).each do |article| %>
|
||||
<div class="flex justify-between content-center h-8 my-1">
|
||||
<div class="flex content-center justify-between h-8 my-1">
|
||||
<a
|
||||
class="text-slate-800 dark:text-slate-50 hover:underline leading-8"
|
||||
href="/hc/<%= portal.slug %>/articles/<%= article.slug %><%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>"
|
||||
class="leading-8 text-slate-800 dark:text-slate-50 hover:underline"
|
||||
href="<%= generate_article_link(portal.slug, article.slug, @theme_from_params, @is_plain_layout_enabled) %>"
|
||||
>
|
||||
<%= article.title %>
|
||||
</a>
|
||||
@@ -45,8 +54,8 @@
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="/hc/<%= portal.slug %>/<%= category.locale %>/categories/<%= category.slug %><%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>"
|
||||
class="flex flex-row items-center text-base font-medium text-woot-600 dark:text-woot-500 hover:text-slate-900 dark:hover:text-white hover:underline mt-4"
|
||||
href="<%= generate_category_link(category_link_params) %>"
|
||||
class="flex flex-row items-center mt-4 text-base font-medium text-woot-600 dark:text-woot-500 hover:text-slate-900 dark:hover:text-white hover:underline"
|
||||
>
|
||||
<%= I18n.t('public_portal.common.view_all_articles') %>
|
||||
<span class="ml-2">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="flex items-center flex-row">
|
||||
<a
|
||||
class="text-slate-500 dark:text-slate-200 text-sm gap-1 <%= @is_plain_layout_enabled && 'hover:underline' %> hover:cursor-pointer leading-8 font-semibold"
|
||||
href="/hc/<%= portal.slug %>/<%= category.locale %><%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>"
|
||||
href="<%= generate_home_link(portal.slug, category.locale, @theme_from_params, @is_plain_layout_enabled) %>"
|
||||
>
|
||||
<%= I18n.t('public_portal.common.home') %>
|
||||
</a>
|
||||
@@ -14,13 +14,17 @@
|
||||
<% if category.icon.present? %>
|
||||
<span class="text-4xl"><%= category.icon %></span>
|
||||
<% end %>
|
||||
<h1 class="text-3xl font-semibold leading-[52.5px] text-slate-900 dark:text-white">
|
||||
<h1 class="text-3xl font-bold tracking-wide leading-[52.5px] text-slate-900 dark:text-white">
|
||||
<%= category.name %>
|
||||
</h1>
|
||||
<% if category.description.present? %>
|
||||
<span class="font-medium text-slate-800 dark:text-slate-75 text-base leading-5"><%= category.description %></span>
|
||||
<span class="font-medium text-slate-700 dark:text-slate-200 text-base leading-5"><%= category.description %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
<span class="flex items-center text-base text-slate-600 dark:text-slate-400 font-medium"><%= render 'public/api/v1/portals/article_count', article_count: category.articles.published.size %></span>
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<%= render "public/api/v1/portals/authors", category: category, show_expanded: true %>
|
||||
<span class="text-slate-600 dark:text-slate-400">•</span>
|
||||
<span class="flex items-center text-base text-slate-600 dark:text-slate-400 font-medium"><%= render 'public/api/v1/portals/article_count', article_count: category.articles.published.size %></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
<% end %>
|
||||
|
||||
<% if !@is_plain_layout_enabled %>
|
||||
<div id="portal-bg" class="bg-woot-50 dark:bg-woot-900" style="<%= generate_portal_bg(@portal.color, @theme) %>">
|
||||
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6" style="<%= generate_gradient_to_bottom(@theme) %>">
|
||||
<div id="portal-bg" class="bg-white dark:bg-slate-900">
|
||||
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6">
|
||||
<%= render 'public/api/v1/portals/categories/category-hero', category: @category, portal: @portal %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -22,17 +22,25 @@
|
||||
</div>
|
||||
<% else %>
|
||||
<% @category.articles.published.order(:position).each do |article| %>
|
||||
<div class="<%= !@is_plain_layout_enabled ? 'group border border-solid border-slate-100 dark:border-slate-800 hover:border-woot-600 dark:hover:border-woot-600 rounded-lg' : '' %>">
|
||||
<div id="<%= !@is_plain_layout_enabled ? 'category-block' : '' %>" class="<%= !@is_plain_layout_enabled ? 'border border-solid border-slate-100 dark:border-slate-800 rounded-lg' : 'group' %>">
|
||||
<a
|
||||
class="<%= !@is_plain_layout_enabled ? 'p-4' : 'px-0 py-1' %> text-slate-800 dark:text-slate-50 flex justify-between content-center hover:cursor-pointer"
|
||||
href="/hc/<%= @portal.slug %>/articles/<%= article.slug %><%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>"
|
||||
href="<%= generate_article_link(@portal.slug, article.slug, @theme_from_params, @is_plain_layout_enabled) %>"
|
||||
>
|
||||
<div class="flex flex-col gap-5">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-lg text-slate-900 dark:text-slate-50 font-semibold <%= @is_plain_layout_enabled ? 'hover:underline' : 'group-hover:text-woot-600 dark:group-hover:text-woot-600' %>"><%= article.title %></h3>
|
||||
<p class="text-base font-normal text-slate-500 dark:text-slate-200 line-clamp-1 break-all"><%= render_category_content(article.content) %></p>
|
||||
<h3 id="<%= !@is_plain_layout_enabled ? 'category-name' : '' %>" class="text-lg text-slate-900 tracking-[0.28px] dark:text-slate-50 font-semibold <%= @is_plain_layout_enabled ? 'group-hover:underline' : '' %>"><%= article.title %></h3>
|
||||
<p class="text-base font-normal text-slate-600 dark:text-slate-200 line-clamp-1 break-all"><%= render_category_content(article.content) %></p>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<% author = article.author %>
|
||||
<%= render "public/api/v1/portals/thumbnail", author: author, size: 5 %>
|
||||
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium flex items-center"><%= "#{I18n.t('public_portal.common.by')} #{author.name}" %></span>
|
||||
</div>
|
||||
<span class="text-slate-600 dark:text-slate-400">•</span>
|
||||
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium flex items-center"><%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %></span>
|
||||
</div>
|
||||
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium flex items-center"><%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %></span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<h1 class="text-6xl text-center font-semibold text-slate-800 dark:text-slate-100 leading-relaxed"><%= I18n.t('public_portal.404.title') %></h1>
|
||||
<p class="text-center text-slate-700 dark:text-slate-300 my-1"><%= I18n.t('public_portal.404.description') %></p>
|
||||
<div class="text-center my-8">
|
||||
<a href="/hc/<%= @portal.slug %>/<%= @portal.config['default_locale'] || params[:locale] %>/<%= @theme.present? ? '?theme='+@theme : '' %>" class="text-woot-500 font-semibold underline">
|
||||
<a href="<%= generate_home_link(@portal.slug, @portal.config['default_locale'] || params[:locale], @theme_from_params, @is_plain_layout_enabled) %>" class="text-woot-500 font-semibold underline">
|
||||
<%= I18n.t('public_portal.404.back_to_home') %>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -29,14 +29,13 @@ Rails.application.reloader.to_prepare do
|
||||
end
|
||||
|
||||
Facebook::Messenger::Bot.on :delivery do |delivery|
|
||||
# delivery.ids # => 'mid.1457764197618:41d102a3e1ae206a38'
|
||||
# delivery.sender # => { 'id' => '1008372609250235' }
|
||||
# delivery.recipient # => { 'id' => '2015573629214912' }
|
||||
# delivery.at # => 2016-04-22 21:30:36 +0200
|
||||
# delivery.seq # => 37
|
||||
updater = Integrations::Facebook::DeliveryStatus.new(delivery)
|
||||
updater.perform
|
||||
Rails.logger.info "Human was online at #{delivery.at}"
|
||||
Rails.logger.info "Recieved delivery status #{delivery.to_json}"
|
||||
Webhooks::FacebookDeliveryJob.perform_later(delivery.to_json)
|
||||
end
|
||||
|
||||
Facebook::Messenger::Bot.on :read do |read|
|
||||
Rails.logger.info "Recieved read status #{read.to_json}"
|
||||
Webhooks::FacebookDeliveryJob.perform_later(read.to_json)
|
||||
end
|
||||
|
||||
Facebook::Messenger::Bot.on :message_echo do |message|
|
||||
|
||||
@@ -215,6 +215,11 @@ en:
|
||||
view_all_articles: View all
|
||||
article: article
|
||||
articles: articles
|
||||
author: author
|
||||
authors: authors
|
||||
other: other
|
||||
others: others
|
||||
by: By
|
||||
no_articles: There are no articles here
|
||||
footer:
|
||||
made_with: Made with
|
||||
|
||||
+1
-1
@@ -166,7 +166,7 @@ Rails.application.routes.draw do
|
||||
end
|
||||
end
|
||||
|
||||
resources :notifications, only: [:index, :update] do
|
||||
resources :notifications, only: [:index, :update, :destroy] do
|
||||
collection do
|
||||
post :read_all
|
||||
get :unread_count
|
||||
|
||||
@@ -754,8 +754,31 @@ function upgrade_redis() {
|
||||
apt install libvips -y
|
||||
}
|
||||
|
||||
|
||||
##############################################################################
|
||||
# Update nodejs to v20+
|
||||
# Globals:
|
||||
# None
|
||||
# Arguments:
|
||||
# None
|
||||
# Outputs:
|
||||
# None
|
||||
##############################################################################
|
||||
function upgrade_node() {
|
||||
echo "Upgrading nodejs version to v20.x"
|
||||
echo "Checking Node.js version..."
|
||||
|
||||
# Get current Node.js version
|
||||
current_version=$(node --version | cut -c 2-)
|
||||
|
||||
# Parse major version number
|
||||
major_version=$(echo "$current_version" | cut -d. -f1)
|
||||
|
||||
if [ "$major_version" -ge 20 ]; then
|
||||
echo "Node.js is already version $current_version (>= 20.x). Skipping Node.js upgrade."
|
||||
return
|
||||
fi
|
||||
|
||||
echo "Upgrading Node.js version to v20.x"
|
||||
curl -sL https://deb.nodesource.com/setup_20.x | sudo bash -
|
||||
apt install -y nodejs
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
module Enterprise::Message
|
||||
def update_message_sentiments
|
||||
::Enterprise::SentimentAnalysisJob.perform_later(self) if ENV.fetch('SENTIMENT_FILE_PATH', nil)
|
||||
::Enterprise::SentimentAnalysisJob.perform_later(self) if ENV.fetch('SENTIMENT_FILE_PATH', nil).present?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,32 +1,37 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Integrations::Facebook::DeliveryStatus
|
||||
def initialize(params)
|
||||
@params = params
|
||||
end
|
||||
pattr_initialize [:params!]
|
||||
|
||||
def perform
|
||||
update_message_status
|
||||
return if facebook_channel.blank?
|
||||
return unless conversation
|
||||
|
||||
process_delivery_status if params.delivery_watermark
|
||||
process_read_status if params.read_watermark
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sender_id
|
||||
@params.sender['id']
|
||||
def process_delivery_status
|
||||
timestamp = Time.zone.at(params.delivery_watermark.to_i).to_datetime.utc
|
||||
::Conversations::UpdateMessageStatusJob.perform_later(conversation.id, timestamp, :delivered)
|
||||
end
|
||||
|
||||
def process_read_status
|
||||
timestamp = Time.zone.at(params.read_watermark.to_i).to_datetime.utc
|
||||
::Conversations::UpdateMessageStatusJob.perform_later(conversation.id, timestamp, :read)
|
||||
end
|
||||
|
||||
def contact
|
||||
::ContactInbox.find_by(source_id: sender_id)&.contact
|
||||
::ContactInbox.find_by(source_id: params.sender_id)&.contact
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= ::Conversation.find_by(contact_id: contact.id) if contact.present?
|
||||
end
|
||||
|
||||
def update_message_status
|
||||
return unless conversation
|
||||
|
||||
conversation.contact_last_seen_at = @params.at
|
||||
conversation.save!
|
||||
def facebook_channel
|
||||
@facebook_channel ||= Channel::FacebookPage.find_by(page_id: params.recipient_id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -34,6 +34,22 @@ class Integrations::Facebook::MessageParser
|
||||
@messaging.dig('message', 'mid')
|
||||
end
|
||||
|
||||
def delivery
|
||||
@messaging['delivery']
|
||||
end
|
||||
|
||||
def read
|
||||
@messaging['read']
|
||||
end
|
||||
|
||||
def read_watermark
|
||||
read&.dig('watermark')
|
||||
end
|
||||
|
||||
def delivery_watermark
|
||||
delivery&.dig('watermark')
|
||||
end
|
||||
|
||||
def echo?
|
||||
@messaging.dig('message', 'is_echo')
|
||||
end
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"babel-plugin-syntax-jsx": "^6.18.0",
|
||||
"babel-plugin-transform-vue-jsx": "^3.7.0",
|
||||
"chart.js": "~2.9.4",
|
||||
"color2k": "^2.0.2",
|
||||
"company-email-validator": "^1.0.8",
|
||||
"core-js": "3.11.0",
|
||||
"date-fns": "2.21.1",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 472 KiB |
@@ -127,4 +127,30 @@ RSpec.describe 'Notifications API', type: :request do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/{account.id}/notifications/:id' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let!(:notification) { create(:notification, account: account, user: admin) }
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
delete "/api/v1/accounts/#{account.id}/notifications/#{notification.id}"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'deletes the notification' do
|
||||
delete "/api/v1/accounts/#{account.id}/notifications/#{notification.id}",
|
||||
headers: admin.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(Notification.count).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,4 +10,24 @@ FactoryBot.define do
|
||||
|
||||
initialize_with { attributes }
|
||||
end
|
||||
|
||||
factory :message_deliveries, class: Hash do
|
||||
messaging do
|
||||
{ sender: { id: '3383290475046708' },
|
||||
recipient: { id: '117172741761305' },
|
||||
delivery: { watermark: '1648581633369' } }
|
||||
end
|
||||
|
||||
initialize_with { attributes }
|
||||
end
|
||||
|
||||
factory :message_reads, class: Hash do
|
||||
messaging do
|
||||
{ sender: { id: '3383290475046708' },
|
||||
recipient: { id: '117172741761305' },
|
||||
read: { watermark: '1648581633369' } }
|
||||
end
|
||||
|
||||
initialize_with { attributes }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -33,71 +33,175 @@ describe PortalHelper do
|
||||
describe '#generate_portal_bg' do
|
||||
context 'when theme is dark' do
|
||||
it 'returns the correct background with dark grid image and color mix with black' do
|
||||
expected_bg = 'background: url(/assets/images/hc/hexagon-dark.svg) color-mix(in srgb, #ff0000 20%, black)'
|
||||
expected_bg = 'url(/assets/images/hc/hexagon-dark.svg) color-mix(in srgb, #ff0000 20%, black)'
|
||||
expect(helper.generate_portal_bg('#ff0000', 'dark')).to eq(expected_bg)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when theme is not dark' do
|
||||
it 'returns the correct background with light grid image and color mix with white' do
|
||||
expected_bg = 'background: url(/assets/images/hc/hexagon-light.svg) color-mix(in srgb, #ff0000 20%, white)'
|
||||
expected_bg = 'url(/assets/images/hc/hexagon-light.svg) color-mix(in srgb, #ff0000 20%, white)'
|
||||
expect(helper.generate_portal_bg('#ff0000', 'light')).to eq(expected_bg)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when provided with various colors' do
|
||||
it 'adjusts the background appropriately for dark theme' do
|
||||
expected_bg = 'background: url(/assets/images/hc/hexagon-dark.svg) color-mix(in srgb, #00ff00 20%, black)'
|
||||
expected_bg = 'url(/assets/images/hc/hexagon-dark.svg) color-mix(in srgb, #00ff00 20%, black)'
|
||||
expect(helper.generate_portal_bg('#00ff00', 'dark')).to eq(expected_bg)
|
||||
end
|
||||
|
||||
it 'adjusts the background appropriately for light theme' do
|
||||
expected_bg = 'background: url(/assets/images/hc/hexagon-light.svg) color-mix(in srgb, #0000ff 20%, white)'
|
||||
expected_bg = 'url(/assets/images/hc/hexagon-light.svg) color-mix(in srgb, #0000ff 20%, white)'
|
||||
expect(helper.generate_portal_bg('#0000ff', 'light')).to eq(expected_bg)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#get_theme_names' do
|
||||
it 'returns the light theme name' do
|
||||
expect(helper.get_theme_names('light')).to eq(I18n.t('public_portal.header.appearance.light'))
|
||||
end
|
||||
|
||||
it 'returns the dark theme name' do
|
||||
expect(helper.get_theme_names('dark')).to eq(I18n.t('public_portal.header.appearance.dark'))
|
||||
end
|
||||
|
||||
it 'returns the system theme name for any other value' do
|
||||
expect(helper.get_theme_names('any_other_value')).to eq(I18n.t('public_portal.header.appearance.system'))
|
||||
end
|
||||
end
|
||||
|
||||
describe '#get_theme_icon' do
|
||||
it 'returns the light theme icon' do
|
||||
expect(helper.get_theme_icon('light')).to eq('icons/sun')
|
||||
end
|
||||
|
||||
it 'returns the dark theme icon' do
|
||||
expect(helper.get_theme_icon('dark')).to eq('icons/moon')
|
||||
end
|
||||
|
||||
it 'returns the system theme icon for any other value' do
|
||||
expect(helper.get_theme_icon('any_other_value')).to eq('icons/monitor')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#generate_gradient_to_bottom' do
|
||||
context 'when theme is dark' do
|
||||
it 'returns the correct background gradient' do
|
||||
expected_gradient = 'background-image: linear-gradient(to bottom, transparent, #151718)'
|
||||
expect(helper.generate_gradient_to_bottom('dark')).to eq(expected_gradient)
|
||||
it 'returns the correct gradient' do
|
||||
expect(helper.generate_gradient_to_bottom('dark')).to eq(
|
||||
'linear-gradient(to bottom, transparent, #151718)'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when theme is not dark' do
|
||||
it 'returns the correct background gradient' do
|
||||
expected_gradient = 'background-image: linear-gradient(to bottom, transparent, white)'
|
||||
expect(helper.generate_gradient_to_bottom('light')).to eq(expected_gradient)
|
||||
it 'returns the correct gradient' do
|
||||
expect(helper.generate_gradient_to_bottom('light')).to eq(
|
||||
'linear-gradient(to bottom, transparent, white)'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when provided with various colors' do
|
||||
it 'adjusts the gradient appropriately' do
|
||||
expect(helper.generate_gradient_to_bottom('dark')).to eq(
|
||||
'linear-gradient(to bottom, transparent, #151718)'
|
||||
)
|
||||
expect(helper.generate_gradient_to_bottom('light')).to eq(
|
||||
'linear-gradient(to bottom, transparent, white)'
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#generate_portal_hover_color' do
|
||||
context 'when theme is dark' do
|
||||
it 'returns the correct color mix with #1B1B1B' do
|
||||
expect(helper.generate_portal_hover_color('#ff0000', 'dark')).to eq(
|
||||
'color-mix(in srgb, #ff0000 5%, #1B1B1B)'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when theme is not dark' do
|
||||
it 'returns the correct color mix with #F9F9F9' do
|
||||
expect(helper.generate_portal_hover_color('#ff0000', 'light')).to eq(
|
||||
'color-mix(in srgb, #ff0000 5%, #F9F9F9)'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when provided with various colors' do
|
||||
it 'adjusts the color mix appropriately' do
|
||||
expect(helper.generate_portal_hover_color('#00ff00', 'dark')).to eq(
|
||||
'color-mix(in srgb, #00ff00 5%, #1B1B1B)'
|
||||
)
|
||||
expect(helper.generate_portal_hover_color('#0000ff', 'light')).to eq(
|
||||
'color-mix(in srgb, #0000ff 5%, #F9F9F9)'
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#theme_query_string' do
|
||||
context 'when theme is present and not system' do
|
||||
it 'returns the correct query string' do
|
||||
expect(helper.theme_query_string('dark')).to eq('?theme=dark')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when theme is not present' do
|
||||
it 'returns the correct query string' do
|
||||
expect(helper.theme_query_string(nil)).to eq('')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when theme is system' do
|
||||
it 'returns the correct query string' do
|
||||
expect(helper.theme_query_string('system')).to eq('')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#generate_home_link' do
|
||||
context 'when theme is not present' do
|
||||
it 'returns the correct link' do
|
||||
expect(helper.generate_home_link('portal_slug', 'en', nil, true)).to eq(
|
||||
'/hc/portal_slug/en'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when theme is present and plain layout is enabled' do
|
||||
it 'returns the correct link' do
|
||||
expect(helper.generate_home_link('portal_slug', 'en', 'dark', true)).to eq(
|
||||
'/hc/portal_slug/en?theme=dark'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when plain layout is not enabled' do
|
||||
it 'returns the correct link' do
|
||||
expect(helper.generate_home_link('portal_slug', 'en', 'dark', false)).to eq(
|
||||
'/hc/portal_slug/en'
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#generate_category_link' do
|
||||
context 'when theme is not present' do
|
||||
it 'returns the correct link' do
|
||||
expect(helper.generate_category_link(
|
||||
portal_slug: 'portal_slug',
|
||||
category_locale: 'en',
|
||||
category_slug: 'category_slug',
|
||||
theme: nil,
|
||||
is_plain_layout_enabled: true
|
||||
)).to eq(
|
||||
'/hc/portal_slug/en/categories/category_slug'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when theme is present and plain layout is enabled' do
|
||||
it 'returns the correct link' do
|
||||
expect(helper.generate_category_link(
|
||||
portal_slug: 'portal_slug',
|
||||
category_locale: 'en',
|
||||
category_slug: 'category_slug',
|
||||
theme: 'dark',
|
||||
is_plain_layout_enabled: true
|
||||
)).to eq(
|
||||
'/hc/portal_slug/en/categories/category_slug?theme=dark'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when plain layout is not enabled' do
|
||||
it 'returns the correct link' do
|
||||
expect(helper.generate_category_link(
|
||||
portal_slug: 'portal_slug',
|
||||
category_locale: 'en',
|
||||
category_slug: 'category_slug',
|
||||
theme: 'dark',
|
||||
is_plain_layout_enabled: false
|
||||
)).to eq(
|
||||
'/hc/portal_slug/en/categories/category_slug'
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -105,19 +209,27 @@ describe PortalHelper do
|
||||
describe '#generate_article_link' do
|
||||
context 'when theme is not present' do
|
||||
it 'returns the correct link' do
|
||||
expect(helper.generate_article_link('portal_slug', 'article_slug', nil)).to eq(
|
||||
expect(helper.generate_article_link('portal_slug', 'article_slug', nil, true)).to eq(
|
||||
'/hc/portal_slug/articles/article_slug'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when theme is present' do
|
||||
context 'when theme is present and plain layout is enabled' do
|
||||
it 'returns the correct link' do
|
||||
expect(helper.generate_article_link('portal_slug', 'article_slug', 'dark')).to eq(
|
||||
expect(helper.generate_article_link('portal_slug', 'article_slug', 'dark', true)).to eq(
|
||||
'/hc/portal_slug/articles/article_slug?theme=dark'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when plain layout is not enabled' do
|
||||
it 'returns the correct link' do
|
||||
expect(helper.generate_article_link('portal_slug', 'article_slug', 'dark', false)).to eq(
|
||||
'/hc/portal_slug/articles/article_slug'
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#render_category_content' do
|
||||
@@ -134,4 +246,16 @@ describe PortalHelper do
|
||||
expect(helper.render_category_content(markdown_content)).to eq(plain_text_content)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#thumbnail_bg_color' do
|
||||
it 'returns the correct color based on username length' do
|
||||
expect(helper.thumbnail_bg_color('')).to eq('#6D95BA') # Length 0, so index is 0
|
||||
expect(helper.thumbnail_bg_color('Joe')).to eq('#6D95BA') # Length 3, so index is 0
|
||||
expect(helper.thumbnail_bg_color('John')).to eq('#A4C3C3') # Length 4, so index is 1
|
||||
expect(helper.thumbnail_bg_color('Jane james')).to eq('#A4C3C3') # Length 10, so index is 1
|
||||
expect(helper.thumbnail_bg_color('Jane_123')).to eq('#E19191') # Length 8, so index is 2
|
||||
expect(helper.thumbnail_bg_color('AlexanderTheGreat')).to eq('#E19191') # Length 17, so index is 2
|
||||
expect(helper.thumbnail_bg_color('Reginald John Sans')).to eq('#6D95BA') # Length 18, so index is 0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Webhooks::FacebookDeliveryJob do
|
||||
include ActiveJob::TestHelper
|
||||
|
||||
let(:message) { 'test_message' }
|
||||
let(:parsed_message) { instance_double(Integrations::Facebook::MessageParser) }
|
||||
let(:delivery_status) { instance_double(Integrations::Facebook::DeliveryStatus) }
|
||||
|
||||
before do
|
||||
allow(Integrations::Facebook::MessageParser).to receive(:new).with(message).and_return(parsed_message)
|
||||
allow(Integrations::Facebook::DeliveryStatus).to receive(:new).with(params: parsed_message).and_return(delivery_status)
|
||||
allow(delivery_status).to receive(:perform)
|
||||
end
|
||||
|
||||
after do
|
||||
clear_enqueued_jobs
|
||||
end
|
||||
|
||||
describe '#perform_later' do
|
||||
it 'enqueues the job' do
|
||||
expect do
|
||||
described_class.perform_later(message)
|
||||
end.to have_enqueued_job(described_class).with(message).on_queue('low')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'calls the MessageParser with the correct argument' do
|
||||
expect(Integrations::Facebook::MessageParser).to receive(:new).with(message)
|
||||
described_class.perform_now(message)
|
||||
end
|
||||
|
||||
it 'calls the DeliveryStatus with the correct argument' do
|
||||
expect(Integrations::Facebook::DeliveryStatus).to receive(:new).with(params: parsed_message)
|
||||
described_class.perform_now(message)
|
||||
end
|
||||
|
||||
it 'executes perform on the DeliveryStatus instance' do
|
||||
expect(delivery_status).to receive(:perform)
|
||||
described_class.perform_now(message)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,83 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Integrations::Facebook::DeliveryStatus do
|
||||
subject(:message_builder) { described_class.new(message_deliveries, facebook_channel.inbox).perform }
|
||||
|
||||
before do
|
||||
stub_request(:post, /graph\.facebook\.com/)
|
||||
end
|
||||
|
||||
let!(:account) { create(:account) }
|
||||
let!(:facebook_channel) { create(:channel_facebook_page, page_id: '117172741761305') }
|
||||
let!(:message_delivery_object) { build(:message_deliveries).to_json }
|
||||
let!(:message_deliveries) { Integrations::Facebook::MessageParser.new(message_delivery_object) }
|
||||
|
||||
let!(:contact) { create(:contact, account: account) }
|
||||
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: facebook_channel.inbox, source_id: '3383290475046708') }
|
||||
let!(:conversation) { create(:conversation, inbox: facebook_channel.inbox, contact: contact, contact_inbox: contact_inbox) }
|
||||
|
||||
let!(:message_read_object) { build(:message_reads).to_json }
|
||||
let!(:message_reads) { Integrations::Facebook::MessageParser.new(message_read_object) }
|
||||
let!(:message1) do
|
||||
create(:message, content: 'facebook message', message_type: 'outgoing', inbox: facebook_channel.inbox, conversation: conversation)
|
||||
end
|
||||
let!(:message2) do
|
||||
create(:message, content: 'facebook message', message_type: 'incoming', inbox: facebook_channel.inbox, conversation: conversation)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when message_deliveries callback fires' do
|
||||
before do
|
||||
allow(Conversations::UpdateMessageStatusJob).to receive(:perform_later)
|
||||
end
|
||||
|
||||
it 'updates all messages if the status is delivered' do
|
||||
described_class.new(params: message_deliveries).perform
|
||||
expect(Conversations::UpdateMessageStatusJob).to have_received(:perform_later).with(
|
||||
message1.conversation.id,
|
||||
Time.zone.at(message_deliveries.delivery['watermark'].to_i).to_datetime,
|
||||
:delivered
|
||||
)
|
||||
end
|
||||
|
||||
it 'does not update the message status if the message is incoming' do
|
||||
described_class.new(params: message_deliveries).perform
|
||||
expect(message2.reload.status).to eq('sent')
|
||||
end
|
||||
|
||||
it 'does not update the message status if the message was created after the watermark' do
|
||||
message1.update(created_at: 1.day.from_now)
|
||||
message_deliveries.delivery['watermark'] = 1.day.ago.to_i
|
||||
described_class.new(params: message_deliveries).perform
|
||||
expect(message1.reload.status).to eq('sent')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message_reads callback fires' do
|
||||
before do
|
||||
allow(Conversations::UpdateMessageStatusJob).to receive(:perform_later)
|
||||
end
|
||||
|
||||
it 'updates all messages if the status is read' do
|
||||
described_class.new(params: message_reads).perform
|
||||
expect(Conversations::UpdateMessageStatusJob).to have_received(:perform_later).with(
|
||||
message1.conversation.id,
|
||||
Time.zone.at(message_reads.read['watermark'].to_i).to_datetime,
|
||||
:read
|
||||
)
|
||||
end
|
||||
|
||||
it 'does not update the message status if the message is incoming' do
|
||||
described_class.new(params: message_reads).perform
|
||||
expect(message2.reload.status).to eq('sent')
|
||||
end
|
||||
|
||||
it 'does not update the message status if the message was created after the watermark' do
|
||||
message1.update(created_at: 1.day.from_now)
|
||||
message_reads.read['watermark'] = 1.day.ago.to_i
|
||||
described_class.new(params: message_reads).perform
|
||||
expect(message1.reload.status).to eq('sent')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user