mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
Merge remote-tracking branch 'origin/dev_nikita' into dev_nikita
This commit is contained in:
287
CHANGELOG.md
287
CHANGELOG.md
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
136
src/App.tsx
136
src/App.tsx
@@ -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={
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
157
src/api/gift.ts
Normal 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
404
src/api/landings.ts
Normal 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
92
src/api/menuLayout.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
@@ -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[]> => {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
|
||||
304
src/components/admin/BackgroundConfigEditor.tsx
Normal file
304
src/components/admin/BackgroundConfigEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
74
src/components/admin/LocaleTabs.tsx
Normal file
74
src/components/admin/LocaleTabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
src/components/admin/LocalizedInput.tsx
Normal file
70
src/components/admin/LocalizedInput.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
891
src/components/admin/MenuEditorTab.tsx
Normal file
891
src/components/admin/MenuEditorTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
src/components/admin/SortableFeatureItem.tsx
Normal file
89
src/components/admin/SortableFeatureItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
251
src/components/admin/SortableSelectedMethodCard.tsx
Normal file
251
src/components/admin/SortableSelectedMethodCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -67,6 +67,8 @@ export const MENU_SECTIONS: MenuSection[] = [
|
||||
'INTERFACE_SUBSCRIPTION',
|
||||
'CONNECT_BUTTON',
|
||||
'MINIAPP',
|
||||
'TELEGRAM_WIDGET',
|
||||
'TELEGRAM_OIDC',
|
||||
'HAPP',
|
||||
'SKIP',
|
||||
'ADDITIONAL',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
80
src/components/dashboard/PendingGiftCard.tsx
Normal file
80
src/components/dashboard/PendingGiftCard.tsx
Normal 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">
|
||||
“{gift.gift_message}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
69
src/components/icons/LandingIcons.tsx
Normal file
69
src/components/icons/LandingIcons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -16,6 +16,7 @@ export {
|
||||
InfoIcon,
|
||||
CogIcon,
|
||||
WheelIcon,
|
||||
GiftIcon,
|
||||
SearchIcon,
|
||||
PlusIcon,
|
||||
ArrowRightIcon,
|
||||
|
||||
37
src/components/ui/AnimatedCheckmark.tsx
Normal file
37
src/components/ui/AnimatedCheckmark.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/components/ui/AnimatedCrossmark.tsx
Normal file
37
src/components/ui/AnimatedCrossmark.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
src/components/ui/Spinner.tsx
Normal file
17
src/components/ui/Spinner.tsx
Normal 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,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "نام کاربری تلگرام قابل تأیید نبود. ممکن است گیرنده اعلان هدیه را دریافت نکند."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Сообщение скопировано"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "请为所有自定义按钮提供有效的URL(http:// 或 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 用户名。收件人可能不会收到礼物通知。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
1117
src/pages/AdminLandingEditor.tsx
Normal file
1117
src/pages/AdminLandingEditor.tsx
Normal file
File diff suppressed because it is too large
Load Diff
747
src/pages/AdminLandingStats.tsx
Normal file
747
src/pages/AdminLandingStats.tsx
Normal 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">
|
||||
{' '}
|
||||
· {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
514
src/pages/AdminLandings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
82
src/pages/AutoLogin.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 } } };
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ export default function Connection() {
|
||||
|
||||
const handleOpenQR = useCallback(() => {
|
||||
navigate('/connection/qr', {
|
||||
replace: !isTelegramWebApp,
|
||||
state: {
|
||||
url: appConfig?.subscriptionUrl,
|
||||
hideLink: appConfig?.hideLink ?? false,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
616
src/pages/GiftResult.tsx
Normal 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">“{giftMessage}”</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>
|
||||
);
|
||||
}
|
||||
1377
src/pages/GiftSubscription.tsx
Normal file
1377
src/pages/GiftSubscription.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -485,10 +485,7 @@ export default function Login() {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<TelegramLoginButton
|
||||
botUsername={botUsername}
|
||||
referralCode={referralCode || undefined}
|
||||
/>
|
||||
<TelegramLoginButton referralCode={referralCode || undefined} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
756
src/pages/PurchaseSuccess.tsx
Normal file
756
src/pages/PurchaseSuccess.tsx
Normal 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
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
@@ -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 && (
|
||||
|
||||
@@ -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
348
src/pages/TopUpResult.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
19
src/utils/api-error.ts
Normal 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
8
src/utils/format.ts
Normal 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);
|
||||
}
|
||||
31
src/utils/paymentStatus.ts
Normal file
31
src/utils/paymentStatus.ts
Normal 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
63
src/utils/topUpStorage.ts
Normal 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
15
src/vite-env.d.ts
vendored
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user