Merge remote-tracking branch 'origin/dev_nikita' into dev_nikita

This commit is contained in:
firewookie
2026-03-10 15:29:14 +05:00
77 changed files with 13169 additions and 1380 deletions

View File

@@ -1,5 +1,292 @@
# Changelog
## [1.29.1](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/compare/v1.29.0...v1.29.1) (2026-03-10)
### Bug Fixes
* daily tariff renewal uses purchaseTariff instead of renewSubscription ([8629cfe](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/8629cfea18aab9daf818f1f6c8e250ede29054d4))
* show fallback when tariff has no available periods for renewal ([ea06ad1](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/ea06ad1d8f7894f5460d150fa72d094617b9fbbe))
## [1.29.0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/compare/v1.28.0...v1.29.0) (2026-03-09)
### Features
* add button reordering within rows and replace modal with inline add panel ([082471b](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/082471bf92cab2577fec6ae047e0ab1ded224ba3))
* add gift navigation, routes, and i18n translations ([7890d48](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/7890d480e05e87f77ea2fea3ae3a7e955bd167d3))
* add gift subscription API client and feature flag ([a495205](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/a49520566e46eb0cfdc22a3661c5ba405dc6cc92))
* add gift subscription toggle to admin branding settings ([9542607](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/9542607832561a8a72bb742947f3388bdaa087dc))
* add GiftSubscription and GiftResult pages ([814b1f5](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/814b1f5e96f968d9bc2829ba395ac187fa4d2e11))
* add gradient fade indicators to scrollable desktop nav ([622172f](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/622172f0387dc7f029c8af797d1f8df2e790771e))
* add menu editor tab with drag-and-drop rows, custom URL buttons, and button configuration ([23aa86f](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/23aa86f1a81556ce2083e8b86107ee1a82c429b1))
* add open_in setting for custom buttons (external browser / Telegram miniapp) ([638844e](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/638844ef47686f4c9540b5591d499255cdc8ff2f))
* read gift warning from status response, soften poll error state ([4322d58](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/4322d58ff8ca56ba401b669370bee8783cf55a86))
### Bug Fixes
* add missing nameRequired i18n key for promo group form validation ([78fda22](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/78fda22679b9f5b4443fa602214e28ad52f7f2e9))
* admin promo groups - add default toggle, fix threshold reset to 0 ([9c7ab4b](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/9c7ab4b789f0d2e92c81afd2199789d03d3768db))
* harden gift subscription frontend after multi-agent review ([6ea1de2](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/6ea1de2e8afba93361c48a364ddc5406f6bc5d4b))
* make desktop nav horizontally scrollable on narrow screens ([ab7d1b7](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/ab7d1b7f25215aa6fb8fe7978d570f02d884b032))
* remove bg-dark-950 from gift pages to preserve animated background ([c8ec221](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/c8ec2211112656ffc3787e905d9d6b2774bc6866))
* remove noreferrer from payment links to preserve Referer header ([45203da](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/45203dac5914c2abd60371ab552d2838048b3ef1))
* restore session from refresh token when access token is missing ([dc740ae](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/dc740ae2664059011fd755ccfa96ee46a26196d3))
* support OIDC mode in TelegramLinkWidget for account linking ([880b2d4](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/880b2d45fe8966f510f77b83d6513e8be0ec1e47))
## [1.28.0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/compare/v1.27.0...v1.28.0) (2026-03-09)
### Features
* add dedicated TopUpResult page for payment return flow ([b591228](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/b59122818c3242ffab512b896f75179dd9a13c1b))
* support disabled daily subscription status in cabinet UI ([7940410](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/7940410d7d913e8c92a7732f4fdc4ababd06ba3b))
### Bug Fixes
* cover all payment provider statuses in TopUpResult ([8897561](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/8897561fb2af322b4b37b84ac07b7746fde70586))
* device purchase guard condition and cache invalidation ([115c684](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/115c684fe00d0e209953e4bdd3ff5d213909e423))
* force fresh balance data on purchase-options query ([69a8fe8](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/69a8fe8e03aca6b0a984a0acb3ed5d9091ed4737))
* support method query param fallback for external browser redirects ([7ce5341](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/7ce5341e955ba34e7336959b09a528269e6b3417))
## [1.27.0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/compare/v1.26.0...v1.27.0) (2026-03-08)
### Features
* add gift purchase UI states for telegram recipients ([eed077b](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/eed077b0197f215c8f74f70a2bf0b73fd41d4628))
* unified device manager with dot-based selector ([edb7ef0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/edb7ef0488b0ae994b7a37be9b95d1ab007feb09))
### Bug Fixes
* add purchase-options cache invalidation on balance changes ([f1102d2](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/f1102d278354ae3225f8b36029590d8c01b74ea0))
* mobile layout overflow on landing page ([9aae9cc](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/9aae9cc0e6e650ff6eb6633b0d08c952aa7f2c4a))
* show infinity symbol for unlimited traffic on landing page ([bda95ed](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/bda95ed23f3b445c9a4a295a3be65310dae039e0))
* unify device manager into additional options card with unbounded dot selector ([6dc8ca0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/6dc8ca0d18bd0e23d6fa05b169f40686f6b2584c))
* use platform-conditional replace for QR navigation to preserve Telegram back behavior ([7bb75aa](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/7bb75aa92045d911533506cf922cbc8a45ef0968))
### Reverts
* remove device manager redesign, restore original device UI ([71a0111](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/71a0111a04d51f25ab8f4b226018519aeb3abcdc))
## [1.26.0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/compare/v1.25.0...v1.26.0) (2026-03-07)
### Features
* add configurable animated background for landing pages ([a404690](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/a4046903344855d849482b585fee1e27d13efcae))
* add landing page statistics page with recharts ([3019019](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/30190199ed88cde6aea575eed44a2f7d4361dbdc))
* add purchases list with pagination to landing stats page ([887b13d](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/887b13dec22bbb6c4f07e8035cbbeefc437f10e2))
### Bug Fixes
* allow animated background to show through on landing pages ([66bb86a](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/66bb86a5f286b40eb0cc7cbac8a82ad3d6336de2))
* rename duplicate 'purchases' i18n key to 'purchaseCount' ([0ce74ea](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/0ce74ea5fb7efad60bb78b56a0bf6518fecb88d8))
* replace deprecated Telegram Login Widget redirect with callback ([32091d3](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/32091d3648889795d01d78bff933da3a38caa10f))
* send Bearer token on email register (link to Telegram account) ([68e6ce1](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/68e6ce1bce1edc3c6048c1ed873865a27c39ea52))
## [1.25.0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/compare/v1.24.0...v1.25.0) (2026-03-07)
### Features
* add discount UI for landing pages ([f7afa00](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/f7afa002f08cfde0421ab8cfed8f699608fd6bc9))
* add external squad selection to tariff admin form ([bc45294](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/bc452944876b64c5346dc04d53c561831fb31bd8))
* add i18n translations and admin category for Telegram OIDC ([c221c6e](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/c221c6e8bfc15b160565083f0198816d4c84c146))
* add landings permission section translations for role editor ([5228b2d](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/5228b2dea6f1adc78c521c197d09726a286516ba))
* add payment sub-option selection on quick purchase page ([58e93cd](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/58e93cd2b72979ec95dd43ba7d6670d879e2f07d))
* add sub-options UI for landing payment methods + extract components ([d0be127](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/d0be127d30574af1cb90503943bfa721dda8e645))
* add user filter chips and resource types to audit log ([4072274](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/40722747e33c2dcc5d5ccc1d213b4d2eb39e0f26))
* guest purchase activation UI & landing editor improvements ([b852e1e](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/b852e1e4cda7303e19ac7af8c3826e2ba52ac68a))
* guest purchase cabinet credentials UI ([d228d99](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/d228d997d8360f8a15a23ec007a06048af7bd47d))
* migrate Telegram Login Widget to v23 with admin-configurable settings ([2c65ca8](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/2c65ca8a7ff372725bcbaa002e96bd043022bad1))
* TelegramLoginButton with OIDC popup + legacy widget fallback ([91f0e9e](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/91f0e9e2fcd0d9c3f3dc7f7e31b763244350f754))
* мультиязычные лендинги + переключатель языка + исправления по ревью ([ab13616](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/ab13616b0f0d31eac007a4c4b7f4f360f0f3c9b4))
* публичные лендинг-страницы для быстрой покупки VPN-подписок ([8b5d777](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/8b5d777f0a94296330227b5fab34c65c83fb3baa))
### Bug Fixes
* adapt admin landings list for mobile layout ([b7c7dec](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/b7c7decfd0f2818b65861699336d6221ba0e0ae2))
* add pagination to campaigns list ([46f640a](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/46f640a7e0c2026c7629f0cd4cd01f7f4758bbe5))
* add unmount safety guard to OIDC callback handler ([dfa7a09](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/dfa7a09a7cb53ebbbc5de057fd587897d77dcb9b))
* address code review findings for TelegramLoginButton ([5c11f12](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/5c11f1251a9bdbb60f49c105b1a3ebcbd477d8b8))
* admin landing editor — tariff period mapping and cleanup ([6a92814](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/6a92814ce25bb718ce29450adbd7d01775e4e1dc))
* auto-select single sub-option and remove unused return_url field ([83fbd0e](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/83fbd0e44564a3b5f174f52549ff29b638701067))
* handle Pydantic validation errors in notify + nullify empty optional fields ([9bd58cb](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/9bd58cb914623b75ec2a035e8c6e077b0fe45e8d))
* landing list crash — title is now LocaleDict, not string ([6755c1d](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/6755c1dc458e7f3ff68fb180306f72b17ff2a5b8))
* mobile layout and period label translations for quick purchase landing ([6d5c6fb](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/6d5c6fb9b3905d8a0c22f39317fd5f77743d3505))
* OIDC login UX improvements from review ([b335d66](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/b335d666c2c1fb557288d70fb249e4166f99b146))
* prevent buyer from activating gift pending subscription ([97959b0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/97959b013241597e77ed3223fb5aa2d1de8be2d0))
* safe error handling and numeric client_id in OIDC login ([45e68ff](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/45e68ffac231516577ac1f5230bf90fd5a1b5cdb))
* show total purchase count instead of paid on admin landings ([b9f1f59](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/b9f1f59e3cbcd5c9839a4a2e9eebeefa01364898))
* админ-редактор — системные методы оплаты, реальные периоды тарифов, фильтрация на публичной странице ([e01c9f5](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/e01c9f51439fe74bbf74d7d40a7f7027252dbd17))
* безопасность и UX лендингов — 16 исправлений ([3cea482](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/3cea48235f373412071afbb7d811a2306ad15b78))
## [1.24.0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/compare/v1.23.1...v1.24.0) (2026-03-05)
### Features
* account linking and merge UI for cabinet ([93f97d4](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/93f97d45bec4ac4ac893475edd3e79107fe5806b))
* account merge flow — merge redirect, error handling, server-complete linking ([2fc0759](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/2fc0759f89da90b7a349deb8a502417a4f790827))
* add sales_stats RBAC permission section to frontend ([262303d](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/262303d623a6e8a597b3aa9310d1b8290b494595))
* add Telegram account linking UI with CSRF protection ([a6fabb1](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/a6fabb1d9d79c6a233e1ac52fcd006d9dea31a3e))
* open OAuth linking in external browser from Telegram Mini App ([7c30a1e](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/7c30a1eab616846253df1ec2c93b97259a54c8b8))
* кликабельные имена пользователей в последних платежах ([e278fec](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/e278fec506e17281d0fb92cb04348b269dc8e30e))
### Bug Fixes
* accessibility, query cache clear, post-merge navigation ([e447e99](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/e447e993cb10989f55525d9bb57ed8a5d5ad9d97))
* add Referrer-Policy to prevent merge token leakage via Referer header ([584f002](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/584f00297bfc38fefc372f28ba0947300b8a6064))
* double-click guard on link, wall-clock timer, blur cleanup ([8ad0500](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/8ad0500cc80fee51b03880e7988ffe1192e7f214))
* harden merge UI and improve error handling ([58cf1e3](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/58cf1e3b504c8577e6d6aa081bf861cb871fb765))
* **merge:** accessibility, token guard, state cleanup ([579f47e](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/579f47e563a13f4a56ca92064949d594bfe66063))
* move useState before useMutation for consistent hook ordering ([fba4481](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/fba4481799081b05f0b082bcf983c6ac4c4daf1b))
* prevent onBlur race cancelling unlink confirmation ([3418ba9](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/3418ba9b8da69ec8ea3822971729bd16fcfcd1ce))
* remove unused linkTelegramWidget i18n key from all locales ([9b4a851](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/9b4a8512c2e3cedf1f075aef62415fa5464b69e6))
* replace window.confirm with inline confirmation for unlink ([d0c01a0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/d0c01a0e5cb656661b75175416ccf98c5aff8911))
* review findings — polling fallback, sessionStorage cleanup, UX ([da1926f](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/da1926f0e1ab7f117aef120ac7648bdd50add72c))
* second round review fixes for merge UI ([aa26059](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/aa26059e004dc7ce96b3b0953343ace5e86696c3))
* заменить Tailwind green/emerald классы на success из темы ([86f75f2](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/86f75f25a78cd4b14c544ccb385474390d12d993))
* заменить хардкодный зелёный ([#3](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/issues/3)EDBB0) на акцентный цвет из темы ([d526d09](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/d526d095dec1c4dc80f45ccd7940516a49051f3b))
## [1.23.1](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/compare/v1.23.0...v1.23.1) (2026-03-04)
### Bug Fixes
* ordered list numbering in Info page shows correct sequence ([8157ca5](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/8157ca5f0280dbcbf99eda210cd83130ec77c0b1))
* replace hardcoded green with theme-aware accent color ([a3ddddf](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/a3ddddfa8ce167c22177fde3b131083c710ea619))
## [1.23.0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/compare/v1.22.0...v1.23.0) (2026-03-02)
### Features
* add admin sales statistics dashboard with 5 analytics tabs ([a47c222](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/a47c222310aea2f55bfa6b4df179aa8e27a5293d))
* add daily deposits by payment method chart ([f012710](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/f012710df0c19c00de0c71c51515e03373a29eb5))
* add daily traffic & device purchase chart to addons stats ([2235b3c](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/2235b3cb77eb266b86eb98175e52855c6a08c828))
* add fullscreen QR code for subscription connection ([4d14e3e](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/4d14e3e8062c321e56fc37e79ed6cc16fa83df2a))
* add recharts analytics to admin campaign stats page ([c7d05c4](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/c7d05c4809166341a1702566a343946fe9126797))
* add reset traffic toggle on tariff switch ([49fff8e](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/49fff8e85520ef3ee08cb06c473ba875cdf05dc6))
* display per-campaign stats on partner detail page ([75a6149](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/75a6149e2db4fd0ead705c431ff04ea6d9ffc3d2))
* enhance sales stats with device stats, per-tariff charts, and dual-series trials ([4622b4b](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/4622b4b200bb2973115b0a9891b0ec5956af89d2))
### Bug Fixes
* add subscription tab to desktop nav, fix device dots overflow, show available referral balance ([27f85a1](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/27f85a1db115ca386c5658786147800e33f484bc))
* align RecentPaymentItem types with backend schema ([3f05039](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/3f050396b8d784d1f5d32a949cfab2caaef4ddac))
* align TypeScript types with backend referral schemas ([11343f4](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/11343f4f12e0f082225f7413308972cb8ed92717))
* bar chart white hover cursor on dark theme ([14e5f43](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/14e5f436ce8e1ad110e60169095631916bf167d3))
* block wheel spin without active subscription ([821e991](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/821e991f51db6033d3e0f2befecf15c364d0e3e8))
* clean up expired trial card - remove redundant badge and subtitle ([d2f02d6](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/d2f02d605c5990bc88fbade5f6fa6e7624abd70b))
* eliminate hover flickering across all pages ([bdc201b](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/bdc201b5ea5e359a7f9d97bd86202be35654a7fe))
* improve campaign stats, shared chart components, and i18n coverage ([673de08](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/673de08dd4ad95a55fd70e230022a920fa8ea279))
* improve light theme visibility for dashboard and subscription cards ([4cdff97](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/4cdff9730b3c70d88c1b32f00561b16673a2d55a))
* improve light theme visibility for inner panels on subscription page ([430b703](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/430b703bbea7c923a54824b3a814d59f61065831))
* isolate content layer from animated background to eliminate flickering ([04eacf6](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/04eacf642184867f5ab3437f0aef09ff4ee73e0c))
* move CTA button above additional options section ([0bc817f](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/0bc817fa7f201e9176a586bb1b5c0a68c9406bf6))
* partner system bugs - commission field, withdrawal UX, admin amount ([e94d81f](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/e94d81fe5a25341172bb787146fd80d70067a140))
* prevent countdown timer overflow on narrow mobile screens ([96bcc76](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/96bcc76d695ee7e26ad2538e1733f439e6a2983b))
* remove devices stat block, stretch countdown to full width ([396f814](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/396f814cbdfae24bad3da8ad29d34ae9196593b9))
* render animated background via portal at z-index:-1 to stop implicit compositing ([12c97a2](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/12c97a2c5ebf0d3dc776f581589a9d4280fbdc2e))
* replace framer-motion with CSS keyframes in boxes background ([7f17d95](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/7f17d95ed6f21b07fee5bd2201e1754611028209))
* resolve hover flickering caused by GPU layer destruction ([d8cf430](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/d8cf4301caeeccb890636faded3867bf22afef00))
* review fixes - Math.round kopecks, fa locale, admin list commission ([82987fd](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/82987fd49a861a4ad167c10f89d51e88e8ecee51))
* rewrite BackgroundBoxes from 225 DOM divs to single canvas element ([d89c534](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/d89c534c0b21bff91747002a6e96bf12d114fcc2))
* show progress bar instead of dots when device_limit > 10 ([d567817](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/d567817e0564f2438d4192eb7b2321e1725da266))
* stop beams background from causing UI flickering in browser ([7e89cce](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/7e89ccea5c8fceaedee2a550d1ba01d9074ac1b2))
* subscription UI improvements - expired card, duplicate badges, live countdown ([f4d7a2c](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/f4d7a2cc8d20301108d86654ff03250206536cf3))
* support VK ID OAuth 2.1 device_id in frontend ([60f16e6](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/60f16e64e8cec2f540b2c49764fe711ddc9da86d))
* tile noise texture instead of stretching on large screens ([f652936](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/f652936d7867fba72b476de169de5e4b25bfaca5))
### Performance Improvements
* optimize animated backgrounds for mobile — reduce GPU load and memory pressure ([a933f66](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/a933f661e49b70af3fadee90bc93257f689086be))
* remove permanent GPU layer promotion from cards to fix flickering ([fe32322](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/fe32322c323cce342a343c21acde9422855a9295))
* throttle theme color picker, rewrite beams with CSS animation ([d019953](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/d0199536939a4553d5dace69453d52b37b6b50b0))
### Reverts
* remove user-facing reset traffic toggle ([4a68347](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/4a68347ae8aec6296187da084031c02474fb97a1))
## [1.22.0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/compare/v1.21.0...v1.22.0) (2026-02-25)
### Features
* adapt dashboard and subscription page for light theme ([f474067](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/f474067efbb36974b47b51ba568304b6cd6b3805))
* add animated gradient border to Connect Device buttons ([70e1ed6](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/70e1ed60bd545535b3148aae2b6546f7c17f9552))
* add dashboard sub-components for subscription cards and stats grid ([909374d](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/909374d369589474623ee006779586fadddd485b))
* add fonts, animations, and shared utilities for dashboard redesign ([7e345fc](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/7e345fc7d0431415496f8363959773e99a853b6e))
* add Freekassa SBP and card payment method icons and labels ([a725265](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/a72526502605ab415c16d3506c6fd4aa0bee5c95))
* add HoverBorderGradient effect to key action buttons ([3fb9606](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/3fb9606fd0f5bf765e117436e7507b4c7c226e89))
* add TrafficProgressBar and Sparkline components ([eb1f788](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/eb1f788033c696c1077002048f144b0bfd59592b))
* replace animated backgrounds with Aceternity UI system ([1a702a6](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/1a702a68b9cad0f112a65494250c11758388a91f))
### Bug Fixes
* animation config not updating for users after admin change ([94ddf31](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/94ddf319bd242211cbebf74e89a6052856f84f60))
* boxes background not covering full screen ([f16f96e](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/f16f96e442506484eae9434ae51c5f0f2fc45729))
* boxes background not covering viewport ([65afb29](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/65afb292747b0e57865bc4c0d5df320fbc58b261))
* improve HoverBorderGradient visibility with accent colors and darker bg ([4332c2b](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/4332c2bd253774ddaef87f5735eec15f2b9645ee))
* remove duplicate tariff info line, make tariff card clickable ([bef5102](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/bef5102a7182ce4eb33a8fd366e6247b3cba9905))
* remove gemini-effect and noise backgrounds, fix aurora animation ([79ff741](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/79ff7412cbc374b7ac085b6c8d3fd5f34de8ce37))
* rewrite 5 broken background components from Aceternity sources ([de97a03](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/de97a030d2ebffcfe957e179f32b2857d13465dc))
* rewrite gradient border with [@property](https://github.com/property) CSS angle animation ([d8b83cc](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/d8b83ccdb8d64e73e9f73785e4d81c5931aa28ec))
* rewrite HoverBorderGradient with CSS rotate instead of framer-motion ([e95db23](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/e95db23573987dcf1abff63a9fae0b3db3686764))
* show actual connected devices count instead of device limit ([a819f30](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/a819f309c8105561618690e1408a826b3bce294a))
### Performance Improvements
* eagerly load Dashboard to improve LCP on main route ([5c1be14](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/5c1be1471e8b372bd6aec5470b0fdadf037cffbb))
* improve LCP — move font loading to HTML, defer logo preload ([962dd43](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/962dd43756438779b6cc1821f3ee6b8147113646))
* prefetch background chunk on page load from localStorage cache ([44d88f7](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/44d88f76532e5b9f7364210fafd3fc6c376c03cc))
## [1.21.0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/compare/v1.20.0...v1.21.0) (2026-02-25)
### Features
* add granular user permissions (balance, subscription, promo_group, referral, send_offer) ([3d6987f](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/3d6987f761b168113c009845d8ff028f9ca86688))
* add per-channel disable settings and global settings to channel admin ([48be067](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/48be067d1b41f57b02d97405b8a92538c306dabd))
* add RBAC permission system to admin cabinet frontend ([874ee26](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/874ee2682e50d9deca42b794a4be0ae0dd95ab5c))
* add translations for permission sections and actions ([80bfaca](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/80bfaca457192d25af182365da8c18a8f97c7830))
* add weekdays condition to ABAC policies ([a1a8dc2](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/a1a8dc22034def5802791e1ceda4da6a3558db6b))
* allow editing system roles ([a050125](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/a050125ea8d4265f096bafe0317e811289f38738))
* improve audit log - translate actions, fix resource filter, show request body ([5d0e353](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/5d0e3539e22576e1824292da09c396123349b371))
* show query params in audit log details ([66f7fcb](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/66f7fcb3dca32748503f1ab92155818369f94da6))
### Bug Fixes
* add missing onError handlers on RBAC mutations ([c4e3211](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/c4e3211baa3bfec81cc0efec4467660180e42ba7))
* guard user detail API calls with RBAC permission checks ([bc5d832](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/bc5d832e0d3faf5dc6f64a6359e32d75e68c4282))
* RBAC frontend type mismatches and translations ([4c9c399](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/4c9c3992abe5ffbf98ad1e44e8e9d4b899af6594))
* RBAC policies page role handling and permission gates ([56188b1](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/56188b1f8aa8526419d7a8e30389ef41787e7640))
* redesign role revoke confirmation dialog ([f829076](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/f829076bc2ec1f3229aa59d209ffbe5d1b00319f))
* replace broken modal with inline confirmation for role revoke ([78e7099](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/78e70992f169861fa51150ad06f91f047f3d0708))
## [1.20.0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/compare/v1.19.1...v1.20.0) (2026-02-24)
### Features
* add channel edit in admin, hide subscribed channels in blocking screen ([5a55892](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/5a5589214529e42fd08a3b41929cddd974d52420))
* add multi-channel subscription blocking UI and admin management ([a767fe9](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/a767fe96d3992f91b5c1b722de132ea67f975432))
## [1.19.1](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/compare/v1.19.0...v1.19.1) (2026-02-23)

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "cabinet-frontend",
"version": "1.19.1",
"version": "1.29.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cabinet-frontend",
"version": "1.19.1",
"version": "1.29.1",
"license": "AGPL-3.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",

View File

@@ -1,7 +1,7 @@
{
"name": "cabinet-frontend",
"private": true,
"version": "1.19.1",
"version": "1.29.1",
"description": "Remnawave Bedolaga Web App",
"license": "AGPL-3.0",
"type": "module",

View File

@@ -9,6 +9,7 @@ import {
ChannelSubscriptionScreen,
BlacklistedScreen,
} from './components/blocking';
import { ErrorBoundary } from './components/ErrorBoundary';
import { PermissionRoute } from '@/components/auth/PermissionRoute';
import { saveReturnUrl } from './utils/token';
import { useAnalyticsCounters } from './hooks/useAnalyticsCounters';
@@ -36,10 +37,16 @@ const Contests = lazy(() => import('./pages/Contests'));
const Polls = lazy(() => import('./pages/Polls'));
const Info = lazy(() => import('./pages/Info'));
const Wheel = lazy(() => import('./pages/Wheel'));
const GiftSubscription = lazy(() => import('./pages/GiftSubscription'));
const GiftResult = lazy(() => import('./pages/GiftResult'));
const Connection = lazy(() => import('./pages/Connection'));
const ConnectionQR = lazy(() => import('./pages/ConnectionQR'));
const QuickPurchase = lazy(() => import('./pages/QuickPurchase'));
const PurchaseSuccess = lazy(() => import('./pages/PurchaseSuccess'));
const AutoLogin = lazy(() => import('./pages/AutoLogin'));
const TopUpMethodSelect = lazy(() => import('./pages/TopUpMethodSelect'));
const TopUpAmount = lazy(() => import('./pages/TopUpAmount'));
const TopUpResult = lazy(() => import('./pages/TopUpResult'));
const ConnectedAccounts = lazy(() => import('./pages/ConnectedAccounts'));
const LinkTelegramCallback = lazy(() => import('./pages/LinkTelegramCallback'));
const MergeAccounts = lazy(() => import('./pages/MergeAccounts'));
@@ -105,8 +112,17 @@ const AdminRoleAssign = lazy(() => import('./pages/AdminRoleAssign'));
const AdminPolicies = lazy(() => import('./pages/AdminPolicies'));
const AdminPolicyEdit = lazy(() => import('./pages/AdminPolicyEdit'));
const AdminAuditLog = lazy(() => import('./pages/AdminAuditLog'));
const AdminLandings = lazy(() => import('./pages/AdminLandings'));
const AdminLandingEditor = lazy(() => import('./pages/AdminLandingEditor'));
const AdminLandingStats = lazy(() => import('./pages/AdminLandingStats'));
function ProtectedRoute({ children }: { children: React.ReactNode }) {
function ProtectedRoute({
children,
withLayout = true,
}: {
children: React.ReactNode;
withLayout?: boolean;
}) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const isLoading = useAuthStore((state) => state.isLoading);
const location = useLocation();
@@ -116,12 +132,11 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
}
if (!isAuthenticated) {
// Сохраняем текущий URL для возврата после авторизации
saveReturnUrl();
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
}
return <Layout>{children}</Layout>;
return withLayout ? <Layout>{children}</Layout> : <>{children}</>;
}
function AdminRoute({ children }: { children: React.ReactNode }) {
@@ -135,7 +150,6 @@ function AdminRoute({ children }: { children: React.ReactNode }) {
}
if (!isAuthenticated) {
// Сохраняем текущий URL для возврата после авторизации
saveReturnUrl();
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
}
@@ -195,6 +209,36 @@ function App() {
</LazyPage>
}
/>
<Route
path="/buy/success/:token"
element={
<ErrorBoundary level="app">
<LazyPage>
<PurchaseSuccess />
</LazyPage>
</ErrorBoundary>
}
/>
<Route
path="/buy/:slug"
element={
<ErrorBoundary level="app">
<LazyPage>
<QuickPurchase />
</LazyPage>
</ErrorBoundary>
}
/>
<Route
path="/auto-login"
element={
<ErrorBoundary level="app">
<LazyPage>
<AutoLogin />
</LazyPage>
</ErrorBoundary>
}
/>
{/* Protected routes */}
<Route
@@ -257,6 +301,18 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/balance/top-up/result"
element={
<ProtectedRoute withLayout={false}>
<ErrorBoundary level="app">
<LazyPage>
<TopUpResult />
</LazyPage>
</ErrorBoundary>
</ProtectedRoute>
}
/>
<Route
path="/balance/top-up/:methodId"
element={
@@ -377,12 +433,38 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/gift"
element={
<ErrorBoundary level="app">
<ProtectedRoute>
<LazyPage>
<GiftSubscription />
</LazyPage>
</ProtectedRoute>
</ErrorBoundary>
}
/>
<Route
path="/gift/result"
element={
<ErrorBoundary level="app">
<ProtectedRoute>
<LazyPage>
<GiftResult />
</LazyPage>
</ProtectedRoute>
</ErrorBoundary>
}
/>
<Route
path="/connection/qr"
element={
<LazyPage>
<ConnectionQR />
</LazyPage>
<ProtectedRoute>
<LazyPage>
<ConnectionQR />
</LazyPage>
</ProtectedRoute>
}
/>
<Route
@@ -487,6 +569,46 @@ function App() {
</PermissionRoute>
}
/>
<Route
path="/admin/landings"
element={
<PermissionRoute permission="landings:read">
<LazyPage>
<AdminLandings />
</LazyPage>
</PermissionRoute>
}
/>
<Route
path="/admin/landings/create"
element={
<PermissionRoute permission="landings:create">
<LazyPage>
<AdminLandingEditor />
</LazyPage>
</PermissionRoute>
}
/>
<Route
path="/admin/landings/:id/edit"
element={
<PermissionRoute permission="landings:edit">
<LazyPage>
<AdminLandingEditor />
</LazyPage>
</PermissionRoute>
}
/>
<Route
path="/admin/landings/:id/stats"
element={
<PermissionRoute permission="landings:read">
<LazyPage>
<AdminLandingStats />
</LazyPage>
</PermissionRoute>
}
/>
<Route
path="/admin/servers"
element={

View File

@@ -49,6 +49,20 @@ export const authApi = {
return response.data;
},
// Telegram OIDC authentication (popup flow with id_token)
loginTelegramOIDC: async (
idToken: string,
campaignSlug?: string | null,
referralCode?: string | null,
): Promise<AuthResponse> => {
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram/oidc', {
id_token: idToken,
campaign_slug: campaignSlug || undefined,
referral_code: referralCode || undefined,
});
return response.data;
},
// Email login
loginEmail: async (
email: string,
@@ -234,10 +248,11 @@ export const authApi = {
return response.data;
},
// Link Telegram account (Mini App initData or Login Widget data)
// Link Telegram account (Mini App initData, OIDC id_token, or Login Widget data)
linkTelegram: async (
data:
| { init_data: string }
| { id_token: string }
| {
id: number;
first_name: string;
@@ -280,6 +295,12 @@ export const authApi = {
return response.data;
},
// Auto-login from guest purchase success page
autoLogin: async (token: string): Promise<AuthResponse> => {
const response = await apiClient.post<AuthResponse>('/cabinet/auth/login/auto', { token });
return response.data;
},
// Account merge (no JWT required)
getMergePreview: async (mergeToken: string): Promise<MergePreviewResponse> => {
const response = await apiClient.get<MergePreviewResponse>(

View File

@@ -110,7 +110,15 @@ export const balanceApi = {
// Get specific pending payment details
getPendingPayment: async (method: string, paymentId: number): Promise<PendingPayment> => {
const response = await apiClient.get<PendingPayment>(
`/cabinet/balance/pending-payments/${method}/${paymentId}`,
`/cabinet/balance/pending-payments/${encodeURIComponent(method)}/${encodeURIComponent(paymentId)}`,
);
return response.data;
},
// Get latest pending payment by method (fallback when sessionStorage unavailable)
getLatestPayment: async (method: string): Promise<PendingPayment> => {
const response = await apiClient.get<PendingPayment>(
`/cabinet/balance/pending-payments/${encodeURIComponent(method)}/latest`,
);
return response.data;
},
@@ -118,7 +126,7 @@ export const balanceApi = {
// Manually check payment status
checkPaymentStatus: async (method: string, paymentId: number): Promise<ManualCheckResponse> => {
const response = await apiClient.post<ManualCheckResponse>(
`/cabinet/balance/pending-payments/${method}/${paymentId}/check`,
`/cabinet/balance/pending-payments/${encodeURIComponent(method)}/${encodeURIComponent(paymentId)}/check`,
);
return response.data;
},

View File

@@ -23,6 +23,20 @@ export interface EmailAuthEnabled {
enabled: boolean;
}
export interface GiftEnabled {
enabled: boolean;
}
export interface TelegramWidgetConfig {
bot_username: string;
size: 'large' | 'medium' | 'small';
radius: number;
userpic: boolean;
request_access: boolean;
oidc_enabled: boolean;
oidc_client_id: string;
}
export interface AnalyticsCounters {
yandex_metrika_id: string;
google_ads_id: string;
@@ -241,6 +255,24 @@ export const brandingApi = {
return response.data;
},
// Get gift enabled (public, no auth required)
getGiftEnabled: async (): Promise<GiftEnabled> => {
try {
const response = await apiClient.get<GiftEnabled>('/cabinet/branding/gift-enabled');
return response.data;
} catch {
return { enabled: false };
}
},
// Update gift enabled (admin only)
updateGiftEnabled: async (enabled: boolean): Promise<GiftEnabled> => {
const response = await apiClient.patch<GiftEnabled>('/cabinet/branding/gift-enabled', {
enabled,
});
return response.data;
},
// Get analytics counters (public, no auth required)
getAnalyticsCounters: async (): Promise<AnalyticsCounters> => {
try {
@@ -251,6 +283,26 @@ export const brandingApi = {
}
},
// Get Telegram widget config (public, no auth required)
getTelegramWidgetConfig: async (): Promise<TelegramWidgetConfig> => {
try {
const response = await apiClient.get<TelegramWidgetConfig>(
'/cabinet/branding/telegram-widget',
);
return response.data;
} catch {
return {
bot_username: import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '',
size: 'large',
radius: 8,
userpic: true,
request_access: true,
oidc_enabled: false,
oidc_client_id: '',
};
}
},
// Update analytics counters (admin only)
updateAnalyticsCounters: async (data: Partial<AnalyticsCounters>): Promise<AnalyticsCounters> => {
const response = await apiClient.patch<AnalyticsCounters>('/cabinet/branding/analytics', data);

View File

@@ -77,6 +77,7 @@ const AUTH_ENDPOINTS = [
'/cabinet/auth/oauth/',
'/cabinet/auth/merge/',
'/cabinet/auth/account/link/server-complete',
'/cabinet/landing/',
];
function isAuthEndpoint(url: string | undefined): boolean {
@@ -91,18 +92,22 @@ apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) =>
if (!isAuthEndpoint(config.url)) {
let token = tokenStorage.getAccessToken();
// Проверяем срок действия токена перед запросом
if (token && isTokenExpired(token)) {
// Используем централизованный менеджер для refresh
// Access token expired — try refresh
const newToken = await tokenRefreshManager.refreshAccessToken();
if (newToken) {
token = newToken;
} else {
// Refresh не удался - редирект на логин
tokenStorage.clearTokens();
safeRedirectToLogin();
return config;
}
} else if (!token && tokenStorage.getRefreshToken()) {
// No access token (e.g. tab reopen) but refresh token exists — restore session
const newToken = await tokenRefreshManager.refreshAccessToken();
if (newToken) {
token = newToken;
}
}
if (token && config.headers) {

157
src/api/gift.ts Normal file
View File

@@ -0,0 +1,157 @@
import apiClient from './client';
// Types
export interface GiftTariffPeriod {
days: number;
price_kopeks: number;
price_label: string;
original_price_kopeks: number | null;
discount_percent: number | null;
}
export interface GiftTariff {
id: number;
name: string;
description: string | null;
traffic_limit_gb: number;
device_limit: number;
periods: GiftTariffPeriod[];
}
export interface GiftPaymentMethodSubOption {
id: string;
name: string;
}
export interface GiftPaymentMethod {
method_id: string;
display_name: string;
description: string | null;
icon_url: string | null;
min_amount_kopeks: number | null;
max_amount_kopeks: number | null;
sub_options: GiftPaymentMethodSubOption[] | null;
}
export interface GiftConfig {
is_enabled: boolean;
tariffs: GiftTariff[];
payment_methods: GiftPaymentMethod[];
balance_kopeks: number;
currency_symbol: string;
}
export interface GiftPurchaseRequest {
tariff_id: number;
period_days: number;
recipient_type?: 'email' | 'telegram';
recipient_value?: string;
gift_message?: string;
payment_mode: 'balance' | 'gateway';
payment_method?: string;
}
export interface GiftPurchaseResponse {
status: 'ok' | 'created' | 'paid';
purchase_token: string;
payment_url: string | null;
warning: string | null;
}
export type GiftPurchaseStatusValue =
| 'pending'
| 'paid'
| 'delivered'
| 'pending_activation'
| 'failed'
| 'expired';
export interface GiftPurchaseStatus {
status: GiftPurchaseStatusValue;
is_gift: boolean;
is_code_only: boolean;
purchase_token: string | null;
recipient_contact_value: string | null;
gift_message: string | null;
tariff_name: string | null;
period_days: number | null;
warning: string | null;
}
export interface PendingGift {
token: string;
tariff_name: string | null;
period_days: number;
gift_message: string | null;
sender_display: string | null;
created_at: string | null;
}
export interface SentGift {
token: string;
tariff_name: string | null;
period_days: number;
device_limit: number;
status: string;
gift_recipient_value: string | null;
gift_message: string | null;
activated_by_username: string | null;
created_at: string | null;
}
export interface ReceivedGift {
token: string;
tariff_name: string | null;
period_days: number;
device_limit: number;
status: string;
sender_display: string | null;
gift_message: string | null;
created_at: string | null;
}
export interface ActivateGiftResponse {
status: string;
tariff_name: string | null;
period_days: number | null;
}
// API
export const giftApi = {
getConfig: async (): Promise<GiftConfig> => {
const { data } = await apiClient.get<GiftConfig>('/cabinet/gift/config');
return data;
},
createPurchase: async (request: GiftPurchaseRequest): Promise<GiftPurchaseResponse> => {
const { data } = await apiClient.post<GiftPurchaseResponse>('/cabinet/gift/purchase', request);
return data;
},
getPurchaseStatus: async (token: string): Promise<GiftPurchaseStatus> => {
const { data } = await apiClient.get<GiftPurchaseStatus>(`/cabinet/gift/purchase/${token}`);
return data;
},
getPendingGifts: async (): Promise<PendingGift[]> => {
const { data } = await apiClient.get<PendingGift[]>('/cabinet/gift/pending');
return data;
},
getSentGifts: async (): Promise<SentGift[]> => {
const { data } = await apiClient.get<SentGift[]>('/cabinet/gift/sent');
return data;
},
getReceivedGifts: async (): Promise<ReceivedGift[]> => {
const { data } = await apiClient.get<ReceivedGift[]>('/cabinet/gift/received');
return data;
},
activateGiftCode: async (code: string): Promise<ActivateGiftResponse> => {
const { data } = await apiClient.post<ActivateGiftResponse>('/cabinet/gift/activate', { code });
return data;
},
};

404
src/api/landings.ts Normal file
View File

@@ -0,0 +1,404 @@
import apiClient from './client';
import type { AnimationConfig } from '@/components/ui/backgrounds/types';
// ============================================================
// Public types
// ============================================================
export interface LandingFeature {
icon: string;
title: string;
description: string;
}
export interface LandingTariffPeriod {
days: number;
label: string;
price_kopeks: number;
price_label: string;
original_price_kopeks: number | null;
original_price_label: string | null;
discount_percent: number | null;
}
export interface LandingTariff {
id: number;
name: string;
description: string | null;
traffic_limit_gb: number;
device_limit: number;
tier_level: number;
periods: LandingTariffPeriod[];
}
export interface LandingPaymentMethodSubOption {
id: string;
name: string;
}
/** Payment method as returned by the public landing config API */
export interface LandingPaymentMethod {
method_id: string;
display_name: string;
description: string | null;
icon_url: string | null;
sort_order: number;
min_amount_kopeks: number | null;
max_amount_kopeks: number | null;
currency: string | null;
sub_options: LandingPaymentMethodSubOption[] | null;
}
/** Payment method as stored/returned by the admin landing API (sub_options is a dict) */
export interface AdminLandingPaymentMethod {
method_id: string;
display_name: string;
description: string | null;
icon_url: string | null;
sort_order: number;
min_amount_kopeks: number | null;
max_amount_kopeks: number | null;
currency: string | null;
return_url: string | null;
sub_options: Record<string, boolean> | null;
}
/** Editable fields on a payment method in the landing editor */
export type EditableMethodField =
| 'display_name'
| 'description'
| 'icon_url'
| 'min_amount_kopeks'
| 'max_amount_kopeks'
| 'currency'
| 'return_url';
export interface LandingDiscountInfo {
percent: number;
ends_at: string; // ISO datetime
badge_text: string | null;
}
export interface LandingConfig {
slug: string;
title: string;
subtitle: string | null;
features: LandingFeature[];
footer_text: string | null;
tariffs: LandingTariff[];
payment_methods: LandingPaymentMethod[];
gift_enabled: boolean;
custom_css: string | null;
meta_title: string | null;
meta_description: string | null;
discount: LandingDiscountInfo | null;
background_config: AnimationConfig | null;
}
export interface PurchaseRequest {
tariff_id: number;
period_days: number;
contact_type: 'email' | 'telegram';
contact_value: string;
payment_method: string;
is_gift: boolean;
gift_recipient_type?: 'email' | 'telegram';
gift_recipient_value?: string;
gift_message?: string;
}
export interface PurchaseResponse {
purchase_token: string;
payment_url: string;
}
export interface PurchaseStatus {
status: 'pending' | 'paid' | 'delivered' | 'pending_activation' | 'failed' | 'expired';
subscription_url: string | null;
subscription_crypto_link: string | null;
is_gift: boolean;
contact_value: string | null;
recipient_contact_value: string | null;
period_days: number | null;
tariff_name: string | null;
gift_message: string | null;
contact_type: 'email' | 'telegram' | null;
cabinet_email: string | null;
cabinet_password: string | null;
auto_login_token: string | null;
recipient_in_bot: boolean | null;
bot_link: string | null;
}
// ============================================================
// Locale helpers
// ============================================================
/** Locale dict for multi-language text fields (admin API) */
export type LocaleDict = Record<string, string>;
/** Supported locales for the admin editor */
export const SUPPORTED_LOCALES = ['ru', 'en', 'zh', 'fa'] as const;
export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number];
export const LOCALE_META: Record<SupportedLocale, { flag: string; name: string; rtl: boolean }> = {
ru: { flag: '\u{1F1F7}\u{1F1FA}', name: 'RU', rtl: false },
en: { flag: '\u{1F1EC}\u{1F1E7}', name: 'EN', rtl: false },
zh: { flag: '\u{1F1E8}\u{1F1F3}', name: 'ZH', rtl: false },
fa: { flag: '\u{1F1EE}\u{1F1F7}', name: 'FA', rtl: true },
};
// ============================================================
// Admin types
// ============================================================
/** Admin feature type with localized title/description */
export interface AdminLandingFeature {
icon: string;
title: LocaleDict;
description: LocaleDict;
}
export interface LandingListItem {
id: number;
slug: string;
title: LocaleDict;
is_active: boolean;
display_order: number;
gift_enabled: boolean;
tariff_count: number;
method_count: number;
purchase_stats: {
total: number;
pending: number;
paid: number;
delivered: number;
pending_activation: number;
failed: number;
expired: number;
};
created_at: string | null;
updated_at: string | null;
has_active_discount: boolean;
}
export interface LandingDetail {
id: number;
slug: string;
title: LocaleDict;
subtitle: LocaleDict | null;
is_active: boolean;
features: AdminLandingFeature[];
footer_text: LocaleDict | null;
allowed_tariff_ids: number[];
allowed_periods: Record<string, number[]>;
payment_methods: AdminLandingPaymentMethod[];
gift_enabled: boolean;
custom_css: string | null;
meta_title: LocaleDict | null;
meta_description: LocaleDict | null;
display_order: number;
created_at: string | null;
updated_at: string | null;
discount_percent: number | null;
discount_overrides: Record<string, number> | null;
discount_starts_at: string | null;
discount_ends_at: string | null;
discount_badge_text: LocaleDict | null;
background_config: AnimationConfig | null;
}
export interface LandingCreateRequest {
slug: string;
title: LocaleDict;
subtitle?: LocaleDict;
is_active?: boolean;
features?: AdminLandingFeature[];
footer_text?: LocaleDict;
allowed_tariff_ids?: number[];
allowed_periods?: Record<string, number[]>;
payment_methods?: AdminLandingPaymentMethod[];
gift_enabled?: boolean;
custom_css?: string;
meta_title?: LocaleDict;
meta_description?: LocaleDict;
discount_percent?: number | null;
discount_overrides?: Record<string, number> | null;
discount_starts_at?: string | null;
discount_ends_at?: string | null;
discount_badge_text?: LocaleDict | null;
background_config?: AnimationConfig | null;
}
export type LandingUpdateRequest = Partial<LandingCreateRequest>;
/**
* Normalize a value that might be a plain string (old API) or a LocaleDict.
* If it's a string, wraps it as `{ ru: value }`.
* If null/undefined, returns the fallback.
*/
/** Extract best display string from a LocaleDict: ru → en → first available → '' */
export function resolveLocaleDisplay(dict: LocaleDict | string | null | undefined): string {
if (!dict) return '';
if (typeof dict === 'string') return dict;
return dict.ru || dict.en || Object.values(dict).find((v) => v?.trim()) || '';
}
export function toLocaleDict(
value: string | LocaleDict | null | undefined,
fallback: LocaleDict = {},
): LocaleDict {
if (value === null || value === undefined) return fallback;
if (typeof value === 'string') return value ? { ru: value } : fallback;
return value;
}
// ============================================================
// Public API
// ============================================================
export const landingApi = {
getConfig: async (slug: string, lang?: string): Promise<LandingConfig> => {
const params = lang ? `?lang=${lang}` : '';
const response = await apiClient.get(`/cabinet/landing/${slug}${params}`);
return response.data;
},
createPurchase: async (slug: string, data: PurchaseRequest): Promise<PurchaseResponse> => {
const response = await apiClient.post(`/cabinet/landing/${slug}/purchase`, data);
return response.data;
},
getPurchaseStatus: async (token: string): Promise<PurchaseStatus> => {
const response = await apiClient.get(`/cabinet/landing/purchase/${token}`);
return response.data;
},
activatePurchase: async (token: string): Promise<PurchaseStatus> => {
const response = await apiClient.post(`/cabinet/landing/activate/${token}`);
return response.data;
},
};
// ============================================================
// Admin stats types
// ============================================================
export interface LandingDailyStat {
date: string;
purchases: number;
revenue_kopeks: number;
gifts: number;
}
export interface LandingTariffStat {
tariff_id: number | null;
tariff_name: string;
purchases: number;
revenue_kopeks: number;
}
export interface LandingStatsResponse {
total_purchases: number;
total_revenue_kopeks: number;
total_gifts: number;
total_regular: number;
avg_purchase_kopeks: number;
total_created: number;
total_successful: number;
conversion_rate: number;
daily_stats: LandingDailyStat[];
tariff_stats: LandingTariffStat[];
}
// ============================================================
// Admin purchase list types
// ============================================================
export type PurchaseItemStatus =
| 'pending'
| 'paid'
| 'delivered'
| 'pending_activation'
| 'failed'
| 'expired';
export interface LandingPurchaseItem {
id: number;
token: string;
contact_type: 'email' | 'telegram';
contact_value: string;
is_gift: boolean;
gift_recipient_type: 'email' | 'telegram' | null;
gift_recipient_value: string | null;
tariff_name: string;
period_days: number;
amount_kopeks: number;
currency: string;
payment_method: string;
status: PurchaseItemStatus;
created_at: string;
paid_at: string | null;
}
export interface LandingPurchaseListResponse {
items: LandingPurchaseItem[];
total: number;
}
// ============================================================
// Admin API
// ============================================================
export const adminLandingsApi = {
list: async (): Promise<LandingListItem[]> => {
const response = await apiClient.get('/cabinet/admin/landings');
return response.data;
},
get: async (id: number): Promise<LandingDetail> => {
const response = await apiClient.get(`/cabinet/admin/landings/${id}`);
return response.data;
},
create: async (data: LandingCreateRequest): Promise<LandingDetail> => {
const response = await apiClient.post('/cabinet/admin/landings', data);
return response.data;
},
update: async (id: number, data: LandingUpdateRequest): Promise<LandingDetail> => {
const response = await apiClient.put(`/cabinet/admin/landings/${id}`, data);
return response.data;
},
delete: async (id: number): Promise<{ success: boolean }> => {
const response = await apiClient.delete(`/cabinet/admin/landings/${id}`);
return response.data;
},
toggle: async (id: number): Promise<LandingDetail> => {
const response = await apiClient.post(`/cabinet/admin/landings/${id}/toggle`);
return response.data;
},
reorder: async (landingIds: number[]): Promise<void> => {
await apiClient.put('/cabinet/admin/landings/order', { landing_ids: landingIds });
},
getStats: async (id: number): Promise<LandingStatsResponse> => {
const response = await apiClient.get(`/cabinet/admin/landings/${id}/stats`);
return response.data;
},
getPurchases: async (
id: number,
offset: number,
limit: number,
status?: PurchaseItemStatus,
): Promise<LandingPurchaseListResponse> => {
const params: Record<string, string | number> = { offset, limit };
if (status) params.status = status;
const response = await apiClient.get(`/cabinet/admin/landings/${id}/purchases`, { params });
return response.data;
},
};

92
src/api/menuLayout.ts Normal file
View File

@@ -0,0 +1,92 @@
import apiClient from './client';
export type OpenIn = 'external' | 'webapp';
export interface MenuButtonConfig {
id: string;
type: 'builtin' | 'custom';
style: 'primary' | 'success' | 'danger' | 'default';
icon_custom_emoji_id: string;
enabled: boolean;
labels: Record<string, string>;
url: string | null;
open_in: OpenIn;
}
export interface MenuRowConfig {
id: string;
max_per_row: number;
buttons: MenuButtonConfig[];
}
export interface MenuConfig {
rows: MenuRowConfig[];
}
export const BOT_LOCALES = ['ru', 'en', 'ua', 'zh', 'fa'] as const;
export type BotLocale = (typeof BOT_LOCALES)[number];
export const BUILTIN_SECTIONS = [
'home',
'subscription',
'balance',
'referral',
'support',
'info',
'admin',
'language',
] as const;
export type BuiltinSection = (typeof BUILTIN_SECTIONS)[number];
export const STYLE_OPTIONS = [
{ value: 'default' as const, colorClass: 'bg-dark-500' },
{ value: 'primary' as const, colorClass: 'bg-blue-500' },
{ value: 'success' as const, colorClass: 'bg-success-500' },
{ value: 'danger' as const, colorClass: 'bg-red-500' },
];
const DEFAULT_CONFIG: MenuConfig = { rows: [] };
const DEFAULT_BUTTON: Omit<MenuButtonConfig, 'id' | 'type'> = {
style: 'primary',
icon_custom_emoji_id: '',
enabled: true,
labels: {},
url: null,
open_in: 'external',
};
function normalizeConfig(data: MenuConfig): MenuConfig {
if (!data || typeof data !== 'object') {
return DEFAULT_CONFIG;
}
return {
rows: (data.rows || []).map((row) => ({
id: row.id,
max_per_row: row.max_per_row ?? 2,
buttons: (row.buttons || []).map((btn) => ({
...DEFAULT_BUTTON,
...btn,
labels: { ...(btn.labels || {}) },
})),
})),
};
}
export const menuLayoutApi = {
getConfig: async (): Promise<MenuConfig> => {
const response = await apiClient.get<MenuConfig>('/cabinet/admin/menu-layout');
return normalizeConfig(response.data);
},
updateConfig: async (config: MenuConfig): Promise<MenuConfig> => {
const response = await apiClient.put<MenuConfig>('/cabinet/admin/menu-layout', config);
return normalizeConfig(response.data);
},
resetConfig: async (): Promise<MenuConfig> => {
const response = await apiClient.post<MenuConfig>('/cabinet/admin/menu-layout/reset');
return normalizeConfig(response.data);
},
};

View File

@@ -49,6 +49,16 @@ export interface UserRoleAssignment {
user_telegram_id: number | null;
}
export interface AdminWithRoles {
user_id: number;
telegram_id: number | null;
username: string | null;
first_name: string | null;
last_name: string | null;
email: string | null;
role_names: string[];
}
export interface AssignRolePayload {
user_id: number;
role_id: number;
@@ -167,6 +177,13 @@ export const rbacApi = {
return response.data;
},
// --- RBAC Users ---
getRbacUsers: async (): Promise<AdminWithRoles[]> => {
const response = await apiClient.get<AdminWithRoles[]>(`${BASE}/users`);
return response.data;
},
// --- Role Users ---
getRoleUsers: async (roleId: number): Promise<UserRoleAssignment[]> => {

View File

@@ -85,6 +85,8 @@ export interface TariffDetail {
daily_price_kopeks: number;
// Режим сброса трафика
traffic_reset_mode: string | null; // 'DAY', 'WEEK', 'MONTH', 'NO_RESET', null = глобальная настройка
// Внешний сквад RemnaWave
external_squad_uuid: string | null;
created_at: string;
updated_at: string | null;
}
@@ -121,6 +123,14 @@ export interface TariffCreateRequest {
daily_price_kopeks?: number;
// Режим сброса трафика
traffic_reset_mode?: string | null;
// Внешний сквад RemnaWave
external_squad_uuid?: string | null;
}
export interface ExternalSquadInfo {
uuid: string;
name: string;
members_count: number;
}
export interface TariffUpdateRequest {
@@ -156,6 +166,8 @@ export interface TariffUpdateRequest {
daily_price_kopeks?: number;
// Режим сброса трафика
traffic_reset_mode?: string | null;
// Внешний сквад RemnaWave
external_squad_uuid?: string | null;
}
export interface TariffToggleResponse {
@@ -249,4 +261,10 @@ export const tariffsApi = {
const response = await apiClient.get('/cabinet/admin/payment-methods/promo-groups');
return response.data;
},
// Get available external squads from RemnaWave
getAvailableExternalSquads: async (): Promise<ExternalSquadInfo[]> => {
const response = await apiClient.get('/cabinet/admin/tariffs/available-external-squads');
return response.data;
},
};

View File

@@ -116,6 +116,8 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
queryClient.invalidateQueries({ queryKey: ['promo-offers'] });
queryClient.invalidateQueries({ queryKey: ['active-discount'] });
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
setSuccessMessage(result.message);
setClaimingId(null);
setTimeout(() => setSuccessMessage(null), 5000);

View File

@@ -1,42 +1,162 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { brandingApi, type TelegramWidgetConfig } from '../api/branding';
import { useAuthStore } from '../store/auth';
import { useNavigate } from 'react-router';
interface TelegramLoginButtonProps {
botUsername: string;
referralCode?: string;
}
export default function TelegramLoginButton({
botUsername,
referralCode,
}: TelegramLoginButtonProps) {
export default function TelegramLoginButton({ referralCode }: TelegramLoginButtonProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const containerRef = useRef<HTMLDivElement>(null);
const [oidcLoading, setOidcLoading] = useState(false);
const [oidcError, setOidcError] = useState('');
const [scriptLoaded, setScriptLoaded] = useState(false);
const loginWithTelegramOIDC = useAuthStore((s) => s.loginWithTelegramOIDC);
const { data: widgetConfig } = useQuery<TelegramWidgetConfig>({
queryKey: ['telegram-widget-config'],
queryFn: brandingApi.getTelegramWidgetConfig,
staleTime: 60000,
});
const botUsername =
widgetConfig?.bot_username || import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '';
const isOIDC = Boolean(widgetConfig?.oidc_enabled && widgetConfig?.oidc_client_id);
// OIDC callback handler (ref pattern to avoid stale closures and unnecessary re-inits)
const handleOIDCCallbackRef =
useRef<(data: { id_token?: string; error?: string }) => void>(undefined);
const mountedRef = useRef(true);
// Load widget script
useEffect(() => {
if (!containerRef.current || !botUsername) return;
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
// Clear previous widget using safe DOM API
while (containerRef.current.firstChild) {
containerRef.current.removeChild(containerRef.current.firstChild);
handleOIDCCallbackRef.current = async (data: { id_token?: string; error?: string }) => {
if (!mountedRef.current) return;
if (data.error || !data.id_token) {
setOidcError(data.error || t('auth.loginFailed'));
setOidcLoading(false);
return;
}
try {
setOidcLoading(true);
setOidcError('');
await loginWithTelegramOIDC(data.id_token);
if (mountedRef.current) navigate('/');
} catch (err: unknown) {
if (!mountedRef.current) return;
let message = t('common.error');
if (err && typeof err === 'object' && 'response' in err) {
const resp = (err as { response?: { data?: { detail?: string } } }).response;
if (resp?.data?.detail) message = resp.data.detail;
}
setOidcError(message);
} finally {
if (mountedRef.current) setOidcLoading(false);
}
};
// Load OIDC script and init
useEffect(() => {
if (!isOIDC || !widgetConfig?.oidc_client_id) return;
const scriptId = 'telegram-login-oidc-script';
let script = document.getElementById(scriptId) as HTMLScriptElement | null;
const initTelegramLogin = () => {
if (window.Telegram?.Login) {
window.Telegram.Login.init(
{
client_id: Number(widgetConfig.oidc_client_id) || widgetConfig.oidc_client_id,
request_access: widgetConfig.request_access ? ['write'] : undefined,
lang: document.documentElement.lang || 'en',
},
(data) => handleOIDCCallbackRef.current?.(data),
);
}
};
if (!script) {
script = document.createElement('script');
script.id = scriptId;
script.src = 'https://oauth.telegram.org/js/telegram-login.js?3';
script.async = true;
script.onload = () => {
setScriptLoaded(true);
initTelegramLogin();
};
script.onerror = () => {
setOidcError(t('auth.loginFailed'));
};
document.head.appendChild(script);
} else {
// Script already loaded, just re-init
setScriptLoaded(true);
initTelegramLogin();
}
}, [isOIDC, widgetConfig?.oidc_client_id, widgetConfig?.request_access]);
// Legacy widget effect (only when NOT OIDC)
const loginWithTelegramWidget = useAuthStore((s) => s.loginWithTelegramWidget);
useEffect(() => {
if (isOIDC || !containerRef.current || !botUsername || !widgetConfig) return;
const container = containerRef.current;
while (container.firstChild) {
container.removeChild(container.firstChild);
}
// Get current URL for redirect
const redirectUrl = `${window.location.origin}/auth/telegram/callback`;
const callbackName = '__onTelegramWidgetAuth';
(window as unknown as Record<string, unknown>)[callbackName] = async (
user: Record<string, unknown>,
) => {
try {
await loginWithTelegramWidget({
id: user.id as number,
first_name: user.first_name as string,
last_name: (user.last_name as string) || undefined,
username: (user.username as string) || undefined,
photo_url: (user.photo_url as string) || undefined,
auth_date: user.auth_date as number,
hash: user.hash as string,
});
navigate('/');
} catch {
// Error handled by auth store
}
};
// Create script element for Telegram Login Widget
const script = document.createElement('script');
script.src = 'https://telegram.org/js/telegram-widget.js?22';
script.src = 'https://telegram.org/js/telegram-widget.js?23';
script.setAttribute('data-telegram-login', botUsername);
script.setAttribute('data-size', 'large');
script.setAttribute('data-radius', '8');
script.setAttribute('data-auth-url', redirectUrl);
script.setAttribute('data-request-access', 'write');
script.setAttribute('data-size', widgetConfig.size);
script.setAttribute('data-radius', String(widgetConfig.radius));
script.setAttribute('data-userpic', String(widgetConfig.userpic));
script.setAttribute('data-onauth', `${callbackName}(user)`);
if (widgetConfig.request_access) {
script.setAttribute('data-request-access', 'write');
}
script.async = true;
containerRef.current.appendChild(script);
}, [botUsername]);
container.appendChild(script);
return () => {
delete (window as unknown as Record<string, unknown>)[callbackName];
while (container.firstChild) {
container.removeChild(container.firstChild);
}
};
}, [isOIDC, botUsername, widgetConfig, loginWithTelegramWidget, navigate]);
if (!botUsername || botUsername === 'your_bot') {
return (
@@ -48,10 +168,35 @@ export default function TelegramLoginButton({
return (
<div className="flex flex-col items-center space-y-4">
{/* Telegram Widget will be inserted here */}
<div ref={containerRef} className="flex justify-center" />
{/* OIDC mode: custom button that opens popup */}
{isOIDC ? (
<div className="flex flex-col items-center space-y-2">
<button
type="button"
onClick={() => {
setOidcError('');
setOidcLoading(true);
if (window.Telegram?.Login) {
window.Telegram.Login.open();
} else {
setOidcLoading(false);
}
}}
disabled={oidcLoading || !scriptLoaded}
className="inline-flex items-center gap-2 rounded-lg bg-[#54a9eb] px-6 py-3 text-sm font-medium text-white shadow-sm transition-colors hover:bg-[#4a96d2] disabled:opacity-50"
>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
</svg>
{oidcLoading ? t('common.loading') : t('auth.loginWithTelegram')}
</button>
{oidcError && <p className="text-xs text-red-500">{oidcError}</p>}
</div>
) : (
/* Legacy widget mode: iframe-based widget */
<div ref={containerRef} className="flex justify-center" />
)}
{/* Fallback link for mobile */}
<div className="text-center">
<p className="mb-2 text-xs text-gray-500">{t('auth.orOpenInApp')}</p>
<a

View File

@@ -41,6 +41,7 @@ export default function WebSocketNotifications() {
});
// Refresh data
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
queryClient.invalidateQueries({ queryKey: ['transactions'] });
refreshUser();
return;
@@ -67,6 +68,7 @@ export default function WebSocketNotifications() {
duration: 5000,
});
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
queryClient.invalidateQueries({ queryKey: ['transactions'] });
refreshUser();
return;
@@ -82,6 +84,7 @@ export default function WebSocketNotifications() {
});
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
refreshUser();
return;
}
@@ -95,6 +98,7 @@ export default function WebSocketNotifications() {
});
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
refreshUser();
return;
}
@@ -151,6 +155,7 @@ export default function WebSocketNotifications() {
duration: 5000,
});
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
refreshUser();
return;
}
@@ -180,6 +185,7 @@ export default function WebSocketNotifications() {
});
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
queryClient.invalidateQueries({ queryKey: ['transactions'] });
refreshUser();
return;
@@ -195,6 +201,7 @@ export default function WebSocketNotifications() {
});
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
queryClient.invalidateQueries({ queryKey: ['transactions'] });
refreshUser();
return;
@@ -220,6 +227,7 @@ export default function WebSocketNotifications() {
});
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
refreshUser();
return;
}
@@ -329,6 +337,7 @@ export default function WebSocketNotifications() {
duration: 8000,
});
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
queryClient.invalidateQueries({ queryKey: ['referral-stats'] });
refreshUser();
return;
@@ -375,6 +384,7 @@ export default function WebSocketNotifications() {
duration: 6000,
});
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
queryClient.invalidateQueries({ queryKey: ['transactions'] });
refreshUser();
return;

View File

@@ -4,6 +4,7 @@ import { BackIcon } from './icons';
interface AdminBackButtonProps {
to?: string;
replace?: boolean;
className?: string;
}
@@ -11,7 +12,7 @@ interface AdminBackButtonProps {
* Back button for admin pages.
* Hidden in Telegram Mini App since native back button is used instead.
*/
export function AdminBackButton({ to = '/admin', className }: AdminBackButtonProps) {
export function AdminBackButton({ to = '/admin', replace, className }: AdminBackButtonProps) {
const { platform } = usePlatform();
// In Telegram Mini App, we use native back button
@@ -22,6 +23,7 @@ export function AdminBackButton({ to = '/admin', className }: AdminBackButtonPro
return (
<Link
to={to}
replace={replace}
className={
className ||
'flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600'

View File

@@ -0,0 +1,304 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { backgroundRegistry } from '@/components/ui/backgrounds/registry';
import { BackgroundPreview } from '@/components/backgrounds/BackgroundPreview';
import type {
AnimationConfig,
BackgroundType,
SettingDefinition,
} from '@/components/ui/backgrounds/types';
import { Toggle } from './Toggle';
import { cn } from '@/lib/utils';
function SettingField({
def,
value,
onChange,
t,
}: {
def: SettingDefinition;
value: unknown;
onChange: (val: unknown) => void;
t: (key: string) => string;
}) {
if (def.type === 'number') {
const numVal = (value as number) ?? (def.default as number);
const displayVal = numVal < 0.01 ? numVal.toExponential(1) : String(numVal);
return (
<div className="flex items-center justify-between gap-4">
<label className="text-sm text-dark-300">{t(def.label)}</label>
<div className="flex items-center gap-2">
<input
type="range"
min={def.min}
max={def.max}
step={def.step}
value={numVal}
onChange={(e) => onChange(parseFloat(e.target.value))}
className="w-24 accent-accent-500"
/>
<span className="w-16 text-right text-xs tabular-nums text-dark-400">{displayVal}</span>
</div>
</div>
);
}
if (def.type === 'color') {
const colorVal = (value as string) ?? (def.default as string);
const hexForInput = /^#[0-9a-fA-F]{3,8}$/.test(colorVal) ? colorVal : '#818cf8';
return (
<div className="flex items-center justify-between gap-4">
<label className="text-sm text-dark-300">{t(def.label)}</label>
<div className="flex items-center gap-2">
<input
type="color"
value={hexForInput}
onChange={(e) => onChange(e.target.value)}
className="h-7 w-10 cursor-pointer rounded border border-dark-600 bg-transparent"
/>
<span className="w-16 text-right text-xs text-dark-400">{colorVal}</span>
</div>
</div>
);
}
if (def.type === 'boolean') {
const boolVal = (value as boolean) ?? (def.default as boolean);
return (
<div className="flex items-center justify-between gap-4">
<label className="text-sm text-dark-300">{t(def.label)}</label>
<Toggle checked={boolVal} onChange={() => onChange(!boolVal)} />
</div>
);
}
if (def.type === 'select' && def.options) {
const selectVal = (value as string) ?? (def.default as string);
return (
<div className="flex items-center justify-between gap-4">
<label className="text-sm text-dark-300">{t(def.label)}</label>
<select
value={selectVal}
onChange={(e) => onChange(e.target.value)}
className="rounded-lg border border-dark-600 bg-dark-700 px-3 py-1.5 text-sm text-dark-200 focus:border-accent-500 focus:outline-none"
>
{def.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label.startsWith('admin.') ? t(opt.label) : opt.label}
</option>
))}
</select>
</div>
);
}
return null;
}
interface BackgroundConfigEditorProps {
value: AnimationConfig;
onChange: (config: AnimationConfig) => void;
}
export function BackgroundConfigEditor({ value: config, onChange }: BackgroundConfigEditorProps) {
const { t } = useTranslation();
const updateConfig = (patch: Partial<AnimationConfig>) => {
onChange({ ...config, ...patch });
};
const updateSetting = (key: string, val: unknown) => {
onChange({ ...config, settings: { ...config.settings, [key]: val } });
};
const handleTypeChange = (type: BackgroundType) => {
const def = backgroundRegistry.find((d) => d.type === type);
const defaults: Record<string, unknown> = {};
if (def) {
for (const s of def.settings) {
defaults[s.key] = s.default;
}
}
onChange({ ...config, type, settings: defaults });
};
const currentDef = useMemo(
() => backgroundRegistry.find((d) => d.type === config.type),
[config.type],
);
const categories = useMemo(() => {
const cats = new Map<string, typeof backgroundRegistry>();
for (const def of backgroundRegistry) {
const list = cats.get(def.category) ?? [];
list.push(def);
cats.set(def.category, list);
}
return cats;
}, []);
return (
<div className="space-y-6">
{/* Header with enable toggle */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-dark-100">{t('admin.backgrounds.title')}</h3>
<p className="text-sm text-dark-400">{t('admin.backgrounds.description')}</p>
</div>
<Toggle
checked={config.enabled}
onChange={() => updateConfig({ enabled: !config.enabled })}
/>
</div>
{config.enabled && (
<>
{/* Preview */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.backgrounds.preview')}
</label>
<BackgroundPreview
type={config.type}
settings={config.settings}
opacity={config.opacity}
blur={config.blur}
className="h-48"
/>
</div>
{/* Type selector gallery */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.backgrounds.selectType')}
</label>
{/* None option */}
<button
onClick={() => handleTypeChange('none')}
className={cn(
'mb-3 w-full rounded-xl border p-3 text-left transition-colors',
config.type === 'none'
? 'border-accent-500 bg-accent-500/10'
: 'border-dark-700/50 bg-dark-800/30 hover:border-dark-600',
)}
>
<span className="text-sm font-medium text-dark-200">
{t('admin.backgrounds.none')}
</span>
<span className="ml-2 text-xs text-dark-400">{t('admin.backgrounds.noneDesc')}</span>
</button>
{/* Background types by category */}
<div className="space-y-4">
{Array.from(categories.entries()).map(([category, defs]) => (
<div key={category}>
<span className="mb-2 block text-xs font-medium uppercase tracking-wider text-dark-500">
{t(`admin.backgrounds.category${category.toUpperCase()}`)}
</span>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{defs.map((def) => (
<button
key={def.type}
onClick={() => handleTypeChange(def.type)}
className={cn(
'rounded-xl border p-3 text-left transition-colors',
config.type === def.type
? 'border-accent-500 bg-accent-500/10'
: 'border-dark-700/50 bg-dark-800/30 hover:border-dark-600',
)}
>
<span className="block text-sm font-medium text-dark-200">
{t(def.labelKey)}
</span>
<span className="block text-xs text-dark-400">{t(def.descriptionKey)}</span>
</button>
))}
</div>
</div>
))}
</div>
</div>
{/* Per-type settings */}
{currentDef && currentDef.settings.length > 0 && (
<div className="rounded-xl border border-dark-700/50 bg-dark-800/30 p-4">
<h4 className="mb-3 text-sm font-medium text-dark-200">
{t('admin.backgrounds.settings')}
</h4>
<div className="space-y-3">
{currentDef.settings.map((def) => (
<SettingField
key={def.key}
def={def}
value={config.settings[def.key]}
onChange={(val) => updateSetting(def.key, val)}
t={t}
/>
))}
</div>
</div>
)}
{/* Global settings */}
<div className="rounded-xl border border-dark-700/50 bg-dark-800/30 p-4">
<div className="space-y-3">
<div className="flex items-center justify-between gap-4">
<label className="text-sm text-dark-300">
{t('admin.backgrounds.globalOpacity')}
</label>
<div className="flex items-center gap-2">
<input
type="range"
min={0.1}
max={1}
step={0.05}
value={config.opacity}
onChange={(e) => updateConfig({ opacity: parseFloat(e.target.value) })}
className="w-24 accent-accent-500"
/>
<span className="w-14 text-right text-xs tabular-nums text-dark-400">
{config.opacity}
</span>
</div>
</div>
<div className="flex items-center justify-between gap-4">
<label className="text-sm text-dark-300">{t('admin.backgrounds.globalBlur')}</label>
<div className="flex items-center gap-2">
<input
type="range"
min={0}
max={20}
step={1}
value={config.blur}
onChange={(e) => updateConfig({ blur: Number(e.target.value) })}
className="w-24 accent-accent-500"
/>
<span className="w-14 text-right text-xs tabular-nums text-dark-400">
{config.blur}px
</span>
</div>
</div>
<div className="flex items-center justify-between gap-4">
<div>
<label className="text-sm text-dark-300">
{t('admin.backgrounds.reducedOnMobile')}
</label>
<p className="text-xs text-dark-500">
{t('admin.backgrounds.reducedOnMobileDesc')}
</p>
</div>
<Toggle
checked={config.reducedOnMobile}
onChange={() => updateConfig({ reducedOnMobile: !config.reducedOnMobile })}
/>
</div>
</div>
</div>
</>
)}
</div>
);
}

View File

@@ -1,111 +1,18 @@
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { useState, useCallback, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { brandingApi } from '@/api/branding';
import { backgroundRegistry } from '@/components/ui/backgrounds/registry';
import { BackgroundPreview } from '@/components/backgrounds/BackgroundPreview';
import { setCachedAnimationConfig } from '@/components/backgrounds/BackgroundRenderer';
import type {
AnimationConfig,
BackgroundType,
SettingDefinition,
} from '@/components/ui/backgrounds/types';
import type { AnimationConfig } from '@/components/ui/backgrounds/types';
import { DEFAULT_ANIMATION_CONFIG } from '@/components/ui/backgrounds/types';
import { Toggle } from './Toggle';
import { BackgroundConfigEditor } from './BackgroundConfigEditor';
import { cn } from '@/lib/utils';
function SettingField({
def,
value,
onChange,
t,
}: {
def: SettingDefinition;
value: unknown;
onChange: (val: unknown) => void;
t: (key: string) => string;
}) {
if (def.type === 'number') {
const numVal = (value as number) ?? (def.default as number);
const displayVal = numVal < 0.01 ? numVal.toExponential(1) : String(numVal);
return (
<div className="flex items-center justify-between gap-4">
<label className="text-sm text-dark-300">{t(def.label)}</label>
<div className="flex items-center gap-2">
<input
type="range"
min={def.min}
max={def.max}
step={def.step}
value={numVal}
onChange={(e) => onChange(parseFloat(e.target.value))}
className="w-24 accent-accent-500"
/>
<span className="w-16 text-right text-xs tabular-nums text-dark-400">{displayVal}</span>
</div>
</div>
);
}
if (def.type === 'color') {
const colorVal = (value as string) ?? (def.default as string);
// HTML color input only supports hex — for rgba defaults, show a neutral hex
const hexForInput = /^#[0-9a-fA-F]{3,8}$/.test(colorVal) ? colorVal : '#818cf8';
return (
<div className="flex items-center justify-between gap-4">
<label className="text-sm text-dark-300">{t(def.label)}</label>
<div className="flex items-center gap-2">
<input
type="color"
value={hexForInput}
onChange={(e) => onChange(e.target.value)}
className="h-7 w-10 cursor-pointer rounded border border-dark-600 bg-transparent"
/>
<span className="w-16 text-right text-xs text-dark-400">{colorVal}</span>
</div>
</div>
);
}
if (def.type === 'boolean') {
const boolVal = (value as boolean) ?? (def.default as boolean);
return (
<div className="flex items-center justify-between gap-4">
<label className="text-sm text-dark-300">{t(def.label)}</label>
<Toggle checked={boolVal} onChange={() => onChange(!boolVal)} />
</div>
);
}
if (def.type === 'select' && def.options) {
const selectVal = (value as string) ?? (def.default as string);
return (
<div className="flex items-center justify-between gap-4">
<label className="text-sm text-dark-300">{t(def.label)}</label>
<select
value={selectVal}
onChange={(e) => onChange(e.target.value)}
className="rounded-lg border border-dark-600 bg-dark-700 px-3 py-1.5 text-sm text-dark-200 focus:border-accent-500 focus:outline-none"
>
{def.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label.startsWith('admin.') ? t(opt.label) : opt.label}
</option>
))}
</select>
</div>
);
}
return null;
}
export function BackgroundEditor() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const saveTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
// Clear save timer on unmount
useEffect(() => {
return () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
@@ -137,40 +44,9 @@ export function BackgroundEditor() {
onError: () => setSaveStatus('idle'),
});
const updateConfig = useCallback(
(patch: Partial<AnimationConfig>) => {
setLocalConfig((prev) => ({
...(prev ?? serverConfig ?? DEFAULT_ANIMATION_CONFIG),
...patch,
}));
},
[serverConfig],
);
// Use functional updater to avoid stale closure when rapidly changing settings
const updateSetting = useCallback(
(key: string, value: unknown) => {
setLocalConfig((prev) => {
const base = prev ?? serverConfig ?? DEFAULT_ANIMATION_CONFIG;
return { ...base, settings: { ...base.settings, [key]: value } };
});
},
[serverConfig],
);
const handleTypeChange = useCallback(
(type: BackgroundType) => {
const def = backgroundRegistry.find((d) => d.type === type);
const defaults: Record<string, unknown> = {};
if (def) {
for (const s of def.settings) {
defaults[s.key] = s.default;
}
}
updateConfig({ type, settings: defaults });
},
[updateConfig],
);
const handleChange = useCallback((newConfig: AnimationConfig) => {
setLocalConfig(newConfig);
}, []);
const handleSave = () => {
saveMutation.mutate(config);
@@ -179,183 +55,9 @@ export function BackgroundEditor() {
const isDirty = localConfig !== null;
const showSaveButton = isDirty || saveStatus === 'saved' || saveStatus === 'saving';
const currentDef = useMemo(
() => backgroundRegistry.find((d) => d.type === config.type),
[config.type],
);
const categories = useMemo(() => {
const cats = new Map<string, typeof backgroundRegistry>();
for (const def of backgroundRegistry) {
const list = cats.get(def.category) ?? [];
list.push(def);
cats.set(def.category, list);
}
return cats;
}, []);
return (
<div className="space-y-6">
{/* Header with enable toggle */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-dark-100">{t('admin.backgrounds.title')}</h3>
<p className="text-sm text-dark-400">{t('admin.backgrounds.description')}</p>
</div>
<Toggle
checked={config.enabled}
onChange={() => updateConfig({ enabled: !config.enabled })}
/>
</div>
{config.enabled && (
<>
{/* Preview */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.backgrounds.preview')}
</label>
<BackgroundPreview
type={config.type}
settings={config.settings}
opacity={config.opacity}
blur={config.blur}
className="h-48"
/>
</div>
{/* Type selector gallery */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.backgrounds.selectType')}
</label>
{/* None option */}
<button
onClick={() => handleTypeChange('none')}
className={cn(
'mb-3 w-full rounded-xl border p-3 text-left transition-colors',
config.type === 'none'
? 'border-accent-500 bg-accent-500/10'
: 'border-dark-700/50 bg-dark-800/30 hover:border-dark-600',
)}
>
<span className="text-sm font-medium text-dark-200">
{t('admin.backgrounds.none')}
</span>
<span className="ml-2 text-xs text-dark-400">{t('admin.backgrounds.noneDesc')}</span>
</button>
{/* Background types by category */}
<div className="space-y-4">
{Array.from(categories.entries()).map(([category, defs]) => (
<div key={category}>
<span className="mb-2 block text-xs font-medium uppercase tracking-wider text-dark-500">
{t(`admin.backgrounds.category${category.toUpperCase()}`)}
</span>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{defs.map((def) => (
<button
key={def.type}
onClick={() => handleTypeChange(def.type)}
className={cn(
'rounded-xl border p-3 text-left transition-colors',
config.type === def.type
? 'border-accent-500 bg-accent-500/10'
: 'border-dark-700/50 bg-dark-800/30 hover:border-dark-600',
)}
>
<span className="block text-sm font-medium text-dark-200">
{t(def.labelKey)}
</span>
<span className="block text-xs text-dark-400">{t(def.descriptionKey)}</span>
</button>
))}
</div>
</div>
))}
</div>
</div>
{/* Per-type settings */}
{currentDef && currentDef.settings.length > 0 && (
<div className="rounded-xl border border-dark-700/50 bg-dark-800/30 p-4">
<h4 className="mb-3 text-sm font-medium text-dark-200">
{t('admin.backgrounds.settings')}
</h4>
<div className="space-y-3">
{currentDef.settings.map((def) => (
<SettingField
key={def.key}
def={def}
value={config.settings[def.key]}
onChange={(val) => updateSetting(def.key, val)}
t={t}
/>
))}
</div>
</div>
)}
{/* Global settings */}
<div className="rounded-xl border border-dark-700/50 bg-dark-800/30 p-4">
<div className="space-y-3">
<div className="flex items-center justify-between gap-4">
<label className="text-sm text-dark-300">
{t('admin.backgrounds.globalOpacity')}
</label>
<div className="flex items-center gap-2">
<input
type="range"
min={0.1}
max={1}
step={0.05}
value={config.opacity}
onChange={(e) => updateConfig({ opacity: parseFloat(e.target.value) })}
className="w-24 accent-accent-500"
/>
<span className="w-14 text-right text-xs tabular-nums text-dark-400">
{config.opacity}
</span>
</div>
</div>
<div className="flex items-center justify-between gap-4">
<label className="text-sm text-dark-300">{t('admin.backgrounds.globalBlur')}</label>
<div className="flex items-center gap-2">
<input
type="range"
min={0}
max={20}
step={1}
value={config.blur}
onChange={(e) => updateConfig({ blur: parseInt(e.target.value) })}
className="w-24 accent-accent-500"
/>
<span className="w-14 text-right text-xs tabular-nums text-dark-400">
{config.blur}px
</span>
</div>
</div>
<div className="flex items-center justify-between gap-4">
<div>
<label className="text-sm text-dark-300">
{t('admin.backgrounds.reducedOnMobile')}
</label>
<p className="text-xs text-dark-500">
{t('admin.backgrounds.reducedOnMobileDesc')}
</p>
</div>
<Toggle
checked={config.reducedOnMobile}
onChange={() => updateConfig({ reducedOnMobile: !config.reducedOnMobile })}
/>
</div>
</div>
</div>
</>
)}
<BackgroundConfigEditor value={config} onChange={handleChange} />
{/* Save button */}
{showSaveButton && (

View File

@@ -35,6 +35,11 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
queryFn: brandingApi.getEmailAuthEnabled,
});
const { data: giftSettings } = useQuery({
queryKey: ['gift-enabled'],
queryFn: brandingApi.getGiftEnabled,
});
// Mutations
const updateBrandingMutation = useMutation({
mutationFn: brandingApi.updateName,
@@ -76,6 +81,13 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
},
});
const updateGiftMutation = useMutation({
mutationFn: (enabled: boolean) => brandingApi.updateGiftEnabled(enabled),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['gift-enabled'] });
},
});
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
@@ -225,6 +237,18 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
disabled={updateEmailAuthMutation.isPending}
/>
</div>
<div className="flex items-center justify-between rounded-xl bg-dark-700/30 p-4">
<div>
<span className="font-medium text-dark-100">{t('admin.settings.giftEnabled')}</span>
<p className="text-sm text-dark-400">{t('admin.settings.giftEnabledDesc')}</p>
</div>
<Toggle
checked={giftSettings?.enabled ?? false}
onChange={() => updateGiftMutation.mutate(!(giftSettings?.enabled ?? false))}
disabled={updateGiftMutation.isPending}
/>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,74 @@
import { useTranslation } from 'react-i18next';
import {
SUPPORTED_LOCALES,
LOCALE_META,
type SupportedLocale,
type LocaleDict,
} from '../../api/landings';
import { cn } from '../../lib/utils';
interface LocaleTabsProps {
activeLocale: SupportedLocale;
onChange: (locale: SupportedLocale) => void;
/** Pass locale dicts to show a green dot indicator when content exists */
contentIndicators?: LocaleDict[];
className?: string;
}
/**
* Horizontal locale tab bar for the admin landing editor.
* Shows a green dot on tabs that have content filled in.
*/
export function LocaleTabs({
activeLocale,
onChange,
contentIndicators,
className,
}: LocaleTabsProps) {
const { t } = useTranslation();
const hasContent = (locale: SupportedLocale): boolean => {
if (!contentIndicators || contentIndicators.length === 0) return false;
return contentIndicators.some((dict) => {
const value = dict[locale];
return typeof value === 'string' && value.trim().length > 0;
});
};
return (
<div className={cn('space-y-1', className)}>
<div className="flex items-center gap-1.5">
{SUPPORTED_LOCALES.map((locale) => {
const meta = LOCALE_META[locale];
const isActive = locale === activeLocale;
const filled = hasContent(locale);
const isRtl = meta.rtl;
return (
<button
key={locale}
type="button"
onClick={() => onChange(locale)}
dir={isRtl ? 'rtl' : 'ltr'}
className={cn(
'relative flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-all',
isActive
? 'bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/30'
: 'bg-dark-800/50 text-dark-400 hover:bg-dark-700/50 hover:text-dark-300',
)}
aria-label={`${t('admin.landings.localeTab')}: ${meta.name}`}
aria-pressed={isActive}
>
<span>{meta.flag}</span>
<span>{meta.name}</span>
{filled && !isActive && (
<span className="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-success-500" />
)}
</button>
);
})}
</div>
<p className="text-xs text-dark-500">{t('admin.landings.localeHint')}</p>
</div>
);
}

View File

@@ -0,0 +1,70 @@
import { LOCALE_META, type LocaleDict, type SupportedLocale } from '../../api/landings';
import { cn } from '../../lib/utils';
interface LocalizedInputProps {
value: LocaleDict;
onChange: (value: LocaleDict) => void;
locale: SupportedLocale;
placeholder?: string;
multiline?: boolean;
rows?: number;
maxLength?: number;
id?: string;
className?: string;
}
/**
* An input (or textarea) that edits a single locale key within a LocaleDict.
* Shows `value[locale]` and updates the dict immutably on change.
*/
export function LocalizedInput({
value,
onChange,
locale,
placeholder,
multiline = false,
rows = 2,
maxLength,
id,
className,
}: LocalizedInputProps) {
const currentValue = value[locale] ?? '';
const isRtl = LOCALE_META[locale].rtl;
const handleChange = (newText: string) => {
onChange({ ...value, [locale]: newText });
};
const inputClasses = cn(
'w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 text-sm text-dark-100 outline-none focus:border-accent-500',
className,
);
if (multiline) {
return (
<textarea
id={id}
dir={isRtl ? 'rtl' : 'ltr'}
value={currentValue}
onChange={(e) => handleChange(e.target.value)}
placeholder={placeholder}
rows={rows}
maxLength={maxLength}
className={inputClasses}
/>
);
}
return (
<input
id={id}
type="text"
dir={isRtl ? 'rtl' : 'ltr'}
value={currentValue}
onChange={(e) => handleChange(e.target.value)}
placeholder={placeholder}
maxLength={maxLength}
className={inputClasses}
/>
);
}

View File

@@ -0,0 +1,891 @@
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
closestCenter,
type DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
menuLayoutApi,
type MenuConfig,
type MenuRowConfig,
type MenuButtonConfig,
BUILTIN_SECTIONS,
BOT_LOCALES,
STYLE_OPTIONS,
} from '../../api/menuLayout';
import { Toggle } from './Toggle';
import { useNotify } from '../../platform/hooks/useNotify';
// ============ Icons ============
const GripIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
);
const TrashIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
);
const PlusIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
);
const ChevronIcon = ({ expanded }: { expanded: boolean }) => (
<svg
className={`h-3.5 w-3.5 transition-transform ${expanded ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
);
const LinkIcon = () => (
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m9.86-2.54a4.5 4.5 0 00-1.242-7.244l-4.5-4.5a4.5 4.5 0 00-6.364 6.364L4.03 8.591"
/>
</svg>
);
const ArrowUpIcon = () => (
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
</svg>
);
const ArrowDownIcon = () => (
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
);
// ============ Helpers ============
function generateId(): string {
return `custom_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
}
function generateRowId(): string {
return `row_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
}
function configsEqual(a: MenuConfig, b: MenuConfig): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
const DEFAULT_CONFIG: MenuConfig = { rows: [] };
// ============ MaxPerRowSelector ============
interface MaxPerRowSelectorProps {
value: number;
onChange: (value: number) => void;
}
function MaxPerRowSelector({ value, onChange }: MaxPerRowSelectorProps) {
return (
<div className="flex gap-1">
{[1, 2, 3].map((n) => (
<button
key={n}
onClick={() => onChange(n)}
className={`flex h-7 w-7 items-center justify-center rounded-lg text-xs font-semibold transition-all ${
value === n
? 'bg-accent-500 text-white'
: 'bg-dark-700/50 text-dark-400 hover:bg-dark-600 hover:text-dark-300'
}`}
>
{n}
</button>
))}
</div>
);
}
// ============ ButtonChip ============
interface ButtonChipProps {
button: MenuButtonConfig;
isExpanded: boolean;
onToggleExpand: () => void;
onUpdate: (updates: Partial<MenuButtonConfig>) => void;
onRemove: () => void;
onMoveUp: (() => void) | null;
onMoveDown: (() => void) | null;
isBuiltin: boolean;
}
function ButtonChip({
button,
isExpanded,
onToggleExpand,
onUpdate,
onRemove,
onMoveUp,
onMoveDown,
isBuiltin,
}: ButtonChipProps) {
const { t } = useTranslation();
const displayName =
button.labels.ru ||
button.labels.en ||
(isBuiltin ? t(`admin.buttons.sections.${button.id}`) : button.id);
const styleOption = STYLE_OPTIONS.find((s) => s.value === button.style);
const colorDotClass = styleOption?.colorClass || 'bg-dark-500';
return (
<div
className={`overflow-hidden rounded-xl border transition-colors ${
button.enabled
? 'border-dark-700/50 bg-dark-800/50'
: 'border-dark-700/30 bg-dark-800/30 opacity-60'
}`}
>
{/* Collapsed header */}
<div className="flex items-center gap-2 px-3 py-2.5">
<div className="flex shrink-0 flex-col">
<button
onClick={onMoveUp ?? undefined}
disabled={!onMoveUp}
aria-label={t('admin.menuEditor.moveUp')}
className={`rounded-lg p-1.5 transition-colors ${
onMoveUp
? 'text-dark-400 hover:bg-dark-700/50 hover:text-dark-300'
: 'cursor-default text-dark-700'
}`}
>
<ArrowUpIcon />
</button>
<button
onClick={onMoveDown ?? undefined}
disabled={!onMoveDown}
aria-label={t('admin.menuEditor.moveDown')}
className={`rounded-lg p-1.5 transition-colors ${
onMoveDown
? 'text-dark-400 hover:bg-dark-700/50 hover:text-dark-300'
: 'cursor-default text-dark-700'
}`}
>
<ArrowDownIcon />
</button>
</div>
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${colorDotClass}`} />
<span className="min-w-0 flex-1 truncate text-sm font-medium text-dark-100">
{displayName}
</span>
{!isBuiltin && (
<span className="text-dark-500" title="URL">
<LinkIcon />
</span>
)}
<Toggle checked={button.enabled} onChange={() => onUpdate({ enabled: !button.enabled })} />
<button
onClick={onToggleExpand}
className="rounded-lg p-1 text-dark-400 transition-colors hover:bg-dark-700/50 hover:text-dark-300"
>
<ChevronIcon expanded={isExpanded} />
</button>
{!isBuiltin && (
<button
onClick={onRemove}
className="rounded-lg p-1 text-dark-500 transition-colors hover:bg-red-500/10 hover:text-red-400"
>
<TrashIcon />
</button>
)}
</div>
{/* Expanded body */}
{isExpanded && (
<div className="space-y-3 border-t border-dark-700/30 px-3 py-3">
{/* Color selector */}
<div>
<label className="mb-1.5 block text-xs font-medium text-dark-300">
{t('admin.buttons.color')}
</label>
<div className="flex flex-wrap gap-1.5">
{STYLE_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => onUpdate({ style: opt.value })}
className={`flex h-7 items-center gap-1.5 rounded-lg border px-2.5 text-xs font-medium transition-all ${
button.style === opt.value
? 'border-accent-500 bg-accent-500/10 text-accent-400'
: 'border-dark-600 bg-dark-700/50 text-dark-300 hover:border-dark-500'
}`}
>
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${opt.colorClass}`} />
{t(`admin.buttons.styles.${opt.value}`)}
</button>
))}
</div>
</div>
{/* Emoji ID */}
<div>
<label className="mb-1.5 block text-xs font-medium text-dark-300">
{t('admin.buttons.emojiId')}
</label>
<input
type="text"
value={button.icon_custom_emoji_id}
onChange={(e) => onUpdate({ icon_custom_emoji_id: e.target.value })}
placeholder={t('admin.buttons.emojiPlaceholder')}
className="w-full rounded-lg border border-dark-600 bg-dark-700/50 px-3 py-2 text-sm text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none"
/>
</div>
{/* URL input + open mode (custom buttons only) */}
{!isBuiltin && (
<>
<div>
<label className="mb-1.5 block text-xs font-medium text-dark-300">URL</label>
<input
type="url"
value={button.url || ''}
onChange={(e) => onUpdate({ url: e.target.value || null })}
placeholder="https://..."
className="w-full rounded-lg border border-dark-600 bg-dark-700/50 px-3 py-2 text-sm text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none"
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-dark-300">
{t('admin.menuEditor.openIn')}
</label>
<div className="flex gap-1.5">
{(['external', 'webapp'] as const).map((mode) => (
<button
key={mode}
onClick={() => onUpdate({ open_in: mode })}
className={`flex h-7 items-center gap-1.5 rounded-lg border px-2.5 text-xs font-medium transition-all ${
button.open_in === mode
? 'border-accent-500 bg-accent-500/10 text-accent-400'
: 'border-dark-600 bg-dark-700/50 text-dark-300 hover:border-dark-500'
}`}
>
{t(`admin.menuEditor.openMode.${mode}`)}
</button>
))}
</div>
</div>
</>
)}
{/* Localized labels */}
<div>
<label className="mb-1.5 block text-xs font-medium text-dark-300">
{t('admin.buttons.customLabels')}
</label>
<div className="space-y-2">
{BOT_LOCALES.map((locale) => (
<div key={locale} className="flex items-center gap-2">
<span className="w-7 shrink-0 text-center text-[10px] font-semibold uppercase text-dark-500">
{locale}
</span>
<input
type="text"
value={button.labels[locale] || ''}
onChange={(e) =>
onUpdate({
labels: { ...button.labels, [locale]: e.target.value },
})
}
placeholder={t('admin.menuEditor.buttonTextPlaceholder')}
maxLength={100}
className="w-full rounded-lg border border-dark-600 bg-dark-700/50 px-3 py-1.5 text-sm text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none"
/>
</div>
))}
<p className="text-[10px] text-dark-500">{t('admin.menuEditor.customLabelsHint')}</p>
</div>
</div>
</div>
)}
</div>
);
}
// ============ SortableRow ============
interface SortableRowProps {
row: MenuRowConfig;
rowIndex: number;
expandedButtons: Set<string>;
usedBuiltinIds: Set<string>;
onToggleExpand: (buttonId: string) => void;
onUpdateRow: (rowId: string, updates: Partial<MenuRowConfig>) => void;
onRemoveRow: (rowId: string) => void;
onUpdateButton: (rowId: string, buttonId: string, updates: Partial<MenuButtonConfig>) => void;
onRemoveButton: (rowId: string, buttonId: string) => void;
onAddBuiltin: (rowId: string, sectionId: string) => void;
onAddCustom: (rowId: string) => void;
onReorderButton: (rowId: string, buttonIndex: number, direction: 'up' | 'down') => void;
}
function SortableRow({
row,
rowIndex,
expandedButtons,
usedBuiltinIds,
onToggleExpand,
onUpdateRow,
onRemoveRow,
onUpdateButton,
onRemoveButton,
onAddBuiltin,
onAddCustom,
onReorderButton,
}: SortableRowProps) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: row.id,
});
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 50 : undefined,
position: isDragging ? 'relative' : undefined,
};
const allBuiltin = row.buttons.every((b) => b.type === 'builtin');
return (
<div
ref={setNodeRef}
style={style}
className={`overflow-hidden rounded-2xl border bg-dark-800/50 transition-all ${
isDragging ? 'border-accent-500/50 shadow-xl shadow-accent-500/20' : 'border-dark-700/50'
}`}
>
{/* Row header */}
<div className="flex items-center gap-3 border-b border-dark-700/30 px-4 py-3">
<button
{...attributes}
{...listeners}
className="flex-shrink-0 cursor-grab touch-none rounded-lg p-1.5 text-dark-500 hover:bg-dark-700/50 hover:text-dark-300 active:cursor-grabbing"
title={t('admin.menuEditor.dragToReorder')}
>
<GripIcon />
</button>
<span className="text-sm font-semibold text-dark-200">
{t('admin.menuEditor.row')} {rowIndex + 1}
</span>
<div className="flex-1" />
<MaxPerRowSelector
value={row.max_per_row}
onChange={(value) => onUpdateRow(row.id, { max_per_row: value })}
/>
{!allBuiltin && (
<button
onClick={() => onRemoveRow(row.id)}
className="rounded-lg p-1.5 text-dark-500 transition-colors hover:bg-red-500/10 hover:text-red-400"
>
<TrashIcon />
</button>
)}
</div>
{/* Row body */}
<div className="space-y-2 p-3">
{row.buttons.map((button, btnIndex) => (
<ButtonChip
key={button.id}
button={button}
isExpanded={expandedButtons.has(button.id)}
onToggleExpand={() => onToggleExpand(button.id)}
onUpdate={(updates) => onUpdateButton(row.id, button.id, updates)}
onRemove={() => onRemoveButton(row.id, button.id)}
onMoveUp={btnIndex > 0 ? () => onReorderButton(row.id, btnIndex, 'up') : null}
onMoveDown={
btnIndex < row.buttons.length - 1
? () => onReorderButton(row.id, btnIndex, 'down')
: null
}
isBuiltin={button.type === 'builtin'}
/>
))}
{/* Inline add button panel */}
<InlineAddPanel
rowId={row.id}
usedBuiltinIds={usedBuiltinIds}
onAddBuiltin={onAddBuiltin}
onAddCustom={onAddCustom}
/>
</div>
</div>
);
}
// ============ InlineAddPanel ============
interface InlineAddPanelProps {
rowId: string;
usedBuiltinIds: Set<string>;
onAddBuiltin: (rowId: string, sectionId: string) => void;
onAddCustom: (rowId: string) => void;
}
function InlineAddPanel({ rowId, usedBuiltinIds, onAddBuiltin, onAddCustom }: InlineAddPanelProps) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const availableBuiltins = BUILTIN_SECTIONS.filter((id) => !usedBuiltinIds.has(id));
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className="flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed border-dark-700/50 py-2.5 text-sm text-dark-500 transition-colors hover:border-dark-600 hover:text-dark-400"
>
<PlusIcon />
{t('admin.menuEditor.addButton')}
</button>
);
}
return (
<div className="space-y-1 rounded-xl border border-dark-700/50 bg-dark-900/30 p-2">
{availableBuiltins.length > 0 && (
<>
<p className="px-2 pb-0.5 text-xs font-medium text-dark-500">
{t('admin.menuEditor.builtinButtons')}
</p>
{availableBuiltins.map((id) => (
<button
key={id}
onClick={() => {
onAddBuiltin(rowId, id);
setIsOpen(false);
}}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm text-dark-200 transition-colors hover:bg-dark-700/50"
>
{t(`admin.buttons.sections.${id}`)}
</button>
))}
<div className="my-1 border-t border-dark-700/30" />
</>
)}
<button
onClick={() => {
onAddCustom(rowId);
setIsOpen(false);
}}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm text-dark-200 transition-colors hover:bg-dark-700/50"
>
<LinkIcon />
{t('admin.menuEditor.addUrlButton')}
</button>
<button
onClick={() => setIsOpen(false)}
className="flex w-full items-center justify-center rounded-lg py-1.5 text-xs text-dark-500 transition-colors hover:text-dark-400"
>
{t('common.cancel')}
</button>
</div>
);
}
// ============ MenuEditorTab ============
export function MenuEditorTab() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const notify = useNotify();
// Fetch config
const {
data: serverConfig,
isLoading,
isError,
} = useQuery({
queryKey: ['menu-layout'],
queryFn: menuLayoutApi.getConfig,
});
// Draft state
const [draftConfig, setDraftConfig] = useState<MenuConfig>(DEFAULT_CONFIG);
const [expandedButtons, setExpandedButtons] = useState<Set<string>>(new Set());
const savedConfigRef = useRef<MenuConfig>(DEFAULT_CONFIG);
const draftConfigRef = useRef(draftConfig);
draftConfigRef.current = draftConfig;
// Sync server data to draft (same pattern as ButtonsTab)
useEffect(() => {
if (serverConfig) {
if (
configsEqual(savedConfigRef.current, draftConfigRef.current) ||
configsEqual(savedConfigRef.current, DEFAULT_CONFIG)
) {
setDraftConfig(serverConfig);
savedConfigRef.current = serverConfig;
}
}
}, [serverConfig]);
const hasUnsavedChanges = !configsEqual(draftConfig, savedConfigRef.current);
// Mutations
const updateMutation = useMutation({
mutationFn: menuLayoutApi.updateConfig,
onSuccess: (data) => {
savedConfigRef.current = data;
setDraftConfig(data);
queryClient.setQueryData(['menu-layout'], data);
},
onError: () => {
notify.error(t('common.error'));
},
});
const resetMutation = useMutation({
mutationFn: menuLayoutApi.resetConfig,
onSuccess: (data) => {
savedConfigRef.current = data;
setDraftConfig(data);
queryClient.setQueryData(['menu-layout'], data);
},
onError: () => {
notify.error(t('common.error'));
},
});
// DnD sensors
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setDraftConfig((prev) => {
const oldIndex = prev.rows.findIndex((r) => r.id === active.id);
const newIndex = prev.rows.findIndex((r) => r.id === over.id);
if (oldIndex === -1 || newIndex === -1) return prev;
return { ...prev, rows: arrayMove(prev.rows, oldIndex, newIndex) };
});
}
}, []);
// Row CRUD
const updateRow = useCallback((rowId: string, updates: Partial<MenuRowConfig>) => {
setDraftConfig((prev) => ({
...prev,
rows: prev.rows.map((r) => (r.id === rowId ? { ...r, ...updates } : r)),
}));
}, []);
const removeRow = useCallback((rowId: string) => {
setDraftConfig((prev) => ({
...prev,
rows: prev.rows.filter((r) => r.id !== rowId),
}));
}, []);
const addRow = useCallback(() => {
setDraftConfig((prev) => ({
...prev,
rows: [
...prev.rows,
{
id: generateRowId(),
max_per_row: 2,
buttons: [],
},
],
}));
}, []);
// Button CRUD
const updateButton = useCallback(
(rowId: string, buttonId: string, updates: Partial<MenuButtonConfig>) => {
setDraftConfig((prev) => ({
...prev,
rows: prev.rows.map((r) =>
r.id === rowId
? {
...r,
buttons: r.buttons.map((b) => (b.id === buttonId ? { ...b, ...updates } : b)),
}
: r,
),
}));
},
[],
);
const removeButton = useCallback((rowId: string, buttonId: string) => {
setDraftConfig((prev) => ({
...prev,
rows: prev.rows.map((r) =>
r.id === rowId ? { ...r, buttons: r.buttons.filter((b) => b.id !== buttonId) } : r,
),
}));
}, []);
const addBuiltinButton = useCallback((rowId: string, sectionId: string) => {
const newButton: MenuButtonConfig = {
id: sectionId,
type: 'builtin',
style: 'default',
icon_custom_emoji_id: '',
enabled: true,
labels: {},
url: null,
open_in: 'external',
};
setDraftConfig((prev) => ({
...prev,
rows: prev.rows.map((r) =>
r.id === rowId ? { ...r, buttons: [...r.buttons, newButton] } : r,
),
}));
}, []);
const addCustomButton = useCallback((rowId: string) => {
const newButton: MenuButtonConfig = {
id: generateId(),
type: 'custom',
style: 'default',
icon_custom_emoji_id: '',
enabled: true,
labels: {},
url: '',
open_in: 'external',
};
setDraftConfig((prev) => ({
...prev,
rows: prev.rows.map((r) =>
r.id === rowId ? { ...r, buttons: [...r.buttons, newButton] } : r,
),
}));
}, []);
const reorderButton = useCallback(
(rowId: string, buttonIndex: number, direction: 'up' | 'down') => {
setDraftConfig((prev) => ({
...prev,
rows: prev.rows.map((r) => {
if (r.id !== rowId) return r;
const newIndex = direction === 'up' ? buttonIndex - 1 : buttonIndex + 1;
if (newIndex < 0 || newIndex >= r.buttons.length) return r;
return { ...r, buttons: arrayMove(r.buttons, buttonIndex, newIndex) };
}),
}));
},
[],
);
// Expand/collapse
const toggleExpand = useCallback((buttonId: string) => {
setExpandedButtons((prev) => {
const next = new Set(prev);
if (next.has(buttonId)) {
next.delete(buttonId);
} else {
next.add(buttonId);
}
return next;
});
}, []);
// Collect used builtin IDs across all rows
const usedBuiltinIds = useMemo(
() =>
new Set(
draftConfig.rows.flatMap((r) =>
r.buttons.filter((b) => b.type === 'builtin').map((b) => b.id),
),
),
[draftConfig.rows],
);
// Handlers
const handleSave = useCallback(() => {
// Validate custom buttons have valid URLs
const currentDraft = draftConfigRef.current;
for (const row of currentDraft.rows) {
for (const btn of row.buttons) {
if (btn.type === 'custom') {
if (!btn.url || (!btn.url.startsWith('http://') && !btn.url.startsWith('https://'))) {
notify.error(t('admin.menuEditor.invalidUrl'));
return;
}
}
}
}
updateMutation.mutate(currentDraft);
}, [updateMutation, notify, t]);
const handleCancel = useCallback(() => {
setDraftConfig(savedConfigRef.current);
}, []);
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-dark-400">
<svg className="mr-2 h-5 w-5 animate-spin" viewBox="0 0 24 24" fill="none">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
{t('common.loading')}
</div>
);
}
if (isError) {
return (
<div className="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-400">
{t('common.error')}
</div>
);
}
return (
<div className="space-y-4">
{/* Drag hint */}
<div className="flex items-center gap-2 text-sm text-dark-500">
<GripIcon />
{t('admin.menuEditor.dragHint')}
</div>
{/* Rows */}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext
items={draftConfig.rows.map((r) => r.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{draftConfig.rows.map((row, index) => (
<SortableRow
key={row.id}
row={row}
rowIndex={index}
expandedButtons={expandedButtons}
usedBuiltinIds={usedBuiltinIds}
onToggleExpand={toggleExpand}
onUpdateRow={updateRow}
onRemoveRow={removeRow}
onUpdateButton={updateButton}
onRemoveButton={removeButton}
onAddBuiltin={addBuiltinButton}
onAddCustom={addCustomButton}
onReorderButton={reorderButton}
/>
))}
</div>
</SortableContext>
</DndContext>
{/* Add row */}
<button
onClick={addRow}
className="flex w-full items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-dark-700/50 py-4 text-sm font-medium text-dark-500 transition-colors hover:border-dark-600 hover:text-dark-400"
>
<PlusIcon />
{t('admin.menuEditor.addRow')}
</button>
{/* Save / Cancel */}
{hasUnsavedChanges && (
<div className="flex flex-wrap items-center gap-3">
<button
onClick={handleSave}
disabled={updateMutation.isPending}
className="rounded-xl bg-accent-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
>
{updateMutation.isPending ? t('common.saving') : t('common.save')}
</button>
<button
onClick={handleCancel}
disabled={updateMutation.isPending}
className="rounded-xl bg-dark-700 px-4 py-2 text-sm font-medium text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-50"
>
{t('common.cancel')}
</button>
</div>
)}
{/* Reset */}
<div className="flex justify-end">
<button
onClick={() => {
if (window.confirm(t('admin.menuEditor.resetConfirm'))) {
resetMutation.mutate();
}
}}
disabled={resetMutation.isPending}
className="rounded-xl bg-dark-700 px-4 py-2 text-sm text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-50"
>
{t('admin.buttons.resetAll')}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,89 @@
import { useTranslation } from 'react-i18next';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { cn } from '../../lib/utils';
import { GripIcon, TrashIcon } from '../icons/LandingIcons';
import { LocalizedInput } from './LocalizedInput';
import type { AdminLandingFeature, LocaleDict, SupportedLocale } from '../../api/landings';
export type FeatureWithId = AdminLandingFeature & { _id: string };
interface SortableFeatureProps {
feature: FeatureWithId;
index: number;
locale: SupportedLocale;
onUpdateIcon: (index: number, value: string) => void;
onUpdateLocalized: (index: number, field: 'title' | 'description', value: LocaleDict) => void;
onRemove: (index: number) => void;
}
export function SortableFeatureItem({
feature,
index,
locale,
onUpdateIcon,
onUpdateLocalized,
onRemove,
}: SortableFeatureProps) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: feature._id,
});
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 50 : undefined,
position: isDragging ? 'relative' : undefined,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'flex items-start gap-2 rounded-lg border p-3',
isDragging ? 'border-accent-500/50 bg-dark-700' : 'border-dark-700 bg-dark-800/50',
)}
>
<button
{...attributes}
{...listeners}
className="mt-2 flex-shrink-0 cursor-grab touch-none text-dark-500 hover:text-dark-300 active:cursor-grabbing"
>
<GripIcon />
</button>
<div className="min-w-0 flex-1 space-y-2">
<div className="flex gap-2">
<input
type="text"
value={feature.icon}
onChange={(e) => onUpdateIcon(index, e.target.value)}
placeholder={t('admin.landings.featureIcon')}
className="w-16 rounded-lg border border-dark-700 bg-dark-800 px-2 py-1.5 text-center text-sm text-dark-100 outline-none focus:border-accent-500"
/>
<LocalizedInput
value={feature.title}
onChange={(v) => onUpdateLocalized(index, 'title', v)}
locale={locale}
placeholder={t('admin.landings.featureTitle')}
className="min-w-0 flex-1 rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
/>
</div>
<LocalizedInput
value={feature.description}
onChange={(v) => onUpdateLocalized(index, 'description', v)}
locale={locale}
placeholder={t('admin.landings.featureDesc')}
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
/>
</div>
<button
onClick={() => onRemove(index)}
className="mt-2 flex-shrink-0 text-dark-500 hover:text-error-400"
>
<TrashIcon />
</button>
</div>
);
}

View File

@@ -0,0 +1,251 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { cn } from '../../lib/utils';
import { GripIcon, TrashIcon } from '../icons/LandingIcons';
import type { AdminLandingPaymentMethod, EditableMethodField } from '../../api/landings';
import type { PaymentMethodSubOptionInfo } from '../../types';
export type MethodWithId = AdminLandingPaymentMethod & { _id: string };
const ChevronDownIcon = ({ open }: { open: boolean }) => (
<svg
className={cn('h-5 w-5 transition-transform', open && 'rotate-180')}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
);
interface SortableSelectedMethodProps {
method: MethodWithId;
availableSubOptions: PaymentMethodSubOptionInfo[] | null;
onUpdate: (methodId: string, field: EditableMethodField, value: string | number | null) => void;
onSubOptionsChange: (methodId: string, subOptions: Record<string, boolean>) => void;
onRemove: (methodId: string) => void;
}
export function SortableSelectedMethodCard({
method,
availableSubOptions,
onUpdate,
onSubOptionsChange,
onRemove,
}: SortableSelectedMethodProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: method._id,
});
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 50 : undefined,
position: isDragging ? 'relative' : undefined,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'rounded-lg border',
isDragging ? 'border-accent-500/50 bg-dark-700' : 'border-dark-700 bg-dark-800/50',
)}
>
<div className="flex items-center gap-2 px-3 py-2">
<button
{...attributes}
{...listeners}
className="flex-shrink-0 cursor-grab touch-none text-dark-500 hover:text-dark-300 active:cursor-grabbing"
>
<GripIcon />
</button>
<button onClick={() => setExpanded((v) => !v)} className="min-w-0 flex-1 text-start">
<span className="truncate text-sm text-dark-100">{method.display_name}</span>
</button>
<button
onClick={() => setExpanded((v) => !v)}
className="flex-shrink-0 text-dark-500 hover:text-dark-300"
>
<ChevronDownIcon open={expanded} />
</button>
<button
onClick={() => onRemove(method.method_id)}
className="flex-shrink-0 text-dark-500 hover:text-error-400"
>
<TrashIcon />
</button>
</div>
{expanded && (
<div className="space-y-3 border-t border-dark-700 px-3 py-3">
<div>
<label className="mb-1 block text-xs text-dark-500">
{t('admin.landings.methodDisplayName', 'Display name')}
</label>
<input
type="text"
value={method.display_name}
onChange={(e) => onUpdate(method.method_id, 'display_name', e.target.value)}
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
/>
</div>
<div>
<label className="mb-1 block text-xs text-dark-500">
{t('admin.landings.methodDescription', 'Description')}
</label>
<input
type="text"
value={method.description ?? ''}
onChange={(e) => onUpdate(method.method_id, 'description', e.target.value || null)}
placeholder={t('admin.landings.methodDescPlaceholder', 'Optional description')}
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
/>
</div>
<div>
<label className="mb-1 block text-xs text-dark-500">
{t('admin.landings.methodIconUrl', 'Icon URL')}
</label>
<input
type="text"
value={method.icon_url ?? ''}
onChange={(e) => onUpdate(method.method_id, 'icon_url', e.target.value || null)}
placeholder="https://..."
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-xs text-dark-500">
{t('admin.landings.methodMinAmount', 'Min amount (kopeks)')}
</label>
<input
type="number"
min={0}
step={1}
value={method.min_amount_kopeks ?? ''}
onChange={(e) =>
onUpdate(
method.method_id,
'min_amount_kopeks',
e.target.value ? Math.max(0, Math.floor(Number(e.target.value))) : null,
)
}
placeholder="—"
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
/>
</div>
<div>
<label className="mb-1 block text-xs text-dark-500">
{t('admin.landings.methodMaxAmount', 'Max amount (kopeks)')}
</label>
<input
type="number"
min={0}
step={1}
value={method.max_amount_kopeks ?? ''}
onChange={(e) =>
onUpdate(
method.method_id,
'max_amount_kopeks',
e.target.value ? Math.max(0, Math.floor(Number(e.target.value))) : null,
)
}
placeholder="—"
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
/>
</div>
</div>
<div>
<label className="mb-1 block text-xs text-dark-500">
{t('admin.landings.methodCurrency', 'Currency')}
</label>
<input
type="text"
value={method.currency ?? ''}
onChange={(e) => onUpdate(method.method_id, 'currency', e.target.value || null)}
placeholder="RUB"
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
/>
</div>
<div>
<label className="mb-1 block text-xs text-dark-500">
{t('admin.landings.methodReturnUrl', 'Return URL after payment')}
</label>
<input
type="text"
value={method.return_url ?? ''}
onChange={(e) => onUpdate(method.method_id, 'return_url', e.target.value || null)}
placeholder={t(
'admin.landings.methodReturnUrlPlaceholder',
'Default: cabinet success page. Use {token} for purchase token',
)}
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
/>
</div>
{availableSubOptions && availableSubOptions.length > 0 && (
<div>
<label className="mb-1.5 block text-xs text-dark-500">
{t('admin.landings.methodSubOptions', 'Payment sub-options')}
</label>
<div className="flex flex-wrap gap-2">
{availableSubOptions.map((opt) => {
// Missing keys treated as enabled (opt-out model)
const enabled = method.sub_options?.[opt.id] !== false;
return (
<button
key={opt.id}
type="button"
role="checkbox"
aria-checked={enabled}
onClick={() => {
const current =
method.sub_options ??
Object.fromEntries(availableSubOptions.map((o) => [o.id, true]));
onSubOptionsChange(method.method_id, { ...current, [opt.id]: !enabled });
}}
className={cn(
'flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors',
enabled
? 'border-accent-500/30 bg-accent-500/10 text-accent-300'
: 'border-dark-700 bg-dark-800 text-dark-500',
)}
>
<div
aria-hidden="true"
className={cn(
'flex h-3.5 w-3.5 items-center justify-center rounded',
enabled
? 'bg-accent-500 text-white'
: 'border border-dark-600 bg-dark-700',
)}
>
{enabled && (
<svg
className="h-2.5 w-2.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
{opt.name}
</button>
);
})}
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -67,6 +67,8 @@ export const MENU_SECTIONS: MenuSection[] = [
'INTERFACE_SUBSCRIPTION',
'CONNECT_BUTTON',
'MINIAPP',
'TELEGRAM_WIDGET',
'TELEGRAM_OIDC',
'HAPP',
'SKIP',
'ADDITIONAL',

View File

@@ -12,6 +12,8 @@ export * from './FavoritesTab';
export * from './SettingsTab';
export * from './SettingsMobileTabs';
export * from './SettingsSearch';
export * from './LocaleTabs';
export * from './LocalizedInput';
// Constants and utils
export * from './constants';

View File

@@ -96,52 +96,35 @@ function reduceMobileSettings(settings: Record<string, unknown>): Record<string,
return reduced;
}
export function BackgroundRenderer() {
/** Renders the background animation into a portal at document.body */
function RenderBackground({ config }: { config: AnimationConfig }) {
const prefersReducedMotion = useMemo(
() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
[],
);
const { data: config } = useQuery({
queryKey: ['animation-config'],
queryFn: async () => {
const raw = await brandingApi.getAnimationConfig();
// Validate and clamp API response same as localStorage
const result = validateConfig(raw) ?? DEFAULT_ANIMATION_CONFIG;
setCachedConfig(result);
return result;
},
initialData: getCachedConfig() ?? undefined,
initialDataUpdatedAt: 0,
staleTime: 30_000,
});
const effectiveConfig = config ?? DEFAULT_ANIMATION_CONFIG;
if (!effectiveConfig.enabled || effectiveConfig.type === 'none' || prefersReducedMotion) {
if (!config.enabled || config.type === 'none' || prefersReducedMotion) {
return null;
}
const bgType = effectiveConfig.type as Exclude<BackgroundType, 'none'>;
const bgType = config.type as Exclude<BackgroundType, 'none'>;
const Component = backgroundComponents[bgType];
if (!Component) return null;
const isMobile = window.innerWidth < 768;
const settings =
effectiveConfig.reducedOnMobile && isMobile
? reduceMobileSettings(effectiveConfig.settings)
: effectiveConfig.settings;
config.reducedOnMobile && isMobile ? reduceMobileSettings(config.settings) : config.settings;
// On mobile, cap blur to 4px max — full blur is extremely GPU-heavy
const effectiveBlur = isMobile ? Math.min(effectiveConfig.blur, 4) : effectiveConfig.blur;
const effectiveBlur = isMobile ? Math.min(config.blur, 4) : config.blur;
return createPortal(
<div
className="pointer-events-none fixed inset-0"
style={{
zIndex: -2,
opacity: effectiveConfig.opacity,
opacity: config.opacity,
filter: effectiveBlur > 0 ? `blur(${effectiveBlur}px)` : undefined,
contain: 'strict',
backfaceVisibility: 'hidden',
@@ -154,3 +137,29 @@ export function BackgroundRenderer() {
document.body,
);
}
/** BackgroundRenderer that fetches config from the branding API (for authenticated app shell) */
export function BackgroundRenderer() {
const { data: config } = useQuery({
queryKey: ['animation-config'],
queryFn: async () => {
const raw = await brandingApi.getAnimationConfig();
const result = validateConfig(raw) ?? DEFAULT_ANIMATION_CONFIG;
setCachedConfig(result);
return result;
},
initialData: getCachedConfig() ?? undefined,
initialDataUpdatedAt: 0,
staleTime: 30_000,
});
const effectiveConfig = config ?? DEFAULT_ANIMATION_CONFIG;
return <RenderBackground config={effectiveConfig} />;
}
/** StaticBackgroundRenderer that uses a provided config (for public landing pages) */
export function StaticBackgroundRenderer({ config }: { config: AnimationConfig }) {
const validated = useMemo(() => validateConfig(config), [config]);
if (!validated) return null;
return <RenderBackground config={validated} />;
}

View File

@@ -10,7 +10,7 @@ function safeOpenUrl(url: string | undefined | null): void {
try {
const parsed = new URL(url);
if (parsed.protocol === 'https:' || parsed.protocol === 'http:') {
window.open(url, '_blank', 'noopener,noreferrer');
window.open(url, '_blank', 'noopener');
}
} catch {
// invalid URL, do nothing

View File

@@ -0,0 +1,80 @@
import { Link } from 'react-router';
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
import type { PendingGift } from '../../api/gift';
interface PendingGiftCardProps {
gifts: PendingGift[];
className?: string;
}
export default function PendingGiftCard({ gifts, className }: PendingGiftCardProps) {
const { t } = useTranslation();
if (gifts.length === 0) return null;
return (
<div className={className ?? 'space-y-3'}>
{gifts.map((gift) => (
<motion.div
key={gift.token}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="relative overflow-hidden rounded-2xl border border-accent-500/30 bg-gradient-to-r from-accent-500/10 via-purple-500/10 to-accent-500/10 p-5"
>
{/* Subtle glow effect */}
<div className="absolute -right-8 -top-8 h-24 w-24 rounded-full bg-accent-500/10 blur-2xl" />
<div className="relative flex items-start gap-4">
{/* Gift icon */}
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-accent-500/20">
<svg
className="h-6 w-6 text-accent-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
/>
</svg>
</div>
{/* Content */}
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-dark-50">{t('gift.pending.title')}</h3>
<p className="mt-0.5 text-xs text-dark-300">
{gift.tariff_name && (
<span>
{gift.tariff_name} {gift.period_days} {t('gift.days')}
</span>
)}
{gift.sender_display && (
<span className="ml-1 text-dark-400">
{t('gift.pending.from', { sender: gift.sender_display })}
</span>
)}
</p>
{gift.gift_message && (
<p className="mt-1.5 line-clamp-2 text-xs italic text-dark-400">
&ldquo;{gift.gift_message}&rdquo;
</p>
)}
</div>
{/* Activate button */}
<Link
to={`/gift?tab=activate&code=${gift.token}`}
className="shrink-0 rounded-xl bg-accent-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-400"
>
{t('gift.pending.activate')}
</Link>
</div>
</motion.div>
))}
</div>
);
}

View File

@@ -236,15 +236,24 @@ export default function SubscriptionCardActive({
{t('dashboard.connectDevice')}
</div>
<div className="mt-0.5 text-[11px] text-dark-50/30">
{t('dashboard.devicesOfMax', {
used: connectedDevices,
max: subscription.device_limit,
})}
{subscription.device_limit === 0
? t('dashboard.devicesConnectedUnlimited', { used: connectedDevices })
: t('dashboard.devicesOfMax', {
used: connectedDevices,
max: subscription.device_limit,
})}
</div>
</div>
{/* Device indicator */}
{subscription.device_limit <= 10 ? (
{subscription.device_limit === 0 ? (
<div
className="flex flex-shrink-0 items-center text-lg text-dark-50/40"
aria-hidden="true"
>
</div>
) : subscription.device_limit <= 10 ? (
<div className="flex flex-shrink-0 gap-1.5" aria-hidden="true">
{Array.from({ length: subscription.device_limit }, (_, i) => (
<div

View File

@@ -1,23 +1,100 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router';
import { Link, useNavigate, useLocation } from 'react-router';
import { useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import type { Subscription } from '../../types';
import { subscriptionApi } from '../../api/subscription';
import { useTheme } from '../../hooks/useTheme';
import { useCurrency } from '../../hooks/useCurrency';
import { useHapticFeedback } from '../../platform/hooks/useHaptic';
import { getGlassColors } from '../../utils/glassTheme';
import { getInsufficientBalanceError } from '../../utils/subscriptionHelpers';
interface SubscriptionCardExpiredProps {
subscription: Subscription;
balanceKopeks?: number;
balanceRubles?: number;
className?: string;
}
export default function SubscriptionCardExpired({ subscription }: SubscriptionCardExpiredProps) {
export default function SubscriptionCardExpired({
subscription,
balanceKopeks = 0,
balanceRubles = 0,
className,
}: SubscriptionCardExpiredProps) {
const { t } = useTranslation();
const { isDark } = useTheme();
const g = getGlassColors(isDark);
const queryClient = useQueryClient();
const navigate = useNavigate();
const location = useLocation();
const { formatAmount, currencySymbol } = useCurrency();
const haptic = useHapticFeedback();
const [isRenewing, setIsRenewing] = useState(false);
const [renewError, setRenewError] = useState<string | null>(null);
const formattedDate = new Date(subscription.end_date).toLocaleDateString();
// Detect daily subscription (disabled or expired)
const isDaily = subscription.is_daily;
const isDisabledDaily = subscription.status === 'disabled' && isDaily;
// For daily subs, check if balance covers daily price; otherwise 100 kopeks minimum
const dailyPrice = subscription.daily_price_kopeks ?? 0;
const hasBalance = isDaily ? balanceKopeks >= dailyPrice && dailyPrice > 0 : balanceKopeks >= 100;
const handleQuickRenew = async () => {
setIsRenewing(true);
setRenewError(null);
haptic.buttonPressHeavy();
try {
if (isDisabledDaily) {
// Resume daily subscription via toggle pause endpoint
await subscriptionApi.togglePause();
} else if (isDaily && subscription.tariff_id) {
// Expired daily tariff — purchase for 1 day
await subscriptionApi.purchaseTariff(subscription.tariff_id, 1);
} else {
await subscriptionApi.renewSubscription(30);
}
haptic.success();
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
} catch (err: unknown) {
haptic.error();
const insufficientData = getInsufficientBalanceError(err);
if (insufficientData) {
setRenewError(t('dashboard.expired.insufficientFunds'));
} else if (err instanceof AxiosError) {
const detail = err.response?.data?.detail;
if (typeof detail === 'string') {
setRenewError(detail);
} else {
setRenewError(t('dashboard.expired.renewError'));
}
} else {
setRenewError(t('dashboard.expired.renewError'));
}
} finally {
setIsRenewing(false);
}
};
const handleTopUp = () => {
haptic.buttonPress();
const params = new URLSearchParams();
params.set('returnTo', location.pathname);
navigate(`/balance/top-up?${params.toString()}`);
};
return (
<div
className="relative overflow-hidden rounded-3xl"
className={`relative overflow-hidden rounded-3xl ${className ?? ''}`}
style={{
background: g.cardBg,
border: isDark ? '1px solid rgba(255,70,70,0.12)' : '1px solid rgba(255,59,92,0.2)',
@@ -81,39 +158,121 @@ export default function SubscriptionCardExpired({ subscription }: SubscriptionCa
</svg>
</div>
<h2 className="text-lg font-bold tracking-tight text-dark-50">
{subscription.is_trial ? t('dashboard.expired.trialTitle') : t('dashboard.expired.title')}
{isDisabledDaily
? t('dashboard.suspended.title')
: subscription.is_trial
? t('dashboard.expired.trialTitle')
: t('dashboard.expired.title')}
</h2>
</div>
{/* Expired date */}
{/* Expired date + Balance row */}
<div
className="mb-5 flex items-center justify-center rounded-[14px]"
className="mb-5 flex items-center justify-between rounded-[14px]"
style={{
background: 'rgba(255,59,92,0.04)',
border: '1px solid rgba(255,59,92,0.08)',
padding: '14px 18px',
}}
>
<div className="mb-0.5 font-mono text-[10px] font-medium uppercase tracking-wider text-dark-50/30">
{t('dashboard.expired.expiredDate')}
<div className="flex items-center">
<div className="mb-0.5 font-mono text-[10px] font-medium uppercase tracking-wider text-dark-50/30">
{t('dashboard.expired.expiredDate')}
</div>
<div className="ml-3 text-base font-bold tracking-tight text-dark-50/50">
{formattedDate}
</div>
</div>
<div className="ml-3 text-base font-bold tracking-tight text-dark-50/50">
{formattedDate}
<div className="flex items-center gap-1.5">
<span className="text-[10px] font-medium uppercase tracking-wider text-dark-50/30">
{t('dashboard.expired.balance')}
</span>
<span
className={`text-sm font-semibold ${hasBalance ? 'text-success-400' : 'text-dark-50/30'}`}
>
{formatAmount(balanceRubles)} {currencySymbol}
</span>
</div>
</div>
{/* Renew error */}
{renewError && (
<div
className="mb-4 rounded-xl border border-error-500/30 bg-error-500/10 p-3 text-center text-sm text-error-400"
role="alert"
>
{renewError}
</div>
)}
{/* Action buttons */}
<div className="flex gap-2.5">
<Link
to="/subscription/purchase"
className="flex flex-1 items-center justify-center rounded-[14px] py-3.5 text-[15px] font-semibold tracking-tight text-white transition-all duration-300"
style={{
background: 'linear-gradient(135deg, #FF3B5C, #FF6B35)',
boxShadow: '0 4px 20px rgba(255,59,92,0.2)',
}}
>
{t('dashboard.expired.renew')}
</Link>
{/* Quick Renew or Top Up button */}
{hasBalance ? (
<button
type="button"
onClick={handleQuickRenew}
disabled={isRenewing}
className="flex flex-1 items-center justify-center gap-2 rounded-[14px] py-3.5 text-[15px] font-semibold tracking-tight text-white transition-all duration-300 disabled:opacity-50"
style={{
background: 'linear-gradient(135deg, #FF3B5C, #FF6B35)',
boxShadow: '0 4px 20px rgba(255,59,92,0.2)',
}}
>
{isRenewing ? (
<span
className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white"
aria-hidden="true"
/>
) : (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
</svg>
)}
{isRenewing
? t('common.loading')
: isDisabledDaily
? t('dashboard.suspended.resume')
: t('dashboard.expired.quickRenew')}
</button>
) : (
<button
type="button"
onClick={handleTopUp}
className="flex flex-1 items-center justify-center gap-2 rounded-[14px] py-3.5 text-[15px] font-semibold tracking-tight text-white transition-all duration-300"
style={{
background: 'linear-gradient(135deg, #FF3B5C, #FF6B35)',
boxShadow: '0 4px 20px rgba(255,59,92,0.2)',
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 4.5v15m7.5-7.5h-15" />
</svg>
{t('dashboard.expired.topUp')}
</button>
)}
{/* Renew (go to purchase page) */}
<Link
to="/subscription/purchase"
className="flex items-center justify-center rounded-[14px] px-5 py-3.5 text-[15px] font-semibold tracking-tight text-dark-50/50 transition-colors duration-200"

View File

@@ -175,7 +175,10 @@ export default function TrialOfferCard({
value: trialInfo.traffic_limit_gb === 0 ? '∞' : String(trialInfo.traffic_limit_gb),
label: t('common.units.gb'),
},
{ value: String(trialInfo.device_limit), label: t('subscription.trial.devices') },
{
value: trialInfo.device_limit === 0 ? '∞' : String(trialInfo.device_limit),
label: t('subscription.trial.devices'),
},
].map((stat, i) => (
<div key={i} className="text-center">
<div className="text-4xl font-extrabold leading-none tracking-tight text-dark-50">

View File

@@ -0,0 +1,69 @@
import { cn } from '../../lib/utils';
interface IconProps {
className?: string;
}
export function BackIcon({ className }: IconProps) {
return (
<svg
className={cn('h-5 w-5 text-dark-400', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
);
}
export function PlusIcon({ className }: IconProps) {
return (
<svg
className={cn('h-4 w-4', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
);
}
export function TrashIcon({ className }: IconProps) {
return (
<svg
className={cn('h-4 w-4', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
);
}
export function GripIcon({ className }: IconProps) {
return (
<svg
className={cn('h-4 w-4', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
);
}

View File

@@ -35,6 +35,7 @@ import {
InfoIcon,
CogIcon,
WheelIcon,
GiftIcon,
MenuIcon,
CloseIcon,
SunIcon,
@@ -60,6 +61,7 @@ interface AppHeaderProps {
referralEnabled?: boolean;
hasContests?: boolean;
hasPolls?: boolean;
giftEnabled?: boolean;
}
export function AppHeader({
@@ -75,6 +77,7 @@ export function AppHeader({
referralEnabled,
hasContests,
hasPolls,
giftEnabled,
}: AppHeaderProps) {
const { t } = useTranslation();
const location = useLocation();
@@ -164,6 +167,7 @@ export function AppHeader({
...(hasContests ? [{ path: '/contests', label: t('nav.contests'), icon: GamepadIcon }] : []),
...(hasPolls ? [{ path: '/polls', label: t('nav.polls'), icon: ClipboardIcon }] : []),
...(wheelEnabled ? [{ path: '/wheel', label: t('nav.wheel'), icon: WheelIcon }] : []),
...(giftEnabled ? [{ path: '/gift', label: t('nav.gift'), icon: GiftIcon }] : []),
{ path: '/info', label: t('nav.info'), icon: InfoIcon },
];

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useLocation, Link } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -20,7 +20,7 @@ import CampaignBonusNotifier from '@/components/CampaignBonusNotifier';
import SuccessNotificationModal from '@/components/SuccessNotificationModal';
import LanguageSwitcher from '@/components/LanguageSwitcher';
import TicketNotificationBell from '@/components/TicketNotificationBell';
import { SubscriptionIcon } from '@/components/icons';
import { SubscriptionIcon, GiftIcon } from '@/components/icons';
import { MobileBottomNav } from './MobileBottomNav';
import { AppHeader } from './AppHeader';
@@ -203,7 +203,7 @@ export function AppShell({ children }: AppShellProps) {
// Extracted hooks
const { appName, logoLetter, hasCustomLogo, logoUrl } = useBranding();
const { referralEnabled, wheelEnabled, hasContests, hasPolls } = useFeatureFlags();
const { referralEnabled, wheelEnabled, hasContests, hasPolls, giftEnabled } = useFeatureFlags();
useScrollRestoration();
// Theme toggle visibility
@@ -269,6 +269,31 @@ export function AppShell({ children }: AppShellProps) {
haptic.impact('light');
};
// Desktop nav scroll fade indicators
const navRef = useRef<HTMLElement>(null);
const [navCanScrollLeft, setNavCanScrollLeft] = useState(false);
const [navCanScrollRight, setNavCanScrollRight] = useState(false);
const updateNavScroll = useCallback(() => {
const el = navRef.current;
if (!el) return;
setNavCanScrollLeft(el.scrollLeft > 2);
setNavCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 2);
}, []);
useEffect(() => {
const el = navRef.current;
if (!el) return;
updateNavScroll();
el.addEventListener('scroll', updateNavScroll, { passive: true });
const ro = new ResizeObserver(updateNavScroll);
ro.observe(el);
return () => {
el.removeEventListener('scroll', updateNavScroll);
ro.disconnect();
};
}, [updateNavScroll]);
// Calculate header height based on fullscreen mode (only on mobile Telegram)
// On iOS: contentSafeAreaInset.top includes status bar + dynamic island + Telegram header
// On Android: safeAreaInset.top only includes status bar, need to add Telegram header height (~48px)
@@ -317,58 +342,89 @@ export function AppShell({ children }: AppShellProps) {
</Link>
{/* Center Navigation */}
<nav className="flex items-center justify-center gap-1">
{desktopNavItems.map((item) => (
<Link
key={item.path}
to={item.path}
onClick={handleNavClick}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive(item.path)
? 'bg-dark-800 text-dark-50'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200',
)}
>
<item.icon className="h-4 w-4" />
<span>{item.label}</span>
</Link>
))}
{referralEnabled && (
<Link
to="/referral"
onClick={handleNavClick}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive('/referral')
? 'bg-dark-800 text-dark-50'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200',
)}
>
<UsersIcon className="h-4 w-4" />
<span>{t('nav.referral')}</span>
</Link>
)}
{isAdmin && (
<>
{/* Separator before admin */}
<div className="mx-2 h-5 w-px bg-dark-700" />
<div className="relative min-w-0">
{/* Left fade */}
<div
className={cn(
'pointer-events-none absolute bottom-0 left-0 top-0 z-10 w-6 bg-gradient-to-r from-dark-950/95 to-transparent transition-opacity duration-200',
navCanScrollLeft ? 'opacity-100' : 'opacity-0',
)}
/>
{/* Right fade */}
<div
className={cn(
'pointer-events-none absolute bottom-0 right-0 top-0 z-10 w-6 bg-gradient-to-l from-dark-950/95 to-transparent transition-opacity duration-200',
navCanScrollRight ? 'opacity-100' : 'opacity-0',
)}
/>
<nav ref={navRef} className="scrollbar-hide flex items-center gap-1 overflow-x-auto">
{desktopNavItems.map((item) => (
<Link
to="/admin"
key={item.path}
to={item.path}
onClick={handleNavClick}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
location.pathname.startsWith('/admin')
? 'bg-warning-500/10 text-warning-400'
: 'text-warning-500/70 hover:bg-warning-500/10 hover:text-warning-400',
'flex shrink-0 items-center gap-2 whitespace-nowrap rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive(item.path)
? 'bg-dark-800 text-dark-50'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200',
)}
>
<ShieldIcon className="h-4 w-4" />
<span>{t('admin.nav.title')}</span>
<item.icon className="h-4 w-4" />
<span>{item.label}</span>
</Link>
</>
)}
</nav>
))}
{referralEnabled && (
<Link
to="/referral"
onClick={handleNavClick}
className={cn(
'flex shrink-0 items-center gap-2 whitespace-nowrap rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive('/referral')
? 'bg-dark-800 text-dark-50'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200',
)}
>
<UsersIcon className="h-4 w-4" />
<span>{t('nav.referral')}</span>
</Link>
)}
{giftEnabled && (
<Link
to="/gift"
onClick={handleNavClick}
className={cn(
'flex shrink-0 items-center gap-2 whitespace-nowrap rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive('/gift')
? 'bg-dark-800 text-dark-50'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200',
)}
>
<GiftIcon className="h-4 w-4" />
<span>{t('nav.gift')}</span>
</Link>
)}
{isAdmin && (
<>
{/* Separator before admin */}
<div className="mx-2 h-5 w-px shrink-0 bg-dark-700" />
<Link
to="/admin"
onClick={handleNavClick}
className={cn(
'flex shrink-0 items-center gap-2 whitespace-nowrap rounded-lg px-3 py-2 text-sm font-medium transition-colors',
location.pathname.startsWith('/admin')
? 'bg-warning-500/10 text-warning-400'
: 'text-warning-500/70 hover:bg-warning-500/10 hover:text-warning-400',
)}
>
<ShieldIcon className="h-4 w-4" />
<span>{t('admin.nav.title')}</span>
</Link>
</>
)}
</nav>
</div>
{/* Right side actions */}
<div className="flex items-center justify-end gap-2">
@@ -415,6 +471,7 @@ export function AppShell({ children }: AppShellProps) {
referralEnabled={referralEnabled}
hasContests={hasContests}
hasPolls={hasPolls}
giftEnabled={giftEnabled}
/>
{/* Desktop spacer */}

View File

@@ -16,6 +16,7 @@ export {
InfoIcon,
CogIcon,
WheelIcon,
GiftIcon,
SearchIcon,
PlusIcon,
ArrowRightIcon,

View File

@@ -0,0 +1,37 @@
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
export function AnimatedCheckmark({ className }: { className?: string }) {
return (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 200, damping: 15, delay: 0.1 }}
className={cn(
'flex h-20 w-20 items-center justify-center rounded-full bg-success-500/10',
className,
)}
>
<motion.svg
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4, delay: 0.3 }}
className="h-10 w-10 text-success-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}
aria-hidden="true"
>
<motion.path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.4, delay: 0.3 }}
/>
</motion.svg>
</motion.div>
);
}

View File

@@ -0,0 +1,37 @@
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
export function AnimatedCrossmark({ className }: { className?: string }) {
return (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 200, damping: 15, delay: 0.1 }}
className={cn(
'flex h-20 w-20 items-center justify-center rounded-full bg-error-500/10',
className,
)}
>
<motion.svg
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: 0.3 }}
className="h-10 w-10 text-error-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<motion.path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.3, delay: 0.3 }}
/>
</motion.svg>
</motion.div>
);
}

View File

@@ -0,0 +1,17 @@
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
export function Spinner({ className }: { className?: string }) {
const { t } = useTranslation();
return (
<div
role="status"
aria-label={t('common.loading')}
className={cn(
'h-8 w-8 animate-spin rounded-full border-2 border-dark-600 border-t-accent-500',
className,
)}
/>
);
}

View File

@@ -1,5 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '@/store/auth';
import { brandingApi } from '@/api/branding';
import { referralApi } from '@/api/referral';
import { wheelApi } from '@/api/wheel';
import { contestsApi } from '@/api/contests';
@@ -40,10 +41,19 @@ export function useFeatureFlags() {
retry: false,
});
const { data: giftConfig } = useQuery({
queryKey: ['gift-enabled'],
queryFn: brandingApi.getGiftEnabled,
enabled: isAuthenticated,
staleTime: 60000,
retry: false,
});
return {
referralEnabled: referralTerms?.is_enabled,
wheelEnabled: wheelConfig?.is_enabled,
hasContests: (contestsCount?.count ?? 0) > 0,
hasPolls: (pollsCount?.count ?? 0) > 0,
giftEnabled: giftConfig?.enabled,
};
}

View File

@@ -44,7 +44,8 @@
"polls": "Polls",
"info": "Info",
"wheel": "Fortune Wheel",
"menu": "Menu"
"menu": "Menu",
"gift": "Gift"
},
"notifications": {
"ticketNotifications": "Ticket Notifications",
@@ -235,13 +236,13 @@
"connectDevice": "Connect Device",
"devicesConnected": "{{count}} connected",
"devicesOfMax": "{{used}} of {{max}} connected",
"devicesConnectedUnlimited": "{{used}} connected · unlimited",
"devicesShort": "dev.",
"trafficUsage": "{{used}} / {{limit}} GB",
"trafficUsageTitle": "Traffic Usage",
"usedTraffic": "used {{amount}}",
"usedSuffix": "used",
"unlimited": "Unlimited",
"unlimitedTraffic": "Unlimited traffic",
"tariff": "Tariff",
"validUntil": "until {{date}}",
"remaining": "Remaining",
@@ -254,11 +255,20 @@
"trialSubtitle": "Trial period ended",
"paidSubtitle": "Subscription has expired",
"renew": "Renew Subscription",
"quickRenew": "Quick Renew",
"insufficientFunds": "Insufficient balance",
"renewError": "Renewal error, try again",
"topUp": "Top up balance",
"balance": "Balance",
"tariffs": "Tariffs",
"traffic": "Traffic",
"devices": "Devices",
"expiredDate": "Expired"
},
"suspended": {
"title": "Subscription Suspended",
"resume": "Resume"
},
"trialOffer": {
"freeTitle": "Free Trial Available",
"paidTitle": "Trial Subscription",
@@ -336,6 +346,9 @@
"daysBeforeExpiry_one": "{{count}} day before expiry",
"daysBeforeExpiry_other": "{{count}} days before expiry",
"selectPeriod": "Select Period",
"noPeriodsAvailable": "No available periods for renewal",
"noPeriodsAvailableHint": "The administrator has changed this plan's settings. Please choose a different plan to renew your subscription.",
"chooseDifferentTariff": "Choose a different plan",
"selectTraffic": "Select Traffic",
"selectServers": "Select Servers",
"selectDevices": "Devices",
@@ -446,6 +459,7 @@
"pause": {
"title": "Subscription Pause",
"paused": "Paused",
"suspended": "Suspended (insufficient funds)",
"active": "Active",
"pauseBtn": "Pause",
"resumeBtn": "Resume",
@@ -690,6 +704,19 @@
"paymentSuccess": {
"title": "Payment Successful",
"message": "Your balance has been topped up successfully. The funds are now available."
},
"topUpResult": {
"awaitingPayment": "Awaiting Payment",
"awaitingPaymentDesc": "We are waiting for your payment confirmation. This may take a few minutes.",
"topUpAmount": "Top-up amount",
"success": "Balance Topped Up!",
"successDesc": "Your balance has been topped up successfully. The funds are now available.",
"failed": "Payment Failed",
"failedDesc": "Unfortunately, the payment was not completed. Please try again or choose a different payment method.",
"timeout": "Taking Longer Than Expected",
"timeoutDesc": "Payment processing is taking longer than usual. You can try checking the status again.",
"goToBalance": "Go to Balance",
"tryAgain": "Try Again"
}
},
"referral": {
@@ -1056,7 +1083,8 @@
"roleAssign": "Role Assignment",
"policies": "Access Policies",
"auditLog": "Audit Log",
"salesStats": "Sales Statistics"
"salesStats": "Sales Statistics",
"landings": "Landings"
},
"panel": {
"title": "Admin Panel",
@@ -1089,7 +1117,8 @@
"roleAssignDesc": "Assign and revoke user roles",
"policiesDesc": "Configure ABAC access policies",
"auditLogDesc": "Review system activity and access history",
"salesStatsDesc": "Sales analytics and trends"
"salesStatsDesc": "Sales analytics and trends",
"landingsDesc": "Quick purchase landing pages"
},
"salesStats": {
"title": "Sales Statistics",
@@ -1683,6 +1712,8 @@
"darkTheme": "Dark theme",
"emailAuth": "Email auth",
"emailAuthDesc": "Allow login via email",
"giftEnabled": "Gift subscription",
"giftEnabledDesc": "Allow users to gift a subscription to another user",
"favoritesEmpty": "No favorites yet",
"favoritesHint": "Star settings to add them here",
"interfaceOptions": "Interface options",
@@ -1692,7 +1723,20 @@
"projectName": "Project name",
"quickPresets": "Quick presets",
"resetAllColors": "Reset all colors",
"statusColors": "Status colors"
"statusColors": "Status colors",
"categories": {
"TELEGRAM_WIDGET": "Telegram Login Widget",
"TELEGRAM_OIDC": "Telegram Login (OIDC)"
},
"settingNames": {
"Telegram Widget Size": "Widget Size",
"Telegram Widget Radius": "Corner Radius",
"Telegram Widget Userpic": "Show User Photo",
"Telegram Widget Request Access": "Request Access",
"telegramOidcEnabled": "OIDC Enabled",
"telegramOidcClientId": "Client ID (Bot ID)",
"telegramOidcClientSecret": "Client Secret"
}
},
"theme": {
"accentColor": "Accent color",
@@ -1720,7 +1764,8 @@
"referral": "Referrals",
"support": "Support",
"info": "Info",
"admin": "Admin Panel"
"admin": "Admin Panel",
"language": "Language"
},
"descriptions": {
"home": "Personal cabinet button",
@@ -1729,7 +1774,8 @@
"referral": "Referral program",
"support": "Support tickets",
"info": "Information section",
"admin": "Web admin panel"
"admin": "Web admin panel",
"language": "Language selection"
},
"styles": {
"default": "No color",
@@ -1738,6 +1784,26 @@
"danger": "Red"
}
},
"menuEditor": {
"dragHint": "Drag rows to reorder them",
"dragToReorder": "Drag to reorder",
"row": "Row",
"addRow": "Add row",
"addButton": "Add button",
"addUrlButton": "URL button (link)",
"builtinButtons": "Built-in buttons",
"resetConfirm": "Reset menu layout to defaults?",
"buttonTextPlaceholder": "Custom button text",
"customLabelsHint": "Empty = default label",
"moveUp": "Move up",
"moveDown": "Move down",
"openIn": "Open in",
"openMode": {
"external": "External browser",
"webapp": "Telegram miniapp"
},
"invalidUrl": "Please provide a valid URL (http:// or https://) for all custom buttons"
},
"apps": {
"title": "App Management",
"addApp": "Add App",
@@ -1921,6 +1987,10 @@
"noPeriodsHint": "No added periods. Add at least one period.",
"daysShort": "d.",
"serversTitle": "Servers",
"externalSquadTitle": "External Squad",
"externalSquadHint": "Assign a RemnaWave external squad for users on this tariff. When a subscription is created, the user will be automatically added to the selected squad.",
"noExternalSquad": "No external squad",
"externalSquadUsers": "users",
"serversTabHint": "Select servers available on this tariff.",
"noServersAvailable": "No servers available",
"promoGroupsTitle": "Promo Groups",
@@ -2025,6 +2095,7 @@
"subtitle": "Manage advertising links",
"createButton": "Create",
"noData": "No ad campaigns",
"loadMore": "Load more",
"bonusType": {
"balance": "Balance",
"subscription": "Subscription",
@@ -2494,6 +2565,7 @@
"form": {
"name": "Group name",
"namePlaceholder": "VIP customers",
"nameRequired": "Enter group name",
"categoryDiscounts": "Category discounts",
"periodDiscounts": "Period discounts",
"add": "Add",
@@ -2505,6 +2577,7 @@
"rub": "rub.",
"autoAssignHint": "0 = don't auto-assign",
"applyToAddons": "Apply to additional services",
"isDefault": "Default group",
"cancel": "Cancel",
"saving": "Saving...",
"save": "Save"
@@ -2880,6 +2953,7 @@
"apps": "Apps",
"email_templates": "Email templates",
"pinned_messages": "Pinned messages",
"landings": "Landing pages",
"updates": "Updates"
},
"permissionActions": {
@@ -3091,6 +3165,7 @@
"filters": {
"title": "Filters",
"active": "Active",
"user": "User",
"action": "Action",
"actionPlaceholder": "Search by action...",
"resource": "Resource type",
@@ -3152,6 +3227,121 @@
"loadFailed": "Failed to load audit log",
"retry": "Try again"
}
},
"landings": {
"title": "Landing Pages",
"create": "Create Landing",
"edit": "Edit",
"slug": "URL Identifier",
"slugHint": "Lowercase, numbers and hyphens",
"pageTitle": "Title",
"subtitle": "Subtitle",
"footerText": "Footer (HTML)",
"features": "Features",
"addFeature": "Add",
"featureIcon": "Icon",
"featureTitle": "Title",
"featureDesc": "Description",
"tariffs": "Plans",
"selectTariffs": "Select plans",
"periods": "Periods",
"paymentMethods": "Payment Methods",
"addMethod": "Add method",
"methodId": "Method ID",
"methodName": "Name",
"methodDesc": "Description",
"methodIcon": "Icon URL",
"gifts": "Gifts",
"giftEnabled": "Gift purchases",
"customCss": "CSS",
"seo": "SEO",
"metaTitle": "Meta Title",
"metaDesc": "Meta Description",
"active": "Active",
"inactive": "Inactive",
"purchaseCount": "purchases",
"copyUrl": "Copy URL",
"urlCopied": "URL copied",
"deleteConfirm": "Delete landing \"{{title}}\"?",
"created": "Landing created",
"updated": "Landing updated",
"deleted": "Landing deleted",
"saveOrder": "Save order",
"orderSaved": "Order saved",
"general": "General",
"content": "Content",
"save": "Save",
"back": "Back",
"invalidSlug": "Slug can only contain lowercase letters, numbers, and hyphens",
"titleRequired": "Title is required",
"noTariffs": "Select at least one tariff",
"noPaymentMethods": "Add at least one payment method",
"selectMethods": "Select available methods",
"noSystemMethods": "No payment methods configured in the system",
"methodOrder": "Drag to reorder",
"methodSubOptions": "Payment sub-options",
"loadingPeriods": "Loading...",
"periodDaySuffix": "d",
"localeTab": "Language",
"localeHint": "Switch language to edit text in that language",
"discount": "Discount",
"discountEnabled": "Enable discount",
"discountPercent": "Discount %",
"discountStartsAt": "Start date",
"discountEndsAt": "End date",
"discountBadge": "Banner text (optional)",
"discountBadgePlaceholder": "e.g. Spring sale!",
"discountOverrides": "Per-tariff overrides",
"discountOverridesHint": "Leave empty to use global discount",
"discountPreview": "Preview",
"discountActive": "Discount",
"statistics": "Statistics",
"stats": {
"title": "Landing Statistics",
"totalPurchases": "Purchases",
"revenue": "Revenue",
"giftPurchases": "Gifts",
"regularPurchases": "Regular",
"conversionRate": "Conversion",
"avgPurchase": "Avg. Check",
"dailyChart": "Purchases & Revenue by Day",
"tariffChart": "Tariff Distribution",
"giftBreakdown": "Gifts vs Regular",
"purchases": "Purchases",
"revenueLabel": "Revenue",
"gifts": "Gifts",
"regular": "Regular",
"created": "Created",
"successful": "Successful",
"funnel": "Funnel",
"loadError": "Failed to load statistics",
"noPurchases": "No purchases"
},
"purchases": {
"title": "Purchases",
"contact": "Contact",
"recipient": "Recipient",
"tariff": "Tariff",
"period": "Period",
"days": "days",
"price": "Price",
"method": "Method",
"date": "Date",
"gift": "Gift",
"forSelf": "For self",
"allStatuses": "All statuses",
"status_pending": "Pending",
"status_paid": "Paid",
"status_delivered": "Delivered",
"status_pending_activation": "Pending activation",
"status_failed": "Failed",
"status_expired": "Expired",
"noPurchases": "No purchases",
"showing": "Showing {{from}}{{to}} of {{total}}",
"page": "Page {{current}} of {{total}}",
"prev": "Previous",
"next": "Next"
}
}
},
"adminUpdates": {
@@ -3879,5 +4069,200 @@
"error": "Failed to merge accounts. Please try again later.",
"expiresIn": "Expires in {{minutes}}",
"merging": "Merging..."
},
"landing": {
"notFound": "Page not found",
"forMe": "For me",
"asGift": "As a gift",
"contactLabel": "Your email or @username",
"yourContact": "Your email or @username",
"contactPlaceholder": "email@example.com or @username",
"contactHint": "Enter email or Telegram @username",
"recipientLabel": "Gift recipient",
"recipientPlaceholder": "Recipient email or @username",
"giftMessageLabel": "Greeting (optional)",
"giftMessagePlaceholder": "Write a greeting...",
"chooseTariff": "Choose a plan",
"devices": "devices",
"paymentMethod": "Payment method",
"processing": "Processing...",
"payButton": "Pay {{price}}",
"accessFor": "Access for {{period}}",
"purchaseError": "Failed to create payment",
"purchaseNotFound": "Purchase not found",
"awaitingPayment": "Awaiting payment",
"awaitingPaymentDesc": "Complete the payment in the opened window",
"purchaseSuccess": "Payment successful!",
"keySentTo": "Subscription key sent to {{contact}}",
"giftSentTo": "Gift sent to {{contact}}",
"purchaseFailed": "Payment failed",
"purchaseFailedDesc": "Payment was unsuccessful. Try again.",
"subscriptionLink": "Subscription link",
"daysAccess": "days of access",
"error": "Error",
"gb": "GB",
"selectedTariff": "Tariff",
"period": "Period",
"total": "Total",
"pay": "Pay",
"choosePeriod": "Choose period",
"copy": "Copy",
"copied": "Copied!",
"copyLink": "Copy link",
"giftToggleLabel": "Purchase type",
"pollTimedOut": "Taking longer than expected",
"pollTimedOutDesc": "Payment processing is taking longer than usual. You can try checking again.",
"pendingActivation": "Subscription ready to activate",
"pendingActivationDesc": "You already have an active subscription. Activating will replace it.",
"activateNow": "Activate now",
"activating": "Activating...",
"activationFailed": "Activation failed",
"giftMessage": "Message",
"cabinetReady": "Your account is ready",
"cabinetEmail": "Email",
"cabinetPassword": "Password",
"goToCabinet": "Go to Cabinet",
"saveCredentials": "Save these login credentials",
"credentialsSentToEmail": "Login credentials sent to your email",
"giftSentSuccess": "Gift sent!",
"giftSentDesc": "Recipient will be notified by email",
"giftPendingActivationDesc": "The recipient already has an active subscription. They will receive a link to activate the gift.",
"giftTelegramSent": "Gift notification sent to recipient via Telegram",
"giftTelegramNotInBot": "Recipient hasn't started the bot yet. Send them this link:",
"giftTelegramPendingSent": "Activation request sent to recipient via Telegram",
"giftTelegramPendingNotInBot": "Recipient hasn't started the bot yet. Send them the bot link so they can activate the gift:",
"openBot": "Open bot",
"autoLoginFailed": "Auto-login failed",
"autoLoginProcessing": "Signing in...",
"discount": {
"days": "d",
"hours": "h",
"minutes": "m",
"seconds": "s"
},
"periodLabels": {
"d1": "1 day",
"d2": "2 days",
"d3": "3 days",
"d5": "5 days",
"d7": "1 week",
"d14": "2 weeks",
"d30": "1 month",
"d60": "2 months",
"d90": "3 months",
"d180": "6 months",
"d365": "1 year",
"d456": "1 year + 3 mo.",
"nDays_one": "{{count}} day",
"nDays_other": "{{count}} days",
"nMonths_one": "{{count}} month",
"nMonths_other": "{{count}} months"
}
},
"gift": {
"title": "Gift Subscription",
"subtitle": "Send a VPN subscription as a gift",
"choosePeriod": "Choose period",
"chooseTariff": "Choose tariff",
"recipient": "Recipient",
"recipientPlaceholder": "Email or @telegram",
"recipientHint": "Enter email or Telegram username",
"giftMessage": "Greeting",
"giftMessagePlaceholder": "Add a personal message (optional)",
"paymentMode": "Payment method",
"fromBalance": "From balance",
"viaGateway": "Via payment gateway",
"yourBalance": "Your balance",
"insufficientBalance": "Insufficient funds",
"topUpBalance": "Top up balance",
"total": "Total",
"giftButton": "Send Gift",
"sending": "Sending gift...",
"gb": "GB",
"devices": "devices",
"paymentMethod": "Payment method",
"processing": "Processing...",
"successTitle": "Gift sent!",
"successDesc": "Recipient {{contact}} will be notified",
"pendingTitle": "Awaiting payment",
"pendingDesc": "Complete the payment in the payment system",
"pendingActivationTitle": "Pending activation",
"pendingActivationDesc": "Recipient has an active subscription. Gift is pending activation.",
"failedTitle": "Error",
"failedDesc": "Failed to send gift. Please try again.",
"backToGift": "Go back",
"backToDashboard": "Back to dashboard",
"tryAgain": "Try again",
"pollTimeout": "Processing is taking longer than usual",
"pollTimeoutDesc": "Try checking the status later",
"pollErrorTitle": "Could not check gift status",
"pollErrorDesc": "Your purchase was successful. Check your dashboard for details.",
"retry": "Check again",
"notFound": "Gift configuration not found",
"noToken": "Invalid link",
"noTokenDesc": "This gift link is invalid or has expired.",
"days": "days",
"tariff": "Tariff",
"period": "Period",
"giftMessageLabel": "Message",
"recipientLabel": "Recipient",
"featureDisabled": "Gift feature is temporarily unavailable",
"redirecting": "Redirecting...",
"pending": {
"title": "You received a gift!",
"from": "from {{sender}}",
"activate": "Activate"
},
"warning": {
"telegram_unresolvable": "Could not verify this Telegram username. The recipient may not receive a notification about the gift."
},
"pageTitle": "Gifts",
"tabBuy": "Buy",
"tabActivate": "Activate",
"tabMyGifts": "My Gifts",
"selectTariff": "SELECT TARIFF",
"selectPeriod": "SUBSCRIPTION PERIOD",
"deviceCount": "{{count}} device",
"deviceCount_other": "{{count}} devices",
"activateTitle": "Enter gift code",
"activateDescription": "Enter the gift code you received from a friend",
"activateCodePlaceholder": "GIFT-XXXXXXXXXXXX",
"activateButton": "Activate gift",
"activating": "Activating...",
"activateSuccess": "Gift activated!",
"activateSuccessDesc": "{{tariff}} — {{days}} days added to your subscription",
"activateError": "Failed to activate gift",
"activateSelfError": "You cannot activate your own gift",
"myGiftsEmpty": "No gifts yet",
"myGiftsEmptyDesc": "Buy a gift for a friend or activate a received code",
"sentGiftsTitle": "Sent",
"activeGiftsTitle": "Active gifts",
"activatedGiftsTitle": "Activated",
"receivedGiftsTitle": "Received",
"statusAvailable": "AVAILABLE",
"statusActivated": "ACTIVATED",
"statusPending": "PENDING",
"statusDelivered": "DELIVERED",
"statusFailed": "FAILED",
"statusExpired": "EXPIRED",
"statusPendingActivation": "PENDING ACTIVATION",
"copyCode": "COPY",
"codeCopied": "Code copied!",
"shareGift": "SHARE",
"shareText": "I have a gift for you! Activate it here:",
"codeReadyTitle": "Gift code is ready!",
"codeLabel": "Gift code",
"sharePreview": "Message to share",
"copyMessage": "Copy",
"activatedBy": "Activated by {{username}}",
"sentTo": "Sent to {{recipient}}",
"daysShort": "days",
"devicesShort": "dev.",
"gbShort": "GB",
"unlimitedTraffic": "Unlimited",
"shareModalActivateVia": "Activate via bot:",
"shareModalActivateViaCabinet": "Or via website:",
"copyMessage": "Copy message",
"shareToastCopied": "Message copied"
}
}

View File

@@ -44,7 +44,8 @@
"polls": "نظرسنجی",
"info": "اطلاعات",
"wheel": "چرخ شانس",
"menu": "منو"
"menu": "منو",
"gift": "هدیه"
},
"notifications": {
"ticketNotifications": "اعلان‌های تیکت",
@@ -219,7 +220,27 @@
"noActiveSubscription": "اشتراک فعال ندارید",
"devicesUsed": "دستگاه‌ها: {{used}} از {{total}}",
"trafficUsed": "ترافیک: {{used}} از {{total}} گیگ",
"unlimitedTraffic": "ترافیک نامحدود"
"unlimitedTraffic": "ترافیک نامحدود",
"expired": {
"title": "اشتراک منقضی شده",
"trialTitle": "دوره آزمایشی منقضی شده",
"trialSubtitle": "دوره آزمایشی پایان یافته",
"paidSubtitle": "اشتراک منقضی شده",
"renew": "تمدید اشتراک",
"quickRenew": "تمدید سریع",
"insufficientFunds": "موجودی ناکافی",
"renewError": "خطا در تمدید، دوباره تلاش کنید",
"topUp": "شارژ موجودی",
"balance": "موجودی",
"tariffs": "تعرفه‌ها",
"traffic": "ترافیک",
"devices": "دستگاه‌ها",
"expiredDate": "منقضی شده"
},
"suspended": {
"title": "اشتراک معلق شده",
"resume": "از سرگیری"
}
},
"subscription": {
"title": "اشتراک",
@@ -276,6 +297,9 @@
"daysBeforeExpiry": "روز قبل از انقضا",
"daysBeforeExpiry_other": "{{count}} روز قبل از انقضا",
"selectPeriod": "انتخاب دوره",
"noPeriodsAvailable": "دوره‌ای برای تمدید موجود نیست",
"noPeriodsAvailableHint": "مدیر تنظیمات این طرح را تغییر داده است. لطفاً طرح دیگری را برای تمدید اشتراک خود انتخاب کنید.",
"chooseDifferentTariff": "انتخاب طرح دیگر",
"selectTraffic": "انتخاب ترافیک",
"selectServers": "انتخاب سرورها",
"selectDevices": "انتخاب دستگاه‌ها",
@@ -428,6 +452,7 @@
"nextCharge": "تا کسر بعدی",
"pauseBtn": "توقف",
"paused": "متوقف شده",
"suspended": "معلق شده (موجودی ناکافی)",
"pausedDescription": "کسر متوقف شد. اشتراک فعال خواهد بود تا",
"pausedInfo": "اشتراک متوقف شده",
"resumeBtn": "ادامه",
@@ -530,6 +555,19 @@
"paymentSuccess": {
"title": "پرداخت موفق",
"message": "موجودی شما با موفقیت شارژ شد. وجوه اکنون در دسترس است."
},
"topUpResult": {
"awaitingPayment": "در انتظار پرداخت",
"awaitingPaymentDesc": "ما منتظر تأیید پرداخت شما هستیم. این ممکن است چند دقیقه طول بکشد.",
"topUpAmount": "مبلغ شارژ",
"success": "شارژ موفق!",
"successDesc": "موجودی شما با موفقیت شارژ شد. وجوه اکنون در دسترس است.",
"failed": "پرداخت ناموفق",
"failedDesc": "متأسفانه پرداخت تکمیل نشد. لطفاً دوباره تلاش کنید یا روش پرداخت دیگری انتخاب کنید.",
"timeout": "بیشتر از حد معمول طول کشید",
"timeoutDesc": "پردازش پرداخت بیشتر از حد معمول طول می‌کشد. می‌توانید دوباره وضعیت را بررسی کنید.",
"goToBalance": "مشاهده موجودی",
"tryAgain": "تلاش مجدد"
}
},
"referral": {
@@ -885,7 +923,8 @@
"roleAssign": "تخصیص نقش",
"policies": "سیاست‌های دسترسی",
"auditLog": "گزارش بازرسی",
"salesStats": "آمار فروش"
"salesStats": "آمار فروش",
"landings": "صفحات فرود"
},
"panel": {
"title": "پنل مدیریت",
@@ -918,7 +957,8 @@
"roleAssignDesc": "تخصیص و لغو نقش‌های کاربران",
"policiesDesc": "تنظیم سیاست‌های کنترل دسترسی ABAC",
"auditLogDesc": "بررسی فعالیت سیستم و تاریخچه دسترسی",
"salesStatsDesc": "تحلیل فروش و روندها"
"salesStatsDesc": "تحلیل فروش و روندها",
"landingsDesc": "صفحات خرید سریع"
},
"salesStats": {
"title": "آمار فروش",
@@ -1349,6 +1389,8 @@
"darkTheme": "پوسته تیره",
"emailAuth": "احراز هویت ایمیل",
"emailAuthDesc": "اجازه ورود با ایمیل",
"giftEnabled": "اشتراک هدیه",
"giftEnabledDesc": "امکان ارسال اشتراک به عنوان هدیه به کاربر دیگر",
"favoritesEmpty": "هنوز علاقه‌مندی ندارید",
"favoritesHint": "ستاره بزنید تا تنظیمات اینجا اضافه شوند",
"interfaceOptions": "گزینه‌های رابط کاربری",
@@ -1358,7 +1400,20 @@
"projectName": "نام پروژه",
"quickPresets": "پیش‌تنظیم‌های سریع",
"resetAllColors": "بازنشانی همه رنگ‌ها",
"statusColors": "رنگ‌های وضعیت"
"statusColors": "رنگ‌های وضعیت",
"categories": {
"TELEGRAM_WIDGET": "Telegram Login Widget",
"TELEGRAM_OIDC": "Telegram Login (OIDC)"
},
"settingNames": {
"Telegram Widget Size": "اندازه ویجت",
"Telegram Widget Radius": "شعاع گوشه",
"Telegram Widget Userpic": "نمایش تصویر کاربر",
"Telegram Widget Request Access": "درخواست دسترسی",
"telegramOidcEnabled": "فعال‌سازی OIDC",
"telegramOidcClientId": "Client ID (شناسه ربات)",
"telegramOidcClientSecret": "Client Secret"
}
},
"buttons": {
"color": "رنگ دکمه",
@@ -1377,7 +1432,8 @@
"referral": "معرفی",
"support": "پشتیبانی",
"info": "اطلاعات",
"admin": "پنل مدیریت"
"admin": "پنل مدیریت",
"language": "زبان"
},
"descriptions": {
"home": "دکمه کابینت شخصی",
@@ -1386,7 +1442,8 @@
"referral": "برنامه معرفی",
"support": "تیکت‌های پشتیبانی",
"info": "بخش اطلاعات",
"admin": "پنل مدیریت وب"
"admin": "پنل مدیریت وب",
"language": "انتخاب زبان ربات"
},
"styles": {
"default": "بدون رنگ",
@@ -1395,6 +1452,26 @@
"danger": "قرمز"
}
},
"menuEditor": {
"dragHint": "ردیف‌ها را بکشید تا ترتیب تغییر کند",
"dragToReorder": "برای مرتب‌سازی بکشید",
"row": "ردیف",
"addRow": "افزودن ردیف",
"addButton": "افزودن دکمه",
"addUrlButton": "دکمه URL (لینک)",
"builtinButtons": "بخش‌های داخلی",
"resetConfirm": "منو به تنظیمات پیش‌فرض بازنشانی شود؟ تمام تغییرات از بین می‌رود.",
"buttonTextPlaceholder": "متن دکمه",
"customLabelsHint": "متن دکمه برای هر زبان ربات",
"moveUp": "انتقال به بالا",
"moveDown": "انتقال به پایین",
"openIn": "باز کردن در",
"openMode": {
"external": "مرورگر خارجی",
"webapp": "مینی‌اپ تلگرام"
},
"invalidUrl": "لطفاً برای تمام دکمه‌های سفارشی یک URL معتبر وارد کنید (http:// یا https://)"
},
"apps": {
"title": "مدیریت برنامه‌ها",
"addApp": "افزودن برنامه",
@@ -1571,6 +1648,10 @@
"noPeriodsHint": "هیچ دوره‌ای اضافه نشده. حداقل یک دوره اضافه کنید.",
"daysShort": "روز",
"serversTitle": "سرورها",
"externalSquadTitle": "گروه خارجی",
"externalSquadHint": "یک گروه خارجی RemnaWave برای کاربران این تعرفه تعیین کنید. هنگام ایجاد اشتراک، کاربر به طور خودکار به گروه انتخاب شده اضافه می‌شود.",
"noExternalSquad": "بدون گروه خارجی",
"externalSquadUsers": "کاربران",
"serversTabHint": "سرورهای موجود در این تعرفه را انتخاب کنید.",
"noServersAvailable": "هیچ سروری در دسترس نیست",
"promoGroupsTitle": "گروه‌های تبلیغاتی",
@@ -2152,6 +2233,7 @@
"form": {
"name": "نام گروه",
"namePlaceholder": "مشتریان VIP",
"nameRequired": "نام گروه را وارد کنید",
"categoryDiscounts": "تخفیف‌های دسته‌بندی",
"periodDiscounts": "تخفیف‌های دوره‌ای",
"add": "افزودن",
@@ -2163,6 +2245,7 @@
"rub": "روبل",
"autoAssignHint": "0 = تخصیص خودکار نشود",
"applyToAddons": "اعمال به خدمات اضافی",
"isDefault": "گروه پیش‌فرض",
"cancel": "انصراف",
"saving": "در حال ذخیره...",
"save": "ذخیره"
@@ -2619,6 +2702,7 @@
"apps": "برنامه‌ها",
"email_templates": "قالب‌های ایمیل",
"pinned_messages": "پیام‌های سنجاق‌شده",
"landings": "صفحات فرود",
"updates": "به‌روزرسانی‌ها"
},
"permissionActions": {
@@ -2826,6 +2910,7 @@
"filters": {
"title": "فیلترها",
"active": "فعال",
"user": "کاربر",
"action": "عملیات",
"actionPlaceholder": "جستجو بر اساس عملیات...",
"resource": "نوع منبع",
@@ -2884,6 +2969,63 @@
"loadFailed": "بارگذاری گزارش بازرسی ناموفق بود",
"retry": "تلاش مجدد"
}
},
"landings": {
"title": "صفحات فرود",
"create": "ایجاد صفحه فرود",
"edit": "ویرایش",
"slug": "شناسه URL",
"slugHint": "حروف کوچک، اعداد و خط تیره",
"pageTitle": "عنوان",
"subtitle": "زیرعنوان",
"footerText": "متن پاورقی (HTML)",
"features": "ویژگی‌ها",
"addFeature": "افزودن",
"featureIcon": "آیکون",
"featureTitle": "عنوان",
"featureDesc": "توضیحات",
"tariffs": "طرح‌ها",
"selectTariffs": "انتخاب طرح‌ها",
"periods": "دوره‌ها",
"paymentMethods": "روش‌های پرداخت",
"addMethod": "افزودن روش",
"methodId": "شناسه روش",
"methodName": "نام",
"methodDesc": "توضیحات",
"methodIcon": "URL آیکون",
"gifts": "هدایا",
"giftEnabled": "خرید هدیه",
"customCss": "CSS",
"seo": "SEO",
"metaTitle": "عنوان متا",
"metaDesc": "توضیحات متا",
"active": "فعال",
"inactive": "غیرفعال",
"purchaseCount": "خرید",
"copyUrl": "کپی URL",
"urlCopied": "URL کپی شد",
"deleteConfirm": "حذف صفحه فرود «{{title}}»؟",
"created": "صفحه فرود ایجاد شد",
"updated": "صفحه فرود به‌روز شد",
"deleted": "صفحه فرود حذف شد",
"saveOrder": "ذخیره ترتیب",
"orderSaved": "ترتیب ذخیره شد",
"general": "عمومی",
"content": "محتوا",
"save": "ذخیره",
"back": "بازگشت",
"invalidSlug": "Slug فقط می‌تواند شامل حروف کوچک، اعداد و خط تیره باشد",
"titleRequired": "عنوان الزامی است",
"noTariffs": "حداقل یک تعرفه انتخاب کنید",
"noPaymentMethods": "حداقل یک روش پرداخت اضافه کنید",
"selectMethods": "انتخاب روش‌های پرداخت موجود",
"noSystemMethods": "هیچ روش پرداختی در سیستم پیکربندی نشده است",
"methodOrder": "برای تغییر ترتیب بکشید",
"methodSubOptions": "گزینه‌های فرعی پرداخت",
"loadingPeriods": "در حال بارگذاری...",
"periodDaySuffix": "روز",
"localeTab": "زبان",
"localeHint": "زبان را تغییر دهید تا متن را به آن زبان ویرایش کنید"
}
},
"adminUpdates": {
@@ -3436,5 +3578,144 @@
"error": "ادغام ناموفق بود. لطفاً بعداً دوباره تلاش کنید.",
"expiresIn": "{{minutes}} دقیقه باقی مانده",
"merging": "در حال ادغام..."
},
"landing": {
"notFound": "صفحه یافت نشد",
"forMe": "برای خودم",
"asGift": "به عنوان هدیه",
"contactLabel": "ایمیل یا @نام کاربری شما",
"yourContact": "ایمیل یا @نام کاربری شما",
"contactPlaceholder": "email@example.com یا @username",
"contactHint": "ایمیل یا @نام کاربری تلگرام را وارد کنید",
"recipientLabel": "گیرنده هدیه",
"recipientPlaceholder": "ایمیل یا @نام کاربری گیرنده",
"giftMessageLabel": "پیام تبریک (اختیاری)",
"giftMessagePlaceholder": "یک پیام تبریک بنویسید...",
"chooseTariff": "یک طرح انتخاب کنید",
"devices": "دستگاه",
"paymentMethod": "روش پرداخت",
"processing": "در حال پردازش...",
"payButton": "پرداخت {{price}}",
"accessFor": "دسترسی برای {{period}}",
"purchaseError": "خطا در ایجاد پرداخت",
"purchaseNotFound": "خرید یافت نشد",
"awaitingPayment": "در انتظار پرداخت",
"awaitingPaymentDesc": "پرداخت را در پنجره باز شده تکمیل کنید",
"purchaseSuccess": "پرداخت موفق بود!",
"keySentTo": "کلید اشتراک به {{contact}} ارسال شد",
"giftSentTo": "هدیه به {{contact}} ارسال شد",
"purchaseFailed": "پرداخت ناموفق",
"purchaseFailedDesc": "پرداخت انجام نشد. دوباره تلاش کنید.",
"subscriptionLink": "لینک اشتراک",
"daysAccess": "روز دسترسی",
"error": "خطا",
"gb": "گیگابایت",
"selectedTariff": "تعرفه",
"period": "دوره",
"total": "مجموع",
"pay": "پرداخت",
"choosePeriod": "انتخاب دوره",
"copy": "کپی",
"copied": "کپی شد!",
"copyLink": "کپی لینک",
"giftToggleLabel": "نوع خرید",
"pollTimedOut": "زمان بیشتری طول کشید",
"pollTimedOutDesc": "پردازش پرداخت بیشتر از حد معمول طول کشیده است. می‌توانید دوباره بررسی کنید.",
"pendingActivation": "اشتراک آماده فعال‌سازی",
"pendingActivationDesc": "شما قبلاً اشتراک فعالی دارید. فعال‌سازی اشتراک جدید جایگزین اشتراک فعلی می‌شود.",
"activateNow": "فعال‌سازی",
"activating": "در حال فعال‌سازی...",
"activationFailed": "خطا در فعال‌سازی",
"giftMessage": "پیام",
"cabinetReady": "حساب شما آماده است",
"cabinetEmail": "ایمیل",
"cabinetPassword": "رمز عبور",
"goToCabinet": "رفتن به پنل کاربری",
"saveCredentials": "این اطلاعات ورود را ذخیره کنید",
"credentialsSentToEmail": "اطلاعات ورود به ایمیل شما ارسال شد",
"giftSentSuccess": "هدیه ارسال شد!",
"giftSentDesc": "گیرنده از طریق ایمیل مطلع خواهد شد",
"giftPendingActivationDesc": "گیرنده در حال حاضر اشتراک فعال دارد. لینک فعال‌سازی هدیه برای او ارسال خواهد شد.",
"giftTelegramSent": "اطلاعیه هدیه از طریق تلگرام به گیرنده ارسال شد",
"giftTelegramNotInBot": "گیرنده هنوز ربات را شروع نکرده است. این لینک را برای او ارسال کنید:",
"giftTelegramPendingSent": "درخواست فعال‌سازی از طریق تلگرام به گیرنده ارسال شد",
"giftTelegramPendingNotInBot": "گیرنده هنوز در ربات نیست. لینک ربات را برایش ارسال کنید تا بتواند هدیه را فعال کند:",
"openBot": "باز کردن ربات",
"autoLoginFailed": "ورود خودکار ناموفق بود",
"autoLoginProcessing": "در حال ورود...",
"periodLabels": {
"d1": "۱ روز",
"d2": "۲ روز",
"d3": "۳ روز",
"d5": "۵ روز",
"d7": "۱ هفته",
"d14": "۲ هفته",
"d30": "۱ ماه",
"d60": "۲ ماه",
"d90": "۳ ماه",
"d180": "۶ ماه",
"d365": "۱ سال",
"d456": "۱ سال و ۳ ماه",
"nDays": "{{count}} روز",
"nMonths": "{{count}} ماه"
}
},
"gift": {
"title": "هدیه اشتراک",
"subtitle": "اشتراک VPN را به عنوان هدیه ارسال کنید",
"choosePeriod": "انتخاب مدت",
"chooseTariff": "انتخاب طرح",
"recipient": "گیرنده",
"recipientPlaceholder": "ایمیل یا @telegram",
"recipientHint": "ایمیل یا نام کاربری تلگرام را وارد کنید",
"giftMessage": "پیام تبریک",
"giftMessagePlaceholder": "پیام شخصی اضافه کنید (اختیاری)",
"paymentMode": "روش پرداخت",
"fromBalance": "از موجودی",
"viaGateway": "درگاه پرداخت",
"yourBalance": "موجودی شما",
"insufficientBalance": "موجودی ناکافی",
"topUpBalance": "شارژ موجودی",
"total": "جمع",
"giftButton": "ارسال هدیه",
"sending": "در حال ارسال هدیه...",
"gb": "گیگابایت",
"devices": "دستگاه",
"paymentMethod": "روش پرداخت",
"processing": "در حال پردازش...",
"successTitle": "هدیه ارسال شد!",
"successDesc": "گیرنده {{contact}} اطلاع‌رسانی خواهد شد",
"pendingTitle": "در انتظار پرداخت",
"pendingDesc": "پرداخت را در سیستم پرداخت تکمیل کنید",
"pendingActivationTitle": "در انتظار فعال‌سازی",
"pendingActivationDesc": "گیرنده اشتراک فعال دارد. هدیه منتظر فعال‌سازی است.",
"failedTitle": "خطا",
"failedDesc": "ارسال هدیه ناموفق بود. لطفاً دوباره تلاش کنید.",
"backToGift": "بازگشت",
"backToDashboard": "بازگشت به داشبورد",
"tryAgain": "تلاش مجدد",
"pollTimeout": "پردازش بیش از حد معمول طول کشیده",
"pollTimeoutDesc": "بعداً وضعیت را بررسی کنید",
"pollErrorTitle": "بررسی وضعیت هدیه امکان‌پذیر نیست",
"pollErrorDesc": "خرید شما موفقیت‌آمیز بود. وضعیت را در داشبورد بررسی کنید.",
"retry": "بررسی مجدد",
"notFound": "تنظیمات هدیه یافت نشد",
"noToken": "لینک نامعتبر",
"noTokenDesc": "این لینک هدیه نامعتبر است یا منقضی شده.",
"days": "روز",
"tariff": "طرح",
"period": "مدت",
"giftMessageLabel": "پیام",
"recipientLabel": "گیرنده",
"featureDisabled": "قابلیت هدیه موقتاً در دسترس نیست",
"redirecting": "در حال انتقال...",
"pending": {
"title": "شما یک هدیه دریافت کردید!",
"from": "از {{sender}}",
"activate": "فعال‌سازی"
},
"warning": {
"telegram_unresolvable": "نام کاربری تلگرام قابل تأیید نبود. ممکن است گیرنده اعلان هدیه را دریافت نکند."
}
}
}

View File

@@ -44,7 +44,8 @@
"polls": "Опросы",
"info": "Информация",
"wheel": "Колесо удачи",
"menu": "Меню"
"menu": "Меню",
"gift": "Подарить"
},
"notifications": {
"ticketNotifications": "Уведомления о тикетах",
@@ -247,13 +248,13 @@
"connectDevice": "Подключить устройство",
"devicesConnected": "{{count}} подключено",
"devicesOfMax": "{{used}} из {{max}} подключено",
"devicesConnectedUnlimited": "{{used}} подключено · без лимита",
"devicesShort": "устр.",
"trafficUsage": "{{used}} / {{limit}} ГБ",
"trafficUsageTitle": "Расход трафика",
"usedTraffic": "использовано {{amount}}",
"usedSuffix": "израсходовано",
"unlimited": "Безлимит",
"unlimitedTraffic": "Безлимитный трафик",
"tariff": "Тариф",
"validUntil": "до {{date}}",
"remaining": "Осталось",
@@ -266,11 +267,20 @@
"trialSubtitle": "Пробный период завершён",
"paidSubtitle": "Срок действия закончился",
"renew": "Продлить подписку",
"quickRenew": "Продлить",
"topUp": "Пополнить баланс",
"balance": "Баланс",
"insufficientFunds": "Недостаточно средств на балансе",
"renewError": "Ошибка продления, попробуйте ещё раз",
"tariffs": "Тарифы",
"traffic": "Трафик",
"devices": "Устройства",
"expiredDate": "Истекла"
},
"suspended": {
"title": "Подписка приостановлена",
"resume": "Возобновить"
},
"trialOffer": {
"freeTitle": "Бесплатный пробный период",
"paidTitle": "Пробная подписка",
@@ -355,6 +365,9 @@
"daysBeforeExpiry_few": "{{count}} дня до окончания",
"daysBeforeExpiry_many": "{{count}} дней до окончания",
"selectPeriod": "Выберите период",
"noPeriodsAvailable": "Нет доступных периодов для продления",
"noPeriodsAvailableHint": "Администратор изменил настройки этого тарифа. Выберите другой тариф для продления подписки.",
"chooseDifferentTariff": "Выбрать другой тариф",
"selectTraffic": "Выберите трафик",
"selectServers": "Выберите серверы",
"selectDevices": "Устройства",
@@ -469,6 +482,7 @@
"pause": {
"title": "Пауза подписки",
"paused": "На паузе",
"suspended": "Приостановлена (недостаточно средств)",
"active": "Активна",
"pauseBtn": "Приостановить",
"resumeBtn": "Возобновить",
@@ -718,6 +732,19 @@
"paymentSuccess": {
"title": "Оплата прошла успешно",
"message": "Ваш баланс успешно пополнен. Средства уже доступны."
},
"topUpResult": {
"awaitingPayment": "Ожидание оплаты",
"awaitingPaymentDesc": "Мы ожидаем подтверждение вашего платежа. Это может занять несколько минут.",
"topUpAmount": "Сумма пополнения",
"success": "Баланс пополнен!",
"successDesc": "Ваш баланс успешно пополнен. Средства уже доступны.",
"failed": "Оплата не прошла",
"failedDesc": "К сожалению, платёж не был завершён. Попробуйте ещё раз или выберите другой способ оплаты.",
"timeout": "Дольше, чем обычно",
"timeoutDesc": "Обработка платежа занимает больше времени. Вы можете проверить статус ещё раз.",
"goToBalance": "Перейти к балансу",
"tryAgain": "Попробовать снова"
}
},
"referral": {
@@ -1077,7 +1104,8 @@
"roleAssign": "Назначение ролей",
"policies": "Политики доступа",
"auditLog": "Журнал аудита",
"salesStats": "Статистика продаж"
"salesStats": "Статистика продаж",
"landings": "Лендинги"
},
"panel": {
"title": "Панель администратора",
@@ -1110,7 +1138,8 @@
"roleAssignDesc": "Назначение и отзыв ролей пользователей",
"policiesDesc": "Настройка политик контроля доступа ABAC",
"auditLogDesc": "Просмотр активности системы и истории доступа",
"salesStatsDesc": "Аналитика продаж и тренды"
"salesStatsDesc": "Аналитика продаж и тренды",
"landingsDesc": "Страницы быстрой покупки"
},
"salesStats": {
"title": "Статистика продаж",
@@ -1693,6 +1722,8 @@
"autoFullscreenDesc": "В Telegram WebApp",
"emailAuth": "Email авторизация",
"emailAuthDesc": "Регистрация и вход через email",
"giftEnabled": "Подписка в подарок",
"giftEnabledDesc": "Возможность отправить подписку в подарок другому пользователю",
"availableThemes": "Доступные темы",
"darkTheme": "Тёмная",
"lightTheme": "Светлая",
@@ -1773,6 +1804,10 @@
"Subscription Show Devices": "Показывать устройства",
"Telegram Stars Enabled": "Stars включены",
"Telegram Stars Rate Rub": "Курс Stars (₽)",
"Telegram Widget Size": "Размер виджета",
"Telegram Widget Radius": "Скругление углов",
"Telegram Widget Userpic": "Показывать аватар",
"Telegram Widget Request Access": "Запрос доступа",
"Tribute Api Key": "API ключ",
"Tribute Donate Link": "Ссылка доната",
"Tribute Enabled": "Tribute включен",
@@ -2161,7 +2196,10 @@
"External Admin Token": "Токен внешней админки",
"External Admin Token Bot Id": "ID бота",
"Bot Username": "Username бота",
"Debug": "Отладка"
"Debug": "Отладка",
"telegramOidcEnabled": "OIDC включён",
"telegramOidcClientId": "Client ID (ID бота)",
"telegramOidcClientSecret": "Client Secret"
},
"categories": {
"PAYMENT": "Платежи",
@@ -2175,6 +2213,8 @@
"PAL24": "PAL24",
"WATA": "Wata",
"TELEGRAM": "Telegram",
"TELEGRAM_WIDGET": "Telegram Login Widget",
"TELEGRAM_OIDC": "Telegram Login (OIDC)",
"SUBSCRIPTIONS_CORE": "Основные",
"SIMPLE_SUBSCRIPTION": "Простая подписка",
"PERIODS": "Периоды",
@@ -2235,7 +2275,8 @@
"referral": "Рефералы",
"support": "Поддержка",
"info": "Информация",
"admin": "Админка"
"admin": "Админка",
"language": "Язык"
},
"descriptions": {
"home": "Кнопка личного кабинета",
@@ -2244,7 +2285,8 @@
"referral": "Реферальная программа",
"support": "Обращения в поддержку",
"info": "Информационный раздел",
"admin": "Веб-админка"
"admin": "Веб-админка",
"language": "Выбор языка"
},
"styles": {
"default": "Без цвета",
@@ -2253,6 +2295,26 @@
"danger": "Красный"
}
},
"menuEditor": {
"dragHint": "Перетаскивайте ряды для изменения порядка",
"dragToReorder": "Перетащите для сортировки",
"row": "Ряд",
"addRow": "Добавить ряд",
"addButton": "Добавить кнопку",
"addUrlButton": "URL-кнопка (ссылка)",
"builtinButtons": "Встроенные кнопки",
"resetConfirm": "Сбросить компоновку меню к значениям по умолчанию?",
"buttonTextPlaceholder": "Свой текст кнопки",
"customLabelsHint": "Пустое поле = название по умолчанию",
"moveUp": "Переместить вверх",
"moveDown": "Переместить вниз",
"openIn": "Открывать в",
"openMode": {
"external": "Внешний браузер",
"webapp": "Telegram miniapp"
},
"invalidUrl": "Укажите корректный URL (http:// или https://) для всех пользовательских кнопок"
},
"apps": {
"title": "Управление приложениями",
"addApp": "Добавить приложение",
@@ -2439,6 +2501,10 @@
"noPeriodsHint": "Нет добавленных периодов. Добавьте хотя бы один период.",
"daysShort": "дн.",
"serversTitle": "Серверы",
"externalSquadTitle": "Внешний сквад",
"externalSquadHint": "Назначьте внешний сквад RemnaWave для пользователей этого тарифа. При создании подписки пользователь будет автоматически добавлен в выбранный сквад.",
"noExternalSquad": "Без внешнего сквада",
"externalSquadUsers": "пользователей",
"serversTabHint": "Выберите серверы, доступные на этом тарифе.",
"noServersAvailable": "Нет доступных серверов",
"promoGroupsTitle": "Промо-группы",
@@ -2543,6 +2609,7 @@
"subtitle": "Управление рекламными ссылками",
"createButton": "Создать",
"noData": "Нет рекламных кампаний",
"loadMore": "Загрузить ещё",
"bonusType": {
"balance": "Баланс",
"subscription": "Подписка",
@@ -3019,6 +3086,7 @@
"form": {
"name": "Название группы",
"namePlaceholder": "VIP клиенты",
"nameRequired": "Введите название группы",
"categoryDiscounts": "Скидки по категориям",
"periodDiscounts": "Скидки по периодам",
"add": "Добавить",
@@ -3030,6 +3098,7 @@
"rub": "руб.",
"autoAssignHint": "0 = не назначать автоматически",
"applyToAddons": "Применять к дополнительным услугам",
"isDefault": "Группа по умолчанию",
"cancel": "Отмена",
"saving": "Сохранение...",
"save": "Сохранить"
@@ -3428,6 +3497,7 @@
"apps": "Приложения",
"email_templates": "Шаблоны писем",
"pinned_messages": "Закреплённые сообщения",
"landings": "Лендинги",
"updates": "Обновления"
},
"permissionActions": {
@@ -3643,6 +3713,7 @@
"filters": {
"title": "Фильтры",
"active": "Активны",
"user": "Пользователь",
"action": "Действие",
"actionPlaceholder": "Поиск по действию...",
"resource": "Тип ресурса",
@@ -3707,6 +3778,122 @@
"loadFailed": "Не удалось загрузить журнал аудита",
"retry": "Попробовать снова"
}
},
"landings": {
"title": "Лендинги",
"create": "Создать лендинг",
"edit": "Редактировать",
"slug": "URL-идентификатор",
"slugHint": "Латиница, цифры и дефис",
"pageTitle": "Заголовок",
"subtitle": "Подзаголовок",
"footerText": "Текст внизу (HTML)",
"features": "Преимущества",
"addFeature": "Добавить",
"featureIcon": "Иконка",
"featureTitle": "Заголовок",
"featureDesc": "Описание",
"tariffs": "Тарифы",
"selectTariffs": "Выберите тарифы",
"periods": "Периоды",
"paymentMethods": "Способы оплаты",
"addMethod": "Добавить метод",
"methodId": "ID метода",
"methodName": "Название",
"methodDesc": "Описание",
"methodIcon": "URL иконки",
"gifts": "Подарки",
"giftEnabled": "Покупка в подарок",
"customCss": "CSS",
"seo": "SEO",
"metaTitle": "Meta Title",
"metaDesc": "Meta Description",
"active": "Активен",
"inactive": "Неактивен",
"purchaseCount": "покупок",
"copyUrl": "Скопировать URL",
"urlCopied": "URL скопирован",
"deleteConfirm": "Удалить лендинг «{{title}}»?",
"created": "Лендинг создан",
"updated": "Лендинг обновлён",
"deleted": "Лендинг удалён",
"saveOrder": "Сохранить порядок",
"orderSaved": "Порядок сохранён",
"general": "Основное",
"content": "Контент",
"save": "Сохранить",
"back": "Назад",
"invalidSlug": "Slug может содержать только строчные буквы, цифры и дефисы",
"titleRequired": "Заголовок обязателен",
"noTariffs": "Выберите хотя бы один тариф",
"noPaymentMethods": "Добавьте хотя бы один способ оплаты",
"selectMethods": "Выберите доступные методы",
"noSystemMethods": "В системе не настроено ни одного способа оплаты",
"methodOrder": "Перетащите для изменения порядка",
"methodSubOptions": "Варианты оплаты",
"loadingPeriods": "Загрузка...",
"periodDaySuffix": "д",
"localeTab": "Язык",
"localeHint": "Переключите язык для редактирования текста на этом языке",
"discount": "Скидка",
"discountEnabled": "Включить скидку",
"discountPercent": "Скидка %",
"discountStartsAt": "Дата начала",
"discountEndsAt": "Дата окончания",
"discountBadge": "Текст баннера (необязательно)",
"discountBadgePlaceholder": "напр. Весенняя распродажа!",
"discountOverrides": "Индивидуальные скидки по тарифам",
"discountOverridesHint": "Оставьте пустым для использования общей скидки",
"discountPreview": "Предпросмотр",
"discountActive": "Скидка",
"background": "Анимированный фон",
"statistics": "Статистика",
"stats": {
"title": "Статистика лендинга",
"totalPurchases": "Покупки",
"revenue": "Доход",
"giftPurchases": "Подарки",
"regularPurchases": "Обычные",
"conversionRate": "Конверсия",
"avgPurchase": "Средний чек",
"dailyChart": "Покупки и доход по дням",
"tariffChart": "Распределение по тарифам",
"giftBreakdown": "Подарки vs обычные",
"purchases": "Покупки",
"revenueLabel": "Доход",
"gifts": "Подарки",
"regular": "Обычные",
"created": "Создано",
"successful": "Успешных",
"funnel": "Воронка",
"loadError": "Не удалось загрузить статистику",
"noPurchases": "Нет покупок"
},
"purchases": {
"title": "Покупки",
"contact": "Контакт",
"recipient": "Получатель",
"tariff": "Тариф",
"period": "Период",
"days": "дн.",
"price": "Цена",
"method": "Метод",
"date": "Дата",
"gift": "Подарок",
"forSelf": "Для себя",
"allStatuses": "Все статусы",
"status_pending": "Ожидание",
"status_paid": "Оплачен",
"status_delivered": "Доставлен",
"status_pending_activation": "Ожидает активации",
"status_failed": "Ошибка",
"status_expired": "Истёк",
"noPurchases": "Нет покупок",
"showing": "Показано {{from}}{{to}} из {{total}}",
"page": "Стр. {{current}} из {{total}}",
"prev": "Назад",
"next": "Далее"
}
}
},
"adminUpdates": {
@@ -4442,5 +4629,207 @@
"error": "Ошибка при объединении. Попробуйте позже.",
"expiresIn": "Действует ещё {{minutes}}",
"merging": "Объединение..."
},
"landing": {
"notFound": "Страница не найдена",
"forMe": "Для себя",
"asGift": "В подарок",
"contactLabel": "Ваш email или @username Telegram",
"yourContact": "Ваш email или @username",
"contactPlaceholder": "email@example.com или @username",
"contactHint": "Введите email или Telegram @username",
"recipientLabel": "Кому подарить",
"recipientPlaceholder": "email или @username получателя",
"giftMessageLabel": "Поздравление (необязательно)",
"giftMessagePlaceholder": "Напишите поздравление...",
"chooseTariff": "Выберите тариф",
"devices": "устройств",
"paymentMethod": "Способ оплаты",
"processing": "Обработка...",
"payButton": "Оплатить {{price}}",
"accessFor": "Доступ на {{period}}",
"purchaseError": "Ошибка при создании оплаты",
"purchaseNotFound": "Покупка не найдена",
"awaitingPayment": "Ожидание оплаты",
"awaitingPaymentDesc": "Завершите оплату в открывшемся окне",
"purchaseSuccess": "Оплата прошла успешно!",
"keySentTo": "Ключ подписки отправлен на {{contact}}",
"giftSentTo": "Подарок отправлен на {{contact}}",
"purchaseFailed": "Ошибка оплаты",
"purchaseFailedDesc": "Оплата не прошла. Попробуйте снова.",
"subscriptionLink": "Ссылка подписки",
"daysAccess": "дней доступа",
"error": "Ошибка",
"gb": "ГБ",
"selectedTariff": "Тариф",
"period": "Период",
"total": "Итого",
"pay": "Оплатить",
"choosePeriod": "Выберите период",
"copy": "Скопировать",
"copied": "Скопировано!",
"copyLink": "Скопировать ссылку",
"giftToggleLabel": "Тип покупки",
"pollTimedOut": "Занимает больше времени, чем ожидалось",
"pollTimedOutDesc": "Обработка платежа занимает больше времени, чем обычно. Вы можете попробовать проверить ещё раз.",
"pendingActivation": "Подписка готова к активации",
"pendingActivationDesc": "У вас уже есть активная подписка. Активация новой заменит текущую.",
"activateNow": "Активировать",
"activating": "Активация...",
"activationFailed": "Ошибка активации",
"giftMessage": "Сообщение",
"cabinetReady": "Ваш аккаунт готов",
"cabinetEmail": "Email",
"cabinetPassword": "Пароль",
"goToCabinet": "Перейти в кабинет",
"saveCredentials": "Сохраните эти данные для входа",
"credentialsSentToEmail": "Данные для входа отправлены на email",
"giftSentSuccess": "Подарок отправлен!",
"giftSentDesc": "Получатель получит уведомление на email",
"giftPendingActivationDesc": "У получателя уже есть активная подписка. Ему будет отправлена ссылка для активации подарка.",
"giftTelegramSent": "Мы отправили получателю уведомление о подарке в Telegram",
"giftTelegramNotInBot": "Получатель пока не пользуется ботом. Отправьте ему эту ссылку, чтобы он мог забрать подарок:",
"giftTelegramPendingSent": "Мы отправили получателю предложение активировать подарок в Telegram",
"giftTelegramPendingNotInBot": "Получатель пока не пользуется ботом. Отправьте ему ссылку, чтобы он мог активировать подарок:",
"openBot": "Ссылка на бота",
"autoLoginFailed": "Не удалось выполнить автоматический вход",
"autoLoginProcessing": "Выполняется вход...",
"discount": {
"days": "д",
"hours": "ч",
"minutes": "м",
"seconds": "с"
},
"periodLabels": {
"d1": "1 день",
"d2": "2 дня",
"d3": "3 дня",
"d5": "5 дней",
"d7": "1 неделя",
"d14": "2 недели",
"d30": "1 месяц",
"d60": "2 месяца",
"d90": "3 месяца",
"d180": "6 месяцев",
"d365": "1 год",
"d456": "1 год + 3 мес.",
"nDays_one": "{{count}} день",
"nDays_few": "{{count}} дня",
"nDays_many": "{{count}} дней",
"nMonths_one": "{{count}} месяц",
"nMonths_few": "{{count}} месяца",
"nMonths_many": "{{count}} месяцев"
}
},
"gift": {
"title": "Подарить подписку",
"subtitle": "Отправьте VPN-подписку в подарок",
"choosePeriod": "Выберите период",
"chooseTariff": "Выберите тариф",
"recipient": "Получатель",
"recipientPlaceholder": "Email или @telegram",
"recipientHint": "Введите email или юзернейм в Telegram",
"giftMessage": "Поздравление",
"giftMessagePlaceholder": "Добавьте личное сообщение (необязательно)",
"paymentMode": "Способ оплаты",
"fromBalance": "С баланса",
"viaGateway": "Через платёжку",
"yourBalance": "Ваш баланс",
"insufficientBalance": "Недостаточно средств",
"topUpBalance": "Пополнить баланс",
"total": "Итого",
"giftButton": "Подарить",
"sending": "Отправляем подарок...",
"gb": "ГБ",
"devices": "устройств",
"paymentMethod": "Способ оплаты",
"processing": "Обработка...",
"successTitle": "Подарок отправлен!",
"successDesc": "Получатель {{contact}} получит уведомление",
"pendingTitle": "Ожидание оплаты",
"pendingDesc": "Завершите оплату в платёжной системе",
"pendingActivationTitle": "Ожидает активации",
"pendingActivationDesc": "У получателя есть активная подписка. Подарок ожидает активации.",
"failedTitle": "Ошибка",
"failedDesc": "Не удалось отправить подарок. Попробуйте снова.",
"backToGift": "Вернуться",
"backToDashboard": "На главную",
"tryAgain": "Попробовать снова",
"pollTimeout": "Обработка занимает больше времени, чем обычно",
"pollTimeoutDesc": "Попробуйте проверить статус позже",
"pollErrorTitle": "Не удалось проверить статус подарка",
"pollErrorDesc": "Ваша покупка прошла успешно. Проверьте статус на главной странице.",
"retry": "Проверить снова",
"notFound": "Конфигурация подарков не найдена",
"noToken": "Ссылка недействительна",
"noTokenDesc": "Ссылка на подарок недействительна или просрочена.",
"days": "дн.",
"tariff": "Тариф",
"period": "Период",
"giftMessageLabel": "Сообщение",
"recipientLabel": "Получатель",
"featureDisabled": "Функция подарков временно недоступна",
"redirecting": "Перенаправляем...",
"pending": {
"title": "Вам подарили подписку!",
"from": "от {{sender}}",
"activate": "Активировать"
},
"warning": {
"telegram_unresolvable": "Не удалось проверить этот Telegram-юзернейм. Получатель может не получить уведомление о подарке."
},
"pageTitle": "Подарки",
"tabBuy": "Купить",
"tabActivate": "Активировать",
"tabMyGifts": "Мои подарки",
"selectTariff": "ВЫБЕРИТЕ ТАРИФ",
"selectPeriod": "ПЕРИОД ПОДПИСКИ",
"deviceCount": "{{count}} устройств",
"deviceCount_one": "{{count}} устройство",
"deviceCount_few": "{{count}} устройства",
"deviceCount_many": "{{count}} устройств",
"activateTitle": "Введите код подарка",
"activateDescription": "Введите код подарка, полученный от друга или знакомого",
"activateCodePlaceholder": "GIFT-XXXXXXXXXXXX",
"activateButton": "Активировать подарок",
"activating": "Активация...",
"activateSuccess": "Подарок активирован!",
"activateSuccessDesc": "{{tariff}} — {{days}} дн. добавлено к вашей подписке",
"activateError": "Не удалось активировать подарок",
"activateSelfError": "Нельзя активировать свой собственный подарок",
"myGiftsEmpty": "У вас пока нет подарков",
"myGiftsEmptyDesc": "Купите подарок для друга или активируйте полученный код",
"sentGiftsTitle": "Отправленные",
"activeGiftsTitle": "Активные подарки",
"activatedGiftsTitle": "Активированные",
"receivedGiftsTitle": "Полученные",
"statusAvailable": "ДОСТУПЕН",
"statusActivated": "АКТИВИРОВАН",
"statusPending": "ОЖИДАЕТ",
"statusDelivered": "ДОСТАВЛЕН",
"statusFailed": "ОШИБКА",
"statusExpired": "ПРОСРОЧЕН",
"statusPendingActivation": "ОЖИДАЕТ АКТИВАЦИИ",
"copyCode": "КОПИРОВАТЬ",
"codeCopied": "Код скопирован!",
"shareGift": "ПОДЕЛИТЬСЯ",
"shareText": "У меня есть подарок для тебя! Активируй его здесь:",
"codeReadyTitle": "Код подарка готов!",
"codeLabel": "Код подарка",
"sharePreview": "Сообщение для отправки",
"copyMessage": "Копировать",
"activatedBy": "Активирован пользователем {{username}}",
"sentTo": "Отправлен {{recipient}}",
"daysShort": "дн.",
"devicesShort": "устр.",
"devicesShort_one": "устр.",
"devicesShort_few": "устр.",
"devicesShort_many": "устр.",
"gbShort": "ГБ",
"unlimitedTraffic": "Безлимит",
"shareModalActivateVia": "Активировать через бота:",
"shareModalActivateViaCabinet": "Или через сайт:",
"copyMessage": "Скопировать сообщение",
"shareToastCopied": "Сообщение скопировано"
}
}

View File

@@ -44,7 +44,8 @@
"polls": "问卷",
"info": "信息",
"wheel": "幸运转盘",
"menu": "菜单"
"menu": "菜单",
"gift": "赠送"
},
"notifications": {
"ticketNotifications": "工单通知",
@@ -219,7 +220,27 @@
"noActiveSubscription": "无有效订阅",
"devicesUsed": "设备:{{used}} / {{total}}",
"trafficUsed": "流量:{{used}} / {{total}} GB",
"unlimitedTraffic": "无限流量"
"unlimitedTraffic": "无限流量",
"expired": {
"title": "订阅已过期",
"trialTitle": "试用已过期",
"trialSubtitle": "试用期已结束",
"paidSubtitle": "订阅已到期",
"renew": "续订",
"quickRenew": "快速续订",
"insufficientFunds": "余额不足",
"renewError": "续订失败,请重试",
"topUp": "充值",
"balance": "余额",
"tariffs": "套餐",
"traffic": "流量",
"devices": "设备",
"expiredDate": "过期时间"
},
"suspended": {
"title": "订阅已暂停",
"resume": "恢复"
}
},
"subscription": {
"title": "订阅",
@@ -276,6 +297,9 @@
"daysBeforeExpiry": "到期前天数",
"daysBeforeExpiry_other": "到期前 {{count}} 天",
"selectPeriod": "选择时长",
"noPeriodsAvailable": "没有可用的续订周期",
"noPeriodsAvailableHint": "管理员已更改此套餐的设置。请选择其他套餐续订您的订阅。",
"chooseDifferentTariff": "选择其他套餐",
"selectTraffic": "选择流量",
"selectServers": "选择服务器",
"selectDevices": "选择设备",
@@ -428,6 +452,7 @@
"nextCharge": "距下次扣费",
"pauseBtn": "暂停",
"paused": "已暂停",
"suspended": "已暂停(余额不足)",
"pausedDescription": "扣费已停止。订阅有效期至",
"pausedInfo": "订阅已暂停",
"resumeBtn": "恢复",
@@ -530,6 +555,19 @@
"paymentSuccess": {
"title": "支付成功",
"message": "您的余额已成功充值,资金现已可用。"
},
"topUpResult": {
"awaitingPayment": "等待付款",
"awaitingPaymentDesc": "我们正在等待您的付款确认。这可能需要几分钟。",
"topUpAmount": "充值金额",
"success": "充值成功!",
"successDesc": "您的余额已成功充值,资金现已可用。",
"failed": "付款失败",
"failedDesc": "很遗憾,付款未完成。请重试或选择其他付款方式。",
"timeout": "处理时间较长",
"timeoutDesc": "付款处理时间比平时长。您可以再次检查状态。",
"goToBalance": "查看余额",
"tryAgain": "重试"
}
},
"referral": {
@@ -885,7 +923,8 @@
"roleAssign": "角色分配",
"policies": "访问策略",
"auditLog": "审计日志",
"salesStats": "销售统计"
"salesStats": "销售统计",
"landings": "落地页"
},
"panel": {
"title": "管理面板",
@@ -918,7 +957,8 @@
"roleAssignDesc": "分配和撤销用户角色",
"policiesDesc": "配置ABAC访问控制策略",
"auditLogDesc": "查看系统活动和访问历史",
"salesStatsDesc": "销售分析与趋势"
"salesStatsDesc": "销售分析与趋势",
"landingsDesc": "快速购买落地页"
},
"salesStats": {
"title": "销售统计",
@@ -1387,6 +1427,8 @@
"darkTheme": "暗色主题",
"emailAuth": "邮箱认证",
"emailAuthDesc": "允许通过邮箱登录",
"giftEnabled": "赠送订阅",
"giftEnabledDesc": "允许用户将订阅作为礼物赠送给其他用户",
"favoritesEmpty": "暂无收藏",
"favoritesHint": "点击星标将设置添加到这里",
"interfaceOptions": "界面选项",
@@ -1396,7 +1438,20 @@
"projectName": "项目名称",
"quickPresets": "快速预设",
"resetAllColors": "重置所有颜色",
"statusColors": "状态颜色"
"statusColors": "状态颜色",
"categories": {
"TELEGRAM_WIDGET": "Telegram Login Widget",
"TELEGRAM_OIDC": "Telegram Login (OIDC)"
},
"settingNames": {
"Telegram Widget Size": "小部件大小",
"Telegram Widget Radius": "圆角半径",
"Telegram Widget Userpic": "显示头像",
"Telegram Widget Request Access": "请求访问权限",
"telegramOidcEnabled": "启用 OIDC",
"telegramOidcClientId": "Client ID机器人 ID",
"telegramOidcClientSecret": "Client Secret"
}
},
"buttons": {
"color": "按钮颜色",
@@ -1415,7 +1470,8 @@
"referral": "推荐",
"support": "支持",
"info": "信息",
"admin": "管理面板"
"admin": "管理面板",
"language": "语言"
},
"descriptions": {
"home": "个人中心按钮",
@@ -1424,7 +1480,8 @@
"referral": "推荐计划",
"support": "支持工单",
"info": "信息板块",
"admin": "网页管理面板"
"admin": "网页管理面板",
"language": "机器人语言选择"
},
"styles": {
"default": "无颜色",
@@ -1433,6 +1490,26 @@
"danger": "红色"
}
},
"menuEditor": {
"dragHint": "拖拽行以重新排序",
"dragToReorder": "拖拽排序",
"row": "行",
"addRow": "添加行",
"addButton": "添加按钮",
"addUrlButton": "URL按钮链接",
"builtinButtons": "内置栏目",
"resetConfirm": "将菜单重置为默认设置?所有更改将丢失。",
"buttonTextPlaceholder": "按钮文本",
"customLabelsHint": "每种语言的按钮文本",
"moveUp": "上移",
"moveDown": "下移",
"openIn": "打开方式",
"openMode": {
"external": "外部浏览器",
"webapp": "Telegram 小程序"
},
"invalidUrl": "请为所有自定义按钮提供有效的URLhttp:// 或 https://"
},
"apps": {
"title": "应用管理",
"addApp": "添加应用",
@@ -1608,6 +1685,10 @@
"noPeriodsHint": "没有添加的周期。请至少添加一个周期。",
"daysShort": "天",
"serversTitle": "服务器",
"externalSquadTitle": "外部小组",
"externalSquadHint": "为此套餐的用户分配RemnaWave外部小组。创建订阅时用户将自动添加到所选小组。",
"noExternalSquad": "无外部小组",
"externalSquadUsers": "用户",
"serversTabHint": "选择此套餐上可用的服务器。",
"noServersAvailable": "没有可用的服务器",
"promoGroupsTitle": "促销组",
@@ -2151,6 +2232,7 @@
"form": {
"name": "组名称",
"namePlaceholder": "VIP客户",
"nameRequired": "请输入组名称",
"categoryDiscounts": "分类折扣",
"periodDiscounts": "期间折扣",
"add": "添加",
@@ -2162,6 +2244,7 @@
"rub": "卢布",
"autoAssignHint": "0 = 不自动分配",
"applyToAddons": "应用于附加服务",
"isDefault": "默认组",
"cancel": "取消",
"saving": "保存中...",
"save": "保存"
@@ -2618,6 +2701,7 @@
"apps": "应用",
"email_templates": "邮件模板",
"pinned_messages": "置顶消息",
"landings": "落地页",
"updates": "更新"
},
"permissionActions": {
@@ -2825,6 +2909,7 @@
"filters": {
"title": "筛选",
"active": "已激活",
"user": "用户",
"action": "操作",
"actionPlaceholder": "按操作搜索...",
"resource": "资源类型",
@@ -2883,6 +2968,63 @@
"loadFailed": "加载审计日志失败",
"retry": "重试"
}
},
"landings": {
"title": "落地页",
"create": "创建落地页",
"edit": "编辑",
"slug": "URL标识符",
"slugHint": "小写字母、数字和连字符",
"pageTitle": "标题",
"subtitle": "副标题",
"footerText": "底部文本 (HTML)",
"features": "功能特点",
"addFeature": "添加",
"featureIcon": "图标",
"featureTitle": "标题",
"featureDesc": "描述",
"tariffs": "套餐",
"selectTariffs": "选择套餐",
"periods": "周期",
"paymentMethods": "支付方式",
"addMethod": "添加方式",
"methodId": "方式ID",
"methodName": "名称",
"methodDesc": "描述",
"methodIcon": "图标URL",
"gifts": "礼物",
"giftEnabled": "礼物购买",
"customCss": "CSS",
"seo": "SEO",
"metaTitle": "Meta标题",
"metaDesc": "Meta描述",
"active": "已激活",
"inactive": "未激活",
"purchaseCount": "次购买",
"copyUrl": "复制URL",
"urlCopied": "URL已复制",
"deleteConfirm": "删除落地页「{{title}}」?",
"created": "落地页已创建",
"updated": "落地页已更新",
"deleted": "落地页已删除",
"saveOrder": "保存排序",
"orderSaved": "排序已保存",
"general": "基本设置",
"content": "内容",
"save": "保存",
"back": "返回",
"invalidSlug": "Slug只能包含小写字母、数字和连字符",
"titleRequired": "标题为必填项",
"noTariffs": "请至少选择一个套餐",
"noPaymentMethods": "请至少添加一个支付方式",
"selectMethods": "选择可用的支付方式",
"noSystemMethods": "系统中未配置任何支付方式",
"methodOrder": "拖动以调整顺序",
"methodSubOptions": "支付子选项",
"loadingPeriods": "加载中...",
"periodDaySuffix": "天",
"localeTab": "语言",
"localeHint": "切换语言以编辑该语言的文本"
}
},
"adminUpdates": {
@@ -3435,5 +3577,144 @@
"error": "合并失败,请稍后重试。",
"expiresIn": "剩余 {{minutes}} 分钟",
"merging": "合并中..."
},
"landing": {
"notFound": "页面未找到",
"forMe": "为自己",
"asGift": "作为礼物",
"contactLabel": "您的邮箱或 @用户名",
"yourContact": "您的邮箱或 @用户名",
"contactPlaceholder": "email@example.com 或 @username",
"contactHint": "输入邮箱或 Telegram @用户名",
"recipientLabel": "赠送对象",
"recipientPlaceholder": "收件人邮箱或 @用户名",
"giftMessageLabel": "祝福语(可选)",
"giftMessagePlaceholder": "写一段祝福语...",
"chooseTariff": "选择套餐",
"devices": "设备",
"paymentMethod": "支付方式",
"processing": "处理中...",
"payButton": "支付 {{price}}",
"accessFor": "{{period}} 访问权限",
"purchaseError": "创建支付失败",
"purchaseNotFound": "未找到购买记录",
"awaitingPayment": "等待支付",
"awaitingPaymentDesc": "请在打开的窗口中完成支付",
"purchaseSuccess": "支付成功!",
"keySentTo": "订阅密钥已发送至 {{contact}}",
"giftSentTo": "礼物已发送至 {{contact}}",
"purchaseFailed": "支付失败",
"purchaseFailedDesc": "支付未成功,请重试。",
"subscriptionLink": "订阅链接",
"daysAccess": "天访问权限",
"error": "错误",
"gb": "GB",
"selectedTariff": "套餐",
"period": "时长",
"total": "总计",
"pay": "支付",
"choosePeriod": "选择时长",
"copy": "复制",
"copied": "已复制!",
"copyLink": "复制链接",
"giftToggleLabel": "购买类型",
"pollTimedOut": "处理时间较长",
"pollTimedOutDesc": "付款处理时间比平时长。您可以尝试再次检查。",
"pendingActivation": "订阅准备激活",
"pendingActivationDesc": "您已有活跃订阅。激活新订阅将替换当前订阅。",
"activateNow": "立即激活",
"activating": "激活中...",
"activationFailed": "激活失败",
"giftMessage": "留言",
"cabinetReady": "您的账户已准备就绪",
"cabinetEmail": "邮箱",
"cabinetPassword": "密码",
"goToCabinet": "前往个人中心",
"saveCredentials": "请保存这些登录信息",
"credentialsSentToEmail": "登录信息已发送到您的邮箱",
"giftSentSuccess": "礼物已发送!",
"giftSentDesc": "收件人将通过邮件收到通知",
"giftPendingActivationDesc": "收件人已有活跃订阅。他们将收到激活礼物的链接。",
"giftTelegramSent": "已通过 Telegram 向收件人发送通知",
"giftTelegramNotInBot": "收件人尚未启动机器人。请将此链接发送给他们:",
"giftTelegramPendingSent": "已通过 Telegram 向收件人发送激活请求",
"giftTelegramPendingNotInBot": "收件人尚未启动机器人。请将机器人链接发送给他们以激活礼物:",
"openBot": "打开机器人",
"autoLoginFailed": "自动登录失败",
"autoLoginProcessing": "正在登录...",
"periodLabels": {
"d1": "1天",
"d2": "2天",
"d3": "3天",
"d5": "5天",
"d7": "1周",
"d14": "2周",
"d30": "1个月",
"d60": "2个月",
"d90": "3个月",
"d180": "6个月",
"d365": "1年",
"d456": "1年3个月",
"nDays": "{{count}}天",
"nMonths": "{{count}}个月"
}
},
"gift": {
"title": "赠送订阅",
"subtitle": "将VPN订阅作为礼物发送",
"choosePeriod": "选择时长",
"chooseTariff": "选择套餐",
"recipient": "收件人",
"recipientPlaceholder": "邮箱或 @telegram",
"recipientHint": "输入邮箱或 Telegram 用户名",
"giftMessage": "祝福语",
"giftMessagePlaceholder": "添加个人消息(可选)",
"paymentMode": "支付方式",
"fromBalance": "余额支付",
"viaGateway": "在线支付",
"yourBalance": "您的余额",
"insufficientBalance": "余额不足",
"topUpBalance": "充值",
"total": "合计",
"giftButton": "赠送",
"sending": "正在发送礼物...",
"gb": "GB",
"devices": "设备",
"paymentMethod": "支付方式",
"processing": "处理中...",
"successTitle": "礼物已发送!",
"successDesc": "收件人 {{contact}} 将收到通知",
"pendingTitle": "等待支付",
"pendingDesc": "请在支付系统中完成支付",
"pendingActivationTitle": "等待激活",
"pendingActivationDesc": "收件人有活跃订阅,礼物等待激活。",
"failedTitle": "错误",
"failedDesc": "礼物发送失败,请重试。",
"backToGift": "返回",
"backToDashboard": "返回首页",
"tryAgain": "重试",
"pollTimeout": "处理时间超出预期",
"pollTimeoutDesc": "请稍后再查看状态",
"pollErrorTitle": "无法检查礼物状态",
"pollErrorDesc": "您的购买已成功。请在仪表板上查看详情。",
"retry": "再次检查",
"notFound": "未找到礼物配置",
"noToken": "无效链接",
"noTokenDesc": "此礼物链接无效或已过期。",
"days": "天",
"tariff": "套餐",
"period": "时长",
"giftMessageLabel": "消息",
"recipientLabel": "收件人",
"featureDisabled": "礼物功能暂时不可用",
"redirecting": "正在跳转...",
"pending": {
"title": "您收到了一份礼物!",
"from": "来自 {{sender}}",
"activate": "激活"
},
"warning": {
"telegram_unresolvable": "无法验证此 Telegram 用户名。收件人可能不会收到礼物通知。"
}
}
}

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { useState, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -84,6 +84,7 @@ const RESOURCE_TYPES = [
'users',
'tickets',
'stats',
'sales_stats',
'broadcasts',
'tariffs',
'promocodes',
@@ -106,6 +107,7 @@ const RESOURCE_TYPES = [
'apps',
'email_templates',
'pinned_messages',
'landings',
'updates',
] as const;
@@ -116,6 +118,7 @@ const PAGE_SIZE_OPTIONS = [20, 50, 100] as const;
const AUTO_REFRESH_INTERVAL = 30_000;
interface FiltersState {
userId: string;
action: string;
resource: string;
status: string;
@@ -124,6 +127,7 @@ interface FiltersState {
}
const INITIAL_FILTERS: FiltersState = {
userId: '',
action: '',
resource: '',
status: '',
@@ -431,9 +435,6 @@ export default function AdminAuditLog() {
const [exporting, setExporting] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
// Auto-refresh interval ref
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Build query params
const queryParams = useMemo((): AuditLogFilters => {
const params: AuditLogFilters = {
@@ -441,6 +442,10 @@ export default function AdminAuditLog() {
offset: page * pageSize,
};
const parsedUserId = parseInt(appliedFilters.userId, 10);
if (!isNaN(parsedUserId) && parsedUserId > 0) {
params.user_id = parsedUserId;
}
if (appliedFilters.action.trim()) {
params.action = appliedFilters.action.trim();
}
@@ -467,21 +472,12 @@ export default function AdminAuditLog() {
refetchInterval: autoRefresh ? AUTO_REFRESH_INTERVAL : false,
});
// Auto-refresh visual indicator
useEffect(() => {
if (autoRefresh) {
intervalRef.current = setInterval(() => {
// The visual indicator updates are driven by isFetching from react-query
}, AUTO_REFRESH_INTERVAL);
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [autoRefresh]);
// RBAC users for filter chips
const { data: rbacUsers } = useQuery({
queryKey: ['rbac-users'],
queryFn: () => rbacApi.getRbacUsers(),
staleTime: 5 * 60 * 1000,
});
const entries = data?.items ?? [];
const total = data?.total ?? 0;
@@ -517,8 +513,11 @@ export default function AdminAuditLog() {
setExporting(true);
try {
const exportParams: AuditLogFilters = {};
const exportUserId = parseInt(appliedFilters.userId, 10);
if (!isNaN(exportUserId) && exportUserId > 0) exportParams.user_id = exportUserId;
if (appliedFilters.action.trim()) exportParams.action = appliedFilters.action.trim();
if (appliedFilters.resource) exportParams.resource_type = appliedFilters.resource;
if (appliedFilters.status) exportParams.status = appliedFilters.status;
if (appliedFilters.dateFrom) exportParams.date_from = appliedFilters.dateFrom;
if (appliedFilters.dateTo) exportParams.date_to = appliedFilters.dateTo;
@@ -548,6 +547,7 @@ export default function AdminAuditLog() {
const hasActiveFilters = useMemo(() => {
return (
appliedFilters.userId.trim() !== '' ||
appliedFilters.action.trim() !== '' ||
appliedFilters.resource !== '' ||
appliedFilters.status !== '' ||
@@ -649,6 +649,51 @@ export default function AdminAuditLog() {
{filtersOpen && (
<div className="border-t border-dark-700 p-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{/* User filter */}
{rbacUsers && rbacUsers.length > 0 && (
<div className="sm:col-span-2 lg:col-span-3">
<label className="mb-1 block text-sm font-medium text-dark-300">
{t('admin.auditLog.filters.user')}
</label>
<div className="flex flex-wrap gap-2">
{rbacUsers.map((ru) => {
const isSelected = filters.userId === String(ru.user_id);
const displayName =
ru.first_name || ru.email || ru.username || `#${ru.user_id}`;
return (
<button
key={ru.user_id}
type="button"
aria-pressed={isSelected}
onClick={() =>
setFilters((prev) => ({
...prev,
userId: isSelected ? '' : String(ru.user_id),
}))
}
className={`flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-500 focus-visible:ring-offset-1 focus-visible:ring-offset-dark-900 ${
isSelected
? 'border-accent-500 bg-accent-500/20 text-accent-300'
: 'border-dark-600 bg-dark-900 text-dark-300 hover:border-dark-500 hover:text-dark-200'
}`}
>
<span
className={`flex h-4 w-4 items-center justify-center rounded border text-xs ${
isSelected
? 'border-accent-500 bg-accent-500 text-white'
: 'border-dark-500 bg-dark-800'
}`}
>
{isSelected && '✓'}
</span>
<span>{displayName}</span>
</button>
);
})}
</div>
</div>
)}
{/* Action search */}
<div>
<label

View File

@@ -1,12 +1,14 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import i18n from '../i18n';
import { campaignsApi, CampaignListItem, CampaignBonusType } from '../api/campaigns';
import { PlusIcon, EditIcon, TrashIcon, CheckIcon, XIcon, ChartIcon } from '../components/icons';
import { usePlatform } from '../platform/hooks/usePlatform';
const PAGE_SIZE = 50;
// Bonus type labels and colors
const bonusTypeConfig: Record<
CampaignBonusType,
@@ -69,9 +71,20 @@ export default function AdminCampaigns() {
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
// Queries
const { data: campaignsData, isLoading } = useQuery({
const {
data: campaignsData,
isLoading,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['admin-campaigns'],
queryFn: () => campaignsApi.getCampaigns(true),
queryFn: ({ pageParam = 0 }) => campaignsApi.getCampaigns(true, pageParam, PAGE_SIZE),
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
const loaded = allPages.reduce((sum, p) => sum + p.campaigns.length, 0);
return loaded < lastPage.total ? loaded : undefined;
},
});
const { data: overview } = useQuery({
@@ -96,7 +109,7 @@ export default function AdminCampaigns() {
},
});
const campaigns = campaignsData?.campaigns || [];
const campaigns = campaignsData?.pages.flatMap((p) => p.campaigns) ?? [];
return (
<div className="animate-fade-in">
@@ -261,6 +274,21 @@ export default function AdminCampaigns() {
</div>
</div>
))}
{/* Load more */}
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
className="flex w-full items-center justify-center gap-2 rounded-xl border border-dark-700 bg-dark-800 py-3 text-sm font-medium text-dark-300 transition-colors hover:border-dark-600 hover:text-dark-100 disabled:opacity-50"
>
{isFetchingNextPage ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-dark-500 border-t-accent-500" />
) : (
t('admin.campaigns.loadMore', 'Load more')
)}
</button>
)}
</div>
)}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,747 @@
import { useState, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import {
adminLandingsApi,
resolveLocaleDisplay,
type PurchaseItemStatus,
type LandingPurchaseItem,
} from '../api/landings';
import { useCurrency } from '../hooks/useCurrency';
import { useChartColors } from '../hooks/useChartColors';
import { CHART_COMMON } from '../constants/charts';
import { AdminBackButton } from '../components/admin';
// Icons
const ChartIcon = () => (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
/>
</svg>
);
const TARIFF_PALETTE = ['#818cf8', '#34d399', '#f59e0b', '#ec4899', '#06b6d4', '#8b5cf6'];
const GIFT_COLOR = '#a855f7';
const PURCHASE_STATUS_STYLES: Record<string, string> = {
pending: 'bg-warning-500/20 text-warning-400',
paid: 'bg-accent-500/20 text-accent-400',
delivered: 'bg-success-500/20 text-success-400',
pending_activation: 'bg-accent-500/20 text-accent-400',
failed: 'bg-error-500/20 text-error-400',
expired: 'bg-dark-500/20 text-dark-400',
};
const PURCHASE_STATUS_OPTIONS: Array<PurchaseItemStatus | 'all'> = [
'all',
'pending',
'paid',
'delivered',
'pending_activation',
'failed',
'expired',
];
const PURCHASES_PAGE_SIZE = 20;
// Small icons for the purchase cards
const EmailIcon = () => (
<svg
className="h-3.5 w-3.5 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
/>
</svg>
);
const TelegramSmallIcon = () => (
<svg className="h-3.5 w-3.5 shrink-0" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
</svg>
);
const ArrowRightIcon = () => (
<svg
className="h-3 w-3 shrink-0 text-dark-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
);
const GiftIcon = () => (
<svg
className="h-3.5 w-3.5 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
/>
</svg>
);
const ChevronLeftSmall = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
);
const ChevronRightSmall = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
);
// Contact display helper
function ContactDisplay({ type, value }: { type: 'email' | 'telegram'; value: string }) {
return (
<span className="flex items-center gap-1 text-dark-300">
{type === 'email' ? <EmailIcon /> : <TelegramSmallIcon />}
<span className="min-w-0 truncate text-xs">{value}</span>
</span>
);
}
// Purchase card component
interface PurchaseCardProps {
item: LandingPurchaseItem;
formatPrice: (kopeks: number) => string;
lang: string;
t: (key: string, opts?: Record<string, unknown>) => string;
}
function PurchaseCard({ item, formatPrice, lang, t }: PurchaseCardProps) {
const statusStyle = PURCHASE_STATUS_STYLES[item.status] || 'bg-dark-600 text-dark-300';
const dateStr = new Date(item.created_at).toLocaleDateString(lang, {
day: 'numeric',
month: 'short',
year: 'numeric',
});
return (
<div className="rounded-xl border border-dark-700/50 bg-dark-800/40 p-3 transition-colors hover:border-dark-600 sm:p-4">
{/* Mobile: stacked | Desktop: horizontal */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
{/* Status badge */}
<div className="shrink-0">
<span
className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium ${statusStyle}`}
>
{t(`admin.landings.purchases.status_${item.status}`)}
</span>
</div>
{/* Contact info */}
<div className="min-w-0 flex-1">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-3">
<ContactDisplay type={item.contact_type} value={item.contact_value} />
{item.is_gift && item.gift_recipient_type && item.gift_recipient_value && (
<span className="flex items-center gap-1">
<ArrowRightIcon />
<ContactDisplay type={item.gift_recipient_type} value={item.gift_recipient_value} />
</span>
)}
</div>
</div>
{/* Tariff + period */}
<div className="shrink-0 text-sm text-dark-200">
<span className="font-medium">{item.tariff_name}</span>
<span className="text-dark-500">
{' '}
&middot; {item.period_days} {t('admin.landings.purchases.days')}
</span>
</div>
{/* Price */}
<div className="shrink-0 text-sm font-medium text-dark-100">
{formatPrice(item.amount_kopeks)}
</div>
{/* Payment method */}
<div className="shrink-0 text-xs text-dark-500">{item.payment_method}</div>
{/* Gift badge */}
{item.is_gift && (
<div className="shrink-0">
<span className="inline-flex items-center gap-1 rounded-md bg-purple-500/20 px-1.5 py-0.5 text-xs text-purple-400">
<GiftIcon />
{t('admin.landings.purchases.gift')}
</span>
</div>
)}
{/* Date */}
<div className="shrink-0 text-xs text-dark-500">{dateStr}</div>
</div>
</div>
);
}
export default function AdminLandingStats() {
const { t, i18n } = useTranslation();
const { id } = useParams<{ id: string }>();
const numericId = id ? Number(id) : NaN;
const isValidId = !isNaN(numericId);
const navigate = useNavigate();
const { formatWithCurrency } = useCurrency();
const colors = useChartColors();
// Purchases list state
const [purchaseOffset, setPurchaseOffset] = useState(0);
const [purchaseStatusFilter, setPurchaseStatusFilter] = useState<PurchaseItemStatus | 'all'>(
'all',
);
// Fetch stats
const {
data: stats,
isLoading,
error,
} = useQuery({
queryKey: ['landing-stats', numericId],
queryFn: () => adminLandingsApi.getStats(numericId),
enabled: isValidId,
staleTime: CHART_COMMON.STALE_TIME,
});
// Fetch landing detail for header
const { data: landing } = useQuery({
queryKey: ['admin-landing', numericId],
queryFn: () => adminLandingsApi.get(numericId),
enabled: isValidId,
staleTime: CHART_COMMON.STALE_TIME,
});
// Fetch purchases list
const { data: purchasesData, isLoading: purchasesLoading } = useQuery({
queryKey: [
'landing-purchases',
numericId,
purchaseOffset,
PURCHASES_PAGE_SIZE,
purchaseStatusFilter,
],
queryFn: () =>
adminLandingsApi.getPurchases(
numericId,
purchaseOffset,
PURCHASES_PAGE_SIZE,
purchaseStatusFilter === 'all' ? undefined : purchaseStatusFilter,
),
enabled: isValidId,
staleTime: CHART_COMMON.STALE_TIME,
});
const purchaseItems = purchasesData?.items ?? [];
const purchaseTotal = purchasesData?.total ?? 0;
const purchaseTotalPages = Math.ceil(purchaseTotal / PURCHASES_PAGE_SIZE);
const purchaseCurrentPage = Math.floor(purchaseOffset / PURCHASES_PAGE_SIZE) + 1;
// Prepare daily chart data
const dailyData = useMemo(() => {
if (!stats) return [];
return stats.daily_stats.map((item) => ({
label: new Date(item.date + 'T00:00:00').toLocaleDateString(i18n.language, {
month: 'short',
day: 'numeric',
}),
purchases: item.purchases,
revenue: item.revenue_kopeks / CHART_COMMON.KOPEKS_DIVISOR,
gifts: item.gifts,
}));
}, [stats, i18n.language]);
// Prepare tariff chart data
const tariffData = useMemo(() => {
if (!stats) return [];
return stats.tariff_stats.map((item) => ({
name: item.tariff_name,
purchases: item.purchases,
revenue: item.revenue_kopeks / CHART_COMMON.KOPEKS_DIVISOR,
}));
}, [stats]);
// Donut data for gift vs regular
const donutData = useMemo(() => {
if (!stats) return [];
return [
{
name: t('admin.landings.stats.regular'),
value: stats.total_regular,
color: colors.referrals,
},
{ name: t('admin.landings.stats.gifts'), value: stats.total_gifts, color: GIFT_COLOR },
];
}, [stats, t, colors.referrals]);
// Bar chart height based on tariff count
const barChartHeight = useMemo(() => {
const count = tariffData.length;
return Math.max(220, count * 45 + 40);
}, [tariffData.length]);
// Loading state
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
);
}
// Error state
if (error || !stats) {
return (
<div className="animate-fade-in">
<div className="mb-6 flex items-center gap-3">
<AdminBackButton to="/admin/landings" />
<h1 className="text-xl font-semibold text-dark-100">{t('admin.landings.stats.title')}</h1>
</div>
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-6 text-center">
<p className="text-error-400">{t('admin.landings.stats.loadError')}</p>
<button
onClick={() => navigate('/admin/landings')}
className="mt-4 text-sm text-dark-400 hover:text-dark-200"
>
{t('common.back')}
</button>
</div>
</div>
);
}
const landingTitle = landing ? resolveLocaleDisplay(landing.title) : `#${numericId}`;
return (
<div className="animate-fade-in">
{/* Header */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<AdminBackButton to="/admin/landings" />
<div className="rounded-lg bg-accent-500/20 p-2 text-accent-400">
<ChartIcon />
</div>
<div className="min-w-0">
<h1 className="truncate text-xl font-semibold text-dark-100">{landingTitle}</h1>
<div className="mt-1 flex items-center gap-2">
{landing?.is_active ? (
<span className="rounded bg-success-500/20 px-2 py-0.5 text-xs text-success-400">
{t('admin.landings.active')}
</span>
) : (
<span className="rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-400">
{t('admin.landings.inactive')}
</span>
)}
</div>
</div>
</div>
</div>
<div className="space-y-6">
{/* Summary Cards */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
<div className="text-xl font-bold text-accent-400 sm:text-2xl">
{stats.total_purchases}
</div>
<div className="text-xs text-dark-500">{t('admin.landings.stats.totalPurchases')}</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
<div className="truncate text-xl font-bold text-success-400 sm:text-2xl">
{formatWithCurrency(stats.total_revenue_kopeks / CHART_COMMON.KOPEKS_DIVISOR)}
</div>
<div className="text-xs text-dark-500">{t('admin.landings.stats.revenue')}</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
<div className="text-xl font-bold text-purple-400 sm:text-2xl">{stats.total_gifts}</div>
<div className="text-xs text-dark-500">{t('admin.landings.stats.giftPurchases')}</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
<div className="text-xl font-bold text-warning-400 sm:text-2xl">
{stats.conversion_rate}%
</div>
<div className="text-xs text-dark-500">{t('admin.landings.stats.conversionRate')}</div>
</div>
</div>
{/* Charts */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{/* Daily Purchases & Revenue */}
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<h3 className="mb-4 font-medium text-dark-200">
{t('admin.landings.stats.dailyChart')}
</h3>
{dailyData.length === 0 ? (
<div className="flex h-[220px] items-center justify-center text-sm text-dark-500">
{t('admin.landings.stats.noPurchases')}
</div>
) : (
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={dailyData} margin={CHART_COMMON.CHART.MARGIN}>
<defs>
<linearGradient
id={`landingPurchaseGrad-${numericId}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset={CHART_COMMON.GRADIENT.START_OFFSET}
stopColor={colors.referrals}
stopOpacity={CHART_COMMON.GRADIENT.START_OPACITY}
/>
<stop
offset={CHART_COMMON.GRADIENT.END_OFFSET}
stopColor={colors.referrals}
stopOpacity={CHART_COMMON.GRADIENT.END_OPACITY}
/>
</linearGradient>
<linearGradient
id={`landingRevenueGrad-${numericId}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset={CHART_COMMON.GRADIENT.START_OFFSET}
stopColor={colors.earnings}
stopOpacity={CHART_COMMON.GRADIENT.START_OPACITY}
/>
<stop
offset={CHART_COMMON.GRADIENT.END_OFFSET}
stopColor={colors.earnings}
stopOpacity={CHART_COMMON.GRADIENT.END_OPACITY}
/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray={CHART_COMMON.GRID_DASH} stroke={colors.grid} />
<XAxis
dataKey="label"
tick={{ fontSize: CHART_COMMON.AXIS.TICK_FONT_SIZE, fill: colors.tick }}
stroke={colors.grid}
interval="preserveStartEnd"
/>
<YAxis
yAxisId="left"
tick={{ fontSize: CHART_COMMON.AXIS.TICK_FONT_SIZE, fill: colors.tick }}
stroke={colors.grid}
allowDecimals={false}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: CHART_COMMON.AXIS.TICK_FONT_SIZE, fill: colors.tick }}
stroke={colors.grid}
/>
<Tooltip
contentStyle={{
backgroundColor: colors.tooltipBg,
border: `1px solid ${colors.tooltipBorder}`,
borderRadius: CHART_COMMON.TOOLTIP.BORDER_RADIUS,
fontSize: CHART_COMMON.TOOLTIP.FONT_SIZE,
}}
labelStyle={{ color: colors.label }}
/>
<Area
yAxisId="left"
type="monotone"
dataKey="purchases"
name={t('admin.landings.stats.purchases')}
stroke={colors.referrals}
fill={`url(#landingPurchaseGrad-${numericId})`}
strokeWidth={CHART_COMMON.STROKE_WIDTH}
/>
<Area
yAxisId="right"
type="monotone"
dataKey="revenue"
name={t('admin.landings.stats.revenueLabel')}
stroke={colors.earnings}
fill={`url(#landingRevenueGrad-${numericId})`}
strokeWidth={CHART_COMMON.STROKE_WIDTH}
/>
</AreaChart>
</ResponsiveContainer>
)}
</div>
{/* Tariff Distribution */}
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<h3 className="mb-4 font-medium text-dark-200">
{t('admin.landings.stats.tariffChart')}
</h3>
{tariffData.length === 0 ? (
<div className="flex h-[220px] items-center justify-center text-sm text-dark-500">
{t('admin.landings.stats.noPurchases')}
</div>
) : (
<ResponsiveContainer width="100%" height={barChartHeight}>
<BarChart
data={tariffData}
layout="vertical"
margin={{ ...CHART_COMMON.CHART.MARGIN, left: 10 }}
>
<CartesianGrid strokeDasharray={CHART_COMMON.GRID_DASH} stroke={colors.grid} />
<XAxis
type="number"
tick={{ fontSize: CHART_COMMON.AXIS.TICK_FONT_SIZE, fill: colors.tick }}
stroke={colors.grid}
allowDecimals={false}
/>
<YAxis
type="category"
dataKey="name"
tick={{ fontSize: CHART_COMMON.AXIS.TICK_FONT_SIZE, fill: colors.tick }}
stroke={colors.grid}
width={100}
/>
<Tooltip
contentStyle={{
backgroundColor: colors.tooltipBg,
border: `1px solid ${colors.tooltipBorder}`,
borderRadius: CHART_COMMON.TOOLTIP.BORDER_RADIUS,
fontSize: CHART_COMMON.TOOLTIP.FONT_SIZE,
}}
labelStyle={{ color: colors.label }}
formatter={(value: number | undefined) => {
return [value ?? 0, t('admin.landings.stats.purchases')];
}}
/>
<Bar
dataKey="purchases"
name={t('admin.landings.stats.purchases')}
radius={[0, 4, 4, 0]}
>
{tariffData.map((_, index) => (
<Cell key={index} fill={TARIFF_PALETTE[index % TARIFF_PALETTE.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
{/* Additional Stats Row */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="mb-1 text-sm text-dark-400">
{t('admin.landings.stats.avgPurchase')}
</div>
<div className="text-lg font-medium text-dark-200">
{formatWithCurrency(stats.avg_purchase_kopeks / CHART_COMMON.KOPEKS_DIVISOR)}
</div>
</div>
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<div className="mb-1 text-sm text-dark-400">
{t('admin.landings.stats.regularPurchases')}
</div>
<div className="text-lg font-medium text-dark-200">{stats.total_regular}</div>
</div>
<div className="col-span-2 rounded-xl border border-dark-700 bg-dark-800 p-4 sm:col-span-1">
<div className="mb-1 text-sm text-dark-400">{t('admin.landings.stats.funnel')}</div>
<div className="text-lg font-medium text-dark-200">
{stats.total_created}{' '}
<span className="text-sm text-dark-500">{t('admin.landings.stats.created')}</span>
{' / '}
{stats.total_successful}{' '}
<span className="text-sm text-dark-500">{t('admin.landings.stats.successful')}</span>
</div>
</div>
</div>
{/* Gift vs Regular Donut */}
{stats.total_purchases > 0 && (
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
<h3 className="mb-4 font-medium text-dark-200">
{t('admin.landings.stats.giftBreakdown')}
</h3>
<div className="flex items-center justify-center gap-8">
<div className="relative">
<ResponsiveContainer width={160} height={160}>
<PieChart>
<Pie
data={donutData}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={70}
dataKey="value"
strokeWidth={0}
>
{donutData.map((entry, index) => (
<Cell key={index} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: colors.tooltipBg,
border: `1px solid ${colors.tooltipBorder}`,
borderRadius: CHART_COMMON.TOOLTIP.BORDER_RADIUS,
fontSize: CHART_COMMON.TOOLTIP.FONT_SIZE,
}}
/>
</PieChart>
</ResponsiveContainer>
{/* Center text */}
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-lg font-bold text-dark-100">{stats.total_purchases}</span>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center gap-2">
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: colors.referrals }}
/>
<span className="text-sm text-dark-300">
{t('admin.landings.stats.regular')}: {stats.total_regular}
</span>
</div>
<div className="flex items-center gap-2">
<div className="h-3 w-3 rounded-full" style={{ backgroundColor: GIFT_COLOR }} />
<span className="text-sm text-dark-300">
{t('admin.landings.stats.gifts')}: {stats.total_gifts}
</span>
</div>
</div>
</div>
</div>
)}
{/* Purchases List */}
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
{/* Header row: title + status filter */}
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h3 className="font-medium text-dark-200">{t('admin.landings.purchases.title')}</h3>
<select
value={purchaseStatusFilter}
onChange={(e) => {
setPurchaseStatusFilter(e.target.value as PurchaseItemStatus | 'all');
setPurchaseOffset(0);
}}
className="rounded-lg border border-dark-600 bg-dark-900 px-3 py-1.5 text-sm text-dark-200 outline-none transition-colors focus:border-accent-500"
aria-label={t('admin.landings.purchases.allStatuses')}
>
{PURCHASE_STATUS_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{opt === 'all'
? t('admin.landings.purchases.allStatuses')
: t(`admin.landings.purchases.status_${opt}`)}
</option>
))}
</select>
</div>
{/* Content */}
{purchasesLoading ? (
<div className="flex items-center justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : purchaseItems.length === 0 ? (
<div className="py-8 text-center text-sm text-dark-500">
{t('admin.landings.purchases.noPurchases')}
</div>
) : (
<>
<div className="space-y-2">
{purchaseItems.map((item) => (
<PurchaseCard
key={item.id}
item={item}
formatPrice={(kopeks) =>
formatWithCurrency(kopeks / CHART_COMMON.KOPEKS_DIVISOR)
}
lang={i18n.language}
t={t}
/>
))}
</div>
{/* Pagination */}
{purchaseTotalPages > 1 && (
<div className="mt-4 flex flex-col items-center gap-2 sm:flex-row sm:justify-between">
<span className="text-xs text-dark-500">
{t('admin.landings.purchases.showing', {
from: purchaseOffset + 1,
to: Math.min(purchaseOffset + PURCHASES_PAGE_SIZE, purchaseTotal),
total: purchaseTotal,
})}
</span>
<div className="flex items-center gap-2">
<button
onClick={() =>
setPurchaseOffset((prev) => Math.max(0, prev - PURCHASES_PAGE_SIZE))
}
disabled={purchaseOffset === 0}
className="flex items-center gap-1 rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-300 transition-colors hover:border-dark-600 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-40"
aria-label={t('admin.landings.purchases.prev')}
>
<ChevronLeftSmall />
<span className="hidden sm:inline">{t('admin.landings.purchases.prev')}</span>
</button>
<span className="px-2 text-xs text-dark-400">
{t('admin.landings.purchases.page', {
current: purchaseCurrentPage,
total: purchaseTotalPages,
})}
</span>
<button
onClick={() => setPurchaseOffset((prev) => prev + PURCHASES_PAGE_SIZE)}
disabled={purchaseOffset + PURCHASES_PAGE_SIZE >= purchaseTotal}
className="flex items-center gap-1 rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-300 transition-colors hover:border-dark-600 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-40"
aria-label={t('admin.landings.purchases.next')}
>
<span className="hidden sm:inline">{t('admin.landings.purchases.next')}</span>
<ChevronRightSmall />
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
</div>
);
}

514
src/pages/AdminLandings.tsx Normal file
View File

@@ -0,0 +1,514 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { adminLandingsApi, LandingListItem, resolveLocaleDisplay } from '../api/landings';
import { useNotify } from '@/platform';
import { copyToClipboard } from '../utils/clipboard';
import { getApiErrorMessage } from '../utils/api-error';
import { usePlatform } from '../platform/hooks/usePlatform';
import { cn } from '../lib/utils';
import { BackIcon, PlusIcon, TrashIcon, GripIcon } from '../components/icons/LandingIcons';
import {
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
// Icons (non-shared, page-specific)
const EditIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
);
const CheckIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
);
const XIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);
const GiftIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
/>
</svg>
);
const SaveIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
);
const CopyIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9.75a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
/>
</svg>
);
const StatsChartIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
/>
</svg>
);
// ============ Sortable Landing Card ============
interface SortableLandingCardProps {
landing: LandingListItem;
onEdit: () => void;
onStats: () => void;
onDelete: () => void;
onToggle: () => void;
onCopyUrl: () => void;
isPendingDelete?: boolean;
}
function SortableLandingCard({
landing,
onEdit,
onStats,
onDelete,
onToggle,
onCopyUrl,
isPendingDelete,
}: SortableLandingCardProps) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: landing.id,
});
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 50 : undefined,
position: isDragging ? 'relative' : undefined,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'rounded-xl border bg-dark-800 p-3 transition-colors sm:p-4',
isDragging
? 'border-accent-500/50 shadow-xl shadow-accent-500/20'
: landing.is_active
? 'border-dark-700'
: 'border-dark-700/50 opacity-60',
)}
>
<div className="flex items-start gap-2 sm:gap-3">
{/* Drag handle */}
<button
{...attributes}
{...listeners}
className="mt-0.5 flex-shrink-0 cursor-grab touch-none rounded-lg p-2 text-dark-500 hover:bg-dark-700/50 hover:text-dark-300 active:cursor-grabbing sm:mt-1 sm:p-1.5"
title={t('admin.tariffs.dragToReorder')}
>
<GripIcon />
</button>
{/* Content + Actions wrapper */}
<div className="min-w-0 flex-1">
{/* Top row: title/slug + actions (desktop) */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="mb-1 flex flex-wrap items-center gap-1.5 sm:gap-2">
<h3 className="truncate font-medium text-dark-100">
{resolveLocaleDisplay(landing.title)}
</h3>
<span className="shrink-0 rounded bg-dark-800 px-2 py-0.5 text-xs text-dark-400">
{landing.slug}
</span>
{landing.is_active ? (
<span className="shrink-0 rounded bg-success-500/20 px-2 py-0.5 text-xs text-success-400">
{t('admin.landings.active')}
</span>
) : (
<span className="shrink-0 rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-400">
{t('admin.landings.inactive')}
</span>
)}
{landing.gift_enabled && (
<span className="shrink-0 rounded bg-accent-500/20 px-1.5 py-0.5 text-xs text-accent-400">
<GiftIcon />
</span>
)}
{landing.has_active_discount && (
<span className="rounded-full bg-accent-500/20 px-2 py-0.5 text-[10px] font-medium text-accent-400">
{t('admin.landings.discountActive', 'Discount')}
</span>
)}
</div>
<div className="text-sm text-dark-400">
<span>
{landing.purchase_stats.total} {t('admin.landings.purchaseCount')}
</span>
</div>
</div>
{/* Actions: hidden on mobile, shown on desktop */}
<div className="hidden shrink-0 items-center gap-1.5 sm:flex">
<button
onClick={onCopyUrl}
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
title={t('admin.landings.copyUrl')}
>
<CopyIcon />
</button>
<button
onClick={onStats}
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
title={t('admin.landings.statistics')}
>
<StatsChartIcon />
</button>
<button
onClick={onToggle}
className={cn(
'rounded-lg p-2 transition-colors',
landing.is_active
? 'bg-success-500/20 text-success-400 hover:bg-success-500/30'
: 'bg-dark-700 text-dark-400 hover:bg-dark-600',
)}
title={
landing.is_active ? t('admin.landings.inactive') : t('admin.landings.active')
}
>
{landing.is_active ? <CheckIcon /> : <XIcon />}
</button>
<button
onClick={onEdit}
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
title={t('admin.landings.edit')}
>
<EditIcon />
</button>
<button
onClick={onDelete}
className={cn(
'rounded-lg p-2 transition-colors',
isPendingDelete
? 'bg-error-500/20 text-error-400 ring-1 ring-error-500/30'
: 'bg-dark-700 text-dark-300 hover:bg-error-500/20 hover:text-error-400',
)}
title={
isPendingDelete
? t('admin.landings.deleteConfirm', {
title: resolveLocaleDisplay(landing.title),
})
: t('common.delete')
}
>
{isPendingDelete ? (
<span className="px-1 text-xs font-medium">{t('common.delete')}?</span>
) : (
<TrashIcon />
)}
</button>
</div>
</div>
{/* Actions: shown on mobile only */}
<div className="mt-2 flex items-center gap-1.5 sm:hidden">
<button
onClick={onCopyUrl}
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
title={t('admin.landings.copyUrl')}
>
<CopyIcon />
</button>
<button
onClick={onStats}
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
title={t('admin.landings.statistics')}
>
<StatsChartIcon />
</button>
<button
onClick={onToggle}
className={cn(
'rounded-lg p-2 transition-colors',
landing.is_active
? 'bg-success-500/20 text-success-400 hover:bg-success-500/30'
: 'bg-dark-700 text-dark-400 hover:bg-dark-600',
)}
title={landing.is_active ? t('admin.landings.inactive') : t('admin.landings.active')}
>
{landing.is_active ? <CheckIcon /> : <XIcon />}
</button>
<button
onClick={onEdit}
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
title={t('admin.landings.edit')}
>
<EditIcon />
</button>
<div className="flex-1" />
<button
onClick={onDelete}
className={cn(
'rounded-lg p-2 transition-colors',
isPendingDelete
? 'bg-error-500/20 text-error-400 ring-1 ring-error-500/30'
: 'bg-dark-700 text-dark-300 hover:bg-error-500/20 hover:text-error-400',
)}
title={
isPendingDelete
? t('admin.landings.deleteConfirm', {
title: resolveLocaleDisplay(landing.title),
})
: t('common.delete')
}
>
{isPendingDelete ? (
<span className="px-1 text-xs font-medium">{t('common.delete')}?</span>
) : (
<TrashIcon />
)}
</button>
</div>
</div>
</div>
</div>
);
}
// ============ Main Page ============
export default function AdminLandings() {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const notify = useNotify();
const { capabilities } = usePlatform();
const [localLandings, setLocalLandings] = useState<LandingListItem[]>([]);
const [orderChanged, setOrderChanged] = useState(false);
const [pendingDeleteId, setPendingDeleteId] = useState<number | null>(null);
const deleteTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => {
return () => {
if (deleteTimeoutRef.current) clearTimeout(deleteTimeoutRef.current);
};
}, []);
// Queries
const { data: landingsData, isLoading } = useQuery({
queryKey: ['admin-landings'],
queryFn: () => adminLandingsApi.list(),
staleTime: 30_000,
});
// Sync fetched data to local state
useEffect(() => {
if (landingsData && !orderChanged) {
setLocalLandings(landingsData);
}
}, [landingsData, orderChanged]);
// Save order mutation
const saveOrderMutation = useMutation({
mutationFn: (landingIds: number[]) => adminLandingsApi.reorder(landingIds),
onSuccess: () => {
setOrderChanged(false);
queryClient.invalidateQueries({ queryKey: ['admin-landings'] });
notify.success(t('admin.landings.orderSaved'));
},
onError: (err: unknown) => {
notify.error(getApiErrorMessage(err, t('common.error')));
},
});
// Mutations
const deleteMutation = useMutation({
mutationFn: adminLandingsApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-landings'] });
notify.success(t('admin.landings.deleted'));
},
onError: (err: unknown) => {
notify.error(getApiErrorMessage(err, t('common.error')));
},
});
const handleDelete = (landing: LandingListItem) => {
if (pendingDeleteId === landing.id) {
deleteMutation.mutate(landing.id);
setPendingDeleteId(null);
} else {
setPendingDeleteId(landing.id);
if (deleteTimeoutRef.current) clearTimeout(deleteTimeoutRef.current);
deleteTimeoutRef.current = setTimeout(
() => setPendingDeleteId((prev) => (prev === landing.id ? null : prev)),
3000,
);
}
};
const toggleMutation = useMutation({
mutationFn: adminLandingsApi.toggle,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-landings'] });
},
onError: (err: unknown) => {
notify.error(getApiErrorMessage(err, t('common.error')));
},
});
const handleCopyUrl = async (slug: string) => {
const url = `${window.location.origin}/buy/${slug}`;
try {
await copyToClipboard(url);
notify.success(t('admin.landings.urlCopied'));
} catch {
// Clipboard write failed silently
}
};
// DnD sensors
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setLocalLandings((prev) => {
const oldIndex = prev.findIndex((l) => l.id === active.id);
const newIndex = prev.findIndex((l) => l.id === over.id);
if (oldIndex === -1 || newIndex === -1) return prev;
return arrayMove(prev, oldIndex, newIndex);
});
setOrderChanged(true);
}
}, []);
const handleSaveOrder = () => {
saveOrderMutation.mutate(localLandings.map((l) => l.id));
};
return (
<div className="animate-fade-in">
{/* Header */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
{/* Show back button only on web, not in Telegram Mini App */}
{!capabilities.hasBackButton && (
<button
onClick={() => navigate('/admin')}
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
>
<BackIcon />
</button>
)}
<div>
<h1 className="text-xl font-semibold text-dark-100">{t('admin.landings.title')}</h1>
</div>
</div>
<div className="flex items-center gap-2">
{orderChanged && (
<button
onClick={handleSaveOrder}
disabled={saveOrderMutation.isPending}
className="flex items-center gap-2 rounded-lg bg-success-500 px-4 py-2 text-white transition-colors hover:bg-success-600"
>
{saveOrderMutation.isPending ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : (
<SaveIcon />
)}
{t('admin.landings.saveOrder')}
</button>
)}
<button
onClick={() => navigate('/admin/landings/create')}
className="flex items-center justify-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
>
<PlusIcon />
{t('admin.landings.create')}
</button>
</div>
</div>
{/* Drag hint */}
<div className="mb-4 flex items-center gap-2 text-sm text-dark-500">
<GripIcon />
{t('admin.tariffs.dragToReorder')}
</div>
{/* Landings List */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : localLandings.length === 0 ? (
<div className="py-12 text-center">
<p className="text-dark-400">{t('common.noData')}</p>
</div>
) : (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<SortableContext
items={localLandings.map((l) => l.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{localLandings.map((landing) => (
<SortableLandingCard
key={landing.id}
landing={landing}
onEdit={() => navigate(`/admin/landings/${landing.id}/edit`)}
onStats={() => navigate(`/admin/landings/${landing.id}/stats`)}
onDelete={() => handleDelete(landing)}
onToggle={() => toggleMutation.mutate(landing.id)}
onCopyUrl={() => handleCopyUrl(landing.slug)}
isPendingDelete={pendingDeleteId === landing.id}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
);
}

View File

@@ -304,6 +304,16 @@ const ClipboardDocumentListIcon = () => (
</svg>
);
const RectangleGroupIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 7.125C2.25 6.504 2.754 6 3.375 6h6c.621 0 1.125.504 1.125 1.125v3.75c0 .621-.504 1.125-1.125 1.125h-6a1.125 1.125 0 01-1.125-1.125v-3.75zM14.25 8.625c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v8.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 01-1.125-1.125v-8.25zM3.75 16.125c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v2.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 01-1.125-1.125v-2.25z"
/>
</svg>
);
interface AdminItem {
to: string;
icon: React.ReactNode;
@@ -499,6 +509,13 @@ export default function AdminPanel() {
description: t('admin.panel.paymentMethodsDesc'),
permission: 'payment_methods:read',
},
{
to: '/admin/landings',
icon: <RectangleGroupIcon />,
title: t('admin.nav.landings'),
description: t('admin.panel.landingsDesc'),
permission: 'landings:read',
},
],
},
{

View File

@@ -228,7 +228,7 @@ export default function AdminPayments() {
<a
href={payment.payment_url}
target="_blank"
rel="noopener noreferrer"
rel="noopener"
className="btn-secondary px-3 py-1.5 text-xs"
>
{t('admin.payments.openLink')}

View File

@@ -61,6 +61,7 @@ export default function AdminPromoGroupCreate() {
const [trafficDiscount, setTrafficDiscount] = useState<number | ''>(0);
const [deviceDiscount, setDeviceDiscount] = useState<number | ''>(0);
const [applyToAddons, setApplyToAddons] = useState(true);
const [isDefault, setIsDefault] = useState(false);
const [autoAssignSpent, setAutoAssignSpent] = useState<number | ''>(0);
const [periodDiscounts, setPeriodDiscounts] = useState<PeriodDiscount[]>([]);
@@ -79,6 +80,7 @@ export default function AdminPromoGroupCreate() {
setTrafficDiscount(data.traffic_discount_percent || 0);
setDeviceDiscount(data.device_discount_percent || 0);
setApplyToAddons(data.apply_discounts_to_addons ?? true);
setIsDefault(data.is_default ?? false);
setAutoAssignSpent(
data.auto_assign_total_spent_kopeks ? data.auto_assign_total_spent_kopeks / 100 : 0,
);
@@ -151,7 +153,8 @@ export default function AdminPromoGroupCreate() {
period_discounts:
Object.keys(periodDiscountsRecord).length > 0 ? periodDiscountsRecord : undefined,
apply_discounts_to_addons: applyToAddons,
auto_assign_total_spent_kopeks: autoAssignVal > 0 ? Math.round(autoAssignVal * 100) : null,
auto_assign_total_spent_kopeks: autoAssignVal > 0 ? Math.round(autoAssignVal * 100) : 0,
is_default: isDefault,
};
if (isEdit) {
@@ -388,6 +391,24 @@ export default function AdminPromoGroupCreate() {
</button>
<span className="text-sm text-dark-200">{t('admin.promoGroups.form.applyToAddons')}</span>
</label>
{/* Default group */}
<label className="flex cursor-pointer items-center gap-3">
<button
type="button"
onClick={() => setIsDefault(!isDefault)}
className={`relative h-6 w-11 rounded-full transition-colors ${
isDefault ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
isDefault ? 'left-6' : 'left-1'
}`}
/>
</button>
<span className="text-sm text-dark-200">{t('admin.promoGroups.form.isDefault')}</span>
</label>
</div>
{/* Footer */}

View File

@@ -9,7 +9,7 @@ import { MENU_SECTIONS, MenuItem, formatSettingKey } from '../components/admin';
import { usePlatform } from '../platform/hooks/usePlatform';
import { AnalyticsTab } from '../components/admin/AnalyticsTab';
import { BrandingTab } from '../components/admin/BrandingTab';
import { ButtonsTab } from '../components/admin/ButtonsTab';
import { MenuEditorTab } from '../components/admin/MenuEditorTab';
import { ThemeTab } from '../components/admin/ThemeTab';
import { FavoritesTab } from '../components/admin/FavoritesTab';
import { SettingsTab } from '../components/admin/SettingsTab';
@@ -176,7 +176,7 @@ export default function AdminSettings() {
case 'theme':
return <ThemeTab />;
case 'buttons':
return <ButtonsTab />;
return <MenuEditorTab />;
case 'favorites':
return (
<FavoritesTab

View File

@@ -9,6 +9,7 @@ import {
TariffUpdateRequest,
PeriodPrice,
ServerInfo,
ExternalSquadInfo,
} from '../api/tariffs';
import { AdminBackButton } from '../components/admin';
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
@@ -105,6 +106,7 @@ export default function AdminTariffCreate() {
const [tierLevel, setTierLevel] = useState<number | ''>(1);
const [periodPrices, setPeriodPrices] = useState<PeriodPrice[]>([]);
const [selectedSquads, setSelectedSquads] = useState<string[]>([]);
const [selectedExternalSquad, setSelectedExternalSquad] = useState<string | null>(null);
const [selectedPromoGroups, setSelectedPromoGroups] = useState<number[]>([]);
const [dailyPriceKopeks, setDailyPriceKopeks] = useState<number | ''>(0);
@@ -138,6 +140,12 @@ export default function AdminTariffCreate() {
queryFn: () => tariffsApi.getAvailableServers(),
});
// Fetch external squads
const { data: externalSquads = [] } = useQuery({
queryKey: ['admin-tariffs-external-squads'],
queryFn: () => tariffsApi.getAvailableExternalSquads(),
});
// Fetch promo groups
const { data: promoGroups = [] } = useQuery({
queryKey: ['admin-tariffs-promo-groups'],
@@ -165,6 +173,7 @@ export default function AdminTariffCreate() {
setTierLevel(data.tier_level || 1);
setPeriodPrices(data.period_prices?.length ? data.period_prices : []);
setSelectedSquads(data.allowed_squads || []);
setSelectedExternalSquad(data.external_squad_uuid || null);
setSelectedPromoGroups(
data.promo_groups?.filter((pg) => pg.is_selected).map((pg) => pg.id) || [],
);
@@ -211,6 +220,7 @@ export default function AdminTariffCreate() {
tier_level: toNumber(tierLevel, 1),
period_prices: isDaily ? [] : periodPrices.filter((p) => p.price_kopeks >= 0),
allowed_squads: selectedSquads,
external_squad_uuid: selectedExternalSquad || null,
promo_group_ids: selectedPromoGroups.length > 0 ? selectedPromoGroups : undefined,
traffic_topup_enabled: trafficTopupEnabled,
traffic_topup_packages: trafficTopupPackages,
@@ -660,6 +670,77 @@ export default function AdminTariffCreate() {
{activeTab === 'servers' && (
<div className="space-y-4">
{/* External Squad */}
{externalSquads.length > 0 && (
<div className="card space-y-4">
<h4 className="text-sm font-medium text-dark-200">
{t('admin.tariffs.externalSquadTitle')}
</h4>
<p className="text-sm text-dark-400">{t('admin.tariffs.externalSquadHint')}</p>
<div className="space-y-2">
<button
type="button"
onClick={() => setSelectedExternalSquad(null)}
className={`flex w-full items-center gap-3 rounded-lg p-3 text-left transition-colors ${
!selectedExternalSquad
? isDaily
? 'bg-warning-500/20 text-warning-300'
: 'bg-accent-500/20 text-accent-300'
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
}`}
>
<div
className={`flex h-5 w-5 items-center justify-center rounded-full ${
!selectedExternalSquad
? isDaily
? 'bg-warning-500 text-white'
: 'bg-accent-500 text-white'
: 'bg-dark-600'
}`}
>
{!selectedExternalSquad && <CheckIcon />}
</div>
<span className="flex-1 text-sm font-medium">
{t('admin.tariffs.noExternalSquad')}
</span>
</button>
{externalSquads.map((squad: ExternalSquadInfo) => {
const isSelected = selectedExternalSquad === squad.uuid;
return (
<button
key={squad.uuid}
type="button"
onClick={() => setSelectedExternalSquad(squad.uuid)}
className={`flex w-full items-center gap-3 rounded-lg p-3 text-left transition-colors ${
isSelected
? isDaily
? 'bg-warning-500/20 text-warning-300'
: 'bg-accent-500/20 text-accent-300'
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
}`}
>
<div
className={`flex h-5 w-5 items-center justify-center rounded-full ${
isSelected
? isDaily
? 'bg-warning-500 text-white'
: 'bg-accent-500 text-white'
: 'bg-dark-600'
}`}
>
{isSelected && <CheckIcon />}
</div>
<span className="flex-1 text-sm font-medium">{squad.name}</span>
<span className="text-xs text-dark-500">
{squad.members_count} {t('admin.tariffs.externalSquadUsers')}
</span>
</button>
);
})}
</div>
</div>
)}
{/* Servers */}
<div className="card space-y-4">
<h4 className="text-sm font-medium text-dark-200">{t('admin.tariffs.serversTitle')}</h4>

82
src/pages/AutoLogin.tsx Normal file
View File

@@ -0,0 +1,82 @@
import { useEffect, useState, useRef } from 'react';
import { useSearchParams, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { authApi } from '../api/auth';
import { useAuthStore } from '../store/auth';
export default function AutoLogin() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { setTokens, setUser, checkAdminStatus } = useAuthStore();
const [error, setError] = useState(false);
const attemptedRef = useRef(false);
const token = searchParams.get('token');
useEffect(() => {
// Prevent referrer leaking the token
const meta = document.createElement('meta');
meta.name = 'referrer';
meta.content = 'no-referrer';
document.head.appendChild(meta);
return () => {
document.head.removeChild(meta);
};
}, []);
useEffect(() => {
if (!token || attemptedRef.current) {
if (!token) setError(true);
return;
}
attemptedRef.current = true;
authApi
.autoLogin(token)
.then(async (response) => {
setTokens(response.access_token, response.refresh_token);
setUser(response.user);
await checkAdminStatus();
navigate('/', { replace: true });
})
.catch(() => {
setError(true);
});
}, [token, navigate, setTokens, setUser, checkAdminStatus]);
return (
<div className="flex min-h-dvh items-center justify-center bg-dark-950 px-4">
<div className="w-full max-w-sm rounded-2xl border border-dark-800/50 bg-dark-900/50 p-8 text-center">
{error ? (
<div className="space-y-4">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-error-500/10">
<svg
className="h-8 w-8 text-error-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<p className="text-sm text-dark-300">{t('landing.autoLoginFailed')}</p>
<button
type="button"
onClick={() => navigate('/login', { replace: true })}
className="rounded-xl bg-accent-500 px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-400"
>
{t('auth.login', 'Login')}
</button>
</div>
) : (
<div className="space-y-4">
<div className="mx-auto h-10 w-10 animate-spin rounded-full border-2 border-dark-600 border-t-accent-500" />
<p className="text-sm text-dark-300">{t('landing.autoLoginProcessing')}</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -8,13 +8,13 @@ import { useAuthStore } from '../store/auth';
import { balanceApi } from '../api/balance';
import { useCurrency } from '../hooks/useCurrency';
import { API } from '../config/constants';
import { useToast } from '../components/Toast';
import type { PaginatedResponse, Transaction } from '../types';
import { Card } from '@/components/data-display/Card';
import { Button } from '@/components/primitives/Button';
import { ChevronDownIcon, ChevronRightIcon } from '@/components/icons';
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
import { isPaidStatus, isFailedStatus } from '../utils/paymentStatus';
const WalletIcon = ({ className = 'h-8 w-8' }: { className?: string }) => (
<svg
@@ -39,7 +39,6 @@ export default function Balance() {
const { formatAmount, currencySymbol } = useCurrency();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { showToast } = useToast();
const paymentHandledRef = useRef(false);
// Fetch balance from API
@@ -60,30 +59,19 @@ export default function Balance() {
if (paymentHandledRef.current) return;
const paymentStatus = searchParams.get('payment') || searchParams.get('status');
const isSuccess =
paymentStatus === 'success' ||
paymentStatus === 'paid' ||
paymentStatus === 'completed' ||
searchParams.get('success') === 'true';
const normalised = paymentStatus?.toLowerCase() ?? '';
const isSuccess = isPaidStatus(normalised) || searchParams.get('success') === 'true';
const isFailed = isFailedStatus(normalised);
if (isSuccess) {
paymentHandledRef.current = true;
refetchBalance();
refreshUser();
queryClient.invalidateQueries({ queryKey: ['transactions'] });
queryClient.invalidateQueries({ queryKey: ['subscription'] });
showToast({
type: 'success',
title: t('balance.paymentSuccess.title'),
message: t('balance.paymentSuccess.message'),
duration: 6000,
});
navigate('/balance', { replace: true });
navigate('/balance/top-up/result?status=success', { replace: true });
} else if (isFailed) {
paymentHandledRef.current = true;
navigate('/balance/top-up/result?status=failed', { replace: true });
}
}, [searchParams, navigate, refetchBalance, refreshUser, queryClient, showToast, t]);
}, [searchParams, navigate]);
const [promocode, setPromocode] = useState('');
const [promocodeLoading, setPromocodeLoading] = useState(false);
@@ -163,6 +151,7 @@ export default function Balance() {
await refetchBalance();
await refreshUser();
queryClient.invalidateQueries({ queryKey: ['transactions'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
}
} catch (error: unknown) {
const axiosError = error as { response?: { data?: { detail?: string } } };

View File

@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { motion } from 'framer-motion';
import { authApi } from '../api/auth';
import { brandingApi, type TelegramWidgetConfig } from '../api/branding';
import { useToast } from '../components/Toast';
import { Card } from '@/components/data-display/Card';
import { Button } from '@/components/primitives/Button';
@@ -24,47 +25,191 @@ const isLinkableProvider = (provider: string): boolean =>
// SessionStorage key for Telegram link CSRF state
export const LINK_TELEGRAM_STATE_KEY = 'link_telegram_state';
/** Compact Telegram Login Widget for account linking (browser only). */
/** Telegram account linking widget (browser only). Supports OIDC popup and legacy widget. */
function TelegramLinkWidget() {
const containerRef = useRef<HTMLDivElement>(null);
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '';
const navigate = useNavigate();
const { showToast } = useToast();
const { t } = useTranslation();
const queryClient = useQueryClient();
const [oidcLoading, setOidcLoading] = useState(false);
const [scriptLoaded, setScriptLoaded] = useState(false);
const mountedRef = useRef(true);
const { data: widgetConfig } = useQuery<TelegramWidgetConfig>({
queryKey: ['telegram-widget-config'],
queryFn: brandingApi.getTelegramWidgetConfig,
staleTime: 60000,
});
const botUsername =
widgetConfig?.bot_username || import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '';
const isOIDC = Boolean(widgetConfig?.oidc_enabled && widgetConfig?.oidc_client_id);
useEffect(() => {
if (!containerRef.current || !botUsername) return;
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
// Shared handler for link result
const handleLinkResult = async (response: Awaited<ReturnType<typeof authApi.linkTelegram>>) => {
if (response.merge_required && response.merge_token) {
navigate(`/merge/${response.merge_token}`, { replace: true });
} else {
queryClient.invalidateQueries({ queryKey: ['linked-providers'] });
showToast({ type: 'success', message: t('profile.accounts.linkSuccess') });
}
};
// OIDC callback handler (ref pattern to avoid stale closures)
const handleOIDCCallbackRef =
useRef<(data: { id_token?: string; error?: string }) => void>(undefined);
handleOIDCCallbackRef.current = async (data: { id_token?: string; error?: string }) => {
if (!mountedRef.current) return;
if (data.error || !data.id_token) {
setOidcLoading(false);
showToast({
type: 'error',
message: data.error || t('profile.accounts.linkError'),
});
return;
}
try {
setOidcLoading(true);
const response = await authApi.linkTelegram({ id_token: data.id_token });
if (mountedRef.current) await handleLinkResult(response);
} catch (err: unknown) {
if (mountedRef.current) {
showToast({
type: 'error',
message: getErrorDetail(err) || t('profile.accounts.linkError'),
});
}
} finally {
if (mountedRef.current) setOidcLoading(false);
}
};
// Load OIDC script and init
useEffect(() => {
if (!isOIDC || !widgetConfig?.oidc_client_id) return;
const scriptId = 'telegram-login-oidc-script';
let script = document.getElementById(scriptId) as HTMLScriptElement | null;
const initTelegramLogin = () => {
if (window.Telegram?.Login) {
window.Telegram.Login.init(
{
client_id: Number(widgetConfig.oidc_client_id) || widgetConfig.oidc_client_id,
request_access: widgetConfig.request_access ? ['write'] : undefined,
lang: document.documentElement.lang || 'en',
},
(data) => handleOIDCCallbackRef.current?.(data),
);
setScriptLoaded(true);
}
};
if (!script) {
script = document.createElement('script');
script.id = scriptId;
script.src = 'https://oauth.telegram.org/js/telegram-login.js?3';
script.async = true;
script.onload = () => initTelegramLogin();
script.onerror = () => {
if (mountedRef.current) {
showToast({ type: 'error', message: t('profile.accounts.linkError') });
}
};
document.head.appendChild(script);
} else {
initTelegramLogin();
}
}, [isOIDC, widgetConfig?.oidc_client_id, widgetConfig?.request_access]);
// Legacy widget effect (only when NOT OIDC)
useEffect(() => {
if (isOIDC || !containerRef.current || !botUsername) return;
const container = containerRef.current;
while (container.firstChild) {
container.removeChild(container.firstChild);
}
// Generate CSRF state token and store in sessionStorage
const csrfState = crypto.randomUUID();
sessionStorage.setItem(LINK_TELEGRAM_STATE_KEY, csrfState);
const redirectUrl = `${window.location.origin}/auth/link/telegram/callback?csrf_state=${encodeURIComponent(csrfState)}`;
const callbackName = '__onTelegramLinkAuth';
(window as unknown as Record<string, unknown>)[callbackName] = async (
user: Record<string, unknown>,
) => {
if (!mountedRef.current) return;
try {
const response = await authApi.linkTelegram({
id: user.id as number,
first_name: user.first_name as string,
last_name: (user.last_name as string) || undefined,
username: (user.username as string) || undefined,
photo_url: (user.photo_url as string) || undefined,
auth_date: user.auth_date as number,
hash: user.hash as string,
});
if (mountedRef.current) await handleLinkResult(response);
} catch (err: unknown) {
if (mountedRef.current) {
showToast({
type: 'error',
message: getErrorDetail(err) || t('profile.accounts.linkError'),
});
}
}
};
const script = document.createElement('script');
script.src = 'https://telegram.org/js/telegram-widget.js?22';
script.src = 'https://telegram.org/js/telegram-widget.js?23';
script.setAttribute('data-telegram-login', botUsername);
script.setAttribute('data-size', 'small');
script.setAttribute('data-radius', '8');
script.setAttribute('data-auth-url', redirectUrl);
script.setAttribute('data-onauth', `${callbackName}(user)`);
script.setAttribute('data-request-access', 'write');
script.async = true;
container.appendChild(script);
return () => {
delete (window as unknown as Record<string, unknown>)[callbackName];
while (container.firstChild) {
container.removeChild(container.firstChild);
}
};
}, [botUsername]);
}, [isOIDC, botUsername, navigate, showToast, t, queryClient]);
if (!botUsername) {
if (!botUsername && !isOIDC) {
return null;
}
if (isOIDC) {
return (
<Button
variant="primary"
size="sm"
disabled={oidcLoading || !scriptLoaded}
loading={oidcLoading}
onClick={() => {
setOidcLoading(true);
if (window.Telegram?.Login) {
window.Telegram.Login.open();
} else {
setOidcLoading(false);
}
}}
>
{t('profile.accounts.link')}
</Button>
);
}
return <div ref={containerRef} className="flex items-center" />;
}

View File

@@ -37,6 +37,7 @@ export default function Connection() {
const handleOpenQR = useCallback(() => {
navigate('/connection/qr', {
replace: !isTelegramWebApp,
state: {
url: appConfig?.subscriptionUrl,
hideLink: appConfig?.hideLink ?? false,

View File

@@ -2,8 +2,8 @@ import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { QRCodeSVG } from 'qrcode.react';
import { useAuthStore } from '../store/auth';
import { useBranding } from '../hooks/useBranding';
import { AdminBackButton } from '@/components/admin';
interface ConnectionQRState {
url: string;
@@ -20,96 +20,56 @@ export default function ConnectionQR() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const isLoading = useAuthStore((state) => state.isLoading);
const { appName } = useBranding();
const state = location.state as unknown;
const validState = isValidState(state) ? state : null;
useEffect(() => {
if (!isLoading && !isAuthenticated) {
navigate('/login', { replace: true });
}
}, [isAuthenticated, isLoading, navigate]);
useEffect(() => {
if (!isLoading && isAuthenticated && !validState) {
if (!validState) {
navigate('/connection', { replace: true });
}
}, [isLoading, isAuthenticated, validState, navigate]);
}, [validState, navigate]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
navigate(-1);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [navigate]);
if (isLoading || !validState) {
if (!validState) {
return null;
}
return (
<div className="fixed inset-0 z-50 flex min-h-dvh flex-col items-center justify-center bg-gradient-to-b from-dark-900 to-dark-950">
{/* Close button */}
<button
type="button"
onClick={() => navigate(-1)}
className="absolute left-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-dark-800/60 backdrop-blur-sm transition-colors hover:bg-dark-700/80"
aria-label={t('common.close')}
>
<svg
className="h-5 w-5 text-dark-200"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div className="animate-fade-in">
<div className="mb-6 flex items-center gap-3">
<AdminBackButton to="/connection" replace />
<h1 className="text-2xl font-bold text-dark-100">{t('subscription.connection.qrTitle')}</h1>
</div>
{/* Content */}
<div className="flex w-full max-w-sm animate-scale-in flex-col items-center px-6">
{/* Branding name */}
{appName && (
<p className="mb-3 text-sm font-medium uppercase tracking-wider text-dark-400">
{appName}
<div className="flex flex-col items-center">
<div className="flex w-full max-w-sm flex-col items-center px-6">
{appName && (
<p className="mb-3 text-sm font-medium uppercase tracking-wider text-dark-400">
{appName}
</p>
)}
<p className="mb-8 text-center text-sm text-dark-400">
{t('subscription.connection.qrScanHint')}
</p>
)}
{/* Title */}
<h1 className="mb-2 text-center text-xl font-bold text-dark-100">
{t('subscription.connection.qrTitle')}
</h1>
<div className="rounded-3xl bg-white p-6">
<QRCodeSVG
value={validState.url}
size={280}
level="M"
includeMargin={false}
className="h-[280px] w-[280px] sm:h-[360px] sm:w-[360px]"
/>
</div>
{/* Hint */}
<p className="mb-8 text-center text-sm text-dark-400">
{t('subscription.connection.qrScanHint')}
</p>
{/* QR code container */}
<div className="rounded-3xl bg-white p-6">
<QRCodeSVG
value={validState.url}
size={280}
level="M"
includeMargin={false}
className="h-[280px] w-[280px] sm:h-[360px] sm:w-[360px]"
/>
{!validState.hideLink && (
<p className="mt-6 max-w-full truncate text-center font-mono text-xs text-dark-500">
{validState.url}
</p>
)}
</div>
{/* URL display */}
{!validState.hideLink && (
<p className="mt-6 max-w-full truncate text-center font-mono text-xs text-dark-500">
{validState.url}
</p>
)}
</div>
</div>
);

View File

@@ -14,6 +14,8 @@ import SubscriptionCardActive from '../components/dashboard/SubscriptionCardActi
import SubscriptionCardExpired from '../components/dashboard/SubscriptionCardExpired';
import TrialOfferCard from '../components/dashboard/TrialOfferCard';
import StatsGrid from '../components/dashboard/StatsGrid';
import { giftApi } from '../api/gift';
import PendingGiftCard from '../components/dashboard/PendingGiftCard';
import { API } from '../config/constants';
const ChevronRightIcon = () => (
@@ -80,6 +82,13 @@ export default function Dashboard() {
retry: false,
});
const { data: pendingGifts } = useQuery({
queryKey: ['pending-gifts'],
queryFn: giftApi.getPendingGifts,
staleTime: 30_000,
retry: false,
});
const activateTrialMutation = useMutation({
mutationFn: subscriptionApi.activateTrial,
onSuccess: () => {
@@ -87,6 +96,7 @@ export default function Dashboard() {
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['trial-info'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
refreshUser();
},
onError: (error: { response?: { data?: { detail?: string } } }) => {
@@ -220,6 +230,9 @@ export default function Dashboard() {
<p className="mt-1 text-dark-400">{t('dashboard.yourSubscription')}</p>
</div>
{/* Pending Gift Activations */}
{pendingGifts && pendingGifts.length > 0 && <PendingGiftCard gifts={pendingGifts} />}
{/* Subscription Status Card */}
{subLoading ? (
<div className="bento-card">
@@ -234,8 +247,12 @@ export default function Dashboard() {
<div className="skeleton h-12 w-full rounded-xl" />
</div>
</div>
) : subscription?.is_expired ? (
<SubscriptionCardExpired subscription={subscription} />
) : subscription?.is_expired || subscription?.status === 'disabled' ? (
<SubscriptionCardExpired
subscription={subscription}
balanceKopeks={balanceData?.balance_kopeks ?? 0}
balanceRubles={balanceData?.balance_rubles ?? 0}
/>
) : subscription ? (
<SubscriptionCardActive
subscription={subscription}

616
src/pages/GiftResult.tsx Normal file
View File

@@ -0,0 +1,616 @@
import { useCallback, useRef, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
import { giftApi } from '../api/gift';
import { Spinner } from '@/components/ui/Spinner';
import { AnimatedCheckmark } from '@/components/ui/AnimatedCheckmark';
import { AnimatedCrossmark } from '@/components/ui/AnimatedCrossmark';
import { cn } from '@/lib/utils';
const MAX_POLL_MS = 10 * 60 * 1000; // 10 minutes
const KNOWN_WARNINGS = new Set(['telegram_unresolvable']);
// ============================================================
// Sub-components
// ============================================================
function PendingState() {
const { t } = useTranslation();
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<Spinner className="h-16 w-16 border-[3px]" />
<div>
<h1 className="text-xl font-bold text-dark-50">
{t('gift.processing', 'Processing your gift...')}
</h1>
<p className="mt-2 text-sm text-dark-400">
{t('gift.pendingDesc', 'Please wait while we process your payment')}
</p>
</div>
</motion.div>
);
}
function CodeOnlySuccessState({
purchaseToken,
tariffName,
periodDays,
}: {
purchaseToken: string;
tariffName: string | null;
periodDays: number | null;
}) {
const { t } = useTranslation();
const navigate = useNavigate();
const [copied, setCopied] = useState(false);
const shortCode = purchaseToken.slice(0, 12);
const giftCode = `GIFT-${shortCode}`;
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME as string | undefined;
const botLink = botUsername ? `https://t.me/${botUsername}?start=GIFT_${shortCode}` : null;
const cabinetLink = `${window.location.origin}/gift?tab=activate&code=${shortCode}`;
const fullMessage = [
t('gift.shareText', 'I have a gift for you! Activate it here:'),
'',
botLink ? `${t('gift.shareModalActivateVia', 'Activate via bot:')} ${botLink}` : null,
`${t('gift.shareModalActivateViaCabinet', 'Or via website:')} ${cabinetLink}`,
]
.filter(Boolean)
.join('\n');
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(fullMessage);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// fallback
}
};
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<AnimatedCheckmark />
<div>
<h1 className="text-xl font-bold text-dark-50">
{t('gift.codeReadyTitle', 'Gift code is ready!')}
</h1>
{tariffName && periodDays !== null && (
<p className="mt-1 text-sm text-dark-300">
{tariffName} {periodDays} {t('gift.days', 'days')}
</p>
)}
</div>
{/* Gift code display */}
<div className="w-full rounded-xl border border-accent-500/20 bg-accent-500/5 p-4">
<p className="mb-1 text-xs font-medium uppercase tracking-wider text-dark-400">
{t('gift.codeLabel', 'Gift code')}
</p>
<p className="select-all font-mono text-lg font-bold text-accent-400">{giftCode}</p>
</div>
{/* Share message preview */}
<div className="w-full rounded-xl border border-dark-700/30 bg-dark-800/40 p-4 text-left">
<p className="mb-3 text-sm font-medium text-dark-100">
{t('gift.shareText', 'I have a gift for you! Activate it here:')}
</p>
{botLink && (
<div className="mb-2">
<p className="mb-1 text-xs font-medium text-dark-400">
{t('gift.shareModalActivateVia', 'Activate via bot:')}
</p>
<p className="truncate rounded-lg bg-dark-900/60 px-3 py-2 text-sm text-accent-400">
{botLink}
</p>
</div>
)}
<div>
<p className="mb-1 text-xs font-medium text-dark-400">
{t('gift.shareModalActivateViaCabinet', 'Or via website:')}
</p>
<p className="truncate rounded-lg bg-dark-900/60 px-3 py-2 text-sm text-accent-400">
{cabinetLink}
</p>
</div>
</div>
{/* Copy button */}
<button
type="button"
onClick={handleCopy}
className={cn(
'flex w-full items-center justify-center gap-2 rounded-xl px-4 py-3.5 text-sm font-bold transition-all duration-200 active:scale-[0.98]',
copied
? 'bg-success-500/20 text-success-400'
: 'bg-accent-500 text-white shadow-lg shadow-accent-500/25 hover:bg-accent-400',
)}
>
{copied ? (
<>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
{t('common.copied', 'Copied!')}
</>
) : (
<>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
{t('gift.copyMessage', 'Copy message')}
</>
)}
</button>
<button
type="button"
onClick={() => navigate('/gift?tab=myGifts')}
className="w-full rounded-xl border border-dark-700/50 px-6 py-3 text-sm font-medium text-dark-300 transition-colors hover:bg-dark-800/50"
>
{t('gift.tabMyGifts', 'My Gifts')}
</button>
</motion.div>
);
}
function DeliveredState({
recipientContact,
tariffName,
periodDays,
giftMessage,
warning,
}: {
recipientContact: string | null;
tariffName: string | null;
periodDays: number | null;
giftMessage: string | null;
warning: string | null;
}) {
const { t } = useTranslation();
const navigate = useNavigate();
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<AnimatedCheckmark />
<div>
<h1 className="text-xl font-bold text-dark-50">{t('gift.successTitle', 'Gift sent!')}</h1>
{tariffName && periodDays !== null && (
<p className="mt-1 text-sm text-dark-300">
{tariffName} {periodDays} {t('gift.days', 'days')}
</p>
)}
{recipientContact && (
<p className="mt-2 text-sm text-dark-400">
{t('gift.successDesc', {
contact: recipientContact,
defaultValue: `Sent to ${recipientContact}`,
})}
</p>
)}
{giftMessage && (
<p className="mt-2 text-sm italic text-dark-400">&ldquo;{giftMessage}&rdquo;</p>
)}
</div>
{warning && (
<div className="w-full rounded-xl border border-warning-500/20 bg-warning-500/5 p-3">
<p className="text-sm text-warning-400">{t(`gift.warning.${warning}`)}</p>
</div>
)}
<button
type="button"
onClick={() => navigate('/')}
className="flex w-full items-center justify-center rounded-xl bg-accent-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-400"
>
{t('gift.backToDashboard', 'Back to dashboard')}
</button>
</motion.div>
);
}
function PendingActivationState({
recipientContact,
tariffName,
periodDays,
warning,
}: {
recipientContact: string | null;
tariffName: string | null;
periodDays: number | null;
warning: string | null;
}) {
const { t } = useTranslation();
const navigate = useNavigate();
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
{/* Info icon */}
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-warning-500/10">
<svg
className="h-10 w-10 text-warning-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"
/>
</svg>
</div>
<div>
<h1 className="text-xl font-bold text-dark-50">
{t('gift.pendingActivationTitle', 'Gift pending activation')}
</h1>
{tariffName && periodDays !== null && (
<p className="mt-1 text-sm text-dark-300">
{tariffName} {periodDays} {t('gift.days', 'days')}
</p>
)}
{recipientContact && (
<p className="mt-2 text-sm text-dark-400">
{t('gift.successDesc', {
contact: recipientContact,
defaultValue: `Sent to ${recipientContact}`,
})}
</p>
)}
<p className="mt-2 text-sm text-dark-400">
{t(
'gift.pendingActivationDesc',
'The recipient currently has an active subscription. Your gift will be activated once their current subscription expires.',
)}
</p>
</div>
{warning && (
<div className="w-full rounded-xl border border-warning-500/20 bg-warning-500/5 p-3">
<p className="text-sm text-warning-400">{t(`gift.warning.${warning}`)}</p>
</div>
)}
<button
type="button"
onClick={() => navigate('/')}
className="flex w-full items-center justify-center rounded-xl bg-accent-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-400"
>
{t('gift.backToDashboard', 'Back to dashboard')}
</button>
</motion.div>
);
}
function FailedState() {
const { t } = useTranslation();
const navigate = useNavigate();
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<AnimatedCrossmark />
<div>
<h1 className="text-xl font-bold text-dark-50">
{t('gift.failedTitle', 'Something went wrong')}
</h1>
<p className="mt-2 text-sm text-dark-400">
{t('gift.failedDesc', 'Your gift could not be processed. Please try again.')}
</p>
</div>
<button
type="button"
onClick={() => navigate('/gift')}
className="flex w-full items-center justify-center rounded-xl bg-accent-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-400"
>
{t('gift.tryAgain', 'Try again')}
</button>
</motion.div>
);
}
function PollErrorState() {
const { t } = useTranslation();
const navigate = useNavigate();
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-warning-500/10">
<svg
className="h-10 w-10 text-warning-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
/>
</svg>
</div>
<div>
<h1 className="text-xl font-bold text-dark-50">
{t('gift.pollErrorTitle', 'Could not check gift status')}
</h1>
<p className="mt-2 text-sm text-dark-400">
{t(
'gift.pollErrorDesc',
'Your purchase was successful. Check your dashboard for details.',
)}
</p>
</div>
<button
type="button"
onClick={() => navigate('/')}
className="flex w-full items-center justify-center rounded-xl bg-accent-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-400"
>
{t('gift.backToDashboard', 'Back to dashboard')}
</button>
</motion.div>
);
}
function PollTimedOutState({ onRetry }: { onRetry: () => void }) {
const { t } = useTranslation();
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-dark-800/50">
<svg
className="h-10 w-10 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div>
<h1 className="text-xl font-bold text-dark-50">
{t('gift.pollTimeout', 'Taking longer than expected')}
</h1>
<p className="mt-2 text-sm text-dark-400">
{t(
'gift.pollTimeoutDesc',
'Payment processing is taking longer than usual. You can try checking again.',
)}
</p>
</div>
<button
type="button"
onClick={onRetry}
className="rounded-xl bg-accent-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-400"
>
{t('gift.retry', 'Retry')}
</button>
</motion.div>
);
}
function NoTokenState() {
const { t } = useTranslation();
const navigate = useNavigate();
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-dark-800/50">
<svg
className="h-10 w-10 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
/>
</svg>
</div>
<div>
<h1 className="text-xl font-bold text-dark-50">{t('gift.noToken', 'Invalid link')}</h1>
<p className="mt-2 text-sm text-dark-400">
{t('gift.noTokenDesc', 'This gift link is invalid or has expired.')}
</p>
</div>
<button
type="button"
onClick={() => navigate('/gift')}
className="rounded-xl bg-accent-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-400"
>
{t('gift.backToGift', 'Go back')}
</button>
</motion.div>
);
}
// ============================================================
// Main Component
// ============================================================
export default function GiftResult() {
const [searchParams] = useSearchParams();
const token = searchParams.get('token');
const mode = searchParams.get('mode');
const rawUrlWarning = searchParams.get('warning');
const urlWarning = rawUrlWarning && KNOWN_WARNINGS.has(rawUrlWarning) ? rawUrlWarning : null;
const pollStart = useRef(Date.now());
const [pollTimedOut, setPollTimedOut] = useState(false);
const isBalanceMode = mode === 'balance';
const {
data: status,
isError,
refetch,
} = useQuery({
queryKey: ['gift-status', token],
queryFn: () => giftApi.getPurchaseStatus(token!),
enabled: !!token && !pollTimedOut,
refetchInterval: (query) => {
// Balance mode: fetch once, no polling
if (isBalanceMode) return false;
const d = query.state.data;
const s = d?.status;
if (s === 'delivered' || s === 'failed' || s === 'pending_activation' || s === 'expired')
return false;
// Code-only gifts stay in 'paid' status — stop polling
if (s === 'paid' && d?.is_code_only) return false;
// Check poll timeout
if (Date.now() - pollStart.current > MAX_POLL_MS) {
setPollTimedOut(true);
return false;
}
return 3000;
},
retry: 2,
});
const handleRetryPoll = useCallback(() => {
pollStart.current = Date.now();
setPollTimedOut(false);
refetch();
}, [refetch]);
// No token
if (!token) {
return (
<div className="flex min-h-dvh items-center justify-center px-4">
<div
className="w-full max-w-md rounded-2xl border border-dark-800/50 bg-dark-900/50 p-8"
aria-live="polite"
aria-atomic="true"
>
<NoTokenState />
</div>
</div>
);
}
const isCodeOnlyPaid =
status?.status === 'paid' && status?.is_code_only && status?.purchase_token != null;
const isDelivered = status?.status === 'delivered';
const isPendingActivation = status?.status === 'pending_activation';
const isFailed = status?.status === 'failed' || status?.status === 'expired';
// Warning from status response (persisted on purchase) takes priority over URL param
const statusWarning =
status?.warning && KNOWN_WARNINGS.has(status.warning) ? status.warning : null;
const warning = statusWarning ?? urlWarning;
return (
<div className="flex min-h-dvh items-center justify-center px-4">
<div
className="w-full max-w-md rounded-2xl border border-dark-800/50 bg-dark-900/50 p-8"
aria-live="polite"
aria-atomic="true"
>
{isError ? (
<PollErrorState />
) : isCodeOnlyPaid ? (
<CodeOnlySuccessState
purchaseToken={status.purchase_token!}
tariffName={status.tariff_name}
periodDays={status.period_days}
/>
) : isDelivered ? (
<DeliveredState
recipientContact={status.recipient_contact_value}
tariffName={status.tariff_name}
periodDays={status.period_days}
giftMessage={status.gift_message}
warning={warning}
/>
) : isPendingActivation ? (
<PendingActivationState
recipientContact={status.recipient_contact_value}
tariffName={status.tariff_name}
periodDays={status.period_days}
warning={warning}
/>
) : isFailed ? (
<FailedState />
) : pollTimedOut ? (
<PollTimedOutState onRetry={handleRetryPoll} />
) : (
<PendingState />
)}
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -485,10 +485,7 @@ export default function Login() {
</p>
</div>
) : (
<TelegramLoginButton
botUsername={botUsername}
referralCode={referralCode || undefined}
/>
<TelegramLoginButton referralCode={referralCode || undefined} />
)}
</div>

View File

@@ -0,0 +1,756 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useParams, useNavigate, useSearchParams } from 'react-router';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
import { QRCodeSVG } from 'qrcode.react';
import { landingApi } from '../api/landings';
import { authApi } from '../api/auth';
import { useAuthStore } from '../store/auth';
import { copyToClipboard } from '../utils/clipboard';
import { Spinner } from '@/components/ui/Spinner';
import { AnimatedCheckmark } from '@/components/ui/AnimatedCheckmark';
import { AnimatedCrossmark } from '@/components/ui/AnimatedCrossmark';
import { cn } from '../lib/utils';
const MAX_POLL_MS = 10 * 60 * 1000; // 10 minutes
// ============================================================
// Sub-components
// ============================================================
function PendingState() {
const { t } = useTranslation();
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<Spinner className="h-16 w-16 border-[3px]" />
<div>
<h1 className="text-xl font-bold text-dark-50">
{t('landing.awaitingPayment', 'Awaiting payment')}
</h1>
<p className="mt-2 text-sm text-dark-400">{t('landing.awaitingPaymentDesc')}</p>
</div>
</motion.div>
);
}
function CopyableField({ label, value }: { label: string; value: string }) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, []);
const handleCopy = useCallback(async () => {
try {
await copyToClipboard(value);
setCopied(true);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard write failed silently
}
}, [value]);
return (
<div className="flex items-center gap-2 rounded-xl bg-dark-800/50 px-4 py-3">
<div className="flex-1 text-left">
<p className="text-xs text-dark-400">{label}</p>
<p className="mt-0.5 font-mono text-sm text-dark-100">{value}</p>
</div>
<button
type="button"
onClick={handleCopy}
className={cn(
'shrink-0 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors',
copied
? 'bg-success-500/10 text-success-500'
: 'bg-dark-700/50 text-dark-300 hover:bg-dark-600/50',
)}
>
{copied ? t('landing.copied', 'Copied!') : t('landing.copy', 'Copy')}
</button>
</div>
);
}
function CabinetCredentialsState({
cabinetEmail,
cabinetPassword,
autoLoginToken,
tariffName,
periodDays,
}: {
cabinetEmail: string;
cabinetPassword: string | null;
autoLoginToken: string | null;
tariffName: string | null;
periodDays: number | null;
}) {
const { t } = useTranslation();
const navigate = useNavigate();
const { setTokens, setUser, checkAdminStatus } = useAuthStore();
const [isLoggingIn, setIsLoggingIn] = useState(false);
const [loginError, setLoginError] = useState(false);
const handleGoToCabinet = useCallback(async () => {
if (!autoLoginToken) {
navigate('/login');
return;
}
setIsLoggingIn(true);
setLoginError(false);
try {
const response = await authApi.autoLogin(autoLoginToken);
setTokens(response.access_token, response.refresh_token);
setUser(response.user);
await checkAdminStatus();
navigate('/');
} catch {
setLoginError(true);
setIsLoggingIn(false);
}
}, [autoLoginToken, navigate, setTokens, setUser, checkAdminStatus]);
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<AnimatedCheckmark />
{/* Title */}
<div>
<h1 className="text-xl font-bold text-dark-50">{t('landing.cabinetReady')}</h1>
{tariffName && periodDays !== null && (
<p className="mt-1 text-sm text-dark-300">
{tariffName} {periodDays} {t('landing.daysAccess')}
</p>
)}
</div>
{/* Credentials */}
<div className="w-full space-y-3">
<CopyableField label={t('landing.cabinetEmail')} value={cabinetEmail} />
{cabinetPassword && (
<CopyableField label={t('landing.cabinetPassword')} value={cabinetPassword} />
)}
{cabinetPassword && <p className="text-xs text-dark-400">{t('landing.saveCredentials')}</p>}
{!cabinetPassword && (
<p className="text-xs text-dark-400">{t('landing.credentialsSentToEmail')}</p>
)}
</div>
{/* Go to Cabinet button */}
<button
type="button"
onClick={handleGoToCabinet}
disabled={isLoggingIn}
className={cn(
'flex w-full items-center justify-center gap-2 rounded-xl px-6 py-3 text-sm font-medium text-white transition-colors',
isLoggingIn ? 'cursor-not-allowed bg-accent-500/50' : 'bg-accent-500 hover:bg-accent-400',
)}
>
{isLoggingIn ? (
<>
<Spinner className="h-4 w-4" />
{t('landing.autoLoginProcessing')}
</>
) : (
t('landing.goToCabinet')
)}
</button>
{loginError && <p className="text-xs text-error-400">{t('landing.autoLoginFailed')}</p>}
</motion.div>
);
}
function SuccessState({
subscriptionUrl,
cryptoLink,
contactValue,
recipientContactValue,
tariffName,
periodDays,
isGift,
giftMessage,
recipientInBot,
botLink,
contactType,
}: {
subscriptionUrl: string | null;
cryptoLink: string | null;
contactValue: string | null;
recipientContactValue: string | null;
tariffName: string | null;
periodDays: number | null;
isGift: boolean;
giftMessage: string | null;
recipientInBot: boolean | null;
botLink: string | null;
contactType: string | null;
}) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
return () => {
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current);
};
}, []);
const handleCopy = useCallback(async () => {
const url = subscriptionUrl ?? cryptoLink;
if (!url) return;
try {
await copyToClipboard(url);
setCopied(true);
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current);
copyTimeoutRef.current = setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard write failed silently
}
}, [subscriptionUrl, cryptoLink]);
const displayUrl = subscriptionUrl ?? cryptoLink;
const displayContact = isGift ? recipientContactValue : contactValue;
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<AnimatedCheckmark />
{/* Title */}
<div>
<h1 className="text-xl font-bold text-dark-50">
{isGift ? t('landing.giftSentSuccess') : t('landing.purchaseSuccess')}
</h1>
{tariffName && periodDays !== null && (
<p className="mt-1 text-sm text-dark-300">
{tariffName} {periodDays} {t('landing.daysAccess')}
</p>
)}
{isGift && contactType === 'telegram' && recipientInBot === true && (
<p className="mt-2 text-sm text-dark-400">{t('landing.giftTelegramSent')}</p>
)}
{isGift && contactType === 'telegram' && recipientInBot !== true && (
<p className="mt-2 text-sm text-dark-400">{t('landing.giftTelegramNotInBot')}</p>
)}
{!(isGift && contactType === 'telegram') && displayContact && (
<p className="mt-2 text-sm text-dark-400">
{isGift
? t('landing.giftSentTo', { contact: displayContact })
: t('landing.keySentTo', { contact: displayContact })}
</p>
)}
{isGift && giftMessage && (
<p className="mt-2 text-sm italic text-dark-400">
{t('landing.giftMessage')}: {giftMessage}
</p>
)}
</div>
{/* Bot link for telegram gifts where recipient is not in bot */}
{isGift && contactType === 'telegram' && recipientInBot !== true && botLink && (
<a
href={botLink}
target="_blank"
rel="noopener noreferrer"
className="flex w-full items-center justify-center gap-2 rounded-xl bg-accent-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-400"
>
{t('landing.openBot')}
</a>
)}
{/* QR Code */}
{displayUrl && (
<div className="space-y-4">
<div className="rounded-2xl bg-white p-5">
<QRCodeSVG
value={displayUrl}
size={200}
level="M"
includeMargin={false}
className="h-[200px] w-[200px]"
/>
</div>
{/* Copy button */}
<button
type="button"
onClick={handleCopy}
className={cn(
'flex w-full items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-medium transition-all duration-200',
copied
? 'bg-success-500/10 text-success-500'
: 'bg-dark-800/50 text-dark-200 hover:bg-dark-700/50',
)}
>
{copied ? (
<>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
{t('landing.copied', 'Copied!')}
</>
) : (
<>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9.75a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
/>
</svg>
{t('landing.copyLink', 'Copy link')}
</>
)}
</button>
</div>
)}
</motion.div>
);
}
function PendingActivationState({
tariffName,
periodDays,
giftMessage,
isGift,
isActivating,
onActivate,
autoLoginToken,
}: {
tariffName: string | null;
periodDays: number | null;
giftMessage: string | null;
isGift: boolean;
isActivating: boolean;
onActivate: () => void;
autoLoginToken: string | null;
}) {
const { t } = useTranslation();
const navigate = useNavigate();
const { setTokens, setUser, checkAdminStatus } = useAuthStore();
const [isLoggingIn, setIsLoggingIn] = useState(false);
const handleGoToCabinet = useCallback(async () => {
if (!autoLoginToken) {
navigate('/login');
return;
}
setIsLoggingIn(true);
try {
const response = await authApi.autoLogin(autoLoginToken);
setTokens(response.access_token, response.refresh_token);
setUser(response.user);
await checkAdminStatus();
navigate('/');
} catch {
setIsLoggingIn(false);
navigate('/login');
}
}, [autoLoginToken, navigate, setTokens, setUser, checkAdminStatus]);
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
{/* Warning icon */}
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-warning-500/10">
<svg
className="h-10 w-10 text-warning-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
</div>
<div>
<h1 className="text-xl font-bold text-dark-50">{t('landing.pendingActivation')}</h1>
{tariffName && periodDays !== null && (
<p className="mt-1 text-sm text-dark-300">
{tariffName} {periodDays} {t('landing.daysAccess')}
</p>
)}
<p className="mt-2 text-sm text-dark-400">{t('landing.pendingActivationDesc')}</p>
{isGift && giftMessage && (
<p className="mt-2 text-sm italic text-dark-400">
{t('landing.giftMessage')}: {giftMessage}
</p>
)}
</div>
<div className="flex w-full flex-col gap-3">
<button
type="button"
onClick={onActivate}
disabled={isActivating}
className={cn(
'flex items-center justify-center gap-2 rounded-xl px-6 py-3 text-sm font-medium text-white transition-colors',
isActivating
? 'cursor-not-allowed bg-accent-500/50'
: 'bg-accent-500 hover:bg-accent-400',
)}
>
{isActivating ? (
<>
<Spinner className="h-4 w-4" />
{t('landing.activating')}
</>
) : (
t('landing.activateNow')
)}
</button>
{autoLoginToken && (
<button
type="button"
onClick={handleGoToCabinet}
disabled={isLoggingIn}
className={cn(
'flex items-center justify-center gap-2 rounded-xl px-6 py-3 text-sm font-medium transition-colors',
isLoggingIn
? 'cursor-not-allowed bg-dark-800/30 text-dark-400'
: 'bg-dark-800/50 text-dark-200 hover:bg-dark-700/50',
)}
>
{isLoggingIn ? (
<>
<Spinner className="h-4 w-4" />
{t('landing.autoLoginProcessing')}
</>
) : (
t('landing.goToCabinet')
)}
</button>
)}
</div>
</motion.div>
);
}
function GiftPendingActivationState({
tariffName,
periodDays,
recipientContactValue,
giftMessage,
recipientInBot,
botLink,
contactType,
}: {
tariffName: string | null;
periodDays: number | null;
recipientContactValue: string | null;
giftMessage: string | null;
recipientInBot: boolean | null;
botLink: string | null;
contactType: string | null;
}) {
const { t } = useTranslation();
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<AnimatedCheckmark />
<div>
<h1 className="text-xl font-bold text-dark-50">{t('landing.giftSentSuccess')}</h1>
{tariffName && periodDays !== null && (
<p className="mt-1 text-sm text-dark-300">
{tariffName} {periodDays} {t('landing.daysAccess')}
</p>
)}
{contactType === 'telegram' && recipientInBot === true && (
<p className="mt-2 text-sm text-dark-400">{t('landing.giftTelegramPendingSent')}</p>
)}
{contactType === 'telegram' && recipientInBot !== true && (
<p className="mt-2 text-sm text-dark-400">{t('landing.giftTelegramPendingNotInBot')}</p>
)}
{contactType !== 'telegram' && (
<p className="mt-2 text-sm text-dark-400">{t('landing.giftPendingActivationDesc')}</p>
)}
{contactType !== 'telegram' && recipientContactValue && (
<p className="mt-2 text-sm text-dark-400">
{t('landing.giftSentTo', { contact: recipientContactValue })}
</p>
)}
{giftMessage && (
<p className="mt-2 text-sm italic text-dark-400">
{t('landing.giftMessage')}: {giftMessage}
</p>
)}
</div>
{/* Bot link for telegram gifts where recipient is not in bot */}
{contactType === 'telegram' && recipientInBot !== true && botLink && (
<a
href={botLink}
target="_blank"
rel="noopener noreferrer"
className="flex w-full items-center justify-center gap-2 rounded-xl bg-accent-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-400"
>
{t('landing.openBot')}
</a>
)}
</motion.div>
);
}
function FailedState() {
const { t } = useTranslation();
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<AnimatedCrossmark />
<div>
<h1 className="text-xl font-bold text-dark-50">{t('landing.purchaseFailed')}</h1>
<p className="mt-2 text-sm text-dark-400">{t('landing.purchaseFailedDesc')}</p>
</div>
</motion.div>
);
}
function PollTimedOutState({ onRetry }: { onRetry: () => void }) {
const { t } = useTranslation();
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-dark-800/50">
<svg
className="h-10 w-10 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div>
<h1 className="text-xl font-bold text-dark-50">
{t('landing.pollTimedOut', 'Taking longer than expected')}
</h1>
<p className="mt-2 text-sm text-dark-400">
{t(
'landing.pollTimedOutDesc',
'Payment processing is taking longer than usual. You can try checking again.',
)}
</p>
</div>
<button
type="button"
onClick={onRetry}
className="rounded-xl bg-accent-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-400"
>
{t('common.retry', 'Retry')}
</button>
</motion.div>
);
}
// ============================================================
// Main Component
// ============================================================
export default function PurchaseSuccess() {
const { t } = useTranslation();
const { token } = useParams<{ token: string }>();
const [searchParams] = useSearchParams();
const isActivateHint = searchParams.get('activate') === '1';
const pollStart = useRef(Date.now());
const [pollTimedOut, setPollTimedOut] = useState(false);
const [isActivating, setIsActivating] = useState(false);
const [activationError, setActivationError] = useState(false);
const activatingRef = useRef(false);
// Referrer-Policy: prevent leaking payment token via referer header
useEffect(() => {
const meta = document.createElement('meta');
meta.name = 'referrer';
meta.content = 'no-referrer';
document.head.appendChild(meta);
return () => {
document.head.removeChild(meta);
};
}, []);
const queryClient = useQueryClient();
const {
data: purchaseStatus,
isError,
refetch,
} = useQuery({
queryKey: ['purchase-status', token],
queryFn: () => landingApi.getPurchaseStatus(token!),
enabled: !!token && !pollTimedOut,
refetchInterval: (query) => {
const currentStatus = query.state.data?.status;
if (currentStatus === 'pending' || currentStatus === 'paid') {
if (Date.now() - pollStart.current > MAX_POLL_MS) {
setPollTimedOut(true);
return false;
}
return 3_000;
}
return false;
},
retry: 2,
});
const handleRetryPoll = useCallback(() => {
pollStart.current = Date.now();
setPollTimedOut(false);
refetch();
}, [refetch]);
const handleActivate = useCallback(async () => {
if (!token || activatingRef.current) return;
activatingRef.current = true;
setIsActivating(true);
setActivationError(false);
try {
const result = await landingApi.activatePurchase(token);
queryClient.setQueryData(['purchase-status', token], result);
} catch {
setActivationError(true);
} finally {
activatingRef.current = false;
setIsActivating(false);
}
}, [token, queryClient]);
const isSuccess = purchaseStatus?.status === 'delivered';
const isPendingActivation = purchaseStatus?.status === 'pending_activation';
const isFailed = purchaseStatus?.status === 'failed' || purchaseStatus?.status === 'expired';
// Gift pending activation → buyer sees "gift sent" message, not the activate button.
// Recipient arrives via email link with ?activate=1 and sees the activate button instead.
const isGiftPendingActivation = isPendingActivation && purchaseStatus?.is_gift && !isActivateHint;
// Email self-purchase delivered → show cabinet credentials
const isEmailSelfPurchase =
isSuccess &&
purchaseStatus.contact_type === 'email' &&
!purchaseStatus.is_gift &&
purchaseStatus.cabinet_email;
return (
<div className="flex min-h-dvh items-center justify-center bg-dark-950 px-4">
<div
className="w-full max-w-md rounded-2xl border border-dark-800/50 bg-dark-900/50 p-8"
aria-live="polite"
aria-atomic="true"
>
{isError ? (
<FailedState />
) : isEmailSelfPurchase ? (
<CabinetCredentialsState
cabinetEmail={purchaseStatus.cabinet_email!}
cabinetPassword={purchaseStatus.cabinet_password}
autoLoginToken={purchaseStatus.auto_login_token}
tariffName={purchaseStatus.tariff_name}
periodDays={purchaseStatus.period_days}
/>
) : isSuccess ? (
<SuccessState
subscriptionUrl={purchaseStatus.subscription_url}
cryptoLink={purchaseStatus.subscription_crypto_link}
contactValue={purchaseStatus.contact_value}
recipientContactValue={purchaseStatus.recipient_contact_value}
tariffName={purchaseStatus.tariff_name}
periodDays={purchaseStatus.period_days}
isGift={purchaseStatus.is_gift}
giftMessage={purchaseStatus.gift_message}
recipientInBot={purchaseStatus.recipient_in_bot}
botLink={purchaseStatus.bot_link}
contactType={purchaseStatus.contact_type}
/>
) : isGiftPendingActivation ? (
<GiftPendingActivationState
tariffName={purchaseStatus.tariff_name}
periodDays={purchaseStatus.period_days}
recipientContactValue={purchaseStatus.recipient_contact_value}
giftMessage={purchaseStatus.gift_message}
recipientInBot={purchaseStatus.recipient_in_bot}
botLink={purchaseStatus.bot_link}
contactType={purchaseStatus.contact_type}
/>
) : isPendingActivation ? (
<div className="space-y-4">
<PendingActivationState
tariffName={purchaseStatus.tariff_name}
periodDays={purchaseStatus.period_days}
giftMessage={purchaseStatus.gift_message}
isGift={purchaseStatus.is_gift}
isActivating={isActivating}
onActivate={handleActivate}
autoLoginToken={purchaseStatus.auto_login_token}
/>
{activationError && (
<p className="text-center text-sm text-error-400">{t('landing.activationFailed')}</p>
)}
</div>
) : isFailed ? (
<FailedState />
) : pollTimedOut ? (
<PollTimedOutState onRetry={handleRetryPoll} />
) : (
<PendingState />
)}
</div>
</div>
);
}

1126
src/pages/QuickPurchase.tsx Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -48,6 +48,8 @@ export default function SubscriptionPurchase() {
const { data: purchaseOptions, isLoading: optionsLoading } = useQuery({
queryKey: ['purchase-options'],
queryFn: subscriptionApi.getPurchaseOptions,
staleTime: 0,
refetchOnMount: 'always',
});
// Active promo discount
@@ -772,7 +774,9 @@ export default function SubscriptionPurchase() {
/>
</svg>
<span className="text-dark-300">
{t('subscription.devices', { count: tariff.device_limit })}
{tariff.device_limit === 0
? '∞'
: t('subscription.devices', { count: tariff.device_limit })}
</span>
</div>
{tariff.traffic_reset_mode &&
@@ -959,7 +963,7 @@ export default function SubscriptionPurchase() {
<div>
<span className="text-dark-500">{t('subscription.devices')}:</span>
<span className="ml-2 text-dark-200">
{selectedTariff.device_limit}
{selectedTariff.device_limit === 0 ? '∞' : selectedTariff.device_limit}
{selectedTariff.extra_devices_count > 0 && (
<span className="ml-1 text-xs text-accent-400">
(+{selectedTariff.extra_devices_count})
@@ -1119,6 +1123,33 @@ export default function SubscriptionPurchase() {
</div>
)}
{/* No periods available fallback */}
{selectedTariff.periods.length === 0 &&
!useCustomDays &&
!(
selectedTariff.custom_days_enabled &&
(selectedTariff.price_per_day_kopeks ?? 0) > 0
) && (
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-center">
<div className="mb-2 text-sm font-medium text-amber-400">
{t('subscription.noPeriodsAvailable')}
</div>
<div className="text-xs text-dark-400">
{t('subscription.noPeriodsAvailableHint')}
</div>
<button
onClick={() => {
setShowTariffPurchase(false);
setSelectedTariff(null);
setSelectedTariffPeriod(null);
}}
className="btn-secondary mt-3 px-4 py-2 text-sm"
>
{t('subscription.chooseDifferentTariff')}
</button>
</div>
)}
{/* Custom days option */}
{selectedTariff.custom_days_enabled &&
(selectedTariff.price_per_day_kopeks ?? 0) > 0 && (

View File

@@ -12,6 +12,7 @@ import { useHaptic, usePlatform } from '@/platform';
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
import type { PaymentMethod } from '../types';
import BentoCard from '../components/ui/BentoCard';
import { saveTopUpPendingInfo } from '../utils/topUpStorage';
// Icons
const StarIcon = () => (
@@ -202,6 +203,20 @@ export default function TopUpAmount() {
const redirectUrl = data.payment_url || data.invoice_url;
if (redirectUrl) {
setPaymentUrl(redirectUrl);
// Save payment info for the result page
if (method && data.payment_id) {
const methodKey = method.id.toLowerCase().replace(/-/g, '_');
const displayName =
t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' }) || method.name;
saveTopUpPendingInfo({
amount_kopeks: data.amount_kopeks,
method_id: method.id,
method_name: displayName,
payment_id: data.payment_id,
created_at: Date.now(),
});
}
}
},
onError: (err: unknown) => {
@@ -296,8 +311,8 @@ export default function TopUpAmount() {
await navigator.clipboard.writeText(paymentUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (e) {
console.warn('Failed to copy:', e);
} catch {
// Clipboard write failed silently
}
};

348
src/pages/TopUpResult.tsx Normal file
View File

@@ -0,0 +1,348 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
import { balanceApi } from '../api/balance';
import { useAuthStore } from '../store/auth';
import { useCurrency } from '../hooks/useCurrency';
import { useHaptic } from '@/platform';
import { Spinner } from '@/components/ui/Spinner';
import { AnimatedCheckmark } from '@/components/ui/AnimatedCheckmark';
import { AnimatedCrossmark } from '@/components/ui/AnimatedCrossmark';
import { loadTopUpPendingInfo, clearTopUpPendingInfo } from '../utils/topUpStorage';
import { isPaidStatus, isFailedStatus } from '../utils/paymentStatus';
// ── Constants ────────────────────────────────────────────────
const MAX_POLL_MS = 10 * 60 * 1000; // 10 minutes
const POLL_INTERVAL_MS = 3_000;
// ── Sub-components ───────────────────────────────────────────
function AmountDisplay({ amountKopeks, label }: { amountKopeks: number; label: string }) {
const { formatAmount, currencySymbol } = useCurrency();
const amountRubles = amountKopeks / 100;
return (
<div className="mt-4 rounded-xl bg-dark-800/50 px-6 py-4">
<p className="text-xs text-dark-400">{label}</p>
<p className="mt-1 text-2xl font-bold text-dark-50">
{formatAmount(amountRubles)} <span className="text-lg text-dark-400">{currencySymbol}</span>
</p>
</div>
);
}
function PendingState({ amountKopeks }: { amountKopeks: number | null }) {
const { t } = useTranslation();
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<Spinner className="h-16 w-16 border-[3px]" />
<div>
<h1 className="text-xl font-bold text-dark-50">
{t('balance.topUpResult.awaitingPayment')}
</h1>
<p className="mt-2 text-sm text-dark-400">{t('balance.topUpResult.awaitingPaymentDesc')}</p>
</div>
{amountKopeks != null && amountKopeks > 0 && (
<AmountDisplay amountKopeks={amountKopeks} label={t('balance.topUpResult.topUpAmount')} />
)}
</motion.div>
);
}
function SuccessState({ amountKopeks }: { amountKopeks: number | null }) {
const { t } = useTranslation();
const navigate = useNavigate();
const handleGoToBalance = useCallback(() => {
navigate('/balance', { replace: true });
}, [navigate]);
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<AnimatedCheckmark />
<div>
<h1 className="text-xl font-bold text-dark-50">{t('balance.topUpResult.success')}</h1>
<p className="mt-2 text-sm text-dark-400">{t('balance.topUpResult.successDesc')}</p>
</div>
{amountKopeks != null && amountKopeks > 0 && (
<AmountDisplay amountKopeks={amountKopeks} label={t('balance.topUpResult.topUpAmount')} />
)}
<button
type="button"
onClick={handleGoToBalance}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-accent-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-400"
>
{t('balance.topUpResult.goToBalance')}
</button>
</motion.div>
);
}
function FailedState({ amountKopeks }: { amountKopeks: number | null }) {
const { t } = useTranslation();
const navigate = useNavigate();
const handleTryAgain = useCallback(() => {
navigate('/balance', { replace: true });
}, [navigate]);
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<AnimatedCrossmark />
<div>
<h1 className="text-xl font-bold text-dark-50">{t('balance.topUpResult.failed')}</h1>
<p className="mt-2 text-sm text-dark-400">{t('balance.topUpResult.failedDesc')}</p>
</div>
{amountKopeks != null && amountKopeks > 0 && (
<AmountDisplay amountKopeks={amountKopeks} label={t('balance.topUpResult.topUpAmount')} />
)}
<button
type="button"
onClick={handleTryAgain}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-dark-800/50 px-6 py-3 text-sm font-medium text-dark-200 transition-colors hover:bg-dark-700/50"
>
{t('balance.topUpResult.tryAgain')}
</button>
</motion.div>
);
}
function TimeoutState({ onRetry, onGoBack }: { onRetry: () => void; onGoBack: () => void }) {
const { t } = useTranslation();
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-dark-800/50">
<svg
className="h-10 w-10 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div>
<h1 className="text-xl font-bold text-dark-50">{t('balance.topUpResult.timeout')}</h1>
<p className="mt-2 text-sm text-dark-400">{t('balance.topUpResult.timeoutDesc')}</p>
</div>
<div className="flex w-full flex-col gap-3">
<button
type="button"
onClick={onRetry}
className="w-full rounded-xl bg-accent-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-400"
>
{t('common.retry')}
</button>
<button
type="button"
onClick={onGoBack}
className="w-full rounded-xl bg-dark-800/50 px-6 py-3 text-sm font-medium text-dark-200 transition-colors hover:bg-dark-700/50"
>
{t('balance.topUpResult.goToBalance')}
</button>
</div>
</motion.div>
);
}
// ── Main Component ───────────────────────────────────────────
export default function TopUpResult() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
const refreshUser = useAuthStore((state) => state.refreshUser);
const haptic = useHaptic();
const pollStart = useRef(Date.now());
const [pollTimedOut, setPollTimedOut] = useState(false);
const hapticFiredRef = useRef(false);
const cleanedUpRef = useRef(false);
// Load saved payment info from sessionStorage (once on mount)
const [pendingInfo] = useState(() => loadTopUpPendingInfo());
// Fallback: read method from query params (for external browser redirects where sessionStorage is unavailable)
const methodFromUrl = searchParams.get('method');
// Detect if user arrived via redirect with success param (no polling needed)
const redirectStatus = searchParams.get('status') || searchParams.get('payment');
const isRedirectSuccess = redirectStatus
? isPaidStatus(redirectStatus)
: searchParams.get('success') === 'true';
const isRedirectFailed = redirectStatus ? isFailedStatus(redirectStatus) : false;
// Determine if we can poll by specific payment_id (need method + numeric payment_id)
const parsedPaymentId = pendingInfo?.payment_id ? parseInt(pendingInfo.payment_id, 10) : NaN;
const canPollById =
!!(pendingInfo?.method_id && !isNaN(parsedPaymentId)) &&
!isRedirectSuccess &&
!isRedirectFailed;
// Fallback: poll by method via /latest endpoint when no sessionStorage data
const canPollByMethod =
!canPollById && !!methodFromUrl && !isRedirectSuccess && !isRedirectFailed;
// Poll payment status by specific ID (primary path — sessionStorage available)
const { data: paymentStatus, refetch } = useQuery({
queryKey: ['topup-status', pendingInfo?.method_id, parsedPaymentId],
queryFn: () => balanceApi.getPendingPayment(pendingInfo!.method_id, parsedPaymentId),
enabled: canPollById && !pollTimedOut,
refetchInterval: (query) => {
const payment = query.state.data;
if (!payment) return POLL_INTERVAL_MS;
if (payment.is_paid || isPaidStatus(payment.status) || isFailedStatus(payment.status)) {
return false;
}
if (Date.now() - pollStart.current > MAX_POLL_MS) {
setPollTimedOut(true);
return false;
}
return POLL_INTERVAL_MS;
},
retry: 2,
});
// Poll payment status by method latest (fallback — external browser, no sessionStorage)
const { data: latestPayment, refetch: refetchLatest } = useQuery({
queryKey: ['topup-status-latest', methodFromUrl],
queryFn: () => balanceApi.getLatestPayment(methodFromUrl!),
enabled: canPollByMethod && !pollTimedOut,
refetchInterval: (query) => {
const payment = query.state.data;
if (!payment) return POLL_INTERVAL_MS;
if (payment.is_paid || isPaidStatus(payment.status) || isFailedStatus(payment.status)) {
return false;
}
if (Date.now() - pollStart.current > MAX_POLL_MS) {
setPollTimedOut(true);
return false;
}
return POLL_INTERVAL_MS;
},
retry: 2,
});
// Merge both polling sources
const effectivePayment = paymentStatus ?? latestPayment;
const handleRetryPoll = useCallback(() => {
pollStart.current = Date.now();
setPollTimedOut(false);
if (canPollById) {
refetch();
} else {
refetchLatest();
}
}, [canPollById, setPollTimedOut, refetch, refetchLatest]);
const handleGoBack = useCallback(() => {
clearTopUpPendingInfo();
navigate('/balance', { replace: true });
}, [navigate]);
// Redirect to balance if absolutely no data source available
useEffect(() => {
if (!pendingInfo && !redirectStatus && !methodFromUrl) {
navigate('/balance', { replace: true });
}
}, [pendingInfo, redirectStatus, methodFromUrl, navigate]);
// Determine current visual state
const amountKopeks = effectivePayment?.amount_kopeks ?? pendingInfo?.amount_kopeks ?? null;
const resolvedPaid =
isRedirectSuccess ||
effectivePayment?.is_paid ||
(effectivePayment && isPaidStatus(effectivePayment.status));
const resolvedFailed =
isRedirectFailed || (effectivePayment && isFailedStatus(effectivePayment.status));
// Clean up sessionStorage and invalidate queries when payment resolves
useEffect(() => {
if (cleanedUpRef.current) return;
if (resolvedPaid) {
cleanedUpRef.current = true;
clearTopUpPendingInfo();
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['transactions'] });
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
refreshUser();
} else if (resolvedFailed) {
cleanedUpRef.current = true;
clearTopUpPendingInfo();
}
}, [resolvedPaid, resolvedFailed, queryClient, refreshUser]);
// Haptic feedback on status resolution (fire once)
useEffect(() => {
if (hapticFiredRef.current) return;
if (resolvedPaid) {
hapticFiredRef.current = true;
haptic.notification('success');
} else if (resolvedFailed) {
hapticFiredRef.current = true;
haptic.notification('error');
}
}, [resolvedPaid, resolvedFailed, haptic]);
return (
<div className="flex min-h-dvh items-center justify-center bg-dark-950 px-4">
<div
className="w-full max-w-md rounded-2xl border border-dark-800/50 bg-dark-900/50 p-8"
aria-live="polite"
aria-atomic="true"
>
{resolvedPaid ? (
<SuccessState amountKopeks={amountKopeks} />
) : resolvedFailed ? (
<FailedState amountKopeks={amountKopeks} />
) : pollTimedOut ? (
<TimeoutState onRetry={handleRetryPoll} onGoBack={handleGoBack} />
) : (
<PendingState amountKopeks={amountKopeks} />
)}
</div>
</div>
);
}

View File

@@ -203,11 +203,11 @@ export function createWebAdapter(): PlatformContext {
},
openLink(url: string, _options?: { tryInstantView?: boolean }) {
window.open(url, '_blank', 'noopener,noreferrer');
window.open(url, '_blank', 'noopener');
},
openTelegramLink(url: string) {
window.open(url, '_blank', 'noopener,noreferrer');
window.open(url, '_blank', 'noopener');
},
async share(text: string, url?: string): Promise<boolean> {

View File

@@ -37,6 +37,7 @@ interface AuthState {
checkAdminStatus: () => Promise<void>;
loginWithTelegram: (initData: string) => Promise<void>;
loginWithTelegramWidget: (data: TelegramWidgetData) => Promise<void>;
loginWithTelegramOIDC: (idToken: string) => Promise<void>;
loginWithEmail: (email: string, password: string) => Promise<void>;
loginWithOAuth: (
provider: string,
@@ -285,6 +286,21 @@ export const useAuthStore = create<AuthState>()(
await get().checkAdminStatus();
},
loginWithTelegramOIDC: async (idToken) => {
const campaignSlug = consumeCampaignSlug();
const referralCode = consumeReferralCode();
const response = await authApi.loginTelegramOIDC(idToken, campaignSlug, referralCode);
tokenStorage.setTokens(response.access_token, response.refresh_token);
set({
accessToken: response.access_token,
refreshToken: response.refresh_token,
user: response.user,
isAuthenticated: true,
pendingCampaignBonus: response.campaign_bonus || null,
});
await get().checkAdminStatus();
},
loginWithEmail: async (email, password) => {
const campaignSlug = consumeCampaignSlug();
const referralCode = consumeReferralCode();

19
src/utils/api-error.ts Normal file
View File

@@ -0,0 +1,19 @@
import axios from 'axios';
export function getApiErrorMessage(err: unknown, fallback: string): string {
if (axios.isAxiosError(err)) {
const detail = err.response?.data?.detail;
if (typeof detail === 'string') return detail;
// Pydantic V2 validation errors: [{type, loc, msg, input, ctx}]
if (Array.isArray(detail) && detail.length > 0) {
return detail
.map((e: { loc?: (string | number)[]; msg?: string }) => {
const field = e.loc?.filter((s) => s !== 'body').join('.') ?? '';
return field ? `${field}: ${e.msg}` : (e.msg ?? '');
})
.join('; ');
}
return fallback;
}
return fallback;
}

8
src/utils/format.ts Normal file
View File

@@ -0,0 +1,8 @@
export function formatPrice(kopeks: number): string {
const rubles = kopeks / 100;
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
maximumFractionDigits: 0,
}).format(rubles);
}

View File

@@ -0,0 +1,31 @@
export const PAID_STATUSES = new Set([
'succeeded',
'success',
'paid',
'paid_over',
'overpaid',
'completed',
'confirmed',
'closed',
]);
export const FAILED_STATUSES = new Set([
'fail',
'failed',
'error',
'canceled',
'cancelled',
'declined',
'expired',
'cancel',
'system_fail',
'refund_paid',
]);
export function isPaidStatus(status: string): boolean {
return PAID_STATUSES.has(status.toLowerCase());
}
export function isFailedStatus(status: string): boolean {
return FAILED_STATUSES.has(status.toLowerCase());
}

63
src/utils/topUpStorage.ts Normal file
View File

@@ -0,0 +1,63 @@
const STORAGE_KEY = 'topup_pending_payment';
const MAX_AGE_MS = 30 * 60 * 1000; // 30 minutes
export interface TopUpPendingInfo {
amount_kopeks: number;
method_id: string;
method_name: string;
payment_id: string;
created_at: number; // Date.now()
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
export function saveTopUpPendingInfo(info: TopUpPendingInfo) {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(info));
} catch {
// sessionStorage unavailable (private mode, quota, etc.)
}
}
export function loadTopUpPendingInfo(): TopUpPendingInfo | null {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
if (
!isRecord(parsed) ||
typeof parsed.amount_kopeks !== 'number' ||
typeof parsed.method_id !== 'string' ||
typeof parsed.method_name !== 'string' ||
typeof parsed.payment_id !== 'string' ||
typeof parsed.created_at !== 'number' ||
parsed.amount_kopeks <= 0
) {
return null;
}
// Discard stale entries
if (Date.now() - (parsed.created_at as number) > MAX_AGE_MS) {
clearTopUpPendingInfo();
return null;
}
return {
amount_kopeks: parsed.amount_kopeks as number,
method_id: parsed.method_id as string,
method_name: parsed.method_name as string,
payment_id: parsed.payment_id as string,
created_at: parsed.created_at as number,
};
} catch {
return null;
}
}
export function clearTopUpPendingInfo() {
try {
sessionStorage.removeItem(STORAGE_KEY);
} catch {
// ignore
}
}

15
src/vite-env.d.ts vendored
View File

@@ -19,8 +19,23 @@ interface TelegramWebAppGlobal {
offEvent?: (event: string, callback: () => void) => void;
}
/** Telegram Login JS SDK — loaded from https://oauth.telegram.org/js/telegram-login.js */
interface TelegramLoginGlobal {
init: (
options: {
client_id: string | number;
request_access?: string[];
lang?: string;
},
callback: (data: { id_token?: string; user?: Record<string, unknown>; error?: string }) => void,
) => void;
open: () => void;
auth: () => void;
}
interface Window {
Telegram?: {
WebApp?: TelegramWebAppGlobal;
Login?: TelegramLoginGlobal;
};
}