♻️ refactor(web): refine data-table cards and pricing page layout
Replace collapsible card details with always-visible label/value rows, add badge-list full display for cards, and polish pricing/channel toolbars and mobile cards.
This commit is contained in:
+254
-296
@@ -28,7 +28,6 @@ import {
|
||||
Layers,
|
||||
Maximize2,
|
||||
Sparkles,
|
||||
Timer,
|
||||
} from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -42,16 +41,10 @@ import {
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@/components/design-system/tabs'
|
||||
import { sideDrawerContentClassName } from '@/components/drawer-layout'
|
||||
import { GroupBadge } from '@/components/group-badge'
|
||||
import { PublicLayout } from '@/components/layout'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { PageTransition } from '@/components/page-transition'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { getPerfMetrics } from '@/features/performance-metrics/api'
|
||||
import {
|
||||
@@ -88,11 +81,19 @@ import { ModelDetailsPerformance } from './model-details-performance'
|
||||
// Local UI helpers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
function SectionTitle(props: { children: React.ReactNode }) {
|
||||
function SectionTitle(props: {
|
||||
children: React.ReactNode
|
||||
description?: string
|
||||
}) {
|
||||
return (
|
||||
<h2 className='text-muted-foreground mb-3 text-xs font-semibold tracking-wider uppercase'>
|
||||
{props.children}
|
||||
</h2>
|
||||
<div className='mb-4'>
|
||||
<h2 className='text-base font-semibold'>{props.children}</h2>
|
||||
{props.description && (
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
{props.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -150,28 +151,22 @@ function normalizeCatalogItems(items?: readonly string[]): string[] {
|
||||
}
|
||||
|
||||
function OverviewMetric(props: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
value: React.ReactNode
|
||||
valueClassName?: string
|
||||
}) {
|
||||
const Icon = props.icon
|
||||
|
||||
return (
|
||||
<div className='flex min-w-0 items-center gap-2 px-3 py-2'>
|
||||
<Icon className='text-muted-foreground/70 size-3.5 shrink-0' />
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='text-muted-foreground truncate text-xs font-medium tracking-wider uppercase'>
|
||||
{props.label}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'text-foreground truncate text-sm font-semibold tabular-nums',
|
||||
props.valueClassName
|
||||
)}
|
||||
>
|
||||
{props.value}
|
||||
</div>
|
||||
<div className='min-w-0 px-4 py-3'>
|
||||
<div className='text-muted-foreground text-xs font-medium'>
|
||||
{props.label}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'text-foreground mt-1 truncate text-sm font-semibold tabular-nums',
|
||||
props.valueClassName
|
||||
)}
|
||||
>
|
||||
{props.value}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -212,19 +207,13 @@ function OverviewSummaryGrid(props: { model: PricingModel }) {
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className='bg-muted/20 grid overflow-hidden rounded-lg border sm:grid-cols-3 sm:divide-x'>
|
||||
<div className='divide-border grid divide-y overflow-hidden rounded-xl border sm:grid-cols-3 sm:divide-x sm:divide-y-0'>
|
||||
<OverviewMetric label='TPS' value={formatThroughput(avgTps)} />
|
||||
<OverviewMetric
|
||||
icon={Timer}
|
||||
label='TPS'
|
||||
value={formatThroughput(avgTps)}
|
||||
/>
|
||||
<OverviewMetric
|
||||
icon={Timer}
|
||||
label={t('Average latency')}
|
||||
value={formatLatency(avgLatency)}
|
||||
/>
|
||||
<OverviewMetric
|
||||
icon={HeartPulse}
|
||||
label={t('Success rate')}
|
||||
value={formatUptimePct(successRate)}
|
||||
valueClassName={getSuccessRateTextClass(successRate)}
|
||||
@@ -237,12 +226,9 @@ function CatalogPillList(props: { items: string[] }) {
|
||||
return (
|
||||
<div className='flex min-w-0 flex-wrap gap-1.5'>
|
||||
{props.items.map((item) => (
|
||||
<span
|
||||
key={item}
|
||||
className='bg-muted text-muted-foreground rounded-md px-2 py-1 text-xs font-medium'
|
||||
>
|
||||
<StatusBadge key={item} variant='neutral' size='md'>
|
||||
{item}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -250,7 +236,7 @@ function CatalogPillList(props: { items: string[] }) {
|
||||
|
||||
function CatalogTextValue(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span className='text-foreground min-w-0 truncate text-sm font-semibold'>
|
||||
<span className='text-foreground min-w-0 truncate text-sm font-medium'>
|
||||
{props.children}
|
||||
</span>
|
||||
)
|
||||
@@ -258,8 +244,8 @@ function CatalogTextValue(props: { children: React.ReactNode }) {
|
||||
|
||||
function CatalogInfoCell(props: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className='bg-card flex min-w-0 flex-col gap-1 px-3 py-2.5'>
|
||||
<span className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
|
||||
<div className='flex min-w-0 flex-col gap-1.5 px-4 py-3'>
|
||||
<span className='text-muted-foreground text-xs font-medium'>
|
||||
{props.label}
|
||||
</span>
|
||||
{props.children}
|
||||
@@ -358,23 +344,20 @@ function ModelBackendQuickStats(props: { model: PricingModel }) {
|
||||
if (stats.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className='bg-muted/20 grid grid-cols-2 gap-px overflow-hidden rounded-lg border @md/details:grid-cols-3 @2xl/details:grid-cols-5'>
|
||||
<div className='divide-border grid grid-cols-2 divide-x divide-y overflow-hidden rounded-xl border @md/details:grid-cols-3 @2xl/details:grid-cols-5'>
|
||||
{stats.map((stat) => {
|
||||
const Icon = stat.icon
|
||||
return (
|
||||
<div
|
||||
key={stat.key}
|
||||
className='bg-background flex min-w-0 flex-col gap-0.5 px-3 py-2.5'
|
||||
>
|
||||
<span className='text-muted-foreground inline-flex min-w-0 items-center gap-1 text-xs font-medium tracking-wider uppercase'>
|
||||
<Icon className='size-3 shrink-0' />
|
||||
<div key={stat.key} className='flex min-w-0 flex-col gap-1 px-4 py-3'>
|
||||
<span className='text-muted-foreground inline-flex min-w-0 items-center gap-1.5 text-xs font-medium'>
|
||||
<Icon className='size-3.5 shrink-0' />
|
||||
<span className='truncate'>{stat.label}</span>
|
||||
</span>
|
||||
<span className='text-foreground truncate text-sm font-semibold tabular-nums'>
|
||||
{stat.value}
|
||||
</span>
|
||||
{stat.hint && (
|
||||
<span className='text-muted-foreground/60 truncate text-xs'>
|
||||
<span className='text-muted-foreground truncate text-xs'>
|
||||
{stat.hint}
|
||||
</span>
|
||||
)}
|
||||
@@ -401,11 +384,9 @@ function ModelBackendSignalsSection(props: { model: PricingModel }) {
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle>
|
||||
{t('Capabilities')} / {t('Supported modalities')}
|
||||
</SectionTitle>
|
||||
<div className='grid gap-3 rounded-xl border p-3 @2xl/details:grid-cols-[minmax(0,1.5fr)_minmax(260px,1fr)]'>
|
||||
{capabilities.length > 0 ? (
|
||||
<SectionTitle>{t('Capabilities')}</SectionTitle>
|
||||
<div className='space-y-4 rounded-xl border p-4'>
|
||||
{capabilities.length > 0 && (
|
||||
<CatalogPillList
|
||||
items={capabilities.map((capability) =>
|
||||
t(
|
||||
@@ -414,13 +395,11 @@ function ModelBackendSignalsSection(props: { model: PricingModel }) {
|
||||
)
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{(inputModalities.length > 0 || outputModalities.length > 0) && (
|
||||
<div className='grid gap-2 sm:grid-cols-2'>
|
||||
{inputModalities.length > 0 && (
|
||||
<div className='flex items-center justify-between gap-3 rounded-lg border px-3 py-2'>
|
||||
<div className='bg-muted/20 flex items-center justify-between gap-3 rounded-lg px-3 py-2.5'>
|
||||
<span className='text-muted-foreground text-xs font-medium'>
|
||||
{t('Input')}
|
||||
</span>
|
||||
@@ -430,7 +409,7 @@ function ModelBackendSignalsSection(props: { model: PricingModel }) {
|
||||
</div>
|
||||
)}
|
||||
{outputModalities.length > 0 && (
|
||||
<div className='flex items-center justify-between gap-3 rounded-lg border px-3 py-2'>
|
||||
<div className='bg-muted/20 flex items-center justify-between gap-3 rounded-lg px-3 py-2.5'>
|
||||
<span className='text-muted-foreground text-xs font-medium'>
|
||||
{t('Output')}
|
||||
</span>
|
||||
@@ -475,7 +454,11 @@ function ModelBackendProviderSection(props: { model: PricingModel }) {
|
||||
if (groups.length > 0) {
|
||||
cells.push(
|
||||
<CatalogInfoCell key='groups' label={t('Groups')}>
|
||||
<CatalogPillList items={groups} />
|
||||
<div className='flex min-w-0 flex-wrap gap-1.5'>
|
||||
{groups.map((group) => (
|
||||
<GroupBadge key={group} group={group} size='md' />
|
||||
))}
|
||||
</div>
|
||||
</CatalogInfoCell>
|
||||
)
|
||||
}
|
||||
@@ -509,7 +492,7 @@ function ModelBackendProviderSection(props: { model: PricingModel }) {
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle>{t('Model')}</SectionTitle>
|
||||
<div className='border-border/60 bg-border/60 grid grid-cols-1 gap-px overflow-hidden rounded-lg border sm:grid-cols-2'>
|
||||
<div className='divide-border grid grid-cols-1 overflow-hidden rounded-xl border sm:grid-cols-2 [&>*]:border-b [&>*:nth-child(odd)]:sm:border-r [&>*:nth-last-child(-n+2)]:sm:border-b-0'>
|
||||
{cells}
|
||||
</div>
|
||||
</section>
|
||||
@@ -519,7 +502,6 @@ function ModelBackendProviderSection(props: { model: PricingModel }) {
|
||||
function ModelBackendDetailsSection(props: { model: PricingModel }) {
|
||||
return (
|
||||
<>
|
||||
<ModelBackendQuickStats model={props.model} />
|
||||
<ModelBackendSignalsSection model={props.model} />
|
||||
<ModelBackendProviderSection model={props.model} />
|
||||
</>
|
||||
@@ -534,55 +516,84 @@ function ModelHeader(props: { model: PricingModel }) {
|
||||
const { t } = useTranslation()
|
||||
const model = props.model
|
||||
const modelIconKey = model.icon || model.vendor_icon
|
||||
const modelIcon = modelIconKey ? getLobeIcon(modelIconKey, 20) : null
|
||||
const modelIcon = modelIconKey ? getLobeIcon(modelIconKey, 28) : null
|
||||
const description = model.description || model.vendor_description || null
|
||||
const tags = parseTags(model.tags)
|
||||
const endpoints = normalizeCatalogItems(model.supported_endpoint_types)
|
||||
const isSpecialExpression =
|
||||
model.billing_mode === 'tiered_expr' &&
|
||||
Boolean(model.billing_expr) &&
|
||||
getDynamicPricingTiers(model).length === 0
|
||||
|
||||
return (
|
||||
<header className='pb-4'>
|
||||
<div className='flex items-center gap-2.5'>
|
||||
{modelIcon}
|
||||
<h1 className='font-mono text-xl font-bold tracking-tight sm:text-2xl'>
|
||||
{model.model_name}
|
||||
</h1>
|
||||
<CopyButton
|
||||
value={model.model_name || ''}
|
||||
size='icon-xs'
|
||||
iconClassName='size-3'
|
||||
tooltip={t('Copy model name')}
|
||||
successTooltip={t('Copied!')}
|
||||
aria-label={t('Copy model name')}
|
||||
/>
|
||||
</div>
|
||||
<div className='mt-1 flex flex-wrap items-center gap-1.5 text-xs'>
|
||||
{model.vendor_name && (
|
||||
<span className='text-muted-foreground'>{model.vendor_name}</span>
|
||||
)}
|
||||
<span className='text-muted-foreground/30'>·</span>
|
||||
<span className='text-muted-foreground/70'>
|
||||
{model.quota_type === QUOTA_TYPE_VALUES.TOKEN
|
||||
? t('Token-based')
|
||||
: t('Per Request')}
|
||||
</span>
|
||||
{model.billing_mode === 'tiered_expr' && model.billing_expr && (
|
||||
<>
|
||||
<span className='text-muted-foreground/30'>·</span>
|
||||
<span className='bg-warning/15 text-warning rounded px-1.5 py-0.5 text-xs font-medium'>
|
||||
{isSpecialExpression
|
||||
? t('Special billing expression')
|
||||
: t('Dynamic Pricing')}
|
||||
<header className='space-y-5'>
|
||||
<div className='flex items-start gap-4'>
|
||||
<div className='bg-muted flex size-12 shrink-0 items-center justify-center rounded-xl'>
|
||||
{modelIcon || (
|
||||
<span className='text-muted-foreground text-base font-medium'>
|
||||
{model.model_name?.charAt(0).toUpperCase() || '?'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex min-w-0 flex-wrap items-center gap-2'>
|
||||
<h1 className='truncate font-mono text-lg font-semibold tracking-tight'>
|
||||
{model.model_name}
|
||||
</h1>
|
||||
<CopyButton
|
||||
value={model.model_name || ''}
|
||||
size='icon-xs'
|
||||
iconClassName='size-3'
|
||||
tooltip={t('Copy model name')}
|
||||
successTooltip={t('Copied!')}
|
||||
aria-label={t('Copy model name')}
|
||||
/>
|
||||
{model.billing_mode === 'tiered_expr' && model.billing_expr && (
|
||||
<StatusBadge variant='warning' size='md'>
|
||||
{isSpecialExpression
|
||||
? t('Special billing expression')
|
||||
: t('Dynamic Pricing')}
|
||||
</StatusBadge>
|
||||
)}
|
||||
</div>
|
||||
<div className='text-muted-foreground mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm'>
|
||||
{model.vendor_name && <span>{model.vendor_name}</span>}
|
||||
{model.vendor_name && (
|
||||
<span className='text-muted-foreground/40'>·</span>
|
||||
)}
|
||||
<span>
|
||||
{model.quota_type === QUOTA_TYPE_VALUES.TOKEN
|
||||
? t('Token-based')
|
||||
: t('Per Request')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{description && (
|
||||
<p className='text-muted-foreground mt-2 text-sm leading-relaxed'>
|
||||
<p className='text-muted-foreground max-w-3xl text-sm leading-relaxed'>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(tags.length > 0 || endpoints.length > 0) && (
|
||||
<div className='flex flex-wrap gap-1.5'>
|
||||
{tags.map((tag) => (
|
||||
<StatusBadge key={`tag-${tag}`} variant='neutral' size='md'>
|
||||
{tag}
|
||||
</StatusBadge>
|
||||
))}
|
||||
{endpoints.map((endpoint) => (
|
||||
<StatusBadge
|
||||
key={`endpoint-${endpoint}`}
|
||||
variant='neutral'
|
||||
size='md'
|
||||
>
|
||||
{endpoint}
|
||||
</StatusBadge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -653,16 +664,16 @@ function PriceSection(props: {
|
||||
if (dynamicSummary.isSpecialExpression) {
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle>{t('Base Price')}</SectionTitle>
|
||||
<div className='border-warning/30 bg-warning/10 rounded-lg border p-3'>
|
||||
<SectionTitle>{t('Pricing')}</SectionTitle>
|
||||
<div className='border-warning/30 bg-warning/10 rounded-xl border p-4'>
|
||||
<div className='text-warning text-sm font-medium'>
|
||||
{t('Special billing expression')}
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-1 text-xs'>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
{t('Unable to parse structured pricing')}
|
||||
</p>
|
||||
<div className='mt-3'>
|
||||
<div className='text-muted-foreground mb-1 text-xs font-medium tracking-wider uppercase'>
|
||||
<div className='text-muted-foreground mb-1 text-xs font-medium'>
|
||||
{t('Raw expression')}
|
||||
</div>
|
||||
<code className='text-muted-foreground bg-background/80 block max-h-28 overflow-auto rounded-md border px-2 py-1.5 font-mono text-xs break-all'>
|
||||
@@ -674,55 +685,37 @@ function PriceSection(props: {
|
||||
)
|
||||
}
|
||||
|
||||
const priceRows = [
|
||||
...dynamicSummary.primaryEntries,
|
||||
...dynamicSummary.secondaryEntries,
|
||||
]
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle>{t('Base Price')}</SectionTitle>
|
||||
{dynamicSummary.primaryEntries.length > 0 ? (
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
{dynamicSummary.primaryEntries.map((entry) => (
|
||||
<SectionTitle>{t('Pricing')}</SectionTitle>
|
||||
<div className='overflow-hidden rounded-xl border'>
|
||||
<div className='flex items-baseline justify-between gap-3 border-b px-4 py-3'>
|
||||
<span className='text-sm font-medium'>{t('Text tokens')}</span>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{t('Prices shown per')} {tokenUnitLabel} {t('tokens')}
|
||||
</span>
|
||||
</div>
|
||||
<div className='divide-y'>
|
||||
{priceRows.map((entry) => (
|
||||
<div
|
||||
key={entry.key}
|
||||
className='bg-muted/20 rounded-lg border p-3'
|
||||
className='flex items-baseline justify-between gap-4 px-4 py-3'
|
||||
>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{t(entry.shortLabel)}
|
||||
</div>
|
||||
<div className='text-foreground mt-1 text-base font-semibold tabular-nums'>
|
||||
</span>
|
||||
<span className='text-sm font-medium tabular-nums'>
|
||||
{entry.formatted}
|
||||
<span className='text-muted-foreground/40 ml-1 text-xs font-normal'>
|
||||
/ {tokenUnitLabel}
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
{t('Dynamic Pricing')}
|
||||
</p>
|
||||
)}
|
||||
{dynamicSummary.secondaryEntries.length > 0 && (
|
||||
<div className='bg-muted/20 mt-3 rounded-lg border px-3 py-2.5'>
|
||||
<div className='space-y-1.5'>
|
||||
{dynamicSummary.secondaryEntries.map((entry) => (
|
||||
<div
|
||||
key={entry.key}
|
||||
className='flex items-baseline justify-between gap-4'
|
||||
>
|
||||
<span className='text-muted-foreground/70 text-sm'>
|
||||
{t(entry.shortLabel)}
|
||||
</span>
|
||||
<span className='text-muted-foreground text-sm tabular-nums'>
|
||||
{entry.formatted}
|
||||
<span className='text-muted-foreground/40 ml-1 text-xs font-normal'>
|
||||
/ {tokenUnitLabel}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -730,77 +723,72 @@ function PriceSection(props: {
|
||||
if (!isTokenBased) {
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle>{t('Base Price')}</SectionTitle>
|
||||
<div className='flex items-baseline justify-between'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{t('Per request')}
|
||||
</span>
|
||||
<span className='text-foreground text-sm font-semibold tabular-nums'>
|
||||
{formatFixedPrice(
|
||||
props.model,
|
||||
baseGroupKey,
|
||||
props.showRechargePrice,
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
baseGroupRatioMap
|
||||
)}
|
||||
</span>
|
||||
<SectionTitle>{t('Pricing')}</SectionTitle>
|
||||
<div className='overflow-hidden rounded-xl border'>
|
||||
<div className='flex items-baseline justify-between gap-4 px-4 py-3'>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{t('Per request')}
|
||||
</span>
|
||||
<span className='text-sm font-medium tabular-nums'>
|
||||
{formatFixedPrice(
|
||||
props.model,
|
||||
baseGroupKey,
|
||||
props.showRechargePrice,
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
baseGroupRatioMap
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const secondaryItems = secondaryPriceTypes.filter((p) => p.available)
|
||||
const renderPrice = (type: PriceType) => (
|
||||
<>
|
||||
{formatGroupPrice(
|
||||
props.model,
|
||||
baseGroupKey,
|
||||
type,
|
||||
props.tokenUnit,
|
||||
props.showRechargePrice,
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
baseGroupRatioMap
|
||||
)}
|
||||
<span className='text-muted-foreground/40 ml-1 text-xs font-normal'>
|
||||
/ {tokenUnitLabel}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
const priceRows = [
|
||||
...primaryPriceTypes,
|
||||
...secondaryItems.map((item) => ({
|
||||
label: item.label,
|
||||
type: item.type,
|
||||
})),
|
||||
]
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle>{t('Base Price')}</SectionTitle>
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
{primaryPriceTypes.map((item) => (
|
||||
<div key={item.type} className='bg-muted/20 rounded-lg border p-3'>
|
||||
<div className='text-muted-foreground text-xs'>{item.label}</div>
|
||||
<div className='text-foreground mt-1 text-base font-semibold tabular-nums'>
|
||||
{renderPrice(item.type)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{secondaryItems.length > 0 && (
|
||||
<div className='bg-muted/20 mt-3 rounded-lg border px-3 py-2.5'>
|
||||
<div className='space-y-1.5'>
|
||||
{secondaryItems.map((item) => (
|
||||
<div
|
||||
key={item.type}
|
||||
className='flex items-baseline justify-between gap-4'
|
||||
>
|
||||
<span className='text-muted-foreground/70 text-sm'>
|
||||
{item.label}
|
||||
</span>
|
||||
<span className='text-muted-foreground text-sm tabular-nums'>
|
||||
{renderPrice(item.type)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<SectionTitle>{t('Pricing')}</SectionTitle>
|
||||
<div className='overflow-hidden rounded-xl border'>
|
||||
<div className='flex items-baseline justify-between gap-3 border-b px-4 py-3'>
|
||||
<span className='text-sm font-medium'>{t('Text tokens')}</span>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{t('Prices shown per')} {tokenUnitLabel} {t('tokens')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className='divide-y'>
|
||||
{priceRows.map((item) => (
|
||||
<div
|
||||
key={item.type}
|
||||
className='flex items-baseline justify-between gap-4 px-4 py-3'
|
||||
>
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
{item.label}
|
||||
</span>
|
||||
<span className='text-sm font-medium tabular-nums'>
|
||||
{formatGroupPrice(
|
||||
props.model,
|
||||
baseGroupKey,
|
||||
item.type,
|
||||
props.tokenUnit,
|
||||
props.showRechargePrice,
|
||||
props.priceRate,
|
||||
props.usdExchangeRate,
|
||||
baseGroupRatioMap
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -844,13 +832,13 @@ function getDynamicPriceFields(
|
||||
tiers: DynamicPricingTier[],
|
||||
options: DynamicPriceOptions
|
||||
) {
|
||||
return Array.from(
|
||||
new Map(
|
||||
return [
|
||||
...new Map(
|
||||
tiers
|
||||
.flatMap((tier) => getDynamicPriceEntries(tier, options))
|
||||
.map((entry) => [entry.field, entry])
|
||||
).values()
|
||||
)
|
||||
).values(),
|
||||
]
|
||||
}
|
||||
|
||||
function getDynamicFormattedPricesByTier(
|
||||
@@ -897,19 +885,24 @@ function GroupPricingSection(props: {
|
||||
|
||||
const extraPriceTypes = useMemo(() => {
|
||||
const types: { label: string; type: PriceType }[] = []
|
||||
if (props.model.cache_ratio != null)
|
||||
if (props.model.cache_ratio != null) {
|
||||
types.push({ label: t('Cache'), type: 'cache' })
|
||||
if (props.model.create_cache_ratio != null)
|
||||
}
|
||||
if (props.model.create_cache_ratio != null) {
|
||||
types.push({ label: t('Cache Write'), type: 'create_cache' })
|
||||
if (props.model.image_ratio != null)
|
||||
}
|
||||
if (props.model.image_ratio != null) {
|
||||
types.push({ label: t('Image'), type: 'image' })
|
||||
if (props.model.audio_ratio != null)
|
||||
}
|
||||
if (props.model.audio_ratio != null) {
|
||||
types.push({ label: t('Audio In'), type: 'audio_input' })
|
||||
}
|
||||
if (
|
||||
props.model.audio_ratio != null &&
|
||||
props.model.audio_completion_ratio != null
|
||||
)
|
||||
) {
|
||||
types.push({ label: t('Audio Out'), type: 'audio_output' })
|
||||
}
|
||||
return types
|
||||
}, [props.model, t])
|
||||
|
||||
@@ -927,8 +920,7 @@ function GroupPricingSection(props: {
|
||||
)
|
||||
}
|
||||
|
||||
const thClass =
|
||||
'text-muted-foreground py-2 text-xs font-medium tracking-wider uppercase'
|
||||
const thClass = 'text-muted-foreground py-2 text-xs font-medium'
|
||||
|
||||
if (isDynamicPricingModel(props.model)) {
|
||||
const dynamicTiers = getDynamicPricingTiers(props.model)
|
||||
@@ -948,7 +940,7 @@ function GroupPricingSection(props: {
|
||||
)}
|
||||
</p>
|
||||
<div className='mt-3'>
|
||||
<div className='text-muted-foreground mb-1 text-xs font-medium tracking-wider uppercase'>
|
||||
<div className='text-muted-foreground mb-1 text-xs font-medium'>
|
||||
{t('Raw expression')}
|
||||
</div>
|
||||
<code className='text-muted-foreground bg-background/80 block max-h-28 overflow-auto rounded-md border px-2 py-1.5 font-mono text-xs break-all'>
|
||||
@@ -1166,53 +1158,52 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) {
|
||||
Boolean(props.model.billing_expr)
|
||||
|
||||
return (
|
||||
<div className='@container/details space-y-4'>
|
||||
<div className='@container/details space-y-8'>
|
||||
<ModelHeader model={props.model} />
|
||||
<ModelBackendQuickStats model={props.model} />
|
||||
<OverviewSummaryGrid model={props.model} />
|
||||
|
||||
<Tabs defaultValue='overview' className='gap-4'>
|
||||
<TabsList className='bg-muted/60 grid w-full grid-cols-3 gap-1 rounded-lg p-1 group-data-horizontal/tabs:h-auto sm:group-data-horizontal/tabs:h-auto'>
|
||||
<Tabs defaultValue='overview' className='gap-6'>
|
||||
<TabsList
|
||||
variant='line'
|
||||
className='w-full justify-start gap-6 overflow-x-auto overflow-y-hidden border-b p-0'
|
||||
>
|
||||
{TAB_VALUES.map((value) => {
|
||||
const Icon = TAB_META[value].icon
|
||||
return (
|
||||
<TabsTrigger
|
||||
key={value}
|
||||
value={value}
|
||||
className='h-8 min-w-0 gap-1.5 rounded-md px-3 text-xs sm:text-sm'
|
||||
className='flex-none gap-1.5 px-0.5 pb-3'
|
||||
>
|
||||
<Icon className='size-3.5' />
|
||||
<Icon aria-hidden='true' className='size-3.5' />
|
||||
<span className='truncate'>{t(TAB_META[value].labelKey)}</span>
|
||||
</TabsTrigger>
|
||||
)
|
||||
})}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='overview' className='space-y-6 outline-none'>
|
||||
<OverviewSummaryGrid model={props.model} />
|
||||
|
||||
<section className='bg-card/60 space-y-5 rounded-xl border p-4 shadow-sm'>
|
||||
<SectionTitle>{t('Pricing')}</SectionTitle>
|
||||
<PriceSection
|
||||
model={props.model}
|
||||
priceRate={props.priceRate}
|
||||
usdExchangeRate={props.usdExchangeRate}
|
||||
tokenUnit={props.tokenUnit}
|
||||
showRechargePrice={showRechargePrice}
|
||||
/>
|
||||
{isDynamic && (
|
||||
<DynamicPricingBreakdown billingExpr={props.model.billing_expr} />
|
||||
)}
|
||||
<GroupPricingSection
|
||||
model={props.model}
|
||||
groupRatio={props.groupRatio}
|
||||
usableGroup={props.usableGroup}
|
||||
autoGroups={props.autoGroups}
|
||||
priceRate={props.priceRate}
|
||||
usdExchangeRate={props.usdExchangeRate}
|
||||
tokenUnit={props.tokenUnit}
|
||||
showRechargePrice={showRechargePrice}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<TabsContent value='overview' className='space-y-8 outline-none'>
|
||||
<PriceSection
|
||||
model={props.model}
|
||||
priceRate={props.priceRate}
|
||||
usdExchangeRate={props.usdExchangeRate}
|
||||
tokenUnit={props.tokenUnit}
|
||||
showRechargePrice={showRechargePrice}
|
||||
/>
|
||||
{isDynamic && (
|
||||
<DynamicPricingBreakdown billingExpr={props.model.billing_expr} />
|
||||
)}
|
||||
<GroupPricingSection
|
||||
model={props.model}
|
||||
groupRatio={props.groupRatio}
|
||||
usableGroup={props.usableGroup}
|
||||
autoGroups={props.autoGroups}
|
||||
priceRate={props.priceRate}
|
||||
usdExchangeRate={props.usdExchangeRate}
|
||||
tokenUnit={props.tokenUnit}
|
||||
showRechargePrice={showRechargePrice}
|
||||
/>
|
||||
<ModelBackendDetailsSection model={props.model} />
|
||||
</TabsContent>
|
||||
|
||||
@@ -1231,39 +1222,6 @@ export function ModelDetailsContent(props: ModelDetailsContentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Drawer & page wrappers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export interface ModelDetailsDrawerProps extends ModelDetailsContentProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function ModelDetailsDrawer(props: ModelDetailsDrawerProps) {
|
||||
const { t } = useTranslation()
|
||||
const { open, onOpenChange, ...contentProps } = props
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side='right'
|
||||
className={sideDrawerContentClassName(
|
||||
'sm:max-w-2xl lg:max-w-3xl xl:max-w-4xl 2xl:max-w-5xl'
|
||||
)}
|
||||
>
|
||||
<SheetHeader className='sr-only'>
|
||||
<SheetTitle>{props.model.model_name}</SheetTitle>
|
||||
<SheetDescription>{t('Model details')}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className='flex-1 overflow-y-auto px-4 pt-11 pb-5 sm:px-6 sm:pt-12 sm:pb-6'>
|
||||
<ModelDetailsContent {...contentProps} />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
export function ModelDetails() {
|
||||
const { t } = useTranslation()
|
||||
const { modelId } = useParams({ from: '/pricing/$modelId/' })
|
||||
@@ -1295,8 +1253,8 @@ export function ModelDetails() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PublicLayout>
|
||||
<div className='mx-auto max-w-5xl px-4 sm:px-6'>
|
||||
<PublicLayout showMainContainer={false}>
|
||||
<div className='mx-auto max-w-6xl px-4 pt-20 pb-10 sm:px-6 lg:px-8'>
|
||||
<Skeleton className='mb-4 h-5 w-16' />
|
||||
<div className='space-y-2'>
|
||||
<Skeleton className='h-7 w-64' />
|
||||
@@ -1304,13 +1262,13 @@ export function ModelDetails() {
|
||||
<Skeleton className='h-4 w-full max-w-md' />
|
||||
</div>
|
||||
<div className='mt-6 grid grid-cols-2 gap-2 sm:grid-cols-4'>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className='h-16 w-full' />
|
||||
{['stats-a', 'stats-b', 'stats-c', 'stats-d'].map((key) => (
|
||||
<Skeleton key={key} className='h-16 w-full' />
|
||||
))}
|
||||
</div>
|
||||
<div className='mt-6 space-y-3'>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className='h-24 w-full' />
|
||||
{['block-a', 'block-b', 'block-c', 'block-d'].map((key) => (
|
||||
<Skeleton key={key} className='h-24 w-full' />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1320,8 +1278,8 @@ export function ModelDetails() {
|
||||
|
||||
if (!model) {
|
||||
return (
|
||||
<PublicLayout>
|
||||
<div className='mx-auto max-w-2xl px-4 text-center sm:px-6'>
|
||||
<PublicLayout showMainContainer={false}>
|
||||
<div className='mx-auto max-w-2xl px-4 pt-24 pb-10 text-center sm:px-6'>
|
||||
<h2 className='mb-1 text-base font-semibold'>
|
||||
{t('Model not found')}
|
||||
</h2>
|
||||
@@ -1337,14 +1295,14 @@ export function ModelDetails() {
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicLayout>
|
||||
<div className='mx-auto max-w-5xl px-4 sm:px-6'>
|
||||
<PublicLayout showMainContainer={false}>
|
||||
<PageTransition className='mx-auto max-w-5xl px-4 pt-20 pb-16 sm:px-6 lg:px-8'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={handleBack}
|
||||
className='text-muted-foreground hover:text-foreground mb-4 h-auto gap-1 px-0 py-1 text-xs sm:h-auto'
|
||||
className='text-muted-foreground hover:text-foreground mb-8 -ml-2'
|
||||
>
|
||||
<ArrowLeft className='size-3.5' />
|
||||
<ArrowLeft aria-hidden='true' />
|
||||
{t('Back')}
|
||||
</Button>
|
||||
|
||||
@@ -1364,7 +1322,7 @@ export function ModelDetails() {
|
||||
>) || {}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</PageTransition>
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user