feat: Add an option on the dashboard to allow switching help center layout (#14491)

<img width="633" height="431" alt="Screenshot 2026-05-18 at 12 32 55 PM"
src="https://github.com/user-attachments/assets/682d4c5f-4c76-465b-8d2f-92fbc2bb2a40"
/>

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
This commit is contained in:
Pranav
2026-05-19 06:42:48 -07:00
committed by GitHub
co-authored by Muhsin Keloth Sivin Varghese iamsivin
parent 497d34c097
commit 6560dbb68d
15 changed files with 414 additions and 106 deletions
@@ -78,7 +78,9 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def portal_params
params.require(:portal).permit(
:id, :color, :custom_domain, :header_text, :homepage_link,
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }, { draft_locales: [] }] }
:name, :page_title, :slug, :archived,
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }] }
)
end
@@ -0,0 +1,290 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import classicLayoutPreview from './classic-layout-preview.svg?raw';
import documentationLayoutPreview from './documentation-layout-preview.svg?raw';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
const props = defineProps({
activePortal: { type: Object, required: true },
isFetching: { type: Boolean, default: false },
});
const emit = defineEmits(['updatePortalConfiguration']);
const { t } = useI18n();
const PORTAL_LAYOUTS = {
CLASSIC: 'classic',
DOCUMENTATION: 'documentation',
};
// `prefix` is the link the help center auto-fills; the DB only stores the handle.
const SOCIAL_PLATFORMS = [
{
key: 'facebook',
label: 'Facebook',
icon: 'i-ri-facebook-circle-fill',
prefix: 'facebook.com/',
},
{ key: 'x', label: 'X', icon: 'i-ri-twitter-x-fill', prefix: 'x.com/' },
{
key: 'instagram',
label: 'Instagram',
icon: 'i-ri-instagram-fill',
prefix: 'instagram.com/',
},
{
key: 'linkedin',
label: 'LinkedIn',
icon: 'i-ri-linkedin-box-fill',
prefix: 'linkedin.com/',
},
{
key: 'youtube',
label: 'YouTube',
icon: 'i-ri-youtube-fill',
prefix: 'youtube.com/',
},
{
key: 'tiktok',
label: 'TikTok',
icon: 'i-ri-tiktok-fill',
prefix: 'tiktok.com/',
},
{
key: 'github',
label: 'GitHub',
icon: 'i-ri-github-fill',
prefix: 'github.com/',
},
{
key: 'whatsapp',
label: 'WhatsApp',
icon: 'i-ri-whatsapp-fill',
prefix: 'wa.me/',
},
];
const portalConfig = computed(() => props.activePortal?.config || {});
const state = reactive({
layout: PORTAL_LAYOUTS.CLASSIC,
socialProfiles: {},
});
const visiblePlatforms = ref([]);
const showAddMenu = ref(false);
let originalSnapshot = '';
const platformByKey = key => SOCIAL_PLATFORMS.find(p => p.key === key);
const trimmedHandle = key => (state.socialProfiles[key] || '').trim();
const buildSocialProfiles = () =>
visiblePlatforms.value.reduce((acc, key) => {
const handle = trimmedHandle(key);
if (handle) acc[key] = handle;
return acc;
}, {});
const snapshot = () =>
JSON.stringify({ layout: state.layout, social: buildSocialProfiles() });
const resetFromPortal = () => {
const savedProfiles = portalConfig.value.social_profiles || {};
state.layout = portalConfig.value.layout || PORTAL_LAYOUTS.CLASSIC;
state.socialProfiles = SOCIAL_PLATFORMS.reduce((acc, { key }) => {
acc[key] = savedProfiles[key] || '';
return acc;
}, {});
visiblePlatforms.value = SOCIAL_PLATFORMS.map(p => p.key).filter(key =>
(savedProfiles[key] || '').trim()
);
originalSnapshot = snapshot();
};
watch(() => props.activePortal, resetFromPortal, {
immediate: true,
deep: true,
});
const hasChanges = computed(() => snapshot() !== originalSnapshot);
const visiblePlatformDetails = computed(() =>
visiblePlatforms.value.map(platformByKey)
);
const addablePlatforms = computed(() =>
SOCIAL_PLATFORMS.filter(p => !visiblePlatforms.value.includes(p.key)).map(
p => ({ label: p.label, value: p.key, action: p.key, icon: p.icon })
)
);
const addPlatform = ({ value }) => {
if (!visiblePlatforms.value.includes(value)) {
visiblePlatforms.value.push(value);
}
showAddMenu.value = false;
};
const removePlatform = key => {
visiblePlatforms.value = visiblePlatforms.value.filter(k => k !== key);
state.socialProfiles[key] = '';
};
const handleSave = () => {
emit('updatePortalConfiguration', {
id: props.activePortal.id,
slug: props.activePortal.slug,
config: {
layout: state.layout,
social_profiles: buildSocialProfiles(),
},
});
};
</script>
<template>
<div class="flex flex-col w-full gap-6">
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
{{ t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.HEADER') }}
</h6>
<span class="text-sm text-n-slate-11">
{{ t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.DESCRIPTION') }}
</span>
</div>
<section class="flex flex-col gap-3">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-n-slate-11">
<RadioCard
:id="PORTAL_LAYOUTS.CLASSIC"
:is-active="state.layout === PORTAL_LAYOUTS.CLASSIC"
:label="
t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.LAYOUT.CLASSIC.TITLE')
"
:description="
t(
'HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.LAYOUT.CLASSIC.DESCRIPTION'
)
"
@select="value => (state.layout = value)"
>
<div
class="w-full mt-2 rounded-md overflow-hidden border border-solid border-n-weak bg-n-slate-2 dark:bg-n-slate-1"
>
<span v-dompurify-html="classicLayoutPreview" />
</div>
</RadioCard>
<RadioCard
:id="PORTAL_LAYOUTS.DOCUMENTATION"
beta
:is-active="state.layout === PORTAL_LAYOUTS.DOCUMENTATION"
:label="
t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.LAYOUT.SIDEBAR.TITLE')
"
:description="
t(
'HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.LAYOUT.SIDEBAR.DESCRIPTION'
)
"
@select="value => (state.layout = value)"
>
<div
class="w-full mt-2 rounded-md overflow-hidden border border-solid border-n-weak bg-n-slate-2 dark:bg-n-slate-1"
>
<span v-dompurify-html="documentationLayoutPreview" />
</div>
</RadioCard>
</div>
</section>
<section
v-if="state.layout === PORTAL_LAYOUTS.DOCUMENTATION"
class="flex flex-col gap-3"
>
<div class="flex flex-col gap-1">
<h6 class="text-sm font-medium text-n-slate-12">
{{
t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.HEADER')
}}
</h6>
<span class="text-sm text-n-slate-11">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.DESCRIPTION'
)
}}
</span>
</div>
<div
v-for="platform in visiblePlatformDetails"
:key="platform.key"
class="flex items-center h-10 gap-1.5 px-3 rounded-lg outline outline-1 outline-n-weak focus-within:outline-n-brand"
>
<Icon :icon="platform.icon" class="size-4 shrink-0 text-n-slate-11" />
<span class="text-sm shrink-0 text-n-slate-10">{{
platform.prefix
}}</span>
<input
v-model="state.socialProfiles[platform.key]"
type="text"
class="flex-1 min-w-0 text-sm bg-transparent outline-none reset-base text-n-slate-12 placeholder:text-n-slate-10"
:placeholder="
t(
'HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.PLACEHOLDER'
)
"
/>
<Button
icon="i-lucide-x"
color="slate"
variant="ghost"
size="xs"
:aria-label="
t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.REMOVE')
"
@click="removePlatform(platform.key)"
/>
</div>
<div
v-if="addablePlatforms.length"
v-on-clickaway="() => (showAddMenu = false)"
class="relative"
>
<Button
:label="
t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.ADD')
"
icon="i-lucide-plus"
color="slate"
variant="faded"
size="sm"
@click="showAddMenu = !showAddMenu"
/>
<DropdownMenu
v-if="showAddMenu"
:menu-items="addablePlatforms"
class="mt-1 w-52 top-full ltr:left-0 rtl:right-0"
@action="addPlatform"
/>
</div>
</section>
<div class="flex justify-end">
<Button
:label="t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SAVE')"
:disabled="!hasChanges || isFetching"
@click="handleSave"
/>
</div>
</div>
</template>
@@ -7,6 +7,7 @@ import { useMapGetter } from 'dashboard/composables/store.js';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import PortalBaseSettings from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue';
import PortalConfigurationSettings from './PortalConfigurationSettings.vue';
import PortalLayoutContentSettings from './PortalLayoutContentSettings.vue';
import ConfirmDeletePortalDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/ConfirmDeletePortalDialog.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -102,6 +103,12 @@ const handleDeletePortal = () => {
@send-cname-instructions="handleSendCnameInstructions"
/>
<div class="w-full h-px bg-n-weak" />
<PortalLayoutContentSettings
:active-portal="activePortal"
:is-fetching="isFetching"
@update-portal-configuration="handleUpdatePortalConfiguration"
/>
<div class="w-full h-px bg-n-weak" />
<div class="flex items-end justify-between w-full gap-4">
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 120" class="w-full h-auto block" aria-hidden="true">
<rect x="0" y="0" width="200" height="14" fill="currentColor" opacity="0.1"/>
<rect x="14" y="5" width="20" height="4" rx="1" fill="currentColor" opacity="0.3"/>
<rect x="14" y="22" width="64" height="5" rx="1" fill="currentColor" opacity="0.32"/>
<rect x="14" y="32" width="128" height="9" rx="2" fill="currentColor" opacity="0.18"/>
<rect x="14" y="70" width="82" height="19" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="104" y="70" width="82" height="19" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="14" y="93" width="82" height="19" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="104" y="93" width="82" height="19" rx="2" fill="currentColor" opacity="0.12"/>
</svg>

After

Width:  |  Height:  |  Size: 818 B

@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 120" class="w-full h-auto block" aria-hidden="true">
<rect x="0" y="0" width="200" height="14" fill="currentColor" opacity="0.1"/>
<rect x="6" y="5" width="20" height="4" rx="1" fill="currentColor" opacity="0.3"/>
<rect x="0" y="14" width="50" height="106" fill="currentColor" opacity="0.07"/>
<rect x="6" y="22" width="38" height="4" rx="1" fill="currentColor" opacity="0.25"/>
<rect x="6" y="32" width="30" height="3" rx="1" fill="currentColor" opacity="0.18"/>
<rect x="6" y="40" width="35" height="3" rx="1" fill="currentColor" opacity="0.18"/>
<rect x="6" y="48" width="28" height="3" rx="1" fill="currentColor" opacity="0.18"/>
<rect x="6" y="56" width="32" height="3" rx="1" fill="currentColor" opacity="0.18"/>
<rect x="60" y="25" width="80" height="6" rx="1" fill="currentColor" opacity="0.35"/>
<rect x="60" y="38" width="120" height="8" rx="2" fill="currentColor" opacity="0.18"/>
<rect x="60" y="55" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="101" y="55" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="142" y="55" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="60" y="82" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="101" y="82" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="142" y="82" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -1,4 +1,5 @@
<script setup>
import { useI18n } from 'vue-i18n';
import Label from 'dashboard/components-next/label/Label.vue';
const props = defineProps({
@@ -30,10 +31,16 @@ const props = defineProps({
type: String,
default: '',
},
beta: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select']);
const { t } = useI18n();
const handleChange = () => {
if (!props.isActive && !props.disabled) {
emit('select', props.id);
@@ -42,22 +49,23 @@ const handleChange = () => {
</script>
<template>
<div
class="cursor-pointer rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
<label
:for="id"
class="rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6 focus-within:has-[:focus-visible]:ring-2 focus-within:has-[:focus-visible]:ring-n-strong"
:class="[
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
isActive ? 'outline-n-blue-9' : 'outline-n-weak',
!disabled && !isActive ? 'hover:outline-n-strong' : '',
]"
@click="handleChange"
>
<div class="flex flex-col gap-2 items-start">
<div class="flex items-center justify-between w-full gap-3">
<div class="flex items-center gap-2">
<h3 class="text-heading-3 text-n-slate-12">
<h3 class="text-heading-2 text-n-slate-12">
{{ label }}
</h3>
<Label v-if="disabled" :label="disabledLabel" color="amber" compact />
<Label v-if="beta" :label="t('GENERAL.BETA')" color="blue" compact />
</div>
<input
:id="`${id}`"
@@ -66,7 +74,7 @@ const handleChange = () => {
:name="id"
:disabled="disabled"
type="radio"
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0 flex-shrink-0"
class="shadow cursor-pointer grid place-items-center border-2 border-n-strong appearance-none rounded-full w-5 h-5 checked:bg-n-brand before:content-[''] before:bg-n-brand before:border-4 before:rounded-full before:border-n-strong checked:before:w-[18px] checked:before:h-[18px] checked:border checked:border-n-brand"
@change="handleChange"
/>
</div>
@@ -75,5 +83,5 @@ const handleChange = () => {
</p>
<slot />
</div>
</div>
</label>
</template>
@@ -866,6 +866,28 @@
},
"EDIT_CONFIGURATION": "Edit configuration"
},
"LAYOUT_CONTENT": {
"HEADER": "Appearance",
"DESCRIPTION": "Pick the layout that fits how your visitors read.",
"LAYOUT": {
"CLASSIC": {
"TITLE": "Classic",
"DESCRIPTION": "A welcoming home page with search and featured topics."
},
"SIDEBAR": {
"TITLE": "Documentation",
"DESCRIPTION": "Side-by-side navigation that keeps every guide a click away."
}
},
"SOCIAL_LINKS": {
"HEADER": "Social links",
"DESCRIPTION": "Add the handle for each network and your help center builds the full link. Shown in the documentation layout footer.",
"PLACEHOLDER": "handle",
"ADD": "Add social link",
"REMOVE": "Remove"
},
"SAVE": "Save changes"
},
"API": {
"CREATE_PORTAL": {
"SUCCESS_MESSAGE": "Portal created successfully",
@@ -13,7 +13,7 @@ defineEmits(['update']);
<template>
<div
class="flex flex-col sm:flex-row md:flex-col xl:flex-row items-start gap-4 mt-3 min-w-0"
class="flex flex-col sm:flex-row flex-col items-start gap-4 mt-3 min-w-0"
>
<RadioCard
id="disabled"
@@ -1,63 +0,0 @@
<script setup>
defineProps({
active: {
type: Boolean,
default: false,
},
title: {
type: String,
default: '',
},
description: {
type: String,
default: '',
},
lightImage: {
type: String,
default: '',
},
darkImage: {
type: String,
default: '',
},
});
</script>
<template>
<button
class="flex flex-col gap-4 w-full h-fit p-4 rounded-md border border-n-weak dark:border-n-weak"
:class="{
'border-n-brand ': active,
}"
>
<div class="flex flex-col gap-2 items-center w-full rounded-t-[5px]">
<div class="grid grid-cols-[1fr_auto] items-center w-full gap-1">
<div class="overflow-hidden text-heading-2 text-n-slate-12 text-start">
<span class="block truncate">{{ title }}</span>
</div>
<input
:checked="active"
type="radio"
:name="`hotkey-${title}`"
class="shadow cursor-pointer grid place-items-center border-2 border-n-strong appearance-none rounded-full w-5 h-5 checked:bg-n-brand before:content-[''] before:bg-n-brand before:border-4 before:rounded-full before:border-n-strong checked:before:w-[18px] checked:before:h-[18px] checked:border checked:border-n-brand"
/>
</div>
<span class="text-n-slate-11 line-clamp-2 text-body-para text-start">
{{ description }}
</span>
</div>
<div>
<img
:src="lightImage"
:alt="`Light themed image for ${title}`"
class="block object-cover w-full dark:hidden"
/>
<img
:src="darkImage"
:alt="`Dark themed image for ${title}`"
class="hidden object-cover w-full dark:block"
/>
</div>
</button>
</template>
@@ -13,7 +13,6 @@ import UserBasicDetails from './UserBasicDetails.vue';
import MessageSignature from './MessageSignature.vue';
import FontSize from './FontSize.vue';
import UserLanguageSelect from './UserLanguageSelect.vue';
import HotKeyCard from './HotKeyCard.vue';
import ChangePassword from './ChangePassword.vue';
import NotificationPreferences from './NotificationPreferences.vue';
import AudioNotifications from './AudioNotifications.vue';
@@ -22,6 +21,7 @@ import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import AccessToken from './AccessToken.vue';
import MfaSettingsCard from './MfaSettingsCard.vue';
import Policy from 'dashboard/components/policy.vue';
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
import {
ROLES,
CONVERSATION_PERMISSIONS,
@@ -36,7 +36,7 @@ export default {
UserProfilePicture,
Policy,
UserBasicDetails,
HotKeyCard,
RadioCard,
ChangePassword,
NotificationPreferences,
AudioNotifications,
@@ -268,26 +268,27 @@ export default {
<div
class="flex flex-col justify-between w-full gap-5 sm:gap-4 sm:flex-row"
>
<button
<RadioCard
v-for="hotKey in hotKeys"
:id="hotKey.key"
:key="hotKey.key"
class="px-0 reset-base w-full sm:flex-1 rounded-xl outline-1 outline"
:class="
isEditorHotKeyEnabled(hotKey.key)
? 'outline-n-brand/30'
: 'outline-n-weak'
"
:label="hotKey.title"
:description="hotKey.description"
:is-active="isEditorHotKeyEnabled(hotKey.key)"
class="sm:flex-1"
@select="toggleHotKey"
>
<HotKeyCard
:key="hotKey.title"
:title="hotKey.title"
:description="hotKey.description"
:light-image="hotKey.lightImage"
:dark-image="hotKey.darkImage"
:active="isEditorHotKeyEnabled(hotKey.key)"
@click="toggleHotKey(hotKey.key)"
<img
:src="hotKey.lightImage"
:alt="`Light themed image for ${hotKey.title}`"
class="block object-cover w-full dark:hidden"
/>
</button>
<img
:src="hotKey.darkImage"
:alt="`Dark themed image for ${hotKey.title}`"
class="hidden object-cover w-full dark:block"
/>
</RadioCard>
</div>
</SectionLayout>
<SectionLayout
+6 -2
View File
@@ -98,15 +98,19 @@ class Portal < ApplicationRecord
config_value('layout').presence || 'classic'
end
def social_profiles
config_value('social_profiles') || {}
end
private
def config_json_format
self.config = (config || {}).deep_stringify_keys
self.config = persisted_config.merge((config || {}).deep_stringify_keys)
config['allowed_locales'] = allowed_locale_codes
config['default_locale'] = default_locale
config['draft_locales'] = draft_locale_codes
denied_keys = config.keys - CONFIG_JSON_KEYS
errors.add(:cofig, "in portal on #{denied_keys.join(',')} is not supported.") if denied_keys.any?
errors.add(:config, "in portal on #{denied_keys.join(',')} is not supported.") if denied_keys.any?
errors.add(:config, 'default locale cannot be drafted.') if draft_locale?(default_locale)
end
@@ -15,6 +15,9 @@ json.config do
json.partial! 'api/v1/models/portal_config', formats: [:json], locale: locale, portal: portal
end
end
json.default_locale portal.default_locale
json.layout portal.layout
json.social_profiles portal.social_profiles
end
if portal.channel_web_widget
@@ -1,20 +1,25 @@
<%# locals: (portal:, global_config:) %>
<%
socials = (portal.config.is_a?(Hash) && portal.config['social_profiles']) || {}
socials = portal.social_profiles
social_definitions = [
{ key: 'facebook', icon: 'i-ri-facebook-circle-fill', label: 'Facebook' },
{ key: 'x', icon: 'i-ri-twitter-x-fill', label: 'X' },
{ key: 'instagram', icon: 'i-ri-instagram-fill', label: 'Instagram' },
{ key: 'tiktok', icon: 'i-ri-tiktok-fill', label: 'TikTok' },
{ key: 'github', icon: 'i-ri-github-fill', label: 'GitHub' },
{ key: 'whatsapp', icon: 'i-ri-whatsapp-fill', label: 'WhatsApp' }
{ key: 'facebook', icon: 'i-ri-facebook-circle-fill', label: 'Facebook', base_url: 'https://facebook.com/' },
{ key: 'x', icon: 'i-ri-twitter-x-fill', label: 'X', base_url: 'https://x.com/' },
{ key: 'instagram', icon: 'i-ri-instagram-fill', label: 'Instagram', base_url: 'https://instagram.com/' },
{ key: 'linkedin', icon: 'i-ri-linkedin-box-fill', label: 'LinkedIn', base_url: 'https://linkedin.com/' },
{ key: 'youtube', icon: 'i-ri-youtube-fill', label: 'YouTube', base_url: 'https://youtube.com/' },
{ key: 'tiktok', icon: 'i-ri-tiktok-fill', label: 'TikTok', base_url: 'https://tiktok.com/' },
{ key: 'github', icon: 'i-ri-github-fill', label: 'GitHub', base_url: 'https://github.com/' },
{ key: 'whatsapp', icon: 'i-ri-whatsapp-fill', label: 'WhatsApp', base_url: 'https://wa.me/' }
]
active_socials = social_definitions.select { |s| socials[s[:key]].to_s.strip.present? }
active_socials = social_definitions.filter_map do |social|
handle = socials[social[:key]].to_s.strip
social.merge(url: "#{social[:base_url]}#{handle}") if handle.present?
end
show_branding = !portal.account.feature_enabled?('disable_branding')
%>
<% if show_branding || active_socials.any? %>
<footer class="mt-auto px-6 md:px-10 py-6 border-t border-solid border-n-weak flex items-center justify-between gap-4 flex-wrap">
<footer class="mt-auto px-6 md:px-10 py-6 border-t border-solid border-n-weak flex items-center gap-4 flex-wrap <%= show_branding ? 'justify-between' : 'justify-end' %>">
<% if show_branding %>
<a href="<%= generate_portal_brand_url(global_config['BRAND_URL'], request.referer) %>"
target="_blank"
@@ -23,19 +28,17 @@
<%= I18n.t('public_portal.footer.made_with') %>
<span class="inline-flex items-center gap-1.5 font-medium text-n-slate-11">
<% if global_config['LOGO_THUMBNAIL'].present? %>
<img class="w-3.5 h-3.5 rounded-sm" alt="<%= global_config['BRAND_NAME'] %>" src="<%= global_config['LOGO_THUMBNAIL'] %>" />
<img class="w-3.5 h-3.5 rounded-sm" alt="<%= global_config['BRAND_NAME'] %>" src="<%= global_config['LOGO_THUMBNAIL'] %>">
<% end %>
<%= global_config['INSTALLATION_NAME'] %>
</span>
</a>
<% else %>
<span></span>
<% end %>
<% if active_socials.any? %>
<div class="flex items-center gap-0.5">
<% active_socials.each do |social| %>
<a href="<%= socials[social[:key]] %>"
<a href="<%= social[:url] %>"
target="_blank"
rel="noopener noreferrer nofollow"
class="p-2 rounded-md text-n-slate-10 hover:text-n-slate-12 hover:bg-n-alpha-2 transition"
@@ -170,7 +170,10 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
'allowed_locales' => [
{ 'articles_count' => 0, 'categories_count' => 0, 'code' => 'en', 'draft' => false },
{ 'articles_count' => 0, 'categories_count' => 0, 'code' => 'es', 'draft' => true }
]
],
'default_locale' => 'en',
'layout' => 'classic',
'social_profiles' => {}
}
)
end
+1 -1
View File
@@ -30,7 +30,7 @@ RSpec.describe Portal do
it 'Does not allow any other config than allowed_locales' do
portal.update(config: { 'some_other_key': 'test_value' })
expect(portal).not_to be_valid
expect(portal.errors.full_messages[0]).to eq('Cofig in portal on some_other_key is not supported.')
expect(portal.errors.full_messages[0]).to eq('Config in portal on some_other_key is not supported.')
end
it 'falls back to no drafted locales for existing portals' do