Add subscription info refresh endpoint and UI stats
This commit is contained in:
@@ -4,7 +4,7 @@ import path from "node:path";
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { spawn, spawnSync } from "node:child_process";
|
import { spawn, spawnSync } from "node:child_process";
|
||||||
import { settings } from "./config.js";
|
import { settings } from "./config.js";
|
||||||
import { fetchSubscription } from "./subscription.js";
|
import { fetchSubscription, fetchSubscriptionInfo } from "./subscription.js";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import {
|
import {
|
||||||
buildGatewayConfig,
|
buildGatewayConfig,
|
||||||
@@ -1424,6 +1424,19 @@ async function handleApi(req, res) {
|
|||||||
return sendJson(res, 200, { success: true, ...parsed });
|
return sendJson(res, 200, { success: true, ...parsed });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST" && req.url === "/api/subscription/refresh-info") {
|
||||||
|
const prevState = readJson(settings.statePath, {});
|
||||||
|
if (!prevState.subscriptionUrl) {
|
||||||
|
return sendJson(res, 400, { success: false, error: "Подписка не настроена" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = await fetchSubscriptionInfo(prevState.subscriptionUrl);
|
||||||
|
writeJson(settings.statePath, { ...prevState, ...info });
|
||||||
|
const cached = readJson(settings.subscriptionCachePath, null);
|
||||||
|
if (cached) writeJson(settings.subscriptionCachePath, { ...cached, ...info });
|
||||||
|
return sendJson(res, 200, { success: true, ...info });
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === "DELETE" && req.url === "/api/subscription") {
|
if (req.method === "DELETE" && req.url === "/api/subscription") {
|
||||||
if (fs.existsSync(settings.subscriptionCachePath))
|
if (fs.existsSync(settings.subscriptionCachePath))
|
||||||
fs.rmSync(settings.subscriptionCachePath);
|
fs.rmSync(settings.subscriptionCachePath);
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export function parseSubscriptionBody(body) {
|
|||||||
return { config: parsedConfig, servers };
|
return { config: parsedConfig, servers };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSubscription(url) {
|
async function requestSubscription(url) {
|
||||||
let parsedUrl;
|
let parsedUrl;
|
||||||
try {
|
try {
|
||||||
parsedUrl = new URL(url);
|
parsedUrl = new URL(url);
|
||||||
@@ -157,6 +157,21 @@ export async function fetchSubscription(url) {
|
|||||||
throw new Error(`Subscription request failed: HTTP ${response.status}`);
|
throw new Error(`Subscription request failed: HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSubscriptionInfo(url) {
|
||||||
|
const response = await requestSubscription(url);
|
||||||
|
await response.body?.cancel();
|
||||||
|
return {
|
||||||
|
userInfo: parseUserInfo(response.headers.get('subscription-userinfo')),
|
||||||
|
fetchedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSubscription(url) {
|
||||||
|
const response = await requestSubscription(url);
|
||||||
|
|
||||||
const body = await response.text();
|
const body = await response.text();
|
||||||
const userInfo = parseUserInfo(response.headers.get('subscription-userinfo'));
|
const userInfo = parseUserInfo(response.headers.get('subscription-userinfo'));
|
||||||
const parsed = parseSubscriptionBody(body);
|
const parsed = parseSubscriptionBody(body);
|
||||||
|
|||||||
@@ -133,6 +133,12 @@ function App() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshSubscriptionInfo() {
|
||||||
|
const data = await api.subscription.refreshInfo();
|
||||||
|
setState((prev) => prev ? { ...prev, userInfo: data.userInfo, fetchedAt: data.fetchedAt } : prev);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
async function forgetSubscription() {
|
async function forgetSubscription() {
|
||||||
if (!confirm('Удалить подписку и остановить sing-box?')) return;
|
if (!confirm('Удалить подписку и остановить sing-box?')) return;
|
||||||
return withBusy('Подписка удалена', async () => {
|
return withBusy('Подписка удалена', async () => {
|
||||||
@@ -426,6 +432,7 @@ function App() {
|
|||||||
pendingTag={pendingTag}
|
pendingTag={pendingTag}
|
||||||
setPendingTag={setPendingTag}
|
setPendingTag={setPendingTag}
|
||||||
onFetchSubscription={fetchSubscription}
|
onFetchSubscription={fetchSubscription}
|
||||||
|
onRefreshSubscriptionInfo={refreshSubscriptionInfo}
|
||||||
onApply={applyServer}
|
onApply={applyServer}
|
||||||
onRestart={restartSingbox}
|
onRestart={restartSingbox}
|
||||||
onStop={() => stopSingbox(false)}
|
onStop={() => stopSingbox(false)}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export const api = {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ url }),
|
body: JSON.stringify({ url }),
|
||||||
}),
|
}),
|
||||||
|
refreshInfo: () => request("/api/subscription/refresh-info", { method: "POST" }),
|
||||||
forget: () => request("/api/subscription", { method: "DELETE" }),
|
forget: () => request("/api/subscription", { method: "DELETE" }),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import {
|
|||||||
formatConnectionDuration,
|
formatConnectionDuration,
|
||||||
localProxyUrls,
|
localProxyUrls,
|
||||||
subscriptionDomain,
|
subscriptionDomain,
|
||||||
|
subscriptionDaysLeft,
|
||||||
|
subscriptionUsage,
|
||||||
} from '../utils/clientControls.js';
|
} from '../utils/clientControls.js';
|
||||||
|
import { formatBytes } from '../utils/format.js';
|
||||||
|
|
||||||
export function ClientOverviewPage({
|
export function ClientOverviewPage({
|
||||||
state,
|
state,
|
||||||
@@ -16,6 +19,7 @@ export function ClientOverviewPage({
|
|||||||
pendingTag,
|
pendingTag,
|
||||||
setPendingTag,
|
setPendingTag,
|
||||||
onFetchSubscription,
|
onFetchSubscription,
|
||||||
|
onRefreshSubscriptionInfo,
|
||||||
onApply,
|
onApply,
|
||||||
onRestart,
|
onRestart,
|
||||||
onStop,
|
onStop,
|
||||||
@@ -27,9 +31,19 @@ export function ClientOverviewPage({
|
|||||||
const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription);
|
const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription);
|
||||||
const [pings, setPings] = useState({});
|
const [pings, setPings] = useState({});
|
||||||
const [copiedProxy, setCopiedProxy] = useState('');
|
const [copiedProxy, setCopiedProxy] = useState('');
|
||||||
|
const [refreshingInfo, setRefreshingInfo] = useState(false);
|
||||||
|
const [usageUpdated, setUsageUpdated] = useState(false);
|
||||||
|
const [serverRevealVersion, setServerRevealVersion] = useState(0);
|
||||||
|
const [serversLeaving, setServersLeaving] = useState(false);
|
||||||
const subscriptionInputRef = useRef(null);
|
const subscriptionInputRef = useRef(null);
|
||||||
|
const subscriptionRef = useRef(null);
|
||||||
const serverKey = servers.map((server) => `${server.tag}:${server.server}:${server.server_port}`).join('|');
|
const serverKey = servers.map((server) => `${server.tag}:${server.server}:${server.server_port}`).join('|');
|
||||||
const proxyUrls = localProxyUrls(state?.proxyPort);
|
const proxyUrls = localProxyUrls(state?.proxyPort);
|
||||||
|
const usage = subscriptionUsage(state?.userInfo);
|
||||||
|
const [displayedUsed, setDisplayedUsed] = useState(usage.used);
|
||||||
|
const hasUsage = Boolean(
|
||||||
|
state?.userInfo && ['upload', 'download', 'total', 'expire'].some((key) => key in state.userInfo),
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNow(Date.now());
|
setNow(Date.now());
|
||||||
@@ -74,6 +88,41 @@ export function ClientOverviewPage({
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [editingSubscription, state?.hasSubscription, subscriptionUrl]);
|
}, [editingSubscription, state?.hasSubscription, subscriptionUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editingSubscription) return undefined;
|
||||||
|
const closeOnOutsideClick = (event) => {
|
||||||
|
if (subscriptionRef.current?.contains(event.target)) return;
|
||||||
|
setSubscriptionUrl('');
|
||||||
|
setEditingSubscription(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', closeOnOutsideClick);
|
||||||
|
return () => document.removeEventListener('pointerdown', closeOnOutsideClick);
|
||||||
|
}, [editingSubscription, setSubscriptionUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!state?.hasSubscription) return undefined;
|
||||||
|
const refresh = () => onRefreshSubscriptionInfo().catch(() => {});
|
||||||
|
refresh();
|
||||||
|
const timer = setInterval(refresh, 60_000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [state?.hasSubscription]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const from = displayedUsed;
|
||||||
|
const to = usage.used;
|
||||||
|
if (from === to) return undefined;
|
||||||
|
const startedAt = performance.now();
|
||||||
|
let frame;
|
||||||
|
const tick = (now) => {
|
||||||
|
const progress = Math.min(1, (now - startedAt) / 900);
|
||||||
|
const eased = 1 - Math.pow(1 - progress, 4);
|
||||||
|
setDisplayedUsed(from + (to - from) * eased);
|
||||||
|
if (progress < 1) frame = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
frame = requestAnimationFrame(tick);
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, [usage.used]);
|
||||||
|
|
||||||
async function toggleConnection() {
|
async function toggleConnection() {
|
||||||
const action = connectionAction({ connected, selectedTag, configExists: state?.configExists });
|
const action = connectionAction({ connected, selectedTag, configExists: state?.configExists });
|
||||||
if (action?.type === 'stop') return onStop();
|
if (action?.type === 'stop') return onStop();
|
||||||
@@ -98,10 +147,30 @@ export function ClientOverviewPage({
|
|||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(proxyUrls[kind]);
|
await navigator.clipboard.writeText(proxyUrls[kind]);
|
||||||
setCopiedProxy(kind);
|
setCopiedProxy(kind);
|
||||||
setTimeout(() => setCopiedProxy(''), 1400);
|
setTimeout(() => setCopiedProxy(''), 800);
|
||||||
} catch {
|
} catch {
|
||||||
setCopiedProxy('error');
|
setCopiedProxy('error');
|
||||||
setTimeout(() => setCopiedProxy(''), 1400);
|
setTimeout(() => setCopiedProxy(''), 800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshInfo() {
|
||||||
|
const startedAt = performance.now();
|
||||||
|
setRefreshingInfo(true);
|
||||||
|
try {
|
||||||
|
await onRefreshSubscriptionInfo();
|
||||||
|
setUsageUpdated(false);
|
||||||
|
requestAnimationFrame(() => setUsageUpdated(true));
|
||||||
|
setTimeout(() => setUsageUpdated(false), 900);
|
||||||
|
setServersLeaving(true);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 420 + Math.max(0, servers.length - 1) * 90));
|
||||||
|
setServerRevealVersion((version) => version + 1);
|
||||||
|
setServersLeaving(false);
|
||||||
|
} finally {
|
||||||
|
const elapsed = performance.now() - startedAt;
|
||||||
|
const completeCyclesAt = Math.max(900, Math.ceil(elapsed / 900) * 900);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, completeCyclesAt - elapsed));
|
||||||
|
setRefreshingInfo(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,16 +191,21 @@ export function ClientOverviewPage({
|
|||||||
<path d="M12 2v10M5.6 5.6a9 9 0 1 0 12.8 0" />
|
<path d="M12 2v10M5.6 5.6a9 9 0 1 0 12.8 0" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div>
|
<div className="client-state-copy" aria-live="polite">
|
||||||
<h2 id="connection-title">{connected ? 'VPN включён' : 'VPN выключен'}</h2>
|
<h2 key={connected ? 'connected' : 'disconnected'} id="connection-title">
|
||||||
{connected && (
|
{connected ? 'VPN включён' : 'VPN выключен'}
|
||||||
<time className="client-duration">
|
</h2>
|
||||||
{formatConnectionDuration(state?.singboxStartedAt, now)}
|
<div className="client-state-detail">
|
||||||
</time>
|
{connected ? (
|
||||||
)}
|
<time key="duration" className="client-duration">
|
||||||
{!connected && (
|
{formatConnectionDuration(state?.singboxStartedAt, now)}
|
||||||
<p>{canStart ? 'Нажмите, чтобы подключиться' : 'Добавьте ссылку и выберите сервер'}</p>
|
</time>
|
||||||
)}
|
) : (
|
||||||
|
<p key="hint">
|
||||||
|
{canStart ? 'Нажмите, чтобы включить' : 'Добавьте ссылку и выберите сервер'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="client-proxies" aria-label="Локальный прокси">
|
<section className="client-proxies" aria-label="Локальный прокси">
|
||||||
@@ -151,8 +225,8 @@ export function ClientOverviewPage({
|
|||||||
aria-label={`Скопировать ${label}: ${proxyUrls[kind]}`}
|
aria-label={`Скопировать ${label}: ${proxyUrls[kind]}`}
|
||||||
onClick={() => copyProxy(kind)}
|
onClick={() => copyProxy(kind)}
|
||||||
>
|
>
|
||||||
<span>{label}</span>
|
<span className="client-copy-label">{label}</span>
|
||||||
{copiedProxy === kind && <span className="client-copy-check">✓</span>}
|
{copiedProxy === kind && <span className="client-copy-feedback">Copied</span>}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -160,18 +234,39 @@ export function ClientOverviewPage({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="client-form">
|
<div className="client-form">
|
||||||
<div className={`client-subscription ${editingSubscription ? 'is-editing' : ''}`}>
|
<div
|
||||||
<button
|
ref={subscriptionRef}
|
||||||
|
className={`client-subscription ${editingSubscription ? 'is-editing' : ''}${editingSubscription && state?.hasSubscription && !subscriptionUrl ? ' is-timing-out' : ''}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
className="client-subscription-summary"
|
className="client-subscription-summary"
|
||||||
type="button"
|
|
||||||
aria-hidden={editingSubscription}
|
aria-hidden={editingSubscription}
|
||||||
inert={editingSubscription ? true : undefined}
|
inert={editingSubscription ? true : undefined}
|
||||||
tabIndex={editingSubscription ? -1 : 0}
|
|
||||||
onClick={() => setEditingSubscription(true)}
|
|
||||||
>
|
>
|
||||||
<span>Ваша подписка</span>
|
<div className="client-subscription-heading">
|
||||||
<strong>{subscriptionDomain(state?.subscriptionHost)}</strong>
|
<span>Ваша подписка</span>
|
||||||
</button>
|
<button
|
||||||
|
className="client-subscription-refresh"
|
||||||
|
type="button"
|
||||||
|
aria-label="Обновить статистику подписки"
|
||||||
|
title="Обновить статистику"
|
||||||
|
disabled={refreshingInfo}
|
||||||
|
onClick={refreshInfo}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M21 12a9 9 0 0 0-15.2-6.5L3 8m0-5v5h5M3 12a9 9 0 0 0 15.2 6.5L21 16m0 5v-5h-5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="client-subscription-domain-button"
|
||||||
|
type="button"
|
||||||
|
tabIndex={editingSubscription ? -1 : 0}
|
||||||
|
onClick={() => setEditingSubscription(true)}
|
||||||
|
>
|
||||||
|
<strong>{subscriptionDomain(state?.subscriptionHost)}</strong>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
className="client-subscription-edit"
|
className="client-subscription-edit"
|
||||||
@@ -210,9 +305,42 @@ export function ClientOverviewPage({
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{hasUsage && (
|
||||||
|
<section className={`client-usage${usageUpdated ? ' is-updated' : ''}`} aria-label="Статистика подписки">
|
||||||
|
<span>Использовано</span>
|
||||||
|
<strong>
|
||||||
|
{formatBytes(displayedUsed)}
|
||||||
|
<small> / {usage.total ? formatBytes(usage.total) : 'без лимита'}</small>
|
||||||
|
</strong>
|
||||||
|
{usage.percent !== null && (
|
||||||
|
<div
|
||||||
|
className="client-usage-bar"
|
||||||
|
role="progressbar"
|
||||||
|
aria-label="Использованный трафик"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
aria-valuenow={Math.round(usage.percent)}
|
||||||
|
>
|
||||||
|
<i style={{ width: `${usage.percent}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="client-usage-details">
|
||||||
|
{usage.expiresAt && !Number.isNaN(usage.expiresAt.getTime()) && (
|
||||||
|
<span>
|
||||||
|
до {usage.expiresAt.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' })}
|
||||||
|
{' · '}{subscriptionDaysLeft(usage.expiresAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className="client-servers" aria-label="Серверы">
|
<section className="client-servers" aria-label="Серверы">
|
||||||
<div className="client-server-grid">
|
<div
|
||||||
{servers.map((server) => {
|
className={`client-server-grid${serversLeaving ? ' is-leaving' : ''}`}
|
||||||
|
key={`${serverKey}:${serverRevealVersion}`}
|
||||||
|
>
|
||||||
|
{servers.map((server, index) => {
|
||||||
const ping = pings[server.tag];
|
const ping = pings[server.tag];
|
||||||
const selected = server.tag === selectedTag;
|
const selected = server.tag === selectedTag;
|
||||||
const pingText = ping?.checking
|
const pingText = ping?.checking
|
||||||
@@ -229,6 +357,7 @@ export function ClientOverviewPage({
|
|||||||
key={server.tag}
|
key={server.tag}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
aria-pressed={selected}
|
aria-pressed={selected}
|
||||||
|
style={{ '--server-index': index }}
|
||||||
onClick={() => selectServer(server.tag)}
|
onClick={() => selectServer(server.tag)}
|
||||||
>
|
>
|
||||||
<strong>{server.tag}</strong>
|
<strong>{server.tag}</strong>
|
||||||
|
|||||||
@@ -893,11 +893,12 @@ code, .mono {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-power-section > div {
|
.client-state-copy {
|
||||||
min-height: 54px;
|
min-height: 54px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-power {
|
.client-power {
|
||||||
|
position: relative;
|
||||||
width: 96px;
|
width: 96px;
|
||||||
height: 96px;
|
height: 96px;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -907,12 +908,30 @@ code, .mono {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--client-muted);
|
color: var(--client-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color 600ms cubic-bezier(0.16, 1, 0.3, 1), transform 600ms cubic-bezier(0.16, 1, 0.3, 1);
|
transition: color 800ms cubic-bezier(0.16, 1, 0.3, 1), opacity 600ms ease, transform 600ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 58px;
|
||||||
|
height: 58px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, color-mix(in oklch, var(--client-accent) 42%, transparent), transparent 70%);
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.55);
|
||||||
|
transition: opacity 900ms cubic-bezier(0.16, 1, 0.3, 1), transform 900ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power[aria-checked='true']::before {
|
||||||
|
opacity: 0.75;
|
||||||
|
transform: scale(1.75);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-power:hover:not(:disabled) {
|
.client-power:hover:not(:disabled) {
|
||||||
transform: translateY(-3px) scale(1.08);
|
transform: translateY(-3px) scale(1.08);
|
||||||
color: var(--client-accent);
|
color: var(--client-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-power[aria-checked='true'] {
|
.client-power[aria-checked='true'] {
|
||||||
@@ -930,6 +949,7 @@ code, .mono {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-power svg {
|
.client-power svg {
|
||||||
|
position: relative;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
fill: none;
|
fill: none;
|
||||||
@@ -941,15 +961,27 @@ code, .mono {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-power:hover:not(:disabled) svg {
|
.client-power:hover:not(:disabled) svg {
|
||||||
|
filter: drop-shadow(0 0 6px color-mix(in oklch, var(--client-muted) 55%, transparent)) drop-shadow(0 0 18px color-mix(in oklch, var(--client-muted) 22%, transparent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power[aria-checked='true']:hover:not(:disabled) {
|
||||||
|
color: var(--client-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-power[aria-checked='true']:hover:not(:disabled) svg {
|
||||||
filter: drop-shadow(0 0 6px color-mix(in oklch, var(--client-accent) 75%, transparent)) drop-shadow(0 0 18px color-mix(in oklch, var(--client-accent) 35%, transparent));
|
filter: drop-shadow(0 0 6px color-mix(in oklch, var(--client-accent) 75%, transparent)) drop-shadow(0 0 18px color-mix(in oklch, var(--client-accent) 35%, transparent));
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-power:active:not(:disabled) svg {
|
.client-power:active:not(:disabled) svg {
|
||||||
transform: scale(0.88);
|
transform: scale(0.88);
|
||||||
filter: drop-shadow(0 0 10px var(--client-accent)) drop-shadow(0 0 26px color-mix(in oklch, var(--client-accent) 55%, transparent));
|
filter: drop-shadow(0 0 10px var(--client-muted)) drop-shadow(0 0 26px color-mix(in oklch, var(--client-muted) 45%, transparent));
|
||||||
transition-duration: 220ms;
|
transition-duration: 220ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-power[aria-checked='true']:active:not(:disabled) svg {
|
||||||
|
filter: drop-shadow(0 0 10px var(--client-accent)) drop-shadow(0 0 26px color-mix(in oklch, var(--client-accent) 55%, transparent));
|
||||||
|
}
|
||||||
|
|
||||||
.client-power[aria-checked='true'] svg {
|
.client-power[aria-checked='true'] svg {
|
||||||
filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-accent) 65%, transparent)) drop-shadow(0 0 14px color-mix(in oklch, var(--client-accent) 28%, transparent));
|
filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-accent) 65%, transparent)) drop-shadow(0 0 14px color-mix(in oklch, var(--client-accent) 28%, transparent));
|
||||||
}
|
}
|
||||||
@@ -957,11 +989,24 @@ code, .mono {
|
|||||||
.client-power-section h2 {
|
.client-power-section h2 {
|
||||||
font: 700 18px/1.3 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
font: 700 18px/1.3 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||||
letter-spacing: -0.04em;
|
letter-spacing: -0.04em;
|
||||||
|
animation: client-state-reveal 700ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-state-detail {
|
||||||
|
min-height: 20px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-state-detail > * {
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
margin: 0;
|
||||||
|
animation: client-state-reveal 850ms 80ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-duration {
|
.client-duration {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 8px;
|
|
||||||
color: var(--client-text);
|
color: var(--client-text);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
@@ -969,8 +1014,13 @@ code, .mono {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-power-section p {
|
.client-power-section p {
|
||||||
min-height: 17px;
|
min-height: 0;
|
||||||
margin-top: 5px;
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes client-state-reveal {
|
||||||
|
0% { opacity: 0; filter: blur(5px); }
|
||||||
|
100% { opacity: 1; filter: blur(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-form {
|
.client-form {
|
||||||
@@ -982,12 +1032,59 @@ code, .mono {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-subscription {
|
.client-subscription {
|
||||||
|
position: relative;
|
||||||
min-height: 88px;
|
min-height: 88px;
|
||||||
display: grid;
|
display: grid;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-subscription-refresh {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--client-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 250ms ease, filter 500ms ease, opacity 300ms ease, transform 600ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-refresh svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.7;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-refresh:hover:not(:disabled) {
|
||||||
|
color: var(--client-accent);
|
||||||
|
filter: drop-shadow(0 0 6px color-mix(in oklch, var(--client-accent) 55%, transparent));
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-refresh:disabled {
|
||||||
|
color: var(--client-accent);
|
||||||
|
cursor: wait;
|
||||||
|
animation: client-spin 900ms linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-refresh:focus-visible {
|
||||||
|
outline: 2px solid var(--client-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription.is-editing .client-subscription-refresh {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.client-subscription-edit,
|
.client-subscription-edit,
|
||||||
.client-subscription-summary {
|
.client-subscription-summary {
|
||||||
grid-area: 1 / 1;
|
grid-area: 1 / 1;
|
||||||
@@ -1030,6 +1127,21 @@ code, .mono {
|
|||||||
box-shadow: 0 0 8px var(--client-accent), 0 0 18px var(--client-accent-soft);
|
box-shadow: 0 0 8px var(--client-accent), 0 0 18px var(--client-accent-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-subscription.is-timing-out .client-subscription-edit::after {
|
||||||
|
animation: client-subscription-timeout 5s linear forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes client-subscription-timeout {
|
||||||
|
0% {
|
||||||
|
opacity: 0.9;
|
||||||
|
box-shadow: 0 0 8px var(--client-accent), 0 0 18px var(--client-accent-soft);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0.08;
|
||||||
|
box-shadow: 0 0 0 transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.client-subscription.is-editing .client-subscription-edit {
|
.client-subscription.is-editing .client-subscription-edit {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
filter: blur(0);
|
filter: blur(0);
|
||||||
@@ -1046,7 +1158,7 @@ code, .mono {
|
|||||||
outline: 0;
|
outline: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--client-text);
|
color: var(--client-text);
|
||||||
caret-color: var(--client-accent);
|
caret-color: transparent;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -1094,6 +1206,20 @@ code, .mono {
|
|||||||
color: var(--client-accent);
|
color: var(--client-accent);
|
||||||
font-size: 19px;
|
font-size: 19px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-subscription-domain-button {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1117,13 +1243,13 @@ code, .mono {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-subscription-summary span {
|
.client-subscription-heading > span {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-subscription-summary strong {
|
.client-subscription-domain-button strong {
|
||||||
color: var(--client-text);
|
color: var(--client-text);
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -1133,12 +1259,89 @@ code, .mono {
|
|||||||
transition: opacity 300ms cubic-bezier(0.16, 1, 0.3, 1), text-shadow 300ms cubic-bezier(0.16, 1, 0.3, 1), transform 300ms cubic-bezier(0.16, 1, 0.3, 1);
|
transition: opacity 300ms cubic-bezier(0.16, 1, 0.3, 1), text-shadow 300ms cubic-bezier(0.16, 1, 0.3, 1), transform 300ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-subscription-summary:hover strong {
|
.client-subscription-domain-button:hover strong {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
text-shadow: 0 0 28px oklch(0.7 0.08 151 / 0.42);
|
text-shadow: 0 0 28px oklch(0.7 0.08 151 / 0.42);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-usage {
|
||||||
|
width: min(100%, 250px);
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
margin: -10px auto 0;
|
||||||
|
color: var(--client-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-usage > span {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes client-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-usage > strong {
|
||||||
|
color: var(--client-text);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: color 700ms ease, filter 700ms ease, text-shadow 700ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-usage.is-updated {
|
||||||
|
animation: client-usage-glow 1100ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-usage.is-updated > strong {
|
||||||
|
color: var(--client-accent);
|
||||||
|
filter: brightness(1.25);
|
||||||
|
text-shadow: 0 0 8px var(--client-accent), 0 0 22px color-mix(in oklch, var(--client-accent) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-usage.is-updated .client-usage-bar i {
|
||||||
|
animation: client-bar-flare 1100ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes client-usage-glow {
|
||||||
|
30% { filter: drop-shadow(0 0 18px color-mix(in oklch, var(--client-accent) 78%, transparent)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes client-bar-flare {
|
||||||
|
30% { box-shadow: 0 0 6px var(--client-accent), 0 0 20px var(--client-accent); filter: brightness(1.45); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-usage > strong small {
|
||||||
|
color: var(--client-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-usage-bar {
|
||||||
|
height: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--client-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-usage-bar i {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--client-accent);
|
||||||
|
box-shadow: 0 0 8px var(--client-accent);
|
||||||
|
transition: width 600ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-usage-details {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 9px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.client-servers {
|
.client-servers {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@@ -1166,9 +1369,44 @@ code, .mono {
|
|||||||
color: var(--client-text);
|
color: var(--client-text);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
animation: client-server-enter 760ms calc(var(--server-index) * 110ms) cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
|
transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-server-grid.is-leaving {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-server-grid.is-leaving .client-server {
|
||||||
|
animation: client-server-leave 420ms calc(var(--server-index) * 90ms) cubic-bezier(0.4, 0, 1, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes client-server-leave {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
filter: blur(0);
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(3px);
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes client-server-enter {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(3px);
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
filter: blur(0);
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.client-server:hover:not(:disabled) {
|
.client-server:hover:not(:disabled) {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -1209,7 +1447,7 @@ code, .mono {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-subscription-edit button:focus-visible,
|
.client-subscription-edit button:focus-visible,
|
||||||
.client-subscription-summary:focus-visible,
|
.client-subscription-domain-button:focus-visible,
|
||||||
.client-server:focus-visible,
|
.client-server:focus-visible,
|
||||||
.client-power:focus-visible {
|
.client-power:focus-visible {
|
||||||
outline: 2px solid var(--client-accent);
|
outline: 2px solid var(--client-accent);
|
||||||
@@ -1259,9 +1497,26 @@ code, .mono {
|
|||||||
transition: color 220ms ease, opacity 220ms ease, transform 220ms cubic-bezier(0.16, 1, 0.3, 1);
|
transition: color 220ms ease, opacity 220ms ease, transform 220ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-copy-check {
|
.client-copy-label {
|
||||||
|
transition: opacity 100ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-proxies button.is-copied .client-copy-label {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-copy-feedback {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 10px;
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--client-accent);
|
||||||
|
animation: client-copy-fade 800ms cubic-bezier(0.4, 0, 1, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes client-copy-fade {
|
||||||
|
0%, 18% { opacity: 1; filter: drop-shadow(0 0 5px var(--client-accent)); }
|
||||||
|
100% { opacity: 0; filter: drop-shadow(0 0 0 transparent); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-proxies button:hover {
|
.client-proxies button:hover {
|
||||||
@@ -1320,13 +1575,26 @@ code, .mono {
|
|||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.client-power,
|
.client-power,
|
||||||
|
.client-power::before,
|
||||||
.client-power svg,
|
.client-power svg,
|
||||||
|
.client-usage-bar i,
|
||||||
|
.client-subscription-refresh,
|
||||||
|
.client-usage,
|
||||||
|
.client-usage > strong,
|
||||||
.client-server,
|
.client-server,
|
||||||
.client-proxies button,
|
.client-proxies button,
|
||||||
|
.client-copy-feedback,
|
||||||
.client-subscription-edit,
|
.client-subscription-edit,
|
||||||
|
.client-subscription-edit::after,
|
||||||
.client-subscription-summary,
|
.client-subscription-summary,
|
||||||
.client-subscription-summary strong {
|
.client-subscription-summary strong {
|
||||||
transition: none;
|
transition: none;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-state-copy h2,
|
||||||
|
.client-state-detail > * {
|
||||||
|
animation: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,3 +34,31 @@ export function localProxyUrls(port = 8082) {
|
|||||||
http: `http://127.0.0.1:${port}`,
|
http: `http://127.0.0.1:${port}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function subscriptionUsage(userInfo = {}) {
|
||||||
|
const upload = Math.max(0, Number(userInfo.upload) || 0);
|
||||||
|
const download = Math.max(0, Number(userInfo.download) || 0);
|
||||||
|
const total = Math.max(0, Number(userInfo.total) || 0);
|
||||||
|
const used = upload + download;
|
||||||
|
|
||||||
|
return {
|
||||||
|
upload,
|
||||||
|
download,
|
||||||
|
total,
|
||||||
|
used,
|
||||||
|
percent: total ? Math.min(100, (used / total) * 100) : null,
|
||||||
|
expiresAt: userInfo.expire ? new Date(Number(userInfo.expire) * 1000) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscriptionDaysLeft(expiresAt, now = Date.now()) {
|
||||||
|
const days = Math.ceil((expiresAt?.getTime() - now) / 86_400_000);
|
||||||
|
if (!Number.isFinite(days)) return '';
|
||||||
|
if (days <= 0) return 'срок истёк';
|
||||||
|
const mod10 = days % 10;
|
||||||
|
const mod100 = days % 100;
|
||||||
|
const unit = mod10 === 1 && mod100 !== 11
|
||||||
|
? 'день'
|
||||||
|
: mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14) ? 'дня' : 'дней';
|
||||||
|
return `${days === 1 ? 'остался' : 'осталось'} ${days} ${unit}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
formatConnectionDuration,
|
formatConnectionDuration,
|
||||||
localProxyUrls,
|
localProxyUrls,
|
||||||
subscriptionDomain,
|
subscriptionDomain,
|
||||||
|
subscriptionDaysLeft,
|
||||||
|
subscriptionUsage,
|
||||||
} from '../../src/web/utils/clientControls.js';
|
} from '../../src/web/utils/clientControls.js';
|
||||||
|
|
||||||
test('connection button chooses the only valid client action', () => {
|
test('connection button chooses the only valid client action', () => {
|
||||||
@@ -44,3 +46,21 @@ test('local proxy defaults to the macOS client port', () => {
|
|||||||
http: 'http://127.0.0.1:8082',
|
http: 'http://127.0.0.1:8082',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('subscription usage combines traffic and caps progress', () => {
|
||||||
|
assert.deepEqual(subscriptionUsage({ upload: 30, download: 80, total: 100, expire: 2 }), {
|
||||||
|
upload: 30,
|
||||||
|
download: 80,
|
||||||
|
total: 100,
|
||||||
|
used: 110,
|
||||||
|
percent: 100,
|
||||||
|
expiresAt: new Date(2000),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subscription expiry uses Russian day forms', () => {
|
||||||
|
const now = Date.parse('2026-07-11T00:00:00Z');
|
||||||
|
assert.equal(subscriptionDaysLeft(new Date('2026-07-12T00:00:00Z'), now), 'остался 1 день');
|
||||||
|
assert.equal(subscriptionDaysLeft(new Date('2026-07-13T00:00:00Z'), now), 'осталось 2 дня');
|
||||||
|
assert.equal(subscriptionDaysLeft(new Date('2026-07-16T00:00:00Z'), now), 'осталось 5 дней');
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user