File size: 7,381 Bytes
aeea10c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a7da6ce
 
 
 
aeea10c
 
 
a7da6ce
aeea10c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { PLAN_LIMIT_EVENT, type PlanLimitDetail } from "../api/client";
import { billingApi, type BillingCycle } from "../api/endpoints";
import type { BillingStatus, Nudge } from "../api/types";
import { errorMessage } from "../components/ui";
import { ONBOARDED_KEY } from "../pages/Welcome";

/**
 * The upgrade system: billing status (plan, meters, nudges), the upgrade popup and nudge dismissals.
 * Popup modes: explore (the opening pitch), limit (a request hit a 402 plan_limit), welcome (back from checkout).
 */

export type UpgradeModal =
  | { mode: "explore"; highlight?: string | null }
  | { mode: "limit"; limit: PlanLimitDetail }
  | { mode: "welcome" };

type UpgradeState = {
  status: BillingStatus | null;
  modal: UpgradeModal | null;
  nudge: Nudge | null;
  openUpgrade: (highlight?: string | null) => void;
  close: () => void;
  refresh: () => Promise<void>;
  dismissNudge: (id: string) => void;
  checkout: (plan: string, cycle: BillingCycle) => Promise<void>;
  manageBilling: () => Promise<void>;
  busy: boolean;
  error: string | null;
};

const SEEN_KEY = "ns_upgrade_seen"; // last time the opening popup showed
const DISMISSED_KEY = "ns_nudges_dismissed"; // { nudgeId: dismissedAtMs }
const OPENING_EVERY_MS = 3 * 24 * 3600 * 1000; // opening popup at most every three days
const NUDGE_SNOOZE_MS = 5 * 24 * 3600 * 1000; // a dismissed nudge stays away for five days
const OPENING_DELAY_MS = 1400;
const NUDGE_DELAY_MS = 5000;
const REFRESH_EVERY_MS = 30_000;

function readJson<T>(key: string, fallback: T): T {
  try {
    const raw = localStorage.getItem(key);
    return raw ? (JSON.parse(raw) as T) : fallback;
  } catch {
    return fallback;
  }
}

function writeJson(key: string, value: unknown): void {
  try {
    localStorage.setItem(key, JSON.stringify(value));
  } catch {
    /* private mode: remember for this visit only */
  }
}

const UpgradeContext = createContext<UpgradeState | null>(null);

export function UpgradeProvider({ children }: { children: ReactNode }) {
  const [status, setStatus] = useState<BillingStatus | null>(null);
  const [modal, setModal] = useState<UpgradeModal | null>(null);
  const [dismissed, setDismissed] = useState<Record<string, number>>(() => readJson(DISMISSED_KEY, {}));
  const [nudgeReady, setNudgeReady] = useState(false);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const lastFetch = useRef(0);
  const openingDone = useRef(false);
  const openingTimer = useRef<ReturnType<typeof setTimeout>>();
  const location = useLocation();
  const navigate = useNavigate();

  const refresh = useCallback(async () => {
    lastFetch.current = Date.now();
    try {
      setStatus(await billingApi.status());
    } catch {
      /* the app works without it; nudges just stay hidden */
    }
  }, []);

  // Fresh meters on navigation (throttled), so nudges follow what the user just did.
  useEffect(() => {
    if (Date.now() - lastFetch.current > REFRESH_EVERY_MS) refresh();
    setNudgeReady(false);
    const t = setTimeout(() => setNudgeReady(true), NUDGE_DELAY_MS);
    return () => clearTimeout(t);
  }, [location.pathname, refresh]);

  // Any request that hits a plan limit opens the out of fuel popup.
  useEffect(() => {
    const onLimit = (e: Event) => {
      setError(null);
      setModal({ mode: "limit", limit: (e as CustomEvent<PlanLimitDetail>).detail });
      refresh();
    };
    window.addEventListener(PLAN_LIMIT_EVENT, onLimit);
    return () => window.removeEventListener(PLAN_LIMIT_EVENT, onLimit);
  }, [refresh]);

  // ?upgrade=1 opens the pitch (links from emails, the pricing page); ?checkout=success celebrates.
  useEffect(() => {
    const params = new URLSearchParams(location.search);
    const upgrade = params.get("upgrade");
    const checkoutResult = params.get("checkout");
    if (!upgrade && !checkoutResult) return;
    if (upgrade) setModal({ mode: "explore", highlight: upgrade === "1" ? null : upgrade });
    if (checkoutResult === "success") {
      setModal({ mode: "welcome" });
      // Apply the purchase now instead of waiting for Stripe's webhook, then show the new plan.
      const sessionId = params.get("session_id");
      if (sessionId) billingApi.confirm(sessionId).catch(() => undefined).finally(refresh);
      else refresh();
    }
    params.delete("upgrade");
    params.delete("checkout");
    params.delete("session_id");
    const rest = params.toString();
    navigate({ pathname: location.pathname, search: rest ? `?${rest}` : "" }, { replace: true });
    openingDone.current = true;
  }, [location.search, location.pathname, navigate, refresh]);

  // The opening popup: once per few days for workspaces that can upgrade, after onboarding.
  useEffect(() => {
    if (!status || openingDone.current || !status.can_upgrade) return;
    openingDone.current = true;
    const onboarded = readJson<number | string>(ONBOARDED_KEY, 0) || status.meters.some((m) => m.key === "sources" && m.used > 0);
    const seen = readJson<number>(SEEN_KEY, 0);
    if (!onboarded || Date.now() - seen < OPENING_EVERY_MS) return;
    // Not cleared when status refreshes; only on unmount (below), or it would never fire.
    openingTimer.current = setTimeout(() => {
      setModal((m) => m ?? { mode: "explore" });
      writeJson(SEEN_KEY, Date.now());
    }, OPENING_DELAY_MS);
  }, [status]);

  useEffect(() => () => clearTimeout(openingTimer.current), []);

  const dismissNudge = useCallback((id: string) => {
    setDismissed((d) => {
      const next = { ...d, [id]: Date.now() };
      writeJson(DISMISSED_KEY, next);
      return next;
    });
    setNudgeReady(false); // the next one waits for the next page
  }, []);

  const nudge = useMemo(() => {
    if (!status || !nudgeReady || modal) return null;
    const now = Date.now();
    return (
      status.nudges.find(
        (n) => !(dismissed[n.id] && now - dismissed[n.id] < NUDGE_SNOOZE_MS) && n.to !== location.pathname,
      ) ?? null
    );
  }, [status, nudgeReady, modal, dismissed, location.pathname]);

  const redirectTo = useCallback(async (get: () => Promise<{ url: string }>) => {
    setBusy(true);
    setError(null);
    try {
      window.location.assign((await get()).url);
    } catch (e) {
      setError(errorMessage(e));
      setBusy(false);
    }
  }, []);

  const value = useMemo<UpgradeState>(
    () => ({
      status,
      modal,
      nudge,
      openUpgrade: (highlight) => {
        setError(null);
        setModal({ mode: "explore", highlight });
      },
      close: () => {
        setModal(null);
        setError(null);
      },
      refresh,
      dismissNudge,
      checkout: (plan, cycle) => redirectTo(() => billingApi.checkout(plan, cycle)),
      manageBilling: () => redirectTo(billingApi.portal),
      busy,
      error,
    }),
    [status, modal, nudge, refresh, dismissNudge, redirectTo, busy, error],
  );
  return <UpgradeContext.Provider value={value}>{children}</UpgradeContext.Provider>;
}

export function useUpgrade(): UpgradeState {
  const ctx = useContext(UpgradeContext);
  if (!ctx) throw new Error("useUpgrade must be used inside UpgradeProvider");
  return ctx;
}