feat: add CLI preview landing page with HubSpot form integration

- Create /cli-preview route with noindex robots meta
- Add HubSpotForm component with placeholder state for unconfigured forms
- Implement UTM attribution parsing and hidden field mapping
- Add PostHog analytics events for funnel tracking
- Include post-submit success state with Cloud CTA links
- Support environment variables:
  - NEXT_PUBLIC_HUBSPOT_PORTAL_ID
  - NEXT_PUBLIC_HUBSPOT_FORM_ID_CLI_PREVIEW
This commit is contained in:
Roo Code 2026-01-07 22:34:16 +00:00
parent e3b90fb182
commit 44f9b0366b
4 changed files with 561 additions and 0 deletions

View file

@ -0,0 +1,257 @@
"use client"
import { useEffect, useState } from "react"
import { useSearchParams, usePathname } from "next/navigation"
import { usePostHog } from "posthog-js/react"
import Link from "next/link"
import { HubSpotForm } from "@/components/forms/hubspot-form"
import { parseAttributionParams, attributionToHiddenFields } from "@/lib/attribution"
import { EXTERNAL_LINKS } from "@/lib/constants"
export function CliPreviewContent() {
const searchParams = useSearchParams()
const pathname = usePathname()
const posthog = usePostHog()
const [isSubmitted, setIsSubmitted] = useState(false)
const [hiddenFields, setHiddenFields] = useState<Record<string, string>>({})
// Get HubSpot config from environment
const portalId = process.env.NEXT_PUBLIC_HUBSPOT_PORTAL_ID
const formId = process.env.NEXT_PUBLIC_HUBSPOT_FORM_ID_CLI_PREVIEW
// Parse attribution parameters and set hidden fields
useEffect(() => {
const attributionParams = parseAttributionParams(searchParams, pathname)
const fields = attributionToHiddenFields(attributionParams)
setHiddenFields(fields)
// Capture pageview event with attribution
posthog?.capture("cli_preview_viewed", {
...attributionParams,
page_path: pathname,
})
}, [searchParams, pathname, posthog])
// Capture form render events
useEffect(() => {
if (portalId && formId) {
posthog?.capture("cli_preview_form_rendered", {
...hiddenFields,
})
} else {
posthog?.capture("cli_preview_form_placeholder_shown", {
...hiddenFields,
})
}
}, [portalId, formId, hiddenFields, posthog])
const handleFormSubmit = () => {
setIsSubmitted(true)
posthog?.capture("cli_preview_form_submitted", {
...hiddenFields,
})
}
const handleLoginClick = () => {
posthog?.capture("cli_preview_nextstep_login_clicked", {
...hiddenFields,
})
}
const handleSignupClick = () => {
posthog?.capture("cli_preview_nextstep_signup_clicked", {
...hiddenFields,
})
}
return (
<div className="min-h-screen bg-gradient-to-b from-white to-gray-50 dark:from-gray-950 dark:to-gray-900">
{/* Hero Section */}
<section className="mx-auto max-w-7xl px-6 py-12 sm:py-20 lg:px-8">
<div className="mx-auto max-w-3xl text-center">
<h1 className="text-4xl font-bold tracking-tight text-gray-900 dark:text-white sm:text-6xl">
Get an early preview of the Roo Code CLI
</h1>
<p className="mt-6 text-lg leading-8 text-gray-600 dark:text-gray-300">
If you like Roo Code in VS Code, you will love scripting it. Join the early preview to help
shape a CLI designed for power users, automation, and teams.
</p>
</div>
</section>
{/* Benefits Section */}
<section className="mx-auto max-w-7xl px-6 py-12 lg:px-8">
<div className="mx-auto max-w-3xl">
<ul className="space-y-4">
<li className="flex items-start gap-4">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-100 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</div>
<div>
<h3 className="font-semibold text-gray-900 dark:text-white">
Run Roo Code workflows from the terminal
</h3>
<p className="mt-1 text-gray-600 dark:text-gray-400">
Execute powerful AI-driven coding tasks directly from your command line
</p>
</div>
</li>
<li className="flex items-start gap-4">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-100 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</div>
<div>
<h3 className="font-semibold text-gray-900 dark:text-white">
Bring your own model keys and configs
</h3>
<p className="mt-1 text-gray-600 dark:text-gray-400">
Full control over your AI models and configuration settings
</p>
</div>
</li>
<li className="flex items-start gap-4">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-100 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</div>
<div>
<h3 className="font-semibold text-gray-900 dark:text-white">
Designed for automation and repeatability
</h3>
<p className="mt-1 text-gray-600 dark:text-gray-400">
Perfect for CI/CD pipelines and scripted workflows
</p>
</div>
</li>
<li className="flex items-start gap-4">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-100 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</div>
<div>
<h3 className="font-semibold text-gray-900 dark:text-white">
Help us prioritize features that matter
</h3>
<p className="mt-1 text-gray-600 dark:text-gray-400">
Your feedback shapes the future of the Roo Code CLI
</p>
</div>
</li>
</ul>
</div>
</section>
{/* Form Section */}
<section className="mx-auto max-w-7xl px-6 py-12 lg:px-8">
<div className="mx-auto max-w-2xl">
{!isSubmitted ? (
<>
<div className="mb-8 text-center">
<h2 className="text-3xl font-bold text-gray-900 dark:text-white">
Join the Early Preview
</h2>
<p className="mt-4 text-gray-600 dark:text-gray-400">
We&apos;re rolling access out in batches. Tell us what you want to do with the CLI
and we&apos;ll follow up with next steps.
</p>
</div>
<HubSpotForm
portalId={portalId}
formId={formId}
onSubmitted={handleFormSubmit}
hiddenFields={hiddenFields}
className="rounded-lg bg-white p-8 shadow-lg dark:bg-gray-800"
/>
</>
) : (
<div className="rounded-lg bg-white p-12 text-center shadow-lg dark:bg-gray-800">
<div className="mb-6">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-green-100 text-green-600 dark:bg-green-900/20 dark:text-green-400">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
</div>
<h3 className="mb-4 text-2xl font-bold text-gray-900 dark:text-white">
Thank You for Your Interest!
</h3>
<p className="mb-8 text-gray-600 dark:text-gray-400">
We&apos;ve received your request. Our team will review your submission and reach out
with next steps shortly.
</p>
<div className="mb-6 border-t border-gray-200 pt-6 dark:border-gray-700">
<p className="mb-4 text-sm font-medium text-gray-900 dark:text-white">
While you wait, jump into Roo Code Cloud to try Cloud Agents and stay close to new
releases.
</p>
<div className="flex flex-col items-center justify-center gap-4 sm:flex-row">
<Link
href={EXTERNAL_LINKS.CLOUD_APP_LOGIN}
onClick={handleLoginClick}
className="inline-flex w-full items-center justify-center rounded-lg border border-gray-300 bg-white px-6 py-3 text-sm font-semibold text-gray-900 shadow-sm transition-colors hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:hover:bg-gray-700 sm:w-auto">
Log in to Cloud
</Link>
<Link
href={EXTERNAL_LINKS.CLOUD_APP_SIGNUP}
onClick={handleSignupClick}
className="inline-flex w-full items-center justify-center rounded-lg bg-blue-600 px-6 py-3 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 sm:w-auto">
Sign up for Cloud
</Link>
</div>
</div>
</div>
)}
</div>
</section>
</div>
)
}

View file

@ -0,0 +1,49 @@
import type { Metadata } from "next"
import { SEO } from "@/lib/seo"
import { ogImageUrl } from "@/lib/og"
import { CliPreviewContent } from "./cli-preview-content"
const TITLE = "CLI Early Preview"
const DESCRIPTION =
"If you like Roo Code in VS Code, you will love scripting it. Join the early preview to help shape a CLI designed for power users, automation, and teams."
const OG_DESCRIPTION = "Early preview of the Roo Code CLI"
const PATH = "/cli-preview"
export const metadata: Metadata = {
title: TITLE,
description: DESCRIPTION,
robots: {
index: false,
follow: false,
},
alternates: {
canonical: `${SEO.url}${PATH}`,
},
openGraph: {
title: TITLE,
description: DESCRIPTION,
url: `${SEO.url}${PATH}`,
siteName: SEO.name,
images: [
{
url: ogImageUrl(TITLE, OG_DESCRIPTION),
width: 1200,
height: 630,
alt: TITLE,
},
],
locale: SEO.locale,
type: "website",
},
twitter: {
card: SEO.twitterCard,
title: TITLE,
description: DESCRIPTION,
images: [ogImageUrl(TITLE, OG_DESCRIPTION)],
},
}
export default function CliPreviewPage() {
return <CliPreviewContent />
}

View file

@ -0,0 +1,173 @@
"use client"
import { useEffect, useRef, useState } from "react"
declare global {
interface Window {
hbspt?: {
forms: {
create: (options: {
region?: string
portalId: string
formId: string
target: string
onFormSubmitted?: () => void
}) => void
}
}
}
}
export interface HubSpotFormProps {
portalId?: string
formId?: string
region?: string
className?: string
onSubmitted?: () => void
hiddenFields?: Record<string, string>
}
export function HubSpotForm({
portalId,
formId,
region = "na1",
className = "",
onSubmitted,
hiddenFields = {},
}: HubSpotFormProps) {
const formContainerRef = useRef<HTMLDivElement>(null)
const [isScriptLoaded, setIsScriptLoaded] = useState(false)
const [hasError, setHasError] = useState(false)
const formCreatedRef = useRef(false)
// Check if HubSpot IDs are configured
const isConfigured = Boolean(portalId && formId)
// Load HubSpot script
useEffect(() => {
if (!isConfigured) return
// Check if script already exists
const existingScript = document.querySelector('script[src*="js.hsforms.net"]')
if (existingScript) {
setIsScriptLoaded(true)
return
}
// Create and load script
const script = document.createElement("script")
script.src = "https://js.hsforms.net/forms/v2.js"
script.async = true
script.defer = true
script.onload = () => {
setIsScriptLoaded(true)
}
script.onerror = () => {
console.error("Failed to load HubSpot form script")
setHasError(true)
}
document.body.appendChild(script)
return () => {
// Don't remove the script on unmount as it might be used by other forms
}
}, [isConfigured])
// Create form once script is loaded
useEffect(() => {
if (!isConfigured || !isScriptLoaded || !window.hbspt || !formContainerRef.current || formCreatedRef.current) {
return
}
try {
// Mark as created before calling create to prevent double-creation
formCreatedRef.current = true
window.hbspt.forms.create({
region,
portalId: portalId!,
formId: formId!,
target: `#${formContainerRef.current.id}`,
onFormSubmitted: () => {
if (onSubmitted) {
onSubmitted()
}
},
})
// Set hidden fields after form is created
// HubSpot forms need a moment to render
setTimeout(() => {
if (Object.keys(hiddenFields).length > 0) {
Object.entries(hiddenFields).forEach(([name, value]) => {
const input = document.querySelector<HTMLInputElement>(`input[name="${name}"]`)
if (input) {
input.value = value
}
})
}
}, 500)
} catch (error) {
console.error("Error creating HubSpot form:", error)
setHasError(true)
}
}, [isConfigured, isScriptLoaded, portalId, formId, region, onSubmitted, hiddenFields])
// Show placeholder if not configured
if (!isConfigured) {
return (
<div className={`rounded-lg border border-dashed border-gray-300 bg-gray-50 p-8 text-center ${className}`}>
<div className="mx-auto max-w-md space-y-4">
<div className="text-4xl">📝</div>
<h3 className="text-lg font-semibold text-gray-900">Form Configuration Pending</h3>
<p className="text-sm text-gray-600">
The HubSpot form for CLI preview is not yet configured. Please set the following environment
variables:
</p>
<div className="rounded bg-white p-4 text-left font-mono text-xs text-gray-700">
<div>NEXT_PUBLIC_HUBSPOT_PORTAL_ID</div>
<div>NEXT_PUBLIC_HUBSPOT_FORM_ID_CLI_PREVIEW</div>
</div>
{process.env.NODE_ENV === "development" && (
<p className="text-xs text-gray-500">
This message is only visible in development. In production, consider showing a contact email
or alternative CTA.
</p>
)}
</div>
</div>
)
}
// Show error state if script failed to load
if (hasError) {
return (
<div className={`rounded-lg border border-red-200 bg-red-50 p-8 text-center ${className}`}>
<div className="mx-auto max-w-md space-y-4">
<div className="text-4xl"></div>
<h3 className="text-lg font-semibold text-red-900">Unable to Load Form</h3>
<p className="text-sm text-red-700">
We&apos;re having trouble loading the form. This might be due to an ad blocker or connectivity
issue.
</p>
<p className="text-sm text-gray-600">
Please reach out to us directly at{" "}
<a href="mailto:support@roocode.com" className="font-medium text-blue-600 hover:underline">
support@roocode.com
</a>
</p>
</div>
</div>
)
}
// Render form container
return (
<div className={className}>
<div ref={formContainerRef} id="hubspot-form-cli-preview" />
</div>
)
}

View file

@ -0,0 +1,82 @@
/**
* Parse attribution parameters from URL search params
*/
export interface AttributionParams {
utm_source?: string
utm_medium?: string
utm_campaign?: string
utm_content?: string
utm_term?: string
ref?: string
landing_path?: string
landing_url?: string
}
/**
* Extract attribution parameters from URLSearchParams
*/
export function parseAttributionParams(searchParams: URLSearchParams, pathname?: string): AttributionParams {
const params: AttributionParams = {}
// Extract UTM parameters
const utmSource = searchParams.get("utm_source")
const utmMedium = searchParams.get("utm_medium")
const utmCampaign = searchParams.get("utm_campaign")
const utmContent = searchParams.get("utm_content")
const utmTerm = searchParams.get("utm_term")
const ref = searchParams.get("ref")
if (utmSource) params.utm_source = utmSource
if (utmMedium) params.utm_medium = utmMedium
if (utmCampaign) params.utm_campaign = utmCampaign
if (utmContent) params.utm_content = utmContent
if (utmTerm) params.utm_term = utmTerm
if (ref) params.ref = ref
// Add landing path and URL if available
if (typeof window !== "undefined") {
if (pathname) {
params.landing_path = pathname
}
params.landing_url = window.location.href
}
return params
}
/**
* Get referrer from document or explicit ref parameter
*/
export function getReferrer(searchParams: URLSearchParams): string | undefined {
// First check for explicit ref parameter
const explicitRef = searchParams.get("ref")
if (explicitRef) {
return explicitRef
}
// Fall back to document referrer
if (typeof document !== "undefined" && document.referrer) {
return document.referrer
}
return undefined
}
/**
* Convert attribution params to HubSpot hidden fields format
*/
export function attributionToHiddenFields(params: AttributionParams): Record<string, string> {
const fields: Record<string, string> = {}
// Only include fields that have values
if (params.utm_source) fields.utm_source = params.utm_source
if (params.utm_medium) fields.utm_medium = params.utm_medium
if (params.utm_campaign) fields.utm_campaign = params.utm_campaign
if (params.utm_content) fields.utm_content = params.utm_content
if (params.utm_term) fields.utm_term = params.utm_term
if (params.ref) fields.ref = params.ref
if (params.landing_path) fields.landing_path = params.landing_path
if (params.landing_url) fields.landing_url = params.landing_url
return fields
}