Browse Source

feat(sub): redesign the subscription page around usage, tabs and app imports

The info page was a long key/value table followed by every link and two
app dropdowns, and it rendered left-to-right even for Persian and Arabic.
It now leads with a usage ring, the remaining quota and a stats grid, and
splits the rest into Subscription / Apps / Configs tabs.

- Status tells expired, data-used-up and disabled apart instead of one
  "Inactive", replacing the hard-coded English expiry chip.
- The Apps tab keeps every Android and iOS app with its existing deep
  link, preselects the visitor's platform and adds Windows: Hiddify and
  Clash Verge Rev import directly, v2rayN copies the link.
- fa-IR and ar-EG render right-to-left; URLs, IDs and sizes stay LTR.
- The footer shows the support link and the client refresh interval, so
  subPageContext now carries subUpdates (also in ?format=info).
- Status, days-left and app deep-link logic lives in subPageModel.ts,
  with unit tests pinning the deep links the page already shipped.
Sanaei 10 hours ago
parent
commit
e26cf1d3ed
42 changed files with 1850 additions and 858 deletions
  1. 2 0
      frontend/src/env.d.ts
  2. 1 1
      frontend/src/pages/inbounds/qr/QrPanel.css
  3. 1 1
      frontend/src/pages/inbounds/qr/QrPanel.tsx
  4. 60 0
      frontend/src/pages/sub/SubAppsTab.tsx
  5. 80 0
      frontend/src/pages/sub/SubConfigsTab.tsx
  6. 112 0
      frontend/src/pages/sub/SubHeader.tsx
  7. 127 0
      frontend/src/pages/sub/SubHero.tsx
  8. 87 0
      frontend/src/pages/sub/SubLinksTab.tsx
  9. 650 57
      frontend/src/pages/sub/SubPage.css
  10. 161 584
      frontend/src/pages/sub/SubPage.tsx
  11. 69 0
      frontend/src/pages/sub/SubQrButton.tsx
  12. 0 87
      frontend/src/pages/sub/SubUsageSummary.css
  13. 0 96
      frontend/src/pages/sub/SubUsageSummary.tsx
  14. 25 0
      frontend/src/pages/sub/app-icons/README.md
  15. 1 0
      frontend/src/pages/sub/app-icons/happ.svg
  16. BIN
      frontend/src/pages/sub/app-icons/incy.webp
  17. BIN
      frontend/src/pages/sub/app-icons/shadowrocket.webp
  18. 1 0
      frontend/src/pages/sub/app-icons/sing-box.svg
  19. BIN
      frontend/src/pages/sub/app-icons/streisand.webp
  20. BIN
      frontend/src/pages/sub/app-icons/v2box.webp
  21. 1 0
      frontend/src/pages/sub/app-icons/v2rayng.svg
  22. 1 0
      frontend/src/pages/sub/app-icons/v2raytun.svg
  23. 20 0
      frontend/src/pages/sub/appIcons.ts
  24. 93 0
      frontend/src/pages/sub/subPageModel.ts
  25. 4 4
      frontend/src/test/qr-panel-readable.test.tsx
  26. 28 0
      frontend/src/test/sub-app-icons.test.ts
  27. 135 0
      frontend/src/test/sub-page-model.test.ts
  28. 4 0
      internal/sub/controller.go
  29. 3 0
      internal/sub/info_endpoint_test.go
  30. 14 2
      internal/web/translation/ar-EG.json
  31. 15 3
      internal/web/translation/en-US.json
  32. 14 2
      internal/web/translation/es-ES.json
  33. 15 3
      internal/web/translation/fa-IR.json
  34. 14 2
      internal/web/translation/id-ID.json
  35. 14 2
      internal/web/translation/ja-JP.json
  36. 14 2
      internal/web/translation/pt-BR.json
  37. 14 2
      internal/web/translation/ru-RU.json
  38. 14 2
      internal/web/translation/tr-TR.json
  39. 14 2
      internal/web/translation/uk-UA.json
  40. 14 2
      internal/web/translation/vi-VN.json
  41. 14 2
      internal/web/translation/zh-CN.json
  42. 14 2
      internal/web/translation/zh-TW.json

+ 2 - 0
frontend/src/env.d.ts

@@ -15,6 +15,8 @@ interface SubPageData {
   subJsonUrl?: string;
   subClashUrl?: string;
   subTitle?: string;
+  subSupportUrl?: string;
+  subUpdates?: number;
   links?: string[];
   emails?: string[];
   datepicker?: 'gregorian' | 'jalalian';

+ 1 - 1
frontend/src/pages/inbounds/qr/QrPanel.css

@@ -28,7 +28,7 @@
 .qr-panel-canvas .qr-code {
   cursor: pointer;
   background: #fff;
-  border-radius: 4px;
+  border-radius: 8px;
   line-height: 0;
 }
 

+ 1 - 1
frontend/src/pages/inbounds/qr/QrPanel.tsx

@@ -141,7 +141,7 @@ export default function QrPanel({
               value={value}
               size={size}
               errorLevel="L"
-              marginSize={4}
+              marginSize={2}
               type="svg"
               bordered={false}
               color="#000000"

+ 60 - 0
frontend/src/pages/sub/SubAppsTab.tsx

@@ -0,0 +1,60 @@
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Button, Segmented } from 'antd';
+import { AndroidOutlined, AppleOutlined } from '@ant-design/icons';
+
+import { APP_ICONS } from './appIcons';
+import type { AppPlatform, SubApp } from './subPageModel';
+
+interface SubAppsTabProps {
+  apps: Record<AppPlatform, SubApp[]>;
+  initialPlatform: AppPlatform;
+  onOpen: (url: string) => void;
+}
+
+const PLATFORM_OPTIONS = [
+  { value: 'android' as const, label: 'Android', icon: <AndroidOutlined /> },
+  { value: 'ios' as const, label: 'iOS', icon: <AppleOutlined /> },
+];
+
+function AppIcon({ name }: { name: string }) {
+  const icon = APP_ICONS[name];
+  if (!icon) {
+    return (
+      <span className="sub-app-mark" aria-hidden="true">
+        {name.charAt(0)}
+      </span>
+    );
+  }
+  if (icon.tinted) {
+    const mask = `url("${icon.src}")`;
+    return (
+      <span className="sub-app-mark" aria-hidden="true">
+        <span className="sub-app-glyph" style={{ maskImage: mask, WebkitMaskImage: mask }} />
+      </span>
+    );
+  }
+  return <img className="sub-app-logo" src={icon.src} alt="" width={32} height={32} />;
+}
+
+export default function SubAppsTab({ apps, initialPlatform, onOpen }: SubAppsTabProps) {
+  const { t } = useTranslation();
+  const [platform, setPlatform] = useState<AppPlatform>(initialPlatform);
+
+  return (
+    <div className="sub-apps">
+      <Segmented<AppPlatform> value={platform} onChange={setPlatform} options={PLATFORM_OPTIONS} />
+      <div className="sub-app-grid">
+        {apps[platform].map((app) => (
+          <div key={app.name} className="sub-row">
+            <AppIcon name={app.name} />
+            <span className="sub-app-name">{app.name}</span>
+            <Button type="primary" size="small" onClick={() => onOpen(app.url)}>
+              {t('add')}
+            </Button>
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+}

+ 80 - 0
frontend/src/pages/sub/SubConfigsTab.tsx

@@ -0,0 +1,80 @@
+import { Fragment } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Button, Tag } from 'antd';
+import { CopyOutlined } from '@ant-design/icons';
+
+import ConfigBlock from '@/components/clients/ConfigBlock';
+import {
+  amneziawgConfigFromLink,
+  isPostQuantumLink,
+  wireguardConfigFromLink,
+} from '@/lib/xray/inbound-link';
+import { LinkTags, parseLinkParts } from '@/lib/xray/link-label';
+import SubQrButton from './SubQrButton';
+
+interface SubConfigsTabProps {
+  links: string[];
+  onCopy: (value: string, toast?: string) => void;
+}
+
+export default function SubConfigsTab({ links, onCopy }: SubConfigsTabProps) {
+  const { t } = useTranslation();
+
+  return (
+    <div className="sub-rows">
+      <div className="sub-configs-bar">
+        <Button
+          icon={<CopyOutlined />}
+          onClick={() => onCopy(links.join('\n'), t('subscription.copyAllConfigsCopied'))}
+        >
+          {t('subscription.copyAllConfigs')}
+        </Button>
+      </div>
+      {links.map((link, idx) => {
+        const parts = parseLinkParts(link);
+        const rowTitle = parts?.remark || `Link ${idx + 1}`;
+        const isWireguardLink = link.startsWith('wireguard://') || link.startsWith('wg://');
+        const isAmneziawgLink = link.startsWith('vpn://');
+        return (
+          <Fragment key={link}>
+            <div className="sub-row">
+              {parts ? <LinkTags parts={parts} /> : <Tag className="sub-row-tag">LINK</Tag>}
+              <span className="sub-row-title" dir="auto" title={rowTitle}>
+                {rowTitle}
+              </span>
+              <div className="sub-row-actions">
+                <Button
+                  icon={<CopyOutlined />}
+                  onClick={() => onCopy(link)}
+                  aria-label={t('copy')}
+                  title={t('copy')}
+                />
+                {!isPostQuantumLink(link) && (
+                  <SubQrButton value={link} label={rowTitle} onCopy={onCopy} />
+                )}
+              </div>
+            </div>
+            {isWireguardLink && (
+              <ConfigBlock
+                label={t('pages.clients.wireguardConfig')}
+                text={wireguardConfigFromLink(link, rowTitle)}
+                fileName={`${rowTitle || 'peer'}.conf`}
+                qrRemark={rowTitle}
+                tagColor="cyan"
+              />
+            )}
+            {isAmneziawgLink && (
+              <ConfigBlock
+                label={t('pages.clients.amneziaWgConfig')}
+                text={amneziawgConfigFromLink(link)}
+                fileName={`${rowTitle || 'peer'}.conf`}
+                qrRemark={rowTitle}
+                tagColor="purple"
+              />
+            )}
+          </Fragment>
+        );
+      })}
+    </div>
+  );
+}

+ 112 - 0
frontend/src/pages/sub/SubHeader.tsx

@@ -0,0 +1,112 @@
+import { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Button, Menu, Popover, Space } from 'antd';
+import {
+  MoonFilled,
+  MoonOutlined,
+  SunOutlined,
+  TranslationOutlined,
+  WifiOutlined,
+} from '@ant-design/icons';
+
+import { LanguageManager } from '@/utils';
+import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
+
+interface SubHeaderProps {
+  title: string;
+  sId: string;
+  email: string;
+  lang: string;
+  onLangChange: (lang: string) => void;
+}
+
+export default function SubHeader({ title, sId, email, lang, onLangChange }: SubHeaderProps) {
+  const { t } = useTranslation();
+  const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
+
+  const cycleTheme = () => {
+    pauseAnimationsUntilLeave('sub-theme-cycle');
+    if (!isDark) {
+      toggleTheme();
+      if (isUltra) toggleUltra();
+    } else if (!isUltra) {
+      toggleUltra();
+    } else {
+      toggleUltra();
+      toggleTheme();
+    }
+  };
+
+  const langMenuItems = useMemo(
+    () =>
+      (LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[]).map(
+        (l) => ({
+          key: l.value,
+          label: (
+            <Space size={8}>
+              <span aria-hidden="true">{l.icon}</span>
+              <span>{l.name}</span>
+            </Space>
+          ),
+        }),
+      ),
+    [],
+  );
+
+  const themeIcon = !isDark ? <SunOutlined /> : !isUltra ? <MoonOutlined /> : <MoonFilled />;
+  const initial = Array.from(title)[0]?.toUpperCase();
+
+  return (
+    <header className="sub-header">
+      <div className="sub-brand">
+        <span className="sub-brand-mark" aria-hidden="true">
+          {initial ?? <WifiOutlined />}
+        </span>
+        <div className="sub-brand-text">
+          <div className="sub-brand-title" dir="auto">
+            {title || t('subscription.title')}
+          </div>
+          <div className="sub-brand-id">
+            <bdi>{email ? `${sId} - ${email}` : sId}</bdi>
+          </div>
+        </div>
+      </div>
+      <div className="sub-toolbar">
+        <Button
+          id="sub-theme-cycle"
+          shape="circle"
+          size="large"
+          className="toolbar-btn"
+          aria-label={t('menu.theme')}
+          title={t('menu.theme')}
+          icon={themeIcon}
+          onClick={cycleTheme}
+        />
+        <Popover
+          rootClassName={isDark ? 'dark' : 'light'}
+          placement="bottomRight"
+          trigger="click"
+          styles={{ content: { padding: 4 } }}
+          content={
+            <Menu
+              mode="vertical"
+              selectable
+              selectedKeys={[lang]}
+              items={langMenuItems}
+              onClick={({ key }) => onLangChange(key)}
+              style={{ border: 'none', minWidth: 160 }}
+            />
+          }
+        >
+          <Button
+            shape="circle"
+            size="large"
+            className="toolbar-btn"
+            aria-label={t('pages.settings.language')}
+            icon={<TranslationOutlined />}
+          />
+        </Popover>
+      </div>
+    </header>
+  );
+}

+ 127 - 0
frontend/src/pages/sub/SubHero.tsx

@@ -0,0 +1,127 @@
+import type { ReactNode } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Progress, Tag, theme } from 'antd';
+
+import { IntlUtil } from '@/utils';
+import type { CalendarKind } from '@/utils';
+import { usagePercent } from './subPageModel';
+import type { SubStatus } from './subPageModel';
+
+interface SubHeroProps {
+  status: SubStatus;
+  daysLeft: number | null;
+  usedByte: number;
+  totalByte: number;
+  expireMs: number;
+  lastOnlineMs: number;
+  download: string;
+  upload: string;
+  used: string;
+  total: string;
+  remained: string;
+  datepicker: CalendarKind;
+  lang: string;
+}
+
+const STATUS_TAGS: Record<SubStatus, { color: string; label: string }> = {
+  active: { color: 'green', label: 'subscription.active' },
+  unlimited: { color: 'purple', label: 'subscription.unlimited' },
+  expired: { color: 'red', label: 'subscription.expired' },
+  depleted: { color: 'red', label: 'subscription.depleted' },
+  disabled: { color: 'red', label: 'subscription.inactive' },
+};
+
+// FormatTraffic renders "37.60GB"; the amount and unit are sized apart.
+function splitSize(label: string): [string, string] {
+  const match = /^([\d.,]+)\s*(\D*)$/.exec(label.trim());
+  return match ? [match[1], match[2]] : [label, ''];
+}
+
+export default function SubHero({
+  status,
+  daysLeft,
+  usedByte,
+  totalByte,
+  expireMs,
+  lastOnlineMs,
+  download,
+  upload,
+  used,
+  total,
+  remained,
+  datepicker,
+  lang,
+}: SubHeroProps) {
+  const { t } = useTranslation();
+  const { token } = theme.useToken();
+
+  const hasQuota = totalByte > 0;
+  const healthy = status === 'active' || status === 'unlimited';
+  const pct = usagePercent(usedByte, totalByte);
+  const ringColor =
+    !healthy || pct >= 90 ? token.colorError : pct >= 75 ? token.colorWarning : token.colorPrimary;
+  const [amount, unit] = splitSize(hasQuota ? remained : used);
+  const formatDate = (ms: number) => IntlUtil.formatDate(ms, datepicker, lang);
+  const statusTag = STATUS_TAGS[status];
+
+  const stats: { key: string; label: string; value: ReactNode }[] = [
+    { key: 'days', label: t('subscription.daysLeft'), value: daysLeft ?? '∞' },
+    {
+      key: 'expiry',
+      label: t('subscription.expiry'),
+      value: expireMs > 0 ? formatDate(expireMs) : t('subscription.noExpiry'),
+    },
+    {
+      key: 'status',
+      label: t('subscription.status'),
+      value: <Tag color={statusTag.color}>{t(statusTag.label)}</Tag>,
+    },
+    { key: 'down', label: t('subscription.downloaded'), value: <bdi>{download}</bdi> },
+    { key: 'up', label: t('subscription.uploaded'), value: <bdi>{upload}</bdi> },
+    { key: 'total', label: t('subscription.totalQuota'), value: <bdi>{total}</bdi> },
+    {
+      key: 'lastOnline',
+      label: t('lastOnline'),
+      value: lastOnlineMs > 0 ? formatDate(lastOnlineMs) : '-',
+    },
+  ];
+
+  return (
+    <section className={healthy ? 'sub-hero' : 'sub-hero is-alert'}>
+      <Progress
+        type="circle"
+        className="sub-ring"
+        percent={pct}
+        status="normal"
+        size={156}
+        strokeColor={ringColor}
+        format={() => (
+          <span className="sub-ring-center">
+            <span className="sub-ring-value">{hasQuota ? `${pct.toFixed(1)}%` : '∞'}</span>
+            <span className="sub-ring-label">
+              {hasQuota ? t('usage') : t('subscription.unlimited')}
+            </span>
+          </span>
+        )}
+      />
+      <div className="sub-hero-summary">
+        <div className="sub-label">{hasQuota ? t('remained') : t('usage')}</div>
+        <bdi className="sub-big">
+          <span className="sub-big-num">{amount}</span>
+          {unit && <span className="sub-big-unit">{unit}</span>}
+        </bdi>
+        <div className="sub-muted">
+          {hasQuota ? t('subscription.ofTotal', { total }) : t('subscription.unlimited')}
+        </div>
+        <dl className="sub-stats">
+          {stats.map((stat) => (
+            <div key={stat.key} className="sub-stat">
+              <dt className="sub-label">{stat.label}</dt>
+              <dd className="sub-stat-value">{stat.value}</dd>
+            </div>
+          ))}
+        </dl>
+      </div>
+    </section>
+  );
+}

+ 87 - 0
frontend/src/pages/sub/SubLinksTab.tsx

@@ -0,0 +1,87 @@
+import { useTranslation } from 'react-i18next';
+import { Button, QRCode, Tag } from 'antd';
+import { CopyOutlined, DownloadOutlined } from '@ant-design/icons';
+
+import SubQrButton from './SubQrButton';
+
+interface SubLinksTabProps {
+  subUrl: string;
+  subJsonUrl: string;
+  subClashUrl: string;
+  onCopy: (value: string) => void;
+}
+
+const appendRawView = (url: string) => `${url}${url.includes('?') ? '&' : '?'}view=raw`;
+
+export default function SubLinksTab({ subUrl, subJsonUrl, subClashUrl, onCopy }: SubLinksTabProps) {
+  const { t } = useTranslation();
+  const subLabel = t('pages.settings.subSettings');
+  const rows = [
+    { kind: 'SUB', color: 'green', url: subUrl, title: subLabel, downloadable: false },
+    {
+      kind: 'JSON',
+      color: 'purple',
+      url: subJsonUrl,
+      title: `${subLabel} JSON`,
+      downloadable: true,
+    },
+    { kind: 'CLASH', color: 'gold', url: subClashUrl, title: 'Clash / Mihomo', downloadable: true },
+  ].filter((row) => row.url);
+
+  return (
+    <div className="sub-rows">
+      {rows.map((row) => (
+        <div key={row.kind} className="sub-row">
+          <Tag color={row.color} className="sub-row-tag">
+            {row.kind}
+          </Tag>
+          <div className="sub-row-main">
+            <a href={row.url} target="_blank" rel="noopener noreferrer" className="sub-row-title">
+              {row.title}
+            </a>
+            <div className="sub-row-url" dir="ltr" title={row.url}>
+              {row.url}
+            </div>
+          </div>
+          <div className="sub-row-actions">
+            {row.downloadable && (
+              <Button
+                href={appendRawView(row.url)}
+                target="_blank"
+                rel="noopener noreferrer"
+                icon={<DownloadOutlined />}
+                aria-label={t('download')}
+                title={t('download')}
+              />
+            )}
+            <Button
+              icon={<CopyOutlined />}
+              onClick={() => onCopy(row.url)}
+              aria-label={t('copy')}
+              title={t('copy')}
+            />
+            <SubQrButton value={row.url} label={row.title} onCopy={onCopy} />
+          </div>
+        </div>
+      ))}
+      {subUrl && (
+        <div className="sub-qr-card">
+          <div className="sub-qr-code">
+            <QRCode
+              value={subUrl}
+              size={112}
+              type="svg"
+              bordered={false}
+              color="#000000"
+              bgColor="#ffffff"
+            />
+          </div>
+          <div>
+            <div className="sub-qr-title">{t('subscription.scanTitle')}</div>
+            <div className="sub-muted">{t('subscription.scanHint')}</div>
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}

+ 650 - 57
frontend/src/pages/sub/SubPage.css

@@ -1,18 +1,78 @@
 .subscription-page {
-  --bg-page: #e6e8ec;
-  --bg-card: #ffffff;
+  /* --sub-grad-* paints graphics, --sub-ink-* paints text: the cyan end darkens
+     so text clears 4.5:1. --sub-accent is ACCENT.primary in SubPage.tsx. */
+  --sub-grad-from: #8b5cf6;
+  --sub-grad-to: #06b6d4;
+  --sub-ink-from: #6d28d9;
+  --sub-ink-to: #0e7490;
+  --sub-accent: #7c3aed;
+  --bg-page: linear-gradient(135deg, #e9e4ff 0%, #ddeefc 52%, #e2f7f2 100%);
+  --sub-card-bg: rgba(255, 255, 255, 0.72);
+  --sub-card-border: rgba(255, 255, 255, 0.7);
+  --sub-card-shadow: 0 1px 3px rgba(15, 23, 42, 0.05), 0 20px 56px rgba(124, 58, 237, 0.16);
+  --sub-card-sheen: linear-gradient(
+    135deg,
+    rgba(255, 255, 255, 0.75),
+    rgba(255, 255, 255, 0) 42%,
+    rgba(124, 58, 237, 0.22) 88%
+  );
+  --sub-blob-1: rgba(139, 92, 246, 0.55);
+  --sub-blob-2: rgba(6, 182, 212, 0.45);
+  --sub-grid: rgba(124, 58, 237, 0.06);
+  --sub-hairline: linear-gradient(90deg, rgba(139, 92, 246, 0.4), rgba(6, 182, 212, 0.4));
+  --sub-tile-bg: rgba(124, 58, 237, 0.05);
+  --sub-tile-border: rgba(124, 58, 237, 0.12);
+  --sub-row-bg: rgba(124, 58, 237, 0.045);
+  --sub-row-border: rgba(124, 58, 237, 0.11);
+  --sub-row-bg-hover: rgba(124, 58, 237, 0.09);
+  --sub-row-border-hover: rgba(124, 58, 237, 0.3);
+  --sub-row-glow: rgba(124, 58, 237, 0.28);
+  --sub-glass-bg: rgba(255, 255, 255, 0.6);
+
+  position: relative;
   min-height: 100vh;
   background: var(--bg-page);
 }
 
 .subscription-page.is-dark {
-  --bg-page: #1a1b1f;
-  --bg-card: #23252b;
+  --sub-grad-from: #a78bfa;
+  --sub-grad-to: #22d3ee;
+  --sub-ink-from: #c4b5fd;
+  --sub-ink-to: #67e8f9;
+  --sub-accent: #a78bfa;
+  --bg-page: radial-gradient(ellipse 120% 90% at 18% -10%, #1f1740 0%, #16171d 52%, #101116 100%);
+  --sub-card-bg: rgba(35, 37, 43, 0.62);
+  --sub-card-border: rgba(255, 255, 255, 0.08);
+  --sub-card-shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 24px 64px rgba(109, 40, 217, 0.24);
+  --sub-card-sheen: linear-gradient(
+    135deg,
+    rgba(255, 255, 255, 0.16),
+    rgba(255, 255, 255, 0) 42%,
+    rgba(167, 139, 250, 0.4) 88%
+  );
+  --sub-blob-1: rgba(139, 92, 246, 0.4);
+  --sub-blob-2: rgba(34, 211, 238, 0.26);
+  --sub-grid: rgba(255, 255, 255, 0.035);
+  --sub-hairline: linear-gradient(90deg, rgba(167, 139, 250, 0.45), rgba(34, 211, 238, 0.45));
+  --sub-tile-bg: rgba(167, 139, 250, 0.07);
+  --sub-tile-border: rgba(167, 139, 250, 0.14);
+  --sub-row-bg: rgba(167, 139, 250, 0.06);
+  --sub-row-border: rgba(167, 139, 250, 0.12);
+  --sub-row-bg-hover: rgba(167, 139, 250, 0.12);
+  --sub-row-border-hover: rgba(167, 139, 250, 0.35);
+  --sub-row-glow: rgba(139, 92, 246, 0.45);
+  --sub-glass-bg: rgba(255, 255, 255, 0.06);
 }
 
 .subscription-page.is-dark.is-ultra {
-  --bg-page: #000;
-  --bg-card: #101013;
+  --bg-page: radial-gradient(ellipse 120% 90% at 18% -10%, #120a2b 0%, #050509 55%, #000 100%);
+  --sub-card-bg: rgba(16, 16, 19, 0.68);
+  --sub-card-border: rgba(255, 255, 255, 0.055);
+  --sub-card-shadow: 0 1px 3px rgba(0, 0, 0, 0.6), 0 24px 64px rgba(88, 28, 135, 0.3);
+  --sub-blob-1: rgba(139, 92, 246, 0.22);
+  --sub-blob-2: rgba(34, 211, 238, 0.14);
+  --sub-grid: rgba(255, 255, 255, 0.022);
+  --sub-glass-bg: rgba(255, 255, 255, 0.04);
 }
 
 .subscription-page .ant-layout,
@@ -20,112 +80,645 @@
   background: transparent;
 }
 
-.subscription-page .content {
-  padding: 24px 12px;
+/* aurora backdrop */
+.sub-aurora {
+  position: fixed;
+  inset: 0;
+  z-index: 0;
+  overflow: hidden;
+  pointer-events: none;
 }
 
-.subscription-card {
-  margin-top: 8px;
+.sub-aurora-grid {
+  position: absolute;
+  inset: 0;
+  background-image:
+    linear-gradient(var(--sub-grid) 1px, transparent 1px),
+    linear-gradient(90deg, var(--sub-grid) 1px, transparent 1px);
+  background-size: 48px 48px;
+  background-position: center;
+  -webkit-mask-image: radial-gradient(ellipse at 50% 30%, black 20%, transparent 72%);
+  mask-image: radial-gradient(ellipse at 50% 30%, black 20%, transparent 72%);
 }
 
-.qr-tag {
-  width: 100%;
-  text-align: center;
-  margin: 0;
+.sub-aurora::before,
+.sub-aurora::after {
+  content: '';
+  position: absolute;
+  width: 70vmax;
+  height: 70vmax;
+  max-width: 820px;
+  max-height: 820px;
+  border-radius: 50%;
+  filter: blur(80px);
+  will-change: transform;
+}
+
+.sub-aurora::before {
+  top: -22vmax;
+  left: -16vmax;
+  background: radial-gradient(circle, var(--sub-blob-1) 0%, transparent 65%);
+  animation: sub-blob-a 28s ease-in-out infinite alternate;
+}
+
+.sub-aurora::after {
+  bottom: -24vmax;
+  right: -18vmax;
+  background: radial-gradient(circle, var(--sub-blob-2) 0%, transparent 65%);
+  animation: sub-blob-b 34s ease-in-out infinite alternate;
+}
+
+@keyframes sub-blob-a {
+  0% {
+    transform: translate(0, 0) scale(1);
+  }
+  100% {
+    transform: translate(16vw, 14vh) scale(1.18);
+  }
+}
+
+@keyframes sub-blob-b {
+  0% {
+    transform: translate(0, 0) scale(1);
+  }
+  100% {
+    transform: translate(-14vw, -12vh) scale(1.15);
+  }
+}
+
+.sub-content {
+  position: relative;
+  z-index: 1;
+  padding: 32px 16px;
+}
+
+.sub-card {
+  max-width: 880px;
+  margin: 0 auto;
+}
+
+.subscription-page .sub-card {
+  position: relative;
+  border-radius: 20px;
+  border: 1px solid var(--sub-card-border);
+  background: var(--sub-card-bg);
+  box-shadow: var(--sub-card-shadow);
+  -webkit-backdrop-filter: blur(24px) saturate(180%);
+  backdrop-filter: blur(24px) saturate(180%);
+}
+
+/* Hairline gradient rim: a padded sheen layer with its own middle masked out. */
+.subscription-page .sub-card::before {
+  content: '';
+  position: absolute;
+  inset: 0;
+  z-index: 0;
+  border-radius: inherit;
+  padding: 1px;
+  background: var(--sub-card-sheen);
+  -webkit-mask:
+    linear-gradient(#000 0 0) content-box,
+    linear-gradient(#000 0 0);
+  -webkit-mask-composite: xor;
+  mask-composite: exclude;
+  pointer-events: none;
+}
+
+.sub-card > .ant-card-body {
+  position: relative;
+  z-index: 1;
+  padding: 28px;
+}
+
+.sub-label,
+.sub-muted {
+  font-size: 12px;
+  color: var(--ant-color-text-tertiary);
+}
+
+.sub-muted {
+  font-size: 13px;
 }
 
-.info-table {
-  margin-top: 4px;
+/* Gradient hairline shared by the header, the stats grid and the footer. */
+.sub-header::after,
+.sub-stats::before,
+.sub-footer::before {
+  content: '';
+  position: absolute;
+  inset-inline: 0;
+  height: 1px;
+  background: var(--sub-hairline);
+  opacity: 0.7;
 }
 
-.links-section {
+/* header */
+.sub-header {
+  position: relative;
   display: flex;
-  flex-direction: column;
-  gap: 8px;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  padding-bottom: 20px;
+  margin-bottom: 24px;
 }
 
-.sub-link-anchor {
-  color: inherit;
-  text-decoration: none;
+.sub-header::after {
+  bottom: 0;
 }
 
-.sub-link-anchor:hover {
-  text-decoration: underline;
+.sub-brand {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  min-width: 0;
 }
 
-.sub-link-row {
+.sub-brand-mark {
+  width: 44px;
+  height: 44px;
+  flex-shrink: 0;
   display: flex;
   align-items: center;
+  justify-content: center;
+  border-radius: 13px;
+  background: linear-gradient(135deg, var(--sub-grad-from), var(--sub-grad-to));
+  box-shadow: 0 8px 20px -8px var(--sub-row-glow);
+  color: #fff;
+  font-size: 20px;
+  font-weight: 600;
+}
+
+.sub-brand-text {
+  min-width: 0;
+}
+
+.sub-brand-title,
+.sub-brand-id {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.sub-brand-title {
+  font-size: 18px;
+  font-weight: 600;
+  line-height: 1.3;
+  color: var(--ant-color-text);
+}
+
+.sub-brand-id {
+  font-size: 12px;
+  color: var(--ant-color-text-tertiary);
+}
+
+.sub-toolbar {
+  display: flex;
   gap: 8px;
-  padding: 8px 12px;
+  flex-shrink: 0;
+}
+
+.toolbar-btn {
+  width: 40px;
+  height: 40px;
+  min-width: 40px;
+  border-radius: 50%;
+  padding: 0;
+}
+
+.toolbar-btn .anticon {
+  font-size: 18px;
+}
+
+.subscription-page .toolbar-btn {
+  border-color: var(--sub-row-border);
+  background: var(--sub-glass-bg);
+  color: var(--sub-accent);
+  -webkit-backdrop-filter: blur(8px);
+  backdrop-filter: blur(8px);
+}
+
+.subscription-page .toolbar-btn:hover {
+  border-color: var(--sub-row-border-hover);
+  background: var(--sub-row-bg-hover);
+  color: var(--sub-accent);
+}
+
+.sub-announce {
+  margin-bottom: 24px;
+}
+
+/* usage hero */
+.sub-hero {
+  display: grid;
+  grid-template-columns: auto minmax(0, 1fr);
+  gap: 32px;
+  align-items: center;
+}
+
+.sub-ring .ant-progress-text {
+  color: var(--ant-color-text);
+}
+
+.sub-ring-center {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 4px;
+  line-height: 1.1;
+}
+
+.sub-ring-value {
+  font-size: 26px;
+  font-weight: 600;
+  font-variant-numeric: tabular-nums;
+}
+
+.sub-ring-label {
+  font-size: 12px;
+  color: var(--ant-color-text-tertiary);
+}
+
+.sub-big {
+  display: inline-flex;
+  align-items: baseline;
+  gap: 6px;
+  margin: 2px 0;
+  line-height: 1.1;
+}
+
+.sub-big-num {
+  font-size: 44px;
+  font-weight: 700;
+  letter-spacing: -0.02em;
+  font-variant-numeric: tabular-nums;
+  background: linear-gradient(135deg, var(--sub-ink-from), var(--sub-ink-to));
+  -webkit-background-clip: text;
+  background-clip: text;
+  -webkit-text-fill-color: transparent;
+  color: var(--sub-ink-from);
+}
+
+.sub-big-unit {
+  font-size: 18px;
+  color: var(--ant-color-text-secondary);
+}
+
+.sub-hero.is-alert .sub-big-num {
+  background: none;
+  -webkit-text-fill-color: var(--ant-color-error);
+  color: var(--ant-color-error);
+}
+
+.sub-stats {
+  position: relative;
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 10px;
+  margin: 20px 0 0;
+  padding-top: 20px;
+}
+
+.sub-stats::before {
+  top: 0;
+}
+
+.sub-stat {
+  padding: 10px 12px;
+  border-radius: 12px;
+  border: 1px solid var(--sub-tile-border);
+  background: var(--sub-tile-bg);
+}
+
+.sub-stat-value {
+  margin: 2px 0 0;
+  font-size: 14px;
+  font-weight: 600;
+  color: var(--ant-color-text);
+  font-variant-numeric: tabular-nums;
+  overflow-wrap: anywhere;
+}
+
+.sub-stat-value .ant-tag {
+  margin: 0;
+}
+
+/* tabs */
+.sub-tabs {
+  margin-top: 28px;
+}
+
+.sub-tabs.ant-tabs .ant-tabs-ink-bar {
+  height: 3px;
+  border-radius: 2px;
+  background: linear-gradient(90deg, var(--sub-grad-from), var(--sub-grad-to));
+}
+
+.sub-tab-count {
+  margin-inline-start: 6px;
+  padding: 0 7px;
   border-radius: 10px;
-  background: rgba(0, 0, 0, 0.03);
-  border: 1px solid rgba(0, 0, 0, 0.08);
-  transition:
-    background 120ms ease,
-    border-color 120ms ease;
+  font-size: 12px;
+  background: var(--sub-tile-bg);
+  border: 1px solid var(--sub-tile-border);
+  color: var(--sub-accent);
 }
 
-.sub-link-row:hover {
-  background: rgba(0, 0, 0, 0.05);
-  border-color: rgba(0, 0, 0, 0.14);
+.sub-rows {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
 }
 
-.is-dark .sub-link-row {
-  background: rgba(0, 0, 0, 0.2);
-  border-color: rgba(255, 255, 255, 0.1);
+.sub-row {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  min-width: 0;
+  padding: 10px 12px;
+  border-radius: 12px;
+  background: var(--sub-row-bg);
+  border: 1px solid var(--sub-row-border);
+  transition:
+    background 160ms ease,
+    border-color 160ms ease,
+    box-shadow 160ms ease,
+    transform 160ms ease;
 }
 
-.is-dark .sub-link-row:hover {
-  background: rgba(0, 0, 0, 0.3);
-  border-color: rgba(255, 255, 255, 0.2);
+.sub-row:hover {
+  background: var(--sub-row-bg-hover);
+  border-color: var(--sub-row-border-hover);
+  box-shadow: 0 8px 20px -14px var(--sub-row-glow);
+  transform: translateY(-1px);
 }
 
-.sub-link-tag {
+.sub-row-tag {
   margin: 0;
   flex-shrink: 0;
   font-weight: 600;
   letter-spacing: 0.3px;
 }
 
-.sub-link-title {
+.sub-row-main {
+  flex: 1;
+  min-width: 0;
+}
+
+.sub-row-title {
+  display: block;
+  font-size: 14px;
+  color: var(--ant-color-text);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.sub-row > .sub-row-title {
   flex: 1;
   min-width: 0;
   font-size: 13px;
+}
+
+a.sub-row-title:hover {
+  color: var(--sub-accent);
+}
+
+.sub-row-url {
+  font-size: 12px;
+  color: var(--ant-color-text-tertiary);
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
+  text-align: left;
 }
 
-.sub-link-actions {
+[dir='rtl'] .sub-row-url,
+[dir='rtl'] .sub-row > .sub-row-title {
+  text-align: right;
+}
+
+.sub-row-actions {
   display: flex;
   gap: 4px;
   flex-shrink: 0;
 }
 
-.sub-link-qr-popover {
+.sub-qr-modal .ant-modal-title {
+  font-size: 20px;
+  font-weight: 600;
+}
+
+.sub-qr-modal .ant-modal-close {
+  width: 36px;
+  height: 36px;
+  border: 1px solid var(--ant-color-border-secondary);
+  border-radius: 50%;
+}
+
+.sub-qr-modal-hint {
+  margin: 2px 0 20px;
+}
+
+.sub-qr-modal-code {
+  width: fit-content;
+  margin: 0 auto 20px;
+  border-radius: 8px;
+  background: #fff;
+  line-height: 0;
+}
+
+.sub-qr-modal-code canvas {
+  display: block;
+}
+
+.sub-qr-modal-link {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+  font-size: 13px;
+}
+
+.sub-qr-modal-actions {
+  display: flex;
+  gap: 12px;
+  margin-top: 20px;
+}
+
+.sub-qr-modal-actions > .ant-btn:first-child {
+  flex: 1;
+}
+
+.sub-qr-card {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  margin-top: 8px;
+  padding: 16px;
+  border-radius: 14px;
+  border: 1px dashed var(--sub-row-border-hover);
+  background: var(--sub-row-bg);
+}
+
+.sub-qr-code {
+  flex-shrink: 0;
+  padding: 6px;
+  border-radius: 8px;
+  background: #fff;
+  line-height: 0;
+}
+
+.sub-qr-title {
+  margin-bottom: 4px;
+  font-size: 15px;
+  font-weight: 600;
+  color: var(--ant-color-text);
+}
+
+/* apps */
+.sub-apps {
   display: flex;
   flex-direction: column;
+  align-items: flex-start;
+  gap: 16px;
+}
+
+.sub-app-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 8px;
+  width: 100%;
+}
+
+.sub-app-mark {
+  width: 32px;
+  height: 32px;
+  flex-shrink: 0;
+  display: flex;
   align-items: center;
-  gap: 6px;
+  justify-content: center;
+  border-radius: 9px;
+  border: 1px solid var(--sub-tile-border);
+  background: linear-gradient(135deg, var(--sub-row-bg-hover), var(--sub-tile-bg));
+  color: var(--sub-accent);
+  font-weight: 600;
 }
 
-.apps-row {
-  margin-top: 24px;
+.sub-app-glyph {
+  width: 22px;
+  height: 22px;
+  background: currentColor;
+  -webkit-mask-position: center;
+  mask-position: center;
+  -webkit-mask-repeat: no-repeat;
+  mask-repeat: no-repeat;
+  -webkit-mask-size: contain;
+  mask-size: contain;
 }
 
-.app-col {
-  text-align: center;
+.sub-app-logo {
+  width: 32px;
+  height: 32px;
+  flex-shrink: 0;
+  border-radius: 9px;
+  object-fit: cover;
+  box-shadow: 0 0 0 1px var(--sub-tile-border);
 }
 
-.toolbar-btn {
-  width: 40px;
-  height: 40px;
-  min-width: 40px;
-  border-radius: 50%;
-  padding: 0;
+.sub-app-name {
+  flex: 1;
+  min-width: 0;
+  font-size: 14px;
+  color: var(--ant-color-text);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
-.toolbar-btn .anticon {
-  font-size: 18px;
+/* configs */
+.sub-configs-bar {
+  display: flex;
+  justify-content: flex-end;
+  margin-bottom: 4px;
+}
+
+/* footer */
+.sub-footer {
+  position: relative;
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: space-between;
+  gap: 8px 16px;
+  margin-top: 28px;
+  padding-top: 16px;
+  font-size: 12px;
+  color: var(--ant-color-text-tertiary);
+}
+
+.sub-footer::before {
+  top: 0;
+}
+
+.sub-footer > span,
+.sub-footer > a {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .sub-aurora::before,
+  .sub-aurora::after {
+    animation: none;
+  }
+
+  .sub-row:hover {
+    transform: none;
+  }
+}
+
+@media (max-width: 576px) {
+  .sub-content {
+    padding: 16px 8px;
+  }
+
+  .sub-card > .ant-card-body {
+    padding: 16px;
+  }
+
+  /* One static blob: two animated 70vmax blurs drop frames on low-end phones. */
+  .sub-aurora::before {
+    animation: none;
+  }
+
+  .sub-aurora::after {
+    display: none;
+  }
+
+  .sub-hero {
+    grid-template-columns: minmax(0, 1fr);
+    gap: 20px;
+  }
+
+  .sub-ring {
+    justify-self: center;
+  }
+
+  .sub-big-num {
+    font-size: 36px;
+  }
+
+  .sub-stats {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  .sub-app-grid {
+    grid-template-columns: minmax(0, 1fr);
+  }
+
+  .sub-tabs .ant-tabs-tab-icon {
+    display: none;
+  }
+
+  .sub-qr-card {
+    display: none;
+  }
 }

+ 161 - 584
frontend/src/pages/sub/SubPage.tsx

@@ -1,98 +1,89 @@
-import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
+import { Alert, Card, ConfigProvider, Layout, Tabs, message } from 'antd';
+import type { TabsProps } from 'antd';
 import {
-  Alert,
-  Button,
-  Card,
-  Col,
-  ConfigProvider,
-  Descriptions,
-  Divider,
-  Dropdown,
-  Layout,
-  Menu,
-  message,
-  Popover,
-  QRCode,
-  Row,
-  Space,
-  Tag,
-  Tooltip,
-} from 'antd';
-import {
-  AndroidOutlined,
-  AppleOutlined,
-  CopyOutlined,
-  DownOutlined,
-  DownloadOutlined,
-  MoonFilled,
-  MoonOutlined,
-  QrcodeOutlined,
-  SunOutlined,
-  TranslationOutlined,
+  AppstoreOutlined,
+  ClockCircleOutlined,
+  CustomerServiceOutlined,
+  LinkOutlined,
+  UnorderedListOutlined,
 } from '@ant-design/icons';
 
-import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils';
-import {
-  amneziawgConfigFromLink,
-  isPostQuantumLink,
-  wireguardConfigFromLink,
-} from '@/lib/xray/inbound-link';
-import { LinkTags, parseLinkParts } from '@/lib/xray/link-label';
-import ConfigBlock from '@/components/clients/ConfigBlock';
+import { ClipboardManager, LanguageManager } from '@/utils';
 import { setMessageInstance } from '@/utils/messageBus';
-import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
-import { useMediaQuery } from '@/hooks/useMediaQuery';
-import SubUsageSummary from './SubUsageSummary';
+import { useTheme } from '@/hooks/useTheme';
+import SubAppsTab from './SubAppsTab';
+import SubConfigsTab from './SubConfigsTab';
+import SubHeader from './SubHeader';
+import SubHero from './SubHero';
+import SubLinksTab from './SubLinksTab';
+import { buildSubApps, daysUntil, detectPlatform, resolveSubStatus } from './subPageModel';
 import './SubPage.css';
 
-const QR_SIZE = 240;
-
 const subData = window.__SUB_PAGE_DATA__ || {};
 
 const sId = subData.sId || '';
-const enabled = !!subData.enabled;
-const download = subData.download || '0';
-const upload = subData.upload || '0';
-const total = subData.total || '∞';
-const used = subData.used || '0';
-const remained = subData.remained || '';
-const totalByte = Number(subData.totalByte || 0);
-const expireMs = Number(subData.expire || 0) * 1000;
-const lastOnlineMs = Number(subData.lastOnline || 0);
 const subUrl = subData.subUrl || '';
 const subJsonUrl = subData.subJsonUrl || '';
 const subClashUrl = subData.subClashUrl || '';
 const subTitle = subData.subTitle || '';
+const subSupportUrl = subData.subSupportUrl || '';
+const updateHours = Number(subData.subUpdates || 0);
+const announce = subData.announce || '';
 const links: string[] = Array.isArray(subData.links) ? subData.links : [];
 const linkEmails: string[] = Array.isArray(subData.emails) ? subData.emails : [];
-const subEmail = [...new Set(linkEmails.filter(Boolean))].join(', ');
-const datepicker = subData.datepicker || 'gregorian';
-const announce = subData.announce || '';
-
-const appendRawView = (url: string) => `${url}${url.includes('?') ? '&' : '?'}view=raw`;
-
-const isUnlimited = totalByte <= 0 && expireMs === 0;
-const isActive = (() => {
-  if (!enabled) return false;
-  if (totalByte > 0) {
-    const usedByteCalc =
-      Number(subData.usedByte || 0) ||
-      Number(subData.downloadByte || 0) + Number(subData.uploadByte || 0);
-    if (usedByteCalc >= totalByte) return false;
-  }
-  if (expireMs > 0 && Date.now() >= expireMs) return false;
-  return true;
-})();
+const totalByte = Number(subData.totalByte || 0);
+const usedByte =
+  Number(subData.usedByte || 0) ||
+  Number(subData.downloadByte || 0) + Number(subData.uploadByte || 0);
+const expireMs = Number(subData.expire || 0) * 1000;
+const clientEmail = [...new Set(linkEmails.filter(Boolean))].join(', ');
+const loadedAt = Date.now();
+
+const heroData = {
+  status: resolveSubStatus({ enabled: !!subData.enabled, usedByte, totalByte, expireMs }, loadedAt),
+  daysLeft: daysUntil(expireMs, loadedAt),
+  usedByte,
+  totalByte,
+  expireMs,
+  lastOnlineMs: Number(subData.lastOnline || 0),
+  download: subData.download || '0',
+  upload: subData.upload || '0',
+  used: subData.used || '0',
+  total: subData.total || '∞',
+  remained: subData.remained || '',
+  datepicker: subData.datepicker || 'gregorian',
+};
+
+const apps = buildSubApps({ subUrl, sId, subTitle });
+const initialPlatform = detectPlatform(navigator.userAgent);
+const RTL_LANGUAGES = new Set(['fa-IR', 'ar-EG']);
+
+// The sub page runs its own violet accent, so every antd control on it picks the
+// hue up instead of the panel blue useTheme pins. Mirrored in SubPage.css.
+const ACCENT = {
+  light: {
+    primary: '#7c3aed',
+    hover: '#8b5cf6',
+    active: '#6d28d9',
+    rail: 'rgba(124, 58, 237, 0.16)',
+  },
+  dark: {
+    primary: '#a78bfa',
+    hover: '#c4b5fd',
+    active: '#8b5cf6',
+    rail: 'rgba(167, 139, 250, 0.18)',
+  },
+};
 
 export default function SubPage() {
   const { t } = useTranslation();
-  const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme();
+  const { isDark, isUltra, antdThemeConfig } = useTheme();
   const [messageApi, messageContextHolder] = message.useMessage();
   useEffect(() => {
     setMessageInstance(messageApi);
   }, [messageApi]);
-  const { isMobile } = useMediaQuery(576);
   const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage('subscription'));
 
   const onLangChange = useCallback((next: string) => {
@@ -100,538 +91,124 @@ export default function SubPage() {
     LanguageManager.setLanguage(next, 'subscription');
   }, []);
 
-  const cycleTheme = useCallback(() => {
-    pauseAnimationsUntilLeave('sub-theme-cycle');
-    if (!isDark) {
-      toggleTheme();
-      if (isUltra) toggleUltra();
-    } else if (!isUltra) {
-      toggleUltra();
-    } else {
-      toggleUltra();
-      toggleTheme();
-    }
-  }, [isDark, isUltra, toggleTheme, toggleUltra]);
-
   const copy = useCallback(
-    async (value: string) => {
+    async (value: string, toast?: string) => {
       if (!value) return;
       const ok = await ClipboardManager.copyText(value);
-      if (ok) messageApi.success(t('copied'));
+      if (ok) messageApi.success(toast ?? t('copied'));
     },
     [t, messageApi],
   );
 
-  const copyAll = useCallback(async () => {
-    if (links.length === 0) return;
-    const allLinks = links.join('\n');
-    const ok = await ClipboardManager.copyText(allLinks);
-    if (ok) messageApi.success(t('subscription.copyAllConfigsCopied'));
-  }, [t, messageApi]);
-
   const open = useCallback((url: string) => {
-    if (!url) return;
-    window.open(url, '_blank');
-  }, []);
-
-  const shadowrocketUrl = useMemo(() => {
-    if (!subUrl) return '';
-    const separator = subUrl.includes('?') ? '&' : '?';
-    const rawUrl = subUrl + separator + 'flag=shadowrocket';
-    const base64Url = btoa(rawUrl);
-    const remark = encodeURIComponent(subTitle || sId || 'Subscription');
-    return `shadowrocket://add/sub://${base64Url}?remark=${remark}`;
+    if (url) window.open(url, '_blank');
   }, []);
 
-  const v2boxUrl = useMemo(
-    () => `v2box://install-sub?url=${encodeURIComponent(subUrl)}&name=${encodeURIComponent(sId)}`,
-    [],
-  );
-  const streisandUrl = useMemo(() => `streisand://import/${encodeURIComponent(subUrl)}`, []);
-  const happUrl = useMemo(() => `happ://add/${subUrl}`, []);
-  const incyUrl = useMemo(() => `incy://add/${subUrl}`, []);
-
-  const pageClass = useMemo(() => {
-    const classes = ['subscription-page'];
-    if (isDark) classes.push('is-dark');
-    if (isUltra) classes.push('is-ultra');
-    return classes.join(' ');
-  }, [isDark, isUltra]);
-
-  const descriptionsItems = useMemo(() => {
-    const items = [
-      { key: 'subId', label: t('subscription.subId'), children: sId },
-      ...(subEmail ? [{ key: 'email', label: t('subscription.email'), children: subEmail }] : []),
-      {
-        key: 'status',
-        label: t('subscription.status'),
-        children: !enabled ? (
-          <Tag color="red">{t('subscription.inactive')}</Tag>
-        ) : isUnlimited ? (
-          <Tag color="purple">{t('subscription.unlimited')}</Tag>
-        ) : (
-          <Tag color={isActive ? 'green' : 'red'}>
-            {isActive ? t('subscription.active') : t('subscription.inactive')}
-          </Tag>
+  const tabs = useMemo(() => {
+    const items: NonNullable<TabsProps['items']> = [];
+    if (subUrl || subJsonUrl || subClashUrl) {
+      items.push({
+        key: 'subscription',
+        icon: <LinkOutlined />,
+        label: t('subscription.tabLinks'),
+        children: (
+          <SubLinksTab
+            subUrl={subUrl}
+            subJsonUrl={subJsonUrl}
+            subClashUrl={subClashUrl}
+            onCopy={copy}
+          />
         ),
-      },
-      { key: 'down', label: t('subscription.downloaded'), children: download },
-      { key: 'up', label: t('subscription.uploaded'), children: upload },
-      { key: 'used', label: t('usage'), children: used },
-      { key: 'total', label: t('subscription.totalQuota'), children: total },
-    ];
-    if (totalByte > 0) {
-      items.push({ key: 'remained', label: t('remained'), children: remained });
+      });
+    }
+    if (subUrl) {
+      items.push({
+        key: 'apps',
+        icon: <AppstoreOutlined />,
+        label: t('subscription.tabApps'),
+        children: <SubAppsTab apps={apps} initialPlatform={initialPlatform} onOpen={open} />,
+      });
+    }
+    if (links.length > 0) {
+      items.push({
+        key: 'configs',
+        icon: <UnorderedListOutlined />,
+        label: (
+          <>
+            {t('subscription.tabConfigs')}
+            <span className="sub-tab-count">{links.length}</span>
+          </>
+        ),
+        children: <SubConfigsTab links={links} onCopy={copy} />,
+      });
     }
-    items.push({
-      key: 'lastOnline',
-      label: t('lastOnline'),
-      children: lastOnlineMs > 0 ? IntlUtil.formatDate(lastOnlineMs, datepicker, lang) : '-',
-    });
-    items.push({
-      key: 'expiry',
-      label: t('subscription.expiry'),
-      children:
-        expireMs === 0
-          ? t('subscription.noExpiry')
-          : IntlUtil.formatDate(expireMs, datepicker, lang),
-    });
     return items;
-  }, [t, lang]);
-
-  const androidMenuItems = useMemo(
-    () => [
-      {
-        key: 'android-v2box',
-        label: 'V2Box',
-        onClick: () =>
-          open(
-            `v2box://install-sub?url=${encodeURIComponent(subUrl)}&name=${encodeURIComponent(sId)}`,
-          ),
+  }, [t, copy, open]);
+
+  const direction = RTL_LANGUAGES.has(lang) ? 'rtl' : 'ltr';
+  const pageClass = ['subscription-page', isDark && 'is-dark', isUltra && 'is-ultra']
+    .filter(Boolean)
+    .join(' ');
+
+  const themeConfig = useMemo(() => {
+    const accent = isDark ? ACCENT.dark : ACCENT.light;
+    const primary = {
+      colorPrimary: accent.primary,
+      colorPrimaryHover: accent.hover,
+      colorPrimaryActive: accent.active,
+    };
+    return {
+      ...antdThemeConfig,
+      token: {
+        ...antdThemeConfig.token,
+        ...primary,
+        colorLink: accent.primary,
+        colorInfo: accent.primary,
       },
-      {
-        key: 'android-v2rayng',
-        label: 'V2RayNG',
-        onClick: () => open(`v2rayng://install-config?url=${encodeURIComponent(subUrl)}`),
+      components: {
+        ...antdThemeConfig.components,
+        Button: { ...antdThemeConfig.components?.Button, ...primary },
+        Progress: { ...antdThemeConfig.components?.Progress, remainingColor: accent.rail },
       },
-      { key: 'android-singbox', label: 'Sing-box', onClick: () => copy(subUrl) },
-      { key: 'android-v2raytun', label: 'V2RayTun', onClick: () => copy(subUrl) },
-      { key: 'android-npvtunnel', label: 'NPV Tunnel', onClick: () => copy(subUrl) },
-      { key: 'android-happ', label: 'Happ', onClick: () => open(`happ://add/${subUrl}`) },
-      { key: 'android-incy', label: 'Incy', onClick: () => open(`incy://add/${subUrl}`) },
-    ],
-    [copy, open],
-  );
-
-  const iosMenuItems = useMemo(
-    () => [
-      { key: 'ios-shadowrocket', label: 'Shadowrocket', onClick: () => open(shadowrocketUrl) },
-      { key: 'ios-v2box', label: 'V2Box', onClick: () => open(v2boxUrl) },
-      { key: 'ios-streisand', label: 'Streisand', onClick: () => open(streisandUrl) },
-      { key: 'ios-v2raytun', label: 'V2RayTun', onClick: () => copy(subUrl) },
-      { key: 'ios-npvtunnel', label: 'NPV Tunnel', onClick: () => copy(subUrl) },
-      { key: 'ios-happ', label: 'Happ', onClick: () => open(happUrl) },
-      { key: 'ios-incy', label: 'Incy', onClick: () => open(incyUrl) },
-    ],
-    [copy, open, shadowrocketUrl, v2boxUrl, streisandUrl, happUrl, incyUrl],
-  );
-
-  const langMenuItems = useMemo(
-    () =>
-      (LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[]).map(
-        (l) => ({
-          key: l.value,
-          label: (
-            <Space size={8}>
-              <span aria-hidden="true">{l.icon}</span>
-              <span>{l.name}</span>
-            </Space>
-          ),
-        }),
-      ),
-    [],
-  );
-
-  const themeIcon = !isDark ? <SunOutlined /> : !isUltra ? <MoonOutlined /> : <MoonFilled />;
-
-  const cardTitle = (
-    <Space>
-      <span>{t('subscription.title')}</span>
-      <Tag>{sId}</Tag>
-    </Space>
-  );
-
-  const cardExtra = (
-    <Space size={8} align="center">
-      <Button
-        shape="circle"
-        size="large"
-        className="toolbar-btn"
-        aria-label={t('menu.theme')}
-        title={t('menu.theme')}
-        icon={themeIcon}
-        onClick={cycleTheme}
-      />
-      <Popover
-        rootClassName={isDark ? 'dark' : 'light'}
-        placement="bottomRight"
-        trigger="click"
-        styles={{ content: { padding: 4 } }}
-        content={
-          <Menu
-            mode="vertical"
-            selectable
-            selectedKeys={[lang]}
-            items={langMenuItems}
-            onClick={({ key }) => onLangChange(key)}
-            style={{ border: 'none', minWidth: 160 }}
-          />
-        }
-      >
-        <Button
-          shape="circle"
-          size="large"
-          className="toolbar-btn"
-          aria-label={t('pages.settings.language')}
-          icon={<TranslationOutlined />}
-        />
-      </Popover>
-    </Space>
-  );
+    };
+  }, [antdThemeConfig, isDark]);
 
   return (
-    <ConfigProvider theme={antdThemeConfig}>
+    <ConfigProvider theme={themeConfig} direction={direction}>
       {messageContextHolder}
-      <Layout className={pageClass}>
-        <Layout.Content className="content">
-          <Row justify="center">
-            <Col xs={24} sm={22} md={18} lg={14} xl={12}>
-              <Card hoverable className="subscription-card" title={cardTitle} extra={cardExtra}>
-                {announce && (
-                  <Alert type="info" showIcon title={announce} style={{ marginBottom: 16 }} />
+      <Layout className={pageClass} dir={direction}>
+        <div className="sub-aurora" aria-hidden="true">
+          <span className="sub-aurora-grid" />
+        </div>
+        <Layout.Content className="sub-content">
+          <Card className="sub-card">
+            <SubHeader
+              title={subTitle}
+              sId={sId}
+              email={clientEmail}
+              lang={lang}
+              onLangChange={onLangChange}
+            />
+            {announce && <Alert type="info" showIcon title={announce} className="sub-announce" />}
+            <SubHero {...heroData} lang={lang} />
+            {tabs.length > 0 && <Tabs className="sub-tabs" tabBarGutter={24} items={tabs} />}
+            {(updateHours > 0 || subSupportUrl) && (
+              <footer className="sub-footer">
+                {updateHours > 0 && (
+                  <span>
+                    <ClockCircleOutlined />
+                    {t('subscription.updateInterval', { hours: updateHours })}
+                  </span>
                 )}
-                <Descriptions
-                  bordered
-                  column={1}
-                  size="small"
-                  className="info-table"
-                  items={descriptionsItems}
-                />
-
-                <SubUsageSummary
-                  usedByte={
-                    Number(subData.usedByte || 0) ||
-                    Number(subData.downloadByte || 0) + Number(subData.uploadByte || 0)
-                  }
-                  totalByte={totalByte}
-                  usedLabel={used}
-                  totalLabel={total}
-                  remainedLabel={remained}
-                  expireMs={expireMs}
-                  isActive={isActive}
-                />
-
-                {(subUrl || subJsonUrl || subClashUrl) && (
-                  <>
-                    <Divider>{t('subscription.title')}</Divider>
-                    <div className="links-section">
-                      {subUrl && (
-                        <div className="sub-link-row">
-                          <Tag color="green" className="sub-link-tag">
-                            SUB
-                          </Tag>
-                          <a
-                            href={subUrl}
-                            target="_blank"
-                            rel="noopener noreferrer"
-                            className="sub-link-title sub-link-anchor"
-                            title={subUrl}
-                          >
-                            {sId}
-                          </a>
-                          <div className="sub-link-actions">
-                            <Button
-                              size="small"
-                              icon={<CopyOutlined />}
-                              onClick={() => copy(subUrl)}
-                              aria-label={t('copy')}
-                              title={t('copy')}
-                            />
-                            <Popover
-                              trigger="click"
-                              placement="left"
-                              destroyOnHidden
-                              content={
-                                <div className="sub-link-qr-popover">
-                                  <Tag color="green" className="qr-tag">
-                                    {t('pages.settings.subSettings')}
-                                  </Tag>
-                                  <QRCode
-                                    value={subUrl}
-                                    size={QR_SIZE}
-                                    type="svg"
-                                    bordered={false}
-                                    color="#000000"
-                                    bgColor="#ffffff"
-                                  />
-                                </div>
-                              }
-                            >
-                              <Button
-                                size="small"
-                                icon={<QrcodeOutlined />}
-                                aria-label="QR"
-                                title="QR"
-                              />
-                            </Popover>
-                          </div>
-                        </div>
-                      )}
-                      {subJsonUrl && (
-                        <div className="sub-link-row">
-                          <Tag color="purple" className="sub-link-tag">
-                            JSON
-                          </Tag>
-                          <a
-                            href={subJsonUrl}
-                            target="_blank"
-                            rel="noopener noreferrer"
-                            className="sub-link-title sub-link-anchor"
-                            title={subJsonUrl}
-                          >
-                            {sId}
-                          </a>
-                          <div className="sub-link-actions">
-                            <Button
-                              size="small"
-                              href={appendRawView(subJsonUrl)}
-                              target="_blank"
-                              rel="noopener noreferrer"
-                              icon={<DownloadOutlined />}
-                              aria-label={t('download')}
-                              title={t('download')}
-                            />
-                            <Button
-                              size="small"
-                              icon={<CopyOutlined />}
-                              onClick={() => copy(subJsonUrl)}
-                              aria-label={t('copy')}
-                              title={t('copy')}
-                            />
-                            <Popover
-                              trigger="click"
-                              placement="left"
-                              destroyOnHidden
-                              content={
-                                <div className="sub-link-qr-popover">
-                                  <Tag color="purple" className="qr-tag">
-                                    {t('pages.settings.subSettings')} JSON
-                                  </Tag>
-                                  <QRCode
-                                    value={subJsonUrl}
-                                    size={QR_SIZE}
-                                    type="svg"
-                                    bordered={false}
-                                    color="#000000"
-                                    bgColor="#ffffff"
-                                  />
-                                </div>
-                              }
-                            >
-                              <Button
-                                size="small"
-                                icon={<QrcodeOutlined />}
-                                aria-label="QR"
-                                title="QR"
-                              />
-                            </Popover>
-                          </div>
-                        </div>
-                      )}
-                      {subClashUrl && (
-                        <div className="sub-link-row">
-                          <Tooltip title="Clash / Mihomo">
-                            <Tag color="gold" className="sub-link-tag">
-                              CLASH
-                            </Tag>
-                          </Tooltip>
-                          <a
-                            href={subClashUrl}
-                            target="_blank"
-                            rel="noopener noreferrer"
-                            className="sub-link-title sub-link-anchor"
-                            title={subClashUrl}
-                          >
-                            {sId}
-                          </a>
-                          <div className="sub-link-actions">
-                            <Button
-                              size="small"
-                              href={appendRawView(subClashUrl)}
-                              target="_blank"
-                              rel="noopener noreferrer"
-                              icon={<DownloadOutlined />}
-                              aria-label={t('download')}
-                              title={t('download')}
-                            />
-                            <Button
-                              size="small"
-                              icon={<CopyOutlined />}
-                              onClick={() => copy(subClashUrl)}
-                              aria-label={t('copy')}
-                              title={t('copy')}
-                            />
-                            <Popover
-                              trigger="click"
-                              placement="left"
-                              destroyOnHidden
-                              content={
-                                <div className="sub-link-qr-popover">
-                                  <Tag color="gold" className="qr-tag">
-                                    Clash / Mihomo
-                                  </Tag>
-                                  <QRCode
-                                    value={subClashUrl}
-                                    size={QR_SIZE}
-                                    type="svg"
-                                    bordered={false}
-                                    color="#000000"
-                                    bgColor="#ffffff"
-                                  />
-                                </div>
-                              }
-                            >
-                              <Button
-                                size="small"
-                                icon={<QrcodeOutlined />}
-                                aria-label="QR"
-                                title="QR"
-                              />
-                            </Popover>
-                          </div>
-                        </div>
-                      )}
-                    </div>
-                  </>
+                {subSupportUrl && (
+                  <a href={subSupportUrl} target="_blank" rel="noopener noreferrer">
+                    <CustomerServiceOutlined />
+                    {t('subscription.support')}
+                  </a>
                 )}
-
-                {links.length > 0 && (
-                  <>
-                    <Divider>{t('pages.inbounds.copyLink')}</Divider>
-                    <div className="links-section">
-                      <div className="sub-link-row">
-                        <span className="sub-link-title">{t('subscription.copyAllConfigs')}</span>
-                        <div className="sub-link-actions">
-                          <Button
-                            size="small"
-                            icon={<CopyOutlined />}
-                            onClick={copyAll}
-                            aria-label={t('subscription.copyAllConfigs')}
-                            title={t('subscription.copyAllConfigs')}
-                          />
-                        </div>
-                      </div>
-                      {links.map((link, idx) => {
-                        const parts = parseLinkParts(link);
-                        const fallback = `Link ${idx + 1}`;
-                        const rowTitle = parts?.remark || fallback;
-                        const qrLabel = parts?.remark || rowTitle;
-                        const canQr = !isPostQuantumLink(link);
-                        const isWireguardLink =
-                          link.startsWith('wireguard://') || link.startsWith('wg://');
-                        const isAmneziawgLink = link.startsWith('vpn://');
-                        return (
-                          <Fragment key={link}>
-                            <div className="sub-link-row">
-                              {parts ? (
-                                <LinkTags parts={parts} />
-                              ) : (
-                                <Tag className="sub-link-tag">LINK</Tag>
-                              )}
-                              <span className="sub-link-title" title={rowTitle}>
-                                {rowTitle}
-                              </span>
-                              <div className="sub-link-actions">
-                                <Button
-                                  size="small"
-                                  icon={<CopyOutlined />}
-                                  onClick={() => copy(link)}
-                                  aria-label={t('copy')}
-                                  title={t('copy')}
-                                />
-                                {canQr && (
-                                  <Popover
-                                    trigger="click"
-                                    placement="left"
-                                    destroyOnHidden
-                                    content={
-                                      <div className="sub-link-qr-popover">
-                                        <Tag className="qr-tag">{qrLabel}</Tag>
-                                        <QRCode
-                                          value={link}
-                                          size={220}
-                                          type="svg"
-                                          bordered={false}
-                                          color="#000000"
-                                          bgColor="#ffffff"
-                                        />
-                                      </div>
-                                    }
-                                  >
-                                    <Button
-                                      size="small"
-                                      icon={<QrcodeOutlined />}
-                                      aria-label="QR"
-                                      title="QR"
-                                    />
-                                  </Popover>
-                                )}
-                              </div>
-                            </div>
-                            {isWireguardLink && (
-                              <ConfigBlock
-                                label={t('pages.clients.wireguardConfig')}
-                                text={wireguardConfigFromLink(link, rowTitle)}
-                                fileName={`${rowTitle || 'peer'}.conf`}
-                                qrRemark={rowTitle}
-                                tagColor="cyan"
-                              />
-                            )}
-                            {isAmneziawgLink && (
-                              <ConfigBlock
-                                label={t('pages.clients.amneziaWgConfig')}
-                                text={amneziawgConfigFromLink(link)}
-                                fileName={`${rowTitle || 'peer'}.conf`}
-                                qrRemark={rowTitle}
-                                tagColor="purple"
-                              />
-                            )}
-                          </Fragment>
-                        );
-                      })}
-                    </div>
-                  </>
-                )}
-
-                <Row gutter={[8, 8]} justify="center" className="apps-row">
-                  <Col xs={24} sm={12} className="app-col">
-                    <Dropdown trigger={['click']} menu={{ items: androidMenuItems }}>
-                      <Button block={isMobile} size="large" type="primary">
-                        <AndroidOutlined /> Android <DownOutlined />
-                      </Button>
-                    </Dropdown>
-                  </Col>
-                  <Col xs={24} sm={12} className="app-col">
-                    <Dropdown trigger={['click']} menu={{ items: iosMenuItems }}>
-                      <Button block={isMobile} size="large" type="primary">
-                        <AppleOutlined /> iOS <DownOutlined />
-                      </Button>
-                    </Dropdown>
-                  </Col>
-                </Row>
-              </Card>
-            </Col>
-          </Row>
+              </footer>
+            )}
+          </Card>
         </Layout.Content>
       </Layout>
     </ConfigProvider>

+ 69 - 0
frontend/src/pages/sub/SubQrButton.tsx

@@ -0,0 +1,69 @@
+import { useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Button, Input, Modal, QRCode } from 'antd';
+import { CopyOutlined, DownloadOutlined, QrcodeOutlined } from '@ant-design/icons';
+
+interface SubQrButtonProps {
+  value: string;
+  label: string;
+  onCopy: (value: string) => void;
+}
+
+export default function SubQrButton({ value, label, onCopy }: SubQrButtonProps) {
+  const { t } = useTranslation();
+  const [open, setOpen] = useState(false);
+  const qrRef = useRef<HTMLDivElement>(null);
+
+  const saveQr = () => {
+    const canvas = qrRef.current?.querySelector('canvas');
+    if (!canvas) return;
+    const link = document.createElement('a');
+    link.href = canvas.toDataURL('image/png');
+    link.download = `${label || 'qrcode'}.png`;
+    link.click();
+  };
+
+  return (
+    <>
+      <Button icon={<QrcodeOutlined />} aria-label="QR" title="QR" onClick={() => setOpen(true)} />
+      <Modal
+        open={open}
+        onCancel={() => setOpen(false)}
+        footer={null}
+        width={440}
+        centered
+        destroyOnHidden
+        rootClassName="sub-qr-modal"
+        title={t('subscription.qrTitle')}
+      >
+        <p className="sub-muted sub-qr-modal-hint">{t('subscription.qrHint')}</p>
+        <div ref={qrRef} className="sub-qr-modal-code">
+          <QRCode
+            value={value}
+            size={240}
+            type="canvas"
+            marginSize={2}
+            bordered={false}
+            color="#000000"
+            bgColor="#ffffff"
+          />
+        </div>
+        <Input.TextArea
+          className="sub-qr-modal-link"
+          value={value}
+          readOnly
+          dir="ltr"
+          autoSize={{ minRows: 2, maxRows: 5 }}
+        />
+        <div className="sub-qr-modal-actions">
+          <Button type="primary" size="large" icon={<CopyOutlined />} onClick={() => onCopy(value)}>
+            {t('copy')}
+          </Button>
+          <Button size="large" icon={<DownloadOutlined />} onClick={saveQr}>
+            {t('subscription.saveQr')}
+          </Button>
+        </div>
+      </Modal>
+    </>
+  );
+}

+ 0 - 87
frontend/src/pages/sub/SubUsageSummary.css

@@ -1,87 +0,0 @@
-.usage-summary {
-  margin-top: 12px;
-  padding: 14px 16px;
-  background: var(--ant-color-fill-alter);
-  border: 1px solid var(--ant-color-border-secondary);
-  border-radius: 12px;
-}
-
-.usage-summary.is-inactive {
-  opacity: 0.7;
-  border-color: var(--ant-color-error-border);
-}
-
-.usage-summary-head {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 12px;
-  margin-bottom: 8px;
-}
-
-.usage-summary-labels {
-  display: flex;
-  align-items: baseline;
-  gap: 6px;
-  font-variant-numeric: tabular-nums;
-  min-width: 0;
-}
-
-.usage-summary-used {
-  font-size: 18px;
-  font-weight: 700;
-  color: var(--ant-color-text);
-}
-
-.usage-summary-sep {
-  color: var(--ant-color-text-quaternary);
-  font-size: 16px;
-}
-
-.usage-summary-total {
-  font-size: 14px;
-  color: var(--ant-color-text-secondary);
-  font-weight: 500;
-}
-
-.usage-summary-chips {
-  display: flex;
-  align-items: center;
-  gap: 6px;
-  flex-shrink: 0;
-}
-
-.usage-summary-chips .ant-tag {
-  margin: 0;
-}
-
-.usage-summary-bar.ant-progress {
-  margin-bottom: 6px;
-}
-
-.usage-summary-bar .ant-progress-outer {
-  padding-inline-end: 0;
-}
-
-.usage-summary-bar .ant-progress-inner {
-  background: var(--ant-color-fill-secondary);
-}
-
-.usage-summary-foot {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  font-size: 12px;
-  color: var(--ant-color-text-tertiary);
-  font-variant-numeric: tabular-nums;
-  min-height: 16px;
-}
-
-.usage-summary-remained::before {
-  content: '';
-}
-
-.usage-summary-pct {
-  font-weight: 600;
-  color: var(--ant-color-text-secondary);
-}

+ 0 - 96
frontend/src/pages/sub/SubUsageSummary.tsx

@@ -1,96 +0,0 @@
-import { useMemo } from 'react';
-import { useTranslation } from 'react-i18next';
-import { Progress, Tag } from 'antd';
-import { ClockCircleOutlined, ThunderboltOutlined } from '@ant-design/icons';
-
-import './SubUsageSummary.css';
-
-interface SubUsageSummaryProps {
-  usedByte: number;
-  totalByte: number;
-  usedLabel: string;
-  totalLabel: string;
-  remainedLabel: string;
-  expireMs: number;
-  isActive: boolean;
-}
-
-function pickStrokeColor(pct: number): { from: string; to: string } {
-  if (pct >= 90) return { from: '#ff7875', to: '#ff4d4f' };
-  if (pct >= 75) return { from: '#ffc53d', to: '#fa8c16' };
-  return { from: '#5fc983', to: '#36b37e' };
-}
-
-function formatExpiryChip(expireMs: number): { label: string; color: string } | null {
-  if (expireMs <= 0) return null;
-  const diff = expireMs - Date.now();
-  if (diff <= 0) return { label: 'Expired', color: 'red' };
-  const days = Math.floor(diff / 86400000);
-  if (days >= 1) return { label: `${days}d`, color: days <= 3 ? 'orange' : 'blue' };
-  const hours = Math.max(1, Math.floor(diff / 3600000));
-  return { label: `${hours}h`, color: 'orange' };
-}
-
-export default function SubUsageSummary({
-  usedByte,
-  totalByte,
-  usedLabel,
-  totalLabel,
-  remainedLabel,
-  expireMs,
-  isActive,
-}: SubUsageSummaryProps) {
-  const { t } = useTranslation();
-  const pct = useMemo(() => {
-    if (totalByte <= 0) return 0;
-    const v = (usedByte / totalByte) * 100;
-    if (!Number.isFinite(v)) return 0;
-    return Math.max(0, Math.min(100, v));
-  }, [usedByte, totalByte]);
-
-  const expiry = formatExpiryChip(expireMs);
-  const isUnlimited = totalByte <= 0;
-  const stroke = pickStrokeColor(pct);
-
-  return (
-    <div className={`usage-summary ${!isActive ? 'is-inactive' : ''}`}>
-      <div className="usage-summary-head">
-        <div className="usage-summary-labels">
-          <span className="usage-summary-used">{usedLabel}</span>
-          <span className="usage-summary-sep">/</span>
-          <span className="usage-summary-total">{isUnlimited ? '∞' : totalLabel}</span>
-        </div>
-        <div className="usage-summary-chips">
-          {isUnlimited && (
-            <Tag color="purple" icon={<ThunderboltOutlined />}>
-              {t('subscription.unlimited')}
-            </Tag>
-          )}
-          {expiry && (
-            <Tag color={expiry.color} icon={<ClockCircleOutlined />}>
-              {expiry.label}
-            </Tag>
-          )}
-        </div>
-      </div>
-      {!isUnlimited && (
-        <Progress
-          percent={pct}
-          showInfo={false}
-          strokeColor={{ '0%': stroke.from, '100%': stroke.to }}
-          railColor="var(--ant-color-fill-secondary)"
-          strokeWidth={10}
-          className="usage-summary-bar"
-        />
-      )}
-      <div className="usage-summary-foot">
-        {!isUnlimited && (
-          <>
-            <span className="usage-summary-remained">{remainedLabel}</span>
-            <span className="usage-summary-pct">{pct.toFixed(1)}%</span>
-          </>
-        )}
-      </div>
-    </div>
-  );
-}

+ 25 - 0
frontend/src/pages/sub/app-icons/README.md

@@ -0,0 +1,25 @@
+# Subscription page app icons
+
+## Line icons — Arcticons (CC BY-SA 4.0)
+
+`happ.svg`, `sing-box.svg`, `v2rayng.svg` and `v2raytun.svg` come from
+[Arcticons](https://github.com/Arcticons-Team/Arcticons) by Donnnno and the
+Arcticons contributors (as listed on svgicons.com), licensed under
+[CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/).
+
+Changes from the originals: the stroke colour is `currentColor` so the page can
+tint them to its theme, the stroke width is raised from 1 to 2 so they stay
+legible at tile size, and the style classes and ids were removed. These modified
+files are distributed under CC BY-SA 4.0 as well.
+
+## App icons
+
+The remaining files are each app's own icon, downscaled to 96 px, used only to
+identify the app a button opens. They remain the trademarks of their owners.
+
+| File                | Source                                                                    |
+| ------------------- | ------------------------------------------------------------------------- |
+| `shadowrocket.webp` | App Store listing [id932747118](https://apps.apple.com/app/id932747118)   |
+| `streisand.webp`    | App Store listing [id6450534064](https://apps.apple.com/app/id6450534064) |
+| `v2box.webp`        | App Store listing [id6446814690](https://apps.apple.com/app/id6446814690) |
+| `incy.webp`         | App Store listing [id6756943388](https://apps.apple.com/app/id6756943388) |

+ 1 - 0
frontend/src/pages/sub/app-icons/happ.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><polyline points="11.9387 18.636 11.2805 19.2322 13.2164 6.3148 21.6618 6.3148 21.082 10.1846"/><polyline points="20.2585 28.1999 18.113 42.5 9.6674 42.5 10.3198 38.1088"/><polyline points="28.1728 36.5585 27.2773 42.5 35.7228 42.5 37.9603 27.5876 36.0158 29.2999"/><polyline points="21.0144 27.3598 27.8555 27.3598 26.2122 38.3731 36.0158 29.2999 39.1998 8.0038 29.2593 17.99 28.8029 21.0714 27.2773 21.0714 26.4784 21.8658"/><polygon points="26.4784 21.8658 20.2243 28.1543 18.9689 28.1543 18.752 29.6379 8.8002 39.6355 11.9387 18.636 21.7423 9.5743 19.9047 21.8658 26.4784 21.8658"/><polyline points="38.0794 9.1294 38.6261 5.5 30.1808 5.5 28.1701 18.9112 29.2593 17.99"/></svg>

BIN
frontend/src/pages/sub/app-icons/incy.webp


BIN
frontend/src/pages/sub/app-icons/shadowrocket.webp


+ 1 - 0
frontend/src/pages/sub/app-icons/sing-box.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M40.7241,15.6976l-15.4896-10.8095c-.7416-.5175-1.7273-.5175-2.4689,0L7.2759,15.6976c-.9141.6379-.9141,1.9909,0,2.6288l15.4896,10.8095c.7416.5175,1.7273.5175,2.4689,0l15.4896-10.8095c.9141-.6379.9141-1.9909,0-2.6288Z"/><path d="M41.4096,17.012v13.976c0,.4977-.2285.9954-.6855,1.3144l-15.4896,10.8095c-.7416.5175-1.7273.5175-2.4689,0l-15.4896-10.8095c-.457-.3189-.6855-.8167-.6855-1.3144h0s0-13.976,0-13.976"/><line x1="24" y1="29.524" x2="24" y2="43.5"/><path d="M11.8734,12.4893l18.3951,13.1335v3.8047c0,.6869.7673,1.0951,1.3369.7111l4.3991-2.9651c.2961-.1996.4735-.5332.4735-.8903v-4.9939l-18.3951-13.1335"/></svg>

BIN
frontend/src/pages/sub/app-icons/streisand.webp


BIN
frontend/src/pages/sub/app-icons/v2box.webp


+ 1 - 0
frontend/src/pages/sub/app-icons/v2rayng.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m9.6185,41.4866V10.6142h-4.1185v-4.0722h10.2641v14.3936c.6957-.6404,1.2872-1.1794,1.8728-1.7248,3.5152-3.2745,7.0291-6.5504,10.5432-9.826.9416-.8777,1.8767-1.7624,2.8301-2.6271.1437-.1303.3712-.2375.5603-.2382,3.5668-.0136,10.9295,0,10.9295,0-10.9555,11.6366-21.8724,23.2736-32.8815,34.9672Z"/></svg>

+ 1 - 0
frontend/src/pages/sub/app-icons/v2raytun.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><rect x="5.5" y="5.5" width="37" height="37" rx="4" ry="4"/><polyline points="28.9436 11.2023 20.4652 36.7977 11.9867 11.2023"/><path d="M28.3091,29.0207c0-2.3774,2.1537-4.2518,4.6165-3.7785,1.6155.3105,2.9055,1.7076,3.0663,3.3447.1195,1.2178-.2658,2.4194-1.1069,3.1576-1.5583,1.3675-6.5759,5.053-6.5759,5.053h7.7042"/></svg>

+ 20 - 0
frontend/src/pages/sub/appIcons.ts

@@ -0,0 +1,20 @@
+import happ from './app-icons/happ.svg';
+import incy from './app-icons/incy.webp';
+import shadowrocket from './app-icons/shadowrocket.webp';
+import singBox from './app-icons/sing-box.svg';
+import streisand from './app-icons/streisand.webp';
+import v2box from './app-icons/v2box.webp';
+import v2rayng from './app-icons/v2rayng.svg';
+import v2raytun from './app-icons/v2raytun.svg';
+
+// Tinted entries are Arcticons line art drawn in the theme colour; the rest are full-colour app icons.
+export const APP_ICONS: Record<string, { src: string; tinted: boolean }> = {
+  V2Box: { src: v2box, tinted: false },
+  V2RayNG: { src: v2rayng, tinted: true },
+  'Sing-box': { src: singBox, tinted: true },
+  V2RayTun: { src: v2raytun, tinted: true },
+  Happ: { src: happ, tinted: true },
+  Incy: { src: incy, tinted: false },
+  Shadowrocket: { src: shadowrocket, tinted: false },
+  Streisand: { src: streisand, tinted: false },
+};

+ 93 - 0
frontend/src/pages/sub/subPageModel.ts

@@ -0,0 +1,93 @@
+const DAY_MS = 86_400_000;
+
+export type SubStatus = 'active' | 'unlimited' | 'expired' | 'depleted' | 'disabled';
+
+export interface SubUsage {
+  enabled: boolean;
+  usedByte: number;
+  totalByte: number;
+  expireMs: number;
+}
+
+export function resolveSubStatus(sub: SubUsage, now: number): SubStatus {
+  if (!sub.enabled) return 'disabled';
+  if (sub.expireMs > 0 && now >= sub.expireMs) return 'expired';
+  if (sub.totalByte > 0 && sub.usedByte >= sub.totalByte) return 'depleted';
+  if (sub.totalByte <= 0 && sub.expireMs === 0) return 'unlimited';
+  return 'active';
+}
+
+export function daysUntil(expireMs: number, now: number): number | null {
+  if (expireMs <= 0) return null;
+  return Math.max(0, Math.ceil((expireMs - now) / DAY_MS));
+}
+
+export function usagePercent(usedByte: number, totalByte: number): number {
+  if (totalByte <= 0) return 0;
+  const pct = (usedByte / totalByte) * 100;
+  return Number.isFinite(pct) ? Math.min(100, Math.max(0, pct)) : 0;
+}
+
+export type AppPlatform = 'android' | 'ios';
+
+export function detectPlatform(userAgent: string): AppPlatform {
+  // iPadOS sends a Macintosh UA, and App Store clients also run on Apple-silicon Macs.
+  if (/iphone|ipad|ipod|macintosh/i.test(userAgent)) return 'ios';
+  return 'android';
+}
+
+export interface SubApp {
+  name: string;
+  url: string;
+}
+
+export interface SubAppSource {
+  subUrl: string;
+  sId: string;
+  subTitle: string;
+}
+
+export function buildSubApps({
+  subUrl,
+  sId,
+  subTitle,
+}: SubAppSource): Record<AppPlatform, SubApp[]> {
+  const encSub = encodeURIComponent(subUrl);
+  const profileName = encodeURIComponent(subTitle || sId);
+
+  const v2box = {
+    name: 'V2Box',
+    url: `v2box://install-sub?url=${encSub}&name=${encodeURIComponent(sId)}`,
+  };
+  const singBox = {
+    name: 'Sing-box',
+    url: `sing-box://import-remote-profile?url=${encSub}#${profileName}`,
+  };
+  const v2raytun = { name: 'V2RayTun', url: `v2raytun://import/${subUrl}` };
+  const happ = { name: 'Happ', url: `happ://add/${subUrl}` };
+  const incy = { name: 'Incy', url: `incy://add/${subUrl}` };
+  const rocketSource = `${subUrl}${subUrl.includes('?') ? '&' : '?'}flag=shadowrocket`;
+  const rocketRemark = encodeURIComponent(subTitle || sId || 'Subscription');
+
+  return {
+    android: [
+      v2box,
+      { name: 'V2RayNG', url: `v2rayng://install-config?url=${encSub}` },
+      singBox,
+      v2raytun,
+      happ,
+      incy,
+    ],
+    ios: [
+      {
+        name: 'Shadowrocket',
+        url: `shadowrocket://add/sub://${btoa(rocketSource)}?remark=${rocketRemark}`,
+      },
+      v2box,
+      { name: 'Streisand', url: `streisand://import/${encSub}` },
+      v2raytun,
+      happ,
+      incy,
+    ],
+  };
+}

+ 4 - 4
frontend/src/test/qr-panel-readable.test.tsx

@@ -81,9 +81,9 @@ describe('QrPanel dense AmneziaWG config', () => {
     const completeQr = qrGeometry(complete);
     const shorterQr = qrGeometry(withoutDisableCookies);
 
-    expect(completeQr.viewBox).toBe('0 0 105 105');
-    expect(shorterQr.viewBox).toBe('0 0 101 101');
-    expect(completeQr.foreground).toMatch(/^M4 4h7/);
-    expect(shorterQr.foreground).toMatch(/^M4 4h7/);
+    expect(completeQr.viewBox).toBe('0 0 101 101');
+    expect(shorterQr.viewBox).toBe('0 0 97 97');
+    expect(completeQr.foreground).toMatch(/^M2 2h7/);
+    expect(shorterQr.foreground).toMatch(/^M2 2h7/);
   });
 });

+ 28 - 0
frontend/src/test/sub-app-icons.test.ts

@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest';
+
+import { APP_ICONS } from '@/pages/sub/appIcons';
+import { buildSubApps } from '@/pages/sub/subPageModel';
+
+describe('APP_ICONS', () => {
+  it('has an icon for every app the subscription page offers on every platform', () => {
+    const apps = buildSubApps({
+      subUrl: 'https://sub.example.com/sub/abc',
+      sId: 'abc',
+      subTitle: '',
+    });
+    const names = [...new Set(Object.values(apps).flatMap((list) => list.map((app) => app.name)))];
+
+    expect(names.filter((name) => !APP_ICONS[name]?.src)).toEqual([]);
+  });
+
+  it('carries no icon for an app the page no longer offers', () => {
+    const apps = buildSubApps({
+      subUrl: 'https://sub.example.com/sub/abc',
+      sId: 'abc',
+      subTitle: '',
+    });
+    const offered = new Set(Object.values(apps).flatMap((list) => list.map((app) => app.name)));
+
+    expect(Object.keys(APP_ICONS).filter((name) => !offered.has(name))).toEqual([]);
+  });
+});

+ 135 - 0
frontend/src/test/sub-page-model.test.ts

@@ -0,0 +1,135 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+  buildSubApps,
+  daysUntil,
+  detectPlatform,
+  resolveSubStatus,
+  usagePercent,
+} from '@/pages/sub/subPageModel';
+
+const DAY = 86_400_000;
+const NOW = Date.UTC(2026, 8, 15, 12, 0, 0);
+
+describe('resolveSubStatus', () => {
+  const base = { enabled: true, usedByte: 10, totalByte: 100, expireMs: NOW + DAY };
+
+  it.each([
+    [
+      'disabled wins over expiry and quota',
+      { ...base, enabled: false, expireMs: NOW - DAY },
+      'disabled',
+    ],
+    ['expired at the expiry instant', { ...base, expireMs: NOW }, 'expired'],
+    ['expired beats depleted', { ...base, usedByte: 100, expireMs: NOW - DAY }, 'expired'],
+    ['depleted once usage reaches the quota', { ...base, usedByte: 100 }, 'depleted'],
+    [
+      'unlimited with neither quota nor expiry',
+      { ...base, totalByte: 0, expireMs: 0 },
+      'unlimited',
+    ],
+    ['active without quota but with a future expiry', { ...base, totalByte: 0 }, 'active'],
+    ['active inside quota and before expiry', base, 'active'],
+  ] as const)('%s', (_name, input, want) => {
+    expect(resolveSubStatus(input, NOW)).toBe(want);
+  });
+});
+
+describe('daysUntil', () => {
+  it('is null for a subscription that never expires', () => {
+    expect(daysUntil(0, NOW)).toBeNull();
+  });
+
+  it('rounds the last partial day up to 1', () => {
+    expect(daysUntil(NOW + 3 * 3_600_000, NOW)).toBe(1);
+  });
+
+  it('counts whole days', () => {
+    expect(daysUntil(NOW + 23 * DAY, NOW)).toBe(23);
+  });
+
+  it('stays at 0 after expiry', () => {
+    expect(daysUntil(NOW - DAY, NOW)).toBe(0);
+  });
+});
+
+describe('usagePercent', () => {
+  it('is 0 without a quota instead of NaN or Infinity', () => {
+    expect(usagePercent(5_000, 0)).toBe(0);
+  });
+
+  it('clamps an over-quota client to 100', () => {
+    expect(usagePercent(150, 100)).toBe(100);
+  });
+});
+
+describe('detectPlatform', () => {
+  it.each([
+    [
+      'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/128.0 Mobile Safari/537.36',
+      'android',
+    ],
+    [
+      'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148',
+      'ios',
+    ],
+    [
+      'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/17.5 Safari/605.1.15',
+      'ios',
+    ],
+    [
+      'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/128.0 Safari/537.36',
+      'android',
+    ],
+    ['Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/128.0 Safari/537.36', 'android'],
+  ])('%s -> %s', (ua, want) => {
+    expect(detectPlatform(ua)).toBe(want);
+  });
+});
+
+describe('buildSubApps', () => {
+  const subUrl = 'https://sub.example.com/sub/abc';
+  const encSub = encodeURIComponent(subUrl);
+  const sub = { subUrl, sId: 'abc', subTitle: 'Nova Net' };
+
+  it('offers Android and iOS app lists only', () => {
+    expect(Object.keys(buildSubApps(sub))).toEqual(['android', 'ios']);
+  });
+
+  it('gives every Android app a one-tap import link', () => {
+    expect(buildSubApps(sub).android).toEqual([
+      { name: 'V2Box', url: `v2box://install-sub?url=${encSub}&name=abc` },
+      { name: 'V2RayNG', url: `v2rayng://install-config?url=${encSub}` },
+      { name: 'Sing-box', url: `sing-box://import-remote-profile?url=${encSub}#Nova%20Net` },
+      { name: 'V2RayTun', url: `v2raytun://import/${subUrl}` },
+      { name: 'Happ', url: `happ://add/${subUrl}` },
+      { name: 'Incy', url: `incy://add/${subUrl}` },
+    ]);
+  });
+
+  it('gives every iOS app a one-tap import link', () => {
+    const rocket = Buffer.from(`${subUrl}?flag=shadowrocket`).toString('base64');
+    expect(buildSubApps(sub).ios).toEqual([
+      { name: 'Shadowrocket', url: `shadowrocket://add/sub://${rocket}?remark=Nova%20Net` },
+      { name: 'V2Box', url: `v2box://install-sub?url=${encSub}&name=abc` },
+      { name: 'Streisand', url: `streisand://import/${encSub}` },
+      { name: 'V2RayTun', url: `v2raytun://import/${subUrl}` },
+      { name: 'Happ', url: `happ://add/${subUrl}` },
+      { name: 'Incy', url: `incy://add/${subUrl}` },
+    ]);
+  });
+
+  it('names the sing-box profile after the subscription id when there is no title', () => {
+    expect(buildSubApps({ ...sub, subTitle: '' }).android[2].url).toBe(
+      `sing-box://import-remote-profile?url=${encSub}#abc`,
+    );
+  });
+
+  it('appends flag=shadowrocket with & when the subscription URL already has a query', () => {
+    const withQuery = { ...sub, subUrl: `${subUrl}?token=1` };
+    const rocket = Buffer.from(`${subUrl}?token=1&flag=shadowrocket`).toString('base64');
+    expect(buildSubApps(withQuery).ios[0].url).toBe(
+      `shadowrocket://add/sub://${rocket}?remark=Nova%20Net`,
+    );
+  });
+});

+ 4 - 0
internal/sub/controller.go

@@ -13,6 +13,7 @@ import (
 	"os"
 	"path/filepath"
 	"regexp"
+	"strconv"
 	"strings"
 	"sync"
 	"time"
@@ -660,6 +661,8 @@ func (a *SUBController) subPageContext(page PageData) map[string]any {
 	if datepicker == "" {
 		datepicker = "gregorian"
 	}
+	subUpdates, _ := a.settingService.GetSubUpdates()
+	updateHours, _ := strconv.Atoi(subUpdates)
 
 	return map[string]any{
 		"sId":           page.SId,
@@ -680,6 +683,7 @@ func (a *SUBController) subPageContext(page PageData) map[string]any {
 		"subClashUrl":   page.SubClashUrl,
 		"subTitle":      page.SubTitle,
 		"subSupportUrl": page.SubSupportUrl,
+		"subUpdates":    updateHours,
 		"links":         page.Result,
 		"emails":        page.Emails,
 		"datepicker":    datepicker,

+ 3 - 0
internal/sub/info_endpoint_test.go

@@ -76,6 +76,9 @@ func TestSubInfoEndpoint_ServesStatusJSONEvenForBrowsers(t *testing.T) {
 			t.Fatalf("info payload missing %q; body=%s", key, w.Body.String())
 		}
 	}
+	if info["subUpdates"] != float64(12) {
+		t.Fatalf("subUpdates = %v, want the default 12-hour interval", info["subUpdates"])
+	}
 }
 
 func TestSubInfoEndpoint_UnknownSubIs404(t *testing.T) {

+ 14 - 2
internal/web/translation/ar-EG.json

@@ -80,7 +80,6 @@
   "somethingWentWrong": "حدث خطأ ما",
   "subscription": {
     "title": "معلومات الاشتراك",
-    "subId": "معرّف الاشتراك",
     "status": "الحالة",
     "downloaded": "التنزيل",
     "uploaded": "الرفع",
@@ -93,7 +92,20 @@
     "noExpiry": "بدون انتهاء",
     "copyAllConfigs": "نسخ جميع الإعدادات",
     "copyAllConfigsCopied": "تم نسخ جميع الإعدادات",
-    "email": "البريد"
+    "daysLeft": "الأيام المتبقية",
+    "expired": "منتهي الصلاحية",
+    "depleted": "نفدت البيانات",
+    "ofTotal": "من {total}",
+    "tabLinks": "الاشتراك",
+    "tabApps": "التطبيقات",
+    "tabConfigs": "الكونفيجات",
+    "scanTitle": "امسح بهاتفك",
+    "scanHint": "امسح هذا الرمز في تطبيق VPN لإضافة الاشتراك دون نسخ الرابط.",
+    "updateInterval": "تحديث تلقائي كل {hours} ساعة",
+    "support": "الدعم",
+    "qrTitle": "امسح. استورد. اتصل.",
+    "qrHint": "امسح الرمز بتطبيق العميل. لا تشارك هذا الرمز مع أحد.",
+    "saveQr": "حفظ QR"
   },
   "menu": {
     "theme": "الثيم",

+ 15 - 3
internal/web/translation/en-US.json

@@ -80,8 +80,6 @@
   "somethingWentWrong": "Something went wrong",
   "subscription": {
     "title": "Subscription info",
-    "subId": "Subscription ID",
-    "email": "Email",
     "status": "Status",
     "downloaded": "Downloaded",
     "uploaded": "Uploaded",
@@ -93,7 +91,21 @@
     "unlimited": "Unlimited",
     "noExpiry": "No expiry",
     "copyAllConfigs": "Copy All Configs",
-    "copyAllConfigsCopied": "All configs copied"
+    "copyAllConfigsCopied": "All configs copied",
+    "daysLeft": "Days left",
+    "expired": "Expired",
+    "depleted": "Data used up",
+    "ofTotal": "of {total}",
+    "tabLinks": "Subscription",
+    "tabApps": "Apps",
+    "tabConfigs": "Configs",
+    "scanTitle": "Scan with your phone",
+    "scanHint": "Scan this code in your VPN app to add the subscription without copying the link.",
+    "updateInterval": "Auto-updates every {hours} hours",
+    "support": "Support",
+    "qrTitle": "Scan. Import. Connect.",
+    "qrHint": "Scan with your client app. Keep this code private.",
+    "saveQr": "Save QR"
   },
   "menu": {
     "theme": "Theme",

+ 14 - 2
internal/web/translation/es-ES.json

@@ -80,7 +80,6 @@
   "somethingWentWrong": "Algo salió mal",
   "subscription": {
     "title": "Información de suscripción",
-    "subId": "ID de suscripción",
     "status": "Estado",
     "downloaded": "Descargado",
     "uploaded": "Subido",
@@ -93,7 +92,20 @@
     "noExpiry": "Sin caducidad",
     "copyAllConfigs": "Copiar Todas las Configuraciones",
     "copyAllConfigsCopied": "Todas las configuraciones copiadas",
-    "email": "Email"
+    "daysLeft": "Días restantes",
+    "expired": "Caducada",
+    "depleted": "Datos agotados",
+    "ofTotal": "de {total}",
+    "tabLinks": "Suscripción",
+    "tabApps": "Aplicaciones",
+    "tabConfigs": "Configuraciones",
+    "scanTitle": "Escanea con tu teléfono",
+    "scanHint": "Escanea este código en tu aplicación VPN para añadir la suscripción sin copiar el enlace.",
+    "updateInterval": "Se actualiza automáticamente cada {hours} horas",
+    "support": "Soporte",
+    "qrTitle": "Escanea. Importa. Conéctate.",
+    "qrHint": "Escanéalo con tu aplicación cliente. Mantén este código en privado.",
+    "saveQr": "Guardar QR"
   },
   "menu": {
     "theme": "Tema",

+ 15 - 3
internal/web/translation/fa-IR.json

@@ -80,8 +80,6 @@
   "somethingWentWrong": "مشکلی پیش آمد",
   "subscription": {
     "title": "اطلاعات سابسکریپشن",
-    "subId": "شناسه اشتراک",
-    "email": "ایمیل",
     "status": "وضعیت",
     "downloaded": "دانلود",
     "uploaded": "آپلود",
@@ -93,7 +91,21 @@
     "unlimited": "نامحدود",
     "noExpiry": "بدون انقضا",
     "copyAllConfigs": "کپی همه کانفیگ‌ها",
-    "copyAllConfigsCopied": "همه کانفیگ‌ها کپی شدند"
+    "copyAllConfigsCopied": "همه کانفیگ‌ها کپی شدند",
+    "daysLeft": "روز باقی‌مانده",
+    "expired": "منقضی شده",
+    "depleted": "حجم تمام شده",
+    "ofTotal": "از {total}",
+    "tabLinks": "اشتراک",
+    "tabApps": "اپلیکیشن‌ها",
+    "tabConfigs": "کانفیگ‌ها",
+    "scanTitle": "اسکن با گوشی",
+    "scanHint": "این کد را در اپلیکیشن VPN اسکن کنید تا اشتراک بدون کپی‌کردن لینک اضافه شود.",
+    "updateInterval": "به‌روزرسانی خودکار هر {hours} ساعت",
+    "support": "پشتیبانی",
+    "qrTitle": "اسکن. افزودن. اتصال.",
+    "qrHint": "با اپلیکیشن کلاینت اسکن کنید. این کد را به کسی ندهید.",
+    "saveQr": "ذخیره QR"
   },
   "menu": {
     "theme": "تم",

+ 14 - 2
internal/web/translation/id-ID.json

@@ -80,7 +80,6 @@
   "somethingWentWrong": "Terjadi kesalahan",
   "subscription": {
     "title": "Info langganan",
-    "subId": "ID langganan",
     "status": "Status",
     "downloaded": "Diunduh",
     "uploaded": "Diunggah",
@@ -93,7 +92,20 @@
     "noExpiry": "Tanpa kedaluwarsa",
     "copyAllConfigs": "Salin Semua Konfigurasi",
     "copyAllConfigsCopied": "Semua konfigurasi tersalin",
-    "email": "Email"
+    "daysLeft": "Sisa hari",
+    "expired": "Kedaluwarsa",
+    "depleted": "Kuota habis",
+    "ofTotal": "dari {total}",
+    "tabLinks": "Langganan",
+    "tabApps": "Aplikasi",
+    "tabConfigs": "Konfigurasi",
+    "scanTitle": "Pindai dengan ponsel",
+    "scanHint": "Pindai kode ini di aplikasi VPN untuk menambahkan langganan tanpa menyalin tautan.",
+    "updateInterval": "Diperbarui otomatis setiap {hours} jam",
+    "support": "Dukungan",
+    "qrTitle": "Pindai. Impor. Terhubung.",
+    "qrHint": "Pindai dengan aplikasi klien Anda. Jaga kerahasiaan kode ini.",
+    "saveQr": "Simpan QR"
   },
   "menu": {
     "theme": "Tema",

+ 14 - 2
internal/web/translation/ja-JP.json

@@ -80,7 +80,6 @@
   "somethingWentWrong": "エラーが発生しました",
   "subscription": {
     "title": "サブスクリプション情報",
-    "subId": "サブスクリプションID",
     "status": "ステータス",
     "downloaded": "ダウンロード",
     "uploaded": "アップロード",
@@ -93,7 +92,20 @@
     "noExpiry": "期限なし",
     "copyAllConfigs": "すべての設定をコピー",
     "copyAllConfigsCopied": "すべての設定をコピーしました",
-    "email": "メール"
+    "daysLeft": "残り日数",
+    "expired": "期限切れ",
+    "depleted": "データ上限到達",
+    "ofTotal": "/ {total}",
+    "tabLinks": "サブスクリプション",
+    "tabApps": "アプリ",
+    "tabConfigs": "設定",
+    "scanTitle": "スマートフォンでスキャン",
+    "scanHint": "VPN アプリでこのコードをスキャンすると、リンクをコピーせずにサブスクリプションを追加できます。",
+    "updateInterval": "{hours} 時間ごとに自動更新",
+    "support": "サポート",
+    "qrTitle": "スキャン。インポート。接続。",
+    "qrHint": "クライアントアプリでスキャンしてください。このコードは他人に見せないでください。",
+    "saveQr": "QR を保存"
   },
   "menu": {
     "theme": "テーマ",

+ 14 - 2
internal/web/translation/pt-BR.json

@@ -80,7 +80,6 @@
   "somethingWentWrong": "Algo deu errado",
   "subscription": {
     "title": "Informações da assinatura",
-    "subId": "ID da assinatura",
     "status": "Status",
     "downloaded": "Baixado",
     "uploaded": "Enviado",
@@ -93,7 +92,20 @@
     "noExpiry": "Sem validade",
     "copyAllConfigs": "Copiar Todas as Configurações",
     "copyAllConfigsCopied": "Todas as configurações copiadas",
-    "email": "Email"
+    "daysLeft": "Dias restantes",
+    "expired": "Expirada",
+    "depleted": "Dados esgotados",
+    "ofTotal": "de {total}",
+    "tabLinks": "Assinatura",
+    "tabApps": "Aplicativos",
+    "tabConfigs": "Configurações",
+    "scanTitle": "Escaneie com o celular",
+    "scanHint": "Escaneie este código no seu aplicativo de VPN para adicionar a assinatura sem copiar o link.",
+    "updateInterval": "Atualiza automaticamente a cada {hours} horas",
+    "support": "Suporte",
+    "qrTitle": "Escaneie. Importe. Conecte.",
+    "qrHint": "Escaneie com seu aplicativo cliente. Mantenha este código em sigilo.",
+    "saveQr": "Salvar QR"
   },
   "menu": {
     "theme": "Tema",

+ 14 - 2
internal/web/translation/ru-RU.json

@@ -80,7 +80,6 @@
   "somethingWentWrong": "Что-то пошло не так",
   "subscription": {
     "title": "Информация о подписке",
-    "subId": "ID подписки",
     "status": "Статус",
     "downloaded": "Загружено",
     "uploaded": "Отправлено",
@@ -93,7 +92,20 @@
     "noExpiry": "Бессрочно",
     "copyAllConfigs": "Копировать все конфигурации",
     "copyAllConfigsCopied": "Все конфигурации скопированы",
-    "email": "Email"
+    "daysLeft": "Осталось дней",
+    "expired": "Истёк",
+    "depleted": "Трафик исчерпан",
+    "ofTotal": "из {total}",
+    "tabLinks": "Подписка",
+    "tabApps": "Приложения",
+    "tabConfigs": "Конфиги",
+    "scanTitle": "Сканируйте телефоном",
+    "scanHint": "Отсканируйте этот код в VPN-приложении, чтобы добавить подписку без копирования ссылки.",
+    "updateInterval": "Автообновление каждые {hours} ч",
+    "support": "Поддержка",
+    "qrTitle": "Сканируйте. Импортируйте. Подключайтесь.",
+    "qrHint": "Отсканируйте в клиентском приложении. Никому не показывайте этот код.",
+    "saveQr": "Сохранить QR"
   },
   "menu": {
     "theme": "Тема",

+ 14 - 2
internal/web/translation/tr-TR.json

@@ -80,7 +80,6 @@
   "somethingWentWrong": "Bir hata oluştu",
   "subscription": {
     "title": "Abonelik Bilgisi",
-    "subId": "Abonelik Kimliği",
     "status": "Durum",
     "downloaded": "İndirilen",
     "uploaded": "Yüklenen",
@@ -93,7 +92,20 @@
     "noExpiry": "Süresiz",
     "copyAllConfigs": "Tüm Yapılandırmaları Kopyala",
     "copyAllConfigsCopied": "Tüm yapılandırmalar kopyalandı",
-    "email": "E-posta"
+    "daysLeft": "Kalan gün",
+    "expired": "Süresi doldu",
+    "depleted": "Kota doldu",
+    "ofTotal": "/ {total}",
+    "tabLinks": "Abonelik",
+    "tabApps": "Uygulamalar",
+    "tabConfigs": "Yapılandırmalar",
+    "scanTitle": "Telefonunuzla tarayın",
+    "scanHint": "Bağlantıyı kopyalamadan aboneliği eklemek için bu kodu VPN uygulamanızda tarayın.",
+    "updateInterval": "Her {hours} saatte bir otomatik güncellenir",
+    "support": "Destek",
+    "qrTitle": "Tara. İçe aktar. Bağlan.",
+    "qrHint": "İstemci uygulamanızla tarayın. Bu kodu kimseyle paylaşmayın.",
+    "saveQr": "QR'ı kaydet"
   },
   "menu": {
     "theme": "Tema",

+ 14 - 2
internal/web/translation/uk-UA.json

@@ -80,7 +80,6 @@
   "somethingWentWrong": "Щось пішло не так",
   "subscription": {
     "title": "Інформація про підписку",
-    "subId": "ID підписки",
     "status": "Статус",
     "downloaded": "Завантажено",
     "uploaded": "Відвантажено",
@@ -93,7 +92,20 @@
     "noExpiry": "Без строку",
     "copyAllConfigs": "Копіювати всі конфігурації",
     "copyAllConfigsCopied": "Всі конфігурації скопійовано",
-    "email": "Email"
+    "daysLeft": "Залишилось днів",
+    "expired": "Термін дії минув",
+    "depleted": "Трафік вичерпано",
+    "ofTotal": "з {total}",
+    "tabLinks": "Підписка",
+    "tabApps": "Застосунки",
+    "tabConfigs": "Конфіги",
+    "scanTitle": "Скануйте телефоном",
+    "scanHint": "Відскануйте цей код у VPN-застосунку, щоб додати підписку без копіювання посилання.",
+    "updateInterval": "Автооновлення кожні {hours} год",
+    "support": "Підтримка",
+    "qrTitle": "Скануйте. Імпортуйте. Підключайтеся.",
+    "qrHint": "Відскануйте в клієнтському застосунку. Нікому не показуйте цей код.",
+    "saveQr": "Зберегти QR"
   },
   "menu": {
     "theme": "Тема",

+ 14 - 2
internal/web/translation/vi-VN.json

@@ -80,7 +80,6 @@
   "somethingWentWrong": "Đã xảy ra lỗi",
   "subscription": {
     "title": "Thông tin đăng ký",
-    "subId": "ID đăng ký",
     "status": "Trạng thái",
     "downloaded": "Đã tải xuống",
     "uploaded": "Đã tải lên",
@@ -93,7 +92,20 @@
     "noExpiry": "Không hết hạn",
     "copyAllConfigs": "Sao chép tất cả cấu hình",
     "copyAllConfigsCopied": "Đã sao chép tất cả cấu hình",
-    "email": "Email"
+    "daysLeft": "Số ngày còn lại",
+    "expired": "Đã hết hạn",
+    "depleted": "Hết dung lượng",
+    "ofTotal": "trên {total}",
+    "tabLinks": "Gói đăng ký",
+    "tabApps": "Ứng dụng",
+    "tabConfigs": "Cấu hình",
+    "scanTitle": "Quét bằng điện thoại",
+    "scanHint": "Quét mã này trong ứng dụng VPN để thêm gói đăng ký mà không cần sao chép liên kết.",
+    "updateInterval": "Tự động cập nhật mỗi {hours} giờ",
+    "support": "Hỗ trợ",
+    "qrTitle": "Quét. Nhập. Kết nối.",
+    "qrHint": "Quét bằng ứng dụng khách. Không chia sẻ mã này cho người khác.",
+    "saveQr": "Lưu QR"
   },
   "menu": {
     "theme": "Chủ đề",

+ 14 - 2
internal/web/translation/zh-CN.json

@@ -80,7 +80,6 @@
   "somethingWentWrong": "出了点问题",
   "subscription": {
     "title": "订阅信息",
-    "subId": "订阅 ID",
     "status": "状态",
     "downloaded": "已下载",
     "uploaded": "已上传",
@@ -93,7 +92,20 @@
     "noExpiry": "无到期",
     "copyAllConfigs": "复制全部配置",
     "copyAllConfigsCopied": "已复制全部配置",
-    "email": "邮箱"
+    "daysLeft": "剩余天数",
+    "expired": "已过期",
+    "depleted": "流量已用完",
+    "ofTotal": "共 {total}",
+    "tabLinks": "订阅",
+    "tabApps": "应用",
+    "tabConfigs": "配置",
+    "scanTitle": "用手机扫码",
+    "scanHint": "在 VPN 应用中扫描此二维码,无需复制链接即可添加订阅。",
+    "updateInterval": "每 {hours} 小时自动更新",
+    "support": "支持",
+    "qrTitle": "扫码。导入。连接。",
+    "qrHint": "使用客户端应用扫描。请勿泄露此二维码。",
+    "saveQr": "保存二维码"
   },
   "menu": {
     "theme": "主题",

+ 14 - 2
internal/web/translation/zh-TW.json

@@ -80,7 +80,6 @@
   "somethingWentWrong": "發生錯誤",
   "subscription": {
     "title": "訂閱資訊",
-    "subId": "訂閱 ID",
     "status": "狀態",
     "downloaded": "已下載",
     "uploaded": "已上傳",
@@ -93,7 +92,20 @@
     "noExpiry": "無到期",
     "copyAllConfigs": "複製全部配置",
     "copyAllConfigsCopied": "已複製全部配置",
-    "email": "電子郵件"
+    "daysLeft": "剩餘天數",
+    "expired": "已過期",
+    "depleted": "流量已用完",
+    "ofTotal": "共 {total}",
+    "tabLinks": "訂閱",
+    "tabApps": "應用程式",
+    "tabConfigs": "設定檔",
+    "scanTitle": "用手機掃描",
+    "scanHint": "在 VPN 應用程式中掃描此 QR 碼,無需複製連結即可新增訂閱。",
+    "updateInterval": "每 {hours} 小時自動更新",
+    "support": "支援",
+    "qrTitle": "掃描。匯入。連線。",
+    "qrHint": "使用用戶端應用程式掃描。請勿外洩此 QR 碼。",
+    "saveQr": "儲存 QR 碼"
   },
   "menu": {
     "theme": "主題",