Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71fc0e0ab1 | ||
|
|
e3133e2103 | ||
|
|
c6d6664962 | ||
|
|
f5f6e527a8 | ||
|
|
1f55d98960 | ||
|
|
c5b13c8d45 | ||
|
|
63a14f7d06 | ||
|
|
a52223e5ea | ||
|
|
f0c4563ab8 | ||
|
|
f926f5e500 | ||
|
|
c9f6fb202c | ||
|
|
7830fec604 | ||
|
|
ba04e0b678 | ||
|
|
8625a00918 | ||
|
|
16e638aeb7 |
@@ -109,6 +109,15 @@ const runSDK = ({ baseUrl, websiteToken }) => {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
openArticle(slug) {
|
||||||
|
if (!slug) {
|
||||||
|
throw new Error('Article slug is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
IFrameHelper.events.toggleBubble('open');
|
||||||
|
IFrameHelper.sendMessage('open-article', { slug });
|
||||||
|
},
|
||||||
|
|
||||||
setUser(identifier, user) {
|
setUser(identifier, user) {
|
||||||
if (typeof identifier !== 'string' && typeof identifier !== 'number') {
|
if (typeof identifier !== 'string' && typeof identifier !== 'number') {
|
||||||
throw new Error('Identifier should be a string or a number');
|
throw new Error('Identifier should be a string or a number');
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
setBubbleText,
|
setBubbleText,
|
||||||
addUnreadClass,
|
addUnreadClass,
|
||||||
removeUnreadClass,
|
removeUnreadClass,
|
||||||
|
addArticleViewClass,
|
||||||
|
removeArticleViewClass,
|
||||||
} from './bubbleHelpers';
|
} from './bubbleHelpers';
|
||||||
import { isWidgetColorLighter } from 'shared/helpers/colorHelper';
|
import { isWidgetColorLighter } from 'shared/helpers/colorHelper';
|
||||||
import { dispatchWindowEvent } from 'shared/helpers/CustomEventHelper';
|
import { dispatchWindowEvent } from 'shared/helpers/CustomEventHelper';
|
||||||
@@ -268,6 +270,10 @@ export const IFrameHelper = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
resetUnreadMode: () => removeUnreadClass(),
|
resetUnreadMode: () => removeUnreadClass(),
|
||||||
|
|
||||||
|
expandWidget: () => addArticleViewClass(),
|
||||||
|
collapseWidget: () => removeArticleViewClass(),
|
||||||
|
|
||||||
handleNotificationDot: event => {
|
handleNotificationDot: event => {
|
||||||
if (window.$chatwoot.hideMessageBubble) {
|
if (window.$chatwoot.hideMessageBubble) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -110,3 +110,13 @@ export const removeUnreadClass = () => {
|
|||||||
const holderEl = document.querySelector('.woot-widget-holder');
|
const holderEl = document.querySelector('.woot-widget-holder');
|
||||||
removeClasses(holderEl, 'has-unread-view');
|
removeClasses(holderEl, 'has-unread-view');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const addArticleViewClass = () => {
|
||||||
|
const holderEl = document.querySelector('.woot-widget-holder');
|
||||||
|
addClasses(holderEl, 'has-article-view');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeArticleViewClass = () => {
|
||||||
|
const holderEl = document.querySelector('.woot-widget-holder');
|
||||||
|
removeClasses(holderEl, 'has-article-view');
|
||||||
|
};
|
||||||
|
|||||||
@@ -7,11 +7,14 @@ export const SDK_CSS = `
|
|||||||
.woot-widget-holder {
|
.woot-widget-holder {
|
||||||
box-shadow: 0 5px 40px rgba(0, 0, 0, .16);
|
box-shadow: 0 5px 40px rgba(0, 0, 0, .16);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
will-change: transform, opacity;
|
will-change: transform, opacity, width, height;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
overflow: hidden !important;
|
overflow: hidden !important;
|
||||||
position: fixed !important;
|
position: fixed !important;
|
||||||
transition: opacity 0.2s linear, transform 0.25s linear;
|
transition: opacity 0.2s linear, transform 0.25s linear,
|
||||||
|
width 0.18s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
height 0.18s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
max-height 0.18s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
z-index: 2147483000 !important;
|
z-index: 2147483000 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,6 +290,12 @@ export const SDK_CSS = `
|
|||||||
min-height: 250px !important;
|
min-height: 250px !important;
|
||||||
width: 400px !important;
|
width: 400px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.woot-widget-holder.has-article-view {
|
||||||
|
width: min(640px, max(0px, -20px + 100dvw)) !important;
|
||||||
|
height: calc(100% - 125px) !important;
|
||||||
|
max-height: calc(100% - 125px) !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.woot-hidden {
|
.woot-hidden {
|
||||||
|
|||||||
@@ -38,3 +38,24 @@ export const getMatchingLocale = (selectedLocale = '', allowedLocales = []) => {
|
|||||||
// Return the first match that exists in the allowed list, or null
|
// Return the first match that exists in the allowed list, or null
|
||||||
return priorityMatches.find(l => l && allowedLocales.includes(l)) ?? null;
|
return priorityMatches.find(l => l && allowedLocales.includes(l)) ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the link consumed by the in-widget article viewer, appending the query
|
||||||
|
* params it expects (plain layout, theme and locale).
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {string} options.link Relative article/portal path (e.g. `hc/slug/articles/foo`).
|
||||||
|
* @param {(string|null)} [options.locale] Resolved portal locale.
|
||||||
|
* @param {boolean} [options.prefersDarkMode] Whether the widget is in dark mode.
|
||||||
|
* @returns {string} The link with the article viewer query params appended.
|
||||||
|
*/
|
||||||
|
export const buildArticleViewerLink = ({ link, locale, prefersDarkMode }) => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
show_plain_layout: 'true',
|
||||||
|
theme: prefersDarkMode ? 'dark' : 'light',
|
||||||
|
...(locale && { locale }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${link}?${params.toString()}`;
|
||||||
|
};
|
||||||
|
|||||||
@@ -16,12 +16,22 @@ import {
|
|||||||
ON_AGENT_MESSAGE_RECEIVED,
|
ON_AGENT_MESSAGE_RECEIVED,
|
||||||
ON_CAMPAIGN_MESSAGE_CLICK,
|
ON_CAMPAIGN_MESSAGE_CLICK,
|
||||||
ON_UNREAD_MESSAGE_CLICK,
|
ON_UNREAD_MESSAGE_CLICK,
|
||||||
|
ON_ARTICLE_VIEW_RESIZING,
|
||||||
} from './constants/widgetBusEvents';
|
} from './constants/widgetBusEvents';
|
||||||
|
|
||||||
|
// Keep in sync with the widget holder width/height transition in sdk.js. The
|
||||||
|
// article view is masked for this long so the iframe can reflow off-screen.
|
||||||
|
const ARTICLE_VIEW_RESIZE_DURATION = 180;
|
||||||
import { useDarkMode } from 'widget/composables/useDarkMode';
|
import { useDarkMode } from 'widget/composables/useDarkMode';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useAvailability } from 'widget/composables/useAvailability';
|
import { useAvailability } from 'widget/composables/useAvailability';
|
||||||
|
import { useArticleView } from 'widget/composables/useArticleView';
|
||||||
import { SDK_SET_BUBBLE_VISIBILITY } from '../shared/constants/sharedFrameEvents';
|
import { SDK_SET_BUBBLE_VISIBILITY } from '../shared/constants/sharedFrameEvents';
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
|
import {
|
||||||
|
getMatchingLocale,
|
||||||
|
buildArticleViewerLink,
|
||||||
|
} from 'shared/helpers/portalHelper';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'App',
|
name: 'App',
|
||||||
@@ -33,8 +43,17 @@ export default {
|
|||||||
const { prefersDarkMode } = useDarkMode();
|
const { prefersDarkMode } = useDarkMode();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { isInWorkingHours } = useAvailability();
|
const { isInWorkingHours } = useAvailability();
|
||||||
|
const { isArticleView, isWidgetExpanded, setArticleView } =
|
||||||
|
useArticleView();
|
||||||
|
|
||||||
return { prefersDarkMode, router, isInWorkingHours };
|
return {
|
||||||
|
prefersDarkMode,
|
||||||
|
router,
|
||||||
|
isInWorkingHours,
|
||||||
|
isArticleView,
|
||||||
|
isWidgetExpanded,
|
||||||
|
setArticleView,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -66,6 +85,11 @@ export default {
|
|||||||
? getLanguageDirection(this.$root.$i18n.locale)
|
? getLanguageDirection(this.$root.$i18n.locale)
|
||||||
: false;
|
: false;
|
||||||
},
|
},
|
||||||
|
shouldExpandArticleView() {
|
||||||
|
// The widget only widens on article pages, and only when the user has
|
||||||
|
// opted in via the header toggle (persisted, collapsed by default).
|
||||||
|
return this.isArticleView && this.isWidgetExpanded;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
activeCampaign() {
|
activeCampaign() {
|
||||||
@@ -77,6 +101,26 @@ export default {
|
|||||||
document.documentElement.dir = value ? 'rtl' : 'ltr';
|
document.documentElement.dir = value ? 'rtl' : 'ltr';
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
'$route.name'(routeName, previousRouteName) {
|
||||||
|
// Leaving the article view tears down the iframe, so reset the flag; the
|
||||||
|
// watcher below collapses the widget if it was expanded.
|
||||||
|
if (previousRouteName === 'article-viewer') {
|
||||||
|
this.isArticleView = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shouldExpandArticleView(shouldExpand) {
|
||||||
|
if (!this.isIFrame) return;
|
||||||
|
// Resize the host widget and mask the iframe while it reflows off-screen,
|
||||||
|
// revealing it once the size transition settles.
|
||||||
|
IFrameHelper.sendMessage({
|
||||||
|
event: shouldExpand ? 'expandWidget' : 'collapseWidget',
|
||||||
|
});
|
||||||
|
emitter.emit(ON_ARTICLE_VIEW_RESIZING, true);
|
||||||
|
setTimeout(
|
||||||
|
() => emitter.emit(ON_ARTICLE_VIEW_RESIZING, false),
|
||||||
|
ARTICLE_VIEW_RESIZE_DURATION
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
const { websiteToken, locale, widgetColor } = window.chatwootWebChannel;
|
const { websiteToken, locale, widgetColor } = window.chatwootWebChannel;
|
||||||
@@ -160,6 +204,26 @@ export default {
|
|||||||
this.$root.$i18n.locale = localeWithoutVariation;
|
this.$root.$i18n.locale = localeWithoutVariation;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
openArticle(slug) {
|
||||||
|
const { portal } = window.chatwootWebChannel;
|
||||||
|
if (!portal || !slug) return;
|
||||||
|
|
||||||
|
const locale = getMatchingLocale(
|
||||||
|
this.$root.$i18n.locale,
|
||||||
|
portal.config?.allowed_locales
|
||||||
|
);
|
||||||
|
const link = buildArticleViewerLink({
|
||||||
|
link: `hc/${portal.slug}/articles/${slug}`,
|
||||||
|
locale,
|
||||||
|
prefersDarkMode: this.prefersDarkMode,
|
||||||
|
});
|
||||||
|
// Add a timestamp so the route always changes, even when the same article
|
||||||
|
// is requested again or the iframe was browsed to another page.
|
||||||
|
this.router.push({
|
||||||
|
name: 'article-viewer',
|
||||||
|
query: { link, v: Date.now() },
|
||||||
|
});
|
||||||
|
},
|
||||||
registerUnreadEvents() {
|
registerUnreadEvents() {
|
||||||
emitter.on(ON_AGENT_MESSAGE_RECEIVED, () => {
|
emitter.on(ON_AGENT_MESSAGE_RECEIVED, () => {
|
||||||
const { name: routeName } = this.$route;
|
const { name: routeName } = this.$route;
|
||||||
@@ -316,6 +380,15 @@ export default {
|
|||||||
this.setBubbleLabel();
|
this.setBubbleLabel();
|
||||||
} else if (message.event === 'set-color-scheme') {
|
} else if (message.event === 'set-color-scheme') {
|
||||||
this.setColorScheme(message.darkMode);
|
this.setColorScheme(message.darkMode);
|
||||||
|
} else if (message.event === 'open-article') {
|
||||||
|
this.openArticle(message.slug);
|
||||||
|
} else if (message.event === 'portalPageLoaded') {
|
||||||
|
// portalPageLoaded is delivered asynchronously and can arrive after
|
||||||
|
// we've left the article view; ignore it unless we're still there so
|
||||||
|
// the expanded width can't leak onto the listing or other views.
|
||||||
|
if (this.$route.name === 'article-viewer') {
|
||||||
|
this.setArticleView(message.isArticle);
|
||||||
|
}
|
||||||
} else if (message.event === 'toggle-open') {
|
} else if (message.event === 'toggle-open') {
|
||||||
this.$store.dispatch('appConfig/toggleWidgetOpen', message.isOpen);
|
this.$store.dispatch('appConfig/toggleWidgetOpen', message.isOpen);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { toRef } from 'vue';
|
import { toRef } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||||
import HeaderActions from './HeaderActions.vue';
|
import HeaderActions from './HeaderActions.vue';
|
||||||
import AvailabilityContainer from 'widget/components/Availability/AvailabilityContainer.vue';
|
import AvailabilityContainer from 'widget/components/Availability/AvailabilityContainer.vue';
|
||||||
import { useAvailability } from 'widget/composables/useAvailability';
|
import { useAvailability } from 'widget/composables/useAvailability';
|
||||||
|
import { useArticleView } from 'widget/composables/useArticleView';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
avatarUrl: { type: String, default: '' },
|
avatarUrl: { type: String, default: '' },
|
||||||
@@ -17,7 +19,10 @@ const props = defineProps({
|
|||||||
const availableAgents = toRef(props, 'availableAgents');
|
const availableAgents = toRef(props, 'availableAgents');
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { t } = useI18n();
|
||||||
const { isOnline } = useAvailability(availableAgents);
|
const { isOnline } = useAvailability(availableAgents);
|
||||||
|
const { isArticleView, isWidgetExpanded, toggleWidgetExpanded } =
|
||||||
|
useArticleView();
|
||||||
|
|
||||||
const onBackButtonClick = () => {
|
const onBackButtonClick = () => {
|
||||||
router.replace({ name: 'home' });
|
router.replace({ name: 'home' });
|
||||||
@@ -58,6 +63,25 @@ const onBackButtonClick = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<HeaderActions :show-popout-button="showPopoutButton" />
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
v-if="isArticleView"
|
||||||
|
class="button transparent compact"
|
||||||
|
:title="
|
||||||
|
isWidgetExpanded
|
||||||
|
? t('PORTAL.COLLAPSE_ARTICLE')
|
||||||
|
: t('PORTAL.EXPAND_ARTICLE')
|
||||||
|
"
|
||||||
|
@click="toggleWidgetExpanded"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="size-4 text-n-slate-12"
|
||||||
|
:class="
|
||||||
|
isWidgetExpanded ? 'i-lucide-minimize-2' : 'i-lucide-maximize-2'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<HeaderActions :show-popout-button="showPopoutButton" />
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import { useRouter } from 'vue-router';
|
|||||||
import { useStore } from 'dashboard/composables/store';
|
import { useStore } from 'dashboard/composables/store';
|
||||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||||
import { useDarkMode } from 'widget/composables/useDarkMode';
|
import { useDarkMode } from 'widget/composables/useDarkMode';
|
||||||
import { getMatchingLocale } from 'shared/helpers/portalHelper';
|
import {
|
||||||
|
getMatchingLocale,
|
||||||
|
buildArticleViewerLink,
|
||||||
|
} from 'shared/helpers/portalHelper';
|
||||||
|
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -38,14 +41,11 @@ const fetchArticles = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openArticleInArticleViewer = link => {
|
const openArticleInArticleViewer = link => {
|
||||||
const params = new URLSearchParams({
|
const linkToOpen = buildArticleViewerLink({
|
||||||
show_plain_layout: 'true',
|
link,
|
||||||
theme: prefersDarkMode.value ? 'dark' : 'light',
|
locale: locale.value,
|
||||||
...(locale.value && { locale: locale.value }),
|
prefersDarkMode: prefersDarkMode.value,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Combine link with query parameters
|
|
||||||
const linkToOpen = `${link}?${params.toString()}`;
|
|
||||||
router.push({ name: 'article-viewer', query: { link: linkToOpen } });
|
router.push({ name: 'article-viewer', query: { link: linkToOpen } });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||||
|
|
||||||
|
const EXPANDED_STORAGE_KEY = 'chatwoot:widget:articleViewExpanded';
|
||||||
|
|
||||||
|
// Module-level singletons so the header toggle and the resize logic in App.vue
|
||||||
|
// share a single source of truth.
|
||||||
|
//
|
||||||
|
// `isArticleView` - whether the iframe is currently showing an article page.
|
||||||
|
// `isWidgetExpanded`- the user's persisted expand/collapse preference. Defaults
|
||||||
|
// to collapsed and only ever applies on article pages.
|
||||||
|
const isArticleView = ref(false);
|
||||||
|
const isWidgetExpanded = ref(LocalStorage.get(EXPANDED_STORAGE_KEY) === true);
|
||||||
|
|
||||||
|
export function useArticleView() {
|
||||||
|
const setArticleView = value => {
|
||||||
|
isArticleView.value = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleWidgetExpanded = () => {
|
||||||
|
isWidgetExpanded.value = !isWidgetExpanded.value;
|
||||||
|
LocalStorage.set(EXPANDED_STORAGE_KEY, isWidgetExpanded.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isArticleView,
|
||||||
|
isWidgetExpanded,
|
||||||
|
setArticleView,
|
||||||
|
toggleWidgetExpanded,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,3 +2,4 @@ export const ON_AGENT_MESSAGE_RECEIVED = 'ON_AGENT_MESSAGE_RECEIVED';
|
|||||||
export const ON_UNREAD_MESSAGE_CLICK = 'ON_UNREAD_MESSAGE_CLICK';
|
export const ON_UNREAD_MESSAGE_CLICK = 'ON_UNREAD_MESSAGE_CLICK';
|
||||||
export const ON_CAMPAIGN_MESSAGE_CLICK = 'ON_CAMPAIGN_MESSAGE_CLICK';
|
export const ON_CAMPAIGN_MESSAGE_CLICK = 'ON_CAMPAIGN_MESSAGE_CLICK';
|
||||||
export const ON_CONVERSATION_CREATED = 'ON_CONVERSATION_CREATED';
|
export const ON_CONVERSATION_CREATED = 'ON_CONVERSATION_CREATED';
|
||||||
|
export const ON_ARTICLE_VIEW_RESIZING = 'ON_ARTICLE_VIEW_RESIZING';
|
||||||
|
|||||||
@@ -125,7 +125,9 @@
|
|||||||
"PORTAL": {
|
"PORTAL": {
|
||||||
"POPULAR_ARTICLES": "Popular Articles",
|
"POPULAR_ARTICLES": "Popular Articles",
|
||||||
"VIEW_ALL_ARTICLES": "View all articles",
|
"VIEW_ALL_ARTICLES": "View all articles",
|
||||||
"IFRAME_LOAD_ERROR": "There was an error loading the article, please refresh the page and try again."
|
"IFRAME_LOAD_ERROR": "There was an error loading the article, please refresh the page and try again.",
|
||||||
|
"EXPAND_ARTICLE": "Expand",
|
||||||
|
"COLLAPSE_ARTICLE": "Collapse"
|
||||||
},
|
},
|
||||||
"ATTACHMENTS": {
|
"ATTACHMENTS": {
|
||||||
"image": {
|
"image": {
|
||||||
|
|||||||
@@ -1,16 +1,41 @@
|
|||||||
<script>
|
<script setup>
|
||||||
|
import { ref, onMounted, onBeforeUnmount } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
|
import { ON_ARTICLE_VIEW_RESIZING } from 'widget/constants/widgetBusEvents';
|
||||||
import IframeLoader from 'shared/components/IframeLoader.vue';
|
import IframeLoader from 'shared/components/IframeLoader.vue';
|
||||||
|
|
||||||
export default {
|
const route = useRoute();
|
||||||
name: 'ArticleViewer',
|
|
||||||
components: {
|
// Masks the article while the widget resizes (see App.vue#setArticleView) so the
|
||||||
IframeLoader,
|
// iframe's text reflow happens off-screen instead of shifting in front of the user.
|
||||||
},
|
const isResizing = ref(false);
|
||||||
|
const setResizing = value => {
|
||||||
|
isResizing.value = value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onMounted(() => emitter.on(ON_ARTICLE_VIEW_RESIZING, setResizing));
|
||||||
|
onBeforeUnmount(() => emitter.off(ON_ARTICLE_VIEW_RESIZING, setResizing));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="bg-white h-full">
|
<div class="bg-white dark:bg-slate-900 h-full relative">
|
||||||
<IframeLoader :url="$route.query.link" />
|
<!--
|
||||||
|
Key by fullPath (not just the link) so the iframe remounts on every
|
||||||
|
navigation here, including re-opening the same article via the SDK after
|
||||||
|
the iframe was browsed to another help-center page. See App.vue#openArticle.
|
||||||
|
-->
|
||||||
|
<IframeLoader :key="route.fullPath" :url="route.query.link" />
|
||||||
|
<!--
|
||||||
|
Cover the article instantly while the widget resizes, then fade it out once
|
||||||
|
the size transition settles. The asymmetric class (no transition on the way
|
||||||
|
in, transition on the way out) keeps the cover from revealing the reflow.
|
||||||
|
-->
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-white dark:bg-slate-900 pointer-events-none"
|
||||||
|
:class="
|
||||||
|
isResizing ? 'opacity-100' : 'opacity-0 transition-opacity duration-100'
|
||||||
|
"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -88,6 +88,19 @@ html.light {
|
|||||||
tocHeader: '<%= I18n.t('public_portal.toc_header') %>'
|
tocHeader: '<%= I18n.t('public_portal.toc_header') %>'
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
<% if @is_plain_layout_enabled %>
|
||||||
|
<script>
|
||||||
|
// When rendered inside the chat widget, tell it whether this is an article
|
||||||
|
// page so the widget widens only for articles and collapses on every other page.
|
||||||
|
window.parent.postMessage(
|
||||||
|
'chatwoot-widget:' + JSON.stringify({
|
||||||
|
event: 'portalPageLoaded',
|
||||||
|
isArticle: <%= @article.present? %>,
|
||||||
|
}),
|
||||||
|
'*'
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
<% end %>
|
||||||
<% if @portal.channel_web_widget.present? && !@is_plain_layout_enabled %>
|
<% if @portal.channel_web_widget.present? && !@is_plain_layout_enabled %>
|
||||||
<script>
|
<script>
|
||||||
window.chatwootSettings = window.chatwootSettings || {};
|
window.chatwootSettings = window.chatwootSettings || {};
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
<section id="portal-bg" class="w-full bg-white dark:bg-slate-900 shadow-inner">
|
<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 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-start">
|
<div class="mx-auto max-w-5xl px-5 md:px-8 flex flex-col items-start">
|
||||||
<span class="text-sm leading-[24px] font-semibold text-slate-600 dark:text-slate-300 mb-1 <%= @portal.logo.present? ? 'block lg:hidden' : 'hidden' %>"><%= @portal.localized_value('name', @locale) %></span>
|
<span class="text-sm leading-[24px] font-semibold text-slate-600 dark:text-slate-300 mb-1 <%= @portal.logo.present? ? 'block lg:hidden' : 'hidden' %>"><%= @portal.localized_value('name', @locale) %></span>
|
||||||
<h1 class="text-2xl md:text-4xl text-slate-900 dark:text-white font-semibold leading-normal">
|
<h1 class="text-2xl md:text-4xl text-slate-900 dark:text-white font-semibold leading-normal">
|
||||||
<%= portal.localized_value('header_text', @locale) %>
|
<%= portal.localized_value('header_text', @locale) %>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<div class="flex flex-row items-center gap-px mb-6">
|
<div class="flex flex-row items-center gap-px mb-6">
|
||||||
<a
|
<a
|
||||||
class="text-slate-500 dark:text-slate-200 text-sm gap-1 hover:cursor-pointer <%= @is_plain_layout_enabled && 'hover:underline' %> leading-8 font-semibold"
|
class="text-slate-500 dark:text-slate-200 text-sm gap-1 hover:cursor-pointer <%= @is_plain_layout_enabled && 'hover:underline' %> leading-8 font-semibold"
|
||||||
href="<%= generate_home_link(@portal.slug, @article.category&.locale, @theme_from_params, @is_plain_layout_enabled) %>"
|
href="<%= generate_home_link(@portal.slug, @article.category&.locale, @theme_from_params, @is_plain_layout_enabled) %>"
|
||||||
>
|
>
|
||||||
<%= I18n.t('public_portal.common.home') %>
|
<%= I18n.t('public_portal.common.home') %>
|
||||||
</a>
|
</a>
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
<h1 class="text-3xl font-semibold leading-normal md:tracking-normal md:text-4xl text-slate-900 dark:text-white">
|
<h1 class="text-3xl font-semibold leading-normal md:tracking-normal md:text-4xl text-slate-900 dark:text-white">
|
||||||
<%= article.title %>
|
<%= article.title %>
|
||||||
</h1>
|
</h1>
|
||||||
<div class="flex flex-col items-start justify-between w-full pt-6 md:flex-row md:items-center">
|
<div class="flex flex-col items-start justify-between w-full md:flex-row md:items-center">
|
||||||
<div class="flex items-start space-x-1">
|
<div class="flex items-start space-x-1">
|
||||||
<span class="flex items-center text-base font-medium text-slate-600 dark:text-slate-400">
|
<span class="flex items-center text-base font-medium text-slate-600 dark:text-slate-400">
|
||||||
<%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %>
|
<%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %>
|
||||||
|
|||||||
@@ -24,19 +24,19 @@
|
|||||||
<% if !@is_plain_layout_enabled %>
|
<% if !@is_plain_layout_enabled %>
|
||||||
<div id="portal-bg" class="bg-white dark:bg-slate-900 shadow-inner">
|
<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 id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6">
|
||||||
<div class="max-w-5xl px-4 md:px-8 mx-auto flex flex-col">
|
<div class="max-w-5xl px-5 md:px-8 mx-auto flex flex-col">
|
||||||
<%= render "public/api/v1/portals/articles/article_header", article: @article %>
|
<%= render "public/api/v1/portals/articles/article_header", article: @article %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="max-w-5xl mx-auto space-y-4 w-full px-4 md:px-8 <%= @is_plain_layout_enabled ? 'py-4' : 'py-8' %>">
|
<div class="max-w-5xl mx-auto space-y-4 w-full px-5 md:px-8 <%= @is_plain_layout_enabled ? 'py-4' : 'py-8' %>">
|
||||||
<%= render "public/api/v1/portals/articles/article_header", article: @article %>
|
<%= render "public/api/v1/portals/articles/article_header", article: @article %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div class="flex max-w-5xl w-full px-4 md:px-8 mx-auto">
|
<div class="flex max-w-5xl w-full px-5 md:px-8 mx-auto">
|
||||||
<article id="cw-article-content" class="article-content flex-grow flex-2 mx-auto text-slate-800 dark:text-slate-50 text-lg max-w-3xl prose-h1:text-2xl prose-h2:text-xl prose-h2:mt-0 prose-h3:text-lg prose-code:[&>p]:p-1 prose-code:[&>p]:rounded-sm prose-code:[&>p]:bg-black-100 dark:prose-code:[&>p]:bg-black-600 prose-code:after:content-none prose-code:before:content-none prose dark:prose-invert break-words w-full [&_table]:!border-slate-200 dark:[&_table]:!border-slate-800 [&_th]:!border-slate-200 dark:[&_th]:!border-slate-800 [&_td]:!border-slate-200 dark:[&_td]:!border-slate-800 [&_th]:!bg-slate-50 dark:[&_th]:!bg-slate-800/50 <%= @is_plain_layout_enabled ? 'py-4' : 'pt-8 pb-12' %>">
|
<article id="cw-article-content" class="article-content flex-grow flex-2 mx-auto text-slate-800 dark:text-slate-50 text-base max-w-3xl prose-h1:text-xl prose-h2:text-lg prose-h2:mt-8 prose-h3:text-base prose-h3:mt-6 prose-headings:mb-3 [&>:first-child]:!mt-0 prose-code:[&>p]:p-1 prose-code:[&>p]:rounded-sm prose-code:[&>p]:bg-black-100 dark:prose-code:[&>p]:bg-black-600 prose-code:after:content-none prose-code:before:content-none prose dark:prose-invert break-words w-full [&_table]:!border-slate-200 dark:[&_table]:!border-slate-800 [&_th]:!border-slate-200 dark:[&_th]:!border-slate-800 [&_td]:!border-slate-200 dark:[&_td]:!border-slate-800 [&_th]:!bg-slate-50 dark:[&_th]:!bg-slate-800/50 <%= @is_plain_layout_enabled ? 'py-4' : 'pt-8 pb-12' %>">
|
||||||
<%= @parsed_content %>
|
<%= @parsed_content %>
|
||||||
</article>
|
</article>
|
||||||
<div class="flex-1" id="cw-hc-toc"></div>
|
<div class="flex-1" id="cw-hc-toc"></div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="flex flex-col px-4 md:px-8 max-w-5xl w-full mx-auto gap-6 <%= @is_plain_layout_enabled && 'py-4' %>">
|
<div class="flex flex-col px-5 md:px-8 max-w-5xl w-full mx-auto gap-6 <%= @is_plain_layout_enabled && 'py-4' %>">
|
||||||
<div class="flex items-center flex-row">
|
<div class="flex items-center flex-row">
|
||||||
<a
|
<a
|
||||||
class="text-slate-500 dark:text-slate-200 text-sm gap-1 <%= @is_plain_layout_enabled && 'hover:underline' %> hover:cursor-pointer leading-8 font-semibold"
|
class="text-slate-500 dark:text-slate-200 text-sm gap-1 <%= @is_plain_layout_enabled && 'hover:underline' %> hover:cursor-pointer leading-8 font-semibold"
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<% else %>
|
<% else %>
|
||||||
<%= render 'public/api/v1/portals/categories/category-hero', category: @category, portal: @portal %>
|
<%= render 'public/api/v1/portals/categories/category-hero', category: @category, portal: @portal %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<section class="max-w-5xl w-full mx-auto px-4 md:px-8 py-6 flex flex-col items-center justify-center flex-grow">
|
<section class="max-w-5xl w-full mx-auto px-5 md:px-8 py-6 flex flex-col items-center justify-center flex-grow">
|
||||||
<div class="w-full flex flex-col gap-6 flex-grow">
|
<div class="w-full flex flex-col gap-6 flex-grow">
|
||||||
<% if @category.articles.published.size == 0 %>
|
<% if @category.articles.published.size == 0 %>
|
||||||
<div class="h-full flex items-center justify-center bg-slate-50 dark:bg-slate-800 rounded-xl py-6">
|
<div class="h-full flex items-center justify-center bg-slate-50 dark:bg-slate-800 rounded-xl py-6">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<% if !@is_plain_layout_enabled %>
|
<% if !@is_plain_layout_enabled %>
|
||||||
<div id="portal-bg" class="bg-white dark:bg-slate-900 shadow-inner">
|
<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 id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6">
|
||||||
<div class="max-w-5xl px-4 md:px-8 mx-auto flex flex-col">
|
<div class="max-w-5xl px-5 md:px-8 mx-auto flex flex-col">
|
||||||
<div class="flex flex-row items-center gap-px mb-6">
|
<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 hover:underline leading-8 font-semibold"
|
<a class="text-slate-500 dark:text-slate-200 text-sm gap-1 hover:cursor-pointer hover:underline leading-8 font-semibold"
|
||||||
href="<%= generate_home_link(@portal.slug, params[:locale], @theme_from_params, @is_plain_layout_enabled) %>">
|
href="<%= generate_home_link(@portal.slug, params[:locale], @theme_from_params, @is_plain_layout_enabled) %>">
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="max-w-5xl px-4 md:px-8 mx-auto flex flex-col py-4">
|
<div class="max-w-5xl px-5 md:px-8 mx-auto flex flex-col py-4">
|
||||||
<div class="flex flex-row items-center gap-px mb-6">
|
<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 hover:underline leading-8 font-semibold"
|
<a class="text-slate-500 dark:text-slate-200 text-sm gap-1 hover:cursor-pointer hover:underline leading-8 font-semibold"
|
||||||
href="<%= generate_home_link(@portal.slug, params[:locale], @theme_from_params, @is_plain_layout_enabled) %>">
|
href="<%= generate_home_link(@portal.slug, params[:locale], @theme_from_params, @is_plain_layout_enabled) %>">
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
|
|
||||||
<%= render 'public/api/v1/portals/search/search_handler' %>
|
<%= render 'public/api/v1/portals/search/search_handler' %>
|
||||||
|
|
||||||
<section class="max-w-5xl w-full mx-auto px-4 md:px-8 py-6 flex flex-col items-center justify-center flex-grow">
|
<section class="max-w-5xl w-full mx-auto px-5 md:px-8 py-6 flex flex-col items-center justify-center flex-grow">
|
||||||
<div class="w-full flex flex-col gap-6 flex-grow">
|
<div class="w-full flex flex-col gap-6 flex-grow">
|
||||||
<% if @articles.empty? %>
|
<% if @articles.empty? %>
|
||||||
<div class="h-full flex items-center justify-center bg-slate-50 dark:bg-slate-800 rounded-xl py-6">
|
<div class="h-full flex items-center justify-center bg-slate-50 dark:bg-slate-800 rounded-xl py-6">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<%= render "public/api/v1/portals/hero", portal: @portal %>
|
<%= render "public/api/v1/portals/hero", portal: @portal %>
|
||||||
<div class="max-w-5xl w-full flex flex-col flex-grow mx-auto py-8 px-4 md:px-8 gap-6">
|
<div class="max-w-5xl w-full flex flex-col flex-grow mx-auto py-8 px-5 md:px-8 gap-6">
|
||||||
<%# Featured Articles %>
|
<%# Featured Articles %>
|
||||||
<% if !@is_plain_layout_enabled %>
|
<% if !@is_plain_layout_enabled %>
|
||||||
<div><%= render "public/api/v1/portals/featured_articles", articles: @portal.articles, categories: @portal.categories.where(locale: @locale), portal: @portal %></div>
|
<div><%= render "public/api/v1/portals/featured_articles", articles: @portal.articles, categories: @portal.categories.where(locale: @locale), portal: @portal %></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user