mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-28 23:35:59 +00:00
Add FAQ section to mini app
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user