feat(ui): ReplyPier 品牌、中文界面与前端构建产物

替换品牌文案与图标,补齐窄屏布局和加载失败提示,确认主要旅程可操作。
This commit is contained in:
WangShen007 committed 2026-10-15 20:25:00 +08:00
1 parent 2adf288cd2
commit ba91abc4b2
77 files changed
+2916

No files matched your search

@@ -0,0 +1,143 @@
{#
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
A single help-center article: breadcrumb trail (which must stay in step with the
BreadcrumbList JSON-LD), rich Markdown body, "Was this helpful?"
feedback, and related articles. `article.body_html` is the ONLY value rendered
`| safe` — it is pre-sanitized to an HTML allowlist by help_center_content;
every other value stays autoescaped.
#}{% extends "help_center/base.html" %}
{% block body %}
<div class="hc-body">
<!-- ---- SIDEBAR ---- -->
<aside class="hc-aside">
{% if topics %}
<div class="hc-aside-label">BROWSE TOPICS</div>
<div class="hc-topics">
{% for topic in topics %}
<a class="hc-topic{% if topic.active %} active{% endif %}" href="{{ base_path }}/?topic={{ topic.category | urlencode }}" style="--ic: {{ topic.color }};">
<span class="hc-topic-icon">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z"></path></svg>
</span>
<span class="hc-topic-name">{{ topic.category }}</span>
<span class="hc-topic-count">{{ topic.count }}</span>
</a>
{% endfor %}
</div>
{% endif %}
{% if widget_id %}
<div class="hc-help-card">
<h3>Can't find it?</h3>
<p>Ask our assistant — it answers instantly and can bring in a human.</p>
<button class="hc-cta-btn" type="button" data-chat style="padding: 9px 14px; font-size: 13px; border-radius: 10px; background: var(--brand); color: var(--brand-ink);">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.5 8.5 0 0 1-12.3 7.6L3 21l1.9-5.7A8.5 8.5 0 1 1 21 11.5z"></path></svg>
Start a chat
</button>
</div>
{% endif %}
</aside>
<!-- ---- ARTICLE ---- -->
<main class="hc-main">
<article class="hc-article" style="--ic: {{ article.color }};">
<nav class="hc-crumbs" aria-label="Breadcrumb">
{%- for crumb in breadcrumbs %}
{%- if not loop.first %}<span class="hc-crumb-sep" aria-hidden="true">›</span>{% endif %}
{%- if crumb.href %}
<a href="{{ crumb.href }}">{{ crumb.label }}</a>
{%- else %}
<span class="hc-crumb-current" aria-current="page" title="{{ crumb.label }}">{{ crumb.label }}</span>
{%- endif %}
{%- endfor %}
</nav>
<div class="hc-article-meta">
<span class="hc-article-tag">{{ article.category }}</span>
<span class="hc-article-time">{{ article.read_time }}</span>
</div>
<h1>{{ article.question }}</h1>
<div class="hc-article-card">
<div class="hc-article-body">{{ article.body_html | safe }}</div>
{% if article.feedback_url %}
<div class="hc-feedback" data-feedback data-feedback-url="{{ article.feedback_url }}">
<span class="hc-feedback-q">Was this helpful?</span>
<button class="hc-vote" type="button" data-vote="yes">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 11v9H4a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1z"></path><path d="M7 11l4-8a2 2 0 0 1 3 1.8V9h4.5a2 2 0 0 1 2 2.4l-1.4 7A2 2 0 0 1 17 20H7"></path></svg>
Yes
</button>
<button class="hc-vote" type="button" data-vote="no">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 13V4h3a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1z"></path><path d="M17 13l-4 8a2 2 0 0 1-3-1.8V15H5.5a2 2 0 0 1-2-2.4l1.4-7A2 2 0 0 1 7 4h10"></path></svg>
No
</button>
<span class="hc-thanks" hidden>Thanks for the feedback!</span>
</div>
{% endif %}
</div>
{% if article.related %}
<div class="hc-related">
<div class="hc-related-label">RELATED ARTICLES</div>
<div class="hc-related-grid">
{% for r in article.related %}
<a class="hc-related-card" href="{{ base_path }}{{ r.href }}" style="--ic: {{ r.color }};">
<span class="hc-related-top">
<span class="hc-related-icon">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z"></path></svg>
</span>
<span style="color:#c4c8d2; display:flex;"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17L17 7M17 7H8M17 7v9"></path></svg></span>
</span>
<span class="hc-related-q">{{ r.question }}</span>
<span class="hc-related-time">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3v5h5"></path><path d="M6 3h9l5 5v13H6z"></path></svg>
{{ r.read_time }}
</span>
</a>
{% endfor %}
</div>
</div>
{% endif %}
</article>
</main>
</div>
{% endblock %}
{% block page_scripts %}
<script>
(function () {
// per-article "Was this helpful?" — records the vote, then acknowledges.
var fb = document.querySelector('[data-feedback]');
if (!fb) return;
var voted = false;
fb.addEventListener('click', function (e) {
var btn = e.target.closest('[data-vote]');
if (!btn || voted) return;
voted = true;
var helpful = btn.getAttribute('data-vote') === 'yes';
fb.querySelectorAll('.hc-vote').forEach(function (v) { v.classList.remove('on-yes', 'on-no'); });
btn.classList.add(helpful ? 'on-yes' : 'on-no');
var thanks = fb.querySelector('.hc-thanks');
if (thanks) thanks.hidden = false;
// Explicit URL, not location.pathname: an article that kept its original
// URL through a migration is served at a path with no /feedback route.
fetch(fb.getAttribute('data-feedback-url'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ helpful: helpful })
}).catch(function () {}); // best-effort; the UI already acknowledged
});
})();
</script>
{% endblock %}
+420
View File
@@ -0,0 +1,420 @@
{#
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Shared chrome for the public help center (list page + article page): head/meta,
all CSS, header, contact CTA, footer, and the chat-widget embed. Pages fill the
`hero`, `body` and `page_scripts` blocks.
Every org/FAQ value is escaped by Jinja2 autoescaping. The ONE exception is the
article body, which is pre-sanitized to an HTML allowlist by
app.services.help_center_content and rendered with `| safe`; never mark any other
org content safe. Colors: only `--brand`/`--brand-ink` come from the server
(validated hex); every other tone is derived in CSS via color-mix.
#}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }}</title>
<meta name="description" content="{{ description }}">
<link rel="canonical" href="{{ canonical_url }}">
{% if favicon_url %}<link rel="icon" href="{{ favicon_url }}">{% endif %}
<meta property="og:title" content="{{ title }}">
<meta property="og:description" content="{{ description }}">
<meta property="og:type" content="{{ og_type | default('website') }}">
<meta property="og:url" content="{{ canonical_url }}">
{% if og_site_name %}<meta property="og:site_name" content="{{ og_site_name }}">{% endif %}
{# og:image must be absolute (scrapers don't resolve relative URLs) — the
route absolutizes it. Without an image the card degrades to a text
summary rather than showing a broken thumbnail. #}
{% if og_image %}
<meta property="og:image" content="{{ og_image }}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="{{ og_image }}">
{% else %}
<meta name="twitter:card" content="summary">
{% endif %}
<meta name="twitter:title" content="{{ title }}">
<meta name="twitter:description" content="{{ description }}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700&family=Instrument+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--brand: {{ brand_color }};
--brand-ink: {{ brand_ink }};
--brand-tint: color-mix(in srgb, var(--brand) 9%, #fff);
--brand-soft: color-mix(in srgb, var(--brand) 30%, #fff);
--brand-line: color-mix(in srgb, var(--brand) 40%, #fff);
--hero-bg: linear-gradient(180deg, color-mix(in srgb, var(--brand) 10%, #fff), #f5f6f9 80%);
--hero-glow: radial-gradient(ellipse, color-mix(in srgb, var(--brand) 12%, transparent), transparent 68%);
--cta-bg: linear-gradient(120deg, var(--brand), color-mix(in srgb, var(--brand) 78%, #fff));
--cta-sub: color-mix(in srgb, var(--brand-ink) 72%, transparent);
--sans: 'Instrument Sans', system-ui, -apple-system, sans-serif;
--display: 'Sora', var(--sans);
--mono: 'JetBrains Mono', ui-monospace, monospace;
}
* { box-sizing: border-box; }
/* Author display rules (e.g. .hc-card { display:flex }) otherwise beat the UA
[hidden] rule, so the hidden attribute must win explicitly for JS toggling. */
[hidden] { display: none !important; }
html, body { margin: 0; padding: 0; background: #f5f6f9; }
body { color: #1a1b25; font-family: var(--sans); -webkit-font-smoothing: antialiased; min-height: 100vh; }
a { color: var(--brand); }
::selection { background: var(--brand-soft); color: #1a1b25; }
@keyframes hc-rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
@keyframes hc-blink { 0%, 100% { opacity: .3; } 50% { opacity: 1; } }
@keyframes hc-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
/* ---------- header ---------- */
.hc-header { background: #fff; border-bottom: 1px solid #ececf2; position: sticky; top: 0; z-index: 40; }
.hc-header-in { max-width: 1120px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 15px 28px; }
.hc-brand { display: flex; align-items: center; gap: 11px; text-decoration: none; color: inherit; min-width: 0; }
.hc-mark { width: 30px; height: 30px; border-radius: 9px; background: var(--brand); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.hc-mark span { width: 12px; height: 12px; border-radius: 3px; background: var(--brand-ink); transform: rotate(45deg); }
.hc-logo { height: 30px; width: auto; display: block; flex-shrink: 0; }
.hc-name { font-family: var(--display); font-weight: 700; font-size: 19px; letter-spacing: -.02em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.hc-kicker { font-size: 14px; color: #8a90a0; font-weight: 500; padding-left: 12px; border-left: 1px solid #e4e6ec; white-space: nowrap; }
.hc-nav { display: flex; align-items: center; gap: 20px; font-size: 14.5px; flex-shrink: 0; }
.hc-nav a { color: #5a6172; text-decoration: none; }
.hc-nav a:hover { color: #1a1b25; }
.hc-nav a.cta { padding: 9px 17px; background: var(--brand); color: var(--brand-ink); border-radius: 10px; font-weight: 600; }
/* ---------- hero ---------- */
.hc-hero { position: relative; overflow: hidden; background: var(--hero-bg); padding: 58px 28px 46px; }
.hc-hero-glow { position: absolute; top: -90px; left: 50%; transform: translateX(-50%); width: 640px; height: 340px; max-width: 100%; background: var(--hero-glow); pointer-events: none; }
.hc-hero-in { position: relative; max-width: 720px; margin: 0 auto; text-align: center; }
.hc-hero h1 { font-family: var(--display); font-weight: 700; font-size: 38px; line-height: 1.1; letter-spacing: -.03em; margin: 0 0 12px; }
.hc-hero p.lead { font-size: 16.5px; color: #5a6172; margin: 0 0 26px; }
.hc-searchbar { display: flex; align-items: center; gap: 12px; background: #fff; border: 1px solid #e4e6ec; border-radius: 15px; padding: 6px 6px 6px 18px; box-shadow: 0 12px 34px rgba(40, 36, 90, .08); max-width: 600px; margin: 0 auto; }
.hc-searchbar svg { flex-shrink: 0; }
.hc-searchbar input { flex: 1; min-width: 0; background: transparent; border: none; outline: none; font-size: 16px; color: #1a1b25; font-family: var(--sans); padding: 12px 0; }
.hc-searchbar input::placeholder { color: #9aa0ad; }
.hc-clear { flex-shrink: 0; width: 34px; height: 34px; border-radius: 9px; background: #f1f2f6; border: none; color: #8a90a0; cursor: pointer; font-size: 14px; display: none; }
.hc-popular { display: flex; align-items: center; justify-content: center; gap: 9px; margin-top: 18px; font-size: 13.5px; color: #8a90a0; flex-wrap: wrap; }
.hc-chip { padding: 5px 12px; background: #fff; border: 1px solid #e4e6ec; border-radius: 999px; font-size: 13px; color: #4a5163; cursor: pointer; font-family: var(--sans); text-decoration: none; }
.hc-chip:hover { border-color: var(--brand-soft); }
/* ---------- body layout ---------- */
.hc-body { max-width: 1120px; margin: 0 auto; padding: 40px 28px 20px; display: grid; grid-template-columns: 250px 1fr; gap: 36px; align-items: start; }
.hc-aside { position: sticky; top: 82px; }
.hc-aside-label { font-family: var(--mono); font-size: 11px; letter-spacing: .09em; color: #9aa0ad; padding: 0 6px; margin-bottom: 12px; }
.hc-topics { display: flex; flex-direction: column; gap: 4px; }
.hc-topic { display: flex; align-items: center; gap: 11px; width: 100%; padding: 9px 10px; border-radius: 12px; cursor: pointer; font-family: var(--sans); border: 1px solid transparent; background: transparent; color: #4a5163; transition: background .15s; text-decoration: none; text-align: left; }
.hc-topic:hover { background: #fff; }
.hc-topic.active { border-color: var(--brand-soft); background: var(--brand-tint); color: #1a1b25; }
.hc-topic-icon { width: 30px; height: 30px; flex-shrink: 0; border-radius: 9px; display: flex; align-items: center; justify-content: center; background: color-mix(in srgb, var(--ic) 12%, #fff); color: var(--ic); }
.hc-topic-name { flex: 1; min-width: 0; text-align: left; font-size: 14.5px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.hc-topic.active .hc-topic-name { font-weight: 600; }
.hc-topic-count { flex-shrink: 0; font-family: var(--mono); font-size: 12px; color: #b4b9c4; }
.hc-topic.active .hc-topic-count { color: var(--brand); }
.hc-help-card { margin-top: 22px; padding: 18px; background: #fff; border: 1px solid #ececf2; border-radius: 16px; box-shadow: 0 2px 10px rgba(40, 36, 90, .04); }
.hc-help-card h3 { font-family: var(--display); font-weight: 600; font-size: 14.5px; margin: 0 0 5px; }
.hc-help-card p { font-size: 13px; color: #8a90a0; line-height: 1.5; margin: 0 0 13px; }
.hc-main { min-width: 0; }
/* ---------- AI answer ---------- */
.hc-ai-wrap { margin-bottom: 24px; }
.hc-ask-btn { display: inline-flex; align-items: center; gap: 8px; padding: 11px 18px; background: #fff; border: 1px solid var(--brand-soft); color: var(--brand); border-radius: 12px; font-size: 14px; font-weight: 600; cursor: pointer; font-family: var(--sans); }
.hc-ask-btn:hover { background: var(--brand-tint); }
.hc-ai { background: #fff; border: 1px solid var(--brand-soft); border-radius: 18px; padding: 22px 24px; box-shadow: 0 12px 34px rgba(40, 36, 90, .06); animation: hc-rise .3s ease; }
.hc-ai-head { display: flex; align-items: center; gap: 10px; margin-bottom: 13px; flex-wrap: wrap; }
.hc-badge { display: inline-flex; align-items: center; gap: 7px; padding: 5px 11px; background: var(--brand-tint); border: 1px solid var(--brand-soft); border-radius: 999px; font-family: var(--mono); font-size: 11px; font-weight: 500; color: var(--brand); letter-spacing: .04em; }
.hc-dots { display: inline-flex; gap: 3px; }
.hc-dots span { width: 4px; height: 4px; border-radius: 50%; background: var(--brand); animation: hc-blink 1s ease-in-out infinite; }
.hc-dots span:nth-child(2) { animation-delay: .18s; }
.hc-dots span:nth-child(3) { animation-delay: .36s; }
.hc-ai-note { font-size: 12px; color: #9aa0ad; }
.hc-ai-answer { font-size: 16px; line-height: 1.65; color: #2a303e; margin: 0; white-space: pre-line; }
.hc-ai-foot { display: flex; align-items: center; justify-content: flex-end; gap: 16px; flex-wrap: wrap; padding-top: 14px; margin-top: 14px; border-top: 1px solid #f1f2f6; }
.hc-skeleton { display: flex; flex-direction: column; gap: 9px; }
.hc-skeleton span { height: 12px; border-radius: 6px; background: linear-gradient(90deg, #eef0f4, #f6f7f9, #eef0f4); background-size: 200% 100%; animation: hc-shimmer 1.3s linear infinite; }
/* ---------- content header ---------- */
.hc-head { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; }
.hc-head-icon { width: 38px; height: 38px; flex-shrink: 0; border-radius: 11px; display: flex; align-items: center; justify-content: center; background: color-mix(in srgb, var(--ic) 12%, #fff); color: var(--ic); }
.hc-head h2 { font-family: var(--display); font-weight: 600; font-size: 22px; letter-spacing: -.02em; margin: 0; }
.hc-head-sub { font-size: 13.5px; color: #8a90a0; margin-top: 2px; }
/* ---------- FAQ list (link cards) ---------- */
.hc-list { display: flex; flex-direction: column; gap: 10px; }
.hc-card { display: flex; align-items: center; justify-content: space-between; gap: 16px; background: #fff; border: 1px solid #ececf2; border-radius: 14px; padding: 16px 20px; text-decoration: none; box-shadow: 0 1px 5px rgba(40, 36, 90, .03); transition: border-color .18s, transform .18s; }
.hc-card:hover { border-color: var(--brand-soft); transform: translateY(-1px); }
.hc-card-main { min-width: 0; flex: 1; }
.hc-tag { display: none; font-family: var(--mono); font-size: 10.5px; padding: 3px 8px; border-radius: 6px; background: color-mix(in srgb, var(--ic) 12%, #fff); color: var(--ic); margin-bottom: 8px; }
.hc-qtext { display: block; font-size: 15.5px; font-weight: 600; color: #1a1b25; line-height: 1.4; }
.hc-preview { display: block; font-size: 13.5px; color: #8a90a0; line-height: 1.5; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hc-card-meta { flex-shrink: 0; display: flex; align-items: center; gap: 12px; }
.hc-read { font-size: 12px; color: #b4b9c4; white-space: nowrap; }
.hc-chev { color: #8a90a0; display: flex; }
/* ---------- grouped browse: every category with its own articles ---------- */
.hc-groups { display: flex; flex-direction: column; gap: 30px; }
.hc-group { scroll-margin-top: 90px; }
.hc-group-head { margin-bottom: 14px; }
.hc-group-head h2 { font-size: 19px; }
.hc-empty { text-align: center; padding: 56px 20px; background: #fff; border: 1px solid #ececf2; border-radius: 18px; }
.hc-empty-icon { width: 52px; height: 52px; margin: 0 auto 18px; border-radius: 14px; background: #f5f6f9; border: 1px solid #ececf2; display: flex; align-items: center; justify-content: center; color: #9aa0ad; }
.hc-empty h3 { font-family: var(--display); font-weight: 600; font-size: 19px; margin: 0 0 8px; }
.hc-empty p { font-size: 15px; color: #8a90a0; margin: 0 0 20px; }
/* ---------- article page ---------- */
/* Breadcrumb trail. The markup mirrors the BreadcrumbList JSON-LD exactly —
change one and change the other, or the structured data stops matching
the visible content. The current page is text, not a link. */
.hc-crumbs { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin-bottom: 20px; font-family: var(--sans); font-size: 13.5px; color: #8a90a0; }
.hc-crumbs a { color: #8a90a0; text-decoration: none; }
.hc-crumbs a:hover { color: #4a5163; text-decoration: underline; }
.hc-crumb-sep { color: #c4c8d2; }
/* The article title can be long; keep the trail to one tidy line. */
.hc-crumb-current { color: #4a5163; max-width: min(100%, 340px); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hc-article-meta { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
.hc-article-tag { font-family: var(--mono); font-size: 11px; padding: 4px 10px; border-radius: 7px; background: color-mix(in srgb, var(--ic) 12%, #fff); color: var(--ic); }
.hc-article-time { font-size: 13px; color: #9aa0ad; }
.hc-article h1 { font-family: var(--display); font-weight: 700; font-size: 30px; line-height: 1.2; letter-spacing: -.025em; margin: 0 0 22px; }
.hc-article-card { background: #fff; border: 1px solid #ececf2; border-radius: 18px; padding: 30px 32px; box-shadow: 0 2px 12px rgba(40, 36, 90, .04); }
/* rendered Markdown body */
.hc-article-body { font-size: 15.5px; color: #3a4152; line-height: 1.72; }
/* A short, single-paragraph answer reads larger so it doesn't look lost in
the card's whitespace. */
.hc-article-body > p:only-child { font-size: 18px; line-height: 1.6; color: #2a3040; }
.hc-article-body > :first-child { margin-top: 0; }
.hc-article-body > :last-child { margin-bottom: 0; }
.hc-article-body p { margin: 0 0 16px; }
.hc-article-body h1, .hc-article-body h2, .hc-article-body h3, .hc-article-body h4 { font-family: var(--display); font-weight: 600; letter-spacing: -.015em; color: #1a1b25; margin: 28px 0 12px; line-height: 1.3; }
.hc-article-body h1 { font-size: 22px; }
.hc-article-body h2 { font-size: 19px; }
.hc-article-body h3 { font-size: 16.5px; }
.hc-article-body h4 { font-size: 15px; }
.hc-article-body a { color: var(--brand); font-weight: 600; text-decoration: none; }
.hc-article-body a:hover { text-decoration: underline; }
.hc-article-body img { max-width: 100%; height: auto; display: block; border-radius: 14px; border: 1px solid #ececf2; margin: 8px 0 22px; }
.hc-article-body ul, .hc-article-body ol { margin: 6px 0 22px; padding-left: 24px; }
.hc-article-body li { margin-bottom: 8px; }
.hc-article-body blockquote { margin: 6px 0 22px; padding: 14px 18px; background: var(--brand-tint); border: 1px solid var(--brand-soft); border-left: 3px solid var(--brand); border-radius: 12px; color: #3a4152; }
.hc-article-body blockquote p { margin: 0; }
.hc-article-body code { font-family: var(--mono); font-size: 13px; background: #f1f2f6; border-radius: 5px; padding: 1px 6px; }
.hc-article-body pre { background: #f1f2f6; border-radius: 12px; padding: 14px 16px; overflow-x: auto; margin: 6px 0 22px; }
.hc-article-body pre code { background: none; padding: 0; }
.hc-article-body table { border-collapse: collapse; width: 100%; margin: 6px 0 22px; font-size: 14px; }
.hc-article-body th, .hc-article-body td { border: 1px solid #ececf2; padding: 8px 11px; text-align: left; }
.hc-article-body th { background: #f5f6f9; font-weight: 600; }
.hc-article-body hr { border: none; border-top: 1px solid #ececf2; margin: 24px 0; }
.hc-feedback { display: flex; align-items: center; gap: 14px; margin-top: 10px; padding-top: 20px; border-top: 1px solid #f1f2f6; flex-wrap: wrap; }
.hc-feedback-q { font-size: 13.5px; color: #8a90a0; }
.hc-vote { display: inline-flex; align-items: center; gap: 6px; padding: 7px 13px; border-radius: 9px; font-size: 13px; cursor: pointer; font-family: var(--sans); background: #f5f6f9; border: 1px solid #e4e6ec; color: #4a5163; }
.hc-vote.on-yes { background: var(--brand-tint); border-color: var(--brand-soft); color: var(--brand); }
.hc-vote.on-no { background: #fdecea; border-color: #f3c6be; color: #cf5b38; }
.hc-thanks { font-size: 12.5px; color: var(--brand); font-weight: 600; }
/* related articles */
.hc-related { margin-top: 34px; }
.hc-related-label { font-family: var(--mono); font-size: 11px; letter-spacing: .09em; color: #9aa0ad; margin-bottom: 14px; }
.hc-related-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
.hc-related-card { display: flex; flex-direction: column; gap: 14px; height: 100%; padding: 18px; background: #fff; border: 1px solid #ececf2; border-radius: 15px; text-decoration: none; box-shadow: 0 1px 5px rgba(40, 36, 90, .03); transition: border-color .18s, transform .18s, box-shadow .18s; }
.hc-related-card:hover { border-color: var(--brand-soft); transform: translateY(-2px); box-shadow: 0 12px 26px rgba(40, 36, 90, .08); }
.hc-related-top { display: flex; align-items: center; justify-content: space-between; }
.hc-related-icon { width: 34px; height: 34px; border-radius: 10px; background: color-mix(in srgb, var(--ic) 12%, #fff); color: var(--ic); display: flex; align-items: center; justify-content: center; }
.hc-related-q { display: block; font-family: var(--display); font-size: 15px; font-weight: 600; color: #1a1b25; line-height: 1.35; letter-spacing: -.01em; }
.hc-related-time { display: inline-flex; align-items: center; gap: 6px; margin-top: auto; font-size: 12.5px; color: #9aa0ad; }
/* ---------- contact CTA ---------- */
.hc-cta-shell { max-width: 1120px; margin: 0 auto; padding: 0 28px; }
.hc-cta { margin: 24px 0 56px; background: var(--cta-bg); border-radius: 24px; padding: 44px; display: flex; align-items: center; justify-content: space-between; gap: 30px; flex-wrap: wrap; color: var(--brand-ink); scroll-margin-top: 90px; }
.hc-cta h2 { font-family: var(--display); font-weight: 700; font-size: 26px; letter-spacing: -.02em; margin: 0 0 8px; }
.hc-cta p { font-size: 16px; color: var(--cta-sub); margin: 0; max-width: 440px; line-height: 1.55; }
.hc-cta-btn { display: inline-flex; align-items: center; gap: 10px; padding: 15px 26px; background: var(--brand-ink); color: var(--brand); border: none; border-radius: 13px; font-size: 15.5px; font-weight: 600; cursor: pointer; font-family: var(--sans); white-space: nowrap; text-decoration: none; }
/* ---------- footer ---------- */
.hc-footer { border-top: 1px solid #ececf2; background: #fff; }
.hc-footer-in { max-width: 1120px; margin: 0 auto; padding: 30px 28px; display: flex; align-items: center; justify-content: space-between; gap: 24px; flex-wrap: wrap; }
.hc-footer-org { display: flex; align-items: center; gap: 11px; }
.hc-footer-org .hc-mark { width: 26px; height: 26px; border-radius: 8px; }
.hc-footer-org .hc-mark span { width: 10px; height: 10px; }
.hc-footer-org .hc-name { font-size: 15px; }
.hc-powered { display: inline-flex; align-items: center; gap: 8px; text-decoration: none; padding: 7px 13px; border: 1px solid #ececf2; border-radius: 999px; font-size: 12.5px; color: #8a90a0; }
.hc-powered strong { display: inline-flex; align-items: center; gap: 6px; color: #1a1b25; font-weight: 600; }
.hc-cm-mark { width: 15px; height: 15px; border-radius: 5px 5px 5px 1px; background: #c9f24e; display: inline-flex; align-items: center; justify-content: center; gap: 1.5px; }
.hc-cm-mark i { width: 2px; height: 2px; border-radius: 50%; background: #0b0c10; }
/* progressive enhancement: only hide things once JS takes over */
.js .hc-tag { display: none; }
@media (max-width: 860px) {
/* Compact hero so the topics and articles start higher up. */
.hc-hero { padding: 30px 20px 22px; }
.hc-hero h1 { font-size: 25px; margin-bottom: 6px; }
.hc-hero p.lead { font-size: 14.5px; margin-bottom: 16px; }
.hc-searchbar { padding: 5px 5px 5px 14px; }
.hc-searchbar input { padding: 11px 0; font-size: 15.5px; }
/* The 2-column topic grid below is the category picker on mobile, so the
duplicate "Popular" chips just add height. */
.hc-popular { display: none; }
.hc-body { grid-template-columns: 1fr; gap: 16px; padding: 18px 16px 16px; }
.hc-aside { position: static; min-width: 0; }
.hc-main { min-width: 0; }
.hc-aside-label { margin-bottom: 10px; }
/* Topics: a compact 3-row band that scrolls left/right. Fixed-width
auto-columns + overflow-x keep the scroll contained to this strip (the
page never scrolls sideways), so it stays short instead of a tall grid. */
.hc-topics {
display: grid;
grid-auto-flow: column;
grid-template-rows: repeat(3, auto);
grid-auto-columns: 46%;
gap: 8px;
overflow-x: auto;
overscroll-behavior-x: contain;
scroll-snap-type: x proximity;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
padding: 0 2px 4px;
margin: 0 -2px;
}
.hc-topics::-webkit-scrollbar { display: none; }
.hc-topic { width: auto; min-width: 0; padding: 10px 11px; gap: 9px; scroll-snap-align: start; }
.hc-topic-icon { width: 26px; height: 26px; }
.hc-topic-name { font-size: 13.5px; }
/* Redundant on mobile — the "Still need a hand?" CTA below covers it. */
.hc-help-card { display: none; }
/* While searching, get the whole browse-topics column out of the way so
the results (and the Ask-AI card) sit right under the search box,
instead of on a "second page" below every category. Also tighten the
hero/body padding so results sit close to the search box. */
body.hc-searching .hc-aside { display: none; }
body.hc-searching .hc-hero { padding-bottom: 8px; }
body.hc-searching .hc-body { padding-top: 8px; }
.hc-cta { padding: 30px; }
.hc-article-card { padding: 24px 20px; }
.hc-related-grid { grid-template-columns: 1fr; }
}
</style>
{% block extra_head %}{% endblock %}
</head>
<body>
<!-- ============ HEADER ============ -->
<header class="hc-header">
<div class="hc-header-in">
<a class="hc-brand" href="{{ base_path }}/">
{% if logo_url %}
<img class="hc-logo" src="{{ logo_url }}" alt="{{ row.organization.name }} logo">
{% else %}
<span class="hc-mark"><span></span></span>
{% endif %}
<span class="hc-name">{{ row.organization.name }}</span>
<span class="hc-kicker">Help Center</span>
</a>
<nav class="hc-nav">
{% for link in header_links %}<a href="{{ link.url }}" rel="noopener">{{ link.label }}</a>{% endfor %}
{% if row.cta_enabled and row.cta_text and row.cta_url %}<a class="cta" href="{{ row.cta_url }}" rel="noopener">{{ row.cta_text }}</a>{% endif %}
</nav>
</div>
</header>
{% block hero %}{% endblock %}
{% block body %}{% endblock %}
<!-- ============ CONTACT CTA ============ -->
{% set cta_shown = row.cta_enabled and row.cta_text and row.cta_url %}
{% if widget_id or cta_shown %}
<div class="hc-cta-shell">
<section class="hc-cta" id="contact">
<div>
<h2>Still need a hand?</h2>
<p>{% if widget_id %}Our support assistant answers instantly, any time of day — and connects you to a human whenever you need one.{% else %}Reach out and our team will get back to you.{% endif %}</p>
</div>
{% if widget_id %}
<button class="hc-cta-btn" type="button" data-chat>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.5 8.5 0 0 1-12.3 7.6L3 21l1.9-5.7A8.5 8.5 0 1 1 21 11.5z"></path></svg>
Start a chat
</button>
{% elif cta_shown %}
<a class="hc-cta-btn" href="{{ row.cta_url }}" rel="noopener">{{ row.cta_text }}</a>
{% endif %}
</section>
</div>
{% endif %}
<!-- ============ FOOTER ============ -->
<footer class="hc-footer">
<div class="hc-footer-in">
<div class="hc-footer-org">
{% if logo_url %}
<img class="hc-logo" src="{{ logo_url }}" alt="{{ row.organization.name }} logo">
{% else %}
<span class="hc-mark"><span></span></span>
{% endif %}
<span class="hc-name">{{ row.organization.name }}</span>
</div>
<a class="hc-powered" href="https://chattermate.chat" target="_blank" rel="noopener">
<span>Powered by</span>
<strong><span class="hc-cm-mark"><i></i><i></i><i></i></span> ChatterMate</strong>
</a>
</div>
</footer>
{% if json_ld %}
<script type="application/ld+json">{{ json_ld | tojson }}</script>
{% endif %}
<!-- open the real embedded ChatterMate widget -->
<script>
(function () {
// The widget script is async and builds its launcher (#chattermate-button)
// a moment after load, so a click can land before it exists — poll briefly
// and click as soon as it appears instead of silently doing nothing.
function openChat() {
var tries = 0;
(function attempt() {
var btn = document.getElementById('chattermate-button');
if (btn) { btn.click(); return; }
if (tries++ < 40) { setTimeout(attempt, 150); return; } // ~6s
if (window.console) console.warn('[ChatterMate] chat widget is not available on this page.');
})();
}
[].slice.call(document.querySelectorAll('[data-chat]')).forEach(function (b) {
b.addEventListener('click', openChat);
});
})();
</script>
{% block page_scripts %}{% endblock %}
{% if widget_id %}
<script>
window.chattermateId = {{ widget_id | tojson }};
// Must be set, exactly like the dashboard's install snippet does: the loader
// otherwise falls back to the API URL baked in at build time (the vendor
// cloud), so a self-hosted help center would ask the wrong backend for its
// widget and the visitor would just see "Chat Unavailable".
window.chattermateBaseUrl = {{ widget_api_url | tojson }};
(function () {
var s = document.createElement('script');
s.src = {{ widget_script_url | tojson }};
s.async = true;
document.body.appendChild(s);
})();
</script>
{% endif %}
</body>
</html>
@@ -0,0 +1,425 @@
{#
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Help-center landing: hero + search, topic sidebar, and a list of FAQ cards that
link to their article pages (/a/{slug}). Fully server-rendered for SEO; the JS is
progressive enhancement only (topic filtering, client-side search, optional
Ask-AI fetch). Card previews/read-times are computed server-side.
#}{% extends "help_center/base.html" %}
{% block hero %}
<section class="hc-hero" id="top">
<div class="hc-hero-glow"></div>
<div class="hc-hero-in">
<h1>How can we help?</h1>
<p class="lead">Search our knowledge base, or pick a topic to browse.</p>
<form class="hc-searchbar" method="get" action="{{ base_path }}/" role="search" onsubmit="return false;">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#9aa0ad" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
<input id="hc-search" type="search" name="q" value="{{ search }}" placeholder="Search for answers…" maxlength="200" autocomplete="off">
<button class="hc-clear" id="hc-clear" type="button" aria-label="Clear search">✕</button>
</form>
{% if groups %}
<div class="hc-popular">
<span>Popular:</span>
{% for category, faqs in groups[:4] %}
<button class="hc-chip" type="button" data-jump="{{ category }}">{{ category }}</button>
{% endfor %}
</div>
{% endif %}
</div>
</section>
{% endblock %}
{% block body %}
{% set first_cat = groups[0][0] if groups else '' %}
<div class="hc-body">
<!-- ---- SIDEBAR ---- -->
<aside class="hc-aside">
{% if groups %}
<div class="hc-aside-label">BROWSE TOPICS</div>
<div class="hc-topics">
{% for category, faqs in groups %}
<button class="hc-topic{% if loop.first %} active{% endif %}" type="button"
data-category="{{ category }}" data-count="{{ faqs | length }}"
data-color="{{ colors[category] }}"
style="--ic: {{ colors[category] }};">
<span class="hc-topic-icon">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z"></path></svg>
</span>
<span class="hc-topic-name">{{ category }}</span>
<span class="hc-topic-count">{{ faqs | length }}</span>
</button>
{% endfor %}
</div>
{% endif %}
{% if widget_id %}
<div class="hc-help-card">
<h3>Can't find it?</h3>
<p>Ask our assistant — it answers instantly and can bring in a human.</p>
<button class="hc-cta-btn" type="button" data-chat style="padding: 9px 14px; font-size: 13px; border-radius: 10px; background: var(--brand); color: var(--brand-ink);">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.5 8.5 0 0 1-12.3 7.6L3 21l1.9-5.7A8.5 8.5 0 1 1 21 11.5z"></path></svg>
Start a chat
</button>
</div>
{% endif %}
</aside>
<!-- ---- CONTENT ---- -->
<main class="hc-main">
{% if ask_enabled %}
<div class="hc-ai-wrap" id="hc-ai-wrap" hidden>
<button class="hc-ask-btn" id="hc-ask-btn" type="button">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 4.7L18.5 9l-4.6 1.3L12 15l-1.9-4.7L5.5 9l4.6-1.3z"></path></svg>
<span id="hc-ask-label">Ask AI for an answer</span>
</button>
<div class="hc-ai" id="hc-ai-loading" hidden>
<div class="hc-ai-head">
<span class="hc-badge"><span class="hc-dots"><span></span><span></span><span></span></span> GENERATING ANSWER</span>
<span class="hc-ai-note">Reading the knowledge base…</span>
</div>
<div class="hc-skeleton"><span style="width:96%"></span><span style="width:88%"></span><span style="width:64%"></span></div>
</div>
<div class="hc-ai" id="hc-ai-card" hidden>
<div class="hc-ai-head">
<span class="hc-badge">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 4.7L18.5 9l-4.6 1.3L12 15l-1.9-4.7L5.5 9l4.6-1.3z"></path></svg>
AI ANSWER
</span>
</div>
<p class="hc-ai-answer" id="hc-ai-answer"></p>
{% if widget_id %}
<div class="hc-ai-foot">
<button class="hc-cta-btn" type="button" data-chat style="padding: 9px 15px; font-size: 13.5px; border-radius: 10px; background: var(--brand); color: var(--brand-ink);">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.5 8.5 0 0 1-12.3 7.6L3 21l1.9-5.7A8.5 8.5 0 1 1 21 11.5z"></path></svg>
Ask a follow-up in chat
</button>
</div>
{% endif %}
</div>
</div>
{% endif %}
<!-- Search-results header (shown only while a query is active). -->
<div class="hc-head" id="hc-head" hidden>
<div class="hc-head-icon" id="hc-head-icon" style="--ic: #6d5bd0;">
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
</div>
<div style="min-width:0;">
<h2 id="hc-head-title">Search results</h2>
<div class="hc-head-sub" id="hc-head-sub"></div>
</div>
</div>
<!-- Flat, relevance-ranked results — cards move here while searching. -->
<div class="hc-list" id="hc-search-results" hidden></div>
<!-- Browse view: every category with its own articles (Option B). On
desktop all sections show and the sidebar jump-scrolls to them; on
mobile the topic grid filters to one section at a time. -->
<div class="hc-groups" id="hc-groups">
{% for category, faqs in groups %}
<section class="hc-group" id="cat-{{ loop.index0 }}" data-category="{{ category }}" style="--ic: {{ colors[category] }};">
<div class="hc-head hc-group-head">
<div class="hc-head-icon">
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z"></path></svg>
</div>
<div style="min-width:0;">
<h2>{{ category }}</h2>
<div class="hc-head-sub">{{ faqs | length }} {{ 'article' if faqs | length == 1 else 'articles' }}</div>
</div>
</div>
<div class="hc-list">
{% for faq in faqs %}
<a class="hc-card" href="{{ base_path }}{{ faq.href }}" data-category="{{ category }}"
data-title="{{ faq.search_title }}" data-search="{{ faq.search_text }}"
style="--ic: {{ colors[category] }};">
<span class="hc-card-main">
<span class="hc-tag">{{ category }}</span>
<span class="hc-qtext">{{ faq.question }}</span>
<span class="hc-preview">{{ faq.preview }}</span>
</span>
<span class="hc-card-meta">
<span class="hc-read">{{ faq.read_time }}</span>
<span class="hc-chev">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"></path></svg>
</span>
</span>
</a>
{% endfor %}
</div>
</section>
{% endfor %}
</div>
<div class="hc-empty" id="hc-empty"{% if groups %} hidden{% endif %}>
<div class="hc-empty-icon">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
</div>
<h3 id="hc-empty-title">{% if groups %}No results{% else %}No published FAQs yet{% endif %}</h3>
<p id="hc-empty-sub">{% if groups %}Try a different search{% if widget_id %}, or chat with us — our assistant can help right away{% endif %}.{% else %}Check back soon.{% endif %}</p>
{% if widget_id %}
<button class="hc-cta-btn" type="button" data-chat style="background: var(--brand); color: var(--brand-ink);">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.5 8.5 0 0 1-12.3 7.6L3 21l1.9-5.7A8.5 8.5 0 1 1 21 11.5z"></path></svg>
Ask a question
</button>
{% endif %}
</div>
</main>
</div>
{% endblock %}
{% block page_scripts %}
<script>
(function () {
document.body.classList.add('js');
var input = document.getElementById('hc-search');
var clearBtn = document.getElementById('hc-clear');
var topics = [].slice.call(document.querySelectorAll('.hc-topic'));
var groupsWrap = document.getElementById('hc-groups');
var groups = [].slice.call(document.querySelectorAll('.hc-group'));
var cards = [].slice.call(document.querySelectorAll('.hc-card'));
// Remember each card's home group + document order so search (which moves
// matched cards into a flat ranked list) can put them back on clear.
cards.forEach(function (c, i) { c._home = c.parentNode; c._idx = i; });
var results = document.getElementById('hc-search-results');
var head = document.getElementById('hc-head');
var headTitle = document.getElementById('hc-head-title');
var headSub = document.getElementById('hc-head-sub');
var empty = document.getElementById('hc-empty');
var emptyTitle = document.getElementById('hc-empty-title');
var aiWrap = document.getElementById('hc-ai-wrap');
var mq = window.matchMedia('(max-width: 860px)');
var activeCat = topics.length ? topics[0].getAttribute('data-category') : null;
function resetAi() {
if (!aiWrap) return;
aiWrap.hidden = true;
var c = document.getElementById('hc-ai-card'); if (c) c.hidden = true;
var l = document.getElementById('hc-ai-loading'); if (l) l.hidden = true;
var b = document.getElementById('hc-ask-btn'); if (b) b.hidden = false;
}
function sectionFor(cat) {
return groups.filter(function (g) { return g.getAttribute('data-category') === cat; })[0];
}
// Return every card to its home group in original document order.
function restoreCards() {
cards.slice().sort(function (a, b) { return a._idx - b._idx; }).forEach(function (c) {
c.hidden = false;
if (c.parentNode !== c._home) c._home.appendChild(c);
});
}
// Desktop shows every category section; mobile shows one at a time.
function applyGroupVisibility() {
groups.forEach(function (g) {
g.hidden = mq.matches && g.getAttribute('data-category') !== activeCat;
});
}
function markActive() {
topics.forEach(function (x) { x.classList.toggle('active', x.getAttribute('data-category') === activeCat); });
}
// ----- browse: grouped category sections -----
function showBrowse() {
document.body.classList.remove('hc-searching');
restoreCards();
if (results) results.hidden = true;
if (head) head.hidden = true;
if (empty) empty.hidden = !!groups.length;
if (groupsWrap) groupsWrap.hidden = false;
markActive();
applyGroupVisibility();
resetAi();
}
// Pick a category: mobile filters to its section, desktop scrolls to it.
function selectCategory(cat) {
if (input) input.value = '';
activeCat = cat;
showBrowse();
if (mq.matches) {
window.scrollTo({ top: 0, behavior: 'smooth' });
} else {
var sec = sectionFor(cat);
if (sec) sec.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
// ----- search across all categories -----
// Token-based relevance search: every query term must match somewhere
// (word-prefix or substring), title hits outrank body hits, and results
// are re-ordered by score — so "close my" finds "How to close my account".
function termScore(term, title, titleWords, body) {
var best = 0;
for (var i = 0; i < titleWords.length; i++) {
if (titleWords[i] === term) return 8; // exact title word
if (titleWords[i].indexOf(term) === 0) best = 5; // title word prefix
}
if (!best && title.indexOf(term) !== -1) best = 3; // inside a title word
if (!best) {
if (body.indexOf(' ' + term) !== -1 || body.indexOf(term) === 0) best = 2; // body word start
else if (body.indexOf(term) !== -1) best = 1; // anywhere in body
}
return best;
}
// Partial-match semantics: a card qualifies when ANY term matches, but
// cards matching MORE terms always rank above cards matching fewer —
// so "close my" still surfaces "How to close your Atoa account" (no
// literal "my") below any article containing both words.
function cardScore(card, terms) {
var title = card.getAttribute('data-title') || '';
var body = card.getAttribute('data-search') || '';
var titleWords = title.split(/\s+/);
var matched = 0;
var total = 0;
for (var i = 0; i < terms.length; i++) {
var s = termScore(terms[i], title, titleWords, body);
if (s) { matched++; total += s; }
}
// Matched-term count dominates; per-term quality breaks ties.
return matched ? matched * 100 + total : 0;
}
function search(raw) {
var q = (raw || '').trim().toLowerCase();
if (clearBtn) clearBtn.style.display = q ? 'block' : 'none';
document.body.classList.toggle('hc-searching', !!q);
if (!q) { showBrowse(); return; }
if (groupsWrap) groupsWrap.hidden = true;
topics.forEach(function (x) { x.classList.remove('active'); });
var terms = q.split(/\s+/);
var matches = [];
cards.forEach(function (card) {
var score = cardScore(card, terms);
card.hidden = !score;
var tag = card.querySelector('.hc-tag');
if (tag) tag.style.display = score ? 'inline-block' : '';
if (score) matches.push({ card: card, score: score });
});
matches.sort(function (a, b) { return b.score - a.score; });
matches.forEach(function (m) { results.appendChild(m.card); });
if (results) results.hidden = false;
var count = matches.length;
if (head) {
head.hidden = false;
headTitle.textContent = 'Search results';
headSub.textContent = count + (count === 1 ? ' answer' : ' answers') + ' for “' + raw.trim() + '”';
}
if (empty) {
empty.hidden = count > 0;
if (emptyTitle) emptyTitle.textContent = 'No results for “' + raw.trim() + '”';
}
if (aiWrap) {
resetAi();
aiWrap.hidden = false;
var label = document.getElementById('hc-ask-label');
if (label) label.textContent = 'Ask AI: “' + raw.trim() + '”';
}
}
// Honor ?topic=… so links from an article page land on the right category.
var params = new URLSearchParams(window.location.search);
var wantTopic = params.get('topic');
if (input) {
input.addEventListener('input', function () { search(input.value); });
if (input.value) search(input.value);
else if (wantTopic && topics.some(function (t) { return t.getAttribute('data-category') === wantTopic; })) selectCategory(wantTopic);
else showBrowse();
} else {
showBrowse();
}
if (clearBtn) clearBtn.addEventListener('click', function () { input.value = ''; input.focus(); search(''); });
topics.forEach(function (t) {
t.addEventListener('click', function () { selectCategory(t.getAttribute('data-category')); });
});
[].slice.call(document.querySelectorAll('[data-jump]')).forEach(function (chip) {
chip.addEventListener('click', function () { selectCategory(chip.getAttribute('data-jump')); });
});
// Re-apply per-breakpoint group visibility when crossing 860px (not searching).
mq.addEventListener('change', function () {
if (!document.body.classList.contains('hc-searching')) applyGroupVisibility();
});
// Desktop scroll-spy: highlight the category whose section is at the top of
// the viewport as you scroll through the grouped list.
var spyScheduled = false;
function syncActiveToScroll() {
spyScheduled = false;
if (mq.matches || document.body.classList.contains('hc-searching')) return;
var current = null;
for (var i = 0; i < groups.length; i++) {
if (groups[i].getBoundingClientRect().top - 100 <= 0) current = groups[i];
else break;
}
if (!current) current = groups[0];
if (current) {
var cat = current.getAttribute('data-category');
if (cat !== activeCat) { activeCat = cat; markActive(); }
}
}
window.addEventListener('scroll', function () {
if (!spyScheduled) { spyScheduled = true; window.requestAnimationFrame(syncActiveToScroll); }
}, { passive: true });
})();
</script>
{% if ask_enabled %}
<script>
(function () {
var askBtn = document.getElementById('hc-ask-btn');
var input = document.getElementById('hc-search');
var loading = document.getElementById('hc-ai-loading');
var card = document.getElementById('hc-ai-card');
var answerEl = document.getElementById('hc-ai-answer');
if (!askBtn) return;
askBtn.addEventListener('click', function () {
var question = (input.value || '').trim();
if (question.length < 3) { input.focus(); return; }
askBtn.hidden = true;
card.hidden = true;
loading.hidden = false;
fetch('{{ base_path }}/ask', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question: question })
}).then(function (r) {
if (!r.ok) throw new Error(r.status);
return r.json();
}).then(function (data) {
loading.hidden = true;
answerEl.textContent = data.answer; // text only — never innerHTML
card.hidden = false;
}).catch(function (err) {
loading.hidden = true;
answerEl.textContent = String(err.message) === '429'
? 'Too many questions — please try again in a minute.'
: 'Sorry, I could not answer that right now.';
card.hidden = false;
});
});
})();
</script>
{% endif %}
{% endblock %}