Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View File

@@ -0,0 +1 @@
json.partial! 'public/api/v1/models/csat_survey', formats: [:json], resource: @message

View File

@@ -0,0 +1 @@
json.partial! 'public/api/v1/models/csat_survey', formats: [:json], resource: @message

View File

@@ -0,0 +1,3 @@
json.source_id @contact_inbox.source_id
json.pubsub_token @contact_inbox.pubsub_token
json.partial! 'public/api/v1/models/contact', resource: @contact_inbox.contact, formats: [:json]

View File

@@ -0,0 +1,3 @@
json.source_id @contact_inbox.source_id
json.pubsub_token @contact_inbox.pubsub_token
json.partial! 'public/api/v1/models/contact', formats: [:json], resource: @contact_inbox.contact

View File

@@ -0,0 +1,3 @@
json.source_id @contact_inbox.source_id
json.pubsub_token @contact_inbox.pubsub_token
json.partial! 'public/api/v1/models/contact', formats: [:json], resource: @contact_inbox.contact

View File

@@ -0,0 +1 @@
json.partial! 'public/api/v1/models/conversation', formats: [:json], resource: @conversation

View File

@@ -0,0 +1,3 @@
json.array! @conversations do |conversation|
json.partial! 'public/api/v1/models/conversation', formats: [:json], resource: conversation
end

View File

@@ -0,0 +1 @@
json.partial! 'public/api/v1/models/conversation', formats: [:json], resource: @conversation

View File

@@ -0,0 +1 @@
json.partial! 'public/api/v1/models/conversation', formats: [:json], resource: @conversation

View File

@@ -0,0 +1 @@
json.partial! 'public/api/v1/models/message', formats: [:json], resource: @message

View File

@@ -0,0 +1,3 @@
json.array! @messages do |message|
json.partial! 'public/api/v1/models/message', formats: [:json], resource: message
end

View File

@@ -0,0 +1 @@
json.partial! 'public/api/v1/models/message', formats: [:json], resource: @message

View File

@@ -0,0 +1,3 @@
json.identifier @inbox_channel.identifier
json.identity_validation_enabled @inbox_channel.hmac_mandatory
json.partial! 'public/api/v1/models/inbox', formats: [:json], resource: @inbox_channel.inbox

View File

@@ -0,0 +1,42 @@
json.id article.id
json.category_id article.category_id
json.title article.title
json.content article.content
json.description article.description
json.status article.status
json.position article.position
json.account_id article.account_id
json.last_updated_at article.updated_at
json.slug article.slug
if article.portal.present?
json.portal do
json.partial! 'public/api/v1/models/hc/portal', formats: [:json], portal: article.portal
end
end
if article.category.present?
json.category do
json.id article.category.id
json.slug article.category.slug
json.locale article.category.locale
end
end
json.views article.views
if article.author.present?
json.author do
json.partial! 'public/api/v1/models/hc/author', formats: [:json], resource: article.author
end
end
json.associated_articles do
if article.associated_articles.any?
json.array! article.associated_articles.each do |associated_article|
json.partial! 'public/api/v1/models/hc/associated_article', formats: [:json], article: associated_article
end
end
end
json.link "hc/#{article.portal.slug}/articles/#{article.slug}"

View File

@@ -0,0 +1,5 @@
json.name category.name
json.slug category.slug
json.locale category.locale
json.description category.description
json.position category.position

View File

@@ -0,0 +1,9 @@
json.name category.name
json.slug category.slug
json.locale category.locale
json.description category.description
json.position category.position
json.meta do
json.articles_count category.articles.published.size
end

View File

@@ -0,0 +1,4 @@
json.id resource.id
json.name resource.name
json.email resource.email
json.phone_number resource.phone_number

View File

@@ -0,0 +1,12 @@
json.id resource.display_id
json.uuid resource.uuid
json.inbox_id resource.inbox_id
json.contact_last_seen_at resource.contact_last_seen_at.to_i
json.status resource.status
json.agent_last_seen_at resource.agent_last_seen_at.to_i
json.messages do
json.array! resource.messages.chat do |message|
json.partial! 'public/api/v1/models/message', formats: [:json], resource: message
end
end
json.contact resource.contact

View File

@@ -0,0 +1,9 @@
json.id resource.id
json.csat_survey_response resource.csat_survey_response
json.display_type resource.inbox.csat_config.try(:[], 'display_type') || 'emoji'
json.content resource.inbox.csat_config.try(:[], 'message')
json.inbox_avatar_url resource.inbox.avatar_url
json.inbox_name resource.inbox.name
json.locale resource.account.locale
json.conversation_id resource.conversation_id
json.created_at resource.created_at

View File

@@ -0,0 +1,6 @@
json.name resource.name
json.timezone resource.timezone
json.working_hours resource.weekly_schedule
json.working_hours_enabled resource.working_hours_enabled
json.csat_survey_enabled resource.csat_survey_enabled
json.greeting_enabled resource.greeting_enabled

View File

@@ -0,0 +1,9 @@
json.id resource.id
json.content resource.content
json.message_type resource.message_type_before_type_cast
json.content_type resource.content_type
json.content_attributes resource.content_attributes
json.created_at resource.created_at.to_i
json.conversation_id resource.conversation.display_id
json.attachments resource.attachments.map(&:push_event_data) if resource.attachments.present?
json.sender resource.sender.push_event_data if resource.sender

View File

@@ -0,0 +1,15 @@
json.id article.id
json.category_id article.category_id
json.title article.title
json.content article.content
json.description article.description
json.status article.status
json.account_id article.account_id
json.last_updated_at article.updated_at
json.views article.views
if article.author.present?
json.author do
json.partial! 'public/api/v1/models/hc/author', formats: [:json], resource: article.author
end
end

View File

@@ -0,0 +1,3 @@
json.available_name resource.available_name
json.name resource.name
json.thumbnail resource.avatar_url

View File

@@ -0,0 +1,22 @@
json.custom_domain portal.custom_domain
json.header_text portal.header_text
json.homepage_link portal.homepage_link
json.name portal.name
json.page_title portal.page_title
json.slug portal.slug
json.categories do
if portal.categories.any?
json.array! portal.categories.each do |category|
json.partial! 'public/api/v1/models/category', formats: [:json], category: category
end
end
end
json.logo portal.file_base_data if portal.logo.present?
json.meta do
json.articles_count portal.articles.published.size
json.categories_count portal.categories.size
json.default_locale portal.default_locale
end

View File

@@ -0,0 +1,6 @@
<% if article_count == 0 %>
<% elsif article_count == 1 %>
<%= article_count %> <%= I18n.t('public_portal.common.article') %>
<% else %>
<%= article_count %> <%= I18n.t('public_portal.common.articles') %>
<% end %>

View File

@@ -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 items-center ltr:flex-row rtl:flex-row-reverse -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 %>

View File

@@ -0,0 +1,60 @@
<% 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 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>
</div>
<% if category.description.present? %>
<span class="text-base text-slate-600 dark:text-slate-400 <%= !@is_plain_layout_enabled && 'px-2' %>"><%= category.description %></span>
<% end %>
</div>
</div>
<div class="flex flex-col gap-2 flex-grow <%= category.description.blank? && '-mt-4' %>">
<% if category.articles.published.size==0 %>
<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="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 mt-1.5">
<%= render partial: 'icons/chevron-right' %>
</span>
</div>
</a>
<% end %>
<% end %>
</div>
<div class="flex justify-between flex-row items-center <%= !@is_plain_layout_enabled && 'px-2' %>">
<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="<%= 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>
</div>
</div>
</section>

View File

@@ -0,0 +1,28 @@
<% featured_articles = articles.where(category_id: categories).search_by_status(:published).order_by_views.limit(6) %>
<% if featured_articles.count >= 6 %>
<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 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 gap-2 md:grid-cols-2 gap-x-2 gap-y-2">
<% featured_articles.each do |article| %>
<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' %>
</span>
</div>
</a>
<% end %>
</div>
</div>
</section>
<% end %>

View File

@@ -0,0 +1,19 @@
<footer class="pt-16 pb-8 flex flex-col items-center justify-center">
<div class="mx-auto max-w-2xl text-center py-2">
<div class="flex items-center gap-2">
<img
class="w-4 h-4"
alt="<%= @global_config['BRAND_NAME'] %>"
src="<%= @global_config['LOGO_THUMBNAIL'] %>"
/>
<p class="text-slate-700 dark:text-slate-300 text-sm font-medium text-center">
<%= I18n.t('public_portal.footer.made_with') %>
<a class="hover:underline" href="<%= generate_portal_brand_url(@global_config['BRAND_URL'], request.referer) %>" target="_blank" rel="noopener noreferrer nofollow"><%= @global_config['INSTALLATION_NAME'] %></a>
</p>
</div>
</div>
</footer>

View File

@@ -0,0 +1,117 @@
<header class="sticky top-0 z-50 w-full bg-white shadow-sm dark:bg-slate-900">
<nav class="hidden sm: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 min-w-0 h-10">
<% if @portal.logo.present? %>
<img src="<%= url_for(@portal.logo) %>" class="w-auto h-10 ltr:mr-2 rtl:ml-2" draggable="false" />
<% end %>
<span class="text-lg font-semibold text-slate-900 truncate dark:text-white <%= @portal.logo.present? ? 'hidden lg:block' : 'hidden sm:block' %>"><%= @portal.name %></span>
</a>
</div>
<%# Go to homepage link section %>
<div class="flex items-center justify-between gap-2 sm:gap-5">
<% if @portal.homepage_link %>
<div class="px-1 py-2 ltr:ml-8 rtl:mr-8 cursor-pointer block">
<div class="flex-grow flex-shrink-0">
<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>
</div>
</div>
<% end %>
<%# Appearance toggle section %>
<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"
data-current-theme="<%= @theme_from_params %>"
class="appearance-menu absolute hidden flex-col w-32 h-auto bg-white border border-solid rounded dark:bg-slate-900 top-9 ltr:right-1 rtl:left-1 border-slate-100 dark:border-slate-800 shadow-lg transition-all duration-200 ease-out opacity-0 scale-95 data-[dropdown-open=true]:opacity-100 data-[dropdown-open=true]:scale-100 data-[dropdown-open=true]:flex"
aria-hidden="true"
data-dropdown="appearance-dropdown">
<button class="desktop-theme-button 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 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors" data-theme="system">
<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>
<span class="check-mark-icon system-theme">
<%= render partial: 'icons/check-mark' %>
</span>
</button>
<button class="desktop-theme-button 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 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors" data-theme="light">
<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>
<span class="check-mark-icon light-theme">
<%= render partial: 'icons/check-mark' %>
</span>
</button>
<button class="desktop-theme-button 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 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors rounded-b" data-theme="dark">
<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>
<span class="check-mark-icon dark-theme">
<%= render partial: 'icons/check-mark' %>
</span>
</button>
</div>
</div>
<%# Locale switcher section %>
<% if @portal.config["allowed_locales"].length > 1 %>
<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="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 == @locale ? 'selected': '' %> value="<%= locale %>"><%= "#{language_name(locale)} (#{locale})" %></option>
<% end %>
</select>
<%= render partial: 'icons/chevron-down' %>
</div>
</div>
<% end %>
</div>
</nav>
<nav class="flex sm:hidden max-w-5xl px-4 mx-auto" aria-label="Mobile Top">
<div class="flex items-center justify-between w-full py-5">
<a href="<%= generate_home_link(@portal.slug, @portal.config['default_locale'] || params[:locale], @theme_from_params, @is_plain_layout_enabled) %>" class="flex items-center min-w-0 h-10 text-lg font-semibold text-slate-900 dark:text-white">
<% if @portal.logo.present? %>
<img src="<%= url_for(@portal.logo) %>" class="w-auto h-10 ltr:mr-2 rtl:ml-2" draggable="false" />
<% end %>
<span class="text-lg font-semibold text-slate-900 truncate dark:text-white <%= @portal.logo.present? ? 'hidden' : 'sm:hidden block' %>"><%= @portal.name %></span>
</a>
<!-- Mobile Menu Component -->
<%= render partial: 'public/api/v1/portals/mobile_menu', locals: {
portal: @portal,
locale: @locale,
theme_from_params: @theme_from_params
} %>
</div>
</nav>
</header>

View File

@@ -0,0 +1,25 @@
<% if !@is_plain_layout_enabled %>
<% content_for :head do %>
<title><%= @portal.name %></title>
<meta name="title" content="<%= @portal.name %>">
<% if @og_image_url.present? %>
<meta name="twitter:card" content="summary_large_image">
<meta name="og:image" content="<%= @og_image_url.html_safe %>">
<meta property="og:image" content="<%= @og_image_url.html_safe %>">
<meta name="twitter:image" content="<%= @og_image_url.html_safe %>">
<% end %>
<% end %>
<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-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.name %></span>
<h1 class="text-2xl md:text-4xl text-slate-900 dark:text-white font-semibold leading-normal">
<%= portal.header_text %>
</h1>
<p class="text-slate-600 dark:text-slate-200 text-start text-lg leading-normal pt-2 pb-4"><%= I18n.t('public_portal.hero.sub_title') %></p>
<div id="search-wrap" class="w-full"></div>
</div>
</div>
</section>
<% end %>

View File

@@ -0,0 +1,86 @@
<% has_multiple_locales = @portal.config["allowed_locales"].length > 1 %>
<input type="checkbox" id="mobile-menu-toggle" class="peer/menu hidden" />
<label for="mobile-menu-toggle" class="relative p-2 text-slate-700 dark:text-slate-200 cursor-pointer z-[60] hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg transition-colors" aria-label="Toggle menu">
<%= render partial: 'icons/hamburger' %>
</label>
<div class="fixed inset-0 z-[1000] invisible select-none opacity-0 peer-checked/menu:visible peer-checked/menu:opacity-100 transition-all duration-300 sm:hidden">
<div class="w-full h-full bg-white dark:bg-slate-900 shadow-xl transition-transform duration-300 ease-out">
<div class="flex flex-col h-full">
<div class="flex items-center justify-end py-5 px-4 border-b border-slate-100 dark:border-slate-800">
<label for="mobile-menu-toggle" class="p-2 text-slate-700 dark:text-slate-200 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg transition-colors" aria-label="Close menu">
<%= render partial: 'icons/close' %>
</label>
</div>
<div class="flex-1 overflow-y-auto px-5 pb-5 pt-2 flex flex-col gap-4">
<!-- Theme Switcher Section -->
<div class="flex flex-col gap-2">
<h3 class="text-base font-medium text-slate-700 dark:text-slate-300 my-2">
<%= I18n.t('public_portal.header.appearance.title', default: 'Appearance') %>
</h3>
<div id="mobile-appearance-dropdown" data-current-theme="<%= @theme_from_params %>" class="appearance-menu space-y-1">
<button class="mobile-theme-button group flex items-center gap-3 justify-start w-full py-2 hover:text-slate-700 dark:hover:text-slate-200 text-slate-800 dark:text-slate-100 transition-colors stroke-slate-800 dark:stroke-slate-100" data-theme="system">
<%= render partial: 'icons/monitor' %>
<span class="text-lg font-medium"><%= I18n.t('public_portal.header.appearance.system') %></span>
<span class="check-mark-icon system-theme">
<%= render partial: 'icons/check-mark' %>
</span>
</button>
<button class="mobile-theme-button group flex items-center gap-3 justify-start w-full py-2 hover:text-slate-700 dark:hover:text-slate-200 text-slate-800 dark:text-slate-100 transition-colors stroke-slate-800 dark:stroke-slate-100" data-theme="light">
<%= render partial: 'icons/sun' %>
<span class="text-lg font-medium"><%= I18n.t('public_portal.header.appearance.light') %></span>
<span class="check-mark-icon light-theme">
<%= render partial: 'icons/check-mark' %>
</span>
</button>
<button class="mobile-theme-button group flex items-center gap-3 justify-start py-2 hover:text-slate-700 dark:hover:text-slate-200 text-slate-800 dark:text-slate-100 transition-colors stroke-slate-800 dark:stroke-slate-100" data-theme="dark">
<%= render partial: 'icons/moon' %>
<span class="text-lg font-medium"><%= I18n.t('public_portal.header.appearance.dark') %></span>
<span class="check-mark-icon dark-theme">
<%= render partial: 'icons/check-mark' %>
</span>
</button>
</div>
</div>
<% if has_multiple_locales %>
<span class="h-px bg-slate-100/70 dark:bg-slate-800/70 w-full"></span>
<% end %>
<!-- Locale Switcher -->
<% if has_multiple_locales %>
<div id="header-action-button" class="flex flex-col gap-2">
<h3 class="text-base font-medium text-slate-700 dark:text-slate-300 my-2">
<%= I18n.t('public_portal.header.language', default: 'Language') %>
</h3>
<div class="flex items-center gap-3 py-2 cursor-pointer stroke-slate-800 dark:stroke-slate-100">
<%= render partial: 'icons/globe' %>
<select
data-portal-slug="<%= @portal.slug %>"
class="flex-1 bg-transparent text-lg font-medium cursor-pointer focus:outline-none locale-switcher text-slate-800 dark:text-slate-100 hover:text-slate-700 dark:hover:text-slate-200"
>
<% @portal.config["allowed_locales"].each do |locale| %>
<option <%= locale == @locale ? 'selected': '' %> value="<%= locale %>"><%= "#{language_name(locale)} (#{locale})" %></option>
<% end %>
</select>
</div>
</div>
<% end %>
<span class="h-px bg-slate-100/70 dark:bg-slate-800/70 w-full"></span>
<!-- Homepage Link -->
<% if @portal.homepage_link %>
<a href="<%= @portal.homepage_link %>" target="_blank" rel="noopener noreferrer nofollow" class="flex items-center gap-3 py-2 cursor-pointer stroke-slate-800 dark:stroke-slate-100 text-slate-800 dark:text-slate-100 hover:text-slate-700 dark:hover:text-slate-200 transition-colors">
<%= render partial: 'icons/redirect' %>
<span class="text-lg font-medium"><%= I18n.t('public_portal.header.visit_website') %></span>
</a>
<% end %>
</div>
</div>
</div>
</div>

View File

@@ -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 %>

View File

@@ -0,0 +1,27 @@
<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 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="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-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' %>
</span>
</div>
</a>
<% end %>
</div>
<div class="flex justify-between flex-row items-center <%= !@is_plain_layout_enabled && 'px-2' %>">
<span class="text-sm font-medium text-slate-600 dark:text-slate-400"><%= render 'public/api/v1/portals/article_count', article_count: portal.articles.published.where(category_id: nil).size %></span>
</div>
</div>
</section>

View File

@@ -0,0 +1,37 @@
<% 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="<%= 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="<%= 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="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-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 pt-6 md:flex-row md:items-center">
<div class="flex items-start space-x-1">
<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")) %>
</span>
</div>
</div>

View File

@@ -0,0 +1,36 @@
<div class="bg-slate-50 dark:bg-slate-800">
<div class="w-full max-w-4xl px-6 py-16 mx-auto space-y-12">
<div class="space-y-4">
<div>
<a
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>
<span>/</span>
<span>/</span>
</div>
<% @articles.each do |article| %>
<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 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 border rounded-full h-812">
<div>
<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>
</div>
</div>
<% end %>
</div>
</div>
</div>
<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="max-w-3xl font-sans text-lg leading-8 text-slate-800 dark:text-slate-50 blog-content">
</div>
</article>
</div>

View File

@@ -0,0 +1,8 @@
json.payload do
json.array! @articles.includes([:category, :associated_articles, { author: { avatar_attachment: [:blob] } }]),
partial: 'public/api/v1/models/article', formats: [:json], as: :article
end
json.meta do
json.articles_count @articles_count
end

View File

@@ -0,0 +1,48 @@
<% content_for :head do %>
<title><%= @article.title %> | <%= @portal.name %></title>
<% if @article.meta["title"].present? %>
<meta name="title" content="<%= @article.meta["title"] %>">
<meta property="og:title" content="<%= @article.meta["title"] %>">
<meta name="twitter:title" content="<%= @article.meta["title"] %>">
<% end %>
<% if @article.meta["description"].present? %>
<meta name="description" content="<%= @article.meta["description"] %>">
<meta property="og:description" content="<%= @article.meta["description"] %>">
<meta name="twitter:description" content="<%= @article.meta["description"] %>">
<% end %>
<% if @article.meta["tags"].present? %>
<meta name="tags" content="<%= @article.meta["tags"].join(',') %>">
<% end %>
<% if @og_image_url.present? %>
<meta name="twitter:card" content="summary_large_image">
<meta name="og:image" content="<%= @og_image_url.html_safe %>">
<meta property="og:image" content="<%= @og_image_url.html_safe %>">
<meta name="twitter:image" content="<%= @og_image_url.html_safe %>">
<% end %>
<% end %>
<% if !@is_plain_layout_enabled %>
<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>
</div>
</div>
<% 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' %>">
<%= render "public/api/v1/portals/articles/article_header", article: @article %>
</div>
<% end %>
<div class="flex max-w-5xl w-full px-4 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 <%= @is_plain_layout_enabled ? 'py-4' : 'pt-8 pb-12' %>">
<%= @parsed_content %>
</article>
<div class="flex-1" id="cw-hc-toc"></div>
</div>
<style>
.article-content li > p {
margin: 0;
}
</style>

View File

@@ -0,0 +1 @@
json.partial! 'public/api/v1/models/article', formats: [:json], article: @article

View File

@@ -0,0 +1,77 @@
<% 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>
<span class="text-slate-500">
<%= render 'public/api/v1/portals/article_count', article_count: category.articles.published.order(position: :asc).size %>
</span>
</div>
<div class="flex-grow w-full py-4 mt-2">
<% if category.articles.published.size == 0 %>
<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 content-center justify-between h-8 my-1">
<a
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>
<span class="flex items-center">
<svg
class="w-4 h-4 fill-current text-slate-700 dark:text-slate-200"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.47 4.22a.75.75 0 0 0 0 1.06L15.19 12l-6.72 6.72a.75.75 0 1 0 1.06 1.06l7.25-7.25a.75.75 0 0 0 0-1.06L9.53 4.22a.75.75 0 0 0-1.06 0Z"
/>
</svg>
</span>
</div>
<% end %>
<% end %>
</div>
<div>
<a
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">
<svg
class="w-4 h-4 fill-current text-woot-500"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.267 4.209a.75.75 0 0 0-1.034 1.086l6.251 5.955H3.75a.75.75 0 0 0 0 1.5h14.734l-6.251 5.954a.75.75 0 0 0 1.034 1.087l7.42-7.067a.996.996 0 0 0 .3-.58.758.758 0 0 0-.001-.29.995.995 0 0 0-.3-.578l-7.419-7.067Z"
/>
</svg>
</span>
</a>
</div>
</section>

View File

@@ -0,0 +1,30 @@
<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 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="<%= generate_home_link(portal.slug, 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>
<span class="text-sm text-slate-800 dark:text-slate-100 font-semibold"><%= category.name %></span>
</div>
<div class="flex justify-start flex-col items-start w-full gap-2">
<div class="flex flex-col gap-2">
<% if category.icon.present? %>
<span class="text-4xl"><%= category.icon %></span>
<% end %>
<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-700 dark:text-slate-200 text-base leading-5"><%= category.description %></span>
<% end %>
</div>
<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>

View File

@@ -0,0 +1,7 @@
<section class="bg-slate-50 dark:bg-slate-800 py-16 flex flex-col items-center justify-center">
<div class="mx-auto max-w-2xl">
<h1 class="text-4xl text-slate-900 dark:text-white font-semibold leading-relaxed text-center"><%= portal.header_text %></h1>
<p class="text-slate-700 dark:text-slate-100 py-2 text-center"><%= I18n.t('public_portal.hero.sub_title') %></p>
</div>
</section>

View File

@@ -0,0 +1,9 @@
<%= render "hero", portal: @portal %>
<div class="max-w-5xl w-full flex-grow mx-auto py-16">
<div class="grid grid-cols-2 gap-x-32 gap-y-12">
<% @categories.each do |category| %>
<%= render "category-block", category: category, portal: @portal %>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,3 @@
json.payload do
json.array! @categories, partial: 'public/api/v1/models/category', formats: [:json], as: :category
end

View File

@@ -0,0 +1,51 @@
<% content_for :head do %>
<title><%= @category.name %> | <%= @portal.name %></title>
<meta name="title" content="<%= @category.name %> | <%= @portal.name %>">
<% if @category.description.present? %>
<meta name="description" content="<%= @category.description %>">
<meta property="og:description" content="<%= @category.description %>">
<meta name="twitter:description" content="<%= @category.description %>">
<% end %>
<% if @og_image_url.present? %>
<meta name="twitter:card" content="summary_large_image">
<meta name="og:image" content="<%= @og_image_url.html_safe %>">
<meta property="og:image" content="<%= @og_image_url.html_safe %>">
<meta name="twitter:image" content="<%= @og_image_url.html_safe %>">
<% end %>
<% end %>
<% if !@is_plain_layout_enabled %>
<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>
<% else %>
<%= render 'public/api/v1/portals/categories/category-hero', category: @category, portal: @portal %>
<% 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">
<div class="w-full flex flex-col gap-6 flex-grow">
<% 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">
<p class="text-sm text-slate-500"><%= I18n.t('public_portal.common.no_articles') %></p>
</div>
<% else %>
<% @category.articles.published.order(:position).each do |article| %>
<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="<%= 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 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>
<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>
<% end %>
<% end %>
</div>
</section>

View File

@@ -0,0 +1 @@
json.partial! 'public/api/v1/models/category', formats: [:json], category: @category

View File

@@ -0,0 +1,12 @@
<div class="max-w-6xl h-full w-full flex-grow flex flex-col items-center justify-center mx-auto py-16 px-4 relative">
<div class="text-center mb-12">
<span class="text-8xl">🔍</span>
</div>
<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="<%= 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>
</div>

View File

@@ -0,0 +1 @@
json.array! @portals, partial: 'public/api/v1/models/hc/portal', formats: [:json], as: :portal

View File

@@ -0,0 +1,19 @@
<%= 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">
<%# Featured Articles %>
<% 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>
<% end %>
<%# Categories with articles %>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-6">
<% @portal.categories.where(locale: @locale).joins(:articles).where(articles:{ status: :published }).order(position: :asc).group('categories.id').each do |category| %>
<%= render "public/api/v1/portals/category-block", category: category, portal: @portal %>
<% end %>
</div>
<%# Uncategorized articles %>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-x-6 gap-y-6">
<% if @portal.articles.where(status: :published, category_id: nil, locale: @locale).count > 0 %>
<%= render "public/api/v1/portals/uncategorized-block", category: "Uncategorized", portal: @portal %>
<% end %>
</div>
</div>

View File

@@ -0,0 +1 @@
json.partial! 'public/api/v1/models/hc/portal', formats: [:json], portal: @portal

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<% @portal.articles.where(status: :published).each do |article| %>
<url>
<loc><%= @help_center_url %><%= generate_article_link(@portal.slug, article.slug, false, false) %></loc>
<lastmod><%= article.updated_at.to_date.iso8601 %></lastmod>
</url>
<% end %>
</urlset>