feat: resume activation handoff after payments
This commit is contained in:
+199
-9
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user