From 76f26430ba67be1fc122b513f21c66e15e7b7c53 Mon Sep 17 00:00:00 2001 From: hexdev Date: Fri, 14 Aug 2026 19:49:27 +0700 Subject: [PATCH] feat: modal windows --- .info/openapi.json | 2 +- src/api/client.test.ts | 4 +- src/components/ClientLinksModal.tsx | 111 ++++++++++++++++++++++++ src/components/StatStrip.tsx | 16 +++- src/components/SubscriptionQrModal.tsx | 77 +++++++++++++++++ src/lib/referral.ts | 20 +++++ src/pages/AccountPage.test.tsx | 23 ++++- src/pages/AccountPage.tsx | 16 +++- src/pages/LoginPage.test.tsx | 4 +- src/pages/PlanPage.test.tsx | 4 +- src/styles/components.css | 113 ++++++++++++++++++++++++- src/types.ts | 1 + tsconfig.tsbuildinfo | 2 +- 13 files changed, 377 insertions(+), 16 deletions(-) create mode 100644 src/components/ClientLinksModal.tsx create mode 100644 src/components/SubscriptionQrModal.tsx create mode 100644 src/lib/referral.ts diff --git a/.info/openapi.json b/.info/openapi.json index 0c5cbe7..2e38d7c 100644 --- a/.info/openapi.json +++ b/.info/openapi.json @@ -1 +1 @@ -{"openapi":"3.1.0","info":{"title":"FastAPI","version":"0.1.0"},"paths":{"/auth/signup":{"post":{"summary":"Signup","operationId":"signup_auth_signup_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserRegistration"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/auth/login":{"post":{"summary":"Login","operationId":"login_auth_login_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserLoginData"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserLogin"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/auth/refresh":{"post":{"summary":"Refresh","operationId":"refresh_auth_refresh_post","parameters":[{"name":"refresh_token","in":"query","required":true,"schema":{"type":"string","title":"Refresh Token"}},{"name":"iss","in":"query","required":true,"schema":{"enum":["credentials","telegram","api"],"type":"string","title":"Iss"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserTokens"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/plans/":{"get":{"summary":"Get Plans","operationId":"get_plans_plans__get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PricingPlans"}}}}}}},"/orders/checkout":{"post":{"summary":"Checkout","operationId":"checkout_orders_checkout_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrderDetails"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckoutResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/users/me":{"get":{"summary":"Get Me","operationId":"get_me_users_me_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserInfo"}}}}}}},"/users/subscription":{"get":{"summary":"Get Subscription","operationId":"get_subscription_users_subscription_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/users/subscription/hwid":{"get":{"summary":"Get Hwid","operationId":"get_hwid_users_subscription_hwid_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Device"},"title":"Response Get Hwid Users Subscription Hwid Get"}}}}}},"delete":{"summary":"Delete Hwid Endpoint","operationId":"delete_hwid_endpoint_users_subscription_hwid_delete","parameters":[{"name":"hwid","in":"query","required":true,"schema":{"type":"string","title":"Hwid"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/payments/pally/result":{"post":{"summary":"Pally Callback","operationId":"pally_callback_payments_pally_result_post","requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/Body_pally_callback_payments_pally_result_post"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}}},"components":{"schemas":{"AddonData":{"properties":{"id":{"type":"string","title":"Id"},"name":{"type":"string","title":"Name"},"price":{"type":"number","title":"Price"},"free_threshold":{"type":"integer","title":"Free Threshold"}},"type":"object","required":["id","name","price","free_threshold"],"title":"AddonData"},"Body_pally_callback_payments_pally_result_post":{"properties":{"InvId":{"type":"string","title":"Invid"},"OutSum":{"type":"string","title":"Outsum"},"Commission":{"type":"string","title":"Commission"},"TrsId":{"type":"string","title":"Trsid"},"Status":{"type":"string","title":"Status"},"CurrencyIn":{"type":"string","title":"Currencyin"},"custom":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Custom"},"SignatureValue":{"type":"string","title":"Signaturevalue"},"AccountType":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Accounttype"},"AccountNumber":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Accountnumber"},"BalanceAmount":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Balanceamount"},"BalanceCurrency":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Balancecurrency"},"PayerPhone":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Payerphone"},"PayerEmail":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Payeremail"},"PayerName":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Payername"},"PayerComment":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Payercomment"},"ErrorCode":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Errorcode"},"ErrorMessage":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Errormessage"}},"type":"object","required":["InvId","OutSum","Commission","TrsId","Status","CurrencyIn","SignatureValue"],"title":"Body_pally_callback_payments_pally_result_post"},"CheckoutResponse":{"properties":{"order_id":{"type":"string","title":"Order Id"},"total_amount":{"type":"number","title":"Total Amount"},"bonus_paid":{"type":"number","title":"Bonus Paid"},"amount_to_pay":{"type":"number","title":"Amount To Pay"},"payment_link":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Payment Link"}},"type":"object","required":["order_id","total_amount","bonus_paid","amount_to_pay"],"title":"CheckoutResponse"},"Device":{"properties":{"os":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Os"},"model":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Model"},"client":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Client"},"hwid":{"type":"string","title":"Hwid"}},"type":"object","required":["hwid"],"title":"Device"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"OrderDetails":{"properties":{"devices":{"type":"integer","title":"Devices"},"addons":{"items":{"type":"string"},"type":"array","title":"Addons","description":"List of addons ids"},"duration_days":{"type":"integer","title":"Duration Days"}},"type":"object","required":["devices","addons","duration_days"],"title":"OrderDetails"},"PricingPlans":{"properties":{"device_price":{"type":"integer","title":"Device Price"},"addons":{"items":{"$ref":"#/components/schemas/AddonData"},"type":"array","title":"Addons"}},"type":"object","required":["device_price","addons"],"title":"PricingPlans"},"TelegramData":{"properties":{},"type":"object","title":"TelegramData"},"UserInfo":{"properties":{"username":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Username"},"telegram_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Telegram Id"},"referal_code":{"type":"string","title":"Referal Code"}},"type":"object","required":["referal_code"],"title":"UserInfo"},"UserLogin":{"properties":{"access_token":{"type":"string","title":"Access Token"},"refresh_token":{"type":"string","title":"Refresh Token"},"expires_at":{"type":"number","title":"Expires At"},"user":{"$ref":"#/components/schemas/UserInfo"}},"type":"object","required":["access_token","refresh_token","expires_at","user"],"title":"UserLogin"},"UserLoginData":{"properties":{"provider":{"type":"string","enum":["credentials","telegram","api"],"title":"Provider"},"username":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Username"},"password":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Password"},"telegram":{"anyOf":[{"$ref":"#/components/schemas/TelegramData"},{"type":"null"}]}},"type":"object","required":["provider"],"title":"UserLoginData"},"UserRegistration":{"properties":{"telegram_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Telegram Id"},"username":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Username"},"password":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Password"},"referal_code":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Referal Code"},"provider":{"type":"string","enum":["credentials","telegram","api"],"title":"Provider"}},"type":"object","required":["provider"],"title":"UserRegistration"},"UserTokens":{"properties":{"access_token":{"type":"string","title":"Access Token"},"refresh_token":{"type":"string","title":"Refresh Token"},"expires_at":{"type":"number","title":"Expires At"}},"type":"object","required":["access_token","refresh_token","expires_at"],"title":"UserTokens"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"}}}} \ No newline at end of file +{"openapi":"3.1.0","info":{"title":"FastAPI","version":"0.1.0"},"paths":{"/auth/signup":{"post":{"summary":"Signup","operationId":"signup_auth_signup_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserRegistration"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/auth/login":{"post":{"summary":"Login","operationId":"login_auth_login_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserLoginData"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserLogin"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/auth/refresh":{"post":{"summary":"Refresh","operationId":"refresh_auth_refresh_post","parameters":[{"name":"refresh_token","in":"query","required":true,"schema":{"type":"string","title":"Refresh Token"}},{"name":"iss","in":"query","required":true,"schema":{"enum":["credentials","telegram","api"],"type":"string","title":"Iss"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserTokens"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/plans/":{"get":{"summary":"Get Plans","operationId":"get_plans_plans__get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PricingPlans"}}}}}}},"/orders/checkout":{"post":{"summary":"Checkout","operationId":"checkout_orders_checkout_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrderDetails"}}},"required":true},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckoutResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/users/me":{"get":{"summary":"Get Me","operationId":"get_me_users_me_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserInfo"}}}}}}},"/users/subscription":{"get":{"summary":"Get Subscription","operationId":"get_subscription_users_subscription_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/users/subscription/hwid":{"get":{"summary":"Get Hwid","operationId":"get_hwid_users_subscription_hwid_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Device"},"title":"Response Get Hwid Users Subscription Hwid Get"}}}}}},"delete":{"summary":"Delete Hwid Endpoint","operationId":"delete_hwid_endpoint_users_subscription_hwid_delete","parameters":[{"name":"hwid","in":"query","required":true,"schema":{"type":"string","title":"Hwid"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/payments/pally/result":{"post":{"summary":"Pally Callback","operationId":"pally_callback_payments_pally_result_post","requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/Body_pally_callback_payments_pally_result_post"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}}},"components":{"schemas":{"AddonData":{"properties":{"id":{"type":"string","title":"Id"},"name":{"type":"string","title":"Name"},"price":{"type":"number","title":"Price"},"free_threshold":{"type":"integer","title":"Free Threshold"}},"type":"object","required":["id","name","price","free_threshold"],"title":"AddonData"},"Body_pally_callback_payments_pally_result_post":{"properties":{"InvId":{"type":"string","title":"Invid"},"OutSum":{"type":"string","title":"Outsum"},"Commission":{"type":"string","title":"Commission"},"TrsId":{"type":"string","title":"Trsid"},"Status":{"type":"string","title":"Status"},"CurrencyIn":{"type":"string","title":"Currencyin"},"custom":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Custom"},"SignatureValue":{"type":"string","title":"Signaturevalue"},"AccountType":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Accounttype"},"AccountNumber":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Accountnumber"},"BalanceAmount":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Balanceamount"},"BalanceCurrency":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Balancecurrency"},"PayerPhone":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Payerphone"},"PayerEmail":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Payeremail"},"PayerName":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Payername"},"PayerComment":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Payercomment"},"ErrorCode":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Errorcode"},"ErrorMessage":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Errormessage"}},"type":"object","required":["InvId","OutSum","Commission","TrsId","Status","CurrencyIn","SignatureValue"],"title":"Body_pally_callback_payments_pally_result_post"},"CheckoutResponse":{"properties":{"order_id":{"type":"string","title":"Order Id"},"total_amount":{"type":"number","title":"Total Amount"},"bonus_paid":{"type":"number","title":"Bonus Paid"},"amount_to_pay":{"type":"number","title":"Amount To Pay"},"payment_link":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Payment Link"}},"type":"object","required":["order_id","total_amount","bonus_paid","amount_to_pay"],"title":"CheckoutResponse"},"Device":{"properties":{"os":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Os"},"model":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Model"},"client":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Client"},"hwid":{"type":"string","title":"Hwid"}},"type":"object","required":["hwid"],"title":"Device"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"OrderDetails":{"properties":{"devices":{"type":"integer","title":"Devices"},"addons":{"items":{"type":"string"},"type":"array","title":"Addons","description":"List of addons ids"},"duration_days":{"type":"integer","title":"Duration Days"}},"type":"object","required":["devices","addons","duration_days"],"title":"OrderDetails"},"PricingPlans":{"properties":{"device_price":{"type":"integer","title":"Device Price"},"addons":{"items":{"$ref":"#/components/schemas/AddonData"},"type":"array","title":"Addons"}},"type":"object","required":["device_price","addons"],"title":"PricingPlans"},"TelegramData":{"properties":{},"type":"object","title":"TelegramData"},"UserInfo":{"properties":{"username":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Username"},"telegram_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Telegram Id"},"referal_code":{"type":"string","title":"Referal Code"},"bonus_balance":{"type":"number","title":"Bonus Balance"}},"type":"object","required":["referal_code","bonus_balance"],"title":"UserInfo"},"UserLogin":{"properties":{"access_token":{"type":"string","title":"Access Token"},"refresh_token":{"type":"string","title":"Refresh Token"},"expires_at":{"type":"number","title":"Expires At"},"user":{"$ref":"#/components/schemas/UserInfo"}},"type":"object","required":["access_token","refresh_token","expires_at","user"],"title":"UserLogin"},"UserLoginData":{"properties":{"provider":{"type":"string","enum":["credentials","telegram","api"],"title":"Provider"},"username":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Username"},"password":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Password"},"telegram":{"anyOf":[{"$ref":"#/components/schemas/TelegramData"},{"type":"null"}]}},"type":"object","required":["provider"],"title":"UserLoginData"},"UserRegistration":{"properties":{"telegram_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Telegram Id"},"username":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Username"},"password":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Password"},"referal_code":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Referal Code"},"provider":{"type":"string","enum":["credentials","telegram","api"],"title":"Provider"}},"type":"object","required":["provider"],"title":"UserRegistration"},"UserTokens":{"properties":{"access_token":{"type":"string","title":"Access Token"},"refresh_token":{"type":"string","title":"Refresh Token"},"expires_at":{"type":"number","title":"Expires At"}},"type":"object","required":["access_token","refresh_token","expires_at"],"title":"UserTokens"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"}}}} \ No newline at end of file diff --git a/src/api/client.test.ts b/src/api/client.test.ts index f7021a9..64dfadd 100644 --- a/src/api/client.test.ts +++ b/src/api/client.test.ts @@ -68,7 +68,7 @@ describe('api client', () => { access_token: 'a', refresh_token: 'r', expires_at: 1, - user: { username: 'u', telegram_id: null, referal_code: 'x' }, + user: { username: 'u', telegram_id: null, referal_code: 'x', bonus_balance: 0 }, }); }); await api.login({ provider: 'credentials', username: 'u', password: 'p' }); @@ -102,7 +102,7 @@ describe('api client', () => { return jsonResponse({ access_token: 'new', refresh_token: 'new-r', expires_at: 9999999999 }); } if (calls === 1) return jsonResponse({ detail: 'expired' }, 401); - return jsonResponse({ username: 'u', telegram_id: null, referal_code: 'x' }); + return jsonResponse({ username: 'u', telegram_id: null, referal_code: 'x', bonus_balance: 0 }); }); const me = await api.getMe(); expect(me.username).toBe('u'); diff --git a/src/components/ClientLinksModal.tsx b/src/components/ClientLinksModal.tsx new file mode 100644 index 0000000..f50068e --- /dev/null +++ b/src/components/ClientLinksModal.tsx @@ -0,0 +1,111 @@ +import { useEffect } from 'react'; +import { createPortal } from 'react-dom'; + +interface Client { + name: string; + href: string; + note?: string; +} + +interface Platform { + name: string; + clients: Client[]; +} + +const PLATFORMS: Platform[] = [ + { + name: 'iOS', + clients: [ + { name: 'Incy Client', href: 'https://apps.apple.com/us/app/incy/id6756943388', note: 'Приложение может быть недоступно в РФ, смените регион.' }, + ], + }, + { + name: 'Windows', + clients: [ + { name: 'Happ', href: 'https://github.com/Happ-proxy/happ-desktop/releases/latest/download/setup-Happ.x64.exe' }, + { + name: 'Throne', + href: 'https://github.com/throneproj/Throne/releases/latest', + note: 'При первом запуске: Настройки -> Подписка -> Разрешить отправку HWID.', + }, + ], + }, + { + name: 'Android', + clients: [ + { name: 'Happ (Google Play)', href: 'https://play.google.com/store/apps/details?id=com.happproxy&pli=1' }, + { name: 'Happ (APK)', href: 'https://github.com/Happ-proxy/happ-android/releases/latest/download/Happ.apk' }, + { name: 'v2raytun', href: 'https://play.google.com/store/apps/details?id=com.v2raytun.android&hl=ru' }, + ], + }, + { + name: 'Linux', + clients: [ + { + name: 'Throne', + href: 'https://github.com/throneproj/Throne/releases/latest', + note: 'При первом запуске: Настройки -> Подписка -> Разрешить отправку HWID.', + }, + ], + }, +]; + +interface ClientLinksModalProps { + open: boolean; + onClose: () => void; +} + +export function ClientLinksModal({ open, onClose }: ClientLinksModalProps) { + useEffect(() => { + if (!open) return; + const onKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', onKey); + document.body.style.overflow = 'hidden'; + return () => { + document.removeEventListener('keydown', onKey); + document.body.style.overflow = ''; + }; + }, [onClose, open]); + + if (!open) return null; + + return createPortal( +
{ + if (event.target === event.currentTarget) onClose(); + }} + > +
+
Подключение устройства
+ +
+ {PLATFORMS.map((platform) => ( +
+

{platform.name}

+ {platform.clients.map((client) => ( +
+ + {client.name} + + {client.note ?

{client.note}

: null} +
+ ))} +
+ ))} +
+ +
+
, + document.body, + ); +} diff --git a/src/components/StatStrip.tsx b/src/components/StatStrip.tsx index 0d7167a..b54c2a5 100644 --- a/src/components/StatStrip.tsx +++ b/src/components/StatStrip.tsx @@ -1,5 +1,5 @@ import type { SubscriptionData, UserInfo } from '../types'; -import { formatDate } from '../lib/format'; +import { formatDate, formatMoney } from '../lib/format'; interface StatStripProps { user: UserInfo | null; @@ -32,6 +32,7 @@ export function StatStrip({ user, subscription, deviceCount }: StatStripProps) { const telegramId = user?.telegram_id ?? null; const devicesLimit = subscription?.devices ?? null; const expiresAt = subscription?.expires_at ?? null; + const bonusBalance = user?.bonus_balance ?? 0; return (
@@ -89,6 +90,19 @@ export function StatStrip({ user, subscription, deviceCount }: StatStripProps) { > {expiresAt ? {formatDate(expiresAt)} : —} + + + + + + } + label="Бонусный баланс" + > + {formatMoney(bonusBalance)} + только на подписку +
); } diff --git a/src/components/SubscriptionQrModal.tsx b/src/components/SubscriptionQrModal.tsx new file mode 100644 index 0000000..2bf5d26 --- /dev/null +++ b/src/components/SubscriptionQrModal.tsx @@ -0,0 +1,77 @@ +import { useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; +import QRCode from 'qrcode'; + +interface SubscriptionQrModalProps { + link: string | null; + label?: string; + imageAlt?: string; + onClose: () => void; +} + +export function SubscriptionQrModal({ + link, + label = 'Ссылка подписки', + imageAlt = 'QR-код ссылки подписки', + onClose, +}: SubscriptionQrModalProps) { + const [image, setImage] = useState(null); + + useEffect(() => { + if (!link) return; + + let active = true; + void QRCode.toDataURL(link, { + errorCorrectionLevel: 'M', + margin: 2, + width: 280, + color: { dark: '#1a1610', light: '#faf7f2' }, + }) + .then((dataUrl) => { + if (active) setImage(dataUrl); + }) + .catch(() => { + if (active) setImage(null); + }); + + const onKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', onKey); + document.body.style.overflow = 'hidden'; + return () => { + active = false; + document.removeEventListener('keydown', onKey); + document.body.style.overflow = ''; + }; + }, [link, onClose]); + + if (!link) return null; + + return createPortal( +
{ + if (event.target === event.currentTarget) onClose(); + }} + > +
+
{label}
+

+ Откройте на устройстве +

+

Отсканируйте код камерой или приложением Malenia.

+
+ {image ? {imageAlt} : Генерируем QR-код} +
+ +
+
, + document.body, + ); +} diff --git a/src/lib/referral.ts b/src/lib/referral.ts new file mode 100644 index 0000000..57386a1 --- /dev/null +++ b/src/lib/referral.ts @@ -0,0 +1,20 @@ +export const REFERRAL_STORAGE_KEY = 'malenia.referral_code'; + +export function getReferralCode(): string | null { + try { + return localStorage.getItem(REFERRAL_STORAGE_KEY); + } catch { + return null; + } +} + +export function saveReferralCode(code: string): void { + const normalized = code.trim(); + if (!normalized) return; + + try { + localStorage.setItem(REFERRAL_STORAGE_KEY, normalized); + } catch { + // Registration can continue when storage is unavailable. + } +} diff --git a/src/pages/AccountPage.test.tsx b/src/pages/AccountPage.test.tsx index ebeb312..d132824 100644 --- a/src/pages/AccountPage.test.tsx +++ b/src/pages/AccountPage.test.tsx @@ -36,7 +36,7 @@ function renderAccount(authed = true) { access: 'a', refresh: 'r', expiresAt: 9999999999, - user: { username: 'alice', telegram_id: 'alice_id', referal_code: 'c' }, + user: { username: 'alice', telegram_id: 'alice_id', referal_code: 'c', bonus_balance: 150 }, remember: true, }); } else { @@ -59,7 +59,7 @@ function renderAccount(authed = true) { ); } -const ME = { username: 'alice', telegram_id: 'alice_id', referal_code: 'c' }; +const ME = { username: 'alice', telegram_id: 'alice_id', referal_code: 'c', bonus_balance: 150 }; const SUB = { devices: 6, expires_at: '2026-03-14T00:00:00Z', @@ -94,6 +94,8 @@ describe('AccountPage', () => { renderAccount(); expect(await screen.findByText('alice')).toBeInTheDocument(); expect(screen.getByText('@alice_id')).toBeInTheDocument(); + expect(screen.getByText(/150\s*₽/)).toBeInTheDocument(); + expect(screen.getByText(/только на подписку/i)).toBeInTheDocument(); expect(screen.getAllByText(/iPhone 15/)).toHaveLength(1); }); @@ -149,6 +151,23 @@ describe('AccountPage', () => { vi.mocked(api.getDevices).mockResolvedValue([]); renderAccount(); expect(await screen.findByText(/нет подключённых устройств/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /показать клиенты для подключения/i })).toBeEnabled(); + }); + + it('opens the client links dialog from the empty state', async () => { + vi.mocked(api.getMe).mockResolvedValue(ME); + vi.mocked(api.getSubscription).mockResolvedValue(SUB); + vi.mocked(api.getDevices).mockResolvedValue([]); + const user = userEvent.setup(); + renderAccount(); + + await user.click(await screen.findByRole('button', { name: /показать клиенты для подключения/i })); + expect(await screen.findByRole('dialog', { name: /выберите клиент/i })).toBeInTheDocument(); + expect(screen.getAllByRole('link', { name: 'v2raytun' })[0]).toHaveAttribute( + 'href', + 'https://apps.apple.com/en/app/v2raytun/id6476628951', + ); + expect(screen.getAllByText(/разрешить отправку hwid/i)).toHaveLength(2); }); it('opens confirmation modal and deletes device on confirm', async () => { diff --git a/src/pages/AccountPage.tsx b/src/pages/AccountPage.tsx index 64fe43c..7ac1e63 100644 --- a/src/pages/AccountPage.tsx +++ b/src/pages/AccountPage.tsx @@ -11,6 +11,7 @@ import { StatStrip } from '../components/StatStrip'; import { DeviceCard } from '../components/DeviceCard'; import { Modal } from '../components/Modal'; import { SubscriptionQrModal } from '../components/SubscriptionQrModal'; +import { ClientLinksModal } from '../components/ClientLinksModal'; import type { Device, SubscriptionData, UserInfo } from '../types'; export function AccountPage() { @@ -25,6 +26,7 @@ export function AccountPage() { const [pending, setPending] = useState(null); const [isQrOpen, setIsQrOpen] = useState(false); const [isReferralQrOpen, setIsReferralQrOpen] = useState(false); + const [isClientLinksOpen, setIsClientLinksOpen] = useState(false); useEffect(() => { if (!isAuthenticated) { @@ -114,7 +116,7 @@ export function AccountPage() { {loadingMeta ? (
- {Array.from({ length: 4 }).map((_, i) => ( + {Array.from({ length: 5 }).map((_, i) => (
@@ -234,9 +236,14 @@ export function AccountPage() {
Нет подключённых устройств
-
- Установите Malenia на устройство и подключите его к этому аккаунту. -
+
) : (
@@ -278,6 +285,7 @@ export function AccountPage() { imageAlt="QR-код реферальной ссылки" onClose={() => setIsReferralQrOpen(false)} /> + setIsClientLinksOpen(false)} /> ); } diff --git a/src/pages/LoginPage.test.tsx b/src/pages/LoginPage.test.tsx index 4cf51e1..d906066 100644 --- a/src/pages/LoginPage.test.tsx +++ b/src/pages/LoginPage.test.tsx @@ -55,7 +55,7 @@ describe('LoginPage', () => { access_token: 'a', refresh_token: 'r', expires_at: 9999999999, - user: { username: 'alice', telegram_id: null, referal_code: 'c' }, + user: { username: 'alice', telegram_id: null, referal_code: 'c', bonus_balance: 0 }, }); renderLogin(); await user.type(screen.getByLabelText('Имя пользователя'), 'alice'); @@ -94,7 +94,7 @@ describe('LoginPage', () => { access_token: 'a', refresh_token: 'r', expires_at: 9999999999, - user: { username: 'alice', telegram_id: null, referal_code: 'new-code' }, + user: { username: 'alice', telegram_id: null, referal_code: 'new-code', bonus_balance: 0 }, }); renderLogin(); await user.click(screen.getByRole('tab', { name: /Регистрация/i })); diff --git a/src/pages/PlanPage.test.tsx b/src/pages/PlanPage.test.tsx index f102d03..d083d57 100644 --- a/src/pages/PlanPage.test.tsx +++ b/src/pages/PlanPage.test.tsx @@ -93,7 +93,7 @@ describe('PlanPage', () => { access: 'a', refresh: 'r', expiresAt: 9999999999, - user: { username: 'u', telegram_id: null, referal_code: 'c' }, + user: { username: 'u', telegram_id: null, referal_code: 'c', bonus_balance: 0 }, remember: true, }); const user = userEvent.setup(); @@ -120,7 +120,7 @@ describe('PlanPage', () => { access: 'a', refresh: 'r', expiresAt: 9999999999, - user: { username: 'u', telegram_id: null, referal_code: 'c' }, + user: { username: 'u', telegram_id: null, referal_code: 'c', bonus_balance: 0 }, remember: true, }); const user = userEvent.setup(); diff --git a/src/styles/components.css b/src/styles/components.css index 5ef862b..a0301f3 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -272,6 +272,12 @@ } @media (max-width: 560px) { + .client-links-modal { + padding: 28px 20px 24px; + } + .client-platforms { + grid-template-columns: 1fr; + } .topbar { flex-direction: column; align-items: center; @@ -431,7 +437,7 @@ /* ── Stat strip (account) ───────────────────────────────── */ .profile { display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(5, 1fr); border-bottom: 1px solid var(--hairline); } .stat { @@ -497,6 +503,47 @@ color: var(--muted); letter-spacing: 0.04em; } +.stat-value .stat-note { + display: block; + margin-top: 5px; + font-family: var(--font-body); + font-size: 10px; + font-weight: 400; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--muted); +} + +@media (max-width: 860px) { + .profile { + grid-template-columns: repeat(3, 1fr); + } + .stat:nth-child(3) { + border-right: none; + } + .stat:nth-child(4) { + border-right: 1px solid var(--hairline); + } +} + +@media (max-width: 560px) { + .profile { + grid-template-columns: repeat(2, 1fr); + } + .stat:nth-child(2n) { + border-right: none; + } + .stat:nth-child(3), + .stat:nth-child(4) { + border-right: 1px solid var(--hairline); + } + .stat:nth-child(4) { + border-right: none; + } + .stat:last-child { + grid-column: span 2; + } +} /* ── Subscription link (account) ───────────────────────── */ .subscription-link { @@ -608,6 +655,52 @@ text-transform: uppercase; color: var(--muted); } +.client-links-modal { + max-width: 560px; + text-align: left; +} +.client-links-modal .modal-kicker, +.client-links-modal .modal-title { + text-align: center; +} +.client-platforms { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1px; + margin: 0 -8px 26px; + border: 1px solid var(--hairline); + background: var(--hairline); +} +.client-platform { + padding: 18px; + background: var(--surface); +} +.client-platform h4 { + margin-bottom: 12px; + font-family: var(--font-display); + font-size: 19px; + font-weight: 500; +} +.client-link + .client-link { + margin-top: 10px; +} +.client-link a { + color: var(--fg); + font-size: 12px; + letter-spacing: 0.06em; + text-decoration: underline; + text-decoration-color: var(--accent); + text-underline-offset: 3px; +} +.client-link a:hover { + color: var(--accent-dim); +} +.client-link p { + margin-top: 5px; + color: var(--muted); + font-size: 11px; + line-height: 1.45; +} /* ── Device grid (account) ──────────────────────────────── */ .devices-head { @@ -754,6 +847,24 @@ color: var(--muted); letter-spacing: 0.02em; } +.devices-empty-help { + width: 28px; + height: 28px; + margin-top: 16px; + border: 1px solid var(--hairline-strong); + border-radius: 50%; + background: transparent; + color: var(--muted); + font-family: var(--font-display); + font-size: 18px; + line-height: 1; + transition: background 0.25s, border-color 0.25s, color 0.25s; +} +.devices-empty-help:hover { + border-color: var(--accent); + background: var(--accent); + color: var(--fg); +} /* ── Plan slider ────────────────────────────────────────── */ .slider-wrap { diff --git a/src/types.ts b/src/types.ts index 906b9c5..b59d929 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,6 +4,7 @@ export interface UserInfo { username: string | null; telegram_id: string | null; referal_code: string; + bonus_balance: number; } export interface LoginResponse { diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index 2658c81..4bf7d37 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/types.ts","./src/api/client.test.ts","./src/api/client.ts","./src/components/AddonList.tsx","./src/components/Breakdown.tsx","./src/components/Cursor.tsx","./src/components/DeviceCard.tsx","./src/components/DeviceSlider.tsx","./src/components/DurationSelect.tsx","./src/components/Emblem.tsx","./src/components/Logo.tsx","./src/components/Modal.tsx","./src/components/Reveal.tsx","./src/components/Shell.tsx","./src/components/Skeleton.tsx","./src/components/StatStrip.tsx","./src/components/SubscriptionQrModal.tsx","./src/components/Toast.tsx","./src/components/TopBar.tsx","./src/hooks/useAuth.ts","./src/hooks/useDevices.ts","./src/hooks/useLocalStorage.ts","./src/hooks/usePlans.ts","./src/lib/format.test.ts","./src/lib/format.ts","./src/lib/planConfig.test.ts","./src/lib/planConfig.ts","./src/lib/redirect.ts","./src/lib/referral.ts","./src/pages/AccountPage.test.tsx","./src/pages/AccountPage.tsx","./src/pages/Landing.test.tsx","./src/pages/Landing.tsx","./src/pages/LoginPage.test.tsx","./src/pages/LoginPage.tsx","./src/pages/PlanPage.test.tsx","./src/pages/PlanPage.tsx","./src/store/useStore.ts","./src/store/useToast.ts","./src/test/setup.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/types.ts","./src/api/client.test.ts","./src/api/client.ts","./src/components/AddonList.tsx","./src/components/Breakdown.tsx","./src/components/ClientLinksModal.tsx","./src/components/Cursor.tsx","./src/components/DeviceCard.tsx","./src/components/DeviceSlider.tsx","./src/components/DurationSelect.tsx","./src/components/Emblem.tsx","./src/components/Logo.tsx","./src/components/Modal.tsx","./src/components/Reveal.tsx","./src/components/Shell.tsx","./src/components/Skeleton.tsx","./src/components/StatStrip.tsx","./src/components/SubscriptionQrModal.tsx","./src/components/Toast.tsx","./src/components/TopBar.tsx","./src/hooks/useAuth.ts","./src/hooks/useDevices.ts","./src/hooks/useLocalStorage.ts","./src/hooks/usePlans.ts","./src/lib/format.test.ts","./src/lib/format.ts","./src/lib/planConfig.test.ts","./src/lib/planConfig.ts","./src/lib/redirect.ts","./src/lib/referral.ts","./src/pages/AccountPage.test.tsx","./src/pages/AccountPage.tsx","./src/pages/Landing.test.tsx","./src/pages/Landing.tsx","./src/pages/LoginPage.test.tsx","./src/pages/LoginPage.tsx","./src/pages/PlanPage.test.tsx","./src/pages/PlanPage.tsx","./src/store/useStore.ts","./src/store/useToast.ts","./src/test/setup.ts"],"version":"5.9.3"} \ No newline at end of file