feat: guide users after subscription activation
This commit is contained in:
+87
-8
@@ -1,5 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount, setContext } from "svelte";
|
import { onMount, setContext, tick } from "svelte";
|
||||||
import { createAuthStore } from "./lib/webapp/stores/authStore.js";
|
import { createAuthStore } from "./lib/webapp/stores/authStore.js";
|
||||||
import { createBillingStore } from "./lib/webapp/stores/billingStore.js";
|
import { createBillingStore } from "./lib/webapp/stores/billingStore.js";
|
||||||
import { createDevicesStore } from "./lib/webapp/stores/devicesStore.js";
|
import { createDevicesStore } from "./lib/webapp/stores/devicesStore.js";
|
||||||
@@ -7,8 +7,11 @@
|
|||||||
import { createSupportStore } from "./lib/webapp/stores/supportStore.js";
|
import { createSupportStore } from "./lib/webapp/stores/supportStore.js";
|
||||||
import { createAccountStore } from "./lib/webapp/stores/accountStore.js";
|
import { createAccountStore } from "./lib/webapp/stores/accountStore.js";
|
||||||
import { Tooltip } from "$components/ui/primitives.js";
|
import { Tooltip } from "$components/ui/primitives.js";
|
||||||
|
import { CheckCircle2 } from "$components/ui/icons.js";
|
||||||
|
|
||||||
import BrandMark from "$lib/webapp/BrandMark.svelte";
|
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 PreviewBoard from "./PreviewBoard.svelte";
|
||||||
import WebAppShell from "./webapp/WebAppShell.svelte";
|
import WebAppShell from "./webapp/WebAppShell.svelte";
|
||||||
import AuthScreen from "./webapp/auth/AuthScreen.svelte";
|
import AuthScreen from "./webapp/auth/AuthScreen.svelte";
|
||||||
@@ -121,6 +124,8 @@
|
|||||||
let trialBusy = false;
|
let trialBusy = false;
|
||||||
let trialActivationResult = null;
|
let trialActivationResult = null;
|
||||||
let trialActivationError = "";
|
let trialActivationError = "";
|
||||||
|
let activationSuccessDialogOpen = false;
|
||||||
|
let activationSuccessUseInstallGuides = false;
|
||||||
let promoCode = "";
|
let promoCode = "";
|
||||||
let promoBusy = false;
|
let promoBusy = false;
|
||||||
let promoStatus = "";
|
let promoStatus = "";
|
||||||
@@ -199,6 +204,7 @@
|
|||||||
t,
|
t,
|
||||||
showToast,
|
showToast,
|
||||||
openExternalLink,
|
openExternalLink,
|
||||||
|
onSubscriptionActivated: handleSubscriptionActivated,
|
||||||
tg,
|
tg,
|
||||||
});
|
});
|
||||||
const devicesStore = createDevicesStore({ api, t, showToast });
|
const devicesStore = createDevicesStore({ api, t, showToast });
|
||||||
@@ -1172,6 +1178,56 @@
|
|||||||
openConnectLink();
|
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")) {
|
async function copyText(value, success = t("wa_copied")) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
showToast(t("wa_unavailable"));
|
showToast(t("wa_unavailable"));
|
||||||
@@ -1220,9 +1276,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function activateTrial(options = {}) {
|
async function activateTrial() {
|
||||||
if (trialBusy) return;
|
if (trialBusy) return;
|
||||||
const stayOnTrial = Boolean(options?.stayOnTrial);
|
|
||||||
trialBusy = true;
|
trialBusy = true;
|
||||||
trialActivationResult = null;
|
trialActivationResult = null;
|
||||||
trialActivationError = "";
|
trialActivationError = "";
|
||||||
@@ -1235,11 +1290,7 @@
|
|||||||
trialActivationResult = response;
|
trialActivationResult = response;
|
||||||
showToast(t("wa_trial_activated"));
|
showToast(t("wa_trial_activated"));
|
||||||
await loadData();
|
await loadData();
|
||||||
if (stayOnTrial) {
|
await showActivationSuccessDialog();
|
||||||
activeTab = "home";
|
|
||||||
screen = "trial";
|
|
||||||
syncSectionPath("trial", true);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error?.message || t("wa_trial_activation_failed");
|
const message = error?.message || t("wa_trial_activation_failed");
|
||||||
trialActivationError = message;
|
trialActivationError = message;
|
||||||
@@ -1788,6 +1839,34 @@
|
|||||||
{trafficMode}
|
{trafficMode}
|
||||||
{t}
|
{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}
|
||||||
|
|
||||||
{#if toastText}
|
{#if toastText}
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
import { writable, get } from "svelte/store";
|
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({
|
const state = writable({
|
||||||
paymentModalOpen: false,
|
paymentModalOpen: false,
|
||||||
paymentStep: "tariff",
|
paymentStep: "tariff",
|
||||||
selectedTariffKey: "",
|
selectedTariffKey: "",
|
||||||
selectedPlan: null,
|
selectedPlan: null,
|
||||||
selectedMethod: "",
|
selectedMethod: "",
|
||||||
|
paymentStartedWithActiveSubscription: false,
|
||||||
topupModalOpen: false,
|
topupModalOpen: false,
|
||||||
topupKind: "regular",
|
topupKind: "regular",
|
||||||
deviceTopupModalOpen: false,
|
deviceTopupModalOpen: false,
|
||||||
@@ -25,11 +34,44 @@ export function createBillingStore({ billing, loadData, t, showToast, openExtern
|
|||||||
|
|
||||||
let topupOptionsRequestId = 0;
|
let topupOptionsRequestId = 0;
|
||||||
let paymentPollToken = 0;
|
let paymentPollToken = 0;
|
||||||
|
const successfulPaymentIds = new Set();
|
||||||
|
|
||||||
function sleep(ms) {
|
function sleep(ms) {
|
||||||
return new Promise((resolve) => setTimeout(resolve, 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(
|
function openPaymentModal(
|
||||||
tariffMode,
|
tariffMode,
|
||||||
singleTariffMode,
|
singleTariffMode,
|
||||||
@@ -71,6 +113,7 @@ export function createBillingStore({ billing, loadData, t, showToast, openExtern
|
|||||||
selectedTariffKey: tariffKey,
|
selectedTariffKey: tariffKey,
|
||||||
selectedPlan: plan,
|
selectedPlan: plan,
|
||||||
selectedMethod: s.selectedMethod || defaultMethod,
|
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 }));
|
state.update((s) => ({ ...s, changeConfirmOpen: false }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function openTelegramInvoice(url) {
|
function openTelegramInvoice(url, successContext = {}) {
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
if (tg?.openInvoice) {
|
if (tg?.openInvoice) {
|
||||||
tg.openInvoice(url, (status) => {
|
tg.openInvoice(url, async (status) => {
|
||||||
if (status === "paid") {
|
if (status === "paid") {
|
||||||
showToast(t("wa_payment_success", {}, "Payment successful"));
|
await handlePaymentSuccess(successContext);
|
||||||
loadData();
|
|
||||||
} else if (status === "failed") {
|
} else if (status === "failed") {
|
||||||
showToast(t("wa_payment_create_failed"));
|
showToast(t("wa_payment_create_failed"));
|
||||||
}
|
}
|
||||||
@@ -180,7 +222,7 @@ export function createBillingStore({ billing, loadData, t, showToast, openExtern
|
|||||||
openExternalLink(url);
|
openExternalLink(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
function startPaymentStatusPolling(paymentId) {
|
function startPaymentStatusPolling(paymentId, successContext = {}) {
|
||||||
if (!paymentId || !billing.fetchPaymentStatus) return;
|
if (!paymentId || !billing.fetchPaymentStatus) return;
|
||||||
const token = ++paymentPollToken;
|
const token = ++paymentPollToken;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -191,8 +233,7 @@ export function createBillingStore({ billing, loadData, t, showToast, openExtern
|
|||||||
const status = await billing.fetchPaymentStatus(paymentId);
|
const status = await billing.fetchPaymentStatus(paymentId);
|
||||||
if (!status?.ok) continue;
|
if (!status?.ok) continue;
|
||||||
if (status.paid || status.status === "succeeded") {
|
if (status.paid || status.status === "succeeded") {
|
||||||
showToast(t("wa_payment_success", {}, "Payment successful"));
|
await handlePaymentSuccess({ ...successContext, paymentId });
|
||||||
await loadData();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const normalized = String(status.status || "").toLowerCase();
|
const normalized = String(status.status || "").toLowerCase();
|
||||||
@@ -222,17 +263,19 @@ export function createBillingStore({ billing, loadData, t, showToast, openExtern
|
|||||||
);
|
);
|
||||||
if (!response.ok) throw response;
|
if (!response.ok) throw response;
|
||||||
showToast(t("wa_payment_created"));
|
showToast(t("wa_payment_created"));
|
||||||
|
const successContext = paymentSuccessContext(s, response);
|
||||||
if (response.action === "open_invoice") {
|
if (response.action === "open_invoice") {
|
||||||
if (!response.payment_url) throw response;
|
if (!response.payment_url) throw response;
|
||||||
openTelegramInvoice(response.payment_url);
|
openTelegramInvoice(response.payment_url, successContext);
|
||||||
} else if (response.action === "invoice_sent") {
|
} else if (response.action === "invoice_sent") {
|
||||||
|
startPaymentStatusPolling(response.payment_id, successContext);
|
||||||
state.update((s) => ({ ...s, paymentModalOpen: false }));
|
state.update((s) => ({ ...s, paymentModalOpen: false }));
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
if (!response.payment_url) throw response;
|
if (!response.payment_url) throw response;
|
||||||
openExternalLink(response.payment_url);
|
openExternalLink(response.payment_url);
|
||||||
}
|
}
|
||||||
startPaymentStatusPolling(response.payment_id);
|
startPaymentStatusPolling(response.payment_id, successContext);
|
||||||
state.update((s) => ({ ...s, paymentModalOpen: false }));
|
state.update((s) => ({ ...s, paymentModalOpen: false }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error?.message || t("wa_payment_create_failed"));
|
showToast(error?.message || t("wa_payment_create_failed"));
|
||||||
|
|||||||
@@ -78,6 +78,34 @@
|
|||||||
gap: 10px;
|
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 {
|
.payment-divider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!requested && canRequestTrial) {
|
if (!requested && canRequestTrial) {
|
||||||
requested = true;
|
requested = true;
|
||||||
activateTrial({ stayOnTrial: true });
|
activateTrial();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -139,11 +139,7 @@
|
|||||||
{t("wa_install_and_configure")}
|
{t("wa_install_and_configure")}
|
||||||
</Button>
|
</Button>
|
||||||
{:else if trialError && canRequestTrial}
|
{:else if trialError && canRequestTrial}
|
||||||
<Button
|
<Button class="wide" onclick={activateTrial} disabled={trialBusy}>
|
||||||
class="wide"
|
|
||||||
onclick={() => activateTrial({ stayOnTrial: true })}
|
|
||||||
disabled={trialBusy}
|
|
||||||
>
|
|
||||||
<RefreshCw size={18} />
|
<RefreshCw size={18} />
|
||||||
{t("wa_trial_retry", {}, "Try again")}
|
{t("wa_trial_retry", {}, "Try again")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -754,6 +754,9 @@
|
|||||||
"wa_trial_download_traffic_label": "Available to download",
|
"wa_trial_download_traffic_label": "Available to download",
|
||||||
"wa_trial_try_free": "Try for free",
|
"wa_trial_try_free": "Try for free",
|
||||||
"wa_trial_activated": "Trial activated",
|
"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_failed": "Failed to activate trial",
|
||||||
"wa_trial_activation_loading": "Activating trial...",
|
"wa_trial_activation_loading": "Activating trial...",
|
||||||
"wa_trial_activation_wait": "Preparing access and connection details.",
|
"wa_trial_activation_wait": "Preparing access and connection details.",
|
||||||
@@ -821,6 +824,7 @@
|
|||||||
"wa_device_disconnected": "Device disconnected",
|
"wa_device_disconnected": "Device disconnected",
|
||||||
"wa_device_disconnect_failed": "Failed to disconnect device",
|
"wa_device_disconnect_failed": "Failed to disconnect device",
|
||||||
"wa_cancel": "Cancel",
|
"wa_cancel": "Cancel",
|
||||||
|
"wa_ok": "OK",
|
||||||
"wa_payment_created": "Payment created",
|
"wa_payment_created": "Payment created",
|
||||||
"wa_payment_create_failed": "Failed to create payment",
|
"wa_payment_create_failed": "Failed to create payment",
|
||||||
"wa_connect_link_unavailable": "Connection link is not available yet",
|
"wa_connect_link_unavailable": "Connection link is not available yet",
|
||||||
|
|||||||
@@ -754,6 +754,9 @@
|
|||||||
"wa_trial_download_traffic_label": "Доступно для скачивания",
|
"wa_trial_download_traffic_label": "Доступно для скачивания",
|
||||||
"wa_trial_try_free": "Попробовать бесплатно",
|
"wa_trial_try_free": "Попробовать бесплатно",
|
||||||
"wa_trial_activated": "Пробный период активирован",
|
"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_failed": "Не удалось активировать пробный период",
|
||||||
"wa_trial_activation_loading": "Активируем пробный период...",
|
"wa_trial_activation_loading": "Активируем пробный период...",
|
||||||
"wa_trial_activation_wait": "Готовим доступ и данные для подключения.",
|
"wa_trial_activation_wait": "Готовим доступ и данные для подключения.",
|
||||||
@@ -821,6 +824,7 @@
|
|||||||
"wa_device_disconnected": "Устройство отключено",
|
"wa_device_disconnected": "Устройство отключено",
|
||||||
"wa_device_disconnect_failed": "Не удалось отключить устройство",
|
"wa_device_disconnect_failed": "Не удалось отключить устройство",
|
||||||
"wa_cancel": "Отмена",
|
"wa_cancel": "Отмена",
|
||||||
|
"wa_ok": "ОК",
|
||||||
"wa_payment_created": "Платеж создан",
|
"wa_payment_created": "Платеж создан",
|
||||||
"wa_payment_create_failed": "Не удалось создать платеж",
|
"wa_payment_create_failed": "Не удалось создать платеж",
|
||||||
"wa_connect_link_unavailable": "Ссылка для подключения пока недоступна",
|
"wa_connect_link_unavailable": "Ссылка для подключения пока недоступна",
|
||||||
|
|||||||
Reference in New Issue
Block a user