diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 5c433d4..c475848 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -2,7 +2,7 @@ Track the progress of phases, milestones, and tasks for the Vibes website. -**Last Updated:** 2025-12-24 +**Last Updated:** 2025-12-26 --- @@ -12,7 +12,7 @@ Track the progress of phases, milestones, and tasks for the Vibes website. |-------|------|--------|----------| | 1 | Foundation (MVP) | Complete | 100% | | 2 | Brand Identity | Complete | 100% | -| 3 | Content & Credibility | Not Started | 0% | +| 3 | Content & Credibility | In Progress | 20% | | 4 | Insights & Growth | Not Started | 0% | | 5 | Polish & Expand | Not Started | 0% | @@ -160,10 +160,30 @@ Track the progress of phases, milestones, and tasks for the Vibes website. ## Phase 3: Content & Credibility -**Goal:** Industry pages, case studies, about page, newsletter integration. +**Goal:** Industry pages, case studies, about page, newsletter integration, and product showcases. ### Milestones +#### Integrations +| Task | Status | PR | +|------|--------|-----| +| Newsletter provider setup | ⬜ Not Started | — | +| Newsletter signup form | ⬜ Not Started | — | + +#### Products +| Product | Page Status | Product Status | Description | +|---------|-------------|----------------|-------------| +| [Vibes](https://github.com/run-vibes/vibes) | ✅ Done | 🔄 In Progress | Remote control for your Claude Code sessions | +| Volt | ✅ Done | 🔄 In Progress | Volatility analysis, simulation & trade execution system | + +| Task | Status | PR | +|------|--------|-----| +| Products index route (/products) | ✅ Done | [#27](https://github.com/run-vibes/website/pull/27) | +| Vibes product page (/products/vibes) | ✅ Done | [#27](https://github.com/run-vibes/website/pull/27) | +| Volt teaser page (/products/volt) | ✅ Done | [#27](https://github.com/run-vibes/website/pull/27) | +| Waitlist API endpoint | ✅ Done | [#27](https://github.com/run-vibes/website/pull/27) | +| Product components (StatusBadge, CodeBlock, WaitlistForm, etc.) | ✅ Done | [#27](https://github.com/run-vibes/website/pull/27) | + #### Industry Pages | Page | Status | PR | |------|--------|-----| @@ -187,12 +207,6 @@ Track the progress of phases, milestones, and tasks for the Vibes website. | About page | ⬜ Not Started | — | | Team section | ⬜ Not Started | — | -#### Integrations -| Task | Status | PR | -|------|--------|-----| -| Newsletter provider setup | ⬜ Not Started | — | -| Newsletter signup form | ⬜ Not Started | — | - --- ## Phase 4: Insights & Growth @@ -239,6 +253,16 @@ Track the progress of phases, milestones, and tasks for the Vibes website. ## Recent Updates +### 2025-12-26 (Products Pages) +- Added product showcase pages for Vibes and Volt ([#27](https://github.com/run-vibes/website/pull/27)) + - Products index page (/products) with product cards + - Vibes product page (/products/vibes) with install command, features, and "How It Works" + - Volt teaser page (/products/volt) with atmospheric design and waitlist form +- Added waitlist API endpoint for email capture +- Created 6 new product components: StatusBadge, CodeBlock, FeatureGrid, BuiltByVibes, WaitlistForm, ProductCard +- Added Products link to navbar +- Updated Phase 3 to In Progress + ### 2025-12-24 (CI/CD Automation) - Simplified CI workflow - Cloudflare's GitHub integration handles Pages deployment (#19) - Added staging environment for chat worker diff --git a/docs/plans/09-products/design.md b/docs/plans/09-products/design.md new file mode 100644 index 0000000..adf266c --- /dev/null +++ b/docs/plans/09-products/design.md @@ -0,0 +1,249 @@ +# Products Pages Design + +## Overview + +Add product showcase pages to the Vibes website, serving dual purposes: +1. **Product marketing** — Treat Vibes and Volt as standalone products +2. **Lead generation** — Demonstrate Vibes studio's product development capabilities + +## Information Architecture + +### URL Structure + +``` +/products → Index page (both products) +/products/vibes → Full Vibes product page +/products/volt → Volt teaser page +``` + +### Navigation + +Add "Products" to main navbar between "Services" and "Contact". + +### Status Model + +```typescript +type ProductStatus = 'available' | 'coming-soon' +``` + +Drives badge styling, CTA text, and whether install commands appear. + +--- + +## Products Index Page (`/products`) + +### Hero Section + +- **Headline:** "What We're Building" +- **Subhead:** "Open source tools and platforms from the Vibes studio." + +### Product Cards + +2-column grid on desktop, stacked on mobile. Each card includes: + +- Screenshot thumbnail (16:9 ratio) +- Status badge ("Available" / "Coming Soon") +- Product name (large heading) +- One-liner tagline +- 3 feature bullets +- "Learn More →" CTA + +#### Vibes Card + +``` +[Screenshot: terminal with vibes claude command] +[Available badge] + +Vibes +Remote control for your Claude Code sessions + +• Control sessions from any device +• Native Rust plugin system +• Real-time session mirroring + +[Learn More →] +``` + +#### Volt Card + +``` +[Screenshot: blurred/cropped prototype dashboard] +[Coming Soon badge] + +Volt +Volatility analysis & trade execution + +• IV surfaces and Greeks analytics +• 11 options strategies built-in +• Backtest with synthetic or real data + +[Learn More →] +``` + +--- + +## Vibes Product Page (`/products/vibes`) + +### Hero Section + +- **Headline:** "Vibes" +- **Tagline:** "Remote control for your Claude Code sessions" +- **Description:** 1-2 sentences expanding on value prop +- **Primary CTA:** Copyable install command + ```bash + curl -sSf https://vibes.run/install | sh + ``` +- **Secondary CTA:** "Star on GitHub" with star count badge +- **Visual:** Terminal screenshot or animated demo + +### Features Grid (2x2) + +| Feature | Description | +|---------|-------------| +| Remote Access | Control sessions from your phone, tablet, or any device via web UI | +| Session Mirroring | Real-time sync between terminal and remote devices | +| Plugin System | Extend with native Rust plugins for custom workflows | +| Cross-Platform | Single binary for Linux, macOS, and Windows | + +### How It Works Section + +3-step visual flow: +1. Install vibes CLI +2. Run `vibes claude "your prompt"` +3. Access from any device at `localhost:7432` + +### Architecture Diagram + +Clean SVG recreation of the ASCII architecture from README. + +### Built by Vibes Callout + +Subtle banner: "Vibes is built by Vibes, the agentic consulting studio. Need custom AI tooling? [Let's talk →]" + +--- + +## Volt Teaser Page (`/products/volt`) + +### Atmospheric Background + +- Blurred, cropped screenshot of prototype dashboard +- Dark overlay gradient for text legibility +- Subtle animated grain or glow effect (matches site aesthetic) + +### Hero Section (centered, minimal) + +- **Status badge:** "Coming Soon" +- **Headline:** "Volt" +- **Tagline:** "Volatility analysis, simulation & trade execution" +- **Description:** "A comprehensive platform for traders who want to analyze options volatility, backtest strategies, and execute with confidence." +- No feature list — maintain mystery + +### Email Capture Form + +``` +[Email input: "Enter your email"] +[Button: "Get Early Access"] +``` + +- Success state: "You're on the list. We'll notify you when Volt launches." +- Stores to D1 `waitlist` table with `product: 'volt'` + +### Screenshot Gallery + +Below the fold, 2-3 static screenshots from HTML prototypes: +- "IV Surface Visualization" +- "Strategy Builder" +- "Backtest Results" + +Styled as floating cards with subtle shadows. + +### Built by Vibes Callout + +Same treatment as Vibes page. + +--- + +## Technical Implementation + +### New Routes + +``` +src/routes/products/index.tsx → Products index +src/routes/products/vibes.tsx → Vibes product page +src/routes/products/volt.tsx → Volt teaser page +``` + +### New Components + +``` +src/components/products/ProductCard.tsx → Card for index page +src/components/products/StatusBadge.tsx → "Available" / "Coming Soon" +src/components/products/FeatureGrid.tsx → 2x2 or 3-col feature display +src/components/products/WaitlistForm.tsx → Email capture for Volt +src/components/products/CodeBlock.tsx → Copyable install command +src/components/products/BuiltByVibes.tsx → Lead-gen callout banner +``` + +### Backend: Waitlist API + +**New endpoint:** `POST /api/waitlist` + +```typescript +// Request +{ email: string, product: string } + +// Response +{ success: true } | { error: string } +``` + +**Validation:** +- Email format validation +- Product must be in allowed list (`volt`, `vibes`) +- Rate limiting (reuse existing session-based approach) + +### Database: Waitlist Table + +```sql +-- 0003_waitlist.sql +CREATE TABLE waitlist ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + email TEXT NOT NULL, + product TEXT NOT NULL, -- 'volt' | 'vibes' | future products + created_at TEXT NOT NULL DEFAULT (datetime('now')), + + -- Optional context + referrer TEXT, -- Where they came from + user_agent TEXT, -- Browser info + + UNIQUE(email, product) -- Prevent duplicate signups per product +); + +CREATE INDEX idx_waitlist_product ON waitlist(product); +CREATE INDEX idx_waitlist_created ON waitlist(created_at); +``` + +--- + +## Assets Required + +### Vibes + +- Terminal screenshot showing `vibes claude` command +- Architecture diagram (SVG, recreated from ASCII) +- OG image for social sharing + +### Volt + +- 2-3 prototype screenshots (from HTML prototypes) +- Blurred hero background image +- OG image for social sharing + +--- + +## Lead Generation Tie-in + +Both product pages include a "Built by Vibes" callout: + +> "Vibes is built by Vibes, the agentic consulting studio. Need custom AI tooling? Let's talk →" + +Links to `/contact` to drive leads. diff --git a/docs/plans/09-products/implementation.md b/docs/plans/09-products/implementation.md new file mode 100644 index 0000000..ebcd429 --- /dev/null +++ b/docs/plans/09-products/implementation.md @@ -0,0 +1,1601 @@ +# Products Pages Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add product showcase pages for Vibes and Volt with waitlist email capture. + +**Architecture:** Three new routes (`/products`, `/products/vibes`, `/products/volt`) with shared product components. Waitlist API endpoint stores emails in D1. Placeholder assets for screenshots that can be swapped later. + +**Tech Stack:** TanStack Start, Tailwind CSS, class-variance-authority, Cloudflare Workers/D1 + +--- + +## Task 1: Waitlist Database Migration + +**Files:** +- Create: `workers/chat-api/migrations/0003_waitlist.sql` + +**Step 1: Write the migration** + +```sql +-- 0003_waitlist.sql +CREATE TABLE waitlist ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + email TEXT NOT NULL, + product TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + referrer TEXT, + user_agent TEXT, + UNIQUE(email, product) +); + +CREATE INDEX idx_waitlist_product ON waitlist(product); +CREATE INDEX idx_waitlist_created ON waitlist(created_at); +``` + +**Step 2: Run migration locally** + +```bash +just worker-migrate local +``` + +Expected: Migration applies successfully + +**Step 3: Commit** + +```bash +git add workers/chat-api/migrations/0003_waitlist.sql +git commit -m "feat: add waitlist table migration" +``` + +--- + +## Task 2: Waitlist API Endpoint + +**Files:** +- Modify: `workers/chat-api/src/index.ts` +- Create: `workers/chat-api/src/waitlist.ts` + +**Step 1: Create waitlist module** + +Create `workers/chat-api/src/waitlist.ts`: + +```typescript +import type { D1Database } from '@cloudflare/workers-types' + +export interface WaitlistEntry { + email: string + product: string + referrer?: string + userAgent?: string +} + +const VALID_PRODUCTS = new Set(['volt', 'vibes']) + +export function isValidEmail(email: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) +} + +export function isValidProduct(product: string): boolean { + return VALID_PRODUCTS.has(product) +} + +export async function addToWaitlist( + db: D1Database, + entry: WaitlistEntry, +): Promise<{ success: boolean; error?: string }> { + if (!isValidEmail(entry.email)) { + return { success: false, error: 'Invalid email format' } + } + + if (!isValidProduct(entry.product)) { + return { success: false, error: 'Invalid product' } + } + + try { + await db + .prepare( + `INSERT INTO waitlist (email, product, referrer, user_agent) + VALUES (?, ?, ?, ?) + ON CONFLICT (email, product) DO NOTHING`, + ) + .bind(entry.email.toLowerCase(), entry.product, entry.referrer ?? null, entry.userAgent ?? null) + .run() + + return { success: true } + } catch (err) { + console.error('Waitlist insert error:', err) + return { success: false, error: 'Failed to join waitlist' } + } +} +``` + +**Step 2: Add route to worker** + +In `workers/chat-api/src/index.ts`, add after the `/chat` route (around line 228): + +```typescript +import { addToWaitlist } from './waitlist' + +// ... existing code ... + + if (url.pathname === '/waitlist' && request.method === 'POST') { + try { + const body = (await request.json()) as { email: string; product: string } + const referrer = request.headers.get('Referer') ?? undefined + const userAgent = request.headers.get('User-Agent') ?? undefined + + const result = await addToWaitlist(env.DB, { + email: body.email, + product: body.product, + referrer, + userAgent, + }) + + if (!result.success) { + return jsonResponse({ error: result.error }, 400, origin) + } + + return jsonResponse({ success: true }, 200, origin) + } catch (err) { + console.error('Waitlist error:', err) + return jsonResponse({ error: 'Invalid request' }, 400, origin) + } + } +``` + +**Step 3: Test locally** + +```bash +just worker-dev +# In another terminal: +curl -X POST http://localhost:8787/waitlist \ + -H "Content-Type: application/json" \ + -d '{"email":"test@example.com","product":"volt"}' +``` + +Expected: `{"success":true}` + +**Step 4: Commit** + +```bash +git add workers/chat-api/src/waitlist.ts workers/chat-api/src/index.ts +git commit -m "feat: add waitlist API endpoint" +``` + +--- + +## Task 3: StatusBadge Component + +**Files:** +- Create: `src/components/products/StatusBadge/StatusBadge.tsx` +- Create: `src/components/products/StatusBadge/StatusBadge.test.tsx` +- Create: `src/components/products/StatusBadge/index.ts` + +**Step 1: Write the failing test** + +Create `src/components/products/StatusBadge/StatusBadge.test.tsx`: + +```tsx +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { StatusBadge } from './StatusBadge' + +describe('StatusBadge', () => { + it('renders available status with green styling', () => { + render() + const badge = screen.getByText('Available') + expect(badge).toBeInTheDocument() + expect(badge).toHaveClass('bg-green-500/10') + }) + + it('renders coming-soon status with amber styling', () => { + render() + const badge = screen.getByText('Coming Soon') + expect(badge).toBeInTheDocument() + expect(badge).toHaveClass('bg-amber-500/10') + }) +}) +``` + +**Step 2: Run test to verify it fails** + +```bash +pnpm test src/components/products/StatusBadge/StatusBadge.test.tsx +``` + +Expected: FAIL - module not found + +**Step 3: Write minimal implementation** + +Create `src/components/products/StatusBadge/StatusBadge.tsx`: + +```tsx +import { cn } from '@/lib/cn' +import { type VariantProps, cva } from 'class-variance-authority' + +const badgeVariants = cva( + 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium font-heading uppercase tracking-wide', + { + variants: { + status: { + available: 'bg-green-500/10 text-green-400 border border-green-500/20', + 'coming-soon': 'bg-amber-500/10 text-amber-400 border border-amber-500/20', + }, + }, + defaultVariants: { + status: 'available', + }, + }, +) + +export type ProductStatus = 'available' | 'coming-soon' + +interface StatusBadgeProps extends VariantProps { + status: ProductStatus + className?: string +} + +const statusLabels: Record = { + available: 'Available', + 'coming-soon': 'Coming Soon', +} + +export function StatusBadge({ status, className }: StatusBadgeProps) { + return {statusLabels[status]} +} +``` + +Create `src/components/products/StatusBadge/index.ts`: + +```typescript +export { StatusBadge, type ProductStatus } from './StatusBadge' +``` + +**Step 4: Run test to verify it passes** + +```bash +pnpm test src/components/products/StatusBadge/StatusBadge.test.tsx +``` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/components/products/StatusBadge/ +git commit -m "feat: add StatusBadge component" +``` + +--- + +## Task 4: CodeBlock Component + +**Files:** +- Create: `src/components/products/CodeBlock/CodeBlock.tsx` +- Create: `src/components/products/CodeBlock/CodeBlock.test.tsx` +- Create: `src/components/products/CodeBlock/index.ts` + +**Step 1: Write the failing test** + +Create `src/components/products/CodeBlock/CodeBlock.test.tsx`: + +```tsx +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { CodeBlock } from './CodeBlock' + +// Mock clipboard API +const mockWriteText = vi.fn() +Object.assign(navigator, { + clipboard: { writeText: mockWriteText }, +}) + +describe('CodeBlock', () => { + it('renders the code content', () => { + render() + expect(screen.getByText('npm install vibes')).toBeInTheDocument() + }) + + it('copies code to clipboard when copy button is clicked', async () => { + mockWriteText.mockResolvedValueOnce(undefined) + render() + + const copyButton = screen.getByRole('button', { name: /copy/i }) + fireEvent.click(copyButton) + + expect(mockWriteText).toHaveBeenCalledWith('curl example.com') + }) + + it('shows copied feedback after clicking', async () => { + mockWriteText.mockResolvedValueOnce(undefined) + render() + + const copyButton = screen.getByRole('button', { name: /copy/i }) + fireEvent.click(copyButton) + + expect(await screen.findByText(/copied/i)).toBeInTheDocument() + }) +}) +``` + +**Step 2: Run test to verify it fails** + +```bash +pnpm test src/components/products/CodeBlock/CodeBlock.test.tsx +``` + +Expected: FAIL - module not found + +**Step 3: Write minimal implementation** + +Create `src/components/products/CodeBlock/CodeBlock.tsx`: + +```tsx +import { cn } from '@/lib/cn' +import { useState } from 'react' + +interface CodeBlockProps { + code: string + className?: string +} + +export function CodeBlock({ code, className }: CodeBlockProps) { + const [copied, setCopied] = useState(false) + + const handleCopy = async () => { + await navigator.clipboard.writeText(code) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( +
+ {code} + +
+ ) +} +``` + +Create `src/components/products/CodeBlock/index.ts`: + +```typescript +export { CodeBlock } from './CodeBlock' +``` + +**Step 4: Run test to verify it passes** + +```bash +pnpm test src/components/products/CodeBlock/CodeBlock.test.tsx +``` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/components/products/CodeBlock/ +git commit -m "feat: add CodeBlock component with copy functionality" +``` + +--- + +## Task 5: FeatureGrid Component + +**Files:** +- Create: `src/components/products/FeatureGrid/FeatureGrid.tsx` +- Create: `src/components/products/FeatureGrid/FeatureGrid.test.tsx` +- Create: `src/components/products/FeatureGrid/index.ts` + +**Step 1: Write the failing test** + +Create `src/components/products/FeatureGrid/FeatureGrid.test.tsx`: + +```tsx +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { FeatureGrid } from './FeatureGrid' + +const features = [ + { title: 'Remote Access', description: 'Control from anywhere' }, + { title: 'Plugin System', description: 'Extend with plugins' }, +] + +describe('FeatureGrid', () => { + it('renders all feature titles', () => { + render() + expect(screen.getByText('Remote Access')).toBeInTheDocument() + expect(screen.getByText('Plugin System')).toBeInTheDocument() + }) + + it('renders all feature descriptions', () => { + render() + expect(screen.getByText('Control from anywhere')).toBeInTheDocument() + expect(screen.getByText('Extend with plugins')).toBeInTheDocument() + }) +}) +``` + +**Step 2: Run test to verify it fails** + +```bash +pnpm test src/components/products/FeatureGrid/FeatureGrid.test.tsx +``` + +Expected: FAIL - module not found + +**Step 3: Write minimal implementation** + +Create `src/components/products/FeatureGrid/FeatureGrid.tsx`: + +```tsx +import { cn } from '@/lib/cn' +import { Heading, Text } from '@/components/ui' + +export interface Feature { + title: string + description: string +} + +interface FeatureGridProps { + features: Feature[] + columns?: 2 | 3 + className?: string +} + +export function FeatureGrid({ features, columns = 2, className }: FeatureGridProps) { + return ( +
+ {features.map((feature) => ( +
+ + {feature.title} + + {feature.description} +
+ ))} +
+ ) +} +``` + +Create `src/components/products/FeatureGrid/index.ts`: + +```typescript +export { FeatureGrid, type Feature } from './FeatureGrid' +``` + +**Step 4: Run test to verify it passes** + +```bash +pnpm test src/components/products/FeatureGrid/FeatureGrid.test.tsx +``` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/components/products/FeatureGrid/ +git commit -m "feat: add FeatureGrid component" +``` + +--- + +## Task 6: BuiltByVibes Component + +**Files:** +- Create: `src/components/products/BuiltByVibes/BuiltByVibes.tsx` +- Create: `src/components/products/BuiltByVibes/BuiltByVibes.test.tsx` +- Create: `src/components/products/BuiltByVibes/index.ts` + +**Step 1: Write the failing test** + +Create `src/components/products/BuiltByVibes/BuiltByVibes.test.tsx`: + +```tsx +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { createMemoryHistory, createRootRoute, createRouter, RouterProvider } from '@tanstack/react-router' +import { BuiltByVibes } from './BuiltByVibes' + +function renderWithRouter(component: React.ReactNode) { + const rootRoute = createRootRoute({ component: () => component }) + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + return render() +} + +describe('BuiltByVibes', () => { + it('renders the callout text', () => { + renderWithRouter() + expect(screen.getByText(/Built by Vibes/i)).toBeInTheDocument() + }) + + it('renders link to contact page', () => { + renderWithRouter() + const link = screen.getByRole('link', { name: /let's talk/i }) + expect(link).toHaveAttribute('href', '/contact') + }) +}) +``` + +**Step 2: Run test to verify it fails** + +```bash +pnpm test src/components/products/BuiltByVibes/BuiltByVibes.test.tsx +``` + +Expected: FAIL - module not found + +**Step 3: Write minimal implementation** + +Create `src/components/products/BuiltByVibes/BuiltByVibes.tsx`: + +```tsx +import { cn } from '@/lib/cn' +import { Text } from '@/components/ui' +import { Link } from '@tanstack/react-router' + +interface BuiltByVibesProps { + className?: string +} + +export function BuiltByVibes({ className }: BuiltByVibesProps) { + return ( +
+ + Built by{' '} + Vibes, the + agentic consulting studio.{' '} + + Let's talk → + + +
+ ) +} +``` + +Create `src/components/products/BuiltByVibes/index.ts`: + +```typescript +export { BuiltByVibes } from './BuiltByVibes' +``` + +**Step 4: Run test to verify it passes** + +```bash +pnpm test src/components/products/BuiltByVibes/BuiltByVibes.test.tsx +``` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/components/products/BuiltByVibes/ +git commit -m "feat: add BuiltByVibes lead-gen callout component" +``` + +--- + +## Task 7: WaitlistForm Component + +**Files:** +- Create: `src/components/products/WaitlistForm/WaitlistForm.tsx` +- Create: `src/components/products/WaitlistForm/WaitlistForm.test.tsx` +- Create: `src/components/products/WaitlistForm/index.ts` + +**Step 1: Write the failing test** + +Create `src/components/products/WaitlistForm/WaitlistForm.test.tsx`: + +```tsx +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { WaitlistForm } from './WaitlistForm' + +const mockFetch = vi.fn() +global.fetch = mockFetch + +describe('WaitlistForm', () => { + beforeEach(() => { + mockFetch.mockReset() + }) + + it('renders email input and submit button', () => { + render() + expect(screen.getByPlaceholderText(/email/i)).toBeInTheDocument() + expect(screen.getByRole('button', { name: /get early access/i })).toBeInTheDocument() + }) + + it('submits email to waitlist API', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true }), + }) + + render() + + const input = screen.getByPlaceholderText(/email/i) + const button = screen.getByRole('button', { name: /get early access/i }) + + fireEvent.change(input, { target: { value: 'test@example.com' } }) + fireEvent.click(button) + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/waitlist'), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ email: 'test@example.com', product: 'volt' }), + }), + ) + }) + }) + + it('shows success message after submission', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true }), + }) + + render() + + fireEvent.change(screen.getByPlaceholderText(/email/i), { + target: { value: 'test@example.com' }, + }) + fireEvent.click(screen.getByRole('button')) + + await waitFor(() => { + expect(screen.getByText(/you're on the list/i)).toBeInTheDocument() + }) + }) +}) +``` + +**Step 2: Run test to verify it fails** + +```bash +pnpm test src/components/products/WaitlistForm/WaitlistForm.test.tsx +``` + +Expected: FAIL - module not found + +**Step 3: Write minimal implementation** + +Create `src/components/products/WaitlistForm/WaitlistForm.tsx`: + +```tsx +import { cn } from '@/lib/cn' +import { Button, Input, Text } from '@/components/ui' +import { useState } from 'react' + +interface WaitlistFormProps { + product: string + className?: string +} + +export function WaitlistForm({ product, className }: WaitlistFormProps) { + const [email, setEmail] = useState('') + const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle') + const [errorMessage, setErrorMessage] = useState('') + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setStatus('loading') + setErrorMessage('') + + try { + const apiUrl = import.meta.env.VITE_CHAT_API_URL || '' + const response = await fetch(`${apiUrl}/waitlist`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, product }), + }) + + const data = await response.json() + + if (!response.ok || !data.success) { + throw new Error(data.error || 'Failed to join waitlist') + } + + setStatus('success') + } catch (err) { + setStatus('error') + setErrorMessage(err instanceof Error ? err.message : 'Something went wrong') + } + } + + if (status === 'success') { + return ( +
+ + You're on the list! We'll notify you when {product === 'volt' ? 'Volt' : 'Vibes'} launches. + +
+ ) + } + + return ( +
+ setEmail(e.target.value)} + required + disabled={status === 'loading'} + className="flex-1" + /> + + {status === 'error' && ( + + {errorMessage} + + )} +
+ ) +} +``` + +Create `src/components/products/WaitlistForm/index.ts`: + +```typescript +export { WaitlistForm } from './WaitlistForm' +``` + +**Step 4: Run test to verify it passes** + +```bash +pnpm test src/components/products/WaitlistForm/WaitlistForm.test.tsx +``` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/components/products/WaitlistForm/ +git commit -m "feat: add WaitlistForm component for email capture" +``` + +--- + +## Task 8: ProductCard Component + +**Files:** +- Create: `src/components/products/ProductCard/ProductCard.tsx` +- Create: `src/components/products/ProductCard/ProductCard.test.tsx` +- Create: `src/components/products/ProductCard/index.ts` + +**Step 1: Write the failing test** + +Create `src/components/products/ProductCard/ProductCard.test.tsx`: + +```tsx +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { createMemoryHistory, createRootRoute, createRouter, RouterProvider } from '@tanstack/react-router' +import { ProductCard } from './ProductCard' + +function renderWithRouter(component: React.ReactNode) { + const rootRoute = createRootRoute({ component: () => component }) + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + return render() +} + +describe('ProductCard', () => { + const props = { + name: 'Vibes', + tagline: 'Remote control for Claude Code', + status: 'available' as const, + features: ['Feature 1', 'Feature 2'], + href: '/products/vibes', + } + + it('renders product name and tagline', () => { + renderWithRouter() + expect(screen.getByText('Vibes')).toBeInTheDocument() + expect(screen.getByText('Remote control for Claude Code')).toBeInTheDocument() + }) + + it('renders status badge', () => { + renderWithRouter() + expect(screen.getByText('Available')).toBeInTheDocument() + }) + + it('renders feature list', () => { + renderWithRouter() + expect(screen.getByText('Feature 1')).toBeInTheDocument() + expect(screen.getByText('Feature 2')).toBeInTheDocument() + }) + + it('renders link to product page', () => { + renderWithRouter() + expect(screen.getByRole('link', { name: /learn more/i })).toHaveAttribute( + 'href', + '/products/vibes', + ) + }) +}) +``` + +**Step 2: Run test to verify it fails** + +```bash +pnpm test src/components/products/ProductCard/ProductCard.test.tsx +``` + +Expected: FAIL - module not found + +**Step 3: Write minimal implementation** + +Create `src/components/products/ProductCard/ProductCard.tsx`: + +```tsx +import { cn } from '@/lib/cn' +import { Card, CardContent, Heading, Text } from '@/components/ui' +import { Link } from '@tanstack/react-router' +import { StatusBadge, type ProductStatus } from '../StatusBadge' + +interface ProductCardProps { + name: string + tagline: string + status: ProductStatus + features: string[] + href: string + image?: string + className?: string +} + +export function ProductCard({ + name, + tagline, + status, + features, + href, + image, + className, +}: ProductCardProps) { + return ( + + {image && ( +
+ {`${name} +
+ )} + +
+ +
+
+ + {name} + + {tagline} +
+
    + {features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ + Learn More → + +
+
+ ) +} +``` + +Create `src/components/products/ProductCard/index.ts`: + +```typescript +export { ProductCard } from './ProductCard' +``` + +**Step 4: Run test to verify it passes** + +```bash +pnpm test src/components/products/ProductCard/ProductCard.test.tsx +``` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/components/products/ProductCard/ +git commit -m "feat: add ProductCard component for products index" +``` + +--- + +## Task 9: Products Index Export + +**Files:** +- Create: `src/components/products/index.ts` + +**Step 1: Create barrel export** + +Create `src/components/products/index.ts`: + +```typescript +export { StatusBadge, type ProductStatus } from './StatusBadge' +export { CodeBlock } from './CodeBlock' +export { FeatureGrid, type Feature } from './FeatureGrid' +export { BuiltByVibes } from './BuiltByVibes' +export { WaitlistForm } from './WaitlistForm' +export { ProductCard } from './ProductCard' +``` + +**Step 2: Commit** + +```bash +git add src/components/products/index.ts +git commit -m "feat: add products components barrel export" +``` + +--- + +## Task 10: Placeholder Assets + +**Files:** +- Create: `public/images/products/vibes-terminal.svg` +- Create: `public/images/products/volt-dashboard.svg` +- Create: `public/images/products/volt-bg.svg` + +**Step 1: Create placeholder directory** + +```bash +mkdir -p public/images/products +``` + +**Step 2: Create Vibes terminal placeholder** + +Create `public/images/products/vibes-terminal.svg`: + +```svg + + + + + + + $ vibes claude "refactor the auth module" + ✓ Session started + → Web UI available at http://localhost:7432 + [Placeholder - replace with real screenshot] + +``` + +**Step 3: Create Volt dashboard placeholder** + +Create `public/images/products/volt-dashboard.svg`: + +```svg + + + + + + + IV Surface + Greeks Chart + P&L Attribution + Risk Limits + [Placeholder - replace with real screenshot] + +``` + +**Step 4: Create Volt background placeholder** + +Create `public/images/products/volt-bg.svg`: + +```svg + + + + + + + + + + + +``` + +**Step 5: Commit** + +```bash +git add public/images/products/ +git commit -m "feat: add placeholder product images" +``` + +--- + +## Task 11: Products Index Route + +**Files:** +- Create: `src/routes/products/index.tsx` + +**Step 1: Create products index route** + +Create `src/routes/products/index.tsx`: + +```tsx +import { Container } from '@/components/ui/Container' +import { Section } from '@/components/ui/Section' +import { Heading, Text } from '@/components/ui/Typography' +import { ProductCard } from '@/components/products' +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/products/')({ + component: ProductsPage, +}) + +const products = [ + { + name: 'Vibes', + tagline: 'Remote control for your Claude Code sessions', + status: 'available' as const, + features: [ + 'Control sessions from any device', + 'Native Rust plugin system', + 'Real-time session mirroring', + ], + href: '/products/vibes', + image: '/images/products/vibes-terminal.svg', + }, + { + name: 'Volt', + tagline: 'Volatility analysis & trade execution', + status: 'coming-soon' as const, + features: [ + 'IV surfaces and Greeks analytics', + '11 options strategies built-in', + 'Backtest with synthetic or real data', + ], + href: '/products/volt', + image: '/images/products/volt-dashboard.svg', + }, +] + +function ProductsPage() { + return ( + <> +
+ + + What We're Building + + + Open source tools and platforms from the Vibes studio. + + +
+ +
+ +
+ {products.map((product) => ( + + ))} +
+
+
+ + ) +} +``` + +**Step 2: Test dev server** + +```bash +pnpm dev +# Navigate to http://localhost:3000/products +``` + +Expected: Products index page renders with both product cards + +**Step 3: Commit** + +```bash +git add src/routes/products/index.tsx +git commit -m "feat: add products index page" +``` + +--- + +## Task 12: Vibes Product Route + +**Files:** +- Create: `src/routes/products/vibes.tsx` + +**Step 1: Create Vibes product route** + +Create `src/routes/products/vibes.tsx`: + +```tsx +import { Button, Container, Heading, Section, Text } from '@/components/ui' +import { BuiltByVibes, CodeBlock, FeatureGrid } from '@/components/products' +import { createFileRoute, Link } from '@tanstack/react-router' + +export const Route = createFileRoute('/products/vibes')({ + component: VibesPage, +}) + +const features = [ + { + title: 'Remote Access', + description: 'Control Claude Code sessions from your phone, tablet, or any device via web UI.', + }, + { + title: 'Session Mirroring', + description: 'Real-time sync between your terminal and remote devices.', + }, + { + title: 'Plugin System', + description: 'Extend vibes with native Rust plugins for custom commands and workflows.', + }, + { + title: 'Cross-Platform', + description: 'Single binary for Linux, macOS, and Windows.', + }, +] + +const steps = [ + { step: '1', title: 'Install', description: 'Run the install command' }, + { step: '2', title: 'Start', description: 'Run vibes claude with your prompt' }, + { step: '3', title: 'Access', description: 'Open the web UI from any device' }, +] + +function VibesPage() { + return ( + <> + {/* Hero */} +
+ + + Vibes + + + Remote control for your Claude Code sessions + + + Wrap Claude Code with remote access, session management, and a plugin ecosystem — + control your AI coding sessions from anywhere. + + +
+ +
+ + +
+
+ + {/* Screenshot */} +
+ +
+ Vibes terminal interface +
+
+
+ + {/* Features */} +
+ + + Features + + + +
+ + {/* How It Works */} +
+ + + How It Works + +
+ {steps.map((item) => ( +
+
+ {item.step} +
+ + {item.title} + + {item.description} +
+ ))} +
+
+
+ + {/* CTA */} +
+ + + +
+ + ) +} +``` + +**Step 2: Test dev server** + +```bash +pnpm dev +# Navigate to http://localhost:3000/products/vibes +``` + +Expected: Vibes product page renders with hero, features, how it works, and callout + +**Step 3: Commit** + +```bash +git add src/routes/products/vibes.tsx +git commit -m "feat: add Vibes product page" +``` + +--- + +## Task 13: Volt Teaser Route + +**Files:** +- Create: `src/routes/products/volt.tsx` + +**Step 1: Create Volt teaser route** + +Create `src/routes/products/volt.tsx`: + +```tsx +import { Container, Heading, Section, Text } from '@/components/ui' +import { BuiltByVibes, StatusBadge, WaitlistForm } from '@/components/products' +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/products/volt')({ + component: VoltPage, +}) + +const screenshots = [ + { src: '/images/products/volt-dashboard.svg', caption: 'IV Surface Visualization' }, +] + +function VoltPage() { + return ( + <> + {/* Hero with atmospheric background */} +
+
+ + + + Volt + + + Volatility analysis, simulation & trade execution + + + A comprehensive platform for traders who want to analyze options volatility, + backtest strategies, and execute with confidence. + + + + +
+ + {/* Screenshot Gallery */} +
+ + + Preview + +
+ {screenshots.map((screenshot) => ( +
+
+ {screenshot.caption} +
+ + {screenshot.caption} + +
+ ))} +
+
+
+ + {/* CTA */} +
+ + + +
+ + ) +} +``` + +**Step 2: Test dev server** + +```bash +pnpm dev +# Navigate to http://localhost:3000/products/volt +``` + +Expected: Volt teaser page renders with atmospheric hero, waitlist form, and screenshots + +**Step 3: Commit** + +```bash +git add src/routes/products/volt.tsx +git commit -m "feat: add Volt teaser page with waitlist" +``` + +--- + +## Task 14: Update Navbar + +**Files:** +- Modify: `src/components/navigation/Navbar.tsx` + +**Step 1: Add Products link to navbar** + +In `src/components/navigation/Navbar.tsx`, add Products link after the logo and before Services: + +```tsx +
+ + Products + + + Services + + + Let's Talk + +
+``` + +**Step 2: Test navigation** + +```bash +pnpm dev +# Click Products in navbar, verify navigation works +``` + +Expected: Products link appears and navigates to /products + +**Step 3: Commit** + +```bash +git add src/components/navigation/Navbar.tsx +git commit -m "feat: add Products link to navbar" +``` + +--- + +## Task 15: E2E Tests + +**Files:** +- Create: `e2e/products.spec.ts` + +**Step 1: Write E2E tests** + +Create `e2e/products.spec.ts`: + +```typescript +import { expect, test } from '@playwright/test' + +test.describe('Products Pages', () => { + test('products index displays both products', async ({ page }) => { + await page.goto('/products') + + await expect(page.getByRole('heading', { name: "What We're Building" })).toBeVisible() + await expect(page.getByText('Vibes')).toBeVisible() + await expect(page.getByText('Volt')).toBeVisible() + await expect(page.getByText('Available')).toBeVisible() + await expect(page.getByText('Coming Soon')).toBeVisible() + }) + + test('can navigate to Vibes product page', async ({ page }) => { + await page.goto('/products') + + await page.getByRole('link', { name: /learn more/i }).first().click() + await expect(page).toHaveURL('/products/vibes') + await expect(page.getByRole('heading', { name: 'Vibes', level: 1 })).toBeVisible() + }) + + test('Vibes page shows install command', async ({ page }) => { + await page.goto('/products/vibes') + + await expect(page.getByText('curl -sSf https://vibes.run/install | sh')).toBeVisible() + await expect(page.getByRole('link', { name: /star on github/i })).toBeVisible() + }) + + test('can navigate to Volt teaser page', async ({ page }) => { + await page.goto('/products') + + await page.getByRole('link', { name: /learn more/i }).last().click() + await expect(page).toHaveURL('/products/volt') + await expect(page.getByRole('heading', { name: 'Volt', level: 1 })).toBeVisible() + }) + + test('Volt page shows waitlist form', async ({ page }) => { + await page.goto('/products/volt') + + await expect(page.getByText('Coming Soon')).toBeVisible() + await expect(page.getByPlaceholder(/email/i)).toBeVisible() + await expect(page.getByRole('button', { name: /get early access/i })).toBeVisible() + }) + + test('navbar has Products link', async ({ page }) => { + await page.goto('/') + + const productsLink = page.getByRole('link', { name: 'Products' }) + await expect(productsLink).toBeVisible() + await productsLink.click() + await expect(page).toHaveURL('/products') + }) +}) +``` + +**Step 2: Run E2E tests** + +```bash +just e2e +``` + +Expected: All products tests pass + +**Step 3: Commit** + +```bash +git add e2e/products.spec.ts +git commit -m "test: add E2E tests for products pages" +``` + +--- + +## Task 16: Run All Checks + +**Step 1: Run full check suite** + +```bash +just check +``` + +Expected: All checks pass (typecheck, lint, test, e2e) + +**Step 2: Verify dev server** + +```bash +pnpm dev +# Navigate through all pages, verify everything works +``` + +**Step 3: Final commit if any fixes needed** + +--- + +## Task 17: Update Progress Doc + +**Files:** +- Modify: `docs/PROGRESS.md` + +**Step 1: Update Products milestone status** + +Update the Products section in Phase 3: + +```markdown +#### Products +| Product | Status | Description | +|---------|--------|-------------| +| [Vibes](https://github.com/run-vibes/vibes) | ✅ Done | Remote control for your Claude Code sessions | +| Volt | ✅ Done | Volatility analysis, simulation & trade execution system | +``` + +**Step 2: Add Recent Updates entry** + +Add to Recent Updates: + +```markdown +### 2025-12-26 (Products Pages) +- Added products index page with product cards ([#XX](https://github.com/run-vibes/website/pull/XX)) +- Added Vibes product page with features, how it works, install command +- Added Volt teaser page with atmospheric background and waitlist form +- Added waitlist API endpoint for email capture +- Added Products link to navigation +``` + +**Step 3: Commit** + +```bash +git add docs/PROGRESS.md +git commit -m "docs: update progress with products milestone" +``` diff --git a/e2e/products.spec.ts b/e2e/products.spec.ts new file mode 100644 index 0000000..1d921e9 --- /dev/null +++ b/e2e/products.spec.ts @@ -0,0 +1,59 @@ +import { expect, test } from '@playwright/test' + +test.describe('Products Pages', () => { + test('products index displays both products', async ({ page }) => { + await page.goto('/products') + + await expect(page.getByRole('heading', { name: "What We're Building" })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Vibes', level: 3 })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Volt', level: 3 })).toBeVisible() + await expect(page.getByText('Available')).toBeVisible() + await expect(page.getByText('Coming Soon')).toBeVisible() + }) + + test('can navigate to Vibes product page', async ({ page }) => { + await page.goto('/products') + + await page + .getByRole('link', { name: /learn more/i }) + .first() + .click() + await expect(page).toHaveURL('/products/vibes') + await expect(page.getByRole('heading', { name: 'Vibes', level: 1 })).toBeVisible() + }) + + test('Vibes page shows install command', async ({ page }) => { + await page.goto('/products/vibes') + + await expect(page.getByText('curl -sSf https://vibes.run/install | sh')).toBeVisible() + await expect(page.getByRole('link', { name: /star on github/i })).toBeVisible() + }) + + test('can navigate to Volt teaser page', async ({ page }) => { + await page.goto('/products') + + await page + .getByRole('link', { name: /learn more/i }) + .last() + .click() + await expect(page).toHaveURL('/products/volt') + await expect(page.getByRole('heading', { name: 'Volt', level: 1 })).toBeVisible() + }) + + test('Volt page shows waitlist form', async ({ page }) => { + await page.goto('/products/volt') + + await expect(page.getByText('Coming Soon')).toBeVisible() + await expect(page.getByPlaceholder(/email/i)).toBeVisible() + await expect(page.getByRole('button', { name: /get early access/i })).toBeVisible() + }) + + test('navbar has Products link', async ({ page }) => { + await page.goto('/') + + const productsLink = page.getByRole('link', { name: 'Products', exact: true }) + await expect(productsLink).toBeVisible() + await productsLink.click() + await expect(page).toHaveURL('/products') + }) +}) diff --git a/justfile b/justfile index c6a6af6..4b0a70c 100644 --- a/justfile +++ b/justfile @@ -96,7 +96,7 @@ worker-deploy env: # Run chat worker locally worker-dev: - cd workers/chat-api && wrangler dev + cd workers/chat-api && wrangler dev --config wrangler.toml # Create D1 database for chat worker worker-db-create: @@ -110,11 +110,11 @@ worker-db-create-staging: worker-migrate env: #!/usr/bin/env bash if [ "{{env}}" = "production" ]; then - cd workers/chat-api && wrangler d1 migrations apply vibes-chat --remote + cd workers/chat-api && wrangler d1 migrations apply vibes-chat --remote --config wrangler.toml elif [ "{{env}}" = "staging" ]; then - cd workers/chat-api && wrangler d1 migrations apply vibes-chat-staging --remote + cd workers/chat-api && wrangler d1 migrations apply vibes-chat-staging --remote --config wrangler.toml elif [ "{{env}}" = "local" ]; then - cd workers/chat-api && wrangler d1 migrations apply vibes-chat --local + cd workers/chat-api && wrangler d1 migrations apply vibes-chat --local --config wrangler.toml else echo "Error: env must be 'staging', 'production', or 'local'" exit 1 diff --git a/public/images/products/vibes-terminal.svg b/public/images/products/vibes-terminal.svg new file mode 100644 index 0000000..cb1a1b1 --- /dev/null +++ b/public/images/products/vibes-terminal.svg @@ -0,0 +1,11 @@ + + + + + + + $ vibes claude "refactor the auth module" + ✓ Session started + → Web UI available at http://localhost:7432 + [Placeholder - replace with real screenshot] + diff --git a/public/images/products/volt-bg.svg b/public/images/products/volt-bg.svg new file mode 100644 index 0000000..c1fadd2 --- /dev/null +++ b/public/images/products/volt-bg.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/images/products/volt-dashboard.svg b/public/images/products/volt-dashboard.svg new file mode 100644 index 0000000..9f03f45 --- /dev/null +++ b/public/images/products/volt-dashboard.svg @@ -0,0 +1,12 @@ + + + + + + + IV Surface + Greeks Chart + P&L Attribution + Risk Limits + [Placeholder - replace with real screenshot] + diff --git a/src/components/navigation/Navbar.tsx b/src/components/navigation/Navbar.tsx index f5da945..c92e9cd 100644 --- a/src/components/navigation/Navbar.tsx +++ b/src/components/navigation/Navbar.tsx @@ -20,6 +20,12 @@ export function Navbar({ className }: NavbarProps) {
+ + Products + ({ + Link: ({ to, children, ...props }: { to: string; children: React.ReactNode }) => ( + + {children} + + ), +})) + +describe('BuiltByVibes', () => { + it('renders the callout text', () => { + render() + expect(screen.getByText(/Built by/i)).toBeInTheDocument() + }) + + it('renders link to contact page', () => { + render() + const link = screen.getByRole('link', { name: /let's talk/i }) + expect(link).toHaveAttribute('href', '/contact') + }) +}) diff --git a/src/components/products/BuiltByVibes/BuiltByVibes.tsx b/src/components/products/BuiltByVibes/BuiltByVibes.tsx new file mode 100644 index 0000000..35523f9 --- /dev/null +++ b/src/components/products/BuiltByVibes/BuiltByVibes.tsx @@ -0,0 +1,24 @@ +import { Text } from '@/components/ui' +import { cn } from '@/lib/cn' +import { Link } from '@tanstack/react-router' + +interface BuiltByVibesProps { + className?: string +} + +export function BuiltByVibes({ className }: BuiltByVibesProps) { + return ( +
+ + Built by Vibes, the agentic + consulting studio.{' '} + + Let's talk → + + +
+ ) +} diff --git a/src/components/products/BuiltByVibes/index.ts b/src/components/products/BuiltByVibes/index.ts new file mode 100644 index 0000000..5a57c38 --- /dev/null +++ b/src/components/products/BuiltByVibes/index.ts @@ -0,0 +1 @@ +export { BuiltByVibes } from './BuiltByVibes' diff --git a/src/components/products/CodeBlock/CodeBlock.test.tsx b/src/components/products/CodeBlock/CodeBlock.test.tsx new file mode 100644 index 0000000..ffc59d0 --- /dev/null +++ b/src/components/products/CodeBlock/CodeBlock.test.tsx @@ -0,0 +1,36 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { CodeBlock } from './CodeBlock' + +// Mock clipboard API +const mockWriteText = vi.fn() +Object.assign(navigator, { + clipboard: { writeText: mockWriteText }, +}) + +describe('CodeBlock', () => { + it('renders the code content', () => { + render() + expect(screen.getByText('npm install vibes')).toBeInTheDocument() + }) + + it('copies code to clipboard when copy button is clicked', async () => { + mockWriteText.mockResolvedValueOnce(undefined) + render() + + const copyButton = screen.getByRole('button', { name: /copy/i }) + fireEvent.click(copyButton) + + expect(mockWriteText).toHaveBeenCalledWith('curl example.com') + }) + + it('shows copied feedback after clicking', async () => { + mockWriteText.mockResolvedValueOnce(undefined) + render() + + const copyButton = screen.getByRole('button', { name: /copy/i }) + fireEvent.click(copyButton) + + expect(await screen.findByText(/copied/i)).toBeInTheDocument() + }) +}) diff --git a/src/components/products/CodeBlock/CodeBlock.tsx b/src/components/products/CodeBlock/CodeBlock.tsx new file mode 100644 index 0000000..8071698 --- /dev/null +++ b/src/components/products/CodeBlock/CodeBlock.tsx @@ -0,0 +1,41 @@ +import { cn } from '@/lib/cn' +import { useState } from 'react' + +interface CodeBlockProps { + code: string + className?: string +} + +export function CodeBlock({ code, className }: CodeBlockProps) { + const [copied, setCopied] = useState(false) + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(code) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch { + // Clipboard API may fail in non-HTTPS contexts or without permissions + console.error('Failed to copy to clipboard') + } + } + + return ( +
+ {code} + +
+ ) +} diff --git a/src/components/products/CodeBlock/index.ts b/src/components/products/CodeBlock/index.ts new file mode 100644 index 0000000..9f4055e --- /dev/null +++ b/src/components/products/CodeBlock/index.ts @@ -0,0 +1 @@ +export { CodeBlock } from './CodeBlock' diff --git a/src/components/products/FeatureGrid/FeatureGrid.test.tsx b/src/components/products/FeatureGrid/FeatureGrid.test.tsx new file mode 100644 index 0000000..0d02c12 --- /dev/null +++ b/src/components/products/FeatureGrid/FeatureGrid.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { FeatureGrid } from './FeatureGrid' + +const features = [ + { title: 'Remote Access', description: 'Control from anywhere' }, + { title: 'Plugin System', description: 'Extend with plugins' }, +] + +describe('FeatureGrid', () => { + it('renders all feature titles', () => { + render() + expect(screen.getByText('Remote Access')).toBeInTheDocument() + expect(screen.getByText('Plugin System')).toBeInTheDocument() + }) + + it('renders all feature descriptions', () => { + render() + expect(screen.getByText('Control from anywhere')).toBeInTheDocument() + expect(screen.getByText('Extend with plugins')).toBeInTheDocument() + }) +}) diff --git a/src/components/products/FeatureGrid/FeatureGrid.tsx b/src/components/products/FeatureGrid/FeatureGrid.tsx new file mode 100644 index 0000000..89fb05b --- /dev/null +++ b/src/components/products/FeatureGrid/FeatureGrid.tsx @@ -0,0 +1,30 @@ +import { Heading, Text } from '@/components/ui' +import { cn } from '@/lib/cn' + +export interface Feature { + title: string + description: string +} + +interface FeatureGridProps { + features: Feature[] + columns?: 2 | 3 + className?: string +} + +export function FeatureGrid({ features, columns = 2, className }: FeatureGridProps) { + return ( +
+ {features.map((feature) => ( +
+ + {feature.title} + + {feature.description} +
+ ))} +
+ ) +} diff --git a/src/components/products/FeatureGrid/index.ts b/src/components/products/FeatureGrid/index.ts new file mode 100644 index 0000000..23ca2af --- /dev/null +++ b/src/components/products/FeatureGrid/index.ts @@ -0,0 +1 @@ +export { FeatureGrid, type Feature } from './FeatureGrid' diff --git a/src/components/products/ProductCard/ProductCard.test.tsx b/src/components/products/ProductCard/ProductCard.test.tsx new file mode 100644 index 0000000..5741c8b --- /dev/null +++ b/src/components/products/ProductCard/ProductCard.test.tsx @@ -0,0 +1,47 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { ProductCard } from './ProductCard' + +// Mock the Link component from TanStack Router +vi.mock('@tanstack/react-router', () => ({ + Link: ({ to, children, ...props }: { to: string; children: React.ReactNode }) => ( + + {children} + + ), +})) + +describe('ProductCard', () => { + const props = { + name: 'Vibes', + tagline: 'Remote control for Claude Code', + status: 'available' as const, + features: ['Feature 1', 'Feature 2'], + href: '/products/vibes', + } + + it('renders product name and tagline', () => { + render() + expect(screen.getByText('Vibes')).toBeInTheDocument() + expect(screen.getByText('Remote control for Claude Code')).toBeInTheDocument() + }) + + it('renders status badge', () => { + render() + expect(screen.getByText('Available')).toBeInTheDocument() + }) + + it('renders feature list', () => { + render() + expect(screen.getByText('Feature 1')).toBeInTheDocument() + expect(screen.getByText('Feature 2')).toBeInTheDocument() + }) + + it('renders link to product page', () => { + render() + expect(screen.getByRole('link', { name: /learn more/i })).toHaveAttribute( + 'href', + '/products/vibes', + ) + }) +}) diff --git a/src/components/products/ProductCard/ProductCard.tsx b/src/components/products/ProductCard/ProductCard.tsx new file mode 100644 index 0000000..bb6aaca --- /dev/null +++ b/src/components/products/ProductCard/ProductCard.tsx @@ -0,0 +1,59 @@ +import { Card, CardContent, Heading, Text } from '@/components/ui' +import { cn } from '@/lib/cn' +import { Link } from '@tanstack/react-router' +import { type ProductStatus, StatusBadge } from '../StatusBadge' + +interface ProductCardProps { + name: string + tagline: string + status: ProductStatus + features: string[] + href: string + image?: string + className?: string +} + +export function ProductCard({ + name, + tagline, + status, + features, + href, + image, + className, +}: ProductCardProps) { + return ( + + {image && ( +
+ {`${name} +
+ )} + +
+ +
+
+ + {name} + + {tagline} +
+
    + {features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ + Learn More → + +
+
+ ) +} diff --git a/src/components/products/ProductCard/index.ts b/src/components/products/ProductCard/index.ts new file mode 100644 index 0000000..d76f29d --- /dev/null +++ b/src/components/products/ProductCard/index.ts @@ -0,0 +1 @@ +export { ProductCard } from './ProductCard' diff --git a/src/components/products/StatusBadge/StatusBadge.test.tsx b/src/components/products/StatusBadge/StatusBadge.test.tsx new file mode 100644 index 0000000..c1d1ef6 --- /dev/null +++ b/src/components/products/StatusBadge/StatusBadge.test.tsx @@ -0,0 +1,19 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { StatusBadge } from './StatusBadge' + +describe('StatusBadge', () => { + it('renders available status with green styling', () => { + render() + const badge = screen.getByText('Available') + expect(badge).toBeInTheDocument() + expect(badge).toHaveClass('bg-green-500/10') + }) + + it('renders coming-soon status with amber styling', () => { + render() + const badge = screen.getByText('Coming Soon') + expect(badge).toBeInTheDocument() + expect(badge).toHaveClass('bg-amber-500/10') + }) +}) diff --git a/src/components/products/StatusBadge/StatusBadge.tsx b/src/components/products/StatusBadge/StatusBadge.tsx new file mode 100644 index 0000000..960004b --- /dev/null +++ b/src/components/products/StatusBadge/StatusBadge.tsx @@ -0,0 +1,33 @@ +import { cn } from '@/lib/cn' +import { type VariantProps, cva } from 'class-variance-authority' + +const badgeVariants = cva( + 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium font-heading uppercase tracking-wide', + { + variants: { + status: { + available: 'bg-green-500/10 text-green-400 border border-green-500/20', + 'coming-soon': 'bg-amber-500/10 text-amber-400 border border-amber-500/20', + }, + }, + defaultVariants: { + status: 'available', + }, + }, +) + +export type ProductStatus = 'available' | 'coming-soon' + +interface StatusBadgeProps extends VariantProps { + status: ProductStatus + className?: string +} + +const statusLabels: Record = { + available: 'Available', + 'coming-soon': 'Coming Soon', +} + +export function StatusBadge({ status, className }: StatusBadgeProps) { + return {statusLabels[status]} +} diff --git a/src/components/products/StatusBadge/index.ts b/src/components/products/StatusBadge/index.ts new file mode 100644 index 0000000..53e8335 --- /dev/null +++ b/src/components/products/StatusBadge/index.ts @@ -0,0 +1 @@ +export { StatusBadge, type ProductStatus } from './StatusBadge' diff --git a/src/components/products/WaitlistForm/WaitlistForm.test.tsx b/src/components/products/WaitlistForm/WaitlistForm.test.tsx new file mode 100644 index 0000000..79e6ad1 --- /dev/null +++ b/src/components/products/WaitlistForm/WaitlistForm.test.tsx @@ -0,0 +1,61 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { WaitlistForm } from './WaitlistForm' + +const mockFetch = vi.fn() +global.fetch = mockFetch + +describe('WaitlistForm', () => { + beforeEach(() => { + mockFetch.mockReset() + }) + + it('renders email input and submit button', () => { + render() + expect(screen.getByPlaceholderText(/email/i)).toBeInTheDocument() + expect(screen.getByRole('button', { name: /get early access/i })).toBeInTheDocument() + }) + + it('submits email to waitlist API', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true }), + }) + + render() + + const input = screen.getByPlaceholderText(/email/i) + const button = screen.getByRole('button', { name: /get early access/i }) + + fireEvent.change(input, { target: { value: 'test@example.com' } }) + fireEvent.click(button) + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/waitlist'), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ email: 'test@example.com', product: 'volt' }), + }), + ) + }) + }) + + it('shows success message after submission', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ success: true }), + }) + + render() + + fireEvent.change(screen.getByPlaceholderText(/email/i), { + target: { value: 'test@example.com' }, + }) + fireEvent.click(screen.getByRole('button')) + + await waitFor(() => { + expect(screen.getByText(/you're on the list/i)).toBeInTheDocument() + }) + }) +}) diff --git a/src/components/products/WaitlistForm/WaitlistForm.tsx b/src/components/products/WaitlistForm/WaitlistForm.tsx new file mode 100644 index 0000000..e29b07d --- /dev/null +++ b/src/components/products/WaitlistForm/WaitlistForm.tsx @@ -0,0 +1,73 @@ +import { Button, Input, Text } from '@/components/ui' +import { cn } from '@/lib/cn' +import { useState } from 'react' + +interface WaitlistFormProps { + product: string + className?: string +} + +export function WaitlistForm({ product, className }: WaitlistFormProps) { + const [email, setEmail] = useState('') + const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle') + const [errorMessage, setErrorMessage] = useState('') + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setStatus('loading') + setErrorMessage('') + + try { + const apiUrl = import.meta.env.VITE_CHAT_API_URL || '' + const response = await fetch(`${apiUrl}/waitlist`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, product }), + }) + + const data = await response.json() + + if (!response.ok || !data.success) { + throw new Error(data.error || 'Failed to join waitlist') + } + + setStatus('success') + } catch (err) { + setStatus('error') + setErrorMessage(err instanceof Error ? err.message : 'Something went wrong') + } + } + + if (status === 'success') { + return ( +
+ + You're on the list! We'll notify you when {product === 'volt' ? 'Volt' : 'Vibes'}{' '} + launches. + +
+ ) + } + + return ( +
+ setEmail(e.target.value)} + required + disabled={status === 'loading'} + className="flex-1" + /> + + {status === 'error' && ( + + {errorMessage} + + )} +
+ ) +} diff --git a/src/components/products/WaitlistForm/index.ts b/src/components/products/WaitlistForm/index.ts new file mode 100644 index 0000000..ec86616 --- /dev/null +++ b/src/components/products/WaitlistForm/index.ts @@ -0,0 +1 @@ +export { WaitlistForm } from './WaitlistForm' diff --git a/src/components/products/index.ts b/src/components/products/index.ts new file mode 100644 index 0000000..1556655 --- /dev/null +++ b/src/components/products/index.ts @@ -0,0 +1,6 @@ +export { StatusBadge, type ProductStatus } from './StatusBadge' +export { CodeBlock } from './CodeBlock' +export { FeatureGrid, type Feature } from './FeatureGrid' +export { BuiltByVibes } from './BuiltByVibes' +export { WaitlistForm } from './WaitlistForm' +export { ProductCard } from './ProductCard' diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index d3f314f..df85b20 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -13,6 +13,9 @@ import { Route as ServicesRouteImport } from './routes/services' import { Route as ContactRouteImport } from './routes/contact' import { Route as BrandRouteImport } from './routes/brand' import { Route as IndexRouteImport } from './routes/index' +import { Route as ProductsIndexRouteImport } from './routes/products/index' +import { Route as ProductsVoltRouteImport } from './routes/products/volt' +import { Route as ProductsVibesRouteImport } from './routes/products/vibes' const ServicesRoute = ServicesRouteImport.update({ id: '/services', @@ -34,18 +37,39 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const ProductsIndexRoute = ProductsIndexRouteImport.update({ + id: '/products/', + path: '/products/', + getParentRoute: () => rootRouteImport, +} as any) +const ProductsVoltRoute = ProductsVoltRouteImport.update({ + id: '/products/volt', + path: '/products/volt', + getParentRoute: () => rootRouteImport, +} as any) +const ProductsVibesRoute = ProductsVibesRouteImport.update({ + id: '/products/vibes', + path: '/products/vibes', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/brand': typeof BrandRoute '/contact': typeof ContactRoute '/services': typeof ServicesRoute + '/products/vibes': typeof ProductsVibesRoute + '/products/volt': typeof ProductsVoltRoute + '/products': typeof ProductsIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/brand': typeof BrandRoute '/contact': typeof ContactRoute '/services': typeof ServicesRoute + '/products/vibes': typeof ProductsVibesRoute + '/products/volt': typeof ProductsVoltRoute + '/products': typeof ProductsIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -53,13 +77,38 @@ export interface FileRoutesById { '/brand': typeof BrandRoute '/contact': typeof ContactRoute '/services': typeof ServicesRoute + '/products/vibes': typeof ProductsVibesRoute + '/products/volt': typeof ProductsVoltRoute + '/products/': typeof ProductsIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/brand' | '/contact' | '/services' + fullPaths: + | '/' + | '/brand' + | '/contact' + | '/services' + | '/products/vibes' + | '/products/volt' + | '/products' fileRoutesByTo: FileRoutesByTo - to: '/' | '/brand' | '/contact' | '/services' - id: '__root__' | '/' | '/brand' | '/contact' | '/services' + to: + | '/' + | '/brand' + | '/contact' + | '/services' + | '/products/vibes' + | '/products/volt' + | '/products' + id: + | '__root__' + | '/' + | '/brand' + | '/contact' + | '/services' + | '/products/vibes' + | '/products/volt' + | '/products/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -67,6 +116,9 @@ export interface RootRouteChildren { BrandRoute: typeof BrandRoute ContactRoute: typeof ContactRoute ServicesRoute: typeof ServicesRoute + ProductsVibesRoute: typeof ProductsVibesRoute + ProductsVoltRoute: typeof ProductsVoltRoute + ProductsIndexRoute: typeof ProductsIndexRoute } declare module '@tanstack/react-router' { @@ -99,6 +151,27 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/products/': { + id: '/products/' + path: '/products' + fullPath: '/products' + preLoaderRoute: typeof ProductsIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/products/volt': { + id: '/products/volt' + path: '/products/volt' + fullPath: '/products/volt' + preLoaderRoute: typeof ProductsVoltRouteImport + parentRoute: typeof rootRouteImport + } + '/products/vibes': { + id: '/products/vibes' + path: '/products/vibes' + fullPath: '/products/vibes' + preLoaderRoute: typeof ProductsVibesRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -107,6 +180,9 @@ const rootRouteChildren: RootRouteChildren = { BrandRoute: BrandRoute, ContactRoute: ContactRoute, ServicesRoute: ServicesRoute, + ProductsVibesRoute: ProductsVibesRoute, + ProductsVoltRoute: ProductsVoltRoute, + ProductsIndexRoute: ProductsIndexRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/products/index.tsx b/src/routes/products/index.tsx new file mode 100644 index 0000000..616ad9e --- /dev/null +++ b/src/routes/products/index.tsx @@ -0,0 +1,63 @@ +import { ProductCard } from '@/components/products' +import { Container } from '@/components/ui/Container' +import { Section } from '@/components/ui/Section' +import { Heading, Text } from '@/components/ui/Typography' +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/products/')({ + component: ProductsPage, +}) + +const products = [ + { + name: 'Vibes', + tagline: 'Remote control for your Claude Code sessions', + status: 'available' as const, + features: [ + 'Control sessions from any device', + 'Native Rust plugin system', + 'Real-time session mirroring', + ], + href: '/products/vibes', + image: '/images/products/vibes-terminal.svg', + }, + { + name: 'Volt', + tagline: 'Volatility analysis & trade execution', + status: 'coming-soon' as const, + features: [ + 'IV surfaces and Greeks analytics', + '11 options strategies built-in', + 'Backtest with synthetic or real data', + ], + href: '/products/volt', + image: '/images/products/volt-dashboard.svg', + }, +] + +function ProductsPage() { + return ( + <> +
+ + + What We're Building + + + Open source tools and platforms from the Vibes studio. + + +
+ +
+ +
+ {products.map((product) => ( + + ))} +
+
+
+ + ) +} diff --git a/src/routes/products/vibes.tsx b/src/routes/products/vibes.tsx new file mode 100644 index 0000000..9e7572d --- /dev/null +++ b/src/routes/products/vibes.tsx @@ -0,0 +1,131 @@ +import { BuiltByVibes, CodeBlock, FeatureGrid } from '@/components/products' +import { Button, Container, Heading, Section, Text } from '@/components/ui' +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/products/vibes')({ + component: VibesPage, +}) + +const features = [ + { + title: 'Remote Access', + description: 'Control Claude Code sessions from your phone, tablet, or any device via web UI.', + }, + { + title: 'Session Mirroring', + description: 'Real-time sync between your terminal and remote devices.', + }, + { + title: 'Plugin System', + description: 'Extend vibes with native Rust plugins for custom commands and workflows.', + }, + { + title: 'Cross-Platform', + description: 'Single binary for Linux, macOS, and Windows.', + }, +] + +const steps = [ + { step: '1', title: 'Install', description: 'Run the install command' }, + { step: '2', title: 'Start', description: 'Run vibes claude with your prompt' }, + { step: '3', title: 'Access', description: 'Open the web UI from any device' }, +] + +function VibesPage() { + return ( + <> + {/* Hero */} +
+ + + Vibes + + + Remote control for your Claude Code sessions + + + Wrap Claude Code with remote access, session management, and a plugin ecosystem — + control your AI coding sessions from anywhere. + + +
+ +
+ + +
+
+ + {/* Screenshot */} +
+ +
+ Vibes terminal interface +
+
+
+ + {/* Features */} +
+ + + Features + + + +
+ + {/* How It Works */} +
+ + + How It Works + +
+ {steps.map((item) => ( +
+
+ {item.step} +
+ + {item.title} + + {item.description} +
+ ))} +
+
+
+ + {/* CTA */} +
+ + + +
+ + ) +} diff --git a/src/routes/products/volt.tsx b/src/routes/products/volt.tsx new file mode 100644 index 0000000..20b6203 --- /dev/null +++ b/src/routes/products/volt.tsx @@ -0,0 +1,68 @@ +import { BuiltByVibes, StatusBadge, WaitlistForm } from '@/components/products' +import { Container, Heading, Section, Text } from '@/components/ui' +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/products/volt')({ + component: VoltPage, +}) + +const screenshots = [ + { src: '/images/products/volt-dashboard.svg', caption: 'IV Surface Visualization' }, +] + +function VoltPage() { + return ( + <> + {/* Hero with atmospheric background */} +
+
+ + + + Volt + + + Volatility analysis, simulation & trade execution + + + A comprehensive platform for traders who want to analyze options volatility, backtest + strategies, and execute with confidence. + + + + +
+ + {/* Screenshot Gallery */} +
+ + + Preview + +
+ {screenshots.map((screenshot) => ( +
+
+ {screenshot.caption} +
+ + {screenshot.caption} + +
+ ))} +
+
+
+ + {/* CTA */} +
+ + + +
+ + ) +} diff --git a/workers/chat-api/migrations/0003_waitlist.sql b/workers/chat-api/migrations/0003_waitlist.sql new file mode 100644 index 0000000..89a251b --- /dev/null +++ b/workers/chat-api/migrations/0003_waitlist.sql @@ -0,0 +1,13 @@ +-- 0003_waitlist.sql +CREATE TABLE waitlist ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + email TEXT NOT NULL, + product TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + referrer TEXT, + user_agent TEXT, + UNIQUE(email, product) +); + +CREATE INDEX idx_waitlist_product ON waitlist(product); +CREATE INDEX idx_waitlist_created ON waitlist(created_at); diff --git a/workers/chat-api/src/index.ts b/workers/chat-api/src/index.ts index 8c6886a..62be755 100644 --- a/workers/chat-api/src/index.ts +++ b/workers/chat-api/src/index.ts @@ -11,6 +11,7 @@ import { saveMessage, } from './session' import type { ChatRequest, ChatResponse, Env, InterviewAnswers, LeadTierValue } from './types' +import { addToWaitlist } from './waitlist' // In-memory store for interview answers per session. // Limited to prevent unbounded growth in long-running isolates. @@ -227,6 +228,30 @@ export default { } } + if (url.pathname === '/waitlist' && request.method === 'POST') { + try { + const body = (await request.json()) as { email: string; product: string } + const referrer = request.headers.get('Referer') ?? undefined + const userAgent = request.headers.get('User-Agent') ?? undefined + + const result = await addToWaitlist(env.DB, { + email: body.email, + product: body.product, + referrer, + userAgent, + }) + + if (!result.success) { + return jsonResponse({ error: result.error }, 400, origin) + } + + return jsonResponse({ success: true }, 200, origin) + } catch (err) { + console.error('Waitlist error:', err) + return jsonResponse({ error: 'Invalid request' }, 400, origin) + } + } + return new Response('Not found', { status: 404 }) }, } diff --git a/workers/chat-api/src/waitlist.ts b/workers/chat-api/src/waitlist.ts new file mode 100644 index 0000000..abafc89 --- /dev/null +++ b/workers/chat-api/src/waitlist.ts @@ -0,0 +1,52 @@ +import type { D1Database } from '@cloudflare/workers-types' + +export interface WaitlistEntry { + email: string + product: string + referrer?: string + userAgent?: string +} + +const VALID_PRODUCTS = new Set(['volt', 'vibes']) + +export function isValidEmail(email: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) +} + +export function isValidProduct(product: string): boolean { + return VALID_PRODUCTS.has(product) +} + +export async function addToWaitlist( + db: D1Database, + entry: WaitlistEntry, +): Promise<{ success: boolean; error?: string }> { + if (!isValidEmail(entry.email)) { + return { success: false, error: 'Invalid email format' } + } + + if (!isValidProduct(entry.product)) { + return { success: false, error: 'Invalid product' } + } + + try { + await db + .prepare( + `INSERT INTO waitlist (email, product, referrer, user_agent) + VALUES (?, ?, ?, ?) + ON CONFLICT (email, product) DO NOTHING`, + ) + .bind( + entry.email.toLowerCase(), + entry.product, + entry.referrer ?? null, + entry.userAgent ?? null, + ) + .run() + + return { success: true } + } catch (err) { + console.error('Waitlist insert error:', err) + return { success: false, error: 'Failed to join waitlist' } + } +} diff --git a/workers/chat-api/wrangler.toml b/workers/chat-api/wrangler.toml index d9680e4..cff9d56 100644 --- a/workers/chat-api/wrangler.toml +++ b/workers/chat-api/wrangler.toml @@ -1,6 +1,8 @@ name = "vibes-chat-api" main = "src/index.ts" compatibility_date = "2024-01-01" +# Directory where Wrangler looks for D1 database migration files +migrations_dir = "migrations" # Production (default) [vars]