Skip to content
Merged
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
6 changes: 3 additions & 3 deletions apps/web/REVENUE-PROCESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ Recurring revenue is anchored on **UVAI Workflow Pro** ($39/mo or $390/yr) via S

## Entry point

- **Pricing UI:** `src/app/pricing/page.tsx`
- **Pro CTA:** `ProCheckoutButton` → `POST /api/billing/checkout` (Turnstile required)
- **Home + Pricing UI:** `src/app/page.tsx` (`HomeProCheckout`) and `src/app/pricing/page.tsx`
- **Pro CTA:** `ProCheckoutButton` → `POST /api/billing/checkout` (Turnstile required). Same button on Home and Pricing — no second Stripe path.
- **Renewal:** `POST /api/billing/renew` (no Turnstile; for returning subscribers)

## Environment
Expand All @@ -18,7 +18,7 @@ Copy from Vercel or use test values in `.env.local`:
| `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Client Stripe.js (future portal) |
| `STRIPE_PRICE_PRO_MONTHLY` / `STRIPE_PRICE_PRO_ANNUAL` | **Required in production.** Last-resort documented fallbacks (non-prod only): `price_1U9AbLAmTgsI2zgNEZD4Kwed` ($39/mo, lookup `uvai-workflow-pro-monthly`) and `price_1U9AbLAmTgsI2zgN0SM70JN9` ($390/yr, lookup `uvai-workflow-pro-annual`). Product `prod_V9TYXeVHLQGrVW`. Never reuse dead EventRelay Pro $19/$180 IDs. |
| `STRIPE_WEBHOOK_SECRET` | Required for `/api/billing/webhook` (checkout.session.completed) |
| `NEXT_PUBLIC_TURNSTILE_SITE_KEY` | Widget on pricing |
| `NEXT_PUBLIC_TURNSTILE_SITE_KEY` | Widget on Home and Pricing |
| `TURNSTILE_SECRET_KEY` | Server siteverify |
| `GROK_BILLING_LEAD_MODEL` | Metadata on subscriptions (`grok-4-1-fast` default) |
| `NEXT_PUBLIC_APP_URL` | Checkout success/cancel URLs |
Expand Down
9 changes: 9 additions & 0 deletions apps/web/playwright/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,21 @@ test.describe('UVAI Production-Path Smoke Suite', () => {

await expect(page).toHaveTitle(/UVAI/i);
await expect(page.getByText('Universal Video Action Intelligence')).toBeVisible();
await expect(
page.getByRole('heading', { name: /Paste a YouTube URL\. Open the Studio workbench\./i })
).toBeVisible();
await expect(page.getByText(/Transcript quality varies by source/i)).toBeVisible();
await expect(page.getByText('Loading studio')).toHaveCount(0);
await expect(page.getByLabel(/YouTube URL/i)).toBeVisible();
await expect(page.getByRole('button', { name: /Run in Studio/i })).toBeVisible();
await expect(page.getByText('$39')).toBeVisible();
await expect(page.getByText('$199/mo')).toBeVisible();
await expect(page.getByRole('link', { name: /Get Pro/i }).first()).toBeVisible();
await expect(page.getByTestId('home-pro-checkout')).toBeVisible();
await expect(page.getByRole('button', { name: /Monthly checkout/i })).toBeVisible();
await expect(page.getByRole('button', { name: /Annual checkout/i })).toBeVisible();
await expect(page.getByTestId('pro-checkout-button')).toBeVisible();
Comment on lines +57 to +59
await expect(page.getByTestId('turnstile-widget')).toBeVisible();
});

test('Features and playground fold into Home', async ({ page }) => {
Expand Down
9 changes: 6 additions & 3 deletions apps/web/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ export const metadata: Metadata = {
default: 'UVAI — Video to Workflow',
template: '%s | UVAI',
},
description: 'Paste a YouTube URL. UVAI turns video evidence into useful workflows, exports, and deployable next steps.',
description:
'Paste a YouTube URL. Open the Studio workbench to start a hashed Video Pack run. Transcript quality varies by source.',
keywords: ['video to software', 'AI video analysis', 'video to code', 'agentic video', 'code generation', 'video API', 'UVAI'],
authors: [{ name: 'UVAI' }],
creator: 'UVAI',
Expand All @@ -40,12 +41,14 @@ export const metadata: Metadata = {
url: 'https://uvai.io',
siteName: 'UVAI',
title: 'UVAI — Video to Workflow',
description: 'Paste a YouTube URL. UVAI turns video evidence into useful workflows, exports, and deployable next steps.',
description:
'Paste a YouTube URL. Open the Studio workbench to start a hashed Video Pack run. Transcript quality varies by source.',
},
twitter: {
card: 'summary_large_image',
title: 'UVAI — Video to Workflow',
description: 'Paste a YouTube URL. UVAI turns video evidence into useful workflows, exports, and deployable next steps.',
description:
'Paste a YouTube URL. Open the Studio workbench to start a hashed Video Pack run. Transcript quality varies by source.',
creator: '@groupthinking',
},
robots: {
Expand Down
12 changes: 6 additions & 6 deletions apps/web/src/app/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ImageResponse } from 'next/og';

export const alt = 'UVAI turns verified video evidence into durable workflows';
export const alt = 'UVAI — paste a YouTube URL and open the Studio workbench';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';

Expand Down Expand Up @@ -49,22 +49,22 @@ export default function OpenGraphImage() {
textTransform: 'uppercase',
}}
>
Evidence first
Studio workbench
</div>
</div>

<div style={{ display: 'flex', flexDirection: 'column', maxWidth: 920 }}>
<div style={{ color: '#6af2de', display: 'flex', fontSize: 20, fontWeight: 700, letterSpacing: 4, marginBottom: 20, textTransform: 'uppercase' }}>
Video, verified transcript, durable workflow
Paste a YouTube URL. Open Studio.
</div>
<div style={{ display: 'flex', fontSize: 70, fontWeight: 800, letterSpacing: -3, lineHeight: 1.04 }}>
Build from source evidence, not a model&apos;s best guess.
<div style={{ display: 'flex', fontSize: 48, fontWeight: 800, letterSpacing: -2, lineHeight: 1.12 }}>
Start a hashed Video Pack run. Transcript quality varies by source.
</div>
</div>

<div style={{ alignItems: 'center', borderTop: '1px solid rgba(248,245,253,0.14)', display: 'flex', justifyContent: 'space-between', paddingTop: 24 }}>
<div style={{ color: 'rgba(248,245,253,0.62)', display: 'flex', fontSize: 18 }}>
Captions or speech-to-text required. Provenance included.
Not a guaranteed production E2E.
</div>
<div style={{ color: '#f8f5fd', display: 'flex', fontSize: 18, fontWeight: 700 }}>
uvai.io
Expand Down
15 changes: 8 additions & 7 deletions apps/web/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import Nav from '@/components/Nav';
import Footer from '@/components/Footer';
import HomePasteForm from '@/components/home/HomePasteForm';
import HomeProCheckout from '@/components/home/HomeProCheckout';
import {
WORKFLOW_PRO_PRODUCT_NAME,
workflowProPriceLabel,
Expand All @@ -11,7 +11,7 @@ import {
export const metadata: Metadata = {
title: 'UVAI — Universal Video Action Intelligence',
description:
'Paste a YouTube URL. UVAI turns video evidence into useful workflows, exports, and deployable next steps.',
'Paste a YouTube URL. Open the Studio workbench to start a hashed Video Pack run. Transcript quality varies by source.',
alternates: { canonical: '/' },
};

Expand Down Expand Up @@ -47,10 +47,11 @@ export default function HomePage() {
Universal Video Action Intelligence
</p>
<h1 className="mt-8 max-w-3xl font-heading text-4xl font-black leading-tight tracking-tight md:text-6xl">
Paste a YouTube URL. Ship the work.
Paste a YouTube URL. Open the Studio workbench.
</h1>
<p className="mt-5 max-w-2xl text-base leading-8 text-white/50 md:text-lg">
Hashed Video Pack, transcript, events, and exports on one Studio workbench. Not a demo.
Starts a hashed Video Pack run in Studio (player, events, exports). Transcript quality
varies by source — not a guaranteed production E2E.
</p>

<div className="mt-10 w-full">
Expand All @@ -71,9 +72,9 @@ export default function HomePage() {
))}
</div>

<Link href="/pricing" className="btn btn-primary mt-8 px-8 py-3 text-sm">
Get Pro
</Link>
<div id="get-pro" className="mt-10 w-full max-w-lg" aria-label="Get Pro">
<HomeProCheckout />
</div>
</div>
</section>

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/Nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export default function Nav({ rightSlot, subtitle, fixed = false }: NavProps) {

<div className="flex items-center gap-3">
{rightSlot || (
<Link href="/pricing" className="btn btn-primary py-2 px-5 text-sm">
<Link href="/#get-pro" className="btn btn-primary py-2 px-5 text-sm">
Get Pro
</Link>
)}
Expand Down
12 changes: 6 additions & 6 deletions apps/web/src/components/StructuredData.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
const howToJsonLd = {
'@context': 'https://schema.org',
'@type': 'HowTo',
name: 'Turn a YouTube video into a workflow with UVAI',
name: 'Open the UVAI Studio workbench from a YouTube URL',
description:
'Paste a YouTube URL, let UVAI extract transcript and events, then export or deploy actionable outputs.',
'Paste a YouTube URL to open Studio and start a hashed Video Pack run. Transcript quality varies by source.',
step: [
{
'@type': 'HowToStep',
Expand All @@ -14,14 +14,14 @@ const howToJsonLd = {
{
'@type': 'HowToStep',
position: 2,
name: 'Watch agents work',
text: 'UVAI transcribes, extracts events, and streams pipeline progress.',
name: 'Open Studio',
text: 'Studio starts a hashed Video Pack run (player, events, exports). Transcript quality varies by source.',
},
{
'@type': 'HowToStep',
position: 3,
name: 'Get results',
text: 'Download artifacts, handoff briefs, or deploy when adapters are configured.',
name: 'Use the workbench',
text: 'Review Studio outputs when the run produces them. Not a guaranteed production E2E.',
},
],
};
Expand Down
73 changes: 73 additions & 0 deletions apps/web/src/components/home/HomeProCheckout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
'use client';

import { useState } from 'react';
import Link from 'next/link';
import ProCheckoutButton from '@/components/billing/ProCheckoutButton';
import {
WORKFLOW_PRO_PRODUCT_NAME,
workflowProPriceLabel,
} from '@/lib/billing/checkout-config';

/**
* Home Get Pro island. Reuses ProCheckoutButton (Turnstile + existing Stripe session).
* Non-Pro offers on the sell grid stay copy-only — no extra Stripe SKUs here.
*/
export default function HomeProCheckout() {
const [annual, setAnnual] = useState(false);

return (
<div
data-testid="home-pro-checkout"
className="rounded-3xl border border-teal-400/35 bg-teal-400/[0.06] p-6 text-left shadow-2xl shadow-teal-500/10 sm:p-8"
>
<p className="text-sm font-bold uppercase tracking-wider text-teal-300">Get Pro</p>
<p
data-testid="home-workflow-pro-selected-price"
className="mt-3 font-heading text-3xl font-black tracking-tight md:text-4xl"
>
{workflowProPriceLabel(annual)}
</p>
<p className="mt-1 text-xs text-white/35">
{WORKFLOW_PRO_PRODUCT_NAME} · {workflowProPriceLabel(false)} or {workflowProPriceLabel(true)}
</p>
<p className="mt-4 text-sm leading-7 text-white/45">
Bot-protected checkout for confirmed external dispatch. Stripe shows the exact amount
and renewal terms before you pay.
</p>

<div className="mt-5 inline-flex rounded-2xl border border-white/[0.08] bg-white/[0.04] p-1.5">
<button
type="button"
onClick={() => setAnnual(false)}
aria-pressed={!annual}
className={`rounded-xl px-5 py-2 text-sm font-semibold transition ${!annual ? 'bg-white/[0.1] text-white' : 'text-white/40'}`}
>
Monthly checkout
</button>
<button
type="button"
onClick={() => setAnnual(true)}
aria-pressed={annual}
className={`rounded-xl px-5 py-2 text-sm font-semibold transition ${annual ? 'bg-white/[0.1] text-white' : 'text-white/40'}`}
>
Annual checkout
</button>
</div>

<div className="mt-6">
<ProCheckoutButton
annual={annual}
label={`Continue to ${WORKFLOW_PRO_PRODUCT_NAME} ${workflowProPriceLabel(annual)} checkout`}
/>
Comment on lines +58 to +61
</div>
<p className="mt-3 text-center text-xs leading-5 text-white/30">
Same checkout as Pricing. Core paste and Studio stay available without paying.
</p>
<p className="mt-4 text-center">
<Link href="/pricing" className="text-sm text-white/45 underline-offset-4 hover:text-white/70 hover:underline">
See all plans
</Link>
</p>
</div>
);
}
50 changes: 45 additions & 5 deletions apps/web/src/lib/__tests__/home-sell-surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,37 @@ describe('Home is a sell page; Studio is the workbench', () => {
expect(home).toContain('Universal Video Action Intelligence');
expect(home).toContain('HomePasteForm');
expect(home).toContain('$199');
expect(home).toContain('HomeProCheckout');
expect(home).toContain('Get Pro');
expect(home).not.toContain('$19/mo');
expect(home).not.toContain('$180');
});

it('does not claim arbitrary-video production E2E or a guaranteed transcript', () => {
const home = readSource('app/page.tsx');
const checkout = readSource('components/home/HomeProCheckout.tsx');
const nav = readSource('components/Nav.tsx');
const layout = readSource('app/layout.tsx');
const structured = readSource('components/StructuredData.tsx');
const ogAlt = readSource('app/opengraph-image.tsx');
const sellCopy = `${home}\n${checkout}\n${nav}\n${layout}\n${structured}\n${ogAlt}`;
expect(home).toContain('Paste a YouTube URL. Open the Studio workbench.');
expect(home).toContain('Starts a hashed Video Pack run in Studio (player, events, exports).');
expect(home).toContain('Transcript quality varies by source');
expect(home).toContain('not a guaranteed production E2E');
expect(layout).toContain('Transcript quality varies by source');
expect(structured).toContain('Transcript quality varies by source');
expect(sellCopy).not.toContain('Ship the work.');
expect(sellCopy).not.toContain('Not a demo.');
expect(sellCopy).not.toContain('turns video evidence into useful workflows');
expect(sellCopy).not.toContain('extract transcript and events');
expect(sellCopy).not.toContain('verified video evidence');
expect(sellCopy).not.toContain('verified transcript');
expect(sellCopy).not.toMatch(/any video/i);
expect(sellCopy).not.toMatch(/reliable.{0,40}transcript/i);
expect(sellCopy).not.toMatch(/grounded transcript/i);
});

it('keeps the live workbench only on /studio', () => {
const studio = readSource('app/studio/page.tsx');
expect(studio).toContain('OneLoopStudio');
Expand Down Expand Up @@ -66,15 +92,29 @@ describe('Home is a sell page; Studio is the workbench', () => {
expect(config).toContain("source: '/prototype'");
});

it('keeps Get Pro checkout on /pricing (CoS: do not fold onto Home this PR)', () => {
it('folds Get Pro checkout onto Home via the existing ProCheckoutButton path', () => {
const home = readSource('app/page.tsx');
const checkout = readSource('components/home/HomeProCheckout.tsx');
const button = readSource('components/billing/ProCheckoutButton.tsx');
const nav = readSource('components/Nav.tsx');
const pricing = readSource('app/pricing/page.tsx');
const config = readWebFile('next.config.js');
expect(home).toContain('href="/pricing"');
expect(home).toContain('Get Pro');
expect(home).not.toContain('ProCheckoutButton');
expect(home).not.toContain('turnstile');
expect(home).toContain('HomeProCheckout');
expect(home).toContain('id="get-pro"');
expect(home).not.toMatch(/href="\/pricing"[\s\S]{0,80}Get Pro/);
expect(checkout).toContain("from '@/components/billing/ProCheckoutButton'");
expect(checkout).toContain('ProCheckoutButton');
expect(checkout).toContain('Monthly checkout');
expect(checkout).toContain('Annual checkout');
expect(checkout).toContain('workflowProPriceLabel');
expect(checkout).toContain('WORKFLOW_PRO_PRODUCT_NAME');
expect(checkout).toContain('href="/pricing"');
expect(checkout).not.toContain('/api/billing/checkout');
expect(checkout).not.toContain('Maintain');
expect(checkout).not.toContain('Ship');
expect(button).toContain("fetch('/api/billing/checkout'");
expect(button).toContain('turnstileToken');
expect(nav).toContain('href="/#get-pro"');
expect(nav).toContain("href: '/pricing'");
expect(pricing).toContain('ProCheckoutButton');
expect(pricing).toContain('turnstile');
Expand Down
Loading
Loading