-
Notifications
You must be signed in to change notification settings - Fork 9.7k
Expand file tree
/
Copy pathAmrBalanceDialog.tsx
More file actions
270 lines (267 loc) · 10.8 KB
/
Copy pathAmrBalanceDialog.tsx
File metadata and controls
270 lines (267 loc) · 10.8 KB
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
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Button, Dialog } from '@open-design/components';
import { useT } from '../i18n';
import { useAnalytics } from '../analytics/provider';
import { getResolvedDeviceId } from '../analytics/client';
import {
amrHandoffDeviceId,
attributedAmrUrl,
recordAmrEntry,
} from '../analytics/amr-attribution';
import { useWorkspaceBilling, useWorkspaceContext } from '../collab/useWorkspaceContext';
import { workspaceUpgradeUrl } from './EntryNavRail';
import {
AMR_HARD_BLOCK_BALANCE_USD,
amrWalletBalanceUsd,
} from '../runtime/amr-balance-gate';
import { fetchAmrWalletSnapshot, formatVelaBalanceUsd } from '../providers/daemon';
import { AmrLoginPill } from './AmrLoginPill';
import { Icon } from './Icon';
import styles from './AmrBalanceDialog.module.css';
/** How often the post-recharge wallet watch polls (daemon-cached reads; the
* daemon's own TTL rate-limits the upstream calls). */
const WALLET_WATCH_INTERVAL_MS = 5_000;
/** Give up watching after this long; the dialog stays, resume goes manual. */
const WALLET_WATCH_TIMEOUT_MS = 10 * 60_000;
interface Props {
/** Why the send was hard-blocked: empty wallet, or not signed in at all. */
reason: 'insufficient' | 'signed_out';
/** Raw wallet balance string from the blocking snapshot; null hides the badge. */
balanceUsd: string | null;
/** Open Design Cloud profile from the blocking snapshot; picks the console origin. */
profile: string | null;
/** Which surface blocked the send — keys the amr_entry attribution. */
entrySource: 'home_balance_gate_upgrade' | 'chat_balance_gate_upgrade';
metricsConsent: boolean;
installationId: string | null | undefined;
/** Dismissal only ("not now" / Esc); the blocked payload stays parked. */
onClose: () => void;
/** The blocking condition just cleared (sign-in completed, or the wallet
* watch saw the recharge land) — the caller resumes the parked task. */
onResolved: () => void;
}
// HARD pre-run blocker for Open Design Cloud tasks: the run cannot possibly
// succeed, so the send is stopped BEFORE any run spawns — unlike the
// post-failure AMR_INSUFFICIENT_BALANCE error card which appears after a run
// already burned its startup. It fires at the moment of PEAK intent — the
// user just wrote a task and pressed send — so it must read as "one step from
// starting", never as an error. Two variants with distinct copy AND CTAs:
//
// insufficient — signed in, wallet definitively empty. The CTA reads
// 「升级套餐」, so it must LAND on the plan picker rather than drop the user
// on a page to hunt for it: it opens the team dashboard with B's
// `billing=checkout` deep link, which auto-opens the checkout dialog on
// arrival. Balance badge shown.
//
// signed_out — Open Design Cloud selected but no account session. The CTA
// is the in-app sign-in (AmrLoginPill: spawns vela login, surfaces the
// activation link when the browser doesn't auto-open, polls until done);
// sending the user to the wallet website would be a dead end.
//
// Both variants AUTO-RESUME: a completed sign-in fires `onResolved`
// immediately, and clicking the wallet CTA arms a bounded wallet watch that
// fires `onResolved` once the recharge lands — so the parked task continues
// without the user having to re-send it.
//
// Both variants keep the benefits list (they sell the service to exactly the
// not-yet-committed cohort). The caller preserves the payload (home keeps
// the composer draft; chat parks the full send in the queue). The softer
// low-balance reminder lives in AmrLowBalanceDialog; this hard tier is never
// subject to its opt-out.
export function AmrBalanceDialog({
reason,
balanceUsd,
profile,
entrySource,
metricsConsent,
installationId,
onClose,
onResolved,
}: Props) {
const t = useT();
const analytics = useAnalytics();
const formattedBalance = formatVelaBalanceUsd(balanceUsd);
const signedOut = reason === 'signed_out';
const signInEntrySource =
entrySource === 'home_balance_gate_upgrade'
? ('home_balance_gate_sign_in' as const)
: ('chat_balance_gate_sign_in' as const);
// Armed by the wallet CTA click (the user's "I'm going to recharge"
// signal): poll the wallet until the balance clears the hard line, then
// resume the parked task via onResolved. Bounded so an abandoned recharge
// doesn't poll forever; guarded against double-fires.
const [watchingWallet, setWatchingWallet] = useState(false);
// Where 「升级套餐」 goes. `workspaceUpgradeUrl` is the one decision point for
// every upgrade affordance: personal workspace → B's personal plan modal
// (`billing=plan`); team → `billing=checkout` vs `billing=plan` by whether
// the team ever completed a first checkout. Getting the personal branch wrong
// opened an error-state dialog: routing a personal workspace onto the team
// `billing=checkout` deep link opened the Upgrade-to-Team dialog with "Team
// plan unavailable" / a 3-seat minimum (recvpYEiH019cD, failed acceptance
// round). B now resolves `billing=plan` against the workspace's own state, so
// the team branch's guess is a hint rather than a requirement. The profile
// fallback keeps the CTA alive after a signed-out/no-context read (but not
// while that read is pending) — same `billing=plan` deep link every other
// Upgrade affordance uses (ChatPane, AvatarMenu, InlineModelSwitcher).
const {
context: workspaceContext,
loading: workspaceContextLoading,
} = useWorkspaceContext();
const workspaceBilling = useWorkspaceBilling();
const upgradeUrl = workspaceContextLoading
? null
: workspaceUpgradeUrl(workspaceContext, workspaceBilling, {
fallbackProfile: profile,
});
const resolvedRef = useRef(false);
const resolveOnce = () => {
if (resolvedRef.current) return;
resolvedRef.current = true;
onResolved();
};
useEffect(() => {
if (!watchingWallet) return;
let cancelled = false;
const startedAt = Date.now();
const tick = async () => {
if (cancelled) return;
const snapshot = await fetchAmrWalletSnapshot().catch(() => null);
if (cancelled) return;
const balance = amrWalletBalanceUsd(snapshot);
if (balance != null && balance > AMR_HARD_BLOCK_BALANCE_USD) {
resolveOnce();
return;
}
if (Date.now() - startedAt > WALLET_WATCH_TIMEOUT_MS) {
setWatchingWallet(false);
}
};
const interval = setInterval(() => void tick(), WALLET_WATCH_INTERVAL_MS);
return () => {
cancelled = true;
clearInterval(interval);
};
// resolveOnce is stable via ref; onResolved changes don't re-arm the watch.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [watchingWallet]);
const openUpgrade = () => {
if (!upgradeUrl) return;
setWatchingWallet(true);
// Same attribution handshake as the other Open Design Cloud handoffs
// (ChatPane recharge, AvatarMenu upgrade): record the amr_entry, forward
// the consent-gated device id, and open the console for the profile.
const attribution = recordAmrEntry(analytics.track, entrySource, new Date(), {
metricsConsent,
});
const deviceId = amrHandoffDeviceId({
metricsConsent,
resolvedDeviceId: getResolvedDeviceId(),
installationId,
});
window.open(
attributedAmrUrl(upgradeUrl, attribution, deviceId),
'_blank',
'noopener,noreferrer',
);
};
const benefits = [
t('chat.amrBalanceGate.benefit1'),
t('chat.amrBalanceGate.benefit2'),
t('chat.amrBalanceGate.benefit3'),
t('chat.amrBalanceGate.benefit4'),
];
const dialog = (
<Dialog
role="alertdialog"
ariaLabel={signedOut ? t('chat.amrBalanceGate.signedOutTitle') : t('chat.amrBalanceGate.title')}
onClose={onClose}
closeOnEscape
className={styles.panel}
data-testid="amr-balance-dialog"
>
<button
type="button"
className={styles.closeButton}
onClick={onClose}
aria-label={t('common.close')}
>
<Icon name="close" size={14} />
</button>
<div className={styles.banner}>
<img
className={styles.bannerImage}
src="/upgrade/cloud-signin-aurora.jpg"
alt=""
width={1680}
height={720}
decoding="async"
draggable={false}
/>
</div>
<h2 className={styles.title}>
{signedOut ? t('chat.amrBalanceGate.signedOutTitle') : t('chat.amrBalanceGate.title')}
</h2>
<p className={styles.message}>
{signedOut
? t('chat.amrBalanceGate.signedOutMessage')
: // The insufficient variant always carries a definitive balance
// (that's what made the gate fire); the fallback is belt and
// suspenders for a malformed snapshot.
t('chat.amrBalanceGate.message', { balance: formattedBalance ?? '$0.00' })}
</p>
<div className={styles.benefitsCard}>
<span className={styles.benefitsTitle}>
{t('chat.amrBalanceGate.benefitsTitle')}
</span>
<ul className={styles.benefits}>
{benefits.map((benefit) => (
<li key={benefit} className={styles.benefit}>
<span className={styles.benefitIcon} aria-hidden>
<Icon name="check" size={14} />
</span>
{benefit}
</li>
))}
</ul>
</div>
<div className={styles.actions}>
{signedOut ? (
<AmrLoginPill
className={styles.signInPill}
signInLabel={t('chat.amrBalanceGate.signInCta')}
amrEntrySourceDetail={signInEntrySource}
metricsConsent={metricsConsent}
installationId={installationId}
showActivationDetails
hideSignedOutStatus
revealPendingCancelAction
onStatusChange={(loginStatus) => {
// Signed in — the gate's reason is gone; resume the parked task.
if (loginStatus?.loggedIn === true) resolveOnce();
}}
/>
) : upgradeUrl ? (
<Button
variant="primary"
className={styles.cta}
onClick={openUpgrade}
data-testid="amr-balance-dialog-plans"
>
{t('chat.amrBalanceGate.plansCta')}
</Button>
) : null}
<Button variant="ghost" className={styles.later} onClick={onClose}>
{t('chat.amrBalanceGate.laterCta')}
</Button>
</div>
{watchingWallet ? (
<p className={styles.watchingHint} data-testid="amr-balance-dialog-watching">
{t('chat.amrBalanceGate.watchingWallet')}
</p>
) : null}
</Dialog>
);
if (typeof document === 'undefined') return dialog;
return createPortal(dialog, document.body);
}