feat: transform contact chat into structured interview experience#24
feat: transform contact chat into structured interview experience#24
Conversation
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
vibes-website | 2328dd3 | Commit Preview URL Branch Preview URL |
Dec 25 2025, 12:20 AM |
There was a problem hiding this comment.
Pull request overview
This PR transforms the contact chat from a freeform conversation into a structured interview experience with 8 visual card-based questions that lead into an AI-powered chat, followed by lead scoring and qualification. The implementation adds a multi-phase flow (structured → chat → post-contact → complete) managed by a state machine, integrates interview context into Claude's system prompt, and calculates lead scores (hot/warm/cool/cold) based on structured answers.
Key Changes:
- Adds 8-question structured interview with visual card selection for intent, role, AI maturity, working style, timeline, company size, industry, and budget
- Implements lead scoring algorithm (0-13 points) with automatic tier classification
- Creates InterviewContainer component orchestrating all phases with progress indicators and response starters
- Updates Claude system prompt to be context-aware using interview answers
- Enhances email notifications with lead score badges and interview profile tables
Reviewed changes
Copilot reviewed 30 out of 30 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| workers/chat-api/src/types.ts | Adds interview answer types, phase enums, and updates ChatRequest/ChatResponse interfaces |
| workers/chat-api/src/scoring.ts | Implements lead scoring algorithm with weighted criteria (timeline, budget, intent, AI maturity, company size) |
| workers/chat-api/src/scoring.test.ts | Comprehensive test coverage for scoring logic including edge cases |
| workers/chat-api/src/leads.ts | Updates saveLead to accept and store interview answers, return score/tier |
| workers/chat-api/src/index.ts | Adds phase-based routing, in-memory session storage for interview answers, and structured answer handling |
| workers/chat-api/src/email.ts | Adds interview profile section and lead score badges to notification emails |
| workers/chat-api/src/claude.ts | Creates context-aware system prompt builder using interview answers |
| workers/chat-api/schema.sql | Adds 11 new columns for interview data and lead scoring |
| workers/chat-api/migrations/0002_interview_fields.sql | Migration to add interview fields to existing leads table |
| vitest.config.ts | Extends test coverage to include workers directory |
| src/features/chat/hooks/useInterview.ts | State machine hook managing interview phases, question progression, and answer collection |
| src/features/chat/hooks/useInterview.test.ts | Tests for interview state transitions through all phases |
| src/features/chat/hooks/useChat.ts | Updates to pass interview answers in API calls |
| src/features/chat/config/questions.ts | Defines 8 interview questions with options, icons, and response starters |
| src/features/chat/config/questions.test.ts | Validates question structure and helper functions |
| src/features/chat/components/AnswerCard.tsx | Visual card component for interview option selection |
| src/features/chat/components/InterviewQuestion.tsx | Displays question with grid of AnswerCard options |
| src/features/chat/components/ProgressIndicator.tsx | Shows interview progress with dots and optional label |
| src/features/chat/components/ResponseStarter.tsx | Clickable suggestion pills to reduce chat friction |
| src/features/chat/components/InterviewContainer.tsx | Orchestrates entire interview flow with phase-based rendering |
| src/features/chat/components/ChatInput.tsx | Updates to support controlled value from parent |
| docs/plans/08-interview-contact-chat/implementation.md | Detailed 20-task implementation plan |
| docs/plans/08-interview-contact-chat/design.md | Design specification with scoring criteria and UI patterns |
| biome.json | Adds .wrangler to ignored paths |
Comments suppressed due to low confidence (2)
src/features/chat/components/InterviewContainer.tsx:1
- The contact detection logic is fragile and relies on simple string matching. The email detection (
includes('@') && includes('.')) will produce false positives (e.g., "john@company.co.uk" is valid but "visit example.com @ 3pm" would also match). Consider using a more robust email regex pattern or relying on the backend'sleadExtractedflag instead of client-side heuristics.
import { cn } from '@/lib/cn'
workers/chat-api/src/claude.ts:1
- The commented-out "Constraints" line appears to be a leftover from the original prompt. Since timeline and budget are now captured in the structured interview, this line should either be removed or updated to clarify that only technical requirements and integrations need to be gathered during the chat phase.
import type { InterviewAnswers, Message } from './types'
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Replace ChatContainer with InterviewContainer on contact page - Update E2E tests to check for interview interface instead of chat - Skip interview navigation test due to hydration timing (same as form toggle)
- Extract setStructuredAnswer helper with validation for type-safe answer storage - Add useMemo to ProgressIndicator for dot config memoization - Replace fragile client-side email detection with backend leadExtracted callback - Add onLeadExtracted callback to useChat hook
4aa3be9 to
fefa2c0
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 33 out of 33 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (2)
workers/chat-api/src/claude.ts:1
- The commented-out Constraints line in the system prompt appears to be an oversight. This field is still being extracted and used in the PRD generation, so either the line should be uncommented or the extraction logic should be updated to reflect that constraints are no longer gathered during chat.
import type { InterviewAnswers, Message } from './types'
src/features/chat/components/AnswerCard.tsx:1
- The role='button' attribute is redundant since the element is already a button element. This adds unnecessary markup and could potentially confuse assistive technologies. Remove the role attribute.
import { cn } from '@/lib/cn'
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const dots = useMemo( | ||
| () => | ||
| Array.from({ length: total }, (_, i) => ({ | ||
| id: `progress-dot-${i}`, | ||
| index: i, | ||
| state: i < current ? 'completed' : i === current ? 'current' : 'upcoming', | ||
| })), | ||
| [total, current], | ||
| ) |
There was a problem hiding this comment.
The array creation in useMemo is unnecessarily complex. Since the dots are simple derived state based on current and total, the memoization adds overhead without clear benefit. The component will re-render when these props change anyway, making the memoization redundant. Consider simplifying by directly mapping in the JSX or creating a simple derived array outside useMemo.
| 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<string, string>)[questionId] = answer | ||
| } | ||
|
|
||
| sessionInterviewAnswers.set(sessionId, answers) | ||
| return answers | ||
| } |
There was a problem hiding this comment.
The setStructuredAnswer function silently ignores invalid question IDs without logging or error handling. When an invalid questionId is provided, the answer is lost without notification, making debugging difficult. Consider logging a warning or throwing an error when an invalid questionId is encountered to aid in debugging and prevent silent data loss.
| import type { ChatRequest, ChatResponse, Env, InterviewAnswers, LeadTierValue } from './types' | ||
|
|
||
| // In-memory store for interview answers per session | ||
| const sessionInterviewAnswers = new Map<string, InterviewAnswers>() |
There was a problem hiding this comment.
Using in-memory storage for interview answers creates a memory leak in a serverless worker environment. The Map will grow unbounded as sessions are created but never cleaned up. Consider either implementing a TTL-based cleanup mechanism, moving this storage to D1, or using Durable Objects if session state persistence is required.
- Simplify ProgressIndicator by removing useMemo overhead (reviewer feedback) - Add warning log when invalid questionId is provided to setStructuredAnswer - Add MAX_CACHED_SESSIONS limit (1000) with LRU eviction to prevent memory leak in long-running isolates
- Add lucide-react library for consistent outline icon styling - Update AnswerCard to render LucideIcon components instead of emoji strings - Replace all emoji icons in interview questions with semantic Lucide icons - Icons now follow theme colors (text-accent when selected, text-muted-foreground default) - Use stroke-[1.5] for clean outline appearance
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 34 out of 35 changed files in this pull request and generated 8 comments.
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Limited to prevent unbounded growth in long-running isolates. | ||
| // When limit is exceeded, oldest sessions are evicted (Map maintains insertion order). |
There was a problem hiding this comment.
The in-memory Map for session storage lacks documentation about eviction behavior and potential data loss on Worker restart. Add a comment explaining that this cache is ephemeral and answers are lost on Worker restarts/migrations, which is acceptable since they're also stored in the database via saveLead.
| // Limited to prevent unbounded growth in long-running isolates. | |
| // When limit is exceeded, oldest sessions are evicted (Map maintains insertion order). | |
| // This is an ephemeral, per-isolate cache only: all entries are lost on Worker | |
| // restart or migration, and there is no cross-request or cross-region durability. | |
| // Limited to prevent unbounded growth in long-running isolates. When the limit | |
| // is exceeded, the oldest sessions are evicted (Map maintains insertion order). | |
| // This potential data loss is acceptable because finalized answers are also | |
| // persisted to the database via `saveLead`, which is the source of truth. |
| // 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) | ||
| } |
There was a problem hiding this comment.
Duplicate eviction logic appears in two places (lines 52-55 and 102-106). Extract this into a helper function to avoid repetition and ensure consistent eviction behavior across both code paths.
| // 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 evictOldInterviewAnswerSessions = () => { | |
| // Evict oldest sessions if limit exceeded | |
| while (sessionInterviewAnswers.size >= MAX_CACHED_SESSIONS) { | |
| const oldestKey = sessionInterviewAnswers.keys().next().value | |
| if (oldestKey) { | |
| sessionInterviewAnswers.delete(oldestKey) | |
| } else { | |
| break | |
| } | |
| } | |
| } | |
| // Store/update interview answers for this session | |
| if (body.interviewAnswers) { | |
| evictOldInterviewAnswerSessions() |
| @@ -0,0 +1,174 @@ | |||
| import type { LucideIcon } from 'lucide-react' | |||
There was a problem hiding this comment.
The questions.ts file imports LucideIcon type but the actual icon components are imported in the same file. Consider separating the type definitions from the icon imports for better code organization and to avoid potential circular dependency issues if this config is used in other type-only contexts.
| 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', | ||
| })) | ||
| } |
There was a problem hiding this comment.
The buildDots function is extracted but only used once. While this improves readability, consider whether the added indirection is necessary for a single-use function. If kept, consider making it a const arrow function for consistency with React conventions.
| 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<string, string>)[questionId] = answer | ||
| } else { | ||
| console.warn(`Invalid questionId "${questionId}" for session ${sessionId} - answer discarded`) | ||
| } |
There was a problem hiding this comment.
The type assertion on line 46 bypasses TypeScript's type safety. Consider using a more type-safe approach such as defining a union type of valid question IDs or using a type guard function to narrow the type instead of using a runtime Set check with a type assertion.
| const getActiveStarters = (): string[] => { | ||
| if (phase !== 'chat' || isLoading) return [] | ||
|
|
||
| // Determine which starters to show based on message count | ||
| const userMessages = messages.filter((m) => m.role === 'user').length | ||
| if (userMessages === 0) return getResponseStarters('problem') | ||
| if (userMessages === 1) return getResponseStarters('vision') | ||
| if (userMessages === 2) return getResponseStarters('users') | ||
| return [] | ||
| } |
There was a problem hiding this comment.
The response starter logic is hardcoded with magic numbers (0, 1, 2) and assumes a specific conversation flow. Consider extracting this into a configuration object or enum that maps conversation stages to starter types for better maintainability and clarity.
| if (answers.intent) | ||
| rows.push( | ||
| `<tr><td style="color:#64748b;padding:4px 8px;">Intent</td><td style="padding:4px 8px;">${INTENT_LABELS[answers.intent] ?? answers.intent}</td></tr>`, | ||
| ) | ||
| if (answers.role) | ||
| rows.push( | ||
| `<tr><td style="color:#64748b;padding:4px 8px;">Role</td><td style="padding:4px 8px;">${ROLE_LABELS[answers.role] ?? answers.role}</td></tr>`, | ||
| ) | ||
| if (answers.ai_maturity) | ||
| rows.push( | ||
| `<tr><td style="color:#64748b;padding:4px 8px;">AI Maturity</td><td style="padding:4px 8px;">${AI_MATURITY_LABELS[answers.ai_maturity] ?? answers.ai_maturity}</td></tr>`, | ||
| ) | ||
| if (answers.working_style) | ||
| rows.push( | ||
| `<tr><td style="color:#64748b;padding:4px 8px;">Working Style</td><td style="padding:4px 8px;">${WORKING_STYLE_LABELS[answers.working_style] ?? answers.working_style}</td></tr>`, | ||
| ) | ||
| if (answers.timeline) | ||
| rows.push( | ||
| `<tr><td style="color:#64748b;padding:4px 8px;">Timeline</td><td style="padding:4px 8px;">${TIMELINE_LABELS[answers.timeline] ?? answers.timeline}</td></tr>`, | ||
| ) | ||
| if (answers.company_size) | ||
| rows.push( | ||
| `<tr><td style="color:#64748b;padding:4px 8px;">Company Size</td><td style="padding:4px 8px;">${COMPANY_SIZE_LABELS[answers.company_size] ?? answers.company_size}</td></tr>`, | ||
| ) | ||
| if (answers.industry) | ||
| rows.push( | ||
| `<tr><td style="color:#64748b;padding:4px 8px;">Industry</td><td style="padding:4px 8px;">${INDUSTRY_LABELS[answers.industry] ?? answers.industry}</td></tr>`, | ||
| ) | ||
| if (answers.budget_range) | ||
| rows.push( | ||
| `<tr><td style="color:#64748b;padding:4px 8px;">Budget</td><td style="padding:4px 8px;">${BUDGET_LABELS[answers.budget_range] ?? answers.budget_range}</td></tr>`, | ||
| ) | ||
|
|
There was a problem hiding this comment.
The email template uses inline HTML generation with repetitive style attributes. Consider extracting the table row generation into a helper function that takes a label and value to reduce duplication across all 8 interview fields.
| if (answers.intent) | |
| rows.push( | |
| `<tr><td style="color:#64748b;padding:4px 8px;">Intent</td><td style="padding:4px 8px;">${INTENT_LABELS[answers.intent] ?? answers.intent}</td></tr>`, | |
| ) | |
| if (answers.role) | |
| rows.push( | |
| `<tr><td style="color:#64748b;padding:4px 8px;">Role</td><td style="padding:4px 8px;">${ROLE_LABELS[answers.role] ?? answers.role}</td></tr>`, | |
| ) | |
| if (answers.ai_maturity) | |
| rows.push( | |
| `<tr><td style="color:#64748b;padding:4px 8px;">AI Maturity</td><td style="padding:4px 8px;">${AI_MATURITY_LABELS[answers.ai_maturity] ?? answers.ai_maturity}</td></tr>`, | |
| ) | |
| if (answers.working_style) | |
| rows.push( | |
| `<tr><td style="color:#64748b;padding:4px 8px;">Working Style</td><td style="padding:4px 8px;">${WORKING_STYLE_LABELS[answers.working_style] ?? answers.working_style}</td></tr>`, | |
| ) | |
| if (answers.timeline) | |
| rows.push( | |
| `<tr><td style="color:#64748b;padding:4px 8px;">Timeline</td><td style="padding:4px 8px;">${TIMELINE_LABELS[answers.timeline] ?? answers.timeline}</td></tr>`, | |
| ) | |
| if (answers.company_size) | |
| rows.push( | |
| `<tr><td style="color:#64748b;padding:4px 8px;">Company Size</td><td style="padding:4px 8px;">${COMPANY_SIZE_LABELS[answers.company_size] ?? answers.company_size}</td></tr>`, | |
| ) | |
| if (answers.industry) | |
| rows.push( | |
| `<tr><td style="color:#64748b;padding:4px 8px;">Industry</td><td style="padding:4px 8px;">${INDUSTRY_LABELS[answers.industry] ?? answers.industry}</td></tr>`, | |
| ) | |
| if (answers.budget_range) | |
| rows.push( | |
| `<tr><td style="color:#64748b;padding:4px 8px;">Budget</td><td style="padding:4px 8px;">${BUDGET_LABELS[answers.budget_range] ?? answers.budget_range}</td></tr>`, | |
| ) | |
| const createRow = (label: string, value: string): string => | |
| `<tr><td style="color:#64748b;padding:4px 8px;">${label}</td><td style="padding:4px 8px;">${value}</td></tr>` | |
| if (answers.intent) { | |
| const value = INTENT_LABELS[answers.intent] ?? answers.intent | |
| rows.push(createRow('Intent', value)) | |
| } | |
| if (answers.role) { | |
| const value = ROLE_LABELS[answers.role] ?? answers.role | |
| rows.push(createRow('Role', value)) | |
| } | |
| if (answers.ai_maturity) { | |
| const value = AI_MATURITY_LABELS[answers.ai_maturity] ?? answers.ai_maturity | |
| rows.push(createRow('AI Maturity', value)) | |
| } | |
| if (answers.working_style) { | |
| const value = WORKING_STYLE_LABELS[answers.working_style] ?? answers.working_style | |
| rows.push(createRow('Working Style', value)) | |
| } | |
| if (answers.timeline) { | |
| const value = TIMELINE_LABELS[answers.timeline] ?? answers.timeline | |
| rows.push(createRow('Timeline', value)) | |
| } | |
| if (answers.company_size) { | |
| const value = COMPANY_SIZE_LABELS[answers.company_size] ?? answers.company_size | |
| rows.push(createRow('Company Size', value)) | |
| } | |
| if (answers.industry) { | |
| const value = INDUSTRY_LABELS[answers.industry] ?? answers.industry | |
| rows.push(createRow('Industry', value)) | |
| } | |
| if (answers.budget_range) { | |
| const value = BUDGET_LABELS[answers.budget_range] ?? answers.budget_range | |
| rows.push(createRow('Budget', value)) | |
| } |
| import { cn } from '@/lib/cn' | ||
| import type { LucideIcon } from 'lucide-react' | ||
| import type { ComponentProps } from 'react' | ||
|
|
||
| interface AnswerCardProps extends Omit<ComponentProps<'button'>, 'onSelect'> { | ||
| icon: LucideIcon |
There was a problem hiding this comment.
The AnswerCard component imports LucideIcon type but receives icon components directly. The icon prop type should be LucideIcon (the component type) not string, which matches the actual usage where Icon components are passed and rendered on line 41.
Switch interview UI from visual icon cards to a conversational text-based format with letter prefixes (A, B, C, etc.): - Remove lucide-react dependency - Simplify AnswerCard to horizontal text layout with letter badge - Update InterviewQuestion to vertical list layout - Remove icon field from question options
- Add message tracking to useInterview hook (questions as assistant messages, answers as user messages) - Create SuggestionChips component for quick-tap answer options - Redesign InterviewContainer as unified chat view - Add placeholder prop to ChatInput for phase-specific prompts - Combine interview and AI chat messages in single thread - Allow typed custom answers in addition to suggestion chips
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 35 out of 35 changed files in this pull request and generated 6 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| export interface InterviewQuestion { | ||
| id: string | ||
| question: string | ||
| subtitle?: string | ||
| options: QuestionOption[] | ||
| phase: 'opener' | 'personality' | 'qualification' | 'post_contact' | ||
| } |
There was a problem hiding this comment.
The QuestionOption interface includes an 'icon' field (line 3) but AnswerCard.tsx uses a 'prefix' prop with letter values (lines 5-6). The questions.ts file defines icon as string (e.g., '🎯'), while AnswerCard expects a letter prefix like 'A', 'B'. This inconsistency suggests either the icon field is unused or there's a mismatch in the component contract.
| function getLetterPrefix(index: number): string { | ||
| return String.fromCharCode(65 + index) | ||
| } |
There was a problem hiding this comment.
This function converts index to letter (0 → 'A', 1 → 'B') but questions.ts defines options with icon emojis. The component generates letter prefixes instead of using the icon field from the question options, creating an inconsistency between the data structure and its usage.
| const MAX_CACHED_SESSIONS = 1000 | ||
| const sessionInterviewAnswers = new Map<string, InterviewAnswers>() |
There was a problem hiding this comment.
The in-memory Map will lose all interview answers when the worker restarts or is redeployed. Since structured answers are critical for lead scoring, consider persisting them to D1 database or using Durable Objects for stateful storage across worker restarts.
| while (sessionInterviewAnswers.size >= MAX_CACHED_SESSIONS) { | ||
| const oldestKey = sessionInterviewAnswers.keys().next().value | ||
| if (oldestKey) sessionInterviewAnswers.delete(oldestKey) | ||
| } |
There was a problem hiding this comment.
This eviction logic is duplicated in lines 103-106. Extract this into a helper function like evictOldestSession() to reduce duplication and ensure consistent behavior.
| interface AnswerCardProps extends Omit<ComponentProps<'button'>, 'onSelect'> { | ||
| /** Letter prefix like "A", "B", "C" */ | ||
| prefix: string | ||
| label: string | ||
| value: string | ||
| selected?: boolean | ||
| onSelect: (value: string) => void | ||
| } |
There was a problem hiding this comment.
The component expects a 'prefix' prop (letter like 'A', 'B') but questions.ts defines options with an 'icon' field (emojis). This creates confusion about whether the component should display letters or icons. Consider renaming to 'icon' and updating the component to match the data structure, or update questions.ts to remove the icon field if it's unused.
| 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(), | ||
| }, | ||
| ] | ||
| } |
There was a problem hiding this comment.
The welcome message hardcodes 'a few quick questions' but the actual count is 7. Consider making this message template-driven or referencing the actual count from structuredQuestions.length for maintainability if question count changes.
- Remove progress indicator check (no longer shown in chat UI) - Update to check for welcome message and suggestion chips - Update skipped navigation test to match new flow
Summary
Architecture
Test Plan
pnpm test)pnpm typecheck)pnpm lint)pnpm build)