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:
Waleed
2026-06-30 15:14:36 -07:00
committed by GitHub
parent ad19f7fc40
commit 32b6a425fd
25 changed files with 198 additions and 74 deletions
@@ -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'
@@ -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>
@@ -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>
))}
@@ -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
+4 -1
View File
@@ -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)
+7 -2
View File
@@ -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