diff --git a/CLAUDE.md b/CLAUDE.md index 24dc604..01764f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -149,6 +149,7 @@ We follow Test-Driven Development for component and utility code: - `pnpm typecheck` — TypeScript type checking - `pnpm lint` — Biome linting - `pnpm test` — Unit tests + - `just e2e` — End-to-end tests 2. **`just dev`** — Verify dev server starts: - Confirm no startup errors @@ -159,10 +160,6 @@ We follow Test-Driven Development for component and utility code: - Check variants and edge cases visually - Confirm no visual regressions -4. **`just e2e`** — End-to-end tests: - - Run Playwright tests against the built app - - Verify critical user journeys work - All checks must pass before work is considered done. ## Progress Tracking diff --git a/biome.json b/biome.json index 1ea36e6..c5e4b51 100644 --- a/biome.json +++ b/biome.json @@ -29,6 +29,7 @@ ".vinxi", ".output", ".tanstack", + ".wrangler", "*.gen.ts", "/nix/**", "test-results", diff --git a/docs/plans/08-interview-contact-chat/design.md b/docs/plans/08-interview-contact-chat/design.md new file mode 100644 index 0000000..d2b2f68 --- /dev/null +++ b/docs/plans/08-interview-contact-chat/design.md @@ -0,0 +1,435 @@ +# Interview-Style Contact Chat Design + +Transform the contact chat from freeform conversation into a structured interview with creative multiple choice questions that adapts to who you're talking to. + +## Design Goals + +1. **Structured, comparable leads** — Extract consistent data for qualification and scoring +2. **Engaging experience** — Personality quiz energy, not a boring form +3. **Adaptive paths** — Tailor the interview based on who they are and what they need +4. **Seamless handoff** — Transition naturally from structured questions to conversational discovery + +## Interview Flow + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 1. OPENER (2 questions) │ +│ → Intent: What brings you here? │ +│ → Role: What's your perspective? │ +├─────────────────────────────────────────────────────────────────┤ +│ 2. PERSONALITY (2 questions) │ +│ → AI Relationship: Where's your team at with AI? │ +│ → Working Style: How do you like to partner? │ +├─────────────────────────────────────────────────────────────────┤ +│ 3. QUALIFICATION (3 questions) │ +│ → Timeline: When are you looking to move? │ +│ → Company Size: How big is your organization? │ +│ → Industry: What space are you in? │ +├─────────────────────────────────────────────────────────────────┤ +│ 4. HYBRID CHAT │ +│ → Claude takes over with full context │ +│ → Suggested response starters reduce friction │ +│ → Explores problem, vision, capabilities conversationally │ +│ → Claude naturally collects contact info │ +├─────────────────────────────────────────────────────────────────┤ +│ 5. POST-CONTACT │ +│ → Budget range question (after commitment) │ +│ → Summary + next steps │ +└─────────────────────────────────────────────────────────────────┘ +``` + +All structured questions use **visual card selection** for a cohesive, premium feel. + +## Structured Questions + +### Opener Questions + +**Q1 — Intent:** *"What brings you to Vibes today?"* + +| Icon | Value | Label | +|------|-------|-------| +| 🎯 | `specific_project` | I have a specific AI project in mind | +| 🔍 | `exploring` | I'm exploring what's possible with AI | +| 🔧 | `existing_system` | I need help with an existing AI system | +| 🎓 | `upskill` | I want to upskill my team | + +**Q2 — Role:** *"What's your perspective on this?"* + +| Icon | Value | Label | +|------|-------|-------| +| ⚙️ | `technical` | Technical (CTO, VP Eng, Developer) | +| 📊 | `business` | Business (CEO, COO, Strategy) | +| 🚀 | `ai_lead` | AI/Innovation Lead | +| 💡 | `founder` | Founder building something new | + +### Personality Questions + +**Q3 — AI Relationship:** *"Your team's relationship with AI is best described as..."* + +| Icon | Value | Label | +|------|-------|-------| +| 🌱 | `first_date` | First date — curious but cautious | +| 🔥 | `going_steady` | Going steady — some experiments working | +| 💍 | `committed` | Committed — AI is core to our strategy | + +**Q4 — Working Style:** *"When you work with partners, you prefer..."* + +| Icon | Value | Label | +|------|-------|-------| +| 🎯 | `full_ownership` | Give us the keys — full ownership | +| 🤝 | `embedded` | Collaborate closely — embedded partnership | +| 🎓 | `knowledge_transfer` | Teach us to fish — knowledge transfer focus | + +### Qualification Questions + +**Q5 — Timeline:** *"When are you looking to move?"* + +| Icon | Value | Label | +|------|-------|-------| +| 🔥 | `asap` | ASAP (within weeks) | +| 📅 | `quarter` | This quarter | +| 🗓️ | `year` | This year | +| 🔭 | `exploring` | Just exploring | + +**Q6 — Company Size:** *"How big is your organization?"* + +| Icon | Value | Label | +|------|-------|-------| +| 🚀 | `startup` | Startup (1-20) | +| 📈 | `growth` | Growth (21-100) | +| 🏢 | `midmarket` | Mid-market (101-1000) | +| 🏛️ | `enterprise` | Enterprise (1000+) | + +**Q7 — Industry:** *"What space are you in?"* + +| Icon | Value | Label | +|------|-------|-------| +| 💳 | `fintech` | Fintech | +| 🛒 | `ecommerce` | E-commerce | +| 💻 | `saas` | SaaS | +| 👔 | `professional_services` | Professional Services | +| 🏥 | `healthcare` | Healthcare | +| 🎯 | `other` | Other | + +### Post-Contact Question + +**Q8 — Budget:** *"What's your budget range for this initiative?"* + +| Icon | Value | Label | +|------|-------|-------| +| 💰 | `under_50k` | Under $50k | +| 💰💰 | `50k_150k` | $50k – $150k | +| 💰💰💰 | `150k_500k` | $150k – $500k | +| 💰💰💰💰 | `500k_plus` | $500k+ | +| 🤷 | `unsure` | Not sure yet | + +## Hybrid Chat with Response Starters + +After structured questions, the UI transitions to conversational chat. Claude has full context from the 7 answers. + +### Transition Message + +Claude opens with a personalized greeting based on their answers: + +> *"Great to meet you! So you're a founder exploring what's possible with AI, and your team is just getting started. I'd love to hear more about what's on your mind."* + +### Response Starters + +When Claude asks an open-ended question, the UI shows 2-3 clickable pills below the input field: + +**For "Tell me about the problem you're trying to solve":** +- `Our biggest challenge is...` +- `We've been struggling with...` +- `Our customers keep asking for...` + +**For "What would success look like?":** +- `If this worked, we could...` +- `The dream scenario is...` +- `We'd measure success by...` + +**For "Who would use this?":** +- `Our internal team needs...` +- `Our customers want...` +- `Both internal and external...` + +Clicking a pill populates the input field with that text, cursor at end. + +### Context-Aware Questions + +Claude adapts based on structured answers: + +| If they selected... | Claude might ask... | +|---------------------|---------------------| +| 🔧 Help with existing AI | *"What's currently not working the way you'd hoped?"* | +| 🎓 Upskill my team | *"What capabilities do you want your team to have?"* | +| 💍 Committed to AI | *"What's the most ambitious thing on your AI roadmap?"* | +| 🎯 Full ownership | *"What does your ideal handoff look like?"* | +| 🔥 ASAP timeline | *"What's driving the urgency?"* | + +## Lead Scoring + +The structured data enables automatic lead scoring: + +### Scoring Criteria + +| Factor | Hot (+3) | Warm (+2) | Cool (+1) | Cold (0) | +|--------|----------|-----------|-----------|----------| +| Timeline | ASAP | This quarter | This year | Exploring | +| Budget | $500k+ | $150k-500k | $50k-150k | <$50k / Unsure | +| Intent | Specific project | Existing system | Upskill | Exploring | +| AI Maturity | Committed | Going steady | First date | — | +| Company Size | Enterprise | Mid-market | Growth | Startup | + +### Score Thresholds + +| Score | Label | Response SLA | Action | +|-------|-------|--------------|--------| +| 12+ | 🔥 Hot | 4 hours | Founder/partner outreach | +| 8-11 | 🌡️ Warm | 24 hours | Senior team follow-up | +| 4-7 | ❄️ Cool | 48 hours | Standard follow-up | +| 0-3 | 🧊 Cold | Best effort | Nurture sequence | + +### Score Calculation + +```typescript +function calculateLeadScore(lead: Lead): number { + let score = 0 + + // Timeline + if (lead.timeline === 'asap') score += 3 + else if (lead.timeline === 'quarter') score += 2 + else if (lead.timeline === 'year') score += 1 + + // Budget + if (lead.budget_range === '500k_plus') score += 3 + else if (lead.budget_range === '150k_500k') score += 2 + else if (lead.budget_range === '50k_150k') score += 1 + + // Intent + if (lead.intent === 'specific_project') score += 3 + else if (lead.intent === 'existing_system') score += 2 + else if (lead.intent === 'upskill') score += 1 + + // AI Maturity + if (lead.ai_maturity === 'committed') score += 2 + else if (lead.ai_maturity === 'going_steady') score += 1 + + // Company Size + if (lead.company_size === 'enterprise') score += 2 + else if (lead.company_size === 'midmarket') score += 1 + + return score +} +``` + +## Technical Architecture + +### Data Model + +The `Lead` table expands to store structured answers: + +```typescript +interface Lead { + // Existing fields + id: number + session_id: string + name: string | null + email: string | null + company: string | null + + // Structured interview answers + intent: 'specific_project' | 'exploring' | 'existing_system' | 'upskill' | null + role: 'technical' | 'business' | 'ai_lead' | 'founder' | null + ai_maturity: 'first_date' | 'going_steady' | 'committed' | null + working_style: 'full_ownership' | 'embedded' | 'knowledge_transfer' | null + timeline: 'asap' | 'quarter' | 'year' | 'exploring' | null + company_size: 'startup' | 'growth' | 'midmarket' | 'enterprise' | null + industry: 'fintech' | 'ecommerce' | 'saas' | 'professional_services' | 'healthcare' | 'other' | null + budget_range: 'under_50k' | '50k_150k' | '150k_500k' | '500k_plus' | 'unsure' | null + + // Lead scoring + lead_score: number | null + lead_tier: 'hot' | 'warm' | 'cool' | 'cold' | null + + // Existing freeform fields (populated by Claude extraction) + project_summary: string | null + problem: string | null + vision: string | null + users: string | null + capabilities: string | null + constraints: string | null + prd_draft: string | null + + created_at: string +} +``` + +### Frontend State Machine + +The chat moves through distinct phases: + +```typescript +type InterviewPhase = + | { type: 'structured'; questionIndex: number } // Q1-Q7 + | { type: 'chat' } // Hybrid conversation + | { type: 'post_contact' } // Budget question + | { type: 'complete' } // Summary shown + +// State transitions +// STRUCTURED (0-6) → CHAT → POST_CONTACT → COMPLETE +``` + +### API Changes + +The chat API receives structured answers alongside messages: + +```typescript +interface ChatRequest { + message?: string // For chat phase + structuredAnswer?: { // For structured phase + questionId: string + answer: string + } + sessionId?: string + phase: 'structured' | 'chat' | 'post_contact' +} + +interface ChatResponse { + message?: string // For chat phase + sessionId: string + leadExtracted?: boolean + leadScore?: number + leadTier?: 'hot' | 'warm' | 'cool' | 'cold' +} +``` + +Claude's system prompt is enhanced with the structured context before the conversation begins. + +## UI Components + +### Question Card Component + +Each structured question displays as a full-width card: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ What brings you to Vibes today? │ +│ (We'll tailor the conversation to your needs) │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ 🎯 │ │ 🔍 │ │ +│ │ Specific │ │ Exploring │ │ +│ │ project │ │ what's │ │ +│ │ in mind │ │ possible │ │ +│ └──────────────┘ └──────────────┘ │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ 🔧 │ │ 🎓 │ │ +│ │ Help │ │ Upskill │ │ +│ │ with │ │ my team │ │ +│ │ existing │ │ │ │ +│ └──────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Answer Card States + +| State | Treatment | +|-------|-----------| +| Default | Subtle border, light background | +| Hover | Elevated shadow, border highlight | +| Selected | Brand color border, subtle fill, checkmark | +| Disabled | Reduced opacity (during loading) | + +### Progress Indicator + +Subtle progress dots showing interview progress: + +``` + ●───●───●───○───○───○───○ + 1 2 3 4 5 6 7 +``` + +Or text: "Question 3 of 7" + +### Response Starter Pills + +Clickable chips below the chat input during hybrid phase: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ [Our biggest challenge is...] [We've been struggling with...] │ +└─────────────────────────────────────────────────────────────────┘ +│ Type your message... Send │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Transition Animation + +When moving from structured → chat: +- Question card fades out +- Chat interface fades in +- Claude's personalized greeting appears with typing indicator + +## Lead Summary View + +After budget is collected, Claude generates a summary: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ ✨ Thanks for sharing your vision! │ +│ │ +│ Here's what I learned: │ +│ ───────────────────────────────────────────────────────────── │ +│ 📋 Project: AI-powered customer support agent │ +│ 🎯 Goal: Reduce ticket response time by 80% │ +│ 👥 Users: Support team + end customers │ +│ ⏰ Timeline: This quarter │ +│ 💰 Budget: $50k – $150k │ +│ ───────────────────────────────────────────────────────────── │ +│ │ +│ A member of the Vibes team will reach out within 24 hours │ +│ to discuss next steps. │ +│ │ +│ [Start Over] [Learn More About Us]│ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Internal Lead Notification + +The email to the Vibes team includes structured data, score, and conversation: + +``` +New Lead: Sarah Chen (sarah@acme.co) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +LEAD SCORE: 🔥 Hot (14 points) + +PROFILE +• Role: Founder +• Intent: Specific project in mind +• AI Maturity: Going steady +• Working Style: Embedded partnership +• Company: Acme (Startup, 1-20) +• Industry: SaaS +• Timeline: This quarter +• Budget: $50k – $150k + +PRD SUMMARY +[Claude-generated summary of problem/vision/capabilities] + +FULL CONVERSATION +[Transcript] +``` + +## Open Questions + +1. **Skip option?** — Should users be able to skip questions? Risk: incomplete data. Benefit: reduces friction. + +2. **Back navigation?** — Can users go back and change previous answers? Adds complexity but improves UX. + +3. **Keyboard navigation?** — Number keys (1-4) to select options? Accessibility consideration. + +4. **Mobile optimization?** — How do cards stack on small screens? 2x2 grid or vertical list? + +5. **Analytics events?** — Track drop-off by question to identify friction points. diff --git a/docs/plans/08-interview-contact-chat/implementation.md b/docs/plans/08-interview-contact-chat/implementation.md new file mode 100644 index 0000000..fc5d47a --- /dev/null +++ b/docs/plans/08-interview-contact-chat/implementation.md @@ -0,0 +1,2548 @@ +# Interview-Style Contact Chat Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Transform the contact chat from freeform conversation into a structured interview with creative multiple choice questions, adaptive branching, and lead scoring. + +**Architecture:** Frontend state machine manages interview phases (structured → chat → post-contact → complete). Backend stores structured answers alongside freeform conversation. Lead scoring computed on extraction. + +**Tech Stack:** React, TypeScript, Tailwind CSS, Cloudflare Workers, D1 SQLite, Claude API + +--- + +## Phase 1: Data Layer + +### Task 1: Update Database Schema + +**Files:** +- Create: `workers/chat-api/migrations/0002_interview_fields.sql` +- Modify: `workers/chat-api/schema.sql` + +**Step 1: Create migration file** + +Create `workers/chat-api/migrations/0002_interview_fields.sql`: + +```sql +-- Add structured interview fields to leads table +ALTER TABLE leads ADD COLUMN intent TEXT; +ALTER TABLE leads ADD COLUMN role TEXT; +ALTER TABLE leads ADD COLUMN ai_maturity TEXT; +ALTER TABLE leads ADD COLUMN working_style TEXT; +ALTER TABLE leads ADD COLUMN timeline TEXT; +ALTER TABLE leads ADD COLUMN company_size TEXT; +ALTER TABLE leads ADD COLUMN industry TEXT; +ALTER TABLE leads ADD COLUMN budget_range TEXT; +ALTER TABLE leads ADD COLUMN lead_score INTEGER; +ALTER TABLE leads ADD COLUMN lead_tier TEXT; + +-- Add interview_answers JSON column for raw storage +ALTER TABLE leads ADD COLUMN interview_answers TEXT; + +-- Add index on lead_tier for filtering +CREATE INDEX IF NOT EXISTS idx_leads_lead_tier ON leads(lead_tier); +CREATE INDEX IF NOT EXISTS idx_leads_lead_score ON leads(lead_score); +``` + +**Step 2: Update schema.sql with new columns** + +Add the new columns to `workers/chat-api/schema.sql` leads table definition (after `prd_draft`): + +```sql + -- Structured interview answers + intent TEXT, + role TEXT, + ai_maturity TEXT, + working_style TEXT, + timeline TEXT, + company_size TEXT, + industry TEXT, + budget_range TEXT, + lead_score INTEGER, + lead_tier TEXT, + interview_answers TEXT, +``` + +**Step 3: Run migration locally** + +Run: `just worker-migrate local` +Expected: Migration applies successfully + +**Step 4: Commit** + +```bash +git add workers/chat-api/migrations/ workers/chat-api/schema.sql +git commit -m "feat(db): add interview fields to leads table" +``` + +--- + +### Task 2: Update TypeScript Types + +**Files:** +- Modify: `workers/chat-api/src/types.ts` + +**Step 1: Add interview types** + +Add to `workers/chat-api/src/types.ts`: + +```typescript +// Interview answer value types +export type IntentValue = 'specific_project' | 'exploring' | 'existing_system' | 'upskill' +export type RoleValue = 'technical' | 'business' | 'ai_lead' | 'founder' +export type AiMaturityValue = 'first_date' | 'going_steady' | 'committed' +export type WorkingStyleValue = 'full_ownership' | 'embedded' | 'knowledge_transfer' +export type TimelineValue = 'asap' | 'quarter' | 'year' | 'exploring' +export type CompanySizeValue = 'startup' | 'growth' | 'midmarket' | 'enterprise' +export type IndustryValue = 'fintech' | 'ecommerce' | 'saas' | 'professional_services' | 'healthcare' | 'other' +export type BudgetRangeValue = 'under_50k' | '50k_150k' | '150k_500k' | '500k_plus' | 'unsure' +export type LeadTierValue = 'hot' | 'warm' | 'cool' | 'cold' + +export interface InterviewAnswers { + intent?: IntentValue + role?: RoleValue + ai_maturity?: AiMaturityValue + working_style?: WorkingStyleValue + timeline?: TimelineValue + company_size?: CompanySizeValue + industry?: IndustryValue + budget_range?: BudgetRangeValue +} + +export type InterviewPhase = 'structured' | 'chat' | 'post_contact' | 'complete' +``` + +**Step 2: Update Lead interface** + +Update the `Lead` interface in `workers/chat-api/src/types.ts`: + +```typescript +export interface Lead { + id: number + session_id: string + name: string | null + email: string | null + company: string | null + project_summary: string | null + problem: string | null + vision: string | null + users: string | null + capabilities: string | null + constraints: string | null + prd_draft: string | null + // Interview fields + intent: IntentValue | null + role: RoleValue | null + ai_maturity: AiMaturityValue | null + working_style: WorkingStyleValue | null + timeline: TimelineValue | null + company_size: CompanySizeValue | null + industry: IndustryValue | null + budget_range: BudgetRangeValue | null + lead_score: number | null + lead_tier: LeadTierValue | null + interview_answers: string | null + created_at: string +} +``` + +**Step 3: Update ChatRequest/ChatResponse** + +Update in `workers/chat-api/src/types.ts`: + +```typescript +export interface ChatRequest { + message?: string + sessionId?: string + phase: InterviewPhase + structuredAnswer?: { + questionId: string + answer: string + } + interviewAnswers?: InterviewAnswers +} + +export interface ChatResponse { + message?: string + sessionId: string + leadExtracted?: boolean + leadScore?: number + leadTier?: LeadTierValue + nextPhase?: InterviewPhase +} +``` + +**Step 4: Commit** + +```bash +git add workers/chat-api/src/types.ts +git commit -m "feat(types): add interview answer types and update Lead interface" +``` + +--- + +### Task 3: Implement Lead Scoring + +**Files:** +- Create: `workers/chat-api/src/scoring.ts` +- Test: `workers/chat-api/src/scoring.test.ts` + +**Step 1: Write the failing test** + +Create `workers/chat-api/src/scoring.test.ts`: + +```typescript +import { describe, expect, it } from 'vitest' +import { calculateLeadScore, getLeadTier } from './scoring' +import type { InterviewAnswers } from './types' + +describe('calculateLeadScore', () => { + it('returns 0 for empty answers', () => { + expect(calculateLeadScore({})).toBe(0) + }) + + it('scores timeline correctly', () => { + expect(calculateLeadScore({ timeline: 'asap' })).toBe(3) + expect(calculateLeadScore({ timeline: 'quarter' })).toBe(2) + expect(calculateLeadScore({ timeline: 'year' })).toBe(1) + expect(calculateLeadScore({ timeline: 'exploring' })).toBe(0) + }) + + it('scores budget correctly', () => { + expect(calculateLeadScore({ budget_range: '500k_plus' })).toBe(3) + expect(calculateLeadScore({ budget_range: '150k_500k' })).toBe(2) + expect(calculateLeadScore({ budget_range: '50k_150k' })).toBe(1) + expect(calculateLeadScore({ budget_range: 'under_50k' })).toBe(0) + expect(calculateLeadScore({ budget_range: 'unsure' })).toBe(0) + }) + + it('scores intent correctly', () => { + expect(calculateLeadScore({ intent: 'specific_project' })).toBe(3) + expect(calculateLeadScore({ intent: 'existing_system' })).toBe(2) + expect(calculateLeadScore({ intent: 'upskill' })).toBe(1) + expect(calculateLeadScore({ intent: 'exploring' })).toBe(0) + }) + + it('scores ai_maturity correctly', () => { + expect(calculateLeadScore({ ai_maturity: 'committed' })).toBe(2) + expect(calculateLeadScore({ ai_maturity: 'going_steady' })).toBe(1) + expect(calculateLeadScore({ ai_maturity: 'first_date' })).toBe(0) + }) + + it('scores company_size correctly', () => { + expect(calculateLeadScore({ company_size: 'enterprise' })).toBe(2) + expect(calculateLeadScore({ company_size: 'midmarket' })).toBe(1) + expect(calculateLeadScore({ company_size: 'growth' })).toBe(0) + expect(calculateLeadScore({ company_size: 'startup' })).toBe(0) + }) + + it('combines all scores correctly', () => { + const hotLead: InterviewAnswers = { + timeline: 'asap', + budget_range: '500k_plus', + intent: 'specific_project', + ai_maturity: 'committed', + company_size: 'enterprise', + } + expect(calculateLeadScore(hotLead)).toBe(13) // 3+3+3+2+2 + }) +}) + +describe('getLeadTier', () => { + it('returns hot for score >= 12', () => { + expect(getLeadTier(12)).toBe('hot') + expect(getLeadTier(13)).toBe('hot') + }) + + it('returns warm for score 8-11', () => { + expect(getLeadTier(8)).toBe('warm') + expect(getLeadTier(11)).toBe('warm') + }) + + it('returns cool for score 4-7', () => { + expect(getLeadTier(4)).toBe('cool') + expect(getLeadTier(7)).toBe('cool') + }) + + it('returns cold for score < 4', () => { + expect(getLeadTier(0)).toBe('cold') + expect(getLeadTier(3)).toBe('cold') + }) +}) +``` + +**Step 2: Run test to verify it fails** + +Run: `cd workers/chat-api && pnpm test src/scoring.test.ts` +Expected: FAIL with "Cannot find module './scoring'" + +**Step 3: Write minimal implementation** + +Create `workers/chat-api/src/scoring.ts`: + +```typescript +import type { InterviewAnswers, LeadTierValue } from './types' + +export function calculateLeadScore(answers: InterviewAnswers): number { + let score = 0 + + // Timeline (max 3) + if (answers.timeline === 'asap') score += 3 + else if (answers.timeline === 'quarter') score += 2 + else if (answers.timeline === 'year') score += 1 + + // Budget (max 3) + if (answers.budget_range === '500k_plus') score += 3 + else if (answers.budget_range === '150k_500k') score += 2 + else if (answers.budget_range === '50k_150k') score += 1 + + // Intent (max 3) + if (answers.intent === 'specific_project') score += 3 + else if (answers.intent === 'existing_system') score += 2 + else if (answers.intent === 'upskill') score += 1 + + // AI Maturity (max 2) + if (answers.ai_maturity === 'committed') score += 2 + else if (answers.ai_maturity === 'going_steady') score += 1 + + // Company Size (max 2) + if (answers.company_size === 'enterprise') score += 2 + else if (answers.company_size === 'midmarket') score += 1 + + return score +} + +export function getLeadTier(score: number): LeadTierValue { + if (score >= 12) return 'hot' + if (score >= 8) return 'warm' + if (score >= 4) return 'cool' + return 'cold' +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd workers/chat-api && pnpm test src/scoring.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add workers/chat-api/src/scoring.ts workers/chat-api/src/scoring.test.ts +git commit -m "feat(scoring): implement lead scoring algorithm" +``` + +--- + +## Phase 2: Interview Questions Config + +### Task 4: Create Interview Questions Config + +**Files:** +- Create: `src/features/chat/config/questions.ts` +- Test: `src/features/chat/config/questions.test.ts` + +**Step 1: Write the failing test** + +Create `src/features/chat/config/questions.test.ts`: + +```typescript +import { describe, expect, it } from 'vitest' +import { + INTERVIEW_QUESTIONS, + getQuestionById, + getStructuredQuestions, + getResponseStarters, +} from './questions' + +describe('INTERVIEW_QUESTIONS', () => { + it('has 8 questions total', () => { + expect(INTERVIEW_QUESTIONS).toHaveLength(8) + }) + + it('has correct question IDs', () => { + const ids = INTERVIEW_QUESTIONS.map((q) => q.id) + expect(ids).toEqual([ + 'intent', + 'role', + 'ai_maturity', + 'working_style', + 'timeline', + 'company_size', + 'industry', + 'budget_range', + ]) + }) + + it('each question has required fields', () => { + for (const q of INTERVIEW_QUESTIONS) { + expect(q).toHaveProperty('id') + expect(q).toHaveProperty('question') + expect(q).toHaveProperty('options') + expect(q.options.length).toBeGreaterThanOrEqual(3) + for (const opt of q.options) { + expect(opt).toHaveProperty('value') + expect(opt).toHaveProperty('label') + expect(opt).toHaveProperty('icon') + } + } + }) +}) + +describe('getQuestionById', () => { + it('returns question for valid ID', () => { + const q = getQuestionById('intent') + expect(q?.question).toContain('brings you') + }) + + it('returns undefined for invalid ID', () => { + expect(getQuestionById('invalid')).toBeUndefined() + }) +}) + +describe('getStructuredQuestions', () => { + it('returns first 7 questions (excludes budget)', () => { + const structured = getStructuredQuestions() + expect(structured).toHaveLength(7) + expect(structured.map((q) => q.id)).not.toContain('budget_range') + }) +}) + +describe('getResponseStarters', () => { + it('returns starters for problem prompt', () => { + const starters = getResponseStarters('problem') + expect(starters.length).toBeGreaterThan(0) + expect(starters[0]).toContain('...') + }) +}) +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm test src/features/chat/config/questions.test.ts` +Expected: FAIL with "Cannot find module './questions'" + +**Step 3: Write implementation** + +Create `src/features/chat/config/questions.ts`: + +```typescript +export interface QuestionOption { + value: string + label: string + icon: string +} + +export interface InterviewQuestion { + id: string + question: string + subtitle?: string + options: QuestionOption[] + phase: 'opener' | 'personality' | 'qualification' | 'post_contact' +} + +export const INTERVIEW_QUESTIONS: InterviewQuestion[] = [ + // Opener + { + id: 'intent', + question: 'What brings you to Vibes today?', + subtitle: "We'll tailor the conversation to your needs", + phase: 'opener', + options: [ + { value: 'specific_project', label: 'I have a specific AI project in mind', icon: '🎯' }, + { value: 'exploring', label: "I'm exploring what's possible with AI", icon: '🔍' }, + { value: 'existing_system', label: 'I need help with an existing AI system', icon: '🔧' }, + { value: 'upskill', label: 'I want to upskill my team', icon: '🎓' }, + ], + }, + { + id: 'role', + question: "What's your perspective on this?", + phase: 'opener', + options: [ + { value: 'technical', label: 'Technical (CTO, VP Eng, Developer)', icon: '⚙️' }, + { value: 'business', label: 'Business (CEO, COO, Strategy)', icon: '📊' }, + { value: 'ai_lead', label: 'AI/Innovation Lead', icon: '🚀' }, + { value: 'founder', label: 'Founder building something new', icon: '💡' }, + ], + }, + // Personality + { + id: 'ai_maturity', + question: "Your team's relationship with AI is best described as...", + phase: 'personality', + options: [ + { value: 'first_date', label: 'First date — curious but cautious', icon: '🌱' }, + { value: 'going_steady', label: 'Going steady — some experiments working', icon: '🔥' }, + { value: 'committed', label: 'Committed — AI is core to our strategy', icon: '💍' }, + ], + }, + { + id: 'working_style', + question: 'When you work with partners, you prefer...', + phase: 'personality', + options: [ + { value: 'full_ownership', label: 'Give us the keys — full ownership', icon: '🎯' }, + { value: 'embedded', label: 'Collaborate closely — embedded partnership', icon: '🤝' }, + { value: 'knowledge_transfer', label: 'Teach us to fish — knowledge transfer focus', icon: '🎓' }, + ], + }, + // Qualification + { + id: 'timeline', + question: 'When are you looking to move?', + phase: 'qualification', + options: [ + { value: 'asap', label: 'ASAP (within weeks)', icon: '🔥' }, + { value: 'quarter', label: 'This quarter', icon: '📅' }, + { value: 'year', label: 'This year', icon: '🗓️' }, + { value: 'exploring', label: 'Just exploring', icon: '🔭' }, + ], + }, + { + id: 'company_size', + question: 'How big is your organization?', + phase: 'qualification', + options: [ + { value: 'startup', label: 'Startup (1-20)', icon: '🚀' }, + { value: 'growth', label: 'Growth (21-100)', icon: '📈' }, + { value: 'midmarket', label: 'Mid-market (101-1000)', icon: '🏢' }, + { value: 'enterprise', label: 'Enterprise (1000+)', icon: '🏛️' }, + ], + }, + { + id: 'industry', + question: 'What space are you in?', + phase: 'qualification', + options: [ + { value: 'fintech', label: 'Fintech', icon: '💳' }, + { value: 'ecommerce', label: 'E-commerce', icon: '🛒' }, + { value: 'saas', label: 'SaaS', icon: '💻' }, + { value: 'professional_services', label: 'Professional Services', icon: '👔' }, + { value: 'healthcare', label: 'Healthcare', icon: '🏥' }, + { value: 'other', label: 'Other', icon: '🎯' }, + ], + }, + // Post-contact + { + id: 'budget_range', + question: 'What\'s your budget range for this initiative?', + phase: 'post_contact', + options: [ + { value: 'under_50k', label: 'Under $50k', icon: '💰' }, + { value: '50k_150k', label: '$50k – $150k', icon: '💰💰' }, + { value: '150k_500k', label: '$150k – $500k', icon: '💰💰💰' }, + { value: '500k_plus', label: '$500k+', icon: '💰💰💰💰' }, + { value: 'unsure', label: 'Not sure yet', icon: '🤷' }, + ], + }, +] + +export function getQuestionById(id: string): InterviewQuestion | undefined { + return INTERVIEW_QUESTIONS.find((q) => q.id === id) +} + +export function getStructuredQuestions(): InterviewQuestion[] { + return INTERVIEW_QUESTIONS.filter((q) => q.phase !== 'post_contact') +} + +export function getPostContactQuestions(): InterviewQuestion[] { + return INTERVIEW_QUESTIONS.filter((q) => q.phase === 'post_contact') +} + +const RESPONSE_STARTERS: Record = { + problem: [ + 'Our biggest challenge is...', + "We've been struggling with...", + 'Our customers keep asking for...', + ], + vision: [ + 'If this worked, we could...', + 'The dream scenario is...', + "We'd measure success by...", + ], + users: [ + 'Our internal team needs...', + 'Our customers want...', + 'Both internal and external...', + ], +} + +export function getResponseStarters(promptType: string): string[] { + return RESPONSE_STARTERS[promptType] ?? [] +} +``` + +**Step 4: Run test to verify it passes** + +Run: `pnpm test src/features/chat/config/questions.test.ts` +Expected: PASS + +**Step 5: Create index export** + +Create `src/features/chat/config/index.ts`: + +```typescript +export * from './questions' +``` + +**Step 6: Commit** + +```bash +git add src/features/chat/config/ +git commit -m "feat(interview): add interview questions config" +``` + +--- + +## Phase 3: UI Components + +### Task 5: Create AnswerCard Component + +**Files:** +- Create: `src/features/chat/components/AnswerCard.tsx` +- Create: `src/features/chat/components/AnswerCard.test.tsx` +- Create: `src/features/chat/components/AnswerCard.stories.tsx` + +**Step 1: Write the failing test** + +Create `src/features/chat/components/AnswerCard.test.tsx`: + +```typescript +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { AnswerCard } from './AnswerCard' + +describe('AnswerCard', () => { + it('renders icon and label', () => { + render() + expect(screen.getByText('🎯')).toBeInTheDocument() + expect(screen.getByText('Test Label')).toBeInTheDocument() + }) + + it('calls onSelect with value when clicked', async () => { + const onSelect = vi.fn() + render() + await userEvent.click(screen.getByRole('button')) + expect(onSelect).toHaveBeenCalledWith('test_value') + }) + + it('shows selected state', () => { + render() + expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true') + }) + + it('is disabled when disabled prop is true', () => { + render() + expect(screen.getByRole('button')).toBeDisabled() + }) +}) +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm test src/features/chat/components/AnswerCard.test.tsx` +Expected: FAIL with "Cannot find module './AnswerCard'" + +**Step 3: Write implementation** + +Create `src/features/chat/components/AnswerCard.tsx`: + +```tsx +import { cn } from '@/lib/cn' +import type { ComponentProps } from 'react' + +interface AnswerCardProps extends Omit, 'onSelect'> { + icon: string + label: string + value: string + selected?: boolean + onSelect: (value: string) => void +} + +export function AnswerCard({ + icon, + label, + value, + selected = false, + disabled = false, + onSelect, + className, + ...props +}: AnswerCardProps) { + return ( + + ) +} +``` + +**Step 4: Run test to verify it passes** + +Run: `pnpm test src/features/chat/components/AnswerCard.test.tsx` +Expected: PASS + +**Step 5: Create Ladle story** + +Create `src/features/chat/components/AnswerCard.stories.tsx`: + +```tsx +import type { Story } from '@ladle/react' +import { AnswerCard } from './AnswerCard' + +export const Default: Story = () => ( +
+ + +
+) + +export const Selected: Story = () => ( + +) + +export const Disabled: Story = () => ( + +) + +export const LongLabel: Story = () => ( +
+ +
+) +``` + +**Step 6: Commit** + +```bash +git add src/features/chat/components/AnswerCard.* +git commit -m "feat(ui): add AnswerCard component for interview options" +``` + +--- + +### Task 6: Create InterviewQuestion Component + +**Files:** +- Create: `src/features/chat/components/InterviewQuestion.tsx` +- Create: `src/features/chat/components/InterviewQuestion.test.tsx` + +**Step 1: Write the failing test** + +Create `src/features/chat/components/InterviewQuestion.test.tsx`: + +```typescript +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { InterviewQuestion } from './InterviewQuestion' + +const mockQuestion = { + id: 'intent', + question: 'What brings you to Vibes?', + subtitle: 'Choose one', + phase: 'opener' as const, + options: [ + { value: 'a', label: 'Option A', icon: '🅰️' }, + { value: 'b', label: 'Option B', icon: '🅱️' }, + ], +} + +describe('InterviewQuestion', () => { + it('renders question text', () => { + render() + expect(screen.getByText('What brings you to Vibes?')).toBeInTheDocument() + }) + + it('renders subtitle when provided', () => { + render() + expect(screen.getByText('Choose one')).toBeInTheDocument() + }) + + it('renders all options as AnswerCards', () => { + render() + expect(screen.getByText('Option A')).toBeInTheDocument() + expect(screen.getByText('Option B')).toBeInTheDocument() + }) + + it('calls onAnswer with question id and value when option selected', async () => { + const onAnswer = vi.fn() + render() + await userEvent.click(screen.getByText('Option A')) + expect(onAnswer).toHaveBeenCalledWith('intent', 'a') + }) + + it('shows selected state for current value', () => { + render() + const buttons = screen.getAllByRole('button') + expect(buttons[0]).toHaveAttribute('aria-pressed', 'true') + expect(buttons[1]).toHaveAttribute('aria-pressed', 'false') + }) + + it('disables all options when disabled', () => { + render() + const buttons = screen.getAllByRole('button') + expect(buttons[0]).toBeDisabled() + expect(buttons[1]).toBeDisabled() + }) +}) +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm test src/features/chat/components/InterviewQuestion.test.tsx` +Expected: FAIL + +**Step 3: Write implementation** + +Create `src/features/chat/components/InterviewQuestion.tsx`: + +```tsx +import { cn } from '@/lib/cn' +import type { InterviewQuestion as QuestionType } from '../config/questions' +import { AnswerCard } from './AnswerCard' + +interface InterviewQuestionProps { + question: QuestionType + currentValue?: string + disabled?: boolean + onAnswer: (questionId: string, value: string) => void + className?: string +} + +export function InterviewQuestion({ + question, + currentValue, + disabled = false, + onAnswer, + className, +}: InterviewQuestionProps) { + return ( +
+
+

{question.question}

+ {question.subtitle && ( +

{question.subtitle}

+ )} +
+
+ {question.options.map((option) => ( + onAnswer(question.id, value)} + /> + ))} +
+
+ ) +} +``` + +**Step 4: Run test to verify it passes** + +Run: `pnpm test src/features/chat/components/InterviewQuestion.test.tsx` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/features/chat/components/InterviewQuestion.* +git commit -m "feat(ui): add InterviewQuestion component" +``` + +--- + +### Task 7: Create ProgressIndicator Component + +**Files:** +- Create: `src/features/chat/components/ProgressIndicator.tsx` +- Create: `src/features/chat/components/ProgressIndicator.test.tsx` + +**Step 1: Write the failing test** + +Create `src/features/chat/components/ProgressIndicator.test.tsx`: + +```typescript +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { ProgressIndicator } from './ProgressIndicator' + +describe('ProgressIndicator', () => { + it('renders correct number of dots', () => { + render() + const dots = screen.getAllByRole('listitem') + expect(dots).toHaveLength(7) + }) + + it('marks completed dots correctly', () => { + render() + const dots = screen.getAllByRole('listitem') + // First 3 should be completed (0, 1, 2) + expect(dots[0]).toHaveAttribute('data-state', 'completed') + expect(dots[1]).toHaveAttribute('data-state', 'completed') + expect(dots[2]).toHaveAttribute('data-state', 'completed') + // Current (3) should be current + expect(dots[3]).toHaveAttribute('data-state', 'current') + // Rest should be upcoming + expect(dots[4]).toHaveAttribute('data-state', 'upcoming') + }) + + it('shows text label when showLabel is true', () => { + render() + expect(screen.getByText('Question 3 of 7')).toBeInTheDocument() + }) +}) +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm test src/features/chat/components/ProgressIndicator.test.tsx` +Expected: FAIL + +**Step 3: Write implementation** + +Create `src/features/chat/components/ProgressIndicator.tsx`: + +```tsx +import { cn } from '@/lib/cn' + +interface ProgressIndicatorProps { + current: number + total: number + showLabel?: boolean + className?: string +} + +export function ProgressIndicator({ + current, + total, + showLabel = false, + className, +}: ProgressIndicatorProps) { + return ( +
+
    + {Array.from({ length: total }, (_, i) => { + const state = i < current ? 'completed' : i === current ? 'current' : 'upcoming' + return ( +
  1. + ) + })} +
+ {showLabel && ( + + Question {current + 1} of {total} + + )} +
+ ) +} +``` + +**Step 4: Run test to verify it passes** + +Run: `pnpm test src/features/chat/components/ProgressIndicator.test.tsx` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/features/chat/components/ProgressIndicator.* +git commit -m "feat(ui): add ProgressIndicator component" +``` + +--- + +### Task 8: Create ResponseStarter Component + +**Files:** +- Create: `src/features/chat/components/ResponseStarter.tsx` +- Create: `src/features/chat/components/ResponseStarter.test.tsx` + +**Step 1: Write the failing test** + +Create `src/features/chat/components/ResponseStarter.test.tsx`: + +```typescript +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { ResponseStarter } from './ResponseStarter' + +describe('ResponseStarter', () => { + const starters = ['Our biggest challenge is...', "We've been struggling with..."] + + it('renders all starter options', () => { + render() + expect(screen.getByText('Our biggest challenge is...')).toBeInTheDocument() + expect(screen.getByText("We've been struggling with...")).toBeInTheDocument() + }) + + it('calls onSelect with starter text when clicked', async () => { + const onSelect = vi.fn() + render() + await userEvent.click(screen.getByText('Our biggest challenge is...')) + expect(onSelect).toHaveBeenCalledWith('Our biggest challenge is...') + }) + + it('renders nothing when starters array is empty', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) +}) +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm test src/features/chat/components/ResponseStarter.test.tsx` +Expected: FAIL + +**Step 3: Write implementation** + +Create `src/features/chat/components/ResponseStarter.tsx`: + +```tsx +import { cn } from '@/lib/cn' + +interface ResponseStarterProps { + starters: string[] + onSelect: (starter: string) => void + className?: string +} + +export function ResponseStarter({ starters, onSelect, className }: ResponseStarterProps) { + if (starters.length === 0) return null + + return ( +
+ {starters.map((starter) => ( + + ))} +
+ ) +} +``` + +**Step 4: Run test to verify it passes** + +Run: `pnpm test src/features/chat/components/ResponseStarter.test.tsx` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/features/chat/components/ResponseStarter.* +git commit -m "feat(ui): add ResponseStarter component for chat prompts" +``` + +--- + +### Task 9: Update Component Exports + +**Files:** +- Modify: `src/features/chat/components/index.ts` + +**Step 1: Add new exports** + +Update `src/features/chat/components/index.ts`: + +```typescript +export * from './AnswerCard' +export * from './ChatBubble' +export * from './ChatContainer' +export * from './ChatInput' +export * from './InterviewQuestion' +export * from './ProgressIndicator' +export * from './ResponseStarter' +``` + +**Step 2: Commit** + +```bash +git add src/features/chat/components/index.ts +git commit -m "chore: export new interview components" +``` + +--- + +## Phase 4: Interview State Machine + +### Task 10: Create useInterview Hook + +**Files:** +- Create: `src/features/chat/hooks/useInterview.ts` +- Create: `src/features/chat/hooks/useInterview.test.ts` + +**Step 1: Write the failing test** + +Create `src/features/chat/hooks/useInterview.test.ts`: + +```typescript +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { useInterview } from './useInterview' + +describe('useInterview', () => { + it('starts at question 0 in structured phase', () => { + const { result } = renderHook(() => useInterview()) + expect(result.current.phase).toBe('structured') + expect(result.current.currentQuestionIndex).toBe(0) + }) + + it('advances to next question on answer', () => { + const { result } = renderHook(() => useInterview()) + act(() => { + result.current.answerQuestion('intent', 'specific_project') + }) + expect(result.current.currentQuestionIndex).toBe(1) + expect(result.current.answers.intent).toBe('specific_project') + }) + + it('transitions to chat phase after all structured questions', () => { + const { result } = renderHook(() => useInterview()) + // Answer all 7 structured questions + act(() => { + result.current.answerQuestion('intent', 'specific_project') + result.current.answerQuestion('role', 'technical') + result.current.answerQuestion('ai_maturity', 'going_steady') + result.current.answerQuestion('working_style', 'embedded') + result.current.answerQuestion('timeline', 'quarter') + result.current.answerQuestion('company_size', 'startup') + result.current.answerQuestion('industry', 'saas') + }) + expect(result.current.phase).toBe('chat') + }) + + it('transitions to post_contact when contact collected', () => { + const { result } = renderHook(() => useInterview()) + // Fast-forward to chat phase + act(() => { + for (let i = 0; i < 7; i++) { + result.current.answerQuestion(`q${i}`, 'val') + } + }) + act(() => { + result.current.setContactCollected(true) + }) + expect(result.current.phase).toBe('post_contact') + }) + + it('transitions to complete after budget answered', () => { + const { result } = renderHook(() => useInterview()) + // Fast-forward through all phases + act(() => { + for (let i = 0; i < 7; i++) { + result.current.answerQuestion(`q${i}`, 'val') + } + result.current.setContactCollected(true) + result.current.answerQuestion('budget_range', '50k_150k') + }) + expect(result.current.phase).toBe('complete') + expect(result.current.answers.budget_range).toBe('50k_150k') + }) + + it('returns current question', () => { + const { result } = renderHook(() => useInterview()) + expect(result.current.currentQuestion?.id).toBe('intent') + }) + + it('calculates progress correctly', () => { + const { result } = renderHook(() => useInterview()) + expect(result.current.progress).toEqual({ current: 0, total: 7 }) + act(() => { + result.current.answerQuestion('intent', 'specific_project') + }) + expect(result.current.progress).toEqual({ current: 1, total: 7 }) + }) +}) +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm test src/features/chat/hooks/useInterview.test.ts` +Expected: FAIL + +**Step 3: Write implementation** + +Create `src/features/chat/hooks/useInterview.ts`: + +```typescript +import { useCallback, useMemo, useState } from 'react' +import { + getPostContactQuestions, + getQuestionById, + getStructuredQuestions, + type InterviewQuestion, +} from '../config/questions' + +export interface InterviewAnswers { + [key: string]: string +} + +export type InterviewPhase = 'structured' | 'chat' | 'post_contact' | 'complete' + +interface UseInterviewReturn { + phase: InterviewPhase + currentQuestionIndex: number + currentQuestion: InterviewQuestion | undefined + answers: InterviewAnswers + progress: { current: number; total: number } + contactCollected: boolean + answerQuestion: (questionId: string, value: string) => void + setContactCollected: (collected: boolean) => void +} + +export function useInterview(): UseInterviewReturn { + const [phase, setPhase] = useState('structured') + const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0) + const [answers, setAnswers] = useState({}) + const [contactCollected, setContactCollectedState] = useState(false) + + const structuredQuestions = useMemo(() => getStructuredQuestions(), []) + const postContactQuestions = useMemo(() => getPostContactQuestions(), []) + + const currentQuestion = useMemo(() => { + if (phase === 'structured') { + return structuredQuestions[currentQuestionIndex] + } + if (phase === 'post_contact') { + return postContactQuestions[0] // Budget question + } + return undefined + }, [phase, currentQuestionIndex, structuredQuestions, postContactQuestions]) + + const progress = useMemo( + () => ({ + current: currentQuestionIndex, + total: structuredQuestions.length, + }), + [currentQuestionIndex, structuredQuestions.length], + ) + + const answerQuestion = useCallback( + (questionId: string, value: string) => { + setAnswers((prev) => ({ ...prev, [questionId]: value })) + + if (phase === 'structured') { + const nextIndex = currentQuestionIndex + 1 + if (nextIndex >= structuredQuestions.length) { + setPhase('chat') + } else { + setCurrentQuestionIndex(nextIndex) + } + } else if (phase === 'post_contact' && questionId === 'budget_range') { + setPhase('complete') + } + }, + [phase, currentQuestionIndex, structuredQuestions.length], + ) + + const setContactCollected = useCallback((collected: boolean) => { + setContactCollectedState(collected) + if (collected) { + setPhase('post_contact') + } + }, []) + + return { + phase, + currentQuestionIndex, + currentQuestion, + answers, + progress, + contactCollected, + answerQuestion, + setContactCollected, + } +} +``` + +**Step 4: Run test to verify it passes** + +Run: `pnpm test src/features/chat/hooks/useInterview.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/features/chat/hooks/useInterview.* +git commit -m "feat(interview): add useInterview state machine hook" +``` + +--- + +## Phase 5: Backend Updates + +### Task 11: Update Claude System Prompt + +**Files:** +- Modify: `workers/chat-api/src/claude.ts` + +**Step 1: Update system prompt to be context-aware** + +Update `workers/chat-api/src/claude.ts`: + +```typescript +import type { InterviewAnswers, Message } from './types' + +function buildContextFromAnswers(answers: InterviewAnswers): string { + const parts: string[] = [] + + // Intent context + if (answers.intent === 'specific_project') { + parts.push('They have a specific AI project in mind.') + } else if (answers.intent === 'exploring') { + parts.push("They're exploring what's possible with AI.") + } else if (answers.intent === 'existing_system') { + parts.push('They need help with an existing AI system.') + } else if (answers.intent === 'upskill') { + parts.push('They want to upskill their team on AI.') + } + + // Role context + if (answers.role === 'technical') { + parts.push('They have a technical background (CTO, VP Eng, or developer).') + } else if (answers.role === 'business') { + parts.push('They focus on the business side (CEO, COO, or strategy).') + } else if (answers.role === 'ai_lead') { + parts.push('They lead AI or innovation initiatives.') + } else if (answers.role === 'founder') { + parts.push("They're a founder building something new.") + } + + // AI maturity context + if (answers.ai_maturity === 'first_date') { + parts.push("Their team is new to AI — they're curious but cautious.") + } else if (answers.ai_maturity === 'going_steady') { + parts.push('Their team has some AI experiments working.') + } else if (answers.ai_maturity === 'committed') { + parts.push('AI is core to their strategy — they are committed.') + } + + // Working style context + if (answers.working_style === 'full_ownership') { + parts.push('They prefer partners who take full ownership.') + } else if (answers.working_style === 'embedded') { + parts.push('They want close collaboration with embedded partnership.') + } else if (answers.working_style === 'knowledge_transfer') { + parts.push('They prioritize knowledge transfer — teach them to fish.') + } + + // Timeline context + if (answers.timeline === 'asap') { + parts.push('They want to move ASAP (within weeks).') + } else if (answers.timeline === 'quarter') { + parts.push('Their timeline is this quarter.') + } else if (answers.timeline === 'year') { + parts.push('Their timeline is this year.') + } else if (answers.timeline === 'exploring') { + parts.push("They're just exploring for now.") + } + + // Company size context + if (answers.company_size === 'startup') { + parts.push("They're a startup (1-20 people).") + } else if (answers.company_size === 'growth') { + parts.push("They're a growth-stage company (21-100 people).") + } else if (answers.company_size === 'midmarket') { + parts.push("They're a mid-market company (101-1000 people).") + } else if (answers.company_size === 'enterprise') { + parts.push("They're an enterprise (1000+ people).") + } + + // Industry context + const industryMap: Record = { + fintech: 'fintech', + ecommerce: 'e-commerce', + saas: 'SaaS', + professional_services: 'professional services', + healthcare: 'healthcare', + other: 'another industry', + } + if (answers.industry) { + parts.push(`They work in ${industryMap[answers.industry] ?? answers.industry}.`) + } + + return parts.join(' ') +} + +function buildSystemPrompt(interviewContext?: string): string { + const contextSection = interviewContext + ? ` +## What You Know About Them +${interviewContext} + +Use this context to personalize your questions and show you've been listening. +` + : '' + + return `You are a friendly, professional assistant for Vibes, an AI agent development studio. Your goal is to have a natural conversation that helps understand what the visitor is looking to build. + +${contextSection} +## Your Objectives +1. Understand their project and business needs +2. Extract enough information to create a mini-PRD +3. Collect their contact information to follow up + +## Information to Gather (naturally, not as a checklist) +- **Problem/Opportunity**: What challenge are they facing? What's the current pain? +- **Vision**: What does success look like? What would an ideal solution do? +- **Users**: Who will use this? What are their needs? +- **Key Capabilities**: What must it do? What's nice-to-have? +- **Contact**: Name, company, email + +## Conversation Style +- Be warm and conversational, not robotic +- Ask follow-up questions that show you're listening +- Reference what you know about them from the interview +- Share brief, relevant insights when appropriate +- Keep responses concise (2-4 sentences typically) +- Don't ask multiple questions at once + +## When You Have Enough Information +When you feel you have a good understanding of their needs AND have their contact info, summarize what you've learned and let them know the team will be in touch. Use the phrase "LEAD_COMPLETE" somewhere in your response (this triggers our system to extract the data). + +## Important +- Never make up information about Vibes' capabilities or past work +- If asked about pricing, say you'll connect them with the team who can discuss specifics +- If they seem unsure, help them articulate their needs through questions` +} + +// ... rest of the file with updated callClaude function +export async function callClaude( + apiKey: string, + conversationHistory: Message[], + newMessage: string, + interviewAnswers?: InterviewAnswers, +): Promise { + const interviewContext = interviewAnswers + ? buildContextFromAnswers(interviewAnswers) + : undefined + const systemPrompt = buildSystemPrompt(interviewContext) + + const messages = conversationHistory + .filter((m) => m.role !== 'system') + .map((m) => ({ + role: m.role as 'user' | 'assistant', + content: m.content, + })) + + messages.push({ role: 'user', content: newMessage }) + + const response = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: 'claude-sonnet-4-20250514', + max_tokens: 1024, + system: systemPrompt, + messages, + }), + }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`Claude API error: ${response.status} - ${error}`) + } + + const data = (await response.json()) as { content: Array<{ type: 'text'; text: string }> } + return ( + data.content[0]?.text ?? + 'I apologize, but I had trouble generating a response. Could you try again?' + ) +} + +export function isLeadComplete(response: string): boolean { + return response.includes('LEAD_COMPLETE') +} + +export function cleanResponse(response: string): string { + return response.replace(/LEAD_COMPLETE/g, '').trim() +} +``` + +**Step 2: Commit** + +```bash +git add workers/chat-api/src/claude.ts +git commit -m "feat(claude): add context-aware system prompt with interview answers" +``` + +--- + +### Task 12: Update Leads Module + +**Files:** +- Modify: `workers/chat-api/src/leads.ts` + +**Step 1: Update saveLead to include interview data** + +Update `workers/chat-api/src/leads.ts` to include new fields: + +```typescript +import { calculateLeadScore, getLeadTier } from './scoring' +import type { InterviewAnswers, Message } from './types' + +// ... existing ExtractedLead interface ... + +export async function saveLead( + db: D1Database, + sessionId: string, + lead: ExtractedLead, + prdDraft: string, + interviewAnswers?: InterviewAnswers, +): Promise<{ score: number; tier: string }> { + const score = interviewAnswers ? calculateLeadScore(interviewAnswers) : 0 + const tier = getLeadTier(score) + + await db + .prepare( + `INSERT INTO leads ( + session_id, name, email, company, project_summary, problem, vision, + users, capabilities, constraints, prd_draft, + intent, role, ai_maturity, working_style, timeline, company_size, + industry, budget_range, lead_score, lead_tier, interview_answers + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(session_id) DO UPDATE SET + name = excluded.name, + email = excluded.email, + company = excluded.company, + project_summary = excluded.project_summary, + problem = excluded.problem, + vision = excluded.vision, + users = excluded.users, + capabilities = excluded.capabilities, + constraints = excluded.constraints, + prd_draft = excluded.prd_draft, + intent = excluded.intent, + role = excluded.role, + ai_maturity = excluded.ai_maturity, + working_style = excluded.working_style, + timeline = excluded.timeline, + company_size = excluded.company_size, + industry = excluded.industry, + budget_range = excluded.budget_range, + lead_score = excluded.lead_score, + lead_tier = excluded.lead_tier, + interview_answers = excluded.interview_answers`, + ) + .bind( + sessionId, + lead.name, + lead.email, + lead.company, + lead.projectSummary, + lead.problem, + lead.vision, + lead.users, + lead.capabilities, + lead.constraints, + prdDraft, + interviewAnswers?.intent ?? null, + interviewAnswers?.role ?? null, + interviewAnswers?.ai_maturity ?? null, + interviewAnswers?.working_style ?? null, + interviewAnswers?.timeline ?? null, + interviewAnswers?.company_size ?? null, + interviewAnswers?.industry ?? null, + interviewAnswers?.budget_range ?? null, + score, + tier, + interviewAnswers ? JSON.stringify(interviewAnswers) : null, + ) + .run() + + return { score, tier } +} +``` + +**Step 2: Commit** + +```bash +git add workers/chat-api/src/leads.ts +git commit -m "feat(leads): add interview answers and scoring to lead storage" +``` + +--- + +### Task 13: Update Email Template + +**Files:** +- Modify: `workers/chat-api/src/email.ts` + +**Step 1: Update email format to include structured data** + +Update `workers/chat-api/src/email.ts`: + +```typescript +import type { InterviewAnswers, LeadTierValue } from './types' + +// Label maps for human-readable display +const INTENT_LABELS: Record = { + specific_project: 'Specific project in mind', + exploring: 'Exploring possibilities', + existing_system: 'Help with existing AI', + upskill: 'Team upskilling', +} + +const ROLE_LABELS: Record = { + technical: 'Technical (CTO/VP Eng/Dev)', + business: 'Business (CEO/COO/Strategy)', + ai_lead: 'AI/Innovation Lead', + founder: 'Founder', +} + +const AI_MATURITY_LABELS: Record = { + first_date: 'First date — curious', + going_steady: 'Going steady — experimenting', + committed: 'Committed — AI is core', +} + +const WORKING_STYLE_LABELS: Record = { + full_ownership: 'Full ownership', + embedded: 'Embedded partnership', + knowledge_transfer: 'Knowledge transfer', +} + +const TIMELINE_LABELS: Record = { + asap: 'ASAP (weeks)', + quarter: 'This quarter', + year: 'This year', + exploring: 'Just exploring', +} + +const COMPANY_SIZE_LABELS: Record = { + startup: 'Startup (1-20)', + growth: 'Growth (21-100)', + midmarket: 'Mid-market (101-1000)', + enterprise: 'Enterprise (1000+)', +} + +const INDUSTRY_LABELS: Record = { + fintech: 'Fintech', + ecommerce: 'E-commerce', + saas: 'SaaS', + professional_services: 'Professional Services', + healthcare: 'Healthcare', + other: 'Other', +} + +const BUDGET_LABELS: Record = { + under_50k: 'Under $50k', + '50k_150k': '$50k – $150k', + '150k_500k': '$150k – $500k', + '500k_plus': '$500k+', + unsure: 'Not sure yet', +} + +const TIER_EMOJI: Record = { + hot: '🔥', + warm: '🌡️', + cool: '❄️', + cold: '🧊', +} + +function formatInterviewSection(answers: InterviewAnswers): string { + const rows: string[] = [] + + if (answers.intent) rows.push(`Intent${INTENT_LABELS[answers.intent] ?? answers.intent}`) + if (answers.role) rows.push(`Role${ROLE_LABELS[answers.role] ?? answers.role}`) + if (answers.ai_maturity) rows.push(`AI Maturity${AI_MATURITY_LABELS[answers.ai_maturity] ?? answers.ai_maturity}`) + if (answers.working_style) rows.push(`Working Style${WORKING_STYLE_LABELS[answers.working_style] ?? answers.working_style}`) + if (answers.timeline) rows.push(`Timeline${TIMELINE_LABELS[answers.timeline] ?? answers.timeline}`) + if (answers.company_size) rows.push(`Company Size${COMPANY_SIZE_LABELS[answers.company_size] ?? answers.company_size}`) + if (answers.industry) rows.push(`Industry${INDUSTRY_LABELS[answers.industry] ?? answers.industry}`) + if (answers.budget_range) rows.push(`Budget${BUDGET_LABELS[answers.budget_range] ?? answers.budget_range}`) + + if (rows.length === 0) return '' + + return ` +
+
Interview Profile
+ + ${rows.join('')} +
+
+ ` +} + +export function formatLeadEmail( + lead: { + name: string | null + email: string | null + company: string | null + projectSummary: string | null + }, + prdDraft: string, + options?: { + interviewAnswers?: InterviewAnswers + leadScore?: number + leadTier?: LeadTierValue + conversationUrl?: string + }, +): string { + const tierEmoji = options?.leadTier ? TIER_EMOJI[options.leadTier] : '' + const tierLabel = options?.leadTier ? options.leadTier.charAt(0).toUpperCase() + options.leadTier.slice(1) : '' + const scoreSection = options?.leadScore !== undefined + ? `
+ ${tierEmoji} ${tierLabel} Lead (Score: ${options.leadScore}/13) +
` + : '' + + const interviewSection = options?.interviewAnswers + ? formatInterviewSection(options.interviewAnswers) + : '' + + return ` + + + + + + +
+
+

New Lead from Vibes Chat

+
+
+ ${scoreSection} + +
+
Contact
+
+ ${lead.name ?? 'Name not provided'}
+ ${lead.email ? `${lead.email}` : 'Email not provided'}
+ ${lead.company ?? 'Company not provided'} +
+
+ + ${interviewSection} + +
+
Project Summary
+
${lead.projectSummary ?? 'Not captured'}
+
+ +
+
Generated PRD Draft
+
${prdDraft.replace(/\n/g, '
')}
+
+ + ${ + options?.conversationUrl + ? ` + + ` + : '' + } +
+ +
+ + +` +} + +// Update notifyTeam to accept new options +export async function notifyTeam( + resendApiKey: string, + notificationEmail: string, + lead: { + name: string | null + email: string | null + company: string | null + projectSummary: string | null + }, + prdDraft: string, + options?: { + interviewAnswers?: InterviewAnswers + leadScore?: number + leadTier?: LeadTierValue + }, +): Promise { + const sender = lead.company ?? lead.name ?? 'Unknown' + const summary = truncate(lead.projectSummary, 50) || 'New inquiry' + const tierEmoji = options?.leadTier ? TIER_EMOJI[options.leadTier] : '' + const subject = `${tierEmoji} New Lead: ${sender} — ${summary}` + + await sendEmail(resendApiKey, { + to: notificationEmail, + subject, + html: formatLeadEmail(lead, prdDraft, options), + }) +} +``` + +**Step 2: Commit** + +```bash +git add workers/chat-api/src/email.ts +git commit -m "feat(email): add interview data and lead scoring to notification emails" +``` + +--- + +### Task 14: Update Worker API Handler + +**Files:** +- Modify: `workers/chat-api/src/index.ts` + +**Step 1: Update API to handle interview phases** + +Update `workers/chat-api/src/index.ts` to handle the new request format and store interview answers in the session: + +```typescript +import { callClaude, cleanResponse, isLeadComplete } from './claude' +import { notifyTeam } from './email' +import { extractLeadFromConversation, generatePRDDraft, saveLead } from './leads' +import { calculateLeadScore, getLeadTier } from './scoring' +import { + checkRateLimit, + getConversationHistory, + getOrCreateSession, + hashIP, + incrementMessageCount, + saveMessage, +} from './session' +import type { ChatRequest, ChatResponse, Env, InterviewAnswers } from './types' + +// In-memory store for interview answers per session (could move to D1) +const sessionInterviewAnswers = new Map() + +function getCorsHeaders(origin: string): Record { + return { + 'Access-Control-Allow-Origin': origin, + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + } +} + +function jsonResponse(data: unknown, status: number, origin: string): Response { + return new Response(JSON.stringify(data), { + status, + headers: { + 'Content-Type': 'application/json', + ...getCorsHeaders(origin), + }, + }) +} + +export default { + async fetch(request: Request, env: Env): Promise { + const origin = env.ALLOWED_ORIGIN + + if (request.method === 'OPTIONS') { + return new Response(null, { headers: getCorsHeaders(origin) }) + } + + const url = new URL(request.url) + + if (url.pathname === '/health') { + return jsonResponse({ status: 'ok', timestamp: new Date().toISOString() }, 200, origin) + } + + if (url.pathname === '/chat' && request.method === 'POST') { + try { + const body = (await request.json()) as ChatRequest + const clientIP = request.headers.get('CF-Connecting-IP') ?? 'unknown' + const ipHash = await hashIP(clientIP) + const session = await getOrCreateSession(env.DB, body.sessionId, ipHash) + + // Store/update interview answers for this session + if (body.interviewAnswers) { + const existing = sessionInterviewAnswers.get(session.id) ?? {} + sessionInterviewAnswers.set(session.id, { ...existing, ...body.interviewAnswers }) + } + + // Handle structured phase (no Claude call needed) + if (body.phase === 'structured' && body.structuredAnswer) { + const answers = sessionInterviewAnswers.get(session.id) ?? {} + answers[body.structuredAnswer.questionId] = body.structuredAnswer.answer + sessionInterviewAnswers.set(session.id, answers) + + const response: ChatResponse = { + sessionId: session.id, + } + return jsonResponse(response, 200, origin) + } + + // Handle post_contact phase (budget question) + if (body.phase === 'post_contact' && body.structuredAnswer) { + const answers = sessionInterviewAnswers.get(session.id) ?? {} + answers[body.structuredAnswer.questionId] = body.structuredAnswer.answer + sessionInterviewAnswers.set(session.id, answers) + + const score = calculateLeadScore(answers) + const tier = getLeadTier(score) + + const response: ChatResponse = { + sessionId: session.id, + leadScore: score, + leadTier: tier, + nextPhase: 'complete', + } + return jsonResponse(response, 200, origin) + } + + // Chat phase - requires message + if (!body.message?.trim()) { + return jsonResponse({ error: 'Message is required' }, 400, origin) + } + + const maxMessagesPerSession = Number.parseInt(env.MAX_MESSAGES_PER_SESSION, 10) || 20 + const { allowed } = await checkRateLimit(env.DB, session.id, maxMessagesPerSession) + + if (!allowed) { + return jsonResponse( + { + error: 'Message limit reached for this session', + message: + "Thanks for your interest! You've reached the message limit. Please email us at hello@vibes.run to continue the conversation.", + sessionId: session.id, + }, + 200, + origin, + ) + } + + await saveMessage(env.DB, session.id, 'user', body.message) + await incrementMessageCount(env.DB, session.id) + + const history = await getConversationHistory(env.DB, session.id) + const interviewAnswers = sessionInterviewAnswers.get(session.id) + const response = await callClaude(env.ANTHROPIC_API_KEY, history, body.message, interviewAnswers) + + await saveMessage(env.DB, session.id, 'assistant', response) + + let leadExtracted = false + let leadScore: number | undefined + let leadTier: string | undefined + + if (isLeadComplete(response)) { + try { + const lead = await extractLeadFromConversation( + env.ANTHROPIC_API_KEY, + await getConversationHistory(env.DB, session.id), + ) + + const prdDraft = generatePRDDraft(lead) + const result = await saveLead(env.DB, session.id, lead, prdDraft, interviewAnswers) + leadScore = result.score + leadTier = result.tier + + if (env.RESEND_API_KEY && env.NOTIFICATION_EMAIL) { + await notifyTeam(env.RESEND_API_KEY, env.NOTIFICATION_EMAIL, lead, prdDraft, { + interviewAnswers, + leadScore, + leadTier: leadTier as any, + }) + } + + leadExtracted = true + } catch (err) { + console.error('Lead extraction failed:', err) + } + } + + const chatResponse: ChatResponse = { + message: cleanResponse(response), + sessionId: session.id, + leadExtracted, + leadScore, + leadTier: leadTier as any, + nextPhase: leadExtracted ? 'post_contact' : undefined, + } + + return jsonResponse(chatResponse, 200, origin) + } catch (err) { + console.error('Chat error:', err) + return jsonResponse( + { error: 'Internal server error', message: 'Something went wrong. Please try again.' }, + 500, + origin, + ) + } + } + + return new Response('Not found', { status: 404 }) + }, +} +``` + +**Step 2: Commit** + +```bash +git add workers/chat-api/src/index.ts +git commit -m "feat(api): handle interview phases and structured answers" +``` + +--- + +## Phase 6: Frontend Integration + +### Task 15: Create InterviewContainer Component + +**Files:** +- Create: `src/features/chat/components/InterviewContainer.tsx` +- Create: `src/features/chat/components/InterviewContainer.test.tsx` + +**Step 1: Write the failing test** + +Create `src/features/chat/components/InterviewContainer.test.tsx`: + +```typescript +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { InterviewContainer } from './InterviewContainer' + +describe('InterviewContainer', () => { + it('shows first question on initial render', () => { + render() + expect(screen.getByText('What brings you to Vibes today?')).toBeInTheDocument() + }) + + it('shows progress indicator', () => { + render() + expect(screen.getByText('Question 1 of 7')).toBeInTheDocument() + }) + + it('advances to next question on answer', async () => { + render() + await userEvent.click(screen.getByText('I have a specific AI project in mind')) + expect(screen.getByText("What's your perspective on this?")).toBeInTheDocument() + expect(screen.getByText('Question 2 of 7')).toBeInTheDocument() + }) + + it('transitions to chat after all questions', async () => { + render() + // Answer all 7 questions + await userEvent.click(screen.getByText('I have a specific AI project in mind')) + await userEvent.click(screen.getByText('Technical (CTO, VP Eng, Developer)')) + await userEvent.click(screen.getByText('Going steady — some experiments working')) + await userEvent.click(screen.getByText('Collaborate closely — embedded partnership')) + await userEvent.click(screen.getByText('This quarter')) + await userEvent.click(screen.getByText('Startup (1-20)')) + await userEvent.click(screen.getByText('SaaS')) + + // Should now show chat interface + expect(screen.getByPlaceholderText(/type your message/i)).toBeInTheDocument() + }) +}) +``` + +**Step 2: Run test to verify it fails** + +Run: `pnpm test src/features/chat/components/InterviewContainer.test.tsx` +Expected: FAIL + +**Step 3: Write implementation** + +Create `src/features/chat/components/InterviewContainer.tsx`: + +```tsx +import { cn } from '@/lib/cn' +import { useCallback, useEffect, useRef, useState } from 'react' +import { getResponseStarters } from '../config/questions' +import { useChat } from '../hooks/useChat' +import { useInterview } from '../hooks/useInterview' +import { ChatBubble } from './ChatBubble' +import { ChatInput } from './ChatInput' +import { InterviewQuestion } from './InterviewQuestion' +import { ProgressIndicator } from './ProgressIndicator' +import { ResponseStarter } from './ResponseStarter' + +interface InterviewContainerProps { + className?: string + apiEndpoint?: string + onInputFocus?: () => void +} + +export function InterviewContainer({ + className, + apiEndpoint, + onInputFocus, +}: InterviewContainerProps) { + const { + phase, + currentQuestion, + currentQuestionIndex, + answers, + progress, + answerQuestion, + setContactCollected, + } = useInterview() + + const { messages, isLoading, error, sendMessage } = useChat({ + apiEndpoint, + interviewAnswers: answers, + }) + + const messagesAreaRef = useRef(null) + const [isExpanded, setIsExpanded] = useState(false) + const [inputValue, setInputValue] = useState('') + const initialMessageCount = useRef(messages.length) + + // Scroll to bottom when new messages arrive + useEffect(() => { + if (messages.length > initialMessageCount.current && messagesAreaRef.current) { + messagesAreaRef.current.scrollTop = messagesAreaRef.current.scrollHeight + } + }, [messages.length]) + + // Check if lead is complete (contact collected) + useEffect(() => { + const lastMessage = messages[messages.length - 1] + if (lastMessage?.role === 'assistant') { + // Check if Claude has collected contact info + const hasContact = messages.some( + (m) => + m.role === 'user' && + (m.content.includes('@') || m.content.toLowerCase().includes('email')), + ) + if (hasContact && lastMessage.content.toLowerCase().includes('thank')) { + setContactCollected(true) + } + } + }, [messages, setContactCollected]) + + const handleInputFocus = useCallback(() => { + setIsExpanded(true) + onInputFocus?.() + }, [onInputFocus]) + + const handleSend = useCallback( + async (content: string) => { + setInputValue('') + await sendMessage(content) + }, + [sendMessage], + ) + + const handleStarterSelect = useCallback((starter: string) => { + setInputValue(starter) + }, []) + + // Determine current response starters based on conversation + const currentStarters = phase === 'chat' ? getResponseStarters('problem') : [] + + // Render structured question phase + if (phase === 'structured' && currentQuestion) { + return ( +
+ +
+ +
+
+ ) + } + + // Render post-contact budget question + if (phase === 'post_contact' && currentQuestion) { + return ( +
+
+ +
+
+ ) + } + + // Render complete phase + if (phase === 'complete') { + return ( +
+
+
+
+

Thanks for sharing your vision!

+

+ A member of the Vibes team will reach out within 24 hours to discuss next steps. +

+
+
+
+ ) + } + + // Render chat phase + return ( +
+ {/* Messages Area */} +
+ {messages.map((message) => ( + + {message.content} + + ))} + {isLoading && ( + + Thinking... + + )} + {error && ( +
+ Gremlins in the system prevented us from sending your message. Please try again. +
+ )} +
+ + {/* Response Starters */} + {currentStarters.length > 0 && !isLoading && ( +
+ +
+ )} + + {/* Input Area */} +
+ +
+
+ ) +} +``` + +**Step 4: Update ChatInput to support controlled value** + +Update `src/features/chat/components/ChatInput.tsx` to accept `value` and `onChange` props: + +```tsx +interface ChatInputProps { + onSend: (message: string) => void + loading?: boolean + onFocus?: () => void + value?: string + onChange?: (value: string) => void +} + +export function ChatInput({ + onSend, + loading = false, + onFocus, + value: controlledValue, + onChange: controlledOnChange, +}: ChatInputProps) { + const [internalValue, setInternalValue] = useState('') + const value = controlledValue ?? internalValue + const setValue = controlledOnChange ?? setInternalValue + + // ... rest of component uses value/setValue +} +``` + +**Step 5: Run test to verify it passes** + +Run: `pnpm test src/features/chat/components/InterviewContainer.test.tsx` +Expected: PASS (may need adjustments based on actual implementation) + +**Step 6: Commit** + +```bash +git add src/features/chat/components/InterviewContainer.* src/features/chat/components/ChatInput.tsx +git commit -m "feat(interview): add InterviewContainer orchestrating full interview flow" +``` + +--- + +### Task 16: Update useChat Hook for Interview Context + +**Files:** +- Modify: `src/features/chat/hooks/useChat.ts` + +**Step 1: Add interview answers to API calls** + +Update `src/features/chat/hooks/useChat.ts`: + +```typescript +interface UseChatOptions { + apiEndpoint?: string + interviewAnswers?: Record +} + +export function useChat(options: UseChatOptions = {}) { + const { apiEndpoint = '/api/chat', interviewAnswers } = options + + // ... existing state ... + + const sendMessage = useCallback( + async (content: string) => { + // ... existing message setup ... + + try { + const response = await fetch(apiEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message: content, + sessionId, + phase: 'chat', + interviewAnswers, + }), + }) + // ... rest of handler ... + } + }, + [apiEndpoint, sessionId, interviewAnswers], + ) + + // ... rest of hook ... +} +``` + +**Step 2: Commit** + +```bash +git add src/features/chat/hooks/useChat.ts +git commit -m "feat(chat): pass interview answers to API in chat phase" +``` + +--- + +### Task 17: Update Contact Page + +**Files:** +- Modify: `src/routes/contact.tsx` + +**Step 1: Replace ChatContainer with InterviewContainer** + +Update the contact page to use the new InterviewContainer. + +**Step 2: Commit** + +```bash +git add src/routes/contact.tsx +git commit -m "feat(contact): use InterviewContainer for structured interview flow" +``` + +--- + +## Phase 7: Testing & Polish + +### Task 18: Run Full Test Suite + +**Step 1: Run all tests** + +Run: `pnpm test` +Expected: All tests pass + +**Step 2: Run type checking** + +Run: `pnpm typecheck` +Expected: No type errors + +**Step 3: Run linting** + +Run: `pnpm lint` +Expected: No lint errors + +**Step 4: Fix any issues and commit** + +```bash +git add -A +git commit -m "fix: address test and lint issues" +``` + +--- + +### Task 19: Manual Testing + +**Step 1: Start dev server** + +Run: `pnpm dev` + +**Step 2: Test interview flow** + +- Navigate to /contact +- Answer all 7 structured questions +- Verify progress indicator updates +- Verify transition to chat phase +- Have conversation with Claude +- Provide contact info +- Verify budget question appears +- Complete flow and verify thank you screen + +**Step 3: Test worker locally** + +Run: `just worker-dev` + +**Step 4: Verify email notification** + +Check that email includes: +- Lead score and tier +- Interview profile table +- PRD draft + +--- + +### Task 20: Deploy and Migrate + +**Step 1: Run staging migration** + +Run: `just worker-migrate staging` + +**Step 2: Deploy worker to staging** + +Run: `just worker-deploy staging` + +**Step 3: Deploy frontend preview** + +Run: `just pages-preview` + +**Step 4: Test on staging** + +Verify full flow works on staging environment. + +**Step 5: Deploy to production** + +Run: `just worker-migrate production` +Run: `just worker-deploy production` +Run: `just pages-deploy` + +**Step 6: Final commit** + +```bash +git add -A +git commit -m "chore: complete interview contact chat implementation" +``` + +--- + +## Summary + +This plan implements the interview-style contact chat in 20 tasks across 7 phases: + +1. **Data Layer** (Tasks 1-3): Database schema, types, lead scoring +2. **Interview Config** (Task 4): Question definitions and response starters +3. **UI Components** (Tasks 5-9): AnswerCard, InterviewQuestion, ProgressIndicator, ResponseStarter +4. **State Machine** (Task 10): useInterview hook +5. **Backend Updates** (Tasks 11-14): Claude prompt, leads, email, API +6. **Frontend Integration** (Tasks 15-17): InterviewContainer, useChat updates, contact page +7. **Testing & Deploy** (Tasks 18-20): Test suite, manual testing, deployment + +Each task follows TDD with explicit file paths, code snippets, and commit points. diff --git a/e2e/contact.spec.ts b/e2e/contact.spec.ts index ff0a062..a71368b 100644 --- a/e2e/contact.spec.ts +++ b/e2e/contact.spec.ts @@ -1,18 +1,38 @@ import { expect, test } from '@playwright/test' test.describe('Contact Page', () => { - test('displays chat interface', async ({ page }) => { + test('displays interview interface', async ({ page }) => { await page.goto('/contact') // Check heading await expect(page.getByRole('heading', { name: /let's talk/i })).toBeVisible() - // Check welcome message - await expect(page.getByText(/what's the vision/i)).toBeVisible() + // Check welcome message in chat interface + await expect(page.getByText(/I'm here to learn/i)).toBeVisible() - // Check input and send button - await expect(page.getByPlaceholder(/type a message/i)).toBeVisible() - await expect(page.getByRole('button', { name: /send/i })).toBeVisible() + // Check first question appears as chat message + await expect(page.getByText(/what brings you to vibes today/i)).toBeVisible() + + // Check suggestion chips are visible (answer options) + await expect(page.getByRole('button', { name: /specific AI project/i })).toBeVisible() + await expect(page.getByRole('button', { name: /exploring/i })).toBeVisible() + }) + + // Note: This test is skipped due to hydration timing issues with TanStack Start + // The interview navigation works correctly when JavaScript is fully hydrated + test.skip('can navigate through interview questions', async ({ page }) => { + await page.goto('/contact') + + // Wait for first question to be visible in chat + await expect(page.getByText(/what brings you to vibes today/i)).toBeVisible() + + // Click suggestion chip to answer first question + const firstOption = page.getByRole('button', { name: /specific AI project/i }) + await expect(firstOption).toBeVisible() + await firstOption.click() + + // User's answer should appear as chat message, then second question + await expect(page.getByText(/what's your perspective/i)).toBeVisible({ timeout: 10000 }) }) // Note: This test is skipped due to hydration timing issues with TanStack Start @@ -32,13 +52,6 @@ test.describe('Contact Page', () => { await expect(page.locator('input[name="email"]')).toBeVisible() }) - test('send button is disabled when input is empty', async ({ page }) => { - await page.goto('/contact') - - const sendButton = page.getByRole('button', { name: /send/i }) - await expect(sendButton).toBeDisabled() - }) - test('displays contact info section', async ({ page }) => { await page.goto('/contact') diff --git a/src/features/chat/components/AnswerCard.test.tsx b/src/features/chat/components/AnswerCard.test.tsx new file mode 100644 index 0000000..3c134fa --- /dev/null +++ b/src/features/chat/components/AnswerCard.test.tsx @@ -0,0 +1,29 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { AnswerCard } from './AnswerCard' + +describe('AnswerCard', () => { + it('renders prefix and label', () => { + render() + expect(screen.getByText('A')).toBeInTheDocument() + expect(screen.getByText('Test Label')).toBeInTheDocument() + }) + + it('calls onSelect with value when clicked', async () => { + const onSelect = vi.fn() + render() + await userEvent.click(screen.getByRole('button')) + expect(onSelect).toHaveBeenCalledWith('test_value') + }) + + it('shows selected state', () => { + render() + expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true') + }) + + it('is disabled when disabled prop is true', () => { + render() + expect(screen.getByRole('button')).toBeDisabled() + }) +}) diff --git a/src/features/chat/components/AnswerCard.tsx b/src/features/chat/components/AnswerCard.tsx new file mode 100644 index 0000000..5cdfd50 --- /dev/null +++ b/src/features/chat/components/AnswerCard.tsx @@ -0,0 +1,50 @@ +import { cn } from '@/lib/cn' +import type { ComponentProps } from 'react' + +interface AnswerCardProps extends Omit, 'onSelect'> { + /** Letter prefix like "A", "B", "C" */ + prefix: string + label: string + value: string + selected?: boolean + onSelect: (value: string) => void +} + +export function AnswerCard({ + prefix, + label, + value, + selected = false, + disabled = false, + onSelect, + className, + ...props +}: AnswerCardProps) { + return ( + + ) +} diff --git a/src/features/chat/components/ChatInput.tsx b/src/features/chat/components/ChatInput.tsx index afa9679..44476e2 100644 --- a/src/features/chat/components/ChatInput.tsx +++ b/src/features/chat/components/ChatInput.tsx @@ -7,13 +7,28 @@ interface ChatInputProps { loading?: boolean className?: string onFocus?: () => void + value?: string + onChange?: (value: string) => void + placeholder?: string } -export function ChatInput({ onSend, loading, className, onFocus }: ChatInputProps) { - const [value, setValue] = useState('') +export function ChatInput({ + onSend, + loading, + className, + onFocus, + value: controlledValue, + onChange: controlledOnChange, + placeholder = 'Type a message...', +}: ChatInputProps) { + const [internalValue, setInternalValue] = useState('') const inputRef = useRef(null) const wasLoading = useRef(false) + // Use controlled value if provided, otherwise use internal state + const value = controlledValue ?? internalValue + const setValue = controlledOnChange ?? setInternalValue + // Refocus input when loading finishes useEffect(() => { if (wasLoading.current && !loading) { @@ -39,7 +54,7 @@ export function ChatInput({ onSend, loading, className, onFocus }: ChatInputProp value={value} onChange={(e) => setValue(e.target.value)} onFocus={onFocus} - placeholder="Type a message..." + placeholder={placeholder} disabled={loading} className="flex-1 rounded-full border border-input bg-background px-4 py-2 text-base focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50" /> diff --git a/src/features/chat/components/InterviewContainer.tsx b/src/features/chat/components/InterviewContainer.tsx new file mode 100644 index 0000000..d64d1e9 --- /dev/null +++ b/src/features/chat/components/InterviewContainer.tsx @@ -0,0 +1,166 @@ +import { cn } from '@/lib/cn' +import { useCallback, useEffect, useRef, useState } from 'react' +import { getResponseStarters } from '../config/questions' +import { useChat } from '../hooks/useChat' +import { useInterview } from '../hooks/useInterview' +import { ChatBubble } from './ChatBubble' +import { ChatInput } from './ChatInput' +import { ResponseStarter } from './ResponseStarter' +import { SuggestionChips } from './SuggestionChips' + +interface InterviewContainerProps { + className?: string + apiEndpoint?: string + onInputFocus?: () => void +} + +export function InterviewContainer({ + className, + apiEndpoint, + onInputFocus, +}: InterviewContainerProps) { + const { + phase, + currentQuestion, + answers, + messages: interviewMessages, + answerQuestion, + setContactCollected, + } = useInterview() + + const { + messages: chatMessages, + isLoading, + error, + sendMessage, + } = useChat({ + apiEndpoint, + interviewAnswers: answers, + onLeadExtracted: () => setContactCollected(true), + }) + + const messagesAreaRef = useRef(null) + const [inputValue, setInputValue] = useState('') + const prevMessageCount = useRef(0) + + // Combine interview messages with chat messages for display + // Chat messages only appear after the transition to chat phase + const allMessages = [...interviewMessages, ...chatMessages.slice(1)] // Skip welcome message from chat since interview has its own + + // Scroll to bottom when new messages arrive + useEffect(() => { + if (allMessages.length > prevMessageCount.current && messagesAreaRef.current) { + messagesAreaRef.current.scrollTop = messagesAreaRef.current.scrollHeight + } + prevMessageCount.current = allMessages.length + }, [allMessages.length]) + + const handleInputFocus = useCallback(() => { + onInputFocus?.() + }, [onInputFocus]) + + const handleSend = useCallback( + async (content: string) => { + const trimmed = content.trim() + if (!trimmed) return + + setInputValue('') + + // During structured or post_contact phase, treat typed input as a custom answer + if ((phase === 'structured' || phase === 'post_contact') && currentQuestion) { + answerQuestion(currentQuestion.id, trimmed, trimmed) + } else if (phase === 'chat') { + // During chat phase, send to AI + await sendMessage(trimmed) + } + }, + [phase, currentQuestion, answerQuestion, sendMessage], + ) + + const handleSuggestionSelect = useCallback( + (value: string, label: string) => { + if (currentQuestion) { + answerQuestion(currentQuestion.id, value, label) + } + }, + [currentQuestion, answerQuestion], + ) + + const handleStarterSelect = useCallback((starter: string) => { + setInputValue(starter) + }, []) + + // Get response starters for chat phase + const getActiveStarters = (): string[] => { + if (phase !== 'chat' || isLoading) return [] + + const userChatMessages = chatMessages.filter((m) => m.role === 'user').length + if (userChatMessages === 0) return getResponseStarters('problem') + if (userChatMessages === 1) return getResponseStarters('vision') + if (userChatMessages === 2) return getResponseStarters('users') + return [] + } + + const activeStarters = getActiveStarters() + const showSuggestions = (phase === 'structured' || phase === 'post_contact') && currentQuestion + const isComplete = phase === 'complete' + + return ( +
+ {/* Messages Area */} +
+ {allMessages.map((message) => ( + + {message.content} + + ))} + {isLoading && ( + + Thinking... + + )} + {error && ( +
+ Something went wrong. Please try again. +
+ )} +
+ + {/* Suggestion Chips for structured questions */} + {showSuggestions && ( +
+ +
+ )} + + {/* Response Starters for chat phase */} + {activeStarters.length > 0 && ( +
+ +
+ )} + + {/* Input Area */} + {!isComplete && ( +
+ +
+ )} +
+ ) +} diff --git a/src/features/chat/components/InterviewQuestion.test.tsx b/src/features/chat/components/InterviewQuestion.test.tsx new file mode 100644 index 0000000..2ba4b94 --- /dev/null +++ b/src/features/chat/components/InterviewQuestion.test.tsx @@ -0,0 +1,54 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { InterviewQuestion } from './InterviewQuestion' + +const mockQuestion = { + id: 'intent', + question: 'What brings you to Vibes?', + subtitle: 'Choose one', + phase: 'opener' as const, + options: [ + { value: 'a', label: 'Option A' }, + { value: 'b', label: 'Option B' }, + ], +} + +describe('InterviewQuestion', () => { + it('renders question text', () => { + render() + expect(screen.getByText('What brings you to Vibes?')).toBeInTheDocument() + }) + + it('renders subtitle when provided', () => { + render() + expect(screen.getByText('Choose one')).toBeInTheDocument() + }) + + it('renders all options as AnswerCards', () => { + render() + expect(screen.getByText('Option A')).toBeInTheDocument() + expect(screen.getByText('Option B')).toBeInTheDocument() + }) + + it('calls onAnswer with question id and value when option selected', async () => { + const onAnswer = vi.fn() + render() + await userEvent.click(screen.getByText('Option A')) + expect(onAnswer).toHaveBeenCalledWith('intent', 'a') + }) + + it('shows selected state for current value', () => { + render() + const buttons = screen.getAllByRole('button') + expect(buttons[0]).toHaveAttribute('aria-pressed', 'true') + expect(buttons[1]).toHaveAttribute('aria-pressed', 'false') + }) + + it('disables all options when disabled', () => { + render() + const buttons = screen.getAllByRole('button') + expect(buttons[0]).toBeDisabled() + expect(buttons[1]).toBeDisabled() + }) +}) diff --git a/src/features/chat/components/InterviewQuestion.tsx b/src/features/chat/components/InterviewQuestion.tsx new file mode 100644 index 0000000..9feb5e7 --- /dev/null +++ b/src/features/chat/components/InterviewQuestion.tsx @@ -0,0 +1,46 @@ +import { cn } from '@/lib/cn' +import type { InterviewQuestion as QuestionType } from '../config/questions' +import { AnswerCard } from './AnswerCard' + +/** Convert index to letter prefix (0 → "A", 1 → "B", etc.) */ +function getLetterPrefix(index: number): string { + return String.fromCharCode(65 + index) +} + +interface InterviewQuestionProps { + question: QuestionType + currentValue?: string + disabled?: boolean + onAnswer: (questionId: string, value: string) => void + className?: string +} + +export function InterviewQuestion({ + question, + currentValue, + disabled = false, + onAnswer, + className, +}: InterviewQuestionProps) { + return ( +
+
+

{question.question}

+ {question.subtitle &&

{question.subtitle}

} +
+
+ {question.options.map((option, index) => ( + onAnswer(question.id, value)} + /> + ))} +
+
+ ) +} diff --git a/src/features/chat/components/ProgressIndicator.test.tsx b/src/features/chat/components/ProgressIndicator.test.tsx new file mode 100644 index 0000000..373b931 --- /dev/null +++ b/src/features/chat/components/ProgressIndicator.test.tsx @@ -0,0 +1,29 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { ProgressIndicator } from './ProgressIndicator' + +describe('ProgressIndicator', () => { + it('renders correct number of dots', () => { + render() + const dots = screen.getAllByRole('listitem') + expect(dots).toHaveLength(7) + }) + + it('marks completed dots correctly', () => { + render() + const dots = screen.getAllByRole('listitem') + // First 3 should be completed (0, 1, 2) + expect(dots[0]).toHaveAttribute('data-state', 'completed') + expect(dots[1]).toHaveAttribute('data-state', 'completed') + expect(dots[2]).toHaveAttribute('data-state', 'completed') + // Current (3) should be current + expect(dots[3]).toHaveAttribute('data-state', 'current') + // Rest should be upcoming + expect(dots[4]).toHaveAttribute('data-state', 'upcoming') + }) + + it('shows text label when showLabel is true', () => { + render() + expect(screen.getByText('Question 3 of 7')).toBeInTheDocument() + }) +}) diff --git a/src/features/chat/components/ProgressIndicator.tsx b/src/features/chat/components/ProgressIndicator.tsx new file mode 100644 index 0000000..9a6afc4 --- /dev/null +++ b/src/features/chat/components/ProgressIndicator.tsx @@ -0,0 +1,56 @@ +import { cn } from '@/lib/cn' + +interface ProgressIndicatorProps { + current: number + total: number + showLabel?: boolean + className?: string +} + +interface DotConfig { + key: string + index: number + state: 'completed' | 'current' | 'upcoming' +} + +function buildDots(total: number, current: number): DotConfig[] { + return Array.from({ length: total }, (_, i) => ({ + key: `progress-dot-${i}`, + index: i, + state: i < current ? 'completed' : i === current ? 'current' : 'upcoming', + })) +} + +export function ProgressIndicator({ + current, + total, + showLabel = false, + className, +}: ProgressIndicatorProps) { + const dots = buildDots(total, current) + + return ( +
+
    + {dots.map((dot) => ( +
  1. + ))} +
+ {showLabel && ( + + Question {current + 1} of {total} + + )} +
+ ) +} diff --git a/src/features/chat/components/ResponseStarter.test.tsx b/src/features/chat/components/ResponseStarter.test.tsx new file mode 100644 index 0000000..f83c119 --- /dev/null +++ b/src/features/chat/components/ResponseStarter.test.tsx @@ -0,0 +1,26 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { ResponseStarter } from './ResponseStarter' + +describe('ResponseStarter', () => { + const starters = ['Our biggest challenge is...', "We've been struggling with..."] + + it('renders all starter options', () => { + render() + expect(screen.getByText('Our biggest challenge is...')).toBeInTheDocument() + expect(screen.getByText("We've been struggling with...")).toBeInTheDocument() + }) + + it('calls onSelect with starter text when clicked', async () => { + const onSelect = vi.fn() + render() + await userEvent.click(screen.getByText('Our biggest challenge is...')) + expect(onSelect).toHaveBeenCalledWith('Our biggest challenge is...') + }) + + it('renders nothing when starters array is empty', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) +}) diff --git a/src/features/chat/components/ResponseStarter.tsx b/src/features/chat/components/ResponseStarter.tsx new file mode 100644 index 0000000..ea76790 --- /dev/null +++ b/src/features/chat/components/ResponseStarter.tsx @@ -0,0 +1,31 @@ +import { cn } from '@/lib/cn' + +interface ResponseStarterProps { + starters: string[] + onSelect: (starter: string) => void + className?: string +} + +export function ResponseStarter({ starters, onSelect, className }: ResponseStarterProps) { + if (starters.length === 0) return null + + return ( +
+ {starters.map((starter) => ( + + ))} +
+ ) +} diff --git a/src/features/chat/components/SuggestionChips.test.tsx b/src/features/chat/components/SuggestionChips.test.tsx new file mode 100644 index 0000000..a3dd281 --- /dev/null +++ b/src/features/chat/components/SuggestionChips.test.tsx @@ -0,0 +1,42 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { SuggestionChips } from './SuggestionChips' + +const mockOptions = [ + { value: 'option_a', label: 'Option A' }, + { value: 'option_b', label: 'Option B' }, + { value: 'option_c', label: 'Option C' }, +] + +describe('SuggestionChips', () => { + it('renders all options as buttons', () => { + render() + expect(screen.getByText('Option A')).toBeInTheDocument() + expect(screen.getByText('Option B')).toBeInTheDocument() + expect(screen.getByText('Option C')).toBeInTheDocument() + }) + + it('calls onSelect with value and label when chip clicked', async () => { + const onSelect = vi.fn() + render() + await userEvent.click(screen.getByText('Option B')) + expect(onSelect).toHaveBeenCalledWith('option_b', 'Option B') + }) + + it('applies custom className', () => { + const { container } = render( + , + ) + expect(container.firstChild).toHaveClass('custom-class') + }) + + it('renders chips as rounded buttons', () => { + render() + const buttons = screen.getAllByRole('button') + expect(buttons).toHaveLength(3) + for (const button of buttons) { + expect(button).toHaveClass('rounded-full') + } + }) +}) diff --git a/src/features/chat/components/SuggestionChips.tsx b/src/features/chat/components/SuggestionChips.tsx new file mode 100644 index 0000000..cf14d93 --- /dev/null +++ b/src/features/chat/components/SuggestionChips.tsx @@ -0,0 +1,29 @@ +import { cn } from '@/lib/cn' +import type { QuestionOption } from '../config/questions' + +interface SuggestionChipsProps { + options: QuestionOption[] + onSelect: (value: string, label: string) => void + className?: string +} + +export function SuggestionChips({ options, onSelect, className }: SuggestionChipsProps) { + return ( +
+ {options.map((option) => ( + + ))} +
+ ) +} diff --git a/src/features/chat/components/index.ts b/src/features/chat/components/index.ts index 52586ae..cb73104 100644 --- a/src/features/chat/components/index.ts +++ b/src/features/chat/components/index.ts @@ -1,3 +1,8 @@ +export { AnswerCard } from './AnswerCard' export { ChatBubble } from './ChatBubble' export { ChatContainer } from './ChatContainer' export { ChatInput } from './ChatInput' +export { InterviewContainer } from './InterviewContainer' +export { InterviewQuestion } from './InterviewQuestion' +export { ProgressIndicator } from './ProgressIndicator' +export { ResponseStarter } from './ResponseStarter' diff --git a/src/features/chat/config/index.ts b/src/features/chat/config/index.ts new file mode 100644 index 0000000..d2105ec --- /dev/null +++ b/src/features/chat/config/index.ts @@ -0,0 +1 @@ +export * from './questions' diff --git a/src/features/chat/config/questions.test.ts b/src/features/chat/config/questions.test.ts new file mode 100644 index 0000000..fa6ba9e --- /dev/null +++ b/src/features/chat/config/questions.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { + INTERVIEW_QUESTIONS, + getQuestionById, + getResponseStarters, + getStructuredQuestions, +} from './questions' + +describe('INTERVIEW_QUESTIONS', () => { + it('has 8 questions total', () => { + expect(INTERVIEW_QUESTIONS).toHaveLength(8) + }) + + it('has correct question IDs', () => { + const ids = INTERVIEW_QUESTIONS.map((q) => q.id) + expect(ids).toEqual([ + 'intent', + 'role', + 'ai_maturity', + 'working_style', + 'timeline', + 'company_size', + 'industry', + 'budget_range', + ]) + }) + + it('each question has required fields', () => { + for (const q of INTERVIEW_QUESTIONS) { + expect(q).toHaveProperty('id') + expect(q).toHaveProperty('question') + expect(q).toHaveProperty('options') + expect(q.options.length).toBeGreaterThanOrEqual(3) + for (const opt of q.options) { + expect(opt).toHaveProperty('value') + expect(opt).toHaveProperty('label') + } + } + }) +}) + +describe('getQuestionById', () => { + it('returns question for valid ID', () => { + const q = getQuestionById('intent') + expect(q?.question).toContain('brings you') + }) + + it('returns undefined for invalid ID', () => { + expect(getQuestionById('invalid')).toBeUndefined() + }) +}) + +describe('getStructuredQuestions', () => { + it('returns first 7 questions (excludes budget)', () => { + const structured = getStructuredQuestions() + expect(structured).toHaveLength(7) + expect(structured.map((q) => q.id)).not.toContain('budget_range') + }) +}) + +describe('getResponseStarters', () => { + it('returns starters for problem prompt', () => { + const starters = getResponseStarters('problem') + expect(starters.length).toBeGreaterThan(0) + expect(starters[0]).toContain('...') + }) +}) diff --git a/src/features/chat/config/questions.ts b/src/features/chat/config/questions.ts new file mode 100644 index 0000000..a0d65ac --- /dev/null +++ b/src/features/chat/config/questions.ts @@ -0,0 +1,135 @@ +export interface QuestionOption { + value: string + label: string +} + +export interface InterviewQuestion { + id: string + question: string + subtitle?: string + options: QuestionOption[] + phase: 'opener' | 'personality' | 'qualification' | 'post_contact' +} + +export const INTERVIEW_QUESTIONS: InterviewQuestion[] = [ + // Opener + { + id: 'intent', + question: 'What brings you to Vibes today?', + subtitle: "We'll tailor the conversation to your needs", + phase: 'opener', + options: [ + { value: 'specific_project', label: 'I have a specific AI project in mind' }, + { value: 'exploring', label: "I'm exploring what's possible with AI" }, + { value: 'existing_system', label: 'I need help with an existing AI system' }, + { value: 'upskill', label: 'I want to upskill my team' }, + ], + }, + { + id: 'role', + question: "What's your perspective on this?", + phase: 'opener', + options: [ + { value: 'technical', label: 'Technical (CTO, VP Eng, Developer)' }, + { value: 'business', label: 'Business (CEO, COO, Strategy)' }, + { value: 'ai_lead', label: 'AI/Innovation Lead' }, + { value: 'founder', label: 'Founder building something new' }, + ], + }, + // Personality + { + id: 'ai_maturity', + question: "Your team's relationship with AI is best described as...", + phase: 'personality', + options: [ + { value: 'first_date', label: 'First date — curious but cautious' }, + { value: 'going_steady', label: 'Going steady — some experiments working' }, + { value: 'committed', label: 'Committed — AI is core to our strategy' }, + ], + }, + { + id: 'working_style', + question: 'When you work with partners, you prefer...', + phase: 'personality', + options: [ + { value: 'full_ownership', label: 'Give us the keys — full ownership' }, + { value: 'embedded', label: 'Collaborate closely — embedded partnership' }, + { value: 'knowledge_transfer', label: 'Teach us to fish — knowledge transfer focus' }, + ], + }, + // Qualification + { + id: 'timeline', + question: 'When are you looking to move?', + phase: 'qualification', + options: [ + { value: 'asap', label: 'ASAP (within weeks)' }, + { value: 'quarter', label: 'This quarter' }, + { value: 'year', label: 'This year' }, + { value: 'exploring', label: 'Just exploring' }, + ], + }, + { + id: 'company_size', + question: 'How big is your organization?', + phase: 'qualification', + options: [ + { value: 'startup', label: 'Startup (1-20)' }, + { value: 'growth', label: 'Growth (21-100)' }, + { value: 'midmarket', label: 'Mid-market (101-1000)' }, + { value: 'enterprise', label: 'Enterprise (1000+)' }, + ], + }, + { + id: 'industry', + question: 'What space are you in?', + phase: 'qualification', + options: [ + { value: 'fintech', label: 'Fintech' }, + { value: 'ecommerce', label: 'E-commerce' }, + { value: 'saas', label: 'SaaS' }, + { value: 'professional_services', label: 'Professional Services' }, + { value: 'healthcare', label: 'Healthcare' }, + { value: 'other', label: 'Other' }, + ], + }, + // Post-contact + { + id: 'budget_range', + question: "What's your budget range for this initiative?", + phase: 'post_contact', + options: [ + { value: 'under_50k', label: 'Under $50k' }, + { value: '50k_150k', label: '$50k – $150k' }, + { value: '150k_500k', label: '$150k – $500k' }, + { value: '500k_plus', label: '$500k+' }, + { value: 'unsure', label: 'Not sure yet' }, + ], + }, +] + +export function getQuestionById(id: string): InterviewQuestion | undefined { + return INTERVIEW_QUESTIONS.find((q) => q.id === id) +} + +export function getStructuredQuestions(): InterviewQuestion[] { + return INTERVIEW_QUESTIONS.filter((q) => q.phase !== 'post_contact') +} + +export function getPostContactQuestions(): InterviewQuestion[] { + return INTERVIEW_QUESTIONS.filter((q) => q.phase === 'post_contact') +} + +const RESPONSE_STARTERS: Record = { + problem: [ + 'Our biggest challenge is...', + "We've been struggling with...", + 'Our customers keep asking for...', + ], + vision: ['If this worked, we could...', 'The dream scenario is...', "We'd measure success by..."], + users: ['Our internal team needs...', 'Our customers want...', 'Both internal and external...'], +} + +export function getResponseStarters(promptType: string): string[] { + return RESPONSE_STARTERS[promptType] ?? [] +} diff --git a/src/features/chat/hooks/useChat.ts b/src/features/chat/hooks/useChat.ts index f431997..e330299 100644 --- a/src/features/chat/hooks/useChat.ts +++ b/src/features/chat/hooks/useChat.ts @@ -9,15 +9,18 @@ export interface Message { interface UseChatOptions { apiEndpoint?: string + interviewAnswers?: Record + onLeadExtracted?: () => void } export function useChat(options: UseChatOptions = {}) { - const { apiEndpoint = '/api/chat' } = options + const { apiEndpoint = '/api/chat', interviewAnswers, onLeadExtracted } = options const [messages, setMessages] = useState([ { id: 'welcome', role: 'assistant', - content: "Howdy! What's the vision? Let's talk about bringing your AI project to life.", + content: + "Great to connect! Based on what you've shared, I'd love to learn more about what you're looking to build. What's the main challenge you're trying to solve?", timestamp: new Date(), }, ]) @@ -45,6 +48,8 @@ export function useChat(options: UseChatOptions = {}) { body: JSON.stringify({ message: content, sessionId, + phase: 'chat', + interviewAnswers, }), }) @@ -66,13 +71,18 @@ export function useChat(options: UseChatOptions = {}) { } setMessages((prev) => [...prev, assistantMessage]) + + // Notify when lead is extracted (backend confirmed contact collection) + if (data.leadExtracted && onLeadExtracted) { + onLeadExtracted() + } } catch (err) { setError(err instanceof Error ? err.message : 'Something went wrong') } finally { setIsLoading(false) } }, - [apiEndpoint, sessionId], + [apiEndpoint, sessionId, interviewAnswers, onLeadExtracted], ) const clearMessages = useCallback(() => { diff --git a/src/features/chat/hooks/useInterview.test.ts b/src/features/chat/hooks/useInterview.test.ts new file mode 100644 index 0000000..ff0f6f7 --- /dev/null +++ b/src/features/chat/hooks/useInterview.test.ts @@ -0,0 +1,222 @@ +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { useInterview } from './useInterview' + +describe('useInterview', () => { + it('starts at question 0 in structured phase', () => { + const { result } = renderHook(() => useInterview()) + expect(result.current.phase).toBe('structured') + expect(result.current.currentQuestionIndex).toBe(0) + }) + + it('advances to next question on answer', () => { + const { result } = renderHook(() => useInterview()) + act(() => { + result.current.answerQuestion('intent', 'specific_project') + }) + expect(result.current.currentQuestionIndex).toBe(1) + expect(result.current.answers.intent).toBe('specific_project') + }) + + it('transitions to chat phase after all structured questions', () => { + const { result } = renderHook(() => useInterview()) + // Answer all 7 structured questions + act(() => { + result.current.answerQuestion('intent', 'specific_project') + }) + act(() => { + result.current.answerQuestion('role', 'technical') + }) + act(() => { + result.current.answerQuestion('ai_maturity', 'going_steady') + }) + act(() => { + result.current.answerQuestion('working_style', 'embedded') + }) + act(() => { + result.current.answerQuestion('timeline', 'quarter') + }) + act(() => { + result.current.answerQuestion('company_size', 'startup') + }) + act(() => { + result.current.answerQuestion('industry', 'saas') + }) + expect(result.current.phase).toBe('chat') + }) + + it('transitions to post_contact when contact collected', () => { + const { result } = renderHook(() => useInterview()) + // Fast-forward to chat phase + act(() => { + result.current.answerQuestion('intent', 'specific_project') + }) + act(() => { + result.current.answerQuestion('role', 'technical') + }) + act(() => { + result.current.answerQuestion('ai_maturity', 'going_steady') + }) + act(() => { + result.current.answerQuestion('working_style', 'embedded') + }) + act(() => { + result.current.answerQuestion('timeline', 'quarter') + }) + act(() => { + result.current.answerQuestion('company_size', 'startup') + }) + act(() => { + result.current.answerQuestion('industry', 'saas') + }) + act(() => { + result.current.setContactCollected(true) + }) + expect(result.current.phase).toBe('post_contact') + }) + + it('transitions to complete after budget answered', () => { + const { result } = renderHook(() => useInterview()) + // Fast-forward through all phases + act(() => { + result.current.answerQuestion('intent', 'specific_project') + }) + act(() => { + result.current.answerQuestion('role', 'technical') + }) + act(() => { + result.current.answerQuestion('ai_maturity', 'going_steady') + }) + act(() => { + result.current.answerQuestion('working_style', 'embedded') + }) + act(() => { + result.current.answerQuestion('timeline', 'quarter') + }) + act(() => { + result.current.answerQuestion('company_size', 'startup') + }) + act(() => { + result.current.answerQuestion('industry', 'saas') + }) + act(() => { + result.current.setContactCollected(true) + }) + act(() => { + result.current.answerQuestion('budget_range', '50k_150k') + }) + expect(result.current.phase).toBe('complete') + expect(result.current.answers.budget_range).toBe('50k_150k') + }) + + it('returns current question', () => { + const { result } = renderHook(() => useInterview()) + expect(result.current.currentQuestion?.id).toBe('intent') + }) + + it('calculates progress correctly', () => { + const { result } = renderHook(() => useInterview()) + expect(result.current.progress).toEqual({ current: 0, total: 7 }) + act(() => { + result.current.answerQuestion('intent', 'specific_project') + }) + expect(result.current.progress).toEqual({ current: 1, total: 7 }) + }) + + describe('messages array', () => { + it('starts with welcome message and first question', () => { + const { result } = renderHook(() => useInterview()) + expect(result.current.messages).toHaveLength(2) + expect(result.current.messages[0].role).toBe('assistant') + expect(result.current.messages[0].content).toContain("I'm here to learn") + expect(result.current.messages[1].role).toBe('assistant') + expect(result.current.messages[1].content).toContain('brings you') + }) + + it('adds user message and next question when answering', () => { + const { result } = renderHook(() => useInterview()) + act(() => { + result.current.answerQuestion('intent', 'specific_project', 'I have a project') + }) + expect(result.current.messages).toHaveLength(4) + expect(result.current.messages[2].role).toBe('user') + expect(result.current.messages[2].content).toBe('I have a project') + expect(result.current.messages[3].role).toBe('assistant') + }) + + it('adds transition message when entering chat phase', () => { + const { result } = renderHook(() => useInterview()) + // Answer all 7 structured questions + const answers = [ + ['intent', 'specific_project'], + ['role', 'technical'], + ['ai_maturity', 'going_steady'], + ['working_style', 'embedded'], + ['timeline', 'quarter'], + ['company_size', 'startup'], + ['industry', 'saas'], + ] + for (const [id, value] of answers) { + act(() => { + result.current.answerQuestion(id, value) + }) + } + const lastMessage = result.current.messages[result.current.messages.length - 1] + expect(lastMessage.role).toBe('assistant') + expect(lastMessage.content).toContain('tell me more') + }) + + it('adds budget question when contact collected', () => { + const { result } = renderHook(() => useInterview()) + // Fast-forward to chat phase + const answers = [ + ['intent', 'specific_project'], + ['role', 'technical'], + ['ai_maturity', 'going_steady'], + ['working_style', 'embedded'], + ['timeline', 'quarter'], + ['company_size', 'startup'], + ['industry', 'saas'], + ] + for (const [id, value] of answers) { + act(() => { + result.current.answerQuestion(id, value) + }) + } + act(() => { + result.current.setContactCollected(true) + }) + const lastMessage = result.current.messages[result.current.messages.length - 1] + expect(lastMessage.role).toBe('assistant') + expect(lastMessage.content).toContain('budget') + }) + + it('adds completion message after budget answered', () => { + const { result } = renderHook(() => useInterview()) + // Fast-forward through all phases + const answers = [ + ['intent', 'specific_project'], + ['role', 'technical'], + ['ai_maturity', 'going_steady'], + ['working_style', 'embedded'], + ['timeline', 'quarter'], + ['company_size', 'startup'], + ['industry', 'saas'], + ] + for (const [id, value] of answers) { + act(() => { + result.current.answerQuestion(id, value) + }) + } + act(() => { + result.current.setContactCollected(true) + }) + act(() => { + result.current.answerQuestion('budget_range', '50k_150k', '$50k - $150k') + }) + const lastMessage = result.current.messages[result.current.messages.length - 1] + expect(lastMessage.role).toBe('assistant') + expect(lastMessage.content).toContain('reach out') + }) + }) +}) diff --git a/src/features/chat/hooks/useInterview.ts b/src/features/chat/hooks/useInterview.ts new file mode 100644 index 0000000..6768eca --- /dev/null +++ b/src/features/chat/hooks/useInterview.ts @@ -0,0 +1,166 @@ +import { useCallback, useMemo, useState } from 'react' +import { + type InterviewQuestion, + getPostContactQuestions, + getStructuredQuestions, +} from '../config/questions' + +export interface InterviewMessage { + id: string + role: 'user' | 'assistant' + content: string + timestamp: Date +} + +export interface InterviewAnswers { + [key: string]: string +} + +export type InterviewPhase = 'structured' | 'chat' | 'post_contact' | 'complete' + +interface UseInterviewReturn { + phase: InterviewPhase + currentQuestionIndex: number + currentQuestion: InterviewQuestion | undefined + answers: InterviewAnswers + messages: InterviewMessage[] + progress: { current: number; total: number } + contactCollected: boolean + answerQuestion: (questionId: string, value: string, displayText?: string) => void + setContactCollected: (collected: boolean) => void +} + +/** Create the initial welcome message and first question */ +function createInitialMessages(firstQuestion: InterviewQuestion): InterviewMessage[] { + return [ + { + id: 'welcome', + role: 'assistant', + content: + "Hey! I'm here to learn about what you're building. Let me ask a few quick questions to understand how we can help.", + timestamp: new Date(), + }, + { + id: `q-${firstQuestion.id}`, + role: 'assistant', + content: firstQuestion.question, + timestamp: new Date(), + }, + ] +} + +export function useInterview(): UseInterviewReturn { + const structuredQuestions = useMemo(() => getStructuredQuestions(), []) + const postContactQuestions = useMemo(() => getPostContactQuestions(), []) + + const [phase, setPhase] = useState('structured') + const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0) + const [answers, setAnswers] = useState({}) + const [contactCollected, setContactCollectedState] = useState(false) + const [messages, setMessages] = useState(() => + createInitialMessages(structuredQuestions[0]), + ) + + const currentQuestion = useMemo(() => { + if (phase === 'structured') { + return structuredQuestions[currentQuestionIndex] + } + if (phase === 'post_contact') { + return postContactQuestions[0] // Budget question + } + return undefined + }, [phase, currentQuestionIndex, structuredQuestions, postContactQuestions]) + + const progress = useMemo( + () => ({ + current: currentQuestionIndex, + total: structuredQuestions.length, + }), + [currentQuestionIndex, structuredQuestions.length], + ) + + const answerQuestion = useCallback( + (questionId: string, value: string, displayText?: string) => { + setAnswers((prev) => ({ ...prev, [questionId]: value })) + + // Add user's answer as a message + const userMessage: InterviewMessage = { + id: `a-${questionId}-${Date.now()}`, + role: 'user', + content: displayText || value, + timestamp: new Date(), + } + setMessages((prev) => [...prev, userMessage]) + + if (phase === 'structured') { + const nextIndex = currentQuestionIndex + 1 + if (nextIndex >= structuredQuestions.length) { + // Transition to chat phase - add transition message + const transitionMessage: InterviewMessage = { + id: 'transition-to-chat', + role: 'assistant', + content: + "Thanks for sharing! Now tell me more about what you're working on. What's the main challenge you're trying to solve?", + timestamp: new Date(), + } + setMessages((prev) => [...prev, transitionMessage]) + setPhase('chat') + } else { + // Add next question as a message + const nextQuestion = structuredQuestions[nextIndex] + const questionMessage: InterviewMessage = { + id: `q-${nextQuestion.id}`, + role: 'assistant', + content: nextQuestion.question, + timestamp: new Date(), + } + setMessages((prev) => [...prev, questionMessage]) + setCurrentQuestionIndex(nextIndex) + } + } else if (phase === 'post_contact' && questionId === 'budget_range') { + // Add completion message + const completionMessage: InterviewMessage = { + id: 'completion', + role: 'assistant', + content: + 'Thanks for sharing your vision! A member of the Vibes team will reach out to discuss next steps.', + timestamp: new Date(), + } + setMessages((prev) => [...prev, completionMessage]) + setPhase('complete') + } + }, + [phase, currentQuestionIndex, structuredQuestions], + ) + + const setContactCollected = useCallback( + (collected: boolean) => { + setContactCollectedState(collected) + if (collected && phase === 'chat') { + // Add budget question as a message + const budgetQuestion = postContactQuestions[0] + const questionMessage: InterviewMessage = { + id: `q-${budgetQuestion.id}`, + role: 'assistant', + content: budgetQuestion.question, + timestamp: new Date(), + } + setMessages((prev) => [...prev, questionMessage]) + setPhase('post_contact') + } + }, + [phase, postContactQuestions], + ) + + return { + phase, + currentQuestionIndex, + currentQuestion, + answers, + messages, + progress, + contactCollected, + answerQuestion, + setContactCollected, + } +} diff --git a/src/routes/contact.tsx b/src/routes/contact.tsx index a734351..f4a6d87 100644 --- a/src/routes/contact.tsx +++ b/src/routes/contact.tsx @@ -3,7 +3,7 @@ import { Container } from '@/components/ui/Container' import { Input } from '@/components/ui/Input' import { Section } from '@/components/ui/Section' import { Heading, Text } from '@/components/ui/Typography' -import { ChatContainer } from '@/features/chat/components/ChatContainer' +import { InterviewContainer } from '@/features/chat/components/InterviewContainer' import { createFileRoute } from '@tanstack/react-router' import { useCallback, useRef, useState } from 'react' @@ -38,7 +38,7 @@ function ContactPage() { {/* Chat Interface */}
- diff --git a/vitest.config.ts b/vitest.config.ts index ee663d9..4796434 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,11 +9,11 @@ export default defineConfig({ environment: 'jsdom', globals: true, setupFiles: ['./src/test/setup.ts'], - include: ['src/**/*.test.{ts,tsx}'], + include: ['src/**/*.test.{ts,tsx}', 'workers/**/*.test.{ts,tsx}'], coverage: { provider: 'v8', reporter: ['text', 'html'], - include: ['src/components/**', 'src/lib/**'], + include: ['src/components/**', 'src/lib/**', 'workers/**/*.ts'], }, }, }) diff --git a/workers/chat-api/migrations/0002_interview_fields.sql b/workers/chat-api/migrations/0002_interview_fields.sql new file mode 100644 index 0000000..c15bbff --- /dev/null +++ b/workers/chat-api/migrations/0002_interview_fields.sql @@ -0,0 +1,18 @@ +-- Add structured interview fields to leads table +ALTER TABLE leads ADD COLUMN intent TEXT; +ALTER TABLE leads ADD COLUMN role TEXT; +ALTER TABLE leads ADD COLUMN ai_maturity TEXT; +ALTER TABLE leads ADD COLUMN working_style TEXT; +ALTER TABLE leads ADD COLUMN timeline TEXT; +ALTER TABLE leads ADD COLUMN company_size TEXT; +ALTER TABLE leads ADD COLUMN industry TEXT; +ALTER TABLE leads ADD COLUMN budget_range TEXT; +ALTER TABLE leads ADD COLUMN lead_score INTEGER; +ALTER TABLE leads ADD COLUMN lead_tier TEXT; + +-- Add interview_answers JSON column for raw storage +ALTER TABLE leads ADD COLUMN interview_answers TEXT; + +-- Add index on lead_tier for filtering +CREATE INDEX IF NOT EXISTS idx_leads_lead_tier ON leads(lead_tier); +CREATE INDEX IF NOT EXISTS idx_leads_lead_score ON leads(lead_score); diff --git a/workers/chat-api/schema.sql b/workers/chat-api/schema.sql index 7c4f68e..c039934 100644 --- a/workers/chat-api/schema.sql +++ b/workers/chat-api/schema.sql @@ -31,6 +31,18 @@ CREATE TABLE IF NOT EXISTS leads ( capabilities TEXT, constraints TEXT, prd_draft TEXT, + -- Structured interview answers + intent TEXT, + role TEXT, + ai_maturity TEXT, + working_style TEXT, + timeline TEXT, + company_size TEXT, + industry TEXT, + budget_range TEXT, + lead_score INTEGER, + lead_tier TEXT, + interview_answers TEXT, created_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (session_id) REFERENCES sessions(id) ); @@ -41,3 +53,5 @@ CREATE INDEX IF NOT EXISTS idx_sessions_created_at ON sessions(created_at); CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id); CREATE INDEX IF NOT EXISTS idx_leads_email ON leads(email); CREATE INDEX IF NOT EXISTS idx_leads_created_at ON leads(created_at); +CREATE INDEX IF NOT EXISTS idx_leads_lead_tier ON leads(lead_tier); +CREATE INDEX IF NOT EXISTS idx_leads_lead_score ON leads(lead_score); diff --git a/workers/chat-api/src/claude.ts b/workers/chat-api/src/claude.ts index 89fe627..4c028c6 100644 --- a/workers/chat-api/src/claude.ts +++ b/workers/chat-api/src/claude.ts @@ -1,7 +1,99 @@ -import type { Message } from './types' +import type { InterviewAnswers, Message } from './types' -const SYSTEM_PROMPT = `You are a friendly, professional assistant for Vibes, an AI agent development studio. Your goal is to have a natural conversation that helps understand what the visitor is looking to build. +function buildContextFromAnswers(answers: InterviewAnswers): string { + const parts: string[] = [] + // Intent context + if (answers.intent === 'specific_project') { + parts.push('They have a specific AI project in mind.') + } else if (answers.intent === 'exploring') { + parts.push("They're exploring what's possible with AI.") + } else if (answers.intent === 'existing_system') { + parts.push('They need help with an existing AI system.') + } else if (answers.intent === 'upskill') { + parts.push('They want to upskill their team on AI.') + } + + // Role context + if (answers.role === 'technical') { + parts.push('They have a technical background (CTO, VP Eng, or developer).') + } else if (answers.role === 'business') { + parts.push('They focus on the business side (CEO, COO, or strategy).') + } else if (answers.role === 'ai_lead') { + parts.push('They lead AI or innovation initiatives.') + } else if (answers.role === 'founder') { + parts.push("They're a founder building something new.") + } + + // AI maturity context + if (answers.ai_maturity === 'first_date') { + parts.push("Their team is new to AI — they're curious but cautious.") + } else if (answers.ai_maturity === 'going_steady') { + parts.push('Their team has some AI experiments working.') + } else if (answers.ai_maturity === 'committed') { + parts.push('AI is core to their strategy — they are committed.') + } + + // Working style context + if (answers.working_style === 'full_ownership') { + parts.push('They prefer partners who take full ownership.') + } else if (answers.working_style === 'embedded') { + parts.push('They want close collaboration with embedded partnership.') + } else if (answers.working_style === 'knowledge_transfer') { + parts.push('They prioritize knowledge transfer — teach them to fish.') + } + + // Timeline context + if (answers.timeline === 'asap') { + parts.push('They want to move ASAP (within weeks).') + } else if (answers.timeline === 'quarter') { + parts.push('Their timeline is this quarter.') + } else if (answers.timeline === 'year') { + parts.push('Their timeline is this year.') + } else if (answers.timeline === 'exploring') { + parts.push("They're just exploring for now.") + } + + // Company size context + if (answers.company_size === 'startup') { + parts.push("They're a startup (1-20 people).") + } else if (answers.company_size === 'growth') { + parts.push("They're a growth-stage company (21-100 people).") + } else if (answers.company_size === 'midmarket') { + parts.push("They're a mid-market company (101-1000 people).") + } else if (answers.company_size === 'enterprise') { + parts.push("They're an enterprise (1000+ people).") + } + + // Industry context + const industryMap: Record = { + fintech: 'fintech', + ecommerce: 'e-commerce', + saas: 'SaaS', + professional_services: 'professional services', + healthcare: 'healthcare', + other: 'another industry', + } + if (answers.industry) { + parts.push(`They work in ${industryMap[answers.industry] ?? answers.industry}.`) + } + + return parts.join(' ') +} + +function buildSystemPrompt(interviewContext?: string): string { + const contextSection = interviewContext + ? ` +## What You Know About Them +${interviewContext} + +Use this context to personalize your questions and show you've been listening. +` + : '' + + return `You are a friendly, professional assistant for Vibes, an AI agent development studio. Your goal is to have a natural conversation that helps understand what the visitor is looking to build. + +${contextSection} ## Your Objectives 1. Understand their project and business needs 2. Extract enough information to create a mini-PRD @@ -12,12 +104,12 @@ const SYSTEM_PROMPT = `You are a friendly, professional assistant for Vibes, an - **Vision**: What does success look like? What would an ideal solution do? - **Users**: Who will use this? What are their needs? - **Key Capabilities**: What must it do? What's nice-to-have? -- **Constraints**: Timeline, budget range, technical requirements, integrations? - **Contact**: Name, company, email ## Conversation Style - Be warm and conversational, not robotic - Ask follow-up questions that show you're listening +- Reference what you know about them from the interview - Share brief, relevant insights when appropriate - Keep responses concise (2-4 sentences typically) - Don't ask multiple questions at once @@ -29,6 +121,7 @@ When you feel you have a good understanding of their needs AND have their contac - Never make up information about Vibes' capabilities or past work - If asked about pricing, say you'll connect them with the team who can discuss specifics - If they seem unsure, help them articulate their needs through questions` +} interface ClaudeMessage { role: 'user' | 'assistant' @@ -43,7 +136,11 @@ export async function callClaude( apiKey: string, conversationHistory: Message[], newMessage: string, + interviewAnswers?: InterviewAnswers, ): Promise { + const interviewContext = interviewAnswers ? buildContextFromAnswers(interviewAnswers) : undefined + const systemPrompt = buildSystemPrompt(interviewContext) + const messages: ClaudeMessage[] = conversationHistory .filter((m) => m.role !== 'system') .map((m) => ({ @@ -63,7 +160,7 @@ export async function callClaude( body: JSON.stringify({ model: 'claude-sonnet-4-20250514', max_tokens: 1024, - system: SYSTEM_PROMPT, + system: systemPrompt, messages, }), }) diff --git a/workers/chat-api/src/email.ts b/workers/chat-api/src/email.ts index f7e08b4..520d04d 100644 --- a/workers/chat-api/src/email.ts +++ b/workers/chat-api/src/email.ts @@ -1,3 +1,5 @@ +import type { InterviewAnswers, LeadTierValue } from './types' + interface EmailOptions { to: string subject: string @@ -26,6 +28,119 @@ export async function sendEmail(apiKey: string, options: EmailOptions): Promise< } } +// Label maps for human-readable display +const INTENT_LABELS: Record = { + specific_project: 'Specific project in mind', + exploring: 'Exploring possibilities', + existing_system: 'Help with existing AI', + upskill: 'Team upskilling', +} + +const ROLE_LABELS: Record = { + technical: 'Technical (CTO/VP Eng/Dev)', + business: 'Business (CEO/COO/Strategy)', + ai_lead: 'AI/Innovation Lead', + founder: 'Founder', +} + +const AI_MATURITY_LABELS: Record = { + first_date: 'First date — curious', + going_steady: 'Going steady — experimenting', + committed: 'Committed — AI is core', +} + +const WORKING_STYLE_LABELS: Record = { + full_ownership: 'Full ownership', + embedded: 'Embedded partnership', + knowledge_transfer: 'Knowledge transfer', +} + +const TIMELINE_LABELS: Record = { + asap: 'ASAP (weeks)', + quarter: 'This quarter', + year: 'This year', + exploring: 'Just exploring', +} + +const COMPANY_SIZE_LABELS: Record = { + startup: 'Startup (1-20)', + growth: 'Growth (21-100)', + midmarket: 'Mid-market (101-1000)', + enterprise: 'Enterprise (1000+)', +} + +const INDUSTRY_LABELS: Record = { + fintech: 'Fintech', + ecommerce: 'E-commerce', + saas: 'SaaS', + professional_services: 'Professional Services', + healthcare: 'Healthcare', + other: 'Other', +} + +const BUDGET_LABELS: Record = { + under_50k: 'Under $50k', + '50k_150k': '$50k – $150k', + '150k_500k': '$150k – $500k', + '500k_plus': '$500k+', + unsure: 'Not sure yet', +} + +const TIER_EMOJI: Record = { + hot: '🔥', + warm: '🌡️', + cool: '❄️', + cold: '🧊', +} + +function formatInterviewSection(answers: InterviewAnswers): string { + const rows: string[] = [] + + if (answers.intent) + rows.push( + `Intent${INTENT_LABELS[answers.intent] ?? answers.intent}`, + ) + if (answers.role) + rows.push( + `Role${ROLE_LABELS[answers.role] ?? answers.role}`, + ) + if (answers.ai_maturity) + rows.push( + `AI Maturity${AI_MATURITY_LABELS[answers.ai_maturity] ?? answers.ai_maturity}`, + ) + if (answers.working_style) + rows.push( + `Working Style${WORKING_STYLE_LABELS[answers.working_style] ?? answers.working_style}`, + ) + if (answers.timeline) + rows.push( + `Timeline${TIMELINE_LABELS[answers.timeline] ?? answers.timeline}`, + ) + if (answers.company_size) + rows.push( + `Company Size${COMPANY_SIZE_LABELS[answers.company_size] ?? answers.company_size}`, + ) + if (answers.industry) + rows.push( + `Industry${INDUSTRY_LABELS[answers.industry] ?? answers.industry}`, + ) + if (answers.budget_range) + rows.push( + `Budget${BUDGET_LABELS[answers.budget_range] ?? answers.budget_range}`, + ) + + if (rows.length === 0) return '' + + return ` +
+
Interview Profile
+ + ${rows.join('')} +
+
+ ` +} + export function formatLeadEmail( lead: { name: string | null @@ -34,8 +149,28 @@ export function formatLeadEmail( projectSummary: string | null }, prdDraft: string, - conversationUrl?: string, + options?: { + interviewAnswers?: InterviewAnswers + leadScore?: number + leadTier?: LeadTierValue + conversationUrl?: string + }, ): string { + const tierEmoji = options?.leadTier ? TIER_EMOJI[options.leadTier] : '' + const tierLabel = options?.leadTier + ? options.leadTier.charAt(0).toUpperCase() + options.leadTier.slice(1) + : '' + const scoreSection = + options?.leadScore !== undefined + ? `
+ ${tierEmoji} ${tierLabel} Lead (Score: ${options.leadScore}/13) +
` + : '' + + const interviewSection = options?.interviewAnswers + ? formatInterviewSection(options.interviewAnswers) + : '' + return ` @@ -46,7 +181,7 @@ export function formatLeadEmail( .header { background: #0f172a; color: white; padding: 20px; border-radius: 8px 8px 0 0; } .content { background: #f8fafc; padding: 20px; border: 1px solid #e2e8f0; } .section { margin-bottom: 20px; } - .label { font-weight: 600; color: #64748b; font-size: 12px; text-transform: uppercase; } + .label { font-weight: 600; color: #64748b; font-size: 12px; text-transform: uppercase; margin-bottom: 8px; } .value { font-size: 16px; margin-top: 4px; } .prd { background: white; padding: 16px; border-radius: 8px; border: 1px solid #e2e8f0; white-space: pre-wrap; font-family: monospace; font-size: 13px; } .footer { padding: 20px; text-align: center; color: #64748b; font-size: 12px; } @@ -58,6 +193,8 @@ export function formatLeadEmail(

New Lead from Vibes Chat

+ ${scoreSection} +
Contact
@@ -67,6 +204,8 @@ export function formatLeadEmail(
+ ${interviewSection} +
Project Summary
${lead.projectSummary ?? 'Not captured'}
@@ -78,10 +217,10 @@ export function formatLeadEmail(
${ - conversationUrl + options?.conversationUrl ? ` ` : '' @@ -111,14 +250,20 @@ export async function notifyTeam( projectSummary: string | null }, prdDraft: string, + options?: { + interviewAnswers?: InterviewAnswers + leadScore?: number + leadTier?: LeadTierValue + }, ): Promise { const sender = lead.company ?? lead.name ?? 'Unknown' const summary = truncate(lead.projectSummary, 50) || 'New inquiry' - const subject = `New Lead: ${sender} — ${summary}` + const tierEmoji = options?.leadTier ? TIER_EMOJI[options.leadTier] : '' + const subject = `${tierEmoji} New Lead: ${sender} — ${summary}` await sendEmail(resendApiKey, { to: notificationEmail, subject, - html: formatLeadEmail(lead, prdDraft), + html: formatLeadEmail(lead, prdDraft, options), }) } diff --git a/workers/chat-api/src/index.ts b/workers/chat-api/src/index.ts index 23ca90f..8c6886a 100644 --- a/workers/chat-api/src/index.ts +++ b/workers/chat-api/src/index.ts @@ -1,6 +1,7 @@ import { callClaude, cleanResponse, isLeadComplete } from './claude' import { notifyTeam } from './email' import { extractLeadFromConversation, generatePRDDraft, saveLead } from './leads' +import { calculateLeadScore, getLeadTier } from './scoring' import { checkRateLimit, getConversationHistory, @@ -9,7 +10,53 @@ import { incrementMessageCount, saveMessage, } from './session' -import type { ChatRequest, ChatResponse, Env } from './types' +import type { ChatRequest, ChatResponse, Env, InterviewAnswers, LeadTierValue } from './types' + +// In-memory store for interview answers per session. +// Limited to prevent unbounded growth in long-running isolates. +// When limit is exceeded, oldest sessions are evicted (Map maintains insertion order). +const MAX_CACHED_SESSIONS = 1000 +const sessionInterviewAnswers = new Map() + +// Valid interview question IDs for type-safe answer storage +const VALID_QUESTION_IDS = new Set([ + 'intent', + 'role', + 'ai_maturity', + 'working_style', + 'timeline', + 'company_size', + 'industry', + 'budget_range', +]) + +/** + * Safely stores a structured answer in the session's interview answers. + * Validates that the questionId is a known interview field before storing. + */ +function setStructuredAnswer( + sessionId: string, + questionId: string, + answer: string, +): InterviewAnswers { + const answers = sessionInterviewAnswers.get(sessionId) ?? {} + + if (VALID_QUESTION_IDS.has(questionId)) { + // Type assertion is safe here because we've validated questionId + ;(answers as Record)[questionId] = answer + } else { + console.warn(`Invalid questionId "${questionId}" for session ${sessionId} - answer discarded`) + } + + // Evict oldest sessions if limit exceeded (Map maintains insertion order) + while (sessionInterviewAnswers.size >= MAX_CACHED_SESSIONS) { + const oldestKey = sessionInterviewAnswers.keys().next().value + if (oldestKey) sessionInterviewAnswers.delete(oldestKey) + } + + sessionInterviewAnswers.set(sessionId, answers) + return answers +} function getCorsHeaders(origin: string): Record { return { @@ -33,35 +80,73 @@ export default { async fetch(request: Request, env: Env): Promise { const origin = env.ALLOWED_ORIGIN - // CORS preflight if (request.method === 'OPTIONS') { return new Response(null, { headers: getCorsHeaders(origin) }) } const url = new URL(request.url) - // Health check if (url.pathname === '/health') { return jsonResponse({ status: 'ok', timestamp: new Date().toISOString() }, 200, origin) } - // Chat endpoint if (url.pathname === '/chat' && request.method === 'POST') { try { const body = (await request.json()) as ChatRequest + const clientIP = request.headers.get('CF-Connecting-IP') ?? 'unknown' + const ipHash = await hashIP(clientIP) + const session = await getOrCreateSession(env.DB, body.sessionId, ipHash) - if (!body.message?.trim()) { - return jsonResponse({ error: 'Message is required' }, 400, origin) + // Store/update interview answers for this session + if (body.interviewAnswers) { + // Evict oldest sessions if limit exceeded + while (sessionInterviewAnswers.size >= MAX_CACHED_SESSIONS) { + const oldestKey = sessionInterviewAnswers.keys().next().value + if (oldestKey) sessionInterviewAnswers.delete(oldestKey) + } + const existing = sessionInterviewAnswers.get(session.id) ?? {} + sessionInterviewAnswers.set(session.id, { ...existing, ...body.interviewAnswers }) } - // Get client IP for session management - const clientIP = request.headers.get('CF-Connecting-IP') ?? 'unknown' - const ipHash = await hashIP(clientIP) + // Handle structured phase (no Claude call needed) + if (body.phase === 'structured' && body.structuredAnswer) { + setStructuredAnswer( + session.id, + body.structuredAnswer.questionId, + body.structuredAnswer.answer, + ) - // Get or create session - const session = await getOrCreateSession(env.DB, body.sessionId, ipHash) + const response: ChatResponse = { + sessionId: session.id, + } + return jsonResponse(response, 200, origin) + } + + // Handle post_contact phase (budget question) + if (body.phase === 'post_contact' && body.structuredAnswer) { + const answers = setStructuredAnswer( + session.id, + body.structuredAnswer.questionId, + body.structuredAnswer.answer, + ) + + const score = calculateLeadScore(answers) + const tier = getLeadTier(score) + + const response: ChatResponse = { + sessionId: session.id, + leadScore: score, + leadTier: tier, + nextPhase: 'complete', + } + return jsonResponse(response, 200, origin) + } + + // Chat phase - requires message + if (!body.message?.trim()) { + return jsonResponse({ error: 'Message is required' }, 400, origin) + } - // Check rate limit const maxMessagesPerSession = Number.parseInt(env.MAX_MESSAGES_PER_SESSION, 10) || 20 const { allowed } = await checkRateLimit(env.DB, session.id, maxMessagesPerSession) @@ -78,38 +163,42 @@ export default { ) } - // Save user message await saveMessage(env.DB, session.id, 'user', body.message) await incrementMessageCount(env.DB, session.id) - // Get conversation history const history = await getConversationHistory(env.DB, session.id) + const interviewAnswers = sessionInterviewAnswers.get(session.id) + const response = await callClaude( + env.ANTHROPIC_API_KEY, + history, + body.message, + interviewAnswers, + ) - // Call Claude - const response = await callClaude(env.ANTHROPIC_API_KEY, history, body.message) - - // Save assistant response await saveMessage(env.DB, session.id, 'assistant', response) - // Check if lead is complete let leadExtracted = false + let leadScore: number | undefined + let leadTier: LeadTierValue | undefined + if (isLeadComplete(response)) { try { - // Extract lead data const lead = await extractLeadFromConversation( env.ANTHROPIC_API_KEY, await getConversationHistory(env.DB, session.id), ) - // Generate PRD draft const prdDraft = generatePRDDraft(lead) + const result = await saveLead(env.DB, session.id, lead, prdDraft, interviewAnswers) + leadScore = result.score + leadTier = result.tier as LeadTierValue - // Save lead to database - await saveLead(env.DB, session.id, lead, prdDraft) - - // Send notification email if (env.RESEND_API_KEY && env.NOTIFICATION_EMAIL) { - await notifyTeam(env.RESEND_API_KEY, env.NOTIFICATION_EMAIL, lead, prdDraft) + await notifyTeam(env.RESEND_API_KEY, env.NOTIFICATION_EMAIL, lead, prdDraft, { + interviewAnswers, + leadScore, + leadTier, + }) } leadExtracted = true @@ -122,6 +211,9 @@ export default { message: cleanResponse(response), sessionId: session.id, leadExtracted, + leadScore, + leadTier, + nextPhase: leadExtracted ? 'post_contact' : undefined, } return jsonResponse(chatResponse, 200, origin) diff --git a/workers/chat-api/src/leads.ts b/workers/chat-api/src/leads.ts index 9dead7b..c3598bb 100644 --- a/workers/chat-api/src/leads.ts +++ b/workers/chat-api/src/leads.ts @@ -1,4 +1,5 @@ -import type { Message } from './types' +import { calculateLeadScore, getLeadTier } from './scoring' +import type { InterviewAnswers, Message } from './types' interface ExtractedLead { name: string | null @@ -127,22 +128,42 @@ export async function saveLead( sessionId: string, lead: ExtractedLead, prdDraft: string, -): Promise { + interviewAnswers?: InterviewAnswers, +): Promise<{ score: number; tier: string }> { + const score = interviewAnswers ? calculateLeadScore(interviewAnswers) : 0 + const tier = getLeadTier(score) + await db .prepare( - `INSERT INTO leads (session_id, name, email, company, project_summary, problem, vision, users, capabilities, constraints, prd_draft) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(session_id) DO UPDATE SET - name = excluded.name, - email = excluded.email, - company = excluded.company, - project_summary = excluded.project_summary, - problem = excluded.problem, - vision = excluded.vision, - users = excluded.users, - capabilities = excluded.capabilities, - constraints = excluded.constraints, - prd_draft = excluded.prd_draft`, + `INSERT INTO leads ( + session_id, name, email, company, project_summary, problem, vision, + users, capabilities, constraints, prd_draft, + intent, role, ai_maturity, working_style, timeline, company_size, + industry, budget_range, lead_score, lead_tier, interview_answers + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(session_id) DO UPDATE SET + name = excluded.name, + email = excluded.email, + company = excluded.company, + project_summary = excluded.project_summary, + problem = excluded.problem, + vision = excluded.vision, + users = excluded.users, + capabilities = excluded.capabilities, + constraints = excluded.constraints, + prd_draft = excluded.prd_draft, + intent = excluded.intent, + role = excluded.role, + ai_maturity = excluded.ai_maturity, + working_style = excluded.working_style, + timeline = excluded.timeline, + company_size = excluded.company_size, + industry = excluded.industry, + budget_range = excluded.budget_range, + lead_score = excluded.lead_score, + lead_tier = excluded.lead_tier, + interview_answers = excluded.interview_answers`, ) .bind( sessionId, @@ -156,6 +177,19 @@ export async function saveLead( lead.capabilities, lead.constraints, prdDraft, + interviewAnswers?.intent ?? null, + interviewAnswers?.role ?? null, + interviewAnswers?.ai_maturity ?? null, + interviewAnswers?.working_style ?? null, + interviewAnswers?.timeline ?? null, + interviewAnswers?.company_size ?? null, + interviewAnswers?.industry ?? null, + interviewAnswers?.budget_range ?? null, + score, + tier, + interviewAnswers ? JSON.stringify(interviewAnswers) : null, ) .run() + + return { score, tier } } diff --git a/workers/chat-api/src/scoring.test.ts b/workers/chat-api/src/scoring.test.ts new file mode 100644 index 0000000..ebe6add --- /dev/null +++ b/workers/chat-api/src/scoring.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { calculateLeadScore, getLeadTier } from './scoring' +import type { InterviewAnswers } from './types' + +describe('calculateLeadScore', () => { + it('returns 0 for empty answers', () => { + expect(calculateLeadScore({})).toBe(0) + }) + + it('scores timeline correctly', () => { + expect(calculateLeadScore({ timeline: 'asap' })).toBe(3) + expect(calculateLeadScore({ timeline: 'quarter' })).toBe(2) + expect(calculateLeadScore({ timeline: 'year' })).toBe(1) + expect(calculateLeadScore({ timeline: 'exploring' })).toBe(0) + }) + + it('scores budget correctly', () => { + expect(calculateLeadScore({ budget_range: '500k_plus' })).toBe(3) + expect(calculateLeadScore({ budget_range: '150k_500k' })).toBe(2) + expect(calculateLeadScore({ budget_range: '50k_150k' })).toBe(1) + expect(calculateLeadScore({ budget_range: 'under_50k' })).toBe(0) + expect(calculateLeadScore({ budget_range: 'unsure' })).toBe(0) + }) + + it('scores intent correctly', () => { + expect(calculateLeadScore({ intent: 'specific_project' })).toBe(3) + expect(calculateLeadScore({ intent: 'existing_system' })).toBe(2) + expect(calculateLeadScore({ intent: 'upskill' })).toBe(1) + expect(calculateLeadScore({ intent: 'exploring' })).toBe(0) + }) + + it('scores ai_maturity correctly', () => { + expect(calculateLeadScore({ ai_maturity: 'committed' })).toBe(2) + expect(calculateLeadScore({ ai_maturity: 'going_steady' })).toBe(1) + expect(calculateLeadScore({ ai_maturity: 'first_date' })).toBe(0) + }) + + it('scores company_size correctly', () => { + expect(calculateLeadScore({ company_size: 'enterprise' })).toBe(2) + expect(calculateLeadScore({ company_size: 'midmarket' })).toBe(1) + expect(calculateLeadScore({ company_size: 'growth' })).toBe(0) + expect(calculateLeadScore({ company_size: 'startup' })).toBe(0) + }) + + it('combines all scores correctly', () => { + const hotLead: InterviewAnswers = { + timeline: 'asap', + budget_range: '500k_plus', + intent: 'specific_project', + ai_maturity: 'committed', + company_size: 'enterprise', + } + expect(calculateLeadScore(hotLead)).toBe(13) // 3+3+3+2+2 + }) +}) + +describe('getLeadTier', () => { + it('returns hot for score >= 12', () => { + expect(getLeadTier(12)).toBe('hot') + expect(getLeadTier(13)).toBe('hot') + }) + + it('returns warm for score 8-11', () => { + expect(getLeadTier(8)).toBe('warm') + expect(getLeadTier(11)).toBe('warm') + }) + + it('returns cool for score 4-7', () => { + expect(getLeadTier(4)).toBe('cool') + expect(getLeadTier(7)).toBe('cool') + }) + + it('returns cold for score < 4', () => { + expect(getLeadTier(0)).toBe('cold') + expect(getLeadTier(3)).toBe('cold') + }) +}) diff --git a/workers/chat-api/src/scoring.ts b/workers/chat-api/src/scoring.ts new file mode 100644 index 0000000..0e9c30e --- /dev/null +++ b/workers/chat-api/src/scoring.ts @@ -0,0 +1,37 @@ +import type { InterviewAnswers, LeadTierValue } from './types' + +export function calculateLeadScore(answers: InterviewAnswers): number { + let score = 0 + + // Timeline (max 3) + if (answers.timeline === 'asap') score += 3 + else if (answers.timeline === 'quarter') score += 2 + else if (answers.timeline === 'year') score += 1 + + // Budget (max 3) + if (answers.budget_range === '500k_plus') score += 3 + else if (answers.budget_range === '150k_500k') score += 2 + else if (answers.budget_range === '50k_150k') score += 1 + + // Intent (max 3) + if (answers.intent === 'specific_project') score += 3 + else if (answers.intent === 'existing_system') score += 2 + else if (answers.intent === 'upskill') score += 1 + + // AI Maturity (max 2) + if (answers.ai_maturity === 'committed') score += 2 + else if (answers.ai_maturity === 'going_steady') score += 1 + + // Company Size (max 2) + if (answers.company_size === 'enterprise') score += 2 + else if (answers.company_size === 'midmarket') score += 1 + + return score +} + +export function getLeadTier(score: number): LeadTierValue { + if (score >= 12) return 'hot' + if (score >= 8) return 'warm' + if (score >= 4) return 'cool' + return 'cold' +} diff --git a/workers/chat-api/src/types.ts b/workers/chat-api/src/types.ts index 7c8ae5a..9ef1c1a 100644 --- a/workers/chat-api/src/types.ts +++ b/workers/chat-api/src/types.ts @@ -23,6 +23,36 @@ export interface Message { created_at: string } +// Interview answer value types +export type IntentValue = 'specific_project' | 'exploring' | 'existing_system' | 'upskill' +export type RoleValue = 'technical' | 'business' | 'ai_lead' | 'founder' +export type AiMaturityValue = 'first_date' | 'going_steady' | 'committed' +export type WorkingStyleValue = 'full_ownership' | 'embedded' | 'knowledge_transfer' +export type TimelineValue = 'asap' | 'quarter' | 'year' | 'exploring' +export type CompanySizeValue = 'startup' | 'growth' | 'midmarket' | 'enterprise' +export type IndustryValue = + | 'fintech' + | 'ecommerce' + | 'saas' + | 'professional_services' + | 'healthcare' + | 'other' +export type BudgetRangeValue = 'under_50k' | '50k_150k' | '150k_500k' | '500k_plus' | 'unsure' +export type LeadTierValue = 'hot' | 'warm' | 'cool' | 'cold' + +export interface InterviewAnswers { + intent?: IntentValue + role?: RoleValue + ai_maturity?: AiMaturityValue + working_style?: WorkingStyleValue + timeline?: TimelineValue + company_size?: CompanySizeValue + industry?: IndustryValue + budget_range?: BudgetRangeValue +} + +export type InterviewPhase = 'structured' | 'chat' | 'post_contact' | 'complete' + export interface Lead { id: number session_id: string @@ -36,16 +66,37 @@ export interface Lead { capabilities: string | null constraints: string | null prd_draft: string | null + // Interview fields + intent: IntentValue | null + role: RoleValue | null + ai_maturity: AiMaturityValue | null + working_style: WorkingStyleValue | null + timeline: TimelineValue | null + company_size: CompanySizeValue | null + industry: IndustryValue | null + budget_range: BudgetRangeValue | null + lead_score: number | null + lead_tier: LeadTierValue | null + interview_answers: string | null created_at: string } export interface ChatRequest { - message: string + message?: string sessionId?: string + phase: InterviewPhase + structuredAnswer?: { + questionId: string + answer: string + } + interviewAnswers?: InterviewAnswers } export interface ChatResponse { - message: string + message?: string sessionId: string leadExtracted?: boolean + leadScore?: number + leadTier?: LeadTierValue + nextPhase?: InterviewPhase }