feat(ui): redesign model square pricing page
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import {
|
||||
ArrowUpDown,
|
||||
Check,
|
||||
Filter,
|
||||
Grid2X2,
|
||||
Table2,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import {
|
||||
VIEW_MODES,
|
||||
getSortLabels,
|
||||
type SortOption,
|
||||
type ViewMode,
|
||||
} from '../constants'
|
||||
import type { PricingModel, PricingVendor, TokenUnit } from '../types'
|
||||
import { PricingSidebar } from './pricing-sidebar'
|
||||
|
||||
type SegmentOption = {
|
||||
value: string
|
||||
label?: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
export interface PricingToolbarProps {
|
||||
filteredCount: number
|
||||
totalCount?: number
|
||||
sortBy: string
|
||||
onSortChange: (value: string) => void
|
||||
tokenUnit: TokenUnit
|
||||
onTokenUnitChange: (value: TokenUnit) => void
|
||||
showRechargePrice: boolean
|
||||
onRechargePriceChange: (value: boolean) => void
|
||||
viewMode: ViewMode
|
||||
onViewModeChange: (value: ViewMode) => void
|
||||
quotaTypeFilter: string
|
||||
endpointTypeFilter: string
|
||||
vendorFilter: string
|
||||
groupFilter: string
|
||||
tagFilter: string
|
||||
onQuotaTypeChange: (value: string) => void
|
||||
onEndpointTypeChange: (value: string) => void
|
||||
onVendorChange: (value: string) => void
|
||||
onGroupChange: (value: string) => void
|
||||
onTagChange: (value: string) => void
|
||||
vendors: PricingVendor[]
|
||||
groups: string[]
|
||||
groupRatios?: Record<string, number>
|
||||
tags: string[]
|
||||
models: PricingModel[]
|
||||
hasActiveFilters: boolean
|
||||
activeFilterCount: number
|
||||
onClearFilters: () => void
|
||||
}
|
||||
|
||||
function SegmentedControl(props: {
|
||||
options: SegmentOption[]
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
ariaLabel: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role='group'
|
||||
aria-label={props.ariaLabel}
|
||||
className='bg-muted/60 inline-flex h-8 items-center rounded-md border p-0.5'
|
||||
>
|
||||
{props.options.map((option) => {
|
||||
const Icon = option.icon
|
||||
const isActive = option.value === props.value
|
||||
const button = (
|
||||
<button
|
||||
key={option.value}
|
||||
type='button'
|
||||
onClick={() => props.onChange(option.value)}
|
||||
aria-pressed={isActive}
|
||||
className={cn(
|
||||
'inline-flex h-full items-center justify-center rounded-[5px] text-xs font-medium transition-all',
|
||||
Icon && !option.label ? 'w-7' : 'gap-1.5 px-3',
|
||||
isActive
|
||||
? 'bg-foreground text-background shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon className='size-3.5' />}
|
||||
{option.label}
|
||||
</button>
|
||||
)
|
||||
|
||||
if (!option.tooltip) {
|
||||
return button
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip key={option.value}>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent side='bottom' className='text-xs'>
|
||||
{option.tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PricingToolbar(props: PricingToolbarProps) {
|
||||
const { t } = useTranslation()
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false)
|
||||
const sortLabels = getSortLabels(t)
|
||||
|
||||
const handleTokenUnitChange = useCallback(
|
||||
(value: string) => props.onTokenUnitChange(value as TokenUnit),
|
||||
[props]
|
||||
)
|
||||
|
||||
const handleViewModeChange = useCallback(
|
||||
(value: string) => props.onViewModeChange(value as ViewMode),
|
||||
[props]
|
||||
)
|
||||
|
||||
const handleRechargePriceChange = useCallback(
|
||||
(value: string) => props.onRechargePriceChange(value === 'recharge'),
|
||||
[props]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='rounded-xl border p-3'>
|
||||
<div className='flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setMobileFiltersOpen(true)}
|
||||
className='gap-1.5 xl:hidden'
|
||||
>
|
||||
<Filter className='size-4' />
|
||||
{t('Filter')}
|
||||
{props.activeFilterCount > 0 && (
|
||||
<Badge className='ml-0.5 size-5 justify-center rounded-full p-0 text-[10px]'>
|
||||
{props.activeFilterCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className='text-muted-foreground flex items-baseline gap-1 text-sm'>
|
||||
<span className='text-foreground font-semibold tabular-nums'>
|
||||
{props.filteredCount.toLocaleString()}
|
||||
</span>
|
||||
<span>
|
||||
{props.filteredCount === 1 ? t('model') : t('models')}
|
||||
</span>
|
||||
{props.hasActiveFilters && props.totalCount && (
|
||||
<span className='text-muted-foreground/60 text-xs'>
|
||||
/ {props.totalCount.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<div className='hidden items-center gap-2 sm:flex'>
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ value: 'standard', label: t('Standard') },
|
||||
{ value: 'recharge', label: t('Recharge') },
|
||||
]}
|
||||
value={props.showRechargePrice ? 'recharge' : 'standard'}
|
||||
onChange={handleRechargePriceChange}
|
||||
ariaLabel={t('Price display mode')}
|
||||
/>
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ value: 'M', label: '/1M' },
|
||||
{ value: 'K', label: '/1K' },
|
||||
]}
|
||||
value={props.tokenUnit}
|
||||
onChange={handleTokenUnitChange}
|
||||
ariaLabel={t('Token unit')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-8 gap-1.5 px-3 text-xs'
|
||||
>
|
||||
<ArrowUpDown className='size-3.5' />
|
||||
<span>
|
||||
{sortLabels[props.sortBy as SortOption] || t('Sort')}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-44'>
|
||||
{Object.entries(sortLabels).map(([value, label]) => (
|
||||
<DropdownMenuItem
|
||||
key={value}
|
||||
onClick={() => props.onSortChange(value)}
|
||||
className='gap-2'
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'size-4 shrink-0',
|
||||
props.sortBy === value ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
{label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{
|
||||
value: VIEW_MODES.CARD,
|
||||
icon: Grid2X2,
|
||||
tooltip: t('Card view'),
|
||||
},
|
||||
{
|
||||
value: VIEW_MODES.TABLE,
|
||||
icon: Table2,
|
||||
tooltip: t('Table view'),
|
||||
},
|
||||
]}
|
||||
value={props.viewMode}
|
||||
onChange={handleViewModeChange}
|
||||
ariaLabel={t('View mode')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Sheet open={mobileFiltersOpen} onOpenChange={setMobileFiltersOpen}>
|
||||
<SheetContent
|
||||
side='right'
|
||||
className='flex w-full flex-col overflow-hidden p-0 sm:max-w-md'
|
||||
>
|
||||
<SheetHeader className='border-b px-6 py-4'>
|
||||
<SheetTitle>{t('Filter')}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{t('Filter models by provider, group, type, endpoint, and tags.')}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className='flex-1 overflow-y-auto p-4'>
|
||||
<PricingSidebar
|
||||
quotaTypeFilter={props.quotaTypeFilter}
|
||||
endpointTypeFilter={props.endpointTypeFilter}
|
||||
vendorFilter={props.vendorFilter}
|
||||
groupFilter={props.groupFilter}
|
||||
tagFilter={props.tagFilter}
|
||||
onQuotaTypeChange={props.onQuotaTypeChange}
|
||||
onEndpointTypeChange={props.onEndpointTypeChange}
|
||||
onVendorChange={props.onVendorChange}
|
||||
onGroupChange={props.onGroupChange}
|
||||
onTagChange={props.onTagChange}
|
||||
vendors={props.vendors}
|
||||
groups={props.groups}
|
||||
groupRatios={props.groupRatios}
|
||||
tags={props.tags}
|
||||
models={props.models}
|
||||
hasActiveFilters={props.hasActiveFilters}
|
||||
onClearFilters={props.onClearFilters}
|
||||
className='border-0 bg-transparent p-0 shadow-none'
|
||||
/>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user