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.
This commit is contained in:
Sanaei
2026-09-16 01:17:04 +02:00
parent 01ce2bcecb
commit e26cf1d3ed
42 changed files with 1869 additions and 877 deletions
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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`,
);
});
});