diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 764e832..200d23b 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -67,6 +67,15 @@ /** Used-traffic percent from which top-up modals and CTAs unlock in the web app home screen */ const TRAFFIC_TOPUP_UNLOCK_PERCENT = 80; + const ACTIVATION_HANDOFF_STORAGE_KEY = "rw_webapp_activation_handoff_v1"; + const ACTIVATION_HANDOFF_TTL_MS = 48 * 60 * 60 * 1000; + const ACTIVATION_PENDING_WATCH_INTERVAL_MS = 2000; + const ACTIVATION_PENDING_WATCH_MAX_ATTEMPTS = 45; + const ACTIVATION_RESUME_CHECK_COOLDOWN_MS = 1500; + import { + activationPaymentFailed, + createActivationHandoff, + } from "./lib/webapp/activationHandoff.js"; import { buildGravatarUrl } from "./lib/webapp/gravatar.js"; import { createBillingActions } from "./lib/webapp/billingActions.js"; import { invalidateWebappTariffOptionCaches } from "./lib/webapp/billingOptionCache.js"; @@ -126,6 +135,11 @@ let trialActivationError = ""; let activationSuccessDialogOpen = false; let activationSuccessUseInstallGuides = false; + let activationPendingWatchTimer = null; + let activationPendingWatchAttempts = 0; + let activationPendingWatchBusy = false; + let activationResumeRefreshBusy = false; + let activationResumeLastCheckAt = 0; let promoCode = ""; let promoBusy = false; let promoStatus = ""; @@ -187,6 +201,10 @@ api: (path, options) => apiClient.api(path, options), t: (...args) => t(...args), }); + const activationHandoff = createActivationHandoff({ + storageKey: ACTIVATION_HANDOFF_STORAGE_KEY, + ttlMs: ACTIVATION_HANDOFF_TTL_MS, + }); const authStore = createAuthStore({ publicApi, @@ -204,6 +222,7 @@ t, showToast, openExternalLink, + onSubscriptionActivationPending: rememberActivationPending, onSubscriptionActivated: handleSubscriptionActivated, tg, }); @@ -515,6 +534,165 @@ return Boolean(enabled && sub?.active); } + function hasPendingActivationHandoff(payload = data) { + return activationHandoff.hasPending(payload); + } + + function rememberActivationPending(context = {}) { + activationHandoff.rememberPending(context, data); + } + + function clearPendingActivationHandoff() { + activationHandoff.clearPending(); + } + + async function maybeShowActivationSuccessDialog(context = {}) { + if (activationSuccessDialogOpen) return false; + await tick(); + const payload = context.payload || data; + const subscriptionKey = activationHandoff.subscriptionKey(payload); + if (!subscriptionKey) return false; + const state = activationHandoff.read(); + const pending = state.pending; + if (activationHandoff.isAcknowledged(subscriptionKey, state)) { + if (pending && activationHandoff.pendingMatchesUser(pending, payload)) { + activationHandoff.write({ ...state, pending: null }); + } + return false; + } + if ( + !context.force && + (!pending || + !activationHandoff.isPendingFresh(pending) || + !activationHandoff.pendingMatchesUser(pending, payload)) + ) { + return false; + } + activationHandoff.acknowledge(subscriptionKey, context, payload, state); + stopPendingActivationWatch(); + navigateToActivationTarget({ replace: true }); + activationSuccessDialogOpen = true; + return true; + } + + function stopPendingActivationWatch() { + if (activationPendingWatchTimer) { + window.clearTimeout(activationPendingWatchTimer); + activationPendingWatchTimer = null; + } + activationPendingWatchAttempts = 0; + activationPendingWatchBusy = false; + } + + function schedulePendingActivationWatch() { + if (activationPendingWatchTimer || !hasPendingActivationHandoff()) return; + activationPendingWatchTimer = window.setTimeout(() => { + activationPendingWatchTimer = null; + void checkPendingActivationWatch(); + }, ACTIVATION_PENDING_WATCH_INTERVAL_MS); + } + + function startPendingActivationWatch() { + if ( + mode !== "app" || + !hasPendingActivationHandoff() || + activationSuccessDialogOpen || + screen === "admin" + ) { + stopPendingActivationWatch(); + return; + } + if (activationPendingWatchTimer || activationPendingWatchBusy) return; + schedulePendingActivationWatch(); + } + + async function checkPendingActivationWatch() { + if (activationPendingWatchBusy) return; + if ( + mode !== "app" || + !hasPendingActivationHandoff() || + activationSuccessDialogOpen || + screen === "admin" + ) { + stopPendingActivationWatch(); + return; + } + if (activationPendingWatchAttempts >= ACTIVATION_PENDING_WATCH_MAX_ATTEMPTS) { + stopPendingActivationWatch(); + return; + } + + const state = activationHandoff.read(); + const pending = state.pending; + activationPendingWatchAttempts += 1; + activationPendingWatchBusy = true; + try { + let shouldRefreshProfile = !pending?.paymentId; + if (pending?.paymentId && billing.fetchPaymentStatus) { + const paymentStatus = await billing.fetchPaymentStatus(pending.paymentId); + if (paymentStatus?.paid || paymentStatus?.status === "succeeded") { + shouldRefreshProfile = true; + } else if (activationPaymentFailed(paymentStatus)) { + clearPendingActivationHandoff(); + stopPendingActivationWatch(); + return; + } + } + if (shouldRefreshProfile) { + await loadData(); + const shown = await maybeShowActivationSuccessDialog({ + source: "watch", + paymentId: pending?.paymentId, + }); + if (shown || !hasPendingActivationHandoff()) { + stopPendingActivationWatch(); + return; + } + } + } catch (_error) { + void _error; + } finally { + activationPendingWatchBusy = false; + } + schedulePendingActivationWatch(); + } + + function canRefreshPendingActivationOnResume() { + return Boolean( + mode === "app" && + screen !== "admin" && + !activationSuccessDialogOpen && + !paymentModalOpen && + !topupModalOpen && + !deviceTopupModalOpen && + !changeModalOpen && + !changeConfirmOpen && + hasPendingActivationHandoff() + ); + } + + async function refreshPendingActivationOnResume() { + if (!canRefreshPendingActivationOnResume()) return; + const now = Date.now(); + if ( + activationResumeRefreshBusy || + now - activationResumeLastCheckAt < ACTIVATION_RESUME_CHECK_COOLDOWN_MS + ) { + return; + } + activationResumeLastCheckAt = now; + activationResumeRefreshBusy = true; + try { + await loadData(); + const shown = await maybeShowActivationSuccessDialog({ source: "resume" }); + if (!shown) startPendingActivationWatch(); + } catch (_error) { + void _error; + } finally { + activationResumeRefreshBusy = false; + } + } + function refreshAppLaunchTarget() { appLaunchTarget = readExternalAppLaunchTarget(); return appLaunchTarget; @@ -534,6 +712,13 @@ const onAnyPointerDown = () => { if (mode === "login") loginEmailTooltipOpen = false; }; + const onActivationResume = () => { + if (typeof document !== "undefined" && document.visibilityState === "hidden") return; + void refreshPendingActivationOnResume(); + }; + const onVisibilityChange = () => { + if (document.visibilityState !== "hidden") onActivationResume(); + }; const onPopState = () => { const shareToken = publicInstallTokenFromPath(window.location.pathname); if (shareToken) { @@ -585,15 +770,22 @@ }; window.addEventListener("popstate", onPopState); window.addEventListener("pointerdown", onAnyPointerDown); + window.addEventListener("focus", onActivationResume); + window.addEventListener("pageshow", onActivationResume); + document.addEventListener("visibilitychange", onVisibilityChange); boot(); return () => { window.removeEventListener("popstate", onPopState); window.removeEventListener("pointerdown", onAnyPointerDown); + window.removeEventListener("focus", onActivationResume); + window.removeEventListener("pageshow", onActivationResume); + document.removeEventListener("visibilitychange", onVisibilityChange); authStore.stopTelegramLoginWatchdog(); authStore.clearCooldownTimer(); accountStore.clearLinkEmailResendTimer(); accountStore.clearSetPasswordResendTimer(); supportStore.closePolling(); + stopPendingActivationWatch(); clearLanguageClickGuard(); syncBodyScrollLock(false); destroyAdminMount(); @@ -877,6 +1069,10 @@ getToken: () => token, getCsrfToken: () => csrfToken, }); + if (mode === "app" && screen !== "admin") { + const shown = await maybeShowActivationSuccessDialog({ source: "boot" }); + if (!shown) startPendingActivationWatch(); + } } function stripTopupQueryFromUrl() { @@ -1206,16 +1402,10 @@ syncSectionPath("home", replace); } - async function showActivationSuccessDialog() { - await tick(); - navigateToActivationTarget({ replace: true }); - activationSuccessDialogOpen = true; - } - - async function handleSubscriptionActivated() { + async function handleSubscriptionActivated(context = {}) { await tick(); if (!subscription?.active) return; - await showActivationSuccessDialog(); + await maybeShowActivationSuccessDialog({ ...context, force: true, source: "payment" }); } function closeActivationSuccessDialog() { @@ -1290,7 +1480,7 @@ trialActivationResult = response; showToast(t("wa_trial_activated")); await loadData(); - await showActivationSuccessDialog(); + await maybeShowActivationSuccessDialog({ source: "trial", force: true }); } catch (error) { const message = error?.message || t("wa_trial_activation_failed"); trialActivationError = message; diff --git a/frontend/src/lib/webapp/activationHandoff.js b/frontend/src/lib/webapp/activationHandoff.js new file mode 100644 index 0000000..48fa6d9 --- /dev/null +++ b/frontend/src/lib/webapp/activationHandoff.js @@ -0,0 +1,151 @@ +export function activationPaymentFailed(status) { + const normalized = String(status?.status || "").toLowerCase(); + return ( + normalized === "failed" || + normalized === "canceled" || + normalized === "cancelled" || + normalized.startsWith("failed_") + ); +} + +export function createActivationHandoff({ storageKey, ttlMs, now = () => Date.now() } = {}) { + let fallbackState = null; + + function normalizeState(value) { + return value && typeof value === "object" + ? { + pending: value.pending && typeof value.pending === "object" ? value.pending : null, + acknowledged: + value.acknowledged && typeof value.acknowledged === "object" + ? value.acknowledged + : null, + } + : { pending: null, acknowledged: null }; + } + + function isPendingFresh(pending) { + const startedAt = Number(pending?.startedAt || 0); + return Boolean(startedAt && now() - startedAt <= ttlMs); + } + + function write(state) { + const normalized = normalizeState(state); + fallbackState = normalized; + if (!storageKey) return; + try { + localStorage.setItem(storageKey, JSON.stringify(normalized)); + } catch (_error) { + void _error; + } + } + + function read() { + let state = fallbackState || { pending: null, acknowledged: null }; + if (storageKey) { + try { + const raw = localStorage.getItem(storageKey); + if (raw) state = JSON.parse(raw); + } catch (_error) { + void _error; + } + } + state = normalizeState(state); + if (state.pending && !isPendingFresh(state.pending)) { + state = { ...state, pending: null }; + write(state); + } + return state; + } + + function userKey(payload = {}) { + const payloadUser = payload?.user || {}; + return String(payloadUser.user_id ?? payloadUser.id ?? payloadUser.telegram_id ?? "").trim(); + } + + function subscriptionKey(payload = {}) { + const payloadSubscription = payload?.subscription || {}; + if (!payloadSubscription?.active) return ""; + return [ + userKey(payload) || "anonymous", + payloadSubscription.panel_short_uuid || + payloadSubscription.panel_uuid || + payloadSubscription.uuid || + payloadSubscription.subscription_id || + payloadSubscription.config_link || + payloadSubscription.connect_url || + "active", + payloadSubscription.end_date || payloadSubscription.end_date_text || "", + payloadSubscription.tariff_key || payloadSubscription.tariff_name || "", + payloadSubscription.status || "", + ] + .map((part) => String(part || "").trim()) + .join("|"); + } + + function pendingMatchesUser(pending, payload = {}) { + if (!pending) return false; + const pendingUserKey = String(pending.userKey || "").trim(); + const currentUserKey = userKey(payload); + return !pendingUserKey || !currentUserKey || pendingUserKey === currentUserKey; + } + + function hasPending(payload = {}) { + const pending = read().pending; + return Boolean(pending && pendingMatchesUser(pending, payload)); + } + + function rememberPending(context = {}, payload = {}) { + if (context.initialSubscriptionPayment === false) return; + const state = read(); + write({ + ...state, + pending: { + kind: "initial_subscription", + source: String(context.source || "payment"), + paymentId: String(context.paymentId || ""), + userKey: userKey(payload), + startedAt: now(), + }, + }); + } + + function clearPending() { + const state = read(); + if (!state.pending) return; + write({ ...state, pending: null }); + } + + function isAcknowledged(nextSubscriptionKey, state = read()) { + return Boolean( + nextSubscriptionKey && state.acknowledged?.subscriptionKey === nextSubscriptionKey + ); + } + + function acknowledge(nextSubscriptionKey, context = {}, payload = {}, state = read()) { + const pending = state.pending || {}; + write({ + ...state, + pending: null, + acknowledged: { + subscriptionKey: nextSubscriptionKey, + source: String(context.source || pending.source || "payment"), + paymentId: String(context.paymentId || pending.paymentId || ""), + userKey: userKey(payload), + acknowledgedAt: now(), + }, + }); + } + + return { + acknowledge, + clearPending, + hasPending, + isAcknowledged, + isPendingFresh, + pendingMatchesUser, + read, + rememberPending, + subscriptionKey, + write, + }; +} diff --git a/frontend/src/lib/webapp/stores/billingStore.js b/frontend/src/lib/webapp/stores/billingStore.js index 326968b..6ce80f4 100644 --- a/frontend/src/lib/webapp/stores/billingStore.js +++ b/frontend/src/lib/webapp/stores/billingStore.js @@ -6,6 +6,7 @@ export function createBillingStore({ t, showToast, openExternalLink, + onSubscriptionActivationPending = null, onSubscriptionActivated = null, tg, }) { @@ -68,7 +69,21 @@ export function createBillingStore({ successContext.initialSubscriptionPayment && typeof onSubscriptionActivated === "function" ) { - await onSubscriptionActivated(); + await onSubscriptionActivated({ source: "payment", ...successContext }); + } + } + + function rememberSubscriptionActivationPending(successContext = {}) { + if ( + !successContext.initialSubscriptionPayment || + typeof onSubscriptionActivationPending !== "function" + ) { + return; + } + try { + onSubscriptionActivationPending({ source: "payment", ...successContext }); + } catch (_error) { + void _error; } } @@ -264,6 +279,7 @@ export function createBillingStore({ if (!response.ok) throw response; showToast(t("wa_payment_created")); const successContext = paymentSuccessContext(s, response); + rememberSubscriptionActivationPending(successContext); if (response.action === "open_invoice") { if (!response.payment_url) throw response; openTelegramInvoice(response.payment_url, successContext);