refactor: project architecture refactor, container splitting
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,280 @@
|
||||
<script>
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Copy,
|
||||
Crown,
|
||||
Database,
|
||||
Download,
|
||||
Gift,
|
||||
Globe2,
|
||||
LockKeyhole,
|
||||
Mail,
|
||||
RefreshCw,
|
||||
Repeat2,
|
||||
Send,
|
||||
Ticket,
|
||||
UserRound,
|
||||
Zap,
|
||||
} from "$components/ui/icons.js";
|
||||
|
||||
import Button from "$components/ui/button.svelte";
|
||||
import Card from "$components/ui/card.svelte";
|
||||
import { LinearProgress } from "$components/patterns/webapp/index.js";
|
||||
import BackTitle from "./preview/BackTitle.svelte";
|
||||
import PhoneFrame from "./preview/PhoneFrame.svelte";
|
||||
import PreviewMethods from "./preview/PreviewMethods.svelte";
|
||||
import PreviewNav from "./preview/PreviewNav.svelte";
|
||||
|
||||
export let config = {};
|
||||
export let mockData = {};
|
||||
|
||||
const title = config.title || "/minishop";
|
||||
const logoEmoji = config.logoEmoji || "🫥";
|
||||
const plans = mockData.plans || [];
|
||||
const sub = mockData.subscription || {};
|
||||
const methods = mockData.payment_methods || [];
|
||||
const user = mockData.user || {};
|
||||
const tariffs = [
|
||||
[
|
||||
"subscription",
|
||||
"Подписка",
|
||||
"Безлимитный трафик",
|
||||
"Идеально для постоянного использования",
|
||||
Zap,
|
||||
],
|
||||
["traffic", "Трафик", "Пакеты гигабайт", "Платите только за нужный объем", Database],
|
||||
["premium", "Премиум", "Максимальная скорость", "Приоритетные серверы и поддержка", Crown],
|
||||
];
|
||||
const traffic = [
|
||||
[20, 290],
|
||||
[50, 590],
|
||||
[100, 990],
|
||||
[300, 2190],
|
||||
];
|
||||
const settingsRows = [
|
||||
[Globe2, "Язык интерфейса", "Русский"],
|
||||
[
|
||||
Send,
|
||||
"Привязка Telegram",
|
||||
user.telegram_linked ? `@${user.username || "username"}` : "Не привязан",
|
||||
],
|
||||
[Mail, "Привязка почты", user.email || "Не привязана"],
|
||||
[UserRound, "Выйти", "Завершить сессию"],
|
||||
];
|
||||
const previewTelegramName =
|
||||
user.first_name || (user.username ? `@${user.username}` : "Telegram не привязан");
|
||||
const previewEmail = user.email || "Почта не привязана";
|
||||
const previewTelegramId = user.telegram_id ? `TG ID ${user.telegram_id}` : "TG ID не привязан";
|
||||
const previewAvatar = user.telegram_photo_url || "";
|
||||
|
||||
function money(value) {
|
||||
return `${value} ₽`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="preview-board" style={`--accent: ${config.primaryColor || "#00fe7a"};`}>
|
||||
<PhoneFrame number="1" label="Главный экран">
|
||||
<main class="home-layout">
|
||||
<div class="login-brand home-brand">
|
||||
<div class="brand-mark brand-mark-xl"><span>{logoEmoji}</span></div>
|
||||
<h1>{title}</h1>
|
||||
</div>
|
||||
<div class="home-bottom">
|
||||
<Card class="status-card">
|
||||
<div class="sub-status">
|
||||
<CheckCircle2 size={23} />
|
||||
<div>
|
||||
<h2>Подписка активна</h2>
|
||||
<p>до {sub.end_date_text}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div class="traffic-top">
|
||||
<span>Использовано трафика</span><strong
|
||||
>{sub.traffic_used} из {sub.traffic_limit}</strong
|
||||
>
|
||||
</div>
|
||||
<LinearProgress value={18} />
|
||||
<div class="traffic-percent">18%</div>
|
||||
</Card>
|
||||
<div class="action-stack">
|
||||
<Button class="wide"><Download size={17} />Установить и настроить</Button>
|
||||
<Button variant="secondary" class="wide"><RefreshCw size={17} />Продлить</Button>
|
||||
<Button variant="secondary" class="wide"><Repeat2 size={17} />Сменить тариф</Button>
|
||||
</div>
|
||||
</div>
|
||||
<PreviewNav active="home" />
|
||||
</main>
|
||||
</PhoneFrame>
|
||||
|
||||
<PhoneFrame number="2" label="Выбор тарифа">
|
||||
<div class="preview-header">
|
||||
<div class="brand-row">
|
||||
<div class="brand-mark"><span>{logoEmoji}</span></div>
|
||||
<strong>{title}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tariff-list">
|
||||
{#each tariffs as tariff, index}
|
||||
<div class:active={index === 0} class="select-card">
|
||||
<span class="select-icon"><svelte:component this={tariff[4]} size={24} /></span>
|
||||
<span><strong>{tariff[1]}</strong><small>{tariff[2]}</small><em>{tariff[3]}</em></span>
|
||||
{#if index === 0}<CheckCircle2 size={21} />{:else}<Circle size={21} />{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<Button class="wide bottom-action">Далее <ArrowRight size={17} /></Button>
|
||||
</PhoneFrame>
|
||||
|
||||
<PhoneFrame number="3" label="Оплата тарифа — подписка" wide>
|
||||
<BackTitle title="Подписка" subtitle="Выберите срок подписки" />
|
||||
<div class="period-grid">
|
||||
{#each plans as plan, index}
|
||||
<div class:active={index === 1} class="period-card">
|
||||
<strong>{plan.title}</strong><span>{money(plan.price)}</span><small
|
||||
>{money(Math.round(plan.price / plan.months))}/мес</small
|
||||
>
|
||||
{#if index === 1}<CheckCircle2 size={18} />{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<Card class="total-card"
|
||||
><span>Итого<br /><small>К оплате</small></span><strong>790 ₽</strong></Card
|
||||
>
|
||||
<PreviewMethods {methods} />
|
||||
<Button class="wide bottom-action">Оплатить 790 ₽ <LockKeyhole size={16} /></Button>
|
||||
</PhoneFrame>
|
||||
|
||||
<PhoneFrame number="4" label="Оплата тарифа — трафик" wide>
|
||||
<BackTitle title="Трафик" subtitle="Выберите пакет трафика" />
|
||||
<div class="period-grid">
|
||||
{#each traffic as pack, index}
|
||||
<div class:active={index === 2} class="period-card">
|
||||
<strong>{pack[0]} ГБ</strong><span>{money(pack[1])}</span><small
|
||||
>{money(Math.round(pack[1] / pack[0]))}/ГБ</small
|
||||
>
|
||||
{#if index === 2}<CheckCircle2 size={18} />{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<Card class="total-card"
|
||||
><span>Итого<br /><small>К оплате</small></span><strong>990 ₽</strong></Card
|
||||
>
|
||||
<PreviewMethods {methods} />
|
||||
<Button class="wide bottom-action">Оплатить 990 ₽ <LockKeyhole size={16} /></Button>
|
||||
</PhoneFrame>
|
||||
|
||||
<PhoneFrame number="5" label="Смена тарифа">
|
||||
<BackTitle title="Смена тарифа" subtitle="Остаток 12 дней будет пересчитан" />
|
||||
<div class="tariff-list compact">
|
||||
<div class="select-card">
|
||||
<span><strong>Подписка</strong><small>Безлимитный трафик</small></span><em>Доплата 190 ₽</em
|
||||
><Circle size={20} />
|
||||
</div>
|
||||
<div class="select-card active">
|
||||
<span><strong>Трафик</strong><small>Пакеты гигабайт</small></span><em
|
||||
>Доплата не требуется</em
|
||||
><CheckCircle2 size={20} />
|
||||
</div>
|
||||
<div class="select-card">
|
||||
<span><strong>Премиум</strong><small>Максимальная скорость</small></span><em
|
||||
>Доплата 390 ₽</em
|
||||
><Circle size={20} />
|
||||
</div>
|
||||
</div>
|
||||
<Button class="wide bottom-action">Далее <ArrowRight size={17} /></Button>
|
||||
<div class="preview-modal">
|
||||
<Repeat2 size={30} />
|
||||
<strong>Сменить тариф без доплаты?</strong>
|
||||
<p>Остаток 12 дней будет пересчитан по новому тарифу.</p>
|
||||
<Button>Да, сменить</Button>
|
||||
<Button variant="secondary">Отмена</Button>
|
||||
</div>
|
||||
</PhoneFrame>
|
||||
|
||||
<PhoneFrame number="6" label="Пригласить друга">
|
||||
<div class="preview-header">
|
||||
<div class="brand-row">
|
||||
<div class="brand-mark"><span>{logoEmoji}</span></div>
|
||||
<strong>{title}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<div class="card-label">Ваша реферальная ссылка</div>
|
||||
<div class="copy-row">
|
||||
<code>https://minishop.app/ref/ABCD1234</code><Button>Копировать <Copy size={16} /></Button>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="bonus-card">
|
||||
<Gift size={42} />
|
||||
<div>
|
||||
<span>Ваш бонус</span><strong>+7 дней за каждого друга</strong>
|
||||
<p>Друг получит +3 дня к подписке.</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Button variant="outline" class="wide"><Ticket size={18} />Активировать промокод</Button>
|
||||
</PhoneFrame>
|
||||
|
||||
<PhoneFrame number="7" label="Настройки">
|
||||
<div class="preview-header">
|
||||
<div class="brand-row">
|
||||
<div class="brand-mark"><span>{logoEmoji}</span></div>
|
||||
<strong>{title}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<Card class="settings-profile">
|
||||
<div class="settings-avatar">
|
||||
{#if previewAvatar}
|
||||
<img src={previewAvatar} alt="Аватар пользователя" />
|
||||
{:else}
|
||||
<UserRound size={27} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="settings-profile-meta">
|
||||
<strong>{previewTelegramName}</strong>
|
||||
<small>{previewEmail}</small>
|
||||
<small>{previewTelegramId}</small>
|
||||
</div>
|
||||
</Card>
|
||||
<div class="settings-list">
|
||||
{#each settingsRows as row}
|
||||
<div class="settings-row">
|
||||
<svelte:component this={row[0]} size={20} />
|
||||
<span><strong>{row[1]}</strong><small>{row[2]}</small></span>
|
||||
<ArrowRight size={16} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</PhoneFrame>
|
||||
|
||||
<PhoneFrame number="8" label="Логин" wide>
|
||||
<div class="login-brand small">
|
||||
<div class="brand-mark brand-mark-xl"><span>{logoEmoji}</span></div>
|
||||
<h1>{title}</h1>
|
||||
<p>Войдите в свой аккаунт</p>
|
||||
</div>
|
||||
<Card class="auth-card">
|
||||
<div class="field-label">Вход по email</div>
|
||||
<div class="auth-email-stack">
|
||||
<div class="input muted">Email</div>
|
||||
<Button class="wide"><Mail size={17} />Войти по почте</Button>
|
||||
</div>
|
||||
<div class="or-line"><span></span>или<span></span></div>
|
||||
<Button variant="telegram" class="wide telegram-login-button">
|
||||
<span class="telegram-login-text"><Send size={17} />Войти через телеграм</span>
|
||||
</Button>
|
||||
</Card>
|
||||
</PhoneFrame>
|
||||
|
||||
<PhoneFrame number="9" label="Подтверждение по коду" wide>
|
||||
<BackTitle title="Подтверждение по email" subtitle="Мы отправили код на user@example.com" />
|
||||
<div class="otp-slots static">
|
||||
{#each [1, 2, 3, 4, 5, 6] as digit}<span>{digit}</span>{/each}
|
||||
</div>
|
||||
<Button class="wide bottom-action">Подтвердить</Button>
|
||||
<button class="link-button"><RefreshCw size={15} />Отправить код повторно (00:45)</button>
|
||||
</PhoneFrame>
|
||||
</div>
|
||||
@@ -0,0 +1,686 @@
|
||||
<script>
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
Coins,
|
||||
CreditCard,
|
||||
Download,
|
||||
FileText,
|
||||
Globe2,
|
||||
LayoutDashboard,
|
||||
Megaphone,
|
||||
Menu,
|
||||
Paintbrush,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Sliders,
|
||||
Sparkles,
|
||||
Tag,
|
||||
UsersRound,
|
||||
} from "$components/ui/icons.js";
|
||||
import { onMount, setContext } from "svelte";
|
||||
import { fade } from "svelte/transition";
|
||||
import { Select } from "$components/ui/primitives.js";
|
||||
import { AdminBadge, AdminButton } from "$components/patterns/admin/index.js";
|
||||
|
||||
import BrandMark from "$lib/webapp/BrandMark.svelte";
|
||||
import AdsSection from "./sections/AdsSection.svelte";
|
||||
import BroadcastSection from "./sections/BroadcastSection.svelte";
|
||||
import LogsSection from "./sections/LogsSection.svelte";
|
||||
import PaymentsSection from "./sections/PaymentsSection.svelte";
|
||||
import PromosSection from "./sections/PromosSection.svelte";
|
||||
import SettingsSection from "./sections/SettingsSection.svelte";
|
||||
import StatsSection from "./sections/StatsSection.svelte";
|
||||
import TariffEditorModal from "./sections/TariffEditorModal.svelte";
|
||||
import TariffsSection from "./sections/TariffsSection.svelte";
|
||||
import AppearanceSection from "./sections/AppearanceSection.svelte";
|
||||
import UserDetailModal from "./sections/UserDetailModal.svelte";
|
||||
import UsersSection from "./sections/UsersSection.svelte";
|
||||
import { createAdsStore } from "../lib/admin/stores/adsStore.js";
|
||||
import { createBroadcastStore } from "../lib/admin/stores/broadcastStore.js";
|
||||
import { createLogsStore } from "../lib/admin/stores/logsStore.js";
|
||||
import { createPaymentsStore } from "../lib/admin/stores/paymentsStore.js";
|
||||
import { createPromosStore } from "../lib/admin/stores/promosStore.js";
|
||||
import { createSettingsStore } from "../lib/admin/stores/settingsStore.js";
|
||||
import { createStatsStore } from "../lib/admin/stores/statsStore.js";
|
||||
import { createTariffsStore } from "../lib/admin/stores/tariffsStore.js";
|
||||
import { createThemesStore } from "../lib/admin/stores/themesStore.js";
|
||||
import { createUsersStore } from "../lib/admin/stores/usersStore.js";
|
||||
import {
|
||||
fmtDate,
|
||||
fmtDateShort,
|
||||
fmtMoney,
|
||||
paymentStatusVariant,
|
||||
trafficLeftLabel,
|
||||
trafficOfLabel,
|
||||
trafficPercentValue,
|
||||
} from "../lib/admin/format.js";
|
||||
import {
|
||||
createGravatarCache,
|
||||
userAvatarUrl,
|
||||
userDisplayName,
|
||||
userInitials,
|
||||
userSecondaryName,
|
||||
} from "../lib/admin/users.js";
|
||||
|
||||
export let api;
|
||||
export let onClose = () => {};
|
||||
export let onToast = () => {};
|
||||
export let initialSection = "stats";
|
||||
export let initialUserId = null;
|
||||
export let onSectionChange = () => {};
|
||||
export let onSettingsSaved = () => {};
|
||||
export let onTariffsSaved = () => {};
|
||||
export let onThemesSaved = () => {};
|
||||
export let brand = {};
|
||||
export let brandTitle = "/minishop";
|
||||
export let appFaviconUrl = "";
|
||||
export let appFaviconUseCustom = false;
|
||||
export let appVersion = "dev+local";
|
||||
export let appRepositoryUrl = "https://github.com/3252a8/remnawave-minishop";
|
||||
export let currentLang = "ru";
|
||||
export let languageOptions = [];
|
||||
export let languageBusy = false;
|
||||
export let onLanguageChange = () => {};
|
||||
export let t = (key, _params = {}, fallback = "") => fallback || key;
|
||||
|
||||
const at = (key, params = {}, fallback = "") => t(`admin_${key}`, params, fallback || key);
|
||||
|
||||
$: NAV_GROUPS = [
|
||||
{
|
||||
id: "overview",
|
||||
label: at("nav_overview", {}, "Обзор"),
|
||||
items: [{ id: "stats", label: at("nav_dashboard", {}, "Дашборд"), icon: LayoutDashboard }],
|
||||
},
|
||||
{
|
||||
id: "operations",
|
||||
label: at("nav_operations", {}, "Управление"),
|
||||
items: [
|
||||
{ id: "users", label: at("nav_users", {}, "Пользователи"), icon: UsersRound },
|
||||
{ id: "payments", label: at("nav_payments", {}, "Платежи"), icon: CreditCard },
|
||||
{ id: "promos", label: at("nav_promos", {}, "Промокоды"), icon: Tag },
|
||||
{ id: "ads", label: at("nav_ads", {}, "Реклама"), icon: Sparkles },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "communication",
|
||||
label: at("nav_communication", {}, "Коммуникации"),
|
||||
items: [
|
||||
{ id: "broadcast", label: at("nav_broadcast", {}, "Рассылка"), icon: Megaphone },
|
||||
{ id: "logs", label: at("nav_logs", {}, "Логи"), icon: FileText },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "system",
|
||||
label: at("nav_system", {}, "Система"),
|
||||
items: [
|
||||
{ id: "tariffs", label: at("nav_tariffs", {}, "Тарифы"), icon: Coins },
|
||||
{ id: "appearance", label: at("nav_appearance", {}, "Внешний вид"), icon: Paintbrush },
|
||||
{ id: "settings", label: at("nav_settings", {}, "Настройки"), icon: Sliders },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
$: SECTION_META = {
|
||||
stats: {
|
||||
title: at("section_stats_title", {}, "Дашборд"),
|
||||
subtitle: at(
|
||||
"section_stats_subtitle",
|
||||
{},
|
||||
"Аудитория, доходы, панель Remnawave и последние платежи"
|
||||
),
|
||||
},
|
||||
users: {
|
||||
title: at("section_users_title", {}, "Пользователи"),
|
||||
subtitle: at("section_users_subtitle", {}, "Поиск, баны и действия над аккаунтами"),
|
||||
},
|
||||
payments: {
|
||||
title: at("section_payments_title", {}, "Платежи"),
|
||||
subtitle: at("section_payments_subtitle", {}, "История транзакций и экспорт"),
|
||||
},
|
||||
promos: {
|
||||
title: at("section_promos_title", {}, "Промокоды"),
|
||||
subtitle: at("section_promos_subtitle", {}, "Создание и управление кодами"),
|
||||
},
|
||||
ads: {
|
||||
title: at("section_ads_title", {}, "Рекламные кампании"),
|
||||
subtitle: at("section_ads_subtitle", {}, "UTM-источники и атрибуция"),
|
||||
},
|
||||
broadcast: {
|
||||
title: at("section_broadcast_title", {}, "Рассылка"),
|
||||
subtitle: at("section_broadcast_subtitle", {}, "Массовая отправка сообщений в Telegram"),
|
||||
},
|
||||
logs: {
|
||||
title: at("section_logs_title", {}, "Логи активности"),
|
||||
subtitle: at("section_logs_subtitle", {}, "События пользователей и админ-действия"),
|
||||
},
|
||||
tariffs: {
|
||||
title: at("section_tariffs_title", {}, "Тарифы"),
|
||||
subtitle: at("section_tariffs_subtitle", {}, "Каталог продаж, периоды, пакеты и лимиты"),
|
||||
},
|
||||
appearance: {
|
||||
title: at("section_appearance_title", {}, "Внешний вид"),
|
||||
subtitle: at("section_appearance_subtitle", {}, "Логотип, темы и акцентные цвета Mini App"),
|
||||
},
|
||||
settings: {
|
||||
title: at("section_settings_title", {}, "Настройки приложения"),
|
||||
subtitle: at("section_settings_subtitle", {}, "Оверрайды над .env, применяются мгновенно"),
|
||||
},
|
||||
};
|
||||
|
||||
$: VALID_SECTIONS = (NAV_GROUPS || []).flatMap((group) =>
|
||||
(group.items || []).map((item) => item.id)
|
||||
);
|
||||
const normalizeSection = (value) => ((VALID_SECTIONS || []).includes(value) ? value : "stats");
|
||||
|
||||
let active = normalizeSection(initialSection);
|
||||
$: if (initialSection) {
|
||||
active = normalizeSection(initialSection);
|
||||
}
|
||||
let sidebarOpen = false;
|
||||
let isCompact = false;
|
||||
let adminLanguageMenuOpen = false;
|
||||
let adminLanguageClickGuard = false;
|
||||
let adminLanguageClickGuardArmed = false;
|
||||
let adminLanguageClickGuardTimer = null;
|
||||
let adminLanguageClickGuardArmTimer = null;
|
||||
|
||||
function readReduceMotion() {
|
||||
return (
|
||||
typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
);
|
||||
}
|
||||
|
||||
let reduceMotion = readReduceMotion();
|
||||
|
||||
function flash(text) {
|
||||
onToast(text);
|
||||
}
|
||||
|
||||
const adsStore = createAdsStore({ api, onToast: flash, at });
|
||||
const broadcastStore = createBroadcastStore({ api, onToast: flash, at });
|
||||
const logsStore = createLogsStore({ api, at });
|
||||
const paymentsStore = createPaymentsStore({ api, at });
|
||||
const promosStore = createPromosStore({ api, onToast: flash, at });
|
||||
const settingsStore = createSettingsStore({ api, onToast: flash, at });
|
||||
const statsStore = createStatsStore({ api, onToast: flash, at });
|
||||
const tariffsStore = createTariffsStore({ api, onToast: flash, onTariffsSaved, flash, at });
|
||||
const themesStore = createThemesStore({ api, onThemesSaved, flash, at });
|
||||
const usersStore = createUsersStore({ api, onToast: flash, at });
|
||||
|
||||
setContext("promosStore", promosStore);
|
||||
setContext("adsStore", adsStore);
|
||||
setContext("broadcastStore", broadcastStore);
|
||||
setContext("logsStore", logsStore);
|
||||
setContext("paymentsStore", paymentsStore);
|
||||
setContext("statsStore", statsStore);
|
||||
setContext("settingsStore", settingsStore);
|
||||
setContext("usersStore", usersStore);
|
||||
setContext("tariffsStore", tariffsStore);
|
||||
setContext("themesStore", themesStore);
|
||||
|
||||
$: usersStore.setActive(active);
|
||||
$: dirtyCount = Object.keys($settingsStore.settingsDirty || {}).length;
|
||||
$: syncBusy = $statsStore.syncBusy;
|
||||
$: settingsSaving = $settingsStore.settingsSaving;
|
||||
$: meta = SECTION_META[active] || { title: active, subtitle: "" };
|
||||
$: currentLanguageOption =
|
||||
languageOptions.find((option) => option.value === currentLang) || languageOptions[0];
|
||||
|
||||
const gravatarCache = createGravatarCache(() => usersStore.updateState({}));
|
||||
|
||||
function setActive(id) {
|
||||
const next = normalizeSection(id);
|
||||
sidebarOpen = false;
|
||||
if (active === next) return;
|
||||
active = next;
|
||||
usersStore.closeUser();
|
||||
onSectionChange(next);
|
||||
}
|
||||
|
||||
function readSectionFromPath() {
|
||||
if (typeof window === "undefined") return "stats";
|
||||
const match = window.location.pathname.match(/^\/admin\/([a-z0-9_-]+)(?:\/[^/]+)?$/i);
|
||||
return normalizeSection(match ? match[1].toLowerCase() : "stats");
|
||||
}
|
||||
|
||||
function readUserIdFromPath() {
|
||||
if (typeof window === "undefined") return null;
|
||||
const match = window.location.pathname.match(/^\/admin\/users\/(-?\d+)$/);
|
||||
return match ? Number(match[1]) : null;
|
||||
}
|
||||
|
||||
function onPopState() {
|
||||
active = readSectionFromPath();
|
||||
sidebarOpen = false;
|
||||
const userId = readUserIdFromPath();
|
||||
if (userId) {
|
||||
if (!$usersStore.openedUser || $usersStore.openedUser.user_id !== userId) {
|
||||
usersStore.openUser(userId, { skipPush: true });
|
||||
}
|
||||
} else if ($usersStore.openedUser) {
|
||||
usersStore.closeUser({ skipPush: true });
|
||||
}
|
||||
}
|
||||
|
||||
function exportPayments() {
|
||||
if (typeof window === "undefined") return;
|
||||
window.open("/api/admin/payments/export.csv", "_blank", "noopener");
|
||||
}
|
||||
|
||||
function openPaymentUserCard(userId) {
|
||||
const uid = Number(userId);
|
||||
// Synthetic email-only users use negative user_id; still a valid admin target.
|
||||
if (!Number.isFinite(uid) || uid === 0) return;
|
||||
const next = normalizeSection("users");
|
||||
sidebarOpen = false;
|
||||
if (active !== next) {
|
||||
active = next;
|
||||
usersStore.closeUser();
|
||||
onSectionChange(next);
|
||||
}
|
||||
usersStore.openUser(uid);
|
||||
}
|
||||
|
||||
function resolvedAvatarUrl(user) {
|
||||
return (
|
||||
userAvatarUrl(user) ||
|
||||
(!user?.telegram_id && user?.email ? gravatarCache.gravatarUrl(user.email) : "")
|
||||
);
|
||||
}
|
||||
|
||||
function panelStatusBadge(user) {
|
||||
const status = String(user?.panel_status || "").toLowerCase();
|
||||
if (user?.is_banned) return { label: at("status_banned", {}, "Бан"), variant: "danger" };
|
||||
switch (status) {
|
||||
case "active":
|
||||
return { label: at("status_active", {}, "Active"), variant: "success" };
|
||||
case "expired":
|
||||
return {
|
||||
label: user?.panel_status_expired_at
|
||||
? at(
|
||||
"expired_badge",
|
||||
{ date: fmtDateShort(user.panel_status_expired_at) },
|
||||
`Expired ${fmtDateShort(user.panel_status_expired_at)}`
|
||||
)
|
||||
: at("status_expired", {}, "Expired"),
|
||||
variant: "warning",
|
||||
};
|
||||
case "limited":
|
||||
return { label: at("status_limited", {}, "Limited"), variant: "warning" };
|
||||
case "disabled":
|
||||
return { label: at("status_disabled", {}, "Disabled"), variant: "muted" };
|
||||
case "bot_only":
|
||||
return { label: at("status_bot_only", {}, "Только бот"), variant: "muted" };
|
||||
default:
|
||||
return { label: status || "—", variant: "muted" };
|
||||
}
|
||||
}
|
||||
|
||||
let compactMql = null;
|
||||
function onCompactChange(event) {
|
||||
isCompact = Boolean(event?.matches);
|
||||
}
|
||||
|
||||
function clearAdminLanguageClickGuard() {
|
||||
if (adminLanguageClickGuardTimer) {
|
||||
window.clearTimeout(adminLanguageClickGuardTimer);
|
||||
adminLanguageClickGuardTimer = null;
|
||||
}
|
||||
if (adminLanguageClickGuardArmTimer) {
|
||||
window.clearTimeout(adminLanguageClickGuardArmTimer);
|
||||
adminLanguageClickGuardArmTimer = null;
|
||||
}
|
||||
adminLanguageClickGuard = false;
|
||||
adminLanguageClickGuardArmed = false;
|
||||
}
|
||||
|
||||
function setAdminLanguageMenuOpen(open) {
|
||||
adminLanguageMenuOpen = Boolean(open);
|
||||
clearAdminLanguageClickGuard();
|
||||
// Desktop doesn't need the click-guard overlay and it can block
|
||||
// option clicks in portaled select content.
|
||||
if (!isCompact) return;
|
||||
if (adminLanguageMenuOpen) {
|
||||
adminLanguageClickGuard = true;
|
||||
adminLanguageClickGuardArmTimer = window.setTimeout(() => {
|
||||
adminLanguageClickGuardArmed = true;
|
||||
adminLanguageClickGuardArmTimer = null;
|
||||
}, 220);
|
||||
return;
|
||||
}
|
||||
adminLanguageClickGuard = true;
|
||||
adminLanguageClickGuardArmed = false;
|
||||
adminLanguageClickGuardTimer = window.setTimeout(() => {
|
||||
adminLanguageClickGuard = false;
|
||||
adminLanguageClickGuardTimer = null;
|
||||
}, 260);
|
||||
}
|
||||
|
||||
function closeAdminLanguageFromGuard(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (adminLanguageClickGuardArmed) setAdminLanguageMenuOpen(false);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
reduceMotion = readReduceMotion();
|
||||
let motionMql = null;
|
||||
const onMotionChange = () => {
|
||||
reduceMotion = readReduceMotion();
|
||||
};
|
||||
if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
|
||||
motionMql = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
reduceMotion = motionMql.matches;
|
||||
motionMql.addEventListener("change", onMotionChange);
|
||||
}
|
||||
if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
|
||||
compactMql = window.matchMedia("(max-width: 720px)");
|
||||
isCompact = compactMql.matches;
|
||||
if (compactMql.addEventListener) compactMql.addEventListener("change", onCompactChange);
|
||||
else if (compactMql.addListener) compactMql.addListener(onCompactChange);
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("popstate", onPopState);
|
||||
}
|
||||
return () => {
|
||||
if (motionMql) motionMql.removeEventListener("change", onMotionChange);
|
||||
if (compactMql) {
|
||||
if (compactMql.removeEventListener)
|
||||
compactMql.removeEventListener("change", onCompactChange);
|
||||
else if (compactMql.removeListener) compactMql.removeListener(onCompactChange);
|
||||
}
|
||||
if (typeof window !== "undefined") window.removeEventListener("popstate", onPopState);
|
||||
clearAdminLanguageClickGuard();
|
||||
};
|
||||
});
|
||||
|
||||
$: sectionFade = reduceMotion ? { duration: 0 } : { duration: 200 };
|
||||
$: sidebarBackdropFade = reduceMotion ? { duration: 0 } : { duration: 180 };
|
||||
|
||||
$: if (
|
||||
active === "users" &&
|
||||
initialUserId &&
|
||||
(!$usersStore.openedUser || $usersStore.openedUser.user_id !== initialUserId)
|
||||
) {
|
||||
usersStore.openUser(initialUserId, { skipPush: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="admin-screen-wrap" class:is-sidebar-open={sidebarOpen}>
|
||||
{#if sidebarOpen}
|
||||
<button
|
||||
type="button"
|
||||
class="admin-sidebar-backdrop"
|
||||
aria-label={at("close_menu", {}, "Закрыть меню")}
|
||||
in:fade={sidebarBackdropFade}
|
||||
out:fade={sidebarBackdropFade}
|
||||
on:click={() => (sidebarOpen = false)}
|
||||
></button>
|
||||
{/if}
|
||||
{#if isCompact && (adminLanguageMenuOpen || adminLanguageClickGuard)}
|
||||
<button
|
||||
class="language-select-guard"
|
||||
class:language-select-guard--armed={adminLanguageClickGuardArmed}
|
||||
type="button"
|
||||
aria-label={t("wa_close", {}, at("close", {}, "Закрыть"))}
|
||||
on:pointerdown={closeAdminLanguageFromGuard}
|
||||
on:click={closeAdminLanguageFromGuard}
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
<aside class="admin-sidebar" aria-label={at("sidebar_navigation", {}, "Навигация админки")}>
|
||||
<div class="admin-sidebar-brand">
|
||||
<BrandMark class="admin-brand-mark" {brand} />
|
||||
<div>
|
||||
<strong class="admin-brand-title">{brandTitle}</strong>
|
||||
<small>{at("panel_title", {}, "Админ-панель")}</small>
|
||||
</div>
|
||||
<AdminButton
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={onClose}
|
||||
aria-label={at("exit", {}, "Выйти")}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</AdminButton>
|
||||
</div>
|
||||
|
||||
{#each NAV_GROUPS as group}
|
||||
<div class="admin-sidebar-section-label">{group.label}</div>
|
||||
<nav class="admin-nav" aria-label={group.label}>
|
||||
{#each group.items as item}
|
||||
<button
|
||||
type="button"
|
||||
class="admin-nav-item"
|
||||
class:active={active === item.id}
|
||||
on:click={() => setActive(item.id)}
|
||||
>
|
||||
<svelte:component this={item.icon} size={16} />
|
||||
<span>{item.label}</span>
|
||||
<span></span>
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
{/each}
|
||||
|
||||
<div class="admin-sidebar-footer">
|
||||
{#if languageOptions.length}
|
||||
<div class="admin-language-switch">
|
||||
<Globe2 size={16} />
|
||||
<Select.Root
|
||||
type="single"
|
||||
bind:open={adminLanguageMenuOpen}
|
||||
value={currentLang}
|
||||
items={languageOptions}
|
||||
disabled={languageBusy}
|
||||
onOpenChange={setAdminLanguageMenuOpen}
|
||||
onValueChange={onLanguageChange}
|
||||
>
|
||||
<Select.Trigger
|
||||
class="admin-language-trigger"
|
||||
aria-label={t("wa_settings_language", {}, at("language", {}, "Язык"))}
|
||||
>
|
||||
<span>
|
||||
<strong>{t("wa_settings_language", {}, at("language", {}, "Язык"))}</strong>
|
||||
<small>
|
||||
<span class="emoji-flag" aria-hidden="true"
|
||||
>{currentLanguageOption?.flag || "🏳️"}</span
|
||||
>
|
||||
{currentLanguageOption?.label || currentLang}
|
||||
</small>
|
||||
</span>
|
||||
<ChevronsUpDown size={14} />
|
||||
</Select.Trigger>
|
||||
<Select.Content class="language-select-content" side="top" align="start" sideOffset={8}>
|
||||
<Select.Viewport class="language-select-viewport">
|
||||
{#each languageOptions as option (option.value)}
|
||||
<Select.Item
|
||||
value={option.value}
|
||||
label={option.label}
|
||||
class="language-select-item"
|
||||
>
|
||||
<span class="language-select-item-main">
|
||||
<span class="emoji-flag" aria-hidden="true">{option.flag}</span>
|
||||
<span>{option.label}</span>
|
||||
</span>
|
||||
<Check size={15} class="language-select-item-check" />
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Viewport>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
{/if}
|
||||
<a
|
||||
class="admin-version-link"
|
||||
href={appRepositoryUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="GitHub"
|
||||
>
|
||||
<span>remnawave-minishop</span>
|
||||
<span>{appVersion || "dev+local"}</span>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="admin-content">
|
||||
<header class="admin-header">
|
||||
<div style="display:flex; align-items:center; gap:12px; min-width:0;">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-mobile-toggle"
|
||||
on:click={() => (sidebarOpen = !sidebarOpen)}
|
||||
aria-label={at("menu", {}, "Меню")}
|
||||
>
|
||||
<Menu size={18} />
|
||||
</button>
|
||||
<div class="admin-header-title">
|
||||
<h2>{meta.title}</h2>
|
||||
{#if meta.subtitle}<small>{meta.subtitle}</small>{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-header-actions">
|
||||
{#if active === "stats"}
|
||||
<AdminButton onclick={statsStore.triggerSync} disabled={syncBusy}>
|
||||
<RefreshCw size={14} />
|
||||
{syncBusy
|
||||
? at("btn_syncing", {}, "Синхронизация...")
|
||||
: at("btn_sync", {}, "Синхронизировать")}
|
||||
</AdminButton>
|
||||
{/if}
|
||||
{#if active === "payments"}
|
||||
<AdminButton onclick={exportPayments}>
|
||||
<Download size={14} /> CSV
|
||||
</AdminButton>
|
||||
{/if}
|
||||
{#if active === "promos"}
|
||||
<AdminButton variant="primary" onclick={() => promosStore.setCreateOpen(true)}>
|
||||
<Plus size={14} />
|
||||
{at("btn_create", {}, "Создать")}
|
||||
</AdminButton>
|
||||
{/if}
|
||||
{#if active === "ads"}
|
||||
<AdminButton variant="primary" onclick={() => adsStore.setCreateOpen(true)}>
|
||||
<Plus size={14} />
|
||||
{at("btn_campaign", {}, "Кампания")}
|
||||
</AdminButton>
|
||||
{/if}
|
||||
{#if active === "tariffs"}
|
||||
<AdminButton variant="primary" onclick={tariffsStore.openCreateTariff}>
|
||||
<Plus size={14} />
|
||||
{at("btn_tariff", {}, "Тариф")}
|
||||
</AdminButton>
|
||||
{/if}
|
||||
{#if active === "settings"}
|
||||
{#if dirtyCount}
|
||||
<AdminBadge variant="warning"
|
||||
>{at(
|
||||
"settings_dirty_count",
|
||||
{ count: dirtyCount },
|
||||
"Изменений: " + dirtyCount
|
||||
)}</AdminBadge
|
||||
>
|
||||
{/if}
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
onclick={() => settingsStore.saveSettings(onSettingsSaved)}
|
||||
disabled={!dirtyCount || settingsSaving}
|
||||
>
|
||||
<Save size={14} />
|
||||
{settingsSaving
|
||||
? at("btn_saving", {}, "Сохранение...")
|
||||
: at("btn_save", {}, "Сохранить")}
|
||||
</AdminButton>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="admin-main">
|
||||
{#key active}
|
||||
<div class="admin-section-stage" in:fade={sectionFade} out:fade={sectionFade}>
|
||||
{#if active === "stats"}
|
||||
<StatsSection {at} {fmtDate} {fmtDateShort} {fmtMoney} {paymentStatusVariant} />
|
||||
{/if}
|
||||
|
||||
{#if active === "users"}
|
||||
<UsersSection
|
||||
{at}
|
||||
{fmtDateShort}
|
||||
{panelStatusBadge}
|
||||
{resolvedAvatarUrl}
|
||||
{userDisplayName}
|
||||
{userInitials}
|
||||
{userSecondaryName}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if active === "payments"}
|
||||
<PaymentsSection
|
||||
{at}
|
||||
{fmtDate}
|
||||
{fmtMoney}
|
||||
{paymentStatusVariant}
|
||||
onOpenUserCard={openPaymentUserCard}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if active === "promos"}
|
||||
<PromosSection {at} {fmtDateShort} />
|
||||
{/if}
|
||||
|
||||
{#if active === "ads"}
|
||||
<AdsSection {at} {fmtMoney} />
|
||||
{/if}
|
||||
|
||||
{#if active === "broadcast"}
|
||||
<BroadcastSection {at} />
|
||||
{/if}
|
||||
|
||||
{#if active === "logs"}
|
||||
<LogsSection {at} {fmtDate} />
|
||||
{/if}
|
||||
|
||||
{#if active === "tariffs"}
|
||||
<TariffsSection {at} {fmtMoney} />
|
||||
{/if}
|
||||
|
||||
{#if active === "appearance"}
|
||||
<AppearanceSection
|
||||
{at}
|
||||
{currentLang}
|
||||
{onSettingsSaved}
|
||||
{brand}
|
||||
{appFaviconUrl}
|
||||
{appFaviconUseCustom}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if active === "settings"}
|
||||
<SettingsSection {at} {isCompact} {onSettingsSaved} {currentLang} />
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
</main>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<TariffEditorModal {at} />
|
||||
|
||||
<UserDetailModal
|
||||
{at}
|
||||
{fmtDate}
|
||||
{fmtDateShort}
|
||||
{fmtMoney}
|
||||
{resolvedAvatarUrl}
|
||||
{userDisplayName}
|
||||
{userSecondaryName}
|
||||
{userInitials}
|
||||
{paymentStatusVariant}
|
||||
{trafficPercentValue}
|
||||
{trafficLeftLabel}
|
||||
{trafficOfLabel}
|
||||
/>
|
||||
@@ -0,0 +1,155 @@
|
||||
<script>
|
||||
import { Trash2 } from "$components/ui/icons.js";
|
||||
import { getContext, onMount } from "svelte";
|
||||
import Dialog from "$components/ui/dialog.svelte";
|
||||
import {
|
||||
AdminBadge,
|
||||
AdminButton,
|
||||
AdminEmptyState,
|
||||
AdminField,
|
||||
AdminTable,
|
||||
AdminTableSkeleton,
|
||||
} from "$components/patterns/admin/index.js";
|
||||
|
||||
export let at;
|
||||
export let fmtMoney;
|
||||
|
||||
const adsStore = getContext("adsStore");
|
||||
|
||||
$: ({ ads, adsLoading, adCreateOpen, adDraft } = $adsStore);
|
||||
$: adHeaders = [
|
||||
at("id", {}, "ID"),
|
||||
at("ads_col_source", {}, "Источник"),
|
||||
at("ads_col_param", {}, "Параметр"),
|
||||
at("ads_col_cost", {}, "Стоимость"),
|
||||
at("ads_col_registrations", {}, "Регистрации"),
|
||||
at("ads_col_conversions", {}, "Конверсии"),
|
||||
at("ads_col_status", {}, "Статус"),
|
||||
at("actions", {}, "Действия"),
|
||||
];
|
||||
|
||||
onMount(() => {
|
||||
adsStore.loadAds();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="admin-table-wrap">
|
||||
{#if adsLoading}
|
||||
<AdminTableSkeleton
|
||||
headers={adHeaders}
|
||||
rows={6}
|
||||
actionColumn
|
||||
widths={["44px", "96px", "110px", "70px", "54px", "54px", "72px", "92px"]}
|
||||
/>
|
||||
{:else if !ads.length}
|
||||
<AdminEmptyState tone="card"
|
||||
><span class="admin-muted">{at("ads_empty", {}, "Кампаний нет")}</span></AdminEmptyState
|
||||
>
|
||||
{:else}
|
||||
<AdminTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{at("id", {}, "ID")}</th>
|
||||
<th>{at("ads_col_source", {}, "Источник")}</th>
|
||||
<th>{at("ads_col_param", {}, "Параметр")}</th>
|
||||
<th>{at("ads_col_cost", {}, "Стоимость")}</th>
|
||||
<th>{at("ads_col_registrations", {}, "Регистрации")}</th>
|
||||
<th>{at("ads_col_conversions", {}, "Конверсии")}</th>
|
||||
<th>{at("ads_col_status", {}, "Статус")}</th>
|
||||
<th class="admin-cell-actions">{at("actions", {}, "Действия")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each ads as ad}
|
||||
<tr>
|
||||
<td class="admin-cell-id" data-label={at("id", {}, "ID")}>#{ad.id}</td>
|
||||
<td data-label={at("ads_col_source", {}, "Источник")}>{ad.source}</td>
|
||||
<td class="admin-cell-mono" data-label={at("ads_col_param", {}, "Параметр")}
|
||||
>{ad.start_param}</td
|
||||
>
|
||||
<td data-label={at("ads_col_cost", {}, "Стоимость")}>{fmtMoney(ad.cost)}</td>
|
||||
<td data-label={at("ads_col_registrations", {}, "Регистрации")}
|
||||
>{ad.stats?.registrations ?? 0}</td
|
||||
>
|
||||
<td data-label={at("ads_col_conversions", {}, "Конверсии")}
|
||||
>{ad.stats?.conversions ?? 0}</td
|
||||
>
|
||||
<td data-label={at("ads_col_status", {}, "Статус")}>
|
||||
{#if ad.is_active}
|
||||
<AdminBadge variant="success">{at("status_active", {}, "Активна")}</AdminBadge>
|
||||
{:else}
|
||||
<AdminBadge variant="muted">{at("status_disabled", {}, "Выключена")}</AdminBadge>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="admin-cell-actions" data-label={at("actions", {}, "Действия")}>
|
||||
<AdminButton size="sm" onclick={() => adsStore.toggleAd(ad)}>
|
||||
{ad.is_active ? at("btn_disable", {}, "Выкл") : at("btn_enable", {}, "Вкл")}
|
||||
</AdminButton>
|
||||
<AdminButton size="sm" variant="danger" onclick={() => adsStore.deleteAd(ad)}>
|
||||
<Trash2 size={13} />
|
||||
</AdminButton>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</AdminTable>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={adCreateOpen}
|
||||
title={at("ad_create_title", {}, "Новая кампания")}
|
||||
closeLabel={at("close", {}, "Закрыть")}
|
||||
onclose={() => adsStore.setCreateOpen(false)}
|
||||
class="admin-dialog admin-dialog-compact"
|
||||
>
|
||||
<div class="admin-form" data-dialog-content>
|
||||
<div class="admin-dialog-form-section">
|
||||
<AdminField label={at("ad_label_source", {}, "Источник")}>
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder="telegram_ads"
|
||||
value={adDraft.source}
|
||||
on:input={(e) => adsStore.updateDraft({ source: e.target.value })}
|
||||
/>
|
||||
</AdminField>
|
||||
<AdminField
|
||||
label={at("ad_label_param", {}, "start-параметр")}
|
||||
hint={at("ad_hint_param", {}, "Передаётся в /start, должен быть уникален")}
|
||||
>
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder="ads_summer25"
|
||||
value={adDraft.start_param}
|
||||
on:input={(e) => adsStore.updateDraft({ start_param: e.target.value })}
|
||||
/>
|
||||
</AdminField>
|
||||
</div>
|
||||
<div class="admin-dialog-form-section">
|
||||
<AdminField label={at("ad_label_cost", {}, "Стоимость, RUB")}>
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={adDraft.cost}
|
||||
on:input={(e) => adsStore.updateDraft({ cost: Number(e.target.value) })}
|
||||
/>
|
||||
</AdminField>
|
||||
</div>
|
||||
<div class="admin-dialog-actions">
|
||||
<AdminButton onclick={() => adsStore.setCreateOpen(false)}
|
||||
>{at("btn_cancel", {}, "Отмена")}</AdminButton
|
||||
>
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
onclick={adsStore.createAd}
|
||||
disabled={!adDraft.source.trim() || !adDraft.start_param.trim()}
|
||||
>
|
||||
{at("btn_create", {}, "Создать")}
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,64 @@
|
||||
<script>
|
||||
import { Send } from "$components/ui/icons.js";
|
||||
import { getContext } from "svelte";
|
||||
import { Label } from "$components/ui/primitives.js";
|
||||
import { AdminButton, AdminSelect } from "$components/patterns/admin/index.js";
|
||||
|
||||
export let at;
|
||||
const broadcastStore = getContext("broadcastStore");
|
||||
|
||||
$: ({ broadcastTarget, broadcastText, broadcastBusy, broadcastResult } = $broadcastStore);
|
||||
|
||||
const BROADCAST_TARGET_OPTIONS = broadcastStore.BROADCAST_TARGET_OPTIONS;
|
||||
</script>
|
||||
|
||||
<div class="admin-card">
|
||||
<header class="admin-card-head">
|
||||
<h3>{at("broadcast_title", {}, "Рассылка")}</h3>
|
||||
<small>{at("broadcast_subtitle", {}, "Доставка через очередь сообщений")}</small>
|
||||
</header>
|
||||
<div class="admin-card-body">
|
||||
<div class="admin-form">
|
||||
<Label.Root class="admin-field-label">
|
||||
<span>{at("broadcast_label_audience", {}, "Аудитория")}</span>
|
||||
<AdminSelect
|
||||
value={broadcastTarget}
|
||||
items={BROADCAST_TARGET_OPTIONS}
|
||||
ariaLabel={at("broadcast_label_audience", {}, "Аудитория")}
|
||||
onValueChange={(value) => broadcastStore.updateField({ broadcastTarget: value })}
|
||||
/>
|
||||
</Label.Root>
|
||||
<Label.Root class="admin-field-label">
|
||||
<span>{at("broadcast_label_text", {}, "Текст сообщения")}</span>
|
||||
<small>{at("broadcast_hint_text", {}, "Поддерживается HTML-разметка Telegram")}</small>
|
||||
<textarea
|
||||
class="admin-textarea"
|
||||
rows="6"
|
||||
value={broadcastText}
|
||||
on:input={(e) => broadcastStore.updateField({ broadcastText: e.target.value })}
|
||||
></textarea>
|
||||
</Label.Root>
|
||||
<div style="display:flex; gap:8px; align-items:center;">
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
onclick={broadcastStore.runBroadcast}
|
||||
disabled={broadcastBusy || !broadcastText.trim()}
|
||||
>
|
||||
<Send size={14} />
|
||||
{broadcastBusy
|
||||
? at("btn_sending", {}, "Отправка...")
|
||||
: at("btn_queue", {}, "Поставить в очередь")}
|
||||
</AdminButton>
|
||||
{#if broadcastResult}
|
||||
<span class="admin-muted"
|
||||
>{at("broadcast_stat_queued", {}, "В очереди")}: {broadcastResult.queued} · {at(
|
||||
"broadcast_stat_failed",
|
||||
{},
|
||||
"Неудач"
|
||||
)}: {broadcastResult.failed}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,119 @@
|
||||
<script>
|
||||
import { getContext, onMount } from "svelte";
|
||||
import {
|
||||
AdminButton,
|
||||
AdminEmptyState,
|
||||
AdminPagination,
|
||||
AdminTable,
|
||||
AdminTableSkeleton,
|
||||
} from "$components/patterns/admin/index.js";
|
||||
|
||||
export let at;
|
||||
export let fmtDate;
|
||||
|
||||
const logsStore = getContext("logsStore");
|
||||
|
||||
$: ({ logs, logsTotal, logsPage, logsUserFilter, logsLoading } = $logsStore);
|
||||
|
||||
$: logsHasMore = logs.length > 0 && logsTotal > (logsPage + 1) * 50; // 50 is LOGS_PAGE_SIZE
|
||||
$: logHeaders = [
|
||||
at("date", {}, "Дата"),
|
||||
at("event", {}, "Событие"),
|
||||
at("user_short", {}, "User"),
|
||||
at("target_short", {}, "Target"),
|
||||
at("content", {}, "Контент"),
|
||||
];
|
||||
|
||||
onMount(() => {
|
||||
logsStore.loadLogs();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="admin-toolbar admin-toolbar-card">
|
||||
<div class="admin-toolbar-search admin-toolbar-search-actions">
|
||||
<input
|
||||
type="search"
|
||||
class="input"
|
||||
placeholder={at("logs_user_filter_placeholder", {}, "Фильтр по ID пользователя")}
|
||||
value={logsUserFilter}
|
||||
on:input={(e) => logsStore.setFilter(e.target.value)}
|
||||
on:keydown={(e) => e.key === "Enter" && logsStore.setPage(0)}
|
||||
/>
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
onclick={() => {
|
||||
logsStore.setPage(0);
|
||||
}}>{at("apply", {}, "Применить")}</AdminButton
|
||||
>
|
||||
<AdminButton
|
||||
variant="ghost"
|
||||
onclick={() => {
|
||||
logsStore.setFilter("");
|
||||
logsStore.setPage(0);
|
||||
}}>{at("reset", {}, "Сбросить")}</AdminButton
|
||||
>
|
||||
</div>
|
||||
<div class="admin-toolbar-summary">
|
||||
<span class="admin-toolbar-field-label">{at("total", {}, "Всего")}</span>
|
||||
<strong>{logsTotal}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-table-wrap">
|
||||
{#if logsLoading}
|
||||
<AdminTableSkeleton
|
||||
headers={logHeaders}
|
||||
rows={10}
|
||||
widths={["120px", "120px", "58px", "58px", "220px"]}
|
||||
/>
|
||||
{:else if !logs.length}
|
||||
<AdminEmptyState tone="card"
|
||||
><span class="admin-muted">{at("logs_empty", {}, "Записей нет")}</span></AdminEmptyState
|
||||
>
|
||||
{:else}
|
||||
<AdminTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{at("date", {}, "Дата")}</th>
|
||||
<th>{at("event", {}, "Событие")}</th>
|
||||
<th>{at("user_short", {}, "User")}</th>
|
||||
<th>{at("target_short", {}, "Target")}</th>
|
||||
<th>{at("content", {}, "Контент")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each logs as entry}
|
||||
<tr>
|
||||
<td data-label={at("date", {}, "Дата")}>{fmtDate(entry.timestamp)}</td>
|
||||
<td class="admin-cell-mono" data-label={at("event", {}, "Событие")}
|
||||
>{entry.event_type}</td
|
||||
>
|
||||
<td class="admin-cell-mono" data-label={at("user_short", {}, "User")}
|
||||
>{entry.user_id || "—"}</td
|
||||
>
|
||||
<td class="admin-cell-mono" data-label={at("target_short", {}, "Target")}
|
||||
>{entry.target_user_id || "—"}</td
|
||||
>
|
||||
<td class="admin-cell-wrap" data-label={at("content", {}, "Контент")}
|
||||
>{entry.content || ""}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</AdminTable>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<AdminPagination
|
||||
meta={`${at("page_short", {}, "Стр.")} ${logsPage + 1}`}
|
||||
prevLabel={at("back", {}, "Назад")}
|
||||
nextLabel={at("next", {}, "Далее")}
|
||||
prevDisabled={logsPage === 0}
|
||||
nextDisabled={!logsHasMore}
|
||||
onPrev={() => {
|
||||
logsStore.setPage(Math.max(0, logsPage - 1));
|
||||
}}
|
||||
onNext={() => {
|
||||
logsStore.setPage(logsPage + 1);
|
||||
}}
|
||||
/>
|
||||
@@ -0,0 +1,216 @@
|
||||
<script>
|
||||
import { getContext, onMount } from "svelte";
|
||||
import {
|
||||
AdminBadge,
|
||||
AdminButton,
|
||||
AdminEmptyState,
|
||||
AdminPagination,
|
||||
AdminTable,
|
||||
AdminTableSkeleton,
|
||||
} from "$components/patterns/admin/index.js";
|
||||
import { User } from "$components/ui/icons.js";
|
||||
|
||||
export let at = (key) => key;
|
||||
export let fmtDate = (value) => value;
|
||||
export let fmtMoney = (value) => value;
|
||||
export let paymentStatusVariant = () => "muted";
|
||||
export let onOpenUserCard = () => {};
|
||||
|
||||
const paymentsStore = getContext("paymentsStore");
|
||||
|
||||
$: ({ payments, paymentsTotal, paymentsPage, paymentsLoading } = $paymentsStore);
|
||||
|
||||
$: paymentsHasMore = payments.length > 0 && paymentsTotal > (paymentsPage + 1) * 25; // 25 is PAYMENTS_PAGE_SIZE
|
||||
|
||||
/** @param {number|null|undefined} v */
|
||||
function formatTrafficGbCell(v) {
|
||||
if (v == null || v === "") return "—";
|
||||
const n = Number(v);
|
||||
if (Number.isNaN(n)) return "—";
|
||||
let s;
|
||||
if (Math.abs(n - Math.round(n)) < 1e-9) {
|
||||
s = String(Math.round(n));
|
||||
} else {
|
||||
s = String(Math.round(n * 100) / 100);
|
||||
}
|
||||
return `${s} GB`;
|
||||
}
|
||||
|
||||
/** @param {number|null|undefined} v */
|
||||
function formatGbAmountPlain(v) {
|
||||
if (v == null || v === "") return "";
|
||||
const n = Number(v);
|
||||
if (Number.isNaN(n)) return "";
|
||||
if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n));
|
||||
return String(Math.round(n * 100) / 100);
|
||||
}
|
||||
|
||||
/** @param {Record<string, unknown>} p */
|
||||
function paymentDescriptionDisplay(p) {
|
||||
const r = p.traffic_regular_gb;
|
||||
const pr = p.traffic_premium_gb;
|
||||
if (r != null && pr == null) {
|
||||
const gb = formatGbAmountPlain(r);
|
||||
return at(
|
||||
"payments_desc_traffic_package_regular",
|
||||
{ gb },
|
||||
`Пакет трафика ${gb} ГБ (обычный)`
|
||||
);
|
||||
}
|
||||
if (pr != null && r == null) {
|
||||
const gb = formatGbAmountPlain(pr);
|
||||
return at(
|
||||
"payments_desc_traffic_package_premium",
|
||||
{ gb },
|
||||
`Пакет трафика ${gb} ГБ (премиум)`
|
||||
);
|
||||
}
|
||||
const raw = p.description && String(p.description).trim();
|
||||
return raw || "—";
|
||||
}
|
||||
|
||||
$: paymentHeaders = [
|
||||
at("id", {}, "ID"),
|
||||
at("user", {}, "Пользователь"),
|
||||
at("payments_col_user_id", {}, "ID"),
|
||||
at("payments_col_traffic_regular", {}, "Основной трафик"),
|
||||
at("payments_col_traffic_premium", {}, "Премиум"),
|
||||
at("amount", {}, "Сумма"),
|
||||
at("provider", {}, "Провайдер"),
|
||||
at("description", {}, "Описание"),
|
||||
at("status", {}, "Статус"),
|
||||
at("date", {}, "Дата"),
|
||||
];
|
||||
|
||||
onMount(() => {
|
||||
paymentsStore.loadPayments();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="admin-table-wrap">
|
||||
{#if paymentsLoading}
|
||||
<AdminTableSkeleton
|
||||
headers={paymentHeaders}
|
||||
rows={8}
|
||||
widths={["48px", "148px", "88px", "72px", "72px", "78px", "82px", "140px", "72px", "96px"]}
|
||||
/>
|
||||
{:else if !payments.length}
|
||||
<AdminEmptyState tone="card"
|
||||
><span class="admin-muted">{at("payments_empty", {}, "Нет платежей")}</span></AdminEmptyState
|
||||
>
|
||||
{:else}
|
||||
<AdminTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{at("id", {}, "ID")}</th>
|
||||
<th>{at("user", {}, "Пользователь")}</th>
|
||||
<th>{at("payments_col_user_id", {}, "ID")}</th>
|
||||
<th>{at("payments_col_traffic_regular", {}, "Основной трафик")}</th>
|
||||
<th>{at("payments_col_traffic_premium", {}, "Премиум")}</th>
|
||||
<th>{at("amount", {}, "Сумма")}</th>
|
||||
<th>{at("provider", {}, "Провайдер")}</th>
|
||||
<th>{at("description", {}, "Описание")}</th>
|
||||
<th>{at("status", {}, "Статус")}</th>
|
||||
<th>{at("date", {}, "Дата")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each payments as p}
|
||||
<tr>
|
||||
<td class="admin-cell-id" data-label="ID">#{p.payment_id}</td>
|
||||
<td class="admin-cell-user-with-action" data-label={at("user", {}, "Пользователь")}>
|
||||
<span class="admin-payments-user-cell">
|
||||
<AdminButton
|
||||
class="admin-payments-user-btn"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={at("payments_open_user", {}, "Открыть карточку пользователя")}
|
||||
aria-label={at("payments_open_user", {}, "Открыть карточку пользователя")}
|
||||
onclick={() => onOpenUserCard(p.user_id)}
|
||||
>
|
||||
<User size={14} />
|
||||
</AdminButton>
|
||||
<span class="admin-payments-user-name">{p.user_label || p.user_id}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="admin-cell-mono" data-label={at("payments_col_user_id", {}, "ID")}>
|
||||
{p.user_id != null && p.user_id !== "" ? p.user_id : "—"}
|
||||
</td>
|
||||
<td
|
||||
class="admin-cell-traffic-gb"
|
||||
data-label={at("payments_col_traffic_regular", {}, "Основной трафик")}
|
||||
>
|
||||
{formatTrafficGbCell(p.traffic_regular_gb)}
|
||||
</td>
|
||||
<td
|
||||
class="admin-cell-traffic-gb"
|
||||
data-label={at("payments_col_traffic_premium", {}, "Премиум")}
|
||||
>
|
||||
{formatTrafficGbCell(p.traffic_premium_gb)}
|
||||
</td>
|
||||
<td data-label={at("amount", {}, "Сумма")}>{fmtMoney(p.amount, p.currency)}</td>
|
||||
<td data-label={at("provider", {}, "Провайдер")}>{p.provider}</td>
|
||||
<td class="admin-cell-wrap" data-label={at("description", {}, "Описание")}
|
||||
>{paymentDescriptionDisplay(p)}</td
|
||||
>
|
||||
<td data-label={at("status", {}, "Статус")}>
|
||||
<AdminBadge variant={paymentStatusVariant(p.status)}>{p.status}</AdminBadge>
|
||||
</td>
|
||||
<td data-label={at("date", {}, "Дата")}>{fmtDate(p.created_at)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</AdminTable>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<AdminPagination
|
||||
meta={`${at("page_short", {}, "Стр.")} ${paymentsPage + 1} · ${at("total", {}, "Всего")} ${paymentsTotal}`}
|
||||
prevLabel={at("back", {}, "Назад")}
|
||||
nextLabel={at("next", {}, "Далее")}
|
||||
prevDisabled={paymentsPage === 0}
|
||||
nextDisabled={!paymentsHasMore}
|
||||
onPrev={() => {
|
||||
paymentsStore.setPage(Math.max(0, paymentsPage - 1));
|
||||
}}
|
||||
onNext={() => {
|
||||
paymentsStore.setPage(paymentsPage + 1);
|
||||
}}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.admin-payments-user-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-payments-user-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-cell-user-with-action :global(.admin-payments-user-btn.admin-btn) {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
min-width: 30px;
|
||||
min-height: 30px;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.admin-cell-user-with-action :global(.admin-payments-user-btn svg) {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.admin-cell-traffic-gb {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,166 @@
|
||||
<script>
|
||||
import { Trash2 } from "$components/ui/icons.js";
|
||||
import { getContext, onMount } from "svelte";
|
||||
import Dialog from "$components/ui/dialog.svelte";
|
||||
import {
|
||||
AdminBadge,
|
||||
AdminButton,
|
||||
AdminEmptyState,
|
||||
AdminField,
|
||||
AdminTable,
|
||||
AdminTableSkeleton,
|
||||
} from "$components/patterns/admin/index.js";
|
||||
|
||||
export let at;
|
||||
export let fmtDateShort;
|
||||
|
||||
const promosStore = getContext("promosStore");
|
||||
|
||||
$: ({ promos, promosTotal, promosPage, promosLoading, promoCreateOpen, promoDraft } =
|
||||
$promosStore);
|
||||
|
||||
$: promosHasMore = promos.length < promosTotal;
|
||||
$: promoHeaders = [
|
||||
at("promo_col_code", {}, "Код"),
|
||||
at("promo_col_bonus", {}, "Бонус"),
|
||||
at("promo_col_activations", {}, "Активаций"),
|
||||
at("promo_col_valid_until", {}, "Действует до"),
|
||||
at("promo_col_status", {}, "Статус"),
|
||||
at("actions", {}, "Действия"),
|
||||
];
|
||||
|
||||
onMount(() => {
|
||||
promosStore.loadPromos();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="admin-table-wrap">
|
||||
{#if promosLoading}
|
||||
<AdminTableSkeleton
|
||||
headers={promoHeaders}
|
||||
rows={6}
|
||||
actionColumn
|
||||
widths={["92px", "52px", "64px", "96px", "72px", "92px"]}
|
||||
/>
|
||||
{:else if !promos.length}
|
||||
<AdminEmptyState tone="card"
|
||||
><span class="admin-muted">{at("promos_empty", {}, "Промокодов нет")}</span></AdminEmptyState
|
||||
>
|
||||
{:else}
|
||||
<AdminTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{at("promo_col_code", {}, "Код")}</th>
|
||||
<th>{at("promo_col_bonus", {}, "Бонус")}</th>
|
||||
<th>{at("promo_col_activations", {}, "Активаций")}</th>
|
||||
<th>{at("promo_col_valid_until", {}, "Действует до")}</th>
|
||||
<th>{at("promo_col_status", {}, "Статус")}</th>
|
||||
<th class="admin-cell-actions">{at("actions", {}, "Действия")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each promos as p}
|
||||
<tr>
|
||||
<td class="admin-cell-mono" data-label={at("promo_col_code", {}, "Код")}>{p.code}</td>
|
||||
<td data-label={at("promo_col_bonus", {}, "Бонус")}
|
||||
>+{p.bonus_days} {at("days_short", {}, "дн.")}</td
|
||||
>
|
||||
<td data-label={at("promo_col_activations", {}, "Активаций")}
|
||||
>{p.current_activations}/{p.max_activations}</td
|
||||
>
|
||||
<td data-label={at("promo_col_valid_until", {}, "Действует до")}
|
||||
>{p.valid_until ? fmtDateShort(p.valid_until) : "∞"}</td
|
||||
>
|
||||
<td data-label={at("promo_col_status", {}, "Статус")}>
|
||||
{#if p.is_active}
|
||||
<AdminBadge variant="success">{at("status_active", {}, "Активен")}</AdminBadge>
|
||||
{:else}
|
||||
<AdminBadge variant="muted">{at("status_disabled", {}, "Выключен")}</AdminBadge>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="admin-cell-actions" data-label={at("actions", {}, "Действия")}>
|
||||
<AdminButton size="sm" onclick={() => promosStore.togglePromo(p)}>
|
||||
{p.is_active ? at("btn_disable", {}, "Выкл") : at("btn_enable", {}, "Вкл")}
|
||||
</AdminButton>
|
||||
<AdminButton size="sm" variant="danger" onclick={() => promosStore.deletePromo(p)}>
|
||||
<Trash2 size={13} />
|
||||
</AdminButton>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</AdminTable>
|
||||
{/if}
|
||||
{#if promosHasMore}
|
||||
<div style="padding: 12px; text-align: center;">
|
||||
<AdminButton onclick={() => promosStore.setPage(promosPage + 1)}
|
||||
>{at("btn_show_more", {}, "Показать еще")}</AdminButton
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={promoCreateOpen}
|
||||
title={at("promo_create_title", {}, "Создать промокод")}
|
||||
closeLabel={at("close", {}, "Закрыть")}
|
||||
onclose={() => promosStore.setCreateOpen(false)}
|
||||
class="admin-dialog admin-dialog-compact"
|
||||
>
|
||||
<div class="admin-form" data-dialog-content>
|
||||
<div class="admin-dialog-form-section">
|
||||
<AdminField label={at("promo_label_code", {}, "Код")}>
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
value={promoDraft.code}
|
||||
on:input={(e) => promosStore.updateDraft({ code: e.target.value })}
|
||||
placeholder="FREE-7-DAYS"
|
||||
/>
|
||||
</AdminField>
|
||||
</div>
|
||||
<div class="admin-dialog-form-section">
|
||||
<div class="admin-form-row-2">
|
||||
<AdminField label={at("promo_label_bonus_days", {}, "Бонус (дней)")}>
|
||||
<input
|
||||
type="number"
|
||||
class="input"
|
||||
min="1"
|
||||
value={promoDraft.bonus_days}
|
||||
on:input={(e) => promosStore.updateDraft({ bonus_days: Number(e.target.value) })}
|
||||
/>
|
||||
</AdminField>
|
||||
<AdminField label={at("promo_label_max_activations", {}, "Макс. активаций")}>
|
||||
<input
|
||||
type="number"
|
||||
class="input"
|
||||
min="1"
|
||||
value={promoDraft.max_activations}
|
||||
on:input={(e) => promosStore.updateDraft({ max_activations: Number(e.target.value) })}
|
||||
/>
|
||||
</AdminField>
|
||||
</div>
|
||||
<AdminField label={at("promo_label_valid_days", {}, "Срок действия (дней от текущего)")}>
|
||||
<input
|
||||
type="number"
|
||||
class="input"
|
||||
min="1"
|
||||
value={promoDraft.valid_days}
|
||||
on:input={(e) => promosStore.updateDraft({ valid_days: Number(e.target.value) })}
|
||||
/>
|
||||
</AdminField>
|
||||
</div>
|
||||
<div class="admin-dialog-actions">
|
||||
<AdminButton onclick={() => promosStore.setCreateOpen(false)}
|
||||
>{at("btn_cancel", {}, "Отмена")}</AdminButton
|
||||
>
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
onclick={promosStore.createPromo}
|
||||
disabled={!promoDraft.code.trim()}
|
||||
>
|
||||
{at("btn_create", {}, "Создать")}
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
@@ -0,0 +1,362 @@
|
||||
<script>
|
||||
import { ChevronRight, Eye, EyeOff, X } from "$components/ui/icons.js";
|
||||
import { Accordion, Switch } from "$components/ui/primitives.js";
|
||||
import {
|
||||
AdminBadge,
|
||||
AdminButton,
|
||||
AdminEmptyState,
|
||||
AdminSelect,
|
||||
} from "$components/patterns/admin/index.js";
|
||||
import { getContext, onMount } from "svelte";
|
||||
|
||||
export let at;
|
||||
export let onSettingsSaved;
|
||||
export let isCompact = false;
|
||||
export let currentLang = "ru";
|
||||
|
||||
const settingsStore = getContext("settingsStore");
|
||||
|
||||
$: ({ settingsSections, settingsLoading, settingsDirty, settingsSaving } = $settingsStore);
|
||||
$: visibleSettingsSections = settingsSections.filter((section) => section.id !== "appearance");
|
||||
|
||||
let settingsOpenSections = [];
|
||||
let settingsOpenSubsections = {};
|
||||
let revealedSecrets = new Set();
|
||||
|
||||
$: settingsAllOpen =
|
||||
visibleSettingsSections.length > 0 &&
|
||||
settingsOpenSections.length === visibleSettingsSections.length;
|
||||
|
||||
onMount(() => {
|
||||
settingsStore.loadSettings().then(() => {
|
||||
if ($settingsStore.settingsSections.length) {
|
||||
const ids = $settingsStore.settingsSections
|
||||
.filter((s) => s.id !== "appearance")
|
||||
.map((s) => s.id);
|
||||
settingsOpenSections = isCompact ? ids.slice(0, 1) : ids.slice();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function toggleAllSections() {
|
||||
if (settingsOpenSections.length === visibleSettingsSections.length) {
|
||||
settingsOpenSections = [];
|
||||
} else {
|
||||
settingsOpenSections = visibleSettingsSections.map((s) => s.id);
|
||||
}
|
||||
}
|
||||
|
||||
function valueFor(field) {
|
||||
if (settingsDirty[field.key]?.deleted) return "";
|
||||
if (Object.prototype.hasOwnProperty.call(settingsDirty, field.key)) {
|
||||
return settingsDirty[field.key].value;
|
||||
}
|
||||
return field.value ?? "";
|
||||
}
|
||||
|
||||
function isOverridden(field) {
|
||||
return Boolean(field.overridden) && !settingsDirty[field.key]?.deleted;
|
||||
}
|
||||
|
||||
function isSecretRevealed(key) {
|
||||
return revealedSecrets.has(key);
|
||||
}
|
||||
|
||||
function toggleSecretReveal(key) {
|
||||
const next = new Set(revealedSecrets);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
revealedSecrets = next;
|
||||
}
|
||||
|
||||
function secretPlaceholder(field) {
|
||||
if (settingsDirty[field.key]?.deleted) return field.placeholder || "••••••••";
|
||||
if (field.has_value) return at("settings_secret_configured", {}, "Secret is set");
|
||||
return field.placeholder || at("settings_secret_empty", {}, "Not set");
|
||||
}
|
||||
|
||||
function groupSectionFields(section) {
|
||||
const groups = new Map();
|
||||
for (const field of section.fields || []) {
|
||||
const key = field.subsection || "_root";
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(field);
|
||||
}
|
||||
return Array.from(groups.entries()).map(([id, fields]) => ({
|
||||
id,
|
||||
label: id === "_root" ? null : id,
|
||||
fields,
|
||||
}));
|
||||
}
|
||||
|
||||
function sectionTitle(id) {
|
||||
const map = {
|
||||
general: at("settings_section_general", {}, "Общие"),
|
||||
appearance: at("settings_section_appearance", {}, "Внешний вид"),
|
||||
pricing: at("settings_section_pricing", {}, "Тарифы и цены"),
|
||||
payments: at("settings_section_payments", {}, "Платёжные системы"),
|
||||
trial: at("settings_section_trial", {}, "Триал"),
|
||||
referral: at("settings_section_referral", {}, "Реферальная программа"),
|
||||
notifications: at("settings_section_notifications", {}, "Уведомления"),
|
||||
devices: at("settings_section_devices", {}, "Устройства"),
|
||||
};
|
||||
return map[id] || id;
|
||||
}
|
||||
|
||||
function englishFieldLabelFallback(key, originalLabel) {
|
||||
if (!key) return originalLabel || "";
|
||||
return String(key)
|
||||
.toLowerCase()
|
||||
.split("_")
|
||||
.filter(Boolean)
|
||||
.map((part) => {
|
||||
if (part === "id") return "ID";
|
||||
if (part === "url") return "URL";
|
||||
if (part === "api") return "API";
|
||||
if (part === "tg") return "TG";
|
||||
return part.charAt(0).toUpperCase() + part.slice(1);
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function fieldLabelText(field) {
|
||||
const isEnglish = String(currentLang || "")
|
||||
.toLowerCase()
|
||||
.startsWith("en");
|
||||
const fallback = isEnglish ? englishFieldLabelFallback(field.key, field.label) : field.label;
|
||||
return field.i18n_label_key ? at(field.i18n_label_key, {}, fallback) : fallback;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet renderField(field)}
|
||||
{@const revealed = isSecretRevealed(field.key)}
|
||||
<div class="admin-setting" class:is-overridden={isOverridden(field)}>
|
||||
<div class="admin-setting-meta">
|
||||
<strong>
|
||||
{fieldLabelText(field)}
|
||||
{#if field.secret}
|
||||
<AdminBadge variant="warning">{at("settings_badge_secret", {}, "Secret")}</AdminBadge>
|
||||
{/if}
|
||||
{#if isOverridden(field)}
|
||||
<AdminBadge variant="success">{at("settings_badge_override", {}, "Override")}</AdminBadge>
|
||||
{/if}
|
||||
</strong>
|
||||
<code>{field.key}</code>
|
||||
{#if field.description}
|
||||
<small
|
||||
>{field.i18n_description_key
|
||||
? at(field.i18n_description_key, {}, field.description)
|
||||
: field.description}</small
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="admin-setting-control">
|
||||
{#if field.type === "bool"}
|
||||
<div class="admin-setting-switch">
|
||||
<Switch.Root
|
||||
checked={Boolean(valueFor(field))}
|
||||
onCheckedChange={(checked) => settingsStore.markDirty(field.key, checked)}
|
||||
class="admin-switch-root"
|
||||
>
|
||||
<Switch.Thumb class="admin-switch-thumb" />
|
||||
</Switch.Root>
|
||||
<span
|
||||
>{valueFor(field)
|
||||
? at("enabled", {}, "Включено")
|
||||
: at("disabled", {}, "Выключено")}</span
|
||||
>
|
||||
</div>
|
||||
{:else if field.type === "color"}
|
||||
<input
|
||||
class="admin-color"
|
||||
type="color"
|
||||
value={valueFor(field) || "#00fe7a"}
|
||||
on:input={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
|
||||
/>
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
value={valueFor(field) || ""}
|
||||
on:input={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
|
||||
/>
|
||||
{:else if field.choices && field.choices.length > 0}
|
||||
<AdminSelect
|
||||
class="admin-setting-select"
|
||||
value={valueFor(field) || ""}
|
||||
items={field.choices}
|
||||
ariaLabel={fieldLabelText(field)}
|
||||
placeholder={field.placeholder || fieldLabelText(field)}
|
||||
onValueChange={(value) => settingsStore.markDirty(field.key, value)}
|
||||
/>
|
||||
{:else if field.type === "int" || field.type === "float"}
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
step={field.type === "float" ? "0.1" : "1"}
|
||||
placeholder={field.placeholder}
|
||||
value={valueFor(field) ?? ""}
|
||||
on:input={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
|
||||
/>
|
||||
{:else if field.secret}
|
||||
<input
|
||||
class="input"
|
||||
type={revealed ? "text" : "password"}
|
||||
placeholder={secretPlaceholder(field)}
|
||||
autocomplete="off"
|
||||
value={valueFor(field) ?? ""}
|
||||
on:input={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
|
||||
/>
|
||||
<AdminButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={revealed ? at("hide", {}, "Скрыть") : at("show", {}, "Показать")}
|
||||
onclick={() => toggleSecretReveal(field.key)}
|
||||
>
|
||||
{#if revealed}<EyeOff size={13} />{:else}<Eye size={13} />{/if}
|
||||
</AdminButton>
|
||||
{:else}
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder={field.placeholder}
|
||||
value={valueFor(field) ?? ""}
|
||||
on:input={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
|
||||
/>
|
||||
{/if}
|
||||
{#if isOverridden(field) || settingsDirty[field.key]}
|
||||
<AdminButton size="sm" variant="ghost" onclick={() => settingsStore.resetField(field)}>
|
||||
<X size={12} />
|
||||
{at("reset", {}, "Сбросить")}
|
||||
</AdminButton>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if settingsLoading || !visibleSettingsSections.length}
|
||||
<AdminEmptyState
|
||||
>{settingsLoading
|
||||
? at("loading", {}, "Загрузка…")
|
||||
: at("no_data", {}, "Нет данных")}</AdminEmptyState
|
||||
>
|
||||
{:else}
|
||||
<div
|
||||
style="display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;"
|
||||
>
|
||||
<p class="admin-muted" style="margin:0;">
|
||||
{at(
|
||||
"settings_hint",
|
||||
{},
|
||||
"Изменения в админке имеют приоритет над .env. Кнопка «Сбросить» возвращает значение из переменных окружения."
|
||||
)}
|
||||
</p>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<AdminButton size="sm" variant="ghost" onclick={toggleAllSections}>
|
||||
{settingsAllOpen
|
||||
? at("collapse_all", {}, "Свернуть всё")
|
||||
: at("expand_all", {}, "Развернуть всё")}
|
||||
</AdminButton>
|
||||
{#if Object.keys(settingsDirty).length > 0}
|
||||
<AdminButton
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onclick={() => settingsStore.saveSettings(onSettingsSaved)}
|
||||
disabled={settingsSaving}
|
||||
>
|
||||
{settingsSaving ? at("saving", {}, "Сохранение...") : at("save", {}, "Сохранить")}
|
||||
</AdminButton>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<Accordion.Root type="multiple" bind:value={settingsOpenSections} class="admin-accordion">
|
||||
{#each visibleSettingsSections as section}
|
||||
{@const dirtyInSection = section.fields.filter((f) => Boolean(settingsDirty[f.key])).length}
|
||||
{@const overriddenInSection = section.fields.filter((f) => isOverridden(f)).length}
|
||||
<Accordion.Item value={section.id} class="admin-accordion-item admin-card">
|
||||
<Accordion.Header class="admin-accordion-header">
|
||||
<Accordion.Trigger class="admin-accordion-trigger">
|
||||
<span class="admin-accordion-title">{sectionTitle(section.id)}</span>
|
||||
<span class="admin-accordion-meta">
|
||||
{at(
|
||||
"settings_params_count",
|
||||
{ count: section.fields.length },
|
||||
`${section.fields.length} параметров`
|
||||
)}{#if overriddenInSection}
|
||||
· {at(
|
||||
"settings_overridden_count",
|
||||
{ count: overriddenInSection },
|
||||
`${overriddenInSection} override`
|
||||
)}{/if}{#if dirtyInSection}
|
||||
· {at(
|
||||
"settings_dirty_count",
|
||||
{ count: dirtyInSection },
|
||||
`${dirtyInSection} изм.`
|
||||
)}{/if}
|
||||
</span>
|
||||
<ChevronRight size={16} class="admin-accordion-chev" />
|
||||
</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content class="admin-accordion-content">
|
||||
{@const groups = groupSectionFields(section)}
|
||||
{@const rootGroup = groups.find((g) => !g.label)}
|
||||
{@const labelGroups = groups.filter((g) => g.label)}
|
||||
<div class="admin-settings-fields">
|
||||
{#if rootGroup}
|
||||
{#each rootGroup.fields as field}
|
||||
{@render renderField(field)}
|
||||
{/each}
|
||||
{/if}
|
||||
{#if labelGroups.length}
|
||||
<Accordion.Root
|
||||
type="multiple"
|
||||
value={settingsOpenSubsections[section.id] || []}
|
||||
onValueChange={(v) =>
|
||||
(settingsOpenSubsections = { ...settingsOpenSubsections, [section.id]: v })}
|
||||
class="admin-subsection-accordion"
|
||||
>
|
||||
{#each labelGroups as group}
|
||||
{@const subDirty = group.fields.filter((f) =>
|
||||
Boolean(settingsDirty[f.key])
|
||||
).length}
|
||||
{@const subOverridden = group.fields.filter((f) => isOverridden(f)).length}
|
||||
<Accordion.Item value={group.id} class="admin-settings-subsection">
|
||||
<Accordion.Header class="admin-accordion-header">
|
||||
<Accordion.Trigger class="admin-settings-subsection-trigger">
|
||||
<strong>{group.label}</strong>
|
||||
<span class="admin-settings-subsection-meta">
|
||||
{at(
|
||||
"settings_fields_count",
|
||||
{ count: group.fields.length },
|
||||
`${group.fields.length} полей`
|
||||
)}{#if subOverridden}
|
||||
· {at(
|
||||
"settings_overridden_count",
|
||||
{ count: subOverridden },
|
||||
`${subOverridden} override`
|
||||
)}{/if}{#if subDirty}
|
||||
· {at(
|
||||
"settings_dirty_count",
|
||||
{ count: subDirty },
|
||||
`${subDirty} изм.`
|
||||
)}{/if}
|
||||
</span>
|
||||
<ChevronRight size={14} class="admin-accordion-chev" />
|
||||
</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content class="admin-accordion-content">
|
||||
<div class="admin-settings-subsection-body">
|
||||
{#each group.fields as field}
|
||||
{@render renderField(field)}
|
||||
{/each}
|
||||
</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
{/each}
|
||||
</Accordion.Root>
|
||||
{/if}
|
||||
</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
{/each}
|
||||
</Accordion.Root>
|
||||
{/if}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,964 @@
|
||||
<script>
|
||||
import { Tabs, Switch, Label } from "$components/ui/primitives.js";
|
||||
import Dialog from "$components/ui/dialog.svelte";
|
||||
import { Plus, Save, Trash2, X } from "$components/ui/icons.js";
|
||||
import { AdminButton, AdminSelect } from "$components/patterns/admin/index.js";
|
||||
import { getContext } from "svelte";
|
||||
import { normalizeUuidList } from "../../lib/admin/tariffDraft.js";
|
||||
|
||||
export let at;
|
||||
const tariffsStore = getContext("tariffsStore");
|
||||
|
||||
$: ({
|
||||
tariffEditorOpen,
|
||||
tariffEditingKey,
|
||||
tariffDraft,
|
||||
tariffsSaving,
|
||||
tariffDeleteOpen,
|
||||
tariffDeleteTarget,
|
||||
panelSquadsLoading,
|
||||
panelSquads,
|
||||
} = $tariffsStore);
|
||||
|
||||
$: billingModelOptions = [
|
||||
{ value: "period", label: at("tariff_model_period_label", {}, "Период") },
|
||||
{ value: "traffic", label: at("tariff_model_traffic_label", {}, "Трафик") },
|
||||
];
|
||||
$: panelSquadOptions = (panelSquads || []).map((squad) => ({
|
||||
value: squad.uuid,
|
||||
label: squad.name,
|
||||
}));
|
||||
</script>
|
||||
|
||||
<Dialog
|
||||
open={tariffEditorOpen}
|
||||
title={tariffEditingKey
|
||||
? at("tariff_edit_title", {}, "Настройка тарифа")
|
||||
: at("tariff_create_title", {}, "Новый тариф")}
|
||||
description={tariffEditingKey ||
|
||||
at("tariff_create_subtitle", {}, "Каталог будет сохранён в JSON после подтверждения")}
|
||||
closeLabel={at("close", {}, "Закрыть")}
|
||||
onclose={() => tariffsStore.updateState({ tariffEditorOpen: false })}
|
||||
class="admin-dialog admin-tariff-dialog"
|
||||
>
|
||||
<Tabs.Root bind:value={$tariffsStore.tariffEditorTab} class="admin-tabs-root">
|
||||
<Tabs.List class="admin-tabs-list">
|
||||
<Tabs.Trigger value="general" class="admin-tabs-trigger"
|
||||
>{at("tariff_tab_general", {}, "Основное")}</Tabs.Trigger
|
||||
>
|
||||
<Tabs.Trigger value="pricing" class="admin-tabs-trigger"
|
||||
>{at("tariff_tab_pricing", {}, "Цены")}</Tabs.Trigger
|
||||
>
|
||||
<Tabs.Trigger value="topup" class="admin-tabs-trigger"
|
||||
>{at("tariff_tab_topup", {}, "Докупки")}</Tabs.Trigger
|
||||
>
|
||||
<Tabs.Trigger value="premium" class="admin-tabs-trigger"
|
||||
>{at("tariff_tab_premium", {}, "Premium")}</Tabs.Trigger
|
||||
>
|
||||
<Tabs.Trigger value="hwid" class="admin-tabs-trigger"
|
||||
>{at("tariff_tab_hwid", {}, "Устройства")}</Tabs.Trigger
|
||||
>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="general" class="admin-tabs-content">
|
||||
<div class="admin-form-row admin-form-row-2">
|
||||
<Label.Root class="admin-field-label">
|
||||
<span>{at("tariff_label_key", {}, "Ключ тарифа")}</span>
|
||||
<small
|
||||
>{at(
|
||||
"tariff_hint_key",
|
||||
{},
|
||||
"Латиницей, без пробелов. Используется в платежах и подписках, менять после публикации не рекомендуется"
|
||||
)}</small
|
||||
>
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder="standard"
|
||||
bind:value={$tariffsStore.tariffDraft.key}
|
||||
/>
|
||||
</Label.Root>
|
||||
|
||||
<div class="admin-field-label">
|
||||
<span>{at("tariff_label_model", {}, "Модель тарификации")}</span>
|
||||
<small
|
||||
><b>{at("tariff_model_period_label", {}, "Период")}</b> — {at(
|
||||
"tariff_model_period_desc",
|
||||
{},
|
||||
"пользователь покупает фиксированный срок (1/3/12 мес. и т.д.)"
|
||||
)}. <b>{at("tariff_model_traffic_label", {}, "Трафик")}</b> — {at(
|
||||
"tariff_model_traffic_desc",
|
||||
{},
|
||||
"пользователь покупает пакеты гигабайт по фиксированной цене за GB"
|
||||
)}</small
|
||||
>
|
||||
<AdminSelect
|
||||
bind:value={$tariffsStore.tariffDraft.billing_model}
|
||||
items={billingModelOptions}
|
||||
ariaLabel={at("tariff_label_model", {}, "Модель")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-action-row admin-action-row-bordered">
|
||||
<Switch.Root
|
||||
checked={tariffDraft.enabled}
|
||||
onCheckedChange={(v) => (tariffDraft.enabled = v)}
|
||||
class="admin-switch-root"
|
||||
>
|
||||
<Switch.Thumb class="admin-switch-thumb" />
|
||||
</Switch.Root>
|
||||
<Label.Root class="admin-action-label">
|
||||
<strong
|
||||
>{tariffDraft.enabled
|
||||
? at("tariff_visible", {}, "Тариф виден на витрине")
|
||||
: at("tariff_hidden", {}, "Тариф скрыт от пользователей")}</strong
|
||||
>
|
||||
<small
|
||||
>{at(
|
||||
"tariff_enabled_hint",
|
||||
{},
|
||||
"Выключенный тариф не показывается в боте/мини-аппе, но активные подписки на нём продолжают работать"
|
||||
)}</small
|
||||
>
|
||||
</Label.Root>
|
||||
</div>
|
||||
|
||||
<div class="admin-form-row admin-form-row-2">
|
||||
<Label.Root class="admin-field-label">
|
||||
<span>{at("tariff_label_name_ru", {}, "Название · RU")}</span>
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder={at("tariff_placeholder_name_ru", {}, "Стандарт")}
|
||||
bind:value={$tariffsStore.tariffDraft.nameRu}
|
||||
/>
|
||||
</Label.Root>
|
||||
<Label.Root class="admin-field-label">
|
||||
<span>{at("tariff_label_name_en", {}, "Название · EN")}</span>
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder={at("tariff_placeholder_name_en", {}, "Standard")}
|
||||
bind:value={$tariffsStore.tariffDraft.nameEn}
|
||||
/>
|
||||
</Label.Root>
|
||||
</div>
|
||||
|
||||
<div class="admin-form-row admin-form-row-2">
|
||||
<Label.Root class="admin-field-label">
|
||||
<span>{at("tariff_label_desc_ru", {}, "Описание · RU")}</span>
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder={at("tariff_placeholder_desc_ru", {}, "Базовый набор серверов")}
|
||||
bind:value={$tariffsStore.tariffDraft.descriptionRu}
|
||||
/>
|
||||
</Label.Root>
|
||||
<Label.Root class="admin-field-label">
|
||||
<span>{at("tariff_label_desc_en", {}, "Описание · EN")}</span>
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder={at("tariff_placeholder_desc_en", {}, "Base server pool")}
|
||||
bind:value={$tariffsStore.tariffDraft.descriptionEn}
|
||||
/>
|
||||
</Label.Root>
|
||||
</div>
|
||||
|
||||
<div class="admin-field-label">
|
||||
<span>{at("tariff_label_squads", {}, "Базовые Internal Squads")}</span>
|
||||
<small
|
||||
>{panelSquadsLoading
|
||||
? at("loading_squads", {}, "Загружаю список из панели…")
|
||||
: at(
|
||||
"tariff_hint_squads",
|
||||
{},
|
||||
"Сквады Remnawave, к которым подключается пользователь по этому тарифу. Выберите один или несколько"
|
||||
)}</small
|
||||
>
|
||||
<AdminSelect
|
||||
bind:value={$tariffsStore.selectedBaseSquad}
|
||||
items={panelSquadOptions}
|
||||
placeholder={at("btn_add_squad", {}, "Добавить сквад")}
|
||||
ariaLabel={at("btn_add_squad", {}, "Добавить основной сквад")}
|
||||
onValueChange={(value) => {
|
||||
tariffsStore.addSquadToDraft("squadUuids", value);
|
||||
tariffsStore.update((s) => ({ ...s, selectedBaseSquad: "" }));
|
||||
}}
|
||||
/>
|
||||
<div class="admin-chip-list">
|
||||
{#each normalizeUuidList(tariffDraft.squadUuids) as uuid}
|
||||
<button
|
||||
type="button"
|
||||
class="admin-chip"
|
||||
on:click={() => tariffsStore.removeSquadFromDraft("squadUuids", uuid)}
|
||||
>
|
||||
{tariffsStore.squadLabel(uuid)}
|
||||
<X size={12} />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-form-row admin-form-row-2">
|
||||
<Label.Root class="admin-field-label">
|
||||
<span>{at("tariff_label_hwid", {}, "Лимит устройств (HWID)")}</span>
|
||||
<small
|
||||
>{at(
|
||||
"tariff_hint_hwid",
|
||||
{},
|
||||
"Сколько устройств может одновременно использовать подписку. Пусто — взять значение из .env, 0 — без ограничений"
|
||||
)}</small
|
||||
>
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="5"
|
||||
bind:value={$tariffsStore.tariffDraft.hwid_device_limit}
|
||||
/>
|
||||
</Label.Root>
|
||||
{#if tariffDraft.billing_model === "period"}
|
||||
<Label.Root class="admin-field-label">
|
||||
<span>{at("tariff_label_traffic_limit", {}, "Месячный лимит трафика, GB")}</span>
|
||||
<small
|
||||
>{at(
|
||||
"tariff_hint_traffic_limit",
|
||||
{},
|
||||
"Сколько GB включено в тариф на каждый месяц. 0 — безлимитный трафика. Сверху можно докупать пакеты на вкладке «Докупки»"
|
||||
)}</small
|
||||
>
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
placeholder="100"
|
||||
bind:value={$tariffsStore.tariffDraft.monthly_gb}
|
||||
/>
|
||||
</Label.Root>
|
||||
{:else}
|
||||
<Label.Root class="admin-field-label">
|
||||
<span>{at("tariff_label_conversion", {}, "Курс конвертации, ₽ за 1 GB")}</span>
|
||||
<small
|
||||
>{at(
|
||||
"tariff_hint_conversion",
|
||||
{},
|
||||
"По этому курсу остаток подписки пересчитывается в гигабайты при переходе пользователя с тарифа «Период» на «Трафик»"
|
||||
)}</small
|
||||
>
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="20"
|
||||
bind:value={$tariffsStore.tariffDraft.conversion_rate_rub_per_gb}
|
||||
/>
|
||||
</Label.Root>
|
||||
{/if}
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="premium" class="admin-tabs-content">
|
||||
<section class="admin-editor-section">
|
||||
<header class="admin-editor-section-head">
|
||||
<div class="admin-editor-section-title">
|
||||
<strong
|
||||
>{at("tariff_premium_head", {}, "Premium-доступ и отдельный счётчик трафика")}</strong
|
||||
>
|
||||
<small
|
||||
>{at(
|
||||
"tariff_premium_subhead",
|
||||
{},
|
||||
"Premium-сквады дают пользователю доступ к более быстрым/премиальным нодам; их трафик считается отдельно от основного, чтобы можно было ограничить или продавать дополнительно"
|
||||
)}</small
|
||||
>
|
||||
</div>
|
||||
</header>
|
||||
<div class="admin-form-row admin-form-row-2">
|
||||
<Label.Root class="admin-field-label">
|
||||
<span>{at("tariff_label_premium_name_ru", {}, "Название premium-раздела, RU")}</span>
|
||||
<small
|
||||
>{at(
|
||||
"tariff_hint_premium_name_ru",
|
||||
{},
|
||||
"Эта строка заменит «Premium-серверы» в кабинете, докупках и карточках лимитов."
|
||||
)}</small
|
||||
>
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder={at("tariff_placeholder_premium_name_ru", {}, "Premium-серверы")}
|
||||
bind:value={$tariffsStore.tariffDraft.premiumNameRu}
|
||||
/>
|
||||
</Label.Root>
|
||||
<Label.Root class="admin-field-label">
|
||||
<span>{at("tariff_label_premium_name_en", {}, "Название premium-раздела, EN")}</span>
|
||||
<small
|
||||
>{at(
|
||||
"tariff_hint_premium_name_en",
|
||||
{},
|
||||
"Опционально для английского интерфейса."
|
||||
)}</small
|
||||
>
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder={at("tariff_placeholder_premium_name_en", {}, "Premium servers")}
|
||||
bind:value={$tariffsStore.tariffDraft.premiumNameEn}
|
||||
/>
|
||||
</Label.Root>
|
||||
</div>
|
||||
<div class="admin-form-row admin-form-row-2">
|
||||
<div class="admin-field-label">
|
||||
<span>{at("tariff_label_premium_squads", {}, "Premium Internal Squads")}</span>
|
||||
<small
|
||||
>{at(
|
||||
"tariff_hint_premium_squads",
|
||||
{},
|
||||
"Сквады из Remnawave, доступные только владельцам этого тарифа. Трафик считается по их accessible nodes"
|
||||
)}</small
|
||||
>
|
||||
<AdminSelect
|
||||
bind:value={$tariffsStore.selectedPremiumSquad}
|
||||
items={panelSquadOptions}
|
||||
placeholder={at("btn_add_premium_squad", {}, "Добавить premium-сквад")}
|
||||
ariaLabel={at("btn_add_premium_squad", {}, "Добавить premium-сквад")}
|
||||
onValueChange={(value) => {
|
||||
tariffsStore.addSquadToDraft("premiumSquadUuids", value);
|
||||
tariffsStore.update((s) => ({ ...s, selectedPremiumSquad: "" }));
|
||||
}}
|
||||
/>
|
||||
<div class="admin-chip-list">
|
||||
{#each normalizeUuidList(tariffDraft.premiumSquadUuids) as uuid}
|
||||
<button
|
||||
type="button"
|
||||
class="admin-chip"
|
||||
on:click={() => tariffsStore.removeSquadFromDraft("premiumSquadUuids", uuid)}
|
||||
>
|
||||
{tariffsStore.squadLabel(uuid)}
|
||||
<X size={12} />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<Label.Root class="admin-field-label">
|
||||
<span
|
||||
>{at(
|
||||
"tariff_label_premium_traffic_limit",
|
||||
{},
|
||||
"Месячный лимит premium-трафика, GB"
|
||||
)}</span
|
||||
>
|
||||
<small
|
||||
>{at(
|
||||
"tariff_hint_premium_traffic_limit",
|
||||
{},
|
||||
"Сколько GB через premium-сквады включено в тариф каждый месяц. 0 или пусто — отдельного premium-лимита нет (premium-нодами можно пользоваться без ограничения)"
|
||||
)}</small
|
||||
>
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
placeholder="50"
|
||||
bind:value={$tariffsStore.tariffDraft.premium_monthly_gb}
|
||||
/>
|
||||
</Label.Root>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-editor-section">
|
||||
<header class="admin-editor-section-head">
|
||||
<div class="admin-editor-section-title">
|
||||
<strong>{at("tariff_premium_topup_title", {}, "Докупка premium-трафика")}</strong>
|
||||
<small
|
||||
>{at(
|
||||
"tariff_premium_topup_subtitle",
|
||||
{},
|
||||
"Пакеты для расширения месячного premium-лимита, когда пользователь его исчерпал"
|
||||
)}</small
|
||||
>
|
||||
</div>
|
||||
<div class="admin-editor-section-actions">
|
||||
<AdminButton
|
||||
size="sm"
|
||||
onclick={() => tariffsStore.addDraftRow("premiumTopupRubRows", { gb: 10, price: "" })}
|
||||
><Plus size={12} /> {at("tariff_btn_package_rub", {}, "Пакет ₽")}</AdminButton
|
||||
>
|
||||
<AdminButton
|
||||
size="sm"
|
||||
onclick={() =>
|
||||
tariffsStore.addDraftRow("premiumTopupStarsRows", { gb: 10, price: "" })}
|
||||
><Plus size={12} /> {at("tariff_btn_package_stars", {}, "Пакет ⭐")}</AdminButton
|
||||
>
|
||||
</div>
|
||||
</header>
|
||||
<div class="admin-package-columns">
|
||||
<div class="admin-row-editor">
|
||||
<span class="admin-row-editor-caption">{at("payment_rub", {}, "Оплата рублями")}</span>
|
||||
{#if tariffDraft.premiumTopupRubRows.length}
|
||||
<div class="admin-row-editor-line admin-row-editor-header">
|
||||
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
|
||||
<span>{at("tariff_col_price_rub", {}, "Цена, ₽")}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{/if}
|
||||
{#each tariffDraft.premiumTopupRubRows as row, index}
|
||||
<div class="admin-row-editor-line">
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
placeholder="10"
|
||||
bind:value={row.gb}
|
||||
aria-label={at("tariff_col_volume_gb", {}, "Объём premium-пакета в GB")}
|
||||
/>
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="199"
|
||||
bind:value={row.price}
|
||||
aria-label={at("tariff_label_price_rub", {}, "Цена premium-пакета в рублях")}
|
||||
/>
|
||||
<AdminButton
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onclick={() => tariffsStore.removeDraftRow("premiumTopupRubRows", index)}
|
||||
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="admin-row-editor">
|
||||
<span class="admin-row-editor-caption"
|
||||
>{at("payment_stars", {}, "Оплата Telegram Stars")}</span
|
||||
>
|
||||
{#if tariffDraft.premiumTopupStarsRows.length}
|
||||
<div class="admin-row-editor-line admin-row-editor-header">
|
||||
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
|
||||
<span>{at("tariff_col_price_stars", {}, "Цена, ⭐")}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{/if}
|
||||
{#each tariffDraft.premiumTopupStarsRows as row, index}
|
||||
<div class="admin-row-editor-line">
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
placeholder="10"
|
||||
bind:value={row.gb}
|
||||
aria-label={at("tariff_col_volume_gb", {}, "Объём premium-пакета в GB")}
|
||||
/>
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="100"
|
||||
bind:value={row.price}
|
||||
aria-label={at(
|
||||
"tariff_label_price_stars",
|
||||
{},
|
||||
"Цена premium-пакета в Telegram Stars"
|
||||
)}
|
||||
/>
|
||||
<AdminButton
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onclick={() => tariffsStore.removeDraftRow("premiumTopupStarsRows", index)}
|
||||
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="pricing" class="admin-tabs-content">
|
||||
{#if tariffDraft.billing_model === "period"}
|
||||
<section class="admin-editor-section">
|
||||
<header class="admin-editor-section-head">
|
||||
<div class="admin-editor-section-title">
|
||||
<strong>{at("tariff_pricing_period_title", {}, "Периоды подписки и цены")}</strong>
|
||||
<small
|
||||
>{at(
|
||||
"tariff_pricing_period_subtitle",
|
||||
{},
|
||||
"Каждая строка — отдельный вариант на витрине: за сколько месяцев пользователь платит и сколько это стоит"
|
||||
)}</small
|
||||
>
|
||||
</div>
|
||||
<AdminButton
|
||||
size="sm"
|
||||
onclick={() =>
|
||||
tariffsStore.addDraftRow("periodRows", { months: 1, rub: "", stars: "" })}
|
||||
>
|
||||
<Plus size={13} />
|
||||
{at("tariff_btn_period", {}, "Период")}
|
||||
</AdminButton>
|
||||
</header>
|
||||
{#if !tariffDraft.periodRows.length}
|
||||
<p class="admin-muted">
|
||||
{at(
|
||||
"tariff_pricing_empty",
|
||||
{},
|
||||
"Добавьте хотя бы один период — без него тариф не появится на витрине."
|
||||
)}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="admin-row-editor">
|
||||
<div class="admin-row-editor-line admin-row-editor-4 admin-row-editor-header">
|
||||
<span>{at("tariff_col_period_months", {}, "Срок, мес.")}</span>
|
||||
<span>{at("tariff_col_price_rub", {}, "Цена, ₽")}</span>
|
||||
<span>{at("tariff_col_price_stars_full", {}, "Цена, ⭐ Stars")}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{#each tariffDraft.periodRows as row, index}
|
||||
<div class="admin-row-editor-line admin-row-editor-4">
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="1"
|
||||
bind:value={row.months}
|
||||
aria-label={at("tariff_col_period_months", {}, "Срок (месяцы)")}
|
||||
/>
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="299"
|
||||
bind:value={row.rub}
|
||||
aria-label={at("tariff_label_price_rub", {}, "Цена в рублях")}
|
||||
/>
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="150"
|
||||
bind:value={row.stars}
|
||||
aria-label={at("tariff_label_price_stars", {}, "Цена в Telegram Stars")}
|
||||
/>
|
||||
<AdminButton
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onclick={() => tariffsStore.removeDraftRow("periodRows", index)}
|
||||
aria-label={at("btn_delete", {}, "Удалить")}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</AdminButton>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{:else}
|
||||
<section class="admin-editor-section">
|
||||
<header class="admin-editor-section-head">
|
||||
<div class="admin-editor-section-title">
|
||||
<strong>{at("tariff_pricing_traffic_title", {}, "Пакеты трафика")}</strong>
|
||||
<small
|
||||
>{at(
|
||||
"tariff_pricing_traffic_subtitle",
|
||||
{},
|
||||
"Базовая витрина для трафиковой модели. Каждая строка — пакет «N гигабайт за N единиц валюты»"
|
||||
)}</small
|
||||
>
|
||||
</div>
|
||||
<div class="admin-editor-section-actions">
|
||||
<AdminButton
|
||||
size="sm"
|
||||
onclick={() => tariffsStore.addDraftRow("trafficRubRows", { gb: 10, price: "" })}
|
||||
><Plus size={12} /> {at("tariff_btn_package_rub", {}, "Пакет ₽")}</AdminButton
|
||||
>
|
||||
<AdminButton
|
||||
size="sm"
|
||||
onclick={() => tariffsStore.addDraftRow("trafficStarsRows", { gb: 10, price: "" })}
|
||||
><Plus size={12} /> {at("tariff_btn_package_stars", {}, "Пакет ⭐")}</AdminButton
|
||||
>
|
||||
</div>
|
||||
</header>
|
||||
<div class="admin-package-columns">
|
||||
<div class="admin-row-editor">
|
||||
<span class="admin-row-editor-caption">{at("payment_rub", {}, "Оплата рублями")}</span
|
||||
>
|
||||
{#if tariffDraft.trafficRubRows.length}
|
||||
<div class="admin-row-editor-line admin-row-editor-header">
|
||||
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
|
||||
<span>{at("tariff_col_price_rub", {}, "Цена, ₽")}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{/if}
|
||||
{#each tariffDraft.trafficRubRows as row, index}
|
||||
<div class="admin-row-editor-line">
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
placeholder="50"
|
||||
bind:value={row.gb}
|
||||
aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")}
|
||||
/>
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="299"
|
||||
bind:value={row.price}
|
||||
aria-label={at("tariff_label_price_rub", {}, "Цена пакета в рублях")}
|
||||
/>
|
||||
<AdminButton
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onclick={() => tariffsStore.removeDraftRow("trafficRubRows", index)}
|
||||
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="admin-row-editor">
|
||||
<span class="admin-row-editor-caption"
|
||||
>{at("payment_stars", {}, "Оплата Telegram Stars")}</span
|
||||
>
|
||||
{#if tariffDraft.trafficStarsRows.length}
|
||||
<div class="admin-row-editor-line admin-row-editor-header">
|
||||
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
|
||||
<span>{at("tariff_col_price_stars", {}, "Цена, ⭐")}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{/if}
|
||||
{#each tariffDraft.trafficStarsRows as row, index}
|
||||
<div class="admin-row-editor-line">
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
placeholder="50"
|
||||
bind:value={row.gb}
|
||||
aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")}
|
||||
/>
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="150"
|
||||
bind:value={row.price}
|
||||
aria-label={at("tariff_label_price_stars", {}, "Цена пакета в Telegram Stars")}
|
||||
/>
|
||||
<AdminButton
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onclick={() => tariffsStore.removeDraftRow("trafficStarsRows", index)}
|
||||
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="topup" class="admin-tabs-content">
|
||||
{#if tariffDraft.billing_model === "period"}
|
||||
<section class="admin-editor-section">
|
||||
<header class="admin-editor-section-head">
|
||||
<div class="admin-editor-section-title">
|
||||
<strong
|
||||
>{at("tariff_topup_title", {}, "Докупка трафика поверх месячного лимита")}</strong
|
||||
>
|
||||
<small
|
||||
>{at(
|
||||
"tariff_topup_subtitle",
|
||||
{},
|
||||
"Когда у пользователя кончился месячный лимит, ему предложат купить дополнительный пакет, не меняя срок подписки"
|
||||
)}</small
|
||||
>
|
||||
</div>
|
||||
<div class="admin-editor-section-actions">
|
||||
<AdminButton
|
||||
size="sm"
|
||||
onclick={() => tariffsStore.addDraftRow("topupRubRows", { gb: 10, price: "" })}
|
||||
><Plus size={12} /> {at("tariff_btn_package_rub", {}, "Пакет ₽")}</AdminButton
|
||||
>
|
||||
<AdminButton
|
||||
size="sm"
|
||||
onclick={() => tariffsStore.addDraftRow("topupStarsRows", { gb: 10, price: "" })}
|
||||
><Plus size={12} /> {at("tariff_btn_package_stars", {}, "Пакет ⭐")}</AdminButton
|
||||
>
|
||||
</div>
|
||||
</header>
|
||||
<div class="admin-package-columns">
|
||||
<div class="admin-row-editor">
|
||||
<span class="admin-row-editor-caption">{at("payment_rub", {}, "Оплата рублями")}</span
|
||||
>
|
||||
{#if tariffDraft.topupRubRows.length}
|
||||
<div class="admin-row-editor-line admin-row-editor-header">
|
||||
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
|
||||
<span>{at("tariff_col_price_rub", {}, "Цена, ₽")}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{/if}
|
||||
{#each tariffDraft.topupRubRows as row, index}
|
||||
<div class="admin-row-editor-line">
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
placeholder="20"
|
||||
bind:value={row.gb}
|
||||
aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")}
|
||||
/>
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="149"
|
||||
bind:value={row.price}
|
||||
aria-label={at("tariff_label_price_rub", {}, "Цена пакета в рублях")}
|
||||
/>
|
||||
<AdminButton
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onclick={() => tariffsStore.removeDraftRow("topupRubRows", index)}
|
||||
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="admin-row-editor">
|
||||
<span class="admin-row-editor-caption"
|
||||
>{at("payment_stars", {}, "Оплата Telegram Stars")}</span
|
||||
>
|
||||
{#if tariffDraft.topupStarsRows.length}
|
||||
<div class="admin-row-editor-line admin-row-editor-header">
|
||||
<span>{at("tariff_col_volume_gb", {}, "Объём, GB")}</span>
|
||||
<span>{at("tariff_col_price_stars", {}, "Цена, ⭐")}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{/if}
|
||||
{#each tariffDraft.topupStarsRows as row, index}
|
||||
<div class="admin-row-editor-line">
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
placeholder="20"
|
||||
bind:value={row.gb}
|
||||
aria-label={at("tariff_col_volume_gb", {}, "Объём пакета в GB")}
|
||||
/>
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="75"
|
||||
bind:value={row.price}
|
||||
aria-label={at("tariff_label_price_stars", {}, "Цена пакета в Telegram Stars")}
|
||||
/>
|
||||
<AdminButton
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onclick={() => tariffsStore.removeDraftRow("topupStarsRows", index)}
|
||||
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{:else}
|
||||
<p class="admin-muted">
|
||||
{at(
|
||||
"tariff_topup_traffic_hint",
|
||||
{},
|
||||
"Для трафиковой модели отдельные «докупки» не нужны — пакеты, которые вы настроили на вкладке «Цены», и являются докупками: пользователь покупает их повторно по мере исчерпания."
|
||||
)}
|
||||
</p>
|
||||
{/if}
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="hwid" class="admin-tabs-content">
|
||||
<section class="admin-editor-section">
|
||||
<header class="admin-editor-section-head">
|
||||
<div class="admin-editor-section-title">
|
||||
<strong
|
||||
>{at(
|
||||
"tariff_hwid_packages_title",
|
||||
{},
|
||||
"Пакеты дополнительных устройств (HWID)"
|
||||
)}</strong
|
||||
>
|
||||
<small
|
||||
>{at(
|
||||
"tariff_hwid_packages_subtitle",
|
||||
{},
|
||||
"Расширяет лимит, указанный во вкладке «Основное». Каждая строка — пакет «+N устройств за N единиц валюты»"
|
||||
)}</small
|
||||
>
|
||||
</div>
|
||||
<div class="admin-editor-section-actions">
|
||||
<AdminButton
|
||||
size="sm"
|
||||
onclick={() => tariffsStore.addDraftRow("hwidRubRows", { count: 1, price: "" })}
|
||||
><Plus size={12} /> {at("tariff_btn_package_rub", {}, "Пакет ₽")}</AdminButton
|
||||
>
|
||||
<AdminButton
|
||||
size="sm"
|
||||
onclick={() => tariffsStore.addDraftRow("hwidStarsRows", { count: 1, price: "" })}
|
||||
><Plus size={12} /> {at("tariff_btn_package_stars", {}, "Пакет ⭐")}</AdminButton
|
||||
>
|
||||
</div>
|
||||
</header>
|
||||
<div class="admin-package-columns">
|
||||
<div class="admin-row-editor">
|
||||
<span class="admin-row-editor-caption">{at("payment_rub", {}, "Оплата рублями")}</span>
|
||||
{#if tariffDraft.hwidRubRows.length}
|
||||
<div class="admin-row-editor-line admin-row-editor-header">
|
||||
<span>{at("tariff_col_hwid_count", {}, "+ устройств")}</span>
|
||||
<span>{at("tariff_col_price_rub", {}, "Цена, ₽")}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{/if}
|
||||
{#each tariffDraft.hwidRubRows as row, index}
|
||||
<div class="admin-row-editor-line">
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
placeholder="1"
|
||||
bind:value={row.count}
|
||||
aria-label={at(
|
||||
"tariff_label_hwid_count_full",
|
||||
{},
|
||||
"Сколько устройств добавляет пакет"
|
||||
)}
|
||||
/>
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="99"
|
||||
bind:value={row.price}
|
||||
aria-label={at("tariff_label_price_rub", {}, "Цена пакета в рублях")}
|
||||
/>
|
||||
<AdminButton
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onclick={() => tariffsStore.removeDraftRow("hwidRubRows", index)}
|
||||
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="admin-row-editor">
|
||||
<span class="admin-row-editor-caption"
|
||||
>{at("payment_stars", {}, "Оплата Telegram Stars")}</span
|
||||
>
|
||||
{#if tariffDraft.hwidStarsRows.length}
|
||||
<div class="admin-row-editor-line admin-row-editor-header">
|
||||
<span>{at("tariff_col_hwid_count", {}, "+ устройств")}</span>
|
||||
<span>{at("tariff_col_price_stars", {}, "Цена, ⭐")}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{/if}
|
||||
{#each tariffDraft.hwidStarsRows as row, index}
|
||||
<div class="admin-row-editor-line">
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
placeholder="1"
|
||||
bind:value={row.count}
|
||||
aria-label={at(
|
||||
"tariff_label_hwid_count_full",
|
||||
{},
|
||||
"Сколько устройств добавляет пакет"
|
||||
)}
|
||||
/>
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="50"
|
||||
bind:value={row.price}
|
||||
aria-label={at("tariff_label_price_stars", {}, "Цена пакета в Telegram Stars")}
|
||||
/>
|
||||
<AdminButton
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onclick={() => tariffsStore.removeDraftRow("hwidStarsRows", index)}
|
||||
aria-label={at("btn_delete", {}, "Удалить")}><Trash2 size={13} /></AdminButton
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
|
||||
<div class="admin-dialog-actions">
|
||||
<AdminButton onclick={() => tariffsStore.updateState({ tariffEditorOpen: false })}
|
||||
>{at("btn_cancel", {}, "Отмена")}</AdminButton
|
||||
>
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
onclick={tariffsStore.saveTariffDraft}
|
||||
disabled={tariffsSaving || !tariffDraft.key.trim()}
|
||||
>
|
||||
<Save size={14} />
|
||||
{tariffsSaving
|
||||
? at("btn_saving", {}, "Сохранение...")
|
||||
: at("btn_save_tariff", {}, "Сохранить тариф")}
|
||||
</AdminButton>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={tariffDeleteOpen}
|
||||
title={at("tariff_delete_title", {}, "Удалить тариф?")}
|
||||
description={tariffDeleteTarget
|
||||
? at(
|
||||
"tariff_delete_subtitle",
|
||||
{ key: tariffDeleteTarget.key },
|
||||
`Тариф ${tariffDeleteTarget.key} исчезнет из каталога продаж.`
|
||||
)
|
||||
: ""}
|
||||
closeLabel={at("close", {}, "Закрыть")}
|
||||
onclose={() => tariffsStore.updateState({ tariffDeleteOpen: false })}
|
||||
class="admin-dialog"
|
||||
>
|
||||
<div class="admin-form-row">
|
||||
<AdminButton onclick={() => tariffsStore.updateState({ tariffDeleteOpen: false })}
|
||||
>{at("btn_cancel", {}, "Отмена")}</AdminButton
|
||||
>
|
||||
<AdminButton variant="danger" onclick={tariffsStore.deleteTariff} disabled={tariffsSaving}>
|
||||
<Trash2 size={14} />
|
||||
{at("btn_confirm_delete", {}, "Подтвердить удаление")}
|
||||
</AdminButton>
|
||||
</div>
|
||||
</Dialog>
|
||||
@@ -0,0 +1,195 @@
|
||||
<script>
|
||||
import { RefreshCw, Trash2, Plus } from "$components/ui/icons.js";
|
||||
import { getContext, onMount } from "svelte";
|
||||
import { AdminBadge, AdminButton, AdminEmptyState } from "$components/patterns/admin/index.js";
|
||||
|
||||
export let at;
|
||||
export let fmtMoney;
|
||||
|
||||
const tariffsStore = getContext("tariffsStore");
|
||||
|
||||
$: ({ tariffsCatalog, tariffsLoading, tariffsPath, tariffsSaving } = $tariffsStore);
|
||||
|
||||
$: enabledTariffs = (tariffsCatalog.tariffs || []).filter((tariff) => tariff.enabled !== false);
|
||||
$: disabledTariffs = Math.max(0, (tariffsCatalog.tariffs || []).length - enabledTariffs.length);
|
||||
|
||||
function tariffName(tariff) {
|
||||
return tariff?.names?.ru || tariff?.names?.en || tariff?.key || "—";
|
||||
}
|
||||
|
||||
function tariffPriceSummary(tariff) {
|
||||
if (tariff.billing_model === "traffic") {
|
||||
const rub = tariff.traffic_packages?.rub || [];
|
||||
const first = rub[0];
|
||||
return first
|
||||
? `${first.gb} GB ${at("at", {}, "за")} ${fmtMoney(first.price, "RUB")}`
|
||||
: at("tariff_traffic_packages", {}, "Пакеты трафика");
|
||||
}
|
||||
const months = [...(tariff.enabled_periods || [])].sort((a, b) => a - b);
|
||||
return months
|
||||
.map((month) => {
|
||||
const rub = tariff.prices_rub?.[String(month)];
|
||||
const stars = tariff.prices_stars?.[String(month)];
|
||||
if (rub) return `${month} ${at("months_short", {}, "мес.")} ${fmtMoney(rub, "RUB")}`;
|
||||
if (stars) return `${month} ${at("months_short", {}, "мес.")} ${stars} ⭐`;
|
||||
return `${month} ${at("months_short", {}, "мес.")}`;
|
||||
})
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
tariffsStore.loadTariffs();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if tariffsLoading}
|
||||
<AdminEmptyState>{at("loading", {}, "Загрузка…")}</AdminEmptyState>
|
||||
{:else}
|
||||
<div class="admin-stat-grid">
|
||||
<div class="admin-stat-card">
|
||||
<span class="admin-stat-label">{at("tariffs_stat_total", {}, "Всего тарифов")}</span>
|
||||
<strong class="admin-stat-value">{tariffsCatalog.tariffs.length}</strong>
|
||||
<span class="admin-stat-trend"
|
||||
>{at("tariffs_stat_enabled", {}, "Включено")}: {enabledTariffs.length}</span
|
||||
>
|
||||
</div>
|
||||
<div class="admin-stat-card">
|
||||
<span class="admin-stat-label">{at("tariffs_stat_default", {}, "По умолчанию")}</span>
|
||||
<strong class="admin-stat-value">{tariffsCatalog.default_tariff || "—"}</strong>
|
||||
<span class="admin-stat-trend"
|
||||
>{at("tariffs_stat_default_hint", {}, "Используется для новых подписок")}</span
|
||||
>
|
||||
</div>
|
||||
<div class="admin-stat-card">
|
||||
<span class="admin-stat-label">{at("tariffs_stat_disabled", {}, "Отключено")}</span>
|
||||
<strong class="admin-stat-value">{disabledTariffs}</strong>
|
||||
<span class="admin-stat-trend"
|
||||
>{at("tariffs_stat_disabled_hint", {}, "Скрыто с витрины")}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article class="admin-card">
|
||||
<header class="admin-card-head">
|
||||
<div>
|
||||
<h3>{at("tariffs_title", {}, "Каталог тарифов")}</h3>
|
||||
<small>{tariffsPath || "data/tariffs.json"}</small>
|
||||
</div>
|
||||
<div class="admin-editor-section-actions">
|
||||
<AdminButton
|
||||
size="sm"
|
||||
onclick={tariffsStore.loadTariffs}
|
||||
disabled={tariffsLoading || tariffsSaving}
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
{at("btn_refresh", {}, "Обновить")}
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onclick={tariffsStore.openCreateTariff}
|
||||
disabled={tariffsLoading || tariffsSaving}
|
||||
>
|
||||
<Plus size={13} />
|
||||
{at("btn_create_tariff", {}, "Создать тариф")}
|
||||
</AdminButton>
|
||||
</div>
|
||||
</header>
|
||||
<div class="admin-card-body">
|
||||
{#if !tariffsCatalog.tariffs.length}
|
||||
<AdminEmptyState>
|
||||
{at(
|
||||
"tariffs_catalog_empty",
|
||||
{},
|
||||
"Каталог пуст. Добавьте первый тариф, после сохранения будет создан JSON-файл каталога."
|
||||
)}
|
||||
</AdminEmptyState>
|
||||
{:else}
|
||||
<div class="admin-tariff-grid">
|
||||
{#each tariffsCatalog.tariffs as tariff}
|
||||
<article class="admin-tariff-card" class:is-disabled={tariff.enabled === false}>
|
||||
<div class="admin-tariff-top">
|
||||
<div>
|
||||
<div class="admin-tariff-title">
|
||||
<strong>{tariffName(tariff)}</strong>
|
||||
{#if tariff.key === tariffsCatalog.default_tariff}
|
||||
<AdminBadge variant="success"
|
||||
>{at("status_default", {}, "Default")}</AdminBadge
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<code>{tariff.key}</code>
|
||||
</div>
|
||||
{#if tariff.enabled === false}
|
||||
<AdminBadge variant="muted">{at("status_disabled", {}, "Выключен")}</AdminBadge>
|
||||
{:else}
|
||||
<AdminBadge variant="success">{at("status_active", {}, "Активен")}</AdminBadge>
|
||||
{/if}
|
||||
</div>
|
||||
<p>
|
||||
{tariff.descriptions?.ru ||
|
||||
tariff.descriptions?.en ||
|
||||
at("no_description", {}, "Без описания")}
|
||||
</p>
|
||||
<div class="admin-tariff-facts">
|
||||
<span
|
||||
>{tariff.billing_model === "traffic"
|
||||
? at("tariff_model_traffic", {}, "Трафик")
|
||||
: at("tariff_model_periods", {}, "Периоды")}</span
|
||||
>
|
||||
<span>{tariffPriceSummary(tariff)}</span>
|
||||
<span>{at("tariff_squads", {}, "Squads")}: {(tariff.squad_uuids || []).length}</span
|
||||
>
|
||||
<span
|
||||
>{at("tariff_premium", {}, "Premium")}: {(tariff.premium_squad_uuids || []).length
|
||||
? `${tariff.premium_monthly_gb || 0} GB`
|
||||
: "—"}</span
|
||||
>
|
||||
<span
|
||||
>{at("tariff_devices", {}, "Устройства")}: {tariff.hwid_device_limit ??
|
||||
"env"}</span
|
||||
>
|
||||
</div>
|
||||
<div class="admin-tariff-actions">
|
||||
<AdminButton size="sm" onclick={() => tariffsStore.openEditTariff(tariff)}>
|
||||
{at("btn_configure", {}, "Настроить")}
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
size="sm"
|
||||
onclick={() => tariffsStore.toggleTariffEnabled(tariff)}
|
||||
disabled={tariffsSaving}
|
||||
>
|
||||
{tariff.enabled === false
|
||||
? at("btn_enable", {}, "Включить")
|
||||
: at("btn_disable", {}, "Выключить")}
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
size="sm"
|
||||
onclick={() => tariffsStore.setDefaultTariff(tariff.key)}
|
||||
disabled={tariffsSaving ||
|
||||
tariff.enabled === false ||
|
||||
tariff.key === tariffsCatalog.default_tariff}
|
||||
>
|
||||
{at("btn_set_default", {}, "По умолчанию")}
|
||||
</AdminButton>
|
||||
<AdminButton
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onclick={() =>
|
||||
tariffsStore.updateState({
|
||||
tariffDeleteTarget: tariff,
|
||||
tariffDeleteOpen: true,
|
||||
})}
|
||||
disabled={tariffsSaving}
|
||||
aria-label={at("btn_delete_tariff", {}, "Удалить тариф")}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</AdminButton>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
{/if}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,365 @@
|
||||
<script>
|
||||
import { Label } from "$components/ui/primitives.js";
|
||||
import {
|
||||
AdminBadge,
|
||||
AdminButton,
|
||||
AdminEmptyState,
|
||||
AdminPagination,
|
||||
AdminSelect,
|
||||
AdminTable,
|
||||
AdminTableSkeleton,
|
||||
} from "$components/patterns/admin/index.js";
|
||||
import { getContext, onMount } from "svelte";
|
||||
import { trafficOfLabel } from "../../lib/admin/format.js";
|
||||
|
||||
export let at = (key) => key;
|
||||
export let fmtDateShort = (value) => value;
|
||||
export let panelStatusBadge = () => ({});
|
||||
export let resolvedAvatarUrl = () => "";
|
||||
export let userDisplayName = () => "";
|
||||
export let userInitials = () => "";
|
||||
export let userSecondaryName = () => "";
|
||||
|
||||
const usersStore = getContext("usersStore");
|
||||
|
||||
$: ({
|
||||
users,
|
||||
usersTotal,
|
||||
usersPage,
|
||||
usersQuery,
|
||||
usersFilter,
|
||||
usersPanelStatus,
|
||||
usersPremiumTraffic,
|
||||
usersSort,
|
||||
usersLoading,
|
||||
} = $usersStore);
|
||||
|
||||
const USERS_PAGE_SIZE = 25;
|
||||
$: usersHasMore = users.length === USERS_PAGE_SIZE;
|
||||
|
||||
const USERS_FILTER_OPTIONS = [
|
||||
{ value: "all", label: at("filter_all", {}, "Все") },
|
||||
{ value: "active", label: at("filter_not_banned", {}, "Не забанены") },
|
||||
{ value: "banned", label: at("filter_banned", {}, "Забанены") },
|
||||
{ value: "tg_linked", label: at("filter_tg_linked", {}, "С Telegram") },
|
||||
{ value: "no_tg", label: at("filter_no_tg", {}, "Без Telegram") },
|
||||
{ value: "email_linked", label: at("filter_email_linked", {}, "С email") },
|
||||
{ value: "no_email", label: at("filter_no_email", {}, "Без email") },
|
||||
{ value: "panel_linked", label: at("filter_panel_linked", {}, "С панелью") },
|
||||
];
|
||||
|
||||
const USERS_SORT_OPTIONS = [
|
||||
{ value: "registered_desc", label: at("sort_registered_desc", {}, "Сначала новые") },
|
||||
{ value: "registered_asc", label: at("sort_registered_asc", {}, "Сначала старые") },
|
||||
{ value: "name_asc", label: at("sort_name_asc", {}, "Имя ↑") },
|
||||
{ value: "name_desc", label: at("sort_name_desc", {}, "Имя ↓") },
|
||||
{ value: "id_asc", label: at("sort_id_asc", {}, "ID ↑") },
|
||||
{ value: "id_desc", label: at("sort_id_desc", {}, "ID ↓") },
|
||||
{ value: "premium_ratio_asc", label: at("sort_premium_ratio_asc", {}, "Премиум % ↑") },
|
||||
{ value: "premium_ratio_desc", label: at("sort_premium_ratio_desc", {}, "Премиум % ↓") },
|
||||
];
|
||||
|
||||
const USERS_PANEL_STATUS_OPTIONS = [
|
||||
{ value: "all", label: at("panel_status_all", {}, "Все статусы") },
|
||||
{ value: "active", label: at("status_active", {}, "active") },
|
||||
{ value: "expired", label: at("status_expired", {}, "expired") },
|
||||
{ value: "limited", label: at("status_limited", {}, "limited") },
|
||||
];
|
||||
|
||||
const USERS_PREMIUM_TRAFFIC_OPTIONS = [
|
||||
{ value: "all", label: at("premium_traffic_filter_all", {}, "Все (премиум)") },
|
||||
{ value: "none", label: at("premium_traffic_filter_none", {}, "Без лимита в тарифе") },
|
||||
{
|
||||
value: "unlimited",
|
||||
label: at("premium_traffic_filter_unlimited", {}, "Безлимит (оверрайд)"),
|
||||
},
|
||||
{ value: "good", label: at("premium_traffic_filter_good", {}, "Премиум: норма") },
|
||||
{ value: "warn", label: at("premium_traffic_filter_warn", {}, "Премиум: мало") },
|
||||
{ value: "critical", label: at("premium_traffic_filter_critical", {}, "Премиум: исчерпан") },
|
||||
];
|
||||
|
||||
/** @param {Record<string, unknown> | null | undefined} pt */
|
||||
function premiumTrafficBadgeVariant(pt) {
|
||||
if (!pt || pt.state === "none") return "muted";
|
||||
if (pt.state === "unlimited" || pt.state === "good") return "success";
|
||||
if (pt.state === "warn") return "warning";
|
||||
return "danger";
|
||||
}
|
||||
|
||||
/** @param {Record<string, unknown> | null | undefined} pt */
|
||||
function premiumTrafficBadgeText(pt) {
|
||||
if (!pt || pt.state === "none") return "";
|
||||
if (pt.state === "unlimited") return trafficOfLabel(pt.used_bytes, 0);
|
||||
return trafficOfLabel(pt.used_bytes, pt.limit_bytes);
|
||||
}
|
||||
|
||||
$: userTableHeaders = [
|
||||
at("user", {}, "Пользователь"),
|
||||
at("premium_traffic_filter_label", {}, "Премиум трафик"),
|
||||
at("status", {}, "Статус"),
|
||||
at("users_col_registration", {}, "Регистрация"),
|
||||
];
|
||||
|
||||
onMount(() => {
|
||||
usersStore.loadUsers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="admin-toolbar admin-toolbar-users">
|
||||
<div class="admin-toolbar-search">
|
||||
<input
|
||||
type="search"
|
||||
class="input"
|
||||
placeholder={at("users_search_placeholder", {}, "ID, @username или email")}
|
||||
value={usersQuery}
|
||||
on:input={(e) => usersStore.updateState({ usersQuery: e.target.value })}
|
||||
on:keydown={(e) =>
|
||||
e.key === "Enter" && (usersStore.updateState({ usersPage: 0 }), usersStore.loadUsers())}
|
||||
/>
|
||||
<AdminButton
|
||||
variant="primary"
|
||||
onclick={() => {
|
||||
usersStore.updateState({ usersPage: 0 });
|
||||
usersStore.loadUsers();
|
||||
}}>{at("find", {}, "Найти")}</AdminButton
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="admin-toolbar-controls">
|
||||
<Label.Root class="admin-toolbar-field">
|
||||
<span class="admin-toolbar-field-label">{at("filter", {}, "Фильтр")}</span>
|
||||
<AdminSelect
|
||||
value={usersFilter}
|
||||
items={USERS_FILTER_OPTIONS}
|
||||
class="admin-toolbar-select"
|
||||
ariaLabel={at("filter", {}, "Фильтр")}
|
||||
onValueChange={(value) => {
|
||||
usersStore.updateState({ usersFilter: value, usersPage: 0 });
|
||||
usersStore.loadUsers();
|
||||
}}
|
||||
/>
|
||||
</Label.Root>
|
||||
|
||||
<Label.Root class="admin-toolbar-field">
|
||||
<span class="admin-toolbar-field-label">{at("panel_status", {}, "Статус панели")}</span>
|
||||
<AdminSelect
|
||||
value={usersPanelStatus}
|
||||
items={USERS_PANEL_STATUS_OPTIONS}
|
||||
class="admin-toolbar-select"
|
||||
ariaLabel={at("panel_status", {}, "Статус панели")}
|
||||
onValueChange={(value) => {
|
||||
usersStore.updateState({ usersPanelStatus: value, usersPage: 0 });
|
||||
usersStore.loadUsers();
|
||||
}}
|
||||
/>
|
||||
</Label.Root>
|
||||
|
||||
<Label.Root class="admin-toolbar-field">
|
||||
<span class="admin-toolbar-field-label"
|
||||
>{at("premium_traffic_filter_label", {}, "Премиум трафик")}</span
|
||||
>
|
||||
<AdminSelect
|
||||
value={usersPremiumTraffic}
|
||||
items={USERS_PREMIUM_TRAFFIC_OPTIONS}
|
||||
class="admin-toolbar-select"
|
||||
ariaLabel={at("premium_traffic_filter_label", {}, "Премиум трафик")}
|
||||
onValueChange={(value) => {
|
||||
usersStore.updateState({ usersPremiumTraffic: value, usersPage: 0 });
|
||||
usersStore.loadUsers();
|
||||
}}
|
||||
/>
|
||||
</Label.Root>
|
||||
|
||||
<Label.Root class="admin-toolbar-field">
|
||||
<span class="admin-toolbar-field-label">{at("sort", {}, "Сортировка")}</span>
|
||||
<AdminSelect
|
||||
value={usersSort}
|
||||
items={USERS_SORT_OPTIONS}
|
||||
class="admin-toolbar-select"
|
||||
ariaLabel={at("sort", {}, "Сортировка")}
|
||||
onValueChange={(value) => {
|
||||
usersStore.updateState({ usersSort: value, usersPage: 0 });
|
||||
usersStore.loadUsers();
|
||||
}}
|
||||
/>
|
||||
</Label.Root>
|
||||
|
||||
<div class="admin-toolbar-summary">
|
||||
<span class="admin-toolbar-field-label">{at("total", {}, "Всего")}</span>
|
||||
<strong>{usersTotal}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-table-wrap admin-users-table-wrap">
|
||||
{#if usersLoading}
|
||||
<AdminTableSkeleton
|
||||
headers={userTableHeaders}
|
||||
rows={USERS_PAGE_SIZE}
|
||||
widths={["minmax(220px, 42%)", "minmax(140px, 28%)", "108px", "112px"]}
|
||||
/>
|
||||
{:else if !users.length}
|
||||
<AdminEmptyState tone="card"
|
||||
><span class="admin-muted">{at("users_empty", {}, "Никого не найдено")}</span
|
||||
></AdminEmptyState
|
||||
>
|
||||
{:else}
|
||||
<AdminTable class="admin-users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{at("user", {}, "Пользователь")}</th>
|
||||
<th>{at("premium_traffic_filter_label", {}, "Премиум трафик")}</th>
|
||||
<th>{at("status", {}, "Статус")}</th>
|
||||
<th>{at("users_col_registration", {}, "Регистрация")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each users as user}
|
||||
{@const avatar = resolvedAvatarUrl(user)}
|
||||
{@const badge = panelStatusBadge(user)}
|
||||
<tr
|
||||
class="is-clickable"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
data-user-id={user.user_id}
|
||||
on:click={() => usersStore.openUser(user)}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
usersStore.openUser(user);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td class="admin-users-cell-user" data-label={at("user", {}, "Пользователь")}>
|
||||
<div class="admin-users-cell-user-inner">
|
||||
<span class="admin-avatar admin-avatar-sm">
|
||||
{#if avatar}
|
||||
<img src={avatar} alt="" loading="lazy" referrerpolicy="no-referrer" />
|
||||
{:else}
|
||||
<span>{userInitials(user)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<div class="admin-users-cell-user-text">
|
||||
<span class="admin-users-cell-name">{userDisplayName(user)}</span>
|
||||
<span class="admin-users-cell-secondary">{userSecondaryName(user)}</span>
|
||||
<span class="admin-users-cell-id">#{user.user_id}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="admin-users-cell-premium"
|
||||
data-label={at("premium_traffic_filter_label", {}, "Премиум трафик")}
|
||||
>
|
||||
{#if user.premium_traffic && user.premium_traffic.state !== "none"}
|
||||
<AdminBadge
|
||||
variant={premiumTrafficBadgeVariant(user.premium_traffic)}
|
||||
class="admin-user-premium-badge"
|
||||
>
|
||||
{premiumTrafficBadgeText(user.premium_traffic)}
|
||||
</AdminBadge>
|
||||
{:else}
|
||||
<span class="admin-user-premium-placeholder"
|
||||
>{at("premium_traffic_na", {}, "—")}</span
|
||||
>
|
||||
{/if}
|
||||
</td>
|
||||
<td data-label={at("status", {}, "Статус")}>
|
||||
<AdminBadge variant={badge.variant}>{badge.label}</AdminBadge>
|
||||
</td>
|
||||
<td
|
||||
class="admin-users-cell-date admin-cell-mono"
|
||||
data-label={at("users_col_registration", {}, "Регистрация")}
|
||||
>
|
||||
{fmtDateShort(user.registration_date)}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</AdminTable>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<AdminPagination
|
||||
meta={`${at("page", {}, "Страница")} ${usersPage + 1}`}
|
||||
prevLabel={at("back", {}, "Назад")}
|
||||
nextLabel={at("next", {}, "Далее")}
|
||||
prevDisabled={usersPage === 0}
|
||||
nextDisabled={!usersHasMore}
|
||||
onPrev={() => {
|
||||
usersStore.updateState({ usersPage: Math.max(0, usersPage - 1) });
|
||||
usersStore.loadUsers();
|
||||
}}
|
||||
onNext={() => {
|
||||
usersStore.updateState({ usersPage: usersPage + 1 });
|
||||
usersStore.loadUsers();
|
||||
}}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.admin-users-cell-user-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-users-cell-user-text {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.admin-users-cell-name {
|
||||
font-weight: 650;
|
||||
font-size: 13px;
|
||||
line-height: 1.25;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-users-cell-secondary {
|
||||
font-size: 11px;
|
||||
color: var(--admin-dim);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-users-cell-id {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
|
||||
.admin-users-cell-premium {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-users-cell-premium :global(.admin-user-premium-badge) {
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.admin-user-premium-placeholder {
|
||||
color: var(--admin-dim);
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.admin-users-cell-date {
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
|
||||
.admin-users-table-wrap :global(.admin-users-table tbody tr.is-clickable:focus-visible) {
|
||||
outline: 2px solid var(--admin-ring);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
export function structuredCloneSafe(value) {
|
||||
if (typeof structuredClone === "function") return structuredClone(value);
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
export function pretty(value) {
|
||||
if (value === null || value === undefined) return "—";
|
||||
if (typeof value === "boolean") return value ? "Да" : "Нет";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function fmtDate(value) {
|
||||
if (!value) return "—";
|
||||
try {
|
||||
return new Date(value).toLocaleString("ru-RU");
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function fmtDateShort(value) {
|
||||
if (!value) return "—";
|
||||
try {
|
||||
return new Date(value).toLocaleDateString("ru-RU");
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function fmtMoney(amount, currency) {
|
||||
const sym = currency === "RUB" ? "₽" : currency || "";
|
||||
const num = Number(amount || 0);
|
||||
return `${num.toFixed(2)} ${sym}`.trim();
|
||||
}
|
||||
|
||||
export function fmtTrafficBytes(value) {
|
||||
const bytes = Number(value || 0);
|
||||
if (!bytes || bytes <= 0) return "0 GB";
|
||||
const gb = bytes / 1073741824;
|
||||
const formatted = gb >= 10 ? gb.toFixed(1) : gb.toFixed(2);
|
||||
return `${formatted.replace(/\.0+$/, "").replace(/(\.\d*[1-9])0+$/, "$1")} GB`;
|
||||
}
|
||||
|
||||
export function trafficPercentValue(used, limit) {
|
||||
const usedBytes = Number(used || 0);
|
||||
const limitBytes = Number(limit || 0);
|
||||
if (!limitBytes || limitBytes <= 0) return 0;
|
||||
return Math.max(0, Math.min(100, Math.round((usedBytes / limitBytes) * 100)));
|
||||
}
|
||||
|
||||
export function trafficLeftLabel(used, limit) {
|
||||
const limitBytes = Number(limit || 0);
|
||||
if (!limitBytes || limitBytes <= 0) return "Без лимита";
|
||||
return fmtTrafficBytes(Math.max(0, limitBytes - Number(used || 0)));
|
||||
}
|
||||
|
||||
export function trafficOfLabel(used, limit) {
|
||||
const limitBytes = Number(limit || 0);
|
||||
if (!limitBytes || limitBytes <= 0) return `${fmtTrafficBytes(used)} / без лимита`;
|
||||
return `${fmtTrafficBytes(used)} / ${fmtTrafficBytes(limit)}`;
|
||||
}
|
||||
|
||||
export function paymentStatusVariant(status) {
|
||||
if (status === "succeeded") return "success";
|
||||
if (typeof status === "string" && status.startsWith("pending")) return "warning";
|
||||
return "danger";
|
||||
}
|
||||
|
||||
export function optionLabel(options, value) {
|
||||
return options.find((option) => option.value === value)?.label || value;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/** @typedef {{ date: string, amount: number }} RevenuePoint */
|
||||
|
||||
/**
|
||||
* @param {string} iso
|
||||
* @returns {number} UTC ms at noon (stable day bucket)
|
||||
*/
|
||||
function noonUtcMs(iso) {
|
||||
const s = String(iso || "");
|
||||
const t = Date.parse(s.includes("T") ? s : `${s}T12:00:00Z`);
|
||||
return Number.isFinite(t) ? t : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} t
|
||||
* @returns {string} YYYY-MM-DD UTC
|
||||
*/
|
||||
function isoUtcDateFromMs(t) {
|
||||
const d = new Date(t);
|
||||
const y = d.getUTCFullYear();
|
||||
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getUTCDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Monday 00:00 UTC for the week containing `iso` (date-only).
|
||||
* @param {string} iso
|
||||
*/
|
||||
export function utcWeekStartMs(iso) {
|
||||
const d = new Date(iso.includes("T") ? iso : `${iso}T12:00:00Z`);
|
||||
const dow = d.getUTCDay();
|
||||
const offset = (dow + 6) % 7;
|
||||
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* First day of month (UTC) containing `iso`.
|
||||
* @param {string} iso
|
||||
*/
|
||||
export function utcMonthStartMs(iso) {
|
||||
const d = new Date(iso.includes("T") ? iso : `${iso}T12:00:00Z`);
|
||||
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RevenuePoint[]} points sorted ascending by `date`
|
||||
* @param {string} fromIso YYYY-MM-DD inclusive
|
||||
* @param {string} toIso YYYY-MM-DD inclusive
|
||||
* @returns {RevenuePoint[]}
|
||||
*/
|
||||
export function filterDailyByIsoRange(points, fromIso, toIso) {
|
||||
if (!fromIso || !toIso) return [];
|
||||
return points.filter((p) => p.date >= fromIso && p.date <= toIso);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RevenuePoint[]} points sorted ascending
|
||||
* @param {number} n
|
||||
*/
|
||||
export function sliceLastDays(points, n) {
|
||||
if (!points?.length || n <= 0) return [];
|
||||
const take = Math.min(n, points.length);
|
||||
return points.slice(-take);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RevenuePoint[]} daily sorted ascending, day granularity
|
||||
* @returns {RevenuePoint[]}
|
||||
*/
|
||||
function bucketWeeks(daily) {
|
||||
/** @type {Map<number, number>} */
|
||||
const sums = new Map();
|
||||
for (const p of daily) {
|
||||
const k = utcWeekStartMs(p.date);
|
||||
const amt = Number(p.amount) || 0;
|
||||
sums.set(k, (sums.get(k) || 0) + amt);
|
||||
}
|
||||
return [...sums.entries()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([ms, amount]) => ({ date: isoUtcDateFromMs(ms), amount }));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RevenuePoint[]} daily sorted ascending
|
||||
* @returns {RevenuePoint[]}
|
||||
*/
|
||||
function bucketMonths(daily) {
|
||||
/** @type {Map<number, number>} */
|
||||
const sums = new Map();
|
||||
for (const p of daily) {
|
||||
const k = utcMonthStartMs(p.date);
|
||||
const amt = Number(p.amount) || 0;
|
||||
sums.set(k, (sums.get(k) || 0) + amt);
|
||||
}
|
||||
return [...sums.entries()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([ms, amount]) => ({ date: isoUtcDateFromMs(ms), amount }));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RevenuePoint[]} dailySorted ascending by date, consecutive calendar days
|
||||
* @param {"day" | "week" | "month"} granularity
|
||||
*/
|
||||
export function aggregateRevenueSeries(dailySorted, granularity) {
|
||||
if (!dailySorted?.length) return [];
|
||||
if (granularity === "week") return bucketWeeks(dailySorted);
|
||||
if (granularity === "month") return bucketMonths(dailySorted);
|
||||
return dailySorted.map((p) => ({ date: p.date, amount: Number(p.amount) || 0 }));
|
||||
}
|
||||
|
||||
/**
|
||||
* For chart hint: calendar span of inclusive range.
|
||||
* @param {string} fromIso
|
||||
* @param {string} toIso
|
||||
*/
|
||||
export function inclusiveDaySpan(fromIso, toIso) {
|
||||
const a = noonUtcMs(fromIso);
|
||||
const b = noonUtcMs(toIso);
|
||||
if (!a || !b) return 0;
|
||||
return Math.max(1, Math.round((b - a) / 86400000) + 1);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
export function createAdsStore({ api, onToast, at }) {
|
||||
const state = writable({
|
||||
ads: [],
|
||||
adsTotals: null,
|
||||
adsLoading: false,
|
||||
adCreateOpen: false,
|
||||
adDraft: { source: "", start_param: "", cost: 0 },
|
||||
});
|
||||
|
||||
async function loadAds() {
|
||||
state.update((s) => ({ ...s, adsLoading: true }));
|
||||
try {
|
||||
const data = await api("/admin/ads");
|
||||
if (data?.ok) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
ads: data.campaigns || [],
|
||||
adsTotals: data.totals || {},
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, adsLoading: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function createAd() {
|
||||
let draft = null;
|
||||
state.update((s) => {
|
||||
draft = s.adDraft;
|
||||
return s;
|
||||
});
|
||||
if (!draft.source.trim() || !draft.start_param.trim()) return;
|
||||
|
||||
const res = await api("/admin/ads", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(draft),
|
||||
});
|
||||
|
||||
if (res?.ok) {
|
||||
onToast(at("ad_created", {}, "Кампания создана"));
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
adCreateOpen: false,
|
||||
adDraft: { source: "", start_param: "", cost: 0 },
|
||||
}));
|
||||
await loadAds();
|
||||
} else {
|
||||
onToast(res?.error || at("error", {}, "Ошибка"));
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAd(ad) {
|
||||
const res = await api(`/admin/ads/${ad.id}/toggle`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ is_active: !ad.is_active }),
|
||||
});
|
||||
if (res?.ok) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
ads: s.ads.map((c) => (c.id === ad.id ? { ...c, is_active: !ad.is_active } : c)),
|
||||
}));
|
||||
} else {
|
||||
onToast(res?.error || at("error", {}, "Ошибка"));
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAd(ad) {
|
||||
const res = await api(`/admin/ads/${ad.id}`, { method: "DELETE" });
|
||||
if (res?.ok) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
ads: s.ads.filter((c) => c.id !== ad.id),
|
||||
}));
|
||||
onToast(at("ad_deleted", {}, "Кампания удалена"));
|
||||
} else {
|
||||
onToast(res?.error || at("error", {}, "Ошибка"));
|
||||
}
|
||||
}
|
||||
|
||||
function setCreateOpen(open) {
|
||||
state.update((s) => ({ ...s, adCreateOpen: open }));
|
||||
}
|
||||
|
||||
function updateDraft(fields) {
|
||||
state.update((s) => ({ ...s, adDraft: { ...s.adDraft, ...fields } }));
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: state.subscribe,
|
||||
set: state.set,
|
||||
update: state.update,
|
||||
loadAds,
|
||||
createAd,
|
||||
toggleAd,
|
||||
deleteAd,
|
||||
setCreateOpen,
|
||||
updateDraft,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
export function createBroadcastStore({ api, onToast, at }) {
|
||||
const state = writable({
|
||||
broadcastTarget: "all",
|
||||
broadcastText: "",
|
||||
broadcastBusy: false,
|
||||
broadcastResult: null,
|
||||
});
|
||||
|
||||
const BROADCAST_TARGET_OPTIONS = [
|
||||
{ value: "all", label: at("broadcast_target_all", {}, "Все активные") },
|
||||
{ value: "active", label: at("broadcast_target_active", {}, "С подпиской") },
|
||||
{ value: "inactive", label: at("broadcast_target_inactive", {}, "Без подписки") },
|
||||
];
|
||||
|
||||
async function runBroadcast() {
|
||||
let text = "";
|
||||
let target = "";
|
||||
state.update((s) => {
|
||||
text = s.broadcastText;
|
||||
target = s.broadcastTarget;
|
||||
s.broadcastBusy = true;
|
||||
s.broadcastResult = null;
|
||||
return s;
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await api("/admin/broadcast", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ target, text }),
|
||||
});
|
||||
if (res?.ok) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
broadcastText: "",
|
||||
broadcastResult: { queued: res.queued || 0, failed: res.failed || 0 },
|
||||
}));
|
||||
onToast(at("broadcast_started", {}, "Рассылка запущена"));
|
||||
} else {
|
||||
onToast(res?.error || at("broadcast_failed", {}, "Ошибка рассылки"));
|
||||
}
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, broadcastBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
function updateField(fields) {
|
||||
state.update((s) => ({ ...s, ...fields }));
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: state.subscribe,
|
||||
set: state.set,
|
||||
update: state.update,
|
||||
runBroadcast,
|
||||
updateField,
|
||||
BROADCAST_TARGET_OPTIONS,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
export function createLogsStore({ api }) {
|
||||
const state = writable({
|
||||
logs: [],
|
||||
logsTotal: 0,
|
||||
logsPage: 0,
|
||||
logsUserFilter: "",
|
||||
logsLoading: false,
|
||||
});
|
||||
|
||||
const LOGS_PAGE_SIZE = 50;
|
||||
|
||||
async function loadLogs() {
|
||||
state.update((s) => ({ ...s, logsLoading: true }));
|
||||
let currentPage = 0;
|
||||
let filter = "";
|
||||
state.update((s) => {
|
||||
currentPage = s.logsPage;
|
||||
filter = s.logsUserFilter;
|
||||
return s;
|
||||
});
|
||||
|
||||
try {
|
||||
let q = `/admin/logs?page=${currentPage}&page_size=${LOGS_PAGE_SIZE}`;
|
||||
if (filter.trim()) {
|
||||
q += `&user_id=${encodeURIComponent(filter.trim())}`;
|
||||
}
|
||||
const data = await api(q);
|
||||
if (data?.ok) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
logs: data.logs || [],
|
||||
logsTotal: data.total || 0,
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, logsLoading: false }));
|
||||
}
|
||||
}
|
||||
|
||||
function setPage(page) {
|
||||
state.update((s) => ({ ...s, logsPage: page }));
|
||||
loadLogs();
|
||||
}
|
||||
|
||||
function setFilter(filter) {
|
||||
state.update((s) => ({ ...s, logsUserFilter: filter }));
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: state.subscribe,
|
||||
set: state.set,
|
||||
update: state.update,
|
||||
loadLogs,
|
||||
setPage,
|
||||
setFilter,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
export function createPaymentsStore({ api }) {
|
||||
const state = writable({
|
||||
payments: [],
|
||||
paymentsTotal: 0,
|
||||
paymentsPage: 0,
|
||||
paymentsLoading: false,
|
||||
});
|
||||
|
||||
const PAYMENTS_PAGE_SIZE = 25;
|
||||
|
||||
async function loadPayments() {
|
||||
state.update((s) => ({ ...s, paymentsLoading: true }));
|
||||
let currentPage = 0;
|
||||
state.update((s) => {
|
||||
currentPage = s.paymentsPage;
|
||||
return s;
|
||||
});
|
||||
|
||||
try {
|
||||
const data = await api(`/admin/payments?page=${currentPage}&page_size=${PAYMENTS_PAGE_SIZE}`);
|
||||
if (data?.ok) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
payments: data.payments || [],
|
||||
paymentsTotal: data.total || 0,
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, paymentsLoading: false }));
|
||||
}
|
||||
}
|
||||
|
||||
function setPage(page) {
|
||||
state.update((s) => ({ ...s, paymentsPage: page }));
|
||||
loadPayments();
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: state.subscribe,
|
||||
set: state.set,
|
||||
update: state.update,
|
||||
loadPayments,
|
||||
setPage,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
export function createPromosStore({ api, onToast }) {
|
||||
const state = writable({
|
||||
promos: [],
|
||||
promosTotal: 0,
|
||||
promosPage: 0,
|
||||
promosLoading: false,
|
||||
promoCreateOpen: false,
|
||||
promoDraft: { code: "", bonus_days: 7, max_activations: 1, valid_days: 30 },
|
||||
});
|
||||
|
||||
const PROMOS_PAGE_SIZE = 25;
|
||||
|
||||
async function loadPromos() {
|
||||
state.update((s) => ({ ...s, promosLoading: true }));
|
||||
let currentPage = 0;
|
||||
state.update((s) => {
|
||||
currentPage = s.promosPage;
|
||||
return s;
|
||||
});
|
||||
try {
|
||||
const data = await api(`/admin/promos?page=${currentPage}&page_size=${PROMOS_PAGE_SIZE}`);
|
||||
if (data?.ok) {
|
||||
state.update((s) => ({ ...s, promos: data.promos || [], promosTotal: data.total || 0 }));
|
||||
}
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, promosLoading: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function createPromo() {
|
||||
let draft = null;
|
||||
state.update((s) => {
|
||||
draft = s.promoDraft;
|
||||
return s;
|
||||
});
|
||||
if (!draft.code.trim()) return;
|
||||
|
||||
const res = await api("/admin/promos", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(draft),
|
||||
});
|
||||
|
||||
if (res?.ok) {
|
||||
onToast("Промокод создан");
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
promoCreateOpen: false,
|
||||
promoDraft: { code: "", bonus_days: 7, max_activations: 1, valid_days: 30 },
|
||||
}));
|
||||
await loadPromos();
|
||||
} else {
|
||||
onToast(res?.error || "Ошибка");
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePromo(promo) {
|
||||
const res = await api(`/admin/promos/${promo.id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ is_active: !promo.is_active }),
|
||||
});
|
||||
if (res?.ok) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
promos: s.promos.map((p) => (p.id === promo.id ? res.promo : p)),
|
||||
}));
|
||||
} else {
|
||||
onToast(res?.error || "Ошибка");
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePromo(promo) {
|
||||
const res = await api(`/admin/promos/${promo.id}`, { method: "DELETE" });
|
||||
if (res?.ok) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
promos: s.promos.filter((p) => p.id !== promo.id),
|
||||
}));
|
||||
onToast("Промокод удалён");
|
||||
} else {
|
||||
onToast(res?.error || "Ошибка");
|
||||
}
|
||||
}
|
||||
|
||||
function setPage(page) {
|
||||
state.update((s) => ({ ...s, promosPage: page }));
|
||||
loadPromos();
|
||||
}
|
||||
|
||||
function setCreateOpen(open) {
|
||||
state.update((s) => ({ ...s, promoCreateOpen: open }));
|
||||
}
|
||||
|
||||
function updateDraft(fields) {
|
||||
state.update((s) => ({ ...s, promoDraft: { ...s.promoDraft, ...fields } }));
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: state.subscribe,
|
||||
set: state.set,
|
||||
update: state.update,
|
||||
loadPromos,
|
||||
createPromo,
|
||||
togglePromo,
|
||||
deletePromo,
|
||||
setPage,
|
||||
setCreateOpen,
|
||||
updateDraft,
|
||||
PROMOS_PAGE_SIZE,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
export function createSettingsStore({ api, onToast, at }) {
|
||||
const state = writable({
|
||||
settingsSections: [],
|
||||
settingsLoading: false,
|
||||
settingsDirty: {},
|
||||
settingsSaving: false,
|
||||
});
|
||||
|
||||
async function loadSettings() {
|
||||
state.update((s) => ({ ...s, settingsLoading: true, settingsDirty: {} }));
|
||||
try {
|
||||
const data = await api("/admin/settings");
|
||||
if (data?.ok) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
settingsSections: data.sections || [],
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, settingsLoading: false }));
|
||||
}
|
||||
}
|
||||
|
||||
function markDirty(key, value, deleted = false) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
settingsDirty: { ...s.settingsDirty, [key]: { value, deleted } },
|
||||
}));
|
||||
}
|
||||
|
||||
function clearDirty(key) {
|
||||
state.update((s) => {
|
||||
const next = { ...s.settingsDirty };
|
||||
delete next[key];
|
||||
return { ...s, settingsDirty: next };
|
||||
});
|
||||
}
|
||||
|
||||
function setFieldValue(key, value) {
|
||||
state.update((s) => {
|
||||
const nextDirty = { ...s.settingsDirty };
|
||||
delete nextDirty[key];
|
||||
return {
|
||||
...s,
|
||||
settingsDirty: nextDirty,
|
||||
settingsSections: (s.settingsSections || []).map((section) => ({
|
||||
...section,
|
||||
fields: (section.fields || []).map((field) =>
|
||||
field.key === key ? { ...field, value, overridden: true } : field
|
||||
),
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function saveSettings(onSettingsSaved) {
|
||||
let dirty = {};
|
||||
state.update((s) => {
|
||||
dirty = s.settingsDirty;
|
||||
return s;
|
||||
});
|
||||
if (!Object.keys(dirty).length) return true;
|
||||
|
||||
state.update((s) => ({ ...s, settingsSaving: true }));
|
||||
try {
|
||||
const updates = {};
|
||||
const deletes = [];
|
||||
for (const [key, change] of Object.entries(dirty)) {
|
||||
if (change.deleted) deletes.push(key);
|
||||
else updates[key] = change.value;
|
||||
}
|
||||
const res = await api("/admin/settings", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ updates, deletes }),
|
||||
});
|
||||
if (res?.ok) {
|
||||
onToast(at("settings_saved", {}, "Настройки сохранены"));
|
||||
state.update((s) => ({ ...s, settingsDirty: {} }));
|
||||
if (onSettingsSaved) await onSettingsSaved({ updates, deletes });
|
||||
await loadSettings();
|
||||
return true;
|
||||
} else if (res?.errors) {
|
||||
const summary = Object.entries(res.errors)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join("; ");
|
||||
onToast(`Ошибки: ${summary}`);
|
||||
} else {
|
||||
onToast(res?.error || "Ошибка");
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, settingsSaving: false }));
|
||||
}
|
||||
}
|
||||
|
||||
function resetField(field) {
|
||||
if (field.overridden) {
|
||||
markDirty(field.key, "", true);
|
||||
} else {
|
||||
clearDirty(field.key);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: state.subscribe,
|
||||
set: state.set,
|
||||
update: state.update,
|
||||
loadSettings,
|
||||
markDirty,
|
||||
clearDirty,
|
||||
setFieldValue,
|
||||
resetField,
|
||||
saveSettings,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
export function createStatsStore({ api, onToast, at }) {
|
||||
const state = writable({
|
||||
stats: null,
|
||||
statsLoading: false,
|
||||
statsError: "",
|
||||
syncBusy: false,
|
||||
});
|
||||
|
||||
async function loadStats() {
|
||||
state.update((s) => ({ ...s, statsLoading: true, statsError: "" }));
|
||||
try {
|
||||
const data = await api("/admin/stats");
|
||||
if (!data?.ok) {
|
||||
state.update((s) => ({ ...s, statsError: data?.error || "load_failed" }));
|
||||
} else {
|
||||
state.update((s) => ({ ...s, stats: data }));
|
||||
}
|
||||
} catch (e) {
|
||||
state.update((s) => ({ ...s, statsError: e?.message || String(e) }));
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, statsLoading: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerSync() {
|
||||
let busy = false;
|
||||
state.update((s) => {
|
||||
busy = s.syncBusy;
|
||||
return s;
|
||||
});
|
||||
if (busy) return;
|
||||
|
||||
state.update((s) => ({ ...s, syncBusy: true }));
|
||||
try {
|
||||
const res = await api("/admin/sync", { method: "POST" });
|
||||
if (res?.ok) {
|
||||
onToast(at("sync_started", {}, "Синхронизация запущена"));
|
||||
await loadStats();
|
||||
} else {
|
||||
onToast(res?.error || at("sync_error", {}, "Ошибка синхронизации"));
|
||||
}
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, syncBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: state.subscribe,
|
||||
set: state.set,
|
||||
update: state.update,
|
||||
loadStats,
|
||||
triggerSync,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import { writable } from "svelte/store";
|
||||
import {
|
||||
emptyTariffDraft,
|
||||
cloneCatalog,
|
||||
draftFromTariff,
|
||||
tariffFromDraft as tariffFromDraftFn,
|
||||
normalizeUuidList,
|
||||
} from "../tariffDraft.js";
|
||||
|
||||
export function createTariffsStore({ api, onTariffsSaved, flash, at }) {
|
||||
const state = writable({
|
||||
tariffsCatalog: {
|
||||
default_tariff: "",
|
||||
topup_packages_default: { rub: [], stars: [] },
|
||||
tariffs: [],
|
||||
},
|
||||
tariffsPath: "",
|
||||
tariffsLoading: false,
|
||||
tariffsSaving: false,
|
||||
tariffEditorOpen: false,
|
||||
tariffEditingKey: "",
|
||||
tariffDeleteOpen: false,
|
||||
tariffDeleteTarget: null,
|
||||
tariffDraft: emptyTariffDraft(),
|
||||
panelSquads: [],
|
||||
panelSquadsLoading: false,
|
||||
selectedBaseSquad: "",
|
||||
selectedPremiumSquad: "",
|
||||
tariffEditorTab: "general",
|
||||
});
|
||||
|
||||
const tariffFromDraft = (draft) => tariffFromDraftFn(draft);
|
||||
|
||||
async function loadTariffs() {
|
||||
state.update((s) => ({ ...s, tariffsLoading: true }));
|
||||
try {
|
||||
loadPanelSquads();
|
||||
const data = await api("/admin/tariffs");
|
||||
if (data?.ok) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
tariffsCatalog: cloneCatalog(data.catalog),
|
||||
tariffsPath: data.path || "",
|
||||
}));
|
||||
} else {
|
||||
flash(data?.message || data?.error || at("load_failed", {}, "Не удалось загрузить тарифы"));
|
||||
}
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, tariffsLoading: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPanelSquads() {
|
||||
let loading = false;
|
||||
state.update((s) => {
|
||||
loading = s.panelSquadsLoading;
|
||||
return s;
|
||||
});
|
||||
if (loading) return;
|
||||
|
||||
state.update((s) => ({ ...s, panelSquadsLoading: true }));
|
||||
try {
|
||||
const data = await api("/admin/panel/internal-squads");
|
||||
if (data?.ok) state.update((s) => ({ ...s, panelSquads: data.squads || [] }));
|
||||
} catch (_error) {
|
||||
void _error;
|
||||
state.update((s) => ({ ...s, panelSquads: [] }));
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, panelSquadsLoading: false }));
|
||||
}
|
||||
}
|
||||
|
||||
function squadLabel(uuid) {
|
||||
let squads = [];
|
||||
state.update((s) => {
|
||||
squads = s.panelSquads;
|
||||
return s;
|
||||
});
|
||||
const squad = squads.find((item) => item.uuid === uuid);
|
||||
return squad ? `${squad.name} · ${uuid.slice(0, 8)}…` : uuid;
|
||||
}
|
||||
|
||||
function addSquadToDraft(field, uuid) {
|
||||
if (!uuid) return;
|
||||
state.update((s) => {
|
||||
const current = normalizeUuidList(s.tariffDraft[field]);
|
||||
if (current.includes(uuid)) return s;
|
||||
return { ...s, tariffDraft: { ...s.tariffDraft, [field]: [...current, uuid] } };
|
||||
});
|
||||
}
|
||||
|
||||
function removeSquadFromDraft(field, uuid) {
|
||||
state.update((s) => {
|
||||
return {
|
||||
...s,
|
||||
tariffDraft: {
|
||||
...s.tariffDraft,
|
||||
[field]: normalizeUuidList(s.tariffDraft[field]).filter((item) => item !== uuid),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function persistTariffs(nextCatalog, successText) {
|
||||
state.update((s) => ({ ...s, tariffsSaving: true }));
|
||||
let currentPath = "";
|
||||
state.update((s) => {
|
||||
currentPath = s.tariffsPath;
|
||||
return s;
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await api("/admin/tariffs", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ catalog: nextCatalog }),
|
||||
});
|
||||
if (res?.ok) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
tariffsCatalog: cloneCatalog(res.catalog),
|
||||
tariffsPath: res.path || currentPath,
|
||||
tariffEditorOpen: false,
|
||||
tariffDeleteOpen: false,
|
||||
tariffDeleteTarget: null,
|
||||
}));
|
||||
if (onTariffsSaved) await onTariffsSaved(res.catalog);
|
||||
flash(successText || at("tariffs_saved", {}, "Тарифы сохранены"));
|
||||
} else {
|
||||
flash(
|
||||
res?.message || res?.error || at("tariffs_save_failed", {}, "Ошибка сохранения тарифов")
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, tariffsSaving: false }));
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateTariff() {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
tariffEditingKey: "",
|
||||
tariffDraft: emptyTariffDraft(),
|
||||
tariffEditorTab: "general",
|
||||
selectedBaseSquad: "",
|
||||
selectedPremiumSquad: "",
|
||||
tariffEditorOpen: true,
|
||||
}));
|
||||
}
|
||||
|
||||
function openEditTariff(tariff) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
tariffEditingKey: tariff.key,
|
||||
tariffDraft: draftFromTariff(tariff),
|
||||
tariffEditorTab: "general",
|
||||
selectedBaseSquad: "",
|
||||
selectedPremiumSquad: "",
|
||||
tariffEditorOpen: true,
|
||||
}));
|
||||
}
|
||||
|
||||
async function saveTariffDraft() {
|
||||
let s;
|
||||
state.update((st) => {
|
||||
s = st;
|
||||
return st;
|
||||
});
|
||||
const tariff = tariffFromDraft(s.tariffDraft);
|
||||
if (!tariff.key) {
|
||||
flash(at("tariff_error_key_required", {}, "Укажите ключ тарифа"));
|
||||
return;
|
||||
}
|
||||
const existing = (s.tariffsCatalog.tariffs || []).find(
|
||||
(item) => item.key === tariff.key && item.key !== s.tariffEditingKey
|
||||
);
|
||||
if (existing) {
|
||||
flash(at("tariff_error_key_exists", {}, "Тариф с таким ключом уже есть"));
|
||||
return;
|
||||
}
|
||||
const current = s.tariffsCatalog.tariffs || [];
|
||||
const tariffs = s.tariffEditingKey
|
||||
? current.map((item) => (item.key === s.tariffEditingKey ? tariff : item))
|
||||
: [...current, tariff];
|
||||
const enabledKeys = tariffs.filter((item) => item.enabled !== false).map((item) => item.key);
|
||||
if (!enabledKeys.length) {
|
||||
flash(at("tariff_error_min_enabled", {}, "Должен быть хотя бы один включённый тариф"));
|
||||
return;
|
||||
}
|
||||
const currentDefault =
|
||||
s.tariffsCatalog.default_tariff === s.tariffEditingKey
|
||||
? tariff.key
|
||||
: s.tariffsCatalog.default_tariff;
|
||||
const defaultTariff = enabledKeys.includes(currentDefault) ? currentDefault : enabledKeys[0];
|
||||
await persistTariffs(
|
||||
{ ...cloneCatalog(s.tariffsCatalog), default_tariff: defaultTariff, tariffs },
|
||||
at("tariff_saved", {}, "Тариф сохранён")
|
||||
);
|
||||
}
|
||||
|
||||
async function toggleTariffEnabled(tariff) {
|
||||
let s;
|
||||
state.update((st) => {
|
||||
s = st;
|
||||
return st;
|
||||
});
|
||||
const tariffs = (s.tariffsCatalog.tariffs || []).map((item) =>
|
||||
item.key === tariff.key ? { ...item, enabled: item.enabled === false } : item
|
||||
);
|
||||
const enabledKeys = tariffs.filter((item) => item.enabled !== false).map((item) => item.key);
|
||||
if (!enabledKeys.length) {
|
||||
flash(at("tariff_error_min_enabled", {}, "Должен остаться хотя бы один включённый тариф"));
|
||||
return;
|
||||
}
|
||||
const defaultTariff = enabledKeys.includes(s.tariffsCatalog.default_tariff)
|
||||
? s.tariffsCatalog.default_tariff
|
||||
: enabledKeys[0];
|
||||
await persistTariffs(
|
||||
{ ...cloneCatalog(s.tariffsCatalog), default_tariff: defaultTariff, tariffs },
|
||||
at("tariff_status_updated", {}, "Статус тарифа обновлён")
|
||||
);
|
||||
}
|
||||
|
||||
async function setDefaultTariff(key) {
|
||||
let s;
|
||||
state.update((st) => {
|
||||
s = st;
|
||||
return st;
|
||||
});
|
||||
if (!key || key === s.tariffsCatalog.default_tariff) return;
|
||||
await persistTariffs(
|
||||
{ ...cloneCatalog(s.tariffsCatalog), default_tariff: key },
|
||||
at("tariff_default_updated", {}, "Тариф по умолчанию обновлён")
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteTariff() {
|
||||
let s;
|
||||
state.update((st) => {
|
||||
s = st;
|
||||
return st;
|
||||
});
|
||||
if (!s.tariffDeleteTarget) return;
|
||||
const tariffs = (s.tariffsCatalog.tariffs || []).filter(
|
||||
(item) => item.key !== s.tariffDeleteTarget.key
|
||||
);
|
||||
const enabledKeys = tariffs.filter((item) => item.enabled !== false).map((item) => item.key);
|
||||
if (!enabledKeys.length) {
|
||||
flash(
|
||||
at("tariff_error_delete_last_enabled", {}, "Нельзя удалить последний включённый тариф")
|
||||
);
|
||||
return;
|
||||
}
|
||||
const defaultTariff = enabledKeys.includes(s.tariffsCatalog.default_tariff)
|
||||
? s.tariffsCatalog.default_tariff
|
||||
: enabledKeys[0];
|
||||
await persistTariffs(
|
||||
{ ...cloneCatalog(s.tariffsCatalog), default_tariff: defaultTariff, tariffs },
|
||||
at("tariff_deleted", {}, "Тариф удалён")
|
||||
);
|
||||
}
|
||||
|
||||
function addDraftRow(field, row) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
tariffDraft: { ...s.tariffDraft, [field]: [...(s.tariffDraft[field] || []), row] },
|
||||
}));
|
||||
}
|
||||
|
||||
function removeDraftRow(field, index) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
tariffDraft: {
|
||||
...s.tariffDraft,
|
||||
[field]: (s.tariffDraft[field] || []).filter((_, idx) => idx !== index),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function updateState(updates) {
|
||||
state.update((s) => ({ ...s, ...updates }));
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: state.subscribe,
|
||||
set: state.set,
|
||||
update: state.update,
|
||||
updateState,
|
||||
loadTariffs,
|
||||
loadPanelSquads,
|
||||
squadLabel,
|
||||
addSquadToDraft,
|
||||
removeSquadFromDraft,
|
||||
openCreateTariff,
|
||||
openEditTariff,
|
||||
saveTariffDraft,
|
||||
toggleTariffEnabled,
|
||||
setDefaultTariff,
|
||||
deleteTariff,
|
||||
addDraftRow,
|
||||
removeDraftRow,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
function cloneCatalog(catalog) {
|
||||
return JSON.parse(JSON.stringify(catalog || { default_theme: "dark", themes: [] }));
|
||||
}
|
||||
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
export function createThemesStore({ api, onThemesSaved, flash, at }) {
|
||||
const state = writable({
|
||||
themesCatalog: { default_theme: "dark", themes: [] },
|
||||
themesDir: "",
|
||||
themesLoading: false,
|
||||
themesSaving: false,
|
||||
});
|
||||
|
||||
async function loadThemes() {
|
||||
state.update((s) => ({ ...s, themesLoading: true }));
|
||||
try {
|
||||
const data = await api("/admin/themes");
|
||||
if (data?.ok) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
themesCatalog: cloneCatalog(data.catalog),
|
||||
themesDir: data.themes_dir || "",
|
||||
}));
|
||||
} else {
|
||||
flash(data?.message || data?.error || at("load_failed", {}, "Не удалось загрузить темы"));
|
||||
}
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, themesLoading: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function saveThemes(options = {}) {
|
||||
const silent = Boolean(options.silent);
|
||||
let catalog = null;
|
||||
state.update((s) => {
|
||||
catalog = cloneCatalog(s.themesCatalog);
|
||||
return { ...s, themesSaving: true };
|
||||
});
|
||||
try {
|
||||
const data = await api("/admin/themes", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ catalog }),
|
||||
});
|
||||
if (data?.ok) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
themesCatalog: cloneCatalog(data.catalog),
|
||||
themesDir: data.themes_dir || s.themesDir,
|
||||
}));
|
||||
if (!silent) flash(at("themes_saved", {}, "Темы сохранены"));
|
||||
if (typeof onThemesSaved === "function") onThemesSaved();
|
||||
} else {
|
||||
flash(data?.message || data?.error || at("themes_save_failed", {}, "Не удалось сохранить"));
|
||||
}
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, themesSaving: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadLogoFile(file) {
|
||||
if (!file) return null;
|
||||
state.update((s) => ({ ...s, themesSaving: true }));
|
||||
try {
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
const data = await api("/admin/appearance/logo", {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
if (data?.ok) {
|
||||
flash(
|
||||
at(
|
||||
"appearance_logo_uploaded_pending",
|
||||
{},
|
||||
"Логотип загружен и применен."
|
||||
)
|
||||
);
|
||||
return { logoUrl: data.logo_url || "", faviconUrl: data.favicon_url || "" };
|
||||
}
|
||||
flash(
|
||||
data?.message ||
|
||||
data?.error ||
|
||||
at("appearance_logo_upload_failed", {}, "Не удалось загрузить логотип")
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, themesSaving: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadLogoUrl(url) {
|
||||
const sourceUrl = String(url || "").trim();
|
||||
if (!sourceUrl) return null;
|
||||
state.update((s) => ({ ...s, themesSaving: true }));
|
||||
try {
|
||||
const data = await api("/admin/appearance/logo", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url: sourceUrl }),
|
||||
});
|
||||
if (data?.ok) {
|
||||
flash(
|
||||
at(
|
||||
"appearance_logo_uploaded_pending",
|
||||
{},
|
||||
"Логотип загружен и применен."
|
||||
)
|
||||
);
|
||||
return { logoUrl: data.logo_url || "", faviconUrl: data.favicon_url || "" };
|
||||
}
|
||||
flash(
|
||||
data?.message ||
|
||||
data?.error ||
|
||||
at("appearance_logo_upload_failed", {}, "Не удалось загрузить логотип")
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, themesSaving: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFaviconFile(file) {
|
||||
if (!file) return null;
|
||||
state.update((s) => ({ ...s, themesSaving: true }));
|
||||
try {
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
const data = await api("/admin/appearance/favicon", {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
if (data?.ok) {
|
||||
flash(
|
||||
at(
|
||||
"appearance_favicon_uploaded_pending",
|
||||
{},
|
||||
"Favicon загружена и применена."
|
||||
)
|
||||
);
|
||||
return { faviconUrl: data.favicon_url || "", variants: data.variants || {} };
|
||||
}
|
||||
flash(
|
||||
data?.message ||
|
||||
data?.error ||
|
||||
at("appearance_favicon_upload_failed", {}, "Не удалось загрузить favicon")
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, themesSaving: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFaviconUrl(url) {
|
||||
const sourceUrl = String(url || "").trim();
|
||||
if (!sourceUrl) return null;
|
||||
state.update((s) => ({ ...s, themesSaving: true }));
|
||||
try {
|
||||
const data = await api("/admin/appearance/favicon", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url: sourceUrl }),
|
||||
});
|
||||
if (data?.ok) {
|
||||
flash(
|
||||
at(
|
||||
"appearance_favicon_uploaded_pending",
|
||||
{},
|
||||
"Favicon загружена и применена."
|
||||
)
|
||||
);
|
||||
return { faviconUrl: data.favicon_url || "", variants: data.variants || {} };
|
||||
}
|
||||
flash(
|
||||
data?.message ||
|
||||
data?.error ||
|
||||
at("appearance_favicon_upload_failed", {}, "Не удалось загрузить favicon")
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, themesSaving: false }));
|
||||
}
|
||||
}
|
||||
|
||||
function setCurrentTheme(key) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
themesCatalog: {
|
||||
...s.themesCatalog,
|
||||
default_theme: key,
|
||||
themes: (s.themesCatalog.themes || []).map((theme) => ({
|
||||
...theme,
|
||||
default: theme.key === key,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function togglePrimaryAccent(key, enabled) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
themesCatalog: {
|
||||
...s.themesCatalog,
|
||||
themes: (s.themesCatalog.themes || []).map((theme) =>
|
||||
theme.key === key ? { ...theme, use_primary_accent: Boolean(enabled) } : theme
|
||||
),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function toggleAdminUse(key, enabled) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
themesCatalog: {
|
||||
...s.themesCatalog,
|
||||
themes: (s.themesCatalog.themes || []).map((theme) =>
|
||||
theme.key === key ? { ...theme, use_in_admin: Boolean(enabled) } : theme
|
||||
),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function setThemeAccent(key, accent) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
themesCatalog: {
|
||||
...s.themesCatalog,
|
||||
themes: (s.themesCatalog.themes || []).map((theme) =>
|
||||
theme.key === key
|
||||
? {
|
||||
...theme,
|
||||
tokens: {
|
||||
...(theme.tokens || {}),
|
||||
accent: String(accent || "").trim() || null,
|
||||
},
|
||||
}
|
||||
: theme
|
||||
),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function setThemeHomeLogoScale(key, scale) {
|
||||
if (String(scale ?? "").trim() === "") scale = 100;
|
||||
const numeric = Number(scale);
|
||||
const nextScale = Number.isFinite(numeric)
|
||||
? Math.min(300, Math.max(50, Math.round(numeric)))
|
||||
: 100;
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
themesCatalog: {
|
||||
...s.themesCatalog,
|
||||
themes: (s.themesCatalog.themes || []).map((theme) =>
|
||||
theme.key === key
|
||||
? {
|
||||
...theme,
|
||||
tokens: {
|
||||
...(theme.tokens || {}),
|
||||
home_logo_scale: nextScale === 100 ? null : nextScale,
|
||||
},
|
||||
}
|
||||
: theme
|
||||
),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: state.subscribe,
|
||||
loadThemes,
|
||||
saveThemes,
|
||||
setCurrentTheme,
|
||||
setThemeAccent,
|
||||
setThemeHomeLogoScale,
|
||||
togglePrimaryAccent,
|
||||
toggleAdminUse,
|
||||
uploadLogoFile,
|
||||
uploadLogoUrl,
|
||||
uploadFaviconFile,
|
||||
uploadFaviconUrl,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
export function createUsersStore({ api, onToast, at }) {
|
||||
const USERS_PAGE_SIZE = 25;
|
||||
const USER_LOGS_PAGE_SIZE = 20;
|
||||
|
||||
const state = writable({
|
||||
users: [],
|
||||
usersTotal: 0,
|
||||
usersPage: 0,
|
||||
usersQuery: "",
|
||||
usersFilter: "all",
|
||||
usersPanelStatus: "all",
|
||||
usersPremiumTraffic: "all",
|
||||
usersSort: "registered_desc",
|
||||
usersLoading: false,
|
||||
|
||||
openedUser: null,
|
||||
openedUserDetail: null,
|
||||
userDetailLoading: false,
|
||||
userMessageDraft: "",
|
||||
userExtendDays: 30,
|
||||
userActionBusy: false,
|
||||
userDeleteOpen: false,
|
||||
userBanConfirmOpen: false,
|
||||
userMessageConfirmOpen: false,
|
||||
userDetailTab: "profile",
|
||||
premiumUnlimitedDraft: false,
|
||||
premiumBonusGbDraft: "",
|
||||
regularUnlimitedDraft: false,
|
||||
regularBonusGbDraft: "",
|
||||
grantTrafficGbDraft: "",
|
||||
grantTrafficKindDraft: "regular",
|
||||
|
||||
userLogs: [],
|
||||
userLogsTotal: 0,
|
||||
userLogsPage: 0,
|
||||
userLogsLoading: false,
|
||||
userLogsLoaded: false,
|
||||
userLogsUserId: null,
|
||||
userLogsPageSize: USER_LOGS_PAGE_SIZE,
|
||||
});
|
||||
|
||||
let _activeRef = "stats"; // fallback if active isn't tracked
|
||||
|
||||
function setActive(active) {
|
||||
_activeRef = active;
|
||||
}
|
||||
|
||||
function _pushUserPath(userId) {
|
||||
if (typeof window === "undefined") return;
|
||||
if (window.location.protocol === "file:") return;
|
||||
if (_activeRef !== "users") return;
|
||||
const target = userId ? `/admin/users/${userId}` : `/admin/users`;
|
||||
if (window.location.pathname === target) return;
|
||||
window.history.pushState(null, "", `${target}${window.location.search}${window.location.hash}`);
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
state.update((s) => ({ ...s, usersLoading: true }));
|
||||
let s;
|
||||
state.update((st) => {
|
||||
s = st;
|
||||
return st;
|
||||
});
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(s.usersPage),
|
||||
page_size: String(USERS_PAGE_SIZE),
|
||||
});
|
||||
if (s.usersQuery.trim()) params.set("q", s.usersQuery.trim());
|
||||
if (s.usersFilter && s.usersFilter !== "all") params.set("filter", s.usersFilter);
|
||||
if (s.usersPanelStatus && s.usersPanelStatus !== "all")
|
||||
params.set("panel_status", s.usersPanelStatus);
|
||||
if (s.usersPremiumTraffic && s.usersPremiumTraffic !== "all") {
|
||||
params.set("premium_traffic", s.usersPremiumTraffic);
|
||||
}
|
||||
if (s.usersSort && s.usersSort !== "registered_desc") params.set("sort", s.usersSort);
|
||||
const data = await api(`/admin/users?${params.toString()}`);
|
||||
if (data?.ok) {
|
||||
state.update((st) => ({
|
||||
...st,
|
||||
users: data.users || [],
|
||||
usersTotal: data.total || (data.users || []).length,
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
state.update((st) => ({ ...st, usersLoading: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function openUser(userOrId, opts = {}) {
|
||||
const userId =
|
||||
typeof userOrId === "object" && userOrId !== null ? userOrId.user_id : Number(userOrId);
|
||||
if (!userId) return;
|
||||
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
openedUser:
|
||||
typeof userOrId === "object" && userOrId !== null ? userOrId : { user_id: userId },
|
||||
openedUserDetail: null,
|
||||
userMessageDraft: "",
|
||||
userMessageConfirmOpen: false,
|
||||
userExtendDays: 30,
|
||||
userDetailLoading: true,
|
||||
userDetailTab: "subscription",
|
||||
userLogs: [],
|
||||
userLogsTotal: 0,
|
||||
userLogsPage: 0,
|
||||
userLogsLoading: false,
|
||||
userLogsLoaded: false,
|
||||
userLogsUserId: userId,
|
||||
}));
|
||||
|
||||
if (!opts.skipPush) _pushUserPath(userId);
|
||||
try {
|
||||
const res = await api(`/admin/users/${userId}`);
|
||||
if (res?.ok) {
|
||||
const sub = res.active_subscription || null;
|
||||
const bonusBytes = Number(sub?.premium_bonus_bytes || 0);
|
||||
const regularBonusBytes = Number(sub?.regular_bonus_bytes || 0);
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
openedUserDetail: res,
|
||||
openedUser: res.user ? { ...res.user, ...s.openedUser, ...res.user } : s.openedUser,
|
||||
premiumUnlimitedDraft: Boolean(sub?.premium_unlimited_override),
|
||||
premiumBonusGbDraft: bonusBytes > 0 ? +(bonusBytes / 1024 ** 3).toFixed(2) : "",
|
||||
regularUnlimitedDraft: Boolean(sub?.regular_unlimited_override),
|
||||
regularBonusGbDraft:
|
||||
regularBonusBytes > 0 ? +(regularBonusBytes / 1024 ** 3).toFixed(2) : "",
|
||||
grantTrafficGbDraft: "",
|
||||
grantTrafficKindDraft: "regular",
|
||||
}));
|
||||
} else {
|
||||
onToast(res?.error || "load_failed");
|
||||
state.update((s) => ({ ...s, openedUser: null }));
|
||||
if (!opts.skipPush) _pushUserPath(null);
|
||||
}
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, userDetailLoading: false }));
|
||||
}
|
||||
}
|
||||
|
||||
function closeUser(opts = {}) {
|
||||
let wasOpen = false;
|
||||
state.update((s) => {
|
||||
wasOpen = Boolean(s.openedUser);
|
||||
return {
|
||||
...s,
|
||||
openedUser: null,
|
||||
openedUserDetail: null,
|
||||
userDeleteOpen: false,
|
||||
userBanConfirmOpen: false,
|
||||
userMessageConfirmOpen: false,
|
||||
userLogs: [],
|
||||
userLogsTotal: 0,
|
||||
userLogsPage: 0,
|
||||
userLogsLoading: false,
|
||||
userLogsLoaded: false,
|
||||
userLogsUserId: null,
|
||||
};
|
||||
});
|
||||
if (wasOpen && !opts.skipPush) _pushUserPath(null);
|
||||
}
|
||||
|
||||
async function loadUserLogs(page) {
|
||||
let s;
|
||||
state.update((st) => {
|
||||
s = st;
|
||||
return st;
|
||||
});
|
||||
if (!s.openedUser) return;
|
||||
const userId = s.openedUser.user_id;
|
||||
const targetPage = Number.isFinite(page) ? Math.max(0, Math.floor(page)) : s.userLogsPage || 0;
|
||||
state.update((st) => ({
|
||||
...st,
|
||||
userLogsLoading: true,
|
||||
userLogsPage: targetPage,
|
||||
userLogsUserId: userId,
|
||||
}));
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(targetPage),
|
||||
page_size: String(USER_LOGS_PAGE_SIZE),
|
||||
user_id: String(userId),
|
||||
});
|
||||
const data = await api(`/admin/logs?${params.toString()}`);
|
||||
if (data?.ok) {
|
||||
state.update((st) => {
|
||||
if (!st.openedUser || st.openedUser.user_id !== userId) return st;
|
||||
return {
|
||||
...st,
|
||||
userLogs: data.logs || [],
|
||||
userLogsTotal: Number(data.total || 0),
|
||||
userLogsLoaded: true,
|
||||
};
|
||||
});
|
||||
} else if (data?.error) {
|
||||
onToast(data.error);
|
||||
}
|
||||
} finally {
|
||||
state.update((st) => ({ ...st, userLogsLoading: false }));
|
||||
}
|
||||
}
|
||||
|
||||
function setUserLogsPage(page) {
|
||||
loadUserLogs(page);
|
||||
}
|
||||
|
||||
function copyToClipboard(text, successMessage = at("link_copied", {}, "Скопировано")) {
|
||||
if (!text) return;
|
||||
if (typeof navigator !== "undefined" && navigator?.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(text).then(
|
||||
() => onToast(successMessage),
|
||||
() => onToast(text)
|
||||
);
|
||||
} else {
|
||||
onToast(text);
|
||||
}
|
||||
}
|
||||
|
||||
function requestBanToggle() {
|
||||
let s;
|
||||
state.update((st) => {
|
||||
s = st;
|
||||
return st;
|
||||
});
|
||||
if (!s.openedUser) return;
|
||||
if (s.openedUser.is_banned) {
|
||||
applyBanToggle(false);
|
||||
} else {
|
||||
state.update((st) => ({ ...st, userBanConfirmOpen: true }));
|
||||
}
|
||||
}
|
||||
|
||||
async function applyBanToggle(banned) {
|
||||
let s;
|
||||
state.update((st) => {
|
||||
s = st;
|
||||
return st;
|
||||
});
|
||||
if (!s.openedUser) return;
|
||||
state.update((st) => ({ ...st, userActionBusy: true }));
|
||||
try {
|
||||
const res = await api(`/admin/users/${s.openedUser.user_id}/ban`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ banned }),
|
||||
});
|
||||
if (res?.ok) {
|
||||
state.update((st) => {
|
||||
const updatedUser = { ...st.openedUser, is_banned: banned };
|
||||
return {
|
||||
...st,
|
||||
openedUser: updatedUser,
|
||||
users: st.users.map((u) => (u.user_id === updatedUser.user_id ? updatedUser : u)),
|
||||
userBanConfirmOpen: false,
|
||||
};
|
||||
});
|
||||
onToast(
|
||||
banned ? at("user_banned", {}, "Заблокирован") : at("user_unbanned", {}, "Разблокирован")
|
||||
);
|
||||
} else onToast(res?.error || at("error", {}, "Ошибка"));
|
||||
} finally {
|
||||
state.update((st) => ({ ...st, userActionBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function sendUserMessage() {
|
||||
let s;
|
||||
state.update((st) => {
|
||||
s = st;
|
||||
return st;
|
||||
});
|
||||
if (!s.openedUser || !s.userMessageDraft.trim()) return;
|
||||
state.update((st) => ({ ...st, userActionBusy: true }));
|
||||
try {
|
||||
const res = await api(`/admin/users/${s.openedUser.user_id}/message`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text: s.userMessageDraft }),
|
||||
});
|
||||
if (res?.ok) {
|
||||
onToast(at("message_sent", {}, "Отправлено"));
|
||||
state.update((st) => ({
|
||||
...st,
|
||||
userMessageDraft: "",
|
||||
userMessageConfirmOpen: false,
|
||||
}));
|
||||
} else onToast(res?.error || at("message_send_failed", {}, "Ошибка отправки"));
|
||||
} finally {
|
||||
state.update((st) => ({ ...st, userActionBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
function requestSendUserMessage() {
|
||||
state.update((s) => {
|
||||
if (!s.openedUser || !s.userMessageDraft.trim()) return s;
|
||||
return { ...s, userMessageConfirmOpen: true };
|
||||
});
|
||||
}
|
||||
|
||||
async function previewUserMessage() {
|
||||
let s;
|
||||
state.update((st) => {
|
||||
s = st;
|
||||
return st;
|
||||
});
|
||||
if (!s.openedUser || !s.userMessageDraft.trim()) return;
|
||||
state.update((st) => ({ ...st, userActionBusy: true }));
|
||||
try {
|
||||
const res = await api(`/admin/users/${s.openedUser.user_id}/message/preview`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text: s.userMessageDraft }),
|
||||
});
|
||||
if (res?.ok) onToast(at("message_preview_sent", {}, "Превью отправлено в Telegram"));
|
||||
else onToast(res?.error || at("message_preview_failed", {}, "Ошибка отправки превью"));
|
||||
} finally {
|
||||
state.update((st) => ({ ...st, userActionBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function extendUser() {
|
||||
let s;
|
||||
state.update((st) => {
|
||||
s = st;
|
||||
return st;
|
||||
});
|
||||
if (!s.openedUser) return;
|
||||
const days = Number(s.userExtendDays);
|
||||
if (!days || days <= 0) return;
|
||||
state.update((st) => ({ ...st, userActionBusy: true }));
|
||||
try {
|
||||
const res = await api(`/admin/users/${s.openedUser.user_id}/extend`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ days }),
|
||||
});
|
||||
if (res?.ok) {
|
||||
onToast(at("subscription_extended", { days }, `Продлено на ${days} д.`));
|
||||
await openUser(s.openedUser, { skipPush: true });
|
||||
} else onToast(res?.error || at("error", {}, "Ошибка"));
|
||||
} finally {
|
||||
state.update((st) => ({ ...st, userActionBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function resetTrialUser() {
|
||||
let s;
|
||||
state.update((st) => {
|
||||
s = st;
|
||||
return st;
|
||||
});
|
||||
if (!s.openedUser) return;
|
||||
state.update((st) => ({ ...st, userActionBusy: true }));
|
||||
try {
|
||||
const res = await api(`/admin/users/${s.openedUser.user_id}/reset-trial`, { method: "POST" });
|
||||
if (res?.ok) onToast(at("trial_reset", {}, "Триал сброшен"));
|
||||
else onToast(res?.error || at("error", {}, "Ошибка"));
|
||||
} finally {
|
||||
state.update((st) => ({ ...st, userActionBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function savePremiumTrafficOverride() {
|
||||
let s;
|
||||
state.update((st) => {
|
||||
s = st;
|
||||
return st;
|
||||
});
|
||||
if (!s.openedUser) return;
|
||||
state.update((st) => ({ ...st, userActionBusy: true }));
|
||||
try {
|
||||
const bonusGbRaw = s.premiumBonusGbDraft;
|
||||
const bonusGb =
|
||||
bonusGbRaw === "" || bonusGbRaw === null || bonusGbRaw === undefined
|
||||
? 0
|
||||
: Number(bonusGbRaw);
|
||||
if (Number.isNaN(bonusGb) || bonusGb < 0) {
|
||||
onToast(at("premium_override_invalid_bonus", {}, "Некорректное значение GB"));
|
||||
return;
|
||||
}
|
||||
const res = await api(`/admin/users/${s.openedUser.user_id}/premium-override`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
unlimited: Boolean(s.premiumUnlimitedDraft),
|
||||
bonus_gb: bonusGb,
|
||||
}),
|
||||
});
|
||||
if (res?.ok) {
|
||||
onToast(at("premium_override_saved", {}, "Премиум-оверрайд сохранён"));
|
||||
await openUser(s.openedUser, { skipPush: true });
|
||||
} else {
|
||||
onToast(res?.error || at("error", {}, "Ошибка"));
|
||||
}
|
||||
} finally {
|
||||
state.update((st) => ({ ...st, userActionBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRegularTrafficOverride() {
|
||||
let s;
|
||||
state.update((st) => {
|
||||
s = st;
|
||||
return st;
|
||||
});
|
||||
if (!s.openedUser) return;
|
||||
state.update((st) => ({ ...st, userActionBusy: true }));
|
||||
try {
|
||||
const regGbRaw = s.regularBonusGbDraft;
|
||||
const regularGb =
|
||||
regGbRaw === "" || regGbRaw === null || regGbRaw === undefined ? 0 : Number(regGbRaw);
|
||||
if (Number.isNaN(regularGb) || regularGb < 0) {
|
||||
onToast(
|
||||
at("regular_override_invalid_bonus", {}, "Некорректное значение GB для основного трафика")
|
||||
);
|
||||
return;
|
||||
}
|
||||
const res = await api(`/admin/users/${s.openedUser.user_id}/regular-traffic-override`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
unlimited: Boolean(s.regularUnlimitedDraft),
|
||||
regular_bonus_gb: regularGb,
|
||||
}),
|
||||
});
|
||||
if (res?.ok) {
|
||||
onToast(at("regular_override_saved", {}, "Оверрайд основного трафика сохранён"));
|
||||
await openUser(s.openedUser, { skipPush: true });
|
||||
} else {
|
||||
onToast(res?.error || at("error", {}, "Ошибка"));
|
||||
}
|
||||
} finally {
|
||||
state.update((st) => ({ ...st, userActionBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function grantTraffic() {
|
||||
let s;
|
||||
state.update((st) => {
|
||||
s = st;
|
||||
return st;
|
||||
});
|
||||
if (!s.openedUser) return;
|
||||
const gbRaw = s.grantTrafficGbDraft;
|
||||
const gb = Number(gbRaw);
|
||||
if (!gbRaw || Number.isNaN(gb) || gb <= 0) {
|
||||
onToast(at("traffic_grant_invalid_gb", {}, "Введите положительное число GB"));
|
||||
return;
|
||||
}
|
||||
const kind = s.grantTrafficKindDraft === "premium" ? "premium" : "regular";
|
||||
state.update((st) => ({ ...st, userActionBusy: true }));
|
||||
try {
|
||||
const res = await api(`/admin/users/${s.openedUser.user_id}/traffic-grant`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ kind, gb }),
|
||||
});
|
||||
if (res?.ok) {
|
||||
onToast(
|
||||
kind === "premium"
|
||||
? at("traffic_grant_premium_done", { gb }, `+${gb} ГБ премиум-трафика`)
|
||||
: at("traffic_grant_regular_done", { gb }, `+${gb} ГБ трафика`)
|
||||
);
|
||||
state.update((st) => ({ ...st, grantTrafficGbDraft: "" }));
|
||||
await openUser(s.openedUser, { skipPush: true });
|
||||
} else {
|
||||
onToast(res?.error || at("error", {}, "Ошибка"));
|
||||
}
|
||||
} finally {
|
||||
state.update((st) => ({ ...st, userActionBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser() {
|
||||
let s;
|
||||
state.update((st) => {
|
||||
s = st;
|
||||
return st;
|
||||
});
|
||||
if (!s.openedUser) return;
|
||||
state.update((st) => ({ ...st, userActionBusy: true }));
|
||||
try {
|
||||
const res = await api(`/admin/users/${s.openedUser.user_id}`, { method: "DELETE" });
|
||||
if (res?.ok) {
|
||||
onToast(at("user_deleted", {}, "Удален"));
|
||||
state.update((st) => ({
|
||||
...st,
|
||||
users: st.users.filter((u) => u.user_id !== st.openedUser.user_id),
|
||||
}));
|
||||
closeUser();
|
||||
} else onToast(res?.error || at("error", {}, "Ошибка"));
|
||||
} finally {
|
||||
state.update((st) => ({ ...st, userActionBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
function updateState(updates) {
|
||||
state.update((s) => ({ ...s, ...updates }));
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: state.subscribe,
|
||||
set: state.set,
|
||||
update: state.update,
|
||||
updateState,
|
||||
setActive,
|
||||
loadUsers,
|
||||
openUser,
|
||||
closeUser,
|
||||
copyToClipboard,
|
||||
requestBanToggle,
|
||||
applyBanToggle,
|
||||
sendUserMessage,
|
||||
requestSendUserMessage,
|
||||
previewUserMessage,
|
||||
extendUser,
|
||||
resetTrialUser,
|
||||
deleteUser,
|
||||
savePremiumTrafficOverride,
|
||||
saveRegularTrafficOverride,
|
||||
grantTraffic,
|
||||
loadUserLogs,
|
||||
setUserLogsPage,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { structuredCloneSafe } from "./format.js";
|
||||
|
||||
export function emptyTariffDraft() {
|
||||
return {
|
||||
key: "",
|
||||
nameRu: "",
|
||||
nameEn: "",
|
||||
descriptionRu: "",
|
||||
descriptionEn: "",
|
||||
premiumNameRu: "",
|
||||
premiumNameEn: "",
|
||||
squadUuids: [],
|
||||
premiumSquadUuids: [],
|
||||
billing_model: "period",
|
||||
enabled: true,
|
||||
monthly_gb: 500,
|
||||
premium_monthly_gb: "",
|
||||
hwid_device_limit: "",
|
||||
conversion_rate_rub_per_gb: "",
|
||||
periodRows: [
|
||||
{ months: 1, rub: 150, stars: "" },
|
||||
{ months: 3, rub: 400, stars: "" },
|
||||
{ months: 6, rub: 750, stars: "" },
|
||||
{ months: 12, rub: 1400, stars: "" },
|
||||
],
|
||||
topupRubRows: [],
|
||||
topupStarsRows: [],
|
||||
premiumTopupRubRows: [],
|
||||
premiumTopupStarsRows: [],
|
||||
trafficRubRows: [
|
||||
{ gb: 10, price: 199 },
|
||||
{ gb: 50, price: 799 },
|
||||
],
|
||||
trafficStarsRows: [],
|
||||
hwidRubRows: [],
|
||||
hwidStarsRows: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function cloneCatalog(catalog) {
|
||||
return structuredCloneSafe({
|
||||
default_tariff: catalog?.default_tariff || "",
|
||||
topup_packages_default: catalog?.topup_packages_default || { rub: [], stars: [] },
|
||||
tariffs: catalog?.tariffs || [],
|
||||
});
|
||||
}
|
||||
|
||||
export function rowsFromPackages(packageSet, currency, valueKey) {
|
||||
return (packageSet?.[currency] || []).map((pkg) => ({
|
||||
[valueKey]: pkg[valueKey],
|
||||
price: pkg.price,
|
||||
}));
|
||||
}
|
||||
|
||||
export function draftFromTariff(tariff) {
|
||||
const months = new Set([
|
||||
...(tariff.enabled_periods || []),
|
||||
...Object.keys(tariff.prices_rub || {}).map(Number),
|
||||
...Object.keys(tariff.prices_stars || {}).map(Number),
|
||||
]);
|
||||
const periodRows = [...months]
|
||||
.filter((month) => Number.isFinite(month) && month > 0)
|
||||
.sort((a, b) => a - b)
|
||||
.map((month) => ({
|
||||
months: month,
|
||||
rub: tariff.prices_rub?.[String(month)] ?? "",
|
||||
stars: tariff.prices_stars?.[String(month)] ?? "",
|
||||
}));
|
||||
|
||||
return {
|
||||
...emptyTariffDraft(),
|
||||
key: tariff.key || "",
|
||||
nameRu: tariff.names?.ru || "",
|
||||
nameEn: tariff.names?.en || "",
|
||||
descriptionRu: tariff.descriptions?.ru || "",
|
||||
descriptionEn: tariff.descriptions?.en || "",
|
||||
premiumNameRu: tariff.premium_names?.ru || "",
|
||||
premiumNameEn: tariff.premium_names?.en || "",
|
||||
squadUuids: tariff.squad_uuids || [],
|
||||
premiumSquadUuids: tariff.premium_squad_uuids || [],
|
||||
billing_model: tariff.billing_model || "period",
|
||||
enabled: tariff.enabled !== false,
|
||||
monthly_gb: tariff.monthly_gb ?? "",
|
||||
premium_monthly_gb: tariff.premium_monthly_gb ?? "",
|
||||
hwid_device_limit: tariff.hwid_device_limit ?? "",
|
||||
conversion_rate_rub_per_gb: tariff.conversion_rate_rub_per_gb ?? "",
|
||||
periodRows: periodRows.length ? periodRows : emptyTariffDraft().periodRows,
|
||||
topupRubRows: rowsFromPackages(tariff.topup_packages, "rub", "gb"),
|
||||
topupStarsRows: rowsFromPackages(tariff.topup_packages, "stars", "gb"),
|
||||
premiumTopupRubRows: rowsFromPackages(tariff.premium_topup_packages, "rub", "gb"),
|
||||
premiumTopupStarsRows: rowsFromPackages(tariff.premium_topup_packages, "stars", "gb"),
|
||||
trafficRubRows: rowsFromPackages(tariff.traffic_packages, "rub", "gb"),
|
||||
trafficStarsRows: rowsFromPackages(tariff.traffic_packages, "stars", "gb"),
|
||||
hwidRubRows: rowsFromPackages(tariff.hwid_device_packages, "rub", "count"),
|
||||
hwidStarsRows: rowsFromPackages(tariff.hwid_device_packages, "stars", "count"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseNumber(value, fallback = null) {
|
||||
if (value === "" || value === null || value === undefined) return fallback;
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : fallback;
|
||||
}
|
||||
|
||||
export function parseIntNumber(value, fallback = null) {
|
||||
const num = parseNumber(value, fallback);
|
||||
return num === null ? fallback : Math.trunc(num);
|
||||
}
|
||||
|
||||
export function compactMap(obj) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).filter(([, value]) => value !== "" && value !== null && value !== undefined)
|
||||
);
|
||||
}
|
||||
|
||||
export function packagesFromRows(rows, valueKey) {
|
||||
return (rows || [])
|
||||
.map((row) => ({
|
||||
[valueKey]: parseNumber(row[valueKey]),
|
||||
price: parseNumber(row.price),
|
||||
}))
|
||||
.filter((row) => row[valueKey] > 0 && row.price !== null && row.price >= 0);
|
||||
}
|
||||
|
||||
export function packageSetFromRows(rubRows, starsRows, valueKey) {
|
||||
const rub = packagesFromRows(rubRows, valueKey);
|
||||
const stars = packagesFromRows(starsRows, valueKey);
|
||||
return rub.length || stars.length ? { rub, stars } : null;
|
||||
}
|
||||
|
||||
export function normalizeUuidList(value) {
|
||||
if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean);
|
||||
return String(value || "")
|
||||
.split(/[\n,]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function tariffFromDraft(draft) {
|
||||
const key = draft.key.trim();
|
||||
const names = compactMap({ ru: draft.nameRu.trim(), en: draft.nameEn.trim() });
|
||||
const descriptions = compactMap({
|
||||
ru: draft.descriptionRu.trim(),
|
||||
en: draft.descriptionEn.trim(),
|
||||
});
|
||||
const premiumNames = compactMap({
|
||||
ru: draft.premiumNameRu.trim(),
|
||||
en: draft.premiumNameEn.trim(),
|
||||
});
|
||||
const tariff = {
|
||||
key,
|
||||
names,
|
||||
descriptions,
|
||||
premium_names: premiumNames,
|
||||
squad_uuids: normalizeUuidList(draft.squadUuids),
|
||||
premium_squad_uuids: normalizeUuidList(draft.premiumSquadUuids),
|
||||
billing_model: draft.billing_model,
|
||||
enabled: Boolean(draft.enabled),
|
||||
};
|
||||
|
||||
const hwidLimit = parseIntNumber(draft.hwid_device_limit);
|
||||
if (hwidLimit !== null) tariff.hwid_device_limit = hwidLimit;
|
||||
const hwidPackages = packageSetFromRows(draft.hwidRubRows, draft.hwidStarsRows, "count");
|
||||
if (hwidPackages) tariff.hwid_device_packages = hwidPackages;
|
||||
const premiumMonthlyGb = parseNumber(draft.premium_monthly_gb);
|
||||
if (premiumMonthlyGb !== null) tariff.premium_monthly_gb = premiumMonthlyGb;
|
||||
const premiumTopupPackages = packageSetFromRows(
|
||||
draft.premiumTopupRubRows,
|
||||
draft.premiumTopupStarsRows,
|
||||
"gb"
|
||||
);
|
||||
if (premiumTopupPackages) tariff.premium_topup_packages = premiumTopupPackages;
|
||||
|
||||
if (tariff.billing_model === "period") {
|
||||
const seenMonths = new Set();
|
||||
const rows = (draft.periodRows || [])
|
||||
.map((row) => ({
|
||||
months: parseIntNumber(row.months),
|
||||
rub: parseNumber(row.rub, 0),
|
||||
stars: parseNumber(row.stars, 0),
|
||||
}))
|
||||
.filter((row) => row.months > 0)
|
||||
.filter((row) => {
|
||||
if (seenMonths.has(row.months)) return false;
|
||||
seenMonths.add(row.months);
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => a.months - b.months);
|
||||
tariff.monthly_gb = parseNumber(draft.monthly_gb, 0);
|
||||
tariff.enabled_periods = rows.map((row) => row.months);
|
||||
tariff.prices_rub = Object.fromEntries(rows.map((row) => [String(row.months), row.rub || 0]));
|
||||
tariff.prices_stars = Object.fromEntries(
|
||||
rows.map((row) => [String(row.months), row.stars || 0])
|
||||
);
|
||||
const topupPackages = packageSetFromRows(draft.topupRubRows, draft.topupStarsRows, "gb");
|
||||
if (topupPackages) tariff.topup_packages = topupPackages;
|
||||
} else {
|
||||
const trafficPackages = packageSetFromRows(draft.trafficRubRows, draft.trafficStarsRows, "gb");
|
||||
if (trafficPackages) tariff.traffic_packages = trafficPackages;
|
||||
const conversion = parseNumber(draft.conversion_rate_rub_per_gb);
|
||||
if (conversion !== null) tariff.conversion_rate_rub_per_gb = conversion;
|
||||
}
|
||||
|
||||
return tariff;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
export function userDisplayName(user) {
|
||||
const full = [user?.first_name, user?.last_name].filter(Boolean).join(" ").trim();
|
||||
return (
|
||||
full || (user?.username ? `@${user.username}` : user?.email || `User #${user?.user_id || "—"}`)
|
||||
);
|
||||
}
|
||||
|
||||
export function userSecondaryName(user) {
|
||||
if (user?.username && userDisplayName(user) !== `@${user.username}`) return `@${user.username}`;
|
||||
if (user?.email && userDisplayName(user) !== user.email) return user.email;
|
||||
return `ID ${user?.user_id || "—"}`;
|
||||
}
|
||||
|
||||
export function userInitials(user) {
|
||||
const source = userDisplayName(user).replace(/^@/, "").trim();
|
||||
const parts = source.split(/\s+/).filter(Boolean);
|
||||
if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
|
||||
return (source.slice(0, 2) || "U").toUpperCase();
|
||||
}
|
||||
|
||||
export function userAvatarUrl(user) {
|
||||
const cached = String(user?.avatar_url || "").trim();
|
||||
if (cached) return cached;
|
||||
const value = String(user?.telegram_photo_url || "").trim();
|
||||
return value && !value.startsWith("/api/account/avatar") ? value : "";
|
||||
}
|
||||
|
||||
export function createGravatarCache(onResolved = () => {}) {
|
||||
const cache = new Map();
|
||||
const pending = new Map();
|
||||
|
||||
async function sha256Hex(value) {
|
||||
const buf = new TextEncoder().encode(value);
|
||||
const digest = await crypto.subtle.digest("SHA-256", buf);
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function gravatarUrl(email) {
|
||||
const key = String(email || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!key) return "";
|
||||
if (cache.has(key)) return cache.get(key);
|
||||
if (pending.has(key)) return "";
|
||||
pending.set(
|
||||
key,
|
||||
sha256Hex(key)
|
||||
.then((h) => {
|
||||
cache.set(key, `https://gravatar.com/avatar/${h}?d=identicon&s=80`);
|
||||
onResolved();
|
||||
})
|
||||
.catch(() => pending.delete(key))
|
||||
);
|
||||
return "";
|
||||
}
|
||||
|
||||
return { gravatarUrl };
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<script>
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
export let variant = "muted";
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<span
|
||||
class={cn(
|
||||
"admin-badge",
|
||||
variant === "success" && "admin-badge-success",
|
||||
variant === "danger" && "admin-badge-danger",
|
||||
variant === "warning" && "admin-badge-warning",
|
||||
variant === "muted" && "admin-badge-muted",
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script>
|
||||
import { cva } from "class-variance-authority";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
export let type = "button";
|
||||
export let variant = "default";
|
||||
export let size = "default";
|
||||
export let disabled = false;
|
||||
export let onclick = undefined;
|
||||
|
||||
let className = "";
|
||||
export { className as class };
|
||||
|
||||
const buttonVariants = cva("admin-btn", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "",
|
||||
primary: "admin-btn-primary",
|
||||
ghost: "admin-btn-ghost",
|
||||
danger: "admin-btn-danger",
|
||||
dangerSoft: "admin-btn-danger-soft",
|
||||
icon: "admin-btn-icon",
|
||||
},
|
||||
size: {
|
||||
default: "",
|
||||
sm: "admin-btn-sm",
|
||||
icon: "admin-btn-icon",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<button
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
{type}
|
||||
{disabled}
|
||||
{onclick}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script>
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
export let columns = 1;
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn("admin-cn-dashboard-grid", columns === 3 && "admin-cn-dashboard-grid--3", className)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script>
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<div class={cn("admin-cn-dashboard-stack", className)} {...$$restProps}>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script>
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
export let tone = "default";
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<div class={cn(tone === "card" ? "admin-card-body" : "admin-empty", className)} {...$$restProps}>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script>
|
||||
import { Label } from "$components/ui/primitives.js";
|
||||
|
||||
export let label = "";
|
||||
export let hint = "";
|
||||
</script>
|
||||
|
||||
<Label.Root class="admin-field-label">
|
||||
{#if label}
|
||||
<span>{label}</span>
|
||||
{/if}
|
||||
{#if hint}
|
||||
<small>{hint}</small>
|
||||
{/if}
|
||||
<slot />
|
||||
</Label.Root>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script>
|
||||
import { ChevronLeft, ChevronRight } from "$components/ui/icons.js";
|
||||
import AdminButton from "./AdminButton.svelte";
|
||||
|
||||
export let meta = "";
|
||||
export let prevLabel = "Back";
|
||||
export let nextLabel = "Next";
|
||||
export let prevDisabled = false;
|
||||
export let nextDisabled = false;
|
||||
export let onPrev = () => {};
|
||||
export let onNext = () => {};
|
||||
</script>
|
||||
|
||||
<div class="admin-pagination">
|
||||
<span class="admin-pagination-meta">{meta}</span>
|
||||
<div class="admin-pagination-buttons">
|
||||
<AdminButton size="sm" disabled={prevDisabled} onclick={onPrev}>
|
||||
<ChevronLeft size={14} />
|
||||
{prevLabel}
|
||||
</AdminButton>
|
||||
<AdminButton size="sm" disabled={nextDisabled} onclick={onNext}>
|
||||
{nextLabel}
|
||||
<ChevronRight size={14} />
|
||||
</AdminButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,190 @@
|
||||
<script>
|
||||
import { onMount, tick } from "svelte";
|
||||
import uPlot from "uplot";
|
||||
import "uplot/dist/uPlot.min.css";
|
||||
|
||||
/** `{ date: ISO date string, amount: number }[]` */
|
||||
export let series = [];
|
||||
/** Total plot height in CSS px (axes + canvas). */
|
||||
export let plotHeight = 204;
|
||||
export let fmtMoney = (v, _currency) => String(v);
|
||||
/** @type {string} */
|
||||
export let currency = "RUB";
|
||||
/** uPlot live legend: column header for the time (x) series */
|
||||
export let legendTimeLabel = "Time";
|
||||
/** uPlot live legend: column header for the value (y) series */
|
||||
export let legendValueLabel = "Value";
|
||||
|
||||
let hostEl;
|
||||
let plot;
|
||||
let resizeObserver;
|
||||
let syncTimer = 0;
|
||||
/** Rebuild plot when legend copy changes (language), since series labels are init-only */
|
||||
let builtLegendSig = "";
|
||||
|
||||
function readCssColor(name, fallback) {
|
||||
if (typeof document === "undefined") return fallback;
|
||||
const scope = hostEl || document.documentElement;
|
||||
const raw = getComputedStyle(scope).getPropertyValue(name).trim();
|
||||
return raw || fallback;
|
||||
}
|
||||
|
||||
function parseDayUnix(iso) {
|
||||
const s = String(iso || "");
|
||||
const t = Date.parse(s.includes("T") ? s : `${s}T12:00:00Z`);
|
||||
if (!Number.isFinite(t)) return 0;
|
||||
return Math.floor(t / 1000);
|
||||
}
|
||||
|
||||
function toAlignedData(rows) {
|
||||
if (!rows?.length) return null;
|
||||
const xs = rows.map((p) => parseDayUnix(p.date));
|
||||
const ys = rows.map((p) => Number(p.amount) || 0);
|
||||
return [xs, ys];
|
||||
}
|
||||
|
||||
function yAxisTickLabels(values) {
|
||||
return values.map((v) => fmtMoney(Number(v), currency));
|
||||
}
|
||||
|
||||
/** uPlot passes already-formatted tick strings; reserve enough gutter so amounts are not clipped */
|
||||
function yAxisGutterWidth(_u, values) {
|
||||
const pad = 14;
|
||||
const charPx = 6.1;
|
||||
const maxChars = (values || []).reduce((m, v) => Math.max(m, String(v ?? "").length), 0);
|
||||
return Math.min(104, Math.max(58, Math.ceil(pad + maxChars * charPx)));
|
||||
}
|
||||
|
||||
/** Axis `size`: height (x / bottom) or width (y / left) in CSS px — only customize the y gutter */
|
||||
function axisBandSize(_u, values, axisIdx) {
|
||||
if (axisIdx !== 1) return 32;
|
||||
return yAxisGutterWidth(_u, values);
|
||||
}
|
||||
|
||||
function buildOpts(width) {
|
||||
const w = Math.max(80, Math.floor(width));
|
||||
const muted = readCssColor("--admin-muted", "#9aa7a2");
|
||||
const border = readCssColor("--admin-border", "rgba(255,255,255,0.12)");
|
||||
const accent = readCssColor("--accent", "#00fe7a");
|
||||
const lineStroke = readCssColor(
|
||||
"--admin-chart-stroke",
|
||||
readCssColor("--admin-text", "#e8f0ec"),
|
||||
);
|
||||
const lineFill = readCssColor("--admin-chart-fill", "rgba(120, 140, 132, 0.14)");
|
||||
|
||||
return {
|
||||
width: w,
|
||||
height: plotHeight,
|
||||
class: "admin-uplot",
|
||||
pxAlign: true,
|
||||
padding: [10, 12, 12, 10],
|
||||
legend: {
|
||||
show: true,
|
||||
live: true,
|
||||
markers: { show: true, width: 10, stroke: accent, fill: accent },
|
||||
},
|
||||
cursor: {
|
||||
drag: { x: false, y: false },
|
||||
points: { size: 7, width: 1, stroke: accent },
|
||||
},
|
||||
scales: {
|
||||
x: { time: true },
|
||||
y: { range: [0, null] },
|
||||
},
|
||||
series: [
|
||||
{ label: legendTimeLabel },
|
||||
{
|
||||
label: legendValueLabel,
|
||||
paths: uPlot.paths.spline(),
|
||||
stroke: lineStroke,
|
||||
width: 2,
|
||||
cap: "round",
|
||||
fill: lineFill,
|
||||
},
|
||||
],
|
||||
axes: [
|
||||
{
|
||||
stroke: muted,
|
||||
gap: 8,
|
||||
grid: { show: true, stroke: border, width: 1 },
|
||||
ticks: { stroke: border },
|
||||
font: "11px system-ui,Segoe UI,sans-serif",
|
||||
},
|
||||
{
|
||||
stroke: muted,
|
||||
size: axisBandSize,
|
||||
gap: 8,
|
||||
grid: { show: true, stroke: border, width: 1 },
|
||||
ticks: { stroke: border },
|
||||
font: "10px system-ui,Segoe UI,sans-serif",
|
||||
values: (u, ticks) => yAxisTickLabels(ticks),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function syncChart() {
|
||||
if (!hostEl) return;
|
||||
const d = toAlignedData(series);
|
||||
const legendSig = `${legendTimeLabel}\0${legendValueLabel}`;
|
||||
if (!d) {
|
||||
plot?.destroy();
|
||||
plot = undefined;
|
||||
builtLegendSig = "";
|
||||
return;
|
||||
}
|
||||
const w = Math.max(80, Math.floor(hostEl.clientWidth));
|
||||
if (plot && builtLegendSig !== legendSig) {
|
||||
plot.destroy();
|
||||
plot = undefined;
|
||||
}
|
||||
if (!plot) {
|
||||
plot = new uPlot(buildOpts(w), d, hostEl);
|
||||
builtLegendSig = legendSig;
|
||||
return;
|
||||
}
|
||||
plot.setData(d, true);
|
||||
plot.setSize({ width: w, height: plotHeight });
|
||||
}
|
||||
|
||||
function scheduleSync() {
|
||||
if (typeof window === "undefined") return;
|
||||
clearTimeout(syncTimer);
|
||||
syncTimer = window.setTimeout(() => {
|
||||
syncTimer = 0;
|
||||
syncChart();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
let rafId = 0;
|
||||
|
||||
onMount(() => {
|
||||
rafId = requestAnimationFrame(() => {
|
||||
void tick().then(() => {
|
||||
scheduleSync();
|
||||
if (!hostEl || typeof ResizeObserver === "undefined") return;
|
||||
resizeObserver = new ResizeObserver(() => scheduleSync());
|
||||
resizeObserver.observe(hostEl);
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
clearTimeout(syncTimer);
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = undefined;
|
||||
plot?.destroy();
|
||||
plot = undefined;
|
||||
builtLegendSig = "";
|
||||
};
|
||||
});
|
||||
|
||||
$: if (hostEl) {
|
||||
series;
|
||||
plotHeight;
|
||||
legendTimeLabel;
|
||||
legendValueLabel;
|
||||
scheduleSync();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="admin-revenue-uplot-host" bind:this={hostEl}></div>
|
||||
@@ -0,0 +1,145 @@
|
||||
<script>
|
||||
import { Popover, RangeCalendar } from "bits-ui";
|
||||
import { parseDate } from "@internationalized/date";
|
||||
import Button from "$components/ui/button.svelte";
|
||||
import { ChevronLeft, ChevronRight } from "$components/ui/icons.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
minIso = "",
|
||||
maxIso = "",
|
||||
committedFrom = "",
|
||||
committedTo = "",
|
||||
title = "",
|
||||
applyLabel = "",
|
||||
triggerLabel = "",
|
||||
isActive = false,
|
||||
onApply = () => {},
|
||||
} = $props();
|
||||
|
||||
let value = $state({ start: undefined, end: undefined });
|
||||
let prevOpen = $state(false);
|
||||
|
||||
function seedFromBounds() {
|
||||
if (!minIso || !maxIso) return;
|
||||
const minV = parseDate(minIso);
|
||||
const maxV = parseDate(maxIso);
|
||||
if (
|
||||
committedFrom &&
|
||||
committedTo &&
|
||||
committedFrom >= minIso &&
|
||||
committedTo <= maxIso &&
|
||||
committedFrom <= committedTo
|
||||
) {
|
||||
value = { start: parseDate(committedFrom), end: parseDate(committedTo) };
|
||||
return;
|
||||
}
|
||||
let start = maxV.subtract({ days: 29 });
|
||||
if (start.compare(minV) < 0) start = minV;
|
||||
value = { start, end: maxV };
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open && !prevOpen) seedFromBounds();
|
||||
prevOpen = open;
|
||||
});
|
||||
|
||||
function calendarDateToIso(d) {
|
||||
if (!d || typeof d !== "object") return "";
|
||||
const y = d.year;
|
||||
const m = String(d.month).padStart(2, "0");
|
||||
const day = String(d.day).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function handleApply() {
|
||||
const fromIso = calendarDateToIso(value?.start);
|
||||
const toIso = calendarDateToIso(value?.end);
|
||||
if (!fromIso || !toIso || fromIso > toIso) return;
|
||||
onApply({ fromIso, toIso });
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger
|
||||
type="button"
|
||||
class={cn("admin-revenue-period-btn", isActive && "is-active")}
|
||||
disabled={!minIso || !maxIso}
|
||||
aria-pressed={isActive}
|
||||
>
|
||||
{triggerLabel}
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
class="admin-revenue-range-popover"
|
||||
side="bottom"
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
trapFocus={true}
|
||||
>
|
||||
{#if title}
|
||||
<div class="admin-revenue-range-popover__title">{title}</div>
|
||||
{/if}
|
||||
{#if minIso && maxIso}
|
||||
<RangeCalendar.Root
|
||||
class="admin-revenue-rcal"
|
||||
bind:value
|
||||
minValue={parseDate(minIso)}
|
||||
maxValue={parseDate(maxIso)}
|
||||
weekdayFormat="short"
|
||||
fixedWeeks={true}
|
||||
weekStartsOn={1}
|
||||
>
|
||||
{#snippet children({ months, weekdays })}
|
||||
<RangeCalendar.Header class="admin-revenue-rcal__header">
|
||||
<RangeCalendar.PrevButton class="admin-revenue-rcal__nav">
|
||||
<ChevronLeft />
|
||||
</RangeCalendar.PrevButton>
|
||||
<RangeCalendar.Heading class="admin-revenue-rcal__heading" />
|
||||
<RangeCalendar.NextButton class="admin-revenue-rcal__nav">
|
||||
<ChevronRight />
|
||||
</RangeCalendar.NextButton>
|
||||
</RangeCalendar.Header>
|
||||
<div class="admin-revenue-rcal__grids">
|
||||
{#each months as month (month.value.month)}
|
||||
<RangeCalendar.Grid class="admin-revenue-rcal__grid">
|
||||
<RangeCalendar.GridHead>
|
||||
<RangeCalendar.GridRow class="admin-revenue-rcal__weekrow">
|
||||
{#each weekdays as wd (wd)}
|
||||
<RangeCalendar.HeadCell class="admin-revenue-rcal__headcell">
|
||||
{wd.slice(0, 2)}
|
||||
</RangeCalendar.HeadCell>
|
||||
{/each}
|
||||
</RangeCalendar.GridRow>
|
||||
</RangeCalendar.GridHead>
|
||||
<RangeCalendar.GridBody>
|
||||
{#each month.weeks as weekDates, wi (wi)}
|
||||
<RangeCalendar.GridRow class="admin-revenue-rcal__weekrow">
|
||||
{#each weekDates as cellDate, di (`${wi}-${di}-${cellDate.toString()}`)}
|
||||
<RangeCalendar.Cell
|
||||
date={cellDate}
|
||||
month={month.value}
|
||||
class="admin-revenue-rcal__cell"
|
||||
>
|
||||
<RangeCalendar.Day class="admin-revenue-rcal__day">
|
||||
{cellDate.day}
|
||||
</RangeCalendar.Day>
|
||||
</RangeCalendar.Cell>
|
||||
{/each}
|
||||
</RangeCalendar.GridRow>
|
||||
{/each}
|
||||
</RangeCalendar.GridBody>
|
||||
</RangeCalendar.Grid>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
</RangeCalendar.Root>
|
||||
{/if}
|
||||
<div class="admin-revenue-range-popover__actions">
|
||||
<Button variant="default" size="sm" onclick={handleApply}>{applyLabel}</Button>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script>
|
||||
export let title = "";
|
||||
export let description = "";
|
||||
</script>
|
||||
|
||||
<div class="admin-dashboard-section-head">
|
||||
<h3>{title}</h3>
|
||||
{#if description}
|
||||
<small>{description}</small>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script>
|
||||
import { Check, ChevronDown } from "$components/ui/icons.js";
|
||||
import { Select } from "$components/ui/primitives.js";
|
||||
|
||||
export let value = "";
|
||||
export let items = [];
|
||||
export let ariaLabel = "";
|
||||
export let placeholder = "";
|
||||
export let disabled = false;
|
||||
export let sideOffset = 6;
|
||||
export let onValueChange = () => {};
|
||||
let className = "";
|
||||
export { className as class };
|
||||
|
||||
$: selected = items.find((item) => item.value === value);
|
||||
|
||||
function handleValueChange(next) {
|
||||
value = next;
|
||||
onValueChange(next);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Select.Root type="single" {value} {items} {disabled} onValueChange={handleValueChange}>
|
||||
<Select.Trigger
|
||||
class={`admin-select-trigger ${className}`.trim()}
|
||||
aria-label={ariaLabel || placeholder}
|
||||
>
|
||||
<span>{selected?.label || placeholder}</span>
|
||||
<ChevronDown size={14} class="admin-select-icon" />
|
||||
</Select.Trigger>
|
||||
<Select.Portal>
|
||||
<Select.Content class="admin-select-content" {sideOffset}>
|
||||
{#each items as item (item.value)}
|
||||
<Select.Item value={item.value} label={item.label} class="admin-select-item">
|
||||
<span>{item.label}</span>
|
||||
<Check size={14} class="admin-select-item-check" />
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select.Root>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script>
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
export let skeleton = false;
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<div class="admin-table-wrap">
|
||||
<table
|
||||
class={cn("admin-table", skeleton && "admin-table-skeleton", className)}
|
||||
aria-hidden={skeleton ? "true" : undefined}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script>
|
||||
import Skeleton from "$components/ui/skeleton.svelte";
|
||||
import AdminTable from "./AdminTable.svelte";
|
||||
|
||||
export let headers = [];
|
||||
export let rows = 6;
|
||||
export let actionColumn = false;
|
||||
export let widths = [];
|
||||
|
||||
function widthFor(index) {
|
||||
if (widths[index]) return widths[index];
|
||||
if (actionColumn && index === headers.length - 1) return "92px";
|
||||
if (index === 0) return "48px";
|
||||
if (index === headers.length - 1) return "76px";
|
||||
return index % 3 === 0 ? "56%" : "72%";
|
||||
}
|
||||
</script>
|
||||
|
||||
<AdminTable skeleton>
|
||||
<thead>
|
||||
<tr>
|
||||
{#each headers as header}
|
||||
<th class:admin-cell-actions={actionColumn && header === headers[headers.length - 1]}
|
||||
>{header}</th
|
||||
>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each Array(rows) as _, rowIndex (rowIndex)}
|
||||
<tr>
|
||||
{#each headers as _header, colIndex (`${rowIndex}-${colIndex}`)}
|
||||
<td>
|
||||
<Skeleton variant="line" width={widthFor(colIndex)} />
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</AdminTable>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script>
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
export let title = "";
|
||||
export let value = "";
|
||||
export let left = "";
|
||||
export let percent = 0;
|
||||
export let warning = false;
|
||||
export let premium = false;
|
||||
export let label = "";
|
||||
|
||||
$: clamped = Math.max(0, Math.min(100, Number(percent) || 0));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
"admin-traffic-card",
|
||||
warning && "admin-traffic-card-warning",
|
||||
premium && "admin-traffic-card-premium"
|
||||
)}
|
||||
>
|
||||
<div class="admin-traffic-head">
|
||||
<span>{title}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
<div
|
||||
class={cn("admin-traffic-bar", premium && "admin-traffic-bar-premium")}
|
||||
aria-label={label || title}
|
||||
role="progressbar"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
aria-valuenow={Math.round(clamped)}
|
||||
>
|
||||
<span style={`width: ${clamped}%`}></span>
|
||||
</div>
|
||||
<div class="admin-traffic-meta">
|
||||
<span>{left}</span>
|
||||
<span>{clamped}%</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,14 @@
|
||||
export { default as AdminBadge } from "./AdminBadge.svelte";
|
||||
export { default as AdminButton } from "./AdminButton.svelte";
|
||||
export { default as AdminDashboardGrid } from "./AdminDashboardGrid.svelte";
|
||||
export { default as AdminDashboardStack } from "./AdminDashboardStack.svelte";
|
||||
export { default as AdminEmptyState } from "./AdminEmptyState.svelte";
|
||||
export { default as AdminField } from "./AdminField.svelte";
|
||||
export { default as AdminPagination } from "./AdminPagination.svelte";
|
||||
export { default as AdminRevenueChart } from "./AdminRevenueChart.svelte";
|
||||
export { default as AdminRevenueCustomRangePopover } from "./AdminRevenueCustomRangePopover.svelte";
|
||||
export { default as AdminSelect } from "./AdminSelect.svelte";
|
||||
export { default as AdminSectionHeader } from "./AdminSectionHeader.svelte";
|
||||
export { default as AdminTable } from "./AdminTable.svelte";
|
||||
export { default as AdminTableSkeleton } from "./AdminTableSkeleton.svelte";
|
||||
export { default as AdminTrafficCard } from "./AdminTrafficCard.svelte";
|
||||
@@ -0,0 +1,67 @@
|
||||
<script>
|
||||
import Skeleton from "$components/ui/skeleton.svelte";
|
||||
|
||||
export let label = "";
|
||||
export let rows = 3;
|
||||
export let actions = 0;
|
||||
export let methods = 2;
|
||||
export let showNote = false;
|
||||
export let showPayButton = true;
|
||||
export let showMeta = true;
|
||||
</script>
|
||||
|
||||
<div class="dialog-skeleton" aria-label={label}>
|
||||
{#if actions}
|
||||
<div class="tariff-action-list">
|
||||
{#each Array(actions) as _, index (index)}
|
||||
<div class="tariff-action-card skeleton-row">
|
||||
<span>
|
||||
<Skeleton variant="title" />
|
||||
<Skeleton variant="short" />
|
||||
</span>
|
||||
<Skeleton class="skeleton-line-price" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="payment-divider" aria-hidden="true"></div>
|
||||
{/if}
|
||||
|
||||
<div class="option-list">
|
||||
{#each Array(rows) as _, index (index)}
|
||||
<div class={`option-row ${showMeta ? "plan-row" : "change-action-row"} skeleton-row`}>
|
||||
<span class="option-row-main">
|
||||
<Skeleton variant="title" />
|
||||
<Skeleton variant="short" />
|
||||
</span>
|
||||
{#if showMeta}
|
||||
<span class="option-row-meta">
|
||||
<Skeleton class="skeleton-line-price" />
|
||||
<Skeleton variant="tiny" />
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if showNote}
|
||||
<div class="topup-carryover-note skeleton-carryover-note">
|
||||
<Skeleton variant="line" />
|
||||
<Skeleton variant="short" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if methods}
|
||||
<div class="method-grid">
|
||||
{#each Array(methods) as _, index (index)}
|
||||
<div class="method-card skeleton-method">
|
||||
<Skeleton variant="dot" />
|
||||
<Skeleton class="skeleton-line-method" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showPayButton}
|
||||
<Skeleton class="skeleton-pay-button" />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script>
|
||||
import Card from "$components/ui/card.svelte";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<Card class={cn("empty-card", className)} {...$$restProps}>
|
||||
<slot />
|
||||
</Card>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script>
|
||||
import { Check, ChevronsUpDown, Globe2 } from "$components/ui/icons.js";
|
||||
import { Select } from "$components/ui/primitives.js";
|
||||
|
||||
export let open = false;
|
||||
export let value = "ru";
|
||||
export let currentOption = null;
|
||||
export let userLanguage = "";
|
||||
export let options = [];
|
||||
export let disabled = false;
|
||||
export let clickGuard = false;
|
||||
export let clickGuardArmed = false;
|
||||
export let closeLabel = "Close";
|
||||
export let label = "Language";
|
||||
export let onOpenChange = () => {};
|
||||
export let onValueChange = () => {};
|
||||
|
||||
function closeFromGuard(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (clickGuardArmed) onOpenChange(false);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open || clickGuard}
|
||||
<button
|
||||
class="language-select-guard"
|
||||
class:language-select-guard--armed={clickGuardArmed}
|
||||
type="button"
|
||||
aria-label={closeLabel}
|
||||
onpointerdown={closeFromGuard}
|
||||
onclick={closeFromGuard}
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
<div class="settings-row settings-row-language">
|
||||
<Globe2 size={21} />
|
||||
<Select.Root
|
||||
type="single"
|
||||
bind:open
|
||||
{value}
|
||||
items={options}
|
||||
{disabled}
|
||||
{onOpenChange}
|
||||
{onValueChange}
|
||||
>
|
||||
<Select.Trigger class="language-select-trigger" aria-label={label}>
|
||||
<span class="language-select-copy">
|
||||
<strong>{label}</strong>
|
||||
<small class="language-select-current">
|
||||
<span class="emoji-flag" aria-hidden="true">{currentOption?.flag || "🏳️"}</span>
|
||||
{currentOption?.label || userLanguage}
|
||||
</small>
|
||||
</span>
|
||||
<ChevronsUpDown size={16} />
|
||||
</Select.Trigger>
|
||||
<Select.Content class="language-select-content" side="bottom" align="end" sideOffset={6}>
|
||||
<Select.Viewport class="language-select-viewport">
|
||||
{#each options as option (option.value)}
|
||||
<Select.Item value={option.value} label={option.label} class="language-select-item">
|
||||
<span class="language-select-item-main">
|
||||
<span class="emoji-flag" aria-hidden="true">{option.flag}</span>
|
||||
<span>{option.label}</span>
|
||||
</span>
|
||||
<Check size={15} class="language-select-item-check" />
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Viewport>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script>
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
export let value = 0;
|
||||
export let label = "";
|
||||
let className = "";
|
||||
export { className as class };
|
||||
|
||||
$: clamped = Math.max(0, Math.min(100, Number(value) || 0));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn("progress", className)}
|
||||
role={label ? "progressbar" : undefined}
|
||||
aria-label={label || undefined}
|
||||
aria-valuemin={label ? "0" : undefined}
|
||||
aria-valuemax={label ? "100" : undefined}
|
||||
aria-valuenow={label ? Math.round(clamped) : undefined}
|
||||
{...$$restProps}
|
||||
>
|
||||
<span style={`width: ${clamped}%`}></span>
|
||||
</div>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script>
|
||||
import { Bitcoin, CreditCard } from "$components/ui/icons.js";
|
||||
|
||||
export let methods = [];
|
||||
export let selectedMethod = "";
|
||||
export let t = (key) => key;
|
||||
export let onSelect = () => {};
|
||||
|
||||
function methodMeta(method) {
|
||||
const id = String(method?.id || "").toLowerCase();
|
||||
if (id.includes("platega_sbp"))
|
||||
return { title: t("wa_method_platega_sbp_card"), icon: CreditCard };
|
||||
if (id.includes("platega_crypto"))
|
||||
return { title: t("wa_method_platega_crypto"), icon: Bitcoin };
|
||||
if (id.includes("yookassa") || id.includes("card"))
|
||||
return { title: t("pay_with_yookassa_button"), icon: null };
|
||||
if (id.includes("severpay")) return { title: t("pay_with_severpay_button"), icon: null };
|
||||
if (id.includes("freekassa")) return { title: t("pay_with_sbp_button"), icon: null };
|
||||
if (id.includes("cryptopay") || id.includes("crypto"))
|
||||
return { title: t("pay_with_cryptopay_button"), icon: null };
|
||||
if (id.includes("stars")) return { title: t("pay_with_stars_button"), icon: null };
|
||||
if (id.includes("sbp")) return { title: t("pay_with_sbp_button"), icon: null };
|
||||
return { title: t("wa_method_other_title"), icon: null };
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="method-grid">
|
||||
{#each methods as method}
|
||||
{@const meta = methodMeta(method)}
|
||||
<button
|
||||
class:active={selectedMethod === method.id}
|
||||
class="method-card"
|
||||
type="button"
|
||||
onclick={() => onSelect(method.id)}
|
||||
>
|
||||
<span class="method-card-main">
|
||||
{#if meta.icon}
|
||||
<svelte:component this={meta.icon} size={19} />
|
||||
{/if}
|
||||
<strong>{meta.title}</strong>
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script>
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
export let error = false;
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<p class={cn("status-line", error && "error", className)} {...$$restProps}>
|
||||
<slot />
|
||||
</p>
|
||||
@@ -0,0 +1,6 @@
|
||||
export { default as DialogOptionsSkeleton } from "./DialogOptionsSkeleton.svelte";
|
||||
export { default as EmptyCard } from "./EmptyCard.svelte";
|
||||
export { default as LinearProgress } from "./LinearProgress.svelte";
|
||||
export { default as LanguageSelect } from "./LanguageSelect.svelte";
|
||||
export { default as PaymentMethodGrid } from "./PaymentMethodGrid.svelte";
|
||||
export { default as StatusMessage } from "./StatusMessage.svelte";
|
||||
@@ -0,0 +1,24 @@
|
||||
<script>
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
/** @type {'default' | 'outline' | 'destructive' | 'success' | 'muted'} */
|
||||
export let variant = "default";
|
||||
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<span
|
||||
data-slot="badge"
|
||||
class={cn(
|
||||
"admin-cn-badge",
|
||||
variant === "outline" && "admin-cn-badge-outline",
|
||||
variant === "destructive" && "admin-cn-badge-destructive",
|
||||
variant === "success" && "admin-cn-badge-success",
|
||||
variant === "muted" && "admin-cn-badge-muted",
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script>
|
||||
import { cva } from "class-variance-authority";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
export let type = "button";
|
||||
export let variant = "default";
|
||||
export let size = "default";
|
||||
export let disabled = false;
|
||||
export let href = "";
|
||||
export let onclick = undefined;
|
||||
let className = "";
|
||||
export { className as class };
|
||||
|
||||
const buttonVariants = cva("btn", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "btn-primary",
|
||||
secondary: "btn-secondary",
|
||||
outline: "btn-outline",
|
||||
ghost: "btn-ghost",
|
||||
telegram: "btn-telegram",
|
||||
icon: "btn-icon",
|
||||
},
|
||||
size: {
|
||||
default: "",
|
||||
sm: "btn-sm",
|
||||
lg: "btn-lg",
|
||||
icon: "btn-square",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a class={cn(buttonVariants({ variant, size }), className)} {href} {onclick} {...$$restProps}>
|
||||
<slot />
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
{type}
|
||||
{disabled}
|
||||
{onclick}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
{/if}
|
||||
@@ -0,0 +1,12 @@
|
||||
<script>
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
export let active = false;
|
||||
export let compact = false;
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<section class={cn("card", active && "card-active", compact && "card-compact", className)}>
|
||||
<slot />
|
||||
</section>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script>
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<div data-slot="card-action" class={cn("admin-cn-card-action", className)} {...$$restProps}>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script>
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<div data-slot="card-content" class={cn("admin-cn-card-content", className)} {...$$restProps}>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script>
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<p data-slot="card-description" class={cn("admin-cn-card-description", className)} {...$$restProps}>
|
||||
<slot />
|
||||
</p>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script>
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<div data-slot="card-footer" class={cn("admin-cn-card-footer", className)} {...$$restProps}>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script>
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<div data-slot="card-header" class={cn("admin-cn-card-header", className)} {...$$restProps}>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script>
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<div data-slot="card-title" class={cn("admin-cn-card-title", className)} {...$$restProps}>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script>
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<div data-slot="card" class={cn("admin-cn-card", className)} {...$$restProps}>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
import Root from "./card.svelte";
|
||||
import Header from "./card-header.svelte";
|
||||
import Title from "./card-title.svelte";
|
||||
import Description from "./card-description.svelte";
|
||||
import Action from "./card-action.svelte";
|
||||
import Footer from "./card-footer.svelte";
|
||||
import Content from "./card-content.svelte";
|
||||
|
||||
export { Root, Header, Title, Description, Action, Footer, Content };
|
||||
@@ -0,0 +1,64 @@
|
||||
<script>
|
||||
import { X } from "$components/ui/icons.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { cubicOut } from "svelte/easing";
|
||||
import { onMount } from "svelte";
|
||||
import { fade, fly } from "svelte/transition";
|
||||
import Button from "./button.svelte";
|
||||
|
||||
export let open = false;
|
||||
export let title = "";
|
||||
export let description = "";
|
||||
export let closeLabel = "Close";
|
||||
export let onclose = () => {};
|
||||
let className = "";
|
||||
export { className as class };
|
||||
|
||||
function readReduceMotion() {
|
||||
return (
|
||||
typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
);
|
||||
}
|
||||
|
||||
let reduceMotion = readReduceMotion();
|
||||
|
||||
onMount(() => {
|
||||
reduceMotion = readReduceMotion();
|
||||
if (typeof window === "undefined" || !window.matchMedia) return () => {};
|
||||
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const handler = () => {
|
||||
reduceMotion = mq.matches;
|
||||
};
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
});
|
||||
|
||||
$: backdropTransition = reduceMotion ? { duration: 0 } : { duration: 200 };
|
||||
$: cardIn = reduceMotion ? { duration: 0, y: 0 } : { duration: 260, y: 16, easing: cubicOut };
|
||||
$: cardOut = reduceMotion ? { duration: 0, y: 0 } : { duration: 200, y: 10, easing: cubicOut };
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div class="dialog" role="dialog" aria-modal="true" aria-label={title}>
|
||||
<button
|
||||
class="dialog-backdrop"
|
||||
type="button"
|
||||
aria-label={closeLabel}
|
||||
onclick={onclose}
|
||||
in:fade={backdropTransition}
|
||||
out:fade={backdropTransition}
|
||||
></button>
|
||||
<section class={cn("dialog-card", className)} in:fly={cardIn} out:fly={cardOut}>
|
||||
<div class="dialog-head">
|
||||
<div>
|
||||
{#if title}<h2>{title}</h2>{/if}
|
||||
{#if description}<p>{description}</p>{/if}
|
||||
</div>
|
||||
<Button variant="icon" size="icon" onclick={onclose} aria-label={closeLabel}>
|
||||
<X size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
<slot />
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,67 @@
|
||||
export {
|
||||
Activity,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Bitcoin,
|
||||
CalendarDays,
|
||||
Check,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsUpDown,
|
||||
Circle,
|
||||
CircleX,
|
||||
Coins,
|
||||
Copy,
|
||||
CreditCard,
|
||||
Crown,
|
||||
Database,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileText,
|
||||
Gift,
|
||||
Globe2,
|
||||
Home,
|
||||
Info,
|
||||
Key,
|
||||
LayoutDashboard,
|
||||
LockKeyhole,
|
||||
Mail,
|
||||
Map,
|
||||
Megaphone,
|
||||
Menu,
|
||||
MessageSquare,
|
||||
MousePointerClick,
|
||||
Paintbrush,
|
||||
Plus,
|
||||
QrCode,
|
||||
Radio,
|
||||
RefreshCw,
|
||||
Repeat2,
|
||||
Save,
|
||||
Send,
|
||||
Server,
|
||||
Settings,
|
||||
Shield,
|
||||
Sliders,
|
||||
Smartphone,
|
||||
Sparkles,
|
||||
Tag,
|
||||
Ticket,
|
||||
Trash2,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
TriangleAlert,
|
||||
User,
|
||||
UserMinus,
|
||||
UserPlus,
|
||||
UserRound,
|
||||
Users,
|
||||
UsersRound,
|
||||
WalletCards,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-svelte";
|
||||
@@ -0,0 +1,10 @@
|
||||
export { default as Badge } from "./badge.svelte";
|
||||
export { default as Button } from "./button.svelte";
|
||||
export { default as Dialog } from "./dialog.svelte";
|
||||
export { default as Input } from "./input.svelte";
|
||||
export { default as LegacyCard } from "./card.svelte";
|
||||
export { default as Skeleton } from "./skeleton.svelte";
|
||||
export { default as Spinner } from "./spinner.svelte";
|
||||
export * as Icons from "./icons.js";
|
||||
export * as Card from "./card/index.js";
|
||||
export { Accordion, Label, Select, Separator, Switch, Tabs, Tooltip } from "./primitives.js";
|
||||
@@ -0,0 +1,29 @@
|
||||
<script>
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
export let value = "";
|
||||
export let type = "text";
|
||||
export let placeholder = "";
|
||||
export let inputmode = undefined;
|
||||
export let maxlength = undefined;
|
||||
export let autocomplete = undefined;
|
||||
export let disabled = false;
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<input
|
||||
bind:value
|
||||
class={cn("input", className)}
|
||||
on:keydown
|
||||
on:input
|
||||
on:focus
|
||||
on:blur
|
||||
{type}
|
||||
{placeholder}
|
||||
{inputmode}
|
||||
{maxlength}
|
||||
{autocomplete}
|
||||
{disabled}
|
||||
{...$$restProps}
|
||||
/>
|
||||
@@ -0,0 +1 @@
|
||||
export { Accordion, Label, Select, Separator, Switch, Tabs, Tooltip } from "bits-ui";
|
||||
@@ -0,0 +1,25 @@
|
||||
<script>
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
export let variant = "block";
|
||||
export let width = "";
|
||||
export let height = "";
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<span
|
||||
class={cn(
|
||||
"ui-skeleton",
|
||||
variant === "line" && "ui-skeleton-line",
|
||||
variant === "title" && "ui-skeleton-line ui-skeleton-title",
|
||||
variant === "short" && "ui-skeleton-line ui-skeleton-short",
|
||||
variant === "tiny" && "ui-skeleton-line ui-skeleton-tiny",
|
||||
variant === "badge" && "ui-skeleton-badge",
|
||||
variant === "dot" && "ui-skeleton-dot",
|
||||
className
|
||||
)}
|
||||
style={`${width ? `width:${width};` : ""}${height ? `height:${height};` : ""}`}
|
||||
aria-hidden="true"
|
||||
{...$$restProps}
|
||||
></span>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script>
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
export let size = "default";
|
||||
export let label = "";
|
||||
let className = "";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<span
|
||||
class={cn(
|
||||
"ui-spinner",
|
||||
size === "sm" && "ui-spinner-sm",
|
||||
size === "lg" && "ui-spinner-lg",
|
||||
className
|
||||
)}
|
||||
role={label ? "status" : undefined}
|
||||
aria-label={label || undefined}
|
||||
aria-hidden={label ? undefined : "true"}
|
||||
{...$$restProps}
|
||||
></span>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
<script>
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
|
||||
import { cn } from "../utils.js";
|
||||
import { animatedEmojiAssetUrls, normalizeBrand } from "./browser.js";
|
||||
|
||||
const LOGO_LOAD_TIMEOUT_MS = 10000;
|
||||
|
||||
const EMOJI_FONT_OPTIONS = {
|
||||
"noto-color": {
|
||||
cssFamily: "Noto Color Emoji",
|
||||
stylesheet: (text) =>
|
||||
`https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&display=swap&text=${encodeURIComponent(text)}`,
|
||||
},
|
||||
"noto-emoji": {
|
||||
cssFamily: "Noto Emoji",
|
||||
stylesheet: (text) =>
|
||||
`https://fonts.googleapis.com/css2?family=Noto+Emoji:wght@700&display=swap&text=${encodeURIComponent(text)}`,
|
||||
},
|
||||
twemoji: {
|
||||
cssFamily: "Twemoji Mozilla",
|
||||
stylesheet: () => "https://cdn.jsdelivr.net/npm/twemoji-colr-font@15.0.3/twemoji.css",
|
||||
},
|
||||
openmoji: {
|
||||
cssFamily: "OpenMoji Color",
|
||||
stylesheet: () => "https://cdn.jsdelivr.net/npm/@openmoji/font@15.1.0/css/openmoji-color.css",
|
||||
},
|
||||
};
|
||||
|
||||
export let brand = {};
|
||||
export let logoUrl = "";
|
||||
export let emoji = "";
|
||||
export let emojiFont = "";
|
||||
export let size = "sm";
|
||||
export let animate = false;
|
||||
export let fallbackEmoji = true;
|
||||
let className = "";
|
||||
export { className as class };
|
||||
|
||||
const SIZE_CLASSES = {
|
||||
sm: "",
|
||||
md: "brand-mark-lg",
|
||||
lg: "brand-mark-xl",
|
||||
xl: "brand-mark-xl",
|
||||
};
|
||||
|
||||
let loaded = false;
|
||||
let failed = false;
|
||||
let lastLogoUrl = "";
|
||||
let logoLoadTimer = null;
|
||||
let logoLoadTimerUrl = "";
|
||||
let fontLoaded = false;
|
||||
let loadedFontKey = "";
|
||||
let animatedEmojiError = false;
|
||||
let animatedEmojiStaticFallback = false;
|
||||
let lastAnimatedEmoji = "";
|
||||
|
||||
$: normalizedBrand = normalizeBrand({
|
||||
...brand,
|
||||
logoUrl: logoUrl || brand?.logoUrl,
|
||||
emoji: emoji || brand?.emoji || brand?.logoEmoji,
|
||||
emojiFont: emojiFont || brand?.emojiFont || brand?.logoEmojiFont,
|
||||
});
|
||||
$: normalizedLogoUrl = normalizedBrand.logoUrl;
|
||||
$: normalizedEmoji = normalizedBrand.emoji;
|
||||
$: normalizedEmojiFont = normalizedBrand.emojiFont;
|
||||
$: sizeClass = SIZE_CLASSES[size] || "";
|
||||
$: animatedEmojiAssets = animatedEmojiAssetUrls(normalizedEmoji);
|
||||
$: animatedEmojiSrc = animatedEmojiAssets.gif;
|
||||
$: animatedEmojiFallbackSrc = animatedEmojiAssets.webp;
|
||||
$: useAnimatedEmoji =
|
||||
!normalizedLogoUrl &&
|
||||
normalizedEmojiFont === "noto-color-animated" &&
|
||||
animatedEmojiSrc &&
|
||||
!animatedEmojiError;
|
||||
|
||||
$: if (normalizedLogoUrl !== lastLogoUrl) {
|
||||
lastLogoUrl = normalizedLogoUrl;
|
||||
loaded = false;
|
||||
failed = false;
|
||||
}
|
||||
$: if (normalizedLogoUrl && !loaded && !failed) armLogoLoadTimeout();
|
||||
$: if (!normalizedLogoUrl || loaded || failed) clearLogoLoadTimeout();
|
||||
$: if (`${normalizedEmojiFont}:${normalizedEmoji}` !== lastAnimatedEmoji) {
|
||||
lastAnimatedEmoji = `${normalizedEmojiFont}:${normalizedEmoji}`;
|
||||
animatedEmojiError = false;
|
||||
animatedEmojiStaticFallback = false;
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
clearLogoLoadTimeout();
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
loadEmojiFont(normalizedEmojiFont, normalizedEmoji);
|
||||
});
|
||||
|
||||
$: if (normalizedEmojiFont && normalizedEmoji) {
|
||||
loadEmojiFont(normalizedEmojiFont, normalizedEmoji);
|
||||
}
|
||||
|
||||
function loadEmojiFont(font, text) {
|
||||
if (typeof document === "undefined") return;
|
||||
if (font === "system" || font === "noto-color-animated" || !font) {
|
||||
fontLoaded = true;
|
||||
loadedFontKey = "system";
|
||||
return;
|
||||
}
|
||||
|
||||
const fontOption = EMOJI_FONT_OPTIONS[font];
|
||||
if (!fontOption) {
|
||||
fontLoaded = true;
|
||||
loadedFontKey = font;
|
||||
return;
|
||||
}
|
||||
|
||||
const fontUrl = fontOption.stylesheet(text);
|
||||
const fontKey = `${font}:${text}`;
|
||||
if (loadedFontKey === fontKey) return;
|
||||
|
||||
fontLoaded = false;
|
||||
loadedFontKey = fontKey;
|
||||
|
||||
const existing = document.querySelector(`link[data-brand-emoji-font="${fontKey}"]`);
|
||||
if (existing) {
|
||||
fontLoaded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = fontUrl;
|
||||
link.dataset.brandEmojiFont = fontKey;
|
||||
link.onload = () => {
|
||||
fontLoaded = true;
|
||||
if (document.fonts && fontOption.cssFamily) {
|
||||
document.fonts.load(`1em "${fontOption.cssFamily}"`, text).finally(() => {
|
||||
fontLoaded = true;
|
||||
});
|
||||
}
|
||||
};
|
||||
link.onerror = () => {
|
||||
fontLoaded = true;
|
||||
};
|
||||
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
function getEmojiFontClass(font) {
|
||||
if (font === "noto-color") return "emoji-font-noto-color";
|
||||
if (font === "noto-emoji") return "emoji-font-noto-emoji";
|
||||
if (font === "twemoji") return "emoji-font-twemoji";
|
||||
if (font === "openmoji") return "emoji-font-openmoji";
|
||||
if (font === "apple") return "emoji-font-apple";
|
||||
if (font === "segoe") return "emoji-font-segoe";
|
||||
if (font === "noto-local") return "emoji-font-noto-local";
|
||||
return "";
|
||||
}
|
||||
|
||||
function clearLogoLoadTimeout() {
|
||||
if (logoLoadTimer) {
|
||||
window.clearTimeout(logoLoadTimer);
|
||||
logoLoadTimer = null;
|
||||
}
|
||||
logoLoadTimerUrl = "";
|
||||
}
|
||||
|
||||
function armLogoLoadTimeout() {
|
||||
if (typeof window === "undefined") return;
|
||||
if (logoLoadTimer && logoLoadTimerUrl === normalizedLogoUrl) return;
|
||||
clearLogoLoadTimeout();
|
||||
logoLoadTimerUrl = normalizedLogoUrl;
|
||||
logoLoadTimer = window.setTimeout(() => {
|
||||
if (logoLoadTimerUrl === normalizedLogoUrl && !loaded) failed = true;
|
||||
}, LOGO_LOAD_TIMEOUT_MS);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
"brand-mark",
|
||||
sizeClass,
|
||||
animate && "brand-mark-animate",
|
||||
normalizedLogoUrl && !failed && !loaded && "brand-mark-loading",
|
||||
normalizedLogoUrl && !failed && loaded && "brand-mark-loaded",
|
||||
className
|
||||
)}
|
||||
aria-busy={normalizedLogoUrl && !failed && !loaded ? "true" : undefined}
|
||||
>
|
||||
{#if normalizedLogoUrl && !failed}
|
||||
{#if !loaded}
|
||||
<span class="brand-mark-spinner" aria-hidden="true"></span>
|
||||
{/if}
|
||||
<img
|
||||
class:loaded
|
||||
src={normalizedLogoUrl}
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
fetchpriority="high"
|
||||
on:load={() => {
|
||||
loaded = true;
|
||||
clearLogoLoadTimeout();
|
||||
}}
|
||||
on:error={() => {
|
||||
failed = true;
|
||||
clearLogoLoadTimeout();
|
||||
}}
|
||||
/>
|
||||
{:else if fallbackEmoji && useAnimatedEmoji}
|
||||
<img
|
||||
class="brand-mark-animated-emoji loaded"
|
||||
src={animatedEmojiStaticFallback ? animatedEmojiFallbackSrc : animatedEmojiSrc}
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
fetchpriority="high"
|
||||
on:error={() => {
|
||||
if (!animatedEmojiStaticFallback && animatedEmojiFallbackSrc) {
|
||||
animatedEmojiStaticFallback = true;
|
||||
} else {
|
||||
animatedEmojiError = true;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{:else if fallbackEmoji}
|
||||
<span
|
||||
class={cn("brand-mark-emoji", getEmojiFontClass(normalizedEmojiFont))}
|
||||
style="opacity: {fontLoaded ? 1 : 0}; transition: opacity 0.2s ease;"
|
||||
>
|
||||
{normalizedEmoji}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.brand-mark {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
flex-shrink: 0;
|
||||
overflow: visible;
|
||||
font-size: 1.625rem;
|
||||
}
|
||||
|
||||
.brand-mark img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.brand-mark img.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.brand-mark.brand-mark-lg {
|
||||
width: 4.125rem;
|
||||
height: 4.125rem;
|
||||
font-size: 2.875rem;
|
||||
}
|
||||
|
||||
.brand-mark.brand-mark-xl {
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
font-size: 4.375rem;
|
||||
}
|
||||
|
||||
.brand-mark img.brand-mark-animated-emoji {
|
||||
object-fit: contain;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.brand-mark.brand-mark-animate {
|
||||
animation: brand-mark-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.brand-mark-spinner {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.brand-mark-spinner::after {
|
||||
content: "";
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid currentColor;
|
||||
border-bottom-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: brand-mark-spin 0.8s linear infinite;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@keyframes brand-mark-spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes brand-mark-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.brand-mark-emoji {
|
||||
color: inherit;
|
||||
font-size: 1em;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: translateY(0.02em);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.brand-mark-xl .brand-mark-emoji {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.brand-mark-lg .brand-mark-emoji {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.emoji-font-noto-color {
|
||||
font-family: "Noto Color Emoji", sans-serif;
|
||||
}
|
||||
|
||||
.emoji-font-noto-emoji {
|
||||
color: var(--accent);
|
||||
font-family: "Noto Emoji", sans-serif;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.emoji-font-twemoji {
|
||||
font-family: "Twemoji Mozilla", sans-serif;
|
||||
}
|
||||
|
||||
.emoji-font-openmoji {
|
||||
font-family: "OpenMoji Color", sans-serif;
|
||||
}
|
||||
|
||||
.emoji-font-apple {
|
||||
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
|
||||
}
|
||||
|
||||
.emoji-font-segoe {
|
||||
font-family: "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", sans-serif;
|
||||
}
|
||||
|
||||
.emoji-font-noto-local {
|
||||
font-family: "Noto Color Emoji", "Noto Emoji", sans-serif;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
import { rememberReferral, readReferral } from "./session.js";
|
||||
|
||||
export function readReferralParam(tg) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const fromQuery = params.get("ref") || params.get("start") || params.get("start_param") || "";
|
||||
const fromTelegram = tg?.initDataUnsafe?.start_param || "";
|
||||
const value = String(fromTelegram || fromQuery || "").trim();
|
||||
return value ? rememberReferral(value) : readReferral();
|
||||
}
|
||||
|
||||
export function readTelegramAuthStatus() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return (params.get("telegram_auth") || "").trim().toLowerCase() || null;
|
||||
}
|
||||
|
||||
export function readMagicLoginToken() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return (params.get("login_token") || "").trim() || null;
|
||||
}
|
||||
|
||||
export function readTelegramLoginWidgetAuthData() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const keys = ["id", "first_name", "last_name", "username", "photo_url", "auth_date", "hash"];
|
||||
const authData = {};
|
||||
let hasAuthValue = false;
|
||||
keys.forEach((key) => {
|
||||
if (!params.has(key)) return;
|
||||
authData[key] = params.get(key) || "";
|
||||
hasAuthValue = true;
|
||||
});
|
||||
if (!hasAuthValue || !authData.id || !authData.auth_date || !authData.hash) return null;
|
||||
return authData;
|
||||
}
|
||||
|
||||
export function clearAuthQuery() {
|
||||
const url = new URL(window.location.href);
|
||||
[
|
||||
"login_token",
|
||||
"login_purpose",
|
||||
"telegram_auth",
|
||||
"id",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"username",
|
||||
"photo_url",
|
||||
"auth_date",
|
||||
"hash",
|
||||
].forEach((key) => url.searchParams.delete(key));
|
||||
window.history?.replaceState?.({}, document.title, url.pathname + url.search + url.hash);
|
||||
}
|
||||
|
||||
export function buildTelegramOAuthStartUrl(purpose = "login", tg = null) {
|
||||
const url = new URL("/auth/telegram/start", window.location.origin);
|
||||
url.searchParams.set("purpose", purpose);
|
||||
const referralParam = readReferralParam(tg);
|
||||
if (referralParam) url.searchParams.set("referral_code", referralParam);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function emailError(error, fallback, t) {
|
||||
if (error?.error === "rate_limited")
|
||||
return t("wa_auth_resend_wait", { seconds: error.retry_after || 60 });
|
||||
if (error?.error === "invalid_email") return t("wa_auth_invalid_email");
|
||||
if (error?.error === "expired_code") return t("wa_auth_code_expired");
|
||||
if (error?.error === "invalid_code" || error?.error === "too_many_attempts")
|
||||
return t("wa_auth_invalid_code");
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function createCooldownTimer() {
|
||||
let timer = null;
|
||||
let cooldown = 0;
|
||||
const listeners = new Set();
|
||||
function notify() {
|
||||
for (const fn of listeners) fn(cooldown);
|
||||
}
|
||||
function clear() {
|
||||
if (timer) {
|
||||
window.clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
function start(seconds = 60) {
|
||||
clear();
|
||||
cooldown = Math.max(0, Number(seconds || 60));
|
||||
notify();
|
||||
timer = window.setInterval(() => {
|
||||
if (cooldown <= 1) {
|
||||
cooldown = 0;
|
||||
clear();
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
cooldown -= 1;
|
||||
notify();
|
||||
}, 1000);
|
||||
}
|
||||
function subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
listener(cooldown);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
return {
|
||||
start,
|
||||
clear,
|
||||
subscribe,
|
||||
get value() {
|
||||
return cooldown;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
export function createBillingActions({ api }) {
|
||||
async function fetchTopupOptions(kind) {
|
||||
return api(`/tariffs/topup-options?kind=${encodeURIComponent(kind)}`);
|
||||
}
|
||||
|
||||
async function fetchDeviceTopupOptions() {
|
||||
return api("/devices/topup-options");
|
||||
}
|
||||
|
||||
async function fetchTariffChangeOptions() {
|
||||
return api("/tariffs/change-options");
|
||||
}
|
||||
|
||||
async function postPayment(body) {
|
||||
return api("/payments", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
async function postTariffChange(body) {
|
||||
return api("/tariffs/change", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
async function postTariffChangePayment(body) {
|
||||
return api("/tariffs/change-payment", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
function planPaymentBody(plan, method) {
|
||||
return {
|
||||
months: plan.months,
|
||||
traffic_gb: plan.traffic_gb,
|
||||
device_count: plan.device_count,
|
||||
tariff_key: plan.tariff_key,
|
||||
sale_mode: plan.sale_mode,
|
||||
method,
|
||||
};
|
||||
}
|
||||
|
||||
function topupPaymentBody(plan, method, fallbackTariffKey) {
|
||||
return {
|
||||
months: plan.months,
|
||||
traffic_gb: plan.traffic_gb,
|
||||
tariff_key: plan.tariff_key || fallbackTariffKey,
|
||||
sale_mode: plan.sale_mode || "topup",
|
||||
method,
|
||||
};
|
||||
}
|
||||
|
||||
function deviceTopupPaymentBody(plan, method, fallbackTariffKey) {
|
||||
return {
|
||||
months: plan.device_count || plan.months,
|
||||
device_count: plan.device_count || plan.months,
|
||||
tariff_key: plan.tariff_key || fallbackTariffKey,
|
||||
sale_mode: "hwid_devices",
|
||||
method,
|
||||
};
|
||||
}
|
||||
|
||||
function changePaymentBody(action, target, method) {
|
||||
if (action.mode === "buy_package") {
|
||||
return {
|
||||
tariff_key: target.tariff_key,
|
||||
traffic_gb: action.traffic_gb,
|
||||
months: action.traffic_gb,
|
||||
sale_mode: "topup",
|
||||
method,
|
||||
};
|
||||
}
|
||||
if (action.mode === "buy_period") {
|
||||
return {
|
||||
tariff_key: target.tariff_key,
|
||||
months: action.months,
|
||||
method,
|
||||
};
|
||||
}
|
||||
return { tariff_key: target.tariff_key, method };
|
||||
}
|
||||
|
||||
return {
|
||||
fetchTopupOptions,
|
||||
fetchDeviceTopupOptions,
|
||||
fetchTariffChangeOptions,
|
||||
postPayment,
|
||||
postTariffChange,
|
||||
postTariffChangePayment,
|
||||
planPaymentBody,
|
||||
topupPaymentBody,
|
||||
deviceTopupPaymentBody,
|
||||
changePaymentBody,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Drop cached topup / change-tariff option payloads so the next open refetches from /api. */
|
||||
export function invalidateWebappTariffOptionCaches(billingStore) {
|
||||
billingStore.update((s) => ({
|
||||
...s,
|
||||
topupOptions: null,
|
||||
deviceTopupOptions: null,
|
||||
changeOptions: null,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
export function readJsonScript(id) {
|
||||
const node = document.getElementById(id);
|
||||
if (!node || !node.textContent) return null;
|
||||
try {
|
||||
return JSON.parse(node.textContent);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to parse JSON config from #${id}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function structuredCloneSafe(value) {
|
||||
try {
|
||||
return structuredClone(value);
|
||||
} catch {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
}
|
||||
|
||||
export function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
export function normalizeBrand(brand = {}) {
|
||||
return {
|
||||
title: String(brand.title || "/minishop").trim() || "/minishop",
|
||||
logoUrl: String(brand.logoUrl || "").trim(),
|
||||
emoji: String(brand.emoji || brand.logoEmoji || "🫥").trim() || "🫥",
|
||||
emojiFont: String(brand.emojiFont || brand.logoEmojiFont || "system").trim() || "system",
|
||||
};
|
||||
}
|
||||
|
||||
export function emojiToCodepoints(value) {
|
||||
return Array.from(String(value || "").trim())
|
||||
.map((char) => char.codePointAt(0)?.toString(16))
|
||||
.filter(Boolean)
|
||||
.join("_");
|
||||
}
|
||||
|
||||
export function animatedEmojiAssetUrls(emoji) {
|
||||
const codepoints = emojiToCodepoints(emoji);
|
||||
if (!codepoints) return { gif: "", webp: "" };
|
||||
return {
|
||||
gif: `/webapp-emoji/${codepoints}/512.gif`,
|
||||
webp: `/webapp-emoji/${codepoints}/512.webp`,
|
||||
};
|
||||
}
|
||||
|
||||
export function brandFaviconHref(brand = {}) {
|
||||
const normalizedBrand = normalizeBrand(brand);
|
||||
if (normalizedBrand.logoUrl) return normalizedBrand.logoUrl;
|
||||
|
||||
if (normalizedBrand.emojiFont === "noto-color-animated") {
|
||||
return animatedEmojiAssetUrls(normalizedBrand.emoji).gif;
|
||||
}
|
||||
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><text x="50%" y="50%" dominant-baseline="central" text-anchor="middle" font-size="52">${escapeHtml(normalizedBrand.emoji)}</text></svg>`;
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
export function applyFavicon(brand = {}) {
|
||||
if (typeof document === "undefined") return;
|
||||
const favicon = document.getElementById("app-favicon");
|
||||
if (!favicon) return;
|
||||
|
||||
const href = brandFaviconHref(brand);
|
||||
favicon.setAttribute("href", href);
|
||||
if (href.startsWith("data:image/svg+xml")) {
|
||||
favicon.setAttribute("type", "image/svg+xml");
|
||||
} else if (href.endsWith(".gif")) {
|
||||
favicon.setAttribute("type", "image/gif");
|
||||
} else if (href.endsWith(".webp")) {
|
||||
favicon.setAttribute("type", "image/webp");
|
||||
} else {
|
||||
favicon.removeAttribute("type");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export const MANUAL_LOGOUT_FLAG_KEY = "rw_webapp_manual_logout";
|
||||
export const LANGUAGE_LABELS = {
|
||||
ru: "Русский",
|
||||
en: "English",
|
||||
de: "Deutsch",
|
||||
es: "Español",
|
||||
fr: "Français",
|
||||
tr: "Türkçe",
|
||||
uk: "Українська",
|
||||
};
|
||||
export const LANGUAGE_FLAGS = {
|
||||
ru: "🇷🇺",
|
||||
en: "🇬🇧",
|
||||
de: "🇩🇪",
|
||||
es: "🇪🇸",
|
||||
fr: "🇫🇷",
|
||||
tr: "🇹🇷",
|
||||
uk: "🇺🇦",
|
||||
};
|
||||
export const WEBAPP_LANGUAGE_ORDER = ["ru", "en"];
|
||||
export const APP_SECTION_PATHS = {
|
||||
home: "/home",
|
||||
invite: "/invite",
|
||||
devices: "/devices",
|
||||
settings: "/settings",
|
||||
admin: "/admin",
|
||||
};
|
||||
export const ADMIN_SECTIONS = new Set([
|
||||
"stats",
|
||||
"users",
|
||||
"payments",
|
||||
"promos",
|
||||
"ads",
|
||||
"broadcast",
|
||||
"logs",
|
||||
"tariffs",
|
||||
"appearance",
|
||||
"settings",
|
||||
]);
|
||||
export const TELEGRAM_WEBAPP_SCRIPT_URL = "https://telegram.org/js/telegram-web-app.js";
|
||||
export const TELEGRAM_SDK_BOOT_TIMEOUT_MS = 900;
|
||||
export const TELEGRAM_SDK_ACTION_TIMEOUT_MS = 1800;
|
||||
export const TELEGRAM_MINI_APP_AUTH_TIMEOUT_MS = 15000;
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Pure helpers for HWID / device limits UI (used by DevicesScreen).
|
||||
* @param {Record<string, unknown>} devicesData API payload from /api/devices
|
||||
* @param {(key: string, vars?: Record<string, unknown>, fallback?: string) => string} t i18n function
|
||||
* @param {unknown} [maxDevicesOverride] optional max_devices override (defaults to devicesData.max_devices)
|
||||
*/
|
||||
export function devicesLimitLabel(devicesData, t, maxDevicesOverride) {
|
||||
const value = maxDevicesOverride !== undefined ? maxDevicesOverride : devicesData?.max_devices;
|
||||
const numeric = Number(value ?? 0);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) return t("wa_devices_unlimited");
|
||||
return String(Math.trunc(numeric));
|
||||
}
|
||||
|
||||
export function devicesCountLabel(devicesData, t) {
|
||||
const current = Number(devicesData?.current_devices ?? devicesData?.devices?.length ?? 0);
|
||||
return t("wa_devices_count", { current, max: devicesLimitLabel(devicesData, t) });
|
||||
}
|
||||
|
||||
export function devicesPercent(devicesData) {
|
||||
const current = Number(devicesData?.current_devices ?? devicesData?.devices?.length ?? 0);
|
||||
const max = Number(devicesData?.max_devices || 0);
|
||||
if (!max || max <= 0) return 100;
|
||||
return Math.max(0, Math.min(100, Math.round((current / max) * 100)));
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
export function formatTemplate(template, params = {}) {
|
||||
const text = String(template ?? "");
|
||||
return text.replace(/\{(\w+)\}/g, (_, key) => String(params[key] ?? `{${key}}`));
|
||||
}
|
||||
|
||||
export function formatMoney(value, currency = "RUB") {
|
||||
const numeric = Number(value || 0);
|
||||
const formatted = Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2);
|
||||
const symbol = currency === "RUB" ? "₽" : currency;
|
||||
return `${formatted} ${symbol}`;
|
||||
}
|
||||
|
||||
export function formatTrafficGb(value) {
|
||||
const numeric = Number(value || 0);
|
||||
const formatted = Number.isInteger(numeric)
|
||||
? String(numeric)
|
||||
: numeric.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
|
||||
return `${formatted} GB`;
|
||||
}
|
||||
|
||||
export function formatTrafficBytes(value) {
|
||||
const gb = Number(value || 0) / 1073741824;
|
||||
return formatTrafficGb(gb);
|
||||
}
|
||||
|
||||
export function formatCompactNumber(value) {
|
||||
const numeric = Number(value || 0);
|
||||
return Number.isInteger(numeric)
|
||||
? String(numeric)
|
||||
: numeric.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
|
||||
}
|
||||
|
||||
export function roundToHalf(value) {
|
||||
return Math.round(Number(value || 0) * 2) / 2;
|
||||
}
|
||||
|
||||
export function formatFraction(value) {
|
||||
const n = Number(value || 0);
|
||||
if (Number.isInteger(n)) return String(n);
|
||||
return n.toFixed(1);
|
||||
}
|
||||
|
||||
export function normalizedEmail(value) {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function telegramName(profile, fallback) {
|
||||
const first = String(profile?.first_name || "").trim();
|
||||
const last = String(profile?.last_name || "").trim();
|
||||
if (first || last) return `${first} ${last}`.trim();
|
||||
const username = String(profile?.username || "").trim();
|
||||
if (username) return `@${username}`;
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
function bytesToHex(buffer) {
|
||||
return Array.from(new Uint8Array(buffer), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
async function sha256Hex(value) {
|
||||
const data = new TextEncoder().encode(value);
|
||||
const hashBuffer = await window.crypto.subtle.digest("SHA-256", data);
|
||||
return bytesToHex(hashBuffer);
|
||||
}
|
||||
|
||||
export async function buildGravatarUrl(emailValue) {
|
||||
if (!emailValue || !window.crypto?.subtle) return "";
|
||||
try {
|
||||
const hash = await sha256Hex(emailValue);
|
||||
return `https://www.gravatar.com/avatar/${hash}?d=mp&s=160`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { LANGUAGE_LABELS } from "./constants.js";
|
||||
import { formatTemplate, formatFraction, roundToHalf } from "./formatters.js";
|
||||
import { unitPluralBucket } from "./plurals.js";
|
||||
|
||||
export function createI18n({ messages = {}, defaultLang = "ru", getLang = null } = {}) {
|
||||
function normalizeLangCode(lang) {
|
||||
const key = String(lang || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!key) return defaultLang;
|
||||
const base = key.split("-")[0];
|
||||
if (LANGUAGE_LABELS[base]) return base;
|
||||
if (messages[base]) return base;
|
||||
if (messages[key]) return key;
|
||||
return defaultLang;
|
||||
}
|
||||
|
||||
function currentLang() {
|
||||
return normalizeLangCode(typeof getLang === "function" ? getLang() : defaultLang);
|
||||
}
|
||||
|
||||
function t(key, params = {}, fallback = "") {
|
||||
const lang = currentLang();
|
||||
const variants = [
|
||||
messages?.[lang]?.[key],
|
||||
messages?.en?.[key],
|
||||
messages?.ru?.[key],
|
||||
fallback,
|
||||
key,
|
||||
];
|
||||
const raw = variants.find((value) => typeof value === "string" && value.length);
|
||||
return formatTemplate(raw, params);
|
||||
}
|
||||
|
||||
function languageName(code) {
|
||||
const key = String(code || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!key) return t("wa_language_default");
|
||||
return LANGUAGE_LABELS[key] || key.toUpperCase();
|
||||
}
|
||||
|
||||
function termUnitLabel(value, unit) {
|
||||
const bucket = unitPluralBucket(value, currentLang());
|
||||
return t(`wa_sub_term_${unit}_${bucket}`);
|
||||
}
|
||||
|
||||
return { normalizeLangCode, t, currentLang, languageName, termUnitLabel };
|
||||
}
|
||||
|
||||
export { formatFraction, roundToHalf };
|
||||
@@ -0,0 +1,429 @@
|
||||
import { DEV_MOCK } from "./previewMock.js";
|
||||
|
||||
function defaultClone(value) {
|
||||
try {
|
||||
return structuredClone(value);
|
||||
} catch {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
}
|
||||
|
||||
export async function mockApi(path, options = {}, context = {}) {
|
||||
const {
|
||||
currentLang = "ru",
|
||||
normalizeLangCode = (value) => value || "ru",
|
||||
clone = defaultClone,
|
||||
} = context;
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 120));
|
||||
const cleanPath = String(path || "").split("?")[0];
|
||||
const adminUsers = [
|
||||
{
|
||||
user_id: 100200300,
|
||||
telegram_id: 100200300,
|
||||
username: "anna_ops",
|
||||
first_name: "Анна",
|
||||
last_name: "Смирнова",
|
||||
email: "anna@example.com",
|
||||
telegram_photo_url: "",
|
||||
registration_date: "2026-04-24T10:20:00Z",
|
||||
is_banned: false,
|
||||
premium_traffic: {
|
||||
state: "good",
|
||||
unlimited: false,
|
||||
used_bytes: 4 * 1073741824,
|
||||
limit_bytes: 25 * 1073741824,
|
||||
percent: 16,
|
||||
},
|
||||
},
|
||||
{
|
||||
user_id: 100200301,
|
||||
telegram_id: 87543123,
|
||||
username: "client_pro",
|
||||
first_name: "Максим",
|
||||
last_name: "Котов",
|
||||
email: "",
|
||||
telegram_photo_url: "",
|
||||
registration_date: "2026-04-26T08:15:00Z",
|
||||
is_banned: false,
|
||||
premium_traffic: {
|
||||
state: "warn",
|
||||
unlimited: false,
|
||||
used_bytes: 22 * 1073741824,
|
||||
limit_bytes: 25 * 1073741824,
|
||||
percent: 88,
|
||||
},
|
||||
},
|
||||
{
|
||||
user_id: 100200302,
|
||||
telegram_id: 88440011,
|
||||
username: "",
|
||||
first_name: "Daria",
|
||||
last_name: "",
|
||||
email: "daria@example.com",
|
||||
telegram_photo_url: "",
|
||||
registration_date: "2026-04-29T16:45:00Z",
|
||||
is_banned: true,
|
||||
premium_traffic: { state: "none" },
|
||||
},
|
||||
];
|
||||
const mockAdminDailySeries = (() => {
|
||||
const days = 730;
|
||||
const out = [];
|
||||
const now = new Date();
|
||||
for (let i = 0; i < days; i++) {
|
||||
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
||||
d.setUTCDate(d.getUTCDate() - (days - 1 - i));
|
||||
const iso = d.toISOString().slice(0, 10);
|
||||
const wave = Math.sin(i / 5) * 520 + 720 + ((i * 41) % 280);
|
||||
out.push({ date: iso, amount: Math.max(0, Math.round(wave)) });
|
||||
}
|
||||
return out;
|
||||
})();
|
||||
if (path === "/admin/stats") {
|
||||
return {
|
||||
ok: true,
|
||||
currency_symbol: "RUB",
|
||||
users: { total_users: 248, active_subscriptions: 172, banned_users: 3 },
|
||||
financial: {
|
||||
today_revenue: 1240,
|
||||
week_revenue: 15800,
|
||||
month_revenue: 44100,
|
||||
all_time_revenue: 186240,
|
||||
today_payments_count: 4,
|
||||
daily_series: mockAdminDailySeries,
|
||||
},
|
||||
panel_sync: {
|
||||
status: "success",
|
||||
last_sync_time: new Date().toISOString(),
|
||||
users_processed: 172,
|
||||
subscriptions_synced: 168,
|
||||
},
|
||||
recent_payments: [
|
||||
{
|
||||
payment_id: 1,
|
||||
user_id: 100200300,
|
||||
user_label: "anna_ops",
|
||||
amount: 790,
|
||||
currency: "RUB",
|
||||
provider: "yookassa",
|
||||
status: "succeeded",
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (cleanPath === "/admin/users")
|
||||
return { ok: true, users: adminUsers, total: adminUsers.length, page: 0, page_size: 25 };
|
||||
if (cleanPath.startsWith("/admin/users/")) {
|
||||
const id = Number(cleanPath.split("/")[3]);
|
||||
const user = adminUsers.find((item) => item.user_id === id) || adminUsers[0];
|
||||
return {
|
||||
ok: true,
|
||||
user,
|
||||
active_subscription: {
|
||||
subscription_id: 10,
|
||||
end_date: "2026-06-08T12:00:00Z",
|
||||
tariff_key: "standard",
|
||||
auto_renew_enabled: true,
|
||||
provider: "yookassa",
|
||||
},
|
||||
subscriptions: [
|
||||
{
|
||||
subscription_id: 10,
|
||||
end_date: "2026-06-08T12:00:00Z",
|
||||
tariff_key: "standard",
|
||||
is_active: true,
|
||||
status_from_panel: "ACTIVE",
|
||||
},
|
||||
{
|
||||
subscription_id: 9,
|
||||
end_date: "2026-05-08T12:00:00Z",
|
||||
tariff_key: "standard",
|
||||
is_active: false,
|
||||
status_from_panel: "EXPIRED",
|
||||
},
|
||||
],
|
||||
total_paid: 2380,
|
||||
recent_payments: [
|
||||
{
|
||||
payment_id: 12,
|
||||
amount: 790,
|
||||
currency: "RUB",
|
||||
provider: "yookassa",
|
||||
status: "succeeded",
|
||||
created_at: "2026-05-01T14:15:00Z",
|
||||
},
|
||||
{
|
||||
payment_id: 11,
|
||||
amount: 790,
|
||||
currency: "RUB",
|
||||
provider: "stars",
|
||||
status: "succeeded",
|
||||
created_at: "2026-04-01T14:15:00Z",
|
||||
},
|
||||
],
|
||||
log_count: 18,
|
||||
subscription_url: "https://panel.example.com/sub/aBcDeFgHiJkLmNoP",
|
||||
referral: {
|
||||
code: "ABCD1234",
|
||||
bot_link: "https://t.me/preview_bot?start=ref_uABCD1234",
|
||||
webapp_link: "https://app.example.com/?ref=uABCD1234",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (path === "/admin/tariffs") {
|
||||
return {
|
||||
ok: true,
|
||||
path: "data/tariffs.json",
|
||||
catalog: {
|
||||
default_tariff: "standard",
|
||||
topup_packages_default: { rub: [{ gb: 10, price: 99 }], stars: [] },
|
||||
tariffs: [
|
||||
{
|
||||
key: "standard",
|
||||
names: { ru: "Стандарт", en: "Standard" },
|
||||
descriptions: { ru: "Базовый набор серверов" },
|
||||
squad_uuids: ["db786ee8-816b-4760-80aa-1fc7a3669ff2"],
|
||||
billing_model: "period",
|
||||
monthly_gb: 500,
|
||||
prices_rub: { 1: 150, 3: 400 },
|
||||
prices_stars: { 1: 0, 3: 0 },
|
||||
enabled_periods: [1, 3],
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
if (path === "/admin/themes") {
|
||||
if (String(options.method || "GET").toUpperCase() === "PUT") {
|
||||
try {
|
||||
const body = options?.body ? JSON.parse(String(options.body)) : {};
|
||||
const catalog = body.catalog || body;
|
||||
if (catalog?.themes) {
|
||||
DEV_MOCK.config.themesCatalog = clone(catalog);
|
||||
DEV_MOCK.data.themes_catalog = clone(catalog);
|
||||
}
|
||||
} catch (_e) {
|
||||
void _e;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
themes_dir: "data/themes",
|
||||
catalog: clone(DEV_MOCK.config.themesCatalog),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
themes_dir: "data/themes",
|
||||
catalog: clone(DEV_MOCK.config.themesCatalog),
|
||||
};
|
||||
}
|
||||
if (path === "/admin/appearance/logo") {
|
||||
return {
|
||||
ok: true,
|
||||
logo_url: "/webapp-uploaded-logo/logo-0000000000000000.png",
|
||||
favicon_url: "/webapp-favicon/0000000000000000/icon-180.png",
|
||||
};
|
||||
}
|
||||
if (path === "/admin/appearance/favicon") {
|
||||
return {
|
||||
ok: true,
|
||||
favicon_url: "/webapp-favicon/1111111111111111/icon-180.png",
|
||||
variants: {
|
||||
"32": "/webapp-favicon/1111111111111111/icon-32.png",
|
||||
apple_touch: "/webapp-favicon/1111111111111111/apple-touch-icon.png",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (path === "/admin/settings" && String(options.method || "GET").toUpperCase() === "PATCH") {
|
||||
try {
|
||||
const body = options?.body ? JSON.parse(String(options.body)) : {};
|
||||
const updates = body.updates || {};
|
||||
if (Object.prototype.hasOwnProperty.call(updates, "WEBAPP_LOGO_URL")) {
|
||||
DEV_MOCK.config.logoUrl = updates.WEBAPP_LOGO_URL || "";
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(updates, "WEBAPP_LOGO_USE_EMOJI")) {
|
||||
DEV_MOCK.config.logoUseEmoji = Boolean(updates.WEBAPP_LOGO_USE_EMOJI);
|
||||
}
|
||||
if (updates.WEBAPP_LOGO_EMOJI) DEV_MOCK.config.logoEmoji = updates.WEBAPP_LOGO_EMOJI;
|
||||
if (updates.WEBAPP_LOGO_EMOJI_FONT) {
|
||||
DEV_MOCK.config.logoEmojiFont = updates.WEBAPP_LOGO_EMOJI_FONT;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(updates, "WEBAPP_FAVICON_URL")) {
|
||||
DEV_MOCK.config.faviconUrl = updates.WEBAPP_FAVICON_URL || "";
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(updates, "WEBAPP_LOGO_FAVICON_URL")) {
|
||||
DEV_MOCK.config.faviconUrl = updates.WEBAPP_LOGO_FAVICON_URL || DEV_MOCK.config.faviconUrl || "";
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(updates, "WEBAPP_FAVICON_USE_CUSTOM")) {
|
||||
DEV_MOCK.config.faviconUseCustom = Boolean(updates.WEBAPP_FAVICON_USE_CUSTOM);
|
||||
}
|
||||
} catch (_e) {
|
||||
void _e;
|
||||
}
|
||||
return { ok: true, applied: 1, reverted: 0 };
|
||||
}
|
||||
if (path === "/admin/settings")
|
||||
return {
|
||||
ok: true,
|
||||
sections: [
|
||||
{
|
||||
id: "appearance",
|
||||
order: 2,
|
||||
fields: [
|
||||
{
|
||||
key: "WEBAPP_LOGO_USE_EMOJI",
|
||||
type: "bool",
|
||||
section: "appearance",
|
||||
label: "Emoji logo",
|
||||
value: Boolean(DEV_MOCK.config.logoUseEmoji),
|
||||
},
|
||||
{
|
||||
key: "WEBAPP_LOGO_URL",
|
||||
type: "url",
|
||||
section: "appearance",
|
||||
label: "URL логотипа",
|
||||
value: DEV_MOCK.config.logoUrl || "",
|
||||
},
|
||||
{
|
||||
key: "WEBAPP_LOGO_EMOJI",
|
||||
type: "string",
|
||||
section: "appearance",
|
||||
label: "Emoji",
|
||||
value: DEV_MOCK.config.logoEmoji || "🫥",
|
||||
},
|
||||
{
|
||||
key: "WEBAPP_LOGO_EMOJI_FONT",
|
||||
type: "string",
|
||||
section: "appearance",
|
||||
label: "Emoji font",
|
||||
value: DEV_MOCK.config.logoEmojiFont || "system",
|
||||
choices: [
|
||||
{ value: "system", label: "Системный" },
|
||||
{ value: "noto-color", label: "Noto Color Emoji" },
|
||||
{ value: "noto-color-animated", label: "Noto Color Emoji Animated" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "WEBAPP_FAVICON_USE_CUSTOM",
|
||||
type: "bool",
|
||||
section: "appearance",
|
||||
label: "Custom favicon",
|
||||
value: Boolean(DEV_MOCK.config.faviconUseCustom),
|
||||
},
|
||||
{
|
||||
key: "WEBAPP_FAVICON_URL",
|
||||
type: "url",
|
||||
section: "appearance",
|
||||
label: "Favicon URL",
|
||||
value: DEV_MOCK.config.faviconUrl || "",
|
||||
},
|
||||
{
|
||||
key: "WEBAPP_LOGO_FAVICON_URL",
|
||||
type: "url",
|
||||
section: "appearance",
|
||||
label: "Logo favicon URL",
|
||||
value: DEV_MOCK.config.faviconUrl || "",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
if (cleanPath.startsWith("/admin/"))
|
||||
return { ok: true, payments: [], promos: [], logs: [], campaigns: [], total: 0 };
|
||||
if (path === "/me") return clone(DEV_MOCK.data);
|
||||
if (path === "/auth/email/request") return { ok: true };
|
||||
if (path === "/auth/email/verify" || path === "/auth/email/magic") {
|
||||
return { ok: true, csrf_token: "local-preview-csrf" };
|
||||
}
|
||||
if (path === "/auth/token") {
|
||||
return { ok: true, csrf_token: "local-preview-csrf" };
|
||||
}
|
||||
if (path === "/promo/apply") return { ok: true, end_date_text: "31.05.2026" };
|
||||
if (path === "/devices") return clone(DEV_MOCK.data.devices);
|
||||
if (path === "/devices/topup-options")
|
||||
return clone(DEV_MOCK.data.device_topup_options || { ok: true, plans: [] });
|
||||
if (cleanPath === "/tariffs/topup-options") {
|
||||
const kind =
|
||||
new URLSearchParams(String(path || "").split("?")[1] || "").get("kind") || "regular";
|
||||
const payload = clone(DEV_MOCK.data.topup_options || { ok: true, plans: [] });
|
||||
payload.topup_kind = kind;
|
||||
payload.plans = (payload.plans || []).filter((plan) =>
|
||||
kind === "premium" ? plan.sale_mode === "premium_topup" : plan.sale_mode !== "premium_topup"
|
||||
);
|
||||
return payload;
|
||||
}
|
||||
if (path === "/tariffs/change-options")
|
||||
return clone(DEV_MOCK.data.tariff_change_options || { ok: true, targets: [] });
|
||||
if (path === "/devices/disconnect" && String(options.method || "").toUpperCase() === "POST") {
|
||||
let payload = {};
|
||||
try {
|
||||
payload = options?.body ? JSON.parse(String(options.body)) : {};
|
||||
} catch (_error) {
|
||||
void _error;
|
||||
}
|
||||
DEV_MOCK.data.devices.devices = DEV_MOCK.data.devices.devices.filter(
|
||||
(device) => device.token !== payload.token
|
||||
);
|
||||
DEV_MOCK.data.devices.current_devices = DEV_MOCK.data.devices.devices.length;
|
||||
return { ok: true };
|
||||
}
|
||||
if (path === "/trial/activate" && String(options.method || "").toUpperCase() === "POST") {
|
||||
DEV_MOCK.data.subscription = {
|
||||
...DEV_MOCK.data.subscription,
|
||||
active: true,
|
||||
status: "TRIAL",
|
||||
remaining_text: "5 д. 0 ч.",
|
||||
end_date_text: "05.05.2026 12:00",
|
||||
days_left: 5,
|
||||
traffic_limit: "10 GB",
|
||||
traffic_limit_bytes: 10737418240,
|
||||
traffic_used: "0 B",
|
||||
traffic_used_bytes: 0,
|
||||
};
|
||||
DEV_MOCK.data.settings.trial_available = false;
|
||||
return { ok: true, activated: true, end_date_text: "05.05.2026 12:00" };
|
||||
}
|
||||
if (path === "/auth/logout") return { ok: true };
|
||||
if (path === "/account/language" && String(options.method || "").toUpperCase() === "POST") {
|
||||
let payload = {};
|
||||
try {
|
||||
payload = options?.body ? JSON.parse(String(options.body)) : {};
|
||||
} catch (_error) {
|
||||
void _error;
|
||||
}
|
||||
const language = normalizeLangCode(payload?.language || currentLang);
|
||||
DEV_MOCK.data.user.language_code = language;
|
||||
return { ok: true, language };
|
||||
}
|
||||
if (path === "/account/email/request" && String(options.method || "").toUpperCase() === "POST") {
|
||||
return { ok: true };
|
||||
}
|
||||
if (path === "/account/email/verify" && String(options.method || "").toUpperCase() === "POST") {
|
||||
return { ok: true, csrf_token: "local-preview-csrf" };
|
||||
}
|
||||
if (path === "/account/telegram/link" && String(options.method || "").toUpperCase() === "POST") {
|
||||
return { ok: true, csrf_token: "local-preview-csrf" };
|
||||
}
|
||||
if (path === "/payments" && String(options.method || "").toUpperCase() === "POST") {
|
||||
return {
|
||||
ok: true,
|
||||
action: "open_link",
|
||||
payment_url: "https://example.com/payment-preview",
|
||||
payment_id: 10001,
|
||||
};
|
||||
}
|
||||
if (path === "/tariffs/change" && String(options.method || "").toUpperCase() === "POST") {
|
||||
return { ok: true, tariff_key: "business" };
|
||||
}
|
||||
if (path === "/tariffs/change-payment" && String(options.method || "").toUpperCase() === "POST") {
|
||||
return {
|
||||
ok: true,
|
||||
action: "open_link",
|
||||
payment_url: "https://example.com/tariff-change-payment-preview",
|
||||
payment_id: 10002,
|
||||
};
|
||||
}
|
||||
return { ok: false, error: "not_found" };
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export function ruPlural(value, one, few, many) {
|
||||
const n = Math.abs(Number(value || 0));
|
||||
const mod10 = n % 10;
|
||||
const mod100 = n % 100;
|
||||
if (mod10 === 1 && mod100 !== 11) return one;
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return few;
|
||||
return many;
|
||||
}
|
||||
|
||||
export function ruFractionAware(value, one, few, many) {
|
||||
const n = Number(value || 0);
|
||||
if (!Number.isInteger(n)) return few;
|
||||
return ruPlural(n, one, few, many);
|
||||
}
|
||||
|
||||
export function unitPluralBucket(value, lang) {
|
||||
if (String(lang || "").toLowerCase() === "ru") {
|
||||
const n = Number(value || 0);
|
||||
if (!Number.isInteger(n)) {
|
||||
const base = Math.floor(Math.abs(n));
|
||||
const mod10 = base % 10;
|
||||
const mod100 = base % 100;
|
||||
return mod10 >= 1 && mod10 <= 4 && (mod100 < 11 || mod100 > 14) ? "few" : "many";
|
||||
}
|
||||
const abs = Math.abs(n);
|
||||
const mod10 = abs % 10;
|
||||
const mod100 = abs % 100;
|
||||
if (mod10 === 1 && mod100 !== 11) return "one";
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return "few";
|
||||
return "many";
|
||||
}
|
||||
return Number(value) === 1 ? "one" : "many";
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
const WINDOWS_95_THEME = {
|
||||
key: "windows95",
|
||||
names: { ru: "Windows 95", en: "Windows 95" },
|
||||
enabled: true,
|
||||
default: false,
|
||||
css_file: "style.css",
|
||||
tokens: {
|
||||
color_scheme: "light",
|
||||
style_preset: "win95",
|
||||
},
|
||||
};
|
||||
|
||||
const ASCII_THEME = {
|
||||
key: "ascii",
|
||||
names: { ru: "ASCII", en: "ASCII" },
|
||||
enabled: true,
|
||||
default: false,
|
||||
css_file: "style.css",
|
||||
tokens: {
|
||||
color_scheme: "dark",
|
||||
style_preset: "ascii",
|
||||
},
|
||||
};
|
||||
|
||||
export const DEV_MOCK = {
|
||||
config: {
|
||||
title: "/minishop",
|
||||
primaryColor: "#00fe7a",
|
||||
logoUrl: "",
|
||||
logoUseEmoji: false,
|
||||
logoEmoji: "🫥",
|
||||
logoEmojiFont: "system",
|
||||
faviconUrl: "",
|
||||
faviconUseCustom: false,
|
||||
apiBase: "/api",
|
||||
supportUrl: "https://t.me/support",
|
||||
privacyPolicyUrl: "https://example.com/privacy",
|
||||
userAgreementUrl: "https://example.com/agreement",
|
||||
currency: "RUB",
|
||||
language: "ru",
|
||||
emailAuthEnabled: true,
|
||||
telegramLoginBotUsername: "preview_bot",
|
||||
telegramLoginBotId: 1234567890,
|
||||
telegramOAuthClientId: 1234567890,
|
||||
telegramOAuthRequestAccess: ["write"],
|
||||
appVersion: "dev+local",
|
||||
appRepositoryUrl: "https://github.com/3252a8/remnawave-minishop",
|
||||
themesCatalog: {
|
||||
default_theme: "dark",
|
||||
themes: [
|
||||
{
|
||||
key: "dark",
|
||||
names: { ru: "Тёмная", en: "Dark" },
|
||||
enabled: true,
|
||||
default: true,
|
||||
tokens: {
|
||||
color_scheme: "dark",
|
||||
accent: "#00fe7a",
|
||||
bg: "#03070b",
|
||||
panel: "#111820",
|
||||
text: "#f2f7f4",
|
||||
muted: "#a9b4b0",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "light",
|
||||
names: { ru: "Светлая", en: "Light" },
|
||||
enabled: true,
|
||||
default: false,
|
||||
css_file: "style.css",
|
||||
tokens: {
|
||||
color_scheme: "light",
|
||||
},
|
||||
},
|
||||
WINDOWS_95_THEME,
|
||||
ASCII_THEME,
|
||||
],
|
||||
},
|
||||
},
|
||||
data: {
|
||||
ok: true,
|
||||
user: {
|
||||
id: 100200300,
|
||||
username: "username",
|
||||
email: "user@example.com",
|
||||
email_verified: true,
|
||||
telegram_id: 100200300,
|
||||
telegram_linked: true,
|
||||
telegram_photo_url: "",
|
||||
first_name: "Preview",
|
||||
language_code: "ru",
|
||||
is_admin: true,
|
||||
},
|
||||
subscription: {
|
||||
active: true,
|
||||
status: "ACTIVE",
|
||||
remaining_text: "25 д. 8 ч.",
|
||||
end_date_text: "24.05.2026",
|
||||
days_left: 25,
|
||||
config_link: "https://sub.example.com/sub/preview-token",
|
||||
connect_url: "https://sub.example.com/connect/preview-token",
|
||||
traffic_used: "18.4 GB",
|
||||
traffic_limit: "100 GB",
|
||||
traffic_used_bytes: 19756849561,
|
||||
traffic_limit_bytes: 107374182400,
|
||||
premium_used: "32.0 GB",
|
||||
premium_limit: "50.0 GB",
|
||||
premium_used_bytes: 34359738368,
|
||||
premium_limit_bytes: 53687091200,
|
||||
premium_baseline_bytes: 53687091200,
|
||||
premium_topup_balance_bytes: 0,
|
||||
premium_is_limited: false,
|
||||
premium_title: "Premium-серверы",
|
||||
premium_node_labels: ["Premium NL-1", "Premium DE-1"],
|
||||
can_topup_regular_traffic: true,
|
||||
can_topup_premium_traffic: true,
|
||||
max_devices: 5,
|
||||
},
|
||||
devices: {
|
||||
ok: true,
|
||||
enabled: true,
|
||||
current_devices: 3,
|
||||
max_devices: 5,
|
||||
max_devices_label: "5",
|
||||
devices: [
|
||||
{
|
||||
index: 1,
|
||||
display_name: "iPhone 15 Pro",
|
||||
platform_label: "iOS 18.4",
|
||||
user_agent: "Streisand/1.6 CFNetwork",
|
||||
created_at_text: "28.04.2026 16:12",
|
||||
hwid_short: "A1B2C3D4...98FA01",
|
||||
token: "preview-device-1",
|
||||
can_disconnect: true,
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
display_name: "MacBook Air",
|
||||
platform_label: "macOS 15.4",
|
||||
user_agent: "Happ/3.1.0",
|
||||
created_at_text: "29.04.2026 09:40",
|
||||
hwid_short: "F0E1D2C3...44AB22",
|
||||
token: "preview-device-2",
|
||||
can_disconnect: true,
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
display_name: "Android Phone",
|
||||
platform_label: "Android 15",
|
||||
user_agent: "v2rayNG/1.9.35",
|
||||
created_at_text: "30.04.2026 07:55",
|
||||
hwid_short: "778899AA...BCDD10",
|
||||
token: "preview-device-3",
|
||||
can_disconnect: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
plans: [
|
||||
{ months: 1, price: 290, currency: "RUB", title: "1 месяц" },
|
||||
{ months: 3, price: 790, currency: "RUB", title: "3 месяца" },
|
||||
{ months: 6, price: 1490, currency: "RUB", title: "6 месяцев" },
|
||||
{ months: 12, price: 2690, currency: "RUB", title: "12 месяцев" },
|
||||
],
|
||||
payment_methods: [
|
||||
{ id: "yookassa", name: "Карта" },
|
||||
{ id: "platega_sbp", name: "Telegram Pay" },
|
||||
{ id: "cryptopay", name: "Криптовалюта" },
|
||||
{ id: "freekassa", name: "Другие способы" },
|
||||
],
|
||||
referral: {
|
||||
code: "ABCD1234",
|
||||
bot_link: "https://t.me/preview_bot?start=ref_uABCD1234",
|
||||
webapp_link: "https://minishop.app/ref/ABCD1234",
|
||||
invited_count: 4,
|
||||
purchased_count: 2,
|
||||
welcome_bonus_days: 3,
|
||||
one_bonus_per_referee: false,
|
||||
bonus_details: [
|
||||
{ months: 1, title: "1 месяц", inviter_days: 14, friend_days: 7 },
|
||||
{ months: 3, title: "3 месяца", inviter_days: 21, friend_days: 14 },
|
||||
{ months: 6, title: "6 месяцев", inviter_days: 31, friend_days: 21 },
|
||||
{ months: 12, title: "12 месяцев", inviter_days: 62, friend_days: 31 },
|
||||
],
|
||||
},
|
||||
themes_catalog: {
|
||||
default_theme: "dark",
|
||||
themes: [
|
||||
{
|
||||
key: "dark",
|
||||
names: { ru: "Тёмная", en: "Dark" },
|
||||
enabled: true,
|
||||
tokens: {
|
||||
color_scheme: "dark",
|
||||
accent: "#00fe7a",
|
||||
bg: "#03070b",
|
||||
panel: "#111820",
|
||||
text: "#f2f7f4",
|
||||
muted: "#a9b4b0",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "light",
|
||||
names: { ru: "Светлая", en: "Light" },
|
||||
enabled: true,
|
||||
css_file: "style.css",
|
||||
tokens: {
|
||||
color_scheme: "light",
|
||||
},
|
||||
},
|
||||
WINDOWS_95_THEME,
|
||||
ASCII_THEME,
|
||||
],
|
||||
},
|
||||
settings: {
|
||||
support_url: "https://t.me/support",
|
||||
traffic_mode: false,
|
||||
my_devices_enabled: false,
|
||||
user_hwid_device_limit: 5,
|
||||
trial_enabled: true,
|
||||
trial_available: true,
|
||||
trial_duration_days: 5,
|
||||
trial_traffic_limit_gb: 10,
|
||||
trial_traffic_strategy: "NO_RESET",
|
||||
email_auth_enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function applyPreviewMock(kind) {
|
||||
const mode = String(kind || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
const themeKeys = new Set((DEV_MOCK.config.themesCatalog.themes || []).map((theme) => theme.key));
|
||||
if (themeKeys.has(mode)) {
|
||||
DEV_MOCK.config.themesCatalog.default_theme = mode;
|
||||
DEV_MOCK.data.themes_catalog.default_theme = mode;
|
||||
for (const theme of DEV_MOCK.config.themesCatalog.themes || []) {
|
||||
theme.default = theme.key === mode;
|
||||
}
|
||||
for (const theme of DEV_MOCK.data.themes_catalog.themes || []) {
|
||||
theme.default = theme.key === mode;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "traffic") {
|
||||
DEV_MOCK.data.settings.traffic_mode = true;
|
||||
DEV_MOCK.data.settings.trial_available = false;
|
||||
DEV_MOCK.data.subscription = {
|
||||
...DEV_MOCK.data.subscription,
|
||||
active: true,
|
||||
status: "ACTIVE",
|
||||
remaining_text: "Навсегда",
|
||||
end_date_text: "01.01.2099 00:00",
|
||||
days_left: 26000,
|
||||
traffic_used: "18.4 GB",
|
||||
traffic_limit: "100 GB",
|
||||
traffic_used_bytes: 19756849561,
|
||||
traffic_limit_bytes: 107374182400,
|
||||
traffic_limit_strategy: "NO_RESET",
|
||||
};
|
||||
DEV_MOCK.data.plans = [
|
||||
{
|
||||
months: 10,
|
||||
traffic_gb: 10,
|
||||
price: 199,
|
||||
currency: "RUB",
|
||||
title: "10 GB",
|
||||
sale_mode: "traffic",
|
||||
},
|
||||
{
|
||||
months: 50,
|
||||
traffic_gb: 50,
|
||||
price: 799,
|
||||
currency: "RUB",
|
||||
title: "50 GB",
|
||||
sale_mode: "traffic",
|
||||
},
|
||||
{
|
||||
months: 100,
|
||||
traffic_gb: 100,
|
||||
price: 1390,
|
||||
currency: "RUB",
|
||||
title: "100 GB",
|
||||
sale_mode: "traffic",
|
||||
},
|
||||
{
|
||||
months: 300,
|
||||
traffic_gb: 300,
|
||||
price: 3490,
|
||||
currency: "RUB",
|
||||
title: "300 GB",
|
||||
sale_mode: "traffic",
|
||||
},
|
||||
];
|
||||
} else if (mode === "tariffs") {
|
||||
DEV_MOCK.data.settings.traffic_mode = false;
|
||||
DEV_MOCK.data.subscription = {
|
||||
...DEV_MOCK.data.subscription,
|
||||
tariff_key: "standard",
|
||||
tariff_name: "Стандарт",
|
||||
tariff_description: "100 GB каждый месяц",
|
||||
billing_model: "period",
|
||||
traffic_limit_strategy: "MONTH",
|
||||
};
|
||||
DEV_MOCK.data.plans = [
|
||||
{
|
||||
id: "standard:period:1",
|
||||
tariff_key: "standard",
|
||||
tariff_name: "Стандарт",
|
||||
billing_model: "period",
|
||||
sale_mode: "subscription",
|
||||
months: 1,
|
||||
price: 150,
|
||||
currency: "RUB",
|
||||
title: "Стандарт",
|
||||
subtitle: "1 месяц",
|
||||
description: "100 GB каждый месяц",
|
||||
monthly_gb: 100,
|
||||
},
|
||||
{
|
||||
id: "standard:period:3",
|
||||
tariff_key: "standard",
|
||||
tariff_name: "Стандарт",
|
||||
billing_model: "period",
|
||||
sale_mode: "subscription",
|
||||
months: 3,
|
||||
price: 400,
|
||||
currency: "RUB",
|
||||
title: "Стандарт",
|
||||
subtitle: "3 месяца",
|
||||
description: "100 GB каждый месяц",
|
||||
monthly_gb: 100,
|
||||
},
|
||||
{
|
||||
id: "business:period:1",
|
||||
tariff_key: "business",
|
||||
tariff_name: "Бизнес",
|
||||
billing_model: "period",
|
||||
sale_mode: "subscription",
|
||||
months: 1,
|
||||
price: 350,
|
||||
currency: "RUB",
|
||||
title: "Бизнес",
|
||||
subtitle: "1 месяц",
|
||||
description: "300 GB и приоритетные серверы",
|
||||
monthly_gb: 300,
|
||||
},
|
||||
{
|
||||
id: "traffic:traffic:50",
|
||||
tariff_key: "traffic",
|
||||
tariff_name: "Трафик",
|
||||
billing_model: "traffic",
|
||||
sale_mode: "traffic_package",
|
||||
months: 50,
|
||||
traffic_gb: 50,
|
||||
price: 799,
|
||||
currency: "RUB",
|
||||
title: "Трафик",
|
||||
subtitle: "50 GB",
|
||||
description: "Пакет без срока действия",
|
||||
},
|
||||
];
|
||||
DEV_MOCK.data.tariff_change_options = {
|
||||
ok: true,
|
||||
current: {
|
||||
tariff_key: "standard",
|
||||
title: "Стандарт",
|
||||
description: "100 GB каждый месяц",
|
||||
billing_model: "period",
|
||||
},
|
||||
targets: [
|
||||
{
|
||||
tariff_key: "business",
|
||||
title: "Бизнес",
|
||||
description: "300 GB и приоритетные серверы",
|
||||
billing_model: "period",
|
||||
monthly_gb: 300,
|
||||
actions: [
|
||||
{
|
||||
mode: "recalc_days",
|
||||
kind: "free",
|
||||
title: "recalc_days",
|
||||
days_after: 10,
|
||||
remaining_days: 25,
|
||||
},
|
||||
{ mode: "paid_diff", kind: "payment", title: "paid_diff", price: 190, currency: "RUB" },
|
||||
],
|
||||
},
|
||||
{
|
||||
tariff_key: "traffic",
|
||||
title: "Трафик",
|
||||
description: "Пакеты без срока действия",
|
||||
billing_model: "traffic",
|
||||
actions: [
|
||||
{
|
||||
mode: "convert_days_to_gb",
|
||||
kind: "free",
|
||||
title: "convert_days_to_gb",
|
||||
converted_gb: 18,
|
||||
remaining_days: 25,
|
||||
},
|
||||
{
|
||||
mode: "buy_package",
|
||||
kind: "payment",
|
||||
title: "+50 GB",
|
||||
traffic_gb: 50,
|
||||
price: 799,
|
||||
currency: "RUB",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
DEV_MOCK.data.topup_options = {
|
||||
ok: true,
|
||||
tariff_key: "standard",
|
||||
tariff_name: "Стандарт",
|
||||
traffic_percent: 86,
|
||||
warning_levels: [85, 90, 95],
|
||||
plans: [
|
||||
{
|
||||
id: "standard:topup:10",
|
||||
tariff_key: "standard",
|
||||
tariff_name: "Стандарт",
|
||||
sale_mode: "topup",
|
||||
traffic_gb: 10,
|
||||
months: 10,
|
||||
price: 99,
|
||||
currency: "RUB",
|
||||
title: "10 GB",
|
||||
subtitle: "Стандарт",
|
||||
},
|
||||
{
|
||||
id: "standard:topup:50",
|
||||
tariff_key: "standard",
|
||||
tariff_name: "Стандарт",
|
||||
sale_mode: "topup",
|
||||
traffic_gb: 50,
|
||||
months: 50,
|
||||
price: 399,
|
||||
currency: "RUB",
|
||||
title: "50 GB",
|
||||
subtitle: "Стандарт",
|
||||
},
|
||||
{
|
||||
id: "standard:topup:200",
|
||||
tariff_key: "standard",
|
||||
tariff_name: "Стандарт",
|
||||
sale_mode: "topup",
|
||||
traffic_gb: 200,
|
||||
months: 200,
|
||||
price: 1299,
|
||||
currency: "RUB",
|
||||
title: "200 GB",
|
||||
subtitle: "Стандарт",
|
||||
},
|
||||
],
|
||||
};
|
||||
DEV_MOCK.data.device_topup_options = {
|
||||
ok: true,
|
||||
tariff_key: "standard",
|
||||
tariff_name: "Стандарт",
|
||||
current_limit: 5,
|
||||
plans: [
|
||||
{
|
||||
id: "standard:hwid:1",
|
||||
tariff_key: "standard",
|
||||
tariff_name: "Стандарт",
|
||||
sale_mode: "hwid_devices",
|
||||
device_count: 1,
|
||||
months: 1,
|
||||
price: 99,
|
||||
currency: "RUB",
|
||||
title: "+1",
|
||||
subtitle: "Стандарт",
|
||||
},
|
||||
{
|
||||
id: "standard:hwid:3",
|
||||
tariff_key: "standard",
|
||||
tariff_name: "Стандарт",
|
||||
sale_mode: "hwid_devices",
|
||||
device_count: 3,
|
||||
months: 3,
|
||||
price: 249,
|
||||
currency: "RUB",
|
||||
title: "+3",
|
||||
subtitle: "Стандарт",
|
||||
},
|
||||
],
|
||||
};
|
||||
} else if (mode === "devices") {
|
||||
DEV_MOCK.data.settings.my_devices_enabled = true;
|
||||
DEV_MOCK.data.subscription = {
|
||||
...DEV_MOCK.data.subscription,
|
||||
active: true,
|
||||
max_devices: 5,
|
||||
};
|
||||
} else if (mode === "trial") {
|
||||
DEV_MOCK.data.settings.traffic_mode = false;
|
||||
DEV_MOCK.data.settings.trial_enabled = true;
|
||||
DEV_MOCK.data.settings.trial_available = true;
|
||||
DEV_MOCK.data.settings.trial_duration_days = 5;
|
||||
DEV_MOCK.data.settings.trial_traffic_limit_gb = 10;
|
||||
DEV_MOCK.data.subscription = {
|
||||
active: false,
|
||||
status: "INACTIVE",
|
||||
remaining_text: "Подписка не активна",
|
||||
end_date_text: "",
|
||||
days_left: 0,
|
||||
config_link: null,
|
||||
connect_url: null,
|
||||
traffic_used: "0 B",
|
||||
traffic_limit: "10 GB",
|
||||
traffic_used_bytes: 0,
|
||||
traffic_limit_bytes: 10737418240,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { readCookie } from "./session.js";
|
||||
|
||||
export function createApiClient({
|
||||
apiBase = "",
|
||||
csrfCookieName = "rw_webapp_csrf",
|
||||
getCsrfToken = () => "",
|
||||
onUnauthorized = () => {},
|
||||
mockApi = null,
|
||||
getMockContext = () => ({}),
|
||||
} = {}) {
|
||||
const isFormDataBody = (body) => typeof FormData !== "undefined" && body instanceof FormData;
|
||||
|
||||
async function api(path, options = {}) {
|
||||
if (mockApi) return mockApi(path, options, getMockContext());
|
||||
|
||||
const method = String(options.method || "GET").toUpperCase();
|
||||
const headers = { ...(options.headers || {}) };
|
||||
|
||||
const csrf = getCsrfToken() || readCookie(csrfCookieName) || "";
|
||||
if (csrf && ["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
|
||||
headers["X-CSRF-Token"] = csrf;
|
||||
}
|
||||
if (options.body && !headers["Content-Type"] && !isFormDataBody(options.body)) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBase}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: "same-origin",
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (response.status === 401) onUnauthorized();
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function publicApi(path, payload = {}, options = {}) {
|
||||
if (mockApi) {
|
||||
return mockApi(path, { method: "POST", body: JSON.stringify(payload) }, getMockContext());
|
||||
}
|
||||
const response = await fetch(`${apiBase}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
signal: options.signal,
|
||||
credentials: "same-origin",
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
return { api, publicApi };
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ADMIN_SECTIONS, APP_SECTION_PATHS } from "./constants.js";
|
||||
|
||||
export function normalizeSection(value) {
|
||||
const section = String(value || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (
|
||||
section === "invite" ||
|
||||
section === "devices" ||
|
||||
section === "settings" ||
|
||||
section === "admin"
|
||||
) {
|
||||
return section;
|
||||
}
|
||||
return "home";
|
||||
}
|
||||
|
||||
export function sectionFromPath(pathname) {
|
||||
const normalizedPath = String(pathname || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\/+$/, "");
|
||||
if (!normalizedPath || normalizedPath === "/") return "home";
|
||||
if (normalizedPath === "/admin" || normalizedPath.startsWith("/admin/")) return "admin";
|
||||
const section = normalizedPath.startsWith("/") ? normalizedPath.slice(1) : normalizedPath;
|
||||
return normalizeSection(section);
|
||||
}
|
||||
|
||||
export function adminSectionFromPath(pathname) {
|
||||
const normalized = String(pathname || "")
|
||||
.toLowerCase()
|
||||
.replace(/\/+$/, "");
|
||||
const m = normalized.match(/^\/admin\/([a-z0-9_-]+)(?:\/[^/]+)?$/);
|
||||
if (m && ADMIN_SECTIONS.has(m[1])) return m[1];
|
||||
return "stats";
|
||||
}
|
||||
|
||||
export function adminUserIdFromPath(pathname) {
|
||||
const normalized = String(pathname || "")
|
||||
.toLowerCase()
|
||||
.replace(/\/+$/, "");
|
||||
const m = normalized.match(/^\/admin\/users\/(-?\d+)$/);
|
||||
return m ? Number(m[1]) : null;
|
||||
}
|
||||
|
||||
export function syncSectionPath(section, replace = false, adminSection = null, adminUserId = null) {
|
||||
if (window.location.protocol === "file:") return;
|
||||
const normalized = normalizeSection(section);
|
||||
let targetPath = APP_SECTION_PATHS[normalized] || APP_SECTION_PATHS.home;
|
||||
if (normalized === "admin") {
|
||||
const adm = adminSection || adminSectionFromPath(window.location.pathname) || "stats";
|
||||
const uid =
|
||||
adminUserId ?? (adm === "users" ? adminUserIdFromPath(window.location.pathname) : null);
|
||||
targetPath = adm === "users" && uid ? `/admin/users/${uid}` : `/admin/${adm}`;
|
||||
}
|
||||
if (window.location.pathname === targetPath) return;
|
||||
const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`;
|
||||
window.history[replace ? "replaceState" : "pushState"](null, "", nextUrl);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
export const TOKEN_STORAGE_KEY = "rw_webapp_token";
|
||||
export const CSRF_COOKIE_NAME = "rw_webapp_csrf";
|
||||
export const REFERRAL_STORAGE_KEY = "rw_webapp_referral";
|
||||
|
||||
function ignoreStorageError(error) {
|
||||
void error;
|
||||
}
|
||||
|
||||
export function readCookie(name) {
|
||||
if (typeof document === "undefined") return "";
|
||||
const prefix = `${name}=`;
|
||||
const cookie = document.cookie.split("; ").find((part) => part.startsWith(prefix));
|
||||
return cookie ? decodeURIComponent(cookie.slice(prefix.length)) : "";
|
||||
}
|
||||
|
||||
export function clearStoredToken(storageKey = TOKEN_STORAGE_KEY) {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
localStorage.removeItem(storageKey);
|
||||
}
|
||||
|
||||
export function markManualLogout(flagKey) {
|
||||
try {
|
||||
localStorage.setItem(flagKey, "1");
|
||||
} catch (error) {
|
||||
ignoreStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearManualLogoutFlag(flagKey) {
|
||||
try {
|
||||
localStorage.removeItem(flagKey);
|
||||
} catch (error) {
|
||||
ignoreStorageError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export function isManuallyLoggedOut(flagKey) {
|
||||
try {
|
||||
return localStorage.getItem(flagKey) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberReferral(value) {
|
||||
const normalized = String(value || "").trim();
|
||||
if (!normalized) return readReferral();
|
||||
try {
|
||||
localStorage.setItem(REFERRAL_STORAGE_KEY, normalized);
|
||||
} catch (error) {
|
||||
ignoreStorageError(error);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function readReferral() {
|
||||
try {
|
||||
return localStorage.getItem(REFERRAL_STORAGE_KEY) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { writable, get } from "svelte/store";
|
||||
import { emailError, buildTelegramOAuthStartUrl } from "../authHelpers.js";
|
||||
|
||||
export function createAccountStore({
|
||||
api,
|
||||
publicApi,
|
||||
setToken,
|
||||
loadData,
|
||||
t,
|
||||
showToast,
|
||||
clearToken,
|
||||
markManualLogout,
|
||||
showLogin,
|
||||
telegramSdk,
|
||||
getTg,
|
||||
telegramOAuthClientId,
|
||||
currentLang,
|
||||
normalizeLangCode,
|
||||
updateLocalData,
|
||||
}) {
|
||||
const state = writable({
|
||||
linkEmailOpen: false,
|
||||
linkEmailBusy: false,
|
||||
linkTelegramBusy: false,
|
||||
linkEmailValue: "",
|
||||
linkEmailPending: "",
|
||||
linkEmailCode: "",
|
||||
linkEmailStatus: "",
|
||||
linkEmailIsError: false,
|
||||
linkEmailFieldError: "",
|
||||
linkEmailResendCooldown: 0,
|
||||
languageBusy: false,
|
||||
});
|
||||
|
||||
let linkEmailResendTimer = null;
|
||||
|
||||
function setLinkEmailStatus(message, isError = false) {
|
||||
state.update((s) => ({ ...s, linkEmailStatus: message, linkEmailIsError: isError }));
|
||||
}
|
||||
|
||||
function clearCooldownTimer() {
|
||||
if (linkEmailResendTimer) {
|
||||
window.clearInterval(linkEmailResendTimer);
|
||||
linkEmailResendTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startCooldownTimer(seconds = 60) {
|
||||
clearCooldownTimer();
|
||||
state.update((s) => ({ ...s, linkEmailResendCooldown: Math.max(0, Number(seconds || 60)) }));
|
||||
linkEmailResendTimer = window.setInterval(() => {
|
||||
const s = get(state);
|
||||
if (s.linkEmailResendCooldown <= 1) {
|
||||
state.update((s) => ({ ...s, linkEmailResendCooldown: 0 }));
|
||||
clearCooldownTimer();
|
||||
return;
|
||||
}
|
||||
state.update((s) => ({ ...s, linkEmailResendCooldown: s.linkEmailResendCooldown - 1 }));
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function openLinkEmailDialog(email) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
linkEmailOpen: true,
|
||||
linkEmailBusy: false,
|
||||
linkEmailCode: "",
|
||||
linkEmailPending: "",
|
||||
linkEmailStatus: "",
|
||||
linkEmailIsError: false,
|
||||
linkEmailFieldError: "",
|
||||
linkEmailValue: email || "",
|
||||
linkEmailResendCooldown: 0,
|
||||
}));
|
||||
clearCooldownTimer();
|
||||
}
|
||||
|
||||
function closeLinkEmailDialog() {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
linkEmailOpen: false,
|
||||
linkEmailBusy: false,
|
||||
linkEmailCode: "",
|
||||
linkEmailPending: "",
|
||||
linkEmailStatus: "",
|
||||
linkEmailIsError: false,
|
||||
linkEmailFieldError: "",
|
||||
linkEmailResendCooldown: 0,
|
||||
}));
|
||||
clearCooldownTimer();
|
||||
}
|
||||
|
||||
async function requestLinkEmailCode() {
|
||||
const s = get(state);
|
||||
if (s.linkEmailPending && s.linkEmailResendCooldown > 0) return;
|
||||
const normalized = String(s.linkEmailValue || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!normalized || !normalized.includes("@")) {
|
||||
state.update((s) => ({ ...s, linkEmailFieldError: t("wa_auth_invalid_email") }));
|
||||
return;
|
||||
}
|
||||
state.update((s) => ({ ...s, linkEmailFieldError: "", linkEmailBusy: true }));
|
||||
setLinkEmailStatus(t("wa_auth_sending_code"));
|
||||
try {
|
||||
const response = await api("/account/email/request", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: normalized }),
|
||||
});
|
||||
if (!response?.ok) throw response;
|
||||
state.update((s) => ({ ...s, linkEmailPending: normalized, linkEmailCode: "" }));
|
||||
setLinkEmailStatus("");
|
||||
startCooldownTimer(60);
|
||||
} catch (error) {
|
||||
setLinkEmailStatus(emailError(error, t("wa_auth_send_code_failed"), t), true);
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, linkEmailBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyLinkEmailCode() {
|
||||
const s = get(state);
|
||||
const code = String(s.linkEmailCode || "")
|
||||
.replace(/\\D/g, "")
|
||||
.slice(0, 6);
|
||||
if (!s.linkEmailPending) {
|
||||
setLinkEmailStatus(t("wa_auth_send_code_failed"), true);
|
||||
return;
|
||||
}
|
||||
if (code.length !== 6) {
|
||||
setLinkEmailStatus(t("wa_auth_enter_code_6digits"), true);
|
||||
return;
|
||||
}
|
||||
state.update((s) => ({ ...s, linkEmailBusy: true }));
|
||||
setLinkEmailStatus(t("wa_auth_checking_code"));
|
||||
try {
|
||||
const response = await api("/account/email/verify", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: s.linkEmailPending, code }),
|
||||
});
|
||||
if (!response?.ok) throw response;
|
||||
if (response?.csrf_token) setToken("", response.csrf_token);
|
||||
await loadData();
|
||||
closeLinkEmailDialog();
|
||||
showToast(t("wa_settings_linked"));
|
||||
} catch (error) {
|
||||
setLinkEmailStatus(emailError(error, t("wa_auth_invalid_code"), t), true);
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, linkEmailBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function linkTelegramAccountWithPayload(payload) {
|
||||
state.update((s) => ({ ...s, linkTelegramBusy: true }));
|
||||
try {
|
||||
const response = await api("/account/telegram/link", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response?.ok) throw response;
|
||||
if (response?.csrf_token) setToken("", response.csrf_token);
|
||||
await loadData();
|
||||
showToast(t("wa_settings_linked"));
|
||||
} catch (error) {
|
||||
showToast(error?.message || t("wa_auth_telegram_not_confirmed"));
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, linkTelegramBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function linkTelegramAccount(getTelegramMiniAppInitData) {
|
||||
const s = get(state);
|
||||
if (s.linkTelegramBusy) return;
|
||||
const isTelegramMiniAppAttempt = telegramSdk.hasLaunchParams();
|
||||
if (isTelegramMiniAppAttempt) {
|
||||
await telegramSdk.ensureForAction();
|
||||
}
|
||||
const initData = getTelegramMiniAppInitData();
|
||||
if (initData) {
|
||||
await linkTelegramAccountWithPayload({ init_data: initData });
|
||||
return;
|
||||
}
|
||||
if (!telegramOAuthClientId) {
|
||||
showToast(t("wa_auth_telegram_not_configured"));
|
||||
return;
|
||||
}
|
||||
state.update((s) => ({ ...s, linkTelegramBusy: true }));
|
||||
window.location.assign(buildTelegramOAuthStartUrl("link", getTg()));
|
||||
}
|
||||
|
||||
async function updateAccountLanguage(nextValue) {
|
||||
const s = get(state);
|
||||
const normalize = typeof normalizeLangCode === "function" ? normalizeLangCode : (v) => v;
|
||||
const language = normalize(nextValue);
|
||||
if (!language || s.languageBusy || language === currentLang()) return;
|
||||
state.update((s) => ({ ...s, languageBusy: true }));
|
||||
try {
|
||||
const response = await api("/account/language", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ language }),
|
||||
});
|
||||
if (!response?.ok) throw response;
|
||||
if (typeof updateLocalData === "function") {
|
||||
updateLocalData(normalize(response.language || language));
|
||||
}
|
||||
await loadData();
|
||||
} catch {
|
||||
showToast(t("wa_settings_language_update_failed"));
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, languageBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
markManualLogout();
|
||||
clearToken();
|
||||
try {
|
||||
await publicApi("/auth/logout", { keepalive: true });
|
||||
} catch (_error) {
|
||||
void _error;
|
||||
}
|
||||
showLogin();
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: state.subscribe,
|
||||
set: state.set,
|
||||
update: state.update,
|
||||
openLinkEmailDialog,
|
||||
closeLinkEmailDialog,
|
||||
requestLinkEmailCode,
|
||||
verifyLinkEmailCode,
|
||||
linkTelegramAccount,
|
||||
updateAccountLanguage,
|
||||
logout,
|
||||
clearLinkEmailResendTimer: clearCooldownTimer,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { writable, get } from "svelte/store";
|
||||
import {
|
||||
readReferralParam,
|
||||
clearAuthQuery,
|
||||
buildTelegramOAuthStartUrl,
|
||||
emailError,
|
||||
} from "../authHelpers.js";
|
||||
|
||||
export function createAuthStore({
|
||||
publicApi,
|
||||
setToken,
|
||||
loadData,
|
||||
telegramSdk,
|
||||
getTg,
|
||||
t,
|
||||
currentLang,
|
||||
}) {
|
||||
const state = writable({
|
||||
authStatus: "",
|
||||
authIsError: false,
|
||||
authBusy: false,
|
||||
telegramLoginBusy: false,
|
||||
telegramLoginAttemptId: 0,
|
||||
loginEmailFieldError: "",
|
||||
loginEmailTooltipOpen: false,
|
||||
authResendCooldown: 0,
|
||||
email: "",
|
||||
pendingEmail: "",
|
||||
emailCode: "",
|
||||
});
|
||||
|
||||
let authResendTimer = null;
|
||||
let telegramLoginWatchdogTimer = null;
|
||||
|
||||
function setAuthStatus(message, isError = false) {
|
||||
state.update((s) => ({ ...s, authStatus: message, authIsError: isError }));
|
||||
}
|
||||
|
||||
function clearCooldownTimer() {
|
||||
if (authResendTimer) {
|
||||
window.clearInterval(authResendTimer);
|
||||
authResendTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startCooldownTimer(seconds = 60) {
|
||||
clearCooldownTimer();
|
||||
state.update((s) => ({ ...s, authResendCooldown: Math.max(0, Number(seconds || 60)) }));
|
||||
authResendTimer = window.setInterval(() => {
|
||||
const { authResendCooldown } = get(state);
|
||||
if (authResendCooldown <= 1) {
|
||||
state.update((s) => ({ ...s, authResendCooldown: 0 }));
|
||||
clearCooldownTimer();
|
||||
return;
|
||||
}
|
||||
state.update((s) => ({ ...s, authResendCooldown: authResendCooldown - 1 }));
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function startTelegramLoginWatchdog() {
|
||||
const TELEGRAM_MINI_APP_AUTH_TIMEOUT_MS = 6000;
|
||||
stopTelegramLoginWatchdog();
|
||||
state.update((s) => ({ ...s, telegramLoginAttemptId: s.telegramLoginAttemptId + 1 }));
|
||||
const { telegramLoginAttemptId } = get(state);
|
||||
|
||||
telegramLoginWatchdogTimer = window.setTimeout(() => {
|
||||
if (get(state).telegramLoginAttemptId !== telegramLoginAttemptId) return;
|
||||
telegramLoginWatchdogTimer = null;
|
||||
state.update((s) => ({ ...s, telegramLoginBusy: false, authBusy: false }));
|
||||
setAuthStatus(t("wa_auth_telegram_timeout"), true);
|
||||
}, TELEGRAM_MINI_APP_AUTH_TIMEOUT_MS);
|
||||
|
||||
return telegramLoginAttemptId;
|
||||
}
|
||||
|
||||
function stopTelegramLoginWatchdog(attemptId = null) {
|
||||
if (attemptId !== null && attemptId !== get(state).telegramLoginAttemptId) return;
|
||||
if (telegramLoginWatchdogTimer) {
|
||||
window.clearTimeout(telegramLoginWatchdogTimer);
|
||||
telegramLoginWatchdogTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function isActiveTelegramLoginAttempt(attemptId) {
|
||||
const s = get(state);
|
||||
return attemptId === s.telegramLoginAttemptId && s.telegramLoginBusy;
|
||||
}
|
||||
|
||||
async function finalizeMagicLogin(loginToken) {
|
||||
const s = get(state);
|
||||
if (s.authBusy) return false;
|
||||
state.update((s) => ({ ...s, authBusy: true }));
|
||||
setAuthStatus(t("wa_auth_checking_login"));
|
||||
try {
|
||||
const payload = { token: loginToken };
|
||||
const referralParam = readReferralParam(getTg());
|
||||
if (referralParam) payload.referral_code = referralParam;
|
||||
const response = await publicApi("/auth/email/magic", payload);
|
||||
if (response.ok && response.csrf_token) {
|
||||
setToken("", response.csrf_token);
|
||||
clearAuthQuery();
|
||||
await loadData();
|
||||
return true;
|
||||
}
|
||||
setAuthStatus(t("wa_auth_login_confirm_failed"), true);
|
||||
} catch {
|
||||
setAuthStatus(t("wa_auth_login_confirm_failed"), true);
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, authBusy: false }));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function finalizeTelegramAuth(authData, source = "auth_data", options = {}) {
|
||||
const s = get(state);
|
||||
if (s.authBusy) return false;
|
||||
state.update((s) => ({ ...s, authBusy: true }));
|
||||
setAuthStatus(t("wa_auth_checking_telegram"));
|
||||
try {
|
||||
const payload =
|
||||
source === "init_data"
|
||||
? { init_data: authData }
|
||||
: source === "id_token"
|
||||
? { id_token: authData.id_token, nonce: authData.nonce }
|
||||
: { auth_data: authData };
|
||||
const referralParam = readReferralParam(getTg());
|
||||
if (referralParam) payload.referral_code = referralParam;
|
||||
const response = await publicApi("/auth/token", payload, { signal: options.signal });
|
||||
if (response.ok && response.csrf_token) {
|
||||
setToken("", response.csrf_token);
|
||||
clearAuthQuery();
|
||||
setAuthStatus("");
|
||||
await loadData();
|
||||
return true;
|
||||
}
|
||||
setAuthStatus(
|
||||
response.error === "banned"
|
||||
? t("wa_auth_access_denied")
|
||||
: t("wa_auth_telegram_not_confirmed"),
|
||||
true
|
||||
);
|
||||
} catch (error) {
|
||||
setAuthStatus(
|
||||
error?.name === "AbortError"
|
||||
? t("wa_auth_telegram_timeout")
|
||||
: t("wa_auth_telegram_unavailable"),
|
||||
true
|
||||
);
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, authBusy: false }));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function requestEmailCode(changeScreen) {
|
||||
const s = get(state);
|
||||
if (s.authResendCooldown > 0 && s.pendingEmail) return;
|
||||
const normalized = s.email.trim().toLowerCase();
|
||||
if (!normalized || !normalized.includes("@")) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
loginEmailFieldError: t("wa_auth_invalid_email"),
|
||||
loginEmailTooltipOpen: true,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
loginEmailFieldError: "",
|
||||
loginEmailTooltipOpen: false,
|
||||
authBusy: true,
|
||||
}));
|
||||
setAuthStatus(t("wa_auth_sending_code"));
|
||||
try {
|
||||
const payload = { email: normalized, language: currentLang() };
|
||||
const referralParam = readReferralParam(getTg());
|
||||
if (referralParam) payload.referral_code = referralParam;
|
||||
const response = await publicApi("/auth/email/request", payload);
|
||||
if (!response.ok) throw response;
|
||||
state.update((s) => ({ ...s, pendingEmail: normalized, emailCode: "" }));
|
||||
changeScreen("code");
|
||||
setAuthStatus("");
|
||||
startCooldownTimer(60);
|
||||
} catch (error) {
|
||||
setAuthStatus(emailError(error, t("wa_auth_send_code_failed"), t), true);
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, authBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyEmailCode() {
|
||||
const s = get(state);
|
||||
const code = s.emailCode.replace(/\\D/g, "").slice(0, 6);
|
||||
if (code.length !== 6) {
|
||||
setAuthStatus(t("wa_auth_enter_code_6digits"), true);
|
||||
return;
|
||||
}
|
||||
state.update((s) => ({ ...s, authBusy: true }));
|
||||
setAuthStatus(t("wa_auth_checking_code"));
|
||||
try {
|
||||
const payload = { email: s.pendingEmail, code };
|
||||
const referralParam = readReferralParam(getTg());
|
||||
if (referralParam) payload.referral_code = referralParam;
|
||||
const response = await publicApi("/auth/email/verify", payload);
|
||||
if (!response.ok || !response.csrf_token) throw response;
|
||||
setToken("", response.csrf_token);
|
||||
await loadData();
|
||||
setAuthStatus("");
|
||||
} catch (error) {
|
||||
setAuthStatus(emailError(error, t("wa_auth_invalid_code"), t), true);
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, authBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function openTelegramLogin(telegramOAuthClientId, getTelegramMiniAppInitData) {
|
||||
const s = get(state);
|
||||
if (s.authBusy || s.telegramLoginBusy) return;
|
||||
setAuthStatus("");
|
||||
|
||||
const isTelegramMiniAppAttempt = telegramSdk.hasLaunchParams();
|
||||
if (!isTelegramMiniAppAttempt && telegramOAuthClientId) {
|
||||
state.update((s) => ({ ...s, telegramLoginBusy: true }));
|
||||
window.location.assign(buildTelegramOAuthStartUrl("login", getTg()));
|
||||
window.setTimeout(() => {
|
||||
state.update((s) => ({ ...s, telegramLoginBusy: false }));
|
||||
}, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
state.update((s) => ({ ...s, telegramLoginBusy: true }));
|
||||
const attemptId = startTelegramLoginWatchdog();
|
||||
const loginTimeout = telegramSdk.createMiniAppAuthTimeout();
|
||||
try {
|
||||
await Promise.race([
|
||||
(async () => {
|
||||
await telegramSdk.ensureForAction();
|
||||
if (!isActiveTelegramLoginAttempt(attemptId)) return;
|
||||
const initData = getTelegramMiniAppInitData();
|
||||
if (initData) {
|
||||
await finalizeTelegramAuth(initData, "init_data", { signal: loginTimeout.signal });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!telegramOAuthClientId) {
|
||||
setAuthStatus(t("wa_auth_telegram_not_configured"), true);
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.assign(buildTelegramOAuthStartUrl("login", getTg()));
|
||||
})(),
|
||||
loginTimeout.promise,
|
||||
]);
|
||||
} catch (error) {
|
||||
if (!isActiveTelegramLoginAttempt(attemptId)) return;
|
||||
if (error?.name === "AbortError") {
|
||||
setAuthStatus(t("wa_auth_telegram_timeout"), true);
|
||||
} else {
|
||||
setAuthStatus(t("wa_auth_telegram_unavailable"), true);
|
||||
}
|
||||
} finally {
|
||||
loginTimeout.clear();
|
||||
if (loginTimeout.timedOut) {
|
||||
setAuthStatus(t("wa_auth_telegram_timeout"), true);
|
||||
state.update((s) => ({ ...s, authBusy: false }));
|
||||
}
|
||||
if (isActiveTelegramLoginAttempt(attemptId)) {
|
||||
stopTelegramLoginWatchdog(attemptId);
|
||||
state.update((s) => ({ ...s, telegramLoginBusy: false }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: state.subscribe,
|
||||
set: state.set,
|
||||
update: state.update,
|
||||
finalizeMagicLogin,
|
||||
finalizeTelegramAuth,
|
||||
requestEmailCode,
|
||||
verifyEmailCode,
|
||||
openTelegramLogin,
|
||||
clearCooldownTimer,
|
||||
stopTelegramLoginWatchdog,
|
||||
setAuthStatus,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
import { writable, get } from "svelte/store";
|
||||
|
||||
export function createBillingStore({ billing, loadData, t, showToast, openExternalLink, tg }) {
|
||||
const state = writable({
|
||||
paymentModalOpen: false,
|
||||
paymentStep: "tariff",
|
||||
selectedTariffKey: "",
|
||||
selectedPlan: null,
|
||||
selectedMethod: "",
|
||||
topupModalOpen: false,
|
||||
topupKind: "regular",
|
||||
deviceTopupModalOpen: false,
|
||||
changeModalOpen: false,
|
||||
topupOptions: null,
|
||||
deviceTopupOptions: null,
|
||||
changeOptions: null,
|
||||
selectedTopupPlan: null,
|
||||
selectedDeviceTopupPlan: null,
|
||||
selectedChangeTarget: null,
|
||||
selectedChangeAction: null,
|
||||
changeConfirmOpen: false,
|
||||
tariffActionBusy: false,
|
||||
payBusy: false,
|
||||
});
|
||||
|
||||
let topupOptionsRequestId = 0;
|
||||
|
||||
function openPaymentModal(
|
||||
tariffMode,
|
||||
singleTariffMode,
|
||||
tariffCatalog,
|
||||
subscription,
|
||||
plans,
|
||||
defaultMethod = ""
|
||||
) {
|
||||
state.update((s) => {
|
||||
let step;
|
||||
let plan = s.selectedPlan;
|
||||
let tariffKey = s.selectedTariffKey;
|
||||
|
||||
if (tariffMode) {
|
||||
if (singleTariffMode && tariffCatalog[0]?.key) {
|
||||
tariffKey = tariffCatalog[0].key;
|
||||
plan = plans.find((p) => p?.tariff_key === tariffKey) || null;
|
||||
step = "checkout";
|
||||
} else if (
|
||||
subscription?.active &&
|
||||
subscription?.tariff_key &&
|
||||
tariffCatalog.some((t) => t.key === subscription.tariff_key)
|
||||
) {
|
||||
tariffKey = subscription.tariff_key;
|
||||
plan = plans.find((p) => p?.tariff_key === tariffKey) || null;
|
||||
step = "checkout";
|
||||
} else {
|
||||
step = "tariff";
|
||||
tariffKey = "";
|
||||
plan = null;
|
||||
}
|
||||
} else {
|
||||
step = "checkout";
|
||||
}
|
||||
return {
|
||||
...s,
|
||||
paymentModalOpen: true,
|
||||
paymentStep: step,
|
||||
selectedTariffKey: tariffKey,
|
||||
selectedPlan: plan,
|
||||
selectedMethod: s.selectedMethod || defaultMethod,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function closePaymentModal() {
|
||||
state.update((s) => ({ ...s, paymentModalOpen: false }));
|
||||
}
|
||||
|
||||
function selectTariff(tariff, plans = []) {
|
||||
const key = String(tariff?.key || "").trim();
|
||||
if (!key) return;
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
selectedTariffKey: key,
|
||||
selectedPlan: plans.find((plan) => plan?.tariff_key === key) || null,
|
||||
}));
|
||||
}
|
||||
|
||||
function continueWithSelectedTariff(selectedTariffPlans = []) {
|
||||
state.update((s) => {
|
||||
if (!s.selectedTariffKey) return s;
|
||||
return {
|
||||
...s,
|
||||
selectedPlan: s.selectedPlan || selectedTariffPlans[0] || null,
|
||||
paymentStep: "checkout",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function backToTariffList(subscription, tariffCatalog = []) {
|
||||
if (
|
||||
subscription?.active &&
|
||||
subscription?.tariff_key &&
|
||||
tariffCatalog.some((t) => t.key === subscription.tariff_key)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
state.update((s) => ({ ...s, paymentStep: "tariff" }));
|
||||
}
|
||||
|
||||
function openTopupModal(kind = "regular", defaultMethod = "") {
|
||||
const normalizedKind = kind === "premium" ? "premium" : "regular";
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
topupKind: normalizedKind,
|
||||
topupModalOpen: true,
|
||||
topupOptions: s.topupOptions?.topup_kind === normalizedKind ? s.topupOptions : null,
|
||||
selectedTopupPlan: s.topupOptions?.topup_kind === normalizedKind ? s.selectedTopupPlan : null,
|
||||
selectedMethod: s.selectedMethod || defaultMethod,
|
||||
}));
|
||||
loadTopupOptions(normalizedKind);
|
||||
}
|
||||
|
||||
function closeTopupModal() {
|
||||
state.update((s) => ({ ...s, topupModalOpen: false }));
|
||||
}
|
||||
|
||||
function openDeviceTopupModal(defaultMethod = "") {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
deviceTopupModalOpen: true,
|
||||
selectedMethod: s.selectedMethod || defaultMethod,
|
||||
}));
|
||||
loadDeviceTopupOptions();
|
||||
}
|
||||
|
||||
function closeDeviceTopupModal() {
|
||||
state.update((s) => ({ ...s, deviceTopupModalOpen: false }));
|
||||
}
|
||||
|
||||
function openTariffChangeModal(defaultMethod = "") {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
changeModalOpen: true,
|
||||
selectedMethod: s.selectedMethod || defaultMethod,
|
||||
}));
|
||||
loadTariffChangeOptions();
|
||||
}
|
||||
|
||||
function closeTariffChangeModal() {
|
||||
state.update((s) => ({ ...s, changeModalOpen: false }));
|
||||
}
|
||||
|
||||
function openTariffChangeConfirm() {
|
||||
const s = get(state);
|
||||
if (!s.selectedChangeTarget || !s.selectedChangeAction) return;
|
||||
state.update((s) => ({ ...s, changeConfirmOpen: true }));
|
||||
}
|
||||
|
||||
function closeTariffChangeConfirm() {
|
||||
state.update((s) => ({ ...s, changeConfirmOpen: false }));
|
||||
}
|
||||
|
||||
function openTelegramInvoice(url) {
|
||||
if (!url) return;
|
||||
if (tg?.openInvoice) {
|
||||
tg.openInvoice(url, (status) => {
|
||||
if (status === "paid") {
|
||||
showToast(t("wa_payment_success", {}, "Payment successful"));
|
||||
loadData();
|
||||
} else if (status === "failed") {
|
||||
showToast(t("wa_payment_create_failed"));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
openExternalLink(url);
|
||||
}
|
||||
|
||||
async function createPayment() {
|
||||
const s = get(state);
|
||||
if (!s.selectedPlan || !s.selectedMethod || s.payBusy) return;
|
||||
state.update((s) => ({ ...s, payBusy: true }));
|
||||
try {
|
||||
const response = await billing.postPayment(
|
||||
billing.planPaymentBody(s.selectedPlan, s.selectedMethod)
|
||||
);
|
||||
if (!response.ok) throw response;
|
||||
showToast(t("wa_payment_created"));
|
||||
if (response.action === "open_invoice") {
|
||||
if (!response.payment_url) throw response;
|
||||
openTelegramInvoice(response.payment_url);
|
||||
} else if (response.action === "invoice_sent") {
|
||||
state.update((s) => ({ ...s, paymentModalOpen: false }));
|
||||
return;
|
||||
} else {
|
||||
if (!response.payment_url) throw response;
|
||||
openExternalLink(response.payment_url);
|
||||
}
|
||||
state.update((s) => ({ ...s, paymentModalOpen: false }));
|
||||
} catch (error) {
|
||||
showToast(error?.message || t("wa_payment_create_failed"));
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, payBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTopupOptions(kind) {
|
||||
const s = get(state);
|
||||
if (s.topupOptions?.topup_kind === kind) return;
|
||||
const requestId = ++topupOptionsRequestId;
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
tariffActionBusy: true,
|
||||
topupOptions: null,
|
||||
selectedTopupPlan: null,
|
||||
}));
|
||||
try {
|
||||
const response = await billing.fetchTopupOptions(kind);
|
||||
if (requestId !== topupOptionsRequestId || kind !== get(state).topupKind) return;
|
||||
if (!response?.ok) throw response;
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
topupOptions: response,
|
||||
selectedTopupPlan: response.plans?.[0] || null,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (requestId !== topupOptionsRequestId || kind !== get(state).topupKind) return;
|
||||
showToast(error?.message || t("wa_tariff_options_failed"));
|
||||
state.update((s) => ({ ...s, topupModalOpen: false }));
|
||||
} finally {
|
||||
if (requestId === topupOptionsRequestId) {
|
||||
state.update((s) => ({ ...s, tariffActionBusy: false }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createTopupPayment() {
|
||||
const s = get(state);
|
||||
if (!s.selectedTopupPlan || !s.selectedMethod || s.payBusy) return;
|
||||
state.update((s) => ({ ...s, payBusy: true }));
|
||||
try {
|
||||
const response = await billing.postPayment(
|
||||
billing.topupPaymentBody(s.selectedTopupPlan, s.selectedMethod, s.topupOptions?.tariff_key)
|
||||
);
|
||||
if (!response.ok || !response.payment_url) throw response;
|
||||
showToast(t("wa_payment_created"));
|
||||
openExternalLink(response.payment_url);
|
||||
state.update((s) => ({ ...s, topupModalOpen: false }));
|
||||
} catch (error) {
|
||||
showToast(error?.message || t("wa_payment_create_failed"));
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, payBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTariffChangeOptions() {
|
||||
const s = get(state);
|
||||
if (s.changeOptions || s.tariffActionBusy) return;
|
||||
state.update((s) => ({ ...s, tariffActionBusy: true }));
|
||||
try {
|
||||
const response = await billing.fetchTariffChangeOptions();
|
||||
if (!response?.ok) throw response;
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
changeOptions: response,
|
||||
selectedChangeTarget: response.targets?.[0] || null,
|
||||
selectedChangeAction: response.targets?.[0]?.actions?.[0] || null,
|
||||
}));
|
||||
} catch (error) {
|
||||
showToast(error?.message || t("wa_tariff_options_failed"));
|
||||
state.update((s) => ({ ...s, changeModalOpen: false }));
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, tariffActionBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function applyTariffChange() {
|
||||
const s = get(state);
|
||||
if (!s.selectedChangeTarget || !s.selectedChangeAction || s.tariffActionBusy) return;
|
||||
if (s.selectedChangeAction.kind === "payment") {
|
||||
await createTariffChangePayment();
|
||||
return;
|
||||
}
|
||||
state.update((s) => ({ ...s, tariffActionBusy: true }));
|
||||
try {
|
||||
const response = await billing.postTariffChange({
|
||||
tariff_key: s.selectedChangeTarget.tariff_key,
|
||||
mode: s.selectedChangeAction.mode,
|
||||
});
|
||||
if (!response?.ok) throw response;
|
||||
showToast(t("wa_tariff_change_applied"));
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
changeConfirmOpen: false,
|
||||
changeModalOpen: false,
|
||||
changeOptions: null,
|
||||
}));
|
||||
await loadData();
|
||||
} catch (error) {
|
||||
showToast(error?.message || t("wa_tariff_change_failed"));
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, tariffActionBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function createTariffChangePayment() {
|
||||
const s = get(state);
|
||||
if (!s.selectedChangeTarget || !s.selectedChangeAction || !s.selectedMethod || s.payBusy)
|
||||
return;
|
||||
state.update((s) => ({ ...s, payBusy: true }));
|
||||
try {
|
||||
const body = billing.changePaymentBody(
|
||||
s.selectedChangeAction,
|
||||
s.selectedChangeTarget,
|
||||
s.selectedMethod
|
||||
);
|
||||
const response =
|
||||
s.selectedChangeAction.mode === "buy_package" ||
|
||||
s.selectedChangeAction.mode === "buy_period"
|
||||
? await billing.postPayment(body)
|
||||
: await billing.postTariffChangePayment(body);
|
||||
if (!response.ok || !response.payment_url) throw response;
|
||||
showToast(t("wa_payment_created"));
|
||||
openExternalLink(response.payment_url);
|
||||
state.update((s) => ({ ...s, changeConfirmOpen: false, changeModalOpen: false }));
|
||||
} catch (error) {
|
||||
showToast(error?.message || t("wa_payment_create_failed"));
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, payBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDeviceTopupOptions() {
|
||||
const s = get(state);
|
||||
if (s.deviceTopupOptions || s.tariffActionBusy) return;
|
||||
state.update((s) => ({ ...s, tariffActionBusy: true }));
|
||||
try {
|
||||
const response = await billing.fetchDeviceTopupOptions();
|
||||
if (!response?.ok) throw response;
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
deviceTopupOptions: response,
|
||||
selectedDeviceTopupPlan: response.plans?.[0] || null,
|
||||
}));
|
||||
} catch (error) {
|
||||
showToast(error?.message || t("wa_device_topup_options_failed"));
|
||||
state.update((s) => ({ ...s, deviceTopupModalOpen: false }));
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, tariffActionBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function createDeviceTopupPayment() {
|
||||
const s = get(state);
|
||||
if (!s.selectedDeviceTopupPlan || !s.selectedMethod || s.payBusy) return;
|
||||
state.update((s) => ({ ...s, payBusy: true }));
|
||||
try {
|
||||
const response = await billing.postPayment(
|
||||
billing.deviceTopupPaymentBody(
|
||||
s.selectedDeviceTopupPlan,
|
||||
s.selectedMethod,
|
||||
s.deviceTopupOptions?.tariff_key
|
||||
)
|
||||
);
|
||||
if (!response.ok || !response.payment_url) throw response;
|
||||
showToast(t("wa_payment_created"));
|
||||
openExternalLink(response.payment_url);
|
||||
state.update((s) => ({ ...s, deviceTopupModalOpen: false }));
|
||||
} catch (error) {
|
||||
showToast(error?.message || t("wa_payment_create_failed"));
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, payBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: state.subscribe,
|
||||
set: state.set,
|
||||
update: state.update,
|
||||
openPaymentModal,
|
||||
closePaymentModal,
|
||||
selectTariff,
|
||||
continueWithSelectedTariff,
|
||||
backToTariffList,
|
||||
createPayment,
|
||||
openTopupModal,
|
||||
closeTopupModal,
|
||||
loadTopupOptions,
|
||||
createTopupPayment,
|
||||
openTariffChangeModal,
|
||||
closeTariffChangeModal,
|
||||
openTariffChangeConfirm,
|
||||
closeTariffChangeConfirm,
|
||||
loadTariffChangeOptions,
|
||||
applyTariffChange,
|
||||
createTariffChangePayment,
|
||||
openDeviceTopupModal,
|
||||
closeDeviceTopupModal,
|
||||
loadDeviceTopupOptions,
|
||||
createDeviceTopupPayment,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { writable, get } from "svelte/store";
|
||||
|
||||
export function createDevicesStore({ api, t, showToast }) {
|
||||
const state = writable({
|
||||
devicesData: null,
|
||||
devicesLoaded: false,
|
||||
devicesBusy: false,
|
||||
devicesStatus: "",
|
||||
devicesIsError: false,
|
||||
deviceConfirmOpen: false,
|
||||
deviceToDisconnect: null,
|
||||
deviceDisconnectBusy: false,
|
||||
});
|
||||
|
||||
async function loadDevices(devicesEnabled, force = false) {
|
||||
const s = get(state);
|
||||
if (!devicesEnabled || s.devicesBusy || (s.devicesLoaded && !force)) return;
|
||||
state.update((s) => ({ ...s, devicesBusy: true, devicesStatus: "", devicesIsError: false }));
|
||||
try {
|
||||
const response = await api("/devices");
|
||||
if (!response?.ok) throw response;
|
||||
state.update((s) => ({ ...s, devicesData: response, devicesLoaded: true }));
|
||||
} catch (error) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
devicesStatus: error?.message || t("wa_devices_load_failed"),
|
||||
devicesIsError: true,
|
||||
devicesLoaded: true,
|
||||
}));
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, devicesBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
function openDeviceDisconnectDialog(device) {
|
||||
state.update((s) => ({ ...s, deviceToDisconnect: device, deviceConfirmOpen: true }));
|
||||
}
|
||||
|
||||
function closeDeviceDisconnectDialog() {
|
||||
const s = get(state);
|
||||
if (s.deviceDisconnectBusy) return;
|
||||
state.update((s) => ({ ...s, deviceConfirmOpen: false, deviceToDisconnect: null }));
|
||||
}
|
||||
|
||||
async function disconnectDevice(devicesEnabled) {
|
||||
const s = get(state);
|
||||
const token = String(s.deviceToDisconnect?.token || "").trim();
|
||||
if (!token || s.deviceDisconnectBusy) return;
|
||||
state.update((s) => ({ ...s, deviceDisconnectBusy: true }));
|
||||
try {
|
||||
const response = await api("/devices/disconnect", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
if (!response?.ok) throw response;
|
||||
showToast(t("wa_device_disconnected"));
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
deviceConfirmOpen: false,
|
||||
deviceToDisconnect: null,
|
||||
devicesLoaded: false,
|
||||
}));
|
||||
await loadDevices(devicesEnabled, true);
|
||||
} catch (error) {
|
||||
showToast(error?.message || t("wa_device_disconnect_failed"));
|
||||
} finally {
|
||||
state.update((s) => ({ ...s, deviceDisconnectBusy: false }));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe: state.subscribe,
|
||||
set: state.set,
|
||||
update: state.update,
|
||||
loadDevices,
|
||||
openDeviceDisconnectDialog,
|
||||
closeDeviceDisconnectDialog,
|
||||
disconnectDevice,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { formatMoney, formatTrafficGb } from "./formatters.js";
|
||||
|
||||
export function planKey(plan) {
|
||||
return (
|
||||
plan?.id ||
|
||||
`${plan?.tariff_key || "legacy"}:${plan?.sale_mode || "subscription"}:${plan?.months || plan?.traffic_gb || ""}`
|
||||
);
|
||||
}
|
||||
|
||||
export function buildTariffCatalog(planList) {
|
||||
const byKey = new Map();
|
||||
for (const plan of planList || []) {
|
||||
const key = String(plan?.tariff_key || planKey(plan) || "").trim();
|
||||
if (!key) continue;
|
||||
const entry = byKey.get(key) || {
|
||||
key,
|
||||
title: plan?.tariff_name || plan?.title || key,
|
||||
description: plan?.description || "",
|
||||
billing_model:
|
||||
plan?.billing_model ||
|
||||
(plan?.sale_mode === "traffic_package" || plan?.sale_mode === "traffic"
|
||||
? "traffic"
|
||||
: "period"),
|
||||
monthly_gb: Number(plan?.monthly_gb || 0),
|
||||
traffic_packages: [],
|
||||
plans_count: 0,
|
||||
};
|
||||
if (!entry.description && plan?.description) entry.description = plan.description;
|
||||
if (!entry.monthly_gb && Number(plan?.monthly_gb || 0) > 0)
|
||||
entry.monthly_gb = Number(plan.monthly_gb);
|
||||
const trafficGb = Number(plan?.traffic_gb || 0);
|
||||
if (trafficGb > 0) entry.traffic_packages.push(trafficGb);
|
||||
entry.plans_count += 1;
|
||||
byKey.set(key, entry);
|
||||
}
|
||||
return Array.from(byKey.values());
|
||||
}
|
||||
|
||||
export function activeTariffName(sub, planList) {
|
||||
const direct = String(sub?.tariff_name || "").trim();
|
||||
if (direct) return direct;
|
||||
const key = String(sub?.tariff_key || "").trim();
|
||||
if (!key) return "";
|
||||
const plan = (planList || []).find((item) => item?.tariff_key === key);
|
||||
return String(plan?.tariff_name || plan?.title || key).trim();
|
||||
}
|
||||
|
||||
export function priceLabel(plan, methodId = "") {
|
||||
if (
|
||||
String(methodId || "")
|
||||
.toLowerCase()
|
||||
.includes("stars") &&
|
||||
Number(plan?.stars_price || 0) > 0
|
||||
) {
|
||||
return `${Number(plan.stars_price)} ⭐`;
|
||||
}
|
||||
return formatMoney(plan?.price || 0, plan?.currency);
|
||||
}
|
||||
|
||||
export function tariffLimitLabel(tariff, { t }) {
|
||||
if (!tariff) return "";
|
||||
if (String(tariff.billing_model || "") === "traffic") {
|
||||
const values = (tariff.traffic_packages || [])
|
||||
.filter((value) => Number(value) > 0)
|
||||
.sort((a, b) => a - b);
|
||||
if (!values.length) return t("wa_tariff_model_traffic");
|
||||
const min = values[0];
|
||||
const max = values[values.length - 1];
|
||||
return min === max ? formatTrafficGb(min) : `${formatTrafficGb(min)} - ${formatTrafficGb(max)}`;
|
||||
}
|
||||
if (Number(tariff.monthly_gb || 0) > 0) return formatTrafficGb(tariff.monthly_gb);
|
||||
return t("wa_unlimited_traffic");
|
||||
}
|
||||
|
||||
export function actionKey(action) {
|
||||
return `${action?.mode || ""}:${action?.months || ""}:${action?.traffic_gb || ""}:${action?.price || ""}`;
|
||||
}
|
||||
|
||||
function formatMonthsForClient(value, { t, termUnitLabel }) {
|
||||
const months = Number(value || 0);
|
||||
if (months === 12) return t("wa_plan_one_year");
|
||||
return t("wa_sub_term_value_unit", {
|
||||
value: String(months),
|
||||
unit: termUnitLabel(months, "month"),
|
||||
});
|
||||
}
|
||||
|
||||
export function planDisplayTitle(plan, { trafficMode, t }) {
|
||||
if (plan?.tariff_key) {
|
||||
return plan?.tariff_name || plan?.title || plan?.tariff_key;
|
||||
}
|
||||
if (trafficMode || plan?.sale_mode === "traffic") {
|
||||
return plan?.title || formatTrafficGb(plan?.traffic_gb || plan?.months);
|
||||
}
|
||||
const months = Number(plan?.months || 0);
|
||||
if (months === 12) return t("wa_plan_one_year");
|
||||
return plan?.title || "";
|
||||
}
|
||||
|
||||
export function planSubtitle(plan, { t, termUnitLabel }) {
|
||||
if (!plan?.tariff_key) return "";
|
||||
if (plan?.subtitle) return plan.subtitle;
|
||||
if (
|
||||
plan?.sale_mode === "traffic_package" ||
|
||||
plan?.sale_mode === "topup" ||
|
||||
plan?.sale_mode === "premium_topup" ||
|
||||
plan?.billing_model === "traffic"
|
||||
) {
|
||||
return formatTrafficGb(plan?.traffic_gb || plan?.months);
|
||||
}
|
||||
return formatMonthsForClient(plan?.months, { t, termUnitLabel });
|
||||
}
|
||||
|
||||
export function planUnitHint(plan, { trafficMode, selectedMethod, t }) {
|
||||
if (
|
||||
trafficMode ||
|
||||
plan?.sale_mode === "traffic" ||
|
||||
plan?.sale_mode === "traffic_package" ||
|
||||
plan?.sale_mode === "topup" ||
|
||||
plan?.sale_mode === "premium_topup"
|
||||
) {
|
||||
const gb = Number(plan?.traffic_gb || plan?.months || 0);
|
||||
if (!gb) return "";
|
||||
if (
|
||||
String(selectedMethod || "")
|
||||
.toLowerCase()
|
||||
.includes("stars") &&
|
||||
Number(plan?.stars_price || 0) > 0
|
||||
) {
|
||||
return `${Number(plan.stars_price / gb).toFixed(0)} ⭐${t("wa_per_gb_short")}`;
|
||||
}
|
||||
return `${formatMoney(Number(plan?.price || 0) / gb, plan?.currency)}${t("wa_per_gb_short")}`;
|
||||
}
|
||||
const months = Number(plan?.months || 0);
|
||||
if (!months || months <= 1) return "";
|
||||
if (
|
||||
String(selectedMethod || "")
|
||||
.toLowerCase()
|
||||
.includes("stars") &&
|
||||
Number(plan?.stars_price || 0) > 0
|
||||
) {
|
||||
return `${Number(plan.stars_price / months).toFixed(0)} ⭐${t("wa_per_month_short")}`;
|
||||
}
|
||||
return `${formatMoney(Number(plan?.price || 0) / months, plan?.currency)}${t("wa_per_month_short")}`;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
export function readTelegramMiniAppInitDataFromLocation() {
|
||||
if (typeof window === "undefined") return "";
|
||||
const queryText = window.location.search.replace(/^\?/, "");
|
||||
const hashText = window.location.hash.replace(/^#/, "");
|
||||
for (const text of [queryText, hashText]) {
|
||||
if (!text) continue;
|
||||
const params = new URLSearchParams(text);
|
||||
const initData = params.get("tgWebAppData");
|
||||
if (initData) return initData;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function createTelegramSdk({
|
||||
scriptUrl,
|
||||
bootTimeoutMs,
|
||||
actionTimeoutMs,
|
||||
miniAppAuthTimeoutMs,
|
||||
onStatusChange = () => {},
|
||||
onInitDataChange = () => {},
|
||||
} = {}) {
|
||||
let tg = resolve();
|
||||
let sdkPromise = null;
|
||||
let launchParamsDetected = false;
|
||||
let initData = tg?.initData || readTelegramMiniAppInitDataFromLocation();
|
||||
if (initData) launchParamsDetected = true;
|
||||
|
||||
function resolve() {
|
||||
return window.Telegram?.WebApp || null;
|
||||
}
|
||||
|
||||
function setStatus(status) {
|
||||
onStatusChange(status);
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
tg = resolve();
|
||||
if (tg) setStatus("ready");
|
||||
initData = tg?.initData || readTelegramMiniAppInitDataFromLocation();
|
||||
onInitDataChange(initData);
|
||||
if (initData) launchParamsDetected = true;
|
||||
return tg;
|
||||
}
|
||||
|
||||
function hasLaunchParams() {
|
||||
refresh();
|
||||
if (launchParamsDetected || initData) {
|
||||
launchParamsDetected = true;
|
||||
return true;
|
||||
}
|
||||
const queryText = window.location.search.replace(/^\?/, "");
|
||||
const hashText = window.location.hash.replace(/^#/, "");
|
||||
const detected = [queryText, hashText].some((text) => {
|
||||
if (!text) return false;
|
||||
const params = new URLSearchParams(text);
|
||||
return ["tgWebAppData", "tgWebAppVersion", "tgWebAppPlatform", "tgWebAppThemeParams"].some(
|
||||
(key) => params.has(key)
|
||||
);
|
||||
});
|
||||
if (detected) launchParamsDetected = true;
|
||||
return detected;
|
||||
}
|
||||
|
||||
function load(timeoutMs = bootTimeoutMs) {
|
||||
if (refresh()) return Promise.resolve(tg);
|
||||
if (sdkPromise) return sdkPromise;
|
||||
if (typeof document === "undefined") return Promise.resolve(null);
|
||||
|
||||
setStatus("loading");
|
||||
sdkPromise = new Promise((resolvePromise) => {
|
||||
const existingScript = document.querySelector("script[data-rw-telegram-web-app-sdk]");
|
||||
const script = existingScript || document.createElement("script");
|
||||
let resolved = false;
|
||||
let timeoutId = null;
|
||||
|
||||
const resolveOnce = (value) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
if (timeoutId) window.clearTimeout(timeoutId);
|
||||
resolvePromise(value);
|
||||
};
|
||||
|
||||
const refreshFromScript = () => {
|
||||
tg = resolve();
|
||||
setStatus(tg ? "ready" : "unavailable");
|
||||
return tg;
|
||||
};
|
||||
|
||||
script.addEventListener("load", () => resolveOnce(refreshFromScript()), { once: true });
|
||||
script.addEventListener(
|
||||
"error",
|
||||
() => {
|
||||
setStatus("unavailable");
|
||||
resolveOnce(null);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
|
||||
if (!existingScript) {
|
||||
script.src = scriptUrl;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.dataset.rwTelegramWebAppSdk = "1";
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
timeoutId = window.setTimeout(() => {
|
||||
if (!tg) setStatus("unavailable");
|
||||
resolveOnce(tg);
|
||||
}, timeoutMs);
|
||||
}).finally(() => {
|
||||
sdkPromise = null;
|
||||
});
|
||||
return sdkPromise;
|
||||
}
|
||||
|
||||
async function ensureForAction() {
|
||||
if (refresh()) return tg;
|
||||
return await load(actionTimeoutMs);
|
||||
}
|
||||
|
||||
function createMiniAppAuthTimeout() {
|
||||
const controller = typeof AbortController === "undefined" ? null : new AbortController();
|
||||
let timedOut = false;
|
||||
let timeoutId = null;
|
||||
let timeoutPromise = new Promise(() => {});
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
timeoutPromise = new Promise((_, reject) => {
|
||||
timeoutId = window.setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller?.abort();
|
||||
const error = new Error("telegram_mini_app_auth_timeout");
|
||||
error.name = "AbortError";
|
||||
reject(error);
|
||||
}, miniAppAuthTimeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
promise: timeoutPromise,
|
||||
get signal() {
|
||||
return controller?.signal;
|
||||
},
|
||||
get timedOut() {
|
||||
return timedOut;
|
||||
},
|
||||
clear() {
|
||||
if (timeoutId) window.clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
get tg() {
|
||||
return tg;
|
||||
},
|
||||
get initData() {
|
||||
return initData;
|
||||
},
|
||||
refresh,
|
||||
hasLaunchParams,
|
||||
load,
|
||||
ensureForAction,
|
||||
createMiniAppAuthTimeout,
|
||||
readInitDataFromLocation: readTelegramMiniAppInitDataFromLocation,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/** Maps JSON theme token keys to CSS custom properties used by the Mini App shell. */
|
||||
|
||||
const TOKEN_TO_CSS_VAR = {
|
||||
accent: "--accent",
|
||||
bg: "--bg",
|
||||
panel: "--panel",
|
||||
panel_2: "--panel-2",
|
||||
panel_3: "--panel-3",
|
||||
border: "--border",
|
||||
border_strong: "--border-strong",
|
||||
text: "--text",
|
||||
muted: "--muted",
|
||||
dim: "--dim",
|
||||
danger: "--danger",
|
||||
danger_text: "--danger-text",
|
||||
danger_soft: "--danger-soft",
|
||||
danger_border: "--danger-border",
|
||||
success: "--success",
|
||||
success_text: "--success-text",
|
||||
success_soft: "--success-soft",
|
||||
success_border: "--success-border",
|
||||
warning: "--warning",
|
||||
warning_text: "--warning-text",
|
||||
warning_soft: "--warning-soft",
|
||||
warning_border: "--warning-border",
|
||||
info: "--info",
|
||||
info_text: "--info-text",
|
||||
info_soft: "--info-soft",
|
||||
info_border: "--info-border",
|
||||
blue: "--blue",
|
||||
radius: "--radius",
|
||||
font_sans: "--font-sans",
|
||||
font_logo: "--font-logo",
|
||||
font_mono: "--font-mono",
|
||||
home_logo_scale: "--home-logo-scale",
|
||||
admin_bg: "--admin-bg",
|
||||
admin_surface: "--admin-surface",
|
||||
admin_surface_2: "--admin-surface-2",
|
||||
admin_elev: "--admin-elev",
|
||||
admin_border: "--admin-border",
|
||||
admin_border_strong: "--admin-border-strong",
|
||||
admin_text: "--admin-text",
|
||||
admin_muted: "--admin-muted",
|
||||
admin_dim: "--admin-dim",
|
||||
};
|
||||
|
||||
export function themeTokensToInlineStyle(tokens, primaryFallback = "#00fe7a", options = {}) {
|
||||
const t = tokens && typeof tokens === "object" ? tokens : {};
|
||||
const parts = [];
|
||||
const useFallbackAccent = options.fallbackAccent !== false;
|
||||
const accent = t.accent || (useFallbackAccent ? primaryFallback || "#00fe7a" : "");
|
||||
if (accent) parts.push(`--accent:${accent}`);
|
||||
for (const [key, cssVar] of Object.entries(TOKEN_TO_CSS_VAR)) {
|
||||
if (key === "accent") continue;
|
||||
const value = t[key];
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
if (key === "home_logo_scale") {
|
||||
const scale = Number(value);
|
||||
if (!Number.isFinite(scale) || scale <= 0) continue;
|
||||
parts.push(`${cssVar}:${scale / 100}`);
|
||||
continue;
|
||||
}
|
||||
parts.push(`${cssVar}:${String(value)}`);
|
||||
}
|
||||
return parts.join(";");
|
||||
}
|
||||
|
||||
export function findThemeEntry(themesCatalog, key) {
|
||||
const themes = themesCatalog?.themes || [];
|
||||
return themes.find((entry) => entry && entry.key === key) || null;
|
||||
}
|
||||
|
||||
export function resolveEffectiveThemeKey(themesCatalog) {
|
||||
const themes = themesCatalog?.themes || [];
|
||||
const byKey = (k) => themes.find((entry) => entry.key === k);
|
||||
const def = themesCatalog?.default_theme || themes[0]?.key || "dark";
|
||||
return byKey(def) ? def : themes[0]?.key || "dark";
|
||||
}
|
||||
|
||||
export function themePresetClass(tokens) {
|
||||
const preset = String(tokens?.style_preset || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!preset || preset === "none") return "";
|
||||
if (preset === "win95" || preset === "windows95") return "theme-preset-win95";
|
||||
return "";
|
||||
}
|
||||
|
||||
export function themeKeyClass(key) {
|
||||
const safe = String(key || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^A-Za-z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return safe ? `theme-key-${safe}` : "";
|
||||
}
|
||||
|
||||
export function themeCssClass(cssFile) {
|
||||
const filename = String(cssFile || "")
|
||||
.replace(/\\/g, "/")
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
const slug = String(filename || "")
|
||||
.replace(/\.css$/i, "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return slug ? `theme-css-${slug}` : "";
|
||||
}
|
||||
|
||||
export function themeRootClass(theme) {
|
||||
return [
|
||||
themeKeyClass(theme?.key),
|
||||
themeCssClass(theme?.css_file),
|
||||
themePresetClass(theme?.tokens),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function themeEntryToInlineStyle(theme, primaryFallback = "#00fe7a") {
|
||||
return themeTokensToInlineStyle(theme?.tokens, primaryFallback, {
|
||||
fallbackAccent: !theme?.css_file,
|
||||
});
|
||||
}
|
||||
|
||||
function encodeThemeCssPath(path) {
|
||||
return String(path || "")
|
||||
.replace(/\\/g, "/")
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map(encodeURIComponent)
|
||||
.join("/");
|
||||
}
|
||||
|
||||
export function themeCssHref(theme) {
|
||||
const cssFile = String(theme?.css_file || "").trim();
|
||||
if (!cssFile) return "";
|
||||
if (/^(?:https?:)?\/\//i.test(cssFile) || cssFile.startsWith("data:")) return "";
|
||||
if (cssFile.startsWith("/")) return cssFile;
|
||||
const normalizedCssFile = cssFile.replace(/\\/g, "/").split("/").filter(Boolean).join("/");
|
||||
const key = String(theme?.key || "")
|
||||
.trim()
|
||||
.replace(/[^A-Za-z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
const themedPath =
|
||||
key && normalizedCssFile.split("/")[0] !== key
|
||||
? `${key}/${normalizedCssFile}`
|
||||
: normalizedCssFile;
|
||||
const encoded = encodeThemeCssPath(themedPath);
|
||||
return encoded ? `/webapp-theme-css/${encoded}` : "";
|
||||
}
|
||||
|
||||
export function localizedThemeName(theme, lang = "en") {
|
||||
const names = theme?.names || {};
|
||||
const key = String(lang || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const base = key.split("-")[0];
|
||||
return names[key] || names[base] || names.en || theme?.key || "";
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { formatTrafficBytes, formatFraction, roundToHalf } from "./formatters.js";
|
||||
|
||||
export function trafficPercent(sub) {
|
||||
const used = Number(sub?.traffic_used_bytes || 0);
|
||||
const limit = Number(sub?.traffic_limit_bytes || 0);
|
||||
if (!limit || limit <= 0) return 100;
|
||||
return Math.max(0, Math.min(100, Math.round((used / limit) * 100)));
|
||||
}
|
||||
|
||||
export function trafficLabel(sub, t) {
|
||||
if (!sub?.traffic_limit_bytes || Number(sub.traffic_limit_bytes) <= 0)
|
||||
return t("wa_unlimited_traffic");
|
||||
return t("wa_traffic_of", {
|
||||
used: sub.traffic_used || "0 GB",
|
||||
limit: sub.traffic_limit || "0 GB",
|
||||
});
|
||||
}
|
||||
|
||||
export function trafficResetLabel(sub, t) {
|
||||
const strategy = String(sub?.traffic_limit_strategy || "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (!strategy || strategy.includes("NO_RESET")) return t("wa_traffic_reset_none");
|
||||
if (strategy.includes("MONTH")) return t("wa_traffic_reset_monthly");
|
||||
if (strategy.includes("WEEK")) return t("wa_traffic_reset_weekly");
|
||||
if (strategy.includes("DAY")) return t("wa_traffic_reset_daily");
|
||||
if (strategy.includes("YEAR")) return t("wa_traffic_reset_yearly");
|
||||
return t("wa_traffic_reset_policy");
|
||||
}
|
||||
|
||||
export function premiumTrafficPercent(sub) {
|
||||
const used = Number(sub?.premium_used_bytes || 0);
|
||||
const limit = Number(sub?.premium_limit_bytes || 0);
|
||||
if (!limit || limit <= 0) return 0;
|
||||
return Math.max(0, Math.min(100, Math.round((used / limit) * 100)));
|
||||
}
|
||||
|
||||
export function premiumTrafficLabel(sub, t) {
|
||||
return t("wa_traffic_of", {
|
||||
used: sub?.premium_used || "0 GB",
|
||||
limit: sub?.premium_limit || "0 GB",
|
||||
});
|
||||
}
|
||||
|
||||
export function premiumTitle(sub, t) {
|
||||
return (
|
||||
String(sub?.premium_title || "").trim() || t("wa_premium_traffic_title", {}, "Premium-серверы")
|
||||
);
|
||||
}
|
||||
|
||||
export function premiumTrafficLeftLabel(sub) {
|
||||
const left = Math.max(
|
||||
0,
|
||||
Number(sub?.premium_limit_bytes || 0) - Number(sub?.premium_used_bytes || 0)
|
||||
);
|
||||
return formatTrafficBytes(left);
|
||||
}
|
||||
|
||||
export function premiumTopupBalanceLabel(sub) {
|
||||
return formatTrafficBytes(Number(sub?.premium_topup_balance_bytes || 0));
|
||||
}
|
||||
|
||||
export function premiumServerLabels(sub) {
|
||||
const labels =
|
||||
Array.isArray(sub?.premium_node_labels) && sub.premium_node_labels.length
|
||||
? sub.premium_node_labels
|
||||
: sub?.premium_squad_labels || [];
|
||||
return labels.map((label) => String(label || "").trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function extractYear(text) {
|
||||
const iso = String(text || "").match(/\b(\d{4})-\d{1,2}-\d{1,2}\b/);
|
||||
if (iso) return Number(iso[1] || 0);
|
||||
const dmy = String(text || "").match(/\b\d{1,2}\.\d{1,2}\.(\d{4})\b/);
|
||||
if (dmy) return Number(dmy[1] || 0);
|
||||
const any4 = String(text || "").match(/\b(\d{4})\b/);
|
||||
if (any4) return Number(any4[1] || 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function isForeverSubscription(sub) {
|
||||
const raw = String(sub?.end_date_text || "").trim();
|
||||
if (!raw) return false;
|
||||
return extractYear(raw) >= 2099;
|
||||
}
|
||||
|
||||
export function activeSubscriptionTermLabel(sub, { t, termUnitLabel }) {
|
||||
if (isForeverSubscription(sub)) return t("wa_sub_term_forever");
|
||||
|
||||
const days = Math.max(0, Number(sub?.days_left || 0));
|
||||
if (!days) return t("wa_sub_term_value_unit", { value: "0", unit: termUnitLabel(0, "day") });
|
||||
|
||||
if (days < 30) {
|
||||
return t("wa_sub_term_value_unit", { value: String(days), unit: termUnitLabel(days, "day") });
|
||||
}
|
||||
if (days < 365) {
|
||||
const months = roundToHalf(days / 30);
|
||||
return t("wa_sub_term_value_unit", {
|
||||
value: formatFraction(months),
|
||||
unit: termUnitLabel(months, "month"),
|
||||
});
|
||||
}
|
||||
const years = roundToHalf(days / 365);
|
||||
return t("wa_sub_term_value_unit", {
|
||||
value: formatFraction(years),
|
||||
unit: termUnitLabel(years, "year"),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
readMagicLoginToken,
|
||||
readTelegramAuthStatus,
|
||||
readTelegramLoginWidgetAuthData,
|
||||
clearAuthQuery,
|
||||
} from "./authHelpers.js";
|
||||
import { TELEGRAM_SDK_BOOT_TIMEOUT_MS } from "./constants.js";
|
||||
|
||||
/**
|
||||
* Initial auth / session bootstrap for the subscription webapp (non-preview).
|
||||
* Keeps side effects in App (mode, tg, token) via injected callbacks.
|
||||
*/
|
||||
export async function runWebappBoot({
|
||||
MOCK,
|
||||
setMode,
|
||||
hasTelegramLaunchParams,
|
||||
loadTelegramSdk,
|
||||
prepareTelegramMiniApp,
|
||||
loadData,
|
||||
showLogin,
|
||||
clearToken,
|
||||
clearManualLogoutFlag,
|
||||
isManuallyLoggedOut,
|
||||
finalizeMagicLogin,
|
||||
finalizeTelegramAuth,
|
||||
setAuthStatus,
|
||||
t,
|
||||
getInitDataForBoot,
|
||||
getToken,
|
||||
getCsrfToken,
|
||||
}) {
|
||||
setMode("loading");
|
||||
if (hasTelegramLaunchParams()) await loadTelegramSdk(TELEGRAM_SDK_BOOT_TIMEOUT_MS);
|
||||
prepareTelegramMiniApp();
|
||||
|
||||
if (MOCK) {
|
||||
await loadData();
|
||||
return;
|
||||
}
|
||||
|
||||
const magicToken = readMagicLoginToken();
|
||||
if (magicToken && (await finalizeMagicLogin(magicToken))) return;
|
||||
|
||||
const telegramAuthStatus = readTelegramAuthStatus();
|
||||
if (telegramAuthStatus === "success") {
|
||||
clearManualLogoutFlag();
|
||||
clearAuthQuery();
|
||||
try {
|
||||
await loadData();
|
||||
return;
|
||||
} catch {
|
||||
clearToken();
|
||||
}
|
||||
} else if (telegramAuthStatus) {
|
||||
clearAuthQuery();
|
||||
setAuthStatus(
|
||||
telegramAuthStatus === "cancelled"
|
||||
? t("wa_auth_telegram_cancelled")
|
||||
: t("wa_auth_telegram_not_confirmed"),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
if (isManuallyLoggedOut()) {
|
||||
showLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
const widgetAuthData = readTelegramLoginWidgetAuthData();
|
||||
if (widgetAuthData && (await finalizeTelegramAuth(widgetAuthData, "auth_data"))) return;
|
||||
|
||||
const initData = getInitDataForBoot();
|
||||
if (initData) {
|
||||
try {
|
||||
if (await finalizeTelegramAuth(initData, "init_data")) return;
|
||||
} catch (_error) {
|
||||
void _error;
|
||||
}
|
||||
}
|
||||
|
||||
if (getToken() || getCsrfToken()) {
|
||||
try {
|
||||
await loadData();
|
||||
return;
|
||||
} catch {
|
||||
clearToken();
|
||||
}
|
||||
}
|
||||
|
||||
showLogin();
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { mount } from "svelte";
|
||||
|
||||
import App from "./App.svelte";
|
||||
import "./styles.css";
|
||||
|
||||
async function loadBootstrap() {
|
||||
if (document.getElementById("webapp-config")) return;
|
||||
try {
|
||||
const response = await fetch("/api/bootstrap", {
|
||||
credentials: "include",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const payload = await response.json();
|
||||
for (const [id, value] of [
|
||||
["webapp-config", payload.config],
|
||||
["i18n", payload.i18n],
|
||||
]) {
|
||||
const script = document.createElement("script");
|
||||
script.id = id;
|
||||
script.type = "application/json";
|
||||
script.textContent = JSON.stringify(value || {});
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
} catch (_error) {
|
||||
void _error;
|
||||
}
|
||||
}
|
||||
|
||||
const target = document.getElementById("app");
|
||||
|
||||
if (target) {
|
||||
loadBootstrap().finally(() => {
|
||||
mount(App, { target });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<script>
|
||||
import { ArrowLeft } from "$components/ui/icons.js";
|
||||
|
||||
export let title = "";
|
||||
export let subtitle = "";
|
||||
</script>
|
||||
|
||||
<header class="screen-head">
|
||||
<button class="btn btn-icon btn-square" type="button" aria-label="Назад">
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
<div class="center-copy">
|
||||
<h1>{title}</h1>
|
||||
<p>{subtitle}</p>
|
||||
</div>
|
||||
<span></span>
|
||||
</header>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script>
|
||||
export let number = "";
|
||||
export let label = "";
|
||||
export let wide = false;
|
||||
</script>
|
||||
|
||||
<section class:wide class="preview-phone-wrap">
|
||||
<h2><span>{number}.</span> {label}</h2>
|
||||
<div class="preview-phone">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script>
|
||||
import { CreditCard, Send, WalletCards } from "$components/ui/icons.js";
|
||||
|
||||
export let methods = [];
|
||||
|
||||
const icons = [CreditCard, Send, WalletCards, WalletCards];
|
||||
|
||||
function note(index) {
|
||||
if (index === 0) return "Visa, Mastercard";
|
||||
if (index === 1) return "Быстро и удобно";
|
||||
if (index === 2) return "USDT, BTC, ETH";
|
||||
return "ЮMoney, СБП и др.";
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="method-grid">
|
||||
{#each methods as method, index}
|
||||
<div class:active={index === 1} class="method-card">
|
||||
<svelte:component this={icons[index] || WalletCards} size={18} />
|
||||
<span>
|
||||
<strong>{method.name}</strong>
|
||||
<small>{note(index)}</small>
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script>
|
||||
import { Gift, Home, Settings } from "$components/ui/icons.js";
|
||||
|
||||
export let active = "home";
|
||||
</script>
|
||||
|
||||
<nav class="bottom-nav static">
|
||||
<button class:active={active === "home"} type="button"
|
||||
><Home size={20} /><span>Главная</span></button
|
||||
>
|
||||
<button class:active={active === "invite"} type="button"
|
||||
><Gift size={20} /><span>Пригласить</span></button
|
||||
>
|
||||
<button class:active={active === "settings"} type="button"
|
||||
><Settings size={20} /><span>Настройки</span></button
|
||||
>
|
||||
</nav>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user