Move billing flows to web dashboard (#956)

This commit is contained in:
Steven Enamakel
2026-04-26 23:06:14 -07:00
committed by GitHub
parent 0836632209
commit 74e532d13a
10 changed files with 119 additions and 482 deletions
+2 -1
View File
@@ -18,6 +18,7 @@ import { purgeWebviewAccount } from '../services/webviewAccountService';
import { addAccount, removeAccount } from '../store/accountsSlice';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import { type Account, type AccountProvider, PROVIDERS } from '../types/accounts';
import { BILLING_DASHBOARD_URL } from '../utils/links';
import { openUrl } from '../utils/openUrl';
import { ProviderIcon } from './accounts/providerIcons';
import ChannelSetupModal from './channels/ChannelSetupModal';
@@ -243,7 +244,7 @@ const BillingBody = ({ close }: { close: () => void }) => {
<button
type="button"
onClick={() => {
void openUrl('https://tinyhumans.ai/dashboard').catch(() => {});
void openUrl(BILLING_DASHBOARD_URL).catch(() => {});
}}
className="w-full rounded-xl bg-primary-500 text-white text-sm font-medium py-2.5 hover:bg-primary-600 transition-colors">
Open dashboard in browser
+5 -4
View File
@@ -1,7 +1,7 @@
import { useNavigate } from 'react-router-dom';
import { useUsageState } from '../../hooks/useUsageState';
import { useAppSelector } from '../../store/hooks';
import { BILLING_DASHBOARD_URL } from '../../utils/links';
import { openUrl } from '../../utils/openUrl';
function formatTokens(n: number): string {
if (n < 1000) return String(n);
@@ -42,7 +42,6 @@ function severityFromPct(pct: number): PillSeverity {
}
const TokenUsagePill = () => {
const navigate = useNavigate();
const sessionTokens = useAppSelector(state => state.chatRuntime.sessionTokenUsage);
const { usagePct10h, usagePct7d, isAtLimit, isNearLimit, currentTier, teamUsage } =
useUsageState();
@@ -82,7 +81,9 @@ const TokenUsagePill = () => {
{showPlanPill ? (
<button
type="button"
onClick={() => navigate('/settings/billing')}
onClick={() => {
void openUrl(BILLING_DASHBOARD_URL);
}}
title={planTitle}
className={`inline-flex items-center gap-1 rounded-full px-2 py-1 font-medium ring-1 transition-colors ${planSeverity.bg} ${planSeverity.text} ${planSeverity.ring} hover:opacity-80`}>
{isAtLimit ? 'Limit' : planSeverity.label}
+1 -3
View File
@@ -1,8 +1,6 @@
import { DISCORD_INVITE_URL } from '../../utils/links';
import { BILLING_DASHBOARD_URL, DISCORD_INVITE_URL } from '../../utils/links';
import { openUrl } from '../../utils/openUrl';
const BILLING_DASHBOARD_URL = 'https://tinyhumans.ai/dashboard';
function formatUsd(amount: number): string {
return `$${amount.toFixed(amount % 1 === 0 ? 0 : 2)}`;
}
+6 -2
View File
@@ -2,6 +2,8 @@ import { useState } from 'react';
import { useCoreState } from '../../providers/CoreStateProvider';
import { persistor } from '../../store';
import { BILLING_DASHBOARD_URL } from '../../utils/links';
import { openUrl } from '../../utils/openUrl';
import {
resetOpenHumanDataAndRestartCore,
restartApp,
@@ -114,7 +116,7 @@ const SettingsHome = () => {
{
id: 'billing',
title: 'Billing & Usage',
description: 'Subscription plan, pay-as-you-go credits, and payment methods',
description: 'Subscription plan, credits, and payment methods',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@@ -125,7 +127,9 @@ const SettingsHome = () => {
/>
</svg>
),
onClick: () => navigateToSettings('billing'),
onClick: () => {
void openUrl(BILLING_DASHBOARD_URL);
},
dangerous: false,
},
{
@@ -1,489 +1,110 @@
import createDebug from 'debug';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useEffect, useState } from 'react';
import PillTabBar from '../../../components/PillTabBar';
import { useCoreState } from '../../../providers/CoreStateProvider';
import { billingApi } from '../../../services/api/billingApi';
import {
type AutoRechargeSettings,
type CreditBalance,
creditsApi,
type CreditTransaction,
type SavedCard,
} from '../../../services/api/creditsApi';
import type { CurrentPlanData, PlanTier } from '../../../types/api';
import { BILLING_DASHBOARD_URL } from '../../../utils/links';
import { openUrl } from '../../../utils/openUrl';
import PageBackButton from '../components/PageBackButton';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import BillingHistoryTab from './billing/BillingHistoryTab';
import BillingPaymentsTab from './billing/BillingPaymentsTab';
import BillingPlansTab from './billing/BillingPlansTab';
import { buildPlanId } from './billingHelpers';
const log = createDebug('openhuman:billing-panel');
type BillingTab = 'overview' | 'plans' | 'payments' | 'history';
// ── Component ───────────────────────────────────────────────────────────
const BillingPanel = () => {
const { navigateBack, breadcrumbs } = useSettingsNavigation();
const { snapshot, teams, refresh } = useCoreState();
const user = snapshot.currentUser;
const sessionToken = snapshot.sessionToken;
const [status, setStatus] = useState<'opening' | 'idle' | 'error'>('opening');
// Active team context
const activeTeamId = user?.activeTeamId;
const activeTeam = teams.find(t => t.team._id === activeTeamId);
const teamName = activeTeam?.team.name;
// Credits & usage state
const [currentPlan, setCurrentPlan] = useState<CurrentPlanData | null>(null);
const [creditBalance, setCreditBalance] = useState<CreditBalance | null>(null);
const [transactions, setTransactions] = useState<CreditTransaction[]>([]);
const [isLoadingCredits, setIsLoadingCredits] = useState(false);
const [isToppingUp, setIsToppingUp] = useState(false);
const [selectedTab, setSelectedTab] = useState<BillingTab>('plans');
// Local state
const [billingInterval, setBillingInterval] = useState<'monthly' | 'annual'>('monthly');
const [paymentMethod, setPaymentMethod] = useState<'card' | 'crypto'>('card');
const [isPurchasing, setIsPurchasing] = useState(false);
const [purchasingTier, setPurchasingTier] = useState<PlanTier | null>(null);
const [paymentConfirmed, setPaymentConfirmed] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const pollStartRef = useRef<number>(0);
const timeoutRef = useRef<number | null>(null);
// ── Auto-recharge state ──────────────────────────────────────────────────
const [arSettings, setArSettings] = useState<AutoRechargeSettings | null>(null);
const [arLoading, setArLoading] = useState(true);
const [arError, setArError] = useState<string | null>(null);
const [arSaving, setArSaving] = useState(false);
const [arThreshold, setArThreshold] = useState(5);
const [arAmount, setArAmount] = useState(20);
const [arWeeklyLimit, setArWeeklyLimit] = useState(50);
const [arDirty, setArDirty] = useState(false);
// Recompute dirty flag whenever local settings or server settings change
useEffect(() => {
if (!arSettings) return;
setArDirty(
arThreshold !== arSettings.thresholdUsd ||
arAmount !== arSettings.rechargeAmountUsd ||
arWeeklyLimit !== arSettings.weeklyLimitUsd
);
}, [arThreshold, arAmount, arWeeklyLimit, arSettings]);
// ── Cards state ──────────────────────────────────────────────────────────
const [cards, setCards] = useState<SavedCard[]>([]);
const [cardsLoading, setCardsLoading] = useState(true);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [deletingCardId, setDeletingCardId] = useState<string | null>(null);
const [settingDefaultId, setSettingDefaultId] = useState<string | null>(null);
const currentTier: PlanTier = currentPlan?.plan ?? activeTeam?.team.subscription?.plan ?? 'FREE';
const hasActive =
currentPlan?.hasActiveSubscription ??
activeTeam?.team.subscription?.hasActiveSubscription ??
false;
// Fetch current plan, credits balance, and team usage once auth is available.
useEffect(() => {
if (!sessionToken) {
log('[load] skipped: no session token yet');
setCurrentPlan(null);
setCreditBalance(null);
setIsLoadingCredits(false);
return;
}
let cancelled = false;
setIsLoadingCredits(true);
log('[load] fetching billing state tokenPresent=%s activeTeamId=%s', true, activeTeamId);
Promise.allSettled([
billingApi.getCurrentPlan(),
creditsApi.getBalance(),
creditsApi.getTransactions(5, 0),
])
.then(([planResult, balanceResult, transactionsResult]) => {
if (planResult.status === 'fulfilled') {
const plan = planResult.value;
log(
'[load] plan=%s active=%s weeklyBudget=%s',
plan.plan,
plan.hasActiveSubscription,
plan.weeklyBudgetUsd
);
if (!cancelled) {
setCurrentPlan(plan);
}
} else {
log('[load] getCurrentPlan failed: %O', planResult.reason);
}
if (balanceResult.status === 'fulfilled') {
log(
'[load] balance promotion=%s teamTopup=%s',
balanceResult.value.promotionBalanceUsd,
balanceResult.value.teamTopupUsd
);
if (!cancelled) {
setCreditBalance(balanceResult.value);
}
} else {
log('[load] getBalance failed: %O', balanceResult.reason);
}
if (transactionsResult.status === 'fulfilled') {
if (!cancelled) {
setTransactions(transactionsResult.value.transactions);
}
} else {
log('[load] getTransactions failed: %O', transactionsResult.reason);
}
})
.finally(() => {
if (!cancelled) {
setIsLoadingCredits(false);
}
});
return () => {
cancelled = true;
};
}, [sessionToken, activeTeamId]);
// When crypto is selected, force annual
useEffect(() => {
if (paymentMethod === 'crypto') {
setBillingInterval('annual');
}
}, [paymentMethod]);
// Cleanup poll on unmount
useEffect(() => {
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, []);
// ── Fetch auto-recharge settings + cards on mount ────────────────────────
useEffect(() => {
let cancelled = false;
const load = async () => {
const openDashboard = async () => {
log('[redirect] opening billing dashboard url=%s', BILLING_DASHBOARD_URL);
try {
const [settings, cardsData] = await Promise.all([
creditsApi.getAutoRecharge(),
creditsApi.getCards(),
]);
if (cancelled) return;
setArSettings(settings);
setArThreshold(settings.thresholdUsd);
setArAmount(settings.rechargeAmountUsd);
setArWeeklyLimit(settings.weeklyLimitUsd);
setCards(cardsData.cards);
} catch {
if (!cancelled) setArError('Failed to load auto-recharge settings.');
} finally {
await openUrl(BILLING_DASHBOARD_URL);
if (!cancelled) {
setArLoading(false);
setCardsLoading(false);
setStatus('idle');
}
} catch (error) {
log('[redirect] failed to open billing dashboard: %O', error);
if (!cancelled) {
setStatus('error');
}
}
};
load().catch(console.error);
void openDashboard();
return () => {
cancelled = true;
};
}, []);
// ── Auto-recharge handlers ───────────────────────────────────────────────
const handleArToggle = async () => {
if (!arSettings || arSaving) return;
const nextEnabled = !arSettings.enabled;
if (nextEnabled && !arSettings.hasSavedPaymentMethod && cards.length === 0) {
setArError('Add a payment card on Stripe before enabling auto-recharge.');
return;
}
setArSaving(true);
setArError(null);
try {
const updated = await creditsApi.updateAutoRecharge({ enabled: nextEnabled });
setArSettings(updated);
} catch (err) {
const msg =
err && typeof err === 'object' && 'error' in err
? String((err as { error: unknown }).error)
: 'Failed to update auto-recharge.';
setArError(msg);
} finally {
setArSaving(false);
}
};
const handleArSave = async () => {
if (!arSettings || arSaving) return;
setArSaving(true);
setArError(null);
try {
const updated = await creditsApi.updateAutoRecharge({
thresholdUsd: arThreshold,
rechargeAmountUsd: arAmount,
weeklyLimitUsd: arWeeklyLimit,
});
setArSettings(updated);
setArDirty(false);
} catch (err) {
const msg =
err && typeof err === 'object' && 'error' in err
? String((err as { error: unknown }).error)
: 'Failed to save settings.';
setArError(msg);
} finally {
setArSaving(false);
}
};
// ── Card handlers ────────────────────────────────────────────────────────
const handleSetDefault = async (paymentMethodId: string) => {
if (settingDefaultId) return;
setSettingDefaultId(paymentMethodId);
try {
const updated = await creditsApi.updateCard(paymentMethodId, { isDefault: true });
setCards(updated.cards);
} catch {
setArError('Failed to update default card.');
} finally {
setSettingDefaultId(null);
}
};
const handleDeleteCard = async (paymentMethodId: string) => {
if (deletingCardId) return;
setDeletingCardId(paymentMethodId);
setConfirmDeleteId(null);
try {
const updated = await creditsApi.deleteCard(paymentMethodId);
setCards(updated.cards);
const refreshed = await creditsApi.getAutoRecharge();
setArSettings(refreshed);
} catch {
setArError('Failed to remove card.');
} finally {
setDeletingCardId(null);
}
};
const handleAddCard = async () => {
try {
const { portalUrl } = await billingApi.createPortalSession();
await openUrl(portalUrl);
} catch {
setArError('Could not open payment portal. Please try again.');
}
};
// Handle payment:success deep link event
useEffect(() => {
const onPaymentSuccess = async () => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
setIsPurchasing(false);
setPurchasingTier(null);
setPaymentConfirmed(true);
try {
const plan = await billingApi.getCurrentPlan();
log('[payment-success] plan=%s active=%s', plan.plan, plan.hasActiveSubscription);
setCurrentPlan(plan);
const balance = await creditsApi.getBalance();
log(
'[payment-success] refreshed balance promotion=%s teamTopup=%s',
balance.promotionBalanceUsd,
balance.teamTopupUsd
);
setCreditBalance(balance);
} catch (e) {
console.error('Failed to fetch current plan after payment', e);
}
await refresh();
timeoutRef.current = window.setTimeout(() => setPaymentConfirmed(false), 5_000);
};
window.addEventListener('payment:success', onPaymentSuccess);
return () => {
window.removeEventListener('payment:success', onPaymentSuccess);
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, [refresh]);
// ── Poll for plan change after checkout ─────────────────────────────
const currentTierRef = useRef(currentTier);
useEffect(() => {
currentTierRef.current = currentTier;
}, [currentTier]);
const startPolling = useCallback(() => {
if (pollRef.current) clearInterval(pollRef.current);
pollStartRef.current = Date.now();
pollRef.current = setInterval(async () => {
if (Date.now() - pollStartRef.current > 120_000) {
if (pollRef.current) clearInterval(pollRef.current);
setIsPurchasing(false);
setPurchasingTier(null);
return;
}
try {
const plan = await billingApi.getCurrentPlan();
log('[poll] plan=%s active=%s', plan.plan, plan.hasActiveSubscription);
setCurrentPlan(plan);
if (plan.hasActiveSubscription && plan.plan !== currentTierRef.current) {
await refresh();
setIsPurchasing(false);
setPurchasingTier(null);
if (pollRef.current) clearInterval(pollRef.current);
}
} catch {
// Ignore polling errors
}
}, 5_000);
}, [refresh]);
// ── Purchase handlers ───────────────────────────────────────────────
const handleUpgrade = async (tier: PlanTier) => {
if (tier === 'FREE' || tier === currentTier) return;
setIsPurchasing(true);
setPurchasingTier(tier);
try {
if (paymentMethod === 'crypto') {
const { hostedUrl } = await billingApi.createCoinbaseCharge(tier, 'annual');
log('[purchase] crypto tier=%s', tier);
await openUrl(hostedUrl);
} else {
const planId = buildPlanId(tier, billingInterval);
const { checkoutUrl } = await billingApi.purchasePlan(planId);
log('[purchase] stripe planId=%s', planId);
if (checkoutUrl) await openUrl(checkoutUrl);
}
startPolling();
} catch (err) {
console.error('Purchase failed:', err);
setIsPurchasing(false);
setPurchasingTier(null);
}
};
const handleManageSubscription = async () => {
try {
const { portalUrl } = await billingApi.createPortalSession();
await openUrl(portalUrl);
} catch (err) {
console.error('Portal session failed:', err);
}
};
const handleTopUp = async (amountUsd: number) => {
setIsToppingUp(true);
try {
log('[top-up] amountUsd=%s', amountUsd);
const result = await creditsApi.topUp(amountUsd, 'stripe');
await openUrl(result.url);
} catch (err) {
console.error('Top-up failed:', err);
} finally {
setIsToppingUp(false);
}
};
const transactionRows = transactions.slice(0, 4);
// ── JSX ─────────────────────────────────────────────────────────────
return (
<div className="overflow-y-auto">
<div className="mx-auto max-w-2xl space-y-5 px-4 py-6 sm:px-6 sm:py-8">
<header className="space-y-5">
<PageBackButton
label={
teamName ? `Back to ${breadcrumbs.at(-1)?.label ?? 'settings'}` : 'Back to settings'
}
onClick={navigateBack}
/>
<PillTabBar
items={[
{ label: 'Plans', value: 'plans' },
{ label: 'Top ups & Credits', value: 'payments' },
{ label: 'History', value: 'history' },
]}
selected={selectedTab}
onChange={setSelectedTab}
activeClassName="border-primary-600 bg-primary-600 text-white"
inactiveClassName="border-stone-200 bg-white text-stone-600 hover:bg-stone-50"
containerClassName="flex gap-2 overflow-x-auto pb-1 scrollbar-hide"
/>
</header>
<div className="px-4 py-5 sm:px-6 lg:px-8">
<div className="mx-auto max-w-3xl">
<PageBackButton
label="Back"
onClick={navigateBack}
trailingContent={
breadcrumbs.length > 0 ? (
<div className="flex flex-wrap items-center gap-2 text-xs text-stone-500">
{breadcrumbs.map((crumb, index) => (
<button
key={`${crumb.label}-${index}`}
type="button"
onClick={crumb.onClick}
className="rounded-full border border-stone-200 bg-white px-3 py-1 font-medium text-stone-600 transition-colors hover:bg-stone-50">
{crumb.label}
</button>
))}
</div>
) : null
}
/>
{selectedTab === 'plans' && (
<BillingPlansTab
currentTier={currentTier}
billingInterval={billingInterval}
setBillingInterval={setBillingInterval}
paymentMethod={paymentMethod}
setPaymentMethod={setPaymentMethod}
isPurchasing={isPurchasing}
purchasingTier={purchasingTier}
paymentConfirmed={paymentConfirmed}
onUpgrade={handleUpgrade}
/>
)}
<div className="mt-6 rounded-3xl border border-stone-200 bg-white p-6 shadow-soft">
<div className="max-w-xl space-y-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-stone-500">
Billing moved to the web
</p>
<h1 className="mt-2 text-2xl font-semibold text-stone-900">Open billing dashboard</h1>
<p className="mt-2 text-sm leading-6 text-stone-600">
Subscription changes, payment methods, credits, and invoices are now managed at
TinyHumans on the web.
</p>
</div>
{selectedTab === 'payments' && (
<BillingPaymentsTab
arAmount={arAmount}
arDirty={arDirty}
arError={arError}
arLoading={arLoading}
arSaving={arSaving}
arSettings={arSettings}
arThreshold={arThreshold}
arWeeklyLimit={arWeeklyLimit}
cards={cards}
cardsLoading={cardsLoading}
confirmDeleteId={confirmDeleteId}
creditBalance={creditBalance}
deletingCardId={deletingCardId}
isLoadingCredits={isLoadingCredits}
isToppingUp={isToppingUp}
onAddCard={handleAddCard}
onArSave={handleArSave}
onArToggle={handleArToggle}
onDeleteCard={handleDeleteCard}
onSetDefault={handleSetDefault}
onTopUp={handleTopUp}
setArAmount={setArAmount}
setArThreshold={setArThreshold}
setArWeeklyLimit={setArWeeklyLimit}
setConfirmDeleteId={setConfirmDeleteId}
settingDefaultId={settingDefaultId}
/>
)}
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={() => {
void openUrl(BILLING_DASHBOARD_URL);
}}
className="inline-flex items-center rounded-full bg-primary-500 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-primary-600">
Open dashboard
</button>
<button
type="button"
onClick={navigateBack}
className="inline-flex items-center rounded-full border border-stone-200 bg-white px-4 py-2 text-sm font-semibold text-stone-700 transition-colors hover:bg-stone-50">
Back to settings
</button>
</div>
{selectedTab === 'history' && (
<BillingHistoryTab
hasActive={hasActive}
onManageSubscription={handleManageSubscription}
transactionRows={transactionRows}
/>
)}
{status === 'opening' && (
<p className="text-xs text-stone-500">Opening your browser</p>
)}
{status === 'idle' && (
<p className="text-xs text-stone-500">
If your browser did not open, use the button above.
</p>
)}
{status === 'error' && (
<p className="text-xs text-coral-600">
The browser could not be opened automatically. Use the button above.
</p>
)}
</div>
</div>
</div>
</div>
);
@@ -1,10 +1,9 @@
import { useNavigate } from 'react-router-dom';
import { useUsageState } from '../../hooks/useUsageState';
import { BILLING_DASHBOARD_URL } from '../../utils/links';
import { openUrl } from '../../utils/openUrl';
import UpsellBanner from './UpsellBanner';
export default function GlobalUpsellBanner() {
const navigate = useNavigate();
const { teamUsage, isLoading, isAtLimit, isNearLimit, isFreeTier, usagePct10h, usagePct7d } =
useUsageState();
@@ -19,7 +18,9 @@ export default function GlobalUpsellBanner() {
message="Upgrade your plan or top up credits to continue"
ctaLabel="Upgrade"
rounded={false}
onCtaClick={() => navigate('/settings/billing')}
onCtaClick={() => {
void openUrl(BILLING_DASHBOARD_URL);
}}
/>
</div>
);
@@ -35,7 +36,9 @@ export default function GlobalUpsellBanner() {
message={`You've used ${pct}% of your usage limit. Upgrade for higher limits.`}
ctaLabel="Upgrade"
rounded={false}
onCtaClick={() => navigate('/settings/billing')}
onCtaClick={() => {
void openUrl(BILLING_DASHBOARD_URL);
}}
/>
</div>
);
@@ -1,6 +1,6 @@
import { useNavigate } from 'react-router-dom';
import type { PlanTier } from '../../types/api';
import { BILLING_DASHBOARD_URL } from '../../utils/links';
import { openUrl } from '../../utils/openUrl';
import { PLANS } from '../settings/panels/billingHelpers';
interface UsageLimitModalProps {
@@ -37,7 +37,6 @@ export default function UsageLimitModal({
resetTime,
currentTier,
}: UsageLimitModalProps) {
const navigate = useNavigate();
const nextPlan = getNextPlan(currentTier);
if (!open) return null;
@@ -91,7 +90,7 @@ export default function UsageLimitModal({
<button
onClick={() => {
onClose();
navigate('/settings/billing');
void openUrl(BILLING_DASHBOARD_URL);
}}
className="w-full py-2.5 rounded-xl bg-primary-600 hover:bg-primary-500 text-white text-sm font-medium transition-colors">
Upgrade Plan
+8 -2
View File
@@ -33,6 +33,8 @@ import {
import type { ConfirmationModal as ConfirmationModalType } from '../types/intelligence';
import type { ThreadMessage } from '../types/thread';
import { splitAgentMessageIntoBubbles } from '../utils/agentMessageBubbles';
import { BILLING_DASHBOARD_URL } from '../utils/links';
import { openUrl } from '../utils/openUrl';
import {
isTauri,
notifyOverlaySttState,
@@ -1290,7 +1292,9 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
title="Approaching usage limit"
message={`You've used ${Math.round(Math.max(usagePct10h, usagePct7d) * 100)}% of your inference budget. Upgrade for higher limits.`}
ctaLabel="Upgrade"
onCtaClick={() => navigate('/settings/billing')}
onCtaClick={() => {
void openUrl(BILLING_DASHBOARD_URL);
}}
dismissible
onDismiss={() => dismissBanner('conversations-warning')}
/>
@@ -1321,7 +1325,9 @@ const Conversations = ({ variant = 'page' }: ConversationsProps = {}) => {
</div>
{shouldShowBudgetCompletedMessage && (
<button
onClick={() => navigate('/settings/billing')}
onClick={() => {
void openUrl(BILLING_DASHBOARD_URL);
}}
className="flex-shrink-0 px-3 py-1.5 rounded-lg bg-coral-500 hover:bg-coral-400 text-white text-xs font-medium transition-colors">
Top Up
</button>
+7 -4
View File
@@ -10,6 +10,7 @@ import {
completeDeepLinkAuthProcessing,
failDeepLinkAuthProcessing,
} from '../store/deepLinkAuthState';
import { BILLING_DASHBOARD_URL } from './links';
import { evaluateOAuthAppVersionGate } from './oauthAppVersionGate';
import { openUrl } from './openUrl';
import { storeSession } from './tauriCommands';
@@ -98,15 +99,17 @@ const handlePaymentDeepLink = async (parsed: URL) => {
console.log('[DeepLink] Payment success, session_id:', sessionId);
// Broadcast to the app so billing components can react
// Broadcast to the app in case any listeners still care about legacy
// payment completion events.
window.dispatchEvent(new CustomEvent('payment:success', { detail: { sessionId } }));
// Navigate to billing settings to show confirmation
window.location.hash = '/settings/billing';
await openUrl(BILLING_DASHBOARD_URL);
window.location.hash = '/home';
} else if (path === 'cancel') {
console.log('[DeepLink] Payment cancelled');
window.dispatchEvent(new CustomEvent('payment:cancel', {}));
window.location.hash = '/settings/billing';
await openUrl(BILLING_DASHBOARD_URL);
window.location.hash = '/home';
} else {
console.warn('[DeepLink] Unknown payment path:', path);
}
+1
View File
@@ -1 +1,2 @@
export const DISCORD_INVITE_URL = 'https://discord.tinyhumans.ai';
export const BILLING_DASHBOARD_URL = 'https://tinyhumans.ai/dashboard';