refactor: project architecture refactor, container splitting

This commit is contained in:
3252a8
2026-05-17 00:01:28 +03:00
parent e0b5218037
commit 30fb774d93
367 changed files with 2609 additions and 864 deletions
+63
View File
@@ -0,0 +1,63 @@
<script>
import {
Gift,
Home,
Settings as SettingsIcon,
Shield,
Smartphone,
} from "$components/ui/icons.js";
import BrandMark from "$lib/webapp/BrandMark.svelte";
export let activeTab = "home";
export let brand = {};
export let brandTitle = "";
export let devicesEnabled = false;
export let hasUnlinkedIdentity = false;
export let isAdmin = false;
export let onAdmin = () => {};
export let onDevices = () => {};
export let onHome = () => {};
export let onInvite = () => {};
export let onSettings = () => {};
export let t = (key) => key;
</script>
<nav class:bottom-nav-devices={devicesEnabled} class="bottom-nav" aria-label={t("wa_navigation")}>
<div class="rail-brand" aria-hidden="true">
<BrandMark {brand} />
<strong>{brandTitle}</strong>
</div>
<button class:active={activeTab === "home"} type="button" onclick={onHome}>
<Home size={21} />
<span>{t("wa_nav_home")}</span>
</button>
<button class:active={activeTab === "invite"} type="button" onclick={onInvite}>
<Gift size={21} />
<span>{t("wa_nav_bonuses")}</span>
</button>
{#if devicesEnabled}
<button class:active={activeTab === "devices"} type="button" onclick={onDevices}>
<Smartphone size={21} />
<span>{t("wa_nav_devices")}</span>
</button>
{/if}
<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}
<SettingsIcon size={21} />
<span>{t("wa_nav_settings")}</span>
</button>
{#if isAdmin}
<button class="rail-admin-entry" type="button" onclick={onAdmin}>
<Shield size={21} />
<span>{t("admin_nav_title", {}, "Админ-панель")}</span>
</button>
{/if}
</nav>
+382
View File
@@ -0,0 +1,382 @@
<script>
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 {
planKey as planKeyFn,
planDisplayTitle as planDisplayTitleFn,
planSubtitle as planSubtitleFn,
planUnitHint as planUnitHintFn,
tariffLimitLabel as tariffLimitLabelFn,
priceLabel as priceLabelFn,
} from "../lib/webapp/tariffs.js";
export let createPayment = () => {};
export let deviceConfirmOpen = false;
export let deviceDisconnectBusy = false;
export let deviceToDisconnect = null;
export let disconnectDevice = () => {};
export let linkEmailBusy = false;
export let linkEmailCode = "";
export let linkEmailFieldError = "";
export let linkEmailIsError = false;
export let linkEmailOpen = false;
export let linkEmailPending = "";
export let linkEmailResendCooldown = 0;
export let linkEmailStatus = "";
export let linkEmailValue = "";
export let hasMultipleTariffs = false;
export let methods = [];
export let payBusy = false;
export let paymentModalOpen = false;
export let paymentStep = "tariff";
export let plans = [];
export let selectedMethod = "";
export let selectedPlan = null;
export let selectedTariff = null;
export let selectedTariffKey = "";
export let selectedTariffPlans = [];
export let singleTariffMode = false;
export let subscription = {};
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 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");
}
export let closeDeviceDisconnectDialog = () => {};
export let closeLinkEmailDialog = () => {};
export let closePaymentModal = () => {};
export let backToTariffList = () => {};
export let continueWithSelectedTariff = () => {};
export let requestLinkEmailCode = () => {};
export let selectTariff = () => {};
export let t = (key) => key;
export let termUnitLabel = () => "";
export let verifyLinkEmailCode = () => {};
</script>
<Dialog
open={paymentModalOpen}
title={paymentTitle()}
description={paymentDescription()}
closeLabel={t("wa_close")}
onclose={closePaymentModal}
class="payment-dialog-card"
>
<div class="payment-dialog-body">
{#if tariffMode && !singleTariffMode && paymentStep === "tariff"}
{#if tariffCatalog.length}
<div class="option-list tariff-list">
{#each tariffCatalog as tariff}
<button
class:active={selectedTariffKey === tariff.key}
class="option-row tariff-row"
type="button"
onclick={() => selectTariff(tariff)}
>
<span class="option-row-main">
<strong>{tariff.title}</strong>
<small>{tariff.description || t("wa_tariff_no_description")}</small>
</span>
<span class="option-row-meta">
<em>{tariffLimitLabel(tariff)}</em>
{#if selectedTariffKey === tariff.key}
<CheckCircle2 size={18} />
{:else}
<ArrowRight size={17} />
{/if}
</span>
</button>
{/each}
</div>
<Button
class="wide bottom-action payment-submit-button"
onclick={continueWithSelectedTariff}
disabled={!selectedTariffKey}
>
{t("wa_next")}
<ArrowRight size={17} />
</Button>
{:else}
<EmptyCard>{t("wa_no_tariff_change_options")}</EmptyCard>
{/if}
{:else}
{#if tariffMode}
{#if !singleTariffMode && !(subscription?.active && subscription?.tariff_key && tariffCatalog.some((t) => t.key === subscription.tariff_key))}
<button class="back-inline" type="button" onclick={backToTariffList}>
<ArrowLeft size={16} />
{t("wa_back_to_tariffs")}
</button>
{/if}
{#if hasMultipleTariffs && selectedTariff}
<p class="tariff-step-caption">
{t("wa_selected_tariff", { tariff: selectedTariff.title })}
</p>
{/if}
{/if}
{#if selectedTariffPlans.length}
<div class="period-grid period-grid-two-columns">
{#each selectedTariffPlans as plan}
<button
class:active={planKey(selectedPlan) === planKey(plan)}
class="period-card"
type="button"
onclick={() => (selectedPlan = plan)}
>
<strong>{planSubtitle(plan) || planDisplayTitle(plan)}</strong>
<span>{priceLabel(plan)}</span>
{#if planUnitHint(plan)}
<small>{planUnitHint(plan)}</small>
{/if}
{#if planKey(selectedPlan) === planKey(plan)}
<CheckCircle2 size={18} />
{/if}
</button>
{/each}
</div>
<div class="payment-divider" aria-hidden="true"></div>
{#if methods.length}
<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) : ""}
<LockKeyhole size={17} />
</Button>
{:else}
<EmptyCard>{t("wa_no_tariff_change_options")}</EmptyCard>
{/if}
{/if}
{#if !tariffMode}
<div class="period-grid period-grid-two-columns">
{#each plans as plan}
<button
class:active={planKey(selectedPlan) === planKey(plan)}
class="period-card"
type="button"
onclick={() => (selectedPlan = plan)}
>
<strong>{planDisplayTitle(plan)}</strong>
{#if planSubtitle(plan)}
<em>{planSubtitle(plan)}</em>
{/if}
<span>{priceLabel(plan)}</span>
{#if planUnitHint(plan)}
<small>{planUnitHint(plan)}</small>
{/if}
{#if planKey(selectedPlan) === planKey(plan)}
<CheckCircle2 size={18} />
{/if}
</button>
{/each}
</div>
<div class="payment-divider" aria-hidden="true"></div>
{#if methods.length}
<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) : ""}
<LockKeyhole size={17} />
</Button>
{/if}
</div>
</Dialog>
<Dialog
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 || "" }),
})}
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}
>
<CircleX size={17} />
{t("wa_devices_disconnect_confirm")}
</Button>
<Button
variant="secondary"
class="wide"
onclick={closeDeviceDisconnectDialog}
disabled={deviceDisconnectBusy}
>
{t("wa_cancel")}
</Button>
</div>
</Dialog>
<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")}
closeLabel={t("wa_close")}
onclose={closeLinkEmailDialog}
class={`payment-dialog-card${linkEmailPending ? " link-email-dialog-card" : ""}`}
>
<div class="payment-dialog-body">
{#if !linkEmailPending}
<div class="field-error-wrap">
<Tooltip.Root open={Boolean(linkEmailFieldError)}>
<Input
bind:value={linkEmailValue}
type="email"
placeholder={t("wa_email_placeholder")}
autocomplete="email"
class={linkEmailFieldError ? "input-error" : ""}
on:input={() => (linkEmailFieldError = "")}
/>
{#if linkEmailFieldError}
<Tooltip.Trigger class="field-error-trigger" aria-label={linkEmailFieldError}>
<span class="field-error-icon" aria-hidden="true"><TriangleAlert size={18} /></span>
</Tooltip.Trigger>
{/if}
{#if linkEmailFieldError}
<Tooltip.Portal>
<Tooltip.Content class="field-error-tooltip">{linkEmailFieldError}</Tooltip.Content>
</Tooltip.Portal>
{/if}
</Tooltip.Root>
</div>
<Button
class="wide bottom-action payment-submit-button"
onclick={requestLinkEmailCode}
disabled={linkEmailBusy}
>
{t("wa_send_code_email")}
</Button>
{:else}
<div class="link-email-code-layout">
<div class="otp-wrap link-email-code-center">
<label class="otp-input-wrap">
<input
bind:value={linkEmailCode}
inputmode="numeric"
autocomplete="one-time-code"
maxlength="6"
aria-label={t("wa_email_code_aria")}
/>
<span class="otp-slots" aria-hidden="true">
{#each Array.from({ length: 6 }) as _, index}
<span class:filled={linkEmailCode[index]}>{linkEmailCode[index] || ""}</span>
{/each}
</span>
</label>
<Button
class="wide bottom-action payment-submit-button"
onclick={verifyLinkEmailCode}
disabled={linkEmailBusy}
>
{t("wa_confirm")}
</Button>
</div>
<button
class="link-button link-email-resend"
type="button"
onclick={requestLinkEmailCode}
disabled={linkEmailBusy || linkEmailResendCooldown > 0}
>
<RefreshCw size={15} />
{linkEmailResendCooldown > 0
? t("wa_auth_resend_wait", { seconds: linkEmailResendCooldown })
: t("wa_resend_code")}
</button>
</div>
{/if}
{#if linkEmailStatus}
<StatusMessage error={linkEmailIsError}>{linkEmailStatus}</StatusMessage>
{/if}
</div>
</Dialog>
+411
View File
@@ -0,0 +1,411 @@
<script>
import { ArrowRight, CheckCircle2, LockKeyhole } from "$components/ui/icons.js";
import Button from "$components/ui/button.svelte";
import {
planKey as planKeyFn,
planUnitHint as planUnitHintFn,
priceLabel as priceLabelFn,
actionKey as actionKeyFn,
} from "../lib/webapp/tariffs.js";
import { premiumTitle as premiumTitleFn } 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";
export let applyTariffChange = () => {};
export let changeConfirmOpen = false;
export let changeModalOpen = false;
export let changeOptions = null;
export let closeDeviceTopupModal = () => {};
export let closeTariffChangeConfirm = () => {};
export let closeTariffChangeModal = () => {};
export let closeTopupModal = () => {};
export let createDeviceTopupPayment = () => {};
export let createTopupPayment = () => {};
export let deviceTopupModalOpen = false;
export let deviceTopupOptions = null;
export let methods = [];
export let openTariffChangeConfirm = () => {};
export let payBusy = false;
export let selectedChangeAction = null;
export let selectedChangeTarget = null;
export let selectedDeviceTopupPlan = null;
export let selectedMethod = "";
export let selectedTopupPlan = null;
export let singleTariffMode = false;
export let tariffActionBusy = false;
export let topupModalOpen = false;
export let topupOptions = null;
export let topupKind = "regular";
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 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 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 isPremiumTopupContext() {
if (selectedTopupPlan?.sale_mode === "premium_topup") return true;
if (topupOptions?.topup_kind) return topupOptions.topup_kind === "premium";
return topupKind === "premium";
}
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 topupModalTitle() {
if (isPremiumTopupContext())
return premiumTitleFn({ ...subscription, ...(topupOptions || {}) }, t);
return t("wa_topup_traffic");
}
export let t = (key) => key;
</script>
<Dialog
open={changeModalOpen}
title={t("wa_change_tariff")}
description={tariffChangeModalDescription()}
closeLabel={t("wa_close")}
onclose={closeTariffChangeModal}
class="payment-dialog-card"
>
<div class="payment-dialog-body">
{#if !changeOptions}
<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">
{#each changeOptions.targets as target}
<button
class:active={selectedChangeTarget?.tariff_key === target.tariff_key}
class="tariff-action-card"
type="button"
onclick={() => {
selectedChangeTarget = target;
selectedChangeAction = target.actions?.[0] || null;
}}
>
<span>
<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
>
</button>
{/each}
</div>
{#if selectedChangeTarget?.actions?.length}
<div class="payment-divider" aria-hidden="true"></div>
<p class="section-kicker">{t("wa_tariff_change_strategy_title")}</p>
<div class="option-list">
{#each selectedChangeTarget.actions as action}
<button
class:active={actionKey(selectedChangeAction) === actionKey(action)}
class="option-row change-action-row"
type="button"
onclick={() => (selectedChangeAction = action)}
>
<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
>
{:else if action.mode === "convert_days_to_gb"}
<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}
</span>
{#if actionKey(selectedChangeAction) === actionKey(action)}
<CheckCircle2 size={18} />
{/if}
</button>
{/each}
</div>
{#if selectedChangeAction?.kind === "payment"}
<PaymentMethodGrid
{methods}
{selectedMethod}
{t}
onSelect={(id) => (selectedMethod = id)}
/>
{/if}
<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>
{:else}
<EmptyCard>{t("wa_no_tariff_change_options")}</EmptyCard>
{/if}
{:else}
<EmptyCard>{t("wa_no_tariff_change_options")}</EmptyCard>
{/if}
</div>
</Dialog>
<Dialog
open={changeConfirmOpen}
title={t("wa_tariff_change_confirm_title")}
description={t("wa_tariff_change_confirm_desc")}
closeLabel={t("wa_close")}
onclose={closeTariffChangeConfirm}
class="payment-dialog-card"
>
<div class="payment-dialog-body">
<Card class="confirm-summary-card">
{#each tariffChangeSummary() as row}
<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")}
<ArrowRight size={17} />
</Button>
<Button
variant="secondary"
class="wide"
onclick={closeTariffChangeConfirm}
disabled={tariffActionBusy || payBusy}
>
{t("wa_cancel")}
</Button>
</div>
</Dialog>
<Dialog
open={topupModalOpen}
title={topupModalTitle()}
description={topupModalDescription()}
closeLabel={t("wa_close")}
onclose={closeTopupModal}
class="payment-dialog-card"
>
<div class="payment-dialog-body">
{#if !topupOptions}
<DialogOptionsSkeleton label={t("wa_tariff_options_loading")} rows={3} showNote />
{:else if topupOptions?.plans?.length}
<div class="option-list">
{#each topupOptions.plans as plan}
<button
class:active={planKey(selectedTopupPlan) === planKey(plan)}
class="option-row plan-row"
type="button"
onclick={() => (selectedTopupPlan = plan)}
>
<span class="option-row-main">
<strong>{plan.title}</strong>
{#if !singleTariffMode || plan.sale_mode === "premium_topup"}
<small>{plan.subtitle || topupOptions.tariff_name}</small>
{/if}
</span>
<span class="option-row-meta">
<em>{priceLabel(plan)}</em>
{#if planUnitHint(plan)}
<small>{planUnitHint(plan)}</small>
{/if}
</span>
</button>
{/each}
</div>
{@const carryoverNotes = topupCarryoverNotes()}
{#if carryoverNotes.length}
<div class="topup-carryover-note">
{#each carryoverNotes as note}
<p>{note}</p>
{/each}
</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) : ""}
<LockKeyhole size={17} />
</Button>
{:else}
<EmptyCard>{t("wa_no_topup_options")}</EmptyCard>
{/if}
</div>
</Dialog>
<Dialog
open={deviceTopupModalOpen}
title={t("wa_buy_hwid_devices")}
description={deviceTopupModalDescription()}
closeLabel={t("wa_close")}
onclose={closeDeviceTopupModal}
class="payment-dialog-card"
>
<div class="payment-dialog-body">
{#if !deviceTopupOptions}
<DialogOptionsSkeleton label={t("wa_tariff_options_loading")} rows={3} />
{:else if deviceTopupOptions?.plans?.length}
<div class="option-list">
{#each deviceTopupOptions.plans as plan}
<button
class:active={planKey(selectedDeviceTopupPlan) === planKey(plan)}
class="option-row plan-row"
type="button"
onclick={() => (selectedDeviceTopupPlan = plan)}
>
<span class="option-row-main">
<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">
<em>{priceLabel(plan)}</em>
{#if planKey(selectedDeviceTopupPlan) === planKey(plan)}
<CheckCircle2 size={18} />
{/if}
</span>
</button>
{/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) : ""}
<LockKeyhole size={17} />
</Button>
{:else}
<EmptyCard>{t("wa_no_hwid_device_options")}</EmptyCard>
{/if}
</div>
</Dialog>
+46
View File
@@ -0,0 +1,46 @@
<script>
import BrandMark from "$lib/webapp/BrandMark.svelte";
import BottomNav from "./BottomNav.svelte";
export let screen;
export let activeTab;
export let brand = {};
export let brandTitle;
export let devicesEnabled;
export let hasUnlinkedIdentity;
export let isAdmin;
export let openAdminPanel;
export let goDevices;
export let goHome;
export let goInvite;
export let goSettings;
export let t;
</script>
<div class="phone-screen" class:home-screen={screen === "home"}>
{#if screen === "invite" || screen === "devices" || screen === "settings"}
<header class="app-header accent-title">
<div class="brand-row">
<BrandMark {brand} />
<strong>{brandTitle}</strong>
</div>
</header>
{/if}
<slot />
<BottomNav
{activeTab}
{brand}
{brandTitle}
{devicesEnabled}
{hasUnlinkedIdentity}
{isAdmin}
onAdmin={openAdminPanel}
onDevices={goDevices}
onHome={goHome}
onInvite={goInvite}
onSettings={goSettings}
{t}
/>
</div>
+199
View File
@@ -0,0 +1,199 @@
<script>
import { ArrowLeft, Mail, RefreshCw, Send, TriangleAlert } from "$components/ui/icons.js";
import { Tooltip } from "$components/ui/primitives.js";
import Button from "$components/ui/button.svelte";
import BrandMark from "$lib/webapp/BrandMark.svelte";
import Card from "$components/ui/card.svelte";
import Input from "$components/ui/input.svelte";
import Spinner from "$components/ui/spinner.svelte";
import { StatusMessage } from "$components/patterns/webapp/index.js";
export let screen;
export let CFG;
export let brand = {};
export let brandTitle;
export let email;
export let emailCode;
export let pendingEmail;
export let authStatus;
export let authIsError;
export let authBusy;
export let authResendCooldown;
export let loginEmailFieldError;
export let loginEmailTooltipOpen;
export let telegramLoginBusy;
export let telegramLoginUnavailable;
export let telegramLoginChecking;
export let telegramLoginLabel;
export let telegramLoginUnavailableMessage;
export let privacyPolicyUrl;
export let userAgreementUrl;
export let t;
export let requestEmailCode;
export let verifyEmailCode;
export let openTelegramLogin;
export let openExternalLink;
export let submitEmailOnEnter;
export let onBackToLogin;
export let clearLoginEmailError;
</script>
<div class="phone-screen auth-screen">
{#if screen === "code"}
<header class="screen-head center-title">
<Button variant="icon" size="icon" onclick={onBackToLogin} aria-label={t("wa_back")}>
<ArrowLeft size={19} />
</Button>
<div>
<h1>{t("wa_email_verification_title")}</h1>
<p>{t("wa_email_sent_to", { email: pendingEmail })}</p>
</div>
<span></span>
</header>
<div class="otp-wrap">
<label class="otp-input-wrap">
<input
bind:value={emailCode}
inputmode="numeric"
autocomplete="one-time-code"
maxlength="6"
aria-label={t("wa_email_code_aria")}
/>
<span class="otp-slots" aria-hidden="true">
{#each Array.from({ length: 6 }) as _, index}
<span class:filled={emailCode[index]}>{emailCode[index] || ""}</span>
{/each}
</span>
</label>
<Button class="wide" onclick={verifyEmailCode} disabled={authBusy}>
{t("wa_confirm")}
</Button>
{#if authStatus}
<StatusMessage error={authIsError}>{authStatus}</StatusMessage>
{/if}
<button
class="link-button"
type="button"
onclick={requestEmailCode}
disabled={authBusy || authResendCooldown > 0}
>
<RefreshCw size={15} />
{authResendCooldown > 0
? t("wa_auth_resend_wait", { seconds: authResendCooldown })
: t("wa_resend_code")}
</button>
</div>
{:else}
<div class="auth-card-wrap">
<div class="login-brand login-brand-auth">
<BrandMark {brand} size="xl" />
<h1>{brandTitle}</h1>
</div>
<Card class="auth-card">
{#if CFG.emailAuthEnabled !== false}
<div class="auth-pane">
<div class="auth-email-stack">
<div class="field-error-wrap">
<Tooltip.Root open={Boolean(loginEmailFieldError) && loginEmailTooltipOpen}>
<Input
bind:value={email}
type="email"
placeholder={t("wa_email_placeholder")}
autocomplete="email"
class={loginEmailFieldError ? "input-error" : ""}
on:keydown={submitEmailOnEnter}
on:input={clearLoginEmailError}
/>
{#if loginEmailFieldError}
<Tooltip.Trigger class="field-error-trigger" aria-label={loginEmailFieldError}>
<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.Portal>
{/if}
</Tooltip.Root>
</div>
<Button class="wide" onclick={requestEmailCode} disabled={authBusy}>
<Mail size={18} />
{t("wa_send_code_email")}
</Button>
</div>
</div>
{/if}
{#if CFG.emailAuthEnabled !== false}
<div class="or-line"><span></span>{t("wa_or")}<span></span></div>
{/if}
<div class="auth-pane">
<Button
variant="telegram"
class={`wide telegram-login-button${telegramLoginUnavailable ? " unavailable" : ""}${telegramLoginChecking ? " checking" : ""}`}
onclick={openTelegramLogin}
disabled={authBusy || telegramLoginBusy || telegramLoginUnavailable}
aria-label={telegramLoginLabel}
>
<span class="telegram-login-text">
{#if telegramLoginChecking}
<Spinner size="sm" />
{:else}
<Send size={17} />
{/if}
{telegramLoginLabel}
</span>
</Button>
</div>
{#if !telegramLoginChecking && (authStatus || telegramLoginUnavailableMessage)}
<StatusMessage
error={authIsError || Boolean(telegramLoginUnavailableMessage)}
class="auth-login-status"
>
{authStatus || telegramLoginUnavailableMessage}
</StatusMessage>
{/if}
</Card>
{#if userAgreementUrl || privacyPolicyUrl}
<div class="auth-legal">
<span class="auth-legal-intro">{t("wa_auth_legal_intro")}</span>
<div class="auth-legal-links">
{#if privacyPolicyUrl}
<a
href={privacyPolicyUrl}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => {
e.preventDefault();
openExternalLink(privacyPolicyUrl);
}}
>
{t("wa_auth_legal_privacy")}
</a>
{/if}
{#if privacyPolicyUrl && userAgreementUrl}
<span>{t("wa_auth_legal_and")}</span>
{/if}
{#if userAgreementUrl}
<a
href={userAgreementUrl}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => {
e.preventDefault();
openExternalLink(userAgreementUrl);
}}
>
{t("wa_auth_legal_agreement")}
</a>
{/if}
</div>
</div>
{/if}
</div>
{/if}
</div>
@@ -0,0 +1,117 @@
<script>
import { CircleX, Plus, RefreshCw, Smartphone } from "$components/ui/icons.js";
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";
export let devicesBusy = false;
export let devicesData = {};
export let devicesIsError = false;
export let devicesLoaded = false;
export let devicesStatus = "";
export let subscription = {};
export let loadDevices = () => {};
export let openDeviceDisconnectDialog = () => {};
export let openDeviceTopupModal = () => {};
export let t = (key) => key;
</script>
<main class="content with-nav">
<Card class="devices-summary-card">
<div class="devices-summary-head">
<Smartphone size={28} />
<span>
<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")}
>
<RefreshCw size={18} />
</Button>
</div>
<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} />
{t("wa_buy_hwid_devices")}
</Button>
{/if}
</Card>
{#if devicesBusy && !devicesLoaded}
<EmptyCard>{t("wa_devices_loading")}</EmptyCard>
{:else if devicesStatus}
<EmptyCard>
<StatusMessage error={devicesIsError}>{devicesStatus}</StatusMessage>
</EmptyCard>
{:else if !devicesData?.devices?.length}
<EmptyCard class="devices-empty-card">
<Smartphone size={28} />
<span>{t("wa_devices_empty")}</span>
<small>{t("wa_devices_empty_hint", { max: devicesLimitLabel(devicesData, t) })}</small>
</EmptyCard>
{:else}
<div class="devices-list">
{#each devicesData.devices as device (device.token || device.index)}
<Card class="device-card">
<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
>
<small>{device.platform_label || t("wa_devices_platform_unknown")}</small>
</span>
</div>
<div class="device-meta">
{#if device.created_at_text}
<div>
<span>{t("wa_devices_connected_at")}</span>
<strong>{device.created_at_text}</strong>
</div>
{/if}
{#if device.hwid_short}
<div>
<span>HWID</span>
<code>{device.hwid_short}</code>
</div>
{/if}
{#if device.user_agent}
<div class="device-user-agent">
<span>User Agent</span>
<small>{device.user_agent}</small>
</div>
{/if}
</div>
{#if device.can_disconnect}
<Button
variant="outline"
class="wide device-disconnect-button"
onclick={() => openDeviceDisconnectDialog(device)}
>
<CircleX size={17} />
{t("wa_devices_disconnect")}
</Button>
{/if}
</Card>
{/each}
</div>
{/if}
</main>
@@ -0,0 +1,285 @@
<script>
import {
CheckCircle2,
ChevronsUpDown,
CircleX,
Database,
Download,
Gift,
RefreshCw,
} from "$components/ui/icons.js";
import BrandMark from "$lib/webapp/BrandMark.svelte";
import Button from "$components/ui/button.svelte";
import Card from "$components/ui/card.svelte";
import { LinearProgress } from "$components/patterns/webapp/index.js";
import { formatTrafficGb } from "../../lib/webapp/formatters.js";
import {
trafficPercent as trafficPercentFn,
trafficLabel as trafficLabelFn,
trafficResetLabel as trafficResetLabelFn,
premiumTrafficPercent as premiumTrafficPercentFn,
premiumTrafficLabel as premiumTrafficLabelFn,
premiumTitle as premiumTitleFn,
premiumServerLabels as premiumServerLabelsFn,
activeSubscriptionTermLabel as activeSubscriptionTermLabelFn,
} from "../../lib/webapp/traffic.js";
export let appSettings = {};
export let brand = {};
export let brandTitle = "";
export let canChangeTariff = false;
export let premiumTrafficTopupBarClickable = false;
export let premiumTrafficTopupUnlocked = false;
export let regularTrafficTopupBarClickable = false;
export let regularTrafficTopupUnlocked = false;
export let currentTariffName = "";
export let hasActiveTariffSubscription = false;
export let hasMultipleTariffs = false;
export let subscription = {};
export let trafficMode = false;
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 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 = () => {};
export let openRegularTopupModal = () => {};
export let openPremiumTopupModal = () => {};
export let openTariffChangeModal = () => {};
export let primaryPayActionLabel = () => "";
export let t = (key) => key;
</script>
<main class="home-layout">
<div class="login-brand home-brand">
<BrandMark {brand} size="xl" />
<h1>{brandTitle}</h1>
</div>
<div class="home-bottom">
<Card class={`status-card${subscription.active ? "" : " status-card-inactive"}`}>
{#if subscription.active}
<div class="sub-status">
<CheckCircle2 size={23} />
<div>
<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>
{/if}
<p>
{subscription.end_date_text
? t("wa_until_date", { date: subscription.end_date_text })
: subscription.remaining_text}
</p>
</div>
</div>
{:else}
<div class="sub-status sub-status-inactive">
<CircleX size={23} />
<h2>{t("wa_home_subscription_inactive")}</h2>
</div>
{/if}
</Card>
{#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>
{/if}
<div class="traffic-top">
<span>{t("wa_home_traffic_used")}</span>
<strong>{trafficLabel(subscription)}</strong>
</div>
<LinearProgress value={trafficPercent(subscription)} label={t("wa_home_traffic_used")} />
<div class="traffic-meta">
<span>{trafficResetLabel(subscription)}</span>
<span class="traffic-percent">{trafficPercent(subscription)}%</span>
</div>
</Card>
{#if subscription?.premium_unlimited_override}
<Card class="premium-traffic-card">
<div class="traffic-top">
<span>{premiumTitle(subscription)}</span>
<strong></strong>
</div>
<div class="traffic-meta premium-traffic-meta">
{#if premiumServerLabels(subscription).length}
<details class="premium-server-dropdown">
<summary>
<span>{t("wa_premium_unlimited", {}, "Безлимит на premium-сервера")}</span>
<ChevronsUpDown size={13} />
</summary>
<div class="premium-server-list premium-server-list-dropdown">
<small
>{t("wa_premium_servers_limited", {}, "Отдельный лимит действует на")}</small
>
<div>
{#each premiumServerLabels(subscription).slice(0, 8) as label}
<span>{label}</span>
{/each}
</div>
</div>
</details>
{:else}
<span>{t("wa_premium_unlimited", {}, "Безлимит на premium-сервера")}</span>
{/if}
</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" : ""}`}
>
{#if premiumTrafficTopupBarClickable}
<button
class="card-click-target"
type="button"
onclick={openPremiumTopupModal}
aria-label={t("wa_add_traffic_premium", { target: premiumTitle(subscription) })}
></button>
{/if}
<div class="traffic-top">
<span>{premiumTitle(subscription)}</span>
<strong>{premiumTrafficLabel(subscription)}</strong>
</div>
<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
>
<ChevronsUpDown size={13} />
</summary>
<div class="premium-server-list premium-server-list-dropdown">
<small
>{t("wa_premium_servers_limited", {}, "Отдельный лимит действует на")}</small
>
<div>
{#each premiumServerLabels(subscription).slice(0, 8) as label}
<span>{label}</span>
{/each}
</div>
</div>
</details>
{:else}
<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>
</Card>
{/if}
{:else if appSettings?.trial_enabled && appSettings?.trial_available}
<Card class="trial-card">
<div class="trial-card-head">
<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
>
</span>
</div>
</Card>
{/if}
<div class="action-stack">
{#if subscription.active}
<Button class="wide" onclick={openConnectLink}>
<Download size={18} />
{t("wa_install_and_configure")}
</Button>
{/if}
<Button
class="wide"
variant={subscription.active ? "secondary" : "default"}
onclick={openPaymentModal}
>
{#if subscription.active}
<RefreshCw size={18} />
{:else if trafficMode}
<Database size={18} />
{/if}
{primaryPayActionLabel()}
</Button>
{#if !subscription.active && appSettings?.trial_enabled && appSettings?.trial_available}
<Button class="wide" variant="secondary" onclick={activateTrial} disabled={trialBusy}>
<Gift size={18} />
{t("wa_activate_trial")}
</Button>
{/if}
{#if canChangeTariff}
<Button class="wide" variant="secondary" onclick={openTariffChangeModal}>
<RefreshCw size={18} />
{t("wa_change_tariff")}
</Button>
{/if}
{#if regularTrafficTopupUnlocked}
<Button class="wide" variant="secondary" onclick={openRegularTopupModal}>
<Database size={18} />
{t("wa_add_traffic")}
</Button>
{/if}
{#if premiumTrafficTopupUnlocked}
<Button class="wide" variant="secondary" onclick={openPremiumTopupModal}>
<Database size={18} />
{t("wa_add_traffic_premium", { target: premiumTitle(subscription) })}
</Button>
{/if}
</div>
</div>
</main>
@@ -0,0 +1,111 @@
<script>
import { Copy, Gift, Ticket, TriangleAlert } from "$components/ui/icons.js";
import { Tooltip } from "$components/ui/primitives.js";
import Button from "$components/ui/button.svelte";
import Card from "$components/ui/card.svelte";
import Input from "$components/ui/input.svelte";
import { StatusMessage } from "$components/patterns/webapp/index.js";
export let referral = {};
export let referralBonusDetails = [];
export let referralOneBonusPerReferee = false;
export let referralWelcomeBonusDays = 0;
export let promoBusy = false;
export let promoCode = "";
export let promoFieldError = "";
export let promoIsError = false;
export let promoStatus = "";
export let applyPromo = () => {};
export let clearPromoFieldError = () => {};
export let copyText = () => {};
export let t = (key) => key;
</script>
<main class="content with-nav">
<Card class="bonus-card">
<div class="bonus-card-head">
<Gift size={42} />
<div>
<strong>{t("wa_referral_bonus_overview_title")}</strong>
{#if referralOneBonusPerReferee}
<p>{t("wa_referral_bonus_once_note")}</p>
{/if}
</div>
</div>
<div>
<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"))}
>
{t("wa_copy")}
<Copy size={17} />
</Button>
</div>
</div>
{#if referralBonusDetails.length || referralWelcomeBonusDays > 0}
<div class="referral-bonus-list">
{#if referralWelcomeBonusDays > 0}
<div class="referral-bonus-row">
<strong>{t("wa_referral_bonus_registration_title")}</strong>
<small>{t("wa_referral_bonus_friend_days", { days: referralWelcomeBonusDays })}</small>
</div>
{/if}
{#if referralBonusDetails.length}
<p class="referral-bonus-intro">{t("wa_referral_bonus_paid_intro")}</p>
{/if}
{#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
>
</div>
{/each}
</div>
{:else}
<StatusMessage>{t("wa_referral_bonus_not_configured")}</StatusMessage>
{/if}
</Card>
<Card>
<h3 class="card-heading card-heading-accent promo-heading">
<Ticket size={18} />
<span>{t("wa_activate_promo_title")}</span>
</h3>
<div class="copy-row">
<div class="field-error-wrap">
<Tooltip.Root open={Boolean(promoFieldError)}>
<Input
bind:value={promoCode}
placeholder="PROMO2026"
class={promoFieldError ? "input-error" : ""}
on:input={clearPromoFieldError}
/>
{#if promoFieldError}
<Tooltip.Trigger class="field-error-trigger" aria-label={promoFieldError}>
<span class="field-error-icon" aria-hidden="true"><TriangleAlert size={18} /></span>
</Tooltip.Trigger>
{/if}
{#if promoFieldError}
<Tooltip.Portal>
<Tooltip.Content class="field-error-tooltip">{promoFieldError}</Tooltip.Content>
</Tooltip.Portal>
{/if}
</Tooltip.Root>
</div>
<Button variant="outline" onclick={applyPromo} disabled={promoBusy}>
{t("wa_activate")}
</Button>
</div>
{#if promoStatus && !(promoIsError && promoFieldError)}
<StatusMessage error={promoIsError}>{promoStatus}</StatusMessage>
{/if}
</Card>
</main>
@@ -0,0 +1,182 @@
<script>
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";
import { LanguageSelect } from "$components/patterns/webapp/index.js";
export let currentLang = "ru";
export let currentLanguageOption = null;
export let emailLinkStatus = "";
export let isAdmin = false;
export let languageBusy = false;
export let languageClickGuard = false;
export let languageClickGuardArmed = false;
export let languageMenuOpen = false;
export let languageOptions = [];
export let linkEmailBusy = false;
export let linkTelegramBusy = false;
export let privacyPolicyUrl = "";
export let profileAvatarUrl = "";
export let profileEmail = "";
export let profileTelegramId = "";
export let supportUrl = "";
export let telegramProfileName = "";
export let user = {};
export let userAgreementUrl = "";
export let userLanguage = "";
export let linkTelegramAccount = () => {};
export let logout = () => {};
export let openAdminPanel = () => {};
export let openExternalLink = () => {};
export let openLinkEmailDialog = () => {};
export let setLanguageMenuOpen = () => {};
export let t = (key) => key;
export let updateAccountLanguage = () => {};
</script>
<main class="content with-nav">
<Card class="settings-profile">
<div class="settings-avatar">
{#if profileAvatarUrl}
<img
src={profileAvatarUrl}
alt={t("wa_settings_avatar_alt")}
loading="lazy"
referrerpolicy="no-referrer"
/>
{:else}
<UserRound size={30} />
{/if}
</div>
<div class="settings-profile-meta">
<strong>{telegramProfileName}</strong>
<small>{profileEmail}</small>
<small>{profileTelegramId}</small>
</div>
</Card>
{#if isAdmin}
<div class="settings-admin-block">
<div class="settings-divider" aria-hidden="true"></div>
<button class="settings-row settings-row-admin" type="button" onclick={openAdminPanel}>
<Shield size={21} />
<span>
<strong>{t("wa_settings_admin_panel", {}, "Админ-панель")}</strong>
<small>{t("wa_settings_admin_panel_hint", {}, "Управление приложением")}</small>
</span>
<ArrowRight size={17} />
</button>
</div>
{/if}
<div class="settings-links-block">
<div class="settings-divider" aria-hidden="true"></div>
{#if user?.telegram_linked}
<div class="settings-row settings-row-linked">
<CheckCircle2 size={21} />
<span>
<strong>{t("wa_settings_telegram_linked_title")}</strong>
<small>{profileTelegramId}</small>
</span>
</div>
{:else}
<Button
variant="telegram"
class="wide settings-telegram-link-btn attention-wrap"
onclick={linkTelegramAccount}
disabled={linkTelegramBusy}
>
<span class="attention-dot" aria-hidden="true"></span>
<Send size={18} />
{t("wa_settings_link_telegram_action")}
</Button>
{/if}
{#if user?.email}
<div class="settings-row settings-row-linked">
<CheckCircle2 size={21} />
<span>
<strong>{t("wa_settings_email_linked_title")}</strong>
<small>{user?.email}</small>
</span>
</div>
{:else}
<button
class="settings-row attention-wrap"
type="button"
onclick={openLinkEmailDialog}
disabled={linkEmailBusy}
>
<span class="attention-dot" aria-hidden="true"></span>
<Mail size={21} />
<span>
<strong>{t("wa_settings_link_email_action")}</strong>
<small>{emailLinkStatus}</small>
</span>
<ArrowRight size={17} />
</button>
{/if}
<div class="settings-divider" aria-hidden="true"></div>
</div>
<div class="settings-list" class:settings-list--language-open={languageMenuOpen}>
<LanguageSelect
bind:open={languageMenuOpen}
value={currentLang}
currentOption={currentLanguageOption}
{userLanguage}
options={languageOptions}
disabled={languageBusy}
clickGuard={languageClickGuard}
clickGuardArmed={languageClickGuardArmed}
closeLabel={t("wa_close")}
label={t("wa_settings_language")}
onOpenChange={setLanguageMenuOpen}
onValueChange={updateAccountLanguage}
/>
{#if 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)}
>
<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)}
>
<Shield size={21} />
<span><strong>{t("wa_settings_privacy_policy")}</strong></span>
<ArrowRight size={17} />
</button>
{/if}
<button class="settings-row settings-row-logout" type="button" onclick={logout}>
<UserRound size={21} />
<span><strong>{t("wa_logout")}</strong><small>{t("wa_end_session")}</small></span>
<ArrowRight size={17} />
</button>
</div>
</main>