Files
remnawave-minishop/frontend/src/webapp/screens/HomeScreen.svelte
T

534 lines
21 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
import { onMount } from "svelte";
import {
CheckCircle2,
CircleQuestionMark,
CircleX,
CreditCard,
Database,
Download,
Gift,
Repeat2,
Send,
} from "$components/ui/icons.js";
import BrandMark from "$lib/webapp/BrandMark.svelte";
import { AttentionDot } from "$components/ui/index.js";
import Button from "$components/ui/button.svelte";
import Card from "$components/ui/card.svelte";
import TelegramNotificationsBanner from "../TelegramNotificationsBanner.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";
const SUBSCRIPTION_EXPIRY_WARNING_MS = 72 * 60 * 60 * 1000;
const SUBSCRIPTION_EXPIRING_SOON_MS = 24 * 60 * 60 * 1000;
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 referral = {};
export let currentTariffName = "";
export let hasActiveTariffSubscription = false;
export let hasMultipleTariffs = false;
export let subscription = {};
export let linkTelegramBusy = false;
export let telegramNotificationsNeedPrompt = false;
export let telegramNotificationsStartLink = "";
export let telegramNotificationsStatus = "unknown";
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.
let nowMs = Date.now();
function trafficPercent(sub) {
return trafficPercentFn(sub);
}
function trafficLabel(sub) {
return trafficLabelFn(sub, t);
}
function trafficResetLabel(sub) {
return trafficResetLabelFn(sub, t);
}
function regularTrafficDepleted(sub = subscription) {
const used = Number(sub?.traffic_used_bytes || 0);
const limit = Number(sub?.traffic_limit_bytes || 0);
return limit > 0 && used >= limit;
}
function regularTrafficCardClass(sub = subscription) {
return [
"traffic-card-compact",
regularTrafficTopupBarClickable ? "traffic-card-clickable" : "",
regularTrafficDepleted(sub) ? "traffic-card-depleted" : "",
]
.filter(Boolean)
.join(" ");
}
function regularTrafficMetaLabel(sub = subscription) {
return regularTrafficDepleted(sub) ? t("wa_traffic_depleted") : trafficResetLabel(sub);
}
function premiumTrafficAvailable(sub = subscription) {
return !regularTrafficDepleted(sub);
}
function premiumTrafficPercent(sub) {
return premiumTrafficPercentFn(sub);
}
function premiumTrafficLabel(sub) {
return premiumTrafficLabelFn(sub, t);
}
function premiumTitle(sub = subscription) {
return premiumTitleFn(sub, t);
}
function premiumTrafficMetaLabel(sub = subscription) {
return sub?.premium_is_limited
? t("wa_premium_access_limited", {}, "Доступ к premium временно ограничен")
: t("wa_premium_reset_monthly", {}, "Отдельный лимит на месяц");
}
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");
}
function trialDurationLabel() {
const days = Number(appSettings?.trial_duration_days || 0);
return t("wa_sub_term_value_unit", {
value: days,
unit: termUnitLabel(days, "day"),
});
}
function parseSubscriptionEndMs(sub) {
const raw = String(sub?.end_date || "").trim();
if (!raw) return null;
const parsed = Date.parse(raw);
return Number.isFinite(parsed) ? parsed : null;
}
function dateOnlyFromEndText(text) {
const value = String(text || "").trim();
if (!value) return "";
return value.split(/\s+/)[0] || value;
}
function dateOnlyFromIso(text) {
const match = String(text || "").match(/^(\d{4})-(\d{2})-(\d{2})/);
return match ? `${match[3]}.${match[2]}.${match[1]}` : "";
}
function subscriptionEndDateLabel(sub) {
return dateOnlyFromEndText(sub?.end_date_text) || dateOnlyFromIso(sub?.end_date);
}
function formatSubscriptionCountdown(ms) {
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const pad = (value) => String(value).padStart(2, "0");
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
}
$: trialOfferAvailable = Boolean(
!subscription?.active && appSettings?.trial_enabled && appSettings?.trial_available
);
$: trialRequiresTelegram = Boolean(
!subscription?.active && appSettings?.trial_enabled && appSettings?.trial_requires_telegram
);
$: referralWelcomeRequiresTelegram = Boolean(
!subscription?.active &&
referral?.welcome_bonus_requires_telegram &&
Number(referral?.welcome_bonus_days || 0) > 0
);
$: subscriptionEndMs = subscription?.active ? parseSubscriptionEndMs(subscription) : null;
$: subscriptionRemainingMs = Math.max(0, Number(subscriptionEndMs || 0) - nowMs);
$: subscriptionExpiryWarning = Boolean(
subscription?.active &&
subscriptionEndMs &&
subscriptionRemainingMs > 0 &&
subscriptionRemainingMs <= SUBSCRIPTION_EXPIRY_WARNING_MS
);
$: subscriptionEndDateText = subscriptionEndDateLabel(subscription);
$: subscriptionEndCountdown = formatSubscriptionCountdown(subscriptionRemainingMs);
$: subscriptionEndCountdownLabel = t(
"wa_subscription_remaining_countdown",
{ countdown: subscriptionEndCountdown },
`осталось: ${subscriptionEndCountdown}`
);
$: subscriptionExpiringSoon = Boolean(
subscription?.active &&
subscriptionEndMs &&
subscriptionRemainingMs > 0 &&
subscriptionRemainingMs < SUBSCRIPTION_EXPIRING_SOON_MS
);
$: subscriptionTermDisplayText = subscriptionExpiringSoon
? t("wa_subscription_expiring_soon", {}, "Скоро закончится!")
: activeSubscriptionTermLabel(subscription);
$: subscriptionEndDisplayText = subscriptionExpiryWarning
? `${subscriptionEndDateText || subscription.end_date_text} \u00b7 ${subscriptionEndCountdownLabel}`
: subscriptionEndDateText;
$: statusCardClass = [
"status-card",
subscription.active ? "" : "status-card-inactive",
subscriptionExpiryWarning ? "status-card-warning" : "",
]
.filter(Boolean)
.join(" ");
onMount(() => {
const countdownTimer = window.setInterval(() => {
if (subscription?.active) nowMs = Date.now();
}, 1000);
return () => window.clearInterval(countdownTimer);
});
export let activateTrial = () => {};
export let linkTelegramAndActivateTrial = () => {};
export let linkTelegramAndClaimReferralWelcome = () => {};
export let openConnectLink = () => {};
export let openPaymentModal = () => {};
export let openTelegramNotificationsBot = () => {};
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>
{#if telegramNotificationsNeedPrompt}
<TelegramNotificationsBanner
startLink={telegramNotificationsStartLink}
status={telegramNotificationsStatus}
onOpenBot={openTelegramNotificationsBot}
{t}
/>
{/if}
<div class="home-bottom">
<Card class={statusCardClass}>
{#if subscription.active}
<div class="sub-status">
<CheckCircle2 class="sub-status-icon" size={23} />
<div class="sub-status-main">
<h2>
{trafficMode ? t("wa_home_access_active") : t("wa_home_subscription_active")} | {subscriptionTermDisplayText}
</h2>
<div
class:sub-status-details-with-tariff={hasActiveTariffSubscription &&
hasMultipleTariffs &&
currentTariffName}
class="sub-status-details"
>
{#if hasActiveTariffSubscription && hasMultipleTariffs && currentTariffName}
<p class="current-tariff-line">
{t("wa_current_tariff", { tariff: currentTariffName })}
</p>
{/if}
<p class="subscription-end-line">
{subscriptionEndDisplayText
? t("wa_until_date", { date: subscriptionEndDisplayText })
: subscription.remaining_text}
</p>
</div>
</div>
{#if canChangeTariff}
<Button
class="status-tariff-action"
variant="secondary"
onclick={openTariffChangeModal}
>
<Repeat2 size={17} />
{t("wa_change_tariff")}
</Button>
{/if}
</div>
{:else}
<div class="sub-status sub-status-inactive">
<CircleX class="sub-status-icon" size={23} />
<h2>{t("wa_home_subscription_inactive")}</h2>
</div>
{/if}
</Card>
{#if subscription.active}
<Card compact class={regularTrafficCardClass(subscription)}>
{#if regularTrafficTopupBarClickable}
<button
class="card-click-target"
type="button"
onclick={openRegularTopupModal}
aria-label={t("wa_add_traffic")}
></button>
{/if}
<div class="traffic-summary-row">
<span class="traffic-summary-left">
{t("wa_home_traffic_used")}
<span class="traffic-summary-separator" aria-hidden="true">|</span>
{regularTrafficMetaLabel(subscription)}
</span>
<strong class="traffic-summary-right">
<span>{trafficLabel(subscription)}</span>
<span class="traffic-summary-separator" aria-hidden="true">|</span>
<span>{trafficPercent(subscription)}%</span>
</strong>
</div>
<LinearProgress value={trafficPercent(subscription)} label={t("wa_home_traffic_used")} />
</Card>
{#if premiumTrafficAvailable(subscription) && subscription?.premium_unlimited_override}
<Card compact class="traffic-card-compact premium-traffic-card">
{#if premiumServerLabels(subscription).length}
<details class="premium-server-dropdown premium-server-dropdown-inline">
<summary class="traffic-summary-row premium-server-summary">
<span class="traffic-summary-left premium-summary-trigger">
<span class="premium-summary-copy">
{premiumTitle(subscription)}
<span class="traffic-summary-separator" aria-hidden="true">|</span>
{t("wa_premium_unlimited", {}, "Безлимит на premium-сервера")}
</span>
<CircleQuestionMark class="premium-server-help-icon" size={15} />
</span>
<strong class="traffic-summary-right"></strong>
</summary>
<div class="premium-server-list premium-server-list-dropdown">
<div>
{#each premiumServerLabels(subscription).slice(0, 8) as label}
<span>{label}</span>
{/each}
</div>
</div>
</details>
{:else}
<div class="traffic-summary-row">
<span class="traffic-summary-left">
{premiumTitle(subscription)}
<span class="traffic-summary-separator" aria-hidden="true">|</span>
{t("wa_premium_unlimited", {}, "Безлимит на premium-сервера")}
</span>
<strong class="traffic-summary-right"></strong>
</div>
{/if}
</Card>
{:else if premiumTrafficAvailable(subscription) && Number(subscription?.premium_limit_bytes || 0) > 0}
<Card
compact
class={`traffic-card-compact ${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}
{#if premiumServerLabels(subscription).length}
<details class="premium-server-dropdown premium-server-dropdown-inline">
<summary class="traffic-summary-row premium-server-summary">
<span class="traffic-summary-left premium-summary-trigger">
<span class="premium-summary-copy">
{premiumTitle(subscription)}
<span class="traffic-summary-separator" aria-hidden="true">|</span>
{premiumTrafficMetaLabel(subscription)}
</span>
<CircleQuestionMark class="premium-server-help-icon" size={15} />
</span>
<strong class="traffic-summary-right">
<span>{premiumTrafficLabel(subscription)}</span>
<span class="traffic-summary-separator" aria-hidden="true">|</span>
<span>{premiumTrafficPercent(subscription)}%</span>
</strong>
</summary>
<div class="premium-server-list premium-server-list-dropdown">
<div>
{#each premiumServerLabels(subscription).slice(0, 8) as label}
<span>{label}</span>
{/each}
</div>
</div>
</details>
{:else}
<div class="traffic-summary-row">
<span class="traffic-summary-left">
{premiumTitle(subscription)}
<span class="traffic-summary-separator" aria-hidden="true">|</span>
{premiumTrafficMetaLabel(subscription)}
</span>
<strong class="traffic-summary-right">
<span>{premiumTrafficLabel(subscription)}</span>
<span class="traffic-summary-separator" aria-hidden="true">|</span>
<span>{premiumTrafficPercent(subscription)}%</span>
</strong>
</div>
{/if}
<LinearProgress
class="premium-progress"
value={premiumTrafficPercent(subscription)}
label={premiumTitle(subscription)}
/>
</Card>
{/if}
{:else}
{#if referralWelcomeRequiresTelegram}
<Card class="trial-card trial-offer-card">
<div class="trial-card-head">
<Gift size={22} />
<span>
<strong>
{t(
"wa_referral_welcome_telegram_required_title",
{},
"Бонус ждёт привязки Telegram"
)}
</strong>
<small>{t("wa_referral_program_title", {}, "Реферальная программа")}</small>
</span>
</div>
<p class="trial-card-description">
{t(
"wa_referral_welcome_telegram_required_description",
{ days: Number(referral?.welcome_bonus_days || 0) },
"Привяжите Telegram, чтобы получить {days} бонусных дней за регистрацию по приглашению."
)}
</p>
<Button
class="wide trial-card-action settings-telegram-link-btn attention-wrap"
variant="telegram"
onclick={linkTelegramAndClaimReferralWelcome}
disabled={linkTelegramBusy}
>
<AttentionDot />
<Send size={18} />
{t("wa_referral_link_telegram_and_claim", {}, "Привязать и получить бонус")}
</Button>
</Card>
{/if}
{#if trialOfferAvailable}
<Card class="trial-card trial-offer-card">
<div class="trial-card-head">
<Gift size={22} />
<span>
<strong>{t("wa_trial_offer_title", {}, "Можно начать с льготного периода")}</strong>
<small>{t("wa_trial_title")}</small>
</span>
</div>
<p class="trial-card-description">
{t(
"wa_trial_offer_description",
{ duration: trialDurationLabel(), traffic: trialTrafficLabel() },
"Активируйте триал: {duration} доступа и {traffic} для скачивания без оплаты."
)}
</p>
<div class="trial-card-facts">
<span>
<small>{t("wa_trial_duration_label", {}, "Срок")}</small>
<strong>{trialDurationLabel()}</strong>
</span>
<span>
<small>{t("wa_trial_download_traffic_label", {}, "Доступно для скачивания")}</small>
<strong>{trialTrafficLabel()}</strong>
</span>
</div>
<Button class="wide trial-card-action" onclick={activateTrial} disabled={trialBusy}>
<Gift size={18} />
{t("wa_trial_try_free", {}, "Попробовать бесплатно")}
</Button>
</Card>
{:else if trialRequiresTelegram}
<Card class="trial-card trial-offer-card">
<div class="trial-card-head">
<Gift size={22} />
<span>
<strong>
{t("wa_trial_telegram_required_title", {}, "Привяжите Telegram для триала")}
</strong>
<small>{t("wa_trial_title")}</small>
</span>
</div>
<p class="trial-card-description">
{t(
"wa_trial_telegram_required_description",
{ duration: trialDurationLabel(), traffic: trialTrafficLabel() },
"Чтобы активировать триал на {duration} с лимитом {traffic}, сначала привяжите Telegram."
)}
</p>
<div class="trial-card-facts">
<span>
<small>{t("wa_trial_duration_label", {}, "Срок")}</small>
<strong>{trialDurationLabel()}</strong>
</span>
<span>
<small>{t("wa_trial_download_traffic_label", {}, "Доступно для скачивания")}</small>
<strong>{trialTrafficLabel()}</strong>
</span>
</div>
<Button
class="wide trial-card-action settings-telegram-link-btn attention-wrap"
variant="telegram"
onclick={linkTelegramAndActivateTrial}
disabled={linkTelegramBusy || trialBusy}
>
<AttentionDot />
<Send size={18} />
{t("wa_trial_link_telegram_and_activate", {}, "Привязать и активировать")}
</Button>
</Card>
{/if}
{/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${subscription.active ? " subscription-renew-action" : ""}`}
variant={subscription.active ? "secondary" : "default"}
onclick={openPaymentModal}
>
{#if subscription.active}
<CreditCard size={18} />
{:else if trafficMode}
<Database size={18} />
{/if}
{primaryPayActionLabel()}
</Button>
{#if regularTrafficTopupUnlocked}
<Button class="wide" variant="secondary" onclick={openRegularTopupModal}>
<Database size={18} />
{t("wa_add_traffic")}
</Button>
{/if}
{#if premiumTrafficTopupUnlocked && premiumTrafficAvailable(subscription)}
<Button class="wide" variant="secondary" onclick={openPremiumTopupModal}>
<Database size={18} />
{t("wa_add_traffic_premium", { target: premiumTitle(subscription) })}
</Button>
{/if}
</div>
</div>
</main>