Files
replypier/backend/app/templates/help_center/index.html
T
WangShen007 54b949b392 chore(brand): rename the product to ReplyPier
The upstream project name is gone from code, assets, docs and build
output: brand strings, domains (replypier.site and its app/api/docs/help
subdomains), env prefixes, image and network names, the widget's public
JS API (window.ReplyPier, replypierId/BaseUrl/Config/Token), CSS tokens
(--rp-*), file and directory names, and the WordPress plugin package.

- widget loader is now webclient/replypier.min.js; both bundles rebuilt
  from source (backend/assets/widget.{js,css}, frontend/public/webclient)
- NOTICE, llms.txt, CONTRIBUTING, HELP_CENTER_INFRA and config.dev.js
  rewritten rather than string-swapped; the demo config no longer ships
  the previous vendor's Firebase/Razorpay/Google keys
- new migration replypier_ai_model_001 adds REPLYPIER to the managed
  AIModelType and rewrites pre-rename rows
- local compose project, database, role and volume renamed; LOCAL_DEPLOY
  documents carrying the old local data over
2026-10-26 00:51:03 +08:00

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