Add FAQ section to mini app

This commit is contained in:
Egor
2025-10-09 05:49:01 +03:00
parent 5769fa63a5
commit 09bdde6f91
3 changed files with 395 additions and 0 deletions

View File

@@ -30,6 +30,7 @@ from app.database.models import (
Transaction,
User,
)
from app.services.faq_service import FaqService
from app.services.remnawave_service import (
RemnaWaveConfigurationError,
RemnaWaveService,
@@ -47,6 +48,8 @@ from ..schemas.miniapp import (
MiniAppAutoPromoGroupLevel,
MiniAppConnectedServer,
MiniAppDevice,
MiniAppFaq,
MiniAppFaqItem,
MiniAppPromoGroup,
MiniAppPromoOffer,
MiniAppPromoOfferClaimRequest,
@@ -848,6 +851,62 @@ async def get_subscription_details(
user=user,
)
faq_payload: Optional[MiniAppFaq] = None
requested_faq_language = user.language or settings.DEFAULT_LANGUAGE or "ru"
requested_faq_language = FaqService.normalize_language(requested_faq_language)
faq_pages = await FaqService.get_pages(
db,
requested_faq_language,
include_inactive=False,
fallback=True,
)
if faq_pages:
faq_setting = await FaqService.get_setting(
db,
requested_faq_language,
fallback=True,
)
is_enabled = bool(faq_setting.is_enabled) if faq_setting else True
if is_enabled:
ordered_pages = sorted(
faq_pages,
key=lambda page: (
(page.display_order or 0),
page.id,
),
)
faq_items: List[MiniAppFaqItem] = []
for page in ordered_pages:
raw_content = (page.content or "").strip()
if not raw_content:
continue
if not re.sub(r"<[^>]+>", "", raw_content).strip():
continue
faq_items.append(
MiniAppFaqItem(
id=page.id,
title=page.title or None,
content=page.content or "",
display_order=getattr(page, "display_order", None),
)
)
if faq_items:
resolved_language = (
faq_setting.language
if faq_setting and faq_setting.language
else ordered_pages[0].language
)
faq_payload = MiniAppFaq(
requested_language=requested_faq_language,
language=resolved_language or requested_faq_language,
is_enabled=is_enabled,
total=len(faq_items),
items=faq_items,
)
response_user = MiniAppSubscriptionUser(
telegram_id=user.telegram_id,
username=user.username,
@@ -917,6 +976,7 @@ async def get_subscription_details(
subscription_type="trial" if subscription.is_trial else "paid",
autopay_enabled=bool(subscription.autopay_enabled),
branding=settings.get_miniapp_branding(),
faq=faq_payload,
)

View File

@@ -123,6 +123,21 @@ class MiniAppPromoOfferClaimResponse(BaseModel):
code: Optional[str] = None
class MiniAppFaqItem(BaseModel):
id: int
title: Optional[str] = None
content: Optional[str] = None
display_order: Optional[int] = None
class MiniAppFaq(BaseModel):
requested_language: str
language: str
is_enabled: bool = True
total: int = 0
items: List[MiniAppFaqItem] = Field(default_factory=list)
class MiniAppSubscriptionResponse(BaseModel):
success: bool = True
subscription_id: int
@@ -154,4 +169,5 @@ class MiniAppSubscriptionResponse(BaseModel):
subscription_type: str
autopay_enabled: bool = False
branding: Optional[MiniAppBranding] = None
faq: Optional[MiniAppFaq] = None

View File

@@ -1599,6 +1599,136 @@
box-shadow: var(--shadow-sm);
}
/* FAQ Section */
.faq-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.faq-item {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 0 16px;
transition: all 0.3s ease;
}
.faq-item[open] {
border-color: rgba(var(--primary-rgb), 0.35);
box-shadow: var(--shadow-sm);
}
.faq-question {
list-style: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
cursor: pointer;
font-weight: 700;
color: var(--text-primary);
padding: 16px 0;
font-size: 15px;
outline: none;
}
.faq-question::-webkit-details-marker {
display: none;
}
.faq-question-text {
flex: 1;
}
.faq-toggle-icon {
width: 20px;
height: 20px;
color: var(--text-secondary);
transition: transform 0.3s ease;
flex-shrink: 0;
}
.faq-item[open] .faq-toggle-icon {
transform: rotate(180deg);
}
.faq-answer {
color: var(--text-secondary);
font-size: 14px;
line-height: 1.6;
padding-bottom: 16px;
border-top: 1px solid var(--border-color);
}
.faq-answer > *:first-child {
margin-top: 16px;
}
.faq-answer p {
margin: 0 0 12px;
}
.faq-answer ul,
.faq-answer ol {
margin: 8px 0 16px 20px;
}
.faq-answer li + li {
margin-top: 6px;
}
.faq-answer a {
color: var(--primary);
text-decoration: none;
font-weight: 600;
}
.faq-answer a:hover {
text-decoration: underline;
}
.faq-answer code {
background: rgba(var(--primary-rgb), 0.08);
padding: 2px 4px;
border-radius: 4px;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 13px;
}
.faq-answer pre {
background: rgba(var(--primary-rgb), 0.08);
padding: 12px;
border-radius: var(--radius);
overflow-x: auto;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 13px;
margin: 16px 0;
}
:root[data-theme="dark"] .faq-item {
border-color: rgba(148, 163, 184, 0.18);
background: rgba(15, 23, 42, 0.75);
}
:root[data-theme="dark"] .faq-item[open] {
border-color: rgba(var(--primary-rgb), 0.45);
box-shadow: var(--shadow-md);
}
:root[data-theme="dark"] .faq-answer pre {
background: rgba(148, 163, 184, 0.12);
}
:root[data-theme="dark"] .faq-answer code {
background: rgba(148, 163, 184, 0.12);
}
.faq-question:focus-visible {
outline: 2px solid rgba(var(--primary-rgb), 0.35);
border-radius: var(--radius);
}
/* Hidden */
.hidden {
display: none !important;
@@ -2061,6 +2191,16 @@
</div>
</div>
</div>
<!-- FAQ Section -->
<div class="card hidden" id="faqCard">
<div class="card-header">
<div class="card-title" data-i18n="faq.title">FAQ</div>
</div>
<div class="card-content">
<div class="faq-list" id="faqList"></div>
</div>
</div>
</div>
</div>
@@ -2250,6 +2390,9 @@
'apps.step.download': 'Download & install',
'apps.step.add': 'Add subscription',
'apps.step.connect': 'Connect & use',
'faq.title': 'FAQ',
'faq.item_default_title': 'Question {index}',
'faq.item_empty': 'Answer will be added soon.',
'history.empty': 'No transactions yet',
'history.status.completed': 'Completed',
'history.status.pending': 'Processing',
@@ -2361,6 +2504,9 @@
'apps.step.download': 'Скачать и установить',
'apps.step.add': 'Добавить подписку',
'apps.step.connect': 'Подключиться и пользоваться',
'faq.title': 'FAQ',
'faq.item_default_title': 'Вопрос {index}',
'faq.item_empty': 'Ответ будет добавлен позже.',
'history.empty': 'Операции ещё не проводились',
'history.status.completed': 'Выполнено',
'history.status.pending': 'Обрабатывается',
@@ -3010,6 +3156,7 @@
renderTransactionHistory();
renderServersList();
renderDevicesList();
renderFaqSection();
updateConnectButtonLabel();
updateActionButtons();
}
@@ -3168,6 +3315,81 @@
return doc.body.innerHTML.replace(/\n/g, '<br>');
}
function sanitizeFaqHtml(input) {
if (!input || typeof input !== 'string') {
return '';
}
const parser = new DOMParser();
const doc = parser.parseFromString(`<div>${input}</div>`, 'text/html');
const allowedTags = new Set([
'P',
'BR',
'UL',
'OL',
'LI',
'STRONG',
'B',
'EM',
'I',
'U',
'A',
'CODE',
'PRE',
'SPAN',
'DIV',
'H1',
'H2',
'H3',
'H4',
'H5',
'H6',
'BLOCKQUOTE',
]);
const elements = [];
const walker = document.createTreeWalker(doc.body, NodeFilter.SHOW_ELEMENT, null);
while (walker.nextNode()) {
elements.push(walker.currentNode);
}
elements.forEach(node => {
const tagName = node.tagName;
if (!allowedTags.has(tagName)) {
const replacementText = node.textContent || '';
node.replaceWith(doc.createTextNode(replacementText));
return;
}
if (tagName === 'A') {
const href = node.getAttribute('href') || '';
if (!/^https?:\/\//i.test(href)) {
node.replaceWith(doc.createTextNode(node.textContent || ''));
return;
}
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
Array.from(node.attributes).forEach(attr => {
if (!['href', 'target', 'rel'].includes(attr.name)) {
node.removeAttribute(attr.name);
}
});
return;
}
Array.from(node.attributes).forEach(attr => node.removeAttribute(attr.name));
if (tagName === 'PRE') {
const codeChild = node.querySelector('code');
if (codeChild) {
Array.from(codeChild.attributes).forEach(attr => codeChild.removeAttribute(attr.name));
}
}
});
return doc.body.innerHTML.trim();
}
function formatShortDuration(seconds) {
if (!Number.isFinite(seconds) || seconds <= 0) {
return t('time.less_than_minute');
@@ -3920,6 +4142,103 @@
}).join('');
}
function renderFaqSection() {
const faqCard = document.getElementById('faqCard');
const faqList = document.getElementById('faqList');
if (!faqCard || !faqList) {
return;
}
const faq = userData?.faq;
const isEnabled = faq && faq.is_enabled !== false;
if (!isEnabled) {
faqCard.classList.add('hidden');
faqList.innerHTML = '';
return;
}
const items = Array.isArray(faq?.items) ? faq.items : [];
const processedItems = [];
items.forEach(item => {
if (!item) {
return;
}
const sanitized = sanitizeFaqHtml(item.content);
if (!sanitized) {
return;
}
const probe = document.createElement('div');
probe.innerHTML = sanitized;
if (!probe.textContent.trim()) {
return;
}
processedItems.push({ item, sanitized });
});
if (!processedItems.length) {
faqCard.classList.add('hidden');
faqList.innerHTML = '';
return;
}
processedItems.sort((a, b) => {
const parseOrder = value => {
if (value === null || value === undefined) {
return null;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
};
const orderA = parseOrder(a.item?.display_order);
const orderB = parseOrder(b.item?.display_order);
if (orderA !== null && orderB !== null && orderA !== orderB) {
return orderA - orderB;
}
if (orderA !== null) {
return -1;
}
if (orderB !== null) {
return 1;
}
const idA = Number(a.item?.id) || 0;
const idB = Number(b.item?.id) || 0;
return idA - idB;
});
const fallbackTitleTemplate = t('faq.item_default_title');
const fallbackAnswer = t('faq.item_empty');
const html = processedItems.map(({ item, sanitized }, index) => {
const hasTitle = typeof item.title === 'string' && item.title.trim().length > 0;
const fallbackTitle = fallbackTitleTemplate.includes('{index}')
? fallbackTitleTemplate.replace('{index}', String(index + 1))
: `${fallbackTitleTemplate} ${index + 1}`;
const question = escapeHtml(hasTitle ? item.title : fallbackTitle);
const answer = sanitized || `<p>${escapeHtml(fallbackAnswer)}</p>`;
return `
<details class="faq-item">
<summary class="faq-question">
<span class="faq-question-text">${question}</span>
<svg class="faq-toggle-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</summary>
<div class="faq-answer">${answer}</div>
</details>
`;
}).join('');
faqList.innerHTML = html;
faqCard.classList.remove('hidden');
}
const PROMO_DISCOUNT_FIELDS = [
{ field: 'server_discount_percent', labelKey: 'promo_levels.discounts.server' },
{ field: 'traffic_discount_percent', labelKey: 'promo_levels.discounts.traffic' },