From 6b1f71381c232c13654112f69b779c63edbcbac2 Mon Sep 17 00:00:00 2001 From: smakosh Date: Mon, 20 Jul 2026 21:11:32 +0200 Subject: [PATCH 1/4] feat(ui): surface top nav links, fix dropdown css Promote the most-visited destinations (DevPass, Chat, Models per PostHog 30d traffic) from the Products/Resources dropdowns to direct navbar links, add nav_link_clicked tracking to all nav links, and fix two dropdown bugs: NavigationMenuLink's flex-col base class stacking the icon cards vertically (also broken in production), and panels clipping off the right viewport edge (now centered under triggers). Claude-Session: https://claude.ai/code/session_01FYyCRLR4CU4FWoiSi7hd1K --- apps/ui/src/components/landing/navbar.tsx | 140 +++++++++++++++++----- 1 file changed, 111 insertions(+), 29 deletions(-) diff --git a/apps/ui/src/components/landing/navbar.tsx b/apps/ui/src/components/landing/navbar.tsx index 03c45d25df..868a68bb5c 100644 --- a/apps/ui/src/components/landing/navbar.tsx +++ b/apps/ui/src/components/landing/navbar.tsx @@ -30,6 +30,7 @@ import { Zap, } from "lucide-react"; import Link from "next/link"; +import { usePostHog } from "posthog-js/react"; import { useEffect, useState } from "react"; import { AuthLink } from "@/components/shared/auth-link"; @@ -70,8 +71,12 @@ function IconMenuItem({ gradient: string; external?: boolean; }) { + const posthog = usePostHog(); const linkClassName = cn( - "group/product flex items-start gap-3 select-none rounded-lg p-3 no-underline outline-none transition-all duration-300 bg-linear-to-br from-transparent to-transparent", + // flex-row is load-bearing: NavigationMenuLink's base classes include + // flex-col and are concatenated onto this link via Radix Slot, so the + // direction must be asserted explicitly or the card stacks vertically. + "group/product flex flex-row items-start gap-3 select-none rounded-lg p-3 no-underline outline-none transition-all duration-300 bg-linear-to-br from-transparent to-transparent", gradient, "hover:shadow-lg focus:shadow-md", ); @@ -96,6 +101,10 @@ function IconMenuItem({ ); + const handleClick = () => { + posthog.capture("nav_link_clicked", { link: title, area: "dropdown" }); + }; + return (
  • @@ -105,11 +114,17 @@ function IconMenuItem({ target="_blank" rel="noopener noreferrer" className={linkClassName} + onClick={handleClick} > {inner} ) : ( - + {inner} )} @@ -130,9 +145,14 @@ export const Navbar = ({ providers?: ApiProvider[]; }) => { const config = useAppConfig(); + const posthog = usePostHog(); const { isAuthenticated: hasSession, isLoading } = useSessionStatus(); const isAuthenticated = hasSession && !isLoading; + const trackNav = (link: string) => { + posthog.capture("nav_link_clicked", { link, area: "navbar" }); + }; + const productsLinks: Array<{ title: string; href: string; @@ -421,7 +441,7 @@ export const Navbar = ({ prefetch={true} > - + LLM Gateway @@ -437,18 +457,58 @@ export const Navbar = ({ {/* Desktop center nav */} -
    -
    +
    +
    - + + {/* Most-clicked destinations surfaced as direct links — + DevPass (top product) and Models (top resource) per + PostHog page traffic; Chat joins on wider screens. */} + + + trackNav("DevPass")} + className="text-muted-foreground hover:text-accent-foreground block duration-150 px-3 py-2 whitespace-nowrap" + > + DevPass + + + + + + + trackNav("Chat")} + className="text-muted-foreground hover:text-accent-foreground block duration-150 px-3 py-2 whitespace-nowrap" + > + Chat + + + + + + + trackNav("Models")} + className="text-muted-foreground hover:text-accent-foreground block duration-150 px-3 py-2 whitespace-nowrap" + > + Models + + + + {/* Products dropdown */} - + Products - +
      +
    • + trackNav("DevPass")} + className="text-muted-foreground hover:text-accent-foreground block py-2.5 duration-150" + > + DevPass + +
    • +
    • + trackNav("Chat")} + className="text-muted-foreground hover:text-accent-foreground block py-2.5 duration-150" + > + Chat + +
    • - {/* GitHub stars (compact) + Discord */} -
      + {/* GitHub stars (compact) + Discord — hidden in the narrow + band above the nav breakpoint so the promoted nav links + don't collide with the right-side controls. */} +
      {children} Date: Mon, 20 Jul 2026 21:11:50 +0200 Subject: [PATCH 2/4] feat(ui): add DevPass CTA cards to blog posts New BlogCta component (passport-styled DevPass variant + generic gateway variant) registered as a markdown override, embedded twice in best-ai-coding-plans (328 readers/wk, 0.6% signup conversion). Clicks fire cta_clicked with post attribution; links carry UTM params. Claude-Session: https://claude.ai/code/session_01FYyCRLR4CU4FWoiSi7hd1K --- apps/ui/src/components/blog/blog-cta.tsx | 112 ++++++++++++++++++ .../blog/2026-06-22-best-ai-coding-plans.md | 6 +- apps/ui/src/lib/utils/markdown.tsx | 5 + 3 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 apps/ui/src/components/blog/blog-cta.tsx diff --git a/apps/ui/src/components/blog/blog-cta.tsx b/apps/ui/src/components/blog/blog-cta.tsx new file mode 100644 index 0000000000..973edc95a1 --- /dev/null +++ b/apps/ui/src/components/blog/blog-cta.tsx @@ -0,0 +1,112 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { usePostHog } from "posthog-js/react"; + +import { Button } from "@/lib/components/button"; + +// Conversion card embeddable in markdown content via the `BlogCta` override in +// getMarkdownOptions. The DevPass variant leans into the passport/boarding-pass +// brand language used across DevPass surfaces. +export function BlogCta({ + variant = "devpass", + location = "inline", +}: { + variant?: "devpass" | "gateway"; + location?: string; +}) { + const posthog = usePostHog(); + const pathname = usePathname(); + const post = pathname?.split("/").pop() ?? "unknown"; + + const track = (cta: string) => { + posthog.capture("cta_clicked", { + location: `blog_${location}`, + cta, + post, + }); + }; + + if (variant === "gateway") { + return ( +
      +
      + LLM Gateway +
      +

      + One API key for every model. +

      +

      + Route to 200+ models with automatic failover, caching, and real-time + cost analytics. Free to start — no credit card required. +

      +
      + + track("browse_models")} + className="text-sm text-muted-foreground underline underline-offset-4 transition-colors hover:text-foreground" + > + Browse all models + +
      +
      + ); + } + + return ( +
      + ); +} diff --git a/apps/ui/src/content/blog/2026-06-22-best-ai-coding-plans.md b/apps/ui/src/content/blog/2026-06-22-best-ai-coding-plans.md index 9f139b5b55..57b77bae8a 100644 --- a/apps/ui/src/content/blog/2026-06-22-best-ai-coding-plans.md +++ b/apps/ui/src/content/blog/2026-06-22-best-ai-coding-plans.md @@ -50,6 +50,8 @@ export ANTHROPIC_API_KEY="$LLM_GATEWAY_API_KEY" # every one of the 200+ models is now a single switch away ``` + + --- ## 2. Claude Code (Anthropic Max) @@ -325,4 +327,6 @@ Switch to one flat rate for every model in under two minutes: No per-token math. No vendor lock-in. Just every model under one key. -**[Get DevPass](https://devpass.llmgateway.io/signup?plan=pro)** | **[Compare DevPass vs Cursor](https://devpass.llmgateway.io/compare/cursor)** | **[Read the 7 best AI gateways](/blog/best-ai-gateways)** + + +**[Compare DevPass vs Cursor](https://devpass.llmgateway.io/compare/cursor)** | **[Read the 7 best AI gateways](/blog/best-ai-gateways)** diff --git a/apps/ui/src/lib/utils/markdown.tsx b/apps/ui/src/lib/utils/markdown.tsx index 8d75782db0..c04ac9a1c3 100644 --- a/apps/ui/src/lib/utils/markdown.tsx +++ b/apps/ui/src/lib/utils/markdown.tsx @@ -1,3 +1,5 @@ +import { BlogCta } from "@/components/blog/blog-cta"; + import { SyntaxHighlightedPre } from "./markdown-code-block"; export interface ChangelogFrontmatter { @@ -22,6 +24,9 @@ export interface ChangelogEntry extends ChangelogFrontmatter { export function getMarkdownOptions() { return { overrides: { + BlogCta: { + component: BlogCta, + }, h1: { props: { className: From fe70403e0bbec3a5b9fcc074ea234d1d26c5baef Mon Sep 17 00:00:00 2001 From: smakosh Date: Mon, 20 Jul 2026 21:12:07 +0200 Subject: [PATCH 3/4] fix(analytics): posthog proxy, init and report Client events were silently dropped two ways: captures fired before the unbounded idle-deferred posthog.init (visible as zero-event days for playground_chat_sent), and ad blockers blocking *.posthog.com. Bound the init deferral (800ms timeout) and route ingestion through a first-party /ingest rewrite in ui and playground. Also make the weekly traffic report count unique persons for cta_clicked/pricing_plan_clicked so a single bot can't skew a week (708 pricing clicks from one crawler on Jul 11). Claude-Session: https://claude.ai/code/session_01FYyCRLR4CU4FWoiSi7hd1K --- apps/playground/next.config.ts | 15 +++++++++++ apps/playground/src/components/providers.tsx | 13 +++++++--- apps/ui/next.config.ts | 11 ++++++++ apps/ui/src/components/providers.tsx | 13 +++++++--- packages/scripts/src/traffic-report.ts | 27 +++++++++++++++----- 5 files changed, 66 insertions(+), 13 deletions(-) diff --git a/apps/playground/next.config.ts b/apps/playground/next.config.ts index 6805f7c64f..37a3ebcf1d 100644 --- a/apps/playground/next.config.ts +++ b/apps/playground/next.config.ts @@ -26,6 +26,21 @@ const nextConfig: NextConfig = { "@json-render/react-pdf", "@json-render/image", ], + async rewrites() { + return [ + // First-party PostHog ingestion proxy — ad blockers block + // *.posthog.com directly, silently dropping client events. The + // client is configured with api_host: "/ingest" (providers.tsx). + { + source: "/ingest/static/:path*", + destination: "https://us-assets.i.posthog.com/static/:path*", + }, + { + source: "/ingest/:path*", + destination: "https://us.i.posthog.com/:path*", + }, + ]; + }, typescript: { ignoreBuildErrors: true, }, diff --git a/apps/playground/src/components/providers.tsx b/apps/playground/src/components/providers.tsx index e7e17231e6..f769bd8634 100644 --- a/apps/playground/src/components/providers.tsx +++ b/apps/playground/src/components/providers.tsx @@ -41,16 +41,23 @@ export function Providers({ children, config }: ProvidersProps) { const host = config.posthogHost; const init = () => { posthog.init(key, { - api_host: host, + // Ingest through our own origin (see the /ingest rewrites in + // next.config.ts) so ad blockers that block *.posthog.com don't + // silently drop client events. + api_host: "/ingest", + ui_host: host, capture_pageview: "history_change", autocapture: true, }); }; + // Captures fired before init() are dropped by posthog-js, so the idle + // deferral must be bounded — a busy main thread (e.g. the chat page) + // can starve requestIdleCallback long enough for a user to act. if (typeof requestIdleCallback !== "undefined") { - const id = requestIdleCallback(init); + const id = requestIdleCallback(init, { timeout: 800 }); return () => cancelIdleCallback(id); } - const timer = setTimeout(init, 1000); + const timer = setTimeout(init, 300); return () => clearTimeout(timer); }, [config.posthogKey, config.posthogHost]); diff --git a/apps/ui/next.config.ts b/apps/ui/next.config.ts index b2f4fd7a9a..301d4a73f4 100644 --- a/apps/ui/next.config.ts +++ b/apps/ui/next.config.ts @@ -422,6 +422,17 @@ const nextConfig: NextConfig = { source: "/docs-health", destination: "https://docs.llmgateway.io/health", }, + // First-party PostHog ingestion proxy — ad blockers block + // *.posthog.com directly, silently dropping client events. The + // client is configured with api_host: "/ingest" (providers.tsx). + { + source: "/ingest/static/:path*", + destination: "https://us-assets.i.posthog.com/static/:path*", + }, + { + source: "/ingest/:path*", + destination: "https://us.i.posthog.com/:path*", + }, ]; }, typescript: { diff --git a/apps/ui/src/components/providers.tsx b/apps/ui/src/components/providers.tsx index 47b64db38b..b5fed3c3ea 100644 --- a/apps/ui/src/components/providers.tsx +++ b/apps/ui/src/components/providers.tsx @@ -71,16 +71,23 @@ export function Providers({ children, config }: ProvidersProps) { const host = config.posthogHost; const init = () => { posthog.init(key, { - api_host: host, + // Ingest through our own origin (see the /ingest rewrites in + // next.config.ts) so ad blockers that block *.posthog.com don't + // silently drop client events. + api_host: "/ingest", + ui_host: host, capture_pageview: "history_change", autocapture: true, }); }; + // Captures fired before init() are dropped by posthog-js, so the idle + // deferral must be bounded — a busy main thread can starve + // requestIdleCallback long enough for a user to act. if (typeof requestIdleCallback !== "undefined") { - const id = requestIdleCallback(init); + const id = requestIdleCallback(init, { timeout: 800 }); return () => cancelIdleCallback(id); } - const timer = setTimeout(init, 1000); + const timer = setTimeout(init, 300); return () => clearTimeout(timer); }, [config.posthogKey, config.posthogHost]); diff --git a/packages/scripts/src/traffic-report.ts b/packages/scripts/src/traffic-report.ts index f4630ec870..9512b015eb 100644 --- a/packages/scripts/src/traffic-report.ts +++ b/packages/scripts/src/traffic-report.ts @@ -65,7 +65,14 @@ const PRODUCTS: ReadonlyArray<{ host: string; label: string }> = [ { host: "docs.llmgateway.io", label: "Docs" }, ]; -const EVENTS: ReadonlyArray<{ event: string; label: string }> = [ +// `unique: true` counts distinct persons instead of raw events. Click events +// use it because a single bot hammering a page can otherwise dominate the +// weekly number (e.g. 708 pricing clicks from one crawler on 2026-07-11). +const EVENTS: ReadonlyArray<{ + event: string; + label: string; + unique?: boolean; +}> = [ { event: "user_signed_up", label: "Signups" }, { event: "credits_purchased", label: "Credit purchases" }, { event: "dev_plan_started", label: "DevPass starts" }, @@ -73,8 +80,8 @@ const EVENTS: ReadonlyArray<{ event: string; label: string }> = [ { event: "reset_pass_purchased", label: "Reset passes" }, { event: "onboarding_completed", label: "Onboarding done" }, { event: "playground_chat_sent", label: "Playground chats" }, - { event: "cta_clicked", label: "CTA clicks" }, - { event: "pricing_plan_clicked", label: "Pricing clicks" }, + { event: "cta_clicked", label: "CTA clickers", unique: true }, + { event: "pricing_plan_clicked", label: "Pricing clickers", unique: true }, { event: "enterprise_contact_submitted", label: "Enterprise leads" }, ]; @@ -268,7 +275,8 @@ async function fetchReport(window: ReportWindow): Promise { const eventList = EVENTS.map((e) => `'${e.event}'`).join(","); const eventsQuery = ` - SELECT event, ${periodExpr} AS period, count() AS hits + SELECT event, ${periodExpr} AS period, count() AS hits, + count(DISTINCT person_id) AS unique_hits FROM events WHERE event IN (${eventList}) AND ${rangeExpr} GROUP BY event, period`; @@ -433,18 +441,23 @@ async function fetchReport(window: ReportWindow): Promise { } const eventsData = new Map(); + const uniqueEvents = new Set( + EVENTS.filter((e) => e.unique).map((e) => e.event), + ); for (const { event } of EVENTS) { eventsData.set(event, { current: 0, previous: 0 }); } for (const row of events) { - const entry = eventsData.get(String(row[0])); + const event = String(row[0]); + const entry = eventsData.get(event); if (!entry) { continue; } + const value = uniqueEvents.has(event) ? num(row[3]) : num(row[2]); if (row[1] === "current") { - entry.current = num(row[2]); + entry.current = value; } else { - entry.previous = num(row[2]); + entry.previous = value; } } From a4ee447ecb3edd9ae78752b3155d3f851653397f Mon Sep 17 00:00:00 2001 From: smakosh Date: Mon, 20 Jul 2026 21:12:23 +0200 Subject: [PATCH 4/4] feat(devpass): track cap hits and pass redeems Close the top of the Reset Pass upsell funnel: capture devpass_weekly_cap_hit_viewed when the dashboard renders the exhausted weekly premium meter (tier, usage, passes available), and capture reset_pass_redeemed server-side in the redeem route, which previously emitted no analytics. Enables the cap-hit -> purchase conversion funnel per tier. Claude-Session: https://claude.ai/code/session_01FYyCRLR4CU4FWoiSi7hd1K --- apps/api/src/routes/dev-plans.ts | 12 +++++++++ .../dashboard/components/UsageOverview.tsx | 27 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/apps/api/src/routes/dev-plans.ts b/apps/api/src/routes/dev-plans.ts index 9bd65d0f72..5dddc3787f 100644 --- a/apps/api/src/routes/dev-plans.ts +++ b/apps/api/src/routes/dev-plans.ts @@ -7,6 +7,7 @@ import { executeSelfRefund, isSelfRefundCandidateType, } from "@/lib/self-refund.js"; +import { posthog } from "@/posthog.js"; import { ensureStripeCustomer, finalizeDevPlanSetupSession, @@ -3216,6 +3217,17 @@ devPlans.openapi(redeemResetPass, async (c) => { }, }); + posthog.capture({ + distinctId: user.id, + event: "reset_pass_redeemed", + groups: { organization: personalOrg.id }, + properties: { + devPlan: tier, + source, + organization: personalOrg.id, + }, + }); + return c.json({ success: true, source, diff --git a/apps/code/src/app/dashboard/components/UsageOverview.tsx b/apps/code/src/app/dashboard/components/UsageOverview.tsx index 5110bbd84c..c68029f84d 100644 --- a/apps/code/src/app/dashboard/components/UsageOverview.tsx +++ b/apps/code/src/app/dashboard/components/UsageOverview.tsx @@ -2,7 +2,10 @@ import { format, formatDistanceToNowStrict } from "date-fns"; import { Activity, Coins, Cpu, Gem, TrendingUp } from "lucide-react"; +import { usePostHog } from "posthog-js/react"; +import { useEffect } from "react"; +import { useAppConfig } from "@/lib/config"; import { useApi } from "@/lib/fetch-client"; import { AgentModelUsageChart } from "./AgentModelUsageChart"; @@ -219,6 +222,8 @@ export default function UsageOverview({ cycle = "monthly", }: UsageOverviewProps) { const api = useApi(); + const posthog = usePostHog(); + const { posthogKey } = useAppConfig(); const tierKey = planName.toLowerCase(); // The monthly credit pool (not the weekly premium allowance) is the hard @@ -226,6 +231,28 @@ export default function UsageOverview({ // card gives way to the upgrade/PAYG promo. const monthlyExhausted = creditsLimit > 0 && creditsUsed >= creditsLimit; + // Top of the Reset Pass upsell funnel: the user sees the exhausted weekly + // premium meter. reset_pass_purchased/redeemed are captured server-side, + // so without this event the funnel has a bottom but no top. + const weeklyExhausted = + premiumWeeklyLimit > 0 && premiumCreditsUsed >= premiumWeeklyLimit; + useEffect(() => { + if (!posthogKey || !weeklyExhausted) { + return; + } + posthog.capture("devpass_weekly_cap_hit_viewed", { + tier: tierKey, + weeklyLimit: premiumWeeklyLimit, + weeklyUsed: premiumCreditsUsed, + resetsAt: premiumWeekResetsAt, + purchasedPasses: resetPasses, + includedPassesRemaining: includedResetPassesRemaining, + monthlyExhausted, + }); + // Fire once per exhausted dashboard view, not on every prop tick. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [posthogKey, weeklyExhausted]); + const { data: activity } = api.useQuery( "get", "/activity",