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, '${escapeHtml(fallbackAnswer)}
`; + + return ` +