refactor: slice web app wip

This commit is contained in:
3252a8
2026-05-11 22:32:23 +03:00
parent 116b0a5c08
commit a575cb057b
9 changed files with 177 additions and 427 deletions
+55 -380
View File
@@ -1,5 +1,4 @@
<script>
import { Bitcoin, CreditCard } from "lucide-svelte";
import { onMount, setContext } from "svelte";
import { createAuthStore } from "./lib/webapp/stores/authStore.js";
import { createBillingStore } from "./lib/webapp/stores/billingStore.js";
@@ -29,39 +28,17 @@
TELEGRAM_WEBAPP_SCRIPT_URL,
WEBAPP_LANGUAGE_ORDER,
} from "./lib/webapp/constants.js";
import { applyFavicon, readJsonScript, structuredCloneSafe } from "./lib/webapp/browser.js";
import { createApiClient } from "./lib/webapp/publicApi.js";
import { createI18n } from "./lib/webapp/i18n.js";
import {
formatCompactNumber,
formatTrafficGb,
normalizedEmail,
telegramName,
} from "./lib/webapp/formatters.js";
import {
activeTariffName,
buildTariffCatalog,
priceLabel,
} from "./lib/webapp/tariffs.js";
import {
premiumTitle,
premiumTrafficPercent,
trafficPercent,
} from "./lib/webapp/traffic.js";
import { normalizedEmail, telegramName } from "./lib/webapp/formatters.js";
import { activeTariffName, buildTariffCatalog } from "./lib/webapp/tariffs.js";
import { premiumTrafficPercent, trafficPercent } from "./lib/webapp/traffic.js";
import { buildGravatarUrl } from "./lib/webapp/gravatar.js";
import { createBillingActions } from "./lib/webapp/billingActions.js";
import {
readReferralParam as readReferralParamHelper,
readTelegramAuthStatus,
readMagicLoginToken,
readTelegramLoginWidgetAuthData,
clearAuthQuery,
buildTelegramOAuthStartUrl as buildTelegramOAuthStartUrlHelper,
emailError as emailErrorHelper,
} from "./lib/webapp/authHelpers.js";
import { invalidateWebappTariffOptionCaches } from "./lib/webapp/billingOptionCache.js";
import { runWebappBoot } from "./lib/webapp/webappBoot.js";
import {
clearManualLogoutFlag as clearManualLogoutFlagInStorage,
clearStoredToken,
@@ -192,12 +169,24 @@
setContext("accountStore", accountStore);
$: ({ authStatus, authIsError, authBusy, telegramLoginBusy, loginEmailFieldError, loginEmailTooltipOpen, authResendCooldown, email, pendingEmail, emailCode } = $authStore);
$: ({ paymentModalOpen, paymentStep, selectedTariffKey, selectedPlan, selectedMethod, topupModalOpen, topupKind, deviceTopupModalOpen, changeModalOpen, topupOptions, deviceTopupOptions, changeOptions, selectedTopupPlan, selectedDeviceTopupPlan, selectedChangeTarget, selectedChangeAction, changeConfirmOpen, tariffActionBusy, payBusy } = $billingStore);
$: ({
paymentModalOpen,
selectedTariffKey,
selectedPlan,
topupModalOpen,
topupKind,
deviceTopupModalOpen,
changeModalOpen,
topupOptions,
deviceTopupOptions,
changeOptions,
changeConfirmOpen,
tariffActionBusy,
payBusy,
} = $billingStore);
$: ({ 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 || "🫥";
$: accent = CFG.primaryColor || "#00fe7a";
@@ -246,7 +235,6 @@
}));
$: currentLanguageOption = languageOptions.find((option) => option.value === currentLang) || languageOptions[0];
$: userLanguage = languageName(currentLang);
$: telegramLinkStatus = user?.telegram_linked ? t("wa_settings_linked") : t("wa_settings_not_linked");
$: emailLinkStatus = user?.email ? t("wa_settings_linked") : t("wa_settings_email_not_linked");
$: hasUnlinkedIdentity = !user?.telegram_linked || !user?.email;
$: referralBonusDetails = Array.isArray(referral?.bonus_details) ? referral.bonus_details : [];
@@ -342,9 +330,9 @@
return () => {
window.removeEventListener("popstate", onPopState);
window.removeEventListener("pointerdown", onAnyPointerDown);
stopTelegramLoginWatchdog();
clearCooldownTimer("auth");
clearCooldownTimer("link_email");
authStore.stopTelegramLoginWatchdog();
authStore.clearCooldownTimer();
accountStore.clearLinkEmailResendTimer();
clearLanguageClickGuard();
syncBodyScrollLock(false);
};
@@ -435,67 +423,33 @@
}
async function boot() {
mode = "loading";
if (hasTelegramLaunchParams()) await loadTelegramSdk(TELEGRAM_SDK_BOOT_TIMEOUT_MS);
if (tg) {
try {
tg.ready();
tg.expand();
} catch {}
}
if (MOCK) {
await loadData();
return;
}
const magicToken = readMagicLoginToken();
if (magicToken && (await finalizeMagicLogin(magicToken))) return;
const telegramAuthStatus = readTelegramAuthStatus();
if (telegramAuthStatus === "success") {
clearManualLogoutFlag();
clearAuthQuery();
try {
await loadData();
return;
} catch {
clearToken();
}
} else if (telegramAuthStatus) {
clearAuthQuery();
setAuthStatus(
telegramAuthStatus === "cancelled" ? t("wa_auth_telegram_cancelled") : t("wa_auth_telegram_not_confirmed"),
true,
);
}
if (isManuallyLoggedOut()) {
showLogin();
return;
}
const widgetAuthData = readTelegramLoginWidgetAuthData();
if (widgetAuthData && (await finalizeTelegramAuth(widgetAuthData, "auth_data"))) return;
const initData = telegramMiniAppInitData || tg?.initData || readTelegramMiniAppInitDataFromLocation();
if (initData) {
try {
if (await finalizeTelegramAuth(initData, "init_data")) return;
} catch {}
}
if (token || csrfToken) {
try {
await loadData();
return;
} catch {
clearToken();
}
}
showLogin();
await runWebappBoot({
MOCK,
setMode: (next) => {
mode = next;
},
hasTelegramLaunchParams,
loadTelegramSdk,
prepareTelegramMiniApp: () => {
if (!tg) return;
try {
tg.ready();
tg.expand();
} catch {}
},
loadData,
showLogin,
clearToken,
clearManualLogoutFlag,
isManuallyLoggedOut,
finalizeMagicLogin: (loginToken) => authStore.finalizeMagicLogin(loginToken),
finalizeTelegramAuth: (authData, source) => authStore.finalizeTelegramAuth(authData, source),
setAuthStatus: (message, isError) => authStore.setAuthStatus(message, isError),
t,
getInitDataForBoot: () => telegramMiniAppInitData || tg?.initData || readTelegramMiniAppInitDataFromLocation(),
getToken: () => token,
getCsrfToken: () => csrfToken,
});
}
async function loadData() {
@@ -567,78 +521,12 @@
return readManualLogoutFlag(MANUAL_LOGOUT_FLAG_KEY);
}
function readReferralParam() {
return readReferralParamHelper(tg);
}
function buildTelegramOAuthStartUrl(purpose = "login") {
return buildTelegramOAuthStartUrlHelper(purpose, tg);
}
function emailError(error, fallback) {
return emailErrorHelper(error, fallback, t);
}
function submitEmailOnEnter(event) {
if (event.key !== "Enter") return;
event.preventDefault();
authStore.requestEmailCode((s) => (screen = s));
}
function openExternalLink(url) {
if (!url) return;
if (tg?.openLink) {
@@ -648,22 +536,6 @@
window.location.assign(url);
}
function openTelegramInvoice(url) {
if (!url) return;
if (tg?.openInvoice) {
tg.openInvoice(url, (status) => {
if (status === "paid") {
showToast(t("wa_payment_success", {}, "Payment successful"));
loadData();
} else if (status === "failed") {
showToast(t("wa_payment_create_failed"));
}
});
return;
}
openExternalLink(url);
}
function openConnectLink() {
const url = subscription?.connect_url || subscription?.config_link;
if (!url) {
@@ -739,16 +611,6 @@
}
}
function showToast(message) {
toastText = message;
if (toastTimer) window.clearTimeout(toastTimer);
@@ -852,72 +714,15 @@
window.history.pushState(null, "", `${targetPath}${window.location.search}${window.location.hash}`);
}
async function handleTariffsSaved() {
billingStore.update((s) => ({ ...s, topupOptions: null, deviceTopupOptions: null, changeOptions: null }));
async function handleAdminPersistedSaved() {
invalidateWebappTariffOptionCaches(billingStore);
try {
await loadData();
} catch {
// The admin save already succeeded; a later app refresh will pick up the new catalog.
// Admin save already succeeded; a later full refresh will pick up new settings or catalog.
}
}
async function handleSettingsSaved() {
billingStore.update((s) => ({ ...s, topupOptions: null, deviceTopupOptions: null, changeOptions: null }));
try {
await loadData();
} catch {
// Settings were saved; the next app refresh will pick up the runtime values.
}
}
function methodMeta(method) {
const id = String(method?.id || "").toLowerCase();
if (id.includes("platega_sbp")) {
return { title: t("wa_method_platega_sbp_card"), icon: CreditCard };
}
if (id.includes("platega_crypto")) {
return { title: t("wa_method_platega_crypto"), icon: Bitcoin };
}
if (id.includes("yookassa") || id.includes("card")) {
return { title: t("pay_with_yookassa_button"), icon: null };
}
if (id.includes("severpay")) {
return { title: t("pay_with_severpay_button"), icon: null };
}
if (id.includes("freekassa")) {
return { title: t("pay_with_sbp_button"), icon: null };
}
if (id.includes("cryptopay")) {
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 };
}
if (id.includes("crypto")) {
return { title: t("pay_with_cryptopay_button"), icon: null };
}
return { title: t("wa_method_other_title"), icon: null };
}
function selectTariff(tariff) {
billingStore.selectTariff(tariff, plans);
}
@@ -930,140 +735,11 @@
billingStore.backToTariffList(subscription, tariffCatalog);
}
function paymentTitle() {
if (singleTariffMode) {
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");
}
function paymentDescription() {
if (tariffMode) {
if (singleTariffMode) {
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 })
: t("wa_tariffs_choose");
}
return trafficMode ? t("wa_traffic_packages_choose") : t("wa_subscription_choose_period");
}
function primaryPayActionLabel() {
if (trafficMode || selectedPlan?.sale_mode === "traffic_package") return t("wa_buy_traffic");
return subscription.active ? t("wa_renew") : t("wa_pay_subscription");
}
function changeActionTitle(action) {
const mode = String(action?.mode || "");
if (mode === "recalc_days") {
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) });
}
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) });
}
if (mode === "buy_period") {
return `${action?.title || ""} · ${priceLabel(action)}`;
}
return action?.title || mode;
}
function tariffChangeSummary() {
if (!selectedChangeTarget || !selectedChangeAction) return [];
const rows = [
t("wa_tariff_change_confirm_target", { tariff: selectedChangeTarget.title }),
t("wa_tariff_change_confirm_action", { action: changeActionTitle(selectedChangeAction) }),
];
const mode = String(selectedChangeAction.mode || "");
if (mode === "recalc_days") {
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) }));
} else if (selectedChangeAction.kind === "payment") {
rows.push(t("wa_tariff_change_confirm_payment", { price: priceLabel(selectedChangeAction) }));
}
return rows;
}
function topupWarningText() {
const percent = Number(topupOptions?.traffic_percent || trafficPercent(subscription));
const levels = topupOptions?.warning_levels?.length ? topupOptions.warning_levels.join(" / ") : "85 / 90 / 95";
if (percent >= 95) return t("wa_topup_warning_critical", { percent, levels });
if (percent >= 90) return t("wa_topup_warning_high", { percent, levels });
if (percent >= 85) return t("wa_topup_warning_medium", { percent, levels });
return t("wa_topup_warning_levels", { levels });
}
function topupModalDescription() {
if (!topupOptions) return "";
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 }) : "";
}
function isPremiumTopupContext() {
if (selectedTopupPlan?.sale_mode === "premium_topup") return true;
if (topupOptions?.topup_kind) return topupOptions.topup_kind === "premium";
return topupKind === "premium";
}
function topupModalTitle() {
if (isPremiumTopupContext()) return premiumTitle(topupOptions || subscription);
return t("wa_topup_traffic");
}
function topupCarryoverNotes() {
const plans = topupOptions?.plans || [];
if (!plans.length) return [];
return [
t(
"wa_topup_carryover",
{},
"Докупленный трафик не сгорает: сначала расходуется месячный лимит, затем докупленный остаток."
),
];
}
function deviceTopupModalDescription() {
if (!deviceTopupOptions) return "";
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 }) : "";
}
function trialTrafficLabel() {
const limit = Number(appSettings?.trial_traffic_limit_gb || 0);
return limit > 0 ? formatTrafficGb(limit) : t("wa_unlimited_traffic");
}
function devicesLimitLabel(value = devicesData?.max_devices) {
const numeric = Number(value ?? 0);
if (!Number.isFinite(numeric) || numeric <= 0) return t("wa_devices_unlimited");
return String(Math.trunc(numeric));
}
function devicesCountLabel() {
const current = Number(devicesData?.current_devices ?? devicesData?.devices?.length ?? 0);
return t("wa_devices_count", { current, max: devicesLimitLabel() });
}
function devicesPercent() {
const current = Number(devicesData?.current_devices ?? devicesData?.devices?.length ?? 0);
const max = Number(devicesData?.max_devices || 0);
if (!max || max <= 0) return 100;
return Math.max(0, Math.min(100, Math.round((current / max) * 100)));
}
</script>
<svelte:head>
@@ -1123,8 +799,8 @@
initialSection={adminSectionFromPath(window.location.pathname)}
initialUserId={adminUserIdFromPath(window.location.pathname)}
onSectionChange={handleAdminSectionChange}
onSettingsSaved={handleSettingsSaved}
onTariffsSaved={handleTariffsSaved}
onSettingsSaved={handleAdminPersistedSaved}
onTariffsSaved={handleAdminPersistedSaved}
brandTitle={brandTitle}
logoUrl={CFG.logoUrl}
logoEmoji={brandEmoji}
@@ -1313,7 +989,6 @@
{topupOptions}
{trafficMode}
{t}
{termUnitLabel}
/>
{/if}
@@ -0,0 +1,4 @@
/** Drop cached topup / change-tariff option payloads so the next open refetches from /api. */
export function invalidateWebappTariffOptionCaches(billingStore) {
billingStore.update((s) => ({ ...s, topupOptions: null, deviceTopupOptions: null, changeOptions: null }));
}
@@ -0,0 +1,24 @@
/**
* Pure helpers for HWID / device limits UI (used by DevicesScreen).
* @param {Record<string, unknown>} devicesData API payload from /api/devices
* @param {(key: string, vars?: Record<string, unknown>, fallback?: string) => string} t i18n function
* @param {unknown} [maxDevicesOverride] optional max_devices override (defaults to devicesData.max_devices)
*/
export function devicesLimitLabel(devicesData, t, maxDevicesOverride) {
const value = maxDevicesOverride !== undefined ? maxDevicesOverride : devicesData?.max_devices;
const numeric = Number(value ?? 0);
if (!Number.isFinite(numeric) || numeric <= 0) return t("wa_devices_unlimited");
return String(Math.trunc(numeric));
}
export function devicesCountLabel(devicesData, t) {
const current = Number(devicesData?.current_devices ?? devicesData?.devices?.length ?? 0);
return t("wa_devices_count", { current, max: devicesLimitLabel(devicesData, t) });
}
export function devicesPercent(devicesData) {
const current = Number(devicesData?.current_devices ?? devicesData?.devices?.length ?? 0);
const max = Number(devicesData?.max_devices || 0);
if (!max || max <= 0) return 100;
return Math.max(0, Math.min(100, Math.round((current / max) * 100)));
}
@@ -211,5 +211,6 @@ export function createAccountStore({ api, publicApi, setToken, loadData, t, show
linkTelegramAccount,
updateAccountLanguage,
logout,
clearLinkEmailResendTimer: clearCooldownTimer,
};
}
@@ -269,6 +269,7 @@ export function createAuthStore({
verifyEmailCode,
openTelegramLogin,
clearCooldownTimer,
setAuthStatus
stopTelegramLoginWatchdog,
setAuthStatus,
};
}
@@ -63,28 +63,6 @@ export function createDevicesStore({ api, t, showToast }) {
}
}
function devicesLimitLabel() {
const s = get(state);
const value = s.devicesData?.max_devices;
const numeric = Number(value ?? 0);
if (!Number.isFinite(numeric) || numeric <= 0) return t("wa_devices_unlimited");
return String(Math.trunc(numeric));
}
function devicesCountLabel() {
const s = get(state);
const current = Number(s.devicesData?.current_devices ?? s.devicesData?.devices?.length ?? 0);
return t("wa_devices_count", { current, max: devicesLimitLabel() });
}
function devicesPercent() {
const s = get(state);
const current = Number(s.devicesData?.current_devices ?? s.devicesData?.devices?.length ?? 0);
const max = Number(s.devicesData?.max_devices || 0);
if (!max || max <= 0) return 100;
return Math.max(0, Math.min(100, Math.round((current / max) * 100)));
}
return {
subscribe: state.subscribe,
set: state.set,
@@ -93,8 +71,5 @@ export function createDevicesStore({ api, t, showToast }) {
openDeviceDisconnectDialog,
closeDeviceDisconnectDialog,
disconnectDevice,
devicesLimitLabel,
devicesCountLabel,
devicesPercent,
};
}
@@ -0,0 +1,87 @@
import {
readMagicLoginToken,
readTelegramAuthStatus,
readTelegramLoginWidgetAuthData,
clearAuthQuery,
} from "./authHelpers.js";
import { TELEGRAM_SDK_BOOT_TIMEOUT_MS } from "./constants.js";
/**
* Initial auth / session bootstrap for the subscription webapp (non-preview).
* Keeps side effects in App (mode, tg, token) via injected callbacks.
*/
export async function runWebappBoot({
MOCK,
setMode,
hasTelegramLaunchParams,
loadTelegramSdk,
prepareTelegramMiniApp,
loadData,
showLogin,
clearToken,
clearManualLogoutFlag,
isManuallyLoggedOut,
finalizeMagicLogin,
finalizeTelegramAuth,
setAuthStatus,
t,
getInitDataForBoot,
getToken,
getCsrfToken,
}) {
setMode("loading");
if (hasTelegramLaunchParams()) await loadTelegramSdk(TELEGRAM_SDK_BOOT_TIMEOUT_MS);
prepareTelegramMiniApp();
if (MOCK) {
await loadData();
return;
}
const magicToken = readMagicLoginToken();
if (magicToken && (await finalizeMagicLogin(magicToken))) return;
const telegramAuthStatus = readTelegramAuthStatus();
if (telegramAuthStatus === "success") {
clearManualLogoutFlag();
clearAuthQuery();
try {
await loadData();
return;
} catch {
clearToken();
}
} else if (telegramAuthStatus) {
clearAuthQuery();
setAuthStatus(
telegramAuthStatus === "cancelled" ? t("wa_auth_telegram_cancelled") : t("wa_auth_telegram_not_confirmed"),
true,
);
}
if (isManuallyLoggedOut()) {
showLogin();
return;
}
const widgetAuthData = readTelegramLoginWidgetAuthData();
if (widgetAuthData && (await finalizeTelegramAuth(widgetAuthData, "auth_data"))) return;
const initData = getInitDataForBoot();
if (initData) {
try {
if (await finalizeTelegramAuth(initData, "init_data")) return;
} catch {}
}
if (getToken() || getCsrfToken()) {
try {
await loadData();
return;
} catch {
clearToken();
}
}
showLogin();
}
@@ -140,7 +140,6 @@
export let t = (key) => key;
export let termUnitLabel = () => "";
</script>
<Dialog
@@ -3,6 +3,7 @@
import Button from "../../lib/components/ui/button.svelte";
import Card from "../../lib/components/ui/card.svelte";
import { devicesCountLabel, devicesLimitLabel, devicesPercent } from "../../lib/webapp/devicesLabels.js";
export let devicesBusy = false;
export let devicesData = {};
@@ -15,23 +16,6 @@
export let openDeviceDisconnectDialog = () => {};
export let openDeviceTopupModal = () => {};
export let t = (key) => key;
function devicesLimitLabel(value = devicesData?.max_devices) {
const numeric = Number(value ?? 0);
if (!Number.isFinite(numeric) || numeric <= 0) return t("wa_devices_unlimited");
return String(Math.trunc(numeric));
}
function devicesCountLabel() {
const current = Number(devicesData?.current_devices ?? devicesData?.devices?.length ?? 0);
return t("wa_devices_count", { current, max: devicesLimitLabel() });
}
function devicesPercent() {
const current = Number(devicesData?.current_devices ?? devicesData?.devices?.length ?? 0);
const max = Number(devicesData?.max_devices || 0);
if (!max || max <= 0) return 100;
return Math.max(0, Math.min(100, Math.round((current / max) * 100)));
}
</script>
@@ -41,14 +25,14 @@
<Smartphone size={28} />
<span>
<strong>{t("wa_devices_title")}</strong>
<small>{devicesCountLabel()}</small>
<small>{devicesCountLabel(devicesData, t)}</small>
</span>
<Button variant="icon" size="icon" onclick={() => loadDevices(true)} disabled={devicesBusy} aria-label={t("wa_devices_refresh")}>
<RefreshCw size={18} />
</Button>
</div>
<div class="progress devices-progress">
<span style={`width: ${devicesPercent()}%`}></span>
<span style={`width: ${devicesPercent(devicesData)}%`}></span>
</div>
{#if subscription?.active && subscription?.max_devices !== 0}
<Button variant="secondary" class="wide" onclick={openDeviceTopupModal}>
@@ -68,7 +52,7 @@
<Card class="empty-card devices-empty-card">
<Smartphone size={28} />
<span>{t("wa_devices_empty")}</span>
<small>{t("wa_devices_empty_hint", { max: devicesLimitLabel() })}</small>
<small>{t("wa_devices_empty_hint", { max: devicesLimitLabel(devicesData, t) })}</small>
</Card>
{:else}
<div class="devices-list">