mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement(landing): SSR-friendly URL-state filters + cleanup pass + polish (#5298)
* chore(landing): cleanup pass — size-* shorthand, drop redundant refs/memos, changelog useInfiniteQuery
- emcn: collapse 29 square h-N w-N pairs to size-* across models/integrations/blog/faq/landing-preview
- landing-preview: remove redundant animationKeyRef (functional setState) + dead isDesktopRef
- model-directory: hoist static provider options to a module const
- auth-modal: drop useMemo over the cheap getBrandConfig() call
- changelog: replace useState+fetch pagination with a co-located useInfiniteQuery hook (first page stays SSR-seeded via initialData)
* feat(landing): URL-state filters via nuqs (SSR-friendly) + stars fallback bump
- integrations grid, models directory: search + category/provider filters now
live in the URL (nuqs useQueryStates), server-parsed via createSearchParamsCache
so filtered views render server-side (crawlable, shareable) — mirrors the blog
index SSR pattern. Search URL writes debounced; in-memory filtering stays instant.
- pricing: billing-period toggle moves to ?billing=annual (shareable, no flash).
- co-located search-params.ts per page as the single source of truth (server + client).
- stars: bump fallback floor 28800 -> 28900 to track the current count.
* fix(landing): use the light sim mark (not green) in the integration CTA
Replace brandbook/logo/small.png — referenced only by the 'Start automating
{service} today' CTA on integration pages — with the black-on-light sim mark so
the tile reads as a neutral brand mark beside the partner icon instead of a green
block.
* fix(landing): calmer release-timeline dot hover
Drop the scale-150 + brightness-75 hover (a 50% size jump plus a muddy darken)
for an understated opacity-fill (0.85 -> 1) with a slight scale-110, matching the
comparison chart's opacity-driven hover language.
* fix(landing): keep changelog load-more when initial page is empty; drop inline comments
- changelog: getNextPageParam no longer treats an empty server-seeded page 1 as
end-of-feed, so a failed/empty initial GitHub fetch still surfaces 'Show more'
(addresses Cursor Bugbot).
- replace the inline comments added in this branch with TSDoc on the search-param
caches (documents the dynamic-render → SSR-filtered behavior), per the
codebase's TSDoc-only convention.
* revert(landing): keep the original changelog timeline (drop useInfiniteQuery)
The React Query conversion of an SSR-seeded, cross-origin, infrequently-changing
paginated list introduced caching edge cases (empty-seed load-more, singleton
cache serving stale page 1 on client-side nav) without real benefit. Restore the
original useState(initialEntries)+fetch timeline, which is simpler, correct, and
already review-clean from #5181.
This commit is contained in:
@@ -41,21 +41,21 @@ export function ShareButton({ url, title }: ShareButtonProps) {
|
||||
className='flex items-center gap-1.5 text-[var(--text-muted)] text-sm hover:text-[var(--text-primary)]'
|
||||
aria-label='Share this post'
|
||||
>
|
||||
<Share2 className='h-4 w-4' />
|
||||
<Share2 className='size-4' />
|
||||
<span>Share</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem onSelect={handleCopyLink}>
|
||||
<Duplicate className='h-4 w-4' />
|
||||
<Duplicate className='size-4' />
|
||||
{copied ? 'Copied!' : 'Copy link'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={handleShareTwitter}>
|
||||
<XIcon className='h-4 w-4' />
|
||||
<XIcon className='size-4' />
|
||||
Share on X
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={handleShareLinkedIn}>
|
||||
<LinkedInIcon className='h-4 w-4' />
|
||||
<LinkedInIcon className='size-4' />
|
||||
Share on LinkedIn
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Loader,
|
||||
Modal,
|
||||
@@ -69,7 +69,7 @@ export function AuthModal({ children, defaultView = 'login', source }: AuthModal
|
||||
const [view, setView] = useState<AuthView>(defaultView)
|
||||
const [providerStatus, setProviderStatus] = useState<ProviderStatus | null>(null)
|
||||
const [socialLoading, setSocialLoading] = useState<'github' | 'google' | 'microsoft' | null>(null)
|
||||
const brand = useMemo(() => getBrandConfig(), [])
|
||||
const brand = getBrandConfig()
|
||||
|
||||
useEffect(() => {
|
||||
fetchProviderStatus().then(setProviderStatus)
|
||||
|
||||
@@ -61,7 +61,7 @@ export function LandingFAQ({ faqs }: LandingFAQProps) {
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-3 w-3 shrink-0 text-[var(--text-muted)] transition-transform duration-200',
|
||||
'size-3 shrink-0 text-[var(--text-muted)] transition-transform duration-200',
|
||||
isOpen ? 'rotate-180' : 'rotate-0'
|
||||
)}
|
||||
aria-hidden='true'
|
||||
|
||||
+6
-8
@@ -196,8 +196,8 @@ export const LandingPreviewHome = memo(function LandingPreviewHome({
|
||||
onClick={() => setToolsExpanded((p) => !p)}
|
||||
className='flex cursor-pointer items-center gap-2'
|
||||
>
|
||||
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>
|
||||
<Blimp className='h-[16px] w-[16px] text-[var(--text-icon)]' />
|
||||
<div className='flex size-[16px] flex-shrink-0 items-center justify-center'>
|
||||
<Blimp className='size-[16px] text-[var(--text-icon)]' />
|
||||
</div>
|
||||
<span className='text-[var(--text-body)] text-sm'>Sim</span>
|
||||
<ChevronDown
|
||||
@@ -218,9 +218,7 @@ export const LandingPreviewHome = memo(function LandingPreviewHome({
|
||||
<div className='overflow-hidden'>
|
||||
<div className='flex flex-col gap-1.5 pt-0.5'>
|
||||
<ToolCallRow
|
||||
icon={
|
||||
<Table className='h-[15px] w-[15px] text-[var(--text-muted)]' />
|
||||
}
|
||||
icon={<Table className='size-[15px] text-[var(--text-muted)]' />}
|
||||
title='Read Customer Leads'
|
||||
/>
|
||||
</div>
|
||||
@@ -343,7 +341,7 @@ export const LandingPreviewHome = memo(function LandingPreviewHome({
|
||||
function ToolCallRow({ icon, title }: { icon: React.ReactNode; title: string }) {
|
||||
return (
|
||||
<div className='flex items-center gap-[8px] pl-[24px]'>
|
||||
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center'>{icon}</div>
|
||||
<div className='flex size-[16px] flex-shrink-0 items-center justify-center'>{icon}</div>
|
||||
<span className='text-[13px] text-[var(--text-muted)]'>{title}</span>
|
||||
</div>
|
||||
)
|
||||
@@ -390,7 +388,7 @@ function MiniTablePanel() {
|
||||
return (
|
||||
<div className='flex h-full w-full flex-col bg-[var(--surface-2)]'>
|
||||
<div className='flex items-center gap-2 border-[var(--border)] border-b px-3 py-2'>
|
||||
<Table className='h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
<Table className='size-[14px] text-[var(--text-icon)]' />
|
||||
<span className='font-medium text-[var(--text-primary)] text-sm'>Customer Leads</span>
|
||||
</div>
|
||||
<div className='min-h-0 flex-1 overflow-auto'>
|
||||
@@ -410,7 +408,7 @@ function MiniTablePanel() {
|
||||
className='border-[var(--border-1)] border-r border-b bg-[var(--surface-1)] p-0 text-left'
|
||||
>
|
||||
<div className='flex items-center gap-1 px-2 py-1.5'>
|
||||
<Icon className='h-3 w-3 shrink-0 text-[var(--text-icon)]' />
|
||||
<Icon className='size-3 shrink-0 text-[var(--text-icon)]' />
|
||||
<span className='font-medium text-[11px] text-[var(--text-primary)]'>
|
||||
{col.label}
|
||||
</span>
|
||||
|
||||
+1
-1
@@ -248,7 +248,7 @@ export function LandingPreviewLogs() {
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{sortKey === key && <ArrowUpDown className='h-[10px] w-[10px] opacity-60' />}
|
||||
{sortKey === key && <ArrowUpDown className='size-[10px] opacity-60' />}
|
||||
</button>
|
||||
</th>
|
||||
))}
|
||||
|
||||
+2
-2
@@ -83,7 +83,7 @@ const MODEL_PROVIDER_ICONS: Array<{
|
||||
{ prefix: 'o4', icon: OpenAIIcon },
|
||||
{ prefix: 'claude-', icon: AnthropicIcon },
|
||||
{ prefix: 'gemini-', icon: GeminiIcon },
|
||||
{ prefix: 'grok-', icon: xAIIcon, size: 'h-[17px] w-[17px]' },
|
||||
{ prefix: 'grok-', icon: xAIIcon, size: 'size-[17px]' },
|
||||
{ prefix: 'mistral-', icon: MistralIcon },
|
||||
]
|
||||
|
||||
@@ -219,7 +219,7 @@ export const PreviewBlockNode = memo(function PreviewBlockNode({
|
||||
<span className='flex min-w-0 flex-1 items-center justify-end gap-2 font-normal text-[14px] text-[var(--text-primary)]'>
|
||||
{ModelIcon && (
|
||||
<ModelIcon
|
||||
className={`inline-block flex-shrink-0 text-[var(--text-primary)] ${modelEntry.size ?? 'h-[14px] w-[14px]'}`}
|
||||
className={`inline-block flex-shrink-0 text-[var(--text-primary)] ${modelEntry.size ?? 'size-[14px]'}`}
|
||||
/>
|
||||
)}
|
||||
<span className='truncate'>{row.value}</span>
|
||||
|
||||
@@ -121,7 +121,6 @@ export function LandingPreview({
|
||||
}: LandingPreviewProps) {
|
||||
const [activeView, setActiveView] = useState<SidebarView>(view)
|
||||
const [activeWorkflowId, setActiveWorkflowId] = useState(workflowId)
|
||||
const animationKeyRef = useRef(0)
|
||||
const [animationKey, setAnimationKey] = useState(0)
|
||||
const [autoTypeHome, setAutoTypeHome] = useState(false)
|
||||
const [isDesktop, setIsDesktop] = useState(true)
|
||||
@@ -129,7 +128,6 @@ export function LandingPreview({
|
||||
const demoIndexRef = useRef(0)
|
||||
const demoTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const autoCycleActiveRef = useRef(true)
|
||||
const isDesktopRef = useRef(true)
|
||||
|
||||
const clearDemoTimer = useCallback(() => {
|
||||
if (demoTimerRef.current) {
|
||||
@@ -144,8 +142,7 @@ export function LandingPreview({
|
||||
if (step.type === 'workflow' && step.workflowId) {
|
||||
setActiveWorkflowId(step.workflowId)
|
||||
setActiveView('workflow')
|
||||
animationKeyRef.current += 1
|
||||
setAnimationKey(animationKeyRef.current)
|
||||
setAnimationKey((k) => k + 1)
|
||||
} else if (step.type === 'home') {
|
||||
setActiveView('home')
|
||||
setAutoTypeHome(true)
|
||||
@@ -168,7 +165,6 @@ export function LandingPreview({
|
||||
|
||||
useEffect(() => {
|
||||
const desktop = window.matchMedia('(min-width: 1024px)').matches
|
||||
isDesktopRef.current = desktop
|
||||
setIsDesktop(desktop)
|
||||
if (!desktop) return
|
||||
if (!autoplay) return
|
||||
@@ -188,8 +184,7 @@ export function LandingPreview({
|
||||
setAutoTypeHome(false)
|
||||
setActiveWorkflowId(id)
|
||||
setActiveView('workflow')
|
||||
animationKeyRef.current += 1
|
||||
setAnimationKey(animationKeyRef.current)
|
||||
setAnimationKey((k) => k + 1)
|
||||
},
|
||||
[stopAutoCycle]
|
||||
)
|
||||
|
||||
@@ -403,7 +403,7 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
|
||||
name={name}
|
||||
Icon={IconComponent}
|
||||
className='size-12 rounded-xl border border-[var(--border-1)]'
|
||||
iconClassName='h-6 w-6'
|
||||
iconClassName='size-6'
|
||||
fallbackClassName='text-[20px]'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
@@ -745,7 +745,7 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
|
||||
Icon={ToolIcon}
|
||||
as='span'
|
||||
className='size-6 rounded-[4px]'
|
||||
iconClassName='h-3.5 w-3.5'
|
||||
iconClassName='size-3.5'
|
||||
fallbackClassName='text-[10px]'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
@@ -935,7 +935,7 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
|
||||
name={name}
|
||||
Icon={IconComponent}
|
||||
className='size-14 rounded-xl'
|
||||
iconClassName='h-7 w-7'
|
||||
iconClassName='size-7'
|
||||
fallbackClassName='text-[22px]'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from 'next'
|
||||
import type { SearchParams } from 'nuqs/server'
|
||||
import { SITE_URL } from '@/lib/core/utils/urls'
|
||||
import {
|
||||
blockTypeToIconMap,
|
||||
@@ -11,6 +12,7 @@ import { LandingFAQ } from '@/app/(landing)/components/landing-faq'
|
||||
import { IntegrationCard } from '@/app/(landing)/integrations/components/integration-card'
|
||||
import { IntegrationGrid } from '@/app/(landing)/integrations/components/integration-grid'
|
||||
import { RequestIntegrationModal } from '@/app/(landing)/integrations/components/request-integration-modal'
|
||||
import { integrationsSearchParamsCache } from '@/app/(landing)/integrations/search-params'
|
||||
|
||||
const allIntegrations = INTEGRATIONS
|
||||
const INTEGRATION_COUNT = allIntegrations.length
|
||||
@@ -87,7 +89,13 @@ export const metadata: Metadata = {
|
||||
alternates: { canonical: `${baseUrl}/integrations` },
|
||||
}
|
||||
|
||||
export default function IntegrationsPage() {
|
||||
export default async function IntegrationsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
await integrationsSearchParamsCache.parse(searchParams)
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
|
||||
@@ -61,7 +61,7 @@ export function IntegrationRow({ integration, IconComponent }: IntegrationItemPr
|
||||
name={name}
|
||||
Icon={IconComponent}
|
||||
className='size-8 shrink-0 rounded-xl border border-[var(--border-1)]'
|
||||
iconClassName='h-4 w-4'
|
||||
iconClassName='size-4'
|
||||
fallbackClassName='text-[13px]'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ChipInput, Search } from '@sim/emcn'
|
||||
import { debounce, useQueryStates } from 'nuqs'
|
||||
import { blockTypeToIconMap, formatIntegrationType, type Integration } from '@/lib/integrations'
|
||||
import { IntegrationRow } from '@/app/(landing)/integrations/components/integration-card'
|
||||
import {
|
||||
integrationsParsers,
|
||||
integrationsUrlKeys,
|
||||
} from '@/app/(landing)/integrations/search-params'
|
||||
|
||||
/** Debounce window for writing the search term to the URL (filtering is instant). */
|
||||
const SEARCH_DEBOUNCE_MS = 300
|
||||
|
||||
const PILL_BASE =
|
||||
'rounded-[5px] border border-[var(--border-1)] px-[9px] py-0.5 text-small text-[var(--text-primary)] transition-colors' as const
|
||||
@@ -15,8 +22,11 @@ interface IntegrationGridProps {
|
||||
}
|
||||
|
||||
export function IntegrationGrid({ integrations }: IntegrationGridProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null)
|
||||
const [{ q: query, category }, setParams] = useQueryStates(
|
||||
integrationsParsers,
|
||||
integrationsUrlKeys
|
||||
)
|
||||
const activeCategory = category || null
|
||||
|
||||
const counts = new Map<string, number>()
|
||||
for (const i of integrations) {
|
||||
@@ -51,7 +61,9 @@ export function IntegrationGrid({ integrations }: IntegrationGridProps) {
|
||||
type='search'
|
||||
placeholder='Search integrations, tools, or triggers…'
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onChange={(e) =>
|
||||
setParams({ q: e.target.value }, { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) })
|
||||
}
|
||||
aria-label='Search integrations'
|
||||
/>
|
||||
</div>
|
||||
@@ -60,7 +72,7 @@ export function IntegrationGrid({ integrations }: IntegrationGridProps) {
|
||||
<div className='mb-6 flex flex-wrap gap-2 px-6'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setActiveCategory(null)}
|
||||
onClick={() => setParams({ category: '' })}
|
||||
className={`${PILL_BASE} ${activeCategory === null ? PILL_ACTIVE : PILL_INACTIVE}`}
|
||||
>
|
||||
All
|
||||
@@ -69,7 +81,7 @@ export function IntegrationGrid({ integrations }: IntegrationGridProps) {
|
||||
<button
|
||||
key={cat}
|
||||
type='button'
|
||||
onClick={() => setActiveCategory(activeCategory === cat ? null : cat)}
|
||||
onClick={() => setParams({ category: activeCategory === cat ? '' : cat })}
|
||||
className={`${PILL_BASE} ${activeCategory === cat ? PILL_ACTIVE : PILL_INACTIVE}`}
|
||||
>
|
||||
{formatIntegrationType(cat)}
|
||||
|
||||
@@ -8,9 +8,9 @@ interface IntegrationIconProps extends HTMLAttributes<HTMLElement> {
|
||||
name: string
|
||||
/** Optional icon component. When absent, renders the first letter of `name`. */
|
||||
Icon?: ComponentType<SVGProps<SVGSVGElement>> | null
|
||||
/** Tailwind size + rounding classes for the container. Default: `h-10 w-10 rounded-xl` */
|
||||
/** Tailwind size + rounding classes for the container. Default: `size-10 rounded-xl` */
|
||||
className?: string
|
||||
/** Tailwind size classes for the icon SVG. Default: `h-5 w-5` */
|
||||
/** Tailwind size classes for the icon SVG. Default: `size-5` */
|
||||
iconClassName?: string
|
||||
/** Tailwind text-size class for the fallback letter. Default: `text-[15px]` */
|
||||
fallbackClassName?: string
|
||||
@@ -28,7 +28,7 @@ export function IntegrationIcon({
|
||||
name,
|
||||
Icon,
|
||||
className,
|
||||
iconClassName = 'h-5 w-5',
|
||||
iconClassName = 'size-5',
|
||||
fallbackClassName = 'text-[15px]',
|
||||
as: Tag = 'div',
|
||||
...rest
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createSearchParamsCache, parseAsString } from 'nuqs/server'
|
||||
|
||||
/**
|
||||
* Co-located, typed URL query params for the integrations catalog. Shared by the
|
||||
* client grid (`useQueryStates`) and the server page (`integrationsSearchParamsCache`)
|
||||
* so the filtered view is server-rendered for shareable, crawlable `?category=`/`?q=`
|
||||
* URLs — the same SSR pattern the blog index uses.
|
||||
*
|
||||
* - `q` is the search filter; its URL write is debounced on the setter, never
|
||||
* written per keystroke.
|
||||
* - `category` filters by integration type (`''` = all).
|
||||
*/
|
||||
export const integrationsParsers = {
|
||||
q: parseAsString.withDefault(''),
|
||||
category: parseAsString.withDefault(''),
|
||||
}
|
||||
|
||||
/** Filter/search view-state: clean URLs, no back-stack churn. */
|
||||
export const integrationsUrlKeys = {
|
||||
history: 'replace',
|
||||
clearOnDefault: true,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Parsing this in the page's server component opts the route into dynamic
|
||||
* rendering, which populates `useSearchParams` during the server render — so the
|
||||
* client grid's `useQueryStates` filters server-side and the initial HTML ships
|
||||
* the filtered catalog (crawlable, shareable), with no post-hydration swap.
|
||||
*/
|
||||
export const integrationsSearchParamsCache = createSearchParamsCache(integrationsParsers)
|
||||
@@ -162,7 +162,7 @@ export default async function ModelPage({
|
||||
<ProviderIcon
|
||||
provider={provider}
|
||||
className='size-16 rounded-xl'
|
||||
iconClassName='h-8 w-8'
|
||||
iconClassName='size-8'
|
||||
/>
|
||||
<div>
|
||||
<p className='mb-0.5 text-[var(--text-muted)] text-xs uppercase tracking-[0.1em]'>
|
||||
|
||||
@@ -154,7 +154,7 @@ export default async function ProviderModelsPage({
|
||||
<ProviderIcon
|
||||
provider={provider}
|
||||
className='size-12 rounded-xl'
|
||||
iconClassName='h-6 w-6'
|
||||
iconClassName='size-6'
|
||||
/>
|
||||
<h1
|
||||
id='provider-heading'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from 'next'
|
||||
import type { SearchParams } from 'nuqs/server'
|
||||
import { SITE_URL } from '@/lib/core/utils/urls'
|
||||
import { LandingFAQ } from '@/app/(landing)/components/landing-faq'
|
||||
import { ModelComparisonCharts } from '@/app/(landing)/models/components/model-comparison-charts'
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
FeaturedModelCard,
|
||||
FeaturedProviderCard,
|
||||
} from '@/app/(landing)/models/components/model-primitives'
|
||||
import { modelsSearchParamsCache } from '@/app/(landing)/models/search-params'
|
||||
import {
|
||||
ALL_CATALOG_MODELS,
|
||||
getPricingBounds,
|
||||
@@ -80,7 +82,13 @@ export const metadata: Metadata = {
|
||||
},
|
||||
}
|
||||
|
||||
export default function ModelsPage() {
|
||||
export default async function ModelsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
await modelsSearchParamsCache.parse(searchParams)
|
||||
|
||||
const flatModels = MODEL_PROVIDERS_WITH_CATALOGS.flatMap((provider) =>
|
||||
provider.models.map((model) => ({ provider, model }))
|
||||
)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { ChipInput, Search } from '@sim/emcn'
|
||||
import Link from 'next/link'
|
||||
import { debounce, useQueryStates } from 'nuqs'
|
||||
import { ChevronArrow } from '@/app/(landing)/components/chevron-arrow'
|
||||
import { ProviderIcon } from '@/app/(landing)/models/components/model-primitives'
|
||||
import { modelsParsers, modelsUrlKeys } from '@/app/(landing)/models/search-params'
|
||||
import {
|
||||
type CatalogModel,
|
||||
type CatalogProvider,
|
||||
@@ -14,19 +16,18 @@ import {
|
||||
MODEL_PROVIDERS_WITH_DYNAMIC_CATALOGS,
|
||||
} from '@/app/(landing)/models/utils'
|
||||
|
||||
export function ModelDirectory() {
|
||||
const [query, setQuery] = useState('')
|
||||
const [activeProviderId, setActiveProviderId] = useState<string | null>(null)
|
||||
/** Debounce window for writing the search term to the URL (filtering is instant). */
|
||||
const SEARCH_DEBOUNCE_MS = 300
|
||||
|
||||
const providerOptions = useMemo(
|
||||
() =>
|
||||
MODEL_PROVIDERS_WITH_CATALOGS.map((provider) => ({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
count: provider.modelCount,
|
||||
})),
|
||||
[]
|
||||
)
|
||||
const PROVIDER_OPTIONS = MODEL_PROVIDERS_WITH_CATALOGS.map((provider) => ({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
count: provider.modelCount,
|
||||
}))
|
||||
|
||||
export function ModelDirectory() {
|
||||
const [{ q: query, provider }, setParams] = useQueryStates(modelsParsers, modelsUrlKeys)
|
||||
const activeProviderId = provider || null
|
||||
|
||||
const normalizedQuery = query.trim().toLowerCase()
|
||||
|
||||
@@ -89,7 +90,12 @@ export function ModelDirectory() {
|
||||
type='search'
|
||||
placeholder='Search models, providers, or capabilities…'
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onChange={(event) =>
|
||||
setParams(
|
||||
{ q: event.target.value },
|
||||
{ limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) }
|
||||
)
|
||||
}
|
||||
aria-label='Search AI models'
|
||||
/>
|
||||
</div>
|
||||
@@ -98,7 +104,7 @@ export function ModelDirectory() {
|
||||
<div className='mb-6 flex flex-wrap gap-2 px-6'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setActiveProviderId(null)}
|
||||
onClick={() => setParams({ provider: '' })}
|
||||
className={`rounded-[5px] border px-[9px] py-0.5 text-small transition-colors ${
|
||||
activeProviderId === null
|
||||
? 'border-[var(--border-1)] bg-[var(--surface-active)] text-[var(--text-primary)]'
|
||||
@@ -107,12 +113,12 @@ export function ModelDirectory() {
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{providerOptions.map((provider) => (
|
||||
{PROVIDER_OPTIONS.map((provider) => (
|
||||
<button
|
||||
key={provider.id}
|
||||
type='button'
|
||||
onClick={() =>
|
||||
setActiveProviderId(activeProviderId === provider.id ? null : provider.id)
|
||||
setParams({ provider: activeProviderId === provider.id ? '' : provider.id })
|
||||
}
|
||||
className={`rounded-[5px] border px-[9px] py-0.5 text-small transition-colors ${
|
||||
activeProviderId === provider.id
|
||||
@@ -148,7 +154,7 @@ export function ModelDirectory() {
|
||||
<ProviderIcon
|
||||
provider={provider}
|
||||
className='size-8 rounded-xl'
|
||||
iconClassName='h-4 w-4'
|
||||
iconClassName='size-4'
|
||||
/>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h3
|
||||
@@ -197,7 +203,7 @@ export function ModelDirectory() {
|
||||
<ProviderIcon
|
||||
provider={provider}
|
||||
className='size-8 rounded-xl'
|
||||
iconClassName='h-4 w-4'
|
||||
iconClassName='size-4'
|
||||
/>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<h4 className='text-[14px] text-[var(--text-primary)] leading-snug'>
|
||||
@@ -229,7 +235,7 @@ function ModelRow({ provider, model }: { provider: CatalogProvider; model: Catal
|
||||
<ProviderIcon
|
||||
provider={provider}
|
||||
className='size-8 shrink-0 rounded-xl'
|
||||
iconClassName='h-4 w-4'
|
||||
iconClassName='size-4'
|
||||
/>
|
||||
|
||||
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { CatalogModel, CatalogProvider } from '@/app/(landing)/models/utils
|
||||
export function ProviderIcon({
|
||||
provider,
|
||||
className = 'size-12 rounded-xl',
|
||||
iconClassName = 'h-6 w-6',
|
||||
iconClassName = 'size-6',
|
||||
}: {
|
||||
provider: Pick<CatalogProvider, 'icon' | 'name'>
|
||||
className?: string
|
||||
@@ -37,7 +37,7 @@ export function FeaturedProviderCard({ provider }: { provider: CatalogProvider }
|
||||
href={provider.href}
|
||||
className='group flex flex-1 flex-col gap-4 border-[var(--border)] border-t p-6 transition-colors first:border-t-0 hover:bg-[var(--surface-hover)] sm:border-t-0 sm:border-l sm:first:border-l-0'
|
||||
>
|
||||
<ProviderIcon provider={provider} className='size-10 rounded-xl' iconClassName='h-5 w-5' />
|
||||
<ProviderIcon provider={provider} className='size-10 rounded-xl' iconClassName='size-5' />
|
||||
<div className='flex flex-col gap-2'>
|
||||
<h3 className='text-[var(--text-primary)] text-lg leading-tight tracking-[-0.01em]'>
|
||||
{provider.name}
|
||||
@@ -62,7 +62,7 @@ export function FeaturedModelCard({
|
||||
href={model.href}
|
||||
className='group flex flex-1 flex-col gap-4 border-[var(--border)] border-t p-6 transition-colors first:border-t-0 hover:bg-[var(--surface-hover)] sm:border-t-0 sm:border-l sm:first:border-l-0'
|
||||
>
|
||||
<ProviderIcon provider={provider} className='size-10 rounded-xl' iconClassName='h-5 w-5' />
|
||||
<ProviderIcon provider={provider} className='size-10 rounded-xl' iconClassName='size-5' />
|
||||
<div className='flex flex-col gap-2'>
|
||||
<span className='text-[var(--text-muted)] text-xs uppercase tracking-[0.1em]'>
|
||||
{provider.name}
|
||||
|
||||
@@ -76,11 +76,9 @@ export function ModelTimelineChart({ models, providerId }: ModelTimelineChartPro
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{/* Dot - centered exactly on the line (70px - 6px). Hover darkens
|
||||
rather than brightens, so light brand colors stay visible. */}
|
||||
<div
|
||||
className='-translate-x-1/2 absolute top-[64px] left-1/2 size-[12px] rounded-full transition-[filter,transform] duration-150 group-hover:scale-150 group-hover:brightness-75'
|
||||
style={{ backgroundColor: color, opacity: 0.85 }}
|
||||
className='-translate-x-1/2 absolute top-[64px] left-1/2 size-[12px] rounded-full opacity-[0.85] transition-[opacity,transform] duration-150 group-hover:scale-110 group-hover:opacity-100'
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
|
||||
{/* Stem + label above */}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createSearchParamsCache, parseAsString } from 'nuqs/server'
|
||||
|
||||
/**
|
||||
* Co-located, typed URL query params for the models directory. Shared by the
|
||||
* client directory (`useQueryStates`) and the server page (`modelsSearchParamsCache`)
|
||||
* so the filtered view is server-rendered for shareable, crawlable `?provider=`/`?q=`
|
||||
* URLs — the same SSR pattern the blog index uses.
|
||||
*
|
||||
* - `q` is the search filter; its URL write is debounced on the setter, never
|
||||
* written per keystroke.
|
||||
* - `provider` filters by provider id (`''` = all).
|
||||
*/
|
||||
export const modelsParsers = {
|
||||
q: parseAsString.withDefault(''),
|
||||
provider: parseAsString.withDefault(''),
|
||||
}
|
||||
|
||||
/** Filter/search view-state: clean URLs, no back-stack churn. */
|
||||
export const modelsUrlKeys = {
|
||||
history: 'replace',
|
||||
clearOnDefault: true,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Parsing this in the page's server component opts the route into dynamic
|
||||
* rendering, which populates `useSearchParams` during the server render — so the
|
||||
* client directory's `useQueryStates` filters server-side and the initial HTML
|
||||
* ships the filtered list (crawlable, shareable), with no post-hydration swap.
|
||||
*/
|
||||
export const modelsSearchParamsCache = createSearchParamsCache(modelsParsers)
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useQueryStates } from 'nuqs'
|
||||
import { getUpgradeCardCta, type PlanTier, type UpgradeCardId } from '@/lib/billing/client'
|
||||
import { ANNUAL_DISCOUNT_RATE, CREDIT_TIERS } from '@/lib/billing/constants'
|
||||
import {
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type PricingCardCta,
|
||||
type PricingCardSection,
|
||||
} from '@/app/(landing)/pricing/components/pricing-card'
|
||||
import { pricingParsers, pricingUrlKeys } from '@/app/(landing)/pricing/search-params'
|
||||
import { BillingPeriodToggle } from '@/app/workspace/[workspaceId]/upgrade/components'
|
||||
import {
|
||||
type CellValue,
|
||||
@@ -65,7 +66,9 @@ function sectionsForColumn(col: number): PricingCardSection[] {
|
||||
* included, funnels to `/signup`.
|
||||
*/
|
||||
export function PricingPlans({ heading }: PricingPlansProps) {
|
||||
const [isAnnual, setIsAnnual] = useState(false)
|
||||
const [{ billing }, setParams] = useQueryStates(pricingParsers, pricingUrlKeys)
|
||||
const isAnnual = billing === 'annual'
|
||||
const setIsAnnual = (next: boolean) => setParams({ billing: next ? 'annual' : 'monthly' })
|
||||
|
||||
const discountPct = Math.round(ANNUAL_DISCOUNT_RATE * 100)
|
||||
const proPrice = isAnnual
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Metadata } from 'next'
|
||||
import type { SearchParams } from 'nuqs/server'
|
||||
import { SITE_URL } from '@/lib/core/utils/urls'
|
||||
import Pricing from '@/app/(landing)/pricing/pricing'
|
||||
import { pricingSearchParamsCache } from '@/app/(landing)/pricing/search-params'
|
||||
|
||||
export const revalidate = 3600
|
||||
|
||||
@@ -58,6 +60,7 @@ export const metadata: Metadata = {
|
||||
category: 'technology',
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
export default async function Page({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||
await pricingSearchParamsCache.parse(searchParams)
|
||||
return <Pricing />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createSearchParamsCache, parseAsStringLiteral } from 'nuqs/server'
|
||||
|
||||
/** Billing periods offered on the pricing page. */
|
||||
export const BILLING_PERIODS = ['monthly', 'annual'] as const
|
||||
|
||||
/**
|
||||
* Co-located, typed URL query param for the pricing page's billing-period
|
||||
* toggle. Shared by the client cards (`useQueryStates`) and the server page
|
||||
* (`pricingSearchParamsCache`) so a shared `?billing=annual` URL is
|
||||
* server-rendered with the annual prices already applied (no toggle flash).
|
||||
*/
|
||||
export const pricingParsers = {
|
||||
billing: parseAsStringLiteral(BILLING_PERIODS).withDefault('monthly'),
|
||||
}
|
||||
|
||||
/** Clean URLs, no back-stack churn — the toggle is a passive view switch. */
|
||||
export const pricingUrlKeys = {
|
||||
history: 'replace',
|
||||
clearOnDefault: true,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Parsing this in the page's server component opts the route into dynamic
|
||||
* rendering, which populates `useSearchParams` during the server render — so the
|
||||
* client cards' `useQueryStates` resolves the billing period server-side and the
|
||||
* initial HTML ships the correct prices (no toggle flash on a shared link).
|
||||
*/
|
||||
export const pricingSearchParamsCache = createSearchParamsCache(pricingParsers)
|
||||
@@ -3,10 +3,15 @@ import { env } from '@/lib/core/config/env'
|
||||
|
||||
const logger = createLogger('GitHubStars')
|
||||
|
||||
const FALLBACK_STAR_COUNT = 28800
|
||||
/**
|
||||
* Floor shown only when the live GitHub count can't be fetched. Kept a touch
|
||||
* below the real count (~28.9k as of 2026-06) so it never overstates, and
|
||||
* bumped periodically.
|
||||
*/
|
||||
const FALLBACK_STAR_COUNT = 28900
|
||||
|
||||
/**
|
||||
* Formats a raw star count for display (e.g. 19400 → "19.4k").
|
||||
* Formats a raw star count for display (e.g. 28900 → "28.9k").
|
||||
*/
|
||||
export function formatStarCount(num: number): string {
|
||||
if (num < 1000) return String(num)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 17 KiB |
Reference in New Issue
Block a user