One word everywhere: ReplyPier in UI copy, package and image names, the replypier.site domains (app/api/docs/help), REPLYPIER_* settings and the --rp-* design tokens. - public widget API is window.ReplyPier with replypierId / replypierBaseUrl / replypierConfig / replypierToken, loader at webclient/replypier.min.js; both bundles rebuilt from source - NOTICE, llms.txt, CONTRIBUTING, HELP_CENTER_INFRA and the demo runtime config rewritten; the demo config ships no third-party keys - migration adds the ReplyPier managed model value to the AI model enum - local compose project, database, role, volume and Docker network carry the same name
426 lines
20 KiB
HTML
426 lines
20 KiB
HTML
{#
|
|
Copyright 2024-2026 ReplyPier
|
|
|
|
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 %}
|