mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
feat(desktop): billing toast + in-chat status-row banner with smart CTA
On a billing wall, raise a sticky, billing-specific toast (never the generic error toast) and a persistent in-composer banner for the active session — both with one recovery action: Nous → in-app Settings → Billing, other providers → their billing page (deep-linked). The banner reuses the shared StatusRow chrome (no bordered alert, Codicon glyph, shared buttons), and the composer stays usable so slash commands keep working.
This commit is contained in:
parent
960d339f86
commit
d0c4a82da9
12 changed files with 370 additions and 0 deletions
|
|
@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom'
|
|||
|
||||
import { blurComposerInput } from '@/app/chat/composer/focus'
|
||||
import { AGENTS_ROUTE } from '@/app/routes'
|
||||
import { BillingBanner } from '@/components/billing-banner'
|
||||
import { composerDockCard } from '@/components/chat/composer-dock'
|
||||
import { StatusSection } from '@/components/chat/status-section'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -11,6 +12,7 @@ import { Codicon } from '@/components/ui/codicon'
|
|||
import { Tip, TipKeybindLabel } from '@/components/ui/tooltip'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $billingBlock } from '@/store/billing-block'
|
||||
import {
|
||||
$statusItemsBySession,
|
||||
type ComposerStatusItem,
|
||||
|
|
@ -70,6 +72,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
|||
const itemsBySession = useStore($statusItemsBySession)
|
||||
const previewsBySession = useStore($previewStatusBySession)
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
const billing = useStore($billingBlock)
|
||||
|
||||
const groups = useMemo(
|
||||
() => groupStatusItems(sessionId ? (itemsBySession[sessionId] ?? []) : []),
|
||||
|
|
@ -123,6 +126,13 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
|||
|
||||
const sections: { key: string; node: ReactNode }[] = []
|
||||
|
||||
// Billing wall sits at the very top of the stack — it's the most important
|
||||
// thing above the composer when the account is out of credits. Rendered here
|
||||
// (not as a composer-disable) so slash commands stay usable.
|
||||
if (billing && sessionId && billing.sessionId === sessionId) {
|
||||
sections.push({ key: 'billing', node: <BillingBanner sessionId={sessionId} /> })
|
||||
}
|
||||
|
||||
for (const group of groups) {
|
||||
sections.push({
|
||||
key: group.type,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChat
|
|||
import { sessionMessagesSignature } from '@/lib/session-signatures'
|
||||
import { isMessagingSource } from '@/lib/session-source'
|
||||
import { latestSessionTodos } from '@/lib/todos'
|
||||
import { $billingSettingsRequest } from '@/store/billing-block'
|
||||
import { setCronFocusJobId } from '@/store/cron'
|
||||
import { $pinnedSessionIds, pinSession, restoreWorktree, unpinSession } from '@/store/layout'
|
||||
import { $filePreviewTarget, $previewTarget } from '@/store/preview'
|
||||
|
|
@ -126,6 +127,10 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
|||
|
||||
const busyRef = useRef(false)
|
||||
const creatingSessionRef = useRef(false)
|
||||
// Billing recovery routes to Settings → Billing from surfaces without router
|
||||
// context (the sticky toast). The shell owns `navigate`, so it consumes the
|
||||
// intent counter here; the ref skips the initial mount value.
|
||||
const billingSettingsSeenRef = useRef(0)
|
||||
const messagingTranscriptSignatureRef = useRef(new Map<string, string>())
|
||||
// Stable identity for the whole callback surface (see WiringActions). Mutated
|
||||
// in place each render so memoized surfaces never re-render on churn.
|
||||
|
|
@ -133,7 +138,20 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
|||
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const billingSettingsRequest = useStore($billingSettingsRequest)
|
||||
const currentCwd = useStore($currentCwd)
|
||||
|
||||
useEffect(() => {
|
||||
if (billingSettingsRequest === billingSettingsSeenRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
billingSettingsSeenRef.current = billingSettingsRequest
|
||||
|
||||
if (billingSettingsRequest > 0) {
|
||||
navigate(`${SETTINGS_ROUTE}?tab=billing`)
|
||||
}
|
||||
}, [billingSettingsRequest, navigate])
|
||||
const freshDraftReady = useStore($freshDraftReady)
|
||||
const resumeFailedSessionId = useStore($resumeFailedSessionId)
|
||||
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { BillingBlock } from '@hermes/shared'
|
||||
import type { HermesSkin } from '@hermes/shared/skin'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
||||
|
|
@ -15,6 +16,7 @@ import { triggerHaptic } from '@/lib/haptics'
|
|||
import { modelOptionsQueryKey } from '@/lib/model-options'
|
||||
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
||||
import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
|
||||
import { billingCtaLabel, clearBillingBlock, runBillingRecovery, setBillingBlock } from '@/store/billing-block'
|
||||
import { clearClarifyRequest, setClarifyRequest } from '@/store/clarify'
|
||||
import { setSessionCompacting } from '@/store/compaction'
|
||||
import { refreshBackgroundProcesses } from '@/store/composer-status'
|
||||
|
|
@ -57,6 +59,50 @@ import type { ClientSessionState } from '../../../types'
|
|||
|
||||
import { hasSessionInfoStatePatch, sessionInfoStatePatch, SUBAGENT_EVENT_TYPES, toTodoPayload } from './utils'
|
||||
|
||||
function firstBillingLine(text: string): string {
|
||||
return (text || '').split('\n')[0]?.trim() ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* A turn failed on a billing wall (out of credits / payment required). The
|
||||
* gateway forwards the structured descriptor built by `agent/billing_links.py`;
|
||||
* we cache it per-session (drives the in-chat banner) AND raise one sticky,
|
||||
* billing-specific toast — never the generic "Hermes error" — with a smart CTA
|
||||
* (Nous → in-app Settings → Billing, other providers → their billing page).
|
||||
*/
|
||||
function surfaceBillingBlock(sessionId: string, raw: unknown): void {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
const block = raw as BillingBlock
|
||||
|
||||
if (typeof block.provider !== 'string') {
|
||||
return
|
||||
}
|
||||
|
||||
setBillingBlock(sessionId, block)
|
||||
|
||||
const ctaCopy = {
|
||||
addCredits: translateNow('billingBlock.addCredits'),
|
||||
openBilling: translateNow('billingBlock.openBilling')
|
||||
}
|
||||
|
||||
notify({
|
||||
// Collapse repeat walls from the same provider into one toast.
|
||||
id: `billing-block:${block.provider}`,
|
||||
kind: 'warning',
|
||||
icon: 'credit-card',
|
||||
title: block.is_nous
|
||||
? translateNow('billingBlock.titleNous')
|
||||
: translateNow('billingBlock.titleProvider', block.provider_label),
|
||||
message: firstBillingLine(block.message) || translateNow('billingBlock.fallbackMessage'),
|
||||
// Sticky: a credit wall blocks every turn until resolved.
|
||||
durationMs: 0,
|
||||
action: { label: billingCtaLabel(block, ctaCopy), onClick: () => runBillingRecovery(block) }
|
||||
})
|
||||
}
|
||||
|
||||
const COMPACTION_RESUME_EVENT_TYPES = new Set([
|
||||
'message.delta',
|
||||
'message.interim',
|
||||
|
|
@ -390,6 +436,9 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
|||
setSessionCompacting(sessionId, false)
|
||||
compactedTurnRef.current.delete(sessionId)
|
||||
nativeSubagentSessionsRef.current.delete(sessionId)
|
||||
// A fresh turn on this session optimistically clears its billing wall;
|
||||
// if credits are still exhausted the next failure re-raises it.
|
||||
clearBillingBlock(sessionId)
|
||||
|
||||
if (isActiveEvent) {
|
||||
triggerHaptic('streamStart')
|
||||
|
|
@ -513,6 +562,12 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
|||
const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered)
|
||||
completeAssistantMessage(sessionId, finalText, payload?.response_previewed)
|
||||
|
||||
// Structured billing wall forwarded by the gateway (out of credits /
|
||||
// payment required) — cache it + raise a billing-specific toast.
|
||||
if (payload?.billing) {
|
||||
surfaceBillingBlock(sessionId, payload.billing)
|
||||
}
|
||||
|
||||
if (isActiveEvent) {
|
||||
setTurnStartedAt(null)
|
||||
|
||||
|
|
|
|||
72
apps/desktop/src/components/billing-banner.tsx
Normal file
72
apps/desktop/src/components/billing-banner.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { StatusRow } from '@/components/chat/status-row'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { $billingBlock, billingCtaLabel, clearBillingBlock, runBillingRecovery } from '@/store/billing-block'
|
||||
|
||||
function firstLine(text: string): string {
|
||||
return (text || '').split('\n')[0]?.trim() ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistent, in-stack billing wall for THIS session. Rendered as a shared
|
||||
* {@link StatusRow} — same chrome as its status-stack siblings, so it reads as
|
||||
* one piece with the composer card (no bordered alert-in-a-card). It never
|
||||
* disables the composer — slash commands (`/topup`, `/model`, `/login`) stay
|
||||
* usable — it only offers recovery: Nous opens Settings → Billing in-app, other
|
||||
* providers deep-link out. The sticky toast is the loud surface; this is the calm
|
||||
* reminder that outlives it.
|
||||
*/
|
||||
export function BillingBanner({ sessionId }: { sessionId: null | string }) {
|
||||
const active = useStore($billingBlock)
|
||||
const { t } = useI18n()
|
||||
|
||||
if (!active || !sessionId || active.sessionId !== sessionId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { block } = active
|
||||
const copy = t.billingBlock
|
||||
const title = block.is_nous ? copy.titleNous : copy.titleProvider(block.provider_label)
|
||||
const message = firstLine(block.message) || copy.fallbackMessage
|
||||
|
||||
return (
|
||||
<StatusRow
|
||||
leading={<Codicon aria-hidden className="text-destructive/85" name="credit-card" size="0.8rem" />}
|
||||
trailing={
|
||||
<>
|
||||
<Button
|
||||
className="text-foreground/90 hover:text-foreground"
|
||||
onClick={() => runBillingRecovery(block)}
|
||||
size="micro"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
{billingCtaLabel(block, copy)}
|
||||
</Button>
|
||||
<Tip label={copy.dismiss}>
|
||||
<Button
|
||||
aria-label={copy.dismiss}
|
||||
className="size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
|
||||
onClick={() => clearBillingBlock(sessionId)}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="close" size="0.75rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
</>
|
||||
}
|
||||
trailingVisible
|
||||
>
|
||||
<span className="min-w-0 truncate text-[0.73rem] leading-4 text-foreground/92">
|
||||
<span className="font-medium">{title}</span>
|
||||
{message && <span className="text-muted-foreground/80"> · {message}</span>}
|
||||
</span>
|
||||
</StatusRow>
|
||||
)
|
||||
}
|
||||
|
|
@ -177,6 +177,15 @@ export const en: Translations = {
|
|||
`Software rendering active — remote display detected (${reason}). GPU acceleration is disabled to prevent flickering.`
|
||||
},
|
||||
|
||||
billingBlock: {
|
||||
titleNous: 'Out of Nous credits',
|
||||
titleProvider: provider => `Out of credits — ${provider}`,
|
||||
fallbackMessage: 'Your account is out of credits. Add credits to keep going.',
|
||||
openBilling: 'Open billing',
|
||||
addCredits: 'Add credits',
|
||||
dismiss: 'Dismiss'
|
||||
},
|
||||
|
||||
titlebar: {
|
||||
hideSidebar: 'Hide sidebar',
|
||||
showSidebar: 'Show sidebar',
|
||||
|
|
|
|||
|
|
@ -178,6 +178,15 @@ export const ja = defineLocale({
|
|||
`ソフトウェアレンダリングが有効です — リモートディスプレイを検出しました(${reason})。ちらつきを防ぐため GPU アクセラレーションは無効化されています。`
|
||||
},
|
||||
|
||||
billingBlock: {
|
||||
titleNous: 'Nous クレジットが不足しています',
|
||||
titleProvider: provider => `クレジット不足 — ${provider}`,
|
||||
fallbackMessage: 'アカウントのクレジットが不足しています。続行するにはクレジットを追加してください。',
|
||||
openBilling: '請求を開く',
|
||||
addCredits: 'クレジットを追加',
|
||||
dismiss: '閉じる'
|
||||
},
|
||||
|
||||
titlebar: {
|
||||
hideSidebar: 'サイドバーを非表示',
|
||||
showSidebar: 'サイドバーを表示',
|
||||
|
|
|
|||
|
|
@ -218,6 +218,15 @@ export interface Translations {
|
|||
message: (reason: string) => string
|
||||
}
|
||||
|
||||
billingBlock: {
|
||||
titleNous: string
|
||||
titleProvider: (provider: string) => string
|
||||
fallbackMessage: string
|
||||
openBilling: string
|
||||
addCredits: string
|
||||
dismiss: string
|
||||
}
|
||||
|
||||
titlebar: {
|
||||
hideSidebar: string
|
||||
showSidebar: string
|
||||
|
|
|
|||
|
|
@ -172,6 +172,15 @@ export const zhHant = defineLocale({
|
|||
message: reason => `軟體繪圖已啟用 — 偵測到遠端顯示(${reason})。為防止畫面閃爍,已停用 GPU 加速。`
|
||||
},
|
||||
|
||||
billingBlock: {
|
||||
titleNous: 'Nous 額度已用盡',
|
||||
titleProvider: provider => `額度已用盡 — ${provider}`,
|
||||
fallbackMessage: '您的帳戶額度已用盡。請儲值以繼續使用。',
|
||||
openBilling: '開啟帳單',
|
||||
addCredits: '新增額度',
|
||||
dismiss: '忽略'
|
||||
},
|
||||
|
||||
titlebar: {
|
||||
hideSidebar: '隱藏側邊欄',
|
||||
showSidebar: '顯示側邊欄',
|
||||
|
|
|
|||
|
|
@ -172,6 +172,15 @@ export const zh: Translations = {
|
|||
message: reason => `软件渲染已启用 — 检测到远程显示(${reason})。为防止画面闪烁,已禁用 GPU 加速。`
|
||||
},
|
||||
|
||||
billingBlock: {
|
||||
titleNous: 'Nous 额度已用尽',
|
||||
titleProvider: provider => `额度已用尽 — ${provider}`,
|
||||
fallbackMessage: '您的账户额度已用尽。请充值以继续使用。',
|
||||
openBilling: '打开账单',
|
||||
addCredits: '添加额度',
|
||||
dismiss: '忽略'
|
||||
},
|
||||
|
||||
titlebar: {
|
||||
hideSidebar: '隐藏侧边栏',
|
||||
showSidebar: '显示侧边栏',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { ThreadMessageLike } from '@assistant-ui/react'
|
||||
import type { BillingBlock } from '@hermes/shared'
|
||||
|
||||
import { dedupeGeneratedImageEchoesInParts } from '@/lib/generated-images'
|
||||
import { mediaDisplayLabel, mediaMarkdownHref } from '@/lib/media'
|
||||
|
|
@ -96,6 +97,10 @@ export type GatewayEventPayload = {
|
|||
// message.complete — signals the final text was already previewed via
|
||||
// interim_assistant_callback, so the UI can settle instead of duplicating.
|
||||
response_previewed?: boolean
|
||||
// Structured billing wall forwarded on message.complete when a turn fails
|
||||
// with FailoverReason.billing (shape mirrors @hermes/shared BillingBlock).
|
||||
billing?: BillingBlock
|
||||
failure_reason?: string
|
||||
}
|
||||
|
||||
export function textPart(text: string): ChatMessagePart {
|
||||
|
|
|
|||
86
apps/desktop/src/store/billing-block.test.ts
Normal file
86
apps/desktop/src/store/billing-block.test.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import type { BillingBlock } from '@hermes/shared'
|
||||
import { beforeEach, expect, test, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/external-link', () => ({ openExternalLink: vi.fn() }))
|
||||
|
||||
import { openExternalLink } from '@/lib/external-link'
|
||||
|
||||
import {
|
||||
$billingBlock,
|
||||
$billingSettingsRequest,
|
||||
billingCtaLabel,
|
||||
clearBillingBlock,
|
||||
requestBillingSettings,
|
||||
runBillingRecovery,
|
||||
setBillingBlock
|
||||
} from './billing-block'
|
||||
|
||||
function makeBlock(overrides: Partial<BillingBlock> = {}): BillingBlock {
|
||||
return {
|
||||
billing_url: 'https://platform.openai.com/settings/organization/billing',
|
||||
is_nous: false,
|
||||
message: 'You are out of credits.',
|
||||
model: 'gpt-5',
|
||||
provider: 'openai',
|
||||
provider_label: 'OpenAI',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
$billingBlock.set(null)
|
||||
$billingSettingsRequest.set(0)
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
test('setBillingBlock stores the block against its session', () => {
|
||||
setBillingBlock('s1', makeBlock())
|
||||
expect($billingBlock.get()?.sessionId).toBe('s1')
|
||||
expect($billingBlock.get()?.block.provider).toBe('openai')
|
||||
})
|
||||
|
||||
test('clearBillingBlock scoped to a session leaves a different session block intact', () => {
|
||||
setBillingBlock('s1', makeBlock())
|
||||
clearBillingBlock('s2')
|
||||
expect($billingBlock.get()).not.toBeNull()
|
||||
|
||||
clearBillingBlock('s1')
|
||||
expect($billingBlock.get()).toBeNull()
|
||||
})
|
||||
|
||||
test('clearBillingBlock with no arg clears any active block', () => {
|
||||
setBillingBlock('s1', makeBlock())
|
||||
clearBillingBlock()
|
||||
expect($billingBlock.get()).toBeNull()
|
||||
})
|
||||
|
||||
test('runBillingRecovery routes Nous to in-app Settings, never an external link', () => {
|
||||
runBillingRecovery(makeBlock({ is_nous: true, provider: 'nous', provider_label: 'Nous Portal' }))
|
||||
expect($billingSettingsRequest.get()).toBe(1)
|
||||
expect(openExternalLink).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('runBillingRecovery deep-links a third-party provider to its billing page', () => {
|
||||
const block = makeBlock({ billing_url: 'https://openrouter.ai/settings/credits', provider: 'openrouter' })
|
||||
runBillingRecovery(block)
|
||||
expect(openExternalLink).toHaveBeenCalledWith('https://openrouter.ai/settings/credits')
|
||||
expect($billingSettingsRequest.get()).toBe(0)
|
||||
})
|
||||
|
||||
test('runBillingRecovery falls back to in-app settings when a provider has no URL', () => {
|
||||
runBillingRecovery(makeBlock({ billing_url: null, provider: 'custom' }))
|
||||
expect(openExternalLink).not.toHaveBeenCalled()
|
||||
expect($billingSettingsRequest.get()).toBe(1)
|
||||
})
|
||||
|
||||
test('requestBillingSettings increments the intent counter', () => {
|
||||
requestBillingSettings()
|
||||
requestBillingSettings()
|
||||
expect($billingSettingsRequest.get()).toBe(2)
|
||||
})
|
||||
|
||||
test('billingCtaLabel picks the right verb per route', () => {
|
||||
const copy = { addCredits: 'Add credits', openBilling: 'Open billing' }
|
||||
expect(billingCtaLabel(makeBlock({ is_nous: true }), copy)).toBe('Open billing')
|
||||
expect(billingCtaLabel(makeBlock({ is_nous: false }), copy)).toBe('Add credits')
|
||||
})
|
||||
79
apps/desktop/src/store/billing-block.ts
Normal file
79
apps/desktop/src/store/billing-block.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import type { BillingBlock } from '@hermes/shared'
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import { openExternalLink } from '@/lib/external-link'
|
||||
|
||||
/**
|
||||
* The active inference billing wall, if any. Set from the gateway
|
||||
* `message.complete` / `error` event when a turn fails with
|
||||
* `FailoverReason.billing` (see `agent/billing_links.py`). One global slot: a
|
||||
* credit wall on the active session's provider is the whole app's problem, and
|
||||
* the newest block wins. Cleared when a new turn starts or the user dismisses.
|
||||
*/
|
||||
export interface ActiveBillingBlock {
|
||||
block: BillingBlock
|
||||
sessionId: string
|
||||
at: number
|
||||
}
|
||||
|
||||
export const $billingBlock = atom<ActiveBillingBlock | null>(null)
|
||||
|
||||
/**
|
||||
* Navigation intent counter. A toast fired outside React (or any surface
|
||||
* without router context) bumps this to ask the shell — which owns
|
||||
* `useNavigate` — to open Settings → Billing in-app. See `contrib/wiring.tsx`.
|
||||
*/
|
||||
export const $billingSettingsRequest = atom(0)
|
||||
|
||||
export function setBillingBlock(sessionId: string, block: BillingBlock): void {
|
||||
$billingBlock.set({ at: Date.now(), block, sessionId })
|
||||
}
|
||||
|
||||
export function clearBillingBlock(sessionId?: string): void {
|
||||
const current = $billingBlock.get()
|
||||
|
||||
if (!current) {
|
||||
return
|
||||
}
|
||||
|
||||
// A scoped clear (new turn on session X) must not wipe a block raised by a
|
||||
// different session's provider.
|
||||
if (sessionId && current.sessionId !== sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
$billingBlock.set(null)
|
||||
}
|
||||
|
||||
export function requestBillingSettings(): void {
|
||||
$billingSettingsRequest.set($billingSettingsRequest.get() + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* The single recovery action for a billing wall, shared by the toast and the
|
||||
* in-chat banner so both behave identically: Nous routes to the in-app
|
||||
* Settings → Billing surface; a third-party provider deep-links to its own
|
||||
* billing page (falling back to the in-app surface only if we have no URL).
|
||||
*/
|
||||
export function runBillingRecovery(block: BillingBlock): void {
|
||||
if (block.is_nous) {
|
||||
requestBillingSettings()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (block.billing_url) {
|
||||
openExternalLink(block.billing_url)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
requestBillingSettings()
|
||||
}
|
||||
|
||||
export function billingCtaLabel(
|
||||
block: BillingBlock,
|
||||
copy: { addCredits: string; openBilling: string }
|
||||
): string {
|
||||
return block.is_nous ? copy.openBilling : copy.addCredits
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue