feat: redesign pricing page - move filters/search to top, remove sidebar, match homepage layout
Docker Build / Build and Push Docker Image (push) Successful in 4m21s

This commit is contained in:
2026-06-14 20:51:14 +08:00
parent 7caf77db63
commit a4c069f88d
3 changed files with 350 additions and 73 deletions
@@ -0,0 +1,264 @@
/*
Copyright (C) 2023-2026 modelstoken
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact admin@modelstoken.com
*/
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { getLobeIcon } from '@/lib/lobe-icon'
import {
ENDPOINT_TYPES,
FILTER_ALL,
QUOTA_TYPES,
getEndpointTypeLabels,
getQuotaTypeLabels,
} from '../constants'
import { parseTags } from '../lib/filters'
import type { PricingModel, PricingVendor } from '../types'
type FilterChipsProps = {
vendors: PricingVendor[]
groups: string[]
groupRatios?: Record<string, number>
tags: string[]
models: PricingModel[]
vendorFilter: string
groupFilter: string
quotaTypeFilter: string
endpointTypeFilter: string
tagFilter: string
onVendorChange: (value: string) => void
onGroupChange: (value: string) => void
onQuotaTypeChange: (value: string) => void
onEndpointTypeChange: (value: string) => void
onTagChange: (value: string) => void
}
function countBy(
models: PricingModel[],
predicate: (model: PricingModel) => boolean
): number {
return models.reduce((count, model) => count + (predicate(model) ? 1 : 0), 0)
}
function formatGroupRatio(ratio: number | undefined): string | undefined {
if (ratio == null) return undefined
const formatted = Number.isInteger(ratio)
? ratio.toString()
: ratio.toFixed(3).replace(/0+$/, '').replace(/\.$/, '')
return `x${formatted}`
}
function Chip(props: {
label: string
active: boolean
onClick: () => void
suffix?: string
count?: number
icon?: React.ReactNode
}) {
return (
<button
type='button'
onClick={props.onClick}
className={cn(
'inline-flex max-w-full items-center gap-1 rounded-md border px-2 py-1 text-xs font-medium transition-all',
props.active
? 'border-primary/40 bg-primary/10 text-primary'
: 'border-border/70 bg-background text-muted-foreground hover:border-border hover:bg-muted/50 hover:text-foreground'
)}
title={props.label}
>
{props.icon && <span className='shrink-0'>{props.icon}</span>}
<span className='truncate'>{props.label}</span>
{(props.suffix || props.count != null) && (
<span
className={cn(
'rounded px-1 py-0.5 text-[10px]',
props.active
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
)}
>
{props.suffix ?? props.count}
</span>
)}
</button>
)
}
function ChipGroup(props: {
label: string
children: React.ReactNode
}) {
return (
<div className='flex items-center gap-1.5'>
<span className='text-muted-foreground/60 shrink-0 text-xs font-medium'>
{props.label}:
</span>
<div className='flex flex-wrap items-center gap-1'>{props.children}</div>
</div>
)
}
export function FilterChips(props: FilterChipsProps) {
const { t } = useTranslation()
const quotaTypeLabels = getQuotaTypeLabels(t)
const endpointTypeLabels = getEndpointTypeLabels(t)
const vendorOptions = [
{ value: FILTER_ALL, label: t('All'), count: props.models.length },
...props.vendors
.map((vendor) => ({
value: vendor.name,
label: vendor.name,
count: countBy(
props.models,
(model) => model.vendor_name === vendor.name
),
icon: vendor.icon ? getLobeIcon(vendor.icon, 12) : undefined,
}))
.filter((v) => v.count > 0),
]
const groupOptions = [
{ value: FILTER_ALL, label: t('All') },
...props.groups.map((group) => ({
value: group,
label: group,
suffix: formatGroupRatio(props.groupRatios?.[group]),
})),
]
const quotaOptions = [
{
value: QUOTA_TYPES.ALL,
label: t('All'),
count: props.models.length,
},
{
value: QUOTA_TYPES.TOKEN,
label: quotaTypeLabels[QUOTA_TYPES.TOKEN],
count: countBy(props.models, (model) => model.quota_type === 0),
},
{
value: QUOTA_TYPES.REQUEST,
label: quotaTypeLabels[QUOTA_TYPES.REQUEST],
count: countBy(props.models, (model) => model.quota_type === 1),
},
]
const tagOptions = [
{ value: FILTER_ALL, label: t('All'), count: props.models.length },
...props.tags.map((tag) => ({
value: tag,
label: tag,
count: countBy(props.models, (model) =>
parseTags(model.tags)
.map((item) => item.toLowerCase())
.includes(tag.toLowerCase())
),
})),
]
const endpointOptions = [
{
value: ENDPOINT_TYPES.ALL,
label: t('All'),
count: props.models.length,
},
...Object.entries(endpointTypeLabels)
.filter(([value]) => value !== ENDPOINT_TYPES.ALL)
.map(([value, label]) => ({
value,
label,
count: countBy(
props.models,
(model) => model.supported_endpoint_types?.includes(value) ?? false
),
}))
.filter((opt) => opt.count > 0),
]
return (
<div className='flex flex-wrap items-center gap-x-4 gap-y-2'>
<ChipGroup label={t('Vendor')}>
{vendorOptions.map((opt) => (
<Chip
key={opt.value}
label={opt.label}
active={props.vendorFilter === opt.value}
onClick={() => props.onVendorChange(opt.value)}
count={opt.count}
icon={opt.icon}
/>
))}
</ChipGroup>
<ChipGroup label={t('Group')}>
{groupOptions.map((opt) => (
<Chip
key={opt.value}
label={opt.label}
active={props.groupFilter === opt.value}
onClick={() => props.onGroupChange(opt.value)}
suffix={opt.suffix}
/>
))}
</ChipGroup>
<ChipGroup label={t('Type')}>
{quotaOptions.map((opt) => (
<Chip
key={opt.value}
label={opt.label}
active={props.quotaTypeFilter === opt.value}
onClick={() => props.onQuotaTypeChange(opt.value)}
count={opt.count}
/>
))}
</ChipGroup>
{endpointOptions.length > 2 && (
<ChipGroup label={t('Endpoint')}>
{endpointOptions.map((opt) => (
<Chip
key={opt.value}
label={opt.label}
active={props.endpointTypeFilter === opt.value}
onClick={() => props.onEndpointTypeChange(opt.value)}
count={opt.count}
/>
))}
</ChipGroup>
)}
{tagOptions.length > 2 && (
<ChipGroup label={t('Tag')}>
{tagOptions.map((opt) => (
<Chip
key={opt.value}
label={opt.label}
active={props.tagFilter === opt.value}
onClick={() => props.onTagChange(opt.value)}
count={opt.count}
/>
))}
</ChipGroup>
)}
</div>
)
}
+1
View File
@@ -18,6 +18,7 @@ For commercial licensing, please contact admin@modelstoken.com
*/
export { PricingSidebar } from './pricing-sidebar'
export { PricingToolbar } from './pricing-toolbar'
export { FilterChips } from './filter-chips'
export { ModelCard } from './model-card'
export { ModelCardGrid } from './model-card-grid'
export { LoadingSkeleton } from './loading-skeleton'
+85 -73
View File
@@ -18,21 +18,22 @@ For commercial licensing, please contact admin@modelstoken.com
*/
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RotateCcw } from 'lucide-react'
import { PublicLayout } from '@/components/layout'
import { PageTransition } from '@/components/page-transition'
import { Button } from '@/components/ui/button'
import {
LoadingSkeleton,
EmptyState,
SearchBar,
PricingTable,
PricingSidebar,
PricingToolbar,
ModelCardGrid,
ModelDetailsDrawer,
} from './components'
import { EXCLUDED_GROUPS, VIEW_MODES } from './constants'
import { useFilters } from './hooks/use-filters'
import { usePricingData } from './hooks/use-pricing-data'
import { FilterChips } from './components/filter-chips'
export function Pricing() {
const { t } = useTranslation()
@@ -147,7 +148,7 @@ export function Pricing() {
if (isLoading) {
return (
<PublicLayout showMainContainer={false}>
<div className='mx-auto w-full max-w-[1800px] px-3 pt-16 pb-8 sm:px-6 sm:pt-20 sm:pb-10 xl:px-8'>
<div className='mx-auto w-full max-w-6xl px-4 pt-20 pb-8'>
<LoadingSkeleton viewMode={viewMode} />
</div>
</PublicLayout>
@@ -159,34 +160,31 @@ export function Pricing() {
<div className='relative'>
<div
aria-hidden
className='pointer-events-none absolute inset-x-0 top-0 h-[600px] opacity-20 dark:opacity-[0.10]'
className='pointer-events-none absolute inset-x-0 top-0 h-[400px] opacity-15 dark:opacity-[0.08]'
style={{
background: [
'radial-gradient(ellipse 60% 50% at 20% 20%, oklch(0.72 0.18 250 / 80%) 0%, transparent 70%)',
'radial-gradient(ellipse 50% 40% at 80% 15%, oklch(0.65 0.15 200 / 60%) 0%, transparent 70%)',
'radial-gradient(ellipse 40% 35% at 50% 70%, oklch(0.70 0.12 280 / 40%) 0%, transparent 70%)',
].join(', '),
background:
'radial-gradient(ellipse 60% 50% at 50% 0%, oklch(0.72 0.18 250 / 80%) 0%, transparent 70%)',
maskImage:
'linear-gradient(to bottom, black 40%, transparent 100%)',
'linear-gradient(to bottom, black 30%, transparent 100%)',
WebkitMaskImage:
'linear-gradient(to bottom, black 40%, transparent 100%)',
'linear-gradient(to bottom, black 30%, transparent 100%)',
}}
/>
<PageTransition className='relative mx-auto w-full max-w-[1800px] px-3 pt-16 pb-8 sm:px-6 sm:pt-20 sm:pb-10 xl:px-8'>
<header className='mx-auto mb-5 max-w-3xl pt-5 text-center sm:mb-10 sm:pt-10'>
<h1 className='text-[clamp(2rem,5.5vw,3.5rem)] leading-[1.15] font-bold tracking-tight'>
<PageTransition className='relative mx-auto w-full max-w-6xl px-4 pt-20 pb-12'>
{/* Header */}
<header className='mb-6'>
<h1 className='text-2xl font-bold tracking-tight sm:text-3xl'>
{t('Model Square')}
</h1>
<p className='text-muted-foreground/80 mt-3 text-sm sm:mt-4 sm:text-base'>
<p className='text-muted-foreground mt-1.5 text-sm'>
{t('This site currently has {{count}} models enabled', {
count: models?.length || 0,
})}
</p>
<p className='text-muted-foreground/60 mx-auto mt-2 max-w-2xl text-xs leading-relaxed sm:text-sm'>
{t(
'Discover curated AI models, compare pricing and capabilities, and choose the right model for every scenario.'
)}
</p>
</header>
{/* Search + Filters */}
<div className='mb-4 space-y-3'>
<SearchBar
value={searchInput}
onChange={setSearchInput}
@@ -194,68 +192,82 @@ export function Pricing() {
placeholder={t(
'Search model name, provider, endpoint, or tag...'
)}
className='mx-auto mt-4 max-w-2xl sm:mt-6'
/>
</header>
<div className='grid gap-4 xl:grid-cols-[330px_minmax(0,1fr)]'>
<PricingSidebar
quotaTypeFilter={quotaTypeFilter}
endpointTypeFilter={endpointTypeFilter}
vendorFilter={vendorFilter}
groupFilter={groupFilter}
tagFilter={tagFilter}
onQuotaTypeChange={setQuotaTypeFilter}
onEndpointTypeChange={setEndpointTypeFilter}
onVendorChange={setVendorFilter}
onGroupChange={setGroupFilter}
onTagChange={setTagFilter}
<FilterChips
vendors={vendors || []}
groups={availableGroups}
groupRatios={groupRatio}
tags={availableTags}
models={models || []}
hasActiveFilters={hasActiveFilters}
onClearFilters={clearFilters}
className='hover-scrollbar sticky top-4 hidden max-h-[calc(100dvh-2rem)] self-start overflow-y-auto xl:block'
vendorFilter={vendorFilter}
groupFilter={groupFilter}
quotaTypeFilter={quotaTypeFilter}
endpointTypeFilter={endpointTypeFilter}
tagFilter={tagFilter}
onVendorChange={setVendorFilter}
onGroupChange={setGroupFilter}
onQuotaTypeChange={setQuotaTypeFilter}
onEndpointTypeChange={setEndpointTypeFilter}
onTagChange={setTagFilter}
/>
<main className='min-w-0 space-y-4'>
<PricingToolbar
filteredCount={filteredModels.length}
totalCount={models?.length}
sortBy={sortBy}
onSortChange={setSortBy}
tokenUnit={tokenUnit}
onTokenUnitChange={setTokenUnit}
showRechargePrice={showRechargePrice}
onRechargePriceChange={setShowRechargePrice}
viewMode={viewMode}
onViewModeChange={setViewMode}
quotaTypeFilter={quotaTypeFilter}
endpointTypeFilter={endpointTypeFilter}
vendorFilter={vendorFilter}
groupFilter={groupFilter}
tagFilter={tagFilter}
onQuotaTypeChange={setQuotaTypeFilter}
onEndpointTypeChange={setEndpointTypeFilter}
onVendorChange={setVendorFilter}
onGroupChange={setGroupFilter}
onTagChange={setTagFilter}
vendors={vendors || []}
groups={availableGroups}
groupRatios={groupRatio}
tags={availableTags}
models={models || []}
hasActiveFilters={hasActiveFilters}
activeFilterCount={activeFilterCount}
onClearFilters={clearFilters}
/>
{renderPricingContent()}
</main>
</div>
{/* Toolbar row */}
<div className='mb-4 flex items-center justify-between gap-3'>
<div className='flex items-center gap-2'>
<span className='text-muted-foreground text-sm'>
<span className='text-foreground font-semibold tabular-nums'>
{filteredModels.length.toLocaleString()}
</span>{' '}
{filteredModels.length === 1 ? t('model') : t('models')}
</span>
{hasActiveFilters && (
<Button
variant='ghost'
size='sm'
onClick={handleClearAll}
className='h-7 gap-1 px-2 text-xs'
>
<RotateCcw className='size-3' />
{t('Reset')}
</Button>
)}
</div>
<div className='flex items-center gap-2'>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className='border-border bg-background h-8 rounded-md border px-2 text-xs outline-none'
>
<option value='name'>{t('Name')}</option>
<option value='price_low'>{t('Price: Low to High')}</option>
<option value='price_high'>{t('Price: High to Low')}</option>
</select>
<select
value={tokenUnit}
onChange={(e) => setTokenUnit(e.target.value as 'M' | 'K')}
className='border-border bg-background h-8 rounded-md border px-2 text-xs outline-none'
>
<option value='M'>/1M</option>
<option value='K'>/1K</option>
</select>
<select
value={showRechargePrice ? 'recharge' : 'standard'}
onChange={(e) =>
setShowRechargePrice(e.target.value === 'recharge')
}
className='border-border bg-background h-8 rounded-md border px-2 text-xs outline-none'
>
<option value='standard'>{t('Standard')}</option>
<option value='recharge'>{t('Recharge')}</option>
</select>
</div>
</div>
{/* Content */}
{renderPricingContent()}
{/* Model Details Drawer */}
{selectedModel && (
<ModelDetailsDrawer
open={Boolean(selectedModel)}