feat: guide users after subscription activation
This commit is contained in:
+87
-8
@@ -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}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Ссылка для подключения пока недоступна",
|
||||
|
||||
Reference in New Issue
Block a user