feat: guide users after subscription activation

This commit is contained in:
3252a8
2026-05-24 22:24:02 +03:00
parent 1165dc3fa6
commit a60de46173
6 changed files with 178 additions and 24 deletions
+87 -8
View File
@@ -1,5 +1,5 @@
<script>
import { onMount, setContext } from "svelte";
import { onMount, setContext, tick } from "svelte";
import { createAuthStore } from "./lib/webapp/stores/authStore.js";
import { createBillingStore } from "./lib/webapp/stores/billingStore.js";
import { createDevicesStore } from "./lib/webapp/stores/devicesStore.js";
@@ -7,8 +7,11 @@
import { createSupportStore } from "./lib/webapp/stores/supportStore.js";
import { createAccountStore } from "./lib/webapp/stores/accountStore.js";
import { Tooltip } from "$components/ui/primitives.js";
import { CheckCircle2 } from "$components/ui/icons.js";
import BrandMark from "$lib/webapp/BrandMark.svelte";
import Button from "$components/ui/button.svelte";
import Dialog from "$components/ui/dialog.svelte";
import PreviewBoard from "./PreviewBoard.svelte";
import WebAppShell from "./webapp/WebAppShell.svelte";
import AuthScreen from "./webapp/auth/AuthScreen.svelte";
@@ -121,6 +124,8 @@
let trialBusy = false;
let trialActivationResult = null;
let trialActivationError = "";
let activationSuccessDialogOpen = false;
let activationSuccessUseInstallGuides = false;
let promoCode = "";
let promoBusy = false;
let promoStatus = "";
@@ -199,6 +204,7 @@
t,
showToast,
openExternalLink,
onSubscriptionActivated: handleSubscriptionActivated,
tg,
});
const devicesStore = createDevicesStore({ api, t, showToast });
@@ -1172,6 +1178,56 @@
openConnectLink();
}
function openActivationConnectLink() {
const url =
subscription?.connect_url ||
subscription?.config_link ||
trialActivationResult?.connect_url ||
trialActivationResult?.config_link;
if (!url) {
showToast(t("wa_connect_link_unavailable"));
return;
}
openExternalLink(url);
}
function navigateToActivationTarget({ replace = true } = {}) {
const useInstallGuides = canUseInstallGuides();
activationSuccessUseInstallGuides = useInstallGuides;
billingStore.closePaymentModal();
activeTab = "home";
if (useInstallGuides) {
screen = "install";
syncSectionPath("install", replace);
installGuidesStore.load(true);
return;
}
screen = "home";
syncSectionPath("home", replace);
}
async function showActivationSuccessDialog() {
await tick();
navigateToActivationTarget({ replace: true });
activationSuccessDialogOpen = true;
}
async function handleSubscriptionActivated() {
await tick();
if (!subscription?.active) return;
await showActivationSuccessDialog();
}
function closeActivationSuccessDialog() {
const shouldOpenConnect = !activationSuccessUseInstallGuides;
activationSuccessDialogOpen = false;
if (activationSuccessUseInstallGuides) {
navigateToActivationTarget({ replace: true });
return;
}
if (shouldOpenConnect) openActivationConnectLink();
}
async function copyText(value, success = t("wa_copied")) {
if (!value) {
showToast(t("wa_unavailable"));
@@ -1220,9 +1276,8 @@
}
}
async function activateTrial(options = {}) {
async function activateTrial() {
if (trialBusy) return;
const stayOnTrial = Boolean(options?.stayOnTrial);
trialBusy = true;
trialActivationResult = null;
trialActivationError = "";
@@ -1235,11 +1290,7 @@
trialActivationResult = response;
showToast(t("wa_trial_activated"));
await loadData();
if (stayOnTrial) {
activeTab = "home";
screen = "trial";
syncSectionPath("trial", true);
}
await showActivationSuccessDialog();
} catch (error) {
const message = error?.message || t("wa_trial_activation_failed");
trialActivationError = message;
@@ -1788,6 +1839,34 @@
{trafficMode}
{t}
/>
<Dialog
open={activationSuccessDialogOpen}
title={t("wa_activation_success_title", {}, "Everything is successfully activated")}
description={activationSuccessUseInstallGuides
? t(
"wa_activation_success_install_hint",
{},
"Press OK and follow the setup instructions for your device."
)
: t(
"wa_activation_success_connect_hint",
{},
"Press OK and we will open the Remnawave subscription page for setup."
)}
closeLabel={t("wa_close")}
onclose={closeActivationSuccessDialog}
class="activation-success-dialog"
>
<div class="activation-success-dialog-body">
<div class="activation-success-mark" aria-hidden="true">
<CheckCircle2 size={34} />
</div>
<Button class="wide" onclick={closeActivationSuccessDialog}>
{t("wa_ok", {}, "OK")}
</Button>
</div>
</Dialog>
{/if}
{#if toastText}
+53 -10
View File
@@ -1,12 +1,21 @@
import { writable, get } from "svelte/store";
export function createBillingStore({ billing, loadData, t, showToast, openExternalLink, tg }) {
export function createBillingStore({
billing,
loadData,
t,
showToast,
openExternalLink,
onSubscriptionActivated = null,
tg,
}) {
const state = writable({
paymentModalOpen: false,
paymentStep: "tariff",
selectedTariffKey: "",
selectedPlan: null,
selectedMethod: "",
paymentStartedWithActiveSubscription: false,
topupModalOpen: false,
topupKind: "regular",
deviceTopupModalOpen: false,
@@ -25,11 +34,44 @@ export function createBillingStore({ billing, loadData, t, showToast, openExtern
let topupOptionsRequestId = 0;
let paymentPollToken = 0;
const successfulPaymentIds = new Set();
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isSubscriptionSale(plan) {
const saleMode = String(plan?.sale_mode || "subscription").toLowerCase();
return !["traffic", "traffic_package", "topup", "premium_topup", "hwid_devices"].includes(
saleMode
);
}
function paymentSuccessContext(s, response = {}) {
return {
paymentId: response.payment_id || "",
initialSubscriptionPayment:
!s.paymentStartedWithActiveSubscription && isSubscriptionSale(s.selectedPlan),
};
}
async function handlePaymentSuccess(successContext = {}) {
const paymentId = String(successContext.paymentId || "");
if (paymentId && successfulPaymentIds.has(paymentId)) return;
if (paymentId) {
successfulPaymentIds.add(paymentId);
paymentPollToken += 1;
}
showToast(t("wa_payment_success", {}, "Payment successful"));
await loadData();
if (
successContext.initialSubscriptionPayment &&
typeof onSubscriptionActivated === "function"
) {
await onSubscriptionActivated();
}
}
function openPaymentModal(
tariffMode,
singleTariffMode,
@@ -71,6 +113,7 @@ export function createBillingStore({ billing, loadData, t, showToast, openExtern
selectedTariffKey: tariffKey,
selectedPlan: plan,
selectedMethod: s.selectedMethod || defaultMethod,
paymentStartedWithActiveSubscription: Boolean(subscription?.active),
};
});
}
@@ -164,13 +207,12 @@ export function createBillingStore({ billing, loadData, t, showToast, openExtern
state.update((s) => ({ ...s, changeConfirmOpen: false }));
}
function openTelegramInvoice(url) {
function openTelegramInvoice(url, successContext = {}) {
if (!url) return;
if (tg?.openInvoice) {
tg.openInvoice(url, (status) => {
tg.openInvoice(url, async (status) => {
if (status === "paid") {
showToast(t("wa_payment_success", {}, "Payment successful"));
loadData();
await handlePaymentSuccess(successContext);
} else if (status === "failed") {
showToast(t("wa_payment_create_failed"));
}
@@ -180,7 +222,7 @@ export function createBillingStore({ billing, loadData, t, showToast, openExtern
openExternalLink(url);
}
function startPaymentStatusPolling(paymentId) {
function startPaymentStatusPolling(paymentId, successContext = {}) {
if (!paymentId || !billing.fetchPaymentStatus) return;
const token = ++paymentPollToken;
void (async () => {
@@ -191,8 +233,7 @@ export function createBillingStore({ billing, loadData, t, showToast, openExtern
const status = await billing.fetchPaymentStatus(paymentId);
if (!status?.ok) continue;
if (status.paid || status.status === "succeeded") {
showToast(t("wa_payment_success", {}, "Payment successful"));
await loadData();
await handlePaymentSuccess({ ...successContext, paymentId });
return;
}
const normalized = String(status.status || "").toLowerCase();
@@ -222,17 +263,19 @@ export function createBillingStore({ billing, loadData, t, showToast, openExtern
);
if (!response.ok) throw response;
showToast(t("wa_payment_created"));
const successContext = paymentSuccessContext(s, response);
if (response.action === "open_invoice") {
if (!response.payment_url) throw response;
openTelegramInvoice(response.payment_url);
openTelegramInvoice(response.payment_url, successContext);
} else if (response.action === "invoice_sent") {
startPaymentStatusPolling(response.payment_id, successContext);
state.update((s) => ({ ...s, paymentModalOpen: false }));
return;
} else {
if (!response.payment_url) throw response;
openExternalLink(response.payment_url);
}
startPaymentStatusPolling(response.payment_id);
startPaymentStatusPolling(response.payment_id, successContext);
state.update((s) => ({ ...s, paymentModalOpen: false }));
} catch (error) {
showToast(error?.message || t("wa_payment_create_failed"));
+28
View File
@@ -78,6 +78,34 @@
gap: 10px;
}
.activation-success-dialog {
width: min(100%, 440px);
}
.activation-success-dialog-body {
display: grid;
justify-items: center;
gap: 16px;
}
.activation-success-mark {
display: grid;
width: 70px;
height: 70px;
place-items: center;
border: 1px solid color-mix(in srgb, var(--success) 38%, transparent);
border-radius: 50%;
background:
radial-gradient(circle at 30% 25%, color-mix(in srgb, white 42%, transparent), transparent 34%),
color-mix(in srgb, var(--success) 16%, var(--panel));
color: var(--success);
box-shadow: 0 18px 38px color-mix(in srgb, var(--success) 20%, transparent);
}
.activation-success-dialog-body .btn {
width: 100%;
}
.payment-divider {
height: 1px;
width: 100%;
@@ -56,7 +56,7 @@
onMount(() => {
if (!requested && canRequestTrial) {
requested = true;
activateTrial({ stayOnTrial: true });
activateTrial();
}
});
</script>
@@ -139,11 +139,7 @@
{t("wa_install_and_configure")}
</Button>
{:else if trialError && canRequestTrial}
<Button
class="wide"
onclick={() => activateTrial({ stayOnTrial: true })}
disabled={trialBusy}
>
<Button class="wide" onclick={activateTrial} disabled={trialBusy}>
<RefreshCw size={18} />
{t("wa_trial_retry", {}, "Try again")}
</Button>
+4
View File
@@ -754,6 +754,9 @@
"wa_trial_download_traffic_label": "Available to download",
"wa_trial_try_free": "Try for free",
"wa_trial_activated": "Trial activated",
"wa_activation_success_title": "Everything is successfully activated",
"wa_activation_success_install_hint": "Press OK and follow the setup instructions for your device.",
"wa_activation_success_connect_hint": "Press OK and we will open the Remnawave subscription page for setup.",
"wa_trial_activation_failed": "Failed to activate trial",
"wa_trial_activation_loading": "Activating trial...",
"wa_trial_activation_wait": "Preparing access and connection details.",
@@ -821,6 +824,7 @@
"wa_device_disconnected": "Device disconnected",
"wa_device_disconnect_failed": "Failed to disconnect device",
"wa_cancel": "Cancel",
"wa_ok": "OK",
"wa_payment_created": "Payment created",
"wa_payment_create_failed": "Failed to create payment",
"wa_connect_link_unavailable": "Connection link is not available yet",
+4
View File
@@ -754,6 +754,9 @@
"wa_trial_download_traffic_label": "Доступно для скачивания",
"wa_trial_try_free": "Попробовать бесплатно",
"wa_trial_activated": "Пробный период активирован",
"wa_activation_success_title": "Всё успешно активировано",
"wa_activation_success_install_hint": "Нажмите ОК и следуйте инструкциям по установке на вашем устройстве.",
"wa_activation_success_connect_hint": "Нажмите ОК, и мы откроем страницу подписки Remnawave для настройки устройства.",
"wa_trial_activation_failed": "Не удалось активировать пробный период",
"wa_trial_activation_loading": "Активируем пробный период...",
"wa_trial_activation_wait": "Готовим доступ и данные для подключения.",
@@ -821,6 +824,7 @@
"wa_device_disconnected": "Устройство отключено",
"wa_device_disconnect_failed": "Не удалось отключить устройство",
"wa_cancel": "Отмена",
"wa_ok": "ОК",
"wa_payment_created": "Платеж создан",
"wa_payment_create_failed": "Не удалось создать платеж",
"wa_connect_link_unavailable": "Ссылка для подключения пока недоступна",