@flamingo-stack/openframe-frontend-core 0.0.723 → 0.0.724
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-WSMOTZRJ.cjs → chunk-2A4AGW7Z.cjs} +11 -11
- package/dist/{chunk-F6JAGSKV.js → chunk-2HLGX6JF.js} +5 -5
- package/dist/{chunk-Y5H3M7OM.cjs → chunk-3MMKIVWZ.cjs} +23 -23
- package/dist/{chunk-OWRD2GZU.cjs → chunk-3NVSC3BU.cjs} +687 -611
- package/dist/{chunk-N4VCU6XP.js → chunk-4N5DLIEC.js} +1 -1
- package/dist/{chunk-OHC2MPUO.cjs → chunk-5CJGNPTT.cjs} +35 -35
- package/dist/{chunk-OVG6MV3X.js → chunk-6BKDXUOQ.js} +6 -6
- package/dist/{chunk-VIFVSFYY.js → chunk-6QO6BIX7.js} +1 -1
- package/dist/{chunk-FDWYBJYD.cjs → chunk-7AXOAA5T.cjs} +3 -3
- package/dist/{chunk-YUSA7IPD.js → chunk-7CNGNPBI.js} +3 -3
- package/dist/{chunk-VGXAZEQP.cjs → chunk-A4WHVIYZ.cjs} +17 -17
- package/dist/{chunk-KSZRALRL.cjs → chunk-BIWG3CYN.cjs} +3 -3
- package/dist/{chunk-WVA7SCLY.js → chunk-BWMEQ7IZ.js} +1 -1
- package/dist/{chunk-PKS2QQJU.js → chunk-CJENFR5T.js} +2 -1
- package/dist/{chunk-JENNI7XU.cjs → chunk-DGNGABNG.cjs} +5 -4
- package/dist/{chunk-KY4WQYYG.cjs → chunk-FR4MFSH5.cjs} +19 -19
- package/dist/{chunk-MWLVYYRX.cjs → chunk-GRB5HVEV.cjs} +2580 -2120
- package/dist/{chunk-3RIKO2O2.cjs → chunk-IPUDFAU5.cjs} +31 -31
- package/dist/{chunk-A7JPMNHF.js → chunk-IUQKIU5A.js} +6 -6
- package/dist/{chunk-LV6UJD2K.cjs → chunk-JKWQWHNV.cjs} +6 -1
- package/dist/{chunk-NESUUHPV.cjs → chunk-JQ7A7PO5.cjs} +76 -76
- package/dist/{chunk-XTG7W75B.js → chunk-KKO4GRM3.js} +4 -4
- package/dist/{chunk-YQOJOW2Z.cjs → chunk-L5CAET76.cjs} +64 -64
- package/dist/{chunk-WY4SLTMZ.js → chunk-MRBPJ4NB.js} +1 -1
- package/dist/{chunk-JL7AHSFE.js → chunk-MU5F5QXJ.js} +6 -6
- package/dist/{chunk-2VOPQJNR.js → chunk-N2PX26BZ.js} +2 -2
- package/dist/{chunk-NFABAOMD.cjs → chunk-N4NGKXUU.cjs} +15 -15
- package/dist/{chunk-SBCJFJ6Q.js → chunk-NLZZUWR6.js} +4 -4
- package/dist/{chunk-7JXCLUU7.js → chunk-OFOBVPDV.js} +2 -2
- package/dist/{chunk-GRDJR2Q7.js → chunk-OWFDXYEO.js} +1 -1
- package/dist/{chunk-QBXEF7D5.js → chunk-PF4WAKCS.js} +4 -4
- package/dist/{chunk-KSADTFNZ.cjs → chunk-QJ67GFF3.cjs} +32 -32
- package/dist/{chunk-G6YNPBWV.js → chunk-QN3OXRTM.js} +3045 -2585
- package/dist/{chunk-P7MDECYI.cjs → chunk-QUI7DKBW.cjs} +41 -41
- package/dist/{chunk-IIBTYCEN.js → chunk-S4SVKXHN.js} +88 -12
- package/dist/{chunk-2MC4DRSC.cjs → chunk-SOTXV3T5.cjs} +3 -3
- package/dist/{chunk-TDSLKKX7.cjs → chunk-SVDXNMBL.cjs} +16 -16
- package/dist/{chunk-UYMTAFHG.js → chunk-VLPGV5WH.js} +1 -1
- package/dist/{chunk-YGIUWQEN.js → chunk-XDTD6YGG.js} +7 -7
- package/dist/{chunk-GYUFKNOQ.cjs → chunk-YKV45BSJ.cjs} +4 -4
- package/dist/{chunk-6QTGLBJH.cjs → chunk-ZKL52ALL.cjs} +3 -3
- package/dist/{chunk-EWED6ISQ.js → chunk-ZQKN2Z6W.js} +5 -0
- package/dist/{cloud-tenants-N6KV2T7X.js → cloud-tenants-5MINXS5J.js} +6 -6
- package/dist/{cloud-tenants-3TVX7U23.cjs → cloud-tenants-UMZJZTMB.cjs} +7 -7
- package/dist/components/case-studies/index.cjs +20 -20
- package/dist/components/case-studies/index.js +7 -7
- package/dist/components/chat/entity-cards/dispatch.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +5 -5
- package/dist/components/chat/index.js +4 -4
- package/dist/components/chat/utils/icon-library.d.ts +2 -0
- package/dist/components/chat/utils/icon-library.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +8 -8
- package/dist/components/contact/index.js +7 -7
- package/dist/components/docs/index.cjs +11 -11
- package/dist/components/docs/index.js +10 -10
- package/dist/components/embeds/index.cjs +8 -8
- package/dist/components/embeds/index.js +7 -7
- package/dist/components/faq/index.cjs +8 -8
- package/dist/components/faq/index.js +7 -7
- package/dist/components/features/index.cjs +23 -7
- package/dist/components/features/index.d.ts +1 -0
- package/dist/components/features/index.d.ts.map +1 -1
- package/dist/components/features/index.js +22 -6
- package/dist/components/features/model-token-rates.d.ts +179 -0
- package/dist/components/features/model-token-rates.d.ts.map +1 -0
- package/dist/components/help-center-pages/index.cjs +106 -106
- package/dist/components/help-center-pages/index.js +18 -18
- package/dist/components/index.cjs +71 -55
- package/dist/components/index.js +34 -18
- package/dist/components/meeting-scheduler/index.cjs +32 -32
- package/dist/components/meeting-scheduler/index.js +6 -6
- package/dist/components/navigation/index.cjs +10 -10
- package/dist/components/navigation/index.js +9 -9
- package/dist/components/onboarding-guides/index.cjs +10 -10
- package/dist/components/onboarding-guides/index.js +9 -9
- package/dist/components/product-demo/index.cjs +7 -7
- package/dist/components/product-demo/index.js +7 -7
- package/dist/components/related-content/index.cjs +4 -4
- package/dist/components/related-content/index.js +3 -3
- package/dist/components/tickets/index.cjs +11 -11
- package/dist/components/tickets/index.js +10 -10
- package/dist/components/ui/index.cjs +7 -7
- package/dist/components/ui/index.js +6 -6
- package/dist/{devices-275UZZUS.js → devices-FSSUAUQN.js} +6 -6
- package/dist/{devices-MAALZ7DX.cjs → devices-HOT7YSDN.cjs} +8 -8
- package/dist/index.cjs +45 -11
- package/dist/index.js +44 -10
- package/dist/{knowledge-YMMULPPK.cjs → knowledge-3N6HCFLU.cjs} +8 -8
- package/dist/{knowledge-THXC2KC3.js → knowledge-7HRUJX26.js} +6 -6
- package/dist/{logs-IBUKQOHS.cjs → logs-75M5YP6V.cjs} +15 -15
- package/dist/{logs-GM2XBHYT.js → logs-T25OE2QW.js} +6 -6
- package/dist/{policies-NKMRK6RR.js → policies-4OK4XTD4.js} +6 -6
- package/dist/{policies-J5GJXD6G.cjs → policies-BTG7UJJC.cjs} +11 -11
- package/dist/{remote-session-Z2YCUPHU.cjs → remote-session-4DTOM3QD.cjs} +5 -5
- package/dist/{remote-session-WCLR47RN.js → remote-session-AZJXG7ZF.js} +3 -3
- package/dist/{software-update-EKKMKDWI.js → software-update-5SOV4FIR.js} +6 -6
- package/dist/{software-update-JHC4T4SE.cjs → software-update-OZURO4VK.cjs} +11 -11
- package/dist/utils/ai-providers.d.ts +29 -0
- package/dist/utils/ai-providers.d.ts.map +1 -0
- package/dist/utils/index.cjs +76 -2
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +68 -3
- package/dist/utils/list-url.d.ts.map +1 -1
- package/dist/utils/model-token-rates.d.ts +55 -0
- package/dist/utils/model-token-rates.d.ts.map +1 -0
- package/dist/utils/source-icons.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/entity-cards/dispatch.tsx +3 -0
- package/src/components/chat/utils/icon-library.tsx +6 -0
- package/src/components/features/index.ts +1 -0
- package/src/components/features/model-token-rates.tsx +696 -0
- package/src/utils/ai-providers.ts +46 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/list-url.ts +1 -0
- package/src/utils/model-token-rates.ts +103 -0
- package/src/utils/source-icons.ts +3 -0
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { type ReactNode, useId, useState } from 'react';
|
|
4
|
+
import { aiProvider } from '../../utils/ai-providers';
|
|
5
|
+
import { cn } from '../../utils/cn';
|
|
6
|
+
import { formatPrice } from '../../utils/format';
|
|
7
|
+
import {
|
|
8
|
+
formatTokenAmount,
|
|
9
|
+
formatTokenRate,
|
|
10
|
+
groupTokenRatesByProvider,
|
|
11
|
+
type ModelTokenRate,
|
|
12
|
+
type TokenPrice,
|
|
13
|
+
tokenCost,
|
|
14
|
+
tokenRateProviderLabel,
|
|
15
|
+
tokensForBalance,
|
|
16
|
+
} from '../../utils/model-token-rates';
|
|
17
|
+
import { findIcon } from '../chat/utils/icon-library';
|
|
18
|
+
import { AlertTriangleIcon } from '../icons-v2-generated/interface/alert-triangle-icon';
|
|
19
|
+
import { Refresh02VrIcon } from '../icons-v2-generated/media-playback/refresh-02-vr-icon';
|
|
20
|
+
import { QuestionCircleIcon } from '../icons-v2-generated/signs-and-symbols/question-circle-icon';
|
|
21
|
+
import { XmarkCircleIcon } from '../icons-v2-generated/signs-and-symbols/xmark-circle-icon';
|
|
22
|
+
import { Autocomplete, type AutocompleteOption } from '../ui/autocomplete';
|
|
23
|
+
import { Button } from '../ui/button';
|
|
24
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '../ui/dropdown-menu';
|
|
25
|
+
import { Skeleton } from '../ui/skeleton';
|
|
26
|
+
import { Slider } from '../ui/slider';
|
|
27
|
+
import { TabSelector } from '../ui/tab-selector';
|
|
28
|
+
import { PushButtonSelector } from './push-button-selector';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The per-model AI token exchange rates, in the two places they are shown:
|
|
32
|
+
*
|
|
33
|
+
* - `ModelTokenRates` (+ `ModelTokenRatesPopover`): a small table behind a
|
|
34
|
+
* question mark, for the product's cards that count in tokens.
|
|
35
|
+
* - `ModelTokenExchange`: the whole idea as three steps with a figure each
|
|
36
|
+
* (buy OpenFrame tokens, use a frontier model, its tokens are charged at an
|
|
37
|
+
* exchange rate). For a page (the website's pricing page).
|
|
38
|
+
*
|
|
39
|
+
* Both state a rate with the same functions (`utils/model-token-rates`,
|
|
40
|
+
* server-safe), so a rate reads the same wherever it is stated. Neither
|
|
41
|
+
* fetches: a host reads the rates its own way and hands them in, with `status`
|
|
42
|
+
* saying where that read stands. Each holds ONE size loading, loaded, failed or
|
|
43
|
+
* empty.
|
|
44
|
+
*
|
|
45
|
+
* A rate is the number of OpenFrame tokens ONE token of the model charges to
|
|
46
|
+
* the balance.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/** Where the host's read of the rates stands. */
|
|
50
|
+
export type ModelTokenRatesStatus = 'loading' | 'error' | 'ready';
|
|
51
|
+
|
|
52
|
+
/** The wording of both views, stated once. */
|
|
53
|
+
export const MODEL_TOKEN_RATES_COPY = {
|
|
54
|
+
trigger: 'Per-model token rates',
|
|
55
|
+
unit: 'OpenFrame tokens used per model token',
|
|
56
|
+
model: 'Model',
|
|
57
|
+
input: 'Input',
|
|
58
|
+
output: 'Output',
|
|
59
|
+
/** The exchange: three steps, each with its own figure. */
|
|
60
|
+
exchange: {
|
|
61
|
+
buy: {
|
|
62
|
+
title: 'You buy OpenFrame tokens',
|
|
63
|
+
/** Under the price: "buys 1M OpenFrame tokens" */
|
|
64
|
+
words: (tokens: string) => `buys ${tokens} OpenFrame tokens`,
|
|
65
|
+
/** Under the tokens, where no price is known. */
|
|
66
|
+
unit: 'OpenFrame tokens',
|
|
67
|
+
/** The amount, where no price is known: "1M OpenFrame tokens" */
|
|
68
|
+
tokens: (tokens: string) => `${tokens} OpenFrame tokens`,
|
|
69
|
+
/** The slider's label. */
|
|
70
|
+
amount: 'How many tokens',
|
|
71
|
+
/** Over the one-tap amounts. */
|
|
72
|
+
presets: 'Included in your plan',
|
|
73
|
+
/** A one-tap amount: "10M every month" */
|
|
74
|
+
preset: (tokens: string, label: string) => `${tokens} ${label}`,
|
|
75
|
+
},
|
|
76
|
+
use: {
|
|
77
|
+
title: 'You use a frontier AI model',
|
|
78
|
+
model: 'Model',
|
|
79
|
+
search: 'Search models',
|
|
80
|
+
noMatch: 'No model matches',
|
|
81
|
+
},
|
|
82
|
+
charge: {
|
|
83
|
+
title: 'Its tokens are charged at an exchange rate',
|
|
84
|
+
/** Over the two rates: the unit, said once. */
|
|
85
|
+
unit: 'OpenFrame tokens charged',
|
|
86
|
+
input: 'per token read',
|
|
87
|
+
output: 'per token written',
|
|
88
|
+
/** Over the answer: "So $10.00 on Claude Opus 5.5 buys" */
|
|
89
|
+
so: (balance: string, model: string) => `So ${balance} on ${model} buys`,
|
|
90
|
+
/** Beside the answer's number: "750K tokens read" */
|
|
91
|
+
read: 'tokens read',
|
|
92
|
+
/** Under it: "or 150K tokens written" */
|
|
93
|
+
orWritten: (output: string) => `or ${output} tokens written`,
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
autoTopUp: { on: 'Auto Top Up Enabled', off: 'Auto Top Up Disabled' },
|
|
97
|
+
unavailable: {
|
|
98
|
+
title: 'Rates unavailable',
|
|
99
|
+
body: "We couldn't load the per-model token rates. They're a reference only: your plan and its price are unaffected.",
|
|
100
|
+
},
|
|
101
|
+
empty: 'No token rates are in effect right now.',
|
|
102
|
+
} as const;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A provider's mark, found in the icon set BY NAME (the name the host's server
|
|
106
|
+
* stated, else the shared provider list's): nothing here imports a provider's
|
|
107
|
+
* icon. The monochrome cut takes the text colour, so no brand colour competes
|
|
108
|
+
* with the figures. A provider the set holds no mark for is shown by name.
|
|
109
|
+
*/
|
|
110
|
+
function providerMark(providerType: string, stated: string | null | undefined, className: string): ReactNode {
|
|
111
|
+
const Mark = findIcon(aiProvider(providerType, { icon: stated }).icon);
|
|
112
|
+
return Mark ? <Mark className={className} /> : undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const PAD_X = 'px-[var(--spacing-system-s)]';
|
|
116
|
+
const ROW = 'flex items-center gap-[var(--spacing-system-s)]';
|
|
117
|
+
/** Fixed width, so the figures of separate rows line up as columns. */
|
|
118
|
+
const NUMBER_CELL = 'w-16 shrink-0 whitespace-nowrap text-right tabular-nums';
|
|
119
|
+
|
|
120
|
+
/** The provider shown: the one picked while the rates still hold it, else the first. */
|
|
121
|
+
function useProviderGroup(rates: readonly ModelTokenRate[]) {
|
|
122
|
+
const groups = groupTokenRatesByProvider(rates);
|
|
123
|
+
const [picked, setPicked] = useState<string | null>(null);
|
|
124
|
+
return { groups, group: groups.find(candidate => candidate.providerType === picked) ?? groups[0], setPicked };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The provider tabs. Tabs keep their own width and the row scrolls, so one more provider never squeezes the rest. */
|
|
128
|
+
function ProviderTabs({
|
|
129
|
+
groups,
|
|
130
|
+
value,
|
|
131
|
+
onValueChange,
|
|
132
|
+
className,
|
|
133
|
+
}: {
|
|
134
|
+
groups: { providerType: string; rates: readonly ModelTokenRate[] }[];
|
|
135
|
+
value: string;
|
|
136
|
+
onValueChange: (providerType: string) => void;
|
|
137
|
+
className?: string;
|
|
138
|
+
}) {
|
|
139
|
+
return (
|
|
140
|
+
<TabSelector
|
|
141
|
+
variant="secondary"
|
|
142
|
+
scrollable
|
|
143
|
+
value={value}
|
|
144
|
+
onValueChange={onValueChange}
|
|
145
|
+
items={groups.map(candidate => ({
|
|
146
|
+
id: candidate.providerType,
|
|
147
|
+
label: tokenRateProviderLabel(candidate.providerType, candidate.rates[0]?.providerLabel),
|
|
148
|
+
icon: providerMark(candidate.providerType, candidate.rates[0]?.providerIcon, 'size-full'),
|
|
149
|
+
}))}
|
|
150
|
+
className={className}
|
|
151
|
+
/>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The tabs' box while the rates load (the selector's own heights). */
|
|
156
|
+
const TABS_SKELETON_CLASS = 'h-11 w-full max-w-sm content-md:h-12';
|
|
157
|
+
|
|
158
|
+
// ─── The table (a popover) ───────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
export interface ModelTokenRatesProps {
|
|
161
|
+
/** The rates, once read. */
|
|
162
|
+
rates?: readonly ModelTokenRate[];
|
|
163
|
+
/** Where the read stands. Default `ready`. */
|
|
164
|
+
status?: ModelTokenRatesStatus;
|
|
165
|
+
/**
|
|
166
|
+
* Whether the balance these rates draw on refills itself: the panel's first
|
|
167
|
+
* line on a billing page. Left out where there is no balance to have that state.
|
|
168
|
+
*/
|
|
169
|
+
autoTopUpEnabled?: boolean;
|
|
170
|
+
className?: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** How many rows the table's skeleton draws: about what the panel's height shows. */
|
|
174
|
+
const TABLE_SKELETON_ROWS = 8;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The table in its frame, at ONE height in every state. The frame and the auto
|
|
178
|
+
* top-up line stay put while the rates load or fail to: that line is a fact
|
|
179
|
+
* about the subscription, not about the rates.
|
|
180
|
+
*/
|
|
181
|
+
export function ModelTokenRates({ rates = [], status = 'ready', autoTopUpEnabled, className }: ModelTokenRatesProps) {
|
|
182
|
+
return (
|
|
183
|
+
<div
|
|
184
|
+
className={cn(
|
|
185
|
+
'flex h-[min(60vh,420px)] w-[340px] max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-[6px] border border-ods-border bg-ods-card',
|
|
186
|
+
className,
|
|
187
|
+
)}
|
|
188
|
+
>
|
|
189
|
+
{autoTopUpEnabled != null && <AutoTopUpLine enabled={autoTopUpEnabled} />}
|
|
190
|
+
{status === 'loading' ? (
|
|
191
|
+
<ModelTokenRatesSkeleton />
|
|
192
|
+
) : status === 'error' ? (
|
|
193
|
+
<ModelTokenRatesUnavailable className="flex-1" />
|
|
194
|
+
) : (
|
|
195
|
+
<ModelTokenRatesTable rates={rates} />
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The question-mark button that opens the rates, for every card that counts in
|
|
203
|
+
* tokens. One trigger, so two cards cannot open two different panels. The panel
|
|
204
|
+
* is rendered only while open, so a host that reads the rates lazily (`children`)
|
|
205
|
+
* reads them on open.
|
|
206
|
+
*/
|
|
207
|
+
export function ModelTokenRatesPopover({
|
|
208
|
+
children,
|
|
209
|
+
triggerClassName,
|
|
210
|
+
...panel
|
|
211
|
+
}: ModelTokenRatesProps & {
|
|
212
|
+
/** The panel, when the host renders it itself (a lazy read behind its own boundaries). Default: `ModelTokenRates` with these props. */
|
|
213
|
+
children?: ReactNode;
|
|
214
|
+
triggerClassName?: string;
|
|
215
|
+
}) {
|
|
216
|
+
return (
|
|
217
|
+
<DropdownMenu>
|
|
218
|
+
<DropdownMenuTrigger asChild>
|
|
219
|
+
<button
|
|
220
|
+
type="button"
|
|
221
|
+
aria-label={MODEL_TOKEN_RATES_COPY.trigger}
|
|
222
|
+
className={cn(
|
|
223
|
+
'shrink-0 text-ods-text-secondary transition-colors hover:text-ods-text-primary',
|
|
224
|
+
triggerClassName,
|
|
225
|
+
)}
|
|
226
|
+
>
|
|
227
|
+
<QuestionCircleIcon className="size-6" />
|
|
228
|
+
</button>
|
|
229
|
+
</DropdownMenuTrigger>
|
|
230
|
+
<DropdownMenuContent align="end" sideOffset={8} className="border-0 bg-transparent p-0 shadow-none">
|
|
231
|
+
{children ?? <ModelTokenRates {...panel} />}
|
|
232
|
+
</DropdownMenuContent>
|
|
233
|
+
</DropdownMenu>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** On: the success tint, the one band of colour in the panel, so a refilling balance reads at a glance. Off: the panel's own background. */
|
|
238
|
+
function AutoTopUpLine({ enabled }: { enabled: boolean }) {
|
|
239
|
+
const Icon = enabled ? Refresh02VrIcon : XmarkCircleIcon;
|
|
240
|
+
return (
|
|
241
|
+
<div
|
|
242
|
+
className={cn(
|
|
243
|
+
'flex shrink-0 items-center gap-[var(--spacing-system-xs)] border-b border-ods-border py-[var(--spacing-system-xs)] text-h4',
|
|
244
|
+
PAD_X,
|
|
245
|
+
enabled ? 'bg-ods-success-secondary text-ods-success' : 'text-ods-text-secondary',
|
|
246
|
+
)}
|
|
247
|
+
>
|
|
248
|
+
<Icon className="size-6 shrink-0" />
|
|
249
|
+
<span className="flex-1">
|
|
250
|
+
{enabled ? MODEL_TOKEN_RATES_COPY.autoTopUp.on : MODEL_TOKEN_RATES_COPY.autoTopUp.off}
|
|
251
|
+
</span>
|
|
252
|
+
</div>
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function ModelTokenRatesTable({ rates }: { rates: readonly ModelTokenRate[] }) {
|
|
257
|
+
const { groups, group, setPicked } = useProviderGroup(rates);
|
|
258
|
+
|
|
259
|
+
if (!group) {
|
|
260
|
+
return (
|
|
261
|
+
<p className={cn('flex-1 py-[var(--spacing-system-s)] text-ods-text-secondary text-h6', PAD_X)}>
|
|
262
|
+
{MODEL_TOKEN_RATES_COPY.empty}
|
|
263
|
+
</p>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return (
|
|
268
|
+
<>
|
|
269
|
+
{groups.length > 1 && (
|
|
270
|
+
<ProviderTabs
|
|
271
|
+
groups={groups}
|
|
272
|
+
value={group.providerType}
|
|
273
|
+
onValueChange={setPicked}
|
|
274
|
+
className={cn('shrink-0 border-b border-ods-border py-[var(--spacing-system-xs)]', PAD_X)}
|
|
275
|
+
/>
|
|
276
|
+
)}
|
|
277
|
+
<div role="table" aria-label={MODEL_TOKEN_RATES_COPY.unit} className="flex min-h-0 flex-1 flex-col">
|
|
278
|
+
<div
|
|
279
|
+
role="row"
|
|
280
|
+
className={cn(
|
|
281
|
+
ROW,
|
|
282
|
+
PAD_X,
|
|
283
|
+
'shrink-0 py-[var(--spacing-system-xs)] uppercase tracking-[-0.02em] text-ods-text-secondary text-h5',
|
|
284
|
+
)}
|
|
285
|
+
>
|
|
286
|
+
<span role="columnheader" className="min-w-0 flex-1 truncate">
|
|
287
|
+
{MODEL_TOKEN_RATES_COPY.model}
|
|
288
|
+
</span>
|
|
289
|
+
<span role="columnheader" className={NUMBER_CELL}>
|
|
290
|
+
{MODEL_TOKEN_RATES_COPY.input}
|
|
291
|
+
</span>
|
|
292
|
+
<span role="columnheader" className={NUMBER_CELL}>
|
|
293
|
+
{MODEL_TOKEN_RATES_COPY.output}
|
|
294
|
+
</span>
|
|
295
|
+
</div>
|
|
296
|
+
{/* Every model of the provider, scrolling under the pinned header. */}
|
|
297
|
+
<div role="rowgroup" className="min-h-0 flex-1 overflow-y-auto">
|
|
298
|
+
{group.rates.map(rate => (
|
|
299
|
+
<div
|
|
300
|
+
key={rate.modelName}
|
|
301
|
+
role="row"
|
|
302
|
+
className={cn(ROW, PAD_X, 'py-[var(--spacing-system-xxs)] text-ods-text-primary text-h6')}
|
|
303
|
+
>
|
|
304
|
+
<span role="rowheader" className="min-w-0 flex-1 truncate">
|
|
305
|
+
{rate.displayName || rate.modelName}
|
|
306
|
+
</span>
|
|
307
|
+
<span role="cell" className={NUMBER_CELL}>
|
|
308
|
+
{formatTokenRate(rate.inputTokenRate)}
|
|
309
|
+
</span>
|
|
310
|
+
<span role="cell" className={NUMBER_CELL}>
|
|
311
|
+
{formatTokenRate(rate.outputTokenRate)}
|
|
312
|
+
</span>
|
|
313
|
+
</div>
|
|
314
|
+
))}
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
{/* The unit, stated once, where it has room to wrap. */}
|
|
318
|
+
<p
|
|
319
|
+
className={cn(
|
|
320
|
+
'shrink-0 border-t border-ods-border py-[var(--spacing-system-xs)] text-ods-text-secondary text-h6',
|
|
321
|
+
PAD_X,
|
|
322
|
+
)}
|
|
323
|
+
>
|
|
324
|
+
{MODEL_TOKEN_RATES_COPY.unit}
|
|
325
|
+
</p>
|
|
326
|
+
</>
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Why the rates are missing, in the box the rates would fill. Shown on a deliberate click or in a page's reserved space, so it never renders empty. */
|
|
331
|
+
export function ModelTokenRatesUnavailable({ className }: { className?: string }) {
|
|
332
|
+
return (
|
|
333
|
+
<div
|
|
334
|
+
role="alert"
|
|
335
|
+
className={cn(
|
|
336
|
+
'flex flex-col items-center justify-center gap-[var(--spacing-system-xs)] p-[var(--spacing-system-mf)] text-center',
|
|
337
|
+
className,
|
|
338
|
+
)}
|
|
339
|
+
>
|
|
340
|
+
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-ods-bg text-ods-text-secondary">
|
|
341
|
+
<AlertTriangleIcon className="size-5" />
|
|
342
|
+
</div>
|
|
343
|
+
<p className="text-ods-text-primary text-h3">{MODEL_TOKEN_RATES_COPY.unavailable.title}</p>
|
|
344
|
+
<p className="max-w-[320px] text-ods-text-secondary text-h6">{MODEL_TOKEN_RATES_COPY.unavailable.body}</p>
|
|
345
|
+
</div>
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** The table's loading footprint: the provider tabs, the header and rows of the boxes the loaded rows use. */
|
|
350
|
+
export function ModelTokenRatesSkeleton() {
|
|
351
|
+
const cells = (
|
|
352
|
+
<>
|
|
353
|
+
<div className="flex-1" />
|
|
354
|
+
<Skeleton className="h-4 w-12" />
|
|
355
|
+
<Skeleton className="h-4 w-12" />
|
|
356
|
+
</>
|
|
357
|
+
);
|
|
358
|
+
return (
|
|
359
|
+
<div aria-busy="true" className="min-h-0 flex-1 overflow-hidden">
|
|
360
|
+
<div className={cn('border-b border-ods-border py-[var(--spacing-system-xs)]', PAD_X)}>
|
|
361
|
+
<Skeleton className={TABS_SKELETON_CLASS} />
|
|
362
|
+
</div>
|
|
363
|
+
<div className={cn(ROW, PAD_X, 'py-[var(--spacing-system-xs)]')}>
|
|
364
|
+
<Skeleton className="h-4 w-14" />
|
|
365
|
+
{cells}
|
|
366
|
+
</div>
|
|
367
|
+
{Array.from({ length: TABLE_SKELETON_ROWS }, (_, index) => (
|
|
368
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: identical placeholder rows
|
|
369
|
+
<div key={index} className={cn(ROW, PAD_X, 'py-[var(--spacing-system-xxs)]')}>
|
|
370
|
+
<Skeleton className="h-4 w-36" />
|
|
371
|
+
{cells}
|
|
372
|
+
</div>
|
|
373
|
+
))}
|
|
374
|
+
</div>
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ─── The exchange (a page) ───────────────────────────────────────────────────
|
|
379
|
+
|
|
380
|
+
export interface ModelTokenExchangeProps {
|
|
381
|
+
/** The model whose rate is shown: the one picked, once the host has read it. */
|
|
382
|
+
rate?: ModelTokenRate | null;
|
|
383
|
+
/** Where the host's read of that model stands. Default `ready`. */
|
|
384
|
+
status?: ModelTokenRatesStatus;
|
|
385
|
+
/** The providers to choose between (their keys), in the host's order. */
|
|
386
|
+
providers?: readonly string[];
|
|
387
|
+
/** The provider chosen, and the host's handler for a change (it searches that provider's models). */
|
|
388
|
+
provider?: string | null;
|
|
389
|
+
onProviderChange?: (providerType: string) => void;
|
|
390
|
+
/**
|
|
391
|
+
* The picker's options: what the HOST's search answered for `query` within
|
|
392
|
+
* `provider`. The picker never filters them itself: searching is the host's
|
|
393
|
+
* server's job.
|
|
394
|
+
*/
|
|
395
|
+
models?: readonly ModelTokenRate[];
|
|
396
|
+
/** What is typed in the picker, and the host's handler for it (it searches). */
|
|
397
|
+
query?: string;
|
|
398
|
+
onQueryChange?: (query: string) => void;
|
|
399
|
+
/** The host's search is in flight. */
|
|
400
|
+
searching?: boolean;
|
|
401
|
+
/** A model was picked: its `modelName`. */
|
|
402
|
+
onPick?: (modelName: string) => void;
|
|
403
|
+
/** What OpenFrame tokens cost. Given: step 1 states the price of the amount chosen. Not given: the tokens alone. */
|
|
404
|
+
tokenPrice?: TokenPrice | null;
|
|
405
|
+
/** The amount step 1 opens on, in OpenFrame tokens. Default: the amount `tokenPrice` is quoted for, else one million. */
|
|
406
|
+
defaultAmount?: number;
|
|
407
|
+
/** The slider's bounds and step, in OpenFrame tokens. Default one to fifty million, a million at a time. */
|
|
408
|
+
amountRange?: { min: number; max: number; step: number };
|
|
409
|
+
/** Amounts offered as one-tap choices under the slider (what a plan includes): the tokens and what to call them. */
|
|
410
|
+
presets?: readonly { tokens: number; label: string }[];
|
|
411
|
+
className?: string;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** The amount step 1 opens on when the host names none, and the slider's default bounds. */
|
|
415
|
+
export const MODEL_TOKEN_EXCHANGE_BALANCE = 1_000_000;
|
|
416
|
+
export const MODEL_TOKEN_EXCHANGE_RANGE = { min: 1_000_000, max: 50_000_000, step: 1_000_000 } as const;
|
|
417
|
+
|
|
418
|
+
/** The provider rows drawn while the providers load: as many as OpenFrame has had. */
|
|
419
|
+
const PROVIDER_PLACEHOLDERS = ['loading-1', 'loading-2', 'loading-3'] as const;
|
|
420
|
+
|
|
421
|
+
/** A line of the surrounding text's own height, as a bar: what a figure is while it loads. */
|
|
422
|
+
function Bar({ className }: { className: string }) {
|
|
423
|
+
return (
|
|
424
|
+
<span
|
|
425
|
+
aria-hidden
|
|
426
|
+
className={cn('inline-block h-[0.8lh] animate-pulse rounded bg-ods-border align-middle', className)}
|
|
427
|
+
/>
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** A model as a picker option: its name, its provider under it, the provider's mark beside it. */
|
|
432
|
+
function modelOption(rate: ModelTokenRate): AutocompleteOption<string> {
|
|
433
|
+
return {
|
|
434
|
+
label: rate.displayName || rate.modelName,
|
|
435
|
+
value: rate.modelName,
|
|
436
|
+
description: tokenRateProviderLabel(rate.providerType, rate.providerLabel),
|
|
437
|
+
icon: providerMark(rate.providerType, rate.providerIcon, 'size-full text-ods-text-secondary'),
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** One step of the flow: its number and what happens in it, over its content. */
|
|
442
|
+
function ExchangeStep({ step, title, children }: { step: number; title: string; children: ReactNode }) {
|
|
443
|
+
return (
|
|
444
|
+
<section className="flex min-w-0 flex-col gap-[var(--spacing-system-mf)] rounded-md border border-ods-border bg-ods-card p-[var(--spacing-system-lf)]">
|
|
445
|
+
<header className="flex items-center gap-[var(--spacing-system-sf)]">
|
|
446
|
+
<span className="grid size-8 shrink-0 place-items-center rounded-full border border-ods-border tabular-nums text-ods-text-primary text-h5">
|
|
447
|
+
{step}
|
|
448
|
+
</span>
|
|
449
|
+
<h3 className="min-w-0 text-ods-text-primary text-h3">{title}</h3>
|
|
450
|
+
</header>
|
|
451
|
+
{children}
|
|
452
|
+
</section>
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* A figure over the plain words that say what it is. `hero` is the step's one
|
|
458
|
+
* headline figure; `plain` is a supporting one, a size down so two sit side by
|
|
459
|
+
* side in a narrow step. The words keep two lines, so a step is one height when
|
|
460
|
+
* they wrap.
|
|
461
|
+
*/
|
|
462
|
+
function ExchangeFigure({
|
|
463
|
+
figure,
|
|
464
|
+
words,
|
|
465
|
+
loading,
|
|
466
|
+
size = 'hero',
|
|
467
|
+
}: {
|
|
468
|
+
figure: ReactNode;
|
|
469
|
+
words: ReactNode;
|
|
470
|
+
loading?: boolean;
|
|
471
|
+
size?: 'hero' | 'plain';
|
|
472
|
+
}) {
|
|
473
|
+
return (
|
|
474
|
+
<div className="flex min-w-0 flex-col">
|
|
475
|
+
<span
|
|
476
|
+
className={cn('whitespace-nowrap tabular-nums text-ods-text-primary', size === 'hero' ? 'text-h1' : 'text-h2')}
|
|
477
|
+
>
|
|
478
|
+
{loading ? <Bar className="w-20" /> : figure}
|
|
479
|
+
</span>
|
|
480
|
+
<span className="min-h-[2lh] text-ods-text-secondary text-h4">
|
|
481
|
+
{loading ? <Bar className="w-full max-w-xs" /> : words}
|
|
482
|
+
</span>
|
|
483
|
+
</div>
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* How AI tokens are paid for, as three steps read left to right, each with its
|
|
489
|
+
* own figure, so the whole idea is on screen at once:
|
|
490
|
+
*
|
|
491
|
+
* 1. You buy OpenFrame tokens "$10.00 buys 1M OpenFrame tokens"
|
|
492
|
+
* 2. You use a frontier AI model provider buttons, then the model
|
|
493
|
+
* 3. Its tokens are charged at an exchange rate
|
|
494
|
+
* "1.33× per token it reads, 6.67× per token it
|
|
495
|
+
* writes", then what the step 1 balance buys
|
|
496
|
+
*
|
|
497
|
+
* Step 2 is where the visitor acts: the lib's `PushButtonSelector` picks the
|
|
498
|
+
* provider and the lib's `Autocomplete` (the provider's mark on every option)
|
|
499
|
+
* finds a model of it. The picker SEARCHES ON THE SERVER: the host hands in
|
|
500
|
+
* `models` (what its search answered for `query` within `provider`) and the
|
|
501
|
+
* picker shows exactly those, never a filter of its own.
|
|
502
|
+
*
|
|
503
|
+
* One size in every state: each figure and its words are the same boxes loading
|
|
504
|
+
* (bars of their own line height), loaded, failed and empty.
|
|
505
|
+
*/
|
|
506
|
+
export function ModelTokenExchange({
|
|
507
|
+
rate,
|
|
508
|
+
status = 'ready',
|
|
509
|
+
providers = [],
|
|
510
|
+
provider,
|
|
511
|
+
onProviderChange,
|
|
512
|
+
models = [],
|
|
513
|
+
query,
|
|
514
|
+
onQueryChange,
|
|
515
|
+
searching = false,
|
|
516
|
+
onPick,
|
|
517
|
+
tokenPrice,
|
|
518
|
+
defaultAmount,
|
|
519
|
+
amountRange = MODEL_TOKEN_EXCHANGE_RANGE,
|
|
520
|
+
presets = [],
|
|
521
|
+
className,
|
|
522
|
+
}: ModelTokenExchangeProps) {
|
|
523
|
+
const copy = MODEL_TOKEN_RATES_COPY.exchange;
|
|
524
|
+
const loading = status === 'loading';
|
|
525
|
+
const failed = status === 'error' || (status === 'ready' && !rate);
|
|
526
|
+
const sliderId = useId();
|
|
527
|
+
// The amount bought: what the visitor set, else what the host opens on. The slider reaches every preset.
|
|
528
|
+
const [chosen, setChosen] = useState<number | null>(null);
|
|
529
|
+
const balance = chosen ?? defaultAmount ?? tokenPrice?.tokens ?? MODEL_TOKEN_EXCHANGE_BALANCE;
|
|
530
|
+
const max = Math.max(amountRange.max, balance, ...presets.map(preset => preset.tokens));
|
|
531
|
+
const tokens = formatTokenAmount(balance);
|
|
532
|
+
const cost = tokenCost(balance, tokenPrice);
|
|
533
|
+
const balancePrice = cost === null ? null : formatPrice(cost);
|
|
534
|
+
const model = rate ? rate.displayName || rate.modelName : '';
|
|
535
|
+
|
|
536
|
+
// The picked model stays addressable when the search's answer does not hold it.
|
|
537
|
+
const options = models.map(modelOption);
|
|
538
|
+
if (rate && !models.some(candidate => candidate.modelName === rate.modelName)) options.unshift(modelOption(rate));
|
|
539
|
+
|
|
540
|
+
return (
|
|
541
|
+
<div className={cn('relative grid grid-cols-1 gap-[var(--spacing-system-mf)] lg:grid-cols-3', className)}>
|
|
542
|
+
<ExchangeStep step={1} title={copy.buy.title}>
|
|
543
|
+
<ExchangeFigure figure={balancePrice ?? tokens} words={balancePrice ? copy.buy.words(tokens) : copy.buy.unit} />
|
|
544
|
+
{/* The calculator: move the amount and every figure in step 3 follows. */}
|
|
545
|
+
<div className="flex flex-col gap-[var(--spacing-system-sf)]">
|
|
546
|
+
<label
|
|
547
|
+
htmlFor={sliderId}
|
|
548
|
+
className="flex items-baseline justify-between gap-[var(--spacing-system-sf)] text-ods-text-secondary text-h4"
|
|
549
|
+
>
|
|
550
|
+
<span>{copy.buy.amount}</span>
|
|
551
|
+
<output htmlFor={sliderId} className="tabular-nums text-ods-text-primary text-h3">
|
|
552
|
+
{tokens}
|
|
553
|
+
</output>
|
|
554
|
+
</label>
|
|
555
|
+
<Slider
|
|
556
|
+
id={sliderId}
|
|
557
|
+
min={amountRange.min}
|
|
558
|
+
max={max}
|
|
559
|
+
step={amountRange.step}
|
|
560
|
+
value={[balance]}
|
|
561
|
+
onValueChange={([value]) => setChosen(value)}
|
|
562
|
+
aria-valuetext={copy.buy.words(tokens)}
|
|
563
|
+
/>
|
|
564
|
+
</div>
|
|
565
|
+
{presets.length > 0 && (
|
|
566
|
+
<div className="mt-auto flex flex-col gap-[var(--spacing-system-xs)] border-t border-ods-border pt-[var(--spacing-system-mf)]">
|
|
567
|
+
<span className="text-ods-text-secondary text-h5">{copy.buy.presets}</span>
|
|
568
|
+
<div className="flex flex-wrap gap-[var(--spacing-system-xs)]">
|
|
569
|
+
{presets.map(preset => (
|
|
570
|
+
<Button
|
|
571
|
+
key={preset.label}
|
|
572
|
+
type="button"
|
|
573
|
+
variant={preset.tokens === balance ? 'accent' : 'outline'}
|
|
574
|
+
size="small"
|
|
575
|
+
onClick={() => setChosen(preset.tokens)}
|
|
576
|
+
>
|
|
577
|
+
{copy.buy.preset(formatTokenAmount(preset.tokens), preset.label)}
|
|
578
|
+
</Button>
|
|
579
|
+
))}
|
|
580
|
+
</div>
|
|
581
|
+
</div>
|
|
582
|
+
)}
|
|
583
|
+
</ExchangeStep>
|
|
584
|
+
|
|
585
|
+
<ExchangeStep step={2} title={copy.use.title}>
|
|
586
|
+
{/*
|
|
587
|
+
While the providers load, the SAME selector draws placeholder rows (not its own skeleton,
|
|
588
|
+
whose rows are a different height), so the step is one height loading and loaded.
|
|
589
|
+
*/}
|
|
590
|
+
<PushButtonSelector<string>
|
|
591
|
+
options={
|
|
592
|
+
providers.length === 0 && !failed
|
|
593
|
+
? PROVIDER_PLACEHOLDERS.map(id => ({
|
|
594
|
+
id,
|
|
595
|
+
name: '\u00a0',
|
|
596
|
+
icon: <Skeleton className="size-8 rounded" />,
|
|
597
|
+
disabled: true,
|
|
598
|
+
}))
|
|
599
|
+
: providers.map(providerType => {
|
|
600
|
+
// What the server stated for the provider rides its models' rows.
|
|
601
|
+
const stated =
|
|
602
|
+
models.find(option => option.providerType === providerType) ??
|
|
603
|
+
(rate?.providerType === providerType ? rate : null);
|
|
604
|
+
return {
|
|
605
|
+
id: providerType,
|
|
606
|
+
name: tokenRateProviderLabel(providerType, stated?.providerLabel),
|
|
607
|
+
icon: providerMark(providerType, stated?.providerIcon, 'size-8 text-ods-text-secondary'),
|
|
608
|
+
};
|
|
609
|
+
})
|
|
610
|
+
}
|
|
611
|
+
selectedIds={provider ? [provider] : []}
|
|
612
|
+
onSelectionChange={ids => {
|
|
613
|
+
// A single choice that cannot be cleared: choosing the chosen provider changes nothing.
|
|
614
|
+
if (ids[0]) onProviderChange?.(ids[0]);
|
|
615
|
+
}}
|
|
616
|
+
multiSelect={false}
|
|
617
|
+
/>
|
|
618
|
+
<Autocomplete<string>
|
|
619
|
+
label={copy.use.model}
|
|
620
|
+
value={rate?.modelName ?? null}
|
|
621
|
+
onChange={modelName => {
|
|
622
|
+
if (modelName) onPick?.(modelName);
|
|
623
|
+
}}
|
|
624
|
+
options={options}
|
|
625
|
+
inputValue={query}
|
|
626
|
+
onInputChange={value => onQueryChange?.(value)}
|
|
627
|
+
disableClientFilter
|
|
628
|
+
loading={searching}
|
|
629
|
+
disabled={providers.length === 0}
|
|
630
|
+
placeholder={copy.use.search}
|
|
631
|
+
noOptionsText={copy.use.noMatch}
|
|
632
|
+
/>
|
|
633
|
+
</ExchangeStep>
|
|
634
|
+
|
|
635
|
+
<ExchangeStep step={3} title={copy.charge.title}>
|
|
636
|
+
<div className="grid grid-cols-2 gap-x-[var(--spacing-system-mf)]">
|
|
637
|
+
<span className="col-span-2 text-ods-text-secondary text-h5">{copy.charge.unit}</span>
|
|
638
|
+
<ExchangeFigure
|
|
639
|
+
size="plain"
|
|
640
|
+
loading={loading}
|
|
641
|
+
figure={formatTokenRate(rate?.inputTokenRate)}
|
|
642
|
+
words={copy.charge.input}
|
|
643
|
+
/>
|
|
644
|
+
<ExchangeFigure
|
|
645
|
+
size="plain"
|
|
646
|
+
loading={loading}
|
|
647
|
+
figure={formatTokenRate(rate?.outputTokenRate)}
|
|
648
|
+
words={copy.charge.output}
|
|
649
|
+
/>
|
|
650
|
+
</div>
|
|
651
|
+
{/* THE ANSWER: the step 1 amount, read through this rate. The most prominent thing in the step. */}
|
|
652
|
+
<div
|
|
653
|
+
aria-live="polite"
|
|
654
|
+
className="mt-auto flex flex-col rounded-md border border-ods-accent bg-ods-bg p-[var(--spacing-system-mf)]"
|
|
655
|
+
>
|
|
656
|
+
<span className="min-h-[2lh] text-ods-text-secondary text-h4">
|
|
657
|
+
{loading || !rate ? (
|
|
658
|
+
<Bar className="w-full max-w-xs" />
|
|
659
|
+
) : (
|
|
660
|
+
copy.charge.so(balancePrice ?? copy.buy.tokens(tokens), model)
|
|
661
|
+
)}
|
|
662
|
+
</span>
|
|
663
|
+
{/* The number is the headline, alone on its line: a longer number never pushes its unit to a new line and the box taller. */}
|
|
664
|
+
<span className="min-h-[1lh] whitespace-nowrap tabular-nums text-ods-text-primary text-h1">
|
|
665
|
+
{loading || !rate ? (
|
|
666
|
+
<Bar className="w-40" />
|
|
667
|
+
) : (
|
|
668
|
+
formatTokenAmount(tokensForBalance(balance, rate.inputTokenRate))
|
|
669
|
+
)}
|
|
670
|
+
</span>
|
|
671
|
+
<span className="min-h-[1lh] text-ods-text-primary text-h3">{copy.charge.read}</span>
|
|
672
|
+
<span className="min-h-[1lh] text-ods-text-secondary text-h4">
|
|
673
|
+
{loading || !rate ? (
|
|
674
|
+
<Bar className="w-48" />
|
|
675
|
+
) : (
|
|
676
|
+
copy.charge.orWritten(formatTokenAmount(tokensForBalance(balance, rate.outputTokenRate)))
|
|
677
|
+
)}
|
|
678
|
+
</span>
|
|
679
|
+
</div>
|
|
680
|
+
</ExchangeStep>
|
|
681
|
+
|
|
682
|
+
{failed && (
|
|
683
|
+
// The frame keeps its size; what is under it states no figure.
|
|
684
|
+
<div className="absolute inset-0 flex rounded-md border border-ods-border bg-ods-card">
|
|
685
|
+
{status === 'error' ? (
|
|
686
|
+
<ModelTokenRatesUnavailable className="flex-1" />
|
|
687
|
+
) : (
|
|
688
|
+
<p className="m-auto p-[var(--spacing-system-mf)] text-center text-ods-text-secondary text-h6">
|
|
689
|
+
{MODEL_TOKEN_RATES_COPY.empty}
|
|
690
|
+
</p>
|
|
691
|
+
)}
|
|
692
|
+
</div>
|
|
693
|
+
)}
|
|
694
|
+
</div>
|
|
695
|
+
);
|
|
696
|
+
}
|