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
Docker Build / Build and Push Docker Image (push) Successful in 4m21s
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user