From 7bb76a25b3cada76be26c4f34b7ae57b169ab682 Mon Sep 17 00:00:00 2001 From: Askar Niroomand Date: Sun, 9 Aug 2026 03:44:14 +0330 Subject: [PATCH 01/18] feat(ui): Telegram Mini App bento shell with manager invite scoping Add /twa user+admin surfaces (scoped by ?ref=), six-language keys, and refresh the main panel dashboard/protocols with the same bento patterns. Version stays 5.1.1. --- CHANGELOG.md | 1 + frontend/app/panel/page.tsx | 193 ++++++++------- frontend/app/panel/protocols/page.tsx | 8 + frontend/app/twa/README.md | 29 +++ frontend/app/twa/admin/layout.tsx | 58 +++++ frontend/app/twa/admin/owner/page.tsx | 60 +++++ frontend/app/twa/admin/protocols/page.tsx | 106 +++++++++ frontend/app/twa/admin/sponsor/page.tsx | 83 +++++++ frontend/app/twa/invalid/page.tsx | 32 +++ frontend/app/twa/layout.tsx | 17 ++ frontend/app/twa/page.tsx | 28 +++ frontend/app/twa/user/layout.tsx | 45 ++++ frontend/app/twa/user/page.tsx | 102 ++++++++ frontend/app/twa/user/profile/page.tsx | 67 ++++++ frontend/app/twa/user/proxies/page.tsx | 100 ++++++++ frontend/app/twa/user/store/page.tsx | 59 +++++ frontend/app/twa/user/wallet/page.tsx | 75 ++++++ frontend/components/twa/bottom-nav.tsx | 48 ++++ .../components/twa/dashboard-bento-layout.tsx | 32 +++ .../twa/manager-branding-header.tsx | 37 +++ frontend/components/twa/pricing-slider.tsx | 33 +++ .../components/twa/protocol-test-dialog.tsx | 58 +++++ frontend/components/twa/server-load-card.tsx | 38 +++ frontend/lib/twa/hooks.ts | 78 +++++++ frontend/lib/twa/i18n.ts | 220 ++++++++++++++++++ frontend/lib/twa/manager-context.tsx | 150 ++++++++++++ frontend/lib/twa/mock.ts | 147 ++++++++++++ frontend/lib/twa/types.ts | 71 ++++++ frontend/locales/en.json | 38 +++ frontend/locales/fa.json | 38 +++ frontend/locales/pt.json | 38 +++ frontend/locales/ru.json | 38 +++ frontend/locales/tr.json | 38 +++ frontend/locales/zh.json | 38 +++ 34 files changed, 2114 insertions(+), 89 deletions(-) create mode 100644 frontend/app/twa/README.md create mode 100644 frontend/app/twa/admin/layout.tsx create mode 100644 frontend/app/twa/admin/owner/page.tsx create mode 100644 frontend/app/twa/admin/protocols/page.tsx create mode 100644 frontend/app/twa/admin/sponsor/page.tsx create mode 100644 frontend/app/twa/invalid/page.tsx create mode 100644 frontend/app/twa/layout.tsx create mode 100644 frontend/app/twa/page.tsx create mode 100644 frontend/app/twa/user/layout.tsx create mode 100644 frontend/app/twa/user/page.tsx create mode 100644 frontend/app/twa/user/profile/page.tsx create mode 100644 frontend/app/twa/user/proxies/page.tsx create mode 100644 frontend/app/twa/user/store/page.tsx create mode 100644 frontend/app/twa/user/wallet/page.tsx create mode 100644 frontend/components/twa/bottom-nav.tsx create mode 100644 frontend/components/twa/dashboard-bento-layout.tsx create mode 100644 frontend/components/twa/manager-branding-header.tsx create mode 100644 frontend/components/twa/pricing-slider.tsx create mode 100644 frontend/components/twa/protocol-test-dialog.tsx create mode 100644 frontend/components/twa/server-load-card.tsx create mode 100644 frontend/lib/twa/hooks.ts create mode 100644 frontend/lib/twa/i18n.ts create mode 100644 frontend/lib/twa/manager-context.tsx create mode 100644 frontend/lib/twa/mock.ts create mode 100644 frontend/lib/twa/types.ts create mode 100644 frontend/locales/en.json create mode 100644 frontend/locales/fa.json create mode 100644 frontend/locales/pt.json create mode 100644 frontend/locales/ru.json create mode 100644 frontend/locales/tr.json create mode 100644 frontend/locales/zh.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 928b70e..9b1c48b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Telegram operator bot (`telegram-bot/`) — create / list / delete / update panels with Persian reply keyboard and step progress - Client-path Clean IP scan with ranked recommendations from the visitor network - Dual-author credit: Askar Niroomand & Pakrohk +- Telegram Mini App UI (`/twa/*`) — manager invite scoping, bento dashboards, 6-language RTL/LTR stubs ### Changed - Panel UI branding normalized to XRayMOD (removed internal theme codenames from user-facing chrome) diff --git a/frontend/app/panel/page.tsx b/frontend/app/panel/page.tsx index 31a69a0..5bd1293 100644 --- a/frontend/app/panel/page.tsx +++ b/frontend/app/panel/page.tsx @@ -1,13 +1,24 @@ 'use client'; import { useEffect, useState } from 'react'; -import { Users, Globe, Activity, Wifi, ArrowUpRight, Shield, Radar, Copy, Crosshair } from 'lucide-react'; +import { + Users, + Globe, + Wifi, + ArrowUpRight, + Shield, + Radar, + Copy, + Crosshair, + Smartphone, +} from 'lucide-react'; import { api, asList } from '@/lib/api'; -import { StatCard, Card, CardHeader, ProgressBar, Button, PageHeader } from '@/components'; +import { CardHeader, ProgressBar, Button, PageHeader } from '@/components'; import { PanelLink } from '@/components/panel-link'; import { useI18n } from '@/lib/i18n'; import { getPanelPrefix, secureSubUrl } from '@/lib/paths'; import { toast } from 'sonner'; +import { BentoCell, DashboardBentoLayout } from '@/components/twa/dashboard-bento-layout'; interface SystemStatus { uptime: string; @@ -57,9 +68,10 @@ export default function DashboardPage() { const today = status?.traffic?.today; const month = status?.traffic?.month; + const activePct = users.total ? Math.round((users.active / users.total) * 100) : 0; return ( -
+
Admin + + + + + + +
-

- XrayMOD control plane -

-

- SECURE PATH · silent 404 · Admin Dashboard · D-tagged domains -

-
-
- - - - - -
- - + -
- - - - -
+ +

{t('users')}

+

{users.total}

+

+ {users.active} {t('active')} · {activePct}% +

+
+ +

{t('status')}

+

+ {status?.configured ? t('active') : 'Setup'} +

+

+ {status?.version || '5.1.1'} +

+
+ +

+ {t('todayTraffic')} +

+

+ {formatBytes(today?.total || 0)} +

+

+ ↑ {formatBytes(today?.up || 0)} / ↓ {formatBytes(today?.down || 0)} +

+
+ +

+ {t('monthTraffic')} +

+

+ {formatBytes(month?.total || 0)} +

+

{t('total')}

+
-
- +
{[ @@ -166,9 +189,9 @@ export default function DashboardPage() {
))}
- + - +
@@ -186,13 +209,11 @@ export default function DashboardPage() {
-
- + -
- - -
+ + +
@@ -200,15 +221,12 @@ export default function DashboardPage() {

{t('config')}

{t('recommended')}

- +
-
-
- - + + + +
@@ -217,14 +235,11 @@ export default function DashboardPage() {

{t('network')}

DNS · WARP · IPv6

- +
- -
- + +
+
@@ -238,7 +253,7 @@ export default function DashboardPage() { {subHint && ( )}
- -
+
+
); } diff --git a/frontend/app/panel/protocols/page.tsx b/frontend/app/panel/protocols/page.tsx index ccf914d..cc2118d 100644 --- a/frontend/app/panel/protocols/page.tsx +++ b/frontend/app/panel/protocols/page.tsx @@ -4,6 +4,8 @@ import { useEffect, useState } from 'react'; import { Shield, Plus, Trash2, Edit2, Copy, Eye, EyeOff, Check } from 'lucide-react'; import { api } from '@/lib/api'; import { Card, CardHeader, Button, Input, StatusBadge, EmptyState } from '@/components'; +import { ProtocolTestDialog } from '@/components/twa/protocol-test-dialog'; +import { TwaI18nProvider } from '@/lib/twa/i18n'; interface Protocol { id: string; @@ -24,6 +26,7 @@ export default function ProtocolsPage() { const [showAdd, setShowAdd] = useState(false); const [editing, setEditing] = useState(null); const [loading, setLoading] = useState(true); + const [showTest, setShowTest] = useState(false); const [form, setForm] = useState({ name: '', @@ -83,10 +86,15 @@ export default function ProtocolsPage() {

مدیریت پروتکل‌ها و ترنسپورت

+
+ + setShowTest(false)} /> + + {/* Protocol Cards */} {loading ? ( diff --git a/frontend/app/twa/README.md b/frontend/app/twa/README.md new file mode 100644 index 0000000..faa0d67 --- /dev/null +++ b/frontend/app/twa/README.md @@ -0,0 +1,29 @@ +# Telegram Mini App UI (manager-scoped) + +Bento + bottom-nav Mini App under `/twa/*`. + +## Invite scoping + +Open only with a manager ref: + +- `/twa/user?ref=owner_demo` +- `/twa/user?ref=sponsor_demo` + +Without `ref` / `startapp` → `/twa/invalid`. + +`ManagerProvider` + `useManager()` filter protocols, proxies, servers, and wallet txs by `managerId`. + +## Routes + +| Path | Role | +|:-----|:-----| +| `/twa/user` | User bento dashboard | +| `/twa/user/proxies` | Scoped proxy list + sub combinator | +| `/twa/user/store` | Scoped store | +| `/twa/user/wallet` | Scoped wallet | +| `/twa/user/profile` | Language (FA/EN/RU/PT/TR/ZH) + admin toggle | +| `/twa/admin/owner` | Owner bento | +| `/twa/admin/sponsor` | Sponsor bento | +| `/twa/admin/protocols` | Protocol table + test dialog + pricing | + +Translation keys: `frontend/locales/{fa,en,ru,pt,tr,zh}.json` diff --git a/frontend/app/twa/admin/layout.tsx b/frontend/app/twa/admin/layout.tsx new file mode 100644 index 0000000..9c3b734 --- /dev/null +++ b/frontend/app/twa/admin/layout.tsx @@ -0,0 +1,58 @@ +'use client'; + +import Link from 'next/link'; +import { type ReactNode } from 'react'; +import { usePathname, useSearchParams } from 'next/navigation'; +import { useManager } from '@/lib/twa/manager-context'; +import { useTwaI18n } from '@/lib/twa/i18n'; +import { Button } from '@/components'; + +export default function AdminLayout({ children }: { children: ReactNode }) { + const { setMode, manager, error, isLoading } = useManager(); + const { t } = useTwaI18n(); + const pathname = usePathname(); + const search = useSearchParams(); + const qs = search.toString() ? `?${search.toString()}` : ''; + + if (isLoading) return
{t('loading')}
; + if (error || !manager) { + return ( +
+ + {t('invalidInvite')} + +
+ ); + } + + const links = [ + { href: '/twa/admin/owner', label: 'Owner' }, + { href: '/twa/admin/sponsor', label: 'Sponsor' }, + { href: '/twa/admin/protocols', label: t('protocols') }, + ]; + + return ( +
+ +
{children}
+
+ ); +} diff --git a/frontend/app/twa/admin/owner/page.tsx b/frontend/app/twa/admin/owner/page.tsx new file mode 100644 index 0000000..b2a85e7 --- /dev/null +++ b/frontend/app/twa/admin/owner/page.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { BentoCell, DashboardBentoLayout } from '@/components/twa/dashboard-bento-layout'; +import { useManager } from '@/lib/twa/manager-context'; +import { useTwaI18n } from '@/lib/twa/i18n'; +import { ServerLoadCard } from '@/components/twa/server-load-card'; + +export default function OwnerDashboardPage() { + const { protocols, transactions } = useManager(); + const { t } = useTwaI18n(); + const active = protocols.filter((p) => p.status === 'active').length; + const profit = transactions + .filter((x) => x.type === 'profit' || x.type === 'purchase') + .reduce((s, x) => s + x.amount, 0); + + return ( +
+

Owner · {t('home')}

+ + +

{t('activeProtocols')}

+

{active}

+

+ {t('subordinates')}: {profit.toFixed(1)} DAI +

+
+ +

{t('serverHealth')}

+ +
+ +

{t('revenue')}

+
+
+ DAI + 62% +
+
+
+
+
+ Stars + 28% +
+
+
+
+
+ Gram + 10% +
+
+
+
+
+ + +
+ ); +} diff --git a/frontend/app/twa/admin/protocols/page.tsx b/frontend/app/twa/admin/protocols/page.tsx new file mode 100644 index 0000000..dfa0103 --- /dev/null +++ b/frontend/app/twa/admin/protocols/page.tsx @@ -0,0 +1,106 @@ +'use client'; + +import { useState } from 'react'; +import { useManager } from '@/lib/twa/manager-context'; +import { useTwaI18n } from '@/lib/twa/i18n'; +import { Button, Input } from '@/components'; +import { ProtocolTestDialog } from '@/components/twa/protocol-test-dialog'; +import { PricingSlider } from '@/components/twa/pricing-slider'; +import { BentoCell, DashboardBentoLayout } from '@/components/twa/dashboard-bento-layout'; +import { toast } from 'sonner'; + +export default function AdminProtocolsPage() { + const { protocols } = useManager(); + const { t } = useTwaI18n(); + const [openTest, setOpenTest] = useState(false); + const [price, setPrice] = useState(2.5); + const [profit, setProfit] = useState(20); + const [showCreate, setShowCreate] = useState(false); + const [name, setName] = useState(''); + const [port, setPort] = useState('443'); + + return ( +
+
+

{t('protocols')}

+
+ + +
+
+ + {showCreate && ( +
+ setName(e.target.value)} /> + setPort(e.target.value)} /> + + + + +
+ )} + +
+ + + + + + + + + + + + {protocols.map((p) => ( + + + + + + + + ))} + {!protocols.length && ( + + + + )} + +
NamePortStatusPingSite
{p.name}{p.port} + + {p.status} + + {p.pingMs ?? '—'} ms{p.assignedSite || '—'}
+ {t('noData')} +
+
+ + + +

{t('scopedNote')}

+
+
+ + setOpenTest(false)} /> +
+ ); +} diff --git a/frontend/app/twa/admin/sponsor/page.tsx b/frontend/app/twa/admin/sponsor/page.tsx new file mode 100644 index 0000000..6990aff --- /dev/null +++ b/frontend/app/twa/admin/sponsor/page.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { BentoCell, DashboardBentoLayout } from '@/components/twa/dashboard-bento-layout'; +import { ServerLoadCard } from '@/components/twa/server-load-card'; +import { useManager } from '@/lib/twa/manager-context'; +import { useTwaI18n } from '@/lib/twa/i18n'; +import { Button } from '@/components'; +import { toast } from 'sonner'; + +export default function SponsorDashboardPage() { + const { servers, inviteUrl } = useManager(); + const { t } = useTwaI18n(); + + return ( +
+

Sponsor · {t('home')}

+ + +

Active users

+

248

+

Monthly bandwidth

+
+
+
+

550 / 1000 GB

+ + + + + +

Server inventory

+
+ {servers.map((s) => ( +
+

{s.ip}

+

{s.location}

+

+ Conn: {s.activeConnections} · RAM {(s.ramLoad * 100).toFixed(0)}% +

+
    + {s.domains.map((d) => ( +
  • + {d.domain} + + DNS {d.dnsOk ? 'OK' : 'FAIL'} + +
  • + ))} +
+ +
+ ))} + {!servers.length &&

{t('noData')}

} +
+
+ +

{t('inviteLinks')}

+

{inviteUrl}

+ +
+ +
+ ); +} diff --git a/frontend/app/twa/invalid/page.tsx b/frontend/app/twa/invalid/page.tsx new file mode 100644 index 0000000..d98147c --- /dev/null +++ b/frontend/app/twa/invalid/page.tsx @@ -0,0 +1,32 @@ +'use client'; + +import Link from 'next/link'; +import { useTwaI18n } from '@/lib/twa/i18n'; + +export default function InvalidInvitePage() { + const { t } = useTwaI18n(); + + return ( +
+

404

+

{t('invalidInvite')}

+

+ {t('invalidInviteDesc')} +

+
+ + Demo owner + + + Demo sponsor + +
+
+ ); +} diff --git a/frontend/app/twa/layout.tsx b/frontend/app/twa/layout.tsx new file mode 100644 index 0000000..a0bbcfb --- /dev/null +++ b/frontend/app/twa/layout.tsx @@ -0,0 +1,17 @@ +'use client'; + +import { Suspense, type ReactNode } from 'react'; +import { ManagerProvider } from '@/lib/twa/manager-context'; +import { TwaI18nProvider } from '@/lib/twa/i18n'; + +export default function TwaRootLayout({ children }: { children: ReactNode }) { + return ( + + +
}> +
{children}
+ + + + ); +} diff --git a/frontend/app/twa/page.tsx b/frontend/app/twa/page.tsx new file mode 100644 index 0000000..14a429a --- /dev/null +++ b/frontend/app/twa/page.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useManager } from '@/lib/twa/manager-context'; + +export default function TwaIndexPage() { + const router = useRouter(); + const search = useSearchParams(); + const { isLoading, error, managerId } = useManager(); + + useEffect(() => { + if (isLoading) return; + const qs = search.toString(); + const suffix = qs ? `?${qs}` : managerId ? `?ref=${managerId}` : ''; + if (error) { + router.replace(`/twa/invalid${suffix}`); + return; + } + router.replace(`/twa/user${suffix}`); + }, [isLoading, error, managerId, router, search]); + + return ( +
+ … +
+ ); +} diff --git a/frontend/app/twa/user/layout.tsx b/frontend/app/twa/user/layout.tsx new file mode 100644 index 0000000..f54ee29 --- /dev/null +++ b/frontend/app/twa/user/layout.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { type ReactNode } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useEffect } from 'react'; +import { useManager } from '@/lib/twa/manager-context'; +import { ManagerBrandingHeader } from '@/components/twa/manager-branding-header'; +import { BottomNav } from '@/components/twa/bottom-nav'; +import { useTwaI18n } from '@/lib/twa/i18n'; + +export default function UserLayout({ children }: { children: ReactNode }) { + const { isLoading, error, mode } = useManager(); + const router = useRouter(); + const search = useSearchParams(); + const { t } = useTwaI18n(); + + useEffect(() => { + if (isLoading) return; + if (error) { + const qs = search.toString(); + router.replace(`/twa/invalid${qs ? `?${qs}` : ''}`); + return; + } + if (mode === 'admin') { + const qs = search.toString(); + router.replace(`/twa/admin/owner${qs ? `?${qs}` : ''}`); + } + }, [isLoading, error, mode, router, search]); + + if (isLoading || error) { + return ( +
{t('loading')}
+ ); + } + + return ( +
+ + {children} +
+ +
+
+ ); +} diff --git a/frontend/app/twa/user/page.tsx b/frontend/app/twa/user/page.tsx new file mode 100644 index 0000000..6ca0d34 --- /dev/null +++ b/frontend/app/twa/user/page.tsx @@ -0,0 +1,102 @@ +'use client'; + +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; +import { Link2, ShoppingCart } from 'lucide-react'; +import { BentoCell, DashboardBentoLayout } from '@/components/twa/dashboard-bento-layout'; +import { useCloudflareCheck, useCurrencyHint } from '@/lib/twa/hooks'; +import { MOCK_USER_SUB } from '@/lib/twa/mock'; +import { useTwaI18n } from '@/lib/twa/i18n'; +import { Button } from '@/components'; + +export default function UserDashboardPage() { + const { t } = useTwaI18n(); + const search = useSearchParams(); + const qs = search.toString() ? `?${search.toString()}` : ''; + const sub = MOCK_USER_SUB; + const pct = Math.round((sub.remainingGb / sub.totalGb) * 100); + const { ip, isClean, isLoading } = useCloudflareCheck(); + const { label } = useCurrencyHint(); + + return ( +
+ + +

+ {t('remainingVolume')} +

+
+
+
+ {pct}% +
+
+
+

+ {sub.remainingGb} + GB +

+

/ {sub.totalGb} GB

+
+
+
+ + +

+ {t('expiry')} +

+

{sub.expiresAt}

+
+ + +

+ {t('quickActions')} +

+
+ + + + + + +
+

{label}

+
+ + +
+
+

{t('cleanIp')}

+

+ cloudflare.com/cdn-cgi/trace +

+
+ + {isLoading ? t('checking') : isClean ? t('cleanFound') : '—'} + +
+

+ {t('externalIp')}: {ip || '—'} +

+
+
+
+ ); +} diff --git a/frontend/app/twa/user/profile/page.tsx b/frontend/app/twa/user/profile/page.tsx new file mode 100644 index 0000000..313e67d --- /dev/null +++ b/frontend/app/twa/user/profile/page.tsx @@ -0,0 +1,67 @@ +'use client'; + +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; +import { toast } from 'sonner'; +import { useManager } from '@/lib/twa/manager-context'; +import { TWA_LANG_OPTIONS, useTwaI18n } from '@/lib/twa/i18n'; +import { Button } from '@/components'; +import { BentoCell, DashboardBentoLayout } from '@/components/twa/dashboard-bento-layout'; + +export default function UserProfilePage() { + const { setMode, inviteUrl, manager } = useManager(); + const { t, lang, setLang } = useTwaI18n(); + const search = useSearchParams(); + const qs = search.toString() ? `?${search.toString()}` : ''; + + return ( +
+

{t('profile')}

+ + +

{t('language')}

+ +
+ + +

{t('inviteLinks')}

+

+ {inviteUrl || manager?.invitePath} +

+ +
+ + + + + + + + + +
+
+ ); +} diff --git a/frontend/app/twa/user/proxies/page.tsx b/frontend/app/twa/user/proxies/page.tsx new file mode 100644 index 0000000..6001c17 --- /dev/null +++ b/frontend/app/twa/user/proxies/page.tsx @@ -0,0 +1,100 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { toast } from 'sonner'; +import { useManager } from '@/lib/twa/manager-context'; +import { useTwaI18n } from '@/lib/twa/i18n'; +import { Button, Input } from '@/components'; +import { BentoCell, DashboardBentoLayout } from '@/components/twa/dashboard-bento-layout'; + +export default function UserProxiesPage() { + const { proxies, managerId } = useManager(); + const { t } = useTwaI18n(); + const [q, setQ] = useState(''); + const [selected, setSelected] = useState([]); + + const filtered = useMemo(() => { + const needle = q.trim().toLowerCase(); + return proxies.filter( + (p) => + !needle || + p.name.toLowerCase().includes(needle) || + p.location.toLowerCase().includes(needle) + ); + }, [proxies, q]); + + const toggle = (id: string) => { + setSelected((prev) => + prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id] + ); + }; + + const generateSub = () => { + if (!selected.length) { + toast.error(t('noData')); + return; + } + const url = `https://sub.example.com/${managerId}?nodes=${selected.join(',')}`; + void navigator.clipboard.writeText(url); + toast.success(url); + }; + + return ( +
+
+

{t('proxies')}

+ setQ(e.target.value)} + /> +
+

+ GET /api/proxies?managerId={managerId} +

+ + + {filtered.map((p) => ( + +
+
+

{p.name}

+

+ {p.location} · {p.protocol}:{p.port} +

+

{p.host}

+
+ {p.pingMs ?? '—'} ms +
+
+ + +
+
+ ))} + {!filtered.length && ( + +

{t('noData')}

+
+ )} +
+ + +
+ ); +} diff --git a/frontend/app/twa/user/store/page.tsx b/frontend/app/twa/user/store/page.tsx new file mode 100644 index 0000000..13e57c6 --- /dev/null +++ b/frontend/app/twa/user/store/page.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { BentoCell, DashboardBentoLayout } from '@/components/twa/dashboard-bento-layout'; +import { useManager } from '@/lib/twa/manager-context'; +import { useTwaI18n } from '@/lib/twa/i18n'; +import { Button } from '@/components'; +import { toast } from 'sonner'; + +export default function UserStorePage() { + const { manager, protocols } = useManager(); + const { t } = useTwaI18n(); + + return ( +
+

{t('store')}

+

+ {manager?.name} — {t('scopedNote')} +

+ + +

{t('buyServer')}

+

RAM / CPU / Bandwidth

+

from 2.5 DAI · Stars

+ +
+ +

Buy Domain

+

Availability + Gram pricing

+ +
+ +

Manager inventory

+
    + {protocols.map((p) => ( +
  • + {p.name} + {p.priceDai} DAI +
  • + ))} + {!protocols.length && ( +
  • {t('noData')}
  • + )} +
+
+
+
+ ); +} diff --git a/frontend/app/twa/user/wallet/page.tsx b/frontend/app/twa/user/wallet/page.tsx new file mode 100644 index 0000000..f156314 --- /dev/null +++ b/frontend/app/twa/user/wallet/page.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { BentoCell, DashboardBentoLayout } from '@/components/twa/dashboard-bento-layout'; +import { useManager } from '@/lib/twa/manager-context'; +import { MOCK_WALLET } from '@/lib/twa/mock'; +import { useTwaI18n } from '@/lib/twa/i18n'; +import { Button } from '@/components'; +import { toast } from 'sonner'; + +export default function UserWalletPage() { + const { transactions } = useManager(); + const { t } = useTwaI18n(); + const w = MOCK_WALLET; + + return ( +
+

{t('wallet')}

+ + +

Stars

+

{w.stars}

+
+ +

Gram

+

{w.gram}

+
+ +

DAI

+

{w.dai}

+
+ +
+ + +
+
+ + + + + + + + + + + {transactions.map((tx) => ( + + + + + + + ))} + {!transactions.length && ( + + + + )} + +
DateAmountTypeStatus
{tx.date} + {tx.amount} {tx.currency} + {tx.type}{tx.status}
+ {t('noData')} +
+
+
+
+
+ ); +} diff --git a/frontend/components/twa/bottom-nav.tsx b/frontend/components/twa/bottom-nav.tsx new file mode 100644 index 0000000..1d2a7fc --- /dev/null +++ b/frontend/components/twa/bottom-nav.tsx @@ -0,0 +1,48 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname, useSearchParams } from 'next/navigation'; +import { Home, Store, Wallet, UserRound } from 'lucide-react'; +import { useTwaI18n } from '@/lib/twa/i18n'; + +const TABS = [ + { href: '/twa/user', key: 'home', icon: Home }, + { href: '/twa/user/store', key: 'store', icon: Store }, + { href: '/twa/user/wallet', key: 'wallet', icon: Wallet }, + { href: '/twa/user/profile', key: 'profile', icon: UserRound }, +] as const; + +export function BottomNav() { + const pathname = usePathname(); + const search = useSearchParams(); + const { t } = useTwaI18n(); + const qs = search.toString(); + const suffix = qs ? `?${qs}` : ''; + + return ( + + ); +} diff --git a/frontend/components/twa/dashboard-bento-layout.tsx b/frontend/components/twa/dashboard-bento-layout.tsx new file mode 100644 index 0000000..7d5486d --- /dev/null +++ b/frontend/components/twa/dashboard-bento-layout.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from 'react'; + +type Span = 3 | 4 | 6 | 12; + +const SPAN: Record = { + 3: 'col-span-12 sm:col-span-6 lg:col-span-3', + 4: 'col-span-12 sm:col-span-6 lg:col-span-4', + 6: 'col-span-12 lg:col-span-6', + 12: 'col-span-12', +}; + +export function DashboardBentoLayout({ children }: { children: ReactNode }) { + return ( +
{children}
+ ); +} + +export function BentoCell({ + span = 6, + children, + className = '', +}: { + span?: Span; + children: ReactNode; + className?: string; +}) { + return ( +
+
{children}
+
+ ); +} diff --git a/frontend/components/twa/manager-branding-header.tsx b/frontend/components/twa/manager-branding-header.tsx new file mode 100644 index 0000000..b2bb0ec --- /dev/null +++ b/frontend/components/twa/manager-branding-header.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { useManager } from '@/lib/twa/manager-context'; +import { useTwaI18n } from '@/lib/twa/i18n'; + +export function ManagerBrandingHeader() { + const { manager } = useManager(); + const { t } = useTwaI18n(); + if (!manager) return null; + + return ( +
+
+
+ {manager.logoUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + manager.name.slice(0, 2).toUpperCase() + )} +
+
+

+ {manager.name} +

+

+ {manager.welcomeText} +

+
+ + {manager.role} + +
+

{t('scopedNote')}

+
+ ); +} diff --git a/frontend/components/twa/pricing-slider.tsx b/frontend/components/twa/pricing-slider.tsx new file mode 100644 index 0000000..add2ac0 --- /dev/null +++ b/frontend/components/twa/pricing-slider.tsx @@ -0,0 +1,33 @@ +'use client'; + +export function PricingSlider({ + value, + onChange, + min = 0.5, + max = 20, + label, +}: { + value: number; + onChange: (v: number) => void; + min?: number; + max?: number; + label: string; +}) { + return ( +
+
+ {label} + {value.toFixed(1)} DAI +
+ onChange(Number(e.target.value))} + className="w-full accent-[var(--accent)]" + /> +
+ ); +} diff --git a/frontend/components/twa/protocol-test-dialog.tsx b/frontend/components/twa/protocol-test-dialog.tsx new file mode 100644 index 0000000..14809f3 --- /dev/null +++ b/frontend/components/twa/protocol-test-dialog.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { useState } from 'react'; +import { Button, Input } from '@/components'; +import { usePingCheck } from '@/lib/twa/hooks'; +import { useTwaI18n } from '@/lib/twa/i18n'; + +export function ProtocolTestDialog({ + open, + onClose, +}: { + open: boolean; + onClose: () => void; +}) { + const { t } = useTwaI18n(); + const [url, setUrl] = useState('https://www.cloudflare.com/cdn-cgi/trace'); + const { data, isLoading, run } = usePingCheck(url); + + if (!open) return null; + + return ( +
+
+
+

Test site

+ +
+
+ setUrl(e.target.value)} + placeholder="https://…" + /> + +
+ {!data && !isLoading && '—'} + {isLoading && t('checking')} + {data && ( +
+                {data.ok
+                  ? `200 OK / no-cors ok\nLatency: ${data.ms} ms`
+                  : `Timeout / failed\nElapsed: ${data.ms} ms`}
+              
+ )} +
+
+
+
+ ); +} diff --git a/frontend/components/twa/server-load-card.tsx b/frontend/components/twa/server-load-card.tsx new file mode 100644 index 0000000..4877c3e --- /dev/null +++ b/frontend/components/twa/server-load-card.tsx @@ -0,0 +1,38 @@ +'use client'; + +export function ServerLoadCard({ + label, + value, +}: { + label: string; + /** 0–1 */ + value: number; +}) { + const pct = Math.round(Math.min(1, Math.max(0, value)) * 100); + const r = 42; + const c = 2 * Math.PI * r; + const offset = c - (pct / 100) * c; + + return ( +
+ + + + +
+

{label}

+

{pct}%

+
+
+ ); +} diff --git a/frontend/lib/twa/hooks.ts b/frontend/lib/twa/hooks.ts new file mode 100644 index 0000000..5062106 --- /dev/null +++ b/frontend/lib/twa/hooks.ts @@ -0,0 +1,78 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { DAI_TO_STARS } from './mock'; + +export function usePingCheck(url: string | null) { + const [data, setData] = useState<{ ok: boolean; ms: number; status?: number } | null>(null); + const [isLoading, setLoading] = useState(false); + + const run = useCallback(async () => { + if (!url) return; + setLoading(true); + setData(null); + const start = performance.now(); + try { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 8000); + const res = await fetch(url, { method: 'GET', mode: 'no-cors', signal: ctrl.signal }); + clearTimeout(timer); + const ms = Math.round(performance.now() - start); + setData({ ok: true, ms, status: res.status || 200 }); + } catch { + const ms = Math.round(performance.now() - start); + setData({ ok: false, ms }); + } finally { + setLoading(false); + } + }, [url]); + + return { data, isLoading, run }; +} + +export function useCloudflareCheck() { + const [ip, setIp] = useState(null); + const [isClean, setClean] = useState(null); + const [isLoading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + (async () => { + setLoading(true); + try { + const res = await fetch('https://cloudflare.com/cdn-cgi/trace', { cache: 'no-store' }); + const text = await res.text(); + const map = Object.fromEntries( + text + .trim() + .split('\n') + .map((line) => { + const i = line.indexOf('='); + return [line.slice(0, i), line.slice(i + 1)]; + }) + ); + if (!cancelled) { + setIp(map.ip || null); + // Heuristic placeholder: colo present ⇒ CF edge path "clean-ish" + setClean(Boolean(map.colo && map.ip)); + } + } catch { + if (!cancelled) { + setIp(null); + setClean(false); + } + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + return { ip, isClean, isLoading }; +} + +export function useCurrencyHint() { + return { daiToStars: DAI_TO_STARS, label: `1 DAI ≈ ${DAI_TO_STARS} Stars` }; +} diff --git a/frontend/lib/twa/i18n.ts b/frontend/lib/twa/i18n.ts new file mode 100644 index 0000000..9fc6db4 --- /dev/null +++ b/frontend/lib/twa/i18n.ts @@ -0,0 +1,220 @@ +'use client'; + +import { + createContext, + createElement, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react'; + +export type TwaLang = 'fa' | 'en' | 'ru' | 'pt' | 'tr' | 'zh'; + +const RTL: TwaLang[] = ['fa']; + +type Dict = Record; + +const baseEn: Dict = { + home: 'Home', + store: 'Store', + wallet: 'Wallet', + profile: 'Profile', + proxies: 'Proxies', + protocols: 'Protocols', + admin: 'Admin', + userMode: 'User mode', + adminMode: 'Admin mode', + invalidInvite: 'Invalid invite', + invalidInviteDesc: 'Open the Mini App from a manager invite link (?ref=…).', + remainingVolume: 'Remaining volume', + expiry: 'Expiry', + quickActions: 'Quick actions', + generateSub: 'Generate Sub', + buyServer: 'Buy server', + cleanIp: 'Clean IP checker', + checking: 'Checking…', + cleanFound: 'Clean IP found', + externalIp: 'External IP', + testPing: 'Test ping', + inviteLinks: 'My invite links', + copyInvite: 'Copy invite', + language: 'Language', + activeProtocols: 'Active protocols', + subordinates: 'Subordinates profit', + serverHealth: 'Server health', + revenue: 'Revenue', + createProtocol: 'Create protocol', + runTest: 'Run test', + pricing: 'Pricing (DAI)', + sponsorProfit: 'Sponsor profit %', + loading: 'Loading…', + search: 'Search…', + noData: 'No data', + scopedNote: 'All data is scoped to this manager invite.', +}; + +const dict: Record = { + en: baseEn, + fa: { + ...baseEn, + home: 'خانه', + store: 'فروشگاه', + wallet: 'کیف پول', + profile: 'پروفایل', + proxies: 'پروکسی‌ها', + protocols: 'پروتکل‌ها', + admin: 'مدیریت', + userMode: 'حالت کاربر', + adminMode: 'حالت ادمین', + invalidInvite: 'لینک دعوت نامعتبر', + invalidInviteDesc: 'مینی‌اپ را فقط از لینک دعوت مدیر باز کنید (?ref=…).', + remainingVolume: 'حجم باقی‌مانده', + expiry: 'انقضا', + quickActions: 'اقدام سریع', + generateSub: 'ساخت ساب', + buyServer: 'خرید سرور', + cleanIp: 'بررسی آی‌پی تمیز', + checking: 'در حال بررسی…', + cleanFound: 'آی‌پی تمیز پیدا شد', + externalIp: 'آی‌پی خارجی', + testPing: 'تست پینگ', + inviteLinks: 'لینک‌های دعوت من', + copyInvite: 'کپی دعوت', + language: 'زبان', + activeProtocols: 'پروتکل‌های فعال', + subordinates: 'سود زیرمجموعه', + serverHealth: 'سلامت سرور', + revenue: 'درآمد', + createProtocol: 'ساخت پروتکل', + runTest: 'اجرای تست', + pricing: 'قیمت (DAI)', + sponsorProfit: 'سود اسپانسر ٪', + loading: 'در حال بارگذاری…', + search: 'جستجو…', + noData: 'داده‌ای نیست', + scopedNote: 'همه داده‌ها فقط در محدودهٔ همین دعوت‌کننده است.', + }, + ru: { + ...baseEn, + home: 'Главная', + store: 'Магазин', + wallet: 'Кошелёк', + profile: 'Профиль', + invalidInvite: 'Недействительное приглашение', + remainingVolume: 'Остаток трафика', + generateSub: 'Создать Sub', + cleanIp: 'Чистый IP', + language: 'Язык', + protocols: 'Протоколы', + scopedNote: 'Все данные ограничены этим менеджером.', + }, + pt: { + ...baseEn, + home: 'Início', + store: 'Loja', + wallet: 'Carteira', + profile: 'Perfil', + invalidInvite: 'Convite inválido', + remainingVolume: 'Volume restante', + generateSub: 'Gerar Sub', + language: 'Idioma', + protocols: 'Protocolos', + scopedNote: 'Todos os dados são do gestor do convite.', + }, + tr: { + ...baseEn, + home: 'Ana sayfa', + store: 'Mağaza', + wallet: 'Cüzdan', + profile: 'Profil', + invalidInvite: 'Geçersiz davet', + remainingVolume: 'Kalan kota', + generateSub: 'Sub oluştur', + language: 'Dil', + protocols: 'Protokollar', + scopedNote: 'Tüm veriler bu davet yöneticisine aittir.', + }, + zh: { + ...baseEn, + home: '首页', + store: '商店', + wallet: '钱包', + profile: '我的', + invalidInvite: '邀请无效', + remainingVolume: '剩余流量', + generateSub: '生成订阅', + language: '语言', + protocols: '协议', + scopedNote: '所有数据均限定于该邀请管理员。', + }, +}; + +type Ctx = { + lang: TwaLang; + setLang: (l: TwaLang) => void; + t: (key: string) => string; + dir: 'rtl' | 'ltr'; +}; + +const TwaI18nContext = createContext(null); + +export function TwaI18nProvider({ children }: { children: ReactNode }) { + const [lang, setLangState] = useState('fa'); + + useEffect(() => { + try { + const saved = localStorage.getItem('xraymod-twa-lang') as TwaLang | null; + if (saved && dict[saved]) setLangState(saved); + } catch { + /* ignore */ + } + }, []); + + const setLang = useCallback((l: TwaLang) => { + setLangState(l); + try { + localStorage.setItem('xraymod-twa-lang', l); + } catch { + /* ignore */ + } + if (typeof document !== 'undefined') { + document.documentElement.lang = l === 'zh' ? 'zh-CN' : l; + document.documentElement.dir = RTL.includes(l) ? 'rtl' : 'ltr'; + } + }, []); + + useEffect(() => { + document.documentElement.lang = lang === 'zh' ? 'zh-CN' : lang; + document.documentElement.dir = RTL.includes(lang) ? 'rtl' : 'ltr'; + }, [lang]); + + const t = useCallback( + (key: string) => dict[lang][key] || dict.en[key] || key, + [lang] + ); + + const value = useMemo( + () => ({ lang, setLang, t, dir: (RTL.includes(lang) ? 'rtl' : 'ltr') as 'rtl' | 'ltr' }), + [lang, setLang, t] + ); + + return createElement(TwaI18nContext.Provider, { value }, children); +} + +export function useTwaI18n() { + const ctx = useContext(TwaI18nContext); + if (!ctx) throw new Error('useTwaI18n outside provider'); + return ctx; +} + +export const TWA_LANG_OPTIONS: { id: TwaLang; label: string }[] = [ + { id: 'fa', label: 'فارسی' }, + { id: 'en', label: 'English' }, + { id: 'ru', label: 'Русский' }, + { id: 'pt', label: 'Português' }, + { id: 'tr', label: 'Türkçe' }, + { id: 'zh', label: '中文' }, +]; diff --git a/frontend/lib/twa/manager-context.tsx b/frontend/lib/twa/manager-context.tsx new file mode 100644 index 0000000..d22d53a --- /dev/null +++ b/frontend/lib/twa/manager-context.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { + createContext, + createElement, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react'; +import type { Manager, Protocol, ProxyItem, ServerNode, Transaction } from './types'; +import { + MOCK_MANAGERS, + MOCK_PROTOCOLS, + MOCK_PROXIES, + MOCK_SERVERS, + MOCK_TX, +} from './mock'; + +type Mode = 'user' | 'admin'; + +type ManagerContextValue = { + managerId: string | null; + manager: Manager | null; + isLoading: boolean; + error: 'missing' | 'invalid' | null; + mode: Mode; + setMode: (m: Mode) => void; + protocols: Protocol[]; + proxies: ProxyItem[]; + servers: ServerNode[]; + transactions: Transaction[]; + inviteUrl: string; + refreshFromUrl: () => void; +}; + +const ManagerContext = createContext(null); + +function parseManagerId(): string | null { + if (typeof window === 'undefined') return null; + + const qs = new URLSearchParams(window.location.search); + const fromQuery = qs.get('ref') || qs.get('managerId') || qs.get('startapp'); + if (fromQuery) return fromQuery.trim(); + + // Telegram Mini App start_param / startapp + try { + const tg = (window as unknown as { Telegram?: { WebApp?: { initDataUnsafe?: { start_param?: string } } } }) + .Telegram?.WebApp?.initDataUnsafe?.start_param; + if (tg) return String(tg).trim(); + } catch { + /* ignore */ + } + + // Hash deep link: #ref=owner_demo + const hash = window.location.hash.replace(/^#/, ''); + if (hash.startsWith('ref=')) return hash.slice(4).trim(); + + // Dev convenience: allow demo without query when on /twa paths + if (process.env.NODE_ENV === 'development' && window.location.pathname.includes('/twa')) { + return 'owner_demo'; + } + + return null; +} + +function expandTelegram() { + try { + const wa = ( + window as unknown as { + Telegram?: { WebApp?: { ready?: () => void; expand?: () => void } }; + } + ).Telegram?.WebApp; + wa?.ready?.(); + wa?.expand?.(); + } catch { + /* browser preview */ + } +} + +export function ManagerProvider({ children }: { children: ReactNode }) { + const [managerId, setManagerId] = useState(null); + const [isLoading, setLoading] = useState(true); + const [error, setError] = useState<'missing' | 'invalid' | null>(null); + const [mode, setMode] = useState('user'); + + const refreshFromUrl = useCallback(() => { + setLoading(true); + const id = parseManagerId(); + setManagerId(id); + if (!id) { + setError('missing'); + } else if (!MOCK_MANAGERS[id]) { + setError('invalid'); + } else { + setError(null); + } + setLoading(false); + }, []); + + useEffect(() => { + expandTelegram(); + refreshFromUrl(); + }, [refreshFromUrl]); + + const manager = managerId ? MOCK_MANAGERS[managerId] || null : null; + + const scoped = useMemo(() => { + if (!managerId) { + return { protocols: [], proxies: [], servers: [], transactions: [] }; + } + return { + protocols: MOCK_PROTOCOLS.filter((p) => p.managerId === managerId), + proxies: MOCK_PROXIES.filter((p) => p.managerId === managerId), + servers: MOCK_SERVERS.filter((s) => s.managerId === managerId), + transactions: MOCK_TX.filter((t) => t.managerId === managerId), + }; + }, [managerId]); + + const inviteUrl = useMemo(() => { + if (typeof window === 'undefined' || !manager) return ''; + const base = `${window.location.origin}${window.location.pathname.split('/twa')[0]}/twa/user`; + return `${base}${manager.invitePath}`; + }, [manager]); + + const value = useMemo( + () => ({ + managerId, + manager, + isLoading, + error, + mode, + setMode, + ...scoped, + inviteUrl, + refreshFromUrl, + }), + [managerId, manager, isLoading, error, mode, scoped, inviteUrl, refreshFromUrl] + ); + + return createElement(ManagerContext.Provider, { value }, children); +} + +export function useManager() { + const ctx = useContext(ManagerContext); + if (!ctx) throw new Error('useManager must be used within ManagerProvider'); + return ctx; +} diff --git a/frontend/lib/twa/mock.ts b/frontend/lib/twa/mock.ts new file mode 100644 index 0000000..5c6ae5a --- /dev/null +++ b/frontend/lib/twa/mock.ts @@ -0,0 +1,147 @@ +import type { + Manager, + Protocol, + ProxyItem, + ServerNode, + Transaction, + UserSubscription, + WalletBalances, +} from './types'; + +export const MOCK_MANAGERS: Record = { + owner_demo: { + id: 'owner_demo', + role: 'owner', + name: 'Niroomand Edge', + welcomeText: 'به پنل اختصاصی خوش آمدید — پروتکل‌ها و قیمت‌ها فقط از این مدیر.', + invitePath: '?ref=owner_demo', + sponsorProfitPct: 20, + }, + sponsor_demo: { + id: 'sponsor_demo', + role: 'sponsor', + name: 'Pakrohk Nodes', + welcomeText: 'Welcome — servers & store scoped to this sponsor invite.', + invitePath: '?ref=sponsor_demo', + sponsorProfitPct: 15, + }, +}; + +export const MOCK_PROTOCOLS: Protocol[] = [ + { + id: 'p1', + managerId: 'owner_demo', + name: 'VLESS-WS-443', + port: 443, + status: 'active', + pingMs: 42, + assignedSite: 'cdn.example.com', + encryption: 'none', + priceDai: 2.5, + }, + { + id: 'p2', + managerId: 'owner_demo', + name: 'Trojan-TLS', + port: 8443, + status: 'active', + pingMs: 58, + assignedSite: 'edge.example.com', + encryption: 'tls', + priceDai: 3.0, + }, + { + id: 'p3', + managerId: 'sponsor_demo', + name: 'VLESS-XHTTP', + port: 443, + status: 'paused', + pingMs: 91, + encryption: 'reality', + priceDai: 1.8, + }, +]; + +export const MOCK_PROXIES: ProxyItem[] = [ + { + id: 'x1', + managerId: 'owner_demo', + name: 'DE-FRA-01', + host: 'fra1.example.com', + port: 443, + protocol: 'vless', + location: 'Frankfurt', + pingMs: 38, + }, + { + id: 'x2', + managerId: 'owner_demo', + name: 'NL-AMS-02', + host: 'ams2.example.com', + port: 443, + protocol: 'trojan', + location: 'Amsterdam', + pingMs: 45, + }, + { + id: 'x3', + managerId: 'sponsor_demo', + name: 'TR-IST-01', + host: 'ist1.example.com', + port: 8443, + protocol: 'vless', + location: 'Istanbul', + pingMs: 62, + }, +]; + +export const MOCK_SERVERS: ServerNode[] = [ + { + id: 's1', + managerId: 'sponsor_demo', + ip: '185.18.10.22', + location: 'Helsinki', + cpuLoad: 0.42, + ramLoad: 0.61, + activeConnections: 128, + domains: [ + { domain: 'a.example.com', dnsOk: true }, + { domain: 'b.example.com', dnsOk: false }, + ], + }, +]; + +export const MOCK_TX: Transaction[] = [ + { + id: 't1', + managerId: 'owner_demo', + date: '2026-08-08', + amount: 12, + currency: 'DAI', + type: 'purchase', + status: 'ok', + }, + { + id: 't2', + managerId: 'owner_demo', + date: '2026-08-07', + amount: 500, + currency: 'STARS', + type: 'topup', + status: 'ok', + }, +]; + +export const MOCK_USER_SUB: UserSubscription = { + remainingGb: 42.5, + totalGb: 100, + expiresAt: '2026-09-15', +}; + +export const MOCK_WALLET: WalletBalances = { + stars: 1200, + gram: 3.4, + dai: 18.75, +}; + +export const DAI_TO_STARS = 85; diff --git a/frontend/lib/twa/types.ts b/frontend/lib/twa/types.ts new file mode 100644 index 0000000..cdbaa50 --- /dev/null +++ b/frontend/lib/twa/types.ts @@ -0,0 +1,71 @@ +/** Shared domain types for the Telegram Mini App (manager-scoped). */ + +export type ManagerRole = 'owner' | 'sponsor'; + +export type Currency = 'DAI' | 'GRAM' | 'STARS'; + +export interface Manager { + id: string; + role: ManagerRole; + name: string; + logoUrl?: string; + welcomeText: string; + invitePath: string; + sponsorProfitPct?: number; +} + +export interface Protocol { + id: string; + managerId: string; + name: string; + port: number; + status: 'active' | 'paused' | 'error'; + pingMs?: number; + assignedSite?: string; + encryption: string; + priceDai: number; +} + +export interface ProxyItem { + id: string; + managerId: string; + name: string; + host: string; + port: number; + protocol: string; + location: string; + pingMs?: number; +} + +export interface ServerNode { + id: string; + managerId: string; + ip: string; + location: string; + cpuLoad: number; + ramLoad: number; + activeConnections: number; + domains: { domain: string; dnsOk: boolean }[]; +} + +export interface Transaction { + id: string; + managerId: string; + date: string; + amount: number; + currency: Currency; + type: 'topup' | 'withdraw' | 'purchase' | 'profit'; + status: 'ok' | 'pending' | 'failed'; +} + +export interface UserSubscription { + remainingGb: number; + totalGb: number; + expiresAt: string; +} + +export interface WalletBalances { + stars: number; + gram: number; + dai: number; +} diff --git a/frontend/locales/en.json b/frontend/locales/en.json new file mode 100644 index 0000000..b8341d2 --- /dev/null +++ b/frontend/locales/en.json @@ -0,0 +1,38 @@ +{ + "home": "Home", + "store": "Store", + "wallet": "Wallet", + "profile": "Profile", + "proxies": "Proxies", + "protocols": "Protocols", + "admin": "Admin", + "userMode": "User mode", + "adminMode": "Admin mode", + "invalidInvite": "Invalid invite", + "invalidInviteDesc": "Open the Mini App from a manager invite link (?ref=…).", + "remainingVolume": "Remaining volume", + "expiry": "Expiry", + "quickActions": "Quick actions", + "generateSub": "Generate Sub", + "buyServer": "Buy server", + "cleanIp": "Clean IP checker", + "checking": "Checking…", + "cleanFound": "Clean IP found", + "externalIp": "External IP", + "testPing": "Test ping", + "inviteLinks": "My invite links", + "copyInvite": "Copy invite", + "language": "Language", + "activeProtocols": "Active protocols", + "subordinates": "Subordinates profit", + "serverHealth": "Server health", + "revenue": "Revenue", + "createProtocol": "Create protocol", + "runTest": "Run test", + "pricing": "Pricing (DAI)", + "sponsorProfit": "Sponsor profit %", + "loading": "Loading…", + "search": "Search…", + "noData": "No data", + "scopedNote": "All data is scoped to this manager invite." +} diff --git a/frontend/locales/fa.json b/frontend/locales/fa.json new file mode 100644 index 0000000..6e89ec9 --- /dev/null +++ b/frontend/locales/fa.json @@ -0,0 +1,38 @@ +{ + "home": "خانه", + "store": "فروشگاه", + "wallet": "کیف پول", + "profile": "پروفایل", + "proxies": "پروکسی‌ها", + "protocols": "پروتکل‌ها", + "admin": "مدیریت", + "userMode": "حالت کاربر", + "adminMode": "حالت ادمین", + "invalidInvite": "لینک دعوت نامعتبر", + "invalidInviteDesc": "مینی‌اپ را فقط از لینک دعوت مدیر باز کنید (?ref=…).", + "remainingVolume": "حجم باقی‌مانده", + "expiry": "انقضا", + "quickActions": "اقدام سریع", + "generateSub": "ساخت ساب", + "buyServer": "خرید سرور", + "cleanIp": "بررسی آی‌پی تمیز", + "checking": "در حال بررسی…", + "cleanFound": "آی‌پی تمیز پیدا شد", + "externalIp": "آی‌پی خارجی", + "testPing": "تست پینگ", + "inviteLinks": "لینک‌های دعوت من", + "copyInvite": "کپی دعوت", + "language": "زبان", + "activeProtocols": "پروتکل‌های فعال", + "subordinates": "سود زیرمجموعه", + "serverHealth": "سلامت سرور", + "revenue": "درآمد", + "createProtocol": "ساخت پروتکل", + "runTest": "اجرای تست", + "pricing": "قیمت (DAI)", + "sponsorProfit": "سود اسپانسر ٪", + "loading": "در حال بارگذاری…", + "search": "جستجو…", + "noData": "داده‌ای نیست", + "scopedNote": "همه داده‌ها فقط در محدودهٔ همین دعوت‌کننده است." +} diff --git a/frontend/locales/pt.json b/frontend/locales/pt.json new file mode 100644 index 0000000..b8341d2 --- /dev/null +++ b/frontend/locales/pt.json @@ -0,0 +1,38 @@ +{ + "home": "Home", + "store": "Store", + "wallet": "Wallet", + "profile": "Profile", + "proxies": "Proxies", + "protocols": "Protocols", + "admin": "Admin", + "userMode": "User mode", + "adminMode": "Admin mode", + "invalidInvite": "Invalid invite", + "invalidInviteDesc": "Open the Mini App from a manager invite link (?ref=…).", + "remainingVolume": "Remaining volume", + "expiry": "Expiry", + "quickActions": "Quick actions", + "generateSub": "Generate Sub", + "buyServer": "Buy server", + "cleanIp": "Clean IP checker", + "checking": "Checking…", + "cleanFound": "Clean IP found", + "externalIp": "External IP", + "testPing": "Test ping", + "inviteLinks": "My invite links", + "copyInvite": "Copy invite", + "language": "Language", + "activeProtocols": "Active protocols", + "subordinates": "Subordinates profit", + "serverHealth": "Server health", + "revenue": "Revenue", + "createProtocol": "Create protocol", + "runTest": "Run test", + "pricing": "Pricing (DAI)", + "sponsorProfit": "Sponsor profit %", + "loading": "Loading…", + "search": "Search…", + "noData": "No data", + "scopedNote": "All data is scoped to this manager invite." +} diff --git a/frontend/locales/ru.json b/frontend/locales/ru.json new file mode 100644 index 0000000..b8341d2 --- /dev/null +++ b/frontend/locales/ru.json @@ -0,0 +1,38 @@ +{ + "home": "Home", + "store": "Store", + "wallet": "Wallet", + "profile": "Profile", + "proxies": "Proxies", + "protocols": "Protocols", + "admin": "Admin", + "userMode": "User mode", + "adminMode": "Admin mode", + "invalidInvite": "Invalid invite", + "invalidInviteDesc": "Open the Mini App from a manager invite link (?ref=…).", + "remainingVolume": "Remaining volume", + "expiry": "Expiry", + "quickActions": "Quick actions", + "generateSub": "Generate Sub", + "buyServer": "Buy server", + "cleanIp": "Clean IP checker", + "checking": "Checking…", + "cleanFound": "Clean IP found", + "externalIp": "External IP", + "testPing": "Test ping", + "inviteLinks": "My invite links", + "copyInvite": "Copy invite", + "language": "Language", + "activeProtocols": "Active protocols", + "subordinates": "Subordinates profit", + "serverHealth": "Server health", + "revenue": "Revenue", + "createProtocol": "Create protocol", + "runTest": "Run test", + "pricing": "Pricing (DAI)", + "sponsorProfit": "Sponsor profit %", + "loading": "Loading…", + "search": "Search…", + "noData": "No data", + "scopedNote": "All data is scoped to this manager invite." +} diff --git a/frontend/locales/tr.json b/frontend/locales/tr.json new file mode 100644 index 0000000..b8341d2 --- /dev/null +++ b/frontend/locales/tr.json @@ -0,0 +1,38 @@ +{ + "home": "Home", + "store": "Store", + "wallet": "Wallet", + "profile": "Profile", + "proxies": "Proxies", + "protocols": "Protocols", + "admin": "Admin", + "userMode": "User mode", + "adminMode": "Admin mode", + "invalidInvite": "Invalid invite", + "invalidInviteDesc": "Open the Mini App from a manager invite link (?ref=…).", + "remainingVolume": "Remaining volume", + "expiry": "Expiry", + "quickActions": "Quick actions", + "generateSub": "Generate Sub", + "buyServer": "Buy server", + "cleanIp": "Clean IP checker", + "checking": "Checking…", + "cleanFound": "Clean IP found", + "externalIp": "External IP", + "testPing": "Test ping", + "inviteLinks": "My invite links", + "copyInvite": "Copy invite", + "language": "Language", + "activeProtocols": "Active protocols", + "subordinates": "Subordinates profit", + "serverHealth": "Server health", + "revenue": "Revenue", + "createProtocol": "Create protocol", + "runTest": "Run test", + "pricing": "Pricing (DAI)", + "sponsorProfit": "Sponsor profit %", + "loading": "Loading…", + "search": "Search…", + "noData": "No data", + "scopedNote": "All data is scoped to this manager invite." +} diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json new file mode 100644 index 0000000..b8341d2 --- /dev/null +++ b/frontend/locales/zh.json @@ -0,0 +1,38 @@ +{ + "home": "Home", + "store": "Store", + "wallet": "Wallet", + "profile": "Profile", + "proxies": "Proxies", + "protocols": "Protocols", + "admin": "Admin", + "userMode": "User mode", + "adminMode": "Admin mode", + "invalidInvite": "Invalid invite", + "invalidInviteDesc": "Open the Mini App from a manager invite link (?ref=…).", + "remainingVolume": "Remaining volume", + "expiry": "Expiry", + "quickActions": "Quick actions", + "generateSub": "Generate Sub", + "buyServer": "Buy server", + "cleanIp": "Clean IP checker", + "checking": "Checking…", + "cleanFound": "Clean IP found", + "externalIp": "External IP", + "testPing": "Test ping", + "inviteLinks": "My invite links", + "copyInvite": "Copy invite", + "language": "Language", + "activeProtocols": "Active protocols", + "subordinates": "Subordinates profit", + "serverHealth": "Server health", + "revenue": "Revenue", + "createProtocol": "Create protocol", + "runTest": "Run test", + "pricing": "Pricing (DAI)", + "sponsorProfit": "Sponsor profit %", + "loading": "Loading…", + "search": "Search…", + "noData": "No data", + "scopedNote": "All data is scoped to this manager invite." +} From 0fcf46fd09834b7fc892bc6107cfa17b02cfc927 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D9=85=D9=87=D9=86=D8=AF=D8=B3?= Date: Sun, 9 Aug 2026 04:20:26 +0330 Subject: [PATCH 02/18] feat: country clean IPs, recommended sub, self-update, login polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add DE/NL/FI/TR-weighted clean-IP pools with larger scan limits, one-click recommended subscription, in-panel GitHub→Cloudflare self-update with live steps, and clearer Persian login UX. Co-authored-by: Cursor --- .github/workflows/rolling-bundle.yml | 23 ++ .gitignore | 2 + frontend/app/login/page.tsx | 73 +++-- frontend/app/panel/admin/page.tsx | 188 ++++++++++-- frontend/app/panel/cleanip/page.tsx | 210 ++++++++++--- frontend/app/panel/page.tsx | 36 +++ scripts/deploy-panel.sh | 58 ++++ scripts/publish-rolling.sh | 82 +++++ worker/api/admin.ts | 157 ++++++++-- worker/api/cleanip.ts | 204 ++++++++++++- worker/lib/cleanip-pool.ts | 232 ++++++++++++++ worker/lib/links.ts | 34 ++- worker/lib/self-update.ts | 439 +++++++++++++++++++++++++++ worker/panel-login.ts | 10 +- worker/subscription.ts | 6 +- worker/utils.ts | 20 +- 16 files changed, 1619 insertions(+), 155 deletions(-) create mode 100644 .github/workflows/rolling-bundle.yml create mode 100755 scripts/deploy-panel.sh create mode 100755 scripts/publish-rolling.sh create mode 100644 worker/lib/cleanip-pool.ts create mode 100644 worker/lib/self-update.ts diff --git a/.github/workflows/rolling-bundle.yml b/.github/workflows/rolling-bundle.yml new file mode 100644 index 0000000..d9b7a3e --- /dev/null +++ b/.github/workflows/rolling-bundle.yml @@ -0,0 +1,23 @@ +name: Rolling bundle + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: write + +jobs: + bundle: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + - name: Build & publish rolling + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash scripts/publish-rolling.sh diff --git a/.gitignore b/.gitignore index 4fab840..ce2e702 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ frontend/.next/ frontend/tsconfig.tsbuildinfo .wrangler/ .wrangler-dist/ +.wrangler-deploy.*.toml +.rolling/ dist-worker/ worker.js *.map diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index 1bc9f3c..86ac018 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -50,7 +50,12 @@ export default function LoginPage() { /* ignore */ } } - goPanel('/panel'); + // Prefer server panel path if provided + const panelPath = + typeof data.panelPath === 'string' && data.panelPath + ? data.panelPath + : '/panel'; + goPanel(panelPath.startsWith('/') ? panelPath : `/${panelPath}`); return; } @@ -63,18 +68,24 @@ export default function LoginPage() { }; return ( -
-
+
+
-
-
+
+
-

- XrayMOD +

+ XrayMOD

-

- {require2fa ? 'تأیید دو مرحله‌ای' : 'ورود امن به کنترل‌پلین'} +

+ {require2fa ? 'تأیید دو مرحله‌ای' : 'ورود امن به داشبورد'}

@@ -82,42 +93,45 @@ export default function LoginPage() { {!require2fa && ( <>
-
-