chore: run lint and prettifier

This commit is contained in:
3252a8
2026-05-12 21:54:12 +03:00
parent f31540afdb
commit 11187487b4
174 changed files with 12383 additions and 6688 deletions
+371 -281
View File
@@ -135,15 +135,32 @@
clearToken();
showLogin();
},
mockApi: MOCK
? (path, options, context) => runMockApi(path, options, context)
: null,
mockApi: MOCK ? (path, options, context) => runMockApi(path, options, context) : null,
getMockContext: () => ({ currentLang, normalizeLangCode, clone: structuredCloneSafe }),
});
const billing = createBillingActions({ api: (path, options) => apiClient.api(path, options), t: (...args) => t(...args) });
const billing = createBillingActions({
api: (path, options) => apiClient.api(path, options),
t: (...args) => t(...args),
});
const authStore = createAuthStore({ publicApi, setToken, loadData, telegramSdk, getTg: () => tg, t, currentLang: () => currentLang, clearManualLogoutFlag });
const billingStore = createBillingStore({ billing, loadData, t, showToast, openExternalLink, tg });
const authStore = createAuthStore({
publicApi,
setToken,
loadData,
telegramSdk,
getTg: () => tg,
t,
currentLang: () => currentLang,
clearManualLogoutFlag,
});
const billingStore = createBillingStore({
billing,
loadData,
t,
showToast,
openExternalLink,
tg,
});
const devicesStore = createDevicesStore({ api, t, showToast });
const accountStore = createAccountStore({
api,
@@ -171,7 +188,18 @@
setContext("devicesStore", devicesStore);
setContext("accountStore", accountStore);
$: ({ authStatus, authIsError, authBusy, telegramLoginBusy, loginEmailFieldError, loginEmailTooltipOpen, authResendCooldown, email, pendingEmail, emailCode } = $authStore);
$: ({
authStatus,
authIsError,
authBusy,
telegramLoginBusy,
loginEmailFieldError,
loginEmailTooltipOpen,
authResendCooldown,
email,
pendingEmail,
emailCode,
} = $authStore);
$: ({
paymentModalOpen,
selectedTariffKey,
@@ -187,8 +215,29 @@
tariffActionBusy,
payBusy,
} = $billingStore);
$: ({ devicesData, devicesLoaded, devicesBusy, devicesStatus, devicesIsError, deviceConfirmOpen, deviceToDisconnect, deviceDisconnectBusy } = $devicesStore);
$: ({ linkEmailOpen, linkEmailBusy, linkTelegramBusy, linkEmailValue, linkEmailPending, linkEmailCode, linkEmailStatus, linkEmailIsError, linkEmailFieldError, linkEmailResendCooldown, languageBusy } = $accountStore);
$: ({
devicesData,
devicesLoaded,
devicesBusy,
devicesStatus,
devicesIsError,
deviceConfirmOpen,
deviceToDisconnect,
deviceDisconnectBusy,
} = $devicesStore);
$: ({
linkEmailOpen,
linkEmailBusy,
linkTelegramBusy,
linkEmailValue,
linkEmailPending,
linkEmailCode,
linkEmailStatus,
linkEmailIsError,
linkEmailFieldError,
linkEmailResendCooldown,
languageBusy,
} = $accountStore);
$: brandTitle = CFG.title || "/minishop";
$: brandEmoji = CFG.logoEmoji || "🫥";
@@ -202,42 +251,51 @@
$: singleTariffMode = tariffMode && tariffCatalog.length === 1;
$: hasMultipleTariffs = tariffCatalog.length > 1;
$: selectedTariff = tariffCatalog.find((tariff) => tariff.key === selectedTariffKey) || null;
$: selectedTariffPlans = tariffMode ? (selectedTariffKey ? plans.filter((plan) => plan?.tariff_key === selectedTariffKey) : []) : plans;
$: selectedTariffPlans = tariffMode
? selectedTariffKey
? plans.filter((plan) => plan?.tariff_key === selectedTariffKey)
: []
: plans;
$: devicesEnabled = Boolean(appSettings?.my_devices_enabled);
$: subscription = data?.subscription || DEV_MOCK.data.subscription;
$: hasActiveTariffSubscription = Boolean(tariffMode && subscription?.active && subscription?.tariff_key);
$: hasActiveTariffSubscription = Boolean(
tariffMode && subscription?.active && subscription?.tariff_key
);
$: canChangeTariff = Boolean(hasActiveTariffSubscription && hasMultipleTariffs);
$: currentTariffName = activeTariffName(subscription, plans);
$: canOpenRegularTopupModal = Boolean(
hasActiveTariffSubscription &&
(subscription?.can_topup_regular_traffic ?? subscription?.can_topup_traffic) &&
Number(subscription?.traffic_limit_bytes || 0) > 0,
(subscription?.can_topup_regular_traffic ?? subscription?.can_topup_traffic) &&
Number(subscription?.traffic_limit_bytes || 0) > 0
);
$: canOpenPremiumTopupModal = Boolean(
hasActiveTariffSubscription &&
(subscription?.can_topup_premium_traffic ?? subscription?.can_topup_traffic) &&
Number(subscription?.premium_limit_bytes || 0) > 0,
(subscription?.can_topup_premium_traffic ?? subscription?.can_topup_traffic) &&
Number(subscription?.premium_limit_bytes || 0) > 0
);
$: activeTariffCatalogEntry =
tariffCatalog.find((entry) => entry.key === String(subscription?.tariff_key || "").trim()) || null;
tariffCatalog.find((entry) => entry.key === String(subscription?.tariff_key || "").trim()) ||
null;
$: subscriptionIsTrafficTariff = Boolean(
String(subscription?.billing_model || activeTariffCatalogEntry?.billing_model || "")
.toLowerCase() === "traffic",
String(
subscription?.billing_model || activeTariffCatalogEntry?.billing_model || ""
).toLowerCase() === "traffic"
);
$: regularTrafficTopupUnlocked = Boolean(
canOpenRegularTopupModal && trafficPercent(subscription) >= TRAFFIC_TOPUP_UNLOCK_PERCENT,
canOpenRegularTopupModal && trafficPercent(subscription) >= TRAFFIC_TOPUP_UNLOCK_PERCENT
);
$: premiumTrafficTopupUnlocked = Boolean(
canOpenPremiumTopupModal && premiumTrafficPercent(subscription) >= TRAFFIC_TOPUP_UNLOCK_PERCENT,
canOpenPremiumTopupModal && premiumTrafficPercent(subscription) >= TRAFFIC_TOPUP_UNLOCK_PERCENT
);
/** Progress-bar card opens top-up immediately on traffic-only tariffs; period tariffs still need 80% usage */
$: regularTrafficTopupBarClickable = Boolean(
canOpenRegularTopupModal &&
(subscriptionIsTrafficTariff || trafficPercent(subscription) >= TRAFFIC_TOPUP_UNLOCK_PERCENT),
(subscriptionIsTrafficTariff || trafficPercent(subscription) >= TRAFFIC_TOPUP_UNLOCK_PERCENT)
);
$: premiumTrafficTopupBarClickable = Boolean(
canOpenPremiumTopupModal &&
(subscriptionIsTrafficTariff || premiumTrafficPercent(subscription) >= TRAFFIC_TOPUP_UNLOCK_PERCENT),
(subscriptionIsTrafficTariff ||
premiumTrafficPercent(subscription) >= TRAFFIC_TOPUP_UNLOCK_PERCENT)
);
$: user = data?.user || {};
$: isAdmin = Boolean(user?.is_admin);
@@ -252,7 +310,8 @@
label: LANGUAGE_LABELS[code] || code.toUpperCase(),
flag: LANGUAGE_FLAGS[code] || "🏳️",
}));
$: currentLanguageOption = languageOptions.find((option) => option.value === currentLang) || languageOptions[0];
$: currentLanguageOption =
languageOptions.find((option) => option.value === currentLang) || languageOptions[0];
$: userLanguage = languageName(currentLang);
$: emailLinkStatus = user?.email ? t("wa_settings_linked") : t("wa_settings_email_not_linked");
$: hasUnlinkedIdentity = !user?.telegram_linked || !user?.email;
@@ -271,8 +330,9 @@
$: telegramMiniAppInitData = tg?.initData || readTelegramMiniAppInitDataFromLocation();
$: telegramMiniAppAuthAvailable = Boolean(telegramMiniAppInitData);
$: telegramLoginUnavailable =
(!telegramMiniAppAuthAvailable && !telegramOAuthClientId && telegramSdkStatus !== "loading");
$: telegramLoginChecking = telegramLoginBusy || (authBusy && authStatus === t("wa_auth_checking_telegram"));
!telegramMiniAppAuthAvailable && !telegramOAuthClientId && telegramSdkStatus !== "loading";
$: telegramLoginChecking =
telegramLoginBusy || (authBusy && authStatus === t("wa_auth_checking_telegram"));
$: telegramLoginLabel = telegramLoginUnavailable
? t("wa_login_telegram_unavailable_button")
: telegramLoginChecking
@@ -280,12 +340,19 @@
: t("wa_login_telegram_button");
$: telegramLoginUnavailableMessage =
telegramLoginUnavailable && telegramSdkStatus === "unavailable"
? t("wa_auth_telegram_unavailable")
: telegramLoginUnavailable
? t("wa_auth_telegram_not_configured")
: "";
? t("wa_auth_telegram_unavailable")
: telegramLoginUnavailable
? t("wa_auth_telegram_not_configured")
: "";
$: applyFavicon(CFG.logoUrl, brandEmoji);
$: syncBodyScrollLock(paymentModalOpen || changeModalOpen || changeConfirmOpen || topupModalOpen || deviceTopupModalOpen || linkEmailOpen);
$: syncBodyScrollLock(
paymentModalOpen ||
changeModalOpen ||
changeConfirmOpen ||
topupModalOpen ||
deviceTopupModalOpen ||
linkEmailOpen
);
$: if (!tariffMode && !$billingStore.selectedPlan && plans.length) {
billingStore.update((s) => ({ ...s, selectedPlan: plans[Math.min(1, plans.length - 1)] }));
}
@@ -298,7 +365,11 @@
paymentStep: s.paymentStep === "tariff" ? "checkout" : s.paymentStep,
}));
}
$: if (tariffMode && selectedTariffKey && !tariffCatalog.some((tariff) => tariff.key === selectedTariffKey)) {
$: if (
tariffMode &&
selectedTariffKey &&
!tariffCatalog.some((tariff) => tariff.key === selectedTariffKey)
) {
billingStore.update((s) => ({
...s,
selectedTariffKey: "",
@@ -306,7 +377,11 @@
paymentStep: singleTariffMode ? "checkout" : "tariff",
}));
}
$: if (tariffMode && selectedTariffKey && (!selectedPlan || selectedPlan.tariff_key !== selectedTariffKey)) {
$: if (
tariffMode &&
selectedTariffKey &&
(!selectedPlan || selectedPlan.tariff_key !== selectedTariffKey)
) {
billingStore.update((s) => ({ ...s, selectedPlan: selectedTariffPlans[0] || null }));
}
$: if (!$billingStore.selectedMethod && methods.length) {
@@ -357,7 +432,6 @@
};
});
function syncBodyScrollLock(locked) {
if (typeof document === "undefined") return;
if (locked && !scrollLockApplied) {
@@ -467,7 +541,8 @@
finalizeTelegramAuth: (authData, source) => authStore.finalizeTelegramAuth(authData, source),
setAuthStatus: (message, isError) => authStore.setAuthStatus(message, isError),
t,
getInitDataForBoot: () => telegramMiniAppInitData || tg?.initData || readTelegramMiniAppInitDataFromLocation(),
getInitDataForBoot: () =>
telegramMiniAppInitData || tg?.initData || readTelegramMiniAppInitDataFromLocation(),
getToken: () => token,
getCsrfToken: () => csrfToken,
});
@@ -494,7 +569,10 @@
paymentStep: "tariff",
selectedMethod: payload.payment_methods?.[0]?.id || "",
}));
let section = MOCK && query.get("screen") ? normalizeSection(query.get("screen")) : sectionFromPath(window.location.pathname);
let section =
MOCK && query.get("screen")
? normalizeSection(query.get("screen"))
: sectionFromPath(window.location.pathname);
if (section === "admin" && !payload.user?.is_admin) section = "settings";
if (section === "devices" && !payload.settings?.my_devices_enabled) section = "home";
activeTab = section === "admin" ? "settings" : section;
@@ -503,7 +581,7 @@
syncSectionPath(
section,
true,
section === "admin" ? adminSectionFromPath(window.location.pathname) : null,
section === "admin" ? adminSectionFromPath(window.location.pathname) : null
);
if (section === "devices" && payload.settings?.my_devices_enabled) {
await devicesStore.loadDevices(true);
@@ -520,9 +598,9 @@
const tariffModeLocal = plansList.some((plan) => plan?.tariff_key);
const hasTariffSub = Boolean(
tariffModeLocal &&
sub?.active &&
sub?.tariff_key &&
tariffCatalogLocal.some((t) => t.key === sub.tariff_key),
sub?.active &&
sub?.tariff_key &&
tariffCatalogLocal.some((t) => t.key === sub.tariff_key)
);
const canRegular =
hasTariffSub &&
@@ -707,7 +785,14 @@
}
function openPaymentModal() {
billingStore.openPaymentModal(tariffMode, singleTariffMode, tariffCatalog, subscription, plans, defaultPaymentMethod());
billingStore.openPaymentModal(
tariffMode,
singleTariffMode,
tariffCatalog,
subscription,
plans,
defaultPaymentMethod()
);
}
function openTopupModal(kind) {
@@ -767,11 +852,16 @@
function handleAdminSectionChange(adminSection, adminUserId = null) {
if (screen !== "admin") return;
if (window.location.protocol === "file:") return;
const targetPath = adminSection === "users" && adminUserId
? `/admin/users/${adminUserId}`
: `/admin/${adminSection}`;
const targetPath =
adminSection === "users" && adminUserId
? `/admin/users/${adminUserId}`
: `/admin/${adminSection}`;
if (window.location.pathname === targetPath) return;
window.history.pushState(null, "", `${targetPath}${window.location.search}${window.location.hash}`);
window.history.pushState(
null,
"",
`${targetPath}${window.location.search}${window.location.hash}`
);
}
async function handleAdminPersistedSaved() {
@@ -799,7 +889,6 @@
if (trafficMode || selectedPlan?.sale_mode === "traffic_package") return t("wa_buy_traffic");
return subscription.active ? t("wa_renew") : t("wa_pay_subscription");
}
</script>
<svelte:head>
@@ -812,250 +901,251 @@
<PreviewBoard config={CFG} mockData={DEV_MOCK.data} />
{:else}
<div class="app-shell" style={`--accent: ${accent};`}>
{#if mode === "loading"}
<div class="loader">
<BrandMark class="brand-mark-lg" logoUrl={CFG.logoUrl} emoji={brandEmoji} />
<div>{t("wa_loading")}</div>
</div>
{:else if mode === "login"}
<AuthScreen
{screen}
{CFG}
{brandTitle}
{brandEmoji}
bind:email={$authStore.email}
bind:emailCode={$authStore.emailCode}
{pendingEmail}
{authStatus}
{authIsError}
{authBusy}
{authResendCooldown}
{loginEmailFieldError}
{loginEmailTooltipOpen}
{telegramLoginBusy}
{telegramLoginUnavailable}
{telegramLoginChecking}
{telegramLoginLabel}
{telegramLoginUnavailableMessage}
{privacyPolicyUrl}
{userAgreementUrl}
{t}
requestEmailCode={() => authStore.requestEmailCode((s) => (screen = s))}
verifyEmailCode={authStore.verifyEmailCode}
openTelegramLogin={() => authStore.openTelegramLogin(telegramOAuthClientId, () => telegramMiniAppInitData)}
{openExternalLink}
{submitEmailOnEnter}
onBackToLogin={() => (screen = "login")}
clearLoginEmailError={() => {
loginEmailFieldError = "";
loginEmailTooltipOpen = false;
}}
/>
{:else if screen === "admin" && isAdmin}
<AdminPanel
api={api}
onClose={closeAdminPanel}
onToast={(text) => showToast(text)}
initialSection={adminSectionFromPath(window.location.pathname)}
initialUserId={adminUserIdFromPath(window.location.pathname)}
onSectionChange={handleAdminSectionChange}
onSettingsSaved={handleAdminPersistedSaved}
onTariffsSaved={handleAdminPersistedSaved}
brandTitle={brandTitle}
logoUrl={CFG.logoUrl}
logoEmoji={brandEmoji}
appVersion={CFG.appVersion}
appRepositoryUrl={CFG.appRepositoryUrl}
currentLang={currentLang}
languageOptions={languageOptions}
languageBusy={languageBusy}
onLanguageChange={accountStore.updateAccountLanguage}
t={t}
/>
{:else}
<WebAppShell
{screen}
{activeTab}
{CFG}
{brandTitle}
{brandEmoji}
{devicesEnabled}
{hasUnlinkedIdentity}
{isAdmin}
{openAdminPanel}
{goDevices}
{goHome}
{goInvite}
{goSettings}
{t}
>
{#if screen === "home"}
<HomeScreen
{#if mode === "loading"}
<div class="loader">
<BrandMark class="brand-mark-lg" logoUrl={CFG.logoUrl} emoji={brandEmoji} />
<div>{t("wa_loading")}</div>
</div>
{:else if mode === "login"}
<AuthScreen
{screen}
{CFG}
{appSettings}
{brandEmoji}
{brandTitle}
{canChangeTariff}
{currentTariffName}
{hasActiveTariffSubscription}
{hasMultipleTariffs}
{premiumTrafficTopupBarClickable}
{premiumTrafficTopupUnlocked}
{regularTrafficTopupBarClickable}
{regularTrafficTopupUnlocked}
{subscription}
{termUnitLabel}
{trafficMode}
{trialBusy}
{activateTrial}
{openConnectLink}
{openPaymentModal}
{openRegularTopupModal}
{openPremiumTopupModal}
{openTariffChangeModal}
{primaryPayActionLabel}
{t}
/>
{:else if screen === "invite"}
<InviteScreen
{referral}
{referralBonusDetails}
{referralOneBonusPerReferee}
{referralWelcomeBonusDays}
bind:promoCode
bind:promoFieldError
{promoBusy}
{promoIsError}
{promoStatus}
{applyPromo}
clearPromoFieldError={() => (promoFieldError = "")}
{copyText}
{t}
/>
{:else if screen === "devices"}
<DevicesScreen
{devicesBusy}
{devicesData}
{devicesIsError}
{devicesLoaded}
{devicesStatus}
{subscription}
{loadDevices}
openDeviceDisconnectDialog={devicesStore.openDeviceDisconnectDialog}
{openDeviceTopupModal}
{t}
/>
{:else if screen === "settings"}
<SettingsScreen
{currentLang}
{currentLanguageOption}
{emailLinkStatus}
{isAdmin}
{languageBusy}
{languageClickGuard}
{languageClickGuardArmed}
bind:languageMenuOpen
{languageOptions}
{linkEmailBusy}
{linkTelegramBusy}
{brandEmoji}
bind:email={$authStore.email}
bind:emailCode={$authStore.emailCode}
{pendingEmail}
{authStatus}
{authIsError}
{authBusy}
{authResendCooldown}
{loginEmailFieldError}
{loginEmailTooltipOpen}
{telegramLoginBusy}
{telegramLoginUnavailable}
{telegramLoginChecking}
{telegramLoginLabel}
{telegramLoginUnavailableMessage}
{privacyPolicyUrl}
{profileAvatarUrl}
{profileEmail}
{profileTelegramId}
{supportUrl}
{telegramProfileName}
{user}
{userAgreementUrl}
{userLanguage}
linkTelegramAccount={accountStore.linkTelegramAccount}
logout={accountStore.logout}
{openAdminPanel}
{openExternalLink}
openLinkEmailDialog={accountStore.openLinkEmailDialog}
{setLanguageMenuOpen}
{t}
updateAccountLanguage={accountStore.updateAccountLanguage}
requestEmailCode={() => authStore.requestEmailCode((s) => (screen = s))}
verifyEmailCode={authStore.verifyEmailCode}
openTelegramLogin={() =>
authStore.openTelegramLogin(telegramOAuthClientId, () => telegramMiniAppInitData)}
{openExternalLink}
{submitEmailOnEnter}
onBackToLogin={() => (screen = "login")}
clearLoginEmailError={() => {
loginEmailFieldError = "";
loginEmailTooltipOpen = false;
}}
/>
{:else if screen === "admin" && isAdmin}
<AdminPanel
{api}
onClose={closeAdminPanel}
onToast={(text) => showToast(text)}
initialSection={adminSectionFromPath(window.location.pathname)}
initialUserId={adminUserIdFromPath(window.location.pathname)}
onSectionChange={handleAdminSectionChange}
onSettingsSaved={handleAdminPersistedSaved}
onTariffsSaved={handleAdminPersistedSaved}
{brandTitle}
logoUrl={CFG.logoUrl}
logoEmoji={brandEmoji}
appVersion={CFG.appVersion}
appRepositoryUrl={CFG.appRepositoryUrl}
{currentLang}
{languageOptions}
{languageBusy}
onLanguageChange={accountStore.updateAccountLanguage}
{t}
/>
{:else}
<WebAppShell
{screen}
{activeTab}
{CFG}
{brandTitle}
{brandEmoji}
{devicesEnabled}
{hasUnlinkedIdentity}
{isAdmin}
{openAdminPanel}
{goDevices}
{goHome}
{goInvite}
{goSettings}
{t}
>
{#if screen === "home"}
<HomeScreen
{CFG}
{appSettings}
{brandEmoji}
{brandTitle}
{canChangeTariff}
{currentTariffName}
{hasActiveTariffSubscription}
{hasMultipleTariffs}
{premiumTrafficTopupBarClickable}
{premiumTrafficTopupUnlocked}
{regularTrafficTopupBarClickable}
{regularTrafficTopupUnlocked}
{subscription}
{termUnitLabel}
{trafficMode}
{trialBusy}
{activateTrial}
{openConnectLink}
{openPaymentModal}
{openRegularTopupModal}
{openPremiumTopupModal}
{openTariffChangeModal}
{primaryPayActionLabel}
{t}
/>
{:else if screen === "invite"}
<InviteScreen
{referral}
{referralBonusDetails}
{referralOneBonusPerReferee}
{referralWelcomeBonusDays}
bind:promoCode
bind:promoFieldError
{promoBusy}
{promoIsError}
{promoStatus}
{applyPromo}
clearPromoFieldError={() => (promoFieldError = "")}
{copyText}
{t}
/>
{:else if screen === "devices"}
<DevicesScreen
{devicesBusy}
{devicesData}
{devicesIsError}
{devicesLoaded}
{devicesStatus}
{subscription}
{loadDevices}
openDeviceDisconnectDialog={devicesStore.openDeviceDisconnectDialog}
{openDeviceTopupModal}
{t}
/>
{:else if screen === "settings"}
<SettingsScreen
{currentLang}
{currentLanguageOption}
{emailLinkStatus}
{isAdmin}
{languageBusy}
{languageClickGuard}
{languageClickGuardArmed}
bind:languageMenuOpen
{languageOptions}
{linkEmailBusy}
{linkTelegramBusy}
{privacyPolicyUrl}
{profileAvatarUrl}
{profileEmail}
{profileTelegramId}
{supportUrl}
{telegramProfileName}
{user}
{userAgreementUrl}
{userLanguage}
linkTelegramAccount={accountStore.linkTelegramAccount}
logout={accountStore.logout}
{openAdminPanel}
{openExternalLink}
openLinkEmailDialog={accountStore.openLinkEmailDialog}
{setLanguageMenuOpen}
{t}
updateAccountLanguage={accountStore.updateAccountLanguage}
/>
{/if}
</WebAppShell>
<PaymentDialogs
bind:linkEmailCode={$accountStore.linkEmailCode}
bind:linkEmailFieldError={$accountStore.linkEmailFieldError}
bind:linkEmailValue={$accountStore.linkEmailValue}
bind:paymentModalOpen={$billingStore.paymentModalOpen}
bind:paymentStep={$billingStore.paymentStep}
bind:selectedMethod={$billingStore.selectedMethod}
bind:selectedPlan={$billingStore.selectedPlan}
bind:selectedTariffKey={$billingStore.selectedTariffKey}
createPayment={billingStore.createPayment}
{deviceConfirmOpen}
{deviceDisconnectBusy}
{deviceToDisconnect}
{disconnectDevice}
{linkEmailBusy}
{linkEmailIsError}
{linkEmailOpen}
{linkEmailPending}
{linkEmailResendCooldown}
{linkEmailStatus}
{hasMultipleTariffs}
{methods}
{payBusy}
{plans}
{selectedTariff}
{selectedTariffPlans}
{singleTariffMode}
{subscription}
{tariffCatalog}
{tariffMode}
closeDeviceDisconnectDialog={devicesStore.closeDeviceDisconnectDialog}
closeLinkEmailDialog={accountStore.closeLinkEmailDialog}
closePaymentModal={billingStore.closePaymentModal}
{backToTariffList}
{continueWithSelectedTariff}
requestLinkEmailCode={accountStore.requestLinkEmailCode}
{selectTariff}
{t}
{termUnitLabel}
verifyLinkEmailCode={accountStore.verifyLinkEmailCode}
/>
<TariffDialogs
bind:changeConfirmOpen={$billingStore.changeConfirmOpen}
bind:changeModalOpen={$billingStore.changeModalOpen}
bind:deviceTopupModalOpen={$billingStore.deviceTopupModalOpen}
bind:selectedChangeAction={$billingStore.selectedChangeAction}
bind:selectedChangeTarget={$billingStore.selectedChangeTarget}
bind:selectedDeviceTopupPlan={$billingStore.selectedDeviceTopupPlan}
bind:selectedMethod={$billingStore.selectedMethod}
bind:selectedTopupPlan={$billingStore.selectedTopupPlan}
bind:topupModalOpen={$billingStore.topupModalOpen}
applyTariffChange={billingStore.applyTariffChange}
{changeOptions}
{closeDeviceTopupModal}
closeTariffChangeConfirm={billingStore.closeTariffChangeConfirm}
closeTariffChangeModal={billingStore.closeTariffChangeModal}
closeTopupModal={billingStore.closeTopupModal}
createDeviceTopupPayment={billingStore.createDeviceTopupPayment}
createTopupPayment={billingStore.createTopupPayment}
{deviceTopupOptions}
{methods}
openTariffChangeConfirm={billingStore.openTariffChangeConfirm}
{payBusy}
{singleTariffMode}
{subscription}
{tariffActionBusy}
{topupKind}
{topupOptions}
{trafficMode}
{t}
/>
{/if}
</WebAppShell>
<PaymentDialogs
bind:linkEmailCode={$accountStore.linkEmailCode}
bind:linkEmailFieldError={$accountStore.linkEmailFieldError}
bind:linkEmailValue={$accountStore.linkEmailValue}
bind:paymentModalOpen={$billingStore.paymentModalOpen}
bind:paymentStep={$billingStore.paymentStep}
bind:selectedMethod={$billingStore.selectedMethod}
bind:selectedPlan={$billingStore.selectedPlan}
bind:selectedTariffKey={$billingStore.selectedTariffKey}
createPayment={billingStore.createPayment}
{deviceConfirmOpen}
{deviceDisconnectBusy}
{deviceToDisconnect}
{disconnectDevice}
{linkEmailBusy}
{linkEmailIsError}
{linkEmailOpen}
{linkEmailPending}
{linkEmailResendCooldown}
{linkEmailStatus}
{hasMultipleTariffs}
{methods}
{payBusy}
{plans}
{selectedTariff}
{selectedTariffPlans}
{singleTariffMode}
{subscription}
{tariffCatalog}
{tariffMode}
closeDeviceDisconnectDialog={devicesStore.closeDeviceDisconnectDialog}
closeLinkEmailDialog={accountStore.closeLinkEmailDialog}
closePaymentModal={billingStore.closePaymentModal}
{backToTariffList}
{continueWithSelectedTariff}
requestLinkEmailCode={accountStore.requestLinkEmailCode}
{selectTariff}
{t}
{termUnitLabel}
verifyLinkEmailCode={accountStore.verifyLinkEmailCode}
/>
<TariffDialogs
bind:changeConfirmOpen={$billingStore.changeConfirmOpen}
bind:changeModalOpen={$billingStore.changeModalOpen}
bind:deviceTopupModalOpen={$billingStore.deviceTopupModalOpen}
bind:selectedChangeAction={$billingStore.selectedChangeAction}
bind:selectedChangeTarget={$billingStore.selectedChangeTarget}
bind:selectedDeviceTopupPlan={$billingStore.selectedDeviceTopupPlan}
bind:selectedMethod={$billingStore.selectedMethod}
bind:selectedTopupPlan={$billingStore.selectedTopupPlan}
bind:topupModalOpen={$billingStore.topupModalOpen}
applyTariffChange={billingStore.applyTariffChange}
{changeOptions}
{closeDeviceTopupModal}
closeTariffChangeConfirm={billingStore.closeTariffChangeConfirm}
closeTariffChangeModal={billingStore.closeTariffChangeModal}
closeTopupModal={billingStore.closeTopupModal}
createDeviceTopupPayment={billingStore.createDeviceTopupPayment}
createTopupPayment={billingStore.createTopupPayment}
{deviceTopupOptions}
{methods}
openTariffChangeConfirm={billingStore.openTariffChangeConfirm}
{payBusy}
{singleTariffMode}
{subscription}
{tariffActionBusy}
{topupKind}
{topupOptions}
{trafficMode}
{t}
/>
{/if}
{#if toastText}
<div class="toast" role="status">{toastText}</div>
{/if}
{#if toastText}
<div class="toast" role="status">{toastText}</div>
{/if}
</div>
{/if}
{/key}
+2 -2
View File
@@ -52,7 +52,7 @@
"brand-mark",
normalizedLogoUrl && !failed && !loaded && "brand-mark-loading",
normalizedLogoUrl && !failed && loaded && "brand-mark-loaded",
className,
className
)}
aria-busy={normalizedLogoUrl && !failed && !loaded ? "true" : undefined}
>
@@ -61,7 +61,7 @@
<span class="brand-mark-spinner" aria-hidden="true"></span>
{/if}
<img
class:loaded={loaded}
class:loaded
src={normalizedLogoUrl}
alt=""
loading="lazy"
+86 -24
View File
@@ -37,7 +37,13 @@
const methods = mockData.payment_methods || [];
const user = mockData.user || {};
const tariffs = [
["subscription", "Подписка", "Безлимитный трафик", "Идеально для постоянного использования", Zap],
[
"subscription",
"Подписка",
"Безлимитный трафик",
"Идеально для постоянного использования",
Zap,
],
["traffic", "Трафик", "Пакеты гигабайт", "Платите только за нужный объем", Database],
["premium", "Премиум", "Максимальная скорость", "Приоритетные серверы и поддержка", Crown],
];
@@ -49,11 +55,16 @@
];
const settingsRows = [
[Globe2, "Язык интерфейса", "Русский"],
[Send, "Привязка Telegram", user.telegram_linked ? `@${user.username || "username"}` : "Не привязан"],
[
Send,
"Привязка Telegram",
user.telegram_linked ? `@${user.username || "username"}` : "Не привязан",
],
[Mail, "Привязка почты", user.email || "Не привязана"],
[UserRound, "Выйти", "Завершить сессию"],
];
const previewTelegramName = user.first_name || (user.username ? `@${user.username}` : "Telegram не привязан");
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 || "";
@@ -74,11 +85,18 @@
<Card class="status-card">
<div class="sub-status">
<CheckCircle2 size={23} />
<div><h2>Подписка активна</h2><p>до {sub.end_date_text}</p></div>
<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>
<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>
@@ -93,7 +111,12 @@
</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="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">
@@ -111,12 +134,16 @@
<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>
<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>
<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>
@@ -126,12 +153,16 @@
<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>
<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>
<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>
@@ -139,9 +170,20 @@
<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 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">
@@ -154,19 +196,35 @@
</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>
<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>
<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>
<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>
<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}
@@ -180,7 +238,8 @@
<small>{previewEmail}</small>
<small>{previewTelegramId}</small>
</div>
</Card> <div class="settings-list">
</Card>
<div class="settings-list">
{#each settingsRows as row}
<div class="settings-row">
<svelte:component this={row[0]} size={20} />
@@ -194,11 +253,15 @@
<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>
<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="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>
@@ -208,11 +271,10 @@
<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>
<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>
+135 -32
View File
@@ -120,19 +120,49 @@
$: SECTION_META = {
stats: {
title: at("section_stats_title", {}, "Дашборд"),
subtitle: at("section_stats_subtitle", {}, "Аудитория, доходы, панель Remnawave и последние платежи"),
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", {}, "Каталог продаж, периоды, пакеты и лимиты"),
},
settings: {
title: at("section_settings_title", {}, "Настройки приложения"),
subtitle: at("section_settings_subtitle", {}, "Оверрайды над .env, применяются мгновенно"),
},
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", {}, "Каталог продаж, периоды, пакеты и лимиты") },
settings: { title: at("section_settings_title", {}, "Настройки приложения"), subtitle: at("section_settings_subtitle", {}, "Оверрайды над .env, применяются мгновенно") },
};
$: VALID_SECTIONS = (NAV_GROUPS || []).flatMap((group) => (group.items || []).map((item) => item.id));
$: 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);
@@ -176,7 +206,8 @@
$: syncBusy = $statsStore.syncBusy;
$: settingsSaving = $settingsStore.settingsSaving;
$: meta = SECTION_META[active] || { title: active, subtitle: "" };
$: currentLanguageOption = languageOptions.find((option) => option.value === currentLang) || languageOptions[0];
$: currentLanguageOption =
languageOptions.find((option) => option.value === currentLang) || languageOptions[0];
const gravatarCache = createGravatarCache(() => usersStore.updateState({}));
@@ -233,7 +264,10 @@
}
function resolvedAvatarUrl(user) {
return userAvatarUrl(user) || (!user?.telegram_id && user?.email ? gravatarCache.gravatarUrl(user.email) : "");
return (
userAvatarUrl(user) ||
(!user?.telegram_id && user?.email ? gravatarCache.gravatarUrl(user.email) : "")
);
}
function panelStatusBadge(user) {
@@ -245,7 +279,11 @@
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(
"expired_badge",
{ date: fmtDateShort(user.panel_status_expired_at) },
`Expired ${fmtDateShort(user.panel_status_expired_at)}`
)
: at("status_expired", {}, "Expired"),
variant: "warning",
};
@@ -318,7 +356,8 @@
}
return () => {
if (compactMql) {
if (compactMql.removeEventListener) compactMql.removeEventListener("change", onCompactChange);
if (compactMql.removeEventListener)
compactMql.removeEventListener("change", onCompactChange);
else if (compactMql.removeListener) compactMql.removeListener(onCompactChange);
}
if (typeof window !== "undefined") window.removeEventListener("popstate", onPopState);
@@ -326,14 +365,23 @@
};
});
$: if (active === "users" && initialUserId && (!$usersStore.openedUser || $usersStore.openedUser.user_id !== initialUserId)) {
$: 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", {}, "Закрыть меню")} on:click={() => (sidebarOpen = false)}></button>
<button
type="button"
class="admin-sidebar-backdrop"
aria-label={at("close_menu", {}, "Закрыть меню")}
on:click={() => (sidebarOpen = false)}
></button>
{/if}
{#if isCompact && (adminLanguageMenuOpen || adminLanguageClickGuard)}
<button
@@ -348,12 +396,17 @@
<aside class="admin-sidebar" aria-label={at("sidebar_navigation", {}, "Навигация админки")}>
<div class="admin-sidebar-brand">
<BrandMark class="admin-brand-mark" logoUrl={logoUrl} emoji={logoEmoji} />
<BrandMark class="admin-brand-mark" {logoUrl} emoji={logoEmoji} />
<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", {}, "Выйти")}>
<AdminButton
variant="ghost"
size="icon"
onclick={onClose}
aria-label={at("exit", {}, "Выйти")}
>
<ArrowLeft size={16} />
</AdminButton>
</div>
@@ -362,7 +415,12 @@
<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)}>
<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>
@@ -384,11 +442,16 @@
onOpenChange={setAdminLanguageMenuOpen}
onValueChange={onLanguageChange}
>
<Select.Trigger class="admin-language-trigger" aria-label={t("wa_settings_language", {}, at("language", {}, "Язык"))}>
<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>
<span class="emoji-flag" aria-hidden="true"
>{currentLanguageOption?.flag || "🏳️"}</span
>
{currentLanguageOption?.label || currentLang}
</small>
</span>
@@ -397,7 +460,11 @@
<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">
<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>
@@ -410,7 +477,13 @@
</Select.Root>
</div>
{/if}
<a class="admin-version-link" href={appRepositoryUrl} target="_blank" rel="noopener noreferrer" title="GitHub">
<a
class="admin-version-link"
href={appRepositoryUrl}
target="_blank"
rel="noopener noreferrer"
title="GitHub"
>
<span>remnawave-minishop</span>
<span>{appVersion || "dev+local"}</span>
</a>
@@ -420,7 +493,12 @@
<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", {}, "Меню")}>
<button
type="button"
class="admin-mobile-toggle"
on:click={() => (sidebarOpen = !sidebarOpen)}
aria-label={at("menu", {}, "Меню")}
>
<Menu size={18} />
</button>
<div class="admin-header-title">
@@ -431,7 +509,10 @@
<div class="admin-header-actions">
{#if active === "stats"}
<AdminButton onclick={statsStore.triggerSync} disabled={syncBusy}>
<RefreshCw size={14} /> {syncBusy ? at("btn_syncing", {}, "Синхронизация...") : at("btn_sync", {}, "Синхронизировать")}
<RefreshCw size={14} />
{syncBusy
? at("btn_syncing", {}, "Синхронизация...")
: at("btn_sync", {}, "Синхронизировать")}
</AdminButton>
{/if}
{#if active === "payments"}
@@ -441,25 +522,41 @@
{/if}
{#if active === "promos"}
<AdminButton variant="primary" onclick={() => promosStore.setCreateOpen(true)}>
<Plus size={14} /> {at("btn_create", {}, "Создать")}
<Plus size={14} />
{at("btn_create", {}, "Создать")}
</AdminButton>
{/if}
{#if active === "ads"}
<AdminButton variant="primary" onclick={() => adsStore.setCreateOpen(true)}>
<Plus size={14} /> {at("btn_campaign", {}, "Кампания")}
<Plus size={14} />
{at("btn_campaign", {}, "Кампания")}
</AdminButton>
{/if}
{#if active === "tariffs"}
<AdminButton variant="primary" onclick={tariffsStore.openCreateTariff}>
<Plus size={14} /> {at("btn_tariff", {}, "Тариф")}
<Plus size={14} />
{at("btn_tariff", {}, "Тариф")}
</AdminButton>
{/if}
{#if active === "settings"}
{#if dirtyCount}
<AdminBadge variant="warning">{at("settings_dirty_count", { count: dirtyCount }, "Изменений: " + dirtyCount)}</AdminBadge>
<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
variant="primary"
onclick={() => settingsStore.saveSettings(onSettingsSaved)}
disabled={!dirtyCount || settingsSaving}
>
<Save size={14} />
{settingsSaving
? at("btn_saving", {}, "Сохранение...")
: at("btn_save", {}, "Сохранить")}
</AdminButton>
{/if}
</div>
@@ -483,7 +580,13 @@
{/if}
{#if active === "payments"}
<PaymentsSection {at} {fmtDate} {fmtMoney} {paymentStatusVariant} onOpenUserCard={openPaymentUserCard} />
<PaymentsSection
{at}
{fmtDate}
{fmtMoney}
{paymentStatusVariant}
onOpenUserCard={openPaymentUserCard}
/>
{/if}
{#if active === "promos"}
@@ -16,13 +16,7 @@
const adsStore = getContext("adsStore");
$: ({
ads,
adsTotals,
adsLoading,
adCreateOpen,
adDraft,
} = $adsStore);
$: ({ ads, adsTotals, adsLoading, adCreateOpen, adDraft } = $adsStore);
$: adHeaders = [
at("id", {}, "ID"),
at("ads_col_source", {}, "Источник"),
@@ -41,9 +35,16 @@
<div class="admin-table-wrap">
{#if adsLoading}
<AdminTableSkeleton headers={adHeaders} rows={6} actionColumn widths={["44px", "96px", "110px", "70px", "54px", "54px", "72px", "92px"]} />
<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>
<AdminEmptyState tone="card"
><span class="admin-muted">{at("ads_empty", {}, "Кампаний нет")}</span></AdminEmptyState
>
{:else}
<AdminTable>
<thead>
@@ -63,10 +64,16 @@
<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 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_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>
@@ -99,23 +106,48 @@
<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 })} />
<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 })} />
<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) })} />
<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()}>
<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>
@@ -7,12 +7,7 @@
export let at;
const broadcastStore = getContext("broadcastStore");
$: ({
broadcastTarget,
broadcastText,
broadcastBusy,
broadcastResult,
} = $broadcastStore);
$: ({ broadcastTarget, broadcastText, broadcastBusy, broadcastResult } = $broadcastStore);
const BROADCAST_TARGET_OPTIONS = broadcastStore.BROADCAST_TARGET_OPTIONS;
</script>
@@ -36,14 +31,32 @@
<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>
<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
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>
<span class="admin-muted"
>{at("broadcast_stat_queued", {}, "В очереди")}: {broadcastResult.queued} · {at(
"broadcast_stat_failed",
{},
"Неудач"
)}: {broadcastResult.failed}</span
>
{/if}
</div>
</div>
@@ -1,19 +1,19 @@
<script>
import { getContext, onMount } from "svelte";
import { AdminButton, AdminEmptyState, AdminPagination, AdminTable, AdminTableSkeleton } from "$components/patterns/admin/index.js";
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);
$: ({ logs, logsTotal, logsPage, logsUserFilter, logsLoading } = $logsStore);
$: logsHasMore = logs.length > 0 && logsTotal > (logsPage + 1) * 50; // 50 is LOGS_PAGE_SIZE
$: logHeaders = [
@@ -37,10 +37,21 @@
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))}
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>
<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>
@@ -50,9 +61,15 @@
<div class="admin-table-wrap">
{#if logsLoading}
<AdminTableSkeleton headers={logHeaders} rows={10} widths={["120px", "120px", "58px", "58px", "220px"]} />
<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>
<AdminEmptyState tone="card"
><span class="admin-muted">{at("logs_empty", {}, "Записей нет")}</span></AdminEmptyState
>
{:else}
<AdminTable>
<thead>
@@ -68,10 +85,18 @@
{#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>
<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>
@@ -85,6 +110,10 @@
nextLabel={at("next", {}, "Далее")}
prevDisabled={logsPage === 0}
nextDisabled={!logsHasMore}
onPrev={() => { logsStore.setPage(Math.max(0, logsPage - 1)); }}
onNext={() => { logsStore.setPage(logsPage + 1); }}
onPrev={() => {
logsStore.setPage(Math.max(0, logsPage - 1));
}}
onNext={() => {
logsStore.setPage(logsPage + 1);
}}
/>
@@ -18,12 +18,7 @@
const paymentsStore = getContext("paymentsStore");
$: ({
payments,
paymentsTotal,
paymentsPage,
paymentsLoading,
} = $paymentsStore);
$: ({ payments, paymentsTotal, paymentsPage, paymentsLoading } = $paymentsStore);
$: paymentsHasMore = payments.length > 0 && paymentsTotal > (paymentsPage + 1) * 25; // 25 is PAYMENTS_PAGE_SIZE
@@ -59,7 +54,7 @@
return at(
"payments_desc_traffic_package_regular",
{ gb },
`Пакет трафика ${gb} ГБ (обычный)`,
`Пакет трафика ${gb} ГБ (обычный)`
);
}
if (pr != null && r == null) {
@@ -67,7 +62,7 @@
return at(
"payments_desc_traffic_package_premium",
{ gb },
`Пакет трафика ${gb} ГБ (премиум)`,
`Пакет трафика ${gb} ГБ (премиум)`
);
}
const raw = p.description && String(p.description).trim();
@@ -100,7 +95,9 @@
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>
<AdminEmptyState tone="card"
><span class="admin-muted">{at("payments_empty", {}, "Нет платежей")}</span></AdminEmptyState
>
{:else}
<AdminTable>
<thead>
@@ -136,18 +133,29 @@
<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_telegram_id", {}, "Telegram ID")}>
<td
class="admin-cell-mono"
data-label={at("payments_col_telegram_id", {}, "Telegram ID")}
>
{p.telegram_id != null ? p.telegram_id : "—"}
</td>
<td class="admin-cell-traffic-gb" data-label={at("payments_col_traffic_regular", {}, "Основной трафик")}>
<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", {}, "Премиум")}>
<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 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>
@@ -16,14 +16,8 @@
const promosStore = getContext("promosStore");
$: ({
promos,
promosTotal,
promosPage,
promosLoading,
promoCreateOpen,
promoDraft,
} = $promosStore);
$: ({ promos, promosTotal, promosPage, promosLoading, promoCreateOpen, promoDraft } =
$promosStore);
$: promosHasMore = promos.length < promosTotal;
$: promoHeaders = [
@@ -42,9 +36,16 @@
<div class="admin-table-wrap">
{#if promosLoading}
<AdminTableSkeleton headers={promoHeaders} rows={6} actionColumn widths={["92px", "52px", "64px", "96px", "72px", "92px"]} />
<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>
<AdminEmptyState tone="card"
><span class="admin-muted">{at("promos_empty", {}, "Промокодов нет")}</span></AdminEmptyState
>
{:else}
<AdminTable>
<thead>
@@ -61,9 +62,15 @@
{#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_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>
@@ -86,7 +93,9 @@
{/if}
{#if promosHasMore}
<div style="padding: 12px; text-align: center;">
<AdminButton onclick={() => promosStore.setPage(promosPage + 1)}>{at("btn_show_more", {}, "Показать еще")}</AdminButton>
<AdminButton onclick={() => promosStore.setPage(promosPage + 1)}
>{at("btn_show_more", {}, "Показать еще")}</AdminButton
>
</div>
{/if}
</div>
@@ -101,25 +110,55 @@
<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" />
<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) })} />
<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) })} />
<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) })} />
<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()}>
<AdminButton onclick={() => promosStore.setCreateOpen(false)}
>{at("btn_cancel", {}, "Отмена")}</AdminButton
>
<AdminButton
variant="primary"
onclick={promosStore.createPromo}
disabled={!promoDraft.code.trim()}
>
{at("btn_create", {}, "Создать")}
</AdminButton>
</div>
@@ -11,18 +11,14 @@
const settingsStore = getContext("settingsStore");
$: ({
settingsSections,
settingsLoading,
settingsDirty,
settingsSaving,
} = $settingsStore);
$: ({ settingsSections, settingsLoading, settingsDirty, settingsSaving } = $settingsStore);
let settingsOpenSections = [];
let settingsOpenSubsections = {};
let revealedSecrets = new Set();
$: settingsAllOpen = settingsSections.length > 0 && settingsOpenSections.length === settingsSections.length;
$: settingsAllOpen =
settingsSections.length > 0 && settingsOpenSections.length === settingsSections.length;
onMount(() => {
settingsStore.loadSettings().then(() => {
@@ -109,7 +105,9 @@
}
function fieldLabelText(field) {
const isEnglish = String(currentLang || "").toLowerCase().startsWith("en");
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;
}
@@ -121,14 +119,18 @@
<div class="admin-setting-meta">
<strong>
{fieldLabelText(field)}
<AdminBadge variant="warning">{at("settings_badge_secret", {}, "Secret")}</AdminBadge>
<AdminBadge variant="warning">{at("settings_badge_secret", {}, "Secret")}</AdminBadge>
{#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>
<small
>{field.i18n_description_key
? at(field.i18n_description_key, {}, field.description)
: field.description}</small
>
{/if}
</div>
<div class="admin-setting-control">
@@ -141,7 +143,11 @@
>
<Switch.Thumb class="admin-switch-thumb" />
</Switch.Root>
<span>{Boolean(valueFor(field)) ? at("enabled", {}, "Включено") : at("disabled", {}, "Выключено")}</span>
<span
>{valueFor(field)
? at("enabled", {}, "Включено")
: at("disabled", {}, "Выключено")}</span
>
</div>
{:else if field.type === "color"}
<input
@@ -193,7 +199,8 @@
{/if}
{#if isOverridden(field) || settingsDirty[field.key]}
<AdminButton size="sm" variant="ghost" onclick={() => settingsStore.resetField(field)}>
<X size={12} /> {at("reset", {}, "Сбросить")}
<X size={12} />
{at("reset", {}, "Сбросить")}
</AdminButton>
{/if}
</div>
@@ -201,18 +208,35 @@
{/snippet}
{#if settingsLoading || !settingsSections.length}
<AdminEmptyState>{settingsLoading ? at("loading", {}, "Загрузка…") : at("no_data", {}, "Нет данных")}</AdminEmptyState>
<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;">
<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. Кнопка «Сбросить» возвращает значение из переменных окружения.")}
{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", {}, "Развернуть всё")}
{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}>
<AdminButton
size="sm"
variant="primary"
onclick={() => settingsStore.saveSettings(onSettingsSaved)}
disabled={settingsSaving}
>
{settingsSaving ? at("saving", {}, "Сохранение...") : at("save", {}, "Сохранить")}
</AdminButton>
{/if}
@@ -227,7 +251,21 @@
<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}
{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>
@@ -246,18 +284,35 @@
<Accordion.Root
type="multiple"
value={settingsOpenSubsections[section.id] || []}
onValueChange={(v) => (settingsOpenSubsections = { ...settingsOpenSubsections, [section.id]: v })}
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 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}
{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>
@@ -23,11 +23,7 @@
const statsStore = getContext("statsStore");
$: ({
stats,
statsError,
statsLoading,
} = $statsStore);
$: ({ stats, statsError, statsLoading } = $statsStore);
$: showSkeleton = !stats && !statsError;
@@ -35,10 +31,8 @@
$: fin = stats?.financial || {};
$: users = stats?.users || {};
$: panelPayload = stats?.panel;
$: panelMetrics =
panelPayload && !panelPayload.error ? parsePanelSystem(panelPayload) : null;
$: panelBw =
panelPayload && !panelPayload.error ? parsePanelBandwidth(panelPayload) : null;
$: panelMetrics = panelPayload && !panelPayload.error ? parsePanelSystem(panelPayload) : null;
$: panelBw = panelPayload && !panelPayload.error ? parsePanelBandwidth(panelPayload) : null;
$: panelNodeTraffic =
panelPayload && !panelPayload.error ? parsePanelNodeTraffic(panelPayload) : null;
@@ -91,8 +85,7 @@
const bw = panel?.bandwidth;
if (!bw || typeof bw !== "object") return null;
const week = bw.bandwidthLastSevenDays?.current;
const month =
bw.bandwidthLast30Days?.current ?? bw.bandwidthLastThirtyDays?.current;
const month = bw.bandwidthLast30Days?.current ?? bw.bandwidthLastThirtyDays?.current;
if (week == null && month == null) return null;
return { week, month };
}
@@ -101,11 +94,9 @@
if (!row || typeof row !== "object") return 0;
const total = Number(row.total);
if (Number.isFinite(total) && total > 0) return total;
const up = Number(
row.uploadBytes ?? row.uplinkBytes ?? row.uplink ?? row.up ?? row.upload,
);
const up = Number(row.uploadBytes ?? row.uplinkBytes ?? row.uplink ?? row.up ?? row.upload);
const down = Number(
row.downloadBytes ?? row.downlinkBytes ?? row.downlink ?? row.down ?? row.download,
row.downloadBytes ?? row.downlinkBytes ?? row.downlink ?? row.down ?? row.download
);
const sum = (Number.isFinite(up) ? up : 0) + (Number.isFinite(down) ? down : 0);
return sum > 0 ? sum : 0;
@@ -117,10 +108,10 @@
const combined = Number(item.total ?? item.bytes ?? item.value);
if (Number.isFinite(combined) && combined > 0) return combined;
const up = Number(
item.uplink ?? item.upload ?? item.uploadBytes ?? item.up ?? item.tx ?? item.sent,
item.uplink ?? item.upload ?? item.uploadBytes ?? item.up ?? item.tx ?? item.sent
);
const down = Number(
item.downlink ?? item.download ?? item.downloadBytes ?? item.down ?? item.rx ?? item.received,
item.downlink ?? item.download ?? item.downloadBytes ?? item.down ?? item.rx ?? item.received
);
return (Number.isFinite(up) ? up : 0) + (Number.isFinite(down) ? down : 0);
}
@@ -140,7 +131,7 @@
sumTaggedStatsList(node.outbounds_stats);
if (b <= 0) b = panelRowBytes(node);
const life = Number(
node.totalBytesLifetime ?? node.totalBytes ?? node.bytesLifetime ?? node.totalTrafficBytes,
node.totalBytesLifetime ?? node.totalBytes ?? node.bytesLifetime ?? node.totalTrafficBytes
);
if (b <= 0 && Number.isFinite(life) && life > 0) b = life;
return b;
@@ -266,7 +257,7 @@
for (const row of rows) {
if (!row || typeof row !== "object") continue;
const key = String(
row.nodeUuid ?? row.node_uuid ?? row.uuid ?? row.nodeName ?? row.name ?? panelRowLabel(row),
row.nodeUuid ?? row.node_uuid ?? row.uuid ?? row.nodeName ?? row.name ?? panelRowLabel(row)
);
const prev = map.get(key) || { label: panelRowLabel(row), bytes: 0, stringHint: "" };
const add = isNodeMetricsShape(row) ? trafficBytesFromNodeRecord(row) : panelRowBytes(row);
@@ -386,7 +377,9 @@
return { seven: attachNodeOnlineToRows(buildNodeMetricsRows(raw.nodes), onlineLookup) };
}
if (Array.isArray(raw.lastSevenDays) && raw.lastSevenDays.length) {
return { seven: attachNodeOnlineToRows(aggregatePanelNodeRows(raw.lastSevenDays), onlineLookup) };
return {
seven: attachNodeOnlineToRows(aggregatePanelNodeRows(raw.lastSevenDays), onlineLookup),
};
}
}
@@ -423,7 +416,9 @@
const y = pad.t + innerH - (amt / maxY) * innerH;
return { x, y, amt, date: series[i]?.date };
});
const lineD = pts.map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join("");
const lineD = pts
.map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(1)},${p.y.toFixed(1)}`)
.join("");
const baseY = (pad.t + innerH).toFixed(1);
const areaD = `${lineD} L${pts[n - 1].x.toFixed(1)},${baseY} L${pts[0].x.toFixed(1)},${baseY} Z`;
const pickIdx = (() => {
@@ -460,11 +455,17 @@
<Card.Root class="admin-cn-card-skeleton">
<Card.Header>
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-short"></span>
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-strong" style="width:72%"></span>
<span
class="admin-skeleton admin-skeleton-line admin-skeleton-line-strong"
style="width:72%"
></span>
</Card.Header>
<Card.Footer class="admin-cn-card-footer--stack">
<span class="admin-skeleton admin-skeleton-line" style="width:88%"></span>
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-tiny" style="width:60%"></span>
<span
class="admin-skeleton admin-skeleton-line admin-skeleton-line-tiny"
style="width:60%"
></span>
</Card.Footer>
</Card.Root>
{/each}
@@ -472,7 +473,10 @@
<Card.Root class="admin-cn-card-skeleton">
<Card.Header>
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-short"></span>
<span class="admin-skeleton admin-skeleton-line admin-skeleton-line-strong" style="width:40%"></span>
<span
class="admin-skeleton admin-skeleton-line admin-skeleton-line-strong"
style="width:40%"
></span>
</Card.Header>
<Card.Content class="admin-cn-card-content--flush">
<div class="admin-revenue-svg-frame">
@@ -481,7 +485,11 @@
</Card.Content>
</Card.Root>
<AdminSectionHeader title={at("stats_recent_payments", {}, "")} />
<AdminTableSkeleton headers={recentPaymentHeaders} rows={6} widths={["48px", "120px", "78px", "82px", "72px", "96px"]} />
<AdminTableSkeleton
headers={recentPaymentHeaders}
rows={6}
widths={["48px", "120px", "78px", "82px", "72px", "96px"]}
/>
</AdminDashboardStack>
{:else if stats}
<AdminDashboardStack>
@@ -530,7 +538,11 @@
<Card.Description>{at("stats_label_inactive", {}, "")}</Card.Description>
<Card.Title>{users.inactive_users ?? 0}</Card.Title>
<Card.Action>
<Badge variant="outline">{users.total_users ? Math.round(((users.inactive_users ?? 0) / (users.total_users || 1)) * 100) : 0}%</Badge>
<Badge variant="outline"
>{users.total_users
? Math.round(((users.inactive_users ?? 0) / (users.total_users || 1)) * 100)
: 0}%</Badge
>
</Card.Action>
</Card.Header>
<Card.Footer class="admin-cn-card-footer--stack">
@@ -569,11 +581,15 @@
<Card.Content>
<div class="admin-revenue-kpis">
<div class="admin-revenue-kpi">
<div class="admin-revenue-kpi-label">{at("stats_trend_payments", { count: fin.today_payments_count ?? 0 }, "")}</div>
<div class="admin-revenue-kpi-label">
{at("stats_trend_payments", { count: fin.today_payments_count ?? 0 }, "")}
</div>
<div class="admin-revenue-kpi-value">{fin.today_payments_count ?? 0}</div>
</div>
<div class="admin-revenue-kpi">
<div class="admin-revenue-kpi-label">{at("stats_revenue_avg_ticket_label", {}, "")}</div>
<div class="admin-revenue-kpi-label">
{at("stats_revenue_avg_ticket_label", {}, "")}
</div>
<div class="admin-revenue-kpi-value">
{revenueKpis.avgToday != null ? fmtMoney(revenueKpis.avgToday, currency) : "—"}
</div>
@@ -619,8 +635,16 @@
<div class="admin-revenue-chart">
<div class="admin-revenue-chart-title">{at("stats_revenue_chart_title", {}, "")}</div>
{#if chartModel}
<div class="admin-revenue-svg-frame" role="img" aria-label={at("stats_revenue_chart_aria", {}, "")}>
<svg class="admin-revenue-svg" viewBox="0 0 {chartModel.W} {chartModel.H}" preserveAspectRatio="none">
<div
class="admin-revenue-svg-frame"
role="img"
aria-label={at("stats_revenue_chart_aria", {}, "")}
>
<svg
class="admin-revenue-svg"
viewBox="0 0 {chartModel.W} {chartModel.H}"
preserveAspectRatio="none"
>
<defs>
<linearGradient id="adminRevenueFillDashboard" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="var(--admin-muted)" stop-opacity="0.28" />
@@ -628,7 +652,15 @@
</linearGradient>
</defs>
{#each chartModel.gridYs as gy}
<line x1={chartModel.pad.l} y1={gy} x2={chartModel.W - chartModel.pad.r} y2={gy} stroke="var(--admin-border)" stroke-opacity="0.45" stroke-width="1" />
<line
x1={chartModel.pad.l}
y1={gy}
x2={chartModel.W - chartModel.pad.r}
y2={gy}
stroke="var(--admin-border)"
stroke-opacity="0.45"
stroke-width="1"
/>
{/each}
<path d={chartModel.areaD} fill="url(#adminRevenueFillDashboard)" stroke="none" />
<path
@@ -655,7 +687,11 @@
<AdminSectionHeader
title={at("stats_section_panel", {}, "")}
description={panelPayload?.error ? at("stats_panel_unavailable", {}, "") : panelMetrics ? at("stats_section_panel_hint", {}, "") : ""}
description={panelPayload?.error
? at("stats_panel_unavailable", {}, "")
: panelMetrics
? at("stats_section_panel_hint", {}, "")
: ""}
/>
{#if panelPayload?.error}
@@ -664,7 +700,11 @@
<Card.Root>
<Card.Content class="admin-cn-card-content admin-panel-dash-card">
<div class="admin-panel-dash">
<div class="admin-panel-dash-tiles" role="group" aria-label={at("stats_section_panel", {}, "")}>
<div
class="admin-panel-dash-tiles"
role="group"
aria-label={at("stats_section_panel", {}, "")}
>
<div class="admin-panel-dash-tile">
<div class="admin-panel-dash-tile-label">
<span class="admin-panel-dash-ico" aria-hidden="true"><Radio size={12} /></span>
@@ -678,7 +718,8 @@
</div>
<div class="admin-panel-dash-tile">
<div class="admin-panel-dash-tile-label">
<span class="admin-panel-dash-ico" aria-hidden="true"><Activity size={12} /></span>
<span class="admin-panel-dash-ico" aria-hidden="true"><Activity size={12} /></span
>
{at("stats_panel_total_users", {}, "")}
</div>
<div class="admin-panel-dash-tile-value">{panelMetrics.totalPanelUsers}</div>
@@ -698,7 +739,8 @@
{#if panelMetrics.nodesOnline != null}
<div class="admin-panel-dash-tile">
<div class="admin-panel-dash-tile-label">
<span class="admin-panel-dash-ico" aria-hidden="true"><Server size={12} /></span>
<span class="admin-panel-dash-ico" aria-hidden="true"><Server size={12} /></span
>
{at("stats_panel_nodes_online", {}, "")}
</div>
<div class="admin-panel-dash-tile-value">{panelMetrics.nodesOnline}</div>
@@ -722,13 +764,19 @@
{#if panelBw?.week != null}
<div class="admin-panel-dash-tile admin-panel-dash-tile--wide">
<div class="admin-panel-dash-tile-label">{at("stats_panel_bw_week", {}, "")}</div>
<div class="admin-panel-dash-tile-value admin-panel-dash-tile-value--sm">{panelBw.week}</div>
<div class="admin-panel-dash-tile-value admin-panel-dash-tile-value--sm">
{panelBw.week}
</div>
</div>
{/if}
{#if panelBw?.month != null}
<div class="admin-panel-dash-tile admin-panel-dash-tile--wide">
<div class="admin-panel-dash-tile-label">{at("stats_panel_bw_month", {}, "")}</div>
<div class="admin-panel-dash-tile-value admin-panel-dash-tile-value--sm">{panelBw.month}</div>
<div class="admin-panel-dash-tile-label">
{at("stats_panel_bw_month", {}, "")}
</div>
<div class="admin-panel-dash-tile-value admin-panel-dash-tile-value--sm">
{panelBw.month}
</div>
</div>
{/if}
</div>
@@ -736,8 +784,12 @@
{#if panelNodeTraffic?.seven?.length}
<div class="admin-panel-dash-nodes">
<div class="admin-panel-dash-nodes-head">
<h3 class="admin-panel-dash-nodes-title">{at("stats_panel_inner_nodes", {}, "")}</h3>
<p class="admin-panel-dash-nodes-hint">{at("stats_panel_inner_nodes_hint", {}, "")}</p>
<h3 class="admin-panel-dash-nodes-title">
{at("stats_panel_inner_nodes", {}, "")}
</h3>
<p class="admin-panel-dash-nodes-hint">
{at("stats_panel_inner_nodes_hint", {}, "")}
</p>
</div>
<div class="admin-panel-dash-nodes-grid">
{#each panelNodeTraffic.seven.slice(0, PANEL_NODE_TILE_LIMIT) as node}
@@ -754,7 +806,11 @@
</div>
{#if panelNodeTraffic.seven.length > PANEL_NODE_TILE_LIMIT}
<p class="admin-panel-dash-nodes-more">
{at("stats_panel_nodes_overflow", { count: panelNodeTraffic.seven.length - PANEL_NODE_TILE_LIMIT }, "")}
{at(
"stats_panel_nodes_overflow",
{ count: panelNodeTraffic.seven.length - PANEL_NODE_TILE_LIMIT },
""
)}
</p>
{/if}
</div>
@@ -767,22 +823,35 @@
{/if}
<Card.Root>
<Card.Content class="admin-cn-card-content--flush" style="padding-top:12px;padding-bottom:12px;">
<Card.Content
class="admin-cn-card-content--flush"
style="padding-top:12px;padding-bottom:12px;"
>
<div class="admin-sync-strip" style="border:0;background:transparent;padding:0;">
<span
><strong>{at("stats_sync_label", {}, "")}:</strong>
{stats.panel_sync?.status ?? "—"}{#if stats.panel_sync?.last_sync_time}
· {at("stats_sync_last", {}, "")}: {fmtDateShort(stats.panel_sync.last_sync_time)}{/if}</span
· {at("stats_sync_last", {}, "")}: {fmtDateShort(
stats.panel_sync.last_sync_time
)}{/if}</span
>
{#if stats.panel_sync && (stats.panel_sync.users_processed > 0 || stats.panel_sync.subscriptions_synced > 0)}
<span
>{at("stats_sync_processed", { users: stats.panel_sync.users_processed, subs: stats.panel_sync.subscriptions_synced }, "")}</span
>{at(
"stats_sync_processed",
{
users: stats.panel_sync.users_processed,
subs: stats.panel_sync.subscriptions_synced,
},
""
)}</span
>
{/if}
{#if stats.queue}
<span
><strong>{at("stats_label_queue", {}, "")}:</strong>
{stats.queue.user_queue_size ?? 0}{at("stats_queue_users", {}, "")}, {stats.queue.group_queue_size ?? 0}{at("stats_queue_groups", {}, "")}</span
{stats.queue.user_queue_size ?? 0}{at("stats_queue_users", {}, "")}, {stats.queue
.group_queue_size ?? 0}{at("stats_queue_groups", {}, "")}</span
>
{/if}
</div>
@@ -791,13 +860,25 @@
<Card.Root>
<Card.Header class="admin-cn-card-header--lead">
<Card.Title class="admin-cn-card-title--section">{at("stats_recent_payments", {}, "")}</Card.Title>
<Card.Description>{at("stats_records_count", { count: (stats.recent_payments || []).length }, "")}</Card.Description>
<Card.Title class="admin-cn-card-title--section"
>{at("stats_recent_payments", {}, "")}</Card.Title
>
<Card.Description
>{at(
"stats_records_count",
{ count: (stats.recent_payments || []).length },
""
)}</Card.Description
>
</Card.Header>
<Card.Content class="admin-cn-card-content--flush">
<div class="admin-table-wrap">
{#if statsLoading}
<AdminTableSkeleton headers={recentPaymentHeaders} rows={5} widths={["48px", "120px", "78px", "82px", "72px", "96px"]} />
<AdminTableSkeleton
headers={recentPaymentHeaders}
rows={5}
widths={["48px", "120px", "78px", "82px", "72px", "96px"]}
/>
{:else if (stats.recent_payments || []).length}
<AdminTable>
<thead>
@@ -826,7 +907,9 @@
</tbody>
</AdminTable>
{:else}
<AdminEmptyState tone="card"><span class="admin-muted">{at("no_data", {}, "")}</span></AdminEmptyState>
<AdminEmptyState tone="card"
><span class="admin-muted">{at("no_data", {}, "")}</span></AdminEmptyState
>
{/if}
</div>
</Card.Content>
@@ -5,10 +5,10 @@
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,
@@ -31,37 +31,70 @@
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 после подтверждения")}
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.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} />
<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>
<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}
@@ -79,36 +112,74 @@
<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>
<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} />
<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} />
<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} />
<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} />
<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>
<small
>{panelSquadsLoading
? at("loading_squads", {}, "Загружаю список из панели…")
: at(
"tariff_hint_squads",
{},
"Сквады Remnawave, к которым подключается пользователь по этому тарифу. Выберите один или несколько"
)}</small
>
<AdminSelect
bind:value={$tariffsStore.selectedBaseSquad}
items={panelSquadOptions}
@@ -121,8 +192,13 @@
/>
<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
type="button"
class="admin-chip"
on:click={() => tariffsStore.removeSquadFromDraft("squadUuids", uuid)}
>
{tariffsStore.squadLabel(uuid)}
<X size={12} />
</button>
{/each}
</div>
@@ -131,20 +207,58 @@
<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} />
<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} />
<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} />
<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>
@@ -154,26 +268,62 @@
<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>
<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} />
<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} />
<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>
<small
>{at(
"tariff_hint_premium_squads",
{},
"Сквады из Remnawave, доступные только владельцам этого тарифа. Трафик считается по их accessible nodes"
)}</small
>
<AdminSelect
bind:value={$tariffsStore.selectedPremiumSquad}
items={panelSquadOptions}
@@ -186,16 +336,40 @@
/>
<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
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} />
<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>
@@ -204,11 +378,26 @@
<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>
<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>
<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">
@@ -223,14 +412,37 @@
{/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>
<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>
<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>
@@ -240,9 +452,34 @@
{/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>
<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>
@@ -256,14 +493,31 @@
<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>
<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
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>
<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">
@@ -274,10 +528,38 @@
</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", {}, "Удалить")}>
<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>
@@ -290,16 +572,31 @@
<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>
<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>
<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>
<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>
@@ -309,14 +606,37 @@
{/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>
<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>
<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>
@@ -326,9 +646,30 @@
{/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>
<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>
@@ -342,17 +683,34 @@
<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>
<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>
<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>
<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>
@@ -362,14 +720,37 @@
{/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>
<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>
<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>
@@ -379,16 +760,43 @@
{/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>
<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>
<p class="admin-muted">
{at(
"tariff_topup_traffic_hint",
{},
"Для трафиковой модели отдельные «докупки» не нужны — пакеты, которые вы настроили на вкладке «Цены», и являются докупками: пользователь покупает их повторно по мере исчерпания."
)}
</p>
{/if}
</Tabs.Content>
@@ -396,12 +804,32 @@
<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>
<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>
<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">
@@ -416,14 +844,41 @@
{/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>
<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>
<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>
@@ -433,9 +888,34 @@
{/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>
<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>
@@ -445,9 +925,18 @@
</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 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>
@@ -455,15 +944,24 @@
<Dialog
open={tariffDeleteOpen}
title={at("tariff_delete_title", {}, "Удалить тариф?")}
description={tariffDeleteTarget ? at("tariff_delete_subtitle", { key: tariffDeleteTarget.key }, `Тариф ${tariffDeleteTarget.key} исчезнет из каталога продаж.`) : ""}
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 onclick={() => tariffsStore.updateState({ tariffDeleteOpen: false })}
>{at("btn_cancel", {}, "Отмена")}</AdminButton
>
<AdminButton variant="danger" onclick={tariffsStore.deleteTariff} disabled={tariffsSaving}>
<Trash2 size={14} /> {at("btn_confirm_delete", {}, "Подтвердить удаление")}
<Trash2 size={14} />
{at("btn_confirm_delete", {}, "Подтвердить удаление")}
</AdminButton>
</div>
</Dialog>
@@ -8,12 +8,7 @@
const tariffsStore = getContext("tariffsStore");
$: ({
tariffsCatalog,
tariffsLoading,
tariffsPath,
tariffsSaving,
} = $tariffsStore);
$: ({ tariffsCatalog, tariffsLoading, tariffsPath, tariffsSaving } = $tariffsStore);
$: enabledTariffs = (tariffsCatalog.tariffs || []).filter((tariff) => tariff.enabled !== false);
$: disabledTariffs = Math.max(0, (tariffsCatalog.tariffs || []).length - enabledTariffs.length);
@@ -26,7 +21,9 @@
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", {}, "Пакеты трафика");
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
@@ -52,17 +49,23 @@
<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>
<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>
<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>
<span class="admin-stat-trend"
>{at("tariffs_stat_disabled_hint", {}, "Скрыто с витрины")}</span
>
</div>
</div>
@@ -73,18 +76,33 @@
<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
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
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-файл каталога.")}
{at(
"tariffs_catalog_empty",
{},
"Каталог пуст. Добавьте первый тариф, после сохранения будет создан JSON-файл каталога."
)}
</AdminEmptyState>
{:else}
<div class="admin-tariff-grid">
@@ -95,7 +113,9 @@
<div class="admin-tariff-title">
<strong>{tariffName(tariff)}</strong>
{#if tariff.key === tariffsCatalog.default_tariff}
<AdminBadge variant="success">{at("status_default", {}, "Default")}</AdminBadge>
<AdminBadge variant="success"
>{at("status_default", {}, "Default")}</AdminBadge
>
{/if}
</div>
<code>{tariff.key}</code>
@@ -106,32 +126,60 @@
<AdminBadge variant="success">{at("status_active", {}, "Активен")}</AdminBadge>
{/if}
</div>
<p>{tariff.descriptions?.ru || tariff.descriptions?.en || at("no_description", {}, "Без описания")}</p>
<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
>{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>
<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
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}
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 })}
onclick={() =>
tariffsStore.updateState({
tariffDeleteTarget: tariff,
tariffDeleteOpen: true,
})}
disabled={tariffsSaving}
aria-label={at("btn_delete_tariff", {}, "Удалить тариф")}
>
File diff suppressed because it is too large Load Diff
@@ -69,7 +69,10 @@
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: "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", {}, "Премиум: исчерпан") },
@@ -110,9 +113,16 @@
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())}
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>
<AdminButton
variant="primary"
onclick={() => {
usersStore.updateState({ usersPage: 0 });
usersStore.loadUsers();
}}>{at("find", {}, "Найти")}</AdminButton
>
</div>
<div class="admin-toolbar-controls">
@@ -123,7 +133,10 @@
items={USERS_FILTER_OPTIONS}
class="admin-toolbar-select"
ariaLabel={at("filter", {}, "Фильтр")}
onValueChange={(value) => { usersStore.updateState({ usersFilter: value, usersPage: 0 }); usersStore.loadUsers(); }}
onValueChange={(value) => {
usersStore.updateState({ usersFilter: value, usersPage: 0 });
usersStore.loadUsers();
}}
/>
</Label.Root>
@@ -134,18 +147,26 @@
items={USERS_PANEL_STATUS_OPTIONS}
class="admin-toolbar-select"
ariaLabel={at("panel_status", {}, "Статус панели")}
onValueChange={(value) => { usersStore.updateState({ usersPanelStatus: value, usersPage: 0 }); usersStore.loadUsers(); }}
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>
<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(); }}
onValueChange={(value) => {
usersStore.updateState({ usersPremiumTraffic: value, usersPage: 0 });
usersStore.loadUsers();
}}
/>
</Label.Root>
@@ -156,7 +177,10 @@
items={USERS_SORT_OPTIONS}
class="admin-toolbar-select"
ariaLabel={at("sort", {}, "Сортировка")}
onValueChange={(value) => { usersStore.updateState({ usersSort: value, usersPage: 0 }); usersStore.loadUsers(); }}
onValueChange={(value) => {
usersStore.updateState({ usersSort: value, usersPage: 0 });
usersStore.loadUsers();
}}
/>
</Label.Root>
@@ -175,7 +199,10 @@
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>
<AdminEmptyState tone="card"
><span class="admin-muted">{at("users_empty", {}, "Никого не найдено")}</span
></AdminEmptyState
>
{:else}
<AdminTable class="admin-users-table">
<thead>
@@ -219,19 +246,30 @@
</div>
</div>
</td>
<td class="admin-users-cell-premium" data-label={at("premium_traffic_filter_label", {}, "Премиум трафик")}>
<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">
<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>
<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", {}, "Регистрация")}>
<td
class="admin-users-cell-date admin-cell-mono"
data-label={at("users_col_registration", {}, "Регистрация")}
>
{fmtDateShort(user.registration_date)}
</td>
</tr>
@@ -247,8 +285,14 @@
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(); }}
onPrev={() => {
usersStore.updateState({ usersPage: Math.max(0, usersPage - 1) });
usersStore.loadUsers();
}}
onNext={() => {
usersStore.updateState({ usersPage: usersPage + 1 });
usersStore.loadUsers();
}}
/>
<style>
@@ -36,18 +36,18 @@ export function createPromosStore({ api, onToast }) {
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 }
promoDraft: { code: "", bonus_days: 7, max_activations: 1, valid_days: 30 },
}));
await loadPromos();
} else {
@@ -63,7 +63,7 @@ export function createPromosStore({ api, onToast }) {
if (res?.ok) {
state.update((s) => ({
...s,
promos: s.promos.map((p) => (p.id === promo.id ? res.promo : p))
promos: s.promos.map((p) => (p.id === promo.id ? res.promo : p)),
}));
} else {
onToast(res?.error || "Ошибка");
@@ -75,7 +75,7 @@ export function createPromosStore({ api, onToast }) {
if (res?.ok) {
state.update((s) => ({
...s,
promos: s.promos.filter((p) => p.id !== promo.id)
promos: s.promos.filter((p) => p.id !== promo.id),
}));
onToast("Промокод удалён");
} else {
@@ -40,10 +40,13 @@ export function createSettingsStore({ api, onToast, at }) {
async function saveSettings(onSettingsSaved) {
let dirty = {};
state.update(s => { dirty = s.settingsDirty; return s; });
state.update((s) => {
dirty = s.settingsDirty;
return s;
});
if (!Object.keys(dirty).length) return;
state.update(s => ({ ...s, settingsSaving: true }));
state.update((s) => ({ ...s, settingsSaving: true }));
try {
const updates = {};
const deletes = [];
@@ -57,17 +60,19 @@ export function createSettingsStore({ api, onToast, at }) {
});
if (res?.ok) {
onToast(at("settings_saved", {}, "Настройки сохранены"));
state.update(s => ({ ...s, settingsDirty: {} }));
state.update((s) => ({ ...s, settingsDirty: {} }));
if (onSettingsSaved) await onSettingsSaved({ updates, deletes });
await loadSettings();
} else if (res?.errors) {
const summary = Object.entries(res.errors).map(([k, v]) => `${k}: ${v}`).join("; ");
const summary = Object.entries(res.errors)
.map(([k, v]) => `${k}: ${v}`)
.join("; ");
onToast(`Ошибки: ${summary}`);
} else {
onToast(res?.error || "Ошибка");
}
} finally {
state.update(s => ({ ...s, settingsSaving: false }));
state.update((s) => ({ ...s, settingsSaving: false }));
}
}
@@ -26,9 +26,12 @@ export function createStatsStore({ api, onToast, at }) {
async function triggerSync() {
let busy = false;
state.update(s => { busy = s.syncBusy; return s; });
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" });
@@ -52,15 +52,18 @@ export function createTariffsStore({ api, onToast, onTariffsSaved, flash, at })
async function loadPanelSquads() {
let loading = false;
state.update(s => { loading = s.panelSquadsLoading; return s; });
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 || [] }));
if (data?.ok) state.update((s) => ({ ...s, panelSquads: data.squads || [] }));
} catch (e) {
state.update(s => ({ ...s, panelSquads: [] }));
state.update((s) => ({ ...s, panelSquads: [] }));
} finally {
state.update((s) => ({ ...s, panelSquadsLoading: false }));
}
@@ -68,14 +71,17 @@ export function createTariffsStore({ api, onToast, onTariffsSaved, flash, at })
function squadLabel(uuid) {
let squads = [];
state.update(s => { squads = s.panelSquads; return s; });
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 => {
state.update((s) => {
const current = normalizeUuidList(s.tariffDraft[field]);
if (current.includes(uuid)) return s;
return { ...s, tariffDraft: { ...s.tariffDraft, [field]: [...current, uuid] } };
@@ -83,13 +89,13 @@ export function createTariffsStore({ api, onToast, onTariffsSaved, flash, at })
}
function removeSquadFromDraft(field, uuid) {
state.update(s => {
state.update((s) => {
return {
...s,
tariffDraft: {
...s.tariffDraft,
[field]: normalizeUuidList(s.tariffDraft[field]).filter((item) => item !== uuid),
}
},
};
});
}
@@ -97,15 +103,18 @@ export function createTariffsStore({ api, onToast, onTariffsSaved, flash, at })
async function persistTariffs(nextCatalog, successText) {
state.update((s) => ({ ...s, tariffsSaving: true }));
let currentPath = "";
state.update(s => { currentPath = s.tariffsPath; return s; });
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 => ({
state.update((s) => ({
...s,
tariffsCatalog: cloneCatalog(res.catalog),
tariffsPath: res.path || currentPath,
@@ -116,7 +125,9 @@ export function createTariffsStore({ api, onToast, onTariffsSaved, flash, at })
if (onTariffsSaved) await onTariffsSaved(res.catalog);
flash(successText || at("tariffs_saved", {}, "Тарифы сохранены"));
} else {
flash(res?.message || res?.error || at("tariffs_save_failed", {}, "Ошибка сохранения тарифов"));
flash(
res?.message || res?.error || at("tariffs_save_failed", {}, "Ошибка сохранения тарифов")
);
}
} finally {
state.update((s) => ({ ...s, tariffsSaving: false }));
@@ -124,7 +135,7 @@ export function createTariffsStore({ api, onToast, onTariffsSaved, flash, at })
}
function openCreateTariff() {
state.update(s => ({
state.update((s) => ({
...s,
tariffEditingKey: "",
tariffDraft: emptyTariffDraft(),
@@ -136,7 +147,7 @@ export function createTariffsStore({ api, onToast, onTariffsSaved, flash, at })
}
function openEditTariff(tariff) {
state.update(s => ({
state.update((s) => ({
...s,
tariffEditingKey: tariff.key,
tariffDraft: draftFromTariff(tariff),
@@ -149,13 +160,18 @@ export function createTariffsStore({ api, onToast, onTariffsSaved, flash, at })
async function saveTariffDraft() {
let s;
state.update(st => { s = st; return st; });
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);
const existing = (s.tariffsCatalog.tariffs || []).find(
(item) => item.key === tariff.key && item.key !== s.tariffEditingKey
);
if (existing) {
flash(at("tariff_error_key_exists", {}, "Тариф с таким ключом уже есть"));
return;
@@ -169,68 +185,98 @@ export function createTariffsStore({ api, onToast, onTariffsSaved, flash, at })
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", {}, "Тариф сохранён"));
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; });
state.update((st) => {
s = st;
return st;
});
const tariffs = (s.tariffsCatalog.tariffs || []).map((item) =>
item.key === tariff.key ? { ...item, enabled: item.enabled === false } : 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", {}, "Статус тарифа обновлён"));
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; });
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", {}, "Тариф по умолчанию обновлён"));
await persistTariffs(
{ ...cloneCatalog(s.tariffsCatalog), default_tariff: key },
at("tariff_default_updated", {}, "Тариф по умолчанию обновлён")
);
}
async function deleteTariff() {
let s;
state.update(st => { s = st; return st; });
state.update((st) => {
s = st;
return st;
});
if (!s.tariffDeleteTarget) return;
const tariffs = (s.tariffsCatalog.tariffs || []).filter((item) => item.key !== s.tariffDeleteTarget.key);
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", {}, "Нельзя удалить последний включённый тариф"));
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", {}, "Тариф удалён"));
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 => ({
state.update((s) => ({
...s,
tariffDraft: { ...s.tariffDraft, [field]: [...(s.tariffDraft[field] || []), row] }
tariffDraft: { ...s.tariffDraft, [field]: [...(s.tariffDraft[field] || []), row] },
}));
}
function removeDraftRow(field, index) {
state.update(s => ({
state.update((s) => ({
...s,
tariffDraft: {
...s.tariffDraft,
[field]: (s.tariffDraft[field] || []).filter((_, idx) => idx !== index),
}
},
}));
}
function updateState(updates) {
state.update(s => ({ ...s, ...updates }));
state.update((s) => ({ ...s, ...updates }));
}
return {
@@ -50,7 +50,10 @@ export function createUsersStore({ api, onToast, at }) {
async function loadUsers() {
state.update((s) => ({ ...s, usersLoading: true }));
let s;
state.update((st) => { s = st; return st; });
state.update((st) => {
s = st;
return st;
});
try {
const params = new URLSearchParams({
@@ -59,14 +62,15 @@ export function createUsersStore({ api, onToast, at }) {
});
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.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 => ({
state.update((st) => ({
...st,
users: data.users || [],
usersTotal: data.total || (data.users || []).length,
@@ -78,12 +82,14 @@ export function createUsersStore({ api, onToast, at }) {
}
async function openUser(userOrId, opts = {}) {
const userId = typeof userOrId === "object" && userOrId !== null ? userOrId.user_id : Number(userOrId);
const userId =
typeof userOrId === "object" && userOrId !== null ? userOrId.user_id : Number(userOrId);
if (!userId) return;
state.update(s => ({
state.update((s) => ({
...s,
openedUser: typeof userOrId === "object" && userOrId !== null ? userOrId : { user_id: userId },
openedUser:
typeof userOrId === "object" && userOrId !== null ? userOrId : { user_id: userId },
openedUserDetail: null,
userMessageDraft: "",
userMessageConfirmOpen: false,
@@ -99,30 +105,31 @@ export function createUsersStore({ api, onToast, at }) {
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 => ({
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) : "",
premiumBonusGbDraft: bonusBytes > 0 ? +(bonusBytes / 1024 ** 3).toFixed(2) : "",
regularUnlimitedDraft: Boolean(sub?.regular_unlimited_override),
regularBonusGbDraft: regularBonusBytes > 0 ? +(regularBonusBytes / (1024 ** 3)).toFixed(2) : "",
regularBonusGbDraft:
regularBonusBytes > 0 ? +(regularBonusBytes / 1024 ** 3).toFixed(2) : "",
grantTrafficGbDraft: "",
grantTrafficKindDraft: "regular",
}));
} else {
onToast(res?.error || "load_failed");
state.update(s => ({ ...s, openedUser: null }));
state.update((s) => ({ ...s, openedUser: null }));
if (!opts.skipPush) _pushUserPath(null);
}
} finally {
state.update(s => ({ ...s, userDetailLoading: false }));
state.update((s) => ({ ...s, userDetailLoading: false }));
}
}
function closeUser(opts = {}) {
let wasOpen = false;
state.update(s => {
state.update((s) => {
wasOpen = Boolean(s.openedUser);
return {
...s,
@@ -150,27 +157,33 @@ export function createUsersStore({ api, onToast, at }) {
function requestBanToggle() {
let s;
state.update(st => { s = st; return st; });
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 }));
state.update((st) => ({ ...st, userBanConfirmOpen: true }));
}
}
async function applyBanToggle(banned) {
let s;
state.update(st => { s = st; return st; });
state.update((st) => {
s = st;
return st;
});
if (!s.openedUser) return;
state.update(st => ({ ...st, userActionBusy: true }));
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 => {
state.update((st) => {
const updatedUser = { ...st.openedUser, is_banned: banned };
return {
...st,
@@ -179,18 +192,23 @@ export function createUsersStore({ api, onToast, at }) {
userBanConfirmOpen: false,
};
});
onToast(banned ? at("user_banned", {}, "Заблокирован") : at("user_unbanned", {}, "Разблокирован"));
onToast(
banned ? at("user_banned", {}, "Заблокирован") : at("user_unbanned", {}, "Разблокирован")
);
} else onToast(res?.error || at("error", {}, "Ошибка"));
} finally {
state.update(st => ({ ...st, userActionBusy: false }));
state.update((st) => ({ ...st, userActionBusy: false }));
}
}
async function sendUserMessage() {
let s;
state.update(st => { s = st; return st; });
state.update((st) => {
s = st;
return st;
});
if (!s.openedUser || !s.userMessageDraft.trim()) return;
state.update(st => ({ ...st, userActionBusy: true }));
state.update((st) => ({ ...st, userActionBusy: true }));
try {
const res = await api(`/admin/users/${s.openedUser.user_id}/message`, {
method: "POST",
@@ -198,19 +216,19 @@ export function createUsersStore({ api, onToast, at }) {
});
if (res?.ok) {
onToast(at("message_sent", {}, "Отправлено"));
state.update(st => ({
state.update((st) => ({
...st,
userMessageDraft: "",
userMessageConfirmOpen: false,
}));
} else onToast(res?.error || at("message_send_failed", {}, "Ошибка отправки"));
} finally {
state.update(st => ({ ...st, userActionBusy: false }));
state.update((st) => ({ ...st, userActionBusy: false }));
}
}
function requestSendUserMessage() {
state.update(s => {
state.update((s) => {
if (!s.openedUser || !s.userMessageDraft.trim()) return s;
return { ...s, userMessageConfirmOpen: true };
});
@@ -218,9 +236,12 @@ export function createUsersStore({ api, onToast, at }) {
async function previewUserMessage() {
let s;
state.update(st => { s = st; return st; });
state.update((st) => {
s = st;
return st;
});
if (!s.openedUser || !s.userMessageDraft.trim()) return;
state.update(st => ({ ...st, userActionBusy: true }));
state.update((st) => ({ ...st, userActionBusy: true }));
try {
const res = await api(`/admin/users/${s.openedUser.user_id}/message/preview`, {
method: "POST",
@@ -229,17 +250,20 @@ export function createUsersStore({ api, onToast, at }) {
if (res?.ok) onToast(at("message_preview_sent", {}, "Превью отправлено в Telegram"));
else onToast(res?.error || at("message_preview_failed", {}, "Ошибка отправки превью"));
} finally {
state.update(st => ({ ...st, userActionBusy: false }));
state.update((st) => ({ ...st, userActionBusy: false }));
}
}
async function extendUser() {
let s;
state.update(st => { s = st; return st; });
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 }));
state.update((st) => ({ ...st, userActionBusy: true }));
try {
const res = await api(`/admin/users/${s.openedUser.user_id}/extend`, {
method: "POST",
@@ -250,34 +274,41 @@ export function createUsersStore({ api, onToast, at }) {
await openUser(s.openedUser, { skipPush: true });
} else onToast(res?.error || at("error", {}, "Ошибка"));
} finally {
state.update(st => ({ ...st, userActionBusy: false }));
state.update((st) => ({ ...st, userActionBusy: false }));
}
}
async function resetTrialUser() {
let s;
state.update(st => { s = st; return st; });
state.update((st) => {
s = st;
return st;
});
if (!s.openedUser) return;
state.update(st => ({ ...st, userActionBusy: true }));
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 }));
state.update((st) => ({ ...st, userActionBusy: false }));
}
}
async function savePremiumTrafficOverride() {
let s;
state.update(st => { s = st; return st; });
state.update((st) => {
s = st;
return st;
});
if (!s.openedUser) return;
state.update(st => ({ ...st, userActionBusy: true }));
state.update((st) => ({ ...st, userActionBusy: true }));
try {
const bonusGbRaw = s.premiumBonusGbDraft;
const bonusGb = bonusGbRaw === "" || bonusGbRaw === null || bonusGbRaw === undefined
? 0
: Number(bonusGbRaw);
const bonusGb =
bonusGbRaw === "" || bonusGbRaw === null || bonusGbRaw === undefined
? 0
: Number(bonusGbRaw);
if (Number.isNaN(bonusGb) || bonusGb < 0) {
onToast(at("premium_override_invalid_bonus", {}, "Некорректное значение GB"));
return;
@@ -296,22 +327,26 @@ export function createUsersStore({ api, onToast, at }) {
onToast(res?.error || at("error", {}, "Ошибка"));
}
} finally {
state.update(st => ({ ...st, userActionBusy: false }));
state.update((st) => ({ ...st, userActionBusy: false }));
}
}
async function saveRegularTrafficOverride() {
let s;
state.update(st => { s = st; return st; });
state.update((st) => {
s = st;
return st;
});
if (!s.openedUser) return;
state.update(st => ({ ...st, userActionBusy: true }));
state.update((st) => ({ ...st, userActionBusy: true }));
try {
const regGbRaw = s.regularBonusGbDraft;
const regularGb = regGbRaw === "" || regGbRaw === null || regGbRaw === undefined
? 0
: Number(regGbRaw);
const regularGb =
regGbRaw === "" || regGbRaw === null || regGbRaw === undefined ? 0 : Number(regGbRaw);
if (Number.isNaN(regularGb) || regularGb < 0) {
onToast(at("regular_override_invalid_bonus", {}, "Некорректное значение GB для основного трафика"));
onToast(
at("regular_override_invalid_bonus", {}, "Некорректное значение GB для основного трафика")
);
return;
}
const res = await api(`/admin/users/${s.openedUser.user_id}/regular-traffic-override`, {
@@ -328,13 +363,16 @@ export function createUsersStore({ api, onToast, at }) {
onToast(res?.error || at("error", {}, "Ошибка"));
}
} finally {
state.update(st => ({ ...st, userActionBusy: false }));
state.update((st) => ({ ...st, userActionBusy: false }));
}
}
async function grantTraffic() {
let s;
state.update(st => { s = st; return st; });
state.update((st) => {
s = st;
return st;
});
if (!s.openedUser) return;
const gbRaw = s.grantTrafficGbDraft;
const gb = Number(gbRaw);
@@ -343,7 +381,7 @@ export function createUsersStore({ api, onToast, at }) {
return;
}
const kind = s.grantTrafficKindDraft === "premium" ? "premium" : "regular";
state.update(st => ({ ...st, userActionBusy: true }));
state.update((st) => ({ ...st, userActionBusy: true }));
try {
const res = await api(`/admin/users/${s.openedUser.user_id}/traffic-grant`, {
method: "POST",
@@ -355,38 +393,41 @@ export function createUsersStore({ api, onToast, at }) {
? at("traffic_grant_premium_done", { gb }, `+${gb} ГБ премиум-трафика`)
: at("traffic_grant_regular_done", { gb }, `+${gb} ГБ трафика`)
);
state.update(st => ({ ...st, grantTrafficGbDraft: "" }));
state.update((st) => ({ ...st, grantTrafficGbDraft: "" }));
await openUser(s.openedUser, { skipPush: true });
} else {
onToast(res?.error || at("error", {}, "Ошибка"));
}
} finally {
state.update(st => ({ ...st, userActionBusy: false }));
state.update((st) => ({ ...st, userActionBusy: false }));
}
}
async function deleteUser() {
let s;
state.update(st => { s = st; return st; });
state.update((st) => {
s = st;
return st;
});
if (!s.openedUser) return;
state.update(st => ({ ...st, userActionBusy: true }));
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 => ({
state.update((st) => ({
...st,
users: st.users.filter((u) => u.user_id !== st.openedUser.user_id)
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 }));
state.update((st) => ({ ...st, userActionBusy: false }));
}
}
function updateState(updates) {
state.update(s => ({ ...s, ...updates }));
state.update((s) => ({ ...s, ...updates }));
}
return {
@@ -109,7 +109,7 @@ export function parseIntNumber(value, fallback = null) {
export function compactMap(obj) {
return Object.fromEntries(
Object.entries(obj).filter(([, value]) => value !== "" && value !== null && value !== undefined),
Object.entries(obj).filter(([, value]) => value !== "" && value !== null && value !== undefined)
);
}
@@ -139,7 +139,10 @@ export function normalizeUuidList(value) {
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 descriptions = compactMap({
ru: draft.descriptionRu.trim(),
en: draft.descriptionEn.trim(),
});
const premiumNames = compactMap({
ru: draft.premiumNameRu.trim(),
en: draft.premiumNameEn.trim(),
@@ -164,7 +167,7 @@ export function tariffFromDraft(draft) {
const premiumTopupPackages = packageSetFromRows(
draft.premiumTopupRubRows,
draft.premiumTopupStarsRows,
"gb",
"gb"
);
if (premiumTopupPackages) tariff.premium_topup_packages = premiumTopupPackages;
@@ -186,7 +189,9 @@ export function tariffFromDraft(draft) {
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]));
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 {
+7 -3
View File
@@ -1,6 +1,8 @@
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 || "—"}`);
return (
full || (user?.username ? `@${user.username}` : user?.email || `User #${user?.user_id || "—"}`)
);
}
export function userSecondaryName(user) {
@@ -36,7 +38,9 @@ export function createGravatarCache(onResolved = () => {}) {
}
function gravatarUrl(email) {
const key = String(email || "").trim().toLowerCase();
const key = String(email || "")
.trim()
.toLowerCase();
if (!key) return "";
if (cache.has(key)) return cache.get(key);
if (pending.has(key)) return "";
@@ -47,7 +51,7 @@ export function createGravatarCache(onResolved = () => {}) {
cache.set(key, `https://gravatar.com/avatar/${h}?d=identicon&s=80`);
onResolved();
})
.catch(() => pending.delete(key)),
.catch(() => pending.delete(key))
);
return "";
}
@@ -13,7 +13,7 @@
variant === "danger" && "admin-badge-danger",
variant === "warning" && "admin-badge-warning",
variant === "muted" && "admin-badge-muted",
className,
className
)}
{...$$restProps}
>
@@ -38,7 +38,7 @@
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
onclick={onclick}
{onclick}
{...$$restProps}
>
<slot />
@@ -7,11 +7,7 @@
</script>
<div
class={cn(
"admin-cn-dashboard-grid",
columns === 3 && "admin-cn-dashboard-grid--3",
className,
)}
class={cn("admin-cn-dashboard-grid", columns === 3 && "admin-cn-dashboard-grid--3", className)}
{...$$restProps}
>
<slot />
@@ -15,10 +15,12 @@
<span class="admin-pagination-meta">{meta}</span>
<div class="admin-pagination-buttons">
<AdminButton size="sm" disabled={prevDisabled} onclick={onPrev}>
<ChevronLeft size={14} /> {prevLabel}
<ChevronLeft size={14} />
{prevLabel}
</AdminButton>
<AdminButton size="sm" disabled={nextDisabled} onclick={onNext}>
{nextLabel} <ChevronRight size={14} />
{nextLabel}
<ChevronRight size={14} />
</AdminButton>
</div>
</div>
@@ -21,7 +21,10 @@
</script>
<Select.Root type="single" {value} {items} {disabled} onValueChange={handleValueChange}>
<Select.Trigger class={`admin-select-trigger ${className}`.trim()} aria-label={ariaLabel || placeholder}>
<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>
@@ -7,7 +7,11 @@
</script>
<div class="admin-table-wrap">
<table class={cn("admin-table", skeleton && "admin-table-skeleton", className)} aria-hidden={skeleton ? "true" : undefined} {...$$restProps}>
<table
class={cn("admin-table", skeleton && "admin-table-skeleton", className)}
aria-hidden={skeleton ? "true" : undefined}
{...$$restProps}
>
<slot />
</table>
</div>
@@ -20,7 +20,9 @@
<thead>
<tr>
{#each headers as header}
<th class:admin-cell-actions={actionColumn && header === headers[headers.length - 1]}>{header}</th>
<th class:admin-cell-actions={actionColumn && header === headers[headers.length - 1]}
>{header}</th
>
{/each}
</tr>
</thead>
@@ -12,7 +12,13 @@
$: 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={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>
@@ -41,8 +41,8 @@
{value}
items={options}
{disabled}
onOpenChange={onOpenChange}
onValueChange={onValueChange}
{onOpenChange}
{onValueChange}
>
<Select.Trigger class="language-select-trigger" aria-label={label}>
<span class="language-select-copy">
@@ -8,12 +8,16 @@
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("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("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 };
@@ -16,7 +16,7 @@
variant === "destructive" && "admin-cn-badge-destructive",
variant === "success" && "admin-cn-badge-success",
variant === "muted" && "admin-cn-badge-muted",
className,
className
)}
{...$$restProps}
>
@@ -36,11 +36,17 @@
</script>
{#if href}
<a class={cn(buttonVariants({ variant, size }), className)} {href} onclick={onclick} {...$$restProps}>
<a class={cn(buttonVariants({ variant, size }), className)} {href} {onclick} {...$$restProps}>
<slot />
</a>
{:else}
<button class={cn(buttonVariants({ variant, size }), className)} {type} {disabled} onclick={onclick} {...$$restProps}>
<button
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{onclick}
{...$$restProps}
>
<slot />
</button>
{/if}
@@ -14,7 +14,8 @@
{#if open}
<div class="dialog" role="dialog" aria-modal="true" aria-label={title}>
<button class="dialog-backdrop" type="button" aria-label={closeLabel} onclick={onclose}></button>
<button class="dialog-backdrop" type="button" aria-label={closeLabel} onclick={onclose}
></button>
<section class={cn("dialog-card", className)}>
<div class="dialog-head">
<div>
@@ -17,7 +17,7 @@
variant === "tiny" && "ui-skeleton-line ui-skeleton-tiny",
variant === "badge" && "ui-skeleton-badge",
variant === "dot" && "ui-skeleton-dot",
className,
className
)}
style={`${width ? `width:${width};` : ""}${height ? `height:${height};` : ""}`}
aria-hidden="true"
@@ -8,7 +8,12 @@
</script>
<span
class={cn("ui-spinner", size === "sm" && "ui-spinner-sm", size === "lg" && "ui-spinner-lg", className)}
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"}
@@ -1,4 +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 }));
billingStore.update((s) => ({
...s,
topupOptions: null,
deviceTopupOptions: null,
changeOptions: null,
}));
}
+11 -11
View File
@@ -19,26 +19,26 @@ export function structuredCloneSafe(value) {
export function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
export function applyFavicon(logoUrl, emoji) {
if (typeof document === 'undefined') return;
const favicon = document.getElementById('app-favicon');
if (typeof document === "undefined") return;
const favicon = document.getElementById("app-favicon");
if (!favicon) return;
const normalizedLogoUrl = String(logoUrl || '').trim();
const normalizedLogoUrl = String(logoUrl || "").trim();
if (normalizedLogoUrl) {
favicon.setAttribute('href', normalizedLogoUrl);
favicon.setAttribute("href", normalizedLogoUrl);
return;
}
const normalizedEmoji = String(emoji || '????').trim() || '????';
const normalizedEmoji = String(emoji || "????").trim() || "????";
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(normalizedEmoji)}</text></svg>`;
const encoded = encodeURIComponent(svg);
favicon.setAttribute('href', `data:image/svg+xml,${encoded}`);
favicon.setAttribute("href", `data:image/svg+xml,${encoded}`);
}
@@ -41,7 +41,9 @@ export function formatFraction(value) {
}
export function normalizedEmail(value) {
return String(value || "").trim().toLowerCase();
return String(value || "")
.trim()
.toLowerCase();
}
export function telegramName(profile, fallback) {
+6 -2
View File
@@ -4,7 +4,9 @@ import { unitPluralBucket } from "./plurals.js";
export function createI18n({ messages = {}, defaultLang = "ru", getLang = null } = {}) {
function normalizeLangCode(lang) {
const key = String(lang || "").trim().toLowerCase();
const key = String(lang || "")
.trim()
.toLowerCase();
if (!key) return defaultLang;
const base = key.split("-")[0];
if (LANGUAGE_LABELS[base]) return base;
@@ -31,7 +33,9 @@ export function createI18n({ messages = {}, defaultLang = "ru", getLang = null }
}
function languageName(code) {
const key = String(code || "").trim().toLowerCase();
const key = String(code || "")
.trim()
.toLowerCase();
if (!key) return t("wa_language_default");
return LANGUAGE_LABELS[key] || key.toUpperCase();
}
+83 -19
View File
@@ -9,7 +9,11 @@ function defaultClone(value) {
}
export async function mockApi(path, options = {}, context = {}) {
const { currentLang = "ru", normalizeLangCode = (value) => value || "ru", clone = defaultClone } = 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 = [
@@ -23,7 +27,13 @@ export async function mockApi(path, options = {}, context = {}) {
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 },
premium_traffic: {
state: "good",
unlimited: false,
used_bytes: 4 * 1073741824,
limit_bytes: 25 * 1073741824,
percent: 16,
},
},
{
user_id: 100200301,
@@ -35,7 +45,13 @@ export async function mockApi(path, options = {}, context = {}) {
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 },
premium_traffic: {
state: "warn",
unlimited: false,
used_bytes: 22 * 1073741824,
limit_bytes: 25 * 1073741824,
percent: 88,
},
},
{
user_id: 100200302,
@@ -55,13 +71,28 @@ export async function mockApi(path, options = {}, context = {}) {
ok: true,
users: { total_users: 248, active_subscriptions: 172, banned_users: 3 },
financial: { total_revenue: 186240, successful_payments_count: 934 },
panel_sync: { status: "success", last_sync_time: new Date().toISOString(), users_processed: 172, subscriptions_synced: 168 },
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() },
{
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 === "/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];
@@ -76,13 +107,39 @@ export async function mockApi(path, options = {}, context = {}) {
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" },
{
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" },
{
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",
@@ -108,8 +165,8 @@ export async function mockApi(path, options = {}, context = {}) {
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 },
prices_rub: { 1: 150, 3: 400 },
prices_stars: { 1: 0, 3: 0 },
enabled_periods: [1, 3],
enabled: true,
},
@@ -118,7 +175,8 @@ export async function mockApi(path, options = {}, context = {}) {
};
}
if (path === "/admin/settings") return { ok: true, sections: [] };
if (cleanPath.startsWith("/admin/")) return { ok: true, payments: [], promos: [], logs: [], campaigns: [], total: 0 };
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") {
@@ -129,21 +187,28 @@ export async function mockApi(path, options = {}, context = {}) {
}
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 (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 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");
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 === "/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 {}
DEV_MOCK.data.devices.devices = DEV_MOCK.data.devices.devices.filter((device) => device.token !== payload.token);
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 };
}
@@ -203,4 +268,3 @@ export async function mockApi(path, options = {}, context = {}) {
}
return { ok: false, error: "not_found" };
}
@@ -139,7 +139,9 @@ export const DEV_MOCK = {
};
export function applyPreviewMock(kind) {
const mode = String(kind || "").trim().toLowerCase();
const mode = String(kind || "")
.trim()
.toLowerCase();
if (mode === "traffic") {
DEV_MOCK.data.settings.traffic_mode = true;
DEV_MOCK.data.settings.trial_available = false;
@@ -157,10 +159,38 @@ export function applyPreviewMock(kind) {
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" },
{
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;
@@ -246,7 +276,13 @@ export function applyPreviewMock(kind) {
billing_model: "period",
monthly_gb: 300,
actions: [
{ mode: "recalc_days", kind: "free", title: "recalc_days", days_after: 10, remaining_days: 25 },
{
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" },
],
},
@@ -256,8 +292,21 @@ export function applyPreviewMock(kind) {
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" },
{
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",
},
],
},
],
@@ -269,9 +318,42 @@ export function applyPreviewMock(kind) {
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: "Стандарт" },
{
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 = {
@@ -280,8 +362,30 @@ export function applyPreviewMock(kind) {
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: "Стандарт" },
{
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") {
+30 -18
View File
@@ -1,47 +1,59 @@
import { ADMIN_SECTIONS, APP_SECTION_PATHS } from './constants.js';
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') {
const section = String(value || "")
.trim()
.toLowerCase();
if (
section === "invite" ||
section === "devices" ||
section === "settings" ||
section === "admin"
) {
return section;
}
return 'home';
return "home";
}
export function sectionFromPath(pathname) {
const normalizedPath = String(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;
.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 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';
return "stats";
}
export function adminUserIdFromPath(pathname) {
const normalized = String(pathname || '').toLowerCase().replace(/\/+$/, '');
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;
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 (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);
window.history[replace ? "replaceState" : "pushState"](null, "", nextUrl);
}
@@ -1,7 +1,23 @@
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 }) {
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,
@@ -19,7 +35,7 @@ export function createAccountStore({ api, publicApi, setToken, loadData, t, show
let linkEmailResendTimer = null;
function setLinkEmailStatus(message, isError = false) {
state.update(s => ({ ...s, linkEmailStatus: message, linkEmailIsError: isError }));
state.update((s) => ({ ...s, linkEmailStatus: message, linkEmailIsError: isError }));
}
function clearCooldownTimer() {
@@ -31,20 +47,20 @@ export function createAccountStore({ api, publicApi, setToken, loadData, t, show
function startCooldownTimer(seconds = 60) {
clearCooldownTimer();
state.update(s => ({ ...s, linkEmailResendCooldown: Math.max(0, Number(seconds || 60)) }));
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 }));
state.update((s) => ({ ...s, linkEmailResendCooldown: 0 }));
clearCooldownTimer();
return;
}
state.update(s => ({ ...s, linkEmailResendCooldown: s.linkEmailResendCooldown - 1 }));
state.update((s) => ({ ...s, linkEmailResendCooldown: s.linkEmailResendCooldown - 1 }));
}, 1000);
}
function openLinkEmailDialog(email) {
state.update(s => ({
state.update((s) => ({
...s,
linkEmailOpen: true,
linkEmailBusy: false,
@@ -60,7 +76,7 @@ export function createAccountStore({ api, publicApi, setToken, loadData, t, show
}
function closeLinkEmailDialog() {
state.update(s => ({
state.update((s) => ({
...s,
linkEmailOpen: false,
linkEmailBusy: false,
@@ -77,12 +93,14 @@ export function createAccountStore({ api, publicApi, setToken, loadData, t, show
async function requestLinkEmailCode() {
const s = get(state);
if (s.linkEmailPending && s.linkEmailResendCooldown > 0) return;
const normalized = String(s.linkEmailValue || "").trim().toLowerCase();
const normalized = String(s.linkEmailValue || "")
.trim()
.toLowerCase();
if (!normalized || !normalized.includes("@")) {
state.update(s => ({ ...s, linkEmailFieldError: t("wa_auth_invalid_email") }));
state.update((s) => ({ ...s, linkEmailFieldError: t("wa_auth_invalid_email") }));
return;
}
state.update(s => ({ ...s, linkEmailFieldError: "", linkEmailBusy: true }));
state.update((s) => ({ ...s, linkEmailFieldError: "", linkEmailBusy: true }));
setLinkEmailStatus(t("wa_auth_sending_code"));
try {
const response = await api("/account/email/request", {
@@ -90,19 +108,21 @@ export function createAccountStore({ api, publicApi, setToken, loadData, t, show
body: JSON.stringify({ email: normalized }),
});
if (!response?.ok) throw response;
state.update(s => ({ ...s, linkEmailPending: normalized, linkEmailCode: "" }));
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 }));
state.update((s) => ({ ...s, linkEmailBusy: false }));
}
}
async function verifyLinkEmailCode() {
const s = get(state);
const code = String(s.linkEmailCode || "").replace(/\\D/g, "").slice(0, 6);
const code = String(s.linkEmailCode || "")
.replace(/\\D/g, "")
.slice(0, 6);
if (!s.linkEmailPending) {
setLinkEmailStatus(t("wa_auth_send_code_failed"), true);
return;
@@ -111,7 +131,7 @@ export function createAccountStore({ api, publicApi, setToken, loadData, t, show
setLinkEmailStatus(t("wa_auth_enter_code_6digits"), true);
return;
}
state.update(s => ({ ...s, linkEmailBusy: true }));
state.update((s) => ({ ...s, linkEmailBusy: true }));
setLinkEmailStatus(t("wa_auth_checking_code"));
try {
const response = await api("/account/email/verify", {
@@ -126,12 +146,12 @@ export function createAccountStore({ api, publicApi, setToken, loadData, t, show
} catch (error) {
setLinkEmailStatus(emailError(error, t("wa_auth_invalid_code"), t), true);
} finally {
state.update(s => ({ ...s, linkEmailBusy: false }));
state.update((s) => ({ ...s, linkEmailBusy: false }));
}
}
async function linkTelegramAccountWithPayload(payload) {
state.update(s => ({ ...s, linkTelegramBusy: true }));
state.update((s) => ({ ...s, linkTelegramBusy: true }));
try {
const response = await api("/account/telegram/link", {
method: "POST",
@@ -144,7 +164,7 @@ export function createAccountStore({ api, publicApi, setToken, loadData, t, show
} catch (error) {
showToast(error?.message || t("wa_auth_telegram_not_confirmed"));
} finally {
state.update(s => ({ ...s, linkTelegramBusy: false }));
state.update((s) => ({ ...s, linkTelegramBusy: false }));
}
}
@@ -164,7 +184,7 @@ export function createAccountStore({ api, publicApi, setToken, loadData, t, show
showToast(t("wa_auth_telegram_not_configured"));
return;
}
state.update(s => ({ ...s, linkTelegramBusy: true }));
state.update((s) => ({ ...s, linkTelegramBusy: true }));
window.location.assign(buildTelegramOAuthStartUrl("link", getTg()));
}
@@ -173,7 +193,7 @@ export function createAccountStore({ api, publicApi, setToken, loadData, t, show
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 }));
state.update((s) => ({ ...s, languageBusy: true }));
try {
const response = await api("/account/language", {
method: "POST",
@@ -187,7 +207,7 @@ export function createAccountStore({ api, publicApi, setToken, loadData, t, show
} catch {
showToast(t("wa_settings_language_update_failed"));
} finally {
state.update(s => ({ ...s, languageBusy: false }));
state.update((s) => ({ ...s, languageBusy: false }));
}
}
@@ -17,7 +17,7 @@ export function createAuthStore({
getTg,
t,
currentLang,
clearManualLogoutFlag
clearManualLogoutFlag,
}) {
const state = writable({
authStatus: "",
@@ -66,14 +66,14 @@ export function createAuthStore({
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;
}
@@ -93,7 +93,7 @@ export function createAuthStore({
async function finalizeMagicLogin(loginToken) {
const s = get(state);
if (s.authBusy) return false;
state.update(s => ({ ...s, authBusy: true }));
state.update((s) => ({ ...s, authBusy: true }));
setAuthStatus(t("wa_auth_checking_login"));
try {
const payload = { token: loginToken };
@@ -110,7 +110,7 @@ export function createAuthStore({
} catch {
setAuthStatus(t("wa_auth_login_confirm_failed"), true);
} finally {
state.update(s => ({ ...s, authBusy: false }));
state.update((s) => ({ ...s, authBusy: false }));
}
return false;
}
@@ -118,7 +118,7 @@ export function createAuthStore({
async function finalizeTelegramAuth(authData, source = "auth_data", options = {}) {
const s = get(state);
if (s.authBusy) return false;
state.update(s => ({ ...s, authBusy: true }));
state.update((s) => ({ ...s, authBusy: true }));
setAuthStatus(t("wa_auth_checking_telegram"));
try {
const payload =
@@ -137,14 +137,21 @@ export function createAuthStore({
await loadData();
return true;
}
setAuthStatus(response.error === "banned" ? t("wa_auth_access_denied") : t("wa_auth_telegram_not_confirmed"), 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,
error?.name === "AbortError"
? t("wa_auth_telegram_timeout")
: t("wa_auth_telegram_unavailable"),
true
);
} finally {
state.update(s => ({ ...s, authBusy: false }));
state.update((s) => ({ ...s, authBusy: false }));
}
return false;
}
@@ -154,10 +161,19 @@ export function createAuthStore({
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 }));
state.update((s) => ({
...s,
loginEmailFieldError: t("wa_auth_invalid_email"),
loginEmailTooltipOpen: true,
}));
return;
}
state.update(s => ({ ...s, loginEmailFieldError: "", loginEmailTooltipOpen: false, authBusy: true }));
state.update((s) => ({
...s,
loginEmailFieldError: "",
loginEmailTooltipOpen: false,
authBusy: true,
}));
setAuthStatus(t("wa_auth_sending_code"));
try {
const payload = { email: normalized, language: currentLang() };
@@ -165,14 +181,14 @@ export function createAuthStore({
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: "" }));
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 }));
state.update((s) => ({ ...s, authBusy: false }));
}
}
@@ -183,7 +199,7 @@ export function createAuthStore({
setAuthStatus(t("wa_auth_enter_code_6digits"), true);
return;
}
state.update(s => ({ ...s, authBusy: true }));
state.update((s) => ({ ...s, authBusy: true }));
setAuthStatus(t("wa_auth_checking_code"));
try {
const payload = { email: s.pendingEmail, code };
@@ -197,7 +213,7 @@ export function createAuthStore({
} catch (error) {
setAuthStatus(emailError(error, t("wa_auth_invalid_code"), t), true);
} finally {
state.update(s => ({ ...s, authBusy: false }));
state.update((s) => ({ ...s, authBusy: false }));
}
}
@@ -208,15 +224,15 @@ export function createAuthStore({
const isTelegramMiniAppAttempt = telegramSdk.hasLaunchParams();
if (!isTelegramMiniAppAttempt && telegramOAuthClientId) {
state.update(s => ({ ...s, telegramLoginBusy: true }));
state.update((s) => ({ ...s, telegramLoginBusy: true }));
window.location.assign(buildTelegramOAuthStartUrl("login", getTg()));
window.setTimeout(() => {
state.update(s => ({ ...s, telegramLoginBusy: false }));
state.update((s) => ({ ...s, telegramLoginBusy: false }));
}, 1500);
return;
}
state.update(s => ({ ...s, telegramLoginBusy: true }));
state.update((s) => ({ ...s, telegramLoginBusy: true }));
const attemptId = startTelegramLoginWatchdog();
const loginTimeout = telegramSdk.createMiniAppAuthTimeout();
try {
@@ -250,11 +266,11 @@ export function createAuthStore({
loginTimeout.clear();
if (loginTimeout.timedOut) {
setAuthStatus(t("wa_auth_telegram_timeout"), true);
state.update(s => ({ ...s, authBusy: false }));
state.update((s) => ({ ...s, authBusy: false }));
}
if (isActiveTelegramLoginAttempt(attemptId)) {
stopTelegramLoginWatchdog(attemptId);
state.update(s => ({ ...s, telegramLoginBusy: false }));
state.update((s) => ({ ...s, telegramLoginBusy: false }));
}
}
}
@@ -25,7 +25,14 @@ export function createBillingStore({ billing, loadData, t, showToast, openExtern
let topupOptionsRequestId = 0;
function openPaymentModal(tariffMode, singleTariffMode, tariffCatalog, subscription, plans, defaultMethod = "") {
function openPaymentModal(
tariffMode,
singleTariffMode,
tariffCatalog,
subscription,
plans,
defaultMethod = ""
) {
state.update((s) => {
let step = s.paymentStep;
let plan = s.selectedPlan;
@@ -36,7 +43,11 @@ export function createBillingStore({ billing, loadData, t, showToast, openExtern
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)) {
} 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";
@@ -85,7 +96,11 @@ export function createBillingStore({ billing, loadData, t, showToast, openExtern
}
function backToTariffList(subscription, tariffCatalog = []) {
if (subscription?.active && subscription?.tariff_key && tariffCatalog.some((t) => t.key === subscription.tariff_key)) {
if (
subscription?.active &&
subscription?.tariff_key &&
tariffCatalog.some((t) => t.key === subscription.tariff_key)
) {
return;
}
state.update((s) => ({ ...s, paymentStep: "tariff" }));
@@ -163,26 +178,28 @@ export function createBillingStore({ billing, loadData, t, showToast, openExtern
async function createPayment() {
const s = get(state);
if (!s.selectedPlan || !s.selectedMethod || s.payBusy) return;
state.update(s => ({ ...s, payBusy: true }));
state.update((s) => ({ ...s, payBusy: true }));
try {
const response = await billing.postPayment(billing.planPaymentBody(s.selectedPlan, s.selectedMethod));
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 }));
state.update((s) => ({ ...s, paymentModalOpen: false }));
return;
} else {
if (!response.payment_url) throw response;
openExternalLink(response.payment_url);
}
state.update(s => ({ ...s, paymentModalOpen: false }));
state.update((s) => ({ ...s, paymentModalOpen: false }));
} catch (error) {
showToast(error?.message || t("wa_payment_create_failed"));
} finally {
state.update(s => ({ ...s, payBusy: false }));
state.update((s) => ({ ...s, payBusy: false }));
}
}
@@ -190,19 +207,28 @@ export function createBillingStore({ billing, loadData, t, showToast, openExtern
const s = get(state);
if (s.topupOptions?.topup_kind === kind) return;
const requestId = ++topupOptionsRequestId;
state.update(s => ({ ...s, tariffActionBusy: true, topupOptions: null, selectedTopupPlan: null }));
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 }));
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 }));
state.update((s) => ({ ...s, topupModalOpen: false }));
} finally {
if (requestId === topupOptionsRequestId) {
state.update(s => ({ ...s, tariffActionBusy: false }));
state.update((s) => ({ ...s, tariffActionBusy: false }));
}
}
}
@@ -210,40 +236,40 @@ export function createBillingStore({ billing, loadData, t, showToast, openExtern
async function createTopupPayment() {
const s = get(state);
if (!s.selectedTopupPlan || !s.selectedMethod || s.payBusy) return;
state.update(s => ({ ...s, payBusy: true }));
state.update((s) => ({ ...s, payBusy: true }));
try {
const response = await billing.postPayment(
billing.topupPaymentBody(s.selectedTopupPlan, s.selectedMethod, s.topupOptions?.tariff_key),
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 }));
state.update((s) => ({ ...s, topupModalOpen: false }));
} catch (error) {
showToast(error?.message || t("wa_payment_create_failed"));
} finally {
state.update(s => ({ ...s, payBusy: false }));
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 }));
state.update((s) => ({ ...s, tariffActionBusy: true }));
try {
const response = await billing.fetchTariffChangeOptions();
if (!response?.ok) throw response;
state.update(s => ({
...s,
changeOptions: response,
state.update((s) => ({
...s,
changeOptions: response,
selectedChangeTarget: response.targets?.[0] || null,
selectedChangeAction: response.targets?.[0]?.actions?.[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 }));
state.update((s) => ({ ...s, changeModalOpen: false }));
} finally {
state.update(s => ({ ...s, tariffActionBusy: false }));
state.update((s) => ({ ...s, tariffActionBusy: false }));
}
}
@@ -254,7 +280,7 @@ export function createBillingStore({ billing, loadData, t, showToast, openExtern
await createTariffChangePayment();
return;
}
state.update(s => ({ ...s, tariffActionBusy: true }));
state.update((s) => ({ ...s, tariffActionBusy: true }));
try {
const response = await billing.postTariffChange({
tariff_key: s.selectedChangeTarget.tariff_key,
@@ -262,72 +288,87 @@ export function createBillingStore({ billing, loadData, t, showToast, openExtern
});
if (!response?.ok) throw response;
showToast(t("wa_tariff_change_applied"));
state.update(s => ({ ...s, changeConfirmOpen: false, changeModalOpen: false, changeOptions: null }));
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 }));
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 }));
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 body = billing.changePaymentBody(
s.selectedChangeAction,
s.selectedChangeTarget,
s.selectedMethod
);
const response =
s.selectedChangeAction.mode === "buy_package" || s.selectedChangeAction.mode === "buy_period"
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 }));
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 }));
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 }));
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
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 }));
state.update((s) => ({ ...s, deviceTopupModalOpen: false }));
} finally {
state.update(s => ({ ...s, tariffActionBusy: false }));
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 }));
state.update((s) => ({ ...s, payBusy: true }));
try {
const response = await billing.postPayment(
billing.deviceTopupPaymentBody(s.selectedDeviceTopupPlan, s.selectedMethod, s.deviceTopupOptions?.tariff_key),
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 }));
state.update((s) => ({ ...s, deviceTopupModalOpen: false }));
} catch (error) {
showToast(error?.message || t("wa_payment_create_failed"));
} finally {
state.update(s => ({ ...s, payBusy: false }));
state.update((s) => ({ ...s, payBusy: false }));
}
}
@@ -355,6 +396,6 @@ export function createBillingStore({ billing, loadData, t, showToast, openExtern
openDeviceTopupModal,
closeDeviceTopupModal,
loadDeviceTopupOptions,
createDeviceTopupPayment
createDeviceTopupPayment,
};
}
@@ -15,38 +15,38 @@ export function createDevicesStore({ api, t, showToast }) {
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 }));
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 }));
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
state.update((s) => ({
...s,
devicesStatus: error?.message || t("wa_devices_load_failed"),
devicesIsError: true,
devicesLoaded: true,
}));
} finally {
state.update(s => ({ ...s, devicesBusy: false }));
state.update((s) => ({ ...s, devicesBusy: false }));
}
}
function openDeviceDisconnectDialog(device) {
state.update(s => ({ ...s, deviceToDisconnect: device, deviceConfirmOpen: true }));
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 }));
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 }));
state.update((s) => ({ ...s, deviceDisconnectBusy: true }));
try {
const response = await api("/devices/disconnect", {
method: "POST",
@@ -54,12 +54,17 @@ export function createDevicesStore({ api, t, showToast }) {
});
if (!response?.ok) throw response;
showToast(t("wa_device_disconnected"));
state.update(s => ({ ...s, deviceConfirmOpen: false, deviceToDisconnect: null, devicesLoaded: false }));
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 }));
state.update((s) => ({ ...s, deviceDisconnectBusy: false }));
}
}
+22 -4
View File
@@ -46,7 +46,12 @@ export function activeTariffName(sub, planList) {
}
export function priceLabel(plan, methodId = "") {
if (String(methodId || "").toLowerCase().includes("stars") && Number(plan?.stars_price || 0) > 0) {
if (
String(methodId || "")
.toLowerCase()
.includes("stars") &&
Number(plan?.stars_price || 0) > 0
) {
return `${Number(plan.stars_price)}`;
}
return formatMoney(plan?.price || 0, plan?.currency);
@@ -74,7 +79,10 @@ export function actionKey(action) {
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") });
return t("wa_sub_term_value_unit", {
value: String(months),
unit: termUnitLabel(months, "month"),
});
}
export function planDisplayTitle(plan, { trafficMode, t }) {
@@ -113,14 +121,24 @@ export function planUnitHint(plan, { trafficMode, selectedMethod, t }) {
) {
const gb = Number(plan?.traffic_gb || plan?.months || 0);
if (!gb) return "";
if (String(selectedMethod || "").toLowerCase().includes("stars") && Number(plan?.stars_price || 0) > 0) {
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) {
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")}`;
@@ -53,8 +53,8 @@ export function createTelegramSdk({
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),
return ["tgWebAppData", "tgWebAppVersion", "tgWebAppPlatform", "tgWebAppThemeParams"].some(
(key) => params.has(key)
);
});
if (detected) launchParamsDetected = true;
@@ -93,7 +93,7 @@ export function createTelegramSdk({
setStatus("unavailable");
resolveOnce(null);
},
{ once: true },
{ once: true }
);
if (!existingScript) {
+20 -6
View File
@@ -8,12 +8,18 @@ export function trafficPercent(sub) {
}
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" });
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();
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");
@@ -30,15 +36,23 @@ export function premiumTrafficPercent(sub) {
}
export function premiumTrafficLabel(sub, t) {
return t("wa_traffic_of", { used: sub?.premium_used || "0 GB", limit: sub?.premium_limit || "0 GB" });
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-серверы");
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));
const left = Math.max(
0,
Number(sub?.premium_limit_bytes || 0) - Number(sub?.premium_used_bytes || 0)
);
return formatTrafficBytes(left);
}
@@ -54,8 +54,10 @@ export async function runWebappBoot({
} else if (telegramAuthStatus) {
clearAuthQuery();
setAuthStatus(
telegramAuthStatus === "cancelled" ? t("wa_auth_telegram_cancelled") : t("wa_auth_telegram_not_confirmed"),
true,
telegramAuthStatus === "cancelled"
? t("wa_auth_telegram_cancelled")
: t("wa_auth_telegram_not_confirmed"),
true
);
}
@@ -5,7 +5,13 @@
</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>
<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>
@@ -1,5 +1,11 @@
<script>
import { Gift, Home, Settings as SettingsIcon, Shield, Smartphone } from "$components/ui/icons.js";
import {
Gift,
Home,
Settings as SettingsIcon,
Shield,
Smartphone,
} from "$components/ui/icons.js";
import BrandMark from "../BrandMark.svelte";
@@ -20,7 +26,7 @@
<nav class:bottom-nav-devices={devicesEnabled} class="bottom-nav" aria-label={t("wa_navigation")}>
<div class="rail-brand" aria-hidden="true">
<BrandMark logoUrl={logoUrl} emoji={logoEmoji} />
<BrandMark {logoUrl} emoji={logoEmoji} />
<strong>{brandTitle}</strong>
</div>
<button class:active={activeTab === "home"} type="button" onclick={onHome}>
@@ -37,7 +43,12 @@
<span>{t("wa_nav_devices")}</span>
</button>
{/if}
<button class:active={activeTab === "settings"} class="attention-wrap" type="button" onclick={onSettings}>
<button
class:active={activeTab === "settings"}
class="attention-wrap"
type="button"
onclick={onSettings}
>
{#if hasUnlinkedIdentity}
<span class="attention-dot nav-attention-dot" aria-hidden="true"></span>
{/if}
@@ -1,11 +1,23 @@
<script>
import { ArrowLeft, CheckCircle2, CircleX, LockKeyhole, RefreshCw, TriangleAlert } from "$components/ui/icons.js";
import {
ArrowLeft,
ArrowRight,
CheckCircle2,
CircleX,
LockKeyhole,
RefreshCw,
TriangleAlert,
} from "$components/ui/icons.js";
import { Tooltip } from "$components/ui/primitives.js";
import Button from "$components/ui/button.svelte";
import Dialog from "$components/ui/dialog.svelte";
import Input from "$components/ui/input.svelte";
import { EmptyCard, PaymentMethodGrid, StatusMessage } from "$components/patterns/webapp/index.js";
import {
EmptyCard,
PaymentMethodGrid,
StatusMessage,
} from "$components/patterns/webapp/index.js";
import {
planKey as planKeyFn,
planDisplayTitle as planDisplayTitleFn,
@@ -15,7 +27,6 @@
priceLabel as priceLabelFn,
} from "../lib/webapp/tariffs.js";
export let createPayment = () => {};
export let deviceConfirmOpen = false;
export let deviceDisconnectBusy = false;
@@ -46,17 +57,31 @@
export let tariffCatalog = [];
export let tariffMode = false;
export let trafficMode = false;
function priceLabel(plan) { return priceLabelFn(plan, selectedMethod); }
function planKey(plan) { return planKeyFn(plan); }
function planDisplayTitle(plan) { return planDisplayTitleFn(plan, { trafficMode, t }); }
function planSubtitle(plan) { return planSubtitleFn(plan, { t, termUnitLabel }); }
function planUnitHint(plan) { return planUnitHintFn(plan, { trafficMode, selectedMethod, t }); }
function tariffLimitLabel(tariff) { return tariffLimitLabelFn(tariff, { t }); }
function priceLabel(plan) {
return priceLabelFn(plan, selectedMethod);
}
function planKey(plan) {
return planKeyFn(plan);
}
function planDisplayTitle(plan) {
return planDisplayTitleFn(plan, { trafficMode, t });
}
function planSubtitle(plan) {
return planSubtitleFn(plan, { t, termUnitLabel });
}
function planUnitHint(plan) {
return planUnitHintFn(plan, { trafficMode, selectedMethod, t });
}
function tariffLimitLabel(tariff) {
return tariffLimitLabelFn(tariff, { t });
}
function paymentTitle() {
if (singleTariffMode) {
return selectedTariff?.billing_model === "traffic" ? t("wa_traffic_packages_title") : t("wa_subscription_title");
return selectedTariff?.billing_model === "traffic"
? t("wa_traffic_packages_title")
: t("wa_subscription_title");
}
if (tariffMode) return t("wa_tariffs_title");
return trafficMode ? t("wa_traffic_packages_title") : t("wa_subscription_title");
@@ -65,7 +90,9 @@
function paymentDescription() {
if (tariffMode) {
if (singleTariffMode) {
return selectedTariff?.billing_model === "traffic" ? t("wa_traffic_packages_choose") : t("wa_subscription_choose_period");
return selectedTariff?.billing_model === "traffic"
? t("wa_traffic_packages_choose")
: t("wa_subscription_choose_period");
}
return paymentStep === "checkout" && selectedTariff
? t("wa_tariff_choose_period_payment", { tariff: selectedTariff.title })
@@ -74,7 +101,6 @@
return trafficMode ? t("wa_traffic_packages_choose") : t("wa_subscription_choose_period");
}
export let closeDeviceDisconnectDialog = () => {};
export let closeLinkEmailDialog = () => {};
export let closePaymentModal = () => {};
@@ -121,7 +147,11 @@
</button>
{/each}
</div>
<Button class="wide bottom-action payment-submit-button" onclick={continueWithSelectedTariff} disabled={!selectedTariffKey}>
<Button
class="wide bottom-action payment-submit-button"
onclick={continueWithSelectedTariff}
disabled={!selectedTariffKey}
>
{t("wa_next")}
<ArrowRight size={17} />
</Button>
@@ -137,7 +167,9 @@
</button>
{/if}
{#if hasMultipleTariffs && selectedTariff}
<p class="tariff-step-caption">{t("wa_selected_tariff", { tariff: selectedTariff.title })}</p>
<p class="tariff-step-caption">
{t("wa_selected_tariff", { tariff: selectedTariff.title })}
</p>
{/if}
{/if}
{#if selectedTariffPlans.length}
@@ -162,12 +194,22 @@
</div>
<div class="payment-divider" aria-hidden="true"></div>
{#if methods.length}
<PaymentMethodGrid {methods} {selectedMethod} {t} onSelect={(id) => (selectedMethod = id)} />
<PaymentMethodGrid
{methods}
{selectedMethod}
{t}
onSelect={(id) => (selectedMethod = id)}
/>
{:else}
<EmptyCard>{t("wa_payment_methods_not_configured")}</EmptyCard>
{/if}
<Button class="wide bottom-action payment-submit-button" onclick={createPayment} disabled={!selectedPlan || !methods.length || payBusy}>
{t("wa_pay")} {selectedPlan ? priceLabel(selectedPlan) : ""}
<Button
class="wide bottom-action payment-submit-button"
onclick={createPayment}
disabled={!selectedPlan || !methods.length || payBusy}
>
{t("wa_pay")}
{selectedPlan ? priceLabel(selectedPlan) : ""}
<LockKeyhole size={17} />
</Button>
{:else}
@@ -199,12 +241,22 @@
</div>
<div class="payment-divider" aria-hidden="true"></div>
{#if methods.length}
<PaymentMethodGrid {methods} {selectedMethod} {t} onSelect={(id) => (selectedMethod = id)} />
<PaymentMethodGrid
{methods}
{selectedMethod}
{t}
onSelect={(id) => (selectedMethod = id)}
/>
{:else}
<EmptyCard>{t("wa_payment_methods_not_configured")}</EmptyCard>
{/if}
<Button class="wide bottom-action payment-submit-button" onclick={createPayment} disabled={!selectedPlan || !methods.length || payBusy}>
{t("wa_pay")} {selectedPlan ? priceLabel(selectedPlan) : ""}
<Button
class="wide bottom-action payment-submit-button"
onclick={createPayment}
disabled={!selectedPlan || !methods.length || payBusy}
>
{t("wa_pay")}
{selectedPlan ? priceLabel(selectedPlan) : ""}
<LockKeyhole size={17} />
</Button>
{/if}
@@ -215,18 +267,30 @@
open={deviceConfirmOpen}
title={t("wa_devices_disconnect_title")}
description={t("wa_devices_disconnect_desc", {
device: deviceToDisconnect?.display_name || t("wa_device_fallback_name", { index: deviceToDisconnect?.index || "" }),
device:
deviceToDisconnect?.display_name ||
t("wa_device_fallback_name", { index: deviceToDisconnect?.index || "" }),
})}
closeLabel={t("wa_close")}
onclose={closeDeviceDisconnectDialog}
class="payment-dialog-card"
>
<div class="payment-dialog-body">
<Button variant="outline" class="wide device-danger-button" onclick={disconnectDevice} disabled={deviceDisconnectBusy}>
<Button
variant="outline"
class="wide device-danger-button"
onclick={disconnectDevice}
disabled={deviceDisconnectBusy}
>
<CircleX size={17} />
{t("wa_devices_disconnect_confirm")}
</Button>
<Button variant="secondary" class="wide" onclick={closeDeviceDisconnectDialog} disabled={deviceDisconnectBusy}>
<Button
variant="secondary"
class="wide"
onclick={closeDeviceDisconnectDialog}
disabled={deviceDisconnectBusy}
>
{t("wa_cancel")}
</Button>
</div>
@@ -235,7 +299,9 @@
<Dialog
open={linkEmailOpen}
title={t("wa_link_email_modal_title")}
description={linkEmailPending ? t("wa_email_sent_to", { email: linkEmailPending }) : t("wa_link_email_modal_desc")}
description={linkEmailPending
? t("wa_email_sent_to", { email: linkEmailPending })
: t("wa_link_email_modal_desc")}
closeLabel={t("wa_close")}
onclose={closeLinkEmailDialog}
class={`payment-dialog-card${linkEmailPending ? " link-email-dialog-card" : ""}`}
@@ -264,7 +330,11 @@
{/if}
</Tooltip.Root>
</div>
<Button class="wide bottom-action payment-submit-button" onclick={requestLinkEmailCode} disabled={linkEmailBusy}>
<Button
class="wide bottom-action payment-submit-button"
onclick={requestLinkEmailCode}
disabled={linkEmailBusy}
>
{t("wa_send_code_email")}
</Button>
{:else}
@@ -284,7 +354,11 @@
{/each}
</span>
</label>
<Button class="wide bottom-action payment-submit-button" onclick={verifyLinkEmailCode} disabled={linkEmailBusy}>
<Button
class="wide bottom-action payment-submit-button"
onclick={verifyLinkEmailCode}
disabled={linkEmailBusy}
>
{t("wa_confirm")}
</Button>
</div>
@@ -8,12 +8,19 @@
priceLabel as priceLabelFn,
actionKey as actionKeyFn,
} from "../lib/webapp/tariffs.js";
import { premiumTitle as premiumTitleFn, trafficPercent as trafficPercentFn } from "../lib/webapp/traffic.js";
import {
premiumTitle as premiumTitleFn,
trafficPercent as trafficPercentFn,
} from "../lib/webapp/traffic.js";
import { formatCompactNumber } from "../lib/webapp/formatters.js";
import Card from "$components/ui/card.svelte";
import Dialog from "$components/ui/dialog.svelte";
import { DialogOptionsSkeleton, EmptyCard, PaymentMethodGrid } from "$components/patterns/webapp/index.js";
import {
DialogOptionsSkeleton,
EmptyCard,
PaymentMethodGrid,
} from "$components/patterns/webapp/index.js";
export let applyTariffChange = () => {};
export let changeConfirmOpen = false;
@@ -43,10 +50,18 @@
export let subscription = {};
export let trafficMode = false;
function priceLabel(plan) { return priceLabelFn(plan, selectedMethod); }
function planKey(plan) { return planKeyFn(plan); }
function planUnitHint(plan) { return planUnitHintFn(plan, { trafficMode, selectedMethod, t }); }
function actionKey(action) { return actionKeyFn(action); }
function priceLabel(plan) {
return priceLabelFn(plan, selectedMethod);
}
function planKey(plan) {
return planKeyFn(plan);
}
function planUnitHint(plan) {
return planUnitHintFn(plan, { trafficMode, selectedMethod, t });
}
function actionKey(action) {
return actionKeyFn(action);
}
function changeActionTitle(action) {
const mode = String(action?.mode || "");
@@ -54,13 +69,18 @@
return t("wa_tariff_change_recalc_days", { days: Number(action?.days_after || 0) });
}
if (mode === "convert_days_to_gb") {
return t("wa_tariff_change_convert_gb", { gb: formatCompactNumber(action?.converted_gb || 0) });
return t("wa_tariff_change_convert_gb", {
gb: formatCompactNumber(action?.converted_gb || 0),
});
}
if (mode === "paid_diff") {
return t("wa_tariff_change_pay_diff", { price: priceLabel(action) });
}
if (mode === "buy_package") {
return t("wa_tariff_change_buy_package", { gb: formatCompactNumber(action?.traffic_gb || 0), price: priceLabel(action) });
return t("wa_tariff_change_buy_package", {
gb: formatCompactNumber(action?.traffic_gb || 0),
price: priceLabel(action),
});
}
if (mode === "buy_period") {
return `${action?.title || ""} · ${priceLabel(action)}`;
@@ -76,9 +96,15 @@
];
const mode = String(selectedChangeAction.mode || "");
if (mode === "recalc_days") {
rows.push(t("wa_tariff_change_confirm_recalc", { days: Number(selectedChangeAction.days_after || 0) }));
rows.push(
t("wa_tariff_change_confirm_recalc", { days: Number(selectedChangeAction.days_after || 0) })
);
} else if (mode === "convert_days_to_gb") {
rows.push(t("wa_tariff_change_confirm_convert", { gb: formatCompactNumber(selectedChangeAction.converted_gb || 0) }));
rows.push(
t("wa_tariff_change_confirm_convert", {
gb: formatCompactNumber(selectedChangeAction.converted_gb || 0),
})
);
} else if (selectedChangeAction.kind === "payment") {
rows.push(t("wa_tariff_change_confirm_payment", { price: priceLabel(selectedChangeAction) }));
}
@@ -99,14 +125,18 @@
function deviceTopupModalDescription() {
if (!deviceTopupOptions) return "";
return deviceTopupOptions?.tariff_name ? t("wa_device_topup_for_tariff", { tariff: deviceTopupOptions.tariff_name }) : "";
return deviceTopupOptions?.tariff_name
? t("wa_device_topup_for_tariff", { tariff: deviceTopupOptions.tariff_name })
: "";
}
function tariffChangeModalDescription() {
if (!changeOptions) return "";
return changeOptions?.current ? t("wa_current_tariff", { tariff: changeOptions.current.title }) : "";
return changeOptions?.current
? t("wa_current_tariff", { tariff: changeOptions.current.title })
: "";
}
function isPremiumTopupContext() {
if (selectedTopupPlan?.sale_mode === "premium_topup") return true;
if (topupOptions?.topup_kind) return topupOptions.topup_kind === "premium";
@@ -115,17 +145,22 @@
function topupModalDescription() {
if (!topupOptions) return "";
if (isPremiumTopupContext()) return topupOptions?.tariff_name ? t("wa_topup_for_tariff", { tariff: topupOptions.tariff_name }) : "";
if (isPremiumTopupContext())
return topupOptions?.tariff_name
? t("wa_topup_for_tariff", { tariff: topupOptions.tariff_name })
: "";
if (singleTariffMode) return "";
return topupOptions?.tariff_name ? t("wa_topup_for_tariff", { tariff: topupOptions.tariff_name }) : "";
return topupOptions?.tariff_name
? t("wa_topup_for_tariff", { tariff: topupOptions.tariff_name })
: "";
}
function topupModalTitle() {
if (isPremiumTopupContext()) return premiumTitleFn({ ...subscription, ...(topupOptions || {}) }, t);
if (isPremiumTopupContext())
return premiumTitleFn({ ...subscription, ...(topupOptions || {}) }, t);
return t("wa_topup_traffic");
}
export let t = (key) => key;
</script>
@@ -139,7 +174,13 @@
>
<div class="payment-dialog-body">
{#if !changeOptions}
<DialogOptionsSkeleton label={t("wa_tariff_options_loading")} actions={2} rows={2} methods={0} showMeta={false} />
<DialogOptionsSkeleton
label={t("wa_tariff_options_loading")}
actions={2}
rows={2}
methods={0}
showMeta={false}
/>
{:else if changeOptions?.targets?.length}
<p class="section-kicker">{t("wa_tariff_change_targets_title")}</p>
<div class="tariff-action-list">
@@ -157,7 +198,11 @@
<strong>{target.title}</strong>
<small>{target.description}</small>
</span>
<em>{target.billing_model === "traffic" ? t("wa_tariff_model_traffic") : t("wa_tariff_model_period")}</em>
<em
>{target.billing_model === "traffic"
? t("wa_tariff_model_traffic")
: t("wa_tariff_model_period")}</em
>
</button>
{/each}
</div>
@@ -175,9 +220,17 @@
<span class="option-row-main">
<strong>{changeActionTitle(action)}</strong>
{#if action.mode === "recalc_days"}
<small>{t("wa_tariff_change_recalc_hint", { days: Number(action.remaining_days || 0) })}</small>
<small
>{t("wa_tariff_change_recalc_hint", {
days: Number(action.remaining_days || 0),
})}</small
>
{:else if action.mode === "convert_days_to_gb"}
<small>{t("wa_tariff_change_convert_hint", { days: Number(action.remaining_days || 0) })}</small>
<small
>{t("wa_tariff_change_convert_hint", {
days: Number(action.remaining_days || 0),
})}</small
>
{:else if action.kind === "payment"}
<small>{t("wa_tariff_change_payment_hint")}</small>
{/if}
@@ -189,9 +242,18 @@
{/each}
</div>
{#if selectedChangeAction?.kind === "payment"}
<PaymentMethodGrid {methods} {selectedMethod} {t} onSelect={(id) => (selectedMethod = id)} />
<PaymentMethodGrid
{methods}
{selectedMethod}
{t}
onSelect={(id) => (selectedMethod = id)}
/>
{/if}
<Button class="wide bottom-action payment-submit-button" onclick={openTariffChangeConfirm} disabled={tariffActionBusy || payBusy}>
<Button
class="wide bottom-action payment-submit-button"
onclick={openTariffChangeConfirm}
disabled={tariffActionBusy || payBusy}
>
{selectedChangeAction?.kind === "payment" ? t("wa_pay") : t("wa_apply")}
<ArrowRight size={17} />
</Button>
@@ -218,11 +280,22 @@
<p>{row}</p>
{/each}
</Card>
<Button class="wide bottom-action payment-submit-button" onclick={applyTariffChange} disabled={tariffActionBusy || payBusy}>
{selectedChangeAction?.kind === "payment" ? t("wa_confirm_and_pay") : t("wa_confirm_and_apply")}
<Button
class="wide bottom-action payment-submit-button"
onclick={applyTariffChange}
disabled={tariffActionBusy || payBusy}
>
{selectedChangeAction?.kind === "payment"
? t("wa_confirm_and_pay")
: t("wa_confirm_and_apply")}
<ArrowRight size={17} />
</Button>
<Button variant="secondary" class="wide" onclick={closeTariffChangeConfirm} disabled={tariffActionBusy || payBusy}>
<Button
variant="secondary"
class="wide"
onclick={closeTariffChangeConfirm}
disabled={tariffActionBusy || payBusy}
>
{t("wa_cancel")}
</Button>
</div>
@@ -272,8 +345,13 @@
</div>
{/if}
<PaymentMethodGrid {methods} {selectedMethod} {t} onSelect={(id) => (selectedMethod = id)} />
<Button class="wide bottom-action payment-submit-button" onclick={createTopupPayment} disabled={!selectedTopupPlan || !methods.length || payBusy}>
{t("wa_buy_traffic")} {selectedTopupPlan ? priceLabel(selectedTopupPlan) : ""}
<Button
class="wide bottom-action payment-submit-button"
onclick={createTopupPayment}
disabled={!selectedTopupPlan || !methods.length || payBusy}
>
{t("wa_buy_traffic")}
{selectedTopupPlan ? priceLabel(selectedTopupPlan) : ""}
<LockKeyhole size={17} />
</Button>
{:else}
@@ -303,7 +381,11 @@
onclick={() => (selectedDeviceTopupPlan = plan)}
>
<span class="option-row-main">
<strong>{t("wa_hwid_devices_package", { count: Number(plan.device_count || plan.months || 0) })}</strong>
<strong
>{t("wa_hwid_devices_package", {
count: Number(plan.device_count || plan.months || 0),
})}</strong
>
<small>{plan.subtitle || deviceTopupOptions.tariff_name}</small>
</span>
<span class="option-row-meta">
@@ -316,8 +398,13 @@
{/each}
</div>
<PaymentMethodGrid {methods} {selectedMethod} {t} onSelect={(id) => (selectedMethod = id)} />
<Button class="wide bottom-action payment-submit-button" onclick={createDeviceTopupPayment} disabled={!selectedDeviceTopupPlan || !methods.length || payBusy}>
{t("wa_pay")} {selectedDeviceTopupPlan ? priceLabel(selectedDeviceTopupPlan) : ""}
<Button
class="wide bottom-action payment-submit-button"
onclick={createDeviceTopupPayment}
disabled={!selectedDeviceTopupPlan || !methods.length || payBusy}
>
{t("wa_pay")}
{selectedDeviceTopupPlan ? priceLabel(selectedDeviceTopupPlan) : ""}
<LockKeyhole size={17} />
</Button>
{:else}
@@ -325,4 +412,3 @@
{/if}
</div>
</Dialog>
@@ -79,7 +79,9 @@
disabled={authBusy || authResendCooldown > 0}
>
<RefreshCw size={15} />
{authResendCooldown > 0 ? t("wa_auth_resend_wait", { seconds: authResendCooldown }) : t("wa_resend_code")}
{authResendCooldown > 0
? t("wa_auth_resend_wait", { seconds: authResendCooldown })
: t("wa_resend_code")}
</button>
</div>
{:else}
@@ -105,12 +107,16 @@
/>
{#if loginEmailFieldError}
<Tooltip.Trigger class="field-error-trigger" aria-label={loginEmailFieldError}>
<span class="field-error-icon" aria-hidden="true"><TriangleAlert size={18} /></span>
<span class="field-error-icon" aria-hidden="true"
><TriangleAlert size={18} /></span
>
</Tooltip.Trigger>
{/if}
{#if loginEmailFieldError}
<Tooltip.Portal>
<Tooltip.Content class="field-error-tooltip">{loginEmailFieldError}</Tooltip.Content>
<Tooltip.Content class="field-error-tooltip"
>{loginEmailFieldError}</Tooltip.Content
>
</Tooltip.Portal>
{/if}
</Tooltip.Root>
@@ -161,7 +167,10 @@
href={privacyPolicyUrl}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => { e.preventDefault(); openExternalLink(privacyPolicyUrl); }}
onclick={(e) => {
e.preventDefault();
openExternalLink(privacyPolicyUrl);
}}
>
{t("wa_auth_legal_privacy")}
</a>
@@ -174,7 +183,10 @@
href={userAgreementUrl}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => { e.preventDefault(); openExternalLink(userAgreementUrl); }}
onclick={(e) => {
e.preventDefault();
openExternalLink(userAgreementUrl);
}}
>
{t("wa_auth_legal_agreement")}
</a>
@@ -4,7 +4,11 @@
import Button from "$components/ui/button.svelte";
import Card from "$components/ui/card.svelte";
import { EmptyCard, LinearProgress, StatusMessage } from "$components/patterns/webapp/index.js";
import { devicesCountLabel, devicesLimitLabel, devicesPercent } from "../../lib/webapp/devicesLabels.js";
import {
devicesCountLabel,
devicesLimitLabel,
devicesPercent,
} from "../../lib/webapp/devicesLabels.js";
export let devicesBusy = false;
export let devicesData = {};
@@ -17,7 +21,6 @@
export let openDeviceDisconnectDialog = () => {};
export let openDeviceTopupModal = () => {};
export let t = (key) => key;
</script>
<main class="content with-nav">
@@ -28,11 +31,21 @@
<strong>{t("wa_devices_title")}</strong>
<small>{devicesCountLabel(devicesData, t)}</small>
</span>
<Button variant="icon" size="icon" onclick={() => loadDevices(true)} disabled={devicesBusy} aria-label={t("wa_devices_refresh")}>
<Button
variant="icon"
size="icon"
onclick={() => loadDevices(true)}
disabled={devicesBusy}
aria-label={t("wa_devices_refresh")}
>
<RefreshCw size={18} />
</Button>
</div>
<LinearProgress class="devices-progress" value={devicesPercent(devicesData)} label={t("wa_devices_title")} />
<LinearProgress
class="devices-progress"
value={devicesPercent(devicesData)}
label={t("wa_devices_title")}
/>
{#if subscription?.active && subscription?.max_devices !== 0}
<Button variant="secondary" class="wide" onclick={openDeviceTopupModal}>
<Plus size={17} />
@@ -60,7 +73,10 @@
<div class="device-card-head">
<div class="device-icon"><Smartphone size={20} /></div>
<span>
<strong>{device.display_name || t("wa_device_fallback_name", { index: device.index })}</strong>
<strong
>{device.display_name ||
t("wa_device_fallback_name", { index: device.index })}</strong
>
<small>{device.platform_label || t("wa_devices_platform_unknown")}</small>
</span>
</div>
@@ -85,7 +101,11 @@
{/if}
</div>
{#if device.can_disconnect}
<Button variant="outline" class="wide device-disconnect-button" onclick={() => openDeviceDisconnectDialog(device)}>
<Button
variant="outline"
class="wide device-disconnect-button"
onclick={() => openDeviceDisconnectDialog(device)}
>
<CircleX size={17} />
{t("wa_devices_disconnect")}
</Button>
@@ -1,5 +1,13 @@
<script>
import { CheckCircle2, ChevronsUpDown, CircleX, Database, Download, Gift, RefreshCw } from "$components/ui/icons.js";
import {
CheckCircle2,
ChevronsUpDown,
CircleX,
Database,
Download,
Gift,
RefreshCw,
} from "$components/ui/icons.js";
import BrandMark from "../../BrandMark.svelte";
import Button from "$components/ui/button.svelte";
@@ -17,7 +25,6 @@
activeSubscriptionTermLabel as activeSubscriptionTermLabelFn,
} from "../../lib/webapp/traffic.js";
export let CFG = {};
export let appSettings = {};
export let brandEmoji = "";
@@ -35,20 +42,35 @@
export let trialBusy = false;
export let termUnitLabel = () => ""; // We need this passed from App or context. Actually, App.svelte doesn't pass it yet. We'll pass it.
function trafficPercent(sub) { return trafficPercentFn(sub); }
function trafficLabel(sub) { return trafficLabelFn(sub, t); }
function trafficResetLabel(sub) { return trafficResetLabelFn(sub, t); }
function premiumTrafficPercent(sub) { return premiumTrafficPercentFn(sub); }
function premiumTrafficLabel(sub) { return premiumTrafficLabelFn(sub, t); }
function premiumTitle(sub = subscription) { return premiumTitleFn(sub, t); }
function premiumServerLabels(sub) { return premiumServerLabelsFn(sub); }
function activeSubscriptionTermLabel(sub) { return activeSubscriptionTermLabelFn(sub, { t, termUnitLabel }); }
function trafficPercent(sub) {
return trafficPercentFn(sub);
}
function trafficLabel(sub) {
return trafficLabelFn(sub, t);
}
function trafficResetLabel(sub) {
return trafficResetLabelFn(sub, t);
}
function premiumTrafficPercent(sub) {
return premiumTrafficPercentFn(sub);
}
function premiumTrafficLabel(sub) {
return premiumTrafficLabelFn(sub, t);
}
function premiumTitle(sub = subscription) {
return premiumTitleFn(sub, t);
}
function premiumServerLabels(sub) {
return premiumServerLabelsFn(sub);
}
function activeSubscriptionTermLabel(sub) {
return activeSubscriptionTermLabelFn(sub, { t, termUnitLabel });
}
function trialTrafficLabel() {
const limit = Number(appSettings?.trial_traffic_limit_gb || 0);
return limit > 0 ? formatTrafficGb(limit) : t("wa_unlimited_traffic");
}
export let activateTrial = () => {};
export let openConnectLink = () => {};
export let openPaymentModal = () => {};
@@ -71,11 +93,21 @@
<div class="sub-status">
<CheckCircle2 size={23} />
<div>
<h2>{trafficMode ? t("wa_home_access_active") : t("wa_home_subscription_active")} | {activeSubscriptionTermLabel(subscription)}</h2>
<h2>
{trafficMode ? t("wa_home_access_active") : t("wa_home_subscription_active")} | {activeSubscriptionTermLabel(
subscription
)}
</h2>
{#if hasActiveTariffSubscription && hasMultipleTariffs && currentTariffName}
<p class="current-tariff-line">{t("wa_current_tariff", { tariff: currentTariffName })}</p>
<p class="current-tariff-line">
{t("wa_current_tariff", { tariff: currentTariffName })}
</p>
{/if}
<p>{subscription.end_date_text ? t("wa_until_date", { date: subscription.end_date_text }) : subscription.remaining_text}</p>
<p>
{subscription.end_date_text
? t("wa_until_date", { date: subscription.end_date_text })
: subscription.remaining_text}
</p>
</div>
</div>
{:else}
@@ -89,7 +121,12 @@
{#if subscription.active}
<Card class={regularTrafficTopupBarClickable ? "traffic-card-clickable" : ""}>
{#if regularTrafficTopupBarClickable}
<button class="card-click-target" type="button" onclick={openRegularTopupModal} aria-label={t("wa_add_traffic")}></button>
<button
class="card-click-target"
type="button"
onclick={openRegularTopupModal}
aria-label={t("wa_add_traffic")}
></button>
{/if}
<div class="traffic-top">
<span>{t("wa_home_traffic_used")}</span>
@@ -115,7 +152,9 @@
<ChevronsUpDown size={13} />
</summary>
<div class="premium-server-list premium-server-list-dropdown">
<small>{t("wa_premium_servers_limited", {}, "Отдельный лимит действует на")}</small>
<small
>{t("wa_premium_servers_limited", {}, "Отдельный лимит действует на")}</small
>
<div>
{#each premiumServerLabels(subscription).slice(0, 8) as label}
<span>{label}</span>
@@ -129,7 +168,9 @@
</div>
</Card>
{:else if Number(subscription?.premium_limit_bytes || 0) > 0}
<Card class={`${premiumTrafficTopupBarClickable ? "traffic-card-clickable " : ""}premium-traffic-card${subscription?.premium_is_limited ? " premium-traffic-card-limited" : ""}`}>
<Card
class={`${premiumTrafficTopupBarClickable ? "traffic-card-clickable " : ""}premium-traffic-card${subscription?.premium_is_limited ? " premium-traffic-card-limited" : ""}`}
>
{#if premiumTrafficTopupBarClickable}
<button
class="card-click-target"
@@ -142,16 +183,26 @@
<span>{premiumTitle(subscription)}</span>
<strong>{premiumTrafficLabel(subscription)}</strong>
</div>
<LinearProgress class="premium-progress" value={premiumTrafficPercent(subscription)} label={premiumTitle(subscription)} />
<LinearProgress
class="premium-progress"
value={premiumTrafficPercent(subscription)}
label={premiumTitle(subscription)}
/>
<div class="traffic-meta premium-traffic-meta">
{#if premiumServerLabels(subscription).length}
<details class="premium-server-dropdown">
<summary>
<span>{subscription?.premium_is_limited ? t("wa_premium_access_limited", {}, "Доступ к premium временно ограничен") : t("wa_premium_reset_monthly", {}, "Отдельный лимит на месяц")}</span>
<span
>{subscription?.premium_is_limited
? t("wa_premium_access_limited", {}, "Доступ к premium временно ограничен")
: t("wa_premium_reset_monthly", {}, "Отдельный лимит на месяц")}</span
>
<ChevronsUpDown size={13} />
</summary>
<div class="premium-server-list premium-server-list-dropdown">
<small>{t("wa_premium_servers_limited", {}, "Отдельный лимит действует на")}</small>
<small
>{t("wa_premium_servers_limited", {}, "Отдельный лимит действует на")}</small
>
<div>
{#each premiumServerLabels(subscription).slice(0, 8) as label}
<span>{label}</span>
@@ -160,7 +211,11 @@
</div>
</details>
{:else}
<span>{subscription?.premium_is_limited ? t("wa_premium_access_limited", {}, "Доступ к premium временно ограничен") : t("wa_premium_reset_monthly", {}, "Отдельный лимит на месяц")}</span>
<span
>{subscription?.premium_is_limited
? t("wa_premium_access_limited", {}, "Доступ к premium временно ограничен")
: t("wa_premium_reset_monthly", {}, "Отдельный лимит на месяц")}</span
>
{/if}
<span class="traffic-percent">{premiumTrafficPercent(subscription)}%</span>
</div>
@@ -172,7 +227,12 @@
<Gift size={22} />
<span>
<strong>{t("wa_trial_title")}</strong>
<small>{t("wa_trial_details", { days: Number(appSettings?.trial_duration_days || 0), traffic: trialTrafficLabel() })}</small>
<small
>{t("wa_trial_details", {
days: Number(appSettings?.trial_duration_days || 0),
traffic: trialTrafficLabel(),
})}</small
>
</span>
</div>
</Card>
@@ -185,7 +245,11 @@
{t("wa_install_and_configure")}
</Button>
{/if}
<Button class="wide" variant={subscription.active ? "secondary" : "default"} onclick={openPaymentModal}>
<Button
class="wide"
variant={subscription.active ? "secondary" : "default"}
onclick={openPaymentModal}
>
{#if subscription.active}
<RefreshCw size={18} />
{:else if trafficMode}
@@ -38,7 +38,10 @@
<h3 class="card-heading">{t("wa_referral_link_title")}</h3>
<div class="copy-row referral-copy-row">
<code>{referral.webapp_link || referral.bot_link || t("wa_link_unavailable")}</code>
<Button class="referral-copy-button" onclick={() => copyText(referral.webapp_link || referral.bot_link, t("wa_link_copied"))}>
<Button
class="referral-copy-button"
onclick={() => copyText(referral.webapp_link || referral.bot_link, t("wa_link_copied"))}
>
{t("wa_copy")}
<Copy size={17} />
</Button>
@@ -58,8 +61,12 @@
{#each referralBonusDetails as bonus, index (bonus.months || index)}
<div class="referral-bonus-row">
<strong>{bonus.title || `${bonus.months || "?"}`}</strong>
<small>{t("wa_referral_bonus_you_days", { days: Number(bonus.inviter_days || 0) })}</small>
<small>{t("wa_referral_bonus_friend_days", { days: Number(bonus.friend_days || 0) })}</small>
<small
>{t("wa_referral_bonus_you_days", { days: Number(bonus.inviter_days || 0) })}</small
>
<small
>{t("wa_referral_bonus_friend_days", { days: Number(bonus.friend_days || 0) })}</small
>
</div>
{/each}
</div>
@@ -1,5 +1,13 @@
<script>
import { ArrowRight, CheckCircle2, FileText, Mail, Send, Shield, UserRound } from "$components/ui/icons.js";
import {
ArrowRight,
CheckCircle2,
FileText,
Mail,
Send,
Shield,
UserRound,
} from "$components/ui/icons.js";
import Button from "$components/ui/button.svelte";
import Card from "$components/ui/card.svelte";
@@ -40,7 +48,12 @@
<Card class="settings-profile">
<div class="settings-avatar">
{#if profileAvatarUrl}
<img src={profileAvatarUrl} alt={t("wa_settings_avatar_alt")} loading="lazy" referrerpolicy="no-referrer" />
<img
src={profileAvatarUrl}
alt={t("wa_settings_avatar_alt")}
loading="lazy"
referrerpolicy="no-referrer"
/>
{:else}
<UserRound size={30} />
{/if}
@@ -95,7 +108,12 @@
</span>
</div>
{:else}
<button class="settings-row attention-wrap" type="button" onclick={openLinkEmailDialog} disabled={linkEmailBusy}>
<button
class="settings-row attention-wrap"
type="button"
onclick={openLinkEmailDialog}
disabled={linkEmailBusy}
>
<span class="attention-dot" aria-hidden="true"></span>
<Mail size={21} />
<span>
@@ -123,21 +141,33 @@
onValueChange={updateAccountLanguage}
/>
{#if supportUrl}
<button class="settings-row settings-row-support" type="button" onclick={() => openExternalLink(supportUrl)}>
<button
class="settings-row settings-row-support"
type="button"
onclick={() => openExternalLink(supportUrl)}
>
<Send size={21} />
<span><strong>{t("menu_support_button")}</strong></span>
<ArrowRight size={17} />
</button>
{/if}
{#if userAgreementUrl}
<button class="settings-row settings-row-policy" type="button" onclick={() => openExternalLink(userAgreementUrl)}>
<button
class="settings-row settings-row-policy"
type="button"
onclick={() => openExternalLink(userAgreementUrl)}
>
<FileText size={21} />
<span><strong>{t("wa_settings_user_agreement")}</strong></span>
<ArrowRight size={17} />
</button>
{/if}
{#if privacyPolicyUrl}
<button class="settings-row settings-row-policy" type="button" onclick={() => openExternalLink(privacyPolicyUrl)}>
<button
class="settings-row settings-row-policy"
type="button"
onclick={() => openExternalLink(privacyPolicyUrl)}
>
<Shield size={21} />
<span><strong>{t("wa_settings_privacy_policy")}</strong></span>
<ArrowRight size={17} />