mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 13:32:23 +00:00
Move billing flows to web dashboard (#956)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,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)}`;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 +1,2 @@
|
||||
export const DISCORD_INVITE_URL = 'https://discord.tinyhumans.ai';
|
||||
export const BILLING_DASHBOARD_URL = 'https://tinyhumans.ai/dashboard';
|
||||
|
||||
Reference in New Issue
Block a user