diff --git a/bot/app/web/frontend/src/App.svelte b/bot/app/web/frontend/src/App.svelte
index de4f3db..79857ff 100644
--- a/bot/app/web/frontend/src/App.svelte
+++ b/bot/app/web/frontend/src/App.svelte
@@ -376,6 +376,8 @@
}, 220);
return;
}
+ languageClickGuard = true;
+ languageClickGuardArmed = false;
languageClickGuardTimer = window.setTimeout(() => {
languageClickGuard = false;
languageClickGuardTimer = null;
diff --git a/bot/app/web/frontend/src/PreviewBoard.svelte b/bot/app/web/frontend/src/PreviewBoard.svelte
index cac8087..0160881 100644
--- a/bot/app/web/frontend/src/PreviewBoard.svelte
+++ b/bot/app/web/frontend/src/PreviewBoard.svelte
@@ -17,10 +17,11 @@
Ticket,
UserRound,
Zap,
- } from "lucide-svelte";
+ } from "$components/ui/icons.js";
import Button from "$components/ui/button.svelte";
import Card from "$components/ui/card.svelte";
+ import { LinearProgress } from "$components/patterns/webapp/index.js";
import BackTitle from "./preview/BackTitle.svelte";
import PhoneFrame from "./preview/PhoneFrame.svelte";
import PreviewMethods from "./preview/PreviewMethods.svelte";
@@ -78,7 +79,7 @@
Использовано трафика {sub.traffic_used} из {sub.traffic_limit}
-
+
18%
diff --git a/bot/app/web/frontend/src/admin/AdminPanel.svelte b/bot/app/web/frontend/src/admin/AdminPanel.svelte
index 66c5fb8..e0bfb05 100644
--- a/bot/app/web/frontend/src/admin/AdminPanel.svelte
+++ b/bot/app/web/frontend/src/admin/AdminPanel.svelte
@@ -18,9 +18,10 @@
Sparkles,
Tag,
UsersRound,
- } from "lucide-svelte";
+ } from "$components/ui/icons.js";
import { onMount, setContext } from "svelte";
import { Select } from "$components/ui/primitives.js";
+ import { AdminBadge, AdminButton } from "$components/patterns/admin/index.js";
import BrandMark from "../BrandMark.svelte";
import AdsSection from "./sections/AdsSection.svelte";
@@ -140,6 +141,11 @@
}
let sidebarOpen = false;
let isCompact = false;
+ let adminLanguageMenuOpen = false;
+ let adminLanguageClickGuard = false;
+ let adminLanguageClickGuardArmed = false;
+ let adminLanguageClickGuardTimer = null;
+ let adminLanguageClickGuardArmTimer = null;
function flash(text) {
onToast(text);
@@ -246,6 +252,44 @@
isCompact = Boolean(event?.matches);
}
+ function clearAdminLanguageClickGuard() {
+ if (adminLanguageClickGuardTimer) {
+ window.clearTimeout(adminLanguageClickGuardTimer);
+ adminLanguageClickGuardTimer = null;
+ }
+ if (adminLanguageClickGuardArmTimer) {
+ window.clearTimeout(adminLanguageClickGuardArmTimer);
+ adminLanguageClickGuardArmTimer = null;
+ }
+ adminLanguageClickGuard = false;
+ adminLanguageClickGuardArmed = false;
+ }
+
+ function setAdminLanguageMenuOpen(open) {
+ adminLanguageMenuOpen = Boolean(open);
+ clearAdminLanguageClickGuard();
+ if (adminLanguageMenuOpen) {
+ adminLanguageClickGuard = true;
+ adminLanguageClickGuardArmTimer = window.setTimeout(() => {
+ adminLanguageClickGuardArmed = true;
+ adminLanguageClickGuardArmTimer = null;
+ }, 220);
+ return;
+ }
+ adminLanguageClickGuard = true;
+ adminLanguageClickGuardArmed = false;
+ adminLanguageClickGuardTimer = window.setTimeout(() => {
+ adminLanguageClickGuard = false;
+ adminLanguageClickGuardTimer = null;
+ }, 260);
+ }
+
+ function closeAdminLanguageFromGuard(event) {
+ event.preventDefault();
+ event.stopPropagation();
+ if (adminLanguageClickGuardArmed) setAdminLanguageMenuOpen(false);
+ }
+
onMount(() => {
if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
compactMql = window.matchMedia("(max-width: 720px)");
@@ -262,6 +306,7 @@
else if (compactMql.removeListener) compactMql.removeListener(onCompactChange);
}
if (typeof window !== "undefined") window.removeEventListener("popstate", onPopState);
+ clearAdminLanguageClickGuard();
};
});
@@ -274,6 +319,16 @@
{#if sidebarOpen}
{/if}
+ {#if adminLanguageMenuOpen || adminLanguageClickGuard}
+
+ {/if}
{#each NAV_GROUPS as group}
@@ -304,7 +359,15 @@
{#if languageOptions.length}
-
+
{t("wa_settings_language", {}, at("language", {}, "Язык"))}
@@ -351,37 +414,37 @@
@@ -395,7 +458,6 @@
+
{/if}
{#if active === "logs"}
diff --git a/bot/app/web/frontend/src/admin/sections/AdsSection.svelte b/bot/app/web/frontend/src/admin/sections/AdsSection.svelte
index 7dbe733..b9be62f 100644
--- a/bot/app/web/frontend/src/admin/sections/AdsSection.svelte
+++ b/bot/app/web/frontend/src/admin/sections/AdsSection.svelte
@@ -1,8 +1,15 @@
{at("tariff_label_model", {}, "Модель тарификации")}
{at("tariff_model_period_label", {}, "Период")} — {at("tariff_model_period_desc", {}, "пользователь покупает фиксированный срок (1/3/12 мес. и т.д.)")}. {at("tariff_model_traffic_label", {}, "Трафик")} — {at("tariff_model_traffic_desc", {}, "пользователь покупает пакеты гигабайт по фиксированной цене за GB")}
-
-
- {tariffDraft.billing_model === "traffic" ? at("tariff_model_traffic_label", {}, "Трафик") : at("tariff_model_period_label", {}, "Период")}
-
-
-
-
-
- {at("tariff_model_period_label", {}, "Период")}
-
-
-
- {at("tariff_model_traffic_label", {}, "Трафик")}
-
-
-
-
-
+
@@ -112,29 +109,16 @@
{at("tariff_label_squads", {}, "Базовые Internal Squads")}
{panelSquadsLoading ? at("loading_squads", {}, "Загружаю список из панели…") : at("tariff_hint_squads", {}, "Сквады Remnawave, к которым подключается пользователь по этому тарифу. Выберите один или несколько")}
-
{
tariffsStore.addSquadToDraft("squadUuids", value);
selectedBaseSquad = "";
}}
- >
-
- {at("btn_add_squad", {}, "Добавить сквад")}
-
-
-
-
- {#each panelSquads as squad}
-
- {squad.name}
-
-
- {/each}
-
-
-
+ />
{#each normalizeUuidList(tariffDraft.squadUuids) as uuid}
tariffsStore.removeSquadFromDraft("squadUuids", uuid)}>
@@ -190,29 +174,16 @@
{at("tariff_label_premium_squads", {}, "Premium Internal Squads")}
{at("tariff_hint_premium_squads", {}, "Сквады из Remnawave, доступные только владельцам этого тарифа. Трафик считается по их accessible nodes")}
-
{
tariffsStore.addSquadToDraft("premiumSquadUuids", value);
selectedPremiumSquad = "";
}}
- >
-
- {at("btn_add_premium_squad", {}, "Добавить premium-сквад")}
-
-
-
-
- {#each panelSquads as squad}
-
- {squad.name}
-
-
- {/each}
-
-
-
+ />
{#each normalizeUuidList(tariffDraft.premiumSquadUuids) as uuid}
tariffsStore.removeSquadFromDraft("premiumSquadUuids", uuid)}>
@@ -236,8 +207,8 @@
{at("tariff_premium_topup_subtitle", {}, "Пакеты для расширения месячного premium-лимита, когда пользователь его исчерпал")}
-
tariffsStore.addDraftRow("premiumTopupRubRows", { gb: 10, price: "" })}> {at("tariff_btn_package_rub", {}, "Пакет ₽")}
-
tariffsStore.addDraftRow("premiumTopupStarsRows", { gb: 10, price: "" })}> {at("tariff_btn_package_stars", {}, "Пакет ⭐")}
+
tariffsStore.addDraftRow("premiumTopupRubRows", { gb: 10, price: "" })}> {at("tariff_btn_package_rub", {}, "Пакет ₽")}
+
tariffsStore.addDraftRow("premiumTopupStarsRows", { gb: 10, price: "" })}> {at("tariff_btn_package_stars", {}, "Пакет ⭐")}
@@ -254,7 +225,7 @@
{/each}
@@ -271,7 +242,7 @@
{/each}
@@ -287,9 +258,9 @@
{at("tariff_pricing_period_title", {}, "Периоды подписки и цены")}
{at("tariff_pricing_period_subtitle", {}, "Каждая строка — отдельный вариант на витрине: за сколько месяцев пользователь платит и сколько это стоит")}
-
tariffsStore.addDraftRow("periodRows", { months: 1, rub: "", stars: "" })}>
+ tariffsStore.addDraftRow("periodRows", { months: 1, rub: "", stars: "" })}>
{at("tariff_btn_period", {}, "Период")}
-
+
{#if !tariffDraft.periodRows.length}
{at("tariff_pricing_empty", {}, "Добавьте хотя бы один период — без него тариф не появится на витрине.")}
@@ -306,9 +277,9 @@
-
tariffsStore.removeDraftRow("periodRows", index)} aria-label={at("btn_delete", {}, "Удалить")}>
+ tariffsStore.removeDraftRow("periodRows", index)} aria-label={at("btn_delete", {}, "Удалить")}>
-
+
{/each}
@@ -322,8 +293,8 @@
{at("tariff_pricing_traffic_subtitle", {}, "Базовая витрина для трафиковой модели. Каждая строка — пакет «N гигабайт за N единиц валюты»")}
-
tariffsStore.addDraftRow("trafficRubRows", { gb: 10, price: "" })}> {at("tariff_btn_package_rub", {}, "Пакет ₽")}
-
tariffsStore.addDraftRow("trafficStarsRows", { gb: 10, price: "" })}> {at("tariff_btn_package_stars", {}, "Пакет ⭐")}
+
tariffsStore.addDraftRow("trafficRubRows", { gb: 10, price: "" })}> {at("tariff_btn_package_rub", {}, "Пакет ₽")}
+
tariffsStore.addDraftRow("trafficStarsRows", { gb: 10, price: "" })}> {at("tariff_btn_package_stars", {}, "Пакет ⭐")}
@@ -340,7 +311,7 @@
{/each}
@@ -357,7 +328,7 @@
{/each}
@@ -375,8 +346,8 @@
{at("tariff_topup_subtitle", {}, "Когда у пользователя кончился месячный лимит, ему предложат купить дополнительный пакет, не меняя срок подписки")}
-
tariffsStore.addDraftRow("topupRubRows", { gb: 10, price: "" })}> {at("tariff_btn_package_rub", {}, "Пакет ₽")}
-
tariffsStore.addDraftRow("topupStarsRows", { gb: 10, price: "" })}> {at("tariff_btn_package_stars", {}, "Пакет ⭐")}
+
tariffsStore.addDraftRow("topupRubRows", { gb: 10, price: "" })}> {at("tariff_btn_package_rub", {}, "Пакет ₽")}
+
tariffsStore.addDraftRow("topupStarsRows", { gb: 10, price: "" })}> {at("tariff_btn_package_stars", {}, "Пакет ⭐")}
@@ -393,7 +364,7 @@
{/each}
@@ -410,7 +381,7 @@
{/each}
@@ -429,8 +400,8 @@
{at("tariff_hwid_packages_subtitle", {}, "Расширяет лимит, указанный во вкладке «Основное». Каждая строка — пакет «+N устройств за N единиц валюты»")}
-
tariffsStore.addDraftRow("hwidRubRows", { count: 1, price: "" })}> {at("tariff_btn_package_rub", {}, "Пакет ₽")}
-
tariffsStore.addDraftRow("hwidStarsRows", { count: 1, price: "" })}> {at("tariff_btn_package_stars", {}, "Пакет ⭐")}
+
tariffsStore.addDraftRow("hwidRubRows", { count: 1, price: "" })}> {at("tariff_btn_package_rub", {}, "Пакет ₽")}
+
tariffsStore.addDraftRow("hwidStarsRows", { count: 1, price: "" })}> {at("tariff_btn_package_stars", {}, "Пакет ⭐")}
@@ -447,7 +418,7 @@
{/each}
@@ -464,7 +435,7 @@
{/each}
@@ -474,10 +445,10 @@
-
tariffsStore.updateState({ tariffEditorOpen: false })}>{at("btn_cancel", {}, "Отмена")}
-
+ tariffsStore.updateState({ tariffEditorOpen: false })}>{at("btn_cancel", {}, "Отмена")}
+
{tariffsSaving ? at("btn_saving", {}, "Сохранение...") : at("btn_save_tariff", {}, "Сохранить тариф")}
-
+
@@ -490,9 +461,9 @@
class="admin-dialog"
>
diff --git a/bot/app/web/frontend/src/admin/sections/TariffsSection.svelte b/bot/app/web/frontend/src/admin/sections/TariffsSection.svelte
index 797dde0..d796a80 100644
--- a/bot/app/web/frontend/src/admin/sections/TariffsSection.svelte
+++ b/bot/app/web/frontend/src/admin/sections/TariffsSection.svelte
@@ -1,6 +1,7 @@
{#if tariffsLoading}
- {at("loading", {}, "Загрузка…")}
+ {at("loading", {}, "Загрузка…")}
{:else}
@@ -72,19 +73,19 @@
{tariffsPath || "data/tariffs.json"}
-
+
{at("btn_refresh", {}, "Обновить")}
-
-
+
+
{at("btn_create_tariff", {}, "Создать тариф")}
-
+
{#if !tariffsCatalog.tariffs.length}
-
+
{at("tariffs_catalog_empty", {}, "Каталог пуст. Добавьте первый тариф, после сохранения будет создан JSON-файл каталога.")}
-
+
{:else}
{#each tariffsCatalog.tariffs as tariff}
@@ -94,15 +95,15 @@
{tariffName(tariff)}
{#if tariff.key === tariffsCatalog.default_tariff}
-
{at("status_default", {}, "Default")}
+
{at("status_default", {}, "Default")}
{/if}
{tariff.key}
{#if tariff.enabled === false}
-
{at("status_disabled", {}, "Выключен")}
+
{at("status_disabled", {}, "Выключен")}
{:else}
-
{at("status_active", {}, "Активен")}
+
{at("status_active", {}, "Активен")}
{/if}
{tariff.descriptions?.ru || tariff.descriptions?.en || at("no_description", {}, "Без описания")}
@@ -114,29 +115,28 @@
{at("tariff_devices", {}, "Устройства")}: {tariff.hwid_device_limit ?? "env"}
-
tariffsStore.openEditTariff(tariff)}>
+ tariffsStore.openEditTariff(tariff)}>
{at("btn_configure", {}, "Настроить")}
-
-
tariffsStore.toggleTariffEnabled(tariff)} disabled={tariffsSaving}>
+
+ tariffsStore.toggleTariffEnabled(tariff)} disabled={tariffsSaving}>
{tariff.enabled === false ? at("btn_enable", {}, "Включить") : at("btn_disable", {}, "Выключить")}
-
-
tariffsStore.setDefaultTariff(tariff.key)}
+
+ tariffsStore.setDefaultTariff(tariff.key)}
disabled={tariffsSaving || tariff.enabled === false || tariff.key === tariffsCatalog.default_tariff}
>
{at("btn_set_default", {}, "По умолчанию")}
-
-
tariffsStore.updateState({ tariffDeleteTarget: tariff, tariffDeleteOpen: true })}
+
+ tariffsStore.updateState({ tariffDeleteTarget: tariff, tariffDeleteOpen: true })}
disabled={tariffsSaving}
aria-label={at("btn_delete_tariff", {}, "Удалить тариф")}
>
-
+
{/each}
diff --git a/bot/app/web/frontend/src/admin/sections/UserDetailModal.svelte b/bot/app/web/frontend/src/admin/sections/UserDetailModal.svelte
index abb7384..277bcea 100644
--- a/bot/app/web/frontend/src/admin/sections/UserDetailModal.svelte
+++ b/bot/app/web/frontend/src/admin/sections/UserDetailModal.svelte
@@ -1,11 +1,12 @@
+
+
+
+
diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminButton.svelte b/bot/app/web/frontend/src/lib/components/patterns/admin/AdminButton.svelte
new file mode 100644
index 0000000..d20615d
--- /dev/null
+++ b/bot/app/web/frontend/src/lib/components/patterns/admin/AdminButton.svelte
@@ -0,0 +1,45 @@
+
+
+
+
+
diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminField.svelte b/bot/app/web/frontend/src/lib/components/patterns/admin/AdminField.svelte
new file mode 100644
index 0000000..ee1ad9e
--- /dev/null
+++ b/bot/app/web/frontend/src/lib/components/patterns/admin/AdminField.svelte
@@ -0,0 +1,16 @@
+
+
+
+ {#if label}
+ {label}
+ {/if}
+ {#if hint}
+ {hint}
+ {/if}
+
+
diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminPagination.svelte b/bot/app/web/frontend/src/lib/components/patterns/admin/AdminPagination.svelte
new file mode 100644
index 0000000..ffcff6a
--- /dev/null
+++ b/bot/app/web/frontend/src/lib/components/patterns/admin/AdminPagination.svelte
@@ -0,0 +1,24 @@
+
+
+
diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminSelect.svelte b/bot/app/web/frontend/src/lib/components/patterns/admin/AdminSelect.svelte
new file mode 100644
index 0000000..042ee83
--- /dev/null
+++ b/bot/app/web/frontend/src/lib/components/patterns/admin/AdminSelect.svelte
@@ -0,0 +1,38 @@
+
+
+
+
+ {selected?.label || placeholder}
+
+
+
+
+ {#each items as item (item.value)}
+
+ {item.label}
+
+
+ {/each}
+
+
+
diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminTable.svelte b/bot/app/web/frontend/src/lib/components/patterns/admin/AdminTable.svelte
new file mode 100644
index 0000000..21ff138
--- /dev/null
+++ b/bot/app/web/frontend/src/lib/components/patterns/admin/AdminTable.svelte
@@ -0,0 +1,13 @@
+
+
+
diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminTableSkeleton.svelte b/bot/app/web/frontend/src/lib/components/patterns/admin/AdminTableSkeleton.svelte
new file mode 100644
index 0000000..a766e99
--- /dev/null
+++ b/bot/app/web/frontend/src/lib/components/patterns/admin/AdminTableSkeleton.svelte
@@ -0,0 +1,38 @@
+
+
+
+
+
+ {#each headers as header}
+ {header}
+ {/each}
+
+
+
+ {#each Array(rows) as _, rowIndex (rowIndex)}
+
+ {#each headers as header, colIndex (`${rowIndex}-${colIndex}`)}
+
+
+
+ {/each}
+
+ {/each}
+
+
diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/AdminTrafficCard.svelte b/bot/app/web/frontend/src/lib/components/patterns/admin/AdminTrafficCard.svelte
new file mode 100644
index 0000000..8c28945
--- /dev/null
+++ b/bot/app/web/frontend/src/lib/components/patterns/admin/AdminTrafficCard.svelte
@@ -0,0 +1,34 @@
+
+
+
+
+ {title}
+ {value}
+
+
+
+
+
+ {left}
+ {clamped}%
+
+
diff --git a/bot/app/web/frontend/src/lib/components/patterns/admin/index.js b/bot/app/web/frontend/src/lib/components/patterns/admin/index.js
index a29622f..f44099e 100644
--- a/bot/app/web/frontend/src/lib/components/patterns/admin/index.js
+++ b/bot/app/web/frontend/src/lib/components/patterns/admin/index.js
@@ -1,4 +1,12 @@
+export { default as AdminBadge } from "./AdminBadge.svelte";
+export { default as AdminButton } from "./AdminButton.svelte";
export { default as AdminDashboardGrid } from "./AdminDashboardGrid.svelte";
export { default as AdminDashboardStack } from "./AdminDashboardStack.svelte";
export { default as AdminEmptyState } from "./AdminEmptyState.svelte";
+export { default as AdminField } from "./AdminField.svelte";
+export { default as AdminPagination } from "./AdminPagination.svelte";
+export { default as AdminSelect } from "./AdminSelect.svelte";
export { default as AdminSectionHeader } from "./AdminSectionHeader.svelte";
+export { default as AdminTable } from "./AdminTable.svelte";
+export { default as AdminTableSkeleton } from "./AdminTableSkeleton.svelte";
+export { default as AdminTrafficCard } from "./AdminTrafficCard.svelte";
diff --git a/bot/app/web/frontend/src/lib/components/patterns/webapp/DialogOptionsSkeleton.svelte b/bot/app/web/frontend/src/lib/components/patterns/webapp/DialogOptionsSkeleton.svelte
new file mode 100644
index 0000000..a1edbf4
--- /dev/null
+++ b/bot/app/web/frontend/src/lib/components/patterns/webapp/DialogOptionsSkeleton.svelte
@@ -0,0 +1,67 @@
+
+
+
+ {#if actions}
+
+ {#each Array(actions) as _, index (index)}
+
+
+
+
+
+
+
+ {/each}
+
+
+ {/if}
+
+
+ {#each Array(rows) as _, index (index)}
+
+
+
+
+
+ {#if showMeta}
+
+
+
+
+ {/if}
+
+ {/each}
+
+
+ {#if showNote}
+
+
+
+
+ {/if}
+
+ {#if methods}
+
+ {#each Array(methods) as _, index (index)}
+
+
+
+
+ {/each}
+
+ {/if}
+
+ {#if showPayButton}
+
+ {/if}
+
diff --git a/bot/app/web/frontend/src/lib/components/patterns/webapp/EmptyCard.svelte b/bot/app/web/frontend/src/lib/components/patterns/webapp/EmptyCard.svelte
new file mode 100644
index 0000000..6872dde
--- /dev/null
+++ b/bot/app/web/frontend/src/lib/components/patterns/webapp/EmptyCard.svelte
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/bot/app/web/frontend/src/lib/components/patterns/webapp/LanguageSelect.svelte b/bot/app/web/frontend/src/lib/components/patterns/webapp/LanguageSelect.svelte
new file mode 100644
index 0000000..379c39b
--- /dev/null
+++ b/bot/app/web/frontend/src/lib/components/patterns/webapp/LanguageSelect.svelte
@@ -0,0 +1,71 @@
+
+
+{#if open || clickGuard}
+
+{/if}
+
+
+
+
+
+
+ {label}
+
+ {currentOption?.flag || "🏳️"}
+ {currentOption?.label || userLanguage}
+
+
+
+
+
+
+ {#each options as option (option.value)}
+
+
+ {option.flag}
+ {option.label}
+
+
+
+ {/each}
+
+
+
+
diff --git a/bot/app/web/frontend/src/lib/components/patterns/webapp/LinearProgress.svelte b/bot/app/web/frontend/src/lib/components/patterns/webapp/LinearProgress.svelte
new file mode 100644
index 0000000..73e4769
--- /dev/null
+++ b/bot/app/web/frontend/src/lib/components/patterns/webapp/LinearProgress.svelte
@@ -0,0 +1,22 @@
+
+
+
+
+
diff --git a/bot/app/web/frontend/src/lib/components/patterns/webapp/PaymentMethodGrid.svelte b/bot/app/web/frontend/src/lib/components/patterns/webapp/PaymentMethodGrid.svelte
new file mode 100644
index 0000000..18a9e0e
--- /dev/null
+++ b/bot/app/web/frontend/src/lib/components/patterns/webapp/PaymentMethodGrid.svelte
@@ -0,0 +1,40 @@
+
+
+
+ {#each methods as method}
+ {@const meta = methodMeta(method)}
+ onSelect(method.id)}
+ >
+
+ {#if meta.icon}
+
+ {/if}
+ {meta.title}
+
+
+ {/each}
+
diff --git a/bot/app/web/frontend/src/lib/components/patterns/webapp/StatusMessage.svelte b/bot/app/web/frontend/src/lib/components/patterns/webapp/StatusMessage.svelte
new file mode 100644
index 0000000..b58f741
--- /dev/null
+++ b/bot/app/web/frontend/src/lib/components/patterns/webapp/StatusMessage.svelte
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/bot/app/web/frontend/src/lib/components/patterns/webapp/index.js b/bot/app/web/frontend/src/lib/components/patterns/webapp/index.js
new file mode 100644
index 0000000..564a40e
--- /dev/null
+++ b/bot/app/web/frontend/src/lib/components/patterns/webapp/index.js
@@ -0,0 +1,6 @@
+export { default as DialogOptionsSkeleton } from "./DialogOptionsSkeleton.svelte";
+export { default as EmptyCard } from "./EmptyCard.svelte";
+export { default as LinearProgress } from "./LinearProgress.svelte";
+export { default as LanguageSelect } from "./LanguageSelect.svelte";
+export { default as PaymentMethodGrid } from "./PaymentMethodGrid.svelte";
+export { default as StatusMessage } from "./StatusMessage.svelte";
diff --git a/bot/app/web/frontend/src/lib/components/ui/dialog.svelte b/bot/app/web/frontend/src/lib/components/ui/dialog.svelte
index 77ec261..2726f0f 100644
--- a/bot/app/web/frontend/src/lib/components/ui/dialog.svelte
+++ b/bot/app/web/frontend/src/lib/components/ui/dialog.svelte
@@ -1,5 +1,5 @@
+
+
diff --git a/bot/app/web/frontend/src/lib/components/ui/spinner.svelte b/bot/app/web/frontend/src/lib/components/ui/spinner.svelte
new file mode 100644
index 0000000..5a26124
--- /dev/null
+++ b/bot/app/web/frontend/src/lib/components/ui/spinner.svelte
@@ -0,0 +1,16 @@
+
+
+
diff --git a/bot/app/web/frontend/src/preview/BackTitle.svelte b/bot/app/web/frontend/src/preview/BackTitle.svelte
index b7ade24..cd7fd39 100644
--- a/bot/app/web/frontend/src/preview/BackTitle.svelte
+++ b/bot/app/web/frontend/src/preview/BackTitle.svelte
@@ -1,5 +1,5 @@
diff --git a/bot/app/web/frontend/src/styles/admin-controls.css b/bot/app/web/frontend/src/styles/admin-controls.css
index acfca78..926a138 100644
--- a/bot/app/web/frontend/src/styles/admin-controls.css
+++ b/bot/app/web/frontend/src/styles/admin-controls.css
@@ -52,7 +52,7 @@
height: 36px;
border-radius: 8px;
border: 1px solid var(--admin-border-strong);
- background: var(--admin-bg);
+ background: color-mix(in srgb, var(--admin-bg) 82%, var(--admin-surface-2));
color: var(--admin-text);
padding: 0 12px;
font-size: 13px;
@@ -88,7 +88,9 @@
padding: 0 14px;
border-radius: 8px;
border: 1px solid var(--admin-border);
- background: var(--admin-surface-2);
+ background:
+ linear-gradient(135deg, rgba(255, 255, 255, 0.052), rgba(255, 255, 255, 0.014)),
+ var(--admin-surface-2);
color: var(--admin-text);
font-size: 13px;
font-weight: 500;
@@ -98,7 +100,10 @@
}
.admin-btn:hover:not(:disabled) {
- background: var(--admin-elev);
+ background:
+ linear-gradient(135deg, rgba(255, 255, 255, 0.068), rgba(255, 255, 255, 0.018)),
+ var(--admin-elev);
+ border-color: var(--admin-border-strong);
}
.admin-btn:disabled {
@@ -124,7 +129,7 @@
}
.admin-btn.admin-btn-ghost:hover:not(:disabled) {
- background: var(--admin-surface-2);
+ background: rgba(255, 255, 255, 0.04);
color: var(--admin-text);
}
diff --git a/bot/app/web/frontend/src/styles/admin.css b/bot/app/web/frontend/src/styles/admin.css
index 3ed1735..55f1097 100644
--- a/bot/app/web/frontend/src/styles/admin.css
+++ b/bot/app/web/frontend/src/styles/admin.css
@@ -6,12 +6,20 @@
.admin-screen-wrap {
--admin-sidebar-w: var(--desktop-rail-width);
--admin-header-h: 60px;
+ --admin-card-bg:
+ linear-gradient(135deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)),
+ var(--admin-surface);
+ --admin-card-shadow:
+ 0 18px 48px rgba(0, 0, 0, 0.22),
+ inset 0 1px 0 rgba(255, 255, 255, 0.05);
position: fixed;
inset: 0;
width: 100vw;
height: 100dvh;
- background: var(--admin-bg);
+ background:
+ radial-gradient(circle at 18% 0%, color-mix(in srgb, var(--accent) 10%, transparent), transparent 32%),
+ #02070b;
color: var(--admin-text);
overflow: hidden;
overscroll-behavior: contain;
@@ -29,10 +37,12 @@
height: 100%;
display: flex;
flex-direction: column;
- gap: 4px;
- padding: 14px 12px 18px;
+ gap: 2px;
+ padding: 28px 14px;
border-right: 1px solid var(--admin-border);
- background: var(--admin-surface);
+ background: rgba(7, 12, 17, 0.55);
+ backdrop-filter: blur(14px);
+ box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.02);
overflow-y: auto;
}
@@ -42,17 +52,18 @@
.admin-sidebar-brand {
display: grid;
- grid-template-columns: 36px minmax(0, 1fr) 32px;
+ grid-template-columns: 38px minmax(0, 1fr) 34px;
align-items: center;
- gap: 10px;
- padding: 6px 6px 14px;
- margin-bottom: 6px;
+ gap: 12px;
+ padding: 4px 12px 18px;
+ margin-bottom: 8px;
border-bottom: 1px solid var(--admin-border);
+ color: var(--admin-text);
}
.admin-sidebar-brand .admin-brand-mark {
- width: 36px;
- height: 36px;
+ width: 38px;
+ height: 38px;
display: grid;
place-items: center;
color: var(--accent);
@@ -60,9 +71,14 @@
.admin-sidebar-brand strong {
display: block;
- font-size: 13px;
- font-weight: 700;
+ min-width: 0;
+ overflow: hidden;
+ color: var(--accent);
+ font-size: 14px;
+ font-weight: 800;
letter-spacing: -0.01em;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
.admin-sidebar-brand small {
@@ -82,54 +98,52 @@
.admin-nav {
display: grid;
- gap: 1px;
+ gap: 2px;
}
.admin-nav-item {
position: relative;
display: grid;
- grid-template-columns: 18px minmax(0, 1fr) auto;
+ grid-template-columns: 22px minmax(0, 1fr) auto;
align-items: center;
- gap: 10px;
- padding: 9px 12px;
- border-radius: 8px;
- border: 0;
+ gap: 12px;
+ padding: 12px 14px;
+ border-radius: 10px;
+ border: 1px solid transparent;
background: transparent;
color: var(--admin-muted);
font-size: 13px;
- font-weight: 500;
+ font-weight: 600;
text-align: left;
width: 100%;
cursor: pointer;
- transition: background 0.12s ease, color 0.12s ease;
+ transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
+}
+
+.admin-nav-item > svg {
+ width: 20px;
+ height: 20px;
}
.admin-nav-item:hover {
- background: var(--admin-surface-2);
+ background: rgba(255, 255, 255, 0.04);
color: var(--admin-text);
}
.admin-nav-item.active {
- background: var(--admin-elev);
+ background: color-mix(in srgb, var(--accent) 16%, transparent);
+ border-color: color-mix(in srgb, var(--accent) 30%, transparent);
color: var(--admin-text);
font-weight: 600;
}
.admin-nav-item.active::before {
- content: "";
- position: absolute;
- width: 3px;
- height: 18px;
- left: -4px;
- top: 50%;
- transform: translateY(-50%);
- border-radius: 2px;
- background: var(--accent);
+ display: none;
}
.admin-sidebar-footer {
margin-top: auto;
- padding: 10px 6px 0;
+ padding: 14px 0 0;
border-top: 1px solid var(--admin-border);
font-size: 11px;
color: var(--admin-dim);
@@ -156,8 +170,8 @@
justify-content: space-between;
gap: 8px;
border: 1px solid var(--admin-border);
- border-radius: 8px;
- background: var(--admin-surface-2);
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.035);
color: var(--admin-text);
padding: 8px 10px;
text-align: left;
@@ -237,9 +251,10 @@
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 16px;
- padding: 0 24px;
+ padding: 0 28px;
border-bottom: 1px solid var(--admin-border);
- background: var(--admin-bg);
+ background: color-mix(in srgb, #02070b 82%, transparent);
+ backdrop-filter: blur(14px);
}
.admin-header-title {
@@ -285,7 +300,7 @@
overscroll-behavior: contain;
overflow-y: auto;
overflow-x: hidden;
- padding: 22px 24px 32px;
+ padding: 24px 28px 36px;
display: flex;
flex-direction: column;
gap: 16px;
@@ -300,8 +315,9 @@
.admin-card {
border: 1px solid var(--admin-border);
- background: var(--admin-surface);
+ background: var(--admin-card-bg);
border-radius: 12px;
+ box-shadow: var(--admin-card-shadow);
display: grid;
grid-template-columns: minmax(0, 1fr);
min-width: 0;
@@ -347,7 +363,7 @@
padding: 48px 16px;
border: 1px dashed var(--admin-border);
border-radius: 12px;
- background: var(--admin-surface);
+ background: var(--admin-card-bg);
color: var(--admin-muted);
font-size: 13px;
}
@@ -383,8 +399,9 @@
.admin-stat-card {
border: 1px solid var(--admin-border);
- background: var(--admin-surface);
+ background: var(--admin-card-bg);
border-radius: 12px;
+ box-shadow: var(--admin-card-shadow);
padding: 16px 18px;
display: grid;
gap: 6px;
@@ -443,8 +460,9 @@
.admin-revenue-panel {
border: 1px solid var(--admin-border);
- background: var(--admin-surface);
+ background: var(--admin-card-bg);
border-radius: 12px;
+ box-shadow: var(--admin-card-shadow);
padding: 16px 18px;
display: grid;
gap: 14px;
@@ -572,8 +590,9 @@
.admin-dashboard-panel-card {
border: 1px solid var(--admin-border);
- background: var(--admin-surface);
+ background: var(--admin-card-bg);
border-radius: 12px;
+ box-shadow: var(--admin-card-shadow);
padding: 16px 18px;
display: grid;
gap: 14px;
@@ -646,8 +665,9 @@
.admin-stat-skeleton-card {
border: 1px solid var(--admin-border);
- background: var(--admin-surface);
+ background: var(--admin-card-bg);
border-radius: 12px;
+ box-shadow: var(--admin-card-shadow);
padding: 16px 18px;
display: grid;
gap: 10px;
@@ -678,7 +698,8 @@
padding: 12px;
border: 1px solid var(--admin-border);
border-radius: 12px;
- background: var(--admin-surface);
+ background: var(--admin-card-bg);
+ box-shadow: var(--admin-card-shadow);
}
.admin-toolbar-card {
@@ -689,7 +710,8 @@
padding: 12px;
border: 1px solid var(--admin-border);
border-radius: 12px;
- background: var(--admin-surface);
+ background: var(--admin-card-bg);
+ box-shadow: var(--admin-card-shadow);
}
.admin-toolbar-search {
@@ -776,7 +798,8 @@
.admin-table-wrap {
border: 1px solid var(--admin-border);
border-radius: 12px;
- background: var(--admin-surface);
+ background: var(--admin-card-bg);
+ box-shadow: var(--admin-card-shadow);
overflow: hidden;
}
@@ -799,7 +822,7 @@
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 12px 16px;
- background: var(--admin-surface-2);
+ background: color-mix(in srgb, var(--admin-surface-2) 72%, var(--admin-bg));
border-bottom: 1px solid var(--admin-border);
white-space: nowrap;
}
@@ -820,7 +843,7 @@
}
.admin-table tbody tr:hover {
- background: color-mix(in srgb, var(--admin-elev) 50%, transparent);
+ background: rgba(255, 255, 255, 0.035);
}
.admin-table td.admin-cell-mono {
@@ -2235,8 +2258,8 @@
height: 36px;
padding: 0 12px;
border: 1px solid var(--admin-border-strong);
- border-radius: 8px;
- background: var(--admin-bg);
+ border-radius: 10px;
+ background: color-mix(in srgb, var(--admin-bg) 82%, var(--admin-surface-2));
color: var(--admin-text);
font: inherit;
font-size: 13px;
@@ -2273,7 +2296,9 @@
overflow-y: auto;
border: 1px solid var(--admin-border);
border-radius: 10px;
- background: var(--admin-surface);
+ background:
+ linear-gradient(135deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)),
+ var(--admin-surface);
padding: 4px;
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.45);
outline: none;
diff --git a/bot/app/web/frontend/src/styles/components.css b/bot/app/web/frontend/src/styles/components.css
index a611d43..6c394e6 100644
--- a/bot/app/web/frontend/src/styles/components.css
+++ b/bot/app/web/frontend/src/styles/components.css
@@ -25,8 +25,12 @@
min-width: 0;
border-radius: 12px;
border: 1px solid var(--admin-border);
- background: var(--admin-surface);
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
+ background:
+ linear-gradient(135deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)),
+ var(--admin-surface);
+ box-shadow:
+ 0 18px 48px rgba(0, 0, 0, 0.22),
+ inset 0 1px 0 rgba(255, 255, 255, 0.05);
position: relative;
overflow: hidden;
}
@@ -180,3 +184,108 @@
background: color-mix(in srgb, var(--admin-surface-2) 70%, var(--admin-bg));
color: var(--admin-muted);
}
+
+.ui-spinner,
+.telegram-button-spinner {
+ width: 16px;
+ height: 16px;
+ border-radius: 999px;
+ border: 2px solid currentColor;
+ border-right-color: transparent;
+ display: inline-block;
+ flex: 0 0 auto;
+ animation: ui-spin 0.72s linear infinite;
+}
+
+.ui-spinner-sm {
+ width: 13px;
+ height: 13px;
+ border-width: 2px;
+}
+
+.ui-spinner-lg {
+ width: 24px;
+ height: 24px;
+ border-width: 3px;
+}
+
+@keyframes ui-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.ui-skeleton,
+.admin-skeleton,
+.skeleton-line,
+.skeleton-dot,
+.skeleton-pay-button {
+ display: inline-block;
+ background: linear-gradient(
+ 90deg,
+ rgba(255, 255, 255, 0.08),
+ rgba(255, 255, 255, 0.14),
+ rgba(255, 255, 255, 0.08)
+ );
+ background-size: 220% 100%;
+ animation: ui-skeleton-pulse 1.2s ease-in-out infinite;
+}
+
+.ui-skeleton {
+ width: 100%;
+ min-height: 40px;
+ border-radius: 10px;
+}
+
+.ui-skeleton-line,
+.admin-skeleton-line,
+.skeleton-line {
+ width: 100%;
+ height: 10px;
+ min-height: 0;
+ border-radius: 999px;
+}
+
+.ui-skeleton-title,
+.skeleton-line-title {
+ height: 14px;
+ min-height: 0;
+ width: 72%;
+}
+
+.ui-skeleton-short,
+.admin-skeleton-line-short,
+.skeleton-line-short {
+ width: 56%;
+}
+
+.ui-skeleton-tiny,
+.admin-skeleton-line-tiny,
+.skeleton-line-tiny {
+ width: 34%;
+}
+
+.ui-skeleton-badge,
+.admin-skeleton-badge {
+ width: 64px;
+ height: 18px;
+ min-height: 0;
+ border-radius: 999px;
+}
+
+.ui-skeleton-dot,
+.skeleton-dot {
+ width: 18px;
+ height: 18px;
+ min-height: 0;
+ border-radius: 999px;
+}
+
+@keyframes ui-skeleton-pulse {
+ 0% {
+ background-position: 120% 0;
+ }
+ 100% {
+ background-position: -120% 0;
+ }
+}
diff --git a/bot/app/web/frontend/src/styles/webapp.css b/bot/app/web/frontend/src/styles/webapp.css
index 0cc93f0..c88a916 100644
--- a/bot/app/web/frontend/src/styles/webapp.css
+++ b/bot/app/web/frontend/src/styles/webapp.css
@@ -821,22 +821,26 @@ a {
.skeleton-line-price {
width: 58px;
height: 12px;
+ min-height: 0;
}
.skeleton-line-tiny {
width: 34px;
height: 9px;
+ min-height: 0;
opacity: 0.7;
}
.skeleton-line-method {
width: 72px;
height: 12px;
+ min-height: 0;
}
.skeleton-dot {
width: 18px;
height: 18px;
+ min-height: 0;
flex: 0 0 auto;
}
@@ -2303,6 +2307,7 @@ a {
}
.bottom-nav .rail-brand strong {
+ color: var(--accent);
font-size: 14px;
font-weight: 800;
letter-spacing: -0.01em;
diff --git a/bot/app/web/frontend/src/webapp/BottomNav.svelte b/bot/app/web/frontend/src/webapp/BottomNav.svelte
index c49e8ce..b45070f 100644
--- a/bot/app/web/frontend/src/webapp/BottomNav.svelte
+++ b/bot/app/web/frontend/src/webapp/BottomNav.svelte
@@ -1,5 +1,5 @@