From cd33cea69f141eb75a466fc161bba35eba0edaaf Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Wed, 13 May 2026 14:18:25 +0530 Subject: [PATCH 01/31] chore: Update `nl` translation in widget (#14441) --- app/javascript/widget/i18n/locale/nl.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/javascript/widget/i18n/locale/nl.json b/app/javascript/widget/i18n/locale/nl.json index 0dd99b863..2f123f80a 100644 --- a/app/javascript/widget/i18n/locale/nl.json +++ b/app/javascript/widget/i18n/locale/nl.json @@ -14,24 +14,24 @@ }, "THUMBNAIL": { "AUTHOR": { - "NOT_AVAILABLE": "Not available" + "NOT_AVAILABLE": "Niet beschikbaar" } }, "TEAM_AVAILABILITY": { "ONLINE": "We zijn online", "OFFLINE": "We zijn momenteel afwezig", - "BACK_AS_SOON_AS_POSSIBLE": "We will be back as soon as possible" + "BACK_AS_SOON_AS_POSSIBLE": "We zijn zo snel mogelijk weer terug" }, "REPLY_TIME": { "IN_A_FEW_MINUTES": "Reageert meestal binnen een paar minuten", "IN_A_FEW_HOURS": "Reageert meestal binnen een paar uur", "IN_A_DAY": "Reageert meestal binnen een dag", - "BACK_IN_HOURS": "We will be back online in {n} hour | We will be back online in {n} hours", - "BACK_IN_MINUTES": "We will be back online in {time} minutes", - "BACK_AT_TIME": "We will be back online at {time}", - "BACK_ON_DAY": "We will be back online on {day}", - "BACK_TOMORROW": "We will be back online tomorrow", - "BACK_IN_SOME_TIME": "We will be back online in some time" + "BACK_IN_HOURS": "We zullen weer online zijn in {n} uur | We zijn weer online in {n} uur", + "BACK_IN_MINUTES": "We zijn weer online in {time} minuten", + "BACK_AT_TIME": "We zijn weer online op {time}", + "BACK_ON_DAY": "We zijn weer online op {day}", + "BACK_TOMORROW": "We zullen morgen weer online zijn", + "BACK_IN_SOME_TIME": "We zijn weer online binnen een bepaalde tijd" }, "DAY_NAMES": { "SUNDAY": "zondag", From c6dceb0e0760253f54ced1dcd9bb18f349b170bf Mon Sep 17 00:00:00 2001 From: Aarav Uniyal <172099753+SapotaDA@users.noreply.github.com> Date: Wed, 13 May 2026 15:28:58 +0530 Subject: [PATCH 02/31] fix: contacts dropdown overlap (#14305) --- .../components-next/Contacts/ContactsHeader/ContactHeader.vue | 2 +- .../Contacts/ContactsHeader/components/ContactSortMenu.vue | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsHeader/ContactHeader.vue b/app/javascript/dashboard/components-next/Contacts/ContactsHeader/ContactHeader.vue index 773867de1..62bbd8419 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsHeader/ContactHeader.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsHeader/ContactHeader.vue @@ -32,7 +32,7 @@ const emit = defineEmits([ diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryHeaderControls.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryHeaderControls.vue index 3424e08bc..f25ac8027 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryHeaderControls.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryHeaderControls.vue @@ -25,7 +25,7 @@ const props = defineProps({ }, }); -const emit = defineEmits(['localeChange']); +const emit = defineEmits(['localeChange', 'newArticle']); const route = useRoute(); const router = useRouter(); @@ -179,7 +179,7 @@ const handleBreadcrumbClick = () => { /> -
+
diff --git a/app/javascript/dashboard/components-next/HelpCenter/PortalSwitcher/PortalSwitcher.vue b/app/javascript/dashboard/components-next/HelpCenter/PortalSwitcher/PortalSwitcher.vue index bfc173d16..b4d1fe263 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/PortalSwitcher/PortalSwitcher.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/PortalSwitcher/PortalSwitcher.vue @@ -19,6 +19,7 @@ const DEFAULT_ROUTE = 'portals_articles_index'; const CATEGORY_ROUTE = 'portals_categories_index'; const CATEGORY_SUB_ROUTES = [ 'portals_categories_articles_index', + 'portals_categories_articles_new', 'portals_categories_articles_edit', ]; diff --git a/app/javascript/dashboard/routes/dashboard/helpcenter/helpcenter.routes.js b/app/javascript/dashboard/routes/dashboard/helpcenter/helpcenter.routes.js index fba4ba666..8face00d0 100644 --- a/app/javascript/dashboard/routes/dashboard/helpcenter/helpcenter.routes.js +++ b/app/javascript/dashboard/routes/dashboard/helpcenter/helpcenter.routes.js @@ -62,6 +62,14 @@ const portalRoutes = [ meta, component: PortalsArticlesIndexPage, }, + { + path: getPortalRoute( + ':portalSlug/:locale/categories/:categorySlug/articles/new' + ), + name: 'portals_categories_articles_new', + meta, + component: PortalsArticlesNewPage, + }, { path: getPortalRoute( ':portalSlug/:locale/categories/:categorySlug/articles/:articleSlug' diff --git a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesNewPage.vue b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesNewPage.vue index baee8a2bb..f63f16fde 100644 --- a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesNewPage.vue +++ b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesNewPage.vue @@ -21,7 +21,18 @@ const selectedCategoryId = ref(null); const currentUserId = useMapGetter('getCurrentUserID'); const categories = useMapGetter('categories/allCategories'); -const categoryId = computed(() => categories.value[0]?.id || null); +const categoryId = computed(() => { + const { categorySlug } = route.params; + if (categorySlug) { + const matched = categories.value?.find(c => c.slug === categorySlug); + if (matched) return matched.id; + } + return categories.value[0]?.id || null; +}); + +const isCategoryArticles = computed( + () => route.name === 'portals_categories_articles_new' +); const article = ref({}); const isUpdating = ref(false); @@ -44,23 +55,34 @@ const createNewArticle = async ({ title, content }) => { isUpdating.value = true; try { const { locale } = route.params; + const resolvedCategoryId = selectedCategoryId.value || categoryId.value; const articleId = await store.dispatch('articles/create', { portalSlug, content: article.value.content, title: article.value.title, locale: locale, authorId: selectedAuthorId.value || currentUserId.value, - categoryId: selectedCategoryId.value || categoryId.value, + categoryId: resolvedCategoryId, }); useTrack(PORTALS_EVENTS.CREATE_ARTICLE, { locale }); + const resolvedSlug = categories.value?.find( + c => c.id === resolvedCategoryId + )?.slug; + const startedFromCategorySlug = route.params.categorySlug; + router.replace({ - name: 'portals_articles_edit', + name: isCategoryArticles.value + ? 'portals_categories_articles_edit' + : 'portals_articles_edit', params: { articleSlug: articleId, portalSlug, locale, + ...(startedFromCategorySlug + ? { categorySlug: resolvedSlug || startedFromCategorySlug } + : {}), }, }); } catch (error) { @@ -74,10 +96,17 @@ const createNewArticle = async ({ title, content }) => { const goBackToArticles = () => { const { tab, categorySlug, locale } = route.params; - router.push({ - name: 'portals_articles_index', - params: { tab, categorySlug, locale }, - }); + if (isCategoryArticles.value) { + router.push({ + name: 'portals_categories_articles_index', + params: { categorySlug, locale }, + }); + } else { + router.push({ + name: 'portals_articles_index', + params: { tab, categorySlug, locale }, + }); + } }; From c4089f2226023d2dd6ba6af3bdbf37ef530a2d69 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Mon, 18 May 2026 20:16:30 +0400 Subject: [PATCH 22/31] fix(csat): Require confirmation before submitting rating (#14450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Customers reported that the CSAT survey was recording their rating the instant they tapped a star — leaving no chance to correct an accidental pick. This change lets the customer freely change their selection until they actually submit the comment/feedback. The rating still saves on click (so we don't lose ratings when a customer never types a comment), but it stays editable until the comment form is submitted. Once that happens, the rating locks. The flow on both surfaces: - Customer taps a star/emoji → rating is saved. - Customer taps a different star/emoji → previous save is overwritten with the new value. - Customer types a comment and submits → latest rating + comment are saved together. - After that submit, the rating is locked and can't be changed. Two surfaces are updated: - **Standalone survey page** (`/survey/responses/:uuid`) — the rating buttons remain re-tappable until the Feedback form is submitted; once submitted, both rating and feedback lock. - **In-conversation widget CSAT** — same behavior, the inline arrow-submit button on the feedback form is the locking action. In-flight guards prevent a race where the customer changes their pick mid-network-call (raised by the codex review on the earlier revision): while a save is in flight, the rating controls are temporarily disabled so the request and the displayed selection can't diverge. ## Closes - https://linear.app/chatwoot/issue/CW-7061/csat-star-rating-submits-on-first-click-needs-confirmation-step ## How to test **Standalone survey page** 1. Enable CSAT on any inbox (Settings → Inboxes → Configuration → CSAT survey). 2. Resolve a conversation in that inbox so a CSAT message is generated. 3. Open the survey URL: `{FRONTEND_URL}/survey/responses/{conversation.uuid}` (easiest: `bundle exec rails runner 'puts Conversation.joins(:messages).where(messages: { content_type: "input_csat" }).last.csat_survey_link'`). 4. Tap a star/emoji — confirm the rating saves (Network panel shows a PUT to `/public/api/v1/csat_survey/{uuid}`). 5. Tap a different star/emoji — confirm a second PUT goes out with the new value; the latest selection is reflected. 6. Type a comment and hit Submit feedback — confirm rating + feedback persist; both controls now lock. 7. Reload the page — the locked state is rehydrated correctly. **Widget CSAT** 1. From an inbox with CSAT enabled, resolve a conversation that has an active widget session. 2. In the widget, the CSAT card appears with stars/emojis + the inline comment box. 3. Tap a star/emoji — confirm a PATCH goes out and the selection visibly updates. 4. Tap different stars/emojis — confirm each overrides the previous save. 5. Type a comment and click the arrow — rating + comment submit together; stars lock. Both display types (emoji and 5-star) should behave consistently. ## What changed - `app/javascript/survey/views/Response.vue` — `selectRating()` saves on every tap and short-circuits while a save is in flight (or after feedback was submitted). Rating components are disabled by `isFeedbackSubmitted || isUpdating` so the lock follows the feedback submission, not the first rating tap. - `app/javascript/survey/components/Rating.vue` — new `isDisabled` prop. The disabled / hover styling and click guard key off it instead of the presence of `selectedRating`, so emojis stay re-clickable until the feedback step locks them. - `app/javascript/shared/components/CustomerSatisfaction.vue` — same shape for the widget: rating click auto-submits and re-clicks override the previous save; controls disabled while a submit is in flight; emoji-button styling and the inline `StarRating` lock on `isFeedbackSubmitted || isUpdating`. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com> --- .../components/CustomerSatisfaction.vue | 14 +++++++++---- app/javascript/survey/components/Feedback.vue | 18 ++++++++++++++++- app/javascript/survey/components/Rating.vue | 9 +++++++-- app/javascript/survey/views/Response.vue | 20 ++++++++++++++----- 4 files changed, 49 insertions(+), 12 deletions(-) diff --git a/app/javascript/shared/components/CustomerSatisfaction.vue b/app/javascript/shared/components/CustomerSatisfaction.vue index 10792aca2..799ec772c 100644 --- a/app/javascript/shared/components/CustomerSatisfaction.vue +++ b/app/javascript/shared/components/CustomerSatisfaction.vue @@ -49,7 +49,9 @@ export default { ?.feedback_message; }, isButtonDisabled() { - return !(this.selectedRating && this.feedback); + if (!(this.selectedRating && this.feedback)) return true; + if (this.isUpdating) return true; + return false; }, textColor() { return getContrastingTextColor(this.widgetColor); @@ -79,14 +81,16 @@ export default { methods: { buttonClass(rating) { + const isLocked = this.isFeedbackSubmitted || this.isUpdating; return [ { selected: rating.value === this.selectedRating }, - { disabled: this.isRatingSubmitted }, - { hover: this.isRatingSubmitted }, + { disabled: isLocked }, + { hover: isLocked }, 'emoji-button', ]; }, async onSubmit() { + if (this.isUpdating) return; this.isUpdating = true; try { await this.$store.dispatch('message/update', { @@ -106,10 +110,12 @@ export default { }, selectRating(rating) { + if (this.isFeedbackSubmitted || this.isUpdating) return; this.selectedRating = rating.value; this.onSubmit(); }, selectStarRating(value) { + if (this.isFeedbackSubmitted || this.isUpdating) return; this.selectedRating = value; this.onSubmit(); }, @@ -138,7 +144,7 @@ export default {
- + {{ $t('SURVEY.FEEDBACK.BUTTON_TEXT') }} diff --git a/app/javascript/survey/components/Rating.vue b/app/javascript/survey/components/Rating.vue index 25467706d..db2fd8b75 100644 --- a/app/javascript/survey/components/Rating.vue +++ b/app/javascript/survey/components/Rating.vue @@ -7,6 +7,10 @@ export default { type: Number, default: null, }, + isDisabled: { + type: Boolean, + default: false, + }, }, emits: ['selectRating'], data() { @@ -19,12 +23,13 @@ export default { buttonClass(rating) { return [ { selected: rating.value === this.selectedRating }, - { disabled: !!this.selectedRating }, - { hover: !!this.selectedRating }, + { disabled: this.isDisabled }, + { hover: this.isDisabled }, 'emoji-button shadow-none text-3xl lg:text-4xl outline-none mr-8', ]; }, onClick(rating) { + if (this.isDisabled) return; this.$emit('selectRating', rating.value); }, }, diff --git a/app/javascript/survey/views/Response.vue b/app/javascript/survey/views/Response.vue index 878dd7c9d..38fc4c66e 100644 --- a/app/javascript/survey/views/Response.vue +++ b/app/javascript/survey/views/Response.vue @@ -27,6 +27,7 @@ export default { errorMessage: null, selectedRating: null, feedbackMessage: '', + hasSubmittedFeedback: false, isUpdating: false, logo: '', inboxName: '', @@ -43,10 +44,14 @@ export default { return this.surveyDetails && this.surveyDetails.rating; }, isFeedbackSubmitted() { - return this.surveyDetails && this.surveyDetails.feedback_message; + return ( + this.hasSubmittedFeedback || !!this.surveyDetails?.feedback_message + ); }, isButtonDisabled() { - return !(this.selectedRating && this.feedback); + if (!this.selectedRating) return true; + if (this.isUpdating) return true; + return false; }, isEmojiType() { return this.displayType === CSAT_DISPLAY_TYPES.EMOJI; @@ -78,12 +83,13 @@ export default { }, methods: { selectRating(rating) { + if (this.isFeedbackSubmitted || this.isUpdating) return; this.selectedRating = rating; this.updateSurveyDetails(); }, sendFeedback(message) { this.feedbackMessage = message; - this.updateSurveyDetails(); + this.updateSurveyDetails({ markFeedbackSubmitted: true }); }, async getSurveyDetails() { this.isLoading = true; @@ -106,7 +112,7 @@ export default { this.isLoading = false; } }, - async updateSurveyDetails() { + async updateSurveyDetails({ markFeedbackSubmitted = false } = {}) { this.isUpdating = true; try { const data = { @@ -127,6 +133,9 @@ export default { rating: this.selectedRating, feedback_message: this.feedbackMessage, }; + if (markFeedbackSubmitted) { + this.hasSubmittedFeedback = true; + } } catch (error) { const errorMessage = error?.response?.data?.error; this.errorMessage = errorMessage || this.$t('SURVEY.API.ERROR_MESSAGE'); @@ -179,12 +188,13 @@ export default { From 92f8c13ce531ce399521c8f5a581eb8f9ac20697 Mon Sep 17 00:00:00 2001 From: Sony Mathew <2040199+sony-mathew@users.noreply.github.com> Date: Mon, 18 May 2026 21:53:43 +0530 Subject: [PATCH 23/31] Bump version to 4.14.0 --- VERSION_CW | 2 +- config/app.yml | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/VERSION_CW b/VERSION_CW index 813b83b65..c412a4e2e 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -4.13.0 +4.14.0 diff --git a/config/app.yml b/config/app.yml index 5adfc1486..1c93a1287 100644 --- a/config/app.yml +++ b/config/app.yml @@ -1,5 +1,5 @@ shared: &shared - version: '4.13.0' + version: '4.14.0' development: <<: *shared diff --git a/package.json b/package.json index 1fbef2b1f..c6d2d95d2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chatwoot/chatwoot", - "version": "4.13.0", + "version": "4.14.0", "license": "MIT", "scripts": { "eslint": "eslint app/**/*.{js,vue}", From 64585faff00d40c178bbb0a07bcb91ab5fd1a7c2 Mon Sep 17 00:00:00 2001 From: Pranav Date: Mon, 18 May 2026 12:30:08 -0700 Subject: [PATCH 24/31] feat: Add a documentation layout design for public help center portal (#14403) https://github.com/user-attachments/assets/fc4d15f9-2b54-4627-940f-94772ec739b1 --------- Co-authored-by: Muhsin Keloth Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> --- .../api/v1/portals/articles_controller.rb | 14 +- .../public/api/v1/portals/base_controller.rb | 12 +- .../api/v1/portals/categories_controller.rb | 13 +- .../public/api/v1/portals_controller.rb | 27 +++ app/helpers/portal_helper.rb | 11 ++ app/javascript/portal/application.scss | 17 +- .../portal/components/PublicArticleSearch.vue | 36 ++++ .../portal/components/PublicSearchInput.vue | 59 ++++++- .../portal/components/SearchSuggestions.vue | 51 +++--- .../portal/components/SidebarThemeToggle.vue | 136 ++++++++++++++++ .../portal/components/TableOfContents.vue | 6 +- app/javascript/portal/portalHelpers.js | 45 ++++- .../portal/specs/SearchSuggestions.spec.js | 1 + app/models/portal.rb | 6 +- app/views/layouts/_portal_head.html.erb | 41 +++++ app/views/layouts/_portal_scripts.html.erb | 92 +++++++++++ .../layouts/portal.html+documentation.erb | 26 +++ app/views/layouts/portal.html.erb | 154 +----------------- .../v1/models/_search_article.json.jbuilder | 2 +- .../public/api/v1/portals/_authors.html.erb | 7 +- .../public/api/v1/portals/_hero.html.erb | 2 +- .../v1/portals/_uncategorized-block.html.erb | 4 +- .../articles/show.html+documentation.erb | 44 +++++ .../categories/show.html+documentation.erb | 25 +++ .../_article_card.html.erb | 12 ++ .../_avatar_group.html.erb | 10 ++ .../documentation_layout/_breadcrumb.html.erb | 31 ++++ .../_category_card.html.erb | 24 +++ .../_empty_state.html.erb | 3 + .../documentation_layout/_footer.html.erb | 50 ++++++ .../documentation_layout/_hero.html.erb | 32 ++++ .../_section_header.html.erb | 6 + .../documentation_layout/_sidebar.html.erb | 89 ++++++++++ .../documentation_layout/_topbar.html.erb | 72 ++++++++ .../_user_avatar.html.erb | 18 ++ .../articles/_actions.html.erb | 42 +++++ .../articles/_header.html.erb | 32 ++++ .../articles/_meta_head.html.erb | 17 ++ .../categories/_header.html.erb | 33 ++++ .../categories/_meta_head.html.erb | 11 ++ .../v1/portals/show.html+documentation.erb | 34 ++++ config/locales/ar.yml | 22 +++ config/locales/en.yml | 23 +++ config/locales/fr.yml | 22 +++ config/locales/pt_BR.yml | 22 +++ config/routes.rb | 8 +- .../v1/portals/articles_controller_spec.rb | 32 ++++ .../v1/portals/categories_controller_spec.rb | 5 +- tailwind.config.js | 5 +- theme/colors.js | 3 + 50 files changed, 1274 insertions(+), 215 deletions(-) create mode 100644 app/javascript/portal/components/SidebarThemeToggle.vue create mode 100644 app/views/layouts/_portal_head.html.erb create mode 100644 app/views/layouts/_portal_scripts.html.erb create mode 100644 app/views/layouts/portal.html+documentation.erb create mode 100644 app/views/public/api/v1/portals/articles/show.html+documentation.erb create mode 100644 app/views/public/api/v1/portals/categories/show.html+documentation.erb create mode 100644 app/views/public/api/v1/portals/documentation_layout/_article_card.html.erb create mode 100644 app/views/public/api/v1/portals/documentation_layout/_avatar_group.html.erb create mode 100644 app/views/public/api/v1/portals/documentation_layout/_breadcrumb.html.erb create mode 100644 app/views/public/api/v1/portals/documentation_layout/_category_card.html.erb create mode 100644 app/views/public/api/v1/portals/documentation_layout/_empty_state.html.erb create mode 100644 app/views/public/api/v1/portals/documentation_layout/_footer.html.erb create mode 100644 app/views/public/api/v1/portals/documentation_layout/_hero.html.erb create mode 100644 app/views/public/api/v1/portals/documentation_layout/_section_header.html.erb create mode 100644 app/views/public/api/v1/portals/documentation_layout/_sidebar.html.erb create mode 100644 app/views/public/api/v1/portals/documentation_layout/_topbar.html.erb create mode 100644 app/views/public/api/v1/portals/documentation_layout/_user_avatar.html.erb create mode 100644 app/views/public/api/v1/portals/documentation_layout/articles/_actions.html.erb create mode 100644 app/views/public/api/v1/portals/documentation_layout/articles/_header.html.erb create mode 100644 app/views/public/api/v1/portals/documentation_layout/articles/_meta_head.html.erb create mode 100644 app/views/public/api/v1/portals/documentation_layout/categories/_header.html.erb create mode 100644 app/views/public/api/v1/portals/documentation_layout/categories/_meta_head.html.erb create mode 100644 app/views/public/api/v1/portals/show.html+documentation.erb diff --git a/app/controllers/public/api/v1/portals/articles_controller.rb b/app/controllers/public/api/v1/portals/articles_controller.rb index 2bbfafcc7..6a83d08fb 100644 --- a/app/controllers/public/api/v1/portals/articles_controller.rb +++ b/app/controllers/public/api/v1/portals/articles_controller.rb @@ -1,9 +1,11 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController - before_action :ensure_custom_domain_request, only: [:show, :index] + before_action :ensure_custom_domain_request, only: [:show, :index, :show_markdown] before_action :portal + before_action :set_portal_layout + before_action :set_view_variant before_action :ensure_portal_feature_enabled before_action :set_category, except: [:index, :show, :tracking_pixel] - before_action :set_article, only: [:show] + before_action :set_article, only: [:show, :show_markdown] layout 'portal' def index @@ -21,6 +23,13 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B def show @og_image_url = helpers.set_og_image_url(@portal.name, @article.title) + @parsed_content = render_article_content(@article.content.to_s) + end + + def show_markdown + return head :not_found unless @article&.published? + + render plain: @article.content.to_s, content_type: 'text/markdown; charset=utf-8' end def tracking_pixel @@ -62,7 +71,6 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B def set_article @article = @portal.articles.find_by(slug: permitted_params[:article_slug]) - @parsed_content = render_article_content(@article.content.to_s) end def set_category diff --git a/app/controllers/public/api/v1/portals/base_controller.rb b/app/controllers/public/api/v1/portals/base_controller.rb index 46158bce9..2991b84d2 100644 --- a/app/controllers/public/api/v1/portals/base_controller.rb +++ b/app/controllers/public/api/v1/portals/base_controller.rb @@ -7,6 +7,8 @@ class Public::Api::V1::Portals::BaseController < PublicController around_action :set_locale after_action :allow_iframe_requests + PORTAL_LAYOUTS = %w[classic documentation].freeze + private def show_plain_layout @@ -17,6 +19,14 @@ class Public::Api::V1::Portals::BaseController < PublicController @theme_from_params = params[:theme] if %w[dark light].include?(params[:theme]) end + def set_portal_layout + @portal_layout = PORTAL_LAYOUTS.include?(@portal&.layout) ? @portal.layout : 'classic' + end + + def set_view_variant + request.variant = :documentation if @portal_layout == 'documentation' && !@is_plain_layout_enabled + end + def portal @portal ||= Portal.find_by!(slug: params[:slug], archived: false) end @@ -42,7 +52,7 @@ class Public::Api::V1::Portals::BaseController < PublicController article_locale = if article.category.present? article.category.locale else - article.portal.default_locale + article.locale end @locale = validate_and_get_locale(article_locale) I18n.with_locale(@locale, &) diff --git a/app/controllers/public/api/v1/portals/categories_controller.rb b/app/controllers/public/api/v1/portals/categories_controller.rb index 3fb200269..d1ebe92c6 100644 --- a/app/controllers/public/api/v1/portals/categories_controller.rb +++ b/app/controllers/public/api/v1/portals/categories_controller.rb @@ -1,12 +1,18 @@ class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController before_action :ensure_custom_domain_request, only: [:show, :index] before_action :portal + before_action :set_portal_layout + before_action :set_view_variant before_action :ensure_portal_feature_enabled before_action :set_category, only: [:show] + before_action :load_category_articles, only: [:show], if: -> { @portal_layout == 'documentation' } layout 'portal' def index - @categories = @portal.categories.order(position: :asc) + respond_to do |format| + format.html { redirect_to public_portal_locale_path(@portal.slug, params[:locale]), status: :moved_permanently } + format.json { @categories = @portal.categories.order(position: :asc) } + end end def show @@ -21,4 +27,9 @@ class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals: Rails.logger.info "Category: not found for slug: #{params[:category_slug]}" render_404 && return if @category.blank? end + + def load_category_articles + @articles = @category.articles.published.order(:position).includes(:author) + @category_authors = @articles.filter_map(&:author).uniq + end end diff --git a/app/controllers/public/api/v1/portals_controller.rb b/app/controllers/public/api/v1/portals_controller.rb index a187ca8a8..63f44b052 100644 --- a/app/controllers/public/api/v1/portals_controller.rb +++ b/app/controllers/public/api/v1/portals_controller.rb @@ -2,7 +2,10 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl before_action :ensure_custom_domain_request, only: [:show] before_action :redirect_to_portal_with_locale, only: [:show] before_action :portal + before_action :set_portal_layout + before_action :set_view_variant before_action :ensure_portal_feature_enabled + before_action :load_home_data, only: [:show], if: -> { @portal_layout == 'documentation' } layout 'portal' def show @@ -28,4 +31,28 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl portal redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}" end + + def load_home_data + base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category) + @visible_categories = @portal.categories + .where(locale: @locale) + .joins(:articles).where(articles: { status: :published }) + .order(position: :asc) + .group('categories.id') + @popular_topics = @visible_categories.first(3) + @featured = base_articles.order_by_views.limit(6) + @category_contributors = build_category_contributors(@visible_categories) + end + + def build_category_contributors(categories) + category_ids = categories.map(&:id) + return {} if category_ids.empty? + + @portal.articles + .published + .where(locale: @locale, category_id: category_ids) + .includes(:author) + .group_by(&:category_id) + .transform_values { |articles| articles.filter_map(&:author).uniq.first(3) } + end end diff --git a/app/helpers/portal_helper.rb b/app/helpers/portal_helper.rb index 15de0fbd7..65166145c 100644 --- a/app/helpers/portal_helper.rb +++ b/app/helpers/portal_helper.rb @@ -96,4 +96,15 @@ module PortalHelper colors[username.length % colors.size] end + + def format_authors_label(authors) + return if authors.blank? + + names = authors.map(&:available_name) + return names.to_sentence if names.size <= 3 + + I18n.t('public_portal.sidebar.authors_others', + names: names.first(2).join(', '), + count: authors.size - 2) + end end diff --git a/app/javascript/portal/application.scss b/app/javascript/portal/application.scss index 4aaf36191..6d83f701a 100644 --- a/app/javascript/portal/application.scss +++ b/app/javascript/portal/application.scss @@ -5,9 +5,18 @@ @import 'widget/assets/scss/reset'; @import 'shared/assets/fonts/InterDisplay/inter-display'; +@import 'shared/assets/fonts/inter'; +@import 'dashboard/assets/scss/next-colors'; html, body { + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; + height: 100%; + letter-spacing: 0.2px; +} + +.font-default { font-family: 'InterDisplay', -apple-system, @@ -23,10 +32,6 @@ body { 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji'; - -moz-osx-font-smoothing: grayscale; - -webkit-font-smoothing: antialiased; - height: 100%; - letter-spacing: 0.2px; } // Taking these utils from tailwind 3.x.x, need to remove once we upgrade @@ -49,3 +54,7 @@ body { } } } + +.turbolinks-progress-bar { + background-color: var(--dynamic-portal-color); +} diff --git a/app/javascript/portal/components/PublicArticleSearch.vue b/app/javascript/portal/components/PublicArticleSearch.vue index e27f56d43..086073fed 100644 --- a/app/javascript/portal/components/PublicArticleSearch.vue +++ b/app/javascript/portal/components/PublicArticleSearch.vue @@ -9,6 +9,17 @@ export default { PublicSearchInput, SearchSuggestions, }, + props: { + size: { + type: String, + default: 'default', + validator: value => ['small', 'default'].includes(value), + }, + showKbd: { + type: Boolean, + default: false, + }, + }, emits: ['input', 'blur'], data() { return { @@ -36,6 +47,13 @@ export default { const { searchTranslations = {} } = window.portalConfig; return searchTranslations; }, + kbdLabel() { + if (!this.showKbd) return ''; + const isMac = /Mac|iPhone|iPad|iPod/i.test( + navigator.platform || navigator.userAgent + ); + return isMac ? '⌘ K' : 'Ctrl K'; + }, }, watch: { @@ -44,7 +62,12 @@ export default { }, }, + mounted() { + if (this.showKbd) document.addEventListener('keydown', this.onKeydown); + }, + unmounted() { + if (this.showKbd) document.removeEventListener('keydown', this.onKeydown); clearTimeout(this.typingTimer); }, @@ -83,6 +106,16 @@ export default { clearSearchTerm() { this.searchTerm = ''; }, + onKeydown(e) { + if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) { + e.preventDefault(); + if (this.$refs.searchInput) this.$refs.searchInput.focusInput(); + } + if (e.key === 'Escape') { + this.closeSearch(); + if (this.$refs.searchInput) this.$refs.searchInput.blurInput(); + } + }, async fetchArticlesByQuery() { const query = this.normalizedSearchTerm; if (!query) { @@ -112,8 +145,11 @@ export default {