diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index f083256a..319cc1a4 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -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, ) diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index e2c2a348..431f9cc2 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -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 diff --git a/miniapp/index.html b/miniapp/index.html index de98a1e7..10d342fc 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -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 @@ + + + @@ -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, '
'); } + function sanitizeFaqHtml(input) { + if (!input || typeof input !== 'string') { + return ''; + } + + const parser = new DOMParser(); + const doc = parser.parseFromString(`
${input}
`, '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 || `

${escapeHtml(fallbackAnswer)}

`; + + return ` +
+ + ${question} + + + + +
${answer}
+
+ `; + }).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' },