Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion app/(marketing)/sponsors/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { connection } from "next/server"

import { Logo } from "@/components/logo"
import { SiteStats } from "@/components/site-stats"
import { buttonVariants } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { getGithubStars } from "@/lib/github"
import { buildPageMetadata } from "@/lib/seo"
import {
getSponsorTiers,
Expand Down Expand Up @@ -72,6 +74,7 @@ function SponsorSection({ tier }: { tier: SponsorTier }) {
}

export default async function SponsorsPage() {
const githubStars = await getGithubStars()
await connection()
const tiers = getSponsorTiers()

Expand All @@ -91,7 +94,9 @@ export default async function SponsorsPage() {
</p>
</header>

<div className="flex flex-col gap-12">
<SiteStats githubStars={githubStars} />

<div className="mt-16 flex flex-col gap-12">
{tiers.map((tier) => (
<SponsorSection key={tier.id} tier={tier} />
))}
Expand Down
146 changes: 146 additions & 0 deletions components/site-stats.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import Link from "next/link"

import { getGithubRepoUrl } from "@/lib/github"
import {
COMPONENT_COUNT,
formatDuration,
formatExactCount,
formatPercent,
formatViewsPerVisit,
} from "@/lib/site-stats"
import { cn } from "@/lib/utils"

const cardClassName =
"flex flex-col items-center justify-center rounded-2xl border border-border bg-card px-4 py-8 text-center"

function SummaryCard({
value,
label,
href,
external,
}: {
value: string
label: string
href?: string
external?: boolean
}) {
const body = (
<>
<p className="text-3xl font-semibold tracking-tight text-foreground tabular-nums sm:text-4xl">
{value}
</p>
<p className="mt-1.5 text-sm text-muted-foreground">{label}</p>
</>
)

if (!href) {
return <div className={cn(cardClassName, "min-h-36")}>{body}</div>
}

const className = cn(
cardClassName,
"min-h-36 transition-colors hover:bg-muted/40"
)

if (external) {
return (
<a href={href} target="_blank" rel="noreferrer" className={className}>
{body}
</a>
)
}

return (
<Link href={href} className={className}>
{body}
</Link>
)
}

function MetricCard({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-xl border border-border bg-card px-4 py-4">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-3 text-3xl font-semibold tracking-tight text-foreground tabular-nums">
{value}
</p>
<p className="mt-2 text-xs text-muted-foreground">No previous range</p>
</div>
)
}

export function SiteStats({
githubStars,
pageviewsLastMonth = null,
pageviewsSinceLaunch = null,
visitors = null,
pageviews = null,
bounceRate = null,
avgTimeSeconds = null,
viewsPerVisit = null,
}: {
githubStars: number | null
pageviewsLastMonth?: number | null
pageviewsSinceLaunch?: number | null
visitors?: number | null
pageviews?: number | null
bounceRate?: number | null
avgTimeSeconds?: number | null
viewsPerVisit?: number | null
}) {
return (
<section aria-label="Site analytics" className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<SummaryCard
value={formatExactCount(pageviewsLastMonth)}
label="Pageviews last month"
/>
<SummaryCard
value={formatExactCount(githubStars)}
label="GitHub stars"
href={getGithubRepoUrl()}
external
/>
<SummaryCard
value={formatExactCount(COMPONENT_COUNT)}
label="Components"
href="/docs"
/>
<SummaryCard value="Free" label="For anything you build" />
</div>

<div className={cn(cardClassName, "min-h-40 px-6 py-10")}>
<p className="text-4xl font-semibold tracking-tight text-foreground tabular-nums sm:text-5xl">
{formatExactCount(pageviewsSinceLaunch)}
</p>
<p className="mt-2 text-sm text-muted-foreground">
Pageviews since launch
</p>
</div>

<div className="grid grid-cols-2 gap-3 pt-3 md:grid-cols-3 xl:grid-cols-5">
<MetricCard
label="Visitors · estimated"
value={formatExactCount(visitors)}
/>
<MetricCard label="Page views" value={formatExactCount(pageviews)} />
<MetricCard
label="Bounce rate · live"
value={formatPercent(bounceRate)}
/>
<MetricCard
label="Avg. time · live"
value={formatDuration(avgTimeSeconds)}
/>
<MetricCard
label="Views per visit"
value={formatViewsPerVisit(viewsPerVisit)}
/>
</div>

<p className="pt-2 text-center text-xs text-balance text-muted-foreground">
GitHub stars refresh daily.
</p>
</section>
)
}
7 changes: 5 additions & 2 deletions lib/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,18 @@ export function formatStarCount(count: number) {
}).format(count)
}

/** Cached star count for the docs header. Revalidates every 10 minutes. */
/** Star count is read from GitHub once a day. */
export const GITHUB_STARS_REVALIDATE_SECONDS = 60 * 60 * 24

/** Cached star count. Revalidates once a day. */
export async function getGithubStars(): Promise<number | null> {
try {
const res = await fetch(`https://api.github.com/repos/${REPO}`, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "23rd.dev",
},
next: { revalidate: 600 },
next: { revalidate: GITHUB_STARS_REVALIDATE_SECONDS },
})
if (!res.ok) return null
const data = (await res.json()) as { stargazers_count?: number }
Expand Down
29 changes: 29 additions & 0 deletions lib/site-stats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import registry from "@/registry.json"

export const COMPONENT_COUNT = registry.include.length

export function formatExactCount(value: number | null): string {
if (value == null) return "—"
return new Intl.NumberFormat("en").format(value)
}

export function formatPercent(value: number | null): string {
if (value == null) return "—"
return `${Math.round(value)}%`
}

export function formatDuration(seconds: number | null): string {
if (seconds == null) return "—"
const rounded = Math.max(0, Math.round(seconds))
if (rounded < 60) return `${rounded}s`
const minutes = Math.floor(rounded / 60)
const remainder = rounded % 60
return remainder === 0 ? `${minutes}m` : `${minutes}m ${remainder}s`
}

export function formatViewsPerVisit(value: number | null): string {
if (value == null) return "—"
return new Intl.NumberFormat("en", {
maximumFractionDigits: 1,
}).format(value)
}
26 changes: 26 additions & 0 deletions tests/sponsors.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { join } from "node:path"
import { ROOT } from "../scripts/registry-lib.mjs"

const sponsorsLib = readFileSync(join(ROOT, "lib/sponsors.ts"), "utf8")
const githubLib = readFileSync(join(ROOT, "lib/github.ts"), "utf8")
const siteStats = readFileSync(join(ROOT, "components/site-stats.tsx"), "utf8")
const siteStatsLib = readFileSync(join(ROOT, "lib/site-stats.ts"), "utf8")
const sponsorsPage = readFileSync(
join(ROOT, "app/(marketing)/sponsors/page.tsx"),
"utf8"
Expand Down Expand Up @@ -196,3 +199,26 @@ test("sponsors prices, terms, privacy, and support email match the shadscan layo
assert.match(nextConfig, /source: "\/pricing"/)
assert.match(nextConfig, /destination: "\/sponsors"/)
})

test("sponsors page lists analytics and reads GitHub stars once a day", () => {
assert.match(sponsorsPage, /<SiteStats/)
assert.match(sponsorsPage, /getGithubStars\(\)/)
assert.match(githubLib, /GITHUB_STARS_REVALIDATE_SECONDS = 60 \* 60 \* 24/)
assert.match(githubLib, /revalidate: GITHUB_STARS_REVALIDATE_SECONDS/)
assert.match(siteStatsLib, /registry\.include\.length/)
for (const label of [
"Pageviews last month",
"GitHub stars",
"Components",
"For anything you build",
"Pageviews since launch",
"Visitors · estimated",
"Page views",
"Bounce rate · live",
"Avg. time · live",
"Views per visit",
"GitHub stars refresh daily",
]) {
assert.ok(siteStats.includes(label), label)
}
})
Loading